add rect masking
This commit is contained in:
@@ -150,6 +150,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"MarkDisconnected",
|
"MarkDisconnected",
|
||||||
"MaskShift",
|
"MaskShift",
|
||||||
"MaskNoisify",
|
"MaskNoisify",
|
||||||
|
"RectangularMask",
|
||||||
],
|
],
|
||||||
"Grains": [
|
"Grains": [
|
||||||
"GrainDistanceTransform",
|
"GrainDistanceTransform",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import numpy as np
|
|||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_overlay
|
from backend.execution_context import emit_overlay
|
||||||
from backend.data_types import DataField, datafield_to_uint8, encode_preview
|
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")
|
@register_node(display_name="Crop / Resize")
|
||||||
@@ -19,6 +20,7 @@ class CropResizeField:
|
|||||||
"target_width": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
|
"target_width": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
|
||||||
"target_height": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
|
"target_height": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
|
||||||
"interpolation": (["bilinear", "nearest", "bicubic"],),
|
"interpolation": (["bilinear", "nearest", "bicubic"],),
|
||||||
|
"square": ("BOOLEAN", {"default": False}),
|
||||||
},
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
"corner_a": ("COORD",),
|
"corner_a": ("COORD",),
|
||||||
@@ -34,7 +36,8 @@ class CropResizeField:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Crop a DATA_FIELD with a draggable rectangle defined by two corners, then optionally resize it. "
|
"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; "
|
"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")
|
KEYWORDS = ("resize", "rescale", "trim", "bilinear", "bicubic", "nearest")
|
||||||
@@ -49,6 +52,7 @@ class CropResizeField:
|
|||||||
target_width: int,
|
target_width: int,
|
||||||
target_height: int,
|
target_height: int,
|
||||||
interpolation: str,
|
interpolation: str,
|
||||||
|
square: bool = False,
|
||||||
corner_a=None,
|
corner_a=None,
|
||||||
corner_b=None,
|
corner_b=None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
@@ -62,21 +66,29 @@ class CropResizeField:
|
|||||||
x2 = float(np.clip(x2, 0.0, 1.0))
|
x2 = float(np.clip(x2, 0.0, 1.0))
|
||||||
y2 = float(np.clip(y2, 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)
|
left = min(x1, x2)
|
||||||
right = max(x1, x2)
|
right = max(x1, x2)
|
||||||
top = min(y1, y2)
|
top = min(y1, y2)
|
||||||
bottom = max(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:
|
if right <= left or bottom <= top:
|
||||||
raise ValueError("Crop region must have non-zero width and height.")
|
raise ValueError("Crop region must have non-zero width and height.")
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,20 @@ def bool_to_mask(binary: np.ndarray) -> np.ndarray:
|
|||||||
return np.asarray(binary, dtype=np.uint8) * 255
|
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(
|
def normalize_mask(
|
||||||
mask: np.ndarray | None, shape: tuple[int, int],
|
mask: np.ndarray | None, shape: tuple[int, int],
|
||||||
) -> np.ndarray | None:
|
) -> np.ndarray | None:
|
||||||
|
|||||||
113
backend/nodes/mask_rectangular.py
Normal file
113
backend/nodes/mask_rectangular.py
Normal 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,)
|
||||||
@@ -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_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) |
|
| target_height | INT | 0 | Output pixel height after resampling (0 = keep cropped height) |
|
||||||
| interpolation | dropdown | bilinear | Resampling interpolation: bilinear, nearest, or bicubic |
|
| 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
|
## Notes
|
||||||
|
|
||||||
- The crop region must have non-zero width and height; an error is raised otherwise.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
34
docs/nodes/Rectangular Mask.md
Normal file
34
docs/nodes/Rectangular Mask.md
Normal 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.
|
||||||
@@ -13,15 +13,40 @@ interface CropBoxOverlayProps {
|
|||||||
bLocked: boolean;
|
bLocked: boolean;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
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({
|
export default function CropBoxOverlay({
|
||||||
image, x1, y1, x2, y2,
|
image, x1, y1, x2, y2,
|
||||||
aLocked, bLocked,
|
aLocked, bLocked,
|
||||||
nodeId, onWidgetChange,
|
nodeId, onWidgetChange,
|
||||||
|
square = false,
|
||||||
|
xreal = 1,
|
||||||
|
yreal = 1,
|
||||||
}: CropBoxOverlayProps) {
|
}: CropBoxOverlayProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [dragging, setDragging] = useState<string | null>(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>) => {
|
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
||||||
return pointerToFraction(e, containerRef.current!);
|
return pointerToFraction(e, containerRef.current!);
|
||||||
@@ -30,28 +55,66 @@ export default function CropBoxOverlay({
|
|||||||
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
||||||
if (point === 'p1' && aLocked) return;
|
if (point === 'p1' && aLocked) return;
|
||||||
if (point === 'p2' && bLocked) return;
|
if (point === 'p2' && bLocked) return;
|
||||||
|
if (point === 'rect' && (aLocked || bLocked)) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
(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);
|
setDragging(point);
|
||||||
}, [aLocked, bLocked]);
|
}, [aLocked, bLocked, getCoords, x1, y1, x2, y2]);
|
||||||
|
|
||||||
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
||||||
if (!dragging || !containerRef.current) return;
|
if (!dragging || !containerRef.current) return;
|
||||||
const { fx, fy } = getCoords(e);
|
const { fx, fy } = getCoords(e);
|
||||||
const vx = parseFloat(fx.toFixed(3));
|
|
||||||
const vy = parseFloat(fy.toFixed(3));
|
if (dragging === 'rect') {
|
||||||
if (dragging === 'p1') {
|
const start = panStartRef.current;
|
||||||
onWidgetChange(nodeId, 'x1', vx);
|
if (!start) return;
|
||||||
onWidgetChange(nodeId, 'y1', vy);
|
const left = Math.min(start.x1, start.x2);
|
||||||
} else {
|
const right = Math.max(start.x1, start.x2);
|
||||||
onWidgetChange(nodeId, 'x2', vx);
|
const top = Math.min(start.y1, start.y2);
|
||||||
onWidgetChange(nodeId, 'y2', vy);
|
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(() => {
|
const onPointerUp = useCallback(() => {
|
||||||
setDragging(null);
|
setDragging(null);
|
||||||
|
panStartRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const left = Math.min(x1, x2);
|
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-dim" style={{ left: 0, top: `${bottom * 100}%`, width: '100%', height: `${(1 - bottom) * 100}%` }} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="crop-rect"
|
className={`crop-rect ${aLocked || bLocked ? 'crop-rect-locked' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
left: `${left * 100}%`,
|
left: `${left * 100}%`,
|
||||||
top: `${top * 100}%`,
|
top: `${top * 100}%`,
|
||||||
width: `${(right - left) * 100}%`,
|
width: `${(right - left) * 100}%`,
|
||||||
height: `${(bottom - top) * 100}%`,
|
height: `${(bottom - top) * 100}%`,
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={onPointerDown('rect')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1503,6 +1503,9 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
bLocked={!!data.overlay!.b_locked}
|
bLocked={!!data.overlay!.b_locked}
|
||||||
nodeId={id}
|
nodeId={id}
|
||||||
onWidgetChange={ctx!.onWidgetChange}
|
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' ? (
|
) : data.overlay!.kind === 'cursor_points' ? (
|
||||||
<CrossSectionOverlay
|
<CrossSectionOverlay
|
||||||
|
|||||||
@@ -1860,7 +1860,15 @@ html, body, #root {
|
|||||||
border: 2px solid var(--accent-lighter);
|
border: 2px solid var(--accent-lighter);
|
||||||
box-shadow: inset 0 0 0 1px var(--crop-inset);
|
box-shadow: inset 0 0 0 1px var(--crop-inset);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
pointer-events: none;
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-rect:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-rect-locked {
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crop-marker {
|
.crop-marker {
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export interface OverlayData {
|
|||||||
cy?: number;
|
cy?: number;
|
||||||
ex?: number;
|
ex?: number;
|
||||||
ey?: number;
|
ey?: number;
|
||||||
|
xreal?: number;
|
||||||
|
yreal?: number;
|
||||||
|
square?: boolean;
|
||||||
a_locked?: boolean;
|
a_locked?: boolean;
|
||||||
b_locked?: boolean;
|
b_locked?: boolean;
|
||||||
section_title?: string;
|
section_title?: string;
|
||||||
|
|||||||
@@ -55,3 +55,73 @@ def test_crop_resize_field():
|
|||||||
raise AssertionError("Expected invalid crop bounds to raise ValueError")
|
raise AssertionError("Expected invalid crop bounds to raise ValueError")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
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