apocaliss92's picture
camstack-train 0.1.0: YOLOv9 root detector fine-tune (job.json in, result.json + bundle.tar out)
ebeed67 verified
Raw History Blame Contribute Delete
7.18 kB
"""RF-DETR postprocessor (DETR-style query decode).
Used by the `rfdetr-package` parcel detector on the `package-detection` step
(per-model `postprocessor: 'rfdetr'` override — the step default is `yolo`).
Model contract, verified on the shipped export (onnx / openvino / coreml all
agree to |d| <= 0.005 in sigmoid space):
input "input" [1, 3, 576, 576] float, square RESIZE (no letterbox),
ImageNet mean/std normalised
(catalog: preprocessMode 'resize' +
inputNormalization 'imagenet')
output "dets" [1, 300, 4] cxcywh, NORMALISED to the input image
output "labels" [1, 300, 2] RAW logits; sigmoid -> per-query prob.
Three things differ from the `yolo` postprocessor and every one of them is a
way to get zero detections while looking correct:
1. **Column 0 is the no-object slot; the real classes start at 1.**
The upstream rfdetr helper slices `logits[..., :-1]` — it drops the LAST
column. On this checkpoint (`num_classes=1` ⇒ head width 2) that would throw
away the `package` column and keep the background one, so every frame
decodes as "nothing, forever". We slice `[..., 1:]`. Measured: the
background column never exceeded 0.003 on any test frame, parcel or not.
2. **No letterbox, so `scale`/`pad` are unused.** Boxes map onto the original
crop by multiplying the normalised cxcywh by orig_w / orig_h directly. The
normalised coords are NOT sigmoid-bounded and DO exceed 1.0 (and go below 0)
on edge queries — they are clamped to the frame, and a box that clamps to
nothing is dropped.
3. **300 independent queries, no objectness.** NMS still runs (near-duplicate
queries do occur) but the model is not anchor-based, so the default IoU is
looser (0.50) than the yolo default on purpose.
DEGENERATE-BOX GUARD (measured, not theoretical): on a parcel-free crop
dominated by one large dark rectangle (the doormat) the top query becomes a box
spanning ~the whole crop at 0.33. Every real parcel detection was <= 25% of the
crop area. Rejecting boxes covering more than `maxAreaFraction` of the crop
removes that failure mode without touching a single true positive.
Output matches the yolo postprocessor:
``{"kind": "detections", "detections": [{"class", "score", "bbox":[x1,y1,x2,y2]}]}``
with pixel bboxes in ORIGINAL frame coordinates.
"""
from __future__ import annotations
import numpy as np
# Fraction of the input crop above which a box is treated as the degenerate
# whole-crop query rather than a parcel. Config key: `maxAreaFraction`.
DEFAULT_MAX_AREA_FRACTION = 0.60
# DETR queries are not anchors, so duplicates overlap less tightly than a yolo
# grid does. Config key: `nmsIouThreshold` (shared with the yolo decoder).
DEFAULT_NMS_IOU = 0.50
def _iou(a: tuple, b: tuple) -> float:
"""IoU between two boxes (x1, y1, x2, y2)."""
ix1 = max(a[0], b[0])
iy1 = max(a[1], b[1])
ix2 = min(a[2], b[2])
iy2 = min(a[3], b[3])
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
if inter == 0:
return 0.0
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
return inter / union if union > 0 else 0.0
def _nms(boxes: list, iou_threshold: float) -> list:
"""Greedy non-maximum suppression over `_xyxy`, highest score first."""
kept: list = []
for box in sorted(boxes, key=lambda b: b["score"], reverse=True):
if all(_iou(box["_xyxy"], k["_xyxy"]) <= iou_threshold for k in kept):
kept.append(box)
return kept
def _resolve_outputs(predictions: dict) -> tuple:
"""(dets, labels) by NAME, falling back to shape when the names are stripped.
The onnx / openvino / coreml exports all keep the `dets` + `labels` names;
the shape fallback covers a re-export that renames them (the boxes tensor is
the one whose last dim is 4).
"""
if "dets" in predictions and "labels" in predictions:
return np.asarray(predictions["dets"]), np.asarray(predictions["labels"])
arrays = [np.asarray(v) for v in predictions.values()]
dets = next((a for a in arrays if a.shape[-1] == 4), None)
labels = next((a for a in arrays if a.shape[-1] != 4), None)
return dets, labels
def postprocess_rfdetr(
predictions: dict,
config: dict,
orig_w: int,
orig_h: int,
scale: float,
pad: tuple,
) -> dict:
"""Decode RF-DETR query output to original-frame detections.
`scale` / `pad` are accepted for signature parity with the other
postprocessors and deliberately UNUSED: this model is a square resize, not a
letterbox, so there is no padding to invert.
"""
conf_threshold = float(config.get("confidence", 0) or 0)
labels_map = config.get("labels") or ["package"]
nms_iou = float(config.get("nmsIouThreshold", DEFAULT_NMS_IOU) or DEFAULT_NMS_IOU)
max_area = float(config.get("maxAreaFraction", DEFAULT_MAX_AREA_FRACTION) or DEFAULT_MAX_AREA_FRACTION)
dets, logits = _resolve_outputs(predictions)
if dets is None or logits is None:
return {"kind": "detections", "detections": []}
dets = dets[0] if dets.ndim == 3 else dets
logits = logits[0] if logits.ndim == 3 else logits
if dets.ndim != 2 or logits.ndim != 2 or logits.shape[1] < 2:
# A single-column `labels` would be the no-object slot alone — nothing
# to decode, and slicing it would raise instead of saying so.
return {"kind": "detections", "detections": []}
probs = 1.0 / (1.0 + np.exp(-logits.astype(np.float32)))
# Column 0 is the DETR background / no-object slot — NOT the last column.
# See the module docstring: dropping the wrong end silently yields zero
# detections on every frame.
class_probs = probs[:, 1:]
best_class = class_probs.argmax(axis=1)
best_score = class_probs.max(axis=1)
frame_area = float(orig_w * orig_h)
if frame_area <= 0:
return {"kind": "detections", "detections": []}
candidates: list = []
for i in np.nonzero(best_score >= conf_threshold)[0]:
cx, cy, w, h = (float(v) for v in dets[i][:4])
# Normalised, NOT sigmoid-bounded: edge queries exceed [0,1] and are
# clamped to the frame rather than dropped.
x1 = max(0.0, (cx - w / 2.0) * orig_w)
y1 = max(0.0, (cy - h / 2.0) * orig_h)
x2 = min(float(orig_w), (cx + w / 2.0) * orig_w)
y2 = min(float(orig_h), (cy + h / 2.0) * orig_h)
if x2 <= x1 or y2 <= y1:
continue
if ((x2 - x1) * (y2 - y1)) / frame_area > max_area:
continue # degenerate whole-crop query (the doormat failure mode)
cls = int(best_class[i])
candidates.append({
"class": labels_map[cls] if cls < len(labels_map) else str(cls),
"score": round(float(best_score[i]), 4),
"bbox": [round(x1, 1), round(y1, 1), round(x2, 1), round(y2, 1)],
"_xyxy": (x1, y1, x2, y2),
})
kept = _nms(candidates, nms_iou)
for box in kept:
box.pop("_xyxy", None)
return {"kind": "detections", "detections": kept}