Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/zarr_arr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
12 changes: 9 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions src/ndv/cli.py
Original file line number Diff line number Diff line change
@@ -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))
152 changes: 152 additions & 0 deletions src/ndv/io.py
Original file line number Diff line number Diff line change
@@ -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.")
2 changes: 1 addition & 1 deletion src/ndv/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading