Skip to content

Commit 1d0f484

Browse files
committed
process_landmarks: fix bugs, remove dead code, add plot_manual_landmark_transforms
- Remove get_bigwarp_params / get_section_landmarks / format_landmarks (dead code superseded by extract_bigwarp_params + _process_section's two-path logic) - Fix NameError in manual_landmarks_transform: landmarks_info only defined when fix_cropped_landmarks=True; refactored so full_scale_pixel_size is always read from sdata up front regardless of which branch is taken - Fix key mismatch: landmarks_info.get('full_scale_pixel_size') -> 'fullres_pixel_size' (the key returned by get_landmarked_image_props) - Add plot_manual_landmark_transforms: two-panel diagnostic figure (landmarked TIFF + original landmarks | sdata morphology + transformed landmarks), analogous to plot_img_landmark_transforms for the sdata-extracted image path - Wire save_imgs_path into _process_section manual branch so validation images are saved from validation_images_folder for 756772-style sections
1 parent eed6d1e commit 1d0f484

1 file changed

Lines changed: 142 additions & 120 deletions

File tree

src/xenium_analysis_tools/alignment/process_landmarks.py

Lines changed: 142 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -64,31 +64,6 @@
6464
lambda x, y, H, W: (W - 1 - y, H - 1 - x)),
6565
]
6666

67-
def get_bigwarp_params(bigwarp_json_path):
68-
with open(bigwarp_json_path, 'r') as f:
69-
bw_json = json.load(f)
70-
for source, source_data in bw_json['Sources'].items():
71-
if source_data['isMoving']:
72-
if 'confocal' in source_data['uri'].lower():
73-
continue
74-
moving_image_path = Path(source_data['uri'])
75-
if moving_image_path.name=='?':
76-
moving_image_path = moving_image_path.parent
77-
else:
78-
reference_image_path = Path(source_data['uri'])
79-
if reference_image_path.name=='?':
80-
reference_image_path = reference_image_path.parent
81-
if 'zstack' in moving_image_path.name.lower() or 'z_stack' in moving_image_path.name.lower():
82-
moving_image = 'czstack'
83-
reference_image = 'xenium_section'
84-
transform_type = bw_json['Transform']['type']
85-
bigwarp_params = {'moving_image': moving_image,
86-
'reference_image': reference_image,
87-
'moving_image_path': moving_image_path,
88-
'reference_image_path': reference_image_path,
89-
'transform_type': transform_type}
90-
return bigwarp_params
91-
9267
def invert_xenium_y_landmarks(landmarks, landmarked_image_path):
9368
with tifffile.TiffFile(landmarked_image_path) as tif:
9469
landmarked_image_shape = tif.pages[0].shape
@@ -107,28 +82,6 @@ def remove_landmark_buffer(landmarks, czstack_buffer=None, xenium_buffer=None):
10782
landmarks['xenium_z'] = landmarks['xenium_z'] - xenium_buffer.get('z', 0)
10883
return landmarks
10984

110-
def get_section_landmarks(landmarks_path=None, dims_order=['x','y','z'], bigwarp_project_path=None, moving_img=None):
111-
if landmarks_path is not None:
112-
if bigwarp_project_path is not None:
113-
bigwarp_params = get_bigwarp_params(bigwarp_project_path)
114-
elif moving_img is not None:
115-
bigwarp_params = {'moving_image': moving_img}
116-
else:
117-
print("No BigWarp project path or moving image specified - assuming czstack was moving image")
118-
bigwarp_params = {'moving_image': 'czstack'}
119-
print(f"Loading landmarks from: {landmarks_path}")
120-
landmarks = pd.read_csv(landmarks_path, header=None)
121-
if bigwarp_params['moving_image']=='czstack':
122-
# Flatten the lists using + operator to concatenate them
123-
landmarks.columns = ['landmark_name', 'active'] + [f'czstack_{dim}' for dim in dims_order] + [f'xenium_{dim}' for dim in dims_order]
124-
else:
125-
landmarks.columns = ['landmark_name', 'active'] + [f'xenium_{dim}' for dim in dims_order] + [f'czstack_{dim}' for dim in dims_order]
126-
else:
127-
landmarks = None
128-
bigwarp_params = None
129-
130-
return landmarks, bigwarp_params
131-
13285
def _normalize_bw_uri(uri):
13386
"""Normalize a BigWarp source URI to a Path.
13487
@@ -302,6 +255,90 @@ def plot_img_landmark_transforms(sdata_img,
302255
else:
303256
plt.close(fig)
304257

258+
259+
def plot_manual_landmark_transforms(landmarks_before,
260+
landmarks_after,
261+
landmarked_image_path,
262+
sdata,
263+
landmarks_tf_info=None,
264+
section=None,
265+
save_path=None,
266+
show=True):
267+
"""Visualise the manual landmark coordinate transform.
268+
269+
Two-panel figure:
270+
Left — landmarked TIFF with original (xenium_x, xenium_y) landmarks in
271+
landmarked-image pixel space.
272+
Right — sdata ``morphology_focus`` (full resolution) with transformed
273+
(xenium_x, xenium_y) landmarks mapped into sdata pixel space,
274+
after all scaling / inversion / bbox-offset corrections.
275+
276+
Parameters
277+
----------
278+
landmarks_before : pd.DataFrame
279+
CSV-loaded landmark table with ``xenium_x``, ``xenium_y`` columns in
280+
landmarked-image pixel space (before any coordinate correction).
281+
landmarks_after : pd.DataFrame
282+
Same table after coordinate corrections (scaling, inversion, offset)
283+
but *before* the column rename to ``x``/``y``. Still has
284+
``xenium_x``/``xenium_y`` columns, now in sdata pixel space.
285+
landmarked_image_path : Path or str
286+
TIFF that was used for BigWarp landmarking.
287+
sdata : SpatialData
288+
Section SpatialData object (already loaded).
289+
landmarks_tf_info : dict or None
290+
Dict returned by ``get_landmarked_image_props``. Used to annotate
291+
scale factors and bbox offset on the right panel.
292+
section : int or str, optional
293+
Section number used as figure suptitle.
294+
save_path : Path or str, optional
295+
show : bool
296+
Display inline. Set ``False`` when called from a worker thread.
297+
"""
298+
# ── Load landmarked TIFF ──────────────────────────────────────────────
299+
with tifffile.TiffFile(landmarked_image_path) as tif:
300+
lm_stack = tif.asarray()
301+
lm_img = lm_stack if lm_stack.ndim == 2 else lm_stack[1]
302+
303+
# ── Load sdata morphology at full resolution ──────────────────────────
304+
morph = sdata['morphology_focus']
305+
sdata_img = np.asarray(sd.get_pyramid_levels(morph, n=0)[1])
306+
307+
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
308+
309+
# ── Left: landmarked image + original landmarks ───────────────────────
310+
axes[0].imshow(lm_img, cmap='gray')
311+
if 'xenium_x' in landmarks_before.columns:
312+
axes[0].scatter(landmarks_before['xenium_x'], landmarks_before['xenium_y'],
313+
c='red', s=15, zorder=5)
314+
axes[0].set_title('Landmarked image + original landmarks (image pixel space)')
315+
316+
# ── Right: sdata image + transformed landmarks ────────────────────────
317+
axes[1].imshow(sdata_img, cmap='gray')
318+
axes[1].scatter(landmarks_after['xenium_x'], landmarks_after['xenium_y'],
319+
c='red', s=15, zorder=5)
320+
subtitle = 'sdata morphology_focus (scale0) + transformed landmarks'
321+
if landmarks_tf_info is not None:
322+
sx = landmarks_tf_info.get('scale_factor_x')
323+
sy = landmarks_tf_info.get('scale_factor_y')
324+
bbox = landmarks_tf_info.get('bbox')
325+
if sx is not None and sy is not None:
326+
subtitle += f'\nscale (x, y): ({sx:.3f}, {sy:.3f})'
327+
if bbox is not None:
328+
subtitle += f' | bbox offset: ({bbox["x_min"]}, {bbox["y_min"]})'
329+
axes[1].set_title(subtitle)
330+
331+
plt.tight_layout()
332+
if section is not None:
333+
plt.suptitle(f'Section: {section}', y=1.01)
334+
if save_path is not None:
335+
plt.savefig(save_path, bbox_inches='tight')
336+
if show:
337+
plt.show()
338+
else:
339+
plt.close(fig)
340+
341+
305342
def find_landmarked_img_transforms(landmarked_image_path,
306343
sdata_path,
307344
landmarks,
@@ -486,13 +523,20 @@ def _process_section(s_n, paths, alignment_params):
486523
print(f" Section {s_n}: landmarks not found at {landmarks_path}, skipping")
487524
return s_n, None
488525
print(f" Manually transforming landmarks...")
489-
landmarked_image_path = paths['data_root'] / alignment_params['landmarked_images_folder'] / alignment_params['landmarked_images_names_fn'](s_n)
490-
formatted_landmarks = manual_landmarks_transform(s_n=s_n,
491-
sdata_path=sdata_path,
492-
landmarks_path=landmarks_path,
493-
landmarked_image_path=landmarked_image_path,
494-
alignment_params=alignment_params,
495-
bigwarp_params=bigwarp_params)
526+
landmarked_image_path = (paths['data_root']
527+
/ alignment_params['landmarked_images_folder']
528+
/ alignment_params['landmarked_images_names_fn'](s_n))
529+
val_folder = alignment_params.get('validation_images_folder')
530+
save_imgs_path = val_folder / f"section_{s_n}_manual.png" if val_folder is not None else None
531+
formatted_landmarks = manual_landmarks_transform(
532+
s_n=s_n,
533+
sdata_path=sdata_path,
534+
landmarks_path=landmarks_path,
535+
landmarked_image_path=landmarked_image_path,
536+
alignment_params=alignment_params,
537+
bigwarp_params=bigwarp_params,
538+
save_imgs_path=save_imgs_path,
539+
)
496540

497541
else:
498542
print(f"Formatting section {s_n} landmarks")
@@ -511,36 +555,59 @@ def _process_section(s_n, paths, alignment_params):
511555
return s_n, formatted_landmarks
512556

513557

514-
def manual_landmarks_transform(s_n, sdata_path, landmarks_path, landmarked_image_path, alignment_params, bigwarp_params, dims_order=['x','y','z']):
558+
def manual_landmarks_transform(s_n, sdata_path, landmarks_path, landmarked_image_path,
559+
alignment_params, bigwarp_params,
560+
dims_order=['x', 'y', 'z'],
561+
save_imgs_path=None):
562+
# ── Read sdata once — needed for pixel size and get_landmarked_image_props ──
563+
sdata = sd.read_zarr(sdata_path)
564+
full_scale_pixel_size = sdata['table'].uns['section_metadata']['pixel_size']
565+
515566
starting_lm_df = pd.read_csv(landmarks_path, header=None)
516-
if bigwarp_params['moving_image_dataset']=='czstack':
517-
starting_lm_df.columns = ['landmark_name', 'active'] + [f'czstack_{dim}' for dim in dims_order] + [f'xenium_{dim}' for dim in dims_order]
567+
if bigwarp_params['moving_image_dataset'] == 'czstack':
568+
starting_lm_df.columns = (['landmark_name', 'active']
569+
+ [f'czstack_{d}' for d in dims_order]
570+
+ [f'xenium_{d}' for d in dims_order])
518571
else:
519-
starting_lm_df.columns = ['landmark_name', 'active'] + [f'xenium_{dim}' for dim in dims_order] + [f'czstack_{dim}' for dim in dims_order]
520-
521-
landmarks_out = starting_lm_df.copy()
522-
if alignment_params.get('czstack_buffer', None) is not None:
523-
landmarks_out = remove_landmark_buffer(landmarks_out, czstack_buffer=alignment_params['czstack_buffer'])
524-
525-
if alignment_params.get('fix_cropped_landmarks', None) and landmarked_image_path is not None:
526-
landmarks_out, landmarks_info = get_landmarked_image_props(
527-
landmarked_image_path,
528-
sdata_path,
529-
landmarks_out,
530-
s_n,
531-
invert_y=alignment_params.get('invert_lm_y', None))
532-
elif alignment_params.get('invert_lm_y', None) and landmarked_image_path is not None:
572+
starting_lm_df.columns = (['landmark_name', 'active']
573+
+ [f'xenium_{d}' for d in dims_order]
574+
+ [f'czstack_{d}' for d in dims_order])
575+
576+
landmarks_out = starting_lm_df.copy()
577+
landmarks_tf_info = None
578+
579+
if alignment_params.get('czstack_buffer') is not None:
580+
landmarks_out = remove_landmark_buffer(
581+
landmarks_out, czstack_buffer=alignment_params['czstack_buffer'])
582+
583+
if alignment_params.get('fix_cropped_landmarks', False) and landmarked_image_path is not None:
584+
landmarks_out, landmarks_tf_info = get_landmarked_image_props(
585+
landmarked_image_path, sdata, landmarks_out, s_n,
586+
invert_y=alignment_params.get('invert_lm_y', False))
587+
elif alignment_params.get('invert_lm_y', False) and landmarked_image_path is not None:
533588
landmarks_out = invert_xenium_y_landmarks(landmarks_out, landmarked_image_path)
534-
landmarks_out = landmarks_out.rename(columns={'xenium_x': 'x', 'xenium_y': 'y', 'xenium_z': 'z'})
535-
full_scale_pixel_size = landmarks_info.get('full_scale_pixel_size')
589+
590+
if save_imgs_path is not None:
591+
plot_manual_landmark_transforms(
592+
landmarks_before=starting_lm_df,
593+
landmarks_after=landmarks_out,
594+
landmarked_image_path=landmarked_image_path,
595+
sdata=sdata,
596+
landmarks_tf_info=landmarks_tf_info,
597+
section=s_n,
598+
save_path=save_imgs_path,
599+
show=False,
600+
)
601+
602+
landmarks_out = landmarks_out.rename(
603+
columns={'xenium_x': 'x', 'xenium_y': 'y', 'xenium_z': 'z'})
536604
parsed_lm = PointsModel.parse(landmarks_out)
537605
set_transformation(parsed_lm, Identity(), to_coordinate_system='global')
538606
set_transformation(
539607
parsed_lm,
540608
Scale([full_scale_pixel_size, full_scale_pixel_size], axes=('x', 'y')),
541-
to_coordinate_system='microns'
609+
to_coordinate_system='microns',
542610
)
543-
544611
return parsed_lm
545612

546613
def get_section_landmarks_threads(xenium_section_ns, paths, alignment_params, n_workers=None):
@@ -742,48 +809,3 @@ def get_landmarked_image_props(landmarked_image_path, sdata, landmarks,
742809

743810
return landmarks, landmarks_tf_info
744811

745-
def format_landmarks(sdata_path,
746-
landmarks_path,
747-
section_n,
748-
bigwarp_project_paths=None,
749-
moving_img=None,
750-
czstack_buffer=None,
751-
invert_lm_y=False,
752-
fix_cropped_landmarks=False,
753-
landmarked_image_path=None,
754-
dims_order=['x','y','z']):
755-
sdata = sd.read_zarr(sdata_path)
756-
landmarks, bigwarp_params = get_section_landmarks(
757-
landmarks_path=landmarks_path,
758-
bigwarp_project_path=bigwarp_project_paths,
759-
moving_img=moving_img,
760-
dims_order=dims_order)
761-
762-
if czstack_buffer is not None:
763-
landmarks = remove_landmark_buffer(landmarks,
764-
czstack_buffer=czstack_buffer)
765-
766-
# if fix_cropped_landmarks and landmarked_image_path is not None:
767-
# invert_y is handled inside get_landmarked_image_props at the right point
768-
landmarks, landmarks_tf_info = get_landmarked_image_props(
769-
landmarked_image_path,
770-
sdata,
771-
landmarks,
772-
section_n,
773-
invert_y=invert_lm_y)
774-
# elif invert_lm_y and landmarked_image_path is not None:
775-
# # sections where fix_cropped_landmarks=False
776-
# landmarks = invert_xenium_y_landmarks(landmarks, landmarked_image_path)
777-
778-
# Adjust landmarks resolutions
779-
full_scale_pixel_size = landmarks_tf_info['fullres_pixel_size']
780-
landmarks = landmarks.rename(columns={'xenium_x': 'x', 'xenium_y': 'y', 'xenium_z': 'z'})
781-
landmarks = PointsModel.parse(landmarks)
782-
set_transformation(landmarks, Identity(), to_coordinate_system='global')
783-
set_transformation(
784-
landmarks,
785-
Scale([full_scale_pixel_size, full_scale_pixel_size], axes=('x', 'y')),
786-
to_coordinate_system='microns'
787-
)
788-
return landmarks
789-

0 commit comments

Comments
 (0)