Skip to content

Commit b904dc0

Browse files
committed
Preserve input dtype in per-instance FFT/bias transforms; guard subsample target
Ghosting, Spike, Motion and BiasField computed in float32 and only cast back to the input dtype for applied elements, so per-instance gating could mix dtypes and break torch.cat for non-float32 batches. Cast every per-element output back to the input dtype (matching the Blur convention). Added a dtype-preservation regression test. Also reject non-positive subsample targets in _subsample_for_quantile with a clear ValueError now that subsample_size is caller-exposed.
1 parent 4e0a7ac commit b904dc0

7 files changed

Lines changed: 61 additions & 5 deletions

File tree

src/torchio/transforms/intensity/bias_field.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,8 @@ def _apply_bias_per_element(
236236
seed=seed_per_element[index],
237237
device=data.device,
238238
)
239-
outputs.append(slice_b / field if divide else slice_b * field)
239+
corrected = slice_b / field if divide else slice_b * field
240+
outputs.append(corrected.to(data.dtype))
240241
return torch.cat(outputs, dim=0)
241242

242243

src/torchio/transforms/intensity/ghosting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,4 @@ def _add_ghosting(
206206
torch.fft.ifftshift(corrupted, dim=(-3, -2, -1)),
207207
dim=(-3, -2, -1),
208208
).real
209-
return result
209+
return result.to(data.dtype)

src/torchio/transforms/intensity/motion.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def _apply_motion(
176176
reconstructed = torch.fft.ifftn(spectrum, dim=(-3, -2, -1))
177177
result[b] = reconstructed.real
178178

179-
return result
179+
return result.to(data.dtype)
180180

181181

182182
def _apply_rigid_transform(

src/torchio/transforms/intensity/normalize.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,12 +384,18 @@ def _subsample_for_quantile(values: Tensor, target: int) -> Tensor:
384384
385385
Args:
386386
values: 1D tensor of values.
387-
target: Maximum number of elements to keep.
387+
target: Maximum number of elements to keep. Must be positive.
388388
389389
Returns:
390390
*values* unchanged if already within *target*, else a strided
391391
view with at most *target* elements.
392+
393+
Raises:
394+
ValueError: If *target* is not a positive integer.
392395
"""
396+
if target <= 0:
397+
msg = f"target must be a positive integer, got {target}"
398+
raise ValueError(msg)
393399
if values.numel() <= target:
394400
return values
395401
step = -(-values.numel() // target)

src/torchio/transforms/intensity/spike.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,4 @@ def _add_spikes(
166166
torch.fft.ifftshift(spectrum, dim=(-3, -2, -1)),
167167
dim=(-3, -2, -1),
168168
).real
169-
return result
169+
return result.to(data.dtype)

tests/test_normalize.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,17 @@ def test_subsample_for_quantile_passthrough_when_small(self) -> None:
282282
sample = _subsample_for_quantile(values, 1000)
283283
assert sample is values
284284

285+
@pytest.mark.parametrize("target", [0, -1])
286+
def test_subsample_for_quantile_rejects_non_positive_target(
287+
self,
288+
target: int,
289+
) -> None:
290+
from torchio.transforms.intensity.normalize import _subsample_for_quantile
291+
292+
values = torch.arange(10, dtype=torch.float32)
293+
with pytest.raises(ValueError, match="positive integer"):
294+
_subsample_for_quantile(values, target)
295+
285296
def test_rescale_intensity_large_image(self) -> None:
286297
# Full integration on a tensor exceeding torch.quantile's 2**24 limit.
287298
# The default 0/100 percentiles use min/max, so this stays fast.

tests/test_per_instance.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,41 @@ def test_history_slice_out_of_range_raises(self) -> None:
129129
history = result.applied_transforms
130130
with pytest.raises(IndexError, match="batch of size 4"):
131131
_slice_history(history, 4)
132+
133+
134+
class TestPerInstanceDtypePreservation:
135+
"""Per-instance gating must not produce mixed-dtype batch outputs.
136+
137+
Float-domain intensity transforms compute in ``float32`` internally.
138+
When per-element gating skips some elements, the skipped ones keep the
139+
input dtype while applied ones must be cast back, so ``torch.cat`` over
140+
the batch does not fail on a dtype mismatch for non-``float32`` inputs.
141+
"""
142+
143+
@staticmethod
144+
def _mixed_dtype_batch(dtype: torch.dtype, batch_size: int = 8):
145+
data = (torch.rand(1, 8, 8, 8) + 0.5).to(dtype)
146+
subjects = [
147+
tio.Subject(t1=tio.ScalarImage(data.clone())) for _ in range(batch_size)
148+
]
149+
return tio.SubjectsBatch.from_subjects(subjects)
150+
151+
@pytest.mark.parametrize(
152+
"transform",
153+
[
154+
tio.Ghosting(num_ghosts=4, intensity=1.0, p=0.5),
155+
tio.Spike(num_spikes=2, intensity=1.0, p=0.5),
156+
tio.Motion(degrees=10.0, translation=10.0, num_transforms=2, p=0.5),
157+
],
158+
)
159+
def test_fft_transforms_preserve_float64(self, transform) -> None:
160+
torch.manual_seed(0)
161+
batch = self._mixed_dtype_batch(torch.float64)
162+
result = transform(batch)
163+
assert result.t1.data.dtype == torch.float64
164+
165+
def test_bias_field_preserves_float16(self) -> None:
166+
torch.manual_seed(0)
167+
batch = self._mixed_dtype_batch(torch.float16)
168+
result = tio.BiasField(std=0.5, p=0.5)(batch)
169+
assert result.t1.data.dtype == torch.float16

0 commit comments

Comments
 (0)