Skip to content

Preserve lazy cropped/padded backend when deep-copying images#1479

Merged
fepegar merged 6 commits into
mainfrom
fepegar/fix-cropped-deepcopy
Jun 21, 2026
Merged

Preserve lazy cropped/padded backend when deep-copying images#1479
fepegar merged 6 commits into
mainfrom
fepegar/fix-cropped-deepcopy

Conversation

@fepegar

@fepegar fepegar commented Jun 18, 2026

Copy link
Copy Markdown
Member

[Generated by a coding agent]


Summary

Fixes a correctness bug where applying a transform after a lazy CropOrPad silently operated on the original, un-cropped image.

Root cause

CropOrPad on an unloaded (path-backed) image installs a lazy _CroppedBackend / _PaddedBackend on a new Image, leaving _data as None and keeping the original _path. Image.__deepcopy__ rebuilt such images from _path only, discarding self._backend (the comment even read "Backend will be lazily recreated from path when needed"). The copy therefore reverted to the full-resolution image.

Because Transform.forward deep-copies its input (copy=True by default), any transform applied after a lazy crop saw the original shape:

colin   = tio.datasets.Colin27(2008)            # 0.5 mm, 362x434x362
cropped = tio.CropOrPad(192)(tio.Subject(t1=colin.images["t1"]))
cropped.t1.shape                 # (1, 192, 192, 192)  ok

copy.deepcopy(cropped.t1).shape  # (1, 362, 434, 362)  BUG before fix
tio.Resample(4)(cropped).t1.shape  # (1, 45, 54, 45)   BUG before fix (should be ~24^3)

Fix

In the source-based branches of Image.__deepcopy__ (_remote_zarr_uri, _path, _zarr_store), when _data is None but a backend is set, deep-copy self._backend so a derived lazy view (cropped/padded) survives the copy. Loaded/in-memory images are unaffected (they still clone _data).

After the fix:

copy.deepcopy(cropped.t1).shape    # (1, 192, 192, 192) ok
tio.Resample(4)(cropped).t1.shape  # (1, 24, 24, 24)    ok

Tests

Added regression tests in tests/test_crop_or_pad.py::TestLazyBackends:

  • deep-copy of a lazily cropped image preserves shape, data, and laziness;
  • deep-copy of a lazily padded image preserves shape and data;
  • a transform applied after a lazy crop uses the cropped shape (CropOrPad(10) then Pad(2) -> 14^3).

uv run pytest tests/test_crop_or_pad.py tests/test_image.py tests/test_backends.py tests/test_transforms_base.py tests/test_crop.py -> all passing. ruff and ty clean.

Notes

This bug was discovered while building a demo for #1477 (partial-volume label resampling); this PR is independent of that change and targets main.

`Image.__deepcopy__` rebuilt path/store-backed images from their source
only, discarding `self._backend`. For a lazily cropped or padded image
(where `CropOrPad` installs a `_CroppedBackend` / `_PaddedBackend` and
leaves `_data` as None), the copy reverted to the full-resolution image.

Because `Transform.forward` deep-copies its input, this meant any
transform applied after a lazy `CropOrPad` operated on the original,
un-cropped image (e.g. `Resample` computed the output grid from the
wrong shape).

Preserve the derived lazy backend by deep-copying `self._backend` in the
source-based branches when `_data` is None, so the crop/pad survives the
copy. Add regression tests covering deep-copy of cropped and padded lazy
images and a transform applied after a lazy crop.
Copilot AI review requested due to automatic review settings June 18, 2026 19:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a correctness issue where copy.deepcopy(Image) could discard a derived lazy backend (e.g., _CroppedBackend / _PaddedBackend from CropOrPad) and silently revert to the original, uncropped/padded source when subsequent transforms deep-copy their inputs.

Changes:

  • Preserve an existing lazy backend during Image.__deepcopy__() when the image is source-backed and still unloaded (_data is None).
  • Add regression tests covering deepcopy of lazily cropped/padded images and a “transform-after-lazy-crop” pipeline.
  • Expand inline documentation in Image.__deepcopy__() explaining why the backend must be preserved.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
src/torchio/data/image.py Updates Image.__deepcopy__ to preserve lazy derived backends for unloaded, source-backed images.
tests/test_crop_or_pad.py Adds regression tests ensuring lazy crop/pad backends survive deepcopy and are respected by subsequent transforms.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/torchio/data/image.py Outdated
Comment thread src/torchio/data/image.py Outdated
Comment thread src/torchio/data/image.py Outdated
Comment thread tests/test_crop_or_pad.py
- Use copy.copy instead of copy.deepcopy for the preserved lazy backend
  in Image.__deepcopy__: the backend is a read-only access adapter, so a
  shallow copy preserves the derived crop/pad view without deep-copying
  nibabel/zarr internals or duplicating open I/O resources.
- Assert the padded lazy image stays unloaded before and after deepcopy
  so the test exercises the lazy-backend code path.
@fepegar
fepegar requested a review from Copilot June 18, 2026 19:31
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

📖 Docs Preview

Preview of the documentation for this PR:

🔗 https://smokeshow.helpmanual.io/6q0a642115225t380d5a/

Built from 3ea394d

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread src/torchio/data/image.py Outdated
A backend-only image (e.g. built from a nib.Nifti1Image, with _backend
set but no _path or _data) fell through __deepcopy__ to the empty
fallback branch, so the copy lost its backend and failed on shape/load.
Preserve the backend (shallow copy) in the fallback branch too, and add
a regression test for deep-copying a nibabel-backed lazy image.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread tests/test_crop_or_pad.py
Assert the intermediate cropped image is unloaded so the regression test
reliably exercises the lazy-backend deepcopy path.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@fepegar fepegar changed the title Fix: preserve lazy cropped/padded backend when deep-copying images Preserve lazy cropped/padded backend when deep-copying images Jun 19, 2026
fepegar and others added 2 commits June 19, 2026 10:19
Extract the repeated source-construction and payload-restoration logic
into `_deepcopy_from_source`, so `__deepcopy__` drops below the Xenon
complexity threshold. Behavior is unchanged.
@fepegar
fepegar merged commit 5529ca0 into main Jun 21, 2026
30 checks passed
@fepegar
fepegar deleted the fepegar/fix-cropped-deepcopy branch June 21, 2026 22:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants