diff --git a/fam-core/src/fam_core/db_layer.py b/fam-core/src/fam_core/db_layer.py index c8f9253..6d4f81f 100644 --- a/fam-core/src/fam_core/db_layer.py +++ b/fam-core/src/fam_core/db_layer.py @@ -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: - """命名成员 + 批量回溯更新历史记录""" + """命名/重命名成员 + 批量回溯更新历史记录 + + - 标签未入库(如 AI 新输出的 人物1)自动注册 + - 已命名成员可重命名(旧真名一并回溯替换) + - person 为多人组合字符串('张三, 汤圆'),用 REPLACE 替换其中目标 + """ conn = get_conn() try: cursor = conn.cursor() - # 1. 校验存在且未命名 + # 1. 查现有记录,拿到旧真名 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,) ) row = cursor.fetchone() - if not row: - return {"error": f"成员 {abstract_label} 不存在或已命名"} + old_name = None + 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 cursor.execute( @@ -447,45 +459,62 @@ def name_member(abstract_label: str, real_name: str, named_by: str) -> Dict: ) # 3. 批量回溯更新 event_details - cursor.execute( - "UPDATE event_details SET person = %s WHERE person = %s", - (real_name, abstract_label) - ) - updated_details_count = cursor.rowcount + # 目标出现的三种形态: 独占整字段 / 多人组合内 / AI 直呼旧真名 + updated_details_count = 0 + for old in {abstract_label, old_name} - {None}: + if old == real_name: + continue + cursor.execute( + "UPDATE event_details SET person = %s WHERE person = %s", + (real_name, old) + ) + 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 # MariaDB 10.11 不支持 MySQL 的 $[*] 通配符 JSON 路径, # 改用 Python 层解析 + 逐行更新 - cursor.execute( - "SELECT event_id, entities_json FROM monitor_events WHERE entities_json LIKE %s", - (f'%{abstract_label}%',) - ) import json as _json updated_events_count = 0 - for eid, entities_raw in cursor.fetchall(): - if not entities_raw: - continue - try: - entities = _json.loads(entities_raw) if isinstance(entities_raw, str) else entities_raw - except (ValueError, TypeError): - continue - changed = False - if isinstance(entities, list): - for ent in entities: - if isinstance(ent, dict) and ent.get('person') == abstract_label: - ent['person'] = real_name - changed = True - if changed: - cursor.execute( - "UPDATE monitor_events SET entities_json = %s WHERE event_id = %s", - (_json.dumps(entities, ensure_ascii=False), eid) - ) - updated_events_count += 1 + 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(): + if not entities_raw: + continue + try: + entities = _json.loads(entities_raw) if isinstance(entities_raw, str) else entities_raw + except (ValueError, TypeError): + continue + changed = False + if isinstance(entities, list): + for ent in entities: + if isinstance(ent, dict) and ent.get('person') in targets: + ent['person'] = real_name + changed = True + if changed: + cursor.execute( + "UPDATE monitor_events SET entities_json = %s WHERE event_id = %s", + (_json.dumps(entities, ensure_ascii=False), eid) + ) + updated_events_count += 1 conn.commit() return { "abstract_label": abstract_label, "real_name": real_name, + "renamed_from": old_name, "updated_event_details_count": updated_details_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() +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: """获取已命名+未命名成员清单,用于注入 VLM Prompt""" conn = get_conn() diff --git a/fam-core/src/fam_core/event_receiver/event_receiver.py b/fam-core/src/fam_core/event_receiver/event_receiver.py index 8448d58..836c5ee 100644 --- a/fam-core/src/fam_core/event_receiver/event_receiver.py +++ b/fam-core/src/fam_core/event_receiver/event_receiver.py @@ -38,10 +38,15 @@ def _is_abstract_label(person: str) -> bool: def _save_frame_images(event_id: int, frame_details: list) -> int: - """把 frame_details 中的 base64 关键帧落盘,返回成功张数""" + """把 frame_details 中的 base64 关键帧落盘,返回成功张数 + + 同时写 meta.json(frame_index -> face_count),UI 据此挑选有人像的帧做头像。 + """ saved = 0 + face_counts = {} for frame in frame_details: img_b64 = frame.pop('frame_image', None) + faces = frame.pop('face_count', None) if not img_b64: continue 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') with open(out_path, 'wb') as f: f.write(base64.b64decode(img_b64)) + if faces is not None: + face_counts[str(idx)] = int(faces) saved += 1 except Exception as 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: logger.info(f"[event_id={event_id}] 关键帧落盘 {saved} 张 -> {FRAME_IMAGE_DIR}") return saved diff --git a/fam-core/src/fam_core/member_manager/member_manager.py b/fam-core/src/fam_core/member_manager/member_manager.py index 53b977c..f0820bb 100644 --- a/fam-core/src/fam_core/member_manager/member_manager.py +++ b/fam-core/src/fam_core/member_manager/member_manager.py @@ -57,6 +57,29 @@ def name_member(): 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']) def list_members(): """列出所有成员""" diff --git a/fam-core/tools/backfill_mark_frames.py b/fam-core/tools/backfill_mark_frames.py new file mode 100644 index 0000000..49e121f --- /dev/null +++ b/fam-core/tools/backfill_mark_frames.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""存量关键帧批量补红框(计算在 Edge/Oracle,NAS 只编排与存图) + +流程: 遍历 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() diff --git a/fam-edge/src/fam_edge/ai_orchestrator/orchestrator.py b/fam-edge/src/fam_edge/ai_orchestrator/orchestrator.py index 856e567..42fa8d8 100644 --- a/fam-edge/src/fam_edge/ai_orchestrator/orchestrator.py +++ b/fam-edge/src/fam_edge/ai_orchestrator/orchestrator.py @@ -173,13 +173,20 @@ class AIOrchestrator: @staticmethod def _attach_frame_images(frame_details: List[dict], frame_paths: List[str]) -> None: - """把关键帧图片 base64 附加到 frame_details(按位置对齐视觉分析输入帧)""" + """把关键帧图片 base64 附加到 frame_details(按位置对齐视觉分析输入帧) + + 附带人脸红框标记与 face_count(NAS 落盘 meta.json,UI 据此挑有人像的头像) + """ + from ..frame_marker import mark_jpeg for i, fd in enumerate(frame_details): if i >= len(frame_paths): break try: 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: logger.warning(f"关键帧图片读取失败: {frame_paths[i]}: {e}") diff --git a/fam-edge/src/fam_edge/api_gateway/api_gateway.py b/fam-edge/src/fam_edge/api_gateway/api_gateway.py index fbceeb8..d636d22 100644 --- a/fam-edge/src/fam_edge/api_gateway/api_gateway.py +++ b/fam-edge/src/fam_edge/api_gateway/api_gateway.py @@ -7,6 +7,7 @@ API-Gateway - Flask 蓝图,接收任务 3. analyze (旧拉取模式, 兼容保留) """ import os +import base64 import threading import requests from flask import Blueprint, request, jsonify @@ -440,6 +441,34 @@ def receive_push_task(): _currently_processing = False +@api_bp.route('/api/edge/mark_frames', methods=['POST']) +def mark_frames(): + """NAS 存量关键帧批量补红框(检测计算在 Edge,NAS 只存图)""" + 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']) def health(): """健康检查""" diff --git a/fam-edge/src/fam_edge/frame_marker.py b/fam-edge/src/fam_edge/frame_marker.py new file mode 100644 index 0000000..021648a --- /dev/null +++ b/fam-edge/src/fam_edge/frame_marker.py @@ -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) diff --git a/fam-ui/src/app.py b/fam-ui/src/app.py index 76cef5c..c8567e6 100644 --- a/fam-ui/src/app.py +++ b/fam-ui/src/app.py @@ -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('