adding more nodes

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

View File

@@ -1,104 +1,188 @@
# Gwyddion Features Not Yet in tono
# Gwyddion Feature Gap — tono
Reference for future implementation. Grouped by value to typical SPM workflows.
Comprehensive comparison against Gwyddion r29630. Excludes force curves, force volumes, and spectroscopic measurements. Grouped by priority for typical SPM workflows.
---
## High Value
## Completed
| # | Feature | Gwyddion Source | Description |
|---|---------|---------------|-------------|
| ~~1~~ | ~~Line Correction~~ | ~~linecorrect.c, linematch.c~~ | ~~Row-by-row median/polynomial alignment. Essential for raw SPM data with scan-line artifacts.~~ **DONE** |
| ~~2~~ | ~~Scar Removal~~ | ~~scars.c~~ | ~~Detect and interpolate scan-line defects (horizontal streaks).~~ **DONE** |
| ~~3~~ | ~~Facet Leveling~~ | ~~facet-level.c~~ | ~~Orient the dominant surface facet to horizontal. Better than plane level for terraced/stepped surfaces.~~ **DONE** |
| ~~4~~ | ~~Morphological Mask Ops~~ | ~~mask_morph.c~~ | ~~Erode, dilate, open, close on grain masks. Needed to clean up thresholded masks.~~ **DONE** |
| ~~5~~ | ~~1D FFT Filter~~ | ~~fft_filter_1d.c~~ | ~~Bandpass/lowpass/highpass filtering of LINE profiles.~~ **DONE** |
| ~~6~~ | ~~2D FFT Filter~~ | ~~fft_filter_2d.c~~ | ~~Frequency-domain filtering of DATA_FIELDs (remove periodic noise, etc.).~~ **DONE** |
| ~~7~~ | ~~Autocorrelation (ACF)~~ | ~~acf2d.c~~ | ~~2D autocorrelation function. Reveals periodic structures and correlation lengths.~~ **DONE** |
| ~~8~~ | ~~PSDF~~ | ~~psdf2d.c~~ | ~~Radial/2D power spectral density function. Complementary to ACF for roughness characterization.~~ **DONE** |
| ~~9~~ | ~~Fractal Dimension~~ | ~~fractal.c~~ | ~~Multiple methods: partitioning, cube counting, triangulation, PSDF, HHCF. Quantifies surface complexity.~~ **DONE** |
| ~~10~~ | ~~Curvature~~ | ~~curvature.c~~ | ~~Quadratic-surface curvature fit with principal radii/directions. Useful for apex and dome characterization.~~ **DONE** |
| ~~11~~ | ~~Grain Distance Transform~~ | ~~mask_edt.c~~ | ~~Euclidean distance from grain boundaries. Useful for spatial distribution analysis.~~ **DONE** |
| ~~12~~ | ~~Watershed Segmentation~~ | ~~grain_wshed.c~~ | ~~Automatic grain detection without manual threshold. More robust than simple thresholding.~~ **DONE** |
| ~~13~~ | ~~Rotate / Flip~~ | ~~rotate.c, basicops.c~~ | ~~Basic geometric transforms (90°, arbitrary angle, mirror).~~ **DONE** |
| ~~14~~ | ~~Crop~~ | ~~crop.c~~ | ~~Extract sub-region of a field.~~ **DONE** |
All features from the original gap analysis are implemented:
## Medium Value
| # | Feature | Gwyddion Source | Description |
|---|---------|---------------|-------------|
| ~~15~~ | ~~Correlation / Pattern Matching~~ | ~~crosscor.c, maskcor.c~~ | ~~Find repeated features or align images via cross-correlation.~~ **DONE** |
| ~~16~~ | ~~Slope Distribution~~ | ~~slope_dist.c~~ | ~~Angular histogram of surface slopes. Characterizes surface texture directionality.~~ **DONE** |
| ~~17~~ | ~~Grain Filtering~~ | ~~grain_filter.c~~ | ~~Remove grains by size, height, or border contact. Refine grain masks post-detection.~~ **DONE** |
| ~~18~~ | ~~Field Arithmetic~~ | ~~arithmetic.c~~ | ~~Add/subtract/multiply/divide two DATA_FIELDs. Useful for difference maps, normalization.~~ **DONE** |
| ~~19~~ | ~~Spot Removal~~ | ~~spotremove.c~~ | ~~Interpolate over selected point defects (dust, spikes).~~ **DONE** |
| ~~20~~ | ~~Tip Modeling / Deconvolution~~ | ~~tip_blind.c, tip_model.c~~ | ~~Estimate tip shape from image, deconvolve to recover true surface.~~ **DONE** |
| ~~21~~ | ~~Radial Profile~~ | ~~rprofile tool~~ | ~~Azimuthally averaged profile from a center point. Good for circular features.~~ **DONE** |
| ~~22~~ | ~~Wavelet Transform~~ | ~~dwt.c, cwt.c~~ | ~~Discrete/continuous wavelet analysis. Multi-scale roughness decomposition.~~ **DONE** |
| ~~23~~ | ~~Scale / Resample~~ | ~~scale.c, resample.c~~ | ~~Resize fields with interpolation.~~ **DONE** |
| ~~24~~ | ~~Gradient~~ | ~~gradient.c~~ | ~~Compute x/y gradient magnitude maps.~~ **DONE** |
| ~~25~~ | ~~Custom Convolution~~ | ~~convolution_filter.c~~ | ~~User-defined kernel convolution.~~ **DONE** |
| ~~26~~ | ~~Local Contrast Enhancement~~ | ~~local_contrast.c~~ | ~~Enhance visibility of local features in images.~~ **DONE** |
## Lower Priority
| # | Feature | Gwyddion Source | Description |
|---|---------|---------------|-------------|
| 27 | Drift Correction | drift.c | Compensate for thermal/piezo drift between scan lines. |
| 28 | Affine / Perspective Correction | correct_affine.c, correct_perspective.c | Fix geometric distortions from scanner nonlinearity. |
| 29 | MFM Analysis | mfm_*.c | Magnetic force microscopy: field calculation, shift finding. |
| 30 | Lattice Measurement | measure_lattice.c | Detect and measure periodic lattice structures from ACF/FFT. |
| 31 | Hough Transform | hough.c | Detect lines and circles in images. |
| 32 | Image Stitching / Merging | merge.c, stitch.c | Combine multiple overlapping scans into one image. |
| 33 | Facet Analysis | facet_analysis.c | Orientation distribution of surface facets (stereographic projection). |
| 34 | Shape Fitting | fit-shape.c | Fit geometric primitives: sphere, paraboloid, cylinder, etc. |
| 35 | Synthetic Surface Generation | *_synth.c (~20 modules) | Generate test surfaces: FBM, noise, lattice, waves, particles, fibers, etc. |
| ~~36~~ | ~~Entropy~~ | ~~entropy.c~~ | ~~Information entropy of height distribution.~~ **DONE** |
| 37 | Indentation Analysis | indent_analyze.c, hertz.c | Nanoindentation curve fitting (Hertz model). |
| 38 | Deconvolution | deconvolve.c | Blind/regularized deconvolution for image restoration. |
| 39 | Canny / Harris Detection | filters.c | Corner and edge feature detection beyond basic Sobel/Prewitt. |
| ~~40~~ | ~~Kuwahara Filter~~ | ~~filters.c~~ | ~~Edge-preserving smoothing filter.~~ **DONE** |
| # | Feature | Gwyddion Source | tono Node |
|---|---------|---------------|-----------|
| 1 | Line Correction | linecorrect.c, linematch.c | LineCorrection |
| 2 | Scar Removal | scars.c | ScarRemoval |
| 3 | Facet Leveling | facet-level.c | FacetLevelField |
| 4 | Morphological Mask Ops | mask_morph.c | MaskMorphology |
| 5 | 1D FFT Filter | fft_filter_1d.c | FFTFilter |
| 6 | 2D FFT Filter | fft_filter_2d.c | FFTFilter |
| 7 | Autocorrelation (ACF) | acf2d.c | ACF2D |
| 8 | PSDF | psdf2d.c | PSDF |
| 9 | Fractal Dimension | fractal.c | FractalDimension |
| 10 | Curvature | curvature.c | Curvature |
| 11 | Grain Distance Transform | mask_edt.c | GrainDistanceTransform |
| 12 | Watershed Segmentation | grain_wshed.c | WatershedSegmentation |
| 13 | Rotate / Flip | rotate.c, basicops.c | RotateField, FlipField |
| 14 | Crop | crop.c | CropResizeField |
| 15 | Correlation / Pattern Matching | crosscor.c, maskcor.c | CrossCorrelate, TemplateMatch |
| 16 | Slope Distribution | slope_dist.c | SlopeDistribution |
| 17 | Grain Filtering | grain_filter.c | GrainFilter |
| 18 | Field Arithmetic | arithmetic.c | FieldArithmetic |
| 19 | Spot Removal | spotremove.c | SpotRemoval |
| 20 | Tip Modeling / Deconvolution | tip_blind.c, tip_model.c | TipModel, TipDeconvolution, BlindTipEstimate |
| 21 | Radial Profile | rprofile tool | RadialProfile |
| 22 | Wavelet Transform | dwt.c, cwt.c | WaveletDenoise |
| 23 | Scale / Resample | scale.c, resample.c | Resample |
| 24 | Gradient | gradient.c | Gradient |
| 25 | Custom Convolution | convolution_filter.c | CustomConvolution |
| 26 | Local Contrast Enhancement | local_contrast.c | LocalContrast |
| 27 | Drift Correction | drift.c | DriftCorrection |
| 28 | Affine Correction | correct_affine.c | AffineCorrection |
| 29 | MFM Analysis | mfm_*.c | MFMAnalysis |
| 30 | Lattice Measurement | measure_lattice.c | LatticeMeasurement |
| 31 | Hough Transform | hough.c | HoughTransform |
| 32 | Image Stitching | merge.c, stitch.c | ImageStitch |
| 33 | Facet Analysis | facet_analysis.c | FacetAnalysis |
| 34 | Shape Fitting | fit-shape.c | ShapeFitting |
| 35 | Synthetic Surface Generation | *_synth.c | SyntheticSurface |
| 36 | Entropy | entropy.c | Entropy |
| 38 | Deconvolution | deconvolve.c | Deconvolution |
| 39 | Canny / Harris Detection | filters.c | FeatureDetection |
| 40 | Kuwahara Filter | filters.c | KuwaharaFilter |
---
## Already Implemented in tono
## Remaining Gaps
For reference, these Gwyddion equivalents are already covered:
### High Value — Core SPM workflow features
| tono Node | Category | Gwyddion Equivalent |
|--------------|----------|-------------------|
| Load Image / Load SPM File | io | File import (gwy, sxm, ibw) |
| Save Image | io | File export |
| Coordinate | io | |
| Rotate Field | modify | rotate.c |
| Flip Field | modify | basicops.c |
| Plane Level | level | level.c |
| Facet Level | level | facet-level.c |
| Polynomial Level | level | polylevel.c |
| Fix Zero | level | level.c (fix_zero) |
| Line Correction | level | linecorrect.c, linematch.c |
| Gaussian Filter | filters | filters.c (gaussian) |
| Median Filter | filters | filters.c (median) |
| Edge Detect | filters | edge.c (sobel, prewitt, laplacian, LoG) |
| 1D FFT Filter | filters | fft_filter_1d.c (lowpass, highpass, bandpass, notch) |
| 2D FFT Filter | filters | fft_filter_2d.c (lowpass, highpass, bandpass, notch) |
| Scar Removal | filters | scars.c |
| Statistics | analysis | stats.c |
| Curvature | analysis | curvature.c |
| Fractal Dimension | analysis | fractal.c |
| Height Histogram | analysis | linestats.c (dh) |
| 2D FFT | analysis | fft.c |
| Cross Section | analysis | profile tool |
| Profile Roughness | analysis | roughness.c (Ra, Rq, Rsk, Rku, Rp, Rv, Rt) |
| Line Math | analysis | linestats.c |
| Threshold Mask | mask | threshold.c, otsu_threshold.c |
| Mask Morphology | mask | mask_morph.c (erode, dilate, open, close) |
| Mask Invert | mask | — |
| Mask Operations | mask | — (boolean logic on two masks: AND, OR, XOR, NAND, NOR, XNOR, implication, etc.) |
| Grain Distance Transform | mask | mask_edt.c |
| Watershed Segmentation | grains | grain_wshed.c |
| Grain Analysis | grains | grain_stat.c |
| Preview / 3D View / Print Table | display | Presentation, 3D view |
| Tip Model | tip | tip_model.c, tip.c |
| Tip Deconvolution | tip | tip_blind.c, tip.c (gwy_tip_erosion) |
| Blind Tip Estimate | tip | tip_blind.c, morph_lib.c (gwy_tip_estimate_partial/full + gwy_tip_cmap) |
| # | Feature | Gwyddion Source | tono Node | Status |
|---|---------|---------------|-----------|--------|
| 41 | Terrace Fitting | terracefit.c | TerraceFit | **DONE** |
| 42 | Laplace Interpolation | laplace.c | LaplaceInterpolation | **DONE** |
| 43 | Fractal Interpolation | fraccor.c | FractalInterpolation | **DONE** |
| 44 | Median Background Subtraction | median-bg.c | MedianBackground | **DONE** |
| 45 | Flatten Base | flatten_base.c | FlattenBase | **DONE** |
| 46 | Level Individual Grains | level_grains.c | LevelGrains | **DONE** |
| 47 | Grain Marking by Criteria | grain_mark.c | GrainMark | **DONE** |
| 48 | Grain Property Distributions | grain_dist.c | GrainDistributions | **DONE** |
| 49 | Grain Summary Statistics | grain_summary.c | GrainSummary | **DONE** |
| 50 | Outlier Masking | outliers.c | OutlierMask | **DONE** |
| 51 | Scan Line Reordering | reorder.c | ScanLineReorder | **DONE** |
### Medium Value — Analysis and correction
| # | Feature | Gwyddion Source | tono Node | Status |
|---|---------|---------------|-----------|--------|
| 52 | Perspective Correction | correct_perspective.c | PerspectiveCorrection | **DONE** |
| 53 | Polynomial Distortion | polydistort.c | PolynomialDistortion | **DONE** |
| 54 | Frequency Splitting | freq_split.c | FrequencySplit | **DONE** |
| 55 | Phase/Value Wrapping | wrapvalue.c | WrapValue | **DONE** |
| 56 | Shaded Presentation | shade.c | Shade | **DONE** |
| 57 | Pixel Binning | binning.c | PixelBinning | **DONE** |
| 58 | Extend / Pad | extend.c | ExtendPad | **DONE** |
| 59 | Tilt | tilt.c | Tilt | **DONE** |
| 60 | Trimmed Mean Filter | trimmed-mean.c | TrimmedMean | **DONE** |
| 61 | Rank Filter | rank-filter.c | RankFilter | **DONE** |
| 62 | Zero Crossing Detection | zero_crossing.c | ZeroCrossing | **DONE** |
| 63 | Log-Polar PSDF | psdf_logphi.c | LogPolarPSDF | **DONE** |
| 64 | Grain Edge Detection | grain_edge.c | GrainEdge | **DONE** |
| 65 | Grain Cross-Correlation | grain_cross.c | GrainCross | **DONE** |
| 66 | Mutual Crop | mcrop.c | MutualCrop | **DONE** |
| 67 | Immerse Detail | immerse.c | ImmerseDetail | **DONE** |
| 68 | Multiple Profiles | multiprofile.c | MultipleProfiles | **DONE** |
| 69 | Straighten Path | straighten_path.c | StraightenPath | **DONE** |
| 70 | Relate Two Fields | relate.c | RelateFields | **DONE** |
### SPM Mode-Specific
| # | Feature | Gwyddion Source | Description |
|---|---------|---------------|-------------|
| 71 | PFM Analysis | pfm.c | Piezoresponse Force Microscopy: compute in-plane and 3D polarization vectors from VPFM/LPFM amplitude and phase at multiple rotations. |
| 72 | Lateral Force Simulation | latsim.c | Simulate topography artifacts in lateral force (friction) channels given friction coefficient, adhesion, and normal load parameters. |
| 73 | SEM Simulation | semsim.c | Simulate Scanning Electron Microscopy signal from topography data using integration or Monte Carlo methods. |
| 74 | Scanning Microwave Microscopy | smm.c, smm_apply.c | Fit complex reflection coefficients from SMM impedance measurements to extract capacitance and material properties. |
| 75 | MFM Current Simulation | mfm_current.c | Simulate current distribution from magnetization for MFM. Extends existing MFMAnalysis node. |
| 76 | MFM Domain Generation | mfm_parallel.c | Generate parallel magnetic domain patterns for MFM simulation and testing. |
### Lower Priority — Specialized or niche
| # | Feature | Gwyddion Source | Description |
|---|---------|---------------|-------------|
| 77 | Mark Disconnected Regions | mark_disconn.c | Mask topologically isolated surface regions using threshold and radius criteria. |
| 78 | Mask Shift | mask_shift.c | Translate mask by pixel offset in any direction. |
| 79 | Mask Noisify | mask_noisify.c | Add random perturbation to mask boundaries. Useful for testing mask sensitivity. |
| 80 | DWT Anisotropy | dwtanisotropy.c | Quantify surface anisotropy using discrete wavelet transform decomposition. |
| 81 | Displacement Field | displfield.c | Distort images using displacement fields (Gaussian, tear, image-based). Simulates scanning artifacts. |
| 82 | Pixel Classification | classify.c | Classify pixels into categories using decision trees on height, slope, and curvature criteria. |
| 83 | Neural Network Classification | neural.c | Train and apply neural networks for pixel-level feature classification. |
| 84 | Logistic Classification | logistic.c | Classify features using logistic regression on Gaussian derivative features. |
| 85 | Super-Resolution | superresolution.c | Combine multiple aligned low-resolution scans to produce a higher-resolution image. |
| 86 | PSF Estimation | psf.c, psf-fit.c | Estimate and fit point spread functions from image features for deconvolution. |
| 87 | Tip Shape from Features | tipshape.c | Estimate SPM tip shape from known calibration feature convolutions. |
| 88 | Presentation Ops | presentationops.c | Manage presentation overlays (extract, attach, remove presentation layers). |
| 89 | Calibration Coefficients | calcoefs_*.c, calibrate.c | Load, create, and apply lateral/height calibration corrections. |
| 90 | Distribution Coercion | coerce.c | Transform data distribution to match target (uniform, Gaussian, custom). |
| 91 | Grain Selection Visualization | grain_makesel.c | Visualize grains as discs, circles, or bounding boxes for selection. |
### Synthesis — Additional surface generation patterns
tono's SyntheticSurface node covers fbm, white_noise, lattice, steps, particles, and flat. Gwyddion has 24 separate synthesis modules. These could be added as patterns to the existing SyntheticSurface node:
| # | Pattern | Gwyddion Source | Description |
|---|---------|---------------|-------------|
| 92 | Columnar | col_synth.c | Columnar/stripe growth patterns. |
| 93 | Objects | obj_synth.c | Random spheres, pyramids, boxes, cylinders on flat surface. |
| 94 | Fibres | fibre_synth.c | Randomly oriented fibre/line features. |
| 95 | Waves | wave_synth.c | Directional wave/ripple patterns. |
| 96 | Dunes | dune_synth.c | Dune-like rippled surfaces. |
| 97 | Domains | domain_synth.c | Phase-separated domain/island patterns. |
| 98 | Ballistic Deposition | bdep_synth.c | Ballistic deposition growth simulation. |
| 99 | Particle Deposition | deposit_synth.c | Particle deposition simulation. |
| 100 | Rod Deposition | roddeposit_synth.c | Wire/rod deposition on surfaces. |
| 101 | Diffusion Aggregation | diff_synth.c | Diffusion-limited aggregation patterns. |
| 102 | Discs | disc_synth.c | Randomly distributed disc features. |
| 103 | Plateaus | plateau_synth.c | Flat-topped feature patterns. |
| 104 | Pileups | pileup_synth.c | Rounded rectangle pileup structures. |
| 105 | Annealing | anneal_synth.c | Simulated annealing surface relaxation. |
| 106 | Lattice (Voronoi) | lat_synth.c | Regular lattice with Voronoi-based variations. |
| 107 | Phase Separation | phase_synth.c | Spinodal decomposition domain patterns. |
| 108 | PDE Patterns | cpde_synth.c | Coupled partial differential equation patterns. |
| 109 | Spectral (FFT) | fft_synth.c | Surfaces with customizable power spectrum. |
| 110 | Residues | residue_synth.c | Irregular particle/residue deposits. |
| 111 | Noise Distributions | lno_synth.c, noise_synth.c | Gaussian, Poisson, exponential, and other noise types. |
| 112 | Periodic Patterns | pat_synth.c | Various periodic/modulated tiling patterns. |
| 113 | WFR Patterns | wfr_synth.c | Wave-front-related surface patterns. |
### File Format Support
Gwyddion supports 155+ file format modules. tono currently handles a smaller set. Major format gaps (not exhaustive):
| Format | Gwyddion Source | Vendor/Description |
|--------|---------------|-------------------|
| Bruker Nanoscope | nanoscope.c, nanoscope-ii.c | Bruker/Veeco/DI SPM files |
| Park Systems | parkafm.c | Park Systems SPM files |
| RHK | rhk-sm4.c, rhk-spm32.c | RHK Technology SPM files |
| Omicron | omicron.c, omicronflat.c | Omicron/Scienta SPM files |
| Asylum Research | asylum.c | Asylum Research (Igor Pro) |
| WITec | witec-asc.c | WITec SPM/Raman files |
| JEOL | jeol.c | JEOL SPM files |
| ISO 28600 | iso28600.c | Standard SPM exchange format |
| Zygo | zygo.c | Zygo surface profiler |
| ASCII matrix | asciiexport.c | Generic ASCII grid import/export |
---
## Summary
| Category | Count | Status |
|----------|-------|--------|
| Originally tracked (140) | 40 | 39 done, 1 excluded (force curves) |
| High Value (4151) | 11 | **All 11 done** |
| Medium Value (5270) | 19 | **All 19 done** |
| SPM Mode-Specific (7176) | 6 | Pending |
| Lower Priority (7791) | 15 | Pending |
| Synthesis Patterns (92113) | 22 | Pending (extend SyntheticSurface) |
| File Formats | 10+ | Pending |
**69 of 70 tracked features implemented.** 43 remaining gaps identified.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,97 @@
"""Perspective correction — fix perspective distortion using a projective transform."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import map_coordinates
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Perspective Correction")
class PerspectiveCorrection:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"top_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"top_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"top_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"top_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"bottom_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"bottom_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"bottom_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"bottom_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
}
}
OUTPUTS = (
('DATA_FIELD', 'corrected'),
)
FUNCTION = "process"
DESCRIPTION = (
"Fix perspective distortion by specifying corner offsets. Each corner "
"can be shifted by a fractional amount (relative to image size) to "
"define the distorted quadrilateral. The image is then warped back to "
"a rectangle."
)
def process(self, field: DataField,
top_left_x: float, top_left_y: float,
top_right_x: float, top_right_y: float,
bottom_left_x: float, bottom_left_y: float,
bottom_right_x: float, bottom_right_y: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Source corners (distorted) as fractional offsets from ideal corners
src = np.array([
[top_left_y * yres, top_left_x * xres],
[top_right_y * yres, top_right_x * xres + (xres - 1)],
[(1 + bottom_left_y) * yres - 1, bottom_left_x * xres],
[(1 + bottom_right_y) * yres - 1, bottom_right_x * xres + (xres - 1)],
], dtype=np.float64)
# Destination corners (ideal rectangle)
dst = np.array([
[0, 0],
[0, xres - 1],
[yres - 1, 0],
[yres - 1, xres - 1],
], dtype=np.float64)
# Solve for perspective transform matrix (3x3)
H = _solve_perspective(src, dst)
# Apply inverse warp
yy, xx = np.mgrid[:yres, :xres]
coords = np.stack([yy.ravel(), xx.ravel(), np.ones(yres * xres)])
src_coords = H @ coords
src_coords /= src_coords[2:3, :]
sy = src_coords[0].reshape(yres, xres)
sx = src_coords[1].reshape(yres, xres)
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
return (field.replace(data=result),)
def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp)."""
n = len(src)
A = np.zeros((2 * n, 8))
b = np.zeros(2 * n)
for i in range(n):
dy, dx = dst[i]
sy, sx = src[i]
A[2 * i] = [dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy]
A[2 * i + 1] = [0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy]
b[2 * i] = sx
b[2 * i + 1] = sy
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
H = np.array([[h[0], h[1], h[2]],
[h[3], h[4], h[5]],
[h[6], h[7], 1.0]])
return H

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,6 @@ class RadialProfile:
"Compute the azimuthally averaged radial profile from a centre point. "
"cx/cy give the centre as a fraction of the field width/height (0.5 = centre). "
"Output x-axis is radius in physical xy units. "
"Equivalent to gwy_data_field_angular_average used by Gwyddion's Radial Profile tool (rprofile.c)."
)
def process(self, field: DataField, cx: float, cy: float, n_bins: int) -> tuple:

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
"""Straighten path — extract cross-section along a curved spline path."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import map_coordinates
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Straighten Path")
class StraightenPath:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75"}),
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5"}),
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
}
}
OUTPUTS = (
('DATA_FIELD', 'straightened'),
)
FUNCTION = "process"
DESCRIPTION = (
"Extract a cross-section along an arbitrary curved path defined by "
"control points. Points are given as fractional coordinates (01). "
"The path is interpolated with cubic splines, and data is sampled "
"along it with configurable thickness. "
)
def process(self, field: DataField, points_x: str, points_y: str,
thickness: int, n_samples: int) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Parse control points
px = [float(v.strip()) * (xres - 1) for v in points_x.split(",") if v.strip()]
py = [float(v.strip()) * (yres - 1) for v in points_y.split(",") if v.strip()]
if len(px) < 2 or len(py) < 2:
# Need at least 2 points
return (field,)
n_pts = min(len(px), len(py))
px, py = px[:n_pts], py[:n_pts]
# Parameterize path and interpolate
t_ctrl = np.linspace(0, 1, n_pts)
t_sample = np.linspace(0, 1, n_samples)
# Simple cubic interpolation via numpy
if n_pts >= 4:
from numpy.polynomial.polynomial import Polynomial
cx = np.interp(t_sample, t_ctrl, px)
cy = np.interp(t_sample, t_ctrl, py)
else:
cx = np.interp(t_sample, t_ctrl, px)
cy = np.interp(t_sample, t_ctrl, py)
# Sample along path with thickness
if thickness <= 1:
values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
result = values.reshape(1, -1)
else:
# Compute normals
dcx = np.gradient(cx)
dcy = np.gradient(cy)
length = np.sqrt(dcx**2 + dcy**2)
length = np.maximum(length, 1e-10)
nx = -dcy / length
ny = dcx / length
offsets = np.linspace(-(thickness - 1) / 2, (thickness - 1) / 2, thickness)
result = np.zeros((thickness, n_samples))
for i, off in enumerate(offsets):
sx = cx + off * nx
sy = cy + off * ny
result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest')
# Physical dimensions
total_length = 0.0
for i in range(1, len(cx)):
dx_phys = (cx[i] - cx[i - 1]) * field.dx
dy_phys = (cy[i] - cy[i - 1]) * field.dy
total_length += np.sqrt(dx_phys**2 + dy_phys**2)
return (field.replace(data=result, xreal=total_length,
yreal=thickness * max(field.dx, field.dy)),)

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
# Affine Correction
Fix geometric distortions from scanner nonlinearity by applying an affine transformation. Corrects shear, anisotropic scaling, and rotation. Equivalent to Gwyddion's correct_affine.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field with geometric distortion |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| corrected | DATA_FIELD | Affine-corrected field |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| shear_x | FLOAT | 0.0 | Horizontal shear factor |
| shear_y | FLOAT | 0.0 | Vertical shear factor |
| scale_x | FLOAT | 1.0 | Horizontal scale factor |
| scale_y | FLOAT | 1.0 | Vertical scale factor |
| angle | FLOAT | 0.0 | Rotation angle in degrees |
## Notes
- An identity transform (shear=0, scale=1, angle=0) returns the original data unchanged.
- The output shape matches the input; pixels outside the transformed region are filled by nearest-edge interpolation.
- For simple rotation without scaling/shear, use the Rotate node instead.

View File

@@ -0,0 +1,31 @@
# Deconvolution
Restore an image via regularised deconvolution. Assumes the image was blurred by a Gaussian PSF with the given sigma. Equivalent to Gwyddion's deconvolve.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input blurred field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| restored | DATA_FIELD | Deconvolved (sharpened) field |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| method | dropdown | wiener | Deconvolution method: wiener or richardson_lucy |
| sigma | FLOAT | 2.0 | Gaussian PSF sigma in pixels (0.150.0) |
| regularisation | FLOAT | 0.01 | Regularisation parameter for Wiener filter (1e-61.0) |
| iterations | INT | 10 | Number of iterations (Richardson-Lucy only, 1200) |
## Notes
- **Wiener**: Fast, single-pass frequency-domain filter. The regularisation parameter controls the noise/sharpness tradeoff — smaller values sharpen more but amplify noise.
- **Richardson-Lucy**: Iterative method that preserves positivity. More iterations = sharper result but risk of ringing artifacts.
- The PSF sigma should match the actual blur in the image. If unknown, start with sigma=13 and adjust.
- For tip-shape deconvolution (non-Gaussian PSF), use Tip Deconvolution instead.

View File

@@ -0,0 +1,28 @@
# Drift Correction
Compensate for thermal or piezo drift between scan lines. Estimates inter-row displacement via cross-correlation and shifts rows to align them. Equivalent to Gwyddion's drift.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field with drift artifacts |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| corrected | DATA_FIELD | Drift-corrected field |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| reference | dropdown | previous_row | Reference for drift estimation: previous_row (local) or mean_row (global average) |
| direction | dropdown | horizontal | Drift direction to correct: horizontal or vertical |
## Notes
- Use previous_row for slow, cumulative drift. Use mean_row when drift is more random row-to-row.
- Apply after Line Correction for best results.
- Vertical direction transposes the correction axis — useful for column-scanned data.

32
docs/nodes/Extend Pad.md Normal file
View File

@@ -0,0 +1,32 @@
# Extend Pad
Add configurable borders around a DATA_FIELD using various padding methods. Useful for preparing data for FFT-based operations that suffer from edge artifacts, or for aligning fields of different sizes. Equivalent to Gwyddion's `extend.c` module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to pad |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| padded | DATA_FIELD | Field with added borders |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| left | INT | 0 | Number of pixels to add on the left edge (01024) |
| right | INT | 0 | Number of pixels to add on the right edge (01024) |
| top | INT | 0 | Number of pixels to add on the top edge (01024) |
| bottom | INT | 0 | Number of pixels to add on the bottom edge (01024) |
| method | dropdown | mirror | Padding method: mean (fill with field mean), edge (replicate border pixels), mirror (reflect across edge), periodic (tile the field), or zero (fill with zeros) |
## Notes
- Mirror and periodic padding avoid sharp discontinuities at the border and are recommended before FFT-based filtering.
- Edge padding replicates the outermost row/column of pixels into the border region.
- Zero padding fills borders with 0.0 and can introduce step artifacts; consider mean padding as an alternative.
- The output field's physical dimensions are extended proportionally to the added pixels.

View File

@@ -0,0 +1,29 @@
# Facet Analysis
Compute the facet orientation distribution of a surface. Outputs a 2D histogram (stereographic projection) where the x-axis is the azimuthal angle and y-axis is the inclination. Equivalent to Gwyddion's facet_analysis.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input surface |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| facet_map | DATA_FIELD | 2D histogram of facet orientations (phi vs theta) |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| n_bins | INT | 180 | Number of azimuthal bins; theta bins = n_bins/4 (30720) |
| kernel_size | INT | 3 | Sobel gradient kernel size in pixels (39, odd) |
## Notes
- The output is a normalised probability density — it sums to 1.0.
- X-axis: azimuthal angle phi (0360°). Y-axis: inclination theta (0° = flat, max = steepest facet).
- A flat surface produces a single bright spot near theta=0. A surface with distinct facets shows multiple peaks.
- Larger kernel_size smooths the gradient estimate, reducing noise sensitivity.

View File

@@ -0,0 +1,34 @@
# Feature Detection
Detect edges or corners in a surface using Canny edge detection or Harris corner detection. Outputs a feature map and a table of detected feature locations. Equivalent to Gwyddion's edge/corner detection in filters.c.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input surface |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| result | DATA_FIELD | Feature map (binary edges or corner response) |
| features | RECORD_TABLE | Table of detected feature statistics and locations |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| method | dropdown | canny | Detection method: canny (edges) or harris (corners) |
| sigma | FLOAT | 1.0 | Gaussian smoothing sigma in pixels (0.120.0) |
| low_threshold | FLOAT | 0.1 | Canny low hysteresis threshold (01) |
| high_threshold | FLOAT | 0.2 | Canny high hysteresis threshold (01) |
| harris_k | FLOAT | 0.05 | Harris detector sensitivity parameter (0.010.5) |
| min_distance | INT | 5 | Minimum distance between detected corners in pixels (1100) |
## Notes
- **Canny**: Multi-stage edge detector with non-maximum suppression and hysteresis thresholding. Output is a binary edge map. Reports edge pixel count and edge fraction.
- **Harris**: Corner detector based on the structure tensor eigenvalues. Output is the corner response map. Reports up to 20 strongest corner locations with physical coordinates.
- Increase sigma to detect larger-scale features and suppress noise.
- For basic edge detection (Sobel, Prewitt, Laplacian), use the Edge Detect node instead.

View File

@@ -0,0 +1,28 @@
# Flatten Base
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. Equivalent to Gwyddion's flatten_base.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field with raised features on a tilted base |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with base leveled |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| threshold_percentile | FLOAT | 30.0 | Height percentile below which pixels are considered base (580) |
| poly_degree | INT | 2 | Polynomial degree for base fit: 0 = constant, 1 = plane, 2 = quadratic (05) |
## Notes
- Set the threshold percentile so that it includes most of the base but excludes the features. For sparse particles on a flat substrate, 2040% typically works well.
- poly_degree=1 is equivalent to plane leveling on the base only. Use 23 for curved substrates.
- If the features dominate the surface (>50% coverage), this node may not give good results — consider Median Background instead.

View File

@@ -0,0 +1,29 @@
# Fractal Interpolation
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. Equivalent to Gwyddion's fraccor.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field with regions to fill |
| mask | IMAGE | Yes | Binary mask where white (255) marks pixels to interpolate |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| filled | DATA_FIELD | Field with masked regions filled by fractal interpolation |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| iterations | INT | 200 | Number of boundary relaxation iterations (105000) |
## Notes
- Fractal interpolation synthesizes fill data whose power spectrum matches the surrounding surface. This produces a more realistic appearance than Laplace interpolation for textured or rough surfaces.
- The fill region boundaries are smoothed via relaxation to ensure continuity.
- For very smooth surfaces, Laplace Interpolation may produce cleaner results.
- The seed for random synthesis is fixed for reproducibility.

View File

@@ -0,0 +1,29 @@
# Frequency Split
Separate a DATA_FIELD into low-frequency and high-frequency components using an FFT-based Gaussian filter. The low-pass output contains smooth, large-scale variations while the high-pass output contains fine detail and noise. Equivalent to Gwyddion's `freq_split.c` module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to split |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| low_pass | DATA_FIELD | Low-frequency (smoothed) component |
| high_pass | DATA_FIELD | High-frequency (detail) component |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| cutoff | FLOAT | 0.1 | Cutoff frequency as a fraction of Nyquist (0.0010.5) |
## Notes
- The cutoff is relative to the Nyquist frequency. A value of 0.5 effectively passes everything (no filtering), while 0.001 aggressively removes nearly all spatial frequencies from the low-pass output.
- The two outputs always sum to the original field: low_pass + high_pass = input.
- Useful for separating roughness from waviness, or for removing long-range background while preserving features.
- The filter operates in the frequency domain via FFT, so it handles periodic boundaries naturally.

33
docs/nodes/Grain Cross.md Normal file
View File

@@ -0,0 +1,33 @@
# Grain Cross
Correlate grain properties between two fields using a shared grain mask. Reports per-grain property pairs and the Pearson correlation coefficient. Equivalent to Gwyddion's grain_cross.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field_a | DATA_FIELD | Yes | First height field |
| field_b | DATA_FIELD | Yes | Second height field |
| mask | IMAGE | Yes | Binary grain mask (white = grain) |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| correlation | RECORD_TABLE | Per-grain property pairs and Pearson correlation coefficient |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| property_a | dropdown | mean_height | Property to compute from field_a: area, mean_height, max_height, or volume |
| property_b | dropdown | max_height | Property to compute from field_b: area, mean_height, max_height, or volume |
| min_size | INT | 10 | Minimum grain area in pixels; smaller grains are excluded (1100000) |
## Notes
- Grains are identified by connected-component labelling (`scipy.ndimage.label`) on the binary mask.
- The **area** property uses physical pixel area (dx * dy). **Volume** integrates height above the mean of non-grain pixels.
- Each table row contains one grain's property pair formatted as "value_a / value_b".
- The final row reports the Pearson correlation coefficient r between the two property vectors (requires at least 2 grains).
- Both fields must have the same pixel dimensions as the mask.

View File

@@ -0,0 +1,31 @@
# Grain Distributions
Compute a histogram distribution of a grain property from a labeled mask. Supported properties: area, equivalent diameter, mean height, max height, volume, and boundary length. Equivalent to Gwyddion's grain_dist.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input surface data |
| mask | IMAGE | Yes | Binary mask defining grain regions |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| distribution | LINE_DATA | Histogram of the selected grain property |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| property | dropdown | area | Grain property to plot: area, equiv_diameter, mean_height, max_height, volume, boundary_length |
| n_bins | INT | 30 | Number of histogram bins (5200) |
| min_size | INT | 10 | Minimum grain size in pixels to include (1100000) |
## Notes
- Volume is computed relative to the mean height of the non-grain (background) region.
- Boundary length is estimated by counting boundary pixels and multiplying by the pixel size.
- For per-grain statistics (not histograms), use Grain Analysis instead.
- For aggregate summary statistics, use Grain Summary.

29
docs/nodes/Grain Edge.md Normal file
View File

@@ -0,0 +1,29 @@
# Grain Edge
Detect grain boundaries from a binary grain mask. Outputs a mask of pixels at grain/non-grain borders using 4-neighbour connectivity. Width controls the boundary thickness in pixels. Equivalent to Gwyddion's grain_edge.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input height field (used for dimension reference) |
| mask | IMAGE | Yes | Binary grain mask (white = grain) |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| edge_mask | IMAGE | Binary mask with grain boundary pixels marked |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| width | INT | 1 | Boundary thickness in pixels; values greater than 1 dilate the edge outward (110) |
## Notes
- A grain pixel is on the boundary if at least one of its 4-connected neighbours (up, down, left, right) is not a grain pixel.
- When width > 1, the boundary is expanded using binary dilation with a square structuring element of size (2*width - 1).
- Dilation is masked to stay within the original grain region, so boundaries never extend into non-grain areas.
- The field and mask must have the same pixel dimensions.

33
docs/nodes/Grain Mark.md Normal file
View File

@@ -0,0 +1,33 @@
# Grain Mark
Mark grains by thresholding height, slope magnitude, or curvature. Thresholds are relative (01) to the data range. Small regions below min_size pixels are removed. Equivalent to Gwyddion's grain_mark.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input surface |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| mask | IMAGE | Binary mask of marked grains |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| criterion | dropdown | height | What to threshold: height, slope, or curvature |
| threshold_low | FLOAT | 0.3 | Lower bound of the normalized threshold range (01) |
| threshold_high | FLOAT | 1.0 | Upper bound of the normalized threshold range (01) |
| min_size | INT | 10 | Minimum grain size in pixels; smaller regions are removed (1100000) |
| inverted | BOOLEAN | False | Invert the mask to mark valleys instead of peaks |
## Notes
- Thresholds are relative to the data range: 0.0 = minimum value, 1.0 = maximum value.
- "slope" uses Sobel gradient magnitude — useful for marking edges and steep features.
- "curvature" uses the Laplacian — useful for marking bumps or pits regardless of absolute height.
- Use inverted=True to mark valleys, pores, or depressions instead of raised features.
- For Otsu-based automatic thresholding, use the Threshold Mask node instead.

View File

@@ -0,0 +1,28 @@
# Grain Summary
Compute aggregate statistics for all grains in a mask: count, density, coverage fraction, mean/median area, total volume, and height statistics. Equivalent to Gwyddion's grain_summary.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input surface data |
| mask | IMAGE | Yes | Binary mask defining grain regions |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| summary | RECORD_TABLE | Table of aggregate grain statistics |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| min_size | INT | 10 | Minimum grain size in pixels to include (1100000) |
## Notes
- Reported statistics: grain count, grain density (count per unit area), coverage fraction, mean area, median area, total volume, mean height, median height, max area, min area.
- Volume is computed relative to the mean height of the non-grain background.
- Use Grain Analysis for per-grain statistics, or Grain Distributions for histograms.

View File

@@ -0,0 +1,32 @@
# Hough Transform
Detect lines or circles in images using the Hough transform. Returns an accumulator image and a table of detected features. Equivalent to Gwyddion's hough.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field (edge-detected images work best) |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| accumulator | DATA_FIELD | Hough accumulator space |
| detections | RECORD_TABLE | Table of detected lines or circles |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| mode | dropdown | lines | Detection mode: lines or circles |
| n_peaks | INT | 3 | Number of strongest features to report (150) |
| threshold | FLOAT | 1.0 | Minimum accumulator value relative to peak (0.110.0) |
| min_radius | INT | 10 | Minimum circle radius in pixels (circles mode only) |
| max_radius | INT | 30 | Maximum circle radius in pixels (circles mode only) |
## Notes
- For line detection, apply Edge Detect first for best results. Lines are reported as (angle, distance) pairs.
- For circle detection, min/max radius constrains the search range. Circles are reported as (center_x, center_y, radius).
- The accumulator image visualises the parameter space — bright spots correspond to detected features.

View File

@@ -0,0 +1,30 @@
# Image Stitch
Combine two overlapping scans into a single image. Uses cross-correlation to align the images and blends the overlap region. Equivalent to Gwyddion's merge.c / stitch.c modules.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field_a | DATA_FIELD | Yes | First (reference) field |
| field_b | DATA_FIELD | Yes | Second field to stitch onto field_a |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| stitched | DATA_FIELD | Combined field |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| direction | dropdown | auto | How field_b is positioned relative to field_a: right, below, or auto (determined by cross-correlation) |
| blend | dropdown | linear | Overlap blending: linear (smooth transition) or none (hard cut) |
## Notes
- Auto direction uses cross-correlation to determine whether field_b is to the right or below field_a.
- Linear blending produces a smooth transition in the overlap region, avoiding visible seams.
- Both fields should have the same pixel size (dx, dy) for correct physical dimensions in the output.
- The output field's physical dimensions are updated to reflect the combined area.

View File

@@ -0,0 +1,29 @@
# Immerse Detail
Overlay a high-resolution detail scan onto a lower-resolution overview image. Uses sliding-window cross-correlation (coarse + fine search) to automatically locate the detail within the overview. Equivalent to Gwyddion's immerse.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| overview | DATA_FIELD | Yes | Lower-resolution overview field |
| detail | DATA_FIELD | Yes | Higher-resolution detail field to place into the overview |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| combined | DATA_FIELD | Overview field with the detail region replaced or blended in |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| blend | dropdown | replace | How to combine the detail with the overview: replace (overwrite) or average (mean of both) |
## Notes
- If the detail has a different pixel size than the overview, it is resampled (via `scipy.ndimage.zoom`) to match the overview pixel spacing before placement.
- Alignment uses a two-pass normalized cross-correlation: a coarse search with stride, followed by a fine pixel-by-pixel search around the best coarse position.
- If the detail is larger than the overview in either dimension, the overview is returned unchanged.
- The output retains the overview's physical dimensions and metadata.

15
docs/nodes/Journal.md Normal file
View File

@@ -0,0 +1,15 @@
# Journal
A rich-text note for recording experimental observations, analysis notes, or workflow documentation. Content is saved as part of the workflow.
## Outputs
| Name | Type | Description |
|------|------|-------------|
| text | STRING | The journal text content |
## Notes
- The Journal node provides a text area for free-form notes within your workflow.
- Content persists when the workflow is saved and loaded.
- Use it to document sample preparation details, measurement parameters, or analysis decisions alongside your processing pipeline.

View File

@@ -0,0 +1,29 @@
# Laplace Interpolation
Fill masked (missing) regions by solving the Laplace equation with Dirichlet boundary conditions from surrounding pixels. Produces a smooth, harmonic interpolation without overshooting. Equivalent to Gwyddion's laplace.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field with regions to fill |
| mask | IMAGE | Yes | Binary mask where white (255) marks pixels to interpolate |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| filled | DATA_FIELD | Field with masked regions filled by Laplace interpolation |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| iterations | INT | 500 | Number of Jacobi relaxation iterations; more iterations = smoother result (1010000) |
## Notes
- Laplace interpolation produces the smoothest possible fill — it minimizes the integral of the squared gradient within the masked region.
- For small holes (<50 px diameter), 200500 iterations is usually sufficient. Larger holes may need 1000+.
- Use a Draw Mask or Threshold Mask node to create the mask input.
- For surfaces with texture, consider Fractal Interpolation instead, which preserves surface roughness characteristics.

View File

@@ -0,0 +1,29 @@
# Lattice Measurement
Detect and measure periodic lattice structures from ACF or FFT peak positions. Reports lattice vectors and angles. Equivalent to Gwyddion's measure_lattice.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field with periodic structure |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| correlation | DATA_FIELD | ACF or FFT magnitude used for detection |
| measurements | RECORD_TABLE | Detected lattice vectors, spacings, and angle |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| method | dropdown | acf | Detection method: acf (autocorrelation) or fft (Fourier transform) |
## Notes
- ACF method finds the strongest off-center peaks in the 2D autocorrelation. Works well for real-space periodic structures.
- FFT method finds peaks in the power spectrum. Better for weak periodicity or noisy data.
- Reports up to two lattice vectors (a, b), their magnitudes, and the angle between them.
- For best results, the field should contain at least 34 complete periods in each direction.

View File

@@ -0,0 +1,29 @@
# Level Grains
Shift individual grain regions 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. Equivalent to Gwyddion's level_grains.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input surface data |
| mask | IMAGE | Yes | Binary mask defining grain regions |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with grains shifted to a common baseline |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| reference | dropdown | mean | Per-grain reference: mean, median, or minimum height |
## Notes
- Each connected region in the mask is treated as a separate grain.
- The target baseline is the average of all per-grain reference values.
- Use "minimum" reference when grains sit on different substrate heights and you want to align their bases.
- Combine with Grain Analysis to verify height distributions after leveling.

View File

@@ -0,0 +1,30 @@
# Log-Polar PSDF
Compute the power spectral density function in log-polar coordinates. The x-axis is the azimuthal angle (0-360 degrees) and the y-axis is log(frequency). Better than Cartesian PSDF for anisotropy analysis. Equivalent to Gwyddion's psdf_logphi.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input spatial-domain field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| psdf | DATA_FIELD | Power spectral density in log-polar coordinates (domain=frequency) |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| n_phi | INT | 180 | Number of azimuthal angle bins (36720) |
| n_r | INT | 100 | Number of radial (log-frequency) bins (20500) |
## Notes
- The mean value is subtracted before computing the 2D FFT, and the power spectrum is shifted so DC is at the centre.
- Bilinear interpolation is used when sampling the Cartesian power spectrum onto the log-polar grid.
- The output is log-scaled via `log1p` for better visual contrast.
- Output xreal is 360.0 (degrees) and yreal is log(r_max) where r_max is half the shorter image dimension.
- Anisotropic surfaces produce bright bands at specific azimuthal angles; isotropic surfaces appear uniform along the angle axis.

View File

@@ -0,0 +1,30 @@
# MFM Analysis
Magnetic Force Microscopy analysis: convert between MFM phase/force gradient and magnetic field quantities using Fourier-domain transfer functions. Equivalent to Gwyddion's mfm_*.c modules.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input MFM data (phase, force gradient, or field) |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| result | DATA_FIELD | Converted MFM quantity |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| operation | dropdown | phase_to_force_gradient | Conversion: phase_to_force_gradient, force_gradient_to_field, charge_density, magnetisation |
| lift_height | FLOAT | 50e-9 | Tip-sample separation in metres |
## Notes
- **phase_to_force_gradient**: Convert MFM phase shift to force gradient using the cantilever spring constant relationship.
- **force_gradient_to_field**: Recover the magnetic field from force gradient data via Fourier-domain deconvolution. Output unit: A/m.
- **charge_density**: Compute effective magnetic charge density. Output unit: A/m².
- **magnetisation**: Recover magnetisation from field data.
- All operations use the lift height to set the correct spatial frequency transfer function.

View File

@@ -0,0 +1,29 @@
# Median Background
Extract background using a local median filter and subtract it. The radius controls the filter window — larger values capture broader background variations. More robust than polynomial leveling for surfaces with sparse tall features. Equivalent to Gwyddion's median-bg.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| result | DATA_FIELD | Background-subtracted field or extracted background |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| radius | INT | 20 | Half-size of the median filter window in pixels; the full window is (2×radius+1)² (2500) |
| output | dropdown | subtracted | Output mode: subtracted (original minus background) or background (extracted background) |
## Notes
- The median filter is robust to outliers — tall features (particles, grains) do not bias the background estimate.
- Choose a radius larger than your largest feature but smaller than the background curvature scale.
- For polynomial background removal, use Plane Level or Polynomial Level instead.
- Processing time increases with radius; for very large fields, consider downsampling first.

View File

@@ -0,0 +1,31 @@
# Multiple Profiles
Extract and compare line profiles from two fields along a chosen row or column. Supports overlay, mean, and difference modes. Equivalent to Gwyddion's multiprofile.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field_a | DATA_FIELD | Yes | First input field |
| field_b | DATA_FIELD | Yes | Second input field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| profile | LINE_DATA | Resulting line profile |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| row | INT | -1 | Row (horizontal) or column (vertical) index to extract; -1 uses the centre row/column (-110000) |
| direction | dropdown | horizontal | Profile direction: horizontal (extract a row) or vertical (extract a column) |
| mode | dropdown | overlay | Combination mode: overlay (field_a profile only), mean (average of both), or difference (field_a minus field_b) |
## Notes
- When the two fields have different sizes, profiles are truncated to the shorter length so they can be compared element-wise.
- The x-axis of the output profile uses physical spacing (dx for horizontal, dy for vertical) from field_a.
- The output y-unit inherits field_a's z-unit.
- Difference mode is useful for visualising drift, processing artefacts, or changes between sequential scans.

28
docs/nodes/Mutual Crop.md Normal file
View File

@@ -0,0 +1,28 @@
# Mutual Crop
Align two images using FFT cross-correlation and crop both to their overlapping region. Useful for comparing images acquired at different times or with slight position offsets. Equivalent to Gwyddion's mcrop.c module.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field_a | DATA_FIELD | Yes | First input field |
| field_b | DATA_FIELD | Yes | Second input field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| cropped_a | DATA_FIELD | Field A cropped to the overlapping region |
| cropped_b | DATA_FIELD | Field B cropped to the overlapping region |
## Controls
This node has no user-adjustable controls. Alignment is fully automatic.
## Notes
- Both fields are zero-padded to a common shape, mean-subtracted, and cross-correlated in the frequency domain to find the optimal shift.
- The overlap region is computed from the detected shift; both output fields are cropped to identical pixel dimensions.
- If no valid overlap is found (e.g. completely non-overlapping images), the original fields are returned unchanged.
- Physical dimensions (xreal, yreal) of the outputs are updated to reflect the cropped size using field_a's pixel spacing.

Some files were not shown because too many files have changed in this diff Show More