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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
"""列出所有成员"""
|
||||
|
||||
Reference in New Issue
Block a user