From d58aeebce891df492e38f248b7dc6e0bba69cc32 Mon Sep 17 00:00:00 2001 From: samlange04 Date: Fri, 18 Sep 2026 14:43:42 +0800 Subject: [PATCH 1/8] feat(gui): Scribbler radial-subtracted side-by-side panels; cross-grid mask regrid New `autogalaxy/gui/display_util.py` (pure numpy, unit-tested): - `radial_median_subtract(array, exclude=None)`: subtract the azimuthally-averaged radial median profile, a display transform that lifts lensed arcs and companions out from under a smooth galaxy's light. `exclude` keeps a known contaminant out of the ring statistics so it cannot stamp a dark annulus at its own radius. - `mask_boundary`: 1-px inner outline of a mask. - `composite_panels` / `fold_panels`: lay panels side by side with a gutter, and fold a scribble drawn on the composite back onto one grid. - `mask_regridded_from` / `mask_2d_regridded_from`: nearest-neighbour transfer of a boolean mask between uniform grids of different pixel scale / size by scaled coordinates (no WCS), so a mask drawn on one waveband can seed another. Long-term home is autoarray.mask; kept here while the GUI is its only caller. `Scribbler(subtract_radial=True, side_by_side=True)` displays [radial-subtracted | as-observed], each panel colour-scaled independently, with any `proposal` outlined on both. Strokes on either panel fold onto the image's own grid, so `get_scribble_masks()` / `mask_from()` always return image-shaped arrays. The single-panel display path is unchanged. Co-Authored-By: Claude Fable 5.1 --- autogalaxy/gui/display_util.py | 194 +++++++++++++++++++++++ autogalaxy/gui/scribbler.py | 192 ++++++++++++++++++---- test_autogalaxy/gui/test_display_util.py | 174 ++++++++++++++++++++ test_autogalaxy/gui/test_scribbler.py | 47 ++++++ 4 files changed, 575 insertions(+), 32 deletions(-) create mode 100644 autogalaxy/gui/display_util.py create mode 100644 test_autogalaxy/gui/test_display_util.py diff --git a/autogalaxy/gui/display_util.py b/autogalaxy/gui/display_util.py new file mode 100644 index 00000000..73335e55 --- /dev/null +++ b/autogalaxy/gui/display_util.py @@ -0,0 +1,194 @@ +""" +Display-side helpers for the mask-drawing GUIs. + +Everything here is a finding aid for the person drawing a mask, never a data product: the +`Scribbler` reads a mask back from brush positions, so none of these transforms can change +what a given stroke masks. + +`mask_regridded_from` / `mask_2d_regridded_from` transfer a mask between two uniform grids +of different pixel scale or size (e.g. two wavebands of the same object), so a mask drawn +on one can seed the next. Its long-term home is `autoarray.mask`; it lives here for now +because the GUI is its only caller. +""" + +from typing import List, Optional, Sequence, Tuple + +import numpy as np + +import autoarray as aa + + +def radial_median_subtract( + array_native: np.ndarray, exclude: Optional[np.ndarray] = None +) -> np.ndarray: + """ + Subtract the azimuthally-averaged (median) radial profile about the array centre. + + A smooth, near-circular galaxy is almost entirely a function of radius, while lensed + arcs, companions and other structure are not; subtracting the per-radius median + removes the galaxy and leaves that structure standing out, including what is buried + inside the galaxy's envelope. It is a finding aid, not a fit: real ellipticity leaves a + quadrupole residual and bright structure biases the median at its own radius. + + Parameters + ---------- + array_native + The 2D image. + exclude + Boolean array of the same shape; `True` pixels are dropped from the per-radius + median (but still have the profile subtracted). Use it to keep a known + contaminant from dragging its radius' median up and stamping a dark annulus + across the image at that radius. + + Returns + ------- + The image minus its radial median profile, as a float array of the same shape. + """ + a = np.asarray(array_native, dtype=float) + n_y, n_x = a.shape + yy, xx = np.mgrid[0:n_y, 0:n_x] + r_bin = np.hypot(yy - (n_y - 1) / 2.0, xx - (n_x - 1) / 2.0).astype(int) + usable = np.isfinite(a) + if exclude is not None: + usable &= ~np.asarray(exclude, dtype=bool) + profile = np.zeros(r_bin.max() + 1) + for i in range(profile.size): + ring = a[(r_bin == i) & usable] + if ring.size: + profile[i] = np.median(ring) + return a - profile[r_bin] + + +def mask_boundary(mask: np.ndarray) -> np.ndarray: + """ + The 1-pixel inner boundary of a boolean mask (`mask & ~erosion(mask)`), for outlining + it over an image. The outline lies on masked pixels; a mask running off the array edge + is not outlined along the edge itself. + """ + from scipy.ndimage import binary_erosion + + m = np.asarray(mask, dtype=bool) + if not m.any(): + return m + return m & ~binary_erosion(m, np.ones((3, 3), dtype=bool), border_value=1) + + +def composite_panels( + panels: Sequence[np.ndarray], gap: int = 6, fill: float = 0.0 +) -> np.ndarray: + """ + Lay 2D panels of equal height side by side, separated by `gap` blank columns of value + `fill`. The inverse of `fold_panels`. + """ + panels = [np.asarray(p) for p in panels] + if len(panels) == 1: + return panels[0] + gutter = np.full((panels[0].shape[0], gap), fill, dtype=float) + parts: List[np.ndarray] = [] + for i, p in enumerate(panels): + if i: + parts.append(gutter) + parts.append(p) + return np.hstack(parts) + + +def fold_panels( + scribbled: np.ndarray, n_panels: int, panel_n_x: int, gap: int = 6 +) -> Tuple[np.ndarray, List[int]]: + """ + Fold a boolean array drawn on a `composite_panels` display back onto one panel's grid. + + Every panel shows the same pixels, so a stroke on any of them means the same thing: + the per-panel slices are OR-ed together. + + Returns + ------- + The folded (n_y, panel_n_x) boolean array and the number of True pixels per panel. + """ + scribbled = np.asarray(scribbled, dtype=bool) + if n_panels <= 1: + return scribbled, [int(scribbled.sum())] + step = panel_n_x + gap + out = np.zeros((scribbled.shape[0], panel_n_x), dtype=bool) + per_panel = [] + for i in range(n_panels): + s = scribbled[:, i * step : i * step + panel_n_x] + per_panel.append(int(s.sum())) + out |= s + return out, per_panel + + +def mask_regridded_from( + mask_native: np.ndarray, + src_pixel_scales, + dst_shape_native: Tuple[int, int], + dst_pixel_scales, + src_origin: Tuple[float, float] = (0.0, 0.0), + dst_origin: Tuple[float, float] = (0.0, 0.0), +) -> np.ndarray: + """ + Transfer a boolean mask from one uniform grid to another by nearest neighbour, so it + covers the same region in scaled (arc-second) coordinates. + + Each destination pixel centre is converted to scaled coordinates, then to the source + pixel containing it, whose value is taken. Nearest neighbour is the right resampling + for a boolean: there is no interpolation across the True / False boundary. Destination + pixels outside the source footprint are `False` (unmasked). + + Parameters + ---------- + mask_native + The source boolean mask, in native 2D form. + src_pixel_scales + The source grid's (y,x) pixel scales (a float is used for both). + dst_shape_native + The (y,x) shape of the destination grid. + dst_pixel_scales + The destination grid's (y,x) pixel scales. + src_origin, dst_origin + The (y,x) scaled-coordinate origins of the two grids. + """ + mask_native = np.asarray(mask_native, dtype=bool) + src_shape = mask_native.shape + src_ps = aa.util.geometry.convert_pixel_scales_2d(pixel_scales=src_pixel_scales) + dst_ps = aa.util.geometry.convert_pixel_scales_2d(pixel_scales=dst_pixel_scales) + n_y, n_x = (int(dst_shape_native[0]), int(dst_shape_native[1])) + + # Destination pixel centres in scaled coordinates. Row 0 is the most positive y + # (autoarray's native convention), column 0 the most negative x. + rows_d, cols_d = np.mgrid[0:n_y, 0:n_x] + y = dst_origin[0] + (n_y / 2.0 - rows_d - 0.5) * dst_ps[0] + x = dst_origin[1] + (cols_d + 0.5 - n_x / 2.0) * dst_ps[1] + + # The source pixel each centre falls in: measured from the source grid's top / left + # edges and floored, so a centre exactly on a pixel edge is assigned consistently. + y_top = src_origin[0] + (src_shape[0] / 2.0) * src_ps[0] + x_left = src_origin[1] - (src_shape[1] / 2.0) * src_ps[1] + rows = np.floor((y_top - y) / src_ps[0]).astype(int) + cols = np.floor((x - x_left) / src_ps[1]).astype(int) + + inside = (rows >= 0) & (rows < src_shape[0]) & (cols >= 0) & (cols < src_shape[1]) + out = np.zeros((n_y, n_x), dtype=bool) + out[inside] = mask_native[rows[inside], cols[inside]] + return out + + +def mask_2d_regridded_from( + mask: aa.Mask2D, + shape_native: Tuple[int, int], + pixel_scales, + origin: Tuple[float, float] = (0.0, 0.0), +) -> aa.Mask2D: + """ + `mask_regridded_from` for an `aa.Mask2D`: returns a `Mask2D` on the new grid covering + the same scaled-coordinate region. + """ + regridded = mask_regridded_from( + mask_native=np.asarray(mask), + src_pixel_scales=mask.pixel_scales, + dst_shape_native=shape_native, + dst_pixel_scales=pixel_scales, + src_origin=mask.origin, + dst_origin=origin, + ) + return aa.Mask2D(mask=regridded, pixel_scales=pixel_scales, origin=origin) diff --git a/autogalaxy/gui/scribbler.py b/autogalaxy/gui/scribbler.py index 2a1953df..46e6a57d 100644 --- a/autogalaxy/gui/scribbler.py +++ b/autogalaxy/gui/scribbler.py @@ -1,8 +1,14 @@ from collections import OrderedDict -from typing import Optional, Tuple +from typing import List, Optional, Tuple import numpy as np +from autogalaxy.gui.display_util import ( + composite_panels, + fold_panels, + radial_median_subtract, +) + class Scribbler: #: Fixed roles of the two default scribble segments. Press the key to switch brush. @@ -32,6 +38,9 @@ def __init__( proposal: Optional[np.ndarray] = None, brush_resize_factor: float = 1.4, min_radius: int = 1, + subtract_radial: bool = False, + side_by_side: bool = True, + panel_gap: int = 6, block: bool = True, ): """ @@ -53,12 +62,26 @@ def __init__( shape, True = masked) to REFINE an existing mask instead of drawing from scratch: its boundary is outlined in white over the image and, after the GUI closes, `mask_from()` returns `(proposal | added) & ~erased`. A proposal can be a mask - drawn earlier for this image, or one drawn for another waveband of the same object. + drawn earlier for this image, or one drawn for another waveband of the same object + (see `autogalaxy.gui.display_util.mask_regridded_from` for moving a mask between + grids of different pixel scale). + + **Seeing under the galaxy.** With `subtract_radial=True` the image's + azimuthally-averaged radial profile is subtracted for display (see + `display_util.radial_median_subtract`), which lifts lensed arcs and companions out + from under a smooth galaxy's light. With `side_by_side=True` (the default) the + subtracted image is shown on the LEFT and the image as observed on the RIGHT, + separated by a blank gutter, since each answers a different question: where faint + structure is, and where a contaminant's real extent and the galaxy's envelope are. + Strokes on either panel are folded onto the one image grid, so painting on the + right masks the same pixels as painting on the left. Each panel is colour-scaled + independently. This is a display transform only: the mask is read back from brush + positions, so it cannot change what a stroke masks. **Reading the result.** `mask_from()` returns the combined mask described above (with no proposal, simply `added & ~erased`); `show_mask()` returns the ADD segment alone, for backwards compatibility; `get_scribble_masks()` returns every segment as - its own boolean array. + its own boolean array. All are on the image's own grid, whatever is displayed. Parameters ---------- @@ -89,13 +112,20 @@ def __init__( rgb_image An optional RGB image shown beside the data. extent - The (x0, x1, y0, y1) scaled-coordinate extent to zoom the display to. + The (x0, x1, y0, y1) scaled-coordinate extent to zoom the display to. With + side-by-side panels it applies to the left panel's pixel coordinates. proposal An existing boolean mask (True = masked) to outline and refine, see above. brush_resize_factor The factor the brush radius is multiplied / divided by per `'='` / `'-'` press. min_radius The smallest brush radius in pixels. + subtract_radial + Display the image with its radial median profile subtracted, see above. + side_by_side + With `subtract_radial`, also show the as-observed image in a second panel. + panel_gap + The width in pixels of the blank gutter between side-by-side panels. block If `True` (the default) the constructor opens the window and blocks until it is closed. If `False` the figure is built but the event loop is not started; call @@ -134,6 +164,7 @@ def __init__( extent = (x0_pix, x1_pix, y0_pix, y1_pix) self.im = image + self.image_shape = tuple(np.asarray(image).shape[:2]) self.backend = backend self.figsize = figsize self.extent = extent @@ -142,8 +173,15 @@ def __init__( self._validate_proposal(proposal) if proposal is not None else None ) + # display panels (the mask is always read back on the image's own grid) + self.subtract_radial = subtract_radial + self.side_by_side = side_by_side + self.panel_gap = panel_gap + self.panel_names: List[str] = [] + self.display = None + # brush - self.brush_radius = max(int(image.shape_native[0] * brush_width), min_radius) + self.brush_radius = max(int(self.image_shape[0] * brush_width), min_radius) self.min_radius = min_radius self.brush_resize_factor = brush_resize_factor self.brush_color = "b" @@ -180,13 +218,50 @@ def _validate_proposal(self, proposal) -> np.ndarray: silently broadcast in `mask_from()`). """ proposal = np.asarray(proposal, dtype=bool) - if proposal.shape != tuple(self.im.shape_native): + if proposal.shape != self.image_shape: raise ValueError( f"proposal shape {proposal.shape} does not match the image's native " - f"shape {tuple(self.im.shape_native)}" + f"shape {self.image_shape}" ) return proposal + @property + def n_panels(self) -> int: + return len(self.panel_names) + + def _panels(self) -> List[Tuple[str, np.ndarray]]: + """ + The (name, values) display panels: the image alone, or its radial-subtracted + version optionally beside it. + """ + values = np.asarray(self.im, dtype=float) + if not self.subtract_radial: + return [("image", values)] + panels = [("radial-subtracted", radial_median_subtract(values))] + if self.side_by_side: + panels.append(("as-observed", values)) + return panels + + @staticmethod + def _normalised(values, cmap, norm, vmin, vmax) -> np.ndarray: + """ + Map one panel's values onto [0, 1] with the same colour scaling the single-panel + display uses, so panels of very different dynamic range can share one image. + """ + import matplotlib.colors + + if hasattr(cmap, "norm_from"): + mpl_norm = cmap.norm_from(array=values) + else: + from autogalaxy.util.plot_utils import norm_from + + mpl_norm = norm_from( + array=values, use_log10=norm == "log", vmin=vmin, vmax=vmax + ) + if mpl_norm is None: + mpl_norm = matplotlib.colors.Normalize() + return np.ma.filled(mpl_norm(np.nan_to_num(values)), 0.0) + def _build_figure(self, cmap, norm, vmin, vmax, mask_overlay, rgb_image): """ Build the matplotlib figure, draw the image (and any proposal outline, mask edge or @@ -199,6 +274,8 @@ def _build_figure(self, cmap, norm, vmin, vmax, mask_overlay, rgb_image): matplotlib.use(self.backend) image = self.im + panels = self._panels() + self.panel_names = [name for name, _ in panels] # create initial plot self.figure = plt.figure(figsize=self.figsize) @@ -212,35 +289,62 @@ def _build_figure(self, cmap, norm, vmin, vmax, mask_overlay, rgb_image): plt.imshow(rgb_image, origin=_conf_imshow_origin()) self.ax = self.figure.add_subplot(111) - if cmap is None and norm is None and vmin is None and vmax is None: - plt.imshow(image, interpolation="none", origin=_conf_imshow_origin()) - elif hasattr(cmap, "norm_from"): - # Legacy `Cmap`-style object. The public plot namespaces no longer - # export one, but a caller holding an instance still works. - mpl_norm = cmap.norm_from(array=image) - cmap_name = getattr(cmap, "cmap_name", None) or cmap.config_dict.get( - "cmap", "viridis" - ) - plt.imshow( - image, cmap=cmap_name, norm=mpl_norm, origin=_conf_imshow_origin() - ) + if self.n_panels == 1: + self.display = np.asarray(image) + if cmap is None and norm is None and vmin is None and vmax is None: + plt.imshow(image, interpolation="none", origin=_conf_imshow_origin()) + elif hasattr(cmap, "norm_from"): + # Legacy `Cmap`-style object. The public plot namespaces no longer + # export one, but a caller holding an instance still works. + mpl_norm = cmap.norm_from(array=image) + cmap_name = getattr(cmap, "cmap_name", None) or cmap.config_dict.get( + "cmap", "viridis" + ) + plt.imshow( + image, cmap=cmap_name, norm=mpl_norm, origin=_conf_imshow_origin() + ) + else: + from autogalaxy.util.plot_utils import _resolve_colormap, norm_from + + mpl_norm = norm_from( + array=image, use_log10=norm == "log", vmin=vmin, vmax=vmax + ) + plt.imshow( + image, + cmap=_resolve_colormap(cmap), + norm=mpl_norm, + origin=_conf_imshow_origin(), + ) else: - from autogalaxy.util.plot_utils import _resolve_colormap, norm_from + from autogalaxy.util.plot_utils import _resolve_colormap - mpl_norm = norm_from( - array=image, use_log10=norm == "log", vmin=vmin, vmax=vmax + self.display = composite_panels( + [self._normalised(v, cmap, norm, vmin, vmax) for _, v in panels], + gap=self.panel_gap, ) + if hasattr(cmap, "norm_from"): + cmap_name = getattr(cmap, "cmap_name", None) or cmap.config_dict.get( + "cmap", "viridis" + ) + else: + cmap_name = _resolve_colormap(cmap) plt.imshow( - image, - cmap=_resolve_colormap(cmap), - norm=mpl_norm, + self.display, + cmap=cmap_name, + vmin=0.0, + vmax=1.0, + interpolation="none", origin=_conf_imshow_origin(), ) if mask_overlay is not None: grid = mask_overlay.derive_grid.edge grid = mask_overlay.geometry.grid_pixel_centres_2d_from(grid_scaled_2d=grid) - plt.scatter(y=grid[:, 0], x=grid[:, 1], c="k", marker="x", s=10) + for i in range(self.n_panels): + x_offset = i * (self.image_shape[1] + self.panel_gap) + plt.scatter( + y=grid[:, 0], x=grid[:, 1] + x_offset, c="k", marker="x", s=10 + ) self._proposal_contour = None if self.proposal is not None and self.proposal.any(): @@ -248,14 +352,23 @@ def _build_figure(self, cmap, norm, vmin, vmax, mask_overlay, rgb_image): # is being judged against. No `origin=`: without X / Y, contour places Z[0, 0] # at data (0, 0), which is where imshow draws pixel [0, 0] for either origin # (contour's own `origin="upper"` would flip the outline vertically). + outline = composite_panels( + [self.proposal.astype(float)] * self.n_panels, gap=self.panel_gap + ) self._proposal_contour = self.ax.contour( - self.proposal.astype(float), + outline, levels=[0.5], colors="w", linewidths=1.0, ) - self.ax.set_title(self.KEY_LEGEND, fontsize=10) + title = self.KEY_LEGEND + if self.n_panels > 1: + title = ( + f"LEFT: {self.panel_names[0]} | RIGHT: {self.panel_names[1]}" + f" -- scribble on either\n{title}" + ) + self.ax.set_title(title, fontsize=10) plt.axis(self.extent) plt.axis("off") @@ -279,6 +392,11 @@ def start(self): import matplotlib.pyplot as plt print(f"Scribbler keys: {self.KEY_LEGEND}") + if self.n_panels > 1: + print( + f" panels: LEFT {self.panel_names[0]}, RIGHT {self.panel_names[1]} " + f"-- scribble on either, both land on the same pixels" + ) print(f" brush radius = {self.brush_radius} px") plt.ion() @@ -440,23 +558,33 @@ def mask_from(self, proposal: Optional[np.ndarray] = None) -> np.ndarray: def add_circle_to_mask(self, center, radius, mask): if center[0] is None or center[1] is None: return - xx, yy = np.mgrid[: self.im.shape[0], : self.im.shape[1]] + xx, yy = np.mgrid[: mask.shape[0], : mask.shape[1]] circle_mask = (xx - center[1]) ** 2 + (yy - center[0]) ** 2 <= radius**2 mask[circle_mask] = 1 def circles_to_mask(self, centers, radii): - mask = np.zeros(self.im.shape[:2], dtype=bool) + # Rasterised on the DISPLAY (which may hold several panels), then folded back + # onto the image grid by `get_scribble_masks`. + mask = np.zeros(self.display.shape[:2], dtype=bool) for center, radius in zip(centers, radii): self.add_circle_to_mask(center, radius, mask) return mask def get_scribble_masks(self): + """ + Every scribble segment as a boolean array on the image's own grid. Strokes on any + side-by-side panel are OR-ed together, so painting the right panel masks the same + pixels as painting the left. + """ masks = {} for name, scribble in self.scribbles.items(): if len(scribble) == 0: - masks[name] = np.zeros(self.im.shape, dtype=bool) + masks[name] = np.zeros(self.image_shape, dtype=bool) else: centers = [circle.center for circle in scribble] radii = [circle.radius for circle in scribble] - masks[name] = self.circles_to_mask(centers, radii) + raster = self.circles_to_mask(centers, radii) + masks[name], _ = fold_panels( + raster, self.n_panels, self.image_shape[1], gap=self.panel_gap + ) return masks diff --git a/test_autogalaxy/gui/test_display_util.py b/test_autogalaxy/gui/test_display_util.py new file mode 100644 index 00000000..4479b49e --- /dev/null +++ b/test_autogalaxy/gui/test_display_util.py @@ -0,0 +1,174 @@ +import numpy as np +import pytest + +import autoarray as aa +from autogalaxy.gui import display_util as du + + +def _radius_grid(shape): + yy, xx = np.mgrid[0 : shape[0], 0 : shape[1]] + return np.hypot(yy - (shape[0] - 1) / 2.0, xx - (shape[1] - 1) / 2.0) + + +class TestRadialMedianSubtract: + def test__pure_radial_profile_is_removed(self): + r = _radius_grid((41, 41)) + image = np.exp(-r.astype(int) / 5.0) # constant within each integer radius bin + + residual = du.radial_median_subtract(image) + + assert residual == pytest.approx(np.zeros_like(image), abs=1e-12) + + def test__off_centre_source_survives_and_nans_are_ignored(self): + r = _radius_grid((41, 41)) + image = np.exp(-r.astype(int) / 5.0) + image[5, 30] += 3.0 + image[0, 0] = np.nan + + residual = du.radial_median_subtract(image) + + assert residual[5, 30] == pytest.approx(3.0, abs=1e-3) + assert np.isnan(residual[0, 0]) + + def test__exclude_keeps_contaminant_out_of_ring_median(self): + r = _radius_grid((21, 21)) + image = np.exp(-r.astype(int) / 5.0) + ring = r.astype(int) == 8 + # a contaminant covering most of one ring drags that ring's median up... + contaminant = ring & (np.mgrid[0:21, 0:21][1] > 6) + image[contaminant] += 10.0 + + biased = du.radial_median_subtract(image) + clean = du.radial_median_subtract(image, exclude=contaminant) + + untouched = ring & ~contaminant + assert np.abs(biased[untouched]).max() > 1.0 # ...stamping the whole annulus + assert np.abs(clean[untouched]).max() == pytest.approx(0.0, abs=1e-12) + assert clean[contaminant].min() > 9.0 + + +class TestMaskBoundary: + def test__one_pixel_inner_outline(self): + mask = np.zeros((10, 10), dtype=bool) + mask[2:7, 2:7] = True + + edge = du.mask_boundary(mask) + + assert edge.sum() == 16 + assert edge[2, 2] and edge[6, 6] and edge[2, 4] + assert not edge[3, 3] and not edge[4, 4] + assert not edge[1, 1] # outline lies on masked pixels only + + def test__mask_at_array_edge_is_not_outlined_along_edge(self): + mask = np.zeros((6, 6), dtype=bool) + mask[0:3, :] = True + + edge = du.mask_boundary(mask) + + assert edge[2, :].all() + assert not edge[0, :].any() + + def test__empty_mask_gives_empty_outline(self): + assert not du.mask_boundary(np.zeros((4, 4), dtype=bool)).any() + + +class TestPanels: + def test__composite_then_fold_round_trips(self): + a = np.zeros((5, 8)) + b = np.ones((5, 8)) + + composite = du.composite_panels([a, b], gap=3) + + assert composite.shape == (5, 19) + assert composite[:, 8:11].sum() == 0.0 + assert composite[:, 11:].all() + + scribbled = np.zeros((5, 19), dtype=bool) + scribbled[1, 2] = True # left panel + scribbled[3, 11 + 6] = True # right panel, column 6 + scribbled[4, 9] = True # gutter: ignored + + folded, per_panel = du.fold_panels(scribbled, n_panels=2, panel_n_x=8, gap=3) + + assert folded.shape == (5, 8) + assert folded[1, 2] and folded[3, 6] + assert folded.sum() == 2 + assert per_panel == [1, 1] + + def test__single_panel_is_identity(self): + a = np.arange(6.0).reshape(2, 3) + + assert du.composite_panels([a]) is a + folded, per_panel = du.fold_panels(a > 2, n_panels=1, panel_n_x=3) + assert np.array_equal(folded, a > 2) + assert per_panel == [3] + + +class TestMaskRegrid: + def test__centred_disc_between_grids_of_different_pixel_scale(self): + # cosmos_web_ring-like: 0.03"/px 419 px <-> 0.06"/px 209 px + r_src = _radius_grid((419, 419)) * 0.03 + src = r_src < 2.0 + + dst = du.mask_regridded_from( + src, + src_pixel_scales=0.03, + dst_shape_native=(209, 209), + dst_pixel_scales=0.06, + ) + + r_dst = _radius_grid((209, 209)) * 0.06 + assert dst.shape == (209, 209) + assert dst[104, 104] + assert np.array_equal(dst, r_dst < 2.0) + assert dst.sum() * 0.06**2 == pytest.approx(src.sum() * 0.03**2, rel=0.01) + + def test__upsampling_and_origin_shift(self): + src = np.zeros((4, 4), dtype=bool) + src[0, 3] = True # top-right pixel: y in (+1, +2), x in (+1, +2) at 1"/px + + dst = du.mask_regridded_from( + src, src_pixel_scales=1.0, dst_shape_native=(8, 8), dst_pixel_scales=0.5 + ) + + assert dst.sum() == 4 + assert dst[0:2, 6:8].all() + + # shift the destination origin by (+1, +1): the masked region moves down/left + shifted = du.mask_regridded_from( + src, + src_pixel_scales=1.0, + dst_shape_native=(8, 8), + dst_pixel_scales=0.5, + dst_origin=(1.0, 1.0), + ) + + assert shifted.sum() == 4 + assert shifted[2:4, 4:6].all() + + def test__outside_source_footprint_is_unmasked(self): + src = np.ones((4, 4), dtype=bool) + + dst = du.mask_regridded_from( + src, src_pixel_scales=1.0, dst_shape_native=(8, 8), dst_pixel_scales=1.0 + ) + + assert dst[2:6, 2:6].all() + assert dst.sum() == 16 + + def test__mask_2d_wrapper(self): + src = aa.Mask2D.circular(shape_native=(40, 40), pixel_scales=0.1, radius=1.0) + + dst = du.mask_2d_regridded_from(src, shape_native=(20, 20), pixel_scales=0.2) + + expected = aa.Mask2D.circular( + shape_native=(20, 20), pixel_scales=0.2, radius=1.0 + ) + assert isinstance(dst, aa.Mask2D) + assert dst.pixel_scales == (0.2, 0.2) + # Every 0.2" pixel centre sits exactly on a 0.1" pixel edge, so pixels on the + # circle's boundary may resolve either way; the interior and exterior must agree. + differ = np.asarray(dst) != np.asarray(expected) + assert differ.sum() <= 8 + r = _radius_grid((20, 20)) * 0.2 + assert not differ[np.abs(r - 1.0) > 0.15].any() diff --git a/test_autogalaxy/gui/test_scribbler.py b/test_autogalaxy/gui/test_scribbler.py index 86af290d..79a034a4 100644 --- a/test_autogalaxy/gui/test_scribbler.py +++ b/test_autogalaxy/gui/test_scribbler.py @@ -198,3 +198,50 @@ def test__motion_inside_axes_requests_redraw(self): s.on_mouse_motion(_event(s, 4.0, 6.0)) draw_idle.assert_called_once() + + +class TestSideBySidePanels: + def test__display_holds_two_panels_and_strokes_fold_onto_image_grid(self): + s = _scribbler( + shape=(20, 20), brush_width=0.1, subtract_radial=True, panel_gap=6 + ) + + assert s.n_panels == 2 + assert s.panel_names == ["radial-subtracted", "as-observed"] + assert s.display.shape == (20, 46) + assert float(s.display.min()) >= 0.0 and float(s.display.max()) <= 1.0 + + s.add_circle_to_scribble((5.0, 10.0)) # left panel + s.add_circle_to_scribble((26.0 + 15.0, 3.0)) # right panel, column 15 + + masks = s.get_scribble_masks() + assert masks["1"].shape == (20, 20) + assert masks["1"][10, 5] and masks["1"][3, 15] + assert s.mask_from().shape == (20, 20) + + def test__proposal_and_erase_work_on_the_right_panel(self): + proposal = np.zeros((20, 20), dtype=bool) + proposal[8:12, 8:12] = True + s = _scribbler( + shape=(20, 20), brush_width=0.1, subtract_radial=True, proposal=proposal + ) + + s.set_active_segment(1) + s.add_circle_to_scribble((26.0 + 10.0, 10.0)) # erase the centre, right panel + + mask = s.mask_from() + assert not mask[10, 10] + assert mask[8, 8] + + def test__subtract_radial_alone_gives_one_panel(self): + s = _scribbler(shape=(20, 20), subtract_radial=True, side_by_side=False) + + assert s.n_panels == 1 + assert s.panel_names == ["radial-subtracted"] + assert s.display.shape == (20, 20) + + def test__single_panel_display_is_the_image(self): + s = _scribbler(shape=(12, 14)) + + assert s.n_panels == 1 + assert s.display.shape == (12, 14) From 81aaf225674c1e17bdb5609d271b58a9c0d26e27 Mon Sep 17 00:00:00 2001 From: samlange04 Date: Mon, 21 Sep 2026 20:22:24 +0100 Subject: [PATCH 2/8] feat(gui): mark known positions with dark gapped crosses in the Scribbler `Scribbler(positions=..., position_marker_size=6)` draws each (y, x) scaled coordinate (a list or `Grid2DIrregular`, e.g. the multiple-image positions from the positions GUI) as four dark ticks on every panel while the mask is painted. A cross rather than a ring: the proposal outline and the mask-overlay edge are closed boundaries, and a third closed shape reads as one more region being masked. The arms stop 2 px short of the centre so the marked pixel is never covered, and they are dark because a bright marker cannot be told from the arc flux it points at. Display only: the mask is read back from brush positions. `positions_pixels()` exposes the (row, column) conversion, which honours the image's pixel scales and origin. Co-Authored-By: Claude Fable 5.1 --- autogalaxy/gui/scribbler.py | 71 +++++++++++++++++++++++++++ test_autogalaxy/gui/test_scribbler.py | 40 +++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/autogalaxy/gui/scribbler.py b/autogalaxy/gui/scribbler.py index 46e6a57d..22242751 100644 --- a/autogalaxy/gui/scribbler.py +++ b/autogalaxy/gui/scribbler.py @@ -41,6 +41,8 @@ def __init__( subtract_radial: bool = False, side_by_side: bool = True, panel_gap: int = 6, + positions=None, + position_marker_size: int = 6, block: bool = True, ): """ @@ -78,6 +80,15 @@ def __init__( independently. This is a display transform only: the mask is read back from brush positions, so it cannot change what a stroke masks. + **Marking known positions.** Pass `positions` (a list of (y, x) scaled coordinates + or a `Grid2DIrregular`, e.g. the multiple-image positions clicked in the + `positions.py` GUI) to mark each one with a dark cross while you paint. A cross, + not a ring: the proposal outline and the mask-overlay edge are closed boundaries, + and a third closed shape reads as one more region being masked. The arms stop + short of the centre so the marked pixel itself is never covered, and they are dark + because a bright marker would be indistinguishable from the arc flux it points at. + Markers are repeated on every panel and are display only. + **Reading the result.** `mask_from()` returns the combined mask described above (with no proposal, simply `added & ~erased`); `show_mask()` returns the ADD segment alone, for backwards compatibility; `get_scribble_masks()` returns every segment as @@ -126,6 +137,11 @@ def __init__( With `subtract_radial`, also show the as-observed image in a second panel. panel_gap The width in pixels of the blank gutter between side-by-side panels. + positions + (y, x) scaled coordinates to mark with a cross, see above. Requires `image` to + carry its geometry (an `Array2D` in native form). + position_marker_size + The arm length of each cross in pixels. block If `True` (the default) the constructor opens the window and blocks until it is closed. If `False` the figure is built but the event loop is not started; call @@ -180,6 +196,19 @@ def __init__( self.panel_names: List[str] = [] self.display = None + # position markers + self.positions = ( + None if positions is None else np.asarray(positions, dtype=float) + ) + if self.positions is not None and self.positions.size: + self.positions = self.positions.reshape(-1, 2) + if not hasattr(image, "pixel_scales"): + raise ValueError( + "positions need the image's geometry: pass an Array2D in native form" + ) + self.position_marker_size = position_marker_size + self.position_markers = [] + # brush self.brush_radius = max(int(self.image_shape[0] * brush_width), min_radius) self.min_radius = min_radius @@ -262,6 +291,45 @@ def _normalised(values, cmap, norm, vmin, vmax) -> np.ndarray: mpl_norm = matplotlib.colors.Normalize() return np.ma.filled(mpl_norm(np.nan_to_num(values)), 0.0) + def positions_pixels(self) -> np.ndarray: + """ + The (row, column) pixel coordinates of `positions` on the image's grid (floats; + the pixel centre of row 0 is the most positive y). + """ + if self.positions is None or not self.positions.size: + return np.zeros((0, 2)) + pixel_scales = self.im.pixel_scales + origin = self.im.origin + n_y, n_x = self.image_shape + rows = (n_y - 1) / 2.0 - (self.positions[:, 0] - origin[0]) / pixel_scales[0] + cols = (n_x - 1) / 2.0 + (self.positions[:, 1] - origin[1]) / pixel_scales[1] + return np.stack([rows, cols], axis=1) + + def _draw_position_markers(self, gap_px: float = 2.0): + """ + Draw each position as four dark ticks (a cross with its centre left open) on + every panel; the `Line2D`s are kept in `position_markers`. + """ + arm = float(self.position_marker_size) + for row, col in self.positions_pixels(): + for i in range(self.n_panels): + x = col + i * (self.image_shape[1] + self.panel_gap) + for xs, ys in ( + ([x, x], [row - arm, row - gap_px]), + ([x, x], [row + gap_px, row + arm]), + ([x - arm, x - gap_px], [row, row]), + ([x + gap_px, x + arm], [row, row]), + ): + (line,) = self.ax.plot( + xs, + ys, + color="k", + linewidth=1.5, + solid_capstyle="butt", + zorder=1e5, + ) + self.position_markers.append(line) + def _build_figure(self, cmap, norm, vmin, vmax, mask_overlay, rgb_image): """ Build the matplotlib figure, draw the image (and any proposal outline, mask edge or @@ -362,6 +430,9 @@ def _build_figure(self, cmap, norm, vmin, vmax, mask_overlay, rgb_image): linewidths=1.0, ) + if self.positions is not None and self.positions.size: + self._draw_position_markers() + title = self.KEY_LEGEND if self.n_panels > 1: title = ( diff --git a/test_autogalaxy/gui/test_scribbler.py b/test_autogalaxy/gui/test_scribbler.py index 79a034a4..d478bd4a 100644 --- a/test_autogalaxy/gui/test_scribbler.py +++ b/test_autogalaxy/gui/test_scribbler.py @@ -245,3 +245,43 @@ def test__single_panel_display_is_the_image(self): assert s.n_panels == 1 assert s.display.shape == (12, 14) + + +class TestPositionMarkers: + def test__positions_convert_to_pixels_and_draw_four_ticks_each(self): + # 20 px at 0.1"/px: (y, x) = (+0.5", -0.5") -> row 4.5, column 4.5 + s = _scribbler(shape=(20, 20), positions=[(0.5, -0.5), (0.0, 0.0)]) + + pixels = s.positions_pixels() + assert pixels[0] == pytest.approx([4.5, 4.5]) + assert pixels[1] == pytest.approx([9.5, 9.5]) + assert len(s.position_markers) == 8 + + xs, ys = s.position_markers[0].get_data() # tick above the first position + assert list(xs) == pytest.approx([4.5, 4.5]) + assert list(ys) == pytest.approx([4.5 - 6, 4.5 - 2]) + + def test__markers_repeat_on_every_panel_and_never_enter_the_mask(self): + s = _scribbler(shape=(20, 20), positions=[(0.0, 0.0)], subtract_radial=True) + + assert len(s.position_markers) == 8 + xs, _ = s.position_markers[4].get_data() # first tick of the right-hand copy + assert list(xs) == pytest.approx([9.5 + 26, 9.5 + 26]) + assert not s.mask_from().any() + + def test__grid_irregular_and_origin_are_honoured(self): + image = aa.Array2D.no_mask( + values=np.zeros((10, 10)), pixel_scales=0.2, origin=(1.0, -1.0) + ) + s = ag.Scribbler( + image=image.native, + backend="Agg", + block=False, + positions=aa.Grid2DIrregular(values=[(1.0, -1.0)]), + ) + + assert s.positions_pixels()[0] == pytest.approx([4.5, 4.5]) + + def test__no_positions_draws_nothing(self): + assert _scribbler().position_markers == [] + assert _scribbler(positions=[]).position_markers == [] From cc118957cf44e833728d5d9e3cff07b92a021f5c Mon Sep 17 00:00:00 2001 From: samlange04 Date: Wed, 30 Sep 2026 20:11:33 +0800 Subject: [PATCH 3/8] test(gui): pin the proposal outline on both side-by-side panels Extends the single-panel outline-bounds test to the radial-subtracted side-by-side display: the asymmetric proposal must be outlined over the same pixels in the left panel and one image-width-plus-gap to the right. Co-Authored-By: Claude Opus 5.5 --- test_autogalaxy/gui/test_scribbler.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test_autogalaxy/gui/test_scribbler.py b/test_autogalaxy/gui/test_scribbler.py index d478bd4a..ab85a852 100644 --- a/test_autogalaxy/gui/test_scribbler.py +++ b/test_autogalaxy/gui/test_scribbler.py @@ -233,6 +233,27 @@ def test__proposal_and_erase_work_on_the_right_panel(self): assert not mask[10, 10] assert mask[8, 8] + def test__proposal_outline_aligns_with_imshow_pixels_on_both_panels(self): + # The asymmetric proposal (rows / cols 2..5) is outlined over those pixels in the + # left panel and again 20 + 6 = 26 columns to the right in the right panel. + proposal = np.zeros((20, 20), dtype=bool) + proposal[2:6, 2:6] = True + s = _scribbler(subtract_radial=True, panel_gap=6, proposal=proposal) + + vertices = np.concatenate( + [path.vertices for path in s._proposal_contour.get_paths() if len(path)] + ) + x, y = vertices[:, 0], vertices[:, 1] + left, right = x < 20, x >= 26 + + assert left.any() and right.any() and (left | right).all() + assert x[left].min() == pytest.approx(1.5, abs=1e-6) + assert x[left].max() == pytest.approx(5.5, abs=1e-6) + assert x[right].min() == pytest.approx(27.5, abs=1e-6) + assert x[right].max() == pytest.approx(31.5, abs=1e-6) + assert y.min() == pytest.approx(1.5, abs=1e-6) + assert y.max() == pytest.approx(5.5, abs=1e-6) + def test__subtract_radial_alone_gives_one_panel(self): s = _scribbler(shape=(20, 20), subtract_radial=True, side_by_side=False) From 99643ebf2d2eb30d08ac4e7124e9595a8c79910d Mon Sep 17 00:00:00 2001 From: samlange04 Date: Sat, 3 Oct 2026 18:49:55 +0800 Subject: [PATCH 4/8] fix(gui): arcsinh display, white/black brushes with halos, small crosses, blank gutter, titles Manual testing of the side-by-side Scribbler on the COSMOS-Web ring and the simulated `simple` dataset found display defects, some of them inherited from the erase-brush PR: - The display was scaled linearly from min to max, so a galaxy core thousands of sigma above the sky collapsed the whole field onto one colour and the radial-subtracted panel showed nothing. Every display (single panel too) is now mapped through an arcsinh stretch about its own sky level (`display_util.arcsinh_stretch`, MAD-scaled, clipped at the 0.5 / 99.5 percentiles); `vmin` / `vmax` set the data values at the ends of the stretch instead, and `stretch="linear"` restores the previous scaling (and `norm="log"`). A legacy `Cmap`-style object keeps its own norm. - The brushes painted green and red, which vanish against a jet-coloured image and are not told apart by every kind of colour vision. Add now paints WHITE and erase BLACK: no colour map contains either. The cursor ring takes the active brush's colour with a thin halo of the other, so it is visible on any background and shows the mode; `brush_color=` overrides the ring colour. - The position crosses had 6 px arms with an open centre regardless of image size, which on a 100 px image was far too large and left the marked pixel ambiguous. They are now closed black crosses with a white halo whose default arm scales with the image (1.5% of the shorter side, at least 3 px). The proposal outline gets the same black-on-white treatment, since plain white was lost on the brighter arcsinh sky. - The gutter between panels was filled with 0.0 and so painted in the colour map's lowest colour, reading as the left image running past its own edge. `composite_panels` now fills the gutter with NaN so it is left unpainted. - With several GUI windows in one session it was not obvious what each one was for. New `title=` goes on the first line of the window (e.g. "Mask extra galaxies: F277W, proposing from F150W"), and the window now lists the markers it is actually showing (proposal outline, black x = overlaid mask edge, + = marked positions) above the keys. Co-Authored-By: Claude Fable 5.1 --- autogalaxy/gui/display_util.py | 62 ++++++- autogalaxy/gui/scribbler.py | 223 ++++++++++++++++++----- test_autogalaxy/gui/test_display_util.py | 49 ++++- test_autogalaxy/gui/test_scribbler.py | 152 ++++++++++++++- 4 files changed, 434 insertions(+), 52 deletions(-) diff --git a/autogalaxy/gui/display_util.py b/autogalaxy/gui/display_util.py index 73335e55..0f73aace 100644 --- a/autogalaxy/gui/display_util.py +++ b/autogalaxy/gui/display_util.py @@ -73,12 +73,72 @@ def mask_boundary(mask: np.ndarray) -> np.ndarray: return m & ~binary_erosion(m, np.ones((3, 3), dtype=bool), border_value=1) +def arcsinh_stretch( + array_native: np.ndarray, + clip_percentiles: Tuple[float, float] = (0.5, 99.5), + vmin: Optional[float] = None, + vmax: Optional[float] = None, +) -> np.ndarray: + """ + Map an image onto [0, 1] through an arcsinh stretch about its median, scaled by a + robust (median absolute deviation) estimate of the noise. + + arcsinh is linear within about one noise sigma of the median and logarithmic beyond, + so faint structure near the sky level and a galaxy core thousands of sigma above it + are both visible at once; a linear min-to-max scale collapses everything onto one + colour as soon as a single bright pixel is present. The stretched values are then + normalised between `clip_percentiles` so a handful of extreme pixels cannot set the + range, unless explicit `vmin` / `vmax` data values are given for either end. Non-finite + pixels map to 0. + + Parameters + ---------- + array_native + The 2D image. + clip_percentiles + The (low, high) percentiles of the stretched values mapped to 0 and 1, for + whichever end has no explicit limit. + vmin, vmax + Data values (not percentiles) mapped to 0 and 1, overriding the percentiles. + """ + a = np.asarray(array_native, dtype=float) + finite = np.isfinite(a) + if not finite.any(): + return np.zeros_like(a) + values = a[finite] + median = np.median(values) + sigma = 1.4826 * np.median(np.abs(values - median)) + if not sigma > 0: + sigma = np.std(values) + if not sigma > 0: + return np.zeros_like(a) + stretched = np.arcsinh((np.where(finite, a, median) - median) / sigma) + lo, hi = np.percentile(stretched[finite], clip_percentiles) + if not hi > lo: + # nearly flat image with a few outliers: the percentiles coincide, so use the + # full range rather than show nothing + lo, hi = stretched[finite].min(), stretched[finite].max() + if vmin is not None: + lo = np.arcsinh((vmin - median) / sigma) + if vmax is not None: + hi = np.arcsinh((vmax - median) / sigma) + if not hi > lo: + return np.zeros_like(a) + out = np.clip((stretched - lo) / (hi - lo), 0.0, 1.0) + out[~finite] = 0.0 + return out + + def composite_panels( - panels: Sequence[np.ndarray], gap: int = 6, fill: float = 0.0 + panels: Sequence[np.ndarray], gap: int = 6, fill: float = np.nan ) -> np.ndarray: """ Lay 2D panels of equal height side by side, separated by `gap` blank columns of value `fill`. The inverse of `fold_panels`. + + The gutter defaults to NaN so that `imshow` leaves it unpainted (background colour) + rather than colouring it as the colour map's lowest value, which would read as the + left panel's image running on past its own edge. """ panels = [np.asarray(p) for p in panels] if len(panels) == 1: diff --git a/autogalaxy/gui/scribbler.py b/autogalaxy/gui/scribbler.py index 22242751..abb50693 100644 --- a/autogalaxy/gui/scribbler.py +++ b/autogalaxy/gui/scribbler.py @@ -4,6 +4,7 @@ import numpy as np from autogalaxy.gui.display_util import ( + arcsinh_stretch, composite_panels, fold_panels, radial_median_subtract, @@ -12,12 +13,12 @@ class Scribbler: #: Fixed roles of the two default scribble segments. Press the key to switch brush. - ADD_SEGMENT = "1" # green: pixels to add to the mask - ERASE_SEGMENT = "2" # red: pixels to remove from the mask (or from a proposal) + ADD_SEGMENT = "1" # white: pixels to add to the mask + ERASE_SEGMENT = "2" # black: pixels to remove from the mask (or from a proposal) #: One-line key legend, printed when the GUI starts and shown as the axes title. KEY_LEGEND = ( - "'1' add (green) | '2' erase (red) | '=' bigger brush | '-' smaller brush | " + "'1' add (white) | '2' erase (black) | '=' bigger brush | '-' smaller brush | " "'z' undo | Esc done" ) @@ -42,7 +43,10 @@ def __init__( side_by_side: bool = True, panel_gap: int = 6, positions=None, - position_marker_size: int = 6, + position_marker_size: Optional[int] = None, + stretch: str = "arcsinh", + brush_color: Optional[str] = None, + title: Optional[str] = None, block: bool = True, ): """ @@ -52,9 +56,13 @@ def __init__( Adapted from https://gist.github.com/brikeats/4f63f867fd8ea0f196c78e9b835150ab - **Brushes.** Two scribble segments are available: `'1'` (green) ADDS pixels to - the mask and `'2'` (red) ERASES them. Press the key to switch brush; the current - brush is drawn as a blue circle following the cursor. `'='` and `'-'` grow and + **Brushes.** Two scribble segments are available: `'1'` (white) ADDS pixels to + the mask and `'2'` (black) ERASES them. White and black because no colour map + contains either, so a stroke is never lost in the image, and because they are + told apart by every kind of colour vision, which a green / red pair is not. Press + the key to switch brush; the current brush follows the cursor as a ring in its + own colour with a thin halo of the opposite one, visible on any background. + `'='` and `'-'` grow and shrink the brush multiplicatively (by `brush_resize_factor` per press, never by less than one pixel), so a few presses take you from a 1 px detail brush to a thick outskirts brush. `'z'` undoes the last circle of the active brush and `Esc` / `q` @@ -62,7 +70,7 @@ def __init__( **Starting from a proposal.** Pass `proposal` (a boolean array of the image's native shape, True = masked) to REFINE an existing mask instead of drawing from scratch: - its boundary is outlined in white over the image and, after the GUI closes, + its boundary is outlined (black on a white halo) over the image and, after the GUI closes, `mask_from()` returns `(proposal | added) & ~erased`. A proposal can be a mask drawn earlier for this image, or one drawn for another waveband of the same object (see `autogalaxy.gui.display_util.mask_regridded_from` for moving a mask between @@ -80,14 +88,24 @@ def __init__( independently. This is a display transform only: the mask is read back from brush positions, so it cannot change what a stroke masks. + **Colour scale.** Every display (single panel or side by side) is mapped through + an arcsinh stretch about its own sky level (`stretch="arcsinh"`, see + `display_util.arcsinh_stretch`): linear within a noise sigma of the sky and + logarithmic beyond, so faint arcs and a galaxy core thousands of sigma brighter + are visible at once, where a linear scale shows only the core. `vmin` / `vmax` + set the data values at the ends of the stretch (the default clips at the 0.5 and + 99.5 percentiles). `stretch="linear"` restores matplotlib's plain scaling and + honours `norm="log"`. + **Marking known positions.** Pass `positions` (a list of (y, x) scaled coordinates or a `Grid2DIrregular`, e.g. the multiple-image positions clicked in the - `positions.py` GUI) to mark each one with a dark cross while you paint. A cross, + `positions.py` GUI) to mark each one with a small cross while you paint. A cross, not a ring: the proposal outline and the mask-overlay edge are closed boundaries, - and a third closed shape reads as one more region being masked. The arms stop - short of the centre so the marked pixel itself is never covered, and they are dark - because a bright marker would be indistinguishable from the arc flux it points at. - Markers are repeated on every panel and are display only. + and a third closed shape reads as one more region being masked. The cross is + small (a few pixels, scaled to the image) and its arms meet at the marked pixel so + there is no doubt which pixel it names; it is drawn black with a thin white halo + so it reads on any background. Markers are repeated on every panel and are + display only. **Reading the result.** `mask_from()` returns the combined mask described above (with no proposal, simply `added & ~erased`); `show_mask()` returns the ADD segment @@ -107,9 +125,9 @@ def __init__( *norm* / *vmin* / *vmax* arguments below. norm ``"log"`` for a logarithmic colour scale, ``"linear"`` (or ``None``) - otherwise. + otherwise. Only used with `stretch="linear"`. vmin, vmax - Explicit colour-scale limits. + The data values at the ends of the colour scale, for either stretch. brush_width The starting brush radius as a fraction of the image height. backend @@ -141,7 +159,22 @@ def __init__( (y, x) scaled coordinates to mark with a cross, see above. Requires `image` to carry its geometry (an `Array2D` in native form). position_marker_size - The arm length of each cross in pixels. + The arm length of each cross in pixels. `None` (the default) scales it to the + image: 1.5% of the shorter side, never less than 3 px. + stretch + How the image is mapped to colour: `"arcsinh"` (the default, see above and + `display_util.arcsinh_stretch`) or `"linear"` (matplotlib's own scaling, + with `norm`). Ignored for a legacy ``Cmap``-style object, which brings its + own norm. + brush_color + The colour of the brush ring that follows the cursor. `None` (the default) + uses the active brush's own colour, white to add and black to erase. + title + What this window is for, shown as the first line above the image, e.g. + `"Mask extra galaxies: F277W, proposing from F150W"`. Below it the window + always lists the markers it is showing (proposal outline, overlay edge, + position crosses) and the keys, so a script need only say what is being + masked and where the proposal came from. block If `True` (the default) the constructor opens the window and blocks until it is closed. If `False` the figure is built but the event loop is not started; call @@ -206,20 +239,28 @@ def __init__( raise ValueError( "positions need the image's geometry: pass an Array2D in native form" ) + if position_marker_size is None: + position_marker_size = max(3, int(round(0.015 * min(self.image_shape)))) self.position_marker_size = position_marker_size self.position_markers = [] + if stretch not in ("arcsinh", "linear"): + raise ValueError(f"stretch must be 'arcsinh' or 'linear', not {stretch!r}") + self.stretch = stretch # brush self.brush_radius = max(int(self.image_shape[0] * brush_width), min_radius) self.min_radius = min_radius self.brush_resize_factor = brush_resize_factor - self.brush_color = "b" + self._brush_color = brush_color self.brush = None + self.brush_halo = None + self.title = title + self.has_mask_overlay = mask_overlay is not None # scribbles if not segment_names: segment_names = [self.ADD_SEGMENT, self.ERASE_SEGMENT] - self.scribble_colors = "gr" + self.scribble_colors = "wk" self.scribbles = OrderedDict() for name in segment_names: self.scribbles[name] = [] @@ -271,14 +312,70 @@ def _panels(self) -> List[Tuple[str, np.ndarray]]: panels.append(("as-observed", values)) return panels - @staticmethod - def _normalised(values, cmap, norm, vmin, vmax) -> np.ndarray: + @property + def marker_legend(self) -> str: """ - Map one panel's values onto [0, 1] with the same colour scaling the single-panel - display uses, so panels of very different dynamic range can share one image. + One line naming every marker drawn on this window (nothing if there are none): + the outline of the proposal being refined, the black `x` edge of the overlaid + mask, and the `+` crosses at the marked positions. + """ + parts = [] + if self.proposal is not None and self.proposal.any(): + parts.append("outline = the mask being refined") + if self.has_mask_overlay: + parts.append("black x = edge of the overlaid mask (masked-out region)") + if self.positions is not None and self.positions.size: + parts.append("+ = marked positions") + return " | ".join(parts) + + @property + def title_text(self) -> str: + """ + The window title: the caller's `title` (what is being masked, and from which + waveband the proposal came), the panel layout, the marker legend and the keys. + """ + lines = [] + if self.title: + lines.append(self.title) + if self.n_panels > 1: + lines.append( + f"LEFT: {self.panel_names[0]} | RIGHT: {self.panel_names[1]}" + f" -- scribble on either" + ) + if self.marker_legend: + lines.append(self.marker_legend) + lines.append(self.KEY_LEGEND) + return "\n".join(lines) + + @property + def brush_color(self) -> str: + """ + The colour of the brush ring: the active brush's own colour unless one was given, + so the ring doubles as a mode indicator. + """ + if self._brush_color is not None: + return self._brush_color + return self.active_scribble_color + + @property + def brush_halo_color(self) -> str: + """ + The thin halo drawn inside the brush ring, in the opposite tone, so the ring is + visible whatever the image colour beneath it. + """ + return "k" if self.brush_color in ("w", "white") else "w" + + def _normalised(self, values, cmap, norm, vmin, vmax) -> np.ndarray: + """ + Map one panel's values onto [0, 1] so panels of very different dynamic range can + share one image: an arcsinh stretch about the panel's own sky level, or (with + `stretch="linear"`) the same colour scaling the single-panel display uses. """ import matplotlib.colors + if self.stretch == "arcsinh" and not hasattr(cmap, "norm_from"): + return arcsinh_stretch(values, vmin=vmin, vmax=vmax) + if hasattr(cmap, "norm_from"): mpl_norm = cmap.norm_from(array=values) else: @@ -305,26 +402,32 @@ def positions_pixels(self) -> np.ndarray: cols = (n_x - 1) / 2.0 + (self.positions[:, 1] - origin[1]) / pixel_scales[1] return np.stack([rows, cols], axis=1) - def _draw_position_markers(self, gap_px: float = 2.0): + def _draw_position_markers(self): """ - Draw each position as four dark ticks (a cross with its centre left open) on - every panel; the `Line2D`s are kept in `position_markers`. + Draw each position as a small dark cross (two lines meeting at the marked pixel) + on every panel; the `Line2D`s are kept in `position_markers`. """ arm = float(self.position_marker_size) for row, col in self.positions_pixels(): for i in range(self.n_panels): x = col + i * (self.image_shape[1] + self.panel_gap) for xs, ys in ( - ([x, x], [row - arm, row - gap_px]), - ([x, x], [row + gap_px, row + arm]), - ([x - arm, x - gap_px], [row, row]), - ([x + gap_px, x + arm], [row, row]), + ([x, x], [row - arm, row + arm]), + ([x - arm, x + arm], [row, row]), ): + self.ax.plot( + xs, + ys, + color="w", + linewidth=2.5, + solid_capstyle="butt", + zorder=1e5 - 1, + ) (line,) = self.ax.plot( xs, ys, color="k", - linewidth=1.5, + linewidth=1.0, solid_capstyle="butt", zorder=1e5, ) @@ -357,7 +460,23 @@ def _build_figure(self, cmap, norm, vmin, vmax, mask_overlay, rgb_image): plt.imshow(rgb_image, origin=_conf_imshow_origin()) self.ax = self.figure.add_subplot(111) - if self.n_panels == 1: + if ( + self.n_panels == 1 + and self.stretch == "arcsinh" + and not hasattr(cmap, "norm_from") + ): + from autogalaxy.util.plot_utils import _resolve_colormap + + self.display = arcsinh_stretch(np.asarray(image), vmin=vmin, vmax=vmax) + plt.imshow( + self.display, + cmap=_resolve_colormap(cmap), + vmin=0.0, + vmax=1.0, + interpolation="none", + origin=_conf_imshow_origin(), + ) + elif self.n_panels == 1: self.display = np.asarray(image) if cmap is None and norm is None and vmin is None and vmax is None: plt.imshow(image, interpolation="none", origin=_conf_imshow_origin()) @@ -421,25 +540,31 @@ def _build_figure(self, cmap, norm, vmin, vmax, mask_overlay, rgb_image): # at data (0, 0), which is where imshow draws pixel [0, 0] for either origin # (contour's own `origin="upper"` would flip the outline vertically). outline = composite_panels( - [self.proposal.astype(float)] * self.n_panels, gap=self.panel_gap + [self.proposal.astype(float)] * self.n_panels, + gap=self.panel_gap, + fill=0.0, ) - self._proposal_contour = self.ax.contour( + # Black line on a white halo, like the position crosses, so the outline + # reads on the bright sky of the arcsinh display as well as on a dark one. + self.ax.contour( outline, levels=[0.5], colors="w", - linewidths=1.0, + linewidths=3.5, + zorder=1e4 - 1, + ) + self._proposal_contour = self.ax.contour( + outline, + levels=[0.5], + colors="k", + linewidths=1.6, + zorder=1e4, ) if self.positions is not None and self.positions.size: self._draw_position_markers() - title = self.KEY_LEGEND - if self.n_panels > 1: - title = ( - f"LEFT: {self.panel_names[0]} | RIGHT: {self.panel_names[1]}" - f" -- scribble on either\n{title}" - ) - self.ax.set_title(title, fontsize=10) + self.ax.set_title(self.title_text, fontsize=10) plt.axis(self.extent) plt.axis("off") @@ -498,15 +623,26 @@ def on_mouse_motion(self, event): # draw the brush circle if self.brush: self.brush.center = center + self.brush_halo.center = center else: self.brush = matplotlib.patches.Circle( center, radius=self.brush_radius, edgecolor=self.brush_color, facecolor="none", + linewidth=2.5, zorder=1e6, ) # always on top + self.brush_halo = matplotlib.patches.Circle( + center, + radius=self.brush_radius, + edgecolor=self.brush_halo_color, + facecolor="none", + linewidth=0.8, + zorder=1e6 + 1, + ) self.ax.add_patch(self.brush) + self.ax.add_patch(self.brush_halo) # add to the scribble, if mouse is down if self.mouse_is_down: @@ -539,6 +675,10 @@ def set_active_segment(self, index: int): name = list(self.scribbles.keys())[index] self.active_scribble = self.scribbles[name] self.active_scribble_color = self.scribble_colors[index] + if self.brush is not None: + self.brush.set_edgecolor(self.brush_color) + self.brush_halo.set_edgecolor(self.brush_halo_color) + self.figure.canvas.draw_idle() def add_circle_to_scribble(self, center): import matplotlib @@ -565,6 +705,7 @@ def _set_brush_radius(self, radius: float): self.brush_radius = max(self.min_radius, int(round(radius))) if self.brush: self.brush.radius = self.brush_radius + self.brush_halo.radius = self.brush_radius self.figure.canvas.draw() print(f" brush radius = {self.brush_radius} px") diff --git a/test_autogalaxy/gui/test_display_util.py b/test_autogalaxy/gui/test_display_util.py index 4479b49e..0c12ec13 100644 --- a/test_autogalaxy/gui/test_display_util.py +++ b/test_autogalaxy/gui/test_display_util.py @@ -80,8 +80,9 @@ def test__composite_then_fold_round_trips(self): composite = du.composite_panels([a, b], gap=3) assert composite.shape == (5, 19) - assert composite[:, 8:11].sum() == 0.0 + assert np.isnan(composite[:, 8:11]).all() # gutter unpainted by default assert composite[:, 11:].all() + assert du.composite_panels([a, b], gap=3, fill=0.0)[:, 8:11].sum() == 0.0 scribbled = np.zeros((5, 19), dtype=bool) scribbled[1, 2] = True # left panel @@ -172,3 +173,49 @@ def test__mask_2d_wrapper(self): assert differ.sum() <= 8 r = _radius_grid((20, 20)) * 0.2 assert not differ[np.abs(r - 1.0) > 0.15].any() + + +class TestArcsinhStretch: + def test__maps_onto_unit_interval_and_keeps_faint_structure_visible(self): + rng = np.random.default_rng(1) + values = rng.normal(0.0, 1.0, (50, 50)) + values[25, 25] = 1e4 + values[5, 5] = 5.0 + + out = du.arcsinh_stretch(values) + + assert out.shape == values.shape + assert out.min() == 0.0 and out.max() == 1.0 + assert out[25, 25] == 1.0 + # 5 sigma above the sky lands well inside the range rather than at the floor + assert 0.2 < out[5, 5] - np.median(out) < 0.9 + # a linear scale would put the same pixel at 5e-4 + linear = (values - values.min()) / (values.max() - values.min()) + assert linear[5, 5] - np.median(linear) < 1e-3 + + def test__explicit_limits_replace_the_percentiles(self): + rng = np.random.default_rng(2) + values = rng.normal(0.0, 1.0, (50, 50)) + values[25, 25] = 1e4 + + out = du.arcsinh_stretch(values, vmin=-2.0, vmax=10.0) + + assert out[25, 25] == 1.0 + assert (out[values >= 10.0] == 1.0).all() + assert (out[values <= -2.0] == 0.0).all() + # a pixel exactly at the limits lands at the ends + values[0, 0], values[0, 1] = -2.0, 10.0 + out = du.arcsinh_stretch(values, vmin=-2.0, vmax=10.0) + assert out[0, 0] == pytest.approx(0.0) and out[0, 1] == pytest.approx(1.0) + + def test__non_finite_pixels_map_to_zero_and_flat_images_do_not_divide_by_zero(self): + values = np.ones((4, 4)) + values[0, 0] = np.nan + values[1, 1] = 3.0 + + out = du.arcsinh_stretch(values) + assert out[0, 0] == 0.0 + assert np.isfinite(out).all() + + assert (du.arcsinh_stretch(np.ones((4, 4))) == 0.0).all() + assert (du.arcsinh_stretch(np.full((4, 4), np.nan)) == 0.0).all() diff --git a/test_autogalaxy/gui/test_scribbler.py b/test_autogalaxy/gui/test_scribbler.py index ab85a852..c3073988 100644 --- a/test_autogalaxy/gui/test_scribbler.py +++ b/test_autogalaxy/gui/test_scribbler.py @@ -28,8 +28,9 @@ def close_figures(): plt.close("all") -def _scribbler(shape=(20, 20), **kwargs): - image = aa.Array2D.no_mask(values=np.zeros(shape), pixel_scales=0.1) +def _scribbler(shape=(20, 20), values=None, **kwargs): + values = np.zeros(shape) if values is None else np.asarray(values, dtype=float) + image = aa.Array2D.no_mask(values=values, pixel_scales=0.1) return ag.Scribbler(image=image.native, backend="Agg", block=False, **kwargs) @@ -209,7 +210,9 @@ def test__display_holds_two_panels_and_strokes_fold_onto_image_grid(self): assert s.n_panels == 2 assert s.panel_names == ["radial-subtracted", "as-observed"] assert s.display.shape == (20, 46) - assert float(s.display.min()) >= 0.0 and float(s.display.max()) <= 1.0 + assert np.isnan(s.display[:, 20:26]).all() # gutter is unpainted + panels = np.hstack([s.display[:, :20], s.display[:, 26:]]) + assert float(panels.min()) >= 0.0 and float(panels.max()) <= 1.0 s.add_circle_to_scribble((5.0, 10.0)) # left panel s.add_circle_to_scribble((26.0 + 15.0, 3.0)) # right panel, column 15 @@ -269,24 +272,40 @@ def test__single_panel_display_is_the_image(self): class TestPositionMarkers: - def test__positions_convert_to_pixels_and_draw_four_ticks_each(self): + def test__positions_convert_to_pixels_and_draw_a_closed_cross_each(self): # 20 px at 0.1"/px: (y, x) = (+0.5", -0.5") -> row 4.5, column 4.5 s = _scribbler(shape=(20, 20), positions=[(0.5, -0.5), (0.0, 0.0)]) pixels = s.positions_pixels() assert pixels[0] == pytest.approx([4.5, 4.5]) assert pixels[1] == pytest.approx([9.5, 9.5]) - assert len(s.position_markers) == 8 + assert len(s.position_markers) == 4 # two lines per cross - xs, ys = s.position_markers[0].get_data() # tick above the first position + # default arm: 1.5% of the shorter side, never below 3 px + assert s.position_marker_size == 3 + xs, ys = s.position_markers[ + 0 + ].get_data() # vertical arm through the first position assert list(xs) == pytest.approx([4.5, 4.5]) - assert list(ys) == pytest.approx([4.5 - 6, 4.5 - 2]) + assert list(ys) == pytest.approx([4.5 - 3, 4.5 + 3]) + xs, ys = s.position_markers[1].get_data() # horizontal arm + assert list(xs) == pytest.approx([4.5 - 3, 4.5 + 3]) + assert list(ys) == pytest.approx([4.5, 4.5]) + + def test__marker_size_scales_with_the_image_or_is_explicit(self): + assert ( + _scribbler(shape=(400, 400), positions=[(0.0, 0.0)]).position_marker_size + == 6 + ) + s = _scribbler(shape=(20, 20), positions=[(0.0, 0.0)], position_marker_size=5) + _, ys = s.position_markers[0].get_data() + assert list(ys) == pytest.approx([9.5 - 5, 9.5 + 5]) def test__markers_repeat_on_every_panel_and_never_enter_the_mask(self): s = _scribbler(shape=(20, 20), positions=[(0.0, 0.0)], subtract_radial=True) - assert len(s.position_markers) == 8 - xs, _ = s.position_markers[4].get_data() # first tick of the right-hand copy + assert len(s.position_markers) == 4 + xs, _ = s.position_markers[2].get_data() # vertical arm of the right-hand copy assert list(xs) == pytest.approx([9.5 + 26, 9.5 + 26]) assert not s.mask_from().any() @@ -306,3 +325,118 @@ def test__grid_irregular_and_origin_are_honoured(self): def test__no_positions_draws_nothing(self): assert _scribbler().position_markers == [] assert _scribbler(positions=[]).position_markers == [] + + +class TestPanelStretch: + def test__arcsinh_default_keeps_faint_structure_visible_beside_a_bright_core(self): + rng = np.random.default_rng(0) + values = rng.normal(0.0, 1.0, (40, 40)) + values[20, 20] = 1e4 # a core thousands of sigma above the sky + values[5, 5] = 4.0 # a faint companion + + s = _scribbler(shape=(40, 40), values=values, subtract_radial=True) + right = s.display[:, 46:] # the as-observed panel + + assert s.stretch == "arcsinh" + assert right[20, 20] == 1.0 + # the companion is clearly separated from the sky, not crushed onto it + assert right[5, 5] - np.median(right) > 0.1 + + def test__linear_stretch_reproduces_the_old_scaling(self): + values = np.zeros((40, 40)) + values[20, 20] = 1e4 + values[5, 5] = 4.0 + + s = _scribbler( + shape=(40, 40), values=values, subtract_radial=True, stretch="linear" + ) + right = s.display[:, 46:] + + assert right[20, 20] == pytest.approx(1.0) + assert right[5, 5] == pytest.approx(4e-4, abs=1e-6) + + def test__single_panel_is_stretched_too_and_limits_are_honoured(self): + rng = np.random.default_rng(0) + values = rng.normal(0.0, 1.0, (40, 40)) + values[20, 20] = 1e4 + values[5, 5] = 4.0 + + s = _scribbler(shape=(40, 40), values=values) + assert s.display.shape == (40, 40) + assert s.display[20, 20] == 1.0 + assert s.display[5, 5] - np.median(s.display) > 0.1 + + # vmax below the core: the core saturates and the companion sits at the top too + s = _scribbler(shape=(40, 40), values=values, vmin=0.0, vmax=4.0) + assert s.display[20, 20] == 1.0 + assert s.display[5, 5] == pytest.approx(1.0) + + # linear keeps the raw image as the display, as before + s = _scribbler(shape=(40, 40), values=values, stretch="linear") + assert s.display[20, 20] == 1e4 + + def test__unknown_stretch_is_rejected(self): + with pytest.raises(ValueError): + _scribbler(subtract_radial=True, stretch="sqrt") + + +class TestBrushColour: + def test__brush_ring_and_halo_follow_the_active_brush(self): + s = _scribbler(shape=(20, 20)) + s.on_mouse_motion(_event(s, 4.0, 6.0)) + + assert s.brush_color == "w" and s.brush_halo_color == "k" + assert s.brush.get_edgecolor()[:3] == pytest.approx((1.0, 1.0, 1.0)) + assert s.brush_halo.get_edgecolor()[:3] == pytest.approx((0.0, 0.0, 0.0)) + assert s.brush_halo.center == s.brush.center + + s.set_active_segment(1) + assert s.brush_color == "k" and s.brush_halo_color == "w" + assert s.brush.get_edgecolor()[:3] == pytest.approx((0.0, 0.0, 0.0)) + assert s.brush_halo.get_edgecolor()[:3] == pytest.approx((1.0, 1.0, 1.0)) + + s.on_keypress(_event(s, 4.0, 6.0, key="=")) + assert s.brush_halo.radius == s.brush.radius == s.brush_radius + + def test__strokes_are_painted_white_to_add_and_black_to_erase(self): + s = _scribbler(shape=(20, 20)) + s.add_circle_to_scribble((5.0, 5.0)) + s.set_active_segment(1) + s.add_circle_to_scribble((15.0, 15.0)) + + add, erase = (list(v) for v in s.scribbles.values()) + assert add[0].get_facecolor()[:3] == pytest.approx((1.0, 1.0, 1.0)) + assert erase[0].get_facecolor()[:3] == pytest.approx((0.0, 0.0, 0.0)) + + def test__explicit_brush_colour_is_kept(self): + s = _scribbler(shape=(20, 20), brush_color="w") + s.set_active_segment(1) + assert s.brush_color == "w" + + +class TestTitle: + def test__title_lists_what_is_shown(self): + s = _scribbler() + assert s.title_text == s.KEY_LEGEND + assert s.ax.get_title() == s.KEY_LEGEND + + proposal = np.zeros((20, 20), dtype=bool) + proposal[5:8, 5:8] = True + overlay = aa.Mask2D.circular( + shape_native=(20, 20), pixel_scales=0.1, radius=0.5 + ) + s = _scribbler( + title="Mask extra galaxies: F277W, proposing from F150W", + subtract_radial=True, + proposal=proposal, + mask_overlay=overlay, + positions=[(0.0, 0.0)], + ) + lines = s.title_text.split("\n") + assert lines[0] == "Mask extra galaxies: F277W, proposing from F150W" + assert lines[1].startswith("LEFT: radial-subtracted") + assert "outline = the mask being refined" in lines[2] + assert "black x = edge of the overlaid mask" in lines[2] + assert "+ = marked positions" in lines[2] + assert lines[3] == s.KEY_LEGEND + assert s.ax.get_title() == s.title_text From 8ecbd69e08deb2008c829b3bc27dce4a390f2301 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 7 Oct 2026 08:07:38 +0100 Subject: [PATCH 5/8] fix(gui): clip each Scribbler brush circle to its own panel A circle near a panel's inner edge was rasterised on the whole side-by-side display, spilled across the gutter into the other panel, and fold_panels then OR-ed it back onto the far edge of the image (e.g. a stroke at column 38 of a 40 px image masked column 0). Each circle is now clipped to the panel its centre falls in; strokes centred in the gutter go to the nearest panel. Co-Authored-By: Claude Fable 5.1 --- autogalaxy/gui/scribbler.py | 39 ++++++++++++++++++++++++--- test_autogalaxy/gui/test_scribbler.py | 34 +++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/autogalaxy/gui/scribbler.py b/autogalaxy/gui/scribbler.py index abb50693..79e34158 100644 --- a/autogalaxy/gui/scribbler.py +++ b/autogalaxy/gui/scribbler.py @@ -774,12 +774,45 @@ def add_circle_to_mask(self, center, radius, mask): circle_mask = (xx - center[1]) ** 2 + (yy - center[0]) ** 2 <= radius**2 mask[circle_mask] = 1 + def _panel_index_of(self, x: float) -> int: + """ + The side-by-side panel a stroke centred at display column `x` belongs to: the + panel containing it, or the nearest panel for a centre in a gutter (or off the + display), ties going to the left. + """ + if self.n_panels <= 1: + return 0 + n_x = self.image_shape[1] + step = n_x + self.panel_gap + distances = [] + for i in range(self.n_panels): + lo, hi = i * step - 0.5, i * step + n_x - 0.5 + distances.append(max(lo - x, 0.0, x - hi)) + return int(np.argmin(distances)) + def circles_to_mask(self, centers, radii): - # Rasterised on the DISPLAY (which may hold several panels), then folded back - # onto the image grid by `get_scribble_masks`. + """ + Rasterise brush circles on the DISPLAY (which may hold several panels); they are + folded back onto the image grid by `get_scribble_masks`. + + Each circle is clipped to the panel its centre falls in (see `_panel_index_of`), + so a stroke near a panel's inner edge cannot spill across the gutter into the + neighbouring panel and fold back onto the far edge of the image. + """ mask = np.zeros(self.display.shape[:2], dtype=bool) + n_x = self.image_shape[1] + step = n_x + self.panel_gap for center, radius in zip(centers, radii): - self.add_circle_to_mask(center, radius, mask) + if center[0] is None or center[1] is None: + continue + circle = np.zeros_like(mask) + self.add_circle_to_mask(center, radius, circle) + if self.n_panels > 1: + i = self._panel_index_of(center[0]) + in_panel = np.zeros_like(mask) + in_panel[:, i * step : i * step + n_x] = True + circle &= in_panel + mask |= circle return mask def get_scribble_masks(self): diff --git a/test_autogalaxy/gui/test_scribbler.py b/test_autogalaxy/gui/test_scribbler.py index c3073988..1e34bbda 100644 --- a/test_autogalaxy/gui/test_scribbler.py +++ b/test_autogalaxy/gui/test_scribbler.py @@ -257,6 +257,40 @@ def test__proposal_outline_aligns_with_imshow_pixels_on_both_panels(self): assert y.min() == pytest.approx(1.5, abs=1e-6) assert y.max() == pytest.approx(5.5, abs=1e-6) + def test__stroke_at_left_panel_inner_edge_does_not_wrap_onto_column_0(self): + # 40 px panels, 6 px gutter: the right panel starts at display column 46. An + # 8 px brush at column 38 reaches column 46, which used to fold back onto + # image column 0. + s = _scribbler(shape=(40, 40), subtract_radial=True) + s._set_brush_radius(8) + + s.add_circle_to_scribble((38.0, 20.0)) + + masked_cols = np.flatnonzero(s.mask_from().any(axis=0)) + assert list(masked_cols) == list(range(30, 40)) + assert not s.mask_from()[:, 0].any() + + def test__stroke_at_right_panel_inner_edge_does_not_wrap_onto_last_column(self): + s = _scribbler(shape=(40, 40), subtract_radial=True) + s._set_brush_radius(8) + + s.add_circle_to_scribble((46.0 + 1.0, 20.0)) # right panel, image column 1 + + masked_cols = np.flatnonzero(s.mask_from().any(axis=0)) + assert list(masked_cols) == list(range(0, 10)) + assert not s.mask_from()[:, 39].any() + + def test__stroke_centred_in_the_gutter_goes_to_the_nearest_panel(self): + s = _scribbler(shape=(40, 40), subtract_radial=True) + s._set_brush_radius(8) + + s.add_circle_to_scribble((41.0, 20.0)) # gutter, nearer the left panel + assert list(np.flatnonzero(s.mask_from().any(axis=0))) == list(range(33, 40)) + + s.remove_circle_from_scribble() + s.add_circle_to_scribble((44.0, 20.0)) # gutter, nearer the right panel + assert list(np.flatnonzero(s.mask_from().any(axis=0))) == list(range(0, 7)) + def test__subtract_radial_alone_gives_one_panel(self): s = _scribbler(shape=(20, 20), subtract_radial=True, side_by_side=False) From 5ec6c632ee606a78f6bad881f28d5a20c6c3ac68 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 7 Oct 2026 08:08:23 +0100 Subject: [PATCH 6/8] feat: export mask_2d_regridded_from at package level So workspaces can call ag.mask_2d_regridded_from to seed a mask on one waveband's grid from a mask drawn on another. Co-Authored-By: Claude Fable 5.1 --- autogalaxy/__init__.py | 2 ++ test_autogalaxy/gui/test_display_util.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/autogalaxy/__init__.py b/autogalaxy/__init__.py index ff02b28f..fec6097c 100644 --- a/autogalaxy/__init__.py +++ b/autogalaxy/__init__.py @@ -122,6 +122,7 @@ from . import cosmology as cosmo from .gui.clicker import Clicker from .gui.scribbler import Scribbler +from .gui.display_util import mask_2d_regridded_from from autonerves import conf from autonerves.fitsable import ndarray_via_hdu_from @@ -154,6 +155,7 @@ def __getattr__(name): return interop raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + # --------------------------------------------------------------------------- # Public re-export of the autonerves configuration / serialization surface. # diff --git a/test_autogalaxy/gui/test_display_util.py b/test_autogalaxy/gui/test_display_util.py index 0c12ec13..861338f8 100644 --- a/test_autogalaxy/gui/test_display_util.py +++ b/test_autogalaxy/gui/test_display_util.py @@ -219,3 +219,8 @@ def test__non_finite_pixels_map_to_zero_and_flat_images_do_not_divide_by_zero(se assert (du.arcsinh_stretch(np.ones((4, 4))) == 0.0).all() assert (du.arcsinh_stretch(np.full((4, 4), np.nan)) == 0.0).all() + + def test__mask_2d_regridded_from_is_exported_at_package_level(self): + import autogalaxy as ag + + assert ag.mask_2d_regridded_from is du.mask_2d_regridded_from From 03ae1fcab67da21e418378275c22d9c8c5a82f12 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 7 Oct 2026 08:09:20 +0100 Subject: [PATCH 7/8] feat(gui): warn when Scribbler norm= is ignored by a non-linear stretch Under the new default stretch="arcsinh" a passed norm was silently dropped (it only applies with stretch="linear"); emit a UserWarning saying so. Co-Authored-By: Claude Fable 5.1 --- autogalaxy/gui/scribbler.py | 13 ++++++++++++- test_autogalaxy/gui/test_scribbler.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/autogalaxy/gui/scribbler.py b/autogalaxy/gui/scribbler.py index 79e34158..ecf02b87 100644 --- a/autogalaxy/gui/scribbler.py +++ b/autogalaxy/gui/scribbler.py @@ -1,3 +1,4 @@ +import warnings from collections import OrderedDict from typing import List, Optional, Tuple @@ -125,7 +126,9 @@ def __init__( *norm* / *vmin* / *vmax* arguments below. norm ``"log"`` for a logarithmic colour scale, ``"linear"`` (or ``None``) - otherwise. Only used with `stretch="linear"`. + otherwise. Only used with `stretch="linear"`; passing it with any + other stretch emits a `UserWarning`, since it would otherwise be + silently ignored. vmin, vmax The data values at the ends of the colour scale, for either stretch. brush_width @@ -246,6 +249,14 @@ def __init__( if stretch not in ("arcsinh", "linear"): raise ValueError(f"stretch must be 'arcsinh' or 'linear', not {stretch!r}") self.stretch = stretch + if norm is not None and stretch != "linear": + warnings.warn( + f"Scribbler: norm={norm!r} is ignored with stretch={stretch!r}; " + "it only applies with stretch='linear'. Pass stretch='linear' to use " + "it, or vmin / vmax to set the ends of the arcsinh stretch.", + UserWarning, + stacklevel=2, + ) # brush self.brush_radius = max(int(self.image_shape[0] * brush_width), min_radius) diff --git a/test_autogalaxy/gui/test_scribbler.py b/test_autogalaxy/gui/test_scribbler.py index 1e34bbda..0444a46b 100644 --- a/test_autogalaxy/gui/test_scribbler.py +++ b/test_autogalaxy/gui/test_scribbler.py @@ -6,6 +6,7 @@ """ from types import SimpleNamespace +import warnings from unittest import mock @@ -413,6 +414,16 @@ def test__unknown_stretch_is_rejected(self): with pytest.raises(ValueError): _scribbler(subtract_radial=True, stretch="sqrt") + def test__norm_with_arcsinh_stretch_warns_that_it_is_ignored(self): + with pytest.warns(UserWarning, match="norm='log' is ignored"): + _scribbler(norm="log") + + def test__norm_with_linear_stretch_does_not_warn(self): + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + _scribbler(norm="log", stretch="linear") + _scribbler() + class TestBrushColour: def test__brush_ring_and_halo_follow_the_active_brush(self): From c0a9a387e56671deea4b16dcbbe43f4686e9d403 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 7 Oct 2026 08:09:32 +0100 Subject: [PATCH 8/8] docs(gui): document regrid footprint and radial-subtract centring assumptions mask_regridded_from / mask_2d_regridded_from return unmasked (False) outside the source footprint, so they suit extra-galaxy masks but not a fit-region mask regridded onto a larger grid; radial_median_subtract assumes the lens is at the array centre. Co-Authored-By: Claude Fable 5.1 --- autogalaxy/gui/display_util.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/autogalaxy/gui/display_util.py b/autogalaxy/gui/display_util.py index 0f73aace..ad8b0eb5 100644 --- a/autogalaxy/gui/display_util.py +++ b/autogalaxy/gui/display_util.py @@ -24,6 +24,10 @@ def radial_median_subtract( """ Subtract the azimuthally-averaged (median) radial profile about the array centre. + The profile is measured about the centre of the array, so this assumes the lens (or + galaxy) sits at the array centre; an off-centre galaxy leaves a strong dipole + residual instead of being removed. + A smooth, near-circular galaxy is almost entirely a function of radius, while lensed arcs, companions and other structure are not; subtracting the per-radius median removes the galaxy and leaves that structure standing out, including what is buried @@ -192,8 +196,12 @@ def mask_regridded_from( Each destination pixel centre is converted to scaled coordinates, then to the source pixel containing it, whose value is taken. Nearest neighbour is the right resampling - for a boolean: there is no interpolation across the True / False boundary. Destination - pixels outside the source footprint are `False` (unmasked). + for a boolean: there is no interpolation across the True / False boundary. + + Destination pixels outside the source footprint are always `False` (unmasked). That + suits masks of extra emission to remove (e.g. extra-galaxy masks), but NOT a + fit-region mask (True = excluded from the fit) regridded onto a larger grid: the + region beyond the source footprint would come back unmasked and enter the fit. Parameters ---------- @@ -242,6 +250,10 @@ def mask_2d_regridded_from( """ `mask_regridded_from` for an `aa.Mask2D`: returns a `Mask2D` on the new grid covering the same scaled-coordinate region. + + Pixels outside the source mask's footprint are returned unmasked (`False`), so this + is suitable for extra-galaxy masks but not for a fit-region mask regridded onto a + larger grid (see `mask_regridded_from`). """ regridded = mask_regridded_from( mask_native=np.asarray(mask),