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
|