diff --git a/examples/zarr_arr.py b/examples/zarr_arr.py index bee4eb09..0c6790a2 100644 --- a/examples/zarr_arr.py +++ b/examples/zarr_arr.py @@ -20,5 +20,4 @@ except ImportError: raise ImportError("Please `pip install zarr aiohttp` to run this example") - -ndv.imshow(zarr_arr["s4"], current_index={1: 30}, visible_axes=(0, 2)) +ndv.imshow(zarr_arr["s4"].astype("uint16"), current_index={1: 30}, visible_axes=(0, 2)) diff --git a/pyproject.toml b/pyproject.toml index dfce64d8..13c0057a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,9 @@ dependencies = [ "typing_extensions >= 4.0", ] +[project.scripts] +ndv = "ndv.cli:main" + # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] # Supported GUI frontends @@ -76,10 +79,13 @@ wxpython = ["pyconify>=0.2.1", "wxpython >=4.2.2"] vispy = ["vispy>=0.14.3", "pyopengl >=3.1"] pygfx = ["pygfx>=0.8.0"] +# opinionated image reading support +io = ["imageio[tifffile] >=2.20", "zarr >2,<3", "tensorstore>=0.1.69,!=0.1.72"] + # ready to go bundles with pygfx -qt = ["ndv[pygfx,pyqt]", "imageio[tifffile] >=2.20"] -jup = ["ndv[pygfx,jupyter]", "imageio[tifffile] >=2.20"] -wx = ["ndv[pygfx,wxpython]", "imageio[tifffile] >=2.20"] +qt = ["ndv[pygfx,pyqt,io]"] +jup = ["ndv[pygfx,jupyter,io]"] +wx = ["ndv[pygfx,wxpython,io]"] [project.urls] diff --git a/src/ndv/cli.py b/src/ndv/cli.py new file mode 100644 index 00000000..e099187d --- /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 00000000..7b8476e0 --- /dev/null +++ b/src/ndv/io.py @@ -0,0 +1,152 @@ +"""All the io we can think of.""" + +from __future__ import annotations + +import json +import logging +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 zarr + +logger = logging.getLogger(__name__) + + +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: + pass + + def __exit__( + 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] = {} + + if _is_tiff_file(path): + with collect_errors(errors, "tifffile"): + return _read_tifffile(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_tiff_file(path: str | Path) -> bool: + path = Path(path) + if path.is_file() and path.suffix.lower() in {".tif", ".tiff"}: + return True + return False + + +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_tifffile(path: str | Path) -> Any: + import tifffile + + path = Path(path) + if not path.is_file(): + raise ValueError(f"Path {path} is not a file.") + logger.info("using tifffile") + return tifffile.imread(path) + + +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() + logger.info("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) + msg = "\n".join(lines) + + if "No module named" in msg: + msg += "\n\nPlease install ndv with io support `pip install ndv[io]`." + return msg + + +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") + logger.info("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 "/" + levels: list[str] = [] + if isinstance(z, zarr.Group): + with suppress(TypeError): + zattrs = json.loads(z.store.get(".zattrs")) + if "multiscales" in zattrs: + 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 27edb8c6..6ea6911d 100644 --- a/src/ndv/util.py +++ b/src/ndv/util.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, overload +from typing import TYPE_CHECKING, Any, overload from ndv.controllers import ArrayViewer from ndv.views._app import run_app diff --git a/uv.lock b/uv.lock index 81746305..11d970d3 100644 --- a/uv.lock +++ b/uv.lock @@ -3145,6 +3145,14 @@ dependencies = [ ] [package.optional-dependencies] +io = [ + { name = "imageio", extra = ["tifffile"] }, + { name = "tensorstore", version = "0.1.69", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "tensorstore", version = "0.1.71", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "zarr", version = "2.18.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zarr", version = "2.18.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "zarr", version = "2.18.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] jup = [ { name = "glfw" }, { name = "imageio", extra = ["tifffile"] }, @@ -3152,6 +3160,11 @@ jup = [ { name = "jupyter" }, { name = "jupyter-rfb" }, { name = "pygfx" }, + { name = "tensorstore", version = "0.1.69", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "tensorstore", version = "0.1.71", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "zarr", version = "2.18.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zarr", version = "2.18.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "zarr", version = "2.18.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] jupyter = [ { name = "glfw" }, @@ -3180,6 +3193,11 @@ qt = [ { name = "pyqt6" }, { name = "qtpy" }, { name = "superqt", extra = ["iconify"] }, + { name = "tensorstore", version = "0.1.69", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "tensorstore", version = "0.1.71", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "zarr", version = "2.18.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zarr", version = "2.18.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "zarr", version = "2.18.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] vispy = [ { name = "pyopengl" }, @@ -3189,7 +3207,12 @@ wx = [ { name = "imageio", extra = ["tifffile"] }, { name = "pyconify" }, { name = "pygfx" }, + { name = "tensorstore", version = "0.1.69", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "tensorstore", version = "0.1.71", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "wxpython" }, + { name = "zarr", version = "2.18.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zarr", version = "2.18.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "zarr", version = "2.18.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wxpython = [ { name = "pyconify" }, @@ -3262,6 +3285,7 @@ requires-dist = [ { name = "cmap", specifier = ">=0.3" }, { name = "glfw", marker = "extra == 'jup'", specifier = ">=2.4" }, { name = "glfw", marker = "extra == 'jupyter'", specifier = ">=2.4" }, + { name = "imageio", extras = ["tifffile"], marker = "extra == 'io'", specifier = ">=2.20" }, { name = "imageio", extras = ["tifffile"], marker = "extra == 'jup'", specifier = ">=2.20" }, { name = "imageio", extras = ["tifffile"], marker = "extra == 'qt'", specifier = ">=2.20" }, { name = "imageio", extras = ["tifffile"], marker = "extra == 'wx'", specifier = ">=2.20" }, @@ -3296,12 +3320,20 @@ requires-dist = [ { name = "superqt", extras = ["iconify"], marker = "extra == 'pyqt'", specifier = ">=0.7.2" }, { name = "superqt", extras = ["iconify"], marker = "extra == 'qt'", specifier = ">=0.7.2" }, { name = "superqt", extras = ["iconify", "pyside6"], marker = "extra == 'pyside'", specifier = ">=0.7.2,<0.7.5" }, + { name = "tensorstore", marker = "extra == 'io'", specifier = ">=0.1.69,!=0.1.72" }, + { name = "tensorstore", marker = "extra == 'jup'", specifier = ">=0.1.69,!=0.1.72" }, + { name = "tensorstore", marker = "extra == 'qt'", specifier = ">=0.1.69,!=0.1.72" }, + { name = "tensorstore", marker = "extra == 'wx'", specifier = ">=0.1.69,!=0.1.72" }, { name = "typing-extensions", specifier = ">=4.0" }, { name = "vispy", marker = "extra == 'vispy'", specifier = ">=0.14.3" }, { name = "wxpython", marker = "extra == 'wx'", specifier = ">=4.2.2" }, { name = "wxpython", marker = "extra == 'wxpython'", specifier = ">=4.2.2" }, + { name = "zarr", marker = "extra == 'io'", specifier = ">2,<3" }, + { name = "zarr", marker = "extra == 'jup'", specifier = ">2,<3" }, + { name = "zarr", marker = "extra == 'qt'", specifier = ">2,<3" }, + { name = "zarr", marker = "extra == 'wx'", specifier = ">2,<3" }, ] -provides-extras = ["jup", "jupyter", "pygfx", "pyqt", "pyside", "qt", "vispy", "wx", "wxpython"] +provides-extras = ["io", "jup", "jupyter", "pygfx", "pyqt", "pyside", "qt", "vispy", "wx", "wxpython"] [package.metadata.requires-dev] array-libs = [