File size: 9,585 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 | # SPDX-License-Identifier: Apache-2.0
"""C14 image pre-processing, METEOR preset: a bit-exact host emulation of OpenCV's ``cv::resize(..., INTER_AREA)`` on
uint8 images (down-scaling), driven by cached per-size tables. numpy only; no ttnn, no torch, no OpenCV.
METEOR's runtimes feed every camera as RGB uint8 768x432 and resize larger frames with ``cv2.resize(img, (768, 432),
interpolation=cv2.INTER_AREA)`` (tier4/METEOR ``hf/onnx_smoke_test.py:42-44``; the training extraction
``bevlane/extract_gt.py:114-130`` resized the raw, unrectified JPEGs the same way; research/meteor/SPEC.md section 3),
anisotropically when the aspect ratio differs (2880x1860 -> 768x432). :func:`meteor_resize` reproduces it; the
intrinsics follow with :func:`scale_intrinsics` (row 0 x 768 / w0, row 1 x 432 / h0, ``extract_gt.py:128-130``).
What OpenCV computes (``modules/imgproc/src/resize.cpp``, identical in 4.8.1 and 4.11.0; the IPP path declines
INTER_AREA because it is not bit-exact, ``ipp_resize``: ``ippSuper`` needs ``useIPP_NotExact``):
- ``inv_scale = (double)dst / src`` per axis, ``scale = 1.0 / inv_scale`` (double).
- **Integer factors** (``|scale - (int)scale| < DBL_EPSILON`` on both axes; ``resizeAreaFast_``): every output pixel is
the integer sum ``s`` of its ``fx * fy`` source pixels, then ``saturate_cast<uchar>(s * (1.f / area))``: one float32
product, rounded half to even (``cvRound``). Exception, the 2x2 ``fast_mode`` of ``ResizeAreaFastVec`` for 1-, 3-
and 4-channel images: ``(s + 2) >> 2`` (half up). Measured on both OpenCV builds (2-channel images take the float
path; 3x3 and 5x5 factors the float path).
- **Other factors** (``resizeArea_<uchar, float>``): ``computeResizeAreaTab`` lists, per output column (and row), the
covered source pixels with float32 weights computed in double (``(sx1 - fsx1) / cellWidth``, ``1.0 / cellWidth``,
``min(min(fsx2 - sx2, 1), cellWidth) / cellWidth``; partial pixels below 1e-3 skipped). Each source row is first
reduced horizontally, ``buf[dx] += S[sx] * alpha`` in table order (float32 multiply, then float32 add: no FMA, the
generic code is compiled for the SSE baseline), then the rows are combined, ``sum[dx] += beta * buf[dx]`` in table
order, and ``saturate_cast<uchar>(sum)`` rounds half to even and clamps to [0, 255].
The emulation replays exactly these float32 operations in the same order, vectorised over the image (zero-weight
padding of the per-pixel tap lists adds exact zeros). Verified against cv2 4.8.1 (tt-env) and 4.11.0 (research venv)
on random and real images of many sizes (``common/tests/host/test_image_area_host.py``): 0 mismatches.
Up-scaling (any axis with ``src < dst``) runs a different OpenCV path (linear-like fixed-point coefficients) and is
refused: METEOR's cameras are all larger than 768x432.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import Any, Optional, Tuple
import numpy as np
from .image import LUTCache
__all__ = ["AreaResizeLUT", "opencv_area_lut", "opencv_resize_area_u8", "meteor_resize", "scale_intrinsics",
"METEOR_INPUT_HW"]
F32 = np.float32
METEOR_INPUT_HW = (432, 768) # every METEOR camera slot (meteor_v157.param.yaml:11-12)
_DBL_EPSILON = sys.float_info.epsilon
def _area_axis(n_src: int, n_dst: int, scale: float) -> Tuple[np.ndarray, np.ndarray]:
"""``computeResizeAreaTab`` of one axis as padded per-output tap lists: (src index [n_dst, T], weight [n_dst, T])
in table order; unused taps have weight 0 and index 0."""
taps = []
for dx in range(n_dst):
fsx1 = dx * scale
fsx2 = fsx1 + scale
cell = min(scale, n_src - fsx1)
sx1 = int(np.ceil(fsx1))
sx2 = int(np.floor(fsx2))
sx2 = min(sx2, n_src - 1)
sx1 = min(sx1, sx2)
row = []
if sx1 - fsx1 > 1e-3:
row.append((sx1 - 1, F32((sx1 - fsx1) / cell)))
for sx in range(sx1, sx2):
row.append((sx, F32(1.0 / cell)))
if fsx2 - sx2 > 1e-3:
row.append((sx2, F32(min(min(fsx2 - sx2, 1.0), cell) / cell)))
taps.append(row)
width = max(len(r) for r in taps)
idx = np.zeros((n_dst, width), np.int64)
w = np.zeros((n_dst, width), F32)
for i, row in enumerate(taps):
for k, (s, a) in enumerate(row):
idx[i, k] = s
w[i, k] = a
return idx, w
@dataclass(frozen=True)
class AreaResizeLUT:
"""The cached tables of one (source size, destination size) pair; :meth:`apply` resizes one uint8 image."""
src_hw: Tuple[int, int]
dst_hw: Tuple[int, int]
fast: Optional[Tuple[int, int]] # integer factors (fy, fx) of the resizeAreaFast_ path, else None
rows: Optional[np.ndarray] # [dst_h, Ty] source rows (general path)
row_w: Optional[np.ndarray] # [dst_h, Ty] float32 beta
cols: Optional[np.ndarray] # [dst_w, Tx] source columns
col_w: Optional[np.ndarray] # [dst_w, Tx] float32 alpha
def apply(self, image: np.ndarray, *, out: Optional[np.ndarray] = None) -> np.ndarray:
img = np.asarray(image)
if img.dtype != np.uint8 or img.ndim not in (2, 3) or tuple(img.shape[:2]) != self.src_hw:
raise ValueError(f"expected a uint8 {self.src_hw} image (HxW or HxWxC), got {img.dtype} {img.shape}")
squeeze = img.ndim == 2
x = img[:, :, None] if squeeze else img
dh, dw = self.dst_hw
if self.fast is not None:
fy, fx = self.fast
s = x[:dh * fy, :dw * fx].astype(np.int64).reshape(dh, fy, dw, fx, x.shape[2]).sum(axis=(1, 3))
if (fy, fx) == (2, 2) and x.shape[2] in (1, 3, 4):
res = (s + 2) >> 2 # ResizeAreaFastVec fast_mode: half up
else:
res = np.rint(s.astype(F32) * F32(1.0 / (fx * fy))) # float32 product, half to even
else:
# horizontal pass per source row: buf[dx] = sum_k S[cols[dx, k]] * col_w[dx, k] (float32, table order)
buf = np.zeros((x.shape[0], dw, x.shape[2]), F32)
src = x.astype(F32)
for k in range(self.cols.shape[1]):
buf += src[:, self.cols[:, k], :] * self.col_w[None, :, k, None]
# vertical pass: sum[dy] = sum_j beta[dy, j] * buf[rows[dy, j]] (float32, table order)
acc = np.zeros((dh, dw, x.shape[2]), F32)
for j in range(self.rows.shape[1]):
acc += self.row_w[:, j, None, None] * buf[self.rows[:, j]]
res = np.rint(acc) # cvRound: half to even
res = np.clip(res, 0, 255).astype(np.uint8)
if squeeze:
res = res[:, :, 0]
if out is not None:
out[...] = res
return out
return res
_AREA_LUTS = LUTCache(maxsize=16) # 8 cameras of up to two sizes each
def opencv_area_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int]) -> AreaResizeLUT:
"""Tables of ``cv::resize(INTER_AREA)`` from ``src_hw`` down to ``dst_hw`` (cached per size pair)."""
src_hw = (int(src_hw[0]), int(src_hw[1]))
dst_hw = (int(dst_hw[0]), int(dst_hw[1]))
return _AREA_LUTS.get((src_hw, dst_hw), lambda: _build_area_lut(src_hw, dst_hw))
def _build_area_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int]) -> AreaResizeLUT:
(sh, sw), (dh, dw) = src_hw, dst_hw
if min(sh, sw, dh, dw) < 1:
raise ValueError(f"bad sizes {src_hw} -> {dst_hw}")
if dh > sh or dw > sw:
raise ValueError(f"INTER_AREA up-scaling {src_hw} -> {dst_hw} is not emulated (OpenCV uses another path)")
scale_y = 1.0 / (float(dh) / sh)
scale_x = 1.0 / (float(dw) / sw)
iy, ix = int(round(scale_y)), int(round(scale_x))
if abs(scale_x - ix) < _DBL_EPSILON and abs(scale_y - iy) < _DBL_EPSILON:
return AreaResizeLUT(src_hw, dst_hw, (iy, ix), None, None, None, None)
rows, row_w = _area_axis(sh, dh, scale_y)
cols, col_w = _area_axis(sw, dw, scale_x)
return AreaResizeLUT(src_hw, dst_hw, None, rows, row_w, cols, col_w)
def opencv_resize_area_u8(image: np.ndarray, dst_hw: Tuple[int, int], *,
out: Optional[np.ndarray] = None) -> np.ndarray:
"""``cv2.resize(image, (dst_w, dst_h), interpolation=cv2.INTER_AREA)`` for uint8 HxW / HxWxC down-scaling;
the same size returns a copy (OpenCV's ``copyTo`` shortcut)."""
img = np.asarray(image)
if tuple(img.shape[:2]) == tuple(dst_hw):
res = np.array(img, dtype=np.uint8, copy=True)
if out is not None:
out[...] = res
return out
return res
return opencv_area_lut(img.shape[:2], dst_hw).apply(img, out=out)
def meteor_resize(image: np.ndarray, *, out: Optional[np.ndarray] = None) -> np.ndarray:
"""One camera frame (uint8 HxWx3, any channel order: the resize is per channel) -> 432x768 as METEOR's runtimes
(``INTER_AREA`` when the size differs, a copy otherwise)."""
return opencv_resize_area_u8(image, METEOR_INPUT_HW, out=out)
def scale_intrinsics(K: Any, src_hw: Tuple[int, int], dst_hw: Tuple[int, int] = METEOR_INPUT_HW) -> np.ndarray:
"""K of a ``src_hw`` image -> K of the resized ``dst_hw`` image: row 0 x dst_w / src_w, row 1 x dst_h / src_h
(``extract_gt.py:128-130``; skew is scaled with row 0 and ignored by the network). float64."""
k = np.array(K, dtype=np.float64).reshape(3, 3).copy()
k[0] *= dst_hw[1] / float(src_hw[1])
k[1] *= dst_hw[0] / float(src_hw[0])
return k
|