Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions anyplotlib/plot2d/_plot2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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")
Expand Down
54 changes: 54 additions & 0 deletions anyplotlib/tests/test_plot2d/test_imshow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions upcoming_changes/+overlay_mask_tile.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
Loading