feat(v3): 人物模块重构 - 大模型结构化特征值替代 OpenCV 帧定位。prompt 增加 person_appearances(uid+7特征+action)并重写合并 prompt(稳定特征优先比对);gemini 透传特征字段;oracle_db events/people 加 person_appearances_json/features_json/display_uid + _merge_features;video_processor 去 cv2 改 ffprobe 校验、_store_result 聚合 uid 特征落 people、删缩略图/事件截图;person_service 聚合特征后基于特征文本 LLM 合并;api_gateway 删 3 个图接口;NAS 镜像+DDL 加新字段;fam-ui 特征卡替代头像、事件时间线人物特征块

This commit is contained in:
ericwyuan
2026-08-21 18:06:21 +08:00
parent 2ba3478291
commit ff01d14c79
12 changed files with 463 additions and 257 deletions

View File

@@ -4,13 +4,17 @@ VideoProcessor - 整视频分析编排
流程(不再切片/抽帧):
1. 从 OracleDB 取当前 known_members_context已命名/合并的人物)
2. 按 vision_order 依次调适配器的 analyze_videoGemini 整视频 -> NVIDIA 整视频)
3. 首个成功结果 -> 归一化 -> 写 OracleDBvideos + events 表)
4. 把本视频 people_mentioned 更新进 people 表(供 person_service 后续合并)
3. 首个成功结果 -> 归一化 -> 写 OracleDBvideos + events 表,含 person_appearances
4. 把本视频 people_mentioned 更新进 people 表(带 features 特征,供 person_service 合并)
降级: 全部视觉模型失败 -> 标记视频 failed不再本地融合
注: 不依赖 OpenCV/cv2。视频文件校验用 ffprobesubprocess不再生成帧 jpg。
"""
import os
import re
import json
import subprocess
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
@@ -86,42 +90,82 @@ def _parse_event_ts(ts: str, start_dt):
return ts, 0.0
def _ffprobe_available() -> bool:
"""ffprobe 是否可用ffmpeg 套件自带)。"""
try:
r = subprocess.run(
['ffprobe', '-version'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
except Exception:
return False
def validate_video(path: str) -> tuple:
"""校验视频文件是否为正常可解码视频。
返回 (ok: bool, error: str, meta: dict|None)
- meta: {fps, frames, duration_sec, width, height}
用 OpenCV 打开并读取至少 1 帧(不校验会导致空/半成品文件浪费云端配额)。
用 ffprobesubprocess查 stream 信息。无 ffprobe 时仅做大小检查
(与旧 cv2 缺失时行为一致,跳过深度校验)。
不校验会导致空/半成品文件浪费云端配额。
"""
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
if not _ffprobe_available():
return True, "", None # 无 ffprobe 时跳过深度校验(仅大小检查)
# -v error: 只报错;-show_entries: 只取需要的字段;-of json: JSON 输出
r = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries',
'stream=codec_type,avg_frame_rate,nb_frames,duration,width,height',
'-of', 'json', path],
capture_output=True, text=True, timeout=30)
if r.returncode != 0:
return False, f"ffprobe_error: {r.stderr[:200]}", None
try:
import cv2
except ImportError:
return True, "", None # 无 cv2 时跳过深度校验(仅大小检查)
cap = cv2.VideoCapture(path)
data = json.loads(r.stdout or '{}')
except ValueError:
return False, "ffprobe_bad_json", None
streams = data.get('streams') or []
vstream = next((s for s in streams if s.get('codec_type') == 'video'), None)
if not vstream:
return False, "no_video_stream", None
# fps: avg_frame_rate 形如 "25/1" -> 25.0
fps = 0.0
avg_rate = vstream.get('avg_frame_rate', '0/1')
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()
num, den = avg_rate.split('/')
den_f = float(den or '1')
fps = float(num) / den_f if den_f else 0.0
except (ValueError, ZeroDivisionError):
fps = 0.0
frames = 0
try:
frames = int(vstream.get('nb_frames') or 0)
except (ValueError, TypeError):
frames = 0
duration = 0.0
try:
duration = float(vstream.get('duration') or 0)
except (ValueError, TypeError):
duration = 0.0
meta = {
"fps": round(fps, 2),
"frames": frames,
"duration_sec": round(duration, 1) if duration else (
round(frames / max(fps, 0.01), 1) if frames and fps else 0),
"width": int(vstream.get('width') or 0),
"height": int(vstream.get('height') or 0),
}
return True, "", meta
except subprocess.TimeoutExpired:
return False, "ffprobe_timeout", None
except Exception as e:
return False, f"validate_exc: {e}", None
@@ -248,94 +292,69 @@ class VideoProcessor:
pass
norm_events = []
offsets = []
for ev in events:
abs_ts, off = _parse_event_ts(ev.get('timestamp'), start_dt)
abs_ts, _ = _parse_event_ts(ev.get('timestamp'), start_dt)
ev_people = [_clean_person(str(p)) for p in ev.get('people', []) if p]
# 透传 person_appearances含 uid/features/action清洗 uid 字符串
appearances = ev.get('person_appearances') or []
norm_appearances = []
for pa in appearances:
if not isinstance(pa, dict):
continue
uid = _clean_person(str(pa.get('uid', '')))
if not uid:
continue
feats = pa.get('features') or {}
if not isinstance(feats, dict):
feats = {}
norm_appearances.append({
"uid": uid,
"features": feats,
"action": str(pa.get('action', '')),
})
norm_events.append({
"timestamp": abs_ts,
"description": str(ev.get('description', '')),
"people": [_clean_person(str(p)) for p in ev.get('people', []) if p],
"people": ev_people,
"person_appearances": norm_appearances,
"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 ('无人', '')]
# mark_video_processed 会把 norm_events 里的 person_appearances 落到
# events.person_appearances_json供 person_service 聚合特征
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 合并
# 更新 people 表(标签级 + 特征:从该视频所有 person_appearances 收集每个 uid 的特征
uid_features = {}
for ev in norm_events:
for pa in ev.get('person_appearances', []):
uid = pa.get('uid')
if not uid or uid in ('无人', ''):
continue
feats = pa.get('features') or {}
if uid not in uid_features:
uid_features[uid] = feats
else:
# 同一 uid 多次出现:合并非 unknown 字段(与 upsert_person 的合并一致)
merged = dict(uid_features[uid])
for k, v in feats.items():
v_str = str(v).strip() if v is not None else ''
if v_str and v_str.lower() != 'unknown':
merged[k] = v_str
elif k not in merged:
merged[k] = v_str or 'unknown'
uid_features[uid] = merged
for p in people:
if p and p not in ('无人', ''):
self.db.upsert_person(p, source='llm')
feats = uid_features.get(p)
if feats:
self.db.upsert_person(p, source='llm', features=feats, display_uid=p)
else:
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}")
f"events={len(norm_events)}, people={people}, "
f"with_features={len(uid_features)}")