diff --git a/autoarray/config/visualize/plots.yaml b/autoarray/config/visualize/plots.yaml index 7d10414f6..ed4da3b0c 100644 --- a/autoarray/config/visualize/plots.yaml +++ b/autoarray/config/visualize/plots.yaml @@ -21,7 +21,7 @@ fit: # Settings for plots of all fits (e.g fit_imaging: {} # Settings for plots of fits to imaging datasets (e.g. FitImagingPlotter). inversion: # Settings for plots of inversions (e.g. InversionPlotter). subplot_inversion: true # Plot subplot of all quantities in each inversion (e.g. reconstrucuted image, reconstruction)? - subplot_mappings: true # Plot subplot of the image-to-source pixels mappings of each pixelization? + subplot_mappings: false # Plot subplot of the image-to-source pixels mappings of each pixelization? data_subtracted: false # Plot individual plots of the data with the other inversion linear objects subtracted? reconstruction_noise_map: false # Plot image of the noise of every mesh-pixel reconstructed value? sub_pixels_per_image_pixels: false # Plot the number of sub pixels per masked data pixels? diff --git a/autoarray/geometry/geometry_2d.py b/autoarray/geometry/geometry_2d.py index fa5d4b24a..89c9fe293 100644 --- a/autoarray/geometry/geometry_2d.py +++ b/autoarray/geometry/geometry_2d.py @@ -132,10 +132,10 @@ def central_scaled_coordinates(self) -> Tuple[float, float]: def pixel_coordinates_2d_from( self, scaled_coordinates_2d: Tuple[float, float] - ) -> Tuple[float, float]: + ) -> Tuple[int, int]: """ - Convert a 2D (y,x) scaled coordinate to a 2D (y,x) pixel coordinate, which are returned as floats such that they - include the decimal offset from each pixel's top-left corner relative to the input scaled coordinate. + Convert a 2D (y,x) scaled coordinate to a 2D (y,x) pixel coordinate, which are returned as integers such that + they do not include the decimal offset from each pixel's top-left corner relative to the input scaled coordinate. The conversion is performed according to the 2D geometry on a uniform grid, where the pixel coordinate origin is at the top left corner, such that the pixel [0,0] corresponds to the highest (most positive) y scaled @@ -190,6 +190,41 @@ def scaled_coordinates_2d_from( origins=self.origin, ) + def pixel_coordinates_wcs_2d_from( + self, scaled_coordinates_2d: Tuple[float, float] + ) -> Tuple[float, float]: + """ + Convert a 2D (y,x) scaled coordinate to a 2D (y,x) WCS/FITS-style pixel coordinate. + + The returned pixel coordinates follow the standard WCS convention: + + - Coordinates are 1-based rather than 0-based, so that the centre of the top-left pixel is at (y, x) = (1.0, 1.0). + - Coordinates refer to pixel centres, not pixel corners. + - Values are continuous floats, so the fractional part encodes the sub-pixel offset from the pixel centre. + + This differs from integer pixel-index conversions (e.g. ``pixel_coordinates_2d_from``), which return 0-based + indices associated with pixel corners/top-left positions. + + The mapping from scaled coordinates to WCS pixel coordinates is defined by this geometry's ``origin``: scaled + coordinates are first shifted by the specified origin(s) before being converted using the pixel scale and + array shape. Changing ``origin`` therefore translates the returned WCS pixel coordinates by a constant offset. + + Parameters + ---------- + scaled_coordinates_2d + The 2D (y,x) coordinates in scaled units to be converted to WCS-style pixel coordinates. + + Returns + ------- + A 2D (y,x) WCS pixel coordinate, expressed as 1-based, pixel-centre, floating-point values. + """ + return geometry_util.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=scaled_coordinates_2d, + shape_native=self.shape_native, + pixel_scales=self.pixel_scales, + origins=self.origin, + ) + def scaled_coordinate_2d_to_scaled_at_pixel_centre_from( self, scaled_coordinate_2d: Tuple[float, float] ) -> Tuple[float, float]: diff --git a/autoarray/geometry/geometry_util.py b/autoarray/geometry/geometry_util.py index 54af5ca8b..cd2fd6533 100644 --- a/autoarray/geometry/geometry_util.py +++ b/autoarray/geometry/geometry_util.py @@ -357,6 +357,57 @@ def scaled_coordinates_2d_from( return (y_pixel, x_pixel) +def pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d: Tuple[float, float], + shape_native: Tuple[int, int], + pixel_scales: ty.PixelScales, + origins: Tuple[float, float] = (0.0, 0.0), +) -> Tuple[float, float]: + """ + Return FITS / WCS pixel coordinates (1-based, pixel-centre convention) as floats. + + This function returns continuous pixel coordinates suitable for Astropy WCS + transforms (e.g. ``wcs_pix2world`` with ``origin=1``). Pixel centres lie at + integer values; for an image of shape ``(ny, nx)`` the geometric centre is:: + + ((ny + 1) / 2, (nx + 1) / 2) + + e.g. ``(100, 100) -> (50.5, 50.5)``. + + Parameters + ---------- + scaled_coordinates_2d + The 2D (y, x) coordinates in scaled units which are converted to WCS + pixel coordinates. + shape_native + The (y, x) shape of the 2D array on which the scaled coordinates are + defined, used to determine the geometric centre in WCS pixel units. + pixel_scales + The (y, x) conversion factors from scaled units to pixel units. + origins + The (y, x) origin in scaled units about which the coordinates are + defined. The scaled coordinates are shifted by this origin before being + converted to WCS pixel coordinates. + + Returns + ------- + pixel_coordinates_wcs_2d + A 2D (y, x) WCS pixel coordinate in the 1-based, pixel-centre + convention, returned as floats. + """ + ny, nx = shape_native + + # Geometric centre in WCS pixel coordinates (1-based, pixel centres at integers) + ycen_wcs = (ny + 1) / 2.0 + xcen_wcs = (nx + 1) / 2.0 + + # Continuous WCS pixel coordinates (NO int-cast, NO +0.5 binning) + y_wcs = (-scaled_coordinates_2d[0] + origins[0]) / pixel_scales[0] + ycen_wcs + x_wcs = (scaled_coordinates_2d[1] - origins[1]) / pixel_scales[1] + xcen_wcs + + return (y_wcs, x_wcs) + + def transform_grid_2d_to_reference_frame( grid_2d: np.ndarray, centre: Tuple[float, float], angle: float, xp=np ) -> np.ndarray: diff --git a/test_autoarray/geometry/test_geometry_util.py b/test_autoarray/geometry/test_geometry_util.py index e65608bf1..0f5781702 100644 --- a/test_autoarray/geometry/test_geometry_util.py +++ b/test_autoarray/geometry/test_geometry_util.py @@ -972,6 +972,281 @@ def test__pixel_coordinates_2d_from(): assert scaled_coordinates == (0.0, 6.0) + + +def test__pixel_coordinates_wcs_2d_from(): + # ----------------------------- + # (2,2) grid: centre is (1.5, 1.5) in WCS pixels + # pixel_scales = (2,2) + # ----------------------------- + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(1.0, -1.0), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.0, 1.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(1.0, 1.0), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.0, 2.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-1.0, -1.0), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((2.0, 1.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-1.0, 1.0), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((2.0, 2.0)) + + # ----------------------------- + # (3,3) grid: centre is (2.0, 2.0) in WCS pixels + # pixel_scales = (3,3) + # ----------------------------- + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(3.0, -3.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((1.0, 1.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(3.0, 0.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((1.0, 2.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(3.0, 3.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((1.0, 3.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.0, -3.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((2.0, 1.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.0, 0.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((2.0, 2.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.0, 3.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((2.0, 3.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-3.0, -3.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((3.0, 1.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-3.0, 0.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((3.0, 2.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-3.0, 3.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((3.0, 3.0)) + + # ----------------------------------------- + # Inputs near corners (continuous coordinates) + # ----------------------------------------- + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(1.99, -1.99), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((0.505, 0.505)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(1.99, -0.01), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((0.505, 1.495)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.01, -1.99), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.495, 0.505)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.01, -0.01), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.495, 1.495)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(2.01, 0.01), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((0.495, 1.505)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(2.01, 1.99), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((0.495, 2.495)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.01, 0.01), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.495, 1.505)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.01, 1.99), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.495, 2.495)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-0.01, -1.99), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.505, 0.505)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-0.01, -0.01), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.505, 1.495)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-1.99, -1.99), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((2.495, 0.505)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-1.99, -0.01), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((2.495, 1.495)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-0.01, 0.01), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.505, 1.505)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-0.01, 1.99), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((1.505, 2.495)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-1.99, 0.01), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((2.495, 1.505)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(-1.99, 1.99), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + ) + assert pixel_coordinates == pytest.approx((2.495, 2.495)) + + # ----------------------------------------- + # Inputs are centres (origins shift), still continuous outputs + # ----------------------------------------- + + # Inputs are centres (origins shift), continuous outputs + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(2.0, 0.0), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + origins=(1.0, 1.0), + ) + assert pixel_coordinates == pytest.approx((1.0, 1.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(2.0, 2.0), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + origins=(1.0, 1.0), + ) + assert pixel_coordinates == pytest.approx((1.0, 2.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.0, 0.0), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + origins=(1.0, 1.0), + ) + assert pixel_coordinates == pytest.approx((2.0, 1.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(0.0, 2.0), + shape_native=(2, 2), + pixel_scales=(2.0, 2.0), + origins=(1.0, 1.0), + ) + assert pixel_coordinates == pytest.approx((2.0, 2.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(6.0, 0.0), + shape_native=(3, 3), + pixel_scales=(3.0, 3.0), + origins=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((1.0, 1.0)) + + pixel_coordinates = aa.util.geometry.pixel_coordinates_wcs_2d_from( + scaled_coordinates_2d=(6.0, 3.0), + shape_native=(4, 4), + pixel_scales=(3.0, 3.0), + origins=(3.0, 3.0), + ) + assert pixel_coordinates == pytest.approx((1.5, 2.5)) + + def test__transform_2d_grid_to_reference_frame(): grid_2d = np.array([[0.0, 1.0], [1.0, 1.0], [1.0, 0.0]])