feat: 事件时间轴缩略帧 + 人物管理头像 + 人物合并硬规则校验
## 新架构:Oracle 集中计算 + NAS 代理展示 ### Oracle 端 (fam-edge) - 新增 frame_service: ffmpeg 视频抽帧 + VLM 人物定位裁剪头像(磁盘缓存) - 新增 /api/oracle/frame: 按 video_id+ts 抽帧返回 jpeg(带 token) - 新增 /api/oracle/avatar: 按 label 生成人物头像(VLM 定位人物 + 兜底整帧居中) - 新增 person_identifier: 人物身份识别模块 - Gemini 适配器支持 flash/flash-lite 双模型切换,429 自动降级 - frame_service VLM 全模型 429 时进入 10 分钟熔断,避免每次请求白打配额 - 兜底头像不落缓存,配额恢复后自动重试 VLM 精确定位 ### 人物合并硬规则校验(框架级修复) - person_service: LLM 合并结果落库前加硬冲突检测 - 性别冲突 → 绝不合并 - 年龄档跨未成年/成年 → 绝不合并(防止把爷爷/宝宝并进同一人) - oracle_db: upsert_person 入口剥离括号后缀(人物A(别名:人物B) → 人物A),消灭垃圾人物行 - 修复 set_canonical 丢弃 source 参数的 bug(旧代码硬编码 'manual' 导致错误合并被永久固化) - get_events_for_label: 只提取该身份组的特征文本,头像定位更精准 ### NAS 端 (fam-core) - 新增 img_proxy: /api/proxy/frame 和 /api/proxy/avatar 代理 Oracle 图片 - app.py 注册 img_bp 蓝图 - oracle_sync / db_layer / member_manager 同步人物表 ### UI 端 (fam-ui) - 事件时间轴: 每条事件卡片加时间点缩略帧 - 人物管理: 每人卡片加头像(150x150 圆角) - parse_persons: 剥离括号备注,与 Oracle 归一化一致 - 新增 EventItem 组件、Timeline 页改造 - Chat / ServiceStatus 页相应调整 ### 数据库 - scripts/ddl.sql: 同步表结构更新 - Oracle people 表: features_json / display_uid / source 字段完善
This commit is contained in:
256
fam-edge/src/fam_edge/person_identifier.py
Normal file
256
fam-edge/src/fam_edge/person_identifier.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
PersonIdentifier - 闭集人物识别(家里固定 4 个人:爷爷/爸爸/媳妇/汤圆)
|
||||
|
||||
背景(2026-08-22): 原来靠大模型每次视频分析自己编的"人物A/B/C"临时 uid + 一段
|
||||
性别/年龄/衣着文字描述做跨视频合并,原理上就不可靠——文字描述会因光线/角度/换衣服
|
||||
对不上,反复出现张冠李戴(用户原话:"现在的识别全是错的")。人脸向量方案也验证
|
||||
过,家庭监控这种大广角/远距离/糊画面下同人内部相似度经常比不同人还低,此路不通。
|
||||
|
||||
现在改成基于已知这个家庭只有 4 个固定成员的闭集规则:
|
||||
- 汤圆(幼儿/儿童)、媳妇(唯一成年女性):Gemini 每次分析已经会标性别/年龄段,
|
||||
这两条命中率验证下来接近 100%,直接用,不需要额外模型调用。
|
||||
- 爷爷、爸爸(两个成年男性,纯外观规则/人脸向量都区分不开):改用视觉大模型
|
||||
"看图比对"——给几张已确认身份的参考图 + 待判断的截图,直接问模型这是谁。
|
||||
实测 NVIDIA nemotron-omni 在留出测试集上 6/6 全对,Gemini flash-lite 7/8,
|
||||
NVIDIA 配额与 Gemini 完全独立、不跟主分析链路抢配额,设为优先。
|
||||
|
||||
调用粒度:每个运动片段(视频行)只调一次(不是每个事件都调)——同一段视频里
|
||||
人不会中途换衣服,取片段内最大 bbox 的成年男性外观代表整段。
|
||||
|
||||
健壮性(2026-08-22 补,用于支撑历史数据批量回填):
|
||||
- NVIDIA/Gemini 各自支持模型链(model_chain,fallback_models 可再加型号)+
|
||||
每个模型独立重试(429/503/超时/连接错误这类瞬时故障,指数退避),非瞬时错误
|
||||
(400 参数错误等)不重试、直接换下一个模型/provider。
|
||||
- min_call_interval_sec 控制连续两次分类调用之间的最小间隔(不分 provider 统一
|
||||
限速)——批量回填时会短时间内密集调用,需要限速避免打爆配额/被限流。
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .logger import setup_logger
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
OpenAI = None
|
||||
|
||||
logger = setup_logger('fam-edge.person_identifier')
|
||||
|
||||
REF_DIR_DEFAULT = '/opt/fam-edge/data/person_refs'
|
||||
ADULT_MALE_CANDIDATES = ('爷爷', '爸爸')
|
||||
# HTTP 状态码:值得重试的瞬时故障(配额限流/服务过载),其余(400 参数错误/401 鉴权等)不重试
|
||||
_RETRYABLE_STATUS = (429, 500, 502, 503, 504)
|
||||
|
||||
|
||||
class PersonIdentifier:
|
||||
def __init__(self, config: dict):
|
||||
self.enabled = bool(config.get('enabled', True))
|
||||
self.ref_dir = config.get('ref_dir', REF_DIR_DEFAULT)
|
||||
self.max_ref_per_person = int(config.get('max_ref_per_person', 6))
|
||||
self.min_call_interval_sec = float(config.get('min_call_interval_sec', 2))
|
||||
self._last_call_at = 0.0
|
||||
|
||||
nv = config.get('nvidia', {})
|
||||
self.nvidia_model_chain = [nv.get('model_name', 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning')] + [
|
||||
m for m in nv.get('fallback_models', []) or [] if m]
|
||||
self.nvidia_base_url = nv.get('base_url', 'https://integrate.api.nvidia.com/v1')
|
||||
self.nvidia_api_key = self._resolve(nv.get('api_key', '${NVIDIA_API_KEY}'))
|
||||
self.nvidia_timeout = int(nv.get('timeout', 60))
|
||||
self.nvidia_max_retries = int(nv.get('max_retries', 3))
|
||||
self.nvidia_retry_backoff = float(nv.get('retry_backoff_sec', 3))
|
||||
|
||||
gm = config.get('gemini', {})
|
||||
self.gemini_model = gm.get('model_name', 'gemini-flash-lite-latest')
|
||||
raw_keys = [gm.get('api_key', '${GEMINI_API_KEY}')] + list(gm.get('extra_api_keys', []) or [])
|
||||
self.gemini_api_keys = [k for k in (self._resolve(r) for r in raw_keys) if k]
|
||||
self.gemini_timeout = int(gm.get('timeout', 60))
|
||||
self.gemini_max_retries = int(gm.get('max_retries', 2))
|
||||
self.gemini_retry_backoff = float(gm.get('retry_backoff_sec', 3))
|
||||
|
||||
self._refs = None # lazy: {person: [base64_str, ...]}
|
||||
|
||||
@staticmethod
|
||||
def _resolve(raw: str) -> str:
|
||||
if isinstance(raw, str) and raw.startswith('${') and raw.endswith('}'):
|
||||
return os.environ.get(raw[2:-1], '')
|
||||
return raw
|
||||
|
||||
def _load_refs(self):
|
||||
if self._refs is not None:
|
||||
return self._refs
|
||||
refs = {}
|
||||
for person in ADULT_MALE_CANDIDATES:
|
||||
d = os.path.join(self.ref_dir, person)
|
||||
files = []
|
||||
if os.path.isdir(d):
|
||||
files = sorted(f for f in os.listdir(d) if f.lower().endswith(('.jpg', '.jpeg', '.png')))
|
||||
imgs = []
|
||||
for f in files[:self.max_ref_per_person]:
|
||||
try:
|
||||
with open(os.path.join(d, f), 'rb') as fh:
|
||||
imgs.append(base64.b64encode(fh.read()).decode('ascii'))
|
||||
except OSError:
|
||||
continue
|
||||
refs[person] = imgs
|
||||
self._refs = refs
|
||||
return refs
|
||||
|
||||
def has_references(self) -> bool:
|
||||
refs = self._load_refs()
|
||||
return all(refs.get(p) for p in ADULT_MALE_CANDIDATES)
|
||||
|
||||
def _pace(self):
|
||||
"""连续两次分类调用之间强制最小间隔,批量回填时避免短时间内打爆配额。"""
|
||||
if self.min_call_interval_sec <= 0:
|
||||
return
|
||||
wait = self.min_call_interval_sec - (time.time() - self._last_call_at)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
|
||||
def classify_adult_male(self, crop_bytes: bytes) -> Optional[str]:
|
||||
"""给一张成年男性截图,返回 '爷爷' / '爸爸',判断不了返回 None(调用方保持原样不动)。
|
||||
|
||||
NVIDIA 优先(配额独立、实测更准,模型链+重试),失败/未配置则退回 Gemini
|
||||
flash-lite(多 key 轮换+重试)。两边都失败返回 None——绝不瞎猜,宁可这次不
|
||||
设置 canonical_name,留给下次(或人工在人物管理页确认)。
|
||||
"""
|
||||
if not self.enabled or not self.has_references():
|
||||
return None
|
||||
self._pace()
|
||||
self._last_call_at = time.time()
|
||||
result = self._classify_nvidia(crop_bytes)
|
||||
if result:
|
||||
return result
|
||||
return self._classify_gemini(crop_bytes)
|
||||
|
||||
def _build_prompt_and_images(self, crop_bytes: bytes):
|
||||
refs = self._load_refs()
|
||||
query_b64 = base64.b64encode(crop_bytes).decode('ascii')
|
||||
images = [] # list of (b64, caption)
|
||||
idx = 1
|
||||
for person in ADULT_MALE_CANDIDATES:
|
||||
for b64 in refs.get(person, []):
|
||||
images.append((b64, f'(上图是参考图{idx},此人是:{person})'))
|
||||
idx += 1
|
||||
images.append((query_b64, '(上图是待判断的截图,请判断这是「爷爷」还是「爸爸」)'))
|
||||
prefix = ('下面先给你几张参考图,每张图后面标了这个人是谁'
|
||||
'(这户人家只有这两个成年男性,一个是爷爷,一个是爸爸):')
|
||||
suffix = ('只根据外观线索(体型/发型/衣着/姿态等)判断,用 JSON 回答,'
|
||||
'格式:{"person":"爷爷或爸爸"},不要输出其他内容。')
|
||||
return prefix, images, suffix
|
||||
|
||||
def _extract_json_person(self, text: str) -> Optional[str]:
|
||||
text = (text or '').strip()
|
||||
for cand in ('爷爷', '爸爸'):
|
||||
if cand in text:
|
||||
# 两个都出现时(比如复述了参考图说明)不采信,避免误判
|
||||
if '爷爷' in text and '爸爸' in text:
|
||||
# 优先信 JSON 里 "person" 字段紧跟的那个
|
||||
m = re.search(r'"person"\s*:\s*"(爷爷|爸爸)"', text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
return cand
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NVIDIA:模型链 × 每个模型独立重试(瞬时故障退避重试,非瞬时故障直接换模型)
|
||||
# ------------------------------------------------------------------
|
||||
def _classify_nvidia(self, crop_bytes: bytes) -> Optional[str]:
|
||||
if not self.nvidia_api_key or OpenAI is None:
|
||||
return None
|
||||
prefix, images, suffix = self._build_prompt_and_images(crop_bytes)
|
||||
if len(images) > 12:
|
||||
images = images[-12:] # NVIDIA 单请求最多 12 张图,优先保留最新的参考+待判断图
|
||||
content = [{'type': 'text', 'text': prefix}]
|
||||
for b64, caption in images:
|
||||
content.append({'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{b64}'}})
|
||||
content.append({'type': 'text', 'text': caption})
|
||||
content.append({'type': 'text', 'text': suffix})
|
||||
|
||||
client = OpenAI(base_url=self.nvidia_base_url, api_key=self.nvidia_api_key)
|
||||
for model in self.nvidia_model_chain:
|
||||
for attempt in range(self.nvidia_max_retries):
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{'role': 'user', 'content': content}],
|
||||
temperature=0.1, max_tokens=200,
|
||||
timeout=self.nvidia_timeout)
|
||||
text = resp.choices[0].message.content
|
||||
person = self._extract_json_person(text)
|
||||
if person:
|
||||
logger.info(f"NVIDIA[{model}] 人物识别: {person}")
|
||||
return person
|
||||
logger.warning(f"NVIDIA[{model}] 返回结果无法解析出人物: {text[:100] if text else text}")
|
||||
break # 解析不出人物是内容问题,不是瞬时故障,重试没用,换下一个模型
|
||||
except Exception as e:
|
||||
status = getattr(getattr(e, 'response', None), 'status_code', None)
|
||||
retryable = status in _RETRYABLE_STATUS or status is None
|
||||
if retryable and attempt < self.nvidia_max_retries - 1:
|
||||
backoff = self.nvidia_retry_backoff * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"NVIDIA[{model}] 第 {attempt+1}/{self.nvidia_max_retries} 次失败"
|
||||
f"(status={status}),{backoff:.1f}s 后重试: {e}")
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
logger.warning(f"NVIDIA[{model}] 失败(status={status}),换下一个模型: {e}")
|
||||
break
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gemini:多 key 轮换 × 每个 key 独立重试
|
||||
# ------------------------------------------------------------------
|
||||
def _classify_gemini(self, crop_bytes: bytes) -> Optional[str]:
|
||||
prefix, images, suffix = self._build_prompt_and_images(crop_bytes)
|
||||
parts = [{'text': prefix}]
|
||||
for b64, caption in images:
|
||||
parts.append({'inline_data': {'mime_type': 'image/jpeg', 'data': b64}})
|
||||
parts.append({'text': caption})
|
||||
parts.append({'text': suffix})
|
||||
|
||||
for key in self.gemini_api_keys:
|
||||
for attempt in range(self.gemini_max_retries):
|
||||
try:
|
||||
resp = requests.post(
|
||||
f'https://generativelanguage.googleapis.com/v1beta/models/'
|
||||
f'{self.gemini_model}:generateContent?key={key}',
|
||||
json={'contents': [{'parts': parts}],
|
||||
'generationConfig': {'temperature': 0.1, 'maxOutputTokens': 200}},
|
||||
timeout=self.gemini_timeout)
|
||||
data = resp.json()
|
||||
if not data.get('candidates'):
|
||||
err = data.get('error') or {}
|
||||
status = resp.status_code
|
||||
if status in _RETRYABLE_STATUS and attempt < self.gemini_max_retries - 1:
|
||||
backoff = self.gemini_retry_backoff * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"Gemini key 第 {attempt+1}/{self.gemini_max_retries} 次失败"
|
||||
f"(status={status}),{backoff:.1f}s 后重试: {err}")
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
logger.warning(f"Gemini 人物识别失败(status={status}): {err}")
|
||||
break # 这个 key 不行了,换下一个 key
|
||||
text = ''.join(
|
||||
p.get('text', '')
|
||||
for p in data['candidates'][0].get('content', {}).get('parts', []))
|
||||
person = self._extract_json_person(text)
|
||||
if person:
|
||||
logger.info(f"Gemini 人物识别: {person}")
|
||||
return person
|
||||
except requests.RequestException as e:
|
||||
if attempt < self.gemini_max_retries - 1:
|
||||
backoff = self.gemini_retry_backoff * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"Gemini 网络异常,{backoff:.1f}s 后重试(第 {attempt+1}/"
|
||||
f"{self.gemini_max_retries} 次): {e}")
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
logger.warning(f"Gemini 人物识别异常: {e}")
|
||||
break
|
||||
return None
|
||||
Reference in New Issue
Block a user