Compare commits

..

12 Commits

Author SHA1 Message Date
d4c5cf4670 add a few more nodes
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-05-18 20:55:46 -07:00
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
53 changed files with 2903 additions and 165 deletions

View File

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

View File

@@ -0,0 +1,88 @@
"""Arc Revolve — subtract a cylindrical arc background."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
def _arc_kernel(radius: int) -> np.ndarray:
"""Build a 1D arc kernel: z = 1 - sqrt(1 - (i/radius)^2)."""
half = min(radius, 4096)
i = np.arange(-half, half + 1, dtype=np.float64)
t = np.clip((i / radius) ** 2, 0.0, 1.0)
return 1.0 - np.sqrt(1.0 - t)
def _arc_revolve_1d(data: np.ndarray, radius: int) -> np.ndarray:
"""Compute arc-revolve background for each row independently."""
yres, xres = data.shape
kernel = _arc_kernel(radius)
half = len(kernel) // 2
bg = np.empty_like(data)
for row in range(yres):
line = data[row].copy()
# Suppress deep outliers before fitting
window = min(half, xres // 2)
if window > 0:
from scipy.ndimage import uniform_filter1d
local_mean = uniform_filter1d(line, size=2 * window + 1, mode='nearest')
local_sq = uniform_filter1d(line ** 2, size=2 * window + 1, mode='nearest')
local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0))
threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30)
line = np.maximum(line, threshold)
# For each pixel, find the lowest position the arc can sit
padded = np.pad(line, half, mode='edge')
row_bg = np.full(xres, np.inf)
for k in range(len(kernel)):
shifted = padded[k:k + xres] - kernel[k]
row_bg = np.minimum(row_bg, shifted)
bg[row] = row_bg
return bg
@register_node(display_name="Arc Revolve")
class ArcRevolve:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"radius": ("INT", {"default": 20, "min": 1, "max": 1000, "step": 1}),
"direction": (["horizontal", "vertical", "both"], {"default": "horizontal"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
('DATA_FIELD', 'background'),
)
FUNCTION = "process"
DESCRIPTION = (
"Subtract a cylindrical arc background. A circular arc of the given "
"radius is rolled under each row (or column), and the envelope it "
"traces out is subtracted as the background."
)
KEYWORDS = ("arc", "revolve", "cylindrical", "background", "level")
def process(self, field: DataField, radius: int = 20,
direction: str = "horizontal") -> tuple:
data = np.asarray(field.data, dtype=np.float64)
if direction == "horizontal":
bg = _arc_revolve_1d(data, radius)
elif direction == "vertical":
bg = _arc_revolve_1d(data.T, radius).T
else:
bg_h = _arc_revolve_1d(data, radius)
bg_v = _arc_revolve_1d(data.T, radius).T
bg = np.minimum(bg_h, bg_v)
return (field.replace(data=data - bg), field.replace(data=bg))

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,75 @@
"""Level Rotate — level by physically rotating the data plane."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import map_coordinates
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Level Rotate")
class LevelRotate:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = (
"Level by physically rotating the data plane. Fits a best-fit plane, "
"converts its slopes to tilt angles, then rotates the surface by "
"those angles using interpolation rather than algebraic subtraction."
)
KEYWORDS = ("rotate", "tilt", "level", "plane")
def process(self, field: DataField) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Fit plane: z = a + bx*x + by*y (x,y in pixel coords)
yy, xx = np.mgrid[:yres, :xres].astype(np.float64)
A = np.column_stack([np.ones(yres * xres), xx.ravel(), yy.ravel()])
coeffs, _, _, _ = np.linalg.lstsq(A, data.ravel(), rcond=None)
_, bx, by = coeffs
# Convert pixel slopes to tilt angles
alpha_x = np.arctan(bx)
alpha_y = np.arctan(by)
# Build rotation: for each output pixel, find where it came from
cx = (xres - 1) / 2.0
cy = (yres - 1) / 2.0
cos_x = np.cos(alpha_x)
cos_y = np.cos(alpha_y)
# Source coordinates after removing tilt
src_x = xx.copy()
src_y = yy.copy()
src_z = data.copy()
# Rotate about x-axis (corrects y-tilt)
dy = yy - cy
src_y_rot = cy + dy * cos_y
src_z = src_z - dy * np.sin(alpha_y)
# Rotate about y-axis (corrects x-tilt)
dx = xx - cx
src_x_rot = cx + dx * cos_x
src_z = src_z - dx * np.sin(alpha_x)
# Resample with the adjusted z values
result = map_coordinates(src_z, [src_y_rot, src_x_rot], order=1,
mode='nearest')
return (field.replace(data=result),)

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

@@ -0,0 +1,74 @@
"""Sphere Revolve — subtract a spherical cap background."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import uniform_filter
from backend.node_registry import register_node
from backend.data_types import DataField
def _sphere_kernel(radius: int) -> np.ndarray:
"""Build a 2D spherical cap kernel."""
half = min(radius, 512)
i = np.arange(-half, half + 1, dtype=np.float64)
ii, jj = np.meshgrid(i, i)
r2 = (ii ** 2 + jj ** 2) / (radius ** 2)
r2 = np.clip(r2, 0.0, 1.0)
return 1.0 - np.sqrt(1.0 - r2)
@register_node(display_name="Sphere Revolve")
class SphereRevolve:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"radius": ("INT", {"default": 20, "min": 1, "max": 500, "step": 1}),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
('DATA_FIELD', 'background'),
)
FUNCTION = "process"
DESCRIPTION = (
"Subtract a spherical cap background. A sphere of the given radius "
"is rolled under the surface, and the envelope it traces is "
"subtracted as the background."
)
KEYWORDS = ("sphere", "revolve", "spherical", "background", "level")
def process(self, field: DataField, radius: int = 20) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
kernel = _sphere_kernel(radius)
half = kernel.shape[0] // 2
# Suppress deep outliers
window = max(1, half // 2)
local_mean = uniform_filter(data, size=2 * window + 1, mode='nearest')
local_sq = uniform_filter(data ** 2, size=2 * window + 1, mode='nearest')
local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0))
threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30)
clipped = np.maximum(data, threshold)
padded = np.pad(clipped, half, mode='edge')
bg = np.full_like(data, np.inf)
ks = kernel.shape[0]
for di in range(ks):
for dj in range(ks):
k_val = kernel[di, dj]
if k_val >= 1.0:
continue
shifted = padded[di:di + yres, dj:dj + xres] - k_val
bg = np.minimum(bg, shifted)
return (field.replace(data=data - bg), field.replace(data=bg))

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)

88
backend/nodes/unrotate.py Normal file
View File

@@ -0,0 +1,88 @@
"""Unrotate — auto-detect and correct in-plane scan rotation."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import rotate as ndimage_rotate
from backend.node_registry import register_node
from backend.data_types import DataField
def _slope_angle_histogram(data: np.ndarray, n_bins: int = 3600) -> np.ndarray:
"""Compute histogram of local slope angles over [0, 2*pi)."""
dy = np.diff(data, axis=0)[:, :-1]
dx = np.diff(data, axis=1)[:-1, :]
angles = np.arctan2(dy, dx) % (2 * np.pi)
hist, _ = np.histogram(angles.ravel(), bins=n_bins, range=(0, 2 * np.pi))
return hist.astype(np.float64)
def _find_dominant_angle(hist: np.ndarray, symmetry: int) -> float:
"""Find the rotation correction angle for a given symmetry order.
Folds the histogram into one symmetry sector, finds the peak, and
returns the offset to the nearest axis.
"""
n_bins = len(hist)
sector = n_bins // symmetry
folded = np.zeros(sector, dtype=np.float64)
for k in range(symmetry):
start = k * sector
end = start + sector
if end <= n_bins:
folded += hist[start:end]
peak_bin = int(np.argmax(folded))
bin_angle = (2 * np.pi / symmetry) / sector
# The angle of the peak
peak_angle = peak_bin * bin_angle
# The nearest axis is at multiples of pi/symmetry
axis_spacing = np.pi / symmetry
nearest_axis = round(peak_angle / axis_spacing) * axis_spacing
correction = nearest_axis - peak_angle
return float(correction)
@register_node(display_name="Unrotate")
class Unrotate:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"symmetry": (["2-fold", "3-fold", "4-fold", "6-fold"], {"default": "4-fold"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = (
"Auto-detect and correct in-plane scan rotation. Computes a slope "
"angle histogram, finds the dominant feature direction for the given "
"symmetry, and rotates the image to align features with the axes."
)
KEYWORDS = ("rotation", "alignment", "angle", "symmetry", "crystal")
def process(self, field: DataField, symmetry: str = "4-fold") -> tuple:
data = np.asarray(field.data, dtype=np.float64)
sym_order = int(symmetry[0])
hist = _slope_angle_histogram(data)
angle_rad = _find_dominant_angle(hist, sym_order)
angle_deg = float(np.degrees(angle_rad))
if abs(angle_deg) < 0.01:
return (field,)
rotated = ndimage_rotate(data, angle_deg, reshape=False, order=1,
mode='nearest')
return (field.replace(data=rotated),)

View File

@@ -0,0 +1,56 @@
"""Zero Value — shift data so the mean or maximum equals zero."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Zero Mean")
class ZeroMean:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = "Shift all values so the mean is exactly zero."
KEYWORDS = ("offset", "center", "level", "mean")
def process(self, field: DataField) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
return (field.replace(data=data - data.mean()),)
@register_node(display_name="Zero Maximum")
class ZeroMaximum:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = "Shift all values so the maximum is exactly zero."
KEYWORDS = ("offset", "level", "maximum")
def process(self, field: DataField) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
return (field.replace(data=data - data.max()),)

View File

@@ -0,0 +1,98 @@
# Missing Gwyddion Features
Gwyddion 2D image/surface processing features not yet implemented in tono. Excludes force curves, force volume, spectroscopy, volume data, XYZ data, graph operations, and file I/O.
## Leveling / Background Removal
- [x] **Arc Revolve** — Subtract cylindrical arc background fitted by revolving an arc under the data
- [x] **Sphere Revolve** — Subtract spherical cap background
- [x] **Unrotate** — Auto-detect and correct in-plane scan rotation by finding dominant feature directions
- [x] **Level Rotate** — Level by physically rotating the data plane rather than subtracting a polynomial
- [x] **Zero Mean Value** — Shift all values so the mean is exactly zero (pure offset, no plane fit)
- [x] **Zero Maximum Value** — Shift all values so the maximum is exactly zero
## Filtering / Signal Processing
- [ ] **2D CWT** — Continuous Wavelet Transform for scale-space analysis
- [ ] **XY Denoise** — Denoise by combining two orthogonal scans (forward/backward or horizontal/vertical)
- [ ] **Rank Presentation** — Rank transform image for local contrast enhancement
- [ ] **Radial Smoothing** — Smooth data in polar coordinates, averaging along radial or angular direction
- [ ] **Convolve Two Images** — Convolve two separate data channels together
## Line Correction / Scan Artifacts
- [ ] **Step Block Correction** — Correct vertical step offsets between scan lines by block-matching
- [ ] **Good Mean Profile** — Compute a high-quality average scan line from repeated scans
- [ ] **Align Rows (extended methods)** — Modus and Gaussian-weighted row alignment beyond tono's current set
## Correction / Restoration
- [ ] **Fractal Correction** — Fill masked/bad pixels using fractal interpolation (alternative to Laplace)
- [ ] **Correlation Averaging** — Average repeated similar structures using autocorrelation alignment
- [ ] **Coerce** — Force data to match the histogram distribution of another dataset
- [ ] **Periodic Translate** — Translate image data treating the field as periodic (wrap-around shift)
- [ ] **Reorder** — Reorder pixel rows/columns (interleaved to sequential, reverse scan, etc.)
## Statistical Analysis
- [ ] **Transfer Function Fit** — Fit PSF from a known reference image and a measured blurred image
- [ ] **Transfer Function Guess** — Estimate PSF from a single image without a reference
- [ ] **Angle Distribution** — Distribution of surface normal angles (distinct from slope distribution)
## Grain Operations
- [ ] **Otsu Threshold** — Automated grain/mask threshold using Otsu's method
- [ ] **Remove Edge-Touching Grains** — Remove all grains touching the image border from a mask
- [ ] **Grain Selection Shapes** — Create geometric selections (bounding boxes, inscribed discs, etc.) from grain masks
## Mask Operations
- [ ] **Mask Thin** — Morphological thinning to single-pixel-wide skeletons
- [ ] **Mask Distribute** — Copy/distribute a mask to multiple channels simultaneously
- [ ] **Mark With** — Create or modify a mask using arithmetic conditions on other channels
## Basic Operations
- [ ] **Invert Value** — Flip heights (z to -z)
- [ ] **Log Scale Presentation** — Log-scaled presentation layer without modifying source data
- [ ] **Limit Range** — Clamp data values to a specified min/max range
- [ ] **Square Samples** — Resample so pixels are physically square (equal x/y size)
- [ ] **Null Offsets** — Zero out the lateral (XY) origin offsets
## SPM-Specific Modes
- [ ] **MFM Field Simulation** — Simulate magnetic stray field above perpendicular media
- [ ] **MFM Parallel Media** — Simulate MFM signal for in-plane magnetic media
- [ ] **MFM Lift Shift** — Simulate MFM signal change when lift height changes
- [ ] **MFM Lift Estimate** — Estimate lift height difference from data blur
- [ ] **MFM Force Gradient** — Convert MFM raw data to force gradient units
- [ ] **SMM Apply Calibration** — Apply Scanning Microwave Microscopy calibration coefficients
## Synthetic Surface Generators
Tono has one generic Synthetic Surface node. Gwyddion has ~20+ specialized generators:
- [ ] **Fractional Brownian Motion** — fBm rough surfaces with controlled Hurst exponent
- [ ] **Spectral Synthesis** — PSD-specified random rough surfaces
- [ ] **Lattice** — Crystalline lattice surface with defects
- [ ] **Objects** — Randomly placed 3D objects (spheres, pyramids, etc.)
- [ ] **Patterns** — Geometric patterns (staircase, gratings, etc.)
- [ ] **Waves** — Sinusoidal/wave patterns
- [ ] **Noise** — Uncorrelated random noise with configurable distribution
- [ ] **Line Noise** — Synthetic scan-line noise/steps/scars for testing
- [ ] **Fibres** — Random fibre network surfaces
- [ ] **Domain Walls** — Phase-separated domain structures
- [ ] **Columnar Growth** — Columnar thin-film growth simulation
- [ ] **Ball Deposition** — Random ballistic deposition growth
- [ ] **Particle Deposition** — Dynamical particle deposition model
- [ ] **Rod Deposition** — Rod-like particle deposition
- [ ] **Diffusion** — Diffusion-limited aggregation surfaces
- [ ] **Discs** — Random overlapping disc surfaces
- [ ] **CPDE / Turing** — Reaction-diffusion / Turing pattern surfaces
- [ ] **Sand Dunes** — Aeolian sand transport simulation
- [ ] **Annealing Lattice Gas** — Annealed lattice-gas model textures
- [ ] **Phase Separation** — Spinodal decomposition textures
- [ ] **Pileup** — Piled-up ellipsoids or bars
- [ ] **Plateaus** — Stacked random plateau/terrace structures
- [ ] **Film Residue** — Residue left after simulated film removal
- [ ] **Wetting Front** — Propagating wetting front simulation

29
docs/nodes/Arc Revolve.md Normal file
View File

@@ -0,0 +1,29 @@
# Arc Revolve
Subtract a cylindrical arc background. A circular arc of the given radius is rolled under each row (or column), and the envelope it traces is subtracted as the background.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with arc background subtracted |
| background | DATA_FIELD | The estimated arc background |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| radius | INT | 20 | Arc radius in pixels (11000) |
| direction | dropdown | horizontal | Direction to apply the arc: horizontal, vertical, or both |
## Notes
- Larger radii produce smoother backgrounds that follow gentle curvature. Smaller radii track finer features.
- The "both" direction takes the minimum of horizontal and vertical backgrounds.
- Deep outliers are suppressed before fitting so that scratches or pits do not pull the arc down.

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

@@ -0,0 +1,21 @@
# Level Rotate
Level by physically rotating the data plane. Fits a best-fit plane, converts its slopes to tilt angles, then rotates the surface by those angles using interpolation rather than algebraic subtraction.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to level |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with tilt removed by rotation |
## Notes
- Unlike Plane Level (which subtracts a fitted plane), this node rotates the 3D surface to make it horizontal. The distinction matters for steep tilts where subtraction introduces distortion.
- Uses bilinear interpolation to resample rotated z-values.
- Edges are handled with nearest-neighbor extension.

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

@@ -0,0 +1,28 @@
# Sphere Revolve
Subtract a spherical cap background. A sphere of the given radius is rolled under the surface, and the envelope it traces is subtracted as the background.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with spherical background subtracted |
| background | DATA_FIELD | The estimated spherical background |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| radius | INT | 20 | Sphere radius in pixels (1500) |
## Notes
- Works like Arc Revolve but in two dimensions — suitable for bowl-shaped or dome-shaped backgrounds.
- Larger radii produce smoother backgrounds. Very small radii will track individual features.
- Deep outliers are suppressed before fitting.

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.

28
docs/nodes/Unrotate.md Normal file
View File

@@ -0,0 +1,28 @@
# Unrotate
Auto-detect and correct in-plane scan rotation. Computes a slope angle histogram, finds the dominant feature direction for the given symmetry, and rotates the image to align features with the axes.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to correct |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with rotation corrected |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| symmetry | dropdown | 4-fold | Expected symmetry of the surface features: 2-fold, 3-fold, 4-fold, or 6-fold |
## Notes
- Best suited for crystalline or patterned surfaces where features have a clear preferred direction.
- 4-fold symmetry is the most common choice for cubic crystal surfaces and rectangular gratings.
- If the detected rotation is less than 0.01°, the data is returned unchanged.
- Uses bilinear interpolation; edges are handled with nearest-neighbor extension.

View File

@@ -0,0 +1,20 @@
# Zero Maximum
Shift all values so the maximum is exactly zero. All resulting values will be zero or negative.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to level |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with maximum subtracted |
## Notes
- Equivalent to subtracting the global maximum from every pixel.
- Useful when the highest point should represent the zero reference.

20
docs/nodes/Zero Mean.md Normal file
View File

@@ -0,0 +1,20 @@
# Zero Mean
Shift all values so the mean is exactly zero. A pure offset subtraction — no plane fit or polynomial involved.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to level |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with mean subtracted |
## Notes
- Equivalent to subtracting a constant (the mean) from every pixel.
- Does not change relative height differences — only shifts the overall offset.

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

@@ -0,0 +1,55 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_arc_revolve_horizontal():
from backend.nodes.arc_revolve import ArcRevolve
node = ArcRevolve()
rng = np.random.default_rng(42)
x = np.linspace(0, 1, 64)
bow = 10.0 * x ** 2
data = bow[None, :] + rng.standard_normal((64, 64)) * 0.01
field = make_field(data=data)
leveled, bg = node.process(field, radius=40, direction="horizontal")
assert leveled.data.shape == field.data.shape
assert bg.data.shape == field.data.shape
assert np.allclose(leveled.data + bg.data, data)
def test_arc_revolve_vertical():
from backend.nodes.arc_revolve import ArcRevolve
node = ArcRevolve()
y = np.linspace(0, 1, 64)
data = (5.0 * y ** 2)[:, None] * np.ones((1, 64))
field = make_field(data=data)
leveled, bg = node.process(field, radius=40, direction="vertical")
assert np.allclose(leveled.data + bg.data, data)
def test_arc_revolve_both():
from backend.nodes.arc_revolve import ArcRevolve
node = ArcRevolve()
y, x = np.mgrid[:32, :32] / 32.0
data = 5.0 * x ** 2 + 3.0 * y ** 2
field = make_field(data=data)
leveled, bg = node.process(field, radius=30, direction="both")
assert leveled.data.shape == data.shape
assert bg.data.shape == data.shape
def test_arc_revolve_flat_passthrough():
from backend.nodes.arc_revolve import ArcRevolve
node = ArcRevolve()
data = np.ones((32, 32)) * 5.0
field = make_field(data=data)
leveled, bg = node.process(field, radius=20, direction="horizontal")
assert leveled.data.std() < 1e-10
assert np.allclose(leveled.data + bg.data, data)

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,37 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_level_rotate_removes_tilt():
from backend.nodes.level_rotate import LevelRotate
node = LevelRotate()
y, x = np.mgrid[:64, :64].astype(np.float64)
data = 2.0 * x + 3.0 * y
field = make_field(data=data)
(result,) = node.process(field)
assert result.data.shape == data.shape
assert result.data.std() < data.std() * 0.25
def test_level_rotate_preserves_shape():
from backend.nodes.level_rotate import LevelRotate
node = LevelRotate()
data = np.random.default_rng(42).standard_normal((48, 48))
field = make_field(data=data)
(result,) = node.process(field)
assert result.data.shape == (48, 48)
def test_level_rotate_flat_noop():
from backend.nodes.level_rotate import LevelRotate
node = LevelRotate()
data = np.ones((32, 32)) * 7.0
field = make_field(data=data)
(result,) = node.process(field)
assert np.allclose(result.data, 7.0, atol=1e-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

@@ -0,0 +1,41 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_sphere_revolve_basic():
from backend.nodes.sphere_revolve import SphereRevolve
node = SphereRevolve()
y, x = np.mgrid[:64, :64] / 64.0
data = 10.0 * (x ** 2 + y ** 2)
field = make_field(data=data)
leveled, bg = node.process(field, radius=30)
assert leveled.data.shape == data.shape
assert bg.data.shape == data.shape
assert np.allclose(leveled.data + bg.data, data)
def test_sphere_revolve_flat():
from backend.nodes.sphere_revolve import SphereRevolve
node = SphereRevolve()
data = np.ones((32, 32)) * 3.0
field = make_field(data=data)
leveled, bg = node.process(field, radius=20)
assert leveled.data.std() < 1e-10
assert np.allclose(leveled.data + bg.data, data)
def test_sphere_revolve_outputs_two_fields():
from backend.nodes.sphere_revolve import SphereRevolve
node = SphereRevolve()
data = np.random.default_rng(7).standard_normal((32, 32))
field = make_field(data=data)
result = node.process(field, radius=15)
assert len(result) == 2
leveled, bg = result
assert np.allclose(leveled.data + bg.data, data)

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]

View File

@@ -0,0 +1,50 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_unrotate_preserves_shape():
from backend.nodes.unrotate import Unrotate
node = Unrotate()
data = np.random.default_rng(42).standard_normal((64, 64))
field = make_field(data=data)
(result,) = node.process(field, symmetry="4-fold")
assert result.data.shape == (64, 64)
def test_unrotate_small_angle():
from backend.nodes.unrotate import Unrotate, _slope_angle_histogram, _find_dominant_angle
y, x = np.mgrid[:128, :128].astype(np.float64)
angle_deg = 3.0
angle_rad = np.radians(angle_deg)
data = np.sin(2 * np.pi * (x * np.cos(angle_rad) + y * np.sin(angle_rad)) / 20.0)
hist = _slope_angle_histogram(data)
correction = _find_dominant_angle(hist, 4)
assert abs(np.degrees(correction)) < 10.0
def test_unrotate_no_rotation_passthrough():
from backend.nodes.unrotate import Unrotate
node = Unrotate()
y, x = np.mgrid[:64, :64].astype(np.float64)
data = np.sin(2 * np.pi * x / 16.0)
field = make_field(data=data)
(result,) = node.process(field, symmetry="4-fold")
assert np.allclose(result.data, data, atol=0.1)
def test_unrotate_symmetry_options():
from backend.nodes.unrotate import Unrotate
node = Unrotate()
data = np.random.default_rng(99).standard_normal((64, 64))
field = make_field(data=data)
for sym in ["2-fold", "3-fold", "4-fold", "6-fold"]:
(result,) = node.process(field, symmetry=sym)
assert result.data.shape == (64, 64)

View File

@@ -0,0 +1,46 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_zero_mean():
from backend.nodes.zero_value import ZeroMean
node = ZeroMean()
data = np.random.default_rng(42).standard_normal((64, 64)) + 100.0
field = make_field(data=data)
(result,) = node.process(field)
assert result.data.shape == field.data.shape
assert abs(result.data.mean()) < 1e-10
def test_zero_mean_preserves_variation():
from backend.nodes.zero_value import ZeroMean
node = ZeroMean()
data = np.random.default_rng(7).standard_normal((32, 32)) + 50.0
field = make_field(data=data)
(result,) = node.process(field)
assert np.allclose(result.data - result.data.mean(), data - data.mean())
def test_zero_maximum():
from backend.nodes.zero_value import ZeroMaximum
node = ZeroMaximum()
data = np.random.default_rng(42).standard_normal((64, 64)) + 100.0
field = make_field(data=data)
(result,) = node.process(field)
assert result.data.shape == field.data.shape
assert abs(result.data.max()) < 1e-10
assert result.data.min() < 0
def test_zero_maximum_preserves_differences():
from backend.nodes.zero_value import ZeroMaximum
node = ZeroMaximum()
data = np.array([[1.0, 3.0], [2.0, 5.0]])
field = make_field(data=data)
(result,) = node.process(field)
expected = data - 5.0
assert np.allclose(result.data, expected)