Files
sentinel-home-ai/fam-edge/src/fam_edge/model_adapters/nvidia_adapter.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

230 lines
11 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.
"""
NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器
provider_name = "nvidia"
模型: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning唯一实测确认可用的视频理解模型
角色: vision (整视频直出结构化 JSON) + 智能问答
SDK: openai (NIM 兼容 OpenAI API 规范)
整视频分析实测结论2026-08-21 用真实短视频逐个探测):
- nemotron-3-nano-omni-30b-a3b-reasoning: video_url 只认 base64 data URI
`data:video/mp4;base64,<...>`Assets API 的 asset_id 引用方式对它直接 500
(报错 "Only base64 data URLs are supported for now")——所以本适配器不再走
Assets API 上传,直接 base64 内嵌整段视频。
- nemotron-nano-12b-v2-vl: 需要走 NVCF 函数调用协议本身的 NVCF-ASSET-DIR/
NVCF-FUNCTION-ASSET-IDS 请求头,而这两个头的值是 NVCF 服务端按内部路径生成、
不是客户端能自己拼对的(实测传什么都 400 "Invalid NVCF-ASSET-DIR"),标准
OpenAI 兼容 chat.completions 调用打不通,已从模型链移除。
- meta/llama-3.2-11b-vision-instruct: 明确不支持视频输入("At most 0 video(s)
may be provided"),只能单图,已移除。
base64 方案的代价是请求体大小受限(原实现注释称约 25MB 上限),所以本适配器会在
上传前检查文件大小,超过 `max_base64_mb`(默认 20MB直接放弃不做注定失败的
慢速编码+上传。真实监控视频压缩后通常在 20MB 上下,属于"够不到就正常降级到失败
重试",不是本地故意限制过窄。
"""
import base64
import os
import time
from datetime import datetime, timezone, timedelta
from typing import Dict, List, Optional
from .base_adapter import BaseModelAdapter
from .circuit_breaker import CircuitBreaker
from ..logger import setup_logger
from ..ai_orchestrator.prompts import build_video_prompt
from ..ai_orchestrator.json_parser import parse_vlm_json, VLMOutputInvalidError
from ..config_loader import load_config
logger = setup_logger('fam-edge.nvidia_adapter')
try:
from openai import OpenAI
except ImportError:
OpenAI = None
class NvidiaVisionAdapter(BaseModelAdapter):
"""NVIDIA NIM 云端 VLM 适配器 (整视频单次调用; 文本问答)
多模型降级链(类似 Gemini flash -> flash-lite:
- model_chain = [model_name] + fallback_models
- asset 上传一次,遍历模型链逐个调用 video_url 引用同一 assetId
- 模型失败/超时 -> 记录统计 -> 间隔 switch_interval_sec 后切换下一模型
- 每个模型可用 model_timeouts 独立设置超时(不参与编排层 ×N 放大)
"""
def __init__(self, config: dict):
super().__init__("nvidia", config)
self.model_name = config.get(
'model_name', 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning')
self.model_chain = [self.model_name] + [
m for m in config.get('fallback_models', []) if m and m != self.model_name]
self.api_key = self._resolve_key(config.get('api_key', ''))
self.base_url = config.get('base_url', 'https://integrate.api.nvidia.com/v1')
self.timeout = config.get('timeout', 600)
# 问答专用超时,跟视频分析分开——交互式问答不该等到跟视频分析一样久
self.chat_timeout = config.get('chat_timeout', 20)
self.max_base64_mb = float(config.get('max_base64_mb', 20))
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
self.model_timeouts = {
str(k): int(v) for k, v in (config.get('model_timeouts') or {}).items()}
# 模型切换间隔(秒):一个模型失败后等待再切下一个,避免连续打爆 API
self.switch_interval_sec = float(config.get('switch_interval_sec', 5))
cb_cfg = config.get('circuit_breaker', {})
self._cb = CircuitBreaker(
threshold=cb_cfg.get('threshold', 3),
cooldown=cb_cfg.get('cooldown', 600),
enabled=cb_cfg.get('enabled', True)
)
self._client = None
if OpenAI is not None and self.api_key:
try:
self._client = OpenAI(base_url=self.base_url, api_key=self.api_key)
except Exception as e:
logger.error(f"NVIDIA OpenAI 客户端初始化失败: {e}")
self._client = None
def _resolve_key(self, raw: str) -> str:
if raw.startswith('${') and raw.endswith('}'):
return os.environ.get(raw[2:-1], '')
return raw
def health_check(self) -> bool:
if self._client is None:
logger.warning("NVIDIA OpenAI SDK 未就绪或 Key 未配置,健康检查失败")
return False
try:
self._client.models.list()
logger.info("NVIDIA 健康检查通过")
return True
except Exception as e:
logger.warning(f"NVIDIA 健康检查失败: {e}")
return False
# ------------------------------------------------------------------
# 整视频分析base64 内嵌 video_url 单次调用omni 只认 base64不认 asset_id 引用)
# ------------------------------------------------------------------
def analyze_video(self, video_path: str,
known_members_context: str,
event_start_time: str = '') -> Optional[Dict]:
if self._cb.is_open():
logger.warning("NVIDIA 熔断器 OPEN跳过视频分析")
return None
if self._client is None:
logger.warning("NVIDIA 客户端未初始化,跳过视频分析")
return None
if not os.path.isfile(video_path):
logger.warning(f"NVIDIA 视频文件不存在: {video_path}")
return None
size_mb = os.path.getsize(video_path) / (1024 * 1024)
if size_mb > self.max_base64_mb:
logger.warning(
f"NVIDIA 视频 {size_mb:.1f}MB 超过 base64 上限 {self.max_base64_mb}MB"
"跳过(不做注定失败的慢速编码)")
return None
try:
with open(video_path, 'rb') as f:
video_b64 = base64.b64encode(f.read()).decode()
except Exception as e:
logger.warning(f"NVIDIA 读取/编码视频失败: {e}")
return None
prompt = self._build_video_prompt(known_members_context, event_start_time)
last_err = "no_model_in_chain"
for idx, model in enumerate(self.model_chain):
model_timeout = self.model_timeouts.get(model, self.timeout)
logger.info(f"NVIDIA 模型链 [{idx+1}/{len(self.model_chain)}] "
f"尝试 {model}(超时 {model_timeout}s")
started = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
t0 = time.time()
try:
resp = self._client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "video_url", "video_url": {
"url": f"data:video/mp4;base64,{video_b64}"}}
]}],
temperature=0.2,
max_tokens=16384,
# NIM 扩展:控制视频采样帧数(部分模型支持)
extra_body={"media_io_kwargs": {"video": {"num_frames": 128}}},
timeout=model_timeout
)
duration = time.time() - t0
content = resp.choices[0].message.content
if not content:
self._emit_model_call(model, started, duration, False, "empty_content")
logger.warning(f"NVIDIA [{model}] 返回空 content切换下一模型")
last_err = f"{model}_empty"
self._sleep_switch(idx)
continue
try:
data = parse_vlm_json(content)
except VLMOutputInvalidError as e:
self._emit_model_call(model, started, duration, False, "json_parse_failed")
logger.warning(f"NVIDIA [{model}] JSON 解析失败,切换下一模型: {e}")
last_err = f"{model}_json"
self._sleep_switch(idx)
continue
self._emit_model_call(model, started, duration, True)
self._cb.record_success()
logger.info(f"NVIDIA [{model}] 整视频分析完成events={len(data.get('events', []))}")
data['compute_provider'] = f"nvidia:{model}"
return data
except Exception as e:
duration = time.time() - t0
self._emit_model_call(model, started, duration, False, str(e))
last_err = f"{model}_failed"
logger.warning(f"NVIDIA [{model}] 视频分析异常,切换下一模型: {str(e)[:150]}")
self._sleep_switch(idx)
self._cb.record_failure()
logger.error(f"NVIDIA 模型链全部失败: {last_err}")
return None
def _sleep_switch(self, idx: int):
"""模型切换间隔(最后一个模型失败后无需再等)"""
if idx < len(self.model_chain) - 1 and self.switch_interval_sec > 0:
logger.info(f"NVIDIA 等待 {self.switch_interval_sec}s 后切换下一模型")
time.sleep(self.switch_interval_sec)
def _build_video_prompt(self, known_members: str, event_start_time: str) -> str:
camera = load_config().get('gdrive_sync', {}).get('camera_name', '')
return build_video_prompt(known_members, event_start_time, camera)
# ------------------------------------------------------------------
# 智能问答:纯文本
# ------------------------------------------------------------------
def chat(self, prompt: str, max_tokens: int = 2048) -> Optional[str]:
if self._cb.is_open():
logger.warning("NVIDIA 熔断器 OPEN跳过问答")
return None
if self._client is None:
logger.warning("NVIDIA 客户端未初始化,跳过问答")
return None
for model in self.model_chain:
try:
resp = self._client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=max_tokens,
timeout=self.chat_timeout
)
content = resp.choices[0].message.content
if content:
self._cb.record_success()
return content.strip()
except Exception as e:
logger.warning(f"NVIDIA [{model}] 问答异常: {e}")
self._cb.record_failure()
return None
def get_timeout(self) -> int:
return self.timeout
def get_circuit_breaker(self) -> CircuitBreaker:
return self._cb