17
17
from imageio import imread as imread2
18
18
from multiscale_spatial_image import MultiscaleSpatialImage
19
19
from numpy .random import default_rng
20
+ from skimage .transform import ProjectiveTransform , warp
20
21
from spatial_image import SpatialImage
21
- from spatialdata import SpatialData , rasterize_bins , rasterize_bins_link_table_to_labels
22
- from spatialdata .models import Image2DModel , ShapesModel , TableModel
23
- from spatialdata .transformations import (
24
- Affine ,
25
- Identity ,
26
- Scale ,
27
- Sequence ,
28
- set_transformation ,
22
+ from spatialdata import (
23
+ SpatialData ,
24
+ get_extent ,
25
+ rasterize_bins ,
26
+ rasterize_bins_link_table_to_labels ,
29
27
)
28
+ from spatialdata ._types import ArrayLike
29
+ from spatialdata .models import Image2DModel , ShapesModel , TableModel
30
+ from spatialdata .transformations import Affine , Identity , Scale , set_transformation
30
31
from xarray import DataArray
31
32
32
33
from spatialdata_io ._constants ._constants import VisiumHDKeys
@@ -358,9 +359,55 @@ def _get_bins(path_bins: Path) -> list[str]:
358
359
)
359
360
image = images [dataset_id + "_cytassist_image" ]
360
361
transform_matrices = _get_transform_matrices (metadata , hd_layout )
361
- affine0 = transform_matrices ["cytassist_colrow_to_spot_colrow" ]
362
- affine1 = transform_matrices ["spot_colrow_to_microscope_colrow" ]
363
- set_transformation (image , Sequence ([affine0 , affine1 ]), "global" )
362
+ projective0 = transform_matrices ["cytassist_colrow_to_spot_colrow" ]
363
+ projective1 = transform_matrices ["spot_colrow_to_microscope_colrow" ]
364
+ projective = projective1 @ projective0
365
+ projective /= projective [2 , 2 ]
366
+ if _projective_matrix_is_affine (projective ):
367
+ affine = Affine (projective , input_axes = ("x" , "y" ), output_axes = ("x" , "y" ))
368
+ set_transformation (image , affine , "global" )
369
+ else :
370
+ # the projective matrix is not affine, we will separate the affine part and the projective shift, and apply
371
+ # the projective shift to the image
372
+ affine_matrix , projective_shift = _decompose_projective_matrix (projective )
373
+ affine = Affine (affine_matrix , input_axes = ("x" , "y" ), output_axes = ("x" , "y" ))
374
+
375
+ # determine the size of the transformed image
376
+ bounding_box = get_extent (image )
377
+ x0 , x1 = bounding_box ["x" ]
378
+ y0 , y1 = bounding_box ["y" ]
379
+ x1 -= 1
380
+ y1 -= 1
381
+ corners = [(x0 , y0 ), (x1 , y0 ), (x1 , y1 ), (x0 , y1 )]
382
+
383
+ transformed_corners = []
384
+ for x , y in corners :
385
+ px , py = _projective_matrix_transform_point (projective_shift , x , y )
386
+ transformed_corners .append ((px , py ))
387
+ transformed_corners_array = np .array (transformed_corners )
388
+ transformed_bounds = (
389
+ np .min (transformed_corners_array [:, 0 ]),
390
+ np .min (transformed_corners_array [:, 1 ]),
391
+ np .max (transformed_corners_array [:, 0 ]),
392
+ np .max (transformed_corners_array [:, 1 ]),
393
+ )
394
+ # the first two components are <= 0, we just discard them since the cytassist image has a lot of padding
395
+ # and therefore we can safely discard pixels with negative coordinates
396
+ transformed_shape = (np .ceil (transformed_bounds [2 ]), np .ceil (transformed_bounds [3 ]))
397
+
398
+ # flip xy
399
+ transformed_shape = (transformed_shape [1 ], transformed_shape [0 ])
400
+
401
+ # the cytassist image is a small, single-scale image, so we can compute it in memory
402
+ numpy_data = image .transpose ("y" , "x" , "c" ).data .compute ()
403
+ warped = warp (
404
+ numpy_data , ProjectiveTransform (projective_shift ).inverse , output_shape = transformed_shape , order = 1
405
+ )
406
+ warped = np .round (warped * 255 ).astype (np .uint8 )
407
+ warped = Image2DModel .parse (warped , dims = ("y" , "x" , "c" ), transformations = {"global" : affine }, rgb = True )
408
+
409
+ # we replace the cytassist image with the warped image
410
+ images [dataset_id + "_cytassist_image" ] = warped
364
411
365
412
sdata = SpatialData (tables = tables , images = images , shapes = shapes , labels = labels )
366
413
@@ -439,13 +486,40 @@ def _load_image(
439
486
return None
440
487
441
488
442
- def _get_affine (coefficients : list [int ]) -> Affine :
443
- matrix = np .array (coefficients ).reshape (3 , 3 )
444
- # the last row doesn't match with machine precision, let's check the matrix it's still close to a homogeneous
445
- # matrix, and fix this
446
- assert np .allclose (matrix [2 ], [0 , 0 , 1 ], atol = 1e-2 ), matrix
447
- matrix [2 ] = [0 , 0 , 1 ]
448
- return Affine (matrix , input_axes = ("x" , "y" ), output_axes = ("x" , "y" ))
489
+ def _projective_matrix_transform_point (projective_shift : ArrayLike , x : float , y : float ) -> tuple [float , float ]:
490
+ v = np .array ([x , y , 1 ])
491
+ v = projective_shift @ v
492
+ v /= v [2 ]
493
+ return v [0 ], v [1 ]
494
+
495
+
496
+ def _projective_matrix_is_affine (projective_matrix : ArrayLike ) -> bool :
497
+ assert np .allclose (projective_matrix [2 , 2 ], 1 ), "A projective matrix should have a 1 in the bottom right corner."
498
+ return np .allclose (projective_matrix [2 , :2 ], [0 , 0 ])
499
+
500
+
501
+ def _decompose_projective_matrix (projective_matrix : ArrayLike ) -> tuple [ArrayLike , ArrayLike ]:
502
+ """
503
+ Decompose a projective transformation matrix into an affine transformation and a projective shift.
504
+
505
+ Parameters
506
+ ----------
507
+ projective_matrix
508
+ Projective transformation matrix.
509
+
510
+ Returns
511
+ -------
512
+ A tuple where the first element is the affine matrix and the second element is the projective shift.
513
+
514
+ Let P be the initial projective matrix and A the affine matrix. The projective shift S is defined as: S = A^-1 @ P.
515
+ """
516
+ assert np .allclose (projective_matrix [2 , 2 ], 1 ), "A projective matrix should have a 1 in the bottom right corner."
517
+ affine_matrix = projective_matrix .copy ()
518
+ affine_matrix [2 ] = [0 , 0 , 1 ]
519
+ # equivalent to np.linalg.inv(affine_matrix) @ projective_matrix, but more numerically stable
520
+ projective_shift = np .linalg .solve (affine_matrix , projective_matrix )
521
+ projective_shift /= projective_shift [2 , 2 ]
522
+ return affine_matrix , projective_shift
449
523
450
524
451
525
def _get_filename_prefix (path : Path , dataset_id : str ) -> str :
@@ -465,19 +539,37 @@ def _parse_metadata(path: Path, filename_prefix: str) -> tuple[dict[str, Any], d
465
539
return metadata , hd_layout
466
540
467
541
468
- def _get_transform_matrices (metadata : dict [str , Any ], hd_layout : dict [str , Any ]) -> dict [str , Affine ]:
542
+ def _get_transform_matrices (metadata : dict [str , Any ], hd_layout : dict [str , Any ]) -> dict [str , ArrayLike ]:
543
+ """
544
+ Gets 4 projective transformation matrices, describing how to align the CytAssist, spots and microscope coordinates.
545
+
546
+ Parameters
547
+ ----------
548
+ metadata
549
+ Metadata of the Visium HD dataset parsed using `_parse_metadata()` from the feature slice file.
550
+ hd_layout
551
+ Layout of the Visium HD dataset parsed using `_parse_metadata()` from the feature slice file.
552
+
553
+ Returns
554
+ -------
555
+ A dictionary containing four projective transformation matrices:
556
+ - CytAssist col/row to Spot col/row
557
+ - Spot col/row to CytAssist col/row
558
+ - Microscope col/row to Spot col/row
559
+ - Spot col/row to Microscope col/row
560
+ """
469
561
transform_matrices = {}
470
562
471
- # not used
472
- transform_matrices ["hd_layout_transform" ] = _get_affine (hd_layout [VisiumHDKeys .TRANSFORM ])
563
+ # this transformation is parsed but not used in the current implementation
564
+ transform_matrices ["hd_layout_transform" ] = np . array (hd_layout [VisiumHDKeys .TRANSFORM ]). reshape ( 3 , 3 )
473
565
474
566
for key in [
475
567
VisiumHDKeys .CYTASSIST_COLROW_TO_SPOT_COLROW ,
476
568
VisiumHDKeys .SPOT_COLROW_TO_CYTASSIST_COLROW ,
477
569
VisiumHDKeys .MICROSCOPE_COLROW_TO_SPOT_COLROW ,
478
570
VisiumHDKeys .SPOT_COLROW_TO_MICROSCOPE_COLROW ,
479
571
]:
480
- data = metadata [VisiumHDKeys .TRANSFORM_MATRICES ][key ]
481
- transform_matrices [key . value ] = _get_affine ( data )
572
+ coefficients = metadata [VisiumHDKeys .TRANSFORM_MATRICES ][key ]
573
+ transform_matrices [key ] = np . array ( coefficients ). reshape ( 3 , 3 )
482
574
483
575
return transform_matrices
0 commit comments