ad7b0d3e26
錯位模組畫面有 6x3 共 18 格,原本只能靠過渡門檻碰巧湊到 15 格且選錯格。stitcher.exclude_x 只篩選格子,box 設定為 [0, 240],可依感測器改寫;合成畫面測試與實機連續開啟 6 次全數成功。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
6.8 KiB
Python
211 lines
6.8 KiB
Python
"""Unit tests for orisys.stitch (synthetic tiles, no camera)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from orisys.frame_processors import CompoundEyeFrameProcessor
|
|
from orisys.stitch import (
|
|
StitchStatus,
|
|
get_blend_backend,
|
|
paste_tile_clipped,
|
|
resolve_blend_mode,
|
|
tile_weight_map,
|
|
)
|
|
from orisys.stitch.backends.numpy_backend import blend_kernel_numpy
|
|
from orisys.stitch.config import numba_blend_available, validate_box_grid
|
|
from orisys.stitch.ops import compute_final_crop_rect, coverage_ratio
|
|
from orisys.Stitcher import Stitcher
|
|
|
|
|
|
def _solid_tile(val: int, h=32, w=32):
|
|
return np.full((h, w, 3), val, dtype=np.uint8)
|
|
|
|
|
|
def test_tile_weight_map_edges_are_low():
|
|
wmap = tile_weight_map(32, 32, margin=4)
|
|
assert wmap.shape == (32, 32)
|
|
assert wmap.dtype == np.float32
|
|
assert float(wmap[0, 0]) < float(wmap[16, 16])
|
|
assert float(wmap[16, 16]) == pytest.approx(1.0)
|
|
|
|
|
|
def test_paste_tile_clipped_and_coverage():
|
|
canvas = np.zeros((40, 40, 3), dtype=np.uint8)
|
|
cov = np.zeros((40, 40), dtype=np.uint8)
|
|
tile = _solid_tile(200, 10, 10)
|
|
paste_tile_clipped(canvas, tile, 5, 5, cov=cov)
|
|
assert int(canvas[5, 5, 0]) == 200
|
|
assert int(cov[5, 5]) == 1
|
|
assert int(cov[0, 0]) == 0
|
|
|
|
|
|
def test_blend_kernel_numpy_first_paint():
|
|
blend = np.zeros((4, 4, 3), dtype=np.float32)
|
|
wsum = np.zeros((4, 4), dtype=np.float32)
|
|
cov = np.zeros((4, 4), dtype=np.uint8)
|
|
src = np.ones((4, 4, 3), dtype=np.float32) * 50
|
|
weight = np.ones((4, 4), dtype=np.float32)
|
|
blend_kernel_numpy(blend, wsum, cov, src, weight)
|
|
assert np.allclose(blend, 50)
|
|
assert np.all(cov == 1)
|
|
|
|
|
|
def test_numpy_backend_blend_tile():
|
|
backend = get_blend_backend("numpy")
|
|
blend = np.zeros((20, 20, 3), dtype=np.float32)
|
|
wsum = np.zeros((20, 20), dtype=np.float32)
|
|
cov = np.zeros((20, 20), dtype=np.uint8)
|
|
src = np.full((8, 8, 3), 40, dtype=np.uint8)
|
|
weight = tile_weight_map(8, 8, margin=2)
|
|
backend.blend_tile_inplace(blend, wsum, cov, src, weight, 2, 3)
|
|
assert float(blend[4, 5, 0]) > 0
|
|
assert coverage_ratio(cov) > 0
|
|
|
|
|
|
@pytest.mark.skipif(not numba_blend_available(), reason="numba blend unavailable")
|
|
def test_numba_numpy_backend_parity():
|
|
numpy_b = get_blend_backend("numpy")
|
|
numba_b = get_blend_backend("numba")
|
|
shape = (16, 16)
|
|
src = np.random.randint(0, 255, (*shape, 3), dtype=np.uint8)
|
|
weight = tile_weight_map(*shape, margin=3)
|
|
|
|
def run(backend):
|
|
blend = np.zeros((*shape, 3), dtype=np.float32)
|
|
wsum = np.zeros(shape, dtype=np.float32)
|
|
cov = np.zeros(shape, dtype=np.uint8)
|
|
backend.blend_tile_inplace(blend, wsum, cov, src, weight, 0, 0)
|
|
return blend, cov
|
|
|
|
b0, c0 = run(numpy_b)
|
|
b1, c1 = run(numba_b)
|
|
assert np.allclose(b0, b1, atol=1e-2)
|
|
assert np.array_equal(c0, c1)
|
|
|
|
|
|
def test_resolve_blend_mode_default(monkeypatch):
|
|
monkeypatch.delenv("ORISYS_STITCH_BLEND", raising=False)
|
|
monkeypatch.delenv("ORISYS_STITCH_BLEND_BACKEND", raising=False)
|
|
mode, fell = resolve_blend_mode({})
|
|
assert mode == "none"
|
|
assert fell is False
|
|
|
|
|
|
def test_validate_box_grid():
|
|
assert validate_box_grid([(0, 0, 1, 1)] * 9, 3, 3)
|
|
assert not validate_box_grid([(0, 0, 1, 1)] * 8, 3, 3)
|
|
|
|
|
|
def test_compute_final_crop_rect():
|
|
cov = np.zeros((20, 20), dtype=np.uint8)
|
|
cov[4:16, 4:16] = 1
|
|
rect = compute_final_crop_rect(cov)
|
|
assert rect is not None
|
|
y0, y1, x0, x1 = rect
|
|
assert y1 > y0 and x1 > x0
|
|
|
|
|
|
def _mini_config():
|
|
return {
|
|
"min_area_frac": 0.01,
|
|
"max_area_frac": 0.05,
|
|
"max_aspect_ratio": 50,
|
|
"index": [0, 1, 2, 3, 4, 5, 6, 7, 8],
|
|
"margin": 10,
|
|
"nominal_ov_pos": 100,
|
|
"n": [3, 3],
|
|
"row_tol": 0.6,
|
|
"box_size": [32, 32],
|
|
"blend": "numpy",
|
|
"n_boxes_target": 9,
|
|
}
|
|
|
|
|
|
def test_stitcher_status_and_rt_with_synthetic_tiles():
|
|
stitcher = Stitcher(isstitch=True, config_stitcher=_mini_config(), verbose=False)
|
|
# Preload tiles as if extract_tiles already ran.
|
|
tiles = [_solid_tile(30 + i * 10) for i in range(9)]
|
|
stitcher.tile_list = tiles
|
|
stitcher.units = [(0, 0, t) for t in tiles]
|
|
stitcher.boxes = [(i * 40, 0, i * 40 + 32, 32) for i in range(9)]
|
|
|
|
status = stitcher.status
|
|
assert isinstance(status, StitchStatus)
|
|
assert status.blend_mode == "numpy"
|
|
assert status.expected_boxes == 9
|
|
|
|
# Feature matching on flat solid tiles usually fails → None is OK.
|
|
out = stitcher.stitch_rt_optimized()
|
|
assert out is None or (isinstance(out, np.ndarray) and out.ndim == 3)
|
|
|
|
|
|
def test_compound_eye_processor_calls_stitcher():
|
|
class FakeStitcher:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def get_stitch_unit(self, frame):
|
|
self.calls.append("get")
|
|
|
|
def extract_tiles(self):
|
|
self.calls.append("extract")
|
|
|
|
def stitch_rt_optimized(self):
|
|
self.calls.append("stitch")
|
|
return np.zeros((8, 8, 3), dtype=np.uint8)
|
|
|
|
fake = FakeStitcher()
|
|
proc = CompoundEyeFrameProcessor(fake)
|
|
out = proc.process(np.zeros((64, 64, 3), dtype=np.uint8))
|
|
assert fake.calls == ["get", "extract", "stitch"]
|
|
assert out.shape == (8, 8, 3)
|
|
|
|
|
|
def test_unknown_backend_raises():
|
|
with pytest.raises(ValueError):
|
|
get_blend_backend("torch")
|
|
|
|
|
|
def _six_by_three_frame():
|
|
"""A 1920x1080 frame showing 6x3 tiles: one column more than the 15 detected."""
|
|
img = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
|
for cx in (160, 470, 790, 1110, 1430, 1750):
|
|
for cy in (180, 520, 860):
|
|
img[cy - 120:cy + 120, cx - 120:cx + 120] = 200
|
|
return img
|
|
|
|
|
|
def _detect(img, **kwargs):
|
|
from orisys.stitch.boxes import detect_nonblack_regions
|
|
|
|
return detect_nonblack_regions(
|
|
img, box_size=(250, 250), min_area_frac=0.01, max_area_frac=0.05,
|
|
max_aspect_ratio=50, save_preview_path="", **kwargs,
|
|
)
|
|
|
|
|
|
def test_an_extra_tile_column_is_counted_without_an_exclusion():
|
|
n_boxes, _boxes = _detect(_six_by_three_frame())
|
|
assert n_boxes == 18
|
|
|
|
|
|
@pytest.mark.parametrize("exclude_x", [(330, 0), (0, 330)])
|
|
def test_exclude_x_masks_out_the_extra_column(exclude_x):
|
|
n_boxes, boxes = _detect(_six_by_three_frame(), exclude_x=exclude_x)
|
|
assert n_boxes == 15
|
|
centres = sorted({(x0 + x1) // 2 for x0, _y0, x1, _y1 in boxes})
|
|
assert (160 not in centres) == (exclude_x[0] > 0)
|
|
assert (1750 not in centres) == (exclude_x[1] > 0)
|
|
|
|
|
|
def test_the_shipped_right_margin_keeps_an_aligned_module():
|
|
"""[0, 240] drops an extra edge column but not the outer column of a 5x3 module."""
|
|
img = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
|
for cx in (324, 642, 960, 1278, 1596):
|
|
for cy in (180, 520, 860):
|
|
img[cy - 120:cy + 120, cx - 120:cx + 120] = 200
|
|
assert _detect(img, exclude_x=(0, 240))[0] == 15
|
|
assert _detect(_six_by_three_frame(), exclude_x=(0, 240))[0] == 15
|