feat(service): 升级AI分析服务支持图像分析和视频详细输出
- 移除Ollama相关配置和回退机制,统一使用SGLang作为视觉模型提供商 - 添加IMAGE_ANALYSIS类型支持,允许对多张图片进行分析 - 实现视频分析的详细输出模式,支持紧凑和详细两种结果格式 - 更新环境变量配置,添加VIDEO_MODEL_FRAME_LIMIT和MAX_IMAGE_BYTES - 修改compose配置文件中的上下文长度和内存分配参数 - 重构视频采样逻辑,限制单次请求帧数以优化显存使用 - 更新API接口文档,添加mediaUrls参数和详细输出选项说明 - 添加图像分析相关的依赖库opencv-python-headless - 实现结构化JSON响应格式验证和重试机制
This commit is contained in:
parent
b386f003a0
commit
873a82b0f5
22
README.md
22
README.md
@ -55,7 +55,7 @@ Audio profiles can reduce single-sample false positives with decision aggregatio
|
||||
|
||||
## API
|
||||
|
||||
`POST /api/v1/analysis/run` accepts `requestId`, `analysisType`, `profileCode`, `mediaUrl`, `options`, and `context`.
|
||||
`POST /api/v1/analysis/run` accepts `requestId`, `analysisType`, `profileCode`, `mediaUrl`, `mediaUrls`, `options`, and `context`.
|
||||
|
||||
The deployed endpoint is `http://192.168.28.10:14080`. It is called by the platform backend and requires a bearer token.
|
||||
|
||||
@ -69,6 +69,21 @@ Video requests may set `options.analysisMode` to one of:
|
||||
|
||||
Existing workflows without this option are treated as `AUTO`.
|
||||
|
||||
Video requests may also set `options.detailedOutput`. It defaults to `false` and
|
||||
returns a compact verdict without an event timeline. Set it to `true` only when
|
||||
time-ranged event evidence and warnings are required.
|
||||
|
||||
Long videos are sampled uniformly across their full duration. At most 30 frames are
|
||||
sent in one model request so visual encoding stays within the H100 dynamic-memory
|
||||
budget; the first and final state remain represented.
|
||||
|
||||
Image requests use `analysisType=IMAGE_ANALYSIS` and profile
|
||||
`common.image_analysis.v1`. Supply one to twelve scene image URLs in `mediaUrls`.
|
||||
Set `options.prompt` to the complete analysis instruction. The model follows the
|
||||
requested content and format without adding pass/fail fields. The response result
|
||||
contains `result` (parsed JSON, array, number, or text) and `resultText` (the exact
|
||||
model text). An optional `options.referenceImageUrl` is appended as the final image.
|
||||
|
||||
## Qwen3.8 video models
|
||||
|
||||
The model server runs the video models as separate SGLang services:
|
||||
@ -86,6 +101,5 @@ docker compose -f deploy/compose.sglang.yaml up -d
|
||||
docker compose -f deploy/compose.sglang.yaml ps
|
||||
```
|
||||
|
||||
The analysis service calls their OpenAI-compatible APIs. When either SGLang endpoint
|
||||
is unavailable, it falls back to the existing Ollama model for the corresponding
|
||||
mode, provided `VISION_OLLAMA_FALLBACK_ENABLED=true`.
|
||||
The analysis service calls only their OpenAI-compatible APIs. An unavailable SGLang
|
||||
endpoint returns an explicit error; there is no Ollama fallback.
|
||||
|
||||
@ -10,19 +10,17 @@ class Settings(BaseSettings):
|
||||
profiles_dir: Path = Path("/data/profiles")
|
||||
artifacts_dir: Path = Path("/data/artifacts")
|
||||
jobs_dir: Path = Path("/data/jobs")
|
||||
ollama_base_url: str = "http://host.docker.internal:11434"
|
||||
ollama_model: str = "qwen3-vl:32b"
|
||||
media_timeout_seconds: int = 120
|
||||
ollama_timeout_seconds: int = 600
|
||||
sglang_fast_base_url: str = ""
|
||||
sglang_accurate_base_url: str = ""
|
||||
sglang_api_key: str = ""
|
||||
sglang_timeout_seconds: int = 600
|
||||
vision_ollama_fallback_enabled: bool = True
|
||||
max_audio_bytes: int = 100 * 1024 * 1024
|
||||
max_image_bytes: int = 30 * 1024 * 1024
|
||||
max_video_bytes: int = 2 * 1024 * 1024 * 1024
|
||||
video_sampling_frame_limit: int = 100
|
||||
max_video_context_tokens: int = 262144
|
||||
video_model_frame_limit: int = 30
|
||||
max_video_context_tokens: int = 65536
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
70
app/main.py
70
app/main.py
@ -8,6 +8,7 @@ from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
|
||||
from app.audio import classify_audio
|
||||
from app.config import settings
|
||||
from app.image import analyze_images
|
||||
from app.media import download_media
|
||||
from app.profile_store import profile_store
|
||||
from app.schemas import AnalysisRequest, AnalysisResponse, AnalysisType
|
||||
@ -47,23 +48,19 @@ async def health() -> dict:
|
||||
"fast": settings.sglang_fast_base_url,
|
||||
"accurate": settings.sglang_accurate_base_url,
|
||||
}
|
||||
checks = [probe(f"{settings.ollama_base_url.rstrip('/')}/api/version")]
|
||||
configured_names = [name for name, url in sglang_urls.items() if url]
|
||||
checks.extend(
|
||||
checks = [
|
||||
probe(f"{sglang_urls[name].rstrip('/')}/models") for name in configured_names
|
||||
)
|
||||
]
|
||||
statuses = await asyncio.gather(*checks)
|
||||
ollama = statuses[0]
|
||||
configured_statuses = dict(zip(configured_names, statuses[1:]))
|
||||
configured_statuses = dict(zip(configured_names, statuses))
|
||||
sglang = {
|
||||
name: configured_statuses.get(name, "NOT_CONFIGURED")
|
||||
for name in sglang_urls
|
||||
}
|
||||
return {
|
||||
"status": "UP",
|
||||
"ollama": ollama,
|
||||
"sglang": sglang,
|
||||
"visionModel": settings.ollama_model,
|
||||
"profiles": profile_store.status(),
|
||||
}
|
||||
|
||||
@ -76,6 +73,7 @@ async def health() -> dict:
|
||||
async def run_analysis(request: AnalysisRequest) -> AnalysisResponse:
|
||||
started = time.monotonic()
|
||||
media_path = None
|
||||
media_paths = []
|
||||
try:
|
||||
profile = profile_store.get(request.profileCode, request.analysisType.value)
|
||||
if request.analysisType == AnalysisType.AUDIO_CLASSIFICATION:
|
||||
@ -84,10 +82,16 @@ async def run_analysis(request: AnalysisRequest) -> AnalysisResponse:
|
||||
)
|
||||
result = classify_audio(profile, media_path)
|
||||
model = {"provider": "CMVR", "name": "mfcc-dtw-audio-fingerprint-v2"}
|
||||
else:
|
||||
elif request.analysisType == AnalysisType.VIDEO_ANALYSIS:
|
||||
media_path = await download_media(
|
||||
str(request.mediaUrl), ".video", settings.max_video_bytes
|
||||
)
|
||||
detailed_option = request.options.get("detailedOutput", False)
|
||||
detailed_output = (
|
||||
detailed_option
|
||||
if isinstance(detailed_option, bool)
|
||||
else str(detailed_option).strip().lower() == "true"
|
||||
)
|
||||
result, duration, video_metadata = await analyze_video(
|
||||
profile,
|
||||
media_path,
|
||||
@ -95,6 +99,7 @@ async def run_analysis(request: AnalysisRequest) -> AnalysisResponse:
|
||||
str(request.options.get("analysisMode", "AUTO")),
|
||||
request.options.get("tuning"),
|
||||
str(request.options.get("decisionPolicy", "FAIL_CLOSED")),
|
||||
detailed_output,
|
||||
)
|
||||
evidence = result.get("evidence")
|
||||
if not isinstance(evidence, dict):
|
||||
@ -105,6 +110,7 @@ async def run_analysis(request: AnalysisRequest) -> AnalysisResponse:
|
||||
"sampledFrameCount": video_metadata["sampledFrameCount"],
|
||||
"sampleFps": video_metadata["effectiveSampleFps"],
|
||||
"samplingFrameLimit": video_metadata["samplingFrameLimit"],
|
||||
"configuredSamplingFrameLimit": video_metadata["configuredSamplingFrameLimit"],
|
||||
"samplingCapped": video_metadata["samplingCapped"],
|
||||
"numCtx": video_metadata["numCtx"],
|
||||
"maximumWidth": video_metadata["maximumWidth"],
|
||||
@ -115,6 +121,7 @@ async def run_analysis(request: AnalysisRequest) -> AnalysisResponse:
|
||||
result["fallback"] = video_metadata["fallback"]
|
||||
result["fallbackReason"] = video_metadata["fallbackReason"]
|
||||
result["effectiveTuning"] = video_metadata["tuning"]
|
||||
result["detailedOutput"] = video_metadata["detailedOutput"]
|
||||
model = {
|
||||
"provider": video_metadata["provider"],
|
||||
"name": video_metadata["model"],
|
||||
@ -125,6 +132,51 @@ async def run_analysis(request: AnalysisRequest) -> AnalysisResponse:
|
||||
"usedMode": video_metadata["mode"],
|
||||
"fallback": video_metadata["fallback"],
|
||||
}
|
||||
else:
|
||||
image_urls = [str(url) for url in request.mediaUrls]
|
||||
if not image_urls and request.mediaUrl is not None:
|
||||
image_urls = [str(request.mediaUrl)]
|
||||
for image_url in image_urls:
|
||||
media_paths.append(await download_media(
|
||||
image_url, ".image", settings.max_image_bytes
|
||||
))
|
||||
reference_path = None
|
||||
reference_url = str(request.options.get("referenceImageUrl", "")).strip()
|
||||
if reference_url:
|
||||
reference_path = await download_media(
|
||||
reference_url, ".reference-image", settings.max_image_bytes
|
||||
)
|
||||
media_paths.append(reference_path)
|
||||
prompt = str(request.options.get("prompt", "")).strip()
|
||||
if not prompt:
|
||||
target = str(request.options.get("targetDescription", "")).strip()
|
||||
instruction = str(request.options.get("instruction", "")).strip()
|
||||
prompt = "\n".join(part for part in (target, instruction) if part)
|
||||
if prompt:
|
||||
prompt += "\n只返回分析结果,不输出通过、未通过或置信度。"
|
||||
result, image_metadata = await analyze_images(
|
||||
profile,
|
||||
media_paths[:-1] if reference_path is not None else media_paths,
|
||||
reference_path,
|
||||
prompt,
|
||||
str(request.options.get("analysisMode", "AUTO")),
|
||||
request.options.get("tuning"),
|
||||
str(request.options.get("analysisMethod", "AUTO")),
|
||||
)
|
||||
result["analysisMode"] = image_metadata["mode"]
|
||||
result["fallback"] = image_metadata["fallback"]
|
||||
result["imageCount"] = image_metadata["imageCount"]
|
||||
result["analysisMethod"] = image_metadata["analysisMethod"]
|
||||
model = {
|
||||
"provider": image_metadata["provider"],
|
||||
"name": image_metadata["model"],
|
||||
"primaryProvider": image_metadata["provider"],
|
||||
"primaryModel": image_metadata["model"],
|
||||
"providerFallback": False,
|
||||
"requestedMode": image_metadata["requestedMode"],
|
||||
"usedMode": image_metadata["mode"],
|
||||
"fallback": image_metadata["fallback"],
|
||||
}
|
||||
return AnalysisResponse(
|
||||
requestId=request.requestId,
|
||||
analysisType=request.analysisType,
|
||||
@ -143,3 +195,5 @@ async def run_analysis(request: AnalysisRequest) -> AnalysisResponse:
|
||||
finally:
|
||||
if media_path is not None:
|
||||
media_path.unlink(missing_ok=True)
|
||||
for path in media_paths:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
@ -1,22 +1,30 @@
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from pydantic import BaseModel, Field, HttpUrl, model_validator
|
||||
|
||||
|
||||
class AnalysisType(str, Enum):
|
||||
AUDIO_CLASSIFICATION = "AUDIO_CLASSIFICATION"
|
||||
VIDEO_ANALYSIS = "VIDEO_ANALYSIS"
|
||||
IMAGE_ANALYSIS = "IMAGE_ANALYSIS"
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
requestId: str = Field(min_length=1, max_length=200)
|
||||
analysisType: AnalysisType
|
||||
profileCode: str = Field(min_length=1, max_length=200)
|
||||
mediaUrl: HttpUrl
|
||||
mediaUrl: HttpUrl | None = None
|
||||
mediaUrls: list[HttpUrl] = Field(default_factory=list, max_length=12)
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_media(self):
|
||||
if self.mediaUrl is None and not self.mediaUrls:
|
||||
raise ValueError("mediaUrl or mediaUrls is required")
|
||||
return self
|
||||
|
||||
|
||||
class AnalysisResponse(BaseModel):
|
||||
requestId: str
|
||||
|
||||
241
app/video.py
241
app/video.py
@ -58,6 +58,20 @@ VIDEO_RESULT_SCHEMA = {
|
||||
],
|
||||
}
|
||||
|
||||
VIDEO_COMPACT_RESULT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"passed": VIDEO_RESULT_SCHEMA["properties"]["passed"],
|
||||
"evidenceSufficient": {"type": "boolean"},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"conclusion": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
},
|
||||
"required": [
|
||||
"passed", "evidenceSufficient", "confidence", "conclusion", "summary",
|
||||
],
|
||||
}
|
||||
|
||||
VIDEO_TUNING_LIMITS = {
|
||||
"sampleFps": (0.25, 6.0),
|
||||
"maxWidth": (640, 1280),
|
||||
@ -103,6 +117,13 @@ def _frame_sampling_plan(
|
||||
return count, prefix_count, prefix_fps, tail_time, capped
|
||||
|
||||
|
||||
def _model_frame_limit() -> int:
|
||||
return max(
|
||||
2,
|
||||
min(settings.video_sampling_frame_limit, settings.video_model_frame_limit),
|
||||
)
|
||||
|
||||
|
||||
def _extract_frames(
|
||||
path: Path, sample_fps: float, maximum_frames: int, maximum_width: int
|
||||
) -> tuple[list[Path], float, Path]:
|
||||
@ -229,19 +250,19 @@ def _parse_json(content: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _fallback_reason(
|
||||
result: dict[str, Any], confidence_threshold: float
|
||||
result: dict[str, Any], confidence_threshold: float, detailed_output: bool = True
|
||||
) -> str | None:
|
||||
if result.get("structured") is False:
|
||||
return "FAST_RESULT_NOT_STRUCTURED"
|
||||
required = (
|
||||
required = [
|
||||
"passed",
|
||||
"evidenceSufficient",
|
||||
"confidence",
|
||||
"conclusion",
|
||||
"summary",
|
||||
"events",
|
||||
"warnings",
|
||||
)
|
||||
]
|
||||
if detailed_output:
|
||||
required.extend(("events", "warnings"))
|
||||
missing = [field for field in required if field not in result]
|
||||
if missing:
|
||||
return "FAST_RESULT_MISSING_FIELDS:" + ",".join(missing)
|
||||
@ -249,7 +270,10 @@ def _fallback_reason(
|
||||
return "FAST_RESULT_EMPTY_CONCLUSION"
|
||||
if not isinstance(result.get("summary"), str) or not result["summary"].strip():
|
||||
return "FAST_RESULT_EMPTY_SUMMARY"
|
||||
if not isinstance(result.get("events"), list) or not isinstance(result.get("warnings"), list):
|
||||
if detailed_output and (
|
||||
not isinstance(result.get("events"), list)
|
||||
or not isinstance(result.get("warnings"), list)
|
||||
):
|
||||
return "FAST_RESULT_INVALID_COLLECTIONS"
|
||||
if result.get("evidenceSufficient") is not True:
|
||||
return "FAST_RESULT_INSUFFICIENT"
|
||||
@ -475,6 +499,7 @@ def _normalize_decision(
|
||||
normalized["confidenceThreshold"] = confidence_threshold
|
||||
normalized.setdefault("conclusion", "通过" if passed else "未通过")
|
||||
normalized.setdefault("summary", normalized["conclusion"])
|
||||
normalized["result"] = normalized["summary"]
|
||||
normalized.pop("structured", None)
|
||||
return normalized
|
||||
|
||||
@ -482,7 +507,8 @@ def _normalize_decision(
|
||||
def _strategy(profile: Profile, name: str) -> dict[str, Any]:
|
||||
defaults = {
|
||||
"FAST": {
|
||||
"model": profile.config.get("model", settings.ollama_model),
|
||||
"model": profile.config.get("model", "Qwen/Qwen3.8-27B-FP8"),
|
||||
"provider": "sglang",
|
||||
"sampleFps": 1.0,
|
||||
"maxWidth": 896,
|
||||
"maxOutputTokens": 512,
|
||||
@ -491,7 +517,8 @@ def _strategy(profile: Profile, name: str) -> dict[str, Any]:
|
||||
"maxRetryOutputTokens": 4096,
|
||||
},
|
||||
"ACCURATE": {
|
||||
"model": profile.config.get("model", settings.ollama_model),
|
||||
"model": profile.config.get("model", "Qwen/Qwen3.8-27B"),
|
||||
"provider": "sglang",
|
||||
"sampleFps": 3.0,
|
||||
"maxWidth": 1280,
|
||||
"maxOutputTokens": 768,
|
||||
@ -502,13 +529,12 @@ def _strategy(profile: Profile, name: str) -> dict[str, Any]:
|
||||
}
|
||||
configured = profile.config.get("strategies", {}).get(name.lower(), {})
|
||||
strategy = {**defaults[name], **configured}
|
||||
strategy["provider"] = str(strategy.get("provider", "ollama")).strip().lower()
|
||||
if strategy["provider"] == "sglang":
|
||||
strategy["baseUrl"] = (
|
||||
settings.sglang_fast_base_url
|
||||
if name == "FAST"
|
||||
else settings.sglang_accurate_base_url
|
||||
)
|
||||
strategy["provider"] = str(strategy.get("provider", "sglang")).strip().lower()
|
||||
strategy["baseUrl"] = (
|
||||
settings.sglang_fast_base_url
|
||||
if name == "FAST"
|
||||
else settings.sglang_accurate_base_url
|
||||
)
|
||||
return strategy
|
||||
|
||||
|
||||
@ -525,31 +551,31 @@ def _sglang_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
for image in message.get("images") or []
|
||||
)
|
||||
options = payload.get("options") or {}
|
||||
return {
|
||||
result = {
|
||||
"model": payload["model"],
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"stream": False,
|
||||
"temperature": float(options.get("temperature", 0.1)),
|
||||
"max_tokens": int(options.get("num_predict", 512)),
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "video_analysis",
|
||||
"strict": True,
|
||||
"schema": VIDEO_RESULT_SCHEMA,
|
||||
},
|
||||
},
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": bool(payload.get("think", False)),
|
||||
"preserve_thinking": False,
|
||||
},
|
||||
}
|
||||
if payload.get("format"):
|
||||
result["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "media_analysis",
|
||||
"strict": True,
|
||||
"schema": payload["format"],
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _request_image_count(payload: dict[str, Any], provider: str) -> int:
|
||||
message = payload.get("messages", [{}])[0]
|
||||
if provider == "ollama":
|
||||
return len(message.get("images") or [])
|
||||
content = message.get("content") or []
|
||||
return sum(
|
||||
1 for item in content
|
||||
@ -560,22 +586,17 @@ def _request_image_count(payload: dict[str, Any], provider: str) -> int:
|
||||
async def _request_vision_model(
|
||||
payload: dict[str, Any], empty_response_retries: int = 1,
|
||||
max_retry_output_tokens: int = 8192,
|
||||
provider: str = "ollama",
|
||||
provider: str = "sglang",
|
||||
base_url: str | None = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
provider = str(provider or "ollama").strip().lower()
|
||||
if provider not in {"ollama", "sglang"}:
|
||||
raise ValueError(f"Unsupported vision model provider: {provider}")
|
||||
if provider == "sglang" and not str(base_url or "").strip():
|
||||
provider = str(provider or "sglang").strip().lower()
|
||||
if provider != "sglang":
|
||||
raise ValueError("Only the SGLang vision model provider is supported")
|
||||
if not str(base_url or "").strip():
|
||||
raise VisionModelError("SGLang vision model endpoint is not configured")
|
||||
|
||||
last_metadata: dict[str, Any] = {}
|
||||
timeout_seconds = (
|
||||
settings.sglang_timeout_seconds
|
||||
if provider == "sglang"
|
||||
else settings.ollama_timeout_seconds
|
||||
)
|
||||
timeout = httpx.Timeout(timeout_seconds, connect=5.0)
|
||||
timeout = httpx.Timeout(settings.sglang_timeout_seconds, connect=5.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
for attempt in range(empty_response_retries + 1):
|
||||
request_payload = copy.deepcopy(payload)
|
||||
@ -592,19 +613,13 @@ async def _request_vision_model(
|
||||
request_payload["think"] = False
|
||||
request_payload["messages"][0]["content"] += (
|
||||
"\n\n/no_think\nReturn only the final JSON now. Do not output reasoning."
|
||||
if request_payload.get("format")
|
||||
else "\n\n/no_think\nReturn only the final answer requested by the user."
|
||||
)
|
||||
provider_payload = (
|
||||
_sglang_payload(request_payload)
|
||||
if provider == "sglang"
|
||||
else request_payload
|
||||
)
|
||||
url = (
|
||||
f"{str(base_url).rstrip('/')}/chat/completions"
|
||||
if provider == "sglang"
|
||||
else f"{settings.ollama_base_url.rstrip('/')}/api/chat"
|
||||
)
|
||||
provider_payload = _sglang_payload(request_payload)
|
||||
url = f"{str(base_url).rstrip('/')}/chat/completions"
|
||||
headers = {}
|
||||
if provider == "sglang" and settings.sglang_api_key:
|
||||
if settings.sglang_api_key:
|
||||
headers["Authorization"] = f"Bearer {settings.sglang_api_key}"
|
||||
response = await client.post(url, json=provider_payload, headers=headers)
|
||||
try:
|
||||
@ -619,34 +634,29 @@ async def _request_vision_model(
|
||||
f"detail={detail})"
|
||||
) from exc
|
||||
body = response.json()
|
||||
if provider == "sglang":
|
||||
choices = body.get("choices") or []
|
||||
choice = choices[0] if choices else {}
|
||||
message = choice.get("message") or {}
|
||||
usage = body.get("usage") or {}
|
||||
content = str(message.get("content") or "").strip()
|
||||
metadata = {
|
||||
"doneReason": choice.get("finish_reason"),
|
||||
"promptEvalCount": usage.get("prompt_tokens"),
|
||||
"evalCount": usage.get("completion_tokens"),
|
||||
"totalDurationNs": None,
|
||||
"thinkingLength": len(str(message.get("reasoning_content") or "")),
|
||||
"attempt": attempt + 1,
|
||||
"requestedOutputTokens": provider_payload.get("max_tokens"),
|
||||
}
|
||||
else:
|
||||
message = body.get("message") or {}
|
||||
content = str(message.get("content") or "").strip()
|
||||
metadata = {
|
||||
"doneReason": body.get("done_reason"),
|
||||
"promptEvalCount": body.get("prompt_eval_count"),
|
||||
"evalCount": body.get("eval_count"),
|
||||
"totalDurationNs": body.get("total_duration"),
|
||||
"thinkingLength": len(str(message.get("thinking") or "")),
|
||||
"attempt": attempt + 1,
|
||||
"requestedOutputTokens": request_payload.get("options", {}).get("num_predict"),
|
||||
}
|
||||
choices = body.get("choices") or []
|
||||
choice = choices[0] if choices else {}
|
||||
message = choice.get("message") or {}
|
||||
usage = body.get("usage") or {}
|
||||
content = str(message.get("content") or "").strip()
|
||||
metadata = {
|
||||
"doneReason": choice.get("finish_reason"),
|
||||
"promptEvalCount": usage.get("prompt_tokens"),
|
||||
"evalCount": usage.get("completion_tokens"),
|
||||
"totalDurationNs": None,
|
||||
"thinkingLength": len(str(message.get("reasoning_content") or "")),
|
||||
"attempt": attempt + 1,
|
||||
"requestedOutputTokens": provider_payload.get("max_tokens"),
|
||||
}
|
||||
metadata["provider"] = provider
|
||||
truncated_structured_result = (
|
||||
bool(request_payload.get("format"))
|
||||
and str(metadata.get("doneReason") or "").lower() == "length"
|
||||
and _parse_json(content).get("structured") is False
|
||||
)
|
||||
if content and truncated_structured_result and attempt < empty_response_retries:
|
||||
last_metadata = metadata
|
||||
continue
|
||||
if content:
|
||||
return content, metadata
|
||||
last_metadata = metadata
|
||||
@ -664,12 +674,14 @@ async def _analyze_frames(
|
||||
extracted_width: int,
|
||||
prompt: str,
|
||||
tuning: dict[str, Any] | None = None,
|
||||
detailed_output: bool = False,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
strategy = _apply_tuning(_strategy(profile, strategy_name), tuning or {})
|
||||
model_frame_limit = _model_frame_limit()
|
||||
desired_count, _, _, _, sampling_capped = _frame_sampling_plan(
|
||||
duration,
|
||||
float(strategy["sampleFps"]),
|
||||
settings.video_sampling_frame_limit,
|
||||
model_frame_limit,
|
||||
)
|
||||
selected = _select_frames(frames, desired_count)
|
||||
maximum_width = max(320, int(strategy["maxWidth"]))
|
||||
@ -678,10 +690,22 @@ async def _analyze_frames(
|
||||
f"\n\nThe video duration is {duration:.3f} seconds. "
|
||||
f"The following {len(selected)} images are ordered frames sampled across the video."
|
||||
)
|
||||
if detailed_output:
|
||||
strategy_prompt += (
|
||||
"\n\nDetailed output is enabled. Include a concise event timeline and warnings."
|
||||
)
|
||||
else:
|
||||
strategy_prompt += (
|
||||
"\n\nCompact output is enabled. Return only passed, evidenceSufficient, "
|
||||
"confidence, conclusion, and a short summary. Do not return events, warnings, "
|
||||
"frame-by-frame descriptions, or repeated evidence. Keep conclusion under 20 "
|
||||
"Chinese characters and summary under 100 Chinese characters."
|
||||
)
|
||||
think = bool(strategy.get("think", profile.config.get("think", False)))
|
||||
if not think:
|
||||
strategy_prompt += "\n\n/no_think\nReturn only the requested JSON object without reasoning."
|
||||
output_tokens = int(strategy["maxOutputTokens"])
|
||||
token_key = "detailedMaxOutputTokens" if detailed_output else "compactMaxOutputTokens"
|
||||
output_tokens = int(strategy.get(token_key, strategy["maxOutputTokens"]))
|
||||
context_tokens = _video_context_size(
|
||||
int(strategy["numCtx"]), len(selected), output_tokens
|
||||
)
|
||||
@ -689,7 +713,7 @@ async def _analyze_frames(
|
||||
"model": strategy["model"],
|
||||
"stream": False,
|
||||
"think": think,
|
||||
"format": VIDEO_RESULT_SCHEMA,
|
||||
"format": VIDEO_RESULT_SCHEMA if detailed_output else VIDEO_COMPACT_RESULT_SCHEMA,
|
||||
"keep_alive": str(strategy.get("keepAlive", "30m")),
|
||||
"messages": [{"role": "user", "content": strategy_prompt, "images": images}],
|
||||
"options": {
|
||||
@ -698,47 +722,29 @@ async def _analyze_frames(
|
||||
"num_ctx": context_tokens,
|
||||
},
|
||||
}
|
||||
provider = str(strategy.get("provider", "ollama"))
|
||||
actual_model = str(strategy["model"])
|
||||
provider_fallback = False
|
||||
try:
|
||||
content, metrics = await _request_vision_model(
|
||||
payload,
|
||||
int(strategy.get("emptyResponseRetries", 0)),
|
||||
int(strategy.get("maxRetryOutputTokens", 8192)),
|
||||
provider,
|
||||
strategy.get("baseUrl"),
|
||||
)
|
||||
except (VisionModelError, httpx.HTTPError):
|
||||
fallback_model = str(strategy.get("fallbackModel") or "").strip()
|
||||
if (
|
||||
provider != "sglang"
|
||||
or not settings.vision_ollama_fallback_enabled
|
||||
or not fallback_model
|
||||
):
|
||||
raise
|
||||
fallback_payload = copy.deepcopy(payload)
|
||||
fallback_payload["model"] = fallback_model
|
||||
content, metrics = await _request_vision_model(
|
||||
fallback_payload,
|
||||
int(strategy.get("emptyResponseRetries", 0)),
|
||||
int(strategy.get("maxRetryOutputTokens", 8192)),
|
||||
)
|
||||
provider_fallback = True
|
||||
actual_model = fallback_model
|
||||
provider = str(strategy.get("provider", "sglang"))
|
||||
content, metrics = await _request_vision_model(
|
||||
payload,
|
||||
int(strategy.get("emptyResponseRetries", 0)),
|
||||
int(strategy.get("maxRetryOutputTokens", 8192)),
|
||||
provider,
|
||||
strategy.get("baseUrl"),
|
||||
)
|
||||
metadata = {
|
||||
"mode": strategy_name,
|
||||
"model": actual_model,
|
||||
"model": str(strategy["model"]),
|
||||
"provider": metrics.get("provider", provider),
|
||||
"primaryProvider": provider,
|
||||
"primaryModel": strategy["model"],
|
||||
"providerFallback": provider_fallback,
|
||||
"providerFallback": False,
|
||||
"sampledFrameCount": len(selected),
|
||||
"maximumWidth": maximum_width,
|
||||
"effectiveSampleFps": float(strategy["sampleFps"]),
|
||||
"samplingFrameLimit": settings.video_sampling_frame_limit,
|
||||
"effectiveSampleFps": round(max(0.0, (len(selected) - 1) / duration), 4),
|
||||
"samplingFrameLimit": model_frame_limit,
|
||||
"configuredSamplingFrameLimit": settings.video_sampling_frame_limit,
|
||||
"samplingCapped": sampling_capped,
|
||||
"numCtx": context_tokens,
|
||||
"detailedOutput": detailed_output,
|
||||
**metrics,
|
||||
}
|
||||
return _parse_json(content), metadata
|
||||
@ -751,6 +757,7 @@ async def analyze_video(
|
||||
analysis_mode: str = "AUTO",
|
||||
tuning: dict[str, Any] | None = None,
|
||||
decision_policy: str = "FAIL_CLOSED",
|
||||
detailed_output: bool = False,
|
||||
) -> tuple[dict[str, Any], float, dict[str, Any]]:
|
||||
requested_mode = str(analysis_mode or "AUTO").strip().upper()
|
||||
if requested_mode not in {"AUTO", "FAST", "ACCURATE"}:
|
||||
@ -767,10 +774,11 @@ async def analyze_video(
|
||||
extraction_strategy = fast if requested_mode == "FAST" else accurate
|
||||
extraction_sample_fps = float(extraction_strategy["sampleFps"])
|
||||
extraction_width = max(320, int(extraction_strategy["maxWidth"]))
|
||||
model_frame_limit = _model_frame_limit()
|
||||
frames, duration, frame_dir = _extract_frames(
|
||||
media_path,
|
||||
extraction_sample_fps,
|
||||
settings.video_sampling_frame_limit,
|
||||
model_frame_limit,
|
||||
extraction_width,
|
||||
)
|
||||
try:
|
||||
@ -807,6 +815,7 @@ async def analyze_video(
|
||||
extraction_width,
|
||||
focused_prompt,
|
||||
effective_tuning,
|
||||
detailed_output,
|
||||
)
|
||||
verified = _normalize_decision(
|
||||
verified, confidence_threshold, extra_instruction
|
||||
@ -833,7 +842,7 @@ async def analyze_video(
|
||||
if requested_mode in {"FAST", "ACCURATE"}:
|
||||
result, metadata = await _analyze_frames(
|
||||
profile, requested_mode, frames, duration, extraction_width, prompt,
|
||||
effective_tuning,
|
||||
effective_tuning, detailed_output,
|
||||
)
|
||||
if requested_mode == "FAST":
|
||||
result, metadata = await finalize_fast_result(
|
||||
@ -857,9 +866,11 @@ async def analyze_video(
|
||||
try:
|
||||
result, metadata = await _analyze_frames(
|
||||
profile, "FAST", frames, duration, extraction_width, prompt,
|
||||
effective_tuning,
|
||||
effective_tuning, detailed_output,
|
||||
)
|
||||
fallback_reason = _fallback_reason(
|
||||
result, confidence_threshold, detailed_output
|
||||
)
|
||||
fallback_reason = _fallback_reason(result, confidence_threshold)
|
||||
if fallback_reason is None or not fallback_to_accurate:
|
||||
result, metadata = await finalize_fast_result(
|
||||
result, metadata, "AUTO", fallback_reason
|
||||
@ -870,7 +881,7 @@ async def analyze_video(
|
||||
|
||||
result, metadata = await _analyze_frames(
|
||||
profile, "ACCURATE", frames, duration, extraction_width, prompt,
|
||||
effective_tuning,
|
||||
effective_tuning, detailed_output,
|
||||
)
|
||||
result = _normalize_decision(result, confidence_threshold, extra_instruction)
|
||||
metadata.update(
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
"code": "aima.power_video.v1",
|
||||
"name": "爱玛测试视频分析",
|
||||
"analysisType": "VIDEO_ANALYSIS",
|
||||
"model": "qwen3-vl:32b",
|
||||
"promptFile": "prompt.txt",
|
||||
"think": false,
|
||||
"temperature": 0.1,
|
||||
@ -10,22 +9,24 @@
|
||||
"fast": {
|
||||
"provider": "sglang",
|
||||
"model": "Qwen/Qwen3.8-27B-FP8",
|
||||
"fallbackModel": "qwen3-vl:8b-instruct",
|
||||
"sampleFps": 1.0,
|
||||
"maxWidth": 896,
|
||||
"maxOutputTokens": 512,
|
||||
"compactMaxOutputTokens": 256,
|
||||
"detailedMaxOutputTokens": 1024,
|
||||
"numCtx": 16384,
|
||||
"emptyResponseRetries": 0,
|
||||
"emptyResponseRetries": 1,
|
||||
"maxRetryOutputTokens": 4096,
|
||||
"keepAlive": "30m"
|
||||
},
|
||||
"accurate": {
|
||||
"provider": "sglang",
|
||||
"model": "Qwen/Qwen3.8-27B",
|
||||
"fallbackModel": "qwen3-vl:32b",
|
||||
"sampleFps": 3.0,
|
||||
"maxWidth": 1120,
|
||||
"maxOutputTokens": 768,
|
||||
"compactMaxOutputTokens": 384,
|
||||
"detailedMaxOutputTokens": 1536,
|
||||
"numCtx": 32768,
|
||||
"emptyResponseRetries": 2,
|
||||
"maxRetryOutputTokens": 8192,
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
API_KEY=replace-with-a-strong-random-token
|
||||
MEDIA_TIMEOUT_SECONDS=120
|
||||
OLLAMA_TIMEOUT_SECONDS=600
|
||||
SGLANG_FAST_BASE_URL=http://192.168.28.10:14081/v1
|
||||
SGLANG_ACCURATE_BASE_URL=http://192.168.28.10:14082/v1
|
||||
SGLANG_API_KEY=
|
||||
SGLANG_TIMEOUT_SECONDS=600
|
||||
VISION_OLLAMA_FALLBACK_ENABLED=true
|
||||
MAX_AUDIO_BYTES=104857600
|
||||
MAX_VIDEO_BYTES=2147483648
|
||||
VIDEO_SAMPLING_FRAME_LIMIT=100
|
||||
MAX_VIDEO_CONTEXT_TOKENS=262144
|
||||
VIDEO_MODEL_FRAME_LIMIT=30
|
||||
MAX_VIDEO_CONTEXT_TOKENS=65536
|
||||
|
||||
@ -32,11 +32,11 @@ services:
|
||||
- --tp-size
|
||||
- "1"
|
||||
- --context-length
|
||||
- "262144"
|
||||
- "65536"
|
||||
- --kv-cache-dtype
|
||||
- fp8_e4m3
|
||||
- --mem-fraction-static
|
||||
- "0.88"
|
||||
- "0.80"
|
||||
- --reasoning-parser
|
||||
- qwen3
|
||||
|
||||
@ -73,10 +73,10 @@ services:
|
||||
- --tp-size
|
||||
- "1"
|
||||
- --context-length
|
||||
- "262144"
|
||||
- "65536"
|
||||
- --kv-cache-dtype
|
||||
- fp8_e4m3
|
||||
- --mem-fraction-static
|
||||
- "0.92"
|
||||
- "0.84"
|
||||
- --reasoning-parser
|
||||
- qwen3
|
||||
|
||||
@ -3,7 +3,7 @@ name: cmvr-ai-analysis-qwen38
|
||||
services:
|
||||
analysis-service:
|
||||
container_name: cmvr-ai-analysis
|
||||
image: cmvr-ai-analysis:1.2.0-qwen38
|
||||
image: cmvr-ai-analysis:1.5.4-multi-match
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
@ -17,12 +17,11 @@ services:
|
||||
PROFILES_DIR: /data/profiles
|
||||
ARTIFACTS_DIR: /data/artifacts
|
||||
JOBS_DIR: /data/jobs
|
||||
OLLAMA_BASE_URL: http://host.docker.internal:11434
|
||||
OLLAMA_MODEL: qwen3-vl:32b
|
||||
SGLANG_FAST_BASE_URL: http://192.168.28.10:14081/v1
|
||||
SGLANG_ACCURATE_BASE_URL: http://192.168.28.10:14082/v1
|
||||
SGLANG_TIMEOUT_SECONDS: "600"
|
||||
VISION_OLLAMA_FALLBACK_ENABLED: "true"
|
||||
VIDEO_MODEL_FRAME_LIMIT: "30"
|
||||
MAX_VIDEO_CONTEXT_TOKENS: "65536"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
fastapi==0.116.1
|
||||
httpx==0.28.1
|
||||
numpy==2.2.6
|
||||
opencv-python-headless==4.12.0.88
|
||||
pydantic-settings==2.10.1
|
||||
python-multipart==0.0.20
|
||||
scipy==1.15.3
|
||||
|
||||
@ -37,68 +37,85 @@ class _FakeClient:
|
||||
def test_vision_request_retries_empty_content(monkeypatch):
|
||||
_FakeClient.responses = [
|
||||
{
|
||||
"done_reason": "length",
|
||||
"eval_count": 1024,
|
||||
"message": {"content": "", "thinking": "reasoning"},
|
||||
"choices": [{
|
||||
"finish_reason": "length",
|
||||
"message": {"content": "", "reasoning_content": "reasoning"},
|
||||
}],
|
||||
"usage": {"completion_tokens": 1024},
|
||||
},
|
||||
{
|
||||
"done_reason": "stop",
|
||||
"eval_count": 20,
|
||||
"message": {"content": '{"passed": true}', "thinking": ""},
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"message": {"content": '{"passed": true}', "reasoning_content": ""},
|
||||
}],
|
||||
"usage": {"completion_tokens": 20},
|
||||
},
|
||||
]
|
||||
_FakeClient.payloads = []
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeClient)
|
||||
payload = {
|
||||
"model": "Qwen/Qwen3.8-27B-FP8",
|
||||
"think": False,
|
||||
"messages": [{"role": "user", "content": "Analyze"}],
|
||||
"options": {"num_predict": 1024},
|
||||
}
|
||||
|
||||
content, metadata = asyncio.run(video._request_vision_model(payload))
|
||||
content, metadata = asyncio.run(video._request_vision_model(
|
||||
payload, base_url="http://sglang-fast:30000/v1"
|
||||
))
|
||||
|
||||
assert content == '{"passed": true}'
|
||||
assert metadata["doneReason"] == "stop"
|
||||
assert len(_FakeClient.payloads) == 2
|
||||
assert _FakeClient.payloads[0]["think"] is False
|
||||
assert _FakeClient.payloads[1]["options"]["num_predict"] == 4096
|
||||
assert _FakeClient.payloads[1]["think"] is False
|
||||
assert "/no_think" in _FakeClient.payloads[1]["messages"][0]["content"]
|
||||
assert _FakeClient.payloads[0]["chat_template_kwargs"]["enable_thinking"] is False
|
||||
assert _FakeClient.payloads[1]["max_tokens"] == 4096
|
||||
assert _FakeClient.payloads[1]["chat_template_kwargs"]["enable_thinking"] is False
|
||||
assert "/no_think" in _FakeClient.payloads[1]["messages"][0]["content"][0]["text"]
|
||||
assert metadata["requestedOutputTokens"] == 4096
|
||||
|
||||
|
||||
def test_vision_request_escalates_budget_after_repeated_length_cutoff(monkeypatch):
|
||||
_FakeClient.responses = [
|
||||
{
|
||||
"done_reason": "length",
|
||||
"eval_count": 768,
|
||||
"message": {"content": "", "thinking": "first reasoning"},
|
||||
"choices": [{
|
||||
"finish_reason": "length",
|
||||
"message": {"content": "", "reasoning_content": "first reasoning"},
|
||||
}],
|
||||
"usage": {"completion_tokens": 768},
|
||||
},
|
||||
{
|
||||
"done_reason": "length",
|
||||
"eval_count": 4096,
|
||||
"message": {"content": "", "thinking": "more reasoning"},
|
||||
"choices": [{
|
||||
"finish_reason": "length",
|
||||
"message": {"content": "", "reasoning_content": "more reasoning"},
|
||||
}],
|
||||
"usage": {"completion_tokens": 4096},
|
||||
},
|
||||
{
|
||||
"done_reason": "stop",
|
||||
"eval_count": 30,
|
||||
"message": {"content": '{"passed": true}', "thinking": ""},
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"message": {"content": '{"passed": true}', "reasoning_content": ""},
|
||||
}],
|
||||
"usage": {"completion_tokens": 30},
|
||||
},
|
||||
]
|
||||
_FakeClient.payloads = []
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeClient)
|
||||
payload = {
|
||||
"model": "Qwen/Qwen3.8-27B-FP8",
|
||||
"think": False,
|
||||
"messages": [{"role": "user", "content": "Analyze /no_think"}],
|
||||
"options": {"num_predict": 768},
|
||||
}
|
||||
|
||||
content, metadata = asyncio.run(
|
||||
video._request_vision_model(payload, empty_response_retries=2)
|
||||
video._request_vision_model(
|
||||
payload, empty_response_retries=2,
|
||||
base_url="http://sglang-fast:30000/v1",
|
||||
)
|
||||
)
|
||||
|
||||
assert content == '{"passed": true}'
|
||||
assert [_payload["options"]["num_predict"] for _payload in _FakeClient.payloads] == [
|
||||
assert [_payload["max_tokens"] for _payload in _FakeClient.payloads] == [
|
||||
768,
|
||||
4096,
|
||||
8192,
|
||||
@ -106,6 +123,45 @@ def test_vision_request_escalates_budget_after_repeated_length_cutoff(monkeypatc
|
||||
assert metadata["attempt"] == 3
|
||||
|
||||
|
||||
def test_vision_request_retries_truncated_structured_content(monkeypatch):
|
||||
_FakeClient.responses = [
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": "length",
|
||||
"message": {"content": '{"passed": true, "events": [', "reasoning_content": ""},
|
||||
}],
|
||||
"usage": {"completion_tokens": 512},
|
||||
},
|
||||
{
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"message": {"content": '{"passed": true}', "reasoning_content": ""},
|
||||
}],
|
||||
"usage": {"completion_tokens": 18},
|
||||
},
|
||||
]
|
||||
_FakeClient.payloads = []
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeClient)
|
||||
payload = {
|
||||
"model": "Qwen/Qwen3.8-27B-FP8",
|
||||
"think": False,
|
||||
"format": video.VIDEO_RESULT_SCHEMA,
|
||||
"messages": [{"role": "user", "content": "Analyze"}],
|
||||
"options": {"num_predict": 512},
|
||||
}
|
||||
|
||||
content, metadata = asyncio.run(video._request_vision_model(
|
||||
payload,
|
||||
empty_response_retries=1,
|
||||
base_url="http://sglang-fast:30000/v1",
|
||||
))
|
||||
|
||||
assert content == '{"passed": true}'
|
||||
assert len(_FakeClient.payloads) == 2
|
||||
assert _FakeClient.payloads[1]["max_tokens"] == 4096
|
||||
assert metadata["attempt"] == 2
|
||||
|
||||
|
||||
def test_sglang_payload_uses_openai_multimodal_format():
|
||||
payload = video._sglang_payload(
|
||||
{
|
||||
@ -115,6 +171,7 @@ def test_sglang_payload_uses_openai_multimodal_format():
|
||||
{"role": "user", "content": "Analyze", "images": ["abc", "def"]}
|
||||
],
|
||||
"options": {"temperature": 0.1, "num_predict": 512},
|
||||
"format": video.VIDEO_RESULT_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@ -191,6 +248,21 @@ def test_fast_result_quality_controls_accurate_fallback():
|
||||
)
|
||||
|
||||
|
||||
def test_compact_result_does_not_require_event_evidence_arrays():
|
||||
compact = {
|
||||
"passed": True,
|
||||
"evidenceSufficient": True,
|
||||
"confidence": 0.9,
|
||||
"conclusion": "通过",
|
||||
"summary": "状态变化符合标准",
|
||||
}
|
||||
|
||||
assert video._fallback_reason(compact, 0.75, detailed_output=False) is None
|
||||
assert video._fallback_reason(compact, 0.75, detailed_output=True).startswith(
|
||||
"FAST_RESULT_MISSING_FIELDS:"
|
||||
)
|
||||
|
||||
|
||||
def test_decision_is_fail_closed_when_evidence_is_insufficient():
|
||||
result = video._normalize_decision(
|
||||
{
|
||||
@ -208,6 +280,7 @@ def test_decision_is_fail_closed_when_evidence_is_insufficient():
|
||||
assert result["passed"] is False
|
||||
assert result["decisionReason"] == "INSUFFICIENT_EVIDENCE"
|
||||
assert result["conclusion"].startswith("未通过:视频证据不足")
|
||||
assert result["result"] == "没有拍到完整过程"
|
||||
|
||||
|
||||
def test_negative_acceptance_criterion_corrects_inconsistent_model_boolean():
|
||||
@ -315,10 +388,21 @@ def test_frame_sampling_always_includes_video_tail():
|
||||
assert capped is True
|
||||
|
||||
|
||||
def test_video_context_expands_with_frame_count():
|
||||
def test_video_context_is_capped_to_safe_model_budget():
|
||||
assert video._video_context_size(16384, 7, 512) == 16384
|
||||
assert video._video_context_size(16384, 18, 1536) == 32768
|
||||
assert video._video_context_size(16384, 27, 512) == 65536
|
||||
assert video._video_context_size(32768, 100, 768) == 262144
|
||||
assert video._video_context_size(32768, 100, 768) == 65536
|
||||
|
||||
|
||||
def test_model_frame_limit_preserves_global_limit(monkeypatch):
|
||||
monkeypatch.setattr(video.settings, "video_sampling_frame_limit", 100)
|
||||
monkeypatch.setattr(video.settings, "video_model_frame_limit", 30)
|
||||
assert video._model_frame_limit() == 30
|
||||
|
||||
count, _, _, _, capped = video._frame_sampling_plan(20.0, 3.0, 30)
|
||||
assert count == 30
|
||||
assert capped is True
|
||||
|
||||
|
||||
def test_auto_mode_falls_back_to_accurate_result(monkeypatch, tmp_path):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user