adding more nodes
This commit is contained in:
42
tests/node_tests/extend_pad.py
Normal file
42
tests/node_tests/extend_pad.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_shape_increase():
|
||||
"""32x32 + top=5, bottom=5, left=10, right=10 should give 42x52."""
|
||||
from backend.nodes.extend_pad import ExtendPad
|
||||
|
||||
node = ExtendPad()
|
||||
field = make_field(shape=(32, 32))
|
||||
result, = node.process(field, top=5, bottom=5, left=10, right=10, method="mirror")
|
||||
assert result.data.shape == (42, 52)
|
||||
|
||||
|
||||
def test_zero_pad():
|
||||
"""Zero padding should fill borders with zeros."""
|
||||
from backend.nodes.extend_pad import ExtendPad
|
||||
|
||||
node = ExtendPad()
|
||||
data = np.ones((16, 16), dtype=np.float64) * 7.0
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, top=2, bottom=2, left=2, right=2, method="zero")
|
||||
assert result.data.shape == (20, 20)
|
||||
# Top border rows should be zero
|
||||
assert np.allclose(result.data[:2, :], 0.0)
|
||||
# Bottom border rows should be zero
|
||||
assert np.allclose(result.data[-2:, :], 0.0)
|
||||
# Left border columns should be zero
|
||||
assert np.allclose(result.data[:, :2], 0.0)
|
||||
# Right border columns should be zero
|
||||
assert np.allclose(result.data[:, -2:], 0.0)
|
||||
|
||||
|
||||
def test_mirror_pad():
|
||||
"""Mirror padding should produce the correct output shape."""
|
||||
from backend.nodes.extend_pad import ExtendPad
|
||||
|
||||
node = ExtendPad()
|
||||
field = make_field(shape=(32, 32))
|
||||
result, = node.process(field, top=4, bottom=4, left=8, right=8, method="mirror")
|
||||
assert result.data.shape == (40, 48)
|
||||
38
tests/node_tests/flatten_base.py
Normal file
38
tests/node_tests/flatten_base.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_flatten_base_removes_tilt():
|
||||
from backend.nodes.flatten_base import FlattenBase
|
||||
|
||||
node = FlattenBase()
|
||||
yy, xx = np.mgrid[:64, :64]
|
||||
base_tilt = 0.01 * xx + 0.02 * yy
|
||||
# Add some tall features
|
||||
data = base_tilt.copy()
|
||||
data[20:30, 20:30] += 10.0
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, 30.0, 1)
|
||||
assert result.data.shape == (64, 64)
|
||||
# Features should remain raised, base should be flatter
|
||||
assert result.data[25, 25] > result.data[0, 0]
|
||||
|
||||
|
||||
def test_flatten_base_preserves_shape():
|
||||
from backend.nodes.flatten_base import FlattenBase
|
||||
|
||||
node = FlattenBase()
|
||||
field = make_field(shape=(48, 64))
|
||||
result, = node.process(field, 30.0, 2)
|
||||
assert result.data.shape == (48, 64)
|
||||
|
||||
|
||||
def test_flatten_base_flat_surface():
|
||||
from backend.nodes.flatten_base import FlattenBase
|
||||
|
||||
node = FlattenBase()
|
||||
field = make_field(data=np.ones((32, 32)) * 5.0)
|
||||
result, = node.process(field, 50.0, 0)
|
||||
# All pixels are the same, subtracting mean gives zero
|
||||
assert np.allclose(result.data, 0.0, atol=1e-10)
|
||||
43
tests/node_tests/fractal_interpolation.py
Normal file
43
tests/node_tests/fractal_interpolation.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
def test_fractal_fills_hole():
|
||||
from backend.nodes.fractal_interpolation import FractalInterpolation
|
||||
|
||||
node = FractalInterpolation()
|
||||
data = np.random.default_rng(42).standard_normal((32, 32))
|
||||
mask = np.zeros((32, 32), dtype=bool)
|
||||
mask[12:20, 12:20] = True
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, bool_to_mask(mask), 50)
|
||||
assert result.data.shape == (32, 32)
|
||||
assert np.isfinite(result.data).all()
|
||||
|
||||
|
||||
def test_fractal_no_mask_unchanged():
|
||||
from backend.nodes.fractal_interpolation import FractalInterpolation
|
||||
|
||||
node = FractalInterpolation()
|
||||
field = make_field(shape=(32, 32))
|
||||
mask = bool_to_mask(np.zeros((32, 32), dtype=bool))
|
||||
result, = node.process(field, mask, 50)
|
||||
assert np.allclose(result.data, field.data)
|
||||
|
||||
|
||||
def test_fractal_preserves_statistics():
|
||||
from backend.nodes.fractal_interpolation import FractalInterpolation
|
||||
|
||||
node = FractalInterpolation()
|
||||
rng = np.random.default_rng(0)
|
||||
data = rng.standard_normal((64, 64))
|
||||
mask = np.zeros((64, 64), dtype=bool)
|
||||
mask[20:40, 20:40] = True
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, bool_to_mask(mask), 100)
|
||||
# Filled region should have similar statistics to the rest
|
||||
filled_std = result.data[mask].std()
|
||||
valid_std = data[~mask].std()
|
||||
assert filled_std > 0.1 * valid_std # not flat
|
||||
35
tests/node_tests/freq_split.py
Normal file
35
tests/node_tests/freq_split.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_sum_reconstruction():
|
||||
"""Low-pass plus high-pass should reconstruct the original field."""
|
||||
from backend.nodes.freq_split import FrequencySplit
|
||||
|
||||
node = FrequencySplit()
|
||||
field = make_field(shape=(64, 64))
|
||||
low, high = node.process(field, cutoff=0.1)
|
||||
reconstructed = low.data + high.data
|
||||
assert np.allclose(reconstructed, field.data, atol=1e-10)
|
||||
|
||||
|
||||
def test_shapes():
|
||||
"""Both outputs should have the same shape as the input."""
|
||||
from backend.nodes.freq_split import FrequencySplit
|
||||
|
||||
node = FrequencySplit()
|
||||
field = make_field(shape=(64, 64))
|
||||
low, high = node.process(field, cutoff=0.1)
|
||||
assert low.data.shape == field.data.shape
|
||||
assert high.data.shape == field.data.shape
|
||||
|
||||
|
||||
def test_low_pass_smoother():
|
||||
"""Low-pass output should have smaller std than the original random data."""
|
||||
from backend.nodes.freq_split import FrequencySplit
|
||||
|
||||
node = FrequencySplit()
|
||||
field = make_field(shape=(64, 64))
|
||||
low, high = node.process(field, cutoff=0.1)
|
||||
assert np.std(low.data) < np.std(field.data)
|
||||
75
tests/node_tests/grain_cross.py
Normal file
75
tests/node_tests/grain_cross.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
def test_basic_correlation():
|
||||
from backend.nodes.grain_cross import GrainCross
|
||||
|
||||
node = GrainCross()
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
data_a = rng.standard_normal((64, 64))
|
||||
data_b = rng.standard_normal((64, 64))
|
||||
field_a = make_field(data=data_a)
|
||||
field_b = make_field(data=data_b)
|
||||
|
||||
# Create mask with two distinct grains
|
||||
mask_bool = np.zeros((64, 64), dtype=bool)
|
||||
mask_bool[5:20, 5:20] = True
|
||||
mask_bool[40:55, 40:55] = True
|
||||
mask = bool_to_mask(mask_bool)
|
||||
|
||||
(table,) = node.process(field_a, field_b, mask=mask,
|
||||
property_a="mean_height", property_b="max_height",
|
||||
min_size=10)
|
||||
assert len(table) > 0, "Should return entries for detected grains"
|
||||
|
||||
|
||||
def test_pearson_reported():
|
||||
from backend.nodes.grain_cross import GrainCross
|
||||
|
||||
node = GrainCross()
|
||||
|
||||
rng = np.random.default_rng(7)
|
||||
data_a = rng.standard_normal((64, 64))
|
||||
data_b = rng.standard_normal((64, 64))
|
||||
field_a = make_field(data=data_a)
|
||||
field_b = make_field(data=data_b)
|
||||
|
||||
# Two grains so Pearson can be computed
|
||||
mask_bool = np.zeros((64, 64), dtype=bool)
|
||||
mask_bool[5:20, 5:20] = True
|
||||
mask_bool[40:55, 40:55] = True
|
||||
mask = bool_to_mask(mask_bool)
|
||||
|
||||
(table,) = node.process(field_a, field_b, mask=mask,
|
||||
property_a="mean_height", property_b="mean_height",
|
||||
min_size=10)
|
||||
quantities = [row["quantity"] for row in table]
|
||||
assert "Pearson r" in quantities, f"Expected 'Pearson r' in {quantities}"
|
||||
|
||||
|
||||
def test_min_size_filters():
|
||||
from backend.nodes.grain_cross import GrainCross
|
||||
|
||||
node = GrainCross()
|
||||
|
||||
data_a = np.zeros((64, 64))
|
||||
data_b = np.zeros((64, 64))
|
||||
field_a = make_field(data=data_a)
|
||||
field_b = make_field(data=data_b)
|
||||
|
||||
# Small grain (10x10 = 100 pixels)
|
||||
mask_bool = np.zeros((64, 64), dtype=bool)
|
||||
mask_bool[5:15, 5:15] = True
|
||||
mask = bool_to_mask(mask_bool)
|
||||
|
||||
# min_size larger than any grain
|
||||
(table,) = node.process(field_a, field_b, mask=mask,
|
||||
property_a="area", property_b="area",
|
||||
min_size=200)
|
||||
# No grain entries (only maybe no Pearson either since < 2 grains)
|
||||
grain_entries = [r for r in table if r["quantity"].startswith("Grain")]
|
||||
assert len(grain_entries) == 0, "No grains should pass with large min_size"
|
||||
45
tests/node_tests/grain_distributions.py
Normal file
45
tests/node_tests/grain_distributions.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
def test_grain_distributions_area():
|
||||
from backend.nodes.grain_distributions import GrainDistributions
|
||||
|
||||
node = GrainDistributions()
|
||||
data = np.zeros((64, 64))
|
||||
data[10:20, 10:20] = 1.0
|
||||
data[40:50, 40:50] = 1.0
|
||||
mask = np.zeros((64, 64), dtype=bool)
|
||||
mask[10:20, 10:20] = True
|
||||
mask[40:50, 40:50] = True
|
||||
field = make_field(data=data)
|
||||
dist, = node.process(field, bool_to_mask(mask), "area", 10, 5)
|
||||
assert hasattr(dist, 'data')
|
||||
assert len(dist.data) == 10 # n_bins
|
||||
|
||||
|
||||
def test_grain_distributions_height():
|
||||
from backend.nodes.grain_distributions import GrainDistributions
|
||||
|
||||
node = GrainDistributions()
|
||||
data = np.zeros((32, 32))
|
||||
data[5:15, 5:15] = 2.0
|
||||
data[20:28, 20:28] = 5.0
|
||||
mask = np.zeros((32, 32), dtype=bool)
|
||||
mask[5:15, 5:15] = True
|
||||
mask[20:28, 20:28] = True
|
||||
field = make_field(data=data)
|
||||
dist, = node.process(field, bool_to_mask(mask), "mean_height", 10, 5)
|
||||
assert len(dist.data) == 10
|
||||
|
||||
|
||||
def test_grain_distributions_no_grains():
|
||||
from backend.nodes.grain_distributions import GrainDistributions
|
||||
|
||||
node = GrainDistributions()
|
||||
field = make_field(shape=(32, 32))
|
||||
mask = bool_to_mask(np.zeros((32, 32), dtype=bool))
|
||||
dist, = node.process(field, mask, "area", 10, 5)
|
||||
assert hasattr(dist, 'data')
|
||||
66
tests/node_tests/grain_edge.py
Normal file
66
tests/node_tests/grain_edge.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import mask_to_bool, bool_to_mask
|
||||
|
||||
|
||||
def test_boundary_detection():
|
||||
from backend.nodes.grain_edge import GrainEdge
|
||||
|
||||
node = GrainEdge()
|
||||
data = np.zeros((64, 64))
|
||||
data[22:42, 22:42] = 1.0
|
||||
field = make_field(data=data)
|
||||
|
||||
mask = np.zeros((64, 64), dtype=np.uint8)
|
||||
mask[22:42, 22:42] = 255
|
||||
|
||||
(edge_mask,) = node.process(field, mask=mask, width=1)
|
||||
edge_bool = mask_to_bool(edge_mask)
|
||||
|
||||
# Edge pixels should lie on the grain boundary (outermost ring of the grain)
|
||||
assert edge_bool.any(), "Should detect some edge pixels"
|
||||
# Boundary pixels: grain pixels with at least one non-grain 4-neighbour
|
||||
# Rows 22 and 41 (top/bottom edges), cols 22 and 41 (left/right edges)
|
||||
assert edge_bool[22, 30], "Top boundary row should be marked"
|
||||
assert edge_bool[41, 30], "Bottom boundary row should be marked"
|
||||
assert edge_bool[30, 22], "Left boundary col should be marked"
|
||||
assert edge_bool[30, 41], "Right boundary col should be marked"
|
||||
|
||||
|
||||
def test_interior_excluded():
|
||||
from backend.nodes.grain_edge import GrainEdge
|
||||
|
||||
node = GrainEdge()
|
||||
data = np.zeros((64, 64))
|
||||
data[10:50, 10:50] = 1.0
|
||||
field = make_field(data=data)
|
||||
|
||||
mask = np.zeros((64, 64), dtype=np.uint8)
|
||||
mask[10:50, 10:50] = 255
|
||||
|
||||
(edge_mask,) = node.process(field, mask=mask, width=1)
|
||||
edge_bool = mask_to_bool(edge_mask)
|
||||
|
||||
# Deep interior pixel (centre of the 40x40 grain) should NOT be edge
|
||||
assert not edge_bool[30, 30], "Interior pixel should not be in edge mask"
|
||||
assert not edge_bool[25, 25], "Another interior pixel should not be edge"
|
||||
|
||||
|
||||
def test_width_expands():
|
||||
from backend.nodes.grain_edge import GrainEdge
|
||||
|
||||
node = GrainEdge()
|
||||
data = np.zeros((64, 64))
|
||||
data[15:45, 15:45] = 1.0
|
||||
field = make_field(data=data)
|
||||
|
||||
mask = np.zeros((64, 64), dtype=np.uint8)
|
||||
mask[15:45, 15:45] = 255
|
||||
|
||||
(edge1,) = node.process(field, mask=mask, width=1)
|
||||
(edge3,) = node.process(field, mask=mask, width=3)
|
||||
|
||||
count1 = mask_to_bool(edge1).sum()
|
||||
count3 = mask_to_bool(edge3).sum()
|
||||
assert count3 > count1, f"width=3 ({count3}) should give more edge pixels than width=1 ({count1})"
|
||||
56
tests/node_tests/grain_mark.py
Normal file
56
tests/node_tests/grain_mark.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
def test_grain_mark_height():
|
||||
from backend.nodes.grain_mark import GrainMark
|
||||
|
||||
node = GrainMark()
|
||||
data = np.zeros((64, 64))
|
||||
data[20:40, 20:40] = 1.0 # raised region
|
||||
field = make_field(data=data)
|
||||
mask, = node.process(field, "height", 0.5, 1.0, 10, False)
|
||||
binary = mask_to_bool(mask)
|
||||
assert binary[30, 30] # center of raised region should be marked
|
||||
assert not binary[0, 0] # corner should not be marked
|
||||
|
||||
|
||||
def test_grain_mark_slope():
|
||||
from backend.nodes.grain_mark import GrainMark
|
||||
|
||||
node = GrainMark()
|
||||
field = make_field(shape=(64, 64))
|
||||
mask, = node.process(field, "slope", 0.3, 1.0, 5, False)
|
||||
assert mask.shape == (64, 64)
|
||||
assert mask.dtype == np.uint8
|
||||
|
||||
|
||||
def test_grain_mark_inverted():
|
||||
from backend.nodes.grain_mark import GrainMark
|
||||
|
||||
node = GrainMark()
|
||||
data = np.zeros((32, 32))
|
||||
data[10:20, 10:20] = 1.0
|
||||
field = make_field(data=data)
|
||||
mask_normal, = node.process(field, "height", 0.5, 1.0, 1, False)
|
||||
mask_inv, = node.process(field, "height", 0.5, 1.0, 1, True)
|
||||
# Inverted should be complement (approximately)
|
||||
n1 = mask_to_bool(mask_normal).sum()
|
||||
n2 = mask_to_bool(mask_inv).sum()
|
||||
assert n1 + n2 > 0
|
||||
|
||||
|
||||
def test_grain_mark_min_size():
|
||||
from backend.nodes.grain_mark import GrainMark
|
||||
|
||||
node = GrainMark()
|
||||
data = np.zeros((64, 64))
|
||||
data[30, 30] = 1.0 # single pixel
|
||||
data[10:20, 10:20] = 1.0 # larger region
|
||||
field = make_field(data=data)
|
||||
mask, = node.process(field, "height", 0.5, 1.0, 50, False)
|
||||
binary = mask_to_bool(mask)
|
||||
# Single pixel should be filtered out
|
||||
assert not binary[30, 30]
|
||||
49
tests/node_tests/grain_summary.py
Normal file
49
tests/node_tests/grain_summary.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
def test_grain_summary_basic():
|
||||
from backend.nodes.grain_summary import GrainSummary
|
||||
|
||||
node = GrainSummary()
|
||||
data = np.zeros((64, 64))
|
||||
data[10:20, 10:20] = 1.0
|
||||
data[40:55, 40:55] = 2.0
|
||||
mask = np.zeros((64, 64), dtype=bool)
|
||||
mask[10:20, 10:20] = True
|
||||
mask[40:55, 40:55] = True
|
||||
field = make_field(data=data)
|
||||
records, = node.process(field, bool_to_mask(mask), 5)
|
||||
assert isinstance(records, list)
|
||||
# Should have grain count
|
||||
quantities = [r["quantity"] for r in records]
|
||||
assert "Grain count" in quantities
|
||||
count_record = [r for r in records if r["quantity"] == "Grain count"][0]
|
||||
assert count_record["value"] == "2"
|
||||
|
||||
|
||||
def test_grain_summary_no_grains():
|
||||
from backend.nodes.grain_summary import GrainSummary
|
||||
|
||||
node = GrainSummary()
|
||||
field = make_field(shape=(32, 32))
|
||||
mask = bool_to_mask(np.zeros((32, 32), dtype=bool))
|
||||
records, = node.process(field, mask, 5)
|
||||
assert isinstance(records, list)
|
||||
count_record = [r for r in records if r["quantity"] == "Grain count"][0]
|
||||
assert count_record["value"] == "0"
|
||||
|
||||
|
||||
def test_grain_summary_coverage():
|
||||
from backend.nodes.grain_summary import GrainSummary
|
||||
|
||||
node = GrainSummary()
|
||||
data = np.ones((32, 32))
|
||||
mask = np.ones((32, 32), dtype=bool) # entire surface is grain
|
||||
field = make_field(data=data)
|
||||
records, = node.process(field, bool_to_mask(mask), 1)
|
||||
quantities = {r["quantity"]: r for r in records}
|
||||
assert "Coverage fraction" in quantities
|
||||
assert float(quantities["Coverage fraction"]["value"]) > 0.9
|
||||
42
tests/node_tests/immerse_detail.py
Normal file
42
tests/node_tests/immerse_detail.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_shape_matches_overview():
|
||||
from backend.nodes.immerse_detail import ImmerseDetail
|
||||
|
||||
node = ImmerseDetail()
|
||||
overview = make_field(shape=(64, 64))
|
||||
# Detail must have matching pixel size (smaller physical area)
|
||||
detail = make_field(shape=(16, 16), xreal=0.25e-6, yreal=0.25e-6)
|
||||
(combined,) = node.process(overview, detail, blend="replace")
|
||||
assert combined.data.shape == overview.data.shape
|
||||
|
||||
|
||||
def test_detail_larger_returns_overview():
|
||||
from backend.nodes.immerse_detail import ImmerseDetail
|
||||
|
||||
node = ImmerseDetail()
|
||||
overview = make_field(shape=(32, 32))
|
||||
# Detail larger than overview after resampling
|
||||
detail = make_field(shape=(64, 64))
|
||||
(combined,) = node.process(overview, detail, blend="replace")
|
||||
# Should return the overview unchanged
|
||||
assert np.array_equal(combined.data, overview.data)
|
||||
|
||||
|
||||
def test_replace_mode():
|
||||
from backend.nodes.immerse_detail import ImmerseDetail
|
||||
|
||||
node = ImmerseDetail()
|
||||
overview_data = np.zeros((64, 64))
|
||||
detail_data = np.ones((16, 16)) * 5.0
|
||||
overview = make_field(data=overview_data)
|
||||
# Match pixel size so detail stays 16x16 (smaller than 64x64)
|
||||
detail = make_field(data=detail_data, xreal=0.25e-6, yreal=0.25e-6)
|
||||
(combined,) = node.process(overview, detail, blend="replace")
|
||||
# After immersion, some pixels should now equal 5.0
|
||||
assert np.any(combined.data == 5.0), "Detail should modify some pixels in replace mode"
|
||||
# But not all pixels changed
|
||||
assert combined.data.shape == (64, 64)
|
||||
39
tests/node_tests/laplace_interpolation.py
Normal file
39
tests/node_tests/laplace_interpolation.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
def test_laplace_fills_hole():
|
||||
from backend.nodes.laplace_interpolation import LaplaceInterpolation
|
||||
|
||||
node = LaplaceInterpolation()
|
||||
data = np.ones((32, 32)) * 5.0
|
||||
mask = np.zeros((32, 32), dtype=bool)
|
||||
mask[10:20, 10:20] = True
|
||||
data[mask] = 0.0 # hole
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, bool_to_mask(mask), 200)
|
||||
# Filled region should be close to surrounding value of 5.0
|
||||
assert result.data[15, 15] > 3.0
|
||||
|
||||
|
||||
def test_laplace_no_mask_unchanged():
|
||||
from backend.nodes.laplace_interpolation import LaplaceInterpolation
|
||||
|
||||
node = LaplaceInterpolation()
|
||||
field = make_field(shape=(32, 32))
|
||||
mask = bool_to_mask(np.zeros((32, 32), dtype=bool))
|
||||
result, = node.process(field, mask, 100)
|
||||
assert np.allclose(result.data, field.data)
|
||||
|
||||
|
||||
def test_laplace_preserves_shape():
|
||||
from backend.nodes.laplace_interpolation import LaplaceInterpolation
|
||||
|
||||
node = LaplaceInterpolation()
|
||||
field = make_field(shape=(48, 64))
|
||||
mask = np.zeros((48, 64), dtype=bool)
|
||||
mask[20:30, 20:40] = True
|
||||
result, = node.process(field, bool_to_mask(mask), 50)
|
||||
assert result.data.shape == (48, 64)
|
||||
46
tests/node_tests/level_grains.py
Normal file
46
tests/node_tests/level_grains.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
def test_level_grains_equalizes():
|
||||
from backend.nodes.level_grains import LevelGrains
|
||||
|
||||
node = LevelGrains()
|
||||
data = np.zeros((32, 32))
|
||||
# Two grains at different heights
|
||||
data[5:10, 5:10] = 3.0
|
||||
data[20:25, 20:25] = 7.0
|
||||
mask = np.zeros((32, 32), dtype=bool)
|
||||
mask[5:10, 5:10] = True
|
||||
mask[20:25, 20:25] = True
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, bool_to_mask(mask), "mean")
|
||||
# After leveling, both grains should have similar mean heights
|
||||
g1 = result.data[5:10, 5:10].mean()
|
||||
g2 = result.data[20:25, 20:25].mean()
|
||||
assert abs(g1 - g2) < 0.1
|
||||
|
||||
|
||||
def test_level_grains_no_grains():
|
||||
from backend.nodes.level_grains import LevelGrains
|
||||
|
||||
node = LevelGrains()
|
||||
field = make_field(shape=(32, 32))
|
||||
mask = bool_to_mask(np.zeros((32, 32), dtype=bool))
|
||||
result, = node.process(field, mask, "mean")
|
||||
assert np.allclose(result.data, field.data)
|
||||
|
||||
|
||||
def test_level_grains_median_reference():
|
||||
from backend.nodes.level_grains import LevelGrains
|
||||
|
||||
node = LevelGrains()
|
||||
data = np.zeros((32, 32))
|
||||
data[5:15, 5:15] = 2.0
|
||||
mask = np.zeros((32, 32), dtype=bool)
|
||||
mask[5:15, 5:15] = True
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, bool_to_mask(mask), "median")
|
||||
assert result.data.shape == (32, 32)
|
||||
35
tests/node_tests/median_background.py
Normal file
35
tests/node_tests/median_background.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_median_background_subtracted():
|
||||
from backend.nodes.median_background import MedianBackground
|
||||
|
||||
node = MedianBackground()
|
||||
# Tilted surface with features
|
||||
yy, xx = np.mgrid[:64, :64]
|
||||
data = 0.01 * xx + 0.02 * yy # tilt
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, 20, "subtracted")
|
||||
assert result.data.shape == (64, 64)
|
||||
# Background-subtracted should have near-zero mean
|
||||
assert abs(result.data.mean()) < abs(data.mean())
|
||||
|
||||
|
||||
def test_median_background_output():
|
||||
from backend.nodes.median_background import MedianBackground
|
||||
|
||||
node = MedianBackground()
|
||||
field = make_field(shape=(32, 32))
|
||||
result, = node.process(field, 10, "background")
|
||||
assert result.data.shape == (32, 32)
|
||||
|
||||
|
||||
def test_median_background_flat_field():
|
||||
from backend.nodes.median_background import MedianBackground
|
||||
|
||||
node = MedianBackground()
|
||||
field = make_field(data=np.ones((32, 32)) * 3.0)
|
||||
result, = node.process(field, 10, "subtracted")
|
||||
assert np.allclose(result.data, 0.0, atol=1e-10)
|
||||
33
tests/node_tests/multi_profile.py
Normal file
33
tests/node_tests/multi_profile.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_horizontal_center():
|
||||
from backend.nodes.multi_profile import MultipleProfiles
|
||||
|
||||
node = MultipleProfiles()
|
||||
field = make_field(shape=(64, 128))
|
||||
(profile,) = node.process(field, field, row=-1, direction="horizontal", mode="overlay")
|
||||
assert len(profile.data) == 128, f"Expected width 128, got {len(profile.data)}"
|
||||
assert profile.x_axis is not None
|
||||
assert len(profile.x_axis) == len(profile.data)
|
||||
|
||||
|
||||
def test_difference_mode():
|
||||
from backend.nodes.multi_profile import MultipleProfiles
|
||||
|
||||
node = MultipleProfiles()
|
||||
data = np.random.default_rng(5).standard_normal((32, 32))
|
||||
field = make_field(data=data)
|
||||
(profile,) = node.process(field, field, row=-1, direction="horizontal", mode="difference")
|
||||
assert np.allclose(profile.data, 0.0), "Difference of same field should be zero"
|
||||
|
||||
|
||||
def test_vertical_direction():
|
||||
from backend.nodes.multi_profile import MultipleProfiles
|
||||
|
||||
node = MultipleProfiles()
|
||||
field = make_field(shape=(80, 40))
|
||||
(profile,) = node.process(field, field, row=-1, direction="vertical", mode="overlay")
|
||||
assert len(profile.data) == 80, f"Vertical profile length should be field height (80), got {len(profile.data)}"
|
||||
38
tests/node_tests/mutual_crop.py
Normal file
38
tests/node_tests/mutual_crop.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_same_image():
|
||||
from backend.nodes.mutual_crop import MutualCrop
|
||||
|
||||
node = MutualCrop()
|
||||
field = make_field()
|
||||
cropped_a, cropped_b = node.process(field, field)
|
||||
assert cropped_a.data.shape == cropped_b.data.shape
|
||||
|
||||
|
||||
def test_output_shapes_match():
|
||||
from backend.nodes.mutual_crop import MutualCrop
|
||||
|
||||
node = MutualCrop()
|
||||
rng = np.random.default_rng(10)
|
||||
field_a = make_field(data=rng.standard_normal((48, 64)))
|
||||
field_b = make_field(data=rng.standard_normal((64, 48)))
|
||||
cropped_a, cropped_b = node.process(field_a, field_b)
|
||||
assert cropped_a.data.shape == cropped_b.data.shape, (
|
||||
f"Shapes should match: {cropped_a.data.shape} vs {cropped_b.data.shape}"
|
||||
)
|
||||
|
||||
|
||||
def test_identical_fields():
|
||||
from backend.nodes.mutual_crop import MutualCrop
|
||||
|
||||
node = MutualCrop()
|
||||
data = np.random.default_rng(99).standard_normal((32, 32))
|
||||
field_a = make_field(data=data.copy())
|
||||
field_b = make_field(data=data.copy())
|
||||
cropped_a, cropped_b = node.process(field_a, field_b)
|
||||
# Identical fields should be fully overlapping, so cropped output ~ original
|
||||
assert cropped_a.data.shape == (32, 32)
|
||||
assert np.allclose(cropped_a.data, data)
|
||||
54
tests/node_tests/outlier_mask.py
Normal file
54
tests/node_tests/outlier_mask.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
def test_outlier_mask_detects_spikes():
|
||||
from backend.nodes.outlier_mask import OutlierMask
|
||||
|
||||
node = OutlierMask()
|
||||
data = np.zeros((64, 64))
|
||||
data[30, 30] = 100.0 # extreme spike
|
||||
field = make_field(data=data)
|
||||
mask, = node.process(field, 3.0, "both")
|
||||
binary = mask_to_bool(mask)
|
||||
assert binary[30, 30] # spike should be flagged
|
||||
|
||||
|
||||
def test_outlier_mask_clean_field():
|
||||
from backend.nodes.outlier_mask import OutlierMask
|
||||
|
||||
node = OutlierMask()
|
||||
# Uniform field has no outliers
|
||||
field = make_field(data=np.ones((32, 32)) * 5.0)
|
||||
mask, = node.process(field, 3.0, "both")
|
||||
assert mask_to_bool(mask).sum() == 0
|
||||
|
||||
|
||||
def test_outlier_mask_high_only():
|
||||
from backend.nodes.outlier_mask import OutlierMask
|
||||
|
||||
node = OutlierMask()
|
||||
data = np.zeros((64, 64))
|
||||
data[10, 10] = 100.0 # high spike
|
||||
data[50, 50] = -100.0 # low spike
|
||||
field = make_field(data=data)
|
||||
mask, = node.process(field, 3.0, "high")
|
||||
binary = mask_to_bool(mask)
|
||||
assert binary[10, 10]
|
||||
assert not binary[50, 50]
|
||||
|
||||
|
||||
def test_outlier_mask_low_only():
|
||||
from backend.nodes.outlier_mask import OutlierMask
|
||||
|
||||
node = OutlierMask()
|
||||
data = np.zeros((64, 64))
|
||||
data[10, 10] = 100.0
|
||||
data[50, 50] = -100.0
|
||||
field = make_field(data=data)
|
||||
mask, = node.process(field, 3.0, "low")
|
||||
binary = mask_to_bool(mask)
|
||||
assert not binary[10, 10]
|
||||
assert binary[50, 50]
|
||||
53
tests/node_tests/perspective_correction.py
Normal file
53
tests/node_tests/perspective_correction.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_identity():
|
||||
"""All offsets zero should return output approximately equal to input."""
|
||||
from backend.nodes.perspective_correction import PerspectiveCorrection
|
||||
|
||||
node = PerspectiveCorrection()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(
|
||||
field,
|
||||
top_left_x=0.0, top_left_y=0.0,
|
||||
top_right_x=0.0, top_right_y=0.0,
|
||||
bottom_left_x=0.0, bottom_left_y=0.0,
|
||||
bottom_right_x=0.0, bottom_right_y=0.0,
|
||||
)
|
||||
assert result.data.shape == field.data.shape
|
||||
assert np.allclose(result.data, field.data, atol=1e-10)
|
||||
|
||||
|
||||
def test_nonzero_offset():
|
||||
"""Non-zero offsets should change the data while preserving shape."""
|
||||
from backend.nodes.perspective_correction import PerspectiveCorrection
|
||||
|
||||
node = PerspectiveCorrection()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(
|
||||
field,
|
||||
top_left_x=0.05, top_left_y=0.05,
|
||||
top_right_x=-0.05, top_right_y=0.05,
|
||||
bottom_left_x=0.05, bottom_left_y=-0.05,
|
||||
bottom_right_x=-0.05, bottom_right_y=-0.05,
|
||||
)
|
||||
assert result.data.shape == field.data.shape
|
||||
assert not np.allclose(result.data, field.data)
|
||||
|
||||
|
||||
def test_output_shape():
|
||||
"""Output shape must match input shape."""
|
||||
from backend.nodes.perspective_correction import PerspectiveCorrection
|
||||
|
||||
node = PerspectiveCorrection()
|
||||
field = make_field(shape=(48, 96))
|
||||
result, = node.process(
|
||||
field,
|
||||
top_left_x=0.1, top_left_y=0.0,
|
||||
top_right_x=0.0, top_right_y=0.0,
|
||||
bottom_left_x=0.0, bottom_left_y=0.0,
|
||||
bottom_right_x=0.0, bottom_right_y=0.0,
|
||||
)
|
||||
assert result.data.shape == (48, 96)
|
||||
35
tests/node_tests/pixel_binning.py
Normal file
35
tests/node_tests/pixel_binning.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_shape_reduction():
|
||||
"""64x64 with bin_size=2 should produce 32x32."""
|
||||
from backend.nodes.pixel_binning import PixelBinning
|
||||
|
||||
node = PixelBinning()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, bin_size=2, method="mean")
|
||||
assert result.data.shape == (32, 32)
|
||||
|
||||
|
||||
def test_mean_uniform():
|
||||
"""Uniform field of value 5.0 with mean binning should keep all values at 5.0."""
|
||||
from backend.nodes.pixel_binning import PixelBinning
|
||||
|
||||
node = PixelBinning()
|
||||
data = np.full((64, 64), 5.0, dtype=np.float64)
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, bin_size=2, method="mean")
|
||||
assert np.allclose(result.data, 5.0)
|
||||
|
||||
|
||||
def test_sum_doubles():
|
||||
"""Uniform field of 1.0 with bin_size=2 and sum should give 4.0 everywhere."""
|
||||
from backend.nodes.pixel_binning import PixelBinning
|
||||
|
||||
node = PixelBinning()
|
||||
data = np.ones((64, 64), dtype=np.float64)
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, bin_size=2, method="sum")
|
||||
assert np.allclose(result.data, 4.0)
|
||||
34
tests/node_tests/poly_distort.py
Normal file
34
tests/node_tests/poly_distort.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_zero_coefficients():
|
||||
"""All coefficients zero should return output approximately equal to input."""
|
||||
from backend.nodes.poly_distort import PolynomialDistortion
|
||||
|
||||
node = PolynomialDistortion()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, k1_x=0.0, k1_y=0.0, k2_x=0.0, k2_y=0.0, k3_x=0.0, k3_y=0.0)
|
||||
assert np.allclose(result.data, field.data, atol=1e-10)
|
||||
|
||||
|
||||
def test_nonzero_distortion():
|
||||
"""Non-zero k1_x should preserve shape but change values."""
|
||||
from backend.nodes.poly_distort import PolynomialDistortion
|
||||
|
||||
node = PolynomialDistortion()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, k1_x=0.1, k1_y=0.0, k2_x=0.0, k2_y=0.0, k3_x=0.0, k3_y=0.0)
|
||||
assert result.data.shape == field.data.shape
|
||||
assert not np.allclose(result.data, field.data)
|
||||
|
||||
|
||||
def test_shape_preserved():
|
||||
"""Output shape must equal input shape."""
|
||||
from backend.nodes.poly_distort import PolynomialDistortion
|
||||
|
||||
node = PolynomialDistortion()
|
||||
field = make_field(shape=(32, 48))
|
||||
result, = node.process(field, k1_x=0.05, k1_y=0.05, k2_x=0.01, k2_y=0.01, k3_x=0.0, k3_y=0.0)
|
||||
assert result.data.shape == (32, 48)
|
||||
30
tests/node_tests/psdf_log_polar.py
Normal file
30
tests/node_tests/psdf_log_polar.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_shape():
|
||||
from backend.nodes.psdf_log_polar import LogPolarPSDF
|
||||
|
||||
node = LogPolarPSDF()
|
||||
field = make_field()
|
||||
(psdf,) = node.process(field, n_phi=90, n_r=50)
|
||||
assert psdf.data.shape == (50, 90)
|
||||
|
||||
|
||||
def test_nonnegative():
|
||||
from backend.nodes.psdf_log_polar import LogPolarPSDF
|
||||
|
||||
node = LogPolarPSDF()
|
||||
field = make_field()
|
||||
(psdf,) = node.process(field, n_phi=180, n_r=100)
|
||||
assert np.all(psdf.data >= 0), "log1p of power should be non-negative"
|
||||
|
||||
|
||||
def test_domain():
|
||||
from backend.nodes.psdf_log_polar import LogPolarPSDF
|
||||
|
||||
node = LogPolarPSDF()
|
||||
field = make_field()
|
||||
(psdf,) = node.process(field, n_phi=180, n_r=100)
|
||||
assert psdf.domain == "frequency"
|
||||
44
tests/node_tests/rank_filter.py
Normal file
44
tests/node_tests/rank_filter.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_erosion_le_original():
|
||||
"""Erosion (local minimum) values should be <= original at every pixel."""
|
||||
from backend.nodes.filter_rank import RankFilter
|
||||
|
||||
node = RankFilter()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, operation="erosion", radius=2, percentile=50.0)
|
||||
assert np.all(result.data <= field.data + 1e-12)
|
||||
|
||||
|
||||
def test_dilation_ge_original():
|
||||
"""Dilation (local maximum) values should be >= original at every pixel."""
|
||||
from backend.nodes.filter_rank import RankFilter
|
||||
|
||||
node = RankFilter()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, operation="dilation", radius=2, percentile=50.0)
|
||||
assert np.all(result.data >= field.data - 1e-12)
|
||||
|
||||
|
||||
def test_median_shape():
|
||||
"""Median output should have the same shape as input."""
|
||||
from backend.nodes.filter_rank import RankFilter
|
||||
|
||||
node = RankFilter()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, operation="median", radius=3, percentile=50.0)
|
||||
assert result.data.shape == field.data.shape
|
||||
|
||||
|
||||
def test_percentile_operation():
|
||||
"""Percentile at 50.0 should approximate the median result."""
|
||||
from backend.nodes.filter_rank import RankFilter
|
||||
|
||||
node = RankFilter()
|
||||
field = make_field(shape=(64, 64))
|
||||
median_result, = node.process(field, operation="median", radius=2, percentile=50.0)
|
||||
percentile_result, = node.process(field, operation="percentile", radius=2, percentile=50.0)
|
||||
assert np.allclose(median_result.data, percentile_result.data, atol=1e-10)
|
||||
54
tests/node_tests/relate_fields.py
Normal file
54
tests/node_tests/relate_fields.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_linear_fit():
|
||||
from backend.nodes.relate_fields import RelateFields
|
||||
|
||||
node = RelateFields()
|
||||
rng = np.random.default_rng(42)
|
||||
a_data = rng.uniform(0.5, 5.0, (32, 32))
|
||||
b_data = 2.0 * a_data + 1.0
|
||||
field_a = make_field(data=a_data)
|
||||
field_b = make_field(data=b_data)
|
||||
|
||||
predicted, records = node.process(field_a, field_b, function="linear")
|
||||
|
||||
params = {r["quantity"]: r["value"] for r in records}
|
||||
assert float(params["slope"]) == pytest.approx(2.0, abs=1e-6)
|
||||
assert float(params["intercept"]) == pytest.approx(1.0, abs=1e-6)
|
||||
assert float(params["R\u00b2"]) == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_r_squared_reported():
|
||||
from backend.nodes.relate_fields import RelateFields
|
||||
|
||||
node = RelateFields()
|
||||
rng = np.random.default_rng(0)
|
||||
field_a = make_field(data=rng.standard_normal((32, 32)))
|
||||
field_b = make_field(data=rng.standard_normal((32, 32)))
|
||||
|
||||
_, records = node.process(field_a, field_b, function="linear")
|
||||
quantities = [r["quantity"] for r in records]
|
||||
assert "R\u00b2" in quantities, f"Expected 'R\u00b2' in {quantities}"
|
||||
|
||||
|
||||
def test_power_fit():
|
||||
from backend.nodes.relate_fields import RelateFields
|
||||
|
||||
node = RelateFields()
|
||||
rng = np.random.default_rng(99)
|
||||
a_data = rng.uniform(1.0, 10.0, (32, 32))
|
||||
# b = 3.0 * a^2.0
|
||||
b_data = 3.0 * np.power(a_data, 2.0)
|
||||
field_a = make_field(data=a_data)
|
||||
field_b = make_field(data=b_data)
|
||||
|
||||
predicted, records = node.process(field_a, field_b, function="power")
|
||||
|
||||
params = {r["quantity"]: r["value"] for r in records}
|
||||
assert "exponent" in params, f"Expected 'exponent' in {params}"
|
||||
assert "coefficient" in params, f"Expected 'coefficient' in {params}"
|
||||
assert float(params["exponent"]) == pytest.approx(2.0, abs=0.05)
|
||||
assert float(params["coefficient"]) == pytest.approx(3.0, abs=0.1)
|
||||
54
tests/node_tests/scan_line_reorder.py
Normal file
54
tests/node_tests/scan_line_reorder.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_reverse_odd_fixes_meander():
|
||||
from backend.nodes.scan_line_reorder import ScanLineReorder
|
||||
|
||||
node = ScanLineReorder()
|
||||
data = np.arange(32 * 32, dtype=np.float64).reshape(32, 32)
|
||||
# Simulate meander: reverse odd rows
|
||||
meander = data.copy()
|
||||
meander[1::2, :] = meander[1::2, ::-1]
|
||||
field = make_field(data=meander)
|
||||
result, = node.process(field, "reverse_odd")
|
||||
# Should restore original order
|
||||
assert np.allclose(result.data, data)
|
||||
|
||||
|
||||
def test_reverse_even():
|
||||
from backend.nodes.scan_line_reorder import ScanLineReorder
|
||||
|
||||
node = ScanLineReorder()
|
||||
field = make_field(shape=(32, 32))
|
||||
result, = node.process(field, "reverse_even")
|
||||
assert result.data.shape == (32, 32)
|
||||
|
||||
|
||||
def test_deinterlace_odd():
|
||||
from backend.nodes.scan_line_reorder import ScanLineReorder
|
||||
|
||||
node = ScanLineReorder()
|
||||
field = make_field(shape=(32, 32))
|
||||
result, = node.process(field, "deinterlace_odd")
|
||||
assert result.data.shape[1] == 32
|
||||
|
||||
|
||||
def test_flip_vertical():
|
||||
from backend.nodes.scan_line_reorder import ScanLineReorder
|
||||
|
||||
node = ScanLineReorder()
|
||||
data = np.arange(16 * 16, dtype=np.float64).reshape(16, 16)
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, "flip_vertical")
|
||||
assert np.array_equal(result.data, data[::-1, :])
|
||||
|
||||
|
||||
def test_unknown_operation():
|
||||
from backend.nodes.scan_line_reorder import ScanLineReorder
|
||||
|
||||
node = ScanLineReorder()
|
||||
field = make_field(shape=(16, 16))
|
||||
with pytest.raises(ValueError):
|
||||
node.process(field, "unknown")
|
||||
36
tests/node_tests/shade.py
Normal file
36
tests/node_tests/shade.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_shape():
|
||||
"""Output shape should match input shape."""
|
||||
from backend.nodes.shade import Shade
|
||||
|
||||
node = Shade()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, azimuth=315.0, elevation=45.0, blend=0.5)
|
||||
assert result.data.shape == field.data.shape
|
||||
|
||||
|
||||
def test_output_range():
|
||||
"""With blend=1.0, output values should be in [0, 1]."""
|
||||
from backend.nodes.shade import Shade
|
||||
|
||||
node = Shade()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, azimuth=315.0, elevation=45.0, blend=1.0)
|
||||
assert result.data.min() >= 0.0 - 1e-12
|
||||
assert result.data.max() <= 1.0 + 1e-12
|
||||
|
||||
|
||||
def test_flat_surface():
|
||||
"""A flat (constant) surface should produce uniform-ish shading output."""
|
||||
from backend.nodes.shade import Shade
|
||||
|
||||
node = Shade()
|
||||
data = np.ones((64, 64), dtype=np.float64) * 5.0
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, azimuth=315.0, elevation=45.0, blend=1.0)
|
||||
# Flat surface -> all surface normals point straight up -> uniform shading
|
||||
assert np.std(result.data) < 1e-10
|
||||
37
tests/node_tests/straighten_path.py
Normal file
37
tests/node_tests/straighten_path.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_basic_extraction():
|
||||
from backend.nodes.straighten_path import StraightenPath
|
||||
|
||||
node = StraightenPath()
|
||||
field = make_field(shape=(64, 64))
|
||||
(result,) = node.process(field, points_x="0.25, 0.5, 0.75",
|
||||
points_y="0.5, 0.3, 0.5",
|
||||
thickness=1, n_samples=256)
|
||||
assert result.data.shape[1] == 256, f"Output width should be n_samples=256, got {result.data.shape[1]}"
|
||||
|
||||
|
||||
def test_thickness():
|
||||
from backend.nodes.straighten_path import StraightenPath
|
||||
|
||||
node = StraightenPath()
|
||||
field = make_field(shape=(64, 64))
|
||||
(result,) = node.process(field, points_x="0.2, 0.8",
|
||||
points_y="0.5, 0.5",
|
||||
thickness=5, n_samples=100)
|
||||
assert result.data.shape[0] == 5, f"Output height should be thickness=5, got {result.data.shape[0]}"
|
||||
|
||||
|
||||
def test_single_point_returns_input():
|
||||
from backend.nodes.straighten_path import StraightenPath
|
||||
|
||||
node = StraightenPath()
|
||||
field = make_field(shape=(64, 64))
|
||||
(result,) = node.process(field, points_x="0.5",
|
||||
points_y="0.5",
|
||||
thickness=1, n_samples=100)
|
||||
# With only 1 point, node returns the original field unchanged
|
||||
assert np.array_equal(result.data, field.data)
|
||||
60
tests/node_tests/terrace_fit.py
Normal file
60
tests/node_tests/terrace_fit.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_terrace_fit_stepped_surface():
|
||||
from backend.nodes.terrace_fit import TerraceFit
|
||||
|
||||
node = TerraceFit()
|
||||
# Create a surface with 3 clear terraces
|
||||
data = np.zeros((64, 64))
|
||||
data[:20, :] = 0.0
|
||||
data[20:40, :] = 1.0
|
||||
data[40:, :] = 2.0
|
||||
field = make_field(data=data)
|
||||
result, records = node.process(field, 3, 1.0, 0, "residual")
|
||||
assert result.data.shape == (64, 64)
|
||||
assert isinstance(records, list)
|
||||
assert len(records) >= 3 # at least terrace heights
|
||||
|
||||
|
||||
def test_terrace_fit_auto_detect():
|
||||
from backend.nodes.terrace_fit import TerraceFit
|
||||
|
||||
node = TerraceFit()
|
||||
data = np.zeros((64, 64))
|
||||
data[:32, :] = 0.0
|
||||
data[32:, :] = 5.0
|
||||
field = make_field(data=data)
|
||||
result, records = node.process(field, 0, 1.0, 0, "fitted")
|
||||
assert result.data.shape == (64, 64)
|
||||
|
||||
|
||||
def test_terrace_fit_labels_output():
|
||||
from backend.nodes.terrace_fit import TerraceFit
|
||||
|
||||
node = TerraceFit()
|
||||
data = np.zeros((32, 32))
|
||||
data[:16, :] = 1.0
|
||||
data[16:, :] = 3.0
|
||||
field = make_field(data=data)
|
||||
result, records = node.process(field, 2, 1.0, 0, "labels")
|
||||
assert result.data.shape == (32, 32)
|
||||
# Labels should have exactly 2 distinct values
|
||||
unique = np.unique(result.data)
|
||||
assert len(unique) == 2
|
||||
|
||||
|
||||
def test_terrace_fit_step_heights_reported():
|
||||
from backend.nodes.terrace_fit import TerraceFit
|
||||
|
||||
node = TerraceFit()
|
||||
data = np.zeros((64, 64))
|
||||
data[:32, :] = 0.0
|
||||
data[32:, :] = 2.5
|
||||
field = make_field(data=data)
|
||||
_, records = node.process(field, 2, 1.0, 0, "residual")
|
||||
# Should report step height
|
||||
step_records = [r for r in records if "Step" in r["quantity"]]
|
||||
assert len(step_records) >= 1
|
||||
34
tests/node_tests/tilt.py
Normal file
34
tests/node_tests/tilt.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_add_subtract_roundtrip():
|
||||
"""Adding then subtracting the same tilt should recover the original."""
|
||||
from backend.nodes.tilt import Tilt
|
||||
|
||||
node = Tilt()
|
||||
field = make_field(shape=(64, 64))
|
||||
tilted, = node.process(field, slope_x=1000.0, slope_y=500.0, mode="add")
|
||||
recovered, = node.process(tilted, slope_x=1000.0, slope_y=500.0, mode="subtract")
|
||||
assert np.allclose(recovered.data, field.data, atol=1e-10)
|
||||
|
||||
|
||||
def test_add_tilt_changes_data():
|
||||
"""Adding a non-zero tilt should change the data."""
|
||||
from backend.nodes.tilt import Tilt
|
||||
|
||||
node = Tilt()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, slope_x=1000.0, slope_y=0.0, mode="add")
|
||||
assert not np.allclose(result.data, field.data)
|
||||
|
||||
|
||||
def test_zero_slope():
|
||||
"""Zero slopes in add mode should leave data unchanged."""
|
||||
from backend.nodes.tilt import Tilt
|
||||
|
||||
node = Tilt()
|
||||
field = make_field(shape=(64, 64))
|
||||
result, = node.process(field, slope_x=0.0, slope_y=0.0, mode="add")
|
||||
assert np.allclose(result.data, field.data, atol=1e-10)
|
||||
37
tests/node_tests/trimmed_mean.py
Normal file
37
tests/node_tests/trimmed_mean.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_uniform_field():
|
||||
"""Uniform field should remain approximately the same after filtering."""
|
||||
from backend.nodes.trimmed_mean import TrimmedMean
|
||||
|
||||
node = TrimmedMean()
|
||||
data = np.full((16, 16), 3.0, dtype=np.float64)
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, radius=2, trim_fraction=0.1)
|
||||
assert np.allclose(result.data, 3.0, atol=1e-10)
|
||||
|
||||
|
||||
def test_shape_preserved():
|
||||
"""Output shape should match input shape."""
|
||||
from backend.nodes.trimmed_mean import TrimmedMean
|
||||
|
||||
node = TrimmedMean()
|
||||
field = make_field(shape=(16, 16))
|
||||
result, = node.process(field, radius=2, trim_fraction=0.1)
|
||||
assert result.data.shape == (16, 16)
|
||||
|
||||
|
||||
def test_reduces_outliers():
|
||||
"""A spike in the field should be reduced by the trimmed mean filter."""
|
||||
from backend.nodes.trimmed_mean import TrimmedMean
|
||||
|
||||
node = TrimmedMean()
|
||||
data = np.zeros((16, 16), dtype=np.float64)
|
||||
data[8, 8] = 100.0 # large spike
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, radius=2, trim_fraction=0.1)
|
||||
# The spike should be significantly reduced
|
||||
assert result.data[8, 8] < 50.0
|
||||
36
tests/node_tests/wrap_value.py
Normal file
36
tests/node_tests/wrap_value.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_wrap_degrees():
|
||||
"""Value 400.0 wrapped to 0..360 should give 40.0."""
|
||||
from backend.nodes.wrap_value import WrapValue
|
||||
|
||||
node = WrapValue()
|
||||
data = np.array([[400.0]], dtype=np.float64)
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, range="0_to_360", custom_min=0.0, custom_max=360.0)
|
||||
assert np.isclose(result.data[0, 0], 40.0)
|
||||
|
||||
|
||||
def test_wrap_negative():
|
||||
"""Value -90.0 wrapped to 0..360 should give 270.0."""
|
||||
from backend.nodes.wrap_value import WrapValue
|
||||
|
||||
node = WrapValue()
|
||||
data = np.array([[-90.0]], dtype=np.float64)
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, range="0_to_360", custom_min=0.0, custom_max=360.0)
|
||||
assert np.isclose(result.data[0, 0], 270.0)
|
||||
|
||||
|
||||
def test_custom_range():
|
||||
"""Value 250 with custom range 0..100 should wrap to 50."""
|
||||
from backend.nodes.wrap_value import WrapValue
|
||||
|
||||
node = WrapValue()
|
||||
data = np.array([[250.0]], dtype=np.float64)
|
||||
field = make_field(data=data)
|
||||
result, = node.process(field, range="custom", custom_min=0.0, custom_max=100.0)
|
||||
assert np.isclose(result.data[0, 0], 50.0)
|
||||
37
tests/node_tests/zero_crossing.py
Normal file
37
tests/node_tests/zero_crossing.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_binary():
|
||||
from backend.nodes.zero_crossing import ZeroCrossing
|
||||
|
||||
node = ZeroCrossing()
|
||||
field = make_field()
|
||||
(edges,) = node.process(field, sigma=2.0, threshold=0.0)
|
||||
unique = set(np.unique(edges.data))
|
||||
assert unique <= {0.0, 1.0}, f"Expected only 0.0/1.0, got {unique}"
|
||||
|
||||
|
||||
def test_detects_step_edge():
|
||||
from backend.nodes.zero_crossing import ZeroCrossing
|
||||
|
||||
node = ZeroCrossing()
|
||||
data = np.zeros((64, 64))
|
||||
data[:, 32:] = 1.0
|
||||
field = make_field(data=data)
|
||||
(edges,) = node.process(field, sigma=2.0, threshold=0.0)
|
||||
|
||||
# Edge energy should concentrate near column 32
|
||||
col_energy = edges.data.sum(axis=0)
|
||||
peak_col = np.argmax(col_energy)
|
||||
assert abs(peak_col - 32) <= 3, f"Peak at col {peak_col}, expected ~32"
|
||||
|
||||
|
||||
def test_shape_preserved():
|
||||
from backend.nodes.zero_crossing import ZeroCrossing
|
||||
|
||||
node = ZeroCrossing()
|
||||
field = make_field(shape=(48, 96))
|
||||
(edges,) = node.process(field, sigma=1.5, threshold=0.1)
|
||||
assert edges.data.shape == (48, 96)
|
||||
Reference in New Issue
Block a user