Files
sentinel-home-ai/fam-edge/src/fam_edge/video_processor.py

342 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
VideoProcessor - 整视频分析编排
流程(不再切片/抽帧):
1. 从 OracleDB 取当前 known_members_context已命名/合并的人物)
2. 按 vision_order 依次调适配器的 analyze_videoGemini 整视频 -> NVIDIA 整视频)
3. 首个成功结果 -> 归一化 -> 写 OracleDBvideos + 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 / 20260821_081500.mp4带/不带分隔符日期)
- Generic_ONVIF-001-20260820-140416-xxx.mp4纯数字 YYYYMMDD-HHMMSS
"""
# 纯数字: YYYYMMDD-HHMMSS 或 YYYYMMDDHHMMSS监控录像文件名格式
m0 = re.search(r'(\d{4})(\d{2})(\d{2})[-_]?(\d{2})(\d{2})(\d{2})', filename)
if m0:
y, mo, d, hh, mm, ss = m0.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
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 ''
def _parse_event_ts(ts: str, start_dt):
"""解析事件时间戳 -> (绝对时间显示串, 视频内偏移秒)。
优先识别"视频内相对时间" HH:MM:SS新 prompt 要求,定位最准);
兼容旧数据的绝对时间 YYYY-MM-DD HH:MM:SS偏移=绝对-视频开始)。
"""
ts = (ts or '').strip()
m = re.match(r'^(\d{1,2}):(\d{2}):(\d{2})$', ts)
if m:
off = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))
# 启发式:监控单段通常 ≤1h相对时间超过 6h 视为模型误输出绝对时间(无日期),不强行定位
if off <= 6 * 3600:
if start_dt is not None:
abs_ts = (start_dt + timedelta(seconds=off)).strftime('%Y-%m-%d %H:%M:%S')
return abs_ts, float(off)
return ts, float(off)
if start_dt is not None:
try:
ev_dt = datetime.strptime(ts[:19], '%Y-%m-%d %H:%M:%S')
return ts, (ev_dt - start_dt).total_seconds()
except ValueError:
pass
return ts, 0.0
if start_dt is not None:
try:
ev_dt = datetime.strptime(ts[:19], '%Y-%m-%d %H:%M:%S')
return ts, (ev_dt - start_dt).total_seconds()
except ValueError:
pass
return ts, 0.0
def validate_video(path: str) -> tuple:
"""校验视频文件是否为正常可解码视频。
返回 (ok: bool, error: str, meta: dict|None)
- meta: {fps, frames, duration_sec, width, height}
用 OpenCV 打开并读取至少 1 帧(不校验会导致空/半成品文件浪费云端配额)。
"""
meta = None
try:
if not path or not os.path.isfile(path):
return False, "file_missing", None
if os.path.getsize(path) == 0:
return False, "file_empty", None
try:
import cv2
except ImportError:
return True, "", None # 无 cv2 时跳过深度校验(仅大小检查)
cap = cv2.VideoCapture(path)
try:
if not cap.isOpened():
return False, "cannot_open", None
ok, frame = cap.read()
if not ok or frame is None:
return False, "no_decodable_frame", None
fps = float(cap.get(cv2.CAP_PROP_FPS) or 0)
frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
meta = {
"fps": round(fps, 2),
"frames": frames,
"duration_sec": round(frames / max(fps, 0.01), 1),
"width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0),
"height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0),
}
finally:
cap.release()
return True, "", meta
except Exception as e:
return False, f"validate_exc: {e}", None
def _clean_person(s: str) -> str:
"""清洗人物标识:去掉括号注释(如 "人物A别名/标识人物B" -> "人物A")。"""
s = (s or '').strip()
for sep in ('', '('):
if sep in s:
s = s.split(sep, 1)[0].strip()
break
return s
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.file_validate = bool(self.config.get('video_processing', {}).get(
'file_validate', True))
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,
timeout_multiplier: float = 1.0) -> bool:
"""处理一个视频记录,返回是否成功。
timeout_multiplier: 云端模型消费的超时放大倍数(如 2 = 在配置 timeout 上 ×2
每次调用前临时放大对应 adapter.timeout调用后恢复避免影响其他调用方。
"""
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
# 处理前二次确认文件有效性(防止登记后文件被破坏/截断;校验结果落库)
if self.file_validate:
ok, verr, vmeta = validate_video(local_path)
if not ok:
logger.error(f"[video_id={video_id}] 文件校验失败({verr}),标记 failed: {local_path}")
self.db.set_video_file_status(video_id, False, verr)
self.db.mark_video_failed(video_id, f"invalid_file:{verr}")
return False
if vmeta:
self.db.set_video_file_status(video_id, True, '', vmeta)
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():
# 模型调用统计 hook带当前 video_id/filename前端展示用
adapter.model_call_hook = (
lambda p, m, s, d, ok, e, _vid=video_id, _fn=filename:
self.db.record_model_call(p, m, _vid, _fn, s, d, ok, e))
# 按模型原配置超时 × multiplier默认 1x队列消费默认 2x
orig_timeout = adapter.get_timeout()
if timeout_multiplier != 1.0:
adapter.timeout = int(orig_timeout * timeout_multiplier)
logger.info(f"[video_id={video_id}] {adapter.provider_name} 超时 "
f"{orig_timeout}s -> {adapter.timeout}s (×{timeout_multiplier})")
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
finally:
adapter.timeout = orig_timeout
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')
# 视频开始时间(绝对时间由后端精确计算:开始时间 + 相对偏移)
start_dt = None
vrow = self.db.get_video_by_id(video_id)
if vrow and vrow['event_start_time']:
try:
start_dt = datetime.strptime(vrow['event_start_time'], '%Y-%m-%d %H:%M:%S')
except ValueError:
pass
norm_events = []
offsets = []
for ev in events:
abs_ts, off = _parse_event_ts(ev.get('timestamp'), start_dt)
norm_events.append({
"timestamp": abs_ts,
"description": str(ev.get('description', '')),
"people": [_clean_person(str(p)) for p in ev.get('people', []) if p],
"is_attention_event": bool(ev.get('is_attention_event', False)),
})
offsets.append(off)
# 清洗 people_mentioned去掉括号注释串防污染人物表/合并)
people = [_clean_person(str(p)) for p in people if p]
people = [p for p in people if p and p not in ('无人', '')]
event_ids = self.db.mark_video_processed(video_id, summary, norm_events, people, provider)
# 缩略图 + 每个事件对应时间点的画面截图(用相对偏移直接定位,避免模型绝对时间误差)
if vrow and vrow['local_path']:
self._generate_thumb(video_id, vrow['local_path'])
self._generate_event_thumbs(video_id, vrow['local_path'], offsets, event_ids)
# 更新 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}")
def _thumbs_dir(self) -> str:
db_path = self.config.get('oracle_db', {}).get(
'path', '/opt/fam-edge/data/oracle.db')
d = os.path.abspath(os.path.join(os.path.dirname(db_path), '..', 'thumbs'))
os.makedirs(d, exist_ok=True)
return d
def _generate_thumb(self, video_id: int, video_path: str) -> bool:
"""抽视频首帧生成 JPEG 缩略图(/opt/fam-edge/thumbs/{video_id}.jpg"""
try:
import cv2
out = os.path.join(self._thumbs_dir(), f"{video_id}.jpg")
cap = cv2.VideoCapture(video_path)
try:
ok, frame = cap.read()
finally:
cap.release()
if not ok or frame is None:
logger.warning(f"抽帧失败 video_id={video_id}: 无法读取首帧")
return False
h, w = frame.shape[:2]
if w > 640:
frame = cv2.resize(frame, (640, int(h * 640 / w)))
cv2.imwrite(out, frame, [cv2.IMWRITE_JPEG_QUALITY, 65])
logger.info(f"缩略图已生成: {out}")
return True
except Exception as e:
logger.warning(f"抽帧异常 video_id={video_id}: {e}")
return False
def _generate_event_thumbs(self, video_id: int, video_path: str,
offsets: List[float], event_ids: List[int]):
"""按事件在视频内的偏移秒定位帧,生成事件画面截图 ev_{event_id}.jpg"""
try:
import cv2
except Exception as e:
logger.warning(f"事件截图依赖缺失 video_id={video_id}: {e}")
return
try:
thumbs = self._thumbs_dir()
cap = cv2.VideoCapture(video_path)
try:
for off, eid in zip(offsets, event_ids):
if off < 0:
off = 0.0
cap.set(cv2.CAP_PROP_POS_MSEC, int(off * 1000))
ok, frame = cap.read()
if not ok or frame is None:
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
ok, frame = cap.read()
if not ok or frame is None:
logger.warning(f"事件截图失败 ev_{eid}: 无法读取 offset={off:.0f}s")
continue
h, w = frame.shape[:2]
if w > 640:
frame = cv2.resize(frame, (640, int(h * 640 / w)))
out = os.path.join(thumbs, f"ev_{eid}.jpg")
cv2.imwrite(out, frame, [cv2.IMWRITE_JPEG_QUALITY, 65])
logger.info(f"事件截图已生成 ev_{eid}.jpg (offset={off:.0f}s)")
finally:
cap.release()
except Exception as e:
logger.warning(f"事件截图异常 video_id={video_id}: {e}")