diff --git a/backend/nodes/arc_revolve.py b/backend/nodes/arc_revolve.py new file mode 100644 index 0000000..d7edb99 --- /dev/null +++ b/backend/nodes/arc_revolve.py @@ -0,0 +1,88 @@ +"""Arc Revolve — subtract a cylindrical arc background.""" + +from __future__ import annotations + +import numpy as np + +from backend.node_registry import register_node +from backend.data_types import DataField + + +def _arc_kernel(radius: int) -> np.ndarray: + """Build a 1D arc kernel: z = 1 - sqrt(1 - (i/radius)^2).""" + half = min(radius, 4096) + i = np.arange(-half, half + 1, dtype=np.float64) + t = np.clip((i / radius) ** 2, 0.0, 1.0) + return 1.0 - np.sqrt(1.0 - t) + + +def _arc_revolve_1d(data: np.ndarray, radius: int) -> np.ndarray: + """Compute arc-revolve background for each row independently.""" + yres, xres = data.shape + kernel = _arc_kernel(radius) + half = len(kernel) // 2 + bg = np.empty_like(data) + + for row in range(yres): + line = data[row].copy() + # Suppress deep outliers before fitting + window = min(half, xres // 2) + if window > 0: + from scipy.ndimage import uniform_filter1d + local_mean = uniform_filter1d(line, size=2 * window + 1, mode='nearest') + local_sq = uniform_filter1d(line ** 2, size=2 * window + 1, mode='nearest') + local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0)) + threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30) + line = np.maximum(line, threshold) + + # For each pixel, find the lowest position the arc can sit + padded = np.pad(line, half, mode='edge') + row_bg = np.full(xres, np.inf) + for k in range(len(kernel)): + shifted = padded[k:k + xres] - kernel[k] + row_bg = np.minimum(row_bg, shifted) + bg[row] = row_bg + + return bg + + +@register_node(display_name="Arc Revolve") +class ArcRevolve: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "radius": ("INT", {"default": 20, "min": 1, "max": 1000, "step": 1}), + "direction": (["horizontal", "vertical", "both"], {"default": "horizontal"}), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'leveled'), + ('DATA_FIELD', 'background'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Subtract a cylindrical arc background. A circular arc of the given " + "radius is rolled under each row (or column), and the envelope it " + "traces out is subtracted as the background." + ) + + KEYWORDS = ("arc", "revolve", "cylindrical", "background", "level") + + def process(self, field: DataField, radius: int = 20, + direction: str = "horizontal") -> tuple: + data = np.asarray(field.data, dtype=np.float64) + + if direction == "horizontal": + bg = _arc_revolve_1d(data, radius) + elif direction == "vertical": + bg = _arc_revolve_1d(data.T, radius).T + else: + bg_h = _arc_revolve_1d(data, radius) + bg_v = _arc_revolve_1d(data.T, radius).T + bg = np.minimum(bg_h, bg_v) + + return (field.replace(data=data - bg), field.replace(data=bg)) diff --git a/backend/nodes/level_rotate.py b/backend/nodes/level_rotate.py new file mode 100644 index 0000000..011396c --- /dev/null +++ b/backend/nodes/level_rotate.py @@ -0,0 +1,75 @@ +"""Level Rotate — level by physically rotating the data plane.""" + +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="Level Rotate") +class LevelRotate: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'leveled'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Level by physically rotating the data plane. Fits a best-fit plane, " + "converts its slopes to tilt angles, then rotates the surface by " + "those angles using interpolation rather than algebraic subtraction." + ) + + KEYWORDS = ("rotate", "tilt", "level", "plane") + + def process(self, field: DataField) -> tuple: + data = np.asarray(field.data, dtype=np.float64) + yres, xres = data.shape + + # Fit plane: z = a + bx*x + by*y (x,y in pixel coords) + yy, xx = np.mgrid[:yres, :xres].astype(np.float64) + A = np.column_stack([np.ones(yres * xres), xx.ravel(), yy.ravel()]) + coeffs, _, _, _ = np.linalg.lstsq(A, data.ravel(), rcond=None) + _, bx, by = coeffs + + # Convert pixel slopes to tilt angles + alpha_x = np.arctan(bx) + alpha_y = np.arctan(by) + + # Build rotation: for each output pixel, find where it came from + cx = (xres - 1) / 2.0 + cy = (yres - 1) / 2.0 + + cos_x = np.cos(alpha_x) + cos_y = np.cos(alpha_y) + + # Source coordinates after removing tilt + src_x = xx.copy() + src_y = yy.copy() + src_z = data.copy() + + # Rotate about x-axis (corrects y-tilt) + dy = yy - cy + src_y_rot = cy + dy * cos_y + src_z = src_z - dy * np.sin(alpha_y) + + # Rotate about y-axis (corrects x-tilt) + dx = xx - cx + src_x_rot = cx + dx * cos_x + src_z = src_z - dx * np.sin(alpha_x) + + # Resample with the adjusted z values + result = map_coordinates(src_z, [src_y_rot, src_x_rot], order=1, + mode='nearest') + + return (field.replace(data=result),) diff --git a/backend/nodes/sphere_revolve.py b/backend/nodes/sphere_revolve.py new file mode 100644 index 0000000..7c1e39c --- /dev/null +++ b/backend/nodes/sphere_revolve.py @@ -0,0 +1,74 @@ +"""Sphere Revolve — subtract a spherical cap background.""" + +from __future__ import annotations + +import numpy as np +from scipy.ndimage import uniform_filter + +from backend.node_registry import register_node +from backend.data_types import DataField + + +def _sphere_kernel(radius: int) -> np.ndarray: + """Build a 2D spherical cap kernel.""" + half = min(radius, 512) + i = np.arange(-half, half + 1, dtype=np.float64) + ii, jj = np.meshgrid(i, i) + r2 = (ii ** 2 + jj ** 2) / (radius ** 2) + r2 = np.clip(r2, 0.0, 1.0) + return 1.0 - np.sqrt(1.0 - r2) + + +@register_node(display_name="Sphere Revolve") +class SphereRevolve: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "radius": ("INT", {"default": 20, "min": 1, "max": 500, "step": 1}), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'leveled'), + ('DATA_FIELD', 'background'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Subtract a spherical cap background. A sphere of the given radius " + "is rolled under the surface, and the envelope it traces is " + "subtracted as the background." + ) + + KEYWORDS = ("sphere", "revolve", "spherical", "background", "level") + + def process(self, field: DataField, radius: int = 20) -> tuple: + data = np.asarray(field.data, dtype=np.float64) + yres, xres = data.shape + + kernel = _sphere_kernel(radius) + half = kernel.shape[0] // 2 + + # Suppress deep outliers + window = max(1, half // 2) + local_mean = uniform_filter(data, size=2 * window + 1, mode='nearest') + local_sq = uniform_filter(data ** 2, size=2 * window + 1, mode='nearest') + local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0)) + threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30) + clipped = np.maximum(data, threshold) + + padded = np.pad(clipped, half, mode='edge') + bg = np.full_like(data, np.inf) + + ks = kernel.shape[0] + for di in range(ks): + for dj in range(ks): + k_val = kernel[di, dj] + if k_val >= 1.0: + continue + shifted = padded[di:di + yres, dj:dj + xres] - k_val + bg = np.minimum(bg, shifted) + + return (field.replace(data=data - bg), field.replace(data=bg)) diff --git a/backend/nodes/unrotate.py b/backend/nodes/unrotate.py new file mode 100644 index 0000000..bb55188 --- /dev/null +++ b/backend/nodes/unrotate.py @@ -0,0 +1,88 @@ +"""Unrotate — auto-detect and correct in-plane scan rotation.""" + +from __future__ import annotations + +import numpy as np +from scipy.ndimage import rotate as ndimage_rotate + +from backend.node_registry import register_node +from backend.data_types import DataField + + +def _slope_angle_histogram(data: np.ndarray, n_bins: int = 3600) -> np.ndarray: + """Compute histogram of local slope angles over [0, 2*pi).""" + dy = np.diff(data, axis=0)[:, :-1] + dx = np.diff(data, axis=1)[:-1, :] + angles = np.arctan2(dy, dx) % (2 * np.pi) + hist, _ = np.histogram(angles.ravel(), bins=n_bins, range=(0, 2 * np.pi)) + return hist.astype(np.float64) + + +def _find_dominant_angle(hist: np.ndarray, symmetry: int) -> float: + """Find the rotation correction angle for a given symmetry order. + + Folds the histogram into one symmetry sector, finds the peak, and + returns the offset to the nearest axis. + """ + n_bins = len(hist) + sector = n_bins // symmetry + folded = np.zeros(sector, dtype=np.float64) + for k in range(symmetry): + start = k * sector + end = start + sector + if end <= n_bins: + folded += hist[start:end] + + peak_bin = int(np.argmax(folded)) + bin_angle = (2 * np.pi / symmetry) / sector + + # The angle of the peak + peak_angle = peak_bin * bin_angle + + # The nearest axis is at multiples of pi/symmetry + axis_spacing = np.pi / symmetry + nearest_axis = round(peak_angle / axis_spacing) * axis_spacing + correction = nearest_axis - peak_angle + + return float(correction) + + +@register_node(display_name="Unrotate") +class Unrotate: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "symmetry": (["2-fold", "3-fold", "4-fold", "6-fold"], {"default": "4-fold"}), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'leveled'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Auto-detect and correct in-plane scan rotation. Computes a slope " + "angle histogram, finds the dominant feature direction for the given " + "symmetry, and rotates the image to align features with the axes." + ) + + KEYWORDS = ("rotation", "alignment", "angle", "symmetry", "crystal") + + def process(self, field: DataField, symmetry: str = "4-fold") -> tuple: + data = np.asarray(field.data, dtype=np.float64) + + sym_order = int(symmetry[0]) + hist = _slope_angle_histogram(data) + angle_rad = _find_dominant_angle(hist, sym_order) + angle_deg = float(np.degrees(angle_rad)) + + if abs(angle_deg) < 0.01: + return (field,) + + rotated = ndimage_rotate(data, angle_deg, reshape=False, order=1, + mode='nearest') + + return (field.replace(data=rotated),) diff --git a/backend/nodes/zero_value.py b/backend/nodes/zero_value.py new file mode 100644 index 0000000..a2de5f3 --- /dev/null +++ b/backend/nodes/zero_value.py @@ -0,0 +1,56 @@ +"""Zero Value — shift data so the mean or maximum equals zero.""" + +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="Zero Mean") +class ZeroMean: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'leveled'), + ) + FUNCTION = "process" + + DESCRIPTION = "Shift all values so the mean is exactly zero." + + KEYWORDS = ("offset", "center", "level", "mean") + + def process(self, field: DataField) -> tuple: + data = np.asarray(field.data, dtype=np.float64) + return (field.replace(data=data - data.mean()),) + + +@register_node(display_name="Zero Maximum") +class ZeroMaximum: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + } + } + + OUTPUTS = ( + ('DATA_FIELD', 'leveled'), + ) + FUNCTION = "process" + + DESCRIPTION = "Shift all values so the maximum is exactly zero." + + KEYWORDS = ("offset", "level", "maximum") + + def process(self, field: DataField) -> tuple: + data = np.asarray(field.data, dtype=np.float64) + return (field.replace(data=data - data.max()),) diff --git a/docs/missing_gwyddion_features.md b/docs/missing_gwyddion_features.md new file mode 100644 index 0000000..e5fc1b7 --- /dev/null +++ b/docs/missing_gwyddion_features.md @@ -0,0 +1,98 @@ +# Missing Gwyddion Features + +Gwyddion 2D image/surface processing features not yet implemented in tono. Excludes force curves, force volume, spectroscopy, volume data, XYZ data, graph operations, and file I/O. + +## Leveling / Background Removal + +- [x] **Arc Revolve** — Subtract cylindrical arc background fitted by revolving an arc under the data +- [x] **Sphere Revolve** — Subtract spherical cap background +- [x] **Unrotate** — Auto-detect and correct in-plane scan rotation by finding dominant feature directions +- [x] **Level Rotate** — Level by physically rotating the data plane rather than subtracting a polynomial +- [x] **Zero Mean Value** — Shift all values so the mean is exactly zero (pure offset, no plane fit) +- [x] **Zero Maximum Value** — Shift all values so the maximum is exactly zero + +## Filtering / Signal Processing + +- [ ] **2D CWT** — Continuous Wavelet Transform for scale-space analysis +- [ ] **XY Denoise** — Denoise by combining two orthogonal scans (forward/backward or horizontal/vertical) +- [ ] **Rank Presentation** — Rank transform image for local contrast enhancement +- [ ] **Radial Smoothing** — Smooth data in polar coordinates, averaging along radial or angular direction +- [ ] **Convolve Two Images** — Convolve two separate data channels together + +## Line Correction / Scan Artifacts + +- [ ] **Step Block Correction** — Correct vertical step offsets between scan lines by block-matching +- [ ] **Good Mean Profile** — Compute a high-quality average scan line from repeated scans +- [ ] **Align Rows (extended methods)** — Modus and Gaussian-weighted row alignment beyond tono's current set + +## Correction / Restoration + +- [ ] **Fractal Correction** — Fill masked/bad pixels using fractal interpolation (alternative to Laplace) +- [ ] **Correlation Averaging** — Average repeated similar structures using autocorrelation alignment +- [ ] **Coerce** — Force data to match the histogram distribution of another dataset +- [ ] **Periodic Translate** — Translate image data treating the field as periodic (wrap-around shift) +- [ ] **Reorder** — Reorder pixel rows/columns (interleaved to sequential, reverse scan, etc.) + +## Statistical Analysis + +- [ ] **Transfer Function Fit** — Fit PSF from a known reference image and a measured blurred image +- [ ] **Transfer Function Guess** — Estimate PSF from a single image without a reference +- [ ] **Angle Distribution** — Distribution of surface normal angles (distinct from slope distribution) + +## Grain Operations + +- [ ] **Otsu Threshold** — Automated grain/mask threshold using Otsu's method +- [ ] **Remove Edge-Touching Grains** — Remove all grains touching the image border from a mask +- [ ] **Grain Selection Shapes** — Create geometric selections (bounding boxes, inscribed discs, etc.) from grain masks + +## Mask Operations + +- [ ] **Mask Thin** — Morphological thinning to single-pixel-wide skeletons +- [ ] **Mask Distribute** — Copy/distribute a mask to multiple channels simultaneously +- [ ] **Mark With** — Create or modify a mask using arithmetic conditions on other channels + +## Basic Operations + +- [ ] **Invert Value** — Flip heights (z to -z) +- [ ] **Log Scale Presentation** — Log-scaled presentation layer without modifying source data +- [ ] **Limit Range** — Clamp data values to a specified min/max range +- [ ] **Square Samples** — Resample so pixels are physically square (equal x/y size) +- [ ] **Null Offsets** — Zero out the lateral (XY) origin offsets + +## SPM-Specific Modes + +- [ ] **MFM Field Simulation** — Simulate magnetic stray field above perpendicular media +- [ ] **MFM Parallel Media** — Simulate MFM signal for in-plane magnetic media +- [ ] **MFM Lift Shift** — Simulate MFM signal change when lift height changes +- [ ] **MFM Lift Estimate** — Estimate lift height difference from data blur +- [ ] **MFM Force Gradient** — Convert MFM raw data to force gradient units +- [ ] **SMM Apply Calibration** — Apply Scanning Microwave Microscopy calibration coefficients + +## Synthetic Surface Generators + +Tono has one generic Synthetic Surface node. Gwyddion has ~20+ specialized generators: + +- [ ] **Fractional Brownian Motion** — fBm rough surfaces with controlled Hurst exponent +- [ ] **Spectral Synthesis** — PSD-specified random rough surfaces +- [ ] **Lattice** — Crystalline lattice surface with defects +- [ ] **Objects** — Randomly placed 3D objects (spheres, pyramids, etc.) +- [ ] **Patterns** — Geometric patterns (staircase, gratings, etc.) +- [ ] **Waves** — Sinusoidal/wave patterns +- [ ] **Noise** — Uncorrelated random noise with configurable distribution +- [ ] **Line Noise** — Synthetic scan-line noise/steps/scars for testing +- [ ] **Fibres** — Random fibre network surfaces +- [ ] **Domain Walls** — Phase-separated domain structures +- [ ] **Columnar Growth** — Columnar thin-film growth simulation +- [ ] **Ball Deposition** — Random ballistic deposition growth +- [ ] **Particle Deposition** — Dynamical particle deposition model +- [ ] **Rod Deposition** — Rod-like particle deposition +- [ ] **Diffusion** — Diffusion-limited aggregation surfaces +- [ ] **Discs** — Random overlapping disc surfaces +- [ ] **CPDE / Turing** — Reaction-diffusion / Turing pattern surfaces +- [ ] **Sand Dunes** — Aeolian sand transport simulation +- [ ] **Annealing Lattice Gas** — Annealed lattice-gas model textures +- [ ] **Phase Separation** — Spinodal decomposition textures +- [ ] **Pileup** — Piled-up ellipsoids or bars +- [ ] **Plateaus** — Stacked random plateau/terrace structures +- [ ] **Film Residue** — Residue left after simulated film removal +- [ ] **Wetting Front** — Propagating wetting front simulation diff --git a/docs/nodes/Arc Revolve.md b/docs/nodes/Arc Revolve.md new file mode 100644 index 0000000..1c7c83d --- /dev/null +++ b/docs/nodes/Arc Revolve.md @@ -0,0 +1,29 @@ +# Arc Revolve + +Subtract a cylindrical arc background. A circular arc of the given radius is rolled under each row (or column), and the envelope it traces is subtracted as the background. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| leveled | DATA_FIELD | Field with arc background subtracted | +| background | DATA_FIELD | The estimated arc background | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| radius | INT | 20 | Arc radius in pixels (1–1000) | +| direction | dropdown | horizontal | Direction to apply the arc: horizontal, vertical, or both | + +## Notes + +- Larger radii produce smoother backgrounds that follow gentle curvature. Smaller radii track finer features. +- The "both" direction takes the minimum of horizontal and vertical backgrounds. +- Deep outliers are suppressed before fitting so that scratches or pits do not pull the arc down. diff --git a/docs/nodes/Level Rotate.md b/docs/nodes/Level Rotate.md new file mode 100644 index 0000000..ec749c5 --- /dev/null +++ b/docs/nodes/Level Rotate.md @@ -0,0 +1,21 @@ +# Level Rotate + +Level by physically rotating the data plane. Fits a best-fit plane, converts its slopes to tilt angles, then rotates the surface by those angles using interpolation rather than algebraic subtraction. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field to level | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| leveled | DATA_FIELD | Field with tilt removed by rotation | + +## Notes + +- Unlike Plane Level (which subtracts a fitted plane), this node rotates the 3D surface to make it horizontal. The distinction matters for steep tilts where subtraction introduces distortion. +- Uses bilinear interpolation to resample rotated z-values. +- Edges are handled with nearest-neighbor extension. diff --git a/docs/nodes/Sphere Revolve.md b/docs/nodes/Sphere Revolve.md new file mode 100644 index 0000000..156095a --- /dev/null +++ b/docs/nodes/Sphere Revolve.md @@ -0,0 +1,28 @@ +# Sphere Revolve + +Subtract a spherical cap background. A sphere of the given radius is rolled under the surface, and the envelope it traces is subtracted as the background. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| leveled | DATA_FIELD | Field with spherical background subtracted | +| background | DATA_FIELD | The estimated spherical background | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| radius | INT | 20 | Sphere radius in pixels (1–500) | + +## Notes + +- Works like Arc Revolve but in two dimensions — suitable for bowl-shaped or dome-shaped backgrounds. +- Larger radii produce smoother backgrounds. Very small radii will track individual features. +- Deep outliers are suppressed before fitting. diff --git a/docs/nodes/Unrotate.md b/docs/nodes/Unrotate.md new file mode 100644 index 0000000..12f3997 --- /dev/null +++ b/docs/nodes/Unrotate.md @@ -0,0 +1,28 @@ +# Unrotate + +Auto-detect and correct in-plane scan rotation. Computes a slope angle histogram, finds the dominant feature direction for the given symmetry, and rotates the image to align features with the axes. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field to correct | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| leveled | DATA_FIELD | Field with rotation corrected | + +## Controls + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| symmetry | dropdown | 4-fold | Expected symmetry of the surface features: 2-fold, 3-fold, 4-fold, or 6-fold | + +## Notes + +- Best suited for crystalline or patterned surfaces where features have a clear preferred direction. +- 4-fold symmetry is the most common choice for cubic crystal surfaces and rectangular gratings. +- If the detected rotation is less than 0.01°, the data is returned unchanged. +- Uses bilinear interpolation; edges are handled with nearest-neighbor extension. diff --git a/docs/nodes/Zero Maximum.md b/docs/nodes/Zero Maximum.md new file mode 100644 index 0000000..b8c0d99 --- /dev/null +++ b/docs/nodes/Zero Maximum.md @@ -0,0 +1,20 @@ +# Zero Maximum + +Shift all values so the maximum is exactly zero. All resulting values will be zero or negative. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field to level | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| leveled | DATA_FIELD | Field with maximum subtracted | + +## Notes + +- Equivalent to subtracting the global maximum from every pixel. +- Useful when the highest point should represent the zero reference. diff --git a/docs/nodes/Zero Mean.md b/docs/nodes/Zero Mean.md new file mode 100644 index 0000000..b8d0f1f --- /dev/null +++ b/docs/nodes/Zero Mean.md @@ -0,0 +1,20 @@ +# Zero Mean + +Shift all values so the mean is exactly zero. A pure offset subtraction — no plane fit or polynomial involved. + +## Inputs + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| field | DATA_FIELD | Yes | Input field to level | + +## Outputs + +| Name | Type | Description | +|------|------|-------------| +| leveled | DATA_FIELD | Field with mean subtracted | + +## Notes + +- Equivalent to subtracting a constant (the mean) from every pixel. +- Does not change relative height differences — only shifts the overall offset. diff --git a/tests/node_tests/arc_revolve.py b/tests/node_tests/arc_revolve.py new file mode 100644 index 0000000..2c3fe11 --- /dev/null +++ b/tests/node_tests/arc_revolve.py @@ -0,0 +1,55 @@ +import numpy as np +from tests.node_tests._shared import make_field + + +def test_arc_revolve_horizontal(): + from backend.nodes.arc_revolve import ArcRevolve + + node = ArcRevolve() + rng = np.random.default_rng(42) + x = np.linspace(0, 1, 64) + bow = 10.0 * x ** 2 + data = bow[None, :] + rng.standard_normal((64, 64)) * 0.01 + field = make_field(data=data) + + leveled, bg = node.process(field, radius=40, direction="horizontal") + assert leveled.data.shape == field.data.shape + assert bg.data.shape == field.data.shape + assert np.allclose(leveled.data + bg.data, data) + + +def test_arc_revolve_vertical(): + from backend.nodes.arc_revolve import ArcRevolve + + node = ArcRevolve() + y = np.linspace(0, 1, 64) + data = (5.0 * y ** 2)[:, None] * np.ones((1, 64)) + field = make_field(data=data) + + leveled, bg = node.process(field, radius=40, direction="vertical") + assert np.allclose(leveled.data + bg.data, data) + + +def test_arc_revolve_both(): + from backend.nodes.arc_revolve import ArcRevolve + + node = ArcRevolve() + y, x = np.mgrid[:32, :32] / 32.0 + data = 5.0 * x ** 2 + 3.0 * y ** 2 + field = make_field(data=data) + + leveled, bg = node.process(field, radius=30, direction="both") + assert leveled.data.shape == data.shape + assert bg.data.shape == data.shape + + +def test_arc_revolve_flat_passthrough(): + from backend.nodes.arc_revolve import ArcRevolve + + node = ArcRevolve() + data = np.ones((32, 32)) * 5.0 + field = make_field(data=data) + + leveled, bg = node.process(field, radius=20, direction="horizontal") + assert leveled.data.std() < 1e-10 + assert np.allclose(leveled.data + bg.data, data) diff --git a/tests/node_tests/level_rotate.py b/tests/node_tests/level_rotate.py new file mode 100644 index 0000000..650cc6f --- /dev/null +++ b/tests/node_tests/level_rotate.py @@ -0,0 +1,37 @@ +import numpy as np +from tests.node_tests._shared import make_field + + +def test_level_rotate_removes_tilt(): + from backend.nodes.level_rotate import LevelRotate + + node = LevelRotate() + y, x = np.mgrid[:64, :64].astype(np.float64) + data = 2.0 * x + 3.0 * y + field = make_field(data=data) + + (result,) = node.process(field) + assert result.data.shape == data.shape + assert result.data.std() < data.std() * 0.25 + + +def test_level_rotate_preserves_shape(): + from backend.nodes.level_rotate import LevelRotate + + node = LevelRotate() + data = np.random.default_rng(42).standard_normal((48, 48)) + field = make_field(data=data) + + (result,) = node.process(field) + assert result.data.shape == (48, 48) + + +def test_level_rotate_flat_noop(): + from backend.nodes.level_rotate import LevelRotate + + node = LevelRotate() + data = np.ones((32, 32)) * 7.0 + field = make_field(data=data) + + (result,) = node.process(field) + assert np.allclose(result.data, 7.0, atol=1e-6) diff --git a/tests/node_tests/sphere_revolve.py b/tests/node_tests/sphere_revolve.py new file mode 100644 index 0000000..e554ef8 --- /dev/null +++ b/tests/node_tests/sphere_revolve.py @@ -0,0 +1,41 @@ +import numpy as np +from tests.node_tests._shared import make_field + + +def test_sphere_revolve_basic(): + from backend.nodes.sphere_revolve import SphereRevolve + + node = SphereRevolve() + y, x = np.mgrid[:64, :64] / 64.0 + data = 10.0 * (x ** 2 + y ** 2) + field = make_field(data=data) + + leveled, bg = node.process(field, radius=30) + assert leveled.data.shape == data.shape + assert bg.data.shape == data.shape + assert np.allclose(leveled.data + bg.data, data) + + +def test_sphere_revolve_flat(): + from backend.nodes.sphere_revolve import SphereRevolve + + node = SphereRevolve() + data = np.ones((32, 32)) * 3.0 + field = make_field(data=data) + + leveled, bg = node.process(field, radius=20) + assert leveled.data.std() < 1e-10 + assert np.allclose(leveled.data + bg.data, data) + + +def test_sphere_revolve_outputs_two_fields(): + from backend.nodes.sphere_revolve import SphereRevolve + + node = SphereRevolve() + data = np.random.default_rng(7).standard_normal((32, 32)) + field = make_field(data=data) + + result = node.process(field, radius=15) + assert len(result) == 2 + leveled, bg = result + assert np.allclose(leveled.data + bg.data, data) diff --git a/tests/node_tests/unrotate.py b/tests/node_tests/unrotate.py new file mode 100644 index 0000000..cfbfb8c --- /dev/null +++ b/tests/node_tests/unrotate.py @@ -0,0 +1,50 @@ +import numpy as np +from tests.node_tests._shared import make_field + + +def test_unrotate_preserves_shape(): + from backend.nodes.unrotate import Unrotate + + node = Unrotate() + data = np.random.default_rng(42).standard_normal((64, 64)) + field = make_field(data=data) + + (result,) = node.process(field, symmetry="4-fold") + assert result.data.shape == (64, 64) + + +def test_unrotate_small_angle(): + from backend.nodes.unrotate import Unrotate, _slope_angle_histogram, _find_dominant_angle + + y, x = np.mgrid[:128, :128].astype(np.float64) + angle_deg = 3.0 + angle_rad = np.radians(angle_deg) + data = np.sin(2 * np.pi * (x * np.cos(angle_rad) + y * np.sin(angle_rad)) / 20.0) + + hist = _slope_angle_histogram(data) + correction = _find_dominant_angle(hist, 4) + assert abs(np.degrees(correction)) < 10.0 + + +def test_unrotate_no_rotation_passthrough(): + from backend.nodes.unrotate import Unrotate + + node = Unrotate() + y, x = np.mgrid[:64, :64].astype(np.float64) + data = np.sin(2 * np.pi * x / 16.0) + field = make_field(data=data) + + (result,) = node.process(field, symmetry="4-fold") + assert np.allclose(result.data, data, atol=0.1) + + +def test_unrotate_symmetry_options(): + from backend.nodes.unrotate import Unrotate + + node = Unrotate() + data = np.random.default_rng(99).standard_normal((64, 64)) + field = make_field(data=data) + + for sym in ["2-fold", "3-fold", "4-fold", "6-fold"]: + (result,) = node.process(field, symmetry=sym) + assert result.data.shape == (64, 64) diff --git a/tests/node_tests/zero_value.py b/tests/node_tests/zero_value.py new file mode 100644 index 0000000..89874a5 --- /dev/null +++ b/tests/node_tests/zero_value.py @@ -0,0 +1,46 @@ +import numpy as np +from tests.node_tests._shared import make_field + + +def test_zero_mean(): + from backend.nodes.zero_value import ZeroMean + + node = ZeroMean() + data = np.random.default_rng(42).standard_normal((64, 64)) + 100.0 + field = make_field(data=data) + (result,) = node.process(field) + assert result.data.shape == field.data.shape + assert abs(result.data.mean()) < 1e-10 + + +def test_zero_mean_preserves_variation(): + from backend.nodes.zero_value import ZeroMean + + node = ZeroMean() + data = np.random.default_rng(7).standard_normal((32, 32)) + 50.0 + field = make_field(data=data) + (result,) = node.process(field) + assert np.allclose(result.data - result.data.mean(), data - data.mean()) + + +def test_zero_maximum(): + from backend.nodes.zero_value import ZeroMaximum + + node = ZeroMaximum() + data = np.random.default_rng(42).standard_normal((64, 64)) + 100.0 + field = make_field(data=data) + (result,) = node.process(field) + assert result.data.shape == field.data.shape + assert abs(result.data.max()) < 1e-10 + assert result.data.min() < 0 + + +def test_zero_maximum_preserves_differences(): + from backend.nodes.zero_value import ZeroMaximum + + node = ZeroMaximum() + data = np.array([[1.0, 3.0], [2.0, 5.0]]) + field = make_field(data=data) + (result,) = node.process(field) + expected = data - 5.0 + assert np.allclose(result.data, expected)