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:
|
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()
|
||||||
|
|||||||
@@ -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.json(frame_index -> face_count),UI 据此挑选有人像的帧做头像。
|
||||||
|
"""
|
||||||
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
|
||||||
|
|||||||
@@ -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():
|
||||||
"""列出所有成员"""
|
"""列出所有成员"""
|
||||||
|
|||||||
118
fam-core/tools/backfill_mark_frames.py
Normal file
118
fam-core/tools/backfill_mark_frames.py
Normal file
@@ -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()
|
||||||
@@ -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_count(NAS 落盘 meta.json,UI 据此挑有人像的头像)
|
||||||
|
"""
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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 存量关键帧批量补红框(检测计算在 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'])
|
@api_bp.route('/health', methods=['GET'])
|
||||||
def health():
|
def health():
|
||||||
"""健康检查"""
|
"""健康检查"""
|
||||||
|
|||||||
71
fam-edge/src/fam_edge/frame_marker.py
Normal file
71
fam-edge/src/fam_edge/frame_marker.py
Normal 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)
|
||||||
@@ -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()
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user