feat: UI 时间轴重构 + 关键帧图片全链路持久化

界面重做(深色监控面板主题):
- 事件时间轴页: 左侧事件列表卡片 + 右侧关键帧时间轴
  (时间点 + 视频帧 + 人物/动作/衣着摘要 + 关注标记)
- 全局 CSS: 深色主题、卡片化按钮、统计卡、对话气泡、成员卡片
- 侧边栏任务队列状态徽章; 历史帧无图时优雅降级占位

关键帧持久化链路:
- Edge orchestrator: process_push_task 成功后把 keyframes
  base64 注入 frame_details[i].frame_image (位置对齐视觉输入帧)
- NAS event_receiver: 落库前 base64 解码写盘到
  fam-ui/static/frames/event_{id}/frame_{idx}.jpg
- payload 增量 ~300KB/事件 (6帧 jpeg q80), 队列/拉取均无压力

配置: fam-core/fam-ui 新增 storage.frame_image_dir
This commit is contained in:
ericwyuan
2026-08-20 19:50:36 +08:00
parent 963b6d7540
commit 0d5173f442
6 changed files with 695 additions and 180 deletions

View File

@@ -47,3 +47,7 @@ chat_handler:
# 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底)
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
timeout: 120
storage:
# 关键帧落盘目录event_receiver 写入fam-ui 读取展示时间轴)
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"

View File

@@ -36,3 +36,7 @@ chat_handler:
ollama_url: "http://100.x.x.20:11434/api/generate"
model_name: "llava-phi3"
timeout: 120
storage:
# 关键帧落盘目录event_receiver 写入fam-ui 读取展示时间轴)
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"

View File

@@ -3,21 +3,31 @@ Event-Receiver - Flask 蓝图,接收 Edge 回调,写库
处理逻辑:
1. 成功回调: 插入 monitor_events 1 条 + 遍历 frame_details 逐条插入 event_details
2. 对未命名的 abstract_label 自动 upsert 到 family_members
3. 更新 process_tasks 状态为 SUCCESS
4. 失败回调: 更新任务状态为 FAILED记录 failure_stage
2. frame_details 携带的关键帧 base64 落盘到 fam-ui 静态目录(供时间轴展示)
3. 对未命名的 abstract_label 自动 upsert 到 family_members
4. 更新 process_tasks 状态为 SUCCESS
5. 失败回调: 更新任务状态为 FAILED记录 failure_stage
"""
import os
import re
import json
import base64
from flask import Blueprint, request, jsonify
from ..logger import setup_logger
from ..config_loader import load_config
from .. import db_layer
logger = setup_logger('fam-core.event_receiver')
event_bp = Blueprint('event_receiver', __name__)
# 关键帧落盘目录fam-ui 读取展示fam-core 与 fam-ui 同机部署)
_cfg = load_config()
FRAME_IMAGE_DIR = _cfg.get('storage', {}).get(
'frame_image_dir',
'/volume1/web/sentinel-home-ai/fam-ui/static/frames')
# 匹配 "人物A" / "人物B" 等 abstract_label
_ABSTRACT_LABEL_PATTERN = re.compile(r'^人物[A-Z]$')
@@ -27,6 +37,28 @@ def _is_abstract_label(person: str) -> bool:
return bool(_ABSTRACT_LABEL_PATTERN.match(person))
def _save_frame_images(event_id: int, frame_details: list) -> int:
"""把 frame_details 中的 base64 关键帧落盘,返回成功张数"""
saved = 0
for frame in frame_details:
img_b64 = frame.pop('frame_image', None)
if not img_b64:
continue
idx = frame.get('frame_index', 0)
try:
out_dir = os.path.join(FRAME_IMAGE_DIR, f'event_{event_id}')
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, f'frame_{idx}.jpg')
with open(out_path, 'wb') as f:
f.write(base64.b64decode(img_b64))
saved += 1
except Exception as e:
logger.warning(f"[event_id={event_id}] 关键帧落盘失败 frame_{idx}: {e}")
if saved:
logger.info(f"[event_id={event_id}] 关键帧落盘 {saved} 张 -> {FRAME_IMAGE_DIR}")
return saved
def _upsert_abstract_members(frame_details: list):
"""对未命名的 abstract_label 自动 upsert 到 family_members"""
seen = {}
@@ -63,8 +95,14 @@ def apply_success_event(task_id, data: dict) -> int:
compute_provider=data.get('compute_provider', [])
)
# 2. 遍历 frame_details 逐条插入
# 2. 关键帧图片落盘先落盘再入库pop 掉 base64 后 insert避免大字段进 DB
frame_details = data.get('frame_details', [])
try:
_save_frame_images(event_id, frame_details)
except Exception as e:
logger.warning(f"[event_id={event_id}] 关键帧落盘异常(不影响入库): {e}")
# 3. 遍历 frame_details 逐条插入
for frame in frame_details:
db_layer.insert_event_detail(
event_id=event_id,