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:
@@ -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}")
|
||||
|
||||
|
||||
@@ -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():
|
||||
"""健康检查"""
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user