初始提交:添加热门视频分析项目代码
771
base_line/prompt_manager.py
Normal file
@ -0,0 +1,771 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Manages the construction of prompts for different AI generation tasks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import traceback
|
||||
import logging # Add logging
|
||||
import re # 添加正则表达式支持
|
||||
import random # 添加随机模块支持
|
||||
from .resource_loader import ResourceLoader # Use relative import within the same package
|
||||
import json
|
||||
|
||||
class PromptManager:
|
||||
"""Handles the loading and construction of prompts."""
|
||||
|
||||
def __init__(self,
|
||||
topic_system_prompt_path: str,
|
||||
topic_user_prompt_path: str,
|
||||
content_system_prompt_path: str,
|
||||
prompts_dir: str = None, # 兼容旧配置
|
||||
prompts_config: list = None, # 新的配置方式
|
||||
resource_dir_config: list = None,
|
||||
topic_gen_num: int = 1, # Default values if needed
|
||||
topic_gen_date: str = "",
|
||||
content_judger_system_prompt_path: str = None # 添加内容审核系统提示词路径参数
|
||||
):
|
||||
self.topic_system_prompt_path = topic_system_prompt_path
|
||||
self.topic_user_prompt_path = topic_user_prompt_path
|
||||
self.content_system_prompt_path = content_system_prompt_path
|
||||
self.content_judger_system_prompt_path = content_judger_system_prompt_path # 添加成员变量
|
||||
self.prompts_dir = prompts_dir # 保留兼容旧配置
|
||||
self.prompts_config = prompts_config or [] # 新的配置方式
|
||||
self.resource_dir_config = resource_dir_config or []
|
||||
self.topic_gen_num = topic_gen_num
|
||||
self.topic_gen_date = topic_gen_date
|
||||
|
||||
# 缓存加载的文件内容
|
||||
self._style_cache = {}
|
||||
self._demand_cache = {}
|
||||
self._refer_cache = {}
|
||||
self._system_prompt_cache = {} # 新增:系统提示词缓存
|
||||
self._user_prompt_cache = {} # 新增:用户提示词缓存
|
||||
self._dateline_cache = None # 新增:日期线缓存
|
||||
|
||||
self._sample_rate = 0.1 # 提高随机抽样率
|
||||
# 初始化时预加载配置的文件
|
||||
self._preload_prompt_files()
|
||||
|
||||
def _preload_prompt_files(self):
|
||||
"""预加载配置中的提示文件到缓存"""
|
||||
# 预加载系统提示词和用户提示词文件
|
||||
if self.topic_system_prompt_path and os.path.exists(self.topic_system_prompt_path):
|
||||
content = ResourceLoader.load_file_content(self.topic_system_prompt_path)
|
||||
if content:
|
||||
self._system_prompt_cache["topic"] = content
|
||||
logging.info(f"预加载系统提示词: {self.topic_system_prompt_path}")
|
||||
|
||||
if self.topic_user_prompt_path and os.path.exists(self.topic_user_prompt_path):
|
||||
content = ResourceLoader.load_file_content(self.topic_user_prompt_path)
|
||||
if content:
|
||||
self._user_prompt_cache["topic"] = content
|
||||
logging.info(f"预加载用户提示词: {self.topic_user_prompt_path}")
|
||||
|
||||
if self.content_system_prompt_path and os.path.exists(self.content_system_prompt_path):
|
||||
content = ResourceLoader.load_file_content(self.content_system_prompt_path)
|
||||
if content:
|
||||
self._system_prompt_cache["content"] = content
|
||||
logging.info(f"预加载内容系统提示词: {self.content_system_prompt_path}")
|
||||
|
||||
# 预加载内容审核系统提示词
|
||||
if self.content_judger_system_prompt_path and os.path.exists(self.content_judger_system_prompt_path):
|
||||
content = ResourceLoader.load_file_content(self.content_judger_system_prompt_path)
|
||||
if content:
|
||||
self._system_prompt_cache["judger_system_prompt"] = content
|
||||
logging.info(f"预加载内容审核系统提示词: {self.content_judger_system_prompt_path}")
|
||||
|
||||
# 预加载日期线文件
|
||||
if self.topic_user_prompt_path:
|
||||
user_prompt_dir = os.path.dirname(self.topic_user_prompt_path)
|
||||
dateline_path = os.path.join(user_prompt_dir, "2025各月节日宣传节点时间表.md")
|
||||
if os.path.exists(dateline_path):
|
||||
self._dateline_cache = ResourceLoader.load_file_content(dateline_path)
|
||||
logging.info(f"预加载日期线文件: {dateline_path}")
|
||||
|
||||
|
||||
# 加载prompts_config配置的文件
|
||||
if not self.prompts_config:
|
||||
return
|
||||
|
||||
for config_item in self.prompts_config:
|
||||
prompt_type = config_item.get("type", "").lower()
|
||||
file_paths = config_item.get("file_path", [])
|
||||
|
||||
if prompt_type == "style":
|
||||
for path in file_paths:
|
||||
if os.path.exists(path):
|
||||
filename = os.path.basename(path)
|
||||
content = ResourceLoader.load_file_content(path)
|
||||
if content:
|
||||
self._style_cache[filename] = content
|
||||
name_without_ext = os.path.splitext(filename)[0]
|
||||
self._style_cache[name_without_ext] = content # 同时缓存不带扩展名的版本
|
||||
|
||||
elif prompt_type == "demand":
|
||||
for path in file_paths:
|
||||
# print(path)
|
||||
if os.path.exists(path):
|
||||
filename = os.path.basename(path)
|
||||
# print(filename)
|
||||
content = ResourceLoader.load_file_content(path)
|
||||
# print(content)
|
||||
if content:
|
||||
self._demand_cache[filename] = content
|
||||
name_without_ext = os.path.splitext(filename)[0]
|
||||
self._demand_cache[name_without_ext] = content # 同时缓存不带扩展名的版本
|
||||
|
||||
elif prompt_type == "refer":
|
||||
for path in file_paths:
|
||||
# print(path)
|
||||
if os.path.exists(path):
|
||||
filename = os.path.basename(path)
|
||||
# print(filename)
|
||||
# 检测是否为JSON文件
|
||||
if filename.lower().endswith('.json'):
|
||||
# 直接加载和解析JSON文件
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
# 移除可能的BOM头
|
||||
if content.startswith('\ufeff'):
|
||||
content = content[1:]
|
||||
# 解析JSON
|
||||
json_data = json.loads(content)
|
||||
# 存储解析后的对象
|
||||
self._refer_cache[filename] = json_data
|
||||
logging.info(f"预加载并解析JSON参考文件: {filename}")
|
||||
except Exception as e:
|
||||
logging.error(f"预加载JSON文件{filename}失败: {str(e)}")
|
||||
# 失败时尝试以普通文本加载
|
||||
content = ResourceLoader.load_file_content(path)
|
||||
if content:
|
||||
self._refer_cache[filename] = content
|
||||
else:
|
||||
# 非JSON文件使用普通加载方式
|
||||
content = ResourceLoader.load_all_refer_files(path, 1)
|
||||
if content:
|
||||
self._refer_cache[filename] = content
|
||||
logging.info(f"预加载普通参考文件: {filename}")
|
||||
|
||||
def find_directory_fuzzy_match(self, name, directory=None, files=None):
|
||||
"""
|
||||
对文件名进行模糊匹配,查找最匹配目标名称的文件
|
||||
|
||||
Args:
|
||||
name: 目标名称
|
||||
directory: 目录路径,如果提供则从目录中读取文件列表
|
||||
files: 文件名列表,如果提供则直接使用
|
||||
|
||||
Returns:
|
||||
tuple: (最佳匹配文件名, 匹配分数) 如果没有匹配则返回 (None, 0)
|
||||
"""
|
||||
logging.info(f"尝试对文件名进行模糊匹配: {name}")
|
||||
|
||||
try:
|
||||
# 准备文件列表
|
||||
all_files = []
|
||||
if files:
|
||||
all_files = files
|
||||
elif directory and os.path.isdir(directory):
|
||||
all_files = [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]
|
||||
|
||||
if not all_files:
|
||||
logging.warning(f"没有可用于匹配的文件")
|
||||
return None, 0
|
||||
|
||||
logging.info(f"找到 {len(all_files)} 个文件可用于模糊匹配")
|
||||
|
||||
# 从名称中提取关键词
|
||||
# 通过常见分隔符分割(+、空格、_、-等)
|
||||
parts = re.split(r'[+\s_\-]', name)
|
||||
keywords = []
|
||||
for part in parts:
|
||||
# 只保留长度大于1的有意义关键词
|
||||
if len(part) > 1:
|
||||
keywords.append(part)
|
||||
|
||||
# 尝试匹配更短的语义单元(例如中文的2-3个字的词语)
|
||||
for i in range(len(name) - 1):
|
||||
keyword = name[i:i+2] # 提取2个字符
|
||||
if len(keyword) == 2 and all('\u4e00' <= c <= '\u9fff' for c in keyword):
|
||||
keywords.append(keyword)
|
||||
|
||||
logging.info(f"用于文件模糊匹配的关键词: {keywords}")
|
||||
|
||||
# 对每个文件进行评分
|
||||
file_scores = {}
|
||||
for filename in all_files:
|
||||
score = 0
|
||||
file_lower = filename.lower()
|
||||
|
||||
# 精确匹配,去掉扩展名比较
|
||||
name_without_ext = os.path.splitext(name)[0].lower()
|
||||
file_without_ext = os.path.splitext(filename)[0].lower()
|
||||
|
||||
if name_without_ext == file_without_ext:
|
||||
# 精确匹配给高分
|
||||
score += 10
|
||||
else:
|
||||
# 为每个匹配的关键词增加分数
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in file_lower:
|
||||
score += 1
|
||||
|
||||
# 如果得分大于0(至少有匹配),记录该文件
|
||||
if score > 0:
|
||||
file_scores[filename] = score
|
||||
|
||||
# 选择得分最高的文件
|
||||
if file_scores:
|
||||
best_match = max(file_scores.items(), key=lambda x: x[1])
|
||||
found_file = best_match[0]
|
||||
score = best_match[1]
|
||||
logging.info(f"模糊匹配成功!匹配文件: {found_file},匹配分数: {score}")
|
||||
return found_file, score
|
||||
else:
|
||||
logging.warning(f"模糊匹配未找到任何包含关键词的文件")
|
||||
return None, 0
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"文件模糊匹配过程中出错: {e}")
|
||||
return None, 0
|
||||
|
||||
def _get_style_content(self, style_name):
|
||||
"""获取Style文件内容,优先从缓存获取,如果不存在则尝试从目录加载"""
|
||||
# 首先检查缓存
|
||||
if style_name in self._style_cache:
|
||||
return self._style_cache[style_name]
|
||||
|
||||
# 确保有扩展名
|
||||
if not style_name.lower().endswith('.txt'):
|
||||
style_file = f"{style_name}.txt"
|
||||
else:
|
||||
style_file = style_name
|
||||
style_name = os.path.splitext(style_name)[0] # 移除扩展名
|
||||
|
||||
# 尝试模糊匹配缓存中的文件名
|
||||
cache_files = list(self._style_cache.keys())
|
||||
matched_key, score = self.find_directory_fuzzy_match(style_name, files=cache_files)
|
||||
|
||||
if matched_key and score > 0:
|
||||
return self._style_cache[matched_key]
|
||||
|
||||
# 如果没有在缓存中找到模糊匹配,尝试从prompts_dir加载
|
||||
if self.prompts_dir:
|
||||
style_dir = os.path.join(self.prompts_dir, "Style")
|
||||
style_path = os.path.join(style_dir, style_file)
|
||||
|
||||
# 精确路径匹配
|
||||
if os.path.exists(style_path):
|
||||
content = ResourceLoader.load_file_content(style_path)
|
||||
if content:
|
||||
# 保存到缓存
|
||||
self._style_cache[style_name] = content
|
||||
self._style_cache[style_file] = content
|
||||
return content
|
||||
|
||||
# 如果精确匹配失败,尝试目录中的模糊匹配
|
||||
if os.path.isdir(style_dir):
|
||||
matched_file, score = self.find_directory_fuzzy_match(style_name, directory=style_dir)
|
||||
if matched_file and score > 0:
|
||||
matched_path = os.path.join(style_dir, matched_file)
|
||||
content = ResourceLoader.load_file_content(matched_path)
|
||||
if content:
|
||||
# 保存到缓存
|
||||
self._style_cache[style_name] = content
|
||||
self._style_cache[matched_file] = content
|
||||
file_without_ext = os.path.splitext(matched_file)[0]
|
||||
self._style_cache[file_without_ext] = content # 同时缓存不带扩展名的版本
|
||||
return content
|
||||
|
||||
logging.warning(f"未能找到Style文件: '{style_name}',尝试过以下位置: 缓存, {self.prompts_dir}/Style/")
|
||||
return None
|
||||
|
||||
def _get_demand_content(self, demand_name):
|
||||
"""获取Demand文件内容,优先从缓存获取,如果不存在则尝试从目录加载"""
|
||||
# 首先检查缓存
|
||||
if demand_name in self._demand_cache:
|
||||
return self._demand_cache[demand_name]
|
||||
|
||||
# 确保有扩展名
|
||||
if not demand_name.lower().endswith('.txt'):
|
||||
demand_file = f"{demand_name}.txt"
|
||||
else:
|
||||
demand_file = demand_name
|
||||
demand_name = os.path.splitext(demand_name)[0] # 移除扩展名
|
||||
|
||||
# 尝试模糊匹配缓存中的文件名
|
||||
cache_files = list(self._demand_cache.keys())
|
||||
matched_key, score = self.find_directory_fuzzy_match(demand_name, files=cache_files)
|
||||
|
||||
if matched_key and score > 0:
|
||||
return self._demand_cache[matched_key]
|
||||
|
||||
# 如果没有在缓存中找到模糊匹配,尝试从prompts_dir加载(向后兼容)
|
||||
if self.prompts_dir:
|
||||
demand_dir = os.path.join(self.prompts_dir, "Demand")
|
||||
demand_path = os.path.join(demand_dir, demand_file)
|
||||
|
||||
# 精确路径匹配
|
||||
if os.path.exists(demand_path):
|
||||
content = ResourceLoader.load_file_content(demand_path)
|
||||
if content:
|
||||
# 保存到缓存
|
||||
self._demand_cache[demand_name] = content
|
||||
self._demand_cache[demand_file] = content
|
||||
return content
|
||||
|
||||
# 如果精确匹配失败,尝试目录中的模糊匹配
|
||||
if os.path.isdir(demand_dir):
|
||||
matched_file, score = self.find_directory_fuzzy_match(demand_name, directory=demand_dir)
|
||||
if matched_file and score > 0:
|
||||
matched_path = os.path.join(demand_dir, matched_file)
|
||||
content = ResourceLoader.load_file_content(matched_path)
|
||||
if content:
|
||||
# 保存到缓存
|
||||
self._demand_cache[demand_name] = content
|
||||
self._demand_cache[matched_file] = content
|
||||
file_without_ext = os.path.splitext(matched_file)[0]
|
||||
self._demand_cache[file_without_ext] = content # 同时缓存不带扩展名的版本
|
||||
return content
|
||||
|
||||
# 如果所有尝试都失败
|
||||
logging.warning(f"未能找到Demand文件: '{demand_name}',尝试过以下位置: 缓存, {self.prompts_dir}/Demand/")
|
||||
return None
|
||||
|
||||
def _get_refer_content(self, refer_dir):
|
||||
"""从Refer目录加载JSON和文本参考文件
|
||||
|
||||
Args:
|
||||
refer_dir: Refer目录路径
|
||||
|
||||
Returns:
|
||||
dict: 文件名到内容的映射
|
||||
"""
|
||||
results = {}
|
||||
if not os.path.isdir(refer_dir):
|
||||
return results
|
||||
|
||||
# 列出所有文件
|
||||
refer_files = [f for f in os.listdir(refer_dir) if os.path.isfile(os.path.join(refer_dir, f))]
|
||||
|
||||
for refer_file in refer_files:
|
||||
refer_path = os.path.join(refer_dir, refer_file)
|
||||
content = None
|
||||
|
||||
# 对JSON文件特殊处理
|
||||
if refer_file.lower().endswith('.json'):
|
||||
try:
|
||||
with open(refer_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
# 移除可能的BOM头
|
||||
if content.startswith('\ufeff'):
|
||||
content = content[1:]
|
||||
|
||||
# 验证是否为有效的JSON
|
||||
try:
|
||||
json.loads(content) # 只验证,不存储结果
|
||||
logging.info(f"成功验证JSON文件: {refer_file}")
|
||||
except json.JSONDecodeError as e:
|
||||
logging.warning(f"文件{refer_file}内容不是有效的JSON: {str(e)}")
|
||||
except Exception as e:
|
||||
logging.error(f"读取JSON文件{refer_file}时出错: {str(e)}")
|
||||
content = None
|
||||
else:
|
||||
# 使用ResourceLoader加载非JSON文件
|
||||
content = ResourceLoader.load_file_content(refer_path)
|
||||
|
||||
if content:
|
||||
results[refer_file] = content
|
||||
|
||||
return results
|
||||
|
||||
def _load_and_parse_json(self, file_path):
|
||||
"""安全地加载和解析JSON文件
|
||||
|
||||
Args:
|
||||
file_path: JSON文件路径
|
||||
|
||||
Returns:
|
||||
dict or None: 解析后的JSON对象,解析失败则返回None
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
# 移除可能的BOM头
|
||||
if content.startswith('\ufeff'):
|
||||
content = content[1:]
|
||||
return json.loads(content)
|
||||
except Exception as e:
|
||||
logging.error(f"加载和解析JSON文件{file_path}时出错: {str(e)}")
|
||||
return None
|
||||
|
||||
def _get_all_refer_contents(self, random_sample=True):
|
||||
"""获取所有Refer文件内容,可选择随机抽样文件内容
|
||||
|
||||
Args:
|
||||
random_sample: 是否对文件内容进行随机抽样,默认为True
|
||||
|
||||
Returns:
|
||||
str: 组合后的refer内容
|
||||
"""
|
||||
import json
|
||||
|
||||
# 初始化结果字符串
|
||||
refer_content_all = ""
|
||||
|
||||
# 如果缓存为空,尝试加载文件
|
||||
if not self._refer_cache and self.prompts_dir:
|
||||
refer_dir = os.path.join(self.prompts_dir, "Refer")
|
||||
if os.path.isdir(refer_dir):
|
||||
refer_files = [f for f in os.listdir(refer_dir) if os.path.isfile(os.path.join(refer_dir, f))]
|
||||
for refer_file in refer_files:
|
||||
file_path = os.path.join(refer_dir, refer_file)
|
||||
|
||||
# 对JSON文件特殊处理
|
||||
if refer_file.lower().endswith('.json'):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
# 移除可能的BOM头
|
||||
if content.startswith('\ufeff'):
|
||||
content = content[1:]
|
||||
# 解析并存储JSON对象
|
||||
json_data = json.loads(content)
|
||||
self._refer_cache[refer_file] = json_data
|
||||
logging.info(f"加载并解析JSON参考文件: {refer_file}")
|
||||
except Exception as e:
|
||||
logging.error(f"加载JSON文件{refer_file}失败: {str(e)}")
|
||||
# 失败时尝试以普通文本加载
|
||||
content = ResourceLoader.load_file_content(file_path)
|
||||
if content:
|
||||
self._refer_cache[refer_file] = content
|
||||
else:
|
||||
# 非JSON文件使用普通加载方式
|
||||
content = ResourceLoader.load_file_content(file_path)
|
||||
if content:
|
||||
self._refer_cache[refer_file] = content
|
||||
|
||||
if not self._refer_cache:
|
||||
logging.warning("没有找到任何Refer文件")
|
||||
return refer_content_all
|
||||
|
||||
logging.info(f"找到{len(self._refer_cache)}个Refer文件")
|
||||
|
||||
# 处理所有文件
|
||||
for filename, content in self._refer_cache.items():
|
||||
# 添加文件头部信息
|
||||
refer_content_all += f"--- Refer File: {filename} ---\n"
|
||||
|
||||
# 检查内容类型
|
||||
if isinstance(content, dict) and 'title' in content and 'examples' in content:
|
||||
# 已解析的JSON对象
|
||||
title = content.get("title", "未命名参考资料")
|
||||
description = content.get("description", "")
|
||||
examples = content.get("examples", [])
|
||||
|
||||
refer_content_all += f"标题: {title}\n"
|
||||
refer_content_all += f"描述: {description}\n\n"
|
||||
|
||||
if examples:
|
||||
# 处理examples数组
|
||||
if random_sample and len(examples) > 10:
|
||||
# 对examples进行随机抽样
|
||||
sample_size = max(10, int(len(examples) * self._sample_rate))
|
||||
sampled_examples = random.sample(examples, sample_size)
|
||||
logging.info(f"从文件{filename}的JSON中随机抽样了{sample_size}/{len(examples)}个示例")
|
||||
|
||||
refer_content_all += "示例:\n"
|
||||
for idx, example in enumerate(sampled_examples, 1):
|
||||
content_text = example.get("content", "")
|
||||
refer_content_all += f"{idx}. {content_text}\n"
|
||||
else:
|
||||
# 不进行抽样或examples数量较少,使用全部
|
||||
refer_content_all += "示例:\n"
|
||||
for idx, example in enumerate(examples, 1):
|
||||
content_text = example.get("content", "")
|
||||
refer_content_all += f"{idx}. {content_text}\n"
|
||||
elif isinstance(content, str):
|
||||
# 文本内容
|
||||
if random_sample:
|
||||
lines = content.split('\n')
|
||||
if len(lines) > 10: # 只对较长的内容进行抽样
|
||||
sample_size = max(10, int(len(lines) * self._sample_rate))
|
||||
sampled_lines = random.sample(lines, sample_size)
|
||||
# 保持原有顺序
|
||||
sampled_lines.sort(key=lambda line: lines.index(line))
|
||||
sampled_content = '\n'.join(sampled_lines)
|
||||
logging.info(f"从文件{filename}中随机抽样了{sample_size}/{len(lines)}行内容")
|
||||
refer_content_all += f"{sampled_content}\n"
|
||||
else:
|
||||
# 内容较短,不进行抽样
|
||||
refer_content_all += f"{content}\n"
|
||||
else:
|
||||
# 不进行抽样
|
||||
refer_content_all += f"{content}\n"
|
||||
else:
|
||||
# 内容是其他类型
|
||||
refer_content_all += f"未知内容类型: {type(content)}\n"
|
||||
|
||||
# 添加文件之间的分隔
|
||||
refer_content_all += "\n"
|
||||
|
||||
return refer_content_all
|
||||
|
||||
def get_topic_prompts(self):
|
||||
"""Constructs the system and user prompts for topic generation."""
|
||||
logging.info("Constructing prompts for topic generation...")
|
||||
try:
|
||||
# --- System Prompt ---
|
||||
system_prompt = self._system_prompt_cache.get("topic")
|
||||
if not system_prompt:
|
||||
if not self.topic_system_prompt_path:
|
||||
logging.error("Topic system prompt path not provided during PromptManager initialization.")
|
||||
return None, None
|
||||
system_prompt = ResourceLoader.load_file_content(self.topic_system_prompt_path)
|
||||
if system_prompt:
|
||||
self._system_prompt_cache["topic"] = system_prompt
|
||||
else:
|
||||
logging.error(f"Failed to load topic system prompt from '{self.topic_system_prompt_path}'.")
|
||||
return None, None
|
||||
|
||||
# --- User Prompt ---
|
||||
base_user_prompt = self._user_prompt_cache.get("topic")
|
||||
if not base_user_prompt:
|
||||
if not self.topic_user_prompt_path:
|
||||
logging.error("Topic user prompt path not provided during PromptManager initialization.")
|
||||
return None, None
|
||||
base_user_prompt = ResourceLoader.load_file_content(self.topic_user_prompt_path)
|
||||
if base_user_prompt:
|
||||
self._user_prompt_cache["topic"] = base_user_prompt
|
||||
else:
|
||||
logging.error(f"Failed to load base topic user prompt from '{self.topic_user_prompt_path}'.")
|
||||
return None, None
|
||||
|
||||
# --- Build the dynamic part of the user prompt ---
|
||||
user_prompt_dynamic = "你拥有的创作资料如下:\n"
|
||||
|
||||
# 添加prompts_config配置的文件信息
|
||||
if self.prompts_config:
|
||||
for config_item in self.prompts_config:
|
||||
prompt_type = config_item.get("type", "").lower()
|
||||
file_paths = config_item.get("file_path", [])
|
||||
|
||||
if file_paths:
|
||||
user_prompt_dynamic += f"{prompt_type.capitalize()}文件列表:\n"
|
||||
for path in file_paths:
|
||||
filename = os.path.basename(path)
|
||||
user_prompt_dynamic += f"- {filename}\n"
|
||||
user_prompt_dynamic += "\n"
|
||||
|
||||
# 兼容旧配置:Add genPrompts directory structure
|
||||
elif self.prompts_dir and os.path.isdir(self.prompts_dir):
|
||||
try:
|
||||
gen_prompts_list = os.listdir(self.prompts_dir)
|
||||
for gen_prompt_folder in gen_prompts_list:
|
||||
folder_path = os.path.join(self.prompts_dir, gen_prompt_folder)
|
||||
if os.path.isdir(folder_path):
|
||||
try:
|
||||
# List files, filter out subdirs if needed
|
||||
gen_prompts_files = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))]
|
||||
user_prompt_dynamic += f"{gen_prompt_folder}\n{gen_prompts_files}\n"
|
||||
except OSError as e:
|
||||
logging.warning(f"Could not list directory {folder_path}: {e}")
|
||||
except OSError as e:
|
||||
logging.warning(f"Could not list base prompts directory {self.prompts_dir}: {e}")
|
||||
else:
|
||||
logging.warning(f"Neither prompts_config nor prompts_dir provided or valid.")
|
||||
|
||||
# Add resource directory contents
|
||||
for dir_info in self.resource_dir_config:
|
||||
source_type = dir_info.get("type", "UnknownType")
|
||||
source_file_paths = dir_info.get("file_path", [])
|
||||
for file_path in source_file_paths:
|
||||
# Use ResourceLoader's static method
|
||||
file_content = ResourceLoader.load_file_content(file_path)
|
||||
if file_content:
|
||||
user_prompt_dynamic += f"{source_type}信息:\n{os.path.basename(file_path)}\n{file_content}\n\n"
|
||||
else:
|
||||
logging.warning(f"Could not load resource file {file_path}")
|
||||
|
||||
# Add dateline information (optional)
|
||||
if self._dateline_cache:
|
||||
user_prompt_dynamic += f"\n{self._dateline_cache}"
|
||||
else:
|
||||
user_prompt_dir = os.path.dirname(self.topic_user_prompt_path)
|
||||
dateline_path = os.path.join(user_prompt_dir, "2025各月节日宣传节点时间表.md") # Consider making this configurable
|
||||
if os.path.exists(dateline_path):
|
||||
dateline_content = ResourceLoader.load_file_content(dateline_path)
|
||||
if dateline_content:
|
||||
self._dateline_cache = dateline_content
|
||||
user_prompt_dynamic += f"\n{dateline_content}"
|
||||
|
||||
# Combine dynamic part, base template, and final parameters
|
||||
user_prompt = user_prompt_dynamic + base_user_prompt
|
||||
|
||||
user_prompt += f"\n选题数量:{self.topic_gen_num}\n选题日期:{self.topic_gen_date}\n"
|
||||
|
||||
logging.info(f"Topic prompts constructed. System: {len(system_prompt)} chars, User: {len(user_prompt)} chars.")
|
||||
return system_prompt, user_prompt
|
||||
|
||||
except Exception as e:
|
||||
logging.exception("Error constructing topic prompts:")
|
||||
return None, None
|
||||
|
||||
def get_content_prompts(self, topic_item):
|
||||
"""Constructs the system and user prompts for content generation."""
|
||||
logging.info("Constructing prompts for content generation...")
|
||||
|
||||
try:
|
||||
# --- System Prompt ---
|
||||
system_prompt = self._system_prompt_cache.get("content")
|
||||
if not system_prompt:
|
||||
if not self.content_system_prompt_path:
|
||||
logging.error("Content system prompt path not provided.")
|
||||
return None, None
|
||||
|
||||
system_prompt = ResourceLoader.load_file_content(self.content_system_prompt_path)
|
||||
if system_prompt:
|
||||
self._system_prompt_cache["content"] = system_prompt
|
||||
else:
|
||||
logging.error(f"Failed to load content system prompt from '{self.content_system_prompt_path}'.")
|
||||
return None, None
|
||||
|
||||
# --- User Prompt ---
|
||||
style = ""
|
||||
demand = ""
|
||||
refers = ""
|
||||
object_info = ""
|
||||
|
||||
# Extract style from topic
|
||||
if "style" in topic_item and topic_item["style"]:
|
||||
style_name = topic_item["style"]
|
||||
style_content = self._get_style_content(style_name)
|
||||
if style_content:
|
||||
style = f"Style: {style_name}\n{style_content}\n\n"
|
||||
else:
|
||||
logging.warning(f"Style content for '{style_name}' not found.")
|
||||
|
||||
# Extract demand from topic
|
||||
if "target_audience" in topic_item and topic_item["target_audience"]:
|
||||
demand_name = topic_item["target_audience"]
|
||||
demand_content = self._get_demand_content(demand_name)
|
||||
if demand_content:
|
||||
demand = f"Demand: {demand_name}\n{demand_content}\n\n"
|
||||
else:
|
||||
logging.warning(f"Demand content for '{demand_name}' not found.")
|
||||
|
||||
# Add refer contents - 现在使用随机抽样
|
||||
refers_content = self._get_all_refer_contents(random_sample=True)
|
||||
if refers_content:
|
||||
refers = f"Reference:\n{refers_content}\n\n"
|
||||
|
||||
# Get object information
|
||||
object_name = topic_item.get("object", "")
|
||||
if object_name:
|
||||
# 优化:遍历resource_dir_config查找对象描述
|
||||
found_object_info = False
|
||||
|
||||
# 1. 搜集所有可能的资源文件
|
||||
all_description_files = []
|
||||
for dir_info in self.resource_dir_config:
|
||||
if dir_info.get("type") in ["Object", "Description"]:
|
||||
all_description_files.extend(dir_info.get("file_path", []))
|
||||
|
||||
# 2. 尝试精确匹配
|
||||
for file_path in all_description_files:
|
||||
if object_name in os.path.basename(file_path):
|
||||
info = ResourceLoader.load_file_content(file_path)
|
||||
if info:
|
||||
object_info = f"Object: {object_name}\n{info}\n\n"
|
||||
logging.info(f"找到对象'{object_name}'的精确匹配资源文件: {file_path}")
|
||||
found_object_info = True
|
||||
break
|
||||
|
||||
# 3. 如果精确匹配失败,尝试模糊匹配
|
||||
if not found_object_info and all_description_files:
|
||||
logging.info(f"尝试模糊匹配对象'{object_name}'的资源文件")
|
||||
|
||||
# 提取所有文件名
|
||||
file_names = [os.path.basename(f) for f in all_description_files]
|
||||
# 模糊匹配
|
||||
matched_filename, score = self.find_directory_fuzzy_match(object_name, files=file_names)
|
||||
|
||||
if matched_filename and score > 0:
|
||||
# 找到匹配的完整路径
|
||||
for file_path in all_description_files:
|
||||
if os.path.basename(file_path) == matched_filename:
|
||||
info = ResourceLoader.load_file_content(file_path)
|
||||
if info:
|
||||
object_info = f"Object: {object_name}\n{info}\n\n"
|
||||
logging.info(f"模糊匹配找到对象'{object_name}'的资源文件: {file_path},匹配分数: {score}")
|
||||
found_object_info = True
|
||||
break
|
||||
|
||||
if not found_object_info:
|
||||
logging.warning(f"未找到对象'{object_name}'的任何匹配资源文件")
|
||||
|
||||
# Get product information if any
|
||||
product_name = topic_item.get("product", "")
|
||||
if product_name:
|
||||
product_info = ""
|
||||
# 优化:遍历resource_dir_config查找产品描述
|
||||
found_product_info = False
|
||||
|
||||
# 搜集所有可能的产品资源文件
|
||||
all_product_files = []
|
||||
for dir_info in self.resource_dir_config:
|
||||
if dir_info.get("type") == "Product":
|
||||
all_product_files.extend(dir_info.get("file_path", []))
|
||||
|
||||
# 尝试精确匹配
|
||||
for file_path in all_product_files:
|
||||
if product_name in os.path.basename(file_path):
|
||||
info = ResourceLoader.load_file_content(file_path)
|
||||
if info:
|
||||
product_info = f"Product: {product_name}\n{info}\n\n"
|
||||
logging.info(f"找到产品'{product_name}'的精确匹配资源文件: {file_path}")
|
||||
found_product_info = True
|
||||
break
|
||||
|
||||
# 如果精确匹配失败,尝试模糊匹配
|
||||
if not found_product_info and all_product_files:
|
||||
logging.info(f"尝试模糊匹配产品'{product_name}'的资源文件")
|
||||
|
||||
# 提取所有文件名
|
||||
file_names = [os.path.basename(f) for f in all_product_files]
|
||||
# 模糊匹配
|
||||
matched_filename, score = self.find_directory_fuzzy_match(product_name, files=file_names)
|
||||
|
||||
if matched_filename and score > 0:
|
||||
# 找到匹配的完整路径
|
||||
for file_path in all_product_files:
|
||||
if os.path.basename(file_path) == matched_filename:
|
||||
info = ResourceLoader.load_file_content(file_path)
|
||||
if info:
|
||||
product_info = f"Product: {product_name}\n{info}\n\n"
|
||||
logging.info(f"模糊匹配找到产品'{product_name}'的资源文件: {file_path},匹配分数: {score}")
|
||||
found_product_info = True
|
||||
break
|
||||
|
||||
if not found_product_info:
|
||||
logging.warning(f"未找到产品'{product_name}'的任何匹配资源文件")
|
||||
|
||||
# 添加产品信息到对象信息中
|
||||
if product_info:
|
||||
object_info += product_info
|
||||
|
||||
# Construct final user prompt
|
||||
user_prompt = f"""请为我创建一个旅游文案。
|
||||
|
||||
{style}{demand}{refers}{object_info}
|
||||
|
||||
请考虑以上所有信息,创作一篇{topic_item.get('target_audience','')}文旅内容。"""
|
||||
|
||||
return system_prompt, user_prompt
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
logging.error(f"Error creating content prompts: {str(e)}")
|
||||
return None, None
|
509
base_line/tweet_prompt.txt
Normal file
@ -0,0 +1,509 @@
|
||||
请为我创建一个旅游文案。
|
||||
|
||||
Style: 攻略风文案提示词
|
||||
你是景区小红书爆款文案策划,你将根据要求创作爆款文案,根据以下规则一步步执行:
|
||||
一、标题创作
|
||||
步骤:先速览景区资料内容,找到与用户需求适配的景点亮点,然后通读用户给的爆款标题素材,挑选一个在用户画像+节点节日+标题吸睛三个方面最契合的进行改写,创作出全新的爆款标题。在此期间以下几点需要你遵循:
|
||||
1.必加1个emoji,标题字数18字以内
|
||||
2.有网感,爆破式标题,吸睛,结合所在地
|
||||
3.标题结合<标题参考格式>进行仿写
|
||||
|
||||
二、正文创作
|
||||
注意:正文以<我><你>这种有人味的人称代词视角创作输出,不要出现<宝子们><姐妹们>这些很假的称呼
|
||||
1. 通过了解产品和当前节点需求,如情人节要约会、儿童节要带孩子玩、临近周末会考虑周边游、传统节日偏好民俗活动等以此类推,直击用户痛点。
|
||||
2. 正文围绕所选定的风格提示词进行创作,有场景感,让人感觉是你的真是体验
|
||||
3. 开头引入部分可以参考我给你的<正文开头引入段落参考>文档,结合所给景区/民宿资料进行改写创作
|
||||
4. 分段+分点论述,开头引入+优势亮点(根据所给材料选择角度:交通优势、住宿优势、周边资源、必打卡项、路线推荐等等...)+你认为的最大优势
|
||||
5. 如果用户给出范文案例,请参考范文案例的写作风格和内容
|
||||
6. 一定要有一种和目标用户对话的感觉,语言平实靠谱,娓娓道来
|
||||
|
||||
三、TAG标签创作
|
||||
每一篇笔记需要携带13个TAG标签,以下举例的加号(+)这个符号不要体现,仅代表内容连接的意思
|
||||
1.前三个TAG标签的内容围绕<#产品所在地区+旅游><#产品所在省份+旅游><#产品所在地区+周边游><#产品周边一线城市+周边游>
|
||||
2.接着三个TAG标签的内容围绕<#景区/酒店名称><#景区/酒店名称+攻略><#景区/酒店名称+推荐>
|
||||
3.再来三个TAG标签的内容围绕<#对应节日+出游><#对应节日+反向旅行><#对应人群+好去处>
|
||||
4.最后三个TAG标签的内容围绕<#周末去哪儿玩><#小众旅游地><##产品所在地区+周末好去处>
|
||||
注意:以上TAG直接按要求直出内容,不需要在直出的时候进行分类
|
||||
|
||||
输出注意点:
|
||||
1.按要求输出标签+正文+TAG标签
|
||||
2.正文直白清楚围绕产品,不要出现比喻和联想
|
||||
3.千万不要虚构景区信息没有的设施设备和活动信息,如果材料中没有指明,不要提及价格信息;如果没有明确的产品价格或者优惠活动信息,不要提及价格和优惠
|
||||
4.输出内容中,要符合社会道德规范和法律规范
|
||||
5.TAG标签按要求顺序在正文最后直出进行罗列
|
||||
6.所有的价格(如XXX元)改成XXXr,即用<r>代替<元>,如:200元变成200r
|
||||
7.关于所有节假日补差价都不要在文中提及
|
||||
|
||||
具体输出格式参照以下格式:
|
||||
{
|
||||
"content": "(正文内容)",
|
||||
"tag": "(TAG内容, 如#地点 #节日 #需求人群旅游 #周末去哪儿玩)",
|
||||
"title": "(标题内容)"
|
||||
}
|
||||
|
||||
Demand: 亲子向文旅需求
|
||||
亲子向用户画像:
|
||||
1. 基本属性
|
||||
- 年龄:25-45岁(家长群体),孩子年龄集中在3-12岁.
|
||||
- 性别:女性主导型,约70%为妈妈群体.
|
||||
|
||||
2. 平台行为
|
||||
- 互动习惯:偏好收藏实用攻略.
|
||||
- 发布内容:分享带娃旅行的温馨瞬间
|
||||
|
||||
3.中国旅游出游做攻略高峰点:
|
||||
1. 春节假期:1-2月
|
||||
2. 暑假:6-8月
|
||||
3. 国庆黄金周:9-10月初
|
||||
4. 五一假期:4-5月初
|
||||
5. 元旦:12月中下旬
|
||||
6.清明:4月初
|
||||
7.端午:5-6月
|
||||
8.中秋:8月底-9月
|
||||
9.寒假:12-2月
|
||||
|
||||
|
||||
Reference:
|
||||
--- Refer File: 标题参考格式.json ---
|
||||
标题: 标题参考格式
|
||||
描述: 你创作标题的时候可以直接按照以下参考标题,结合景区/酒店资料进行仿写。
|
||||
|
||||
示例:
|
||||
1. 呼伦贝尔亲子游!娃哭着不愿回来的攻略~
|
||||
2. 打工人首选🙋广州出发周末两日游
|
||||
3. 2-3月广东赏花季🌸6个绝美赏花地必收藏!
|
||||
4. 去了8次扬州❗这条高质量遛娃路线藏不住了
|
||||
5. 东莞小洱海🌊来吹吹蓝色的风
|
||||
6. 🦒佛山长鹿遛娃一日游,节日氛围已经拉满
|
||||
7. 南京2天1晚亲子游旅游攻略! 不踩雷!
|
||||
8. 周末去哪玩|电玩🎮天河客运站
|
||||
9. 广州欢乐模拟经营密室🧑🏻🍳2人可开‼️
|
||||
10. 广东周边亲子游|适合6岁以下娃踏春好去处❗️
|
||||
11. 假期打开新方式🥳一天玩转古镇+春日赏花🌸
|
||||
12. 广东亲子游ᵕ̈过年还要再来赏樱花🌸
|
||||
13. 天呐🤣这里已经颠覆了我的想象‼️实在太好玩
|
||||
|
||||
--- Refer File: 正文范文参考.json ---
|
||||
标题: 正文范文参考
|
||||
描述: 你需要用这些内容作为参考,创作正文笔记。你可以整合多段获取材料,创作出更丰富的内容。参考写法和视角
|
||||
|
||||
示例:
|
||||
1. 夏天和假期准备来啦!
|
||||
我又发现一个新的玩水避暑胜地!
|
||||
这是全新的一个网红夏威夷泳道啦~
|
||||
-
|
||||
🏨阳江 DS 温泉酒店
|
||||
超级推荐的是夏威夷亲水别墅
|
||||
无限次免费游玩的夏威夷网红泳道
|
||||
踏出房门就能跳进泳池夏天太爽了!
|
||||
-
|
||||
💰599🉐套餐👇
|
||||
【住】酒店别墅星空双床房1间1晚;
|
||||
【吃】价值168元两大一小自助早餐
|
||||
【泡】赠送2池阳台私家泡池温泉水
|
||||
【泡】无限次爽泡高热偏硅酸公共温泉
|
||||
【玩】畅玩网红超长泳道、水上乐园
|
||||
【玩】打卡儿童狮子星空中儿童乐园
|
||||
【赏】天香大草坪、花森林婚庆广场
|
||||
【赠】公区温泉泳道免费水果、茶饮、清食等
|
||||
【赠】管家服务,一次洗漱用品,酒店免费停车
|
||||
---------------
|
||||
💥怎么订:一定要先关.🐖后下方留言咨询:999,稍后会一一回复哈
|
||||
2. 来广州外下午三点多的时候外面太热了 实在不知道去哪 突然决定去长隆水上乐园
|
||||
临时起意 即刻出发!
|
||||
目的地:地铁三号线汉溪长隆
|
||||
|
||||
1⃣️什么时候去?票价多少?
|
||||
建议工作日去人少些
|
||||
下午4点后进场 美团69r/人 !!!很便宜!
|
||||
我觉得这个时间点太棒了
|
||||
太阳已经不是很毒辣 也不是太热了 不用担心晒伤
|
||||
|
||||
2⃣️没带泳衣怎么办?园内买泳衣太贵怎么办?
|
||||
不用担心!万能的小🍠
|
||||
我直接搜“广州长隆水上乐园泳衣”几个关键字
|
||||
果然出现了很多5分钟内送泳衣的帖子
|
||||
随便找了一家直接➕了联系方式
|
||||
在地铁上就看商家发的图
|
||||
选好了泳衣款式➕一个男士泳裤➕防水袋
|
||||
三个东西一共才105!
|
||||
力省几百!!
|
||||
而且店家还给我把东西送到地铁口,还顺便把我人免费送到水上乐园园区门口
|
||||
|
||||
当然也可以直接把泳衣送到园区门口 (切记先不要进园!进园他就送不了了!问了好几家都是这样说!只能在没进园之前送到地铁口或者园区门口!)
|
||||
|
||||
没带泳衣的姐妹千万不要在园区里买!!
|
||||
|
||||
3⃣️进去玩什么?
|
||||
我们是五点多才进园
|
||||
但是到八点半已经玩完了六七个项目
|
||||
所有大项目几乎都玩了
|
||||
介绍一下时间紧任务重的 我们的玩法 :
|
||||
离心滑道—摇滚巨轮-合家大滑板-竞速赛道-巨兽碗-巨洪峡
|
||||
-上面全部玩完之后也才七点五十多
|
||||
然后八点电音节开场觉得没什么意思
|
||||
就去刷之前排队人太多没玩过的项目
|
||||
当然是直接冲大喇叭!!
|
||||
这个时候的大喇叭直接没人!上去就玩没排队!!!
|
||||
迅速玩完 又去玩了一些小项目比如 垂直极限这种 都没人排队了 直接随便玩
|
||||
|
||||
点击左下角查看更多,
|
||||
听说现在力度超级给力呢!
|
||||
3. ☀️暑假来了,同学们想去广州长隆水上乐园玩吗?如果想去,那首先要做攻略,下面是阿毅替你们写的攻略。
|
||||
·
|
||||
🧳出发前要做哪些准备?
|
||||
1、订票🎫:
|
||||
门票分为全天票09:30-22:30、夜场票17:00-22:30
|
||||
可在欢乐长隆gzh、某携、某哪、某评上订
|
||||
2、做交通攻略🚘:
|
||||
自驾导航长隆水上乐园,景区有停车场,收20
|
||||
打车用顺风车比较划算,目的地填长隆水上乐园
|
||||
坐地铁3号线到汉溪长隆站,从E口出来,步行1.2公里可到景区门口
|
||||
3、收拾行李🪪:
|
||||
身份证、毛巾、手机防水袋、游泳衣、防晒霜、拖鞋、手机、塑料袋、零食、水
|
||||
·
|
||||
🗺️进园步骤?
|
||||
1、刷身份证进园
|
||||
2、先去租个柜子储存东西,小柜40,大柜60,另外还要给20押金,等还了手环会返回
|
||||
3、去更衣室换衣服
|
||||
4、可以去玩了
|
||||
·
|
||||
🤹♀️必看演出:
|
||||
1、长隆水上电音派对🪩
|
||||
地点:造浪池舞台
|
||||
时间:20:00-21:00
|
||||
2、玩水激乐大巡游💃
|
||||
地点:园区
|
||||
时间:17:00-17:35
|
||||
3、水战派对🔫
|
||||
地点:沙滩区舞台
|
||||
时间:17:00-17:35
|
||||
4、泡泡派对🫧
|
||||
地点:亲子市集
|
||||
时间:16:00-16:20
|
||||
·
|
||||
🎢必玩项目:
|
||||
1、超级大喇叭📢
|
||||
坐在四叶草浮圈里,从六层高的平台出发,滑进一个巨大的喇叭中,在快速滑行中尽情欢叫
|
||||
2、摇滚巨轮🪇
|
||||
竖立自转滑道,整体外型犹如一座水上摩天轮,高达26米,每个转角都十分圆滑
|
||||
3、巨洪峡🌊
|
||||
在狭长的峡谷中只有滔天巨浪与你相伴,在跌宕起伏中挑战山洪暴发的澎湃,领略被抛向浪尖的快感
|
||||
4、超级巨兽碗👾
|
||||
首先在紫水晶通道俯冲穿越,来到一个巨碗之中。当你稍觉平稳,突然出现一个黑洞将你吸入,让你措手不及
|
||||
5、垂直极限↕️
|
||||
有3条滑道,黄色滑道滑下时速度能达到近40公里,蓝色滑道为旋转滑道,体验离心冲击,橙色滑道垂直度高,体验水花四溅的感觉
|
||||
6、巨蟒滑道🐍
|
||||
从弯形盘旋滑道划出后会进入长达6米的全封闭滑道,集合了扭转,螺旋和振动的感觉
|
||||
7、大滑板滑道🛹
|
||||
6人一组坐在浮圈上,从高处快速滑到底部,然后继续向上滑行到呈 90度的滑道上,然后再从垂直的滑道上滑落到平缓的地段
|
||||
·
|
||||
❤️温馨提示:
|
||||
建议9点半刚开园就去玩,那会人很多,如果下午去人会很多,玩一个项目要等一两个小时。
|
||||
4. 🏨广州森林海温泉度假酒店
|
||||
♨️室内水乐园,水温30度以上,温泉泡池全面!
|
||||
😭千万别错过这个森林海美食专属套餐
|
||||
-
|
||||
现在只要1399就可以享受3大3小的的Chao值套餐哦~真的是太划算啦!
|
||||
🔥3大3小自助早餐+3大3小自助晚餐+2天无限次嬉水乐园十亲子房
|
||||
现在还有限时活动,每个成人住客可以免费带两个1.5以下的小朋友一起畅玩嬉水乐园💦
|
||||
-
|
||||
限时特价房型是森林海爆款房型
|
||||
🔹海洋亲子/嬉水海洋亲子/星空亲子
|
||||
🎈自助早餐
|
||||
🎈私家露台温泉泡池
|
||||
🎈无限次威尼斯泳道
|
||||
🎈嬉水乐园【天阳湖区、神秘海岛、古海森林、森林温泉、抓鱼摸虾]
|
||||
-
|
||||
怎么订❓先关㊗️,然后下方留言【999】看到会一一回复!
|
||||
5. 深圳海边酒店已经住过洲际和万豪
|
||||
这两家酒店海滩都很漂亮
|
||||
没想到这次住桔钓沙莱华直接来了个玻璃海震撼
|
||||
庆幸遇上大晴天☀️
|
||||
才能看到这美到窒息的景色
|
||||
唯一缺点是有围网也不能下海游泳🏊…
|
||||
|
||||
🏨酒店很大不过偏老旧,公区维护一般。房间正海景,躺在床上就能看到海滩,阳台有大浴缸可以一边看海一边泡澡🛀,度假感拉满!
|
||||
|
||||
🎡入住每间房都有赠送一次免费小火车体验+儿童乐园100个币+帆船/皮划艇项目,赠送的游戏币可以在儿童乐园电玩区使用,室内乐园还有攀爬区、蹦床区、波波球… 足够小朋友玩很久了
|
||||
|
||||
🍽️酒店自助早餐一般,不太好吃。晚餐在🍠找的友友农庄,家常菜,偏贵,味道还行吧,窑鸡不好吃,感觉附近就没什么好吃的东西。
|
||||
|
||||
💗莱华酒店虽然没有很完美,比起洲际不够豪华,比起万豪不够漂亮,但是它游玩项目足够多,性价比也很高,而且为了这片玻璃海景色我一定会再刷的!!🏖️
|
||||
6. 位于大鹏新区的桔钓沙,这里沙子超白超好干净还很细腻 脚踩下去都软乎乎的 !
|
||||
不出深圳,也能实现海滨度假🏖
|
||||
.
|
||||
推门见大海🌊下楼即沙滩🏖
|
||||
【深圳桔钓沙莱华度假酒店🏨】
|
||||
作为深圳为数不多拥有独.立沙滩的度假酒店,私密性ji高
|
||||
而且酒店附近的可玩项目好多
|
||||
杨梅坑踩单车🚴♀、看美人鱼拍摄地🧜♀、徒步七娘山⛰、观国家地质博物馆等,都离酒店不到2公里路程!妥妥的遛娃好去处!
|
||||
·
|
||||
🏊♂还有超大的私人泳池,沙滩就在泳池旁边,随时可以下海畅游!
|
||||
☀️沙滩边还有各种娱乐设施,小朋友可以挖沙踏浪,大人则可以玩滑皮划艇
|
||||
👧儿童乐园也是一大亮点!室内室外都有各种儿童游乐设施
|
||||
室内攀岩、积木乐园、、4D动感影院、音乐厅和电子游戏等
|
||||
爸妈们可以省心省力地遛娃,让孩子们尽情享受快乐的时光!🎉
|
||||
·
|
||||
🏨酒店拥有246间客房,间间都带有宽阔独.立景观阳台
|
||||
有多种房型可供选择,房间设施齐全,有超大浴缸.
|
||||
·
|
||||
【双人下午茶套票】
|
||||
💰1299
|
||||
🔹豪华园景房一间一晚
|
||||
🔹双人自助早餐
|
||||
🔹双人下午茶1套(大堂吧/池畔吧 二选一)
|
||||
-
|
||||
想要购买的宝子们,戳我即可获取,酒店住宿套餐
|
||||
7. 发现了一家神仙度假酒店!
|
||||
不在三亚,也不在东南亚
|
||||
而是在深圳⛱️
|
||||
这片果冻海真的美到心颤💕
|
||||
-
|
||||
🌟桔钓沙莱华度假酒店
|
||||
✅封神级沙滩体验🏝️
|
||||
有深圳一绝的牛奶沙+玻璃海,光脚踩在沙滩上,就像踩在热奶粉上一样巨舒服。海水呈渐变色,如果冻一般
|
||||
-
|
||||
✅东南亚秘境园林🌴
|
||||
15万㎡东南亚热带花园,秒穿普吉岛
|
||||
-
|
||||
✅100%阳台景观房⛱️
|
||||
所有房型都有超大阳台,山海园林尽享眼底
|
||||
-
|
||||
✅亲子友好天花板🛝
|
||||
拥有1600㎡的儿童奇妙世界,含欢乐海洋泡泡池、室内攀岩、积木乐园、知识海洋、DIY手工坊等,玩到不想走
|
||||
-
|
||||
✅海上玩乐不停⛵️
|
||||
游艇出海、摩托艇、香蕉船、帆船、皮划艇等超多水上活动
|
||||
-
|
||||
🌟现在这个「小马代」错峰大促,人均最低只要400+,即享:
|
||||
✔️海景豪华房1间1晚
|
||||
✔️双人自助早餐
|
||||
✔️双人小帆船或皮划艇套餐(2选1)
|
||||
✔️客房迷你吧冰箱饮品
|
||||
✔️奇妙世界项目:鲸鱼大滑梯、潜艇通道、海马秋千、波波球池、儿童攀岩、儿童蹦床、手工制作等
|
||||
✔️沙滩排球、沙滩足球
|
||||
✔️赠送双人精美旅拍1份
|
||||
✔️每间房赠送一张免费小火车票
|
||||
-
|
||||
*使用有效期至2025年4月29日
|
||||
-
|
||||
今年第一场海边度假,准备好开始了吗?
|
||||
在牛奶沙上踩出脚印👣
|
||||
看着波光粼粼的海发呆🏝️
|
||||
等一场橘子汽水味的日落🌅
|
||||
这才是度假啊~
|
||||
-!
|
||||
8.
|
||||
伙计们,都给我冲‼️
|
||||
北洛秘境是真的有点东西
|
||||
🚗出行方式:自驾(酒店本身没有停车场)
|
||||
|
||||
🏨住宿day1:北洛秘境度假酒店1栋
|
||||
(1)属于外部干干净净,内部缝缝补补……
|
||||
(2)30层天际泳池🉑去,大海带来的疗愈……
|
||||
(3)包含早晚餐,附赠的悬崖泳池、山体公园、临海栈道套票(整体还是相当划算❤️)
|
||||
🏨住宿day2:铂悦度假公寓
|
||||
|
||||
属于民宿类,大落地窗➕独立阳台,三面看海😍,晚上看沙滩的烟花都达不到我住的高度
|
||||
|
||||
🌟打卡推荐🌟
|
||||
|
||||
1️⃣首先需要知道:山体公园、悬崖泳池、悬崖咖啡、临海栈道都是在一块(只能单次进出‼️,一条道走到底)
|
||||
|
||||
2️⃣山体公园(8:00-18:30),栈道式爬山,建议早上11:00前或者下午15点后完成打卡,它主要包括:
|
||||
|
||||
(1)🌈彩虹步道(比较脏,顶上损坏待维修,在入口打卡即可✅);
|
||||
|
||||
(2)不走回头路🚏路标➕鸟巢🪹(分布在道路两边,可以拍到海和马路上的“阳江”logo);
|
||||
|
||||
(3)不上班行不行🚏路标➕露天小房子(主打一个社畜的精神支柱,房子目前有点像半竣工状态);
|
||||
|
||||
(4)破破烂烂的粉色车尾巴(我合理怀疑它是报废状态,就跟我的精神状态蛮契合);
|
||||
|
||||
(5)悬崖咖啡馆(😐比较有个性,但饮品看个人口味,拍照的话,我想说手残党上午大概率是会废片);
|
||||
|
||||
(6)粉色观景台(圆形⭕️设计,可以上二楼看海,但太晒了,我没去……)可以在外围把建筑做成背景,进去拍的话,一整个粉掉……
|
||||
|
||||
(7)网红天际秋千(两根大铁链子⛓️💥垂下来的,是景点摄影付费装置,平时秋千的板板都是收起来的😳);
|
||||
|
||||
3️⃣悬崖泳池(8:30-18:30),这里需要二次检票(游泳需要泳装➕泳帽,拍照的话检票进去即可),可以付费寄存(20rmb,真的没必要😐)……
|
||||
|
||||
❤️很出片是一回事,很晒是另一回事❤️
|
||||
|
||||
4️⃣海边栈道:去‼️都给我去这里‼️真的好好看😻,果冻海➕岩石➕远处的船➕脚下的石头路,一整个波妞附体
|
||||
|
||||
‼️建议
|
||||
真的要做好防晒😳,感觉已经黑一圈了
|
||||
穿衣是自由的,但姐妹儿,穿鞋一定是自律的
|
||||
防蚊虫是必须的,拍照是要大胆尝试的
|
||||
旅游要自己开心的😃
|
||||
9. ▶️清远新世界酒店
|
||||
还没退房就已经想要二刷的酒店
|
||||
真的太适合躺平溜娃了!
|
||||
|
||||
🌟酒店环境服务
|
||||
很干净整洁的酒店,整体装修风格很舒服,房间也很大。入住登记后有工作人员帮忙拿行李带路,管家服务也很周到,大晚上有需求也回复得很及时,遇到的工作人员都很热情友好~
|
||||
入住的时候尽量让安排到3座,玩的地方基本都在这边,我们住2座,每次都要经过主楼走很远。
|
||||
|
||||
🎪儿童乐园(嗨玩)
|
||||
儿童乐园区域看似不大,但有很多东西玩,三个不同类型的室内游戏房,户外区域有滑梯、蹦床、沙池、小水池、蔬菜园,还有平衡车可以骑,适合不同年龄段的小朋友。我们工作日去几乎包场,完全解放双手,阿乐自己玩得很开心。
|
||||
|
||||
⛳️娱乐项目(活馆)
|
||||
在3座1楼,射箭、迷你高尔夫、地上桌球在一个房间进去,室内球类在对面房。有些项目需要提前预约,但我们去的时候没什么人,都是去了就能玩。迷你高尔夫和地上桌球很适合小朋友,尤其地上桌球真的太好玩了!我觉得每个宝宝都会喜欢的哈哈哈。
|
||||
|
||||
♨️温泉(9:00-22:30)
|
||||
🏊🏻♂️泳池 (9:00-20:00)
|
||||
室外游泳池不是恒温的,我们去的时候天气有点凉,就没去了。带阿乐去泡了温泉,室内有一个泡池一个汗蒸房,室外区域不大,但环境很舒服,泡池不大,有五六个可以泡,建议人少的时候去,体验会好很多。
|
||||
|
||||
💡以上的酒店活动项目,入住前退房后也是可以玩的,这点很人性化,毕竟2天1夜真的安排不过来哈哈~
|
||||
|
||||
🍱餐饮
|
||||
酒店的自助早餐在主楼的1楼,环境很好,我们在室外用餐很惬意,早餐种类挺多,出品我觉得比很多酒店早餐都要好吃很多哈哈~
|
||||
正餐我们没有在酒店吃,附近有很多农庄,当晚我们选了【来来农庄】,开车5min就到,老板很热情,菜品也很不错;第二天退房后去了【三禾稻里】,10min路程,我们到店太晚了只有西餐,价格偏贵,但味道挺好的。不得不说这里真的好美啊,如果不带娃的话我可能会选这个民宿哈哈,很适合躺平发呆~
|
||||
|
||||
🌟总结:是一家非常不错的亲子酒店,很适合亲子家庭入住,必二刷!
|
||||
10. 一开始看朋友去了觉得不错,还心想清远酒店应该很便宜吧,一查居然要1300+当时觉得有点奢侈,但是去了之后觉得好好玩,好舒服,还想再来!
|
||||
|
||||
👨👩👦 酒店设施——超多亲子项目
|
||||
有三个主题的儿童屋,小厨房、钢琴、玩具等,户外可以玩沙子、滑滑梯,还有小农场可以浇花,有大型桌球台、小型高尔夫场,还可以射箭。我儿子玩得很开心,我自己也觉得很有趣!
|
||||
|
||||
♨️ 酒店房间&温泉——
|
||||
房间挺新,可以加装围栏,房间有个独立的小池子,我们晚上把娃带睡之后,在露台吃宵夜泡温泉,带娃人幸福时刻。公域的温泉环境也不错,不过池子有点小
|
||||
|
||||
🥣 酒店自助早餐——很丰盛 选择很多
|
||||
餐厅环境很好,吃早餐的时候看窗外简直心旷神怡。选择也很多,光是那碟绿色肠粉就拿了好几盘!早餐吃得很满足,也挺好吃
|
||||
|
||||
🌴 酒店户外环境——很大 绿化很好
|
||||
超级适合我崽骑车!吃完早餐之后,在酒店户外绕了一圈,有种像逛公园的感觉,光合作用拉满,真的很舒服!
|
||||
|
||||
⭐️⭐️⭐️⭐️⭐️
|
||||
已经是两个月前去的了,还是忍不住mark篇帖子记录。当时选了工作日来的,人少体验好,真的觉得很适合两岁起的娃,我们应该还会再去玩滴!
|
||||
|
||||
|
||||
|
||||
Object: 北洛秘境盛季酒店
|
||||
{
|
||||
"主体名称": "北洛秘境盛季酒店",
|
||||
"产品有效期": "2025年5月13日-6月30日",
|
||||
“产品售卖期”:"2025年5月13日-6月30日",
|
||||
"地址": "闸坡镇北洛大道199号碧桂园北洛明珠花园3栋1层",
|
||||
"交通指南": "【交通建议】导航酒店地址自驾前往或高铁到站后导航前往,广州到阳江站1.5H,到了高铁站可以搭顺风车1H到酒店,人均30。人多也可以直接打顺风车。",
|
||||
"使用规则、加价说明、预约规则、退改政策、优惠内容": "【使用规则】
|
||||
① 本产品购买成功未预约支持随时退,预约成功不改不退
|
||||
② 本产品具体节假日加收最终以国家法定节假日为准,如漏补或少补会有客服联系补回,介意勿拍
|
||||
③ 本产品为特价秒杀,如突遇特殊情况无法正常安排入住,不做任何赔偿,介意者勿拍(订单特殊情况无法安排会有客服联系改期或退款)
|
||||
【加收说明】
|
||||
①5月6月平日不加收,周六+50元,端午节5月31日-6月1日加收150元
|
||||
②指定海景房,在基础加收上叠加30元/间
|
||||
③升级2房1厅海景房,5月加收80元,周六加收230,6月6月加收130元,周六加收280元,端午节5月31日-6月1日加收330元,
|
||||
【预约规则】
|
||||
库存有限,先约先得,加收均为线上加收
|
||||
【注意事项】
|
||||
①床型大小:双床1.2M*2
|
||||
②入住时间:15点后 退房时间:12点前
|
||||
③本套餐不包含门票,周边游玩景点需自费
|
||||
④注:仅供参考,实际以现场为准
|
||||
",
|
||||
"产品最突出优势":"①低于某程、某团价格
|
||||
②可以升级海景2房1厅
|
||||
③节假日:端午节可用",
|
||||
"产品亮点":"周边游玩:
|
||||
酒店位置超优越,能畅游马尾岛,那里的海水清澈见底,沙滩细腻柔软 ,漫步海边,吹着海风,真的好惬意。还能去北洛东边免费沙滩,玩沙踏浪、晒太阳。
|
||||
|
||||
游玩路线:山体公园→悬崖无边泳池→海边栈道→椰林沙滩
|
||||
|
||||
①靠近海陵岛北洛湾沙滩,有12处风景各异的天然海滩,异彩纷呈地点缀,私人海滩、悬崖栈道、天际泳池、山体公园的山顶秋千、粉红观景台,超多景点打卡
|
||||
-悬崖临海栈道
|
||||
|
||||
沿着礁石悬崖蜿蜒的橙色步道,俯拍海浪拍岸的瞬间,仿佛走在世界尽头!(穿防滑鞋!)
|
||||
|
||||
-珍珠白沙滩
|
||||
沙子细软得像面粉,一点都不硌脚。椰林树影,水清,还有海边夜市~
|
||||
|
||||
-山体公园
|
||||
登顶10分钟就能get俯瞰全岛的视角,记得和“海边秋千”合影!
|
||||
|
||||
-彩虹灯塔
|
||||
上山的路有一段彩虹楼梯,楼梯的尽头是彩色灯塔,在这里可以俯瞰整个白沙滩。
|
||||
|
||||
-临海栈道
|
||||
一边是悬崖,一边是大海,临海栈道依山而建,海浪不断拍打着脚下的礁石,浪花飞溅,场面太壮观了
|
||||
|
||||
②酒店楼下小区免费使用儿童游客设施
|
||||
|
||||
③自费打卡北洛湾沙滩泳池、秘境悬崖泳池(悬崖泳池自东向西方向全线视野可以将整片蔚蓝的大海一览无遗,泳池建于马尾岛山顶的悬崖边,108米高空俯瞰大海,Tiffany蓝的池水)等
|
||||
|
||||
美食TIP:
|
||||
▪️ 闸坡渔港的海鲜大排档!皮皮虾/海胆炒饭
|
||||
▪️ 本地糖水铺的“椰子冻”清爽解暑
|
||||
|
||||
④游玩建议:2天1晚,酒店自身处处是网红拍照打卡点,周边景点也多,值得去逛逛,比如天麓山景区、月光殿堂、风车山等
|
||||
"
|
||||
}
|
||||
|
||||
|
||||
Product: 北洛秘境盛季酒店-海景或山景双床房
|
||||
{
|
||||
"产品名称":"北洛秘境盛季酒店-海景或山景双床房",
|
||||
"产品门市价":199,
|
||||
"产品实际售价":99,
|
||||
"套票详情": " 【阳江·北洛秘境盛季酒店】可升级海景2房,99元抢全新海景/山景双床房(随机安排),毗邻悬崖泳池,打卡“东方夏威夷”海陵岛!感受天空海阔融为一体的奇妙体验~ ",
|
||||
"产品售卖期":"2025年5月13日-6月30日",
|
||||
"产品库存":100,
|
||||
"产品最突出优势":"①低于某程、某团价格
|
||||
②可以升级海景2房1厅
|
||||
③节假日:端午节可用",
|
||||
"产品亮点":"周边游玩:
|
||||
酒店位置超优越,能畅游马尾岛,那里的海水清澈见底,沙滩细腻柔软 ,漫步海边,吹着海风,真的好惬意。还能去北洛东边免费沙滩,玩沙踏浪、晒太阳。
|
||||
|
||||
游玩路线:山体公园→悬崖无边泳池→海边栈道→椰林沙滩
|
||||
|
||||
①靠近海陵岛北洛湾沙滩,有12处风景各异的天然海滩,异彩纷呈地点缀,私人海滩、悬崖栈道、天际泳池、山体公园的山顶秋千、粉红观景台,超多景点打卡
|
||||
-悬崖临海栈道
|
||||
|
||||
沿着礁石悬崖蜿蜒的橙色步道,俯拍海浪拍岸的瞬间,仿佛走在世界尽头!(穿防滑鞋!)
|
||||
|
||||
-珍珠白沙滩
|
||||
沙子细软得像面粉,一点都不硌脚。椰林树影,水清,还有海边夜市~
|
||||
|
||||
-山体公园
|
||||
登顶10分钟就能get俯瞰全岛的视角,记得和“海边秋千”合影!
|
||||
|
||||
-彩虹灯塔
|
||||
上山的路有一段彩虹楼梯,楼梯的尽头是彩色灯塔,在这里可以俯瞰整个白沙滩。
|
||||
|
||||
-临海栈道
|
||||
一边是悬崖,一边是大海,临海栈道依山而建,海浪不断拍打着脚下的礁石,浪花飞溅,场面太壮观了
|
||||
|
||||
②酒店楼下小区免费使用儿童游客设施
|
||||
|
||||
③自费打卡北洛湾沙滩泳池、秘境悬崖泳池(悬崖泳池自东向西方向全线视野可以将整片蔚蓝的大海一览无遗,泳池建于马尾岛山顶的悬崖边,108米高空俯瞰大海,Tiffany蓝的池水)等
|
||||
|
||||
美食TIP:
|
||||
▪️ 闸坡渔港的海鲜大排档!皮皮虾/海胆炒饭
|
||||
▪️ 本地糖水铺的“椰子冻”清爽解暑
|
||||
|
||||
④游玩建议:2天1晚,酒店自身处处是网红拍照打卡点,周边景点也多,值得去逛逛,比如天麓山景区、月光殿堂、风车山等
|
||||
",
|
||||
"产品须知": "【使用规则】
|
||||
① 本产品购买成功未预约支持随时退,预约成功不改不退
|
||||
② 本产品具体节假日加收最终以国家法定节假日为准,如漏补或少补会有客服联系补回,介意勿拍
|
||||
③ 本产品为特价秒杀,如突遇特殊情况无法正常安排入住,不做任何赔偿,介意者勿拍(订单特殊情况无法安排会有客服联系改期或退款)
|
||||
【加收说明】
|
||||
①5月6月平日不加收,周六+50元,端午节5月31日-6月1日加收150元
|
||||
②指定海景房,在基础加收上叠加30元/间
|
||||
③升级2房1厅海景房,5月加收80元,周六加收230,6月6月加收130元,周六加收280元,端午节5月31日-6月1日加收330元,
|
||||
【预约规则】
|
||||
库存有限,先约先得,加收均为线上加收
|
||||
【注意事项】
|
||||
①床型大小:双床1.2M*2
|
||||
②入住时间:15点后 退房时间:12点前
|
||||
③本套餐不包含门票,周边游玩景点需自费
|
||||
④注:仅供参考,实际以现场为准
|
||||
"
|
||||
}
|
||||
|
||||
|
||||
|
||||
请考虑以上所有信息,创作一篇亲子向文旅需求文旅内容。
|
420
base_line/标题参考格式.json
Normal file
@ -0,0 +1,420 @@
|
||||
{
|
||||
"title": "标题参考格式",
|
||||
"description": "你创作标题的时候可以直接按照以下参考标题,结合景区/酒店资料进行仿写。",
|
||||
"examples": [
|
||||
{
|
||||
"content": "没有人敢说惠州希尔顿酒店的事吗?"
|
||||
},
|
||||
{
|
||||
"content": "暑假遛娃来青甘亲子游,听听这5条良心建议"
|
||||
},
|
||||
{
|
||||
"content": "湖州🦒亲子圣地|5天4晚行程分享"
|
||||
},
|
||||
{
|
||||
"content": "🐼成都也太适合亲子游了吧!3天2晚攻略来喽"
|
||||
},
|
||||
{
|
||||
"content": "带娃去大理后,我后悔了! 😭后悔没早来!"
|
||||
},
|
||||
{
|
||||
"content": "🦒长隆二天一夜❸园🏨亲子游经验🐘"
|
||||
},
|
||||
{
|
||||
"content": "2024旅行结算|超🐔大型亲子乐园平躺攻略"
|
||||
},
|
||||
{
|
||||
"content": "国庆广州亲子游❄️冰雪游乐园室内遛娃好去处"
|
||||
},
|
||||
{
|
||||
"content": "去了8次扬州❗这条高质量遛娃路线藏不住了"
|
||||
},
|
||||
{
|
||||
"content": "追鲸🐳桂西南带娃已回🔥跟风最成功的一次🔥"
|
||||
},
|
||||
{
|
||||
"content": "济南春季亲子游‼️3天2晚看这一篇就够了💯"
|
||||
},
|
||||
{
|
||||
"content": "广州亲子游🚗1.5h到温泉乐园宫崎骏树屋遛娃"
|
||||
},
|
||||
{
|
||||
"content": "三刷乌村‼️2天1晚保姆级吃住玩攻略‼️"
|
||||
},
|
||||
{
|
||||
"content": "低幼友好!京郊2h直达懒人遛娃地,解放爸妈!"
|
||||
},
|
||||
{
|
||||
"content": "清远新世界酒店|遛娃真的太爽太舒服了♨️"
|
||||
},
|
||||
{
|
||||
"content": "2月18陵水穷游已回,说点掏心窝子的话"
|
||||
},
|
||||
{
|
||||
"content": "带娃避寒首选,广西崇左+北海+涠洲岛攻略!"
|
||||
},
|
||||
{
|
||||
"content": "嘉兴三天两夜亲子游|杭州🚗1.5h达!"
|
||||
},
|
||||
{
|
||||
"content": "江浙沪亲子酒店排名,还有漏的吗?"
|
||||
},
|
||||
{
|
||||
"content": "崇左带娃已回...说点难听的实话"
|
||||
},
|
||||
{
|
||||
"content": "江浙沪遛娃❤️徐州欧乐堡两天一晚亲子游"
|
||||
},
|
||||
{
|
||||
"content": "成都带娃已回,亲子游真实体验(不删)"
|
||||
},
|
||||
{
|
||||
"content": "周末游 | 江门市赤坎古镇,是谁说不推荐的?"
|
||||
},
|
||||
{
|
||||
"content": "亲子游天花板❗️小众不挤还省钱✨抄作业啦"
|
||||
},
|
||||
{
|
||||
"content": "三亚家庭游|5天四晚这样安排,人均1800"
|
||||
},
|
||||
{
|
||||
"content": "两天一夜!宁波亲子游攻略"
|
||||
},
|
||||
{
|
||||
"content": "西双版纳 5 天 4 晚亲子游,get\"不费妈\"攻略"
|
||||
},
|
||||
{
|
||||
"content": "📍广州顺德佛山丨省心路线🗺️避雷指南"
|
||||
},
|
||||
{
|
||||
"content": "迄今为止我最喜欢的亲子游目的地出现了!"
|
||||
},
|
||||
{
|
||||
"content": "初次海南亲子游|海口、陵水7日总结"
|
||||
},
|
||||
{
|
||||
"content": "四天三晚广州亲子游攻略"
|
||||
},
|
||||
{
|
||||
"content": "北京牛马不请假,25年带爸妈遛娃这么玩!1"
|
||||
},
|
||||
{
|
||||
"content": "📍清远!带娃去生态度假村做了一回野孩子"
|
||||
},
|
||||
{
|
||||
"content": "拜托🙏去万宁就死磕威斯汀,1088/三天"
|
||||
},
|
||||
{
|
||||
"content": "5个亲子游地带娃超轻松|1️⃣岁宝宝亲测"
|
||||
},
|
||||
{
|
||||
"content": "广州动物园亲子游 | 千万不要临近中午才去"
|
||||
},
|
||||
{
|
||||
"content": "在成都,带宝贝在6000平游乐园里寻找宝藏吧"
|
||||
},
|
||||
{
|
||||
"content": "千岛湖|很chill的一次亲子游"
|
||||
},
|
||||
{
|
||||
"content": "今年五一我只带娃去一个地方:徽州!🤩"
|
||||
},
|
||||
{
|
||||
"content": "成都周末去哪儿I古镇星空亲子游"
|
||||
},
|
||||
{
|
||||
"content": "解锁亲子游的「瘦shen副本」攻略】✨"
|
||||
},
|
||||
{
|
||||
"content": "宁波奈尔宝/遛娃乐园天花板"
|
||||
},
|
||||
{
|
||||
"content": "敦煌带娃已回,亲子游真实体验(不删)"
|
||||
},
|
||||
{
|
||||
"content": "来广州过个暖冬‼️4天3晚保姆级亲子游攻略"
|
||||
},
|
||||
{
|
||||
"content": "天呐🤣这里已经颠覆了我的想象‼️实在太好玩"
|
||||
},
|
||||
{
|
||||
"content": "划重点!3月适合带幼儿园孩子去的5个地方"
|
||||
},
|
||||
{
|
||||
"content": "N次扬州三天两夜亲子游🧸看这里就够全了🎈"
|
||||
},
|
||||
{
|
||||
"content": "我的年度最爱 甩娃亲子游天花板!"
|
||||
},
|
||||
{
|
||||
"content": "大连亲子游|本地人推荐的4天3夜✨"
|
||||
},
|
||||
{
|
||||
"content": "说点实话:贵州🆚版纳 已回 慎重 附路线"
|
||||
},
|
||||
{
|
||||
"content": "🦒佛山长鹿遛娃一日游,节日氛围已经拉满"
|
||||
},
|
||||
{
|
||||
"content": "2025年带娃必去的🔟个旅游地点"
|
||||
},
|
||||
{
|
||||
"content": "冷空气杀到!😎我反手带娃跳进38℃温泉海💦"
|
||||
},
|
||||
{
|
||||
"content": "🌈迄今为止,我最喜欢的亲子游城市出现了"
|
||||
},
|
||||
{
|
||||
"content": "广东亲子游ᵕ̈过年还要再来赏樱花🌸"
|
||||
},
|
||||
{
|
||||
"content": "高铁遛娃|济南太适合亲子游了!📋超实用攻略"
|
||||
},
|
||||
{
|
||||
"content": "亲子游的松弛感拿捏住了"
|
||||
},
|
||||
{
|
||||
"content": "江浙沪徒步|上海1-2h可达的亲子路线合集🍃"
|
||||
},
|
||||
{
|
||||
"content": "我宣布‼这个亲子酒店399我们玩回本了"
|
||||
},
|
||||
{
|
||||
"content": "云南弥勒☀️和版纳相比,更推荐带娃去这里"
|
||||
},
|
||||
{
|
||||
"content": "听劝❗广东旅游保姆级攻略,吃住玩全都有!"
|
||||
},
|
||||
{
|
||||
"content": "昆明➕抚仙湖4天3晚🌈亲子游分享"
|
||||
},
|
||||
{
|
||||
"content": "全新💯香港海洋公园亲子游不走回头路攻略"
|
||||
},
|
||||
{
|
||||
"content": "抄作业‼️周五出发3日2夜成都亲子游不请假"
|
||||
},
|
||||
{
|
||||
"content": "🌿 华南植物园亲子游 赏花科普一站搞定❗"
|
||||
},
|
||||
{
|
||||
"content": "广东周边亲子游|适合6岁以下娃踏春好去处❗️"
|
||||
},
|
||||
{
|
||||
"content": "广西遛娃plog🌈 | 带娃实现嗦粉自由🍜"
|
||||
},
|
||||
{
|
||||
"content": "呼伦贝尔亲子游!娃哭着不愿回来的攻略~"
|
||||
},
|
||||
{
|
||||
"content": "南京2天1晚亲子游旅游攻略! 不踩雷!"
|
||||
},
|
||||
{
|
||||
"content": "东莞小洱海🌊来吹吹蓝色的风"
|
||||
},
|
||||
{
|
||||
"content": "广州周边游|高铁0-4小时可到达城市汇总"
|
||||
},
|
||||
{
|
||||
"content": "广州番禺该去哪里❓看这篇就够了,快收藏吧"
|
||||
},
|
||||
{
|
||||
"content": "3.4实拍 松山湖这里已进入盛花期~"
|
||||
},
|
||||
{
|
||||
"content": "重庆本地人周末都去哪儿玩 ‼️"
|
||||
},
|
||||
{
|
||||
"content": "🔥终于公布了。广州39个蕞值得去的地方"
|
||||
},
|
||||
{
|
||||
"content": "广州🆓新展|好治愈!! 掉进奇妙春日幻境✨⛲️"
|
||||
},
|
||||
{
|
||||
"content": "国庆假期🚄从广州出发!4小时内可达城市汇总"
|
||||
},
|
||||
{
|
||||
"content": "春日限定🌼在广州拍到了✨故事里的小黄花~"
|
||||
},
|
||||
{
|
||||
"content": "石家庄出发,周末两天出游攻略来啦!"
|
||||
},
|
||||
{
|
||||
"content": "2-3月广东赏花季🌸6个绝美赏花地必收藏!"
|
||||
},
|
||||
{
|
||||
"content": "终于整理好了😭武汉春日必去的①⑥个徒步路线"
|
||||
},
|
||||
{
|
||||
"content": "大胆周末48H❗️天津➡徐州两日游"
|
||||
},
|
||||
{
|
||||
"content": "🆘除了七星岩,端州大学生周末还能去哪?"
|
||||
},
|
||||
{
|
||||
"content": "春游踏青| 十条适合周末出行玩乐新手路线"
|
||||
},
|
||||
{
|
||||
"content": "📘Infj|男大学生 ✨周末无课plog"
|
||||
},
|
||||
{
|
||||
"content": "广州一日往返!9个春天上春山周末小众散心处"
|
||||
},
|
||||
{
|
||||
"content": "深圳📍误入莫奈瀑布花海🌷春日浪漫💕免费!"
|
||||
},
|
||||
{
|
||||
"content": "广州🔥线下活动 3.4-3.15丨不准你不知道"
|
||||
},
|
||||
{
|
||||
"content": "广州1h直达‼被严重低估了的百年骑楼小镇‼️"
|
||||
},
|
||||
{
|
||||
"content": "📍3.7 广州已回。。😭我都崩溃了"
|
||||
},
|
||||
{
|
||||
"content": "周末遛娃别再发朋友圈了!都发给我!"
|
||||
},
|
||||
{
|
||||
"content": "杭州车程1H,千亩白梅 本周花开成海!"
|
||||
},
|
||||
{
|
||||
"content": "广州大学生周末去哪玩(8/100)"
|
||||
},
|
||||
{
|
||||
"content": "打工人首选🙋广州出发周末两日游"
|
||||
},
|
||||
{
|
||||
"content": "广东版小洪崖洞❗️❓东莞西溪古村小众秘境"
|
||||
},
|
||||
{
|
||||
"content": "南昌3.4实况🌸艾溪湖粉色樱花盛开‼️快来拍"
|
||||
},
|
||||
{
|
||||
"content": "地铁直达🚇深圳12个游玩景点攻略合集🔥"
|
||||
},
|
||||
{
|
||||
"content": "💥超齐全攻略|广东省内春季两天一夜团建📝"
|
||||
},
|
||||
{
|
||||
"content": "被问爆的春日踏青地图!广东9城花海巡礼指南"
|
||||
},
|
||||
{
|
||||
"content": "接下来的武汉,免费带娃见世面方式是这样的!"
|
||||
},
|
||||
{
|
||||
"content": "广州➡️阳朔⛰️ 两天一夜 人均700💰详细攻"
|
||||
},
|
||||
{
|
||||
"content": "广州地铁直达的绝美看海地|打工人周末充电"
|
||||
},
|
||||
{
|
||||
"content": "苏州可以逛一整天的市场推荐!全部🚇直达"
|
||||
},
|
||||
{
|
||||
"content": "【广州周边游】周末逃离城市计划🏖️桂山"
|
||||
},
|
||||
{
|
||||
"content": "清远!广东\"小桂林\"和男友三刷了,太chill了"
|
||||
},
|
||||
{
|
||||
"content": "广州citywalk | 长洲岛真的好治愈啊"
|
||||
},
|
||||
{
|
||||
"content": "女生最期待你✨带她去的约会项目🥰"
|
||||
},
|
||||
{
|
||||
"content": "女生最期待男生带她去的约会项目!"
|
||||
},
|
||||
{
|
||||
"content": "【广州情侣私奔地图】人少景美悄悄话圣地"
|
||||
},
|
||||
{
|
||||
"content": "绝了❗佛山新开的!山间弯道超车太炫酷了😭"
|
||||
},
|
||||
{
|
||||
"content": "周末去哪玩|电玩🎮天河客运站"
|
||||
},
|
||||
{
|
||||
"content": "假期打开新方式🥳一天玩转古镇+春日赏花🌸"
|
||||
},
|
||||
{
|
||||
"content": "📍佛山南海|\"洱海\"般的日落免费小众露营地"
|
||||
},
|
||||
{
|
||||
"content": "广州欢乐模拟经营密室🧑🏻🍳2人可开‼️"
|
||||
},
|
||||
{
|
||||
"content": "3月学生党穷游海边城市TOP9!省钱又好玩!"
|
||||
},
|
||||
{
|
||||
"content": "3月来广州,邂逅这5片免费花海吧🥰"
|
||||
},
|
||||
{
|
||||
"content": "周末在广州可以做的30件事!"
|
||||
},
|
||||
{
|
||||
"content": "广州出发1h | 直达广东\"小九寨\"🍃"
|
||||
},
|
||||
{
|
||||
"content": "广州!居然还有人没去过这个天花板公园?"
|
||||
},
|
||||
{
|
||||
"content": "春天总要来一趟广州吧!满城鲜花真的好美🌸"
|
||||
},
|
||||
{
|
||||
"content": "广东周边2-4天游!10个高铁🚄直达宝藏地"
|
||||
},
|
||||
{
|
||||
"content": "在广州,99%的人都不知道的小众徒步公园❗️"
|
||||
},
|
||||
{
|
||||
"content": "3-4月旅行淡季,这5个地方美到窒息人少到哭"
|
||||
},
|
||||
{
|
||||
"content": "广州周末去哪玩❓陈家祠深度游攻略‼️"
|
||||
},
|
||||
{
|
||||
"content": "五一丨在这个奇幻乐园魔法降临啦🎩 🔮"
|
||||
},
|
||||
{
|
||||
"content": "520心动企划💗看不见你的笑我怎么睡得着"
|
||||
},
|
||||
{
|
||||
"content": "昆明遛娃丨遛娃YYDS🤙🏻古滇新春大庙会🐯"
|
||||
},
|
||||
{
|
||||
"content": "中秋攻略丨最低255.8 ,全家6口畅玩欢乐世界"
|
||||
},
|
||||
{
|
||||
"content": "昆明赏樱踏青|看过一场樱吹雪,才不负春光"
|
||||
},
|
||||
{
|
||||
"content": "醉云南的火把节🔥当然要配最云南的乐队🪘🎸"
|
||||
},
|
||||
{
|
||||
"content": "仅99💰|带你体验不一样的云南异域狂欢!"
|
||||
},
|
||||
{
|
||||
"content": "五百里音乐节🎸|双人票👫酒店套餐来咯"
|
||||
},
|
||||
{
|
||||
"content": "六一去哪儿?欢乐世界全攻略来了‼️建议收藏"
|
||||
},
|
||||
{
|
||||
"content": "六一带娃玩七彩云南🦄这份身高攻略请收好!"
|
||||
},
|
||||
{
|
||||
"content": "10月27日超级NPC互动拍照时间地点来咯💓"
|
||||
},
|
||||
{
|
||||
"content": "任何人不知道温泉节特惠♨我都会伤心的OK⁉️"
|
||||
},
|
||||
{
|
||||
"content": "昆明万圣|整蛊狂欢全攻略🎃建议收藏背诵"
|
||||
},
|
||||
{
|
||||
"content": "古滇温泉山庄粉丝福利👒限时138♨速来泡汤!"
|
||||
}
|
||||
]
|
||||
}
|
35
base_line/正文范文参考.json
Normal file
@ -0,0 +1,35 @@
|
||||
{ "title": "正文范文参考",
|
||||
"description": "你需要用这些内容作为参考,创作正文笔记。你可以整合多段获取材料,创作出更丰富的内容。参考写法和视角",
|
||||
"examples":[
|
||||
{
|
||||
"content": "夏天和假期准备来啦!\n我又发现一个新的玩水避暑胜地!\n这是全新的一个网红夏威夷泳道啦~\n-\n🏨阳江 DS 温泉酒店\n超级推荐的是夏威夷亲水别墅\n无限次免费游玩的夏威夷网红泳道\n踏出房门就能跳进泳池夏天太爽了!\n-\n💰599🉐套餐👇\n【住】酒店别墅星空双床房1间1晚;\n【吃】价值168元两大一小自助早餐\n【泡】赠送2池阳台私家泡池温泉水\n【泡】无限次爽泡高热偏硅酸公共温泉\n【玩】畅玩网红超长泳道、水上乐园\n【玩】打卡儿童狮子星空中儿童乐园\n【赏】天香大草坪、花森林婚庆广场\n【赠】公区温泉泳道免费水果、茶饮、清食等\n【赠】管家服务,一次洗漱用品,酒店免费停车\n---------------\n💥怎么订:一定要先关.🐖后下方留言咨询:999,稍后会一一回复哈"
|
||||
},
|
||||
{
|
||||
"content": "来广州外下午三点多的时候外面太热了 实在不知道去哪 突然决定去长隆水上乐园\n临时起意 即刻出发!\n目的地:地铁三号线汉溪长隆\n\n1⃣️什么时候去?票价多少?\n建议工作日去人少些\n下午4点后进场 美团69r/人 !!!很便宜!\n我觉得这个时间点太棒了\n太阳已经不是很毒辣 也不是太热了 不用担心晒伤\n\n2⃣️没带泳衣怎么办?园内买泳衣太贵怎么办?\n不用担心!万能的小🍠\n我直接搜“广州长隆水上乐园泳衣”几个关键字\n果然出现了很多5分钟内送泳衣的帖子\n随便找了一家直接➕了联系方式\n在地铁上就看商家发的图\n选好了泳衣款式➕一个男士泳裤➕防水袋\n三个东西一共才105!\n力省几百!!\n而且店家还给我把东西送到地铁口,还顺便把我人免费送到水上乐园园区门口\n\n当然也可以直接把泳衣送到园区门口 (切记先不要进园!进园他就送不了了!问了好几家都是这样说!只能在没进园之前送到地铁口或者园区门口!)\n\n没带泳衣的姐妹千万不要在园区里买!!\n\n3⃣️进去玩什么?\n我们是五点多才进园\n但是到八点半已经玩完了六七个项目\n所有大项目几乎都玩了\n介绍一下时间紧任务重的 我们的玩法 :\n离心滑道—摇滚巨轮-合家大滑板-竞速赛道-巨兽碗-巨洪峡\n-上面全部玩完之后也才七点五十多\n然后八点电音节开场觉得没什么意思\n就去刷之前排队人太多没玩过的项目\n当然是直接冲大喇叭!!\n这个时候的大喇叭直接没人!上去就玩没排队!!!\n迅速玩完 又去玩了一些小项目比如 垂直极限这种 都没人排队了 直接随便玩\n\n点击左下角查看更多,\n听说现在力度超级给力呢!"
|
||||
},
|
||||
{
|
||||
"content": "☀️暑假来了,同学们想去广州长隆水上乐园玩吗?如果想去,那首先要做攻略,下面是阿毅替你们写的攻略。\n·\n🧳出发前要做哪些准备?\n1、订票🎫:\n门票分为全天票09:30-22:30、夜场票17:00-22:30\n可在欢乐长隆gzh、某携、某哪、某评上订\n2、做交通攻略🚘:\n自驾导航长隆水上乐园,景区有停车场,收20\n打车用顺风车比较划算,目的地填长隆水上乐园\n坐地铁3号线到汉溪长隆站,从E口出来,步行1.2公里可到景区门口\n3、收拾行李🪪:\n身份证、毛巾、手机防水袋、游泳衣、防晒霜、拖鞋、手机、塑料袋、零食、水\n·\n🗺️进园步骤?\n1、刷身份证进园\n2、先去租个柜子储存东西,小柜40,大柜60,另外还要给20押金,等还了手环会返回\n3、去更衣室换衣服\n4、可以去玩了\n·\n🤹♀️必看演出:\n1、长隆水上电音派对🪩\n地点:造浪池舞台\n时间:20:00-21:00\n2、玩水激乐大巡游💃\n地点:园区\n时间:17:00-17:35\n3、水战派对🔫\n地点:沙滩区舞台\n时间:17:00-17:35\n4、泡泡派对🫧\n地点:亲子市集\n时间:16:00-16:20\n·\n🎢必玩项目:\n1、超级大喇叭📢\n坐在四叶草浮圈里,从六层高的平台出发,滑进一个巨大的喇叭中,在快速滑行中尽情欢叫\n2、摇滚巨轮🪇\n竖立自转滑道,整体外型犹如一座水上摩天轮,高达26米,每个转角都十分圆滑\n3、巨洪峡🌊\n在狭长的峡谷中只有滔天巨浪与你相伴,在跌宕起伏中挑战山洪暴发的澎湃,领略被抛向浪尖的快感\n4、超级巨兽碗👾\n首先在紫水晶通道俯冲穿越,来到一个巨碗之中。当你稍觉平稳,突然出现一个黑洞将你吸入,让你措手不及\n5、垂直极限↕️\n有3条滑道,黄色滑道滑下时速度能达到近40公里,蓝色滑道为旋转滑道,体验离心冲击,橙色滑道垂直度高,体验水花四溅的感觉\n6、巨蟒滑道🐍\n从弯形盘旋滑道划出后会进入长达6米的全封闭滑道,集合了扭转,螺旋和振动的感觉\n7、大滑板滑道🛹\n6人一组坐在浮圈上,从高处快速滑到底部,然后继续向上滑行到呈 90度的滑道上,然后再从垂直的滑道上滑落到平缓的地段\n·\n❤️温馨提示:\n建议9点半刚开园就去玩,那会人很多,如果下午去人会很多,玩一个项目要等一两个小时。"
|
||||
},
|
||||
{
|
||||
"content": "🏨广州森林海温泉度假酒店\n♨️室内水乐园,水温30度以上,温泉泡池全面!\n😭千万别错过这个森林海美食专属套餐\n-\n现在只要1399就可以享受3大3小的的Chao值套餐哦~真的是太划算啦!\n🔥3大3小自助早餐+3大3小自助晚餐+2天无限次嬉水乐园十亲子房\n现在还有限时活动,每个成人住客可以免费带两个1.5以下的小朋友一起畅玩嬉水乐园💦\n-\n限时特价房型是森林海爆款房型\n🔹海洋亲子/嬉水海洋亲子/星空亲子\n🎈自助早餐\n🎈私家露台温泉泡池\n🎈无限次威尼斯泳道\n🎈嬉水乐园【天阳湖区、神秘海岛、古海森林、森林温泉、抓鱼摸虾]\n-\n怎么订❓先关㊗️,然后下方留言【999】看到会一一回复!"
|
||||
},
|
||||
{
|
||||
"content": "深圳海边酒店已经住过洲际和万豪\n这两家酒店海滩都很漂亮\n没想到这次住桔钓沙莱华直接来了个玻璃海震撼\n庆幸遇上大晴天☀️\n才能看到这美到窒息的景色\n唯一缺点是有围网也不能下海游泳🏊…\n\n🏨酒店很大不过偏老旧,公区维护一般。房间正海景,躺在床上就能看到海滩,阳台有大浴缸可以一边看海一边泡澡🛀,度假感拉满!\n\n🎡入住每间房都有赠送一次免费小火车体验+儿童乐园100个币+帆船/皮划艇项目,赠送的游戏币可以在儿童乐园电玩区使用,室内乐园还有攀爬区、蹦床区、波波球… 足够小朋友玩很久了\n\n🍽️酒店自助早餐一般,不太好吃。晚餐在🍠找的友友农庄,家常菜,偏贵,味道还行吧,窑鸡不好吃,感觉附近就没什么好吃的东西。\n\n💗莱华酒店虽然没有很完美,比起洲际不够豪华,比起万豪不够漂亮,但是它游玩项目足够多,性价比也很高,而且为了这片玻璃海景色我一定会再刷的!!🏖️"
|
||||
},
|
||||
{
|
||||
"content": "位于大鹏新区的桔钓沙,这里沙子超白超好干净还很细腻 脚踩下去都软乎乎的 !\n不出深圳,也能实现海滨度假🏖\n.\n推门见大海🌊下楼即沙滩🏖\n【深圳桔钓沙莱华度假酒店🏨】\n作为深圳为数不多拥有独.立沙滩的度假酒店,私密性ji高\n而且酒店附近的可玩项目好多\n杨梅坑踩单车🚴♀、看美人鱼拍摄地🧜♀、徒步七娘山⛰、观国家地质博物馆等,都离酒店不到2公里路程!妥妥的遛娃好去处!\n·\n🏊♂还有超大的私人泳池,沙滩就在泳池旁边,随时可以下海畅游!\n☀️沙滩边还有各种娱乐设施,小朋友可以挖沙踏浪,大人则可以玩滑皮划艇\n👧儿童乐园也是一大亮点!室内室外都有各种儿童游乐设施\n室内攀岩、积木乐园、、4D动感影院、音乐厅和电子游戏等\n爸妈们可以省心省力地遛娃,让孩子们尽情享受快乐的时光!🎉\n·\n🏨酒店拥有246间客房,间间都带有宽阔独.立景观阳台\n有多种房型可供选择,房间设施齐全,有超大浴缸.\n·\n【双人下午茶套票】\n💰1299\n🔹豪华园景房一间一晚\n🔹双人自助早餐\n🔹双人下午茶1套(大堂吧/池畔吧 二选一)\n-\n想要购买的宝子们,戳我即可获取,酒店住宿套餐"
|
||||
},
|
||||
{
|
||||
"content": "发现了一家神仙度假酒店!\n不在三亚,也不在东南亚\n而是在深圳⛱️\n这片果冻海真的美到心颤💕\n-\n🌟桔钓沙莱华度假酒店\n✅封神级沙滩体验🏝️\n有深圳一绝的牛奶沙+玻璃海,光脚踩在沙滩上,就像踩在热奶粉上一样巨舒服。海水呈渐变色,如果冻一般\n-\n✅东南亚秘境园林🌴\n15万㎡东南亚热带花园,秒穿普吉岛\n-\n✅100%阳台景观房⛱️\n所有房型都有超大阳台,山海园林尽享眼底\n-\n✅亲子友好天花板🛝\n拥有1600㎡的儿童奇妙世界,含欢乐海洋泡泡池、室内攀岩、积木乐园、知识海洋、DIY手工坊等,玩到不想走\n-\n✅海上玩乐不停⛵️\n游艇出海、摩托艇、香蕉船、帆船、皮划艇等超多水上活动\n-\n🌟现在这个「小马代」错峰大促,人均最低只要400+,即享:\n✔️海景豪华房1间1晚\n✔️双人自助早餐\n✔️双人小帆船或皮划艇套餐(2选1)\n✔️客房迷你吧冰箱饮品\n✔️奇妙世界项目:鲸鱼大滑梯、潜艇通道、海马秋千、波波球池、儿童攀岩、儿童蹦床、手工制作等\n✔️沙滩排球、沙滩足球\n✔️赠送双人精美旅拍1份\n✔️每间房赠送一张免费小火车票\n-\n*使用有效期至2025年4月29日\n-\n今年第一场海边度假,准备好开始了吗?\n在牛奶沙上踩出脚印👣\n看着波光粼粼的海发呆🏝️\n等一场橘子汽水味的日落🌅\n这才是度假啊~\n-!"
|
||||
},
|
||||
{
|
||||
"content": "\n伙计们,都给我冲‼️\n北洛秘境是真的有点东西\n🚗出行方式:自驾(酒店本身没有停车场)\n\n🏨住宿day1:北洛秘境度假酒店1栋\n(1)属于外部干干净净,内部缝缝补补……\n(2)30层天际泳池🉑去,大海带来的疗愈……\n(3)包含早晚餐,附赠的悬崖泳池、山体公园、临海栈道套票(整体还是相当划算❤️)\n🏨住宿day2:铂悦度假公寓\n\n属于民宿类,大落地窗➕独立阳台,三面看海😍,晚上看沙滩的烟花都达不到我住的高度\n\n🌟打卡推荐🌟\n\n1️⃣首先需要知道:山体公园、悬崖泳池、悬崖咖啡、临海栈道都是在一块(只能单次进出‼️,一条道走到底)\n\n2️⃣山体公园(8:00-18:30),栈道式爬山,建议早上11:00前或者下午15点后完成打卡,它主要包括:\n\n(1)🌈彩虹步道(比较脏,顶上损坏待维修,在入口打卡即可✅);\n\n(2)不走回头路🚏路标➕鸟巢🪹(分布在道路两边,可以拍到海和马路上的“阳江”logo);\n\n(3)不上班行不行🚏路标➕露天小房子(主打一个社畜的精神支柱,房子目前有点像半竣工状态);\n\n(4)破破烂烂的粉色车尾巴(我合理怀疑它是报废状态,就跟我的精神状态蛮契合);\n\n(5)悬崖咖啡馆(😐比较有个性,但饮品看个人口味,拍照的话,我想说手残党上午大概率是会废片);\n\n(6)粉色观景台(圆形⭕️设计,可以上二楼看海,但太晒了,我没去……)可以在外围把建筑做成背景,进去拍的话,一整个粉掉……\n\n(7)网红天际秋千(两根大铁链子⛓️💥垂下来的,是景点摄影付费装置,平时秋千的板板都是收起来的😳);\n\n3️⃣悬崖泳池(8:30-18:30),这里需要二次检票(游泳需要泳装➕泳帽,拍照的话检票进去即可),可以付费寄存(20rmb,真的没必要😐)……\n\n❤️很出片是一回事,很晒是另一回事❤️\n\n4️⃣海边栈道:去‼️都给我去这里‼️真的好好看😻,果冻海➕岩石➕远处的船➕脚下的石头路,一整个波妞附体\n\n‼️建议\n真的要做好防晒😳,感觉已经黑一圈了\n穿衣是自由的,但姐妹儿,穿鞋一定是自律的\n防蚊虫是必须的,拍照是要大胆尝试的\n旅游要自己开心的😃"
|
||||
},
|
||||
{
|
||||
"content": "▶️清远新世界酒店\n还没退房就已经想要二刷的酒店\n真的太适合躺平溜娃了!\n\n🌟酒店环境服务\n很干净整洁的酒店,整体装修风格很舒服,房间也很大。入住登记后有工作人员帮忙拿行李带路,管家服务也很周到,大晚上有需求也回复得很及时,遇到的工作人员都很热情友好~\n入住的时候尽量让安排到3座,玩的地方基本都在这边,我们住2座,每次都要经过主楼走很远。\n\n🎪儿童乐园(嗨玩)\n儿童乐园区域看似不大,但有很多东西玩,三个不同类型的室内游戏房,户外区域有滑梯、蹦床、沙池、小水池、蔬菜园,还有平衡车可以骑,适合不同年龄段的小朋友。我们工作日去几乎包场,完全解放双手,阿乐自己玩得很开心。\n\n⛳️娱乐项目(活馆)\n在3座1楼,射箭、迷你高尔夫、地上桌球在一个房间进去,室内球类在对面房。有些项目需要提前预约,但我们去的时候没什么人,都是去了就能玩。迷你高尔夫和地上桌球很适合小朋友,尤其地上桌球真的太好玩了!我觉得每个宝宝都会喜欢的哈哈哈。\n\n♨️温泉(9:00-22:30)\n🏊🏻♂️泳池 (9:00-20:00)\n室外游泳池不是恒温的,我们去的时候天气有点凉,就没去了。带阿乐去泡了温泉,室内有一个泡池一个汗蒸房,室外区域不大,但环境很舒服,泡池不大,有五六个可以泡,建议人少的时候去,体验会好很多。\n\n💡以上的酒店活动项目,入住前退房后也是可以玩的,这点很人性化,毕竟2天1夜真的安排不过来哈哈~\n\n🍱餐饮\n酒店的自助早餐在主楼的1楼,环境很好,我们在室外用餐很惬意,早餐种类挺多,出品我觉得比很多酒店早餐都要好吃很多哈哈~\n正餐我们没有在酒店吃,附近有很多农庄,当晚我们选了【来来农庄】,开车5min就到,老板很热情,菜品也很不错;第二天退房后去了【三禾稻里】,10min路程,我们到店太晚了只有西餐,价格偏贵,但味道挺好的。不得不说这里真的好美啊,如果不带娃的话我可能会选这个民宿哈哈,很适合躺平发呆~\n\n🌟总结:是一家非常不错的亲子酒店,很适合亲子家庭入住,必二刷!"
|
||||
},
|
||||
{
|
||||
"content": "一开始看朋友去了觉得不错,还心想清远酒店应该很便宜吧,一查居然要1300+当时觉得有点奢侈,但是去了之后觉得好好玩,好舒服,还想再来!\n\n👨👩👦 酒店设施——超多亲子项目\n有三个主题的儿童屋,小厨房、钢琴、玩具等,户外可以玩沙子、滑滑梯,还有小农场可以浇花,有大型桌球台、小型高尔夫场,还可以射箭。我儿子玩得很开心,我自己也觉得很有趣!\n\n♨️ 酒店房间&温泉——\n房间挺新,可以加装围栏,房间有个独立的小池子,我们晚上把娃带睡之后,在露台吃宵夜泡温泉,带娃人幸福时刻。公域的温泉环境也不错,不过池子有点小\n\n🥣 酒店自助早餐——很丰盛 选择很多\n餐厅环境很好,吃早餐的时候看窗外简直心旷神怡。选择也很多,光是那碟绿色肠粉就拿了好几盘!早餐吃得很满足,也挺好吃\n\n🌴 酒店户外环境——很大 绿化很好\n超级适合我崽骑车!吃完早餐之后,在酒店户外绕了一圈,有种像逛公园的感觉,光合作用拉满,真的很舒服!\n\n⭐️⭐️⭐️⭐️⭐️\n已经是两个月前去的了,还是忍不住mark篇帖子记录。当时选了工作日来的,人少体验好,真的觉得很适合两岁起的娃,我们应该还会再去玩滴!"
|
||||
}
|
||||
]
|
||||
}
|
524
code/VideoSplitter.py
Normal file
@ -0,0 +1,524 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
from datetime import timedelta
|
||||
import argparse
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
from skimage.metrics import structural_similarity as ssim
|
||||
|
||||
# 设置固定的输入输出路径
|
||||
INPUT_VIDEO_PATH = "/root/autodl-tmp/hot_video_analyse/source/sample_demo_1.mp4" # 修改为视频文件夹路径
|
||||
OUTPUT_DIR = "/root/autodl-tmp/hot_video_analyse/source/Splitter" # 请修改为您的实际输出目录路径
|
||||
|
||||
# 支持的视频格式
|
||||
VIDEO_EXTENSIONS = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv']
|
||||
|
||||
# 设置参数
|
||||
SAMPLE_RATE = 1 # 帧采样率
|
||||
METHOD = "ssim" # 比较方法,可选 "ssim" 或 "cosine"
|
||||
## 有分镜0.2 无分镜0.7
|
||||
THRESHOLD = 0.8 # 相似度阈值
|
||||
|
||||
# 启用详细日志输出
|
||||
VERBOSE = True
|
||||
|
||||
# FFMPEG可能的路径
|
||||
FFMPEG_PATHS = [
|
||||
'ffmpeg', # 系统路径
|
||||
'/usr/bin/ffmpeg', # 常见Linux路径
|
||||
'/usr/local/bin/ffmpeg', # 常见macOS路径
|
||||
'C:\\ffmpeg\\bin\\ffmpeg.exe', # 常见Windows路径
|
||||
]
|
||||
|
||||
def find_ffmpeg():
|
||||
"""查找系统中可用的ffmpeg路径"""
|
||||
# 首先尝试使用which/where命令
|
||||
try:
|
||||
if os.name == 'nt': # Windows
|
||||
result = subprocess.run(['where', 'ffmpeg'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip().split('\n')[0]
|
||||
else: # Linux/Mac
|
||||
result = subprocess.run(['which', 'ffmpeg'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 然后检查预定义的路径
|
||||
for path in FFMPEG_PATHS:
|
||||
if shutil.which(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def extract_frames(video_path, output_dir, sample_rate=1):
|
||||
"""
|
||||
从视频中提取帧并保存到指定目录
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_dir: 输出帧的目录
|
||||
sample_rate: 采样率(每N帧提取一帧)
|
||||
|
||||
Returns:
|
||||
frames_info: 包含帧信息的列表 [(frame_number, timestamp, frame_path), ...]
|
||||
"""
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
# 打开视频
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
|
||||
# 获取视频属性
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
duration = frame_count / fps
|
||||
|
||||
print(f"视频信息:{frame_count}帧, {fps}fps, 时长:{timedelta(seconds=duration)}")
|
||||
|
||||
frames_info = []
|
||||
frame_number = 0
|
||||
saved_count = 0
|
||||
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
if frame_number % sample_rate == 0:
|
||||
# 计算时间戳(秒)
|
||||
timestamp = frame_number / fps
|
||||
# 保存帧
|
||||
frame_path = os.path.join(output_dir, f"frame_{saved_count:05d}.jpg")
|
||||
cv2.imwrite(frame_path, frame)
|
||||
# 记录帧信息
|
||||
frames_info.append((frame_number, timestamp, frame_path))
|
||||
saved_count += 1
|
||||
|
||||
frame_number += 1
|
||||
|
||||
# 显示进度
|
||||
if frame_number % 100 == 0:
|
||||
print(f"处理进度: {frame_number}/{frame_count} ({frame_number/frame_count*100:.2f}%)")
|
||||
|
||||
cap.release()
|
||||
print(f"共提取了 {saved_count} 帧")
|
||||
return frames_info
|
||||
|
||||
def compare_frames(frame1_path, frame2_path, method='ssim', threshold=0.85):
|
||||
"""
|
||||
比较两帧的相似度
|
||||
|
||||
Args:
|
||||
frame1_path: 第一帧路径
|
||||
frame2_path: 第二帧路径
|
||||
method: 比较方法,'ssim'或'cosine'
|
||||
threshold: 相似度阈值
|
||||
|
||||
Returns:
|
||||
is_similar: 是否相似
|
||||
similarity: 相似度值
|
||||
"""
|
||||
# 读取帧
|
||||
frame1 = cv2.imread(frame1_path)
|
||||
frame2 = cv2.imread(frame2_path)
|
||||
|
||||
# 调整大小以加快处理
|
||||
frame1_resized = cv2.resize(frame1, (320, 180))
|
||||
frame2_resized = cv2.resize(frame2, (320, 180))
|
||||
|
||||
# 转换为灰度图
|
||||
gray1 = cv2.cvtColor(frame1_resized, cv2.COLOR_BGR2GRAY)
|
||||
gray2 = cv2.cvtColor(frame2_resized, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
if method == 'ssim':
|
||||
# 使用结构相似性指数
|
||||
similarity, _ = ssim(gray1, gray2, full=True)
|
||||
else: # cosine
|
||||
# 使用余弦相似度
|
||||
flat1 = gray1.flatten().reshape(1, -1)
|
||||
flat2 = gray2.flatten().reshape(1, -1)
|
||||
similarity = cosine_similarity(flat1, flat2)[0][0]
|
||||
|
||||
is_similar = similarity >= threshold
|
||||
return is_similar, similarity
|
||||
|
||||
def detect_scene_changes(frames_info, method='ssim', threshold=0.85):
|
||||
"""
|
||||
检测场景变化
|
||||
|
||||
Args:
|
||||
frames_info: 帧信息列表
|
||||
method: 比较方法
|
||||
threshold: 相似度阈值
|
||||
|
||||
Returns:
|
||||
scenes: 场景信息列表 [(start_frame, end_frame, start_time, end_time), ...]
|
||||
"""
|
||||
global SCENE_START_FRAMES # 声明使用全局变量
|
||||
|
||||
if len(frames_info) < 2:
|
||||
return []
|
||||
|
||||
scenes = []
|
||||
clips = []
|
||||
scene_start = frames_info[0]
|
||||
|
||||
for i in range(1, len(frames_info)):
|
||||
prev_frame_path = frames_info[i-1][2]
|
||||
curr_frame_path = frames_info[i][2]
|
||||
|
||||
is_similar, similarity = compare_frames(
|
||||
prev_frame_path, curr_frame_path, method, threshold
|
||||
)
|
||||
|
||||
if not is_similar:
|
||||
# 场景变化,记录上一个场景
|
||||
scene_end = frames_info[i-1]
|
||||
scene_duration = scene_end[1] - scene_start[1]
|
||||
clips.append(scene_start[0])
|
||||
# 只记录持续时间超过1秒的场景
|
||||
if scene_duration >= 0.2:
|
||||
scenes.append((
|
||||
scene_start[0], # 开始帧号
|
||||
scene_end[0], # 结束帧号
|
||||
scene_start[1], # 开始时间
|
||||
scene_end[1], # 结束时间
|
||||
scene_duration # 持续时间
|
||||
))
|
||||
|
||||
# 开始新场景
|
||||
scene_start = frames_info[i]
|
||||
|
||||
if VERBOSE:
|
||||
print(f"检测到场景变化点: 帧 {scene_end[0]}, 时间 {timedelta(seconds=scene_end[1])}, 相似度: {similarity:.4f}")
|
||||
|
||||
# 添加最后一个场景
|
||||
scene_end = frames_info[-1]
|
||||
scene_duration = scene_end[1] - scene_start[1]
|
||||
if scene_duration >= 0.2:
|
||||
scenes.append((
|
||||
scene_start[0],
|
||||
scene_end[0],
|
||||
scene_start[1],
|
||||
scene_end[1],
|
||||
scene_duration
|
||||
))
|
||||
|
||||
print(f"\n场景变化统计:")
|
||||
print(f"检测到 {len(scenes)} 个场景, 平均时长: {sum(s[4] for s in scenes)/max(1, len(scenes)):.2f}秒")
|
||||
return scenes , clips
|
||||
|
||||
def extract_video_clips(video_path, scenes, output_dir, ffmpeg_path=None):
|
||||
"""
|
||||
根据场景信息提取视频片段
|
||||
|
||||
Args:
|
||||
video_path: 视频路径
|
||||
scenes: 场景信息列表
|
||||
output_dir: 输出目录
|
||||
ffmpeg_path: ffmpeg可执行文件路径
|
||||
"""
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
# 检查ffmpeg是否可用
|
||||
if ffmpeg_path is None:
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if ffmpeg_path is None:
|
||||
print("错误: 找不到ffmpeg。请安装ffmpeg并确保它在系统路径中。")
|
||||
print("您可以从 https://ffmpeg.org/download.html 下载ffmpeg")
|
||||
print("或使用包管理器安装: apt-get install ffmpeg / brew install ffmpeg 等")
|
||||
return []
|
||||
|
||||
print(f"\n开始切割视频: {video_path}")
|
||||
print(f"输出目录: {output_dir}")
|
||||
print(f"使用ffmpeg: {ffmpeg_path}")
|
||||
print("-" * 60)
|
||||
|
||||
clips_info = []
|
||||
|
||||
for i, scene in enumerate(scenes):
|
||||
start_time = scene[2]
|
||||
end_time = scene[3]
|
||||
duration = scene[4]
|
||||
|
||||
output_file = os.path.join(output_dir, f"clip_{i:03d}_{duration:.2f}s.mp4")
|
||||
|
||||
# 构建ffmpeg命令
|
||||
cmd = [
|
||||
ffmpeg_path,
|
||||
'-i', video_path,
|
||||
'-ss', f"{start_time:.2f}",
|
||||
'-to', f"{end_time:.2f}",
|
||||
'-c:v', 'libx264',
|
||||
'-c:a', 'aac',
|
||||
'-y', # 覆盖已存在的文件
|
||||
output_file
|
||||
]
|
||||
|
||||
try:
|
||||
print(f"\n切割片段 {i+1}/{len(scenes)}:")
|
||||
print(f" 开始时间: {timedelta(seconds=start_time)}")
|
||||
print(f" 结束时间: {timedelta(seconds=end_time)}")
|
||||
print(f" 时长: {duration:.2f}秒")
|
||||
print(f" 输出文件: {os.path.basename(output_file)}")
|
||||
|
||||
if VERBOSE:
|
||||
print(f" 执行命令: {' '.join(cmd)}")
|
||||
|
||||
# 使用subprocess执行命令,可以获取输出和错误
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE if not VERBOSE else None,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# 获取输出文件信息
|
||||
file_size = os.path.getsize(output_file) / (1024 * 1024) # MB
|
||||
print(f" ✓ 切割成功: {os.path.basename(output_file)} ({file_size:.2f} MB)")
|
||||
|
||||
clips_info.append({
|
||||
'index': i,
|
||||
'file': output_file,
|
||||
'start': start_time,
|
||||
'end': end_time,
|
||||
'duration': duration,
|
||||
'size_mb': file_size
|
||||
})
|
||||
else:
|
||||
print(f" ✗ 切割失败: {result.stderr}")
|
||||
except Exception as e:
|
||||
print(f" ✗ 切割失败: {str(e)}")
|
||||
|
||||
# 汇总信息
|
||||
if clips_info:
|
||||
total_size = sum(clip['size_mb'] for clip in clips_info)
|
||||
total_duration = sum(clip['duration'] for clip in clips_info)
|
||||
|
||||
print("\n切割完成汇总:")
|
||||
print(f" 成功切割片段数: {len(clips_info)}/{len(scenes)}")
|
||||
print(f" 总时长: {timedelta(seconds=total_duration)}")
|
||||
print(f" 总文件大小: {total_size:.2f} MB")
|
||||
|
||||
# 列出所有文件
|
||||
print("\n输出文件列表:")
|
||||
for clip in clips_info:
|
||||
print(f" {os.path.basename(clip['file'])} - {clip['duration']:.2f}秒 ({clip['size_mb']:.2f} MB)")
|
||||
else:
|
||||
print("\n没有成功切割任何片段")
|
||||
|
||||
return clips_info
|
||||
|
||||
def get_video_files(directory):
|
||||
"""
|
||||
获取目录中所有视频文件
|
||||
|
||||
Args:
|
||||
directory: 目录路径
|
||||
|
||||
Returns:
|
||||
视频文件路径列表
|
||||
"""
|
||||
video_files = []
|
||||
|
||||
# 检查是否是单个文件
|
||||
if os.path.isfile(directory):
|
||||
ext = os.path.splitext(directory)[1].lower()
|
||||
if ext in VIDEO_EXTENSIONS:
|
||||
return [directory]
|
||||
|
||||
# 遍历目录
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
# 检查文件扩展名
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in VIDEO_EXTENSIONS:
|
||||
video_files.append(os.path.join(root, file))
|
||||
|
||||
return video_files
|
||||
|
||||
def process_video(video_path, output_base_dir, sample_rate, method, threshold, ffmpeg_path):
|
||||
"""
|
||||
处理单个视频文件
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_base_dir: 基础输出目录
|
||||
sample_rate: 帧采样率
|
||||
method: 比较方法
|
||||
threshold: 相似度阈值
|
||||
ffmpeg_path: ffmpeg路径
|
||||
|
||||
Returns:
|
||||
处理是否成功
|
||||
"""
|
||||
# 获取视频文件名(不含扩展名)
|
||||
video_filename = os.path.splitext(os.path.basename(video_path))[0]
|
||||
|
||||
# 为当前视频创建输出目录
|
||||
video_output_dir = os.path.join(output_base_dir, video_filename)
|
||||
if not os.path.exists(video_output_dir):
|
||||
os.makedirs(video_output_dir)
|
||||
|
||||
# 创建输出子目录
|
||||
frames_dir = os.path.join(video_output_dir, 'frames')
|
||||
clips_dir = os.path.join(video_output_dir, 'clips')
|
||||
|
||||
if not os.path.exists(frames_dir):
|
||||
os.makedirs(frames_dir)
|
||||
if not os.path.exists(clips_dir):
|
||||
os.makedirs(clips_dir)
|
||||
|
||||
print("\n处理参数:")
|
||||
print(f"输入视频: {os.path.abspath(video_path)}")
|
||||
print(f"输出目录: {os.path.abspath(video_output_dir)}")
|
||||
print(f"帧采样率: 每{sample_rate}帧")
|
||||
print(f"比较方法: {method}")
|
||||
print(f"相似度阈值: {threshold}")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
# 步骤1: 提取帧
|
||||
print("\n步骤1: 正在提取视频帧...")
|
||||
frames_info = extract_frames(video_path, frames_dir, sample_rate)
|
||||
|
||||
# 步骤2: 检测场景变化
|
||||
print("\n步骤2: 正在检测场景变化...")
|
||||
scenes = detect_scene_changes(frames_info, method, threshold)
|
||||
|
||||
# 输出场景信息
|
||||
print(f"\n检测到 {len(scenes)} 个场景:")
|
||||
for i, scene in enumerate(scenes):
|
||||
start_time = timedelta(seconds=scene[2])
|
||||
end_time = timedelta(seconds=scene[3])
|
||||
duration = scene[4]
|
||||
print(f"场景 {i+1}: {start_time} - {end_time} (时长: {duration:.2f}s)")
|
||||
|
||||
# 如果没有ffmpeg,提前报错
|
||||
if not ffmpeg_path:
|
||||
print("\n错误: 缺少ffmpeg,无法继续视频切割步骤。")
|
||||
print("请安装ffmpeg后重试。")
|
||||
return False
|
||||
|
||||
# 步骤3: 提取视频片段
|
||||
print("\n步骤3: 正在提取视频片段...")
|
||||
clips_info = extract_video_clips(video_path, scenes, clips_dir, ffmpeg_path)
|
||||
|
||||
# 创建结果摘要文件
|
||||
summary_file = os.path.join(video_output_dir, 'summary.txt')
|
||||
try:
|
||||
with open(summary_file, 'w', encoding='utf-8') as f:
|
||||
f.write("智能视频切割结果摘要\n")
|
||||
f.write("=" * 40 + "\n\n")
|
||||
f.write(f"输入视频: {os.path.abspath(video_path)}\n")
|
||||
f.write(f"处理时间: {os.path.getmtime(summary_file)}\n\n")
|
||||
f.write(f"检测到场景数: {len(scenes)}\n")
|
||||
f.write(f"生成片段数: {len(clips_info)}\n\n")
|
||||
|
||||
f.write("片段详情:\n")
|
||||
for i, clip in enumerate(clips_info):
|
||||
f.write(f"{i+1}. {os.path.basename(clip['file'])}\n")
|
||||
f.write(f" 开始: {timedelta(seconds=clip['start'])}\n")
|
||||
f.write(f" 结束: {timedelta(seconds=clip['end'])}\n")
|
||||
f.write(f" 时长: {clip['duration']:.2f}秒\n")
|
||||
f.write(f" 大小: {clip['size_mb']:.2f} MB\n\n")
|
||||
|
||||
print(f"\n已保存处理摘要到: {summary_file}")
|
||||
except Exception as e:
|
||||
print(f"保存摘要文件失败: {str(e)}")
|
||||
|
||||
print("\n处理完成!")
|
||||
print(f"帧提取目录: {os.path.abspath(frames_dir)}")
|
||||
print(f"视频片段目录: {os.path.abspath(clips_dir)}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n处理视频 {video_path} 时发生错误: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_parent_folder_name(path):
|
||||
"""
|
||||
获取路径中 'video' 上一级文件夹的名字
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
# 如果是文件夹,直接用
|
||||
if os.path.isdir(abs_path):
|
||||
parent = os.path.dirname(abs_path.rstrip('/'))
|
||||
folder_name = os.path.basename(parent)
|
||||
else:
|
||||
# 如果是文件,取其父目录的父目录
|
||||
parent = os.path.dirname(os.path.dirname(abs_path))
|
||||
folder_name = os.path.basename(parent)
|
||||
return folder_name
|
||||
|
||||
def main():
|
||||
# 欢迎信息
|
||||
print("=" * 60)
|
||||
print("智能视频切割工具 - 批量处理版")
|
||||
print("=" * 60)
|
||||
|
||||
# 查找ffmpeg
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if ffmpeg_path:
|
||||
print(f"已找到ffmpeg: {ffmpeg_path}")
|
||||
else:
|
||||
print("警告: 未找到ffmpeg,视频切割功能将不可用")
|
||||
print("请安装ffmpeg并确保它在系统路径中")
|
||||
|
||||
# 获取输入目录中的所有视频文件
|
||||
video_files = get_video_files(INPUT_VIDEO_PATH)
|
||||
|
||||
if not video_files:
|
||||
print(f"错误: 在 '{INPUT_VIDEO_PATH}' 中没有找到视频文件")
|
||||
print(f"支持的视频格式: {', '.join(VIDEO_EXTENSIONS)}")
|
||||
return
|
||||
|
||||
# 自动获取video上一级文件夹名
|
||||
parent_folder_name = get_parent_folder_name(INPUT_VIDEO_PATH)
|
||||
output_dir = os.path.join(OUTPUT_DIR, parent_folder_name)
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
print(f"\n输出目录: {output_dir}")
|
||||
|
||||
# 处理每个视频文件
|
||||
successful = 0
|
||||
failed = 0
|
||||
|
||||
for i, video_path in enumerate(video_files):
|
||||
print("\n" + "=" * 60)
|
||||
print(f"正在处理视频 [{i+1}/{len(video_files)}]: {os.path.basename(video_path)}")
|
||||
print("=" * 60)
|
||||
|
||||
success = process_video(
|
||||
video_path=video_path,
|
||||
output_base_dir=output_dir,
|
||||
sample_rate=SAMPLE_RATE,
|
||||
method=METHOD,
|
||||
threshold=THRESHOLD,
|
||||
ffmpeg_path=ffmpeg_path
|
||||
)
|
||||
|
||||
if success:
|
||||
successful += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# 打印批量处理总结
|
||||
print("\n" + "=" * 60)
|
||||
print("批量处理完成!")
|
||||
print("=" * 60)
|
||||
print(f"总共处理: {len(video_files)} 个视频文件")
|
||||
print(f"成功: {successful} 个")
|
||||
print(f"失败: {failed} 个")
|
||||
print(f"输出目录: {os.path.abspath(output_dir)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
BIN
code/__pycache__/VideoSplitter.cpython-312.pyc
Normal file
242
code/client.py
Normal file
@ -0,0 +1,242 @@
|
||||
from vllm import LLM, SamplingParams
|
||||
from transformers import AutoTokenizer
|
||||
from vllm.assets.image import ImageAsset
|
||||
from vllm.assets.video import VideoAsset
|
||||
from openai import OpenAI
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, ClassVar
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
import librosa
|
||||
import librosa.util
|
||||
import os
|
||||
import cv2
|
||||
# import ray # 注释掉Ray导入
|
||||
|
||||
# # 使用本地模式初始化Ray,避免分布式通信问题
|
||||
# ray.init(local_mode=True, ignore_reinit_error=True)
|
||||
|
||||
# 设置环境变量,禁用在线检查
|
||||
os.environ["HF_DATASETS_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoAsset:
|
||||
name: str
|
||||
num_frames: int = -1
|
||||
|
||||
_NAME_TO_FILE: ClassVar[dict[str, str]] = {
|
||||
"baby_reading": "sample_demo_1.mp4",
|
||||
}
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self._NAME_TO_FILE[self.name]
|
||||
|
||||
@property
|
||||
def pil_images(self) -> list[Image.Image]:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return video_to_pil_images_list(video_path, self.num_frames)
|
||||
|
||||
@property
|
||||
def np_ndarrays(self) -> NDArray:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return video_to_ndarrays(video_path, self.num_frames)
|
||||
|
||||
def get_audio(self, sampling_rate: Optional[float] = None) -> NDArray:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return librosa.load(video_path, sr=sampling_rate)[0]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalVideoAsset:
|
||||
local_path: str
|
||||
name: str = "local_video"
|
||||
num_frames: int = -1
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self.local_path
|
||||
|
||||
@property
|
||||
def pil_images(self) -> list[Image.Image]:
|
||||
return video_to_pil_images_list(self.filename, self.num_frames)
|
||||
|
||||
@property
|
||||
def np_ndarrays(self) -> NDArray:
|
||||
return video_to_ndarrays(self.filename, self.num_frames)
|
||||
|
||||
def get_audio(self, sampling_rate: Optional[float] = None) -> NDArray:
|
||||
try:
|
||||
if not os.path.exists(self.filename):
|
||||
print(f"音频文件不存在: {self.filename}")
|
||||
return np.zeros(1) # 返回空数组
|
||||
return librosa.load(self.filename, sr=sampling_rate)[0]
|
||||
except Exception as e:
|
||||
print(f"加载音频时出错: {e}")
|
||||
return np.zeros(1) # 出错时返回空数组
|
||||
|
||||
# 辅助函数实现
|
||||
def download_video_asset(filename: str) -> str:
|
||||
# 如果路径是绝对路径或相对路径,直接返回
|
||||
if filename.startswith("/") or filename.startswith("./"):
|
||||
return filename
|
||||
# 否则执行下载逻辑(原实现)
|
||||
return f"/path/to/downloaded/{filename}"
|
||||
|
||||
def video_to_pil_images_list(video_path: str, num_frames: int) -> list[Image.Image]:
|
||||
"""将视频转换为PIL图像列表"""
|
||||
if not os.path.exists(video_path):
|
||||
print(f"视频文件不存在: {video_path}")
|
||||
return []
|
||||
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
print(f"无法打开视频: {video_path}")
|
||||
return []
|
||||
|
||||
# 获取视频帧数
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
duration = total_frames / fps if fps > 0 else 0
|
||||
|
||||
print(f"视频信息: 总帧数={total_frames}, FPS={fps:.2f}, 时长={duration:.2f}秒")
|
||||
|
||||
# 如果指定了帧数,设置采样间隔;否则读取所有帧
|
||||
if num_frames > 0 and num_frames < total_frames:
|
||||
frame_interval = total_frames / num_frames
|
||||
print(f"将提取 {num_frames} 帧,采样间隔为每 {frame_interval:.2f} 帧")
|
||||
else:
|
||||
frame_interval = 1
|
||||
num_frames = total_frames
|
||||
print(f"将提取所有 {total_frames} 帧")
|
||||
|
||||
pil_images = []
|
||||
frame_count = 0
|
||||
success = True
|
||||
last_progress = -1
|
||||
|
||||
while success and len(pil_images) < num_frames:
|
||||
# 读取下一帧
|
||||
success, frame = cap.read()
|
||||
if not success:
|
||||
break
|
||||
|
||||
# 按间隔采样帧
|
||||
if frame_count % max(1, int(frame_interval)) == 0:
|
||||
# OpenCV使用BGR,转为RGB
|
||||
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
# 转为PIL图像
|
||||
pil_image = Image.fromarray(rgb_frame)
|
||||
pil_images.append(pil_image)
|
||||
|
||||
# 显示进度(每10%显示一次)
|
||||
progress = int(len(pil_images) / num_frames * 10)
|
||||
if progress > last_progress:
|
||||
print(f"提取进度: {len(pil_images)}/{num_frames} ({len(pil_images)/num_frames*100:.1f}%)")
|
||||
last_progress = progress
|
||||
|
||||
frame_count += 1
|
||||
|
||||
cap.release()
|
||||
print(f"从视频中共提取了 {len(pil_images)} 帧")
|
||||
return pil_images
|
||||
|
||||
def video_to_ndarrays(video_path: str, num_frames: int) -> NDArray:
|
||||
"""将视频转换为NumPy数组"""
|
||||
pil_images = video_to_pil_images_list(video_path, num_frames)
|
||||
if not pil_images:
|
||||
print(f"未能从视频中提取帧: {video_path}")
|
||||
return np.zeros((1, 224, 224, 3))
|
||||
|
||||
# 将PIL图像列表转换为NumPy数组
|
||||
arrays = []
|
||||
for img in pil_images:
|
||||
# 调整图像大小为统一尺寸
|
||||
img_resized = img.resize((224, 224))
|
||||
# 转换为NumPy数组
|
||||
arr = np.array(img_resized)
|
||||
arrays.append(arr)
|
||||
|
||||
# 堆叠为单个NumPy数组,形状为[num_frames, height, width, channels]
|
||||
stacked_array = np.stack(arrays, axis=0)
|
||||
print(f"NumPy数组形状: {stacked_array.shape}")
|
||||
return stacked_array
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
# 使用本地视频资源
|
||||
local_video = LocalVideoAsset(
|
||||
local_path="/root/autodl-tmp/hot_vedio_analyse/source/sample_demo_1.mp4",
|
||||
num_frames= 1
|
||||
)
|
||||
print("本地资源:", local_video.filename)
|
||||
|
||||
# 获取PIL图像列表(实际会调用download_video_asset和转换函数)
|
||||
pil_images = local_video.pil_images
|
||||
#print("PIL图像数量:", len(pil_images))
|
||||
|
||||
# 获取NumPy数组
|
||||
#np_arrays = local_video.np_ndarrays
|
||||
|
||||
# 获取音频数据
|
||||
#audio = local_video.get_audio(sampling_rate=16000)
|
||||
#print("音频数据形状:", audio.shape)
|
||||
|
||||
try:
|
||||
print("尝试加载模型...")
|
||||
|
||||
# 模型和分词器路径
|
||||
model_path = "/root/autodl-tmp/llm/Qwen2.5-VL"
|
||||
|
||||
# 使用离线模式加载分词器
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_path,
|
||||
local_files_only=True,
|
||||
trust_remote_code=True
|
||||
)
|
||||
|
||||
# 采样参数
|
||||
sampling_params = SamplingParams(
|
||||
temperature=0.6,
|
||||
top_p=0.95,
|
||||
top_k=20,
|
||||
max_tokens=1024
|
||||
)
|
||||
|
||||
openai_api_key = "EMPTY"
|
||||
openai_api_base = "http://localhost:8000/v1"
|
||||
client = OpenAI(
|
||||
api_key=openai_api_key,
|
||||
base_url=openai_api_base,
|
||||
)
|
||||
completion = client.completions.create(model="Qwen/Qwen2.5-1.5B-Instruct",
|
||||
prompt="San Francisco is a")
|
||||
print("Completion result:", completion)
|
||||
|
||||
prompt = "这个视频展示了什么内容?详细描述一下。"
|
||||
|
||||
# 使用generate而不是generate_videos (如果不存在generate_videos方法)
|
||||
try:
|
||||
# 尝试使用generate_videos
|
||||
outputs = client.completions(prompt, videos=[pil_images], sampling_params=sampling_params)
|
||||
print(outputs[0].outputs[0].text) # 打印模型输出
|
||||
except AttributeError:
|
||||
print("generate_videos方法不可用,尝试使用普通generate方法...")
|
||||
# 如果不支持generate_videos,使用普通的generate
|
||||
outputs = llm.generate([prompt], sampling_params=sampling_params)
|
||||
print(outputs[0].outputs[0].text) # 打印模型输出
|
||||
|
||||
except Exception as e:
|
||||
print(f"模型加载或推理过程中出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
82
code/clip.py
Normal file
@ -0,0 +1,82 @@
|
||||
from VideoSplitter import *
|
||||
import cv2
|
||||
from PIL import Image
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
def get_pil_images_from_frame_numbers(video_path, frame_numbers):
|
||||
"""
|
||||
从视频中读取指定帧号的帧,并转换为PIL图像
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
frame_numbers: 要读取的帧号列表
|
||||
|
||||
Returns:
|
||||
pil_images: PIL图像列表
|
||||
"""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
|
||||
pil_images = []
|
||||
|
||||
print(f"开始读取 {len(frame_numbers)} 个指定帧并转换为PIL图像...")
|
||||
|
||||
for i, frame_number in enumerate(frame_numbers):
|
||||
# 设置视频位置到指定帧
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
|
||||
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
# 将OpenCV的BGR格式转换为RGB格式
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
|
||||
# 转换为PIL图像
|
||||
pil_image = Image.fromarray(frame_rgb)
|
||||
|
||||
pil_images.append(pil_image)
|
||||
|
||||
print(f"已读取并转换帧 {frame_number} ({i+1}/{len(frame_numbers)})")
|
||||
else:
|
||||
print(f"无法读取帧 {frame_number}")
|
||||
|
||||
cap.release()
|
||||
print(f"完成! 共获得了 {len(pil_images)} 个PIL图像")
|
||||
return pil_images
|
||||
|
||||
video_path = "/root/autodl-tmp/hot_video_analyse/source/sample_demo_1.mp4"
|
||||
frames_dir = "/root/autodl-tmp/hot_video_analyse/source/Splitter/hot_video_analyse/sample_demo_1/frames"
|
||||
output_dir = "/root/autodl-tmp/hot_video_analyse/source/pil_images"
|
||||
sample_rate = 1
|
||||
method = "ssim"
|
||||
threshold = 0.8
|
||||
|
||||
# 步骤1: 提取帧
|
||||
print("\n步骤1: 正在提取视频帧...")
|
||||
frames_info = extract_frames(video_path, frames_dir, sample_rate)
|
||||
|
||||
# 步骤2: 检测场景变化
|
||||
print("\n步骤2: 正在检测场景变化...")
|
||||
scenes, a = detect_scene_changes(frames_info, method, threshold)
|
||||
|
||||
print(f"场景开始帧号: {a}")
|
||||
|
||||
# 步骤3: 将场景开始帧转换为PIL图像
|
||||
print("\n步骤3: 转换场景开始帧为PIL图像...")
|
||||
pil_images = get_pil_images_from_frame_numbers(video_path, a)
|
||||
|
||||
# 显示结果
|
||||
print(f"\n成功获得 {len(pil_images)} 个PIL图像")
|
||||
|
||||
# 查看第一个PIL图像的信息
|
||||
if pil_images:
|
||||
first_pil = pil_images[0]
|
||||
pil_output_path = os.path.join(output_dir, f"first_scene_frame_{a[0]}.jpg")
|
||||
first_pil.save(pil_output_path, quality=95)
|
||||
print(f"第一个PIL图像信息:")
|
||||
print(f" 尺寸: {first_pil.size}")
|
||||
print(f" 模式: {first_pil.mode}")
|
||||
"""
|
||||
视频帧
|
||||
音频转文字
|
||||
全视频
|
||||
"""
|
107
code/npy2jpg.py
Normal file
@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import os
|
||||
import sys
|
||||
|
||||
def npy_to_images(npy_file):
|
||||
"""
|
||||
将NumPy文件转换为图片
|
||||
|
||||
Args:
|
||||
npy_file: .npy文件路径
|
||||
"""
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(npy_file):
|
||||
print(f"错误: 文件不存在 - {npy_file}")
|
||||
return
|
||||
|
||||
# 加载NumPy数组
|
||||
print(f"加载文件: {npy_file}")
|
||||
arrays = np.load(npy_file)
|
||||
print(f"数组形状: {arrays.shape}")
|
||||
print(f"数据类型: {arrays.dtype}")
|
||||
|
||||
# 创建输出目录
|
||||
output_dir = f"images_{os.path.splitext(os.path.basename(npy_file))[0]}"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# 数据类型转换
|
||||
if arrays.dtype == np.float32 or arrays.dtype == np.float64:
|
||||
if arrays.max() <= 1.0:
|
||||
# 范围 [0, 1],转换为 [0, 255]
|
||||
arrays = (arrays * 255).astype(np.uint8)
|
||||
else:
|
||||
# 范围可能是 [0, 255] 的浮点数
|
||||
arrays = np.clip(arrays, 0, 255).astype(np.uint8)
|
||||
else:
|
||||
arrays = arrays.astype(np.uint8)
|
||||
|
||||
# 根据数组维度处理
|
||||
if len(arrays.shape) == 4:
|
||||
# 4D数组: 多张图片
|
||||
print(f"检测到4D数组,转换 {arrays.shape[0]} 张图片")
|
||||
|
||||
for i in range(arrays.shape[0]):
|
||||
frame = arrays[i]
|
||||
|
||||
# 处理通道顺序 (CHW -> HWC)
|
||||
if frame.shape[0] == 3 or frame.shape[0] == 1:
|
||||
frame = np.transpose(frame, (1, 2, 0))
|
||||
|
||||
# 保存图片
|
||||
if len(frame.shape) == 3 and frame.shape[2] == 1:
|
||||
frame = frame.squeeze(axis=2) # 移除单通道维度
|
||||
|
||||
img = Image.fromarray(frame)
|
||||
filename = f"{output_dir}/image_{i+1:04d}.jpg"
|
||||
img.save(filename)
|
||||
|
||||
if (i + 1) % 10 == 0 or i == 0:
|
||||
print(f"已保存: {filename}")
|
||||
|
||||
elif len(arrays.shape) == 3:
|
||||
# 3D数组: 单张图片或多张灰度图
|
||||
if arrays.shape[2] == 3 or arrays.shape[2] == 1:
|
||||
# 单张彩色或灰度图片 (H, W, C)
|
||||
print("检测到3D数组,转换为单张图片")
|
||||
if arrays.shape[2] == 1:
|
||||
arrays = arrays.squeeze(axis=2)
|
||||
img = Image.fromarray(arrays)
|
||||
filename = f"{output_dir}/image.jpg"
|
||||
img.save(filename)
|
||||
print(f"已保存: {filename}")
|
||||
else:
|
||||
# 多张灰度图片 (N, H, W)
|
||||
print(f"检测到3D数组,转换 {arrays.shape[0]} 张灰度图片")
|
||||
for i in range(arrays.shape[0]):
|
||||
img = Image.fromarray(arrays[i])
|
||||
filename = f"{output_dir}/image_{i+1:04d}.jpg"
|
||||
img.save(filename)
|
||||
if (i + 1) % 10 == 0 or i == 0:
|
||||
print(f"已保存: {filename}")
|
||||
|
||||
elif len(arrays.shape) == 2:
|
||||
# 2D数组: 单张灰度图片
|
||||
print("检测到2D数组,转换为灰度图片")
|
||||
img = Image.fromarray(arrays)
|
||||
filename = f"{output_dir}/image.jpg"
|
||||
img.save(filename)
|
||||
print(f"已保存: {filename}")
|
||||
|
||||
else:
|
||||
print(f"不支持的数组维度: {arrays.shape}")
|
||||
return
|
||||
|
||||
print(f"所有图片已保存到目录: {output_dir}")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
npy_file = "/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_arrays.npy"
|
||||
npy_to_images(npy_file)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
280
code/qwen.py
Normal file
@ -0,0 +1,280 @@
|
||||
from vllm import LLM, SamplingParams
|
||||
from transformers import AutoTokenizer
|
||||
from vllm.assets.image import ImageAsset
|
||||
from vllm.assets.video import VideoAsset
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, ClassVar, List, Tuple
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
import librosa
|
||||
import librosa.util
|
||||
import cv2
|
||||
import os
|
||||
from openai import OpenAI
|
||||
import base64
|
||||
# import ray # 注释掉Ray导入
|
||||
|
||||
# # 使用本地模式初始化Ray,避免分布式通信问题
|
||||
# ray.init(local_mode=True, ignore_reinit_error=True)
|
||||
|
||||
# 设置环境变量,禁用在线检查
|
||||
os.environ["HF_DATASETS_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoAsset:
|
||||
name: str
|
||||
num_frames: int = -1
|
||||
|
||||
_NAME_TO_FILE: ClassVar[dict[str, str]] = {
|
||||
"baby_reading": "sample_demo_1.mp4",
|
||||
}
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self._NAME_TO_FILE[self.name]
|
||||
|
||||
@property
|
||||
def pil_images(self) -> list[Image.Image]:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return video_to_pil_images_list(video_path, self.num_frames)
|
||||
|
||||
@property
|
||||
def np_ndarrays(self) -> NDArray:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return video_to_ndarrays(video_path, self.num_frames)
|
||||
|
||||
def get_audio(self, sampling_rate: Optional[float] = None) -> NDArray:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return librosa.load(video_path, sr=sampling_rate)[0]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalVideoAsset:
|
||||
local_path: str
|
||||
name: str = "local_video"
|
||||
num_frames: int = -1
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self.local_path
|
||||
|
||||
@property
|
||||
def pil_images(self) -> list[Image.Image]:
|
||||
return video_to_pil_images_list(self.filename, self.num_frames)
|
||||
|
||||
@property
|
||||
def np_ndarrays(self) -> NDArray:
|
||||
return video_to_ndarrays(self.filename, self.num_frames)
|
||||
|
||||
def get_audio(self, sampling_rate: Optional[float] = None) -> NDArray:
|
||||
try:
|
||||
if not os.path.exists(self.filename):
|
||||
print(f"音频文件不存在: {self.filename}")
|
||||
return np.zeros(1) # 返回空数组
|
||||
return librosa.load(self.filename, sr=sampling_rate)[0]
|
||||
except Exception as e:
|
||||
print(f"加载音频时出错: {e}")
|
||||
return np.zeros(1) # 出错时返回空数组
|
||||
|
||||
# 辅助函数实现
|
||||
def download_video_asset(filename: str) -> str:
|
||||
# 如果路径是绝对路径或相对路径,直接返回
|
||||
if filename.startswith("/") or filename.startswith("./"):
|
||||
return filename
|
||||
# 否则执行下载逻辑(原实现)
|
||||
return f"/path/to/downloaded/{filename}"
|
||||
|
||||
def video_to_pil_images_list(video_path: str, num_frames: int) -> list[Image.Image]:
|
||||
"""将视频转换为PIL图像列表"""
|
||||
if not os.path.exists(video_path):
|
||||
print(f"视频文件不存在: {video_path}")
|
||||
return []
|
||||
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
print(f"无法打开视频: {video_path}")
|
||||
return []
|
||||
|
||||
# 获取视频帧数
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
duration = total_frames / fps if fps > 0 else 0
|
||||
|
||||
print(f"视频信息: 总帧数={total_frames}, FPS={fps:.2f}, 时长={duration:.2f}秒")
|
||||
|
||||
# 如果指定了帧数,设置采样间隔;否则读取所有帧
|
||||
if num_frames > 0 and num_frames < total_frames:
|
||||
frame_interval = total_frames / num_frames
|
||||
print(f"将提取 {num_frames} 帧,采样间隔为每 {frame_interval:.2f} 帧")
|
||||
else:
|
||||
frame_interval = 1
|
||||
num_frames = total_frames
|
||||
print(f"将提取所有 {total_frames} 帧")
|
||||
|
||||
pil_images = []
|
||||
frame_count = 0
|
||||
success = True
|
||||
last_progress = -1
|
||||
|
||||
while success and len(pil_images) < num_frames:
|
||||
# 读取下一帧
|
||||
success, frame = cap.read()
|
||||
if not success:
|
||||
break
|
||||
|
||||
# 按间隔采样帧
|
||||
if frame_count % max(1, int(frame_interval)) == 0:
|
||||
# OpenCV使用BGR,转为RGB
|
||||
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
# 转为PIL图像
|
||||
pil_image = Image.fromarray(rgb_frame)
|
||||
pil_images.append(pil_image)
|
||||
|
||||
# 显示进度(每10%显示一次)
|
||||
progress = int(len(pil_images) / num_frames * 10)
|
||||
if progress > last_progress:
|
||||
print(f"提取进度: {len(pil_images)}/{num_frames} ({len(pil_images)/num_frames*100:.1f}%)")
|
||||
last_progress = progress
|
||||
|
||||
frame_count += 1
|
||||
|
||||
cap.release()
|
||||
print(f"从视频中共提取了 {len(pil_images)} 帧")
|
||||
return pil_images
|
||||
|
||||
def video_to_ndarrays(video_path: str, num_frames: int) -> NDArray:
|
||||
"""将视频转换为NumPy数组"""
|
||||
pil_images = video_to_pil_images_list(video_path, num_frames)
|
||||
if not pil_images:
|
||||
print(f"未能从视频中提取帧: {video_path}")
|
||||
return np.zeros((1, 224, 224, 3))
|
||||
|
||||
# 将PIL图像列表转换为NumPy数组
|
||||
arrays = []
|
||||
for img in pil_images:
|
||||
# 调整图像大小为统一尺寸
|
||||
img_resized = img.resize((224, 224))
|
||||
# 转换为NumPy数组
|
||||
arr = np.array(img_resized)
|
||||
arrays.append(arr)
|
||||
|
||||
# 堆叠为单个NumPy数组,形状为[num_frames, height, width, channels]
|
||||
stacked_array = np.stack(arrays, axis=0)
|
||||
print(f"NumPy数组形状: {stacked_array.shape}")
|
||||
return stacked_array
|
||||
|
||||
def encode_image(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
return base64.b64encode(image_file.read()).decode("utf-8")
|
||||
|
||||
def encode_audio(audio_path):
|
||||
with open(audio_path, "rb") as audio_file:
|
||||
return base64.b64encode(audio_file.read()).decode("utf-8")
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
# 使用本地视频资源
|
||||
# local_video = LocalVideoAsset(
|
||||
# local_path="/root/autodl-tmp/hot_video_analyse/source/sample_demo_1.mp4",
|
||||
# num_frames = -1 # 限制帧数以加快测试速度
|
||||
# )
|
||||
# print("本地资源:", local_video.filename)
|
||||
|
||||
# 获取PIL图像列表(实际会调用download_video_asset和转换函数)
|
||||
# pil_images = local_video.pil_images
|
||||
# print("PIL图像数量:", len(pil_images))
|
||||
# print(pil_images[0])
|
||||
|
||||
# 获取NumPy数组
|
||||
# print("\n=== 加载视频帧 ===")
|
||||
# np_arrays = local_video.np_ndarrays
|
||||
# print(f"视频数组形状: {np_arrays.shape}")
|
||||
# scene_change_arrays = np.load('/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_arrays.npy')
|
||||
# print(f"场景变化帧数组形状: {scene_change_arrays.shape}")
|
||||
|
||||
# 获取音频数据
|
||||
# audio = local_video.get_audio(sampling_rate=16000)
|
||||
# print("音频数据形状:", audio.shape)
|
||||
# print(type(audio))
|
||||
base64_audio = encode_audio("/root/autodl-tmp/hot_video_analyse/source/transcription/sample_demo_1_audio.wav")
|
||||
client = OpenAI(
|
||||
# 若没有配置环境变量,请用阿里云百炼API Key将下行替换为:api_key="sk-xxx",
|
||||
api_key=os.getenv("DASHSCOPE_API_KEY"),
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
)
|
||||
|
||||
base64_image_0 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000034_t1.13s_ssim0.5673.jpg")
|
||||
base64_image_1 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000117_t3.90s_ssim0.1989.jpg")
|
||||
base64_image_2 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000119_t3.97s_ssim0.2138.jpg")
|
||||
base64_image_3 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000140_t4.67s_ssim0.5160.jpg")
|
||||
base64_image_4 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000160_t5.33s_ssim0.4934.jpg")
|
||||
base64_image_5 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000180_t6.00s_ssim0.3577.jpg")
|
||||
base64_image_6 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000201_t6.70s_ssim0.3738.jpg")
|
||||
base64_image_7 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000222_t7.40s_ssim0.6104.jpg")
|
||||
base64_image_8 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000243_t8.10s_ssim0.5099.jpg")
|
||||
base64_image_9 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000261_t8.70s_ssim0.4735.jpg")
|
||||
base64_image_10 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000281_t9.37s_ssim0.2703.jpg")
|
||||
base64_image_11 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000301_t10.03s_ssim0.2772.jpg")
|
||||
base64_image_12 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000321_t10.70s_ssim0.3721.jpg")
|
||||
base64_image_13 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000341_t11.37s_ssim0.3700.jpg")
|
||||
base64_image_14 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000361_t12.03s_ssim0.3494.jpg")
|
||||
base64_image_15 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000382_t12.73s_ssim0.3423.jpg")
|
||||
base64_image_16 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000402_t13.40s_ssim0.3252.jpg")
|
||||
base64_image_17 = encode_image("/root/autodl-tmp/hot_video_analyse/source/scene_change/scene_change_frames/scene_change_000421_t14.03s_ssim0.2086.jpg")
|
||||
|
||||
|
||||
completion = client.chat.completions.create(
|
||||
model="/root/autodl-tmp/llm/Qwen2.5-Omni",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "video",
|
||||
"video": [
|
||||
f"data:image/jpeg;base64,{base64_image_1}",
|
||||
f"data:image/jpeg;base64,{base64_image_2}",
|
||||
f"data:image/jpeg;base64,{base64_image_3}",
|
||||
f"data:image/jpeg;base64,{base64_image_4}",
|
||||
f"data:image/jpeg;base64,{base64_image_5}",
|
||||
f"data:image/jpeg;base64,{base64_image_6}",
|
||||
f"data:image/jpeg;base64,{base64_image_7}",
|
||||
f"data:image/jpeg;base64,{base64_image_8}",
|
||||
f"data:image/jpeg;base64,{base64_image_9}",
|
||||
f"data:image/jpeg;base64,{base64_image_10}",
|
||||
f"data:image/jpeg;base64,{base64_image_11}",
|
||||
f"data:image/jpeg;base64,{base64_image_12}",
|
||||
f"data:image/jpeg;base64,{base64_image_13}",
|
||||
f"data:image/jpeg;base64,{base64_image_14}",
|
||||
f"data:image/jpeg;base64,{base64_image_15}",
|
||||
f"data:image/jpeg;base64,{base64_image_16}",
|
||||
f"data:image/jpeg;base64,{base64_image_17}",
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "audio",
|
||||
"audio": f"data:audio/mpeg;base64,{base64_audio}",
|
||||
"format": "wav",
|
||||
},
|
||||
{"type": "text", "text": "描述这个视频的具体过程"},
|
||||
],
|
||||
}
|
||||
],
|
||||
# 设置输出数据的模态,当前支持两种:["text","audio"]、["text"]
|
||||
modalities=["text"],
|
||||
#audio={"voice": "Cherry", "format": "wav"},
|
||||
# stream 必须设置为 True,否则会报错
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
|
||||
for chunk in completion:
|
||||
if chunk.choices:
|
||||
print(chunk.choices[0].delta)
|
||||
else:
|
||||
print(chunk.usage)
|
||||
|
||||
|
||||
|
540
code/video_split.py
Normal file
@ -0,0 +1,540 @@
|
||||
from vllm import LLM, SamplingParams
|
||||
from transformers import AutoTokenizer
|
||||
from vllm.assets.image import ImageAsset
|
||||
from vllm.assets.video import VideoAsset
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, ClassVar, List, Tuple
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
import librosa
|
||||
import librosa.util
|
||||
import os
|
||||
import cv2
|
||||
from skimage.metrics import structural_similarity as ssim
|
||||
# import ray # 注释掉Ray导入
|
||||
|
||||
# # 使用本地模式初始化Ray,避免分布式通信问题
|
||||
# ray.init(local_mode=True, ignore_reinit_error=True)
|
||||
|
||||
# 设置环境变量,禁用在线检查
|
||||
os.environ["HF_DATASETS_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoAsset:
|
||||
name: str
|
||||
num_frames: int = -1
|
||||
|
||||
_NAME_TO_FILE: ClassVar[dict[str, str]] = {
|
||||
"baby_reading": "sample_demo_1.mp4",
|
||||
}
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self._NAME_TO_FILE[self.name]
|
||||
|
||||
@property
|
||||
def pil_images(self) -> list[Image.Image]:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return video_to_pil_images_list(video_path, self.num_frames)
|
||||
|
||||
@property
|
||||
def np_ndarrays(self) -> NDArray:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return video_to_ndarrays(video_path, self.num_frames)
|
||||
|
||||
def get_audio(self, sampling_rate: Optional[float] = None) -> NDArray:
|
||||
video_path = download_video_asset(self.filename)
|
||||
return librosa.load(video_path, sr=sampling_rate)[0]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalVideoAsset:
|
||||
local_path: str
|
||||
name: str = "local_video"
|
||||
num_frames: int = -1
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self.local_path
|
||||
|
||||
@property
|
||||
def pil_images(self) -> list[Image.Image]:
|
||||
return video_to_pil_images_list(self.filename, self.num_frames)
|
||||
|
||||
@property
|
||||
def np_ndarrays(self) -> NDArray:
|
||||
return video_to_ndarrays(self.filename, self.num_frames)
|
||||
|
||||
def get_audio(self, sampling_rate: Optional[float] = None) -> NDArray:
|
||||
try:
|
||||
if not os.path.exists(self.filename):
|
||||
print(f"音频文件不存在: {self.filename}")
|
||||
return np.zeros(1) # 返回空数组
|
||||
return librosa.load(self.filename, sr=sampling_rate)[0]
|
||||
except Exception as e:
|
||||
print(f"加载音频时出错: {e}")
|
||||
return np.zeros(1) # 出错时返回空数组
|
||||
|
||||
# 辅助函数实现
|
||||
def download_video_asset(filename: str) -> str:
|
||||
# 如果路径是绝对路径或相对路径,直接返回
|
||||
if filename.startswith("/") or filename.startswith("./"):
|
||||
return filename
|
||||
# 否则执行下载逻辑(原实现)
|
||||
return f"/path/to/downloaded/{filename}"
|
||||
|
||||
def video_to_pil_images_list(video_path: str, num_frames: int) -> list[Image.Image]:
|
||||
"""将视频转换为PIL图像列表"""
|
||||
if not os.path.exists(video_path):
|
||||
print(f"视频文件不存在: {video_path}")
|
||||
return []
|
||||
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
print(f"无法打开视频: {video_path}")
|
||||
return []
|
||||
|
||||
# 获取视频帧数
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
duration = total_frames / fps if fps > 0 else 0
|
||||
|
||||
print(f"视频信息: 总帧数={total_frames}, FPS={fps:.2f}, 时长={duration:.2f}秒")
|
||||
|
||||
# 如果指定了帧数,设置采样间隔;否则读取所有帧
|
||||
if num_frames > 0 and num_frames < total_frames:
|
||||
frame_interval = total_frames / num_frames
|
||||
print(f"将提取 {num_frames} 帧,采样间隔为每 {frame_interval:.2f} 帧")
|
||||
else:
|
||||
frame_interval = 1
|
||||
num_frames = total_frames
|
||||
print(f"将提取所有 {total_frames} 帧")
|
||||
|
||||
pil_images = []
|
||||
frame_count = 0
|
||||
success = True
|
||||
last_progress = -1
|
||||
|
||||
while success and len(pil_images) < num_frames:
|
||||
# 读取下一帧
|
||||
success, frame = cap.read()
|
||||
if not success:
|
||||
break
|
||||
|
||||
# 按间隔采样帧
|
||||
if frame_count % max(1, int(frame_interval)) == 0:
|
||||
# OpenCV使用BGR,转为RGB
|
||||
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
# 转为PIL图像
|
||||
pil_image = Image.fromarray(rgb_frame)
|
||||
pil_images.append(pil_image)
|
||||
|
||||
# 显示进度(每10%显示一次)
|
||||
progress = int(len(pil_images) / num_frames * 10)
|
||||
if progress > last_progress:
|
||||
print(f"提取进度: {len(pil_images)}/{num_frames} ({len(pil_images)/num_frames*100:.1f}%)")
|
||||
last_progress = progress
|
||||
|
||||
frame_count += 1
|
||||
|
||||
cap.release()
|
||||
print(f"从视频中共提取了 {len(pil_images)} 帧")
|
||||
return pil_images
|
||||
|
||||
def video_to_ndarrays(video_path: str, num_frames: int) -> NDArray:
|
||||
"""将视频转换为NumPy数组"""
|
||||
pil_images = video_to_pil_images_list(video_path, num_frames)
|
||||
if not pil_images:
|
||||
print(f"未能从视频中提取帧: {video_path}")
|
||||
return np.zeros((1, 224, 224, 3))
|
||||
|
||||
# 将PIL图像列表转换为NumPy数组
|
||||
arrays = []
|
||||
for img in pil_images:
|
||||
# 调整图像大小为统一尺寸
|
||||
img_resized = img.resize((224, 224))
|
||||
# 转换为NumPy数组
|
||||
arr = np.array(img_resized)
|
||||
arrays.append(arr)
|
||||
|
||||
# 堆叠为单个NumPy数组,形状为[num_frames, height, width, channels]
|
||||
stacked_array = np.stack(arrays, axis=0)
|
||||
print(f"NumPy数组形状: {stacked_array.shape}")
|
||||
return stacked_array
|
||||
|
||||
@dataclass
|
||||
class SceneChangeFrame:
|
||||
"""场景变化帧信息"""
|
||||
frame_index: int
|
||||
timestamp: float
|
||||
ssim_score: float
|
||||
is_scene_change: bool
|
||||
|
||||
def calculate_ssim_between_frames(frame1: np.ndarray, frame2: np.ndarray) -> float:
|
||||
"""
|
||||
计算两帧之间的SSIM相似度
|
||||
|
||||
Args:
|
||||
frame1: 第一帧图像 (H, W, C)
|
||||
frame2: 第二帧图像 (H, W, C)
|
||||
|
||||
Returns:
|
||||
SSIM相似度分数 (0-1之间)
|
||||
"""
|
||||
try:
|
||||
# 转换为灰度图像以提高计算效率
|
||||
if len(frame1.shape) == 3:
|
||||
gray1 = cv2.cvtColor(frame1, cv2.COLOR_RGB2GRAY)
|
||||
else:
|
||||
gray1 = frame1
|
||||
|
||||
if len(frame2.shape) == 3:
|
||||
gray2 = cv2.cvtColor(frame2, cv2.COLOR_RGB2GRAY)
|
||||
else:
|
||||
gray2 = frame2
|
||||
|
||||
# 确保两个图像尺寸相同
|
||||
if gray1.shape != gray2.shape:
|
||||
gray2 = cv2.resize(gray2, (gray1.shape[1], gray1.shape[0]))
|
||||
|
||||
# 计算SSIM
|
||||
score = ssim(gray1, gray2, data_range=255)
|
||||
return score
|
||||
except Exception as e:
|
||||
print(f"计算SSIM时出错: {e}")
|
||||
return 1.0 # 出错时返回高相似度
|
||||
|
||||
def detect_scene_changes_from_arrays(video_arrays: np.ndarray,
|
||||
similarity_threshold: float = 0.8,
|
||||
fps: float = 30.0) -> List[SceneChangeFrame]:
|
||||
"""
|
||||
从视频NumPy数组中检测场景变化
|
||||
|
||||
Args:
|
||||
video_arrays: 视频帧数组,形状为 (num_frames, height, width, channels)
|
||||
similarity_threshold: SSIM相似度阈值,低于此值认为是场景变化
|
||||
fps: 视频帧率,用于计算时间戳
|
||||
|
||||
Returns:
|
||||
场景变化帧列表
|
||||
"""
|
||||
if len(video_arrays) < 2:
|
||||
print("视频帧数不足,无法进行场景变化检测")
|
||||
return []
|
||||
|
||||
print(f"开始检测场景变化...")
|
||||
print(f"视频帧数: {len(video_arrays)}")
|
||||
print(f"帧尺寸: {video_arrays.shape[1:3]}")
|
||||
print(f"SSIM相似度阈值: {similarity_threshold}")
|
||||
print(f"视频帧率: {fps} FPS")
|
||||
|
||||
scene_changes = []
|
||||
|
||||
# 逐帧比较相似度
|
||||
for i in range(1, len(video_arrays)):
|
||||
# 获取前后两帧
|
||||
prev_frame = video_arrays[i-1]
|
||||
curr_frame = video_arrays[i]
|
||||
|
||||
# 计算SSIM相似度
|
||||
ssim_score = calculate_ssim_between_frames(prev_frame, curr_frame)
|
||||
|
||||
# 计算时间戳
|
||||
timestamp = i / fps
|
||||
|
||||
# 判断是否为场景变化
|
||||
is_scene_change = ssim_score < similarity_threshold
|
||||
|
||||
# 创建场景变化信息
|
||||
scene_change = SceneChangeFrame(
|
||||
frame_index=i,
|
||||
timestamp=timestamp,
|
||||
ssim_score=ssim_score,
|
||||
is_scene_change=is_scene_change
|
||||
)
|
||||
|
||||
scene_changes.append(scene_change)
|
||||
|
||||
# 输出场景变化信息
|
||||
if is_scene_change:
|
||||
print(f"场景变化检测到! 帧号: {i}, 时间: {timestamp:.2f}s, SSIM: {ssim_score:.4f}")
|
||||
|
||||
# 显示进度
|
||||
if i % 100 == 0:
|
||||
progress = i / len(video_arrays) * 100
|
||||
print(f"检测进度: {i}/{len(video_arrays)} ({progress:.1f}%)")
|
||||
|
||||
# 统计结果
|
||||
scene_change_count = sum(1 for sc in scene_changes if sc.is_scene_change)
|
||||
print(f"\n检测完成!")
|
||||
print(f"总比较帧数: {len(scene_changes)}")
|
||||
print(f"场景变化数量: {scene_change_count}")
|
||||
|
||||
return scene_changes
|
||||
|
||||
def get_scene_change_frames(scene_changes: List[SceneChangeFrame]) -> List[SceneChangeFrame]:
|
||||
"""
|
||||
获取所有场景变化帧(相似度低于阈值的帧)
|
||||
|
||||
Args:
|
||||
scene_changes: 场景变化检测结果列表
|
||||
|
||||
Returns:
|
||||
场景变化帧列表
|
||||
"""
|
||||
return [sc for sc in scene_changes if sc.is_scene_change]
|
||||
|
||||
def save_scene_change_frames_from_arrays(video_arrays: np.ndarray,
|
||||
scene_change_frames: List[SceneChangeFrame],
|
||||
output_dir: str = "scene_change_frames"):
|
||||
"""
|
||||
保存场景变化帧为图像文件
|
||||
|
||||
Args:
|
||||
video_arrays: 视频帧数组
|
||||
scene_change_frames: 场景变化帧列表
|
||||
output_dir: 输出目录
|
||||
"""
|
||||
if not scene_change_frames:
|
||||
print("没有检测到场景变化帧")
|
||||
return
|
||||
|
||||
# 创建输出目录
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
print(f"保存 {len(scene_change_frames)} 个场景变化帧到 {output_dir}")
|
||||
|
||||
for i, scene_change in enumerate(scene_change_frames):
|
||||
frame_index = scene_change.frame_index
|
||||
|
||||
if frame_index < len(video_arrays):
|
||||
# 获取帧图像
|
||||
frame = video_arrays[frame_index]
|
||||
|
||||
# 转换为PIL图像并保存
|
||||
pil_image = Image.fromarray(frame.astype(np.uint8))
|
||||
filename = f"scene_change_{frame_index:06d}_t{scene_change.timestamp:.2f}s_ssim{scene_change.ssim_score:.4f}.jpg"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
pil_image.save(filepath)
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
print(f"已保存 {i + 1}/{len(scene_change_frames)} 帧")
|
||||
|
||||
print(f"所有场景变化帧已保存到: {output_dir}")
|
||||
|
||||
def export_scene_change_results(scene_change_frames: List[SceneChangeFrame],
|
||||
similarity_threshold: float,
|
||||
output_file: str = "scene_change_results.txt"):
|
||||
"""
|
||||
导出场景变化检测结果到文件
|
||||
|
||||
Args:
|
||||
scene_change_frames: 场景变化帧列表
|
||||
similarity_threshold: 使用的相似度阈值
|
||||
output_file: 输出文件路径
|
||||
"""
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"场景变化检测结果\n")
|
||||
f.write(f"相似度阈值: {similarity_threshold}\n")
|
||||
f.write(f"场景变化数量: {len(scene_change_frames)}\n")
|
||||
f.write("-" * 50 + "\n")
|
||||
|
||||
for sc in scene_change_frames:
|
||||
f.write(f"帧号: {sc.frame_index:6d}, "
|
||||
f"时间: {sc.timestamp:8.2f}s, "
|
||||
f"SSIM: {sc.ssim_score:.6f}\n")
|
||||
|
||||
print(f"检测结果已导出到: {output_file}")
|
||||
|
||||
def get_scene_change_frames_arrays(video_arrays: np.ndarray,
|
||||
scene_change_frames: List[SceneChangeFrame]) -> np.ndarray:
|
||||
"""
|
||||
获取场景变化帧对应的NumPy数组集合
|
||||
|
||||
Args:
|
||||
video_arrays: 完整的视频帧数组,形状为 (num_frames, height, width, channels)
|
||||
scene_change_frames: 场景变化帧列表
|
||||
|
||||
Returns:
|
||||
场景变化帧的NumPy数组,形状为 (num_scene_changes, height, width, channels)
|
||||
"""
|
||||
if not scene_change_frames:
|
||||
print("没有场景变化帧")
|
||||
return np.array([])
|
||||
|
||||
# 提取场景变化帧的索引
|
||||
scene_change_indices = [sc.frame_index for sc in scene_change_frames]
|
||||
|
||||
# 确保索引在有效范围内
|
||||
valid_indices = [idx for idx in scene_change_indices if 0 <= idx < len(video_arrays)]
|
||||
|
||||
if not valid_indices:
|
||||
print("没有有效的场景变化帧索引")
|
||||
return np.array([])
|
||||
|
||||
# 提取对应的帧数组
|
||||
scene_change_arrays = video_arrays[valid_indices]
|
||||
|
||||
print(f"提取了 {len(valid_indices)} 个场景变化帧的NumPy数组")
|
||||
print(f"场景变化帧数组形状: {scene_change_arrays.shape}")
|
||||
|
||||
return scene_change_arrays
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
# 使用本地视频资源
|
||||
local_video = LocalVideoAsset(
|
||||
local_path="/root/autodl-tmp/hot_video_analyse/source/sample_demo_1.mp4",
|
||||
num_frames = -1 # 限制帧数以加快测试速度
|
||||
)
|
||||
print("本地资源:", local_video.filename)
|
||||
|
||||
# 获取PIL图像列表(实际会调用download_video_asset和转换函数)
|
||||
# pil_images = local_video.pil_images
|
||||
# print("PIL图像数量:", len(pil_images))
|
||||
# print(pil_images[0])
|
||||
|
||||
# 获取NumPy数组
|
||||
print("\n=== 加载视频帧 ===")
|
||||
np_arrays = local_video.np_ndarrays
|
||||
print(f"视频数组形状: {np_arrays.shape}")
|
||||
|
||||
# 设置检测参数
|
||||
similarity_threshold = 0.7 # SSIM相似度阈值
|
||||
fps = 30.0 # 假设帧率为30fps,可以根据实际视频调整
|
||||
|
||||
print(f"\n=== 开始场景变化检测 ===")
|
||||
print(f"相似度阈值: {similarity_threshold}")
|
||||
|
||||
# 检测场景变化
|
||||
scene_changes = detect_scene_changes_from_arrays(
|
||||
video_arrays=np_arrays,
|
||||
similarity_threshold=similarity_threshold,
|
||||
fps=fps
|
||||
)
|
||||
|
||||
# 获取场景变化帧(相似度低于阈值的帧)
|
||||
scene_change_frames = get_scene_change_frames(scene_changes)
|
||||
|
||||
# 获取场景变化帧对应的NumPy数组集合
|
||||
scene_change_arrays = get_scene_change_frames_arrays(np_arrays, scene_change_frames)
|
||||
|
||||
print(f"\n=== 场景变化帧NumPy数组信息 ===")
|
||||
if len(scene_change_arrays) > 0:
|
||||
print(f"场景变化帧数组形状: {scene_change_arrays.shape}")
|
||||
print(f"数据类型: {scene_change_arrays.dtype}")
|
||||
print(f"数组大小: {scene_change_arrays.nbytes / (1024*1024):.2f} MB")
|
||||
|
||||
# 获取详细信息(包含帧信息和数组)
|
||||
print(f"\n=== 检测结果摘要 ===")
|
||||
print(f"低于阈值 {similarity_threshold} 的帧数: {len(scene_change_frames)}")
|
||||
|
||||
if scene_change_frames:
|
||||
print(f"\n所有场景变化帧:")
|
||||
for i, sc in enumerate(scene_change_frames):
|
||||
print(f"{i+1:3d}. 帧号: {sc.frame_index:6d}, "
|
||||
f"时间: {sc.timestamp:7.2f}s, "
|
||||
f"SSIM: {sc.ssim_score:.6f}")
|
||||
|
||||
# 保存场景变化帧图像
|
||||
print(f"\n=== 保存场景变化帧 ===")
|
||||
save_scene_change_frames_from_arrays(
|
||||
video_arrays=np_arrays,
|
||||
scene_change_frames=scene_change_frames,
|
||||
output_dir="scene_change_frames"
|
||||
)
|
||||
|
||||
# 导出结果到文件
|
||||
export_scene_change_results(
|
||||
scene_change_frames=scene_change_frames,
|
||||
similarity_threshold=similarity_threshold,
|
||||
output_file="scene_change_results.txt"
|
||||
)
|
||||
|
||||
# 演示如何使用场景变化帧的NumPy数组
|
||||
print(f"\n=== 场景变化帧NumPy数组使用示例 ===")
|
||||
print(f"场景变化帧数组变量名: scene_change_arrays")
|
||||
print(f"可以直接使用这个数组进行进一步处理,例如:")
|
||||
print(f"- 输入到深度学习模型")
|
||||
print(f"- 进行图像处理操作")
|
||||
print(f"- 保存为其他格式")
|
||||
|
||||
# 保存场景变化帧数组到文件(可选)
|
||||
print(f"\n=== 保存场景变化帧数组 ===")
|
||||
if len(scene_change_arrays) > 0:
|
||||
np.save("scene_change_arrays.npy", scene_change_arrays)
|
||||
print(f"场景变化帧NumPy数组已保存到: scene_change_arrays.npy")
|
||||
print(f"可以使用 np.load('scene_change_arrays.npy') 重新加载")
|
||||
else:
|
||||
print("没有检测到场景变化帧")
|
||||
print("建议:")
|
||||
print("1. 降低相似度阈值(如0.7或0.6)")
|
||||
print("2. 检查视频是否包含场景变化")
|
||||
print("3. 增加视频帧数进行更全面的检测")
|
||||
|
||||
# 获取音频数据
|
||||
audio = local_video.get_audio(sampling_rate=16000)
|
||||
print("音频数据形状:", audio.shape)
|
||||
print(type(audio))
|
||||
"""
|
||||
try:
|
||||
print("尝试加载模型...")
|
||||
|
||||
# 模型和分词器路径
|
||||
model_path = "/root/autodl-tmp/llm/Qwen2.5-VL"
|
||||
|
||||
# 使用离线模式加载分词器
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_path,
|
||||
local_files_only=True,
|
||||
trust_remote_code=True
|
||||
)
|
||||
|
||||
# 采样参数
|
||||
sampling_params = SamplingParams(
|
||||
temperature=0.6,
|
||||
top_p=0.95,
|
||||
top_k=20,
|
||||
max_tokens=1024
|
||||
)
|
||||
|
||||
# 离线模式初始化模型
|
||||
llm = LLM(
|
||||
model=model_path,
|
||||
tokenizer=model_path, # 使用相同的路径作为分词器路径
|
||||
max_model_len=4096,
|
||||
tensor_parallel_size=1, # 减少为1,避免多GPU通信问题
|
||||
gpu_memory_utilization=0.8, # 稍微降低内存使用率
|
||||
trust_remote_code=True,
|
||||
enable_lora=False, # 禁用LoRA
|
||||
)
|
||||
|
||||
# 使用提取的PIL图像
|
||||
print("生成视频描述...")
|
||||
prompt = "这个视频展示了什么内容?详细描述一下。"
|
||||
|
||||
# 使用generate而不是generate_videos (如果不存在generate_videos方法)
|
||||
try:
|
||||
# 尝试使用generate_videos
|
||||
outputs = llm.generate_videos(prompt, videos=[pil_images], sampling_params=sampling_params)
|
||||
print(outputs[0].outputs[0].text) # 打印模型输出
|
||||
except AttributeError:
|
||||
print("generate_videos方法不可用,尝试使用普通generate方法...")
|
||||
# 如果不支持generate_videos,使用普通的generate
|
||||
outputs = llm.generate([prompt], sampling_params=sampling_params)
|
||||
print(outputs[0].outputs[0].text) # 打印模型输出
|
||||
|
||||
except Exception as e:
|
||||
print(f"模型加载或推理过程中出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
"""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
267
code/video_to_text.py
Normal file
@ -0,0 +1,267 @@
|
||||
import os
|
||||
import subprocess
|
||||
import whisper
|
||||
import torch
|
||||
from moviepy import VideoFileClip
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
class VideoToTextConverter:
|
||||
def __init__(self, model_size="base"):
|
||||
"""
|
||||
初始化视频转文字转换器
|
||||
|
||||
Args:
|
||||
model_size: Whisper模型大小 ("tiny", "base", "small", "medium", "large")
|
||||
"""
|
||||
self.model_size = model_size
|
||||
self.model = None
|
||||
self.load_model()
|
||||
|
||||
def load_model(self):
|
||||
"""加载Whisper模型"""
|
||||
try:
|
||||
print(f"正在加载Whisper {self.model_size} 模型...")
|
||||
self.model = whisper.load_model(self.model_size)
|
||||
print("模型加载成功!")
|
||||
except Exception as e:
|
||||
print(f"模型加载失败: {e}")
|
||||
print("尝试使用CPU模式...")
|
||||
self.model = whisper.load_model(self.model_size, device="cpu")
|
||||
|
||||
def extract_audio_moviepy(self, video_path, audio_path):
|
||||
"""
|
||||
使用moviepy提取音频
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
audio_path: 输出音频文件路径
|
||||
"""
|
||||
try:
|
||||
print("正在使用moviepy提取音频...")
|
||||
video = VideoFileClip(video_path)
|
||||
audio = video.audio
|
||||
audio.write_audiofile(audio_path, verbose=False, logger=None)
|
||||
audio.close()
|
||||
video.close()
|
||||
print(f"音频提取成功: {audio_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"moviepy提取音频失败: {e}")
|
||||
return False
|
||||
|
||||
def extract_audio_ffmpeg(self, video_path, audio_path):
|
||||
"""
|
||||
使用ffmpeg提取音频
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
audio_path: 输出音频文件路径
|
||||
"""
|
||||
try:
|
||||
print("正在使用ffmpeg提取音频...")
|
||||
cmd = [
|
||||
'ffmpeg', '-i', video_path,
|
||||
'-vn', # 不包含视频
|
||||
'-acodec', 'pcm_s16le', # 音频编码
|
||||
'-ar', '16000', # 采样率
|
||||
'-ac', '1', # 单声道
|
||||
'-y', # 覆盖输出文件
|
||||
audio_path
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print(f"音频提取成功: {audio_path}")
|
||||
return True
|
||||
else:
|
||||
print(f"ffmpeg错误: {result.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"ffmpeg提取音频失败: {e}")
|
||||
return False
|
||||
|
||||
def extract_audio(self, video_path, audio_path=None):
|
||||
"""
|
||||
提取视频中的音频
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
audio_path: 输出音频文件路径,如果为None则自动生成
|
||||
|
||||
Returns:
|
||||
audio_path: 成功提取的音频文件路径,失败返回None
|
||||
"""
|
||||
if audio_path is None:
|
||||
# 自动生成音频文件路径
|
||||
video_dir = os.path.dirname(video_path)
|
||||
video_name = os.path.splitext(os.path.basename(video_path))[0]
|
||||
audio_path = os.path.join(video_dir, f"{video_name}_audio.wav")
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(os.path.dirname(audio_path), exist_ok=True)
|
||||
|
||||
# 首先尝试使用moviepy
|
||||
if self.extract_audio_moviepy(video_path, audio_path):
|
||||
return audio_path
|
||||
|
||||
# 如果moviepy失败,尝试使用ffmpeg
|
||||
if self.extract_audio_ffmpeg(video_path, audio_path):
|
||||
return audio_path
|
||||
|
||||
print("所有音频提取方法都失败了")
|
||||
return None
|
||||
|
||||
def transcribe_audio(self, audio_path, language="zh"):
|
||||
"""
|
||||
将音频转换为文字
|
||||
|
||||
Args:
|
||||
audio_path: 音频文件路径
|
||||
language: 语言代码 ("zh"中文, "en"英文, None自动检测)
|
||||
|
||||
Returns:
|
||||
dict: 包含转录结果的字典
|
||||
"""
|
||||
if self.model is None:
|
||||
print("模型未加载,无法进行转录")
|
||||
return None
|
||||
|
||||
try:
|
||||
print("正在进行语音识别...")
|
||||
|
||||
# 设置转录选项
|
||||
options = {
|
||||
"language": language if language != "auto" else None,
|
||||
"task": "transcribe",
|
||||
"fp16": torch.cuda.is_available() # 如果有GPU则使用半精度
|
||||
}
|
||||
|
||||
result = self.model.transcribe(audio_path, **options)
|
||||
|
||||
print("语音识别完成!")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"语音识别失败: {e}")
|
||||
return None
|
||||
|
||||
def video_to_text(self, video_path, output_dir=None, language="zh", save_audio=True):
|
||||
"""
|
||||
完整的视频转文字流程
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_dir: 输出目录,如果为None则使用视频所在目录
|
||||
language: 语言代码
|
||||
save_audio: 是否保存提取的音频文件
|
||||
|
||||
Returns:
|
||||
dict: 包含转录结果和文件路径的字典
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = os.path.dirname(video_path)
|
||||
|
||||
video_name = os.path.splitext(os.path.basename(video_path))[0]
|
||||
|
||||
# 提取音频
|
||||
audio_path = os.path.join(output_dir, f"{video_name}_audio.wav")
|
||||
extracted_audio = self.extract_audio(video_path, audio_path)
|
||||
|
||||
if extracted_audio is None:
|
||||
return {"success": False, "error": "音频提取失败"}
|
||||
|
||||
# 转录音频
|
||||
transcription = self.transcribe_audio(extracted_audio, language)
|
||||
|
||||
if transcription is None:
|
||||
return {"success": False, "error": "语音识别失败"}
|
||||
|
||||
# 保存转录结果
|
||||
text_path = os.path.join(output_dir, f"{video_name}_transcription.txt")
|
||||
with open(text_path, 'w', encoding='utf-8') as f:
|
||||
f.write(transcription["text"])
|
||||
|
||||
# 保存详细结果(包含时间戳)
|
||||
detailed_path = os.path.join(output_dir, f"{video_name}_detailed.txt")
|
||||
with open(detailed_path, 'w', encoding='utf-8') as f:
|
||||
for segment in transcription["segments"]:
|
||||
start_time = segment["start"]
|
||||
end_time = segment["end"]
|
||||
text = segment["text"]
|
||||
f.write(f"[{start_time:.2f}s - {end_time:.2f}s]: {text}\n")
|
||||
|
||||
# 如果不需要保存音频文件,则删除
|
||||
if not save_audio and os.path.exists(extracted_audio):
|
||||
os.remove(extracted_audio)
|
||||
extracted_audio = None
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"text": transcription["text"],
|
||||
"segments": transcription["segments"],
|
||||
"language": transcription["language"],
|
||||
"audio_path": extracted_audio,
|
||||
"text_path": text_path,
|
||||
"detailed_path": detailed_path
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def print_result(self, result):
|
||||
"""打印转录结果"""
|
||||
if not result["success"]:
|
||||
print(f"转录失败: {result['error']}")
|
||||
return
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("视频转文字结果")
|
||||
print("="*50)
|
||||
print(f"检测到的语言: {result['language']}")
|
||||
print(f"文本文件: {result['text_path']}")
|
||||
print(f"详细文件: {result['detailed_path']}")
|
||||
if result['audio_path']:
|
||||
print(f"音频文件: {result['audio_path']}")
|
||||
|
||||
print("\n完整文本:")
|
||||
print("-" * 30)
|
||||
print(result["text"])
|
||||
|
||||
print("\n分段文本 (前5段):")
|
||||
print("-" * 30)
|
||||
for i, segment in enumerate(result["segments"][:5]):
|
||||
start_time = segment["start"]
|
||||
end_time = segment["end"]
|
||||
text = segment["text"]
|
||||
print(f"[{start_time:.2f}s - {end_time:.2f}s]: {text}")
|
||||
|
||||
if len(result["segments"]) > 5:
|
||||
print(f"... 还有 {len(result['segments']) - 5} 个分段")
|
||||
|
||||
|
||||
def main():
|
||||
"""示例用法"""
|
||||
# 初始化转换器
|
||||
converter = VideoToTextConverter(model_size="base")
|
||||
|
||||
# 视频文件路径
|
||||
video_path = "/root/autodl-tmp/hot_video_analyse/source/sample_demo_1.mp4"
|
||||
output_dir = "/root/autodl-tmp/hot_video_analyse/source/transcription"
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# 转换视频为文字
|
||||
result = converter.video_to_text(
|
||||
video_path=video_path,
|
||||
output_dir=output_dir,
|
||||
language="auto", # 中文,也可以用"en"英文或"auto"自动检测
|
||||
save_audio=True
|
||||
)
|
||||
|
||||
# 打印结果
|
||||
converter.print_result(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
After Width: | Height: | Size: 295 KiB |
After Width: | Height: | Size: 298 KiB |
After Width: | Height: | Size: 295 KiB |
After Width: | Height: | Size: 299 KiB |
After Width: | Height: | Size: 302 KiB |
After Width: | Height: | Size: 304 KiB |
After Width: | Height: | Size: 305 KiB |
After Width: | Height: | Size: 306 KiB |
After Width: | Height: | Size: 306 KiB |
After Width: | Height: | Size: 308 KiB |
After Width: | Height: | Size: 310 KiB |
After Width: | Height: | Size: 311 KiB |
After Width: | Height: | Size: 314 KiB |
After Width: | Height: | Size: 315 KiB |
After Width: | Height: | Size: 316 KiB |
After Width: | Height: | Size: 318 KiB |
After Width: | Height: | Size: 321 KiB |
After Width: | Height: | Size: 324 KiB |
After Width: | Height: | Size: 327 KiB |
After Width: | Height: | Size: 328 KiB |
After Width: | Height: | Size: 331 KiB |
After Width: | Height: | Size: 330 KiB |
After Width: | Height: | Size: 332 KiB |
After Width: | Height: | Size: 332 KiB |
After Width: | Height: | Size: 334 KiB |
After Width: | Height: | Size: 338 KiB |
After Width: | Height: | Size: 341 KiB |
After Width: | Height: | Size: 340 KiB |
After Width: | Height: | Size: 340 KiB |
After Width: | Height: | Size: 342 KiB |
After Width: | Height: | Size: 340 KiB |
After Width: | Height: | Size: 340 KiB |
After Width: | Height: | Size: 339 KiB |
After Width: | Height: | Size: 341 KiB |
After Width: | Height: | Size: 217 KiB |
After Width: | Height: | Size: 217 KiB |
After Width: | Height: | Size: 218 KiB |
After Width: | Height: | Size: 224 KiB |
After Width: | Height: | Size: 227 KiB |
After Width: | Height: | Size: 231 KiB |
After Width: | Height: | Size: 233 KiB |
After Width: | Height: | Size: 237 KiB |
After Width: | Height: | Size: 242 KiB |
After Width: | Height: | Size: 243 KiB |
After Width: | Height: | Size: 244 KiB |
After Width: | Height: | Size: 242 KiB |
After Width: | Height: | Size: 245 KiB |
After Width: | Height: | Size: 247 KiB |
After Width: | Height: | Size: 248 KiB |
After Width: | Height: | Size: 247 KiB |
After Width: | Height: | Size: 248 KiB |
After Width: | Height: | Size: 246 KiB |
After Width: | Height: | Size: 248 KiB |
After Width: | Height: | Size: 248 KiB |
After Width: | Height: | Size: 247 KiB |
After Width: | Height: | Size: 247 KiB |
After Width: | Height: | Size: 248 KiB |
After Width: | Height: | Size: 247 KiB |
After Width: | Height: | Size: 247 KiB |
After Width: | Height: | Size: 248 KiB |
After Width: | Height: | Size: 299 KiB |
After Width: | Height: | Size: 305 KiB |
After Width: | Height: | Size: 302 KiB |
After Width: | Height: | Size: 305 KiB |
After Width: | Height: | Size: 306 KiB |
After Width: | Height: | Size: 307 KiB |
After Width: | Height: | Size: 306 KiB |
After Width: | Height: | Size: 309 KiB |
After Width: | Height: | Size: 308 KiB |
After Width: | Height: | Size: 307 KiB |