Skip to content

Commit 68a69ae

Browse files
authored
Merge pull request #487 from PyAutoLabs/claude/numba-kernel-shift-axes-j5wo2p
fix: derive numba PSF kernel y/x shifts from the correct kernel axes
2 parents 9e47505 + cc61161 commit 68a69ae

2 files changed

Lines changed: 166 additions & 34 deletions

File tree

autoarray/inversion/inversion/imaging_numba/inversion_imaging_numba_util.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,13 @@ def psf_weighted_data_from(
4545
efficient calculation of the data vector.
4646
"""
4747

48-
kernel_shift_y = -(kernel_native.shape[1] // 2)
49-
kernel_shift_x = -(kernel_native.shape[0] // 2)
48+
# `kernel_native` is indexed [y, x], so the y half-width comes from
49+
# shape[0] and the x half-width from shape[1]. Deriving them from the
50+
# opposite axes is invisible for a square kernel but mis-centres the
51+
# gather along both axes for a non-square one -- and kernels are only
52+
# validated as odd per axis, never as square.
53+
kernel_shift_y = -(kernel_native.shape[0] // 2)
54+
kernel_shift_x = -(kernel_native.shape[1] // 2)
5055

5156
image_pixels = len(native_index_for_slim_index)
5257

@@ -310,8 +315,13 @@ def psf_precision_value_from(
310315

311316
curvature_value = 0.0
312317

313-
kernel_shift_y = -(kernel_native.shape[1] // 2)
314-
kernel_shift_x = -(kernel_native.shape[0] // 2)
318+
# `kernel_native` is indexed [y, x], so the y half-width comes from
319+
# shape[0] and the x half-width from shape[1]. Deriving them from the
320+
# opposite axes is invisible for a square kernel but mis-centres the
321+
# gather along both axes for a non-square one -- and kernels are only
322+
# validated as odd per axis, never as square.
323+
kernel_shift_y = -(kernel_native.shape[0] // 2)
324+
kernel_shift_x = -(kernel_native.shape[1] // 2)
315325

316326
ip_y_offset = ip0_y - ip1_y
317327
ip_x_offset = ip0_x - ip1_x

test_autoarray/inversion/inversion/imaging/test_inversion_imaging_util.py

Lines changed: 152 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -36,33 +36,50 @@ def test__psf_weighted_noise_imaging_from():
3636
)
3737

3838

39-
def test__psf_weighted_data_from__unmasked_pixels_on_array_edge():
39+
# Odd in each axis, as the kernel validation requires, but deliberately not
40+
# all square: `kernel_shape // 2` is only orientation-safe when the y half-width
41+
# is taken from axis 0 and the x half-width from axis 1. Values are asymmetric
42+
# so a transposed gather cannot hide behind a symmetric kernel.
43+
KERNELS_ODD = [
44+
np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 1.0], [2.0, 0.0, 1.0]]), # 3x3 square
45+
np.arange(1.0, 16.0).reshape(3, 5), # 3x5 wide
46+
np.arange(1.0, 16.0).reshape(5, 3), # 5x3 tall
47+
np.arange(1.0, 36.0).reshape(5, 7), # 5x7 wide
48+
]
49+
50+
KERNEL_IDS = ["3x3", "3x5", "5x3", "5x7"]
51+
52+
53+
@pytest.mark.parametrize("kernel", KERNELS_ODD, ids=KERNEL_IDS)
54+
def test__psf_weighted_data_from__unmasked_pixels_on_array_edge(kernel):
4055
"""
41-
Regression test: an unmasked pixel within `kernel_shape // 2` of the array
42-
edge drives the kernel off the weight map.
43-
44-
numba `@jit()` does not bounds-check array reads, so those positions
45-
silently returned uninitialized memory (values of order 1e299) rather than
46-
raising, poisoning `psf_weighted_data` and the data vector built from it.
47-
Because the values read depend on whatever the allocator left next to the
48-
weight map, the corruption was heap-state dependent: deterministic on the
49-
first call after a cold-cache compile, and intermittent in forked
50-
multiprocessing workers.
51-
52-
The zero-padded numpy implementation is the reference — kernel positions
53-
off the array contribute zero. Every other test in this module masks a
54-
one-pixel border, so none of them exercise this path.
56+
Regression test for two distinct defects in the numba gather, both of which
57+
the zero-padded numpy implementation is the reference for.
58+
59+
1. An unmasked pixel within `kernel_shape // 2` of the array edge drives the
60+
kernel off the weight map. numba `@jit()` does not bounds-check array
61+
reads, so those positions silently returned uninitialized memory (values
62+
of order 1e299) rather than raising, poisoning `psf_weighted_data` and
63+
the data vector built from it. Because the values read depend on whatever
64+
the allocator left next to the weight map, the corruption was heap-state
65+
dependent: deterministic on the first call after a cold-cache compile,
66+
and intermittent in forked multiprocessing workers.
67+
68+
2. The y and x kernel half-widths were derived from the *transposed* kernel
69+
axes. That is invisible for a square kernel -- the only shape the tests
70+
used to cover -- but mis-centres the gather along both axes for a
71+
non-square one. Kernels are validated as odd per axis, never as square,
72+
so a 3x5 PSF reaches this path and silently returns wrong values.
73+
74+
Every other test in this module masks a one-pixel border and uses a square
75+
kernel, so none of them exercise either path.
5576
"""
5677

5778
image = np.arange(1.0, 26.0).reshape(5, 5)
5879
noise_map = np.ones((5, 5))
5980

60-
kernel = np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 1.0], [2.0, 0.0, 1.0]])
61-
6281
# Every pixel unmasked, so the border pixels push the kernel off the array.
63-
native_index_for_slim_index = np.array(
64-
[[y, x] for y in range(5) for x in range(5)]
65-
)
82+
native_index_for_slim_index = np.array([[y, x] for y in range(5) for x in range(5)])
6683

6784
psf_weighted_data = aa.util.inversion_imaging_numba.psf_weighted_data_from(
6885
image_native=image,
@@ -81,6 +98,52 @@ def test__psf_weighted_data_from__unmasked_pixels_on_array_edge():
8198
assert psf_weighted_data == pytest.approx(psf_weighted_data_numpy, 1.0e-8)
8299

83100

101+
def test__psf_weighted_data_from__kernel_axes_are_not_transposed():
102+
"""
103+
A direct probe of which weight-map pixel the gather actually reads, that does
104+
not re-derive the implementation to do it.
105+
106+
The kernel is zero everywhere except its top-left corner, so a single kernel
107+
tap fires per image pixel, and the weight map encodes its own coordinates as
108+
`10 * (y + 1) + (x + 1)`. The returned value therefore *names* the pixel that
109+
was gathered.
110+
111+
For a (ky, kx) kernel the corner tap sits at offset `(-(ky // 2), -(kx // 2))`
112+
from the probe pixel. Transposing the half-widths swaps those offsets, so a
113+
wide kernel and its tall transpose must return different, individually
114+
predictable values -- which is exactly what a square kernel cannot show.
115+
"""
116+
117+
y_indexes, x_indexes = np.indices((7, 7))
118+
119+
# weight[y, x] == 10 * (y + 1) + (x + 1); noise of 1 leaves image == weight.
120+
image = 10.0 * (y_indexes + 1.0) + (x_indexes + 1.0)
121+
noise_map = np.ones((7, 7))
122+
123+
probe_y, probe_x = 3, 3
124+
native_index_for_slim_index = np.array([[probe_y, probe_x]])
125+
126+
def gathered_value(kernel_shape):
127+
kernel = np.zeros(kernel_shape)
128+
kernel[0, 0] = 1.0
129+
130+
return aa.util.inversion_imaging_numba.psf_weighted_data_from(
131+
image_native=image,
132+
noise_map_native=noise_map,
133+
kernel_native=kernel,
134+
native_index_for_slim_index=native_index_for_slim_index,
135+
)[0]
136+
137+
# 3x5: y half-width 1, x half-width 2 -> reads (3 - 1, 3 - 2) == (2, 1) == 32.
138+
assert gathered_value((3, 5)) == pytest.approx(32.0, 1.0e-8)
139+
140+
# 5x3: y half-width 2, x half-width 1 -> reads (3 - 2, 3 - 1) == (1, 2) == 23.
141+
assert gathered_value((5, 3)) == pytest.approx(23.0, 1.0e-8)
142+
143+
# Square: both half-widths 1 -> reads (2, 2) == 33, and is blind to the swap.
144+
assert gathered_value((3, 3)) == pytest.approx(33.0, 1.0e-8)
145+
146+
84147
def test__psf_weighted_data_from():
85148

86149
mask = aa.Mask2D(
@@ -166,12 +229,23 @@ def test__psf_precision_operator_sparse_from():
166229
assert psf_weighted_noise_lengths == pytest.approx(np.array([4, 3, 2, 1]), 1.0e-4)
167230

168231

169-
def test__psf_precision_operator_sparse_from__edge_pixels():
170-
# Regression test: every slim pixel sits at a corner of the 4x4 noise map,
171-
# so the kernel walk in psf_precision_value_from indexes off the array.
172-
# numba.jit() does not bounds-check, so without the explicit guard added
173-
# in the function those reads return uninitialized memory and produce
174-
# astronomically large or non-finite operator entries.
232+
@pytest.mark.parametrize("kernel", KERNELS_ODD, ids=KERNEL_IDS)
233+
def test__psf_precision_operator_sparse_from__edge_pixels(kernel):
234+
"""
235+
Regression test for the same two defects as the `psf_weighted_data_from`
236+
pair above, on the precision-operator path.
237+
238+
Every slim pixel sits at a corner of the 4x4 noise map, so the kernel walk
239+
in `psf_precision_value_from` indexes off the array; numba.jit() does not
240+
bounds-check, so without the explicit guard in the function those reads
241+
return uninitialized memory. And the kernel half-widths were derived from
242+
the transposed axes, which the non-square parametrisations below exercise
243+
and a square kernel cannot.
244+
245+
The two functions are fixed together deliberately: they must agree on kernel
246+
orientation, or the `psf_weighted_data` and `psf_precision_operator` paths
247+
would disagree with each other.
248+
"""
175249
noise_map = np.array(
176250
[
177251
[1.0, 1.0, 1.0, 1.0],
@@ -180,7 +254,6 @@ def test__psf_precision_operator_sparse_from__edge_pixels():
180254
[1.0, 1.0, 1.0, 1.0],
181255
]
182256
)
183-
kernel = np.array([[1.0, 1.0, 0.0], [1.0, 2.0, 1.0], [0.0, 1.0, 1.0]])
184257
native_index_for_slim_index = np.array([[0, 0], [0, 3], [3, 0], [3, 3]])
185258

186259
(
@@ -200,11 +273,16 @@ def test__psf_precision_operator_sparse_from__edge_pixels():
200273
# Independent reference: a pure-numpy bounds-checked re-implementation of
201274
# psf_precision_value_from. The numba version with the fix applied must
202275
# match this byte-for-byte.
276+
#
277+
# `kernel` is indexed [y, x], so the y half-width comes from its first axis
278+
# and the x half-width from its second. This reference used to derive them
279+
# the other way round -- mirroring the very bug it is meant to catch, which
280+
# a square kernel made invisible.
203281
def _reference_value(ip0_y, ip0_x, ip1_y, ip1_x):
204282
h, w = noise_map.shape
205283
kh, kw = kernel.shape
206-
kernel_shift_y = -(kw // 2)
207-
kernel_shift_x = -(kh // 2)
284+
kernel_shift_y = -(kh // 2)
285+
kernel_shift_x = -(kw // 2)
208286
ip_y_offset = ip0_y - ip1_y
209287
ip_x_offset = ip0_x - ip1_x
210288
if (
@@ -226,7 +304,7 @@ def _reference_value(ip0_y, ip0_x, ip1_y, ip1_x):
226304
k1_y = k0_y + ip_y_offset
227305
k1_x = k0_x + ip_x_offset
228306
if 0 <= k1_y < kh and 0 <= k1_x < kw:
229-
total += kernel[k0_y, k0_x] * kernel[k1_y, k1_x] / v ** 2
307+
total += kernel[k0_y, k0_x] * kernel[k1_y, k1_x] / v**2
230308
return total
231309

232310
n_pix = native_index_for_slim_index.shape[0]
@@ -252,6 +330,50 @@ def _reference_value(ip0_y, ip0_x, ip1_y, ip1_x):
252330
assert lengths == pytest.approx(np.array(expected_lengths), 1.0e-4)
253331

254332

333+
def test__psf_precision_value_from__kernel_axes_are_not_transposed():
334+
"""
335+
The `psf_weighted_data_from` orientation probe's twin, on the precision path,
336+
so both gathers are pinned to the same kernel orientation independently.
337+
338+
A single-tap kernel (non-zero only at its top-left corner) with `ip0 == ip1`
339+
reduces `psf_precision_value_from` to `1.0 / value_native[gathered]**2`, and
340+
the value map encodes its own coordinates as `10 * (y + 1) + (x + 1)`. The
341+
returned value therefore names the pixel that was gathered, without the test
342+
re-deriving the kernel walk.
343+
"""
344+
345+
y_indexes, x_indexes = np.indices((7, 7))
346+
347+
value_native = 10.0 * (y_indexes + 1.0) + (x_indexes + 1.0)
348+
349+
probe_y, probe_x = 3, 3
350+
351+
def gathered_value(kernel_shape):
352+
kernel = np.zeros(kernel_shape)
353+
kernel[0, 0] = 1.0
354+
355+
curvature_value = aa.util.inversion_imaging_numba.psf_precision_value_from(
356+
value_native=value_native,
357+
kernel_native=kernel,
358+
ip0_y=probe_y,
359+
ip0_x=probe_x,
360+
ip1_y=probe_y,
361+
ip1_x=probe_x,
362+
)
363+
364+
# curvature_value == 1.0 / value_native[gathered] ** 2.0
365+
return 1.0 / np.sqrt(curvature_value)
366+
367+
# 3x5: y half-width 1, x half-width 2 -> reads (3 - 1, 3 - 2) == (2, 1) == 32.
368+
assert gathered_value((3, 5)) == pytest.approx(32.0, 1.0e-8)
369+
370+
# 5x3: y half-width 2, x half-width 1 -> reads (3 - 2, 3 - 1) == (1, 2) == 23.
371+
assert gathered_value((5, 3)) == pytest.approx(23.0, 1.0e-8)
372+
373+
# Square: both half-widths 1 -> reads (2, 2) == 33, and is blind to the swap.
374+
assert gathered_value((3, 3)) == pytest.approx(33.0, 1.0e-8)
375+
376+
255377
def test__data_vector_via_blurred_mapping_matrix_from():
256378
blurred_mapping_matrix = np.array(
257379
[

0 commit comments

Comments
 (0)