Skip to content

Commit d59c6dd

Browse files
authored
Merge pull request #661 from PyAutoLabs/feature/cosmos-web-ring-intro-visuals
feat: four-panel introductory imaging plot using standard styling
2 parents 7eba0c0 + cd26722 commit d59c6dd

4 files changed

Lines changed: 218 additions & 6 deletions

File tree

‎autoarray/dataset/plot/imaging_plots.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,97 @@ def subplot_imaging_dataset(
146146

147147

148148

149+
def _intro_panel(array, radius=None, excluded_pixels=None):
150+
"""Prepare a display-only crop with excluded values missing, not zeroed.
151+
152+
Preserve the native pixel/coordinate registration; fitting arrays are never
153+
mutated. Coordinates, ticks and colour mapping remain the renderer's job.
154+
"""
155+
import numpy as np
156+
from autoarray.plot.utils import zoom_array
157+
158+
if radius is not None and (not np.isfinite(radius) or radius <= 0):
159+
raise ValueError("radius must be a finite positive number")
160+
if radius is None and excluded_pixels is None:
161+
array = zoom_array(array)
162+
values = np.array(array.native.array, dtype=float, copy=True)
163+
values[np.asarray(array.mask.array, dtype=bool)] = np.nan
164+
if excluded_pixels is not None:
165+
excluded = np.asarray(excluded_pixels, dtype=bool)
166+
if excluded.shape != values.shape:
167+
raise ValueError("excluded_pixels must have the dataset image shape")
168+
values[excluded] = np.nan
169+
xmin, xmax, ymin, ymax = array.geometry.extent
170+
if radius is not None:
171+
ny, nx = values.shape
172+
dx, dy = (xmax - xmin) / nx, (ymax - ymin) / ny
173+
x = xmin + (np.arange(nx) + 0.5) * dx
174+
y = ymax - (np.arange(ny) + 0.5) * dy
175+
rows, cols = np.flatnonzero(abs(y) <= radius), np.flatnonzero(abs(x) <= radius)
176+
if not len(rows) or not len(cols):
177+
raise ValueError("radius does not include any pixel centres")
178+
r0, r1, c0, c1 = rows[0], rows[-1] + 1, cols[0], cols[-1] + 1
179+
values = values[r0:r1, c0:c1]
180+
xmin, xmax, ymin, ymax = xmin + c0 * dx, xmin + c1 * dx, ymax - r1 * dy, ymax - r0 * dy
181+
return values, (xmin, xmax, ymin, ymax)
182+
183+
184+
def subplot_imaging_dataset_intro(
185+
dataset,
186+
output_path: Optional[str] = None,
187+
output_filename: str = "dataset_intro",
188+
output_format: str = None,
189+
colormap=None,
190+
stretch: Optional[float] = 0.01,
191+
radius: Optional[float] = None,
192+
excluded_pixels=None,
193+
):
194+
"""Four-panel introduction: Image, Noise map, Signal-to-noise map and PSF.
195+
196+
Uses the ordinary plot renderer and configured colormap, fonts, titles,
197+
arcsecond ticks, colourbars and saving. Unlike the diagnostic 3x3 subplot,
198+
it omits duplicate log panels and over-sampling maps. Numerical sampling
199+
and the dataset itself are unchanged.
200+
201+
``stretch`` is the image's asinh linear-width fraction: smaller values
202+
reveal faint structure next to bright light; None selects linear display.
203+
The S/N and PSF panels use asinh fractions 0.1 and 0.003, respectively;
204+
noise remains linear. All colourbars retain original measurement values.
205+
``colormap=None`` inherits configuration on every panel.
206+
207+
``radius`` optionally crops the first three panels about (0, 0), in
208+
arcseconds, for display only. None follows the usual mask-zoom convention.
209+
``excluded_pixels`` is a native-shaped boolean array (True = exclude) for
210+
deliberately down-weighted neighbours. These pixels, dataset-masked values
211+
and non-finite measurements use the colormap's standard bad-value colour;
212+
they do not determine the display scale. No clipping or data mutation.
213+
"""
214+
from autoarray.plot.array import plot_array
215+
from autoarray.plot.utils import apply_labels
216+
217+
panels = [
218+
(dataset.data, "Image", stretch, None),
219+
(dataset.noise_map, "Noise map", None, None),
220+
(dataset.signal_to_noise_map, "Signal-to-noise map", 0.1, ""),
221+
]
222+
prepared = [(_intro_panel(array, radius, excluded_pixels), title, scale, unit)
223+
for array, title, scale, unit in panels]
224+
fig, axes = subplots(2, 2, figsize=conf_subplot_figsize(2, 2))
225+
for ax, ((values, extent), title, scale, unit) in zip(axes.flat, prepared):
226+
plot_array(values, ax=ax, extent=extent, title=title,
227+
colormap=colormap, stretch=scale, cb_unit=unit)
228+
if dataset.psf is not None:
229+
plot_array(dataset.psf.kernel, ax=axes[1, 1], title="PSF",
230+
colormap=colormap, stretch=0.003, cb_unit="")
231+
else:
232+
apply_labels(axes[1, 1], title="PSF", is_subplot=True)
233+
axes[1, 1].text(0.5, 0.5, "No PSF supplied", ha="center", va="center",
234+
transform=axes[1, 1].transAxes)
235+
tight_layout()
236+
subplot_save(fig, output_path, output_filename, output_format)
237+
return fig
238+
239+
149240
def subplot_imaging_dataset_list(
150241
dataset_list,
151242
output_path=None,

‎autoarray/plot/array.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def plot_array(
6969
output_path: Optional[str] = None,
7070
output_filename: str = "array",
7171
output_format: str = None,
72+
stretch: Optional[float] = None,
7273
) -> None:
7374
"""
7475
Plot a 2D array (image) using ``plt.imshow``.
@@ -127,6 +128,10 @@ def plot_array(
127128
Explicit color scale limits.
128129
use_log10
129130
When ``True`` a ``LogNorm`` is applied.
131+
stretch
132+
Optional positive asinh linear-width fraction of the displayed range,
133+
e.g. 0.01 to reveal faint structure beside a bright peak. Only changes
134+
the colour mapping; incompatible with ``use_log10=True``.
130135
origin_imshow
131136
Passed directly to ``imshow`` (``"upper"`` or ``"lower"``).
132137
arcsec_after_decimal
@@ -194,7 +199,7 @@ def plot_array(
194199
fig = ax.get_figure()
195200

196201
# --- colour normalisation --------------------------------------------------
197-
norm = norm_from(array=array, use_log10=use_log10, vmin=vmin, vmax=vmax)
202+
norm = norm_from(array=array, use_log10=use_log10, vmin=vmin, vmax=vmax, stretch=stretch)
198203

199204
# Compute the axes-box aspect ratio from the data extent so that the
200205
# physical cell is correctly shaped and tight_layout has no whitespace

‎autoarray/plot/utils.py‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,17 @@ def symmetric_cmap_from(array, symmetric_value=None):
255255
If given, fix the half-range to this value (``vmin=-symmetric_value``,
256256
``vmax=+symmetric_value``).
257257
258+
stretch
259+
Optional positive asinh linear-width fraction of the displayed range.
260+
Smaller values bring up faint structure; negative values remain defined.
261+
Mutually exclusive with ``use_log10``. ``None`` preserves the existing
262+
linear/log behavior.
263+
264+
stretch : float or None
265+
Positive asinh linear-width fraction of the displayed value range.
266+
Mutually exclusive with ``use_log10``. Non-finite values are ignored
267+
when deriving limits. ``None`` retains the existing linear/log scale.
268+
258269
Returns
259270
-------
260271
matplotlib.colors.Normalize or None
@@ -272,7 +283,7 @@ def symmetric_cmap_from(array, symmetric_value=None):
272283
return colors.Normalize(vmin=-abs_max, vmax=abs_max)
273284

274285

275-
def norm_from(array=None, use_log10=False, vmin=None, vmax=None):
286+
def norm_from(array=None, use_log10=False, vmin=None, vmax=None, stretch=None):
276287
"""Build the matplotlib colour norm for *array* from flat arguments.
277288
278289
The single implementation behind every colour scale the plot functions
@@ -307,8 +318,8 @@ def norm_from(array=None, use_log10=False, vmin=None, vmax=None):
307318
Parameters
308319
----------
309320
array
310-
The values being coloured. Only read when *use_log10* is ``True`` and
311-
no finite *vmax* is given; ``None`` is accepted and takes the fallback.
321+
The values being coloured. Read for log or asinh scaling when limits
322+
are omitted; ``None`` is accepted and takes a finite fallback.
312323
use_log10
313324
When ``True`` a ``LogNorm`` is returned, floored at the configured
314325
``log10_min_value``.
@@ -320,6 +331,22 @@ def norm_from(array=None, use_log10=False, vmin=None, vmax=None):
320331
-------
321332
matplotlib.colors.Normalize or None
322333
"""
334+
if stretch is not None:
335+
if use_log10:
336+
raise ValueError("stretch and use_log10 are mutually exclusive")
337+
if not np.isfinite(stretch) or stretch <= 0:
338+
raise ValueError("stretch must be a finite positive fraction")
339+
from matplotlib.colors import AsinhNorm
340+
341+
values = np.ma.masked_invalid(np.ma.asarray(array, dtype=float)).compressed() if array is not None else np.array([])
342+
lo = float(vmin) if vmin is not None else (float(values.min()) if values.size else 0.0)
343+
hi = float(vmax) if vmax is not None else (float(values.max()) if values.size else 1.0)
344+
if not np.isfinite(lo) or not np.isfinite(hi):
345+
raise ValueError("asinh colour limits must be finite")
346+
if hi <= lo:
347+
hi = lo + max(abs(lo) * 0.01, 1e-6)
348+
return AsinhNorm(linear_width=stretch * (hi - lo), vmin=lo, vmax=hi)
349+
323350
if use_log10:
324351
log10_min = _conf_log10_min_value()
325352

@@ -693,8 +720,8 @@ def _colorbar_tick_values(norm) -> Optional[List[float]]:
693720
import matplotlib.colors as mcolors
694721

695722
lo, hi = float(norm.vmin), float(norm.vmax)
696-
if isinstance(norm, mcolors.LogNorm):
697-
mid = 10 ** ((np.log10(lo) + np.log10(hi)) / 2.0)
723+
if isinstance(norm, (mcolors.LogNorm, mcolors.AsinhNorm)):
724+
mid = float(norm.inverse(0.5))
698725
else:
699726
mid = (lo + hi) / 2.0
700727
return [lo, mid, hi]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""The intro compositor uses the ordinary renderer, style and configuration."""
2+
import numpy as np
3+
import matplotlib.pyplot as plt
4+
import pytest
5+
from matplotlib.colors import AsinhNorm
6+
from autonerves import conf
7+
import autoarray as aa
8+
from autoarray.plot.array import plot_array
9+
from autoarray.plot.utils import norm_from, _colorbar_tick_values
10+
from autoarray.dataset.plot.imaging_plots import subplot_imaging_dataset_intro, _intro_panel
11+
12+
13+
def dataset():
14+
values = np.arange(81, dtype=float).reshape(9, 9) - 3
15+
return aa.Imaging(
16+
data=aa.Array2D.no_mask(values, pixel_scales=0.1),
17+
noise_map=aa.Array2D.no_mask(np.ones((9, 9)), pixel_scales=0.1),
18+
psf=aa.Convolver(kernel=aa.Array2D.no_mask(np.ones((3, 3)) / 9, pixel_scales=0.1)),
19+
)
20+
21+
22+
def test_intro_inherits_config_and_standard_style(tmp_path, monkeypatch):
23+
general = conf.instance["visualize"]["general"]
24+
monkeypatch.setitem(general, "colormap", "cividis")
25+
d = dataset()
26+
original = d.data.native.array.copy()
27+
fig = subplot_imaging_dataset_intro(d, output_path=tmp_path, output_format="png")
28+
panels = [ax for ax in fig.axes if ax.images]
29+
assert [ax.get_title() for ax in panels] == ["Image", "Noise map", "Signal-to-noise map", "PSF"]
30+
assert all(ax.images[0].get_cmap().name == "cividis" for ax in panels)
31+
direct, ax = plt.subplots()
32+
values, extent = _intro_panel(d.data)
33+
plot_array(values, extent=extent, ax=ax, title="Image", stretch=0.01)
34+
assert ax.title.get_fontsize() == panels[0].title.get_fontsize()
35+
assert [t.get_text() for t in ax.get_xticklabels()] == [t.get_text() for t in panels[0].get_xticklabels()]
36+
assert ax.get_yticklabels()[0].get_rotation() == panels[0].get_yticklabels()[0].get_rotation()
37+
assert isinstance(panels[0].images[0].norm, AsinhNorm)
38+
np.testing.assert_array_equal(d.data.native.array, original)
39+
assert (tmp_path / 'dataset_intro.png').is_file()
40+
plt.close(direct)
41+
42+
43+
def test_explicit_colormap_and_excluded_noise(tmp_path):
44+
d = dataset()
45+
excluded = np.zeros((9, 9), dtype=bool)
46+
excluded[2, 3] = True
47+
noise = np.ones((9, 9))
48+
noise[2, 3] = 1e8
49+
d = aa.Imaging(data=d.data, noise_map=aa.Array2D.no_mask(noise, pixel_scales=.1), psf=d.psf)
50+
fig = subplot_imaging_dataset_intro(d, colormap="magma", radius=0.25,
51+
excluded_pixels=excluded, output_path=tmp_path, output_format="png")
52+
panels = [ax for ax in fig.axes if ax.images]
53+
assert all(ax.images[0].get_cmap().name == "magma" for ax in panels)
54+
assert panels[1].images[0].norm.vmax < 2
55+
assert np.ma.getmaskarray(panels[0].images[0].get_array()).any()
56+
assert d.noise_map.native.array[2, 3] == 1e8
57+
58+
59+
def test_crop_preserves_pixel_coordinates():
60+
d = dataset()
61+
values, extent = _intro_panel(d.data, radius=0.25)
62+
assert values.shape == (5, 5)
63+
np.testing.assert_allclose(extent, [-0.25, 0.25, -0.25, 0.25])
64+
assert values[2, 2] == d.data.native.array[4, 4]
65+
66+
67+
@pytest.mark.parametrize('values', [np.zeros((2, 2)), np.full((2, 2), np.nan), np.array([-2., 0., 100.])])
68+
def test_asinh_range_is_finite_and_invertible(values):
69+
norm = norm_from(values, stretch=0.01)
70+
assert np.isfinite([norm.vmin, norm.vmax]).all()
71+
assert norm.vmax > norm.vmin
72+
ticks = _colorbar_tick_values(norm)
73+
np.testing.assert_allclose(norm(ticks), [0, 0.5, 1], atol=1e-10)
74+
75+
76+
@pytest.mark.parametrize('stretch', [0, -1, np.nan, np.inf])
77+
def test_invalid_stretch_rejected(stretch):
78+
with pytest.raises(ValueError, match='stretch'):
79+
norm_from(np.ones(3), stretch=stretch)
80+
81+
82+
def test_asinh_cannot_be_combined_with_log():
83+
with pytest.raises(ValueError, match='mutually exclusive'):
84+
norm_from([1, 2], stretch=.01, use_log10=True)
85+
86+
87+
def test_ordinary_norm_remains_unchanged():
88+
assert norm_from([1, 2]) is None
89+
assert not isinstance(norm_from([1, 2], use_log10=True), AsinhNorm)

0 commit comments

Comments
 (0)