feat: add media analysis service

This commit is contained in:
lixiaolong 2026-08-13 16:56:32 +08:00
commit a0d2faac55
35 changed files with 1409 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
.idea
__pycache__
.pytest_cache
data
tests

9
.gitattributes vendored Normal file
View File

@ -0,0 +1,9 @@
* text=auto
*.py text eol=lf
*.sh text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.txt text eol=lf
*.md text eol=lf

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
deploy/.env
data/artifacts/**
data/jobs/**
data/logs/**
!data/artifacts/.gitkeep
!data/jobs/.gitkeep
!data/logs/.gitkeep
__pycache__/
.pytest_cache/

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM docker.m.daocloud.io/library/python:3.11-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY app ./app
COPY tools ./tools
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

61
README.md Normal file
View File

@ -0,0 +1,61 @@
# CMVR Media Analysis Service
This service provides a project-neutral API for multi-label audio event classification and video analysis. Audio labels are discovered from reference subdirectories, so adding categories does not require code changes.
## Runtime layout
The production deployment lives under `/data/apps/cmvr-ai-analysis` on the model server. Reference media belongs in `data/profiles`; generated features belong in `data/artifacts`.
## Build an audio profile
Place at least three reference files in each label directory, then run:
```bash
docker compose run --rm analysis-service python -m tools.build_audio_profile \
--profile-code aima.power_state.v1
```
On the model server the reference directories are:
```text
/data/apps/cmvr-ai-analysis/data/profiles/aima/power-state/v1/references/POWER_ON
/data/apps/cmvr-ai-analysis/data/profiles/aima/power-state/v1/references/POWER_OFF
/data/apps/cmvr-ai-analysis/data/profiles/aima/power-state/v1/references/ARMED
/data/apps/cmvr-ai-analysis/data/profiles/aima/power-state/v1/references/DISARMED
```
After adding or replacing reference files, rebuild the feature library and restart the service profile state:
```bash
cd /data/apps/cmvr-ai-analysis
sh scripts/build-audio-profile.sh aima.power_state.v1
docker compose -f deploy/compose.yaml restart analysis-service
```
Run leave-one-out validation after rebuilding. Each sample is compared only with the
other references, so a file cannot obtain a perfect score by matching itself:
```bash
sh scripts/validate-audio-profile.sh aima.power_state.v1
```
To add another sound category later, create a new stable uppercase label directory
under `references`, add its display name to `labelNames`, upload the reference audio,
and rebuild the profile. The classifier and platform workflow component do not need
another code change.
## API
`POST /api/v1/analysis/run` accepts `requestId`, `analysisType`, `profileCode`, `mediaUrl`, `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.
The platform backend deployment must provide the same token through `MEDIA_ANALYSIS_API_KEY`. The service token is stored only in `/data/apps/cmvr-ai-analysis/deploy/.env`; it is not exposed to the browser or workflow JSON.
Video requests may set `options.analysisMode` to one of:
- `AUTO`: use the fast model first and fall back to the accurate model when the result is incomplete.
- `FAST`: use the low-latency model only.
- `ACCURATE`: use the high-accuracy model only.
Existing workflows without this option are treated as `AUTO`.

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@

241
app/audio.py Normal file
View File

@ -0,0 +1,241 @@
import json
from pathlib import Path
from typing import Any
import numpy as np
from scipy import signal
from scipy.fft import dct
from scipy.spatial.distance import cdist
from app.media import decode_audio
from app.profile_store import Profile, artifact_path
SUPPORTED_AUDIO_SUFFIXES = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg"}
def _hz_to_mel(hz: np.ndarray | float) -> np.ndarray | float:
return 2595.0 * np.log10(1.0 + np.asarray(hz) / 700.0)
def _mel_to_hz(mel: np.ndarray) -> np.ndarray:
return 700.0 * (np.power(10.0, mel / 2595.0) - 1.0)
def _mel_filterbank(sample_rate: int, n_fft: int, bands: int) -> np.ndarray:
mel_points = np.linspace(_hz_to_mel(40.0), _hz_to_mel(sample_rate / 2), bands + 2)
bins = np.floor((n_fft + 1) * _mel_to_hz(mel_points) / sample_rate).astype(int)
bins = np.clip(bins, 0, n_fft // 2)
filters = np.zeros((bands, n_fft // 2 + 1), dtype=np.float32)
for index in range(bands):
left, center, right = bins[index : index + 3]
if center > left:
filters[index, left:center] = np.linspace(0, 1, center - left, endpoint=False)
if right > center:
filters[index, center:right] = np.linspace(1, 0, right - center, endpoint=False)
return filters
def _trim_audio(samples: np.ndarray, sample_rate: int) -> np.ndarray:
if len(samples) < sample_rate // 4:
return samples
frame = max(1, int(sample_rate * 0.02))
energy = np.convolve(samples * samples, np.ones(frame) / frame, mode="same")
threshold = max(float(np.max(energy)) * 0.0025, 1e-7)
active = np.flatnonzero(energy >= threshold)
if not len(active):
return samples
padding = int(sample_rate * 0.15)
return samples[max(0, active[0] - padding) : min(len(samples), active[-1] + padding)]
def feature_sequence(
samples: np.ndarray, sample_rate: int, max_frames: int = 320
) -> np.ndarray:
samples = _trim_audio(samples.astype(np.float32), sample_rate)
samples -= float(np.mean(samples))
peak = float(np.max(np.abs(samples))) if len(samples) else 0.0
if peak < 1e-5:
raise ValueError("Audio contains no usable sound")
samples /= peak
n_fft = 512
if len(samples) < n_fft:
samples = np.pad(samples, (0, n_fft - len(samples)))
_, _, stft = signal.stft(
samples,
fs=sample_rate,
window="hann",
nperseg=n_fft,
noverlap=384,
boundary=None,
padded=False,
)
mel = _mel_filterbank(sample_rate, n_fft, 40) @ (np.abs(stft) ** 2)
coefficients = dct(np.log(mel + 1e-8), axis=0, norm="ortho")[1:21].T
mean = np.mean(coefficients, axis=0)
static = mean / max(float(np.linalg.norm(mean)), 1e-5)
normalized = (coefficients - mean) / np.maximum(np.std(coefficients, axis=0), 1e-5)
delta = np.gradient(normalized, axis=0)
sequence = np.concatenate(
[normalized, delta, np.tile(static * 0.75, (len(normalized), 1))], axis=1
).astype(np.float32)
sequence /= np.maximum(np.linalg.norm(sequence, axis=1, keepdims=True), 1e-5)
sequence = sequence[::2]
if len(sequence) > max_frames:
indices = np.linspace(0, len(sequence) - 1, max_frames).round().astype(int)
sequence = sequence[indices]
return sequence
def sequence_distance(first: np.ndarray, second: np.ndarray, band_ratio: float = 0.35) -> float:
distances = cdist(first, second, metric="cosine")
first_length, second_length = distances.shape
band = max(
abs(first_length - second_length) + 2,
int(max(first_length, second_length) * band_ratio),
)
previous = np.full(second_length + 1, np.inf, dtype=np.float32)
previous[0] = 0.0
for first_index in range(1, first_length + 1):
current = np.full(second_length + 1, np.inf, dtype=np.float32)
start = max(1, first_index - band)
end = min(second_length, first_index + band)
for second_index in range(start, end + 1):
current[second_index] = distances[first_index - 1, second_index - 1] + min(
current[second_index - 1],
previous[second_index],
previous[second_index - 1],
)
previous = current
return float(previous[second_length] / (first_length + second_length))
def _read_audio(path: Path) -> tuple[np.ndarray, int]:
raw, sample_rate = decode_audio(path)
return np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0, sample_rate
def build_audio_profile(profile: Profile) -> dict[str, Any]:
references = profile.directory / "references"
labels: list[str] = []
files: list[str] = []
durations: list[float] = []
sequences: list[np.ndarray] = []
for label_dir in sorted(path for path in references.glob("*") if path.is_dir()):
for path in sorted(label_dir.iterdir()):
if path.suffix.lower() not in SUPPORTED_AUDIO_SUFFIXES:
continue
samples, sample_rate = _read_audio(path)
labels.append(label_dir.name)
files.append(path.name)
durations.append(len(samples) / sample_rate)
sequences.append(feature_sequence(samples, sample_rate))
unique_labels = sorted(set(labels))
if len(unique_labels) < 2:
raise ValueError("At least two non-empty reference audio groups are required")
minimum_per_label = int(profile.config.get("minimumReferencesPerLabel", 1))
counts = {label: labels.count(label) for label in unique_labels}
insufficient = [label for label, count in counts.items() if count < minimum_per_label]
if insufficient:
raise ValueError(
f"Reference groups below minimum {minimum_per_label}: {', '.join(insufficient)}"
)
offsets = np.concatenate(([0], np.cumsum([len(sequence) for sequence in sequences])))
output = artifact_path(profile)
output.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(
output,
sequence_features=np.concatenate(sequences),
sequence_offsets=offsets,
labels=np.asarray(labels),
files=np.asarray(files),
durations=np.asarray(durations, dtype=np.float32),
)
manifest = output.with_name("manifest.json")
manifest.write_text(
json.dumps(
{
"profileCode": profile.code,
"featureVersion": "mfcc-dtw-v2",
"labelCounts": counts,
"referenceCount": len(sequences),
"files": files,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
return {"referenceCount": len(sequences), "labelCounts": counts}
def load_reference_sequences(profile: Profile):
artifact = artifact_path(profile)
if not artifact.exists():
raise ValueError(
f"Profile {profile.code} has no feature library; add reference audio and rebuild it"
)
data = np.load(artifact, allow_pickle=False)
if "sequence_features" not in data or "sequence_offsets" not in data:
raise ValueError(f"Profile {profile.code} uses an obsolete feature library; rebuild it")
features = data["sequence_features"]
offsets = data["sequence_offsets"].astype(int)
sequences = [features[offsets[index] : offsets[index + 1]] for index in range(len(offsets) - 1)]
return sequences, data["labels"].astype(str), data["files"].astype(str)
def classify_sequence(
profile: Profile,
query: np.ndarray,
reference_sequences: list[np.ndarray],
labels: np.ndarray,
files: np.ndarray,
) -> dict[str, Any]:
best_by_label: dict[str, dict[str, Any]] = {}
for reference, label, file_name in zip(reference_sequences, labels, files):
distance = sequence_distance(query, reference)
current = best_by_label.get(label)
if current is None or distance < current["distance"]:
best_by_label[label] = {"distance": distance, "reference": file_name}
ranking = sorted(best_by_label.items(), key=lambda item: item[1]["distance"])
winner_label, winner = ranking[0]
second_distance = ranking[1][1]["distance"] if len(ranking) > 1 else 1.0
distance_margin = second_distance - winner["distance"]
decision = profile.config.get("decision", {})
maximum_distance = float(decision.get("maxDistance", 0.45))
minimum_margin = float(decision.get("minDistanceMargin", 0.015))
matched = winner["distance"] <= maximum_distance and distance_margin >= minimum_margin
output_label = winner_label if matched else str(decision.get("unknownLabel", "UNKNOWN"))
label_names = profile.config.get("labelNames", {})
return {
"label": output_label,
"labelName": label_names.get(output_label, output_label),
"matched": matched,
"confidence": round(max(0.0, min(1.0, 1.0 - winner["distance"])), 6),
"scores": {
label: round(max(0.0, min(1.0, 1.0 - item["distance"])), 6)
for label, item in ranking
},
"evidence": {"reference": winner["reference"] if matched else None},
"thresholds": {
"maxDistance": maximum_distance,
"minDistanceMargin": minimum_margin,
},
}
def classify_audio(profile: Profile, media_path: Path) -> dict[str, Any]:
reference_sequences, labels, files = load_reference_sequences(profile)
samples, sample_rate = _read_audio(media_path)
maximum_seconds = float(profile.config.get("maximumAudioSeconds", 30))
if len(samples) / sample_rate > maximum_seconds:
raise ValueError(f"Audio duration exceeds the profile limit of {maximum_seconds:g} seconds")
query = feature_sequence(samples, sample_rate)
result = classify_sequence(profile, query, reference_sequences, labels, files)
result["evidence"].update({"startMs": 0, "endMs": round(len(samples) * 1000 / sample_rate)})
return result

22
app/config.py Normal file
View File

@ -0,0 +1,22 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
api_key: str = ""
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
max_audio_bytes: int = 100 * 1024 * 1024
max_video_bytes: int = 2 * 1024 * 1024 * 1024
max_video_frames: int = 24
settings = Settings()

116
app/main.py Normal file
View File

@ -0,0 +1,116 @@
import shutil
import time
from contextlib import asynccontextmanager
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException
from app.audio import classify_audio
from app.config import settings
from app.media import download_media
from app.profile_store import profile_store
from app.schemas import AnalysisRequest, AnalysisResponse, AnalysisType
from app.video import VisionModelError, analyze_video
def authorize(authorization: str | None = Header(default=None)) -> None:
if not settings.api_key:
return
if authorization != f"Bearer {settings.api_key}":
raise HTTPException(status_code=401, detail="Invalid analysis service credential")
@asynccontextmanager
async def lifespan(_: FastAPI):
settings.jobs_dir.mkdir(parents=True, exist_ok=True)
settings.artifacts_dir.mkdir(parents=True, exist_ok=True)
profile_store.reload()
yield
app = FastAPI(title="CMVR Media Analysis Service", version="1.0.0", lifespan=lifespan)
@app.get("/health")
async def health() -> dict:
ollama = "DOWN"
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"{settings.ollama_base_url.rstrip('/')}/api/version")
response.raise_for_status()
ollama = "UP"
except Exception:
pass
return {
"status": "UP",
"ollama": ollama,
"visionModel": settings.ollama_model,
"profiles": profile_store.status(),
}
@app.post(
"/api/v1/analysis/run",
response_model=AnalysisResponse,
dependencies=[Depends(authorize)],
)
async def run_analysis(request: AnalysisRequest) -> AnalysisResponse:
started = time.monotonic()
media_path = None
try:
profile = profile_store.get(request.profileCode, request.analysisType.value)
if request.analysisType == AnalysisType.AUDIO_CLASSIFICATION:
media_path = await download_media(
str(request.mediaUrl), ".audio", settings.max_audio_bytes
)
result = classify_audio(profile, media_path)
model = {"provider": "CMVR", "name": "mfcc-dtw-audio-fingerprint-v2"}
else:
media_path = await download_media(
str(request.mediaUrl), ".video", settings.max_video_bytes
)
result, duration, video_metadata = await analyze_video(
profile,
media_path,
str(request.options.get("instruction", "")),
str(request.options.get("analysisMode", "AUTO")),
)
evidence = result.get("evidence")
if not isinstance(evidence, dict):
evidence = {}
result["evidence"] = evidence
evidence.update(
{
"sampledFrameCount": video_metadata["sampledFrameCount"],
"maximumWidth": video_metadata["maximumWidth"],
"durationSeconds": duration,
}
)
result["analysisMode"] = video_metadata["mode"]
result["fallback"] = video_metadata["fallback"]
result["fallbackReason"] = video_metadata["fallbackReason"]
model = {
"provider": "Ollama",
"name": video_metadata["model"],
"requestedMode": video_metadata["requestedMode"],
"usedMode": video_metadata["mode"],
"fallback": video_metadata["fallback"],
}
return AnalysisResponse(
requestId=request.requestId,
analysisType=request.analysisType,
profileCode=request.profileCode,
status="SUCCEEDED",
result=result,
model=model,
timingMs=round((time.monotonic() - started) * 1000),
)
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Remote service request failed: {exc}") from exc
except VisionModelError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
finally:
if media_path is not None:
media_path.unlink(missing_ok=True)

72
app/media.py Normal file
View File

@ -0,0 +1,72 @@
import ipaddress
import socket
import subprocess
import uuid
from pathlib import Path
from urllib.parse import urlparse
import httpx
from app.config import settings
def _validate_remote_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("Only HTTP or HTTPS media URLs are supported")
default_port = 443 if parsed.scheme == "https" else 80
for address in socket.getaddrinfo(parsed.hostname, parsed.port or default_port):
ip = ipaddress.ip_address(address[4][0])
if ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified:
raise ValueError("The media URL resolves to a prohibited address")
async def download_media(url: str, suffix: str, max_bytes: int) -> Path:
_validate_remote_url(url)
settings.jobs_dir.mkdir(parents=True, exist_ok=True)
destination = settings.jobs_dir / f"{uuid.uuid4().hex}{suffix}"
total = 0
timeout = httpx.Timeout(settings.media_timeout_seconds)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
with destination.open("wb") as output:
async for chunk in response.aiter_bytes(1024 * 1024):
total += len(chunk)
if total > max_bytes:
raise ValueError("Media file exceeds the configured size limit")
output.write(chunk)
if total == 0:
raise ValueError("Downloaded media file is empty")
return destination
except Exception:
destination.unlink(missing_ok=True)
raise
def decode_audio(path: Path, sample_rate: int = 16000) -> tuple[bytes, int]:
process = subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-i",
str(path),
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-ac",
"1",
"-ar",
str(sample_rate),
"pipe:1",
],
capture_output=True,
check=False,
)
if process.returncode != 0 or not process.stdout:
error = process.stderr.decode("utf-8", errors="replace")[-500:]
raise ValueError(f"Unable to decode audio: {error}")
return process.stdout, sample_rate

70
app/profile_store.py Normal file
View File

@ -0,0 +1,70 @@
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.config import settings
@dataclass(frozen=True)
class Profile:
code: str
analysis_type: str
directory: Path
config: dict[str, Any]
class ProfileStore:
def __init__(self) -> None:
self._profiles: dict[str, Profile] = {}
def reload(self) -> None:
profiles: dict[str, Profile] = {}
if settings.profiles_dir.exists():
for path in settings.profiles_dir.rglob("profile.json"):
data = json.loads(path.read_text(encoding="utf-8"))
code = str(data.get("code", "")).strip()
analysis_type = str(data.get("analysisType", "")).strip()
if not code or not analysis_type:
raise ValueError(f"Invalid profile: {path}")
if code in profiles:
raise ValueError(f"Duplicate profile code: {code}")
profiles[code] = Profile(code, analysis_type, path.parent, data)
self._profiles = profiles
def get(self, code: str, analysis_type: str) -> Profile:
profile = self._profiles.get(code)
if profile is None:
raise KeyError(f"Analysis profile does not exist: {code}")
if profile.analysis_type != analysis_type:
raise ValueError(
f"Profile {code} is for {profile.analysis_type}, not {analysis_type}"
)
return profile
def status(self) -> list[dict[str, Any]]:
result = []
for profile in self._profiles.values():
ready = True
detail = "READY"
if profile.analysis_type == "AUDIO_CLASSIFICATION":
artifact = artifact_path(profile)
ready = artifact.exists()
detail = "READY" if ready else "WAITING_FOR_REFERENCE_AUDIO"
result.append(
{
"code": profile.code,
"analysisType": profile.analysis_type,
"ready": ready,
"detail": detail,
}
)
return result
def artifact_path(profile: Profile) -> Path:
relative = profile.directory.relative_to(settings.profiles_dir)
return settings.artifacts_dir / relative / "audio_features.npz"
profile_store = ProfileStore()

28
app/schemas.py Normal file
View File

@ -0,0 +1,28 @@
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, HttpUrl
class AnalysisType(str, Enum):
AUDIO_CLASSIFICATION = "AUDIO_CLASSIFICATION"
VIDEO_ANALYSIS = "VIDEO_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
options: dict[str, Any] = Field(default_factory=dict)
context: dict[str, Any] = Field(default_factory=dict)
class AnalysisResponse(BaseModel):
requestId: str
analysisType: AnalysisType
profileCode: str
status: str
result: dict[str, Any] = Field(default_factory=dict)
model: dict[str, Any] = Field(default_factory=dict)
timingMs: int

335
app/video.py Normal file
View File

@ -0,0 +1,335 @@
import base64
import copy
import json
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Any
import httpx
from app.config import settings
from app.profile_store import Profile
class VisionModelError(RuntimeError):
pass
VIDEO_RESULT_SCHEMA = {
"type": "object",
"properties": {
"passed": {"type": ["boolean", "null"]},
"conclusion": {"type": "string"},
"summary": {"type": "string"},
"events": {
"type": "array",
"items": {
"type": "object",
"properties": {
"timeRange": {"type": "string"},
"event": {"type": "string"},
"confidence": {"type": "number"},
},
"required": ["timeRange", "event", "confidence"],
},
},
"warnings": {"type": "array", "items": {"type": "string"}},
},
"required": ["passed", "conclusion", "summary", "events", "warnings"],
}
def _duration(path: Path) -> float:
process = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True,
text=True,
check=False,
)
try:
value = float(process.stdout.strip())
except ValueError as exc:
raise ValueError("Unable to read video duration") from exc
if value <= 0:
raise ValueError("Video duration must be positive")
return value
def _extract_frames(
path: Path, maximum: int, maximum_width: int
) -> tuple[list[Path], float, Path]:
duration = _duration(path)
count = max(2, min(maximum, int(duration) + 1))
fps = count / duration
directory = Path(tempfile.mkdtemp(prefix="frames-", dir=settings.jobs_dir))
output = directory / "frame-%03d.jpg"
process = subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-i",
str(path),
"-vf",
f"fps={fps:.8f},scale='min({maximum_width},iw)':-2",
"-frames:v",
str(count),
"-q:v",
"3",
str(output),
],
capture_output=True,
check=False,
)
frames = sorted(directory.glob("frame-*.jpg"))
if process.returncode != 0 or not frames:
error = process.stderr.decode("utf-8", errors="replace")[-500:]
raise ValueError(f"Unable to extract video frames: {error}")
return frames, duration, directory
def _select_frames(frames: list[Path], maximum: int) -> list[Path]:
if len(frames) <= maximum:
return frames
if maximum <= 1:
return [frames[len(frames) // 2]]
indices = [round(index * (len(frames) - 1) / (maximum - 1)) for index in range(maximum)]
return [frames[index] for index in indices]
def _encode_image(path: Path, maximum_width: int, extracted_width: int) -> str:
if maximum_width >= extracted_width:
image = path.read_bytes()
else:
process = subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-i",
str(path),
"-vf",
f"scale='min({maximum_width},iw)':-2",
"-q:v",
"3",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"pipe:1",
],
capture_output=True,
check=False,
)
if process.returncode != 0 or not process.stdout:
error = process.stderr.decode("utf-8", errors="replace")[-500:]
raise ValueError(f"Unable to resize video frame: {error}")
image = process.stdout
return base64.b64encode(image).decode("ascii")
def _parse_json(content: str) -> dict[str, Any]:
content = content.strip()
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL)
if fenced:
content = fenced.group(1)
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {"result": parsed, "structured": False}
except json.JSONDecodeError:
return {"summary": content, "structured": False}
def _fallback_reason(result: dict[str, Any], fallback_on_unknown: bool) -> str | None:
if result.get("structured") is False:
return "FAST_RESULT_NOT_STRUCTURED"
required = ("passed", "conclusion", "summary", "events", "warnings")
missing = [field for field in required if field not in result]
if missing:
return "FAST_RESULT_MISSING_FIELDS:" + ",".join(missing)
if not isinstance(result.get("conclusion"), str) or not result["conclusion"].strip():
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):
return "FAST_RESULT_INVALID_COLLECTIONS"
if fallback_on_unknown and result.get("passed") is None:
return "FAST_RESULT_INSUFFICIENT"
if result.get("passed") is not True and result.get("passed") is not False and result.get("passed") is not None:
return "FAST_RESULT_INVALID_DECISION"
return None
def _strategy(profile: Profile, name: str) -> dict[str, Any]:
defaults = {
"FAST": {
"model": profile.config.get("model", settings.ollama_model),
"maxFrames": 6,
"maxWidth": 896,
"maxOutputTokens": 512,
"numCtx": 16384,
"emptyResponseRetries": 0,
},
"ACCURATE": {
"model": profile.config.get("model", settings.ollama_model),
"maxFrames": 12,
"maxWidth": 1280,
"maxOutputTokens": 768,
"numCtx": 32768,
"emptyResponseRetries": 1,
},
}
configured = profile.config.get("strategies", {}).get(name.lower(), {})
return {**defaults[name], **configured}
async def _request_vision_model(
payload: dict[str, Any], empty_response_retries: int = 1
) -> tuple[str, dict[str, Any]]:
last_metadata: dict[str, Any] = {}
timeout = httpx.Timeout(settings.ollama_timeout_seconds)
async with httpx.AsyncClient(timeout=timeout) as client:
for attempt in range(empty_response_retries + 1):
request_payload = copy.deepcopy(payload)
if attempt:
options = request_payload.setdefault("options", {})
options["num_predict"] = max(int(options.get("num_predict", 0)), 1536)
request_payload["messages"][0]["content"] += (
"\n\nReturn the final JSON now. Do not return reasoning without a final answer."
)
response = await client.post(
f"{settings.ollama_base_url.rstrip('/')}/api/chat", json=request_payload
)
response.raise_for_status()
body = response.json()
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 "")),
}
if content:
return content, metadata
last_metadata = metadata
raise VisionModelError(
"Vision model returned an empty final response after retry "
f"({json.dumps(last_metadata, ensure_ascii=False)})"
)
async def _analyze_frames(
profile: Profile,
strategy_name: str,
frames: list[Path],
duration: float,
extracted_width: int,
prompt: str,
) -> tuple[dict[str, Any], dict[str, Any]]:
strategy = _strategy(profile, strategy_name)
selected = _select_frames(frames, max(2, int(strategy["maxFrames"])))
maximum_width = max(320, int(strategy["maxWidth"]))
images = [_encode_image(path, maximum_width, extracted_width) for path in selected]
strategy_prompt = prompt + (
f"\n\nThe video duration is {duration:.3f} seconds. "
f"The following {len(selected)} images are ordered frames sampled across the video."
)
payload = {
"model": strategy["model"],
"stream": False,
"think": bool(strategy.get("think", profile.config.get("think", False))),
"format": VIDEO_RESULT_SCHEMA,
"keep_alive": str(strategy.get("keepAlive", "30m")),
"messages": [{"role": "user", "content": strategy_prompt, "images": images}],
"options": {
"temperature": float(strategy.get("temperature", profile.config.get("temperature", 0.1))),
"num_predict": int(strategy["maxOutputTokens"]),
"num_ctx": int(strategy["numCtx"]),
},
}
content, metrics = await _request_vision_model(
payload, int(strategy.get("emptyResponseRetries", 0))
)
metadata = {
"mode": strategy_name,
"model": strategy["model"],
"sampledFrameCount": len(selected),
"maximumWidth": maximum_width,
**metrics,
}
return _parse_json(content), metadata
async def analyze_video(
profile: Profile,
media_path: Path,
extra_instruction: str = "",
analysis_mode: str = "AUTO",
) -> 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"}:
raise ValueError("Video analysis mode must be AUTO, FAST, or ACCURATE")
fast = _strategy(profile, "FAST")
accurate = _strategy(profile, "ACCURATE")
extraction_strategy = fast if requested_mode == "FAST" else accurate
extraction_frames = max(2, int(extraction_strategy["maxFrames"]))
extraction_width = max(320, int(extraction_strategy["maxWidth"]))
frames, duration, frame_dir = _extract_frames(
media_path, extraction_frames, extraction_width
)
try:
prompt_path = profile.directory / str(profile.config.get("promptFile", "prompt.txt"))
prompt = prompt_path.read_text(encoding="utf-8")
if extra_instruction.strip():
prompt += "\n\nAdditional inspection requirement:\n" + extra_instruction.strip()
if requested_mode in {"FAST", "ACCURATE"}:
result, metadata = await _analyze_frames(
profile, requested_mode, frames, duration, extraction_width, prompt
)
metadata.update(
{"requestedMode": requested_mode, "fallback": False, "fallbackReason": None}
)
return result, duration, metadata
fallback_reason = None
try:
result, metadata = await _analyze_frames(
profile, "FAST", frames, duration, extraction_width, prompt
)
fallback_reason = _fallback_reason(
result, bool(profile.config.get("fallbackWhenPassedUnknown", True))
)
if fallback_reason is None:
metadata.update(
{"requestedMode": "AUTO", "fallback": False, "fallbackReason": None}
)
return result, duration, metadata
except (VisionModelError, httpx.HTTPError) as exc:
fallback_reason = f"FAST_MODEL_ERROR:{type(exc).__name__}"
result, metadata = await _analyze_frames(
profile, "ACCURATE", frames, duration, extraction_width, prompt
)
metadata.update(
{"requestedMode": "AUTO", "fallback": True, "fallbackReason": fallback_reason}
)
return result, duration, metadata
finally:
for frame in frames:
frame.unlink(missing_ok=True)
frame_dir.rmdir()

1
data/artifacts/.gitkeep Normal file
View File

@ -0,0 +1 @@

1
data/jobs/.gitkeep Normal file
View File

@ -0,0 +1 @@

1
data/logs/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,19 @@
{
"code": "aima.power_state.v1",
"name": "爱玛车辆声音事件识别",
"analysisType": "AUDIO_CLASSIFICATION",
"minimumReferencesPerLabel": 3,
"maximumAudioSeconds": 30,
"labelNames": {
"POWER_ON": "开机",
"POWER_OFF": "关机",
"ARMED": "设防",
"DISARMED": "解防",
"UNKNOWN": "其他声音"
},
"decision": {
"maxDistance": 0.45,
"minDistanceMargin": 0.015,
"unknownLabel": "UNKNOWN"
}
}

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,30 @@
{
"code": "aima.power_video.v1",
"name": "爱玛测试视频分析",
"analysisType": "VIDEO_ANALYSIS",
"model": "qwen3-vl:32b",
"promptFile": "prompt.txt",
"think": false,
"temperature": 0.1,
"fallbackWhenPassedUnknown": true,
"strategies": {
"fast": {
"model": "qwen3-vl:8b-instruct",
"maxFrames": 6,
"maxWidth": 896,
"maxOutputTokens": 512,
"numCtx": 16384,
"emptyResponseRetries": 0,
"keepAlive": "30m"
},
"accurate": {
"model": "qwen3-vl:32b",
"maxFrames": 12,
"maxWidth": 1280,
"maxOutputTokens": 768,
"numCtx": 32768,
"emptyResponseRetries": 1,
"keepAlive": "30m"
}
}
}

View File

@ -0,0 +1 @@
You are an industrial test video analyst. Analyze only visible evidence in the ordered video frames. Do not invent events that are not visible. Return one JSON object with these fields: passed (boolean or null when the criterion is insufficient), conclusion (short string), summary (string), events (array of objects containing timeRange, event, confidence), and warnings (array of strings). Use Chinese for all explanatory text.

6
deploy/.env.example Normal file
View File

@ -0,0 +1,6 @@
API_KEY=replace-with-a-strong-random-token
MEDIA_TIMEOUT_SECONDS=120
OLLAMA_TIMEOUT_SECONDS=600
MAX_AUDIO_BYTES=104857600
MAX_VIDEO_BYTES=2147483648
MAX_VIDEO_FRAMES=24

37
deploy/compose.yaml Normal file
View File

@ -0,0 +1,37 @@
services:
analysis-service:
container_name: cmvr-ai-analysis
image: cmvr-ai-analysis:1.0.0
build:
context: ..
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "192.168.28.10:14080:8080"
env_file:
- .env
environment:
TZ: Asia/Shanghai
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
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ../data/profiles:/data/profiles:ro
- ../data/artifacts:/data/artifacts
- ../data/jobs:/data/jobs
- ../data/logs:/data/logs
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: json-file
options:
max-size: "100m"
max-file: "5"

2
requirements-dev.txt Normal file
View File

@ -0,0 +1,2 @@
-r requirements.txt
pytest==8.4.1

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
fastapi==0.116.1
httpx==0.28.1
numpy==2.2.6
pydantic-settings==2.10.1
python-multipart==0.0.20
scipy==1.15.3
uvicorn[standard]==0.35.0

View File

@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")/../deploy"
docker compose run --rm analysis-service \
python -m tools.build_audio_profile --profile-code "${1:-aima.power_state.v1}"

7
scripts/status.sh Normal file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")/../deploy"
docker compose ps
curl -fsS http://192.168.28.10:14080/health
printf '\n'

View File

@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")/../deploy"
docker compose run --rm analysis-service \
python -m tools.validate_audio_profile --profile-code "${1:-aima.power_state.v1}"

View File

@ -0,0 +1,64 @@
import json
import wave
from pathlib import Path
import numpy as np
from app.audio import build_audio_profile, classify_audio
from app.config import settings
from app.profile_store import Profile
def _write_tone(path: Path, frequency: float, duration: float = 1.2) -> None:
sample_rate = 16000
time = np.arange(int(sample_rate * duration)) / sample_rate
envelope = np.minimum(1.0, time * 8) * np.minimum(1.0, (duration - time) * 8)
samples = 0.7 * np.sin(2 * np.pi * frequency * time) * envelope
pcm = (samples * 32767).astype("<i2")
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(sample_rate)
output.writeframes(pcm.tobytes())
def test_audio_profile_classifies_and_rejects(tmp_path: Path) -> None:
profiles = tmp_path / "profiles"
artifacts = tmp_path / "artifacts"
settings.profiles_dir = profiles
settings.artifacts_dir = artifacts
directory = profiles / "test" / "tones" / "v1"
config = {
"code": "test.tones.v1",
"analysisType": "AUDIO_CLASSIFICATION",
"decision": {
"maxDistance": 0.45,
"minDistanceMargin": 0.02,
"unknownLabel": "UNKNOWN",
},
}
directory.mkdir(parents=True)
(directory / "profile.json").write_text(json.dumps(config), encoding="utf-8")
reference_tones = {
"POWER_ON": 440,
"POWER_OFF": 660,
"ARMED": 880,
"DISARMED": 1100,
}
for label, frequency in reference_tones.items():
_write_tone(directory / "references" / label / f"{label.lower()}.wav", frequency)
profile = Profile("test.tones.v1", "AUDIO_CLASSIFICATION", directory, config)
build_audio_profile(profile)
classified_paths = {}
for label, frequency in reference_tones.items():
path = tmp_path / f"{label.lower()}-test.wav"
_write_tone(path, frequency)
classified_paths[label] = path
other_path = tmp_path / "other-test.wav"
_write_tone(other_path, 1600)
for label, path in classified_paths.items():
assert classify_audio(profile, path)["label"] == label
assert classify_audio(profile, other_path)["label"] == "UNKNOWN"

137
tests/test_video.py Normal file
View File

@ -0,0 +1,137 @@
import asyncio
import httpx
from app import video
class _FakeResponse:
def __init__(self, body):
self.body = body
def raise_for_status(self):
return None
def json(self):
return self.body
class _FakeClient:
responses = []
payloads = []
def __init__(self, **_):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return None
async def post(self, _, json):
self.payloads.append(json)
return _FakeResponse(self.responses.pop(0))
def test_vision_request_retries_empty_content(monkeypatch):
_FakeClient.responses = [
{
"done_reason": "length",
"eval_count": 1024,
"message": {"content": "", "thinking": "reasoning"},
},
{
"done_reason": "stop",
"eval_count": 20,
"message": {"content": '{"passed": true}', "thinking": ""},
},
]
_FakeClient.payloads = []
monkeypatch.setattr(httpx, "AsyncClient", _FakeClient)
payload = {
"think": False,
"messages": [{"role": "user", "content": "Analyze"}],
"options": {"num_predict": 1024},
}
content, metadata = asyncio.run(video._request_vision_model(payload))
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"] == 1536
def test_fast_result_quality_controls_accurate_fallback():
complete = {
"passed": True,
"conclusion": "通过",
"summary": "状态正常",
"events": [],
"warnings": [],
}
assert video._fallback_reason(complete, True) is None
assert video._fallback_reason({**complete, "passed": None}, True) == "FAST_RESULT_INSUFFICIENT"
assert video._fallback_reason({**complete, "passed": None}, False) is None
assert video._fallback_reason({"summary": "缺少字段"}, True).startswith(
"FAST_RESULT_MISSING_FIELDS:"
)
def test_auto_mode_falls_back_to_accurate_result(monkeypatch, tmp_path):
frame_dir = tmp_path / "frames"
frame_dir.mkdir()
frame = frame_dir / "frame-001.jpg"
frame.write_bytes(b"frame")
calls = []
monkeypatch.setattr(
video,
"_extract_frames",
lambda *_: ([frame], 2.0, frame_dir),
)
async def fake_analyze(_, mode, *args):
calls.append(mode)
if mode == "FAST":
return (
{
"passed": None,
"conclusion": "证据不足",
"summary": "快速分析无法判断",
"events": [],
"warnings": [],
},
{"mode": "FAST", "model": "fast", "sampledFrameCount": 1, "maximumWidth": 896},
)
return (
{
"passed": True,
"conclusion": "通过",
"summary": "精确分析确认通过",
"events": [],
"warnings": [],
},
{"mode": "ACCURATE", "model": "accurate", "sampledFrameCount": 1, "maximumWidth": 1280},
)
monkeypatch.setattr(video, "_analyze_frames", fake_analyze)
profile_dir = tmp_path / "profile"
profile_dir.mkdir()
(profile_dir / "prompt.txt").write_text("Analyze", encoding="utf-8")
profile = video.Profile(
"test.video.v1",
"VIDEO_ANALYSIS",
profile_dir,
{"fallbackWhenPassedUnknown": True},
)
result, _, metadata = asyncio.run(video.analyze_video(profile, tmp_path / "video.mp4"))
assert calls == ["FAST", "ACCURATE"]
assert result["passed"] is True
assert metadata["fallback"] is True
assert metadata["fallbackReason"] == "FAST_RESULT_INSUFFICIENT"

1
tools/__init__.py Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,17 @@
import argparse
from app.audio import build_audio_profile
from app.profile_store import profile_store
def main() -> None:
parser = argparse.ArgumentParser(description="Build an audio reference feature library")
parser.add_argument("--profile-code", required=True)
args = parser.parse_args()
profile_store.reload()
profile = profile_store.get(args.profile_code, "AUDIO_CLASSIFICATION")
print(build_audio_profile(profile))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,73 @@
import argparse
import json
from collections import Counter, defaultdict
from typing import Any
import numpy as np
from app.audio import classify_sequence, load_reference_sequences
from app.profile_store import profile_store
def validate_profile(profile_code: str) -> dict[str, Any]:
profile_store.reload()
profile = profile_store.get(profile_code, "AUDIO_CLASSIFICATION")
sequences, labels, files = load_reference_sequences(profile)
confusion: dict[str, Counter[str]] = defaultdict(Counter)
details: list[dict[str, Any]] = []
correct = 0
rejected = 0
for index, (query, expected, file_name) in enumerate(zip(sequences, labels, files)):
other_indices = [candidate for candidate in range(len(sequences)) if candidate != index]
result = classify_sequence(
profile,
query,
[sequences[candidate] for candidate in other_indices],
labels[other_indices],
files[other_indices],
)
predicted = result["label"]
if predicted == profile.config.get("decision", {}).get("unknownLabel", "UNKNOWN"):
rejected += 1
if predicted == expected:
correct += 1
confusion[expected][predicted] += 1
ranking = sorted(result["scores"].items(), key=lambda item: item[1], reverse=True)
margin = ranking[0][1] - ranking[1][1] if len(ranking) > 1 else ranking[0][1]
details.append(
{
"file": file_name,
"expected": expected,
"predicted": predicted,
"confidence": result["confidence"],
"scoreMargin": round(margin, 6),
}
)
total = len(details)
return {
"profileCode": profile.code,
"method": "leave-one-out",
"total": total,
"correct": correct,
"accuracy": round(correct / total, 6) if total else 0.0,
"rejected": rejected,
"thresholds": profile.config.get("decision", {}),
"confusion": {label: dict(values) for label, values in sorted(confusion.items())},
"errors": [item for item in details if item["expected"] != item["predicted"]],
}
def main() -> None:
parser = argparse.ArgumentParser(description="Validate an audio feature library without self-matching")
parser.add_argument("--profile-code", required=True)
parser.add_argument("--fail-below", type=float, default=0.0)
args = parser.parse_args()
result = validate_profile(args.profile_code)
print(json.dumps(result, ensure_ascii=False, indent=2))
if result["accuracy"] < args.fail_below:
raise SystemExit(2)
if __name__ == "__main__":
main()