feat: 新增视频分析流水线、Web界面、孤儿清理工具;修复健壮性问题
- 新增 analysis/ 模块:MOG2活动检测、YOLO人物跟踪、MediaPipe摔倒检测、Ollama活动打标、缩略图抽取 - 新增 webapp/:Flask事件浏览界面,按需HEVC→H264转码缓存 - 新增 analyze.py:7阶段状态机流水线(scan→backfill→motion→person→fall→activity→thumbnail) - 新增 cleanup_orphans.py:清理文件已删除的数据库孤儿记录 - 修复:motion/person/activity三阶段加单视频异常兜底,避免批次中断 - 修复:ollama_client加健康检查和3次重试,num_ctx从8192降到2048 - 修复:transcode_cache加fcntl文件锁,防止并发转码同一视频 - 修复:SQLite开WAL模式+busy_timeout,支持读写并发 - 修复:sync.py ffmpeg路径改为shutil.which查找,删除automate.py死代码PYTHON - 修复:thumbnail失败加3次重试上限,避免无限重试损坏文件 - 新增:requirements.lock锁定版本、README文档 - 更新:.gitignore排除.playwright-mcp、截图、备份文件
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""用 ffmpeg 在每条 events 记录的 start_offset_sec 处抽一帧,生成缩略图。
|
||||
|
||||
供网页事件列表展示预览图,点击缩略图再跳转播放对应视频的完整时间点。
|
||||
|
||||
失败重试上限:单个事件连续失败超过 MAX_ATTEMPTS 次后,在 extra_json 里标记
|
||||
thumbnail_failed,后续运行直接跳过,避免对损坏文件无限重试。
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from analysis.config import PROJECT_ROOT, THUMBNAILS_DIR
|
||||
from analysis.db import get_events, get_video_by_id, update_event_thumbnail, update_event_extra
|
||||
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _extract_frame(video_path, offset_sec, output_path):
|
||||
# -ss 放在 -i 之后用精确seek:放在 -i 之前的快速seek在临近视频结尾处
|
||||
# 会因缺少解码所需的参考帧而失败(HEVC "Could not find ref with POC")。
|
||||
# 录像多为几秒到几十秒的短片,精确seek的性能代价可忽略。
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-i", str(video_path), "-ss", str(offset_sec),
|
||||
"-frames:v", "1", "-q:v", "3", "-y", str(output_path),
|
||||
],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
return result.returncode == 0 and output_path.exists()
|
||||
|
||||
|
||||
def _get_attempts(event):
|
||||
try:
|
||||
extra = json.loads(event["extra_json"] or "{}")
|
||||
return extra.get("thumbnail_attempts", 0)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def _increment_attempts(event):
|
||||
try:
|
||||
extra = json.loads(event["extra_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
extra = {}
|
||||
extra["thumbnail_attempts"] = extra.get("thumbnail_attempts", 0) + 1
|
||||
if extra["thumbnail_attempts"] >= MAX_ATTEMPTS:
|
||||
extra["thumbnail_failed"] = True
|
||||
update_event_extra(event["id"], json.dumps(extra, ensure_ascii=False))
|
||||
|
||||
|
||||
def process_all(limit=None):
|
||||
THUMBNAILS_DIR.mkdir(exist_ok=True)
|
||||
events = []
|
||||
for e in get_events():
|
||||
if e["thumbnail_path"]:
|
||||
continue
|
||||
try:
|
||||
extra = json.loads(e["extra_json"] or "{}")
|
||||
if extra.get("thumbnail_failed"):
|
||||
continue
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
events.append(e)
|
||||
|
||||
if limit:
|
||||
events = events[:limit]
|
||||
|
||||
video_cache = {}
|
||||
processed = 0
|
||||
failed = 0
|
||||
for event in events:
|
||||
video_id = event["video_id"]
|
||||
if video_id not in video_cache:
|
||||
video_cache[video_id] = get_video_by_id(video_id)
|
||||
video = video_cache[video_id]
|
||||
if video is None:
|
||||
_increment_attempts(event)
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
video_path = PROJECT_ROOT / video["file_path"]
|
||||
if not video_path.exists():
|
||||
_increment_attempts(event)
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
output_path = THUMBNAILS_DIR / f"{event['id']}.jpg"
|
||||
offset_sec = event["start_offset_sec"] or 0.0
|
||||
|
||||
if _extract_frame(video_path, offset_sec, output_path):
|
||||
update_event_thumbnail(event["id"], str(output_path.relative_to(PROJECT_ROOT)))
|
||||
processed += 1
|
||||
else:
|
||||
_increment_attempts(event)
|
||||
failed += 1
|
||||
|
||||
return {"processed": processed, "failed": failed}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("===== 开始生成缩略图 =====")
|
||||
result = process_all()
|
||||
print(f"→ 生成 {result['processed']} 张缩略图,失败 {result['failed']} 条")
|
||||
print("===== 生成完成 =====")
|
||||
Reference in New Issue
Block a user