low pri features

This commit is contained in:
2026-04-03 22:09:19 -07:00
parent c24eed104e
commit 5d4c6dfcea
25 changed files with 1707 additions and 117 deletions

View File

@@ -1,81 +1,6 @@
# Import all node modules to trigger @register_node decorators.
from backend.nodes import (
# IO
colormap,
crop_resize,
fft_2d_inverse,
filter_fft,
filter_gaussian,
filter_median,
flip,
font,
image,
image_demo,
folder,
coordinate,
coordinate_pair,
level_facet,
level_plane,
level_poly,
mask_draw,
mask_threshold,
note,
number,
text_note,
range_slider,
rotate,
save,
edge_detect,
colormap_adjust,
fix_zero,
line_correction,
# Mask
mask_morphology,
mask_invert,
mask_operations,
grain_distance_transform,
save_layers,
# Correction
scar_removal,
# Display
annotations,
angle_measure,
markup,
preview_image,
statistics,
value_io,
view_3d,
print_table,
curvature,
fractal_dimension,
histogram,
acf_2d,
acf_1d,
cursors,
fft_2d,
psdf,
cross_section,
stats,
watershed_segmentation,
grain_analysis,
fft_1d,
# Medium-value nodes (Gwyddion feature-gap)
field_arithmetic,
gradient,
resample,
slope_distribution,
grain_filter,
radial_profile,
tip_model,
tip_deconvolution,
tip_blind_estimate,
# New: remaining Gwyddion feature-gap nodes
filter_kuwahara,
entropy,
local_contrast,
filter_custom,
spot_removal,
wavelet_denoise,
cross_correlate,
template_match,
)
# Auto-import all node modules to trigger @register_node decorators.
import importlib
import pkgutil
for _finder, _name, _ispkg in pkgutil.iter_modules(__path__):
importlib.import_module(f"{__name__}.{_name}")

View File

@@ -0,0 +1,66 @@
"""Affine correction — fix geometric distortions from scanner nonlinearity."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import affine_transform
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Affine Correction")
class AffineCorrection:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"shear_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"shear_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"scale_x": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.01}),
"scale_y": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.01}),
"angle": ("FLOAT", {"default": 0.0, "min": -45.0, "max": 45.0, "step": 0.1}),
}
}
OUTPUTS = (
('DATA_FIELD', 'corrected'),
)
FUNCTION = "process"
DESCRIPTION = (
"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(
self,
field: DataField,
shear_x: float,
shear_y: float,
scale_x: float,
scale_y: float,
angle: float,
) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
cy, cx = yres / 2.0, xres / 2.0
theta = np.radians(angle)
cos_t, sin_t = np.cos(theta), np.sin(theta)
# Build the correction matrix: Scale * Shear * Rotation
rotation = np.array([[cos_t, -sin_t], [sin_t, cos_t]])
shear = np.array([[1.0, shear_x], [shear_y, 1.0]])
scale = np.array([[1.0 / scale_x, 0.0], [0.0, 1.0 / scale_y]])
matrix = scale @ shear @ rotation
# Offset so the transform is centred
offset = np.array([cy, cx]) - matrix @ np.array([cy, cx])
corrected = affine_transform(data, matrix, offset=offset, order=3, mode="reflect")
return (field.replace(data=corrected),)

View File

@@ -0,0 +1,80 @@
"""Deconvolution — image restoration via regularised deconvolution."""
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="Deconvolution")
class Deconvolution:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"method": (["wiener", "richardson_lucy"], {"default": "wiener"}),
"sigma": ("FLOAT", {"default": 2.0, "min": 0.1, "max": 50.0, "step": 0.1}),
"regularisation": ("FLOAT", {"default": 0.01, "min": 1e-6, "max": 1.0, "step": 0.001}),
"iterations": ("INT", {"default": 10, "min": 1, "max": 200}),
}
}
OUTPUTS = (
('DATA_FIELD', 'restored'),
)
FUNCTION = "process"
DESCRIPTION = (
"Restore an image via regularised deconvolution. Assumes the image was "
"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(
self,
field: DataField,
method: str,
sigma: float,
regularisation: float,
iterations: int,
) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Build Gaussian PSF in frequency domain
kx = np.fft.fftfreq(xres)
ky = np.fft.fftfreq(yres)
KX, KY = np.meshgrid(kx, ky)
K2 = KX**2 + KY**2
H = np.exp(-2.0 * (np.pi * sigma)**2 * K2)
if method == "wiener":
fft_data = np.fft.fft2(data)
H2 = H * H
# Wiener filter: H* / (|H|² + λ)
wiener = np.conj(H) / (H2 + regularisation)
restored = np.real(np.fft.ifft2(fft_data * wiener))
elif method == "richardson_lucy":
# Richardson-Lucy iterative deconvolution
estimate = data.copy()
H_conj = np.conj(H)
for _ in range(iterations):
estimate = np.maximum(estimate, 1e-30) # positivity
blurred = np.real(np.fft.ifft2(np.fft.fft2(estimate) * H))
blurred = np.maximum(blurred, 1e-30)
ratio = data / blurred
correction = np.real(np.fft.ifft2(np.fft.fft2(ratio) * H_conj))
estimate = estimate * correction
restored = estimate
else:
raise ValueError(f"Unknown deconvolution method: {method!r}")
return (field.replace(data=restored),)

View File

@@ -0,0 +1,81 @@
"""Drift correction — compensate for thermal/piezo drift between scan lines."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import shift as ndimage_shift
from backend.node_registry import register_node
from backend.data_types import DataField
def _estimate_drift(data: np.ndarray, reference: str) -> np.ndarray:
"""Estimate per-row horizontal drift via cross-correlation with a reference.
Returns an array of shape (yres,) with the estimated x-shift (in pixels)
for each row.
"""
yres, xres = data.shape
shifts = np.zeros(yres)
if reference == "previous_row":
for i in range(1, yres):
corr = np.correlate(data[i - 1], data[i], mode="full")
peak = np.argmax(corr) - (xres - 1)
shifts[i] = shifts[i - 1] + peak
elif reference == "mean_row":
mean_row = data.mean(axis=0)
for i in range(yres):
corr = np.correlate(mean_row, data[i], mode="full")
peak = np.argmax(corr) - (xres - 1)
shifts[i] = peak
else:
raise ValueError(f"Unknown reference: {reference!r}")
# Remove the overall mean to centre the correction
shifts -= shifts.mean()
return shifts
@register_node(display_name="Drift Correction")
class DriftCorrection:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"reference": (["previous_row", "mean_row"], {"default": "previous_row"}),
"direction": (["horizontal", "vertical"], {"default": "horizontal"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'corrected'),
)
FUNCTION = "process"
DESCRIPTION = (
"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:
data = np.asarray(field.data, dtype=np.float64)
if direction == "vertical":
data = data.T
shifts = _estimate_drift(data, reference)
corrected = np.empty_like(data)
for i, s in enumerate(shifts):
if abs(s) < 0.01:
corrected[i] = data[i]
else:
ndimage_shift(data[i], -s, output=corrected[i], mode="reflect")
if direction == "vertical":
corrected = corrected.T
return (field.replace(data=corrected),)

View File

@@ -0,0 +1,80 @@
"""Facet analysis — orientation distribution of surface facets."""
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="Facet Analysis")
class FacetAnalysis:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"n_bins": ("INT", {"default": 180, "min": 30, "max": 720}),
"kernel_size": ("INT", {"default": 3, "min": 3, "max": 9, "step": 2}),
}
}
OUTPUTS = (
('DATA_FIELD', 'facet_map'),
)
FUNCTION = "process"
DESCRIPTION = (
"Compute the facet orientation distribution of a surface. "
"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:
data = np.asarray(field.data, dtype=np.float64)
# Compute gradients with Sobel-like kernel for robustness
from scipy.ndimage import sobel
gx = sobel(data, axis=1) / (kernel_size * field.dx)
gy = sobel(data, axis=0) / (kernel_size * field.dy)
# Convert to spherical angles
theta = np.arctan(np.sqrt(gx**2 + gy**2)) # inclination from vertical
phi = np.arctan2(gy, gx) # azimuthal angle
# Map to [0, pi/2] and [0, 2*pi]
theta_flat = theta.ravel()
phi_flat = (phi.ravel() + 2 * np.pi) % (2 * np.pi)
# Build 2D histogram (stereographic projection)
theta_max = float(theta_flat.max()) if theta_flat.size > 0 else np.pi / 4
theta_max = max(theta_max, 0.01)
n_theta = max(1, n_bins // 4)
n_phi = n_bins
hist, _, _ = np.histogram2d(
theta_flat,
phi_flat,
bins=[n_theta, n_phi],
range=[[0.0, theta_max], [0.0, 2 * np.pi]],
)
# Normalise so it represents a probability density
total = hist.sum()
if total > 0:
hist = hist / total
facet_field = DataField(
data=hist,
xreal=360.0,
yreal=np.degrees(theta_max),
si_unit_xy="deg",
si_unit_z="",
domain="spatial",
)
return (facet_field,)

View File

@@ -0,0 +1,99 @@
"""Feature detection — Canny edge and Harris corner detection."""
from __future__ import annotations
import numpy as np
from skimage.feature import canny, corner_harris, corner_peaks
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
@register_node(display_name="Feature Detection")
class FeatureDetection:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"method": (["canny", "harris"], {"default": "canny"}),
"sigma": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 20.0, "step": 0.1}),
},
"optional": {
"low_threshold": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
"high_threshold": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}),
"harris_k": ("FLOAT", {"default": 0.05, "min": 0.01, "max": 0.5, "step": 0.01}),
"min_distance": ("INT", {"default": 5, "min": 1, "max": 100}),
}
}
OUTPUTS = (
('DATA_FIELD', 'result'),
('RECORD_TABLE', 'features'),
)
FUNCTION = "process"
DESCRIPTION = (
"Detect edges or corners in a surface. "
"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(
self,
field: DataField,
method: str,
sigma: float,
low_threshold: float = 0.1,
high_threshold: float = 0.2,
harris_k: float = 0.05,
min_distance: int = 5,
) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
# Normalise to [0, 1]
dmin, dmax = data.min(), data.max()
if dmax > dmin:
norm = (data - dmin) / (dmax - dmin)
else:
norm = np.zeros_like(data)
records: RecordTable = RecordTable()
if method == "canny":
edges = canny(
norm,
sigma=sigma,
low_threshold=low_threshold,
high_threshold=high_threshold,
)
result = edges.astype(np.float64)
n_edge_pixels = int(edges.sum())
edge_fraction = n_edge_pixels / data.size
records.append({"quantity": "Edge pixels", "value": str(n_edge_pixels), "unit": ""})
records.append({"quantity": "Edge fraction", "value": f"{edge_fraction:.4f}", "unit": ""})
elif method == "harris":
response = corner_harris(norm, sigma=sigma, k=harris_k)
result = response.astype(np.float64)
corners = corner_peaks(
response,
min_distance=min_distance,
threshold_rel=0.1,
)
records.append({"quantity": "Corners detected", "value": str(len(corners)), "unit": ""})
for i, (row, col) in enumerate(corners[:20]):
x_phys = col * field.dx
y_phys = row * field.dy
records.append({
"quantity": f"Corner {i + 1}",
"value": f"({x_phys:.4g}, {y_phys:.4g})",
"unit": field.si_unit_xy,
})
else:
raise ValueError(f"Unknown method: {method!r}")
return (field.replace(data=result, si_unit_z=""), records)

View File

@@ -0,0 +1,102 @@
"""Hough transform — detect lines and circles in images."""
from __future__ import annotations
import numpy as np
from skimage.transform import hough_line, hough_line_peaks, hough_circle, hough_circle_peaks
from skimage.feature import canny
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
@register_node(display_name="Hough Transform")
class HoughTransform:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"detect": (["lines", "circles"], {"default": "lines"}),
"n_features": ("INT", {"default": 5, "min": 1, "max": 50}),
"canny_sigma": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1}),
"min_radius_px": ("INT", {"default": 10, "min": 2, "max": 500}),
"max_radius_px": ("INT", {"default": 100, "min": 5, "max": 1000}),
}
}
OUTPUTS = (
('DATA_FIELD', 'accumulator'),
('RECORD_TABLE', 'features'),
)
FUNCTION = "process"
DESCRIPTION = (
"Detect lines or circles using the Hough transform. "
"First applies Canny edge detection, then accumulates votes in "
"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(
self,
field: DataField,
detect: str,
n_features: int,
canny_sigma: float,
min_radius_px: int,
max_radius_px: int,
) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
# Normalise to [0, 1] for edge detection
dmin, dmax = data.min(), data.max()
if dmax > dmin:
norm = (data - dmin) / (dmax - dmin)
else:
norm = np.zeros_like(data)
edges = canny(norm, sigma=canny_sigma)
records: RecordTable = RecordTable()
if detect == "lines":
tested_angles = np.linspace(-np.pi / 2, np.pi / 2, 180, endpoint=False)
h, theta, d = hough_line(edges, theta=tested_angles)
accum = field.replace(data=h.astype(np.float64), si_unit_z="", domain="frequency")
peaks = hough_line_peaks(h, theta, d, num_peaks=n_features)
for i, (hval, angle, dist) in enumerate(zip(*peaks)):
angle_deg = np.degrees(angle)
dist_phys = dist * field.dx
records.append({"quantity": f"Line {i + 1} angle", "value": f"{angle_deg:.1f}", "unit": "deg"})
records.append({"quantity": f"Line {i + 1} distance", "value": f"{dist_phys:.4g}", "unit": field.si_unit_xy})
records.append({"quantity": f"Line {i + 1} votes", "value": f"{hval:.0f}", "unit": ""})
elif detect == "circles":
max_radius_px = max(min_radius_px + 1, max_radius_px)
radii = np.arange(min_radius_px, max_radius_px + 1)
h = hough_circle(edges, radii)
# Sum over radii for the accumulator visualisation
accum_data = h.sum(axis=0).astype(np.float64)
accum = field.replace(data=accum_data, si_unit_z="")
accum_list, cx_list, cy_list, rad_list = hough_circle_peaks(
h, radii, total_num_peaks=n_features,
)
for i, (hval, cx, cy, r) in enumerate(zip(accum_list, cx_list, cy_list, rad_list)):
cx_phys = cx * field.dx
cy_phys = cy * field.dy
r_phys = r * field.dx
records.append({"quantity": f"Circle {i + 1} x", "value": f"{cx_phys:.4g}", "unit": field.si_unit_xy})
records.append({"quantity": f"Circle {i + 1} y", "value": f"{cy_phys:.4g}", "unit": field.si_unit_xy})
records.append({"quantity": f"Circle {i + 1} radius", "value": f"{r_phys:.4g}", "unit": field.si_unit_xy})
else:
raise ValueError(f"Unknown detect mode: {detect!r}")
return (accum, records)

View File

@@ -0,0 +1,131 @@
"""Image stitching — combine two overlapping scans into one image."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
def _find_overlap_shift(a: np.ndarray, b: np.ndarray) -> tuple[int, int]:
"""Estimate the (dy, dx) pixel shift of b relative to a via cross-correlation."""
fa = np.fft.fft2(a - a.mean())
fb = np.fft.fft2(b - b.mean())
cross = np.fft.ifft2(fa * np.conj(fb))
cc = np.abs(np.fft.fftshift(cross))
cy, cx = np.array(cc.shape) // 2
peak = np.unravel_index(np.argmax(cc), cc.shape)
dy = peak[0] - cy
dx = peak[1] - cx
return int(dy), int(dx)
def _blend_overlap(a: np.ndarray, b: np.ndarray, axis: int) -> np.ndarray:
"""Linear blend along the overlap axis."""
length = a.shape[axis]
weight = np.linspace(1.0, 0.0, length)
if axis == 0:
weight = weight[:, np.newaxis]
return a * weight + b * (1.0 - weight)
@register_node(display_name="Image Stitch")
class ImageStitch:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field_a": ("DATA_FIELD",),
"field_b": ("DATA_FIELD",),
"direction": (["right", "below", "auto"], {"default": "auto"}),
"blend": (["linear", "none"], {"default": "linear"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'stitched'),
)
FUNCTION = "process"
DESCRIPTION = (
"Combine two overlapping scans into a single image. "
"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:
a = np.asarray(field_a.data, dtype=np.float64)
b = np.asarray(field_b.data, dtype=np.float64)
if direction == "auto":
# Pad b to match a's 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
b_pad[:b.shape[0], :b.shape[1]] = b
dy, dx = _find_overlap_shift(a_pad, b_pad)
direction = "right" if abs(dx) >= abs(dy) else "below"
if direction == "right":
# b is to the right of a
dy, dx = _find_overlap_shift(
a[:, -min(a.shape[1], b.shape[1]):],
b[:, :min(a.shape[1], b.shape[1])],
)
overlap = max(0, min(a.shape[1], b.shape[1]) - abs(dx))
if overlap <= 0:
# No overlap, just concatenate
out_h = max(a.shape[0], b.shape[0])
out = np.zeros((out_h, a.shape[1] + b.shape[1]))
out[:a.shape[0], :a.shape[1]] = a
out[:b.shape[0], a.shape[1]:] = b
else:
out_h = max(a.shape[0], b.shape[0])
out_w = a.shape[1] + b.shape[1] - overlap
out = np.zeros((out_h, out_w))
out[:a.shape[0], :a.shape[1]] = a
b_start = a.shape[1] - overlap
if blend == "linear" and overlap > 1:
ov_a = a[:min(a.shape[0], b.shape[0]), a.shape[1] - overlap:]
ov_b = b[:min(a.shape[0], b.shape[0]), :overlap]
blended = _blend_overlap(ov_a, ov_b, axis=1)
out[:blended.shape[0], b_start:b_start + overlap] = blended
out[:b.shape[0], b_start + overlap:b_start + b.shape[1]] = b[:, overlap:]
else:
out[:b.shape[0], b_start:b_start + b.shape[1]] = b
elif direction == "below":
dy, dx = _find_overlap_shift(
a[-min(a.shape[0], b.shape[0]):, :],
b[:min(a.shape[0], b.shape[0]), :],
)
overlap = max(0, min(a.shape[0], b.shape[0]) - abs(dy))
if overlap <= 0:
out_w = max(a.shape[1], b.shape[1])
out = np.zeros((a.shape[0] + b.shape[0], out_w))
out[:a.shape[0], :a.shape[1]] = a
out[a.shape[0]:, :b.shape[1]] = b
else:
out_w = max(a.shape[1], b.shape[1])
out_h = a.shape[0] + b.shape[0] - overlap
out = np.zeros((out_h, out_w))
out[:a.shape[0], :a.shape[1]] = a
b_start = a.shape[0] - overlap
if blend == "linear" and overlap > 1:
ov_a = a[a.shape[0] - overlap:, :min(a.shape[1], b.shape[1])]
ov_b = b[:overlap, :min(a.shape[1], b.shape[1])]
blended = _blend_overlap(ov_a, ov_b, axis=0)
out[b_start:b_start + overlap, :blended.shape[1]] = blended
out[b_start + overlap:b_start + b.shape[0], :b.shape[1]] = b[overlap:, :]
else:
out[b_start:b_start + b.shape[0], :b.shape[1]] = b
else:
raise ValueError(f"Unknown direction: {direction!r}")
xreal = out.shape[1] * field_a.dx
yreal = out.shape[0] * field_a.dy
return (field_a.replace(data=out, xreal=xreal, yreal=yreal),)

View File

@@ -0,0 +1,116 @@
"""Lattice measurement — detect and measure periodic lattice structures."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
def _find_acf_peaks(acf: np.ndarray, dx: float, dy: float, n_peaks: int = 6):
"""Find the strongest off-centre peaks in a 2D ACF.
Returns list of (row, col, distance, angle_deg) tuples sorted by
correlation strength.
"""
yres, xres = acf.shape
cy, cx = yres // 2, xres // 2
# Mask the central DC region (within ~5% of the field size)
mask_radius = max(3, min(yres, xres) // 20)
yy, xx = np.ogrid[:yres, :xres]
dc_mask = ((yy - cy) ** 2 + (xx - cx) ** 2) <= mask_radius ** 2
acf_masked = acf.copy()
acf_masked[dc_mask] = -np.inf
peaks = []
for _ in range(n_peaks):
idx = np.argmax(acf_masked)
r, c = np.unravel_index(idx, acf.shape)
if acf_masked[r, c] == -np.inf:
break
# Physical distance from centre
dist_x = (c - cx) * dx
dist_y = (r - cy) * dy
distance = np.hypot(dist_x, dist_y)
angle = np.degrees(np.arctan2(dist_y, dist_x))
peaks.append((r, c, distance, angle, float(acf[r, c])))
# Suppress neighbourhood
sup_r = max(0, r - mask_radius), min(yres, r + mask_radius + 1)
sup_c = max(0, c - mask_radius), min(xres, c + mask_radius + 1)
acf_masked[sup_r[0]:sup_r[1], sup_c[0]:sup_c[1]] = -np.inf
return peaks
@register_node(display_name="Lattice Measurement")
class LatticeMeasurement:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"method": (["acf", "fft"], {"default": "acf"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'correlation'),
('RECORD_TABLE', 'lattice'),
)
FUNCTION = "process"
DESCRIPTION = (
"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:
data = np.asarray(field.data, dtype=np.float64)
data = data - data.mean()
if method == "acf":
fft_data = np.fft.fft2(data)
power = np.abs(fft_data) ** 2
acf = np.real(np.fft.ifft2(power))
acf = np.fft.fftshift(acf)
acf /= acf.max() if acf.max() != 0 else 1.0
corr_field = field.replace(data=acf, si_unit_z="", domain="spatial")
elif method == "fft":
fft_data = np.fft.fft2(data)
power = np.fft.fftshift(np.abs(fft_data) ** 2)
power = np.log1p(power)
corr_field = field.replace(data=power, si_unit_z="", domain="frequency")
else:
raise ValueError(f"Unknown method: {method!r}")
# Find lattice peaks from ACF
if method == "fft":
# Convert FFT peaks to ACF for lattice measurement
fft_data = np.fft.fft2(data)
power_raw = np.abs(fft_data) ** 2
acf = np.real(np.fft.ifft2(power_raw))
acf = np.fft.fftshift(acf)
acf /= acf.max() if acf.max() != 0 else 1.0
peaks = _find_acf_peaks(acf, field.dx, field.dy)
records: RecordTable = RecordTable()
if len(peaks) >= 1:
_, _, d1, a1, s1 = peaks[0]
records.append({"quantity": "Vector 1 spacing", "value": f"{d1:.4g}", "unit": field.si_unit_xy})
records.append({"quantity": "Vector 1 angle", "value": f"{a1:.1f}", "unit": "deg"})
records.append({"quantity": "Vector 1 strength", "value": f"{s1:.4g}", "unit": ""})
if len(peaks) >= 2:
_, _, d2, a2, s2 = peaks[1]
records.append({"quantity": "Vector 2 spacing", "value": f"{d2:.4g}", "unit": field.si_unit_xy})
records.append({"quantity": "Vector 2 angle", "value": f"{a2:.1f}", "unit": "deg"})
records.append({"quantity": "Vector 2 strength", "value": f"{s2:.4g}", "unit": ""})
if len(peaks) >= 2:
angle_diff = abs(peaks[1][3] - peaks[0][3])
records.append({"quantity": "Lattice angle", "value": f"{angle_diff:.1f}", "unit": "deg"})
return (corr_field, records)

View File

@@ -0,0 +1,89 @@
"""MFM analysis — magnetic force microscopy field calculations."""
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="MFM Analysis")
class MFMAnalysis:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"operation": (["phase_to_force_gradient", "force_gradient_to_field",
"charge_density", "magnetisation"],),
"lift_height": ("FLOAT", {
"default": 50e-9, "min": 1e-9, "max": 1e-6, "step": 1e-9,
}),
}
}
OUTPUTS = (
('DATA_FIELD', 'result'),
)
FUNCTION = "process"
DESCRIPTION = (
"Magnetic force microscopy analysis. Converts MFM phase or frequency "
"shift data into physical quantities using Fourier-domain transfer "
"functions. Operations: phase_to_force_gradient converts phase to "
"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:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Build frequency grids
kx = np.fft.fftfreq(xres, d=field.dx) * 2 * np.pi
ky = np.fft.fftfreq(yres, d=field.dy) * 2 * np.pi
KX, KY = np.meshgrid(kx, ky)
K = np.sqrt(KX**2 + KY**2)
# Avoid division by zero at DC
K_safe = np.where(K == 0, 1.0, K)
fft_data = np.fft.fft2(data)
if operation == "phase_to_force_gradient":
# Phase ∝ F'' / k_cantilever, output is proportional to d²F/dz²
# Just propagate to the surface by multiplying by exp(k*h)
transfer = np.exp(K * lift_height)
transfer[0, 0] = 1.0
result = np.real(np.fft.ifft2(fft_data * transfer))
z_unit = field.si_unit_z
elif operation == "force_gradient_to_field":
# Recover Hz from d²F/dz² using the relation in Fourier space:
# Hz(k) = F''(k) * exp(k*h) / (μ₀ * k²)
transfer = np.exp(K * lift_height) / (K_safe**2)
transfer[0, 0] = 0.0 # remove DC
result = np.real(np.fft.ifft2(fft_data * transfer))
z_unit = "A/m"
elif operation == "charge_density":
# σ_m = -dHz/dz ∝ k * Hz in Fourier space
transfer = K * np.exp(K * lift_height) / (K_safe**2)
transfer[0, 0] = 0.0
result = np.real(np.fft.ifft2(fft_data * transfer))
z_unit = "A/m²"
elif operation == "magnetisation":
# Mz ∝ σ_m, further divide by k to integrate
transfer = np.exp(K * lift_height) / K_safe
transfer[0, 0] = 0.0
result = np.real(np.fft.ifft2(fft_data * transfer))
z_unit = "A/m"
else:
raise ValueError(f"Unknown MFM operation: {operation!r}")
return (field.replace(data=result, si_unit_z=z_unit),)

View File

@@ -0,0 +1,132 @@
"""Shape fitting — fit geometric primitives to surface data."""
from __future__ import annotations
import numpy as np
from scipy.optimize import least_squares
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
def _fit_sphere(x, y, z):
"""Fit z = z0 - sqrt(R² - (x-cx)² - (y-cy)²) via least squares."""
cx0 = x.mean()
cy0 = y.mean()
r0 = max(x.max() - x.min(), y.max() - y.min()) * 2
def residuals(params):
cx, cy, z0, R = params
r2 = (x - cx)**2 + (y - cy)**2
valid = r2 < R**2
model = np.where(valid, z0 - np.sqrt(np.maximum(R**2 - r2, 0)), z0)
return z - model
result = least_squares(residuals, [cx0, cy0, z.max(), r0], method="lm")
cx, cy, z0, R = result.x
return {"cx": cx, "cy": cy, "z0": z0, "R": abs(R)}, result.fun
def _fit_paraboloid(x, y, z):
"""Fit z = z0 + a*(x-cx)² + b*(y-cy)² via least squares."""
cx0 = x.mean()
cy0 = y.mean()
def residuals(params):
cx, cy, z0, a, b = params
model = z0 + a * (x - cx)**2 + b * (y - cy)**2
return z - model
result = least_squares(residuals, [cx0, cy0, z.mean(), 0.0, 0.0], method="lm")
cx, cy, z0, a, b = result.x
return {"cx": cx, "cy": cy, "z0": z0, "a": a, "b": b}, result.fun
def _fit_cylinder(x, y, z):
"""Fit z = z0 + a*(x*cos(θ) + y*sin(θ) - d)² (cylinder along one axis)."""
def residuals(params):
z0, a, theta, d = params
u = x * np.cos(theta) + y * np.sin(theta) - d
model = z0 + a * u**2
return z - model
result = least_squares(residuals, [z.mean(), 0.0, 0.0, 0.0], method="lm")
z0, a, theta, d = result.x
R = abs(0.5 / a) if abs(a) > 1e-20 else float("inf")
return {"z0": z0, "curvature": a, "angle_deg": np.degrees(theta), "R": R}, result.fun
@register_node(display_name="Shape Fitting")
class ShapeFitting:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"shape": (["sphere", "paraboloid", "cylinder"], {"default": "sphere"}),
"output": (["residual", "fitted"], {"default": "residual"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'result'),
('RECORD_TABLE', 'parameters'),
)
FUNCTION = "process"
DESCRIPTION = (
"Fit a geometric primitive (sphere, paraboloid, or cylinder) to the "
"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:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Build physical coordinate grids
x = np.arange(xres) * field.dx + field.xoff
y = np.arange(yres) * field.dy + field.yoff
X, Y = np.meshgrid(x, y)
x_flat = X.ravel()
y_flat = Y.ravel()
z_flat = data.ravel()
if shape == "sphere":
params, residuals = _fit_sphere(x_flat, y_flat, z_flat)
elif shape == "paraboloid":
params, residuals = _fit_paraboloid(x_flat, y_flat, z_flat)
elif shape == "cylinder":
params, residuals = _fit_cylinder(x_flat, y_flat, z_flat)
else:
raise ValueError(f"Unknown shape: {shape!r}")
# Reconstruct the fitted surface
residual_map = residuals.reshape(data.shape)
fitted_map = data - residual_map
if output == "residual":
out_data = residual_map
else:
out_data = fitted_map
# Build result table
records: RecordTable = RecordTable()
rms = float(np.sqrt(np.mean(residuals**2)))
records.append({"quantity": "RMS residual", "value": f"{rms:.4g}", "unit": field.si_unit_z})
unit_xy = field.si_unit_xy
unit_z = field.si_unit_z
for key, val in params.items():
if key in ("cx", "cy", "R", "d"):
records.append({"quantity": key, "value": f"{val:.4g}", "unit": unit_xy})
elif key in ("z0",):
records.append({"quantity": key, "value": f"{val:.4g}", "unit": unit_z})
elif key == "angle_deg":
records.append({"quantity": "angle", "value": f"{val:.2f}", "unit": "deg"})
else:
records.append({"quantity": key, "value": f"{val:.4g}", "unit": ""})
return (field.replace(data=out_data), records)

View File

@@ -0,0 +1,152 @@
"""Synthetic surface generation — create test surfaces for development and calibration."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
def _fbm_surface(shape, rng, H=0.7):
"""Fractional Brownian motion surface via spectral synthesis."""
yres, xres = shape
kx = np.fft.fftfreq(xres)
ky = np.fft.fftfreq(yres)
KX, KY = np.meshgrid(kx, ky)
K = np.sqrt(KX**2 + KY**2)
K[0, 0] = 1.0 # avoid division by zero
power = K ** (-(H + 1.0))
power[0, 0] = 0.0
phases = rng.uniform(0, 2 * np.pi, shape)
amplitudes = rng.standard_normal(shape)
fft_data = amplitudes * np.sqrt(power) * np.exp(1j * phases)
surface = np.real(np.fft.ifft2(fft_data))
return surface
def _lattice_surface(shape, xreal, yreal, spacing, angle_deg):
"""Periodic lattice (sinusoidal grid)."""
yres, xres = shape
x = np.linspace(0, xreal, xres, endpoint=False)
y = np.linspace(0, yreal, yres, endpoint=False)
X, Y = np.meshgrid(x, y)
theta = np.radians(angle_deg)
k = 2 * np.pi / spacing
surface = np.cos(k * X) + np.cos(k * (X * np.cos(theta) + Y * np.sin(theta)))
return surface
def _steps_surface(shape, n_steps):
"""Terraced step structure."""
yres, xres = shape
ramp = np.linspace(0, n_steps, xres, endpoint=False)
steps = np.floor(ramp)
return np.tile(steps, (yres, 1)).astype(np.float64)
def _particles_surface(shape, rng, n_particles, radius_px):
"""Random spherical particles on a flat background."""
yres, xres = shape
surface = np.zeros(shape)
yy, xx = np.ogrid[:yres, :xres]
for _ in range(n_particles):
cy = rng.integers(0, yres)
cx = rng.integers(0, xres)
r2 = (yy - cy)**2 + (xx - cx)**2
height = np.sqrt(np.maximum(radius_px**2 - r2, 0.0))
surface = np.maximum(surface, height)
return surface
@register_node(display_name="Synthetic Surface")
class SyntheticSurface:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pattern": (["fbm", "white_noise", "lattice", "steps", "particles", "flat"],
{"default": "fbm"}),
"xres": ("INT", {"default": 256, "min": 16, "max": 2048}),
"yres": ("INT", {"default": 256, "min": 16, "max": 2048}),
"xreal": ("FLOAT", {"default": 1e-6, "min": 1e-9, "max": 1.0, "step": 1e-9}),
"yreal": ("FLOAT", {"default": 1e-6, "min": 1e-9, "max": 1.0, "step": 1e-9}),
"amplitude": ("FLOAT", {"default": 1e-9, "min": 0.0, "max": 1e-3, "step": 1e-10}),
"seed": ("INT", {"default": 42, "min": 0, "max": 999999}),
},
"optional": {
"hurst_exponent": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.05}),
"lattice_spacing": ("FLOAT", {
"default": 100e-9, "min": 1e-9, "max": 1e-3, "step": 1e-9,
}),
"lattice_angle": ("FLOAT", {"default": 90.0, "min": 0.0, "max": 180.0, "step": 1.0}),
"n_steps": ("INT", {"default": 5, "min": 1, "max": 100}),
"n_particles": ("INT", {"default": 20, "min": 1, "max": 500}),
"particle_radius_px": ("INT", {"default": 10, "min": 2, "max": 100}),
}
}
OUTPUTS = (
('DATA_FIELD', 'surface'),
)
FUNCTION = "process"
DESCRIPTION = (
"Generate synthetic test surfaces for development, calibration, and "
"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(
self,
pattern: str,
xres: int,
yres: int,
xreal: float,
yreal: float,
amplitude: float,
seed: int,
hurst_exponent: float = 0.7,
lattice_spacing: float = 100e-9,
lattice_angle: float = 90.0,
n_steps: int = 5,
n_particles: int = 20,
particle_radius_px: int = 10,
) -> tuple:
shape = (yres, xres)
rng = np.random.default_rng(seed)
if pattern == "fbm":
data = _fbm_surface(shape, rng, H=hurst_exponent)
elif pattern == "white_noise":
data = rng.standard_normal(shape)
elif pattern == "lattice":
data = _lattice_surface(shape, xreal, yreal, lattice_spacing, lattice_angle)
elif pattern == "steps":
data = _steps_surface(shape, n_steps)
elif pattern == "particles":
data = _particles_surface(shape, rng, n_particles, particle_radius_px)
elif pattern == "flat":
data = np.zeros(shape)
else:
raise ValueError(f"Unknown pattern: {pattern!r}")
# Normalise and scale by amplitude
drange = data.max() - data.min()
if drange > 0:
data = (data - data.min()) / drange * amplitude
else:
data = np.zeros(shape)
field = DataField(
data=data,
xreal=xreal,
yreal=yreal,
si_unit_xy="m",
si_unit_z="m",
)
return (field,)