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."""
|
"""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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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',
|
LINE: '#ffbe5c',
|
||||||
TABLE: '#35e2fd',
|
TABLE: '#35e2fd',
|
||||||
COORD: '#e91ed1',
|
COORD: '#e91ed1',
|
||||||
|
FLOAT: '#7dd3fc',
|
||||||
};
|
};
|
||||||
|
|
||||||
const NODE_TYPES = { custom: CustomNode };
|
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 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>
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user