add rotate, crop and slider widget
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
247
backend/nodes/modify.py
Normal file
247
backend/nodes/modify.py
Normal 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)
|
||||
@@ -26,6 +26,7 @@ const TYPE_COLORS = {
|
||||
LINE: '#ffbe5c',
|
||||
TABLE: '#35e2fd',
|
||||
COORD: '#e91ed1',
|
||||
FLOAT: '#7dd3fc',
|
||||
};
|
||||
|
||||
const NODE_TYPES = { custom: CustomNode };
|
||||
|
||||
88
frontend/src/CropBoxOverlay.jsx
Normal file
88
frontend/src/CropBoxOverlay.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<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
|
||||
widget={w}
|
||||
nodeId={id}
|
||||
value={data.widgetValues[w.name]}
|
||||
widgetValues={data.widgetValues}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
/>
|
||||
@@ -347,7 +367,7 @@ function CustomNode({ id, data }) {
|
||||
|
||||
{/* Interactive cross-section overlay */}
|
||||
{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>}>
|
||||
{data.overlay.kind === 'line_plot' ? (
|
||||
<LinePlotOverlay
|
||||
@@ -359,6 +379,18 @@ function CustomNode({ id, data }) {
|
||||
nodeId={id}
|
||||
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
|
||||
image={data.overlay.image}
|
||||
@@ -403,7 +435,7 @@ function CustomNode({ id, data }) {
|
||||
|
||||
// ── Widget renderer ───────────────────────────────────────────────────
|
||||
|
||||
function WidgetControl({ widget, nodeId, value, onChange, openFileBrowser }) {
|
||||
function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser }) {
|
||||
const { name, type, opts } = widget;
|
||||
const val = value ?? opts?.default ?? '';
|
||||
|
||||
@@ -449,6 +481,39 @@ function WidgetControl({ widget, nodeId, value, onChange, openFileBrowser }) {
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user