adding more nodes
This commit is contained in:
@@ -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 |
|
All features from the original gap analysis are implemented:
|
||||||
|---|---------|---------------|-------------|
|
|
||||||
| ~~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** |
|
|
||||||
|
|
||||||
## Medium Value
|
| # | Feature | Gwyddion Source | tono Node |
|
||||||
|
|---|---------|---------------|-----------|
|
||||||
| # | Feature | Gwyddion Source | Description |
|
| 1 | Line Correction | linecorrect.c, linematch.c | LineCorrection |
|
||||||
|---|---------|---------------|-------------|
|
| 2 | Scar Removal | scars.c | ScarRemoval |
|
||||||
| ~~15~~ | ~~Correlation / Pattern Matching~~ | ~~crosscor.c, maskcor.c~~ | ~~Find repeated features or align images via cross-correlation.~~ **DONE** |
|
| 3 | Facet Leveling | facet-level.c | FacetLevelField |
|
||||||
| ~~16~~ | ~~Slope Distribution~~ | ~~slope_dist.c~~ | ~~Angular histogram of surface slopes. Characterizes surface texture directionality.~~ **DONE** |
|
| 4 | Morphological Mask Ops | mask_morph.c | MaskMorphology |
|
||||||
| ~~17~~ | ~~Grain Filtering~~ | ~~grain_filter.c~~ | ~~Remove grains by size, height, or border contact. Refine grain masks post-detection.~~ **DONE** |
|
| 5 | 1D FFT Filter | fft_filter_1d.c | FFTFilter |
|
||||||
| ~~18~~ | ~~Field Arithmetic~~ | ~~arithmetic.c~~ | ~~Add/subtract/multiply/divide two DATA_FIELDs. Useful for difference maps, normalization.~~ **DONE** |
|
| 6 | 2D FFT Filter | fft_filter_2d.c | FFTFilter |
|
||||||
| ~~19~~ | ~~Spot Removal~~ | ~~spotremove.c~~ | ~~Interpolate over selected point defects (dust, spikes).~~ **DONE** |
|
| 7 | Autocorrelation (ACF) | acf2d.c | ACF2D |
|
||||||
| ~~20~~ | ~~Tip Modeling / Deconvolution~~ | ~~tip_blind.c, tip_model.c~~ | ~~Estimate tip shape from image, deconvolve to recover true surface.~~ **DONE** |
|
| 8 | PSDF | psdf2d.c | PSDF |
|
||||||
| ~~21~~ | ~~Radial Profile~~ | ~~rprofile tool~~ | ~~Azimuthally averaged profile from a center point. Good for circular features.~~ **DONE** |
|
| 9 | Fractal Dimension | fractal.c | FractalDimension |
|
||||||
| ~~22~~ | ~~Wavelet Transform~~ | ~~dwt.c, cwt.c~~ | ~~Discrete/continuous wavelet analysis. Multi-scale roughness decomposition.~~ **DONE** |
|
| 10 | Curvature | curvature.c | Curvature |
|
||||||
| ~~23~~ | ~~Scale / Resample~~ | ~~scale.c, resample.c~~ | ~~Resize fields with interpolation.~~ **DONE** |
|
| 11 | Grain Distance Transform | mask_edt.c | GrainDistanceTransform |
|
||||||
| ~~24~~ | ~~Gradient~~ | ~~gradient.c~~ | ~~Compute x/y gradient magnitude maps.~~ **DONE** |
|
| 12 | Watershed Segmentation | grain_wshed.c | WatershedSegmentation |
|
||||||
| ~~25~~ | ~~Custom Convolution~~ | ~~convolution_filter.c~~ | ~~User-defined kernel convolution.~~ **DONE** |
|
| 13 | Rotate / Flip | rotate.c, basicops.c | RotateField, FlipField |
|
||||||
| ~~26~~ | ~~Local Contrast Enhancement~~ | ~~local_contrast.c~~ | ~~Enhance visibility of local features in images.~~ **DONE** |
|
| 14 | Crop | crop.c | CropResizeField |
|
||||||
|
| 15 | Correlation / Pattern Matching | crosscor.c, maskcor.c | CrossCorrelate, TemplateMatch |
|
||||||
## Lower Priority
|
| 16 | Slope Distribution | slope_dist.c | SlopeDistribution |
|
||||||
|
| 17 | Grain Filtering | grain_filter.c | GrainFilter |
|
||||||
| # | Feature | Gwyddion Source | Description |
|
| 18 | Field Arithmetic | arithmetic.c | FieldArithmetic |
|
||||||
|---|---------|---------------|-------------|
|
| 19 | Spot Removal | spotremove.c | SpotRemoval |
|
||||||
| 27 | Drift Correction | drift.c | Compensate for thermal/piezo drift between scan lines. |
|
| 20 | Tip Modeling / Deconvolution | tip_blind.c, tip_model.c | TipModel, TipDeconvolution, BlindTipEstimate |
|
||||||
| 28 | Affine / Perspective Correction | correct_affine.c, correct_perspective.c | Fix geometric distortions from scanner nonlinearity. |
|
| 21 | Radial Profile | rprofile tool | RadialProfile |
|
||||||
| 29 | MFM Analysis | mfm_*.c | Magnetic force microscopy: field calculation, shift finding. |
|
| 22 | Wavelet Transform | dwt.c, cwt.c | WaveletDenoise |
|
||||||
| 30 | Lattice Measurement | measure_lattice.c | Detect and measure periodic lattice structures from ACF/FFT. |
|
| 23 | Scale / Resample | scale.c, resample.c | Resample |
|
||||||
| 31 | Hough Transform | hough.c | Detect lines and circles in images. |
|
| 24 | Gradient | gradient.c | Gradient |
|
||||||
| 32 | Image Stitching / Merging | merge.c, stitch.c | Combine multiple overlapping scans into one image. |
|
| 25 | Custom Convolution | convolution_filter.c | CustomConvolution |
|
||||||
| 33 | Facet Analysis | facet_analysis.c | Orientation distribution of surface facets (stereographic projection). |
|
| 26 | Local Contrast Enhancement | local_contrast.c | LocalContrast |
|
||||||
| 34 | Shape Fitting | fit-shape.c | Fit geometric primitives: sphere, paraboloid, cylinder, etc. |
|
| 27 | Drift Correction | drift.c | DriftCorrection |
|
||||||
| 35 | Synthetic Surface Generation | *_synth.c (~20 modules) | Generate test surfaces: FBM, noise, lattice, waves, particles, fibers, etc. |
|
| 28 | Affine Correction | correct_affine.c | AffineCorrection |
|
||||||
| ~~36~~ | ~~Entropy~~ | ~~entropy.c~~ | ~~Information entropy of height distribution.~~ **DONE** |
|
| 29 | MFM Analysis | mfm_*.c | MFMAnalysis |
|
||||||
| 37 | Indentation Analysis | indent_analyze.c, hertz.c | Nanoindentation curve fitting (Hertz model). |
|
| 30 | Lattice Measurement | measure_lattice.c | LatticeMeasurement |
|
||||||
| 38 | Deconvolution | deconvolve.c | Blind/regularized deconvolution for image restoration. |
|
| 31 | Hough Transform | hough.c | HoughTransform |
|
||||||
| 39 | Canny / Harris Detection | filters.c | Corner and edge feature detection beyond basic Sobel/Prewitt. |
|
| 32 | Image Stitching | merge.c, stitch.c | ImageStitch |
|
||||||
| ~~40~~ | ~~Kuwahara Filter~~ | ~~filters.c~~ | ~~Edge-preserving smoothing filter.~~ **DONE** |
|
| 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 |
|
| # | Feature | Gwyddion Source | tono Node | Status |
|
||||||
|--------------|----------|-------------------|
|
|---|---------|---------------|-----------|--------|
|
||||||
| Load Image / Load SPM File | io | File import (gwy, sxm, ibw) |
|
| 41 | Terrace Fitting | terracefit.c | TerraceFit | **DONE** |
|
||||||
| Save Image | io | File export |
|
| 42 | Laplace Interpolation | laplace.c | LaplaceInterpolation | **DONE** |
|
||||||
| Coordinate | io | — |
|
| 43 | Fractal Interpolation | fraccor.c | FractalInterpolation | **DONE** |
|
||||||
| Rotate Field | modify | rotate.c |
|
| 44 | Median Background Subtraction | median-bg.c | MedianBackground | **DONE** |
|
||||||
| Flip Field | modify | basicops.c |
|
| 45 | Flatten Base | flatten_base.c | FlattenBase | **DONE** |
|
||||||
| Plane Level | level | level.c |
|
| 46 | Level Individual Grains | level_grains.c | LevelGrains | **DONE** |
|
||||||
| Facet Level | level | facet-level.c |
|
| 47 | Grain Marking by Criteria | grain_mark.c | GrainMark | **DONE** |
|
||||||
| Polynomial Level | level | polylevel.c |
|
| 48 | Grain Property Distributions | grain_dist.c | GrainDistributions | **DONE** |
|
||||||
| Fix Zero | level | level.c (fix_zero) |
|
| 49 | Grain Summary Statistics | grain_summary.c | GrainSummary | **DONE** |
|
||||||
| Line Correction | level | linecorrect.c, linematch.c |
|
| 50 | Outlier Masking | outliers.c | OutlierMask | **DONE** |
|
||||||
| Gaussian Filter | filters | filters.c (gaussian) |
|
| 51 | Scan Line Reordering | reorder.c | ScanLineReorder | **DONE** |
|
||||||
| Median Filter | filters | filters.c (median) |
|
|
||||||
| Edge Detect | filters | edge.c (sobel, prewitt, laplacian, LoG) |
|
### Medium Value — Analysis and correction
|
||||||
| 1D FFT Filter | filters | fft_filter_1d.c (lowpass, highpass, bandpass, notch) |
|
|
||||||
| 2D FFT Filter | filters | fft_filter_2d.c (lowpass, highpass, bandpass, notch) |
|
| # | Feature | Gwyddion Source | tono Node | Status |
|
||||||
| Scar Removal | filters | scars.c |
|
|---|---------|---------------|-----------|--------|
|
||||||
| Statistics | analysis | stats.c |
|
| 52 | Perspective Correction | correct_perspective.c | PerspectiveCorrection | **DONE** |
|
||||||
| Curvature | analysis | curvature.c |
|
| 53 | Polynomial Distortion | polydistort.c | PolynomialDistortion | **DONE** |
|
||||||
| Fractal Dimension | analysis | fractal.c |
|
| 54 | Frequency Splitting | freq_split.c | FrequencySplit | **DONE** |
|
||||||
| Height Histogram | analysis | linestats.c (dh) |
|
| 55 | Phase/Value Wrapping | wrapvalue.c | WrapValue | **DONE** |
|
||||||
| 2D FFT | analysis | fft.c |
|
| 56 | Shaded Presentation | shade.c | Shade | **DONE** |
|
||||||
| Cross Section | analysis | profile tool |
|
| 57 | Pixel Binning | binning.c | PixelBinning | **DONE** |
|
||||||
| Profile Roughness | analysis | roughness.c (Ra, Rq, Rsk, Rku, Rp, Rv, Rt) |
|
| 58 | Extend / Pad | extend.c | ExtendPad | **DONE** |
|
||||||
| Line Math | analysis | linestats.c |
|
| 59 | Tilt | tilt.c | Tilt | **DONE** |
|
||||||
| Threshold Mask | mask | threshold.c, otsu_threshold.c |
|
| 60 | Trimmed Mean Filter | trimmed-mean.c | TrimmedMean | **DONE** |
|
||||||
| Mask Morphology | mask | mask_morph.c (erode, dilate, open, close) |
|
| 61 | Rank Filter | rank-filter.c | RankFilter | **DONE** |
|
||||||
| Mask Invert | mask | — |
|
| 62 | Zero Crossing Detection | zero_crossing.c | ZeroCrossing | **DONE** |
|
||||||
| Mask Operations | mask | — (boolean logic on two masks: AND, OR, XOR, NAND, NOR, XNOR, implication, etc.) |
|
| 63 | Log-Polar PSDF | psdf_logphi.c | LogPolarPSDF | **DONE** |
|
||||||
| Grain Distance Transform | mask | mask_edt.c |
|
| 64 | Grain Edge Detection | grain_edge.c | GrainEdge | **DONE** |
|
||||||
| Watershed Segmentation | grains | grain_wshed.c |
|
| 65 | Grain Cross-Correlation | grain_cross.c | GrainCross | **DONE** |
|
||||||
| Grain Analysis | grains | grain_stat.c |
|
| 66 | Mutual Crop | mcrop.c | MutualCrop | **DONE** |
|
||||||
| Preview / 3D View / Print Table | display | Presentation, 3D view |
|
| 67 | Immerse Detail | immerse.c | ImmerseDetail | **DONE** |
|
||||||
| Tip Model | tip | tip_model.c, tip.c |
|
| 68 | Multiple Profiles | multiprofile.c | MultipleProfiles | **DONE** |
|
||||||
| Tip Deconvolution | tip | tip_blind.c, tip.c (gwy_tip_erosion) |
|
| 69 | Straighten Path | straighten_path.c | StraightenPath | **DONE** |
|
||||||
| Blind Tip Estimate | tip | tip_blind.c, morph_lib.c (gwy_tip_estimate_partial/full + gwy_tip_cmap) |
|
| 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.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"PrintTable",
|
"PrintTable",
|
||||||
"Save",
|
"Save",
|
||||||
"SaveImage",
|
"SaveImage",
|
||||||
|
"Shade",
|
||||||
],
|
],
|
||||||
"Overlay": [
|
"Overlay": [
|
||||||
"Markup",
|
"Markup",
|
||||||
@@ -47,7 +48,13 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"FlipField",
|
"FlipField",
|
||||||
"Resample",
|
"Resample",
|
||||||
"AffineCorrection",
|
"AffineCorrection",
|
||||||
|
"PerspectiveCorrection",
|
||||||
|
"PolynomialDistortion",
|
||||||
"ImageStitch",
|
"ImageStitch",
|
||||||
|
"MutualCrop",
|
||||||
|
"ImmerseDetail",
|
||||||
|
"PixelBinning",
|
||||||
|
"ExtendPad",
|
||||||
"FieldArithmetic",
|
"FieldArithmetic",
|
||||||
],
|
],
|
||||||
"Level & Correct": [
|
"Level & Correct": [
|
||||||
@@ -55,10 +62,16 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"PlaneLevelField",
|
"PlaneLevelField",
|
||||||
"PolyLevelField",
|
"PolyLevelField",
|
||||||
"FacetLevelField",
|
"FacetLevelField",
|
||||||
|
"FlattenBase",
|
||||||
"LineCorrection",
|
"LineCorrection",
|
||||||
"DriftCorrection",
|
"DriftCorrection",
|
||||||
"ScarRemoval",
|
"ScarRemoval",
|
||||||
"SpotRemoval",
|
"SpotRemoval",
|
||||||
|
"LaplaceInterpolation",
|
||||||
|
"FractalInterpolation",
|
||||||
|
"ScanLineReorder",
|
||||||
|
"Tilt",
|
||||||
|
"WrapValue",
|
||||||
],
|
],
|
||||||
"Filter": [
|
"Filter": [
|
||||||
"GaussianFilter",
|
"GaussianFilter",
|
||||||
@@ -68,6 +81,9 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"LocalContrast",
|
"LocalContrast",
|
||||||
"CustomConvolution",
|
"CustomConvolution",
|
||||||
"Deconvolution",
|
"Deconvolution",
|
||||||
|
"MedianBackground",
|
||||||
|
"TrimmedMean",
|
||||||
|
"RankFilter",
|
||||||
"Gradient",
|
"Gradient",
|
||||||
"EdgeDetect",
|
"EdgeDetect",
|
||||||
],
|
],
|
||||||
@@ -79,6 +95,8 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"ACF2D",
|
"ACF2D",
|
||||||
"ACF1D",
|
"ACF1D",
|
||||||
"PSDF",
|
"PSDF",
|
||||||
|
"LogPolarPSDF",
|
||||||
|
"FrequencySplit",
|
||||||
"CrossCorrelate",
|
"CrossCorrelate",
|
||||||
],
|
],
|
||||||
"Measure": [
|
"Measure": [
|
||||||
@@ -88,12 +106,16 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"Stats",
|
"Stats",
|
||||||
"Curvature",
|
"Curvature",
|
||||||
"ShapeFitting",
|
"ShapeFitting",
|
||||||
|
"TerraceFit",
|
||||||
"FractalDimension",
|
"FractalDimension",
|
||||||
"Entropy",
|
"Entropy",
|
||||||
"SlopeDistribution",
|
"SlopeDistribution",
|
||||||
"RadialProfile",
|
"RadialProfile",
|
||||||
"LatticeMeasurement",
|
"LatticeMeasurement",
|
||||||
"AngleMeasure",
|
"AngleMeasure",
|
||||||
|
"MultipleProfiles",
|
||||||
|
"StraightenPath",
|
||||||
|
"RelateFields",
|
||||||
],
|
],
|
||||||
"Detect": [
|
"Detect": [
|
||||||
"FeatureDetection",
|
"FeatureDetection",
|
||||||
@@ -101,10 +123,13 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"TemplateMatch",
|
"TemplateMatch",
|
||||||
"FacetAnalysis",
|
"FacetAnalysis",
|
||||||
"MFMAnalysis",
|
"MFMAnalysis",
|
||||||
|
"ZeroCrossing",
|
||||||
],
|
],
|
||||||
"Mask": [
|
"Mask": [
|
||||||
"DrawMask",
|
"DrawMask",
|
||||||
"ThresholdMask",
|
"ThresholdMask",
|
||||||
|
"GrainMark",
|
||||||
|
"OutlierMask",
|
||||||
"MaskMorphology",
|
"MaskMorphology",
|
||||||
"MaskInvert",
|
"MaskInvert",
|
||||||
"MaskOperations",
|
"MaskOperations",
|
||||||
@@ -114,6 +139,11 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"WatershedSegmentation",
|
"WatershedSegmentation",
|
||||||
"GrainAnalysis",
|
"GrainAnalysis",
|
||||||
"GrainFilter",
|
"GrainFilter",
|
||||||
|
"GrainDistributions",
|
||||||
|
"GrainSummary",
|
||||||
|
"LevelGrains",
|
||||||
|
"GrainEdge",
|
||||||
|
"GrainCross",
|
||||||
],
|
],
|
||||||
"Tip": [
|
"Tip": [
|
||||||
"TipModel",
|
"TipModel",
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class AffineCorrection:
|
|||||||
"Apply an affine correction to fix geometric distortions from scanner "
|
"Apply an affine correction to fix geometric distortions from scanner "
|
||||||
"nonlinearity. Parameters specify shear, scale, and rotation corrections. "
|
"nonlinearity. Parameters specify shear, scale, and rotation corrections. "
|
||||||
"The transform is applied about the centre of the field. "
|
"The transform is applied about the centre of the field. "
|
||||||
"Equivalent to Gwyddion's correct_affine.c module."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class CrossCorrelate:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Compute 2D cross-correlation between two fields. The correlation peak indicates "
|
"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 "
|
"the offset where the two fields best match. Useful for drift measurement and feature "
|
||||||
"alignment. Equivalent to Gwyddion crosscor.c."
|
"alignment."
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ class CrossSection:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Extract a cross-section profile along a line between two points. "
|
"Extract a cross-section profile along a line between two points. "
|
||||||
"Drag the markers on the image to set the line endpoints. "
|
"Drag the markers on the image to set the line endpoints. "
|
||||||
"Equivalent to gwy_data_field_get_profile."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class Deconvolution:
|
|||||||
"blurred by a Gaussian PSF with the given sigma (in pixels). "
|
"blurred by a Gaussian PSF with the given sigma (in pixels). "
|
||||||
"Wiener filtering is fast and works in one pass. "
|
"Wiener filtering is fast and works in one pass. "
|
||||||
"Richardson-Lucy is iterative and preserves positivity. "
|
"Richardson-Lucy is iterative and preserves positivity. "
|
||||||
"Equivalent to Gwyddion's deconvolve.c module."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ class DriftCorrection:
|
|||||||
"Compensate for thermal or piezo drift between scan lines. "
|
"Compensate for thermal or piezo drift between scan lines. "
|
||||||
"Cross-correlates each row (or column) against a reference to estimate "
|
"Cross-correlates each row (or column) against a reference to estimate "
|
||||||
"the drift offset, then shifts lines to correct. "
|
"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:
|
def process(self, field: DataField, reference: str, direction: str) -> tuple:
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class EdgeDetect:
|
|||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Detect edges using Sobel, Prewitt, Laplacian, or LoG operators. "
|
"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:
|
def process(self, field: DataField, method: str, sigma: float) -> tuple:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class Entropy:
|
|||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Shannon entropy of the height or slope distribution. "
|
"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:
|
def process(self, field: DataField, mode: str, n_bins: int) -> tuple:
|
||||||
|
|||||||
64
backend/nodes/extend_pad.py
Normal file
64
backend/nodes/extend_pad.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Extend / pad — add borders to a field with various fill methods."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Extend / Pad")
|
||||||
|
class ExtendPad:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"top": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
|
||||||
|
"bottom": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
|
||||||
|
"left": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
|
||||||
|
"right": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
|
||||||
|
"method": (["mean", "edge", "mirror", "periodic", "zero"],
|
||||||
|
{"default": "mirror"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'padded'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Add borders to a field with configurable padding method. "
|
||||||
|
"Mirror and periodic modes avoid edge discontinuities for FFT. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, top: int, bottom: int,
|
||||||
|
left: int, right: int, method: str) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
|
||||||
|
mode_map = {
|
||||||
|
"mean": "constant",
|
||||||
|
"edge": "edge",
|
||||||
|
"mirror": "reflect",
|
||||||
|
"periodic": "wrap",
|
||||||
|
"zero": "constant",
|
||||||
|
}
|
||||||
|
np_mode = mode_map.get(method, "constant")
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
if method == "mean":
|
||||||
|
kwargs["constant_values"] = data.mean()
|
||||||
|
elif method == "zero":
|
||||||
|
kwargs["constant_values"] = 0.0
|
||||||
|
|
||||||
|
result = np.pad(data, ((top, bottom), (left, right)), mode=np_mode, **kwargs)
|
||||||
|
|
||||||
|
new_xreal = result.shape[1] * field.dx
|
||||||
|
new_yreal = result.shape[0] * field.dy
|
||||||
|
new_xoff = field.xoff - left * field.dx
|
||||||
|
new_yoff = field.yoff - top * field.dy
|
||||||
|
|
||||||
|
return (field.replace(data=result, xreal=new_xreal, yreal=new_yreal,
|
||||||
|
xoff=new_xoff, yoff=new_yoff),)
|
||||||
@@ -30,7 +30,6 @@ class FacetAnalysis:
|
|||||||
"Outputs a 2D histogram (stereographic projection) where the x-axis "
|
"Outputs a 2D histogram (stereographic projection) where the x-axis "
|
||||||
"is the azimuthal angle (phi) and y-axis is the inclination (theta). "
|
"is the azimuthal angle (phi) and y-axis is the inclination (theta). "
|
||||||
"Intensity represents how much surface area faces each orientation. "
|
"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:
|
def process(self, field: DataField, n_bins: int, kernel_size: int) -> tuple:
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class FeatureDetection:
|
|||||||
"Canny: multi-stage edge detector with hysteresis thresholding. "
|
"Canny: multi-stage edge detector with hysteresis thresholding. "
|
||||||
"Harris: corner/interest point detector based on structure tensor. "
|
"Harris: corner/interest point detector based on structure tensor. "
|
||||||
"Outputs a feature map and a table of detected feature locations. "
|
"Outputs a feature map and a table of detected feature locations. "
|
||||||
"Equivalent to Gwyddion's edge/corner detection in filters.c."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class FFT2D:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Compute the 2D FFT with optional windowing and mean/plane subtraction. "
|
"Compute the 2D FFT with optional windowing and mean/plane subtraction. "
|
||||||
"Outputs log magnitude, magnitude, phase, and PSDF as separate channels. "
|
"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:
|
def process(self, field: DataField, windowing: str, level: str) -> tuple:
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ class FieldArithmetic:
|
|||||||
"Apply a point-wise arithmetic operation to two DATA_FIELDs of the same resolution. "
|
"Apply a point-wise arithmetic operation to two DATA_FIELDs of the same resolution. "
|
||||||
"add/subtract/multiply/divide/min/max perform element-wise operations; "
|
"add/subtract/multiply/divide/min/max perform element-wise operations; "
|
||||||
"hypot computes sqrt(a² + b²) per pixel. "
|
"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:
|
def process(self, field_a: DataField, field_b: DataField, operation: str) -> tuple:
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ class CustomConvolution:
|
|||||||
"Apply a user-defined convolution kernel. "
|
"Apply a user-defined convolution kernel. "
|
||||||
"Enter rows of space-separated numbers. "
|
"Enter rows of space-separated numbers. "
|
||||||
"Example sharpen: '0 -1 0 / -1 5 -1 / 0 -1 0' (use newlines, not slashes). "
|
"Example sharpen: '0 -1 0 / -1 5 -1 / 0 -1 0' (use newlines, not slashes). "
|
||||||
"Equivalent to Gwyddion convolution_filter.c."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class FFTFilter:
|
|||||||
|
|
||||||
Accepts either a LINE or DATA_FIELD and returns a filtered output of the
|
Accepts either a LINE or DATA_FIELD and returns a filtered output of the
|
||||||
same type. Uses a Butterworth transfer function with configurable order
|
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
|
@classmethod
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class GaussianFilter:
|
|||||||
)
|
)
|
||||||
FUNCTION = "process"
|
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:
|
def process(self, field: DataField, sigma: float) -> tuple:
|
||||||
from scipy.ndimage import gaussian_filter
|
from scipy.ndimage import gaussian_filter
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ class KuwaharaFilter:
|
|||||||
FUNCTION = "process"
|
FUNCTION = "process"
|
||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Edge-preserving smoothing using Kuwahara's minimum-variance quadrant method. "
|
"""
|
||||||
"Unlike Gaussian blur, sharp boundaries are preserved. "
|
Edge-preserving smoothing using Kuwahara's minimum-variance quadrant method.
|
||||||
"Equivalent to Gwyddion's Kuwahara filter."
|
"Unlike Gaussian blur, sharp boundaries are preserved.
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(self, field: DataField, iterations: int) -> tuple:
|
def process(self, field: DataField, iterations: int) -> tuple:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class MedianFilter:
|
|||||||
)
|
)
|
||||||
FUNCTION = "process"
|
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:
|
def process(self, field: DataField, size: int) -> tuple:
|
||||||
from scipy.ndimage import median_filter
|
from scipy.ndimage import median_filter
|
||||||
|
|||||||
53
backend/nodes/filter_rank.py
Normal file
53
backend/nodes/filter_rank.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Rank filter — general k-th rank filter for morphological operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import percentile_filter, minimum_filter, maximum_filter, median_filter
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Rank Filter")
|
||||||
|
class RankFilter:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"operation": (["erosion", "dilation", "median", "percentile"],
|
||||||
|
{"default": "median"}),
|
||||||
|
"radius": ("INT", {"default": 3, "min": 1, "max": 50, "step": 1}),
|
||||||
|
"percentile": ("FLOAT", {"default": 50.0, "min": 0.0, "max": 100.0, "step": 1.0}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'filtered'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Apply rank-based morphological filtering. Erosion selects the local "
|
||||||
|
"minimum (shrinks features), dilation the local maximum (grows features), "
|
||||||
|
"median the 50th percentile. Custom percentile allows any rank. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, operation: str, radius: int,
|
||||||
|
percentile: float) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
size = 2 * radius + 1
|
||||||
|
|
||||||
|
if operation == "erosion":
|
||||||
|
result = minimum_filter(data, size=size)
|
||||||
|
elif operation == "dilation":
|
||||||
|
result = maximum_filter(data, size=size)
|
||||||
|
elif operation == "median":
|
||||||
|
result = median_filter(data, size=size)
|
||||||
|
elif operation == "percentile":
|
||||||
|
result = percentile_filter(data, percentile=percentile, size=size)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown operation: {operation!r}")
|
||||||
|
|
||||||
|
return (field.replace(data=result),)
|
||||||
@@ -22,7 +22,6 @@ class FixZero:
|
|||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Shift data so that the minimum (or mean/median) is zero. "
|
"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:
|
def process(self, field: DataField, method: str) -> tuple:
|
||||||
|
|||||||
67
backend/nodes/flatten_base.py
Normal file
67
backend/nodes/flatten_base.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Flatten base — level the flat base of a surface with raised features."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import median_filter
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Flatten Base")
|
||||||
|
class FlattenBase:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"threshold_percentile": ("FLOAT", {"default": 30.0, "min": 5.0, "max": 80.0, "step": 1.0}),
|
||||||
|
"poly_degree": ("INT", {"default": 2, "min": 0, "max": 5}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'leveled'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Level the flat base of a surface that has raised features (particles, "
|
||||||
|
"grains). Uses a height percentile threshold to identify base pixels, "
|
||||||
|
"fits a polynomial to those pixels, and subtracts it. Unlike plane level, "
|
||||||
|
"this ignores tall features that would bias the fit. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, threshold_percentile: float, poly_degree: int) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
# Identify base pixels: those below the threshold percentile
|
||||||
|
threshold = np.percentile(data, threshold_percentile)
|
||||||
|
base_mask = data <= threshold
|
||||||
|
|
||||||
|
if base_mask.sum() < max(3, (poly_degree + 1) ** 2):
|
||||||
|
# Not enough base pixels, fall back to subtracting the mean
|
||||||
|
return (field.replace(data=data - data.mean()),)
|
||||||
|
|
||||||
|
yy, xx = np.mgrid[:yres, :xres]
|
||||||
|
x_norm = xx.ravel() / max(xres - 1, 1)
|
||||||
|
y_norm = yy.ravel() / max(yres - 1, 1)
|
||||||
|
|
||||||
|
# Build polynomial basis
|
||||||
|
cols = []
|
||||||
|
for py in range(poly_degree + 1):
|
||||||
|
for px in range(poly_degree + 1 - py):
|
||||||
|
cols.append(x_norm**px * y_norm**py)
|
||||||
|
A_full = np.column_stack(cols)
|
||||||
|
|
||||||
|
# Fit on base pixels only
|
||||||
|
base_indices = np.where(base_mask.ravel())[0]
|
||||||
|
A_base = A_full[base_indices]
|
||||||
|
z_base = data.ravel()[base_indices]
|
||||||
|
coeffs, _, _, _ = np.linalg.lstsq(A_base, z_base, rcond=None)
|
||||||
|
|
||||||
|
# Evaluate and subtract
|
||||||
|
background = (A_full @ coeffs).reshape(data.shape)
|
||||||
|
return (field.replace(data=data - background),)
|
||||||
78
backend/nodes/fractal_interpolation.py
Normal file
78
backend/nodes/fractal_interpolation.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Fractal interpolation — fill masked regions using fractal (self-similar) synthesis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
from backend.nodes.helpers import mask_to_bool
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Fractal Interpolation")
|
||||||
|
class FractalInterpolation:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"mask": ("IMAGE",),
|
||||||
|
"iterations": ("INT", {"default": 200, "min": 10, "max": 5000, "step": 10}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'filled'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Fill masked regions using fractal interpolation. Matches the spectral "
|
||||||
|
"characteristics of the surrounding surface to produce natural-looking "
|
||||||
|
"infill that preserves texture. Better than Laplace for rough surfaces. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, mask: np.ndarray, iterations: int) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64).copy()
|
||||||
|
hole = mask_to_bool(mask)
|
||||||
|
|
||||||
|
if not hole.any():
|
||||||
|
return (field.replace(data=data),)
|
||||||
|
|
||||||
|
# Step 1: Estimate power spectrum from valid (unmasked) data
|
||||||
|
valid_data = data.copy()
|
||||||
|
valid_mean = data[~hole].mean() if (~hole).any() else 0.0
|
||||||
|
valid_data[hole] = valid_mean
|
||||||
|
|
||||||
|
fft_valid = np.fft.fft2(valid_data)
|
||||||
|
power = np.abs(fft_valid) ** 2
|
||||||
|
|
||||||
|
# Step 2: Generate fractal noise matching the power spectrum
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
phases = rng.uniform(0, 2 * np.pi, data.shape)
|
||||||
|
noise_fft = np.sqrt(power) * np.exp(1j * phases)
|
||||||
|
noise = np.real(np.fft.ifft2(noise_fft))
|
||||||
|
|
||||||
|
# Normalize noise to match local statistics around masked region
|
||||||
|
if (~hole).any():
|
||||||
|
noise = (noise - noise[~hole].mean()) / max(noise[~hole].std(), 1e-30) * \
|
||||||
|
data[~hole].std() + data[~hole].mean()
|
||||||
|
|
||||||
|
# Step 3: Initialize masked pixels with fractal noise, then blend
|
||||||
|
# with Laplace relaxation for smooth boundaries
|
||||||
|
data[hole] = noise[hole]
|
||||||
|
|
||||||
|
# Relax boundaries to ensure continuity
|
||||||
|
padded = np.pad(data, 1, mode='edge')
|
||||||
|
hole_padded = np.pad(hole, 1, mode='constant', constant_values=False)
|
||||||
|
|
||||||
|
for _ in range(iterations):
|
||||||
|
avg = (padded[:-2, 1:-1] + padded[2:, 1:-1] +
|
||||||
|
padded[1:-1, :-2] + padded[1:-1, 2:]) / 4.0
|
||||||
|
# Blend: 90% fractal noise + 10% relaxation to smooth boundaries
|
||||||
|
blend = 0.1
|
||||||
|
new_vals = (1.0 - blend) * padded[1:-1, 1:-1][hole] + blend * avg[hole]
|
||||||
|
padded[1:-1, 1:-1][hole] = new_vals
|
||||||
|
|
||||||
|
data = padded[1:-1, 1:-1].copy()
|
||||||
|
return (field.replace(data=data),)
|
||||||
50
backend/nodes/freq_split.py
Normal file
50
backend/nodes/freq_split.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Frequency splitting — separate image into low-pass and high-pass components."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Frequency Split")
|
||||||
|
class FrequencySplit:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"cutoff": ("FLOAT", {"default": 0.1, "min": 0.001, "max": 0.5, "step": 0.001}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'low_pass'),
|
||||||
|
('DATA_FIELD', 'high_pass'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Separate a field into low-frequency (background) and high-frequency "
|
||||||
|
"(detail) components using FFT. The cutoff is relative to the Nyquist "
|
||||||
|
"frequency (0.5 = no filtering, 0.001 = very aggressive). "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, cutoff: float) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
kx = np.fft.fftfreq(xres)
|
||||||
|
ky = np.fft.fftfreq(yres)
|
||||||
|
KX, KY = np.meshgrid(kx, ky)
|
||||||
|
K = np.sqrt(KX**2 + KY**2)
|
||||||
|
|
||||||
|
# Gaussian low-pass filter
|
||||||
|
lp_filter = np.exp(-0.5 * (K / cutoff)**2)
|
||||||
|
|
||||||
|
fft_data = np.fft.fft2(data)
|
||||||
|
low = np.real(np.fft.ifft2(fft_data * lp_filter))
|
||||||
|
high = data - low
|
||||||
|
|
||||||
|
return (field.replace(data=low), field.replace(data=high))
|
||||||
@@ -27,7 +27,6 @@ class Gradient:
|
|||||||
"'x'/'y' give the physical gradient components (z_unit/xy_unit); "
|
"'x'/'y' give the physical gradient components (z_unit/xy_unit); "
|
||||||
"'magnitude' gives sqrt(gx²+gy²); "
|
"'magnitude' gives sqrt(gx²+gy²); "
|
||||||
"'azimuth' gives the local slope direction in radians via atan2(gy, gx). "
|
"'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:
|
def process(self, field: DataField, component: str) -> tuple:
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class GrainAnalysis:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Label connected grain regions in a binary mask and compute per-grain "
|
"Label connected grain regions in a binary mask and compute per-grain "
|
||||||
"statistics: area, equivalent diameter, mean/max height, bounding box. "
|
"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:
|
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||||
|
|||||||
84
backend/nodes/grain_cross.py
Normal file
84
backend/nodes/grain_cross.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Grain cross-correlation — scatter plots of grain properties between two fields."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import label
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField, RecordTable
|
||||||
|
from backend.nodes.helpers import mask_to_bool
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Grain Cross")
|
||||||
|
class GrainCross:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field_a": ("DATA_FIELD",),
|
||||||
|
"field_b": ("DATA_FIELD",),
|
||||||
|
"mask": ("IMAGE",),
|
||||||
|
"property_a": (["area", "mean_height", "max_height", "volume"],
|
||||||
|
{"default": "mean_height"}),
|
||||||
|
"property_b": (["area", "mean_height", "max_height", "volume"],
|
||||||
|
{"default": "max_height"}),
|
||||||
|
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('RECORD_TABLE', 'correlation'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Correlate grain properties between two fields using a shared mask. "
|
||||||
|
"Outputs a table of (property_a, property_b) pairs for each grain, "
|
||||||
|
"plus Pearson correlation coefficient. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field_a: DataField, field_b: DataField, mask: np.ndarray,
|
||||||
|
property_a: str, property_b: str, min_size: int) -> tuple:
|
||||||
|
data_a = np.asarray(field_a.data, dtype=np.float64)
|
||||||
|
data_b = np.asarray(field_b.data, dtype=np.float64)
|
||||||
|
grain = mask_to_bool(mask)
|
||||||
|
labeled, n_grains = label(grain.astype(np.int32))
|
||||||
|
|
||||||
|
pixel_area = field_a.dx * field_a.dy
|
||||||
|
|
||||||
|
def _get_prop(data, gpx, prop):
|
||||||
|
n_px = gpx.sum()
|
||||||
|
if prop == "area":
|
||||||
|
return n_px * pixel_area
|
||||||
|
elif prop == "mean_height":
|
||||||
|
return float(data[gpx].mean())
|
||||||
|
elif prop == "max_height":
|
||||||
|
return float(data[gpx].max())
|
||||||
|
elif prop == "volume":
|
||||||
|
base = float(data[~grain].mean()) if (~grain).any() else 0.0
|
||||||
|
return float(np.sum(data[gpx] - base) * pixel_area)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
vals_a, vals_b = [], []
|
||||||
|
records = RecordTable()
|
||||||
|
for gid in range(1, n_grains + 1):
|
||||||
|
gpx = labeled == gid
|
||||||
|
if gpx.sum() < min_size:
|
||||||
|
continue
|
||||||
|
va = _get_prop(data_a, gpx, property_a)
|
||||||
|
vb = _get_prop(data_b, gpx, property_b)
|
||||||
|
vals_a.append(va)
|
||||||
|
vals_b.append(vb)
|
||||||
|
records.append({
|
||||||
|
"quantity": f"Grain {gid}",
|
||||||
|
"value": f"{va:.4g} / {vb:.4g}",
|
||||||
|
"unit": f"{property_a} / {property_b}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Pearson correlation
|
||||||
|
if len(vals_a) >= 2:
|
||||||
|
corr = float(np.corrcoef(vals_a, vals_b)[0, 1])
|
||||||
|
records.append({"quantity": "Pearson r", "value": f"{corr:.4f}", "unit": ""})
|
||||||
|
|
||||||
|
return (records,)
|
||||||
99
backend/nodes/grain_distributions.py
Normal file
99
backend/nodes/grain_distributions.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Grain property distributions — compute histograms of grain properties."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import label
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField, LineData
|
||||||
|
from backend.nodes.helpers import mask_to_bool
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Grain Distributions")
|
||||||
|
class GrainDistributions:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"mask": ("IMAGE",),
|
||||||
|
"property": (["area", "equiv_diameter", "mean_height", "max_height",
|
||||||
|
"volume", "boundary_length"], {"default": "area"}),
|
||||||
|
"n_bins": ("INT", {"default": 30, "min": 5, "max": 200, "step": 1}),
|
||||||
|
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('LINE_DATA', 'distribution'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Compute a histogram of a grain property from a labeled mask. "
|
||||||
|
"Supported properties: area, equivalent diameter, mean height, "
|
||||||
|
"max height, volume, and boundary length. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, mask: np.ndarray, property: str,
|
||||||
|
n_bins: int, min_size: int) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
grain_mask = mask_to_bool(mask)
|
||||||
|
labeled, n_grains = label(grain_mask.astype(np.int32))
|
||||||
|
|
||||||
|
pixel_area = field.dx * field.dy
|
||||||
|
xy_unit = field.si_unit_xy or "m"
|
||||||
|
z_unit = field.si_unit_z or "m"
|
||||||
|
|
||||||
|
values = []
|
||||||
|
for gid in range(1, n_grains + 1):
|
||||||
|
gpx = labeled == gid
|
||||||
|
n_px = int(gpx.sum())
|
||||||
|
if n_px < min_size:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if property == "area":
|
||||||
|
values.append(n_px * pixel_area)
|
||||||
|
elif property == "equiv_diameter":
|
||||||
|
area = n_px * pixel_area
|
||||||
|
values.append(2.0 * np.sqrt(area / np.pi))
|
||||||
|
elif property == "mean_height":
|
||||||
|
values.append(float(data[gpx].mean()))
|
||||||
|
elif property == "max_height":
|
||||||
|
values.append(float(data[gpx].max()))
|
||||||
|
elif property == "volume":
|
||||||
|
base = float(data[~grain_mask].mean()) if (~grain_mask).any() else 0.0
|
||||||
|
values.append(float(np.sum(data[gpx] - base) * pixel_area))
|
||||||
|
elif property == "boundary_length":
|
||||||
|
# Count boundary pixels (pixels with at least one non-grain neighbour)
|
||||||
|
padded = np.pad(gpx, 1, mode='constant', constant_values=False)
|
||||||
|
boundary = gpx & ~(
|
||||||
|
padded[:-2, 1:-1] & padded[2:, 1:-1] &
|
||||||
|
padded[1:-1, :-2] & padded[1:-1, 2:]
|
||||||
|
)
|
||||||
|
values.append(int(boundary.sum()) * max(field.dx, field.dy))
|
||||||
|
|
||||||
|
if len(values) == 0:
|
||||||
|
values = [0.0]
|
||||||
|
|
||||||
|
# Unit labels
|
||||||
|
unit_map = {
|
||||||
|
"area": f"{xy_unit}²",
|
||||||
|
"equiv_diameter": xy_unit,
|
||||||
|
"mean_height": z_unit,
|
||||||
|
"max_height": z_unit,
|
||||||
|
"volume": f"{xy_unit}²·{z_unit}",
|
||||||
|
"boundary_length": xy_unit,
|
||||||
|
}
|
||||||
|
|
||||||
|
arr = np.array(values)
|
||||||
|
counts, edges = np.histogram(arr, bins=n_bins)
|
||||||
|
centers = 0.5 * (edges[:-1] + edges[1:])
|
||||||
|
|
||||||
|
return (LineData(
|
||||||
|
data=counts.astype(np.float64),
|
||||||
|
x_axis=centers,
|
||||||
|
x_unit=unit_map.get(property, ""),
|
||||||
|
y_unit="count",
|
||||||
|
),)
|
||||||
51
backend/nodes/grain_edge.py
Normal file
51
backend/nodes/grain_edge.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Grain edge detection — detect grain boundaries using Laplacian edge detection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import label
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
from backend.nodes.helpers import mask_to_bool, bool_to_mask
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Grain Edge")
|
||||||
|
class GrainEdge:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"mask": ("IMAGE",),
|
||||||
|
"width": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('IMAGE', 'edge_mask'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Detect grain boundaries from a binary grain mask. Outputs a mask "
|
||||||
|
"of pixels at grain edges (where grain meets non-grain). Width "
|
||||||
|
"controls the boundary thickness in pixels. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, mask: np.ndarray, width: int) -> tuple:
|
||||||
|
grain = mask_to_bool(mask)
|
||||||
|
|
||||||
|
# Find boundary: grain pixels with at least one non-grain 4-neighbour
|
||||||
|
padded = np.pad(grain, 1, mode='constant', constant_values=False)
|
||||||
|
interior = (padded[:-2, 1:-1] & padded[2:, 1:-1] &
|
||||||
|
padded[1:-1, :-2] & padded[1:-1, 2:])
|
||||||
|
boundary = grain & ~interior
|
||||||
|
|
||||||
|
# Expand boundary by width
|
||||||
|
if width > 1:
|
||||||
|
from scipy.ndimage import binary_dilation
|
||||||
|
struct = np.ones((2 * width - 1, 2 * width - 1), dtype=bool)
|
||||||
|
boundary = binary_dilation(boundary, structure=struct) & grain
|
||||||
|
|
||||||
|
return (bool_to_mask(boundary),)
|
||||||
@@ -29,7 +29,6 @@ class GrainFilter:
|
|||||||
"'min_area': discard grains smaller than this many pixels (removes specks). "
|
"'min_area': discard grains smaller than this many pixels (removes specks). "
|
||||||
"'max_area': discard grains larger than this many pixels (0 = no limit). "
|
"'max_area': discard grains larger than this many pixels (0 = no limit). "
|
||||||
"'remove_border': discard any grain that touches the image edge. "
|
"'remove_border': discard any grain that touches the image edge. "
|
||||||
"Equivalent to Gwyddion's grain_filter module (grain_filter.c)."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
75
backend/nodes/grain_mark.py
Normal file
75
backend/nodes/grain_mark.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Grain marking — mark grains by height, slope, or curvature criteria."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import label, sobel
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
from backend.nodes.helpers import bool_to_mask
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Grain Mark")
|
||||||
|
class GrainMark:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"criterion": (["height", "slope", "curvature"], {"default": "height"}),
|
||||||
|
"threshold_low": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"threshold_high": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
|
||||||
|
"inverted": ("BOOLEAN", {"default": False}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('IMAGE', 'mask'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Mark grains by thresholding height, slope magnitude, or curvature. "
|
||||||
|
"Thresholds are relative (0–1) to the data range. Small regions below "
|
||||||
|
"min_size pixels are removed. Use inverted to mark valleys instead of peaks. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, criterion: str, threshold_low: float,
|
||||||
|
threshold_high: float, min_size: int, inverted: bool) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
|
||||||
|
if criterion == "height":
|
||||||
|
values = data
|
||||||
|
elif criterion == "slope":
|
||||||
|
gx = sobel(data, axis=1)
|
||||||
|
gy = sobel(data, axis=0)
|
||||||
|
values = np.sqrt(gx**2 + gy**2)
|
||||||
|
elif criterion == "curvature":
|
||||||
|
gxx = sobel(sobel(data, axis=1), axis=1)
|
||||||
|
gyy = sobel(sobel(data, axis=0), axis=0)
|
||||||
|
values = np.abs(gxx + gyy)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown criterion: {criterion!r}")
|
||||||
|
|
||||||
|
# Normalize to [0, 1]
|
||||||
|
vmin, vmax = values.min(), values.max()
|
||||||
|
if vmax > vmin:
|
||||||
|
norm = (values - vmin) / (vmax - vmin)
|
||||||
|
else:
|
||||||
|
norm = np.zeros_like(values)
|
||||||
|
|
||||||
|
# Apply thresholds
|
||||||
|
binary = (norm >= threshold_low) & (norm <= threshold_high)
|
||||||
|
|
||||||
|
if inverted:
|
||||||
|
binary = ~binary
|
||||||
|
|
||||||
|
# Remove small regions
|
||||||
|
labeled, n_labels = label(binary.astype(np.int32))
|
||||||
|
for gid in range(1, n_labels + 1):
|
||||||
|
if (labeled == gid).sum() < min_size:
|
||||||
|
binary[labeled == gid] = False
|
||||||
|
|
||||||
|
return (bool_to_mask(binary),)
|
||||||
78
backend/nodes/grain_summary.py
Normal file
78
backend/nodes/grain_summary.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Grain summary statistics — aggregate statistics for all grains."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import label
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField, RecordTable
|
||||||
|
from backend.nodes.helpers import mask_to_bool, _square_unit
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Grain Summary")
|
||||||
|
class GrainSummary:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"mask": ("IMAGE",),
|
||||||
|
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('RECORD_TABLE', 'summary'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Compute aggregate statistics for all grains in a mask: count, density, "
|
||||||
|
"coverage fraction, mean/median area, total volume, and height statistics. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
grain_mask = mask_to_bool(mask)
|
||||||
|
labeled, n_grains = label(grain_mask.astype(np.int32))
|
||||||
|
|
||||||
|
pixel_area = field.dx * field.dy
|
||||||
|
total_area = field.xreal * field.yreal
|
||||||
|
xy_unit = field.si_unit_xy or "m"
|
||||||
|
z_unit = field.si_unit_z or "m"
|
||||||
|
|
||||||
|
# Collect per-grain properties
|
||||||
|
areas = []
|
||||||
|
heights = []
|
||||||
|
volumes = []
|
||||||
|
base_height = float(data[~grain_mask].mean()) if (~grain_mask).any() else 0.0
|
||||||
|
|
||||||
|
for gid in range(1, n_grains + 1):
|
||||||
|
gpx = labeled == gid
|
||||||
|
n_px = int(gpx.sum())
|
||||||
|
if n_px < min_size:
|
||||||
|
continue
|
||||||
|
area = n_px * pixel_area
|
||||||
|
areas.append(area)
|
||||||
|
heights.append(float(data[gpx].mean()))
|
||||||
|
volumes.append(float(np.sum(data[gpx] - base_height) * pixel_area))
|
||||||
|
|
||||||
|
records = RecordTable()
|
||||||
|
n_valid = len(areas)
|
||||||
|
records.append({"quantity": "Grain count", "value": str(n_valid), "unit": ""})
|
||||||
|
records.append({"quantity": "Grain density", "value": f"{n_valid / total_area:.4g}" if total_area > 0 else "0", "unit": f"1/{_square_unit(xy_unit)}"})
|
||||||
|
|
||||||
|
coverage = sum(areas) / total_area if total_area > 0 else 0.0
|
||||||
|
records.append({"quantity": "Coverage fraction", "value": f"{coverage:.4f}", "unit": ""})
|
||||||
|
|
||||||
|
if n_valid > 0:
|
||||||
|
records.append({"quantity": "Mean area", "value": f"{np.mean(areas):.4g}", "unit": _square_unit(xy_unit)})
|
||||||
|
records.append({"quantity": "Median area", "value": f"{np.median(areas):.4g}", "unit": _square_unit(xy_unit)})
|
||||||
|
records.append({"quantity": "Total volume", "value": f"{sum(volumes):.4g}", "unit": f"{_square_unit(xy_unit)}·{z_unit}"})
|
||||||
|
records.append({"quantity": "Mean height", "value": f"{np.mean(heights):.4g}", "unit": z_unit})
|
||||||
|
records.append({"quantity": "Median height", "value": f"{np.median(heights):.4g}", "unit": z_unit})
|
||||||
|
records.append({"quantity": "Max area", "value": f"{max(areas):.4g}", "unit": _square_unit(xy_unit)})
|
||||||
|
records.append({"quantity": "Min area", "value": f"{min(areas):.4g}", "unit": _square_unit(xy_unit)})
|
||||||
|
|
||||||
|
return (records,)
|
||||||
@@ -32,7 +32,6 @@ class Histogram:
|
|||||||
"Compute the height distribution histogram (DH). "
|
"Compute the height distribution histogram (DH). "
|
||||||
"Use log scale to reveal small peaks next to a dominant background. "
|
"Use log scale to reveal small peaks next to a dominant background. "
|
||||||
"Outputs marker measurements while showing the histogram interactively in-node. "
|
"Outputs marker measurements while showing the histogram interactively in-node. "
|
||||||
"Equivalent to gwy_data_field_dh."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class HoughTransform:
|
|||||||
"Hough parameter space. Reports detected features with their parameters. "
|
"Hough parameter space. Reports detected features with their parameters. "
|
||||||
"For lines: angle and distance from origin. "
|
"For lines: angle and distance from origin. "
|
||||||
"For circles: centre coordinates and radius. "
|
"For circles: centre coordinates and radius. "
|
||||||
"Equivalent to Gwyddion's hough.c module."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class ImageStitch:
|
|||||||
"Uses cross-correlation to align the images and blends the overlap region. "
|
"Uses cross-correlation to align the images and blends the overlap region. "
|
||||||
"Direction specifies how field_b is positioned relative to field_a. "
|
"Direction specifies how field_b is positioned relative to field_a. "
|
||||||
"'auto' uses cross-correlation to determine the best placement. "
|
"'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:
|
def process(self, field_a: DataField, field_b: DataField, direction: str, blend: str) -> tuple:
|
||||||
|
|||||||
89
backend/nodes/immerse_detail.py
Normal file
89
backend/nodes/immerse_detail.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Immerse detail — overlay high-resolution detail onto lower-resolution overview."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Immerse Detail")
|
||||||
|
class ImmerseDetail:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"overview": ("DATA_FIELD",),
|
||||||
|
"detail": ("DATA_FIELD",),
|
||||||
|
"blend": (["replace", "average"], {"default": "replace"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'combined'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Overlay a high-resolution detail scan onto a lower-resolution overview "
|
||||||
|
"image using cross-correlation to find the best position. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, overview: DataField, detail: DataField, blend: str) -> tuple:
|
||||||
|
ov = np.asarray(overview.data, dtype=np.float64)
|
||||||
|
dt = np.asarray(detail.data, dtype=np.float64)
|
||||||
|
|
||||||
|
# Resample detail to overview pixel size if needed
|
||||||
|
scale_x = detail.dx / overview.dx
|
||||||
|
scale_y = detail.dy / overview.dy
|
||||||
|
|
||||||
|
if abs(scale_x - 1.0) > 0.01 or abs(scale_y - 1.0) > 0.01:
|
||||||
|
from scipy.ndimage import zoom
|
||||||
|
dt = zoom(dt, (scale_y, scale_x), order=1)
|
||||||
|
|
||||||
|
dy_res, dx_res = dt.shape
|
||||||
|
oy_res, ox_res = ov.shape
|
||||||
|
|
||||||
|
if dy_res >= oy_res or dx_res >= ox_res:
|
||||||
|
# Detail is larger than overview, just return overview
|
||||||
|
return (overview,)
|
||||||
|
|
||||||
|
# Cross-correlate to find best position
|
||||||
|
# Use a sliding window approach for small detail
|
||||||
|
best_score = -np.inf
|
||||||
|
best_y, best_x = 0, 0
|
||||||
|
|
||||||
|
dt_norm = dt - dt.mean()
|
||||||
|
dt_std = dt.std()
|
||||||
|
if dt_std < 1e-30:
|
||||||
|
dt_std = 1.0
|
||||||
|
|
||||||
|
# Coarse search with stride
|
||||||
|
stride = max(1, min(dy_res, dx_res) // 4)
|
||||||
|
for iy in range(0, oy_res - dy_res + 1, stride):
|
||||||
|
for ix in range(0, ox_res - dx_res + 1, stride):
|
||||||
|
patch = ov[iy:iy + dy_res, ix:ix + dx_res]
|
||||||
|
score = np.sum((patch - patch.mean()) * dt_norm) / (patch.std() * dt_std + 1e-30)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_y, best_x = iy, ix
|
||||||
|
|
||||||
|
# Fine search around best position
|
||||||
|
for iy in range(max(0, best_y - stride), min(oy_res - dy_res + 1, best_y + stride + 1)):
|
||||||
|
for ix in range(max(0, best_x - stride), min(ox_res - dx_res + 1, best_x + stride + 1)):
|
||||||
|
patch = ov[iy:iy + dy_res, ix:ix + dx_res]
|
||||||
|
score = np.sum((patch - patch.mean()) * dt_norm) / (patch.std() * dt_std + 1e-30)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_y, best_x = iy, ix
|
||||||
|
|
||||||
|
# Place detail into overview
|
||||||
|
result = ov.copy()
|
||||||
|
if blend == "replace":
|
||||||
|
result[best_y:best_y + dy_res, best_x:best_x + dx_res] = dt
|
||||||
|
else: # average
|
||||||
|
result[best_y:best_y + dy_res, best_x:best_x + dx_res] = \
|
||||||
|
0.5 * (ov[best_y:best_y + dy_res, best_x:best_x + dx_res] + dt)
|
||||||
|
|
||||||
|
return (overview.replace(data=result),)
|
||||||
57
backend/nodes/laplace_interpolation.py
Normal file
57
backend/nodes/laplace_interpolation.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Laplace interpolation — fill masked regions by solving the Laplace equation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
from backend.nodes.helpers import mask_to_bool
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Laplace Interpolation")
|
||||||
|
class LaplaceInterpolation:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"mask": ("IMAGE",),
|
||||||
|
"iterations": ("INT", {"default": 500, "min": 10, "max": 10000, "step": 10}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'filled'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Fill masked (missing) regions by solving the Laplace equation with "
|
||||||
|
"Dirichlet boundary conditions from surrounding pixels. "
|
||||||
|
"Produces a smooth, harmonic interpolation without overshooting. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, mask: np.ndarray, iterations: int) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64).copy()
|
||||||
|
hole = mask_to_bool(mask)
|
||||||
|
|
||||||
|
if not hole.any():
|
||||||
|
return (field.replace(data=data),)
|
||||||
|
|
||||||
|
# Initialize masked pixels to mean of unmasked neighbours or global mean
|
||||||
|
valid_mean = data[~hole].mean() if (~hole).any() else 0.0
|
||||||
|
data[hole] = valid_mean
|
||||||
|
|
||||||
|
# Iterative Jacobi relaxation: replace each masked pixel with
|
||||||
|
# the mean of its 4-connected neighbours
|
||||||
|
padded = np.pad(data, 1, mode='edge')
|
||||||
|
hole_padded = np.pad(hole, 1, mode='constant', constant_values=False)
|
||||||
|
|
||||||
|
for _ in range(iterations):
|
||||||
|
avg = (padded[:-2, 1:-1] + padded[2:, 1:-1] +
|
||||||
|
padded[1:-1, :-2] + padded[1:-1, 2:]) / 4.0
|
||||||
|
padded[1:-1, 1:-1][hole] = avg[hole]
|
||||||
|
|
||||||
|
data = padded[1:-1, 1:-1].copy()
|
||||||
|
return (field.replace(data=data),)
|
||||||
@@ -65,7 +65,6 @@ class LatticeMeasurement:
|
|||||||
"Detect and measure periodic lattice structures from a surface. "
|
"Detect and measure periodic lattice structures from a surface. "
|
||||||
"Computes the 2D ACF or FFT power spectrum, finds the strongest peaks, "
|
"Computes the 2D ACF or FFT power spectrum, finds the strongest peaks, "
|
||||||
"and reports lattice vectors (spacing and angle). "
|
"and reports lattice vectors (spacing and angle). "
|
||||||
"Equivalent to Gwyddion's measure_lattice.c module."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(self, field: DataField, method: str) -> tuple:
|
def process(self, field: DataField, method: str) -> tuple:
|
||||||
|
|||||||
68
backend/nodes/level_grains.py
Normal file
68
backend/nodes/level_grains.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Level grains — shift individual grain regions to a common baseline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import label
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
from backend.nodes.helpers import mask_to_bool
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Level Grains")
|
||||||
|
class LevelGrains:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"mask": ("IMAGE",),
|
||||||
|
"reference": (["mean", "median", "minimum"], {"default": "mean"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'leveled'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Shift individual grain regions (from a mask) so they all share a "
|
||||||
|
"common baseline. Uses the selected reference statistic (mean, median, "
|
||||||
|
"or minimum) per grain to compute the offset. "
|
||||||
|
"Useful for consistent grain height comparisons. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, mask: np.ndarray, reference: str) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64).copy()
|
||||||
|
grain_mask = mask_to_bool(mask)
|
||||||
|
labeled, n_grains = label(grain_mask.astype(np.int32))
|
||||||
|
|
||||||
|
if n_grains == 0:
|
||||||
|
return (field.replace(data=data),)
|
||||||
|
|
||||||
|
# Compute reference value for each grain
|
||||||
|
refs = []
|
||||||
|
for gid in range(1, n_grains + 1):
|
||||||
|
pixels = data[labeled == gid]
|
||||||
|
if len(pixels) == 0:
|
||||||
|
refs.append(0.0)
|
||||||
|
continue
|
||||||
|
if reference == "mean":
|
||||||
|
refs.append(float(pixels.mean()))
|
||||||
|
elif reference == "median":
|
||||||
|
refs.append(float(np.median(pixels)))
|
||||||
|
else: # minimum
|
||||||
|
refs.append(float(pixels.min()))
|
||||||
|
|
||||||
|
# Target: global reference across all grains
|
||||||
|
target = float(np.mean(refs))
|
||||||
|
|
||||||
|
# Shift each grain
|
||||||
|
for gid in range(1, n_grains + 1):
|
||||||
|
grain_pixels = labeled == gid
|
||||||
|
offset = target - refs[gid - 1]
|
||||||
|
data[grain_pixels] += offset
|
||||||
|
|
||||||
|
return (field.replace(data=data),)
|
||||||
@@ -24,7 +24,6 @@ class PolyLevelField:
|
|||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Fit and subtract a polynomial background of given degree in x and y. "
|
"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:
|
def process(self, field: DataField, degree_x: int, degree_y: int) -> tuple:
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class LocalContrast:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Expand the local dynamic range at each pixel. "
|
"Expand the local dynamic range at each pixel. "
|
||||||
"Reveals fine surface features that are hidden by global contrast range. "
|
"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:
|
def process(self, field: DataField, kernel_size: int, weight: float) -> tuple:
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ from backend.nodes.helpers import _mask_structure, mask_to_bool, bool_to_mask, e
|
|||||||
|
|
||||||
@register_node(display_name="Mask Morphology")
|
@register_node(display_name="Mask Morphology")
|
||||||
class MaskMorphology:
|
class MaskMorphology:
|
||||||
"""Morphological operations on binary masks.
|
"""
|
||||||
|
Morphological operations on binary masks.
|
||||||
Equivalent to Gwyddion's mask_morph.c (erode, dilate, open, close).
|
|
||||||
"""
|
"""
|
||||||
_CUSTOM_PREVIEW = True
|
_CUSTOM_PREVIEW = True
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ class MaskMorphology:
|
|||||||
"Dilate expands regions, erode shrinks them, "
|
"Dilate expands regions, erode shrinks them, "
|
||||||
"open (erode then dilate) removes small spots, "
|
"open (erode then dilate) removes small spots, "
|
||||||
"close (dilate then erode) fills small holes. "
|
"close (dilate then erode) fills small holes. "
|
||||||
"Equivalent to Gwyddion mask_morph."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(self, mask: np.ndarray, operation: str, radius: int, shape: str,
|
def process(self, mask: np.ndarray, operation: str, radius: int, shape: str,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class ThresholdMask:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Create a binary mask by thresholding data. "
|
"Create a binary mask by thresholding data. "
|
||||||
"Otsu automatically finds the optimal threshold. "
|
"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:
|
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:
|
||||||
|
|||||||
44
backend/nodes/median_background.py
Normal file
44
backend/nodes/median_background.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Median background subtraction — extract and subtract background using local median."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import median_filter
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Median Background")
|
||||||
|
class MedianBackground:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"radius": ("INT", {"default": 20, "min": 2, "max": 500, "step": 1}),
|
||||||
|
"output": (["subtracted", "background"], {"default": "subtracted"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'result'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Extract background using a local median filter and subtract it. "
|
||||||
|
"The radius controls the filter window size — larger values capture "
|
||||||
|
"broader background variations. More robust than polynomial leveling "
|
||||||
|
"for surfaces with sparse tall features. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, radius: int, output: str) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
size = 2 * radius + 1
|
||||||
|
background = median_filter(data, size=size)
|
||||||
|
|
||||||
|
if output == "background":
|
||||||
|
return (field.replace(data=background),)
|
||||||
|
else:
|
||||||
|
return (field.replace(data=data - background),)
|
||||||
@@ -35,7 +35,6 @@ class MFMAnalysis:
|
|||||||
"d²F/dz²; force_gradient_to_field recovers the stray field Hz; "
|
"d²F/dz²; force_gradient_to_field recovers the stray field Hz; "
|
||||||
"charge_density computes the effective magnetic charge; "
|
"charge_density computes the effective magnetic charge; "
|
||||||
"magnetisation estimates the z-component of sample magnetisation. "
|
"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:
|
def process(self, field: DataField, operation: str, lift_height: float) -> tuple:
|
||||||
|
|||||||
72
backend/nodes/multi_profile.py
Normal file
72
backend/nodes/multi_profile.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Multiple profiles — extract and compare profiles from multiple images."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField, LineData
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Multiple Profiles")
|
||||||
|
class MultipleProfiles:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field_a": ("DATA_FIELD",),
|
||||||
|
"field_b": ("DATA_FIELD",),
|
||||||
|
"row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}),
|
||||||
|
"direction": (["horizontal", "vertical"], {"default": "horizontal"}),
|
||||||
|
"mode": (["overlay", "mean", "difference"], {"default": "overlay"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('LINE_DATA', 'profile'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Extract and compare line profiles from two fields. "
|
||||||
|
"Row=-1 uses the center row/column. Modes: overlay returns field_a's "
|
||||||
|
"profile, mean averages both, difference subtracts b from a. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field_a: DataField, field_b: DataField,
|
||||||
|
row: int, direction: str, mode: str) -> tuple:
|
||||||
|
a = np.asarray(field_a.data, dtype=np.float64)
|
||||||
|
b = np.asarray(field_b.data, dtype=np.float64)
|
||||||
|
|
||||||
|
if direction == "horizontal":
|
||||||
|
if row < 0:
|
||||||
|
row = a.shape[0] // 2
|
||||||
|
row = min(row, a.shape[0] - 1, b.shape[0] - 1)
|
||||||
|
pa = a[row, :]
|
||||||
|
pb = b[row, :min(a.shape[1], b.shape[1])]
|
||||||
|
pa = pa[:len(pb)]
|
||||||
|
dx = field_a.dx
|
||||||
|
x_unit = field_a.si_unit_xy
|
||||||
|
else:
|
||||||
|
if row < 0:
|
||||||
|
row = a.shape[1] // 2
|
||||||
|
row = min(row, a.shape[1] - 1, b.shape[1] - 1)
|
||||||
|
pa = a[:, row]
|
||||||
|
pb = b[:min(a.shape[0], b.shape[0]), row]
|
||||||
|
pa = pa[:len(pb)]
|
||||||
|
dx = field_a.dy
|
||||||
|
x_unit = field_a.si_unit_xy
|
||||||
|
|
||||||
|
x_axis = np.arange(len(pa)) * dx
|
||||||
|
|
||||||
|
if mode == "overlay":
|
||||||
|
result = pa
|
||||||
|
elif mode == "mean":
|
||||||
|
result = 0.5 * (pa + pb)
|
||||||
|
elif mode == "difference":
|
||||||
|
result = pa - pb
|
||||||
|
else:
|
||||||
|
result = pa
|
||||||
|
|
||||||
|
return (LineData(data=result, x_axis=x_axis, x_unit=x_unit,
|
||||||
|
y_unit=field_a.si_unit_z),)
|
||||||
79
backend/nodes/mutual_crop.py
Normal file
79
backend/nodes/mutual_crop.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Mutual crop — align and crop two images to their overlapping region."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Mutual Crop")
|
||||||
|
class MutualCrop:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field_a": ("DATA_FIELD",),
|
||||||
|
"field_b": ("DATA_FIELD",),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'cropped_a'),
|
||||||
|
('DATA_FIELD', 'cropped_b'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Align two images using cross-correlation and crop both to their "
|
||||||
|
"overlapping region. Useful for comparing images acquired at "
|
||||||
|
"different times or with slight position offsets. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field_a: DataField, field_b: DataField) -> tuple:
|
||||||
|
a = np.asarray(field_a.data, dtype=np.float64)
|
||||||
|
b = np.asarray(field_b.data, dtype=np.float64)
|
||||||
|
|
||||||
|
# Pad to common shape for cross-correlation
|
||||||
|
shape = (max(a.shape[0], b.shape[0]), max(a.shape[1], b.shape[1]))
|
||||||
|
a_pad = np.zeros(shape)
|
||||||
|
b_pad = np.zeros(shape)
|
||||||
|
a_pad[:a.shape[0], :a.shape[1]] = a - a.mean()
|
||||||
|
b_pad[:b.shape[0], :b.shape[1]] = b - b.mean()
|
||||||
|
|
||||||
|
# Cross-correlate to find shift
|
||||||
|
fa = np.fft.fft2(a_pad)
|
||||||
|
fb = np.fft.fft2(b_pad)
|
||||||
|
cc = np.abs(np.fft.ifft2(fa * np.conj(fb)))
|
||||||
|
cc = np.fft.fftshift(cc)
|
||||||
|
cy, cx = np.array(shape) // 2
|
||||||
|
peak = np.unravel_index(np.argmax(cc), shape)
|
||||||
|
dy = peak[0] - cy
|
||||||
|
dx = peak[1] - cx
|
||||||
|
|
||||||
|
# Compute overlap region
|
||||||
|
ay_start = max(0, dy)
|
||||||
|
ay_end = min(a.shape[0], b.shape[0] + dy)
|
||||||
|
ax_start = max(0, dx)
|
||||||
|
ax_end = min(a.shape[1], b.shape[1] + dx)
|
||||||
|
|
||||||
|
by_start = max(0, -dy)
|
||||||
|
by_end = by_start + (ay_end - ay_start)
|
||||||
|
bx_start = max(0, -dx)
|
||||||
|
bx_end = bx_start + (ax_end - ax_start)
|
||||||
|
|
||||||
|
if ay_end <= ay_start or ax_end <= ax_start:
|
||||||
|
# No overlap found, return originals
|
||||||
|
return (field_a, field_b)
|
||||||
|
|
||||||
|
crop_a = a[ay_start:ay_end, ax_start:ax_end]
|
||||||
|
crop_b = b[by_start:by_end, bx_start:bx_end]
|
||||||
|
|
||||||
|
xreal = crop_a.shape[1] * field_a.dx
|
||||||
|
yreal = crop_a.shape[0] * field_a.dy
|
||||||
|
|
||||||
|
return (
|
||||||
|
field_a.replace(data=crop_a, xreal=xreal, yreal=yreal),
|
||||||
|
field_b.replace(data=crop_b, xreal=xreal, yreal=yreal),
|
||||||
|
)
|
||||||
54
backend/nodes/outlier_mask.py
Normal file
54
backend/nodes/outlier_mask.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Outlier masking — mark statistical outlier pixels."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
from backend.nodes.helpers import bool_to_mask
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Outlier Mask")
|
||||||
|
class OutlierMask:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"sigma_threshold": ("FLOAT", {"default": 3.0, "min": 1.0, "max": 10.0, "step": 0.1}),
|
||||||
|
"mode": (["both", "high", "low"], {"default": "both"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('IMAGE', 'mask'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Create a mask marking pixels that deviate more than N standard "
|
||||||
|
"deviations from the mean. Mode selects whether to flag high outliers, "
|
||||||
|
"low outliers, or both. Quick way to identify noise spikes and defects. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, sigma_threshold: float, mode: str) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
mean = data.mean()
|
||||||
|
std = data.std()
|
||||||
|
|
||||||
|
if std < 1e-30:
|
||||||
|
return (bool_to_mask(np.zeros(data.shape, dtype=bool)),)
|
||||||
|
|
||||||
|
z = (data - mean) / std
|
||||||
|
|
||||||
|
if mode == "both":
|
||||||
|
outliers = np.abs(z) > sigma_threshold
|
||||||
|
elif mode == "high":
|
||||||
|
outliers = z > sigma_threshold
|
||||||
|
elif mode == "low":
|
||||||
|
outliers = z < -sigma_threshold
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown mode: {mode!r}")
|
||||||
|
|
||||||
|
return (bool_to_mask(outliers),)
|
||||||
97
backend/nodes/perspective_correction.py
Normal file
97
backend/nodes/perspective_correction.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Perspective correction — fix perspective distortion using a projective transform."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Perspective Correction")
|
||||||
|
class PerspectiveCorrection:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"top_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"top_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"top_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"top_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"bottom_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"bottom_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"bottom_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||||
|
"bottom_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'corrected'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Fix perspective distortion by specifying corner offsets. Each corner "
|
||||||
|
"can be shifted by a fractional amount (relative to image size) to "
|
||||||
|
"define the distorted quadrilateral. The image is then warped back to "
|
||||||
|
"a rectangle."
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField,
|
||||||
|
top_left_x: float, top_left_y: float,
|
||||||
|
top_right_x: float, top_right_y: float,
|
||||||
|
bottom_left_x: float, bottom_left_y: float,
|
||||||
|
bottom_right_x: float, bottom_right_y: float) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
# Source corners (distorted) as fractional offsets from ideal corners
|
||||||
|
src = np.array([
|
||||||
|
[top_left_y * yres, top_left_x * xres],
|
||||||
|
[top_right_y * yres, top_right_x * xres + (xres - 1)],
|
||||||
|
[(1 + bottom_left_y) * yres - 1, bottom_left_x * xres],
|
||||||
|
[(1 + bottom_right_y) * yres - 1, bottom_right_x * xres + (xres - 1)],
|
||||||
|
], dtype=np.float64)
|
||||||
|
|
||||||
|
# Destination corners (ideal rectangle)
|
||||||
|
dst = np.array([
|
||||||
|
[0, 0],
|
||||||
|
[0, xres - 1],
|
||||||
|
[yres - 1, 0],
|
||||||
|
[yres - 1, xres - 1],
|
||||||
|
], dtype=np.float64)
|
||||||
|
|
||||||
|
# Solve for perspective transform matrix (3x3)
|
||||||
|
H = _solve_perspective(src, dst)
|
||||||
|
|
||||||
|
# Apply inverse warp
|
||||||
|
yy, xx = np.mgrid[:yres, :xres]
|
||||||
|
coords = np.stack([yy.ravel(), xx.ravel(), np.ones(yres * xres)])
|
||||||
|
src_coords = H @ coords
|
||||||
|
src_coords /= src_coords[2:3, :]
|
||||||
|
sy = src_coords[0].reshape(yres, xres)
|
||||||
|
sx = src_coords[1].reshape(yres, xres)
|
||||||
|
|
||||||
|
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||||
|
return (field.replace(data=result),)
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
|
||||||
|
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp)."""
|
||||||
|
n = len(src)
|
||||||
|
A = np.zeros((2 * n, 8))
|
||||||
|
b = np.zeros(2 * n)
|
||||||
|
for i in range(n):
|
||||||
|
dy, dx = dst[i]
|
||||||
|
sy, sx = src[i]
|
||||||
|
A[2 * i] = [dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy]
|
||||||
|
A[2 * i + 1] = [0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy]
|
||||||
|
b[2 * i] = sx
|
||||||
|
b[2 * i + 1] = sy
|
||||||
|
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
||||||
|
H = np.array([[h[0], h[1], h[2]],
|
||||||
|
[h[3], h[4], h[5]],
|
||||||
|
[h[6], h[7], 1.0]])
|
||||||
|
return H
|
||||||
58
backend/nodes/pixel_binning.py
Normal file
58
backend/nodes/pixel_binning.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Pixel binning — downsample by averaging NxN pixel blocks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Pixel Binning")
|
||||||
|
class PixelBinning:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"bin_size": ("INT", {"default": 2, "min": 2, "max": 32, "step": 1}),
|
||||||
|
"method": (["mean", "sum", "median"], {"default": "mean"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'binned'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Downsample by grouping NxN pixel blocks and computing their mean, "
|
||||||
|
"sum, or median. Faster and more controlled than interpolation-based "
|
||||||
|
"resampling. Pixels that don't fill a complete block are trimmed. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, bin_size: int, method: str) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
# Trim to multiple of bin_size
|
||||||
|
ny = (yres // bin_size) * bin_size
|
||||||
|
nx = (xres // bin_size) * bin_size
|
||||||
|
trimmed = data[:ny, :nx]
|
||||||
|
|
||||||
|
# Reshape into blocks
|
||||||
|
blocks = trimmed.reshape(ny // bin_size, bin_size, nx // bin_size, bin_size)
|
||||||
|
|
||||||
|
if method == "mean":
|
||||||
|
result = blocks.mean(axis=(1, 3))
|
||||||
|
elif method == "sum":
|
||||||
|
result = blocks.sum(axis=(1, 3))
|
||||||
|
elif method == "median":
|
||||||
|
result = np.median(blocks, axis=(1, 3))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown method: {method!r}")
|
||||||
|
|
||||||
|
# Update physical dimensions
|
||||||
|
new_xreal = field.dx * nx
|
||||||
|
new_yreal = field.dy * ny
|
||||||
|
return (field.replace(data=result, xreal=new_xreal, yreal=new_yreal),)
|
||||||
60
backend/nodes/poly_distort.py
Normal file
60
backend/nodes/poly_distort.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Polynomial distortion correction — correct nonlinear scanner distortions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Polynomial Distortion")
|
||||||
|
class PolynomialDistortion:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"k1_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
|
||||||
|
"k1_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
|
||||||
|
"k2_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
|
||||||
|
"k2_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
|
||||||
|
"k3_x": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001}),
|
||||||
|
"k3_y": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'corrected'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Correct nonlinear scanner distortions with polynomial coordinate "
|
||||||
|
"warping up to cubic order. Coefficients k1 (linear correction), "
|
||||||
|
"k2 (quadratic), k3 (cubic) are applied independently to x and y axes. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField,
|
||||||
|
k1_x: float, k1_y: float,
|
||||||
|
k2_x: float, k2_y: float,
|
||||||
|
k3_x: float, k3_y: float) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
# Normalised coordinates [-1, 1]
|
||||||
|
yy, xx = np.mgrid[:yres, :xres]
|
||||||
|
xn = 2.0 * xx / max(xres - 1, 1) - 1.0
|
||||||
|
yn = 2.0 * yy / max(yres - 1, 1) - 1.0
|
||||||
|
|
||||||
|
# Apply polynomial distortion (inverse mapping)
|
||||||
|
xn_src = xn + k1_x * xn + k2_x * xn**2 + k3_x * xn**3
|
||||||
|
yn_src = yn + k1_y * yn + k2_y * yn**2 + k3_y * yn**3
|
||||||
|
|
||||||
|
# Convert back to pixel coordinates
|
||||||
|
sx = (xn_src + 1.0) * max(xres - 1, 1) / 2.0
|
||||||
|
sy = (yn_src + 1.0) * max(yres - 1, 1) / 2.0
|
||||||
|
|
||||||
|
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||||
|
return (field.replace(data=result),)
|
||||||
@@ -24,8 +24,7 @@ class PSDF:
|
|||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Compute the two-dimensional power spectral density function with Gwyddion-style "
|
"Compute the two-dimensional power spectral density function with Gwyddion-style "
|
||||||
"window RMS compensation and centered zero frequency. Equivalent to psdf2d / "
|
"window RMS compensation and centered zero frequency."
|
||||||
"gwy_data_field_2dpsdf."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(self, field: DataField, windowing: str, level: str) -> tuple:
|
def process(self, field: DataField, windowing: str, level: str) -> tuple:
|
||||||
|
|||||||
79
backend/nodes/psdf_log_polar.py
Normal file
79
backend/nodes/psdf_log_polar.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Log-polar PSDF — power spectral density in log-polar coordinates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Log-Polar PSDF")
|
||||||
|
class LogPolarPSDF:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"n_phi": ("INT", {"default": 180, "min": 36, "max": 720, "step": 1}),
|
||||||
|
"n_r": ("INT", {"default": 100, "min": 20, "max": 500, "step": 1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'psdf'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Compute the power spectral density function in log-polar coordinates. "
|
||||||
|
"The x-axis is the azimuthal angle (0–360°) and y-axis is log(frequency). "
|
||||||
|
"Better than Cartesian PSDF for anisotropy analysis. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, n_phi: int, n_r: int) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
# Compute 2D power spectrum
|
||||||
|
fft = np.fft.fft2(data - data.mean())
|
||||||
|
power = np.abs(np.fft.fftshift(fft))**2
|
||||||
|
|
||||||
|
cy, cx = yres // 2, xres // 2
|
||||||
|
|
||||||
|
# Build log-polar grid
|
||||||
|
r_max = min(cx, cy)
|
||||||
|
log_r = np.linspace(0, np.log(r_max), n_r)
|
||||||
|
phi = np.linspace(0, 2 * np.pi, n_phi, endpoint=False)
|
||||||
|
|
||||||
|
result = np.zeros((n_r, n_phi))
|
||||||
|
|
||||||
|
for ir in range(n_r):
|
||||||
|
r = np.exp(log_r[ir])
|
||||||
|
for ip in range(n_phi):
|
||||||
|
fx = cx + r * np.cos(phi[ip])
|
||||||
|
fy = cy + r * np.sin(phi[ip])
|
||||||
|
# Bilinear interpolation
|
||||||
|
ix = int(fx)
|
||||||
|
iy = int(fy)
|
||||||
|
if 0 <= ix < xres - 1 and 0 <= iy < yres - 1:
|
||||||
|
dx = fx - ix
|
||||||
|
dy = fy - iy
|
||||||
|
val = (power[iy, ix] * (1 - dx) * (1 - dy) +
|
||||||
|
power[iy, ix + 1] * dx * (1 - dy) +
|
||||||
|
power[iy + 1, ix] * (1 - dx) * dy +
|
||||||
|
power[iy + 1, ix + 1] * dx * dy)
|
||||||
|
result[ir, ip] = val
|
||||||
|
|
||||||
|
# Log scale for display
|
||||||
|
result = np.log1p(result)
|
||||||
|
|
||||||
|
psdf_field = DataField(
|
||||||
|
data=result,
|
||||||
|
xreal=360.0,
|
||||||
|
yreal=float(np.log(r_max)),
|
||||||
|
si_unit_xy="deg",
|
||||||
|
si_unit_z="",
|
||||||
|
domain="frequency",
|
||||||
|
)
|
||||||
|
return (psdf_field,)
|
||||||
@@ -28,7 +28,6 @@ class RadialProfile:
|
|||||||
"Compute the azimuthally averaged radial profile from a centre point. "
|
"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). "
|
"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. "
|
"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:
|
def process(self, field: DataField, cx: float, cy: float, n_bins: int) -> tuple:
|
||||||
|
|||||||
98
backend/nodes/relate_fields.py
Normal file
98
backend/nodes/relate_fields.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Relate two fields — fit functional relationships between two data fields."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField, RecordTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Relate Fields")
|
||||||
|
class RelateFields:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field_a": ("DATA_FIELD",),
|
||||||
|
"field_b": ("DATA_FIELD",),
|
||||||
|
"function": (["linear", "quadratic", "cubic", "power", "logarithmic"],
|
||||||
|
{"default": "linear"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'predicted'),
|
||||||
|
('RECORD_TABLE', 'fit_params'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Fit a functional relationship between two data fields: b = f(a). "
|
||||||
|
"Outputs the predicted field_b from the fit and a table of fitted "
|
||||||
|
"parameters with R² goodness-of-fit. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field_a: DataField, field_b: DataField,
|
||||||
|
function: str) -> tuple:
|
||||||
|
a = np.asarray(field_a.data, dtype=np.float64).ravel()
|
||||||
|
b = np.asarray(field_b.data, dtype=np.float64).ravel()
|
||||||
|
n = min(len(a), len(b))
|
||||||
|
a, b = a[:n], b[:n]
|
||||||
|
|
||||||
|
records = RecordTable()
|
||||||
|
|
||||||
|
if function == "linear":
|
||||||
|
coeffs = np.polyfit(a, b, 1)
|
||||||
|
predicted = np.polyval(coeffs, a)
|
||||||
|
records.append({"quantity": "slope", "value": f"{coeffs[0]:.6g}", "unit": ""})
|
||||||
|
records.append({"quantity": "intercept", "value": f"{coeffs[1]:.6g}", "unit": ""})
|
||||||
|
|
||||||
|
elif function == "quadratic":
|
||||||
|
coeffs = np.polyfit(a, b, 2)
|
||||||
|
predicted = np.polyval(coeffs, a)
|
||||||
|
for i, name in enumerate(["a2", "a1", "a0"]):
|
||||||
|
records.append({"quantity": name, "value": f"{coeffs[i]:.6g}", "unit": ""})
|
||||||
|
|
||||||
|
elif function == "cubic":
|
||||||
|
coeffs = np.polyfit(a, b, 3)
|
||||||
|
predicted = np.polyval(coeffs, a)
|
||||||
|
for i, name in enumerate(["a3", "a2", "a1", "a0"]):
|
||||||
|
records.append({"quantity": name, "value": f"{coeffs[i]:.6g}", "unit": ""})
|
||||||
|
|
||||||
|
elif function == "power":
|
||||||
|
# b = c * a^n → log(b) = log(c) + n*log(a)
|
||||||
|
valid = (a > 0) & (b > 0)
|
||||||
|
if valid.sum() < 2:
|
||||||
|
predicted = np.zeros_like(a)
|
||||||
|
records.append({"quantity": "error", "value": "insufficient positive values", "unit": ""})
|
||||||
|
else:
|
||||||
|
log_coeffs = np.polyfit(np.log(a[valid]), np.log(b[valid]), 1)
|
||||||
|
n_exp = log_coeffs[0]
|
||||||
|
c = np.exp(log_coeffs[1])
|
||||||
|
predicted = np.where(a > 0, c * np.power(a, n_exp), 0)
|
||||||
|
records.append({"quantity": "exponent", "value": f"{n_exp:.6g}", "unit": ""})
|
||||||
|
records.append({"quantity": "coefficient", "value": f"{c:.6g}", "unit": ""})
|
||||||
|
|
||||||
|
elif function == "logarithmic":
|
||||||
|
# b = a0 + a1 * log(a)
|
||||||
|
valid = a > 0
|
||||||
|
if valid.sum() < 2:
|
||||||
|
predicted = np.zeros_like(a)
|
||||||
|
records.append({"quantity": "error", "value": "insufficient positive values", "unit": ""})
|
||||||
|
else:
|
||||||
|
coeffs = np.polyfit(np.log(a[valid]), b[valid], 1)
|
||||||
|
predicted = np.where(a > 0, np.polyval(coeffs, np.log(a)), 0)
|
||||||
|
records.append({"quantity": "log_coeff", "value": f"{coeffs[0]:.6g}", "unit": ""})
|
||||||
|
records.append({"quantity": "intercept", "value": f"{coeffs[1]:.6g}", "unit": ""})
|
||||||
|
else:
|
||||||
|
predicted = np.zeros_like(a)
|
||||||
|
|
||||||
|
# R² statistic
|
||||||
|
ss_res = np.sum((b - predicted)**2)
|
||||||
|
ss_tot = np.sum((b - b.mean())**2)
|
||||||
|
r2 = 1.0 - ss_res / max(ss_tot, 1e-30)
|
||||||
|
records.append({"quantity": "R²", "value": f"{r2:.6f}", "unit": ""})
|
||||||
|
|
||||||
|
pred_field = field_b.replace(data=predicted.reshape(field_b.data.shape))
|
||||||
|
return (pred_field, records)
|
||||||
@@ -27,8 +27,6 @@ class Resample:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Resample a DATA_FIELD to a new pixel resolution while preserving physical dimensions. "
|
"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. "
|
"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}
|
_ORDERS = {"nearest": 0, "linear": 1, "cubic": 3}
|
||||||
|
|||||||
58
backend/nodes/scan_line_reorder.py
Normal file
58
backend/nodes/scan_line_reorder.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Scan line reordering — fix meander, interlace, and deinterlace artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Scan Line Reorder")
|
||||||
|
class ScanLineReorder:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"operation": (["reverse_odd", "reverse_even", "deinterlace_odd",
|
||||||
|
"deinterlace_even", "flip_vertical"], {"default": "reverse_odd"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'result'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Fix scan line ordering artifacts. reverse_odd/even reverses alternate "
|
||||||
|
"rows to correct meander (serpentine) scanning. deinterlace selects only "
|
||||||
|
"odd or even rows and stretches to fill. flip_vertical reverses row order. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, operation: str) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64).copy()
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
if operation == "reverse_odd":
|
||||||
|
data[1::2, :] = data[1::2, ::-1]
|
||||||
|
elif operation == "reverse_even":
|
||||||
|
data[0::2, :] = data[0::2, ::-1]
|
||||||
|
elif operation == "deinterlace_odd":
|
||||||
|
odd_rows = data[1::2, :]
|
||||||
|
# Stretch back to original height via linear interpolation
|
||||||
|
from scipy.ndimage import zoom
|
||||||
|
scale_y = yres / odd_rows.shape[0]
|
||||||
|
data = zoom(odd_rows, (scale_y, 1.0), order=1)
|
||||||
|
elif operation == "deinterlace_even":
|
||||||
|
even_rows = data[0::2, :]
|
||||||
|
from scipy.ndimage import zoom
|
||||||
|
scale_y = yres / even_rows.shape[0]
|
||||||
|
data = zoom(even_rows, (scale_y, 1.0), order=1)
|
||||||
|
elif operation == "flip_vertical":
|
||||||
|
data = data[::-1, :].copy()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown operation: {operation!r}")
|
||||||
|
|
||||||
|
return (field.replace(data=data),)
|
||||||
66
backend/nodes/shade.py
Normal file
66
backend/nodes/shade.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Shaded presentation — render surface with directional lighting."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import sobel
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Shade")
|
||||||
|
class Shade:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"azimuth": ("FLOAT", {"default": 315.0, "min": 0.0, "max": 360.0, "step": 1.0}),
|
||||||
|
"elevation": ("FLOAT", {"default": 45.0, "min": 5.0, "max": 85.0, "step": 1.0}),
|
||||||
|
"blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'shaded'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Render a surface with directional hillshade lighting. "
|
||||||
|
"Azimuth controls the light direction (0=north, 90=east). "
|
||||||
|
"Elevation controls the light angle above the horizon. "
|
||||||
|
"Blend mixes original data (0) with shaded relief (1). "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, azimuth: float, elevation: float,
|
||||||
|
blend: float) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
|
||||||
|
gx = sobel(data, axis=1) / (8.0 * field.dx)
|
||||||
|
gy = sobel(data, axis=0) / (8.0 * field.dy)
|
||||||
|
|
||||||
|
az_rad = np.radians(azimuth)
|
||||||
|
el_rad = np.radians(elevation)
|
||||||
|
|
||||||
|
# Lambertian shading
|
||||||
|
lx = np.cos(el_rad) * np.sin(az_rad)
|
||||||
|
ly = np.cos(el_rad) * np.cos(az_rad)
|
||||||
|
lz = np.sin(el_rad)
|
||||||
|
|
||||||
|
normal_z = 1.0 / np.sqrt(gx**2 + gy**2 + 1.0)
|
||||||
|
normal_x = -gx * normal_z
|
||||||
|
normal_y = -gy * normal_z
|
||||||
|
|
||||||
|
shade = np.clip(normal_x * lx + normal_y * ly + normal_z * lz, 0.0, 1.0)
|
||||||
|
|
||||||
|
# Normalize original data to [0, 1]
|
||||||
|
dmin, dmax = data.min(), data.max()
|
||||||
|
if dmax > dmin:
|
||||||
|
norm_data = (data - dmin) / (dmax - dmin)
|
||||||
|
else:
|
||||||
|
norm_data = np.ones_like(data) * 0.5
|
||||||
|
|
||||||
|
result = (1.0 - blend) * norm_data + blend * shade
|
||||||
|
return (field.replace(data=result, si_unit_z=""),)
|
||||||
@@ -79,7 +79,6 @@ class ShapeFitting:
|
|||||||
"surface data. Outputs either the fitted surface or the residual "
|
"surface data. Outputs either the fitted surface or the residual "
|
||||||
"(original minus fit). Reports fitted parameters including radius "
|
"(original minus fit). Reports fitted parameters including radius "
|
||||||
"of curvature, centre position, etc. "
|
"of curvature, centre position, etc. "
|
||||||
"Equivalent to Gwyddion's fit-shape.c module."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(self, field: DataField, shape: str, output: str) -> tuple:
|
def process(self, field: DataField, shape: str, output: str) -> tuple:
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class SlopeDistribution:
|
|||||||
"'theta' is the inclination angle (0–max°), probability density (1/deg); "
|
"'theta' is the inclination angle (0–max°), probability density (1/deg); "
|
||||||
"'phi' is the azimuthal slope direction (0–360°), weighted by slope² (z/xy)²; "
|
"'phi' is the azimuthal slope direction (0–360°), weighted by slope² (z/xy)²; "
|
||||||
"'gradient' is the gradient magnitude distribution, probability density (1/(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:
|
def process(self, field: DataField, distribution: str, n_bins: int) -> tuple:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class SpotRemoval:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Fill defect pixels (hot pixels, dropouts, scan artifacts) by interpolation. "
|
"Fill defect pixels (hot pixels, dropouts, scan artifacts) by interpolation. "
|
||||||
"The mask defines defect locations. Laplace method solves the 2D Laplace equation "
|
"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(
|
def process(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class Statistics:
|
|||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Compute basic surface statistics: min, max, mean, RMS roughness, median, "
|
"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:
|
def process(self, field: DataField) -> tuple:
|
||||||
|
|||||||
95
backend/nodes/straighten_path.py
Normal file
95
backend/nodes/straighten_path.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Straighten path — extract cross-section along a curved spline path."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Straighten Path")
|
||||||
|
class StraightenPath:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75"}),
|
||||||
|
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5"}),
|
||||||
|
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
|
||||||
|
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'straightened'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Extract a cross-section along an arbitrary curved path defined by "
|
||||||
|
"control points. Points are given as fractional coordinates (0–1). "
|
||||||
|
"The path is interpolated with cubic splines, and data is sampled "
|
||||||
|
"along it with configurable thickness. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, points_x: str, points_y: str,
|
||||||
|
thickness: int, n_samples: int) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
# Parse control points
|
||||||
|
px = [float(v.strip()) * (xres - 1) for v in points_x.split(",") if v.strip()]
|
||||||
|
py = [float(v.strip()) * (yres - 1) for v in points_y.split(",") if v.strip()]
|
||||||
|
|
||||||
|
if len(px) < 2 or len(py) < 2:
|
||||||
|
# Need at least 2 points
|
||||||
|
return (field,)
|
||||||
|
|
||||||
|
n_pts = min(len(px), len(py))
|
||||||
|
px, py = px[:n_pts], py[:n_pts]
|
||||||
|
|
||||||
|
# Parameterize path and interpolate
|
||||||
|
t_ctrl = np.linspace(0, 1, n_pts)
|
||||||
|
t_sample = np.linspace(0, 1, n_samples)
|
||||||
|
|
||||||
|
# Simple cubic interpolation via numpy
|
||||||
|
if n_pts >= 4:
|
||||||
|
from numpy.polynomial.polynomial import Polynomial
|
||||||
|
cx = np.interp(t_sample, t_ctrl, px)
|
||||||
|
cy = np.interp(t_sample, t_ctrl, py)
|
||||||
|
else:
|
||||||
|
cx = np.interp(t_sample, t_ctrl, px)
|
||||||
|
cy = np.interp(t_sample, t_ctrl, py)
|
||||||
|
|
||||||
|
# Sample along path with thickness
|
||||||
|
if thickness <= 1:
|
||||||
|
values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
|
||||||
|
result = values.reshape(1, -1)
|
||||||
|
else:
|
||||||
|
# Compute normals
|
||||||
|
dcx = np.gradient(cx)
|
||||||
|
dcy = np.gradient(cy)
|
||||||
|
length = np.sqrt(dcx**2 + dcy**2)
|
||||||
|
length = np.maximum(length, 1e-10)
|
||||||
|
nx = -dcy / length
|
||||||
|
ny = dcx / length
|
||||||
|
|
||||||
|
offsets = np.linspace(-(thickness - 1) / 2, (thickness - 1) / 2, thickness)
|
||||||
|
result = np.zeros((thickness, n_samples))
|
||||||
|
for i, off in enumerate(offsets):
|
||||||
|
sx = cx + off * nx
|
||||||
|
sy = cy + off * ny
|
||||||
|
result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||||
|
|
||||||
|
# Physical dimensions
|
||||||
|
total_length = 0.0
|
||||||
|
for i in range(1, len(cx)):
|
||||||
|
dx_phys = (cx[i] - cx[i - 1]) * field.dx
|
||||||
|
dy_phys = (cy[i] - cy[i - 1]) * field.dy
|
||||||
|
total_length += np.sqrt(dx_phys**2 + dy_phys**2)
|
||||||
|
|
||||||
|
return (field.replace(data=result, xreal=total_length,
|
||||||
|
yreal=thickness * max(field.dx, field.dy)),)
|
||||||
@@ -98,7 +98,6 @@ class SyntheticSurface:
|
|||||||
"algorithm testing. Patterns: fbm (fractional Brownian motion), "
|
"algorithm testing. Patterns: fbm (fractional Brownian motion), "
|
||||||
"white_noise, lattice (periodic grid), steps (terraced), "
|
"white_noise, lattice (periodic grid), steps (terraced), "
|
||||||
"particles (spherical bumps on flat), flat (zero surface). "
|
"particles (spherical bumps on flat), flat (zero surface). "
|
||||||
"Equivalent to Gwyddion's *_synth.c modules."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class TemplateMatch:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Find a template pattern within a larger data field using normalised cross-correlation. "
|
"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 "
|
"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(
|
def process(
|
||||||
|
|||||||
154
backend/nodes/terrace_fit.py
Normal file
154
backend/nodes/terrace_fit.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Terrace fitting — segment atomic step terraces and extract step heights."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import label, uniform_filter
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField, RecordTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Terrace Fit")
|
||||||
|
class TerraceFit:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"n_terraces": ("INT", {"default": 0, "min": 0, "max": 50}),
|
||||||
|
"broadening": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 20.0, "step": 0.1}),
|
||||||
|
"poly_degree": ("INT", {"default": 0, "min": 0, "max": 3}),
|
||||||
|
"output": (["residual", "fitted", "labels"], {"default": "residual"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'result'),
|
||||||
|
('RECORD_TABLE', 'step_heights'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Segment a surface into flat terraces separated by atomic steps, fit "
|
||||||
|
"a polynomial to each terrace, and extract step heights. "
|
||||||
|
"Set n_terraces=0 for automatic detection via histogram clustering. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, n_terraces: int, broadening: float,
|
||||||
|
poly_degree: int, output: str) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
|
||||||
|
# Smooth data to reduce noise before terrace detection
|
||||||
|
smoothed = uniform_filter(data, size=max(3, int(broadening * 3)))
|
||||||
|
|
||||||
|
if n_terraces <= 0:
|
||||||
|
# Automatic detection: find peaks in height histogram
|
||||||
|
n_terraces = self._auto_detect_terraces(smoothed)
|
||||||
|
|
||||||
|
# Assign each pixel to the nearest terrace level using k-means-like clustering
|
||||||
|
levels = self._cluster_terraces(smoothed, n_terraces)
|
||||||
|
labels = np.zeros_like(data, dtype=np.int32)
|
||||||
|
fitted = np.zeros_like(data)
|
||||||
|
|
||||||
|
terrace_means = []
|
||||||
|
|
||||||
|
# Direct assignment via argmin
|
||||||
|
level_arr = np.array(levels)
|
||||||
|
diffs = np.abs(smoothed[..., np.newaxis] - level_arr[np.newaxis, np.newaxis, :])
|
||||||
|
labels = np.argmin(diffs, axis=-1).astype(np.int32)
|
||||||
|
|
||||||
|
# Fit polynomial per terrace and build fitted surface
|
||||||
|
yy, xx = np.mgrid[:data.shape[0], :data.shape[1]]
|
||||||
|
x_phys = xx * field.dx
|
||||||
|
y_phys = yy * field.dy
|
||||||
|
|
||||||
|
for i in range(len(levels)):
|
||||||
|
terrace_mask = labels == i
|
||||||
|
if terrace_mask.sum() < max(3, (poly_degree + 1) ** 2):
|
||||||
|
fitted[terrace_mask] = data[terrace_mask].mean() if terrace_mask.any() else 0
|
||||||
|
terrace_means.append(float(data[terrace_mask].mean()) if terrace_mask.any() else 0.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if poly_degree == 0:
|
||||||
|
val = data[terrace_mask].mean()
|
||||||
|
fitted[terrace_mask] = val
|
||||||
|
terrace_means.append(float(val))
|
||||||
|
else:
|
||||||
|
# Build Vandermonde matrix for polynomial fit
|
||||||
|
xp = x_phys[terrace_mask]
|
||||||
|
yp = y_phys[terrace_mask]
|
||||||
|
zp = data[terrace_mask]
|
||||||
|
cols = []
|
||||||
|
for py in range(poly_degree + 1):
|
||||||
|
for px in range(poly_degree + 1 - py):
|
||||||
|
cols.append(xp**px * yp**py)
|
||||||
|
A = np.column_stack(cols)
|
||||||
|
coeffs, _, _, _ = np.linalg.lstsq(A, zp, rcond=None)
|
||||||
|
|
||||||
|
# Evaluate on all terrace pixels
|
||||||
|
all_xp = x_phys[terrace_mask]
|
||||||
|
all_yp = y_phys[terrace_mask]
|
||||||
|
val = np.zeros(terrace_mask.sum())
|
||||||
|
idx = 0
|
||||||
|
for py in range(poly_degree + 1):
|
||||||
|
for px in range(poly_degree + 1 - py):
|
||||||
|
val += coeffs[idx] * all_xp**px * all_yp**py
|
||||||
|
idx += 1
|
||||||
|
fitted[terrace_mask] = val
|
||||||
|
terrace_means.append(float(val.mean()))
|
||||||
|
|
||||||
|
# Sort terrace means and compute step heights
|
||||||
|
terrace_means.sort()
|
||||||
|
records = RecordTable()
|
||||||
|
unit = field.si_unit_z
|
||||||
|
for i, mean in enumerate(terrace_means):
|
||||||
|
records.append({"quantity": f"Terrace {i + 1} height", "value": f"{mean:.4g}", "unit": unit})
|
||||||
|
for i in range(1, len(terrace_means)):
|
||||||
|
step = terrace_means[i] - terrace_means[i - 1]
|
||||||
|
records.append({"quantity": f"Step {i}→{i + 1}", "value": f"{step:.4g}", "unit": unit})
|
||||||
|
|
||||||
|
if output == "residual":
|
||||||
|
out_data = data - fitted
|
||||||
|
elif output == "fitted":
|
||||||
|
out_data = fitted
|
||||||
|
else: # labels
|
||||||
|
out_data = labels.astype(np.float64)
|
||||||
|
|
||||||
|
return (field.replace(data=out_data), records)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auto_detect_terraces(data: np.ndarray) -> int:
|
||||||
|
"""Detect number of terraces from histogram peaks."""
|
||||||
|
hist, edges = np.histogram(data.ravel(), bins=256)
|
||||||
|
smoothed = np.convolve(hist, np.ones(5) / 5, mode='same')
|
||||||
|
# Find peaks: local maxima above mean
|
||||||
|
threshold = smoothed.mean()
|
||||||
|
peaks = []
|
||||||
|
for i in range(1, len(smoothed) - 1):
|
||||||
|
if smoothed[i] > smoothed[i - 1] and smoothed[i] > smoothed[i + 1] and smoothed[i] > threshold:
|
||||||
|
peaks.append(i)
|
||||||
|
return max(2, min(len(peaks), 20))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cluster_terraces(data: np.ndarray, k: int) -> list[float]:
|
||||||
|
"""Simple 1D k-means clustering on height values."""
|
||||||
|
flat = data.ravel()
|
||||||
|
# Initialize with evenly spaced percentiles
|
||||||
|
centers = [float(np.percentile(flat, 100 * (i + 0.5) / k)) for i in range(k)]
|
||||||
|
|
||||||
|
for _ in range(50):
|
||||||
|
# Assign
|
||||||
|
center_arr = np.array(centers)
|
||||||
|
dists = np.abs(flat[:, np.newaxis] - center_arr[np.newaxis, :])
|
||||||
|
assignments = np.argmin(dists, axis=1)
|
||||||
|
# Update
|
||||||
|
new_centers = []
|
||||||
|
for i in range(k):
|
||||||
|
members = flat[assignments == i]
|
||||||
|
new_centers.append(float(members.mean()) if len(members) > 0 else centers[i])
|
||||||
|
if np.allclose(centers, new_centers, atol=1e-12):
|
||||||
|
break
|
||||||
|
centers = new_centers
|
||||||
|
|
||||||
|
return sorted(centers)
|
||||||
49
backend/nodes/tilt.py
Normal file
49
backend/nodes/tilt.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Tilt — apply or remove a linear tilt."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Tilt")
|
||||||
|
class Tilt:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"slope_x": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.001}),
|
||||||
|
"slope_y": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.001}),
|
||||||
|
"mode": (["add", "subtract"], {"default": "add"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'tilted'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Apply or subtract a linear tilt (plane). slope_x and slope_y are "
|
||||||
|
"in data units per physical unit (e.g., m/m for height data). "
|
||||||
|
"Use 'subtract' mode to remove a known tilt. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, slope_x: float, slope_y: float,
|
||||||
|
mode: str) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
|
||||||
|
x = np.arange(xres) * field.dx
|
||||||
|
y = np.arange(yres) * field.dy
|
||||||
|
X, Y = np.meshgrid(x, y)
|
||||||
|
|
||||||
|
tilt_plane = slope_x * X + slope_y * Y
|
||||||
|
|
||||||
|
if mode == "subtract":
|
||||||
|
return (field.replace(data=data - tilt_plane),)
|
||||||
|
else:
|
||||||
|
return (field.replace(data=data + tilt_plane),)
|
||||||
@@ -492,7 +492,6 @@ class BlindTipEstimate:
|
|||||||
"threshold: noise floor in metres — start at 0 and increase if tip is too sharp. "
|
"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). "
|
"Output tip has apex=max, edges=0 (same convention as TipModel). "
|
||||||
"Certainty map marks surface pixels where the tip was in unambiguous single contact. "
|
"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(
|
def process(
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class TipDeconvolution:
|
|||||||
" surface[y,x] = min_{dy,dx}[image[y+dy, x+dx] − mytip[dy,dx]] "
|
" surface[y,x] = min_{dy,dx}[image[y+dy, x+dx] − mytip[dy,dx]] "
|
||||||
"Connect the tip output from a TipModel node. "
|
"Connect the tip output from a TipModel node. "
|
||||||
"The tip pixel size must match the image pixel size. "
|
"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:
|
def process(self, field: DataField, tip: DataField) -> tuple:
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ class TipModel:
|
|||||||
"Shapes: parabola — paraboloid with apex radius R; "
|
"Shapes: parabola — paraboloid with apex radius R; "
|
||||||
"cone — sphere-capped cone (radius R, half_angle from tip axis in degrees); "
|
"cone — sphere-capped cone (radius R, half_angle from tip axis in degrees); "
|
||||||
"sphere — ball-on-stick (sphere cap only). "
|
"sphere — ball-on-stick (sphere cap only). "
|
||||||
"Equivalent to gwy_tip_model_preset_create (tip.c / tip_model.c)."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
54
backend/nodes/trimmed_mean.py
Normal file
54
backend/nodes/trimmed_mean.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Trimmed mean filter — mean filter excluding extreme percentiles."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Trimmed Mean")
|
||||||
|
class TrimmedMean:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"radius": ("INT", {"default": 3, "min": 1, "max": 50, "step": 1}),
|
||||||
|
"trim_fraction": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 0.45, "step": 0.01}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'filtered'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Apply a local mean filter that excludes the lowest and highest "
|
||||||
|
"fraction of values in each window. More robust than Gaussian for "
|
||||||
|
"data with outlier spikes. trim_fraction=0 is a plain mean; "
|
||||||
|
"trim_fraction=0.5 approaches the median. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, radius: int, trim_fraction: float) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
yres, xres = data.shape
|
||||||
|
result = np.zeros_like(data)
|
||||||
|
|
||||||
|
padded = np.pad(data, radius, mode='edge')
|
||||||
|
|
||||||
|
for iy in range(yres):
|
||||||
|
for ix in range(xres):
|
||||||
|
window = padded[iy:iy + 2 * radius + 1, ix:ix + 2 * radius + 1].ravel()
|
||||||
|
n = len(window)
|
||||||
|
k = int(n * trim_fraction)
|
||||||
|
if k > 0:
|
||||||
|
sorted_w = np.sort(window)
|
||||||
|
trimmed = sorted_w[k:n - k]
|
||||||
|
else:
|
||||||
|
trimmed = window
|
||||||
|
result[iy, ix] = trimmed.mean()
|
||||||
|
|
||||||
|
return (field.replace(data=result),)
|
||||||
@@ -33,8 +33,7 @@ class WaveletDenoise:
|
|||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Denoise using wavelet coefficient thresholding. BayesShrink adapts the threshold "
|
"Denoise using wavelet coefficient thresholding. BayesShrink adapts the threshold "
|
||||||
"per sub-band; VisuShrink uses a global threshold. Equivalent to applying DWT from "
|
"per sub-band; VisuShrink uses a global threshold."
|
||||||
"Gwyddion dwt.c with coefficient thresholding."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
|
|||||||
56
backend/nodes/wrap_value.py
Normal file
56
backend/nodes/wrap_value.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Value wrapping — rewrap periodic values to different ranges."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Wrap Value")
|
||||||
|
class WrapValue:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"range": (["0_to_360", "neg180_to_180", "0_to_2pi", "neg_pi_to_pi", "custom"],
|
||||||
|
{"default": "0_to_360"}),
|
||||||
|
"custom_min": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.1}),
|
||||||
|
"custom_max": ("FLOAT", {"default": 360.0, "min": -1e6, "max": 1e6, "step": 0.1}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'wrapped'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Rewrap periodic values (phase, angle) to a specified range. "
|
||||||
|
"Preset ranges for degrees and radians, or specify a custom range. "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, range: str,
|
||||||
|
custom_min: float, custom_max: float) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
|
||||||
|
ranges = {
|
||||||
|
"0_to_360": (0.0, 360.0),
|
||||||
|
"neg180_to_180": (-180.0, 180.0),
|
||||||
|
"0_to_2pi": (0.0, 2 * np.pi),
|
||||||
|
"neg_pi_to_pi": (-np.pi, np.pi),
|
||||||
|
}
|
||||||
|
|
||||||
|
if range == "custom":
|
||||||
|
lo, hi = custom_min, custom_max
|
||||||
|
else:
|
||||||
|
lo, hi = ranges[range]
|
||||||
|
|
||||||
|
period = hi - lo
|
||||||
|
if period <= 0:
|
||||||
|
return (field.replace(data=data),)
|
||||||
|
|
||||||
|
wrapped = lo + (data - lo) % period
|
||||||
|
return (field.replace(data=wrapped),)
|
||||||
60
backend/nodes/zero_crossing.py
Normal file
60
backend/nodes/zero_crossing.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Zero crossing detection — edge detection via LoG zero crossings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.ndimage import gaussian_laplace
|
||||||
|
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
|
@register_node(display_name="Zero Crossing")
|
||||||
|
class ZeroCrossing:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"field": ("DATA_FIELD",),
|
||||||
|
"sigma": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 20.0, "step": 0.1}),
|
||||||
|
"threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTPUTS = (
|
||||||
|
('DATA_FIELD', 'edges'),
|
||||||
|
)
|
||||||
|
FUNCTION = "process"
|
||||||
|
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Detect edges by finding zero crossings of the Laplacian of Gaussian "
|
||||||
|
"(LoG). Sigma controls the Gaussian smoothing scale. Threshold filters "
|
||||||
|
"out weak edges (relative to the LoG range). "
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, field: DataField, sigma: float, threshold: float) -> tuple:
|
||||||
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
|
|
||||||
|
# Compute LoG
|
||||||
|
log = gaussian_laplace(data, sigma=sigma)
|
||||||
|
|
||||||
|
# Find zero crossings: adjacent pixels with opposite signs
|
||||||
|
edges = np.zeros_like(data)
|
||||||
|
|
||||||
|
# Horizontal crossings
|
||||||
|
sign_diff_x = log[:, :-1] * log[:, 1:]
|
||||||
|
cross_x = sign_diff_x < 0
|
||||||
|
strength_x = np.abs(log[:, :-1] - log[:, 1:])
|
||||||
|
|
||||||
|
# Vertical crossings
|
||||||
|
sign_diff_y = log[:-1, :] * log[1:, :]
|
||||||
|
cross_y = sign_diff_y < 0
|
||||||
|
strength_y = np.abs(log[:-1, :] - log[1:, :])
|
||||||
|
|
||||||
|
# Apply threshold
|
||||||
|
max_strength = max(strength_x.max(), strength_y.max(), 1e-30)
|
||||||
|
edges[:, :-1] += cross_x & (strength_x > threshold * max_strength)
|
||||||
|
edges[:-1, :] += cross_y & (strength_y > threshold * max_strength)
|
||||||
|
|
||||||
|
result = (edges > 0).astype(np.float64)
|
||||||
|
return (field.replace(data=result, si_unit_z=""),)
|
||||||
31
docs/nodes/Affine Correction.md
Normal file
31
docs/nodes/Affine Correction.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Affine Correction
|
||||||
|
|
||||||
|
Fix geometric distortions from scanner nonlinearity by applying an affine transformation. Corrects shear, anisotropic scaling, and rotation. Equivalent to Gwyddion's correct_affine.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field with geometric distortion |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| corrected | DATA_FIELD | Affine-corrected field |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| shear_x | FLOAT | 0.0 | Horizontal shear factor |
|
||||||
|
| shear_y | FLOAT | 0.0 | Vertical shear factor |
|
||||||
|
| scale_x | FLOAT | 1.0 | Horizontal scale factor |
|
||||||
|
| scale_y | FLOAT | 1.0 | Vertical scale factor |
|
||||||
|
| angle | FLOAT | 0.0 | Rotation angle in degrees |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- An identity transform (shear=0, scale=1, angle=0) returns the original data unchanged.
|
||||||
|
- The output shape matches the input; pixels outside the transformed region are filled by nearest-edge interpolation.
|
||||||
|
- For simple rotation without scaling/shear, use the Rotate node instead.
|
||||||
31
docs/nodes/Deconvolution.md
Normal file
31
docs/nodes/Deconvolution.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Deconvolution
|
||||||
|
|
||||||
|
Restore an image via regularised deconvolution. Assumes the image was blurred by a Gaussian PSF with the given sigma. Equivalent to Gwyddion's deconvolve.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input blurred field |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| restored | DATA_FIELD | Deconvolved (sharpened) field |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| method | dropdown | wiener | Deconvolution method: wiener or richardson_lucy |
|
||||||
|
| sigma | FLOAT | 2.0 | Gaussian PSF sigma in pixels (0.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.
|
||||||
28
docs/nodes/Drift Correction.md
Normal file
28
docs/nodes/Drift Correction.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Drift Correction
|
||||||
|
|
||||||
|
Compensate for thermal or piezo drift between scan lines. Estimates inter-row displacement via cross-correlation and shifts rows to align them. Equivalent to Gwyddion's drift.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field with drift artifacts |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| corrected | DATA_FIELD | Drift-corrected field |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| reference | dropdown | previous_row | Reference for drift estimation: previous_row (local) or mean_row (global average) |
|
||||||
|
| direction | dropdown | horizontal | Drift direction to correct: horizontal or vertical |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Use previous_row for slow, cumulative drift. Use mean_row when drift is more random row-to-row.
|
||||||
|
- Apply after Line Correction for best results.
|
||||||
|
- Vertical direction transposes the correction axis — useful for column-scanned data.
|
||||||
32
docs/nodes/Extend Pad.md
Normal file
32
docs/nodes/Extend Pad.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Extend Pad
|
||||||
|
|
||||||
|
Add configurable borders around a DATA_FIELD using various padding methods. Useful for preparing data for FFT-based operations that suffer from edge artifacts, or for aligning fields of different sizes. Equivalent to Gwyddion's `extend.c` module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field to pad |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| padded | DATA_FIELD | Field with added borders |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| left | INT | 0 | Number of pixels to add on the left edge (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.
|
||||||
29
docs/nodes/Facet Analysis.md
Normal file
29
docs/nodes/Facet Analysis.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Facet Analysis
|
||||||
|
|
||||||
|
Compute the facet orientation distribution of a surface. Outputs a 2D histogram (stereographic projection) where the x-axis is the azimuthal angle and y-axis is the inclination. Equivalent to Gwyddion's facet_analysis.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input surface |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| facet_map | DATA_FIELD | 2D histogram of facet orientations (phi vs theta) |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| n_bins | INT | 180 | Number of azimuthal bins; theta bins = n_bins/4 (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.
|
||||||
34
docs/nodes/Feature Detection.md
Normal file
34
docs/nodes/Feature Detection.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Feature Detection
|
||||||
|
|
||||||
|
Detect edges or corners in a surface using Canny edge detection or Harris corner detection. Outputs a feature map and a table of detected feature locations. Equivalent to Gwyddion's edge/corner detection in filters.c.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input surface |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| result | DATA_FIELD | Feature map (binary edges or corner response) |
|
||||||
|
| features | RECORD_TABLE | Table of detected feature statistics and locations |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| method | dropdown | canny | Detection method: canny (edges) or harris (corners) |
|
||||||
|
| sigma | FLOAT | 1.0 | Gaussian smoothing sigma in pixels (0.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.
|
||||||
28
docs/nodes/Flatten Base.md
Normal file
28
docs/nodes/Flatten Base.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Flatten Base
|
||||||
|
|
||||||
|
Level the flat base of a surface that has raised features (particles, grains). Uses a height percentile threshold to identify base pixels, fits a polynomial to those pixels, and subtracts it. Unlike plane level, this ignores tall features that would bias the fit. Equivalent to Gwyddion's flatten_base.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field with raised features on a tilted base |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| leveled | DATA_FIELD | Field with base leveled |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| threshold_percentile | FLOAT | 30.0 | Height percentile below which pixels are considered base (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.
|
||||||
29
docs/nodes/Fractal Interpolation.md
Normal file
29
docs/nodes/Fractal Interpolation.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Fractal Interpolation
|
||||||
|
|
||||||
|
Fill masked regions using fractal interpolation. Matches the spectral characteristics of the surrounding surface to produce natural-looking infill that preserves texture. Better than Laplace for rough surfaces. Equivalent to Gwyddion's fraccor.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field with regions to fill |
|
||||||
|
| mask | IMAGE | Yes | Binary mask where white (255) marks pixels to interpolate |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| filled | DATA_FIELD | Field with masked regions filled by fractal interpolation |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| iterations | INT | 200 | Number of boundary relaxation iterations (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.
|
||||||
29
docs/nodes/Frequency Split.md
Normal file
29
docs/nodes/Frequency Split.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frequency Split
|
||||||
|
|
||||||
|
Separate a DATA_FIELD into low-frequency and high-frequency components using an FFT-based Gaussian filter. The low-pass output contains smooth, large-scale variations while the high-pass output contains fine detail and noise. Equivalent to Gwyddion's `freq_split.c` module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field to split |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| low_pass | DATA_FIELD | Low-frequency (smoothed) component |
|
||||||
|
| high_pass | DATA_FIELD | High-frequency (detail) component |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| cutoff | FLOAT | 0.1 | Cutoff frequency as a fraction of Nyquist (0.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.
|
||||||
33
docs/nodes/Grain Cross.md
Normal file
33
docs/nodes/Grain Cross.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Grain Cross
|
||||||
|
|
||||||
|
Correlate grain properties between two fields using a shared grain mask. Reports per-grain property pairs and the Pearson correlation coefficient. Equivalent to Gwyddion's grain_cross.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field_a | DATA_FIELD | Yes | First height field |
|
||||||
|
| field_b | DATA_FIELD | Yes | Second height field |
|
||||||
|
| mask | IMAGE | Yes | Binary grain mask (white = grain) |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| correlation | RECORD_TABLE | Per-grain property pairs and Pearson correlation coefficient |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| property_a | dropdown | mean_height | Property to compute from field_a: area, mean_height, max_height, or volume |
|
||||||
|
| property_b | dropdown | max_height | Property to compute from field_b: area, mean_height, max_height, or volume |
|
||||||
|
| min_size | INT | 10 | Minimum grain area in pixels; smaller grains are excluded (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.
|
||||||
31
docs/nodes/Grain Distributions.md
Normal file
31
docs/nodes/Grain Distributions.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Grain Distributions
|
||||||
|
|
||||||
|
Compute a histogram distribution of a grain property from a labeled mask. Supported properties: area, equivalent diameter, mean height, max height, volume, and boundary length. Equivalent to Gwyddion's grain_dist.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input surface data |
|
||||||
|
| mask | IMAGE | Yes | Binary mask defining grain regions |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| distribution | LINE_DATA | Histogram of the selected grain property |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| property | dropdown | area | Grain property to plot: area, equiv_diameter, mean_height, max_height, volume, boundary_length |
|
||||||
|
| n_bins | INT | 30 | Number of histogram bins (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.
|
||||||
29
docs/nodes/Grain Edge.md
Normal file
29
docs/nodes/Grain Edge.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Grain Edge
|
||||||
|
|
||||||
|
Detect grain boundaries from a binary grain mask. Outputs a mask of pixels at grain/non-grain borders using 4-neighbour connectivity. Width controls the boundary thickness in pixels. Equivalent to Gwyddion's grain_edge.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input height field (used for dimension reference) |
|
||||||
|
| mask | IMAGE | Yes | Binary grain mask (white = grain) |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| edge_mask | IMAGE | Binary mask with grain boundary pixels marked |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| width | INT | 1 | Boundary thickness in pixels; values greater than 1 dilate the edge outward (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.
|
||||||
33
docs/nodes/Grain Mark.md
Normal file
33
docs/nodes/Grain Mark.md
Normal file
@@ -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.
|
||||||
28
docs/nodes/Grain Summary.md
Normal file
28
docs/nodes/Grain Summary.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Grain Summary
|
||||||
|
|
||||||
|
Compute aggregate statistics for all grains in a mask: count, density, coverage fraction, mean/median area, total volume, and height statistics. Equivalent to Gwyddion's grain_summary.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input surface data |
|
||||||
|
| mask | IMAGE | Yes | Binary mask defining grain regions |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| summary | RECORD_TABLE | Table of aggregate grain statistics |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| min_size | INT | 10 | Minimum grain size in pixels to include (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.
|
||||||
32
docs/nodes/Hough Transform.md
Normal file
32
docs/nodes/Hough Transform.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Hough Transform
|
||||||
|
|
||||||
|
Detect lines or circles in images using the Hough transform. Returns an accumulator image and a table of detected features. Equivalent to Gwyddion's hough.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field (edge-detected images work best) |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| accumulator | DATA_FIELD | Hough accumulator space |
|
||||||
|
| detections | RECORD_TABLE | Table of detected lines or circles |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| mode | dropdown | lines | Detection mode: lines or circles |
|
||||||
|
| n_peaks | INT | 3 | Number of strongest features to report (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.
|
||||||
30
docs/nodes/Image Stitch.md
Normal file
30
docs/nodes/Image Stitch.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Image Stitch
|
||||||
|
|
||||||
|
Combine two overlapping scans into a single image. Uses cross-correlation to align the images and blends the overlap region. Equivalent to Gwyddion's merge.c / stitch.c modules.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field_a | DATA_FIELD | Yes | First (reference) field |
|
||||||
|
| field_b | DATA_FIELD | Yes | Second field to stitch onto field_a |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| stitched | DATA_FIELD | Combined field |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| direction | dropdown | auto | How field_b is positioned relative to field_a: right, below, or auto (determined by cross-correlation) |
|
||||||
|
| blend | dropdown | linear | Overlap blending: linear (smooth transition) or none (hard cut) |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Auto direction uses cross-correlation to determine whether field_b is to the right or below field_a.
|
||||||
|
- Linear blending produces a smooth transition in the overlap region, avoiding visible seams.
|
||||||
|
- Both fields should have the same pixel size (dx, dy) for correct physical dimensions in the output.
|
||||||
|
- The output field's physical dimensions are updated to reflect the combined area.
|
||||||
29
docs/nodes/Immerse Detail.md
Normal file
29
docs/nodes/Immerse Detail.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Immerse Detail
|
||||||
|
|
||||||
|
Overlay a high-resolution detail scan onto a lower-resolution overview image. Uses sliding-window cross-correlation (coarse + fine search) to automatically locate the detail within the overview. Equivalent to Gwyddion's immerse.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| overview | DATA_FIELD | Yes | Lower-resolution overview field |
|
||||||
|
| detail | DATA_FIELD | Yes | Higher-resolution detail field to place into the overview |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| combined | DATA_FIELD | Overview field with the detail region replaced or blended in |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| blend | dropdown | replace | How to combine the detail with the overview: replace (overwrite) or average (mean of both) |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- If the detail has a different pixel size than the overview, it is resampled (via `scipy.ndimage.zoom`) to match the overview pixel spacing before placement.
|
||||||
|
- Alignment uses a two-pass normalized cross-correlation: a coarse search with stride, followed by a fine pixel-by-pixel search around the best coarse position.
|
||||||
|
- If the detail is larger than the overview in either dimension, the overview is returned unchanged.
|
||||||
|
- The output retains the overview's physical dimensions and metadata.
|
||||||
15
docs/nodes/Journal.md
Normal file
15
docs/nodes/Journal.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Journal
|
||||||
|
|
||||||
|
A rich-text note for recording experimental observations, analysis notes, or workflow documentation. Content is saved as part of the workflow.
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| text | STRING | The journal text content |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The Journal node provides a text area for free-form notes within your workflow.
|
||||||
|
- Content persists when the workflow is saved and loaded.
|
||||||
|
- Use it to document sample preparation details, measurement parameters, or analysis decisions alongside your processing pipeline.
|
||||||
29
docs/nodes/Laplace Interpolation.md
Normal file
29
docs/nodes/Laplace Interpolation.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Laplace Interpolation
|
||||||
|
|
||||||
|
Fill masked (missing) regions by solving the Laplace equation with Dirichlet boundary conditions from surrounding pixels. Produces a smooth, harmonic interpolation without overshooting. Equivalent to Gwyddion's laplace.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field with regions to fill |
|
||||||
|
| mask | IMAGE | Yes | Binary mask where white (255) marks pixels to interpolate |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| filled | DATA_FIELD | Field with masked regions filled by Laplace interpolation |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| iterations | INT | 500 | Number of Jacobi relaxation iterations; more iterations = smoother result (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.
|
||||||
29
docs/nodes/Lattice Measurement.md
Normal file
29
docs/nodes/Lattice Measurement.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Lattice Measurement
|
||||||
|
|
||||||
|
Detect and measure periodic lattice structures from ACF or FFT peak positions. Reports lattice vectors and angles. Equivalent to Gwyddion's measure_lattice.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field with periodic structure |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| correlation | DATA_FIELD | ACF or FFT magnitude used for detection |
|
||||||
|
| measurements | RECORD_TABLE | Detected lattice vectors, spacings, and angle |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| method | dropdown | acf | Detection method: acf (autocorrelation) or fft (Fourier transform) |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- ACF method finds the strongest off-center peaks in the 2D autocorrelation. Works well for real-space periodic structures.
|
||||||
|
- FFT method finds peaks in the power spectrum. Better for weak periodicity or noisy data.
|
||||||
|
- Reports up to two lattice vectors (a, b), their magnitudes, and the angle between them.
|
||||||
|
- For best results, the field should contain at least 3–4 complete periods in each direction.
|
||||||
29
docs/nodes/Level Grains.md
Normal file
29
docs/nodes/Level Grains.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Level Grains
|
||||||
|
|
||||||
|
Shift individual grain regions so they all share a common baseline. Uses the selected reference statistic (mean, median, or minimum) per grain to compute the offset. Useful for consistent grain height comparisons. Equivalent to Gwyddion's level_grains.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input surface data |
|
||||||
|
| mask | IMAGE | Yes | Binary mask defining grain regions |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| leveled | DATA_FIELD | Field with grains shifted to a common baseline |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| reference | dropdown | mean | Per-grain reference: mean, median, or minimum height |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Each connected region in the mask is treated as a separate grain.
|
||||||
|
- The target baseline is the average of all per-grain reference values.
|
||||||
|
- Use "minimum" reference when grains sit on different substrate heights and you want to align their bases.
|
||||||
|
- Combine with Grain Analysis to verify height distributions after leveling.
|
||||||
30
docs/nodes/Log-Polar PSDF.md
Normal file
30
docs/nodes/Log-Polar PSDF.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Log-Polar PSDF
|
||||||
|
|
||||||
|
Compute the power spectral density function in log-polar coordinates. The x-axis is the azimuthal angle (0-360 degrees) and the y-axis is log(frequency). Better than Cartesian PSDF for anisotropy analysis. Equivalent to Gwyddion's psdf_logphi.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input spatial-domain field |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| psdf | DATA_FIELD | Power spectral density in log-polar coordinates (domain=frequency) |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| n_phi | INT | 180 | Number of azimuthal angle bins (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.
|
||||||
30
docs/nodes/MFM Analysis.md
Normal file
30
docs/nodes/MFM Analysis.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# MFM Analysis
|
||||||
|
|
||||||
|
Magnetic Force Microscopy analysis: convert between MFM phase/force gradient and magnetic field quantities using Fourier-domain transfer functions. Equivalent to Gwyddion's mfm_*.c modules.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input MFM data (phase, force gradient, or field) |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| result | DATA_FIELD | Converted MFM quantity |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| operation | dropdown | phase_to_force_gradient | Conversion: phase_to_force_gradient, force_gradient_to_field, charge_density, magnetisation |
|
||||||
|
| lift_height | FLOAT | 50e-9 | Tip-sample separation in metres |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **phase_to_force_gradient**: Convert MFM phase shift to force gradient using the cantilever spring constant relationship.
|
||||||
|
- **force_gradient_to_field**: Recover the magnetic field from force gradient data via Fourier-domain deconvolution. Output unit: A/m.
|
||||||
|
- **charge_density**: Compute effective magnetic charge density. Output unit: A/m².
|
||||||
|
- **magnetisation**: Recover magnetisation from field data.
|
||||||
|
- All operations use the lift height to set the correct spatial frequency transfer function.
|
||||||
29
docs/nodes/Median Background.md
Normal file
29
docs/nodes/Median Background.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Median Background
|
||||||
|
|
||||||
|
Extract background using a local median filter and subtract it. The radius controls the filter window — larger values capture broader background variations. More robust than polynomial leveling for surfaces with sparse tall features. Equivalent to Gwyddion's median-bg.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field | DATA_FIELD | Yes | Input field |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| result | DATA_FIELD | Background-subtracted field or extracted background |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| radius | INT | 20 | Half-size of the median filter window in pixels; the full window is (2×radius+1)² (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.
|
||||||
31
docs/nodes/Multiple Profiles.md
Normal file
31
docs/nodes/Multiple Profiles.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Multiple Profiles
|
||||||
|
|
||||||
|
Extract and compare line profiles from two fields along a chosen row or column. Supports overlay, mean, and difference modes. Equivalent to Gwyddion's multiprofile.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field_a | DATA_FIELD | Yes | First input field |
|
||||||
|
| field_b | DATA_FIELD | Yes | Second input field |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| profile | LINE_DATA | Resulting line profile |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| row | INT | -1 | Row (horizontal) or column (vertical) index to extract; -1 uses the centre row/column (-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.
|
||||||
28
docs/nodes/Mutual Crop.md
Normal file
28
docs/nodes/Mutual Crop.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Mutual Crop
|
||||||
|
|
||||||
|
Align two images using FFT cross-correlation and crop both to their overlapping region. Useful for comparing images acquired at different times or with slight position offsets. Equivalent to Gwyddion's mcrop.c module.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| field_a | DATA_FIELD | Yes | First input field |
|
||||||
|
| field_b | DATA_FIELD | Yes | Second input field |
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| cropped_a | DATA_FIELD | Field A cropped to the overlapping region |
|
||||||
|
| cropped_b | DATA_FIELD | Field B cropped to the overlapping region |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
This node has no user-adjustable controls. Alignment is fully automatic.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Both fields are zero-padded to a common shape, mean-subtracted, and cross-correlated in the frequency domain to find the optimal shift.
|
||||||
|
- The overlap region is computed from the detected shift; both output fields are cropped to identical pixel dimensions.
|
||||||
|
- If no valid overlap is found (e.g. completely non-overlapping images), the original fields are returned unchanged.
|
||||||
|
- Physical dimensions (xreal, yreal) of the outputs are updated to reflect the cropped size using field_a's pixel spacing.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user