feat(人物管理/红框标记): 关键帧人脸标记 + 人物命名重命名回溯 + 人物管理页改版

1. fam-edge 新增 frame_marker.py: Edge 端人脸检测画红框+统计人脸数(NAS ARM 太弱只存图零计算),orchestrator 分析后回传前标记,新增 /api/edge/mark_frames 批量补标端点
2. fam-core 新增 tools/backfill_mark_frames.py: 存量关键帧批量补红框(幂等+备份 frames_orig)
3. db_layer.name_member 增强: 支持自动注册新标签/重命名回溯(旧真名一并替换)/多人组合字符串 REPLACE
4. fam-ui 成员命名页改为人物管理页: 所有人物照片墙+命名重命名+统计去重
5. event_receiver/member_manager 配套适配
This commit is contained in:
ericwyuan
2026-08-21 00:53:30 +08:00
parent 47c42f24df
commit 43b3178a9d
8 changed files with 544 additions and 132 deletions

View File

@@ -403,7 +403,7 @@ st.sidebar.markdown(
# 顶部横向导航(窄屏下侧边栏自动收起,放主区域保证任何屏宽都可见)
page = st.segmented_control(
"功能页面",
["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 成员命名", "📈 统计图表"],
["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 人物管理", "📈 统计图表"],
default="🕒 事件时间轴",
label_visibility="collapsed"
) or "🕒 事件时间轴"
@@ -771,111 +771,129 @@ elif page == "📝 对话历史":
# ============================================================
# 成员命名
# 人物管理
# ============================================================
elif page == "👤 成员命名":
page_header('👤', '家庭成员命名', '为识别到的人物起')
elif page == "👤 人物管理":
page_header('👤', '人物管理', '所有出现的人物 · 照片 · 命名与重命')
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
FROM family_members fm
WHERE fm.real_name IS NULL AND fm.is_active = TRUE
ORDER BY fm.first_seen_at ASC"""
)
unnamed = cursor.fetchall()
if not unnamed:
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, 1.6, 1])
with col1:
first_seen = serialize_datetime(m['first_seen_at'])
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']}命名",
label_visibility="collapsed")
with col3:
if st.button("命名", key=f"btn_{m['abstract_label']}",
type="primary", use_container_width=True):
if real_name.strip():
try:
resp = requests.post(
f"{_core_url}/api/member/name",
json={
"abstract_label": m['abstract_label'],
"real_name": real_name.strip(),
"named_by": "UI管理员"
},
timeout=10
)
if resp.status_code == 200:
result = resp.json()
st.success(
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}")
except Exception as e:
st.error(f"异常: {e}")
else:
st.warning("请输入名字")
st.markdown('<div style="height:8px"></div>', unsafe_allow_html=True)
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
'margin:22px 0 14px 0;">已命名成员</div>', unsafe_allow_html=True)
"""SELECT abstract_label, real_name, feature_description
FROM family_members WHERE is_active = TRUE""")
members = cursor.fetchall()
cursor.execute(
"""SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
FROM family_members
WHERE real_name IS NOT NULL AND is_active = TRUE
ORDER BY named_at DESC"""
)
named = cursor.fetchall()
if not named:
st.markdown(
'<div class="fam-empty">暂无已命名成员</div>', unsafe_allow_html=True)
else:
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)
"""SELECT ed.event_id, ed.frame_index, ed.frame_timestamp, ed.person
FROM event_details ed ORDER BY ed.frame_timestamp ASC""")
detail_rows = cursor.fetchall()
finally:
conn.close()
alias_map = {m['abstract_label']: m['real_name'] for m in members if m['real_name']}
feat_map = {m['abstract_label']: m['feature_description'] for m in members}
real_to_label = {v: k for k, v in alias_map.items()}
# 聚合每个真实人物出现的帧(时间升序)
person_frames = {}
for r in detail_rows:
for p in resolve_persons(r['person'], alias_map):
person_frames.setdefault(p, []).append(
(r['event_id'], r['frame_index'], r['frame_timestamp']))
named_keys = [k for k in person_frames if k in real_to_label]
st.markdown(
f'<div style="font-size:12px;color:#8b93a7;margin:2px 0 16px 0;">'
f'共 <b style="color:#f1f5f9;">{len(person_frames)}</b> 人 · '
f'已命名 <b style="color:#7dd3fc;">{len(named_keys)}</b> · '
f'未命名 <b style="color:#fbbf24;">{len(person_frames) - len(named_keys)}</b>'
f'<span style="color:#64748b;">(与事件时间轴"出现人物"统计一致)</span></div>',
unsafe_allow_html=True)
def person_photo(frames):
for eid, fidx, _ in frames:
b64 = load_frame_b64(eid, fidx)
if b64:
return b64
return None
def do_name(label, new_name):
try:
resp = requests.post(
f"{_core_url}/api/member/name",
json={"abstract_label": label, "real_name": new_name, "named_by": "UI管理员"},
timeout=15)
if resp.status_code == 200:
result = resp.json()
info = f"更新明细 {result.get('updated_event_details_count', 0)}"
if result.get('renamed_from'):
info += f"(原 {result['renamed_from']}"
st.success(f"已保存:{label}{new_name}{info}")
st.rerun()
else:
st.error(f"失败: {resp.status_code} {resp.text[:150]}")
except Exception as e:
st.error(f"异常: {e}")
if not person_frames:
st.markdown(
'<div class="fam-empty"><span class="fe-ico">👤</span>'
'暂未发现任何人物</div>', unsafe_allow_html=True)
else:
for key in sorted(person_frames, key=lambda k: -len(person_frames[k])):
frames = person_frames[key]
is_named = key in real_to_label
label = real_to_label.get(key, key)
feat = feat_map.get(label) or '暂无特征描述'
photo = person_photo(frames)
first_ts = parse_ts(frames[0][2])
last_ts = parse_ts(frames[-1][2])
first_str = first_ts.strftime('%m-%d %H:%M') if first_ts else '--'
last_str = last_ts.strftime('%m-%d %H:%M') if last_ts else '--'
col_photo, col_info, col_act = st.columns([0.9, 2.2, 1.4])
with col_photo:
if photo:
st.markdown(
f'<img src="data:image/jpeg;base64,{photo}" '
f'style="width:100%;aspect-ratio:16/10;object-fit:cover;'
f'border-radius:10px;border:1px solid #2b3648;">',
unsafe_allow_html=True)
else:
st.markdown(
'<div style="width:100%;aspect-ratio:16/10;border-radius:10px;'
'border:1px dashed #2b3648;display:flex;align-items:center;'
'justify-content:center;color:#475569;font-size:22px;">📷</div>',
unsafe_allow_html=True)
with col_info:
tag = (f'<span style="font-size:11px;color:#7dd3fc;background:#0e2233;'
f'border:1px solid #164e63;padding:1px 8px;border-radius:10px;'
f'margin-left:8px;">已命名 · {esc(label)}</span>') if is_named else \
(f'<span style="font-size:11px;color:#fbbf24;background:#2a2107;'
f'border:1px solid #713f12;padding:1px 8px;border-radius:10px;'
f'margin-left:8px;">未命名</span>')
st.markdown(
f'<div style="font-size:16px;font-weight:700;color:#f1f5f9;">'
f'{esc(key)}{tag}</div>'
f'<div style="font-size:12px;color:#8b93a7;margin-top:5px;line-height:1.6;">'
f'{esc(feat)}</div>'
f'<div style="font-size:11px;color:#64748b;margin-top:6px;">'
f'出现 <b style="color:#cbd5e1;">{len(frames)}</b> 帧 · '
f'{esc(first_str)}{esc(last_str)}</div>',
unsafe_allow_html=True)
with col_act:
placeholder = '输入新名字' if is_named else f'为「{key}」起名'
new_name = st.text_input(
"名字", key=f"nm_{label}", placeholder=placeholder,
label_visibility="collapsed")
if st.button("保存" if is_named else "命名", key=f"btn_{label}",
type="primary", use_container_width=True):
if new_name.strip():
do_name(label, new_name.strip())
else:
st.warning("请输入名字")
st.markdown('<div style="height:10px"></div>', unsafe_allow_html=True)
# ============================================================
# 统计图表页