@@ -203,76 +203,122 @@ def sub_border_slim_from(mask, sub_size):
203203 ).astype ("int" )
204204
205205
206- def relocated_grid_from ( grid , border_grid , xp = np ):
206+ def ellipse_params_via_border_pca_from ( border_grid , xp = np , eps = 1e-12 ):
207207 """
208- Relocate the coordinates of a grid to its border if they are outside the border, where the border is
209- defined as all pixels at the edge of the grid's mask (see *mask._border_1d_indexes*) .
208+ Estimate origin, semi-axes (a,b), and rotation phi from border points using PCA.
209+ Works well for circle/ellipse-like borders .
210210
211- This is performed as follows:
211+ Parameters
212+ ----------
213+ border_grid : (M,2)
214+ Border coordinates in (y, x) order.
215+ xp : module
216+ numpy-like module (np, jnp, cupy, etc.).
217+ eps : float
218+ Numerical safety epsilon.
219+
220+ Returns
221+ -------
222+ origin : (2,)
223+ Estimated center (y0, x0).
224+ a : float
225+ Semi-major axis.
226+ b : float
227+ Semi-minor axis.
228+ phi : float
229+ Rotation angle in radians.
230+ """
231+
232+ origin = xp .mean (border_grid , axis = 0 )
233+
234+ dy = border_grid [:, 0 ] - origin [0 ]
235+ dx = border_grid [:, 1 ] - origin [1 ]
236+
237+ # Build data matrix in (x, y) order for PCA
238+ X = xp .stack ([dx , dy ], axis = 1 ) # (M,2)
239+
240+ # Covariance matrix
241+ denom = xp .maximum (X .shape [0 ] - 1 , 1 )
242+ C = (X .T @ X ) / denom # (2,2)
243+
244+ # Eigen-decomposition (ascending eigenvalues)
245+ evals , evecs = xp .linalg .eigh (C )
246+
247+ # Major axis = eigenvector with largest eigenvalue
248+ v_major = evecs [:, - 1 ] # (2,) in (x,y)
249+
250+ phi = xp .arctan2 (v_major [1 ], v_major [0 ])
251+
252+ # Rotate border points into ellipse-aligned frame
253+ c = xp .cos (phi )
254+ s = xp .sin (phi )
255+
256+ xprime = c * dx + s * dy
257+ yprime = - s * dx + c * dy
258+
259+ # Semi-axes from maximal extent
260+ a = xp .max (xp .abs (xprime )) + eps
261+ b = xp .max (xp .abs (yprime )) + eps
212262
213- 1: Use the mean value of the grid's y and x coordinates to determine the origin of the grid.
214- 2: Compute the radial distance of every grid coordinate from the origin.
215- 3: For every coordinate, find its nearest pixel in the border.
216- 4: Determine if it is outside the border, by comparing its radial distance from the origin to its paired
217- border pixel's radial distance.
218- 5: If its radial distance is larger, use the ratio of radial distances to move the coordinate to the
219- border (if its inside the border, do nothing).
263+ return origin , a , b , phi
220264
221- The method can be used on uniform or irregular grids, however for irregular grids the border of the
222- 'image-plane' mask is used to define border pixels.
265+
266+ def relocated_grid_via_ellipse_border_from (grid , origin , a , b , phi , xp = np , eps = 1e-12 ):
267+ """
268+ Rotated ellipse centered at origin with semi-axes a (major, x'), b (minor, y'),
269+ rotated by phi radians (counterclockwise).
223270
224271 Parameters
225272 ----------
226- grid
227- The grid (uniform or irregular) whose pixels are to be relocated to the border edge if outside it.
228- border_grid : Grid2D
229- The grid of border (y,x) coordinates.
273+ grid : (N,2)
274+ Coordinates in (y, x) order.
275+ origin : (2,)
276+ Ellipse center (y0, x0).
277+ a, b : float
278+ Semi-major and semi-minor axes.
279+ phi : float
280+ Rotation angle in radians.
281+ xp : module
282+ numpy-like module (np, jnp, cupy, etc.).
283+ eps : float
284+ Numerical safety epsilon.
230285 """
231286
232- # Compute origin (center) of the border grid
233- border_origin = xp .mean (border_grid , axis = 0 )
287+ # shift to origin
288+ dy = grid [:, 0 ] - origin [0 ]
289+ dx = grid [:, 1 ] - origin [1 ]
234290
235- # Radii from origin
236- grid_radii = xp .linalg .norm (grid - border_origin , axis = 1 ) # (N,)
237- border_radii = xp .linalg .norm (border_grid - border_origin , axis = 1 ) # (M,)
238- border_min_radius = xp .min (border_radii )
291+ c = xp .cos (phi )
292+ s = xp .sin (phi )
239293
240- # Determine which points are outside
241- outside_mask = grid_radii > border_min_radius # (N,)
294+ # rotate into ellipse-aligned frame
295+ xprime = c * dx + s * dy
296+ yprime = - s * dx + c * dy
242297
243- # To compute nearest border point for each grid point, we must do it for all and then mask later
244- # Compute all distances: (N, M)
245- diffs = grid [:, None , :] - border_grid [None , :, :] # (N, M, 2)
246- dists_squared = xp .sum (diffs ** 2 , axis = 2 ) # (N, M)
247- closest_indices = xp .argmin (dists_squared , axis = 1 ) # (N,)
298+ # ellipse radius in normalized coords
299+ q = (xprime / a ) ** 2 + (yprime / b ) ** 2
248300
249- # Get border radius for closest border point to each grid point
250- matched_border_radii = border_radii [ closest_indices ] # (N, )
301+ outside = q > 1.0
302+ scale = 1.0 / xp . sqrt ( xp . maximum ( q , 1.0 + eps ) )
251303
252- # Ratio of border to grid radius
253- move_factors = matched_border_radii / grid_radii # (N,)
304+ # scale back to boundary
305+ xprime2 = xprime * scale
306+ yprime2 = yprime * scale
254307
255- # Only move if:
256- # - the point is outside the border
257- # - the matched border point is closer to the origin (i.e. move_factor < 1)
258- apply_move = xp .logical_and (outside_mask , move_factors < 1.0 ) # (N,)
308+ # rotate back to original frame
309+ dx2 = c * xprime2 - s * yprime2
310+ dy2 = s * xprime2 + c * yprime2
259311
260- # Compute moved positions (for all points, but will select with mask)
261- direction_vectors = grid - border_origin # (N, 2)
262- moved_grid = move_factors [:, None ] * direction_vectors + border_origin # (N, 2)
312+ moved = xp .stack ([origin [0 ] + dy2 , origin [1 ] + dx2 ], axis = 1 )
263313
264- # Select which grid points to move
265- relocated_grid = xp .where (apply_move [:, None ], moved_grid , grid ) # (N, 2)
266-
267- return relocated_grid
314+ return xp .where (outside [:, None ], moved , grid )
268315
269316
270317class BorderRelocator :
271318 def __init__ (
272319 self ,
273320 mask : Mask2D ,
274321 sub_size : Union [int , Array2D ],
275- use_sparse_operator : bool = False ,
276322 ):
277323 """
278324 Relocates source plane coordinates that trace outside the mask’s border in the source-plane back onto the
@@ -330,8 +376,6 @@ def __init__(
330376
331377 self .sub_border_grid = sub_grid [self .sub_border_slim ]
332378
333- self .use_sparse_operator = use_sparse_operator
334-
335379 def relocated_grid_from (self , grid : Grid2D , xp = np ) -> Grid2D :
336380 """
337381 Relocate the coordinates of a grid to the border of this grid if they are outside the border, where the
@@ -359,32 +403,17 @@ def relocated_grid_from(self, grid: Grid2D, xp=np) -> Grid2D:
359403 if len (self .sub_border_grid ) == 0 :
360404 return grid
361405
362- if self .use_sparse_operator is False or xp .__name__ .startswith ("jax" ):
363-
364- values = relocated_grid_from (
365- grid = grid .array , border_grid = grid .array [self .border_slim ], xp = xp
366- )
367-
368- over_sampled = relocated_grid_from (
369- grid = grid .over_sampled .array ,
370- border_grid = grid .over_sampled .array [self .sub_border_slim ],
371- xp = xp ,
372- )
373-
374- else :
375-
376- from autoarray .inversion .inversion .imaging_numba import (
377- inversion_imaging_numba_util ,
378- )
406+ origin , a , b , phi = ellipse_params_via_border_pca_from (
407+ grid .array [self .border_slim ], xp = xp
408+ )
379409
380- values = inversion_imaging_numba_util . relocated_grid_via_jit_from (
381- grid = grid .array , border_grid = grid [ self . border_slim ]
382- )
410+ values = relocated_grid_via_ellipse_border_from (
411+ grid = grid .array , origin = origin , a = a , b = b , phi = phi , xp = xp
412+ )
383413
384- over_sampled = inversion_imaging_numba_util .relocated_grid_via_jit_from (
385- grid = grid .over_sampled .array ,
386- border_grid = grid .over_sampled [self .sub_border_slim ],
387- )
414+ over_sampled = relocated_grid_via_ellipse_border_from (
415+ grid = grid .over_sampled .array , origin = origin , a = a , b = b , phi = phi , xp = xp
416+ )
388417
389418 return Grid2D (
390419 values = values ,
@@ -411,24 +440,13 @@ def relocated_mesh_grid_from(
411440 if len (self .sub_border_grid ) == 0 :
412441 return mesh_grid
413442
414- if self .use_sparse_operator is False or xp .__name__ .startswith ("jax" ):
415-
416- relocated_grid = relocated_grid_from (
417- grid = mesh_grid .array ,
418- border_grid = grid [self .sub_border_slim ],
419- xp = xp ,
420- )
421-
422- else :
423-
424- from autoarray .inversion .inversion .imaging import (
425- inversion_imaging_numba_util ,
426- )
443+ origin , a , b , phi = ellipse_params_via_border_pca_from (
444+ grid .array [self .border_slim ], xp = xp
445+ )
427446
428- relocated_grid = inversion_imaging_numba_util .relocated_grid_via_jit_from (
429- grid = mesh_grid .array ,
430- border_grid = grid [self .sub_border_slim ],
431- )
447+ relocated_grid = relocated_grid_via_ellipse_border_from (
448+ grid = mesh_grid .array , origin = origin , a = a , b = b , phi = phi , xp = xp
449+ )
432450
433451 return Grid2DIrregular (
434452 values = relocated_grid ,
0 commit comments