@@ -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+
84147def 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+
255377def test__data_vector_via_blurred_mapping_matrix_from ():
256378 blurred_mapping_matrix = np .array (
257379 [
0 commit comments