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:
@@ -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)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 统计图表页
|
||||
|
||||
Reference in New Issue
Block a user