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: