Compare commits
11 Commits
1d98ccf190
...
92ede31867
| Author | SHA1 | Date | |
|---|---|---|---|
| 92ede31867 | |||
| d35cdd6971 | |||
| a4c8d2b01c | |||
| 924b29757f | |||
| ad48a40edc | |||
| c7e7531206 | |||
| 2d66eaef02 | |||
| 9fbd305854 | |||
| 31422e76db | |||
| 349142f0e6 | |||
| 0bf001c24b |
@@ -150,6 +150,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"MarkDisconnected",
|
||||
"MaskShift",
|
||||
"MaskNoisify",
|
||||
"RectangularMask",
|
||||
],
|
||||
"Grains": [
|
||||
"GrainDistanceTransform",
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
113
backend/nodes/mask_rectangular.py
Normal file
113
backend/nodes/mask_rectangular.py
Normal 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,)
|
||||
@@ -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),)
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
34
docs/nodes/Rectangular Mask.md
Normal file
34
docs/nodes/Rectangular Mask.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? ''}
|
||||
|
||||
89
frontend/src/MultiProfileOverlay.tsx
Normal file
89
frontend/src/MultiProfileOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend/src/PerspectiveOverlay.tsx
Normal file
150
frontend/src/PerspectiveOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
frontend/src/RadialProfileOverlay.tsx
Normal file
125
frontend/src/RadialProfileOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
frontend/src/StraightenPathOverlay.tsx
Normal file
213
frontend/src/StraightenPathOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
68
frontend/src/favorites.ts
Normal 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
46
frontend/src/nodeUsage.ts
Normal 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];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
143
tests/node_tests/mask_rectangular.py
Normal file
143
tests/node_tests/mask_rectangular.py
Normal 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,")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user