add a few more nodes
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
This commit is contained in:
88
backend/nodes/arc_revolve.py
Normal file
88
backend/nodes/arc_revolve.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Arc Revolve — subtract a cylindrical arc background."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
def _arc_kernel(radius: int) -> np.ndarray:
|
||||||
|
"""Build a 1D arc kernel: z = 1 - sqrt(1 - (i/radius)^2)."""
|
||||||
|
half = min(radius, 4096)
|
||||||
|
i = np.arange(-half, half + 1, dtype=np.float64)
|
||||||
|
t = np.clip((i / radius) ** 2, 0.0, 1.0)
|
||||||
|
return 1.0 - np.sqrt(1.0 - t)
|
||||||
|
|
||||||
|
|
||||||
|
def _arc_revolve_1d(data: np.ndarray, radius: int) -> np.ndarray:
|
||||||
|
"""Compute arc-revolve background for each row independently."""
|
||||||
|
yres, xres = data.shape
|
||||||
|
kernel = _arc_kernel(radius)
|
||||||
|
half = len(kernel) // 2
|
||||||
|
bg = np.empty_like(data)
|
||||||
|
|
||||||
|
for row in range(yres):
|
||||||
|
line = data[row].copy()
|
||||||
|
# Suppress deep outliers before fitting
|
||||||
|
window = min(half, xres // 2)
|
||||||
|
if window > 0:
|
||||||
|
from scipy.ndimage import uniform_filter1d
|
||||||
|
local_mean = uniform_filter1d(line, size=2 * window + 1, mode='nearest')
|
||||||
|
local_sq = uniform_filter1d(line ** 2, size=2 * window + 1, mode='nearest')
|
||||||
|
local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0))
|
||||||
|
threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30)
|
||||||
|
line = np.maximum(line, threshold)
|
||||||
|
|
||||||
|
# For each pixel, find the lowest position the arc can sit
|
||||||
|
padded = np.pad(line, half, mode='edge')
|
||||||
|
row_bg = np.full(xres, np.inf)
|
||||||
|
for k in range(len(kernel)):
|
||||||
|
shifted = padded[k:k + xres] - kernel[k]
|
||||||
|
row_bg = np.minimum(row_bg, shifted)
|
||||||
|
bg[row] = row_bg
|
||||||
|
|
||||||
|
return bg
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Arc Revolve")
|
||||||
|
class ArcRevolve:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"radius": ("INT", {"default": 20, "min": 1, "max": 1000, "step": 1}),
|
||||||
|
"direction": (["horizontal", "vertical", "both"], {"default": "horizontal"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'leveled'),
|
||||||
|
('DATA_FIELD', 'background'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Subtract a cylindrical arc background. A circular arc of the given "
|
||||||
|
"radius is rolled under each row (or column), and the envelope it "
|
||||||
|
"traces out is subtracted as the background."
|
||||||
|
)
|
||||||
|
|
||||||
|
KEYWORDS = ("arc", "revolve", "cylindrical", "background", "level")
|
||||||
|
|
||||||
|
def process(self, field: DataField, radius: int = 20,
|
||||||
|
direction: str = "horizontal") -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
|
||||||
|
if direction == "horizontal":
|
||||||
|
bg = _arc_revolve_1d(data, radius)
|
||||||
|
elif direction == "vertical":
|
||||||
|
bg = _arc_revolve_1d(data.T, radius).T
|
||||||
|
else:
|
||||||
|
bg_h = _arc_revolve_1d(data, radius)
|
||||||
|
bg_v = _arc_revolve_1d(data.T, radius).T
|
||||||
|
bg = np.minimum(bg_h, bg_v)
|
||||||
|
|
||||||
|
return (field.replace(data=data - bg), field.replace(data=bg))
|
||||||
75
backend/nodes/level_rotate.py
Normal file
75
backend/nodes/level_rotate.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Level Rotate — level by physically rotating the data plane."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Level Rotate")
|
||||||
|
class LevelRotate:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'leveled'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Level by physically rotating the data plane. Fits a best-fit plane, "
|
||||||
|
"converts its slopes to tilt angles, then rotates the surface by "
|
||||||
|
"those angles using interpolation rather than algebraic subtraction."
|
||||||
|
)
|
||||||
|
|
||||||
|
KEYWORDS = ("rotate", "tilt", "level", "plane")
|
||||||
|
|
||||||
|
def process(self, field: DataField) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
# Fit plane: z = a + bx*x + by*y (x,y in pixel coords)
|
||||||
|
yy, xx = np.mgrid[:yres, :xres].astype(np.float64)
|
||||||
|
A = np.column_stack([np.ones(yres * xres), xx.ravel(), yy.ravel()])
|
||||||
|
coeffs, _, _, _ = np.linalg.lstsq(A, data.ravel(), rcond=None)
|
||||||
|
_, bx, by = coeffs
|
||||||
|
|
||||||
|
# Convert pixel slopes to tilt angles
|
||||||
|
alpha_x = np.arctan(bx)
|
||||||
|
alpha_y = np.arctan(by)
|
||||||
|
|
||||||
|
# Build rotation: for each output pixel, find where it came from
|
||||||
|
cx = (xres - 1) / 2.0
|
||||||
|
cy = (yres - 1) / 2.0
|
||||||
|
|
||||||
|
cos_x = np.cos(alpha_x)
|
||||||
|
cos_y = np.cos(alpha_y)
|
||||||
|
|
||||||
|
# Source coordinates after removing tilt
|
||||||
|
src_x = xx.copy()
|
||||||
|
src_y = yy.copy()
|
||||||
|
src_z = data.copy()
|
||||||
|
|
||||||
|
# Rotate about x-axis (corrects y-tilt)
|
||||||
|
dy = yy - cy
|
||||||
|
src_y_rot = cy + dy * cos_y
|
||||||
|
src_z = src_z - dy * np.sin(alpha_y)
|
||||||
|
|
||||||
|
# Rotate about y-axis (corrects x-tilt)
|
||||||
|
dx = xx - cx
|
||||||
|
src_x_rot = cx + dx * cos_x
|
||||||
|
src_z = src_z - dx * np.sin(alpha_x)
|
||||||
|
|
||||||
|
# Resample with the adjusted z values
|
||||||
|
result = map_coordinates(src_z, [src_y_rot, src_x_rot], order=1,
|
||||||
|
mode='nearest')
|
||||||
|
|
||||||
|
return (field.replace(data=result),)
|
||||||
74
backend/nodes/sphere_revolve.py
Normal file
74
backend/nodes/sphere_revolve.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Sphere Revolve — subtract a spherical cap background."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import uniform_filter
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
def _sphere_kernel(radius: int) -> np.ndarray:
|
||||||
|
"""Build a 2D spherical cap kernel."""
|
||||||
|
half = min(radius, 512)
|
||||||
|
i = np.arange(-half, half + 1, dtype=np.float64)
|
||||||
|
ii, jj = np.meshgrid(i, i)
|
||||||
|
r2 = (ii ** 2 + jj ** 2) / (radius ** 2)
|
||||||
|
r2 = np.clip(r2, 0.0, 1.0)
|
||||||
|
return 1.0 - np.sqrt(1.0 - r2)
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Sphere Revolve")
|
||||||
|
class SphereRevolve:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"radius": ("INT", {"default": 20, "min": 1, "max": 500, "step": 1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'leveled'),
|
||||||
|
('DATA_FIELD', 'background'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Subtract a spherical cap background. A sphere of the given radius "
|
||||||
|
"is rolled under the surface, and the envelope it traces is "
|
||||||
|
"subtracted as the background."
|
||||||
|
)
|
||||||
|
|
||||||
|
KEYWORDS = ("sphere", "revolve", "spherical", "background", "level")
|
||||||
|
|
||||||
|
def process(self, field: DataField, radius: int = 20) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
kernel = _sphere_kernel(radius)
|
||||||
|
half = kernel.shape[0] // 2
|
||||||
|
|
||||||
|
# Suppress deep outliers
|
||||||
|
window = max(1, half // 2)
|
||||||
|
local_mean = uniform_filter(data, size=2 * window + 1, mode='nearest')
|
||||||
|
local_sq = uniform_filter(data ** 2, size=2 * window + 1, mode='nearest')
|
||||||
|
local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0))
|
||||||
|
threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30)
|
||||||
|
clipped = np.maximum(data, threshold)
|
||||||
|
|
||||||
|
padded = np.pad(clipped, half, mode='edge')
|
||||||
|
bg = np.full_like(data, np.inf)
|
||||||
|
|
||||||
|
ks = kernel.shape[0]
|
||||||
|
for di in range(ks):
|
||||||
|
for dj in range(ks):
|
||||||
|
k_val = kernel[di, dj]
|
||||||
|
if k_val >= 1.0:
|
||||||
|
continue
|
||||||
|
shifted = padded[di:di + yres, dj:dj + xres] - k_val
|
||||||
|
bg = np.minimum(bg, shifted)
|
||||||
|
|
||||||
|
return (field.replace(data=data - bg), field.replace(data=bg))
|
||||||
88
backend/nodes/unrotate.py
Normal file
88
backend/nodes/unrotate.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Unrotate — auto-detect and correct in-plane scan rotation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import rotate as ndimage_rotate
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
def _slope_angle_histogram(data: np.ndarray, n_bins: int = 3600) -> np.ndarray:
|
||||||
|
"""Compute histogram of local slope angles over [0, 2*pi)."""
|
||||||
|
dy = np.diff(data, axis=0)[:, :-1]
|
||||||
|
dx = np.diff(data, axis=1)[:-1, :]
|
||||||
|
angles = np.arctan2(dy, dx) % (2 * np.pi)
|
||||||
|
hist, _ = np.histogram(angles.ravel(), bins=n_bins, range=(0, 2 * np.pi))
|
||||||
|
return hist.astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_dominant_angle(hist: np.ndarray, symmetry: int) -> float:
|
||||||
|
"""Find the rotation correction angle for a given symmetry order.
|
||||||
|
|
||||||
|
Folds the histogram into one symmetry sector, finds the peak, and
|
||||||
|
returns the offset to the nearest axis.
|
||||||
|
"""
|
||||||
|
n_bins = len(hist)
|
||||||
|
sector = n_bins // symmetry
|
||||||
|
folded = np.zeros(sector, dtype=np.float64)
|
||||||
|
for k in range(symmetry):
|
||||||
|
start = k * sector
|
||||||
|
end = start + sector
|
||||||
|
if end <= n_bins:
|
||||||
|
folded += hist[start:end]
|
||||||
|
|
||||||
|
peak_bin = int(np.argmax(folded))
|
||||||
|
bin_angle = (2 * np.pi / symmetry) / sector
|
||||||
|
|
||||||
|
# The angle of the peak
|
||||||
|
peak_angle = peak_bin * bin_angle
|
||||||
|
|
||||||
|
# The nearest axis is at multiples of pi/symmetry
|
||||||
|
axis_spacing = np.pi / symmetry
|
||||||
|
nearest_axis = round(peak_angle / axis_spacing) * axis_spacing
|
||||||
|
correction = nearest_axis - peak_angle
|
||||||
|
|
||||||
|
return float(correction)
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Unrotate")
|
||||||
|
class Unrotate:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"symmetry": (["2-fold", "3-fold", "4-fold", "6-fold"], {"default": "4-fold"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'leveled'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Auto-detect and correct in-plane scan rotation. Computes a slope "
|
||||||
|
"angle histogram, finds the dominant feature direction for the given "
|
||||||
|
"symmetry, and rotates the image to align features with the axes."
|
||||||
|
)
|
||||||
|
|
||||||
|
KEYWORDS = ("rotation", "alignment", "angle", "symmetry", "crystal")
|
||||||
|
|
||||||
|
def process(self, field: DataField, symmetry: str = "4-fold") -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
|
||||||
|
sym_order = int(symmetry[0])
|
||||||
|
hist = _slope_angle_histogram(data)
|
||||||
|
angle_rad = _find_dominant_angle(hist, sym_order)
|
||||||
|
angle_deg = float(np.degrees(angle_rad))
|
||||||
|
|
||||||
|
if abs(angle_deg) < 0.01:
|
||||||
|
return (field,)
|
||||||
|
|
||||||
|
rotated = ndimage_rotate(data, angle_deg, reshape=False, order=1,
|
||||||
|
mode='nearest')
|
||||||
|
|
||||||
|
return (field.replace(data=rotated),)
|
||||||
56
backend/nodes/zero_value.py
Normal file
56
backend/nodes/zero_value.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Zero Value — shift data so the mean or maximum equals zero."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Zero Mean")
|
||||||
|
class ZeroMean:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'leveled'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = "Shift all values so the mean is exactly zero."
|
||||||
|
|
||||||
|
KEYWORDS = ("offset", "center", "level", "mean")
|
||||||
|
|
||||||
|
def process(self, field: DataField) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
return (field.replace(data=data - data.mean()),)
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Zero Maximum")
|
||||||
|
class ZeroMaximum:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'leveled'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = "Shift all values so the maximum is exactly zero."
|
||||||
|
|
||||||
|
KEYWORDS = ("offset", "level", "maximum")
|
||||||
|
|
||||||
|
def process(self, field: DataField) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
return (field.replace(data=data - data.max()),)
|
||||||
98
docs/missing_gwyddion_features.md
Normal file
98
docs/missing_gwyddion_features.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Missing Gwyddion Features
|
||||||
|
|
||||||
|
Gwyddion 2D image/surface processing features not yet implemented in tono. Excludes force curves, force volume, spectroscopy, volume data, XYZ data, graph operations, and file I/O.
|
||||||
|
|
||||||
|
## Leveling / Background Removal
|
||||||
|
|
||||||
|
- [x] **Arc Revolve** — Subtract cylindrical arc background fitted by revolving an arc under the data
|
||||||
|
- [x] **Sphere Revolve** — Subtract spherical cap background
|
||||||
|
- [x] **Unrotate** — Auto-detect and correct in-plane scan rotation by finding dominant feature directions
|
||||||
|
- [x] **Level Rotate** — Level by physically rotating the data plane rather than subtracting a polynomial
|
||||||
|
- [x] **Zero Mean Value** — Shift all values so the mean is exactly zero (pure offset, no plane fit)
|
||||||
|
- [x] **Zero Maximum Value** — Shift all values so the maximum is exactly zero
|
||||||
|
|
||||||
|
## Filtering / Signal Processing
|
||||||
|
|
||||||
|
- [ ] **2D CWT** — Continuous Wavelet Transform for scale-space analysis
|
||||||
|
- [ ] **XY Denoise** — Denoise by combining two orthogonal scans (forward/backward or horizontal/vertical)
|
||||||
|
- [ ] **Rank Presentation** — Rank transform image for local contrast enhancement
|
||||||
|
- [ ] **Radial Smoothing** — Smooth data in polar coordinates, averaging along radial or angular direction
|
||||||
|
- [ ] **Convolve Two Images** — Convolve two separate data channels together
|
||||||
|
|
||||||
|
## Line Correction / Scan Artifacts
|
||||||
|
|
||||||
|
- [ ] **Step Block Correction** — Correct vertical step offsets between scan lines by block-matching
|
||||||
|
- [ ] **Good Mean Profile** — Compute a high-quality average scan line from repeated scans
|
||||||
|
- [ ] **Align Rows (extended methods)** — Modus and Gaussian-weighted row alignment beyond tono's current set
|
||||||
|
|
||||||
|
## Correction / Restoration
|
||||||
|
|
||||||
|
- [ ] **Fractal Correction** — Fill masked/bad pixels using fractal interpolation (alternative to Laplace)
|
||||||
|
- [ ] **Correlation Averaging** — Average repeated similar structures using autocorrelation alignment
|
||||||
|
- [ ] **Coerce** — Force data to match the histogram distribution of another dataset
|
||||||
|
- [ ] **Periodic Translate** — Translate image data treating the field as periodic (wrap-around shift)
|
||||||
|
- [ ] **Reorder** — Reorder pixel rows/columns (interleaved to sequential, reverse scan, etc.)
|
||||||
|
|
||||||
|
## Statistical Analysis
|
||||||
|
|
||||||
|
- [ ] **Transfer Function Fit** — Fit PSF from a known reference image and a measured blurred image
|
||||||
|
- [ ] **Transfer Function Guess** — Estimate PSF from a single image without a reference
|
||||||
|
- [ ] **Angle Distribution** — Distribution of surface normal angles (distinct from slope distribution)
|
||||||
|
|
||||||
|
## Grain Operations
|
||||||
|
|
||||||
|
- [ ] **Otsu Threshold** — Automated grain/mask threshold using Otsu's method
|
||||||
|
- [ ] **Remove Edge-Touching Grains** — Remove all grains touching the image border from a mask
|
||||||
|
- [ ] **Grain Selection Shapes** — Create geometric selections (bounding boxes, inscribed discs, etc.) from grain masks
|
||||||
|
|
||||||
|
## Mask Operations
|
||||||
|
|
||||||
|
- [ ] **Mask Thin** — Morphological thinning to single-pixel-wide skeletons
|
||||||
|
- [ ] **Mask Distribute** — Copy/distribute a mask to multiple channels simultaneously
|
||||||
|
- [ ] **Mark With** — Create or modify a mask using arithmetic conditions on other channels
|
||||||
|
|
||||||
|
## Basic Operations
|
||||||
|
|
||||||
|
- [ ] **Invert Value** — Flip heights (z to -z)
|
||||||
|
- [ ] **Log Scale Presentation** — Log-scaled presentation layer without modifying source data
|
||||||
|
- [ ] **Limit Range** — Clamp data values to a specified min/max range
|
||||||
|
- [ ] **Square Samples** — Resample so pixels are physically square (equal x/y size)
|
||||||
|
- [ ] **Null Offsets** — Zero out the lateral (XY) origin offsets
|
||||||
|
|
||||||
|
## SPM-Specific Modes
|
||||||
|
|
||||||
|
- [ ] **MFM Field Simulation** — Simulate magnetic stray field above perpendicular media
|
||||||
|
- [ ] **MFM Parallel Media** — Simulate MFM signal for in-plane magnetic media
|
||||||
|
- [ ] **MFM Lift Shift** — Simulate MFM signal change when lift height changes
|
||||||
|
- [ ] **MFM Lift Estimate** — Estimate lift height difference from data blur
|
||||||
|
- [ ] **MFM Force Gradient** — Convert MFM raw data to force gradient units
|
||||||
|
- [ ] **SMM Apply Calibration** — Apply Scanning Microwave Microscopy calibration coefficients
|
||||||
|
|
||||||
|
## Synthetic Surface Generators
|
||||||
|
|
||||||
|
Tono has one generic Synthetic Surface node. Gwyddion has ~20+ specialized generators:
|
||||||
|
|
||||||
|
- [ ] **Fractional Brownian Motion** — fBm rough surfaces with controlled Hurst exponent
|
||||||
|
- [ ] **Spectral Synthesis** — PSD-specified random rough surfaces
|
||||||
|
- [ ] **Lattice** — Crystalline lattice surface with defects
|
||||||
|
- [ ] **Objects** — Randomly placed 3D objects (spheres, pyramids, etc.)
|
||||||
|
- [ ] **Patterns** — Geometric patterns (staircase, gratings, etc.)
|
||||||
|
- [ ] **Waves** — Sinusoidal/wave patterns
|
||||||
|
- [ ] **Noise** — Uncorrelated random noise with configurable distribution
|
||||||
|
- [ ] **Line Noise** — Synthetic scan-line noise/steps/scars for testing
|
||||||
|
- [ ] **Fibres** — Random fibre network surfaces
|
||||||
|
- [ ] **Domain Walls** — Phase-separated domain structures
|
||||||
|
- [ ] **Columnar Growth** — Columnar thin-film growth simulation
|
||||||
|
- [ ] **Ball Deposition** — Random ballistic deposition growth
|
||||||
|
- [ ] **Particle Deposition** — Dynamical particle deposition model
|
||||||
|
- [ ] **Rod Deposition** — Rod-like particle deposition
|
||||||
|
- [ ] **Diffusion** — Diffusion-limited aggregation surfaces
|
||||||
|
- [ ] **Discs** — Random overlapping disc surfaces
|
||||||
|
- [ ] **CPDE / Turing** — Reaction-diffusion / Turing pattern surfaces
|
||||||
|
- [ ] **Sand Dunes** — Aeolian sand transport simulation
|
||||||
|
- [ ] **Annealing Lattice Gas** — Annealed lattice-gas model textures
|
||||||
|
- [ ] **Phase Separation** — Spinodal decomposition textures
|
||||||
|
- [ ] **Pileup** — Piled-up ellipsoids or bars
|
||||||
|
- [ ] **Plateaus** — Stacked random plateau/terrace structures
|
||||||
|
- [ ] **Film Residue** — Residue left after simulated film removal
|
||||||
|
- [ ] **Wetting Front** — Propagating wetting front simulation
|
||||||
29
docs/nodes/Arc Revolve.md
Normal file
29
docs/nodes/Arc Revolve.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Arc Revolve
|
||||||
|
|
||||||
|
Subtract a cylindrical arc background. A circular arc of the given radius is rolled under each row (or column), and the envelope it traces is subtracted as the background.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| leveled | DATA_FIELD | Field with arc background subtracted |
|
||||||
|
| background | DATA_FIELD | The estimated arc background |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| radius | INT | 20 | Arc radius in pixels (1–1000) |
|
||||||
|
| direction | dropdown | horizontal | Direction to apply the arc: horizontal, vertical, or both |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Larger radii produce smoother backgrounds that follow gentle curvature. Smaller radii track finer features.
|
||||||
|
- The "both" direction takes the minimum of horizontal and vertical backgrounds.
|
||||||
|
- Deep outliers are suppressed before fitting so that scratches or pits do not pull the arc down.
|
||||||
21
docs/nodes/Level Rotate.md
Normal file
21
docs/nodes/Level Rotate.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Level Rotate
|
||||||
|
|
||||||
|
Level by physically rotating the data plane. Fits a best-fit plane, converts its slopes to tilt angles, then rotates the surface by those angles using interpolation rather than algebraic subtraction.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field to level |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| leveled | DATA_FIELD | Field with tilt removed by rotation |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Unlike Plane Level (which subtracts a fitted plane), this node rotates the 3D surface to make it horizontal. The distinction matters for steep tilts where subtraction introduces distortion.
|
||||||
|
- Uses bilinear interpolation to resample rotated z-values.
|
||||||
|
- Edges are handled with nearest-neighbor extension.
|
||||||
28
docs/nodes/Sphere Revolve.md
Normal file
28
docs/nodes/Sphere Revolve.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Sphere Revolve
|
||||||
|
|
||||||
|
Subtract a spherical cap background. A sphere of the given radius is rolled under the surface, and the envelope it traces is subtracted as the background.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| leveled | DATA_FIELD | Field with spherical background subtracted |
|
||||||
|
| background | DATA_FIELD | The estimated spherical background |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| radius | INT | 20 | Sphere radius in pixels (1–500) |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Works like Arc Revolve but in two dimensions — suitable for bowl-shaped or dome-shaped backgrounds.
|
||||||
|
- Larger radii produce smoother backgrounds. Very small radii will track individual features.
|
||||||
|
- Deep outliers are suppressed before fitting.
|
||||||
28
docs/nodes/Unrotate.md
Normal file
28
docs/nodes/Unrotate.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Unrotate
|
||||||
|
|
||||||
|
Auto-detect and correct in-plane scan rotation. Computes a slope angle histogram, finds the dominant feature direction for the given symmetry, and rotates the image to align features with the axes.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field to correct |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| leveled | DATA_FIELD | Field with rotation corrected |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| symmetry | dropdown | 4-fold | Expected symmetry of the surface features: 2-fold, 3-fold, 4-fold, or 6-fold |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Best suited for crystalline or patterned surfaces where features have a clear preferred direction.
|
||||||
|
- 4-fold symmetry is the most common choice for cubic crystal surfaces and rectangular gratings.
|
||||||
|
- If the detected rotation is less than 0.01°, the data is returned unchanged.
|
||||||
|
- Uses bilinear interpolation; edges are handled with nearest-neighbor extension.
|
||||||
20
docs/nodes/Zero Maximum.md
Normal file
20
docs/nodes/Zero Maximum.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Zero Maximum
|
||||||
|
|
||||||
|
Shift all values so the maximum is exactly zero. All resulting values will be zero or negative.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field to level |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| leveled | DATA_FIELD | Field with maximum subtracted |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Equivalent to subtracting the global maximum from every pixel.
|
||||||
|
- Useful when the highest point should represent the zero reference.
|
||||||
20
docs/nodes/Zero Mean.md
Normal file
20
docs/nodes/Zero Mean.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Zero Mean
|
||||||
|
|
||||||
|
Shift all values so the mean is exactly zero. A pure offset subtraction — no plane fit or polynomial involved.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field to level |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| leveled | DATA_FIELD | Field with mean subtracted |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Equivalent to subtracting a constant (the mean) from every pixel.
|
||||||
|
- Does not change relative height differences — only shifts the overall offset.
|
||||||
55
tests/node_tests/arc_revolve.py
Normal file
55
tests/node_tests/arc_revolve.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import numpy as np
|
||||||
|
from tests.node_tests._shared import make_field
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_revolve_horizontal():
|
||||||
|
from backend.nodes.arc_revolve import ArcRevolve
|
||||||
|
|
||||||
|
node = ArcRevolve()
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
x = np.linspace(0, 1, 64)
|
||||||
|
bow = 10.0 * x ** 2
|
||||||
|
data = bow[None, :] + rng.standard_normal((64, 64)) * 0.01
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
leveled, bg = node.process(field, radius=40, direction="horizontal")
|
||||||
|
assert leveled.data.shape == field.data.shape
|
||||||
|
assert bg.data.shape == field.data.shape
|
||||||
|
assert np.allclose(leveled.data + bg.data, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_revolve_vertical():
|
||||||
|
from backend.nodes.arc_revolve import ArcRevolve
|
||||||
|
|
||||||
|
node = ArcRevolve()
|
||||||
|
y = np.linspace(0, 1, 64)
|
||||||
|
data = (5.0 * y ** 2)[:, None] * np.ones((1, 64))
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
leveled, bg = node.process(field, radius=40, direction="vertical")
|
||||||
|
assert np.allclose(leveled.data + bg.data, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_revolve_both():
|
||||||
|
from backend.nodes.arc_revolve import ArcRevolve
|
||||||
|
|
||||||
|
node = ArcRevolve()
|
||||||
|
y, x = np.mgrid[:32, :32] / 32.0
|
||||||
|
data = 5.0 * x ** 2 + 3.0 * y ** 2
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
leveled, bg = node.process(field, radius=30, direction="both")
|
||||||
|
assert leveled.data.shape == data.shape
|
||||||
|
assert bg.data.shape == data.shape
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_revolve_flat_passthrough():
|
||||||
|
from backend.nodes.arc_revolve import ArcRevolve
|
||||||
|
|
||||||
|
node = ArcRevolve()
|
||||||
|
data = np.ones((32, 32)) * 5.0
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
leveled, bg = node.process(field, radius=20, direction="horizontal")
|
||||||
|
assert leveled.data.std() < 1e-10
|
||||||
|
assert np.allclose(leveled.data + bg.data, data)
|
||||||
37
tests/node_tests/level_rotate.py
Normal file
37
tests/node_tests/level_rotate.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import numpy as np
|
||||||
|
from tests.node_tests._shared import make_field
|
||||||
|
|
||||||
|
|
||||||
|
def test_level_rotate_removes_tilt():
|
||||||
|
from backend.nodes.level_rotate import LevelRotate
|
||||||
|
|
||||||
|
node = LevelRotate()
|
||||||
|
y, x = np.mgrid[:64, :64].astype(np.float64)
|
||||||
|
data = 2.0 * x + 3.0 * y
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
(result,) = node.process(field)
|
||||||
|
assert result.data.shape == data.shape
|
||||||
|
assert result.data.std() < data.std() * 0.25
|
||||||
|
|
||||||
|
|
||||||
|
def test_level_rotate_preserves_shape():
|
||||||
|
from backend.nodes.level_rotate import LevelRotate
|
||||||
|
|
||||||
|
node = LevelRotate()
|
||||||
|
data = np.random.default_rng(42).standard_normal((48, 48))
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
(result,) = node.process(field)
|
||||||
|
assert result.data.shape == (48, 48)
|
||||||
|
|
||||||
|
|
||||||
|
def test_level_rotate_flat_noop():
|
||||||
|
from backend.nodes.level_rotate import LevelRotate
|
||||||
|
|
||||||
|
node = LevelRotate()
|
||||||
|
data = np.ones((32, 32)) * 7.0
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
(result,) = node.process(field)
|
||||||
|
assert np.allclose(result.data, 7.0, atol=1e-6)
|
||||||
41
tests/node_tests/sphere_revolve.py
Normal file
41
tests/node_tests/sphere_revolve.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import numpy as np
|
||||||
|
from tests.node_tests._shared import make_field
|
||||||
|
|
||||||
|
|
||||||
|
def test_sphere_revolve_basic():
|
||||||
|
from backend.nodes.sphere_revolve import SphereRevolve
|
||||||
|
|
||||||
|
node = SphereRevolve()
|
||||||
|
y, x = np.mgrid[:64, :64] / 64.0
|
||||||
|
data = 10.0 * (x ** 2 + y ** 2)
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
leveled, bg = node.process(field, radius=30)
|
||||||
|
assert leveled.data.shape == data.shape
|
||||||
|
assert bg.data.shape == data.shape
|
||||||
|
assert np.allclose(leveled.data + bg.data, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sphere_revolve_flat():
|
||||||
|
from backend.nodes.sphere_revolve import SphereRevolve
|
||||||
|
|
||||||
|
node = SphereRevolve()
|
||||||
|
data = np.ones((32, 32)) * 3.0
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
leveled, bg = node.process(field, radius=20)
|
||||||
|
assert leveled.data.std() < 1e-10
|
||||||
|
assert np.allclose(leveled.data + bg.data, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sphere_revolve_outputs_two_fields():
|
||||||
|
from backend.nodes.sphere_revolve import SphereRevolve
|
||||||
|
|
||||||
|
node = SphereRevolve()
|
||||||
|
data = np.random.default_rng(7).standard_normal((32, 32))
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
result = node.process(field, radius=15)
|
||||||
|
assert len(result) == 2
|
||||||
|
leveled, bg = result
|
||||||
|
assert np.allclose(leveled.data + bg.data, data)
|
||||||
50
tests/node_tests/unrotate.py
Normal file
50
tests/node_tests/unrotate.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import numpy as np
|
||||||
|
from tests.node_tests._shared import make_field
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrotate_preserves_shape():
|
||||||
|
from backend.nodes.unrotate import Unrotate
|
||||||
|
|
||||||
|
node = Unrotate()
|
||||||
|
data = np.random.default_rng(42).standard_normal((64, 64))
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
(result,) = node.process(field, symmetry="4-fold")
|
||||||
|
assert result.data.shape == (64, 64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrotate_small_angle():
|
||||||
|
from backend.nodes.unrotate import Unrotate, _slope_angle_histogram, _find_dominant_angle
|
||||||
|
|
||||||
|
y, x = np.mgrid[:128, :128].astype(np.float64)
|
||||||
|
angle_deg = 3.0
|
||||||
|
angle_rad = np.radians(angle_deg)
|
||||||
|
data = np.sin(2 * np.pi * (x * np.cos(angle_rad) + y * np.sin(angle_rad)) / 20.0)
|
||||||
|
|
||||||
|
hist = _slope_angle_histogram(data)
|
||||||
|
correction = _find_dominant_angle(hist, 4)
|
||||||
|
assert abs(np.degrees(correction)) < 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrotate_no_rotation_passthrough():
|
||||||
|
from backend.nodes.unrotate import Unrotate
|
||||||
|
|
||||||
|
node = Unrotate()
|
||||||
|
y, x = np.mgrid[:64, :64].astype(np.float64)
|
||||||
|
data = np.sin(2 * np.pi * x / 16.0)
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
(result,) = node.process(field, symmetry="4-fold")
|
||||||
|
assert np.allclose(result.data, data, atol=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrotate_symmetry_options():
|
||||||
|
from backend.nodes.unrotate import Unrotate
|
||||||
|
|
||||||
|
node = Unrotate()
|
||||||
|
data = np.random.default_rng(99).standard_normal((64, 64))
|
||||||
|
field = make_field(data=data)
|
||||||
|
|
||||||
|
for sym in ["2-fold", "3-fold", "4-fold", "6-fold"]:
|
||||||
|
(result,) = node.process(field, symmetry=sym)
|
||||||
|
assert result.data.shape == (64, 64)
|
||||||
46
tests/node_tests/zero_value.py
Normal file
46
tests/node_tests/zero_value.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import numpy as np
|
||||||
|
from tests.node_tests._shared import make_field
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_mean():
|
||||||
|
from backend.nodes.zero_value import ZeroMean
|
||||||
|
|
||||||
|
node = ZeroMean()
|
||||||
|
data = np.random.default_rng(42).standard_normal((64, 64)) + 100.0
|
||||||
|
field = make_field(data=data)
|
||||||
|
(result,) = node.process(field)
|
||||||
|
assert result.data.shape == field.data.shape
|
||||||
|
assert abs(result.data.mean()) < 1e-10
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_mean_preserves_variation():
|
||||||
|
from backend.nodes.zero_value import ZeroMean
|
||||||
|
|
||||||
|
node = ZeroMean()
|
||||||
|
data = np.random.default_rng(7).standard_normal((32, 32)) + 50.0
|
||||||
|
field = make_field(data=data)
|
||||||
|
(result,) = node.process(field)
|
||||||
|
assert np.allclose(result.data - result.data.mean(), data - data.mean())
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_maximum():
|
||||||
|
from backend.nodes.zero_value import ZeroMaximum
|
||||||
|
|
||||||
|
node = ZeroMaximum()
|
||||||
|
data = np.random.default_rng(42).standard_normal((64, 64)) + 100.0
|
||||||
|
field = make_field(data=data)
|
||||||
|
(result,) = node.process(field)
|
||||||
|
assert result.data.shape == field.data.shape
|
||||||
|
assert abs(result.data.max()) < 1e-10
|
||||||
|
assert result.data.min() < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_maximum_preserves_differences():
|
||||||
|
from backend.nodes.zero_value import ZeroMaximum
|
||||||
|
|
||||||
|
node = ZeroMaximum()
|
||||||
|
data = np.array([[1.0, 3.0], [2.0, 5.0]])
|
||||||
|
field = make_field(data=data)
|
||||||
|
(result,) = node.process(field)
|
||||||
|
expected = data - 5.0
|
||||||
|
assert np.allclose(result.data, expected)
|
||||||
Reference in New Issue
Block a user