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,

View File

@@ -171,6 +171,18 @@ class AIOrchestrator:
adapter.get_circuit_breaker().record_failure()
return model_outputs
@staticmethod
def _attach_frame_images(frame_details: List[dict], frame_paths: List[str]) -> None:
"""把关键帧图片 base64 附加到 frame_details按位置对齐视觉分析输入帧"""
for i, fd in enumerate(frame_details):
if i >= len(frame_paths):
break
try:
with open(frame_paths[i], 'rb') as f:
fd['frame_image'] = base64.b64encode(f.read()).decode('ascii')
except OSError as e:
logger.warning(f"关键帧图片读取失败: {frame_paths[i]}: {e}")
def format_cloud_result(self, provider: str, raw_result: dict,
known_members_context: str = '',
task_id: int = 0) -> dict:
@@ -481,6 +493,10 @@ class AIOrchestrator:
fusion_result = self.format_cloud_result(
provider, model_outputs[provider], known_members, task_id)
# 5. 附加关键帧图片NAS 落盘后供 UI 时间轴展示)
frame_details = fusion_result.get('frame_details', [])
self._attach_frame_images(frame_details, compressed_frames)
total_ms = int((time.time() - start_time) * 1000)
log_task(logger, task_id, 'overall', '推送任务完成', duration_ms=total_ms)
@@ -492,7 +508,7 @@ class AIOrchestrator:
"camera_name": task_data.get('camera_name', ''),
"global_summary": fusion_result.get('global_summary', ''),
"entities_json": fusion_result.get('entities_json', []),
"frame_details": fusion_result.get('frame_details', []),
"frame_details": frame_details,
"compute_provider": fusion_result.get('compute_provider', []),
"error_message": None
}

View File

@@ -10,3 +10,7 @@ database:
password: "iLoveJava5!"
database: "sentinel_home_ai"
unix_socket: "/run/mysqld/mysqld10.sock"
storage:
# 关键帧目录fam-core event_receiver 落盘UI 读取展示时间轴)
frame_image_dir: "/volume1/web/sentinel-home-ai/fam-ui/static/frames"

View File

@@ -1,28 +1,32 @@
"""
FAM-UI - 家庭多模态智能监控系统前端
FAM-UI - 家庭多模态智能监控系统前端 v2
Streamlit 直读 MariaDB展示:
- 事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页
深色监控面板主题 + 关键帧时间轴:
- 事件时间轴页: 左侧事件列表,右侧时间轴(时间点 + 关键帧 + 摘要)
- AI 对话 / 对话历史 / 成员命名 / 统计图表
"""
import os
import sys
import json
import base64
import html as _html
import requests
import streamlit as st
import pymysql
import pymysql.cursors
import pandas as pd
import json
from datetime import datetime, date
# 添加共享模块路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from config_loader import load_config
# 加载配置
_cfg = load_config()
_core_url = _cfg.get('core_url', 'http://127.0.0.1:8000')
_db_cfg = _cfg.get('database', {})
FRAME_DIR = _cfg.get('storage', {}).get(
'frame_image_dir', '/volume1/web/sentinel-home-ai/fam-ui/static/frames')
esc = _html.escape
def get_db_conn():
@@ -45,8 +49,30 @@ def serialize_datetime(obj):
return str(obj)
def parse_ts(ts):
"""'2026-08-14 22:31:15' / datetime -> datetime"""
if isinstance(ts, datetime):
return ts
try:
return datetime.strptime(str(ts)[:19], '%Y-%m-%d %H:%M:%S')
except ValueError:
return None
def load_frame_b64(event_id, frame_index):
"""读取落盘关键帧为 base64不存在返回 None"""
path = os.path.join(FRAME_DIR, f'event_{event_id}', f'frame_{frame_index}.jpg')
try:
if os.path.isfile(path):
with open(path, 'rb') as f:
return base64.b64encode(f.read()).decode('ascii')
except OSError:
pass
return None
# ============================================================
# 页面配置
# 页面配置 & 全局样式
# ============================================================
st.set_page_config(
page_title="家庭智能监控",
@@ -55,136 +81,536 @@ st.set_page_config(
initial_sidebar_state="expanded"
)
# 侧边栏导航
st.sidebar.title("🏠 家庭智能监控")
GLOBAL_CSS = """
<style>
:root {
--bg: #0b0e14;
--panel: #131826;
--panel-2: #161b28;
--border: #232a3b;
--border-hi: #33405c;
--text: #e2e8f0;
--text-dim: #8b93a7;
--text-mute: #64748b;
--accent: #5b8cff;
--accent-soft: rgba(91,140,255,.15);
--danger: #ff6b6b;
--ok: #34d399;
}
.stApp { background: var(--bg); color: var(--text); }
#MainMenu, footer, header { visibility: hidden; }
h1, h2, h3 { color: #f1f5f9 !important; font-weight: 700 !important; }
a { color: var(--accent) !important; }
section[data-testid="stSidebar"] {
background: #0e1220 !important;
border-right: 1px solid var(--border);
}
[data-testid="stSidebar"] * { color: var(--text-dim) !important; }
[data-testid="stSidebar"] .stRadio label { font-size: 14px; }
/* Streamlit 组件暗色适配 */
.stTabs [data-baseweb="tab"] { color: var(--text-dim); }
.stTabs [aria-selected="true"] { color: #fff !important; }
div[data-testid="stDataFrame"] { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.stProgress > div > div { background: var(--accent); }
hr { border-color: var(--border) !important; }
/* 输入组件 */
.stTextInput > div > div > input, .stTextArea textarea {
background: var(--panel) !important;
border: 1px solid var(--border) !important;
color: var(--text) !important;
border-radius: 10px !important;
}
.stTextInput > div > div > input:focus, .stTextArea textarea:focus {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent-soft) !important;
}
div[data-testid="stDateInput"] > div > div > input {
background: var(--panel) !important;
border: 1px solid var(--border) !important;
color: var(--text) !important;
border-radius: 10px !important;
}
/* 按钮卡片化 */
div[data-testid="stButton"] > button {
background: var(--panel);
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px 14px;
font-size: 13px;
transition: all .18s ease;
}
div[data-testid="stButton"] > button:hover {
border-color: var(--accent);
color: #fff;
background: #182034;
box-shadow: 0 4px 14px rgba(91,140,255,.12);
}
div[data-testid="stButton"] > button[kind="primary"],
.stButton > button[kind="primary"] {
background: linear-gradient(135deg, #3d6bff, #5b8cff);
border: none;
color: #fff;
font-weight: 600;
box-shadow: 0 4px 14px rgba(91,140,255,.25);
}
/* 页面标题 */
.fam-pagehead {
display: flex; align-items: center; gap: 12px;
margin: 4px 0 20px 0;
}
.fam-pagehead .fp-ico {
width: 42px; height: 42px; border-radius: 12px;
background: linear-gradient(135deg, rgba(91,140,255,.25), rgba(124,91,255,.18));
border: 1px solid rgba(91,140,255,.35);
display: flex; align-items: center; justify-content: center;
font-size: 21px;
}
.fam-pagehead .fp-title { font-size: 21px; font-weight: 700; color: #f1f5f9; }
.fam-pagehead .fp-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
/* 统计卡 */
.stat-row { display: flex; gap: 14px; margin: 0 0 22px 0; flex-wrap: wrap; }
.stat-card {
flex: 1; min-width: 130px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px 18px;
}
.stat-card .s-val {
font-size: 26px; font-weight: 700; color: #f1f5f9;
font-variant-numeric: tabular-nums; line-height: 1.2;
}
.stat-card .s-label { font-size: 12px; color: var(--text-mute); margin-top: 3px; }
.stat-card.warn .s-val { color: #ff9b9b; }
.stat-card.ok .s-val { color: #6ee7b7; }
/* 事件头卡片 */
.ev-head {
background: linear-gradient(135deg, rgba(91,140,255,.12), rgba(124,91,255,.07));
border: 1px solid #26304a;
border-radius: 16px;
padding: 18px 22px;
margin-bottom: 26px;
}
.ev-head .ev-title { font-size: 18px; font-weight: 700; color: #f1f5f9; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ev-head .ev-range {
font-size: 13px; color: var(--text-dim); margin-top: 6px;
font-variant-numeric: tabular-nums;
}
.ev-head .ev-summary { margin-top: 10px; font-size: 14px; color: #cbd5e1; line-height: 1.65; }
/* 徽章 */
.bdg {
display: inline-block; font-size: 11px; padding: 3px 10px;
border-radius: 999px; font-weight: 600; line-height: 1.4;
}
.bdg-person { background: var(--accent-soft); color: #8fb0ff; border: 1px solid rgba(91,140,255,.32); }
.bdg-att { background: rgba(255,107,107,.14); color: #ff9b9b; border: 1px solid rgba(255,107,107,.35); }
.bdg-cam { background: rgba(52,211,153,.12); color: #6ee7b7; border: 1px solid rgba(52,211,153,.3); }
.bdg-model { background: rgba(148,163,184,.12); color: #a8b3c7; border: 1px solid rgba(148,163,184,.28); }
/* ===== 关键帧时间轴 ===== */
.fam-timeline { margin-top: 4px; }
.tl-item {
display: grid;
grid-template-columns: 86px 34px 1fr;
position: relative;
padding-bottom: 26px;
}
.tl-item:last-child { padding-bottom: 4px; }
.tl-time { padding: 18px 8px 0 0; text-align: right; }
.tl-time .hm {
display: block; font-size: 16px; font-weight: 700; color: #cbd5e1;
font-variant-numeric: tabular-nums; line-height: 1.15;
}
.tl-time .ss { font-size: 11px; color: var(--text-mute); font-variant-numeric: tabular-nums; }
.tl-rail { position: relative; }
.tl-rail::before {
content: ''; position: absolute; left: 50%; top: 0; bottom: -26px;
width: 2px; margin-left: -1px; background: #1e2740;
}
.tl-item:last-child .tl-rail::before { display: none; }
.tl-dot {
position: absolute; left: 50%; top: 21px;
width: 11px; height: 11px; margin-left: -5.5px;
border-radius: 50%; background: var(--accent);
box-shadow: 0 0 0 4px rgba(91,140,255,.16);
z-index: 1;
}
.tl-item.attention .tl-dot { background: var(--danger); box-shadow: 0 0 0 4px rgba(255,107,107,.16); }
.tl-card {
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
display: flex; gap: 14px;
transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease;
}
.tl-card:hover { border-color: var(--border-hi); transform: translateX(3px); box-shadow: 0 6px 20px rgba(0,0,0,.35); }
.tl-item.attention .tl-card { border-color: rgba(255,107,107,.38); }
.tl-img {
width: 224px; height: 126px; border-radius: 10px;
object-fit: cover; background: #0d1019; flex-shrink: 0;
border: 1px solid #1c2233;
}
.tl-noimg {
width: 224px; height: 126px; border-radius: 10px; flex-shrink: 0;
background:
linear-gradient(135deg, #12161f 25%, #0f1320 25%, #0f1320 50%, #12161f 50%, #12161f 75%, #0f1320 75%);
background-size: 14px 14px;
border: 1px dashed #26304a;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #475069; font-size: 12px; gap: 6px;
}
.tl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tl-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 9px; }
.tl-action { font-size: 14px; color: var(--text); line-height: 1.6; flex: 1; }
.tl-action .no-action { color: var(--text-mute); }
.tl-meta { margin-top: 9px; font-size: 12px; color: var(--text-mute); }
/* 空态 */
.fam-empty {
border: 1px dashed var(--border); border-radius: 14px;
padding: 40px 20px; text-align: center; color: var(--text-mute);
font-size: 14px; background: rgba(19,24,38,.5);
}
.fam-empty .fe-ico { font-size: 30px; display: block; margin-bottom: 10px; opacity: .55; }
/* 对话气泡 */
.chat-q, .chat-a {
border-radius: 14px; padding: 14px 18px; margin-bottom: 14px;
font-size: 14px; line-height: 1.7;
}
.chat-q { background: var(--accent-soft); border: 1px solid rgba(91,140,255,.28); }
.chat-a { background: var(--panel); border: 1px solid var(--border); }
.chat-who { font-size: 11px; color: var(--text-mute); margin-bottom: 6px; }
/* 成员卡 */
.member-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.member-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
padding: 16px 18px;
}
@media (max-width: 960px) {
.tl-card { flex-direction: column; }
.tl-img, .tl-noimg { width: 100%; height: 180px; }
.stat-card { min-width: 45%; }
}
</style>
"""
def page_header(icon: str, title: str, sub: str = ''):
st.markdown(
f'<div class="fam-pagehead">'
f'<div class="fp-ico">{icon}</div>'
f'<div><div class="fp-title">{esc(title)}</div>'
f'{f"<div class=fp-sub>{esc(sub)}</div>" if sub else ""}'
f'</div></div>',
unsafe_allow_html=True)
def render_timeline(details: list, event_id: int):
"""渲染关键帧时间轴:左时间点 + 中轴 + 右(帧图 + 摘要)"""
items = []
for d in details:
ts = parse_ts(d['frame_timestamp'])
hm = ts.strftime('%H:%M') if ts else '--:--'
ss = ts.strftime(':%S') if ts else ''
person = d.get('person') or '无人'
attention = bool(d.get('is_attention_event'))
action = d.get('action') or ''
clothing = d.get('clothing') or ''
providers = d.get('source_providers')
if isinstance(providers, str):
try:
providers = json.loads(providers)
except ValueError:
providers = [providers] if providers else []
providers = providers or []
model = providers[0] if providers else ''
img_b64 = load_frame_b64(event_id, d.get('frame_index', 0))
if img_b64:
img_html = (f'<img class="tl-img" alt="关键帧" '
f'src="data:image/jpeg;base64,{img_b64}">')
else:
img_html = ('<div class="tl-noimg"><span>📷</span>暂无帧图</div>')
badges = [f'<span class="bdg bdg-person">{esc(person)}</span>']
if attention:
badges.append('<span class="bdg bdg-att">⚠ 需关注</span>')
badge_html = ''.join(badges)
action_html = esc(action) if action else '<span class="no-action">未识别到明显活动</span>'
meta_parts = []
if clothing:
meta_parts.append(f'衣着: {esc(clothing)}')
if model:
meta_parts.append(f'模型: {esc(model)}')
meta_html = f'<div class="tl-meta">{" · ".join(meta_parts)}</div>' if meta_parts else ''
items.append(
f'<div class="tl-item {"attention" if attention else ""}">'
f'<div class="tl-time"><span class="hm">{hm}</span><span class="ss">{ss}</span></div>'
f'<div class="tl-rail"><span class="tl-dot"></span></div>'
f'<div class="tl-card">{img_html}'
f'<div class="tl-info"><div class="tl-badges">{badge_html}</div>'
f'<div class="tl-action">{action_html}</div>{meta_html}</div>'
f'</div></div>'
)
st.markdown(
f'<div class="fam-timeline">{"".join(items)}</div>',
unsafe_allow_html=True)
# ============================================================
# 侧边栏
# ============================================================
st.markdown(GLOBAL_CSS, unsafe_allow_html=True)
st.sidebar.markdown(
'<div style="padding:6px 2px 14px 2px;">'
'<div style="font-size:17px;font-weight:700;color:#f1f5f9;">🏠 家庭智能监控</div>'
'<div style="font-size:11px;color:#64748b;margin-top:3px;">SENTINEL HOME AI</div>'
'</div>', unsafe_allow_html=True)
page = st.sidebar.radio("功能页面", [
"📊 事件列表",
"🕒 事件时间轴",
"💬 AI 对话",
"📝 对话历史",
"👤 成员命名",
"📈 统计图表"
])
], label_visibility="collapsed")
# 侧边栏底部:任务队列状态
try:
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute("SELECT status, COUNT(*) AS c FROM process_tasks GROUP BY status")
rows = {r['status']: r['c'] for r in cursor.fetchall()}
finally:
conn.close()
pending = rows.get('PENDING', 0)
processing = rows.get('PROCESSING', 0)
failed = rows.get('FAILED', 0)
st.sidebar.markdown('---')
st.sidebar.markdown(
f'<div style="font-size:11px;color:#64748b;line-height:2.1;">'
f'任务队列<br>'
f'待处理 <b style="color:#cbd5e1">{pending}</b> · '
f'处理中 <b style="color:#8fb0ff">{processing}</b> · '
f'失败 <b style="color:#ff9b9b">{failed}</b>'
f'</div>', unsafe_allow_html=True)
except Exception:
pass
# ============================================================
# 事件列表
# 事件时间轴
# ============================================================
if page == "📊 事件列表":
st.title("📊 监控事件列表")
if page == "🕒 事件时间轴":
page_header('🕒', '事件时间轴', '关键时间点 · 视频帧 · 信息摘要')
# 日期筛选
col1, col2 = st.columns([1, 3])
with col1:
date_filter = st.date_input("日期筛选", value=None)
with st.container():
col_date, col_sp = st.columns([1, 3])
with col_date:
date_filter = st.date_input("日期", value=None)
# 分页
page_size = 20
date_str = date_filter.isoformat() if date_filter else None
# 统计卡
try:
conn = get_db_conn()
try:
cursor = conn.cursor()
if date_str:
cursor.execute("""
SELECT COUNT(DISTINCT me.event_id) AS ev,
COUNT(ed.detail_id) AS fr,
COALESCE(SUM(ed.is_attention_event), 0) AS att,
COUNT(DISTINCT CASE WHEN ed.person <> '无人' THEN ed.person END) AS ps
FROM monitor_events me
LEFT JOIN event_details ed ON ed.event_id = me.event_id
WHERE DATE(me.event_start_time) = %s
""", (date_str,))
else:
cursor.execute("""
SELECT COUNT(DISTINCT me.event_id) AS ev,
COUNT(ed.detail_id) AS fr,
COALESCE(SUM(ed.is_attention_event), 0) AS att,
COUNT(DISTINCT CASE WHEN ed.person <> '无人' THEN ed.person END) AS ps
FROM monitor_events me
LEFT JOIN event_details ed ON ed.event_id = me.event_id
""")
s = cursor.fetchone() or {}
finally:
conn.close()
st.markdown(
f'<div class="stat-row">'
f'<div class="stat-card"><div class="s-val">{s.get("ev", 0)}</div><div class="s-label">监控事件</div></div>'
f'<div class="stat-card"><div class="s-val">{s.get("fr", 0)}</div><div class="s-label">关键帧</div></div>'
f'<div class="stat-card ok"><div class="s-val">{s.get("ps", 0)}</div><div class="s-label">出现人物</div></div>'
f'<div class="stat-card warn"><div class="s-val">{s.get("att", 0)}</div><div class="s-label">需关注</div></div>'
f'</div>', unsafe_allow_html=True)
except Exception:
pass
# 事件列表
page_size = 15
if 'event_page' not in st.session_state:
st.session_state.event_page = 0
offset = st.session_state.event_page * page_size
conn = get_db_conn()
try:
cursor = conn.cursor()
# 查询条件
date_str = date_filter.isoformat() if date_filter else None
if date_str:
cursor.execute(
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
me.camera_name, me.global_summary, me.compute_provider, me.created_at,
me.camera_name, me.global_summary, me.compute_provider,
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
FROM monitor_events me
WHERE DATE(me.event_start_time) = %s
ORDER BY me.event_start_time DESC
LIMIT %s OFFSET %s""",
(date_str, page_size, offset)
)
(date_str, page_size, offset))
else:
cursor.execute(
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
me.camera_name, me.global_summary, me.compute_provider, me.created_at,
me.camera_name, me.global_summary, me.compute_provider,
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
FROM monitor_events me
ORDER BY me.event_start_time DESC
LIMIT %s OFFSET %s""",
(page_size, offset)
)
(page_size, offset))
events = cursor.fetchall()
finally:
conn.close()
if not events:
st.info("暂无事件数据")
st.markdown(
'<div class="fam-empty"><span class="fe-ico">🗓</span>'
'该日期暂无监控事件</div>', unsafe_allow_html=True)
else:
# 默认选中最新事件
if 'selected_event_id' not in st.session_state:
st.session_state.selected_event_id = events[0]['event_id']
# 选中的事件不在当前列表时重置
valid_ids = {e['event_id'] for e in events}
if st.session_state.selected_event_id not in valid_ids:
st.session_state.selected_event_id = events[0]['event_id']
selected_id = st.session_state.selected_event_id
col_list, col_detail = st.columns([1, 2.35], gap='large')
with col_list:
st.markdown(
f'<div style="font-size:13px;font-weight:600;color:#8b93a7;'
f'margin-bottom:10px;">事件列表 · {len(events)} 条</div>',
unsafe_allow_html=True)
for ev in events:
ts = parse_ts(ev['event_start_time'])
time_label = ts.strftime('%H:%M') if ts else '--:--'
summary = (ev.get('global_summary') or '').strip()
if not summary:
summary = '暂无摘要'
summary_short = summary if len(summary) <= 26 else summary[:26] + ''
is_selected = ev['event_id'] == selected_id
if st.button(
f"{'' if is_selected else ''}{time_label} · {ev.get('camera_name') or '未知'} · {ev['detail_count']}",
key=f"evbtn_{ev['event_id']}",
type="primary" if is_selected else "secondary",
use_container_width=True
):
st.session_state.selected_event_id = ev['event_id']
st.rerun()
st.markdown(
f'<div style="font-size:11px;color:#64748b;margin:-4px 2px 12px 2px;'
f'line-height:1.5;">{esc(summary_short)}</div>',
unsafe_allow_html=True)
# 分页
nav1, nav2, nav3 = st.columns(3)
with nav1:
if st.button("← 上一页", use_container_width=True,
disabled=st.session_state.event_page == 0):
st.session_state.event_page -= 1
st.rerun()
with nav3:
if st.button("下一页 →", use_container_width=True,
disabled=len(events) < page_size):
st.session_state.event_page += 1
st.rerun()
with col_detail:
ev = next(e for e in events if e['event_id'] == selected_id)
start = parse_ts(ev['event_start_time'])
end = parse_ts(ev['event_end_time'])
range_str = ''
if start and end:
span_min = (end - start).total_seconds() / 60
range_str = (f"{start.strftime('%Y-%m-%d %H:%M:%S')}"
f"{end.strftime('%H:%M:%S')}"
f"(约 {int(span_min)} 分钟)")
providers = ev.get('compute_provider', '[]')
if isinstance(providers, str):
try:
providers = json.loads(providers)
except ValueError:
providers = []
model_badges = ''.join(
f'<span class="bdg bdg-model">{esc(p)}</span>' for p in (providers or []))
with st.container():
col1, col2, col3 = st.columns([2, 1, 1])
with col1:
start_time = serialize_datetime(ev['event_start_time'])
end_time = serialize_datetime(ev['event_end_time'])
st.markdown(f"**{ev['camera_name'] or '未知摄像头'}** | {start_time} ~ {end_time}")
st.markdown(f"_{ev['global_summary']}_")
with col2:
st.markdown(f"明细: {ev['detail_count']}")
st.markdown(f"模型: {', '.join(providers)}")
with col3:
if st.button("查看明细", key=f"detail_{ev['event_id']}"):
st.session_state.selected_event_id = ev['event_id']
st.markdown(
f'<div class="ev-head">'
f'<div class="ev-title">'
f'<span>{esc(ev.get("camera_name") or "未知摄像头")}</span>'
f'<span class="bdg bdg-cam">事件 #{ev["event_id"]}</span>'
f'{model_badges}</div>'
f'<div class="ev-range">⏱ {esc(range_str)}</div>'
f'<div class="ev-summary">{esc(ev.get("global_summary") or "暂无全局摘要")}</div>'
f'</div>', unsafe_allow_html=True)
# 展开明细
if st.session_state.get('selected_event_id') == ev['event_id']:
conn = get_db_conn()
try:
cursor = conn.cursor()
cursor.execute(
"""SELECT frame_index, frame_timestamp, camera_name, person,
action, clothing, is_attention_event, source_providers
FROM event_details
WHERE event_id = %s
ORDER BY frame_index ASC""",
(ev['event_id'],)
)
(selected_id,))
details = cursor.fetchall()
df = pd.DataFrame([{
'帧号': d['frame_index'],
'时间': serialize_datetime(d['frame_timestamp']),
'位置': d['camera_name'],
'人物': d['person'],
'动作': d['action'],
'衣着': d['clothing'],
'关注': '⚠️' if d['is_attention_event'] else '',
'模型来源': json.dumps(d['source_providers']) if isinstance(d['source_providers'], str) else json.dumps(d['source_providers'])
} for d in details])
st.dataframe(df, use_container_width=True, hide_index=True)
st.divider()
# 分页控制
col1, col2, col3 = st.columns([1, 1, 1])
with col1:
if st.button("上一页") and st.session_state.event_page > 0:
st.session_state.event_page -= 1
st.rerun()
with col2:
st.markdown(f"{st.session_state.event_page + 1}")
with col3:
if st.button("下一页") and len(events) == page_size:
st.session_state.event_page += 1
st.rerun()
finally:
conn.close()
if details:
render_timeline(details, selected_id)
else:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">🎞</span>'
'该事件暂无关键帧明细</div>', unsafe_allow_html=True)
# ============================================================
# AI 对话页
# ============================================================
elif page == "💬 AI 对话":
st.title("💬 AI 对话")
page_header('💬', 'AI 对话', '基于监控数据的智能问答')
# 快捷人物按钮
conn = get_db_conn()
try:
cursor = conn.cursor()
@@ -199,7 +625,6 @@ elif page == "💬 AI 对话":
with col2:
queried_date = st.date_input("查询日期", value=date.today())
# 快捷提问
if named:
quick_person = st.selectbox("快捷选择成员", [""] + named)
if quick_person:
@@ -211,7 +636,6 @@ elif page == "💬 AI 对话":
f"今天{queried_person}的活动时间线是什么?",
]
selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions)
user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "")
if st.button("提问", type="primary"):
@@ -233,9 +657,15 @@ elif page == "💬 AI 对话":
)
if resp.status_code == 200:
data = resp.json()
st.success("回答:")
st.markdown(
f'<div class="chat-q"><div class="chat-who">❓ 提问</div>'
f'{esc(user_question)}</div>', unsafe_allow_html=True)
st.markdown(
f'<div class="chat-a"><div class="chat-who">🤖 回答</div>'
f'</div>', unsafe_allow_html=True)
st.markdown(data['answer'])
st.caption(f"上下文: {data.get('context_summary', '')}")
if data.get('context_summary'):
st.caption(f"上下文: {data['context_summary']}")
else:
st.error(f"请求失败: {resp.status_code} {resp.text}")
except requests.ConnectionError:
@@ -248,7 +678,7 @@ elif page == "💬 AI 对话":
# 对话历史页
# ============================================================
elif page == "📝 对话历史":
st.title("📝 对话历史")
page_header('📝', '对话历史', '历史问答记录')
if 'chat_page' not in st.session_state:
st.session_state.chat_page = 0
@@ -268,52 +698,55 @@ elif page == "📝 对话历史":
(page_size, offset)
)
history = cursor.fetchall()
if not history:
st.info("暂无对话记录")
else:
for h in history:
with st.container():
col1, col2 = st.columns([1, 4])
with col1:
created = serialize_datetime(h['created_at'])
st.markdown(f"**{h['queried_person']}**")
st.caption(created)
if h['queried_date']:
st.caption(f"日期: {serialize_datetime(h['queried_date'])}")
with col2:
st.markdown(f"**Q:** {h['user_question']}")
st.markdown(f"**A:** {h['ai_answer']}")
st.divider()
# 分页
col1, col2, col3 = st.columns([1, 1, 1])
with col1:
if st.button("上一页") and st.session_state.chat_page > 0:
st.session_state.chat_page -= 1
st.rerun()
with col2:
st.markdown(f"{st.session_state.chat_page + 1}")
with col3:
if st.button("下一页") and len(history) == page_size:
st.session_state.chat_page += 1
st.rerun()
finally:
conn.close()
if not history:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">💬</span>'
'暂无对话记录</div>', unsafe_allow_html=True)
else:
for h in history:
created = serialize_datetime(h['created_at'])
st.markdown(
f'<div class="chat-q"><div class="chat-who">'
f'👤 {esc(h.get("queried_person") or "未知")} · {esc(created)}</div>'
f'{esc(h["user_question"])}</div>',
unsafe_allow_html=True)
st.markdown(
f'<div class="chat-a"><div class="chat-who">🤖 回答</div></div>',
unsafe_allow_html=True)
st.markdown(h['ai_answer'] or '')
st.markdown('<div style="height:14px"></div>', unsafe_allow_html=True)
nav1, nav2, nav3 = st.columns([1, 1, 1])
with nav1:
if st.button("← 上一页", disabled=st.session_state.chat_page == 0):
st.session_state.chat_page -= 1
st.rerun()
with nav2:
st.markdown(
f'<div style="text-align:center;color:#64748b;font-size:13px;'
f'padding-top:8px;">第 {st.session_state.chat_page + 1} 页</div>',
unsafe_allow_html=True)
with nav3:
if st.button("下一页 →", disabled=len(history) < page_size):
st.session_state.chat_page += 1
st.rerun()
# ============================================================
# 成员命名页
# ============================================================
elif page == "👤 成员命名":
st.title("👤 家庭成员命名")
page_header('👤', '家庭成员命名', '为识别到的人物起名')
# 未命名成员
st.subheader("未命名人物")
conn = get_db_conn()
try:
cursor = conn.cursor()
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:6px 0 14px 0;">未命名人物</div>', unsafe_allow_html=True)
cursor.execute(
"""SELECT fm.abstract_label, fm.feature_description, fm.first_seen_at,
(SELECT COUNT(*) FROM event_details ed WHERE ed.person = fm.abstract_label) AS event_count
@@ -324,22 +757,31 @@ elif page == "👤 成员命名":
unnamed = cursor.fetchall()
if not unnamed:
st.info("所有人物已命名,或暂未发现新人物")
st.markdown(
'<div class="fam-empty"><span class="fe-ico">✅</span>'
'所有人物已命名,或暂未发现新人物</div>', unsafe_allow_html=True)
else:
for m in unnamed:
col1, col2, col3 = st.columns([2, 2, 1])
col1, col2, col3 = st.columns([2, 1.6, 1])
with col1:
st.markdown(f"**{m['abstract_label']}**")
st.text(m['feature_description'] or '无特征描述')
first_seen = serialize_datetime(m['first_seen_at'])
st.caption(f"首次出现: {first_seen} | 事件数: {m['event_count']}")
st.markdown(
f'<div class="member-card">'
f'<div style="font-size:15px;font-weight:700;color:#8fb0ff;">'
f'{esc(m["abstract_label"])}</div>'
f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;'
f'line-height:1.6;">{esc(m["feature_description"] or "无特征描述")}</div>'
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
f'首次出现 {esc(first_seen)} · 事件数 {m["event_count"]}</div>'
f'</div>', unsafe_allow_html=True)
with col2:
real_name = st.text_input(
"输入名字", key=f"name_{m['abstract_label']}",
placeholder=f"{m['abstract_label']}命名"
)
placeholder=f"{m['abstract_label']}命名",
label_visibility="collapsed")
with col3:
if st.button("命名", key=f"btn_{m['abstract_label']}"):
if st.button("命名", key=f"btn_{m['abstract_label']}",
type="primary", use_container_width=True):
if real_name.strip():
try:
resp = requests.post(
@@ -354,9 +796,8 @@ elif page == "👤 成员命名":
if resp.status_code == 200:
result = resp.json()
st.success(
f"命名成功!{m['abstract_label']} -> {real_name}"
f"更新明细 {result.get('updated_event_details_count', 0)}"
)
f"命名成功!{m['abstract_label']} {real_name}"
f"更新明细 {result.get('updated_event_details_count', 0)}")
st.rerun()
else:
st.error(f"命名失败: {resp.status_code} {resp.text}")
@@ -364,10 +805,10 @@ elif page == "👤 成员命名":
st.error(f"异常: {e}")
else:
st.warning("请输入名字")
st.divider()
st.markdown('<div style="height:8px"></div>', unsafe_allow_html=True)
# 已命名成员
st.subheader("已命名成员")
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:22px 0 14px 0;">已命名成员</div>', unsafe_allow_html=True)
cursor.execute(
"""SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
FROM family_members
@@ -377,17 +818,25 @@ elif page == "👤 成员命名":
named = cursor.fetchall()
if not named:
st.info("暂无已命名成员")
st.markdown(
'<div class="fam-empty">暂无已命名成员</div>', unsafe_allow_html=True)
else:
df = pd.DataFrame([{
'抽象标识': m['abstract_label'],
'真实名字': m['real_name'],
'特征描述': m['feature_description'],
'首次出现': serialize_datetime(m['first_seen_at']),
'命名时间': serialize_datetime(m['named_at']),
'命名人': m['named_by']
} for m in named])
st.dataframe(df, use_container_width=True, hide_index=True)
cards = []
for m in named:
cards.append(
f'<div class="member-card">'
f'<div style="font-size:15px;font-weight:700;color:#f1f5f9;">'
f'{esc(m["real_name"])} '
f'<span style="font-size:11px;color:#64748b;font-weight:400;">'
f'{esc(m["abstract_label"])}</span></div>'
f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;line-height:1.6;">'
f'{esc(m["feature_description"] or "")}</div>'
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
f'首次出现 {esc(serialize_datetime(m["first_seen_at"]))} · '
f'命名于 {esc(serialize_datetime(m["named_at"]))}</div>'
f'</div>')
st.markdown(f'<div class="member-grid">{"".join(cards)}</div>',
unsafe_allow_html=True)
finally:
conn.close()
@@ -397,14 +846,14 @@ elif page == "👤 成员命名":
# 统计图表页
# ============================================================
elif page == "📈 统计图表":
st.title("📈 统计图表")
page_header('📈', '统计图表', '模型来源 / 关注事件 / 任务状态')
conn = get_db_conn()
try:
cursor = conn.cursor()
# compute_provider 分布
st.subheader("模型来源分布")
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:6px 0 14px 0;">模型来源分布</div>', unsafe_allow_html=True)
try:
cursor.execute("""
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count
@@ -420,14 +869,13 @@ elif page == "📈 统计图表":
stats = cursor.fetchall()
if stats:
df_stats = pd.DataFrame(stats)
st.bar_chart(df_stats.set_index('provider')['count'])
st.dataframe(df_stats, use_container_width=True, hide_index=True)
st.bar_chart({r['provider']: r['count'] for r in stats})
else:
st.info("暂无统计数据")
st.markdown('<div class="fam-empty">暂无统计数据</div>',
unsafe_allow_html=True)
# 关注事件统计
st.subheader("关注事件统计")
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:22px 0 14px 0;">关注事件统计</div>', unsafe_allow_html=True)
cursor.execute("""
SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count
FROM event_details
@@ -437,13 +885,15 @@ elif page == "📈 统计图表":
""")
attention = cursor.fetchall()
if attention:
import pandas as pd
df_att = pd.DataFrame(attention)
st.dataframe(df_att, use_container_width=True, hide_index=True)
else:
st.info("暂无关注事件")
st.markdown('<div class="fam-empty">暂无关注事件</div>',
unsafe_allow_html=True)
# 任务统计
st.subheader("任务状态统计")
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:22px 0 14px 0;">任务状态统计</div>', unsafe_allow_html=True)
cursor.execute("""
SELECT status, COUNT(*) AS count
FROM process_tasks
@@ -451,11 +901,10 @@ elif page == "📈 统计图表":
""")
task_stats = cursor.fetchall()
if task_stats:
df_tasks = pd.DataFrame(task_stats)
st.bar_chart(df_tasks.set_index('status')['count'])
st.dataframe(df_tasks, use_container_width=True, hide_index=True)
st.bar_chart({r['status']: r['count'] for r in task_stats})
else:
st.info("暂无任务数据")
st.markdown('<div class="fam-empty">暂无任务数据</div>',
unsafe_allow_html=True)
finally:
conn.close()