From a61d865813f4854a006fa2e86480c8b9d8f93f7c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 2 Aug 2026 10:59:14 -0500 Subject: [PATCH] fix(plot2d): set_overlay_mask was unusable in tile mode, both ways In tile mode NO mask shape could be drawn. The renderer sizes the overlay against `base_width || image_width` -- the OVERVIEW grid -- but `enable_tile` sets `image_width`/`image_height` to the FULL native frame, and the shape check compared only against those. So: * an overview-sized mask (what the renderer wants) raised ValueError here; * a full-resolution mask was accepted, encoded, and then silently discarded by the renderer (`mBytes.length===iw*ih` else `maskCache=null` -- no error, no overlay, nothing in any log). Measured on a 4096x4096 tiled plot: the first raised, the second shipped 22.4 MB of base64 the renderer dropped on the floor. A caller could not get this right, because there was no right answer. Both shapes are now accepted and a full-resolution mask is reduced to the overview grid before encoding, so the same call works tiled or not and ships 1.4 MB instead of 22.4 MB. The reduction is a block ANY (`_reduce_mask_any`), never a subsample: a mask marks WHERE OBJECTS ARE, and objects a few pixels across must not vanish because the sampled pixel missed them -- striding 4096 -> 1024 keeps one pixel in sixteen. The uneven tail rows/columns fold into the last block rather than being cropped, so an object at the right or bottom edge survives too. Two tests, both asserting the bytes that actually SHIP rather than the array handed in: either input shape ships exactly `base_width*base_height` bytes and stays non-empty, and a single lit pixel off a block boundary survives the reduction. 2002 passed, 58 skipped. --- anyplotlib/plot2d/_plot2d.py | 69 +++++++++++++++++-- anyplotlib/tests/test_plot2d/test_imshow.py | 54 +++++++++++++++ .../+overlay_mask_tile.bugfix.rst | 10 +++ 3 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 upcoming_changes/+overlay_mask_tile.bugfix.rst diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 32d1d9f9..43586021 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -45,6 +45,40 @@ def _binary_transport_active() -> bool: return os.environ.get("APL_BINARY_TRANSPORT") == "1" +def _reduce_mask_any(mask, out_h: int, out_w: int): + """Reduce a boolean *mask* to ``(out_h, out_w)`` with a block ANY. + + A block of the source is True in the output if ANY of its pixels is. For a + mask that is the only defensible reduction: it marks *where objects are*, + and an object smaller than one output block must not vanish because the + sampled pixel happened to miss it. + + The tail rows/columns that do not divide evenly are folded into the last + block rather than cropped, so an object at the right or bottom edge is kept. + """ + import numpy as _np + + src_h, src_w = mask.shape + if (src_h, src_w) == (out_h, out_w): + return mask + ys = max(1, src_h // out_h) + xs = max(1, src_w // out_w) + keep_h, keep_w = (src_h // ys) * ys, (src_w // xs) * xs + small = mask[:keep_h, :keep_w].reshape( + keep_h // ys, ys, keep_w // xs, xs).any(axis=(1, 3)) + out = _np.zeros((out_h, out_w), bool) + sh, sw = min(out_h, small.shape[0]), min(out_w, small.shape[1]) + out[:sh, :sw] = small[:sh, :sw] + # Fold any cropped tail into the final block so edge objects survive. + if keep_h < src_h and sh > 0: + out[sh - 1, :sw] |= mask[keep_h:, :keep_w].reshape( + 1, src_h - keep_h, keep_w // xs, xs).any(axis=(1, 3))[0, :sw] + if keep_w < src_w and sw > 0: + out[:sh, sw - 1] |= mask[:keep_h, keep_w:].reshape( + keep_h // ys, ys, 1, src_w - keep_w).any(axis=(1, 3))[:sh, 0] + return out + + class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin): """2-D image plot panel. @@ -1115,6 +1149,13 @@ def set_overlay_mask(self, mask: "np.ndarray | None", Boolean array aligned to the image data. ``True`` / non-zero pixels are filled with *color* at transparency *alpha*. Pass ``None`` to clear the overlay. + + In TILE mode pass the mask at the FULL image resolution: the + renderer composites the mask against the OVERVIEW texture, so it + is reduced to the base grid here. A mask already at the base grid + is accepted unchanged. (Both are accepted because neither is + wrong; what was wrong was accepting only the full-resolution one + and then shipping bytes the renderer silently discards.) color : str, optional CSS hex colour for the overlay, e.g. ``"#ff4444"``. Default red. Must be in ``#RRGGBB`` format. @@ -1137,14 +1178,32 @@ def set_overlay_mask(self, mask: "np.ndarray | None", self._state["overlay_mask_alpha"] = alpha else: arr = np.asarray(mask) - if arr.shape != (self._state["image_height"], self._state["image_width"]): - raise ValueError( - f"mask shape {arr.shape} does not match image " - f"({self._state['image_height']} x {self._state['image_width']})" - ) + ih, iw = self._state["image_height"], self._state["image_width"] + bh = int(self._state.get("base_height") or 0) + bw = int(self._state.get("base_width") or 0) + # THE TILE-MODE SHAPE TRAP. The renderer sizes the mask against + # `base_width || image_width` -- the OVERVIEW grid -- but tile mode + # sets image_width/height to the FULL native frame. So validating + # only against the image shape rejected the one shape that renders + # and accepted the one the renderer drops on the floor + # (`mBytes.length===iw*ih` else `maskCache=null`, silently). Both + # shapes are legal here; the overview is what actually ships. + tiled = bw > 0 and bh > 0 and (bh, bw) != (ih, iw) + if arr.shape != (ih, iw) and not (tiled and arr.shape == (bh, bw)): + want = f"({ih} x {iw})" + if tiled: + want += f" or the tile overview ({bh} x {bw})" + raise ValueError(f"mask shape {arr.shape} does not match image {want}") # For origin='lower' the image data was flipped; flip mask to match. if self._origin == "lower": arr = np.flipud(arr) + if tiled and arr.shape == (ih, iw): + # Block ANY, never a subsample: a mask marks objects that are + # often only a few pixels across, and striding a 4096² mask + # down to 1024² drops three quarters of them at random. ANY + # keeps every object visible at the cost of fattening it, which + # is the right trade for an overlay you are looking at. + arr = _reduce_mask_any(np.asarray(arr, dtype=bool), bh, bw) # Convert to uint8: True/non-zero → 255, False/zero → 0 u8 = (np.asarray(arr, dtype=bool).view(np.uint8) * 255).astype(np.uint8) self._state["overlay_mask_b64"] = base64.b64encode(u8.tobytes()).decode("ascii") diff --git a/anyplotlib/tests/test_plot2d/test_imshow.py b/anyplotlib/tests/test_plot2d/test_imshow.py index 711a2a7f..d7b35e5b 100644 --- a/anyplotlib/tests/test_plot2d/test_imshow.py +++ b/anyplotlib/tests/test_plot2d/test_imshow.py @@ -575,6 +575,60 @@ def test_set_overlay_mask_shape_mismatch(self): with pytest.raises(ValueError, match="mask shape"): plot.set_overlay_mask(bad_mask) + def test_overlay_mask_in_TILE_mode_ships_the_overview_size(self): + """The mask must be sized for what the RENDERER checks. + + The renderer sizes the mask against ``base_width || image_width`` — the + OVERVIEW grid — and on a mismatch sets ``maskCache=null``: no error, no + overlay, nothing in the log. Tile mode sets ``image_width`` to the FULL + native frame, so validating a mask only against the image shape both + rejected the one shape that renders and accepted the one the renderer + drops. A caller that did the reduction itself got a ValueError; one that + did not got 22 MB of bytes silently discarded. + + Either shape in, overview-sized bytes out. + """ + import base64 + + plot = apl.subplots(1, 1)[1].imshow( + np.zeros((2048, 2048), np.uint8), tile="auto") + st = plot._state + bw, bh = st.get("base_width"), st.get("base_height") + assert bw and bh and (bw, bh) != (st["image_width"], st["image_height"]), \ + "this test needs a genuinely tiled plot with a smaller base grid" + + for shape in [(st["image_height"], st["image_width"]), (bh, bw)]: + mask = np.zeros(shape, bool) + mask[::5, ::5] = True + plot.set_overlay_mask(mask) + sent = base64.b64decode(st["overlay_mask_b64"]) + assert len(sent) == bw * bh, ( + f"a {shape} mask shipped {len(sent)} bytes; the renderer " + f"expects {bw * bh} and drops anything else silently") + assert any(sent), "the reduction emptied the mask" + + def test_overlay_mask_reduction_is_block_ANY_not_a_subsample(self): + """A single-pixel object 4 blocks in must survive the reduction. + + Objects here are often a few pixels across; a strided sample of a 2048² + mask at 512² keeps one pixel in sixteen and drops them at random. + """ + import base64 + + plot = apl.subplots(1, 1)[1].imshow( + np.zeros((2048, 2048), np.uint8), tile="auto") + st = plot._state + bw, bh = st["base_width"], st["base_height"] + mask = np.zeros((st["image_height"], st["image_width"]), bool) + # Deliberately NOT on a block boundary — a subsample would miss it. + mask[537, 921] = True + plot.set_overlay_mask(mask) + sent = np.frombuffer(base64.b64decode(st["overlay_mask_b64"]), np.uint8) + # Bytes are 0/255, so COUNT the lit ones rather than summing them. + assert np.count_nonzero(sent.reshape(bh, bw)) == 1, ( + "the single lit pixel did not survive the reduction — this is a " + "subsample, not a block ANY") + def test_set_overlay_mask_alpha_boundary(self): plot = _img(n=16) mask = np.zeros((16, 16), dtype=bool) diff --git a/upcoming_changes/+overlay_mask_tile.bugfix.rst b/upcoming_changes/+overlay_mask_tile.bugfix.rst new file mode 100644 index 00000000..7f06a1e4 --- /dev/null +++ b/upcoming_changes/+overlay_mask_tile.bugfix.rst @@ -0,0 +1,10 @@ +``Plot2D.set_overlay_mask`` now works in TILE mode. The renderer sizes the mask +against ``base_width || image_width`` -- the tile overview grid -- but tile mode +sets ``image_width`` to the full native frame, so the shape check accepted only +the one shape the renderer silently discards (``maskCache = null``, no error) and +rejected the one that actually renders. On a 4096x4096 tiled plot neither a +1024x1024 nor a 4096x4096 mask could be drawn: the first raised ``ValueError``, +the second encoded 22.4 MB the renderer dropped. Both shapes are now accepted and +a full-resolution mask is reduced to the overview grid with a block ANY -- never +a subsample, so an object a few pixels across cannot vanish into a skipped +sample.