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:
@@ -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 都给一个命名框
|
||||
|
||||
Reference in New Issue
Block a user