Files
orisys-sdk/src/orisys/unroll/remap.py
T

191 lines
6.2 KiB
Python

"""2D UV flatten remap tables and runtime frame remapping.
Stores precomputed map_x/map_y/valid_mask and applies cv2.remap at runtime to
convert source camera frames into the UV flatten domain.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Tuple
import cv2
import numpy as np
@dataclass
class UVRemap:
"""Precomputed UV flatten remap tables."""
map_x: np.ndarray
map_y: np.ndarray
valid_mask: np.ndarray
metadata: Dict[str, Any] = field(default_factory=dict)
_map1_fixed: Optional[np.ndarray] = field(default=None, repr=False, compare=False)
_map2_fixed: Optional[np.ndarray] = field(default=None, repr=False, compare=False)
_border_only_invalid: bool = field(default=False, repr=False, compare=False)
@property
def out_height(self) -> int:
return int(self.map_x.shape[0])
@property
def out_width(self) -> int:
return int(self.map_x.shape[1])
@classmethod
def from_dict(cls, remap_dict: dict) -> "UVRemap":
"""Build UVRemap from a flat dict (e.g. np.load result)."""
meta_keys = {
"source_width",
"source_height",
"out_width",
"out_height",
"flip_v",
"edge_band",
"edge_expand",
"projection_K",
"method",
}
metadata = {k: remap_dict[k] for k in meta_keys if k in remap_dict}
remap = cls(
map_x=np.asarray(remap_dict["map_x"], dtype=np.float32),
map_y=np.asarray(remap_dict["map_y"], dtype=np.float32),
valid_mask=np.asarray(remap_dict["valid_mask"], dtype=bool),
metadata=metadata,
)
remap.ensure_fast_maps()
return remap
@classmethod
def load(cls, path: str) -> "UVRemap":
"""Load cached remap from .npz file."""
data = np.load(path)
return cls.from_dict({key: data[key] for key in data.files})
def to_dict(self) -> dict:
"""Serialize to dict suitable for np.savez_compressed."""
out = {
"map_x": self.map_x.astype(np.float32),
"map_y": self.map_y.astype(np.float32),
"valid_mask": self.valid_mask.astype(bool),
}
out.update(self.metadata)
return out
def save(self, path: str) -> None:
"""Save remap cache to .npz file."""
np.savez_compressed(path, **self.to_dict())
def ensure_fast_maps(self) -> None:
"""Build OpenCV CV_16SC2 remap tables once for faster cv2.remap."""
if self._map1_fixed is not None:
return
self._map1_fixed, self._map2_fixed = cv2.convertMaps(
self.map_x,
self.map_y,
cv2.CV_16SC2,
)
invalid = ~self.valid_mask
if not np.any(invalid):
self._border_only_invalid = True
else:
self._border_only_invalid = bool(
np.all(self.map_x[invalid] <= 0.0) and np.all(self.map_y[invalid] <= 0.0)
)
def flatten(self, frame_bgr, border_value=0, dst=None):
"""Remap a source BGR frame into UV flatten coordinates.
frame_bgr: (H_src, W_src, 3) source camera image
border_value: fill value for invalid/out-of-domain pixels
returns: (H_out, W_out, 3) flattened BGR image
"""
return flatten_frame(frame_bgr, self, border_value=border_value, dst=dst)
def remap_valid_ratio(remap) -> float:
"""Fraction of output pixels covered by the valid UV domain."""
if isinstance(remap, UVRemap):
mask = remap.valid_mask
else:
mask = remap["valid_mask"]
return float(np.mean(mask))
def _cache_matches(
remap,
source_K,
source_width,
source_height,
out_width,
out_height,
flip_v,
edge_band,
edge_expand,
) -> bool:
"""True if cached remap matches the requested build parameters."""
if isinstance(remap, UVRemap):
rd = remap.to_dict()
else:
rd = remap
required = {"map_x", "map_y", "valid_mask", "source_width", "source_height", "out_width", "out_height"}
if not required.issubset(rd.keys()):
return False
if rd["map_x"].shape != (out_height, out_width):
return False
if int(rd["source_width"]) != source_width or int(rd["source_height"]) != source_height:
return False
if int(rd["out_width"]) != out_width or int(rd["out_height"]) != out_height:
return False
if "flip_v" in rd and int(rd["flip_v"]) != int(flip_v):
return False
if "edge_band" in rd and not np.isclose(float(rd["edge_band"]), float(edge_band), rtol=1e-6, atol=1e-6):
return False
if "edge_expand" in rd and not np.isclose(float(rd["edge_expand"]), float(edge_expand), rtol=1e-6, atol=1e-6):
return False
if "projection_K" in rd and not np.allclose(rd["projection_K"], source_K, rtol=1e-6, atol=1e-6):
return False
return True
def _remap_maps(remap) -> Tuple[np.ndarray, np.ndarray, np.ndarray, bool]:
if isinstance(remap, UVRemap):
remap.ensure_fast_maps()
valid_mask = remap.valid_mask
skip_invalid_fill = remap._border_only_invalid
if remap._map1_fixed is not None:
return remap._map1_fixed, remap._map2_fixed, valid_mask, skip_invalid_fill
return remap.map_x, remap.map_y, valid_mask, skip_invalid_fill
return (
remap["map_x"].astype(np.float32),
remap["map_y"].astype(np.float32),
remap["valid_mask"].astype(bool),
False,
)
def flatten_frame(frame, remap, border_value=0, dst=None):
"""Remap frame using map_x/map_y; zeros invalid UV pixels.
frame: (H, W, 3) or (H, W) source image
remap: UVRemap or dict with map_x, map_y, valid_mask
returns: flattened image, same channels as input
"""
map1, map2, valid_mask, skip_invalid_fill = _remap_maps(remap)
flat = cv2.remap(
frame,
map1,
map2,
interpolation=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=border_value,
dst=dst,
)
if skip_invalid_fill and border_value == 0:
return flat
if not flat.flags.writeable:
flat = flat.copy()
flat[~valid_mask] = border_value
return flat