""" Frame-Service - 关键帧抽帧与人物头像裁剪(Oracle 端集中计算) 新架构 v4(2026-08-21 重构): - extract_frame: 按 video_id + 绝对时间戳,ffmpeg 精确抽帧,磁盘缓存 - build_avatar: 用该人物候选事件里随视频分析一次性产出的 bbox(person_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