Skip to content

Commit 548ff1e

Browse files
authored
Merge pull request #523 from PyAutoLabs/feature/delaunay-area-magnification-audit
test: Delaunay pixel-area known-answer tests + areas_for_magnification docstring (#522)
2 parents e36a5af + 8c2e0d1 commit 548ff1e

3 files changed

Lines changed: 259 additions & 5 deletions

File tree

autoarray/inversion/mesh/mesh_geometry/delaunay.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,20 @@ def voronoi_areas(self):
194194
@property
195195
def areas_for_magnification(self) -> np.ndarray:
196196
"""
197-
Returns the area of every Voronoi pixel in the Voronoi mesh.
198-
199-
Pixels at boundaries can sometimes have large unrealistic areas, which can impact the magnification
200-
calculation. This method therefore sets their areas to zero so they do not impact the magnification
201-
calculation.
197+
Returns the Voronoi cell area of every pixel in the mesh, as computed by `voronoi_areas_numpy` (a shoelace
198+
sum over the `scipy.spatial.Voronoi` cell of each mesh point).
199+
200+
Only cells that are **unbounded** in the Voronoi diagram (those `voronoi_areas_numpy` flags with the `-1`
201+
sentinel, because their region runs to infinity and has no finite area) are set to zero. Cells that are
202+
bounded but sit at the edge of the mesh are **kept at full size**, even though they can be far larger than
203+
the interior cells -- an order of magnitude is routine, because a boundary cell extends out to the
204+
circumcentres of the outermost triangles rather than being clipped to the mesh.
205+
206+
These Voronoi areas are **not** the barycentric dual areas used by the Delaunay interpolator
207+
(`barycentric_dual_area_from` in `autoarray.inversion.mesh.interpolator.delaunay`), which assign each vertex
208+
the sum of `triangle_area / 3` over the triangles touching it. The dual areas tile the convex hull of the
209+
mesh exactly and integrate the piecewise-linear reconstruction exactly; these Voronoi areas do neither, so
210+
`sum(reconstruction * areas_for_magnification)` is not the integral of the reconstructed source.
202211
"""
203212
areas = self.voronoi_areas
204213

test_autoarray/inversion/pixelization/interpolator/test_delaunay.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
import importlib.util
2+
13
import numpy as np
24
import pytest
35

46
import autoarray as aa
7+
from autoarray.inversion.mesh.interpolator.delaunay import (
8+
barycentric_dual_area_from,
9+
jax_delaunay,
10+
scipy_delaunay,
11+
)
12+
from autoarray.inversion.mesh.mesh_geometry.delaunay import voronoi_areas_numpy
513

614

715
def test__scipy_delaunay__simplices(grid_2d_sub_1_7x7):
@@ -57,3 +65,143 @@ def test__scipy_delaunay__split(grid_2d_sub_1_7x7):
5765
assert mesh_grid.delaunay.splitted_mappings[-1, :] == pytest.approx(
5866
[5, 1, 2], 1.0e-4
5967
)
68+
69+
70+
# ----------------------------------------------------------------------------
71+
# Barycentric dual areas (euclid-dr1-prep phase 8 audit).
72+
#
73+
# Two different "areas" exist for a Delaunay mesh on two different code paths:
74+
#
75+
# * `barycentric_dual_area_from` (here) -- sum of triangle_area / 3 over the
76+
# triangles touching each vertex. These tile the convex hull exactly and
77+
# integrate the piecewise-linear interpolant exactly.
78+
# * `MeshGeometryDelaunay.areas_for_magnification` -- scipy Voronoi cell
79+
# areas with only the *unbounded* cells zeroed. These do NOT tile the hull
80+
# and do NOT integrate the interpolant.
81+
#
82+
# The tests below pin both facts, including the size of the divergence.
83+
# ----------------------------------------------------------------------------
84+
85+
# jax is an `[optional]` extra and is absent on the NumPy-only matrix env, so
86+
# the in-graph parity test skips rather than fails there (same convention as
87+
# test_knn_barycentric.py).
88+
requires_jax = pytest.mark.skipif(
89+
importlib.util.find_spec("jax") is None,
90+
reason="requires jax (installed via the [optional] extras; absent on the NumPy-only matrix env)",
91+
)
92+
93+
94+
def test__barycentric_dual_area__single_triangle():
95+
96+
points = np.array([[0.0, 0.0], [0.0, 4.0], [3.0, 0.0]])
97+
simplices = np.array([[0, 1, 2]])
98+
99+
area = 0.5 * 3.0 * 4.0
100+
101+
dual = barycentric_dual_area_from(points, simplices, xp=np)
102+
103+
assert dual == pytest.approx(np.full(3, area / 3.0), 1.0e-10)
104+
assert dual.sum() == pytest.approx(area, 1.0e-10)
105+
106+
107+
def test__barycentric_dual_area__sums_to_convex_hull_area():
108+
"""
109+
The dual areas partition the convex hull exactly, so they sum to the hull
110+
area. The Voronoi areas behind `areas_for_magnification` do not -- on this
111+
configuration they overshoot the hull by ~29%, because the bounded boundary
112+
cells extend well outside the hull and are kept.
113+
"""
114+
import scipy.spatial
115+
116+
points = np.random.default_rng(1).random((40, 2))
117+
118+
simplices = scipy.spatial.Delaunay(points).simplices
119+
120+
dual = barycentric_dual_area_from(points, simplices, xp=np)
121+
122+
hull_area = scipy.spatial.ConvexHull(points).volume
123+
124+
assert dual.sum() == pytest.approx(hull_area, rel=1.0e-10)
125+
126+
voronoi = voronoi_areas_numpy(points)
127+
voronoi = np.where(voronoi == -1.0, 0.0, voronoi)
128+
129+
ratio = voronoi.sum() / hull_area
130+
131+
assert ratio == pytest.approx(1.2868, 1.0e-3), (
132+
f"Voronoi areas (unbounded cells zeroed) sum to {voronoi.sum()} against a "
133+
f"convex-hull area of {hull_area} (ratio {ratio}); the two area "
134+
f"definitions are not interchangeable."
135+
)
136+
assert voronoi.sum() != pytest.approx(hull_area, rel=1.0e-2)
137+
138+
139+
def test__linear_field_integral__dual_areas_exact():
140+
"""
141+
For a field that is linear over the mesh, the piecewise-linear interpolant
142+
is the field itself, so its integral over the hull is exactly
143+
`sum(f_i * dual_area_i)`. The Voronoi areas get the same integral wrong by
144+
tens of percent.
145+
"""
146+
import scipy.spatial
147+
148+
points = np.random.default_rng(1).random((40, 2))
149+
150+
simplices = scipy.spatial.Delaunay(points).simplices
151+
152+
f = 0.3 + 0.7 * points[:, 0] - 0.2 * points[:, 1]
153+
154+
p0 = points[simplices[:, 0]]
155+
p1 = points[simplices[:, 1]]
156+
p2 = points[simplices[:, 2]]
157+
158+
cross = (p1[:, 0] - p0[:, 0]) * (p2[:, 1] - p0[:, 1]) - (
159+
p1[:, 1] - p0[:, 1]
160+
) * (p2[:, 0] - p0[:, 0])
161+
162+
tri_area = 0.5 * np.abs(cross)
163+
164+
# exact: on each triangle a linear field integrates to (mean of its three
165+
# vertex values) x (triangle area)
166+
exact = (f[simplices].mean(axis=1) * tri_area).sum()
167+
168+
dual = barycentric_dual_area_from(points, simplices, xp=np)
169+
170+
assert (f * dual).sum() == pytest.approx(exact, rel=1.0e-10)
171+
172+
voronoi = voronoi_areas_numpy(points)
173+
voronoi = np.where(voronoi == -1.0, 0.0, voronoi)
174+
175+
voronoi_integral = (f * voronoi).sum()
176+
177+
assert abs(voronoi_integral - exact) / exact > 0.01, (
178+
f"Voronoi-weighted integral {voronoi_integral} vs exact {exact}"
179+
)
180+
181+
182+
@requires_jax
183+
def test__barycentric_dual_area__numpy_matches_jax_in_graph():
184+
"""
185+
`jax_delaunay` carries its own in-graph copy of the dual-area computation
186+
(a masked scatter-add over the padded simplices) rather than calling
187+
`barycentric_dual_area_from`. The two must agree.
188+
189+
The dual areas are not returned by either path; they enter it as the split
190+
point weights (`areas_factor * sqrt(areas)`), so the split points -- which
191+
both paths do return -- are the observable that pins them. Comparing them
192+
exercises the real library path rather than a hand-rolled copy of it.
193+
"""
194+
import jax.numpy as jnp
195+
196+
rng = np.random.default_rng(3)
197+
198+
points = rng.random((25, 2))
199+
query_points = rng.random((30, 2))
200+
201+
_, simplices_np, _, split_np, _ = scipy_delaunay(points, query_points, 0.5)
202+
_, simplices_jx, _, split_jx, _ = jax_delaunay(
203+
jnp.asarray(points), jnp.asarray(query_points), 0.5
204+
)
205+
206+
assert np.array_equal(np.asarray(simplices_np), np.asarray(simplices_jx))
207+
assert np.asarray(split_jx) == pytest.approx(np.asarray(split_np), abs=1.0e-10)

test_autoarray/inversion/pixelization/mesh_geometry/test_delaunay.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,100 @@ def test__voronoi_areas_via_delaunay_from(grid_2d_sub_1_7x7):
5555
assert voronoi_areas[1] == pytest.approx(1.39137102, 1.0e-4)
5656
assert voronoi_areas[3] == pytest.approx(29.836324, 1.0e-4)
5757
assert voronoi_areas[4] == pytest.approx(-1.0, 1.0e-4)
58+
59+
60+
def test__areas_for_magnification__uniform_lattice(grid_2d_sub_1_7x7):
61+
"""
62+
Known-answer test: on an n x n unit-spacing lattice every *interior* Voronoi
63+
cell is the unit square around its point (area exactly 1.0), and every point
64+
on the convex hull has an unbounded Voronoi region, which
65+
`areas_for_magnification` zeroes.
66+
67+
The total is therefore (n - 2) ** 2, not n ** 2 -- the summed Delaunay
68+
"source-plane area" is strictly smaller than the region the mesh covers.
69+
70+
(scipy's `Qbb Qc Qx Qm Q12 Pp` options handle the perfectly regular lattice
71+
without degenerate output, so no jitter of the interior points is needed.)
72+
"""
73+
n = 5
74+
75+
y, x = np.meshgrid(
76+
np.arange(n, dtype=float), np.arange(n, dtype=float), indexing="ij"
77+
)
78+
points = np.stack([y.ravel(), x.ravel()], axis=1)
79+
80+
mesh = aa.MeshGeometryDelaunay(
81+
mesh=aa.mesh.Delaunay(pixels=n * n),
82+
mesh_grid=aa.Grid2DIrregular(points),
83+
data_grid=grid_2d_sub_1_7x7.over_sampled,
84+
)
85+
86+
areas = mesh.areas_for_magnification.reshape(n, n)
87+
88+
interior = areas[1:-1, 1:-1]
89+
assert interior == pytest.approx(np.ones((n - 2, n - 2)), 1.0e-8)
90+
91+
assert areas[0, :] == pytest.approx(np.zeros(n), 1.0e-8)
92+
assert areas[-1, :] == pytest.approx(np.zeros(n), 1.0e-8)
93+
assert areas[:, 0] == pytest.approx(np.zeros(n), 1.0e-8)
94+
assert areas[:, -1] == pytest.approx(np.zeros(n), 1.0e-8)
95+
96+
assert areas.sum() == pytest.approx(float((n - 2) ** 2), 1.0e-8)
97+
98+
99+
def test__areas_for_magnification__bounded_boundary_cells_are_kept(grid_2d_sub_1_7x7):
100+
"""
101+
Pins the CURRENT semantics of `areas_for_magnification`: only cells whose
102+
Voronoi region is *unbounded* (the `-1` sentinel) are zeroed. A cell that is
103+
bounded but sits at the edge of the mesh is kept at full size, even when it
104+
is an order of magnitude larger than the interior cells -- here index 3 keeps
105+
an area of ~29.8 against index 1's ~1.4.
106+
107+
This is the bias candidate flagged by the euclid-dr1-prep phase 8 audit: a
108+
magnification denominator built from these areas is inflated by the huge
109+
bounded boundary cells. A later fix should flip this test *deliberately*,
110+
not silently.
111+
"""
112+
mesh_grid = aa.Grid2DIrregular(
113+
[[0.0, 0.0], [1.1, 0.6], [2.1, 0.1], [0.4, 1.1], [1.1, 7.1], [2.1, 1.1]]
114+
)
115+
116+
mesh = aa.MeshGeometryDelaunay(
117+
mesh=aa.mesh.Delaunay(pixels=6),
118+
mesh_grid=mesh_grid,
119+
data_grid=grid_2d_sub_1_7x7.over_sampled,
120+
)
121+
122+
areas = mesh.areas_for_magnification
123+
124+
assert areas[1] == pytest.approx(1.39137102, 1.0e-4)
125+
# bounded, huge, and kept:
126+
assert areas[3] == pytest.approx(29.836324, 1.0e-4)
127+
# unbounded (-1 in `voronoi_areas`), and therefore zeroed:
128+
assert areas[4] == pytest.approx(0.0, 1.0e-8)
129+
130+
131+
def test__areas_for_magnification__repeat_calls_agree(grid_2d_sub_1_7x7):
132+
"""
133+
`areas_for_magnification` mutates the array it gets from `voronoi_areas`
134+
(`areas[areas == -1] = 0.0`). `voronoi_areas` is an uncached property that
135+
recomputes each call, so the `-1` sentinel must never be written back into
136+
any state shared between calls.
137+
"""
138+
mesh_grid = aa.Grid2DIrregular(
139+
[[0.0, 0.0], [1.1, 0.6], [2.1, 0.1], [0.4, 1.1], [1.1, 7.1], [2.1, 1.1]]
140+
)
141+
142+
mesh = aa.MeshGeometryDelaunay(
143+
mesh=aa.mesh.Delaunay(pixels=6),
144+
mesh_grid=mesh_grid,
145+
data_grid=grid_2d_sub_1_7x7.over_sampled,
146+
)
147+
148+
first = mesh.areas_for_magnification
149+
second = mesh.areas_for_magnification
150+
151+
assert first == pytest.approx(second, 1.0e-8)
152+
153+
# the sentinel is still there afterwards -- nothing was written back
154+
assert mesh.voronoi_areas[4] == pytest.approx(-1.0, 1.0e-8)

0 commit comments

Comments
 (0)