Merge pull request 'Crop-proposer detector config → Settings → Tagging (default-on, lease-announced) (#134)' (#194) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
This commit was merged in pull request #194.
This commit is contained in:
@@ -265,6 +265,8 @@ class Worker:
|
|||||||
self._embedder = None
|
self._embedder = None
|
||||||
self._embedder_lock = threading.Lock()
|
self._embedder_lock = threading.Lock()
|
||||||
self._proposers = None
|
self._proposers = None
|
||||||
|
self._proposers_sig = None # detector-config signature the current
|
||||||
|
# proposers were built for (#134)
|
||||||
self._proposers_lock = threading.Lock()
|
self._proposers_lock = threading.Lock()
|
||||||
|
|
||||||
# --- held-lease bookkeeping --------------------------------------------
|
# --- held-lease bookkeeping --------------------------------------------
|
||||||
@@ -796,14 +798,52 @@ class Worker:
|
|||||||
self._embedder = CropEmbedder(model_name, self.cfg.embed_dtype)
|
self._embedder = CropEmbedder(model_name, self.cfg.embed_dtype)
|
||||||
return self._embedder
|
return self._embedder
|
||||||
|
|
||||||
def _ensure_proposers(self):
|
def _effective_cfg(self, det: dict | None):
|
||||||
if self._proposers is not None:
|
"""The detector config for a job: the LEASE-ANNOUNCED block (the DB/UI
|
||||||
return self._proposers
|
source of truth, #134) overlaid on the env cfg, or the env cfg unchanged
|
||||||
|
when the server announced nothing (bootstrap / older backend). An enabled
|
||||||
|
toggle off maps to empty weights — the 'weights == "" → proposer skipped'
|
||||||
|
contract in detectors.py. detector_level + ccip_model stay env (separate
|
||||||
|
settings, not part of the crop-proposer config)."""
|
||||||
|
if not det:
|
||||||
|
return self.cfg
|
||||||
|
|
||||||
|
def _w(p: str) -> str:
|
||||||
|
slot = det.get(p) or {}
|
||||||
|
return (slot.get("weights") or "") if slot.get("enabled", True) else ""
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
return replace(
|
||||||
|
self.cfg,
|
||||||
|
person_weights=_w("person"),
|
||||||
|
person_conf=float(det["person"]["conf"]),
|
||||||
|
anatomy_weights=_w("anatomy"),
|
||||||
|
anatomy_conf=float(det["anatomy"]["conf"]),
|
||||||
|
panel_weights=_w("panel"),
|
||||||
|
panel_conf=float(det["panel"]["conf"]),
|
||||||
|
max_figures=int(det["max_figures"]),
|
||||||
|
max_components=int(det["max_components"]),
|
||||||
|
max_panels=int(det["max_panels"]),
|
||||||
|
max_regions=int(det["max_regions"]),
|
||||||
|
dedupe_iou=float(det["dedupe_iou"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _proposers_for(self, eff):
|
||||||
|
"""Proposers built from the effective detector config, rebuilt ONLY when
|
||||||
|
that config changes — so an operator's UI edit takes effect on the next
|
||||||
|
lease with no restart, and steady-state reuses the loaded YOLO models."""
|
||||||
|
sig = (
|
||||||
|
eff.person_weights, eff.person_conf,
|
||||||
|
eff.anatomy_weights, eff.anatomy_conf,
|
||||||
|
eff.panel_weights, eff.panel_conf,
|
||||||
|
eff.max_figures, eff.max_components, eff.max_panels,
|
||||||
|
)
|
||||||
with self._proposers_lock:
|
with self._proposers_lock:
|
||||||
if self._proposers is None:
|
if self._proposers is None or self._proposers_sig != sig:
|
||||||
from .detectors import Proposers
|
from .detectors import Proposers
|
||||||
self._proposers = Proposers(self.cfg)
|
self._proposers = Proposers(eff)
|
||||||
return self._proposers
|
self._proposers_sig = sig
|
||||||
|
return self._proposers
|
||||||
|
|
||||||
def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool:
|
def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool:
|
||||||
"""Detect + embed the decoded frames and submit the result. Returns True
|
"""Detect + embed the decoded frames and submit the result. Returns True
|
||||||
@@ -856,7 +896,8 @@ class Worker:
|
|||||||
else ["figure", "face", "concept", "panel"]
|
else ["figure", "face", "concept", "panel"]
|
||||||
)
|
)
|
||||||
embedder = self._ensure_embedder(model_name) if want_siglip else None
|
embedder = self._ensure_embedder(model_name) if want_siglip else None
|
||||||
proposers = self._ensure_proposers()
|
eff = self._effective_cfg(job.get("detectors"))
|
||||||
|
proposers = self._proposers_for(eff)
|
||||||
|
|
||||||
regions = []
|
regions = []
|
||||||
ccip_ev = self.cfg.ccip_model or "ccip-default"
|
ccip_ev = self.cfg.ccip_model or "ccip-default"
|
||||||
@@ -922,7 +963,7 @@ class Worker:
|
|||||||
# we never SigLIP the same region twice — saves GPU and a
|
# we never SigLIP the same region twice — saves GPU and a
|
||||||
# slot against max_regions. High-IoU + kind-aware, so
|
# slot against max_regions. High-IoU + kind-aware, so
|
||||||
# intentional nested crops (figure ⊃ head) survive.
|
# intentional nested crops (figure ⊃ head) survive.
|
||||||
pending = dedupe_crops(pending, self.cfg.dedupe_iou)
|
pending = dedupe_crops(pending, eff.dedupe_iou)
|
||||||
vecs = embedder.embed_batch([c for c, _ in pending])
|
vecs = embedder.embed_batch([c for c, _ in pending])
|
||||||
for (_c, tmpl), vec in zip(pending, vecs, strict=True):
|
for (_c, tmpl), vec in zip(pending, vecs, strict=True):
|
||||||
tmpl["siglip_embedding"] = vec
|
tmpl["siglip_embedding"] = vec
|
||||||
@@ -932,7 +973,7 @@ class Worker:
|
|||||||
# mp4, 64 sampled frames × ~32 regions) would otherwise burn
|
# mp4, 64 sampled frames × ~32 regions) would otherwise burn
|
||||||
# ~38s of GPU across every frame before the submit is even
|
# ~38s of GPU across every frame before the submit is even
|
||||||
# truncated. Bounds the WORK, not just the POST body.
|
# truncated. Bounds the WORK, not just the POST body.
|
||||||
if len(regions) >= self.cfg.max_regions:
|
if len(regions) >= eff.max_regions:
|
||||||
break
|
break
|
||||||
|
|
||||||
# A Stop mid-frame-loop leaves partial regions — don't submit those;
|
# A Stop mid-frame-loop leaves partial regions — don't submit those;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""ml_settings crop-proposer / detector config (#134)
|
||||||
|
|
||||||
|
Move the WHERE-to-crop detector config (per-proposer enable + weights + conf,
|
||||||
|
plus caps + dedupe IoU) into the DB so it's UI-tunable and announced to the GPU
|
||||||
|
agent in the lease (like the embedder model) — no restart, agent env is now
|
||||||
|
bootstrap-only. All server_defaults are the working values so existing rows +
|
||||||
|
fresh installs crop out-of-the-box with all three proposers ON.
|
||||||
|
|
||||||
|
Revision ID: 0078
|
||||||
|
Revises: 0077
|
||||||
|
Create Date: 2026-07-05
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0078"
|
||||||
|
down_revision: Union[str, None] = "0077"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
_ANATOMY_DEFAULT = (
|
||||||
|
"https://github.com/aperveyev/booru_yolo/raw/main/models/yolov11m_aa22.pt"
|
||||||
|
)
|
||||||
|
_PANEL_DEFAULT = "mosesb/best-comic-panel-detection::best.pt"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_person_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.true()))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_person_weights", sa.String(512), nullable=False,
|
||||||
|
server_default="yolo11n.pt"))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_person_conf", sa.Float(), nullable=False,
|
||||||
|
server_default=sa.text("0.35")))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_anatomy_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.true()))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_anatomy_weights", sa.String(512), nullable=False,
|
||||||
|
server_default=_ANATOMY_DEFAULT))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_anatomy_conf", sa.Float(), nullable=False,
|
||||||
|
server_default=sa.text("0.30")))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_panel_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.true()))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_panel_weights", sa.String(512), nullable=False,
|
||||||
|
server_default=_PANEL_DEFAULT))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_panel_conf", sa.Float(), nullable=False,
|
||||||
|
server_default=sa.text("0.30")))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_max_figures", sa.Integer(), nullable=False,
|
||||||
|
server_default=sa.text("8")))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_max_components", sa.Integer(), nullable=False,
|
||||||
|
server_default=sa.text("8")))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_max_panels", sa.Integer(), nullable=False,
|
||||||
|
server_default=sa.text("8")))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_max_regions", sa.Integer(), nullable=False,
|
||||||
|
server_default=sa.text("128")))
|
||||||
|
op.add_column("ml_settings", sa.Column(
|
||||||
|
"detector_dedupe_iou", sa.Float(), nullable=False,
|
||||||
|
server_default=sa.text("0.85")))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for col in (
|
||||||
|
"detector_person_enabled", "detector_person_weights", "detector_person_conf",
|
||||||
|
"detector_anatomy_enabled", "detector_anatomy_weights", "detector_anatomy_conf",
|
||||||
|
"detector_panel_enabled", "detector_panel_weights", "detector_panel_conf",
|
||||||
|
"detector_max_figures", "detector_max_components", "detector_max_panels",
|
||||||
|
"detector_max_regions", "detector_dedupe_iou",
|
||||||
|
):
|
||||||
|
op.drop_column("ml_settings", col)
|
||||||
@@ -269,6 +269,33 @@ async def lease():
|
|||||||
).scalars()
|
).scalars()
|
||||||
} if ids else {}
|
} if ids else {}
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
# Crop-proposer config, announced FROM THE SETTING like embed_model_name
|
||||||
|
# (#134): the agent builds its detectors from this, rebuilding live when
|
||||||
|
# it changes — so tuning is a DB/UI edit, never an agent restart. Same
|
||||||
|
# block for every job in the batch (it's global), built once. An enabled
|
||||||
|
# toggle off is carried through so the agent skips that proposer.
|
||||||
|
detectors = {
|
||||||
|
"person": {
|
||||||
|
"enabled": ml.detector_person_enabled,
|
||||||
|
"weights": ml.detector_person_weights,
|
||||||
|
"conf": ml.detector_person_conf,
|
||||||
|
},
|
||||||
|
"anatomy": {
|
||||||
|
"enabled": ml.detector_anatomy_enabled,
|
||||||
|
"weights": ml.detector_anatomy_weights,
|
||||||
|
"conf": ml.detector_anatomy_conf,
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"enabled": ml.detector_panel_enabled,
|
||||||
|
"weights": ml.detector_panel_weights,
|
||||||
|
"conf": ml.detector_panel_conf,
|
||||||
|
},
|
||||||
|
"max_figures": ml.detector_max_figures,
|
||||||
|
"max_components": ml.detector_max_components,
|
||||||
|
"max_panels": ml.detector_max_panels,
|
||||||
|
"max_regions": ml.detector_max_regions,
|
||||||
|
"dedupe_iou": ml.detector_dedupe_iou,
|
||||||
|
}
|
||||||
out = []
|
out = []
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
img = imgs.get(j.image_record_id)
|
img = imgs.get(j.image_record_id)
|
||||||
@@ -290,6 +317,7 @@ async def lease():
|
|||||||
# re-embed, never an agent change.
|
# re-embed, never an agent change.
|
||||||
"embed_model_name": ml.embedder_model_name,
|
"embed_model_name": ml.embedder_model_name,
|
||||||
"embed_version": ml.embedder_model_version,
|
"embed_version": ml.embedder_model_version,
|
||||||
|
"detectors": detectors,
|
||||||
})
|
})
|
||||||
return jsonify({"jobs": out})
|
return jsonify({"jobs": out})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,26 @@ from ..models import MLSettings
|
|||||||
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
||||||
|
|
||||||
|
|
||||||
|
# Crop-proposer / detector config (#134). Announced to the GPU agent in the lease
|
||||||
|
# → tunable here with no restart. weights = ultralytics name | URL | hf_repo::file
|
||||||
|
# (empty, or enabled off, skips that proposer).
|
||||||
|
_DETECTOR_FIELDS = (
|
||||||
|
"detector_person_enabled",
|
||||||
|
"detector_person_weights",
|
||||||
|
"detector_person_conf",
|
||||||
|
"detector_anatomy_enabled",
|
||||||
|
"detector_anatomy_weights",
|
||||||
|
"detector_anatomy_conf",
|
||||||
|
"detector_panel_enabled",
|
||||||
|
"detector_panel_weights",
|
||||||
|
"detector_panel_conf",
|
||||||
|
"detector_max_figures",
|
||||||
|
"detector_max_components",
|
||||||
|
"detector_max_panels",
|
||||||
|
"detector_max_regions",
|
||||||
|
"detector_dedupe_iou",
|
||||||
|
)
|
||||||
|
|
||||||
_EDITABLE = (
|
_EDITABLE = (
|
||||||
"cpu_embed_enabled",
|
"cpu_embed_enabled",
|
||||||
"video_frame_interval_seconds",
|
"video_frame_interval_seconds",
|
||||||
@@ -21,6 +41,7 @@ _EDITABLE = (
|
|||||||
"ccip_auto_apply_threshold",
|
"ccip_auto_apply_threshold",
|
||||||
"embedder_model_name",
|
"embedder_model_name",
|
||||||
"embedder_model_version",
|
"embedder_model_version",
|
||||||
|
*_DETECTOR_FIELDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +97,7 @@ async def get_settings():
|
|||||||
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
|
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
|
||||||
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
|
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
|
||||||
"embedder_model_name": s.embedder_model_name,
|
"embedder_model_name": s.embedder_model_name,
|
||||||
|
**{f: getattr(s, f) for f in _DETECTOR_FIELDS},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,6 +155,19 @@ def _validate(p: dict) -> str | None:
|
|||||||
for key in ("embedder_model_name", "embedder_model_version"):
|
for key in ("embedder_model_name", "embedder_model_version"):
|
||||||
if not str(p[key]).strip():
|
if not str(p[key]).strip():
|
||||||
return f"{key} must not be empty"
|
return f"{key} must not be empty"
|
||||||
|
# Crop proposers (#134). Weights may be empty (that proposer is just off);
|
||||||
|
# confidences are probabilities; caps are positive counts; IoU is [0,1].
|
||||||
|
for key in ("detector_person_conf", "detector_anatomy_conf", "detector_panel_conf"):
|
||||||
|
if not (0.0 <= float(p[key]) <= 1.0):
|
||||||
|
return f"{key} must be between 0 and 1"
|
||||||
|
for key in (
|
||||||
|
"detector_max_figures", "detector_max_components",
|
||||||
|
"detector_max_panels", "detector_max_regions",
|
||||||
|
):
|
||||||
|
if int(p[key]) < 1:
|
||||||
|
return f"{key} must be >= 1"
|
||||||
|
if not (0.0 <= float(p["detector_dedupe_iou"]) <= 1.0):
|
||||||
|
return "detector_dedupe_iou must be between 0 and 1"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,70 @@ class MLSettings(Base):
|
|||||||
embedder_model_name: Mapped[str] = mapped_column(
|
embedder_model_name: Mapped[str] = mapped_column(
|
||||||
String(128), nullable=False, default="google/siglip2-so400m-patch16-512"
|
String(128), nullable=False, default="google/siglip2-so400m-patch16-512"
|
||||||
)
|
)
|
||||||
|
# -- Crop proposers / detectors (#1202, #134) --------------------------
|
||||||
|
# WHERE-to-crop YOLO detectors feeding the crop→SigLIP bag + CCIP. Config
|
||||||
|
# lives HERE (DB) and is announced to the GPU agent in the lease — same as
|
||||||
|
# the embedder model — so it is UI-tunable with NO restart, and the agent's
|
||||||
|
# env is bootstrap-only. Each weights spec is an ultralytics builtin name,
|
||||||
|
# an http(s) URL, or "hf_repo::file" (agent's _resolve). enabled off (or an
|
||||||
|
# empty weights) skips that proposer. All ON by default (operator 2026-07-05)
|
||||||
|
# so a fresh install crops out-of-the-box.
|
||||||
|
# person: general COCO figure detector for Western/realistic art the anime
|
||||||
|
# person-detector misses → NMS-merged with imgutils → CCIP + concept.
|
||||||
|
detector_person_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True
|
||||||
|
)
|
||||||
|
detector_person_weights: Mapped[str] = mapped_column(
|
||||||
|
String(512), nullable=False, default="yolo11n.pt"
|
||||||
|
)
|
||||||
|
detector_person_conf: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.35
|
||||||
|
)
|
||||||
|
# anatomy: booru_yolo anime/furry/NSFW torso components → concept crops.
|
||||||
|
# Default = yolov11m_aa22 (26 classes, best mAP50-95 0.96), committed in the
|
||||||
|
# upstream repo so the URL resolves. License UNSTATED — fine for a private
|
||||||
|
# homelab (operator accepted #1202).
|
||||||
|
detector_anatomy_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True
|
||||||
|
)
|
||||||
|
detector_anatomy_weights: Mapped[str] = mapped_column(
|
||||||
|
String(512), nullable=False,
|
||||||
|
default=(
|
||||||
|
"https://github.com/aperveyev/booru_yolo/raw/main/models/"
|
||||||
|
"yolov11m_aa22.pt"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
detector_anatomy_conf: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.30
|
||||||
|
)
|
||||||
|
# panel: comic page → panel regions → concept crops (Apache-2.0, YOLOv12x).
|
||||||
|
detector_panel_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=True
|
||||||
|
)
|
||||||
|
detector_panel_weights: Mapped[str] = mapped_column(
|
||||||
|
String(512), nullable=False,
|
||||||
|
default="mosesb/best-comic-panel-detection::best.pt",
|
||||||
|
)
|
||||||
|
detector_panel_conf: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.30
|
||||||
|
)
|
||||||
|
# Per-frame caps bound the crop→embed explosion; max_regions is the hard
|
||||||
|
# per-job backstop; dedupe_iou drops near-duplicate crops before the embed.
|
||||||
|
detector_max_figures: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=8
|
||||||
|
)
|
||||||
|
detector_max_components: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=8
|
||||||
|
)
|
||||||
|
detector_max_panels: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=8
|
||||||
|
)
|
||||||
|
detector_max_regions: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=128
|
||||||
|
)
|
||||||
|
detector_dedupe_iou: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False, default=0.85
|
||||||
|
)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<MaintenanceTile
|
||||||
|
icon="mdi-crop"
|
||||||
|
title="Crop proposers (where to crop)"
|
||||||
|
blurb="Detectors that decide WHERE to crop each image before embedding — general figures, anime/furry anatomy, and comic panels — so a tag can ground to a region, not just the whole image."
|
||||||
|
:open="false"
|
||||||
|
>
|
||||||
|
<p class="fc-muted text-body-2 mb-4">
|
||||||
|
Each proposer finds regions that the crop → SigLIP bag scores alongside the
|
||||||
|
whole image; the best-scoring region is what grounds a tag. Weights accept
|
||||||
|
an <strong>ultralytics name</strong> (<code>yolo11n.pt</code>), a
|
||||||
|
<strong>URL</strong>, or <strong>hf_repo::file</strong>. Edits reach the GPU
|
||||||
|
agent on its next lease — no restart. After turning a proposer on, re-process
|
||||||
|
the library so existing images gain its regions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-for="p in proposers" :key="p.key" class="fc-proposer">
|
||||||
|
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||||
|
<v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon>
|
||||||
|
<span class="fc-section-h">{{ p.label }}</span>
|
||||||
|
<v-switch
|
||||||
|
v-model="p.on" :loading="busy" hide-details density="compact"
|
||||||
|
color="success" class="ml-auto"
|
||||||
|
@update:model-value="v => saveToggle(p, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
|
||||||
|
<div class="d-flex flex-wrap mb-4" style="gap: 12px;">
|
||||||
|
<v-text-field
|
||||||
|
v-model="p.weights" label="Weights" density="compact" hide-details
|
||||||
|
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
|
||||||
|
placeholder="name | URL | hf_repo::file"
|
||||||
|
@change="save({ [`detector_${p.key}_weights`]: p.weights })"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="p.conf" label="Confidence" type="number"
|
||||||
|
min="0" max="1" step="0.05" density="compact" hide-details
|
||||||
|
style="max-width: 140px;" :disabled="busy || !p.on"
|
||||||
|
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fc-caps">
|
||||||
|
<div class="fc-section-h mb-1">Caps</div>
|
||||||
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
|
Bound how many crops each image can produce — protects GPU time and
|
||||||
|
storage. Dedupe IoU drops near-duplicate crops before embedding.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 12px;">
|
||||||
|
<v-text-field
|
||||||
|
v-for="c in caps" :key="c.key"
|
||||||
|
v-model.number="c.val" :label="c.label" type="number"
|
||||||
|
:min="c.min" :max="c.max" :step="c.step || 1" density="compact"
|
||||||
|
hide-details style="max-width: 165px;" :disabled="busy"
|
||||||
|
@change="save({ [c.key]: Number(c.val) })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MaintenanceTile>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { toast } from '../../utils/toast.js'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
|
|
||||||
|
const mlSettings = useMLStore()
|
||||||
|
const busy = ref(false)
|
||||||
|
const proposers = ref([])
|
||||||
|
const caps = ref([])
|
||||||
|
|
||||||
|
// Static copy per proposer; the mutable state (on/weights/conf) is seeded from
|
||||||
|
// settings on mount so the card reflects the live DB config (rule 26 — it works
|
||||||
|
// untouched).
|
||||||
|
const DEFS = [
|
||||||
|
{
|
||||||
|
key: 'person', label: 'Figures (person)', icon: 'mdi-account-outline',
|
||||||
|
help: 'General figure detector for the Western/realistic art the anime ' +
|
||||||
|
'detector misses → character identity + a whole-figure crop.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'anatomy', label: 'Anatomy (booru)', icon: 'mdi-dog-side',
|
||||||
|
help: 'Anime / furry / NSFW torso components (heads, busts, hips, …) → ' +
|
||||||
|
'localized concept crops.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'panel', label: 'Comic panels', icon: 'mdi-view-grid-outline',
|
||||||
|
help: 'Splits a comic page into its panels → one concept crop each.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const CAP_DEFS = [
|
||||||
|
{ key: 'detector_max_figures', label: 'Max figures', min: 1 },
|
||||||
|
{ key: 'detector_max_components', label: 'Max components', min: 1 },
|
||||||
|
{ key: 'detector_max_panels', label: 'Max panels', min: 1 },
|
||||||
|
{ key: 'detector_max_regions', label: 'Max regions / job', min: 1 },
|
||||||
|
{ key: 'detector_dedupe_iou', label: 'Dedupe IoU', min: 0, max: 1, step: 0.05 },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try { await mlSettings.loadSettings() } catch { /* non-fatal — defaults show */ }
|
||||||
|
const s = mlSettings.settings || {}
|
||||||
|
proposers.value = DEFS.map(d => ({
|
||||||
|
...d,
|
||||||
|
on: !!s[`detector_${d.key}_enabled`],
|
||||||
|
weights: s[`detector_${d.key}_weights`] ?? '',
|
||||||
|
conf: s[`detector_${d.key}_conf`] ?? 0.3,
|
||||||
|
}))
|
||||||
|
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save(patch, revert) {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await mlSettings.patchSettings(patch)
|
||||||
|
toast({ text: 'Saved', type: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
if (revert) revert()
|
||||||
|
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToggle (p, v) {
|
||||||
|
// Revert the switch on failure so it never lies about the persisted state.
|
||||||
|
save({ [`detector_${p.key}_enabled`]: !!v }, () => { p.on = !v })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
.fc-section-h {
|
||||||
|
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-proposer {
|
||||||
|
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px;
|
||||||
|
}
|
||||||
|
.fc-proposer:first-of-type { border-top: none; padding-top: 0; }
|
||||||
|
.fc-caps {
|
||||||
|
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 0.85em;
|
||||||
|
background: rgb(var(--v-theme-surface-light)); padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="fc-tile-stack">
|
<div class="fc-tile-stack">
|
||||||
<MLThresholdSliders />
|
<MLThresholdSliders />
|
||||||
|
<CropProposersCard />
|
||||||
<HeadsCard />
|
<HeadsCard />
|
||||||
<AliasTable />
|
<AliasTable />
|
||||||
</div>
|
</div>
|
||||||
@@ -75,6 +76,7 @@ import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
|||||||
import GpuTriageCard from './GpuTriageCard.vue'
|
import GpuTriageCard from './GpuTriageCard.vue'
|
||||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||||
|
import CropProposersCard from './CropProposersCard.vue'
|
||||||
import HeadsCard from './HeadsCard.vue'
|
import HeadsCard from './HeadsCard.vue'
|
||||||
import GpuAgentCard from './GpuAgentCard.vue'
|
import GpuAgentCard from './GpuAgentCard.vue'
|
||||||
import AliasTable from './AliasTable.vue'
|
import AliasTable from './AliasTable.vue'
|
||||||
|
|||||||
@@ -105,6 +105,30 @@ async def test_lease_announces_embed_model_then_submit_embedding(client, db):
|
|||||||
assert img.siglip_embedding is not None and len(list(img.siglip_embedding)) == 1152
|
assert img.siglip_embedding is not None and len(list(img.siglip_embedding)) == 1152
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lease_announces_detector_config(client, db):
|
||||||
|
# #134: the lease carries the crop-proposer config from MLSettings, so the
|
||||||
|
# agent builds its detectors from the DB/UI (no restart). Defaults are all-on
|
||||||
|
# with the pinned working weights.
|
||||||
|
img = await _img(db, "c" * 64)
|
||||||
|
await GpuJobService(db).enqueue(img.id, "embed")
|
||||||
|
await db.commit()
|
||||||
|
token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
|
||||||
|
hdr = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
leased = await client.post(
|
||||||
|
"/api/gpu/jobs/lease", json={"agent_id": "a1", "batch_size": 5}, headers=hdr,
|
||||||
|
)
|
||||||
|
det = (await leased.get_json())["jobs"][0]["detectors"]
|
||||||
|
assert det["person"]["enabled"]
|
||||||
|
assert det["anatomy"]["enabled"]
|
||||||
|
assert det["panel"]["enabled"]
|
||||||
|
assert "yolov11m_aa22" in det["anatomy"]["weights"] # booru_yolo default
|
||||||
|
assert det["panel"]["weights"].endswith("::best.pt") # mosesb default
|
||||||
|
assert det["max_regions"] == 128
|
||||||
|
assert det["dedupe_iou"] == 0.85
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_submit_with_stale_lease_is_409(client, db):
|
async def test_submit_with_stale_lease_is_409(client, db):
|
||||||
img = await _img(db, "b" * 64)
|
img = await _img(db, "b" * 64)
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ async def test_embedder_model_default_settable_and_empty_rejected(client):
|
|||||||
assert bad.status_code == 400
|
assert bad.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_detector_settings_defaults_patch_and_validation(client):
|
||||||
|
# #134: crop-proposer config is exposed + editable here (announced to the
|
||||||
|
# agent via the lease). Defaults are all-on with the pinned weights.
|
||||||
|
body = await (await client.get("/api/ml/settings")).get_json()
|
||||||
|
assert body["detector_person_enabled"] is True
|
||||||
|
assert body["detector_anatomy_enabled"] is True
|
||||||
|
assert "yolov11m_aa22" in body["detector_anatomy_weights"]
|
||||||
|
assert body["detector_panel_weights"].endswith("::best.pt")
|
||||||
|
assert body["detector_max_regions"] == 128
|
||||||
|
|
||||||
|
ok = await client.patch("/api/ml/settings", json={
|
||||||
|
"detector_anatomy_enabled": False,
|
||||||
|
"detector_person_conf": 0.5,
|
||||||
|
"detector_max_panels": 4,
|
||||||
|
})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
out = await ok.get_json()
|
||||||
|
assert out["detector_anatomy_enabled"] is False
|
||||||
|
assert out["detector_person_conf"] == 0.5
|
||||||
|
assert out["detector_max_panels"] == 4
|
||||||
|
|
||||||
|
# Out-of-range confidence + non-positive cap are rejected.
|
||||||
|
assert (await client.patch(
|
||||||
|
"/api/ml/settings", json={"detector_panel_conf": 1.5})).status_code == 400
|
||||||
|
assert (await client.patch(
|
||||||
|
"/api/ml/settings", json={"detector_max_regions": 0})).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_embedder_models_list(client):
|
async def test_embedder_models_list(client):
|
||||||
# #1203: the dropdown reads the supported-model list from the server.
|
# #1203: the dropdown reads the supported-model list from the server.
|
||||||
|
|||||||
Reference in New Issue
Block a user