[3.1-3.5] FAM-Edge 全链路 - API-Gateway/Video-Preprocessor/AI-Orchestrator/模型适配器(基类+Ollama+Gemini)/熔断器/JSON解析容错 + 配置

This commit is contained in:
ericwyuan
2026-08-19 22:25:38 +08:00
parent da6b1c8d39
commit cdd1f21d4c
20 changed files with 1384 additions and 0 deletions

View File

View File

@@ -0,0 +1,104 @@
"""
VLM JSON 解析容错 + Schema 校验
三层容错策略:
1. 直接 json.loads
2. 提取 markdown fence 内容
3. 贪婪匹配最大的 {...}
validate_schema: 校验 + 脏数据清洗
"""
import re
import json
from typing import Dict
class VLMOutputInvalidError(Exception):
"""VLM 输出无法解析为合法 JSON"""
pass
def parse_vlm_json(raw: str) -> dict:
"""三层容错解析 VLM 输出的 JSON"""
# 第 1 层:直接 json.loads
try:
return validate_schema(json.loads(raw.strip()))
except json.JSONDecodeError:
pass
# 第 2 层:提取 markdown fence 内容
fence_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', raw, re.DOTALL)
if fence_match:
try:
return validate_schema(json.loads(fence_match.group(1)))
except json.JSONDecodeError:
pass
# 第 3 层:贪婪匹配最大的 {...}
brace_match = re.search(r'\{.*\}', raw, re.DOTALL)
if brace_match:
try:
return validate_schema(json.loads(brace_match.group(0)))
except json.JSONDecodeError:
pass
raise VLMOutputInvalidError(f"无法从 VLM 输出中解析 JSON: {raw[:200]}")
def validate_schema(data: dict) -> dict:
"""Schema 校验 + 脏数据清洗"""
required = ["global_summary", "entities_json", "frame_details", "compute_provider"]
for k in required:
if k not in data:
raise VLMOutputInvalidError(f"缺失字段: {k}")
# entities_json 结构校验
if not isinstance(data["entities_json"], list):
raise VLMOutputInvalidError("entities_json 必须为数组")
cleaned_entities = []
for ent in data["entities_json"]:
if not isinstance(ent, dict):
continue
if "person" not in ent or "action" not in ent:
raise VLMOutputInvalidError("entity 缺少 person 或 action 字段")
cleaned_entities.append({
"person": str(ent["person"]),
"action": str(ent["action"]),
"clothing": str(ent.get("clothing", ""))
})
data["entities_json"] = cleaned_entities
# frame_details 结构校验
if not isinstance(data["frame_details"], list):
raise VLMOutputInvalidError("frame_details 必须为数组")
cleaned_frames = []
for frame in data["frame_details"]:
if not isinstance(frame, dict):
continue
for k in ["frame_index", "frame_timestamp", "person", "action", "source_providers"]:
if k not in frame:
raise VLMOutputInvalidError(f"frame_details 缺少字段: {k}")
sp = frame["source_providers"]
if not isinstance(sp, list) or len(sp) == 0:
raise VLMOutputInvalidError("frame_details.source_providers 必须为非空数组")
cleaned_frames.append({
"frame_index": int(frame["frame_index"]),
"frame_timestamp": str(frame["frame_timestamp"]),
"person": str(frame["person"]),
"action": str(frame["action"]),
"clothing": str(frame.get("clothing", "")),
"is_attention_event": bool(frame.get("is_attention_event", False)),
"source_providers": [str(p) for p in sp]
})
data["frame_details"] = cleaned_frames
# compute_provider 校验为数组
if not isinstance(data["compute_provider"], list):
raise VLMOutputInvalidError("compute_provider 必须为数组")
if len(data["compute_provider"]) == 0:
raise VLMOutputInvalidError("compute_provider 不能为空数组")
data["compute_provider"] = [str(p) for p in data["compute_provider"]]
return data

View File

@@ -0,0 +1,327 @@
"""
AI-Orchestrator - 多模型并行编排
流程:
1. 加载所有启用的模型适配器
2. 健康检查
3. 抽帧 + 关键帧筛选 + 压缩
4. 并行调用所有健康模型ThreadPoolExecutor
5. 文本融合(多模型输出平等交叉验证)
6. 回调 NAS
7. 清理临时文件
"""
import time
import json
import base64
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
from typing import Dict, List, Optional
from ..logger import setup_logger, log_task
from ..config_loader import load_config
from ..model_adapters.adapter_factory import build_adapters
from ..model_adapters.base_adapter import BaseModelAdapter
from ..video_preprocessor.preprocessor import VideoPreprocessor
from .json_parser import parse_vlm_json, VLMOutputInvalidError
logger = setup_logger('fam-edge.orchestrator')
FUSION_SYSTEM_PROMPT = """你是一个无情的数据提取器。不要输出任何思考过程,只输出合法 JSON。
输入参数:
- 多模型视觉分析日志(每个模型独立输出,平等对待,交叉验证):
{model_outputs}
- 已知成员清单: {known_members}
执行规则:
1. 多个模型的输出平等对待,交叉验证:
- 多个模型一致描述的内容 → 可信度高,必须纳入 frame_detailssource_providers 列出所有一致的模型
- 仅单一模型描述的内容 → 纳入 frame_detailssource_providers 仅含该模型
- 多个模型冲突时(如人物动作描述不一致)→ 以多数模型一致为准source_providers 列出多数派模型
2. 画面人物按特征匹配已知成员清单:
- 匹配到已命名成员real_name 非空)→ person 字段填 real_name
- 匹配到未命名成员real_name 为空)→ person 字段填 abstract_label
- 都不匹配 → 按出现顺序赋予新标识"人物B""人物C"...
3. 提取每张关键帧对应的时间点、人物、动作、衣着,输出到 frame_details 数组。
4. frame_details 每条必须包含 source_providers 数组。
5. compute_provider 字段填入本次实际成功调用的所有模型标识数组(去重)。
6. 仅输出合法 JSON不输出任何思考过程、markdown 标记或注释。
输出 JSON 结构:
{{
"global_summary": "字符串,整个时段的整体摘要,简体中文",
"entities_json": [
{{
"person": "字符串",
"action": "字符串",
"clothing": "字符串"
}}
],
"frame_details": [
{{
"frame_index": "数字",
"frame_timestamp": "字符串ISO 8601 格式时间戳",
"person": "字符串",
"action": "字符串",
"clothing": "字符串",
"is_attention_event": "布尔值",
"source_providers": "数组"
}}
],
"compute_provider": "数组"
}}
"""
class AIOrchestrator:
"""AI 编排器"""
def __init__(self):
self.config = load_config()
self.adapters: List[BaseModelAdapter] = build_adapters(self.config.get('models', []))
self.timeout_cfg = self.config.get('timeout', {})
def health_check_all(self) -> List[BaseModelAdapter]:
"""健康检查,返回健康的适配器列表"""
healthy = []
for adapter in self.adapters:
try:
if adapter.health_check():
healthy.append(adapter)
except Exception as e:
logger.error(f"适配器 {adapter.provider_name} 健康检查异常: {e}")
return healthy
def run_visual_analysis(self, adapters: List[BaseModelAdapter],
frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: str) -> Dict[str, str]:
"""并行调用所有健康模型进行视觉分析"""
model_outputs = {}
max_timeout = max((a.get_timeout() for a in adapters), default=240)
with ThreadPoolExecutor(max_workers=len(adapters)) as pool:
futures = {}
for adapter in adapters:
if adapter.get_circuit_breaker().is_open():
logger.warning(f"[{adapter.provider_name}] 熔断器 OPEN跳过")
continue
future = pool.submit(
adapter.analyze_frames,
frame_paths, frame_timestamps, known_members_context
)
futures[future] = adapter.provider_name
for future in as_completed(futures, timeout=max_timeout + 10):
provider = futures[future]
start = time.time()
try:
adapter = next(a for a in adapters if a.provider_name == provider)
output = future.result(timeout=adapter.get_timeout())
duration_ms = int((time.time() - start) * 1000)
if output:
model_outputs[provider] = output
adapter.get_circuit_breaker().record_success()
log_task(logger, 0, f'model_{provider}', f'视觉分析成功,输出长度={len(output)}', duration_ms=duration_ms)
else:
adapter.get_circuit_breaker().record_failure()
logger.warning(f"[{provider}] 视觉分析返回空")
except FuturesTimeout:
logger.warning(f"[{provider}] 视觉分析超时")
adapter = next(a for a in adapters if a.provider_name == provider)
adapter.get_circuit_breaker().record_failure()
except Exception as e:
logger.error(f"[{provider}] 视觉分析异常: {e}")
adapter = next(a for a in adapters if a.provider_name == provider)
adapter.get_circuit_breaker().record_failure()
return model_outputs
def run_text_fusion(self, model_outputs: Dict[str, str],
known_members_context: str,
task_id: int) -> dict:
"""文本融合阶段 - 多模型输出平等交叉验证"""
# 构建 model_outputs 文本
outputs_text = '\n'.join(
f" - {provider} 输出: {output}" for provider, output in model_outputs.items()
)
prompt = FUSION_SYSTEM_PROMPT.format(
model_outputs=outputs_text,
known_members=known_members_context or '(暂无已知成员)'
)
# 调用 Ollama 纯文本模式
ollama_cfg = next(
(cfg for cfg in self.config.get('models', []) if cfg.get('provider') == 'ollama'),
None
)
if not ollama_cfg:
raise VLMOutputInvalidError("没有 Ollama 配置,无法执行文本融合")
base_url = ollama_cfg.get('base_url', 'http://localhost:11434')
model_name = ollama_cfg.get('model_name', 'llava-phi3')
fusion_timeout = self.timeout_cfg.get('vlm_fusion', 120)
start = time.time()
resp = requests.post(
f"{base_url}/api/generate",
json={
"model": model_name,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.0}
},
timeout=fusion_timeout
)
if resp.status_code != 200:
raise VLMOutputInvalidError(f"融合阶段 Ollama 调用失败: {resp.status_code}")
raw_output = resp.json().get('response', '')
duration_ms = int((time.time() - start) * 1000)
log_task(logger, task_id, 'vlm_fusion', f'融合完成,原始输出长度={len(raw_output)}', duration_ms=duration_ms)
# 解析 JSON三层容错
result = parse_vlm_json(raw_output)
# 确保 compute_provider 与实际调用的模型一致
result['compute_provider'] = list(model_outputs.keys())
# 确保 frame_details 的 source_providers 只包含实际成功的模型
valid_providers = set(model_outputs.keys())
for frame in result.get('frame_details', []):
frame['source_providers'] = [
p for p in frame.get('source_providers', []) if p in valid_providers
] or list(valid_providers)
return result
def send_callback(self, webhook_url: str, task_id: int,
result: dict, camera_name: str = '',
event_start_time: str = '', event_end_time: str = ''):
"""回调 NAS"""
payload = {
"task_id": task_id,
"status": "success",
"event_start_time": event_start_time,
"event_end_time": event_end_time,
"camera_name": camera_name,
"global_summary": result.get('global_summary', ''),
"entities_json": result.get('entities_json', []),
"frame_details": result.get('frame_details', []),
"compute_provider": result.get('compute_provider', []),
"error_message": None
}
callback_timeout = self.timeout_cfg.get('callback', 30)
max_retries = 3
for attempt in range(max_retries):
try:
resp = requests.post(webhook_url, json=payload, timeout=callback_timeout)
if resp.status_code == 200:
log_task(logger, task_id, 'callback', '回调成功')
return
else:
logger.warning(f"[task_id={task_id}] 回调返回 {resp.status_code},重试 {attempt+1}/{max_retries}")
except Exception as e:
logger.warning(f"[task_id={task_id}] 回调异常: {e},重试 {attempt+1}/{max_retries}")
raise Exception(f"回调失败,已重试 {max_retries}")
def send_failure_callback(self, webhook_url: str, task_id: int,
failure_stage: str, error_message: str):
"""发送失败回调"""
payload = {
"task_id": task_id,
"status": "failed",
"failure_stage": failure_stage,
"error_message": error_message
}
try:
requests.post(webhook_url, json=payload, timeout=30)
except Exception as e:
logger.error(f"[task_id={task_id}] 失败回调也失败: {e}")
def process_task(self, task_data: dict):
"""端到端处理任务"""
task_id = task_data.get('task_id')
video_url = task_data.get('video_url')
webhook_url = task_data.get('webhook_url')
known_members = task_data.get('known_members_context', '')
logger.info(f"[task_id={task_id}] ====== 开始处理任务 ======")
start_time = time.time()
# 1. 健康检查
healthy_adapters = self.health_check_all()
if not healthy_adapters:
logger.error(f"[task_id={task_id}] 所有模型不健康,返回 503")
self.send_failure_callback(webhook_url, task_id, 'vlm_visual', 'All models unhealthy')
return 503
# 2. 下载 + 抽帧
preprocessor = VideoPreprocessor(task_id)
try:
# 下载
video_path = preprocessor.download_video(video_url)
# 抽帧
candidate_frames = preprocessor.extract_candidate_frames(video_path)
if not candidate_frames:
raise Exception("抽帧失败,无候选帧")
# 关键帧筛选
key_frames = preprocessor.select_key_frames(candidate_frames)
# 压缩
compressed_frames = preprocessor.compress_frames(key_frames)
if not compressed_frames:
raise Exception("压缩后无可用帧")
# 计算时间戳
event_start_time = task_data.get('event_start_time', '')
frame_timestamps = preprocessor.compute_timestamps(
video_path, len(compressed_frames), event_start_time
)
# 3. 并行视觉分析
model_outputs = self.run_visual_analysis(
healthy_adapters, compressed_frames, frame_timestamps, known_members
)
if not model_outputs:
raise Exception('All models failed in visual analysis')
# 4. 文本融合
fusion_result = self.run_text_fusion(model_outputs, known_members, task_id)
# 5. 回调
# 从视频文件名推断 camera_name
camera_name = task_data.get('camera_name', '')
event_end_time = task_data.get('event_end_time', '')
self.send_callback(
webhook_url, task_id, fusion_result,
camera_name=camera_name,
event_start_time=event_start_time,
event_end_time=event_end_time
)
total_ms = int((time.time() - start_time) * 1000)
log_task(logger, task_id, 'overall', f'任务完成', duration_ms=total_ms)
except VLMOutputInvalidError as e:
logger.error(f"[task_id={task_id}] VLM 输出解析失败: {e}")
self.send_failure_callback(webhook_url, task_id, 'vlm_fusion', str(e))
except Exception as e:
logger.error(f"[task_id={task_id}] 任务处理失败: {e}", exc_info=True)
self.send_failure_callback(webhook_url, task_id, 'download', str(e))
finally:
# 6. 清理
if 'preprocessor' in locals():
preprocessor.cleanup()
return 200

View File

@@ -0,0 +1,89 @@
"""
API-Gateway - Flask 蓝图,接收任务
同时只允许 1 个任务在处理;新任务到达时若当前有任务处理中,返回 429
"""
import threading
from flask import Blueprint, request, jsonify
from ..logger import setup_logger
from ..ai_orchestrator.orchestrator import AIOrchestrator
logger = setup_logger('fam-edge.api_gateway')
api_bp = Blueprint('api_gateway', __name__)
# 并发控制:同时只允许 1 个任务
_current_task_lock = threading.Lock()
_currently_processing = False
_orchestrator = None
def get_orchestrator():
global _orchestrator
if _orchestrator is None:
_orchestrator = AIOrchestrator()
return _orchestrator
@api_bp.route('/api/edge/video/analyze', methods=['POST'])
def receive_task():
"""接收分析任务"""
global _currently_processing
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Invalid JSON"}), 400
task_id = data.get('task_id')
video_url = data.get('video_url')
webhook_url = data.get('webhook_url')
if not task_id or not video_url or not webhook_url:
return jsonify({"error": "缺少必填字段: task_id, video_url, webhook_url"}), 400
logger.info(f"[task_id={task_id}] 收到任务: {video_url}")
# 并发控制
with _current_task_lock:
if _currently_processing:
logger.warning(f"[task_id={task_id}] 队列已满 (当前有任务处理中),返回 429")
return jsonify({"error": "Queue full", "retry_after": 60}), 429
_currently_processing = True
# 异步处理
def _process():
global _currently_processing
try:
orch = get_orchestrator()
orch.process_task(data)
except Exception as e:
logger.error(f"[task_id={task_id}] 处理异常: {e}", exc_info=True)
finally:
with _current_task_lock:
_currently_processing = False
thread = threading.Thread(target=_process, daemon=True, name=f'task-{task_id}')
thread.start()
return jsonify({"status": "accepted", "task_id": task_id}), 202
@api_bp.route('/health', methods=['GET'])
def health():
"""健康检查"""
global _currently_processing
orch = get_orchestrator()
healthy = orch.health_check_all()
if not healthy:
return jsonify({
"status": "unavailable",
"healthy_models": [],
"processing": _currently_processing
}), 503
return jsonify({
"status": "ok",
"healthy_models": [a.provider_name for a in healthy],
"processing": _currently_processing
}), 200

View File

@@ -0,0 +1,30 @@
"""
FAM-Edge 主应用 - Flask 单进程
承载: API-Gateway / Video-Preprocessor / AI-Orchestrator / Storage-Cleaner
"""
import os
import sys
from flask import Flask, jsonify
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from .config_loader import load_config
from .logger import setup_logger
from .api_gateway.api_gateway import api_bp
logger = setup_logger('fam-edge.app')
app = Flask(__name__)
app.register_blueprint(api_bp)
@app.route('/', methods=['GET'])
def root():
return jsonify({"service": "fam-edge", "version": "1.0"}), 200
if __name__ == '__main__':
cfg = load_config()
port = cfg.get('server', {}).get('port', 5000)
app.run(host='0.0.0.0', port=port, debug=False, threaded=True)

View File

@@ -0,0 +1,30 @@
"""
配置加载器 - 从 config.yaml 读取配置,支持 ${ENV_VAR} 解析
"""
import os
import re
import yaml
def _resolve_env_vars(value):
if isinstance(value, str):
def replace_env(match):
env_name = match.group(1)
return os.environ.get(env_name, match.group(0))
return re.sub(r'\$\{(\w+)\}', replace_env, value)
elif isinstance(value, dict):
return {k: _resolve_env_vars(v) for k, v in value.items()}
elif isinstance(value, list):
return [_resolve_env_vars(item) for item in value]
return value
def load_config(config_path=None):
if config_path is None:
config_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'config', 'config.yaml'
)
with open(config_path, 'r', encoding='utf-8') as f:
raw = yaml.safe_load(f)
return _resolve_env_vars(raw)

View File

@@ -0,0 +1,28 @@
"""
日志工具 - 统一格式,带 task_id 作为 trace_id
"""
import logging
import sys
def setup_logger(name='fam-edge', level=logging.INFO):
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(level)
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter(
'%(asctime)s [%(name)s] %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def log_task(logger, task_id, stage, message, level=logging.INFO, duration_ms=None):
parts = [f"[task_id={task_id}]", stage]
if duration_ms is not None:
parts.append(f"done in {duration_ms}ms")
parts.append(message)
logger.log(level, ' '.join(parts))

View File

@@ -0,0 +1,54 @@
"""
适配器工厂 - 根据 config 创建适配器实例
新增模型只需:
1. 实现适配器类(继承 BaseModelAdapter
2. 在此工厂注册
3. 在 config.yaml 的 models 数组加一项
"""
from typing import List
from .base_adapter import BaseModelAdapter
from .ollama_adapter import OllamaAdapter
from .gemini_adapter import GeminiAdapter
from ..logger import setup_logger
logger = setup_logger('fam-edge.adapter_factory')
_ADAPTER_REGISTRY = {
"ollama": OllamaAdapter,
"gemini": GeminiAdapter,
# v1.1 扩展:
# "openai": OpenAIAdapter,
# "nvidia": NvidiaAdapter,
}
def build_adapter(config: dict) -> BaseModelAdapter:
"""根据 config 中的 provider 字段创建适配器"""
provider = config.get('provider', '')
adapter_cls = _ADAPTER_REGISTRY.get(provider)
if adapter_cls is None:
raise ValueError(f"未知的模型 provider: {provider},请先注册适配器")
return adapter_cls(config)
def build_adapters(configs: List[dict]) -> List[BaseModelAdapter]:
"""批量创建适配器(仅 enabled 的)"""
adapters = []
for cfg in configs:
if not cfg.get('enabled', False):
continue
try:
adapter = build_adapter(cfg)
adapters.append(adapter)
logger.info(f"适配器已创建: {adapter.provider_name} ({cfg.get('model_name', '?')})")
except Exception as e:
logger.error(f"创建适配器失败 ({cfg.get('provider', '?')}): {e}")
return adapters
def register_adapter(provider_name: str, adapter_cls):
"""注册新适配器(供扩展使用)"""
_ADAPTER_REGISTRY[provider_name] = adapter_cls
logger.info(f"适配器已注册: {provider_name}")

View File

@@ -0,0 +1,42 @@
"""
模型适配器基类 - 所有模型适配器的抽象基类
新增模型只需继承此类并实现 4 个方法:
1. health_check() -> bool
2. analyze_frames(frame_paths, frame_timestamps, known_members_context) -> Optional[str]
3. get_timeout() -> int
4. get_circuit_breaker() -> CircuitBreaker
"""
from abc import ABC, abstractmethod
from typing import List, Optional
class BaseModelAdapter(ABC):
"""所有模型适配器的抽象基类"""
def __init__(self, provider_name: str, config: dict):
self.provider_name = provider_name # 如 "ollama", "gemini"
self.config = config
@abstractmethod
def health_check(self) -> bool:
"""健康检查,返回 True/False"""
pass
@abstractmethod
def analyze_frames(self, frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: str) -> Optional[str]:
"""视觉分析:输入帧图片路径 + 时间戳 + 成员清单,输出自然语言描述。
失败/超时返回 None。"""
pass
@abstractmethod
def get_timeout(self) -> int:
"""该模型的调用超时秒数"""
pass
@abstractmethod
def get_circuit_breaker(self):
"""返回该模型专属的熔断器实例"""
pass

View File

@@ -0,0 +1,47 @@
"""
熔断器 - 每个云端模型独立实例
状态机: CLOSED -> OPEN -> HALF_OPEN -> CLOSED/OPEN
- 连续 threshold 次失败 -> OPEN
- 冷却 cooldown 秒后 -> HALF_OPEN允许一次探测
- 探测成功 -> CLOSED探测失败 -> 重新 OPEN
"""
from collections import deque
import time
class CircuitBreaker:
def __init__(self, threshold: int = 5, cooldown: int = 900, enabled: bool = True):
self.enabled = enabled
if enabled:
self.failures = deque(maxlen=threshold)
else:
self.failures = None
self.threshold = threshold
self.cooldown = cooldown
self.state = 'CLOSED'
self.last_failure = None
def record_failure(self):
if not self.enabled:
return
self.failures.append(time.time())
if len(self.failures) >= self.threshold:
self.state = 'OPEN'
self.last_failure = time.time()
def record_success(self):
if not self.enabled:
return
self.failures.clear()
self.state = 'CLOSED'
def is_open(self):
if not self.enabled:
return False
if self.state == 'OPEN' and self.last_failure and time.time() - self.last_failure > self.cooldown:
self.state = 'HALF_OPEN'
return self.state == 'OPEN'
def __repr__(self):
return f"CircuitBreaker(state={self.state}, enabled={self.enabled})"

View File

@@ -0,0 +1,156 @@
"""
GeminiAdapter - Google Gemini 云端模型适配器
provider_name = "gemini"
模型: gemini-1.5-flash
健康检查: GET models API
熔断器: 启用,连续 5 次失败 -> OPEN 15 分钟
"""
import os
import base64
import requests
from typing import List, Optional
from .base_adapter import BaseModelAdapter
from .circuit_breaker import CircuitBreaker
from ..logger import setup_logger
logger = setup_logger('fam-edge.gemini_adapter')
class GeminiAdapter(BaseModelAdapter):
"""Gemini 云端 VLM 适配器"""
def __init__(self, config: dict):
super().__init__("gemini", config)
self.model_name = config.get('model_name', 'gemini-1.5-flash')
self.api_key = config.get('api_key', '')
self.timeout = config.get('timeout', 8)
cb_cfg = config.get('circuit_breaker', {})
self._cb = CircuitBreaker(
threshold=cb_cfg.get('threshold', 5),
cooldown=cb_cfg.get('cooldown', 900),
enabled=cb_cfg.get('enabled', True) # 云端默认启用
)
self._base_url = "https://generativelanguage.googleapis.com/v1beta"
def health_check(self) -> bool:
"""GET models API检查可用性"""
if not self.api_key:
logger.warning("Gemini API Key 未配置,健康检查失败")
return False
try:
resp = requests.get(
f"{self._base_url}/models?key={self.api_key}",
timeout=10
)
if resp.status_code == 200:
models = resp.json().get('models', [])
model_names = [m.get('name', '') for m in models]
has_model = any(self.model_name in name for name in model_names)
if has_model:
logger.info(f"Gemini 健康检查通过: 模型 {self.model_name} 可用")
return True
else:
logger.warning(f"Gemini 健康检查失败: 模型 {self.model_name} 未找到")
return False
return False
except Exception as e:
logger.error(f"Gemini 健康检查异常: {e}")
return False
def analyze_frames(self, frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: str) -> Optional[str]:
"""调用 Gemini 视觉分析"""
if self._cb.is_open():
logger.warning("Gemini 熔断器 OPEN跳过调用")
return None
if not self.api_key:
logger.warning("Gemini API Key 未配置,跳过调用")
return None
# 构建 Prompt
n = len(frame_paths)
prompt = self._build_visual_prompt(n, frame_timestamps, known_members_context)
# 构建 inline_data
parts = [{"text": prompt}]
for path in frame_paths:
try:
with open(path, 'rb') as f:
img_data = base64.b64encode(f.read()).decode('utf-8')
parts.append({
"inline_data": {
"mime_type": "image/jpeg",
"data": img_data
}
})
except Exception as e:
logger.error(f"读取图片失败 {path}: {e}")
try:
resp = requests.post(
f"{self._base_url}/models/{self.model_name}:generateContent?key={self.api_key}",
json={
"contents": [{"parts": parts}],
"generationConfig": {"temperature": 0.2, "topP": 0.8}
},
timeout=self.timeout
)
if resp.status_code == 200:
data = resp.json()
candidates = data.get('candidates', [])
if candidates:
output = candidates[0].get('content', {}).get('parts', [{}])[0].get('text', '')
self._cb.record_success()
logger.info(f"Gemini 视觉分析完成,输出长度={len(output)}")
return output
else:
logger.warning("Gemini 返回空 candidates")
self._cb.record_failure()
return None
else:
logger.error(f"Gemini 调用失败: {resp.status_code} {resp.text[:200]}")
self._cb.record_failure()
return None
except requests.Timeout:
logger.warning(f"Gemini 调用超时 ({self.timeout}s),降级跳过")
self._cb.record_failure()
return None
except Exception as e:
logger.error(f"Gemini 调用异常: {e}")
self._cb.record_failure()
return None
def get_timeout(self) -> int:
return self.timeout
def get_circuit_breaker(self) -> CircuitBreaker:
return self._cb
def _build_visual_prompt(self, n: int, timestamps: List[str], known_members: str) -> str:
ts_lines = '\n'.join(
f"[Image {i+1}] Time: {ts}" for i, ts in enumerate(timestamps)
)
return f"""You are a home surveillance video analysis assistant. Describe what you see in the following {n} images chronologically. Be objective.
Timestamps:
{ts_lines}
For each image, report:
1. People: count, clothing (color + type), visible actions
2. Objects: toys, bottles, furniture, etc.
3. Interactions: between people or people and objects
Known family members (match by features, use real_name if matched, otherwise "PersonX"):
{known_members or 'None'}
Output format (plain text, one paragraph per image, keep timestamp markers):
[Image 1] Time: {timestamps[0] if timestamps else ''}
Description: ...
Be concise and objective. Do not output JSON or markdown."""

View File

@@ -0,0 +1,139 @@
"""
OllamaAdapter - 本地 VLM 模型适配器
provider_name = "ollama"
模型: llava-phi3
健康检查: GET /api/tags
"""
import base64
import requests
from typing import List, Optional
from .base_adapter import BaseModelAdapter
from .circuit_breaker import CircuitBreaker
from ..logger import setup_logger
logger = setup_logger('fam-edge.ollama_adapter')
class OllamaAdapter(BaseModelAdapter):
"""Ollama 本地 VLM 适配器"""
def __init__(self, config: dict):
super().__init__("ollama", config)
self.base_url = config.get('base_url', 'http://localhost:11434')
self.model_name = config.get('model_name', 'llava-phi3')
self.timeout = config.get('timeout', 240)
cb_cfg = config.get('circuit_breaker', {})
self._cb = CircuitBreaker(
threshold=cb_cfg.get('threshold', 5),
cooldown=cb_cfg.get('cooldown', 900),
enabled=cb_cfg.get('enabled', False) # 本地模型默认不启用
)
def health_check(self) -> bool:
"""GET /api/tags检查模型是否可用"""
try:
resp = requests.get(f"{self.base_url}/api/tags", timeout=10)
if resp.status_code == 200:
models = resp.json().get('models', [])
model_names = [m.get('name', '') for m in models]
# 兼容 llava-phi3:latest 等后缀
has_model = any(self.model_name in name for name in model_names)
if has_model:
logger.info(f"Ollama 健康检查通过: 模型 {self.model_name} 可用")
return True
else:
logger.warning(f"Ollama 健康检查失败: 模型 {self.model_name} 未找到,可用模型: {model_names}")
return False
return False
except Exception as e:
logger.error(f"Ollama 健康检查异常: {e}")
return False
def analyze_frames(self, frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: str) -> Optional[str]:
"""调用 Ollama 视觉分析"""
if self._cb.is_open():
logger.warning("Ollama 熔断器 OPEN跳过调用")
return None
# 构建 Prompt
n = len(frame_paths)
prompt = self._build_visual_prompt(n, frame_timestamps, known_members_context)
# 读取图片并 Base64 编码
images = []
for path in frame_paths:
try:
with open(path, 'rb') as f:
images.append(base64.b64encode(f.read()).decode('utf-8'))
except Exception as e:
logger.error(f"读取图片失败 {path}: {e}")
if not images:
logger.error("没有可用的图片帧")
return None
try:
resp = requests.post(
f"{self.base_url}/api/generate",
json={
"model": self.model_name,
"prompt": prompt,
"images": images,
"stream": False,
"options": {"temperature": 0.2, "top_p": 0.8}
},
timeout=self.timeout
)
if resp.status_code == 200:
output = resp.json().get('response', '')
self._cb.record_success()
logger.info(f"Ollama 视觉分析完成,输出长度={len(output)}")
return output
else:
logger.error(f"Ollama 调用失败: {resp.status_code} {resp.text[:200]}")
self._cb.record_failure()
return None
except requests.Timeout:
logger.error(f"Ollama 调用超时 ({self.timeout}s)")
self._cb.record_failure()
return None
except Exception as e:
logger.error(f"Ollama 调用异常: {e}")
self._cb.record_failure()
return None
def get_timeout(self) -> int:
return self.timeout
def get_circuit_breaker(self) -> CircuitBreaker:
return self._cb
def _build_visual_prompt(self, n: int, timestamps: List[str], known_members: str) -> str:
"""构建视觉分析 Prompt"""
ts_lines = '\n'.join(
f"[图片{i+1}] 时间: {ts}" for i, ts in enumerate(timestamps)
)
return f"""你是家庭监控视频分析助手。请按时间顺序描述下列 {n} 张图片中可见的内容,只描述客观画面,不要猜测或推测。
每张图片对应的时间戳如下:
{ts_lines}
每张图片需报告:
1. 人物:数量、衣着(颜色+类型)、可见动作
2. 物品:玩具、奶瓶、家具等显眼物品
3. 互动:人与人、人与物品之间的互动
已知家庭成员清单(按特征匹配,匹配成功用 real_name未匹配用"人物X"标识):
{known_members or '(暂无已知成员)'}
输出格式(纯文本,每张图片一段,保留时间戳标记):
[图片1] 时间: {timestamps[0] if timestamps else ''}
内容: ...
要求简洁、客观。不要输出 JSON不要输出 markdown。"""

View File

@@ -0,0 +1,255 @@
"""
Video-Preprocessor - 视频预处理
流程:
1. 下载视频(超时 60s
2. FFmpeg 等距粗抽 30 张候选帧
3. OpenCV 帧差分析筛选 5-8 张关键帧MSE 阈值)
4. 压缩(长边 ≤ 1024pxJPEG 质量 80
异常兜底:
- ffprobe 失败 -> 退化为按 60s 间隔抽帧
- 帧差分析异常 -> 退化为等距抽 5 帧
- OpenCV 压缩失败 -> 跳过该帧,记录 WARN
"""
import os
import time
import subprocess
import requests
import cv2
import numpy as np
from typing import List, Tuple, Optional
from ..logger import setup_logger, log_task
from ..config_loader import load_config
logger = setup_logger('fam-edge.preprocessor')
class VideoPreprocessor:
"""视频预处理器"""
def __init__(self, task_id: int):
self.task_id = task_id
cfg = load_config()
video_cfg = cfg.get('video', {})
self.candidate_frames = video_cfg.get('candidate_frames', 30)
self.min_key_frames = video_cfg.get('min_key_frames', 5)
self.max_key_frames = video_cfg.get('max_key_frames', 8)
self.mse_threshold = video_cfg.get('mse_threshold', 500)
self.jpeg_quality = video_cfg.get('jpeg_quality', 80)
self.max_long_edge = video_cfg.get('max_long_edge', 1024)
timeout_cfg = cfg.get('timeout', {})
self.download_timeout = timeout_cfg.get('download', 60)
# 临时目录
self.work_dir = f"/tmp/fam_media/task_{task_id}"
self.video_path = os.path.join(self.work_dir, f"video_{task_id}.mp4")
self.frames_dir = os.path.join(self.work_dir, "frames")
self.keyframes_dir = os.path.join(self.work_dir, "keyframes")
def download_video(self, video_url: str) -> str:
"""下载视频"""
os.makedirs(self.work_dir, exist_ok=True)
start = time.time()
log_task(logger, self.task_id, 'download', f'开始下载: {video_url}')
resp = requests.get(video_url, stream=True, timeout=self.download_timeout)
if resp.status_code != 200:
raise Exception(f"下载失败: HTTP {resp.status_code}")
with open(self.video_path, 'wb') as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
duration_ms = int((time.time() - start) * 1000)
size_mb = os.path.getsize(self.video_path) / (1024 * 1024)
log_task(logger, self.task_id, 'download', f'下载完成: {size_mb:.1f}MB', duration_ms=duration_ms)
return self.video_path
def _get_video_duration(self, video_path: str) -> float:
"""用 ffprobe 获取视频时长(秒)"""
try:
cmd = [
'ffprobe', '-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
video_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
return float(result.stdout.strip())
except Exception as e:
logger.warning(f"[task_id={self.task_id}] ffprobe 失败: {e}")
return 0.0
def extract_candidate_frames(self, video_path: str) -> List[str]:
"""等距粗抽候选帧"""
os.makedirs(self.frames_dir, exist_ok=True)
duration = self._get_video_duration(video_path)
if duration > 0:
interval = duration / self.candidate_frames
else:
# 兜底: 每 60s 抽一帧
interval = 60
logger.warning(f"[task_id={self.task_id}] ffprobe 失败,退化为 60s 间隔抽帧")
cmd = [
'ffmpeg', '-i', video_path,
'-vf', f'fps=1/{interval}',
'-q:v', '2',
os.path.join(self.frames_dir, 'frame_%04d.jpg')
]
try:
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
except subprocess.CalledProcessError as e:
logger.error(f"[task_id={self.task_id}] FFmpeg 抽帧失败: {e}")
raise
# 收集候选帧路径
frames = sorted([
os.path.join(self.frames_dir, f)
for f in os.listdir(self.frames_dir)
if f.endswith('.jpg')
])
log_task(logger, self.task_id, 'extract', f'粗抽 {len(frames)} 张候选帧')
return frames
def select_key_frames(self, candidate_frames: List[str]) -> List[str]:
"""帧差分析筛选关键帧"""
if len(candidate_frames) <= self.min_key_frames:
return candidate_frames[:self.max_key_frames]
try:
# 加载所有候选帧
images = []
for path in candidate_frames:
img = cv2.imread(path)
if img is not None:
images.append((path, img))
if len(images) < 2:
return candidate_frames[:self.max_key_frames]
# 计算每帧与前一关键帧的 MSE
key_indices = [0] # 首帧必选
last_key_img = images[0][1]
for i in range(1, len(images)):
mse = self._compute_mse(last_key_img, images[i][1])
if mse > self.mse_threshold:
key_indices.append(i)
last_key_img = images[i][1]
# 末帧必选
if key_indices[-1] != len(images) - 1:
key_indices.append(len(images) - 1)
# 若 < min_key_frames从剩余中均匀补足
if len(key_indices) < self.min_key_frames:
remaining = [i for i in range(len(images)) if i not in key_indices]
step = max(1, len(remaining) // (self.min_key_frames - len(key_indices)))
for i in range(0, len(remaining), step):
if len(key_indices) >= self.min_key_frames:
break
key_indices.append(remaining[i])
key_indices.sort()
# 若 > max_key_frames按差异值降序取前 N
if len(key_indices) > self.max_key_frames:
# 计算每个关键帧与前一帧的差异
diffs = []
for idx in key_indices[1:-1]: # 不含首末帧
diff = self._compute_mse(images[idx-1][1], images[idx][1])
diffs.append((idx, diff))
diffs.sort(key=lambda x: x[1], reverse=True)
# 保留首末帧 + 差异最大的
keep = {0, len(images)-1}
for idx, _ in diffs[:self.max_key_frames - 2]:
keep.add(idx)
key_indices = sorted(keep)
key_frames = [images[i][0] for i in key_indices]
log_task(logger, self.task_id, 'select_keyframes', f'筛选 {len(key_frames)} 张关键帧')
return key_frames
except Exception as e:
logger.warning(f"[task_id={self.task_id}] 帧差分析异常: {e},退化为等距抽 5 帧")
step = max(1, len(candidate_frames) // self.min_key_frames)
return candidate_frames[::step][:self.min_key_frames]
def _compute_mse(self, img1, img2) -> float:
"""计算两帧的 MSE"""
# 转灰度并统一尺寸
h = min(img1.shape[0], img2.shape[0])
w = min(img1.shape[1], img2.shape[1])
g1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
g2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
g1 = cv2.resize(g1, (w, h))
g2 = cv2.resize(g2, (w, h))
diff = g1.astype(np.float64) - g2.astype(np.float64)
mse = np.mean(diff ** 2)
return float(mse)
def compress_frames(self, frame_paths: List[str]) -> List[str]:
"""压缩关键帧(长边 ≤ max_long_edgeJPEG 质量 80"""
os.makedirs(self.keyframes_dir, exist_ok=True)
compressed = []
for i, path in enumerate(frame_paths):
out_path = os.path.join(self.keyframes_dir, f"keyframe_{i+1:02d}.jpg")
try:
img = cv2.imread(path)
if img is None:
logger.warning(f"[task_id={self.task_id}] 读取图片失败: {path}")
continue
h, w = img.shape[:2]
if max(h, w) > self.max_long_edge:
scale = self.max_long_edge / max(h, w)
img = cv2.resize(img, (int(w * scale), int(h * scale)))
cv2.imwrite(out_path, img, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
compressed.append(out_path)
except Exception as e:
logger.warning(f"[task_id={self.task_id}] 压缩失败 {path}: {e}")
continue
log_task(logger, self.task_id, 'compress', f'压缩 {len(compressed)} 张关键帧')
return compressed
def compute_timestamps(self, video_path: str, frame_count: int,
event_start_time: str) -> List[str]:
"""计算每帧的绝对时间戳 = 视频开始时间 + 帧偏移"""
from datetime import datetime, timedelta
duration = self._get_video_duration(video_path)
if duration <= 0:
duration = frame_count * 60 # 兜底
interval = duration / frame_count
try:
start_dt = datetime.fromisoformat(event_start_time.replace('Z', '+00:00'))
except Exception:
start_dt = datetime.now()
timestamps = []
for i in range(frame_count):
offset = interval * i
ts = start_dt + timedelta(seconds=offset)
timestamps.append(ts.strftime('%Y-%m-%d %H:%M:%S'))
return timestamps
def cleanup(self):
"""清理临时文件"""
import shutil
try:
if os.path.exists(self.work_dir):
shutil.rmtree(self.work_dir)
log_task(logger, self.task_id, 'cleanup', f'清理临时目录: {self.work_dir}')
except Exception as e:
logger.warning(f"[task_id={self.task_id}] 清理失败: {e}")