tip modelling and deconvolution

This commit is contained in:
2026-03-29 21:49:17 -07:00
parent 24b2c55f2a
commit 1df4df2811
23 changed files with 2231 additions and 28 deletions

View File

@@ -24,9 +24,12 @@ def test_acf_1d():
assert isinstance(acf, LineData)
assert isinstance(measurement, RecordTable)
# ACF should be symmetric about zero lag
center = len(acf) // 2
assert np.allclose(acf.data, acf.data[::-1], atol=1e-10)
# Only positive lags are output
assert acf.x_axis is not None
assert np.all(acf.x_axis > 0)
# ACF should be monotonically decreasing at the very start (falls from the zero-lag peak)
assert acf.data[0] > 0
# Peak period should be close to the input period in metres
expected_period_m = period * 1e-9
@@ -35,13 +38,6 @@ def test_acf_1d():
assert abs(measurement[0]["value"] - expected_period_m) / expected_period_m < 0.1
assert measurement[0]["unit"] == "m"
# x_axis should be centred on zero
assert acf.x_axis is not None
assert acf.x_axis[center] == 0.0 or abs(acf.x_axis[center]) < 1e-15
# ACF at zero lag should equal variance (signal is mean-subtracted)
assert acf.data[center] > 0
def test_acf_1d_no_peak():
from backend.nodes.acf_1d import ACF1D
@@ -68,5 +64,5 @@ def test_acf_1d_level_none():
profile = LineData(data=data, x_axis=np.arange(32, dtype=np.float64))
acf, _ = node.process(profile, level="none")
# ACF of a constant is a constant
assert acf.data[len(acf) // 2] > 0
# ACF of a constant is a constant — first positive-lag value should be positive
assert acf.data[0] > 0

View File

@@ -0,0 +1,75 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
def test_field_arithmetic_basic():
from backend.nodes.field_arithmetic import FieldArithmetic
node = FieldArithmetic()
a = make_field(data=np.array([[1.0, 2.0], [3.0, 4.0]]))
b = make_field(data=np.array([[1.0, 1.0], [1.0, 1.0]]))
result, = node.process(a, b, "add")
assert np.allclose(result.data, [[2.0, 3.0], [4.0, 5.0]])
result, = node.process(a, b, "subtract")
assert np.allclose(result.data, [[0.0, 1.0], [2.0, 3.0]])
result, = node.process(a, b, "multiply")
assert np.allclose(result.data, [[1.0, 2.0], [3.0, 4.0]])
result, = node.process(a, b, "divide")
assert np.allclose(result.data, [[1.0, 2.0], [3.0, 4.0]])
result, = node.process(a, b, "min")
assert np.allclose(result.data, [[1.0, 1.0], [1.0, 1.0]])
result, = node.process(a, b, "max")
assert np.allclose(result.data, [[1.0, 2.0], [3.0, 4.0]])
def test_field_arithmetic_hypot():
from backend.nodes.field_arithmetic import FieldArithmetic
node = FieldArithmetic()
a = make_field(data=np.array([[3.0, 0.0], [0.0, 5.0]]))
b = make_field(data=np.array([[4.0, 5.0], [3.0, 12.0]]))
result, = node.process(a, b, "hypot")
assert np.allclose(result.data, [[5.0, 5.0], [3.0, 13.0]])
def test_field_arithmetic_metadata_inherited():
from backend.nodes.field_arithmetic import FieldArithmetic
node = FieldArithmetic()
a = make_field(data=np.ones((4, 4)))
b = make_field(data=np.ones((4, 4)))
result, = node.process(a, b, "add")
assert result.xreal == a.xreal
assert result.si_unit_xy == a.si_unit_xy
assert result.si_unit_z == a.si_unit_z
def test_field_arithmetic_shape_mismatch():
from backend.nodes.field_arithmetic import FieldArithmetic
node = FieldArithmetic()
a = make_field(data=np.ones((4, 4)))
b = make_field(data=np.ones((3, 3)))
with pytest.raises(ValueError, match="resolution"):
node.process(a, b, "add")
def test_field_arithmetic_unknown_operation():
from backend.nodes.field_arithmetic import FieldArithmetic
node = FieldArithmetic()
a = make_field(data=np.ones((4, 4)))
b = make_field(data=np.ones((4, 4)))
with pytest.raises(ValueError):
node.process(a, b, "power")

View File

@@ -0,0 +1,91 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
def test_gradient_flat_field():
from backend.nodes.gradient import Gradient
node = Gradient()
field = make_field(data=np.zeros((16, 16)))
for component in ("magnitude", "x", "y"):
result, = node.process(field, component)
assert np.allclose(result.data, 0.0)
assert result.data.shape == field.data.shape
def test_gradient_x_ramp():
"""Linear ramp in x should give uniform x-gradient and zero y-gradient."""
from backend.nodes.gradient import Gradient
node = Gradient()
data = np.tile(np.linspace(0.0, 1.0, 32), (32, 1))
field = make_field(data=data)
gx, = node.process(field, "x")
gy, = node.process(field, "y")
# Interior pixels should have nearly identical x-gradient values
interior_gx = gx.data[1:-1, 1:-1]
assert np.all(interior_gx > 0)
assert np.std(interior_gx) < np.mean(interior_gx) * 0.01
# y-gradient should be zero everywhere in the interior
assert np.allclose(gy.data[1:-1, 1:-1], 0.0, atol=1e-10)
def test_gradient_magnitude_non_negative():
from backend.nodes.gradient import Gradient
node = Gradient()
field = make_field()
result, = node.process(field, "magnitude")
assert np.all(result.data >= 0.0)
def test_gradient_azimuth_range():
from backend.nodes.gradient import Gradient
node = Gradient()
field = make_field()
result, = node.process(field, "azimuth")
assert np.all(result.data >= -np.pi - 1e-10)
assert np.all(result.data <= np.pi + 1e-10)
assert result.si_unit_z == "rad"
def test_gradient_units():
from backend.nodes.gradient import Gradient
node = Gradient()
field = make_field() # si_unit_z="m", si_unit_xy="m"
result, = node.process(field, "magnitude")
assert result.si_unit_z == "m/m"
result, = node.process(field, "x")
assert result.si_unit_z == "m/m"
def test_gradient_shape_preserved():
from backend.nodes.gradient import Gradient
node = Gradient()
field = make_field(shape=(48, 64))
for component in ("magnitude", "x", "y", "azimuth"):
result, = node.process(field, component)
assert result.data.shape == (48, 64)
def test_gradient_unknown_component():
from backend.nodes.gradient import Gradient
node = Gradient()
field = make_field()
with pytest.raises(ValueError):
node.process(field, "diagonal")

View File

@@ -0,0 +1,91 @@
import numpy as np
import pytest
def _make_mask(*rects):
"""Create a 30x30 uint8 mask with 255 in the given (row_start, row_end, col_start, col_end) rects."""
m = np.zeros((30, 30), dtype=np.uint8)
for r0, r1, c0, c1 in rects:
m[r0:r1, c0:c1] = 255
return m
def test_grain_filter_min_area():
from backend.nodes.grain_filter import GrainFilter
node = GrainFilter()
# Small grain: 2×2 = 4 px; large grain: 6×6 = 36 px
mask = _make_mask((1, 3, 1, 3), (15, 21, 15, 21))
# Remove grains smaller than 10 px → only large grain survives
result, = node.process(mask, min_area=10, max_area=0, remove_border=False)
assert result[1:3, 1:3].sum() == 0 # small grain removed
assert result[15:21, 15:21].sum() > 0 # large grain kept
def test_grain_filter_max_area():
from backend.nodes.grain_filter import GrainFilter
node = GrainFilter()
mask = _make_mask((1, 3, 1, 3), (15, 21, 15, 21))
# Remove grains larger than 10 px → only small grain survives
result, = node.process(mask, min_area=1, max_area=10, remove_border=False)
assert result[1:3, 1:3].sum() > 0 # small grain kept
assert result[15:21, 15:21].sum() == 0 # large grain removed
def test_grain_filter_remove_border():
from backend.nodes.grain_filter import GrainFilter
node = GrainFilter()
# Border grain (touches top-left corner) and interior grain
mask = _make_mask((0, 3, 0, 3), (10, 15, 10, 15))
result, = node.process(mask, min_area=1, max_area=0, remove_border=True)
assert result[0:3, 0:3].sum() == 0 # border grain removed
assert result[10:15, 10:15].sum() > 0 # interior grain kept
def test_grain_filter_no_grains():
from backend.nodes.grain_filter import GrainFilter
node = GrainFilter()
mask = np.zeros((20, 20), dtype=np.uint8)
result, = node.process(mask, min_area=1, max_area=0, remove_border=False)
assert result.sum() == 0
def test_grain_filter_all_grains_kept():
from backend.nodes.grain_filter import GrainFilter
node = GrainFilter()
mask = _make_mask((5, 9, 5, 9), (15, 19, 15, 19))
# Permissive thresholds: no grains should be removed
result, = node.process(mask, min_area=1, max_area=0, remove_border=False)
assert result[5:9, 5:9].sum() > 0
assert result[15:19, 15:19].sum() > 0
def test_grain_filter_max_area_zero_means_no_limit():
from backend.nodes.grain_filter import GrainFilter
node = GrainFilter()
# Large grain 10×10 = 100 px; max_area=0 means no upper limit
mask = _make_mask((5, 15, 5, 15))
result, = node.process(mask, min_area=1, max_area=0, remove_border=False)
assert result[5:15, 5:15].sum() > 0
def test_grain_filter_output_dtype():
from backend.nodes.grain_filter import GrainFilter
node = GrainFilter()
mask = _make_mask((5, 10, 5, 10))
result, = node.process(mask, min_area=1, max_area=0, remove_border=False)
assert result.dtype == np.uint8
assert set(result.flat).issubset({0, 255})

View File

@@ -0,0 +1,85 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
from backend.data_types import LineData
def test_radial_profile_constant_field():
"""Constant field: profile should equal the constant at every finite bin."""
from backend.nodes.radial_profile import RadialProfile
node = RadialProfile()
field = make_field(data=np.full((64, 64), 2.5))
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32)
assert isinstance(result, LineData)
assert len(result.data) == 32
finite = result.data[np.isfinite(result.data)]
assert finite.size > 0
assert np.allclose(finite, 2.5, atol=1e-10)
def test_radial_profile_units():
from backend.nodes.radial_profile import RadialProfile
node = RadialProfile()
field = make_field()
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32)
assert result.x_unit == field.si_unit_xy
assert result.y_unit == field.si_unit_z
def test_radial_profile_x_axis_monotone():
"""Radius axis must be strictly increasing and start near zero."""
from backend.nodes.radial_profile import RadialProfile
node = RadialProfile()
field = make_field()
result, = node.process(field, cx=0.5, cy=0.5, n_bins=64)
assert result.x_axis[0] >= 0.0
assert np.all(np.diff(result.x_axis) > 0)
def test_radial_profile_off_centre():
"""Off-centre origin produces a valid profile with the same number of bins."""
from backend.nodes.radial_profile import RadialProfile
node = RadialProfile()
field = make_field(data=np.ones((64, 64)))
result, = node.process(field, cx=0.0, cy=0.0, n_bins=32)
assert len(result.data) == 32
finite = result.data[np.isfinite(result.data)]
assert np.allclose(finite, 1.0, atol=1e-10)
def test_radial_profile_radial_symmetry():
"""A radially symmetric field should give a smooth, non-constant profile."""
from backend.nodes.radial_profile import RadialProfile
node = RadialProfile()
yres, xres = 64, 64
ys, xs = np.mgrid[0:yres, 0:xres]
r = np.hypot(xs - xres / 2.0, ys - yres / 2.0)
data = np.cos(r * np.pi / (xres / 2.0))
field = make_field(data=data)
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32)
finite = result.data[np.isfinite(result.data)]
# The profile should vary (not constant)
assert np.std(finite) > 0.01
def test_radial_profile_n_bins():
from backend.nodes.radial_profile import RadialProfile
node = RadialProfile()
field = make_field()
for n in (16, 64, 256):
result, = node.process(field, cx=0.5, cy=0.5, n_bins=n)
assert len(result.data) == n
assert len(result.x_axis) == n

View File

@@ -0,0 +1,83 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
def test_resample_upsample():
from backend.nodes.resample import Resample
node = Resample()
field = make_field(shape=(32, 32))
result, = node.process(field, width=64, height=64, interpolation="linear")
assert result.data.shape == (64, 64)
assert np.isclose(result.xreal, field.xreal)
assert np.isclose(result.yreal, field.yreal)
# dx should halve since physical size is same but twice the pixels
assert np.isclose(result.dx, field.dx / 2, rtol=1e-9)
def test_resample_downsample():
from backend.nodes.resample import Resample
node = Resample()
field = make_field(shape=(64, 64))
result, = node.process(field, width=32, height=32, interpolation="linear")
assert result.data.shape == (32, 32)
assert np.isclose(result.xreal, field.xreal)
def test_resample_non_square():
from backend.nodes.resample import Resample
node = Resample()
field = make_field(shape=(32, 64))
result, = node.process(field, width=128, height=64, interpolation="nearest")
assert result.data.shape == (64, 128)
def test_resample_interpolation_modes():
from backend.nodes.resample import Resample
node = Resample()
field = make_field(shape=(32, 32))
for interp in ("linear", "cubic", "nearest"):
result, = node.process(field, width=64, height=64, interpolation=interp)
assert result.data.shape == (64, 64)
def test_resample_constant_field():
"""Resampling a constant field should remain constant regardless of interpolation."""
from backend.nodes.resample import Resample
node = Resample()
field = make_field(data=np.full((16, 16), 3.14))
for interp in ("linear", "cubic", "nearest"):
result, = node.process(field, width=32, height=32, interpolation=interp)
assert np.allclose(result.data, 3.14, atol=1e-6)
def test_resample_metadata_preserved():
from backend.nodes.resample import Resample
node = Resample()
field = make_field()
result, = node.process(field, width=64, height=64, interpolation="linear")
assert result.si_unit_xy == field.si_unit_xy
assert result.si_unit_z == field.si_unit_z
assert np.isclose(result.xreal, field.xreal)
def test_resample_unknown_interpolation():
from backend.nodes.resample import Resample
node = Resample()
field = make_field()
with pytest.raises(ValueError, match="interpolation"):
node.process(field, width=64, height=64, interpolation="lanczos")

View File

@@ -0,0 +1,98 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
from backend.data_types import LineData
def test_slope_distribution_output_shape():
from backend.nodes.slope_distribution import SlopeDistribution
node = SlopeDistribution()
field = make_field()
for dist in ("theta", "phi", "gradient"):
result, = node.process(field, dist, 90)
assert isinstance(result, LineData)
assert len(result.data) == 90
assert len(result.x_axis) == 90
def test_slope_distribution_theta_non_negative_and_normalized():
from backend.nodes.slope_distribution import SlopeDistribution
node = SlopeDistribution()
field = make_field()
result, = node.process(field, "theta", 90)
assert np.all(result.data >= 0.0)
assert result.x_unit == "deg"
# Probability density: integral ≈ 1 (bin_width × sum ≈ 1)
bin_width = result.x_axis[1] - result.x_axis[0]
integral = float(np.sum(result.data) * bin_width)
assert abs(integral - 1.0) < 0.05
def test_slope_distribution_gradient_normalized():
from backend.nodes.slope_distribution import SlopeDistribution
node = SlopeDistribution()
field = make_field()
result, = node.process(field, "gradient", 100)
assert np.all(result.data >= 0.0)
# Probability density: integral ≈ 1
bin_width = result.x_axis[1] - result.x_axis[0]
integral = float(np.sum(result.data) * bin_width)
assert abs(integral - 1.0) < 0.05
def test_slope_distribution_phi_units():
from backend.nodes.slope_distribution import SlopeDistribution
node = SlopeDistribution()
field = make_field()
result, = node.process(field, "phi", 180)
assert result.x_unit == "deg"
assert np.all(result.data >= 0.0)
# x-axis must span [0, 360)
assert result.x_axis[0] >= 0.0
assert result.x_axis[-1] < 360.0
def test_slope_distribution_flat_field_theta():
"""Flat field: all gradients are zero → theta=0 everywhere, first bin gets all weight."""
from backend.nodes.slope_distribution import SlopeDistribution
node = SlopeDistribution()
field = make_field(data=np.zeros((32, 32)))
result, = node.process(field, "theta", 90)
# With max_theta=0 we use fallback 90; all pixels land in bin 0
assert result.data[0] == pytest.approx(result.data.sum(), abs=1e-10)
def test_slope_distribution_x_ramp_phi():
"""X-only ramp: slope direction should be concentrated near phi=180° (ascending left, or 0°/360°)."""
from backend.nodes.slope_distribution import SlopeDistribution
node = SlopeDistribution()
data = np.tile(np.linspace(0.0, 1.0, 64), (64, 1))
field = make_field(data=data)
result, = node.process(field, "phi", 360)
# Peak should be within first or last few bins (phi ≈ 0 or 2π, i.e., ascending in +x direction)
# atan2(0, -gx) with gx>0 gives atan2(0,-gx) = π, so peak near 180°
peak_idx = int(np.argmax(result.data))
peak_deg = float(result.x_axis[peak_idx])
assert 150.0 < peak_deg < 210.0, f"Unexpected peak at {peak_deg}°"
def test_slope_distribution_unknown_distribution():
from backend.nodes.slope_distribution import SlopeDistribution
node = SlopeDistribution()
field = make_field()
with pytest.raises(ValueError):
node.process(field, "azimuth", 90)

View File

@@ -0,0 +1,168 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
from backend.data_types import DataField
def run_blind(field, n_pixels=17, threshold=0.0, method="partial", use_edges=False):
from backend.nodes.tip_blind_estimate import BlindTipEstimate
node = BlindTipEstimate()
tip, certainty = node.process(
field=field, n_pixels=n_pixels, threshold=threshold,
method=method, use_edges=use_edges,
)
return tip, certainty
# ── Output types and dimensions ──────────────────────────────────────────────
def test_outputs_are_data_fields():
field = make_field(shape=(32, 32), xreal=32e-9, yreal=32e-9)
tip, certainty = run_blind(field, n_pixels=9)
assert isinstance(tip, DataField)
assert isinstance(certainty, DataField)
def test_tip_output_shape():
"""Tip must be (n_pixels, n_pixels) — odd, bumped if even."""
field = make_field(shape=(32, 32), xreal=32e-9, yreal=32e-9)
for n in (7, 11, 17):
tip, _ = run_blind(field, n_pixels=n)
assert tip.data.shape == (n, n), f"Expected ({n},{n}), got {tip.data.shape}"
def test_tip_n_pixels_even_bumped():
field = make_field(shape=(32, 32), xreal=32e-9, yreal=32e-9)
tip, _ = run_blind(field, n_pixels=16)
assert tip.data.shape[0] == 17
def test_certainty_output_matches_field_shape():
field = make_field(shape=(48, 64))
_, certainty = run_blind(field, n_pixels=9)
assert certainty.data.shape == field.data.shape
def test_certainty_is_binary():
"""Certainty map values must all be 0.0 or 1.0."""
field = make_field(shape=(32, 32), xreal=32e-9, yreal=32e-9)
_, certainty = run_blind(field, n_pixels=9)
vals = np.unique(certainty.data)
for v in vals:
assert v in (0.0, 1.0), f"Non-binary certainty value: {v}"
# ── Tip conventions ───────────────────────────────────────────────────────────
def test_tip_min_is_zero():
field = make_field(shape=(32, 32), xreal=32e-9, yreal=32e-9)
tip, _ = run_blind(field, n_pixels=9)
assert tip.data.min() >= -1e-15
def test_tip_max_at_centre():
"""Apex (centre pixel) must be the maximum of the estimated tip."""
field = make_field(shape=(32, 32), xreal=32e-9, yreal=32e-9)
tip, _ = run_blind(field, n_pixels=9)
ci = tip.data.shape[0] // 2
assert tip.data[ci, ci] == pytest.approx(tip.data.max(), abs=1e-20)
def test_tip_units_inherited():
field = make_field(xreal=1e-6, yreal=1e-6)
field.si_unit_xy = "nm"
field.si_unit_z = "V"
tip, _ = run_blind(field, n_pixels=9)
assert tip.si_unit_xy == "nm"
assert tip.si_unit_z == "V"
# ── Flat field gives flat (zero) tip ─────────────────────────────────────────
def test_flat_field_gives_zero_tip():
"""A flat image has no features → blind estimation cannot refine the tip → stays flat."""
flat = make_field(data=np.full((32, 32), 3.14))
tip, _ = run_blind(flat, n_pixels=7)
# Tip should be all zeros (no refinement possible)
assert np.allclose(tip.data, 0.0, atol=1e-12)
# ── Single spike → estimated tip matches expected shape ──────────────────────
def test_spike_gives_sharp_tip():
"""
A single sharp spike dilated by a known paraboloid tip gives a broadened image.
Blind estimation on the broadened image should recover a tip that is ≤ the true tip
everywhere (blind estimation is an upper bound on the true tip shape).
"""
from scipy.ndimage import grey_dilation
from backend.nodes.tip_model import TipModel
pixel_size = 1e-9
n = 64
field_ref = make_field(shape=(n, n), xreal=n * pixel_size, yreal=n * pixel_size)
# Create true parabolic tip (33×33, radius=20nm)
true_tip_node = TipModel()
true_tip, = true_tip_node.process(
field=field_ref, shape="parabola", radius=20e-9,
half_angle=20.0, n_pixels=33,
)
# Spike surface
surface = np.zeros((n, n))
surface[n // 2, n // 2] = 1e-9 # 1 nm spike
# Dilated (measured) image
dil_struct = true_tip.data - true_tip.data.max()
measured_data = grey_dilation(surface, structure=dil_struct)
measured = make_field(data=measured_data, xreal=n * pixel_size, yreal=n * pixel_size)
# Blind estimation
est_tip, certainty = run_blind(measured, n_pixels=33, threshold=0.0, method="partial")
# The estimated tip must be ≤ the true tip everywhere (blind est. is upper bound)
# Both are min=0, max=apex. Compare normalised shapes.
true_norm = true_tip.data / true_tip.data.max() if true_tip.data.max() > 0 else true_tip.data
est_norm = est_tip.data / est_tip.data.max() if est_tip.data.max() > 0 else est_tip.data
# After normalisation: est ≤ true everywhere (blind is conservative)
assert np.all(est_norm <= true_norm + 1e-6), \
"Blind estimate exceeded true tip (not a valid upper bound)"
# ── Partial vs full ───────────────────────────────────────────────────────────
def test_full_method_runs():
"""Full estimation should run without error on a small image."""
field = make_field(shape=(24, 24), xreal=24e-9, yreal=24e-9)
tip, certainty = run_blind(field, n_pixels=7, method="full")
assert isinstance(tip, DataField)
assert isinstance(certainty, DataField)
# ── Certainty increases with sharp features ───────────────────────────────────
def test_certainty_nonzero_for_sharp_image():
"""An image with distinct features should produce some certain pixels."""
from scipy.ndimage import grey_dilation
from backend.nodes.tip_model import TipModel
pixel_size = 1e-9
n = 64
field_ref = make_field(shape=(n, n), xreal=n * pixel_size, yreal=n * pixel_size)
true_tip_node = TipModel()
true_tip, = true_tip_node.process(
field=field_ref, shape="parabola", radius=20e-9,
half_angle=20.0, n_pixels=17,
)
# Create a sharp pyramid surface
cx, cy = n // 2, n // 2
ys, xs = np.mgrid[0:n, 0:n]
surface = np.maximum(0, 5e-9 - 0.3e-9 * np.maximum(np.abs(xs - cx), np.abs(ys - cy)))
dil_struct = true_tip.data - true_tip.data.max()
measured_data = grey_dilation(surface, structure=dil_struct)
measured = make_field(data=measured_data, xreal=n * pixel_size, yreal=n * pixel_size)
_, certainty = run_blind(measured, n_pixels=17, method="partial")
assert certainty.data.sum() > 0, "No certain pixels found for a sharp image"

View File

@@ -0,0 +1,120 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
from backend.data_types import DataField
def run_deconv(field, tip):
from backend.nodes.tip_deconvolution import TipDeconvolution
node = TipDeconvolution()
result, = node.process(field=field, tip=tip)
return result
def make_tip(shape="parabola", radius=10e-9, half_angle=20.0, n_pixels=33):
from backend.nodes.tip_model import TipModel
field = make_field(shape=(64, 64), xreal=64e-9, yreal=64e-9)
node = TipModel()
result, = node.process(field=field, shape=shape, radius=radius,
half_angle=half_angle, n_pixels=n_pixels)
return result
# ── Output type and shape ────────────────────────────────────────────────────
def test_deconv_output_is_data_field():
field = make_field()
tip = DataField(data=np.zeros((1, 1)), xreal=1e-9, yreal=1e-9)
result = run_deconv(field, tip)
assert isinstance(result, DataField)
def test_deconv_output_shape_matches_input():
field = make_field(shape=(64, 64))
tip = make_tip(n_pixels=11)
result = run_deconv(field, tip)
assert result.data.shape == field.data.shape
def test_deconv_preserves_physical_dimensions():
field = make_field(xreal=2e-6, yreal=3e-6)
tip = DataField(data=np.zeros((1, 1)), xreal=1e-9, yreal=1e-9)
result = run_deconv(field, tip)
assert result.xreal == field.xreal
assert result.yreal == field.yreal
def test_deconv_preserves_units():
field = make_field()
field.si_unit_xy = "nm"
field.si_unit_z = "V"
tip = DataField(data=np.zeros((1, 1)), xreal=1e-9, yreal=1e-9)
result = run_deconv(field, tip)
assert result.si_unit_xy == "nm"
assert result.si_unit_z == "V"
# ── Identity with a point tip ────────────────────────────────────────────────
def test_deconv_point_tip_is_identity():
"""A 1×1 tip with value 0 is the identity: erosion with structure [[0]] = original."""
field = make_field(data=np.random.default_rng(0).standard_normal((32, 32)) + 10)
tip = DataField(data=np.zeros((1, 1)), xreal=1e-9, yreal=1e-9)
result = run_deconv(field, tip)
assert np.allclose(result.data, field.data, atol=1e-12)
# ── Flat field invariance ────────────────────────────────────────────────────
@pytest.mark.parametrize("shape", ["parabola", "cone", "sphere"])
def test_deconv_flat_field_stays_flat(shape):
"""Deconvolution of a constant-valued field must remain constant."""
flat_data = np.full((64, 64), 5.0)
field = make_field(data=flat_data)
tip = make_tip(shape=shape, n_pixels=15)
result = run_deconv(field, tip)
interior = result.data[8:-8, 8:-8] # avoid border effects
assert np.allclose(interior, 5.0, atol=1e-10)
# ── Deconvolution sharpens features ─────────────────────────────────────────
def test_deconv_sharpens_broadened_image():
"""
Forward tip dilation broadens a spike; deconvolution (erosion) should remove
the broadening. Result must be ≤ input everywhere (erosion is a lower bound).
"""
from scipy.ndimage import grey_dilation
# Build a field with a single spike
data = np.zeros((64, 64))
data[32, 32] = 1.0
field = make_field(data=data)
# Create a small parabolic tip
tip = make_tip(shape="parabola", radius=50e-9, n_pixels=11)
tip_data = tip.data
# Simulate measured image via tip dilation (Gwyddion gwy_tip_dilation):
# dilation_tip = tip - max(tip) (max shifted to 0, values ≤ 0)
# measured[y,x] = max_{ty,tx}[surface[yty, xtx] + dilation_tip[ty,tx]]
dilation_struct = tip_data - tip_data.max()
measured_data = grey_dilation(data, structure=dilation_struct)
measured = make_field(data=measured_data)
# Deconvolve
result = run_deconv(measured, tip)
# Erosion is a lower bound on the input
assert np.all(result.data <= measured_data + 1e-10)
# The spike should be recovered: result at spike position ≥ most surroundings
assert result.data[32, 32] > result.data[32, 36]
def test_deconv_erosion_never_exceeds_input():
"""Grey erosion is always ≤ the input (fundamental morphological property)."""
field = make_field(data=np.abs(np.random.default_rng(7).standard_normal((32, 32))))
tip = make_tip(shape="parabola", n_pixels=7)
result = run_deconv(field, tip)
assert np.all(result.data <= field.data + 1e-10)

View File

@@ -0,0 +1,153 @@
import numpy as np
import pytest
from tests.node_tests._shared import make_field
def make_tip(shape="parabola", radius=10e-9, half_angle=20.0, n_pixels=65, field=None):
from backend.nodes.tip_model import TipModel
if field is None:
# 1 nm/pixel reference field
field = make_field(shape=(128, 128), xreal=128e-9, yreal=128e-9)
node = TipModel()
result, = node.process(field=field, shape=shape, radius=radius,
half_angle=half_angle, n_pixels=n_pixels)
return result
# ── Shape and dimensionality ────────────────────────────────────────────────
def test_tip_output_is_data_field():
from backend.data_types import DataField
tip = make_tip()
assert isinstance(tip, DataField)
def test_tip_shape_matches_n_pixels():
"""Output grid must be (n_pixels, n_pixels)."""
for n in (5, 9, 33, 65):
tip = make_tip(n_pixels=n)
assert tip.data.shape == (n, n)
def test_tip_n_pixels_even_forced_odd():
"""Even n_pixels should be silently bumped to n+1."""
tip = make_tip(n_pixels=64)
assert tip.data.shape[0] % 2 == 1
assert tip.data.shape[0] == 65
def test_tip_xreal_equals_n_times_pixel_size():
"""xreal must equal n_pixels * pixel_size of the reference field."""
pixel_size = 1e-9
field = make_field(shape=(128, 128), xreal=128 * pixel_size, yreal=128 * pixel_size)
tip = make_tip(n_pixels=65, field=field)
assert abs(tip.xreal - 65 * pixel_size) < 1e-25
def test_tip_units_inherited_from_field():
field = make_field()
field.si_unit_xy = "nm"
field.si_unit_z = "V"
tip = make_tip(field=field)
assert tip.si_unit_xy == "nm"
assert tip.si_unit_z == "V"
# ── Apex and minimum conventions ────────────────────────────────────────────
@pytest.mark.parametrize("shape", ["parabola", "cone", "sphere"])
def test_tip_min_is_zero(shape):
"""All shapes must shift the data so the minimum is 0."""
tip = make_tip(shape=shape)
assert tip.data.min() >= -1e-15
@pytest.mark.parametrize("shape", ["parabola", "cone", "sphere"])
def test_tip_apex_at_centre(shape):
"""Centre pixel must equal the maximum for all shapes."""
tip = make_tip(shape=shape)
n = tip.data.shape[0]
ci = n // 2
assert tip.data[ci, ci] == pytest.approx(tip.data.max(), abs=1e-20)
@pytest.mark.parametrize("shape", ["parabola", "cone", "sphere"])
def test_tip_radial_symmetry(shape):
"""All shapes must be left-right and up-down symmetric."""
tip = make_tip(shape=shape)
data = tip.data
assert np.allclose(data, np.flipud(data), atol=1e-12)
assert np.allclose(data, np.fliplr(data), atol=1e-12)
# ── Parabola formula verification ────────────────────────────────────────────
def test_parabola_apex_formula():
"""Parabola apex height = (half_width)² / R (Gwyddion: z0 = 2a·x_half²)."""
radius = 20e-9
n = 65
pixel_size = 1e-9
field = make_field(shape=(256, 256), xreal=256 * pixel_size, yreal=256 * pixel_size)
tip = make_tip(shape="parabola", radius=radius, n_pixels=n, field=field)
x_half = (n // 2) * pixel_size # = 32 nm
expected_apex = x_half**2 / radius # = 1024e-18 / 20e-9 = 51.2 nm
assert abs(tip.data[n // 2, n // 2] - expected_apex) < 1e-12 * expected_apex
def test_parabola_decreases_monotonically_from_centre():
"""Parabola row profile must be monotonically decreasing from centre outward."""
tip = make_tip(shape="parabola", n_pixels=33)
ci = 16
row = tip.data[ci, :]
assert np.all(np.diff(row[:ci + 1]) >= 0) # left half increases toward centre
assert np.all(np.diff(row[ci:]) <= 0) # right half decreases from centre
def test_parabola_corner_value_is_zero():
"""Gwyddion constructs z0 so that z at (±half, ±half) = 0 exactly."""
n = 33
tip = make_tip(shape="parabola", n_pixels=n)
corner = tip.data[0, 0]
# The corner is by construction the minimum; after the min-shift it should be ~0
assert abs(corner) < 1e-15 * tip.data[n // 2, n // 2]
# ── Cone shape ──────────────────────────────────────────────────────────────
def test_cone_apex_greater_than_edges():
tip = make_tip(shape="cone", half_angle=20.0, n_pixels=33)
ci = 16
assert tip.data[ci, ci] > tip.data[0, 0]
assert tip.data[0, 0] >= -1e-15 # edges should be ~0
def test_cone_decreases_away_from_centre():
"""Cone must be monotonically decreasing along any radial line from centre."""
tip = make_tip(shape="cone", half_angle=20.0, n_pixels=33)
ci = 16
row = tip.data[ci, :]
assert np.all(np.diff(row[ci:]) <= 0)
def test_cone_different_half_angles_differ():
"""Tips with different half-angles must produce different shapes."""
tip_wide = make_tip(shape="cone", half_angle=40.0, n_pixels=33)
tip_narrow = make_tip(shape="cone", half_angle=10.0, n_pixels=33)
assert not np.allclose(tip_wide.data, tip_narrow.data)
# ── Sphere shape ─────────────────────────────────────────────────────────────
def test_sphere_curvature_near_apex():
"""Near the apex the sphere cap gives z ≈ sqrt(R²-r²), so
z[ci,ci] - z[ci, ci+1] ≈ pixel_size² / (2R) for r << R."""
radius = 100e-9
pixel_size = 1e-9
field = make_field(shape=(256, 256), xreal=256 * pixel_size, yreal=256 * pixel_size)
tip = make_tip(shape="sphere", radius=radius, n_pixels=33, field=field)
ci = 16
delta_z = tip.data[ci, ci] - tip.data[ci, ci + 1]
expected = pixel_size**2 / (2 * radius)
# relative tolerance 0.1% (small-angle approximation holds well for r << R)
assert abs(delta_z - expected) / expected < 1e-3