@@ -170,9 +170,141 @@ def _copy(
170170 tables = self ._sdata .tables if tables is None else tables ,
171171 )
172172 sdata .plotting_tree = self ._sdata .plotting_tree if hasattr (self ._sdata , "plotting_tree" ) else OrderedDict ()
173+ sdata ._source_sdata = getattr (self ._sdata , "_source_sdata" , self ._sdata )
173174
174175 return sdata
175176
177+ def annotate (
178+ self ,
179+ * ,
180+ coordinate_systems : str | None = None ,
181+ point_radius_frac : float = 0.005 ,
182+ figsize : tuple [float , float ] = (7 , 7 ),
183+ dpi : int = 120 ,
184+ ) -> Any :
185+ """Terminal step on a render chain: drop the plot into an interactive annotator.
186+
187+ Renders the accumulated ``plotting_tree`` (so any ``render_images`` /
188+ ``render_shapes`` / ``render_points`` / ``render_labels`` overlays composed
189+ upstream of this call appear in the annotation canvas), then hands the
190+ rasterised figure to a ``BioImageViewer`` widget. The user draws
191+ rectangles, polygons, and points on the canvas, types a name, and clicks
192+ *Save* — the shapes are converted from canvas-pixel space to the chosen
193+ coordinate system and stored in ``sdata.shapes[<name>]`` with an
194+ ``Identity`` transformation in that CS. Points are stored as small
195+ circle polygons (radius = ``point_radius_frac`` of the rendered image's
196+ CS extent) so the resulting ``ShapesModel`` is uniform-type.
197+
198+ Single coordinate system only. If the chain spans more than one CS, or
199+ none can be inferred, raises ``ValueError``.
200+
201+ Requires the ``interactive`` extra: ``pip install 'spatialdata-plot[interactive]'``.
202+
203+ Parameters
204+ ----------
205+ coordinate_systems :
206+ Coordinate system to render and resolve drawn shapes against.
207+ Drawn shapes are stored with an ``Identity`` transformation in this
208+ CS. If ``None`` and the SpatialData has exactly one CS, that one is
209+ used; otherwise this argument is required.
210+ point_radius_frac :
211+ Radius of the circle polygon used to store each point, expressed as
212+ a fraction of the rendered image's CS extent. Default 0.005 (0.5%).
213+ figsize :
214+ Matplotlib figure size used for the underlying rasterisation. The
215+ same value affects the canvas resolution alongside ``dpi``.
216+ dpi :
217+ DPI of the rasterised figure. Combined with ``figsize`` this sets
218+ the pixel resolution the annotator works in.
219+
220+ Returns
221+ -------
222+ InteractiveSession
223+ The session object, with the widget already displayed. Holding the
224+ reference keeps the underlying ``BioImageViewer`` alive across cell
225+ re-runs; usually you can ignore the return value.
226+
227+ Raises
228+ ------
229+ ValueError
230+ If no single coordinate system can be resolved.
231+ ImportError
232+ If the ``interactive`` extra is not installed.
233+
234+ Examples
235+ --------
236+ >>> import spatialdata_plot # noqa: F401 registers .pl
237+ >>> (
238+ ... sdata.pl
239+ ... .render_images(element="he")
240+ ... .pl.render_shapes(element="cells", outline_color="red")
241+ ... .pl.annotate()
242+ ... )
243+ >>> # ... user draws and clicks Save with name "tumor" ...
244+ >>> sdata.shapes["tumor"]
245+ """
246+ try :
247+ from spatialdata_plot .pl .interactive ._session import _InteractiveSession
248+ except ImportError as exc :
249+ raise ImportError (
250+ "sdata.pl.annotate() requires the `interactive` extra. "
251+ "Install with: pip install 'spatialdata-plot[interactive]'"
252+ ) from exc
253+
254+ import io as _io
255+
256+ from PIL import Image as _Image
257+
258+ available_cs = list (self ._sdata .coordinate_systems )
259+ if coordinate_systems is None :
260+ if len (available_cs ) != 1 :
261+ raise ValueError (
262+ "annotate() needs exactly one coordinate system. "
263+ f"SpatialData has { len (available_cs )} : { available_cs !r} . "
264+ "Pass coordinate_systems=<name> explicitly."
265+ )
266+ cs = available_cs [0 ]
267+ else :
268+ if isinstance (coordinate_systems , list ):
269+ if len (coordinate_systems ) != 1 :
270+ raise ValueError (f"annotate() supports a single coordinate system; got { coordinate_systems !r} ." )
271+ cs = coordinate_systems [0 ]
272+ else :
273+ cs = coordinate_systems
274+ if cs not in available_cs :
275+ raise ValueError (f"Unknown coordinate system { cs !r} . Available: { available_cs !r} " )
276+
277+ fig = plt .figure (figsize = figsize , dpi = dpi )
278+ try :
279+ ax = fig .add_axes ([0 , 0 , 1 , 1 ])
280+ self .show (coordinate_systems = cs , ax = ax )
281+ xlim = ax .get_xlim ()
282+ ylim = ax .get_ylim ()
283+ ax .set_axis_off ()
284+ # set_aspect("equal") inside show() can shrink the axes box so the
285+ # figure has blank padding around the data. Crop the saved PNG to
286+ # the axes bbox so PNG pixels map 1:1 to (xlim, ylim) and the
287+ # px→cs transform in _commit.py stays correct.
288+ fig .canvas .draw ()
289+ bbox = ax .get_window_extent ().transformed (fig .dpi_scale_trans .inverted ())
290+ buf = _io .BytesIO ()
291+ fig .savefig (buf , format = "png" , dpi = dpi , bbox_inches = bbox , pad_inches = 0 )
292+ finally :
293+ plt .close (fig )
294+ rgb = np .asarray (_Image .open (buf ).convert ("RGB" ))
295+
296+ target_sdata = getattr (self ._sdata , "_source_sdata" , self ._sdata )
297+ session = _InteractiveSession (
298+ sdata = target_sdata ,
299+ coordinate_system = cs ,
300+ rgb = rgb ,
301+ xlim = tuple (xlim ),
302+ ylim = tuple (ylim ),
303+ point_radius_frac = point_radius_frac ,
304+ )
305+ session .show ()
306+ return session
307+
176308 @_deprecation_alias (elements = "element" , version = "0.3.0" )
177309 def render_shapes (
178310 self ,
0 commit comments