Skip to content

Commit 47c1e1d

Browse files
Merge pull request #247 from scverse/fix/visium_hd_projective
Visium HD fix bug projective transformation (CytAssist image)
2 parents 7451288 + 16ba0fe commit 47c1e1d

File tree

1 file changed

+115
-23
lines changed

1 file changed

+115
-23
lines changed

src/spatialdata_io/readers/visium_hd.py

Lines changed: 115 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@
1717
from imageio import imread as imread2
1818
from multiscale_spatial_image import MultiscaleSpatialImage
1919
from numpy.random import default_rng
20+
from skimage.transform import ProjectiveTransform, warp
2021
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,
2927
)
28+
from spatialdata._types import ArrayLike
29+
from spatialdata.models import Image2DModel, ShapesModel, TableModel
30+
from spatialdata.transformations import Affine, Identity, Scale, set_transformation
3031
from xarray import DataArray
3132

3233
from spatialdata_io._constants._constants import VisiumHDKeys
@@ -358,9 +359,55 @@ def _get_bins(path_bins: Path) -> list[str]:
358359
)
359360
image = images[dataset_id + "_cytassist_image"]
360361
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
364411

365412
sdata = SpatialData(tables=tables, images=images, shapes=shapes, labels=labels)
366413

@@ -439,13 +486,40 @@ def _load_image(
439486
return None
440487

441488

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
449523

450524

451525
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
465539
return metadata, hd_layout
466540

467541

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+
"""
469561
transform_matrices = {}
470562

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)
473565

474566
for key in [
475567
VisiumHDKeys.CYTASSIST_COLROW_TO_SPOT_COLROW,
476568
VisiumHDKeys.SPOT_COLROW_TO_CYTASSIST_COLROW,
477569
VisiumHDKeys.MICROSCOPE_COLROW_TO_SPOT_COLROW,
478570
VisiumHDKeys.SPOT_COLROW_TO_MICROSCOPE_COLROW,
479571
]:
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)
482574

483575
return transform_matrices

0 commit comments

Comments
 (0)