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

@@ -424,19 +424,31 @@ def get_all_members(include_named=True, include_unnamed=True) -> List[Dict]:
def name_member(abstract_label: str, real_name: str, named_by: str) -> Dict: def name_member(abstract_label: str, real_name: str, named_by: str) -> Dict:
"""命名成员 + 批量回溯更新历史记录""" """命名/重命名成员 + 批量回溯更新历史记录
- 标签未入库(如 AI 新输出的 人物1自动注册
- 已命名成员可重命名(旧真名一并回溯替换)
- person 为多人组合字符串('张三, 汤圆'),用 REPLACE 替换其中目标
"""
conn = get_conn() conn = get_conn()
try: try:
cursor = conn.cursor() cursor = conn.cursor()
# 1. 校验存在且未命 # 1. 查现有记录,拿到旧真
cursor.execute( cursor.execute(
"SELECT member_id FROM family_members WHERE abstract_label = %s AND real_name IS NULL", "SELECT member_id, real_name FROM family_members WHERE abstract_label = %s",
(abstract_label,) (abstract_label,)
) )
row = cursor.fetchone() row = cursor.fetchone()
if not row: old_name = None
return {"error": f"成员 {abstract_label} 不存在或已命名"} if row:
old_name = row[1]
else:
cursor.execute(
"""INSERT INTO family_members (abstract_label, feature_description, first_seen_at)
VALUES (%s, %s, NOW())""",
(abstract_label, f'由命名操作自动注册: {real_name}')
)
# 2. 更新 family_members # 2. 更新 family_members
cursor.execute( cursor.execute(
@@ -447,21 +459,37 @@ def name_member(abstract_label: str, real_name: str, named_by: str) -> Dict:
) )
# 3. 批量回溯更新 event_details # 3. 批量回溯更新 event_details
# 目标出现的三种形态: 独占整字段 / 多人组合内 / AI 直呼旧真名
updated_details_count = 0
for old in {abstract_label, old_name} - {None}:
if old == real_name:
continue
cursor.execute( cursor.execute(
"UPDATE event_details SET person = %s WHERE person = %s", "UPDATE event_details SET person = %s WHERE person = %s",
(real_name, abstract_label) (real_name, old)
) )
updated_details_count = cursor.rowcount updated_details_count += cursor.rowcount
cursor.execute(
"""UPDATE event_details
SET person = REPLACE(person, %s, %s)
WHERE person LIKE %s AND person <> %s""",
(old, real_name, f'%{old}%', real_name)
)
updated_details_count += cursor.rowcount
# 4. 批量回溯更新 monitor_events.entities_json # 4. 批量回溯更新 monitor_events.entities_json
# MariaDB 10.11 不支持 MySQL 的 $[*] 通配符 JSON 路径, # MariaDB 10.11 不支持 MySQL 的 $[*] 通配符 JSON 路径,
# 改用 Python 层解析 + 逐行更新 # 改用 Python 层解析 + 逐行更新
cursor.execute(
"SELECT event_id, entities_json FROM monitor_events WHERE entities_json LIKE %s",
(f'%{abstract_label}%',)
)
import json as _json import json as _json
updated_events_count = 0 updated_events_count = 0
targets = {abstract_label, old_name} - {None}
if targets and targets != {real_name}:
like_conds = ' OR '.join(['entities_json LIKE %s'] * len(targets))
like_args = [f'%{t}%' for t in targets]
cursor.execute(
f"SELECT event_id, entities_json FROM monitor_events WHERE {like_conds}",
tuple(like_args)
)
for eid, entities_raw in cursor.fetchall(): for eid, entities_raw in cursor.fetchall():
if not entities_raw: if not entities_raw:
continue continue
@@ -472,7 +500,7 @@ def name_member(abstract_label: str, real_name: str, named_by: str) -> Dict:
changed = False changed = False
if isinstance(entities, list): if isinstance(entities, list):
for ent in entities: for ent in entities:
if isinstance(ent, dict) and ent.get('person') == abstract_label: if isinstance(ent, dict) and ent.get('person') in targets:
ent['person'] = real_name ent['person'] = real_name
changed = True changed = True
if changed: if changed:
@@ -486,6 +514,7 @@ def name_member(abstract_label: str, real_name: str, named_by: str) -> Dict:
return { return {
"abstract_label": abstract_label, "abstract_label": abstract_label,
"real_name": real_name, "real_name": real_name,
"renamed_from": old_name,
"updated_event_details_count": updated_details_count, "updated_event_details_count": updated_details_count,
"updated_monitor_events_count": updated_events_count "updated_monitor_events_count": updated_events_count
} }
@@ -496,6 +525,109 @@ def name_member(abstract_label: str, real_name: str, named_by: str) -> Dict:
conn.close() conn.close()
def merge_member(source_key: str, target_key: str, named_by: str = '管理员') -> Dict:
"""合并人物: source 并入 target用户判断两帧是同一人时
source_key/target_key 可为 abstract_label 或 real_name。
- event_details.person: 独占/组合字符串内的 source 一律替换为 target 显示名
- monitor_events.entities_json: person 字段替换
- family_members: source 行置 is_active=0保留历史target 未入库则注册
"""
if source_key == target_key:
return {"error": "source 与 target 不能相同"}
conn = get_conn()
try:
cursor = conn.cursor(pymysql.cursors.DictCursor)
def resolve(key):
cursor.execute(
"""SELECT member_id, abstract_label, real_name FROM family_members
WHERE is_active = TRUE AND (abstract_label = %s OR real_name = %s)
ORDER BY real_name IS NULL LIMIT 1""",
(key, key))
return cursor.fetchone()
src = resolve(source_key)
tgt = resolve(target_key)
if not src:
return {"error": f"人物 {source_key} 不存在"}
if not tgt:
# target 是未入库的裸标签(如 人物1注册后作为目标
cursor.execute(
"""INSERT INTO family_members (abstract_label, feature_description, first_seen_at)
VALUES (%s, %s, NOW())""",
(target_key, f'合并操作自动注册: {source_key} 并入'))
tgt = {'abstract_label': target_key, 'real_name': None}
target_display = tgt['real_name'] or tgt['abstract_label']
# source 的所有称呼: 抽象标签 + 旧真名(多人组合里两种都可能出现)
source_names = {src['abstract_label']}
if src['real_name']:
source_names.add(src['real_name'])
updated_details = 0
for name in source_names:
if name == target_display:
continue
cursor.execute(
"UPDATE event_details SET person = %s WHERE person = %s",
(target_display, name))
updated_details += cursor.rowcount
cursor.execute(
"""UPDATE event_details
SET person = REPLACE(person, %s, %s)
WHERE person LIKE %s AND person <> %s""",
(name, target_display, f'%{name}%', target_display))
updated_details += cursor.rowcount
# entities_json 逐行替换
import json as _json
updated_events = 0
like_conds = ' OR '.join(['entities_json LIKE %s'] * len(source_names))
cursor.execute(
f"SELECT event_id, entities_json FROM monitor_events WHERE {like_conds}",
tuple(f'%{n}%' for n in source_names))
for row in cursor.fetchall():
raw = row['entities_json']
if not raw:
continue
try:
entities = _json.loads(raw) if isinstance(raw, str) else raw
except (ValueError, TypeError):
continue
changed = False
if isinstance(entities, list):
for ent in entities:
if isinstance(ent, dict) and ent.get('person') in source_names:
ent['person'] = target_display
changed = True
if changed:
cursor.execute(
"UPDATE monitor_events SET entities_json = %s WHERE event_id = %s",
(_json.dumps(entities, ensure_ascii=False), row['event_id']))
updated_events += 1
# source 行停用
cursor.execute(
"UPDATE family_members SET is_active = 0, updated_at = NOW() WHERE member_id = %s",
(src['member_id'],))
conn.commit()
return {
"source": source_key,
"target": target_display,
"merged_names": sorted(source_names),
"updated_event_details_count": updated_details,
"updated_monitor_events_count": updated_events
}
except Exception as e:
conn.rollback()
raise e
finally:
conn.close()
def get_known_members_context() -> str: def get_known_members_context() -> str:
"""获取已命名+未命名成员清单,用于注入 VLM Prompt""" """获取已命名+未命名成员清单,用于注入 VLM Prompt"""
conn = get_conn() conn = get_conn()

View File

@@ -38,10 +38,15 @@ def _is_abstract_label(person: str) -> bool:
def _save_frame_images(event_id: int, frame_details: list) -> int: def _save_frame_images(event_id: int, frame_details: list) -> int:
"""把 frame_details 中的 base64 关键帧落盘,返回成功张数""" """把 frame_details 中的 base64 关键帧落盘,返回成功张数
同时写 meta.jsonframe_index -> face_countUI 据此挑选有人像的帧做头像。
"""
saved = 0 saved = 0
face_counts = {}
for frame in frame_details: for frame in frame_details:
img_b64 = frame.pop('frame_image', None) img_b64 = frame.pop('frame_image', None)
faces = frame.pop('face_count', None)
if not img_b64: if not img_b64:
continue continue
idx = frame.get('frame_index', 0) idx = frame.get('frame_index', 0)
@@ -51,9 +56,18 @@ def _save_frame_images(event_id: int, frame_details: list) -> int:
out_path = os.path.join(out_dir, f'frame_{idx}.jpg') out_path = os.path.join(out_dir, f'frame_{idx}.jpg')
with open(out_path, 'wb') as f: with open(out_path, 'wb') as f:
f.write(base64.b64decode(img_b64)) f.write(base64.b64decode(img_b64))
if faces is not None:
face_counts[str(idx)] = int(faces)
saved += 1 saved += 1
except Exception as e: except Exception as e:
logger.warning(f"[event_id={event_id}] 关键帧落盘失败 frame_{idx}: {e}") logger.warning(f"[event_id={event_id}] 关键帧落盘失败 frame_{idx}: {e}")
if face_counts:
try:
import json as _json
with open(os.path.join(out_dir, 'meta.json'), 'w') as f:
_json.dump(face_counts, f)
except Exception as e:
logger.warning(f"[event_id={event_id}] meta.json 写入失败: {e}")
if saved: if saved:
logger.info(f"[event_id={event_id}] 关键帧落盘 {saved} 张 -> {FRAME_IMAGE_DIR}") logger.info(f"[event_id={event_id}] 关键帧落盘 {saved} 张 -> {FRAME_IMAGE_DIR}")
return saved return saved

View File

@@ -57,6 +57,29 @@ def name_member():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@member_bp.route('/api/member/merge', methods=['POST'])
def merge_member():
"""合并人物用户判断两个标签是同一人时source 并入 target"""
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Invalid JSON"}), 400
source_key = data.get('source')
target_key = data.get('target')
if not source_key or not target_key:
return jsonify({"error": "缺少必填字段: source, target"}), 400
logger.info(f"合并人物: {source_key} -> {target_key}")
try:
result = db_layer.merge_member(source_key, target_key)
if 'error' in result:
return jsonify(result), 404
return jsonify(result), 200
except Exception as e:
logger.error(f"合并失败: {e}", exc_info=True)
return jsonify({"error": str(e)}), 500
@member_bp.route('/api/member/list', methods=['GET']) @member_bp.route('/api/member/list', methods=['GET'])
def list_members(): def list_members():
"""列出所有成员""" """列出所有成员"""

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""存量关键帧批量补红框(计算在 Edge/OracleNAS 只编排与存图)
流程: 遍历 frames/event_*/frame_*.jpg -> 分批(8张)上传 Edge /api/edge/mark_frames
-> 用标记后的图覆盖原文件 -> 写 meta.json (frame_index -> face_count)
幂等: 已有 meta.json 的事件跳过;--force 强制重跑
备份: 首次覆盖前原文件备份到 frames_orig/event_*/
"""
import argparse
import base64
import json
import os
import shutil
import sys
import requests
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from fam_core.config_loader import load_config # noqa: E402
FRAME_DIR = load_config().get('storage', {}).get(
'frame_image_dir', '/volume1/web/sentinel-home-ai/fam-ui/static/frames')
MARK_URL = load_config().get('poller', {}).get(
'results_url', 'http://129.146.203.203:5000/api/edge/results'
).rsplit('/', 1)[0] + '/mark_frames'
BATCH = 8
def mark_batch(images):
"""images: [(key, jpeg_bytes)] -> {key: (marked_bytes, faces)}"""
payload = {
'images': [
{'key': k, 'data': base64.b64encode(b).decode('ascii')}
for k, b in images
]
}
resp = requests.post(MARK_URL, json=payload, timeout=(30, 120))
resp.raise_for_status()
out = {}
for r in resp.json().get('results', []):
if r.get('data'):
out[r['key']] = (base64.b64decode(r['data']), r.get('faces', 0))
else:
out[r['key']] = (None, 0)
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--force', action='store_true', help='忽略已有 meta.json 重跑')
ap.add_argument('--event', type=int, help='只处理指定 event_id')
args = ap.parse_args()
orig_root = os.path.join(os.path.dirname(FRAME_DIR.rstrip('/')), 'frames_orig')
stat = {'events': 0, 'frames': 0, 'faces': 0, 'skip': 0, 'fail': 0}
for name in sorted(os.listdir(FRAME_DIR)):
if not name.startswith('event_'):
continue
eid = int(name.split('_')[1])
if args.event and eid != args.event:
continue
edir = os.path.join(FRAME_DIR, name)
meta_path = os.path.join(edir, 'meta.json')
frames = sorted(
f for f in os.listdir(edir)
if f.startswith('frame_') and f.endswith('.jpg'))
if not frames:
continue
if os.path.exists(meta_path) and not args.force:
stat['skip'] += 1
continue
# 备份原件(一次)
bak_dir = os.path.join(orig_root, name)
if not os.path.isdir(bak_dir):
os.makedirs(bak_dir, exist_ok=True)
for f in frames:
shutil.copy2(os.path.join(edir, f), os.path.join(bak_dir, f))
face_counts = {}
for i in range(0, len(frames), BATCH):
batch = []
for f in frames[i:i + BATCH]:
with open(os.path.join(edir, f), 'rb') as fh:
batch.append((f, fh.read()))
try:
marked = mark_batch(batch)
except Exception as e:
print(f'[event {eid}] 批次失败 (跳过 {len(batch)} 帧): {e}')
stat['fail'] += len(batch)
continue
for f, _ in batch:
data, faces = marked.get(f, (None, 0))
if data is None:
stat['fail'] += 1
continue
with open(os.path.join(edir, f), 'wb') as fh:
fh.write(data)
idx = f[len('frame_'):-len('.jpg')]
face_counts[idx] = faces
stat['frames'] += 1
stat['faces'] += faces
if face_counts:
with open(meta_path, 'w') as fh:
json.dump(face_counts, fh)
stat['events'] += 1
print(f'[event {eid}] 标记 {len(face_counts)}/{len(frames)} 帧, '
f'人脸合计 {sum(face_counts.values())}')
print(f"\n完成: 事件 {stat['events']} | 帧标记 {stat['frames']} "
f"(含人脸帧人脸数 {stat['faces']}) | 已标跳过 {stat['skip']} | 失败 {stat['fail']}")
print(f'备份目录: {orig_root}')
if __name__ == '__main__':
main()

View File

@@ -173,13 +173,20 @@ class AIOrchestrator:
@staticmethod @staticmethod
def _attach_frame_images(frame_details: List[dict], frame_paths: List[str]) -> None: def _attach_frame_images(frame_details: List[dict], frame_paths: List[str]) -> None:
"""把关键帧图片 base64 附加到 frame_details按位置对齐视觉分析输入帧""" """把关键帧图片 base64 附加到 frame_details按位置对齐视觉分析输入帧
附带人脸红框标记与 face_countNAS 落盘 meta.jsonUI 据此挑有人像的头像)
"""
from ..frame_marker import mark_jpeg
for i, fd in enumerate(frame_details): for i, fd in enumerate(frame_details):
if i >= len(frame_paths): if i >= len(frame_paths):
break break
try: try:
with open(frame_paths[i], 'rb') as f: with open(frame_paths[i], 'rb') as f:
fd['frame_image'] = base64.b64encode(f.read()).decode('ascii') raw = f.read()
marked, faces = mark_jpeg(raw)
fd['frame_image'] = base64.b64encode(marked).decode('ascii')
fd['face_count'] = faces
except OSError as e: except OSError as e:
logger.warning(f"关键帧图片读取失败: {frame_paths[i]}: {e}") logger.warning(f"关键帧图片读取失败: {frame_paths[i]}: {e}")

View File

@@ -7,6 +7,7 @@ API-Gateway - Flask 蓝图,接收任务
3. analyze (旧拉取模式, 兼容保留) 3. analyze (旧拉取模式, 兼容保留)
""" """
import os import os
import base64
import threading import threading
import requests import requests
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
@@ -440,6 +441,34 @@ def receive_push_task():
_currently_processing = False _currently_processing = False
@api_bp.route('/api/edge/mark_frames', methods=['POST'])
def mark_frames():
"""NAS 存量关键帧批量补红框(检测计算在 EdgeNAS 只存图)"""
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Invalid JSON"}), 400
images = data.get('images')
if not isinstance(images, list) or not images or len(images) > 12:
return jsonify({"error": "images 需要 1-12 项 [{key, data}]"}), 400
from ..frame_marker import mark_jpeg
results = []
for item in images:
key = item.get('key', '')
b64 = item.get('data', '')
try:
marked, faces = mark_jpeg(base64.b64decode(b64))
results.append({
"key": key,
"data": base64.b64encode(marked).decode('ascii'),
"faces": faces
})
except Exception as e:
logger.warning(f"补标失败 {key}: {e}")
results.append({"key": key, "data": None, "faces": 0, "error": str(e)})
return jsonify({"results": results}), 200
@api_bp.route('/health', methods=['GET']) @api_bp.route('/health', methods=['GET'])
def health(): def health():
"""健康检查""" """健康检查"""

View File

@@ -0,0 +1,71 @@
"""
Frame-Marker - 关键帧人脸红框标记
Edge 端统一做检测计算NAS ARM 太弱NAS 只存图零计算:
- orchestrator 分析后、回传前: 画红框 + 统计人脸数
- /api/edge/mark_frames: NAS 存量帧批量补标
"""
import os
import threading
import cv2
import numpy as np
from .logger import setup_logger
from .config_loader import load_config
logger = setup_logger('fam-edge.frame_marker')
_lock = threading.Lock()
_detector = None
DEFAULT_MODEL = '/opt/fam-edge/models/yunet.onnx'
def _get_detector():
global _detector
if _detector is not None:
return _detector
with _lock:
if _detector is not None:
return _detector
path = load_config().get('frame_marker', {}).get('model_path', DEFAULT_MODEL)
if not os.path.isfile(path):
logger.warning(f"YuNet 模型不存在,跳过红框标记: {path}")
return None
det = cv2.FaceDetectorYN_create(path, '', (320, 320), score_threshold=0.6)
_detector = det
logger.info(f"YuNet 人脸检测器就绪: {path}")
return det
def mark_jpeg(jpeg_bytes: bytes):
"""在 JPEG 帧图上画人脸红框
返回 (标记后的 JPEG bytes, 人脸数)。检测失败/无模型时原样返回。
"""
det = _get_detector()
if det is None:
return jpeg_bytes, 0
img = cv2.imdecode(np.frombuffer(jpeg_bytes, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return jpeg_bytes, 0
h, w = img.shape[:2]
with _lock:
det.setInputSize((w, h))
_, faces = det.detect(img)
if faces is None or len(faces) == 0:
return jpeg_bytes, 0
for f in faces:
x, y, fw, fh = int(f[0]), int(f[1]), int(f[2]), int(f[3])
# 人脸框外扩 40%,远处小脸也能看清
pad_w, pad_h = int(fw * 0.4), int(fh * 0.4)
x1 = max(0, x - pad_w)
y1 = max(0, y - pad_h)
x2 = min(w, x + fw + pad_w)
y2 = min(h, y + fh + pad_h)
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2)
ok, buf = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ok:
return jpeg_bytes, 0
return buf.tobytes(), len(faces)

View File

@@ -403,7 +403,7 @@ st.sidebar.markdown(
# 顶部横向导航(窄屏下侧边栏自动收起,放主区域保证任何屏宽都可见) # 顶部横向导航(窄屏下侧边栏自动收起,放主区域保证任何屏宽都可见)
page = st.segmented_control( page = st.segmented_control(
"功能页面", "功能页面",
["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 成员命名", "📈 统计图表"], ["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 人物管理", "📈 统计图表"],
default="🕒 事件时间轴", default="🕒 事件时间轴",
label_visibility="collapsed" label_visibility="collapsed"
) or "🕒 事件时间轴" ) or "🕒 事件时间轴"
@@ -771,110 +771,128 @@ elif page == "📝 对话历史":
# ============================================================ # ============================================================
# 成员命名 # 人物管理
# ============================================================ # ============================================================
elif page == "👤 成员命名": elif page == "👤 人物管理":
page_header('👤', '家庭成员命名', '为识别到的人物起') page_header('👤', '人物管理', '所有出现的人物 · 照片 · 命名与重命')
conn = get_db_conn() conn = get_db_conn()
try: try:
cursor = conn.cursor() 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( cursor.execute(
"""SELECT fm.abstract_label, fm.feature_description, fm.first_seen_at, """SELECT abstract_label, real_name, feature_description
(SELECT COUNT(*) FROM event_details ed WHERE ed.person = fm.abstract_label) AS event_count FROM family_members WHERE is_active = TRUE""")
FROM family_members fm members = cursor.fetchall()
WHERE fm.real_name IS NULL AND fm.is_active = TRUE cursor.execute(
ORDER BY fm.first_seen_at ASC""" """SELECT ed.event_id, ed.frame_index, ed.frame_timestamp, ed.person
) FROM event_details ed ORDER BY ed.frame_timestamp ASC""")
unnamed = cursor.fetchall() detail_rows = cursor.fetchall()
finally:
conn.close()
if not unnamed: 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( st.markdown(
'<div class="fam-empty"><span class="fe-ico">✅</span>' f'<div style="font-size:12px;color:#8b93a7;margin:2px 0 16px 0;">'
'所有人物已命名,或暂未发现新人物</div>', unsafe_allow_html=True) f'共 <b style="color:#f1f5f9;">{len(person_frames)}</b> 人 · '
else: f'已命名 <b style="color:#7dd3fc;">{len(named_keys)}</b> · '
for m in unnamed: f'未命名 <b style="color:#fbbf24;">{len(person_frames) - len(named_keys)}</b>'
col1, col2, col3 = st.columns([2, 1.6, 1]) f'<span style="color:#64748b;">(与事件时间轴"出现人物"统计一致)</span></div>',
with col1: unsafe_allow_html=True)
first_seen = serialize_datetime(m['first_seen_at'])
st.markdown( def person_photo(frames):
f'<div class="member-card">' for eid, fidx, _ in frames:
f'<div style="font-size:15px;font-weight:700;color:#8fb0ff;">' b64 = load_frame_b64(eid, fidx)
f'{esc(m["abstract_label"])}</div>' if b64:
f'<div style="font-size:12px;color:#8b93a7;margin-top:6px;' return b64
f'line-height:1.6;">{esc(m["feature_description"] or "无特征描述")}</div>' return None
f'<div style="font-size:11px;color:#64748b;margin-top:8px;">'
f'首次出现 {esc(first_seen)} · 事件数 {m["event_count"]}</div>' def do_name(label, new_name):
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: try:
resp = requests.post( resp = requests.post(
f"{_core_url}/api/member/name", f"{_core_url}/api/member/name",
json={ json={"abstract_label": label, "real_name": new_name, "named_by": "UI管理员"},
"abstract_label": m['abstract_label'], timeout=15)
"real_name": real_name.strip(),
"named_by": "UI管理员"
},
timeout=10
)
if resp.status_code == 200: if resp.status_code == 200:
result = resp.json() result = resp.json()
st.success( info = f"更新明细 {result.get('updated_event_details_count', 0)}"
f"命名成功!{m['abstract_label']}{real_name}" if result.get('renamed_from'):
f"更新明细 {result.get('updated_event_details_count', 0)}") info += f"(原 {result['renamed_from']}"
st.success(f"已保存:{label}{new_name}{info}")
st.rerun() st.rerun()
else: else:
st.error(f"命名失败: {resp.status_code} {resp.text}") st.error(f"失败: {resp.status_code} {resp.text[:150]}")
except Exception as e: except Exception as e:
st.error(f"异常: {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: else:
st.warning("请输入名字") st.warning("请输入名字")
st.markdown('<div style="height:8px"></div>', unsafe_allow_html=True) st.markdown('<div style="height:10px"></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)
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)
finally:
conn.close()
# ============================================================ # ============================================================