Files
sentinel-home-ai/fam-edge/src/fam_edge/model_adapters/nvidia_adapter.py

204 lines
8.1 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-nano-12b-v2-vlNIM 官方支持整视频 video_url 输入,内部自行采样帧)
角色: vision (整视频直出结构化 JSON) + 智能问答
SDK: openai (NIM 兼容 OpenAI API 规范)
整视频分析: 整视频 base64 经 video_url 单次调用 —— 本地不切片、不抽帧
"""
import os
import base64
import json
import re
from typing import Dict, List, Optional
from .base_adapter import BaseModelAdapter
from .circuit_breaker import CircuitBreaker
from ..logger import setup_logger
logger = setup_logger('fam-edge.nvidia_adapter')
try:
from openai import OpenAI
except ImportError:
OpenAI = None
class NvidiaVisionAdapter(BaseModelAdapter):
"""NVIDIA NIM 云端 VLM 适配器 (整视频单次调用; 文本问答)"""
def __init__(self, config: dict):
super().__init__("nvidia", config)
self.model_name = config.get(
'model_name', 'nvidia/nemotron-nano-12b-v2-vl')
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)
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 单次调用
# ------------------------------------------------------------------
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
try:
with open(video_path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.warning(f"NVIDIA 读取视频失败: {e}")
return None
prompt = self._build_video_prompt(known_members_context, event_start_time)
try:
resp = self._client.chat.completions.create(
model=self.model_name,
messages=[{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "video_url", "video_url": {
"url": f"data:video/mp4;base64,{b64}"}}
]}],
temperature=0.2,
max_tokens=4096,
# NIM 扩展:控制视频采样帧数(模型上限 128 帧)
extra_body={"media_io_kwargs": {"video": {"num_frames": 128}}},
timeout=self.timeout
)
content = resp.choices[0].message.content
if not content:
logger.warning("NVIDIA 视频分析返回空 content")
self._cb.record_failure()
return None
data = self._parse_json(content)
if not data or 'events' not in data:
logger.warning(f"NVIDIA 视频 JSON 解析失败: {content[:150]}")
self._cb.record_failure()
return None
self._cb.record_success()
logger.info(f"NVIDIA 整视频分析完成events={len(data.get('events', []))}")
return {
"global_summary": str(data.get('global_summary', '')),
"events": data.get('events', []),
"people_mentioned": data.get('people_mentioned', []),
"compute_provider": "nvidia",
}
except Exception as e:
self._cb.record_failure()
logger.warning(f"NVIDIA 视频分析异常: {e}")
return None
@staticmethod
def _parse_json(content: str) -> Optional[dict]:
content = content.strip()
try:
return json.loads(content)
except json.JSONDecodeError:
pass
fence = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
if fence:
try:
return json.loads(fence.group(1))
except json.JSONDecodeError:
pass
brace = re.search(r'\{.*\}', content, re.DOTALL)
if brace:
try:
return json.loads(brace.group(0))
except json.JSONDecodeError:
pass
return None
def _build_video_prompt(self, known_members: str, event_start_time: str) -> str:
start_hint = ""
if event_start_time:
start_hint = f"\n视频开始时间(北京时间)约为:{event_start_time}。请据此推算每个事件的绝对时间戳。"
return f"""你是家庭监控视频分析助手。下面是一段完整监控录像(已整段上传)。
请观看整段视频,提取其中有用的信息,只输出合法 JSON不要 markdown、不要解释结构如下
{{
"global_summary": "整个时段的整体摘要简体中文2-4 句",
"events": [
{{
"timestamp": "事件发生时的绝对北京时间(YYYY-MM-DD HH:MM:SS)",
"description": "该时刻画面/动作信息摘要",
"people": ["出现在该时刻的人物,用已知成员真名或'人物A'"],
"is_attention_event": false
}}
],
"people_mentioned": ["本视频出现过的所有人物标识/真名"]
}}{start_hint}
规则:
1. 只描述客观画面,不猜测。
2. events 提取有意义的时间点(人物出现/动作变化/异常timestamp 用绝对北京时间。
3. 已知家庭成员(按特征匹配,匹配到用 real_name否则用"人物X"
{known_members or '(暂无已知成员)'}
4. is_attention_event跌倒、危险、异常哭闹等需关注事件没有则为 false"""
# ------------------------------------------------------------------
# 智能问答:纯文本
# ------------------------------------------------------------------
def chat(self, prompt: str, max_tokens: int = 2048) -> Optional[str]:
if self._client is None:
logger.warning("NVIDIA 客户端未初始化,跳过问答")
return None
try:
resp = self._client.chat.completions.create(
model=self.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=max_tokens,
timeout=self.timeout
)
content = resp.choices[0].message.content
return content.strip() if content else None
except Exception as e:
logger.warning(f"NVIDIA 问答异常: {e}")
return None
def get_timeout(self) -> int:
return self.timeout
def get_circuit_breaker(self) -> CircuitBreaker:
return self._cb