adding more nodes

This commit is contained in:
2026-04-03 23:11:52 -07:00
parent 5d4c6dfcea
commit 7747c1c7bc
146 changed files with 4950 additions and 145 deletions

View File

@@ -34,6 +34,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
"PrintTable",
"Save",
"SaveImage",
"Shade",
],
"Overlay": [
"Markup",
@@ -47,7 +48,13 @@ MENU_LAYOUT: dict[str, list[str]] = {
"FlipField",
"Resample",
"AffineCorrection",
"PerspectiveCorrection",
"PolynomialDistortion",
"ImageStitch",
"MutualCrop",
"ImmerseDetail",
"PixelBinning",
"ExtendPad",
"FieldArithmetic",
],
"Level & Correct": [
@@ -55,10 +62,16 @@ MENU_LAYOUT: dict[str, list[str]] = {
"PlaneLevelField",
"PolyLevelField",
"FacetLevelField",
"FlattenBase",
"LineCorrection",
"DriftCorrection",
"ScarRemoval",
"SpotRemoval",
"LaplaceInterpolation",
"FractalInterpolation",
"ScanLineReorder",
"Tilt",
"WrapValue",
],
"Filter": [
"GaussianFilter",
@@ -68,6 +81,9 @@ MENU_LAYOUT: dict[str, list[str]] = {
"LocalContrast",
"CustomConvolution",
"Deconvolution",
"MedianBackground",
"TrimmedMean",
"RankFilter",
"Gradient",
"EdgeDetect",
],
@@ -79,6 +95,8 @@ MENU_LAYOUT: dict[str, list[str]] = {
"ACF2D",
"ACF1D",
"PSDF",
"LogPolarPSDF",
"FrequencySplit",
"CrossCorrelate",
],
"Measure": [
@@ -88,12 +106,16 @@ MENU_LAYOUT: dict[str, list[str]] = {
"Stats",
"Curvature",
"ShapeFitting",
"TerraceFit",
"FractalDimension",
"Entropy",
"SlopeDistribution",
"RadialProfile",
"LatticeMeasurement",
"AngleMeasure",
"MultipleProfiles",
"StraightenPath",
"RelateFields",
],
"Detect": [
"FeatureDetection",
@@ -101,10 +123,13 @@ MENU_LAYOUT: dict[str, list[str]] = {
"TemplateMatch",
"FacetAnalysis",
"MFMAnalysis",
"ZeroCrossing",
],
"Mask": [
"DrawMask",
"ThresholdMask",
"GrainMark",
"OutlierMask",
"MaskMorphology",
"MaskInvert",
"MaskOperations",
@@ -114,6 +139,11 @@ MENU_LAYOUT: dict[str, list[str]] = {
"WatershedSegmentation",
"GrainAnalysis",
"GrainFilter",
"GrainDistributions",
"GrainSummary",
"LevelGrains",
"GrainEdge",
"GrainCross",
],
"Tip": [
"TipModel",

View File

@@ -33,7 +33,6 @@ class AffineCorrection:
"Apply an affine correction to fix geometric distortions from scanner "
"nonlinearity. Parameters specify shear, scale, and rotation corrections. "
"The transform is applied about the centre of the field. "
"Equivalent to Gwyddion's correct_affine.c module."
)
def process(

View File

@@ -27,7 +27,7 @@ class CrossCorrelate:
DESCRIPTION = (
"Compute 2D cross-correlation between two fields. The correlation peak indicates "
"the offset where the two fields best match. Useful for drift measurement and feature "
"alignment. Equivalent to Gwyddion crosscor.c."
"alignment."
)
def process(

View File

@@ -36,7 +36,6 @@ class CrossSection:
DESCRIPTION = (
"Extract a cross-section profile along a line between two points. "
"Drag the markers on the image to set the line endpoints. "
"Equivalent to gwy_data_field_get_profile."
)
def process(

View File

@@ -32,7 +32,6 @@ class Deconvolution:
"blurred by a Gaussian PSF with the given sigma (in pixels). "
"Wiener filtering is fast and works in one pass. "
"Richardson-Lucy is iterative and preserves positivity. "
"Equivalent to Gwyddion's deconvolve.c module."
)
def process(

View File

@@ -58,7 +58,6 @@ class DriftCorrection:
"Compensate for thermal or piezo drift between scan lines. "
"Cross-correlates each row (or column) against a reference to estimate "
"the drift offset, then shifts lines to correct. "
"Equivalent to Gwyddion's drift.c module."
)
def process(self, field: DataField, reference: str, direction: str) -> tuple:

View File

@@ -23,7 +23,6 @@ class EdgeDetect:
DESCRIPTION = (
"Detect edges using Sobel, Prewitt, Laplacian, or LoG operators. "
"Equivalent to gwy_data_field_filter_sobel / gwy_data_field_filter_laplacian."
)
def process(self, field: DataField, method: str, sigma: float) -> tuple:

View File

@@ -27,7 +27,7 @@ class Entropy:
DESCRIPTION = (
"Shannon entropy of the height or slope distribution. "
"H = -\u03a3 p\u00b7ln(p). Equivalent to Gwyddion entropy.c."
"H = -\u03a3 p\u00b7ln(p)."
)
def process(self, field: DataField, mode: str, n_bins: int) -> tuple:

View File

@@ -0,0 +1,64 @@
"""Extend / pad — add borders to a field with various fill methods."""
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="Extend / Pad")
class ExtendPad:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"top": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
"bottom": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
"left": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
"right": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
"method": (["mean", "edge", "mirror", "periodic", "zero"],
{"default": "mirror"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'padded'),
)
FUNCTION = "process"
DESCRIPTION = (
"Add borders to a field with configurable padding method. "
"Mirror and periodic modes avoid edge discontinuities for FFT. "
)
def process(self, field: DataField, top: int, bottom: int,
left: int, right: int, method: str) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
mode_map = {
"mean": "constant",
"edge": "edge",
"mirror": "reflect",
"periodic": "wrap",
"zero": "constant",
}
np_mode = mode_map.get(method, "constant")
kwargs = {}
if method == "mean":
kwargs["constant_values"] = data.mean()
elif method == "zero":
kwargs["constant_values"] = 0.0
result = np.pad(data, ((top, bottom), (left, right)), mode=np_mode, **kwargs)
new_xreal = result.shape[1] * field.dx
new_yreal = result.shape[0] * field.dy
new_xoff = field.xoff - left * field.dx
new_yoff = field.yoff - top * field.dy
return (field.replace(data=result, xreal=new_xreal, yreal=new_yreal,
xoff=new_xoff, yoff=new_yoff),)

View File

@@ -30,7 +30,6 @@ class FacetAnalysis:
"Outputs a 2D histogram (stereographic projection) where the x-axis "
"is the azimuthal angle (phi) and y-axis is the inclination (theta). "
"Intensity represents how much surface area faces each orientation. "
"Equivalent to Gwyddion's facet_analysis.c module."
)
def process(self, field: DataField, n_bins: int, kernel_size: int) -> tuple:

View File

@@ -38,7 +38,6 @@ class FeatureDetection:
"Canny: multi-stage edge detector with hysteresis thresholding. "
"Harris: corner/interest point detector based on structure tensor. "
"Outputs a feature map and a table of detected feature locations. "
"Equivalent to Gwyddion's edge/corner detection in filters.c."
)
def process(

View File

@@ -32,7 +32,6 @@ class FFT2D:
DESCRIPTION = (
"Compute the 2D FFT with optional windowing and mean/plane subtraction. "
"Outputs log magnitude, magnitude, phase, and PSDF as separate channels. "
"Equivalent to gwy_data_field_2dfft / gwy_data_field_2dpsdf."
)
def process(self, field: DataField, windowing: str, level: str) -> tuple:

View File

@@ -27,8 +27,6 @@ class FieldArithmetic:
"Apply a point-wise arithmetic operation to two DATA_FIELDs of the same resolution. "
"add/subtract/multiply/divide/min/max perform element-wise operations; "
"hypot computes sqrt(a² + b²) per pixel. "
"Equivalent to gwy_data_field_sum_fields / subtract_fields / multiply_fields / "
"divide_fields / min_of_fields / max_of_fields / hypot_of_fields in arithmetic.c."
)
def process(self, field_a: DataField, field_b: DataField, operation: str) -> tuple:

View File

@@ -97,7 +97,6 @@ class CustomConvolution:
"Apply a user-defined convolution kernel. "
"Enter rows of space-separated numbers. "
"Example sharpen: '0 -1 0 / -1 5 -1 / 0 -1 0' (use newlines, not slashes). "
"Equivalent to Gwyddion convolution_filter.c."
)
def process(

View File

@@ -11,7 +11,7 @@ class FFTFilter:
Accepts either a LINE or DATA_FIELD and returns a filtered output of the
same type. Uses a Butterworth transfer function with configurable order
for a smooth roll-off. Equivalent to Gwyddion fft_filter_1d / fft_filter_2d.
for a smooth roll-off.
"""
@classmethod

View File

@@ -19,8 +19,8 @@ class GaussianFilter:
)
FUNCTION = "process"
DESCRIPTION = "Apply a Gaussian blur. Equivalent to gwy_data_field_filter_gaussian."
DESCRIPTION = "Apply a Gaussian blur."
def process(self, field: DataField, sigma: float) -> tuple:
from scipy.ndimage import gaussian_filter
data = gaussian_filter(field.data, sigma=float(sigma))

View File

@@ -73,9 +73,10 @@ class KuwaharaFilter:
FUNCTION = "process"
DESCRIPTION = (
"Edge-preserving smoothing using Kuwahara's minimum-variance quadrant method. "
"Unlike Gaussian blur, sharp boundaries are preserved. "
"Equivalent to Gwyddion's Kuwahara filter."
"""
Edge-preserving smoothing using Kuwahara's minimum-variance quadrant method.
"Unlike Gaussian blur, sharp boundaries are preserved.
"""
)
def process(self, field: DataField, iterations: int) -> tuple:

View File

@@ -19,7 +19,7 @@ class MedianFilter:
)
FUNCTION = "process"
DESCRIPTION = "Apply a median filter. Equivalent to gwy_data_field_filter_median."
DESCRIPTION = "Apply a median filter."
def process(self, field: DataField, size: int) -> tuple:
from scipy.ndimage import median_filter

View File

@@ -0,0 +1,53 @@
"""Rank filter — general k-th rank filter for morphological operations."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import percentile_filter, minimum_filter, maximum_filter, median_filter
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Rank Filter")
class RankFilter:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"operation": (["erosion", "dilation", "median", "percentile"],
{"default": "median"}),
"radius": ("INT", {"default": 3, "min": 1, "max": 50, "step": 1}),
"percentile": ("FLOAT", {"default": 50.0, "min": 0.0, "max": 100.0, "step": 1.0}),
}
}
OUTPUTS = (
('DATA_FIELD', 'filtered'),
)
FUNCTION = "process"
DESCRIPTION = (
"Apply rank-based morphological filtering. Erosion selects the local "
"minimum (shrinks features), dilation the local maximum (grows features), "
"median the 50th percentile. Custom percentile allows any rank. "
)
def process(self, field: DataField, operation: str, radius: int,
percentile: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
size = 2 * radius + 1
if operation == "erosion":
result = minimum_filter(data, size=size)
elif operation == "dilation":
result = maximum_filter(data, size=size)
elif operation == "median":
result = median_filter(data, size=size)
elif operation == "percentile":
result = percentile_filter(data, percentile=percentile, size=size)
else:
raise ValueError(f"Unknown operation: {operation!r}")
return (field.replace(data=result),)

View File

@@ -22,7 +22,6 @@ class FixZero:
DESCRIPTION = (
"Shift data so that the minimum (or mean/median) is zero. "
"Equivalent to fix_zero in Gwyddion's level.c."
)
def process(self, field: DataField, method: str) -> tuple:

View File

@@ -0,0 +1,67 @@
"""Flatten base — level the flat base of a surface with raised features."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import median_filter
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Flatten Base")
class FlattenBase:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"threshold_percentile": ("FLOAT", {"default": 30.0, "min": 5.0, "max": 80.0, "step": 1.0}),
"poly_degree": ("INT", {"default": 2, "min": 0, "max": 5}),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = (
"Level the flat base of a surface that has raised features (particles, "
"grains). Uses a height percentile threshold to identify base pixels, "
"fits a polynomial to those pixels, and subtracts it. Unlike plane level, "
"this ignores tall features that would bias the fit. "
)
def process(self, field: DataField, threshold_percentile: float, poly_degree: int) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Identify base pixels: those below the threshold percentile
threshold = np.percentile(data, threshold_percentile)
base_mask = data <= threshold
if base_mask.sum() < max(3, (poly_degree + 1) ** 2):
# Not enough base pixels, fall back to subtracting the mean
return (field.replace(data=data - data.mean()),)
yy, xx = np.mgrid[:yres, :xres]
x_norm = xx.ravel() / max(xres - 1, 1)
y_norm = yy.ravel() / max(yres - 1, 1)
# Build polynomial basis
cols = []
for py in range(poly_degree + 1):
for px in range(poly_degree + 1 - py):
cols.append(x_norm**px * y_norm**py)
A_full = np.column_stack(cols)
# Fit on base pixels only
base_indices = np.where(base_mask.ravel())[0]
A_base = A_full[base_indices]
z_base = data.ravel()[base_indices]
coeffs, _, _, _ = np.linalg.lstsq(A_base, z_base, rcond=None)
# Evaluate and subtract
background = (A_full @ coeffs).reshape(data.shape)
return (field.replace(data=data - background),)

View File

@@ -0,0 +1,78 @@
"""Fractal interpolation — fill masked regions using fractal (self-similar) synthesis."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
from backend.nodes.helpers import mask_to_bool
@register_node(display_name="Fractal Interpolation")
class FractalInterpolation:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"mask": ("IMAGE",),
"iterations": ("INT", {"default": 200, "min": 10, "max": 5000, "step": 10}),
}
}
OUTPUTS = (
('DATA_FIELD', 'filled'),
)
FUNCTION = "process"
DESCRIPTION = (
"Fill masked regions using fractal interpolation. Matches the spectral "
"characteristics of the surrounding surface to produce natural-looking "
"infill that preserves texture. Better than Laplace for rough surfaces. "
)
def process(self, field: DataField, mask: np.ndarray, iterations: int) -> tuple:
data = np.asarray(field.data, dtype=np.float64).copy()
hole = mask_to_bool(mask)
if not hole.any():
return (field.replace(data=data),)
# Step 1: Estimate power spectrum from valid (unmasked) data
valid_data = data.copy()
valid_mean = data[~hole].mean() if (~hole).any() else 0.0
valid_data[hole] = valid_mean
fft_valid = np.fft.fft2(valid_data)
power = np.abs(fft_valid) ** 2
# Step 2: Generate fractal noise matching the power spectrum
rng = np.random.default_rng(42)
phases = rng.uniform(0, 2 * np.pi, data.shape)
noise_fft = np.sqrt(power) * np.exp(1j * phases)
noise = np.real(np.fft.ifft2(noise_fft))
# Normalize noise to match local statistics around masked region
if (~hole).any():
noise = (noise - noise[~hole].mean()) / max(noise[~hole].std(), 1e-30) * \
data[~hole].std() + data[~hole].mean()
# Step 3: Initialize masked pixels with fractal noise, then blend
# with Laplace relaxation for smooth boundaries
data[hole] = noise[hole]
# Relax boundaries to ensure continuity
padded = np.pad(data, 1, mode='edge')
hole_padded = np.pad(hole, 1, mode='constant', constant_values=False)
for _ in range(iterations):
avg = (padded[:-2, 1:-1] + padded[2:, 1:-1] +
padded[1:-1, :-2] + padded[1:-1, 2:]) / 4.0
# Blend: 90% fractal noise + 10% relaxation to smooth boundaries
blend = 0.1
new_vals = (1.0 - blend) * padded[1:-1, 1:-1][hole] + blend * avg[hole]
padded[1:-1, 1:-1][hole] = new_vals
data = padded[1:-1, 1:-1].copy()
return (field.replace(data=data),)

View File

@@ -0,0 +1,50 @@
"""Frequency splitting — separate image into low-pass and high-pass components."""
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="Frequency Split")
class FrequencySplit:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"cutoff": ("FLOAT", {"default": 0.1, "min": 0.001, "max": 0.5, "step": 0.001}),
}
}
OUTPUTS = (
('DATA_FIELD', 'low_pass'),
('DATA_FIELD', 'high_pass'),
)
FUNCTION = "process"
DESCRIPTION = (
"Separate a field into low-frequency (background) and high-frequency "
"(detail) components using FFT. The cutoff is relative to the Nyquist "
"frequency (0.5 = no filtering, 0.001 = very aggressive). "
)
def process(self, field: DataField, cutoff: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
kx = np.fft.fftfreq(xres)
ky = np.fft.fftfreq(yres)
KX, KY = np.meshgrid(kx, ky)
K = np.sqrt(KX**2 + KY**2)
# Gaussian low-pass filter
lp_filter = np.exp(-0.5 * (K / cutoff)**2)
fft_data = np.fft.fft2(data)
low = np.real(np.fft.ifft2(fft_data * lp_filter))
high = data - low
return (field.replace(data=low), field.replace(data=high))

View File

@@ -27,7 +27,6 @@ class Gradient:
"'x'/'y' give the physical gradient components (z_unit/xy_unit); "
"'magnitude' gives sqrt(gx²+gy²); "
"'azimuth' gives the local slope direction in radians via atan2(gy, gx). "
"Equivalent to gwy_data_field_filter_sobel in Gwyddion (gradient.c)."
)
def process(self, field: DataField, component: str) -> tuple:

View File

@@ -25,7 +25,6 @@ class GrainAnalysis:
DESCRIPTION = (
"Label connected grain regions in a binary mask and compute per-grain "
"statistics: area, equivalent diameter, mean/max height, bounding box. "
"Equivalent to Gwyddion's grain statistics tools."
)
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:

View File

@@ -0,0 +1,84 @@
"""Grain cross-correlation — scatter plots of grain properties between two fields."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import label
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="Grain Cross")
class GrainCross:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field_a": ("DATA_FIELD",),
"field_b": ("DATA_FIELD",),
"mask": ("IMAGE",),
"property_a": (["area", "mean_height", "max_height", "volume"],
{"default": "mean_height"}),
"property_b": (["area", "mean_height", "max_height", "volume"],
{"default": "max_height"}),
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
}
}
OUTPUTS = (
('RECORD_TABLE', 'correlation'),
)
FUNCTION = "process"
DESCRIPTION = (
"Correlate grain properties between two fields using a shared mask. "
"Outputs a table of (property_a, property_b) pairs for each grain, "
"plus Pearson correlation coefficient. "
)
def process(self, field_a: DataField, field_b: DataField, mask: np.ndarray,
property_a: str, property_b: str, min_size: int) -> tuple:
data_a = np.asarray(field_a.data, dtype=np.float64)
data_b = np.asarray(field_b.data, dtype=np.float64)
grain = mask_to_bool(mask)
labeled, n_grains = label(grain.astype(np.int32))
pixel_area = field_a.dx * field_a.dy
def _get_prop(data, gpx, prop):
n_px = gpx.sum()
if prop == "area":
return n_px * pixel_area
elif prop == "mean_height":
return float(data[gpx].mean())
elif prop == "max_height":
return float(data[gpx].max())
elif prop == "volume":
base = float(data[~grain].mean()) if (~grain).any() else 0.0
return float(np.sum(data[gpx] - base) * pixel_area)
return 0.0
vals_a, vals_b = [], []
records = RecordTable()
for gid in range(1, n_grains + 1):
gpx = labeled == gid
if gpx.sum() < min_size:
continue
va = _get_prop(data_a, gpx, property_a)
vb = _get_prop(data_b, gpx, property_b)
vals_a.append(va)
vals_b.append(vb)
records.append({
"quantity": f"Grain {gid}",
"value": f"{va:.4g} / {vb:.4g}",
"unit": f"{property_a} / {property_b}",
})
# Pearson correlation
if len(vals_a) >= 2:
corr = float(np.corrcoef(vals_a, vals_b)[0, 1])
records.append({"quantity": "Pearson r", "value": f"{corr:.4f}", "unit": ""})
return (records,)

View File

@@ -0,0 +1,99 @@
"""Grain property distributions — compute histograms of grain properties."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import label
from backend.node_registry import register_node
from backend.data_types import DataField, LineData
from backend.nodes.helpers import mask_to_bool
@register_node(display_name="Grain Distributions")
class GrainDistributions:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"mask": ("IMAGE",),
"property": (["area", "equiv_diameter", "mean_height", "max_height",
"volume", "boundary_length"], {"default": "area"}),
"n_bins": ("INT", {"default": 30, "min": 5, "max": 200, "step": 1}),
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
}
}
OUTPUTS = (
('LINE_DATA', 'distribution'),
)
FUNCTION = "process"
DESCRIPTION = (
"Compute a histogram of a grain property from a labeled mask. "
"Supported properties: area, equivalent diameter, mean height, "
"max height, volume, and boundary length. "
)
def process(self, field: DataField, mask: np.ndarray, property: str,
n_bins: int, min_size: int) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
grain_mask = mask_to_bool(mask)
labeled, n_grains = label(grain_mask.astype(np.int32))
pixel_area = field.dx * field.dy
xy_unit = field.si_unit_xy or "m"
z_unit = field.si_unit_z or "m"
values = []
for gid in range(1, n_grains + 1):
gpx = labeled == gid
n_px = int(gpx.sum())
if n_px < min_size:
continue
if property == "area":
values.append(n_px * pixel_area)
elif property == "equiv_diameter":
area = n_px * pixel_area
values.append(2.0 * np.sqrt(area / np.pi))
elif property == "mean_height":
values.append(float(data[gpx].mean()))
elif property == "max_height":
values.append(float(data[gpx].max()))
elif property == "volume":
base = float(data[~grain_mask].mean()) if (~grain_mask).any() else 0.0
values.append(float(np.sum(data[gpx] - base) * pixel_area))
elif property == "boundary_length":
# Count boundary pixels (pixels with at least one non-grain neighbour)
padded = np.pad(gpx, 1, mode='constant', constant_values=False)
boundary = gpx & ~(
padded[:-2, 1:-1] & padded[2:, 1:-1] &
padded[1:-1, :-2] & padded[1:-1, 2:]
)
values.append(int(boundary.sum()) * max(field.dx, field.dy))
if len(values) == 0:
values = [0.0]
# Unit labels
unit_map = {
"area": f"{xy_unit}²",
"equiv_diameter": xy_unit,
"mean_height": z_unit,
"max_height": z_unit,
"volume": f"{xy_unit}²·{z_unit}",
"boundary_length": xy_unit,
}
arr = np.array(values)
counts, edges = np.histogram(arr, bins=n_bins)
centers = 0.5 * (edges[:-1] + edges[1:])
return (LineData(
data=counts.astype(np.float64),
x_axis=centers,
x_unit=unit_map.get(property, ""),
y_unit="count",
),)

View File

@@ -0,0 +1,51 @@
"""Grain edge detection — detect grain boundaries using Laplacian edge detection."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import label
from backend.node_registry import register_node
from backend.data_types import DataField
from backend.nodes.helpers import mask_to_bool, bool_to_mask
@register_node(display_name="Grain Edge")
class GrainEdge:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"mask": ("IMAGE",),
"width": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
}
}
OUTPUTS = (
('IMAGE', 'edge_mask'),
)
FUNCTION = "process"
DESCRIPTION = (
"Detect grain boundaries from a binary grain mask. Outputs a mask "
"of pixels at grain edges (where grain meets non-grain). Width "
"controls the boundary thickness in pixels. "
)
def process(self, field: DataField, mask: np.ndarray, width: int) -> tuple:
grain = mask_to_bool(mask)
# Find boundary: grain pixels with at least one non-grain 4-neighbour
padded = np.pad(grain, 1, mode='constant', constant_values=False)
interior = (padded[:-2, 1:-1] & padded[2:, 1:-1] &
padded[1:-1, :-2] & padded[1:-1, 2:])
boundary = grain & ~interior
# Expand boundary by width
if width > 1:
from scipy.ndimage import binary_dilation
struct = np.ones((2 * width - 1, 2 * width - 1), dtype=bool)
boundary = binary_dilation(boundary, structure=struct) & grain
return (bool_to_mask(boundary),)

View File

@@ -29,7 +29,6 @@ class GrainFilter:
"'min_area': discard grains smaller than this many pixels (removes specks). "
"'max_area': discard grains larger than this many pixels (0 = no limit). "
"'remove_border': discard any grain that touches the image edge. "
"Equivalent to Gwyddion's grain_filter module (grain_filter.c)."
)
def process(

View File

@@ -0,0 +1,75 @@
"""Grain marking — mark grains by height, slope, or curvature criteria."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import label, sobel
from backend.node_registry import register_node
from backend.data_types import DataField
from backend.nodes.helpers import bool_to_mask
@register_node(display_name="Grain Mark")
class GrainMark:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"criterion": (["height", "slope", "curvature"], {"default": "height"}),
"threshold_low": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}),
"threshold_high": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
"inverted": ("BOOLEAN", {"default": False}),
}
}
OUTPUTS = (
('IMAGE', 'mask'),
)
FUNCTION = "process"
DESCRIPTION = (
"Mark grains by thresholding height, slope magnitude, or curvature. "
"Thresholds are relative (01) to the data range. Small regions below "
"min_size pixels are removed. Use inverted to mark valleys instead of peaks. "
)
def process(self, field: DataField, criterion: str, threshold_low: float,
threshold_high: float, min_size: int, inverted: bool) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
if criterion == "height":
values = data
elif criterion == "slope":
gx = sobel(data, axis=1)
gy = sobel(data, axis=0)
values = np.sqrt(gx**2 + gy**2)
elif criterion == "curvature":
gxx = sobel(sobel(data, axis=1), axis=1)
gyy = sobel(sobel(data, axis=0), axis=0)
values = np.abs(gxx + gyy)
else:
raise ValueError(f"Unknown criterion: {criterion!r}")
# Normalize to [0, 1]
vmin, vmax = values.min(), values.max()
if vmax > vmin:
norm = (values - vmin) / (vmax - vmin)
else:
norm = np.zeros_like(values)
# Apply thresholds
binary = (norm >= threshold_low) & (norm <= threshold_high)
if inverted:
binary = ~binary
# Remove small regions
labeled, n_labels = label(binary.astype(np.int32))
for gid in range(1, n_labels + 1):
if (labeled == gid).sum() < min_size:
binary[labeled == gid] = False
return (bool_to_mask(binary),)

View File

@@ -0,0 +1,78 @@
"""Grain summary statistics — aggregate statistics for all grains."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import label
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
from backend.nodes.helpers import mask_to_bool, _square_unit
@register_node(display_name="Grain Summary")
class GrainSummary:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"mask": ("IMAGE",),
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
}
}
OUTPUTS = (
('RECORD_TABLE', 'summary'),
)
FUNCTION = "process"
DESCRIPTION = (
"Compute aggregate statistics for all grains in a mask: count, density, "
"coverage fraction, mean/median area, total volume, and height statistics. "
)
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
grain_mask = mask_to_bool(mask)
labeled, n_grains = label(grain_mask.astype(np.int32))
pixel_area = field.dx * field.dy
total_area = field.xreal * field.yreal
xy_unit = field.si_unit_xy or "m"
z_unit = field.si_unit_z or "m"
# Collect per-grain properties
areas = []
heights = []
volumes = []
base_height = float(data[~grain_mask].mean()) if (~grain_mask).any() else 0.0
for gid in range(1, n_grains + 1):
gpx = labeled == gid
n_px = int(gpx.sum())
if n_px < min_size:
continue
area = n_px * pixel_area
areas.append(area)
heights.append(float(data[gpx].mean()))
volumes.append(float(np.sum(data[gpx] - base_height) * pixel_area))
records = RecordTable()
n_valid = len(areas)
records.append({"quantity": "Grain count", "value": str(n_valid), "unit": ""})
records.append({"quantity": "Grain density", "value": f"{n_valid / total_area:.4g}" if total_area > 0 else "0", "unit": f"1/{_square_unit(xy_unit)}"})
coverage = sum(areas) / total_area if total_area > 0 else 0.0
records.append({"quantity": "Coverage fraction", "value": f"{coverage:.4f}", "unit": ""})
if n_valid > 0:
records.append({"quantity": "Mean area", "value": f"{np.mean(areas):.4g}", "unit": _square_unit(xy_unit)})
records.append({"quantity": "Median area", "value": f"{np.median(areas):.4g}", "unit": _square_unit(xy_unit)})
records.append({"quantity": "Total volume", "value": f"{sum(volumes):.4g}", "unit": f"{_square_unit(xy_unit)}·{z_unit}"})
records.append({"quantity": "Mean height", "value": f"{np.mean(heights):.4g}", "unit": z_unit})
records.append({"quantity": "Median height", "value": f"{np.median(heights):.4g}", "unit": z_unit})
records.append({"quantity": "Max area", "value": f"{max(areas):.4g}", "unit": _square_unit(xy_unit)})
records.append({"quantity": "Min area", "value": f"{min(areas):.4g}", "unit": _square_unit(xy_unit)})
return (records,)

View File

@@ -32,7 +32,6 @@ class Histogram:
"Compute the height distribution histogram (DH). "
"Use log scale to reveal small peaks next to a dominant background. "
"Outputs marker measurements while showing the histogram interactively in-node. "
"Equivalent to gwy_data_field_dh."
)
def process(

View File

@@ -37,7 +37,6 @@ class HoughTransform:
"Hough parameter space. Reports detected features with their parameters. "
"For lines: angle and distance from origin. "
"For circles: centre coordinates and radius. "
"Equivalent to Gwyddion's hough.c module."
)
def process(

View File

@@ -53,7 +53,6 @@ class ImageStitch:
"Uses cross-correlation to align the images and blends the overlap region. "
"Direction specifies how field_b is positioned relative to field_a. "
"'auto' uses cross-correlation to determine the best placement. "
"Equivalent to Gwyddion's merge.c / stitch.c modules."
)
def process(self, field_a: DataField, field_b: DataField, direction: str, blend: str) -> tuple:

View File

@@ -0,0 +1,89 @@
"""Immerse detail — overlay high-resolution detail onto lower-resolution overview."""
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="Immerse Detail")
class ImmerseDetail:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"overview": ("DATA_FIELD",),
"detail": ("DATA_FIELD",),
"blend": (["replace", "average"], {"default": "replace"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'combined'),
)
FUNCTION = "process"
DESCRIPTION = (
"Overlay a high-resolution detail scan onto a lower-resolution overview "
"image using cross-correlation to find the best position. "
)
def process(self, overview: DataField, detail: DataField, blend: str) -> tuple:
ov = np.asarray(overview.data, dtype=np.float64)
dt = np.asarray(detail.data, dtype=np.float64)
# Resample detail to overview pixel size if needed
scale_x = detail.dx / overview.dx
scale_y = detail.dy / overview.dy
if abs(scale_x - 1.0) > 0.01 or abs(scale_y - 1.0) > 0.01:
from scipy.ndimage import zoom
dt = zoom(dt, (scale_y, scale_x), order=1)
dy_res, dx_res = dt.shape
oy_res, ox_res = ov.shape
if dy_res >= oy_res or dx_res >= ox_res:
# Detail is larger than overview, just return overview
return (overview,)
# Cross-correlate to find best position
# Use a sliding window approach for small detail
best_score = -np.inf
best_y, best_x = 0, 0
dt_norm = dt - dt.mean()
dt_std = dt.std()
if dt_std < 1e-30:
dt_std = 1.0
# Coarse search with stride
stride = max(1, min(dy_res, dx_res) // 4)
for iy in range(0, oy_res - dy_res + 1, stride):
for ix in range(0, ox_res - dx_res + 1, stride):
patch = ov[iy:iy + dy_res, ix:ix + dx_res]
score = np.sum((patch - patch.mean()) * dt_norm) / (patch.std() * dt_std + 1e-30)
if score > best_score:
best_score = score
best_y, best_x = iy, ix
# Fine search around best position
for iy in range(max(0, best_y - stride), min(oy_res - dy_res + 1, best_y + stride + 1)):
for ix in range(max(0, best_x - stride), min(ox_res - dx_res + 1, best_x + stride + 1)):
patch = ov[iy:iy + dy_res, ix:ix + dx_res]
score = np.sum((patch - patch.mean()) * dt_norm) / (patch.std() * dt_std + 1e-30)
if score > best_score:
best_score = score
best_y, best_x = iy, ix
# Place detail into overview
result = ov.copy()
if blend == "replace":
result[best_y:best_y + dy_res, best_x:best_x + dx_res] = dt
else: # average
result[best_y:best_y + dy_res, best_x:best_x + dx_res] = \
0.5 * (ov[best_y:best_y + dy_res, best_x:best_x + dx_res] + dt)
return (overview.replace(data=result),)

View File

@@ -0,0 +1,57 @@
"""Laplace interpolation — fill masked regions by solving the Laplace equation."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
from backend.nodes.helpers import mask_to_bool
@register_node(display_name="Laplace Interpolation")
class LaplaceInterpolation:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"mask": ("IMAGE",),
"iterations": ("INT", {"default": 500, "min": 10, "max": 10000, "step": 10}),
}
}
OUTPUTS = (
('DATA_FIELD', 'filled'),
)
FUNCTION = "process"
DESCRIPTION = (
"Fill masked (missing) regions by solving the Laplace equation with "
"Dirichlet boundary conditions from surrounding pixels. "
"Produces a smooth, harmonic interpolation without overshooting. "
)
def process(self, field: DataField, mask: np.ndarray, iterations: int) -> tuple:
data = np.asarray(field.data, dtype=np.float64).copy()
hole = mask_to_bool(mask)
if not hole.any():
return (field.replace(data=data),)
# Initialize masked pixels to mean of unmasked neighbours or global mean
valid_mean = data[~hole].mean() if (~hole).any() else 0.0
data[hole] = valid_mean
# Iterative Jacobi relaxation: replace each masked pixel with
# the mean of its 4-connected neighbours
padded = np.pad(data, 1, mode='edge')
hole_padded = np.pad(hole, 1, mode='constant', constant_values=False)
for _ in range(iterations):
avg = (padded[:-2, 1:-1] + padded[2:, 1:-1] +
padded[1:-1, :-2] + padded[1:-1, 2:]) / 4.0
padded[1:-1, 1:-1][hole] = avg[hole]
data = padded[1:-1, 1:-1].copy()
return (field.replace(data=data),)

View File

@@ -65,7 +65,6 @@ class LatticeMeasurement:
"Detect and measure periodic lattice structures from a surface. "
"Computes the 2D ACF or FFT power spectrum, finds the strongest peaks, "
"and reports lattice vectors (spacing and angle). "
"Equivalent to Gwyddion's measure_lattice.c module."
)
def process(self, field: DataField, method: str) -> tuple:

View File

@@ -0,0 +1,68 @@
"""Level grains — shift individual grain regions to a common baseline."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import label
from backend.node_registry import register_node
from backend.data_types import DataField
from backend.nodes.helpers import mask_to_bool
@register_node(display_name="Level Grains")
class LevelGrains:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"mask": ("IMAGE",),
"reference": (["mean", "median", "minimum"], {"default": "mean"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = (
"Shift individual grain regions (from a mask) so they all share a "
"common baseline. Uses the selected reference statistic (mean, median, "
"or minimum) per grain to compute the offset. "
"Useful for consistent grain height comparisons. "
)
def process(self, field: DataField, mask: np.ndarray, reference: str) -> tuple:
data = np.asarray(field.data, dtype=np.float64).copy()
grain_mask = mask_to_bool(mask)
labeled, n_grains = label(grain_mask.astype(np.int32))
if n_grains == 0:
return (field.replace(data=data),)
# Compute reference value for each grain
refs = []
for gid in range(1, n_grains + 1):
pixels = data[labeled == gid]
if len(pixels) == 0:
refs.append(0.0)
continue
if reference == "mean":
refs.append(float(pixels.mean()))
elif reference == "median":
refs.append(float(np.median(pixels)))
else: # minimum
refs.append(float(pixels.min()))
# Target: global reference across all grains
target = float(np.mean(refs))
# Shift each grain
for gid in range(1, n_grains + 1):
grain_pixels = labeled == gid
offset = target - refs[gid - 1]
data[grain_pixels] += offset
return (field.replace(data=data),)

View File

@@ -24,7 +24,6 @@ class PolyLevelField:
DESCRIPTION = (
"Fit and subtract a polynomial background of given degree in x and y. "
"Equivalent to gwy_data_field_fit_polynom."
)
def process(self, field: DataField, degree_x: int, degree_y: int) -> tuple:

View File

@@ -26,7 +26,6 @@ class LocalContrast:
DESCRIPTION = (
"Expand the local dynamic range at each pixel. "
"Reveals fine surface features that are hidden by global contrast range. "
"Equivalent to Gwyddion local_contrast.c."
)
def process(self, field: DataField, kernel_size: int, weight: float) -> tuple:

View File

@@ -7,9 +7,8 @@ from backend.nodes.helpers import _mask_structure, mask_to_bool, bool_to_mask, e
@register_node(display_name="Mask Morphology")
class MaskMorphology:
"""Morphological operations on binary masks.
Equivalent to Gwyddion's mask_morph.c (erode, dilate, open, close).
"""
Morphological operations on binary masks.
"""
_CUSTOM_PREVIEW = True
@@ -37,7 +36,6 @@ class MaskMorphology:
"Dilate expands regions, erode shrinks them, "
"open (erode then dilate) removes small spots, "
"close (dilate then erode) fills small holes. "
"Equivalent to Gwyddion mask_morph."
)
def process(self, mask: np.ndarray, operation: str, radius: int, shape: str,

View File

@@ -30,7 +30,6 @@ class ThresholdMask:
DESCRIPTION = (
"Create a binary mask by thresholding data. "
"Otsu automatically finds the optimal threshold. "
"Equivalent to Gwyddion's threshold and otsu_threshold modules."
)
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:

View File

@@ -0,0 +1,44 @@
"""Median background subtraction — extract and subtract background using local median."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import median_filter
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Median Background")
class MedianBackground:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"radius": ("INT", {"default": 20, "min": 2, "max": 500, "step": 1}),
"output": (["subtracted", "background"], {"default": "subtracted"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'result'),
)
FUNCTION = "process"
DESCRIPTION = (
"Extract background using a local median filter and subtract it. "
"The radius controls the filter window size — larger values capture "
"broader background variations. More robust than polynomial leveling "
"for surfaces with sparse tall features. "
)
def process(self, field: DataField, radius: int, output: str) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
size = 2 * radius + 1
background = median_filter(data, size=size)
if output == "background":
return (field.replace(data=background),)
else:
return (field.replace(data=data - background),)

View File

@@ -35,7 +35,6 @@ class MFMAnalysis:
"d²F/dz²; force_gradient_to_field recovers the stray field Hz; "
"charge_density computes the effective magnetic charge; "
"magnetisation estimates the z-component of sample magnetisation. "
"Equivalent to Gwyddion's mfm_*.c modules."
)
def process(self, field: DataField, operation: str, lift_height: float) -> tuple:

View File

@@ -0,0 +1,72 @@
"""Multiple profiles — extract and compare profiles from multiple images."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField, LineData
@register_node(display_name="Multiple Profiles")
class MultipleProfiles:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field_a": ("DATA_FIELD",),
"field_b": ("DATA_FIELD",),
"row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}),
"direction": (["horizontal", "vertical"], {"default": "horizontal"}),
"mode": (["overlay", "mean", "difference"], {"default": "overlay"}),
}
}
OUTPUTS = (
('LINE_DATA', 'profile'),
)
FUNCTION = "process"
DESCRIPTION = (
"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. "
)
def process(self, field_a: DataField, field_b: DataField,
row: int, direction: str, mode: str) -> tuple:
a = np.asarray(field_a.data, dtype=np.float64)
b = np.asarray(field_b.data, dtype=np.float64)
if direction == "horizontal":
if row < 0:
row = a.shape[0] // 2
row = min(row, a.shape[0] - 1, b.shape[0] - 1)
pa = a[row, :]
pb = b[row, :min(a.shape[1], b.shape[1])]
pa = pa[:len(pb)]
dx = field_a.dx
x_unit = field_a.si_unit_xy
else:
if row < 0:
row = a.shape[1] // 2
row = min(row, a.shape[1] - 1, b.shape[1] - 1)
pa = a[:, row]
pb = b[:min(a.shape[0], b.shape[0]), row]
pa = pa[:len(pb)]
dx = field_a.dy
x_unit = field_a.si_unit_xy
x_axis = np.arange(len(pa)) * dx
if mode == "overlay":
result = pa
elif mode == "mean":
result = 0.5 * (pa + pb)
elif mode == "difference":
result = pa - pb
else:
result = pa
return (LineData(data=result, x_axis=x_axis, x_unit=x_unit,
y_unit=field_a.si_unit_z),)

View File

@@ -0,0 +1,79 @@
"""Mutual crop — align and crop two images to their overlapping region."""
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="Mutual Crop")
class MutualCrop:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field_a": ("DATA_FIELD",),
"field_b": ("DATA_FIELD",),
}
}
OUTPUTS = (
('DATA_FIELD', 'cropped_a'),
('DATA_FIELD', 'cropped_b'),
)
FUNCTION = "process"
DESCRIPTION = (
"Align two images using cross-correlation and crop both to their "
"overlapping region. Useful for comparing images acquired at "
"different times or with slight position offsets. "
)
def process(self, field_a: DataField, field_b: DataField) -> tuple:
a = np.asarray(field_a.data, dtype=np.float64)
b = np.asarray(field_b.data, dtype=np.float64)
# Pad to common shape for cross-correlation
shape = (max(a.shape[0], b.shape[0]), max(a.shape[1], b.shape[1]))
a_pad = np.zeros(shape)
b_pad = np.zeros(shape)
a_pad[:a.shape[0], :a.shape[1]] = a - a.mean()
b_pad[:b.shape[0], :b.shape[1]] = b - b.mean()
# Cross-correlate to find shift
fa = np.fft.fft2(a_pad)
fb = np.fft.fft2(b_pad)
cc = np.abs(np.fft.ifft2(fa * np.conj(fb)))
cc = np.fft.fftshift(cc)
cy, cx = np.array(shape) // 2
peak = np.unravel_index(np.argmax(cc), shape)
dy = peak[0] - cy
dx = peak[1] - cx
# Compute overlap region
ay_start = max(0, dy)
ay_end = min(a.shape[0], b.shape[0] + dy)
ax_start = max(0, dx)
ax_end = min(a.shape[1], b.shape[1] + dx)
by_start = max(0, -dy)
by_end = by_start + (ay_end - ay_start)
bx_start = max(0, -dx)
bx_end = bx_start + (ax_end - ax_start)
if ay_end <= ay_start or ax_end <= ax_start:
# No overlap found, return originals
return (field_a, field_b)
crop_a = a[ay_start:ay_end, ax_start:ax_end]
crop_b = b[by_start:by_end, bx_start:bx_end]
xreal = crop_a.shape[1] * field_a.dx
yreal = crop_a.shape[0] * field_a.dy
return (
field_a.replace(data=crop_a, xreal=xreal, yreal=yreal),
field_b.replace(data=crop_b, xreal=xreal, yreal=yreal),
)

View File

@@ -0,0 +1,54 @@
"""Outlier masking — mark statistical outlier pixels."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
from backend.nodes.helpers import bool_to_mask
@register_node(display_name="Outlier Mask")
class OutlierMask:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"sigma_threshold": ("FLOAT", {"default": 3.0, "min": 1.0, "max": 10.0, "step": 0.1}),
"mode": (["both", "high", "low"], {"default": "both"}),
}
}
OUTPUTS = (
('IMAGE', 'mask'),
)
FUNCTION = "process"
DESCRIPTION = (
"Create a mask marking pixels that deviate more than N standard "
"deviations from the mean. Mode selects whether to flag high outliers, "
"low outliers, or both. Quick way to identify noise spikes and defects. "
)
def process(self, field: DataField, sigma_threshold: float, mode: str) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
mean = data.mean()
std = data.std()
if std < 1e-30:
return (bool_to_mask(np.zeros(data.shape, dtype=bool)),)
z = (data - mean) / std
if mode == "both":
outliers = np.abs(z) > sigma_threshold
elif mode == "high":
outliers = z > sigma_threshold
elif mode == "low":
outliers = z < -sigma_threshold
else:
raise ValueError(f"Unknown mode: {mode!r}")
return (bool_to_mask(outliers),)

View File

@@ -0,0 +1,97 @@
"""Perspective correction — fix perspective distortion using a projective transform."""
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="Perspective Correction")
class PerspectiveCorrection:
@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}),
}
}
OUTPUTS = (
('DATA_FIELD', 'corrected'),
)
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 "
"a rectangle."
)
def process(self, field: DataField,
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:
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)],
[(1 + bottom_left_y) * yres - 1, bottom_left_x * xres],
[(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],
[yres - 1, 0],
[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)])
src_coords = H @ coords
src_coords /= src_coords[2:3, :]
sy = src_coords[0].reshape(yres, xres)
sx = src_coords[1].reshape(yres, xres)
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
return (field.replace(data=result),)
def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp)."""
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
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
H = np.array([[h[0], h[1], h[2]],
[h[3], h[4], h[5]],
[h[6], h[7], 1.0]])
return H

View File

@@ -0,0 +1,58 @@
"""Pixel binning — downsample by averaging NxN pixel blocks."""
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="Pixel Binning")
class PixelBinning:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"bin_size": ("INT", {"default": 2, "min": 2, "max": 32, "step": 1}),
"method": (["mean", "sum", "median"], {"default": "mean"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'binned'),
)
FUNCTION = "process"
DESCRIPTION = (
"Downsample by grouping NxN pixel blocks and computing their mean, "
"sum, or median. Faster and more controlled than interpolation-based "
"resampling. Pixels that don't fill a complete block are trimmed. "
)
def process(self, field: DataField, bin_size: int, method: str) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Trim to multiple of bin_size
ny = (yres // bin_size) * bin_size
nx = (xres // bin_size) * bin_size
trimmed = data[:ny, :nx]
# Reshape into blocks
blocks = trimmed.reshape(ny // bin_size, bin_size, nx // bin_size, bin_size)
if method == "mean":
result = blocks.mean(axis=(1, 3))
elif method == "sum":
result = blocks.sum(axis=(1, 3))
elif method == "median":
result = np.median(blocks, axis=(1, 3))
else:
raise ValueError(f"Unknown method: {method!r}")
# Update physical dimensions
new_xreal = field.dx * nx
new_yreal = field.dy * ny
return (field.replace(data=result, xreal=new_xreal, yreal=new_yreal),)

View File

@@ -0,0 +1,60 @@
"""Polynomial distortion correction — correct nonlinear scanner distortions."""
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="Polynomial Distortion")
class PolynomialDistortion:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"k1_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
"k1_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
"k2_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
"k2_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
"k3_x": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001}),
"k3_y": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001}),
}
}
OUTPUTS = (
('DATA_FIELD', 'corrected'),
)
FUNCTION = "process"
DESCRIPTION = (
"Correct nonlinear scanner distortions with polynomial coordinate "
"warping up to cubic order. Coefficients k1 (linear correction), "
"k2 (quadratic), k3 (cubic) are applied independently to x and y axes. "
)
def process(self, field: DataField,
k1_x: float, k1_y: float,
k2_x: float, k2_y: float,
k3_x: float, k3_y: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Normalised coordinates [-1, 1]
yy, xx = np.mgrid[:yres, :xres]
xn = 2.0 * xx / max(xres - 1, 1) - 1.0
yn = 2.0 * yy / max(yres - 1, 1) - 1.0
# Apply polynomial distortion (inverse mapping)
xn_src = xn + k1_x * xn + k2_x * xn**2 + k3_x * xn**3
yn_src = yn + k1_y * yn + k2_y * yn**2 + k3_y * yn**3
# Convert back to pixel coordinates
sx = (xn_src + 1.0) * max(xres - 1, 1) / 2.0
sy = (yn_src + 1.0) * max(yres - 1, 1) / 2.0
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
return (field.replace(data=result),)

View File

@@ -24,8 +24,7 @@ class PSDF:
DESCRIPTION = (
"Compute the two-dimensional power spectral density function with Gwyddion-style "
"window RMS compensation and centered zero frequency. Equivalent to psdf2d / "
"gwy_data_field_2dpsdf."
"window RMS compensation and centered zero frequency."
)
def process(self, field: DataField, windowing: str, level: str) -> tuple:

View File

@@ -0,0 +1,79 @@
"""Log-polar PSDF — power spectral density in log-polar coordinates."""
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="Log-Polar PSDF")
class LogPolarPSDF:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"n_phi": ("INT", {"default": 180, "min": 36, "max": 720, "step": 1}),
"n_r": ("INT", {"default": 100, "min": 20, "max": 500, "step": 1}),
}
}
OUTPUTS = (
('DATA_FIELD', 'psdf'),
)
FUNCTION = "process"
DESCRIPTION = (
"Compute the power spectral density function in log-polar coordinates. "
"The x-axis is the azimuthal angle (0360°) and y-axis is log(frequency). "
"Better than Cartesian PSDF for anisotropy analysis. "
)
def process(self, field: DataField, n_phi: int, n_r: int) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Compute 2D power spectrum
fft = np.fft.fft2(data - data.mean())
power = np.abs(np.fft.fftshift(fft))**2
cy, cx = yres // 2, xres // 2
# Build log-polar grid
r_max = min(cx, cy)
log_r = np.linspace(0, np.log(r_max), n_r)
phi = np.linspace(0, 2 * np.pi, n_phi, endpoint=False)
result = np.zeros((n_r, n_phi))
for ir in range(n_r):
r = np.exp(log_r[ir])
for ip in range(n_phi):
fx = cx + r * np.cos(phi[ip])
fy = cy + r * np.sin(phi[ip])
# Bilinear interpolation
ix = int(fx)
iy = int(fy)
if 0 <= ix < xres - 1 and 0 <= iy < yres - 1:
dx = fx - ix
dy = fy - iy
val = (power[iy, ix] * (1 - dx) * (1 - dy) +
power[iy, ix + 1] * dx * (1 - dy) +
power[iy + 1, ix] * (1 - dx) * dy +
power[iy + 1, ix + 1] * dx * dy)
result[ir, ip] = val
# Log scale for display
result = np.log1p(result)
psdf_field = DataField(
data=result,
xreal=360.0,
yreal=float(np.log(r_max)),
si_unit_xy="deg",
si_unit_z="",
domain="frequency",
)
return (psdf_field,)

View File

@@ -28,7 +28,6 @@ class RadialProfile:
"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. "
"Equivalent to gwy_data_field_angular_average used by Gwyddion's Radial Profile tool (rprofile.c)."
)
def process(self, field: DataField, cx: float, cy: float, n_bins: int) -> tuple:

View File

@@ -0,0 +1,98 @@
"""Relate two fields — fit functional relationships between two data fields."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
@register_node(display_name="Relate Fields")
class RelateFields:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field_a": ("DATA_FIELD",),
"field_b": ("DATA_FIELD",),
"function": (["linear", "quadratic", "cubic", "power", "logarithmic"],
{"default": "linear"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'predicted'),
('RECORD_TABLE', 'fit_params'),
)
FUNCTION = "process"
DESCRIPTION = (
"Fit a functional relationship between two data fields: b = f(a). "
"Outputs the predicted field_b from the fit and a table of fitted "
"parameters with R² goodness-of-fit. "
)
def process(self, field_a: DataField, field_b: DataField,
function: str) -> tuple:
a = np.asarray(field_a.data, dtype=np.float64).ravel()
b = np.asarray(field_b.data, dtype=np.float64).ravel()
n = min(len(a), len(b))
a, b = a[:n], b[:n]
records = RecordTable()
if function == "linear":
coeffs = np.polyfit(a, b, 1)
predicted = np.polyval(coeffs, a)
records.append({"quantity": "slope", "value": f"{coeffs[0]:.6g}", "unit": ""})
records.append({"quantity": "intercept", "value": f"{coeffs[1]:.6g}", "unit": ""})
elif function == "quadratic":
coeffs = np.polyfit(a, b, 2)
predicted = np.polyval(coeffs, a)
for i, name in enumerate(["a2", "a1", "a0"]):
records.append({"quantity": name, "value": f"{coeffs[i]:.6g}", "unit": ""})
elif function == "cubic":
coeffs = np.polyfit(a, b, 3)
predicted = np.polyval(coeffs, a)
for i, name in enumerate(["a3", "a2", "a1", "a0"]):
records.append({"quantity": name, "value": f"{coeffs[i]:.6g}", "unit": ""})
elif function == "power":
# b = c * a^n → log(b) = log(c) + n*log(a)
valid = (a > 0) & (b > 0)
if valid.sum() < 2:
predicted = np.zeros_like(a)
records.append({"quantity": "error", "value": "insufficient positive values", "unit": ""})
else:
log_coeffs = np.polyfit(np.log(a[valid]), np.log(b[valid]), 1)
n_exp = log_coeffs[0]
c = np.exp(log_coeffs[1])
predicted = np.where(a > 0, c * np.power(a, n_exp), 0)
records.append({"quantity": "exponent", "value": f"{n_exp:.6g}", "unit": ""})
records.append({"quantity": "coefficient", "value": f"{c:.6g}", "unit": ""})
elif function == "logarithmic":
# b = a0 + a1 * log(a)
valid = a > 0
if valid.sum() < 2:
predicted = np.zeros_like(a)
records.append({"quantity": "error", "value": "insufficient positive values", "unit": ""})
else:
coeffs = np.polyfit(np.log(a[valid]), b[valid], 1)
predicted = np.where(a > 0, np.polyval(coeffs, np.log(a)), 0)
records.append({"quantity": "log_coeff", "value": f"{coeffs[0]:.6g}", "unit": ""})
records.append({"quantity": "intercept", "value": f"{coeffs[1]:.6g}", "unit": ""})
else:
predicted = np.zeros_like(a)
# R² statistic
ss_res = np.sum((b - predicted)**2)
ss_tot = np.sum((b - b.mean())**2)
r2 = 1.0 - ss_res / max(ss_tot, 1e-30)
records.append({"quantity": "", "value": f"{r2:.6f}", "unit": ""})
pred_field = field_b.replace(data=predicted.reshape(field_b.data.shape))
return (pred_field, records)

View File

@@ -27,8 +27,6 @@ class Resample:
DESCRIPTION = (
"Resample a DATA_FIELD to a new pixel resolution while preserving physical dimensions. "
"Physical size (xreal, yreal) is unchanged; pixel size dx/dy scales accordingly. "
"Equivalent to gwy_data_field_new_resampled with GWY_INTERPOLATION_LINEAR / "
"GWY_INTERPOLATION_CUBIC / GWY_INTERPOLATION_ROUND (scale.c)."
)
_ORDERS = {"nearest": 0, "linear": 1, "cubic": 3}

View File

@@ -0,0 +1,58 @@
"""Scan line reordering — fix meander, interlace, and deinterlace artifacts."""
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="Scan Line Reorder")
class ScanLineReorder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"operation": (["reverse_odd", "reverse_even", "deinterlace_odd",
"deinterlace_even", "flip_vertical"], {"default": "reverse_odd"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'result'),
)
FUNCTION = "process"
DESCRIPTION = (
"Fix scan line ordering artifacts. reverse_odd/even reverses alternate "
"rows to correct meander (serpentine) scanning. deinterlace selects only "
"odd or even rows and stretches to fill. flip_vertical reverses row order. "
)
def process(self, field: DataField, operation: str) -> tuple:
data = np.asarray(field.data, dtype=np.float64).copy()
yres, xres = data.shape
if operation == "reverse_odd":
data[1::2, :] = data[1::2, ::-1]
elif operation == "reverse_even":
data[0::2, :] = data[0::2, ::-1]
elif operation == "deinterlace_odd":
odd_rows = data[1::2, :]
# Stretch back to original height via linear interpolation
from scipy.ndimage import zoom
scale_y = yres / odd_rows.shape[0]
data = zoom(odd_rows, (scale_y, 1.0), order=1)
elif operation == "deinterlace_even":
even_rows = data[0::2, :]
from scipy.ndimage import zoom
scale_y = yres / even_rows.shape[0]
data = zoom(even_rows, (scale_y, 1.0), order=1)
elif operation == "flip_vertical":
data = data[::-1, :].copy()
else:
raise ValueError(f"Unknown operation: {operation!r}")
return (field.replace(data=data),)

66
backend/nodes/shade.py Normal file
View File

@@ -0,0 +1,66 @@
"""Shaded presentation — render surface with directional lighting."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import sobel
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Shade")
class Shade:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"azimuth": ("FLOAT", {"default": 315.0, "min": 0.0, "max": 360.0, "step": 1.0}),
"elevation": ("FLOAT", {"default": 45.0, "min": 5.0, "max": 85.0, "step": 1.0}),
"blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}),
}
}
OUTPUTS = (
('DATA_FIELD', 'shaded'),
)
FUNCTION = "process"
DESCRIPTION = (
"Render a surface with directional hillshade lighting. "
"Azimuth controls the light direction (0=north, 90=east). "
"Elevation controls the light angle above the horizon. "
"Blend mixes original data (0) with shaded relief (1). "
)
def process(self, field: DataField, azimuth: float, elevation: float,
blend: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
gx = sobel(data, axis=1) / (8.0 * field.dx)
gy = sobel(data, axis=0) / (8.0 * field.dy)
az_rad = np.radians(azimuth)
el_rad = np.radians(elevation)
# Lambertian shading
lx = np.cos(el_rad) * np.sin(az_rad)
ly = np.cos(el_rad) * np.cos(az_rad)
lz = np.sin(el_rad)
normal_z = 1.0 / np.sqrt(gx**2 + gy**2 + 1.0)
normal_x = -gx * normal_z
normal_y = -gy * normal_z
shade = np.clip(normal_x * lx + normal_y * ly + normal_z * lz, 0.0, 1.0)
# Normalize original data to [0, 1]
dmin, dmax = data.min(), data.max()
if dmax > dmin:
norm_data = (data - dmin) / (dmax - dmin)
else:
norm_data = np.ones_like(data) * 0.5
result = (1.0 - blend) * norm_data + blend * shade
return (field.replace(data=result, si_unit_z=""),)

View File

@@ -79,7 +79,6 @@ class ShapeFitting:
"surface data. Outputs either the fitted surface or the residual "
"(original minus fit). Reports fitted parameters including radius "
"of curvature, centre position, etc. "
"Equivalent to Gwyddion's fit-shape.c module."
)
def process(self, field: DataField, shape: str, output: str) -> tuple:

View File

@@ -28,7 +28,6 @@ class SlopeDistribution:
"'theta' is the inclination angle (0max°), probability density (1/deg); "
"'phi' is the azimuthal slope direction (0360°), weighted by slope² (z/xy)²; "
"'gradient' is the gradient magnitude distribution, probability density (1/(z/xy)). "
"Equivalent to Gwyddion's slope_dist module (slope_dist.c)."
)
def process(self, field: DataField, distribution: str, n_bins: int) -> tuple:

View File

@@ -29,7 +29,7 @@ class SpotRemoval:
DESCRIPTION = (
"Fill defect pixels (hot pixels, dropouts, scan artifacts) by interpolation. "
"The mask defines defect locations. Laplace method solves the 2D Laplace equation "
"for smooth inpainting. Equivalent to Gwyddion spotremove.c."
"for smooth inpainting."
)
def process(

View File

@@ -21,7 +21,7 @@ class Statistics:
DESCRIPTION = (
"Compute basic surface statistics: min, max, mean, RMS roughness, median, "
"and skewness. Equivalent to gwy_data_field_get_min/max/avg/rms."
"and skewness."
)
def process(self, field: DataField) -> tuple:

View File

@@ -0,0 +1,95 @@
"""Straighten path — extract cross-section along a curved spline path."""
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="Straighten Path")
class StraightenPath:
@classmethod
def INPUT_TYPES(cls):
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"}),
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
}
}
OUTPUTS = (
('DATA_FIELD', 'straightened'),
)
FUNCTION = "process"
DESCRIPTION = (
"Extract a cross-section along an arbitrary curved path defined by "
"control points. Points are given as fractional coordinates (01). "
"The path is interpolated with cubic splines, and data is sampled "
"along it with configurable thickness. "
)
def process(self, field: DataField, points_x: str, points_y: str,
thickness: int, n_samples: int) -> tuple:
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()]
if len(px) < 2 or len(py) < 2:
# Need at least 2 points
return (field,)
n_pts = min(len(px), len(py))
px, py = px[:n_pts], py[:n_pts]
# 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)
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)
length = np.maximum(length, 1e-10)
nx = -dcy / length
ny = dcx / length
offsets = np.linspace(-(thickness - 1) / 2, (thickness - 1) / 2, thickness)
result = np.zeros((thickness, n_samples))
for i, off in enumerate(offsets):
sx = cx + off * nx
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)),)

View File

@@ -98,7 +98,6 @@ class SyntheticSurface:
"algorithm testing. Patterns: fbm (fractional Brownian motion), "
"white_noise, lattice (periodic grid), steps (terraced), "
"particles (spherical bumps on flat), flat (zero surface). "
"Equivalent to Gwyddion's *_synth.c modules."
)
def process(

View File

@@ -31,7 +31,7 @@ class TemplateMatch:
DESCRIPTION = (
"Find a template pattern within a larger data field using normalised cross-correlation. "
"The score output shows match quality (1 = perfect match). Detections mask marks positions "
"above the threshold. Equivalent to Gwyddion maskcor.c."
"above the threshold."
)
def process(

View File

@@ -0,0 +1,154 @@
"""Terrace fitting — segment atomic step terraces and extract step heights."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import label, uniform_filter
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
@register_node(display_name="Terrace Fit")
class TerraceFit:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"n_terraces": ("INT", {"default": 0, "min": 0, "max": 50}),
"broadening": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 20.0, "step": 0.1}),
"poly_degree": ("INT", {"default": 0, "min": 0, "max": 3}),
"output": (["residual", "fitted", "labels"], {"default": "residual"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'result'),
('RECORD_TABLE', 'step_heights'),
)
FUNCTION = "process"
DESCRIPTION = (
"Segment a surface into flat terraces separated by atomic steps, fit "
"a polynomial to each terrace, and extract step heights. "
"Set n_terraces=0 for automatic detection via histogram clustering. "
)
def process(self, field: DataField, n_terraces: int, broadening: float,
poly_degree: int, output: str) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
# Smooth data to reduce noise before terrace detection
smoothed = uniform_filter(data, size=max(3, int(broadening * 3)))
if n_terraces <= 0:
# Automatic detection: find peaks in height histogram
n_terraces = self._auto_detect_terraces(smoothed)
# Assign each pixel to the nearest terrace level using k-means-like clustering
levels = self._cluster_terraces(smoothed, n_terraces)
labels = np.zeros_like(data, dtype=np.int32)
fitted = np.zeros_like(data)
terrace_means = []
# Direct assignment via argmin
level_arr = np.array(levels)
diffs = np.abs(smoothed[..., np.newaxis] - level_arr[np.newaxis, np.newaxis, :])
labels = np.argmin(diffs, axis=-1).astype(np.int32)
# Fit polynomial per terrace and build fitted surface
yy, xx = np.mgrid[:data.shape[0], :data.shape[1]]
x_phys = xx * field.dx
y_phys = yy * field.dy
for i in range(len(levels)):
terrace_mask = labels == i
if terrace_mask.sum() < max(3, (poly_degree + 1) ** 2):
fitted[terrace_mask] = data[terrace_mask].mean() if terrace_mask.any() else 0
terrace_means.append(float(data[terrace_mask].mean()) if terrace_mask.any() else 0.0)
continue
if poly_degree == 0:
val = data[terrace_mask].mean()
fitted[terrace_mask] = val
terrace_means.append(float(val))
else:
# Build Vandermonde matrix for polynomial fit
xp = x_phys[terrace_mask]
yp = y_phys[terrace_mask]
zp = data[terrace_mask]
cols = []
for py in range(poly_degree + 1):
for px in range(poly_degree + 1 - py):
cols.append(xp**px * yp**py)
A = np.column_stack(cols)
coeffs, _, _, _ = np.linalg.lstsq(A, zp, rcond=None)
# Evaluate on all terrace pixels
all_xp = x_phys[terrace_mask]
all_yp = y_phys[terrace_mask]
val = np.zeros(terrace_mask.sum())
idx = 0
for py in range(poly_degree + 1):
for px in range(poly_degree + 1 - py):
val += coeffs[idx] * all_xp**px * all_yp**py
idx += 1
fitted[terrace_mask] = val
terrace_means.append(float(val.mean()))
# Sort terrace means and compute step heights
terrace_means.sort()
records = RecordTable()
unit = field.si_unit_z
for i, mean in enumerate(terrace_means):
records.append({"quantity": f"Terrace {i + 1} height", "value": f"{mean:.4g}", "unit": unit})
for i in range(1, len(terrace_means)):
step = terrace_means[i] - terrace_means[i - 1]
records.append({"quantity": f"Step {i}{i + 1}", "value": f"{step:.4g}", "unit": unit})
if output == "residual":
out_data = data - fitted
elif output == "fitted":
out_data = fitted
else: # labels
out_data = labels.astype(np.float64)
return (field.replace(data=out_data), records)
@staticmethod
def _auto_detect_terraces(data: np.ndarray) -> int:
"""Detect number of terraces from histogram peaks."""
hist, edges = np.histogram(data.ravel(), bins=256)
smoothed = np.convolve(hist, np.ones(5) / 5, mode='same')
# Find peaks: local maxima above mean
threshold = smoothed.mean()
peaks = []
for i in range(1, len(smoothed) - 1):
if smoothed[i] > smoothed[i - 1] and smoothed[i] > smoothed[i + 1] and smoothed[i] > threshold:
peaks.append(i)
return max(2, min(len(peaks), 20))
@staticmethod
def _cluster_terraces(data: np.ndarray, k: int) -> list[float]:
"""Simple 1D k-means clustering on height values."""
flat = data.ravel()
# Initialize with evenly spaced percentiles
centers = [float(np.percentile(flat, 100 * (i + 0.5) / k)) for i in range(k)]
for _ in range(50):
# Assign
center_arr = np.array(centers)
dists = np.abs(flat[:, np.newaxis] - center_arr[np.newaxis, :])
assignments = np.argmin(dists, axis=1)
# Update
new_centers = []
for i in range(k):
members = flat[assignments == i]
new_centers.append(float(members.mean()) if len(members) > 0 else centers[i])
if np.allclose(centers, new_centers, atol=1e-12):
break
centers = new_centers
return sorted(centers)

49
backend/nodes/tilt.py Normal file
View File

@@ -0,0 +1,49 @@
"""Tilt — apply or remove a linear tilt."""
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="Tilt")
class Tilt:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"slope_x": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.001}),
"slope_y": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.001}),
"mode": (["add", "subtract"], {"default": "add"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'tilted'),
)
FUNCTION = "process"
DESCRIPTION = (
"Apply or subtract a linear tilt (plane). slope_x and slope_y are "
"in data units per physical unit (e.g., m/m for height data). "
"Use 'subtract' mode to remove a known tilt. "
)
def process(self, field: DataField, slope_x: float, slope_y: float,
mode: str) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
x = np.arange(xres) * field.dx
y = np.arange(yres) * field.dy
X, Y = np.meshgrid(x, y)
tilt_plane = slope_x * X + slope_y * Y
if mode == "subtract":
return (field.replace(data=data - tilt_plane),)
else:
return (field.replace(data=data + tilt_plane),)

View File

@@ -492,7 +492,6 @@ class BlindTipEstimate:
"threshold: noise floor in metres — start at 0 and increase if tip is too sharp. "
"Output tip has apex=max, edges=0 (same convention as TipModel). "
"Certainty map marks surface pixels where the tip was in unambiguous single contact. "
"Equivalent to gwy_tip_estimate_partial / gwy_tip_estimate_full + gwy_tip_cmap (tip.c)."
)
def process(

View File

@@ -30,7 +30,6 @@ class TipDeconvolution:
" surface[y,x] = min_{dy,dx}[image[y+dy, x+dx] mytip[dy,dx]] "
"Connect the tip output from a TipModel node. "
"The tip pixel size must match the image pixel size. "
"Equivalent to gwy_tip_erosion (tip.c)."
)
def process(self, field: DataField, tip: DataField) -> tuple:

View File

@@ -36,7 +36,6 @@ class TipModel:
"Shapes: parabola — paraboloid with apex radius R; "
"cone — sphere-capped cone (radius R, half_angle from tip axis in degrees); "
"sphere — ball-on-stick (sphere cap only). "
"Equivalent to gwy_tip_model_preset_create (tip.c / tip_model.c)."
)
def process(

View File

@@ -0,0 +1,54 @@
"""Trimmed mean filter — mean filter excluding extreme percentiles."""
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="Trimmed Mean")
class TrimmedMean:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"radius": ("INT", {"default": 3, "min": 1, "max": 50, "step": 1}),
"trim_fraction": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 0.45, "step": 0.01}),
}
}
OUTPUTS = (
('DATA_FIELD', 'filtered'),
)
FUNCTION = "process"
DESCRIPTION = (
"Apply a local mean filter that excludes the lowest and highest "
"fraction of values in each window. More robust than Gaussian for "
"data with outlier spikes. trim_fraction=0 is a plain mean; "
"trim_fraction=0.5 approaches the median. "
)
def process(self, field: DataField, radius: int, trim_fraction: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
result = np.zeros_like(data)
padded = np.pad(data, radius, mode='edge')
for iy in range(yres):
for ix in range(xres):
window = padded[iy:iy + 2 * radius + 1, ix:ix + 2 * radius + 1].ravel()
n = len(window)
k = int(n * trim_fraction)
if k > 0:
sorted_w = np.sort(window)
trimmed = sorted_w[k:n - k]
else:
trimmed = window
result[iy, ix] = trimmed.mean()
return (field.replace(data=result),)

View File

@@ -33,8 +33,7 @@ class WaveletDenoise:
DESCRIPTION = (
"Denoise using wavelet coefficient thresholding. BayesShrink adapts the threshold "
"per sub-band; VisuShrink uses a global threshold. Equivalent to applying DWT from "
"Gwyddion dwt.c with coefficient thresholding."
"per sub-band; VisuShrink uses a global threshold."
)
def process(

View File

@@ -0,0 +1,56 @@
"""Value wrapping — rewrap periodic values to different ranges."""
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="Wrap Value")
class WrapValue:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"range": (["0_to_360", "neg180_to_180", "0_to_2pi", "neg_pi_to_pi", "custom"],
{"default": "0_to_360"}),
"custom_min": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.1}),
"custom_max": ("FLOAT", {"default": 360.0, "min": -1e6, "max": 1e6, "step": 0.1}),
}
}
OUTPUTS = (
('DATA_FIELD', 'wrapped'),
)
FUNCTION = "process"
DESCRIPTION = (
"Rewrap periodic values (phase, angle) to a specified range. "
"Preset ranges for degrees and radians, or specify a custom range. "
)
def process(self, field: DataField, range: str,
custom_min: float, custom_max: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
ranges = {
"0_to_360": (0.0, 360.0),
"neg180_to_180": (-180.0, 180.0),
"0_to_2pi": (0.0, 2 * np.pi),
"neg_pi_to_pi": (-np.pi, np.pi),
}
if range == "custom":
lo, hi = custom_min, custom_max
else:
lo, hi = ranges[range]
period = hi - lo
if period <= 0:
return (field.replace(data=data),)
wrapped = lo + (data - lo) % period
return (field.replace(data=wrapped),)

View File

@@ -0,0 +1,60 @@
"""Zero crossing detection — edge detection via LoG zero crossings."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import gaussian_laplace
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Zero Crossing")
class ZeroCrossing:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"sigma": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 20.0, "step": 0.1}),
"threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
OUTPUTS = (
('DATA_FIELD', 'edges'),
)
FUNCTION = "process"
DESCRIPTION = (
"Detect edges by finding zero crossings of the Laplacian of Gaussian "
"(LoG). Sigma controls the Gaussian smoothing scale. Threshold filters "
"out weak edges (relative to the LoG range). "
)
def process(self, field: DataField, sigma: float, threshold: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
# Compute LoG
log = gaussian_laplace(data, sigma=sigma)
# Find zero crossings: adjacent pixels with opposite signs
edges = np.zeros_like(data)
# Horizontal crossings
sign_diff_x = log[:, :-1] * log[:, 1:]
cross_x = sign_diff_x < 0
strength_x = np.abs(log[:, :-1] - log[:, 1:])
# Vertical crossings
sign_diff_y = log[:-1, :] * log[1:, :]
cross_y = sign_diff_y < 0
strength_y = np.abs(log[:-1, :] - log[1:, :])
# Apply threshold
max_strength = max(strength_x.max(), strength_y.max(), 1e-30)
edges[:, :-1] += cross_x & (strength_x > threshold * max_strength)
edges[:-1, :] += cross_y & (strength_y > threshold * max_strength)
result = (edges > 0).astype(np.float64)
return (field.replace(data=result, si_unit_z=""),)