Files
sentinel-home-ai/fam-edge/src/fam_edge/person_identifier.py
ericwyuan 2c5bf950c5 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 字段完善
2026-08-23 00:13:56 +08:00

257 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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_chainfallback_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