""" NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器 provider_name = "nvidia" 模型: nvidia/nemotron-nano-12b-v2-vl(NIM 官方支持整视频 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