""" 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