Skip to content

fix: imshow_origin "lower" mirrors the raster but not the vector overlays` #535

Description

@ClarkGuilty
Image Image

imshow_origin: lower mirrors the image raster but not the vector overlays

Target repository: PyAutoArray
(the defect is in autoarray/plot/array.py; it surfaces through every
PyAutoLens/PyAutoGalaxy plot that draws overlays).

Searched existing issues: yes. imshow_origin matches only two closed pull
requests in PyAutoArray — #247
("Visualization final: config origin, fits API, output mode"), which appears to
be where the config lever was introduced, and
#510. No open issue covers
this. Nothing in PyAutoLens.

Summary

visualize/general.yaml -> general -> imshow_origin is presented as a supported
choice between "upper" and "lower", and _conf_imshow_origin validates it as
such. Setting it to "lower" mirrors the image raster but leaves every vector
overlay — critical curves, caustics, mask edge, border, grid, mesh grid,
positions, the origin marker and quiver vectors — in unmirrored data
coordinates.

The figure still looks plausible, so the failure is silent. In lens modelling the
visible symptom is critical curves and caustics no longer tracing the arcs they
belong to.

Expected behaviour

An overlay drawn at a given (y, x) lands on the image feature at that (y, x),
for any accepted value of imshow_origin.

Actual behaviour

Under imshow_origin: "lower" the overlay is reflected about the horizontal
midline of the extent relative to the raster. In the example below a marker
placed at the coordinates of a bright block renders 2.5" away from it.

There is no traceback: nothing raises, nothing warns, and the figure is
produced normally. That silence is the main reason this is worth fixing.

Minimal reproducible example

Self-contained — no data files and no lens model. A bright block is placed
off-centre in y, and a positions marker is placed at that block's own
coordinates. The marker must land on the block.

import matplotlib
matplotlib.use("Agg")

import numpy as np

from autonerves import conf
import autolens as al
import autolens.plot as aplt

values = np.zeros((50, 50))
values[10:14, 23:27] = 1.0            # block near the TOP of the array

array = al.Array2D.no_mask(values=values, pixel_scales=0.1)

grid = np.asarray(array.mask.derive_grid.all_false.native)
positions = al.Grid2DIrregular([(float(grid[12, 25, 0]), float(grid[12, 25, 1]))])
# block centre, internal coords: (y=1.250, x=0.050)

for origin in ("upper", "lower"):
    conf.instance["visualize"]["general"]["general"]["imshow_origin"] = origin
    aplt.plot_array(
        array=array,
        positions=positions,
        title=f"imshow_origin = {origin!r}",
        output_path="out/",
        output_filename=f"mre_{origin}",
        output_format="png",
    )

Result. In mre_upper.png the black marker sits on the red block, at
y = +1.25". In mre_lower.png the block has moved to y = -1.25" while the
marker stayed at y = +1.25". Same for lines= (critical curves and caustics
travel through the same code path).

Root cause

In plot_array (autoarray/plot/array.py) the raster honours origin:

im = ax.imshow(
    array,
    cmap=colormap,
    norm=norm,
    extent=extent,
    aspect="auto",
    origin=origin_imshow,   # line 193
)

while the overlays are drawn straight in data coordinates and never consult it:

if mask is not None:
    ax.scatter(mask[:, 1], mask[:, 0], s=1, c="k")           # line 216

if positions is not None:
    ...
    ax.scatter(pos[:, 1], pos[:, 0], ...)                     # line 239

if lines is not None:
    for i, line in enumerate(lines):
        ...
        ax.plot(line[:, 1], line[:, 0], **kw)                 # line 250

if vector_yx is not None:
    ax.quiver(vector_yx[:, 1], vector_yx[:, 0], ...)          # line 253

imshow with an explicit extent maps row 0 to ymax under origin="upper"
and to ymin under origin="lower". PyAutoArray's own convention gives row 0
the largest y — Grid2D.uniform(shape_native=(100, 100), pixel_scales=0.05)
reports y = +2.475 at row 0 — so "upper" is the only setting that renders an
array consistently with the coordinate system its overlays live in.

_apply_contours in autoarray/plot/utils.py already handles this correctly:

origin = _conf_imshow_origin()
xs = np.linspace(extent[0], extent[1], nx)
if origin == "upper":
    ys = np.linspace(extent[3], extent[2], ny)  # ymax -> ymin
else:
    ys = np.linspace(extent[2], extent[3], ny)  # ymin -> ymax

No equivalent adjustment exists for the ax.plot / ax.scatter / ax.quiver
overlays, which is what makes this look like an oversight rather than a design
decision.

Suggested resolution

Either would close it:

  1. Make the overlays origin-aware, as _apply_contours already is: reflect
    overlay y about the extent midpoint when origin == "lower".
  2. Reject the value. If arrays are only ever meant to be rendered in their
    native row 0 == ymax orientation, have _conf_imshow_origin raise on
    "lower". It already raises ValueError for values imshow itself would
    reject, so this would fit the existing contract and fail loudly.

Option 1 seems the more useful outcome, because users of FITS imaging reasonably
want north-up display: ndarray_via_hdu_from (PyAutoNerves) does a bare
hdu.data.astype("float") with no flip, so array row 0 is the first FITS row,
which for a standard WCS (CD2_2 > 0) is the southern edge.

One caveat worth documenting alongside the config key either way: imshow_origin
only changes presentation. It does not change the internal y convention, so
under "lower" the displayed axis and the reported model parameters disagree in
sign on y. Achieving north-up display with matching parameter signs requires
flipping the data at load time instead — a different operation, and one that
flips the sign of every y-odd model parameter.

Proposed regression test

Origin-agnostic: it reads the position the marker was actually drawn at, maps
that through the image's own extent and origin, and asserts the image is bright
there. It therefore keeps passing under a fix that mirrors the overlays, rather
than encoding today's behaviour.

def marker_lands_on_block(origin):
    conf.instance["visualize"]["general"]["general"]["imshow_origin"] = origin

    values = np.zeros((50, 50))
    values[10:14, 23:27] = 1.0
    array = al.Array2D.no_mask(values=values, pixel_scales=0.1)

    grid = np.asarray(array.mask.derive_grid.all_false.native)
    y, x = float(grid[12, 25, 0]), float(grid[12, 25, 1])

    fig, ax = plt.subplots()
    aplt.plot_array(array=array, positions=al.Grid2DIrregular([(y, x)]), ax=ax)

    image = ax.images[0]
    data = np.asarray(image.get_array())
    xmin, xmax, ymin, ymax = image.get_extent()
    ny, nx = data.shape[:2]

    drawn = np.concatenate([c.get_offsets() for c in ax.collections])
    draw_x, draw_y = float(drawn[0, 0]), float(drawn[0, 1])

    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 float(data[row, col]) == 1.0

Observed on 2026.8.29.1:

origin='upper': marker drawn at (y=+1.250, x=+0.050) -> array[12,25] = 1.0   PASS
origin='lower': marker drawn at (y=+1.250, x=+0.050) -> array[37,25] = 0.0   FAIL

Impact

Silent and easy to miss. Images look correct on their own; circular overlays such
as a typical mask edge look unchanged; only asymmetric overlays reveal it. A user
can reach a wrong conclusion about where a feature lies on the sky before
noticing. Encountered while modelling a Euclid strong-lens candidate: the
critical curve had visibly slid off the lensed arc in the fit subplot.

Environment

OS Fedora Linux 44, kernel 7.1.10-200.fc44.x86_64 (glibc 2.43)
Python 3.13.14
autoarray 2026.8.29.1
autolens / autogalaxy / autofit / autonerves 2026.8.29.1
matplotlib 3.11.1
numpy 2.5.2

AI assistance

This report was drafted with AI assistance, per the PyAutoLabs
AI policy.
How the claims were checked, rather than merely generated:

  • The minimal example above was executed on the environment listed, and both
    output PNGs were inspected. The "upper" marker lands on the block; the
    "lower" marker is 2.5" away. It is not a hypothesised reproduction.
  • The row 0 == ymax convention was confirmed by evaluating a light profile at a
    known centre and locating its peak by array index (centre=(y=+1.0, x=0.0) on
    a 100x100, 0.05"/pix grid peaks at row 30), not inferred from documentation.
  • The absence of a flip in ndarray_via_hdu_from was confirmed by reading the
    installed source.
  • Line numbers were read from the installed 2026.8.29.1 package by grep, not
    estimated. The surrounding code is quoted so they remain locatable if they
    have since moved. (origin also reaches imshow at lines 184 and 212, the
    RGB and array_overlay branches.)
  • The proposed regression test below was run: it passes under "upper" and
    fails under "lower" on this version.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions