1919from spatialdata ._io ._utils import _resolve_zarr_store
2020from tqdm .notebook import tqdm
2121import json
22+ import threading
2223from concurrent .futures import ThreadPoolExecutor , as_completed
2324
25+ # Matplotlib's pyplot interface is NOT thread-safe. All plot functions that
26+ # may be called from worker threads must acquire this lock before touching any
27+ # plt.* state.
28+ _MATPLOTLIB_LOCK = threading .Lock ()
29+
2430from spatialdata .transformations import (
2531 get_transformation ,
2632 set_transformation ,
@@ -225,6 +231,14 @@ def plot_img_landmark_transforms(sdata_img,
225231 in a background thread (matplotlib is not thread-safe) or in batch mode
226232 to avoid blocking; the figure is saved to ``save_path`` and then closed.
227233 """
234+ with _MATPLOTLIB_LOCK :
235+ _plot_img_landmark_transforms_inner (
236+ sdata_img , lm_img , best_transformed_img , transform_info ,
237+ landmarks , landmarks_out , section , save_path , show )
238+
239+ def _plot_img_landmark_transforms_inner (sdata_img , lm_img , best_transformed_img ,
240+ transform_info , landmarks , landmarks_out ,
241+ section , save_path , show ):
228242 fig , axes = plt .subplots (1 , 3 , figsize = (18 , 5 ))
229243 axes [0 ].imshow (lm_img , cmap = 'gray' )
230244 if landmarks is not None :
@@ -260,6 +274,7 @@ def plot_manual_landmark_transforms(landmarks_before,
260274 landmarks_after ,
261275 landmarked_image_path ,
262276 sdata ,
277+ ch = 0 ,
263278 landmarks_tf_info = None ,
264279 section = None ,
265280 save_path = None ,
@@ -301,14 +316,16 @@ def plot_manual_landmark_transforms(landmarks_before,
301316 # means the first axis is height, i.e. (H, W) or (H, W, C).
302317 with tifffile .TiffFile (landmarked_image_path ) as tif :
303318 lm_stack = tif .asarray ()
319+ # Disambiguate (C, H, W) vs (H, W, C): a small leading dimension (≤8)
320+ # means channel-first; a large one means height is axis 0.
304321 if lm_stack .ndim == 2 :
305322 lm_img = lm_stack
306323 elif lm_stack .ndim == 3 and lm_stack .shape [0 ] <= 8 : # (C, H, W)
307- lm_img = lm_stack [min (1 , lm_stack .shape [0 ] - 1 )]
324+ lm_img = lm_stack [min (ch , lm_stack .shape [0 ] - 1 )]
308325 elif lm_stack .ndim == 3 : # (H, W, C)
309- lm_img = lm_stack [:, :, min (1 , lm_stack .shape [2 ] - 1 )]
310- else : # unexpected shape — take first plane
311- lm_img = lm_stack .reshape (- 1 , lm_stack .shape [- 2 ], lm_stack .shape [- 1 ])[0 ]
326+ lm_img = lm_stack [:, :, min (ch , lm_stack .shape [2 ] - 1 )]
327+ else :
328+ lm_img = lm_stack .reshape (- 1 , lm_stack .shape [- 2 ], lm_stack .shape [- 1 ])[ch ]
312329
313330 # ── Load sdata morphology at a downsampled level for speed ────────────
314331 # Full-res (n=0) can be 4k–8k px and is slow to materialise; a lower
@@ -326,41 +343,42 @@ def plot_manual_landmark_transforms(landmarks_before,
326343 scale_y = sdata_img .shape [0 ] / full_res_shape [0 ]
327344 scale_x = sdata_img .shape [1 ] / full_res_shape [1 ]
328345
329- fig , axes = plt .subplots (1 , 2 , figsize = (14 , 6 ))
330-
331- # ── Left: landmarked image + original landmarks ───────────────────────
332- axes [0 ].imshow (lm_img , cmap = 'gray' )
333- if 'xenium_x' in landmarks_before .columns :
334- axes [0 ].scatter (landmarks_before ['xenium_x' ], landmarks_before ['xenium_y' ],
346+ with _MATPLOTLIB_LOCK :
347+ fig , axes = plt .subplots (1 , 2 , figsize = (14 , 6 ))
348+
349+ # ── Left: landmarked image + original landmarks ───────────────────
350+ axes [0 ].imshow (lm_img , cmap = 'gray' )
351+ if 'xenium_x' in landmarks_before .columns :
352+ axes [0 ].scatter (landmarks_before ['xenium_x' ], landmarks_before ['xenium_y' ],
353+ c = 'red' , s = 15 , zorder = 5 )
354+ axes [0 ].set_title ('Landmarked image + original landmarks (image pixel space)' )
355+
356+ # ── Right: sdata image + transformed landmarks ────────────────────
357+ # Scale landmark coordinates from full-res pixel space to display level.
358+ axes [1 ].imshow (sdata_img , cmap = 'gray' )
359+ axes [1 ].scatter (landmarks_after ['xenium_x' ] * scale_x ,
360+ landmarks_after ['xenium_y' ] * scale_y ,
335361 c = 'red' , s = 15 , zorder = 5 )
336- axes [0 ].set_title ('Landmarked image + original landmarks (image pixel space)' )
337-
338- # ── Right: sdata image + transformed landmarks ────────────────────────
339- # Scale landmark coordinates from full-res pixel space to display level.
340- axes [1 ].imshow (sdata_img , cmap = 'gray' )
341- axes [1 ].scatter (landmarks_after ['xenium_x' ] * scale_x ,
342- landmarks_after ['xenium_y' ] * scale_y ,
343- c = 'red' , s = 15 , zorder = 5 )
344- subtitle = 'sdata morphology_focus (scale0) + transformed landmarks'
345- if landmarks_tf_info is not None :
346- sx = landmarks_tf_info .get ('scale_factor_x' )
347- sy = landmarks_tf_info .get ('scale_factor_y' )
348- bbox = landmarks_tf_info .get ('bbox' )
349- if sx is not None and sy is not None :
350- subtitle += f'\n scale (x, y): ({ sx :.3f} , { sy :.3f} )'
351- if bbox is not None :
352- subtitle += f' | bbox offset: ({ bbox ["x_min" ]} , { bbox ["y_min" ]} )'
353- axes [1 ].set_title (subtitle )
354-
355- plt .tight_layout ()
356- if section is not None :
357- plt .suptitle (f'Section: { section } ' , y = 1.01 )
358- if save_path is not None :
359- plt .savefig (save_path , bbox_inches = 'tight' )
360- if show :
361- plt .show ()
362- else :
363- plt .close (fig )
362+ subtitle = 'sdata morphology_focus (scale0) + transformed landmarks'
363+ if landmarks_tf_info is not None :
364+ sx = landmarks_tf_info .get ('scale_factor_x' )
365+ sy = landmarks_tf_info .get ('scale_factor_y' )
366+ bbox = landmarks_tf_info .get ('bbox' )
367+ if sx is not None and sy is not None :
368+ subtitle += f'\n scale (x, y): ({ sx :.3f} , { sy :.3f} )'
369+ if bbox is not None :
370+ subtitle += f' | bbox offset: ({ bbox ["x_min" ]} , { bbox ["y_min" ]} )'
371+ axes [1 ].set_title (subtitle )
372+
373+ plt .tight_layout ()
374+ if section is not None :
375+ plt .suptitle (f'Section: { section } ' , y = 1.01 )
376+ if save_path is not None :
377+ plt .savefig (save_path , bbox_inches = 'tight' )
378+ if show :
379+ plt .show ()
380+ else :
381+ plt .close (fig )
364382
365383def find_landmarked_img_transforms (landmarked_image_path ,
366384 sdata_path ,
@@ -644,11 +662,19 @@ def get_section_landmarks_threads(xenium_section_ns, paths, alignment_params, n_
644662 pool .submit (_process_section , s_n , paths , alignment_params ): s_n
645663 for s_n in xenium_section_ns
646664 }
665+ failed = []
647666 for fut in tqdm (as_completed (futures ), total = len (futures ),
648667 desc = 'Processing sections' , unit = 'section' ):
649- s_n , lm = fut .result ()
650- if lm is not None :
651- sections_landmarks [s_n ] = lm
668+ s_n = futures [fut ]
669+ try :
670+ s_n_result , lm = fut .result ()
671+ if lm is not None :
672+ sections_landmarks [s_n_result ] = lm
673+ except Exception as exc :
674+ failed .append (s_n )
675+ print (f" Section { s_n } : FAILED with { type (exc ).__name__ } : { exc } " )
676+ if failed :
677+ print (f"\n Warning: { len (failed )} section(s) failed: { sorted (failed )} " )
652678 return sections_landmarks
653679
654680# ── Affine comparison helpers ─────────────────────────────────────────────────
0 commit comments