From 93961c689655c555324fc796c90cd13165552266 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 7 Oct 2026 09:41:41 +0100 Subject: [PATCH] fix: make plot_array overlays respect imshow_origin "lower" (#565) With imshow_origin: lower the raster was reflected about the extent's y midpoint but every vector overlay (mask edge, border, origin marker, grid, mesh grid, positions, lines, regions, quiver) stayed in unreflected data coordinates, so critical curves and caustics silently slid off their arcs. Add private helpers _overlay_yx_for_origin / _vector_yx_for_origin in plot/utils.py that reflect overlay y (and negate quiver dy) under "lower", following the pattern _apply_contours already used, and route every plot_array overlay through them. The uniform rectangular mesh path of plot_inversion_reconstruction had the same defect; _plot_rectangular now returns its raster extent so its overlays are reflected too. "upper" is unchanged. Patches are documented as not origin-aware. Add origin-agnostic regression tests (positions, lines, inversion grid) parametrised over both origins, red on unfixed main for every "lower" case. Document on the config key that imshow_origin is presentation only. Reported by @ClarkGuilty in PyAutoLabs Discussion #14, including the reproducer and the regression-test design. Co-Authored-By: Claude Fable 5.1 --- autoarray/config/visualize/general.yaml | 2 +- autoarray/plot/array.py | 42 +++++++ autoarray/plot/inversion.py | 50 ++++++++- autoarray/plot/utils.py | 54 +++++++++ test_autoarray/plot/test_array.py | 142 ++++++++++++++++++++++++ 5 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 test_autoarray/plot/test_array.py diff --git a/autoarray/config/visualize/general.yaml b/autoarray/config/visualize/general.yaml index 04e1d1fcb..18851d0c2 100644 --- a/autoarray/config/visualize/general.yaml +++ b/autoarray/config/visualize/general.yaml @@ -1,7 +1,7 @@ general: backend: default # The matplotlib backend used for visualization. `default` uses the system default, can specify specific backend (e.g. TKAgg, Qt5Agg, WXAgg). dpi: 150 # Resolution in dots per inch used when saving figures. Lower values reduce file size (e.g. 150 gives ~50% smaller files than 300 with negligible quality loss for diagnostic subplots). - imshow_origin: upper # The `origin` input of `imshow`, determining if pixel values are ascending or descending on the y-axis. + imshow_origin: upper # The `origin` input of `imshow`, determining if pixel values are ascending or descending on the y-axis. Presentation only: overlays are reflected with the image so they stay registered, but under `lower` the displayed y axis and reported model y parameters (e.g. centres) disagree in sign. Flipping the data itself at load time is a different operation. log10_min_value: 1.0e-4 # If negative values are being plotted on a log10 scale, values below this value are rounded up to it (e.g. to remove negative values). log10_max_value: 1.0e99 # If positive values are being plotted on a log10 scale, values above this value are rounded down to it. zoom_around_mask: true # If True, plots of data structures with a mask automatically zoom in the masked region. diff --git a/autoarray/plot/array.py b/autoarray/plot/array.py index 3d40c0209..1852dac2c 100644 --- a/autoarray/plot/array.py +++ b/autoarray/plot/array.py @@ -21,6 +21,8 @@ norm_from, _apply_contours, _conf_imshow_origin, + _overlay_yx_for_origin, + _vector_yx_for_origin, plot_regions, ) @@ -233,6 +235,45 @@ def plot_array( _apply_colorbar(im, ax, cb_unit=cb_unit, is_subplot=not owns_figure) # --- overlays -------------------------------------------------------------- + # Every (y, x) overlay is reflected for display when ``origin_imshow`` is + # ``"lower"`` so it stays registered to the raster (PyAutoArray#565). When + # ``extent`` is ``None`` the raster is drawn in pixel-index coordinates, + # where matplotlib already places row ``i`` at ``y = i`` under either + # origin, so no reflection is applied. + def _for_origin(yx): + return _overlay_yx_for_origin(yx, extent, origin_imshow) + + mask = _for_origin(mask) + border = _for_origin(border) + origin = _for_origin(origin) + grid = _for_origin(grid) + mesh_grid = _for_origin(mesh_grid) + if positions is not None: + positions = [_for_origin(np.asarray(pos).reshape(-1, 2)) for pos in positions] + if lines is not None: + lines = [ + ( + _for_origin(np.asarray(line).reshape(-1, 2)) + if line is not None and len(line) > 0 + else line + ) + for line in lines + ] + if regions is not None: + regions = [ + ( + None + if region is None + else ( + _for_origin(region) + if isinstance(region, np.ndarray) and region.ndim == 2 + else [_for_origin(polygon) for polygon in region] + ) + ) + for region in regions + ] + vector_yx = _vector_yx_for_origin(vector_yx, extent, origin_imshow) + if array_overlay is not None: ax.imshow( array_overlay, @@ -299,6 +340,7 @@ def plot_array( vector_yx[:, 2], ) + # Patches are drawn as given: they are not origin-aware. if patches is not None: for patch in patches: import copy diff --git a/autoarray/plot/inversion.py b/autoarray/plot/inversion.py index 94e22d1ef..7b8da7e2c 100644 --- a/autoarray/plot/inversion.py +++ b/autoarray/plot/inversion.py @@ -16,6 +16,8 @@ save_figure, norm_from, _conf_imshow_origin, + _overlay_yx_for_origin, + numpy_grid, plot_regions, ) @@ -130,10 +132,15 @@ def plot_inversion_reconstruction( is_subplot = not owns_figure + # Extent of an ``imshow`` raster drawn on *ax* (uniform rectangular meshes + # only); ``None`` for ``pcolormesh`` / ``tripcolor`` panels, which are drawn + # in data coordinates and need no origin handling. + raster_extent = None + if isinstance( mapper.interpolator, (InterpolatorRectangular, InterpolatorRectangularUniform) ): - _plot_rectangular( + raster_extent = _plot_rectangular( ax, pixel_values, mapper, norm, colormap, extent, is_subplot=is_subplot ) elif isinstance( @@ -144,6 +151,39 @@ def plot_inversion_reconstruction( ) # --- overlays -------------------------------------------------------------- + # Over an ``imshow`` raster drawn with ``origin="lower"`` the (y, x) + # overlays are reflected for display so they stay registered to the + # pixels (PyAutoArray#565); every other case is left unchanged. + origin_imshow = _conf_imshow_origin() if raster_extent is not None else None + if origin_imshow == "lower": + + def _for_origin(yx): + return _overlay_yx_for_origin(yx, raster_extent, origin_imshow) + + if lines is not None: + lines = [ + ( + _for_origin(np.asarray(line).reshape(-1, 2)) + if line is not None and len(line) > 0 + else line + ) + for line in lines + ] + if regions is not None: + regions = [ + ( + None + if region is None + else ( + _for_origin(region) + if isinstance(region, np.ndarray) and region.ndim == 2 + else [_for_origin(polygon) for polygon in region] + ) + ) + for region in regions + ] + grid = _for_origin(numpy_grid(grid)) + if lines is not None: for i, line in enumerate(lines): if line is not None and len(line) > 0: @@ -217,6 +257,12 @@ def _plot_rectangular( is_subplot When ``True`` uses ``labelsize_subplot`` from config for the colorbar tick labels (matches the behaviour of :func:`~autoarray.plot.array.plot_array`). + + Returns + ------- + tuple or None + The ``imshow`` extent when the uniform path draws a raster (so callers + can register overlays to its origin), else ``None``. """ from autoarray.inversion.mesh.interpolator.rectangular_uniform import ( InterpolatorRectangularUniform, @@ -258,6 +304,7 @@ def _plot_rectangular( origin=_conf_imshow_origin(), ) _apply_colorbar(im, ax, is_subplot=is_subplot) + return pix_array.geometry.extent else: y_edges, x_edges = mapper.mesh_geometry.edges_transformed.T Y, X = np.meshgrid(y_edges, x_edges, indexing="ij") @@ -270,6 +317,7 @@ def _plot_rectangular( cmap=colormap, ) _apply_colorbar(im, ax, is_subplot=is_subplot) + return None def _plot_delaunay(ax, pixel_values, mapper, norm, colormap, extent, is_subplot=False): diff --git a/autoarray/plot/utils.py b/autoarray/plot/utils.py index bfbbc1904..cc095d99f 100644 --- a/autoarray/plot/utils.py +++ b/autoarray/plot/utils.py @@ -1030,6 +1030,60 @@ def _conf_imshow_origin() -> str: return origin +def _overlay_yx_for_origin(yx, extent, origin_imshow): + """ + Map ``(y, x)`` overlay coordinates so they stay registered to an ``imshow`` + raster drawn with ``origin=origin_imshow`` over ``extent``. + + PyAutoArray stores native arrays with row 0 at ``ymax``, which is exactly + what ``imshow(origin="upper")`` renders, so under ``"upper"`` the input is + returned unchanged. Under ``"lower"`` matplotlib draws row 0 at ``ymin``, + reflecting the raster about the extent's y midpoint; overlays given in data + ``(y, x)`` coordinates are reflected the same way + (``y -> ymin + ymax - y``) so that they sit on the pixels they describe. + + This is a display-only reflection: the internal y convention of the data + structures is unchanged, and nothing outside the drawn figure is affected. + + Parameters + ---------- + yx + Array of shape ``(..., 2+)`` whose column 0 is ``y`` and column 1 is + ``x`` (extra trailing columns are carried through untouched). ``None`` + passes straight through. + extent + ``(xmin, xmax, ymin, ymax)`` of the raster the overlay is drawn on. + ``None`` means no reflection can be defined and the input is returned + unchanged. + origin_imshow + The ``origin`` passed to ``imshow`` (``"upper"`` or ``"lower"``). + """ + if yx is None or extent is None or origin_imshow != "lower": + return yx + + yx = np.array(yx, dtype=float) + yx[..., 0] = extent[2] + extent[3] - yx[..., 0] + return yx + + +def _vector_yx_for_origin(vector_yx, extent, origin_imshow): + """ + Quiver sibling of :func:`_overlay_yx_for_origin` for ``(y, x, dy, dx)`` rows. + + Under ``"lower"`` the anchor ``y`` is reflected about the extent's y + midpoint and the ``dy`` component (column 2) is negated, so each arrow + keeps pointing at the same pixels of the reflected raster. Under + ``"upper"`` (or with ``vector_yx`` / ``extent`` ``None``) the input is + returned unchanged. Display-only, like :func:`_overlay_yx_for_origin`. + """ + if vector_yx is None or extent is None or origin_imshow != "lower": + return vector_yx + + vector_yx = _overlay_yx_for_origin(vector_yx, extent, origin_imshow) + vector_yx[..., 2] = -vector_yx[..., 2] + return vector_yx + + def _conf_output_format() -> str: """Return the default output_format from config (``"show"``, ``"png"``, etc.). diff --git a/test_autoarray/plot/test_array.py b/test_autoarray/plot/test_array.py new file mode 100644 index 000000000..27bed9c7f --- /dev/null +++ b/test_autoarray/plot/test_array.py @@ -0,0 +1,142 @@ +""" +Overlays drawn by ``plot_array`` must stay registered to the raster whatever the +``visualize/general.yaml -> general -> imshow_origin`` config is (PyAutoArray#565). + +The checks are origin-agnostic: they read the drawn overlay coordinates back off +the axes and map them through the rendered image's own extent and origin to the +pixel they land on, which must be a bright pixel of an off-centre block. +""" + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from autonerves import conf + +import autoarray as aa +import autoarray.plot as aplt + + +def _bright_block_array(): + values = np.zeros((50, 50)) + values[10:14, 23:27] = 1.0 + return aa.Array2D.no_mask(values=values, pixel_scales=0.1) + + +def _native_grid(array): + return np.asarray(array.mask.derive_grid.all_false.native) + + +def _pixel_value_under(ax, draw_x, draw_y): + image = ax.images[0] + data = np.asarray(image.get_array()) + xmin, xmax, ymin, ymax = image.get_extent() + ny, nx = data.shape[:2] + + col = int((draw_x - xmin) / (xmax - xmin) * nx) + frac = (draw_y - ymin) / (ymax - ymin) + row = int((1.0 - frac) * ny) if image.origin == "upper" else int(frac * ny) + + return data[row, col] + + +@pytest.fixture(name="imshow_origin") +def make_imshow_origin(request): + general = conf.instance["visualize"]["general"]["general"] + original = general["imshow_origin"] + try: + general["imshow_origin"] = request.param + yield request.param + finally: + general["imshow_origin"] = original + + +@pytest.mark.parametrize("imshow_origin", ["upper", "lower"], indirect=True) +def test__plot_array__positions_marker_lands_on_block(imshow_origin): + array = _bright_block_array() + grid = _native_grid(array) + y, x = float(grid[12, 25, 0]), float(grid[12, 25, 1]) + + fig, ax = plt.subplots() + try: + aplt.plot_array(array=array, positions=aa.Grid2DIrregular([(y, x)]), ax=ax) + + assert ax.images[0].origin == imshow_origin + + # The positions scatter is the only s=20, zorder=5 collection (the auto + # mask-edge scatter is s=1). + positions = [ + c + for c in ax.collections + if c.get_zorder() == 5 and np.allclose(c.get_sizes(), 20) + ] + assert len(positions) == 1 + draw_x, draw_y = positions[0].get_offsets()[0] + + assert _pixel_value_under(ax, float(draw_x), float(draw_y)) == 1.0 + finally: + plt.close(fig) + + +@pytest.mark.parametrize("imshow_origin", ["upper", "lower"], indirect=True) +def test__plot_array__lines_vertices_land_on_block(imshow_origin): + array = _bright_block_array() + grid = _native_grid(array) + + # An asymmetric polyline whose every vertex sits on a bright pixel. + pixels = [(10, 23), (13, 24), (11, 26)] + line = np.array([[grid[r, c, 0], grid[r, c, 1]] for r, c in pixels]) + + fig, ax = plt.subplots() + try: + aplt.plot_array(array=array, lines=[line], ax=ax) + + assert ax.images[0].origin == imshow_origin + + xy = ax.lines[-1].get_xydata() + assert xy.shape == (3, 2) + + for draw_x, draw_y in xy: + assert _pixel_value_under(ax, float(draw_x), float(draw_y)) == 1.0 + finally: + plt.close(fig) + + +@pytest.mark.parametrize("imshow_origin", ["upper", "lower"], indirect=True) +def test__plot_inversion_reconstruction__uniform_mesh_grid_lands_on_pixel( + imshow_origin, rectangular_mapper_7x7_3x3 +): + mapper = rectangular_mapper_7x7_3x3 + + # One off-centre bright source pixel (row 0, column 1 of the 3x3 mesh). + pixel_values = np.zeros(9) + pixel_values[1] = 1.0 + + mesh = aa.Array2D.no_mask( + values=np.zeros(mapper.mesh_geometry.shape), + pixel_scales=mapper.mesh_geometry.pixel_scales, + origin=mapper.mesh_geometry.origin, + ) + y, x = _native_grid(mesh)[0, 1] + + fig, ax = plt.subplots() + try: + aplt.plot_inversion_reconstruction( + pixel_values=pixel_values, + mapper=mapper, + grid=np.array([[y, x]]), + zoom_to_brightest=False, + ax=ax, + ) + + assert ax.images[0].origin == imshow_origin + + draw_x, draw_y = ax.collections[-1].get_offsets()[0] + + assert _pixel_value_under(ax, float(draw_x), float(draw_y)) == 1.0 + finally: + plt.close(fig)