Skip to content

Commit

Permalink
Inheriting BaseFOV on ClearControlFOV (#148)
Browse files Browse the repository at this point in the history
* inheriting basefov on ccfov

* add deprecation warning to ccfov.scale

* activating github pr workflow

* Update NDTIFF reader (#145)

use new ndtiff and stop sorting axes

* add ccfov scales tests

* Release timing requirement for I/O-heavy test (#147)

* release timing requirement for I/O-heavy test

* suppress data size check for arrays

---------

Co-authored-by: Ziwen Liu <67518483+ziw-liu@users.noreply.github.com>
  • Loading branch information
JoOkuma and ziw-liu authored Jun 21, 2023
1 parent aabfd2f commit d40594e
Show file tree
Hide file tree
Showing 7 changed files with 86 additions and 43 deletions.
1 change: 1 addition & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
pull_request:
branches:
- main
- unified-api

jobs:
style:
Expand Down
50 changes: 40 additions & 10 deletions iohub/clearcontrol.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import numpy as np
import pandas as pd

from iohub.fov import BaseFOV

if TYPE_CHECKING:
from _typeshed import StrOrBytesPath

Expand Down Expand Up @@ -128,7 +130,7 @@ def _key_cache_wrapper(
return _key_cache_wrapper


class ClearControlFOV:
class ClearControlFOV(BaseFOV):
"""
Reader class for Clear Control dataset
https://github.com/royerlab/opensimview.
Expand Down Expand Up @@ -157,15 +159,19 @@ def __init__(
cache: bool = False,
):
super().__init__()
self._data_path = Path(data_path)
self._root = Path(data_path)
self._missing_value = missing_value
self._dtype = np.uint16
self._cache = cache
self._cache_key = None
self._cache_array = None

@property
def shape(self) -> tuple[int, ...]:
def root(self) -> Path:
return self._root

@property
def shape(self) -> tuple[int, int, int, int, int]:
"""
Reads Clear Control index data of every data and returns
the element-wise minimum shape.
Expand All @@ -177,7 +183,7 @@ def shape(self) -> tuple[int, ...]:
minimum_size = 64
numbers = re.compile(r"\d+\.\d+|\d+")

for index_filepath in self._data_path.glob("*.index.txt"):
for index_filepath in self._root.glob("*.index.txt"):
with open(index_filepath, "rb") as f:
if index_filepath.stat().st_size > minimum_size:
f.seek(
Expand All @@ -195,19 +201,23 @@ def shape(self) -> tuple[int, ...]:

shape = [min(s, v) for s, v in zip(shape, values)]

shape.insert(1, len(self.channels))
shape.insert(1, len(self.channel_names))
shape[0] += 1 # time points starts counts on zero

return tuple(shape)

@property
def channels(self) -> list[str]:
def axes_names(self) -> list[str]:
return ["T", "C", "Z", "Y", "X"]

@property
def channel_names(self) -> list[str]:
"""Return sorted channels name."""
suffix = ".index.txt"
return sorted(
[
p.name.removesuffix(suffix)
for p in self._data_path.glob(f"*{suffix}")
for p in self._root.glob(f"*{suffix}")
]
)

Expand Down Expand Up @@ -243,7 +253,7 @@ def _read_volume(
# single channel
if isinstance(channels, str):
volume_name = f"{str(time_point).zfill(6)}.blc"
volume_path = self._data_path / "stacks" / channels / volume_name
volume_path = self._root / "stacks" / channels / volume_name
if not volume_path.exists():
if self._missing_value is None:
raise ValueError(f"{volume_path} not found.")
Expand Down Expand Up @@ -323,7 +333,7 @@ def _load_array(
) -> np.ndarray:
# these are properties are loaded to avoid multiple reads per call
shape = self.shape
channels = np.asarray(self.channels)
channels = np.asarray(self.channel_names)
time_pts = list(range(shape[0]))
volume_shape = shape[-3:]

Expand Down Expand Up @@ -391,7 +401,7 @@ def cache(self, value: bool) -> None:
def metadata(self) -> dict[str, Any]:
"""Summarizes Clear Control metadata into a dictionary."""
cc_metadata = []
for path in self._data_path.glob("*.metadata.txt"):
for path in self._root.glob("*.metadata.txt"):
with open(path, mode="r") as f:
channel_metadata = pd.DataFrame(
[json.loads(s) for s in f.readlines()]
Expand All @@ -418,6 +428,10 @@ def metadata(self) -> dict[str, Any]:
@property
def scale(self) -> list[float]:
"""Dataset temporal, channel and spacial scales."""
warnings.warn(
".scale will be deprecated use .zyx_scale or .t_scale.",
category=DeprecationWarning,
)
metadata = self.metadata()
return [
metadata["time_delta"],
Expand All @@ -427,6 +441,22 @@ def scale(self) -> list[float]:
metadata["voxel_size_x"],
]

@property
def zyx_scale(self) -> tuple[float, float, float]:
"""Helper function for FOV spatial scale (micrometer)."""
metadata = self.metadata()
return (
metadata["voxel_size_z"],
metadata["voxel_size_y"],
metadata["voxel_size_x"],
)

@property
def t_scale(self) -> float:
"""Helper function for FOV time scale (seconds)."""
metadata = self.metadata()
return metadata["time_delta"]


def create_mock_clear_control_dataset(path: "StrOrBytesPath") -> None:
"""
Expand Down
2 changes: 1 addition & 1 deletion iohub/fov.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def channel_names(self) -> list[str]:

def channel_index(self, key: str) -> int:
"""Return index of given channel."""
return self.channels.index(key)
return self.channel_names.index(key)

def _missing_axes(self) -> list[int]:
"""Return sorted indices of missing axes."""
Expand Down
27 changes: 8 additions & 19 deletions iohub/ndtiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,32 +140,21 @@ def get_zarr(self, position: int) -> zarr.array:
# TODO: try casting the dask array into a zarr array
# using `dask.array.to_zarr()`.
# Currently this call brings the data into memory

ax = [
ax_
for ax_ in ["position", "time", "channel", "z"]
if ax_ in self._axes
]

if "position" in self._axes.keys():
# da is Dask array
da = self.dataset.as_array(axes=ax, position=position)
else:
if position not in (0, None):
warnings.warn(
f"Position index {position} is not part of this dataset."
f" Returning data at default position."
)
da = self.dataset.as_array(axes=ax)

if "position" not in self._axes.keys() and position not in (0, None):
warnings.warn(
f"Position index {position} is not part of this dataset. "
"Returning data at the default position."
)
position = None
da = self.dataset.as_array(position=position)
shape = (
self.frames,
self.channels,
self.slices,
self.height,
self.width,
)

# add singleton axes so output is 5D
return da.reshape(shape)

def get_array(self, position: int) -> np.ndarray:
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ install_requires =
pydantic>=1.10.2
tifffile>=2023.2.3, <2023.3.15
natsort>=7.1.1
ndtiff>=1.9.0
ndtiff>=2.1.0
zarr>=2.13
tqdm
pillow>=9.4.0
Expand Down
40 changes: 29 additions & 11 deletions tests/clearcontrol/test_clearcontrol.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,27 @@ def mock_clear_control_dataset_path(tmp_path: Path) -> Path:


def test_blosc_buffer(tmp_path: Path) -> None:

buffer_path = tmp_path / "buffer.blc"
in_array = np.random.randint(0, 5_000, size=(32, 32))

_array_to_blosc_buffer(in_array, buffer_path)
out_array = blosc_buffer_to_array(buffer_path, in_array.shape, in_array.dtype)
out_array = blosc_buffer_to_array(
buffer_path, in_array.shape, in_array.dtype
)

assert np.allclose(in_array, out_array)


@pytest.mark.parametrize(
"key",
[1,
(slice(None), 1),
(0, [1, 2]),
(-1, np.asarray([0, 3])),
(slice(1), -2),
(np.asarray(0),),
(0, 0, slice(32)),
[
1,
(slice(None), 1),
(0, [1, 2]),
(-1, np.asarray([0, 3])),
(slice(1), -2),
(np.asarray(0),),
(0, 0, slice(32)),
],
)
def test_CCFOV_indexing(
Expand All @@ -58,11 +60,27 @@ def test_CCFOV_metadata(
mock_clear_control_dataset_path: Path,
) -> None:
cc = ClearControlFOV(mock_clear_control_dataset_path)
expected_metadata = {"voxel_size_z": 1.0, "voxel_size_y": 0.25, "voxel_size_x": 0.25, "acquisition_type": "NA", "time_delta": 45.0}
expected_metadata = {
"voxel_size_z": 1.0,
"voxel_size_y": 0.25,
"voxel_size_x": 0.25,
"acquisition_type": "NA",
"time_delta": 45.0,
}
metadata = cc.metadata()
assert metadata == expected_metadata


def test_CCFOV_scales(
mock_clear_control_dataset_path: Path,
) -> None:
cc = ClearControlFOV(mock_clear_control_dataset_path)
zyx_scale = (1.0, 0.25, 0.25)
time_delta = 45.0
assert zyx_scale == cc.zyx_scale
assert time_delta == cc.t_scale


def test_CCFOV_cache(
mock_clear_control_dataset_path: Path,
) -> None:
Expand All @@ -79,7 +97,7 @@ def test_CCFOV_cache(
assert np.array_equal(cc._cache_array, array)

new_array = cc[1]
assert id(new_array) == id(array) # same reference, so cache worked
assert id(new_array) == id(array) # same reference, so cache worked

cc.cache = False
assert cc._cache_key is None
Expand Down
7 changes: 6 additions & 1 deletion tests/ngff/test_ngff.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ def test_write_ome_zarr(channels_and_random_5d, arr_name):
ch_shape_dtype=_channels_and_random_5d_shape_and_dtype(),
arr_name=short_alpha_numeric,
)
@settings(max_examples=16, deadline=2000)
@settings(
max_examples=16,
deadline=2000,
suppress_health_check=[HealthCheck.data_too_large],
)
def test_create_zeros(ch_shape_dtype, arr_name):
"""Test `iohub.ngff.Position.create_zeros()`"""
channel_names, shape, dtype = ch_shape_dtype
Expand Down Expand Up @@ -556,6 +560,7 @@ def test_get_channel_index(setup_test_data, setup_hcs_ref, wrong_channel_name):
@given(
row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric
)
@settings(max_examples=16, deadline=2000)
def test_modify_hcs_ref(setup_test_data, setup_hcs_ref, row, col, pos):
"""Test `iohub.ngff.open_ome_zarr()`"""
with _temp_copy(setup_hcs_ref) as store_path:
Expand Down

0 comments on commit d40594e

Please sign in to comment.