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:
@@ -4,13 +4,17 @@ VideoProcessor - 整视频分析编排
|
||||
流程(不再切片/抽帧):
|
||||
1. 从 OracleDB 取当前 known_members_context(已命名/合并的人物)
|
||||
2. 按 vision_order 依次调适配器的 analyze_video(Gemini 整视频 -> NVIDIA 整视频)
|
||||
3. 首个成功结果 -> 归一化 -> 写 OracleDB(videos + events 表)
|
||||
4. 把本视频 people_mentioned 更新进 people 表(供 person_service 后续合并)
|
||||
3. 首个成功结果 -> 归一化 -> 写 OracleDB(videos + events 表,含 person_appearances)
|
||||
4. 把本视频 people_mentioned 更新进 people 表(带 features 特征,供 person_service 合并)
|
||||
|
||||
降级: 全部视觉模型失败 -> 标记视频 failed(不再本地融合)
|
||||
|
||||
注: 不依赖 OpenCV/cv2。视频文件校验用 ffprobe(subprocess);不再生成帧 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 帧(不校验会导致空/半成品文件浪费云端配额)。
|
||||
|
||||
用 ffprobe(subprocess)查 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)}")
|
||||
|
||||
Reference in New Issue
Block a user