From ffe580fcd7cc0a51322bcc215311c2f0158334bb Mon Sep 17 00:00:00 2001 From: dmt Date: Thu, 11 Jun 2026 11:33:41 -0700 Subject: [PATCH 1/6] refactor(transforms): split reshape_data into compute_dim_specs + finalize_dims Co-Authored-By: Claude Opus 4.8 --- bioio_base/tests/test_transforms.py | 37 +++++ bioio_base/transforms.py | 236 ++++++++++++++++++---------- 2 files changed, 189 insertions(+), 84 deletions(-) diff --git a/bioio_base/tests/test_transforms.py b/bioio_base/tests/test_transforms.py index 37e786d..06232b4 100644 --- a/bioio_base/tests/test_transforms.py +++ b/bioio_base/tests/test_transforms.py @@ -520,3 +520,40 @@ def test_convert_list_to_slice( list_to_test: Union[List, Tuple], expected: Union[int, List, slice, Tuple] ) -> None: assert transforms.reduce_to_slice(list_to_test) == expected + + +def test_compute_dim_specs_fixed_integer_drops_axis() -> None: + specs, new_dims = transforms.compute_dim_specs( + (10, 100, 100), "ZYX", "YX", Z=1 + ) + # Z is fixed and not in return_dims -> integer spec, axis dropped from new_dims + assert specs == [1, slice(None, None, None), slice(None, None, None)] + assert new_dims == "YX" + + +def test_compute_dim_specs_slice_kept() -> None: + specs, new_dims = transforms.compute_dim_specs( + (10, 100, 100), "ZYX", "ZYX", Z=slice(0, 4, 2) + ) + assert specs == [ + slice(0, 4, 2), + slice(None, None, None), + slice(None, None, None), + ] + assert new_dims == "ZYX" + + +def test_finalize_dims_pads_and_transposes() -> None: + data = np.zeros((100, 100)) # YX after indexing + out = transforms.finalize_dims(data, "YX", "YX", "TYX") + assert out.shape == (1, 100, 100) + + +def test_reshape_data_still_matches_helpers() -> None: + data = np.random.rand(10, 100, 100) + direct = transforms.reshape_data(data, "ZYX", "YX", Z=3) + specs, new_dims = transforms.compute_dim_specs(data.shape, "ZYX", "YX", Z=3) + via_helpers = transforms.finalize_dims( + data[tuple(specs)], new_dims, "ZYX", "YX" + ) + np.testing.assert_array_equal(direct, via_helpers) diff --git a/bioio_base/transforms.py b/bioio_base/transforms.py index ccdac54..d42d3b8 100644 --- a/bioio_base/transforms.py +++ b/bioio_base/transforms.py @@ -82,94 +82,38 @@ def transpose_to_dims( return data -def reshape_data( - data: types.ArrayLike, given_dims: str, return_dims: str, **kwargs: Any -) -> types.ArrayLike: +def compute_dim_specs( + shape: Tuple[int, ...], given_dims: str, return_dims: str, **kwargs: Any +) -> Tuple[List[Any], str]: """ - Reshape the data into return_dims, pad missing dimensions, and prune extra - dimensions. Warns the user to use the base reader if the depth of the Dimension - being removed is not 1. + Compute the per-dimension getitem spec and the resulting dim string. + + This is the indexer-building half of ``reshape_data``: it turns the kwargs + selection into a list of getitem operations (one per dimension in + ``given_dims``) plus the dimension string that results after the getitem. + + Integer specs (a dimension fixed to one index and NOT present in + ``return_dims``) drop their axis under numpy/dask getitem; slice/list specs + keep their axis. Reading no pixels, this can be computed from ``shape`` alone. Parameters ---------- - data: types.ArrayLike - Either a dask array or numpy.ndarray of arbitrary shape but with the dimensions - specified in given_dims + shape: Tuple[int, ...] + The shape of the data the specs will be applied to. given_dims: str - The dimension ordering of data, "CZYX", "VBTCXZY" etc + The dimension ordering of the data, "CZYX", "VBTCXZY" etc. return_dims: str - The dimension ordering of the return data - kwargs: - * C=1 => desired specific channel, if C in the input data has depth 3 then C=1 - returns the 2nd slice (0 indexed) - * Z=10 => desired specific channel, if Z in the input data has depth 20 then - Z=10 returns the 11th slice - * T=[0, 1] => desired specific timepoints, if T in the input data has depth 100 - then T=[0, 1] returns the 1st and 2nd slice (0 indexed) - * T=(0, 1) => desired specific timepoints, if T in the input data has depth 100 - then T=(0, 1) returns the 1st and 2nd slice (0 indexed) - * T=(0, -1) => desired specific timepoints, if T in the input data has depth 100 - then T=(0, -1) returns the first and last slice - * T=range(10) => desired specific timepoints, if T in the input data has depth - 100 then T=range(10) returns the first ten slices - * T=slice(0, -1, 5) => desired specific timepoints, T=slice(0, -1, 5) returns - every fifth timepoint + The dimension ordering of the return data. + kwargs: Any + Per-dimension selections (see ``reshape_data``). Returns ------- - data: types.ArrayLike - The data with the specified dimension ordering. - - Raises - ------ - ConflictingArgumentsError - Missing dimension in return dims when using range, slice, or multi-index - dimension selection for the requested dimension. - - IndexError - Requested dimension index not present in data. - - Examples - -------- - Specific index selection - - >>> data = np.random.rand((10, 100, 100)) - ... z1 = reshape_data(data, "ZYX", "YX", Z=1) - - List of index selection - - >>> data = np.random.rand((10, 100, 100)) - ... first_and_second = reshape_data(data, "ZYX", "YX", Z=[0, 1]) - - Tuple of index selection - - >>> data = np.random.rand((10, 100, 100)) - ... first_and_last = reshape_data(data, "ZYX", "YX", Z=(0, -1)) - - Range of index selection - - >>> data = np.random.rand((10, 100, 100)) - ... first_three = reshape_data(data, "ZYX", "YX", Z=range(3)) - - Slice selection - - >>> data = np.random.rand((10, 100, 100)) - ... every_other = reshape_data(data, "ZYX", "YX", Z=slice(0, -1, 2)) - - Empty dimension expansion - - >>> data = np.random.rand((10, 100, 100)) - ... with_time = reshape_data(data, "ZYX", "TZYX") - - Dimension order shuffle - - >>> data = np.random.rand((10, 100, 100)) - ... as_zx_base = reshape_data(data, "ZYX", "YZX") - - Selections, empty dimension expansions, and dimension order shuffle - - >>> data = np.random.rand((10, 100, 100)) - ... example = reshape_data(data, "CYX", "BSTCZYX", C=slice(0, -1, 3)) + dim_specs: List[Any] + One getitem operation per dimension in ``given_dims``. + new_dims: str + The dimension ordering after applying ``dim_specs`` (integer-selected, + non-return dimensions removed). """ # Check for parameter conflicts for dim in given_dims: @@ -189,7 +133,7 @@ def reshape_data( # Process each dimension available new_dims = given_dims - dim_specs = [] + dim_specs: List[Any] = [] for dim in given_dims: # Store index of the dim as it is in given data dim_index = given_dims.index(dim) @@ -254,18 +198,45 @@ def reshape_data( new_dims = new_dims.replace(dim, "") # Check that fixed integer request isn't outside of request - if check_selection_max > data.shape[dim_index]: + if check_selection_max > shape[dim_index]: raise IndexError( f"Dimension specified with {dim}={display_dim_spec} " - f"but Dimension shape is {data.shape[dim_index]}." + f"but Dimension shape is {shape[dim_index]}." ) # All checks and operations passed, append dim operation to getitem ops dim_specs.append(dim_spec) - # Run getitems - data = data[tuple(dim_specs)] + return dim_specs, new_dims + +def finalize_dims( + data: types.ArrayLike, new_dims: str, given_dims: str, return_dims: str +) -> types.ArrayLike: + """ + Pad missing return dimensions with depth-1 axes, then transpose to + ``return_dims``. + + This is the reshape/transpose half of ``reshape_data``, applied after a + getitem has already selected the requested data. + + Parameters + ---------- + data: types.ArrayLike + The already-indexed data (in ``new_dims`` order). + new_dims: str + The dimension ordering of ``data`` (the output of ``compute_dim_specs``). + given_dims: str + The original dimension ordering, used to detect which return dimensions + were not present in the source data and must be padded in. + return_dims: str + The desired output dimension ordering. + + Returns + ------- + data: types.ArrayLike + The data with the specified dimension ordering. + """ # Add empty dims where dimensions were requested but data doesn't exist # Add dimensions to new dims where empty dims are added for i, dim in enumerate(return_dims): @@ -280,6 +251,103 @@ def reshape_data( ) # don't pass kwargs or 2 copies +def reshape_data( + data: types.ArrayLike, given_dims: str, return_dims: str, **kwargs: Any +) -> types.ArrayLike: + """ + Reshape the data into return_dims, pad missing dimensions, and prune extra + dimensions. Warns the user to use the base reader if the depth of the Dimension + being removed is not 1. + + Parameters + ---------- + data: types.ArrayLike + Either a dask array or numpy.ndarray of arbitrary shape but with the dimensions + specified in given_dims + given_dims: str + The dimension ordering of data, "CZYX", "VBTCXZY" etc + return_dims: str + The dimension ordering of the return data + kwargs: + * C=1 => desired specific channel, if C in the input data has depth 3 then C=1 + returns the 2nd slice (0 indexed) + * Z=10 => desired specific channel, if Z in the input data has depth 20 then + Z=10 returns the 11th slice + * T=[0, 1] => desired specific timepoints, if T in the input data has depth 100 + then T=[0, 1] returns the 1st and 2nd slice (0 indexed) + * T=(0, 1) => desired specific timepoints, if T in the input data has depth 100 + then T=(0, 1) returns the 1st and 2nd slice (0 indexed) + * T=(0, -1) => desired specific timepoints, if T in the input data has depth 100 + then T=(0, -1) returns the first and last slice + * T=range(10) => desired specific timepoints, if T in the input data has depth + 100 then T=range(10) returns the first ten slices + * T=slice(0, -1, 5) => desired specific timepoints, T=slice(0, -1, 5) returns + every fifth timepoint + + Returns + ------- + data: types.ArrayLike + The data with the specified dimension ordering. + + Raises + ------ + ConflictingArgumentsError + Missing dimension in return dims when using range, slice, or multi-index + dimension selection for the requested dimension. + + IndexError + Requested dimension index not present in data. + + Examples + -------- + Specific index selection + + >>> data = np.random.rand((10, 100, 100)) + ... z1 = reshape_data(data, "ZYX", "YX", Z=1) + + List of index selection + + >>> data = np.random.rand((10, 100, 100)) + ... first_and_second = reshape_data(data, "ZYX", "YX", Z=[0, 1]) + + Tuple of index selection + + >>> data = np.random.rand((10, 100, 100)) + ... first_and_last = reshape_data(data, "ZYX", "YX", Z=(0, -1)) + + Range of index selection + + >>> data = np.random.rand((10, 100, 100)) + ... first_three = reshape_data(data, "ZYX", "YX", Z=range(3)) + + Slice selection + + >>> data = np.random.rand((10, 100, 100)) + ... every_other = reshape_data(data, "ZYX", "YX", Z=slice(0, -1, 2)) + + Empty dimension expansion + + >>> data = np.random.rand((10, 100, 100)) + ... with_time = reshape_data(data, "ZYX", "TZYX") + + Dimension order shuffle + + >>> data = np.random.rand((10, 100, 100)) + ... as_zx_base = reshape_data(data, "ZYX", "YZX") + + Selections, empty dimension expansions, and dimension order shuffle + + >>> data = np.random.rand((10, 100, 100)) + ... example = reshape_data(data, "CYX", "BSTCZYX", C=slice(0, -1, 3)) + """ + # Compute the per-dimension getitem ops, run them, then pad/transpose. + dim_specs, new_dims = compute_dim_specs( + data.shape, given_dims, return_dims, **kwargs + ) + data = data[tuple(dim_specs)] + return finalize_dims(data, new_dims, given_dims, return_dims) + + def generate_stack( image_container: ImageContainer, mode: Literal["data", "dask_data", "xarray_data", "xarray_dask_data"], From c2b164b95d88a497643a8d271ff7ef9f33a4c0eb Mon Sep 17 00:00:00 2001 From: dmt Date: Thu, 11 Jun 2026 11:56:19 -0700 Subject: [PATCH 2/6] feat(reader): add _read_indexed seam so get_image_data reads only requested region Co-Authored-By: Claude Opus 4.8 --- bioio_base/reader.py | 47 ++++++++++++++++++++++++++++----- bioio_base/tests/test_reader.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 bioio_base/tests/test_reader.py diff --git a/bioio_base/reader.py b/bioio_base/reader.py index 8eefdb3..d9b6b9c 100644 --- a/bioio_base/reader.py +++ b/bioio_base/reader.py @@ -727,6 +727,33 @@ def get_image_dask_data( **kwargs, ) + def _read_indexed(self, given_dims: str, dim_specs: list) -> np.ndarray: + """ + Return the native-order array with ``dim_specs`` applied. + + This is the seam that lets ``get_image_data`` read only the requested + sub-region. The default implementation materializes the full image and + slices it; readers that can read sub-regions cheaply should override it. + + The result MUST equal ``self.data[tuple(dim_specs)]`` — i.e. integer specs + drop their axis while slice/list specs keep theirs, and the remaining axes + stay in ``given_dims`` order. + + Parameters + ---------- + given_dims: str + The native dimension ordering of the image (``self.dims.order``). + dim_specs: list + One getitem operation per dimension in ``given_dims``, as produced by + ``transforms.compute_dim_specs``. + + Returns + ------- + data: np.ndarray + The indexed image data in native (reduced) dimension order. + """ + return self.data[tuple(dim_specs)] + def get_image_data( self, dimension_order_out: Optional[str] = None, **kwargs: Any ) -> np.ndarray: @@ -789,21 +816,27 @@ def get_image_data( ----- * If a requested dimension is not present in the data the dimension is added with a depth of 1. - * This will preload the entire image before returning the requested data. + * Only the requested sub-region is read when the reader supports it (see + ``_read_indexed``); otherwise the full image is read then sliced. See `aicsimageio.transforms.reshape_data` for more details. """ - # If no out orientation, simply return current data as dask array + # If no out orientation, simply return current data if dimension_order_out is None: return self.data - # Transform and return - return transforms.reshape_data( - data=self.data, - given_dims=self.dims.order, - return_dims=dimension_order_out, + # Compute the indexer (reads no pixels), let the reader materialize only + # the requested sub-region, then pad/transpose to the requested order. + dim_specs, new_dims = transforms.compute_dim_specs( + self.shape, + self.dims.order, + dimension_order_out, **kwargs, ) + indexed = self._read_indexed(self.dims.order, dim_specs) + return transforms.finalize_dims( + indexed, new_dims, self.dims.order, dimension_order_out + ) @property def metadata(self) -> Any: diff --git a/bioio_base/tests/test_reader.py b/bioio_base/tests/test_reader.py new file mode 100644 index 0000000..80e8dbb --- /dev/null +++ b/bioio_base/tests/test_reader.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import numpy as np + +from bioio_base import noop_reader, transforms + + +def test_get_image_data_matches_full_read_then_slice() -> None: + # NoopReader exposes mock data with dims TCZYX (shape 4, 5, 6, 7, 8). + reader = noop_reader.NoopReader("anything") + expected = transforms.reshape_data( + reader.data, reader.dims.order, "ZYX", T=0, C=1 + ) + actual = reader.get_image_data("ZYX", T=0, C=1) + np.testing.assert_array_equal(actual, expected) + + +def test_get_image_data_matches_full_read_then_slice_with_iterables() -> None: + reader = noop_reader.NoopReader("anything") + expected = transforms.reshape_data( + reader.data, reader.dims.order, "TCZYX", C=[0, 2], Z=slice(0, 4, 2) + ) + actual = reader.get_image_data("TCZYX", C=[0, 2], Z=slice(0, 4, 2)) + np.testing.assert_array_equal(actual, expected) + + +def test_get_image_data_no_order_returns_full_data() -> None: + reader = noop_reader.NoopReader("anything") + np.testing.assert_array_equal(reader.get_image_data(), reader.data) + + +def test_read_indexed_default_matches_getitem() -> None: + reader = noop_reader.NoopReader("anything") + specs, _ = transforms.compute_dim_specs( + reader.shape, reader.dims.order, "CZYX", T=1 + ) + np.testing.assert_array_equal( + reader._read_indexed(reader.dims.order, specs), + reader.data[tuple(specs)], + ) From 6a840dc6fb19f9fc7f987a626cf51676f26527ac Mon Sep 17 00:00:00 2001 From: toloudis Date: Thu, 11 Jun 2026 13:29:44 -0700 Subject: [PATCH 3/6] fix lint --- bioio_base/tests/test_reader.py | 4 +--- bioio_base/tests/test_transforms.py | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/bioio_base/tests/test_reader.py b/bioio_base/tests/test_reader.py index 80e8dbb..cac00ad 100644 --- a/bioio_base/tests/test_reader.py +++ b/bioio_base/tests/test_reader.py @@ -8,9 +8,7 @@ def test_get_image_data_matches_full_read_then_slice() -> None: # NoopReader exposes mock data with dims TCZYX (shape 4, 5, 6, 7, 8). reader = noop_reader.NoopReader("anything") - expected = transforms.reshape_data( - reader.data, reader.dims.order, "ZYX", T=0, C=1 - ) + expected = transforms.reshape_data(reader.data, reader.dims.order, "ZYX", T=0, C=1) actual = reader.get_image_data("ZYX", T=0, C=1) np.testing.assert_array_equal(actual, expected) diff --git a/bioio_base/tests/test_transforms.py b/bioio_base/tests/test_transforms.py index 06232b4..1a9abcb 100644 --- a/bioio_base/tests/test_transforms.py +++ b/bioio_base/tests/test_transforms.py @@ -523,9 +523,7 @@ def test_convert_list_to_slice( def test_compute_dim_specs_fixed_integer_drops_axis() -> None: - specs, new_dims = transforms.compute_dim_specs( - (10, 100, 100), "ZYX", "YX", Z=1 - ) + specs, new_dims = transforms.compute_dim_specs((10, 100, 100), "ZYX", "YX", Z=1) # Z is fixed and not in return_dims -> integer spec, axis dropped from new_dims assert specs == [1, slice(None, None, None), slice(None, None, None)] assert new_dims == "YX" @@ -553,7 +551,5 @@ def test_reshape_data_still_matches_helpers() -> None: data = np.random.rand(10, 100, 100) direct = transforms.reshape_data(data, "ZYX", "YX", Z=3) specs, new_dims = transforms.compute_dim_specs(data.shape, "ZYX", "YX", Z=3) - via_helpers = transforms.finalize_dims( - data[tuple(specs)], new_dims, "ZYX", "YX" - ) + via_helpers = transforms.finalize_dims(data[tuple(specs)], new_dims, "ZYX", "YX") np.testing.assert_array_equal(direct, via_helpers) From a98cddeeef6ab95024571d571c546f4c23b93b4f Mon Sep 17 00:00:00 2001 From: Brian Whitney Date: Thu, 18 Jun 2026 12:58:20 -0700 Subject: [PATCH 4/6] refactor(types): type dim selections/specs and tighten transform tests Replace the Any typing on compute_dim_specs/reshape_data/_read_indexed with new DimSelection and DimSpec aliases, and parameterize the get_image_data and compute_dim_specs tests. finalize_dims test now verifies a real transpose. Co-Authored-By: Claude Opus 4.8 --- bioio_base/reader.py | 16 +++---- bioio_base/tests/test_reader.py | 29 +++++++------ bioio_base/tests/test_transforms.py | 66 ++++++++++++++++++++++------- bioio_base/transforms.py | 33 ++++++++------- bioio_base/types.py | 8 +++- 5 files changed, 97 insertions(+), 55 deletions(-) diff --git a/bioio_base/reader.py b/bioio_base/reader.py index d9b6b9c..d2545ec 100644 --- a/bioio_base/reader.py +++ b/bioio_base/reader.py @@ -727,23 +727,21 @@ def get_image_dask_data( **kwargs, ) - def _read_indexed(self, given_dims: str, dim_specs: list) -> np.ndarray: + def _read_indexed( + self, given_dims: str, dim_specs: List[types.DimSpec] + ) -> np.ndarray: """ Return the native-order array with ``dim_specs`` applied. - This is the seam that lets ``get_image_data`` read only the requested - sub-region. The default implementation materializes the full image and - slices it; readers that can read sub-regions cheaply should override it. - - The result MUST equal ``self.data[tuple(dim_specs)]`` — i.e. integer specs - drop their axis while slice/list specs keep theirs, and the remaining axes - stay in ``given_dims`` order. + This lets ``get_image_data`` read only the requested sub-region. + The default implementation materializes the full image and slices it; + readers that can read sub-regions cheaply should override it. Parameters ---------- given_dims: str The native dimension ordering of the image (``self.dims.order``). - dim_specs: list + dim_specs: List[types.DimSpec] One getitem operation per dimension in ``given_dims``, as produced by ``transforms.compute_dim_specs``. diff --git a/bioio_base/tests/test_reader.py b/bioio_base/tests/test_reader.py index cac00ad..c2085f9 100644 --- a/bioio_base/tests/test_reader.py +++ b/bioio_base/tests/test_reader.py @@ -1,24 +1,25 @@ -from __future__ import annotations +from typing import Any, Dict import numpy as np +import pytest from bioio_base import noop_reader, transforms -def test_get_image_data_matches_full_read_then_slice() -> None: - # NoopReader exposes mock data with dims TCZYX (shape 4, 5, 6, 7, 8). +# NoopReader exposes mock data with dims TCZYX (shape 4, 5, 6, 7, 8). +@pytest.mark.parametrize( + "order, kwargs", + [ + ("ZYX", {"T": 0, "C": 1}), + ("TCZYX", {"C": [0, 2], "Z": slice(0, 4, 2)}), + ], +) +def test_get_image_data_matches_full_read_then_slice( + order: str, kwargs: Dict[str, Any] +) -> None: reader = noop_reader.NoopReader("anything") - expected = transforms.reshape_data(reader.data, reader.dims.order, "ZYX", T=0, C=1) - actual = reader.get_image_data("ZYX", T=0, C=1) - np.testing.assert_array_equal(actual, expected) - - -def test_get_image_data_matches_full_read_then_slice_with_iterables() -> None: - reader = noop_reader.NoopReader("anything") - expected = transforms.reshape_data( - reader.data, reader.dims.order, "TCZYX", C=[0, 2], Z=slice(0, 4, 2) - ) - actual = reader.get_image_data("TCZYX", C=[0, 2], Z=slice(0, 4, 2)) + expected = transforms.reshape_data(reader.data, reader.dims.order, order, **kwargs) + actual = reader.get_image_data(order, **kwargs) np.testing.assert_array_equal(actual, expected) diff --git a/bioio_base/tests/test_transforms.py b/bioio_base/tests/test_transforms.py index 1a9abcb..256c1bb 100644 --- a/bioio_base/tests/test_transforms.py +++ b/bioio_base/tests/test_transforms.py @@ -522,29 +522,63 @@ def test_convert_list_to_slice( assert transforms.reduce_to_slice(list_to_test) == expected -def test_compute_dim_specs_fixed_integer_drops_axis() -> None: - specs, new_dims = transforms.compute_dim_specs((10, 100, 100), "ZYX", "YX", Z=1) - # Z is fixed and not in return_dims -> integer spec, axis dropped from new_dims - assert specs == [1, slice(None, None, None), slice(None, None, None)] - assert new_dims == "YX" +# slice(None, None, None) is the "select everything" spec for an untouched axis. +_ALL = slice(None, None, None) -def test_compute_dim_specs_slice_kept() -> None: +@pytest.mark.parametrize( + "shape, given_dims, return_dims, kwargs, expected_specs, expected_new_dims", + [ + # Fixed integer not in return_dims -> bare-int spec, axis dropped. + ((10, 100, 100), "ZYX", "YX", {"Z": 1}, [1, _ALL, _ALL], "YX"), + # Slice in return_dims is kept as-is, axis stays. + ( + (10, 100, 100), + "ZYX", + "ZYX", + {"Z": slice(0, 4, 2)}, + [slice(0, 4, 2), _ALL, _ALL], + "ZYX", + ), + # Evenly-spaced list collapses to an equivalent slice. + ( + (6, 100, 100), + "CYX", + "CYX", + {"C": [0, 2, 4]}, + [slice(0, 5, 2), _ALL, _ALL], + "CYX", + ), + # Unevenly-spaced list cannot reduce, so it is kept as a list. + ((6, 100, 100), "CYX", "CYX", {"C": [0, 3, 1]}, [[0, 3, 1], _ALL, _ALL], "CYX"), + ], + ids=[ + "fixed_integer_drops_axis", + "slice_kept", + "list_reduced_to_slice", + "list_kept", + ], +) +def test_compute_dim_specs( + shape: Tuple[int, ...], + given_dims: str, + return_dims: str, + kwargs: Mapping[str, Any], + expected_specs: List[Any], + expected_new_dims: str, +) -> None: specs, new_dims = transforms.compute_dim_specs( - (10, 100, 100), "ZYX", "ZYX", Z=slice(0, 4, 2) + shape, given_dims, return_dims, **kwargs ) - assert specs == [ - slice(0, 4, 2), - slice(None, None, None), - slice(None, None, None), - ] - assert new_dims == "ZYX" + assert specs == expected_specs + assert new_dims == expected_new_dims def test_finalize_dims_pads_and_transposes() -> None: - data = np.zeros((100, 100)) # YX after indexing - out = transforms.finalize_dims(data, "YX", "YX", "TYX") - assert out.shape == (1, 100, 100) + data = np.arange(12).reshape(3, 4) # YX after indexing + out = transforms.finalize_dims(data, "YX", "YX", "TXY") + assert out.shape == (1, 4, 3) + np.testing.assert_array_equal(out[0], data.T) def test_reshape_data_still_matches_helpers() -> None: diff --git a/bioio_base/transforms.py b/bioio_base/transforms.py index d42d3b8..586a72a 100644 --- a/bioio_base/transforms.py +++ b/bioio_base/transforms.py @@ -1,10 +1,9 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from __future__ import annotations from collections import Counter from numbers import Integral -from typing import Any, List, Literal, Optional, Tuple, Union +from typing import List, Literal, Optional, Tuple, Union, cast import dask.array as da import numpy as np @@ -83,8 +82,11 @@ def transpose_to_dims( def compute_dim_specs( - shape: Tuple[int, ...], given_dims: str, return_dims: str, **kwargs: Any -) -> Tuple[List[Any], str]: + shape: Tuple[int, ...], + given_dims: str, + return_dims: str, + **kwargs: types.DimSelection, +) -> Tuple[List[types.DimSpec], str]: """ Compute the per-dimension getitem spec and the resulting dim string. @@ -92,24 +94,20 @@ def compute_dim_specs( selection into a list of getitem operations (one per dimension in ``given_dims``) plus the dimension string that results after the getitem. - Integer specs (a dimension fixed to one index and NOT present in - ``return_dims``) drop their axis under numpy/dask getitem; slice/list specs - keep their axis. Reading no pixels, this can be computed from ``shape`` alone. - Parameters ---------- shape: Tuple[int, ...] The shape of the data the specs will be applied to. given_dims: str - The dimension ordering of the data, "CZYX", "VBTCXZY" etc. + The dimension ordering of the data, "CZYX", "MSTCZYX" etc. return_dims: str The dimension ordering of the return data. - kwargs: Any + kwargs: types.DimSelection Per-dimension selections (see ``reshape_data``). Returns ------- - dim_specs: List[Any] + dim_specs: List[types.DimSpec] One getitem operation per dimension in ``given_dims``. new_dims: str The dimension ordering after applying ``dim_specs`` (integer-selected, @@ -133,7 +131,7 @@ def compute_dim_specs( # Process each dimension available new_dims = given_dims - dim_specs: List[Any] = [] + dim_specs: List[types.DimSpec] = [] for dim in given_dims: # Store index of the dim as it is in given data dim_index = given_dims.index(dim) @@ -204,8 +202,10 @@ def compute_dim_specs( f"but Dimension shape is {shape[dim_index]}." ) - # All checks and operations passed, append dim operation to getitem ops - dim_specs.append(dim_spec) + # All checks and operations passed, append dim operation to getitem ops. + # The branches above narrow dim_spec to an int, slice, or List[int], but + # that can't be proven statically through the reassignments, so cast. + dim_specs.append(cast(types.DimSpec, dim_spec)) return dim_specs, new_dims @@ -252,7 +252,10 @@ def finalize_dims( def reshape_data( - data: types.ArrayLike, given_dims: str, return_dims: str, **kwargs: Any + data: types.ArrayLike, + given_dims: str, + return_dims: str, + **kwargs: types.DimSelection, ) -> types.ArrayLike: """ Reshape the data into return_dims, pad missing dimensions, and prune extra diff --git a/bioio_base/types.py b/bioio_base/types.py index c727036..93bbdfe 100644 --- a/bioio_base/types.py +++ b/bioio_base/types.py @@ -3,7 +3,7 @@ from datetime import timedelta from pathlib import Path -from typing import List, NamedTuple, Optional, TypeAlias, Union +from typing import List, NamedTuple, Optional, Tuple, TypeAlias, Union import dask.array as da import numpy as np @@ -26,6 +26,12 @@ List[PathLike], ] +# A per-dimension selection (e.g. C=1, C=[0, 2], Z=slice(0, 4, 2)). +DimSelection: TypeAlias = Union[int, List[int], Tuple[int, ...], range, slice] + +# A single getitem op applied to one dimension, as produced by compute_dim_specs. +DimSpec: TypeAlias = Union[int, slice, List[int]] + # Public type aliases for units UnitRegistry: TypeAlias = pint.UnitRegistry Unit: TypeAlias = pint.Unit From 14ca56a6aae415f3ef2435ef1dcf0a7eca68d497 Mon Sep 17 00:00:00 2001 From: Brian Whitney Date: Thu, 18 Jun 2026 13:09:51 -0700 Subject: [PATCH 5/6] remove unnecessary comment --- bioio_base/transforms.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bioio_base/transforms.py b/bioio_base/transforms.py index 586a72a..ab8737f 100644 --- a/bioio_base/transforms.py +++ b/bioio_base/transforms.py @@ -203,8 +203,6 @@ def compute_dim_specs( ) # All checks and operations passed, append dim operation to getitem ops. - # The branches above narrow dim_spec to an int, slice, or List[int], but - # that can't be proven statically through the reassignments, so cast. dim_specs.append(cast(types.DimSpec, dim_spec)) return dim_specs, new_dims From 085e2f082b1085b075e6a29aa58a2625f0796b86 Mon Sep 17 00:00:00 2001 From: Brian Whitney Date: Wed, 24 Jun 2026 08:57:18 -0700 Subject: [PATCH 6/6] docs(reader): use Dan's wording for _read_indexed override note Co-Authored-By: Claude Opus 4.8 --- bioio_base/reader.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bioio_base/reader.py b/bioio_base/reader.py index d2545ec..a1a08da 100644 --- a/bioio_base/reader.py +++ b/bioio_base/reader.py @@ -733,9 +733,10 @@ def _read_indexed( """ Return the native-order array with ``dim_specs`` applied. - This lets ``get_image_data`` read only the requested sub-region. - The default implementation materializes the full image and slices it; - readers that can read sub-regions cheaply should override it. + The default behavior here is to call ``self.data`` which can cause a + delayed-load of the whole image. Readers should override this function + in order to do more optimal finegrained data reads based on the + ``dim_specs``. Parameters ----------