add keywords for all nodes
This commit is contained in:
@@ -2,12 +2,12 @@
|
||||
Core data types for tono.
|
||||
|
||||
DataField mirrors Gwyddion's GwyDataField structure:
|
||||
xres, yres – pixel dimensions
|
||||
xreal, yreal – physical dimensions in metres
|
||||
xoff, yoff – position offset in metres
|
||||
si_unit_xy – lateral unit string (e.g. "m", "nm")
|
||||
si_unit_z – height/value unit string (e.g. "m", "V", "A")
|
||||
domain – "spatial" or "frequency" (set by FFT nodes)
|
||||
xres, yres - pixel dimensions
|
||||
xreal, yreal - physical dimensions in metres
|
||||
xoff, yoff - position offset in metres
|
||||
si_unit_xy - lateral unit string (e.g. "m", "nm")
|
||||
si_unit_z - height/value unit string (e.g. "m", "V", "A")
|
||||
domain - "spatial" or "frequency" (set by FFT nodes)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
File importer registry.
|
||||
|
||||
Each module in this package exposes:
|
||||
extensions frozenset[str] – lower-case extensions it handles
|
||||
calibrated bool – True when physical dimensions are known
|
||||
load(path) → list[DataField] – load all channels
|
||||
channel_names(path) → list[str] – channel name strings (same order as load)
|
||||
extensions frozenset[str] - lower-case extensions it handles
|
||||
calibrated bool - True when physical dimensions are known
|
||||
load(path) → list[DataField] - load all channels
|
||||
channel_names(path) → list[str] - channel name strings (same order as load)
|
||||
|
||||
Usage::
|
||||
|
||||
|
||||
@@ -5,19 +5,19 @@ Asylum Research instruments store scan metadata in a sidecar group rather
|
||||
than as dataset attributes. This importer reads physical dimensions from:
|
||||
|
||||
Image/DataSetInfo/Global/Channels/<channel>/ImageDims
|
||||
DimScaling – (2,2) array: [[Y_start, Y_end], [X_start, X_end]]
|
||||
DimScaling - (2,2) array: [[Y_start, Y_end], [X_start, X_end]]
|
||||
absolute physical coordinate ranges in DimUnits
|
||||
DimExtents – pixel counts [yres, xres] (stored in a child group, not used for sizing)
|
||||
DimUnits – lateral unit strings [Y_unit, X_unit]
|
||||
DataUnits – Z unit string
|
||||
DimExtents - pixel counts [yres, xres] (stored in a child group, not used for sizing)
|
||||
DimUnits - lateral unit strings [Y_unit, X_unit]
|
||||
DataUnits - Z unit string
|
||||
|
||||
If the sidecar group is absent (generic HDF5), standard dataset attributes
|
||||
are used as a fallback:
|
||||
|
||||
xreal / yreal – physical scan size in metres (fallback: 1e-6)
|
||||
xoff / yoff – position offset in metres (fallback: 0)
|
||||
si_unit_xy – lateral unit string (fallback: "m")
|
||||
si_unit_z – value unit string (fallback: "m")
|
||||
xreal / yreal - physical scan size in metres (fallback: 1e-6)
|
||||
xoff / yoff - position offset in metres (fallback: 0)
|
||||
si_unit_xy - lateral unit string (fallback: "m")
|
||||
si_unit_z - value unit string (fallback: "m")
|
||||
|
||||
Requires:
|
||||
pip install h5py
|
||||
|
||||
@@ -4,10 +4,10 @@ Generic HDF5 importer (.h5, .hdf5, .he5).
|
||||
Each 2-D dataset found in the file is returned as a DataField. Physical
|
||||
dimensions are read from standard dataset attributes if present:
|
||||
|
||||
xreal / yreal – physical scan size in metres (fallback: 1e-6)
|
||||
xoff / yoff – position offset in metres (fallback: 0)
|
||||
si_unit_xy – lateral unit string (fallback: "m")
|
||||
si_unit_z – value unit string (fallback: "m")
|
||||
xreal / yreal - physical scan size in metres (fallback: 1e-6)
|
||||
xoff / yoff - position offset in metres (fallback: 0)
|
||||
si_unit_xy - lateral unit string (fallback: "m")
|
||||
si_unit_z - value unit string (fallback: "m")
|
||||
|
||||
For Asylum Research / Ergo format files (which store scan metadata in a
|
||||
sidecar group rather than as dataset attributes), use the ergo_hdf5 importer.
|
||||
|
||||
@@ -44,6 +44,8 @@ class ACF1D:
|
||||
"The measurement table reports the dominant period from the first positive peak."
|
||||
)
|
||||
|
||||
KEYWORDS = ("autocorrelation", "correlation", "period")
|
||||
|
||||
def process(self, profile: LineData, level: str) -> tuple:
|
||||
z = np.asarray(profile, dtype=np.float64)
|
||||
if level == "mean":
|
||||
|
||||
@@ -27,6 +27,8 @@ class ACF2D:
|
||||
"and uses the default half-range extents from acf2d."
|
||||
)
|
||||
|
||||
KEYWORDS = ("autocorrelation", "correlation")
|
||||
|
||||
def process(self, field: DataField, level: str) -> tuple:
|
||||
data = preprocess_spectral_data(field, level=level, windowing="none")
|
||||
return (acf_field_from_data(field, data),)
|
||||
|
||||
@@ -35,6 +35,8 @@ class AffineCorrection:
|
||||
"The transform is applied about the centre of the field. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("shear", "scale", "distortion", "warp")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -50,6 +50,8 @@ class Annotations:
|
||||
"or annotate an IMAGE that carries viewport metadata from View3D."
|
||||
)
|
||||
|
||||
KEYWORDS = ("scale bar", "legend", "colorbar", "publication")
|
||||
|
||||
def render(
|
||||
self,
|
||||
input,
|
||||
|
||||
@@ -84,6 +84,8 @@ class Calibration:
|
||||
"Equivalent to Gwyddion's calibrate functionality."
|
||||
)
|
||||
|
||||
KEYWORDS = ("units", "rescale", "dimensions")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -33,6 +33,8 @@ class ColorMap:
|
||||
"and any number of intermediate stops."
|
||||
)
|
||||
|
||||
KEYWORDS = ("colour", "palette", "lut", "gradient")
|
||||
|
||||
def build(self, mode: str, preset: str, stops: str | None = None, stops_json: str | None = None) -> tuple:
|
||||
if mode == "preset":
|
||||
return ({"mode": "preset", "preset": normalize_colormap_spec(preset)},)
|
||||
|
||||
@@ -37,6 +37,8 @@ class CropResizeField:
|
||||
"resizing preserves the cropped physical size."
|
||||
)
|
||||
|
||||
KEYWORDS = ("resize", "rescale", "trim", "bilinear", "bicubic", "nearest")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -30,6 +30,8 @@ class CrossCorrelate:
|
||||
"alignment."
|
||||
)
|
||||
|
||||
KEYWORDS = ("xcorr", "alignment", "registration", "drift", "match")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field_a: DataField,
|
||||
|
||||
@@ -38,6 +38,8 @@ class CrossSection:
|
||||
"Drag the markers on the image to set the line endpoints. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("profile", "line profile", "extract", "slice")
|
||||
|
||||
def process(
|
||||
self, field: DataField,
|
||||
x1: float, y1: float, x2: float, y2: float,
|
||||
|
||||
@@ -40,6 +40,8 @@ class Cursors:
|
||||
"On fields it reports x/y/z at both markers plus dx/dy/dz."
|
||||
)
|
||||
|
||||
KEYWORDS = ("marker", "distance", "measure", "delta", "ruler")
|
||||
|
||||
def process(
|
||||
self, line, x1: float, y1: float, x2: float, y2: float,
|
||||
coord_pair=None,
|
||||
|
||||
@@ -291,6 +291,8 @@ class Curvature:
|
||||
"also returns the two corresponding height profiles."
|
||||
)
|
||||
|
||||
KEYWORDS = ("radius", "principal", "quadratic", "bow")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -34,6 +34,8 @@ class Deconvolution:
|
||||
"Richardson-Lucy is iterative and preserves positivity. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("wiener", "richardson lucy", "deblur", "restoration", "psf")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -39,9 +39,10 @@ class DisplacementField:
|
||||
"Distort an image using synthetic displacement fields. "
|
||||
"Supports 1D Gaussian (row-correlated), 2D Gaussian (fully correlated), "
|
||||
"and tear (random horizontal tear lines) distortion modes. "
|
||||
"Equivalent to Gwyddion's displfield.c module."
|
||||
)
|
||||
|
||||
KEYWORDS = ("distortion", "warp", "tear")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -67,6 +67,8 @@ class DistributionCoercion:
|
||||
"Equivalent to Gwyddion's coerce.c module."
|
||||
)
|
||||
|
||||
KEYWORDS = ("coerce", "histogram matching", "equalize", "uniform", "gaussian", "quantize")
|
||||
|
||||
def process(self, field: DataField, distribution: str, n_levels: int,
|
||||
processing: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
@@ -60,6 +60,8 @@ class DriftCorrection:
|
||||
"the drift offset, then shifts lines to correct. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("thermal", "piezo", "alignment", "shift", "row")
|
||||
|
||||
def process(self, field: DataField, reference: str, direction: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
|
||||
@@ -163,9 +163,11 @@ class DWTAnisotropy:
|
||||
"Quantify surface anisotropy using a multi-level 2-D Haar wavelet decomposition. "
|
||||
"At each level, horizontal (HL) and vertical (LH) detail energies are compared to "
|
||||
"produce an X/Y energy ratio. Ratio > 1 indicates more horizontal features; "
|
||||
"ratio < 1 indicates more vertical features. Equivalent to Gwyddion's dwtanisotropy.c."
|
||||
"ratio < 1 indicates more vertical features."
|
||||
)
|
||||
|
||||
KEYWORDS = ("wavelet", "haar", "directional", "texture")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -30,6 +30,8 @@ class Entropy:
|
||||
"H = -\u03a3 p\u00b7ln(p)."
|
||||
)
|
||||
|
||||
KEYWORDS = ("shannon", "information", "disorder")
|
||||
|
||||
def process(self, field: DataField, mode: str, n_bins: int) -> tuple:
|
||||
n_bins = max(16, int(n_bins))
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
@@ -34,6 +34,8 @@ class ExtendPad:
|
||||
"Mirror and periodic modes avoid edge discontinuities for FFT. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("border", "margin", "mirror", "periodic")
|
||||
|
||||
def process(self, field: DataField, top: int, bottom: int,
|
||||
left: int, right: int, method: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
@@ -32,6 +32,8 @@ class FacetAnalysis:
|
||||
"Intensity represents how much surface area faces each orientation. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("orientation", "stereographic", "azimuth", "inclination", "slope", "crystal")
|
||||
|
||||
def process(self, field: DataField, n_bins: int, kernel_size: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ class FeatureDetection:
|
||||
"Outputs a feature map and a table of detected feature locations. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("canny", "harris", "corner", "edge", "interest point", "roi")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -30,6 +30,8 @@ class FFT1D:
|
||||
"Returns the FFT spectrum of the line, and identifies peaks."
|
||||
)
|
||||
|
||||
KEYWORDS = ("fourier", "frequency", "spectrum", "period")
|
||||
|
||||
def process(
|
||||
self, profile,
|
||||
) -> tuple:
|
||||
|
||||
@@ -34,6 +34,8 @@ class FFT2D:
|
||||
"Outputs log magnitude, magnitude, phase, and PSDF as separate channels. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("fourier", "frequency", "spectrum", "psdf", "magnitude", "phase")
|
||||
|
||||
def process(self, field: DataField, windowing: str, level: str) -> tuple:
|
||||
data = preprocess_spectral_data(field, level=level, windowing=windowing)
|
||||
F = np.fft.fftshift(np.fft.fft2(data))
|
||||
|
||||
@@ -29,6 +29,8 @@ class FFT2DInverse:
|
||||
"or PSDF/phase) from the 2D FFT node. If phase is omitted, zero phase is assumed."
|
||||
)
|
||||
|
||||
KEYWORDS = ("fourier", "ifft", "reconstruct")
|
||||
|
||||
def process(self, spectrum: DataField, representation: str, phase: DataField | None = None) -> tuple:
|
||||
if spectrum.domain != "frequency":
|
||||
raise ValueError("Inverse 2D FFT requires a frequency-domain DATA_FIELD input.")
|
||||
|
||||
@@ -29,6 +29,8 @@ class FieldArithmetic:
|
||||
"hypot computes sqrt(a² + b²) per pixel. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("math", "add", "subtract", "multiply", "divide", "hypot")
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField, operation: str) -> tuple:
|
||||
if field_a.data.shape != field_b.data.shape:
|
||||
raise ValueError(
|
||||
|
||||
@@ -99,6 +99,8 @@ class CustomConvolution:
|
||||
"Example sharpen: '0 -1 0 / -1 5 -1 / 0 -1 0' (use newlines, not slashes). "
|
||||
)
|
||||
|
||||
KEYWORDS = ("kernel", "sharpen", "custom filter", "convolution")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -46,6 +46,8 @@ class FFTFilter:
|
||||
"with a Butterworth roll-off. Cutoffs are fractions of the Nyquist frequency."
|
||||
)
|
||||
|
||||
KEYWORDS = ("butterworth", "lowpass", "highpass", "bandpass", "notch", "fourier")
|
||||
|
||||
def process(self, input, filter_type: str, cutoff: float,
|
||||
cutoff_high: float, order: int) -> tuple:
|
||||
if isinstance(input, DataField):
|
||||
|
||||
@@ -21,6 +21,8 @@ class GaussianFilter:
|
||||
|
||||
DESCRIPTION = "Apply a Gaussian blur."
|
||||
|
||||
KEYWORDS = ("blur", "smooth", "lowpass")
|
||||
|
||||
def process(self, field: DataField, sigma: float) -> tuple:
|
||||
from scipy.ndimage import gaussian_filter
|
||||
data = gaussian_filter(field.data, sigma=float(sigma))
|
||||
|
||||
@@ -79,6 +79,8 @@ class KuwaharaFilter:
|
||||
"""
|
||||
)
|
||||
|
||||
KEYWORDS = ("edge preserving", "smooth", "denoise")
|
||||
|
||||
def process(self, field: DataField, iterations: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
iterations = max(1, int(iterations))
|
||||
|
||||
@@ -21,6 +21,8 @@ class MedianFilter:
|
||||
|
||||
DESCRIPTION = "Apply a median filter."
|
||||
|
||||
KEYWORDS = ("denoise", "despeckle", "salt pepper")
|
||||
|
||||
def process(self, field: DataField, size: int) -> tuple:
|
||||
from scipy.ndimage import median_filter
|
||||
size = max(1, int(size))
|
||||
|
||||
@@ -34,6 +34,8 @@ class RankFilter:
|
||||
"median the 50th percentile. Custom percentile allows any rank. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("erosion", "dilation", "percentile", "morphology", "minimum", "maximum")
|
||||
|
||||
def process(self, field: DataField, operation: str, radius: int,
|
||||
percentile: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
@@ -24,6 +24,8 @@ class FixZero:
|
||||
"Shift data so that the minimum (or mean/median) is zero. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("offset", "subtract", "baseline", "datum")
|
||||
|
||||
def process(self, field: DataField, method: str) -> tuple:
|
||||
data = field.data.copy()
|
||||
if method == "min":
|
||||
|
||||
@@ -33,6 +33,8 @@ class FlattenBase:
|
||||
"this ignores tall features that would bias the fit. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("level", "background", "substrate", "tilt")
|
||||
|
||||
def process(self, field: DataField, threshold_percentile: float, poly_degree: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
@@ -29,6 +29,8 @@ class FlipField:
|
||||
"Physical extents are preserved, and stored markup overlays are mirrored with the data."
|
||||
)
|
||||
|
||||
KEYWORDS = ("mirror", "reflect", "invert")
|
||||
|
||||
def process(self, field: DataField, axis: str) -> tuple:
|
||||
axis_name = str(axis).strip().lower()
|
||||
if axis_name == "x":
|
||||
|
||||
@@ -20,9 +20,10 @@ class Folder:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Pick a folder and output its directory path plus one file socket per compatible image, array, or SPM file inside it. "
|
||||
"Supported files include common images, .npy/.npz arrays, and .gwy/.sxm/.ibw scans."
|
||||
)
|
||||
|
||||
KEYWORDS = ("directory", "batch", "load", "open")
|
||||
|
||||
def list_files(self, folder: str) -> tuple:
|
||||
entries = list_folder_paths(folder)
|
||||
if not entries:
|
||||
|
||||
@@ -310,6 +310,8 @@ class FractalDimension:
|
||||
"power-spectrum, or HHCF methods. The in-node graph shows the log-log curve and lets you drag the fit range."
|
||||
)
|
||||
|
||||
KEYWORDS = ("partitioning", "cube counting", "triangulation", "hhcf", "box counting", "self similar")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field,
|
||||
|
||||
@@ -32,6 +32,8 @@ class FractalInterpolation:
|
||||
"infill that preserves texture. Better than Laplace for rough surfaces. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("inpaint", "fill", "hole", "infill")
|
||||
|
||||
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)
|
||||
|
||||
@@ -31,6 +31,8 @@ class FrequencySplit:
|
||||
"frequency (0.5 = no filtering, 0.001 = very aggressive). "
|
||||
)
|
||||
|
||||
KEYWORDS = ("lowpass", "highpass", "decompose", "background", "detail")
|
||||
|
||||
def process(self, field: DataField, cutoff: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
@@ -29,6 +29,8 @@ class Gradient:
|
||||
"'azimuth' gives the local slope direction in radians via atan2(gy, gx). "
|
||||
)
|
||||
|
||||
KEYWORDS = ("sobel", "slope", "derivative", "azimuth")
|
||||
|
||||
def process(self, field: DataField, component: str) -> tuple:
|
||||
from backend.nodes.surface_common import physical_sobel_gradient, slope_unit
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ class GrainAnalysis:
|
||||
"statistics: area, equivalent diameter, mean/max height, bounding box. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("particle", "blob", "label", "connected components", "area")
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||
from scipy.ndimage import label
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ class GrainCross:
|
||||
"plus Pearson correlation coefficient. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("pearson", "scatter", "correlate", "property")
|
||||
|
||||
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)
|
||||
|
||||
@@ -115,6 +115,8 @@ class GrainDistanceTransform:
|
||||
"image-boundary handling matching mask_edt."
|
||||
)
|
||||
|
||||
KEYWORDS = ("edt", "euclidean", "chessboard", "cityblock", "manhattan")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -36,6 +36,8 @@ class GrainDistributions:
|
||||
"max height, volume, and boundary length. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("histogram", "particle", "diameter", "area", "volume")
|
||||
|
||||
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)
|
||||
|
||||
@@ -33,6 +33,8 @@ class GrainEdge:
|
||||
"controls the boundary thickness in pixels. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("boundary", "outline", "perimeter", "contour")
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, width: int) -> tuple:
|
||||
grain = mask_to_bool(mask)
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ class GrainFilter:
|
||||
"'remove_border': discard any grain that touches the image edge. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("particle", "size filter", "despeckle", "area")
|
||||
|
||||
def process(
|
||||
self,
|
||||
mask: np.ndarray,
|
||||
|
||||
@@ -32,10 +32,12 @@ class GrainMark:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Mark grains by thresholding height, slope magnitude, or curvature. "
|
||||
"Thresholds are relative (0–1) to the data range. Small regions below "
|
||||
"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. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("threshold", "segment", "peak", "particle")
|
||||
|
||||
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)
|
||||
|
||||
@@ -32,6 +32,8 @@ class GrainSummary:
|
||||
"coverage fraction, mean/median area, total volume, and height statistics. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("particle", "count", "density", "coverage", "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)
|
||||
|
||||
@@ -181,6 +181,8 @@ class GrainVisualization:
|
||||
"Equivalent to Gwyddion's grain selection visualization (grain_makesel)."
|
||||
)
|
||||
|
||||
KEYWORDS = ("ellipse", "disc", "bounding box", "centroid", "inscribed", "label")
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, style: str, fill: bool) -> tuple:
|
||||
mask_bool = mask_to_bool(mask)
|
||||
labels, n_grains = label(mask_bool.astype(np.int32))
|
||||
|
||||
@@ -34,6 +34,8 @@ class Histogram:
|
||||
"Outputs marker measurements while showing the histogram interactively in-node. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("distribution", "height distribution", "dh")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -39,6 +39,8 @@ class HoughTransform:
|
||||
"For circles: centre coordinates and radius. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("line detection", "circle detection", "shape detection")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -31,11 +31,13 @@ class Image:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Load any supported file. "
|
||||
"SPM formats (.gwy, .sxm, .ibw) and HDF5 (.h5, .hdf5) provide calibrated dimensions; "
|
||||
"SPM and HDF5 provide calibrated dimensions; "
|
||||
"each channel gets its own output. "
|
||||
"Images (.png, .tiff, .jpg) and arrays (.npy, .npz) are loaded as uncalibrated fields."
|
||||
"Images and arrays are loaded as uncalibrated fields."
|
||||
)
|
||||
|
||||
KEYWORDS = ("load", "open", "file", "import", "gwy", "sxm", "ibw", "png", "tiff", "npy", "hdf5")
|
||||
|
||||
def load(self, filename: str = "", colormap: str = "viridis", colormap_map=None, path: str | None = None):
|
||||
selected_path = str(path).strip() if path is not None else str(filename).strip()
|
||||
if not selected_path:
|
||||
|
||||
@@ -26,6 +26,8 @@ class ImageDemo:
|
||||
|
||||
DESCRIPTION = "Load a bundled demo file so you can try the app without providing your own data."
|
||||
|
||||
KEYWORDS = ("example", "sample", "test")
|
||||
|
||||
def load(self, name: str = "", colormap: str = "viridis", colormap_map=None):
|
||||
from backend.nodes.image import Image
|
||||
loader = Image()
|
||||
|
||||
@@ -55,6 +55,8 @@ class ImageStitch:
|
||||
"'auto' uses cross-correlation to determine the best placement. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("mosaic", "merge", "combine", "panorama", "blend")
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField, direction: str, blend: str) -> tuple:
|
||||
a = np.asarray(field_a.data, dtype=np.float64)
|
||||
b = np.asarray(field_b.data, dtype=np.float64)
|
||||
|
||||
@@ -30,6 +30,8 @@ class ImmerseDetail:
|
||||
"image using cross-correlation to find the best position. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("inset", "zoom", "merge", "overlay")
|
||||
|
||||
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)
|
||||
|
||||
@@ -32,6 +32,8 @@ class LaplaceInterpolation:
|
||||
"Produces a smooth, harmonic interpolation without overshooting. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("inpaint", "fill", "hole", "infill", "harmonic")
|
||||
|
||||
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)
|
||||
|
||||
@@ -43,6 +43,8 @@ class LateralForceSim:
|
||||
"the sample surface."
|
||||
)
|
||||
|
||||
KEYWORDS = ("lfm", "friction", "ffm", "tribology")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -67,6 +67,8 @@ class LatticeMeasurement:
|
||||
"and reports lattice vectors (spacing and angle). "
|
||||
)
|
||||
|
||||
KEYWORDS = ("periodic", "crystal", "unit cell", "spacing", "atomic")
|
||||
|
||||
def process(self, field: DataField, method: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
data = data - data.mean()
|
||||
|
||||
@@ -125,6 +125,8 @@ class FacetLevelField:
|
||||
"selection and expects topographic data with compatible XY and Z units."
|
||||
)
|
||||
|
||||
KEYWORDS = ("flatten", "tilt", "plane")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -34,6 +34,8 @@ class LevelGrains:
|
||||
"Useful for consistent grain height comparisons. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("align", "baseline", "flatten", "particle")
|
||||
|
||||
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)
|
||||
|
||||
@@ -55,6 +55,8 @@ class PlaneLevelField:
|
||||
"for flattening around features, similar to masked plane fitting workflows in Gwyddion."
|
||||
)
|
||||
|
||||
KEYWORDS = ("flatten", "tilt", "background")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -26,6 +26,8 @@ class PolyLevelField:
|
||||
"Fit and subtract a polynomial background of given degree in x and y. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("flatten", "background", "bow", "curvature")
|
||||
|
||||
def process(self, field: DataField, degree_x: int, degree_y: int) -> tuple:
|
||||
data = field.data.copy()
|
||||
yres, xres = data.shape
|
||||
|
||||
@@ -296,6 +296,8 @@ class LineCorrection:
|
||||
"and the step-line correction path from Gwyddion's linecorrect/linematch modules."
|
||||
)
|
||||
|
||||
KEYWORDS = ("row", "linematch", "linecorrect", "destripe", "scanline", "align")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -28,6 +28,8 @@ class LocalContrast:
|
||||
"Reveals fine surface features that are hidden by global contrast range. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("clahe", "enhance", "adaptive", "dynamic range")
|
||||
|
||||
def process(self, field: DataField, kernel_size: int, weight: float) -> tuple:
|
||||
from scipy.ndimage import minimum_filter, maximum_filter
|
||||
|
||||
@@ -50,7 +52,7 @@ class LocalContrast:
|
||||
safe_range = np.where(local_range > eps, local_range, 1.0)
|
||||
global_span = global_max - global_min
|
||||
if global_span <= eps:
|
||||
# Uniform field – nothing to enhance.
|
||||
# Uniform field - nothing to enhance.
|
||||
return (field.replace(data=data.copy()),)
|
||||
|
||||
enhancement_factor = global_span / safe_range
|
||||
|
||||
@@ -172,6 +172,8 @@ class LogisticClassification:
|
||||
"generates pseudo-labels automatically."
|
||||
)
|
||||
|
||||
KEYWORDS = ("machine learning", "regression", "segment", "ml", "neural")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -41,6 +41,8 @@ class MarkDisconnected:
|
||||
"Equivalent to Gwyddion's mark_disconn module."
|
||||
)
|
||||
|
||||
KEYWORDS = ("isolated", "defect", "morphology", "disconn", "outlier")
|
||||
|
||||
def process(self, field: DataField, defect_type: str, radius: int, threshold: float) -> tuple:
|
||||
data = field.data.astype(np.float64)
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ class Markup:
|
||||
"or rasterize markup directly onto an IMAGE."
|
||||
)
|
||||
|
||||
KEYWORDS = ("annotate", "arrow", "rectangle", "circle", "line", "draw", "overlay")
|
||||
|
||||
def process(
|
||||
self,
|
||||
input,
|
||||
|
||||
@@ -33,6 +33,8 @@ class DrawMask:
|
||||
"and invert flips the final binary output."
|
||||
)
|
||||
|
||||
KEYWORDS = ("paint", "brush", "roi", "annotate", "manual")
|
||||
|
||||
def process(self, field: DataField, pen_size: int, invert: bool, mask_paths: str) -> tuple:
|
||||
strokes = _parse_mask_strokes(mask_paths)
|
||||
mask = _rasterize_mask(field.xres, field.yres, strokes, pen_size)
|
||||
|
||||
@@ -38,6 +38,8 @@ class MaskMorphology:
|
||||
"close (dilate then erode) fills small holes. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("dilate", "erode", "open", "close", "binary")
|
||||
|
||||
def process(self, mask: np.ndarray, operation: str, radius: int, shape: str,
|
||||
field: DataField | None = None) -> tuple:
|
||||
from scipy.ndimage import binary_closing, binary_dilation, binary_erosion, binary_opening
|
||||
|
||||
@@ -41,6 +41,8 @@ class MaskNoisify:
|
||||
"Use a fixed seed for reproducible results."
|
||||
)
|
||||
|
||||
KEYWORDS = ("random", "perturb", "boundary", "roughen", "jitter")
|
||||
|
||||
def process(self, mask: np.ndarray, density: float, direction: str,
|
||||
boundaries_only: bool, seed: int,
|
||||
field: DataField | None = None) -> tuple:
|
||||
|
||||
@@ -48,6 +48,8 @@ class MaskOperations:
|
||||
"XNOR, directional subtraction, implication, pass-through, and constant true/false outputs."
|
||||
)
|
||||
|
||||
KEYWORDS = ("boolean", "and", "or", "xor", "logic", "union", "intersect", "subtract")
|
||||
|
||||
def process(
|
||||
self,
|
||||
mask_a: np.ndarray,
|
||||
|
||||
@@ -40,6 +40,8 @@ class MaskShift:
|
||||
"wrap (periodic roll), or mirror (reflected padding)."
|
||||
)
|
||||
|
||||
KEYWORDS = ("translate", "offset", "move", "roll")
|
||||
|
||||
def process(self, mask: np.ndarray, shift_x: int, shift_y: int,
|
||||
border_mode: str, field: DataField | None = None) -> tuple:
|
||||
binary = mask_to_bool(mask)
|
||||
|
||||
@@ -32,6 +32,8 @@ class ThresholdMask:
|
||||
"Otsu automatically finds the optimal threshold. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("otsu", "binarize", "segment", "cutoff", "level")
|
||||
|
||||
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:
|
||||
data = field.data
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ class MedianBackground:
|
||||
"for surfaces with sparse tall features. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("rolling ball", "flatten", "level", "subtract", "baseline")
|
||||
|
||||
def process(self, field: DataField, radius: int, output: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
size = 2 * radius + 1
|
||||
|
||||
@@ -37,6 +37,8 @@ class MFMAnalysis:
|
||||
"magnetisation estimates the z-component of sample magnetisation. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("magnetic", "force gradient", "stray field", "phase", "charge density", "magnetisation")
|
||||
|
||||
def process(self, field: DataField, operation: str, lift_height: float) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
@@ -49,6 +49,8 @@ class MFMCurrentSimulation:
|
||||
"gradient dHz/dz."
|
||||
)
|
||||
|
||||
KEYWORDS = ("magnetic", "biot savart", "wire", "strip", "force", "dipole", "tip")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -49,6 +49,8 @@ class MFMDomainGeneration:
|
||||
"FFT-based transfer functions, suitable for MFM simulation and testing. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("magnetic", "stripe", "stray field", "synthetic", "simulation")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -33,6 +33,8 @@ class MultipleProfiles:
|
||||
"profile, mean averages both, difference subtracts b from a. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("line profile", "compare", "overlay", "cross section")
|
||||
|
||||
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)
|
||||
|
||||
@@ -31,6 +31,8 @@ class MutualCrop:
|
||||
"different times or with slight position offsets. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("align", "overlap", "registration", "cross correlation", "match")
|
||||
|
||||
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)
|
||||
|
||||
@@ -126,6 +126,8 @@ class NeuralClassification:
|
||||
"Equivalent in purpose to Gwyddion's neural.c classifier."
|
||||
)
|
||||
|
||||
KEYWORDS = ("machine learning", "ml", "segment", "nn", "feedforward", "classifier")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -32,8 +32,8 @@ def _parse_ibw_note(note_bytes: bytes) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
@register_node(display_name="Note")
|
||||
class Note:
|
||||
@register_node(display_name="Igor Note")
|
||||
class IgorNote:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
@@ -55,6 +55,8 @@ class Note:
|
||||
"as a table of key/value pairs."
|
||||
)
|
||||
|
||||
KEYWORDS = ("ibw", "metadata", "header", "parameters", "igor")
|
||||
|
||||
def load(self, filename: str = "", path: str | None = None) -> tuple:
|
||||
selected = str(path).strip() if path is not None else str(filename).strip()
|
||||
if not selected:
|
||||
|
||||
@@ -32,6 +32,8 @@ class OutlierMask:
|
||||
"low outliers, or both. Quick way to identify noise spikes and defects. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("sigma", "zscore", "spikes", "defect", "anomaly", "despeckle")
|
||||
|
||||
def process(self, field: DataField, sigma_threshold: float, mode: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
mean = data.mean()
|
||||
|
||||
@@ -39,6 +39,8 @@ class PerspectiveCorrection:
|
||||
"a rectangle."
|
||||
)
|
||||
|
||||
KEYWORDS = ("keystone", "homography", "projective", "warp", "quadrilateral", "distortion")
|
||||
|
||||
def process(self, field: DataField,
|
||||
top_left_x: float, top_left_y: float,
|
||||
top_right_x: float, top_right_y: float,
|
||||
|
||||
@@ -41,6 +41,8 @@ class PFMAnalysis:
|
||||
"inclination angle (zero in 2D mode)."
|
||||
)
|
||||
|
||||
KEYWORDS = ("piezoresponse", "polarization", "ferroelectric", "vpfm", "lpfm", "domain")
|
||||
|
||||
def process(
|
||||
self,
|
||||
vpfm_amplitude: DataField,
|
||||
|
||||
@@ -31,6 +31,8 @@ class PixelBinning:
|
||||
"resampling. Pixels that don't fill a complete block are trimmed. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("downsample", "block", "reduce", "coarsen", "average")
|
||||
|
||||
def process(self, field: DataField, bin_size: int, method: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
@@ -178,9 +178,10 @@ class PixelClassification:
|
||||
"Classify pixels into discrete classes based on height, slope, and/or curvature. "
|
||||
"Single-feature modes use threshold-based classification (Otsu, equal range, or quantile). "
|
||||
"Multi-feature modes (height_slope, all) use k-means clustering. "
|
||||
"Equivalent to Gwyddion's classify.c module."
|
||||
)
|
||||
|
||||
KEYWORDS = ("kmeans", "cluster", "otsu", "segment", "quantile", "slope", "curvature")
|
||||
|
||||
def process(self, field: DataField, n_classes: int, feature: str, method: str) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
maps = _feature_maps(data, feature)
|
||||
|
||||
@@ -36,6 +36,8 @@ class PolynomialDistortion:
|
||||
"k2 (quadratic), k3 (cubic) are applied independently to x and y axes. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("warp", "scanner", "nonlinear", "cubic", "quadratic", "barrel")
|
||||
|
||||
def process(self, field: DataField,
|
||||
k1_x: float, k1_y: float,
|
||||
k2_x: float, k2_y: float,
|
||||
|
||||
@@ -42,9 +42,10 @@ class PresentationOps:
|
||||
"extract_presentation normalises the field to [0, 1]. "
|
||||
"attach replaces the field data with an overlay (resampled if needed). "
|
||||
"blend linearly mixes the field and overlay by a configurable factor. "
|
||||
"Equivalent to Gwyddion's presentationops.c module."
|
||||
)
|
||||
|
||||
KEYWORDS = ("logscale", "normalize", "blend", "attach", "overlay")
|
||||
|
||||
def process(self, field: DataField, operation: str, blend_factor: float,
|
||||
overlay: DataField | None = None) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
@@ -36,6 +36,8 @@ class PreviewImage:
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Display an IMAGE or DATA_FIELD as a coloured thumbnail."
|
||||
|
||||
KEYWORDS = ("display", "thumbnail", "show", "view", "render")
|
||||
|
||||
def preview(
|
||||
self,
|
||||
colormap: str,
|
||||
|
||||
@@ -19,7 +19,9 @@ class PrintTable:
|
||||
FUNCTION = "print_table"
|
||||
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Send a measurement or record table to the browser as a WebSocket message for display."
|
||||
DESCRIPTION = "Show a measurement or record table."
|
||||
|
||||
KEYWORDS = ("display", "show", "report", "view")
|
||||
|
||||
def print_table(self, table: list) -> tuple:
|
||||
emit_table(table)
|
||||
|
||||
@@ -27,6 +27,8 @@ class PSDF:
|
||||
"window RMS compensation and centered zero frequency."
|
||||
)
|
||||
|
||||
KEYWORDS = ("power spectrum", "fourier", "frequency", "roughness", "spectral density")
|
||||
|
||||
def process(self, field: DataField, windowing: str, level: str) -> tuple:
|
||||
data = preprocess_spectral_data(field, level=level, windowing=windowing)
|
||||
return (psdf_field_from_data(field, data),)
|
||||
|
||||
@@ -27,10 +27,12 @@ class LogPolarPSDF:
|
||||
|
||||
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). "
|
||||
"The x-axis is the azimuthal angle (0-360°) and y-axis is log(frequency). "
|
||||
"Better than Cartesian PSDF for anisotropy analysis. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("power spectrum", "azimuthal", "anisotropy", "directional", "fourier")
|
||||
|
||||
def process(self, field: DataField, n_phi: int, n_r: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
@@ -33,9 +33,10 @@ class PSFEstimation:
|
||||
"and an ideal (sharp) reference. The PSF can then be used with the "
|
||||
"Deconvolution node to restore other images. Three methods are available: "
|
||||
"pseudo-Wiener deconvolution, regularised least-squares, and Gaussian fit. "
|
||||
"Equivalent to Gwyddion's psf.c / psf-fit.c modules."
|
||||
)
|
||||
|
||||
KEYWORDS = ("point spread function", "deconvolution", "wiener", "gaussian", "blur", "kernel")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -30,6 +30,8 @@ class RadialProfile:
|
||||
"Output x-axis is radius in physical xy units. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("azimuthal average", "ring average", "circular", "isotropic")
|
||||
|
||||
def process(self, field: DataField, cx: float, cy: float, n_bins: int) -> tuple:
|
||||
yres, xres = field.data.shape
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ class RelateFields:
|
||||
"parameters with R² goodness-of-fit. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("fit", "regression", "correlate", "polynomial", "power", "logarithmic")
|
||||
|
||||
def process(self, field_a: DataField, field_b: DataField,
|
||||
function: str) -> tuple:
|
||||
a = np.asarray(field_a.data, dtype=np.float64).ravel()
|
||||
|
||||
@@ -29,6 +29,8 @@ class Resample:
|
||||
"Physical size (xreal, yreal) is unchanged; pixel size dx/dy scales accordingly. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("resize", "rescale", "interpolate", "bilinear", "bicubic", "nearest", "zoom")
|
||||
|
||||
_ORDERS = {"nearest": 0, "linear": 1, "cubic": 3}
|
||||
|
||||
def process(self, field: DataField, width: int, height: int, interpolation: str) -> tuple:
|
||||
|
||||
@@ -28,6 +28,8 @@ class RotateField:
|
||||
"Optionally expand the canvas to keep the full rotated field while preserving the field center."
|
||||
)
|
||||
|
||||
KEYWORDS = ("turn", "angle", "spin", "orient")
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
|
||||
@@ -74,6 +74,8 @@ class Save:
|
||||
"Save a single graph value to disk. Supports fields, images, lines, tables, scalars, and 3D meshes."
|
||||
)
|
||||
|
||||
KEYWORDS = ("export", "write", "download", "png", "tiff", "csv", "json", "npz", "obj", "stl")
|
||||
|
||||
def save(
|
||||
self,
|
||||
filename: str,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user