1313
1414import functools
1515import warnings
16- from collections .abc import Sequence
16+ from collections .abc import Mapping , Sequence
1717from copy import deepcopy
18+ from numbers import Integral
1819from typing import Any
1920
2021import numpy as np
2122import torch
2223
2324import monai
24- from monai .config .type_definitions import NdarrayTensor
25- from monai .data .meta_obj import MetaObj , get_track_meta
26- from monai .data .utils import affine_to_spacing , decollate_batch , list_data_collate , remove_extra_metadata
25+ from monai .config .type_definitions import NdarrayOrTensor , NdarrayTensor
26+ from monai .data .meta_obj import _DEFAULT_SPATIAL_NDIM , MetaObj , get_track_meta
27+ from monai .data .utils import affine_to_spacing , decollate_batch , is_no_channel , list_data_collate , remove_extra_metadata
2728from monai .utils import look_up_option
2829from monai .utils .enums import LazyAttr , MetaKeys , PostFix , SpaceKeys
2930from monai .utils .type_conversion import convert_data_type , convert_to_dst_type , convert_to_numpy , convert_to_tensor
3031
31- __all__ = ["MetaTensor" ]
32+ __all__ = ["MetaTensor" , "get_spatial_ndim" ]
33+
34+
35+ def _normalize_spatial_ndim (spatial_ndim : int , tensor_ndim : int , no_channel : bool = False ) -> int :
36+ """Clamp spatial dims to a valid range for the current tensor shape."""
37+ limit = max (int (tensor_ndim ), 1 ) if no_channel else max (int (tensor_ndim ) - 1 , 1 )
38+ return max (1 , min (int (spatial_ndim ), limit ))
39+
40+
41+ def _has_explicit_no_channel (meta : Mapping | None ) -> bool :
42+ return (
43+ isinstance (meta , Mapping )
44+ and MetaKeys .ORIGINAL_CHANNEL_DIM in meta
45+ and is_no_channel (meta [MetaKeys .ORIGINAL_CHANNEL_DIM ])
46+ )
47+
48+
49+ def get_spatial_ndim (img : NdarrayOrTensor ) -> int :
50+ """Return the number of spatial dimensions assuming channel-first layout.
51+
52+ Uses ``MetaTensor.spatial_ndim`` when available, otherwise falls back to
53+ ``img.ndim - 1``. Always assumes channel-first (``no_channel=False``)
54+ because callers run after ``EnsureChannelFirst`` has already added one.
55+ """
56+ if isinstance (img , MetaTensor ):
57+ return _normalize_spatial_ndim (img .spatial_ndim , img .ndim )
58+ return img .ndim - 1
59+
60+
61+ def _is_batch_only_index (index : Any ) -> bool :
62+ """True when indexing pattern selects only the batch axis (e.g., ``x[0]`` or ``x[0, ...]``)."""
63+ if isinstance (index , (int , np .integer )):
64+ return True
65+ if not isinstance (index , Sequence ) or not index :
66+ return False
67+ if not isinstance (index [0 ], (int , np .integer )):
68+ return False
69+ return all (i in (slice (None , None , None ), Ellipsis , None ) for i in index [1 :])
3270
3371
3472@functools .lru_cache (None )
@@ -111,6 +149,7 @@ def __new__(
111149 meta : dict | None = None ,
112150 applied_operations : list | None = None ,
113151 * args ,
152+ spatial_ndim : int | None = None ,
114153 ** kwargs ,
115154 ) -> MetaTensor :
116155 _kwargs = {"device" : kwargs .pop ("device" , None ), "dtype" : kwargs .pop ("dtype" , None )} if kwargs else {}
@@ -123,6 +162,7 @@ def __init__(
123162 meta : dict | None = None ,
124163 applied_operations : list | None = None ,
125164 * _args ,
165+ spatial_ndim : int | None = None ,
126166 ** _kwargs ,
127167 ) -> None :
128168 """
@@ -134,6 +174,8 @@ def __init__(
134174 the list is typically maintained by `monai.transforms.TraceableTransform`.
135175 See also: :py:class:`monai.transforms.TraceableTransform`
136176 _args: additional args (currently not in use in this constructor).
177+ spatial_ndim: optional number of spatial dimensions. If ``None``, derived
178+ from the affine matrix clamped by the tensor shape.
137179 _kwargs: additional kwargs (currently not in use in this constructor).
138180
139181 Note:
@@ -158,6 +200,14 @@ def __init__(
158200 self .affine = self .meta [MetaKeys .AFFINE ]
159201 else :
160202 self .affine = self .get_default_affine ()
203+ # Initialize spatial_ndim from affine matrix (source of truth), clamped by tensor shape.
204+ # This cached value is kept in sync via the affine setter for hot-path performance.
205+ no_channel = _has_explicit_no_channel (self .meta )
206+ if spatial_ndim is not None :
207+ self .spatial_ndim = _normalize_spatial_ndim (spatial_ndim , self .ndim , no_channel = no_channel )
208+ elif self .affine .ndim == 2 :
209+ self .spatial_ndim = _normalize_spatial_ndim (self .affine .shape [- 1 ] - 1 , self .ndim , no_channel = no_channel )
210+
161211 # applied_operations
162212 if applied_operations is not None :
163213 self .applied_operations = applied_operations
@@ -237,6 +287,7 @@ def _handle_batched(cls, ret, idx, metas, func, args, kwargs):
237287 if func == torch .Tensor .__getitem__ :
238288 if idx > 0 or len (args ) < 2 or len (args [0 ]) < 1 :
239289 return ret
290+ full_idx = args [1 ]
240291 batch_idx = args [1 ][0 ] if isinstance (args [1 ], Sequence ) else args [1 ]
241292 # if using e.g., `batch[:, -1]` or `batch[..., -1]`, then the
242293 # first element will be `slice(None, None, None)` and `Ellipsis`,
@@ -258,6 +309,8 @@ def _handle_batched(cls, ret, idx, metas, func, args, kwargs):
258309 ret_meta .is_batch = False
259310 if hasattr (ret_meta , "__dict__" ):
260311 ret .__dict__ = ret_meta .__dict__ .copy ()
312+ if _is_batch_only_index (full_idx ):
313+ ret .spatial_ndim = _normalize_spatial_ndim (ret .spatial_ndim , ret .ndim )
261314 # `unbind` is used for `next(iter(batch))`. Also for `decollate_batch`.
262315 # But we only want to split the batch if the `unbind` is along the 0th dimension.
263316 elif func == torch .Tensor .unbind :
@@ -467,15 +520,42 @@ def affine(self) -> torch.Tensor:
467520
468521 @affine .setter
469522 def affine (self , d : NdarrayTensor ) -> None :
470- """Set the affine."""
471- self .meta [MetaKeys .AFFINE ] = torch .as_tensor (d , device = torch .device ("cpu" ), dtype = torch .float64 )
523+ """Set the affine.
524+
525+ When setting a non-batched affine matrix, automatically synchronizes the cached
526+ spatial_ndim attribute to maintain consistency between the affine matrix (source of truth)
527+ and the cached spatial dimension count.
528+ """
529+ a = torch .as_tensor (d , device = torch .device ("cpu" ), dtype = torch .float64 )
530+ self .meta [MetaKeys .AFFINE ] = a
531+ if a .ndim == 2 : # non-batched: sync spatial_ndim from affine (source of truth)
532+ no_channel = _has_explicit_no_channel (self .meta )
533+ self .spatial_ndim = _normalize_spatial_ndim (a .shape [- 1 ] - 1 , self .ndim , no_channel = no_channel )
534+
535+ @property
536+ def spatial_ndim (self ) -> int :
537+ """Get the number of spatial dimensions.
538+
539+ This value is cached for hot-path performance and is kept in sync with the affine matrix
540+ via the affine setter. The affine matrix is the source of truth for spatial dimensions.
541+ """
542+ return getattr (self , "_spatial_ndim" , _DEFAULT_SPATIAL_NDIM )
543+
544+ @spatial_ndim .setter
545+ def spatial_ndim (self , val : int ) -> None :
546+ """Set the number of spatial dimensions."""
547+ if not isinstance (val , Integral ):
548+ raise TypeError (f"'val' must be an numbers.Integral type; got { type (val )} ." )
549+ if val < 1 :
550+ raise ValueError (f"spatial_ndim must be >= 1, got { val } " )
551+ self ._spatial_ndim = int (val )
472552
473553 @property
474554 def pixdim (self ):
475555 """Get the spacing"""
476556 if self .is_batch :
477- return [affine_to_spacing (a ) for a in self .affine ]
478- return affine_to_spacing (self .affine )
557+ return [affine_to_spacing (a , r = self . spatial_ndim ) for a in self .affine ]
558+ return affine_to_spacing (self .affine , r = self . spatial_ndim )
479559
480560 def peek_pending_shape (self ):
481561 """
@@ -490,7 +570,7 @@ def peek_pending_shape(self):
490570
491571 def peek_pending_affine (self ):
492572 res = self .affine
493- r = len ( res ) - 1
573+ r = res . shape [ - 1 ] - 1 if res . ndim >= 2 else self . spatial_ndim
494574 if r not in (2 , 3 ):
495575 warnings .warn (f"Only 2d and 3d affine are supported, got { r } d input." )
496576 for p in self .pending_operations :
@@ -503,8 +583,10 @@ def peek_pending_affine(self):
503583 return res
504584
505585 def peek_pending_rank (self ):
506- a = self .pending_operations [- 1 ].get (LazyAttr .AFFINE , None ) if self .pending_operations else self .affine
507- return 1 if a is None else int (max (1 , len (a ) - 1 ))
586+ if self .pending_operations :
587+ a = self .pending_operations [- 1 ].get (LazyAttr .AFFINE , None )
588+ return 1 if a is None else int (max (1 , len (a ) - 1 ))
589+ return self .spatial_ndim
508590
509591 def new_empty (self , size , dtype = None , device = None , requires_grad = False ): # type: ignore[override]
510592 """
0 commit comments