add rect masking
This commit is contained in:
@@ -55,3 +55,73 @@ def test_crop_resize_field():
|
||||
raise AssertionError("Expected invalid crop bounds to raise ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_crop_resize_square_constraint():
|
||||
"""With square=True, the crop region is coerced to a physical square."""
|
||||
from backend.nodes.crop_resize import CropResizeField
|
||||
node = CropResizeField()
|
||||
|
||||
# Square-pixel field (xreal == yreal): fraction-square == physical-square.
|
||||
data = np.arange(64 * 64, dtype=np.float64).reshape(64, 64)
|
||||
field = DataField(
|
||||
data=data, xreal=1e-6, yreal=1e-6,
|
||||
si_unit_xy="m", si_unit_z="m",
|
||||
)
|
||||
|
||||
# Requested region: 0.1..0.9 (wide, 80%) x 0.1..0.5 (tall, 40%).
|
||||
# Physical-square clamp shrinks the longer (x) side to match y → 40% x 40%.
|
||||
cropped, = node.process(
|
||||
field, x1=0.1, y1=0.1, x2=0.9, y2=0.5,
|
||||
target_width=0, target_height=0, interpolation="bilinear", square=True,
|
||||
)
|
||||
assert cropped.data.shape[0] == cropped.data.shape[1], (
|
||||
f"expected square crop, got {cropped.data.shape}"
|
||||
)
|
||||
assert np.isclose(cropped.xreal, cropped.yreal)
|
||||
|
||||
|
||||
def test_crop_resize_square_physical_aspect():
|
||||
"""Square on a non-square-pixel field gives a physical square (not pixel square)."""
|
||||
from backend.nodes.crop_resize import CropResizeField
|
||||
node = CropResizeField()
|
||||
|
||||
# 64x64 pixels but xreal = 2*yreal → x is physically twice as wide per fraction.
|
||||
data = np.arange(64 * 64, dtype=np.float64).reshape(64, 64)
|
||||
field = DataField(
|
||||
data=data, xreal=2e-6, yreal=1e-6,
|
||||
si_unit_xy="m", si_unit_z="m",
|
||||
)
|
||||
|
||||
# Requested region: 0.1..0.9 x 0.1..0.9 (both 80% fraction).
|
||||
# Physical widths: 0.8 * 2e-6 = 1.6e-6 vs 0.8 * 1e-6 = 0.8e-6.
|
||||
# Shorter is y (0.8e-6). Clamp x to 0.4 fraction → 0.1..0.5.
|
||||
cropped, = node.process(
|
||||
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9,
|
||||
target_width=0, target_height=0, interpolation="bilinear", square=True,
|
||||
)
|
||||
assert np.isclose(cropped.xreal, cropped.yreal, rtol=0.05), (
|
||||
f"expected physical square, got xreal={cropped.xreal} yreal={cropped.yreal}"
|
||||
)
|
||||
|
||||
|
||||
def test_crop_resize_overlay_includes_aspect():
|
||||
"""Overlay payload should include xreal/yreal so the frontend can snap to square."""
|
||||
from backend.nodes.crop_resize import CropResizeField
|
||||
node = CropResizeField()
|
||||
|
||||
data = np.ones((16, 16), dtype=np.float64)
|
||||
field = DataField(
|
||||
data=data, xreal=3e-6, yreal=2e-6,
|
||||
si_unit_xy="m", si_unit_z="m",
|
||||
)
|
||||
|
||||
overlays = []
|
||||
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
|
||||
node.process(
|
||||
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9,
|
||||
target_width=0, target_height=0, interpolation="bilinear",
|
||||
)
|
||||
|
||||
assert overlays[0]["xreal"] == 3e-6
|
||||
assert overlays[0]["yreal"] == 2e-6
|
||||
|
||||
143
tests/node_tests/mask_rectangular.py
Normal file
143
tests/node_tests/mask_rectangular.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import numpy as np
|
||||
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_mask_rectangular_basic():
|
||||
from backend.nodes.mask_rectangular import RectangularMask
|
||||
|
||||
node = RectangularMask()
|
||||
field = make_field(data=np.zeros((32, 32)))
|
||||
|
||||
mask, = node.process(
|
||||
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=False,
|
||||
)
|
||||
assert mask.dtype == np.uint8
|
||||
assert mask.shape == (32, 32)
|
||||
# Corners defined by 0.25..0.75 on a 32-wide field → pixels 8..24
|
||||
assert mask[0, 0] == 0
|
||||
assert mask[16, 16] == 255
|
||||
assert np.all(mask[8:24, 8:24] == 255)
|
||||
assert np.all(mask[:8, :] == 0)
|
||||
assert np.all(mask[24:, :] == 0)
|
||||
|
||||
|
||||
def test_mask_rectangular_invert():
|
||||
from backend.nodes.mask_rectangular import RectangularMask
|
||||
|
||||
node = RectangularMask()
|
||||
field = make_field(data=np.zeros((32, 32)))
|
||||
|
||||
mask, = node.process(
|
||||
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=True,
|
||||
)
|
||||
assert mask[0, 0] == 255
|
||||
assert mask[16, 16] == 0
|
||||
|
||||
|
||||
def test_mask_rectangular_corner_inputs_override_widgets():
|
||||
from backend.nodes.mask_rectangular import RectangularMask
|
||||
|
||||
node = RectangularMask()
|
||||
field = make_field(data=np.zeros((32, 32)))
|
||||
|
||||
mask, = node.process(
|
||||
field, x1=0.0, y1=0.0, x2=1.0, y2=1.0, square=False, invert=False,
|
||||
corner_a=(0.5, 0.5), corner_b=(1.0, 1.0),
|
||||
)
|
||||
# Corner override → rectangle is the lower-right quadrant (pixels 16..32)
|
||||
assert mask[0, 0] == 0
|
||||
assert mask[24, 24] == 255
|
||||
assert np.all(mask[16:32, 16:32] == 255)
|
||||
assert np.all(mask[:16, :16] == 0)
|
||||
|
||||
|
||||
def test_mask_rectangular_reversed_corners():
|
||||
"""x2 < x1 or y2 < y1 should still produce the same rectangle."""
|
||||
from backend.nodes.mask_rectangular import RectangularMask
|
||||
|
||||
node = RectangularMask()
|
||||
field = make_field(data=np.zeros((32, 32)))
|
||||
|
||||
forward, = node.process(
|
||||
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=False,
|
||||
)
|
||||
reversed_, = node.process(
|
||||
field, x1=0.75, y1=0.75, x2=0.25, y2=0.25, square=False, invert=False,
|
||||
)
|
||||
assert np.array_equal(forward, reversed_)
|
||||
|
||||
|
||||
def test_mask_rectangular_clamps_out_of_bounds():
|
||||
from backend.nodes.mask_rectangular import RectangularMask
|
||||
|
||||
node = RectangularMask()
|
||||
field = make_field(data=np.zeros((16, 16)))
|
||||
|
||||
mask, = node.process(
|
||||
field, x1=-0.5, y1=-0.5, x2=2.0, y2=2.0, square=False, invert=False,
|
||||
)
|
||||
assert mask.shape == (16, 16)
|
||||
assert np.all(mask == 255)
|
||||
|
||||
|
||||
def test_mask_rectangular_square_shrinks_longer_side():
|
||||
"""With square=True on a square field, the longer side collapses to the shorter."""
|
||||
from backend.nodes.mask_rectangular import RectangularMask
|
||||
|
||||
node = RectangularMask()
|
||||
field = make_field(data=np.zeros((64, 64)))
|
||||
|
||||
# Non-square fractional region: 0.1..0.9 in x (80% wide), 0.1..0.5 in y (40% tall).
|
||||
# With square=True the shorter dimension (y, 40%) wins; x shrinks to match.
|
||||
mask, = node.process(
|
||||
field, x1=0.1, y1=0.1, x2=0.9, y2=0.5, square=True, invert=False,
|
||||
)
|
||||
ys, xs = np.where(mask == 255)
|
||||
assert ys.size > 0
|
||||
width = xs.max() - xs.min() + 1
|
||||
height = ys.max() - ys.min() + 1
|
||||
assert width == height, f"expected square, got {width}x{height}"
|
||||
|
||||
|
||||
def test_mask_rectangular_square_physical_aspect():
|
||||
"""On a field with non-square physical aspect, 'square' is physical, not pixel."""
|
||||
from backend.nodes.mask_rectangular import RectangularMask
|
||||
|
||||
node = RectangularMask()
|
||||
# xreal = 2e-6, yreal = 1e-6 — so a physical square covers twice the x-fraction of the y-fraction.
|
||||
field = make_field(data=np.zeros((64, 64)), xreal=2e-6, yreal=1e-6)
|
||||
|
||||
# Start with a region 0.1..0.9 in x (0.8 frac, 1.6e-6 phys) and 0.1..0.9 in y (0.8 frac, 0.8e-6 phys).
|
||||
# Shorter physical side = 0.8e-6. In x that's 0.4 fraction → shrink x to 0.1..0.5.
|
||||
mask, = node.process(
|
||||
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9, square=True, invert=False,
|
||||
)
|
||||
ys, xs = np.where(mask == 255)
|
||||
assert ys.size > 0
|
||||
# The selected region in pixels should be roughly 0.1..0.5 in x (pixels ~6..32)
|
||||
# and 0.1..0.9 in y (pixels ~6..58)
|
||||
assert xs.max() < 40
|
||||
assert ys.max() > 50
|
||||
|
||||
|
||||
def test_mask_rectangular_emits_overlay():
|
||||
from backend.execution_context import active_node, execution_callbacks
|
||||
from backend.nodes.mask_rectangular import RectangularMask
|
||||
|
||||
node = RectangularMask()
|
||||
field = make_field(data=np.zeros((32, 32)))
|
||||
|
||||
overlays = []
|
||||
with execution_callbacks(
|
||||
overlay=lambda nid, d: overlays.append(d),
|
||||
), active_node("test"):
|
||||
node.process(
|
||||
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9, square=False, invert=False,
|
||||
)
|
||||
|
||||
assert len(overlays) == 1
|
||||
assert overlays[0]["kind"] == "crop_box"
|
||||
assert overlays[0]["section_title"] == "Preview"
|
||||
assert overlays[0]["x1"] == 0.1
|
||||
assert overlays[0]["image"].startswith("data:image/png;base64,")
|
||||
Reference in New Issue
Block a user