add rect masking

This commit is contained in:
2026-04-15 23:58:34 -07:00
parent 349142f0e6
commit 31422e76db
12 changed files with 491 additions and 24 deletions

View File

@@ -150,6 +150,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
"MarkDisconnected",
"MaskShift",
"MaskNoisify",
"RectangularMask",
],
"Grains": [
"GrainDistanceTransform",

View File

@@ -3,6 +3,7 @@ import numpy as np
from backend.node_registry import register_node
from backend.execution_context import emit_overlay
from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.nodes.helpers import coerce_physical_square
@register_node(display_name="Crop / Resize")
@@ -19,6 +20,7 @@ class CropResizeField:
"target_width": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"target_height": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"interpolation": (["bilinear", "nearest", "bicubic"],),
"square": ("BOOLEAN", {"default": False}),
},
"optional": {
"corner_a": ("COORD",),
@@ -34,7 +36,8 @@ class CropResizeField:
DESCRIPTION = (
"Crop a DATA_FIELD with a draggable rectangle defined by two corners, then optionally resize it. "
"Incoming COORD inputs can lock either corner. Cropping updates physical extents and offsets; "
"resizing preserves the cropped physical size."
"resizing preserves the cropped physical size. Enable 'square' to constrain the crop region to a "
"physical square (longer side shrinks to match shorter)."
)
KEYWORDS = ("resize", "rescale", "trim", "bilinear", "bicubic", "nearest")
@@ -49,6 +52,7 @@ class CropResizeField:
target_width: int,
target_height: int,
interpolation: str,
square: bool = False,
corner_a=None,
corner_b=None,
) -> tuple:
@@ -62,21 +66,29 @@ class CropResizeField:
x2 = float(np.clip(x2, 0.0, 1.0))
y2 = float(np.clip(y2, 0.0, 1.0))
emit_overlay({
"kind": "crop_box",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
left = min(x1, x2)
right = max(x1, x2)
top = min(y1, y2)
bottom = max(y1, y2)
if square:
left, top, right, bottom = coerce_physical_square(
left, top, right, bottom, field.xreal, field.yreal,
)
emit_overlay({
"kind": "crop_box",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": left,
"y1": top,
"x2": right,
"y2": bottom,
"xreal": float(field.xreal),
"yreal": float(field.yreal),
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
if right <= left or bottom <= top:
raise ValueError("Crop region must have non-zero width and height.")

View File

@@ -319,6 +319,20 @@ def bool_to_mask(binary: np.ndarray) -> np.ndarray:
return np.asarray(binary, dtype=np.uint8) * 255
def coerce_physical_square(
left: float, top: float, right: float, bottom: float,
xreal: float, yreal: float,
) -> tuple[float, float, float, float]:
"""Shrink the longer physical side so the rectangle is a physical square,
anchored at (left, top)."""
side_phys = min((right - left) * xreal, (bottom - top) * yreal)
if xreal > 0:
right = left + side_phys / xreal
if yreal > 0:
bottom = top + side_phys / yreal
return left, top, right, bottom
def normalize_mask(
mask: np.ndarray | None, shape: tuple[int, int],
) -> np.ndarray | None:

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import numpy as np
from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
from backend.node_registry import register_node
from backend.nodes.helpers import bool_to_mask, coerce_physical_square
@register_node(display_name="Rectangular Mask")
class RectangularMask:
_CUSTOM_PREVIEW = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"x1": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y1": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"x2": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y2": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"square": ("BOOLEAN", {"default": False}),
"invert": ("BOOLEAN", {"default": False}),
},
"optional": {
"corner_a": ("COORD",),
"corner_b": ("COORD",),
},
}
OUTPUTS = (
('IMAGE', 'mask'),
)
FUNCTION = "process"
DESCRIPTION = (
"Create a binary mask covering a rectangular region of a DATA_FIELD, "
"defined by two draggable corners on the preview. Useful for selecting "
"a region of interest without cropping the image. When 'square' is on, "
"the mask is coerced to a physical square (the longer side shrinks to "
"match the shorter, anchored at the top-left corner). When 'invert' is "
"on, the mask covers everything outside the rectangle instead. "
"Incoming COORD inputs can lock either corner."
)
KEYWORDS = ("roi", "region", "rectangle", "square", "box", "selection", "crop mask")
def process(
self,
field: DataField,
x1: float,
y1: float,
x2: float,
y2: float,
square: bool,
invert: bool,
corner_a=None,
corner_b=None,
) -> tuple:
if corner_a is not None:
x1, y1 = float(corner_a[0]), float(corner_a[1])
if corner_b is not None:
x2, y2 = float(corner_b[0]), float(corner_b[1])
x1 = float(np.clip(x1, 0.0, 1.0))
y1 = float(np.clip(y1, 0.0, 1.0))
x2 = float(np.clip(x2, 0.0, 1.0))
y2 = float(np.clip(y2, 0.0, 1.0))
left = min(x1, x2)
right = max(x1, x2)
top = min(y1, y2)
bottom = max(y1, y2)
if square:
left, top, right, bottom = coerce_physical_square(
left, top, right, bottom, field.xreal, field.yreal,
)
emit_overlay({
"kind": "crop_box",
"section_title": "Preview",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": left,
"y1": top,
"x2": right,
"y2": bottom,
"xreal": float(field.xreal),
"yreal": float(field.yreal),
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
px0 = int(np.floor(left * field.xres))
py0 = int(np.floor(top * field.yres))
px1 = int(np.ceil(right * field.xres))
py1 = int(np.ceil(bottom * field.yres))
px0 = min(max(px0, 0), field.xres)
py0 = min(max(py0, 0), field.yres)
px1 = min(max(px1, px0), field.xres)
py1 = min(max(py1, py0), field.yres)
binary = np.zeros((field.yres, field.xres), dtype=bool)
binary[py0:py1, px0:px1] = True
if invert:
binary = ~binary
mask = bool_to_mask(binary)
return (mask,)

View File

@@ -23,9 +23,11 @@ Crop a DATA_FIELD with a draggable rectangle defined by two corners, then option
| target_width | INT | 0 | Output pixel width after resampling (0 = keep cropped width) |
| target_height | INT | 0 | Output pixel height after resampling (0 = keep cropped height) |
| interpolation | dropdown | bilinear | Resampling interpolation: bilinear, nearest, or bicubic |
| square | BOOLEAN | False | If true, the crop region is constrained to a physical square (longer side shrinks to match shorter). The on-preview rectangle also snaps to square while dragging either corner. |
## Notes
- The crop region must have non-zero width and height; an error is raised otherwise.
- If only one of target_width or target_height is set, the other dimension is computed to preserve aspect ratio.
- Physical extents are scaled proportionally when resampling.
- With `square` enabled, the side length is chosen in physical units (using the field's `xreal`/`yreal`), so the cropped region looks square on the preview for fields with square pixels.

View File

@@ -0,0 +1,34 @@
# Rectangular Mask
Create a binary mask covering a rectangular region of a DATA_FIELD. Useful when you want to select a region of interest for downstream nodes (statistics, flattening, masking operators) without cropping the image.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field |
| corner_a | COORD | No | Locks corner A from an external coordinate |
| corner_b | COORD | No | Locks corner B from an external coordinate |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| mask | IMAGE | Binary mask (255 inside the rectangle, 0 outside) matching the input field's pixel resolution |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| square | BOOLEAN | False | If true, the mask is coerced to a physical square — the longer side is shrunk to match the shorter, anchored at the top-left corner |
| invert | BOOLEAN | False | If true, the mask covers everything outside the rectangle instead of inside |
## Interactive preview
The node renders the input field with a draggable rectangle. Drag corner A or B to resize; drag inside the box to move it. Incoming COORD inputs lock the corresponding corner so it can't be moved interactively.
## Notes
- The output mask has the same resolution (xres × yres) as the input field.
- Pixel boundaries are chosen to fully contain the selected rectangle (floor on the low corner, ceil on the high corner).
- With `square` enabled, the side length is chosen in physical units (using `xreal`/`yreal`), so the mask looks square on the preview for fields with square pixels. For non-square pixels it is physically square but may render as a rectangle pixel-wise.

View File

@@ -13,15 +13,40 @@ interface CropBoxOverlayProps {
bLocked: boolean;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
square?: boolean;
xreal?: number;
yreal?: number;
}
function snapPhysicalSquare(
anchorX: number, anchorY: number,
moverX: number, moverY: number,
xreal: number, yreal: number,
) {
const dx = moverX - anchorX;
const dy = moverY - anchorY;
const ax = xreal > 0 ? xreal : 1;
const ay = yreal > 0 ? yreal : 1;
const shortPhys = Math.min(Math.abs(dx) * ax, Math.abs(dy) * ay);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
return {
x: anchorX + sx * (shortPhys / ax),
y: anchorY + sy * (shortPhys / ay),
};
}
export default function CropBoxOverlay({
image, x1, y1, x2, y2,
aLocked, bLocked,
nodeId, onWidgetChange,
square = false,
xreal = 1,
yreal = 1,
}: CropBoxOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<string | null>(null);
const panStartRef = useRef<{ fx: number; fy: number; x1: number; y1: number; x2: number; y2: number } | null>(null);
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
return pointerToFraction(e, containerRef.current!);
@@ -30,28 +55,66 @@ export default function CropBoxOverlay({
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
if (point === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return;
if (point === 'rect' && (aLocked || bLocked)) return;
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
if (point === 'rect') {
const { fx, fy } = getCoords(e);
panStartRef.current = { fx, fy, x1, y1, x2, y2 };
}
setDragging(point);
}, [aLocked, bLocked]);
}, [aLocked, bLocked, getCoords, x1, y1, x2, y2]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e);
const vx = parseFloat(fx.toFixed(3));
const vy = parseFloat(fy.toFixed(3));
if (dragging === 'p1') {
onWidgetChange(nodeId, 'x1', vx);
onWidgetChange(nodeId, 'y1', vy);
} else {
onWidgetChange(nodeId, 'x2', vx);
onWidgetChange(nodeId, 'y2', vy);
if (dragging === 'rect') {
const start = panStartRef.current;
if (!start) return;
const left = Math.min(start.x1, start.x2);
const right = Math.max(start.x1, start.x2);
const top = Math.min(start.y1, start.y2);
const bottom = Math.max(start.y1, start.y2);
let dx = fx - start.fx;
let dy = fy - start.fy;
dx = Math.max(-left, Math.min(1 - right, dx));
dy = Math.max(-top, Math.min(1 - bottom, dy));
const nx1 = parseFloat((start.x1 + dx).toFixed(3));
const ny1 = parseFloat((start.y1 + dy).toFixed(3));
const nx2 = parseFloat((start.x2 + dx).toFixed(3));
const ny2 = parseFloat((start.y2 + dy).toFixed(3));
onWidgetChange(nodeId, 'x1', nx1);
onWidgetChange(nodeId, 'y1', ny1);
onWidgetChange(nodeId, 'x2', nx2);
onWidgetChange(nodeId, 'y2', ny2);
return;
}
}, [dragging, getCoords, nodeId, onWidgetChange]);
let vx = fx;
let vy = fy;
if (square) {
const anchorX = dragging === 'p2' ? x1 : x2;
const anchorY = dragging === 'p2' ? y1 : y2;
const snapped = snapPhysicalSquare(anchorX, anchorY, fx, fy, xreal, yreal);
vx = snapped.x;
vy = snapped.y;
}
const vxR = parseFloat(vx.toFixed(3));
const vyR = parseFloat(vy.toFixed(3));
if (dragging === 'p1') {
onWidgetChange(nodeId, 'x1', vxR);
onWidgetChange(nodeId, 'y1', vyR);
} else {
onWidgetChange(nodeId, 'x2', vxR);
onWidgetChange(nodeId, 'y2', vyR);
}
}, [dragging, getCoords, nodeId, onWidgetChange, square, xreal, yreal, x1, y1, x2, y2]);
const onPointerUp = useCallback(() => {
setDragging(null);
panStartRef.current = null;
}, []);
const left = Math.min(x1, x2);
@@ -75,13 +138,14 @@ export default function CropBoxOverlay({
<div className="crop-dim" style={{ left: 0, top: `${bottom * 100}%`, width: '100%', height: `${(1 - bottom) * 100}%` }} />
<div
className="crop-rect"
className={`crop-rect ${aLocked || bLocked ? 'crop-rect-locked' : ''}`}
style={{
left: `${left * 100}%`,
top: `${top * 100}%`,
width: `${(right - left) * 100}%`,
height: `${(bottom - top) * 100}%`,
}}
onPointerDown={onPointerDown('rect')}
/>
<div

View File

@@ -1503,6 +1503,9 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
bLocked={!!data.overlay!.b_locked}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
square={!!(data.widgetValues.square ?? data.overlay!.square)}
xreal={(data.overlay!.xreal ?? 1) as number}
yreal={(data.overlay!.yreal ?? 1) as number}
/>
) : data.overlay!.kind === 'cursor_points' ? (
<CrossSectionOverlay

View File

@@ -1860,7 +1860,15 @@ html, body, #root {
border: 2px solid var(--accent-lighter);
box-shadow: inset 0 0 0 1px var(--crop-inset);
background: transparent;
pointer-events: none;
cursor: grab;
}
.crop-rect:active {
cursor: grabbing;
}
.crop-rect-locked {
cursor: default;
}
.crop-marker {

View File

@@ -70,6 +70,9 @@ export interface OverlayData {
cy?: number;
ex?: number;
ey?: number;
xreal?: number;
yreal?: number;
square?: boolean;
a_locked?: boolean;
b_locked?: boolean;
section_title?: string;

View File

@@ -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

View 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,")