From 7747c1c7bc662b7064424821da4e44b6f581da48 Mon Sep 17 00:00:00 2001 From: matei jordache Date: Fri, 3 Apr 2026 23:11:52 -0700 Subject: [PATCH] adding more nodes --- GWYDDION_FEATURE_GAP.md | 270 ++++++++++++++------- backend/node_menu.py | 30 +++ backend/nodes/affine_correction.py | 1 - backend/nodes/cross_correlate.py | 2 +- backend/nodes/cross_section.py | 1 - backend/nodes/deconvolution.py | 1 - backend/nodes/drift_correction.py | 1 - backend/nodes/edge_detect.py | 1 - backend/nodes/entropy.py | 2 +- backend/nodes/extend_pad.py | 64 +++++ backend/nodes/facet_analysis.py | 1 - backend/nodes/feature_detection.py | 1 - backend/nodes/fft_2d.py | 1 - backend/nodes/field_arithmetic.py | 2 - backend/nodes/filter_custom.py | 1 - backend/nodes/filter_fft.py | 2 +- backend/nodes/filter_gaussian.py | 4 +- backend/nodes/filter_kuwahara.py | 7 +- backend/nodes/filter_median.py | 2 +- backend/nodes/filter_rank.py | 53 ++++ backend/nodes/fix_zero.py | 1 - backend/nodes/flatten_base.py | 67 +++++ backend/nodes/fractal_interpolation.py | 78 ++++++ backend/nodes/freq_split.py | 50 ++++ backend/nodes/gradient.py | 1 - backend/nodes/grain_analysis.py | 1 - backend/nodes/grain_cross.py | 84 +++++++ backend/nodes/grain_distributions.py | 99 ++++++++ backend/nodes/grain_edge.py | 51 ++++ backend/nodes/grain_filter.py | 1 - backend/nodes/grain_mark.py | 75 ++++++ backend/nodes/grain_summary.py | 78 ++++++ backend/nodes/histogram.py | 1 - backend/nodes/hough_transform.py | 1 - backend/nodes/image_stitch.py | 1 - backend/nodes/immerse_detail.py | 89 +++++++ backend/nodes/laplace_interpolation.py | 57 +++++ backend/nodes/lattice_measurement.py | 1 - backend/nodes/level_grains.py | 68 ++++++ backend/nodes/level_poly.py | 1 - backend/nodes/local_contrast.py | 1 - backend/nodes/mask_morphology.py | 6 +- backend/nodes/mask_threshold.py | 1 - backend/nodes/median_background.py | 44 ++++ backend/nodes/mfm_analysis.py | 1 - backend/nodes/multi_profile.py | 72 ++++++ backend/nodes/mutual_crop.py | 79 ++++++ backend/nodes/outlier_mask.py | 54 +++++ backend/nodes/perspective_correction.py | 97 ++++++++ backend/nodes/pixel_binning.py | 58 +++++ backend/nodes/poly_distort.py | 60 +++++ backend/nodes/psdf.py | 3 +- backend/nodes/psdf_log_polar.py | 79 ++++++ backend/nodes/radial_profile.py | 1 - backend/nodes/relate_fields.py | 98 ++++++++ backend/nodes/resample.py | 2 - backend/nodes/scan_line_reorder.py | 58 +++++ backend/nodes/shade.py | 66 +++++ backend/nodes/shape_fitting.py | 1 - backend/nodes/slope_distribution.py | 1 - backend/nodes/spot_removal.py | 2 +- backend/nodes/statistics.py | 2 +- backend/nodes/straighten_path.py | 95 ++++++++ backend/nodes/synthetic_surface.py | 1 - backend/nodes/template_match.py | 2 +- backend/nodes/terrace_fit.py | 154 ++++++++++++ backend/nodes/tilt.py | 49 ++++ backend/nodes/tip_blind_estimate.py | 1 - backend/nodes/tip_deconvolution.py | 1 - backend/nodes/tip_model.py | 1 - backend/nodes/trimmed_mean.py | 54 +++++ backend/nodes/wavelet_denoise.py | 3 +- backend/nodes/wrap_value.py | 56 +++++ backend/nodes/zero_crossing.py | 60 +++++ docs/nodes/Affine Correction.md | 31 +++ docs/nodes/Deconvolution.md | 31 +++ docs/nodes/Drift Correction.md | 28 +++ docs/nodes/Extend Pad.md | 32 +++ docs/nodes/Facet Analysis.md | 29 +++ docs/nodes/Feature Detection.md | 34 +++ docs/nodes/Flatten Base.md | 28 +++ docs/nodes/Fractal Interpolation.md | 29 +++ docs/nodes/Frequency Split.md | 29 +++ docs/nodes/Grain Cross.md | 33 +++ docs/nodes/Grain Distributions.md | 31 +++ docs/nodes/Grain Edge.md | 29 +++ docs/nodes/Grain Mark.md | 33 +++ docs/nodes/Grain Summary.md | 28 +++ docs/nodes/Hough Transform.md | 32 +++ docs/nodes/Image Stitch.md | 30 +++ docs/nodes/Immerse Detail.md | 29 +++ docs/nodes/Journal.md | 15 ++ docs/nodes/Laplace Interpolation.md | 29 +++ docs/nodes/Lattice Measurement.md | 29 +++ docs/nodes/Level Grains.md | 29 +++ docs/nodes/Log-Polar PSDF.md | 30 +++ docs/nodes/MFM Analysis.md | 30 +++ docs/nodes/Median Background.md | 29 +++ docs/nodes/Multiple Profiles.md | 31 +++ docs/nodes/Mutual Crop.md | 28 +++ docs/nodes/Outlier Mask.md | 29 +++ docs/nodes/Perspective Correction.md | 35 +++ docs/nodes/Pixel Binning.md | 29 +++ docs/nodes/Polynomial Distortion.md | 33 +++ docs/nodes/Rank Filter.md | 31 +++ docs/nodes/Relate Fields.md | 34 +++ docs/nodes/Scan Line Reorder.md | 28 +++ docs/nodes/Shade.md | 30 +++ docs/nodes/Shape Fitting.md | 30 +++ docs/nodes/Straighten Path.md | 32 +++ docs/nodes/Synthetic Surface.md | 37 +++ docs/nodes/Terrace Fit.md | 31 +++ docs/nodes/Tilt.md | 30 +++ docs/nodes/Trimmed Mean.md | 29 +++ docs/nodes/Wrap Value.md | 30 +++ docs/nodes/Zero Crossing.md | 29 +++ tests/node_tests/extend_pad.py | 42 ++++ tests/node_tests/flatten_base.py | 38 +++ tests/node_tests/fractal_interpolation.py | 43 ++++ tests/node_tests/freq_split.py | 35 +++ tests/node_tests/grain_cross.py | 75 ++++++ tests/node_tests/grain_distributions.py | 45 ++++ tests/node_tests/grain_edge.py | 66 +++++ tests/node_tests/grain_mark.py | 56 +++++ tests/node_tests/grain_summary.py | 49 ++++ tests/node_tests/immerse_detail.py | 42 ++++ tests/node_tests/laplace_interpolation.py | 39 +++ tests/node_tests/level_grains.py | 46 ++++ tests/node_tests/median_background.py | 35 +++ tests/node_tests/multi_profile.py | 33 +++ tests/node_tests/mutual_crop.py | 38 +++ tests/node_tests/outlier_mask.py | 54 +++++ tests/node_tests/perspective_correction.py | 53 ++++ tests/node_tests/pixel_binning.py | 35 +++ tests/node_tests/poly_distort.py | 34 +++ tests/node_tests/psdf_log_polar.py | 30 +++ tests/node_tests/rank_filter.py | 44 ++++ tests/node_tests/relate_fields.py | 54 +++++ tests/node_tests/scan_line_reorder.py | 54 +++++ tests/node_tests/shade.py | 36 +++ tests/node_tests/straighten_path.py | 37 +++ tests/node_tests/terrace_fit.py | 60 +++++ tests/node_tests/tilt.py | 34 +++ tests/node_tests/trimmed_mean.py | 37 +++ tests/node_tests/wrap_value.py | 36 +++ tests/node_tests/zero_crossing.py | 37 +++ 146 files changed, 4950 insertions(+), 145 deletions(-) create mode 100644 backend/nodes/extend_pad.py create mode 100644 backend/nodes/filter_rank.py create mode 100644 backend/nodes/flatten_base.py create mode 100644 backend/nodes/fractal_interpolation.py create mode 100644 backend/nodes/freq_split.py create mode 100644 backend/nodes/grain_cross.py create mode 100644 backend/nodes/grain_distributions.py create mode 100644 backend/nodes/grain_edge.py create mode 100644 backend/nodes/grain_mark.py create mode 100644 backend/nodes/grain_summary.py create mode 100644 backend/nodes/immerse_detail.py create mode 100644 backend/nodes/laplace_interpolation.py create mode 100644 backend/nodes/level_grains.py create mode 100644 backend/nodes/median_background.py create mode 100644 backend/nodes/multi_profile.py create mode 100644 backend/nodes/mutual_crop.py create mode 100644 backend/nodes/outlier_mask.py create mode 100644 backend/nodes/perspective_correction.py create mode 100644 backend/nodes/pixel_binning.py create mode 100644 backend/nodes/poly_distort.py create mode 100644 backend/nodes/psdf_log_polar.py create mode 100644 backend/nodes/relate_fields.py create mode 100644 backend/nodes/scan_line_reorder.py create mode 100644 backend/nodes/shade.py create mode 100644 backend/nodes/straighten_path.py create mode 100644 backend/nodes/terrace_fit.py create mode 100644 backend/nodes/tilt.py create mode 100644 backend/nodes/trimmed_mean.py create mode 100644 backend/nodes/wrap_value.py create mode 100644 backend/nodes/zero_crossing.py create mode 100644 docs/nodes/Affine Correction.md create mode 100644 docs/nodes/Deconvolution.md create mode 100644 docs/nodes/Drift Correction.md create mode 100644 docs/nodes/Extend Pad.md create mode 100644 docs/nodes/Facet Analysis.md create mode 100644 docs/nodes/Feature Detection.md create mode 100644 docs/nodes/Flatten Base.md create mode 100644 docs/nodes/Fractal Interpolation.md create mode 100644 docs/nodes/Frequency Split.md create mode 100644 docs/nodes/Grain Cross.md create mode 100644 docs/nodes/Grain Distributions.md create mode 100644 docs/nodes/Grain Edge.md create mode 100644 docs/nodes/Grain Mark.md create mode 100644 docs/nodes/Grain Summary.md create mode 100644 docs/nodes/Hough Transform.md create mode 100644 docs/nodes/Image Stitch.md create mode 100644 docs/nodes/Immerse Detail.md create mode 100644 docs/nodes/Journal.md create mode 100644 docs/nodes/Laplace Interpolation.md create mode 100644 docs/nodes/Lattice Measurement.md create mode 100644 docs/nodes/Level Grains.md create mode 100644 docs/nodes/Log-Polar PSDF.md create mode 100644 docs/nodes/MFM Analysis.md create mode 100644 docs/nodes/Median Background.md create mode 100644 docs/nodes/Multiple Profiles.md create mode 100644 docs/nodes/Mutual Crop.md create mode 100644 docs/nodes/Outlier Mask.md create mode 100644 docs/nodes/Perspective Correction.md create mode 100644 docs/nodes/Pixel Binning.md create mode 100644 docs/nodes/Polynomial Distortion.md create mode 100644 docs/nodes/Rank Filter.md create mode 100644 docs/nodes/Relate Fields.md create mode 100644 docs/nodes/Scan Line Reorder.md create mode 100644 docs/nodes/Shade.md create mode 100644 docs/nodes/Shape Fitting.md create mode 100644 docs/nodes/Straighten Path.md create mode 100644 docs/nodes/Synthetic Surface.md create mode 100644 docs/nodes/Terrace Fit.md create mode 100644 docs/nodes/Tilt.md create mode 100644 docs/nodes/Trimmed Mean.md create mode 100644 docs/nodes/Wrap Value.md create mode 100644 docs/nodes/Zero Crossing.md create mode 100644 tests/node_tests/extend_pad.py create mode 100644 tests/node_tests/flatten_base.py create mode 100644 tests/node_tests/fractal_interpolation.py create mode 100644 tests/node_tests/freq_split.py create mode 100644 tests/node_tests/grain_cross.py create mode 100644 tests/node_tests/grain_distributions.py create mode 100644 tests/node_tests/grain_edge.py create mode 100644 tests/node_tests/grain_mark.py create mode 100644 tests/node_tests/grain_summary.py create mode 100644 tests/node_tests/immerse_detail.py create mode 100644 tests/node_tests/laplace_interpolation.py create mode 100644 tests/node_tests/level_grains.py create mode 100644 tests/node_tests/median_background.py create mode 100644 tests/node_tests/multi_profile.py create mode 100644 tests/node_tests/mutual_crop.py create mode 100644 tests/node_tests/outlier_mask.py create mode 100644 tests/node_tests/perspective_correction.py create mode 100644 tests/node_tests/pixel_binning.py create mode 100644 tests/node_tests/poly_distort.py create mode 100644 tests/node_tests/psdf_log_polar.py create mode 100644 tests/node_tests/rank_filter.py create mode 100644 tests/node_tests/relate_fields.py create mode 100644 tests/node_tests/scan_line_reorder.py create mode 100644 tests/node_tests/shade.py create mode 100644 tests/node_tests/straighten_path.py create mode 100644 tests/node_tests/terrace_fit.py create mode 100644 tests/node_tests/tilt.py create mode 100644 tests/node_tests/trimmed_mean.py create mode 100644 tests/node_tests/wrap_value.py create mode 100644 tests/node_tests/zero_crossing.py diff --git a/GWYDDION_FEATURE_GAP.md b/GWYDDION_FEATURE_GAP.md index 67f8f75..33f738c 100644 --- a/GWYDDION_FEATURE_GAP.md +++ b/GWYDDION_FEATURE_GAP.md @@ -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 (1–40) | 40 | 39 done, 1 excluded (force curves) | +| High Value (41–51) | 11 | **All 11 done** | +| Medium Value (52–70) | 19 | **All 19 done** | +| SPM Mode-Specific (71–76) | 6 | Pending | +| Lower Priority (77–91) | 15 | Pending | +| Synthesis Patterns (92–113) | 22 | Pending (extend SyntheticSurface) | +| File Formats | 10+ | Pending | + +**69 of 70 tracked features implemented.** 43 remaining gaps identified. diff --git a/backend/node_menu.py b/backend/node_menu.py index b395273..332f5c0 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -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", diff --git a/backend/nodes/affine_correction.py b/backend/nodes/affine_correction.py index d9fe8bb..d155ab6 100644 --- a/backend/nodes/affine_correction.py +++ b/backend/nodes/affine_correction.py @@ -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( diff --git a/backend/nodes/cross_correlate.py b/backend/nodes/cross_correlate.py index 545f515..316cfbd 100644 --- a/backend/nodes/cross_correlate.py +++ b/backend/nodes/cross_correlate.py @@ -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( diff --git a/backend/nodes/cross_section.py b/backend/nodes/cross_section.py index 41d3305..ce5c128 100644 --- a/backend/nodes/cross_section.py +++ b/backend/nodes/cross_section.py @@ -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( diff --git a/backend/nodes/deconvolution.py b/backend/nodes/deconvolution.py index fcb4bf8..d61a890 100644 --- a/backend/nodes/deconvolution.py +++ b/backend/nodes/deconvolution.py @@ -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( diff --git a/backend/nodes/drift_correction.py b/backend/nodes/drift_correction.py index bdfb3da..258e58a 100644 --- a/backend/nodes/drift_correction.py +++ b/backend/nodes/drift_correction.py @@ -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: diff --git a/backend/nodes/edge_detect.py b/backend/nodes/edge_detect.py index e0fd3aa..4e77439 100644 --- a/backend/nodes/edge_detect.py +++ b/backend/nodes/edge_detect.py @@ -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: diff --git a/backend/nodes/entropy.py b/backend/nodes/entropy.py index 7c5a64d..23b8c4e 100644 --- a/backend/nodes/entropy.py +++ b/backend/nodes/entropy.py @@ -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: diff --git a/backend/nodes/extend_pad.py b/backend/nodes/extend_pad.py new file mode 100644 index 0000000..8062630 --- /dev/null +++ b/backend/nodes/extend_pad.py @@ -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),) diff --git a/backend/nodes/facet_analysis.py b/backend/nodes/facet_analysis.py index aa7f4bb..5298754 100644 --- a/backend/nodes/facet_analysis.py +++ b/backend/nodes/facet_analysis.py @@ -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: diff --git a/backend/nodes/feature_detection.py b/backend/nodes/feature_detection.py index 9638ce5..1d539e5 100644 --- a/backend/nodes/feature_detection.py +++ b/backend/nodes/feature_detection.py @@ -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( diff --git a/backend/nodes/fft_2d.py b/backend/nodes/fft_2d.py index 796f3c2..eab168e 100644 --- a/backend/nodes/fft_2d.py +++ b/backend/nodes/fft_2d.py @@ -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: diff --git a/backend/nodes/field_arithmetic.py b/backend/nodes/field_arithmetic.py index 5c347d5..f96cdd5 100644 --- a/backend/nodes/field_arithmetic.py +++ b/backend/nodes/field_arithmetic.py @@ -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: diff --git a/backend/nodes/filter_custom.py b/backend/nodes/filter_custom.py index 5eedd11..3fc1907 100644 --- a/backend/nodes/filter_custom.py +++ b/backend/nodes/filter_custom.py @@ -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( diff --git a/backend/nodes/filter_fft.py b/backend/nodes/filter_fft.py index 3e81f3e..3c88900 100644 --- a/backend/nodes/filter_fft.py +++ b/backend/nodes/filter_fft.py @@ -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 diff --git a/backend/nodes/filter_gaussian.py b/backend/nodes/filter_gaussian.py index cc243f1..a2aef8e 100644 --- a/backend/nodes/filter_gaussian.py +++ b/backend/nodes/filter_gaussian.py @@ -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)) diff --git a/backend/nodes/filter_kuwahara.py b/backend/nodes/filter_kuwahara.py index 880d813..08a0a48 100644 --- a/backend/nodes/filter_kuwahara.py +++ b/backend/nodes/filter_kuwahara.py @@ -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: diff --git a/backend/nodes/filter_median.py b/backend/nodes/filter_median.py index 1a8e5d8..18650fc 100644 --- a/backend/nodes/filter_median.py +++ b/backend/nodes/filter_median.py @@ -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 diff --git a/backend/nodes/filter_rank.py b/backend/nodes/filter_rank.py new file mode 100644 index 0000000..4643088 --- /dev/null +++ b/backend/nodes/filter_rank.py @@ -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),) diff --git a/backend/nodes/fix_zero.py b/backend/nodes/fix_zero.py index be6943b..7b6e219 100644 --- a/backend/nodes/fix_zero.py +++ b/backend/nodes/fix_zero.py @@ -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: diff --git a/backend/nodes/flatten_base.py b/backend/nodes/flatten_base.py new file mode 100644 index 0000000..24781b2 --- /dev/null +++ b/backend/nodes/flatten_base.py @@ -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),) diff --git a/backend/nodes/fractal_interpolation.py b/backend/nodes/fractal_interpolation.py new file mode 100644 index 0000000..776b98d --- /dev/null +++ b/backend/nodes/fractal_interpolation.py @@ -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),) diff --git a/backend/nodes/freq_split.py b/backend/nodes/freq_split.py new file mode 100644 index 0000000..3aaa4cf --- /dev/null +++ b/backend/nodes/freq_split.py @@ -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)) diff --git a/backend/nodes/gradient.py b/backend/nodes/gradient.py index 59a33b6..0baa9e2 100644 --- a/backend/nodes/gradient.py +++ b/backend/nodes/gradient.py @@ -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: diff --git a/backend/nodes/grain_analysis.py b/backend/nodes/grain_analysis.py index 4d7138a..8078149 100644 --- a/backend/nodes/grain_analysis.py +++ b/backend/nodes/grain_analysis.py @@ -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: diff --git a/backend/nodes/grain_cross.py b/backend/nodes/grain_cross.py new file mode 100644 index 0000000..439e3d8 --- /dev/null +++ b/backend/nodes/grain_cross.py @@ -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,) diff --git a/backend/nodes/grain_distributions.py b/backend/nodes/grain_distributions.py new file mode 100644 index 0000000..577c2ba --- /dev/null +++ b/backend/nodes/grain_distributions.py @@ -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", + ),) diff --git a/backend/nodes/grain_edge.py b/backend/nodes/grain_edge.py new file mode 100644 index 0000000..2352c7c --- /dev/null +++ b/backend/nodes/grain_edge.py @@ -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),) diff --git a/backend/nodes/grain_filter.py b/backend/nodes/grain_filter.py index 83fcb6e..e0a1f98 100644 --- a/backend/nodes/grain_filter.py +++ b/backend/nodes/grain_filter.py @@ -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( diff --git a/backend/nodes/grain_mark.py b/backend/nodes/grain_mark.py new file mode 100644 index 0000000..cea6965 --- /dev/null +++ b/backend/nodes/grain_mark.py @@ -0,0 +1,75 @@ +"""Grain marking — mark grains by height, slope, or curvature criteria.""" + +from __future__ import annotations + +import numpy as np +from scipy.ndimage import label, sobel + +from backend.node_registry import register_node +from backend.data_types import DataField +from backend.nodes.helpers import bool_to_mask + + +@register_node(display_name="Grain Mark") +class GrainMark: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "criterion": (["height", "slope", "curvature"], {"default": "height"}), + "threshold_low": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}), + "threshold_high": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}), + "inverted": ("BOOLEAN", {"default": False}), + } + } + + OUTPUTS = ( + ('IMAGE', 'mask'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Mark grains by thresholding height, slope magnitude, or curvature. " + "Thresholds are relative (0–1) to the data range. Small regions below " + "min_size pixels are removed. Use inverted to mark valleys instead of peaks. " + ) + + def process(self, field: DataField, criterion: str, threshold_low: float, + threshold_high: float, min_size: int, inverted: bool) -> tuple: + data = np.asarray(field.data, dtype=np.float64) + + if criterion == "height": + values = data + elif criterion == "slope": + gx = sobel(data, axis=1) + gy = sobel(data, axis=0) + values = np.sqrt(gx**2 + gy**2) + elif criterion == "curvature": + gxx = sobel(sobel(data, axis=1), axis=1) + gyy = sobel(sobel(data, axis=0), axis=0) + values = np.abs(gxx + gyy) + else: + raise ValueError(f"Unknown criterion: {criterion!r}") + + # Normalize to [0, 1] + vmin, vmax = values.min(), values.max() + if vmax > vmin: + norm = (values - vmin) / (vmax - vmin) + else: + norm = np.zeros_like(values) + + # Apply thresholds + binary = (norm >= threshold_low) & (norm <= threshold_high) + + if inverted: + binary = ~binary + + # Remove small regions + labeled, n_labels = label(binary.astype(np.int32)) + for gid in range(1, n_labels + 1): + if (labeled == gid).sum() < min_size: + binary[labeled == gid] = False + + return (bool_to_mask(binary),) diff --git a/backend/nodes/grain_summary.py b/backend/nodes/grain_summary.py new file mode 100644 index 0000000..7437b38 --- /dev/null +++ b/backend/nodes/grain_summary.py @@ -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,) diff --git a/backend/nodes/histogram.py b/backend/nodes/histogram.py index cf91b9a..cccf320 100644 --- a/backend/nodes/histogram.py +++ b/backend/nodes/histogram.py @@ -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( diff --git a/backend/nodes/hough_transform.py b/backend/nodes/hough_transform.py index 614c17d..519a1fe 100644 --- a/backend/nodes/hough_transform.py +++ b/backend/nodes/hough_transform.py @@ -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( diff --git a/backend/nodes/image_stitch.py b/backend/nodes/image_stitch.py index c1e0c5d..daabb4d 100644 --- a/backend/nodes/image_stitch.py +++ b/backend/nodes/image_stitch.py @@ -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: diff --git a/backend/nodes/immerse_detail.py b/backend/nodes/immerse_detail.py new file mode 100644 index 0000000..6dc761d --- /dev/null +++ b/backend/nodes/immerse_detail.py @@ -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),) diff --git a/backend/nodes/laplace_interpolation.py b/backend/nodes/laplace_interpolation.py new file mode 100644 index 0000000..188fbf8 --- /dev/null +++ b/backend/nodes/laplace_interpolation.py @@ -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),) diff --git a/backend/nodes/lattice_measurement.py b/backend/nodes/lattice_measurement.py index 1d8170f..ae9a86c 100644 --- a/backend/nodes/lattice_measurement.py +++ b/backend/nodes/lattice_measurement.py @@ -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: diff --git a/backend/nodes/level_grains.py b/backend/nodes/level_grains.py new file mode 100644 index 0000000..7dffb91 --- /dev/null +++ b/backend/nodes/level_grains.py @@ -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),) diff --git a/backend/nodes/level_poly.py b/backend/nodes/level_poly.py index ba420dc..302c6a8 100644 --- a/backend/nodes/level_poly.py +++ b/backend/nodes/level_poly.py @@ -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: diff --git a/backend/nodes/local_contrast.py b/backend/nodes/local_contrast.py index 624624d..c1772b8 100644 --- a/backend/nodes/local_contrast.py +++ b/backend/nodes/local_contrast.py @@ -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: diff --git a/backend/nodes/mask_morphology.py b/backend/nodes/mask_morphology.py index 2aaf8ac..45df80a 100644 --- a/backend/nodes/mask_morphology.py +++ b/backend/nodes/mask_morphology.py @@ -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, diff --git a/backend/nodes/mask_threshold.py b/backend/nodes/mask_threshold.py index f06fc98..dfcb8b0 100644 --- a/backend/nodes/mask_threshold.py +++ b/backend/nodes/mask_threshold.py @@ -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: diff --git a/backend/nodes/median_background.py b/backend/nodes/median_background.py new file mode 100644 index 0000000..5ac7680 --- /dev/null +++ b/backend/nodes/median_background.py @@ -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),) diff --git a/backend/nodes/mfm_analysis.py b/backend/nodes/mfm_analysis.py index 05905d8..44fa1fa 100644 --- a/backend/nodes/mfm_analysis.py +++ b/backend/nodes/mfm_analysis.py @@ -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: diff --git a/backend/nodes/multi_profile.py b/backend/nodes/multi_profile.py new file mode 100644 index 0000000..0a67046 --- /dev/null +++ b/backend/nodes/multi_profile.py @@ -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),) diff --git a/backend/nodes/mutual_crop.py b/backend/nodes/mutual_crop.py new file mode 100644 index 0000000..02a877f --- /dev/null +++ b/backend/nodes/mutual_crop.py @@ -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), + ) diff --git a/backend/nodes/outlier_mask.py b/backend/nodes/outlier_mask.py new file mode 100644 index 0000000..6c6db9f --- /dev/null +++ b/backend/nodes/outlier_mask.py @@ -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),) diff --git a/backend/nodes/perspective_correction.py b/backend/nodes/perspective_correction.py new file mode 100644 index 0000000..38e4788 --- /dev/null +++ b/backend/nodes/perspective_correction.py @@ -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 diff --git a/backend/nodes/pixel_binning.py b/backend/nodes/pixel_binning.py new file mode 100644 index 0000000..318d49d --- /dev/null +++ b/backend/nodes/pixel_binning.py @@ -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),) diff --git a/backend/nodes/poly_distort.py b/backend/nodes/poly_distort.py new file mode 100644 index 0000000..058f7fa --- /dev/null +++ b/backend/nodes/poly_distort.py @@ -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),) diff --git a/backend/nodes/psdf.py b/backend/nodes/psdf.py index 29a4d37..6b2927c 100644 --- a/backend/nodes/psdf.py +++ b/backend/nodes/psdf.py @@ -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: diff --git a/backend/nodes/psdf_log_polar.py b/backend/nodes/psdf_log_polar.py new file mode 100644 index 0000000..10a8ce4 --- /dev/null +++ b/backend/nodes/psdf_log_polar.py @@ -0,0 +1,79 @@ +"""Log-polar PSDF — power spectral density in log-polar coordinates.""" + +from __future__ import annotations + +import numpy as np + +from backend.node_registry import register_node +from backend.data_types import DataField + + +@register_node(display_name="Log-Polar PSDF") +class LogPolarPSDF: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "n_phi": ("INT", {"default": 180, "min": 36, "max": 720, "step": 1}), + "n_r": ("INT", {"default": 100, "min": 20, "max": 500, "step": 1}), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'psdf'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Compute the power spectral density function in log-polar coordinates. " + "The x-axis is the azimuthal angle (0–360°) and y-axis is log(frequency). " + "Better than Cartesian PSDF for anisotropy analysis. " + ) + + def process(self, field: DataField, n_phi: int, n_r: int) -> tuple: + data = np.asarray(field.data, dtype=np.float64) + yres, xres = data.shape + + # Compute 2D power spectrum + fft = np.fft.fft2(data - data.mean()) + power = np.abs(np.fft.fftshift(fft))**2 + + cy, cx = yres // 2, xres // 2 + + # Build log-polar grid + r_max = min(cx, cy) + log_r = np.linspace(0, np.log(r_max), n_r) + phi = np.linspace(0, 2 * np.pi, n_phi, endpoint=False) + + result = np.zeros((n_r, n_phi)) + + for ir in range(n_r): + r = np.exp(log_r[ir]) + for ip in range(n_phi): + fx = cx + r * np.cos(phi[ip]) + fy = cy + r * np.sin(phi[ip]) + # Bilinear interpolation + ix = int(fx) + iy = int(fy) + if 0 <= ix < xres - 1 and 0 <= iy < yres - 1: + dx = fx - ix + dy = fy - iy + val = (power[iy, ix] * (1 - dx) * (1 - dy) + + power[iy, ix + 1] * dx * (1 - dy) + + power[iy + 1, ix] * (1 - dx) * dy + + power[iy + 1, ix + 1] * dx * dy) + result[ir, ip] = val + + # Log scale for display + result = np.log1p(result) + + psdf_field = DataField( + data=result, + xreal=360.0, + yreal=float(np.log(r_max)), + si_unit_xy="deg", + si_unit_z="", + domain="frequency", + ) + return (psdf_field,) diff --git a/backend/nodes/radial_profile.py b/backend/nodes/radial_profile.py index 0b88eb1..8aad709 100644 --- a/backend/nodes/radial_profile.py +++ b/backend/nodes/radial_profile.py @@ -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: diff --git a/backend/nodes/relate_fields.py b/backend/nodes/relate_fields.py new file mode 100644 index 0000000..ec8aa57 --- /dev/null +++ b/backend/nodes/relate_fields.py @@ -0,0 +1,98 @@ +"""Relate two fields — fit functional relationships between two data fields.""" + +from __future__ import annotations + +import numpy as np + +from backend.node_registry import register_node +from backend.data_types import DataField, RecordTable + + +@register_node(display_name="Relate Fields") +class RelateFields: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field_a": ("DATA_FIELD",), + "field_b": ("DATA_FIELD",), + "function": (["linear", "quadratic", "cubic", "power", "logarithmic"], + {"default": "linear"}), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'predicted'), + ('RECORD_TABLE', 'fit_params'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Fit a functional relationship between two data fields: b = f(a). " + "Outputs the predicted field_b from the fit and a table of fitted " + "parameters with R² goodness-of-fit. " + ) + + def process(self, field_a: DataField, field_b: DataField, + function: str) -> tuple: + a = np.asarray(field_a.data, dtype=np.float64).ravel() + b = np.asarray(field_b.data, dtype=np.float64).ravel() + n = min(len(a), len(b)) + a, b = a[:n], b[:n] + + records = RecordTable() + + if function == "linear": + coeffs = np.polyfit(a, b, 1) + predicted = np.polyval(coeffs, a) + records.append({"quantity": "slope", "value": f"{coeffs[0]:.6g}", "unit": ""}) + records.append({"quantity": "intercept", "value": f"{coeffs[1]:.6g}", "unit": ""}) + + elif function == "quadratic": + coeffs = np.polyfit(a, b, 2) + predicted = np.polyval(coeffs, a) + for i, name in enumerate(["a2", "a1", "a0"]): + records.append({"quantity": name, "value": f"{coeffs[i]:.6g}", "unit": ""}) + + elif function == "cubic": + coeffs = np.polyfit(a, b, 3) + predicted = np.polyval(coeffs, a) + for i, name in enumerate(["a3", "a2", "a1", "a0"]): + records.append({"quantity": name, "value": f"{coeffs[i]:.6g}", "unit": ""}) + + elif function == "power": + # b = c * a^n → log(b) = log(c) + n*log(a) + valid = (a > 0) & (b > 0) + if valid.sum() < 2: + predicted = np.zeros_like(a) + records.append({"quantity": "error", "value": "insufficient positive values", "unit": ""}) + else: + log_coeffs = np.polyfit(np.log(a[valid]), np.log(b[valid]), 1) + n_exp = log_coeffs[0] + c = np.exp(log_coeffs[1]) + predicted = np.where(a > 0, c * np.power(a, n_exp), 0) + records.append({"quantity": "exponent", "value": f"{n_exp:.6g}", "unit": ""}) + records.append({"quantity": "coefficient", "value": f"{c:.6g}", "unit": ""}) + + elif function == "logarithmic": + # b = a0 + a1 * log(a) + valid = a > 0 + if valid.sum() < 2: + predicted = np.zeros_like(a) + records.append({"quantity": "error", "value": "insufficient positive values", "unit": ""}) + else: + coeffs = np.polyfit(np.log(a[valid]), b[valid], 1) + predicted = np.where(a > 0, np.polyval(coeffs, np.log(a)), 0) + records.append({"quantity": "log_coeff", "value": f"{coeffs[0]:.6g}", "unit": ""}) + records.append({"quantity": "intercept", "value": f"{coeffs[1]:.6g}", "unit": ""}) + else: + predicted = np.zeros_like(a) + + # R² statistic + ss_res = np.sum((b - predicted)**2) + ss_tot = np.sum((b - b.mean())**2) + r2 = 1.0 - ss_res / max(ss_tot, 1e-30) + records.append({"quantity": "R²", "value": f"{r2:.6f}", "unit": ""}) + + pred_field = field_b.replace(data=predicted.reshape(field_b.data.shape)) + return (pred_field, records) diff --git a/backend/nodes/resample.py b/backend/nodes/resample.py index 95ca32f..37d94cf 100644 --- a/backend/nodes/resample.py +++ b/backend/nodes/resample.py @@ -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} diff --git a/backend/nodes/scan_line_reorder.py b/backend/nodes/scan_line_reorder.py new file mode 100644 index 0000000..5d7aa6d --- /dev/null +++ b/backend/nodes/scan_line_reorder.py @@ -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),) diff --git a/backend/nodes/shade.py b/backend/nodes/shade.py new file mode 100644 index 0000000..4591ceb --- /dev/null +++ b/backend/nodes/shade.py @@ -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=""),) diff --git a/backend/nodes/shape_fitting.py b/backend/nodes/shape_fitting.py index d788740..ac67c16 100644 --- a/backend/nodes/shape_fitting.py +++ b/backend/nodes/shape_fitting.py @@ -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: diff --git a/backend/nodes/slope_distribution.py b/backend/nodes/slope_distribution.py index 5eaecd4..7226c1a 100644 --- a/backend/nodes/slope_distribution.py +++ b/backend/nodes/slope_distribution.py @@ -28,7 +28,6 @@ class SlopeDistribution: "'theta' is the inclination angle (0–max°), probability density (1/deg); " "'phi' is the azimuthal slope direction (0–360°), weighted by slope² (z/xy)²; " "'gradient' is the gradient magnitude distribution, probability density (1/(z/xy)). " - "Equivalent to Gwyddion's slope_dist module (slope_dist.c)." ) def process(self, field: DataField, distribution: str, n_bins: int) -> tuple: diff --git a/backend/nodes/spot_removal.py b/backend/nodes/spot_removal.py index f3c7f76..d6d4e8f 100644 --- a/backend/nodes/spot_removal.py +++ b/backend/nodes/spot_removal.py @@ -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( diff --git a/backend/nodes/statistics.py b/backend/nodes/statistics.py index 2e26059..a571099 100644 --- a/backend/nodes/statistics.py +++ b/backend/nodes/statistics.py @@ -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: diff --git a/backend/nodes/straighten_path.py b/backend/nodes/straighten_path.py new file mode 100644 index 0000000..bb263f1 --- /dev/null +++ b/backend/nodes/straighten_path.py @@ -0,0 +1,95 @@ +"""Straighten path — extract cross-section along a curved spline path.""" + +from __future__ import annotations + +import numpy as np +from scipy.ndimage import map_coordinates + +from backend.node_registry import register_node +from backend.data_types import DataField + + +@register_node(display_name="Straighten Path") +class StraightenPath: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "points_x": ("STRING", {"default": "0.25, 0.5, 0.75"}), + "points_y": ("STRING", {"default": "0.5, 0.3, 0.5"}), + "thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}), + "n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'straightened'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Extract a cross-section along an arbitrary curved path defined by " + "control points. Points are given as fractional coordinates (0–1). " + "The path is interpolated with cubic splines, and data is sampled " + "along it with configurable thickness. " + ) + + def process(self, field: DataField, points_x: str, points_y: str, + thickness: int, n_samples: int) -> tuple: + data = np.asarray(field.data, dtype=np.float64) + yres, xres = data.shape + + # Parse control points + px = [float(v.strip()) * (xres - 1) for v in points_x.split(",") if v.strip()] + py = [float(v.strip()) * (yres - 1) for v in points_y.split(",") if v.strip()] + + if len(px) < 2 or len(py) < 2: + # Need at least 2 points + return (field,) + + n_pts = min(len(px), len(py)) + px, py = px[:n_pts], py[:n_pts] + + # Parameterize path and interpolate + t_ctrl = np.linspace(0, 1, n_pts) + t_sample = np.linspace(0, 1, n_samples) + + # Simple cubic interpolation via numpy + if n_pts >= 4: + from numpy.polynomial.polynomial import Polynomial + cx = np.interp(t_sample, t_ctrl, px) + cy = np.interp(t_sample, t_ctrl, py) + else: + cx = np.interp(t_sample, t_ctrl, px) + cy = np.interp(t_sample, t_ctrl, py) + + # Sample along path with thickness + if thickness <= 1: + values = map_coordinates(data, [cy, cx], order=1, mode='nearest') + result = values.reshape(1, -1) + else: + # Compute normals + dcx = np.gradient(cx) + dcy = np.gradient(cy) + length = np.sqrt(dcx**2 + dcy**2) + length = np.maximum(length, 1e-10) + nx = -dcy / length + ny = dcx / length + + offsets = np.linspace(-(thickness - 1) / 2, (thickness - 1) / 2, thickness) + result = np.zeros((thickness, n_samples)) + for i, off in enumerate(offsets): + sx = cx + off * nx + sy = cy + off * ny + result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest') + + # Physical dimensions + total_length = 0.0 + for i in range(1, len(cx)): + dx_phys = (cx[i] - cx[i - 1]) * field.dx + dy_phys = (cy[i] - cy[i - 1]) * field.dy + total_length += np.sqrt(dx_phys**2 + dy_phys**2) + + return (field.replace(data=result, xreal=total_length, + yreal=thickness * max(field.dx, field.dy)),) diff --git a/backend/nodes/synthetic_surface.py b/backend/nodes/synthetic_surface.py index f5dc57b..a78ce2f 100644 --- a/backend/nodes/synthetic_surface.py +++ b/backend/nodes/synthetic_surface.py @@ -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( diff --git a/backend/nodes/template_match.py b/backend/nodes/template_match.py index 83d36df..4ec77aa 100644 --- a/backend/nodes/template_match.py +++ b/backend/nodes/template_match.py @@ -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( diff --git a/backend/nodes/terrace_fit.py b/backend/nodes/terrace_fit.py new file mode 100644 index 0000000..8b25ac4 --- /dev/null +++ b/backend/nodes/terrace_fit.py @@ -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) diff --git a/backend/nodes/tilt.py b/backend/nodes/tilt.py new file mode 100644 index 0000000..5a11560 --- /dev/null +++ b/backend/nodes/tilt.py @@ -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),) diff --git a/backend/nodes/tip_blind_estimate.py b/backend/nodes/tip_blind_estimate.py index 5fb7471..19ceca2 100644 --- a/backend/nodes/tip_blind_estimate.py +++ b/backend/nodes/tip_blind_estimate.py @@ -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( diff --git a/backend/nodes/tip_deconvolution.py b/backend/nodes/tip_deconvolution.py index 2a28b34..6e4d2e3 100644 --- a/backend/nodes/tip_deconvolution.py +++ b/backend/nodes/tip_deconvolution.py @@ -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: diff --git a/backend/nodes/tip_model.py b/backend/nodes/tip_model.py index 57b1444..9d78d62 100644 --- a/backend/nodes/tip_model.py +++ b/backend/nodes/tip_model.py @@ -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( diff --git a/backend/nodes/trimmed_mean.py b/backend/nodes/trimmed_mean.py new file mode 100644 index 0000000..bbdbe1e --- /dev/null +++ b/backend/nodes/trimmed_mean.py @@ -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),) diff --git a/backend/nodes/wavelet_denoise.py b/backend/nodes/wavelet_denoise.py index 9689902..320642a 100644 --- a/backend/nodes/wavelet_denoise.py +++ b/backend/nodes/wavelet_denoise.py @@ -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( diff --git a/backend/nodes/wrap_value.py b/backend/nodes/wrap_value.py new file mode 100644 index 0000000..87b7ebe --- /dev/null +++ b/backend/nodes/wrap_value.py @@ -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),) diff --git a/backend/nodes/zero_crossing.py b/backend/nodes/zero_crossing.py new file mode 100644 index 0000000..ddd50a0 --- /dev/null +++ b/backend/nodes/zero_crossing.py @@ -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=""),) diff --git a/docs/nodes/Affine Correction.md b/docs/nodes/Affine Correction.md new file mode 100644 index 0000000..74be75c --- /dev/null +++ b/docs/nodes/Affine Correction.md @@ -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. diff --git a/docs/nodes/Deconvolution.md b/docs/nodes/Deconvolution.md new file mode 100644 index 0000000..d6e8922 --- /dev/null +++ b/docs/nodes/Deconvolution.md @@ -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.1–50.0) | +| regularisation | FLOAT | 0.01 | Regularisation parameter for Wiener filter (1e-6–1.0) | +| iterations | INT | 10 | Number of iterations (Richardson-Lucy only, 1–200) | + +## 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=1–3 and adjust. +- For tip-shape deconvolution (non-Gaussian PSF), use Tip Deconvolution instead. diff --git a/docs/nodes/Drift Correction.md b/docs/nodes/Drift Correction.md new file mode 100644 index 0000000..9ad491e --- /dev/null +++ b/docs/nodes/Drift Correction.md @@ -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. diff --git a/docs/nodes/Extend Pad.md b/docs/nodes/Extend Pad.md new file mode 100644 index 0000000..f186125 --- /dev/null +++ b/docs/nodes/Extend Pad.md @@ -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 (0–1024) | +| right | INT | 0 | Number of pixels to add on the right edge (0–1024) | +| top | INT | 0 | Number of pixels to add on the top edge (0–1024) | +| bottom | INT | 0 | Number of pixels to add on the bottom edge (0–1024) | +| 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. diff --git a/docs/nodes/Facet Analysis.md b/docs/nodes/Facet Analysis.md new file mode 100644 index 0000000..1cc6102 --- /dev/null +++ b/docs/nodes/Facet Analysis.md @@ -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 (30–720) | +| kernel_size | INT | 3 | Sobel gradient kernel size in pixels (3–9, odd) | + +## Notes + +- The output is a normalised probability density — it sums to 1.0. +- X-axis: azimuthal angle phi (0–360°). 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. diff --git a/docs/nodes/Feature Detection.md b/docs/nodes/Feature Detection.md new file mode 100644 index 0000000..9257b1a --- /dev/null +++ b/docs/nodes/Feature Detection.md @@ -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.1–20.0) | +| low_threshold | FLOAT | 0.1 | Canny low hysteresis threshold (0–1) | +| high_threshold | FLOAT | 0.2 | Canny high hysteresis threshold (0–1) | +| harris_k | FLOAT | 0.05 | Harris detector sensitivity parameter (0.01–0.5) | +| min_distance | INT | 5 | Minimum distance between detected corners in pixels (1–100) | + +## 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. diff --git a/docs/nodes/Flatten Base.md b/docs/nodes/Flatten Base.md new file mode 100644 index 0000000..7361bb6 --- /dev/null +++ b/docs/nodes/Flatten Base.md @@ -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 (5–80) | +| poly_degree | INT | 2 | Polynomial degree for base fit: 0 = constant, 1 = plane, 2 = quadratic (0–5) | + +## Notes + +- Set the threshold percentile so that it includes most of the base but excludes the features. For sparse particles on a flat substrate, 20–40% typically works well. +- poly_degree=1 is equivalent to plane leveling on the base only. Use 2–3 for curved substrates. +- If the features dominate the surface (>50% coverage), this node may not give good results — consider Median Background instead. diff --git a/docs/nodes/Fractal Interpolation.md b/docs/nodes/Fractal Interpolation.md new file mode 100644 index 0000000..d5fb907 --- /dev/null +++ b/docs/nodes/Fractal Interpolation.md @@ -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 (10–5000) | + +## 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. diff --git a/docs/nodes/Frequency Split.md b/docs/nodes/Frequency Split.md new file mode 100644 index 0000000..2912206 --- /dev/null +++ b/docs/nodes/Frequency Split.md @@ -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.001–0.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. diff --git a/docs/nodes/Grain Cross.md b/docs/nodes/Grain Cross.md new file mode 100644 index 0000000..cea9ee0 --- /dev/null +++ b/docs/nodes/Grain Cross.md @@ -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 (1–100000) | + +## 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. diff --git a/docs/nodes/Grain Distributions.md b/docs/nodes/Grain Distributions.md new file mode 100644 index 0000000..ca9c3e7 --- /dev/null +++ b/docs/nodes/Grain Distributions.md @@ -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 (5–200) | +| min_size | INT | 10 | Minimum grain size in pixels to include (1–100000) | + +## 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. diff --git a/docs/nodes/Grain Edge.md b/docs/nodes/Grain Edge.md new file mode 100644 index 0000000..c064e92 --- /dev/null +++ b/docs/nodes/Grain Edge.md @@ -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 (1–10) | + +## 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. diff --git a/docs/nodes/Grain Mark.md b/docs/nodes/Grain Mark.md new file mode 100644 index 0000000..a103da1 --- /dev/null +++ b/docs/nodes/Grain Mark.md @@ -0,0 +1,33 @@ +# Grain Mark + +Mark grains by thresholding height, slope magnitude, or curvature. Thresholds are relative (0–1) to the data range. Small regions below min_size pixels are removed. 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 (0–1) | +| threshold_high | FLOAT | 1.0 | Upper bound of the normalized threshold range (0–1) | +| min_size | INT | 10 | Minimum grain size in pixels; smaller regions are removed (1–100000) | +| 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. diff --git a/docs/nodes/Grain Summary.md b/docs/nodes/Grain Summary.md new file mode 100644 index 0000000..6bf8b77 --- /dev/null +++ b/docs/nodes/Grain Summary.md @@ -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 (1–100000) | + +## 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. diff --git a/docs/nodes/Hough Transform.md b/docs/nodes/Hough Transform.md new file mode 100644 index 0000000..90cc688 --- /dev/null +++ b/docs/nodes/Hough Transform.md @@ -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 (1–50) | +| threshold | FLOAT | 1.0 | Minimum accumulator value relative to peak (0.1–10.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. diff --git a/docs/nodes/Image Stitch.md b/docs/nodes/Image Stitch.md new file mode 100644 index 0000000..4b22323 --- /dev/null +++ b/docs/nodes/Image Stitch.md @@ -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. diff --git a/docs/nodes/Immerse Detail.md b/docs/nodes/Immerse Detail.md new file mode 100644 index 0000000..ca91ac9 --- /dev/null +++ b/docs/nodes/Immerse Detail.md @@ -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. diff --git a/docs/nodes/Journal.md b/docs/nodes/Journal.md new file mode 100644 index 0000000..f737b59 --- /dev/null +++ b/docs/nodes/Journal.md @@ -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. diff --git a/docs/nodes/Laplace Interpolation.md b/docs/nodes/Laplace Interpolation.md new file mode 100644 index 0000000..241d3c4 --- /dev/null +++ b/docs/nodes/Laplace Interpolation.md @@ -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 (10–10000) | + +## 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), 200–500 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. diff --git a/docs/nodes/Lattice Measurement.md b/docs/nodes/Lattice Measurement.md new file mode 100644 index 0000000..a4eda50 --- /dev/null +++ b/docs/nodes/Lattice Measurement.md @@ -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 3–4 complete periods in each direction. diff --git a/docs/nodes/Level Grains.md b/docs/nodes/Level Grains.md new file mode 100644 index 0000000..7561db5 --- /dev/null +++ b/docs/nodes/Level Grains.md @@ -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. diff --git a/docs/nodes/Log-Polar PSDF.md b/docs/nodes/Log-Polar PSDF.md new file mode 100644 index 0000000..3c3a48f --- /dev/null +++ b/docs/nodes/Log-Polar PSDF.md @@ -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 (36–720) | +| n_r | INT | 100 | Number of radial (log-frequency) bins (20–500) | + +## 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. diff --git a/docs/nodes/MFM Analysis.md b/docs/nodes/MFM Analysis.md new file mode 100644 index 0000000..534be4f --- /dev/null +++ b/docs/nodes/MFM Analysis.md @@ -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. diff --git a/docs/nodes/Median Background.md b/docs/nodes/Median Background.md new file mode 100644 index 0000000..3df7ff7 --- /dev/null +++ b/docs/nodes/Median Background.md @@ -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)² (2–500) | +| 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. diff --git a/docs/nodes/Multiple Profiles.md b/docs/nodes/Multiple Profiles.md new file mode 100644 index 0000000..cd668f7 --- /dev/null +++ b/docs/nodes/Multiple Profiles.md @@ -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 (-1–10000) | +| 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. diff --git a/docs/nodes/Mutual Crop.md b/docs/nodes/Mutual Crop.md new file mode 100644 index 0000000..8c71360 --- /dev/null +++ b/docs/nodes/Mutual Crop.md @@ -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. diff --git a/docs/nodes/Outlier Mask.md b/docs/nodes/Outlier Mask.md new file mode 100644 index 0000000..b989e60 --- /dev/null +++ b/docs/nodes/Outlier Mask.md @@ -0,0 +1,29 @@ +# Outlier Mask + +Create a mask marking pixels that deviate more than N standard deviations from the mean. Quick way to identify noise spikes and defects. Equivalent to Gwyddion's outliers.c module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input surface | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| mask | IMAGE | Binary mask of outlier pixels | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| sigma_threshold | FLOAT | 3.0 | Number of standard deviations beyond which a pixel is an outlier (1.0–10.0) | +| mode | dropdown | both | Which outliers to flag: both (high and low), high only, or low only | + +## Notes + +- A pixel is flagged if its z-score (data - mean) / std exceeds the threshold. +- 3σ catches ~0.3% of pixels in a Gaussian distribution. Use 2σ for aggressive filtering or 5σ for conservative. +- The resulting mask can be fed to Laplace Interpolation or Fractal Interpolation to fill the defects. +- For a uniform (constant) field, no pixels are flagged regardless of threshold. diff --git a/docs/nodes/Perspective Correction.md b/docs/nodes/Perspective Correction.md new file mode 100644 index 0000000..facf1af --- /dev/null +++ b/docs/nodes/Perspective Correction.md @@ -0,0 +1,35 @@ +# Perspective Correction + +Fix perspective distortion in a DATA_FIELD via a projective (homography) transform. Each corner can be shifted by a fractional offset to map a distorted quadrilateral back to a rectangle. Equivalent to Gwyddion's `correct_perspective.c` module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field with perspective distortion | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| corrected | DATA_FIELD | Perspective-corrected field | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| top_left_x | FLOAT | 0.0 | Horizontal offset of the top-left corner as a fraction of image width (-0.5–0.5) | +| top_left_y | FLOAT | 0.0 | Vertical offset of the top-left corner as a fraction of image height (-0.5–0.5) | +| top_right_x | FLOAT | 0.0 | Horizontal offset of the top-right corner as a fraction of image width (-0.5–0.5) | +| top_right_y | FLOAT | 0.0 | Vertical offset of the top-right corner as a fraction of image height (-0.5–0.5) | +| bottom_left_x | FLOAT | 0.0 | Horizontal offset of the bottom-left corner as a fraction of image width (-0.5–0.5) | +| bottom_left_y | FLOAT | 0.0 | Vertical offset of the bottom-left corner as a fraction of image height (-0.5–0.5) | +| bottom_right_x | FLOAT | 0.0 | Horizontal offset of the bottom-right corner as a fraction of image width (-0.5–0.5) | +| bottom_right_y | FLOAT | 0.0 | Vertical offset of the bottom-right corner as a fraction of image height (-0.5–0.5) | + +## Notes + +- All offsets are given as fractions of the image dimensions (0.0 = no shift, 0.1 = 10% shift). Positive x shifts right, positive y shifts down. +- The transform uses bilinear interpolation to resample pixel values at non-integer locations. +- For trapezoidal distortions (common in tilted AFM scans), typically only two corners need adjustment. +- Set all offsets to 0.0 to pass the field through unchanged. diff --git a/docs/nodes/Pixel Binning.md b/docs/nodes/Pixel Binning.md new file mode 100644 index 0000000..62a25c6 --- /dev/null +++ b/docs/nodes/Pixel Binning.md @@ -0,0 +1,29 @@ +# Pixel Binning + +Downsample a DATA_FIELD by grouping pixels into NxN blocks and reducing each block to a single value. Supports mean, sum, and median reduction methods. Equivalent to Gwyddion's `binning.c` module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field to downsample | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| binned | DATA_FIELD | Downsampled field | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| bin_size | INT | 2 | Side length of the square binning block in pixels (2–64) | +| method | dropdown | mean | Reduction method per block: mean (average), sum (total), or median (middle value) | + +## Notes + +- Pixels at the right and bottom edges that do not fill a complete block are trimmed and discarded. +- The output dimensions are floor(width / bin_size) x floor(height / bin_size). +- Mean binning improves signal-to-noise ratio by a factor of bin_size. Sum binning preserves total signal (useful for count data). Median binning is robust to outlier pixels within each block. +- Physical dimensions (real-space size) of the output field are updated to reflect the trimmed area. diff --git a/docs/nodes/Polynomial Distortion.md b/docs/nodes/Polynomial Distortion.md new file mode 100644 index 0000000..e153046 --- /dev/null +++ b/docs/nodes/Polynomial Distortion.md @@ -0,0 +1,33 @@ +# Polynomial Distortion + +Correct nonlinear scanner distortions by applying polynomial coordinate warping independently in the x and y directions. Each axis has three polynomial coefficients controlling linear, quadratic, and cubic distortion terms. Equivalent to Gwyddion's `polydistort.c` module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field with scanner distortion | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| corrected | DATA_FIELD | Distortion-corrected field | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| k1_x | FLOAT | 0.0 | Linear distortion coefficient for the x axis (-1.0–1.0) | +| k2_x | FLOAT | 0.0 | Quadratic distortion coefficient for the x axis (-1.0–1.0) | +| k3_x | FLOAT | 0.0 | Cubic distortion coefficient for the x axis (-1.0–1.0) | +| k1_y | FLOAT | 0.0 | Linear distortion coefficient for the y axis (-1.0–1.0) | +| k2_y | FLOAT | 0.0 | Quadratic distortion coefficient for the y axis (-1.0–1.0) | +| k3_y | FLOAT | 0.0 | Cubic distortion coefficient for the y axis (-1.0–1.0) | + +## Notes + +- k1 controls linear stretching/compression, k2 controls barrel/pincushion-like quadratic distortion, and k3 controls cubic (S-shaped) distortion. +- Coefficients for x and y are independent, allowing correction of anisotropic scanner nonlinearities. +- Small values (0.01–0.1) are typical for real scanner corrections; large values produce extreme warping. +- Set all coefficients to 0.0 to pass the field through unchanged. diff --git a/docs/nodes/Rank Filter.md b/docs/nodes/Rank Filter.md new file mode 100644 index 0000000..c6fb9ba --- /dev/null +++ b/docs/nodes/Rank Filter.md @@ -0,0 +1,31 @@ +# Rank Filter + +Apply a general rank-order (morphological) filter to a DATA_FIELD. Selects the k-th smallest value within a local window, encompassing erosion, dilation, median, and arbitrary percentile operations. Equivalent to Gwyddion's `rank-filter.c` module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field to filter | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| filtered | DATA_FIELD | Rank-filtered field | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| radius | INT | 3 | Radius of the circular filter window in pixels (1–50) | +| operation | dropdown | median | Filter operation: erosion (local minimum), dilation (local maximum), median (50th percentile), or percentile (custom rank) | +| percentile | FLOAT | 50.0 | Custom percentile rank, used only when operation is percentile (0.0–100.0) | + +## Notes + +- Erosion (local min) shrinks bright features and expands dark ones. Dilation (local max) does the opposite. +- Median is equivalent to percentile = 50 and provides edge-preserving smoothing. +- The percentile parameter is ignored unless operation is set to percentile. A percentile of 0 is equivalent to erosion, and 100 is equivalent to dilation. +- The filter uses a circular (disc-shaped) kernel; the actual window diameter is 2*radius + 1 pixels. +- Combining erosion followed by dilation (opening) or dilation followed by erosion (closing) can be achieved by chaining two Rank Filter nodes. diff --git a/docs/nodes/Relate Fields.md b/docs/nodes/Relate Fields.md new file mode 100644 index 0000000..50d616e --- /dev/null +++ b/docs/nodes/Relate Fields.md @@ -0,0 +1,34 @@ +# Relate Fields + +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-squared goodness-of-fit. Equivalent to Gwyddion's relate.c module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field_a | DATA_FIELD | Yes | Independent variable field | +| field_b | DATA_FIELD | Yes | Dependent variable field | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| predicted | DATA_FIELD | Predicted field_b values from the fitted function | +| fit_params | RECORD_TABLE | Fitted coefficients and R-squared statistic | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| function | dropdown | linear | Functional form to fit: linear, quadratic, cubic, power, or logarithmic | + +## Notes + +- **Linear**: b = slope * a + intercept. Reports slope and intercept. +- **Quadratic**: b = a2*a^2 + a1*a + a0. Reports a2, a1, a0. +- **Cubic**: b = a3*a^3 + a2*a^2 + a1*a + a0. Reports a3, a2, a1, a0. +- **Power**: b = c * a^n (fitted via log-log regression). Reports exponent and coefficient. Requires positive values in both fields. +- **Logarithmic**: b = log_coeff * log(a) + intercept. Requires positive values in field_a. +- R-squared is always reported as the last row: values near 1.0 indicate a good fit. +- Both fields are flattened and truncated to the shorter length when they differ in total pixel count. +- The predicted output retains field_b's shape and metadata. diff --git a/docs/nodes/Scan Line Reorder.md b/docs/nodes/Scan Line Reorder.md new file mode 100644 index 0000000..0e83cc8 --- /dev/null +++ b/docs/nodes/Scan Line Reorder.md @@ -0,0 +1,28 @@ +# Scan Line Reorder + +Fix scan line ordering artifacts from meander (serpentine) scanning, interlacing, or inverted scan direction. Equivalent to Gwyddion's reorder.c module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field with scan line artifacts | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| result | DATA_FIELD | Field with corrected scan line order | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| operation | dropdown | reverse_odd | Operation: reverse_odd, reverse_even, deinterlace_odd, deinterlace_even, flip_vertical | + +## Notes + +- **reverse_odd / reverse_even**: Reverse alternate rows to correct meander (serpentine/boustrophedon) scanning. Most SPM controllers scan left-to-right on odd lines and right-to-left on even lines; reversing odd rows fixes this. +- **deinterlace_odd / deinterlace_even**: Keep only odd or even rows and stretch to fill the original height. Useful when alternate scan lines were acquired under different conditions. +- **flip_vertical**: Reverse the row order (top becomes bottom). Useful when the scan direction is inverted. +- Apply this node before Line Correction for best results. diff --git a/docs/nodes/Shade.md b/docs/nodes/Shade.md new file mode 100644 index 0000000..c867304 --- /dev/null +++ b/docs/nodes/Shade.md @@ -0,0 +1,30 @@ +# Shade + +Render a DATA_FIELD as a directional hillshade image using Lambertian reflectance. Surface normals are estimated from Sobel gradients, and shading intensity is computed from the dot product with a configurable light direction. Equivalent to Gwyddion's `shade.c` module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input surface field to shade | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| shaded | DATA_FIELD | Shaded surface rendering | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| azimuth | FLOAT | 0.0 | Light direction in degrees: 0 = north, 90 = east, 180 = south, 270 = west (0–360) | +| elevation | FLOAT | 45.0 | Light elevation angle above the horizon in degrees (0–90) | +| blend | FLOAT | 0.5 | Blend factor between original data and shading: 0.0 = original data only, 1.0 = shading only (0.0–1.0) | + +## Notes + +- Surface normals are computed using Sobel gradient operators, providing good noise resilience compared to simple finite differences. +- An azimuth of 315° (northwest illumination) is a common choice that produces natural-looking topographic shading. +- Elevation of 90° gives flat, uniform lighting; lower angles emphasize surface texture and fine features. +- The blend parameter controls how much shading modulates the original height data. Use 0.5 for a balanced view, or 1.0 for pure shaded relief. diff --git a/docs/nodes/Shape Fitting.md b/docs/nodes/Shape Fitting.md new file mode 100644 index 0000000..a6e504d --- /dev/null +++ b/docs/nodes/Shape Fitting.md @@ -0,0 +1,30 @@ +# Shape Fitting + +Fit a geometric primitive (sphere, paraboloid, or cylinder) to the surface data. Outputs either the fitted surface or the residual. Reports fitted parameters including radius of curvature, centre position, etc. Equivalent to Gwyddion's fit-shape.c module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input surface | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| result | DATA_FIELD | Fitted surface or residual (original minus fit) | +| parameters | RECORD_TABLE | Fitted parameters and RMS residual | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| shape | dropdown | sphere | Geometric primitive: sphere, paraboloid, or cylinder | +| output | dropdown | residual | Output mode: residual (deviations from fit) or fitted (the fit itself) | + +## Notes + +- **Sphere**: Fits z = z0 - sqrt(R² - (x-cx)² - (y-cy)²). Reports centre (cx, cy), apex height (z0), and radius R. +- **Paraboloid**: Fits z = z0 + a(x-cx)² + b(y-cy)². Reports centre, apex, and curvature coefficients a, b. +- **Cylinder**: Fits a parabolic profile along one axis. Reports radius, curvature, and cylinder orientation angle. +- The residual output is useful for evaluating fit quality — a good fit produces small, random residuals. diff --git a/docs/nodes/Straighten Path.md b/docs/nodes/Straighten Path.md new file mode 100644 index 0000000..2afc385 --- /dev/null +++ b/docs/nodes/Straighten Path.md @@ -0,0 +1,32 @@ +# Straighten Path + +Extract a cross-section along an arbitrary curved path defined by control points. The path is interpolated between points and data is sampled along it using `scipy.ndimage.map_coordinates`. Equivalent to Gwyddion's straighten_path.c module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input height field | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| straightened | DATA_FIELD | Straightened cross-section; width = n_samples, height = thickness | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| points_x | STRING | "0.25, 0.5, 0.75" | Comma-separated fractional x-coordinates of control points (0.0–1.0) | +| points_y | STRING | "0.5, 0.3, 0.5" | Comma-separated fractional y-coordinates of control points (0.0–1.0) | +| thickness | INT | 1 | Width of the sampled strip perpendicular to the path, in pixels (1–100) | +| n_samples | INT | 256 | Number of sample points along the path (10–2048) | + +## Notes + +- Control points are specified as fractions of the image dimensions (0 = left/top edge, 1 = right/bottom edge). At least 2 points are required. +- Points are connected by linear interpolation; the path is sampled at n_samples evenly spaced positions. +- When thickness > 1, samples are taken along the local normal direction at each path position, producing a 2D strip rather than a single line. +- The output xreal equals the physical path length (computed from pixel spacing), and yreal equals thickness times the pixel size. +- Bilinear interpolation (order=1) is used with nearest-edge boundary handling. diff --git a/docs/nodes/Synthetic Surface.md b/docs/nodes/Synthetic Surface.md new file mode 100644 index 0000000..153f934 --- /dev/null +++ b/docs/nodes/Synthetic Surface.md @@ -0,0 +1,37 @@ +# Synthetic Surface + +Generate synthetic test surfaces for development, calibration, and algorithm testing. Equivalent to Gwyddion's *_synth.c modules. + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| surface | DATA_FIELD | Generated synthetic surface | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| pattern | dropdown | fbm | Pattern type: fbm, white_noise, lattice, steps, particles, flat | +| xres | INT | 256 | Horizontal resolution in pixels (16–2048) | +| yres | INT | 256 | Vertical resolution in pixels (16–2048) | +| xreal | FLOAT | 1e-6 | Physical width in metres | +| yreal | FLOAT | 1e-6 | Physical height in metres | +| amplitude | FLOAT | 1e-9 | Peak-to-peak amplitude in metres | +| seed | INT | 42 | Random seed for reproducibility (0–999999) | +| hurst_exponent | FLOAT | 0.7 | FBM roughness exponent: 0 = rough, 1 = smooth (fbm only) | +| lattice_spacing | FLOAT | 100e-9 | Lattice period in metres (lattice only) | +| lattice_angle | FLOAT | 90.0 | Angle between lattice vectors in degrees (lattice only) | +| n_steps | INT | 5 | Number of step terraces (steps only) | +| n_particles | INT | 20 | Number of particles (particles only) | +| particle_radius_px | INT | 10 | Particle radius in pixels (particles only) | + +## Notes + +- **fbm**: Fractional Brownian motion via spectral synthesis. Hurst exponent controls roughness. +- **white_noise**: Gaussian random noise. +- **lattice**: Two-axis sinusoidal grid with configurable spacing and angle. +- **steps**: Terraced step structure with equal step heights. +- **particles**: Random spherical particles on a flat background. +- **flat**: Zero surface (useful as a baseline). +- All patterns are normalised to the specified amplitude range. diff --git a/docs/nodes/Terrace Fit.md b/docs/nodes/Terrace Fit.md new file mode 100644 index 0000000..10f65c6 --- /dev/null +++ b/docs/nodes/Terrace Fit.md @@ -0,0 +1,31 @@ +# Terrace Fit + +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. Equivalent to Gwyddion's terracefit.c module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input surface with step/terrace features | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| result | DATA_FIELD | Residual, fitted surface, or label map | +| step_heights | RECORD_TABLE | Per-terrace heights and step height differences | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| n_terraces | INT | 0 | Number of terraces to fit; 0 = auto-detect from histogram peaks (0–50) | +| broadening | FLOAT | 1.0 | Smoothing factor for terrace detection; larger values merge noisy pixels (0.1–20.0) | +| poly_degree | INT | 0 | Polynomial degree per terrace: 0 = constant (flat), 1 = linear, 2 = quadratic, 3 = cubic (0–3) | +| output | dropdown | residual | Output mode: residual (original minus fit), fitted (fit surface), or labels (terrace assignment map) | + +## Notes + +- Use poly_degree=0 for ideal crystalline surfaces with perfectly flat terraces. Higher degrees compensate for sample curvature within each terrace. +- Auto-detection works best when terraces are well-separated in height. For noisy surfaces, increase broadening to improve terrace segmentation. +- The labels output assigns integer IDs (0, 1, 2, ...) to each terrace, ordered by height. diff --git a/docs/nodes/Tilt.md b/docs/nodes/Tilt.md new file mode 100644 index 0000000..1f02a89 --- /dev/null +++ b/docs/nodes/Tilt.md @@ -0,0 +1,30 @@ +# Tilt + +Apply or remove a linear tilt (planar slope) from a DATA_FIELD. The tilt is defined by slope values in data units per physical unit along the x and y directions. Equivalent to Gwyddion's `tilt.c` module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| tilted | DATA_FIELD | Field with tilt applied or removed | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| slope_x | FLOAT | 0.0 | Slope along the x axis in data units per physical unit (e.g. nm/um) | +| slope_y | FLOAT | 0.0 | Slope along the y axis in data units per physical unit (e.g. nm/um) | +| mode | dropdown | subtract | Operation mode: subtract (remove tilt) or add (apply tilt) | + +## Notes + +- In subtract mode the specified planar slope is removed from the field, useful for compensating known sample tilt. +- In add mode the slope is added, useful for simulating a tilted surface or restoring a previously subtracted tilt. +- The slope values are in data-unit per physical-unit (e.g. nm/um). The actual z-change per pixel depends on the field's physical pixel size. +- For leveling an unknown tilt, consider Plane Level instead, which automatically fits and removes the best-fit plane. diff --git a/docs/nodes/Trimmed Mean.md b/docs/nodes/Trimmed Mean.md new file mode 100644 index 0000000..744b16f --- /dev/null +++ b/docs/nodes/Trimmed Mean.md @@ -0,0 +1,29 @@ +# Trimmed Mean + +Apply a local trimmed-mean filter to a DATA_FIELD. Within each circular window, the lowest and highest fraction of pixel values are excluded before computing the mean. This provides smoothing that is more robust to outliers than a plain Gaussian or mean filter. Equivalent to Gwyddion's `trimmed-mean.c` module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field to filter | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| filtered | DATA_FIELD | Trimmed-mean filtered field | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| radius | INT | 3 | Radius of the circular filter window in pixels (1–50) | +| trim_fraction | FLOAT | 0.1 | Fraction of values to exclude from each end of the sorted window (0.0–0.49) | + +## Notes + +- A trim_fraction of 0.0 gives a plain local mean (no trimming). A trim_fraction approaching 0.5 converges to the local median. +- Typical values of 0.05–0.2 effectively suppress outlier spikes while preserving smooth features better than a median filter. +- The filter uses a circular (disc-shaped) kernel; the actual window diameter is 2*radius + 1 pixels. +- More computationally expensive than a Gaussian filter due to the sorting step within each window. diff --git a/docs/nodes/Wrap Value.md b/docs/nodes/Wrap Value.md new file mode 100644 index 0000000..af0c06b --- /dev/null +++ b/docs/nodes/Wrap Value.md @@ -0,0 +1,30 @@ +# Wrap Value + +Rewrap periodic values in a DATA_FIELD to a specified angular or custom range. Commonly used for phase images where values should lie within a well-defined interval. Equivalent to Gwyddion's `wrapvalue.c` module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field with periodic values to wrap | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| wrapped | DATA_FIELD | Field with values wrapped to the target range | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| preset | dropdown | 0-360 | Wrapping range preset: 0-360 (degrees), pm180 (±180°), 0-2pi (radians), pm_pi (±π), or custom | +| custom_min | FLOAT | 0.0 | Lower bound of the wrapping range (used only when preset is custom) | +| custom_max | FLOAT | 360.0 | Upper bound of the wrapping range (used only when preset is custom) | + +## Notes + +- Preset ranges: 0-360 wraps to [0, 360], pm180 wraps to [-180, 180], 0-2pi wraps to [0, 2π], pm_pi wraps to [-π, π]. +- custom_min and custom_max are ignored unless preset is set to custom. +- This node applies modular arithmetic so that all output values fall within the chosen interval. It does not scale or rescale values. +- Particularly useful for MFM phase channels, Kelvin probe contact potential images, and interferometric phase maps. diff --git a/docs/nodes/Zero Crossing.md b/docs/nodes/Zero Crossing.md new file mode 100644 index 0000000..3b00669 --- /dev/null +++ b/docs/nodes/Zero Crossing.md @@ -0,0 +1,29 @@ +# Zero Crossing + +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. Equivalent to Gwyddion's zero_crossing.c module. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input height field | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| edges | DATA_FIELD | Binary edge map (1.0 at edges, 0.0 elsewhere) | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| sigma | FLOAT | 2.0 | Gaussian smoothing scale for the LoG operator (0.5–20.0) | +| threshold | FLOAT | 0.0 | Minimum edge strength as a fraction of the maximum LoG contrast; filters weak edges (0.0–1.0) | + +## Notes + +- The algorithm computes the Laplacian of Gaussian (via `scipy.ndimage.gaussian_laplace`), then marks pixels where adjacent values change sign (horizontal and vertical neighbours). +- Larger sigma values detect coarser features and suppress noise; smaller values pick up finer detail but are noisier. +- Threshold = 0.0 keeps all zero crossings. Increasing it towards 1.0 retains only the strongest edges. +- The output z-unit is cleared (dimensionless binary mask). diff --git a/tests/node_tests/extend_pad.py b/tests/node_tests/extend_pad.py new file mode 100644 index 0000000..bde22e8 --- /dev/null +++ b/tests/node_tests/extend_pad.py @@ -0,0 +1,42 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_shape_increase(): + """32x32 + top=5, bottom=5, left=10, right=10 should give 42x52.""" + from backend.nodes.extend_pad import ExtendPad + + node = ExtendPad() + field = make_field(shape=(32, 32)) + result, = node.process(field, top=5, bottom=5, left=10, right=10, method="mirror") + assert result.data.shape == (42, 52) + + +def test_zero_pad(): + """Zero padding should fill borders with zeros.""" + from backend.nodes.extend_pad import ExtendPad + + node = ExtendPad() + data = np.ones((16, 16), dtype=np.float64) * 7.0 + field = make_field(data=data) + result, = node.process(field, top=2, bottom=2, left=2, right=2, method="zero") + assert result.data.shape == (20, 20) + # Top border rows should be zero + assert np.allclose(result.data[:2, :], 0.0) + # Bottom border rows should be zero + assert np.allclose(result.data[-2:, :], 0.0) + # Left border columns should be zero + assert np.allclose(result.data[:, :2], 0.0) + # Right border columns should be zero + assert np.allclose(result.data[:, -2:], 0.0) + + +def test_mirror_pad(): + """Mirror padding should produce the correct output shape.""" + from backend.nodes.extend_pad import ExtendPad + + node = ExtendPad() + field = make_field(shape=(32, 32)) + result, = node.process(field, top=4, bottom=4, left=8, right=8, method="mirror") + assert result.data.shape == (40, 48) diff --git a/tests/node_tests/flatten_base.py b/tests/node_tests/flatten_base.py new file mode 100644 index 0000000..173e4ed --- /dev/null +++ b/tests/node_tests/flatten_base.py @@ -0,0 +1,38 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_flatten_base_removes_tilt(): + from backend.nodes.flatten_base import FlattenBase + + node = FlattenBase() + yy, xx = np.mgrid[:64, :64] + base_tilt = 0.01 * xx + 0.02 * yy + # Add some tall features + data = base_tilt.copy() + data[20:30, 20:30] += 10.0 + field = make_field(data=data) + result, = node.process(field, 30.0, 1) + assert result.data.shape == (64, 64) + # Features should remain raised, base should be flatter + assert result.data[25, 25] > result.data[0, 0] + + +def test_flatten_base_preserves_shape(): + from backend.nodes.flatten_base import FlattenBase + + node = FlattenBase() + field = make_field(shape=(48, 64)) + result, = node.process(field, 30.0, 2) + assert result.data.shape == (48, 64) + + +def test_flatten_base_flat_surface(): + from backend.nodes.flatten_base import FlattenBase + + node = FlattenBase() + field = make_field(data=np.ones((32, 32)) * 5.0) + result, = node.process(field, 50.0, 0) + # All pixels are the same, subtracting mean gives zero + assert np.allclose(result.data, 0.0, atol=1e-10) diff --git a/tests/node_tests/fractal_interpolation.py b/tests/node_tests/fractal_interpolation.py new file mode 100644 index 0000000..ba7a2c8 --- /dev/null +++ b/tests/node_tests/fractal_interpolation.py @@ -0,0 +1,43 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import bool_to_mask + + +def test_fractal_fills_hole(): + from backend.nodes.fractal_interpolation import FractalInterpolation + + node = FractalInterpolation() + data = np.random.default_rng(42).standard_normal((32, 32)) + mask = np.zeros((32, 32), dtype=bool) + mask[12:20, 12:20] = True + field = make_field(data=data) + result, = node.process(field, bool_to_mask(mask), 50) + assert result.data.shape == (32, 32) + assert np.isfinite(result.data).all() + + +def test_fractal_no_mask_unchanged(): + from backend.nodes.fractal_interpolation import FractalInterpolation + + node = FractalInterpolation() + field = make_field(shape=(32, 32)) + mask = bool_to_mask(np.zeros((32, 32), dtype=bool)) + result, = node.process(field, mask, 50) + assert np.allclose(result.data, field.data) + + +def test_fractal_preserves_statistics(): + from backend.nodes.fractal_interpolation import FractalInterpolation + + node = FractalInterpolation() + rng = np.random.default_rng(0) + data = rng.standard_normal((64, 64)) + mask = np.zeros((64, 64), dtype=bool) + mask[20:40, 20:40] = True + field = make_field(data=data) + result, = node.process(field, bool_to_mask(mask), 100) + # Filled region should have similar statistics to the rest + filled_std = result.data[mask].std() + valid_std = data[~mask].std() + assert filled_std > 0.1 * valid_std # not flat diff --git a/tests/node_tests/freq_split.py b/tests/node_tests/freq_split.py new file mode 100644 index 0000000..600b308 --- /dev/null +++ b/tests/node_tests/freq_split.py @@ -0,0 +1,35 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_sum_reconstruction(): + """Low-pass plus high-pass should reconstruct the original field.""" + from backend.nodes.freq_split import FrequencySplit + + node = FrequencySplit() + field = make_field(shape=(64, 64)) + low, high = node.process(field, cutoff=0.1) + reconstructed = low.data + high.data + assert np.allclose(reconstructed, field.data, atol=1e-10) + + +def test_shapes(): + """Both outputs should have the same shape as the input.""" + from backend.nodes.freq_split import FrequencySplit + + node = FrequencySplit() + field = make_field(shape=(64, 64)) + low, high = node.process(field, cutoff=0.1) + assert low.data.shape == field.data.shape + assert high.data.shape == field.data.shape + + +def test_low_pass_smoother(): + """Low-pass output should have smaller std than the original random data.""" + from backend.nodes.freq_split import FrequencySplit + + node = FrequencySplit() + field = make_field(shape=(64, 64)) + low, high = node.process(field, cutoff=0.1) + assert np.std(low.data) < np.std(field.data) diff --git a/tests/node_tests/grain_cross.py b/tests/node_tests/grain_cross.py new file mode 100644 index 0000000..8e4bd19 --- /dev/null +++ b/tests/node_tests/grain_cross.py @@ -0,0 +1,75 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import bool_to_mask + + +def test_basic_correlation(): + from backend.nodes.grain_cross import GrainCross + + node = GrainCross() + + rng = np.random.default_rng(42) + data_a = rng.standard_normal((64, 64)) + data_b = rng.standard_normal((64, 64)) + field_a = make_field(data=data_a) + field_b = make_field(data=data_b) + + # Create mask with two distinct grains + mask_bool = np.zeros((64, 64), dtype=bool) + mask_bool[5:20, 5:20] = True + mask_bool[40:55, 40:55] = True + mask = bool_to_mask(mask_bool) + + (table,) = node.process(field_a, field_b, mask=mask, + property_a="mean_height", property_b="max_height", + min_size=10) + assert len(table) > 0, "Should return entries for detected grains" + + +def test_pearson_reported(): + from backend.nodes.grain_cross import GrainCross + + node = GrainCross() + + rng = np.random.default_rng(7) + data_a = rng.standard_normal((64, 64)) + data_b = rng.standard_normal((64, 64)) + field_a = make_field(data=data_a) + field_b = make_field(data=data_b) + + # Two grains so Pearson can be computed + mask_bool = np.zeros((64, 64), dtype=bool) + mask_bool[5:20, 5:20] = True + mask_bool[40:55, 40:55] = True + mask = bool_to_mask(mask_bool) + + (table,) = node.process(field_a, field_b, mask=mask, + property_a="mean_height", property_b="mean_height", + min_size=10) + quantities = [row["quantity"] for row in table] + assert "Pearson r" in quantities, f"Expected 'Pearson r' in {quantities}" + + +def test_min_size_filters(): + from backend.nodes.grain_cross import GrainCross + + node = GrainCross() + + data_a = np.zeros((64, 64)) + data_b = np.zeros((64, 64)) + field_a = make_field(data=data_a) + field_b = make_field(data=data_b) + + # Small grain (10x10 = 100 pixels) + mask_bool = np.zeros((64, 64), dtype=bool) + mask_bool[5:15, 5:15] = True + mask = bool_to_mask(mask_bool) + + # min_size larger than any grain + (table,) = node.process(field_a, field_b, mask=mask, + property_a="area", property_b="area", + min_size=200) + # No grain entries (only maybe no Pearson either since < 2 grains) + grain_entries = [r for r in table if r["quantity"].startswith("Grain")] + assert len(grain_entries) == 0, "No grains should pass with large min_size" diff --git a/tests/node_tests/grain_distributions.py b/tests/node_tests/grain_distributions.py new file mode 100644 index 0000000..e47878f --- /dev/null +++ b/tests/node_tests/grain_distributions.py @@ -0,0 +1,45 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import bool_to_mask + + +def test_grain_distributions_area(): + from backend.nodes.grain_distributions import GrainDistributions + + node = GrainDistributions() + data = np.zeros((64, 64)) + data[10:20, 10:20] = 1.0 + data[40:50, 40:50] = 1.0 + mask = np.zeros((64, 64), dtype=bool) + mask[10:20, 10:20] = True + mask[40:50, 40:50] = True + field = make_field(data=data) + dist, = node.process(field, bool_to_mask(mask), "area", 10, 5) + assert hasattr(dist, 'data') + assert len(dist.data) == 10 # n_bins + + +def test_grain_distributions_height(): + from backend.nodes.grain_distributions import GrainDistributions + + node = GrainDistributions() + data = np.zeros((32, 32)) + data[5:15, 5:15] = 2.0 + data[20:28, 20:28] = 5.0 + mask = np.zeros((32, 32), dtype=bool) + mask[5:15, 5:15] = True + mask[20:28, 20:28] = True + field = make_field(data=data) + dist, = node.process(field, bool_to_mask(mask), "mean_height", 10, 5) + assert len(dist.data) == 10 + + +def test_grain_distributions_no_grains(): + from backend.nodes.grain_distributions import GrainDistributions + + node = GrainDistributions() + field = make_field(shape=(32, 32)) + mask = bool_to_mask(np.zeros((32, 32), dtype=bool)) + dist, = node.process(field, mask, "area", 10, 5) + assert hasattr(dist, 'data') diff --git a/tests/node_tests/grain_edge.py b/tests/node_tests/grain_edge.py new file mode 100644 index 0000000..f89a9ae --- /dev/null +++ b/tests/node_tests/grain_edge.py @@ -0,0 +1,66 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import mask_to_bool, bool_to_mask + + +def test_boundary_detection(): + from backend.nodes.grain_edge import GrainEdge + + node = GrainEdge() + data = np.zeros((64, 64)) + data[22:42, 22:42] = 1.0 + field = make_field(data=data) + + mask = np.zeros((64, 64), dtype=np.uint8) + mask[22:42, 22:42] = 255 + + (edge_mask,) = node.process(field, mask=mask, width=1) + edge_bool = mask_to_bool(edge_mask) + + # Edge pixels should lie on the grain boundary (outermost ring of the grain) + assert edge_bool.any(), "Should detect some edge pixels" + # Boundary pixels: grain pixels with at least one non-grain 4-neighbour + # Rows 22 and 41 (top/bottom edges), cols 22 and 41 (left/right edges) + assert edge_bool[22, 30], "Top boundary row should be marked" + assert edge_bool[41, 30], "Bottom boundary row should be marked" + assert edge_bool[30, 22], "Left boundary col should be marked" + assert edge_bool[30, 41], "Right boundary col should be marked" + + +def test_interior_excluded(): + from backend.nodes.grain_edge import GrainEdge + + node = GrainEdge() + data = np.zeros((64, 64)) + data[10:50, 10:50] = 1.0 + field = make_field(data=data) + + mask = np.zeros((64, 64), dtype=np.uint8) + mask[10:50, 10:50] = 255 + + (edge_mask,) = node.process(field, mask=mask, width=1) + edge_bool = mask_to_bool(edge_mask) + + # Deep interior pixel (centre of the 40x40 grain) should NOT be edge + assert not edge_bool[30, 30], "Interior pixel should not be in edge mask" + assert not edge_bool[25, 25], "Another interior pixel should not be edge" + + +def test_width_expands(): + from backend.nodes.grain_edge import GrainEdge + + node = GrainEdge() + data = np.zeros((64, 64)) + data[15:45, 15:45] = 1.0 + field = make_field(data=data) + + mask = np.zeros((64, 64), dtype=np.uint8) + mask[15:45, 15:45] = 255 + + (edge1,) = node.process(field, mask=mask, width=1) + (edge3,) = node.process(field, mask=mask, width=3) + + count1 = mask_to_bool(edge1).sum() + count3 = mask_to_bool(edge3).sum() + assert count3 > count1, f"width=3 ({count3}) should give more edge pixels than width=1 ({count1})" diff --git a/tests/node_tests/grain_mark.py b/tests/node_tests/grain_mark.py new file mode 100644 index 0000000..e59be87 --- /dev/null +++ b/tests/node_tests/grain_mark.py @@ -0,0 +1,56 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import mask_to_bool + + +def test_grain_mark_height(): + from backend.nodes.grain_mark import GrainMark + + node = GrainMark() + data = np.zeros((64, 64)) + data[20:40, 20:40] = 1.0 # raised region + field = make_field(data=data) + mask, = node.process(field, "height", 0.5, 1.0, 10, False) + binary = mask_to_bool(mask) + assert binary[30, 30] # center of raised region should be marked + assert not binary[0, 0] # corner should not be marked + + +def test_grain_mark_slope(): + from backend.nodes.grain_mark import GrainMark + + node = GrainMark() + field = make_field(shape=(64, 64)) + mask, = node.process(field, "slope", 0.3, 1.0, 5, False) + assert mask.shape == (64, 64) + assert mask.dtype == np.uint8 + + +def test_grain_mark_inverted(): + from backend.nodes.grain_mark import GrainMark + + node = GrainMark() + data = np.zeros((32, 32)) + data[10:20, 10:20] = 1.0 + field = make_field(data=data) + mask_normal, = node.process(field, "height", 0.5, 1.0, 1, False) + mask_inv, = node.process(field, "height", 0.5, 1.0, 1, True) + # Inverted should be complement (approximately) + n1 = mask_to_bool(mask_normal).sum() + n2 = mask_to_bool(mask_inv).sum() + assert n1 + n2 > 0 + + +def test_grain_mark_min_size(): + from backend.nodes.grain_mark import GrainMark + + node = GrainMark() + data = np.zeros((64, 64)) + data[30, 30] = 1.0 # single pixel + data[10:20, 10:20] = 1.0 # larger region + field = make_field(data=data) + mask, = node.process(field, "height", 0.5, 1.0, 50, False) + binary = mask_to_bool(mask) + # Single pixel should be filtered out + assert not binary[30, 30] diff --git a/tests/node_tests/grain_summary.py b/tests/node_tests/grain_summary.py new file mode 100644 index 0000000..86d9220 --- /dev/null +++ b/tests/node_tests/grain_summary.py @@ -0,0 +1,49 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import bool_to_mask + + +def test_grain_summary_basic(): + from backend.nodes.grain_summary import GrainSummary + + node = GrainSummary() + data = np.zeros((64, 64)) + data[10:20, 10:20] = 1.0 + data[40:55, 40:55] = 2.0 + mask = np.zeros((64, 64), dtype=bool) + mask[10:20, 10:20] = True + mask[40:55, 40:55] = True + field = make_field(data=data) + records, = node.process(field, bool_to_mask(mask), 5) + assert isinstance(records, list) + # Should have grain count + quantities = [r["quantity"] for r in records] + assert "Grain count" in quantities + count_record = [r for r in records if r["quantity"] == "Grain count"][0] + assert count_record["value"] == "2" + + +def test_grain_summary_no_grains(): + from backend.nodes.grain_summary import GrainSummary + + node = GrainSummary() + field = make_field(shape=(32, 32)) + mask = bool_to_mask(np.zeros((32, 32), dtype=bool)) + records, = node.process(field, mask, 5) + assert isinstance(records, list) + count_record = [r for r in records if r["quantity"] == "Grain count"][0] + assert count_record["value"] == "0" + + +def test_grain_summary_coverage(): + from backend.nodes.grain_summary import GrainSummary + + node = GrainSummary() + data = np.ones((32, 32)) + mask = np.ones((32, 32), dtype=bool) # entire surface is grain + field = make_field(data=data) + records, = node.process(field, bool_to_mask(mask), 1) + quantities = {r["quantity"]: r for r in records} + assert "Coverage fraction" in quantities + assert float(quantities["Coverage fraction"]["value"]) > 0.9 diff --git a/tests/node_tests/immerse_detail.py b/tests/node_tests/immerse_detail.py new file mode 100644 index 0000000..8641044 --- /dev/null +++ b/tests/node_tests/immerse_detail.py @@ -0,0 +1,42 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_output_shape_matches_overview(): + from backend.nodes.immerse_detail import ImmerseDetail + + node = ImmerseDetail() + overview = make_field(shape=(64, 64)) + # Detail must have matching pixel size (smaller physical area) + detail = make_field(shape=(16, 16), xreal=0.25e-6, yreal=0.25e-6) + (combined,) = node.process(overview, detail, blend="replace") + assert combined.data.shape == overview.data.shape + + +def test_detail_larger_returns_overview(): + from backend.nodes.immerse_detail import ImmerseDetail + + node = ImmerseDetail() + overview = make_field(shape=(32, 32)) + # Detail larger than overview after resampling + detail = make_field(shape=(64, 64)) + (combined,) = node.process(overview, detail, blend="replace") + # Should return the overview unchanged + assert np.array_equal(combined.data, overview.data) + + +def test_replace_mode(): + from backend.nodes.immerse_detail import ImmerseDetail + + node = ImmerseDetail() + overview_data = np.zeros((64, 64)) + detail_data = np.ones((16, 16)) * 5.0 + overview = make_field(data=overview_data) + # Match pixel size so detail stays 16x16 (smaller than 64x64) + detail = make_field(data=detail_data, xreal=0.25e-6, yreal=0.25e-6) + (combined,) = node.process(overview, detail, blend="replace") + # After immersion, some pixels should now equal 5.0 + assert np.any(combined.data == 5.0), "Detail should modify some pixels in replace mode" + # But not all pixels changed + assert combined.data.shape == (64, 64) diff --git a/tests/node_tests/laplace_interpolation.py b/tests/node_tests/laplace_interpolation.py new file mode 100644 index 0000000..ad34f5f --- /dev/null +++ b/tests/node_tests/laplace_interpolation.py @@ -0,0 +1,39 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import bool_to_mask + + +def test_laplace_fills_hole(): + from backend.nodes.laplace_interpolation import LaplaceInterpolation + + node = LaplaceInterpolation() + data = np.ones((32, 32)) * 5.0 + mask = np.zeros((32, 32), dtype=bool) + mask[10:20, 10:20] = True + data[mask] = 0.0 # hole + field = make_field(data=data) + result, = node.process(field, bool_to_mask(mask), 200) + # Filled region should be close to surrounding value of 5.0 + assert result.data[15, 15] > 3.0 + + +def test_laplace_no_mask_unchanged(): + from backend.nodes.laplace_interpolation import LaplaceInterpolation + + node = LaplaceInterpolation() + field = make_field(shape=(32, 32)) + mask = bool_to_mask(np.zeros((32, 32), dtype=bool)) + result, = node.process(field, mask, 100) + assert np.allclose(result.data, field.data) + + +def test_laplace_preserves_shape(): + from backend.nodes.laplace_interpolation import LaplaceInterpolation + + node = LaplaceInterpolation() + field = make_field(shape=(48, 64)) + mask = np.zeros((48, 64), dtype=bool) + mask[20:30, 20:40] = True + result, = node.process(field, bool_to_mask(mask), 50) + assert result.data.shape == (48, 64) diff --git a/tests/node_tests/level_grains.py b/tests/node_tests/level_grains.py new file mode 100644 index 0000000..18eef8f --- /dev/null +++ b/tests/node_tests/level_grains.py @@ -0,0 +1,46 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import bool_to_mask + + +def test_level_grains_equalizes(): + from backend.nodes.level_grains import LevelGrains + + node = LevelGrains() + data = np.zeros((32, 32)) + # Two grains at different heights + data[5:10, 5:10] = 3.0 + data[20:25, 20:25] = 7.0 + mask = np.zeros((32, 32), dtype=bool) + mask[5:10, 5:10] = True + mask[20:25, 20:25] = True + field = make_field(data=data) + result, = node.process(field, bool_to_mask(mask), "mean") + # After leveling, both grains should have similar mean heights + g1 = result.data[5:10, 5:10].mean() + g2 = result.data[20:25, 20:25].mean() + assert abs(g1 - g2) < 0.1 + + +def test_level_grains_no_grains(): + from backend.nodes.level_grains import LevelGrains + + node = LevelGrains() + field = make_field(shape=(32, 32)) + mask = bool_to_mask(np.zeros((32, 32), dtype=bool)) + result, = node.process(field, mask, "mean") + assert np.allclose(result.data, field.data) + + +def test_level_grains_median_reference(): + from backend.nodes.level_grains import LevelGrains + + node = LevelGrains() + data = np.zeros((32, 32)) + data[5:15, 5:15] = 2.0 + mask = np.zeros((32, 32), dtype=bool) + mask[5:15, 5:15] = True + field = make_field(data=data) + result, = node.process(field, bool_to_mask(mask), "median") + assert result.data.shape == (32, 32) diff --git a/tests/node_tests/median_background.py b/tests/node_tests/median_background.py new file mode 100644 index 0000000..601cae5 --- /dev/null +++ b/tests/node_tests/median_background.py @@ -0,0 +1,35 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_median_background_subtracted(): + from backend.nodes.median_background import MedianBackground + + node = MedianBackground() + # Tilted surface with features + yy, xx = np.mgrid[:64, :64] + data = 0.01 * xx + 0.02 * yy # tilt + field = make_field(data=data) + result, = node.process(field, 20, "subtracted") + assert result.data.shape == (64, 64) + # Background-subtracted should have near-zero mean + assert abs(result.data.mean()) < abs(data.mean()) + + +def test_median_background_output(): + from backend.nodes.median_background import MedianBackground + + node = MedianBackground() + field = make_field(shape=(32, 32)) + result, = node.process(field, 10, "background") + assert result.data.shape == (32, 32) + + +def test_median_background_flat_field(): + from backend.nodes.median_background import MedianBackground + + node = MedianBackground() + field = make_field(data=np.ones((32, 32)) * 3.0) + result, = node.process(field, 10, "subtracted") + assert np.allclose(result.data, 0.0, atol=1e-10) diff --git a/tests/node_tests/multi_profile.py b/tests/node_tests/multi_profile.py new file mode 100644 index 0000000..b56e916 --- /dev/null +++ b/tests/node_tests/multi_profile.py @@ -0,0 +1,33 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_horizontal_center(): + from backend.nodes.multi_profile import MultipleProfiles + + node = MultipleProfiles() + field = make_field(shape=(64, 128)) + (profile,) = node.process(field, field, row=-1, direction="horizontal", mode="overlay") + assert len(profile.data) == 128, f"Expected width 128, got {len(profile.data)}" + assert profile.x_axis is not None + assert len(profile.x_axis) == len(profile.data) + + +def test_difference_mode(): + from backend.nodes.multi_profile import MultipleProfiles + + node = MultipleProfiles() + data = np.random.default_rng(5).standard_normal((32, 32)) + field = make_field(data=data) + (profile,) = node.process(field, field, row=-1, direction="horizontal", mode="difference") + assert np.allclose(profile.data, 0.0), "Difference of same field should be zero" + + +def test_vertical_direction(): + from backend.nodes.multi_profile import MultipleProfiles + + node = MultipleProfiles() + field = make_field(shape=(80, 40)) + (profile,) = node.process(field, field, row=-1, direction="vertical", mode="overlay") + assert len(profile.data) == 80, f"Vertical profile length should be field height (80), got {len(profile.data)}" diff --git a/tests/node_tests/mutual_crop.py b/tests/node_tests/mutual_crop.py new file mode 100644 index 0000000..714a000 --- /dev/null +++ b/tests/node_tests/mutual_crop.py @@ -0,0 +1,38 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_same_image(): + from backend.nodes.mutual_crop import MutualCrop + + node = MutualCrop() + field = make_field() + cropped_a, cropped_b = node.process(field, field) + assert cropped_a.data.shape == cropped_b.data.shape + + +def test_output_shapes_match(): + from backend.nodes.mutual_crop import MutualCrop + + node = MutualCrop() + rng = np.random.default_rng(10) + field_a = make_field(data=rng.standard_normal((48, 64))) + field_b = make_field(data=rng.standard_normal((64, 48))) + cropped_a, cropped_b = node.process(field_a, field_b) + assert cropped_a.data.shape == cropped_b.data.shape, ( + f"Shapes should match: {cropped_a.data.shape} vs {cropped_b.data.shape}" + ) + + +def test_identical_fields(): + from backend.nodes.mutual_crop import MutualCrop + + node = MutualCrop() + data = np.random.default_rng(99).standard_normal((32, 32)) + field_a = make_field(data=data.copy()) + field_b = make_field(data=data.copy()) + cropped_a, cropped_b = node.process(field_a, field_b) + # Identical fields should be fully overlapping, so cropped output ~ original + assert cropped_a.data.shape == (32, 32) + assert np.allclose(cropped_a.data, data) diff --git a/tests/node_tests/outlier_mask.py b/tests/node_tests/outlier_mask.py new file mode 100644 index 0000000..b034781 --- /dev/null +++ b/tests/node_tests/outlier_mask.py @@ -0,0 +1,54 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field +from backend.nodes.helpers import mask_to_bool + + +def test_outlier_mask_detects_spikes(): + from backend.nodes.outlier_mask import OutlierMask + + node = OutlierMask() + data = np.zeros((64, 64)) + data[30, 30] = 100.0 # extreme spike + field = make_field(data=data) + mask, = node.process(field, 3.0, "both") + binary = mask_to_bool(mask) + assert binary[30, 30] # spike should be flagged + + +def test_outlier_mask_clean_field(): + from backend.nodes.outlier_mask import OutlierMask + + node = OutlierMask() + # Uniform field has no outliers + field = make_field(data=np.ones((32, 32)) * 5.0) + mask, = node.process(field, 3.0, "both") + assert mask_to_bool(mask).sum() == 0 + + +def test_outlier_mask_high_only(): + from backend.nodes.outlier_mask import OutlierMask + + node = OutlierMask() + data = np.zeros((64, 64)) + data[10, 10] = 100.0 # high spike + data[50, 50] = -100.0 # low spike + field = make_field(data=data) + mask, = node.process(field, 3.0, "high") + binary = mask_to_bool(mask) + assert binary[10, 10] + assert not binary[50, 50] + + +def test_outlier_mask_low_only(): + from backend.nodes.outlier_mask import OutlierMask + + node = OutlierMask() + data = np.zeros((64, 64)) + data[10, 10] = 100.0 + data[50, 50] = -100.0 + field = make_field(data=data) + mask, = node.process(field, 3.0, "low") + binary = mask_to_bool(mask) + assert not binary[10, 10] + assert binary[50, 50] diff --git a/tests/node_tests/perspective_correction.py b/tests/node_tests/perspective_correction.py new file mode 100644 index 0000000..3a1ac3c --- /dev/null +++ b/tests/node_tests/perspective_correction.py @@ -0,0 +1,53 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_identity(): + """All offsets zero should return output approximately equal to input.""" + from backend.nodes.perspective_correction import PerspectiveCorrection + + node = PerspectiveCorrection() + field = make_field(shape=(64, 64)) + result, = node.process( + field, + top_left_x=0.0, top_left_y=0.0, + top_right_x=0.0, top_right_y=0.0, + bottom_left_x=0.0, bottom_left_y=0.0, + bottom_right_x=0.0, bottom_right_y=0.0, + ) + assert result.data.shape == field.data.shape + assert np.allclose(result.data, field.data, atol=1e-10) + + +def test_nonzero_offset(): + """Non-zero offsets should change the data while preserving shape.""" + from backend.nodes.perspective_correction import PerspectiveCorrection + + node = PerspectiveCorrection() + field = make_field(shape=(64, 64)) + result, = node.process( + field, + top_left_x=0.05, top_left_y=0.05, + top_right_x=-0.05, top_right_y=0.05, + bottom_left_x=0.05, bottom_left_y=-0.05, + bottom_right_x=-0.05, bottom_right_y=-0.05, + ) + assert result.data.shape == field.data.shape + assert not np.allclose(result.data, field.data) + + +def test_output_shape(): + """Output shape must match input shape.""" + from backend.nodes.perspective_correction import PerspectiveCorrection + + node = PerspectiveCorrection() + field = make_field(shape=(48, 96)) + result, = node.process( + field, + top_left_x=0.1, top_left_y=0.0, + top_right_x=0.0, top_right_y=0.0, + bottom_left_x=0.0, bottom_left_y=0.0, + bottom_right_x=0.0, bottom_right_y=0.0, + ) + assert result.data.shape == (48, 96) diff --git a/tests/node_tests/pixel_binning.py b/tests/node_tests/pixel_binning.py new file mode 100644 index 0000000..539e148 --- /dev/null +++ b/tests/node_tests/pixel_binning.py @@ -0,0 +1,35 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_shape_reduction(): + """64x64 with bin_size=2 should produce 32x32.""" + from backend.nodes.pixel_binning import PixelBinning + + node = PixelBinning() + field = make_field(shape=(64, 64)) + result, = node.process(field, bin_size=2, method="mean") + assert result.data.shape == (32, 32) + + +def test_mean_uniform(): + """Uniform field of value 5.0 with mean binning should keep all values at 5.0.""" + from backend.nodes.pixel_binning import PixelBinning + + node = PixelBinning() + data = np.full((64, 64), 5.0, dtype=np.float64) + field = make_field(data=data) + result, = node.process(field, bin_size=2, method="mean") + assert np.allclose(result.data, 5.0) + + +def test_sum_doubles(): + """Uniform field of 1.0 with bin_size=2 and sum should give 4.0 everywhere.""" + from backend.nodes.pixel_binning import PixelBinning + + node = PixelBinning() + data = np.ones((64, 64), dtype=np.float64) + field = make_field(data=data) + result, = node.process(field, bin_size=2, method="sum") + assert np.allclose(result.data, 4.0) diff --git a/tests/node_tests/poly_distort.py b/tests/node_tests/poly_distort.py new file mode 100644 index 0000000..2ae00fe --- /dev/null +++ b/tests/node_tests/poly_distort.py @@ -0,0 +1,34 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_zero_coefficients(): + """All coefficients zero should return output approximately equal to input.""" + from backend.nodes.poly_distort import PolynomialDistortion + + node = PolynomialDistortion() + field = make_field(shape=(64, 64)) + result, = node.process(field, k1_x=0.0, k1_y=0.0, k2_x=0.0, k2_y=0.0, k3_x=0.0, k3_y=0.0) + assert np.allclose(result.data, field.data, atol=1e-10) + + +def test_nonzero_distortion(): + """Non-zero k1_x should preserve shape but change values.""" + from backend.nodes.poly_distort import PolynomialDistortion + + node = PolynomialDistortion() + field = make_field(shape=(64, 64)) + result, = node.process(field, k1_x=0.1, k1_y=0.0, k2_x=0.0, k2_y=0.0, k3_x=0.0, k3_y=0.0) + assert result.data.shape == field.data.shape + assert not np.allclose(result.data, field.data) + + +def test_shape_preserved(): + """Output shape must equal input shape.""" + from backend.nodes.poly_distort import PolynomialDistortion + + node = PolynomialDistortion() + field = make_field(shape=(32, 48)) + result, = node.process(field, k1_x=0.05, k1_y=0.05, k2_x=0.01, k2_y=0.01, k3_x=0.0, k3_y=0.0) + assert result.data.shape == (32, 48) diff --git a/tests/node_tests/psdf_log_polar.py b/tests/node_tests/psdf_log_polar.py new file mode 100644 index 0000000..d0bb091 --- /dev/null +++ b/tests/node_tests/psdf_log_polar.py @@ -0,0 +1,30 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_output_shape(): + from backend.nodes.psdf_log_polar import LogPolarPSDF + + node = LogPolarPSDF() + field = make_field() + (psdf,) = node.process(field, n_phi=90, n_r=50) + assert psdf.data.shape == (50, 90) + + +def test_nonnegative(): + from backend.nodes.psdf_log_polar import LogPolarPSDF + + node = LogPolarPSDF() + field = make_field() + (psdf,) = node.process(field, n_phi=180, n_r=100) + assert np.all(psdf.data >= 0), "log1p of power should be non-negative" + + +def test_domain(): + from backend.nodes.psdf_log_polar import LogPolarPSDF + + node = LogPolarPSDF() + field = make_field() + (psdf,) = node.process(field, n_phi=180, n_r=100) + assert psdf.domain == "frequency" diff --git a/tests/node_tests/rank_filter.py b/tests/node_tests/rank_filter.py new file mode 100644 index 0000000..73ba803 --- /dev/null +++ b/tests/node_tests/rank_filter.py @@ -0,0 +1,44 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_erosion_le_original(): + """Erosion (local minimum) values should be <= original at every pixel.""" + from backend.nodes.filter_rank import RankFilter + + node = RankFilter() + field = make_field(shape=(64, 64)) + result, = node.process(field, operation="erosion", radius=2, percentile=50.0) + assert np.all(result.data <= field.data + 1e-12) + + +def test_dilation_ge_original(): + """Dilation (local maximum) values should be >= original at every pixel.""" + from backend.nodes.filter_rank import RankFilter + + node = RankFilter() + field = make_field(shape=(64, 64)) + result, = node.process(field, operation="dilation", radius=2, percentile=50.0) + assert np.all(result.data >= field.data - 1e-12) + + +def test_median_shape(): + """Median output should have the same shape as input.""" + from backend.nodes.filter_rank import RankFilter + + node = RankFilter() + field = make_field(shape=(64, 64)) + result, = node.process(field, operation="median", radius=3, percentile=50.0) + assert result.data.shape == field.data.shape + + +def test_percentile_operation(): + """Percentile at 50.0 should approximate the median result.""" + from backend.nodes.filter_rank import RankFilter + + node = RankFilter() + field = make_field(shape=(64, 64)) + median_result, = node.process(field, operation="median", radius=2, percentile=50.0) + percentile_result, = node.process(field, operation="percentile", radius=2, percentile=50.0) + assert np.allclose(median_result.data, percentile_result.data, atol=1e-10) diff --git a/tests/node_tests/relate_fields.py b/tests/node_tests/relate_fields.py new file mode 100644 index 0000000..1dd3c67 --- /dev/null +++ b/tests/node_tests/relate_fields.py @@ -0,0 +1,54 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_linear_fit(): + from backend.nodes.relate_fields import RelateFields + + node = RelateFields() + rng = np.random.default_rng(42) + a_data = rng.uniform(0.5, 5.0, (32, 32)) + b_data = 2.0 * a_data + 1.0 + field_a = make_field(data=a_data) + field_b = make_field(data=b_data) + + predicted, records = node.process(field_a, field_b, function="linear") + + params = {r["quantity"]: r["value"] for r in records} + assert float(params["slope"]) == pytest.approx(2.0, abs=1e-6) + assert float(params["intercept"]) == pytest.approx(1.0, abs=1e-6) + assert float(params["R\u00b2"]) == pytest.approx(1.0, abs=1e-6) + + +def test_r_squared_reported(): + from backend.nodes.relate_fields import RelateFields + + node = RelateFields() + rng = np.random.default_rng(0) + field_a = make_field(data=rng.standard_normal((32, 32))) + field_b = make_field(data=rng.standard_normal((32, 32))) + + _, records = node.process(field_a, field_b, function="linear") + quantities = [r["quantity"] for r in records] + assert "R\u00b2" in quantities, f"Expected 'R\u00b2' in {quantities}" + + +def test_power_fit(): + from backend.nodes.relate_fields import RelateFields + + node = RelateFields() + rng = np.random.default_rng(99) + a_data = rng.uniform(1.0, 10.0, (32, 32)) + # b = 3.0 * a^2.0 + b_data = 3.0 * np.power(a_data, 2.0) + field_a = make_field(data=a_data) + field_b = make_field(data=b_data) + + predicted, records = node.process(field_a, field_b, function="power") + + params = {r["quantity"]: r["value"] for r in records} + assert "exponent" in params, f"Expected 'exponent' in {params}" + assert "coefficient" in params, f"Expected 'coefficient' in {params}" + assert float(params["exponent"]) == pytest.approx(2.0, abs=0.05) + assert float(params["coefficient"]) == pytest.approx(3.0, abs=0.1) diff --git a/tests/node_tests/scan_line_reorder.py b/tests/node_tests/scan_line_reorder.py new file mode 100644 index 0000000..aa9aab0 --- /dev/null +++ b/tests/node_tests/scan_line_reorder.py @@ -0,0 +1,54 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_reverse_odd_fixes_meander(): + from backend.nodes.scan_line_reorder import ScanLineReorder + + node = ScanLineReorder() + data = np.arange(32 * 32, dtype=np.float64).reshape(32, 32) + # Simulate meander: reverse odd rows + meander = data.copy() + meander[1::2, :] = meander[1::2, ::-1] + field = make_field(data=meander) + result, = node.process(field, "reverse_odd") + # Should restore original order + assert np.allclose(result.data, data) + + +def test_reverse_even(): + from backend.nodes.scan_line_reorder import ScanLineReorder + + node = ScanLineReorder() + field = make_field(shape=(32, 32)) + result, = node.process(field, "reverse_even") + assert result.data.shape == (32, 32) + + +def test_deinterlace_odd(): + from backend.nodes.scan_line_reorder import ScanLineReorder + + node = ScanLineReorder() + field = make_field(shape=(32, 32)) + result, = node.process(field, "deinterlace_odd") + assert result.data.shape[1] == 32 + + +def test_flip_vertical(): + from backend.nodes.scan_line_reorder import ScanLineReorder + + node = ScanLineReorder() + data = np.arange(16 * 16, dtype=np.float64).reshape(16, 16) + field = make_field(data=data) + result, = node.process(field, "flip_vertical") + assert np.array_equal(result.data, data[::-1, :]) + + +def test_unknown_operation(): + from backend.nodes.scan_line_reorder import ScanLineReorder + + node = ScanLineReorder() + field = make_field(shape=(16, 16)) + with pytest.raises(ValueError): + node.process(field, "unknown") diff --git a/tests/node_tests/shade.py b/tests/node_tests/shade.py new file mode 100644 index 0000000..7d4adca --- /dev/null +++ b/tests/node_tests/shade.py @@ -0,0 +1,36 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_output_shape(): + """Output shape should match input shape.""" + from backend.nodes.shade import Shade + + node = Shade() + field = make_field(shape=(64, 64)) + result, = node.process(field, azimuth=315.0, elevation=45.0, blend=0.5) + assert result.data.shape == field.data.shape + + +def test_output_range(): + """With blend=1.0, output values should be in [0, 1].""" + from backend.nodes.shade import Shade + + node = Shade() + field = make_field(shape=(64, 64)) + result, = node.process(field, azimuth=315.0, elevation=45.0, blend=1.0) + assert result.data.min() >= 0.0 - 1e-12 + assert result.data.max() <= 1.0 + 1e-12 + + +def test_flat_surface(): + """A flat (constant) surface should produce uniform-ish shading output.""" + from backend.nodes.shade import Shade + + node = Shade() + data = np.ones((64, 64), dtype=np.float64) * 5.0 + field = make_field(data=data) + result, = node.process(field, azimuth=315.0, elevation=45.0, blend=1.0) + # Flat surface -> all surface normals point straight up -> uniform shading + assert np.std(result.data) < 1e-10 diff --git a/tests/node_tests/straighten_path.py b/tests/node_tests/straighten_path.py new file mode 100644 index 0000000..1be386c --- /dev/null +++ b/tests/node_tests/straighten_path.py @@ -0,0 +1,37 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_basic_extraction(): + from backend.nodes.straighten_path import StraightenPath + + node = StraightenPath() + field = make_field(shape=(64, 64)) + (result,) = node.process(field, points_x="0.25, 0.5, 0.75", + points_y="0.5, 0.3, 0.5", + thickness=1, n_samples=256) + assert result.data.shape[1] == 256, f"Output width should be n_samples=256, got {result.data.shape[1]}" + + +def test_thickness(): + from backend.nodes.straighten_path import StraightenPath + + node = StraightenPath() + field = make_field(shape=(64, 64)) + (result,) = node.process(field, points_x="0.2, 0.8", + points_y="0.5, 0.5", + thickness=5, n_samples=100) + assert result.data.shape[0] == 5, f"Output height should be thickness=5, got {result.data.shape[0]}" + + +def test_single_point_returns_input(): + from backend.nodes.straighten_path import StraightenPath + + node = StraightenPath() + field = make_field(shape=(64, 64)) + (result,) = node.process(field, points_x="0.5", + points_y="0.5", + thickness=1, n_samples=100) + # With only 1 point, node returns the original field unchanged + assert np.array_equal(result.data, field.data) diff --git a/tests/node_tests/terrace_fit.py b/tests/node_tests/terrace_fit.py new file mode 100644 index 0000000..30e0843 --- /dev/null +++ b/tests/node_tests/terrace_fit.py @@ -0,0 +1,60 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_terrace_fit_stepped_surface(): + from backend.nodes.terrace_fit import TerraceFit + + node = TerraceFit() + # Create a surface with 3 clear terraces + data = np.zeros((64, 64)) + data[:20, :] = 0.0 + data[20:40, :] = 1.0 + data[40:, :] = 2.0 + field = make_field(data=data) + result, records = node.process(field, 3, 1.0, 0, "residual") + assert result.data.shape == (64, 64) + assert isinstance(records, list) + assert len(records) >= 3 # at least terrace heights + + +def test_terrace_fit_auto_detect(): + from backend.nodes.terrace_fit import TerraceFit + + node = TerraceFit() + data = np.zeros((64, 64)) + data[:32, :] = 0.0 + data[32:, :] = 5.0 + field = make_field(data=data) + result, records = node.process(field, 0, 1.0, 0, "fitted") + assert result.data.shape == (64, 64) + + +def test_terrace_fit_labels_output(): + from backend.nodes.terrace_fit import TerraceFit + + node = TerraceFit() + data = np.zeros((32, 32)) + data[:16, :] = 1.0 + data[16:, :] = 3.0 + field = make_field(data=data) + result, records = node.process(field, 2, 1.0, 0, "labels") + assert result.data.shape == (32, 32) + # Labels should have exactly 2 distinct values + unique = np.unique(result.data) + assert len(unique) == 2 + + +def test_terrace_fit_step_heights_reported(): + from backend.nodes.terrace_fit import TerraceFit + + node = TerraceFit() + data = np.zeros((64, 64)) + data[:32, :] = 0.0 + data[32:, :] = 2.5 + field = make_field(data=data) + _, records = node.process(field, 2, 1.0, 0, "residual") + # Should report step height + step_records = [r for r in records if "Step" in r["quantity"]] + assert len(step_records) >= 1 diff --git a/tests/node_tests/tilt.py b/tests/node_tests/tilt.py new file mode 100644 index 0000000..305be79 --- /dev/null +++ b/tests/node_tests/tilt.py @@ -0,0 +1,34 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_add_subtract_roundtrip(): + """Adding then subtracting the same tilt should recover the original.""" + from backend.nodes.tilt import Tilt + + node = Tilt() + field = make_field(shape=(64, 64)) + tilted, = node.process(field, slope_x=1000.0, slope_y=500.0, mode="add") + recovered, = node.process(tilted, slope_x=1000.0, slope_y=500.0, mode="subtract") + assert np.allclose(recovered.data, field.data, atol=1e-10) + + +def test_add_tilt_changes_data(): + """Adding a non-zero tilt should change the data.""" + from backend.nodes.tilt import Tilt + + node = Tilt() + field = make_field(shape=(64, 64)) + result, = node.process(field, slope_x=1000.0, slope_y=0.0, mode="add") + assert not np.allclose(result.data, field.data) + + +def test_zero_slope(): + """Zero slopes in add mode should leave data unchanged.""" + from backend.nodes.tilt import Tilt + + node = Tilt() + field = make_field(shape=(64, 64)) + result, = node.process(field, slope_x=0.0, slope_y=0.0, mode="add") + assert np.allclose(result.data, field.data, atol=1e-10) diff --git a/tests/node_tests/trimmed_mean.py b/tests/node_tests/trimmed_mean.py new file mode 100644 index 0000000..4ae57e8 --- /dev/null +++ b/tests/node_tests/trimmed_mean.py @@ -0,0 +1,37 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_uniform_field(): + """Uniform field should remain approximately the same after filtering.""" + from backend.nodes.trimmed_mean import TrimmedMean + + node = TrimmedMean() + data = np.full((16, 16), 3.0, dtype=np.float64) + field = make_field(data=data) + result, = node.process(field, radius=2, trim_fraction=0.1) + assert np.allclose(result.data, 3.0, atol=1e-10) + + +def test_shape_preserved(): + """Output shape should match input shape.""" + from backend.nodes.trimmed_mean import TrimmedMean + + node = TrimmedMean() + field = make_field(shape=(16, 16)) + result, = node.process(field, radius=2, trim_fraction=0.1) + assert result.data.shape == (16, 16) + + +def test_reduces_outliers(): + """A spike in the field should be reduced by the trimmed mean filter.""" + from backend.nodes.trimmed_mean import TrimmedMean + + node = TrimmedMean() + data = np.zeros((16, 16), dtype=np.float64) + data[8, 8] = 100.0 # large spike + field = make_field(data=data) + result, = node.process(field, radius=2, trim_fraction=0.1) + # The spike should be significantly reduced + assert result.data[8, 8] < 50.0 diff --git a/tests/node_tests/wrap_value.py b/tests/node_tests/wrap_value.py new file mode 100644 index 0000000..fcf40f8 --- /dev/null +++ b/tests/node_tests/wrap_value.py @@ -0,0 +1,36 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_wrap_degrees(): + """Value 400.0 wrapped to 0..360 should give 40.0.""" + from backend.nodes.wrap_value import WrapValue + + node = WrapValue() + data = np.array([[400.0]], dtype=np.float64) + field = make_field(data=data) + result, = node.process(field, range="0_to_360", custom_min=0.0, custom_max=360.0) + assert np.isclose(result.data[0, 0], 40.0) + + +def test_wrap_negative(): + """Value -90.0 wrapped to 0..360 should give 270.0.""" + from backend.nodes.wrap_value import WrapValue + + node = WrapValue() + data = np.array([[-90.0]], dtype=np.float64) + field = make_field(data=data) + result, = node.process(field, range="0_to_360", custom_min=0.0, custom_max=360.0) + assert np.isclose(result.data[0, 0], 270.0) + + +def test_custom_range(): + """Value 250 with custom range 0..100 should wrap to 50.""" + from backend.nodes.wrap_value import WrapValue + + node = WrapValue() + data = np.array([[250.0]], dtype=np.float64) + field = make_field(data=data) + result, = node.process(field, range="custom", custom_min=0.0, custom_max=100.0) + assert np.isclose(result.data[0, 0], 50.0) diff --git a/tests/node_tests/zero_crossing.py b/tests/node_tests/zero_crossing.py new file mode 100644 index 0000000..7b79e1e --- /dev/null +++ b/tests/node_tests/zero_crossing.py @@ -0,0 +1,37 @@ +import numpy as np +import pytest +from tests.node_tests._shared import make_field + + +def test_output_binary(): + from backend.nodes.zero_crossing import ZeroCrossing + + node = ZeroCrossing() + field = make_field() + (edges,) = node.process(field, sigma=2.0, threshold=0.0) + unique = set(np.unique(edges.data)) + assert unique <= {0.0, 1.0}, f"Expected only 0.0/1.0, got {unique}" + + +def test_detects_step_edge(): + from backend.nodes.zero_crossing import ZeroCrossing + + node = ZeroCrossing() + data = np.zeros((64, 64)) + data[:, 32:] = 1.0 + field = make_field(data=data) + (edges,) = node.process(field, sigma=2.0, threshold=0.0) + + # Edge energy should concentrate near column 32 + col_energy = edges.data.sum(axis=0) + peak_col = np.argmax(col_energy) + assert abs(peak_col - 32) <= 3, f"Peak at col {peak_col}, expected ~32" + + +def test_shape_preserved(): + from backend.nodes.zero_crossing import ZeroCrossing + + node = ZeroCrossing() + field = make_field(shape=(48, 96)) + (edges,) = node.process(field, sigma=1.5, threshold=0.1) + assert edges.data.shape == (48, 96)