369e3de684
Video tag noise root cause: frames were a FIXED count (6) max-pooled — a tag firing on one frame survived at peak confidence, and a fixed count under-samples long multi-scene videos so real scene-local tags looked like noise. Redesign (operator-steered): - Sample at a fixed CADENCE — one frame every `video_frame_interval_seconds` (default 4) across the 5–95% window — so a tag's frame-presence reflects real screen time independent of video length. Capped at `video_max_frames` (default 64): a long video stretches the spacing instead of exploding into hundreds of inferences, bounding per-video cost on the single ml-worker (per-frame ffmpeg timeout also cut 60s→30s). - Aggregate with `_aggregate_video_predictions`: keep a tag only if it appears in >= `video_min_tag_frames` sampled frames (≈ that many × interval seconds on screen — duration-independent noise rejection), with confidence = MEAN over the frames it appears in (not max). Clamps the threshold to the sample count so a 1–2-frame short video still tags. - All three knobs are DB-backed ml_settings (migration 0053), patchable via /api/ml/settings + sliders in the ML settings card — replaces the VIDEO_ML_FRAMES env var (product-not-project). Tests: aggregation drops one-frame noise + means corroborated tags + clamps on short videos; settings round-trip + min>max validation. Replaced the _maxpool_predictions unit test. NOTE: this is the QUALITY half of #747. The perf half — the ml-worker runs CPU-only — is GPU enablement, tracked separately in #872. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
4.1 KiB
Python
120 lines
4.1 KiB
Python
"""ML admin API: settings, backfill trigger, centroid recompute trigger."""
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from ..extensions import get_session
|
|
from ..models import MLSettings
|
|
|
|
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
|
|
|
|
|
_EDITABLE = (
|
|
"suggestion_threshold_character",
|
|
"suggestion_threshold_general",
|
|
"centroid_similarity_threshold",
|
|
"min_reference_images",
|
|
"tagger_store_floor",
|
|
"video_frame_interval_seconds",
|
|
"video_max_frames",
|
|
"video_min_tag_frames",
|
|
)
|
|
|
|
|
|
@ml_admin_bp.route("/settings", methods=["GET"])
|
|
async def get_settings():
|
|
from sqlalchemy import select
|
|
|
|
async with get_session() as session:
|
|
s = (
|
|
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
|
).scalar_one()
|
|
return jsonify(
|
|
{
|
|
"suggestion_threshold_character": s.suggestion_threshold_character,
|
|
"suggestion_threshold_general": s.suggestion_threshold_general,
|
|
"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,
|
|
}
|
|
)
|
|
|
|
|
|
@ml_admin_bp.route("/settings", methods=["PATCH"])
|
|
async def patch_settings():
|
|
from sqlalchemy import select
|
|
|
|
body = await request.get_json()
|
|
if not isinstance(body, dict):
|
|
return jsonify({"error": "body must be an object"}), 400
|
|
async with get_session() as session:
|
|
s = (
|
|
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
|
).scalar_one()
|
|
|
|
# Merge the patch over current values, then validate the result as a
|
|
# whole — the store-floor invariant couples three fields, so they
|
|
# can't be checked one at a time.
|
|
proposed = {f: getattr(s, f) for f in _EDITABLE}
|
|
for field in _EDITABLE:
|
|
if field in body:
|
|
proposed[field] = body[field]
|
|
|
|
err = _validate(proposed)
|
|
if err is not None:
|
|
return jsonify({"error": err}), 400
|
|
|
|
for field in _EDITABLE:
|
|
setattr(s, field, proposed[field])
|
|
await session.commit()
|
|
return await get_settings()
|
|
|
|
|
|
def _validate(p: dict) -> str | None:
|
|
"""Returns an error string if the proposed settings are invalid, else None.
|
|
|
|
Invariant (plan-task #764): the per-category suggestion thresholds can't
|
|
drop below tagger_store_floor — nothing below the floor is stored, so a
|
|
lower threshold would silently surface nothing in that gap. The UI clamps
|
|
the sliders to the floor; this is the server-side backstop.
|
|
"""
|
|
floor = p["tagger_store_floor"]
|
|
if not (0.0 <= floor <= 1.0):
|
|
return "tagger_store_floor must be between 0 and 1"
|
|
for cat in ("character", "general"):
|
|
if p[f"suggestion_threshold_{cat}"] < floor:
|
|
return (
|
|
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
|
|
|
|
|
|
@ml_admin_bp.route("/backfill", methods=["POST"])
|
|
async def trigger_backfill():
|
|
from ..tasks.ml import backfill
|
|
|
|
r = backfill.delay()
|
|
return jsonify({"celery_task_id": r.id}), 202
|
|
|
|
|
|
@ml_admin_bp.route("/recompute-centroids", methods=["POST"])
|
|
async def trigger_recompute():
|
|
from ..tasks.ml import recompute_centroids
|
|
|
|
r = recompute_centroids.delay()
|
|
return jsonify({"celery_task_id": r.id}), 202
|