adding more nodes
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
64
backend/nodes/extend_pad.py
Normal file
64
backend/nodes/extend_pad.py
Normal 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),)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
53
backend/nodes/filter_rank.py
Normal file
53
backend/nodes/filter_rank.py
Normal 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),)
|
||||
@@ -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:
|
||||
|
||||
67
backend/nodes/flatten_base.py
Normal file
67
backend/nodes/flatten_base.py
Normal 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),)
|
||||
78
backend/nodes/fractal_interpolation.py
Normal file
78
backend/nodes/fractal_interpolation.py
Normal 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),)
|
||||
50
backend/nodes/freq_split.py
Normal file
50
backend/nodes/freq_split.py
Normal 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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
84
backend/nodes/grain_cross.py
Normal file
84
backend/nodes/grain_cross.py
Normal 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,)
|
||||
99
backend/nodes/grain_distributions.py
Normal file
99
backend/nodes/grain_distributions.py
Normal 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",
|
||||
),)
|
||||
51
backend/nodes/grain_edge.py
Normal file
51
backend/nodes/grain_edge.py
Normal 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),)
|
||||
@@ -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(
|
||||
|
||||
75
backend/nodes/grain_mark.py
Normal file
75
backend/nodes/grain_mark.py
Normal 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 (0–1) 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),)
|
||||
78
backend/nodes/grain_summary.py
Normal file
78
backend/nodes/grain_summary.py
Normal 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,)
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
89
backend/nodes/immerse_detail.py
Normal file
89
backend/nodes/immerse_detail.py
Normal 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),)
|
||||
57
backend/nodes/laplace_interpolation.py
Normal file
57
backend/nodes/laplace_interpolation.py
Normal 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),)
|
||||
@@ -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:
|
||||
|
||||
68
backend/nodes/level_grains.py
Normal file
68
backend/nodes/level_grains.py
Normal 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),)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
44
backend/nodes/median_background.py
Normal file
44
backend/nodes/median_background.py
Normal 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),)
|
||||
@@ -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:
|
||||
|
||||
72
backend/nodes/multi_profile.py
Normal file
72
backend/nodes/multi_profile.py
Normal 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),)
|
||||
79
backend/nodes/mutual_crop.py
Normal file
79
backend/nodes/mutual_crop.py
Normal 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),
|
||||
)
|
||||
54
backend/nodes/outlier_mask.py
Normal file
54
backend/nodes/outlier_mask.py
Normal 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),)
|
||||
97
backend/nodes/perspective_correction.py
Normal file
97
backend/nodes/perspective_correction.py
Normal 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
|
||||
58
backend/nodes/pixel_binning.py
Normal file
58
backend/nodes/pixel_binning.py
Normal 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),)
|
||||
60
backend/nodes/poly_distort.py
Normal file
60
backend/nodes/poly_distort.py
Normal 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),)
|
||||
@@ -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:
|
||||
|
||||
79
backend/nodes/psdf_log_polar.py
Normal file
79
backend/nodes/psdf_log_polar.py
Normal 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 (0–360°) 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,)
|
||||
@@ -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:
|
||||
|
||||
98
backend/nodes/relate_fields.py
Normal file
98
backend/nodes/relate_fields.py
Normal 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": "R²", "value": f"{r2:.6f}", "unit": ""})
|
||||
|
||||
pred_field = field_b.replace(data=predicted.reshape(field_b.data.shape))
|
||||
return (pred_field, records)
|
||||
@@ -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}
|
||||
|
||||
58
backend/nodes/scan_line_reorder.py
Normal file
58
backend/nodes/scan_line_reorder.py
Normal 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
66
backend/nodes/shade.py
Normal 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=""),)
|
||||
@@ -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:
|
||||
|
||||
@@ -28,7 +28,6 @@ class SlopeDistribution:
|
||||
"'theta' is the inclination angle (0–max°), probability density (1/deg); "
|
||||
"'phi' is the azimuthal slope direction (0–360°), 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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
95
backend/nodes/straighten_path.py
Normal file
95
backend/nodes/straighten_path.py
Normal 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 (0–1). "
|
||||
"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)),)
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
154
backend/nodes/terrace_fit.py
Normal file
154
backend/nodes/terrace_fit.py
Normal 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
49
backend/nodes/tilt.py
Normal 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),)
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
54
backend/nodes/trimmed_mean.py
Normal file
54
backend/nodes/trimmed_mean.py
Normal 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),)
|
||||
@@ -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(
|
||||
|
||||
56
backend/nodes/wrap_value.py
Normal file
56
backend/nodes/wrap_value.py
Normal 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),)
|
||||
60
backend/nodes/zero_crossing.py
Normal file
60
backend/nodes/zero_crossing.py
Normal 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=""),)
|
||||
Reference in New Issue
Block a user