## 新架构: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 字段完善
230 lines
11 KiB
Python
230 lines
11 KiB
Python
"""
|
||
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
|