add rotate, crop and slider widget

This commit is contained in:
2026-03-24 23:19:41 -07:00
parent 6959c62c8f
commit edfdead4c1
9 changed files with 717 additions and 8 deletions

View File

@@ -181,6 +181,7 @@ class ExecutionEngine:
"""Wire up broadcast callbacks on display node classes.""" """Wire up broadcast callbacks on display node classes."""
from backend.nodes.display import PreviewImage, PrintTable, View3D from backend.nodes.display import PreviewImage, PrintTable, View3D
from backend.nodes.analysis import CrossSection, LineCursors 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.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine
from backend.nodes.io import SaveImage, LoadFile from backend.nodes.io import SaveImage, LoadFile
@@ -193,6 +194,7 @@ class ExecutionEngine:
PrintTable._broadcast_table_fn = on_table PrintTable._broadcast_table_fn = on_table
CrossSection._broadcast_overlay_fn = on_overlay CrossSection._broadcast_overlay_fn = on_overlay
LineCursors._broadcast_overlay_fn = on_overlay LineCursors._broadcast_overlay_fn = on_overlay
CropResizeField._broadcast_overlay_fn = on_overlay
LoadFile._broadcast_warning_fn = on_warning LoadFile._broadcast_warning_fn = on_warning
SaveImage._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.""" """Inform display nodes of their current node_id for WS tagging."""
from backend.nodes.display import PreviewImage, PrintTable, View3D from backend.nodes.display import PreviewImage, PrintTable, View3D
from backend.nodes.analysis import CrossSection, LineCursors 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.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine
from backend.nodes.io import LoadFile, SaveImage 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, ThresholdMask, MaskMorphology, MaskInvert, MaskCombine,
LoadFile, SaveImage): LoadFile, SaveImage):
cls._current_node_id = node_id cls._current_node_id = node_id

View File

@@ -1,2 +1,2 @@
# Import all node modules to trigger @register_node decorators. # 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

View File

@@ -395,6 +395,46 @@ class Coordinate:
return ((float(x), float(y)),) 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 # SaveImage
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

247
backend/nodes/modify.py Normal file
View File

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

View File

@@ -26,6 +26,7 @@ const TYPE_COLORS = {
LINE: '#ffbe5c', LINE: '#ffbe5c',
TABLE: '#35e2fd', TABLE: '#35e2fd',
COORD: '#e91ed1', COORD: '#e91ed1',
FLOAT: '#7dd3fc',
}; };
const NODE_TYPES = { custom: CustomNode }; const NODE_TYPES = { custom: CustomNode };

View File

@@ -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 (
<div
ref={containerRef}
className="nodrag nowheel crop-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="crop source" draggable={false} className="crop-image" />
<div className="crop-dim" style={{ left: 0, top: 0, width: '100%', height: `${top * 100}%` }} />
<div className="crop-dim" style={{ left: 0, top: `${top * 100}%`, width: `${left * 100}%`, height: `${(bottom - top) * 100}%` }} />
<div className="crop-dim" style={{ left: `${right * 100}%`, top: `${top * 100}%`, width: `${(1 - right) * 100}%`, height: `${(bottom - top) * 100}%` }} />
<div className="crop-dim" style={{ left: 0, top: `${bottom * 100}%`, width: '100%', height: `${(1 - bottom) * 100}%` }} />
<div
className="crop-rect"
style={{
left: `${left * 100}%`,
top: `${top * 100}%`,
width: `${(right - left) * 100}%`,
height: `${(bottom - top) * 100}%`,
}}
/>
<div
className={`crop-marker ${aLocked ? 'crop-marker-locked' : ''}`}
style={{ left: `${x1 * 100}%`, top: `${y1 * 100}%` }}
onPointerDown={onPointerDown('p1')}
/>
<div
className={`crop-marker ${bLocked ? 'crop-marker-locked' : ''}`}
style={{ left: `${x2 * 100}%`, top: `${y2 * 100}%` }}
onPointerDown={onPointerDown('p2')}
/>
</div>
);
}

View File

@@ -4,10 +4,12 @@ import LinePlotOverlay from './LinePlotOverlay';
const SurfaceView = lazy(() => import('./SurfaceView')); const SurfaceView = lazy(() => import('./SurfaceView'));
const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay')); const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay'));
const CropBoxOverlay = lazy(() => import('./CropBoxOverlay'));
// ── Constants ───────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────
const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']); const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']);
const SOCKET_WIDGET_TYPES = new Set(['FLOAT']);
const TYPE_COLORS = { const TYPE_COLORS = {
DATA_FIELD: '#3a7abf', DATA_FIELD: '#3a7abf',
@@ -15,11 +17,13 @@ const TYPE_COLORS = {
LINE: '#ff9800', LINE: '#ff9800',
TABLE: '#fdd835', TABLE: '#fdd835',
COORD: '#e91e63', COORD: '#e91e63',
FLOAT: '#7dd3fc',
}; };
const CAT_COLORS = { const CAT_COLORS = {
io: '#37474f', io: '#37474f',
filters: '#1a237e', filters: '#1a237e',
modify: '#0f766e',
level: '#1b5e20', level: '#1b5e20',
analysis: '#4a148c', analysis: '#4a148c',
grains: '#bf360c', grains: '#bf360c',
@@ -189,7 +193,7 @@ function CustomNode({ id, data }) {
} else if (opts?.hidden) { } else if (opts?.hidden) {
hiddenWidgets.add(name); hiddenWidgets.add(name);
} else { } 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)) { 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)) { if (isProgressive && DATA_TYPES.has(type)) {
// Progressive: show this slot only if it's the first or the previous is connected // Progressive: show this slot only if it's the first or the previous is connected
const match = name.match(/^field_(\d+)$/); const match = name.match(/^field_(\d+)$/);
@@ -226,7 +230,13 @@ function CustomNode({ id, data }) {
continue; 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) => ({ const outputs = def.output.map((type, i) => ({
@@ -291,11 +301,21 @@ function CustomNode({ id, data }) {
{/* Widget rows */} {/* Widget rows */}
{widgets.map((w) => ( {widgets.map((w) => (
<div className="widget-row" key={w.name}> <div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
{w.socketType && (
<Handle
type="target"
position={Position.Left}
id={`input::${w.name}::${w.socketType}`}
className="typed-handle"
style={{ background: TYPE_COLORS[w.socketType] || '#999' }}
/>
)}
<WidgetControl <WidgetControl
widget={w} widget={w}
nodeId={id} nodeId={id}
value={data.widgetValues[w.name]} value={data.widgetValues[w.name]}
widgetValues={data.widgetValues}
onChange={ctx.onWidgetChange} onChange={ctx.onWidgetChange}
openFileBrowser={ctx.openFileBrowser} openFileBrowser={ctx.openFileBrowser}
/> />
@@ -347,7 +367,7 @@ function CustomNode({ id, data }) {
{/* Interactive cross-section overlay */} {/* Interactive cross-section overlay */}
{data.overlay && hiddenWidgets.has('x1') && ( {data.overlay && hiddenWidgets.has('x1') && (
<CollapsibleSection title="Cross Section" defaultOpen={true}> <CollapsibleSection title={data.overlay.kind === 'crop_box' ? 'Crop' : 'Cross Section'} defaultOpen={true}>
<Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading...</div>}> <Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading...</div>}>
{data.overlay.kind === 'line_plot' ? ( {data.overlay.kind === 'line_plot' ? (
<LinePlotOverlay <LinePlotOverlay
@@ -359,6 +379,18 @@ function CustomNode({ id, data }) {
nodeId={id} nodeId={id}
onWidgetChange={ctx.onWidgetChange} onWidgetChange={ctx.onWidgetChange}
/> />
) : data.overlay.kind === 'crop_box' ? (
<CropBoxOverlay
image={data.overlay.image}
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
y1={data.overlay.a_locked ? data.overlay.y1 : (data.widgetValues.y1 ?? data.overlay.y1)}
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
y2={data.overlay.b_locked ? data.overlay.y2 : (data.widgetValues.y2 ?? data.overlay.y2)}
aLocked={data.overlay.a_locked}
bLocked={data.overlay.b_locked}
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
) : ( ) : (
<CrossSectionOverlay <CrossSectionOverlay
image={data.overlay.image} image={data.overlay.image}
@@ -403,7 +435,7 @@ function CustomNode({ id, data }) {
// ── Widget renderer ─────────────────────────────────────────────────── // ── Widget renderer ───────────────────────────────────────────────────
function WidgetControl({ widget, nodeId, value, onChange, openFileBrowser }) { function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser }) {
const { name, type, opts } = widget; const { name, type, opts } = widget;
const val = value ?? opts?.default ?? ''; const val = value ?? opts?.default ?? '';
@@ -449,6 +481,39 @@ function WidgetControl({ widget, nodeId, value, onChange, openFileBrowser }) {
} }
if (type === 'FLOAT') { if (type === 'FLOAT') {
if (opts?.slider) {
const rawMin = opts?.min_widget ? widgetValues?.[opts.min_widget] : opts?.min;
const rawMax = opts?.max_widget ? widgetValues?.[opts.max_widget] : opts?.max;
const parsedMin = Number(rawMin);
const parsedMax = Number(rawMax);
let sliderMin = Number.isFinite(parsedMin) ? parsedMin : 0;
let sliderMax = Number.isFinite(parsedMax) ? parsedMax : 1;
if (sliderMax < sliderMin) [sliderMin, sliderMax] = [sliderMax, sliderMin];
const step = opts?.step ?? 0.01;
const numericVal = Number(val);
const clampedVal = Number.isFinite(numericVal)
? Math.min(sliderMax, Math.max(sliderMin, numericVal))
: sliderMin;
return (
<>
<label>{name}</label>
<div className="slider-control">
<input
className="nodrag slider-input"
type="range"
min={sliderMin}
max={sliderMax}
step={step}
value={clampedVal}
onChange={(e) => onChange(nodeId, name, parseFloat(e.target.value))}
/>
<span className="slider-value">{clampedVal.toFixed(4)}</span>
</div>
</>
);
}
return ( return (
<> <>
<label>{name}</label> <label>{name}</label>

View File

@@ -196,6 +196,11 @@ html, body, #root {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
position: relative;
}
.widget-row-socket {
padding-left: 20px;
} }
.widget-row label { .widget-row label {
@@ -222,6 +227,28 @@ html, body, #root {
accent-color: #3a7abf; 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 input:focus,
.widget-row select:focus { .widget-row select:focus {
outline: none; outline: none;
@@ -402,6 +429,58 @@ html, body, #root {
cursor: default; 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 ──────────────────────────────────────────────── */ /* ── 3D surface view ──────────────────────────────────────────────── */
.surface-view-container { .surface-view-container {
width: 100%; width: 100%;

View File

@@ -64,6 +64,165 @@ def test_median_filter():
print(" PASS\n") 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(): def test_edge_detect():
print("=== Test: EdgeDetect ===") print("=== Test: EdgeDetect ===")
from backend.nodes.filters import EdgeDetect from backend.nodes.filters import EdgeDetect
@@ -883,6 +1042,30 @@ def test_coordinate():
print(" PASS\n") 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 # Analysis — LineCursors
# ========================================================================= # =========================================================================
@@ -1137,6 +1320,8 @@ if __name__ == "__main__":
# Filters # Filters
test_gaussian_filter() test_gaussian_filter()
test_median_filter() test_median_filter()
test_crop_resize_field()
test_rotate_field()
test_edge_detect() test_edge_detect()
test_fft_filter_1d() test_fft_filter_1d()
test_fft_filter_2d() test_fft_filter_2d()
@@ -1173,6 +1358,7 @@ if __name__ == "__main__":
test_list_channels() test_list_channels()
test_load_demo() test_load_demo()
test_coordinate() test_coordinate()
test_range_slider()
test_save_image() test_save_image()
# Display # Display