[阶段2] FAM-Edge 重构为整视频分析+同步接口+人物服务 - 移除切片/抽帧/队列,新增 oracle_db/person_service/qa/watch_processor/video_processor,api_gateway 提供 /api/oracle/sync 与 /api/oracle/people/correct

This commit is contained in:
ericwyuan
2026-08-21 10:38:15 +08:00
parent ae1c13589f
commit 9b1cc8f93b
23 changed files with 1097 additions and 2335 deletions

View File

@@ -3,13 +3,21 @@
新增模型只需继承此类并实现方法:
1. health_check() -> bool
2. analyze_frames(frame_paths, frame_timestamps, known_members_context) -> Optional[dict]
- 视觉分析:输入帧图片路径 + 时间戳 + 成员清单,直接输出**结构化结果 dict**
(含 frame_details 等,详见 format_cloud_result 约定)
- 失败/超时返回 None。
2. analyze_video(video_path, known_members_context, event_start_time) -> Optional[dict]
- 整视频分析:直接把完整视频交给云端 VLM本地不切片、不抽帧
- 模型内部自行采样帧,输出结构化结果 dict。失败/超时返回 None
- 返回约定:
{
"global_summary": str, # 整段视频摘要
"events": [ # 有用时间点 + 画面信息
{"timestamp": "2026-08-21 08:15:30", # 绝对北京时间event_start_time 推算)
"description": str,
"people": [str],
"is_attention_event": bool}, ...],
"people_mentioned": [str], # 本视频出现的人物标识/真名
}
3. chat(prompt) -> Optional[str]
- 纯文本问答(智能问答场景),返回文本或 None。
- 默认实现抛 NotImplementedError文本/视觉模型按需实现。
4. get_timeout() -> int
5. get_circuit_breaker() -> CircuitBreaker
"""
@@ -36,22 +44,13 @@ class BaseModelAdapter(ABC):
pass
@abstractmethod
def analyze_frames(self, frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: str) -> Optional[Dict]:
"""视觉分析:输入帧图片路径 + 时间戳 + 成员清单,
直接输出结构化结果 dict含 frame_details 等)。失败/超时返回 None。
def analyze_video(self, video_path: str,
known_members_context: str,
event_start_time: str = '') -> Optional[Dict]:
"""整视频分析:把完整视频交给云端 VLM输出结构化结果 dict。
约定返回结构云端模型直出Edge 仅做格式化校验,不再本地融合):
{
"global_summary": "整个时段整体摘要(可选,缺失时由 Edge 格式化生成)",
"entities_json": [{"person","action","clothing"}] (可选,缺失时由 frame_details 推导),
"frame_details": [
{"frame_index":int, "frame_timestamp":str, "person":str,
"action":str, "clothing":str, "is_attention_event":bool,
"source_providers":[provider]}
]
}
本地不切片、不抽帧;模型内部自行采样帧。
失败/超时返回 None。
"""
pass

View File

@@ -2,15 +2,16 @@
GeminiAdapter - Google Gemini 云端 VLM 适配器
provider_name = "gemini"
模型: gemini-flash-latest (v1beta 下 gemini-1.5-flash 会 404用 flash-latest 别名)
角色: vision (视觉分析直出结构化 JSON) + 智能问答
模型: gemini-flash-latest
角色: vision (整视频直出结构化 JSON) + 智能问答
健康检查: GET /v1beta/models?key=...
熔断器: 启用
视觉分析: 多图单请求直出结构化 JSONglobal_summary/entities_json/frame_details
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
本地不切片、不抽帧Gemini 原生支持长视频)
"""
import os
import time
import base64
import json
import requests
from typing import Dict, List, Optional
@@ -23,16 +24,15 @@ logger = setup_logger('fam-edge.gemini_adapter')
class GeminiAdapter(BaseModelAdapter):
"""Gemini 云端 VLM 适配器 (视觉直出结构化 JSON + 文本问答)"""
"""Gemini 云端 VLM 适配器 (整视频直出结构化 JSON + 文本问答)"""
def __init__(self, config: dict):
super().__init__("gemini", config)
self.model_name = config.get('model_name', 'gemini-flash-latest')
# 免费层配额按模型独立20 请求/天/模型fallback 链用于跨模型借用配额
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.timeout = config.get('timeout', 30)
self.timeout = config.get('timeout', 600)
cb_cfg = config.get('circuit_breaker', {})
self._cb = CircuitBreaker(
threshold=cb_cfg.get('threshold', 3),
@@ -69,76 +69,131 @@ class GeminiAdapter(BaseModelAdapter):
return False
# ------------------------------------------------------------------
# 视觉分析:多图单请求,直出结构化 JSON
# 整视频分析Files API 上传 -> generateContent
# ------------------------------------------------------------------
def analyze_frames(self, frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: str) -> Optional[Dict]:
def analyze_video(self, video_path: str,
known_members_context: str,
event_start_time: str = '') -> Optional[Dict]:
if self._cb.is_open():
logger.warning("Gemini 熔断器 OPEN跳过调用")
logger.warning("Gemini 熔断器 OPEN跳过视频分析")
return None
if not self.api_key:
logger.warning("Gemini API Key 未配置,跳过调用")
logger.warning("Gemini API Key 未配置,跳过视频分析")
return None
if not frame_paths:
logger.warning("Gemini 无帧可分析")
if not os.path.isfile(video_path):
logger.warning(f"Gemini 视频文件不存在: {video_path}")
return None
parts = []
ts_map = {}
for i, (path, ts) in enumerate(zip(frame_paths, frame_timestamps), 1):
try:
with open(path, 'rb') as f:
img = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.error(f"读取图片失败 {path}: {e}")
continue
parts.append({"inline_data": {"mime_type": "image/jpeg", "data": img}})
parts.append({"text": f"[图片{i}] 时间: {ts}"})
ts_map[i] = ts
if not parts:
file_uri = self._upload_file(video_path)
if not file_uri:
self._cb.record_failure()
return None
parts.insert(0, {"text": self._build_structured_prompt(known_members_context)})
prompt = self._build_video_prompt(known_members_context, event_start_time)
try:
text = self._generate(parts, max_tokens=2048, temperature=0.2)
text = self._generate_video(file_uri, prompt, max_tokens=4096, temperature=0.2)
if text is None:
self._cb.record_failure()
return None
try:
result = parse_vlm_json(text)
# 确保 frame_details 的 frame_timestamp 与标注一致
for f in result.get('frame_details', []):
idx = f.get('frame_index')
if isinstance(idx, int) and idx in ts_map and not f.get('frame_timestamp'):
f['frame_timestamp'] = ts_map[idx]
for f in result.get('frame_details', []):
if 'source_providers' not in f or not f.get('source_providers'):
f['source_providers'] = ['gemini']
result = self._normalize(result)
if not result or 'events' not in result:
logger.error(f"Gemini 视频输出缺少 events: {text[:150]}")
self._cb.record_failure()
return None
result['compute_provider'] = 'gemini'
self._cb.record_success()
logger.info(f"Gemini 视觉分析完成,frame_details={len(result.get('frame_details', []))}")
logger.info(f"Gemini 整视频分析完成,events={len(result.get('events', []))}")
return result
except VLMOutputInvalidError as e:
logger.error(f"Gemini 输出无法解析为 JSON: {e}")
logger.error(f"Gemini 视频输出无法解析为 JSON: {e}")
self._cb.record_failure()
return None
except requests.Timeout:
logger.warning(f"Gemini 视分析超时 ({self.timeout}s)")
logger.warning(f"Gemini 视分析超时 ({self.timeout}s)")
self._cb.record_failure()
return None
except Exception as e:
logger.error(f"Gemini 视分析异常: {e}")
logger.error(f"Gemini 视分析异常: {e}")
self._cb.record_failure()
return None
finally:
self._delete_file(file_uri)
def _upload_file(self, video_path: str) -> Optional[str]:
"""用 Files API 上传完整视频,返回可引用 URI。"""
name = os.path.basename(video_path)
upload_url = f"{self._base_url}/files?key={self.api_key}"
try:
with open(video_path, 'rb') as f:
data = f.read()
except OSError as e:
logger.error(f"读取视频失败 {video_path}: {e}")
return None
headers = {
"X-Goog-Upload-Protocol": "raw",
"X-Goog-Upload-File-Name": name,
"Content-Type": "video/mp4",
}
try:
resp = requests.post(upload_url, headers=headers, data=data, timeout=300)
except requests.Timeout:
logger.warning("Gemini 文件上传超时 (300s)")
return None
except Exception as e:
logger.error(f"Gemini 文件上传异常: {e}")
return None
if resp.status_code not in (200, 201):
logger.warning(f"Gemini 文件上传失败 HTTP {resp.status_code}: {resp.text[:200]}")
return None
try:
info = resp.json().get('file', {})
uri = info.get('uri')
file_name = info.get('name')
state = info.get('state')
except (ValueError, KeyError):
logger.warning("Gemini 文件上传响应解析失败")
return None
if not uri:
return None
# 等待 ACTIVE大文件可能还在处理
if state != 'ACTIVE' and file_name:
uri = self._wait_active(file_name)
return uri
def _wait_active(self, file_name: str, max_wait: int = 120) -> Optional[str]:
url = f"{self._base_url}/{file_name}?key={self.api_key}"
deadline = time.time() + max_wait
while time.time() < deadline:
try:
r = requests.get(url, timeout=15)
if r.status_code == 200:
j = r.json()
if j.get('state') == 'ACTIVE':
return j.get('uri')
except Exception:
pass
time.sleep(5)
logger.warning(f"Gemini 文件 {file_name} 未在 {max_wait}s 内 ACTIVE")
return None
def _generate(self, parts: List[dict], max_tokens: int,
temperature: float) -> Optional[str]:
"""带模型 fallback 链的 generateContent 调用
def _delete_file(self, file_uri: str):
if not file_uri or 'files/' not in file_uri:
return
name = file_uri.split('files/', 1)[-1]
try:
requests.delete(f"{self._base_url}/files/{name}?key={self.api_key}", timeout=15)
except Exception:
pass
- 429每日免费配额耗尽按模型独立→ 立即换下一个模型,不重试
- 503模型过载临时性→ 同模型退避 3s 重试一次,仍失败换下一个
"""
def _generate_video(self, file_uri: str, prompt: str,
max_tokens: int, temperature: float) -> Optional[str]:
"""带模型 fallback 链的 generateContent视频文件引用调用。"""
parts = [
{"file_data": {"mime_type": "video/mp4", "file_uri": file_uri}},
{"text": prompt},
]
for model in self.model_chain:
for attempt in range(2):
try:
@@ -146,14 +201,15 @@ class GeminiAdapter(BaseModelAdapter):
f"{self._base_url}/models/{model}:generateContent?key={self.api_key}",
json={"contents": [{"parts": parts}],
"generationConfig": {
"temperature": temperature, "maxOutputTokens": max_tokens}},
"temperature": temperature,
"maxOutputTokens": max_tokens}},
timeout=self.timeout
)
except requests.Timeout:
logger.warning(f"Gemini [{model}] 请求超时 ({self.timeout}s)")
logger.warning(f"Gemini [{model}] 视频请求超时 ({self.timeout}s)")
break
except Exception as e:
logger.error(f"Gemini [{model}] 请求异常: {e}")
logger.error(f"Gemini [{model}] 视频请求异常: {e}")
break
if resp.status_code == 200:
@@ -164,55 +220,76 @@ class GeminiAdapter(BaseModelAdapter):
).strip() if cands else ''
if text:
if model != self.model_name:
logger.info(f"Gemini 主模型不可用,由 fallback 模型 [{model}] 出结果")
logger.info(f"Gemini 主模型不可用,由 fallback [{model}] 出结果")
return text
logger.warning(f"Gemini [{model}] 返回空文本")
continue
detail = resp.text[:150].replace('\n', ' ')
if resp.status_code == 429:
logger.warning(f"Gemini [{model}] 429 每日免费配额耗尽,切换下一模型")
logger.warning(f"Gemini [{model}] 429 配额耗尽,切换下一模型")
break
if resp.status_code == 503:
if attempt == 0:
logger.warning(f"Gemini [{model}] 503 过载3s 后重试")
time.sleep(3)
continue
logger.warning(f"Gemini [{model}] 503 重试仍失败,切换下一模型")
break
logger.warning(f"Gemini [{model}] HTTP {resp.status_code}: {detail}")
break
return None
def _build_structured_prompt(self, known_members: str) -> str:
return f"""你是家庭监控视频分析助手。下面按时间顺序排列了多张监控截图。
请分析整个时段,只输出合法 JSON不要 markdown、不要任何解释文字结构如下
@staticmethod
def _normalize(result: dict) -> dict:
"""统一字段名frame_details -> events兼容旧结构"""
events = result.get('events')
if events is None and 'frame_details' in result:
events = []
for f in result['frame_details']:
events.append({
"timestamp": f.get('frame_timestamp', ''),
"description": f.get('action', ''),
"people": [f.get('person', '')] if f.get('person') else [],
"is_attention_event": bool(f.get('is_attention_event', False)),
})
if events is None:
events = []
people = result.get('people_mentioned') or result.get('entities_json') or []
if isinstance(people, list) and people and isinstance(people[0], dict):
people = [p.get('person', '') for p in people]
people = [p for p in people if p]
return {
"global_summary": result.get('global_summary', ''),
"events": events,
"people_mentioned": people,
}
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 句,客观描述人物与主要活动",
"entities_json": [
{{"person": "人物标识(匹配已知成员用真名,否则用'人物A'/'人物B'...)", "action": "主要动作", "clothing": "衣着"}}
],
"frame_details": [
"events": [
{{
"frame_index": 图片序号(从1开始与[图片N]标注对应),
"frame_timestamp": "该帧的时间戳(用[图片N]标注里的时间)",
"person": "该帧画面中的人物或'无人'",
"action": "该帧可见动作",
"clothing": "该帧衣着(颜色+类型)",
"is_attention_event": false,
"source_providers": ["gemini"]
"timestamp": "事件发生时的绝对北京时间(格式 YYYY-MM-DD HH:MM:SS)",
"description": "该时间点的画面/动作信息摘要(谁、在做什么、位置)",
"people": ["出现在该时刻的人物,用已知成员真名或'人物A'/'人物B'"],
"is_attention_event": false
}}
]
}}
],
"people_mentioned": ["本视频出现过的所有人物标识/真名"]
}}{start_hint}
规则:
1. 只描述客观画面,不要猜测或想象。
2. frame_details 每帧一条frame_index 与上方[图片N]序号对应frame_timestamp 用标注时间。
2. events 提取视频中"有意义的时间点"(人物出现/动作变化/异常),不要逐秒罗列;timestamp 用绝对北京时间。
3. 已知家庭成员(按特征匹配,匹配到用 real_name否则用"人物X"
{known_members or '(暂无已知成员)'}
4. is_attention_event是否为跌倒、危险、异常哭闹等需关注事件没有则为 false
5. 没有人物出现的帧 person 填"无人"action 填"""""
5. 没有人物出现的时段不要单独成 eventpeople 留空数组"""
# ------------------------------------------------------------------
# 智能问答:纯文本
@@ -222,11 +299,42 @@ class GeminiAdapter(BaseModelAdapter):
logger.warning("Gemini API Key 未配置,跳过问答")
return None
try:
return self._generate([{"text": prompt}], max_tokens=max_tokens, temperature=0.3)
return self._generate_text(prompt, max_tokens=max_tokens, temperature=0.3)
except Exception as e:
logger.error(f"Gemini 问答异常: {e}")
return None
def _generate_text(self, text: str, max_tokens: int, temperature: float) -> Optional[str]:
"""纯文本 generateContent复用模型 fallback 链)。"""
for model in self.model_chain:
try:
resp = requests.post(
f"{self._base_url}/models/{model}:generateContent?key={self.api_key}",
json={"contents": [{"parts": [{"text": text}]}],
"generationConfig": {
"temperature": temperature,
"maxOutputTokens": max_tokens}},
timeout=self.timeout
)
except requests.Timeout:
logger.warning(f"Gemini [{model}] 问答超时")
continue
except Exception as e:
logger.error(f"Gemini [{model}] 问答异常: {e}")
continue
if resp.status_code == 200:
cands = resp.json().get('candidates', [])
out = ''.join(
p.get('text', '')
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
).strip() if cands else ''
if out:
return out
elif resp.status_code == 429:
logger.warning(f"Gemini [{model}] 429切换模型")
continue
return None
def get_timeout(self) -> int:
return self.timeout

View File

@@ -2,23 +2,15 @@
NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器
provider_name = "nvidia"
模型: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning (Omni, 原生视频输入)
角色: vision (视觉分析直出结构化 JSON) + 智能问答
模型: nvidia/nemotron-nano-12b-v2-vlNIM 官方支持整视频 video_url 输入,内部自行采样帧)
角色: vision (整视频直出结构化 JSON) + 智能问答
SDK: openai (NIM 兼容 OpenAI API 规范)
视频模式 (analyze_video): 按关键帧时间点截取 ±1.5s 片段拼接集锦视频
(片段左上角叠加原始时间戳)base64 后经 video_url 单次调用 —
模型看到动态画面而非静态帧,动作/轨迹识别显著优于逐帧图片。
图片模式 (analyze_frames): 逐帧 image_url 调用(无视频文件时的降级路径)。
注意: nemotron-omni 是 reasoning 模型max_tokens 需给足reasoning 消耗 token
整视频分析: 整视频 base64 经 video_url 单次调用 —— 本地不切片、不抽帧
"""
import os
import base64
import json
import re
import subprocess
import tempfile
from typing import Dict, List, Optional
from .base_adapter import BaseModelAdapter
@@ -32,20 +24,17 @@ try:
except ImportError:
OpenAI = None
VIDEO_SEGMENT_PAD = 1.5 # 关键帧前后各截取秒数
HIGHLIGHT_WIDTH = 640 # 集锦视频宽度(保持宽高比)
class NvidiaVisionAdapter(BaseModelAdapter):
"""NVIDIA NIM 云端 VLM 适配器 (视频集锦单次调用; 逐帧降级; 文本问答)"""
"""NVIDIA NIM 云端 VLM 适配器 (视频单次调用; 文本问答)"""
def __init__(self, config: dict):
super().__init__("nvidia", config)
self.model_name = config.get(
'model_name', 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning')
'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', 120)
self.timeout = config.get('timeout', 600)
cb_cfg = config.get('circuit_breaker', {})
self._cb = CircuitBreaker(
threshold=cb_cfg.get('threshold', 3),
@@ -78,127 +67,29 @@ class NvidiaVisionAdapter(BaseModelAdapter):
return False
# ------------------------------------------------------------------
# 视频模式:集锦视频 + video_url 单次调用(主路径)
# 视频分析base64 整视频 -> video_url 单次调用
# ------------------------------------------------------------------
@staticmethod
def _ts_to_seconds(ts: str) -> float:
"""'2026-08-20 04:34:10'(生产格式)/ 'HH:MM:SS' -> 当日秒偏移"""
s = str(ts).strip()
# 绝对时间格式: 取时间部分(同一天 30min 片段内足够)
date_part, _, time_part = s.partition(' ')
if time_part and '-' in date_part:
s = time_part
parts = s.split(':')
try:
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
if len(parts) == 2:
return int(parts[0]) * 60 + float(parts[1])
return float(ts)
except ValueError:
return -1.0
@staticmethod
def _probe_duration(video_path: str) -> float:
"""ffprobe 解析视频时长,失败返回 0"""
try:
r = subprocess.run(
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'csv=p=0', video_path],
capture_output=True, timeout=30)
return float(r.stdout.decode().strip() or 0)
except (subprocess.TimeoutExpired, OSError, ValueError):
return 0.0
def _build_highlight_video(self, video_path: str, frame_timestamps: List[str],
event_start_time: str = '') -> Optional[str]:
"""按关键帧时间点截取 ±pad 秒片段,叠加时间戳后拼接集锦视频
时间戳以 2026-08-20 04-34-10 形式叠加(连字符避免 ffmpeg drawtext 冒号转义)。
偏移换算: 绝对时间戳 - 视频开始时间event_start_time 缺失时,
时间戳值本身须已是视频内偏移,如 HH:MM:SS 相对时间)。
"""
start_sec = self._ts_to_seconds(event_start_time) if event_start_time else 0.0
duration = self._probe_duration(video_path)
clips = []
for ts in frame_timestamps:
sec = self._ts_to_seconds(ts)
if sec < 0:
continue
if start_sec > 0:
sec -= start_sec
if sec < 0:
sec += 86400 # 跨午夜
if duration > 0 and (sec < -VIDEO_SEGMENT_PAD
or sec > duration - 0.5):
logger.info(f"NVIDIA 跳过超界片段: {ts} -> {sec:.1f}s (视频 {duration:.0f}s)")
continue
clips.append((sec, str(ts).replace(':', '-')))
if not clips:
return None
out_path = os.path.join(
tempfile.mkdtemp(prefix='nim_highlight_'), 'highlight.mp4')
cmd = ['ffmpeg', '-y', '-loglevel', 'error']
for start, _ in clips:
cmd += ['-ss', f'{start:.2f}', '-t', f'{VIDEO_SEGMENT_PAD * 2}', '-i', video_path]
parts = []
for i, (_, label) in enumerate(clips):
parts.append(
f"[{i}:v]fps=15,scale={HIGHLIGHT_WIDTH}:-2,"
f"drawtext=text='ts {label}':x=8:y=8:fontsize=22:"
f"fontcolor=white:box=1:boxcolor=black@0.6[v{i}]")
concat_in = ''.join(f'[v{i}]' for i in range(len(clips)))
parts.append(f'{concat_in}concat=n={len(clips)}:v=1:a=0[out]')
cmd += ['-filter_complex', ';'.join(parts), '-map', '[out]',
'-r', '15',
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '28',
'-an', out_path]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
except subprocess.TimeoutExpired:
logger.warning("NVIDIA 集锦视频生成超时")
return None
except subprocess.CalledProcessError as e:
logger.warning(f"NVIDIA 集锦视频生成失败: {e.stderr.decode()[:200] if e.stderr else e}")
return None
size = os.path.getsize(out_path)
logger.info(f"NVIDIA 集锦视频生成: {len(clips)} 片段, {size // 1024}KB")
if size < 1024:
return None
return out_path
def analyze_video(self, video_path: str,
frame_timestamps: List[str],
known_members_context: str,
event_start_time: str = '') -> Optional[Dict]:
"""原生视频输入分析: 集锦片段 -> video_url 单次调用"""
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
highlight = self._build_highlight_video(
video_path, frame_timestamps, event_start_time)
if not highlight:
logger.warning("NVIDIA 集锦视频不可用,降级逐帧模式")
if not os.path.isfile(video_path):
logger.warning(f"NVIDIA 视频文件不存在: {video_path}")
return None
try:
with open(highlight, 'rb') as f:
with open(video_path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.warning(f"NVIDIA 读取集锦视频失败: {e}")
logger.warning(f"NVIDIA 读取视频失败: {e}")
return None
finally:
try:
os.remove(highlight)
os.rmdir(os.path.dirname(highlight))
except OSError:
pass
prompt = self._build_video_prompt(frame_timestamps, known_members_context)
prompt = self._build_video_prompt(known_members_context, event_start_time)
try:
resp = self._client.chat.completions.create(
model=self.model_name,
@@ -208,134 +99,47 @@ class NvidiaVisionAdapter(BaseModelAdapter):
"url": f"data:video/mp4;base64,{b64}"}}
]}],
temperature=0.2,
max_tokens=3072,
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_single_frame_json(content)
if not data or 'frame_details' not in data:
data = self._parse_json(content)
if not data or 'events' not in data:
logger.warning(f"NVIDIA 视频 JSON 解析失败: {content[:150]}")
return None
frame_details = self._normalize_frame_details(data, frame_timestamps)
if not frame_details:
self._cb.record_failure()
return None
self._cb.record_success()
logger.info(f"NVIDIA 视频分析完成,frame_details={len(frame_details)}")
result = {"frame_details": frame_details}
if data.get('global_summary'):
result['global_summary'] = str(data['global_summary'])
if data.get('entities_json'):
result['entities_json'] = data['entities_json']
return result
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
def _normalize_frame_details(self, data: dict,
frame_timestamps: List[str]) -> List[Dict]:
"""归一化模型输出的 frame_details按已知时间戳对齐"""
details = []
for i, item in enumerate(data.get('frame_details', []), 1):
if not isinstance(item, dict):
continue
ts = str(item.get('frame_timestamp',
frame_timestamps[i - 1] if i <= len(frame_timestamps) else ''))
details.append({
"frame_index": i,
"frame_timestamp": ts,
"person": str(item.get('person', '无人')),
"action": str(item.get('action', '')),
"clothing": str(item.get('clothing', '')),
"is_attention_event": bool(item.get('is_attention_event', False)),
"source_providers": ["nvidia"],
})
return details
def _build_video_prompt(self, frame_timestamps: List[str],
known_members: str) -> str:
ts_list = '\n'.join(f' 片段{i}: 原始时间 {ts}' for i, ts in enumerate(frame_timestamps, 1))
return f"""你是家庭监控视频分析助手。下面的视频是由一段长时间监控录像中抽取的片段集锦,
{len(frame_timestamps)} 个片段(每个约 3 秒),按顺序拼接。每个片段左上角叠加了
原始时间戳ts 后的 2026-08-20 04-34-10 表示北京时间 2026年8月20日 04:34:10
片段时间对照:
{ts_list}
只输出合法 JSON不要 markdown、不要解释结构如下
{{
"frame_details": [
{{
"frame_timestamp": "<片段原始时间>",
"person": "片段中的人物或'无人'",
"action": "片段中人物的动作(动态观察,如走动/跑动/坐下)",
"clothing": "衣着(颜色+类型)",
"is_attention_event": false
}}
],
"global_summary": "整段录像的综合摘要",
"entities_json": [{{"person": "人物名或人物X", "action": "行为概括", "clothing": "衣着"}}]
}}
规则:
1. 只描述客观画面,不猜测。
2. 已知家庭成员(按特征匹配,匹配到用 real_name否则用"人物X"
{known_members or '(暂无已知成员)'}
3. is_attention_event跌倒、危险、异常哭闹等需关注事件没有则为 false
4. 无人出现的片段 person 填"无人"action 填"""""
# ------------------------------------------------------------------
# 图片模式:逐帧调用(降级路径,无视频文件时使用)
# ------------------------------------------------------------------
def analyze_frames(self, frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: 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 frame_paths:
logger.warning("NVIDIA 无帧可分析")
return None
frame_details = []
ok = False
for i, (path, ts) in enumerate(zip(frame_paths, frame_timestamps), 1):
detail = self._analyze_one_structured(path, ts, i, known_members_context)
if detail:
frame_details.append(detail)
ok = True
if not ok:
self._cb.record_failure()
return None
self._cb.record_success()
logger.info(f"NVIDIA 视觉分析完成frame_details={len(frame_details)}")
# NVIDIA 单帧无法跨帧综合 global_summary交由 Edge format_cloud_result 格式化生成
return {"frame_details": frame_details}
def _parse_single_frame_json(self, content: str) -> Optional[dict]:
"""轻量解析单帧 JSON不要求全 schema仅提取字段"""
@staticmethod
def _parse_json(content: str) -> Optional[dict]:
content = content.strip()
# 直接解析
try:
return json.loads(content)
except json.JSONDecodeError:
pass
# 提取 markdown fence
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:
@@ -344,68 +148,35 @@ class NvidiaVisionAdapter(BaseModelAdapter):
pass
return None
def _analyze_one_structured(self, path: str, ts: str, idx: int,
known_members: str) -> Optional[Dict]:
try:
with open(path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.error(f"读取图片失败 {path}: {e}")
return None
prompt = self._build_structured_prompt(ts, idx, known_members)
try:
resp = self._client.chat.completions.create(
model=self.model_name,
messages=[{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
]}],
temperature=0.2,
max_tokens=512,
timeout=self.timeout
)
content = resp.choices[0].message.content
if not content:
return None
data = self._parse_single_frame_json(content)
if not data:
logger.warning(f"NVIDIA 单帧 JSON 解析失败: {content[:120]}")
return None
return {
"frame_index": idx,
"frame_timestamp": str(data.get("frame_timestamp", ts)),
"person": str(data.get("person", "无人")),
"action": str(data.get("action", "")),
"clothing": str(data.get("clothing", "")),
"is_attention_event": bool(data.get("is_attention_event", False)),
"source_providers": ["nvidia"],
}
except Exception as e:
logger.warning(f"NVIDIA 单帧异常: {e}")
return None
def _build_structured_prompt(self, ts: str, idx: int, known_members: str) -> str:
return f"""你是家庭监控视频分析助手。请看这张监控截图(拍摄时间 {ts})。
只输出合法 JSON不要 markdown、不要解释结构如下
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、不要解释结构如下
{{
"frame_timestamp": "{ts}",
"person": "该帧画面中的人物或'无人'",
"action": "该帧可见动作",
"clothing": "该帧衣着(颜色+类型)",
"is_attention_event": false
}}
"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. 已知家庭成员(按特征匹配,匹配到用 real_name否则用"人物X"
2. events 提取有意义的时间点(人物出现/动作变化/异常timestamp 用绝对北京时间。
3. 已知家庭成员(按特征匹配,匹配到用 real_name否则用"人物X"
{known_members or '(暂无已知成员)'}
3. is_attention_event是否为跌倒、危险、异常哭闹等需关注事件(没有则为 false
4. 没有人物出现的帧 person 填"无人"action 填"""""
4. is_attention_event跌倒、危险、异常哭闹等需关注事件没有则为 false"""
# ------------------------------------------------------------------
# 智能问答:纯文本reasoning 模型max_tokens 需给足)
# 智能问答:纯文本
# ------------------------------------------------------------------
def chat(self, prompt: str, max_tokens: int = 2048) -> Optional[str]:
if self._client is None:

View File

@@ -111,6 +111,13 @@ class OllamaAdapter(BaseModelAdapter):
self._cb.record_failure()
return None
def analyze_video(self, video_path: str,
known_members_context: str,
event_start_time: str = '') -> Optional[Dict]:
"""Ollama 为纯文本模型,不参与视频分析,返回 None降级链不会选它做视频"""
logger.info("Ollama 为纯文本模型,跳过视频分析")
return None
def get_timeout(self) -> int:
return self.timeout