diff --git a/fam-core/tools/backfill_frames.py b/fam-core/tools/backfill_frames.py new file mode 100644 index 0000000..e00d97e --- /dev/null +++ b/fam-core/tools/backfill_frames.py @@ -0,0 +1,156 @@ +"""为历史事件补抽关键帧图(视频仍存于 NAS 时) + +Edge 注入关键帧 base64 上线前落库的事件没有帧图,本脚本从原始视频 +按 frame_timestamp - event_start_time 偏移重新抽帧,补齐到 UI 静态目录。 + +用法: + venv/bin/python tools/backfill_frames.py [--dry-run] [--event-id N] + +特性: +- 幂等: 单帧文件已存在即跳过,帧数齐全的事件整条跳过 +- NAS ffmpeg41 无 image2 muxer,必须用 -f singlejpeg 输出 jpg +- 抽帧尺寸与 NAS 预压缩一致(480p 等比缩放),-q:v 5 约 60-100KB/张 +""" +import os +import re +import sys +import argparse +import subprocess +from datetime import datetime + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')) + +import pymysql +from fam_core.config_loader import load_config + +FFMPEG = '/var/packages/CodecPack/target/bin/ffmpeg41' + + +def video_duration(path: str) -> float: + """解析 ffmpeg header 里的 Duration(无 ffprobe 环境)""" + try: + r = subprocess.run([FFMPEG, '-i', path], capture_output=True, text=True, timeout=60) + m = re.search(r'Duration:\s*(\d+):(\d+):(\d+)', r.stderr) + if m: + h, mi, s = (int(x) for x in m.groups()) + return h * 3600 + mi * 60 + s + except Exception: + pass + return 0.0 + + +def extract_frame(video: str, offset: float, out_path: str) -> bool: + cmd = [ + FFMPEG, '-y', + '-ss', f'{offset:.1f}', + '-i', video, + '-frames:v', '1', + '-vf', 'scale=854:480:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2', + '-q:v', '5', + '-f', 'singlejpeg', + out_path, + ] + try: + r = subprocess.run(cmd, capture_output=True, timeout=120) + return r.returncode == 0 and os.path.getsize(out_path) > 1024 + except Exception: + return False + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--dry-run', action='store_true', help='只统计不落盘') + ap.add_argument('--event-id', type=int, default=None, help='只处理指定事件') + args = ap.parse_args() + + cfg = load_config() + frame_dir = cfg.get('storage', {}).get( + 'frame_image_dir', '/volume1/web/sentinel-home-ai/fam-ui/static/frames') + db = cfg['database'] + conn = pymysql.connect( + host=db.get('host', '127.0.0.1'), + port=db.get('port', 3306), + user=db.get('user', 'root'), + password=db.get('password', ''), + database=db.get('database', 'sentinel_home_ai'), + unix_socket=db.get('unix_socket'), + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor, + ) + + try: + with conn.cursor() as cur: + sql = """ + SELECT me.event_id, me.event_start_time, pt.video_path, + (SELECT COUNT(*) FROM event_details ed + WHERE ed.event_id = me.event_id) AS detail_count + FROM monitor_events me + LEFT JOIN process_tasks pt ON pt.task_id = me.task_id + """ + params = () + if args.event_id: + sql += ' WHERE me.event_id = %s' + params = (args.event_id,) + sql += ' ORDER BY me.event_id' + cur.execute(sql, params) + events = cur.fetchall() + + stat = {'skip_complete': 0, 'skip_no_video': 0, 'extracted': 0, 'failed': 0, 'events': 0} + + for ev in events: + eid = ev['event_id'] + edir = os.path.join(frame_dir, f'event_{eid}') + existing = {f for f in os.listdir(edir) if f.endswith('.jpg')} if os.path.isdir(edir) else set() + + with conn.cursor() as cur: + cur.execute( + """SELECT frame_index, frame_timestamp FROM event_details + WHERE event_id = %s ORDER BY frame_index""", + (eid,)) + frames = cur.fetchall() + + missing = [f for f in frames if f'frame_{f["frame_index"]}.jpg' not in existing] + if not missing: + stat['skip_complete'] += 1 + continue + + video = ev.get('video_path') or '' + if not video or not os.path.isfile(video): + print(f'[event {eid}] 视频不存在,跳过 {len(missing)} 帧: {video}') + stat['skip_no_video'] += 1 + continue + + dur = video_duration(video) + start = ev['event_start_time'] + if isinstance(start, str): + start = datetime.strptime(start[:19], '%Y-%m-%d %H:%M:%S') + + os.makedirs(edir, exist_ok=True) + stat['events'] += 1 + print(f'[event {eid}] 补 {len(missing)}/{len(frames)} 帧 (视频 {os.path.basename(video)}, {dur:.0f}s)') + + for f in missing: + out_path = os.path.join(edir, f'frame_{f["frame_index"]}.jpg') + ts = f['frame_timestamp'] + if isinstance(ts, str): + ts = datetime.strptime(ts[:19], '%Y-%m-%d %H:%M:%S') + offset = (ts - start).total_seconds() + offset = max(1.0, min(offset, max(1.0, dur - 2))) + if args.dry_run: + print(f' dry-run frame_{f["frame_index"]} @ {offset:.0f}s') + continue + ok = extract_frame(video, offset, out_path) + stat['extracted' if ok else 'failed'] += 1 + if not ok: + print(f' 失败 frame_{f["frame_index"]} @ {offset:.0f}s') + if os.path.exists(out_path): + os.remove(out_path) + + print(f"\n完成: 事件 {stat['events']} 个已补 | 抽帧成功 {stat['extracted']} 失败 {stat['failed']} " + f"| 齐全跳过 {stat['skip_complete']} | 视频缺失跳过 {stat['skip_no_video']}") + finally: + conn.close() + + +if __name__ == '__main__': + main()