diff --git a/autoarray/inversion/inversion/settings.py b/autoarray/inversion/inversion/settings.py index ad0477b9..7b78891c 100644 --- a/autoarray/inversion/inversion/settings.py +++ b/autoarray/inversion/inversion/settings.py @@ -21,8 +21,10 @@ def __init__( use_w_tilde_numpy: bool = False, use_source_loop: bool = False, use_linear_operators: bool = False, - image_mesh_min_mesh_pixels_per_pixel = None, - image_mesh_min_mesh_number : int = 5, + image_mesh_min_mesh_pixels_per_pixel=None, + image_mesh_min_mesh_number: int = 5, + image_mesh_adapt_background_percent_threshold: float = None, + image_mesh_adapt_background_percent_check: float = 0.1, tolerance: float = 1e-8, maxiter: int = 250, ): @@ -55,6 +57,19 @@ def __init__( use_linear_operators For an interferometer inversion, whether to use the linear operator solution to solve the linear system or not (this input does nothing for dataset data). + image_mesh_min_mesh_pixels_per_pixel + If not None, the image-mesh must place this many mesh pixels per image pixels in the N highest weighted + regions of the adapt data, or an `InversionException` is raised. This can be used to force the image-mesh + to cluster large numbers of source pixels to the adapt-datas brightest regions. + image_mesh_min_mesh_number + The value N given above in the docstring for `image_mesh_min_mesh_pixels_per_pixel`, indicating how many + image pixels are checked for having a threshold number of mesh pixels. + image_mesh_adapt_background_percent_threshold + If not None, the image-mesh must place this percentage of mesh-pixels in the background regions of the + `adapt_data`, where the background is the `image_mesh_adapt_background_percent_check` masked data pixels + with the lowest values. + image_mesh_adapt_background_percent_check + The percentage of masked data pixels which are checked for the background criteria. tolerance For an interferometer inversion using the linear operators method, sets the tolerance of the solver (this input does nothing for dataset data and other interferometer methods). @@ -76,6 +91,13 @@ def __init__( ) self.image_mesh_min_mesh_pixels_per_pixel = image_mesh_min_mesh_pixels_per_pixel self.image_mesh_min_mesh_number = image_mesh_min_mesh_number + self.image_mesh_adapt_background_percent_threshold = ( + image_mesh_adapt_background_percent_threshold + ) + self.image_mesh_adapt_background_percent_check = ( + image_mesh_adapt_background_percent_check + ) + self.tolerance = tolerance self.maxiter = maxiter self.use_w_tilde_numpy = use_w_tilde_numpy diff --git a/autoarray/inversion/mock/mock_image_mesh.py b/autoarray/inversion/mock/mock_image_mesh.py index c834a8d0..6c263bf6 100644 --- a/autoarray/inversion/mock/mock_image_mesh.py +++ b/autoarray/inversion/mock/mock_image_mesh.py @@ -14,7 +14,7 @@ def __init__(self, image_plane_mesh_grid=None): self.image_plane_mesh_grid = image_plane_mesh_grid def image_plane_mesh_grid_from( - self, grid: Grid2D, adapt_data: Optional[np.ndarray], settings = None + self, grid: Grid2D, adapt_data: Optional[np.ndarray], settings=None ) -> Grid2DIrregular: if adapt_data is not None and self.image_plane_mesh_grid is not None: return adapt_data * self.image_plane_mesh_grid diff --git a/autoarray/inversion/pixelization/image_mesh/abstract.py b/autoarray/inversion/pixelization/image_mesh/abstract.py index 19e70dec..d09cedaf 100644 --- a/autoarray/inversion/pixelization/image_mesh/abstract.py +++ b/autoarray/inversion/pixelization/image_mesh/abstract.py @@ -1,5 +1,6 @@ from typing import Optional +import copy import numpy as np from autoarray.structures.arrays.uniform_2d import Array2D @@ -10,6 +11,7 @@ from autoarray import exc + class AbstractImageMesh: def __init__(self): """ @@ -49,7 +51,7 @@ def weight_map_from(self, adapt_data: np.ndarray): return weight_map def image_plane_mesh_grid_from( - self, grid: Grid2D, adapt_data: Optional[np.ndarray] = None, settings = None + self, grid: Grid2D, adapt_data: Optional[np.ndarray] = None, settings=None ) -> Grid2DIrregular: raise NotImplementedError @@ -102,7 +104,7 @@ def check_mesh_pixels_per_image_pixels(self, grid, mesh_grid, settings): the number of mesh pixels in the 5 data pixels with the most data pixels is computed. 3) Compare the lowest value above to the value `settings.image_mesh_min_mesh_pixels_per_pixel`. If the value is below this value, raise an `InversionException`. - + Therefore, by settings `settings.image_mesh_min_mesh_pixels_per_pixel` to a value above 1 the code is forced to adapt the image mesh enough to put many mesh pixels in the brightest image pixels. @@ -121,16 +123,80 @@ def check_mesh_pixels_per_image_pixels(self, grid, mesh_grid, settings): if settings is not None: if settings.image_mesh_min_mesh_pixels_per_pixel is not None: - mesh_pixels_per_image_pixels = self.mesh_pixels_per_image_pixels_from( - grid=grid, - mesh_grid=mesh_grid + grid=grid, mesh_grid=mesh_grid ) - indices_of_highest_values = np.argsort(mesh_pixels_per_image_pixels)[-settings.image_mesh_min_mesh_number:] - lowest_mesh_pixels = np.min(mesh_pixels_per_image_pixels[indices_of_highest_values]) + indices_of_highest_values = np.argsort(mesh_pixels_per_image_pixels)[ + -settings.image_mesh_min_mesh_number : + ] + lowest_mesh_pixels = np.min( + mesh_pixels_per_image_pixels[indices_of_highest_values] + ) if lowest_mesh_pixels < settings.image_mesh_min_mesh_pixels_per_pixel: raise exc.InversionException() - return mesh_grid \ No newline at end of file + return mesh_grid + + def check_adapt_background_pixels(self, grid, mesh_grid, adapt_data, settings): + """ + Checks the number of mesh pixels in the background of the image-mesh and raises an `InversionException` if + there are fewer mesh pixels in the background than the input settings. + + This allows a user to force a model-fit to use image-mesh's which cluster a minimum number of mesh pixels to + the faintest regions of the image data (E.g. the lowest weighted regions). This prevents too few image-mesh + pixels being allocated to the background of the data. + + The check works as follows: + + 1) Find all pixels in the background of the `adapt_data`, which are N pixels with the lowest values, where N is + a percentage given by `settings.image_mesh_adapt_background_percent_check`. If N is 50%, then the half of + pixels in `adapt_data` with the lowest values will be checked. + 2) Sum the total number of mesh pixels in these background pixels, thereby estimating the number of mesh pixels + assigned to background pixels. + 3) Compare this value to the total number of mesh pixels multiplied + by `settings.image_mesh_adapt_background_percent_threshold` and raise an `InversionException` if the number + of mesh pixels is below this value, meaning the background did not have sufficient mesh pixels in it. + + Therefore, by setting `settings.image_mesh_adapt_background_percent_threshold` the code is forced + to adapt the image mesh in a way that places many mesh pixels in the background regions. + + Parameters + ---------- + grid + The masked (y,x) grid of the data coordinates, corresponding to the mask applied to the data. The number of + mesh pixels mapped inside each of this grid's image-pixels is returned. + mesh_grid + The image mesh-grid computed by the class which adapts to the data's mask. The number of image mesh pixels + that fall within each of the data's mask pixels is returned. + adapt_data + A image which represents one or more components in the masked 2D data in the image-plane. + settings + The inversion settings, which have the criteria dictating if the image-mesh has clustered enough or if + an exception is raised. + """ + if settings is not None: + if settings.image_mesh_adapt_background_percent_threshold is not None: + pixels = mesh_grid.shape[0] + + pixels_in_background = int( + grid.shape[0] * settings.image_mesh_adapt_background_percent_check + ) + + indices_of_lowest_values = np.argsort(adapt_data)[:pixels_in_background] + mask_background = np.zeros_like(adapt_data, dtype=bool) + mask_background[indices_of_lowest_values] = True + + mesh_pixels_per_image_pixels = self.mesh_pixels_per_image_pixels_from( + grid=grid, mesh_grid=mesh_grid + ) + + mesh_pixels_in_background = sum( + mesh_pixels_per_image_pixels[mask_background] + ) + + if mesh_pixels_in_background < ( + pixels * settings.image_mesh_adapt_background_percent_threshold + ): + raise exc.InversionException() diff --git a/autoarray/inversion/pixelization/image_mesh/abstract_weighted.py b/autoarray/inversion/pixelization/image_mesh/abstract_weighted.py index 46ab1b9d..ece8c25d 100644 --- a/autoarray/inversion/pixelization/image_mesh/abstract_weighted.py +++ b/autoarray/inversion/pixelization/image_mesh/abstract_weighted.py @@ -70,6 +70,6 @@ def weight_map_from(self, adapt_data: np.ndarray): return weight_map def image_plane_mesh_grid_from( - self, grid: Grid2D, adapt_data: Optional[np.ndarray] = None, settings = None + self, grid: Grid2D, adapt_data: Optional[np.ndarray] = None, settings=None ) -> Grid2DIrregular: raise NotImplementedError diff --git a/autoarray/inversion/pixelization/image_mesh/hilbert.py b/autoarray/inversion/pixelization/image_mesh/hilbert.py index 39b88b20..9e43fbb2 100644 --- a/autoarray/inversion/pixelization/image_mesh/hilbert.py +++ b/autoarray/inversion/pixelization/image_mesh/hilbert.py @@ -246,7 +246,10 @@ def __init__( ) def image_plane_mesh_grid_from( - self, grid: Grid2D, adapt_data: Optional[np.ndarray], settings : SettingsInversion = None + self, + grid: Grid2D, + adapt_data: Optional[np.ndarray], + settings: SettingsInversion = None, ) -> Grid2DIrregular: """ Returns an image mesh by running the Hilbert curve on the weight map. @@ -300,9 +303,11 @@ def image_plane_mesh_grid_from( mesh_grid = Grid2DIrregular(values=np.stack((drawn_y, drawn_x), axis=-1)) self.check_mesh_pixels_per_image_pixels( - grid=grid, - mesh_grid=mesh_grid, - settings=settings + grid=grid, mesh_grid=mesh_grid, settings=settings + ) + + self.check_adapt_background_pixels( + grid=grid, mesh_grid=mesh_grid, adapt_data=adapt_data, settings=settings ) return mesh_grid diff --git a/autoarray/inversion/pixelization/image_mesh/hilbert_balanced.py b/autoarray/inversion/pixelization/image_mesh/hilbert_balanced.py index e96deffd..49740349 100644 --- a/autoarray/inversion/pixelization/image_mesh/hilbert_balanced.py +++ b/autoarray/inversion/pixelization/image_mesh/hilbert_balanced.py @@ -71,7 +71,7 @@ def __init__( self.ratio = ratio def image_plane_mesh_grid_from( - self, grid: Grid2D, adapt_data: Optional[np.ndarray], settings = None + self, grid: Grid2D, adapt_data: Optional[np.ndarray], settings=None ) -> Grid2DIrregular: """ Returns an image mesh by running the balanced Hilbert curve on the weight map. diff --git a/autoarray/inversion/pixelization/image_mesh/kmeans.py b/autoarray/inversion/pixelization/image_mesh/kmeans.py index 4f051d03..8a7a25c8 100644 --- a/autoarray/inversion/pixelization/image_mesh/kmeans.py +++ b/autoarray/inversion/pixelization/image_mesh/kmeans.py @@ -51,7 +51,7 @@ def __init__( ) def image_plane_mesh_grid_from( - self, grid: Grid2D, adapt_data: Optional[np.ndarray], settings = None + self, grid: Grid2D, adapt_data: Optional[np.ndarray], settings=None ) -> Grid2DIrregular: """ Returns an image mesh by running a KMeans clustering algorithm on the weight map. diff --git a/autoarray/inversion/pixelization/image_mesh/overlay.py b/autoarray/inversion/pixelization/image_mesh/overlay.py index e616cf90..2f359163 100644 --- a/autoarray/inversion/pixelization/image_mesh/overlay.py +++ b/autoarray/inversion/pixelization/image_mesh/overlay.py @@ -185,7 +185,7 @@ def __init__(self, shape=(3, 3)): self.shape = (int(shape[0]), int(shape[1])) def image_plane_mesh_grid_from( - self, grid: Grid2D, adapt_data: Optional[np.ndarray] = None, settings = None + self, grid: Grid2D, adapt_data: Optional[np.ndarray] = None, settings=None ) -> Grid2DIrregular: """ Returns an image-mesh by overlaying a uniform grid of (y,x) coordinates over the masked image that the diff --git a/test_autoarray/inversion/pixelization/image_mesh/test_abstract.py b/test_autoarray/inversion/pixelization/image_mesh/test_abstract.py index 15ee1e41..d44c90e4 100644 --- a/test_autoarray/inversion/pixelization/image_mesh/test_abstract.py +++ b/test_autoarray/inversion/pixelization/image_mesh/test_abstract.py @@ -4,7 +4,8 @@ import autoarray as aa -def test__mesh_pixels_per_image_pixels_from(): +@pytest.fixture(name="grid") +def make_grid(): mask = aa.Mask2D.circular( shape_native=(3, 3), radius=2.0, @@ -12,14 +13,29 @@ def test__mesh_pixels_per_image_pixels_from(): sub_size=1, ) - grid = aa.Grid2D.from_mask(mask=mask) + return aa.Grid2D.from_mask(mask=mask) - mesh_grid = aa.Grid2DIrregular( - values=[(0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (0.0, 1.0), (0.0, 1.0), (-1.0, -1.0)] + +@pytest.fixture(name="mesh_grid") +def make_mesh_grid(): + return aa.Grid2DIrregular( + values=[ + (0.0, 0.0), + (0.0, 0.0), + (0.0, 0.0), + (0.0, 1.0), + (0.0, 1.0), + (-1.0, -1.0), + ] ) - image_mesh = aa.image_mesh.Hilbert(pixels=8) +@pytest.fixture(name="image_mesh") +def make_image_mesh(): + return aa.image_mesh.Hilbert(pixels=8) + + +def test__mesh_pixels_per_image_pixels_from(grid, mesh_grid, image_mesh): mesh_pixels_per_image_pixels = image_mesh.mesh_pixels_per_image_pixels_from( grid=grid, mesh_grid=mesh_grid ) @@ -28,45 +44,66 @@ def test__mesh_pixels_per_image_pixels_from(): np.array([[0, 0, 0], [0, 3, 2], [1, 0, 0]]), 1.0e-4 ) -def test__check_mesh_pixels_per_image_pixels(): - - mask = aa.Mask2D.circular( - shape_native=(3, 3), - radius=2.0, - pixel_scales=1.0, - sub_size=1, - ) - - grid = aa.Grid2D.from_mask(mask=mask) - - mesh_grid = aa.Grid2DIrregular( - values=[(0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (0.0, 1.0), (0.0, 1.0), (-1.0, -1.0)] - ) - - image_mesh = aa.image_mesh.Hilbert(pixels=8) +def test__check_mesh_pixels_per_image_pixels(grid, mesh_grid, image_mesh): image_mesh.check_mesh_pixels_per_image_pixels( - grid=grid, - mesh_grid=mesh_grid, - settings=None + grid=grid, mesh_grid=mesh_grid, settings=None ) image_mesh.check_mesh_pixels_per_image_pixels( grid=grid, mesh_grid=mesh_grid, - settings=aa.SettingsInversion(image_mesh_min_mesh_pixels_per_pixel=3, image_mesh_min_mesh_number=1) + settings=aa.SettingsInversion( + image_mesh_min_mesh_pixels_per_pixel=3, image_mesh_min_mesh_number=1 + ), ) with pytest.raises(aa.exc.InversionException): image_mesh.check_mesh_pixels_per_image_pixels( grid=grid, mesh_grid=mesh_grid, - settings=aa.SettingsInversion(image_mesh_min_mesh_pixels_per_pixel=5, image_mesh_min_mesh_number=1) + settings=aa.SettingsInversion( + image_mesh_min_mesh_pixels_per_pixel=5, image_mesh_min_mesh_number=1 + ), ) with pytest.raises(aa.exc.InversionException): image_mesh.check_mesh_pixels_per_image_pixels( grid=grid, mesh_grid=mesh_grid, - settings=aa.SettingsInversion(image_mesh_min_mesh_pixels_per_pixel=3, image_mesh_min_mesh_number=2) - ) \ No newline at end of file + settings=aa.SettingsInversion( + image_mesh_min_mesh_pixels_per_pixel=3, image_mesh_min_mesh_number=2 + ), + ) + + +def test__check_adapt_background_pixels(grid, mesh_grid, image_mesh): + adapt_data = aa.Array2D.no_mask( + values=[[0.05, 0.05, 0.05], [0.05, 0.6, 0.05], [0.05, 0.05, 0.05]], + pixel_scales=(1.0, 1.0), + ) + + image_mesh.check_adapt_background_pixels( + grid=grid, mesh_grid=mesh_grid, adapt_data=adapt_data, settings=None + ) + + image_mesh.check_adapt_background_pixels( + grid=grid, + mesh_grid=mesh_grid, + adapt_data=adapt_data, + settings=aa.SettingsInversion( + image_mesh_adapt_background_percent_threshold=0.05, + image_mesh_adapt_background_percent_check=0.9, + ), + ) + + with pytest.raises(aa.exc.InversionException): + image_mesh.check_adapt_background_pixels( + grid=grid, + mesh_grid=mesh_grid, + adapt_data=adapt_data, + settings=aa.SettingsInversion( + image_mesh_adapt_background_percent_threshold=0.8, + image_mesh_adapt_background_percent_check=0.5, + ), + ) diff --git a/test_autoarray/inversion/pixelization/image_mesh/test_hilbert.py b/test_autoarray/inversion/pixelization/image_mesh/test_hilbert.py index 8b5f44ac..3286f4a2 100644 --- a/test_autoarray/inversion/pixelization/image_mesh/test_hilbert.py +++ b/test_autoarray/inversion/pixelization/image_mesh/test_hilbert.py @@ -25,4 +25,3 @@ def test__image_plane_mesh_grid_from(): [-1.02590674, -1.70984456], 1.0e-4, ) -