feat: per-class max box fraction (REQ-188)

- labeling/preview/autolabel: max_box_frac + per-class max_box_fracs
  ceiling filter (0=none, 1=off) before NMS, mirrors min_box_frac
- exemplar review-assist: ceiling before max_detections truncation;
  ExemplarLabelRequest + filter panel 'Max box size' slider
- ClassParamsTable: MaxBox column; copy line now REQ-186 amended
  order: class <name> conf <v> iou <v> minbox <v> maxbox <v> container
- docs: design/ui-spec/tasks updated (incl. stale 4-slider narrative)
This commit is contained in:
asus committed 2026-10-02 17:27:11 +07:00
1 parent dee58e4ae5
commit d3a6aa49b6
13 files changed
+90 -25

No files matched your search

+2
View File
@@ -56,6 +56,7 @@ class ExemplarLabelRequest(BaseModel):
threshold: float = 0.5
iou_threshold: float = 0.8
min_box_frac: float = 0.002
max_box_frac: float = 1.0
max_detections: int = 100
# Off by default: a drag previews, only Apply writes.
apply: bool = False
@@ -131,6 +132,7 @@ def exemplar_label(frame_id: int, request: ExemplarLabelRequest) -> dict:
threshold=request.threshold,
iou_threshold=request.iou_threshold,
min_box_frac=request.min_box_frac,
max_box_frac=request.max_box_frac,
max_detections=request.max_detections,
apply=request.apply,
)
+8 -2
View File
@@ -27,7 +27,7 @@ def _parse_class_params(raw) -> dict:
if not isinstance(values, dict):
continue
entry = {}
for key in ("threshold", "iou_threshold", "min_box_frac"):
for key in ("threshold", "iou_threshold", "min_box_frac", "max_box_frac"):
if key in values:
try:
entry[key] = float(values[key])
@@ -229,6 +229,9 @@ def _run_autolabel(job) -> None:
mb = cp.get("min_box_frac")
if mb and mb > 0 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) < mb:
continue
xb = cp.get("max_box_frac")
if xb and xb < 1 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) > xb:
continue
all_raw_detections.append(labeling.Detection(
class_id=target_class_id,
@@ -240,7 +243,7 @@ def _run_autolabel(job) -> None:
if selected_engine == "sam3" and sam3_target_classes:
prompts = [(c.get("prompt") or c["name"]).strip() for c in sam3_target_classes]
thr_list = iou_list = mb_list = None
thr_list = iou_list = mb_list = mx_list = None
if per_class:
names = [c["name"].strip().lower() for c in sam3_target_classes]
if any("threshold" in v for v in per_class.values()):
@@ -249,12 +252,15 @@ def _run_autolabel(job) -> None:
iou_list = [per_class.get(n, {}).get("iou_threshold", iou_thresh) for n in names]
if any("min_box_frac" in v for v in per_class.values()):
mb_list = [per_class.get(n, {}).get("min_box_frac", job.params.get("min_box_frac", 0.0)) for n in names]
if any("max_box_frac" in v for v in per_class.values()):
mx_list = [per_class.get(n, {}).get("max_box_frac", 1.0) for n in names]
res = labeling.label_image(
frame_file, frame["filename"], prompts, conf,
iou_threshold=iou_thresh, min_box_frac=job.params.get("min_box_frac", 0.0),
thresholds=thr_list,
iou_by_class=dict(enumerate(iou_list)) if iou_list else None,
min_box_fracs=mb_list,
max_box_fracs=mx_list,
container_ids=prompt_container_ids,
)
if not res.error and res.detections:
+9 -3
View File
@@ -37,6 +37,7 @@ DEFAULTS = {
"threshold": 0.5,
"iou_threshold": 0.8,
"min_box_frac": 0.002,
"max_box_frac": 1.0,
"max_detections": 100,
}
@@ -116,7 +117,8 @@ def _drop_negative_overlaps(frame_id: int, class_id: int,
def label(frame_id: int, class_id: int, exemplars: List[dict],
threshold: float = 0.5, iou_threshold: float = 0.8,
min_box_frac: float = 0.002, max_detections: int = 100,
min_box_frac: float = 0.002, max_box_frac: float = 1.0,
max_detections: int = 100,
apply: bool = False) -> dict:
"""Detect one class on one frame from the frame's exemplar pool.
@@ -189,12 +191,16 @@ def label(frame_id: int, class_id: int, exemplars: List[dict],
finally:
jobs.gpu_lock.release()
# The panel's filters, in the order the batch job applies them (REQ-175):
# area floor, then NMS, then the cap on how many survive.
# The panel's filters, in the order the batch job applies them (REQ-175/188):
# area floor, then ceiling, then NMS, then the cap on how many survive.
if min_box_frac > 0:
floor = width * height * min_box_frac
found = [d for d in found
if (d.box[2] - d.box[0]) * (d.box[3] - d.box[1]) >= floor]
if 0 < max_box_frac < 1:
ceiling = width * height * max_box_frac
found = [d for d in found
if (d.box[2] - d.box[0]) * (d.box[3] - d.box[1]) <= ceiling]
found = deduplicate(found, iou_threshold)
found.sort(key=lambda d: d.score, reverse=True)
if max_detections > 0:
+21 -2
View File
@@ -112,19 +112,23 @@ def label_image(
threshold: float,
iou_threshold: float = 0.8,
min_box_frac: float = 0.0,
max_box_frac: float = 1.0,
exemplar_index: int = -1,
exemplars: Optional[List[dict]] = None,
thresholds: Optional[List[float]] = None,
iou_by_class: Optional[Dict[int, float]] = None,
min_box_fracs: Optional[List[float]] = None,
max_box_fracs: Optional[List[float]] = None,
container_ids: Optional[Set[int]] = None,
) -> ImageResult:
"""Detect every prompt in one image and return the surviving instances.
When `exemplars` are given, the prompt at `exemplar_index` also carries them
as drawn box exemplars (REQ-172); every other prompt runs on text alone.
`thresholds`, `iou_by_class` and `min_box_fracs` are per-prompt overrides
aligned with `prompts` (REQ-181); classes without one use the global values.
`thresholds`, `iou_by_class`, `min_box_fracs` and `max_box_fracs` are
per-prompt overrides aligned with `prompts` (REQ-181); classes without one
use the global values. `max_box_frac(s)` is the per-class size ceiling,
1.0 = off (REQ-188).
`container_ids` are prompt indices marked container (REQ-184) — the caller
maps them, because detections still carry prompt-index class ids here."""
try:
@@ -160,6 +164,21 @@ def label_image(
if (d.box[2] - d.box[0]) * (d.box[3] - d.box[1]) >= floor
]
if max_box_fracs is not None:
def _keep_max(det) -> bool:
frac = max_box_fracs[det.class_id] if det.class_id < len(max_box_fracs) else max_box_frac
if frac >= 1 or frac <= 0:
return True
ceiling = width * height * frac
return (det.box[2] - det.box[0]) * (det.box[3] - det.box[1]) <= ceiling
detections = [d for d in detections if _keep_max(d)]
elif 0 < max_box_frac < 1:
ceiling = width * height * max_box_frac
detections = [
d for d in detections
if (d.box[2] - d.box[0]) * (d.box[3] - d.box[1]) <= ceiling
]
return ImageResult(image_path, rel_path, width, height,
deduplicate(detections, iou_threshold, iou_by_class=iou_by_class,
container_ids=container_ids))
+7 -1
View File
@@ -120,6 +120,9 @@ def preview_frame(
mb = cp.get("min_box_frac")
if mb and mb > 0 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) < mb:
continue
xb = cp.get("max_box_frac")
if xb and xb < 1 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) > xb:
continue
all_raw_detections.append(labeling.Detection(
class_id=target_class_id,
@@ -143,7 +146,7 @@ def preview_frame(
if c["name"].strip().lower() == wanted),
-1,
)
thr_list = iou_list = mb_list = None
thr_list = iou_list = mb_list = mx_list = None
if per_class:
names = [c["name"].strip().lower() for c in sam3_target_classes]
if any("threshold" in v for v in per_class.values()):
@@ -152,6 +155,8 @@ def preview_frame(
iou_list = [per_class.get(n, {}).get("iou_threshold", iou_threshold) for n in names]
if any("min_box_frac" in v for v in per_class.values()):
mb_list = [per_class.get(n, {}).get("min_box_frac", min_box_frac) for n in names]
if any("max_box_frac" in v for v in per_class.values()):
mx_list = [per_class.get(n, {}).get("max_box_frac", 1.0) for n in names]
res = labeling.label_image(
frame_file, frame["filename"], prompts, threshold,
iou_threshold=iou_threshold, min_box_frac=min_box_frac,
@@ -159,6 +164,7 @@ def preview_frame(
thresholds=thr_list,
iou_by_class=dict(enumerate(iou_list)) if iou_list else None,
min_box_fracs=mb_list,
max_box_fracs=mx_list,
container_ids=prompt_container_ids,
)
if not res.error and res.detections:
+7 -7
View File
@@ -181,7 +181,7 @@ GET /api/batches/{id} # status + review progress (REQ-
GET /api/batches/{id}/frames # frames + statuses
POST /api/batches/{id}/autolabel # {threshold, class_params} → job (REQ-030,032,034,181)
POST /api/batches/{id}/preview # one frame, run now, nothing written;
# +class_params {name: {threshold?, iou_threshold?, min_box_frac?}}
# +class_params {name: {threshold?, iou_threshold?, min_box_frac?, max_box_frac?}}
# empty override = global value, keys are class names (REQ-181)
# +exemplars[] {box:[cx,cy,w,h], positive} — the touched class's own pool only
# +exemplar_class_name, +target_class_names; per-class, accumulating
@@ -249,10 +249,10 @@ updated. Range and fps live on the batch, so one video can be used repeatedly.
**autolabel (REQ-030…034).** Per frame: one `set_image`, then loop each class's prompt (see
the domain invariants in `../AGENTS.md`), cross-class greedy NMS (REQ-031), write `annotations` rows with
`source='auto'`. `class_params` (REQ-181) is consulted per class: a numeric
`threshold` / `iou_threshold` / `min_box_frac` entry replaces the job's global for that class
`threshold` / `iou_threshold` / `min_box_frac` / `max_box_frac` entry replaces the job's global for that class
only; classes without an entry — and a run with `class_params` empty or absent — take the
exact global path. The YOLO confidence floor uses `min([global] + overrides)`, the SAM3
threshold/dedup/min-box lists are built only from classes that actually override that key,
threshold/dedup/min-box/max-box lists are built only from classes that actually override that key,
and `/preview` applies the same overrides so the preview and the job cannot disagree.
The job and `/preview` also read `project_classes.container` per class when building the
container-id set for the NMS containment carve-out — same stored flag for both (REQ-184,
@@ -363,7 +363,7 @@ per-class overrides table also carries a **Container** checkbox (REQ-184) that t
`project_classes.container` through `PATCH /api/projects/{id} { containers: … }`. The same
table carries a **Copy** button (REQ-186): one click puts every selected class's effective
settings (per-class override where set, else the global slider; container = the checkbox
state) on the clipboard as short lines `class <name> conf … iou … minbox … container
state) on the clipboard as short lines `class <name> conf … iou … minbox … maxbox … container
true|false`, through `clipboard.js`'s `copyText` (Clipboard API with an `execCommand`
fallback for insecure contexts) — read-only, no network, no setting changed. Because
`ClassParamsTable` is shared, the mass modal carries the button too.
@@ -394,7 +394,7 @@ with the shortened pool.
The run is a **dry run by default** (REQ-175). `ExemplarFilterPanel.jsx` is passed to
`ReviewSidebar` and rendered above the class list — not over the frame, which is where its
proposals are drawn. It is mounted only while a run is undecided (`pool.active`): the first
drag opens it, Apply and Discard close it, and the pool outlives it. Its four sliders
drag opens it, Apply and Discard close it, and the pool outlives it. Its five sliders
re-preview on a 250 ms debounce. While a preview is up the canvas **hides the stored shapes
of the class under review** — the run replaces them wholesale, and leaving them on screen made
a rejected detection look like it had never gone; other classes stay, dimmed
@@ -419,7 +419,7 @@ POST /api/frames/{id}/exemplar-label
{ "exemplars": [{"box": [x0,y0,x1,y1], "positive": true}, …], # normalized xyxy
"class_id": 0,
"threshold": 0.5, "iou_threshold": 0.8, # the panel (REQ-175)
"min_box_frac": 0.002, "max_detections": 100,
"min_box_frac": 0.002, "max_box_frac": 1.0, "max_detections": 100,
"apply": false }
→ { "shapes": [{"geometry": …, "score": …, "source": "manual"|"auto"}, …],
"applied": false, "redetected": true, "message": null,
@@ -440,7 +440,7 @@ then rewrites the frame **for that class only**:
while the drawn shapes survive.
The panel's filters run before any of that, in the order the batch job uses them: area floor,
then NMS (`labeling.deduplicate`), then the cap on how many survive.
then ceiling (REQ-188), then NMS (`labeling.deduplicate`), then the cap on how many survive.
The delete-and-reinsert happens in one `db.cursor()` transaction, so the frame is never
briefly empty. Other classes on the frame are never touched. The GPU lock is taken with a
+18
View File
@@ -1371,6 +1371,24 @@ read it.
raw path in `list_models` payload, `secondary_model_path` outside REQ-187 scope,
non-str TypeError unreachable); no DB rewrite needed — resolver covers legacy rows.
## Task — per-class max box fraction (REQ-188) `[DONE]`
1. `max_box_frac` (default `1.0` = off) mirrored 1:1 over every `min_box_frac` site:
`labeling.label_image` gains `max_box_frac`/`max_box_fracs` with a ceiling block right
after the floor (before dedup), guard `frac >= 1 or frac <= 0` → keep; `preview.py` and
`autolabel.py` gain the inline `xyxyn` gate (`xb and xb < 1`) and the `mx_list` per-class
build (fallback `1.0`) passed as `max_box_fracs=`; `_parse_class_params` accepts the key;
`exemplar.label` filters inline (it does not delegate to `label_image`) with
`0 < max_box_frac < 1` between floor and NMS; `ExemplarLabelRequest` carries the explicit
review-filter field through to `exemplar_store.label`. Frontend: `MaxBox` added to
`ClassParamsTable` KEYS (so `buildClassParams` sends it automatically), `maxbox <v>`
inserted in the copy line per amended REQ-186, `max_box_frac: 1.0` in both modals'
globals, `Max box size` slider in `ExemplarFilterPanel` + `FILTER_DEFAULTS` —
verify: **[DONE]** import smoke exit 0; `npm run build` green (520 ms); behavioral harness
over `label_image` prints the 8 cases (off/0/1/per-class/list-fallback/floor+ceiling)
with expected drops; node eval proves `buildClassParams` emits `max_box_frac`;
`git diff --stat` = 5 backend + 5 frontend files + docs.
## Known open points
- *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume
+8 -7
View File
@@ -450,7 +450,7 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
- NMS IoU threshold — 0…0.9 step 0.05, default 0.0.
- Min box size (fraction of frame) — 0…0.5 step 0.005, default 0, shown as a percentage.
- **Per-class overrides** (REQ-181) — a compact table below the sliders, one row per currently
selected class: `Conf` / `IoU` / `MinBox` number inputs and a **Container** checkbox
selected class: `Conf` / `IoU` / `MinBox` / `MaxBox` number inputs and a **Container** checkbox
(REQ-184). Each input's placeholder shows the current global value and an empty input
inherits it; only filled cells are sent, as
`class_params` on **both** the preview and the job request, so preview and run cannot
@@ -465,7 +465,7 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
- **Copy (REQ-186)** — small right-aligned ghost button above the overrides table; label
`Copy`, flips to `Copied` for 1.5 s inside an `aria-live` span, and on failure silently
stays `Copy`. One line per selected class, e.g.
`class sack conf 0.4 iou 0.6 minbox 0.005 container false` — an empty input copies the
`class sack conf 0.4 iou 0.6 minbox 0.005 maxbox 1 container false` — an empty input copies the
global value. Present in the engine-chooser modal **and** the mass modal (shared
component).
- `ClassPromptPanel`:
@@ -1024,13 +1024,14 @@ over the frame** — the shapes it governs are drawn on the canvas, and a card o
covers the thing being judged. It scrolls itself into view on mount, because the sidebar
scrolls and a decision the user cannot see is not a decision.
Its four sliders, re-previewing on a 250 ms debounce:
Its five sliders, re-previewing on a 250 ms debounce:
| Key | Label | Range | Default | Hint |
|---|---|---|---|---|
| `threshold` | Confidence | 0.05–0.95 | 0.5 | lower finds more, and more junk |
| `iou_threshold` | Overlap (NMS) | 0.1–1 | 0.8 | boxes overlapping this much are one object; lower deletes more |
| `min_box_frac` | Min box size | 0–0.05 | 0.002 | shown as % of frame; higher deletes more |
| `max_box_frac` | Max box size | 0–1 | 1.0 | shown as % of frame; boxes bigger than this are junk, 100% = off |
| `max_detections` | Max shapes | 5–300 | 100 | keep only the highest-scoring N |
Panel header: `Auto-label "<class>"` + *Undo* (drops the last example drawn). Status line:
@@ -1062,7 +1063,7 @@ POST /api/frames/{id}/exemplar-label
{ "exemplars": [{ "box": [x0,y0,x1,y1], "positive": true }],
"class_id": 0,
"threshold": 0.5, "iou_threshold": 0.8,
"min_box_frac": 0.002, "max_detections": 100,
"min_box_frac": 0.002, "max_box_frac": 1.0, "max_detections": 100,
"apply": false }
→ { "shapes": [{ "geometry", "score", "source": "manual"|"auto" }],
"applied": false, "redetected": true, "message": null,
@@ -1072,7 +1073,7 @@ POST /api/frames/{id}/exemplar-label
Server-side, positives become `source="manual"` and survive a batch re-run; what SAM3 adds
becomes `source="auto"` and is replaceable. Detections overlapping a negative by ≥0.3 are
dropped; ones overlapping a positive by ≥0.6 are dropped as already covered. The panel's
filters run first, in the batch job's order: area floor → NMS → cap.
filters run first, in the batch job's order: area floor → ceiling → NMS → cap.
**GPU-lock timeout is 5 s here** (versus 20 s for `assist`) because this fires from a mouse
gesture. On timeout the response carries `redetected: false`, a message the panel shows, and
@@ -1527,8 +1528,8 @@ Shortcuts).
- Dua mode: **Draw** / **Select** (`V`). Select mode tidak pernah mengubah geometri; Draw mode
tidak marquee.
- **Di draw mode, drag bukan menggambar kotak — itu adalah *contoh*.** User menggambar satu
instance, SAM3 mencari sisanya, hasilnya di-*preview*, di-*tune* lewat 4 slider (Confidence,
Overlap/NMS, Min box size, Max shapes), lalu baru **Apply** yang menulis. Shift-drag = contoh
instance, SAM3 mencari sisanya, hasilnya di-*preview*, di-*tune* lewat 5 slider (Confidence,
Overlap/NMS, Min box size, Max box size, Max shapes), lalu baru **Apply** yang menulis. Shift-drag = contoh
negatif ("bukan ini"). Panel filter duduk di **sidebar**, bukan menutupi frame.
- Tahan `S` = SAM3 click-assist (satu box → satu shape).
- Keyboard-first: `V ← → A X U N C T 1–9 Del H Esc Enter`. Semua aksi punya shortcut; panel
@@ -364,7 +364,7 @@ export default function AutoAnnotateModal({
<div style={{ marginBottom: 12 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Per-class overrides (empty = global):</span>
<div style={{ marginTop: 4 }}>
<ClassParamsTable classNames={selectedClasses} globals={{ threshold, iou_threshold: iouThreshold, min_box_frac: minBoxFrac }} value={classParams} onChange={setClassParams} project={project} />
<ClassParamsTable classNames={selectedClasses} globals={{ threshold, iou_threshold: iouThreshold, min_box_frac: minBoxFrac, max_box_frac: 1.0 }} value={classParams} onChange={setClassParams} project={project} />
</div>
</div>
+4 -1
View File
@@ -7,6 +7,7 @@ const KEYS = [
{ key: 'threshold', label: 'Conf', step: 0.05, color: '#38bdf8' },
{ key: 'iou_threshold', label: 'IoU', step: 0.05, color: '#c084fc' },
{ key: 'min_box_frac', label: 'MinBox', step: 0.005, color: '#34d399' },
{ key: 'max_box_frac', label: 'MaxBox', step: 0.05, color: '#f472b6' },
]
export function buildClassParams(classParams) {
@@ -66,7 +67,9 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
(name) =>
`class ${name} conf ${fmt(effective(name, 'threshold'))} iou ${fmt(
effective(name, 'iou_threshold')
)} minbox ${fmt(effective(name, 'min_box_frac'))} container ${containers.has(name)}`
)} minbox ${fmt(effective(name, 'min_box_frac'))} maxbox ${fmt(
effective(name, 'max_box_frac')
)} container ${containers.has(name)}`
)
.join('\n')
const copy = async () => {
@@ -19,6 +19,9 @@ const SLIDERS = [
{ key: 'min_box_frac', label: 'Min box size', min: 0, max: 0.05, step: 0.001,
color: '#34d399', format: (v) => `${(v * 100).toFixed(1)}% of frame`,
hint: 'Boxes smaller than this are specks. Higher deletes more.' },
{ key: 'max_box_frac', label: 'Max box size', min: 0, max: 1, step: 0.05,
color: '#f472b6', format: (v) => `${(v * 100).toFixed(0)}% of frame`,
hint: 'Boxes bigger than this are junk. 100% = off.' },
{ key: 'max_detections', label: 'Max shapes', min: 5, max: 300, step: 5,
color: '#fbbf24', format: (v) => String(v),
hint: 'Keep only the highest-scoring N.' },
@@ -250,7 +250,7 @@ export default function MassAutoAnnotateModal({ batches, project, onClose, onSuc
<div style={{ marginBottom: 12 }}>
<span className="hint" style={{ fontSize: '0.8rem' }}>Per-class overrides (empty = global):</span>
<div style={{ marginTop: 4 }}>
<ClassParamsTable classNames={selectedClasses} globals={{ threshold, iou_threshold: iouThreshold, min_box_frac: minBoxFrac }} value={classParams} onChange={setClassParams} project={project} />
<ClassParamsTable classNames={selectedClasses} globals={{ threshold, iou_threshold: iouThreshold, min_box_frac: minBoxFrac, max_box_frac: 1.0 }} value={classParams} onChange={setClassParams} project={project} />
</div>
</div>
+1
View File
@@ -17,6 +17,7 @@ export const FILTER_DEFAULTS = {
threshold: 0.5,
iou_threshold: 0.8,
min_box_frac: 0.002,
max_box_frac: 1.0,
max_detections: 100,
}