Repository: fepegar/torchio

Issue:
Add pythonic slicing support to torchio.Image
**🚀 Feature**

Allow torchio.Image to be sliced like numpy arrays
```python
image = torchio.Image(...)
print(image.spatial_shape) # -> (100, 100, 100)

crop = image[10:20, :, ::2] # returns a new ScalarImage where the affine matrix and data have been modified accordingly
print(crop.spatial_shape) # -> (10, 100, 50)

# image['data'] or image['affine'] would still work
```

If the image is not yet loaded when the slicing is applied, it would modify the affine and when the image is finally loaded, it would load only the given slice, making use of NiBabel's slicing [https://nipy.org/nibabel/nibabel_images.html#image-slicing](https://nipy.org/nibabel/nibabel_images.html#image-slicing).

**Motivation**

- More pythonic cropping.
- Easier subsampling and flipping.
- Much faster patch loading for patch-based pipelines.

<!-- A clear and concise description of what you want to happen. -->

<!-- A clear and concise description of any alternative solutions or features you've considered, if any. -->

**Additional context**

The `__getitem__` method of `torchio.Image` could look like this:
```python
def __getitem__(self, item):
    if isinstance(item, str):
        if item in (DATA, AFFINE):
            if item not in self:
                self.load()
        return super().__getitem__(item)
    else:
        check_validity(item) # do some checks
        if not isinstance(item, tuple):
            item = (item,)
        self.affine = modify_affine(self.affine, item) # to be implemented.
        if not self._loaded:
            self._slice = modify_slice(self._slice, item) # to be implemented. self._slice will be used when the
                                                          # image is finally loaded into memory. It would be
                                                          # initialized as an empty tuple.
        else:
            self.set_data(self.data[(slice(None), *item)]) # add the channel dim
        return self
```
Whether to modify the image inplace or to return a new image must be discussed.

Discussion:

Patch:
diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml
index 1396c21..eee95d1 100644
--- a/.github/workflows/code_quality.yml
+++ b/.github/workflows/code_quality.yml
@@ -18,7 +18,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: "3.11"
+          python-version: "3.12"
 
       - name: Checkout repository
         uses: actions/checkout@v4
@@ -39,7 +39,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: "3.11"
+          python-version: "3.12"
 
       - name: Checkout repository
         uses: actions/checkout@v4
@@ -60,7 +60,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: "3.11"
+          python-version: "3.12"
 
       - name: Checkout repository
         uses: actions/checkout@v4
@@ -81,7 +81,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: "3.11"
+          python-version: "3.12"
 
       - name: Checkout repository
         uses: actions/checkout@v4
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 10aea6b..145e9d1 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -16,10 +16,10 @@ jobs:
     steps:
     - uses: actions/checkout@v4
 
-    - name: Set up Python 3.11
+    - name: Set up Python
       uses: actions/setup-python@v5
       with:
-        python-version: "3.11"
+        python-version: "3.12"
 
     - name: Install pypa/build
       run: >-
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6ad20e3..e428663 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -34,7 +34,6 @@ repos:
           flake8-bugbear,           # find likely bugs and design problems
           flake8-builtins,          # check for python builtins being used as variables or parameters
           flake8-fixme,             # check for FIXME, TODO and other temporary developer notes
-          flake8-logging-format,    # validate (lack of) logging format strings
           flake8-markdown,          # lint Python code blocks in Markdown files using flake8
           flake8-pep3101,           # check for old string formatting
           flake8-print,             # check for print statements
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 1b67c70..dc68811 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -18,7 +18,7 @@ formats:
 build:
   os: ubuntu-22.04
   tools:
-    python: "3.11"
+    python: "3.12"
 
 python:
   install:
diff --git a/docs/source/data/image.rst b/docs/source/data/image.rst
index cbaf442..bc17094 100644
--- a/docs/source/data/image.rst
+++ b/docs/source/data/image.rst
@@ -22,6 +22,10 @@ instantiation time.
 Instead, the data is only loaded when needed for an operation
 (e.g., if a transform is applied to the image).
 
+Images can be sliced using the standard NumPy / PyTorch slicing syntax.
+This operation updates the coordinates origin in the affine matrix
+correspondingly.
+
 The figure below shows two instances of :class:`Image`.
 The instance of :class:`ScalarImage` contains a 4D tensor representing a
 diffusion MRI, which contains four 3D volumes (one per gradient direction),
diff --git a/docs/source/data/subject.rst b/docs/source/data/subject.rst
index f345c42..67e21a7 100644
--- a/docs/source/data/subject.rst
+++ b/docs/source/data/subject.rst
@@ -8,6 +8,11 @@ The :class:`Subject` is a data structure used to store
 images associated with a subject and any other metadata necessary for
 processing.
 
+Subject objects can be sliced using the standard NumPy / PyTorch slicing
+syntax, returning a new subject with sliced images.
+This is only possible if all images in the subject have the same spatial
+shape.
+
 All transforms applied to a :class:`Subject` are saved
 in its :attr:`history` attribute (see :ref:`Reproducibility`).
 
diff --git a/setup.cfg b/setup.cfg
index b35eee5..7204930 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -24,7 +24,7 @@ classifiers =
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
-    Programming Language :: Python :: 3.11
+    Programming Language :: Python :: 3.12
     Programming Language :: Python :: Implementation :: CPython
     Topic :: Scientific/Engineering
     Topic :: Scientific/Engineering :: Artificial Intelligence
diff --git a/src/torchio/data/image.py b/src/torchio/data/image.py
index e4e0b8e..31fbb19 100644
--- a/src/torchio/data/image.py
+++ b/src/torchio/data/image.py
@@ -30,9 +30,11 @@ from ..typing import TypeDataAffine
 from ..typing import TypeDirection3D
 from ..typing import TypePath
 from ..typing import TypeQuartetInt
+from ..typing import TypeSlice
 from ..typing import TypeTripletFloat
 from ..typing import TypeTripletInt
 from ..utils import get_stem
+from ..utils import to_tuple
 from ..utils import guess_external_viewer
 from ..utils import is_iterable
 from .io import check_uint_to_int
@@ -201,6 +203,9 @@ class Image(dict):
         return string
 
     def __getitem__(self, item):
+        if isinstance(item, (slice, int, tuple)):
+            return self._crop_from_slices(item)
+
         if item in (DATA, AFFINE):
             if item not in self:
                 self.load()
@@ -784,6 +789,44 @@ class Image(dict):
             image_viewer.SetApplication(str(viewer_path))
             image_viewer.Execute(sitk_image)
 
+    def _crop_from_slices(
+        self,
+        slices: Union[TypeSlice, Tuple[TypeSlice, ...]],
+    ) -> 'Image':
+        from ..transforms import Crop
+
+        slices_tuple = to_tuple(slices)  # type: ignore[assignment]
+        cropping: List[int] = []
+        for dim, slice_ in enumerate(slices_tuple):
+            if isinstance(slice_, slice):
+                pass
+            elif slice_ is Ellipsis:
+                message = 'Ellipsis slicing is not supported yet'
+                raise NotImplementedError(message)
+            elif isinstance(slice_, int):
+                slice_ = slice(slice_, slice_ + 1)  # type: ignore[assignment]
+            else:
+                message = f'Slice type not understood: "{type(slice_)}"'
+                raise TypeError(message)
+            shape_dim = self.spatial_shape[dim]
+            assert isinstance(slice_, slice)
+            start, stop, step = slice_.indices(shape_dim)
+            if step != 1:
+                message = (
+                    'Slicing with steps different from 1 is not supported yet.'
+                    ' Use the Crop transform instead'
+                )
+                raise ValueError(message)
+            crop_ini = start
+            crop_fin = shape_dim - stop
+            cropping.extend([crop_ini, crop_fin])
+        while dim < 2:
+            cropping.extend([0, 0])
+            dim += 1
+        w_ini, w_fin, h_ini, h_fin, d_ini, d_fin = cropping
+        cropping_arg = w_ini, w_fin, h_ini, h_fin, d_ini, d_fin  # making mypy happy
+        return Crop(cropping_arg)(self)  # type: ignore[return-value]
+
 
 class ScalarImage(Image):
     """Image whose pixel values represent scalars.
diff --git a/src/torchio/data/subject.py b/src/torchio/data/subject.py
index e30760a..ccffc6f 100644
--- a/src/torchio/data/subject.py
+++ b/src/torchio/data/subject.py
@@ -77,6 +77,23 @@ class Subject(dict):
     def __len__(self):
         return len(self.get_images(intensity_only=False))
 
+    def __getitem__(self, item):
+        if isinstance(item, (slice, int, tuple)):
+            try:
+                self.check_consistent_spatial_shape()
+            except RuntimeError as e:
+                message = (
+                    'To use indexing, all images in the subject must have the'
+                    ' same spatial shape'
+                )
+                raise RuntimeError(message) from e
+            copied = copy.deepcopy(self)
+            for image_name, image in copied.items():
+                copied[image_name] = image[item]
+            return copied
+        else:
+            return super().__getitem__(item)
+
     @staticmethod
     def _parse_images(images: List[Image]) -> None:
         # Check that it's not empty
diff --git a/src/torchio/transforms/preprocessing/spatial/resample.py b/src/torchio/transforms/preprocessing/spatial/resample.py
index e7d99d3..d9a91de 100644
--- a/src/torchio/transforms/preprocessing/spatial/resample.py
+++ b/src/torchio/transforms/preprocessing/spatial/resample.py
@@ -158,14 +158,17 @@ class Resample(SpatialTransform):
             self.check_affine_key_presence(self.pre_affine_name, subject)
 
         for image in self.get_images(subject):
-            # Do not resample the reference image if it is in the subject
+            # If the current image is the reference, don't resample it
             if self.target is image:
                 continue
+
+            # If the target is not a string, or is not an image in the subject,
+            # do nothing
             try:
                 target_image = subject[self.target]
                 if target_image is image:
                     continue
-            except (KeyError, TypeError):
+            except (KeyError, TypeError, RuntimeError):
                 pass
 
             # Choose interpolation
diff --git a/src/torchio/typing.py b/src/torchio/typing.py
index 4a393db..2c1022c 100644
--- a/src/torchio/typing.py
+++ b/src/torchio/typing.py
@@ -15,6 +15,7 @@ TypeNumber = Union[int, float]
 TypeKeys = Optional[Sequence[str]]
 TypeData = Union[torch.Tensor, np.ndarray]
 TypeDataAffine = Tuple[torch.Tensor, np.ndarray]
+TypeSlice = Union[int, slice]
 
 TypeDoubletInt = Tuple[int, int]
 TypeTripletInt = Tuple[int, int, int]


Test patch:
diff --git a/tests/data/test_image.py b/tests/data/test_image.py
index 249003e..b467132 100644
--- a/tests/data/test_image.py
+++ b/tests/data/test_image.py
@@ -283,3 +283,27 @@ class TestImage(TorchioTestCase):
         new_image = copy.copy(my_image)
         assert my_image._loaded
         assert new_image._loaded
+
+    def test_slicing(self):
+        path = self.get_image_path('im_slicing')
+        image = tio.ScalarImage(path)
+
+        assert image.shape == (1, 10, 20, 30)
+
+        cropped = image[0]
+        assert cropped.shape == (1, 1, 20, 30)
+
+        cropped = image[:, 2:-3]
+        assert cropped.shape == (1, 10, 15, 30)
+
+        cropped = image[-5:, 5:]
+        assert cropped.shape == (1, 5, 15, 30)
+
+        with pytest.raises(NotImplementedError):
+            image[..., 5]
+
+        with pytest.raises(ValueError):
+            image[0:8:-1]
+
+        with pytest.raises(ValueError):
+            image[3::-1]