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))
28 changes: 24 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ dependencies = [
"ihist>=0.1.3",
]

[project.scripts]
ndv = "ndv.cli:main"

# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
[project.optional-dependencies]
# Supported GUI frontends
Expand All @@ -63,10 +66,27 @@ wxpython = ["pyconify>=0.2.1", "wxpython >=4.2.2"]
vispy = ["vispy>=0.16", "pyopengl >=3.1"]
pygfx = ["pygfx>=0.16.0", "rendercanvas>=2.6.2", "wgpu>=0.31.0"]

# 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"]
# Core IO (tiff + zarr, no Java)
io = [
"tifffile >=2024.1.30",
"xarray >=2024.7.0",
"yaozarrs >=0.1.0",
"tensorstore >=0.1.69,!=0.1.72",
]

# Format-specific readers
nd2 = ["nd2[legacy] >=0.10.0"]
lif = ["readlif >=0.6.0"]
czi = ["pylibCZIrw >=4.1.0"]
bioformats = ["bffile >=0.0.1rc1", "xarray >=2024.7.0"]

# Everything
io-all = ["ndv[io,nd2,lif,czi,bioformats]"]

# ready to go bundles with pygfx and io
qt = ["ndv[pygfx,pyqt,io-all]"]
jup = ["ndv[pygfx,jupyter,io-all]"]
wx = ["ndv[pygfx,wxpython,io-all]"]


[project.urls]
Expand Down
38 changes: 38 additions & 0 deletions src/ndv/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""command-line program."""

from __future__ import annotations

import argparse
from typing import Any

from ndv.util import imshow


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="ndv: ndarray viewer")
parser.add_argument("path", type=str, help="File to view")
parser.add_argument(
"-s", "--series", type=int, default=0, help="Series index (default: 0)"
)
parser.add_argument(
"-l", "--level", type=int, default=0, help="Resolution level (default: 0)"
)
return parser.parse_args()


def main() -> None:
"""Run the command-line program."""
from ndv import io

args = _parse_args()
data = io.imread(args.path, series=args.series, level=args.level)

display_kwargs: dict[str, Any] = {}
if ndv_display := data.attrs.pop("ndv_display", None):
if colors := ndv_display.get("channel_colors"):
display_kwargs["luts"] = {i: {"cmap": c} for i, c in colors.items()}

if "C" in data.dims and data.sizes["C"] > 1:
display_kwargs.setdefault("channel_mode", "composite")

imshow(data, **display_kwargs)
7 changes: 7 additions & 0 deletions src/ndv/io/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Opinionated bioimage IO — read common formats into xarray.DataArray."""

from __future__ import annotations

from ndv.io._dispatch import imread

__all__ = ["imread"]
74 changes: 74 additions & 0 deletions src/ndv/io/_bioformats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Bio-Formats fallback reader using bffile."""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from pathlib import Path

import ome_types
import xarray as xr

EXTENSIONS: set[str] = set() # fallback — handles anything


def can_read(path: Path) -> bool:
return True


def imread(path: Path, *, series: int = 0, level: int = 0) -> xr.DataArray:
"""Read a file using Bio-Formats via bffile."""
from bffile import BioFile

bf = BioFile(path)
bf.open()
da = bf.to_xarray(series=series, resolution=-1)

# Extract channel colors from OME metadata
ndv_display: dict[str, object] = {}
try:
ome = bf.ome_metadata
img = ome.images[series]
channels = img.pixels.channels
colors: dict[int, str] = {}
for i, ch in enumerate(channels):
if ch.color is not None:
cmap_name = _ome_color_to_cmap(ch.color)
if cmap_name:
colors[i] = cmap_name
if colors:
ndv_display["channel_colors"] = colors
except Exception:
pass

if ndv_display:
da.attrs["ndv_display"] = ndv_display

# Keep file handle alive
da.attrs["_biofile"] = bf
return da


def _ome_color_to_cmap(color: ome_types.model.Color) -> str | None:
"""Convert an ome_types Color to a cmap name."""
try:
r, g, b = color.as_rgb_tuple(alpha=False) # pyright: ignore[reportAssignmentType]
except AttributeError:
return None

if r > 200 and g < 50 and b < 50:
return "red"
if r < 50 and g > 200 and b < 50:
return "green"
if r < 50 and g < 50 and b > 200:
return "blue"
if r > 200 and g > 200 and b < 50:
return "yellow"
if r > 200 and g < 50 and b > 200:
return "magenta"
if r < 50 and g > 200 and b > 200:
return "cyan"
if r > 200 and g > 200 and b > 200:
return "gray"
return None
180 changes: 180 additions & 0 deletions src/ndv/io/_czi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""CZI reader using pylibCZIrw."""

from __future__ import annotations

import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from pathlib import Path

import xarray as xr

EXTENSIONS: set[str] = {".czi"}


def can_read(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in EXTENSIONS


def imread(path: Path, *, series: int = 0, level: int = 0) -> xr.DataArray:
"""Read a CZI file."""
import numpy as np
import pylibCZIrw.czi as pyczi
import xarray as xr

with pyczi.open_czi(str(path)) as czidoc:
bb = czidoc.total_bounding_box
scenes = czidoc.scenes_bounding_rectangle
meta = ET.fromstring(czidoc.raw_metadata)

# Determine ROI from scene or full bounding box
if scenes and series in scenes:
rect = scenes[series]
roi = (rect.x, rect.y, rect.w, rect.h)
else:
roi = (bb["X"][0], bb["Y"][0], bb["X"][1], bb["Y"][1])

n_t = bb["T"][1] - bb["T"][0]
n_z = bb["Z"][1] - bb["Z"][0]
n_c = bb["C"][1] - bb["C"][0]

# Read a sample plane to detect shape/dtype/RGB
# pylibCZIrw returns (Y, X, S) where S=1 for grayscale, 3/4 for RGB
sample = czidoc.read(roi=roi, plane={"T": 0, "Z": 0, "C": 0})
is_rgb = sample.ndim == 3 and sample.shape[-1] in (3, 4)
if not is_rgb and sample.ndim == 3 and sample.shape[-1] == 1:
sample = sample[..., 0]

if n_t <= 1 and n_z <= 1 and n_c <= 1:
data = sample
else:
# Read all planes and stack into (T, C, Z, Y, X)
planes = np.empty((n_t, n_c, n_z, *sample.shape), dtype=sample.dtype)
for t in range(n_t):
for c in range(n_c):
for z in range(n_z):
plane = czidoc.read(roi=roi, plane={"T": t, "Z": z, "C": c})
if not is_rgb and plane.shape[-1] == 1:
plane = plane[..., 0]
planes[t, c, z] = plane
data = planes

# Build dims — squeeze leading singletons, always keep Y, X
dims: list[str] = []
squeeze_axes: list[int] = []
if n_t <= 1 and n_z <= 1 and n_c <= 1:
# Simple image — no leading dims
dims = ["Y", "X"]
if is_rgb:
dims.append("S")
else:
for i, (label, n) in enumerate([("T", n_t), ("C", n_c), ("Z", n_z)]):
if n <= 1:
squeeze_axes.append(i)
dims.append(label)
dims.extend(["Y", "X"])
if is_rgb:
dims.append("S")
if squeeze_axes:
data = np.squeeze(data, axis=tuple(squeeze_axes))
dims = [d for i, d in enumerate(dims) if i not in squeeze_axes]

# Extract metadata
coords: dict[str, Any] = {}
ndv_display: dict[str, object] = {}
scales = _parse_scales(meta)
channels = _parse_channels(meta)

for dim_label in ("Z", "Y", "X"):
scale = scales.get(dim_label)
if scale is not None and dim_label in dims:
idx = dims.index(dim_label)
n = data.shape[idx]
coords[dim_label] = [i * scale for i in range(n)]

if channels and "C" in dims:
names = [ch["name"] for ch in channels]
coords["C"] = names
colors: dict[int, str] = {}
for i, ch in enumerate(channels):
if cmap := ch.get("cmap"):
colors[i] = cmap
if colors:
ndv_display["channel_colors"] = colors

attrs: dict[str, object] = {}
if ndv_display:
attrs["ndv_display"] = ndv_display

return xr.DataArray(data, dims=dims, coords=coords, attrs=attrs)


def _parse_scales(meta: ET.Element) -> dict[str, float]:
"""Extract physical pixel sizes from CZI XML metadata."""
scales: dict[str, float] = {}
for item in meta.iter("Distance"):
dim_id = item.get("Id")
val_el = item.find("Value")
if dim_id and val_el is not None and val_el.text:
try:
scales[dim_id] = float(val_el.text)
except ValueError:
pass
return scales


def _parse_channels(meta: ET.Element) -> list[dict[str, str]]:
"""Extract acquisition channel names and colors."""
channels: list[dict[str, str]] = []
for dims_el in meta.iter("Dimensions"):
ch_el = dims_el.find("Channels")
if ch_el is None:
continue
for ch in ch_el.findall("Channel"):
if ch.get("Id") is None:
continue
info: dict[str, str] = {}
name = ch.get("Name") or ch.findtext("Name") or ""
if name:
info["name"] = name
color = ch.findtext("Color")
if color:
cmap = _hex_color_to_cmap(color)
if cmap:
info["cmap"] = cmap
channels.append(info)
break
return channels


def _hex_color_to_cmap(color: str) -> str | None:
"""Convert #AARRGGBB hex color to a cmap name."""
color = color.lstrip("#")
if len(color) == 8:
# ARGB format
r = int(color[2:4], 16)
g = int(color[4:6], 16)
b = int(color[6:8], 16)
elif len(color) == 6:
r = int(color[0:2], 16)
g = int(color[2:4], 16)
b = int(color[4:6], 16)
else:
return None

if r > 200 and g < 50 and b < 50:
return "red"
if r < 50 and g > 200 and b < 50:
return "green"
if r < 50 and g < 50 and b > 200:
return "blue"
if r > 200 and g > 200 and b < 50:
return "yellow"
if r > 200 and g < 50 and b > 200:
return "magenta"
if r < 50 and g > 200 and b > 200:
return "cyan"
if r > 200 and g > 200 and b > 200:
return "gray"
return None
Loading
Loading