From 31422e76db66936d0ed1260e64546bcfa197f040 Mon Sep 17 00:00:00 2001 From: matei jordache Date: Wed, 15 Apr 2026 23:58:34 -0700 Subject: [PATCH] add rect masking --- backend/node_menu.py | 1 + backend/nodes/crop_resize.py | 36 ++++--- backend/nodes/helpers.py | 14 +++ backend/nodes/mask_rectangular.py | 113 +++++++++++++++++++++ docs/nodes/Crop-Resize.md | 2 + docs/nodes/Rectangular Mask.md | 34 +++++++ frontend/src/CropBoxOverlay.tsx | 86 +++++++++++++--- frontend/src/CustomNode.tsx | 3 + frontend/src/styles.css | 10 +- frontend/src/types.ts | 3 + tests/node_tests/crop_resize.py | 70 +++++++++++++ tests/node_tests/mask_rectangular.py | 143 +++++++++++++++++++++++++++ 12 files changed, 491 insertions(+), 24 deletions(-) create mode 100644 backend/nodes/mask_rectangular.py create mode 100644 docs/nodes/Rectangular Mask.md create mode 100644 tests/node_tests/mask_rectangular.py diff --git a/backend/node_menu.py b/backend/node_menu.py index 014703e..0a5e7da 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -150,6 +150,7 @@ MENU_LAYOUT: dict[str, list[str]] = { "MarkDisconnected", "MaskShift", "MaskNoisify", + "RectangularMask", ], "Grains": [ "GrainDistanceTransform", diff --git a/backend/nodes/crop_resize.py b/backend/nodes/crop_resize.py index f951191..4eed6be 100644 --- a/backend/nodes/crop_resize.py +++ b/backend/nodes/crop_resize.py @@ -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.") diff --git a/backend/nodes/helpers.py b/backend/nodes/helpers.py index 6271d83..dabd8a2 100644 --- a/backend/nodes/helpers.py +++ b/backend/nodes/helpers.py @@ -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: diff --git a/backend/nodes/mask_rectangular.py b/backend/nodes/mask_rectangular.py new file mode 100644 index 0000000..97206ef --- /dev/null +++ b/backend/nodes/mask_rectangular.py @@ -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,) diff --git a/docs/nodes/Crop-Resize.md b/docs/nodes/Crop-Resize.md index 987f732..8b7ffa3 100644 --- a/docs/nodes/Crop-Resize.md +++ b/docs/nodes/Crop-Resize.md @@ -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. diff --git a/docs/nodes/Rectangular Mask.md b/docs/nodes/Rectangular Mask.md new file mode 100644 index 0000000..32a5843 --- /dev/null +++ b/docs/nodes/Rectangular Mask.md @@ -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. diff --git a/frontend/src/CropBoxOverlay.tsx b/frontend/src/CropBoxOverlay.tsx index 5b4a15b..728d796 100644 --- a/frontend/src/CropBoxOverlay.tsx +++ b/frontend/src/CropBoxOverlay.tsx @@ -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(null); const [dragging, setDragging] = useState(null); + const panStartRef = useRef<{ fx: number; fy: number; x1: number; y1: number; x2: number; y2: number } | null>(null); const getCoords = useCallback((e: React.PointerEvent) => { return pointerToFraction(e, containerRef.current!); @@ -30,28 +55,66 @@ export default function CropBoxOverlay({ const onPointerDown = useCallback((point: string) => (e: React.PointerEvent) => { 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) => { 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({
) : data.overlay!.kind === 'cursor_points' ? ( 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,")