[阶段2] FAM-Edge 重构为整视频分析+同步接口+人物服务 - 移除切片/抽帧/队列,新增 oracle_db/person_service/qa/watch_processor/video_processor,api_gateway 提供 /api/oracle/sync 与 /api/oracle/people/correct
This commit is contained in:
132
fam-edge/src/fam_edge/video_processor.py
Normal file
132
fam-edge/src/fam_edge/video_processor.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
VideoProcessor - 整视频分析编排
|
||||
|
||||
流程(不再切片/抽帧):
|
||||
1. 从 OracleDB 取当前 known_members_context(已命名/合并的人物)
|
||||
2. 按 vision_order 依次调适配器的 analyze_video(Gemini 整视频 -> NVIDIA 整视频)
|
||||
3. 首个成功结果 -> 归一化 -> 写 OracleDB(videos + events 表)
|
||||
4. 把本视频 people_mentioned 更新进 people 表(供 person_service 后续合并)
|
||||
|
||||
降级: 全部视觉模型失败 -> 标记视频 failed(不再本地融合)
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .logger import setup_logger
|
||||
from .config_loader import load_config
|
||||
from .model_adapters.adapter_factory import build_adapters
|
||||
from .model_adapters.base_adapter import BaseModelAdapter
|
||||
from . import oracle_db
|
||||
|
||||
logger = setup_logger('fam-edge.video_processor')
|
||||
|
||||
|
||||
def _parse_event_start_from_filename(filename: str) -> str:
|
||||
"""从监控文件名解析开始时间(北京时间)。示例: 2026-08-21_081500.mp4"""
|
||||
m = re.search(r'(\d{4})[-_](\d{2})[-_](\d{2})[_-]?(\d{2})(\d{2})(\d{2})', filename)
|
||||
if m:
|
||||
y, mo, d, hh, mm, ss = m.groups()
|
||||
try:
|
||||
dt = datetime(int(y), int(mo), int(d), int(hh), int(mm), int(ss))
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
pass
|
||||
# 退而求其次: 2026-08-21 08-15-00 等
|
||||
m2 = re.search(r'(\d{4}-\d{2}-\d{2})[ _T-]+(\d{2})[-:](\d{2})[-:](\d{2})', filename)
|
||||
if m2:
|
||||
return f"{m2.group(1)} {m2.group(2)}:{m2.group(3)}:{m2.group(4)}"
|
||||
return ''
|
||||
|
||||
|
||||
class VideoProcessor:
|
||||
def __init__(self, db: oracle_db.OracleDB):
|
||||
self.config = load_config()
|
||||
self.db = db
|
||||
self.vision_order = self.config.get('video_processing', {}).get(
|
||||
'vision_order', ['gemini', 'nvidia'])
|
||||
self.vision_timeout = self.config.get('video_processing', {}).get('timeout', 900)
|
||||
self.parse_start = self.config.get('gdrive_sync', {}).get(
|
||||
'parse_start_from_filename', True)
|
||||
adapters = build_adapters(self.config.get('models', []))
|
||||
self.vision_adapters: Dict[str, BaseModelAdapter] = {
|
||||
a.provider_name: a for a in adapters if a.get_role() == 'vision'}
|
||||
|
||||
def _ordered_vision_adapters(self) -> List[BaseModelAdapter]:
|
||||
ordered = []
|
||||
for name in self.vision_order:
|
||||
if name in self.vision_adapters:
|
||||
ordered.append(self.vision_adapters[name])
|
||||
# 追加未在顺序里但启用的视觉适配器
|
||||
for name, a in self.vision_adapters.items():
|
||||
if name not in self.vision_order:
|
||||
ordered.append(a)
|
||||
return ordered
|
||||
|
||||
def process_video(self, video_id: int, filename: str, local_path: str) -> bool:
|
||||
"""处理一个视频记录,返回是否成功。"""
|
||||
if not os.path.isfile(local_path):
|
||||
logger.error(f"[video_id={video_id}] 文件不存在,跳过: {local_path}")
|
||||
self.db.mark_video_failed(video_id, "file_missing")
|
||||
return False
|
||||
|
||||
camera_name = self.db.get_video_by_filename(filename)['camera_name'] or ''
|
||||
event_start = ''
|
||||
if self.parse_start:
|
||||
event_start = _parse_event_start_from_filename(filename)
|
||||
# 回写解析到的开始时间
|
||||
if event_start:
|
||||
self.db._conn.execute(
|
||||
"UPDATE videos SET event_start_time=? WHERE id=?",
|
||||
(event_start, video_id))
|
||||
self.db._conn.commit()
|
||||
|
||||
known = self.db.get_known_members_context()
|
||||
logger.info(f"[video_id={video_id}] 开始整视频分析: {filename} "
|
||||
f"(event_start={event_start}, known_members={'有' if known else '无'})")
|
||||
|
||||
last_err = "no_vision_adapter"
|
||||
for adapter in self._ordered_vision_adapters():
|
||||
try:
|
||||
logger.info(f"[video_id={video_id}] 尝试 {adapter.provider_name} 整视频分析")
|
||||
result = adapter.analyze_video(local_path, known, event_start)
|
||||
except Exception as e:
|
||||
logger.error(f"[video_id={video_id}] {adapter.provider_name} 异常: {e}")
|
||||
last_err = str(e)
|
||||
continue
|
||||
if result:
|
||||
self._store_result(video_id, result)
|
||||
return True
|
||||
else:
|
||||
last_err = f"{adapter.provider_name}_failed"
|
||||
logger.warning(f"[video_id={video_id}] {adapter.provider_name} 未返回结果,降级下一模型")
|
||||
|
||||
logger.error(f"[video_id={video_id}] 所有视觉模型失败,标记 failed: {last_err}")
|
||||
self.db.mark_video_failed(video_id, last_err)
|
||||
return False
|
||||
|
||||
def _store_result(self, video_id: int, result: Dict):
|
||||
events = result.get('events', [])
|
||||
people = result.get('people_mentioned', [])
|
||||
summary = result.get('global_summary', '')
|
||||
provider = result.get('compute_provider', 'unknown')
|
||||
|
||||
# 归一化 events 时间戳(若模型给的是相对偏移,这里不强制;以模型输出为准)
|
||||
norm_events = []
|
||||
for ev in events:
|
||||
norm_events.append({
|
||||
"timestamp": str(ev.get('timestamp', '')),
|
||||
"description": str(ev.get('description', '')),
|
||||
"people": [str(p) for p in ev.get('people', []) if p],
|
||||
"is_attention_event": bool(ev.get('is_attention_event', False)),
|
||||
})
|
||||
|
||||
self.db.mark_video_processed(video_id, summary, norm_events, people, provider)
|
||||
|
||||
# 更新 people 表(标签级,待 person_service 合并)
|
||||
for p in people:
|
||||
if p and p not in ('无人', '无'):
|
||||
self.db.upsert_person(p, source='llm')
|
||||
logger.info(f"[video_id={video_id}] 已落库: summary={len(summary)}字, "
|
||||
f"events={len(norm_events)}, people={people}")
|
||||
Reference in New Issue
Block a user