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 |
|
||||
|---|---------|---------------|-------------|
|
||||
| ~~1~~ | ~~Line Correction~~ | ~~linecorrect.c, linematch.c~~ | ~~Row-by-row median/polynomial alignment. Essential for raw SPM data with scan-line artifacts.~~ **DONE** |
|
||||
| ~~2~~ | ~~Scar Removal~~ | ~~scars.c~~ | ~~Detect and interpolate scan-line defects (horizontal streaks).~~ **DONE** |
|
||||
| ~~3~~ | ~~Facet Leveling~~ | ~~facet-level.c~~ | ~~Orient the dominant surface facet to horizontal. Better than plane level for terraced/stepped surfaces.~~ **DONE** |
|
||||
| ~~4~~ | ~~Morphological Mask Ops~~ | ~~mask_morph.c~~ | ~~Erode, dilate, open, close on grain masks. Needed to clean up thresholded masks.~~ **DONE** |
|
||||
| ~~5~~ | ~~1D FFT Filter~~ | ~~fft_filter_1d.c~~ | ~~Bandpass/lowpass/highpass filtering of LINE profiles.~~ **DONE** |
|
||||
| ~~6~~ | ~~2D FFT Filter~~ | ~~fft_filter_2d.c~~ | ~~Frequency-domain filtering of DATA_FIELDs (remove periodic noise, etc.).~~ **DONE** |
|
||||
| ~~7~~ | ~~Autocorrelation (ACF)~~ | ~~acf2d.c~~ | ~~2D autocorrelation function. Reveals periodic structures and correlation lengths.~~ **DONE** |
|
||||
| ~~8~~ | ~~PSDF~~ | ~~psdf2d.c~~ | ~~Radial/2D power spectral density function. Complementary to ACF for roughness characterization.~~ **DONE** |
|
||||
| ~~9~~ | ~~Fractal Dimension~~ | ~~fractal.c~~ | ~~Multiple methods: partitioning, cube counting, triangulation, PSDF, HHCF. Quantifies surface complexity.~~ **DONE** |
|
||||
| ~~10~~ | ~~Curvature~~ | ~~curvature.c~~ | ~~Quadratic-surface curvature fit with principal radii/directions. Useful for apex and dome characterization.~~ **DONE** |
|
||||
| ~~11~~ | ~~Grain Distance Transform~~ | ~~mask_edt.c~~ | ~~Euclidean distance from grain boundaries. Useful for spatial distribution analysis.~~ **DONE** |
|
||||
| ~~12~~ | ~~Watershed Segmentation~~ | ~~grain_wshed.c~~ | ~~Automatic grain detection without manual threshold. More robust than simple thresholding.~~ **DONE** |
|
||||
| ~~13~~ | ~~Rotate / Flip~~ | ~~rotate.c, basicops.c~~ | ~~Basic geometric transforms (90°, arbitrary angle, mirror).~~ **DONE** |
|
||||
| ~~14~~ | ~~Crop~~ | ~~crop.c~~ | ~~Extract sub-region of a field.~~ **DONE** |
|
||||
All features from the original gap analysis are implemented:
|
||||
|
||||
## Medium Value
|
||||
|
||||
| # | Feature | Gwyddion Source | Description |
|
||||
|---|---------|---------------|-------------|
|
||||
| ~~15~~ | ~~Correlation / Pattern Matching~~ | ~~crosscor.c, maskcor.c~~ | ~~Find repeated features or align images via cross-correlation.~~ **DONE** |
|
||||
| ~~16~~ | ~~Slope Distribution~~ | ~~slope_dist.c~~ | ~~Angular histogram of surface slopes. Characterizes surface texture directionality.~~ **DONE** |
|
||||
| ~~17~~ | ~~Grain Filtering~~ | ~~grain_filter.c~~ | ~~Remove grains by size, height, or border contact. Refine grain masks post-detection.~~ **DONE** |
|
||||
| ~~18~~ | ~~Field Arithmetic~~ | ~~arithmetic.c~~ | ~~Add/subtract/multiply/divide two DATA_FIELDs. Useful for difference maps, normalization.~~ **DONE** |
|
||||
| ~~19~~ | ~~Spot Removal~~ | ~~spotremove.c~~ | ~~Interpolate over selected point defects (dust, spikes).~~ **DONE** |
|
||||
| ~~20~~ | ~~Tip Modeling / Deconvolution~~ | ~~tip_blind.c, tip_model.c~~ | ~~Estimate tip shape from image, deconvolve to recover true surface.~~ **DONE** |
|
||||
| ~~21~~ | ~~Radial Profile~~ | ~~rprofile tool~~ | ~~Azimuthally averaged profile from a center point. Good for circular features.~~ **DONE** |
|
||||
| ~~22~~ | ~~Wavelet Transform~~ | ~~dwt.c, cwt.c~~ | ~~Discrete/continuous wavelet analysis. Multi-scale roughness decomposition.~~ **DONE** |
|
||||
| ~~23~~ | ~~Scale / Resample~~ | ~~scale.c, resample.c~~ | ~~Resize fields with interpolation.~~ **DONE** |
|
||||
| ~~24~~ | ~~Gradient~~ | ~~gradient.c~~ | ~~Compute x/y gradient magnitude maps.~~ **DONE** |
|
||||
| ~~25~~ | ~~Custom Convolution~~ | ~~convolution_filter.c~~ | ~~User-defined kernel convolution.~~ **DONE** |
|
||||
| ~~26~~ | ~~Local Contrast Enhancement~~ | ~~local_contrast.c~~ | ~~Enhance visibility of local features in images.~~ **DONE** |
|
||||
|
||||
## Lower Priority
|
||||
|
||||
| # | Feature | Gwyddion Source | Description |
|
||||
|---|---------|---------------|-------------|
|
||||
| 27 | Drift Correction | drift.c | Compensate for thermal/piezo drift between scan lines. |
|
||||
| 28 | Affine / Perspective Correction | correct_affine.c, correct_perspective.c | Fix geometric distortions from scanner nonlinearity. |
|
||||
| 29 | MFM Analysis | mfm_*.c | Magnetic force microscopy: field calculation, shift finding. |
|
||||
| 30 | Lattice Measurement | measure_lattice.c | Detect and measure periodic lattice structures from ACF/FFT. |
|
||||
| 31 | Hough Transform | hough.c | Detect lines and circles in images. |
|
||||
| 32 | Image Stitching / Merging | merge.c, stitch.c | Combine multiple overlapping scans into one image. |
|
||||
| 33 | Facet Analysis | facet_analysis.c | Orientation distribution of surface facets (stereographic projection). |
|
||||
| 34 | Shape Fitting | fit-shape.c | Fit geometric primitives: sphere, paraboloid, cylinder, etc. |
|
||||
| 35 | Synthetic Surface Generation | *_synth.c (~20 modules) | Generate test surfaces: FBM, noise, lattice, waves, particles, fibers, etc. |
|
||||
| ~~36~~ | ~~Entropy~~ | ~~entropy.c~~ | ~~Information entropy of height distribution.~~ **DONE** |
|
||||
| 37 | Indentation Analysis | indent_analyze.c, hertz.c | Nanoindentation curve fitting (Hertz model). |
|
||||
| 38 | Deconvolution | deconvolve.c | Blind/regularized deconvolution for image restoration. |
|
||||
| 39 | Canny / Harris Detection | filters.c | Corner and edge feature detection beyond basic Sobel/Prewitt. |
|
||||
| ~~40~~ | ~~Kuwahara Filter~~ | ~~filters.c~~ | ~~Edge-preserving smoothing filter.~~ **DONE** |
|
||||
| # | Feature | Gwyddion Source | tono Node |
|
||||
|---|---------|---------------|-----------|
|
||||
| 1 | Line Correction | linecorrect.c, linematch.c | LineCorrection |
|
||||
| 2 | Scar Removal | scars.c | ScarRemoval |
|
||||
| 3 | Facet Leveling | facet-level.c | FacetLevelField |
|
||||
| 4 | Morphological Mask Ops | mask_morph.c | MaskMorphology |
|
||||
| 5 | 1D FFT Filter | fft_filter_1d.c | FFTFilter |
|
||||
| 6 | 2D FFT Filter | fft_filter_2d.c | FFTFilter |
|
||||
| 7 | Autocorrelation (ACF) | acf2d.c | ACF2D |
|
||||
| 8 | PSDF | psdf2d.c | PSDF |
|
||||
| 9 | Fractal Dimension | fractal.c | FractalDimension |
|
||||
| 10 | Curvature | curvature.c | Curvature |
|
||||
| 11 | Grain Distance Transform | mask_edt.c | GrainDistanceTransform |
|
||||
| 12 | Watershed Segmentation | grain_wshed.c | WatershedSegmentation |
|
||||
| 13 | Rotate / Flip | rotate.c, basicops.c | RotateField, FlipField |
|
||||
| 14 | Crop | crop.c | CropResizeField |
|
||||
| 15 | Correlation / Pattern Matching | crosscor.c, maskcor.c | CrossCorrelate, TemplateMatch |
|
||||
| 16 | Slope Distribution | slope_dist.c | SlopeDistribution |
|
||||
| 17 | Grain Filtering | grain_filter.c | GrainFilter |
|
||||
| 18 | Field Arithmetic | arithmetic.c | FieldArithmetic |
|
||||
| 19 | Spot Removal | spotremove.c | SpotRemoval |
|
||||
| 20 | Tip Modeling / Deconvolution | tip_blind.c, tip_model.c | TipModel, TipDeconvolution, BlindTipEstimate |
|
||||
| 21 | Radial Profile | rprofile tool | RadialProfile |
|
||||
| 22 | Wavelet Transform | dwt.c, cwt.c | WaveletDenoise |
|
||||
| 23 | Scale / Resample | scale.c, resample.c | Resample |
|
||||
| 24 | Gradient | gradient.c | Gradient |
|
||||
| 25 | Custom Convolution | convolution_filter.c | CustomConvolution |
|
||||
| 26 | Local Contrast Enhancement | local_contrast.c | LocalContrast |
|
||||
| 27 | Drift Correction | drift.c | DriftCorrection |
|
||||
| 28 | Affine Correction | correct_affine.c | AffineCorrection |
|
||||
| 29 | MFM Analysis | mfm_*.c | MFMAnalysis |
|
||||
| 30 | Lattice Measurement | measure_lattice.c | LatticeMeasurement |
|
||||
| 31 | Hough Transform | hough.c | HoughTransform |
|
||||
| 32 | Image Stitching | merge.c, stitch.c | ImageStitch |
|
||||
| 33 | Facet Analysis | facet_analysis.c | FacetAnalysis |
|
||||
| 34 | Shape Fitting | fit-shape.c | ShapeFitting |
|
||||
| 35 | Synthetic Surface Generation | *_synth.c | SyntheticSurface |
|
||||
| 36 | Entropy | entropy.c | Entropy |
|
||||
| 38 | Deconvolution | deconvolve.c | Deconvolution |
|
||||
| 39 | Canny / Harris Detection | filters.c | FeatureDetection |
|
||||
| 40 | Kuwahara Filter | filters.c | KuwaharaFilter |
|
||||
|
||||
---
|
||||
|
||||
## Already Implemented in tono
|
||||
## Remaining Gaps
|
||||
|
||||
For reference, these Gwyddion equivalents are already covered:
|
||||
### High Value — Core SPM workflow features
|
||||
|
||||
| tono Node | Category | Gwyddion Equivalent |
|
||||
|--------------|----------|-------------------|
|
||||
| Load Image / Load SPM File | io | File import (gwy, sxm, ibw) |
|
||||
| Save Image | io | File export |
|
||||
| Coordinate | io | — |
|
||||
| Rotate Field | modify | rotate.c |
|
||||
| Flip Field | modify | basicops.c |
|
||||
| Plane Level | level | level.c |
|
||||
| Facet Level | level | facet-level.c |
|
||||
| Polynomial Level | level | polylevel.c |
|
||||
| Fix Zero | level | level.c (fix_zero) |
|
||||
| Line Correction | level | linecorrect.c, linematch.c |
|
||||
| Gaussian Filter | filters | filters.c (gaussian) |
|
||||
| Median Filter | filters | filters.c (median) |
|
||||
| Edge Detect | filters | edge.c (sobel, prewitt, laplacian, LoG) |
|
||||
| 1D FFT Filter | filters | fft_filter_1d.c (lowpass, highpass, bandpass, notch) |
|
||||
| 2D FFT Filter | filters | fft_filter_2d.c (lowpass, highpass, bandpass, notch) |
|
||||
| Scar Removal | filters | scars.c |
|
||||
| Statistics | analysis | stats.c |
|
||||
| Curvature | analysis | curvature.c |
|
||||
| Fractal Dimension | analysis | fractal.c |
|
||||
| Height Histogram | analysis | linestats.c (dh) |
|
||||
| 2D FFT | analysis | fft.c |
|
||||
| Cross Section | analysis | profile tool |
|
||||
| Profile Roughness | analysis | roughness.c (Ra, Rq, Rsk, Rku, Rp, Rv, Rt) |
|
||||
| Line Math | analysis | linestats.c |
|
||||
| Threshold Mask | mask | threshold.c, otsu_threshold.c |
|
||||
| Mask Morphology | mask | mask_morph.c (erode, dilate, open, close) |
|
||||
| Mask Invert | mask | — |
|
||||
| Mask Operations | mask | — (boolean logic on two masks: AND, OR, XOR, NAND, NOR, XNOR, implication, etc.) |
|
||||
| Grain Distance Transform | mask | mask_edt.c |
|
||||
| Watershed Segmentation | grains | grain_wshed.c |
|
||||
| Grain Analysis | grains | grain_stat.c |
|
||||
| Preview / 3D View / Print Table | display | Presentation, 3D view |
|
||||
| Tip Model | tip | tip_model.c, tip.c |
|
||||
| Tip Deconvolution | tip | tip_blind.c, tip.c (gwy_tip_erosion) |
|
||||
| Blind Tip Estimate | tip | tip_blind.c, morph_lib.c (gwy_tip_estimate_partial/full + gwy_tip_cmap) |
|
||||
| # | Feature | Gwyddion Source | tono Node | Status |
|
||||
|---|---------|---------------|-----------|--------|
|
||||
| 41 | Terrace Fitting | terracefit.c | TerraceFit | **DONE** |
|
||||
| 42 | Laplace Interpolation | laplace.c | LaplaceInterpolation | **DONE** |
|
||||
| 43 | Fractal Interpolation | fraccor.c | FractalInterpolation | **DONE** |
|
||||
| 44 | Median Background Subtraction | median-bg.c | MedianBackground | **DONE** |
|
||||
| 45 | Flatten Base | flatten_base.c | FlattenBase | **DONE** |
|
||||
| 46 | Level Individual Grains | level_grains.c | LevelGrains | **DONE** |
|
||||
| 47 | Grain Marking by Criteria | grain_mark.c | GrainMark | **DONE** |
|
||||
| 48 | Grain Property Distributions | grain_dist.c | GrainDistributions | **DONE** |
|
||||
| 49 | Grain Summary Statistics | grain_summary.c | GrainSummary | **DONE** |
|
||||
| 50 | Outlier Masking | outliers.c | OutlierMask | **DONE** |
|
||||
| 51 | Scan Line Reordering | reorder.c | ScanLineReorder | **DONE** |
|
||||
|
||||
### Medium Value — Analysis and correction
|
||||
|
||||
| # | Feature | Gwyddion Source | tono Node | Status |
|
||||
|---|---------|---------------|-----------|--------|
|
||||
| 52 | Perspective Correction | correct_perspective.c | PerspectiveCorrection | **DONE** |
|
||||
| 53 | Polynomial Distortion | polydistort.c | PolynomialDistortion | **DONE** |
|
||||
| 54 | Frequency Splitting | freq_split.c | FrequencySplit | **DONE** |
|
||||
| 55 | Phase/Value Wrapping | wrapvalue.c | WrapValue | **DONE** |
|
||||
| 56 | Shaded Presentation | shade.c | Shade | **DONE** |
|
||||
| 57 | Pixel Binning | binning.c | PixelBinning | **DONE** |
|
||||
| 58 | Extend / Pad | extend.c | ExtendPad | **DONE** |
|
||||
| 59 | Tilt | tilt.c | Tilt | **DONE** |
|
||||
| 60 | Trimmed Mean Filter | trimmed-mean.c | TrimmedMean | **DONE** |
|
||||
| 61 | Rank Filter | rank-filter.c | RankFilter | **DONE** |
|
||||
| 62 | Zero Crossing Detection | zero_crossing.c | ZeroCrossing | **DONE** |
|
||||
| 63 | Log-Polar PSDF | psdf_logphi.c | LogPolarPSDF | **DONE** |
|
||||
| 64 | Grain Edge Detection | grain_edge.c | GrainEdge | **DONE** |
|
||||
| 65 | Grain Cross-Correlation | grain_cross.c | GrainCross | **DONE** |
|
||||
| 66 | Mutual Crop | mcrop.c | MutualCrop | **DONE** |
|
||||
| 67 | Immerse Detail | immerse.c | ImmerseDetail | **DONE** |
|
||||
| 68 | Multiple Profiles | multiprofile.c | MultipleProfiles | **DONE** |
|
||||
| 69 | Straighten Path | straighten_path.c | StraightenPath | **DONE** |
|
||||
| 70 | Relate Two Fields | relate.c | RelateFields | **DONE** |
|
||||
|
||||
### SPM Mode-Specific
|
||||
|
||||
| # | Feature | Gwyddion Source | Description |
|
||||
|---|---------|---------------|-------------|
|
||||
| 71 | PFM Analysis | pfm.c | Piezoresponse Force Microscopy: compute in-plane and 3D polarization vectors from VPFM/LPFM amplitude and phase at multiple rotations. |
|
||||
| 72 | Lateral Force Simulation | latsim.c | Simulate topography artifacts in lateral force (friction) channels given friction coefficient, adhesion, and normal load parameters. |
|
||||
| 73 | SEM Simulation | semsim.c | Simulate Scanning Electron Microscopy signal from topography data using integration or Monte Carlo methods. |
|
||||
| 74 | Scanning Microwave Microscopy | smm.c, smm_apply.c | Fit complex reflection coefficients from SMM impedance measurements to extract capacitance and material properties. |
|
||||
| 75 | MFM Current Simulation | mfm_current.c | Simulate current distribution from magnetization for MFM. Extends existing MFMAnalysis node. |
|
||||
| 76 | MFM Domain Generation | mfm_parallel.c | Generate parallel magnetic domain patterns for MFM simulation and testing. |
|
||||
|
||||
### Lower Priority — Specialized or niche
|
||||
|
||||
| # | Feature | Gwyddion Source | Description |
|
||||
|---|---------|---------------|-------------|
|
||||
| 77 | Mark Disconnected Regions | mark_disconn.c | Mask topologically isolated surface regions using threshold and radius criteria. |
|
||||
| 78 | Mask Shift | mask_shift.c | Translate mask by pixel offset in any direction. |
|
||||
| 79 | Mask Noisify | mask_noisify.c | Add random perturbation to mask boundaries. Useful for testing mask sensitivity. |
|
||||
| 80 | DWT Anisotropy | dwtanisotropy.c | Quantify surface anisotropy using discrete wavelet transform decomposition. |
|
||||
| 81 | Displacement Field | displfield.c | Distort images using displacement fields (Gaussian, tear, image-based). Simulates scanning artifacts. |
|
||||
| 82 | Pixel Classification | classify.c | Classify pixels into categories using decision trees on height, slope, and curvature criteria. |
|
||||
| 83 | Neural Network Classification | neural.c | Train and apply neural networks for pixel-level feature classification. |
|
||||
| 84 | Logistic Classification | logistic.c | Classify features using logistic regression on Gaussian derivative features. |
|
||||
| 85 | Super-Resolution | superresolution.c | Combine multiple aligned low-resolution scans to produce a higher-resolution image. |
|
||||
| 86 | PSF Estimation | psf.c, psf-fit.c | Estimate and fit point spread functions from image features for deconvolution. |
|
||||
| 87 | Tip Shape from Features | tipshape.c | Estimate SPM tip shape from known calibration feature convolutions. |
|
||||
| 88 | Presentation Ops | presentationops.c | Manage presentation overlays (extract, attach, remove presentation layers). |
|
||||
| 89 | Calibration Coefficients | calcoefs_*.c, calibrate.c | Load, create, and apply lateral/height calibration corrections. |
|
||||
| 90 | Distribution Coercion | coerce.c | Transform data distribution to match target (uniform, Gaussian, custom). |
|
||||
| 91 | Grain Selection Visualization | grain_makesel.c | Visualize grains as discs, circles, or bounding boxes for selection. |
|
||||
|
||||
### Synthesis — Additional surface generation patterns
|
||||
|
||||
tono's SyntheticSurface node covers fbm, white_noise, lattice, steps, particles, and flat. Gwyddion has 24 separate synthesis modules. These could be added as patterns to the existing SyntheticSurface node:
|
||||
|
||||
| # | Pattern | Gwyddion Source | Description |
|
||||
|---|---------|---------------|-------------|
|
||||
| 92 | Columnar | col_synth.c | Columnar/stripe growth patterns. |
|
||||
| 93 | Objects | obj_synth.c | Random spheres, pyramids, boxes, cylinders on flat surface. |
|
||||
| 94 | Fibres | fibre_synth.c | Randomly oriented fibre/line features. |
|
||||
| 95 | Waves | wave_synth.c | Directional wave/ripple patterns. |
|
||||
| 96 | Dunes | dune_synth.c | Dune-like rippled surfaces. |
|
||||
| 97 | Domains | domain_synth.c | Phase-separated domain/island patterns. |
|
||||
| 98 | Ballistic Deposition | bdep_synth.c | Ballistic deposition growth simulation. |
|
||||
| 99 | Particle Deposition | deposit_synth.c | Particle deposition simulation. |
|
||||
| 100 | Rod Deposition | roddeposit_synth.c | Wire/rod deposition on surfaces. |
|
||||
| 101 | Diffusion Aggregation | diff_synth.c | Diffusion-limited aggregation patterns. |
|
||||
| 102 | Discs | disc_synth.c | Randomly distributed disc features. |
|
||||
| 103 | Plateaus | plateau_synth.c | Flat-topped feature patterns. |
|
||||
| 104 | Pileups | pileup_synth.c | Rounded rectangle pileup structures. |
|
||||
| 105 | Annealing | anneal_synth.c | Simulated annealing surface relaxation. |
|
||||
| 106 | Lattice (Voronoi) | lat_synth.c | Regular lattice with Voronoi-based variations. |
|
||||
| 107 | Phase Separation | phase_synth.c | Spinodal decomposition domain patterns. |
|
||||
| 108 | PDE Patterns | cpde_synth.c | Coupled partial differential equation patterns. |
|
||||
| 109 | Spectral (FFT) | fft_synth.c | Surfaces with customizable power spectrum. |
|
||||
| 110 | Residues | residue_synth.c | Irregular particle/residue deposits. |
|
||||
| 111 | Noise Distributions | lno_synth.c, noise_synth.c | Gaussian, Poisson, exponential, and other noise types. |
|
||||
| 112 | Periodic Patterns | pat_synth.c | Various periodic/modulated tiling patterns. |
|
||||
| 113 | WFR Patterns | wfr_synth.c | Wave-front-related surface patterns. |
|
||||
|
||||
### File Format Support
|
||||
|
||||
Gwyddion supports 155+ file format modules. tono currently handles a smaller set. Major format gaps (not exhaustive):
|
||||
|
||||
| Format | Gwyddion Source | Vendor/Description |
|
||||
|--------|---------------|-------------------|
|
||||
| Bruker Nanoscope | nanoscope.c, nanoscope-ii.c | Bruker/Veeco/DI SPM files |
|
||||
| Park Systems | parkafm.c | Park Systems SPM files |
|
||||
| RHK | rhk-sm4.c, rhk-spm32.c | RHK Technology SPM files |
|
||||
| Omicron | omicron.c, omicronflat.c | Omicron/Scienta SPM files |
|
||||
| Asylum Research | asylum.c | Asylum Research (Igor Pro) |
|
||||
| WITec | witec-asc.c | WITec SPM/Raman files |
|
||||
| JEOL | jeol.c | JEOL SPM files |
|
||||
| ISO 28600 | iso28600.c | Standard SPM exchange format |
|
||||
| Zygo | zygo.c | Zygo surface profiler |
|
||||
| ASCII matrix | asciiexport.c | Generic ASCII grid import/export |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Count | Status |
|
||||
|----------|-------|--------|
|
||||
| Originally tracked (1–40) | 40 | 39 done, 1 excluded (force curves) |
|
||||
| High Value (41–51) | 11 | **All 11 done** |
|
||||
| Medium Value (52–70) | 19 | **All 19 done** |
|
||||
| SPM Mode-Specific (71–76) | 6 | Pending |
|
||||
| Lower Priority (77–91) | 15 | Pending |
|
||||
| Synthesis Patterns (92–113) | 22 | Pending (extend SyntheticSurface) |
|
||||
| File Formats | 10+ | Pending |
|
||||
|
||||
**69 of 70 tracked features implemented.** 43 remaining gaps identified.
|
||||
|
||||
@@ -34,6 +34,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"PrintTable",
|
||||
"Save",
|
||||
"SaveImage",
|
||||
"Shade",
|
||||
],
|
||||
"Overlay": [
|
||||
"Markup",
|
||||
@@ -47,7 +48,13 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"FlipField",
|
||||
"Resample",
|
||||
"AffineCorrection",
|
||||
"PerspectiveCorrection",
|
||||
"PolynomialDistortion",
|
||||
"ImageStitch",
|
||||
"MutualCrop",
|
||||
"ImmerseDetail",
|
||||
"PixelBinning",
|
||||
"ExtendPad",
|
||||
"FieldArithmetic",
|
||||
],
|
||||
"Level & Correct": [
|
||||
@@ -55,10 +62,16 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"PlaneLevelField",
|
||||
"PolyLevelField",
|
||||
"FacetLevelField",
|
||||
"FlattenBase",
|
||||
"LineCorrection",
|
||||
"DriftCorrection",
|
||||
"ScarRemoval",
|
||||
"SpotRemoval",
|
||||
"LaplaceInterpolation",
|
||||
"FractalInterpolation",
|
||||
"ScanLineReorder",
|
||||
"Tilt",
|
||||
"WrapValue",
|
||||
],
|
||||
"Filter": [
|
||||
"GaussianFilter",
|
||||
@@ -68,6 +81,9 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"LocalContrast",
|
||||
"CustomConvolution",
|
||||
"Deconvolution",
|
||||
"MedianBackground",
|
||||
"TrimmedMean",
|
||||
"RankFilter",
|
||||
"Gradient",
|
||||
"EdgeDetect",
|
||||
],
|
||||
@@ -79,6 +95,8 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"ACF2D",
|
||||
"ACF1D",
|
||||
"PSDF",
|
||||
"LogPolarPSDF",
|
||||
"FrequencySplit",
|
||||
"CrossCorrelate",
|
||||
],
|
||||
"Measure": [
|
||||
@@ -88,12 +106,16 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"Stats",
|
||||
"Curvature",
|
||||
"ShapeFitting",
|
||||
"TerraceFit",
|
||||
"FractalDimension",
|
||||
"Entropy",
|
||||
"SlopeDistribution",
|
||||
"RadialProfile",
|
||||
"LatticeMeasurement",
|
||||
"AngleMeasure",
|
||||
"MultipleProfiles",
|
||||
"StraightenPath",
|
||||
"RelateFields",
|
||||
],
|
||||
"Detect": [
|
||||
"FeatureDetection",
|
||||
@@ -101,10 +123,13 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"TemplateMatch",
|
||||
"FacetAnalysis",
|
||||
"MFMAnalysis",
|
||||
"ZeroCrossing",
|
||||
],
|
||||
"Mask": [
|
||||
"DrawMask",
|
||||
"ThresholdMask",
|
||||
"GrainMark",
|
||||
"OutlierMask",
|
||||
"MaskMorphology",
|
||||
"MaskInvert",
|
||||
"MaskOperations",
|
||||
@@ -114,6 +139,11 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"WatershedSegmentation",
|
||||
"GrainAnalysis",
|
||||
"GrainFilter",
|
||||
"GrainDistributions",
|
||||
"GrainSummary",
|
||||
"LevelGrains",
|
||||
"GrainEdge",
|
||||
"GrainCross",
|
||||
],
|
||||
"Tip": [
|
||||
"TipModel",
|
||||
|
||||
@@ -33,7 +33,6 @@ class AffineCorrection:
|
||||
"Apply an affine correction to fix geometric distortions from scanner "
|
||||
"nonlinearity. Parameters specify shear, scale, and rotation corrections. "
|
||||
"The transform is applied about the centre of the field. "
|
||||
"Equivalent to Gwyddion's correct_affine.c module."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -27,7 +27,7 @@ class CrossCorrelate:
|
||||
DESCRIPTION = (
|
||||
"Compute 2D cross-correlation between two fields. The correlation peak indicates "
|
||||
"the offset where the two fields best match. Useful for drift measurement and feature "
|
||||
"alignment. Equivalent to Gwyddion crosscor.c."
|
||||
"alignment."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -36,7 +36,6 @@ class CrossSection:
|
||||
DESCRIPTION = (
|
||||
"Extract a cross-section profile along a line between two points. "
|
||||
"Drag the markers on the image to set the line endpoints. "
|
||||
"Equivalent to gwy_data_field_get_profile."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -32,7 +32,6 @@ class Deconvolution:
|
||||
"blurred by a Gaussian PSF with the given sigma (in pixels). "
|
||||
"Wiener filtering is fast and works in one pass. "
|
||||
"Richardson-Lucy is iterative and preserves positivity. "
|
||||
"Equivalent to Gwyddion's deconvolve.c module."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -58,7 +58,6 @@ class DriftCorrection:
|
||||
"Compensate for thermal or piezo drift between scan lines. "
|
||||
"Cross-correlates each row (or column) against a reference to estimate "
|
||||
"the drift offset, then shifts lines to correct. "
|
||||
"Equivalent to Gwyddion's drift.c module."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, reference: str, direction: str) -> tuple:
|
||||
|
||||
@@ -23,7 +23,6 @@ class EdgeDetect:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Detect edges using Sobel, Prewitt, Laplacian, or LoG operators. "
|
||||
"Equivalent to gwy_data_field_filter_sobel / gwy_data_field_filter_laplacian."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, method: str, sigma: float) -> tuple:
|
||||
|
||||
@@ -27,7 +27,7 @@ class Entropy:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Shannon entropy of the height or slope distribution. "
|
||||
"H = -\u03a3 p\u00b7ln(p). Equivalent to Gwyddion entropy.c."
|
||||
"H = -\u03a3 p\u00b7ln(p)."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mode: str, n_bins: int) -> tuple:
|
||||
|
||||
64
backend/nodes/extend_pad.py
Normal file
64
backend/nodes/extend_pad.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Extend / pad — add borders to a field with various fill methods."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Extend / Pad")
|
||||
class ExtendPad:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"top": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
|
||||
"bottom": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
|
||||
"left": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
|
||||
"right": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 1}),
|
||||
"method": (["mean", "edge", "mirror", "periodic", "zero"],
|
||||
{"default": "mirror"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'padded'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Add borders to a field with configurable padding method. "
|
||||
"Mirror and periodic modes avoid edge discontinuities for FFT. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, top: int, bottom: int,
|
||||
left: int, right: int, method: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
mode_map = {
|
||||
"mean": "constant",
|
||||
"edge": "edge",
|
||||
"mirror": "reflect",
|
||||
"periodic": "wrap",
|
||||
"zero": "constant",
|
||||
}
|
||||
np_mode = mode_map.get(method, "constant")
|
||||
|
||||
kwargs = {}
|
||||
if method == "mean":
|
||||
kwargs["constant_values"] = data.mean()
|
||||
elif method == "zero":
|
||||
kwargs["constant_values"] = 0.0
|
||||
|
||||
result = np.pad(data, ((top, bottom), (left, right)), mode=np_mode, **kwargs)
|
||||
|
||||
new_xreal = result.shape[1] * field.dx
|
||||
new_yreal = result.shape[0] * field.dy
|
||||
new_xoff = field.xoff - left * field.dx
|
||||
new_yoff = field.yoff - top * field.dy
|
||||
|
||||
return (field.replace(data=result, xreal=new_xreal, yreal=new_yreal,
|
||||
xoff=new_xoff, yoff=new_yoff),)
|
||||
@@ -30,7 +30,6 @@ class FacetAnalysis:
|
||||
"Outputs a 2D histogram (stereographic projection) where the x-axis "
|
||||
"is the azimuthal angle (phi) and y-axis is the inclination (theta). "
|
||||
"Intensity represents how much surface area faces each orientation. "
|
||||
"Equivalent to Gwyddion's facet_analysis.c module."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, n_bins: int, kernel_size: int) -> tuple:
|
||||
|
||||
@@ -38,7 +38,6 @@ class FeatureDetection:
|
||||
"Canny: multi-stage edge detector with hysteresis thresholding. "
|
||||
"Harris: corner/interest point detector based on structure tensor. "
|
||||
"Outputs a feature map and a table of detected feature locations. "
|
||||
"Equivalent to Gwyddion's edge/corner detection in filters.c."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -32,7 +32,6 @@ class FFT2D:
|
||||
DESCRIPTION = (
|
||||
"Compute the 2D FFT with optional windowing and mean/plane subtraction. "
|
||||
"Outputs log magnitude, magnitude, phase, and PSDF as separate channels. "
|
||||
"Equivalent to gwy_data_field_2dfft / gwy_data_field_2dpsdf."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, windowing: str, level: str) -> tuple:
|
||||
|
||||
@@ -27,8 +27,6 @@ class FieldArithmetic:
|
||||
"Apply a point-wise arithmetic operation to two DATA_FIELDs of the same resolution. "
|
||||
"add/subtract/multiply/divide/min/max perform element-wise operations; "
|
||||
"hypot computes sqrt(a² + b²) per pixel. "
|
||||
"Equivalent to gwy_data_field_sum_fields / subtract_fields / multiply_fields / "
|
||||
"divide_fields / min_of_fields / max_of_fields / hypot_of_fields in arithmetic.c."
|
||||
)
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField, operation: str) -> tuple:
|
||||
|
||||
@@ -97,7 +97,6 @@ class CustomConvolution:
|
||||
"Apply a user-defined convolution kernel. "
|
||||
"Enter rows of space-separated numbers. "
|
||||
"Example sharpen: '0 -1 0 / -1 5 -1 / 0 -1 0' (use newlines, not slashes). "
|
||||
"Equivalent to Gwyddion convolution_filter.c."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -11,7 +11,7 @@ class FFTFilter:
|
||||
|
||||
Accepts either a LINE or DATA_FIELD and returns a filtered output of the
|
||||
same type. Uses a Butterworth transfer function with configurable order
|
||||
for a smooth roll-off. Equivalent to Gwyddion fft_filter_1d / fft_filter_2d.
|
||||
for a smooth roll-off.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -19,8 +19,8 @@ class GaussianFilter:
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = "Apply a Gaussian blur. Equivalent to gwy_data_field_filter_gaussian."
|
||||
|
||||
DESCRIPTION = "Apply a Gaussian blur."
|
||||
|
||||
def process(self, field: DataField, sigma: float) -> tuple:
|
||||
from scipy.ndimage import gaussian_filter
|
||||
data = gaussian_filter(field.data, sigma=float(sigma))
|
||||
|
||||
@@ -73,9 +73,10 @@ class KuwaharaFilter:
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Edge-preserving smoothing using Kuwahara's minimum-variance quadrant method. "
|
||||
"Unlike Gaussian blur, sharp boundaries are preserved. "
|
||||
"Equivalent to Gwyddion's Kuwahara filter."
|
||||
"""
|
||||
Edge-preserving smoothing using Kuwahara's minimum-variance quadrant method.
|
||||
"Unlike Gaussian blur, sharp boundaries are preserved.
|
||||
"""
|
||||
)
|
||||
|
||||
def process(self, field: DataField, iterations: int) -> tuple:
|
||||
|
||||
@@ -19,7 +19,7 @@ class MedianFilter:
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = "Apply a median filter. Equivalent to gwy_data_field_filter_median."
|
||||
DESCRIPTION = "Apply a median filter."
|
||||
|
||||
def process(self, field: DataField, size: int) -> tuple:
|
||||
from scipy.ndimage import median_filter
|
||||
|
||||
53
backend/nodes/filter_rank.py
Normal file
53
backend/nodes/filter_rank.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Rank filter — general k-th rank filter for morphological operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import percentile_filter, minimum_filter, maximum_filter, median_filter
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Rank Filter")
|
||||
class RankFilter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"operation": (["erosion", "dilation", "median", "percentile"],
|
||||
{"default": "median"}),
|
||||
"radius": ("INT", {"default": 3, "min": 1, "max": 50, "step": 1}),
|
||||
"percentile": ("FLOAT", {"default": 50.0, "min": 0.0, "max": 100.0, "step": 1.0}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'filtered'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Apply rank-based morphological filtering. Erosion selects the local "
|
||||
"minimum (shrinks features), dilation the local maximum (grows features), "
|
||||
"median the 50th percentile. Custom percentile allows any rank. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, operation: str, radius: int,
|
||||
percentile: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
size = 2 * radius + 1
|
||||
|
||||
if operation == "erosion":
|
||||
result = minimum_filter(data, size=size)
|
||||
elif operation == "dilation":
|
||||
result = maximum_filter(data, size=size)
|
||||
elif operation == "median":
|
||||
result = median_filter(data, size=size)
|
||||
elif operation == "percentile":
|
||||
result = percentile_filter(data, percentile=percentile, size=size)
|
||||
else:
|
||||
raise ValueError(f"Unknown operation: {operation!r}")
|
||||
|
||||
return (field.replace(data=result),)
|
||||
@@ -22,7 +22,6 @@ class FixZero:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Shift data so that the minimum (or mean/median) is zero. "
|
||||
"Equivalent to fix_zero in Gwyddion's level.c."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, method: str) -> tuple:
|
||||
|
||||
67
backend/nodes/flatten_base.py
Normal file
67
backend/nodes/flatten_base.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Flatten base — level the flat base of a surface with raised features."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import median_filter
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Flatten Base")
|
||||
class FlattenBase:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"threshold_percentile": ("FLOAT", {"default": 30.0, "min": 5.0, "max": 80.0, "step": 1.0}),
|
||||
"poly_degree": ("INT", {"default": 2, "min": 0, "max": 5}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'leveled'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Level the flat base of a surface that has raised features (particles, "
|
||||
"grains). Uses a height percentile threshold to identify base pixels, "
|
||||
"fits a polynomial to those pixels, and subtracts it. Unlike plane level, "
|
||||
"this ignores tall features that would bias the fit. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, threshold_percentile: float, poly_degree: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Identify base pixels: those below the threshold percentile
|
||||
threshold = np.percentile(data, threshold_percentile)
|
||||
base_mask = data <= threshold
|
||||
|
||||
if base_mask.sum() < max(3, (poly_degree + 1) ** 2):
|
||||
# Not enough base pixels, fall back to subtracting the mean
|
||||
return (field.replace(data=data - data.mean()),)
|
||||
|
||||
yy, xx = np.mgrid[:yres, :xres]
|
||||
x_norm = xx.ravel() / max(xres - 1, 1)
|
||||
y_norm = yy.ravel() / max(yres - 1, 1)
|
||||
|
||||
# Build polynomial basis
|
||||
cols = []
|
||||
for py in range(poly_degree + 1):
|
||||
for px in range(poly_degree + 1 - py):
|
||||
cols.append(x_norm**px * y_norm**py)
|
||||
A_full = np.column_stack(cols)
|
||||
|
||||
# Fit on base pixels only
|
||||
base_indices = np.where(base_mask.ravel())[0]
|
||||
A_base = A_full[base_indices]
|
||||
z_base = data.ravel()[base_indices]
|
||||
coeffs, _, _, _ = np.linalg.lstsq(A_base, z_base, rcond=None)
|
||||
|
||||
# Evaluate and subtract
|
||||
background = (A_full @ coeffs).reshape(data.shape)
|
||||
return (field.replace(data=data - background),)
|
||||
78
backend/nodes/fractal_interpolation.py
Normal file
78
backend/nodes/fractal_interpolation.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Fractal interpolation — fill masked regions using fractal (self-similar) synthesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
@register_node(display_name="Fractal Interpolation")
|
||||
class FractalInterpolation:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"mask": ("IMAGE",),
|
||||
"iterations": ("INT", {"default": 200, "min": 10, "max": 5000, "step": 10}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'filled'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Fill masked regions using fractal interpolation. Matches the spectral "
|
||||
"characteristics of the surrounding surface to produce natural-looking "
|
||||
"infill that preserves texture. Better than Laplace for rough surfaces. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, iterations: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64).copy()
|
||||
hole = mask_to_bool(mask)
|
||||
|
||||
if not hole.any():
|
||||
return (field.replace(data=data),)
|
||||
|
||||
# Step 1: Estimate power spectrum from valid (unmasked) data
|
||||
valid_data = data.copy()
|
||||
valid_mean = data[~hole].mean() if (~hole).any() else 0.0
|
||||
valid_data[hole] = valid_mean
|
||||
|
||||
fft_valid = np.fft.fft2(valid_data)
|
||||
power = np.abs(fft_valid) ** 2
|
||||
|
||||
# Step 2: Generate fractal noise matching the power spectrum
|
||||
rng = np.random.default_rng(42)
|
||||
phases = rng.uniform(0, 2 * np.pi, data.shape)
|
||||
noise_fft = np.sqrt(power) * np.exp(1j * phases)
|
||||
noise = np.real(np.fft.ifft2(noise_fft))
|
||||
|
||||
# Normalize noise to match local statistics around masked region
|
||||
if (~hole).any():
|
||||
noise = (noise - noise[~hole].mean()) / max(noise[~hole].std(), 1e-30) * \
|
||||
data[~hole].std() + data[~hole].mean()
|
||||
|
||||
# Step 3: Initialize masked pixels with fractal noise, then blend
|
||||
# with Laplace relaxation for smooth boundaries
|
||||
data[hole] = noise[hole]
|
||||
|
||||
# Relax boundaries to ensure continuity
|
||||
padded = np.pad(data, 1, mode='edge')
|
||||
hole_padded = np.pad(hole, 1, mode='constant', constant_values=False)
|
||||
|
||||
for _ in range(iterations):
|
||||
avg = (padded[:-2, 1:-1] + padded[2:, 1:-1] +
|
||||
padded[1:-1, :-2] + padded[1:-1, 2:]) / 4.0
|
||||
# Blend: 90% fractal noise + 10% relaxation to smooth boundaries
|
||||
blend = 0.1
|
||||
new_vals = (1.0 - blend) * padded[1:-1, 1:-1][hole] + blend * avg[hole]
|
||||
padded[1:-1, 1:-1][hole] = new_vals
|
||||
|
||||
data = padded[1:-1, 1:-1].copy()
|
||||
return (field.replace(data=data),)
|
||||
50
backend/nodes/freq_split.py
Normal file
50
backend/nodes/freq_split.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Frequency splitting — separate image into low-pass and high-pass components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Frequency Split")
|
||||
class FrequencySplit:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"cutoff": ("FLOAT", {"default": 0.1, "min": 0.001, "max": 0.5, "step": 0.001}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'low_pass'),
|
||||
('DATA_FIELD', 'high_pass'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Separate a field into low-frequency (background) and high-frequency "
|
||||
"(detail) components using FFT. The cutoff is relative to the Nyquist "
|
||||
"frequency (0.5 = no filtering, 0.001 = very aggressive). "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, cutoff: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
kx = np.fft.fftfreq(xres)
|
||||
ky = np.fft.fftfreq(yres)
|
||||
KX, KY = np.meshgrid(kx, ky)
|
||||
K = np.sqrt(KX**2 + KY**2)
|
||||
|
||||
# Gaussian low-pass filter
|
||||
lp_filter = np.exp(-0.5 * (K / cutoff)**2)
|
||||
|
||||
fft_data = np.fft.fft2(data)
|
||||
low = np.real(np.fft.ifft2(fft_data * lp_filter))
|
||||
high = data - low
|
||||
|
||||
return (field.replace(data=low), field.replace(data=high))
|
||||
@@ -27,7 +27,6 @@ class Gradient:
|
||||
"'x'/'y' give the physical gradient components (z_unit/xy_unit); "
|
||||
"'magnitude' gives sqrt(gx²+gy²); "
|
||||
"'azimuth' gives the local slope direction in radians via atan2(gy, gx). "
|
||||
"Equivalent to gwy_data_field_filter_sobel in Gwyddion (gradient.c)."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, component: str) -> tuple:
|
||||
|
||||
@@ -25,7 +25,6 @@ class GrainAnalysis:
|
||||
DESCRIPTION = (
|
||||
"Label connected grain regions in a binary mask and compute per-grain "
|
||||
"statistics: area, equivalent diameter, mean/max height, bounding box. "
|
||||
"Equivalent to Gwyddion's grain statistics tools."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||
|
||||
84
backend/nodes/grain_cross.py
Normal file
84
backend/nodes/grain_cross.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Grain cross-correlation — scatter plots of grain properties between two fields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import label
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, RecordTable
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
@register_node(display_name="Grain Cross")
|
||||
class GrainCross:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field_a": ("DATA_FIELD",),
|
||||
"field_b": ("DATA_FIELD",),
|
||||
"mask": ("IMAGE",),
|
||||
"property_a": (["area", "mean_height", "max_height", "volume"],
|
||||
{"default": "mean_height"}),
|
||||
"property_b": (["area", "mean_height", "max_height", "volume"],
|
||||
{"default": "max_height"}),
|
||||
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('RECORD_TABLE', 'correlation'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Correlate grain properties between two fields using a shared mask. "
|
||||
"Outputs a table of (property_a, property_b) pairs for each grain, "
|
||||
"plus Pearson correlation coefficient. "
|
||||
)
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField, mask: np.ndarray,
|
||||
property_a: str, property_b: str, min_size: int) -> tuple:
|
||||
data_a = np.asarray(field_a.data, dtype=np.float64)
|
||||
data_b = np.asarray(field_b.data, dtype=np.float64)
|
||||
grain = mask_to_bool(mask)
|
||||
labeled, n_grains = label(grain.astype(np.int32))
|
||||
|
||||
pixel_area = field_a.dx * field_a.dy
|
||||
|
||||
def _get_prop(data, gpx, prop):
|
||||
n_px = gpx.sum()
|
||||
if prop == "area":
|
||||
return n_px * pixel_area
|
||||
elif prop == "mean_height":
|
||||
return float(data[gpx].mean())
|
||||
elif prop == "max_height":
|
||||
return float(data[gpx].max())
|
||||
elif prop == "volume":
|
||||
base = float(data[~grain].mean()) if (~grain).any() else 0.0
|
||||
return float(np.sum(data[gpx] - base) * pixel_area)
|
||||
return 0.0
|
||||
|
||||
vals_a, vals_b = [], []
|
||||
records = RecordTable()
|
||||
for gid in range(1, n_grains + 1):
|
||||
gpx = labeled == gid
|
||||
if gpx.sum() < min_size:
|
||||
continue
|
||||
va = _get_prop(data_a, gpx, property_a)
|
||||
vb = _get_prop(data_b, gpx, property_b)
|
||||
vals_a.append(va)
|
||||
vals_b.append(vb)
|
||||
records.append({
|
||||
"quantity": f"Grain {gid}",
|
||||
"value": f"{va:.4g} / {vb:.4g}",
|
||||
"unit": f"{property_a} / {property_b}",
|
||||
})
|
||||
|
||||
# Pearson correlation
|
||||
if len(vals_a) >= 2:
|
||||
corr = float(np.corrcoef(vals_a, vals_b)[0, 1])
|
||||
records.append({"quantity": "Pearson r", "value": f"{corr:.4f}", "unit": ""})
|
||||
|
||||
return (records,)
|
||||
99
backend/nodes/grain_distributions.py
Normal file
99
backend/nodes/grain_distributions.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Grain property distributions — compute histograms of grain properties."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import label
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, LineData
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
@register_node(display_name="Grain Distributions")
|
||||
class GrainDistributions:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"mask": ("IMAGE",),
|
||||
"property": (["area", "equiv_diameter", "mean_height", "max_height",
|
||||
"volume", "boundary_length"], {"default": "area"}),
|
||||
"n_bins": ("INT", {"default": 30, "min": 5, "max": 200, "step": 1}),
|
||||
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('LINE_DATA', 'distribution'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Compute a histogram of a grain property from a labeled mask. "
|
||||
"Supported properties: area, equivalent diameter, mean height, "
|
||||
"max height, volume, and boundary length. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, property: str,
|
||||
n_bins: int, min_size: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
grain_mask = mask_to_bool(mask)
|
||||
labeled, n_grains = label(grain_mask.astype(np.int32))
|
||||
|
||||
pixel_area = field.dx * field.dy
|
||||
xy_unit = field.si_unit_xy or "m"
|
||||
z_unit = field.si_unit_z or "m"
|
||||
|
||||
values = []
|
||||
for gid in range(1, n_grains + 1):
|
||||
gpx = labeled == gid
|
||||
n_px = int(gpx.sum())
|
||||
if n_px < min_size:
|
||||
continue
|
||||
|
||||
if property == "area":
|
||||
values.append(n_px * pixel_area)
|
||||
elif property == "equiv_diameter":
|
||||
area = n_px * pixel_area
|
||||
values.append(2.0 * np.sqrt(area / np.pi))
|
||||
elif property == "mean_height":
|
||||
values.append(float(data[gpx].mean()))
|
||||
elif property == "max_height":
|
||||
values.append(float(data[gpx].max()))
|
||||
elif property == "volume":
|
||||
base = float(data[~grain_mask].mean()) if (~grain_mask).any() else 0.0
|
||||
values.append(float(np.sum(data[gpx] - base) * pixel_area))
|
||||
elif property == "boundary_length":
|
||||
# Count boundary pixels (pixels with at least one non-grain neighbour)
|
||||
padded = np.pad(gpx, 1, mode='constant', constant_values=False)
|
||||
boundary = gpx & ~(
|
||||
padded[:-2, 1:-1] & padded[2:, 1:-1] &
|
||||
padded[1:-1, :-2] & padded[1:-1, 2:]
|
||||
)
|
||||
values.append(int(boundary.sum()) * max(field.dx, field.dy))
|
||||
|
||||
if len(values) == 0:
|
||||
values = [0.0]
|
||||
|
||||
# Unit labels
|
||||
unit_map = {
|
||||
"area": f"{xy_unit}²",
|
||||
"equiv_diameter": xy_unit,
|
||||
"mean_height": z_unit,
|
||||
"max_height": z_unit,
|
||||
"volume": f"{xy_unit}²·{z_unit}",
|
||||
"boundary_length": xy_unit,
|
||||
}
|
||||
|
||||
arr = np.array(values)
|
||||
counts, edges = np.histogram(arr, bins=n_bins)
|
||||
centers = 0.5 * (edges[:-1] + edges[1:])
|
||||
|
||||
return (LineData(
|
||||
data=counts.astype(np.float64),
|
||||
x_axis=centers,
|
||||
x_unit=unit_map.get(property, ""),
|
||||
y_unit="count",
|
||||
),)
|
||||
51
backend/nodes/grain_edge.py
Normal file
51
backend/nodes/grain_edge.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Grain edge detection — detect grain boundaries using Laplacian edge detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import label
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import mask_to_bool, bool_to_mask
|
||||
|
||||
|
||||
@register_node(display_name="Grain Edge")
|
||||
class GrainEdge:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"mask": ("IMAGE",),
|
||||
"width": ("INT", {"default": 1, "min": 1, "max": 10, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('IMAGE', 'edge_mask'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Detect grain boundaries from a binary grain mask. Outputs a mask "
|
||||
"of pixels at grain edges (where grain meets non-grain). Width "
|
||||
"controls the boundary thickness in pixels. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, width: int) -> tuple:
|
||||
grain = mask_to_bool(mask)
|
||||
|
||||
# Find boundary: grain pixels with at least one non-grain 4-neighbour
|
||||
padded = np.pad(grain, 1, mode='constant', constant_values=False)
|
||||
interior = (padded[:-2, 1:-1] & padded[2:, 1:-1] &
|
||||
padded[1:-1, :-2] & padded[1:-1, 2:])
|
||||
boundary = grain & ~interior
|
||||
|
||||
# Expand boundary by width
|
||||
if width > 1:
|
||||
from scipy.ndimage import binary_dilation
|
||||
struct = np.ones((2 * width - 1, 2 * width - 1), dtype=bool)
|
||||
boundary = binary_dilation(boundary, structure=struct) & grain
|
||||
|
||||
return (bool_to_mask(boundary),)
|
||||
@@ -29,7 +29,6 @@ class GrainFilter:
|
||||
"'min_area': discard grains smaller than this many pixels (removes specks). "
|
||||
"'max_area': discard grains larger than this many pixels (0 = no limit). "
|
||||
"'remove_border': discard any grain that touches the image edge. "
|
||||
"Equivalent to Gwyddion's grain_filter module (grain_filter.c)."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
75
backend/nodes/grain_mark.py
Normal file
75
backend/nodes/grain_mark.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Grain marking — mark grains by height, slope, or curvature criteria."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import label, sobel
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
@register_node(display_name="Grain Mark")
|
||||
class GrainMark:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"criterion": (["height", "slope", "curvature"], {"default": "height"}),
|
||||
"threshold_low": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"threshold_high": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
|
||||
"inverted": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('IMAGE', 'mask'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Mark grains by thresholding height, slope magnitude, or curvature. "
|
||||
"Thresholds are relative (0–1) to the data range. Small regions below "
|
||||
"min_size pixels are removed. Use inverted to mark valleys instead of peaks. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, criterion: str, threshold_low: float,
|
||||
threshold_high: float, min_size: int, inverted: bool) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
if criterion == "height":
|
||||
values = data
|
||||
elif criterion == "slope":
|
||||
gx = sobel(data, axis=1)
|
||||
gy = sobel(data, axis=0)
|
||||
values = np.sqrt(gx**2 + gy**2)
|
||||
elif criterion == "curvature":
|
||||
gxx = sobel(sobel(data, axis=1), axis=1)
|
||||
gyy = sobel(sobel(data, axis=0), axis=0)
|
||||
values = np.abs(gxx + gyy)
|
||||
else:
|
||||
raise ValueError(f"Unknown criterion: {criterion!r}")
|
||||
|
||||
# Normalize to [0, 1]
|
||||
vmin, vmax = values.min(), values.max()
|
||||
if vmax > vmin:
|
||||
norm = (values - vmin) / (vmax - vmin)
|
||||
else:
|
||||
norm = np.zeros_like(values)
|
||||
|
||||
# Apply thresholds
|
||||
binary = (norm >= threshold_low) & (norm <= threshold_high)
|
||||
|
||||
if inverted:
|
||||
binary = ~binary
|
||||
|
||||
# Remove small regions
|
||||
labeled, n_labels = label(binary.astype(np.int32))
|
||||
for gid in range(1, n_labels + 1):
|
||||
if (labeled == gid).sum() < min_size:
|
||||
binary[labeled == gid] = False
|
||||
|
||||
return (bool_to_mask(binary),)
|
||||
78
backend/nodes/grain_summary.py
Normal file
78
backend/nodes/grain_summary.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Grain summary statistics — aggregate statistics for all grains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import label
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, RecordTable
|
||||
from backend.nodes.helpers import mask_to_bool, _square_unit
|
||||
|
||||
|
||||
@register_node(display_name="Grain Summary")
|
||||
class GrainSummary:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"mask": ("IMAGE",),
|
||||
"min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('RECORD_TABLE', 'summary'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Compute aggregate statistics for all grains in a mask: count, density, "
|
||||
"coverage fraction, mean/median area, total volume, and height statistics. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
grain_mask = mask_to_bool(mask)
|
||||
labeled, n_grains = label(grain_mask.astype(np.int32))
|
||||
|
||||
pixel_area = field.dx * field.dy
|
||||
total_area = field.xreal * field.yreal
|
||||
xy_unit = field.si_unit_xy or "m"
|
||||
z_unit = field.si_unit_z or "m"
|
||||
|
||||
# Collect per-grain properties
|
||||
areas = []
|
||||
heights = []
|
||||
volumes = []
|
||||
base_height = float(data[~grain_mask].mean()) if (~grain_mask).any() else 0.0
|
||||
|
||||
for gid in range(1, n_grains + 1):
|
||||
gpx = labeled == gid
|
||||
n_px = int(gpx.sum())
|
||||
if n_px < min_size:
|
||||
continue
|
||||
area = n_px * pixel_area
|
||||
areas.append(area)
|
||||
heights.append(float(data[gpx].mean()))
|
||||
volumes.append(float(np.sum(data[gpx] - base_height) * pixel_area))
|
||||
|
||||
records = RecordTable()
|
||||
n_valid = len(areas)
|
||||
records.append({"quantity": "Grain count", "value": str(n_valid), "unit": ""})
|
||||
records.append({"quantity": "Grain density", "value": f"{n_valid / total_area:.4g}" if total_area > 0 else "0", "unit": f"1/{_square_unit(xy_unit)}"})
|
||||
|
||||
coverage = sum(areas) / total_area if total_area > 0 else 0.0
|
||||
records.append({"quantity": "Coverage fraction", "value": f"{coverage:.4f}", "unit": ""})
|
||||
|
||||
if n_valid > 0:
|
||||
records.append({"quantity": "Mean area", "value": f"{np.mean(areas):.4g}", "unit": _square_unit(xy_unit)})
|
||||
records.append({"quantity": "Median area", "value": f"{np.median(areas):.4g}", "unit": _square_unit(xy_unit)})
|
||||
records.append({"quantity": "Total volume", "value": f"{sum(volumes):.4g}", "unit": f"{_square_unit(xy_unit)}·{z_unit}"})
|
||||
records.append({"quantity": "Mean height", "value": f"{np.mean(heights):.4g}", "unit": z_unit})
|
||||
records.append({"quantity": "Median height", "value": f"{np.median(heights):.4g}", "unit": z_unit})
|
||||
records.append({"quantity": "Max area", "value": f"{max(areas):.4g}", "unit": _square_unit(xy_unit)})
|
||||
records.append({"quantity": "Min area", "value": f"{min(areas):.4g}", "unit": _square_unit(xy_unit)})
|
||||
|
||||
return (records,)
|
||||
@@ -32,7 +32,6 @@ class Histogram:
|
||||
"Compute the height distribution histogram (DH). "
|
||||
"Use log scale to reveal small peaks next to a dominant background. "
|
||||
"Outputs marker measurements while showing the histogram interactively in-node. "
|
||||
"Equivalent to gwy_data_field_dh."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -37,7 +37,6 @@ class HoughTransform:
|
||||
"Hough parameter space. Reports detected features with their parameters. "
|
||||
"For lines: angle and distance from origin. "
|
||||
"For circles: centre coordinates and radius. "
|
||||
"Equivalent to Gwyddion's hough.c module."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -53,7 +53,6 @@ class ImageStitch:
|
||||
"Uses cross-correlation to align the images and blends the overlap region. "
|
||||
"Direction specifies how field_b is positioned relative to field_a. "
|
||||
"'auto' uses cross-correlation to determine the best placement. "
|
||||
"Equivalent to Gwyddion's merge.c / stitch.c modules."
|
||||
)
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField, direction: str, blend: str) -> tuple:
|
||||
|
||||
89
backend/nodes/immerse_detail.py
Normal file
89
backend/nodes/immerse_detail.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Immerse detail — overlay high-resolution detail onto lower-resolution overview."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Immerse Detail")
|
||||
class ImmerseDetail:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"overview": ("DATA_FIELD",),
|
||||
"detail": ("DATA_FIELD",),
|
||||
"blend": (["replace", "average"], {"default": "replace"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'combined'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Overlay a high-resolution detail scan onto a lower-resolution overview "
|
||||
"image using cross-correlation to find the best position. "
|
||||
)
|
||||
|
||||
def process(self, overview: DataField, detail: DataField, blend: str) -> tuple:
|
||||
ov = np.asarray(overview.data, dtype=np.float64)
|
||||
dt = np.asarray(detail.data, dtype=np.float64)
|
||||
|
||||
# Resample detail to overview pixel size if needed
|
||||
scale_x = detail.dx / overview.dx
|
||||
scale_y = detail.dy / overview.dy
|
||||
|
||||
if abs(scale_x - 1.0) > 0.01 or abs(scale_y - 1.0) > 0.01:
|
||||
from scipy.ndimage import zoom
|
||||
dt = zoom(dt, (scale_y, scale_x), order=1)
|
||||
|
||||
dy_res, dx_res = dt.shape
|
||||
oy_res, ox_res = ov.shape
|
||||
|
||||
if dy_res >= oy_res or dx_res >= ox_res:
|
||||
# Detail is larger than overview, just return overview
|
||||
return (overview,)
|
||||
|
||||
# Cross-correlate to find best position
|
||||
# Use a sliding window approach for small detail
|
||||
best_score = -np.inf
|
||||
best_y, best_x = 0, 0
|
||||
|
||||
dt_norm = dt - dt.mean()
|
||||
dt_std = dt.std()
|
||||
if dt_std < 1e-30:
|
||||
dt_std = 1.0
|
||||
|
||||
# Coarse search with stride
|
||||
stride = max(1, min(dy_res, dx_res) // 4)
|
||||
for iy in range(0, oy_res - dy_res + 1, stride):
|
||||
for ix in range(0, ox_res - dx_res + 1, stride):
|
||||
patch = ov[iy:iy + dy_res, ix:ix + dx_res]
|
||||
score = np.sum((patch - patch.mean()) * dt_norm) / (patch.std() * dt_std + 1e-30)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_y, best_x = iy, ix
|
||||
|
||||
# Fine search around best position
|
||||
for iy in range(max(0, best_y - stride), min(oy_res - dy_res + 1, best_y + stride + 1)):
|
||||
for ix in range(max(0, best_x - stride), min(ox_res - dx_res + 1, best_x + stride + 1)):
|
||||
patch = ov[iy:iy + dy_res, ix:ix + dx_res]
|
||||
score = np.sum((patch - patch.mean()) * dt_norm) / (patch.std() * dt_std + 1e-30)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_y, best_x = iy, ix
|
||||
|
||||
# Place detail into overview
|
||||
result = ov.copy()
|
||||
if blend == "replace":
|
||||
result[best_y:best_y + dy_res, best_x:best_x + dx_res] = dt
|
||||
else: # average
|
||||
result[best_y:best_y + dy_res, best_x:best_x + dx_res] = \
|
||||
0.5 * (ov[best_y:best_y + dy_res, best_x:best_x + dx_res] + dt)
|
||||
|
||||
return (overview.replace(data=result),)
|
||||
57
backend/nodes/laplace_interpolation.py
Normal file
57
backend/nodes/laplace_interpolation.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Laplace interpolation — fill masked regions by solving the Laplace equation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
@register_node(display_name="Laplace Interpolation")
|
||||
class LaplaceInterpolation:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"mask": ("IMAGE",),
|
||||
"iterations": ("INT", {"default": 500, "min": 10, "max": 10000, "step": 10}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'filled'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Fill masked (missing) regions by solving the Laplace equation with "
|
||||
"Dirichlet boundary conditions from surrounding pixels. "
|
||||
"Produces a smooth, harmonic interpolation without overshooting. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, iterations: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64).copy()
|
||||
hole = mask_to_bool(mask)
|
||||
|
||||
if not hole.any():
|
||||
return (field.replace(data=data),)
|
||||
|
||||
# Initialize masked pixels to mean of unmasked neighbours or global mean
|
||||
valid_mean = data[~hole].mean() if (~hole).any() else 0.0
|
||||
data[hole] = valid_mean
|
||||
|
||||
# Iterative Jacobi relaxation: replace each masked pixel with
|
||||
# the mean of its 4-connected neighbours
|
||||
padded = np.pad(data, 1, mode='edge')
|
||||
hole_padded = np.pad(hole, 1, mode='constant', constant_values=False)
|
||||
|
||||
for _ in range(iterations):
|
||||
avg = (padded[:-2, 1:-1] + padded[2:, 1:-1] +
|
||||
padded[1:-1, :-2] + padded[1:-1, 2:]) / 4.0
|
||||
padded[1:-1, 1:-1][hole] = avg[hole]
|
||||
|
||||
data = padded[1:-1, 1:-1].copy()
|
||||
return (field.replace(data=data),)
|
||||
@@ -65,7 +65,6 @@ class LatticeMeasurement:
|
||||
"Detect and measure periodic lattice structures from a surface. "
|
||||
"Computes the 2D ACF or FFT power spectrum, finds the strongest peaks, "
|
||||
"and reports lattice vectors (spacing and angle). "
|
||||
"Equivalent to Gwyddion's measure_lattice.c module."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, method: str) -> tuple:
|
||||
|
||||
68
backend/nodes/level_grains.py
Normal file
68
backend/nodes/level_grains.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Level grains — shift individual grain regions to a common baseline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import label
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
@register_node(display_name="Level Grains")
|
||||
class LevelGrains:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"mask": ("IMAGE",),
|
||||
"reference": (["mean", "median", "minimum"], {"default": "mean"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'leveled'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Shift individual grain regions (from a mask) so they all share a "
|
||||
"common baseline. Uses the selected reference statistic (mean, median, "
|
||||
"or minimum) per grain to compute the offset. "
|
||||
"Useful for consistent grain height comparisons. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, reference: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64).copy()
|
||||
grain_mask = mask_to_bool(mask)
|
||||
labeled, n_grains = label(grain_mask.astype(np.int32))
|
||||
|
||||
if n_grains == 0:
|
||||
return (field.replace(data=data),)
|
||||
|
||||
# Compute reference value for each grain
|
||||
refs = []
|
||||
for gid in range(1, n_grains + 1):
|
||||
pixels = data[labeled == gid]
|
||||
if len(pixels) == 0:
|
||||
refs.append(0.0)
|
||||
continue
|
||||
if reference == "mean":
|
||||
refs.append(float(pixels.mean()))
|
||||
elif reference == "median":
|
||||
refs.append(float(np.median(pixels)))
|
||||
else: # minimum
|
||||
refs.append(float(pixels.min()))
|
||||
|
||||
# Target: global reference across all grains
|
||||
target = float(np.mean(refs))
|
||||
|
||||
# Shift each grain
|
||||
for gid in range(1, n_grains + 1):
|
||||
grain_pixels = labeled == gid
|
||||
offset = target - refs[gid - 1]
|
||||
data[grain_pixels] += offset
|
||||
|
||||
return (field.replace(data=data),)
|
||||
@@ -24,7 +24,6 @@ class PolyLevelField:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Fit and subtract a polynomial background of given degree in x and y. "
|
||||
"Equivalent to gwy_data_field_fit_polynom."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, degree_x: int, degree_y: int) -> tuple:
|
||||
|
||||
@@ -26,7 +26,6 @@ class LocalContrast:
|
||||
DESCRIPTION = (
|
||||
"Expand the local dynamic range at each pixel. "
|
||||
"Reveals fine surface features that are hidden by global contrast range. "
|
||||
"Equivalent to Gwyddion local_contrast.c."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, kernel_size: int, weight: float) -> tuple:
|
||||
|
||||
@@ -7,9 +7,8 @@ from backend.nodes.helpers import _mask_structure, mask_to_bool, bool_to_mask, e
|
||||
|
||||
@register_node(display_name="Mask Morphology")
|
||||
class MaskMorphology:
|
||||
"""Morphological operations on binary masks.
|
||||
|
||||
Equivalent to Gwyddion's mask_morph.c (erode, dilate, open, close).
|
||||
"""
|
||||
Morphological operations on binary masks.
|
||||
"""
|
||||
_CUSTOM_PREVIEW = True
|
||||
|
||||
@@ -37,7 +36,6 @@ class MaskMorphology:
|
||||
"Dilate expands regions, erode shrinks them, "
|
||||
"open (erode then dilate) removes small spots, "
|
||||
"close (dilate then erode) fills small holes. "
|
||||
"Equivalent to Gwyddion mask_morph."
|
||||
)
|
||||
|
||||
def process(self, mask: np.ndarray, operation: str, radius: int, shape: str,
|
||||
|
||||
@@ -30,7 +30,6 @@ class ThresholdMask:
|
||||
DESCRIPTION = (
|
||||
"Create a binary mask by thresholding data. "
|
||||
"Otsu automatically finds the optimal threshold. "
|
||||
"Equivalent to Gwyddion's threshold and otsu_threshold modules."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:
|
||||
|
||||
44
backend/nodes/median_background.py
Normal file
44
backend/nodes/median_background.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Median background subtraction — extract and subtract background using local median."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import median_filter
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Median Background")
|
||||
class MedianBackground:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"radius": ("INT", {"default": 20, "min": 2, "max": 500, "step": 1}),
|
||||
"output": (["subtracted", "background"], {"default": "subtracted"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'result'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Extract background using a local median filter and subtract it. "
|
||||
"The radius controls the filter window size — larger values capture "
|
||||
"broader background variations. More robust than polynomial leveling "
|
||||
"for surfaces with sparse tall features. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, radius: int, output: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
size = 2 * radius + 1
|
||||
background = median_filter(data, size=size)
|
||||
|
||||
if output == "background":
|
||||
return (field.replace(data=background),)
|
||||
else:
|
||||
return (field.replace(data=data - background),)
|
||||
@@ -35,7 +35,6 @@ class MFMAnalysis:
|
||||
"d²F/dz²; force_gradient_to_field recovers the stray field Hz; "
|
||||
"charge_density computes the effective magnetic charge; "
|
||||
"magnetisation estimates the z-component of sample magnetisation. "
|
||||
"Equivalent to Gwyddion's mfm_*.c modules."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, operation: str, lift_height: float) -> tuple:
|
||||
|
||||
72
backend/nodes/multi_profile.py
Normal file
72
backend/nodes/multi_profile.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Multiple profiles — extract and compare profiles from multiple images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, LineData
|
||||
|
||||
|
||||
@register_node(display_name="Multiple Profiles")
|
||||
class MultipleProfiles:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field_a": ("DATA_FIELD",),
|
||||
"field_b": ("DATA_FIELD",),
|
||||
"row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}),
|
||||
"direction": (["horizontal", "vertical"], {"default": "horizontal"}),
|
||||
"mode": (["overlay", "mean", "difference"], {"default": "overlay"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('LINE_DATA', 'profile'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Extract and compare line profiles from two fields. "
|
||||
"Row=-1 uses the center row/column. Modes: overlay returns field_a's "
|
||||
"profile, mean averages both, difference subtracts b from a. "
|
||||
)
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField,
|
||||
row: int, direction: str, mode: str) -> tuple:
|
||||
a = np.asarray(field_a.data, dtype=np.float64)
|
||||
b = np.asarray(field_b.data, dtype=np.float64)
|
||||
|
||||
if direction == "horizontal":
|
||||
if row < 0:
|
||||
row = a.shape[0] // 2
|
||||
row = min(row, a.shape[0] - 1, b.shape[0] - 1)
|
||||
pa = a[row, :]
|
||||
pb = b[row, :min(a.shape[1], b.shape[1])]
|
||||
pa = pa[:len(pb)]
|
||||
dx = field_a.dx
|
||||
x_unit = field_a.si_unit_xy
|
||||
else:
|
||||
if row < 0:
|
||||
row = a.shape[1] // 2
|
||||
row = min(row, a.shape[1] - 1, b.shape[1] - 1)
|
||||
pa = a[:, row]
|
||||
pb = b[:min(a.shape[0], b.shape[0]), row]
|
||||
pa = pa[:len(pb)]
|
||||
dx = field_a.dy
|
||||
x_unit = field_a.si_unit_xy
|
||||
|
||||
x_axis = np.arange(len(pa)) * dx
|
||||
|
||||
if mode == "overlay":
|
||||
result = pa
|
||||
elif mode == "mean":
|
||||
result = 0.5 * (pa + pb)
|
||||
elif mode == "difference":
|
||||
result = pa - pb
|
||||
else:
|
||||
result = pa
|
||||
|
||||
return (LineData(data=result, x_axis=x_axis, x_unit=x_unit,
|
||||
y_unit=field_a.si_unit_z),)
|
||||
79
backend/nodes/mutual_crop.py
Normal file
79
backend/nodes/mutual_crop.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Mutual crop — align and crop two images to their overlapping region."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Mutual Crop")
|
||||
class MutualCrop:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field_a": ("DATA_FIELD",),
|
||||
"field_b": ("DATA_FIELD",),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'cropped_a'),
|
||||
('DATA_FIELD', 'cropped_b'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Align two images using cross-correlation and crop both to their "
|
||||
"overlapping region. Useful for comparing images acquired at "
|
||||
"different times or with slight position offsets. "
|
||||
)
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField) -> tuple:
|
||||
a = np.asarray(field_a.data, dtype=np.float64)
|
||||
b = np.asarray(field_b.data, dtype=np.float64)
|
||||
|
||||
# Pad to common shape for cross-correlation
|
||||
shape = (max(a.shape[0], b.shape[0]), max(a.shape[1], b.shape[1]))
|
||||
a_pad = np.zeros(shape)
|
||||
b_pad = np.zeros(shape)
|
||||
a_pad[:a.shape[0], :a.shape[1]] = a - a.mean()
|
||||
b_pad[:b.shape[0], :b.shape[1]] = b - b.mean()
|
||||
|
||||
# Cross-correlate to find shift
|
||||
fa = np.fft.fft2(a_pad)
|
||||
fb = np.fft.fft2(b_pad)
|
||||
cc = np.abs(np.fft.ifft2(fa * np.conj(fb)))
|
||||
cc = np.fft.fftshift(cc)
|
||||
cy, cx = np.array(shape) // 2
|
||||
peak = np.unravel_index(np.argmax(cc), shape)
|
||||
dy = peak[0] - cy
|
||||
dx = peak[1] - cx
|
||||
|
||||
# Compute overlap region
|
||||
ay_start = max(0, dy)
|
||||
ay_end = min(a.shape[0], b.shape[0] + dy)
|
||||
ax_start = max(0, dx)
|
||||
ax_end = min(a.shape[1], b.shape[1] + dx)
|
||||
|
||||
by_start = max(0, -dy)
|
||||
by_end = by_start + (ay_end - ay_start)
|
||||
bx_start = max(0, -dx)
|
||||
bx_end = bx_start + (ax_end - ax_start)
|
||||
|
||||
if ay_end <= ay_start or ax_end <= ax_start:
|
||||
# No overlap found, return originals
|
||||
return (field_a, field_b)
|
||||
|
||||
crop_a = a[ay_start:ay_end, ax_start:ax_end]
|
||||
crop_b = b[by_start:by_end, bx_start:bx_end]
|
||||
|
||||
xreal = crop_a.shape[1] * field_a.dx
|
||||
yreal = crop_a.shape[0] * field_a.dy
|
||||
|
||||
return (
|
||||
field_a.replace(data=crop_a, xreal=xreal, yreal=yreal),
|
||||
field_b.replace(data=crop_b, xreal=xreal, yreal=yreal),
|
||||
)
|
||||
54
backend/nodes/outlier_mask.py
Normal file
54
backend/nodes/outlier_mask.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Outlier masking — mark statistical outlier pixels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
@register_node(display_name="Outlier Mask")
|
||||
class OutlierMask:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"sigma_threshold": ("FLOAT", {"default": 3.0, "min": 1.0, "max": 10.0, "step": 0.1}),
|
||||
"mode": (["both", "high", "low"], {"default": "both"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('IMAGE', 'mask'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Create a mask marking pixels that deviate more than N standard "
|
||||
"deviations from the mean. Mode selects whether to flag high outliers, "
|
||||
"low outliers, or both. Quick way to identify noise spikes and defects. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, sigma_threshold: float, mode: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
mean = data.mean()
|
||||
std = data.std()
|
||||
|
||||
if std < 1e-30:
|
||||
return (bool_to_mask(np.zeros(data.shape, dtype=bool)),)
|
||||
|
||||
z = (data - mean) / std
|
||||
|
||||
if mode == "both":
|
||||
outliers = np.abs(z) > sigma_threshold
|
||||
elif mode == "high":
|
||||
outliers = z > sigma_threshold
|
||||
elif mode == "low":
|
||||
outliers = z < -sigma_threshold
|
||||
else:
|
||||
raise ValueError(f"Unknown mode: {mode!r}")
|
||||
|
||||
return (bool_to_mask(outliers),)
|
||||
97
backend/nodes/perspective_correction.py
Normal file
97
backend/nodes/perspective_correction.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Perspective correction — fix perspective distortion using a projective transform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import map_coordinates
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Perspective Correction")
|
||||
class PerspectiveCorrection:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"top_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"top_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"top_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"top_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"bottom_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"bottom_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"bottom_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"bottom_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'corrected'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Fix perspective distortion by specifying corner offsets. Each corner "
|
||||
"can be shifted by a fractional amount (relative to image size) to "
|
||||
"define the distorted quadrilateral. The image is then warped back to "
|
||||
"a rectangle."
|
||||
)
|
||||
|
||||
def process(self, field: DataField,
|
||||
top_left_x: float, top_left_y: float,
|
||||
top_right_x: float, top_right_y: float,
|
||||
bottom_left_x: float, bottom_left_y: float,
|
||||
bottom_right_x: float, bottom_right_y: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Source corners (distorted) as fractional offsets from ideal corners
|
||||
src = np.array([
|
||||
[top_left_y * yres, top_left_x * xres],
|
||||
[top_right_y * yres, top_right_x * xres + (xres - 1)],
|
||||
[(1 + bottom_left_y) * yres - 1, bottom_left_x * xres],
|
||||
[(1 + bottom_right_y) * yres - 1, bottom_right_x * xres + (xres - 1)],
|
||||
], dtype=np.float64)
|
||||
|
||||
# Destination corners (ideal rectangle)
|
||||
dst = np.array([
|
||||
[0, 0],
|
||||
[0, xres - 1],
|
||||
[yres - 1, 0],
|
||||
[yres - 1, xres - 1],
|
||||
], dtype=np.float64)
|
||||
|
||||
# Solve for perspective transform matrix (3x3)
|
||||
H = _solve_perspective(src, dst)
|
||||
|
||||
# Apply inverse warp
|
||||
yy, xx = np.mgrid[:yres, :xres]
|
||||
coords = np.stack([yy.ravel(), xx.ravel(), np.ones(yres * xres)])
|
||||
src_coords = H @ coords
|
||||
src_coords /= src_coords[2:3, :]
|
||||
sy = src_coords[0].reshape(yres, xres)
|
||||
sx = src_coords[1].reshape(yres, xres)
|
||||
|
||||
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||
return (field.replace(data=result),)
|
||||
|
||||
|
||||
def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
|
||||
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp)."""
|
||||
n = len(src)
|
||||
A = np.zeros((2 * n, 8))
|
||||
b = np.zeros(2 * n)
|
||||
for i in range(n):
|
||||
dy, dx = dst[i]
|
||||
sy, sx = src[i]
|
||||
A[2 * i] = [dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy]
|
||||
A[2 * i + 1] = [0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy]
|
||||
b[2 * i] = sx
|
||||
b[2 * i + 1] = sy
|
||||
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
||||
H = np.array([[h[0], h[1], h[2]],
|
||||
[h[3], h[4], h[5]],
|
||||
[h[6], h[7], 1.0]])
|
||||
return H
|
||||
58
backend/nodes/pixel_binning.py
Normal file
58
backend/nodes/pixel_binning.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Pixel binning — downsample by averaging NxN pixel blocks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Pixel Binning")
|
||||
class PixelBinning:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"bin_size": ("INT", {"default": 2, "min": 2, "max": 32, "step": 1}),
|
||||
"method": (["mean", "sum", "median"], {"default": "mean"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'binned'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Downsample by grouping NxN pixel blocks and computing their mean, "
|
||||
"sum, or median. Faster and more controlled than interpolation-based "
|
||||
"resampling. Pixels that don't fill a complete block are trimmed. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, bin_size: int, method: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Trim to multiple of bin_size
|
||||
ny = (yres // bin_size) * bin_size
|
||||
nx = (xres // bin_size) * bin_size
|
||||
trimmed = data[:ny, :nx]
|
||||
|
||||
# Reshape into blocks
|
||||
blocks = trimmed.reshape(ny // bin_size, bin_size, nx // bin_size, bin_size)
|
||||
|
||||
if method == "mean":
|
||||
result = blocks.mean(axis=(1, 3))
|
||||
elif method == "sum":
|
||||
result = blocks.sum(axis=(1, 3))
|
||||
elif method == "median":
|
||||
result = np.median(blocks, axis=(1, 3))
|
||||
else:
|
||||
raise ValueError(f"Unknown method: {method!r}")
|
||||
|
||||
# Update physical dimensions
|
||||
new_xreal = field.dx * nx
|
||||
new_yreal = field.dy * ny
|
||||
return (field.replace(data=result, xreal=new_xreal, yreal=new_yreal),)
|
||||
60
backend/nodes/poly_distort.py
Normal file
60
backend/nodes/poly_distort.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Polynomial distortion correction — correct nonlinear scanner distortions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import map_coordinates
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Polynomial Distortion")
|
||||
class PolynomialDistortion:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"k1_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
|
||||
"k1_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
|
||||
"k2_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
|
||||
"k2_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.001}),
|
||||
"k3_x": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001}),
|
||||
"k3_y": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.001}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'corrected'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Correct nonlinear scanner distortions with polynomial coordinate "
|
||||
"warping up to cubic order. Coefficients k1 (linear correction), "
|
||||
"k2 (quadratic), k3 (cubic) are applied independently to x and y axes. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField,
|
||||
k1_x: float, k1_y: float,
|
||||
k2_x: float, k2_y: float,
|
||||
k3_x: float, k3_y: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Normalised coordinates [-1, 1]
|
||||
yy, xx = np.mgrid[:yres, :xres]
|
||||
xn = 2.0 * xx / max(xres - 1, 1) - 1.0
|
||||
yn = 2.0 * yy / max(yres - 1, 1) - 1.0
|
||||
|
||||
# Apply polynomial distortion (inverse mapping)
|
||||
xn_src = xn + k1_x * xn + k2_x * xn**2 + k3_x * xn**3
|
||||
yn_src = yn + k1_y * yn + k2_y * yn**2 + k3_y * yn**3
|
||||
|
||||
# Convert back to pixel coordinates
|
||||
sx = (xn_src + 1.0) * max(xres - 1, 1) / 2.0
|
||||
sy = (yn_src + 1.0) * max(yres - 1, 1) / 2.0
|
||||
|
||||
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||
return (field.replace(data=result),)
|
||||
@@ -24,8 +24,7 @@ class PSDF:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Compute the two-dimensional power spectral density function with Gwyddion-style "
|
||||
"window RMS compensation and centered zero frequency. Equivalent to psdf2d / "
|
||||
"gwy_data_field_2dpsdf."
|
||||
"window RMS compensation and centered zero frequency."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, windowing: str, level: str) -> tuple:
|
||||
|
||||
79
backend/nodes/psdf_log_polar.py
Normal file
79
backend/nodes/psdf_log_polar.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Log-polar PSDF — power spectral density in log-polar coordinates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Log-Polar PSDF")
|
||||
class LogPolarPSDF:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"n_phi": ("INT", {"default": 180, "min": 36, "max": 720, "step": 1}),
|
||||
"n_r": ("INT", {"default": 100, "min": 20, "max": 500, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'psdf'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Compute the power spectral density function in log-polar coordinates. "
|
||||
"The x-axis is the azimuthal angle (0–360°) and y-axis is log(frequency). "
|
||||
"Better than Cartesian PSDF for anisotropy analysis. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, n_phi: int, n_r: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Compute 2D power spectrum
|
||||
fft = np.fft.fft2(data - data.mean())
|
||||
power = np.abs(np.fft.fftshift(fft))**2
|
||||
|
||||
cy, cx = yres // 2, xres // 2
|
||||
|
||||
# Build log-polar grid
|
||||
r_max = min(cx, cy)
|
||||
log_r = np.linspace(0, np.log(r_max), n_r)
|
||||
phi = np.linspace(0, 2 * np.pi, n_phi, endpoint=False)
|
||||
|
||||
result = np.zeros((n_r, n_phi))
|
||||
|
||||
for ir in range(n_r):
|
||||
r = np.exp(log_r[ir])
|
||||
for ip in range(n_phi):
|
||||
fx = cx + r * np.cos(phi[ip])
|
||||
fy = cy + r * np.sin(phi[ip])
|
||||
# Bilinear interpolation
|
||||
ix = int(fx)
|
||||
iy = int(fy)
|
||||
if 0 <= ix < xres - 1 and 0 <= iy < yres - 1:
|
||||
dx = fx - ix
|
||||
dy = fy - iy
|
||||
val = (power[iy, ix] * (1 - dx) * (1 - dy) +
|
||||
power[iy, ix + 1] * dx * (1 - dy) +
|
||||
power[iy + 1, ix] * (1 - dx) * dy +
|
||||
power[iy + 1, ix + 1] * dx * dy)
|
||||
result[ir, ip] = val
|
||||
|
||||
# Log scale for display
|
||||
result = np.log1p(result)
|
||||
|
||||
psdf_field = DataField(
|
||||
data=result,
|
||||
xreal=360.0,
|
||||
yreal=float(np.log(r_max)),
|
||||
si_unit_xy="deg",
|
||||
si_unit_z="",
|
||||
domain="frequency",
|
||||
)
|
||||
return (psdf_field,)
|
||||
@@ -28,7 +28,6 @@ class RadialProfile:
|
||||
"Compute the azimuthally averaged radial profile from a centre point. "
|
||||
"cx/cy give the centre as a fraction of the field width/height (0.5 = centre). "
|
||||
"Output x-axis is radius in physical xy units. "
|
||||
"Equivalent to gwy_data_field_angular_average used by Gwyddion's Radial Profile tool (rprofile.c)."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, cx: float, cy: float, n_bins: int) -> tuple:
|
||||
|
||||
98
backend/nodes/relate_fields.py
Normal file
98
backend/nodes/relate_fields.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Relate two fields — fit functional relationships between two data fields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, RecordTable
|
||||
|
||||
|
||||
@register_node(display_name="Relate Fields")
|
||||
class RelateFields:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field_a": ("DATA_FIELD",),
|
||||
"field_b": ("DATA_FIELD",),
|
||||
"function": (["linear", "quadratic", "cubic", "power", "logarithmic"],
|
||||
{"default": "linear"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'predicted'),
|
||||
('RECORD_TABLE', 'fit_params'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Fit a functional relationship between two data fields: b = f(a). "
|
||||
"Outputs the predicted field_b from the fit and a table of fitted "
|
||||
"parameters with R² goodness-of-fit. "
|
||||
)
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField,
|
||||
function: str) -> tuple:
|
||||
a = np.asarray(field_a.data, dtype=np.float64).ravel()
|
||||
b = np.asarray(field_b.data, dtype=np.float64).ravel()
|
||||
n = min(len(a), len(b))
|
||||
a, b = a[:n], b[:n]
|
||||
|
||||
records = RecordTable()
|
||||
|
||||
if function == "linear":
|
||||
coeffs = np.polyfit(a, b, 1)
|
||||
predicted = np.polyval(coeffs, a)
|
||||
records.append({"quantity": "slope", "value": f"{coeffs[0]:.6g}", "unit": ""})
|
||||
records.append({"quantity": "intercept", "value": f"{coeffs[1]:.6g}", "unit": ""})
|
||||
|
||||
elif function == "quadratic":
|
||||
coeffs = np.polyfit(a, b, 2)
|
||||
predicted = np.polyval(coeffs, a)
|
||||
for i, name in enumerate(["a2", "a1", "a0"]):
|
||||
records.append({"quantity": name, "value": f"{coeffs[i]:.6g}", "unit": ""})
|
||||
|
||||
elif function == "cubic":
|
||||
coeffs = np.polyfit(a, b, 3)
|
||||
predicted = np.polyval(coeffs, a)
|
||||
for i, name in enumerate(["a3", "a2", "a1", "a0"]):
|
||||
records.append({"quantity": name, "value": f"{coeffs[i]:.6g}", "unit": ""})
|
||||
|
||||
elif function == "power":
|
||||
# b = c * a^n → log(b) = log(c) + n*log(a)
|
||||
valid = (a > 0) & (b > 0)
|
||||
if valid.sum() < 2:
|
||||
predicted = np.zeros_like(a)
|
||||
records.append({"quantity": "error", "value": "insufficient positive values", "unit": ""})
|
||||
else:
|
||||
log_coeffs = np.polyfit(np.log(a[valid]), np.log(b[valid]), 1)
|
||||
n_exp = log_coeffs[0]
|
||||
c = np.exp(log_coeffs[1])
|
||||
predicted = np.where(a > 0, c * np.power(a, n_exp), 0)
|
||||
records.append({"quantity": "exponent", "value": f"{n_exp:.6g}", "unit": ""})
|
||||
records.append({"quantity": "coefficient", "value": f"{c:.6g}", "unit": ""})
|
||||
|
||||
elif function == "logarithmic":
|
||||
# b = a0 + a1 * log(a)
|
||||
valid = a > 0
|
||||
if valid.sum() < 2:
|
||||
predicted = np.zeros_like(a)
|
||||
records.append({"quantity": "error", "value": "insufficient positive values", "unit": ""})
|
||||
else:
|
||||
coeffs = np.polyfit(np.log(a[valid]), b[valid], 1)
|
||||
predicted = np.where(a > 0, np.polyval(coeffs, np.log(a)), 0)
|
||||
records.append({"quantity": "log_coeff", "value": f"{coeffs[0]:.6g}", "unit": ""})
|
||||
records.append({"quantity": "intercept", "value": f"{coeffs[1]:.6g}", "unit": ""})
|
||||
else:
|
||||
predicted = np.zeros_like(a)
|
||||
|
||||
# R² statistic
|
||||
ss_res = np.sum((b - predicted)**2)
|
||||
ss_tot = np.sum((b - b.mean())**2)
|
||||
r2 = 1.0 - ss_res / max(ss_tot, 1e-30)
|
||||
records.append({"quantity": "R²", "value": f"{r2:.6f}", "unit": ""})
|
||||
|
||||
pred_field = field_b.replace(data=predicted.reshape(field_b.data.shape))
|
||||
return (pred_field, records)
|
||||
@@ -27,8 +27,6 @@ class Resample:
|
||||
DESCRIPTION = (
|
||||
"Resample a DATA_FIELD to a new pixel resolution while preserving physical dimensions. "
|
||||
"Physical size (xreal, yreal) is unchanged; pixel size dx/dy scales accordingly. "
|
||||
"Equivalent to gwy_data_field_new_resampled with GWY_INTERPOLATION_LINEAR / "
|
||||
"GWY_INTERPOLATION_CUBIC / GWY_INTERPOLATION_ROUND (scale.c)."
|
||||
)
|
||||
|
||||
_ORDERS = {"nearest": 0, "linear": 1, "cubic": 3}
|
||||
|
||||
58
backend/nodes/scan_line_reorder.py
Normal file
58
backend/nodes/scan_line_reorder.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Scan line reordering — fix meander, interlace, and deinterlace artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Scan Line Reorder")
|
||||
class ScanLineReorder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"operation": (["reverse_odd", "reverse_even", "deinterlace_odd",
|
||||
"deinterlace_even", "flip_vertical"], {"default": "reverse_odd"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'result'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Fix scan line ordering artifacts. reverse_odd/even reverses alternate "
|
||||
"rows to correct meander (serpentine) scanning. deinterlace selects only "
|
||||
"odd or even rows and stretches to fill. flip_vertical reverses row order. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, operation: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64).copy()
|
||||
yres, xres = data.shape
|
||||
|
||||
if operation == "reverse_odd":
|
||||
data[1::2, :] = data[1::2, ::-1]
|
||||
elif operation == "reverse_even":
|
||||
data[0::2, :] = data[0::2, ::-1]
|
||||
elif operation == "deinterlace_odd":
|
||||
odd_rows = data[1::2, :]
|
||||
# Stretch back to original height via linear interpolation
|
||||
from scipy.ndimage import zoom
|
||||
scale_y = yres / odd_rows.shape[0]
|
||||
data = zoom(odd_rows, (scale_y, 1.0), order=1)
|
||||
elif operation == "deinterlace_even":
|
||||
even_rows = data[0::2, :]
|
||||
from scipy.ndimage import zoom
|
||||
scale_y = yres / even_rows.shape[0]
|
||||
data = zoom(even_rows, (scale_y, 1.0), order=1)
|
||||
elif operation == "flip_vertical":
|
||||
data = data[::-1, :].copy()
|
||||
else:
|
||||
raise ValueError(f"Unknown operation: {operation!r}")
|
||||
|
||||
return (field.replace(data=data),)
|
||||
66
backend/nodes/shade.py
Normal file
66
backend/nodes/shade.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Shaded presentation — render surface with directional lighting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import sobel
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Shade")
|
||||
class Shade:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"azimuth": ("FLOAT", {"default": 315.0, "min": 0.0, "max": 360.0, "step": 1.0}),
|
||||
"elevation": ("FLOAT", {"default": 45.0, "min": 5.0, "max": 85.0, "step": 1.0}),
|
||||
"blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'shaded'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Render a surface with directional hillshade lighting. "
|
||||
"Azimuth controls the light direction (0=north, 90=east). "
|
||||
"Elevation controls the light angle above the horizon. "
|
||||
"Blend mixes original data (0) with shaded relief (1). "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, azimuth: float, elevation: float,
|
||||
blend: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
gx = sobel(data, axis=1) / (8.0 * field.dx)
|
||||
gy = sobel(data, axis=0) / (8.0 * field.dy)
|
||||
|
||||
az_rad = np.radians(azimuth)
|
||||
el_rad = np.radians(elevation)
|
||||
|
||||
# Lambertian shading
|
||||
lx = np.cos(el_rad) * np.sin(az_rad)
|
||||
ly = np.cos(el_rad) * np.cos(az_rad)
|
||||
lz = np.sin(el_rad)
|
||||
|
||||
normal_z = 1.0 / np.sqrt(gx**2 + gy**2 + 1.0)
|
||||
normal_x = -gx * normal_z
|
||||
normal_y = -gy * normal_z
|
||||
|
||||
shade = np.clip(normal_x * lx + normal_y * ly + normal_z * lz, 0.0, 1.0)
|
||||
|
||||
# Normalize original data to [0, 1]
|
||||
dmin, dmax = data.min(), data.max()
|
||||
if dmax > dmin:
|
||||
norm_data = (data - dmin) / (dmax - dmin)
|
||||
else:
|
||||
norm_data = np.ones_like(data) * 0.5
|
||||
|
||||
result = (1.0 - blend) * norm_data + blend * shade
|
||||
return (field.replace(data=result, si_unit_z=""),)
|
||||
@@ -79,7 +79,6 @@ class ShapeFitting:
|
||||
"surface data. Outputs either the fitted surface or the residual "
|
||||
"(original minus fit). Reports fitted parameters including radius "
|
||||
"of curvature, centre position, etc. "
|
||||
"Equivalent to Gwyddion's fit-shape.c module."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, shape: str, output: str) -> tuple:
|
||||
|
||||
@@ -28,7 +28,6 @@ class SlopeDistribution:
|
||||
"'theta' is the inclination angle (0–max°), probability density (1/deg); "
|
||||
"'phi' is the azimuthal slope direction (0–360°), weighted by slope² (z/xy)²; "
|
||||
"'gradient' is the gradient magnitude distribution, probability density (1/(z/xy)). "
|
||||
"Equivalent to Gwyddion's slope_dist module (slope_dist.c)."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, distribution: str, n_bins: int) -> tuple:
|
||||
|
||||
@@ -29,7 +29,7 @@ class SpotRemoval:
|
||||
DESCRIPTION = (
|
||||
"Fill defect pixels (hot pixels, dropouts, scan artifacts) by interpolation. "
|
||||
"The mask defines defect locations. Laplace method solves the 2D Laplace equation "
|
||||
"for smooth inpainting. Equivalent to Gwyddion spotremove.c."
|
||||
"for smooth inpainting."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -21,7 +21,7 @@ class Statistics:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Compute basic surface statistics: min, max, mean, RMS roughness, median, "
|
||||
"and skewness. Equivalent to gwy_data_field_get_min/max/avg/rms."
|
||||
"and skewness."
|
||||
)
|
||||
|
||||
def process(self, field: DataField) -> tuple:
|
||||
|
||||
95
backend/nodes/straighten_path.py
Normal file
95
backend/nodes/straighten_path.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Straighten path — extract cross-section along a curved spline path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import map_coordinates
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Straighten Path")
|
||||
class StraightenPath:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75"}),
|
||||
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5"}),
|
||||
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
|
||||
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'straightened'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Extract a cross-section along an arbitrary curved path defined by "
|
||||
"control points. Points are given as fractional coordinates (0–1). "
|
||||
"The path is interpolated with cubic splines, and data is sampled "
|
||||
"along it with configurable thickness. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, points_x: str, points_y: str,
|
||||
thickness: int, n_samples: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Parse control points
|
||||
px = [float(v.strip()) * (xres - 1) for v in points_x.split(",") if v.strip()]
|
||||
py = [float(v.strip()) * (yres - 1) for v in points_y.split(",") if v.strip()]
|
||||
|
||||
if len(px) < 2 or len(py) < 2:
|
||||
# Need at least 2 points
|
||||
return (field,)
|
||||
|
||||
n_pts = min(len(px), len(py))
|
||||
px, py = px[:n_pts], py[:n_pts]
|
||||
|
||||
# Parameterize path and interpolate
|
||||
t_ctrl = np.linspace(0, 1, n_pts)
|
||||
t_sample = np.linspace(0, 1, n_samples)
|
||||
|
||||
# Simple cubic interpolation via numpy
|
||||
if n_pts >= 4:
|
||||
from numpy.polynomial.polynomial import Polynomial
|
||||
cx = np.interp(t_sample, t_ctrl, px)
|
||||
cy = np.interp(t_sample, t_ctrl, py)
|
||||
else:
|
||||
cx = np.interp(t_sample, t_ctrl, px)
|
||||
cy = np.interp(t_sample, t_ctrl, py)
|
||||
|
||||
# Sample along path with thickness
|
||||
if thickness <= 1:
|
||||
values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
|
||||
result = values.reshape(1, -1)
|
||||
else:
|
||||
# Compute normals
|
||||
dcx = np.gradient(cx)
|
||||
dcy = np.gradient(cy)
|
||||
length = np.sqrt(dcx**2 + dcy**2)
|
||||
length = np.maximum(length, 1e-10)
|
||||
nx = -dcy / length
|
||||
ny = dcx / length
|
||||
|
||||
offsets = np.linspace(-(thickness - 1) / 2, (thickness - 1) / 2, thickness)
|
||||
result = np.zeros((thickness, n_samples))
|
||||
for i, off in enumerate(offsets):
|
||||
sx = cx + off * nx
|
||||
sy = cy + off * ny
|
||||
result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||
|
||||
# Physical dimensions
|
||||
total_length = 0.0
|
||||
for i in range(1, len(cx)):
|
||||
dx_phys = (cx[i] - cx[i - 1]) * field.dx
|
||||
dy_phys = (cy[i] - cy[i - 1]) * field.dy
|
||||
total_length += np.sqrt(dx_phys**2 + dy_phys**2)
|
||||
|
||||
return (field.replace(data=result, xreal=total_length,
|
||||
yreal=thickness * max(field.dx, field.dy)),)
|
||||
@@ -98,7 +98,6 @@ class SyntheticSurface:
|
||||
"algorithm testing. Patterns: fbm (fractional Brownian motion), "
|
||||
"white_noise, lattice (periodic grid), steps (terraced), "
|
||||
"particles (spherical bumps on flat), flat (zero surface). "
|
||||
"Equivalent to Gwyddion's *_synth.c modules."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -31,7 +31,7 @@ class TemplateMatch:
|
||||
DESCRIPTION = (
|
||||
"Find a template pattern within a larger data field using normalised cross-correlation. "
|
||||
"The score output shows match quality (1 = perfect match). Detections mask marks positions "
|
||||
"above the threshold. Equivalent to Gwyddion maskcor.c."
|
||||
"above the threshold."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
154
backend/nodes/terrace_fit.py
Normal file
154
backend/nodes/terrace_fit.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Terrace fitting — segment atomic step terraces and extract step heights."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import label, uniform_filter
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, RecordTable
|
||||
|
||||
|
||||
@register_node(display_name="Terrace Fit")
|
||||
class TerraceFit:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"n_terraces": ("INT", {"default": 0, "min": 0, "max": 50}),
|
||||
"broadening": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 20.0, "step": 0.1}),
|
||||
"poly_degree": ("INT", {"default": 0, "min": 0, "max": 3}),
|
||||
"output": (["residual", "fitted", "labels"], {"default": "residual"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'result'),
|
||||
('RECORD_TABLE', 'step_heights'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Segment a surface into flat terraces separated by atomic steps, fit "
|
||||
"a polynomial to each terrace, and extract step heights. "
|
||||
"Set n_terraces=0 for automatic detection via histogram clustering. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, n_terraces: int, broadening: float,
|
||||
poly_degree: int, output: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
# Smooth data to reduce noise before terrace detection
|
||||
smoothed = uniform_filter(data, size=max(3, int(broadening * 3)))
|
||||
|
||||
if n_terraces <= 0:
|
||||
# Automatic detection: find peaks in height histogram
|
||||
n_terraces = self._auto_detect_terraces(smoothed)
|
||||
|
||||
# Assign each pixel to the nearest terrace level using k-means-like clustering
|
||||
levels = self._cluster_terraces(smoothed, n_terraces)
|
||||
labels = np.zeros_like(data, dtype=np.int32)
|
||||
fitted = np.zeros_like(data)
|
||||
|
||||
terrace_means = []
|
||||
|
||||
# Direct assignment via argmin
|
||||
level_arr = np.array(levels)
|
||||
diffs = np.abs(smoothed[..., np.newaxis] - level_arr[np.newaxis, np.newaxis, :])
|
||||
labels = np.argmin(diffs, axis=-1).astype(np.int32)
|
||||
|
||||
# Fit polynomial per terrace and build fitted surface
|
||||
yy, xx = np.mgrid[:data.shape[0], :data.shape[1]]
|
||||
x_phys = xx * field.dx
|
||||
y_phys = yy * field.dy
|
||||
|
||||
for i in range(len(levels)):
|
||||
terrace_mask = labels == i
|
||||
if terrace_mask.sum() < max(3, (poly_degree + 1) ** 2):
|
||||
fitted[terrace_mask] = data[terrace_mask].mean() if terrace_mask.any() else 0
|
||||
terrace_means.append(float(data[terrace_mask].mean()) if terrace_mask.any() else 0.0)
|
||||
continue
|
||||
|
||||
if poly_degree == 0:
|
||||
val = data[terrace_mask].mean()
|
||||
fitted[terrace_mask] = val
|
||||
terrace_means.append(float(val))
|
||||
else:
|
||||
# Build Vandermonde matrix for polynomial fit
|
||||
xp = x_phys[terrace_mask]
|
||||
yp = y_phys[terrace_mask]
|
||||
zp = data[terrace_mask]
|
||||
cols = []
|
||||
for py in range(poly_degree + 1):
|
||||
for px in range(poly_degree + 1 - py):
|
||||
cols.append(xp**px * yp**py)
|
||||
A = np.column_stack(cols)
|
||||
coeffs, _, _, _ = np.linalg.lstsq(A, zp, rcond=None)
|
||||
|
||||
# Evaluate on all terrace pixels
|
||||
all_xp = x_phys[terrace_mask]
|
||||
all_yp = y_phys[terrace_mask]
|
||||
val = np.zeros(terrace_mask.sum())
|
||||
idx = 0
|
||||
for py in range(poly_degree + 1):
|
||||
for px in range(poly_degree + 1 - py):
|
||||
val += coeffs[idx] * all_xp**px * all_yp**py
|
||||
idx += 1
|
||||
fitted[terrace_mask] = val
|
||||
terrace_means.append(float(val.mean()))
|
||||
|
||||
# Sort terrace means and compute step heights
|
||||
terrace_means.sort()
|
||||
records = RecordTable()
|
||||
unit = field.si_unit_z
|
||||
for i, mean in enumerate(terrace_means):
|
||||
records.append({"quantity": f"Terrace {i + 1} height", "value": f"{mean:.4g}", "unit": unit})
|
||||
for i in range(1, len(terrace_means)):
|
||||
step = terrace_means[i] - terrace_means[i - 1]
|
||||
records.append({"quantity": f"Step {i}→{i + 1}", "value": f"{step:.4g}", "unit": unit})
|
||||
|
||||
if output == "residual":
|
||||
out_data = data - fitted
|
||||
elif output == "fitted":
|
||||
out_data = fitted
|
||||
else: # labels
|
||||
out_data = labels.astype(np.float64)
|
||||
|
||||
return (field.replace(data=out_data), records)
|
||||
|
||||
@staticmethod
|
||||
def _auto_detect_terraces(data: np.ndarray) -> int:
|
||||
"""Detect number of terraces from histogram peaks."""
|
||||
hist, edges = np.histogram(data.ravel(), bins=256)
|
||||
smoothed = np.convolve(hist, np.ones(5) / 5, mode='same')
|
||||
# Find peaks: local maxima above mean
|
||||
threshold = smoothed.mean()
|
||||
peaks = []
|
||||
for i in range(1, len(smoothed) - 1):
|
||||
if smoothed[i] > smoothed[i - 1] and smoothed[i] > smoothed[i + 1] and smoothed[i] > threshold:
|
||||
peaks.append(i)
|
||||
return max(2, min(len(peaks), 20))
|
||||
|
||||
@staticmethod
|
||||
def _cluster_terraces(data: np.ndarray, k: int) -> list[float]:
|
||||
"""Simple 1D k-means clustering on height values."""
|
||||
flat = data.ravel()
|
||||
# Initialize with evenly spaced percentiles
|
||||
centers = [float(np.percentile(flat, 100 * (i + 0.5) / k)) for i in range(k)]
|
||||
|
||||
for _ in range(50):
|
||||
# Assign
|
||||
center_arr = np.array(centers)
|
||||
dists = np.abs(flat[:, np.newaxis] - center_arr[np.newaxis, :])
|
||||
assignments = np.argmin(dists, axis=1)
|
||||
# Update
|
||||
new_centers = []
|
||||
for i in range(k):
|
||||
members = flat[assignments == i]
|
||||
new_centers.append(float(members.mean()) if len(members) > 0 else centers[i])
|
||||
if np.allclose(centers, new_centers, atol=1e-12):
|
||||
break
|
||||
centers = new_centers
|
||||
|
||||
return sorted(centers)
|
||||
49
backend/nodes/tilt.py
Normal file
49
backend/nodes/tilt.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Tilt — apply or remove a linear tilt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Tilt")
|
||||
class Tilt:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"slope_x": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.001}),
|
||||
"slope_y": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.001}),
|
||||
"mode": (["add", "subtract"], {"default": "add"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'tilted'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Apply or subtract a linear tilt (plane). slope_x and slope_y are "
|
||||
"in data units per physical unit (e.g., m/m for height data). "
|
||||
"Use 'subtract' mode to remove a known tilt. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, slope_x: float, slope_y: float,
|
||||
mode: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
x = np.arange(xres) * field.dx
|
||||
y = np.arange(yres) * field.dy
|
||||
X, Y = np.meshgrid(x, y)
|
||||
|
||||
tilt_plane = slope_x * X + slope_y * Y
|
||||
|
||||
if mode == "subtract":
|
||||
return (field.replace(data=data - tilt_plane),)
|
||||
else:
|
||||
return (field.replace(data=data + tilt_plane),)
|
||||
@@ -492,7 +492,6 @@ class BlindTipEstimate:
|
||||
"threshold: noise floor in metres — start at 0 and increase if tip is too sharp. "
|
||||
"Output tip has apex=max, edges=0 (same convention as TipModel). "
|
||||
"Certainty map marks surface pixels where the tip was in unambiguous single contact. "
|
||||
"Equivalent to gwy_tip_estimate_partial / gwy_tip_estimate_full + gwy_tip_cmap (tip.c)."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
@@ -30,7 +30,6 @@ class TipDeconvolution:
|
||||
" surface[y,x] = min_{dy,dx}[image[y+dy, x+dx] − mytip[dy,dx]] "
|
||||
"Connect the tip output from a TipModel node. "
|
||||
"The tip pixel size must match the image pixel size. "
|
||||
"Equivalent to gwy_tip_erosion (tip.c)."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, tip: DataField) -> tuple:
|
||||
|
||||
@@ -36,7 +36,6 @@ class TipModel:
|
||||
"Shapes: parabola — paraboloid with apex radius R; "
|
||||
"cone — sphere-capped cone (radius R, half_angle from tip axis in degrees); "
|
||||
"sphere — ball-on-stick (sphere cap only). "
|
||||
"Equivalent to gwy_tip_model_preset_create (tip.c / tip_model.c)."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
54
backend/nodes/trimmed_mean.py
Normal file
54
backend/nodes/trimmed_mean.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Trimmed mean filter — mean filter excluding extreme percentiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Trimmed Mean")
|
||||
class TrimmedMean:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"radius": ("INT", {"default": 3, "min": 1, "max": 50, "step": 1}),
|
||||
"trim_fraction": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 0.45, "step": 0.01}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'filtered'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Apply a local mean filter that excludes the lowest and highest "
|
||||
"fraction of values in each window. More robust than Gaussian for "
|
||||
"data with outlier spikes. trim_fraction=0 is a plain mean; "
|
||||
"trim_fraction=0.5 approaches the median. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, radius: int, trim_fraction: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
result = np.zeros_like(data)
|
||||
|
||||
padded = np.pad(data, radius, mode='edge')
|
||||
|
||||
for iy in range(yres):
|
||||
for ix in range(xres):
|
||||
window = padded[iy:iy + 2 * radius + 1, ix:ix + 2 * radius + 1].ravel()
|
||||
n = len(window)
|
||||
k = int(n * trim_fraction)
|
||||
if k > 0:
|
||||
sorted_w = np.sort(window)
|
||||
trimmed = sorted_w[k:n - k]
|
||||
else:
|
||||
trimmed = window
|
||||
result[iy, ix] = trimmed.mean()
|
||||
|
||||
return (field.replace(data=result),)
|
||||
@@ -33,8 +33,7 @@ class WaveletDenoise:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Denoise using wavelet coefficient thresholding. BayesShrink adapts the threshold "
|
||||
"per sub-band; VisuShrink uses a global threshold. Equivalent to applying DWT from "
|
||||
"Gwyddion dwt.c with coefficient thresholding."
|
||||
"per sub-band; VisuShrink uses a global threshold."
|
||||
)
|
||||
|
||||
def process(
|
||||
|
||||
56
backend/nodes/wrap_value.py
Normal file
56
backend/nodes/wrap_value.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Value wrapping — rewrap periodic values to different ranges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Wrap Value")
|
||||
class WrapValue:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"range": (["0_to_360", "neg180_to_180", "0_to_2pi", "neg_pi_to_pi", "custom"],
|
||||
{"default": "0_to_360"}),
|
||||
"custom_min": ("FLOAT", {"default": 0.0, "min": -1e6, "max": 1e6, "step": 0.1}),
|
||||
"custom_max": ("FLOAT", {"default": 360.0, "min": -1e6, "max": 1e6, "step": 0.1}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'wrapped'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Rewrap periodic values (phase, angle) to a specified range. "
|
||||
"Preset ranges for degrees and radians, or specify a custom range. "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, range: str,
|
||||
custom_min: float, custom_max: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
ranges = {
|
||||
"0_to_360": (0.0, 360.0),
|
||||
"neg180_to_180": (-180.0, 180.0),
|
||||
"0_to_2pi": (0.0, 2 * np.pi),
|
||||
"neg_pi_to_pi": (-np.pi, np.pi),
|
||||
}
|
||||
|
||||
if range == "custom":
|
||||
lo, hi = custom_min, custom_max
|
||||
else:
|
||||
lo, hi = ranges[range]
|
||||
|
||||
period = hi - lo
|
||||
if period <= 0:
|
||||
return (field.replace(data=data),)
|
||||
|
||||
wrapped = lo + (data - lo) % period
|
||||
return (field.replace(data=wrapped),)
|
||||
60
backend/nodes/zero_crossing.py
Normal file
60
backend/nodes/zero_crossing.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Zero crossing detection — edge detection via LoG zero crossings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import gaussian_laplace
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Zero Crossing")
|
||||
class ZeroCrossing:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"sigma": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 20.0, "step": 0.1}),
|
||||
"threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'edges'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Detect edges by finding zero crossings of the Laplacian of Gaussian "
|
||||
"(LoG). Sigma controls the Gaussian smoothing scale. Threshold filters "
|
||||
"out weak edges (relative to the LoG range). "
|
||||
)
|
||||
|
||||
def process(self, field: DataField, sigma: float, threshold: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
# Compute LoG
|
||||
log = gaussian_laplace(data, sigma=sigma)
|
||||
|
||||
# Find zero crossings: adjacent pixels with opposite signs
|
||||
edges = np.zeros_like(data)
|
||||
|
||||
# Horizontal crossings
|
||||
sign_diff_x = log[:, :-1] * log[:, 1:]
|
||||
cross_x = sign_diff_x < 0
|
||||
strength_x = np.abs(log[:, :-1] - log[:, 1:])
|
||||
|
||||
# Vertical crossings
|
||||
sign_diff_y = log[:-1, :] * log[1:, :]
|
||||
cross_y = sign_diff_y < 0
|
||||
strength_y = np.abs(log[:-1, :] - log[1:, :])
|
||||
|
||||
# Apply threshold
|
||||
max_strength = max(strength_x.max(), strength_y.max(), 1e-30)
|
||||
edges[:, :-1] += cross_x & (strength_x > threshold * max_strength)
|
||||
edges[:-1, :] += cross_y & (strength_y > threshold * max_strength)
|
||||
|
||||
result = (edges > 0).astype(np.float64)
|
||||
return (field.replace(data=result, si_unit_z=""),)
|
||||
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