diff --git a/fam-edge/src/fam_edge/api_gateway/api_gateway.py b/fam-edge/src/fam_edge/api_gateway/api_gateway.py index 28efd4e..b4c4e9f 100644 --- a/fam-edge/src/fam_edge/api_gateway/api_gateway.py +++ b/fam-edge/src/fam_edge/api_gateway/api_gateway.py @@ -130,6 +130,21 @@ def video_thumb(video_id): return send_file(path, mimetype='image/jpeg') +@api_bp.route('/api/oracle/event//thumb', methods=['GET']) +def event_thumb(event_id): + """返回事件对应时间点的画面截图(token 校验)。 + + 由 video_processor 处理成功后按事件时间戳定位视频帧生成: + /opt/fam-edge/thumbs/ev_{event_id}.jpg + """ + if not _check_token(): + return jsonify({"error": "unauthorized"}), 401 + path = f"/opt/fam-edge/thumbs/ev_{event_id}.jpg" + if not os.path.isfile(path): + return jsonify({"error": "thumb_not_found"}), 404 + return send_file(path, mimetype='image/jpeg') + + @api_bp.route('/health', methods=['GET']) def health(): """健康检查""" diff --git a/fam-edge/src/fam_edge/oracle_db.py b/fam-edge/src/fam_edge/oracle_db.py index a6d178a..5defc4b 100644 --- a/fam-edge/src/fam_edge/oracle_db.py +++ b/fam-edge/src/fam_edge/oracle_db.py @@ -157,7 +157,8 @@ class OracleDB: return cur.fetchall() def mark_video_processed(self, video_id: int, summary: str, events: List[dict], - people: List[str], compute_provider: str): + people: List[str], compute_provider: str) -> List[int]: + """落库视频结果;返回新插入事件的 id 列表(与 events 参数一一对应,供事件截图用)""" now = _now_iso() self._conn.execute( "UPDATE videos SET status='done', summary_json=?, events_json=?, " @@ -166,14 +167,17 @@ class OracleDB: compute_provider, now, now, video_id)) # 事件落独立表,便于 NAS 拉取 self._conn.execute("DELETE FROM events WHERE video_id=?", (video_id,)) + event_ids: List[int] = [] for ev in events: - self._conn.execute( + cur = self._conn.execute( "INSERT INTO events (video_id, ts, description, person_list_json, " "is_attention_event) VALUES (?,?,?,?,?)", (video_id, ev.get('timestamp', ''), ev.get('description', ''), json.dumps(ev.get('people', []), ensure_ascii=False), 1 if ev.get('is_attention_event') else 0)) + event_ids.append(cur.lastrowid) self._conn.commit() + return event_ids def mark_video_failed(self, video_id: int, error: str = ''): now = _now_iso() diff --git a/fam-edge/src/fam_edge/video_processor.py b/fam-edge/src/fam_edge/video_processor.py index efec9a2..9cc5c65 100644 --- a/fam-edge/src/fam_edge/video_processor.py +++ b/fam-edge/src/fam_edge/video_processor.py @@ -139,11 +139,12 @@ class VideoProcessor: "is_attention_event": bool(ev.get('is_attention_event', False)), }) - self.db.mark_video_processed(video_id, summary, norm_events, people, provider) - # 抽首帧生成缩略图(供前端展示;失败不影响主流程) + event_ids = self.db.mark_video_processed(video_id, summary, norm_events, people, provider) + # 缩略图 + 每个事件对应时间点的画面截图(供前端展示;失败不影响主流程) row = self.db.get_video_by_id(video_id) if row and row['local_path']: self._generate_thumb(video_id, row['local_path']) + self._generate_event_thumbs(video_id, row['local_path'], norm_events, event_ids) # 更新 people 表(标签级,待 person_service 合并) for p in people: @@ -152,16 +153,18 @@ class VideoProcessor: 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 - db_path = self.config.get('oracle_db', {}).get( - 'path', '/opt/fam-edge/data/oracle.db') - thumbs_dir = os.path.abspath(os.path.join( - os.path.dirname(db_path), '..', 'thumbs')) - os.makedirs(thumbs_dir, exist_ok=True) - out = os.path.join(thumbs_dir, f"{video_id}.jpg") + out = os.path.join(self._thumbs_dir(), f"{video_id}.jpg") cap = cv2.VideoCapture(video_path) try: ok, frame = cap.read() @@ -179,3 +182,53 @@ class VideoProcessor: 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, + events: List[dict], event_ids: List[int]): + """按事件时间戳定位视频帧,生成事件画面截图 ev_{event_id}.jpg""" + try: + import cv2 + from datetime import datetime as _dt + except Exception as e: + logger.warning(f"事件截图依赖缺失 video_id={video_id}: {e}") + return + try: + start_str = self.db.get_video_by_id(video_id)['event_start_time'] or '' + start_dt = None + if start_str: + try: + start_dt = _dt.strptime(start_str, '%Y-%m-%d %H:%M:%S') + except ValueError: + pass + thumbs = self._thumbs_dir() + cap = cv2.VideoCapture(video_path) + try: + for ev, eid in zip(events, event_ids): + offset = 0.0 + ts = str(ev.get('timestamp', '')) + if start_dt and ts: + try: + ev_dt = _dt.strptime(ts[:19], '%Y-%m-%d %H:%M:%S') + offset = (ev_dt - start_dt).total_seconds() + except ValueError: + pass + if offset < 0: + offset = 0.0 + cap.set(cv2.CAP_PROP_POS_MSEC, int(offset * 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={offset:.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={offset:.0f}s)") + finally: + cap.release() + except Exception as e: + logger.warning(f"事件截图异常 video_id={video_id}: {e}") diff --git a/fam-ui/src/app.py b/fam-ui/src/app.py index 02ff341..a52c79a 100644 --- a/fam-ui/src/app.py +++ b/fam-ui/src/app.py @@ -279,7 +279,7 @@ def page_header(icon: str, title: str, sub: str = ''): def render_event_list(events: list): - """渲染事件时间线:左相对时间 + 右事件卡(人物/关注徽章 + 描述)""" + """渲染事件时间线:左相对时间 + 右事件卡(事件画面截图 + 人物/关注徽章 + 描述)""" items = [] for e in events: ts = parse_ts(e.get('ts')) @@ -288,6 +288,16 @@ def render_event_list(events: list): persons = parse_persons(e.get('person_list_json')) attention = bool(e.get('is_attention_event')) desc = e.get('description') or '(无描述)' + eid = e.get('id') + + # 事件对应时间点的画面截图(Oracle 带 token 接口;加载失败自动隐藏) + img_html = '' + if _oracle_url and eid: + img_html = ( + f'') badges = ''.join( f'{esc(p)}' for p in sorted(persons)) @@ -298,7 +308,7 @@ def render_event_list(events: list): f'
' f'
{esc(time_label)}' f'{f"{esc(camera)}" if camera else ""}
' - f'
{badges}' + f'
{img_html}{badges}' f'
{esc(desc)}
' f'
' ) @@ -546,7 +556,7 @@ if page == "🕒 事件时间轴": try: cursor = conn.cursor() cursor.execute( - """SELECT e.ts, e.description, e.person_list_json, + """SELECT e.id, e.ts, e.description, e.person_list_json, e.is_attention_event, v.camera_name FROM sync_events e JOIN sync_videos v ON e.video_id = v.id