Compare commits

...

11 Commits

Author SHA1 Message Date
92ede31867 align node menu
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-16 22:43:12 -07:00
d35cdd6971 fix perspective correction 2026-04-16 22:41:56 -07:00
a4c8d2b01c clean up node menu 2026-04-16 21:37:03 -07:00
924b29757f add favorites 2026-04-16 19:13:32 -07:00
ad48a40edc fix node menu ordering 2026-04-16 01:21:14 -07:00
c7e7531206 fix multi-profile 2026-04-16 01:14:57 -07:00
2d66eaef02 work on straighten path 2026-04-16 00:52:49 -07:00
9fbd305854 add masking to stats 2026-04-16 00:06:15 -07:00
31422e76db add rect masking 2026-04-15 23:58:34 -07:00
349142f0e6 update docs and tests 2026-04-15 23:21:08 -07:00
0bf001c24b add radial profile 2026-04-15 23:01:47 -07:00
36 changed files with 2049 additions and 165 deletions

View File

@@ -150,6 +150,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
"MarkDisconnected",
"MaskShift",
"MaskNoisify",
"RectangularMask",
],
"Grains": [
"GrainDistanceTransform",

View File

@@ -3,6 +3,7 @@ import numpy as np
from backend.node_registry import register_node
from backend.execution_context import emit_overlay
from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.nodes.helpers import coerce_physical_square
@register_node(display_name="Crop / Resize")
@@ -19,6 +20,7 @@ class CropResizeField:
"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"],),
"square": ("BOOLEAN", {"default": False}),
},
"optional": {
"corner_a": ("COORD",),
@@ -34,7 +36,8 @@ class CropResizeField:
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."
"resizing preserves the cropped physical size. Enable 'square' to constrain the crop region to a "
"physical square (longer side shrinks to match shorter)."
)
KEYWORDS = ("resize", "rescale", "trim", "bilinear", "bicubic", "nearest")
@@ -49,6 +52,7 @@ class CropResizeField:
target_width: int,
target_height: int,
interpolation: str,
square: bool = False,
corner_a=None,
corner_b=None,
) -> tuple:
@@ -62,21 +66,29 @@ class CropResizeField:
x2 = float(np.clip(x2, 0.0, 1.0))
y2 = float(np.clip(y2, 0.0, 1.0))
emit_overlay({
"kind": "crop_box",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
left = min(x1, x2)
right = max(x1, x2)
top = min(y1, y2)
bottom = max(y1, y2)
if square:
left, top, right, bottom = coerce_physical_square(
left, top, right, bottom, field.xreal, field.yreal,
)
emit_overlay({
"kind": "crop_box",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": left,
"y1": top,
"x2": right,
"y2": bottom,
"xreal": float(field.xreal),
"yreal": float(field.yreal),
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
if right <= left or bottom <= top:
raise ValueError("Crop region must have non-zero width and height.")

View File

@@ -319,6 +319,20 @@ def bool_to_mask(binary: np.ndarray) -> np.ndarray:
return np.asarray(binary, dtype=np.uint8) * 255
def coerce_physical_square(
left: float, top: float, right: float, bottom: float,
xreal: float, yreal: float,
) -> tuple[float, float, float, float]:
"""Shrink the longer physical side so the rectangle is a physical square,
anchored at (left, top)."""
side_phys = min((right - left) * xreal, (bottom - top) * yreal)
if xreal > 0:
right = left + side_phys / xreal
if yreal > 0:
bottom = top + side_phys / yreal
return left, top, right, bottom
def normalize_mask(
mask: np.ndarray | None, shape: tuple[int, int],
) -> np.ndarray | None:

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import numpy as np
from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
from backend.node_registry import register_node
from backend.nodes.helpers import bool_to_mask, coerce_physical_square
@register_node(display_name="Rectangular Mask")
class RectangularMask:
_CUSTOM_PREVIEW = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"x1": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y1": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"x2": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y2": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"square": ("BOOLEAN", {"default": False}),
"invert": ("BOOLEAN", {"default": False}),
},
"optional": {
"corner_a": ("COORD",),
"corner_b": ("COORD",),
},
}
OUTPUTS = (
('IMAGE', 'mask'),
)
FUNCTION = "process"
DESCRIPTION = (
"Create a binary mask covering a rectangular region of a DATA_FIELD, "
"defined by two draggable corners on the preview. Useful for selecting "
"a region of interest without cropping the image. When 'square' is on, "
"the mask is coerced to a physical square (the longer side shrinks to "
"match the shorter, anchored at the top-left corner). When 'invert' is "
"on, the mask covers everything outside the rectangle instead. "
"Incoming COORD inputs can lock either corner."
)
KEYWORDS = ("roi", "region", "rectangle", "square", "box", "selection", "crop mask")
def process(
self,
field: DataField,
x1: float,
y1: float,
x2: float,
y2: float,
square: bool,
invert: bool,
corner_a=None,
corner_b=None,
) -> tuple:
if corner_a is not None:
x1, y1 = float(corner_a[0]), float(corner_a[1])
if corner_b is not None:
x2, y2 = float(corner_b[0]), float(corner_b[1])
x1 = float(np.clip(x1, 0.0, 1.0))
y1 = float(np.clip(y1, 0.0, 1.0))
x2 = float(np.clip(x2, 0.0, 1.0))
y2 = float(np.clip(y2, 0.0, 1.0))
left = min(x1, x2)
right = max(x1, x2)
top = min(y1, y2)
bottom = max(y1, y2)
if square:
left, top, right, bottom = coerce_physical_square(
left, top, right, bottom, field.xreal, field.yreal,
)
emit_overlay({
"kind": "crop_box",
"section_title": "Preview",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": left,
"y1": top,
"x2": right,
"y2": bottom,
"xreal": float(field.xreal),
"yreal": float(field.yreal),
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
px0 = int(np.floor(left * field.xres))
py0 = int(np.floor(top * field.yres))
px1 = int(np.ceil(right * field.xres))
py1 = int(np.ceil(bottom * field.yres))
px0 = min(max(px0, 0), field.xres)
py0 = min(max(py0, 0), field.yres)
px1 = min(max(px1, px0), field.xres)
py1 = min(max(py1, py0), field.yres)
binary = np.zeros((field.yres, field.xres), dtype=bool)
binary[py0:py1, px0:px1] = True
if invert:
binary = ~binary
mask = bool_to_mask(binary)
return (mask,)

View File

@@ -5,7 +5,23 @@ from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField, LineData
from backend.data_types import DataField, LineData, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
def _blend_fields(field_a: DataField, field_b: DataField, alpha: float) -> np.ndarray:
"""Render field A with field B overlaid at `alpha` opacity (0=A only, 1=B only)."""
a_rgb = datafield_to_uint8(field_a, field_a.colormap).astype(np.float32)
b_rgb = datafield_to_uint8(field_b, field_b.colormap).astype(np.float32)
wa = 1.0 - alpha
wb = alpha
if b_rgb.shape != a_rgb.shape:
h = min(a_rgb.shape[0], b_rgb.shape[0])
w = min(a_rgb.shape[1], b_rgb.shape[1])
canvas = a_rgb.copy()
canvas[:h, :w] = wa * a_rgb[:h, :w] + wb * b_rgb[:h, :w]
return canvas.astype(np.uint8)
return (wa * a_rgb + wb * b_rgb).astype(np.uint8)
@register_node(display_name="Multiple Profiles")
@@ -19,11 +35,12 @@ class MultipleProfiles:
"row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}),
"direction": (["horizontal", "vertical"], {"default": "horizontal"}),
"mode": (["overlay", "mean", "difference"], {"default": "overlay"}),
"blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "slider": True}),
}
}
OUTPUTS = (
('LINE_DATA', 'profile'),
('LINE', 'profile'),
)
FUNCTION = "process"
@@ -31,12 +48,14 @@ class MultipleProfiles:
"Extract and compare line profiles from two fields. "
"Row=-1 uses the center row/column. Modes: overlay returns field_a's "
"profile, mean averages both, difference subtracts b from a. "
"The preview shows field A blended with field B and highlights the "
"row or column being sampled — drag to move the line."
)
KEYWORDS = ("line profile", "compare", "overlay", "cross section")
def process(self, field_a: DataField, field_b: DataField,
row: int, direction: str, mode: str) -> tuple:
row: int, direction: str, mode: str, blend: float = 0.5) -> tuple:
a = np.asarray(field_a.data, dtype=np.float64)
b = np.asarray(field_b.data, dtype=np.float64)
@@ -49,6 +68,7 @@ class MultipleProfiles:
pa = pa[:len(pb)]
dx = field_a.dx
x_unit = field_a.si_unit_xy
line_axis_max = a.shape[0] - 1
else:
if row < 0:
row = a.shape[1] // 2
@@ -58,6 +78,7 @@ class MultipleProfiles:
pa = pa[:len(pb)]
dx = field_a.dy
x_unit = field_a.si_unit_xy
line_axis_max = a.shape[1] - 1
x_axis = np.arange(len(pa)) * dx
@@ -70,5 +91,15 @@ class MultipleProfiles:
else:
result = pa
alpha = float(np.clip(blend, 0.0, 1.0))
emit_overlay({
"kind": "multi_profile",
"section_title": "Preview",
"image": encode_preview(_blend_fields(field_a, field_b, alpha)),
"row": int(row),
"direction": direction,
"max_index": int(line_axis_max),
})
return (LineData(data=result, x_axis=x_axis, x_unit=x_unit,
y_unit=field_a.si_unit_z),)

View File

@@ -6,25 +6,34 @@ import numpy as np
from scipy.ndimage import map_coordinates
from backend.node_registry import register_node
from backend.data_types import DataField
from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
@register_node(display_name="Perspective Correction")
class PerspectiveCorrection:
_CUSTOM_PREVIEW = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"top_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"top_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"top_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"top_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"bottom_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"bottom_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"bottom_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"bottom_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
}
"top_left_x": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"top_left_y": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"top_right_x": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"top_right_y": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"bottom_left_x": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"bottom_left_y": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"bottom_right_x": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"bottom_right_y": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
},
"optional": {
"top_left": ("COORD",),
"top_right": ("COORD",),
"bottom_left": ("COORD",),
"bottom_right": ("COORD",),
},
}
OUTPUTS = (
@@ -33,9 +42,8 @@ class PerspectiveCorrection:
FUNCTION = "process"
DESCRIPTION = (
"Fix perspective distortion by specifying corner offsets. Each corner "
"can be shifted by a fractional amount (relative to image size) to "
"define the distorted quadrilateral. The image is then warped back to "
"Fix perspective distortion by dragging corner handles. Each corner "
"offset defines a distorted quadrilateral that is warped back to "
"a rectangle."
)
@@ -45,11 +53,23 @@ class PerspectiveCorrection:
top_left_x: float, top_left_y: float,
top_right_x: float, top_right_y: float,
bottom_left_x: float, bottom_left_y: float,
bottom_right_x: float, bottom_right_y: float) -> tuple:
bottom_right_x: float, bottom_right_y: float,
top_left: tuple[float, float] | None = None,
top_right: tuple[float, float] | None = None,
bottom_left: tuple[float, float] | None = None,
bottom_right: tuple[float, float] | None = None) -> tuple:
if top_left is not None:
top_left_x, top_left_y = float(top_left[0]), float(top_left[1])
if top_right is not None:
top_right_x, top_right_y = float(top_right[0]), float(top_right[1])
if bottom_left is not None:
bottom_left_x, bottom_left_y = float(bottom_left[0]), float(bottom_left[1])
if bottom_right is not None:
bottom_right_x, bottom_right_y = float(bottom_right[0]), float(bottom_right[1])
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Source corners (distorted) as fractional offsets from ideal corners
src = np.array([
[top_left_y * yres, top_left_x * xres],
[top_right_y * yres, top_right_x * xres + (xres - 1)],
@@ -57,7 +77,6 @@ class PerspectiveCorrection:
[(1 + bottom_right_y) * yres - 1, bottom_right_x * xres + (xres - 1)],
], dtype=np.float64)
# Destination corners (ideal rectangle)
dst = np.array([
[0, 0],
[0, xres - 1],
@@ -65,33 +84,54 @@ class PerspectiveCorrection:
[yres - 1, xres - 1],
], dtype=np.float64)
# Solve for perspective transform matrix (3x3)
H = _solve_perspective(src, dst)
# Apply inverse warp
yy, xx = np.mgrid[:yres, :xres]
coords = np.stack([yy.ravel(), xx.ravel(), np.ones(yres * xres)])
coords = np.stack([xx.ravel(), yy.ravel(), np.ones(yres * xres)])
src_coords = H @ coords
src_coords /= src_coords[2:3, :]
sy = src_coords[0].reshape(yres, xres)
sx = src_coords[1].reshape(yres, xres)
sx = src_coords[0].reshape(yres, xres)
sy = src_coords[1].reshape(yres, xres)
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
return (field.replace(data=result),)
corrected = field.replace(data=result)
source_rgb = datafield_to_uint8(field, field.colormap)
corrected_rgb = datafield_to_uint8(corrected, corrected.colormap)
corners = [
{"x": float(top_left_x), "y": float(top_left_y)},
{"x": float(top_right_x), "y": float(top_right_y)},
{"x": float(bottom_left_x), "y": float(bottom_left_y)},
{"x": float(bottom_right_x), "y": float(bottom_right_y)},
]
emit_overlay({
"kind": "perspective",
"section_title": "Perspective",
"image": encode_preview(source_rgb),
"corrected_image": encode_preview(corrected_rgb),
"corners": corners,
})
return (corrected,)
def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp)."""
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp).
Coordinates are (col, row) — the matrix is applied to [col, row, 1] vectors.
"""
n = len(src)
A = np.zeros((2 * n, 8))
b = np.zeros(2 * n)
for i in range(n):
dy, dx = dst[i]
sy, sx = src[i]
A[2 * i] = [dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy]
A[2 * i + 1] = [0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy]
b[2 * i] = sx
b[2 * i + 1] = sy
dr, dc = dst[i] # dest row, col
sr, sc = src[i] # src row, col
A[2 * i] = [dc, dr, 1, 0, 0, 0, -sc * dc, -sc * dr]
A[2 * i + 1] = [0, 0, 0, dc, dr, 1, -sr * dc, -sr * dr]
b[2 * i] = sc
b[2 * i + 1] = sr
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
H = np.array([[h[0], h[1], h[2]],
[h[3], h[4], h[5]],

View File

@@ -2,8 +2,14 @@ from __future__ import annotations
import numpy as np
from backend.data_types import (
DataField,
LineData,
encode_preview,
render_datafield_preview,
)
from backend.execution_context import emit_overlay
from backend.node_registry import register_node
from backend.data_types import DataField, LineData
@register_node(display_name="Radial Profile")
@@ -13,9 +19,11 @@ class RadialProfile:
return {
"required": {
"field": ("DATA_FIELD",),
"cx": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"cy": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"n_bins": ("INT", {"default": 128, "min": 4, "max": 4096, "step": 1}),
"cx": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"cy": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"ex": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"ey": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
}
}
@@ -25,21 +33,38 @@ class RadialProfile:
FUNCTION = "process"
DESCRIPTION = (
"Compute the azimuthally averaged radial profile from a centre point. "
"cx/cy give the centre as a fraction of the field width/height (0.5 = centre). "
"Output x-axis is radius in physical xy units. "
"Compute an azimuthally averaged profile around a centre point. "
"At each radius, every pixel in the full 360° ring is averaged together, "
"so the profile is direction-independent — there is no clockwise/counter-clockwise "
"traversal and no start/end point along the ring. "
"Drag the centre marker on the preview to reposition the profile, "
"or drag either end marker (both just set the outer radius) to change the extent. "
"Output x-axis is radius in physical xy units."
)
KEYWORDS = ("azimuthal average", "ring average", "circular", "isotropic")
def process(self, field: DataField, cx: float, cy: float, n_bins: int) -> tuple:
def process(
self,
field: DataField,
n_bins: int,
cx: float,
cy: float,
ex: float,
ey: float,
) -> tuple:
yres, xres = field.data.shape
# Centre in physical coordinates (matches Gwyddion: xc = cx*xreal + xoff)
cx = float(np.clip(cx, 0.0, 1.0))
cy = float(np.clip(cy, 0.0, 1.0))
ex = float(np.clip(ex, 0.0, 1.0))
ey = float(np.clip(ey, 0.0, 1.0))
xc_phys = cx * field.xreal + field.xoff
yc_phys = cy * field.yreal + field.yoff
xe_phys = ex * field.xreal + field.xoff
ye_phys = ey * field.yreal + field.yoff
# Pixel-centre physical coordinates
xs = (np.arange(xres) + 0.5) * field.dx + field.xoff
ys = (np.arange(yres) + 0.5) * field.dy + field.yoff
gx, gy = np.meshgrid(xs, ys)
@@ -47,20 +72,19 @@ class RadialProfile:
r = np.hypot(gx - xc_phys, gy - yc_phys).ravel()
values = field.data.ravel()
# Maximum radius — farthest pixel from centre
r_max = float(r.max())
if r_max == 0.0:
r_max = float(np.hypot(xe_phys - xc_phys, ye_phys - yc_phys))
if r_max <= 0.0:
r_max = max(field.dx, field.dy)
# Bin by radius — matches Gwyddion's lineres-bin approach
bin_edges = np.linspace(0.0, r_max, n_bins + 1)
mask = r <= r_max
idx = np.clip(
np.floor(n_bins * r / r_max).astype(np.intp), 0, n_bins - 1
np.floor(n_bins * r[mask] / r_max).astype(np.intp), 0, n_bins - 1
)
sums = np.zeros(n_bins)
counts = np.zeros(n_bins, dtype=np.intp)
np.add.at(sums, idx, values)
np.add.at(sums, idx, values[mask])
np.add.at(counts, idx, 1)
with np.errstate(invalid="ignore"):
@@ -68,6 +92,16 @@ class RadialProfile:
centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
emit_overlay({
"kind": "radial_profile",
"section_title": "Radial Profile",
"image": encode_preview(render_datafield_preview(field, field.colormap)),
"cx": cx,
"cy": cy,
"ex": ex,
"ey": ey,
})
return (LineData(
data=profile,
x_axis=centers,

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
from backend.nodes.helpers import mask_to_bool
@register_node(display_name="Statistics")
@@ -11,7 +12,10 @@ class Statistics:
return {
"required": {
"field": ("DATA_FIELD",),
}
},
"optional": {
"mask": ("IMAGE",),
},
}
OUTPUTS = (
@@ -21,13 +25,24 @@ class Statistics:
DESCRIPTION = (
"Compute basic surface statistics: min, max, mean, RMS roughness, median, "
"and skewness."
"and skewness. When a mask is provided, only pixels inside the mask are "
"included."
)
KEYWORDS = ("mean", "rms", "min", "max", "skewness", "kurtosis", "median", "roughness")
def process(self, field: DataField) -> tuple:
def process(self, field: DataField, mask: np.ndarray | None = None) -> tuple:
d = field.data
if mask is not None:
selector = mask_to_bool(mask)
if selector.shape != d.shape:
raise ValueError(
f"Mask shape {selector.shape} does not match field shape {d.shape}"
)
d = d[selector]
if d.size == 0:
raise ValueError("Mask selects no pixels")
mean = float(d.mean())
rms = float(np.sqrt(np.mean((d - mean) ** 2)))
skewness = float(np.mean(((d - mean) / rms) ** 3)) if rms > 0 else 0.0

View File

@@ -3,10 +3,12 @@
from __future__ import annotations
import numpy as np
from scipy.interpolate import CubicSpline
from scipy.ndimage import map_coordinates
from backend.node_registry import register_node
from backend.data_types import DataField
from backend.data_types import DataField, LineData, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
@register_node(display_name="Straighten Path")
@@ -16,8 +18,8 @@ class StraightenPath:
return {
"required": {
"field": ("DATA_FIELD",),
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75"}),
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5"}),
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75", "hidden": True}),
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5", "hidden": True}),
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
}
@@ -25,14 +27,15 @@ class StraightenPath:
OUTPUTS = (
('DATA_FIELD', 'straightened'),
('LINE', 'profile'),
)
FUNCTION = "process"
DESCRIPTION = (
"Extract a cross-section along an arbitrary curved path defined by "
"control points. Points are given as fractional coordinates (0-1). "
"The path is interpolated with cubic splines, and data is sampled "
"along it with configurable thickness. "
"control points. The path is a natural cubic spline through the "
"points. Drag the points on the preview to reshape the path; the "
"shaded band shows the sampling thickness. "
)
KEYWORDS = ("unbend", "unroll", "spline", "curved profile", "extract path")
@@ -42,36 +45,46 @@ class StraightenPath:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Parse control points
px = [float(v.strip()) * (xres - 1) for v in points_x.split(",") if v.strip()]
py = [float(v.strip()) * (yres - 1) for v in points_y.split(",") if v.strip()]
fx = [float(v.strip()) for v in points_x.split(",") if v.strip()]
fy = [float(v.strip()) for v in points_y.split(",") if v.strip()]
n_pts = min(len(fx), len(fy))
fx, fy = fx[:n_pts], fy[:n_pts]
if len(px) < 2 or len(py) < 2:
# Need at least 2 points
return (field,)
emit_overlay({
"kind": "straighten_path",
"section_title": "Path",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"points": [{"x": float(fx[i]), "y": float(fy[i])} for i in range(n_pts)],
"thickness": int(thickness),
"xres": int(xres),
"yres": int(yres),
})
n_pts = min(len(px), len(py))
px, py = px[:n_pts], py[:n_pts]
if n_pts < 2:
empty_line = LineData(
data=np.zeros(0, dtype=np.float64),
x_axis=np.zeros(0, dtype=np.float64),
x_unit=field.si_unit_xy,
y_unit=field.si_unit_z,
)
return (field, empty_line)
px = [f * (xres - 1) for f in fx]
py = [f * (yres - 1) for f in fy]
# Parameterize path and interpolate
t_ctrl = np.linspace(0, 1, n_pts)
t_sample = np.linspace(0, 1, n_samples)
# Simple cubic interpolation via numpy
if n_pts >= 4:
from numpy.polynomial.polynomial import Polynomial
cx = np.interp(t_sample, t_ctrl, px)
cy = np.interp(t_sample, t_ctrl, py)
if n_pts >= 3:
cx = CubicSpline(t_ctrl, px, bc_type="natural")(t_sample)
cy = CubicSpline(t_ctrl, py, bc_type="natural")(t_sample)
else:
cx = np.interp(t_sample, t_ctrl, px)
cy = np.interp(t_sample, t_ctrl, py)
# Sample along path with thickness
if thickness <= 1:
values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
result = values.reshape(1, -1)
else:
# Compute normals
dcx = np.gradient(cx)
dcy = np.gradient(cy)
length = np.sqrt(dcx**2 + dcy**2)
@@ -86,12 +99,22 @@ class StraightenPath:
sy = cy + off * ny
result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest')
# Physical dimensions
total_length = 0.0
for i in range(1, len(cx)):
dx_phys = (cx[i] - cx[i - 1]) * field.dx
dy_phys = (cy[i] - cy[i - 1]) * field.dy
total_length += np.sqrt(dx_phys**2 + dy_phys**2)
return (field.replace(data=result, xreal=total_length,
yreal=thickness * max(field.dx, field.dy)),)
center_values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
profile = LineData(
data=center_values,
x_axis=np.linspace(0.0, total_length, n_samples),
x_unit=field.si_unit_xy,
y_unit=field.si_unit_z,
)
straightened = field.replace(
data=result, xreal=total_length,
yreal=thickness * max(field.dx, field.dy),
)
return (straightened, profile)

View File

@@ -23,9 +23,11 @@ Crop a DATA_FIELD with a draggable rectangle defined by two corners, then option
| target_width | INT | 0 | Output pixel width after resampling (0 = keep cropped width) |
| target_height | INT | 0 | Output pixel height after resampling (0 = keep cropped height) |
| interpolation | dropdown | bilinear | Resampling interpolation: bilinear, nearest, or bicubic |
| square | BOOLEAN | False | If true, the crop region is constrained to a physical square (longer side shrinks to match shorter). The on-preview rectangle also snaps to square while dragging either corner. |
## Notes
- The crop region must have non-zero width and height; an error is raised otherwise.
- If only one of target_width or target_height is set, the other dimension is computed to preserve aspect ratio.
- Physical extents are scaled proportionally when resampling.
- With `square` enabled, the side length is chosen in physical units (using the field's `xreal`/`yreal`), so the cropped region looks square on the preview for fields with square pixels.

View File

@@ -1,6 +1,6 @@
# Multiple Profiles
Extract and compare line profiles from two fields along a chosen row or column. Supports overlay, mean, and difference modes. Equivalent to Gwyddion's multiprofile.c module.
Extract and compare line profiles from two fields along a chosen row or column. Supports overlay, mean, and difference modes.
## Inputs
@@ -13,7 +13,7 @@ Extract and compare line profiles from two fields along a chosen row or column.
| Name | Type | Description |
|------|------|-------------|
| profile | LINE_DATA | Resulting line profile |
| profile | LINE | Resulting line profile |
## Controls
@@ -22,6 +22,11 @@ Extract and compare line profiles from two fields along a chosen row or column.
| row | INT | -1 | Row (horizontal) or column (vertical) index to extract; -1 uses the centre row/column (-1-10000) |
| direction | dropdown | horizontal | Profile direction: horizontal (extract a row) or vertical (extract a column) |
| mode | dropdown | overlay | Combination mode: overlay (field_a profile only), mean (average of both), or difference (field_a minus field_b) |
| blend | FLOAT | 0.5 | Opacity of field B in the preview (0 = only A, 1 = only B). Affects the preview image only, not the extracted profile. |
## Interactive preview
The preview shows field A blended with field B and highlights the row or column being sampled. Click or drag on the image to move the line; switch between row and column extraction with the `direction` control.
## Notes

View File

@@ -1,12 +1,16 @@
# Perspective Correction
Fix perspective distortion in a DATA_FIELD via a projective (homography) transform. Each corner can be shifted by a fractional offset to map a distorted quadrilateral back to a rectangle. Equivalent to Gwyddion's `correct_perspective.c` module.
Fix perspective distortion in a DATA_FIELD via a projective (homography) transform. Each corner can be shifted by a fractional offset to map a distorted quadrilateral back to a rectangle.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field with perspective distortion |
| top_left | COORD | No | Override top-left corner offset (x, y) |
| top_right | COORD | No | Override top-right corner offset (x, y) |
| bottom_left | COORD | No | Override bottom-left corner offset (x, y) |
| bottom_right | COORD | No | Override bottom-right corner offset (x, y) |
## Outputs
@@ -14,22 +18,15 @@ Fix perspective distortion in a DATA_FIELD via a projective (homography) transfo
|------|------|-------------|
| corrected | DATA_FIELD | Perspective-corrected field |
## Controls
## Interactive preview
| Name | Type | Default | Description |
|------|------|---------|-------------|
| top_left_x | FLOAT | 0.0 | Horizontal offset of the top-left corner as a fraction of image width (-0.5-0.5) |
| top_left_y | FLOAT | 0.0 | Vertical offset of the top-left corner as a fraction of image height (-0.5-0.5) |
| top_right_x | FLOAT | 0.0 | Horizontal offset of the top-right corner as a fraction of image width (-0.5-0.5) |
| top_right_y | FLOAT | 0.0 | Vertical offset of the top-right corner as a fraction of image height (-0.5-0.5) |
| bottom_left_x | FLOAT | 0.0 | Horizontal offset of the bottom-left corner as a fraction of image width (-0.5-0.5) |
| bottom_left_y | FLOAT | 0.0 | Vertical offset of the bottom-left corner as a fraction of image height (-0.5-0.5) |
| bottom_right_x | FLOAT | 0.0 | Horizontal offset of the bottom-right corner as a fraction of image width (-0.5-0.5) |
| bottom_right_y | FLOAT | 0.0 | Vertical offset of the bottom-right corner as a fraction of image height (-0.5-0.5) |
The preview shows the source image with a draggable quadrilateral overlay. Drag any corner handle to adjust the perspective correction. Use the Source/Corrected tabs to switch between the input image (with handles) and the corrected result.
Corner positions can also be set by connecting Coordinate nodes to the optional COORD inputs, which override the handle-driven values.
## Notes
- All offsets are given as fractions of the image dimensions (0.0 = no shift, 0.1 = 10% shift). Positive x shifts right, positive y shifts down.
- The transform uses bilinear interpolation to resample pixel values at non-integer locations.
- For trapezoidal distortions (common in tilted AFM scans), typically only two corners need adjustment.
- Set all offsets to 0.0 to pass the field through unchanged.
- When all offsets are zero (default), the field passes through unchanged.

View File

@@ -1,6 +1,6 @@
# Radial Profile
Compute the azimuthally averaged radial profile from a centre point. The output x-axis is radius in physical xy units. Equivalent to gwy_data_field_angular_average used by Gwyddion's Radial Profile tool.
Compute an **azimuthally averaged** profile around a centre point on a DATA_FIELD. At each radius, every pixel in the full 360° ring around the centre is averaged together, so the profile is direction-independent — there is no clockwise/counter-clockwise traversal and no start or end point along the ring. The output is a single 1-D profile: value vs. radius.
## Inputs
@@ -18,11 +18,13 @@ Compute the azimuthally averaged radial profile from a centre point. The output
| Name | Type | Default | Description |
|------|------|---------|-------------|
| cx | FLOAT | 0.5 | Centre x position as a fraction of field width (0 = left, 1 = right) |
| cy | FLOAT | 0.5 | Centre y position as a fraction of field height (0 = top, 1 = bottom) |
| n_bins | INT | 128 | Number of radial bins (4-4096) |
## Interactive preview
The dashed circle around the centre shows the outer radius used by the profile. Pixels beyond it are not included in the averaging.
## Notes
- Pixels are assigned to radial bins by Euclidean distance; bins near the centre contain fewer pixels and may be noisier.
- Pixels are assigned to radial bins by Euclidean distance from the centre; inner bins contain fewer pixels and may be noisier.
- Physical x-axis units come from the field's si_unit_xy; uncalibrated fields produce pixel-unit radii.

View File

@@ -0,0 +1,34 @@
# Rectangular Mask
Create a binary mask covering a rectangular region of a DATA_FIELD. Useful when you want to select a region of interest for downstream nodes (statistics, flattening, masking operators) without cropping the image.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field |
| corner_a | COORD | No | Locks corner A from an external coordinate |
| corner_b | COORD | No | Locks corner B from an external coordinate |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| mask | IMAGE | Binary mask (255 inside the rectangle, 0 outside) matching the input field's pixel resolution |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| square | BOOLEAN | False | If true, the mask is coerced to a physical square — the longer side is shrunk to match the shorter, anchored at the top-left corner |
| invert | BOOLEAN | False | If true, the mask covers everything outside the rectangle instead of inside |
## Interactive preview
The node renders the input field with a draggable rectangle. Drag corner A or B to resize; drag inside the box to move it. Incoming COORD inputs lock the corresponding corner so it can't be moved interactively.
## Notes
- The output mask has the same resolution (xres × yres) as the input field.
- Pixel boundaries are chosen to fully contain the selected rectangle (floor on the low corner, ceil on the high corner).
- With `square` enabled, the side length is chosen in physical units (using `xreal`/`yreal`), so the mask looks square on the preview for fields with square pixels. For non-square pixels it is physically square but may render as a rectangle pixel-wise.

View File

@@ -7,6 +7,7 @@ Compute basic surface statistics: min, max, mean, RMS roughness, median, and ske
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to analyze |
| mask | IMAGE | No | Optional binary mask — only pixels inside the mask contribute to the statistics |
## Outputs
@@ -20,4 +21,4 @@ None.
## Notes
- None.
- When a mask is provided, it must match the field's pixel resolution. Only pixels where the mask is non-zero are included in the statistics.

View File

@@ -13,20 +13,25 @@ Extract a cross-section along an arbitrary curved path defined by control points
| Name | Type | Description |
|------|------|-------------|
| straightened | DATA_FIELD | Straightened cross-section; width = n_samples, height = thickness |
| profile | LINE | 1-pixel-wide profile sampled along the centerline of the path |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| points_x | STRING | "0.25, 0.5, 0.75" | Comma-separated fractional x-coordinates of control points (0.0-1.0) |
| points_y | STRING | "0.5, 0.3, 0.5" | Comma-separated fractional y-coordinates of control points (0.0-1.0) |
| thickness | INT | 1 | Width of the sampled strip perpendicular to the path, in pixels (1-100) |
| n_samples | INT | 256 | Number of sample points along the path (10-2048) |
## Interactive preview
The node renders the input field with the control points and a smooth curve through them. Drag any point to reshape the path. Double-click anywhere on the image to add a new point at that location. Shift-click a point to delete it (a minimum of two points is kept). The shaded band along the curve previews the sampling thickness.
The straightened result is shown in the regular preview section below.
## Notes
- Control points are specified as fractions of the image dimensions (0 = left/top edge, 1 = right/bottom edge). At least 2 points are required.
- Points are connected by linear interpolation; the path is sampled at n_samples evenly spaced positions.
- With 3 or more points, the path is a natural cubic spline (C² continuous) passing through each control point, matching the smooth curve drawn on the preview. With exactly 2 points the path is a straight line.
- When thickness > 1, samples are taken along the local normal direction at each path position, producing a 2D strip rather than a single line.
- The output xreal equals the physical path length (computed from pixel spacing), and yreal equals thickness times the pixel size.
- Bilinear interpolation (order=1) is used with nearest-edge boundary handling.

View File

@@ -2,6 +2,10 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { socketSpecAcceptsType } from './constants';
import { outputTypeCanConnectToTarget } from './connectionUtils';
import { compareMenuNodes, compareMenuCategories } from './canvasEvents';
import { useFavorites } from './favorites';
import { recordUsage, pickWeightedRandom } from './nodeUsage';
const FAVORITES_CATEGORY = 'favorites';
export default function ContextMenu({
x,
@@ -26,6 +30,7 @@ export default function ContextMenu({
selectedNodeCount?: number;
onCreateGroup?: (() => void) | null;
}) {
const favorites = useFavorites();
const [openCat, setOpenCat] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -88,13 +93,31 @@ export default function ContextMenu({
});
}
}
return Object.values(cats)
const sorted = Object.values(cats)
.map((category: any) => ({
...category,
items: [...category.items].sort(compareMenuNodes),
}))
.sort(compareMenuCategories);
}, [nodeDefs, filterDirection, filterSpec, filterType]);
const favItems: any[] = [];
const seenFav = new Set<string>();
for (const category of sorted) {
for (const item of category.items) {
if (favorites.has(item.className) && !seenFav.has(item.className)) {
seenFav.add(item.className);
favItems.push(item);
}
}
}
if (favItems.length > 0) {
return [
{ name: FAVORITES_CATEGORY, order: -Infinity, items: favItems.sort(compareMenuNodes) },
...sorted,
];
}
return sorted;
}, [nodeDefs, filterDirection, filterSpec, filterType, favorites]);
// Flat filtered list for search
const searchResults = useMemo(() => {
@@ -191,6 +214,29 @@ export default function ContextMenu({
setOpenCat(cat);
}, []);
const allNodeEntries = useMemo(() => {
const map = new Map<string, any>();
for (const category of categories) {
for (const item of category.items) {
if (!map.has(item.className)) map.set(item.className, item.def);
}
}
return map;
}, [categories]);
const handleAdd = useCallback((className: string, def: any) => {
recordUsage(className);
onAdd(className, def);
}, [onAdd]);
const handleRandomNode = useCallback(() => {
const classNames = [...allNodeEntries.keys()];
const pick = pickWeightedRandom(classNames);
if (!pick) return;
const def = allNodeEntries.get(pick);
if (def) { handleAdd(pick, def); onClose(); }
}, [allNodeEntries, handleAdd, onClose]);
if (categories.length === 0) {
return (
<div className="context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }} onClick={(e) => e.stopPropagation()}>
@@ -234,7 +280,7 @@ export default function ContextMenu({
className="context-item"
onClick={() => { onCreateGroup(); onClose(); }}
>
create group
Create Group
</div>
)}
@@ -248,7 +294,7 @@ export default function ContextMenu({
key={className}
ref={idx === selectedIndex ? selectedItemRef : null}
className={`context-item${idx === selectedIndex ? ' context-item--selected' : ''}`}
onClick={() => { onAdd(className, def); onClose(); }}
onClick={() => { handleAdd(className, def); onClose(); }}
onMouseEnter={() => setSelectedIndex(idx)}
>
{def.display_name || className}
@@ -262,13 +308,22 @@ export default function ContextMenu({
<div
key={cat}
ref={(el) => { catRowRefs.current[cat] = el; }}
className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`}
className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}${cat === FAVORITES_CATEGORY ? ' ctx-cat-favorites' : ''}`}
onMouseEnter={() => handleCatEnter(cat)}
>
<span className="ctx-cat-label">{cat}</span>
<span className="ctx-cat-label">
{cat === FAVORITES_CATEGORY ? 'Favorites' : cat}
</span>
<span className="ctx-cat-arrow"></span>
</div>
))}
<div
className="ctx-cat-item ctx-random-node"
onClick={handleRandomNode}
onMouseEnter={() => setOpenCat(null)}
>
<span className="ctx-cat-label">surprise me</span>
</div>
</div>
)}
</div>
@@ -290,7 +345,7 @@ export default function ContextMenu({
<div
key={className}
className="context-item"
onClick={() => { onAdd(className, def); onClose(); }}
onClick={() => { handleAdd(className, def); onClose(); }}
>
{def.display_name || className}
</div>

View File

@@ -13,15 +13,40 @@ interface CropBoxOverlayProps {
bLocked: boolean;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
square?: boolean;
xreal?: number;
yreal?: number;
}
function snapPhysicalSquare(
anchorX: number, anchorY: number,
moverX: number, moverY: number,
xreal: number, yreal: number,
) {
const dx = moverX - anchorX;
const dy = moverY - anchorY;
const ax = xreal > 0 ? xreal : 1;
const ay = yreal > 0 ? yreal : 1;
const shortPhys = Math.min(Math.abs(dx) * ax, Math.abs(dy) * ay);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
return {
x: anchorX + sx * (shortPhys / ax),
y: anchorY + sy * (shortPhys / ay),
};
}
export default function CropBoxOverlay({
image, x1, y1, x2, y2,
aLocked, bLocked,
nodeId, onWidgetChange,
square = false,
xreal = 1,
yreal = 1,
}: CropBoxOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<string | null>(null);
const panStartRef = useRef<{ fx: number; fy: number; x1: number; y1: number; x2: number; y2: number } | null>(null);
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
return pointerToFraction(e, containerRef.current!);
@@ -30,28 +55,66 @@ export default function CropBoxOverlay({
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
if (point === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return;
if (point === 'rect' && (aLocked || bLocked)) return;
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
if (point === 'rect') {
const { fx, fy } = getCoords(e);
panStartRef.current = { fx, fy, x1, y1, x2, y2 };
}
setDragging(point);
}, [aLocked, bLocked]);
}, [aLocked, bLocked, getCoords, x1, y1, x2, y2]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
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);
if (dragging === 'rect') {
const start = panStartRef.current;
if (!start) return;
const left = Math.min(start.x1, start.x2);
const right = Math.max(start.x1, start.x2);
const top = Math.min(start.y1, start.y2);
const bottom = Math.max(start.y1, start.y2);
let dx = fx - start.fx;
let dy = fy - start.fy;
dx = Math.max(-left, Math.min(1 - right, dx));
dy = Math.max(-top, Math.min(1 - bottom, dy));
const nx1 = parseFloat((start.x1 + dx).toFixed(3));
const ny1 = parseFloat((start.y1 + dy).toFixed(3));
const nx2 = parseFloat((start.x2 + dx).toFixed(3));
const ny2 = parseFloat((start.y2 + dy).toFixed(3));
onWidgetChange(nodeId, 'x1', nx1);
onWidgetChange(nodeId, 'y1', ny1);
onWidgetChange(nodeId, 'x2', nx2);
onWidgetChange(nodeId, 'y2', ny2);
return;
}
}, [dragging, getCoords, nodeId, onWidgetChange]);
let vx = fx;
let vy = fy;
if (square) {
const anchorX = dragging === 'p2' ? x1 : x2;
const anchorY = dragging === 'p2' ? y1 : y2;
const snapped = snapPhysicalSquare(anchorX, anchorY, fx, fy, xreal, yreal);
vx = snapped.x;
vy = snapped.y;
}
const vxR = parseFloat(vx.toFixed(3));
const vyR = parseFloat(vy.toFixed(3));
if (dragging === 'p1') {
onWidgetChange(nodeId, 'x1', vxR);
onWidgetChange(nodeId, 'y1', vyR);
} else {
onWidgetChange(nodeId, 'x2', vxR);
onWidgetChange(nodeId, 'y2', vyR);
}
}, [dragging, getCoords, nodeId, onWidgetChange, square, xreal, yreal, x1, y1, x2, y2]);
const onPointerUp = useCallback(() => {
setDragging(null);
panStartRef.current = null;
}, []);
const left = Math.min(x1, x2);
@@ -75,13 +138,14 @@ export default function CropBoxOverlay({
<div className="crop-dim" style={{ left: 0, top: `${bottom * 100}%`, width: '100%', height: `${(1 - bottom) * 100}%` }} />
<div
className="crop-rect"
className={`crop-rect ${aLocked || bLocked ? 'crop-rect-locked' : ''}`}
style={{
left: `${left * 100}%`,
top: `${top * 100}%`,
width: `${(right - left) * 100}%`,
height: `${(bottom - top) * 100}%`,
}}
onPointerDown={onPointerDown('rect')}
/>
<div

View File

@@ -12,6 +12,10 @@ const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay'));
const StraightenPathOverlay = lazy(() => import('./StraightenPathOverlay'));
const MultiProfileOverlay = lazy(() => import('./MultiProfileOverlay'));
const PerspectiveOverlay = lazy(() => import('./PerspectiveOverlay'));
import TextNoteNode from './TextNoteNode';
@@ -21,6 +25,7 @@ import {
import { getGroupMinimumSize } from './groupSizing';
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting';
import { useIsFavorite, toggleFavorite } from './favorites';
import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types';
@@ -998,6 +1003,7 @@ function NodeTable({ rows }: { rows: Array<Record<string, unknown>> }) {
function CustomNode({ id, data }: { id: string; data: NodeData }) {
const ctx = useContext(NodeContext);
const def = data.definition;
const favorited = useIsFavorite(data.className);
const scalarDisplay = formatScalarDisplay(data.scalarValue);
const processingTimeText = formatProcessingTime(data.processingTimeMs);
const nodeWidth = useStore(
@@ -1194,6 +1200,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|| data.overlay.kind === 'mask_paint'
|| data.overlay.kind === 'markup'
|| data.overlay.kind === 'threshold_histogram'
|| data.overlay.kind === 'radial_profile'
|| data.overlay.kind === 'straighten_path'
|| data.overlay.kind === 'multi_profile'
|| data.overlay.kind === 'perspective'
);
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup';
const overlayTitle = data.overlay?.section_title
@@ -1209,6 +1219,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
? 'Cursors'
: data.overlay?.kind === 'line_plot'
? 'Line Plot'
: data.overlay?.kind === 'radial_profile'
? 'Radial Profile'
: data.overlay?.kind === 'straighten_path'
? 'Path'
: data.overlay?.kind === 'multi_profile'
? 'Preview'
: data.overlay?.kind === 'perspective'
? 'Perspective'
: 'Cross Section');
const headerMeta = (() => {
if (data.className === 'Folder') {
@@ -1232,6 +1250,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
<div className="node-title-left">
<span className="node-title-main">{data.label}</span>
<button className="node-help-btn nodrag nopan" title="Documentation" onClick={(e) => { e.stopPropagation(); ctx?.openHelp(data.label); }}>?</button>
<button
className={`node-fav-btn nodrag nopan${favorited ? ' is-favorited' : ''}`}
title={favorited ? 'Remove from favorites' : 'Add to favorites'}
aria-pressed={favorited}
onClick={(e) => { e.stopPropagation(); toggleFavorite(data.className); }}
>
{favorited ? '♥' : '♡'}
</button>
</div>
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
</div>
@@ -1404,15 +1430,17 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
{/* Interactive 3D surface view */}
{!!data.meshData && (
<CollapsibleSection title="3D View" defaultOpen={true}>
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading 3D...</div>}>
<SurfaceView
meshData={data.meshData as any}
nodeId={id}
widgetValues={data.widgetValues}
runtimeValues={data.runtimeValues}
onRuntimeValuesChange={ctx?.onRuntimeValuesChange}
/>
</Suspense>
<PreviewBoundary resetKey={String(data.meshData)}>
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading 3D...</div>}>
<SurfaceView
meshData={data.meshData as any}
nodeId={id}
widgetValues={data.widgetValues}
runtimeValues={data.runtimeValues}
onRuntimeValuesChange={ctx?.onRuntimeValuesChange}
/>
</Suspense>
</PreviewBoundary>
</CollapsibleSection>
)}
@@ -1499,6 +1527,9 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
bLocked={!!data.overlay!.b_locked}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
square={!!(data.widgetValues.square ?? data.overlay!.square)}
xreal={(data.overlay!.xreal ?? 1) as number}
yreal={(data.overlay!.yreal ?? 1) as number}
/>
) : data.overlay!.kind === 'cursor_points' ? (
<CrossSectionOverlay
@@ -1541,6 +1572,43 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'radial_profile' ? (
<RadialProfileOverlay
image={data.overlay!.image ?? ''}
cx={(data.widgetValues.cx ?? data.overlay!.cx ?? 0.5) as number}
cy={(data.widgetValues.cy ?? data.overlay!.cy ?? 0.5) as number}
ex={(data.widgetValues.ex ?? data.overlay!.ex ?? 0.9) as number}
ey={(data.widgetValues.ey ?? data.overlay!.ey ?? 0.5) as number}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'straighten_path' ? (
<StraightenPathOverlay
image={data.overlay!.image ?? ''}
points={(data.overlay!.points ?? []) as Array<{ x: number; y: number }>}
thickness={(data.widgetValues.thickness ?? data.overlay!.thickness ?? 1) as number}
xres={(data.overlay!.xres ?? 1) as number}
yres={(data.overlay!.yres ?? 1) as number}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'multi_profile' ? (
<MultiProfileOverlay
image={data.overlay!.image ?? ''}
row={(data.overlay!.row ?? 0) as number}
direction={(data.overlay!.direction ?? 'horizontal') as 'horizontal' | 'vertical'}
maxIndex={(data.overlay!.max_index ?? 0) as number}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'perspective' ? (
<PerspectiveOverlay
image={data.overlay!.image ?? ''}
correctedImage={data.overlay!.corrected_image ?? ''}
corners={(data.overlay!.corners ?? []) as Array<{ x: number; y: number }>}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'angle_measure' ? (
<AngleMeasureOverlay
image={data.overlay!.image ?? ''}

View File

@@ -0,0 +1,89 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { clamp, pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.multiprofile-overlay';
interface MultiProfileOverlayProps {
image: string;
row: number;
direction: 'horizontal' | 'vertical';
maxIndex: number;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
export default function MultiProfileOverlay({
image, row, direction, maxIndex,
nodeId, onWidgetChange,
}: MultiProfileOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [draftRow, setDraftRow] = useState<number | null>(null);
const draggingRef = useRef(false);
const pendingCommitRef = useRef<number | null>(null);
useEffect(() => {
if (pendingCommitRef.current !== null && row === pendingCommitRef.current) {
pendingCommitRef.current = null;
setDraftRow(null);
}
}, [row]);
const displayRow = draftRow ?? row;
const fractionFromEvent = useCallback((e: React.PointerEvent<Element>): number => {
if (!containerRef.current) return 0;
const { fx, fy } = pointerToFraction(e, containerRef.current);
return direction === 'horizontal' ? fy : fx;
}, [direction]);
const fractionToIndex = useCallback((frac: number): number => {
return clamp(Math.round(frac * maxIndex), 0, maxIndex);
}, [maxIndex]);
const onPointerDown = useCallback((e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
setDraftRow(fractionToIndex(fractionFromEvent(e)));
}, [fractionFromEvent, fractionToIndex]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!draggingRef.current) return;
setDraftRow(fractionToIndex(fractionFromEvent(e)));
}, [fractionFromEvent, fractionToIndex]);
const onPointerUp = useCallback(() => {
if (draggingRef.current && draftRow !== null) {
pendingCommitRef.current = draftRow;
onWidgetChange(nodeId, 'row', draftRow);
}
draggingRef.current = false;
}, [draftRow, nodeId, onWidgetChange]);
const fracPos = maxIndex > 0 ? displayRow / maxIndex : 0;
const linePct = clamp(fracPos * 100, 0, 100);
const lineStyle: React.CSSProperties = direction === 'horizontal'
? { left: 0, right: 0, top: `${linePct}%`, height: 0 }
: { top: 0, bottom: 0, left: `${linePct}%`, width: 0 };
const cursorClass = direction === 'horizontal' ? 'cursor-row' : 'cursor-col';
return (
<div
ref={containerRef}
className={`nodrag nowheel multiprofile-overlay ${cursorClass}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="A blended with B" draggable={false} className="multiprofile-image" />
<div className={`multiprofile-line multiprofile-line-${direction}`} style={lineStyle} />
<div className="multiprofile-readout">
{direction === 'horizontal' ? 'row' : 'col'} {displayRow}
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.perspective-overlay';
const CORNER_NAMES = ['top_left', 'top_right', 'bottom_left', 'bottom_right'] as const;
type CornerName = typeof CORNER_NAMES[number];
const CORNER_ANCHORS: Record<CornerName, { ax: number; ay: number }> = {
top_left: { ax: 0, ay: 0 },
top_right: { ax: 1, ay: 0 },
bottom_left: { ax: 0, ay: 1 },
bottom_right: { ax: 1, ay: 1 },
};
interface Corner { x: number; y: number }
interface Props {
image: string;
correctedImage: string;
corners: Corner[];
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
function cornerToPercent(corner: Corner, name: CornerName) {
const anchor = CORNER_ANCHORS[name];
return {
left: (anchor.ax + corner.x) * 100,
top: (anchor.ay + corner.y) * 100,
};
}
function cornersKey(c: Corner[]): string {
return c.map((p) => `${p.x},${p.y}`).join(';');
}
export default function PerspectiveOverlay({
image, correctedImage, corners, nodeId, onWidgetChange,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<CornerName | null>(null);
const [draft, setDraft] = useState<Corner[] | null>(null);
const pendingCommitRef = useRef<string | null>(null);
const [showCorrected, setShowCorrected] = useState(false);
useEffect(() => {
if (pendingCommitRef.current && cornersKey(corners) === pendingCommitRef.current) {
pendingCommitRef.current = null;
setDraft(null);
}
}, [corners]);
const liveCorners = draft ?? corners;
const onPointerDown = useCallback((corner: CornerName) => (e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = corner;
setDraft([...liveCorners]);
}, [liveCorners]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
const name = draggingRef.current;
if (!name || !containerRef.current) return;
const { fx, fy } = pointerToFraction(e, containerRef.current);
const anchor = CORNER_ANCHORS[name];
const cx = Math.max(-1, Math.min(1, parseFloat((fx - anchor.ax).toFixed(3))));
const cy = Math.max(-1, Math.min(1, parseFloat((fy - anchor.ay).toFixed(3))));
const idx = CORNER_NAMES.indexOf(name);
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev];
next[idx] = { x: cx, y: cy };
return next;
});
}, []);
const onPointerUp = useCallback(() => {
const name = draggingRef.current;
if (!name || !draft) {
draggingRef.current = null;
return;
}
draggingRef.current = null;
pendingCommitRef.current = cornersKey(draft);
for (let i = 0; i < CORNER_NAMES.length; i++) {
onWidgetChange(nodeId, `${CORNER_NAMES[i]}_x`, draft[i].x);
onWidgetChange(nodeId, `${CORNER_NAMES[i]}_y`, draft[i].y);
}
}, [draft, nodeId, onWidgetChange]);
const positions = CORNER_NAMES.map((name, i) => cornerToPercent(liveCorners[i] || { x: 0, y: 0 }, name));
const quadPoints = `${positions[0].left},${positions[0].top} ${positions[1].left},${positions[1].top} ${positions[3].left},${positions[3].top} ${positions[2].left},${positions[2].top}`;
return (
<div className="perspective-overlay-wrap">
<div className="perspective-tab-bar">
<button
className={`perspective-tab nodrag${!showCorrected ? ' active' : ''}`}
onClick={() => setShowCorrected(false)}
>
Source
</button>
<button
className={`perspective-tab nodrag${showCorrected ? ' active' : ''}`}
onClick={() => setShowCorrected(true)}
>
Corrected
</button>
</div>
{showCorrected ? (
<div className="perspective-overlay perspective-corrected">
<img src={correctedImage} alt="corrected" draggable={false} />
</div>
) : (
<div
ref={containerRef}
className="nodrag nowheel perspective-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="source" draggable={false} />
<svg className="perspective-quad" viewBox="0 0 100 100" preserveAspectRatio="none">
<polygon
points={quadPoints}
fill="none"
stroke="var(--selection, #3b82f6)"
strokeWidth="0.4"
strokeLinejoin="round"
/>
</svg>
{CORNER_NAMES.map((name, i) => (
<div
key={name}
className={`perspective-handle${draggingRef.current === name ? ' dragging' : ''}`}
style={{ left: `${positions[i].left}%`, top: `${positions[i].top}%` }}
onPointerDown={onPointerDown(name)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import React, { useRef, useState, useCallback } from 'react';
import { clampFraction, pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.radial-overlay';
interface RadialProfileOverlayProps {
image: string;
cx: number;
cy: number;
ex: number;
ey: number;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
type DragHandle = 'center' | 'a' | 'b';
interface DragState {
handle: DragHandle;
start: { fx: number; fy: number };
points: { cx: number; cy: number; ex: number; ey: number };
}
const round3 = (v: number) => parseFloat(v.toFixed(3));
export default function RadialProfileOverlay({
image, cx, cy, ex, ey,
nodeId, onWidgetChange,
}: RadialProfileOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<DragState | null>(null);
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
return pointerToFraction(e, containerRef.current!);
}, []);
const updateWidgets = useCallback((updates: Record<string, number>) => {
for (const [name, value] of Object.entries(updates)) {
onWidgetChange(nodeId, name, value);
}
}, [nodeId, onWidgetChange]);
const onPointerDown = useCallback((handle: DragHandle) => (e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const start = getCoords(e);
setDragging({ handle, start, points: { cx, cy, ex, ey } });
}, [cx, cy, ex, ey, getCoords]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e);
const pts = dragging.points;
if (dragging.handle === 'center') {
const dx = fx - dragging.start.fx;
const dy = fy - dragging.start.fy;
updateWidgets({
cx: round3(clampFraction(pts.cx + dx)),
cy: round3(clampFraction(pts.cy + dy)),
ex: round3(clampFraction(pts.ex + dx)),
ey: round3(clampFraction(pts.ey + dy)),
});
} else if (dragging.handle === 'a') {
updateWidgets({ ex: round3(fx), ey: round3(fy) });
} else {
updateWidgets({
ex: round3(clampFraction(2 * pts.cx - fx)),
ey: round3(clampFraction(2 * pts.cy - fy)),
});
}
}, [dragging, getCoords, updateWidgets]);
const onPointerUp = useCallback(() => {
setDragging(null);
}, []);
const bx = 2 * cx - ex;
const by = 2 * cy - ey;
const rxFrac = Math.abs(ex - cx);
const ryFrac = Math.abs(ey - cy);
return (
<div
ref={containerRef}
className="nodrag nowheel radial-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="field" draggable={false} className="radial-image" />
<svg className="radial-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<ellipse
cx={cx * 100} cy={cy * 100}
rx={rxFrac * 100} ry={ryFrac * 100}
className="radial-circle"
/>
<line
x1={ex * 100} y1={ey * 100}
x2={bx * 100} y2={by * 100}
className="radial-diameter"
/>
</svg>
<div
className="radial-marker radial-marker-end"
style={{ left: `${ex * 100}%`, top: `${ey * 100}%` }}
onPointerDown={onPointerDown('a')}
/>
<div
className="radial-marker radial-marker-end"
style={{ left: `${bx * 100}%`, top: `${by * 100}%` }}
onPointerDown={onPointerDown('b')}
/>
<div
className="radial-marker radial-marker-center"
style={{ left: `${cx * 100}%`, top: `${cy * 100}%` }}
onPointerDown={onPointerDown('center')}
/>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { clampFraction, pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.straighten-overlay';
interface Point { x: number; y: number; }
interface StraightenPathOverlayProps {
image: string;
points: Point[];
thickness: number;
xres: number;
yres: number;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
const round3 = (v: number) => parseFloat(v.toFixed(3));
function pointsToStrings(points: Point[]) {
return {
points_x: points.map(p => round3(p.x)).join(', '),
points_y: points.map(p => round3(p.y)).join(', '),
};
}
// Solve a 1-D natural cubic spline (matches scipy.interpolate.CubicSpline with
// bc_type="natural") and return a function that evaluates it at any t.
function naturalCubicSpline(t: number[], y: number[]): (tq: number) => number {
const n = t.length;
if (n < 2) return () => y[0] ?? 0;
if (n === 2) {
return (tq) => y[0] + (y[1] - y[0]) * (tq - t[0]) / (t[1] - t[0]);
}
const h = new Array(n - 1);
for (let i = 0; i < n - 1; i++) h[i] = t[i + 1] - t[i];
// Tridiagonal system for second derivatives M[1..n-2] (M[0] = M[n-1] = 0).
const a = new Array(n).fill(0);
const b = new Array(n).fill(0);
const c = new Array(n).fill(0);
const d = new Array(n).fill(0);
for (let i = 1; i < n - 1; i++) {
a[i] = h[i - 1];
b[i] = 2 * (h[i - 1] + h[i]);
c[i] = h[i];
d[i] = 6 * ((y[i + 1] - y[i]) / h[i] - (y[i] - y[i - 1]) / h[i - 1]);
}
for (let i = 2; i < n - 1; i++) {
const w = a[i] / b[i - 1];
b[i] -= w * c[i - 1];
d[i] -= w * d[i - 1];
}
const M = new Array(n).fill(0);
if (n >= 3) {
M[n - 2] = d[n - 2] / b[n - 2];
for (let i = n - 3; i >= 1; i--) {
M[i] = (d[i] - c[i] * M[i + 1]) / b[i];
}
}
return (tq) => {
let i = 0;
while (i < n - 2 && tq > t[i + 1]) i++;
const dx = h[i];
const A = (t[i + 1] - tq) / dx;
const B = (tq - t[i]) / dx;
return A * y[i] + B * y[i + 1]
+ ((A ** 3 - A) * M[i] + (B ** 3 - B) * M[i + 1]) * (dx * dx) / 6;
};
}
const CURVE_SAMPLES_PER_SEGMENT = 24;
function buildCurvePath(points: Point[]): string {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x * 100} ${points[0].y * 100}`;
if (points.length === 2) {
return `M ${points[0].x * 100} ${points[0].y * 100} L ${points[1].x * 100} ${points[1].y * 100}`;
}
const n = points.length;
const t = Array.from({ length: n }, (_, i) => i / (n - 1));
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const fx = naturalCubicSpline(t, xs);
const fy = naturalCubicSpline(t, ys);
const total = (n - 1) * CURVE_SAMPLES_PER_SEGMENT;
const segs: string[] = [`M ${points[0].x * 100} ${points[0].y * 100}`];
for (let i = 1; i <= total; i++) {
const tq = i / total;
segs.push(`L ${fx(tq) * 100} ${fy(tq) * 100}`);
}
return segs.join(' ');
}
function pointsKey(points: Point[]) {
return points.map(p => `${round3(p.x)},${round3(p.y)}`).join('|');
}
export default function StraightenPathOverlay({
image, points, thickness, xres, yres,
nodeId, onWidgetChange,
}: StraightenPathOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [draft, setDraft] = useState<Point[] | null>(null);
const draggingRef = useRef<number | null>(null);
const pendingCommitRef = useRef<string | null>(null);
useEffect(() => {
if (pendingCommitRef.current !== null
&& pointsKey(points) === pendingCommitRef.current) {
pendingCommitRef.current = null;
setDraft(null);
}
}, [points]);
const commit = useCallback((next: Point[]) => {
pendingCommitRef.current = pointsKey(next);
const { points_x, points_y } = pointsToStrings(next);
onWidgetChange(nodeId, 'points_x', points_x);
onWidgetChange(nodeId, 'points_y', points_y);
}, [nodeId, onWidgetChange]);
const displayPoints = draft ?? points;
const onPointerDownMarker = useCallback((idx: number) => (e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
if (e.shiftKey && displayPoints.length > 2) {
const next = displayPoints.filter((_, i) => i !== idx);
setDraft(next);
commit(next);
return;
}
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = idx;
setDraft(displayPoints);
}, [displayPoints, commit]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
const idx = draggingRef.current;
if (idx === null || !containerRef.current) return;
const { fx, fy } = pointerToFraction(e, containerRef.current);
setDraft(prev => {
const base = prev ?? points;
return base.map((p, i) => i === idx
? { x: clampFraction(fx), y: clampFraction(fy) }
: p);
});
}, [points]);
const onPointerUp = useCallback(() => {
if (draggingRef.current !== null && draft) {
commit(draft);
}
draggingRef.current = null;
}, [draft, commit]);
const onDoubleClick = useCallback((e: React.MouseEvent<Element>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const fx = clampFraction((e.clientX - rect.left) / rect.width);
const fy = clampFraction((e.clientY - rect.top) / rect.height);
const next = [...displayPoints, { x: fx, y: fy }];
setDraft(next);
commit(next);
}, [displayPoints, commit]);
const curveD = buildCurvePath(displayPoints);
const refRes = Math.max(xres, yres) || 1;
const bandWidthPct = (thickness / refRes) * 100;
return (
<div
ref={containerRef}
className="nodrag nowheel straighten-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
onDoubleClick={onDoubleClick}
>
<img src={image} alt="field" draggable={false} className="straighten-image" />
<svg className="straighten-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
{curveD && bandWidthPct > 0 && (
<path
d={curveD}
className="straighten-band"
fill="none"
strokeWidth={bandWidthPct}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{curveD && (
<path d={curveD} className="straighten-curve" fill="none" />
)}
</svg>
{displayPoints.map((p, i) => (
<div
key={i}
className="straighten-marker"
style={{ left: `${p.x * 100}%`, top: `${p.y * 100}%` }}
onPointerDown={onPointerDownMarker(i)}
title="shift-click to remove"
/>
))}
</div>
);
}

View File

@@ -133,6 +133,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const pointerEnteredAtRef = useRef(0);
const lastWheelAtRef = useRef(0);
const gestureStartedInsideRef = useRef(false);
const scheduleViewportSyncRef = useRef<(delay?: number, scheduleRun?: boolean) => void>(() => {});
const updateDiagnosticsRef = useRef<(patch: Partial<DiagnosticsState>) => void>(() => {});
const [diagnostics, setDiagnostics] = useState<DiagnosticsState>({
status: meshData ? 'initializing' : 'waiting for mesh',
webgl: 'pending',
@@ -239,6 +241,9 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
scheduleViewportSync(0, true);
}, [applyCameraState, scheduleViewportSync]);
scheduleViewportSyncRef.current = scheduleViewportSync;
updateDiagnosticsRef.current = updateDiagnostics;
// Initialize Three.js scene once
useEffect(() => {
const container = containerRef.current;
@@ -256,8 +261,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x0f172a);
container.appendChild(renderer.domElement);
updateDiagnostics({
status: meshData ? 'renderer ready' : 'waiting for mesh',
updateDiagnosticsRef.current({
status: 'renderer ready',
webgl: `${renderer.capabilities.isWebGL2 ? 'webgl2' : 'webgl1'} / ${renderer.capabilities.precision}`,
canvas: `${renderer.domElement.width}x${renderer.domElement.height} px`,
render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`,
@@ -266,13 +271,13 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const handleContextLost = (event: Event) => {
event.preventDefault();
updateDiagnostics({
updateDiagnosticsRef.current({
status: 'webgl context lost',
error: 'WebGL context lost',
});
};
const handleContextRestored = () => {
updateDiagnostics({
updateDiagnosticsRef.current({
status: 'webgl context restored',
error: '',
});
@@ -303,7 +308,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
TWO: THREE.TOUCH.DOLLY_ROTATE,
};
renderer.domElement.style.touchAction = 'none';
const handleControlsEnd = () => scheduleViewportSync(120, true);
const handleControlsEnd = () => scheduleViewportSyncRef.current(120, true);
controls.addEventListener('end', handleControlsEnd);
// Lighting
@@ -341,7 +346,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
r.setSize(w, w);
c.aspect = 1;
c.updateProjectionMatrix();
updateDiagnostics({
updateDiagnosticsRef.current({
canvas: `${r.domElement.width}x${r.domElement.height} px`,
});
});
@@ -361,7 +366,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
}
threeRef.current = null;
};
}, [scheduleViewportSync, updateDiagnostics]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [applyCameraState]);
useEffect(() => {
if (meshData) {

68
frontend/src/favorites.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useSyncExternalStore } from 'react';
const STORAGE_KEY = 'tono_favorite_nodes';
let favorites: Set<string> = loadFromStorage();
const listeners = new Set<() => void>();
function loadFromStorage(): Set<string> {
if (typeof localStorage === 'undefined') return new Set();
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((x): x is string => typeof x === 'string'));
} catch {
return new Set();
}
}
function persist(): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...favorites]));
} catch {
// Storage full or disabled — ignore.
}
}
function notify(): void {
for (const cb of listeners) cb();
}
export function getFavorites(): Set<string> {
return favorites;
}
export function isFavorite(className: string): boolean {
return favorites.has(className);
}
export function toggleFavorite(className: string): void {
const next = new Set(favorites);
if (next.has(className)) next.delete(className);
else next.add(className);
favorites = next;
persist();
notify();
}
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
export function useFavorites(): Set<string> {
return useSyncExternalStore(subscribe, getFavorites, getFavorites);
}
export function useIsFavorite(className: string): boolean {
return useSyncExternalStore(
subscribe,
() => favorites.has(className),
() => favorites.has(className),
);
}

46
frontend/src/nodeUsage.ts Normal file
View File

@@ -0,0 +1,46 @@
const STORAGE_KEY = 'tono_node_usage_counts';
let counts: Record<string, number> = loadFromStorage();
function loadFromStorage(): Record<string, number> {
if (typeof localStorage === 'undefined') return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
return parsed;
} catch {
return {};
}
}
function persist(): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(counts));
} catch {
// ignore
}
}
export function recordUsage(className: string): void {
counts = { ...counts, [className]: (counts[className] || 0) + 1 };
persist();
}
export function getUsageCount(className: string): number {
return counts[className] || 0;
}
export function pickWeightedRandom(classNames: string[]): string | null {
if (classNames.length === 0) return null;
const weights = classNames.map((cn) => 1 / (1 + (counts[cn] || 0)));
const total = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * total;
for (let i = 0; i < classNames.length; i++) {
r -= weights[i];
if (r <= 0) return classNames[i];
}
return classNames[classNames.length - 1];
}

View File

@@ -792,6 +792,34 @@ html, body, #root {
border-color: var(--node-help-btn-border-hover);
}
.node-fav-btn {
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--node-help-btn-bg);
border: 1px solid var(--node-help-btn-border);
color: var(--node-help-btn-text);
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 0;
cursor: pointer;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.node-fav-btn:hover {
background: var(--node-help-btn-bg-hover);
border-color: var(--node-help-btn-border-hover);
}
.node-fav-btn.is-favorited {
color: #ffffff;
}
/* ── Node help panel ─────────────────────────────────────── */
.node-help-tabs {
@@ -1633,6 +1661,213 @@ html, body, #root {
opacity: 0.9;
}
/* ── Radial profile overlay ───────────────────────────────────────── */
.radial-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
}
.radial-image {
width: 100%;
display: block;
}
.radial-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.radial-circle {
fill: none;
stroke: var(--marker);
stroke-width: 1.4;
vector-effect: non-scaling-stroke;
stroke-dasharray: 4 3;
}
.radial-diameter {
stroke: var(--marker);
stroke-width: 1.2;
vector-effect: non-scaling-stroke;
stroke-dasharray: 3 3;
opacity: 0.7;
}
.radial-marker {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--marker);
border: 1px solid var(--marker-border);
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 0 4px var(--marker-shadow);
z-index: 1;
}
.radial-marker:active {
cursor: grabbing;
background: var(--marker-active);
transform: translate(-50%, -50%) scale(1.2);
}
.radial-marker-center {
width: 10px;
height: 10px;
border-radius: 2px;
}
/* ── Straighten Path overlay ──────────────────────────────────────── */
.straighten-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
cursor: crosshair;
}
.straighten-image {
width: 100%;
display: block;
}
.straighten-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.straighten-band {
stroke: var(--accent-lighter);
opacity: 0.25;
}
.straighten-curve {
stroke: var(--marker);
stroke-width: 1.4;
vector-effect: non-scaling-stroke;
}
.straighten-marker {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--marker);
border: 1px solid var(--marker-border);
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 0 4px var(--marker-shadow);
z-index: 1;
}
.straighten-marker:active {
cursor: grabbing;
background: var(--marker-active);
transform: translate(-50%, -50%) scale(1.2);
}
/* ── Multi Profile overlay ────────────────────────────────────────── */
.multiprofile-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
}
.multiprofile-overlay.cursor-row { cursor: row-resize; }
.multiprofile-overlay.cursor-col { cursor: col-resize; }
.multiprofile-image {
width: 100%;
display: block;
}
.multiprofile-line {
position: absolute;
pointer-events: none;
}
.multiprofile-line-horizontal {
border-top: 1.5px solid var(--marker);
box-shadow: 0 0 4px var(--marker-shadow);
}
.multiprofile-line-vertical {
border-left: 1.5px solid var(--marker);
box-shadow: 0 0 4px var(--marker-shadow);
}
.multiprofile-readout {
position: absolute;
top: 4px;
left: 4px;
font-size: 10px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
padding: 2px 5px;
border-radius: 3px;
pointer-events: none;
}
/* ── Perspective correction overlay ──────────────────────────────────── */
.perspective-overlay-wrap {
display: flex;
flex-direction: column;
}
.perspective-tab-bar {
display: flex;
gap: 1px;
background: var(--border-default);
border-bottom: 1px solid var(--border-default);
}
.perspective-tab {
flex: 1;
padding: 4px 0;
font-size: 11px;
font-weight: 500;
border: none;
background: var(--bg-deep);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.perspective-tab:hover {
background: var(--bg-panel);
}
.perspective-tab.active {
background: var(--bg-panel);
color: var(--text-primary);
}
.perspective-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
}
.perspective-overlay img {
width: 100%;
display: block;
}
.perspective-quad {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.perspective-handle {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--selection, #3b82f6);
border: 2px solid #fff;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
transform: translate(-50%, -50%);
cursor: grab;
z-index: 2;
}
.perspective-handle:hover,
.perspective-handle.dragging {
cursor: grabbing;
transform: translate(-50%, -50%) scale(1.2);
}
.is-panning .perspective-overlay {
pointer-events: none;
}
.angle-overlay {
--angle-line-color: #ff9800;
--angle-arc-color: rgb(255, 166, 77);
@@ -1804,7 +2039,15 @@ html, body, #root {
border: 2px solid var(--accent-lighter);
box-shadow: inset 0 0 0 1px var(--crop-inset);
background: transparent;
pointer-events: none;
cursor: grab;
}
.crop-rect:active {
cursor: grabbing;
}
.crop-rect-locked {
cursor: default;
}
.crop-marker {
@@ -1879,7 +2122,10 @@ html, body, #root {
.is-panning .lineplot-overlay,
.is-panning .crop-overlay,
.is-panning .mask-paint-overlay,
.is-panning .markup-overlay {
.is-panning .markup-overlay,
.is-panning .radial-overlay,
.is-panning .straighten-overlay,
.is-panning .multiprofile-overlay {
pointer-events: none;
}
@@ -2264,7 +2510,7 @@ html, body, #root {
/* ── Context menu ──────────────────────────────────────────────────── */
.context-menu {
position: fixed;
z-index: 1000;
z-index: 10000;
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: 6px;
@@ -2333,6 +2579,14 @@ html, body, #root {
.ctx-cat-active .ctx-cat-arrow {
color: var(--text-primary);
}
.ctx-cat-favorites .ctx-cat-label {
text-transform: none;
}
.ctx-random-node {
border-top: 1px solid var(--border-strong);
color: var(--text-secondary);
font-style: italic;
}
/* ── Submenu panel (separate fixed-position sibling) ── */
.ctx-submenu {
@@ -2342,7 +2596,7 @@ html, body, #root {
}
.context-item {
padding: 5px 20px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
color: var(--text-primary);

View File

@@ -66,8 +66,24 @@ export interface OverlayData {
y2?: number;
xm?: number;
ym?: number;
cx?: number;
cy?: number;
ex?: number;
ey?: number;
xreal?: number;
yreal?: number;
square?: boolean;
a_locked?: boolean;
b_locked?: boolean;
points?: Array<{ x: number; y: number }>;
thickness?: number;
xres?: number;
yres?: number;
row?: number;
direction?: 'horizontal' | 'vertical';
max_index?: number;
corrected_image?: string;
corners?: Array<{ x: number; y: number }>;
section_title?: string;
line?: number[];
shape?: string;

View File

@@ -11,6 +11,10 @@ export const OVERLAY_CAPTURE_SELECTORS = [
'.crop-overlay', // CropBoxOverlay
'.markup-overlay', // MarkupOverlay
'.angle-overlay', // AngleMeasureOverlay
'.radial-overlay', // RadialProfileOverlay
'.straighten-overlay', // StraightenPathOverlay
'.multiprofile-overlay', // MultiProfileOverlay
'.perspective-overlay', // PerspectiveOverlay
];
function encodeBase64(bytes: Uint8Array) {

View File

@@ -55,3 +55,73 @@ def test_crop_resize_field():
raise AssertionError("Expected invalid crop bounds to raise ValueError")
except ValueError:
pass
def test_crop_resize_square_constraint():
"""With square=True, the crop region is coerced to a physical square."""
from backend.nodes.crop_resize import CropResizeField
node = CropResizeField()
# Square-pixel field (xreal == yreal): fraction-square == physical-square.
data = np.arange(64 * 64, dtype=np.float64).reshape(64, 64)
field = DataField(
data=data, xreal=1e-6, yreal=1e-6,
si_unit_xy="m", si_unit_z="m",
)
# Requested region: 0.1..0.9 (wide, 80%) x 0.1..0.5 (tall, 40%).
# Physical-square clamp shrinks the longer (x) side to match y → 40% x 40%.
cropped, = node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.5,
target_width=0, target_height=0, interpolation="bilinear", square=True,
)
assert cropped.data.shape[0] == cropped.data.shape[1], (
f"expected square crop, got {cropped.data.shape}"
)
assert np.isclose(cropped.xreal, cropped.yreal)
def test_crop_resize_square_physical_aspect():
"""Square on a non-square-pixel field gives a physical square (not pixel square)."""
from backend.nodes.crop_resize import CropResizeField
node = CropResizeField()
# 64x64 pixels but xreal = 2*yreal → x is physically twice as wide per fraction.
data = np.arange(64 * 64, dtype=np.float64).reshape(64, 64)
field = DataField(
data=data, xreal=2e-6, yreal=1e-6,
si_unit_xy="m", si_unit_z="m",
)
# Requested region: 0.1..0.9 x 0.1..0.9 (both 80% fraction).
# Physical widths: 0.8 * 2e-6 = 1.6e-6 vs 0.8 * 1e-6 = 0.8e-6.
# Shorter is y (0.8e-6). Clamp x to 0.4 fraction → 0.1..0.5.
cropped, = node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9,
target_width=0, target_height=0, interpolation="bilinear", square=True,
)
assert np.isclose(cropped.xreal, cropped.yreal, rtol=0.05), (
f"expected physical square, got xreal={cropped.xreal} yreal={cropped.yreal}"
)
def test_crop_resize_overlay_includes_aspect():
"""Overlay payload should include xreal/yreal so the frontend can snap to square."""
from backend.nodes.crop_resize import CropResizeField
node = CropResizeField()
data = np.ones((16, 16), dtype=np.float64)
field = DataField(
data=data, xreal=3e-6, yreal=2e-6,
si_unit_xy="m", si_unit_z="m",
)
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9,
target_width=0, target_height=0, interpolation="bilinear",
)
assert overlays[0]["xreal"] == 3e-6
assert overlays[0]["yreal"] == 2e-6

View File

@@ -0,0 +1,143 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_mask_rectangular_basic():
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
mask, = node.process(
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=False,
)
assert mask.dtype == np.uint8
assert mask.shape == (32, 32)
# Corners defined by 0.25..0.75 on a 32-wide field → pixels 8..24
assert mask[0, 0] == 0
assert mask[16, 16] == 255
assert np.all(mask[8:24, 8:24] == 255)
assert np.all(mask[:8, :] == 0)
assert np.all(mask[24:, :] == 0)
def test_mask_rectangular_invert():
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
mask, = node.process(
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=True,
)
assert mask[0, 0] == 255
assert mask[16, 16] == 0
def test_mask_rectangular_corner_inputs_override_widgets():
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
mask, = node.process(
field, x1=0.0, y1=0.0, x2=1.0, y2=1.0, square=False, invert=False,
corner_a=(0.5, 0.5), corner_b=(1.0, 1.0),
)
# Corner override → rectangle is the lower-right quadrant (pixels 16..32)
assert mask[0, 0] == 0
assert mask[24, 24] == 255
assert np.all(mask[16:32, 16:32] == 255)
assert np.all(mask[:16, :16] == 0)
def test_mask_rectangular_reversed_corners():
"""x2 < x1 or y2 < y1 should still produce the same rectangle."""
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
forward, = node.process(
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=False,
)
reversed_, = node.process(
field, x1=0.75, y1=0.75, x2=0.25, y2=0.25, square=False, invert=False,
)
assert np.array_equal(forward, reversed_)
def test_mask_rectangular_clamps_out_of_bounds():
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((16, 16)))
mask, = node.process(
field, x1=-0.5, y1=-0.5, x2=2.0, y2=2.0, square=False, invert=False,
)
assert mask.shape == (16, 16)
assert np.all(mask == 255)
def test_mask_rectangular_square_shrinks_longer_side():
"""With square=True on a square field, the longer side collapses to the shorter."""
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((64, 64)))
# Non-square fractional region: 0.1..0.9 in x (80% wide), 0.1..0.5 in y (40% tall).
# With square=True the shorter dimension (y, 40%) wins; x shrinks to match.
mask, = node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.5, square=True, invert=False,
)
ys, xs = np.where(mask == 255)
assert ys.size > 0
width = xs.max() - xs.min() + 1
height = ys.max() - ys.min() + 1
assert width == height, f"expected square, got {width}x{height}"
def test_mask_rectangular_square_physical_aspect():
"""On a field with non-square physical aspect, 'square' is physical, not pixel."""
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
# xreal = 2e-6, yreal = 1e-6 — so a physical square covers twice the x-fraction of the y-fraction.
field = make_field(data=np.zeros((64, 64)), xreal=2e-6, yreal=1e-6)
# Start with a region 0.1..0.9 in x (0.8 frac, 1.6e-6 phys) and 0.1..0.9 in y (0.8 frac, 0.8e-6 phys).
# Shorter physical side = 0.8e-6. In x that's 0.4 fraction → shrink x to 0.1..0.5.
mask, = node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9, square=True, invert=False,
)
ys, xs = np.where(mask == 255)
assert ys.size > 0
# The selected region in pixels should be roughly 0.1..0.5 in x (pixels ~6..32)
# and 0.1..0.9 in y (pixels ~6..58)
assert xs.max() < 40
assert ys.max() > 50
def test_mask_rectangular_emits_overlay():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
overlays = []
with execution_callbacks(
overlay=lambda nid, d: overlays.append(d),
), active_node("test"):
node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9, square=False, invert=False,
)
assert len(overlays) == 1
assert overlays[0]["kind"] == "crop_box"
assert overlays[0]["section_title"] == "Preview"
assert overlays[0]["x1"] == 0.1
assert overlays[0]["image"].startswith("data:image/png;base64,")

View File

@@ -31,3 +31,41 @@ def test_vertical_direction():
field = make_field(shape=(80, 40))
(profile,) = node.process(field, field, row=-1, direction="vertical", mode="overlay")
assert len(profile.data) == 80, f"Vertical profile length should be field height (80), got {len(profile.data)}"
def test_emits_blended_overlay():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.multi_profile import MultipleProfiles
node = MultipleProfiles()
field = make_field(shape=(64, 128))
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(field, field, row=10, direction="horizontal", mode="overlay")
assert len(overlays) == 1
ov = overlays[0]
assert ov["kind"] == "multi_profile"
assert ov["section_title"] == "Preview"
assert ov["image"].startswith("data:image/png;base64,")
assert ov["row"] == 10
assert ov["direction"] == "horizontal"
assert ov["max_index"] == 63 # height - 1
def test_overlay_max_index_for_vertical():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.multi_profile import MultipleProfiles
node = MultipleProfiles()
field = make_field(shape=(80, 40))
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(field, field, row=-1, direction="vertical", mode="overlay")
ov = overlays[0]
assert ov["direction"] == "vertical"
assert ov["max_index"] == 39 # width - 1
assert ov["row"] == 20 # center column for 40 wide

View File

@@ -51,3 +51,57 @@ def test_output_shape():
bottom_right_x=0.0, bottom_right_y=0.0,
)
assert result.data.shape == (48, 96)
def test_emits_perspective_overlay():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.perspective_correction import PerspectiveCorrection
node = PerspectiveCorrection()
field = make_field(shape=(64, 64))
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(
field,
top_left_x=0.05, top_left_y=0.05,
top_right_x=-0.05, top_right_y=0.05,
bottom_left_x=0.05, bottom_left_y=-0.05,
bottom_right_x=-0.05, bottom_right_y=-0.05,
)
assert len(overlays) == 1
ov = overlays[0]
assert ov["kind"] == "perspective"
assert ov["section_title"] == "Perspective"
assert ov["image"].startswith("data:image/png;base64,")
assert ov["corrected_image"].startswith("data:image/png;base64,")
assert len(ov["corners"]) == 4
assert ov["corners"][0] == {"x": 0.05, "y": 0.05}
assert ov["corners"][3] == {"x": -0.05, "y": -0.05}
def test_coord_input_overrides_floats():
from backend.nodes.perspective_correction import PerspectiveCorrection
node = PerspectiveCorrection()
field = make_field(shape=(64, 64))
result_floats, = node.process(
field,
top_left_x=0.1, top_left_y=0.1,
top_right_x=0.0, top_right_y=0.0,
bottom_left_x=0.0, bottom_left_y=0.0,
bottom_right_x=0.0, bottom_right_y=0.0,
)
result_coord, = node.process(
field,
top_left_x=0.0, top_left_y=0.0,
top_right_x=0.0, top_right_y=0.0,
bottom_left_x=0.0, bottom_left_y=0.0,
bottom_right_x=0.0, bottom_right_y=0.0,
top_left=(0.1, 0.1),
)
assert np.allclose(result_floats.data, result_coord.data)

View File

@@ -11,7 +11,7 @@ def test_radial_profile_constant_field():
node = RadialProfile()
field = make_field(data=np.full((64, 64), 2.5))
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32)
result, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
assert isinstance(result, LineData)
assert len(result.data) == 32
@@ -26,7 +26,7 @@ def test_radial_profile_units():
node = RadialProfile()
field = make_field()
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32)
result, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
assert result.x_unit == field.si_unit_xy
assert result.y_unit == field.si_unit_z
@@ -38,7 +38,7 @@ def test_radial_profile_x_axis_monotone():
node = RadialProfile()
field = make_field()
result, = node.process(field, cx=0.5, cy=0.5, n_bins=64)
result, = node.process(field, n_bins=64, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
assert result.x_axis[0] >= 0.0
assert np.all(np.diff(result.x_axis) > 0)
@@ -50,7 +50,7 @@ def test_radial_profile_off_centre():
node = RadialProfile()
field = make_field(data=np.ones((64, 64)))
result, = node.process(field, cx=0.0, cy=0.0, n_bins=32)
result, = node.process(field, n_bins=32, cx=0.0, cy=0.0, ex=1.0, ey=1.0)
assert len(result.data) == 32
finite = result.data[np.isfinite(result.data)]
assert np.allclose(finite, 1.0, atol=1e-10)
@@ -67,7 +67,7 @@ def test_radial_profile_radial_symmetry():
data = np.cos(r * np.pi / (xres / 2.0))
field = make_field(data=data)
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32)
result, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
finite = result.data[np.isfinite(result.data)]
# The profile should vary (not constant)
assert np.std(finite) > 0.01
@@ -80,6 +80,25 @@ def test_radial_profile_n_bins():
field = make_field()
for n in (16, 64, 256):
result, = node.process(field, cx=0.5, cy=0.5, n_bins=n)
result, = node.process(field, n_bins=n, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
assert len(result.data) == n
assert len(result.x_axis) == n
def test_radial_profile_radius_controlled_by_endpoint():
"""The outer radius is set by the distance from (cx,cy) to (ex,ey)."""
from backend.nodes.radial_profile import RadialProfile
node = RadialProfile()
field = make_field()
# End at (1.0, 0.5): radius = 0.5 * xreal
short, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
expected_r_short = 0.5 * field.xreal
assert np.isclose(short.x_axis[-1], expected_r_short, rtol=0.05)
# End at corner: radius = sqrt(xreal^2 + yreal^2) * 0.5 (half-diagonal)
diag, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=1.0)
expected_r_diag = 0.5 * np.hypot(field.xreal, field.yreal)
assert np.isclose(diag.x_axis[-1], expected_r_diag, rtol=0.05)
assert diag.x_axis[-1] > short.x_axis[-1]

View File

@@ -26,3 +26,46 @@ def test_statistics():
assert const_stats["RMS"] == 0.0
assert const_stats["skewness"] == 0.0
assert const_stats["kurtosis"] == 0.0
def test_statistics_with_mask():
"""A mask restricts the stats to pixels where mask != 0."""
from backend.nodes.statistics import Statistics
node = Statistics()
data = np.array([[1, 2], [3, 4]], dtype=np.float64)
field = make_field(data=data)
# Mask selects only pixels >= 3 (bottom row).
mask = np.array([[0, 0], [255, 255]], dtype=np.uint8)
table, = node.process(field, mask=mask)
stats = {row["quantity"]: row["value"] for row in table}
assert stats["min"] == 3.0
assert stats["max"] == 4.0
assert stats["mean"] == 3.5
def test_statistics_mask_shape_mismatch():
from backend.nodes.statistics import Statistics
node = Statistics()
field = make_field(data=np.zeros((4, 4)))
bad_mask = np.zeros((3, 3), dtype=np.uint8)
try:
node.process(field, mask=bad_mask)
raise AssertionError("expected shape mismatch to raise")
except ValueError:
pass
def test_statistics_empty_mask():
from backend.nodes.statistics import Statistics
node = Statistics()
field = make_field(data=np.ones((4, 4)))
empty_mask = np.zeros((4, 4), dtype=np.uint8)
try:
node.process(field, mask=empty_mask)
raise AssertionError("expected empty mask to raise")
except ValueError:
pass

View File

@@ -8,10 +8,11 @@ def test_basic_extraction():
node = StraightenPath()
field = make_field(shape=(64, 64))
(result,) = node.process(field, points_x="0.25, 0.5, 0.75",
points_y="0.5, 0.3, 0.5",
thickness=1, n_samples=256)
result, profile = node.process(field, points_x="0.25, 0.5, 0.75",
points_y="0.5, 0.3, 0.5",
thickness=1, n_samples=256)
assert result.data.shape[1] == 256, f"Output width should be n_samples=256, got {result.data.shape[1]}"
assert profile.data.shape == (256,)
def test_thickness():
@@ -19,10 +20,14 @@ def test_thickness():
node = StraightenPath()
field = make_field(shape=(64, 64))
(result,) = node.process(field, points_x="0.2, 0.8",
points_y="0.5, 0.5",
thickness=5, n_samples=100)
result, profile = node.process(field, points_x="0.2, 0.8",
points_y="0.5, 0.5",
thickness=5, n_samples=100)
assert result.data.shape[0] == 5, f"Output height should be thickness=5, got {result.data.shape[0]}"
# Profile is the 1-pixel-wide centerline regardless of thickness.
assert profile.data.shape == (100,)
# For a horizontal line, the centerline equals the middle row of the strip.
assert np.allclose(profile.data, result.data[2])
def test_single_point_returns_input():
@@ -30,8 +35,33 @@ def test_single_point_returns_input():
node = StraightenPath()
field = make_field(shape=(64, 64))
(result,) = node.process(field, points_x="0.5",
points_y="0.5",
thickness=1, n_samples=100)
# With only 1 point, node returns the original field unchanged
result, profile = node.process(field, points_x="0.5",
points_y="0.5",
thickness=1, n_samples=100)
# With only 1 point, node returns the original field unchanged + empty profile.
assert np.array_equal(result.data, field.data)
assert profile.data.shape == (0,)
def test_emits_overlay_with_points_and_thickness():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.straighten_path import StraightenPath
node = StraightenPath()
field = make_field(shape=(64, 64))
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(field, points_x="0.25, 0.5, 0.75",
points_y="0.5, 0.3, 0.5",
thickness=4, n_samples=128)
assert len(overlays) == 1
ov = overlays[0]
assert ov["kind"] == "straighten_path"
assert ov["section_title"] == "Path"
assert ov["image"].startswith("data:image/png;base64,")
assert ov["thickness"] == 4
assert ov["xres"] == 64 and ov["yres"] == 64
assert [p["x"] for p in ov["points"]] == [0.25, 0.5, 0.75]
assert [p["y"] for p in ov["points"]] == [0.5, 0.3, 0.5]