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:
- Make the overlays origin-aware, as
_apply_contours already is: reflect
overlay y about the extent midpoint when origin == "lower".
- 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.
imshow_origin: lowermirrors the image raster but not the vector overlaysTarget repository: PyAutoArray
(the defect is in
autoarray/plot/array.py; it surfaces through everyPyAutoLens/PyAutoGalaxy plot that draws overlays).
Searched existing issues: yes.
imshow_originmatches only two closed pullrequests 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_originis presented as a supportedchoice between
"upper"and"lower", and_conf_imshow_originvalidates it assuch. Setting it to
"lower"mirrors the image raster but leaves every vectoroverlay — 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 horizontalmidline 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 apositionsmarker is placed at that block's owncoordinates. The marker must land on the block.
Result. In
mre_upper.pngthe black marker sits on the red block, aty = +1.25". Inmre_lower.pngthe block has moved toy = -1.25"while themarker stayed at
y = +1.25". Same forlines=(critical curves and causticstravel through the same code path).
Root cause
In
plot_array(autoarray/plot/array.py) the raster honoursorigin:while the overlays are drawn straight in data coordinates and never consult it:
imshowwith an explicitextentmaps row 0 toymaxunderorigin="upper"and to
yminunderorigin="lower". PyAutoArray's own convention gives row 0the largest
y—Grid2D.uniform(shape_native=(100, 100), pixel_scales=0.05)reports
y = +2.475at row 0 — so"upper"is the only setting that renders anarray consistently with the coordinate system its overlays live in.
_apply_contoursinautoarray/plot/utils.pyalready handles this correctly:No equivalent adjustment exists for the
ax.plot/ax.scatter/ax.quiveroverlays, which is what makes this look like an oversight rather than a design
decision.
Suggested resolution
Either would close it:
_apply_contoursalready is: reflectoverlay
yabout the extent midpoint whenorigin == "lower".native
row 0 == ymaxorientation, have_conf_imshow_originraise on"lower". It already raisesValueErrorfor valuesimshowitself wouldreject, 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 barehdu.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_originonly changes presentation. It does not change the internal
yconvention, sounder
"lower"the displayed axis and the reported model parameters disagree insign on
y. Achieving north-up display with matching parameter signs requiresflipping 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.
Observed on 2026.8.29.1:
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
AI assistance
This report was drafted with AI assistance, per the PyAutoLabs
AI policy.
How the claims were checked, rather than merely generated:
output PNGs were inspected. The
"upper"marker lands on the block; the"lower"marker is 2.5" away. It is not a hypothesised reproduction.row 0 == ymaxconvention was confirmed by evaluating a light profile at aknown centre and locating its peak by array index (
centre=(y=+1.0, x=0.0)ona 100x100, 0.05"/pix grid peaks at row 30), not inferred from documentation.
ndarray_via_hdu_fromwas confirmed by reading theinstalled source.
estimated. The surrounding code is quoted so they remain locatable if they
have since moved. (
originalso reachesimshowat lines 184 and 212, theRGB and
array_overlaybranches.)"upper"andfails under
"lower"on this version.