Skip to content

Commit 34c2e83

Browse files
author
James Nightingale
authored
Merge pull request #616 from PyAutoLabs/feature/imshow-origin-lower-overlays
fix: make plot_array overlays respect imshow_origin "lower" (#565)
2 parents 58bdda0 + 93961c6 commit 34c2e83

5 files changed

Lines changed: 288 additions & 2 deletions

File tree

‎autoarray/config/visualize/general.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
general:
22
backend: default # The matplotlib backend used for visualization. `default` uses the system default, can specify specific backend (e.g. TKAgg, Qt5Agg, WXAgg).
33
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).
4-
imshow_origin: upper # The `origin` input of `imshow`, determining if pixel values are ascending or descending on the y-axis.
4+
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.
55
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).
66
log10_max_value: 1.0e99 # If positive values are being plotted on a log10 scale, values above this value are rounded down to it.
77
zoom_around_mask: true # If True, plots of data structures with a mask automatically zoom in the masked region.

‎autoarray/plot/array.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
norm_from,
2222
_apply_contours,
2323
_conf_imshow_origin,
24+
_overlay_yx_for_origin,
25+
_vector_yx_for_origin,
2426
plot_regions,
2527
)
2628

@@ -233,6 +235,45 @@ def plot_array(
233235
_apply_colorbar(im, ax, cb_unit=cb_unit, is_subplot=not owns_figure)
234236

235237
# --- overlays --------------------------------------------------------------
238+
# Every (y, x) overlay is reflected for display when ``origin_imshow`` is
239+
# ``"lower"`` so it stays registered to the raster (PyAutoArray#565). When
240+
# ``extent`` is ``None`` the raster is drawn in pixel-index coordinates,
241+
# where matplotlib already places row ``i`` at ``y = i`` under either
242+
# origin, so no reflection is applied.
243+
def _for_origin(yx):
244+
return _overlay_yx_for_origin(yx, extent, origin_imshow)
245+
246+
mask = _for_origin(mask)
247+
border = _for_origin(border)
248+
origin = _for_origin(origin)
249+
grid = _for_origin(grid)
250+
mesh_grid = _for_origin(mesh_grid)
251+
if positions is not None:
252+
positions = [_for_origin(np.asarray(pos).reshape(-1, 2)) for pos in positions]
253+
if lines is not None:
254+
lines = [
255+
(
256+
_for_origin(np.asarray(line).reshape(-1, 2))
257+
if line is not None and len(line) > 0
258+
else line
259+
)
260+
for line in lines
261+
]
262+
if regions is not None:
263+
regions = [
264+
(
265+
None
266+
if region is None
267+
else (
268+
_for_origin(region)
269+
if isinstance(region, np.ndarray) and region.ndim == 2
270+
else [_for_origin(polygon) for polygon in region]
271+
)
272+
)
273+
for region in regions
274+
]
275+
vector_yx = _vector_yx_for_origin(vector_yx, extent, origin_imshow)
276+
236277
if array_overlay is not None:
237278
ax.imshow(
238279
array_overlay,
@@ -299,6 +340,7 @@ def plot_array(
299340
vector_yx[:, 2],
300341
)
301342

343+
# Patches are drawn as given: they are not origin-aware.
302344
if patches is not None:
303345
for patch in patches:
304346
import copy

‎autoarray/plot/inversion.py‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
save_figure,
1717
norm_from,
1818
_conf_imshow_origin,
19+
_overlay_yx_for_origin,
20+
numpy_grid,
1921
plot_regions,
2022
)
2123

@@ -130,10 +132,15 @@ def plot_inversion_reconstruction(
130132

131133
is_subplot = not owns_figure
132134

135+
# Extent of an ``imshow`` raster drawn on *ax* (uniform rectangular meshes
136+
# only); ``None`` for ``pcolormesh`` / ``tripcolor`` panels, which are drawn
137+
# in data coordinates and need no origin handling.
138+
raster_extent = None
139+
133140
if isinstance(
134141
mapper.interpolator, (InterpolatorRectangular, InterpolatorRectangularUniform)
135142
):
136-
_plot_rectangular(
143+
raster_extent = _plot_rectangular(
137144
ax, pixel_values, mapper, norm, colormap, extent, is_subplot=is_subplot
138145
)
139146
elif isinstance(
@@ -144,6 +151,39 @@ def plot_inversion_reconstruction(
144151
)
145152

146153
# --- overlays --------------------------------------------------------------
154+
# Over an ``imshow`` raster drawn with ``origin="lower"`` the (y, x)
155+
# overlays are reflected for display so they stay registered to the
156+
# pixels (PyAutoArray#565); every other case is left unchanged.
157+
origin_imshow = _conf_imshow_origin() if raster_extent is not None else None
158+
if origin_imshow == "lower":
159+
160+
def _for_origin(yx):
161+
return _overlay_yx_for_origin(yx, raster_extent, origin_imshow)
162+
163+
if lines is not None:
164+
lines = [
165+
(
166+
_for_origin(np.asarray(line).reshape(-1, 2))
167+
if line is not None and len(line) > 0
168+
else line
169+
)
170+
for line in lines
171+
]
172+
if regions is not None:
173+
regions = [
174+
(
175+
None
176+
if region is None
177+
else (
178+
_for_origin(region)
179+
if isinstance(region, np.ndarray) and region.ndim == 2
180+
else [_for_origin(polygon) for polygon in region]
181+
)
182+
)
183+
for region in regions
184+
]
185+
grid = _for_origin(numpy_grid(grid))
186+
147187
if lines is not None:
148188
for i, line in enumerate(lines):
149189
if line is not None and len(line) > 0:
@@ -217,6 +257,12 @@ def _plot_rectangular(
217257
is_subplot
218258
When ``True`` uses ``labelsize_subplot`` from config for the colorbar
219259
tick labels (matches the behaviour of :func:`~autoarray.plot.array.plot_array`).
260+
261+
Returns
262+
-------
263+
tuple or None
264+
The ``imshow`` extent when the uniform path draws a raster (so callers
265+
can register overlays to its origin), else ``None``.
220266
"""
221267
from autoarray.inversion.mesh.interpolator.rectangular_uniform import (
222268
InterpolatorRectangularUniform,
@@ -258,6 +304,7 @@ def _plot_rectangular(
258304
origin=_conf_imshow_origin(),
259305
)
260306
_apply_colorbar(im, ax, is_subplot=is_subplot)
307+
return pix_array.geometry.extent
261308
else:
262309
y_edges, x_edges = mapper.mesh_geometry.edges_transformed.T
263310
Y, X = np.meshgrid(y_edges, x_edges, indexing="ij")
@@ -270,6 +317,7 @@ def _plot_rectangular(
270317
cmap=colormap,
271318
)
272319
_apply_colorbar(im, ax, is_subplot=is_subplot)
320+
return None
273321

274322

275323
def _plot_delaunay(ax, pixel_values, mapper, norm, colormap, extent, is_subplot=False):

‎autoarray/plot/utils.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,60 @@ def _conf_imshow_origin() -> str:
10301030
return origin
10311031

10321032

1033+
def _overlay_yx_for_origin(yx, extent, origin_imshow):
1034+
"""
1035+
Map ``(y, x)`` overlay coordinates so they stay registered to an ``imshow``
1036+
raster drawn with ``origin=origin_imshow`` over ``extent``.
1037+
1038+
PyAutoArray stores native arrays with row 0 at ``ymax``, which is exactly
1039+
what ``imshow(origin="upper")`` renders, so under ``"upper"`` the input is
1040+
returned unchanged. Under ``"lower"`` matplotlib draws row 0 at ``ymin``,
1041+
reflecting the raster about the extent's y midpoint; overlays given in data
1042+
``(y, x)`` coordinates are reflected the same way
1043+
(``y -> ymin + ymax - y``) so that they sit on the pixels they describe.
1044+
1045+
This is a display-only reflection: the internal y convention of the data
1046+
structures is unchanged, and nothing outside the drawn figure is affected.
1047+
1048+
Parameters
1049+
----------
1050+
yx
1051+
Array of shape ``(..., 2+)`` whose column 0 is ``y`` and column 1 is
1052+
``x`` (extra trailing columns are carried through untouched). ``None``
1053+
passes straight through.
1054+
extent
1055+
``(xmin, xmax, ymin, ymax)`` of the raster the overlay is drawn on.
1056+
``None`` means no reflection can be defined and the input is returned
1057+
unchanged.
1058+
origin_imshow
1059+
The ``origin`` passed to ``imshow`` (``"upper"`` or ``"lower"``).
1060+
"""
1061+
if yx is None or extent is None or origin_imshow != "lower":
1062+
return yx
1063+
1064+
yx = np.array(yx, dtype=float)
1065+
yx[..., 0] = extent[2] + extent[3] - yx[..., 0]
1066+
return yx
1067+
1068+
1069+
def _vector_yx_for_origin(vector_yx, extent, origin_imshow):
1070+
"""
1071+
Quiver sibling of :func:`_overlay_yx_for_origin` for ``(y, x, dy, dx)`` rows.
1072+
1073+
Under ``"lower"`` the anchor ``y`` is reflected about the extent's y
1074+
midpoint and the ``dy`` component (column 2) is negated, so each arrow
1075+
keeps pointing at the same pixels of the reflected raster. Under
1076+
``"upper"`` (or with ``vector_yx`` / ``extent`` ``None``) the input is
1077+
returned unchanged. Display-only, like :func:`_overlay_yx_for_origin`.
1078+
"""
1079+
if vector_yx is None or extent is None or origin_imshow != "lower":
1080+
return vector_yx
1081+
1082+
vector_yx = _overlay_yx_for_origin(vector_yx, extent, origin_imshow)
1083+
vector_yx[..., 2] = -vector_yx[..., 2]
1084+
return vector_yx
1085+
1086+
10331087
def _conf_output_format() -> str:
10341088
"""Return the default output_format from config (``"show"``, ``"png"``, etc.).
10351089

‎test_autoarray/plot/test_array.py‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""
2+
Overlays drawn by ``plot_array`` must stay registered to the raster whatever the
3+
``visualize/general.yaml -> general -> imshow_origin`` config is (PyAutoArray#565).
4+
5+
The checks are origin-agnostic: they read the drawn overlay coordinates back off
6+
the axes and map them through the rendered image's own extent and origin to the
7+
pixel they land on, which must be a bright pixel of an off-centre block.
8+
"""
9+
10+
import matplotlib
11+
12+
matplotlib.use("Agg")
13+
14+
import matplotlib.pyplot as plt
15+
import numpy as np
16+
import pytest
17+
18+
from autonerves import conf
19+
20+
import autoarray as aa
21+
import autoarray.plot as aplt
22+
23+
24+
def _bright_block_array():
25+
values = np.zeros((50, 50))
26+
values[10:14, 23:27] = 1.0
27+
return aa.Array2D.no_mask(values=values, pixel_scales=0.1)
28+
29+
30+
def _native_grid(array):
31+
return np.asarray(array.mask.derive_grid.all_false.native)
32+
33+
34+
def _pixel_value_under(ax, draw_x, draw_y):
35+
image = ax.images[0]
36+
data = np.asarray(image.get_array())
37+
xmin, xmax, ymin, ymax = image.get_extent()
38+
ny, nx = data.shape[:2]
39+
40+
col = int((draw_x - xmin) / (xmax - xmin) * nx)
41+
frac = (draw_y - ymin) / (ymax - ymin)
42+
row = int((1.0 - frac) * ny) if image.origin == "upper" else int(frac * ny)
43+
44+
return data[row, col]
45+
46+
47+
@pytest.fixture(name="imshow_origin")
48+
def make_imshow_origin(request):
49+
general = conf.instance["visualize"]["general"]["general"]
50+
original = general["imshow_origin"]
51+
try:
52+
general["imshow_origin"] = request.param
53+
yield request.param
54+
finally:
55+
general["imshow_origin"] = original
56+
57+
58+
@pytest.mark.parametrize("imshow_origin", ["upper", "lower"], indirect=True)
59+
def test__plot_array__positions_marker_lands_on_block(imshow_origin):
60+
array = _bright_block_array()
61+
grid = _native_grid(array)
62+
y, x = float(grid[12, 25, 0]), float(grid[12, 25, 1])
63+
64+
fig, ax = plt.subplots()
65+
try:
66+
aplt.plot_array(array=array, positions=aa.Grid2DIrregular([(y, x)]), ax=ax)
67+
68+
assert ax.images[0].origin == imshow_origin
69+
70+
# The positions scatter is the only s=20, zorder=5 collection (the auto
71+
# mask-edge scatter is s=1).
72+
positions = [
73+
c
74+
for c in ax.collections
75+
if c.get_zorder() == 5 and np.allclose(c.get_sizes(), 20)
76+
]
77+
assert len(positions) == 1
78+
draw_x, draw_y = positions[0].get_offsets()[0]
79+
80+
assert _pixel_value_under(ax, float(draw_x), float(draw_y)) == 1.0
81+
finally:
82+
plt.close(fig)
83+
84+
85+
@pytest.mark.parametrize("imshow_origin", ["upper", "lower"], indirect=True)
86+
def test__plot_array__lines_vertices_land_on_block(imshow_origin):
87+
array = _bright_block_array()
88+
grid = _native_grid(array)
89+
90+
# An asymmetric polyline whose every vertex sits on a bright pixel.
91+
pixels = [(10, 23), (13, 24), (11, 26)]
92+
line = np.array([[grid[r, c, 0], grid[r, c, 1]] for r, c in pixels])
93+
94+
fig, ax = plt.subplots()
95+
try:
96+
aplt.plot_array(array=array, lines=[line], ax=ax)
97+
98+
assert ax.images[0].origin == imshow_origin
99+
100+
xy = ax.lines[-1].get_xydata()
101+
assert xy.shape == (3, 2)
102+
103+
for draw_x, draw_y in xy:
104+
assert _pixel_value_under(ax, float(draw_x), float(draw_y)) == 1.0
105+
finally:
106+
plt.close(fig)
107+
108+
109+
@pytest.mark.parametrize("imshow_origin", ["upper", "lower"], indirect=True)
110+
def test__plot_inversion_reconstruction__uniform_mesh_grid_lands_on_pixel(
111+
imshow_origin, rectangular_mapper_7x7_3x3
112+
):
113+
mapper = rectangular_mapper_7x7_3x3
114+
115+
# One off-centre bright source pixel (row 0, column 1 of the 3x3 mesh).
116+
pixel_values = np.zeros(9)
117+
pixel_values[1] = 1.0
118+
119+
mesh = aa.Array2D.no_mask(
120+
values=np.zeros(mapper.mesh_geometry.shape),
121+
pixel_scales=mapper.mesh_geometry.pixel_scales,
122+
origin=mapper.mesh_geometry.origin,
123+
)
124+
y, x = _native_grid(mesh)[0, 1]
125+
126+
fig, ax = plt.subplots()
127+
try:
128+
aplt.plot_inversion_reconstruction(
129+
pixel_values=pixel_values,
130+
mapper=mapper,
131+
grid=np.array([[y, x]]),
132+
zoom_to_brightest=False,
133+
ax=ax,
134+
)
135+
136+
assert ax.images[0].origin == imshow_origin
137+
138+
draw_x, draw_y = ax.collections[-1].get_offsets()[0]
139+
140+
assert _pixel_value_under(ax, float(draw_x), float(draw_y)) == 1.0
141+
finally:
142+
plt.close(fig)

0 commit comments

Comments
 (0)