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:
ericwyuan
2026-08-23 00:13:56 +08:00
parent aca1a674b1
commit 2c5bf950c5
31 changed files with 2166 additions and 25 deletions

View 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_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