Skip to content

Commit 23b30cf

Browse files
fepegartcollins-hubsiddharth10ssCopilot
committed
Add Image.new_like() for custom subclass support
Add a new_like(tensor, affine) factory method to Image so that transforms creating new image instances work with custom subclasses that have different __init__ signatures. Extra dict keys (metadata like age, site, etc.) are automatically propagated. Only the two actual reconstruction call sites are changed: - Crop._crop_image: type(image)(...) → image.new_like(...) - build_image_from_reference: class_(...) → reference.new_like(...) Image.__copy__() now delegates to new_like() for loaded images. Fixes #1391 Co-authored-by: tcollins-hub <245905921+tcollins-hub@users.noreply.github.com> Co-authored-by: siddharth10ss <143159776+siddharth10ss@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3ee81f2 commit 23b30cf

4 files changed

Lines changed: 258 additions & 17 deletions

File tree

src/torchio/data/image.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from collections.abc import Sequence
77
from pathlib import Path
88
from typing import TYPE_CHECKING
9-
from typing import Any
109
from typing import TypeGuard
1110
from typing import cast
1211
from typing import overload
@@ -246,17 +245,19 @@ def __copy__(self):
246245
for key, value in self.items():
247246
if key in PROTECTED_KEYS:
248247
continue
249-
extra_kwargs[key] = value # should I copy? deepcopy?
250-
new_image_class = type(self)
251-
new_image = new_image_class(
252-
path=self.path,
253-
type=self.type,
254-
tensor=self.data if self._loaded else None,
255-
affine=self.affine if self._loaded else None,
256-
check_nans=self.check_nans,
257-
reader=self.reader,
258-
**cast(dict[str, Any], extra_kwargs),
259-
)
248+
extra_kwargs[key] = value
249+
if self._loaded:
250+
new_image = self.new_like(tensor=self.data, affine=self.affine)
251+
else:
252+
new_image = type(self)(
253+
path=self.path,
254+
type=self.type,
255+
check_nans=self.check_nans,
256+
reader=self.reader,
257+
)
258+
new_image.path = self.path
259+
for key, value in extra_kwargs.items():
260+
new_image[key] = value
260261
return new_image
261262

262263
@property
@@ -830,6 +831,54 @@ def get_center(self, lps: bool = False) -> TypeTripletFloat:
830831
def set_check_nans(self, check_nans: bool) -> None:
831832
self.check_nans = check_nans
832833

834+
def new_like(self, tensor: TypeData, affine: TypeData | None = None) -> Image:
835+
"""Create a new image of the same type with different data.
836+
837+
This is the extension point for custom :class:`Image` subclasses whose
838+
``__init__`` signature differs from the base class. Transforms that
839+
need to create a *new* image (e.g. :class:`~torchio.Crop` with
840+
``copy_patch=True``) call this method instead of ``type(image)(...)``.
841+
842+
The default implementation works for :class:`Image`,
843+
:class:`ScalarImage`, and :class:`LabelMap`. Subclasses that add
844+
required constructor arguments **must** override this method.
845+
846+
Args:
847+
tensor: 4D tensor with dimensions :math:`(C, W, H, D)`.
848+
affine: :math:`4 \\times 4` affine matrix. If ``None``, the
849+
current image's affine is reused.
850+
851+
Returns:
852+
A new image instance of the same type.
853+
854+
Example:
855+
>>> import torch
856+
>>> import torchio as tio
857+
>>> class MyImage(tio.ScalarImage):
858+
... def __init__(self, tensor, affine, meta, **kw):
859+
... super().__init__(tensor=tensor, affine=affine, **kw)
860+
... self.meta = meta
861+
... def new_like(self, tensor, affine=None):
862+
... return type(self)(
863+
... tensor=tensor,
864+
... affine=affine if affine is not None else self.affine,
865+
... meta=self.meta,
866+
... )
867+
"""
868+
if affine is None:
869+
affine = self.affine
870+
new_image = type(self)(
871+
tensor=tensor,
872+
affine=affine,
873+
type=self.type,
874+
check_nans=self.check_nans,
875+
reader=self.reader,
876+
)
877+
for key, value in self.items():
878+
if key not in PROTECTED_KEYS:
879+
new_image[key] = value
880+
return new_image
881+
833882
def plot(self, return_fig: bool = False, **kwargs) -> None | Figure:
834883
"""Plot image."""
835884
if self.is_2d():

src/torchio/transforms/preprocessing/spatial/crop.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,11 @@ def _crop_image(
111111
if copy_patch:
112112
# Create a new image with the cropped data
113113
cropped_data = image.data[:, i0:i1, j0:j1, k0:k1].clone()
114-
new_image = type(image)(
114+
new_image = image.new_like(
115115
tensor=cropped_data,
116116
affine=new_affine,
117-
type=image.type,
118-
path=image.path,
119117
)
118+
new_image.path = image.path
120119
return new_image
121120
else:
122121
image.set_data(image.data[:, i0:i1, j0:j1, k0:k1].clone())

src/torchio/transforms/preprocessing/spatial/to_reference_space.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,5 @@ def build_image_from_reference(tensor: torch.Tensor, reference: Image) -> Image:
4848
output_spacing = input_spacing * downsampling_factor
4949
downsample = Resample(output_spacing, image_interpolation='nearest')
5050
reference = downsample(reference)
51-
class_ = reference.__class__
52-
result = class_(tensor=tensor, affine=reference.affine)
51+
result = reference.new_like(tensor=tensor, affine=reference.affine)
5352
return result
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Tests for custom Image subclasses with transforms."""
2+
3+
from __future__ import annotations
4+
5+
import copy
6+
7+
import pytest
8+
import torch
9+
10+
import torchio as tio
11+
12+
13+
class HistoryScalarImage(tio.ScalarImage):
14+
"""Custom Image that requires an extra ``history`` argument.
15+
16+
This is the exact subclass from the #1391 reproduction snippet.
17+
"""
18+
19+
def __init__(self, tensor, affine, history, **kwargs):
20+
super().__init__(tensor=tensor, affine=affine, **kwargs)
21+
self.history = history
22+
23+
def new_like(self, tensor, affine=None):
24+
return type(self)(
25+
tensor=tensor,
26+
affine=affine if affine is not None else self.affine,
27+
history=self.history,
28+
check_nans=self.check_nans,
29+
reader=self.reader,
30+
)
31+
32+
33+
class MetadataLabelMap(tio.LabelMap):
34+
"""Custom LabelMap with optional metadata."""
35+
36+
def __init__(self, tensor, affine, labels_info=None, **kwargs):
37+
super().__init__(tensor=tensor, affine=affine, **kwargs)
38+
self.labels_info = labels_info or {}
39+
40+
def new_like(self, tensor, affine=None):
41+
return type(self)(
42+
tensor=tensor,
43+
affine=affine if affine is not None else self.affine,
44+
labels_info=self.labels_info,
45+
check_nans=self.check_nans,
46+
reader=self.reader,
47+
)
48+
49+
50+
@pytest.fixture()
51+
def history_image():
52+
tensor = torch.rand(1, 10, 10, 10)
53+
affine = torch.eye(4)
54+
return HistoryScalarImage(tensor=tensor, affine=affine, history=['created'])
55+
56+
57+
@pytest.fixture()
58+
def history_subject(history_image):
59+
return tio.Subject(image=history_image)
60+
61+
62+
class TestIssue1391Reproduction:
63+
"""Exact reproduction of the snippet in issue #1391."""
64+
65+
def test_crop_custom_subclass(self):
66+
img = HistoryScalarImage(
67+
torch.rand(1, 10, 10, 10),
68+
affine=torch.eye(4),
69+
history=[],
70+
)
71+
subject = tio.Subject(image=img)
72+
transform = tio.Crop(cropping=2)
73+
result = transform(subject)
74+
assert isinstance(result.image, HistoryScalarImage)
75+
assert result.image.shape == (1, 6, 6, 6)
76+
77+
78+
class TestNewLike:
79+
"""Tests for the Image.new_like() factory method."""
80+
81+
def test_new_like_preserves_type(self, history_image):
82+
new = history_image.new_like(torch.rand(1, 5, 5, 5))
83+
assert isinstance(new, HistoryScalarImage)
84+
85+
def test_new_like_preserves_custom_attribute(self, history_image):
86+
new = history_image.new_like(torch.rand(1, 5, 5, 5))
87+
assert new.history == ['created']
88+
89+
def test_new_like_uses_new_tensor(self, history_image):
90+
new_tensor = torch.rand(1, 5, 5, 5)
91+
new = history_image.new_like(new_tensor)
92+
assert torch.equal(new.data, new_tensor)
93+
94+
def test_new_like_uses_new_affine(self, history_image):
95+
new_affine = torch.diag(torch.tensor([2.0, 2.0, 2.0, 1.0]))
96+
new = history_image.new_like(torch.rand(1, 5, 5, 5), affine=new_affine)
97+
assert torch.allclose(
98+
torch.as_tensor(new.affine, dtype=torch.float32),
99+
new_affine,
100+
)
101+
102+
def test_new_like_defaults_to_original_affine(self, history_image):
103+
new = history_image.new_like(torch.rand(1, 5, 5, 5))
104+
assert torch.allclose(
105+
torch.as_tensor(new.affine, dtype=torch.float32),
106+
torch.as_tensor(history_image.affine, dtype=torch.float32),
107+
)
108+
109+
def test_new_like_standard_scalar_image(self):
110+
image = tio.ScalarImage(tensor=torch.rand(1, 8, 8, 8), affine=torch.eye(4))
111+
new = image.new_like(torch.rand(1, 4, 4, 4))
112+
assert isinstance(new, tio.ScalarImage)
113+
114+
def test_new_like_standard_label_map(self):
115+
image = tio.LabelMap(tensor=torch.randint(0, 3, (1, 8, 8, 8)))
116+
new = image.new_like(torch.randint(0, 3, (1, 4, 4, 4)))
117+
assert isinstance(new, tio.LabelMap)
118+
119+
def test_new_like_propagates_extra_dict_keys(self):
120+
image = tio.ScalarImage(
121+
tensor=torch.rand(1, 8, 8, 8),
122+
affine=torch.eye(4),
123+
age=30,
124+
site='hospital_a',
125+
)
126+
new = image.new_like(torch.rand(1, 4, 4, 4))
127+
assert new['age'] == 30
128+
assert new['site'] == 'hospital_a'
129+
130+
def test_crop_preserves_extra_dict_keys(self):
131+
image = tio.ScalarImage(
132+
tensor=torch.rand(1, 10, 10, 10),
133+
affine=torch.eye(4),
134+
age=30,
135+
)
136+
subject = tio.Subject(image=image)
137+
result = tio.Crop(cropping=2)(subject)
138+
assert result.image['age'] == 30
139+
140+
141+
class TestCropWithCustomSubclass:
142+
def test_crop_preserves_type_and_attribute(self, history_subject):
143+
result = tio.Crop(cropping=2)(history_subject)
144+
assert isinstance(result.image, HistoryScalarImage)
145+
assert result.image.history == ['created']
146+
assert result.image.shape == (1, 6, 6, 6)
147+
148+
def test_crop_or_pad_preserves_type(self, history_subject):
149+
result = tio.CropOrPad(target_shape=(6, 6, 6))(history_subject)
150+
assert isinstance(result.image, HistoryScalarImage)
151+
assert result.image.history == ['created']
152+
153+
def test_chained_crops_preserve_type(self, history_subject):
154+
transform = tio.Compose([tio.Crop(cropping=1), tio.Crop(cropping=1)])
155+
result = transform(history_subject)
156+
assert isinstance(result.image, HistoryScalarImage)
157+
assert result.image.history == ['created']
158+
assert result.image.shape == (1, 6, 6, 6)
159+
160+
def test_crop_custom_label_map(self):
161+
tensor = torch.randint(0, 3, (1, 8, 8, 8))
162+
affine = torch.eye(4)
163+
labels_info = {0: 'bg', 1: 'tissue', 2: 'lesion'}
164+
label = MetadataLabelMap(tensor=tensor, affine=affine, labels_info=labels_info)
165+
subject = tio.Subject(seg=label)
166+
result = tio.Crop(cropping=1)(subject)
167+
assert isinstance(result.seg, MetadataLabelMap)
168+
assert result.seg.labels_info == labels_info
169+
assert result.seg.shape == (1, 6, 6, 6)
170+
171+
172+
class TestToReferenceSpaceWithCustomSubclass:
173+
def test_from_tensor_preserves_type(self, history_image):
174+
embedding = torch.rand(1, 10, 10, 10)
175+
result = tio.ToReferenceSpace.from_tensor(embedding, history_image)
176+
assert isinstance(result, HistoryScalarImage)
177+
assert result.history == ['created']
178+
179+
180+
class TestCopyWithCustomSubclass:
181+
def test_copy_preserves_type(self, history_image):
182+
copied = copy.copy(history_image)
183+
assert isinstance(copied, HistoryScalarImage)
184+
assert copied.history == ['created']
185+
186+
def test_copy_preserves_data(self, history_image):
187+
copied = copy.copy(history_image)
188+
assert torch.equal(copied.data, history_image.data)
189+
190+
def test_copy_standard_image(self):
191+
image = tio.ScalarImage(tensor=torch.rand(1, 8, 8, 8), affine=torch.eye(4))
192+
copied = copy.copy(image)
193+
assert isinstance(copied, tio.ScalarImage)
194+
assert torch.equal(copied.data, image.data)

0 commit comments

Comments
 (0)