From edfdead4c11148dea82e7750cb27d6d7b10bc253 Mon Sep 17 00:00:00 2001 From: matei jordache Date: Tue, 24 Mar 2026 23:19:41 -0700 Subject: [PATCH] add rotate, crop and slider widget --- backend/execution.py | 5 +- backend/nodes/__init__.py | 2 +- backend/nodes/io.py | 40 ++++++ backend/nodes/modify.py | 247 ++++++++++++++++++++++++++++++++ frontend/src/App.jsx | 1 + frontend/src/CropBoxOverlay.jsx | 88 ++++++++++++ frontend/src/CustomNode.jsx | 77 +++++++++- frontend/src/styles.css | 79 ++++++++++ tests/test_nodes.py | 186 ++++++++++++++++++++++++ 9 files changed, 717 insertions(+), 8 deletions(-) create mode 100644 backend/nodes/modify.py create mode 100644 frontend/src/CropBoxOverlay.jsx diff --git a/backend/execution.py b/backend/execution.py index 740f4c6..a0e01b6 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -181,6 +181,7 @@ class ExecutionEngine: """Wire up broadcast callbacks on display node classes.""" from backend.nodes.display import PreviewImage, PrintTable, View3D from backend.nodes.analysis import CrossSection, LineCursors + from backend.nodes.modify import CropResizeField from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine from backend.nodes.io import SaveImage, LoadFile @@ -193,6 +194,7 @@ class ExecutionEngine: PrintTable._broadcast_table_fn = on_table CrossSection._broadcast_overlay_fn = on_overlay LineCursors._broadcast_overlay_fn = on_overlay + CropResizeField._broadcast_overlay_fn = on_overlay LoadFile._broadcast_warning_fn = on_warning SaveImage._broadcast_warning_fn = on_warning @@ -200,9 +202,10 @@ class ExecutionEngine: """Inform display nodes of their current node_id for WS tagging.""" from backend.nodes.display import PreviewImage, PrintTable, View3D from backend.nodes.analysis import CrossSection, LineCursors + from backend.nodes.modify import CropResizeField from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine from backend.nodes.io import LoadFile, SaveImage - if cls in (PreviewImage, PrintTable, View3D, CrossSection, LineCursors, + if cls in (PreviewImage, PrintTable, View3D, CrossSection, LineCursors, CropResizeField, ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, LoadFile, SaveImage): cls._current_node_id = node_id diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py index cae9bb6..2515999 100644 --- a/backend/nodes/__init__.py +++ b/backend/nodes/__init__.py @@ -1,2 +1,2 @@ # Import all node modules to trigger @register_node decorators. -from . import io, filters, level, analysis, grains, mask, display +from . import io, filters, modify, level, analysis, grains, mask, display diff --git a/backend/nodes/io.py b/backend/nodes/io.py index 3c9de0d..f34fe52 100644 --- a/backend/nodes/io.py +++ b/backend/nodes/io.py @@ -395,6 +395,46 @@ class Coordinate: return ((float(x), float(y)),) +# --------------------------------------------------------------------------- +# RangeSlider +# --------------------------------------------------------------------------- + +@register_node(display_name="Float Slider") +class RangeSlider: + """Interactive float control node with min/max bounds and a slider value.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "min_value": ("FLOAT", {"default": 0.0, "step": 0.01}), + "max_value": ("FLOAT", {"default": 1.0, "step": 0.01}), + "value": ("FLOAT", { + "default": 0.5, + "step": 0.01, + "slider": True, + "min_widget": "min_value", + "max_widget": "max_value", + }), + } + } + + RETURN_TYPES = ("FLOAT",) + RETURN_NAMES = ("value",) + FUNCTION = "process" + CATEGORY = "io" + DESCRIPTION = ( + "Interactive float slider. Set min and max bounds, then drag the slider to output a FLOAT value." + ) + + def process(self, min_value: float, max_value: float, value: float) -> tuple: + lo = min(float(min_value), float(max_value)) + hi = max(float(min_value), float(max_value)) + if hi == lo: + return (lo,) + return (float(np.clip(float(value), lo, hi)),) + + # --------------------------------------------------------------------------- # SaveImage # --------------------------------------------------------------------------- diff --git a/backend/nodes/modify.py b/backend/nodes/modify.py new file mode 100644 index 0000000..cebc1aa --- /dev/null +++ b/backend/nodes/modify.py @@ -0,0 +1,247 @@ +""" +Modify nodes — geometric transforms for DATA_FIELDs. +""" + +from __future__ import annotations + +import numpy as np + +from backend.node_registry import register_node +from backend.data_types import DataField, datafield_to_uint8, encode_preview + + +# --------------------------------------------------------------------------- +# CropResizeField +# --------------------------------------------------------------------------- + +@register_node(display_name="Crop / Resize") +class CropResizeField: + @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}), + "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"],), + }, + "optional": { + "corner_a": ("COORD",), + "corner_b": ("COORD",), + }, + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("field",) + FUNCTION = "process" + CATEGORY = "modify" + 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." + ) + + _broadcast_overlay_fn = None + _current_node_id: str = "" + + def process( + self, + field: DataField, + x1: float, + y1: float, + x2: float, + y2: float, + target_width: int, + target_height: int, + interpolation: str, + 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)) + + if CropResizeField._broadcast_overlay_fn is not None: + CropResizeField._broadcast_overlay_fn( + CropResizeField._current_node_id, + { + "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 right <= left or bottom <= top: + raise ValueError("Crop region must have non-zero width and height.") + + 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 - 1) + py0 = min(max(py0, 0), field.yres - 1) + px1 = min(max(px1, px0 + 1), field.xres) + py1 = min(max(py1, py0 + 1), field.yres) + + cropped = field.data[py0:py1, px0:px1].copy() + cropped_field = field.replace( + data=cropped, + xreal=(px1 - px0) * field.dx, + yreal=(py1 - py0) * field.dy, + xoff=field.xoff + px0 * field.dx, + yoff=field.yoff + py0 * field.dy, + ) + + target_width, target_height = self._resolve_target_shape( + cropped_field.xres, cropped_field.yres, target_width, target_height, + ) + if (target_width, target_height) == (cropped_field.xres, cropped_field.yres): + return (cropped_field,) + + from PIL import Image + + resample_map = { + "nearest": Image.Resampling.NEAREST, + "bilinear": Image.Resampling.BILINEAR, + "bicubic": Image.Resampling.BICUBIC, + } + if interpolation not in resample_map: + raise ValueError(f"Unknown interpolation mode: {interpolation}") + + resized = Image.fromarray(cropped_field.data.astype(np.float32)).resize( + (target_width, target_height), + resample=resample_map[interpolation], + ) + resized_data = np.asarray(resized, dtype=np.float64) + return (cropped_field.replace(data=resized_data),) + + @staticmethod + def _resolve_target_shape( + width: int, + height: int, + target_width: int, + target_height: int, + ) -> tuple[int, int]: + target_width = int(target_width) + target_height = int(target_height) + + if target_width < 0 or target_height < 0: + raise ValueError("Target dimensions must be zero or positive.") + + if target_width == 0 and target_height == 0: + return (width, height) + if target_width == 0: + target_width = max(1, int(round(width * (target_height / height)))) + if target_height == 0: + target_height = max(1, int(round(height * (target_width / width)))) + + return (max(1, target_width), max(1, target_height)) + + +# --------------------------------------------------------------------------- +# RotateField +# --------------------------------------------------------------------------- + +@register_node(display_name="Rotate") +class RotateField: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "angle": ("FLOAT", {"default": 90.0, "min": -360.0, "max": 360.0, "step": 1.0}), + "interpolation": (["bilinear", "nearest", "bicubic"],), + "expand_canvas": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("field",) + FUNCTION = "process" + CATEGORY = "modify" + DESCRIPTION = ( + "Rotate a DATA_FIELD counterclockwise by an angle in degrees. " + "Optionally expand the canvas to keep the full rotated field while preserving the field center." + ) + + def process( + self, + field: DataField, + angle: float, + interpolation: str, + expand_canvas: bool, + ) -> tuple: + angle = float(angle) + order_map = { + "nearest": 0, + "bilinear": 1, + "bicubic": 3, + } + if interpolation not in order_map: + raise ValueError(f"Unknown interpolation mode: {interpolation}") + + normalized_angle = angle % 360.0 + snapped_quarters = int(round(normalized_angle / 90.0)) % 4 + snapped_angle = snapped_quarters * 90.0 + is_right_angle = abs(normalized_angle - snapped_angle) < 1e-9 + + if is_right_angle and expand_canvas: + rotated = np.rot90(field.data, k=snapped_quarters).copy() + elif abs(normalized_angle) < 1e-9: + rotated = field.data.copy() + else: + from scipy.ndimage import rotate as nd_rotate + + rotated = nd_rotate( + field.data, + angle=angle, + reshape=bool(expand_canvas), + order=order_map[interpolation], + mode="nearest", + prefilter=order_map[interpolation] > 1, + ) + + new_xreal, new_yreal = self._rotated_extents(field, angle, expand_canvas) + center_x = field.xoff + field.xreal / 2.0 + center_y = field.yoff + field.yreal / 2.0 + + result = field.replace( + data=np.asarray(rotated, dtype=np.float64), + xreal=new_xreal, + yreal=new_yreal, + xoff=center_x - new_xreal / 2.0, + yoff=center_y - new_yreal / 2.0, + ) + return (result,) + + @staticmethod + def _rotated_extents(field: DataField, angle: float, expand_canvas: bool) -> tuple[float, float]: + if not expand_canvas: + return (field.xreal, field.yreal) + + theta = np.deg2rad(angle) + cos_t = abs(float(np.cos(theta))) + sin_t = abs(float(np.sin(theta))) + new_xreal = field.xreal * cos_t + field.yreal * sin_t + new_yreal = field.xreal * sin_t + field.yreal * cos_t + return (new_xreal, new_yreal) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 26c5cff..2880ba6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -26,6 +26,7 @@ const TYPE_COLORS = { LINE: '#ffbe5c', TABLE: '#35e2fd', COORD: '#e91ed1', + FLOAT: '#7dd3fc', }; const NODE_TYPES = { custom: CustomNode }; diff --git a/frontend/src/CropBoxOverlay.jsx b/frontend/src/CropBoxOverlay.jsx new file mode 100644 index 0000000..858703b --- /dev/null +++ b/frontend/src/CropBoxOverlay.jsx @@ -0,0 +1,88 @@ +import React, { useRef, useState, useCallback } from 'react'; + +export default function CropBoxOverlay({ + image, x1, y1, x2, y2, + aLocked, bLocked, + nodeId, onWidgetChange, +}) { + const containerRef = useRef(null); + const [dragging, setDragging] = useState(null); + + const getCoords = useCallback((e) => { + const rect = containerRef.current.getBoundingClientRect(); + return { + fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)), + fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)), + }; + }, []); + + const onPointerDown = useCallback((point) => (e) => { + if (point === 'p1' && aLocked) return; + if (point === 'p2' && bLocked) return; + e.stopPropagation(); + e.preventDefault(); + e.target.setPointerCapture(e.pointerId); + setDragging(point); + }, [aLocked, bLocked]); + + const onPointerMove = useCallback((e) => { + 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); + } + }, [dragging, getCoords, nodeId, onWidgetChange]); + + const onPointerUp = useCallback(() => { + setDragging(null); + }, []); + + const left = Math.min(x1, x2); + const right = Math.max(x1, x2); + const top = Math.min(y1, y2); + const bottom = Math.max(y1, y2); + + return ( +
+ crop source + +
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index 97ee57a..e2571b1 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -4,10 +4,12 @@ import LinePlotOverlay from './LinePlotOverlay'; const SurfaceView = lazy(() => import('./SurfaceView')); const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay')); +const CropBoxOverlay = lazy(() => import('./CropBoxOverlay')); // ── Constants ───────────────────────────────────────────────────────── const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']); +const SOCKET_WIDGET_TYPES = new Set(['FLOAT']); const TYPE_COLORS = { DATA_FIELD: '#3a7abf', @@ -15,11 +17,13 @@ const TYPE_COLORS = { LINE: '#ff9800', TABLE: '#fdd835', COORD: '#e91e63', + FLOAT: '#7dd3fc', }; const CAT_COLORS = { io: '#37474f', filters: '#1a237e', + modify: '#0f766e', level: '#1b5e20', analysis: '#4a148c', grains: '#bf360c', @@ -189,7 +193,7 @@ function CustomNode({ id, data }) { } else if (opts?.hidden) { hiddenWidgets.add(name); } else { - widgets.push({ name, type, opts: opts || {} }); + widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null }); } } @@ -214,7 +218,7 @@ function CustomNode({ id, data }) { ); for (const [name, spec] of Object.entries(optional)) { - const [type] = Array.isArray(spec) ? spec : [spec]; + const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; if (isProgressive && DATA_TYPES.has(type)) { // Progressive: show this slot only if it's the first or the previous is connected const match = name.match(/^field_(\d+)$/); @@ -226,7 +230,13 @@ function CustomNode({ id, data }) { continue; } } - dataInputs.push({ name, type }); + if (opts?.hidden) { + hiddenWidgets.add(name); + } else if (DATA_TYPES.has(type)) { + dataInputs.push({ name, type }); + } else { + widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null }); + } } const outputs = def.output.map((type, i) => ({ @@ -291,11 +301,21 @@ function CustomNode({ id, data }) { {/* Widget rows */} {widgets.map((w) => ( -
+
+ {w.socketType && ( + + )} @@ -347,7 +367,7 @@ function CustomNode({ id, data }) { {/* Interactive cross-section overlay */} {data.overlay && hiddenWidgets.has('x1') && ( - + Loading...
}> {data.overlay.kind === 'line_plot' ? ( + ) : data.overlay.kind === 'crop_box' ? ( + ) : ( + +
+ onChange(nodeId, name, parseFloat(e.target.value))} + /> + {clampedVal.toFixed(4)} +
+ + ); + } + return ( <> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 597f4d6..a98fc90 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -196,6 +196,11 @@ html, body, #root { display: flex; align-items: center; gap: 6px; + position: relative; +} + +.widget-row-socket { + padding-left: 20px; } .widget-row label { @@ -222,6 +227,28 @@ html, body, #root { accent-color: #3a7abf; } +.slider-control { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +.slider-input { + flex: 1; + min-width: 0; + accent-color: #7dd3fc; +} + +.slider-value { + font-family: "SF Mono", "Fira Code", monospace; + font-size: 10px; + color: #cbd5e1; + min-width: 52px; + text-align: right; +} + .widget-row input:focus, .widget-row select:focus { outline: none; @@ -402,6 +429,58 @@ html, body, #root { cursor: default; } +.crop-overlay { + position: relative; + user-select: none; + touch-action: none; + overflow: hidden; +} + +.crop-image { + width: 100%; + display: block; +} + +.crop-dim { + position: absolute; + background: rgba(2, 6, 23, 0.58); + pointer-events: none; +} + +.crop-rect { + position: absolute; + border: 2px solid #7dd3fc; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22); + background: transparent; + pointer-events: none; +} + +.crop-marker { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: #7dd3fc; + border: 2px solid #fff; + transform: translate(-50%, -50%); + cursor: grab; + box-shadow: 0 0 4px rgba(0,0,0,0.6); + z-index: 1; +} + +.crop-marker:active:not(.crop-marker-locked) { + cursor: grabbing; + background: #bae6fd; + transform: translate(-50%, -50%) scale(1.15); +} + +.crop-marker-locked { + background: #e91e63; + border-color: #e91e63; + cursor: default; + opacity: 0.9; +} + /* ── 3D surface view ──────────────────────────────────────────────── */ .surface-view-container { width: 100%; diff --git a/tests/test_nodes.py b/tests/test_nodes.py index e22f062..a0d1ce1 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -64,6 +64,165 @@ def test_median_filter(): print(" PASS\n") +def test_crop_resize_field(): + print("=== Test: CropResizeField ===") + from backend.nodes.modify import CropResizeField + node = CropResizeField() + + data = np.arange(32, dtype=np.float64).reshape(4, 8) + field = DataField( + data=data, + xreal=8.0, + yreal=4.0, + xoff=10.0, + yoff=20.0, + si_unit_xy="nm", + si_unit_z="nm", + ) + + overlays = [] + CropResizeField._broadcast_overlay_fn = lambda nid, data: overlays.append(data) + CropResizeField._current_node_id = "test" + + cropped, = node.process( + field, + x1=0.25, + y1=0.25, + x2=0.75, + y2=1.0, + target_width=0, + target_height=0, + interpolation="bilinear", + ) + assert cropped.data.shape == (3, 4) + assert np.array_equal(cropped.data, data[1:4, 2:6]) + assert cropped.xreal == 4.0 + assert cropped.yreal == 3.0 + assert cropped.xoff == 12.0 + assert cropped.yoff == 21.0 + assert cropped.si_unit_xy == field.si_unit_xy + assert cropped.si_unit_z == field.si_unit_z + assert len(overlays) == 1 + assert overlays[0]["kind"] == "crop_box" + assert overlays[0]["image"].startswith("data:image/png;base64,") + assert overlays[0]["a_locked"] is False + assert overlays[0]["b_locked"] is False + + resized, = node.process( + field, + x1=0.0, + y1=0.0, + x2=1.0, + y2=1.0, + target_width=8, + target_height=0, + interpolation="bilinear", + corner_a=(0.25, 0.25), + corner_b=(0.75, 1.0), + ) + assert resized.data.shape == (6, 8) + assert resized.xreal == cropped.xreal + assert resized.yreal == cropped.yreal + assert resized.xoff == cropped.xoff + assert resized.yoff == cropped.yoff + assert resized.domain == field.domain + assert overlays[-1]["a_locked"] is True + assert overlays[-1]["b_locked"] is True + + reversed_crop, = node.process( + field, + x1=0.75, + y1=1.0, + x2=0.25, + y2=0.25, + target_width=0, + target_height=0, + interpolation="nearest", + ) + assert np.array_equal(reversed_crop.data, cropped.data) + + try: + node.process( + field, + x1=0.9, + y1=0.0, + x2=0.9, + y2=1.0, + target_width=0, + target_height=0, + interpolation="nearest", + ) + raise AssertionError("Expected invalid crop bounds to raise ValueError") + except ValueError: + pass + + CropResizeField._broadcast_overlay_fn = None + + print(" PASS\n") + + +def test_rotate_field(): + print("=== Test: RotateField ===") + from backend.nodes.modify import RotateField + node = RotateField() + + data = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float64) + field = DataField( + data=data, + xreal=6.0, + yreal=4.0, + xoff=10.0, + yoff=20.0, + si_unit_xy="nm", + si_unit_z="nm", + ) + + rotated_90, = node.process( + field, + angle=90.0, + interpolation="nearest", + expand_canvas=True, + ) + assert np.array_equal(rotated_90.data, np.rot90(data)) + assert rotated_90.data.shape == (3, 2) + assert rotated_90.xreal == 4.0 + assert rotated_90.yreal == 6.0 + assert rotated_90.xoff == 11.0 + assert rotated_90.yoff == 19.0 + assert rotated_90.si_unit_xy == field.si_unit_xy + assert rotated_90.si_unit_z == field.si_unit_z + + rotated_180, = node.process( + field, + angle=180.0, + interpolation="nearest", + expand_canvas=False, + ) + assert np.array_equal(rotated_180.data, np.rot90(data, 2)) + assert rotated_180.data.shape == data.shape + assert rotated_180.xreal == field.xreal + assert rotated_180.yreal == field.yreal + assert rotated_180.xoff == field.xoff + assert rotated_180.yoff == field.yoff + + rotated_45, = node.process( + field, + angle=45.0, + interpolation="bilinear", + expand_canvas=True, + ) + expected_xreal = abs(field.xreal * np.cos(np.deg2rad(45.0))) + abs(field.yreal * np.sin(np.deg2rad(45.0))) + expected_yreal = abs(field.xreal * np.sin(np.deg2rad(45.0))) + abs(field.yreal * np.cos(np.deg2rad(45.0))) + assert rotated_45.data.shape[0] > field.data.shape[0] + assert rotated_45.data.shape[1] > field.data.shape[1] + assert np.isclose(rotated_45.xreal, expected_xreal) + assert np.isclose(rotated_45.yreal, expected_yreal) + assert np.isclose(rotated_45.xoff + rotated_45.xreal / 2.0, field.xoff + field.xreal / 2.0) + assert np.isclose(rotated_45.yoff + rotated_45.yreal / 2.0, field.yoff + field.yreal / 2.0) + + print(" PASS\n") + + def test_edge_detect(): print("=== Test: EdgeDetect ===") from backend.nodes.filters import EdgeDetect @@ -883,6 +1042,30 @@ def test_coordinate(): print(" PASS\n") +def test_range_slider(): + print("=== Test: RangeSlider ===") + from backend.nodes.io import RangeSlider + + node = RangeSlider() + + result = node.process(min_value=0.0, max_value=10.0, value=3.25) + assert result == (3.25,) + + # Clamp above max + result_high = node.process(min_value=0.0, max_value=10.0, value=12.0) + assert result_high == (10.0,) + + # Reversed bounds should still work + result_reversed = node.process(min_value=5.0, max_value=-1.0, value=4.0) + assert result_reversed == (4.0,) + + # Equal bounds collapse to a fixed value + result_fixed = node.process(min_value=2.5, max_value=2.5, value=99.0) + assert result_fixed == (2.5,) + + print(" PASS\n") + + # ========================================================================= # Analysis — LineCursors # ========================================================================= @@ -1137,6 +1320,8 @@ if __name__ == "__main__": # Filters test_gaussian_filter() test_median_filter() + test_crop_resize_field() + test_rotate_field() test_edge_detect() test_fft_filter_1d() test_fft_filter_2d() @@ -1173,6 +1358,7 @@ if __name__ == "__main__": test_list_channels() test_load_demo() test_coordinate() + test_range_slider() test_save_image() # Display