Skip to content

Commit 8d06062

Browse files
committed
Adds affine transformation support
Adds support for affine transformations to the MLArray metadata. This change introduces the `affine` property to the `MetaSpatial` class, allowing users to specify a homogeneous affine matrix for spatial data. This provides a more general way to represent spatial transformations compared to separate spacing, origin, and direction parameters. A new test case is added to verify that the affine transformation is stored correctly in the metadata. A check prevents mixing `affine` with other spatial parameters like `spacing`, `origin`, or `direction`.
1 parent 534408e commit 8d06062

File tree

3 files changed

+85
-4
lines changed

3 files changed

+85
-4
lines changed

mlarray/meta.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ class MetaSpatial(BaseMeta):
676676
spacing: Per-dimension spacing values. Length must match ndims.
677677
origin: Per-dimension origin values. Length must match ndims.
678678
direction: Direction cosine matrix of shape [ndims, ndims].
679+
affine: Homogeneous affine matrix of shape [ndims + 1, ndims + 1].
679680
shape: Array shape. Length must match (spatial + non-spatial) ndims.
680681
axis_labels: Per-axis labels or roles. Length must match ndims.
681682
axis_units: Per-axis units. Length must match ndims.
@@ -686,6 +687,7 @@ class MetaSpatial(BaseMeta):
686687
spacing: Optional[list[Union[int,float]]] = None
687688
origin: Optional[list[Union[int,float]]] = None
688689
direction: Optional[list[list[Union[int,float]]]] = None
690+
affine: Optional[list[list[Union[int,float]]]] = None
689691
shape: Optional[list[int]] = None
690692
axis_labels: Optional[list[Union[str,AxisLabel]]] = None
691693
axis_units: Optional[list[str]] = None
@@ -716,6 +718,21 @@ def _validate_and_cast(self, *, ndims: Optional[int] = None, spatial_ndims: Opti
716718
self.direction = _cast_to_list(self.direction, "meta.spatial.direction")
717719
_validate_float_int_matrix(self.direction, "meta.spatial.direction", spatial_ndims)
718720

721+
if self.affine is not None:
722+
self.affine = _cast_to_list(self.affine, "meta.spatial.affine")
723+
if spatial_ndims is not None:
724+
_validate_float_int_matrix(
725+
self.affine,
726+
"meta.spatial.affine",
727+
spatial_ndims + 1,
728+
)
729+
else:
730+
_validate_float_int_matrix(self.affine, "meta.spatial.affine")
731+
n_rows = len(self.affine)
732+
for row in self.affine:
733+
if len(row) != n_rows:
734+
raise ValueError("meta.spatial.affine must be a square matrix")
735+
719736
if self.shape is not None:
720737
self.shape = _cast_to_list(self.shape, "meta.spatial.shape")
721738
_validate_float_int_list(self.shape, "meta.spatial.shape", ndims)
@@ -915,7 +932,7 @@ class Meta(BaseMeta):
915932
Attributes:
916933
source: Source metadata from the original image source (JSON-serializable dict).
917934
extra: Additional metadata (JSON-serializable dict).
918-
spatial: Spatial metadata (spacing, origin, direction, shape).
935+
spatial: Spatial metadata (spacing, origin, direction, affine, shape).
919936
stats: Summary statistics.
920937
bbox: Bounding boxes.
921938
is_seg: Segmentation flag.

mlarray/mlarray.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def __init__(
2020
spacing: Optional[Union[List, Tuple, np.ndarray]] = None,
2121
origin: Optional[Union[List, Tuple, np.ndarray]] = None,
2222
direction: Optional[Union[List, Tuple, np.ndarray]] = None,
23+
affine: Optional[Union[List, Tuple, np.ndarray]] = None,
2324
meta: Optional[Union[Dict, Meta]] = None,
2425
axis_labels: Optional[List[Union[str, AxisLabel]]] = None,
2526
copy: Optional['MLArray'] = None,
@@ -49,6 +50,9 @@ def __init__(
4950
direction (Optional[Union[List, Tuple, np.ndarray]]): Direction
5051
cosine matrix. Provide a 2D list/tuple/ndarray with shape
5152
(ndims, ndims) for spatial dimensions.
53+
affine (Optional[Union[List, Tuple, np.ndarray]]): Homogeneous
54+
affine matrix. Provide a 2D list/tuple/ndarray with shape
55+
(spatial_ndims + 1, spatial_ndims + 1).
5256
meta (Optional[Dict | Meta]): Free-form metadata dictionary or Meta
5357
instance. Must be JSON-serializable when saving.
5458
If meta is passed as a Dict, it is internally converted into a
@@ -81,6 +85,7 @@ def __init__(
8185
spacing is not None
8286
or origin is not None
8387
or direction is not None
88+
or affine is not None
8489
or meta is not None
8590
or axis_labels is not None
8691
or copy is not None
@@ -91,14 +96,23 @@ def __init__(
9196
or dparams is not None
9297
):
9398
raise RuntimeError(
94-
"Spacing, origin, direction, meta, axis_labels, copy, patch_size, "
99+
"Spacing, origin, direction, affine, meta, axis_labels, copy, patch_size, "
95100
"chunk_size, block_size, cparams or dparams cannot be set when "
96101
"array is a filepath."
97102
)
98103
if isinstance(array, (str, Path)):
99104
self._load(array, compressed=compressed)
100105
else:
101-
self._validate_and_add_meta(meta, spacing, origin, direction, axis_labels, False, validate=False)
106+
self._validate_and_add_meta(
107+
meta,
108+
spacing=spacing,
109+
origin=origin,
110+
direction=direction,
111+
affine=affine,
112+
axis_labels=axis_labels,
113+
has_array=False,
114+
validate=False,
115+
)
102116
if array is not None:
103117
self._asarray(
104118
array,
@@ -1112,6 +1126,8 @@ def affine(self) -> np.ndarray:
11121126
"""
11131127
if self._store is None or self.meta._has_array.has_array == False:
11141128
return None
1129+
if self.meta.spatial.affine is not None:
1130+
return self.meta.spatial.affine
11151131
spacing = np.array(self.spacing) if self.spacing is not None else np.ones(self.spatial_ndim)
11161132
origin = np.array(self.origin) if self.origin is not None else np.zeros(self.spatial_ndim)
11171133
direction = np.array(self.direction) if self.direction is not None else np.eye(self.spatial_ndim)
@@ -1824,7 +1840,17 @@ def _comp_and_validate_blosc2_meta(self, meta_blosc2, patch_size, chunk_size, bl
18241840
meta_blosc2._validate_and_cast(ndims=len(shape), spatial_ndims=num_spatial_axes)
18251841
return meta_blosc2
18261842

1827-
def _validate_and_add_meta(self, meta, spacing=None, origin=None, direction=None, axis_labels=None, has_array=None, validate=True):
1843+
def _validate_and_add_meta(
1844+
self,
1845+
meta,
1846+
spacing=None,
1847+
origin=None,
1848+
direction=None,
1849+
affine=None,
1850+
axis_labels=None,
1851+
has_array=None,
1852+
validate=True,
1853+
):
18281854
"""Validate and attach metadata to the MLArray instance.
18291855
18301856
Args:
@@ -1836,6 +1862,8 @@ def _validate_and_add_meta(self, meta, spacing=None, origin=None, direction=None
18361862
spatial axis.
18371863
direction (Optional[Union[List, Tuple, np.ndarray]]): Direction
18381864
cosine matrix with shape (ndims, ndims).
1865+
affine (Optional[Union[List, Tuple, np.ndarray]]): Homogeneous
1866+
affine matrix with shape (spatial_ndims + 1, spatial_ndims + 1).
18391867
axis_labels (Optional[List[Union[str, AxisLabel]]]): Per-axis labels or roles. Length must match ndims.
18401868
has_array (Optional[bool]): Explicitly set whether array data is
18411869
present. When True, metadata is validated with array-dependent
@@ -1853,12 +1881,22 @@ def _validate_and_add_meta(self, meta, spacing=None, origin=None, direction=None
18531881
meta = Meta()
18541882
self.meta = meta
18551883
self.meta._mlarray_version = MLARRAY_VERSION
1884+
1885+
if affine is not None and (
1886+
spacing is not None or origin is not None or direction is not None
1887+
):
1888+
raise ValueError(
1889+
"affine cannot be provided together with spacing, origin, or direction."
1890+
)
1891+
18561892
if spacing is not None:
18571893
self.meta.spatial.spacing = spacing
18581894
if origin is not None:
18591895
self.meta.spatial.origin = origin
18601896
if direction is not None:
18611897
self.meta.spatial.direction = direction
1898+
if affine is not None:
1899+
self.meta.spatial.affine = affine
18621900
if axis_labels is not None:
18631901
self.meta.spatial.axis_labels = axis_labels
18641902
if has_array == True:

tests/test_usage.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,32 @@ def test_metadata_inspection_and_manipulation(self):
8888
self.assertEqual(loaded.origin, [10.0, 10.0, 30.0])
8989
self.assertEqual(loaded.meta.source["study_id"], "study-001")
9090

91+
def test_affine_stored_in_meta_spatial(self):
92+
array = _make_array(shape=(8, 8, 8))
93+
affine = [
94+
[1.0, 0.0, 0.0, 10.0],
95+
[0.0, 2.0, 0.0, 20.0],
96+
[0.0, 0.0, 3.0, 30.0],
97+
[0.0, 0.0, 0.0, 1.0],
98+
]
99+
image = MLArray(array, affine=affine)
100+
101+
self.assertEqual(image.meta.spatial.affine, affine)
102+
self.assertIsNone(image.meta.spatial.spacing)
103+
self.assertIsNone(image.meta.spatial.origin)
104+
self.assertIsNone(image.meta.spatial.direction)
105+
106+
def test_affine_and_spacing_origin_direction_mix_raises(self):
107+
array = _make_array(shape=(8, 8, 8))
108+
affine = [
109+
[1.0, 0.0, 0.0, 10.0],
110+
[0.0, 1.0, 0.0, 20.0],
111+
[0.0, 0.0, 1.0, 30.0],
112+
[0.0, 0.0, 0.0, 1.0],
113+
]
114+
with self.assertRaises(ValueError):
115+
MLArray(array, spacing=(1.0, 1.0, 1.0), affine=affine)
116+
91117
def test_copy_metadata_with_override(self):
92118
with tempfile.TemporaryDirectory() as tmpdir:
93119
array = _make_array()

0 commit comments

Comments
 (0)