11"""Segmentation and interactive mask editing for the Streamlit app."""
22
3- import hashlib
3+ import base64
4+ import io
45from streamlit_image_coordinates import streamlit_image_coordinates
56import numpy as np
67import streamlit as st
1415 apply_undo ,
1516 disp_to_full ,
1617 full_to_disp ,
18+ view_token ,
1719)
1820from 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 )
100100def 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
110126def 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
173220def _selection_of (chart_key : str , kind : str ) -> list :
@@ -177,7 +224,7 @@ def _selection_of(chart_key: str, kind: str) -> list:
177224
178225
179226def _selection_chart (
180- bg : Image . Image ,
227+ bg : str ,
181228 disp_w : int ,
182229 disp_h : int ,
183230 chart_key : str ,
0 commit comments