low pri features

This commit is contained in:
2026-04-04 00:25:53 -07:00
parent 4818c1123c
commit 5de93e6c4d
47 changed files with 3866 additions and 19 deletions

View File

@@ -0,0 +1,77 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_keep_mode_unchanged():
from backend.nodes.calibration import Calibration
node = Calibration()
field = make_field(data=np.array([[1.0, 2.0], [3.0, 4.0]]))
result, = node.process(
field,
xy_mode="keep", z_mode="keep",
xreal_new=1e-6, yreal_new=1e-6, xy_scale=1.0,
z_min=0.0, z_max=1e-9, z_scale=1.0, z_offset=0.0,
xy_unit="", z_unit="",
)
assert np.array_equal(result.data, field.data)
assert result.xreal == field.xreal
assert result.yreal == field.yreal
assert result.si_unit_xy == field.si_unit_xy
assert result.si_unit_z == field.si_unit_z
def test_set_size():
from backend.nodes.calibration import Calibration
node = Calibration()
field = make_field(data=np.array([[1.0, 2.0], [3.0, 4.0]]))
result, = node.process(
field,
xy_mode="set_size", z_mode="keep",
xreal_new=5e-6, yreal_new=3e-6, xy_scale=1.0,
z_min=0.0, z_max=1e-9, z_scale=1.0, z_offset=0.0,
xy_unit="", z_unit="",
)
assert result.xreal == 5e-6
assert result.yreal == 3e-6
assert np.array_equal(result.data, field.data)
def test_z_scale():
from backend.nodes.calibration import Calibration
node = Calibration()
data = np.array([[1.0, 2.0], [3.0, 4.0]])
field = make_field(data=data.copy())
result, = node.process(
field,
xy_mode="keep", z_mode="scale",
xreal_new=1e-6, yreal_new=1e-6, xy_scale=1.0,
z_min=0.0, z_max=1e-9, z_scale=2.0, z_offset=0.0,
xy_unit="", z_unit="",
)
np.testing.assert_allclose(result.data, data * 2.0)
def test_z_set_range():
from backend.nodes.calibration import Calibration
node = Calibration()
data = np.array([[1.0, 2.0], [3.0, 4.0]])
field = make_field(data=data.copy())
result, = node.process(
field,
xy_mode="keep", z_mode="set_range",
xreal_new=1e-6, yreal_new=1e-6, xy_scale=1.0,
z_min=0.0, z_max=1.0, z_scale=1.0, z_offset=0.0,
xy_unit="", z_unit="",
)
assert float(result.data.min()) == 0.0
assert float(result.data.max()) == 1.0
# Check that intermediate values are linearly mapped
np.testing.assert_allclose(result.data, (data - 1.0) / 3.0)

View File

@@ -0,0 +1,41 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
def test_output_shape():
from backend.nodes.displacement_field import DisplacementField
node = DisplacementField()
field = make_field(shape=(48, 64))
result, = node.process(field, "gaussian_1d", sigma=5.0, tau=20.0, density=0.02, seed=42)
assert result.data.shape == (48, 64)
def test_zero_sigma_unchanged():
from backend.nodes.displacement_field import DisplacementField
node = DisplacementField()
field = make_field(shape=(32, 32))
result, = node.process(field, "gaussian_1d", sigma=0.1, tau=20.0, density=0.02, seed=42)
np.testing.assert_allclose(result.data, field.data, atol=0.5)
def test_gaussian_2d_modifies():
from backend.nodes.displacement_field import DisplacementField
node = DisplacementField()
field = make_field(shape=(32, 32))
result, = node.process(field, "gaussian_2d", sigma=10.0, tau=20.0, density=0.02, seed=42)
# With sigma=10 the output should differ from the input
assert not np.allclose(result.data, field.data, atol=1e-3)
def test_all_methods_finite():
from backend.nodes.displacement_field import DisplacementField
node = DisplacementField()
field = make_field(shape=(32, 32))
for method in ("gaussian_1d", "gaussian_2d", "tear"):
result, = node.process(field, method, sigma=5.0, tau=20.0, density=0.02, seed=42)
assert np.all(np.isfinite(result.data)), f"{method} produced non-finite values"

View File

@@ -0,0 +1,61 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_output_shape():
from backend.nodes.distribution_coercion import DistributionCoercion
node = DistributionCoercion()
field = make_field(shape=(48, 64))
for dist in ("uniform", "gaussian", "levels"):
(result,) = node.process(field, distribution=dist, n_levels=4, processing="field")
assert result.data.shape == field.data.shape
def test_uniform_distribution():
from backend.nodes.distribution_coercion import DistributionCoercion
node = DistributionCoercion()
rng = np.random.default_rng(7)
data = rng.exponential(scale=2.0, size=(64, 64))
field = make_field(data=data)
(result,) = node.process(field, distribution="uniform", n_levels=4, processing="field")
assert np.isclose(result.data.min(), data.min())
assert np.isclose(result.data.max(), data.max())
# Histogram should be roughly uniform — check that no bin has more than
# 2x the expected count.
counts, _ = np.histogram(result.data.ravel(), bins=10)
expected = result.data.size / 10
assert all(c < 2.0 * expected for c in counts)
def test_levels_count():
from backend.nodes.distribution_coercion import DistributionCoercion
node = DistributionCoercion()
field = make_field(shape=(64, 64))
for n in (2, 5, 10):
(result,) = node.process(field, distribution="levels", n_levels=n, processing="field")
assert len(np.unique(result.data)) == n
def test_row_mode():
from backend.nodes.distribution_coercion import DistributionCoercion
node = DistributionCoercion()
field = make_field(shape=(32, 48))
(result,) = node.process(field, distribution="uniform", n_levels=4, processing="rows")
assert result.data.shape == field.data.shape
# Each row should span the row's own min/max
for i in range(field.data.shape[0]):
row_in = field.data[i]
row_out = result.data[i]
assert np.isclose(row_out.min(), row_in.min())
assert np.isclose(row_out.max(), row_in.max())

View File

@@ -0,0 +1,64 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
def test_output_shape():
"""Anisotropy map shape must match the input field."""
from backend.nodes.dwt_anisotropy import DWTAnisotropy
node = DWTAnisotropy()
field = make_field(shape=(64, 64))
aniso_field, stats = node.process(field, n_levels=4, ratio_threshold=0.2)
assert aniso_field.data.shape == (64, 64)
def test_isotropic_surface():
"""A random isotropic surface should have X/Y energy ratios near 1.0."""
from backend.nodes.dwt_anisotropy import DWTAnisotropy
rng = np.random.default_rng(42)
# Use a larger field so deeper levels still have enough coefficients
data = rng.standard_normal((128, 128))
field = make_field(data=data)
node = DWTAnisotropy()
aniso_field, stats = node.process(field, n_levels=3, ratio_threshold=0.2)
for row in stats:
assert 0.5 < row["ratio"] < 2.0, (
f"Level {row['level']} ratio {row['ratio']:.3f} too far from 1.0 for isotropic surface"
)
def test_statistics_table():
"""Statistics output is a list of dicts with the expected keys."""
from backend.nodes.dwt_anisotropy import DWTAnisotropy
node = DWTAnisotropy()
field = make_field(shape=(64, 64))
aniso_field, stats = node.process(field, n_levels=3, ratio_threshold=0.2)
assert isinstance(stats, list)
assert len(stats) == 3
expected_keys = {"level", "x_energy", "y_energy", "ratio", "anisotropic"}
for row in stats:
assert isinstance(row, dict)
assert set(row.keys()) == expected_keys
def test_anisotropic_detection():
"""Horizontal stripes should produce a ratio clearly different from 1.0."""
from backend.nodes.dwt_anisotropy import DWTAnisotropy
# Create horizontal stripes: constant along columns, varying along rows
data = np.tile(np.sin(np.linspace(0, 10 * np.pi, 64)), (64, 1))
field = make_field(data=data)
node = DWTAnisotropy()
aniso_field, stats = node.process(field, n_levels=4, ratio_threshold=0.2)
# At least one level should show a ratio far from 1.0
has_anisotropic = any(abs(row["ratio"] - 1.0) > 0.2 for row in stats)
assert has_anisotropic, (
f"Expected anisotropic detection for horizontal stripes, ratios: "
f"{[row['ratio'] for row in stats]}"
)

View File

@@ -0,0 +1,64 @@
import numpy as np
from tests.node_tests._shared import make_field
def _make_test_inputs():
"""Create a 64x64 field and mask with two isolated blobs."""
data = np.zeros((64, 64), dtype=np.float64)
data[10:20, 10:20] = 5.0
data[40:55, 40:55] = 3.0
field = make_field(data=data, xreal=1e-6, yreal=1e-6)
mask = np.zeros((64, 64), dtype=np.uint8)
mask[10:20, 10:20] = 255
mask[40:55, 40:55] = 255
return field, mask
def test_output_shape():
from backend.nodes.grain_visualization import GrainVisualization
node = GrainVisualization()
field, mask = _make_test_inputs()
result, labeled = node.process(field, mask, style="inscribed_disc", fill=False)
assert result.shape == mask.shape, (
f"Result shape {result.shape} does not match input shape {mask.shape}"
)
def test_labeled_grains():
from backend.nodes.grain_visualization import GrainVisualization
node = GrainVisualization()
field, mask = _make_test_inputs()
result, labeled = node.process(field, mask, style="inscribed_disc", fill=False)
unique_ids = set(np.unique(labeled.data)) - {0.0}
assert len(unique_ids) == 2, (
f"Expected 2 unique nonzero grain labels, got {len(unique_ids)}: {unique_ids}"
)
def test_disc_style():
from backend.nodes.grain_visualization import GrainVisualization
node = GrainVisualization()
field, mask = _make_test_inputs()
result_outline, _ = node.process(field, mask, style="inscribed_disc", fill=False)
assert np.any(result_outline > 0), "inscribed_disc outline produced an empty mask"
result_filled, _ = node.process(field, mask, style="inscribed_disc", fill=True)
assert np.any(result_filled > 0), "inscribed_disc filled produced an empty mask"
def test_bounding_box_style():
from backend.nodes.grain_visualization import GrainVisualization
node = GrainVisualization()
field, mask = _make_test_inputs()
result_outline, _ = node.process(field, mask, style="bounding_box", fill=False)
assert np.any(result_outline > 0), "bounding_box outline produced an empty mask"
result_filled, _ = node.process(field, mask, style="bounding_box", fill=True)
assert np.any(result_filled > 0), "bounding_box filled produced an empty mask"

View File

@@ -0,0 +1,111 @@
import numpy as np
from backend.execution_context import active_node, execution_callbacks
from tests.node_tests._shared import make_field
def test_output_shapes():
from backend.nodes.logistic_classification import LogisticClassification
node = LogisticClassification()
data = np.random.default_rng(0).standard_normal((64, 64))
field = make_field(data=data)
previews = []
with execution_callbacks(preview=lambda nid, uri: previews.append(uri)), active_node("test"):
mask, prob = node.process(
field,
use_gaussians=True,
n_gaussians=4,
use_sobel=True,
use_laplacian=True,
regularization=1.0,
max_iter=500,
seed=42,
)
assert mask.shape == field.data.shape
assert prob.data.shape == field.data.shape
def test_mask_binary():
from backend.nodes.logistic_classification import LogisticClassification
node = LogisticClassification()
data = np.zeros((32, 32))
data[:, 16:] = 1.0
field = make_field(data=data)
with execution_callbacks(preview=lambda nid, uri: None), active_node("test"):
mask, _ = node.process(
field,
use_gaussians=True,
n_gaussians=2,
use_sobel=True,
use_laplacian=True,
regularization=1.0,
max_iter=500,
seed=42,
)
unique = set(np.unique(mask))
assert unique <= {0, 255}, f"Mask contains non-binary values: {unique}"
def test_probability_range():
from backend.nodes.logistic_classification import LogisticClassification
node = LogisticClassification()
data = np.random.default_rng(7).standard_normal((48, 48))
field = make_field(data=data)
with execution_callbacks(preview=lambda nid, uri: None), active_node("test"):
_, prob = node.process(
field,
use_gaussians=True,
n_gaussians=3,
use_sobel=True,
use_laplacian=True,
regularization=1.0,
max_iter=500,
seed=42,
)
assert prob.data.min() >= 0.0, f"Probability min {prob.data.min()} < 0"
assert prob.data.max() <= 1.0, f"Probability max {prob.data.max()} > 1"
def test_with_training():
from backend.nodes.logistic_classification import LogisticClassification
node = LogisticClassification()
# Create a field with two distinct regions
data = np.zeros((64, 64))
data[:, 32:] = 2.0
data += np.random.default_rng(1).standard_normal((64, 64)) * 0.1
field = make_field(data=data)
# Create a training mask marking the right half as positive
training_mask = np.zeros((64, 64), dtype=np.uint8)
training_mask[:, 32:] = 255
with execution_callbacks(preview=lambda nid, uri: None), active_node("test"):
mask, prob = node.process(
field,
use_gaussians=True,
n_gaussians=3,
use_sobel=True,
use_laplacian=True,
regularization=1.0,
max_iter=500,
seed=42,
training_mask=training_mask,
)
assert mask.dtype == np.uint8
assert mask.shape == field.data.shape
# The classifier should learn that the right half is positive
right_positive = np.count_nonzero(mask[:, 32:] == 255)
left_positive = np.count_nonzero(mask[:, :32] == 255)
assert right_positive > left_positive, (
f"Expected more positives on right ({right_positive}) than left ({left_positive})"
)

View File

@@ -0,0 +1,43 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
def test_output_shape():
from backend.nodes.mark_disconnected import MarkDisconnected
node = MarkDisconnected()
field = make_field(shape=(64, 64))
mask, = node.process(field, defect_type="both", radius=5, threshold=0.1)
assert mask.shape == (64, 64)
def test_flat_surface_no_defects():
from backend.nodes.mark_disconnected import MarkDisconnected
node = MarkDisconnected()
data = np.ones((64, 64)) * 5.0
field = make_field(data=data)
mask, = node.process(field, defect_type="both", radius=5, threshold=0.1)
assert np.count_nonzero(mask) == 0
def test_spike_detected():
from backend.nodes.mark_disconnected import MarkDisconnected
node = MarkDisconnected()
data = np.ones((64, 64), dtype=np.float64)
mean_val = data.mean()
data[32, 32] = mean_val * 100 # large spike
field = make_field(data=data)
mask, = node.process(field, defect_type="positive", radius=3, threshold=0.05)
assert mask[32, 32] == 255
def test_output_is_uint8():
from backend.nodes.mark_disconnected import MarkDisconnected
node = MarkDisconnected()
field = make_field(shape=(32, 32))
mask, = node.process(field, defect_type="negative", radius=5, threshold=0.1)
assert mask.dtype == np.uint8

View File

@@ -0,0 +1,50 @@
import numpy as np
def _make_test_mask():
mask = np.zeros((64, 64), dtype=np.uint8)
mask[20:40, 20:40] = 255
return mask
def test_output_shape():
from backend.nodes.mask_noisify import MaskNoisify
node = MaskNoisify()
mask = _make_test_mask()
result, = node.process(mask, density=0.1, direction="both",
boundaries_only=True, seed=42)
assert result.shape == mask.shape
assert result.dtype == np.uint8
def test_zero_density_unchanged():
from backend.nodes.mask_noisify import MaskNoisify
node = MaskNoisify()
mask = _make_test_mask()
result, = node.process(mask, density=0.0, direction="both",
boundaries_only=True, seed=42)
assert np.array_equal(result, mask)
def test_density_modifies_mask():
from backend.nodes.mask_noisify import MaskNoisify
node = MaskNoisify()
mask = _make_test_mask()
result, = node.process(mask, density=0.5, direction="both",
boundaries_only=True, seed=42)
assert not np.array_equal(result, mask)
def test_seed_reproducibility():
from backend.nodes.mask_noisify import MaskNoisify
node = MaskNoisify()
mask = _make_test_mask()
result_a, = node.process(mask, density=0.3, direction="both",
boundaries_only=True, seed=123)
result_b, = node.process(mask, density=0.3, direction="both",
boundaries_only=True, seed=123)
assert np.array_equal(result_a, result_b)

View File

@@ -0,0 +1,74 @@
import numpy as np
import pytest
def _make_mask():
"""Create a simple test mask: 10x10 block of 255 in a 64x64 field."""
mask = np.zeros((64, 64), dtype=np.uint8)
mask[10:20, 10:20] = 255
return mask
def test_output_shape():
from backend.nodes.mask_shift import MaskShift
node = MaskShift()
mask = _make_mask()
result, = node.process(mask, shift_x=5, shift_y=3, border_mode="zero")
assert result.shape == mask.shape
assert result.dtype == np.uint8
result_wrap, = node.process(mask, shift_x=-10, shift_y=7, border_mode="wrap")
assert result_wrap.shape == mask.shape
result_mirror, = node.process(mask, shift_x=2, shift_y=-4, border_mode="mirror")
assert result_mirror.shape == mask.shape
def test_zero_shift_unchanged():
from backend.nodes.mask_shift import MaskShift
node = MaskShift()
mask = _make_mask()
result_zero, = node.process(mask, shift_x=0, shift_y=0, border_mode="zero")
assert np.array_equal(result_zero, mask)
result_wrap, = node.process(mask, shift_x=0, shift_y=0, border_mode="wrap")
assert np.array_equal(result_wrap, mask)
result_mirror, = node.process(mask, shift_x=0, shift_y=0, border_mode="mirror")
assert np.array_equal(result_mirror, mask)
def test_wrap_mode():
from backend.nodes.mask_shift import MaskShift
node = MaskShift()
mask = _make_mask()
# Shift block right by 60 pixels — the block at cols 10:20 should wrap
# and appear at cols 70%64=6 to 80%64=16, spanning the boundary.
result, = node.process(mask, shift_x=60, shift_y=0, border_mode="wrap")
assert result.dtype == np.uint8
# The total number of masked pixels should be preserved in wrap mode
assert np.count_nonzero(result) == np.count_nonzero(mask)
# Original location should not all still be set
# (shift is large enough to move block away from original position)
assert not np.array_equal(result, mask)
def test_zero_mode_fills():
from backend.nodes.mask_shift import MaskShift
node = MaskShift()
mask = _make_mask()
# Shift right by 5 — left 5 columns should be zeroed
result, = node.process(mask, shift_x=5, shift_y=0, border_mode="zero")
assert np.all(result[:, :5] == 0)
# Block should now be at cols 15:25, rows 10:20
assert np.all(result[10:20, 15:25] == 255)
# Shift down by 5 — top 5 rows should be zeroed
result2, = node.process(mask, shift_x=0, shift_y=5, border_mode="zero")
assert np.all(result2[:5, :] == 0)
# Block should now be at rows 15:25, cols 10:20
assert np.all(result2[15:25, 10:20] == 255)

View File

@@ -0,0 +1,72 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_output_shapes():
from backend.nodes.neural_classification import NeuralClassification
node = NeuralClassification()
data = np.random.default_rng(0).standard_normal((32, 32))
field = make_field(data=data)
mask, prob_field = node.process(field, n_gaussians=3, n_hidden=8,
train_steps=20, seed=7)
assert mask.shape == (32, 32)
assert prob_field.data.shape == (32, 32)
def test_mask_is_binary():
from backend.nodes.neural_classification import NeuralClassification
node = NeuralClassification()
data = np.random.default_rng(1).standard_normal((24, 24))
field = make_field(data=data)
mask, _ = node.process(field, n_gaussians=2, n_hidden=8,
train_steps=10, seed=0)
unique = set(np.unique(mask).tolist())
assert unique <= {0, 255}, f"Unexpected mask values: {unique}"
def test_probability_range():
from backend.nodes.neural_classification import NeuralClassification
node = NeuralClassification()
data = np.random.default_rng(2).standard_normal((32, 32))
field = make_field(data=data)
_, prob_field = node.process(field, n_gaussians=4, n_hidden=16,
train_steps=50, seed=42)
assert prob_field.data.min() >= 0.0
assert prob_field.data.max() <= 1.0
def test_with_training_mask():
from backend.nodes.neural_classification import NeuralClassification
node = NeuralClassification()
# Create a field with two distinct height regions
data = np.zeros((48, 48), dtype=np.float64)
data[:, 24:] = 5.0 # right half is elevated
field = make_field(data=data)
# Training mask: left half = 0 (class A), right half = 255 (class B)
training_mask = np.zeros((48, 48), dtype=np.uint8)
training_mask[:, 24:] = 255
mask, prob_field = node.process(field, n_gaussians=4, n_hidden=16,
train_steps=200, seed=42,
training_mask=training_mask)
assert mask.dtype == np.uint8
assert mask.shape == (48, 48)
assert prob_field.data.shape == (48, 48)
# The network should learn to classify the two regions correctly.
# Check that most of the right half is class B and left half is class A.
right_classified = np.count_nonzero(mask[:, 24:] == 255)
left_classified = np.count_nonzero(mask[:, :24] == 0)
total_half = 48 * 24
assert right_classified > total_half * 0.8, "Right half should mostly be class B"
assert left_classified > total_half * 0.8, "Left half should mostly be class A"

View File

@@ -0,0 +1,49 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_output_shape():
from backend.nodes.pixel_classification import PixelClassification
node = PixelClassification()
field = make_field(shape=(64, 64))
classified, mask = node.process(field, n_classes=3, feature="height", method="quantile")
assert classified.data.shape == field.data.shape
def test_correct_number_of_classes():
from backend.nodes.pixel_classification import PixelClassification
node = PixelClassification()
field = make_field(shape=(64, 64))
for n in (2, 4, 5):
classified, _ = node.process(field, n_classes=n, feature="height", method="quantile")
unique = np.unique(classified.data)
assert len(unique) <= n, f"Expected at most {n} classes, got {len(unique)}"
def test_equal_range_method():
from backend.nodes.pixel_classification import PixelClassification
node = PixelClassification()
# Linear ramp: equal_range should produce evenly distributed labels
ramp = np.linspace(0, 1, 64 * 64).reshape(64, 64)
field = make_field(data=ramp)
classified, _ = node.process(field, n_classes=4, feature="height", method="equal_range")
labels = classified.data.astype(int)
unique = np.unique(labels)
assert len(unique) == 4
# Each class should have roughly 25% of pixels
counts = [np.sum(labels == u) for u in unique]
for c in counts:
assert abs(c - 64 * 64 / 4) < 64 * 64 * 0.05 # within 5%
def test_mask_output():
from backend.nodes.pixel_classification import PixelClassification
node = PixelClassification()
field = make_field(shape=(32, 32))
_, mask = node.process(field, n_classes=3, feature="height", method="otsu")
assert mask.dtype == np.uint8
assert set(np.unique(mask)).issubset({0, 255})

View File

@@ -0,0 +1,43 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_output_shape():
from backend.nodes.presentation_ops import PresentationOps
node = PresentationOps()
field = make_field(shape=(32, 32))
result, = node.process(field, "logscale", blend_factor=0.5)
assert result.data.shape == field.data.shape
def test_logscale():
from backend.nodes.presentation_ops import PresentationOps
node = PresentationOps()
data = np.array([[1.0, 10.0], [100.0, 1000.0]])
field = make_field(data=data)
result, = node.process(field, "logscale", blend_factor=0.5)
assert np.all(np.isfinite(result.data))
# logscale should preserve ordering
assert result.data[0, 0] < result.data[0, 1] < result.data[1, 0] < result.data[1, 1]
def test_blend_at_zero():
from backend.nodes.presentation_ops import PresentationOps
node = PresentationOps()
field = make_field(data=np.array([[1.0, 2.0], [3.0, 4.0]]))
overlay = make_field(data=np.array([[10.0, 20.0], [30.0, 40.0]]))
result, = node.process(field, "blend", blend_factor=0.0, overlay=overlay)
assert np.allclose(result.data, field.data)
def test_blend_at_one():
from backend.nodes.presentation_ops import PresentationOps
node = PresentationOps()
field = make_field(data=np.array([[1.0, 2.0], [3.0, 4.0]]))
overlay = make_field(data=np.array([[10.0, 20.0], [30.0, 40.0]]))
result, = node.process(field, "blend", blend_factor=1.0, overlay=overlay)
assert np.allclose(result.data, overlay.data)

View File

@@ -0,0 +1,56 @@
import numpy as np
from scipy.ndimage import gaussian_filter
from tests.node_tests._shared import make_field
def _make_test_pair(shape=(64, 64), sigma=2.0):
"""Return (measured, ideal) where measured = gaussian_filter(ideal)."""
ideal = make_field(shape=shape)
measured_data = gaussian_filter(ideal.data, sigma=sigma)
measured = make_field(data=measured_data)
return measured, ideal
def test_output_shape():
from backend.nodes.psf_estimation import PSFEstimation
node = PSFEstimation()
measured, ideal = _make_test_pair()
psf_field, _ = node.process(measured, ideal, "wiener", 0.01, 32)
assert psf_field.data.shape == (32, 32)
def test_psf_normalized():
from backend.nodes.psf_estimation import PSFEstimation
node = PSFEstimation()
measured, ideal = _make_test_pair()
for method in ("wiener", "least_squares", "gaussian_fit"):
psf_field, _ = node.process(measured, ideal, method, 0.01, 32)
assert abs(psf_field.data.sum() - 1.0) < 1e-6, (
f"{method}: PSF sum = {psf_field.data.sum()}"
)
def test_gaussian_fit_parameters():
from backend.nodes.psf_estimation import PSFEstimation
node = PSFEstimation()
measured, ideal = _make_test_pair()
_, parameters = node.process(measured, ideal, "gaussian_fit", 0.01, 32)
names = {row["quantity"] for row in parameters}
assert "sigma_x" in names
assert "sigma_y" in names
assert "amplitude" in names
def test_all_methods_finite():
from backend.nodes.psf_estimation import PSFEstimation
node = PSFEstimation()
measured, ideal = _make_test_pair()
for method in ("wiener", "least_squares", "gaussian_fit"):
psf_field, _ = node.process(measured, ideal, method, 0.01, 32)
assert np.isfinite(psf_field.data).all(), (
f"{method}: PSF contains non-finite values"
)

View File

@@ -0,0 +1,51 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_output_shape_single():
"""Single input with upscale=2 gives 2x output size."""
from backend.nodes.super_resolution import SuperResolution
field = make_field(shape=(32, 32))
node = SuperResolution()
result, = node.process(field, upscale=2)
assert result.data.shape == (64, 64)
def test_output_shape_multi():
"""Multiple inputs still give 2x output size."""
from backend.nodes.super_resolution import SuperResolution
rng = np.random.default_rng(0)
f1 = make_field(data=rng.standard_normal((32, 32)))
f2 = make_field(data=rng.standard_normal((32, 32)))
f3 = make_field(data=rng.standard_normal((32, 32)))
node = SuperResolution()
result, = node.process(f1, upscale=2, field2=f2, field3=f3)
assert result.data.shape == (64, 64)
def test_finite_values():
"""Output values must all be finite."""
from backend.nodes.super_resolution import SuperResolution
rng = np.random.default_rng(1)
f1 = make_field(data=rng.standard_normal((32, 32)))
f2 = make_field(data=rng.standard_normal((32, 32)))
node = SuperResolution()
result, = node.process(f1, upscale=2, field2=f2)
assert np.all(np.isfinite(result.data))
def test_upscale_factor():
"""Output dimensions should equal input dimensions times upscale factor."""
from backend.nodes.super_resolution import SuperResolution
field = make_field(shape=(32, 32))
node = SuperResolution()
for factor in (2, 3, 4):
result, = node.process(field, upscale=factor)
expected = (32 * factor, 32 * factor)
assert result.data.shape == expected, (
f"upscale={factor}: expected {expected}, got {result.data.shape}"
)

View File

@@ -0,0 +1,86 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
def run_tip_shape(field, feature_type="edge", feature_radius=100e-9, n_points=100):
from backend.nodes.tip_shape_estimate import TipShapeEstimate
node = TipShapeEstimate()
tip_shape, parameters = node.process(
field=field,
feature_type=feature_type,
feature_radius=feature_radius,
n_points=n_points,
)
return tip_shape, parameters
# ── Output shape and type ────────────────────────────────────────────────────
def test_output_shape():
"""tip_shape must be a 2D DataField."""
from backend.data_types import DataField
field = make_field(shape=(64, 64), xreal=64e-9, yreal=64e-9)
tip_shape, _ = run_tip_shape(field, feature_type="edge", n_points=33)
assert isinstance(tip_shape, DataField)
assert tip_shape.data.ndim == 2
assert tip_shape.data.shape[0] == tip_shape.data.shape[1]
# ── Parameters table ─────────────────────────────────────────────────────────
def test_parameters_table():
"""parameters must be a RecordTable containing a tip_radius entry."""
from backend.data_types import RecordTable
field = make_field(shape=(64, 64), xreal=64e-9, yreal=64e-9)
_, parameters = run_tip_shape(field, feature_type="edge", n_points=33)
assert isinstance(parameters, RecordTable)
quantities = {row["quantity"] for row in parameters}
assert "tip_radius" in quantities
# ── Tip apex is maximum ──────────────────────────────────────────────────────
def test_tip_apex_is_maximum():
"""The centre of the tip shape should be the highest point."""
field = make_field(shape=(64, 64), xreal=64e-9, yreal=64e-9)
tip_shape, _ = run_tip_shape(field, feature_type="edge", n_points=33)
n = tip_shape.data.shape[0]
ci = n // 2
assert tip_shape.data[ci, ci] == pytest.approx(tip_shape.data.max(), abs=1e-20)
# ── Sphere feature produces valid estimate ───────────────────────────────────
def test_sphere_feature():
"""Using a synthetic sphere as input produces a valid tip estimate."""
from backend.data_types import DataField
# Create a synthetic sphere on a 64x64 grid.
R = 100e-9 # sphere radius
n = 64
pixel_size = 10e-9
xreal = n * pixel_size
Y, X = np.mgrid[:n, :n]
r = np.sqrt((X - 32) ** 2 + (Y - 32) ** 2) * pixel_size
data = np.sqrt(np.maximum(R ** 2 - r ** 2, 0.0))
field = make_field(data=data, xreal=xreal, yreal=xreal)
tip_shape, parameters = run_tip_shape(
field, feature_type="sphere", feature_radius=R, n_points=33,
)
# The output must be a valid 2D DataField.
assert isinstance(tip_shape, DataField)
assert tip_shape.data.ndim == 2
# Apex should be the maximum.
ci = tip_shape.data.shape[0] // 2
assert tip_shape.data[ci, ci] == pytest.approx(tip_shape.data.max(), abs=1e-20)
# Parameters should contain tip_radius.
quantities = {row["quantity"]: row["value"] for row in parameters}
assert "tip_radius" in quantities
# The estimated radius must be a positive finite number.
assert quantities["tip_radius"] > 0