feat(v3): 人物模块重构 - 大模型结构化特征值替代 OpenCV 帧定位。prompt 增加 person_appearances(uid+7特征+action)并重写合并 prompt(稳定特征优先比对);gemini 透传特征字段;oracle_db events/people 加 person_appearances_json/features_json/display_uid + _merge_features;video_processor 去 cv2 改 ffprobe 校验、_store_result 聚合 uid 特征落 people、删缩略图/事件截图;person_service 聚合特征后基于特征文本 LLM 合并;api_gateway 删 3 个图接口;NAS 镜像+DDL 加新字段;fam-ui 特征卡替代头像、事件时间线人物特征块

This commit is contained in:
ericwyuan
2026-08-21 18:06:21 +08:00
parent 2ba3478291
commit ff01d14c79
12 changed files with 463 additions and 257 deletions

View File

@@ -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,16 +288,41 @@ 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'<img src="{_oracle_url}/api/oracle/event/{eid}/thumb?token={_oracle_token}" '
f'style="width:100%;max-height:200px;object-fit:cover;border-radius:8px;'
f'margin-bottom:6px;" '
f'onerror="this.style.display=\'none\'"/>')
# 人物出现明细:从 person_appearances_json 渲染 uid + 简短特征 + action
appearances_html = ''
pa_raw = e.get('person_appearances_json')
appearances = []
if pa_raw:
try:
appearances = json.loads(pa_raw) if isinstance(pa_raw, str) else pa_raw
except (ValueError, TypeError):
appearances = []
if appearances and isinstance(appearances, list):
cells = []
for pa in appearances:
if not isinstance(pa, dict):
continue
uid = str(pa.get('uid') or '').strip()
if not uid:
continue
feats = pa.get('features') or {}
feat_bits = []
for fk in ('gender', 'clothing', 'face'):
fv = (feats.get(fk) or '').strip() if isinstance(feats, dict) else ''
if fv and fv.lower() != 'unknown':
feat_bits.append(fv)
feat_str = ' · '.join(feat_bits) if feat_bits else '无特征'
action = str(pa.get('action') or '').strip()
cells.append(
f'<div style="margin:6px 0;padding:6px 10px;border-radius:8px;'
f'background:#0e1420;border:1px solid #1e293b;font-size:12px;">'
f'<b style="color:#8fb0ff;">{esc(uid)}</b>'
f'<span style="color:#64748b;margin-left:8px;">{esc(feat_str)}</span>'
f'{f"<div style=color:#94a3b8;margin-top:3px;>{esc(action)}</div>" if action else ""}'
f'</div>')
if cells:
appearances_html = '<div style="margin-bottom:8px;">' + ''.join(cells) + '</div>'
badges = ''.join(
f'<span class="bdg bdg-person">{esc(p)}</span>' for p in sorted(persons))
@@ -308,7 +333,7 @@ def render_event_list(events: list):
f'<div class="ev-item {"attention" if attention else ""}">'
f'<div class="ev-time">{esc(time_label)}'
f'{f"<span class=cam>{esc(camera)}</span>" if camera else ""}</div>'
f'<div class="ev-card">{img_html}{badges}'
f'<div class="ev-card">{badges}{appearances_html}'
f'<div class="ev-desc">{esc(desc)}</div></div>'
f'</div>'
)
@@ -535,18 +560,10 @@ if page == "🕒 事件时间轴":
f'<span class="bdg bdg-model">{esc(p)}</span>' for p in str(provider).split(','))
summary = vid.get('summary_json') or '暂无全局摘要'
# 视频缩略图Oracle 带 token 接口,无图时优雅降级)
thumb_html = ''
if _oracle_url:
thumb_html = (
f'<img src="{_oracle_url}/api/oracle/video/{vid["id"]}/thumb'
f'?token={_oracle_token}" '
f'style="width:100%;max-height:240px;object-fit:cover;'
f'border-radius:10px;margin-bottom:10px;'
f'onerror="this.style.display=\'none\'"/>')
# 不再展示视频首帧缩略图Oracle 端不再生成 jpg
# 摘要文本 + 下方事件时间线已足够定位画面内容
st.markdown(
f'<div class="ev-head">'
f'{thumb_html}'
f'<div class="ev-title">'
f'<span>{esc(vid.get("camera_name") or "未知摄像头")}</span>'
f'<span class="bdg bdg-cam">会话 #{vid["id"]}</span>'
@@ -562,7 +579,8 @@ if page == "🕒 事件时间轴":
cursor = conn.cursor()
cursor.execute(
"""SELECT e.id, e.ts, e.description, e.person_list_json,
e.is_attention_event, v.camera_name
e.person_appearances_json, e.is_attention_event,
v.camera_name
FROM sync_events e
JOIN sync_videos v ON e.video_id = v.id
WHERE e.video_id=%s ORDER BY e.ts ASC""",
@@ -731,7 +749,8 @@ elif page == "👤 人物管理":
try:
cursor = conn.cursor()
cursor.execute(
"SELECT id, label, canonical_name, first_seen, appearances, source "
"SELECT id, label, canonical_name, first_seen, appearances, source, "
"features_json, display_uid "
"FROM sync_people ORDER BY id ASC")
people = cursor.fetchall()
finally:
@@ -745,13 +764,19 @@ elif page == "👤 人物管理":
for p in people:
key = p['canonical_name'] or p['label']
groups.setdefault(key, {'display': key, 'is_named': bool(p['canonical_name']),
'labels': [], 'appearances': 0, 'first_seen': None})
'labels': [], 'appearances': 0, 'first_seen': None,
'features_json': None, 'display_uid': None})
g = groups[key]
g['labels'].append(p['label'])
g['appearances'] += (p.get('appearances') or 0)
fs = p.get('first_seen')
if fs and (g['first_seen'] is None or str(fs) < str(g['first_seen'])):
g['first_seen'] = fs
# 特征:取该聚合下任一 label 的 features_json非空优先
if not g['features_json'] and p.get('features_json'):
g['features_json'] = p['features_json']
if not g['display_uid'] and p.get('display_uid'):
g['display_uid'] = p['display_uid']
if not groups:
st.markdown(
@@ -807,35 +832,57 @@ elif page == "👤 人物管理":
f'border:1px solid #713f12;padding:1px 8px;border-radius:10px;'
f'margin-left:8px;">未命名</span>')
label_str = ' · '.join(g['labels'])
# 人物代表画面Oracle 端在该人物出现的所有事件中挑第一个有截图的事件画面
# 该人物在多个视频多次出现,肯定能找到出现时刻的画面,不用视频首帧冒充
rep_img = ''
if _oracle_url:
for lb in g['labels']:
try:
rp = requests.get(
f"{_oracle_url}/api/oracle/person/avatar",
params={"label": lb, "token": _oracle_token},
timeout=10)
if rp.status_code == 200:
import urllib.parse
rep_img = (
f'<img src="{_oracle_url}/api/oracle/person/avatar'
f'?label={urllib.parse.quote(lb)}&token={_oracle_token}" '
f'style="width:140px;height:94px;object-fit:cover;border-radius:8px;'
f'border:1px solid #1e293b;margin:8px 0;" '
f'onerror="this.style.display=\'none\'"/>')
break
except Exception:
# 人物特征卡:从 sync_people.features_json 渲染结构化特征
# 不依赖任何 jpg 文件;大模型每次分析视频时落库的特征值
feats_html = ''
feats_raw = g.get('features_json') or '{}'
try:
feats = json.loads(feats_raw) if feats_raw else {}
except (ValueError, TypeError):
feats = {}
if feats:
# 结构化特征网格
feat_rows = [
('性别', feats.get('gender')),
('年龄段', feats.get('age_band')),
('身材', feats.get('build')),
('发型', feats.get('hair')),
('衣着', feats.get('clothing')),
('面部', feats.get('face')),
('辨识点', feats.get('distinguishing')),
]
feat_cells = []
for label_name, val in feat_rows:
v = (val or '').strip() if val else ''
if not v:
continue
cls = '' if v.lower() != 'unknown' else 'style="color:#475569;"'
feat_cells.append(
f'<span style="display:inline-block;margin:3px 6px 3px 0;'
f'padding:2px 9px;border-radius:8px;background:#0e1420;'
f'border:1px solid #1e293b;font-size:11px;">'
f'<span style="color:#64748b;">{esc(label_name)}</span> '
f'<b {cls}>{esc(v)}</b></span>')
if feat_cells:
feats_html = (
f'<div style="margin:8px 0 4px 0;">'
+ ''.join(feat_cells) + '</div>')
else:
feats_html = (
'<div style="margin:8px 0;font-size:11px;color:#475569;">'
'特征待大模型补充(下段视频分析时由 VLM 落库)</div>')
uid_html = ''
if g.get('display_uid') and g['display_uid'] != key:
uid_html = (f'<span style="font-size:11px;color:#64748b;'
f'margin-left:8px;">UID: {esc(g["display_uid"])}</span>')
st.markdown(
f'<div style="font-size:16px;font-weight:700;color:#f1f5f9;">'
f'{esc(key)}{tag}</div>'
f'{esc(key)}{tag}{uid_html}</div>'
f'<div style="font-size:12px;color:#8b93a7;margin-top:5px;line-height:1.6;">'
f'标识: {esc(label_str)}</div>'
f'<div style="font-size:11px;color:#64748b;margin-top:6px;">'
f'出现 <b style="color:#cbd5e1;">{g["appearances"]}</b> 次 · 首次 {esc(first_str)}</div>'
f'{rep_img}',
f'{feats_html}',
unsafe_allow_html=True)
if not is_named:
# 未命名身份:每个 label 都给一个命名框