Skip to content

Commit 47a6b3f

Browse files
committed
speed up annotate reruns by caching the encoded canvas
1 parent 39cd5e4 commit 47a6b3f

3 files changed

Lines changed: 106 additions & 13 deletions

File tree

src/helpers/mask_editing_functions.py

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Segmentation and interactive mask editing for the Streamlit app."""
22

3-
import hashlib
3+
import base64
4+
import io
45
from streamlit_image_coordinates import streamlit_image_coordinates
56
import numpy as np
67
import streamlit as st
@@ -14,6 +15,7 @@
1415
apply_undo,
1516
disp_to_full,
1617
full_to_disp,
18+
view_token,
1719
)
1820
from src.helpers.classifying_functions import (
1921
classes_map_from_labels,
@@ -95,16 +97,30 @@ def create_image_mask_overlay(image, mask, classes_map, palette, alpha=0.5):
9597
return (np.clip(out, 0, 1) * 255).astype(np.uint8)
9698

9799

98-
# Caches the current and previous mask overlay.
99-
@st.cache_data(show_spinner=False, max_entries=2)
100100
def cached_image_mask_overlay(
101101
image: np.ndarray,
102102
mask: np.ndarray,
103103
classes_map: dict,
104104
palette: dict,
105105
alpha: float,
106+
token: str,
106107
) -> np.ndarray:
107-
return create_image_mask_overlay(image, mask, classes_map, palette, alpha)
108+
"""The current view's overlay, memoised in one session slot keyed on `token`.
109+
110+
One slot is enough: only the view on screen is ever redrawn, and anything that
111+
would change it (an edit, a zoom, a toggle, undo restoring the previous masks)
112+
moves the token, so a second entry could never be hit. Holding one overlay
113+
rather than two also halves what a session pins for a 5 MP image.
114+
115+
Replaces an @st.cache_data memo, which kept two entries in a process-wide cache
116+
and hashed megabytes of pixels on every hit — and, for masks above 500k
117+
elements, hashed only a 100k sample of them."""
118+
slot = ss.get("_overlay_slot")
119+
if slot is not None and slot[0] == token:
120+
return slot[1]
121+
out = create_image_mask_overlay(image, mask, classes_map, palette, alpha)
122+
ss["_overlay_slot"] = (token, out)
123+
return out
108124

109125

110126
def create_image_display(rec, viewport=800):
@@ -145,8 +161,10 @@ def create_image_display(rec, viewport=800):
145161
else bg_disp
146162
)
147163
# the cropped mask is downsized (NEAREST) to the background in the overlay helper
164+
# (ss["view"] is set above, so the token already reflects this crop)
148165
base_img = cached_image_mask_overlay(
149-
background, mask[oy : oy + ch, ox : ox + cw], classes_map, palette, alpha=0.35
166+
background, mask[oy : oy + ch, ox : ox + cw], classes_map, palette, 0.35,
167+
view_token(),
150168
)
151169
else:
152170
base_img = bg_disp
@@ -162,12 +180,41 @@ def _commit_mask(rec: Record, mask_full: MaskArray) -> None:
162180
rec.setdefault("labels", {})[int(new_id)] = rec["labels"].get(int(new_id), None)
163181

164182

165-
def _chart_bg(base_img: ImageArray, key_ns: str, name: str) -> tuple[Image.Image, str]:
166-
"""Plotly background image plus its chart key.
183+
# Background encoder. PNG at the fastest compression keeps mask edges crisp; "jpeg"
184+
# encodes ~20x faster again and halves the payload, at the cost of being lossy.
185+
_BG_FORMAT = "png"
186+
187+
188+
def _background_source(base_img: ImageArray, token: str) -> str:
189+
"""Base64 data-URI of the display image, encoded once per view.
190+
191+
Plotly re-encodes a PIL image to base64 PNG inside every `add_layout_image`
192+
call, which dominates a rerun. Handing it an already-encoded string instead
193+
means unchanged views (mode switches, shortcuts, the refocus shim) skip the
194+
encode entirely. One slot: only the current view is ever redrawn."""
195+
slot = ss.get("_bg_uri_slot")
196+
if slot is not None and slot[0] == token:
197+
return slot[1]
198+
199+
buf = io.BytesIO()
200+
im = Image.fromarray(base_img).convert("RGB")
201+
if _BG_FORMAT == "jpeg":
202+
im.save(buf, format="JPEG", quality=90)
203+
else:
204+
im.save(buf, format="PNG", compress_level=1)
205+
uri = f"data:image/{_BG_FORMAT};base64," + base64.b64encode(buf.getvalue()).decode()
206+
ss["_bg_uri_slot"] = (token, uri)
207+
return uri
208+
209+
210+
def _chart_bg(base_img: ImageArray, key_ns: str, name: str) -> tuple[str, str]:
211+
"""Plotly background source plus its chart key, both keyed on the view token.
167212
168-
The key carries an image hash so Streamlit doesn't reuse chart state across images."""
169-
bg = Image.fromarray(base_img).convert("RGBA")
170-
return bg, f"{key_ns}_plotly_{name}_{hashlib.md5(bg.tobytes()).hexdigest()[:8]}"
213+
The token stands in for a hash of the display image: it changes whenever the
214+
view does, so Streamlit still doesn't reuse chart state across images, without
215+
hashing megabytes of pixels on every rerun."""
216+
token = view_token()
217+
return _background_source(base_img, token), f"{key_ns}_plotly_{name}_{token}"
171218

172219

173220
def _selection_of(chart_key: str, kind: str) -> list:
@@ -177,7 +224,7 @@ def _selection_of(chart_key: str, kind: str) -> list:
177224

178225

179226
def _selection_chart(
180-
bg: Image.Image,
227+
bg: str,
181228
disp_w: int,
182229
disp_h: int,
183230
chart_key: str,

src/helpers/state_ops.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import copy
2+
import hashlib
23
import io
4+
import zlib
35
from pathlib import Path
46
import streamlit as st
57
import numpy as np
@@ -186,6 +188,47 @@ def get_current_rec():
186188
return ss.images.get(k) if k is not None else None
187189

188190

191+
def view_token() -> str:
192+
"""Short fingerprint of everything the annotate display is drawn from.
193+
194+
Changes whenever the pixels on screen would change — the image, its masks or
195+
labels, the zoom/pan crop, the view toggles — and stays put otherwise, so it can
196+
key both the chart widget and the encoded background image.
197+
198+
Derived from the data rather than bumped by callers: a missed call site would
199+
leave a stale image on screen, whereas a missed *input* here costs only a
200+
redundant re-render. Boxes are deliberately excluded — they are drawn as figure
201+
shapes, not into the background, so including them would remount the chart on
202+
every box drawn.
203+
"""
204+
rec = get_current_rec()
205+
if rec is None:
206+
return "empty"
207+
labels = rec.get("labels") or {}
208+
fingerprint = (
209+
ss.get("current_key"),
210+
_mask_crc(rec.get("masks")),
211+
len(labels),
212+
hash(frozenset(labels.items())),
213+
ss.get("view"),
214+
ss.get("show_overlay"),
215+
ss.get("show_image"),
216+
ss.get("show_normalized"),
217+
)
218+
return hashlib.md5(repr(fingerprint).encode()).hexdigest()[:8]
219+
220+
221+
def _mask_crc(masks) -> int | None:
222+
"""CRC32 of the label image. Exact, and faster than max()+count_nonzero() on the
223+
same array — zlib's crc32 is hardware-accelerated, so even a 10 MB mask costs
224+
~0.4 ms. Being exact matters: summary statistics miss an edit that renumbers
225+
instances without changing how many pixels are covered."""
226+
if masks is None:
227+
return None
228+
buf = masks if masks.flags.c_contiguous else np.ascontiguousarray(masks)
229+
return zlib.crc32(memoryview(buf).cast("B"))
230+
231+
189232
def snapshot_for_undo(rec) -> None:
190233
"""Save the one app-wide undo snapshot for the current image.
191234

src/panels/fine_tune_panel.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,12 @@ def get_train_setup():
400400
)
401401

402402

403-
@st.cache_data(show_spinner=False)
404403
def prepare_eval_data(recs):
405-
"""Preprocess all images in recs order (matches the finetune worker's order)."""
404+
"""Preprocess all images in recs order (matches the finetune worker's order).
405+
406+
Deliberately uncached: it runs once when validation starts, so an @st.cache_data
407+
memo here only pinned a grayscale copy of every training image for the rest of
408+
the session (with no max_entries, one set per distinct selection)."""
406409
names = [rec.get("name", f"Image {i}") for i, rec in enumerate(recs.values())]
407410
masks = [rec["masks"] for rec in recs.values()]
408411
images = [preprocess_for_cellpose(rec) for rec in recs.values()]

0 commit comments

Comments
 (0)