Skip to content

Commit 9cda999

Browse files
fix: optimize memory usage in CellMapImage by changing full_coords to property and caching _array_shape
1 parent e3b2612 commit 9cda999

2 files changed

Lines changed: 146 additions & 6 deletions

File tree

src/cellmap_data/image.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,20 @@ def coord_offsets(self) -> Mapping[str, np.ndarray]:
187187
for c in self.axes
188188
}
189189

190+
@cached_property
191+
def _array_shape(self) -> tuple[int, ...]:
192+
"""Shape of the selected scale-level array.
193+
194+
Cached as a compact tuple of ints rather than opening the zarr array
195+
on every access. Used by ``full_coords`` and ``shape`` so that both
196+
share the same single zarr metadata read.
197+
"""
198+
return tuple(int(s) for s in self.group[self.scale_level].shape)
199+
190200
@cached_property
191201
def shape(self) -> Mapping[str, int]:
192202
"""Returns the shape of the image."""
193-
shape = self.group[self.scale_level].shape
194-
return {c: int(s) for c, s in zip(self.axes, shape)}
203+
return {c: s for c, s in zip(self.axes, self._array_shape)}
195204

196205
@cached_property
197206
def center(self) -> Mapping[str, float]:
@@ -220,14 +229,25 @@ def coordinateTransformations(
220229
# tx_fused = normalize_transforms(multi_tx, dset.coordinateTransformations)
221230
return dset.coordinateTransformations
222231

223-
@cached_property
232+
@property
224233
def full_coords(self) -> tuple[xarray.DataArray, ...]:
225-
"""Returns the full coordinates of the image's axes in world units."""
234+
"""Returns the full coordinates of the image's axes in world units.
235+
236+
This is a plain ``@property`` (not ``@cached_property``) so the large
237+
coordinate arrays are NOT kept alive between ``__getitem__`` calls.
238+
``bounding_box`` accesses it once (during its own cached initialisation)
239+
and then the arrays are freed. ``array`` accesses it each time it is
240+
rebuilt (after ``_clear_array_cache``), which is also once per
241+
``__getitem__``, so there is no performance regression.
242+
243+
All inputs (``multiscale_attrs``, ``coordinateTransformations``,
244+
``_array_shape``) are individually cached, so reconstruction is pure
245+
in-process arithmetic — no NFS reads after the first call.
246+
"""
226247
return coords_from_transforms(
227248
axes=self.multiscale_attrs.axes,
228249
transforms=self.coordinateTransformations, # type: ignore
229-
# transforms=tx_fused,
230-
shape=self.group[self.scale_level].shape, # type: ignore
250+
shape=self._array_shape, # type: ignore
231251
)
232252

233253
@cached_property

tests/test_image_edge_cases.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,3 +622,123 @@ def test_coord_offsets_values_match_output_size_and_scale(self, test_zarr_image)
622622
expected_hi = image.output_size[axis] / 2 - image.scale[axis] / 2
623623
assert abs(arr[0] - expected_lo) < 1e-9
624624
assert abs(arr[-1] - expected_hi) < 1e-9
625+
626+
627+
# ---------------------------------------------------------------------------
628+
# full_coords memory fix: property (not cached) + _array_shape
629+
# ---------------------------------------------------------------------------
630+
631+
632+
class TestFullCoordsMemoryFix:
633+
"""Verify the full_coords / _array_shape memory-reduction change.
634+
635+
full_coords was changed from @cached_property to @property so that the
636+
large per-axis coordinate arrays are not held in memory between
637+
__getitem__ calls. _array_shape replaces the per-call zarr shape read
638+
with a compact cached tuple.
639+
"""
640+
641+
@pytest.fixture
642+
def image(self, tmp_path):
643+
data = create_test_image_data((32, 32, 32), pattern="gradient")
644+
path = tmp_path / "test_image.zarr"
645+
create_test_zarr_array(path, data, scale=(4.0, 4.0, 4.0))
646+
return CellMapImage(
647+
path=str(path),
648+
target_class="test_class",
649+
target_scale=(4.0, 4.0, 4.0),
650+
target_voxel_shape=(8, 8, 8),
651+
)
652+
653+
# ------------------------------------------------------------------
654+
# _array_shape: cached, compact
655+
# ------------------------------------------------------------------
656+
657+
def test_array_shape_is_cached(self, image):
658+
"""_array_shape must be a @cached_property (stored in __dict__)."""
659+
_ = image._array_shape
660+
assert "_array_shape" in image.__dict__
661+
662+
def test_array_shape_is_tuple_of_ints(self, image):
663+
"""_array_shape must be a tuple of plain Python ints."""
664+
shape = image._array_shape
665+
assert isinstance(shape, tuple)
666+
assert all(isinstance(s, int) for s in shape)
667+
668+
def test_array_shape_matches_source_array(self, image):
669+
"""_array_shape must match the underlying zarr array dimensions."""
670+
shape = image._array_shape
671+
assert shape == (32, 32, 32)
672+
673+
def test_array_shape_same_object_on_repeated_access(self, image):
674+
"""_array_shape returns the same tuple object (cached, not recomputed)."""
675+
s1 = image._array_shape
676+
s2 = image._array_shape
677+
assert s1 is s2
678+
679+
# ------------------------------------------------------------------
680+
# full_coords: NOT cached between calls
681+
# ------------------------------------------------------------------
682+
683+
def test_full_coords_not_in_dict_after_bounding_box(self, image):
684+
"""After bounding_box initialises, full_coords must NOT be in __dict__.
685+
686+
bounding_box is the primary consumer of full_coords during setup;
687+
the fix requires that the large coord arrays are freed immediately
688+
after bounding_box is cached.
689+
"""
690+
_ = image.bounding_box # triggers full_coords access internally
691+
assert "full_coords" not in image.__dict__
692+
693+
def test_full_coords_not_cached_after_getitem(self, image):
694+
"""After a __getitem__ call, full_coords must not be in __dict__."""
695+
center = {"z": 64.0, "y": 64.0, "x": 64.0}
696+
_ = image[center]
697+
assert "full_coords" not in image.__dict__
698+
699+
def test_full_coords_returns_new_object_each_access(self, image):
700+
"""full_coords must produce a new tuple on every call (not cached)."""
701+
fc1 = image.full_coords
702+
fc2 = image.full_coords
703+
assert fc1 is not fc2
704+
705+
def test_full_coords_values_consistent(self, image):
706+
"""Repeated calls to full_coords must return equivalent coordinate values."""
707+
import numpy as np
708+
709+
fc1 = image.full_coords
710+
fc2 = image.full_coords
711+
assert len(fc1) == len(fc2)
712+
for da1, da2 in zip(fc1, fc2):
713+
np.testing.assert_array_equal(da1.values, da2.values)
714+
assert da1.dims == da2.dims
715+
716+
# ------------------------------------------------------------------
717+
# shape property still correct (now delegates to _array_shape)
718+
# ------------------------------------------------------------------
719+
720+
def test_shape_property_correct(self, image):
721+
"""shape must still return the correct axis→size mapping."""
722+
shape = image.shape
723+
assert isinstance(shape, dict)
724+
for axis in image.axes:
725+
assert axis in shape
726+
assert shape[axis] == 32
727+
728+
def test_shape_uses_array_shape(self, image):
729+
"""shape values must match _array_shape elements."""
730+
arr_shape = image._array_shape
731+
for s, axis in zip(arr_shape, image.axes):
732+
assert image.shape[axis] == s
733+
734+
# ------------------------------------------------------------------
735+
# bounding_box correctness preserved after the refactor
736+
# ------------------------------------------------------------------
737+
738+
def test_bounding_box_still_correct_after_refactor(self, image):
739+
"""bounding_box must still return valid min/max per axis."""
740+
bbox = image.bounding_box
741+
assert set(bbox.keys()) == set(image.axes)
742+
for axis in image.axes:
743+
lo, hi = bbox[axis]
744+
assert lo <= hi

0 commit comments

Comments
 (0)