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.49 kB
"""SSD / EfficientDet-Lite postprocessor (TFLite_Detection_PostProcess op).
Used by the EdgeTPU (Coral USB Edge TPU) runtime with the Coral model-zoo
SSD MobileNet V2 and EfficientDet-Lite detectors. Those models embed the
``TFLite_Detection_PostProcess`` op, so the 4 output tensors are ALREADY
decoded (boxes + per-box class + score, post-NMS):
output 0 boxes [1, N, 4] (ymin, xmin, ymax, xmax) normalised 0..1
output 1 classes [1, N] float class indices (COCO 90-class map)
output 2 scores [1, N] float 0..1
output 3 count [1] number of valid detections
The inference_pool ``edgetpu`` predict closure returns them keyed by their
positional output index ("0".."3"). Output matches the yolo postprocessor:
``{"kind": "detections", "detections": [{"class", "score", "bbox":[x1,y1,x2,y2]}]}``
with pixel bboxes in ORIGINAL frame coordinates.
The same layout, verified per model on the upstream graph (2026-09-26):
ssd-mobilenet-v2-coco-edgetpu 300x300 uint8, N=20
efficientdet-lite0-edgetpu 320x320 uint8, N=25
ssdlite-mobiledet-coco-edgetpu 320x320 uint8, N=100, 91 score columns
(background + COCO-90), so the emitted
class index is already 0-based: 0 = person
The decode is plain Python over nested lists. Each tensor is turned into a list
once (``ndarray.tolist()``), so this module needs no numpy: CI's self-hosted
runner has none, and the MobileDet fixture test (``test_ssd.py``) must run
there, not be skipped there. Measured: ~85 µs per frame against ~14 µs for
the old numpy decode (about 6×), under 1% of a core at 100 fps and small next
to a ~9 ms Coral invoke.
"""
from __future__ import annotations
from typing import Any
# Coral COCO 90-class label map (github.com/google-coral/test_data coco_labels.txt).
# The "n/a" placeholders keep the raw model class indices aligned and are skipped.
COCO_90 = [
"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck",
"boat", "traffic light", "fire hydrant", "n/a", "stop sign", "parking meter",
"bench", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear",
"zebra", "giraffe", "n/a", "backpack", "umbrella", "n/a", "n/a", "handbag",
"tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", "kite",
"baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket",
"bottle", "n/a", "wine glass", "cup", "fork", "knife", "spoon", "bowl",
"banana", "apple", "sandwich", "orange", "broccoli", "carrot", "hot dog",
"pizza", "donut", "cake", "chair", "couch", "potted plant", "bed", "n/a",
"dining table", "n/a", "n/a", "toilet", "n/a", "tv", "laptop", "mouse",
"remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink",
"refrigerator", "n/a", "book", "clock", "vase", "scissors", "teddy bear",
"hair drier", "toothbrush",
]
def _as_list(value: Any) -> Any:
"""A tensor as nested Python lists (a scalar stays a scalar)."""
return value.tolist() if hasattr(value, "tolist") else value
def _shape(value: Any) -> tuple:
"""numpy-style shape of a nested list; ``()`` for a scalar."""
dims: list[int] = []
while isinstance(value, (list, tuple)):
dims.append(len(value))
if not value:
break
value = value[0]
return tuple(dims)
def _flatten(value: Any) -> list:
"""Every scalar of a nested list, in row-major order (``reshape(-1)``)."""
if not isinstance(value, (list, tuple)):
return [value]
flat: list = []
for item in value:
flat.extend(_flatten(item))
return flat
def _rows_of_4(value: Any) -> list:
"""``reshape(-1, 4)`` of a nested list; refuses a size numpy would refuse."""
flat = _flatten(value)
if len(flat) % 4 != 0:
raise ValueError(f"cannot reshape {len(flat)} box values into rows of 4")
return [flat[i:i + 4] for i in range(0, len(flat), 4)]
def _split_outputs(predictions: dict) -> tuple:
"""Resolve (boxes, classes, scores, count) from the raw prediction dict.
Primary path: positional keys "0".."3" (the edgetpu predict closure).
Fallback: shape-based identification for name-keyed / reordered exports.
"""
if all(k in predictions for k in ("0", "1", "2", "3")):
return (
_as_list(predictions["0"]),
_as_list(predictions["1"]),
_as_list(predictions["2"]),
_as_list(predictions["3"]),
)
vals = [_as_list(v) for v in predictions.values()]
boxes = count = None
two_d: list = []
for v in vals:
shape = _shape(v)
ndim = len(shape)
if ndim >= 2 and shape[-1] == 4:
boxes = v
elif ndim == 1 or (ndim == 2 and shape[-1] == 1):
count = v
elif ndim == 2:
two_d.append(v)
# TFLite_Detection_PostProcess emits classes BEFORE scores.
classes = two_d[0] if len(two_d) > 0 else None
scores = two_d[1] if len(two_d) > 1 else None
return boxes, classes, scores, count
def label_for_class(cls: int) -> str:
"""COCO-90 label of a raw class index; ``"n/a"`` for a gap, ``str`` if outside."""
return COCO_90[cls] if 0 <= cls < len(COCO_90) else str(cls)
def postprocess_ssd(
predictions: dict,
config: dict,
orig_w: int,
orig_h: int,
scale: float,
pad: tuple,
) -> dict:
"""Decode SSD/EfficientDet postprocess outputs to original-frame detections."""
conf_threshold = float(config.get("confidence", 0.4) or 0.4)
input_size = int(config.get("inputSize", 300) or 300)
boxes, classes, scores, count = _split_outputs(predictions)
if boxes is None or scores is None:
return {"kind": "detections", "detections": []}
boxes = _rows_of_4(boxes)
scores = _flatten(scores)
classes = _flatten(classes) if classes is not None else [0.0] * len(scores)
n = len(scores)
count_flat = _flatten(count) if count is not None else []
if count_flat:
n = min(n, int(count_flat[0]))
n = min(n, len(boxes), len(classes))
pad_x, pad_y = pad
is_letterbox = not (abs(scale - 1.0) < 1e-6 and pad_x == 0 and pad_y == 0)
detections = []
for i in range(n):
score = float(scores[i])
if score < conf_threshold:
continue
ymin, xmin, ymax, xmax = (float(v) for v in boxes[i])
if is_letterbox:
# Invert letterbox: normalised -> input px -> remove pad -> unscale.
x1 = (xmin * input_size - pad_x) / scale
y1 = (ymin * input_size - pad_y) / scale
x2 = (xmax * input_size - pad_x) / scale
y2 = (ymax * input_size - pad_y) / scale
else:
# Plain resize: normalised coords map linearly to the original frame.
x1, y1, x2, y2 = xmin * orig_w, ymin * orig_h, xmax * orig_w, ymax * orig_h
x1 = max(0.0, min(x1, orig_w))
x2 = max(0.0, min(x2, orig_w))
y1 = max(0.0, min(y1, orig_h))
y2 = max(0.0, min(y2, orig_h))
if x2 <= x1 or y2 <= y1:
continue
label = label_for_class(int(classes[i]))
if label == "n/a":
continue
detections.append({
"class": label,
"score": round(score, 4),
"bbox": [round(x1, 1), round(y1, 1), round(x2, 1), round(y2, 1)],
})
return {"kind": "detections", "detections": detections}