File size: 7,488 Bytes
ebeed67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
"""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}