diff --git a/examples/zarr_arr.py b/examples/zarr_arr.py index e3a759b..393f4d5 100644 --- a/examples/zarr_arr.py +++ b/examples/zarr_arr.py @@ -8,7 +8,7 @@ except ImportError: raise ImportError("Please `pip install zarr aiohttp` to run this example") - +# url = "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0062A/6001240.zarr" URL = "https://s3.embl.de/i2k-2020/ngff-example-data/v0.4/tczyx.ome.zarr" zarr_arr = zarr.open(URL, mode="r") diff --git a/pyproject.toml b/pyproject.toml index 3dea28d..e85d773 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,12 +35,21 @@ classifiers = [ ] dependencies = ["qtpy", "numpy", "superqt[cmap,iconify]"] +[project.scripts] +ndv = "ndv.cli:main" + # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] pyqt = ["pyqt6"] vispy = ["vispy", "pyopengl"] pyside = ["pyside6"] pygfx = ["pygfx"] +io = [ + "aicsimageio[all]", + "bioformats_jar", + "readlif", + "aicspylibczi", +] third_party_arrays = [ "aiohttp", # for zarr example "jax[cpu]", diff --git a/src/ndv/cli.py b/src/ndv/cli.py new file mode 100644 index 0000000..e099187 --- /dev/null +++ b/src/ndv/cli.py @@ -0,0 +1,20 @@ +"""command-line program.""" + +import argparse + +from ndv.util import imshow + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ndv: ndarray viewer") + parser.add_argument("path", type=str, help="The filename of the numpy file to view") + return parser.parse_args() + + +def main() -> None: + """Run the command-line program.""" + from ndv import io + + args = _parse_args() + + imshow(io.imread(args.path)) diff --git a/src/ndv/io.py b/src/ndv/io.py new file mode 100644 index 0000000..ae99cb5 --- /dev/null +++ b/src/ndv/io.py @@ -0,0 +1,136 @@ +"""All the io we can think of.""" + +from __future__ import annotations + +import json +from contextlib import suppress +from pathlib import Path +from textwrap import indent, wrap +from typing import TYPE_CHECKING, Any + +import numpy as np + +if TYPE_CHECKING: + import xarray as xr + import zarr + + +class collect_errors: + """Store exceptions in `errors` under `key`, rather than raising.""" + + def __init__(self, errors: dict, key: str): + self.errors = errors + self.key = key + + def __enter__(self) -> None: # noqa: D105 + pass + + def __exit__( # noqa: D105 + self, exc_type: type[BaseException], exc_value: BaseException, traceback: Any + ) -> bool: + if exc_type is not None: + self.errors[self.key] = exc_value + return True + + +def imread(path: str | Path) -> Any: + """Just read the thing already. + + Try to read `path` and return something that ndv can open. + """ + path_str = str(path) + if path_str.endswith(".npy"): + return np.load(path_str) + + errors: dict[str, Exception] = {} + + with collect_errors(errors, "aicsimageio"): + return _read_aicsimageio(path) + + if _is_zarr_folder(path): + with collect_errors(errors, "tensorstore-zarr"): + return _read_tensorstore(path) + with collect_errors(errors, "zarr"): + return _read_zarr_python(path) + + if _is_n5_folder(path): + with collect_errors(errors, "tensorstore-n5"): + return _read_tensorstore(path, driver="n5") + + raise ValueError(_format_error_message(errors)) + + +def _is_n5_folder(path: str | Path) -> bool: + path = Path(path) + return path.is_dir() and any(path.glob("attributes.json")) + + +def _is_zarr_folder(path: str | Path) -> bool: + if str(path).endswith(".zarr"): + return True + path = Path(path) + return path.is_dir() and any(path.glob("*.zarr")) + + +def _read_tensorstore(path: str | Path, driver: str = "zarr", level: int = 0) -> Any: + import tensorstore as ts + + sub = _array_path(path, level=level) + store = ts.open({"driver": driver, "kvstore": str(path) + sub}).result() + print("using tensorstore") + return store + + +def _format_error_message(errors: dict[str, Exception]) -> str: + lines = ["\nCould not read file. Here's what we tried and errors we got", ""] + for _key, err in errors.items(): + lines.append(f"{_key}:") + wrapped = wrap(str(err), width=120) + indented = indent("\n".join(wrapped), " ") + lines.append(indented) + return "\n".join(lines) + + +def _read_aicsimageio(path: str | Path) -> xr.DataArray: + from aicsimageio import AICSImage + + data = AICSImage(str(path)).xarray_dask_data + print("using aicsimageio") + return data + + +def _read_zarr_python(path: str | Path, level: int = 0) -> zarr.Array: + import zarr + + _subpath = _array_path(path, level=level) + z = zarr.open(str(path) + _subpath, mode="r") + print("using zarr python") + return z + + +def _array_path(path: str | Path, level: int = 0) -> str: + import zarr + + z = zarr.open(path, mode="r") + if isinstance(z, zarr.Array): + return "/" + if isinstance(z, zarr.Group): + with suppress(TypeError): + zattrs = json.loads(z.store.get(".zattrs")) + if "multiscales" in zattrs: + levels: list[str] = [] + for dset in zattrs["multiscales"][0]["datasets"]: + if "path" in dset: + levels.append(dset["path"]) + if levels: + return "/" + levels[level] + + arrays = list(z.array_keys()) + if arrays: + return f"/{arrays[0]}" + + if level != 0 and levels: + raise ValueError( + f"Could not find a dataset with level {level} in the group. Found: {levels}" + ) + raise ValueError("Could not find an array or multiscales information in the group.") diff --git a/src/ndv/util.py b/src/ndv/util.py index ac72d19..edd352e 100644 --- a/src/ndv/util.py +++ b/src/ndv/util.py @@ -3,8 +3,10 @@ from __future__ import annotations import sys +import warnings from typing import TYPE_CHECKING, Any, Literal +import numpy as np from qtpy.QtWidgets import QApplication from .viewer._viewer import NDViewer @@ -45,7 +47,24 @@ def imshow( cmap = [cmap] elif channel_mode == "auto": channel_mode = "mono" - viewer = NDViewer(data, colormaps=cmap, channel_mode=channel_mode) + channel_axis = None + shape = getattr(data, "shape", [None]) + if shape[-1] in (3, 4): + try: + has_alpha = shape[-1] == 4 + channel_mode = "composite" + cmap = ["red", "green", "blue"] + data = _transpose_color(data).squeeze() + if has_alpha: + data = data[:3, ...] + channel_axis = 0 + except Exception: + warnings.warn( + "Failed to interpret data as RGB(A), falling back to mono", stacklevel=2 + ) + viewer = NDViewer( + data, colormaps=cmap, channel_mode=channel_mode, channel_axis=channel_axis + ) viewer.show() viewer.raise_() if should_exec: @@ -53,6 +72,14 @@ def imshow( return viewer +def _transpose_color(data: Any) -> Any: + """Move the color axis to the front of the array.""" + if xr := sys.modules.get("xarray"): + if isinstance(data, xr.DataArray): + data = data.data + return np.moveaxis(data, -1, 0).squeeze() + + def _get_app() -> tuple[QCoreApplication, bool]: is_ipython = False if (app := QApplication.instance()) is None: diff --git a/src/ndv/viewer/_data_wrapper.py b/src/ndv/viewer/_data_wrapper.py index dd4983e..4eabcd9 100644 --- a/src/ndv/viewer/_data_wrapper.py +++ b/src/ndv/viewer/_data_wrapper.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging import sys from abc import abstractmethod @@ -97,9 +98,10 @@ def create(cls, data: ArrayT) -> DataWrapper[ArrayT]: # be automatically detected (assuming they have been imported by this point) for subclass in sorted(_recurse_subclasses(cls), key=lambda x: x.PRIORITY): with suppress(Exception): - if subclass.supports(data): - logging.debug(f"Using {subclass.__name__} to wrap {type(data)}") - return subclass(data) + if not subclass.supports(data): + continue + logging.debug(f"Using {subclass.__name__} to wrap {type(data)}") + return subclass(data) raise NotImplementedError(f"Don't know how to wrap type {type(data)}") def __init__(self, data: ArrayT) -> None: @@ -217,12 +219,9 @@ class TensorstoreWrapper(DataWrapper["ts.TensorStore"]): def __init__(self, data: Any) -> None: super().__init__(data) - import json import tensorstore as ts - self._ts = ts - spec = self.data.spec().to_json() labels: Sequence[Hashable] | None = None self._ts = ts diff --git a/src/ndv/viewer/_viewer.py b/src/ndv/viewer/_viewer.py index 1055972..4ffc90f 100755 --- a/src/ndv/viewer/_viewer.py +++ b/src/ndv/viewer/_viewer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections import defaultdict from itertools import cycle from typing import TYPE_CHECKING, Literal, cast @@ -274,9 +275,9 @@ def set_data( """ # store the data self._data_wrapper = DataWrapper.create(data) - # set channel axis - self._channel_axis = self._data_wrapper.guess_channel_axis() + if self._channel_axis is None: + self._channel_axis = self._data_wrapper.guess_channel_axis() # update the dimensions we are visualizing sizes = self._data_wrapper.sizes() @@ -474,6 +475,10 @@ def _on_data_slice_ready( if future.cancelled(): return + if exc := future.exception(): + logging.error(f"Error getting data: {exc}") + return + for idx, datum in future.result(): self._update_canvas_data(datum, idx) self._canvas.refresh()