File size: 18,825 Bytes
4d9b003
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# SPDX-License-Identifier: Apache-2.0
"""C14 image pre-processing, StreamPETR preset: a bit-exact host emulation of Autoware's fused anti-aliased resize +
crop + normalise CUDA kernel (a PIL-style triangle filter with adaptive support), driven by cached per-size tables.
numpy only; no ttnn, no torch, no OpenCV.

Deployed code (autoware_universe main @ 9ceaccf, ``perception/autoware_camera_streampetr``; research/streampetr/SPEC.md
section 3.1):

- ``calculate_image_processing_params`` (``lib/network/camera_data_store.cpp:287-316``), float32:
  ``resize = max(480 / (float)H, 640 / (float)W)``; ``new_w = (int)(W * resize)``, ``new_h = (int)(H * resize)``;
  ``crop_h = max(0, (int)(1.0f * new_h) - 480)`` (the top rows are cropped, the bottom kept: ``bottom_crop_portion``
  0), ``crop_w = max(0, (new_w - 640) / 2)`` (centred) -> :func:`autoware_resize_geometry`.
- ``resize_and_extract_roi_kernel`` (``lib/network/preprocess_kernel.cu:73-176``; the 0.52.1 kernel
  ``resizeAndExtractRoi_kernel`` has the same arithmetic), per output pixel, all float32:
  ``r = (float)(out + roi_start)``, ``scale = (float)in / (float)resized``, ``centre = (r + 0.5f) * scale - 0.5f``,
  ``support = scale > 1 ? scale : 1``, taps ``ceilf(centre - support) .. floorf(centre + support)`` clamped to the
  image, ``w = max(0, 1 - |centre - i| / support)`` per axis, ``w = wx * wy``; the loop runs **y outer, x inner**
  and skips ``w <= 0``: ``sum_c += pix_c * w``, ``sum_w += w``; then ``sum_c /= sum_w`` (when ``sum_w > 0``) and
  ``out_c = (sum_c - mean[c]) / std[c]``, written planar (CHW). The channel order of the output is the byte order
  of the buffer the kernel reads: RGB on main (a ``bgr8`` message is swapped in place first,
  ``convert_bgr_to_rgb_kernel``), the message order on 0.52.1.

The emulation replays exactly these float32 operations in the same order, vectorised over the output pixels: per
output row / column the source taps and the 1-D weights are tabulated once (:class:`TriangleResizeLUT`), and the 2-D
accumulation runs tap pair by tap pair in the kernel's (y outer, x inner) order. Taps outside a pixel's window carry
weight 0, which adds exact zeros (the kernel skips them).

**Floating-point contraction** (``fma``). Written as C, every operation rounds once: ``fma=False`` (default) does
that, and equals the SPEC's numpy port (``research/streampetr/scripts/sp_common.py``) and the research goldens bit
for bit (their ``inputs_sha256.json``). The node's CMakeLists passes no ``--fmad`` / ``--use_fast_math`` flag, so
nvcc compiles with its default ``--fmad=true``, which lets the compiler fuse a multiply feeding an add into one
``fmaf``. ``fma=True`` models LLVM NVPTX's aggressive fusion: ``centre = fmaf(r + 0.5f, scale, -0.5f)``,
``sum_c = fmaf(pix_c, w, sum_c)`` and ``sum_w = fmaf(wx, wy, sum_w)`` (the rounded ``w`` still feeds the channel
products). Which form the deployed binary has cannot be checked without CUDA. The two differ in ~43 % of the
inputs by at most ~3e-5 in normalised units (one ulp of a ~1000 px centre moves every tap weight; measured in
``common/tests/host/test_image_triangle_host.py``), two orders of magnitude below the bf16 rounding of the device
input.

Normalisation presets (:data:`STREAMPETR_PRESETS`, SPEC section 3.4; PLAN.md D7 picks ``autoware_main``):

==================  ======================  ==================================================================
preset              planes (buffer order)   statistics
==================  ======================  ==================================================================
``autoware_main``   R, G, B                 mean (123.675, 116.28, 103.53), std (58.395, 57.12, 57.375)
                                            (universe main / 0.53.0, ``camera_data_store.cpp:128-134``)
``autoware_0.52``   R, G, B (an ``rgb8``    mean (103.53, 116.28, 123.675), std (57.375, 57.12, 58.395) on the
                    camera, as TIER IV's)   message bytes (0.52.1, ``camera_data_store.cpp:122-127``)
``awml_training``   B, G, R                 the 0.52 statistics (AWML t4base v2.5 test pipeline: cv2 decode,
                                            ``to_rgb=False``; = 0.52.1 fed a ``bgr8`` camera)
==================  ======================  ==================================================================
"""
from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any, Dict, Optional, Sequence, Tuple

import numpy as np

from .image import LUTCache

__all__ = [
    "TriangleGeometry",
    "TriangleResizeLUT",
    "StreamPETRPreset",
    "autoware_resize_geometry",
    "triangle_resize_lut",
    "fma_f32",
    "streampetr_preprocess",
    "streampetr_preprocess_batch",
    "STREAMPETR_PRESETS",
    "STREAMPETR_INPUT_HW",
    "STREAMPETR_MEAN_RGB",
    "STREAMPETR_STD_RGB",
    "STREAMPETR_MEAN_BGR",
    "STREAMPETR_STD_BGR",
]

F32 = np.float32
F64 = np.float64
STREAMPETR_INPUT_HW = (480, 640)   # HF ml_package_camera_streampetr.param.yaml:7-8 (input_image_height / _width)
# camera_data_store.cpp:128-134 (main: RGB order) and AW052 camera_data_store.cpp:122-127 (0.52.1: BGR order)
STREAMPETR_MEAN_RGB = (123.675, 116.280, 103.530)
STREAMPETR_STD_RGB = (58.395, 57.120, 57.375)
STREAMPETR_MEAN_BGR = (103.530, 116.280, 123.675)
STREAMPETR_STD_BGR = (57.375, 57.120, 58.395)


@dataclass(frozen=True)
class StreamPETRPreset:
    """One normalisation preset: the plane order the kernel sees (``"rgb"`` or ``"bgr"``) and the per-plane
    statistics, as float32 values (the kernel reads them from a float device buffer)."""

    name: str
    planes: str
    mean: Tuple[float, float, float]
    std: Tuple[float, float, float]
    description: str = ""

    def mean_f32(self) -> np.ndarray:
        return np.asarray(self.mean, dtype=F32)

    def std_f32(self) -> np.ndarray:
        return np.asarray(self.std, dtype=F32)


STREAMPETR_PRESETS: Dict[str, StreamPETRPreset] = {
    "autoware_main": StreamPETRPreset("autoware_main", "rgb", STREAMPETR_MEAN_RGB, STREAMPETR_STD_RGB,
                                      "Autoware universe main / 0.53.0: RGB planes, RGB-ordered statistics"),
    "autoware_0.52": StreamPETRPreset("autoware_0.52", "rgb", STREAMPETR_MEAN_BGR, STREAMPETR_STD_BGR,
                                      "Autoware 0.52.1 fed an rgb8 camera: message-order (RGB) planes, "
                                      "BGR-ordered statistics"),
    "awml_training": StreamPETRPreset("awml_training", "bgr", STREAMPETR_MEAN_BGR, STREAMPETR_STD_BGR,
                                      "AWML t4base v2.5 training / test pipeline (= 0.52.1 fed a bgr8 camera): "
                                      "BGR planes, BGR statistics"),
}


# --------------------------------------------------------------------------------------------- geometry


@dataclass(frozen=True)
class TriangleGeometry:
    """``calculate_image_processing_params`` of one source size: the virtual resized image and the ROI cut from it."""

    src_hw: Tuple[int, int]
    resized_hw: Tuple[int, int]       # (new_h, new_w)
    roi_hw: Tuple[int, int]           # the network input (480, 640)
    roi_start: Tuple[int, int]        # (start_y, start_x) in the resized image
    resize: float                     # the float32 resize factor, as an exact Python float

    @property
    def resize_f32(self) -> np.float32:
        return F32(self.resize)

    def to_dict(self) -> Dict[str, Any]:
        return {"src_hw": list(self.src_hw), "resized_hw": list(self.resized_hw), "roi_hw": list(self.roi_hw),
                "roi_start": list(self.roi_start), "resize": self.resize}


def autoware_resize_geometry(src_h: int, src_w: int, dst_h: int = STREAMPETR_INPUT_HW[0],
                             dst_w: int = STREAMPETR_INPUT_HW[1]) -> TriangleGeometry:
    """``camera_data_store.cpp:287-316`` in float32 (module docstring)."""
    src_h, src_w, dst_h, dst_w = int(src_h), int(src_w), int(dst_h), int(dst_w)
    if min(src_h, src_w, dst_h, dst_w) <= 0:
        raise ValueError(f"sizes must be positive: src {src_h}x{src_w}, dst {dst_h}x{dst_w}")
    scale_h = F32(F32(dst_h) / F32(src_h))
    scale_w = F32(F32(dst_w) / F32(src_w))
    resize = scale_w if scale_h < scale_w else scale_h            # std::max(scaleH, scaleW)
    new_w = int(F32(F32(src_w) * resize))                         # static_cast<int>(int * float): truncation
    new_h = int(F32(F32(src_h) * resize))
    crop_h = max(0, int(F32(F32(1.0) * F32(new_h))) - dst_h)      # (1.0f - bottom_crop_portion) * newH, portion 0
    crop_w = max(0, int((new_w - dst_w) / 2)) if new_w > dst_w else 0   # C int division, then std::max(0, .)
    return TriangleGeometry((src_h, src_w), (new_h, new_w), (dst_h, dst_w), (max(0, crop_h), max(0, crop_w)),
                            float(resize))


# --------------------------------------------------------------------------------------------- arithmetic


def fma_f32(a: Any, b: Any, c: Any) -> np.ndarray:
    """``fmaf(a, b, c)`` for float32 arrays: ``a * b + c`` rounded ONCE to float32 (round to nearest even), exactly.

    The float64 product of two float32 values is exact; the float64 sum may round, and a float64 result that lands
    on a float32 tie the exact value does not sit on would round twice. The TwoSum error term detects that case and
    the tie is broken towards the exact value."""
    a64 = np.asarray(a, dtype=F32).astype(F64)
    b64 = np.asarray(b, dtype=F32).astype(F64)
    c64 = np.asarray(c, dtype=F32).astype(F64)
    p = a64 * b64
    s = p + c64
    r = np.asarray(s.astype(F32))
    bv = s - p
    err = (p - (s - bv)) + (c64 - bv)            # p + c == s + err exactly (Knuth's TwoSum)
    bad = np.nonzero(np.broadcast_to(err, s.shape) != 0)
    if bad[0].size:
        sb, eb = s[bad], np.broadcast_to(err, s.shape)[bad]
        rb = r[bad]
        d = sb - rb.astype(F64)
        nb = np.nextafter(rb, np.where(d > 0, np.inf, -np.inf).astype(F32))
        tie = (d != 0) & (sb == (rb.astype(F64) + nb.astype(F64)) * 0.5)
        fix = tie & (np.sign(eb) == np.sign(d))
        rb = np.where(fix, nb, rb)
        r = r.copy()
        r[bad] = rb
    return r


def _axis_taps(n_out: int, start: int, n_in: int, n_resized: int, fma: bool) -> Tuple[np.ndarray, np.ndarray]:
    """Source index [n_out, T] and float32 weight [n_out, T] of every output row (or column), in the kernel's tap
    order; taps past a pixel's window have weight 0 (index clamped into the image)."""
    scale = F32(F32(n_in) / F32(n_resized))
    support = scale if scale > F32(1.0) else F32(1.0)
    r = (np.arange(n_out, dtype=np.int64) + int(start)).astype(F32)         # (float)(out + roi_start), exact
    r5 = (r + F32(0.5)).astype(F32)
    if fma:
        centre = (r5.astype(F64) * F64(scale) - 0.5).astype(F32)            # one rounding (exact in float64)
    else:
        centre = ((r5 * scale).astype(F32) - F32(0.5)).astype(F32)
    lo = np.maximum(0, np.ceil((centre - support).astype(F32)).astype(np.int64))
    hi = np.minimum(n_in - 1, np.floor((centre + support).astype(F32)).astype(np.int64))
    n_taps = max(1, int((hi - lo).max()) + 1)
    idx = lo[:, None] + np.arange(n_taps, dtype=np.int64)[None, :]
    valid = idx <= hi[:, None]
    idx = np.minimum(idx, n_in - 1)
    d = (centre[:, None] - idx.astype(F32)).astype(F32)
    w = (F32(1.0) - (np.abs(d) / support).astype(F32)).astype(F32)
    w = np.maximum(F32(0.0), w).astype(F32)
    w = np.where(valid, w, F32(0.0)).astype(F32)
    return idx, w


@dataclass(frozen=True)
class TriangleResizeLUT:
    """The per-size tables of ``resize_and_extract_roi_kernel``: build with :func:`triangle_resize_lut` (cached),
    apply with :meth:`apply` (the per-pixel weighted mean, before the mean / std normalisation)."""

    geometry: TriangleGeometry
    rows: np.ndarray       # (roi_h, Ty) int64 source rows
    row_w: np.ndarray      # (roi_h, Ty) float32 wy (0 past the window)
    cols: np.ndarray       # (roi_w, Tx) int64 source columns
    col_w: np.ndarray      # (roi_w, Tx) float32 wx
    fma: bool

    def apply(self, image: np.ndarray, *, out: Optional[np.ndarray] = None) -> np.ndarray:
        """One uint8 image ``(src_h, src_w, C)`` (any channel count, channel order kept) -> float32 ``(C, roi_h,
        roi_w)``: ``sum_c / sum_w`` (``sum_c`` where ``sum_w == 0``), exactly as the kernel before step 9."""
        g = self.geometry
        img = np.asarray(image)
        if img.ndim != 3 or img.dtype != np.uint8 or tuple(img.shape[:2]) != g.src_hw:
            raise ValueError(f"expected a uint8 ({g.src_hw[0]}, {g.src_hw[1]}, C) image, got {img.dtype} {img.shape}")
        roi_h, roi_w = g.roi_hw
        cn = img.shape[2]
        acc = np.zeros((cn, roi_h, roi_w), F32)
        wsum = np.zeros((roi_h, roi_w), F32)
        pix = np.empty((cn, roi_h, roi_w), F32)
        planes = np.ascontiguousarray(img.transpose(2, 0, 1))                   # (C, H, W) uint8
        # the source columns of every horizontal tap, once per image (float32 holds uint8 exactly)
        cols = [planes[:, :, self.cols[:, tx]].astype(F32) for tx in range(self.cols.shape[1])]
        for ty in range(self.rows.shape[1]):                                     # y outer ...
            rows = self.rows[:, ty]
            wy = self.row_w[:, ty][:, None]
            for tx in range(self.cols.shape[1]):                                 # ... x inner, as the kernel
                wx = self.col_w[:, tx][None, :]
                w = (wx * wy).astype(F32)                                        # w = wx * wy (rounded)
                np.take(cols[tx], rows, axis=1, out=pix)                         # (C, roi_h, roi_w) pixels
                if self.fma:
                    acc = fma_f32(pix, w, acc)                                   # sum_c = fmaf(pix, w, sum_c)
                    wsum = fma_f32(np.broadcast_to(wx, w.shape), np.broadcast_to(wy, w.shape), wsum)
                else:
                    np.multiply(pix, w, out=pix)                                 # pix * w (rounded)
                    np.add(acc, pix, out=acc)                                    # sum_c + . (rounded)
                    np.add(wsum, w, out=wsum)
        pos = wsum > 0
        result = np.where(pos, acc / np.where(pos, wsum, F32(1.0)), acc).astype(F32)
        if out is not None:
            if out.shape != result.shape or out.dtype != F32:
                raise ValueError(f"out must be float32 {result.shape}, got {out.dtype} {out.shape}")
            out[...] = result
            return out
        return result


_TRIANGLE_LUTS = LUTCache(maxsize=16)


def triangle_resize_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int] = STREAMPETR_INPUT_HW, *,
                        geometry: Optional[TriangleGeometry] = None, fma: bool = False) -> TriangleResizeLUT:
    """The (cached) tables for one source size. ``geometry`` overrides the StreamPETR rule
    (:func:`autoware_resize_geometry`) for other nodes of the same kernel family (resized size, ROI start)."""
    g = geometry or autoware_resize_geometry(src_hw[0], src_hw[1], dst_hw[0], dst_hw[1])

    def make() -> TriangleResizeLUT:
        rows, row_w = _axis_taps(g.roi_hw[0], g.roi_start[0], g.src_hw[0], g.resized_hw[0], fma)
        cols, col_w = _axis_taps(g.roi_hw[1], g.roi_start[1], g.src_hw[1], g.resized_hw[1], fma)
        return TriangleResizeLUT(g, rows, row_w, cols, col_w, bool(fma))

    key = ("triangle", g.src_hw, g.resized_hw, g.roi_hw, g.roi_start, bool(fma))
    return _TRIANGLE_LUTS.get(key, make)


# --------------------------------------------------------------------------------------------- the preset


def _preset(preset: Any) -> StreamPETRPreset:
    if isinstance(preset, StreamPETRPreset):
        return preset
    try:
        return STREAMPETR_PRESETS[str(preset)]
    except KeyError:
        raise ValueError(f"unknown normalisation preset {preset!r}; one of {sorted(STREAMPETR_PRESETS)}") from None


def streampetr_preprocess(image: np.ndarray, *, preset: Any = "autoware_main", channels: str = "rgb",
                          dst_hw: Tuple[int, int] = STREAMPETR_INPUT_HW, fma: bool = False,
                          out: Optional[np.ndarray] = None) -> np.ndarray:
    """One camera image -> the float32 network input ``(3, 480, 640)`` of ``autoware_camera_streampetr``.

    ``image``: uint8 ``(H, W, 3)`` in the channel order ``channels`` (``"rgb"``: :func:`ttaw.io.load_image`, an
    ``rgb8`` message; ``"bgr"``: OpenCV, a ``bgr8`` message). The preset fixes the plane order the kernel sees and
    the statistics (:data:`STREAMPETR_PRESETS`); ``fma`` the contraction model (module docstring)."""
    p = _preset(preset)
    img = np.asarray(image)
    if img.ndim != 3 or img.shape[2] != 3 or img.dtype != np.uint8:
        raise ValueError(f"expected an (H, W, 3) uint8 image, got {img.dtype} {img.shape}")
    if channels not in ("rgb", "bgr"):
        raise ValueError("channels must be 'rgb' or 'bgr'")
    if channels != p.planes:
        img = img[:, :, ::-1]
    lut = triangle_resize_lut(img.shape[:2], dst_hw, fma=fma)
    v = lut.apply(img)                                                        # (3, H, W) float32
    mean = p.mean_f32()[:, None, None]
    std = p.std_f32()[:, None, None]
    res = ((v - mean).astype(F32) / std).astype(F32)                          # (sum_c - mean[c]) / std[c]
    if out is not None:
        if out.shape != res.shape or out.dtype != F32:
            raise ValueError(f"out must be float32 {res.shape}, got {out.dtype} {out.shape}")
        out[...] = res
        return out
    return res


def streampetr_preprocess_batch(images: Sequence[np.ndarray], *, preset: Any = "autoware_main",
                                channels: str = "rgb", dst_hw: Tuple[int, int] = STREAMPETR_INPUT_HW,
                                fma: bool = False, workers: int = 1, out: Optional[np.ndarray] = None) -> np.ndarray:
    """The cameras of one frame -> float32 ``(N, 3, 480, 640)`` (each camera may have its own size). ``workers``
    threads over the cameras (numpy releases the GIL in the array passes)."""
    imgs = list(images)
    shape = (len(imgs), 3) + tuple(int(v) for v in dst_hw)
    if out is None:
        out = np.empty(shape, F32)
    elif out.shape != shape or out.dtype != F32:
        raise ValueError(f"out must be float32 {shape}, got {out.dtype} {out.shape}")

    def one(i: int) -> None:
        streampetr_preprocess(imgs[i], preset=preset, channels=channels, dst_hw=dst_hw, fma=fma, out=out[i])

    if workers > 1 and len(imgs) > 1:
        with ThreadPoolExecutor(max_workers=min(int(workers), len(imgs))) as ex:
            list(ex.map(one, range(len(imgs))))
    else:
        for i in range(len(imgs)):
            one(i)
    return out