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",
|
"centroid_similarity_threshold",
|
||||||
"min_reference_images",
|
"min_reference_images",
|
||||||
"tagger_store_floor",
|
"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,
|
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
||||||
"min_reference_images": s.min_reference_images,
|
"min_reference_images": s.min_reference_images,
|
||||||
"tagger_store_floor": s.tagger_store_floor,
|
"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,
|
"tagger_model_version": s.tagger_model_version,
|
||||||
"embedder_model_version": s.embedder_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"suggestion_threshold_{cat} cannot be below tagger_store_floor "
|
||||||
f"({floor}) — predictions below the floor are not stored"
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ class MLSettings(Base):
|
|||||||
min_reference_images: Mapped[int] = mapped_column(
|
min_reference_images: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, default=5
|
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(
|
tagger_model_version: Mapped[str] = mapped_column(
|
||||||
String(128), nullable=False, default="camie-tagger-v2"
|
String(128), nullable=False, default="camie-tagger-v2"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""SigLIP SO400M image-embedding wrapper (PyTorch CPU).
|
"""SigLIP SO400M image-embedding wrapper (PyTorch CPU).
|
||||||
|
|
||||||
Direct port of ImageRepo's siglip.py. torch/transformers are imported
|
torch/transformers are imported lazily inside load() so this module can be
|
||||||
lazily inside load() so this module can be imported in the web container
|
imported in the web container (which never runs inference) without paying the
|
||||||
(which never runs inference) without paying the torch import cost.
|
torch import cost.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -13,6 +13,11 @@ from PIL import Image, ImageFile
|
|||||||
|
|
||||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
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(
|
MODEL_NAME = os.environ.get(
|
||||||
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
|
"SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
|
||||||
)
|
)
|
||||||
@@ -37,6 +42,9 @@ class Embedder:
|
|||||||
from transformers import AutoModel, SiglipImageProcessor
|
from transformers import AutoModel, SiglipImageProcessor
|
||||||
|
|
||||||
self._torch = torch
|
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
|
# FC's embedder only does IMAGE inference — never text. AutoProcessor
|
||||||
# loads the full processor including SiglipTokenizer, which requires
|
# loads the full processor including SiglipTokenizer, which requires
|
||||||
# the sentencepiece library at import time even if we never call it.
|
# 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
|
Single-image at a time. Loaded lazily inside the ml-worker process; NOT
|
||||||
process; NOT thread-safe — the ml queue worker must run --concurrency=1
|
thread-safe — the ml queue worker runs --concurrency=1 per process (scale ML by
|
||||||
(set by the FC-1 entrypoint).
|
running multiple worker replicas, not threads).
|
||||||
|
|
||||||
v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
|
v2 layout reference: HuggingFace Camais03/camie-tagger-v2 root has
|
||||||
camie-tagger-v2.onnx (789 MB) + camie-tagger-v2-metadata.json (7.77 MB)
|
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
|
import numpy as np
|
||||||
from PIL import Image, ImageFile
|
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
|
# 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
|
# lean web image or in CI. Imported lazily inside Tagger.load() so this module
|
||||||
# imports fine without it (the suggestion service imports SURFACED_CATEGORIES
|
# imports fine without it (the suggestion service imports SURFACED_CATEGORIES
|
||||||
@@ -117,8 +122,15 @@ class Tagger:
|
|||||||
# without onnxruntime (CI / lean web image).
|
# without onnxruntime (CI / lean web image).
|
||||||
import onnxruntime as ort
|
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(
|
session = ort.InferenceSession(
|
||||||
str(model_path), providers=["CPUExecutionProvider"]
|
str(model_path), sess_options=opts, providers=["CPUExecutionProvider"],
|
||||||
)
|
)
|
||||||
self._input_name = session.get_inputs()[0].name
|
self._input_name = session.get_inputs()[0].name
|
||||||
# Assign sentinels last so a partial load isn't observable.
|
# 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;
|
"""Run Camie + SigLIP on one image; store predictions + embedding;
|
||||||
then enqueue per-image allowlist application.
|
then enqueue per-image allowlist application.
|
||||||
|
|
||||||
Video: sample frames between 10% and 90% of duration (VIDEO_ML_FRAMES,
|
Video (#747): sample frames at a fixed cadence (ml_settings
|
||||||
default 6). Max-pool tagger confidences across frames, mean-pool the
|
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).
|
SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..services.ml.embedder import get_embedder
|
from ..services.ml.embedder import get_embedder
|
||||||
@@ -116,7 +117,9 @@ def tag_and_embed(self, image_id: int) -> dict:
|
|||||||
phase = "video_sample_frames"
|
phase = "video_sample_frames"
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
frames = _sample_video_frames(
|
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(
|
log.info(
|
||||||
"tag_and_embed sampled %d frame(s) in %.1fs: %s",
|
"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"
|
phase = "video_infer"
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
preds = _maxpool_predictions(
|
preds = _aggregate_video_predictions(
|
||||||
[tagger.infer(f, store_floor=settings.tagger_store_floor)
|
[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(
|
embedding = np.mean(
|
||||||
[embedder.infer(f) for f in frames], axis=0
|
[embedder.infer(f) for f in frames], axis=0
|
||||||
).astype("float32")
|
).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:
|
for f in frames:
|
||||||
f.unlink(missing_ok=True)
|
f.unlink(missing_ok=True)
|
||||||
else:
|
else:
|
||||||
@@ -208,9 +217,17 @@ def tag_and_embed(self, image_id: int) -> dict:
|
|||||||
return {"status": "ok", "image_id": image_id, "tags": len(preds)}
|
return {"status": "ok", "image_id": image_id, "tags": len(preds)}
|
||||||
|
|
||||||
|
|
||||||
def _sample_video_frames(src: Path, n: int) -> list[Path]:
|
def _sample_video_frames(
|
||||||
"""Extract n frames evenly between 10% and 90% of duration via ffmpeg.
|
src: Path, *, interval: float, max_frames: int,
|
||||||
Returns temp file paths (caller deletes). Empty list on failure."""
|
) -> 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 json
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -229,20 +246,25 @@ def _sample_video_frames(src: Path, n: int) -> list[Path]:
|
|||||||
if duration <= 0:
|
if duration <= 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
start, end = duration * 0.10, duration * 0.90
|
start, end = duration * 0.05, duration * 0.95
|
||||||
step = (end - start) / max(n - 1, 1)
|
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] = []
|
out: list[Path] = []
|
||||||
tmpdir = Path(tempfile.mkdtemp(prefix="fc_vid_"))
|
tmpdir = Path(tempfile.mkdtemp(prefix="fc_vid_"))
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
ts = start + i * step
|
ts = start + i * step
|
||||||
dest = tmpdir / f"frame_{i:02d}.jpg"
|
dest = tmpdir / f"frame_{i:04d}.jpg"
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
"ffmpeg", "-ss", f"{ts:.2f}", "-i", str(src),
|
"ffmpeg", "-ss", f"{ts:.2f}", "-i", str(src),
|
||||||
"-frames:v", "1", "-q:v", "3", "-y", str(dest),
|
"-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():
|
if dest.is_file():
|
||||||
out.append(dest)
|
out.append(dest)
|
||||||
@@ -251,18 +273,38 @@ def _sample_video_frames(src: Path, n: int) -> list[Path]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _maxpool_predictions(per_frame: list[dict]) -> dict:
|
def _aggregate_video_predictions(per_frame: list[dict], *, min_frames: int) -> dict:
|
||||||
"""Aggregate per-frame {name: TagPrediction} dicts by max confidence."""
|
"""Aggregate per-frame {name: TagPrediction} into one prediction set (#747).
|
||||||
merged: dict[str, dict] = {}
|
|
||||||
|
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 frame_preds in per_frame:
|
||||||
for name, p in frame_preds.items():
|
for name, p in frame_preds.items():
|
||||||
cur = merged.get(name)
|
cur = agg.get(name)
|
||||||
if cur is None or p.confidence > cur["confidence"]:
|
if cur is None:
|
||||||
merged[name] = {
|
agg[name] = {"category": p.category, "sum": p.confidence, "count": 1}
|
||||||
"category": p.category,
|
else:
|
||||||
"confidence": p.confidence,
|
cur["sum"] += p.confidence
|
||||||
}
|
cur["count"] += 1
|
||||||
return merged
|
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)
|
@celery.task(name="backend.app.tasks.ml.backfill", bind=True)
|
||||||
|
|||||||
@@ -30,6 +30,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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-card-text v-else><v-skeleton-loader type="paragraph" /></v-card-text>
|
<v-card-text v-else><v-skeleton-loader type="paragraph" /></v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -59,9 +92,14 @@ async function save() {
|
|||||||
const floor = local.tagger_store_floor
|
const floor = local.tagger_store_floor
|
||||||
local.suggestion_threshold_character = Math.max(local.suggestion_threshold_character, floor)
|
local.suggestion_threshold_character = Math.max(local.suggestion_threshold_character, floor)
|
||||||
local.suggestion_threshold_general = Math.max(local.suggestion_threshold_general, 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 = {}
|
const patch = {}
|
||||||
for (const f of fields) patch[f.key] = local[f.key]
|
for (const f of fields) patch[f.key] = local[f.key]
|
||||||
patch.tagger_store_floor = local.tagger_store_floor
|
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) }
|
try { await store.patchSettings(patch) }
|
||||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
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"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_backfill_and_recompute_trigger(client):
|
async def test_backfill_and_recompute_trigger(client):
|
||||||
r1 = await client.post("/api/ml/backfill")
|
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
|
"""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
|
the pure helpers (_aggregate_video_predictions, _is_video) as unit tests, and
|
||||||
DB-touching backfill query as an integration test with monkeypatched
|
the DB-touching backfill query as an integration test with monkeypatched
|
||||||
inference.
|
inference.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backend.app.services.ml.tagger import TagPrediction
|
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():
|
def test_is_video():
|
||||||
@@ -18,15 +18,32 @@ def test_is_video():
|
|||||||
assert _is_video(Path("a.jpg")) is False
|
assert _is_video(Path("a.jpg")) is False
|
||||||
|
|
||||||
|
|
||||||
def test_maxpool_predictions():
|
def _pred(name, conf, cat="general"):
|
||||||
f1 = {"smile": TagPrediction("smile", "general", 0.6)}
|
return {name: TagPrediction(name, cat, conf)}
|
||||||
f2 = {
|
|
||||||
"smile": TagPrediction("smile", "general", 0.9),
|
|
||||||
"sword": TagPrediction("sword", "general", 0.7),
|
def test_aggregate_video_keeps_corroborated_and_means():
|
||||||
}
|
# #747: 4 frames; "smile" in 3, "sword" in 1 (noise). min_frames=2.
|
||||||
merged = _maxpool_predictions([f1, f2])
|
per_frame = [
|
||||||
assert merged["smile"]["confidence"] == 0.9
|
{"smile": TagPrediction("smile", "general", 0.6),
|
||||||
assert merged["sword"]["confidence"] == 0.7
|
"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
|
@pytest.mark.integration
|
||||||
|
|||||||
Reference in New Issue
Block a user