File size: 11,767 Bytes
25e8396 58ba6df 25e8396 58ba6df 25e8396 58ba6df 25e8396 58ba6df 25e8396 58ba6df 2ed2aa1 58ba6df 2ed2aa1 58ba6df 2ed2aa1 25e8396 2ed2aa1 58ba6df 25e8396 58ba6df 25e8396 | 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 | # SPDX-FileCopyrightText: © 2026 Tenstorrent Inc.
# SPDX-License-Identifier: Apache-2.0
"""Image preprocessing for the HaMeR / HaMeR regressor input.
Mirrors the upstream ``ViTDetDataset`` + ``HAMER.forward`` behaviour:
1. Around the supplied hand bbox, expand by ``rescale_factor`` (default 2.0)
and take the larger side → a square bbox of ``bbox_size`` pixels in
original-image coords centred on the bbox.
2. Crop that square out of the image, resize to ``256 × 256``.
3. Centre-slice ``[..., 32:-32]`` along the W axis → ``256 × 192``. This
matches HaMeR's ``self.backbone(x[:,:,:,32:-32])``.
4. ImageNet-normalize.
"""
from __future__ import annotations
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Optional, Tuple
import numpy as np
import torch
from PIL import Image
IMAGE_MEAN = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
IMAGE_STD = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
IMAGE_HEIGHT = 256 # ViT input H after the [:,:,:,32:-32] slice
IMAGE_WIDTH = 192 # ViT input W after the [:,:,:,32:-32] slice
BBOX_CROP_SIZE = 256 # square the bbox is resized to before W-slice
W_SLICE = 32 # pixels removed from each side of W
# round 13: the 192 x 256 resize runs as RESIZE_BANDS horizontal bands of output rows, the first on the calling thread and
# the others on a small thread pool (PIL releases the GIL while resampling). Bit-identical to the one-call resize: the
# crop side S is an integer, so every band's box edge r * S / 256 and PIL's per-row filter centres box0 + (y + 0.5) * scale
# are exact dyadic floats -> the same filter windows and fixed-point weights as the full resize (checked on 1000 random
# crops x 2 band counts, tests/test_fused_host.py). NOT the default: a standalone loop gains 0.3-0.5 ms (2 bands), but
# inside the server (300-request A/B on chip 5) the preprocess p50 moved 2.03 / 2.01 -> 1.87 / 2.33 ms (2 bands) and
# 2.41 ms (3 bands) -- the pool threads wake cold on the loaded host. HAMER_PRE_BANDS=1 (default): one call.
RESIZE_BANDS = max(1, int(os.environ.get("HAMER_PRE_BANDS", "1")))
_POOL: Optional[ThreadPoolExecutor] = None
_POOL_LOCK = threading.Lock()
# round 14 (P1): native crop + resize (hamer/native, bit-identical to PIL, ~3x faster); HAMER_PRE_NATIVE=0 -> PIL path.
# Not used together with HAMER_PRE_BANDS > 1 (that knob measures the PIL band path).
def native_enabled() -> bool:
return RESIZE_BANDS == 1 and os.environ.get("HAMER_PRE_NATIVE", "1").strip().lower() not in ("0", "false", "no", "off")
def _pool(n: int) -> ThreadPoolExecutor:
global _POOL
with _POOL_LOCK:
if _POOL is None or _POOL._max_workers < n: # noqa: SLF001
_POOL = ThreadPoolExecutor(max_workers=n, thread_name_prefix="hamer-resize")
return _POOL
def resize_crop_hw(crop: Image.Image, is_right: bool = True, bands: Optional[int] = None) -> np.ndarray:
"""The square crop -> uint8 HWC ``[256, 192, 3]``: the centre 192 of the 256 resized columns (BILINEAR), mirrored
for a left hand. ``bands`` > 1 splits the output rows over threads (bit-identical, see RESIZE_BANDS)."""
S = crop.width
x0, x1 = W_SLICE * S / BBOX_CROP_SIZE, (BBOX_CROP_SIZE - W_SLICE) * S / BBOX_CROP_SIZE
nb = RESIZE_BANDS if bands is None else max(1, int(bands))
if nb == 1:
c = crop.resize((IMAGE_WIDTH, BBOX_CROP_SIZE), Image.BILINEAR, box=(x0, 0, x1, S))
if not is_right:
c = c.transpose(Image.FLIP_LEFT_RIGHT)
return np.asarray(c).copy()
out = np.empty((BBOX_CROP_SIZE, IMAGE_WIDTH, 3), np.uint8)
edges = [round(i * BBOX_CROP_SIZE / nb) for i in range(nb + 1)]
def band(i: int) -> None:
r0, r1 = edges[i], edges[i + 1]
b = crop.resize((IMAGE_WIDTH, r1 - r0), Image.BILINEAR,
box=(x0, r0 * S / BBOX_CROP_SIZE, x1, r1 * S / BBOX_CROP_SIZE))
if not is_right:
b = b.transpose(Image.FLIP_LEFT_RIGHT)
out[r0:r1] = np.asarray(b)
futs = [_pool(nb - 1).submit(band, i) for i in range(1, nb)]
band(0)
for f in futs:
f.result()
return out
def hamer_bbox_size(bbox_xyxy: Tuple[float, float, float, float], rescale_factor: float = 2.0) -> float:
"""Mirrors HaMeR's ``ViTDetDataset`` scale computation:
scale = rescale_factor * (bbox_w, bbox_h) / 200
bbox_size = (scale * 200).max()
With ``BBOX_SHAPE=None`` (the released checkpoint's default), no
aspect-ratio expansion is applied — the square bbox just fits the
longer side.
"""
x1, y1, x2, y2 = bbox_xyxy
w, h = x2 - x1, y2 - y1
return float(rescale_factor * max(w, h))
def crop_bbox(
image: Image.Image,
bbox_xyxy: Tuple[float, float, float, float],
rescale_factor: float = 2.0,
) -> Tuple[Image.Image, Tuple[float, float, float]]:
"""Crop the HaMeR-style square hand bbox out of ``image`` and return the
256×256 PIL crop plus ``(cx, cy, bbox_size)`` in *original-image coords*.
"""
crop, meta = _square_crop(image, bbox_xyxy, rescale_factor)
return crop.resize((BBOX_CROP_SIZE, BBOX_CROP_SIZE), Image.BILINEAR), meta
def _square_crop(
image: Image.Image,
bbox_xyxy: Tuple[float, float, float, float],
rescale_factor: float = 2.0,
) -> Tuple[Image.Image, Tuple[float, float, float]]:
"""The HaMeR-style square crop (grey padding) at source resolution, plus ``(cx, cy, bbox_size)``."""
x1, y1, x2, y2 = bbox_xyxy
cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0
bbox_size = hamer_bbox_size(bbox_xyxy, rescale_factor)
half = bbox_size / 2.0
crop_box = (cx - half, cy - half, cx + half, cy + half)
# Pad with grey if the bbox extends beyond the image edges.
crop = Image.new("RGB", (int(round(bbox_size)), int(round(bbox_size))), (128, 128, 128))
src = image.crop((max(0, int(round(crop_box[0]))), max(0, int(round(crop_box[1]))),
min(image.width, int(round(crop_box[2]))), min(image.height, int(round(crop_box[3])))))
paste_x = max(0, int(round(-crop_box[0])))
paste_y = max(0, int(round(-crop_box[1])))
crop.paste(src, (paste_x, paste_y))
return crop, (cx, cy, bbox_size)
def preprocess(
image_path: str | Path,
bbox_xyxy: Optional[Tuple[float, float, float, float]] = None,
rescale_factor: float = 2.0,
is_right: bool = True,
raw: bool = False,
) -> Tuple[torch.Tensor, Optional[Tuple[float, float, float]]]:
"""Load image, crop HaMeR-style around ``bbox_xyxy``, return
``(input_tensor, bbox_meta)``.
``bbox_xyxy`` is in original-image pixel coords. When omitted, the
image is centre-cropped to a square — useful for smoke-tests, but
geometrically meaningless for the camera prediction.
``is_right=False`` mirrors the crop horizontally before normalisation —
HaMeR is trained on right hands only, so a left hand is fed in
flipped form (matches ``ViTDetDataset``'s ``flip = (right == 0)``).
``bbox_meta = (cx, cy, bbox_size)`` lets the visualiser reverse the
crop to overlay the mesh back onto the original frame.
"""
img = Image.open(image_path).convert("RGB")
return preprocess_pil(img, bbox_xyxy, rescale_factor=rescale_factor, is_right=is_right, raw=raw)
def center_square_bbox(img: Image.Image) -> Tuple[float, float, float, float]:
"""The centre square of ``img`` as an xyxy bbox (the no-bbox fallback)."""
side = min(img.width, img.height)
return (
(img.width - side) / 2.0,
(img.height - side) / 2.0,
(img.width + side) / 2.0,
(img.height + side) / 2.0,
)
def preprocess_pil(
img: Image.Image,
bbox_xyxy: Optional[Tuple[float, float, float, float]] = None,
rescale_factor: float = 2.0,
is_right: bool = True,
raw: bool = False,
contiguous: bool = True,
) -> Tuple[torch.Tensor, Tuple[float, float, float]]:
"""In-memory variant of :func:`preprocess` for an already-decoded PIL
image (what a server holds). Same crop / flip / slice / normalise
sequence, byte-for-byte. ``raw=True``: the uint8 ``[1, 3, 256, 192]`` crop before the
normalisation (``normalize_raw`` of it is bit-identical to ``raw=False``); the
``TT_FUSED_INPUT=u8`` device graph takes it directly. ``contiguous=False`` (raw only): return the
``[1, 3, 256, 192]`` view of the HWC array instead of a CHW copy (the server; same values).
"""
if img.mode != "RGB":
img = img.convert("RGB")
if bbox_xyxy is None:
bbox_xyxy = center_square_bbox(img)
res = None
if native_enabled():
# round 14 (P1): crop + grey pad + box resize + mirror in one native call, bit-identical to the PIL path below
# (hamer/native/resize.c; self-checked at load, tests/test_fused_host.py::test_native_crop_resize_bit_identical)
from hamer import native
# planar CHW straight from the native pass: the raw crop is contiguous [1, 3, 256, 192] at no extra cost (the
# model's centring then reads it contiguously: 0.02 vs 0.07 ms for the strided HWC view)
# round 15: the frame's pixel buffer is read in place (HAMER_PRE_ZEROCOPY=1; no np.asarray copy)
res = native.crop_resize_image(img, bbox_xyxy, rescale_factor, is_right, chw=True)
if res is not None:
u8_chw, bbox_meta = res
x = torch.from_numpy(u8_chw).unsqueeze(0)
if raw:
return x, bbox_meta
return (x.float() / 255.0 - IMAGE_MEAN) / IMAGE_STD, bbox_meta
crop, bbox_meta = _square_crop(img, bbox_xyxy, rescale_factor)
# Only the centre 192 of the 256 resized columns are used: resize just that box (round 6: bit-identical to
# resize-to-256 + slice -- PIL's filter windows depend only on the output pixel centre and the source image,
# checked on 400 random crops -- and ~25% less host time). The flip commutes with the symmetric slice.
return _finish(torch.from_numpy(resize_crop_hw(crop, is_right)), bbox_meta, raw, contiguous)
def _finish(u8: torch.Tensor, bbox_meta, raw: bool, contiguous: bool):
if raw:
x = u8.permute(2, 0, 1).unsqueeze(0)
# round 13 (server): contiguous=False keeps the view (no CHW copy); the model's input centring reads it strided
return (x.contiguous() if contiguous else x), bbox_meta
arr = u8.float() / 255.0
arr = arr.permute(2, 0, 1).unsqueeze(0) # (1, 3, 256, 192)
return (arr - IMAGE_MEAN) / IMAGE_STD, bbox_meta
def normalize_raw(u8: torch.Tensor) -> torch.Tensor:
"""uint8 ``[B, 3, 256, 192]`` crop (``preprocess_pil(raw=True)``) -> the normalized fp32 input,
bit-identical to ``preprocess_pil(raw=False)``."""
return (u8.float() / 255.0 - IMAGE_MEAN) / IMAGE_STD
def denormalize(x: torch.Tensor) -> np.ndarray:
img = x * IMAGE_STD + IMAGE_MEAN
img = img.squeeze(0).clamp(0, 1).permute(1, 2, 0).numpy()
return (img * 255).astype(np.uint8)
def map_network_to_original(
pts_192x256: np.ndarray,
bbox_meta: Tuple[float, float, float],
) -> np.ndarray:
"""Map 2-D points in network-input pixel coords (192 × 256) back to
original-image pixel coords.
Inverse of the preprocess: shift x by ``W_SLICE`` to undo the slice,
then scale by ``bbox_size / BBOX_CROP_SIZE`` and translate by the bbox
top-left corner.
"""
cx, cy, bbox_size = bbox_meta
pts = pts_192x256.astype(np.float64).copy()
pts[:, 0] += W_SLICE # 192-wide → 256-wide
pts *= bbox_size / BBOX_CROP_SIZE # 256×256 → bbox_size×bbox_size
pts[:, 0] += cx - bbox_size / 2.0
pts[:, 1] += cy - bbox_size / 2.0
return pts
|