low pri features
This commit is contained in:
77
tests/node_tests/calibration.py
Normal file
77
tests/node_tests/calibration.py
Normal 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)
|
||||
41
tests/node_tests/displacement_field.py
Normal file
41
tests/node_tests/displacement_field.py
Normal 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"
|
||||
61
tests/node_tests/distribution_coercion.py
Normal file
61
tests/node_tests/distribution_coercion.py
Normal 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())
|
||||
64
tests/node_tests/dwt_anisotropy.py
Normal file
64
tests/node_tests/dwt_anisotropy.py
Normal 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]}"
|
||||
)
|
||||
64
tests/node_tests/grain_visualization.py
Normal file
64
tests/node_tests/grain_visualization.py
Normal 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"
|
||||
111
tests/node_tests/logistic_classification.py
Normal file
111
tests/node_tests/logistic_classification.py
Normal 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})"
|
||||
)
|
||||
43
tests/node_tests/mark_disconnected.py
Normal file
43
tests/node_tests/mark_disconnected.py
Normal 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
|
||||
50
tests/node_tests/mask_noisify.py
Normal file
50
tests/node_tests/mask_noisify.py
Normal 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)
|
||||
74
tests/node_tests/mask_shift.py
Normal file
74
tests/node_tests/mask_shift.py
Normal 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)
|
||||
72
tests/node_tests/neural_classification.py
Normal file
72
tests/node_tests/neural_classification.py
Normal 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"
|
||||
49
tests/node_tests/pixel_classification.py
Normal file
49
tests/node_tests/pixel_classification.py
Normal 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})
|
||||
43
tests/node_tests/presentation_ops.py
Normal file
43
tests/node_tests/presentation_ops.py
Normal 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)
|
||||
56
tests/node_tests/psf_estimation.py
Normal file
56
tests/node_tests/psf_estimation.py
Normal 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"
|
||||
)
|
||||
51
tests/node_tests/super_resolution.py
Normal file
51
tests/node_tests/super_resolution.py
Normal 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}"
|
||||
)
|
||||
86
tests/node_tests/tip_shape_estimate.py
Normal file
86
tests/node_tests/tip_shape_estimate.py
Normal 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
|
||||
Reference in New Issue
Block a user