Files
sentinel-home-ai/fam-edge/src/fam_edge/frame_service.py
ericwyuan 61db82cb9b refactor(fam-edge): 重构第一阶段 - 人物图片零额外调用 + 运行时稳定性 + 工程质量
人物图片功能重做: bbox 随核心视频分析那一次 Gemini 调用一并产出(prompts.py 加
person_appearances.bbox 字段, [ymin,xmin,ymax,xmax] 0-1000 归一化), frame_service
直接用存好的 bbox 裁剪头像/事件缩略图, 删除原来"展示时额外调用 Gemini 定位人物"的
整套逻辑(locate_person_bbox/VLM 校验/熔断), 从架构上消除与核心视频分析共抢配额的
问题; 用真实数据验证裁剪结果正确框住人物本体。

NVIDIA 模型修复: 实测原配置的 3 个模型均不可用(asset_id 引用 500/400, 不支持视频),
改用 nemotron-3-nano-omni 的 base64 内嵌视频方式(唯一实测打通), 加 max_base64_mb
防止对大文件做注定失败的编码。

Gemini 多 Key 轮换: 支持 extra_api_keys 配置多个独立项目的 key, 配额用尽时依次
换 key 重试(每换 key 需重新上传, Files API 按项目隔离)。

稳定性加固: CircuitBreaker HALF_OPEN 清空旧失败计数(修复探测一失败就重新 OPEN 的
bug); chat() 统一接入熔断器(原来只有视频分析路径检查); NVIDIA 适配器改用共享
json_parser(原来自己重复实现且不做 schema 校验); Gemini Files API 上传超时也尝试
清理远程孤儿文件; video_processor/video_queue 里直接操作 OracleDB._conn 的裸 SQL
改走新增的 set_event_start_time/mark_video_invalid/reset_video_to_pending 方法;
/health 加入队列线程存活状态; 密钥改用 ${ENV_VAR} 引用(.env 已支持自动加载),
不再明文写入 config.yaml。

工程质量: 新增 fam-edge/tests(32 个单元测试, 覆盖熔断器状态机/JSON 解析容错/
时间戳解析/bbox 坐标换算/多 key 解析), 新增 scripts/smoke_test.py(发版前接口
稳定性检查); 清理死代码(OllamaAdapter.analyze_frames、get_sync_delta 死分支、
未使用的 vision_timeout/max_concurrent_tasks 配置项); 修正 get_events_for_label
排序(改最近优先 + 过滤畸形历史时间戳)。

已部署 Oracle 并跑通 smoke test 全部 6 项检查。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 00:22:57 +08:00

233 lines
8.3 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.
"""
Frame-Service - 关键帧抽帧与人物头像裁剪Oracle 端集中计算)
新架构 v42026-08-21 重构):
- extract_frame: 按 video_id + 绝对时间戳ffmpeg 精确抽帧,磁盘缓存
- build_avatar: 用该人物候选事件里随视频分析一次性产出的 bboxperson_appearances
里的 [ymin,xmin,ymax,xmax])直接裁剪,不再额外调用模型定位人物。
v3 曾经的做法是在展示缩略图/头像时另外调用一次 Gemini 做人物定位校验,这会和核心
视频分析共用同一份 Gemini Key 抢配额(实测两边同时打 429且当时的坐标解析本身
也有 bug错把 Gemini 原生 [ymin,xmin,ymax,xmax]/1000 当成 [x1,y1,x2,y2]/1。v4
把 bbox 改成随视频分析那一次 Gemini 调用一并产出(见 ai_orchestrator/prompts.py 的
person_appearances.bbox 字段frame_service 只做纯本地的抽帧/裁剪,不再对任何
模型发起请求,从架构上消除配额争抢。
所有产物落到 CACHE_DIR以 (video_id, offset) 或 label 为 key避免重复计算。
NAS 侧只负责代理与展示,不做任何图像计算。
"""
import os
import shutil
import subprocess
import tempfile
from datetime import datetime
from .logger import setup_logger
logger = setup_logger('fam-edge.frame_service')
try:
from .config_loader import load_config
_CFG = load_config().get('frame_service', {})
except Exception:
_CFG = {}
CACHE_DIR = _CFG.get('cache_dir', '/opt/fam-edge/frames_cache')
FFMPEG = shutil.which('ffmpeg') or 'ffmpeg'
FFPROBE = shutil.which('ffprobe') or 'ffprobe'
AVATAR_W = int(_CFG.get('avatar_width', 160))
FRAME_W = int(_CFG.get('frame_width', 400))
def _ensure_dir():
os.makedirs(CACHE_DIR, exist_ok=True)
def _parse_dt(ts_str: str):
try:
return datetime.strptime(ts_str.strip()[:19], '%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError):
return None
def _run_ffmpeg(args, timeout=60) -> bool:
try:
proc = subprocess.run([FFMPEG, '-hide_banner', '-loglevel', 'error', *args],
capture_output=True, timeout=timeout)
return proc.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
def _out_size(path):
"""ffprobe 读图片宽高 -> (w, h) 或 None"""
try:
out = subprocess.run(
[FFPROBE, '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', path],
capture_output=True, text=True, timeout=20).stdout.strip()
w, h = out.split('x')
return int(w), int(h)
except Exception:
return None
def extract_frame(db, video_id: int, ts: str, width: int = FRAME_W) -> bytes:
"""按 video_id + 绝对时间戳抽帧,返回 jpeg bytes带磁盘缓存"""
if not FFMPEG:
return None
row = db.get_video_by_id(video_id)
if not row:
return None
local_path = row['local_path']
if not local_path or not os.path.isfile(local_path):
logger.warning(f"[video_id={video_id}] 视频文件不存在: {local_path}")
return None
start = _parse_dt(row['event_start_time'] or '')
t = _parse_dt(ts)
if t and start:
offset = max(0.0, (t - start).total_seconds())
else:
offset = 0.0
_ensure_dir()
cache = os.path.join(CACHE_DIR, f"frame_{video_id}_{int(offset)}.jpg")
if os.path.isfile(cache) and os.path.getsize(cache) > 0:
with open(cache, 'rb') as f:
return f.read()
fd, tmp = tempfile.mkstemp(suffix='.jpg', dir=CACHE_DIR)
os.close(fd)
try:
# 粗 seek-i 前,关键帧快进)+ 精 seek-i 后,逐帧解码):
# 纯输入端 seek 只能跳到最近关键帧,事件按 3s 密度打点时若 GOP 间隔
# 大于 3s 会抽到别的关键帧,导致画面与描述对不上。
coarse = max(0.0, offset - 5.0)
fine = offset - coarse
ok = _run_ffmpeg([
'-ss', f'{coarse:.3f}', '-i', local_path,
'-ss', f'{fine:.3f}',
'-frames:v', '1', '-vf', f'scale={width}:-2',
'-q:v', '5', '-f', 'image2', '-y', tmp,
], timeout=120)
if not ok or not os.path.isfile(tmp) or os.path.getsize(tmp) == 0:
return None
with open(tmp, 'rb') as f:
data = f.read()
os.replace(tmp, cache) # 原子落缓存
return data
finally:
if os.path.exists(tmp):
try:
os.remove(tmp)
except OSError:
pass
def _bbox_to_pixels(bbox, width, height):
"""bbox 为 [ymin,xmin,ymax,xmax]0-1000 归一化 -> 像素 (x1,y1,x2,y2)。"""
ymin, xmin, ymax, xmax = bbox
x1, y1 = xmin / 1000.0 * width, ymin / 1000.0 * height
x2, y2 = xmax / 1000.0 * width, ymax / 1000.0 * height
return int(x1), int(y1), int(x2), int(y2)
def _crop_ffmpeg(img_path: str, bbox_px, target_w) -> bool:
"""按像素 bbox 裁剪居中并缩小为正方形,覆盖 img_path。失败返回 False。"""
size = _out_size(img_path)
if not size:
return False
w, h = size
x1, y1, x2, y2 = bbox_px
x1, x2 = max(0, x1), min(w, x2)
y1, y2 = max(0, y1), min(h, y2)
if x2 - x1 <= 0 or y2 - y1 <= 0:
return False
px, py = int((x2 - x1) * 0.3), int((y2 - y1) * 0.3)
x1, y1 = max(0, x1 - px), max(0, y1 - py)
x2, y2 = min(w, x2 + px), min(h, y2 + py)
cw, ch = x2 - x1, y2 - y1
if cw <= 0 or ch <= 0:
return False
return _apply_filter(img_path, f'crop={cw}:{ch}:{x1}:{y1},scale={target_w}:{target_w}')
def _center_square_ffmpeg(img_path: str, target_w) -> bool:
"""整帧居中正方形裁剪缩小,当人物没有 bbox 时兜底"""
size = _out_size(img_path)
if not size:
return False
w, h = size
side = min(w, h)
cx, cy = (w - side) // 2, (h - side) // 2
return _apply_filter(img_path, f'crop={side}:{side}:{cx}:{cy},scale={target_w}:{target_w}')
def _apply_filter(img_path: str, vf: str) -> bool:
fd, tmp = tempfile.mkstemp(suffix='.jpg', dir=CACHE_DIR)
os.close(fd)
try:
if not _run_ffmpeg(['-i', img_path, '-vf', vf,
'-q:v', '5', '-frames:v', '1', '-f', 'image2', '-y', tmp]):
return False
os.replace(tmp, img_path)
return True
finally:
if os.path.exists(tmp):
try:
os.remove(tmp)
except OSError:
pass
def build_avatar(db, label: str, width: int = AVATAR_W) -> bytes:
"""为人物构建头像:用候选事件里已经随视频分析产出的 bbox 直接裁剪;
没有任何候选事件带 bbox 时,回退整帧居中裁剪。零额外模型调用。
cache key = avatar_{label}"""
_ensure_dir()
cache = os.path.join(CACHE_DIR, f"avatar_{label}.jpg")
if os.path.isfile(cache) and os.path.getsize(cache) > 0:
with open(cache, 'rb') as f:
return f.read()
events = db.get_events_for_label(label, limit=6)
if not events:
return None
fd, tmp = tempfile.mkstemp(suffix='.jpg', dir=CACHE_DIR)
os.close(fd)
try:
crop_data = None
first_good = None
for ev in events:
src = extract_frame(db, ev['video_id'], ev['ts'], width=600)
if src is None:
continue
with open(tmp, 'wb') as f:
f.write(src)
if first_good is None:
first_good = os.path.getsize(tmp) > 0
bbox = ev.get('bbox')
if bbox:
size = _out_size(tmp)
if size and _crop_ffmpeg(tmp, _bbox_to_pixels(bbox, *size), width):
crop_data = ('bbox', os.path.getsize(tmp))
break
# 兜底:首张可用的帧整帧居中(候选事件都没有 bbox 时)
if crop_data is None:
if first_good and _center_square_ffmpeg(tmp, width):
crop_data = ('fallback', os.path.getsize(tmp))
if crop_data is None:
return None
with open(tmp, 'rb') as f:
data = f.read()
if data:
os.replace(tmp, cache)
return data
finally:
if os.path.exists(tmp):
try:
os.remove(tmp)
except OSError:
pass