feat: add media analysis service
This commit is contained in:
commit
a0d2faac55
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
.git
|
||||
.idea
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
data
|
||||
tests
|
||||
9
.gitattributes
vendored
Normal file
9
.gitattributes
vendored
Normal 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
9
.gitignore
vendored
Normal 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
19
Dockerfile
Normal 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
61
README.md
Normal 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
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
241
app/audio.py
Normal file
241
app/audio.py
Normal 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
22
app/config.py
Normal 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
116
app/main.py
Normal 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
72
app/media.py
Normal 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
70
app/profile_store.py
Normal 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
28
app/schemas.py
Normal 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
335
app/video.py
Normal 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
1
data/artifacts/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
data/jobs/.gitkeep
Normal file
1
data/jobs/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
data/logs/.gitkeep
Normal file
1
data/logs/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
19
data/profiles/aima/power-state/v1/profile.json
Normal file
19
data/profiles/aima/power-state/v1/profile.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
30
data/profiles/aima/power-video/v1/profile.json
Normal file
30
data/profiles/aima/power-video/v1/profile.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
data/profiles/aima/power-video/v1/prompt.txt
Normal file
1
data/profiles/aima/power-video/v1/prompt.txt
Normal 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
6
deploy/.env.example
Normal 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
37
deploy/compose.yaml
Normal 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
2
requirements-dev.txt
Normal file
@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==8.4.1
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal 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
|
||||
6
scripts/build-audio-profile.sh
Normal file
6
scripts/build-audio-profile.sh
Normal 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
7
scripts/status.sh
Normal 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'
|
||||
6
scripts/validate-audio-profile.sh
Normal file
6
scripts/validate-audio-profile.sh
Normal 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}"
|
||||
64
tests/test_audio_classifier.py
Normal file
64
tests/test_audio_classifier.py
Normal 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
137
tests/test_video.py
Normal 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
1
tools/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
17
tools/build_audio_profile.py
Normal file
17
tools/build_audio_profile.py
Normal 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()
|
||||
73
tools/validate_audio_profile.py
Normal file
73
tools/validate_audio_profile.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user