Repository navigation
Description
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 253imshow 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 -> ymaxNo 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:
- Make the overlays origin-aware, as
_apply_contoursalready is: reflect
overlayyabout the extent midpoint whenorigin == "lower". - Reject the value. If arrays are only ever meant to be rendered in their
nativerow 0 == ymaxorientation, have_conf_imshow_originraise on
"lower". It already raisesValueErrorfor valuesimshowitself 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.0Observed 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 == ymaxconvention 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_fromwas 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. (originalso reachesimshowat lines 184 and 212, the
RGB andarray_overlaybranches.) - The proposed regression test below was run: it passes under
"upper"and
fails under"lower"on this version.
Activity