feat: per-class autolabel params + frame-scoped class clear (REQ-180, REQ-181)
- REQ-181: class_params {name: {threshold?, iou_threshold?, min_box_frac?}}
on /preview and /autolabel, per-class override table in both modals;
empty overrides take the unchanged global path
- REQ-180: x button on each review sidebar class row clears that class on
the current frame only via bulk-delete, no confirmation
- includes REQ-178 empty date-folder cycle fix (archive_index.py)
This commit is contained in:
1 parent
c244ab9795
commit
1a99f2ffe4
16 files changed
+372
-45
No files matched your search
+59
-5
@@ -17,6 +17,26 @@ DEFAULT_THRESHOLD = 0.35
|
||||
DEFAULT_IOU = 0.0
|
||||
|
||||
|
||||
def _parse_class_params(raw) -> dict:
|
||||
"""Normalize `class_params` (REQ-181): lowercased class name → overrides.
|
||||
|
||||
Unknown keys and non-numeric values are dropped, so a malformed payload
|
||||
degrades to the global values instead of failing the job."""
|
||||
out: dict = {}
|
||||
for name, values in (raw or {}).items():
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
entry = {}
|
||||
for key in ("threshold", "iou_threshold", "min_box_frac"):
|
||||
if key in values:
|
||||
try:
|
||||
entry[key] = float(values[key])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if entry:
|
||||
out[str(name).strip().lower()] = entry
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD,
|
||||
@@ -26,7 +46,8 @@ def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD,
|
||||
class_ids: Optional[List[int]] = None,
|
||||
engine_classes: Optional[dict[str, List[str]]] = None,
|
||||
custom_model_path: Optional[str] = None,
|
||||
target_class_names: Optional[List[str]] = None) -> dict:
|
||||
target_class_names: Optional[List[str]] = None,
|
||||
class_params: Optional[dict] = None) -> dict:
|
||||
batch = batches.get(batch_id)
|
||||
if batch is None:
|
||||
raise batches.BatchError("No such batch")
|
||||
@@ -41,7 +62,8 @@ def start(batch_id: int, threshold: float = DEFAULT_THRESHOLD,
|
||||
"iou_threshold": iou_threshold, "min_box_frac": min_box_frac,
|
||||
"resume": resume, "append": append, "engine": active_engines[0], "engines": active_engines,
|
||||
"class_ids": class_ids, "engine_classes": engine_classes,
|
||||
"custom_model_path": custom_model_path, "target_class_names": target_class_names},
|
||||
"custom_model_path": custom_model_path, "target_class_names": target_class_names,
|
||||
"class_params": class_params},
|
||||
project_id=batch["project_id"],
|
||||
batch_id=batch_id,
|
||||
message=f"{batch['date_label']}/{batch['batch_label']} ({'+'.join(e.upper() for e in active_engines)})",
|
||||
@@ -85,6 +107,10 @@ def _run_autolabel(job) -> None:
|
||||
|
||||
conf = job.params.get("threshold", DEFAULT_THRESHOLD)
|
||||
iou_thresh = job.params.get("iou_threshold", DEFAULT_IOU)
|
||||
per_class = _parse_class_params(job.params.get("class_params"))
|
||||
# Predict at the LOWEST threshold in play so a class with a lower override
|
||||
# can still see its boxes; each box then passes its own class gate below.
|
||||
predict_conf = min([conf] + [v["threshold"] for v in per_class.values() if "threshold" in v])
|
||||
|
||||
yolo_model = None
|
||||
sam3_target_classes = []
|
||||
@@ -159,7 +185,7 @@ def _run_autolabel(job) -> None:
|
||||
all_raw_detections = []
|
||||
|
||||
if yolo_model is not None:
|
||||
results = yolo_model.predict(frame_file, conf=conf, verbose=False)
|
||||
results = yolo_model.predict(frame_file, conf=predict_conf, verbose=False)
|
||||
if results and len(results) > 0:
|
||||
model_names = results[0].names
|
||||
for box in results[0].boxes:
|
||||
@@ -189,6 +215,15 @@ def _run_autolabel(job) -> None:
|
||||
|
||||
score = float(box.conf[0].item())
|
||||
xyxyn = box.xyxyn[0].tolist()
|
||||
|
||||
cp = per_class.get(proj_cls_name) or per_class.get(raw_cls_name)
|
||||
if cp:
|
||||
if score < cp.get("threshold", conf):
|
||||
continue
|
||||
mb = cp.get("min_box_frac")
|
||||
if mb and mb > 0 and (xyxyn[2]-xyxyn[0])*(xyxyn[3]-xyxyn[1]) < mb:
|
||||
continue
|
||||
|
||||
all_raw_detections.append(labeling.Detection(
|
||||
class_id=target_class_id,
|
||||
class_name=proj_cls_name or raw_cls_name,
|
||||
@@ -199,9 +234,21 @@ 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
|
||||
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()):
|
||||
thr_list = [per_class.get(n, {}).get("threshold", conf) for n in names]
|
||||
if any("iou_threshold" in v for v in per_class.values()):
|
||||
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]
|
||||
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)
|
||||
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,
|
||||
)
|
||||
if not res.error and res.detections:
|
||||
for det in res.detections:
|
||||
@@ -213,7 +260,14 @@ def _run_autolabel(job) -> None:
|
||||
elif res.error:
|
||||
job.log(f"[SAM3 ERROR] {frame['filename']}: {res.error}")
|
||||
|
||||
kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh)
|
||||
iou_by_class_proj = {
|
||||
c["class_id"]: per_class[c["name"].strip().lower()]["iou_threshold"]
|
||||
for c in project["classes"]
|
||||
if c["name"].strip().lower() in per_class
|
||||
and "iou_threshold" in per_class[c["name"].strip().lower()]
|
||||
}
|
||||
kept = labeling.deduplicate(all_raw_detections, iou_threshold=iou_thresh,
|
||||
iou_by_class=iou_by_class_proj or None)
|
||||
items = []
|
||||
for det in kept:
|
||||
if project["label_type"] == "bbox" or det.mask is None:
|
||||
|
||||
Reference in new issue
Block a user