refractor stitcher class into module
This commit is contained in:
+16
-1534
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
"""Compound-eye stitching package (ops, backends, feature/box/tile modules)."""
|
||||
|
||||
from .api import Stitcher
|
||||
from .backends import available_blend_backends, get_blend_backend
|
||||
from .config import BLEND_MODES, StitchStatus, resolve_blend_mode, validate_box_grid
|
||||
from .ops import (
|
||||
build_blend_coef,
|
||||
compute_final_crop_rect,
|
||||
copy_into_tile_buffer,
|
||||
coverage_ratio,
|
||||
largest_fully_covered_rect,
|
||||
match_bgr_brightness,
|
||||
paste_tile_clipped,
|
||||
tile_weight_map,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Stitcher",
|
||||
"StitchStatus",
|
||||
"BLEND_MODES",
|
||||
"resolve_blend_mode",
|
||||
"validate_box_grid",
|
||||
"get_blend_backend",
|
||||
"available_blend_backends",
|
||||
"tile_weight_map",
|
||||
"paste_tile_clipped",
|
||||
"copy_into_tile_buffer",
|
||||
"build_blend_coef",
|
||||
"compute_final_crop_rect",
|
||||
"largest_fully_covered_rect",
|
||||
"match_bgr_brightness",
|
||||
"coverage_ratio",
|
||||
]
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Public Stitcher façade composing tiles, boxes, feature stitch, and blend backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .backends import get_blend_backend
|
||||
from .config import (
|
||||
BLEND_MODES,
|
||||
StitchStatus,
|
||||
numba_blend_available,
|
||||
resolve_blend_mode,
|
||||
)
|
||||
from . import boxes as boxes_mod
|
||||
from . import feature as feature_mod
|
||||
from .ops import build_blend_coef
|
||||
from . import tiles as tiles_mod
|
||||
|
||||
|
||||
class Stitcher:
|
||||
"""Compound-eye stitcher (feature/ORB path + feathered blend backends)."""
|
||||
|
||||
def __init__(self, isstitch=False, config_stitcher=None, verbose=False):
|
||||
self.isstitch = isstitch
|
||||
self.config_stitcher = config_stitcher
|
||||
self.verbose = verbose
|
||||
|
||||
self.min_area_frac = config_stitcher["min_area_frac"]
|
||||
self.max_area_frac = config_stitcher["max_area_frac"]
|
||||
self.max_aspect_ratio = config_stitcher["max_aspect_ratio"]
|
||||
|
||||
self.stitch_valid = False
|
||||
self.boxes = self.units = None
|
||||
self.index = config_stitcher["index"]
|
||||
self.stitch_calibration = self.tile_list = []
|
||||
self.ncol = config_stitcher["n"][0]
|
||||
self.nrow = config_stitcher["n"][1]
|
||||
self.nominal_ov_pos = config_stitcher["nominal_ov_pos"]
|
||||
self.margin = config_stitcher["margin"]
|
||||
self.bound = self.nominal_ov_pos - self.margin
|
||||
self.row_tol = config_stitcher["row_tol"]
|
||||
self.box_size = config_stitcher["box_size"]
|
||||
# Physical tile count for box detection (often 15); grid n is the active stitch layout.
|
||||
self.n_boxes_target = int(config_stitcher.get("n_boxes_target", 15))
|
||||
|
||||
self._blend_coef_cache = {}
|
||||
self._feature_stitch_cache = {
|
||||
"ref_idx": None,
|
||||
"moving_indices": None,
|
||||
"tile_shapes": None,
|
||||
"transforms": None,
|
||||
"canvas": {},
|
||||
}
|
||||
self._feature_frame_idx = {"idx": 0}
|
||||
self._feature_stitch_print_every = 30
|
||||
self._feature_stitch_pad = 5
|
||||
self._feature_stitch_match_brightness = False
|
||||
self._blend_mode, fell_back = resolve_blend_mode(config_stitcher)
|
||||
self._blend_backend_name = "numpy" if self._blend_mode == "none" else self._blend_mode
|
||||
self._numba_failed = False
|
||||
self._blend_backend = get_blend_backend(
|
||||
self._blend_backend_name,
|
||||
on_numba_failure=self._mark_numba_failed,
|
||||
)
|
||||
self._last_coverage = 0.0
|
||||
self._last_ms = 0.0
|
||||
|
||||
if fell_back and self.isstitch:
|
||||
print(
|
||||
"Warning: stitcher blend=numba requested but numba is "
|
||||
"unavailable; using numpy blend path."
|
||||
)
|
||||
elif self.isstitch and self.verbose:
|
||||
print(f"Stitch blend: {self._blend_mode}")
|
||||
|
||||
if self.verbose:
|
||||
print(f"Stitcher initialized with config: {self.config_stitcher}")
|
||||
|
||||
@property
|
||||
def blend_mode(self):
|
||||
"""Configured blend mode: ``none``, ``numpy``, or ``numba``."""
|
||||
return self._blend_mode
|
||||
|
||||
@property
|
||||
def status(self) -> StitchStatus:
|
||||
return StitchStatus(
|
||||
blend_mode=self._blend_mode,
|
||||
backend_name=getattr(self._blend_backend, "name", self._blend_backend_name),
|
||||
numba_failed=self._numba_failed,
|
||||
coverage=float(self._last_coverage),
|
||||
last_ms=float(self._last_ms),
|
||||
tile_count=len(self.tile_list) if self.tile_list is not None else 0,
|
||||
stitch_valid=bool(self.stitch_valid),
|
||||
box_count=len(self.boxes) if self.boxes is not None else 0,
|
||||
expected_boxes=int(self.n_boxes_target),
|
||||
)
|
||||
|
||||
def configure_feature_stitch(
|
||||
self,
|
||||
*,
|
||||
match_brightness=None,
|
||||
pad=None,
|
||||
print_every=None,
|
||||
blend=None,
|
||||
blend_backend=None,
|
||||
):
|
||||
if match_brightness is not None:
|
||||
self._feature_stitch_match_brightness = bool(match_brightness)
|
||||
if pad is not None:
|
||||
self._feature_stitch_pad = max(0, int(pad))
|
||||
if print_every is not None:
|
||||
self._feature_stitch_print_every = max(1, int(print_every))
|
||||
mode = blend if blend is not None else blend_backend
|
||||
if mode is not None:
|
||||
mode = str(mode).strip().lower()
|
||||
if mode not in BLEND_MODES:
|
||||
raise ValueError(
|
||||
f"Unknown blend={mode!r}; expected one of {sorted(BLEND_MODES)}"
|
||||
)
|
||||
if mode == "numba" and (not numba_blend_available() or self._numba_failed):
|
||||
print("Warning: numba unavailable; using numpy blend.")
|
||||
mode = "numpy"
|
||||
self._blend_mode = mode
|
||||
self._blend_backend_name = "numpy" if mode == "none" else mode
|
||||
if mode == "numba":
|
||||
self._numba_failed = False
|
||||
self._blend_backend = get_blend_backend(
|
||||
self._blend_backend_name,
|
||||
on_numba_failure=self._mark_numba_failed,
|
||||
)
|
||||
|
||||
def _resolve_call_blend(self, blend):
|
||||
if blend is None:
|
||||
mode = self._blend_mode
|
||||
elif isinstance(blend, bool):
|
||||
if blend:
|
||||
mode = self._blend_mode if self._blend_mode != "none" else "numpy"
|
||||
else:
|
||||
return False, self._blend_backend_name
|
||||
else:
|
||||
mode = str(blend).strip().lower()
|
||||
if mode not in BLEND_MODES:
|
||||
raise ValueError(
|
||||
f"Unknown blend={mode!r}; expected one of {sorted(BLEND_MODES)}"
|
||||
)
|
||||
if mode == "numba" and not numba_blend_available():
|
||||
mode = "numpy"
|
||||
|
||||
if mode == "numba" and self._numba_failed:
|
||||
mode = "numpy"
|
||||
|
||||
if mode == "none":
|
||||
return False, self._blend_backend_name
|
||||
return True, mode
|
||||
|
||||
def _mark_numba_failed(self, exc):
|
||||
if self._numba_failed:
|
||||
return
|
||||
self._numba_failed = True
|
||||
self._blend_backend_name = "numpy"
|
||||
if self._blend_mode == "numba":
|
||||
self._blend_mode = "numpy"
|
||||
self._blend_backend = get_blend_backend("numpy")
|
||||
print(
|
||||
f"Warning: numba blend failed ({type(exc).__name__}: {exc}); "
|
||||
f"falling back to numpy."
|
||||
)
|
||||
|
||||
def _get_blend_coef(self, ov_pos, dim, ndim):
|
||||
key = (ov_pos, dim, ndim)
|
||||
coef = self._blend_coef_cache.get(key)
|
||||
if coef is not None:
|
||||
return coef
|
||||
blend_coef = build_blend_coef(ov_pos, dim, ndim)
|
||||
self._blend_coef_cache[key] = blend_coef
|
||||
return blend_coef
|
||||
|
||||
def _ensure_blend_backend(self, backend_name: str):
|
||||
name = "numpy" if backend_name == "none" else backend_name
|
||||
if self._numba_failed and name == "numba":
|
||||
name = "numpy"
|
||||
current = getattr(self._blend_backend, "name", None)
|
||||
if current != name:
|
||||
self._blend_backend = get_blend_backend(
|
||||
name,
|
||||
on_numba_failure=self._mark_numba_failed,
|
||||
)
|
||||
self._blend_backend_name = name
|
||||
|
||||
def stitch_rt_optimized(self, change_sv=False, s_mean=28.0, v_mean=180.0, blend=None):
|
||||
"""Stitch the current tile list via the feature/ORB path.
|
||||
|
||||
Returns ``None`` when a valid feature layout cannot be built.
|
||||
"""
|
||||
use_feather, blend_backend = self._resolve_call_blend(blend)
|
||||
prev_backend = self._blend_backend_name
|
||||
self._ensure_blend_backend(blend_backend)
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
feature_stitched = self.stitch_feature_optimized(
|
||||
blend=use_feather,
|
||||
match_brightness=self._feature_stitch_match_brightness,
|
||||
pad=self._feature_stitch_pad,
|
||||
print_every=self._feature_stitch_print_every,
|
||||
)
|
||||
finally:
|
||||
if self._numba_failed:
|
||||
self._ensure_blend_backend("numpy")
|
||||
else:
|
||||
self._ensure_blend_backend(prev_backend)
|
||||
self._last_ms = (time.perf_counter() - t0) * 1000.0
|
||||
canvas = self._feature_stitch_cache.get("canvas") or {}
|
||||
self._last_coverage = float(canvas.get("last_coverage", self._last_coverage))
|
||||
if feature_stitched is not None:
|
||||
return feature_stitched
|
||||
return None
|
||||
|
||||
def stitch_feature_optimized(self, blend=True, match_brightness=False, pad=5, print_every=30):
|
||||
return feature_mod.stitch_feature_optimized(
|
||||
isstitch=self.isstitch,
|
||||
index=self.index,
|
||||
nrow=self.nrow,
|
||||
ncol=self.ncol,
|
||||
tile_list=self.tile_list,
|
||||
cache=self._feature_stitch_cache,
|
||||
blend_backend=self._blend_backend,
|
||||
blend=blend,
|
||||
match_brightness=match_brightness,
|
||||
pad=pad,
|
||||
print_every=print_every,
|
||||
verbose=self.verbose,
|
||||
frame_idx_holder=self._feature_frame_idx,
|
||||
)
|
||||
|
||||
def find_stitch_boxes(self, img_src, show=False):
|
||||
n_boxes, boxes, grid_ok = boxes_mod.find_stitch_boxes(
|
||||
img_src,
|
||||
box_size=self.box_size,
|
||||
min_area_frac=self.min_area_frac,
|
||||
max_area_frac=self.max_area_frac,
|
||||
max_aspect_ratio=self.max_aspect_ratio,
|
||||
row_tol=self.row_tol,
|
||||
nrow=self.nrow,
|
||||
ncol=self.ncol,
|
||||
show=show,
|
||||
n_boxes_target=self.n_boxes_target,
|
||||
)
|
||||
self.boxes = boxes
|
||||
# Detection target is physical unit count; active stitch grid is nrow*ncol via index.
|
||||
self.stitch_valid = bool(self.isstitch and n_boxes == self.n_boxes_target)
|
||||
if self.isstitch and n_boxes != self.n_boxes_target:
|
||||
print(
|
||||
f"Warning: stitch box count {n_boxes} != expected "
|
||||
f"n_boxes_target={self.n_boxes_target}; stitch_valid=False."
|
||||
)
|
||||
elif self.isstitch and not grid_ok and self.verbose:
|
||||
# Soft note: active index length may be nrow*ncol while more boxes exist.
|
||||
print(
|
||||
f"Note: detected {n_boxes} boxes; active stitch grid is "
|
||||
f"{self.nrow}x{self.ncol} via index ({len(tiles_mod.normalize_index(self.index))} tiles)."
|
||||
)
|
||||
return n_boxes, self.boxes
|
||||
|
||||
def get_stitch_unit(self, img, flip_x=True, flip_y=True, index=np.arange(15)):
|
||||
active_index = index if self.units is None else self.index
|
||||
if isinstance(active_index, int):
|
||||
selected_index = {active_index}
|
||||
else:
|
||||
selected_index = set(int(i) for i in active_index)
|
||||
self.units = tiles_mod.extract_units_from_boxes(
|
||||
img,
|
||||
self.boxes,
|
||||
selected_index=selected_index,
|
||||
units=self.units,
|
||||
flip_x=flip_x,
|
||||
flip_y=flip_y,
|
||||
)
|
||||
return True
|
||||
|
||||
def extract_tiles(self):
|
||||
if not self.isstitch:
|
||||
tile_idx = 7
|
||||
self.index = [tile_idx] if isinstance(tile_idx, int) else tile_idx
|
||||
self.ncol = 1
|
||||
self.nrow = 1
|
||||
|
||||
tile_list = tiles_mod.get_tile_by_index(self.units, self.index)
|
||||
self.tile_list = tile_list
|
||||
return tile_list
|
||||
|
||||
def draw_boxes(self, img, thickness=2):
|
||||
return boxes_mod.draw_boxes(img, self.boxes, thickness=thickness)
|
||||
|
||||
# Compatibility aliases used by older call sites / tests.
|
||||
def _copy_into_tile_buffer(self, src_view, dst, flip_x=True, flip_y=True):
|
||||
from .ops import copy_into_tile_buffer
|
||||
|
||||
copy_into_tile_buffer(src_view, dst, flip_x=flip_x, flip_y=flip_y)
|
||||
|
||||
def _tile_weight_map(self, h, w, margin=16):
|
||||
from .ops import tile_weight_map
|
||||
|
||||
return tile_weight_map(h, w, margin)
|
||||
|
||||
def _paste_tile_clipped(self, canvas, tile, y0, x0, cov=None):
|
||||
from .ops import paste_tile_clipped
|
||||
|
||||
paste_tile_clipped(canvas, tile, y0, x0, cov=cov)
|
||||
|
||||
def _blend_tile_inplace(self, blend, wsum, cov, src, weight, y0, x0, scratch=None):
|
||||
self._blend_backend.blend_tile_inplace(blend, wsum, cov, src, weight, y0, x0)
|
||||
|
||||
def _compute_final_crop_rect(self, cov):
|
||||
from .ops import compute_final_crop_rect
|
||||
|
||||
return compute_final_crop_rect(cov)
|
||||
|
||||
def _largest_fully_covered_rect(self, cov):
|
||||
from .ops import largest_fully_covered_rect
|
||||
|
||||
return largest_fully_covered_rect(cov)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Blend backend registry (numpy / numba; future: torch / cupy)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .base import BlendBackend
|
||||
from .numba_backend import NumbaBlendBackend
|
||||
from .numpy_backend import NumpyBlendBackend
|
||||
from ..config import numba_blend_available
|
||||
|
||||
_BACKENDS = {
|
||||
"numpy": NumpyBlendBackend,
|
||||
"numba": NumbaBlendBackend,
|
||||
}
|
||||
|
||||
|
||||
def get_blend_backend(
|
||||
name: str,
|
||||
*,
|
||||
on_numba_failure: Optional[Callable[[BaseException], None]] = None,
|
||||
) -> BlendBackend:
|
||||
"""Return a blend backend instance by name.
|
||||
|
||||
Unknown names raise ``ValueError``. ``numba`` falls back to construction
|
||||
even if JIT is missing; the backend itself falls back to numpy on failure.
|
||||
"""
|
||||
key = str(name).strip().lower()
|
||||
if key == "none":
|
||||
# Feather off uses paste path; keep a numpy backend available if forced.
|
||||
key = "numpy"
|
||||
if key not in _BACKENDS:
|
||||
raise ValueError(
|
||||
f"Unknown blend backend {name!r}; expected one of "
|
||||
f"{sorted(set(_BACKENDS) | {'none'})} "
|
||||
f"(future backends e.g. 'torch' / 'cupy' are not registered yet)."
|
||||
)
|
||||
if key == "numba":
|
||||
return NumbaBlendBackend(on_failure=on_numba_failure)
|
||||
return NumpyBlendBackend()
|
||||
|
||||
|
||||
def available_blend_backends() -> list[str]:
|
||||
names = ["none", "numpy"]
|
||||
if numba_blend_available():
|
||||
names.append("numba")
|
||||
return names
|
||||
@@ -0,0 +1,29 @@
|
||||
"""BlendBackend protocol for CPU (and future GPU) stitch compositors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BlendBackend(Protocol):
|
||||
"""Array-in / in-place composite kernel.
|
||||
|
||||
Implementations must accept contiguous float32/uint8 NumPy arrays today.
|
||||
Future Torch/CuPy backends can adapt tensors to this contract or extend it.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def blend_tile_inplace(
|
||||
self,
|
||||
blend,
|
||||
wsum,
|
||||
cov,
|
||||
src,
|
||||
weight,
|
||||
y0: int,
|
||||
x0: int,
|
||||
) -> None:
|
||||
"""Composite ``src`` weighted by ``weight`` onto canvas buffers at (y0, x0)."""
|
||||
...
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Numba blend backend wrapping ``orisys_blend.kernel``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .numpy_backend import NumpyBlendBackend, blend_kernel_numpy
|
||||
|
||||
try:
|
||||
from orisys_blend.kernel import _NUMBA_AVAILABLE, blend_kernel as _blend_kernel_numba
|
||||
except Exception:
|
||||
_NUMBA_AVAILABLE = False
|
||||
_blend_kernel_numba = None
|
||||
|
||||
if _blend_kernel_numba is None:
|
||||
_NUMBA_AVAILABLE = False
|
||||
|
||||
|
||||
class NumbaBlendBackend:
|
||||
name = "numba"
|
||||
|
||||
def __init__(self, *, on_failure=None):
|
||||
self._on_failure = on_failure
|
||||
self._fallback = NumpyBlendBackend()
|
||||
self.failed = False
|
||||
|
||||
def blend_tile_inplace(self, blend, wsum, cov, src, weight, y0, x0) -> None:
|
||||
h, w = src.shape[:2]
|
||||
H, W = blend.shape[:2]
|
||||
y0d, x0d = max(0, y0), max(0, x0)
|
||||
y1d, x1d = min(H, y0 + h), min(W, x0 + w)
|
||||
if y0d >= y1d or x0d >= x1d:
|
||||
return
|
||||
|
||||
sy0, sx0 = y0d - y0, x0d - x0
|
||||
sh, sw = y1d - y0d, x1d - x0d
|
||||
src_f = src[sy0 : sy0 + sh, sx0 : sx0 + sw].astype(np.float32, copy=False)
|
||||
args = (
|
||||
blend[y0d:y1d, x0d:x1d],
|
||||
wsum[y0d:y1d, x0d:x1d],
|
||||
cov[y0d:y1d, x0d:x1d],
|
||||
src_f,
|
||||
weight[sy0 : sy0 + sh, sx0 : sx0 + sw],
|
||||
)
|
||||
if not self.failed and _NUMBA_AVAILABLE and _blend_kernel_numba is not None:
|
||||
try:
|
||||
_blend_kernel_numba(*args)
|
||||
return
|
||||
except Exception as exc:
|
||||
self.failed = True
|
||||
if self._on_failure is not None:
|
||||
self._on_failure(exc)
|
||||
blend_kernel_numpy(*args)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""NumPy blend backend (no JIT)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def blend_kernel_numpy(blend_patch, wsum_patch, cov_patch, src_patch, w_patch):
|
||||
"""Incremental weighted-average composite (pure NumPy, no JIT).
|
||||
|
||||
Uses contiguous arithmetic instead of boolean fancy indexing. Callers must
|
||||
zero ``blend`` / ``wsum`` on uncovered pixels (as the stitcher reset does);
|
||||
then the covered lerp also paints first-touch pixels correctly when ``w > 0``.
|
||||
"""
|
||||
w = w_patch if w_patch.dtype == np.float32 else np.asarray(w_patch, dtype=np.float32)
|
||||
src = src_patch if src_patch.dtype == np.float32 else np.asarray(src_patch, dtype=np.float32)
|
||||
|
||||
uncovered = cov_patch == 0
|
||||
if uncovered.all():
|
||||
np.copyto(blend_patch, src)
|
||||
np.copyto(wsum_patch, w)
|
||||
cov_patch.fill(1)
|
||||
return
|
||||
|
||||
ws = wsum_patch + w
|
||||
a = w / np.maximum(ws, np.float32(1e-6))
|
||||
for c in range(src.shape[2]):
|
||||
bc = blend_patch[..., c]
|
||||
bc += (src[..., c] - bc) * a
|
||||
np.copyto(wsum_patch, ws)
|
||||
|
||||
if uncovered.any():
|
||||
need_fix = uncovered & (w < np.float32(1e-6))
|
||||
if need_fix.any():
|
||||
blend_patch[need_fix] = src[need_fix]
|
||||
wsum_patch[need_fix] = w[need_fix]
|
||||
|
||||
cov_patch.fill(1)
|
||||
|
||||
|
||||
class NumpyBlendBackend:
|
||||
name = "numpy"
|
||||
|
||||
def blend_tile_inplace(self, blend, wsum, cov, src, weight, y0, x0) -> None:
|
||||
h, w = src.shape[:2]
|
||||
H, W = blend.shape[:2]
|
||||
y0d, x0d = max(0, y0), max(0, x0)
|
||||
y1d, x1d = min(H, y0 + h), min(W, x0 + w)
|
||||
if y0d >= y1d or x0d >= x1d:
|
||||
return
|
||||
|
||||
sy0, sx0 = y0d - y0, x0d - x0
|
||||
sh, sw = y1d - y0d, x1d - x0d
|
||||
src_f = src[sy0 : sy0 + sh, sx0 : sx0 + sw].astype(np.float32, copy=False)
|
||||
blend_kernel_numpy(
|
||||
blend[y0d:y1d, x0d:x1d],
|
||||
wsum[y0d:y1d, x0d:x1d],
|
||||
cov[y0d:y1d, x0d:x1d],
|
||||
src_f,
|
||||
weight[sy0 : sy0 + sh, sx0 : sx0 + sw],
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Box discovery and visualization for compound-eye calibration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def correct_edge_bloom(img, method: str = "divide", kernel_size=None, clip: bool = True):
|
||||
"""Illumination correction to reduce edge bloom (box detection only)."""
|
||||
if img is None:
|
||||
raise ValueError("Input image is None.")
|
||||
|
||||
h, w = img.shape[:2]
|
||||
if kernel_size is None:
|
||||
k = max(31, min(h, w) // 2)
|
||||
if k % 2 == 0:
|
||||
k += 1
|
||||
kernel_size = k
|
||||
img_f = img.astype(np.float32)
|
||||
bg = cv2.GaussianBlur(img_f, (kernel_size, kernel_size), 0)
|
||||
if method == "subtract":
|
||||
mean_bg = bg.mean(axis=(0, 1), keepdims=True)
|
||||
corr = img_f - (bg - mean_bg)
|
||||
else:
|
||||
eps = 1e-6
|
||||
mean_bg = bg.mean(axis=(0, 1), keepdims=True) + eps
|
||||
gain = mean_bg * 0.5
|
||||
corr = (img_f / (bg + eps)) * gain
|
||||
if clip:
|
||||
corr = np.clip(corr, 0, 255).astype(np.uint8)
|
||||
else:
|
||||
corr = corr.astype(np.uint8)
|
||||
return corr
|
||||
|
||||
|
||||
def filter_boxes_by_size(
|
||||
boxes: Sequence[Tuple[int, int, int, int]],
|
||||
img_shape,
|
||||
*,
|
||||
min_area_frac: float,
|
||||
max_area_frac: float,
|
||||
max_aspect_ratio: float,
|
||||
) -> List[Tuple[int, int, int, int]]:
|
||||
h, w = img_shape
|
||||
img_area = h * w
|
||||
min_area = img_area * float(min_area_frac)
|
||||
max_area = img_area * float(max_area_frac)
|
||||
filtered = []
|
||||
for (x, y, ww, hh) in boxes:
|
||||
area = ww * hh
|
||||
if area < min_area or area > max_area or (abs(ww - hh) > max_aspect_ratio):
|
||||
continue
|
||||
filtered.append((x, y, ww, hh))
|
||||
return filtered
|
||||
|
||||
|
||||
def detect_nonblack_regions(
|
||||
img,
|
||||
*,
|
||||
box_size: Sequence[int],
|
||||
min_area_frac: float,
|
||||
max_area_frac: float,
|
||||
max_aspect_ratio: float,
|
||||
morph_k: int = 3,
|
||||
n_boxes_target: int = 15,
|
||||
bk_thresh_step: int = 5,
|
||||
bk_thresh_max: int = 150,
|
||||
show: bool = False,
|
||||
row_tol: float = 0.4,
|
||||
save_preview_path: str = "./config/compound_eye.png",
|
||||
) -> Tuple[int, List[Tuple[int, int, int, int]]]:
|
||||
"""Detect tile boxes; returns ``(n_boxes, boxes as x0,y0,x1,y1)``."""
|
||||
if img is None:
|
||||
raise ValueError("Input image is None.")
|
||||
|
||||
h, w = img.shape[:2]
|
||||
n_boxes = 0
|
||||
bk_thresh = 0
|
||||
boxes: List[Tuple[int, int, int, int]] = []
|
||||
|
||||
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||
v = hsv[:, :, 2]
|
||||
while n_boxes != n_boxes_target and bk_thresh <= bk_thresh_max:
|
||||
_, v_mask = cv2.threshold(v, bk_thresh, 255, cv2.THRESH_BINARY)
|
||||
mask = v_mask.copy()
|
||||
edge = 20
|
||||
if w > edge * 2:
|
||||
mask[:, :edge] = 0
|
||||
mask[:, -edge:] = 0
|
||||
|
||||
k = max(1, morph_k)
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (k, k))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
boxes = []
|
||||
for c in contours:
|
||||
x, y, ww, hh = cv2.boundingRect(c)
|
||||
boxes.append((x, y, ww, hh))
|
||||
|
||||
boxes = filter_boxes_by_size(
|
||||
boxes,
|
||||
(h, w),
|
||||
min_area_frac=min_area_frac,
|
||||
max_area_frac=max_area_frac,
|
||||
max_aspect_ratio=max_aspect_ratio,
|
||||
)
|
||||
n_boxes = len(boxes)
|
||||
bk_thresh += bk_thresh_step
|
||||
|
||||
print(
|
||||
"Detected {} candidate boxes with bk_thresh={}".format(
|
||||
n_boxes, bk_thresh - bk_thresh_step
|
||||
)
|
||||
)
|
||||
if n_boxes == 0:
|
||||
return 0, []
|
||||
|
||||
target_w = int(box_size[0])
|
||||
target_h = int(box_size[1])
|
||||
cropped_boxes = []
|
||||
for (x, y, ww, hh) in boxes:
|
||||
cx = x + ww // 2
|
||||
cy = y + hh // 2
|
||||
nx0 = int(cx - target_w // 2)
|
||||
ny0 = int(cy - target_h // 2)
|
||||
nx0 = max(0, min(nx0, w - target_w))
|
||||
ny0 = max(0, min(ny0, h - target_h))
|
||||
cropped_boxes.append((nx0, ny0, nx0 + target_w, ny0 + target_h))
|
||||
|
||||
if cropped_boxes:
|
||||
heights = [b[3] - b[1] for b in cropped_boxes]
|
||||
median_height = np.median(heights) if heights else target_h
|
||||
row_tolerance = max(1, int(median_height * row_tol))
|
||||
|
||||
def get_row_key(box):
|
||||
y = box[1]
|
||||
quantized_y = (y // row_tolerance) * row_tolerance
|
||||
return (quantized_y, box[0])
|
||||
|
||||
cropped_boxes = sorted(cropped_boxes, key=get_row_key)
|
||||
else:
|
||||
cropped_boxes = sorted(cropped_boxes, key=lambda b: (b[1], b[0]))
|
||||
|
||||
img_with_boxes = draw_boxes(img, cropped_boxes)
|
||||
if show:
|
||||
cv2.imshow("Detected Boxes", img_with_boxes)
|
||||
cv2.waitKey(0)
|
||||
|
||||
if save_preview_path:
|
||||
parent = os.path.dirname(save_preview_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
cv2.imwrite(save_preview_path, img_with_boxes)
|
||||
|
||||
return len(cropped_boxes), cropped_boxes
|
||||
|
||||
|
||||
def draw_boxes(img, boxes, thickness: int = 2):
|
||||
if boxes is None or len(boxes) == 0:
|
||||
print("No boxes to draw")
|
||||
return img
|
||||
|
||||
img_draw = img.copy()
|
||||
color = (0, 255, 0)
|
||||
for i, (x0, y0, x1, y1) in enumerate(boxes):
|
||||
cv2.rectangle(img_draw, (x0, y0), (x1, y1), color, thickness)
|
||||
text_size = cv2.getTextSize(str(i), cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0]
|
||||
text_pos = (x0 + 5, y0 + text_size[1] + 5)
|
||||
cv2.rectangle(
|
||||
img_draw,
|
||||
(text_pos[0] - 2, text_pos[1] - text_size[1] - 2),
|
||||
(text_pos[0] + text_size[0] + 2, text_pos[1] + 2),
|
||||
(0, 0, 0),
|
||||
-1,
|
||||
)
|
||||
cv2.putText(img_draw, str(i), text_pos, cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
|
||||
return img_draw
|
||||
|
||||
|
||||
def find_stitch_boxes(
|
||||
img_src,
|
||||
*,
|
||||
box_size,
|
||||
min_area_frac,
|
||||
max_area_frac,
|
||||
max_aspect_ratio,
|
||||
row_tol,
|
||||
nrow: int,
|
||||
ncol: int,
|
||||
show: bool = False,
|
||||
n_boxes_target: Optional[int] = None,
|
||||
):
|
||||
"""Run bloom correction + detection; return ``(n_boxes, boxes, grid_ok)``.
|
||||
|
||||
``n_boxes_target`` is the physical tile count to detect (default 15).
|
||||
``grid_ok`` is True when that count matches ``n_boxes_target``.
|
||||
"""
|
||||
img_corr = correct_edge_bloom(img_src)
|
||||
target = int(n_boxes_target) if n_boxes_target is not None else 15
|
||||
n_boxes, boxes = detect_nonblack_regions(
|
||||
img_corr,
|
||||
box_size=box_size,
|
||||
min_area_frac=min_area_frac,
|
||||
max_area_frac=max_area_frac,
|
||||
max_aspect_ratio=max_aspect_ratio,
|
||||
n_boxes_target=target,
|
||||
show=show,
|
||||
row_tol=row_tol,
|
||||
)
|
||||
grid_ok = n_boxes == target
|
||||
return n_boxes, boxes, grid_ok
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Stitch configuration, blend-mode resolution, and status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
# JSON ``stitcher.blend`` / ``blend_list``: none = fast paste; numpy/numba = feathered.
|
||||
BLEND_MODES = frozenset({"none", "numpy", "numba"})
|
||||
DEFAULT_BLEND_MODE = "none"
|
||||
|
||||
|
||||
def numba_blend_available() -> bool:
|
||||
try:
|
||||
from orisys_blend.kernel import _NUMBA_AVAILABLE, blend_kernel
|
||||
except Exception:
|
||||
return False
|
||||
return bool(_NUMBA_AVAILABLE and blend_kernel is not None)
|
||||
|
||||
|
||||
def resolve_blend_mode(config_stitcher: Optional[Mapping[str, Any]] = None):
|
||||
"""Resolve stitcher blend mode from env, then JSON ``blend``.
|
||||
|
||||
Returns ``(mode, fell_back_from_numba)`` where mode is one of
|
||||
``none`` / ``numpy`` / ``numba``.
|
||||
|
||||
Priority: ``ORISYS_STITCH_BLEND``, ``ORISYS_STITCH_BLEND_BACKEND``,
|
||||
then ``config_stitcher["blend"]`` / legacy ``blend_backend``, then ``none``.
|
||||
"""
|
||||
requested = None
|
||||
for key in ("ORISYS_STITCH_BLEND", "ORISYS_STITCH_BLEND_BACKEND"):
|
||||
env = os.environ.get(key, "").strip().lower()
|
||||
if env in BLEND_MODES:
|
||||
requested = env
|
||||
break
|
||||
|
||||
if requested is None and isinstance(config_stitcher, dict):
|
||||
for key in ("blend", "blend_backend"):
|
||||
cfg = str(config_stitcher.get(key, "")).strip().lower()
|
||||
if cfg in BLEND_MODES:
|
||||
requested = cfg
|
||||
break
|
||||
|
||||
if requested is None:
|
||||
requested = DEFAULT_BLEND_MODE
|
||||
|
||||
if requested == "numba" and not numba_blend_available():
|
||||
return "numpy", True
|
||||
return requested, False
|
||||
|
||||
|
||||
def expected_box_count(nrow: int, ncol: int) -> int:
|
||||
return int(nrow) * int(ncol)
|
||||
|
||||
|
||||
def validate_box_grid(boxes, nrow: int, ncol: int) -> bool:
|
||||
"""True when detected boxes match the configured grid size."""
|
||||
if boxes is None:
|
||||
return False
|
||||
return len(boxes) == expected_box_count(nrow, ncol)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StitchStatus:
|
||||
"""Runtime health snapshot for compound-eye stitching."""
|
||||
|
||||
blend_mode: str = DEFAULT_BLEND_MODE
|
||||
backend_name: str = "numpy"
|
||||
numba_failed: bool = False
|
||||
coverage: float = 0.0
|
||||
last_ms: float = 0.0
|
||||
tile_count: int = 0
|
||||
stitch_valid: bool = False
|
||||
box_count: int = 0
|
||||
expected_boxes: int = 0
|
||||
@@ -0,0 +1,599 @@
|
||||
"""Feature-based (ORB/BFMatcher) stitch plan and render orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .backends.base import BlendBackend
|
||||
from .ops import (
|
||||
compute_final_crop_rect,
|
||||
coverage_ratio,
|
||||
match_bgr_brightness,
|
||||
mean_gray_bgr,
|
||||
paste_tile_clipped,
|
||||
tile_weight_map,
|
||||
translation_from_H_to_ref,
|
||||
)
|
||||
from .tiles import normalize_index
|
||||
|
||||
|
||||
def tile_to_bgr(img):
|
||||
if img.ndim == 2:
|
||||
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||
return img
|
||||
|
||||
|
||||
def tile_to_gray(bgr):
|
||||
if bgr.ndim == 2:
|
||||
return bgr
|
||||
return cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
|
||||
def ensure_u8_gray(gray):
|
||||
if gray.dtype == np.uint8:
|
||||
return gray
|
||||
return cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||
|
||||
|
||||
def preprocess_gray_for_orb(g0, g1):
|
||||
g0 = cv2.GaussianBlur(g0, (5, 5), 0)
|
||||
g1 = cv2.GaussianBlur(g1, (5, 5), 0)
|
||||
g0 = cv2.equalizeHist(g0)
|
||||
g1 = cv2.equalizeHist(g1)
|
||||
return g0, g1
|
||||
|
||||
|
||||
def estimate_bfmatcher_stitch_transform(
|
||||
t0,
|
||||
t1,
|
||||
*,
|
||||
ransac_reproj_thresh=5.0,
|
||||
ratio=0.9,
|
||||
orb_nfeatures=5000,
|
||||
verbose=True,
|
||||
):
|
||||
"""Estimate a 3x3 transform mapping tile coordinates into the reference frame."""
|
||||
t0_bgr = tile_to_bgr(t0)
|
||||
t1_bgr = tile_to_bgr(t1)
|
||||
g0 = ensure_u8_gray(tile_to_gray(t0_bgr))
|
||||
g1 = ensure_u8_gray(tile_to_gray(t1_bgr))
|
||||
|
||||
if verbose:
|
||||
print(
|
||||
f"BF stitcher debug: t0 dtype={t0.dtype}, t1 dtype={t1.dtype}, "
|
||||
f"g0 dtype={g0.dtype}, g0 range=({np.min(g0)}, {np.max(g0)}), "
|
||||
f"g1 dtype={g1.dtype}, g1 range=({np.min(g1)}, {np.max(g1)})"
|
||||
)
|
||||
|
||||
# g0, g1 = preprocess_gray_for_orb(g0, g1)
|
||||
orb = cv2.ORB_create(
|
||||
nfeatures=orb_nfeatures,
|
||||
scaleFactor=1.2,
|
||||
nlevels=8,
|
||||
edgeThreshold=15,
|
||||
fastThreshold=5,
|
||||
)
|
||||
k0, d0 = orb.detectAndCompute(g0, None)
|
||||
k1, d1 = orb.detectAndCompute(g1, None)
|
||||
|
||||
info = {
|
||||
"ok": False,
|
||||
"method": None,
|
||||
"inliers": 0,
|
||||
"mean_err": None,
|
||||
"good_matches": 0,
|
||||
"k0": k0,
|
||||
"k1": k1,
|
||||
"t0_bgr": t0_bgr,
|
||||
"t1_bgr": t1_bgr,
|
||||
}
|
||||
if d0 is None or d1 is None or len(k0) < 4 or len(k1) < 4:
|
||||
if verbose:
|
||||
print(
|
||||
"OpenCV BFMatcher stitcher: not enough features.",
|
||||
f"k0={0 if k0 is None else len(k0)}",
|
||||
f"k1={0 if k1 is None else len(k1)}",
|
||||
f"d0_is_none={d0 is None}",
|
||||
f"d1_is_none={d1 is None}",
|
||||
)
|
||||
return None, info
|
||||
|
||||
matches = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False).knnMatch(d0, d1, k=2)
|
||||
good = []
|
||||
for m_n in matches:
|
||||
if len(m_n) != 2:
|
||||
continue
|
||||
m, n = m_n
|
||||
if m.distance < ratio * n.distance:
|
||||
good.append(m)
|
||||
|
||||
info["good_matches"] = len(good)
|
||||
if verbose:
|
||||
print(f"BFMatcher good matches: {len(good)}")
|
||||
if len(good) < 4:
|
||||
return None, info
|
||||
|
||||
pts0 = np.float32([k0[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
pts1 = np.float32([k1[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
method = "affine_partial2d"
|
||||
M, inlier_mask = cv2.estimateAffinePartial2D(
|
||||
pts1,
|
||||
pts0,
|
||||
method=cv2.RANSAC,
|
||||
ransacReprojThreshold=ransac_reproj_thresh,
|
||||
)
|
||||
if M is not None:
|
||||
H = np.vstack([M, [0, 0, 1]]).astype(np.float64)
|
||||
else:
|
||||
method = "homography"
|
||||
H, inlier_mask = cv2.findHomography(pts1, pts0, cv2.RANSAC, ransac_reproj_thresh)
|
||||
|
||||
info["method"] = method
|
||||
info["inliers"] = int(inlier_mask.sum()) if inlier_mask is not None else 0
|
||||
if verbose:
|
||||
print(f"Estimated {method} inliers: {info['inliers']}")
|
||||
if H is None:
|
||||
return None, info
|
||||
|
||||
if abs(H[2, 2]) > 1e-12:
|
||||
H = H / H[2, 2]
|
||||
|
||||
mean_err = 1e9
|
||||
if inlier_mask is not None:
|
||||
inlier_bool = inlier_mask.ravel().astype(bool)
|
||||
if np.any(inlier_bool):
|
||||
projected = cv2.perspectiveTransform(pts1[inlier_bool], H)
|
||||
err = np.linalg.norm(
|
||||
projected.reshape(-1, 2) - pts0[inlier_bool].reshape(-1, 2),
|
||||
axis=1,
|
||||
)
|
||||
mean_err = float(err.mean()) if err.size else 1e9
|
||||
info["mean_err"] = mean_err
|
||||
info["ok"] = True
|
||||
return H, info
|
||||
|
||||
|
||||
def get_feature_stitch_plan(isstitch, index, nrow, ncol, tile_list):
|
||||
if not isstitch:
|
||||
return None
|
||||
|
||||
configured_indices = normalize_index(index)
|
||||
total_tiles = len(configured_indices)
|
||||
expected_tiles = nrow * ncol
|
||||
if total_tiles <= 1 or total_tiles != expected_tiles or len(tile_list) < total_tiles:
|
||||
return None
|
||||
|
||||
ref_pos = (nrow // 2) * ncol + (ncol // 2)
|
||||
if ref_pos >= total_tiles:
|
||||
return None
|
||||
|
||||
selected_positions = tuple(range(total_tiles))
|
||||
ref_idx = configured_indices[ref_pos]
|
||||
moving_positions = tuple(pos for pos in selected_positions if pos != ref_pos)
|
||||
moving_indices = tuple(configured_indices[pos] for pos in moving_positions)
|
||||
moving_tiles = [tile_list[pos] for pos in moving_positions]
|
||||
return ref_idx, tile_list[ref_pos], moving_indices, moving_tiles, selected_positions
|
||||
|
||||
|
||||
def _geom_cache_key(t_ref_bgr, tiles_bgr_c, pad):
|
||||
ref_h, ref_w = t_ref_bgr.shape[:2]
|
||||
shapes = tuple(tb.shape[:2] for tb in tiles_bgr_c)
|
||||
return ref_h, ref_w, shapes, pad
|
||||
|
||||
|
||||
def ensure_feature_geom_cache(
|
||||
canvas_cache,
|
||||
t_ref_bgr,
|
||||
tiles_bgr_c,
|
||||
H_use_list,
|
||||
pad,
|
||||
*,
|
||||
allocate_blend_bufs=True,
|
||||
):
|
||||
h0c, w0c = t_ref_bgr.shape[:2]
|
||||
geom_key = _geom_cache_key(t_ref_bgr, tiles_bgr_c, pad)
|
||||
geom_cache_hit = canvas_cache.get("geom_key") == geom_key
|
||||
if geom_cache_hit:
|
||||
if allocate_blend_bufs and (
|
||||
"blend_buf" not in canvas_cache
|
||||
or "wsum_buf" not in canvas_cache
|
||||
or "cov_buf" not in canvas_cache
|
||||
):
|
||||
out_h = canvas_cache["out_h"]
|
||||
out_w = canvas_cache["out_w"]
|
||||
canvas_cache["blend_buf"] = np.zeros((out_h, out_w, 3), dtype=np.float32)
|
||||
canvas_cache["wsum_buf"] = np.zeros((out_h, out_w), dtype=np.float32)
|
||||
canvas_cache["cov_buf"] = np.zeros((out_h, out_w), dtype=np.uint8)
|
||||
return True
|
||||
|
||||
ref_corners = np.array([[0, 0], [w0c, 0], [w0c, h0c], [0, h0c]], dtype=np.float64)
|
||||
pts = [ref_corners]
|
||||
for tile_bgr, H_use in zip(tiles_bgr_c, H_use_list):
|
||||
if H_use is None:
|
||||
continue
|
||||
hi, wi = tile_bgr.shape[:2]
|
||||
tx, ty = translation_from_H_to_ref(H_use)
|
||||
pts.append(
|
||||
np.array(
|
||||
[[tx, ty], [tx + wi, ty], [tx + wi, ty + hi], [tx, ty + hi]],
|
||||
dtype=np.float64,
|
||||
)
|
||||
)
|
||||
|
||||
all_xy = np.concatenate(pts, axis=0)
|
||||
x_min_f = float(all_xy[:, 0].min()) - pad
|
||||
y_min_f = float(all_xy[:, 1].min()) - pad
|
||||
x_max_f = float(all_xy[:, 0].max()) + pad
|
||||
y_max_f = float(all_xy[:, 1].max()) + pad
|
||||
out_w = max(1, int(np.ceil(x_max_f - x_min_f)))
|
||||
out_h = max(1, int(np.ceil(y_max_f - y_min_f)))
|
||||
ref_y0 = int(np.floor(-y_min_f))
|
||||
ref_x0 = int(np.floor(-x_min_f))
|
||||
|
||||
pastes = []
|
||||
for H_use in H_use_list:
|
||||
if H_use is None:
|
||||
pastes.append(None)
|
||||
continue
|
||||
tx, ty = translation_from_H_to_ref(H_use)
|
||||
pastes.append((int(np.floor(ty - y_min_f)), int(np.floor(tx - x_min_f))))
|
||||
|
||||
_dr_y0 = max(0, ref_y0)
|
||||
_dr_x0 = max(0, ref_x0)
|
||||
_dr_y1 = min(out_h, ref_y0 + h0c)
|
||||
_dr_x1 = min(out_w, ref_x0 + w0c)
|
||||
for _tb, _pa in zip(tiles_bgr_c, pastes):
|
||||
if _pa is None:
|
||||
continue
|
||||
_py, _px = _pa
|
||||
_th, _tw = _tb.shape[:2]
|
||||
_dr_y0 = min(_dr_y0, max(0, _py))
|
||||
_dr_x0 = min(_dr_x0, max(0, _px))
|
||||
_dr_y1 = max(_dr_y1, min(out_h, _py + _th))
|
||||
_dr_x1 = max(_dr_x1, min(out_w, _px + _tw))
|
||||
|
||||
canvas_cache["geom_key"] = geom_key
|
||||
canvas_cache["out_w"] = out_w
|
||||
canvas_cache["out_h"] = out_h
|
||||
canvas_cache["ref_y0"] = ref_y0
|
||||
canvas_cache["ref_x0"] = ref_x0
|
||||
canvas_cache["pastes"] = pastes
|
||||
canvas_cache["x_min_f"] = x_min_f
|
||||
canvas_cache["y_min_f"] = y_min_f
|
||||
canvas_cache["crop_rect"] = None
|
||||
canvas_cache["dirty_rect"] = (_dr_y0, _dr_y1, _dr_x0, _dr_x1)
|
||||
canvas_cache["weight_maps"] = {}
|
||||
canvas_cache["uint8_buf"] = np.zeros((out_h, out_w, 3), dtype=np.uint8)
|
||||
if allocate_blend_bufs:
|
||||
canvas_cache["blend_buf"] = np.zeros((out_h, out_w, 3), dtype=np.float32)
|
||||
canvas_cache["wsum_buf"] = np.zeros((out_h, out_w), dtype=np.float32)
|
||||
canvas_cache["cov_buf"] = np.zeros((out_h, out_w), dtype=np.uint8)
|
||||
else:
|
||||
canvas_cache["cov_buf"] = np.zeros((out_h, out_w), dtype=np.uint8)
|
||||
canvas_cache.pop("blend_buf", None)
|
||||
canvas_cache.pop("wsum_buf", None)
|
||||
return False
|
||||
|
||||
|
||||
def paste_feature_tiles_fast(
|
||||
t_ref_bgr,
|
||||
tiles_bgr_c,
|
||||
H_use_list,
|
||||
canvas_cache,
|
||||
*,
|
||||
geom_cache_hit,
|
||||
frame_idx=0,
|
||||
print_every=30,
|
||||
verbose=False,
|
||||
t_profile0=0.0,
|
||||
t_after_brightness=0.0,
|
||||
t_after_geom=0.0,
|
||||
):
|
||||
do_profile = verbose and frame_idx % print_every == 0
|
||||
out_w = canvas_cache["out_w"]
|
||||
out_h = canvas_cache["out_h"]
|
||||
ref_y0 = canvas_cache["ref_y0"]
|
||||
ref_x0 = canvas_cache["ref_x0"]
|
||||
pastes = canvas_cache["pastes"]
|
||||
|
||||
canvas = canvas_cache.get("uint8_buf")
|
||||
if canvas is None or canvas.shape[0] != out_h or canvas.shape[1] != out_w:
|
||||
canvas = np.zeros((out_h, out_w, 3), dtype=np.uint8)
|
||||
canvas_cache["uint8_buf"] = canvas
|
||||
|
||||
dirty = canvas_cache.get("dirty_rect")
|
||||
if geom_cache_hit and dirty is not None:
|
||||
dy0, dy1, dx0, dx1 = dirty
|
||||
canvas[dy0:dy1, dx0:dx1] = 0
|
||||
else:
|
||||
canvas.fill(0)
|
||||
t_after_reset = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
need_crop = canvas_cache.get("crop_rect") is None
|
||||
cov = canvas_cache.get("cov_buf") if need_crop else None
|
||||
if need_crop:
|
||||
if cov is None or cov.shape != (out_h, out_w):
|
||||
cov = np.zeros((out_h, out_w), dtype=np.uint8)
|
||||
canvas_cache["cov_buf"] = cov
|
||||
else:
|
||||
if dirty is not None:
|
||||
dy0, dy1, dx0, dx1 = dirty
|
||||
cov[dy0:dy1, dx0:dx1] = 0
|
||||
else:
|
||||
cov.fill(0)
|
||||
|
||||
pasted_tiles = 0
|
||||
for tile_bgr, H_use, paste in zip(tiles_bgr_c, H_use_list, pastes):
|
||||
if H_use is None or paste is None:
|
||||
continue
|
||||
y0, x0 = paste
|
||||
paste_tile_clipped(canvas, tile_bgr, y0, x0, cov=cov)
|
||||
pasted_tiles += 1
|
||||
paste_tile_clipped(canvas, t_ref_bgr, ref_y0, ref_x0, cov=cov)
|
||||
t_after_paste = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
crop_rect = canvas_cache.get("crop_rect")
|
||||
if crop_rect is None and cov is not None:
|
||||
crop_rect = compute_final_crop_rect(cov)
|
||||
canvas_cache["crop_rect"] = crop_rect
|
||||
if crop_rect is not None:
|
||||
y0, y1, x0, x1 = crop_rect
|
||||
roi = canvas[y0:y1, x0:x1]
|
||||
else:
|
||||
roi = canvas
|
||||
|
||||
rh, rw = roi.shape[:2]
|
||||
stitch_buf = canvas_cache.get("stitch_buf")
|
||||
if stitch_buf is None or stitch_buf.shape[0] != rh or stitch_buf.shape[1] != rw:
|
||||
stitch_buf = np.empty((rh, rw, 3), dtype=np.uint8)
|
||||
canvas_cache["stitch_buf"] = stitch_buf
|
||||
np.copyto(stitch_buf, roi)
|
||||
t_after_finalize = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
if do_profile:
|
||||
xmf = canvas_cache.get("x_min_f", 0.0)
|
||||
ymf = canvas_cache.get("y_min_f", 0.0)
|
||||
print(
|
||||
f"stitch_feature_optimized: canvas out_w={out_w}, out_h={out_h}, "
|
||||
f"x_min={xmf:.2f}, y_min={ymf:.2f} (fast_paste)"
|
||||
)
|
||||
print(
|
||||
"stitch_feature_optimized steps: "
|
||||
f"brightness={t_after_brightness - t_profile0:.6f}s, "
|
||||
f"geom={t_after_geom - t_after_brightness:.6f}s ({'hit' if geom_cache_hit else 'miss'}), "
|
||||
f"reset={t_after_reset - t_after_geom:.6f}s, "
|
||||
f"paste={t_after_paste - t_after_reset:.6f}s ({pasted_tiles + 1} tiles incl ref), "
|
||||
f"finalize={t_after_finalize - t_after_paste:.6f}s, "
|
||||
f"total={t_after_finalize - t_profile0:.6f}s"
|
||||
)
|
||||
canvas_cache["last_coverage"] = coverage_ratio(cov) if cov is not None else canvas_cache.get("last_coverage", 0.0)
|
||||
return stitch_buf
|
||||
|
||||
|
||||
def render_feature_tiles(
|
||||
t_ref_bgr,
|
||||
tiles_bgr,
|
||||
H_use_list,
|
||||
*,
|
||||
blend_backend: BlendBackend,
|
||||
pad=5,
|
||||
frame_idx=0,
|
||||
print_every=30,
|
||||
verbose=False,
|
||||
match_brightness=False,
|
||||
canvas_cache=None,
|
||||
blend=True,
|
||||
):
|
||||
if H_use_list is None:
|
||||
return None
|
||||
|
||||
if canvas_cache is None:
|
||||
canvas_cache = {}
|
||||
do_profile = verbose and frame_idx % print_every == 0
|
||||
t_profile0 = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
if t_ref_bgr.ndim == 2:
|
||||
t_ref_bgr = cv2.cvtColor(t_ref_bgr, cv2.COLOR_GRAY2BGR)
|
||||
h0c, w0c = t_ref_bgr.shape[:2]
|
||||
if match_brightness:
|
||||
ref_mean_gray = mean_gray_bgr(t_ref_bgr)
|
||||
tiles_bgr_c = [
|
||||
match_bgr_brightness(
|
||||
tb if tb.ndim == 3 else cv2.cvtColor(tb, cv2.COLOR_GRAY2BGR),
|
||||
ref_mean_gray,
|
||||
)
|
||||
for tb in tiles_bgr
|
||||
]
|
||||
elif any(tb.ndim == 2 for tb in tiles_bgr):
|
||||
tiles_bgr_c = [tb if tb.ndim == 3 else cv2.cvtColor(tb, cv2.COLOR_GRAY2BGR) for tb in tiles_bgr]
|
||||
else:
|
||||
tiles_bgr_c = tiles_bgr
|
||||
t_after_brightness = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
geom_cache_hit = ensure_feature_geom_cache(
|
||||
canvas_cache,
|
||||
t_ref_bgr,
|
||||
tiles_bgr_c,
|
||||
H_use_list,
|
||||
pad,
|
||||
allocate_blend_bufs=bool(blend),
|
||||
)
|
||||
t_after_geom = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
if not blend:
|
||||
return paste_feature_tiles_fast(
|
||||
t_ref_bgr,
|
||||
tiles_bgr_c,
|
||||
H_use_list,
|
||||
canvas_cache,
|
||||
geom_cache_hit=geom_cache_hit,
|
||||
frame_idx=frame_idx,
|
||||
print_every=print_every,
|
||||
verbose=verbose,
|
||||
t_profile0=t_profile0,
|
||||
t_after_brightness=t_after_brightness,
|
||||
t_after_geom=t_after_geom,
|
||||
)
|
||||
|
||||
out_w = canvas_cache["out_w"]
|
||||
out_h = canvas_cache["out_h"]
|
||||
ref_y0 = canvas_cache["ref_y0"]
|
||||
ref_x0 = canvas_cache["ref_x0"]
|
||||
pastes = canvas_cache["pastes"]
|
||||
|
||||
blend_buf = canvas_cache["blend_buf"]
|
||||
wsum = canvas_cache["wsum_buf"]
|
||||
cov = canvas_cache["cov_buf"]
|
||||
weight_maps = canvas_cache["weight_maps"]
|
||||
if geom_cache_hit:
|
||||
_dr = canvas_cache.get("dirty_rect")
|
||||
if _dr is not None:
|
||||
_dy0, _dy1, _dx0, _dx1 = _dr
|
||||
blend_buf[_dy0:_dy1, _dx0:_dx1] = 0
|
||||
wsum[_dy0:_dy1, _dx0:_dx1] = 0
|
||||
cov[_dy0:_dy1, _dx0:_dx1] = 0
|
||||
else:
|
||||
blend_buf.fill(0)
|
||||
wsum.fill(0)
|
||||
cov.fill(0)
|
||||
t_after_reset = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
blend_margin = 16
|
||||
blended_tiles = 0
|
||||
for tile_bgr, H_use, paste in zip(tiles_bgr_c, H_use_list, pastes):
|
||||
if H_use is None or paste is None:
|
||||
continue
|
||||
y0, x0 = paste
|
||||
th, tw = tile_bgr.shape[:2]
|
||||
wkey = (th, tw, blend_margin)
|
||||
if wkey not in weight_maps:
|
||||
weight_maps[wkey] = tile_weight_map(th, tw, blend_margin)
|
||||
blend_backend.blend_tile_inplace(blend_buf, wsum, cov, tile_bgr, weight_maps[wkey], y0, x0)
|
||||
blended_tiles += 1
|
||||
|
||||
ref_wkey = (h0c, w0c, blend_margin)
|
||||
if ref_wkey not in weight_maps:
|
||||
weight_maps[ref_wkey] = tile_weight_map(h0c, w0c, blend_margin)
|
||||
blend_backend.blend_tile_inplace(
|
||||
blend_buf, wsum, cov, t_ref_bgr, weight_maps[ref_wkey], ref_y0, ref_x0
|
||||
)
|
||||
t_after_blend = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
crop_rect = canvas_cache.get("crop_rect")
|
||||
if crop_rect is None:
|
||||
crop_rect = compute_final_crop_rect(cov)
|
||||
canvas_cache["crop_rect"] = crop_rect
|
||||
if crop_rect is not None:
|
||||
y0, y1, x0, x1 = crop_rect
|
||||
roi = blend_buf[y0:y1, x0:x1]
|
||||
else:
|
||||
roi = blend_buf
|
||||
rh, rw = roi.shape[:2]
|
||||
stitch_buf = canvas_cache.get("stitch_buf")
|
||||
if stitch_buf is None or stitch_buf.shape[0] != rh or stitch_buf.shape[1] != rw:
|
||||
stitch_buf = np.empty((rh, rw, 3), dtype=np.uint8)
|
||||
canvas_cache["stitch_buf"] = stitch_buf
|
||||
cv2.convertScaleAbs(roi, dst=stitch_buf)
|
||||
t_after_finalize = cv2.getTickCount() / cv2.getTickFrequency() if do_profile else 0.0
|
||||
|
||||
if do_profile:
|
||||
xmf = canvas_cache.get("x_min_f", 0.0)
|
||||
ymf = canvas_cache.get("y_min_f", 0.0)
|
||||
print(
|
||||
f"stitch_feature_optimized: canvas out_w={out_w}, out_h={out_h}, "
|
||||
f"x_min={xmf:.2f}, y_min={ymf:.2f}"
|
||||
)
|
||||
print(
|
||||
"stitch_feature_optimized steps: "
|
||||
f"brightness={t_after_brightness - t_profile0:.6f}s, "
|
||||
f"geom={t_after_geom - t_after_brightness:.6f}s ({'hit' if geom_cache_hit else 'miss'}), "
|
||||
f"reset={t_after_reset - t_after_geom:.6f}s, "
|
||||
f"blend={t_after_blend - t_after_reset:.6f}s ({blended_tiles + 1} tiles incl ref), "
|
||||
f"finalize={t_after_finalize - t_after_blend:.6f}s, "
|
||||
f"total={t_after_finalize - t_profile0:.6f}s"
|
||||
)
|
||||
|
||||
canvas_cache["last_coverage"] = coverage_ratio(cov)
|
||||
canvas_cache["last_ms"] = (
|
||||
(t_after_finalize - t_profile0) * 1000.0 if do_profile else canvas_cache.get("last_ms", 0.0)
|
||||
)
|
||||
return stitch_buf
|
||||
|
||||
|
||||
def stitch_feature_optimized(
|
||||
*,
|
||||
isstitch: bool,
|
||||
index,
|
||||
nrow: int,
|
||||
ncol: int,
|
||||
tile_list: Sequence,
|
||||
cache: Dict[str, Any],
|
||||
blend_backend: BlendBackend,
|
||||
blend: bool = True,
|
||||
match_brightness: bool = False,
|
||||
pad: int = 5,
|
||||
print_every: int = 30,
|
||||
verbose: bool = False,
|
||||
frame_idx_holder: Optional[Dict[str, int]] = None,
|
||||
):
|
||||
"""Estimate transforms once and composite the current tile grid."""
|
||||
plan = get_feature_stitch_plan(isstitch, index, nrow, ncol, tile_list)
|
||||
if plan is None:
|
||||
return None
|
||||
|
||||
ref_idx, t_ref, moving_indices, moving_tiles, selected_positions = plan
|
||||
if frame_idx_holder is None:
|
||||
frame_idx_holder = {"idx": 0}
|
||||
frame_idx_holder["idx"] = int(frame_idx_holder.get("idx", 0)) + 1
|
||||
frame_idx = frame_idx_holder["idx"]
|
||||
tile_shapes = tuple(tile_list[pos].shape[:2] for pos in selected_positions)
|
||||
cache_changed = (
|
||||
cache["ref_idx"] != ref_idx
|
||||
or cache["moving_indices"] != moving_indices
|
||||
or cache["tile_shapes"] != tile_shapes
|
||||
)
|
||||
|
||||
if cache_changed:
|
||||
cache["ref_idx"] = ref_idx
|
||||
cache["moving_indices"] = moving_indices
|
||||
cache["tile_shapes"] = tile_shapes
|
||||
cache["transforms"] = None
|
||||
cache["canvas"] = {}
|
||||
if verbose:
|
||||
print(
|
||||
"Feature stitch tile selection: "
|
||||
f"ref_idx={ref_idx}, moving_indices={list(moving_indices)}"
|
||||
)
|
||||
|
||||
H_use_list = cache["transforms"]
|
||||
if H_use_list is None:
|
||||
do_verbose_first = verbose and frame_idx % print_every == 0
|
||||
H_use_list = []
|
||||
for j, tile in enumerate(moving_tiles):
|
||||
H_use_j, _ = estimate_bfmatcher_stitch_transform(
|
||||
t_ref,
|
||||
tile,
|
||||
verbose=(do_verbose_first and j == 0),
|
||||
)
|
||||
H_use_list.append(H_use_j)
|
||||
cache["transforms"] = H_use_list
|
||||
|
||||
if not any(H is not None for H in H_use_list):
|
||||
return None
|
||||
|
||||
return render_feature_tiles(
|
||||
t_ref,
|
||||
moving_tiles,
|
||||
H_use_list,
|
||||
blend_backend=blend_backend,
|
||||
pad=pad,
|
||||
frame_idx=frame_idx,
|
||||
print_every=print_every,
|
||||
verbose=verbose,
|
||||
match_brightness=match_brightness,
|
||||
canvas_cache=cache["canvas"],
|
||||
blend=blend,
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Pure array compute helpers for stitching (backend-agnostic).
|
||||
|
||||
These functions take/return NumPy arrays and must not depend on ``Stitcher``
|
||||
instance state so future Torch/CuPy backends can reuse the same call shapes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def tile_weight_map(h: int, w: int, margin: int = 16) -> np.ndarray:
|
||||
"""Linear ramp weight: 0 at edges, 1.0 at ``margin`` pixels inward."""
|
||||
wy = np.ones(h, dtype=np.float32)
|
||||
wx = np.ones(w, dtype=np.float32)
|
||||
m = min(margin, h // 2, w // 2)
|
||||
if m > 0:
|
||||
ramp = np.linspace(0.0, 1.0, m + 1, dtype=np.float32)[1:]
|
||||
wy[:m] = ramp
|
||||
wy[-m:] = ramp[::-1]
|
||||
wx[:m] = ramp
|
||||
wx[-m:] = ramp[::-1]
|
||||
return wy[:, None] * wx[None, :]
|
||||
|
||||
|
||||
def paste_tile_clipped(canvas, tile, y0: int, x0: int, cov=None) -> None:
|
||||
"""Paste tile into canvas with clipping; optionally mark coverage."""
|
||||
th, tw = tile.shape[:2]
|
||||
H, W = canvas.shape[:2]
|
||||
y0d, x0d = max(0, y0), max(0, x0)
|
||||
y1d, x1d = min(H, y0 + th), min(W, x0 + tw)
|
||||
if y0d >= y1d or x0d >= x1d:
|
||||
return
|
||||
sy0, sx0 = y0d - y0, x0d - x0
|
||||
sh, sw = y1d - y0d, x1d - x0d
|
||||
np.copyto(canvas[y0d:y1d, x0d:x1d], tile[sy0 : sy0 + sh, sx0 : sx0 + sw])
|
||||
if cov is not None:
|
||||
cov[y0d:y1d, x0d:x1d] = 1
|
||||
|
||||
|
||||
def copy_into_tile_buffer(src_view, dst, flip_x: bool = True, flip_y: bool = True) -> None:
|
||||
"""Flip/copy ``src_view`` into preallocated ``dst``."""
|
||||
if flip_x and flip_y:
|
||||
cv2.flip(src_view, -1, dst)
|
||||
elif flip_x:
|
||||
cv2.flip(src_view, 1, dst)
|
||||
elif flip_y:
|
||||
cv2.flip(src_view, 0, dst)
|
||||
else:
|
||||
np.copyto(dst, src_view)
|
||||
|
||||
|
||||
def build_blend_coef(ov_pos: int, dim: int, ndim: int) -> np.ndarray:
|
||||
"""1D/2D blend ramp for legacy overlap stitching."""
|
||||
blend_coef = np.linspace(1.0, 0.0, ov_pos, dtype=np.float32)
|
||||
if dim == 1:
|
||||
return blend_coef.reshape(1, ov_pos, 1) if ndim == 3 else blend_coef.reshape(1, ov_pos)
|
||||
return blend_coef.reshape(ov_pos, 1, 1) if ndim == 3 else blend_coef.reshape(ov_pos, 1)
|
||||
|
||||
|
||||
def mean_gray_bgr(bgr) -> float:
|
||||
ch = cv2.mean(bgr)
|
||||
return (ch[0] + ch[1] + ch[2]) / 3.0
|
||||
|
||||
|
||||
def match_bgr_brightness(tile_bgr, ref_mean_gray: float):
|
||||
delta = int(round(ref_mean_gray - mean_gray_bgr(tile_bgr)))
|
||||
if delta == 0:
|
||||
return tile_bgr
|
||||
lut = np.clip(np.arange(256, dtype=np.int16) + delta, 0, 255).astype(np.uint8)
|
||||
return cv2.LUT(tile_bgr, lut)
|
||||
|
||||
|
||||
def largest_fully_covered_rect(cov) -> Optional[Tuple[int, int, int, int]]:
|
||||
if cov.size == 0:
|
||||
return None
|
||||
|
||||
mask = cov > 0
|
||||
if not np.any(mask):
|
||||
return None
|
||||
|
||||
h, w = mask.shape
|
||||
heights = np.zeros(w, dtype=np.int32)
|
||||
best_area = 0
|
||||
best_rect = None
|
||||
for y in range(h):
|
||||
heights = np.where(mask[y], heights + 1, 0)
|
||||
stack = []
|
||||
for x in range(w + 1):
|
||||
cur_h = heights[x] if x < w else 0
|
||||
start = x
|
||||
while stack and stack[-1][1] > cur_h:
|
||||
start0, h0 = stack.pop()
|
||||
area = h0 * (x - start0)
|
||||
if area > best_area:
|
||||
best_area = area
|
||||
best_rect = (y - h0 + 1, y + 1, start0, x)
|
||||
start = start0
|
||||
if not stack or stack[-1][1] < cur_h:
|
||||
stack.append((start, cur_h))
|
||||
return best_rect
|
||||
|
||||
|
||||
def compute_final_crop_rect(cov) -> Optional[Tuple[int, int, int, int]]:
|
||||
valid = cov > 0
|
||||
if not np.any(valid):
|
||||
return None
|
||||
|
||||
ys, xs = np.where(valid)
|
||||
by0, by1 = int(ys.min()), int(ys.max()) + 1
|
||||
bx0, bx1 = int(xs.min()), int(xs.max()) + 1
|
||||
rect2 = largest_fully_covered_rect(cov[by0:by1, bx0:bx1])
|
||||
if rect2 is None:
|
||||
return by0, by1, bx0, bx1
|
||||
|
||||
y0, y1, x0, x1 = rect2
|
||||
return by0 + y0, by0 + y1, bx0 + x0, bx0 + x1
|
||||
|
||||
|
||||
def coverage_ratio(cov) -> float:
|
||||
if cov is None or cov.size == 0:
|
||||
return 0.0
|
||||
return float(np.mean(cov > 0))
|
||||
|
||||
|
||||
def translation_from_H_to_ref(H) -> Tuple[float, float]:
|
||||
if H is None:
|
||||
return 0.0, 0.0
|
||||
H = np.asarray(H, dtype=np.float64)
|
||||
if abs(H[2, 2]) > 1e-12:
|
||||
H = H / H[2, 2]
|
||||
pt = H @ np.array([[0.0], [0.0], [1.0]], dtype=np.float64)
|
||||
return float(pt[0, 0]), float(pt[1, 0])
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tile extraction and indexing for compound-eye frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .ops import copy_into_tile_buffer
|
||||
|
||||
|
||||
def extract_units_from_boxes(
|
||||
img,
|
||||
boxes: Sequence[Tuple[int, int, int, int]],
|
||||
*,
|
||||
selected_index: Iterable[int],
|
||||
units: Optional[list] = None,
|
||||
flip_x: bool = True,
|
||||
flip_y: bool = True,
|
||||
) -> list:
|
||||
"""Crop/flip tiles from ``boxes`` into ``units`` buffers (reused when possible)."""
|
||||
h, w = img.shape[:2]
|
||||
selected = set(selected_index)
|
||||
flipped_units = [] if units is None else units
|
||||
|
||||
for i, (x0, y0, x1, y1) in enumerate(boxes):
|
||||
if i not in selected:
|
||||
continue
|
||||
|
||||
x0 = max(0, int(x0))
|
||||
y0 = max(0, int(y0))
|
||||
x1 = min(w, int(x1))
|
||||
y1 = min(h, int(y1))
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
continue
|
||||
|
||||
src_view = img[y0:y1, x0:x1]
|
||||
if units is None:
|
||||
tile = np.empty_like(src_view)
|
||||
copy_into_tile_buffer(src_view, tile, flip_x=flip_x, flip_y=flip_y)
|
||||
flipped_units.append((x0, y0, tile))
|
||||
else:
|
||||
_, _, tile = flipped_units[i]
|
||||
if tile.shape != src_view.shape or tile.dtype != src_view.dtype:
|
||||
tile = np.empty_like(src_view)
|
||||
copy_into_tile_buffer(src_view, tile, flip_x=flip_x, flip_y=flip_y)
|
||||
flipped_units[i] = (x0, y0, tile)
|
||||
|
||||
return flipped_units
|
||||
|
||||
|
||||
def get_tile_by_index(units: Sequence, idx) -> List:
|
||||
if isinstance(idx, int):
|
||||
idx = [idx]
|
||||
if len(idx) > len(units):
|
||||
raise ValueError("Index exceeds number of tiles.")
|
||||
tile_list = []
|
||||
for i in range(len(idx)):
|
||||
_xi, _yi, tile = units[idx[i]]
|
||||
tile_list.append(tile)
|
||||
return tile_list
|
||||
|
||||
|
||||
def get_tile_index_by_position(units: Sequence, x, y, tol: int = 20) -> Optional[int]:
|
||||
i = 0
|
||||
for xi, yi, _tile in units:
|
||||
if abs(int(x) - int(xi)) <= tol and abs(int(y) - int(yi)) <= tol:
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def get_unit_pos(units: Sequence, idx) -> List[Tuple[int, int]]:
|
||||
if isinstance(idx, int):
|
||||
idx = [idx]
|
||||
if len(idx) > len(units):
|
||||
raise ValueError("Index exceeds number of vision units.")
|
||||
return [(units[i][0], units[i][1]) for i in idx]
|
||||
|
||||
|
||||
def normalize_index(index) -> Tuple[int, ...]:
|
||||
if isinstance(index, int):
|
||||
return (index,)
|
||||
if isinstance(index, np.ndarray):
|
||||
return tuple(int(i) for i in index.tolist())
|
||||
return tuple(int(i) for i in index)
|
||||
Reference in New Issue
Block a user