refactor(ml): drop dead tagger/suggestion settings + columns (#1199)
Hygiene follow-up to the Camie retirement (#1189) — these were left inert to bound that change; nothing reads them now. Migration 0068 drops: - ml_settings: tagger_store_floor, tagger_model_version, suggestion_threshold_ character/general (already dead pre-retirement — scoring uses per-head thresholds), video_min_tag_frames (only the deleted video-prediction aggregator used it). - image_record: tagger_model_version (no writer), centroid_scores (dead JSON cache, no reader). Also: ml_admin _EDITABLE/GET/_validate pruned (dropped the store-floor invariant + video_min_tag_frames check); MLThresholdSliders trimmed to a video-embedding card (interval + max frames only); importer no longer resets the dropped cols; download_models drops the Camie fetch; stale CASCADE comments in cleanup_service no longer name the removed tables. Tests updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
"""drop dead tagger/suggestion settings + columns left after Camie retirement (#1199)
|
||||||
|
|
||||||
|
Hygiene follow-up to #1189. These were left inert to bound that change; nothing
|
||||||
|
reads them now:
|
||||||
|
- ml_settings: tagger_store_floor + tagger_model_version (only the deleted Camie
|
||||||
|
tagger used them), suggestion_threshold_character/general (already dead pre-
|
||||||
|
retirement — scoring uses per-head thresholds), video_min_tag_frames (only the
|
||||||
|
deleted video-prediction aggregator used it).
|
||||||
|
- image_record: tagger_model_version (no writer now), centroid_scores (long-dead
|
||||||
|
JSON cache, no reader).
|
||||||
|
|
||||||
|
Revision ID: 0068
|
||||||
|
Revises: 0067
|
||||||
|
Create Date: 2026-06-30
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0068"
|
||||||
|
down_revision: Union[str, None] = "0067"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("ml_settings", "suggestion_threshold_character")
|
||||||
|
op.drop_column("ml_settings", "suggestion_threshold_general")
|
||||||
|
op.drop_column("ml_settings", "tagger_store_floor")
|
||||||
|
op.drop_column("ml_settings", "video_min_tag_frames")
|
||||||
|
op.drop_column("ml_settings", "tagger_model_version")
|
||||||
|
op.drop_column("image_record", "tagger_model_version")
|
||||||
|
op.drop_column("image_record", "centroid_scores")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"image_record",
|
||||||
|
sa.Column("centroid_scores", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"image_record",
|
||||||
|
sa.Column("tagger_model_version", sa.String(length=128), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"tagger_model_version", sa.String(length=128), nullable=False,
|
||||||
|
server_default="camie-tagger-v2",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"video_min_tag_frames", sa.Integer(), nullable=False,
|
||||||
|
server_default="3",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"tagger_store_floor", sa.Float(), nullable=False,
|
||||||
|
server_default="0.7",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"suggestion_threshold_general", sa.Float(), nullable=False,
|
||||||
|
server_default="0.7",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ml_settings",
|
||||||
|
sa.Column(
|
||||||
|
"suggestion_threshold_character", sa.Float(), nullable=False,
|
||||||
|
server_default="0.7",
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -9,12 +9,8 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
|||||||
|
|
||||||
|
|
||||||
_EDITABLE = (
|
_EDITABLE = (
|
||||||
"suggestion_threshold_character",
|
|
||||||
"suggestion_threshold_general",
|
|
||||||
"tagger_store_floor",
|
|
||||||
"video_frame_interval_seconds",
|
"video_frame_interval_seconds",
|
||||||
"video_max_frames",
|
"video_max_frames",
|
||||||
"video_min_tag_frames",
|
|
||||||
"head_min_positives",
|
"head_min_positives",
|
||||||
"head_auto_apply_precision",
|
"head_auto_apply_precision",
|
||||||
"head_auto_apply_enabled",
|
"head_auto_apply_enabled",
|
||||||
@@ -37,13 +33,8 @@ async def get_settings():
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"suggestion_threshold_character": s.suggestion_threshold_character,
|
|
||||||
"suggestion_threshold_general": s.suggestion_threshold_general,
|
|
||||||
"tagger_store_floor": s.tagger_store_floor,
|
|
||||||
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
||||||
"video_max_frames": s.video_max_frames,
|
"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,
|
"embedder_model_version": s.embedder_model_version,
|
||||||
"head_min_positives": s.head_min_positives,
|
"head_min_positives": s.head_min_positives,
|
||||||
"head_auto_apply_precision": s.head_auto_apply_precision,
|
"head_auto_apply_precision": s.head_auto_apply_precision,
|
||||||
@@ -88,31 +79,12 @@ async def patch_settings():
|
|||||||
|
|
||||||
|
|
||||||
def _validate(p: dict) -> str | None:
|
def _validate(p: dict) -> str | None:
|
||||||
"""Returns an error string if the proposed settings are invalid, else None.
|
"""Returns an error string if the proposed settings are invalid, else None."""
|
||||||
|
# Video embedding (#747).
|
||||||
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:
|
if p["video_frame_interval_seconds"] <= 0:
|
||||||
return "video_frame_interval_seconds must be > 0"
|
return "video_frame_interval_seconds must be > 0"
|
||||||
if p["video_max_frames"] < 1:
|
if p["video_max_frames"] < 1:
|
||||||
return "video_max_frames must be >= 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"
|
|
||||||
# Head training (#114).
|
# Head training (#114).
|
||||||
if int(p["head_min_positives"]) < 1:
|
if int(p["head_min_positives"]) < 1:
|
||||||
return "head_min_positives must be >= 1"
|
return "head_min_positives must be >= 1"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from datetime import datetime
|
|||||||
|
|
||||||
from pgvector.sqlalchemy import Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
|
||||||
BigInteger,
|
BigInteger,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
@@ -77,19 +76,13 @@ class ImageRecord(Base):
|
|||||||
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# ML fields (populated by FC-2's ml-worker). Per-tag predictions live in the
|
# ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
|
||||||
# normalized image_prediction table (#768) — the tagger_predictions JSON
|
# embedding dim; siglip_model_version stamps which model produced it (so an
|
||||||
# column was dropped in migration 0046. tagger_model_version stays as the
|
# operator model swap, #1190, can re-embed the stale rows). A different-dim
|
||||||
# "has this been tagged / is it current?" signal the backfill sweep reads.
|
# model would need a column-width migration.
|
||||||
tagger_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
||||||
# 1152 = SigLIP-so400m embedding dim. Swapping models in FC-2 may require
|
|
||||||
# a column-width migration.
|
|
||||||
siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
|
siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
|
||||||
siglip_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
siglip_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
|
||||||
# Centroid score cache (populated post-tagging)
|
|
||||||
centroid_scores: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,39 +23,16 @@ class MLSettings(Base):
|
|||||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
suggestion_threshold_character: Mapped[float] = mapped_column(
|
# Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
|
||||||
Float, nullable=False, default=0.70
|
# a fixed count) so coverage reflects real screen time regardless of length;
|
||||||
)
|
# cap the total so a long video can't explode into hundreds of embeds. The
|
||||||
# Default raised 0.50 → 0.70 on 2026-06-02 — operator-flagged 0.50
|
# per-frame SigLIP embeddings are mean-pooled. Operator-tunable.
|
||||||
# surfaced too many low-confidence picks; 0.70 keeps the rail
|
|
||||||
# signal-rich while still surfacing more than the original 0.95
|
|
||||||
# which hid almost everything. Operator-tunable via Settings → ML.
|
|
||||||
suggestion_threshold_general: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.70
|
|
||||||
)
|
|
||||||
# Ingest floor: tagger predictions below this confidence are not stored
|
|
||||||
# (tagger.Tagger.infer). Default 0.70 — the suggestion path already filters
|
|
||||||
# there, so the sub-0.70 tail is redundant weight (it had bloated
|
|
||||||
# image_record's TOAST to ~100 GB; plan-task #764). Operator-tunable via
|
|
||||||
# Settings → ML; must stay ≤ the suggestion thresholds.
|
|
||||||
tagger_store_floor: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False, default=0.70
|
|
||||||
)
|
|
||||||
# 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(
|
video_frame_interval_seconds: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=4.0
|
Float, nullable=False, default=4.0
|
||||||
)
|
)
|
||||||
video_max_frames: Mapped[int] = mapped_column(
|
video_max_frames: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, default=64
|
Integer, nullable=False, default=64
|
||||||
)
|
)
|
||||||
video_min_tag_frames: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False, default=3
|
|
||||||
)
|
|
||||||
# Tagging-v2 head training (#114). The head is the suggestion source that
|
# Tagging-v2 head training (#114). The head is the suggestion source that
|
||||||
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
|
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
|
||||||
# needs >= head_min_positives labelled images before a head is trained;
|
# needs >= head_min_positives labelled images before a head is trained;
|
||||||
@@ -94,9 +71,6 @@ class MLSettings(Base):
|
|||||||
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
|
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.92
|
Float, nullable=False, default=0.92
|
||||||
)
|
)
|
||||||
tagger_model_version: Mapped[str] = mapped_column(
|
|
||||||
String(128), nullable=False, default="camie-tagger-v2"
|
|
||||||
)
|
|
||||||
embedder_model_version: Mapped[str] = mapped_column(
|
embedder_model_version: Mapped[str] = mapped_column(
|
||||||
String(128), nullable=False, default="siglip-so400m-patch14-384"
|
String(128), nullable=False, default="siglip-so400m-patch14-384"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
MODEL_ROOT = Path(os.environ.get("ML_MODEL_DIR", "/models"))
|
MODEL_ROOT = Path(os.environ.get("ML_MODEL_DIR", "/models"))
|
||||||
CAMIE_REPO = os.environ.get("CAMIE_HF_REPO", "Camais03/camie-tagger-v2")
|
|
||||||
SIGLIP_REPO = os.environ.get(
|
SIGLIP_REPO = os.environ.get(
|
||||||
"SIGLIP_HF_REPO", "google/siglip-so400m-patch14-384"
|
"SIGLIP_HF_REPO", "google/siglip-so400m-patch14-384"
|
||||||
)
|
)
|
||||||
@@ -24,34 +23,6 @@ def _snapshot(repo_id: str, dest: Path, allow_patterns: list[str] | None) -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_camie() -> None:
|
|
||||||
"""Fetch Camie v2 weights + metadata.
|
|
||||||
|
|
||||||
v2 layout (HuggingFace Camais03/camie-tagger-v2): the ONNX file is
|
|
||||||
named camie-tagger-v2.onnx (not model.onnx) and tags ship inside
|
|
||||||
camie-tagger-v2-metadata.json (not selected_tags.csv). Both at root.
|
|
||||||
The repo also contains app/, game/, training/, images/ subdirs full
|
|
||||||
of setup/demo files we don't need — allow_patterns scopes the fetch
|
|
||||||
to just the inference essentials (~790 MB instead of ~2 GB).
|
|
||||||
"""
|
|
||||||
dest = MODEL_ROOT / "camie"
|
|
||||||
model_file = dest / "camie-tagger-v2.onnx"
|
|
||||||
meta_file = dest / "camie-tagger-v2-metadata.json"
|
|
||||||
if model_file.is_file() and meta_file.is_file():
|
|
||||||
print(f"[download_models] Camie present at {dest}")
|
|
||||||
return
|
|
||||||
print(f"[download_models] Fetching {CAMIE_REPO} -> {dest}")
|
|
||||||
_snapshot(
|
|
||||||
CAMIE_REPO, dest,
|
|
||||||
[
|
|
||||||
"camie-tagger-v2.onnx",
|
|
||||||
"camie-tagger-v2-metadata.json",
|
|
||||||
"config.json",
|
|
||||||
"config.yaml",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_siglip() -> None:
|
def ensure_siglip() -> None:
|
||||||
dest = MODEL_ROOT / "siglip"
|
dest = MODEL_ROOT / "siglip"
|
||||||
if (dest / "config.json").is_file() and any(dest.glob("*.safetensors")):
|
if (dest / "config.json").is_file() and any(dest.glob("*.safetensors")):
|
||||||
@@ -62,7 +33,6 @@ def ensure_siglip() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ensure_camie()
|
|
||||||
ensure_siglip()
|
ensure_siglip()
|
||||||
print("[download_models] Done.")
|
print("[download_models] Done.")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -395,9 +395,8 @@ def delete_images(
|
|||||||
def delete_tag(session: Session, *, tag_id: int) -> dict:
|
def delete_tag(session: Session, *, tag_id: int) -> dict:
|
||||||
"""Simple DELETE FROM tag WHERE id=?.
|
"""Simple DELETE FROM tag WHERE id=?.
|
||||||
|
|
||||||
Postgres cascades the rest (image_tag, tag_alias, tag_allowlist,
|
Postgres cascades the rest (image_tag, tag_alias, tag_suggestion_rejection,
|
||||||
tag_reference_embedding, tag_suggestion_rejection, series_page).
|
series_page). Returns counts BEFORE delete so the caller can surface them.
|
||||||
Returns counts BEFORE delete so the caller can surface them.
|
|
||||||
Raises LookupError if tag_id not found.
|
Raises LookupError if tag_id not found.
|
||||||
"""
|
"""
|
||||||
tag = session.get(Tag, tag_id)
|
tag = session.get(Tag, tag_id)
|
||||||
@@ -742,8 +741,7 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
|
|||||||
artist-kind tags PLUS general tags whose name matches a legacy
|
artist-kind tags PLUS general tags whose name matches a legacy
|
||||||
prefix (source:*).
|
prefix (source:*).
|
||||||
|
|
||||||
CASCADE on image_tag / tag_alias / tag_allowlist /
|
CASCADE on image_tag / tag_alias / tag_suggestion_rejection / series_page
|
||||||
tag_reference_embedding / tag_suggestion_rejection / series_page
|
|
||||||
clears the related rows on the parent DELETE.
|
clears the related rows on the parent DELETE.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -785,23 +783,21 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes
|
# The CONTENT vocabulary. "Reset content tagging" wipes these so the operator
|
||||||
# these so the operator can re-tag from scratch via auto-suggest. fandom +
|
# can re-tag from scratch. fandom + series (and series_page ordering) are
|
||||||
# series (and series_page ordering) are deliberately NOT here — they're kept.
|
# deliberately NOT here — they're kept.
|
||||||
RESETTABLE_TAG_KINDS = ("general", "character")
|
RESETTABLE_TAG_KINDS = ("general", "character")
|
||||||
|
|
||||||
|
|
||||||
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
|
||||||
"""Count (dry_run) or DELETE every general + character tag so the operator
|
"""Count (dry_run) or DELETE every general + character tag so the operator
|
||||||
can re-tag from scratch via the Camie auto-suggest.
|
can re-tag from scratch (heads/CCIP repopulate suggestions).
|
||||||
|
|
||||||
PRESERVED: fandom + series tags and their series_page ordering, plus every
|
PRESERVED: fandom + series tags and their series_page ordering. CASCADE on
|
||||||
image's image_prediction rows (untouched) so suggestions
|
image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's
|
||||||
repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist /
|
applications + metadata. Tag.fandom_id is SET NULL, so deleting character
|
||||||
tag_reference_embedding / tag_suggestion_rejection clears each deleted
|
tags never touches the fandom rows. Irreversible except via DB backup
|
||||||
tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting
|
restore.
|
||||||
character tags never touches the fandom rows. Irreversible except via DB
|
|
||||||
backup restore.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"by_kind": {"general": N, "character": M},
|
{"by_kind": {"general": N, "character": M},
|
||||||
|
|||||||
@@ -1475,10 +1475,8 @@ class Importer:
|
|||||||
existing.duration_seconds = duration # #871: keep the kept copy's duration
|
existing.duration_seconds = duration # #871: keep the kept copy's duration
|
||||||
existing.thumbnail_path = None
|
existing.thumbnail_path = None
|
||||||
existing.integrity_status = "unknown"
|
existing.integrity_status = "unknown"
|
||||||
existing.tagger_model_version = None
|
|
||||||
existing.siglip_embedding = None
|
existing.siglip_embedding = None
|
||||||
existing.siglip_model_version = None
|
existing.siglip_model_version = None
|
||||||
existing.centroid_scores = None
|
|
||||||
# created_at intentionally preserved; updated_at auto-bumps.
|
# created_at intentionally preserved; updated_at auto-bumps.
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|||||||
@@ -1,69 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<MaintenanceTile
|
<MaintenanceTile
|
||||||
icon="mdi-tune"
|
icon="mdi-filmstrip"
|
||||||
title="Suggestion thresholds"
|
title="Video embedding"
|
||||||
blurb="Confidence cutoffs that gate auto-suggested tags + video sampling."
|
blurb="How videos are sampled into frames before embedding."
|
||||||
>
|
>
|
||||||
<div v-if="store.settings">
|
<div v-if="store.settings">
|
||||||
<v-row v-for="f in fields" :key="f.key">
|
|
||||||
<v-col cols="12">
|
|
||||||
<v-slider
|
|
||||||
v-model="local[f.key]" :label="f.label"
|
|
||||||
:min="f.floorMin ? local.tagger_store_floor : 0" max="1" step="0.05"
|
|
||||||
thumb-label hide-details
|
|
||||||
color="accent" @end="save"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-divider class="my-4" />
|
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12">
|
|
||||||
<v-slider
|
|
||||||
v-model="local.tagger_store_floor" label="Tagger store floor"
|
|
||||||
min="0" max="1" step="0.05" thumb-label hide-details
|
|
||||||
color="accent" @end="save"
|
|
||||||
/>
|
|
||||||
<div class="text-caption fc-muted mt-1">
|
|
||||||
Tagger predictions below this confidence aren't stored — raising it
|
|
||||||
keeps the image library lean. Suggestions can't be shown below the
|
|
||||||
floor.
|
|
||||||
</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">
|
<div class="text-caption fc-muted mb-3">
|
||||||
Videos are tagged by sampling frames at a fixed cadence. A tag is kept
|
Videos are embedded by sampling frames at a fixed cadence and mean-pooling
|
||||||
only if it shows up in enough frames (≈ that many × the interval in
|
their SigLIP embeddings. The interval sets the cadence; the cap bounds how
|
||||||
seconds of screen time), which filters one-frame noise without losing
|
many frames a long video samples.
|
||||||
tags that only appear in part of a longer video.
|
|
||||||
</div>
|
</div>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" sm="4">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="local.video_frame_interval_seconds"
|
v-model.number="local.video_frame_interval_seconds"
|
||||||
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
||||||
density="comfortable" hide-details @change="save"
|
density="comfortable" hide-details @change="save"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" sm="4">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="local.video_max_frames"
|
v-model.number="local.video_max_frames"
|
||||||
label="Max frames" type="number" min="1" step="1"
|
label="Max frames" type="number" min="1" step="1"
|
||||||
density="comfortable" hide-details @change="save"
|
density="comfortable" hide-details @change="save"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</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-row>
|
||||||
</div>
|
</div>
|
||||||
<div v-else><v-skeleton-loader type="paragraph" /></div>
|
<div v-else><v-skeleton-loader type="paragraph" /></div>
|
||||||
@@ -77,31 +38,14 @@ import { useMLStore } from '../../stores/ml.js'
|
|||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
|
||||||
const store = useMLStore()
|
const store = useMLStore()
|
||||||
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
|
||||||
// suggestion categories; their threshold rows are gone.
|
|
||||||
// floorMin: the per-category suggestion thresholds can't drop below the
|
|
||||||
// tagger store floor (nothing below the floor is stored to surface).
|
|
||||||
const fields = [
|
|
||||||
{ key: 'suggestion_threshold_character', label: 'Character', floorMin: true },
|
|
||||||
{ key: 'suggestion_threshold_general', label: 'General', floorMin: true }
|
|
||||||
]
|
|
||||||
const local = reactive({})
|
const local = reactive({})
|
||||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
// Mirror the server invariant: keep the category thresholds at or above the
|
const patch = {
|
||||||
// store floor so a raised floor doesn't leave a threshold stranded below it.
|
video_frame_interval_seconds: local.video_frame_interval_seconds,
|
||||||
const floor = local.tagger_store_floor
|
video_max_frames: local.video_max_frames
|
||||||
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) }
|
try { await store.patchSettings(patch) }
|
||||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
catch (e) { toast({ text: e.message, type: 'error' }) }
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-41
@@ -19,19 +19,18 @@ async def test_get_and_patch_settings(client):
|
|||||||
resp = await client.get("/api/ml/settings")
|
resp = await client.get("/api/ml/settings")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = await resp.get_json()
|
body = await resp.get_json()
|
||||||
# Default raised 0.50 → 0.70 on 2026-06-02 (alembic 0033) — 0.50
|
assert body["head_min_positives"] == 8
|
||||||
# was too noisy in practice. The 0.70 default keeps the rail
|
# Retired tagger/suggestion-threshold columns are gone from the payload
|
||||||
# signal-rich without hiding everything like the original 0.95.
|
# (Camie retirement #1189/#1199).
|
||||||
assert body["suggestion_threshold_general"] == pytest.approx(0.70)
|
assert "suggestion_threshold_general" not in body
|
||||||
# Retired threshold columns must not appear in the payload.
|
assert "tagger_store_floor" not in body
|
||||||
assert "suggestion_threshold_artist" not in body
|
assert "tagger_model_version" not in body
|
||||||
assert "suggestion_threshold_copyright" not in body
|
|
||||||
|
|
||||||
resp = await client.patch(
|
resp = await client.patch(
|
||||||
"/api/ml/settings", json={"suggestion_threshold_general": 0.90}
|
"/api/ml/settings", json={"head_min_positives": 12}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert (await resp.get_json())["suggestion_threshold_general"] == pytest.approx(0.90)
|
assert (await resp.get_json())["head_min_positives"] == 12
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -55,55 +54,29 @@ async def test_embedder_model_settable_and_empty_rejected(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tagger_store_floor_default_and_patch(client):
|
async def test_video_settings_default_and_patch(client):
|
||||||
body = await (await client.get("/api/ml/settings")).get_json()
|
"""#747: video frame-sampling knobs are exposed + patchable."""
|
||||||
assert body["tagger_store_floor"] == pytest.approx(0.70)
|
|
||||||
|
|
||||||
resp = await client.patch("/api/ml/settings", json={"tagger_store_floor": 0.6})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert (await resp.get_json())["tagger_store_floor"] == pytest.approx(0.6)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_suggestion_threshold_below_store_floor_rejected(client):
|
|
||||||
# Invariant (#764): a category threshold can't sit below the store floor —
|
|
||||||
# nothing below the floor is stored, so the gap would surface nothing.
|
|
||||||
# Floor defaults to 0.70; pushing general down to 0.50 must 400.
|
|
||||||
resp = await client.patch(
|
|
||||||
"/api/ml/settings", json={"suggestion_threshold_general": 0.50}
|
|
||||||
)
|
|
||||||
assert resp.status_code == 400
|
|
||||||
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()
|
body = await (await client.get("/api/ml/settings")).get_json()
|
||||||
assert body["video_frame_interval_seconds"] == pytest.approx(4.0)
|
assert body["video_frame_interval_seconds"] == pytest.approx(4.0)
|
||||||
assert body["video_max_frames"] == 64
|
assert body["video_max_frames"] == 64
|
||||||
assert body["video_min_tag_frames"] == 3
|
|
||||||
|
|
||||||
resp = await client.patch(
|
resp = await client.patch(
|
||||||
"/api/ml/settings",
|
"/api/ml/settings",
|
||||||
json={"video_frame_interval_seconds": 5, "video_max_frames": 40,
|
json={"video_frame_interval_seconds": 5, "video_max_frames": 40},
|
||||||
"video_min_tag_frames": 4},
|
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
out = await resp.get_json()
|
out = await resp.get_json()
|
||||||
assert out["video_frame_interval_seconds"] == pytest.approx(5.0)
|
assert out["video_frame_interval_seconds"] == pytest.approx(5.0)
|
||||||
assert out["video_max_frames"] == 40
|
assert out["video_max_frames"] == 40
|
||||||
assert out["video_min_tag_frames"] == 4
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_video_min_tag_frames_above_max_rejected(client):
|
async def test_video_max_frames_below_one_rejected(client):
|
||||||
resp = await client.patch(
|
resp = await client.patch(
|
||||||
"/api/ml/settings",
|
"/api/ml/settings", json={"video_max_frames": 0},
|
||||||
json={"video_max_frames": 10, "video_min_tag_frames": 20},
|
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "video_min_tag_frames" in (await resp.get_json())["error"]
|
assert "video_max_frames" in (await resp.get_json())["error"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""download_models tests. No network in CI; we test the 'already present →
|
"""download_models tests. No network in CI; we test the 'already present →
|
||||||
skip' short-circuit by faking the expected files, and that main() wires
|
skip' short-circuit by faking the expected files, and that main() wires the
|
||||||
both ensure_* calls.
|
SigLIP fetch. (Camie download retired with the tagger, #1199.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -8,29 +8,6 @@ from unittest.mock import patch
|
|||||||
from backend.app.scripts import download_models as dm
|
from backend.app.scripts import download_models as dm
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_camie_skips_when_present(tmp_path, monkeypatch):
|
|
||||||
"""v2 layout (HF Camais03/camie-tagger-v2): the ONNX file is named
|
|
||||||
camie-tagger-v2.onnx (not model.onnx) and tags ship inside
|
|
||||||
camie-tagger-v2-metadata.json (not selected_tags.csv). Both at root.
|
|
||||||
Updated 2026-05-25 after the actual repo layout was confirmed via
|
|
||||||
WebFetch — the old assertion pinned the v1 filenames."""
|
|
||||||
monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
|
|
||||||
camie = tmp_path / "camie"
|
|
||||||
camie.mkdir(parents=True)
|
|
||||||
(camie / "camie-tagger-v2.onnx").write_bytes(b"x")
|
|
||||||
(camie / "camie-tagger-v2-metadata.json").write_text("{}")
|
|
||||||
with patch.object(dm, "_snapshot") as snap:
|
|
||||||
dm.ensure_camie()
|
|
||||||
snap.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_camie_downloads_when_missing(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
|
|
||||||
with patch.object(dm, "_snapshot") as snap:
|
|
||||||
dm.ensure_camie()
|
|
||||||
snap.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_siglip_skips_when_present(tmp_path, monkeypatch):
|
def test_ensure_siglip_skips_when_present(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
|
monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
|
||||||
sig = tmp_path / "siglip"
|
sig = tmp_path / "siglip"
|
||||||
@@ -42,9 +19,15 @@ def test_ensure_siglip_skips_when_present(tmp_path, monkeypatch):
|
|||||||
snap.assert_not_called()
|
snap.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_main_calls_both(monkeypatch):
|
def test_ensure_siglip_downloads_when_missing(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(dm, "MODEL_ROOT", tmp_path)
|
||||||
|
with patch.object(dm, "_snapshot") as snap:
|
||||||
|
dm.ensure_siglip()
|
||||||
|
snap.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_fetches_siglip(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
monkeypatch.setattr(dm, "ensure_camie", lambda: calls.append("camie"))
|
|
||||||
monkeypatch.setattr(dm, "ensure_siglip", lambda: calls.append("siglip"))
|
monkeypatch.setattr(dm, "ensure_siglip", lambda: calls.append("siglip"))
|
||||||
assert dm.main() == 0
|
assert dm.main() == 0
|
||||||
assert calls == ["camie", "siglip"]
|
assert calls == ["siglip"]
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ def test_new_tables_registered():
|
|||||||
|
|
||||||
def test_image_record_columns_renamed():
|
def test_image_record_columns_renamed():
|
||||||
cols = {c.name for c in ImageRecord.__table__.columns}
|
cols = {c.name for c in ImageRecord.__table__.columns}
|
||||||
# tagger_predictions (the renamed wd14_predictions) was later dropped in
|
# Legacy tagger columns are all gone: tagger_predictions/wd14_* dropped in
|
||||||
# migration 0046 — predictions live in image_prediction now (#768).
|
# 0046, tagger_model_version + centroid_scores dropped in 0068 (#1199, Camie
|
||||||
assert "tagger_model_version" in cols
|
# retirement). The SigLIP embedding columns are the live ML fields.
|
||||||
|
assert "siglip_embedding" in cols
|
||||||
|
assert "siglip_model_version" in cols
|
||||||
|
assert "tagger_model_version" not in cols
|
||||||
|
assert "centroid_scores" not in cols
|
||||||
assert "tagger_predictions" not in cols
|
assert "tagger_predictions" not in cols
|
||||||
assert "wd14_predictions" not in cols
|
assert "wd14_predictions" not in cols
|
||||||
assert "wd14_model_version" not in cols
|
|
||||||
|
|
||||||
|
|
||||||
def test_tag_alias_composite_pk():
|
def test_tag_alias_composite_pk():
|
||||||
|
|||||||
Reference in New Issue
Block a user