Merge pull request 'Video tag quality: cadence sampling + min-frame aggregation + ML thread cap (#747)' (#111) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m15s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m15s
This commit was merged in pull request #111.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""ml_settings: video tagging knobs (cadence sampling + noise floor)
|
||||
|
||||
#747. Video tag quality/perf: sample frames at a fixed cadence (interval) so a
|
||||
tag's frame-presence reflects real screen time, cap total frames so long videos
|
||||
stay bounded, and keep a tag only if it appears in >= min_tag_frames sampled
|
||||
frames. Operator-tunable via Settings → ML (replaces the VIDEO_ML_FRAMES env var).
|
||||
|
||||
Revision ID: 0053
|
||||
Revises: 0052
|
||||
Create Date: 2026-06-16
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0053"
|
||||
down_revision: Union[str, None] = "0052"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"video_frame_interval_seconds", sa.Float(), nullable=False,
|
||||
server_default="4.0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"video_max_frames", sa.Integer(), nullable=False, server_default="64",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ml_settings",
|
||||
sa.Column(
|
||||
"video_min_tag_frames", sa.Integer(), nullable=False,
|
||||
server_default="3",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ml_settings", "video_min_tag_frames")
|
||||
op.drop_column("ml_settings", "video_max_frames")
|
||||
op.drop_column("ml_settings", "video_frame_interval_seconds")
|
||||
@@ -14,6 +14,9 @@ _EDITABLE = (
|
||||
"centroid_similarity_threshold",
|
||||
"min_reference_images",
|
||||
"tagger_store_floor",
|
||||
"video_frame_interval_seconds",
|
||||
"video_max_frames",
|
||||
"video_min_tag_frames",
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +35,9 @@ async def get_settings():
|
||||
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
||||
"min_reference_images": s.min_reference_images,
|
||||
"tagger_store_floor": s.tagger_store_floor,
|
||||
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
||||
"video_max_frames": s.video_max_frames,
|
||||
"video_min_tag_frames": s.video_min_tag_frames,
|
||||
"tagger_model_version": s.tagger_model_version,
|
||||
"embedder_model_version": s.embedder_model_version,
|
||||
}
|
||||
@@ -85,6 +91,15 @@ def _validate(p: dict) -> str | None:
|
||||
f"suggestion_threshold_{cat} cannot be below tagger_store_floor "
|
||||
f"({floor}) — predictions below the floor are not stored"
|
||||
)
|
||||
# Video tagging (#747).
|
||||
if p["video_frame_interval_seconds"] <= 0:
|
||||
return "video_frame_interval_seconds must be > 0"
|
||||
if p["video_max_frames"] < 1:
|
||||
return "video_max_frames must be >= 1"
|
||||
if p["video_min_tag_frames"] < 1:
|
||||
return "video_min_tag_frames must be >= 1"
|
||||
if p["video_min_tag_frames"] > p["video_max_frames"]:
|
||||
return "video_min_tag_frames cannot exceed video_max_frames"
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,21 @@ class MLSettings(Base):
|
||||
min_reference_images: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=5
|
||||
)
|
||||
# Video tagging (#747). Sample one frame every N seconds (fixed CADENCE, not a
|
||||
# fixed count) so a tag's frame-presence reflects real screen time regardless
|
||||
# of video length; cap the total so a long video can't explode into hundreds
|
||||
# of inferences (the cadence stretches past the cap). A tag is kept only if it
|
||||
# appears in >= video_min_tag_frames sampled frames (≈ that many × interval
|
||||
# seconds on screen) — duration-independent noise rejection. Operator-tunable.
|
||||
video_frame_interval_seconds: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=4.0
|
||||
)
|
||||
video_max_frames: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=64
|
||||
)
|
||||
video_min_tag_frames: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=3
|
||||
)
|
||||
tagger_model_version: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, default="camie-tagger-v2"
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""SigLIP SO400M image-embedding wrapper (PyTorch CPU).
|
||||
|
||||
Direct port of ImageRepo's siglip.py. torch/transformers are imported
|
||||
lazily inside load() so this module can be imported in the web container
|
||||
(which never runs inference) without paying the torch import cost.
|
||||
torch/transformers are imported lazily inside load() so this module can be
|
||||
imported in the web container (which never runs inference) without paying the
|
||||
torch import cost.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -13,6 +13,11 @@ from PIL import Image, ImageFile
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
# Cap torch's intra-op threads so each ml-worker replica is a bounded core
|
||||
# consumer on a shared node (torch otherwise uses all cores). Keep
|
||||
# N_replicas × this within the cores allotted to ML to avoid oversubscription.
|
||||
_INTRA_OP_THREADS = 4
|
||||
|
||||
MODEL_NAME = os.environ.get(
|
||||
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
|
||||
)
|
||||
@@ -37,6 +42,9 @@ class Embedder:
|
||||
from transformers import AutoModel, SiglipImageProcessor
|
||||
|
||||
self._torch = torch
|
||||
# Bound torch's CPU thread pool (see _INTRA_OP_THREADS) so each replica
|
||||
# stays a predictable core consumer on a shared node.
|
||||
torch.set_num_threads(_INTRA_OP_THREADS)
|
||||
# FC's embedder only does IMAGE inference — never text. AutoProcessor
|
||||
# loads the full processor including SiglipTokenizer, which requires
|
||||
# the sentencepiece library at import time even if we never call it.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Camie-tagger-v2 ONNX wrapper.
|
||||
"""Camie-tagger-v2 ONNX wrapper (CPU).
|
||||
|
||||
CPU-only, single-image at a time. Loaded lazily inside the ml-worker
|
||||
process; NOT thread-safe — the ml queue worker must run --concurrency=1
|
||||
(set by the FC-1 entrypoint).
|
||||
Single-image at a time. Loaded lazily inside the ml-worker process; NOT
|
||||
thread-safe — the ml queue worker runs --concurrency=1 per process (scale ML by
|
||||
running multiple worker replicas, not threads).
|
||||
|
||||
v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
|
||||
camie-tagger-v2.onnx (789 MB) + camie-tagger-v2-metadata.json (7.77 MB)
|
||||
@@ -19,6 +19,11 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFile
|
||||
|
||||
# Cap inference threads (see Tagger.load) so each ml-worker replica is a bounded
|
||||
# core consumer on a shared node — keep N_replicas × this within the cores
|
||||
# allotted to ML so replicas don't oversubscribe the box / starve the DB.
|
||||
_INTRA_OP_THREADS = 4
|
||||
|
||||
# onnxruntime lives in requirements-ml.txt only — it is NOT installed in the
|
||||
# lean web image or in CI. Imported lazily inside Tagger.load() so this module
|
||||
# imports fine without it (the suggestion service imports SURFACED_CATEGORIES
|
||||
@@ -117,8 +122,15 @@ class Tagger:
|
||||
# without onnxruntime (CI / lean web image).
|
||||
import onnxruntime as ort
|
||||
|
||||
# Cap the intra-op thread pool. ONNX Runtime otherwise sizes it to ALL
|
||||
# host cores, so on a shared node each ml-worker replica would grab every
|
||||
# core and oversubscribe (and starve the co-located DB/web). Bounding it
|
||||
# makes each replica a predictable core consumer — run N replicas where
|
||||
# N × _INTRA_OP_THREADS stays within the cores you allot to ML.
|
||||
opts = ort.SessionOptions()
|
||||
opts.intra_op_num_threads = _INTRA_OP_THREADS
|
||||
session = ort.InferenceSession(
|
||||
str(model_path), providers=["CPUExecutionProvider"]
|
||||
str(model_path), sess_options=opts, providers=["CPUExecutionProvider"],
|
||||
)
|
||||
self._input_name = session.get_inputs()[0].name
|
||||
# Assign sentinels last so a partial load isn't observable.
|
||||
|
||||
+65
-23
@@ -49,11 +49,12 @@ def tag_and_embed(self, image_id: int) -> dict:
|
||||
"""Run Camie + SigLIP on one image; store predictions + embedding;
|
||||
then enqueue per-image allowlist application.
|
||||
|
||||
Video: sample frames between 10% and 90% of duration (VIDEO_ML_FRAMES,
|
||||
default 6). Max-pool tagger confidences across frames, mean-pool the
|
||||
Video (#747): sample frames at a fixed cadence (ml_settings
|
||||
video_frame_interval_seconds, capped at video_max_frames), keep a tag only if
|
||||
it appears in >= video_min_tag_frames frames and average its confidence over
|
||||
those frames (mean-pool, not max — kills one-frame noise); mean-pool the
|
||||
SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..services.ml.embedder import get_embedder
|
||||
@@ -116,7 +117,9 @@ def tag_and_embed(self, image_id: int) -> dict:
|
||||
phase = "video_sample_frames"
|
||||
t0 = time.monotonic()
|
||||
frames = _sample_video_frames(
|
||||
src, int(os.environ.get("VIDEO_ML_FRAMES", "6"))
|
||||
src,
|
||||
interval=settings.video_frame_interval_seconds,
|
||||
max_frames=settings.video_max_frames,
|
||||
)
|
||||
log.info(
|
||||
"tag_and_embed sampled %d frame(s) in %.1fs: %s",
|
||||
@@ -127,13 +130,19 @@ def tag_and_embed(self, image_id: int) -> dict:
|
||||
phase = "video_infer"
|
||||
import numpy as np
|
||||
|
||||
preds = _maxpool_predictions(
|
||||
preds = _aggregate_video_predictions(
|
||||
[tagger.infer(f, store_floor=settings.tagger_store_floor)
|
||||
for f in frames]
|
||||
for f in frames],
|
||||
min_frames=settings.video_min_tag_frames,
|
||||
)
|
||||
embedding = np.mean(
|
||||
[embedder.infer(f) for f in frames], axis=0
|
||||
).astype("float32")
|
||||
log.info(
|
||||
"tag_and_embed video aggregated %d tag(s) from %d frame(s) "
|
||||
"(min_frames=%d): %s",
|
||||
len(preds), len(frames), settings.video_min_tag_frames, ctx,
|
||||
)
|
||||
for f in frames:
|
||||
f.unlink(missing_ok=True)
|
||||
else:
|
||||
@@ -208,9 +217,17 @@ def tag_and_embed(self, image_id: int) -> dict:
|
||||
return {"status": "ok", "image_id": image_id, "tags": len(preds)}
|
||||
|
||||
|
||||
def _sample_video_frames(src: Path, n: int) -> list[Path]:
|
||||
"""Extract n frames evenly between 10% and 90% of duration via ffmpeg.
|
||||
Returns temp file paths (caller deletes). Empty list on failure."""
|
||||
def _sample_video_frames(
|
||||
src: Path, *, interval: float, max_frames: int,
|
||||
) -> list[Path]:
|
||||
"""Sample frames at a fixed CADENCE — one every `interval` seconds — so a
|
||||
tag's frame-presence reflects real screen time regardless of video length
|
||||
(#747). The count is capped at `max_frames`: a video longer than
|
||||
interval×max_frames stretches the spacing instead of exploding the frame
|
||||
count (keeps cost bounded so a long video can't hog the single ml-worker).
|
||||
Frames are taken across the 5%–95% window (skip intro/outro black/cards) via
|
||||
per-frame fast-seek. Returns temp file paths (caller deletes); [] on failure.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -229,20 +246,25 @@ def _sample_video_frames(src: Path, n: int) -> list[Path]:
|
||||
if duration <= 0:
|
||||
return []
|
||||
|
||||
start, end = duration * 0.10, duration * 0.90
|
||||
step = (end - start) / max(n - 1, 1)
|
||||
start, end = duration * 0.05, duration * 0.95
|
||||
span = max(end - start, 0.0)
|
||||
# Cadence count, clamped to [1, max_frames]. int(span/interval)+1 ≈ one frame
|
||||
# per `interval` seconds across the window; the cap stretches spacing on very
|
||||
# long videos.
|
||||
n = max(1, min(int(span / interval) + 1, max(1, max_frames)))
|
||||
step = span / max(n - 1, 1)
|
||||
out: list[Path] = []
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="fc_vid_"))
|
||||
for i in range(n):
|
||||
ts = start + i * step
|
||||
dest = tmpdir / f"frame_{i:02d}.jpg"
|
||||
dest = tmpdir / f"frame_{i:04d}.jpg"
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-ss", f"{ts:.2f}", "-i", str(src),
|
||||
"-frames:v", "1", "-q:v", "3", "-y", str(dest),
|
||||
],
|
||||
check=True, capture_output=True, timeout=60,
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
if dest.is_file():
|
||||
out.append(dest)
|
||||
@@ -251,18 +273,38 @@ def _sample_video_frames(src: Path, n: int) -> list[Path]:
|
||||
return out
|
||||
|
||||
|
||||
def _maxpool_predictions(per_frame: list[dict]) -> dict:
|
||||
"""Aggregate per-frame {name: TagPrediction} dicts by max confidence."""
|
||||
merged: dict[str, dict] = {}
|
||||
def _aggregate_video_predictions(per_frame: list[dict], *, min_frames: int) -> dict:
|
||||
"""Aggregate per-frame {name: TagPrediction} into one prediction set (#747).
|
||||
|
||||
A tag is kept only if it appears (≥ the tagger store floor, already applied)
|
||||
in at least `min_frames` of the sampled frames — because sampling is at a
|
||||
fixed cadence, that means it was on screen for roughly min_frames×interval
|
||||
seconds, so a single-frame flicker / scene-transition artifact is dropped
|
||||
while a genuine scene-local tag in a long video survives. Confidence is the
|
||||
MEAN over the frames where the tag appears (not max — max re-inflated the
|
||||
one-frame noise this whole change exists to remove).
|
||||
|
||||
`min_frames` is clamped to the number of frames actually sampled so a very
|
||||
short video (1–2 frames) still tags instead of dropping everything.
|
||||
"""
|
||||
n = len(per_frame)
|
||||
if n == 0:
|
||||
return {}
|
||||
threshold = max(1, min(min_frames, n))
|
||||
agg: dict[str, dict] = {}
|
||||
for frame_preds in per_frame:
|
||||
for name, p in frame_preds.items():
|
||||
cur = merged.get(name)
|
||||
if cur is None or p.confidence > cur["confidence"]:
|
||||
merged[name] = {
|
||||
"category": p.category,
|
||||
"confidence": p.confidence,
|
||||
}
|
||||
return merged
|
||||
cur = agg.get(name)
|
||||
if cur is None:
|
||||
agg[name] = {"category": p.category, "sum": p.confidence, "count": 1}
|
||||
else:
|
||||
cur["sum"] += p.confidence
|
||||
cur["count"] += 1
|
||||
return {
|
||||
name: {"category": v["category"], "confidence": v["sum"] / v["count"]}
|
||||
for name, v in agg.items()
|
||||
if v["count"] >= threshold
|
||||
}
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.ml.backfill", bind=True)
|
||||
|
||||
@@ -30,6 +30,39 @@
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<div class="text-subtitle-2 mb-1">Video tagging</div>
|
||||
<div class="text-caption fc-muted mb-3">
|
||||
Videos are tagged by sampling frames at a fixed cadence. A tag is kept
|
||||
only if it shows up in enough frames (≈ that many × the interval in
|
||||
seconds of screen time), which filters one-frame noise without losing
|
||||
tags that only appear in part of a longer video.
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model.number="local.video_frame_interval_seconds"
|
||||
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
||||
density="comfortable" hide-details @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model.number="local.video_max_frames"
|
||||
label="Max frames" type="number" min="1" step="1"
|
||||
density="comfortable" hide-details @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model.number="local.video_min_tag_frames"
|
||||
label="Min frames per tag" type="number" min="1" step="1"
|
||||
density="comfortable" hide-details @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text v-else><v-skeleton-loader type="paragraph" /></v-card-text>
|
||||
</v-card>
|
||||
@@ -59,9 +92,14 @@ async function save() {
|
||||
const floor = local.tagger_store_floor
|
||||
local.suggestion_threshold_character = Math.max(local.suggestion_threshold_character, floor)
|
||||
local.suggestion_threshold_general = Math.max(local.suggestion_threshold_general, floor)
|
||||
// Mirror the server invariant: a tag can't require more frames than are sampled.
|
||||
local.video_min_tag_frames = Math.min(local.video_min_tag_frames, local.video_max_frames)
|
||||
const patch = {}
|
||||
for (const f of fields) patch[f.key] = local[f.key]
|
||||
patch.tagger_store_floor = local.tagger_store_floor
|
||||
patch.video_frame_interval_seconds = local.video_frame_interval_seconds
|
||||
patch.video_max_frames = local.video_max_frames
|
||||
patch.video_min_tag_frames = local.video_min_tag_frames
|
||||
try { await store.patchSettings(patch) }
|
||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
||||
}
|
||||
|
||||
@@ -56,6 +56,36 @@ async def test_suggestion_threshold_below_store_floor_rejected(client):
|
||||
assert "tagger_store_floor" in (await resp.get_json())["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_tagging_settings_default_and_patch(client):
|
||||
"""#747: video cadence/noise knobs are exposed + patchable."""
|
||||
body = await (await client.get("/api/ml/settings")).get_json()
|
||||
assert body["video_frame_interval_seconds"] == pytest.approx(4.0)
|
||||
assert body["video_max_frames"] == 64
|
||||
assert body["video_min_tag_frames"] == 3
|
||||
|
||||
resp = await client.patch(
|
||||
"/api/ml/settings",
|
||||
json={"video_frame_interval_seconds": 5, "video_max_frames": 40,
|
||||
"video_min_tag_frames": 4},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
out = await resp.get_json()
|
||||
assert out["video_frame_interval_seconds"] == pytest.approx(5.0)
|
||||
assert out["video_max_frames"] == 40
|
||||
assert out["video_min_tag_frames"] == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_min_tag_frames_above_max_rejected(client):
|
||||
resp = await client.patch(
|
||||
"/api/ml/settings",
|
||||
json={"video_max_frames": 10, "video_min_tag_frames": 20},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "video_min_tag_frames" in (await resp.get_json())["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_and_recompute_trigger(client):
|
||||
r1 = await client.post("/api/ml/backfill")
|
||||
|
||||
+29
-12
@@ -1,6 +1,6 @@
|
||||
"""tag_and_embed / backfill task tests. Models aren't in CI, so we test
|
||||
the pure helpers (_maxpool_predictions, _is_video) as unit tests, and the
|
||||
DB-touching backfill query as an integration test with monkeypatched
|
||||
the pure helpers (_aggregate_video_predictions, _is_video) as unit tests, and
|
||||
the DB-touching backfill query as an integration test with monkeypatched
|
||||
inference.
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from backend.app.services.ml.tagger import TagPrediction
|
||||
from backend.app.tasks.ml import _is_video, _maxpool_predictions
|
||||
from backend.app.tasks.ml import _aggregate_video_predictions, _is_video
|
||||
|
||||
|
||||
def test_is_video():
|
||||
@@ -18,15 +18,32 @@ def test_is_video():
|
||||
assert _is_video(Path("a.jpg")) is False
|
||||
|
||||
|
||||
def test_maxpool_predictions():
|
||||
f1 = {"smile": TagPrediction("smile", "general", 0.6)}
|
||||
f2 = {
|
||||
"smile": TagPrediction("smile", "general", 0.9),
|
||||
"sword": TagPrediction("sword", "general", 0.7),
|
||||
}
|
||||
merged = _maxpool_predictions([f1, f2])
|
||||
assert merged["smile"]["confidence"] == 0.9
|
||||
assert merged["sword"]["confidence"] == 0.7
|
||||
def _pred(name, conf, cat="general"):
|
||||
return {name: TagPrediction(name, cat, conf)}
|
||||
|
||||
|
||||
def test_aggregate_video_keeps_corroborated_and_means():
|
||||
# #747: 4 frames; "smile" in 3, "sword" in 1 (noise). min_frames=2.
|
||||
per_frame = [
|
||||
{"smile": TagPrediction("smile", "general", 0.6),
|
||||
"sword": TagPrediction("sword", "general", 0.9)},
|
||||
_pred("smile", 0.8),
|
||||
_pred("smile", 0.7),
|
||||
{},
|
||||
]
|
||||
out = _aggregate_video_predictions(per_frame, min_frames=2)
|
||||
assert "sword" not in out # one-frame flicker dropped
|
||||
assert abs(out["smile"]["confidence"] - (0.6 + 0.8 + 0.7) / 3) < 1e-9 # mean, not max
|
||||
|
||||
|
||||
def test_aggregate_video_clamps_min_frames_to_sample_count():
|
||||
# Short video: 1 frame but min_frames=3 — clamp so it still tags.
|
||||
out = _aggregate_video_predictions([_pred("solo", 0.8)], min_frames=3)
|
||||
assert out["solo"]["confidence"] == 0.8
|
||||
|
||||
|
||||
def test_aggregate_video_empty():
|
||||
assert _aggregate_video_predictions([], min_frames=3) == {}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
Reference in New Issue
Block a user