From dbe0c2c74274a9ce569a7de8039a1d872254cba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:34:30 +0000 Subject: [PATCH 1/3] smartspim: prefer SmartSPIM's stitched All_Channels volume Port of NDR-matlab b8f0e4b. ferret-lightsheet established that All_Channels/ holds SmartSPIM AutoStitch's TeraStitcher merge of the destriped tiles with flat-field / left-right correction and blended overlaps -- the better-stitched, harmonized data Imaris is built from. Format layer (new, bridged regular_port): * readAllChannelsInfo - index ___ch.tiff, map channels to ch via _MIP_stitched, else filter-channel rank; numeric z order; canvas size / class; voxel size from metadata.json. * hasAllChannels - non-throwing probe, usable only if every channel has slices. * readAllChannelsVolume - one channel as (z, y, x), optional ZRange. Reader epochs: [root] stitched volume, all channels on C (ch order) [root, channel] stitched volume, one channel [root, channel, tile] destriped Ex_* tile, unchanged Without a usable All_Channels, the first two keep the "does not pin a channel / tile" errors (the Ex_* tiles are not obsolete). imagecodecs joins the dev and formats extras: real All_Channels slices are LZW, and the new tests write LZW fixtures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U33UPxYJWPpcQimNu5t5Cb --- pyproject.toml | 5 + src/ndr/format/smartspim/__init__.py | 8 +- src/ndr/format/smartspim/hasAllChannels.py | 38 +++ .../smartspim/ndr_matlab_python_bridge.yaml | 46 ++++ .../format/smartspim/readAllChannelsInfo.py | 176 +++++++++++++ .../format/smartspim/readAllChannelsVolume.py | 91 +++++++ src/ndr/reader/ndr_matlab_python_bridge.yaml | 30 +-- src/ndr/reader/smartspim.py | 229 ++++++++++++----- tests/_smartspim_fixture.py | 31 ++- tests/test_smartspim_all_channels.py | 232 ++++++++++++++++++ 10 files changed, 804 insertions(+), 82 deletions(-) create mode 100644 src/ndr/format/smartspim/hasAllChannels.py create mode 100644 src/ndr/format/smartspim/readAllChannelsInfo.py create mode 100644 src/ndr/format/smartspim/readAllChannelsVolume.py create mode 100644 tests/test_smartspim_all_channels.py diff --git a/pyproject.toml b/pyproject.toml index a31e908..9ed8d69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ dev = [ # job from failing at collection. "pyyaml>=6.0", "tifffile>=2021.7.2", + # LZW codec for SmartSPIM All_Channels slices (tests write LZW TIFFs). + "imagecodecs>=2021.11.20", ] formats = [ # ndr.format.stereoseq.readGEF reads Stereo-seq .gef files, which are HDF5. @@ -37,6 +39,9 @@ formats = [ "neo>=0.12.0", "tdt>=0.5.0", "tifffile>=2021.7.2", + # SmartSPIM All_Channels slices are LZW-compressed; tifffile decodes + # LZW through imagecodecs. + "imagecodecs>=2021.11.20", "scipy>=1.7.0", # ndr.format.omezarr uses the zarr library so we do not need to # hand-roll chunk reading or Blosc/Zstd decoding. diff --git a/src/ndr/format/smartspim/__init__.py b/src/ndr/format/smartspim/__init__.py index e27a177..74da2cb 100644 --- a/src/ndr/format/smartspim/__init__.py +++ b/src/ndr/format/smartspim/__init__.py @@ -1,21 +1,27 @@ -"""LifeCanvas SmartSPIM raw-acquisition format reader. +"""LifeCanvas SmartSPIM acquisition format reader (tiles and All_Channels). Mirrors ``+ndr/+format/+smartspim/`` in NDR-matlab. """ +from ndr.format.smartspim.hasAllChannels import hasAllChannels from ndr.format.smartspim.isSmartSPIM import isSmartSPIM from ndr.format.smartspim.listChannels import listChannels from ndr.format.smartspim.listTiles import listTiles from ndr.format.smartspim.readAcquisitionMetadata import readAcquisitionMetadata +from ndr.format.smartspim.readAllChannelsInfo import readAllChannelsInfo +from ndr.format.smartspim.readAllChannelsVolume import readAllChannelsVolume from ndr.format.smartspim.readStitcherXml import readStitcherXml from ndr.format.smartspim.readTileInfo import readTileInfo from ndr.format.smartspim.readTileVolume import readTileVolume __all__ = [ + "hasAllChannels", "isSmartSPIM", "listChannels", "listTiles", "readAcquisitionMetadata", + "readAllChannelsInfo", + "readAllChannelsVolume", "readStitcherXml", "readTileInfo", "readTileVolume", diff --git a/src/ndr/format/smartspim/hasAllChannels.py b/src/ndr/format/smartspim/hasAllChannels.py new file mode 100644 index 0000000..83bf1cf --- /dev/null +++ b/src/ndr/format/smartspim/hasAllChannels.py @@ -0,0 +1,38 @@ +"""Non-throwing probe for a usable SmartSPIM ``All_Channels`` volume. + +Port of ``+ndr/+format/+smartspim/hasAllChannels.m``. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ndr.format.smartspim.readAllChannelsInfo import readAllChannelsInfo + +__all__ = ["hasAllChannels"] + + +def hasAllChannels(rootDir: Any) -> bool: + """Return True if ``rootDir/All_Channels`` is usable. + + True when ``All_Channels`` holds SmartSPIM's stitched, + flat-field-corrected slices for EVERY channel of the acquisition; + False otherwise (folder missing, no slices, a channel's ``_ch`` + slices missing, unreadable or non-path input). + + Only file names are inspected; no TIFF header is opened. Readers use + this to prefer the stitched ``All_Channels`` volume and to fall back + to the per-tile ``Ex_*`` layout for acquisitions where AutoStitch has + not been run (or ``All_Channels`` was not restored from the archive). + Takes any input (like the MATLAB probe) so it never raises. + """ + if not isinstance(rootDir, (str, Path)) or not str(rootDir): + return False + if not (Path(rootDir) / "All_Channels").is_dir(): + return False + try: + readAllChannelsInfo(str(rootDir), ReadHeader=False) + except Exception: + return False + return True diff --git a/src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml b/src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml index 9f12df6..2da6ea6 100644 --- a/src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml +++ b/src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml @@ -104,3 +104,49 @@ functions: slice locks the dtype, matching the MATLAB behavior. Stitching / cross-tile assembly is deliberately out of scope on both sides (issue #128). + + - name: hasAllChannels + matlab_path: "+ndr/+format/+smartspim/hasAllChannels.m" + status: regular_port + matlab_last_sync_hash: "b8f0e4b" + python_path: "ndr/format/smartspim/hasAllChannels.py" + decision_log: >- + Cheap non-throwing probe: TRUE iff rootDir/All_Channels holds + "*_ch.tif(f)" slices for every channel (file names only, via + readAllChannelsInfo with ReadHeader false). Like isSmartSPIM it + takes any input and returns FALSE rather than raising, so it has no + validate_call. Readers use it to prefer the stitched volume and + fall back to the per-tile Ex_* layout. + + - name: readAllChannelsInfo + matlab_path: "+ndr/+format/+smartspim/readAllChannelsInfo.m" + status: regular_port + matlab_last_sync_hash: "b8f0e4b" + python_path: "ndr/format/smartspim/readAllChannelsInfo.py" + decision_log: >- + Indexes SmartSPIM AutoStitch's stitched output + All_Channels/___ch.tiff (ferret-lightsheet + formats/SmartSPIM.md). Channel -> ch comes from the single + _ch suffix in _MIP_stitched, else the stable rank of + the filter-channel number (same rule as ferret-lightsheet's + SmartSPIMVolume.all_channels_ch_index). Slices sort by the numeric + z field. Field names match the MATLAB struct; channels is a list of + dicts, voxelSizeUm a list. ReadHeader is a keyword-only argument + (MATLAB name/value). MATLAB reads the header with imfinfo, Python + with tifffile; dtype is the MATLAB class name on both sides. Error + identifiers map to NotADirectoryError / FileNotFoundError (missing + folder, slices or channel) / ValueError (duplicate ch, bad + header). + + - name: readAllChannelsVolume + matlab_path: "+ndr/+format/+smartspim/readAllChannelsVolume.m" + status: regular_port + matlab_last_sync_hash: "b8f0e4b" + python_path: "ndr/format/smartspim/readAllChannelsVolume.py" + decision_log: >- + Reads one channel of the stitched All_Channels volume as (z, y, x), + the same layout as readTileVolume. ZRange is a 1-based inclusive + keyword-only argument (MATLAB name/value). MATLAB decodes the LZW + slices with imread; Python with tifffile, which needs the + imagecodecs package for LZW, so a decode error naming imagecodecs + gets an install hint appended. diff --git a/src/ndr/format/smartspim/readAllChannelsInfo.py b/src/ndr/format/smartspim/readAllChannelsInfo.py new file mode 100644 index 0000000..2cbf5c0 --- /dev/null +++ b/src/ndr/format/smartspim/readAllChannelsInfo.py @@ -0,0 +1,176 @@ +"""Describe SmartSPIM's stitched ``All_Channels`` volume. + +Port of ``+ndr/+format/+smartspim/readAllChannelsInfo.m``. + +``rootDir/All_Channels`` holds the output of the SmartSPIM AutoStitch +pipeline: a TeraStitcher merge of the destriped ``Ex_*`` tiles with the +per-channel ``_flat.tiff`` flat-field / left-right +illumination correction applied and the tile overlaps blended. It is the +volume Imaris is built from, and the one analysis should use +(ferret-lightsheet ``formats/SmartSPIM.md``, sections 4, 6 and 7). + +``All_Channels`` is a flat folder of 2-D TIFFs, one per (z, channel), +named ``___ch.tiff`` (e.g. +``675050_455000_000110_ch1.tiff``), plus an ``ImageList.txt`` for +Imaris. Every slice spans the whole stitched canvas (height = Y, +width = X), so there are no tiles. The ``ch`` index follows the +filter-channel number, NOT the acquisition order. It is taken from the +``_MIP_stitched`` folder, whose files carry the same ``_ch`` +suffix; when that folder is absent (e.g. a partial restore), ``k`` is +the rank of the channel's filter-channel number among all channels +(Ch1 -> ch0, Ch2 -> ch1, ...). +""" + +from __future__ import annotations + +import math +import re +from pathlib import Path +from typing import Any + +import numpy as np +from pydantic import validate_call + +from ndr.format.smartspim.listChannels import listChannels +from ndr.format.smartspim.readAcquisitionMetadata import readAcquisitionMetadata + +__all__ = ["readAllChannelsInfo"] + +# e.g. 675050_455000_000110_ch0.tiff -> (Y origin, X origin, z*10, k) +_SLICE_RE = re.compile(r"^(\d+)_(\d+)_(\d+)_ch(\d+)\.tiff?$", re.IGNORECASE) + +_MATLAB_CLASS = {"float32": "single", "float64": "double", "bool": "logical"} + + +def _tifffile(): + """Import tifffile lazily so it is only required when a TIFF is read.""" + try: + import tifffile + except ImportError as err: # pragma: no cover - exercised only without tifffile + raise ImportError( + "tifffile is required for reading SmartSPIM All_Channels slices. " + 'Install with: pip install "ndr[formats]"' + ) from err + return tifffile + + +def _index_slices(folder: Path) -> list[tuple[int, int, str]]: + """Return ``(k, z, path)`` for every ``*_ch.tif(f)`` file in ``folder``.""" + out = [] + for e in folder.iterdir(): + if e.is_dir(): + continue + m = _SLICE_RE.match(e.name) + if m: + out.append((int(m.group(4)), int(m.group(3)), str(folder / e.name))) + return out + + +def _channel_index(root: Path, channel_name: str, acq_channels: list[dict[str, Any]]) -> int: + # Preferred: the single _ch suffix used in _MIP_stitched. + mip_dir = root / f"{channel_name}_MIP_stitched" + if mip_dir.is_dir(): + ks = {k for k, _, _ in _index_slices(mip_dir)} + if len(ks) == 1: + return ks.pop() + # Fallback: rank of the filter-channel number (stable on ties). + ranked = sorted(acq_channels, key=lambda c: c["filterChannel"]) + return [c["name"] for c in ranked].index(channel_name) + + +@validate_call +def readAllChannelsInfo(rootDir: str, *, ReadHeader: bool = True) -> dict[str, Any]: + """Return a dict describing the stitched ``All_Channels`` volume. + + Fields: + + - ``allChannelsDir`` (str): path of ``rootDir/All_Channels`` + - ``channels`` (list[dict]): one entry per channel, sorted by + ``chIndex``, with ``name`` (channel directory name), ``chIndex`` + (float, the ``k`` of its ``_ch`` files), ``numSlices`` (float) + and ``slicePaths`` (list[str] sorted by the numeric z field) + - ``channelNames`` (list[str]): channel names in ``chIndex`` order + - ``height`` / ``width`` (float): slice size in pixels, NaN if not read + - ``dtype`` (str): MATLAB numeric class of the pixels, ``''`` if not read + - ``voxelSizeUm`` (list[float]): ``[z, y, x]`` in micrometers from + ``metadata.json``, NaNs if unavailable + + With ``ReadHeader=False`` the first slice's TIFF header is not opened + and ``height``/``width``/``dtype`` come back NaN/NaN/``''``; this is + what :func:`hasAllChannels` uses for its cheap probe. + + Raises :class:`NotADirectoryError` if ``rootDir`` is not a folder, + :class:`FileNotFoundError` if ``All_Channels`` is missing, holds no + ``*_ch.tif(f)`` slices or lacks the slices of a channel, and + :class:`ValueError` if two channels map to the same ``ch`` or the + first slice cannot be read. + """ + if not rootDir: + raise ValueError("rootDir must be a non-empty string.") + root = Path(rootDir) + if not root.is_dir(): + raise NotADirectoryError(f"Not a directory: {rootDir}") + + all_dir = root / "All_Channels" + if not all_dir.is_dir(): + raise FileNotFoundError(f"All_Channels directory not found: {all_dir}") + + index = _index_slices(all_dir) + if not index: + raise FileNotFoundError(f'No "*_ch.tif(f)" slices found in {all_dir}') + + acq_channels = listChannels(rootDir) + names = [c["name"] for c in acq_channels] + ch_indices = [_channel_index(root, n, acq_channels) for n in names] + if len(set(ch_indices)) != len(ch_indices): + raise ValueError(f"Two channels map to the same All_Channels _ch index in {rootDir}.") + + order = sorted(range(len(names)), key=lambda i: ch_indices[i]) + channels = [] + for i in order: + k = ch_indices[i] + sel = sorted((z, p) for kk, z, p in index if kk == k) + if not sel: + raise FileNotFoundError(f'No "_ch{k}" slices for {names[i]} in {all_dir}') + paths = [p for _, p in sel] + channels.append( + { + "name": names[i], + "chIndex": float(k), + "numSlices": float(len(paths)), + "slicePaths": paths, + } + ) + + height = math.nan + width = math.nan + dtype = "" + if ReadHeader: + first_path = channels[0]["slicePaths"][0] + tifffile = _tifffile() + try: + with tifffile.TiffFile(first_path) as tf: + page = tf.pages[0] + height = float(page.imagelength) + width = float(page.imagewidth) + np_dtype = np.dtype(page.dtype) + except Exception as err: + raise ValueError(f"Failed to read TIFF header of {first_path}: {err}") from err + dtype = _MATLAB_CLASS.get(np_dtype.name, np_dtype.name) + + voxel_size_um = [math.nan, math.nan, math.nan] + try: + voxel_size_um = [float(v) for v in readAcquisitionMetadata(rootDir)["voxelSizeUm"]] + except Exception: + # metadata.json missing or unreadable; leave voxel size unknown. + pass + + return { + "allChannelsDir": str(all_dir), + "channels": channels, + "channelNames": [c["name"] for c in channels], + "height": height, + "width": width, + "dtype": dtype, + "voxelSizeUm": voxel_size_um, + } diff --git a/src/ndr/format/smartspim/readAllChannelsVolume.py b/src/ndr/format/smartspim/readAllChannelsVolume.py new file mode 100644 index 0000000..b26bd7f --- /dev/null +++ b/src/ndr/format/smartspim/readAllChannelsVolume.py @@ -0,0 +1,91 @@ +"""Read one channel of SmartSPIM's stitched ``All_Channels`` volume (z, y, x). + +Port of ``+ndr/+format/+smartspim/readAllChannelsVolume.m``. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from pydantic import validate_call + +from ndr.format.smartspim.readAllChannelsInfo import _tifffile, readAllChannelsInfo + +__all__ = ["readAllChannelsVolume"] + +_NUMPY_DTYPE = {"single": "float32", "double": "float64", "logical": "bool"} + + +@validate_call(config={"arbitrary_types_allowed": True}) +def readAllChannelsVolume( + rootDir: str, + channelName: str, + *, + ZRange: Any = None, +) -> np.ndarray: + """Read the stitched z-slices of one channel into a ``(z, y, x)`` array. + + Same layout as :func:`readTileVolume`. By default all slices are + read; ``ZRange`` is a 1-based inclusive ``[start, stop]``. + ``channelName`` is the channel directory name (e.g. + ``"Ex_561_Em_561F_Ch2"``); the mapping to the ``_ch`` files is + resolved by :func:`readAllChannelsInfo`. + + Real All_Channels slices are LZW-compressed, which tifffile decodes + through the ``imagecodecs`` package. + + Raises :class:`ValueError` if the channel is unknown, ``ZRange`` is + invalid, or a slice fails to decode or differs in shape/class, and + the errors of :func:`readAllChannelsInfo` if ``All_Channels`` is not + usable. + """ + if not channelName: + raise ValueError("channelName must be a non-empty string.") + info = readAllChannelsInfo(rootDir) + if channelName not in info["channelNames"]: + raise ValueError( + f'Channel "{channelName}" not found in {rootDir}. ' + f"Available: {', '.join(info['channelNames'])}" + ) + ch = info["channels"][info["channelNames"].index(channelName)] + slice_paths = ch["slicePaths"] + n = int(ch["numSlices"]) + + if ZRange is None or np.asarray(ZRange).size == 0: + first_z, last_z = 1, n + else: + arr = np.asarray(ZRange).ravel() + if arr.size != 2 or not np.all(arr == np.floor(arr)): + raise ValueError("ZRange must be a two-element [START STOP] vector.") + first_z, last_z = int(arr[0]), int(arr[1]) + if first_z < 1 or last_z > n or first_z > last_z: + raise ValueError( + f"ZRange [{first_z} {last_z}] is not a valid 1-based " + f"inclusive range in [1, {n}]." + ) + + h = int(info["height"]) + w = int(info["width"]) + dt = np.dtype(_NUMPY_DTYPE.get(info["dtype"], info["dtype"])) + volume = np.zeros((last_z - first_z + 1, h, w), dtype=dt) + + tifffile = _tifffile() + for i, path_i in enumerate(slice_paths[first_z - 1 : last_z]): + try: + img = tifffile.imread(path_i) + except Exception as err: + hint = ( + " (LZW-compressed slices need the 'imagecodecs' package: " + "pip install imagecodecs)" + if "imagecodecs" in str(err) + else "" + ) + raise ValueError(f"Failed to decode {path_i}: {err}{hint}") from err + if img.shape[:2] != (h, w) or img.ndim != 2 or img.dtype != dt: + raise ValueError( + f"Slice {path_i} has shape {'x'.join(map(str, img.shape))} " + f"({img.dtype}); expected {h}x{w} ({dt})." + ) + volume[i, :, :] = img + return volume diff --git a/src/ndr/reader/ndr_matlab_python_bridge.yaml b/src/ndr/reader/ndr_matlab_python_bridge.yaml index 8738692..2230336 100644 --- a/src/ndr/reader/ndr_matlab_python_bridge.yaml +++ b/src/ndr/reader/ndr_matlab_python_bridge.yaml @@ -1375,23 +1375,25 @@ classes: matlab_path: "+ndr/+reader/smartspim.m" status: regular_port python_path: "ndr/reader/smartspim.py" - matlab_last_sync_hash: "34759c7" + matlab_last_sync_hash: "b8f0e4b" python_class: "ndr_reader_smartspim" inherits: base decision_log: >- - Ported 2026-09-06. Native SmartSPIM (LifeCanvas raw acquisition) - image reader; sibling of omezarr/tiffstack, implements the frame - API only. One (channel, tile) pair per epoch: stitching is - deliberately out of scope on both sides (issue #128). MATLAB uses - imfinfo/imread through ndr.format.smartspim.readTileVolume; the - Python port uses tifffile (an optional dependency, imported - lazily). - - Epochstream shape mirrors MATLAB's cell array: either a str path - (which errors with the channel list), or a list of - [rootDir, channelName] (errors with the tile list), or a list of - [rootDir, channelName, tileId] that pins the epoch, optionally - nested inside a larger list with sidecar entries. + Ported 2026-09-06; All_Channels support synced 2026-10-12. Native + SmartSPIM (LifeCanvas) image reader; sibling of omezarr/tiffstack, + implements the frame API only. The reader prefers SmartSPIM + AutoStitch's stitched, flat-field-corrected All_Channels volume: + [rootDir] reads it with every channel on the C axis (ordered by + ch), [rootDir, channelName] reads one channel of it. A list of + [rootDir, channelName, tileId] still reads one destriped Ex_* tile, + optionally nested inside a larger list with sidecar entries. + Without a usable All_Channels folder the first two forms keep + erroring with the channel / tile list. Stitching tiles itself is + out of scope on both sides (issue #128). MATLAB uses imfinfo/imread + through ndr.format.smartspim; the Python port uses tifffile (an + optional dependency, imported lazily; LZW slices need imagecodecs). + Error identifiers map to ValueError (not pinned, unknown channel, + slice-count mismatch), NotADirectoryError and IndexError. methods: - name: resolveepoch input_arguments: diff --git a/src/ndr/reader/smartspim.py b/src/ndr/reader/smartspim.py index 462e641..201c61a 100644 --- a/src/ndr/reader/smartspim.py +++ b/src/ndr/reader/smartspim.py @@ -1,30 +1,49 @@ -"""Reader class for LifeCanvas SmartSPIM raw acquisitions (one tile per epoch). +"""Reader class for LifeCanvas SmartSPIM acquisitions. Port of ``+ndr/+reader/smartspim.m``. -This class exposes one SmartSPIM tile through NDR's image (frame) API. -It is a native NDR image reader, sibling of :class:`ndr.reader.omezarr` +This class exposes a SmartSPIM acquisition through NDR's image (frame) +API. It is a native NDR image reader, sibling of :class:`ndr.reader.omezarr` and :class:`ndr.reader.tiffstack.ndr_reader_tiffstack`. -Epoch layout: a SmartSPIM epoch is ONE (channel, tile) pair inside a -SmartSPIM acquisition directory. The epochstream pins all three: -``[ROOTDIR, CHANNELNAME, TILEID]``. Sidecar files may accompany it -inside a larger list. Anything less specific errors with a list of what -could complete the pinning. +Image sources. A processed SmartSPIM acquisition holds two images of the +same brain (ferret-lightsheet ``formats/SmartSPIM.md``): -Stitching / cross-tile assembly is deliberately out of scope: this -reader returns ONE tile per epoch and reports it as-is. See issue #128. +- ``All_Channels/`` -- SmartSPIM AutoStitch's stitched volume: the + destriped tiles merged by TeraStitcher with flat-field and left/right + illumination correction and blended overlaps. This is the + better-stitched, harmonized data (and what Imaris shows), so the reader + PREFERS it whenever it is present for every channel. +- ``Ex_*/`` -- the destriped per-tile stacks, not flat-corrected and not + stitched. They stay readable tile by tile (they are the source of truth + and keep the destriper's unscaled intensities), and they are the only + image source of acquisitions without ``All_Channels``. + +The reader never stitches tiles itself: stitched data comes only from +SmartSPIM's own ``All_Channels`` output (issue #128). + +Epoch layout. The epochstream names what to read: + +- ``[ROOTDIR]`` -- stitched volume, every channel on the C axis +- ``[ROOTDIR, CHANNELNAME]`` -- stitched volume of one channel (C = 1) +- ``[ROOTDIR, CHANNELNAME, TILEID]`` -- one destriped tile (C = 1) + +Sidecar files may accompany it inside a larger list. Without a usable +``All_Channels`` folder the first two forms fall back to the per-tile +layout and error with a list of what could complete the pinning. Dimension model: -- T <- Z axis of the tile (each z-slice TIFF is a "frame") -- Y, X <- Y, X axes of the plane -- C = 1 (one channel per epoch; multi-channel data pins a different - epoch per channel) +- T <- Z axis of the volume (each z-slice TIFF is a "frame") +- Y, X <- Y, X axes of the plane (for ``All_Channels`` the whole + stitched canvas) +- C = number of channels in the epoch; for ``[ROOTDIR]`` they are + ordered by their ``_ch`` index and named in + ``resolveepoch(...)["channelNames"]`` - Z = 1 -Frames are returned in ``'YXCZT'`` order: size ``[Y X 1 1 nFrames]``. -Timing: SmartSPIM raw acquisitions do not carry per-slice timestamps. +Frames are returned in ``'YXCZT'`` order: size ``[Y X C 1 nFrames]``. +Timing: SmartSPIM acquisitions do not carry per-slice timestamps. ``epochclock`` is ``'no_time'`` and ``frametimes`` returns NaN. """ @@ -36,9 +55,12 @@ import numpy as np from ndr.format.smartspim import ( + hasAllChannels, isSmartSPIM, listChannels, listTiles, + readAllChannelsInfo, + readAllChannelsVolume, readTileInfo, readTileVolume, ) @@ -49,7 +71,7 @@ class ndr_reader_smartspim(ndr_reader_base): - """Reader for one SmartSPIM tile per epoch.""" + """Reader for SmartSPIM stitched volumes (All_Channels) and tiles.""" def __init__(self) -> None: super().__init__() @@ -59,12 +81,19 @@ def __init__(self) -> None: # ------------------------------------------------------------------ def resolveepoch(self, epochstreams: Any) -> dict[str, Any]: - """Resolve an epoch to ``(rootDir, channelName, tileId)`` plus tile info. - - Returns a dict with ``rootDir``, ``channelName``, ``tileId`` and - ``tile`` (the :func:`readTileInfo` result for the pinned tile). - Errors with a specific message listing what could complete the - pinning when a channel or tile is not pinned. + """Resolve an epoch to its image source. + + Returns a dict with ``rootDir``, ``channelName`` (``''`` for an + all-channel epoch), ``tileId`` (``''`` for a stitched epoch), + ``source`` (``'allChannels'`` or ``'tile'``), ``channelNames`` + (names along the C axis), ``numSlices``, ``height``, ``width``, + ``dtype``, ``tile`` (the :func:`readTileInfo` result for a tile + epoch, else None) and ``allChannels`` (the + :func:`readAllChannelsInfo` result for a stitched epoch, else None). + + ``[ROOTDIR]`` and ``[ROOTDIR, CHANNELNAME]`` read the stitched + ``All_Channels`` volume when it is usable; otherwise they error + with a specific message listing what could complete the pinning. """ root_dir, channel_name, tile_id = ndr_reader_smartspim.parseEpochstreams(epochstreams) @@ -73,12 +102,51 @@ def resolveepoch(self, epochstreams: Any) -> dict[str, Any]: if not Path(root_dir).is_dir(): raise NotADirectoryError(f"SmartSPIM root does not exist: {root_dir}") + info: dict[str, Any] = { + "rootDir": root_dir, + "channelName": channel_name, + "tileId": tile_id, + } + + if not tile_id and hasAllChannels(root_dir): + stitched = readAllChannelsInfo(root_dir) + if not channel_name: + sel = list(range(len(stitched["channels"]))) + counts = [int(c["numSlices"]) for c in stitched["channels"]] + if any(c != counts[0] for c in counts): + raise ValueError( + "All_Channels channels have different slice counts " + f"({' '.join(map(str, counts))}) in {root_dir}; pin one " + "channel with [rootDir, channelName]." + ) + else: + if channel_name not in stitched["channelNames"]: + raise ValueError( + f'Channel "{channel_name}" not found. Available in {root_dir}: ' + f"{ndr_reader_smartspim.formatNameList(stitched['channelNames'])}." + ) + sel = [stitched["channelNames"].index(channel_name)] + info.update( + { + "source": "allChannels", + "channelNames": [stitched["channelNames"][i] for i in sel], + "numSlices": stitched["channels"][sel[0]]["numSlices"], + "height": stitched["height"], + "width": stitched["width"], + "dtype": stitched["dtype"], + "tile": None, + "allChannels": stitched, + } + ) + return info + if not channel_name: channels = listChannels(root_dir) names = [c["name"] for c in channels] raise ValueError( - "Epochstream does not pin a channel. " - f"Available in {root_dir}: {ndr_reader_smartspim.formatNameList(names)}. " + f"Epochstream does not pin a channel and {root_dir} has no usable " + "All_Channels stitched volume. " + f"Available channels: {ndr_reader_smartspim.formatNameList(names)}. " "Pass [rootDir, channelName, tileId] as the epochstream." ) @@ -86,41 +154,55 @@ def resolveepoch(self, epochstreams: Any) -> dict[str, Any]: tiles = listTiles(root_dir, channel_name) ids = [t["id"] for t in tiles] raise ValueError( - f'Epochstream does not pin a tile for channel "{channel_name}". ' - f"Available in {root_dir}: {ndr_reader_smartspim.formatNameList(ids)}. " + f'Epochstream does not pin a tile for channel "{channel_name}" and ' + f"{root_dir} has no usable All_Channels stitched volume. " + f"Available tiles: {ndr_reader_smartspim.formatNameList(ids)}. " "Pass [rootDir, channelName, tileId] as the epochstream." ) - return { - "rootDir": root_dir, - "channelName": channel_name, - "tileId": tile_id, - "tile": readTileInfo(root_dir, channel_name, tile_id), - } + tile = readTileInfo(root_dir, channel_name, tile_id) + info.update( + { + "source": "tile", + "channelNames": [channel_name], + "numSlices": tile["numSlices"], + "height": tile["height"], + "width": tile["width"], + "dtype": tile["dtype"], + "tile": tile, + "allChannels": None, + } + ) + return info # ------------------------------------------------------------------ # Frame API # ------------------------------------------------------------------ def numframes(self, epochstreams: Any, epoch_select: int = 1) -> int: - """Return the number of frames (z-slices) in this tile.""" + """Return the number of frames (z-slices) in this epoch.""" info = self.resolveepoch(epochstreams) - return int(info["tile"]["numSlices"]) + return int(info["numSlices"]) def framesize(self, epochstreams: Any, epoch_select: int = 1) -> list[int]: - """Return the ``[Y X C Z T]`` extent of the tile.""" + """Return the ``[Y X C Z T]`` extent of the epoch.""" info = self.resolveepoch(epochstreams) - tile = info["tile"] - return [int(tile["height"]), int(tile["width"]), 1, 1, int(tile["numSlices"])] + return [ + int(info["height"]), + int(info["width"]), + len(info["channelNames"]), + 1, + int(info["numSlices"]), + ] def dimensionorder(self, epochstreams: Any, epoch_select: int = 1) -> str: """Return the dimension order of returned frames.""" return "YXCZT" def datatype(self, epochstreams: Any, epoch_select: int = 1) -> str: - """Return the underlying numeric class of the tile (MATLAB class name).""" + """Return the underlying numeric class of the pixels (MATLAB class name).""" info = self.resolveepoch(epochstreams) - return info["tile"]["dtype"] + return info["dtype"] def frametimes( self, @@ -143,23 +225,23 @@ def readframes( SelectC: list[int] | np.ndarray | None = None, SelectZ: list[int] | np.ndarray | None = None, ) -> np.ndarray: - """Read z-slices from the pinned tile. + """Read z-slices from the epoch. - Reads the z-slices indexed by ``frameind`` from the pinned tile - and returns them in ``'YXCZT'`` order: size - ``[Y X 1 1 numel(frameind)]``. + Returns them in ``'YXCZT'`` order: size + ``[Y X C 1 numel(frameind)]``, where C is the number of channels + in the epoch (see ``resolveepoch(...)["channelNames"]``). - Frames are read one at a time (the format layer's - :func:`readTileVolume` can read a contiguous range; for arbitrary - index lists this is a simple loop). Sparse random reads are fine; - large contiguous reads that want the extra efficiency can call - :func:`readTileVolume` with a ``ZRange`` directly. + Stitched epochs read each run of consecutive frame indices with + one :func:`readAllChannelsVolume` call per channel. Tile epochs + read frames one at a time through :func:`readTileVolume`; large + contiguous reads that want the extra efficiency can call the + format functions with a ``ZRange`` directly. """ info = self.resolveepoch(epochstreams) - tile = info["tile"] - Y = int(tile["height"]) - X = int(tile["width"]) - n = int(tile["numSlices"]) + Y = int(info["height"]) + X = int(info["width"]) + C = len(info["channelNames"]) + n = int(info["numSlices"]) if frameind is None or len(np.asarray(frameind).ravel()) == 0: frameind = list(range(1, n + 1)) @@ -170,16 +252,31 @@ def readframes( dt = self.datatype(epochstreams, epoch_select) np_dtype = np.dtype(_NUMPY_DTYPE.get(dt, dt)) - frames = np.zeros((Y, X, 1, 1, len(frameind)), dtype=np_dtype) - for i, z in enumerate(frameind): - vol = readTileVolume( - info["rootDir"], - info["channelName"], - info["tileId"], - ZRange=[z, z], - ) - # vol shape is (1, Y, X) - frames[:, :, 0, 0, i] = vol[0, :, :].astype(np_dtype) + frames = np.zeros((Y, X, C, 1, len(frameind)), dtype=np_dtype) + + if info["source"] == "allChannels": + i = 0 + while i < len(frameind): + j = i + while j + 1 < len(frameind) and frameind[j + 1] == frameind[j] + 1: + j += 1 + for c, name in enumerate(info["channelNames"]): + vol = readAllChannelsVolume( + info["rootDir"], name, ZRange=[frameind[i], frameind[j]] + ) + # vol shape is (n, Y, X) -> (Y, X, n) + frames[:, :, c, 0, i : j + 1] = np.moveaxis(vol, 0, -1).astype(np_dtype) + i = j + 1 + else: + for i, z in enumerate(frameind): + vol = readTileVolume( + info["rootDir"], + info["channelName"], + info["tileId"], + ZRange=[z, z], + ) + # vol shape is (1, Y, X) + frames[:, :, 0, 0, i] = vol[0, :, :].astype(np_dtype) return ndr_reader_base.selectframeCZ(frames, SelectC, SelectZ) def epochclock(self, epochstreams: Any, epoch_select: int = 1) -> list[ClockType]: @@ -193,9 +290,9 @@ def t0_t1(self, epochstreams: Any, epoch_select: int = 1) -> list[list[float]]: def getchannelsepoch(self, epochstreams: Any, epoch_select: int = 1) -> list[dict[str, Any]]: """List the channels available: a single image channel. - A SmartSPIM epoch pins a specific fluorophore channel via its - epochstream, so at the NDR-channel level the tile is - single-channel. + Matches :class:`ndr.reader.omezarr`: fluorophore channels of a + stitched all-channel epoch are returned together on the C axis of + :meth:`readframes` rather than as separate NDR channels. """ return [{"name": "image1", "type": "image", "time_channel": None}] diff --git a/tests/_smartspim_fixture.py b/tests/_smartspim_fixture.py index 5873713..142888f 100644 --- a/tests/_smartspim_fixture.py +++ b/tests/_smartspim_fixture.py @@ -8,6 +8,13 @@ channel folder holds ``xml_import.xml``, ``xml_merging.xml``, and one tile directory per stack; each tile directory holds a small stack of zero-padded 2D TIFF z-slices with pixel_value = 100*channelIndex + z. + +With ``all_channels=True`` it also writes an ``All_Channels`` folder +shaped like SmartSPIM AutoStitch's stitched output: one LZW-compressed +106x13 uint16 TIFF per (z, channel) named +``200000_100000__ch.tiff`` plus ``ImageList.txt``, with +pixel_value = 1000*(k + 1) + z. No ``_MIP_stitched`` folders are written, +so ``k`` follows the filter-channel rank (Ch2 -> ch0, Ch3 -> ch1). """ from __future__ import annotations @@ -173,7 +180,26 @@ def _sequence(channels: list[dict[str, Any]]) -> dict[str, Any]: return {"objective": "LCT 1.625x", "immersion": "1.52+", "steps": [step]} -def make_example_fixture(parent_dir: str | Path) -> Path: +_STITCHED_HEIGHT = 106 # xml_merging.xml mosaic of the two tiles: 98 + 8 rows +_STITCHED_WIDTH = 13 # and 3 + 10 columns + + +def _write_all_channels(fixture_dir: Path, num_channels: int) -> None: + import tifffile + + all_dir = fixture_dir / "All_Channels" + all_dir.mkdir() + names = [] + for k in range(num_channels): + for z in range(_NUM_SLICES): + img = np.full((_STITCHED_HEIGHT, _STITCHED_WIDTH), 1000 * (k + 1) + z, dtype=np.uint16) + name = f"200000_100000_{z * 10:06d}_ch{k}.tiff" + tifffile.imwrite(all_dir / name, img, compression="lzw") + names.append(name) + (all_dir / "ImageList.txt").write_text("\n".join(names)) + + +def make_example_fixture(parent_dir: str | Path, *, all_channels: bool = False) -> Path: """Write a synthetic SmartSPIM directory and return its root path. Pixel values encode ``(channelIndex, z)`` so tests can verify a read @@ -214,4 +240,7 @@ def make_example_fixture(parent_dir: str | Path) -> Path: (fixture_dir / "metadata.json").write_text(json.dumps(_metadata(_CHANNELS, _STACKS_IMPORT))) (fixture_dir / "sequence.json").write_text(json.dumps(_sequence(_CHANNELS))) + if all_channels: + _write_all_channels(fixture_dir, len(_CHANNELS)) + return fixture_dir diff --git a/tests/test_smartspim_all_channels.py b/tests/test_smartspim_all_channels.py new file mode 100644 index 0000000..79e4df8 --- /dev/null +++ b/tests/test_smartspim_all_channels.py @@ -0,0 +1,232 @@ +"""Tests for the SmartSPIM ``All_Channels`` (stitched volume) support. + +Python mirror of +``tools/tests/+ndr/+unittest/+format/+smartspim/TestSmartSPIMAllChannels.m`` +and +``tools/tests/+ndr/+unittest/+reader/+smartspim/TestSmartSPIMReaderAllChannels.m``. + +Fixture: the usual 2-channel, 2-tile acquisition plus an All_Channels +folder of 4 x 106x13 uint16 LZW slices per channel with pixel value +``1000 * (k + 1) + z`` for file ``_ch``, slice ``z`` (0-based). +Without ``_MIP_stitched`` folders, Ex_561_Em_561F_Ch2 -> ch0 and +Ex_640_Em_640F_Ch3 -> ch1 (filter-channel rank). +""" + +from __future__ import annotations + +import math +import shutil +from pathlib import Path + +import numpy as np +import pytest + +tifffile = pytest.importorskip("tifffile") +pytest.importorskip("imagecodecs") # LZW, as in real All_Channels slices + +from ndr.format.smartspim import ( # noqa: E402 + hasAllChannels, + readAllChannelsInfo, + readAllChannelsVolume, +) +from ndr.reader.smartspim import ndr_reader_smartspim # noqa: E402 +from tests._smartspim_fixture import make_example_fixture # noqa: E402 + +_CH561 = "Ex_561_Em_561F_Ch2" +_CH640 = "Ex_640_Em_640F_Ch3" +_TILE1 = "100000/100000_200000" + + +@pytest.fixture +def fixture_dir(tmp_path): + return make_example_fixture(tmp_path, all_channels=True) + + +def _all_dir(fixture_dir: Path) -> Path: + return fixture_dir / "All_Channels" + + +# ---------------------------------------------------------------------- +# Format layer +# ---------------------------------------------------------------------- + + +class TestHasAllChannels: + def test_true(self, fixture_dir): + assert hasAllChannels(str(fixture_dir)) is True + + def test_false_without_folder(self, fixture_dir): + shutil.rmtree(_all_dir(fixture_dir)) + assert hasAllChannels(str(fixture_dir)) is False + + def test_false_for_bad_input(self, tmp_path): + assert hasAllChannels(str(tmp_path / "nope")) is False + assert hasAllChannels(42) is False + + def test_false_when_channel_missing(self, fixture_dir): + for p in _all_dir(fixture_dir).glob("*_ch1.tiff"): + p.unlink() + assert hasAllChannels(str(fixture_dir)) is False + with pytest.raises(FileNotFoundError, match="_ch1"): + readAllChannelsInfo(str(fixture_dir)) + + +class TestReadAllChannelsInfo: + def test_channels_by_filter_channel_rank(self, fixture_dir): + info = readAllChannelsInfo(str(fixture_dir)) + assert info["channelNames"] == [_CH561, _CH640] + assert [c["chIndex"] for c in info["channels"]] == [0, 1] + assert [c["numSlices"] for c in info["channels"]] == [4, 4] + assert info["height"] == 106 + assert info["width"] == 13 + assert info["dtype"] == "uint16" + assert info["voxelSizeUm"] == [4, 4, 4] + assert Path(info["channels"][1]["slicePaths"][-1]).name == "200000_100000_000030_ch1.tiff" + + def test_without_header(self, fixture_dir): + info = readAllChannelsInfo(str(fixture_dir), ReadHeader=False) + assert math.isnan(info["height"]) + assert info["dtype"] == "" + + def test_slices_sorted_numerically(self, fixture_dir): + # "5" sorts after "000030" as text but between 0 and 10 by value. + for k in range(2): + img = np.full((106, 13), 1000 * (k + 1) + 99, dtype=np.uint16) + tifffile.imwrite(_all_dir(fixture_dir) / f"200000_100000_5_ch{k}.tiff", img) + info = readAllChannelsInfo(str(fixture_dir)) + assert info["channels"][0]["numSlices"] == 5 + assert Path(info["channels"][0]["slicePaths"][1]).stem == "200000_100000_5_ch0" + + def test_mip_stitched_overrides_rank(self, fixture_dir): + for name, k in ((_CH561, 1), (_CH640, 0)): + mip = fixture_dir / f"{name}_MIP_stitched" + mip.mkdir() + tifffile.imwrite(mip / f"200000_100000_000000_ch{k}.tiff", np.zeros((2, 2), np.uint16)) + info = readAllChannelsInfo(str(fixture_dir)) + assert info["channelNames"] == [_CH640, _CH561] + vol = readAllChannelsVolume(str(fixture_dir), _CH561, ZRange=[1, 1]) + assert vol[0, 0, 0] == 2000 + + def test_errors_without_folder(self, fixture_dir): + shutil.rmtree(_all_dir(fixture_dir)) + with pytest.raises(FileNotFoundError, match="All_Channels directory not found"): + readAllChannelsInfo(str(fixture_dir)) + + def test_errors_without_slices(self, fixture_dir): + for p in _all_dir(fixture_dir).glob("*.tiff"): + p.unlink() + with pytest.raises(FileNotFoundError, match="No"): + readAllChannelsInfo(str(fixture_dir)) + + +class TestReadAllChannelsVolume: + def test_full(self, fixture_dir): + vol = readAllChannelsVolume(str(fixture_dir), _CH640) + assert vol.shape == (4, 106, 13) + assert vol.dtype == np.uint16 + assert vol[:, 4, 6].tolist() == [2000, 2001, 2002, 2003] + + def test_zrange(self, fixture_dir): + vol = readAllChannelsVolume(str(fixture_dir), _CH561, ZRange=[2, 3]) + assert vol.shape == (2, 106, 13) + assert vol[:, 0, 0].tolist() == [1001, 1002] + + def test_bad_zrange(self, fixture_dir): + with pytest.raises(ValueError, match="ZRange"): + readAllChannelsVolume(str(fixture_dir), _CH561, ZRange=[0, 2]) + with pytest.raises(ValueError, match="ZRange"): + readAllChannelsVolume(str(fixture_dir), _CH561, ZRange=[3, 5]) + + def test_unknown_channel(self, fixture_dir): + with pytest.raises(ValueError, match="not found"): + readAllChannelsVolume(str(fixture_dir), "Ex_488_Em_488F_Ch1") + + +# ---------------------------------------------------------------------- +# Reader +# ---------------------------------------------------------------------- + + +class TestReaderAllChannels: + def test_resolve_root_is_all_channels(self, fixture_dir): + r = ndr_reader_smartspim() + info = r.resolveepoch([str(fixture_dir)]) + assert info["source"] == "allChannels" + assert info["channelName"] == "" + assert info["tileId"] == "" + assert info["channelNames"] == [_CH561, _CH640] + assert info["numSlices"] == 4 + assert info["allChannels"]["voxelSizeUm"] == [4, 4, 4] + assert r.resolveepoch(str(fixture_dir))["source"] == "allChannels" + + def test_resolve_channel_is_stitched(self, fixture_dir): + info = ndr_reader_smartspim().resolveepoch([str(fixture_dir), _CH640]) + assert info["source"] == "allChannels" + assert info["channelNames"] == [_CH640] + assert info["tile"] is None + + def test_resolve_tile_still_reads_tile(self, fixture_dir): + info = ndr_reader_smartspim().resolveepoch([str(fixture_dir), _CH561, _TILE1]) + assert info["source"] == "tile" + assert info["tile"]["height"] == 8 + assert info["allChannels"] is None + + def test_unknown_channel_errors(self, fixture_dir): + with pytest.raises(ValueError, match="not found"): + ndr_reader_smartspim().resolveepoch([str(fixture_dir), "Ex_488_Em_488F_Ch1"]) + + def test_slice_count_mismatch_errors_for_root(self, fixture_dir): + (_all_dir(fixture_dir) / "200000_100000_000030_ch1.tiff").unlink() + r = ndr_reader_smartspim() + with pytest.raises(ValueError, match="different slice counts"): + r.resolveepoch([str(fixture_dir)]) + assert r.numframes([str(fixture_dir), _CH640], 1) == 3 + + def test_fallback_when_channel_incomplete(self, fixture_dir): + for p in _all_dir(fixture_dir).glob("*_ch1.tiff"): + p.unlink() + r = ndr_reader_smartspim() + with pytest.raises(ValueError, match="does not pin a channel"): + r.resolveepoch([str(fixture_dir)]) + with pytest.raises(ValueError, match="does not pin a tile"): + r.resolveepoch([str(fixture_dir), _CH561]) + + def test_framesize_and_type(self, fixture_dir): + r = ndr_reader_smartspim() + root = str(fixture_dir) + assert r.framesize([root], 1) == [106, 13, 2, 1, 4] + assert r.framesize([root, _CH561], 1) == [106, 13, 1, 1, 4] + assert r.numframes([root], 1) == 4 + assert r.datatype([root], 1) == "uint16" + assert r.dimensionorder([root], 1) == "YXCZT" + assert len(r.frametimes([root], 1)) == 4 + + def test_readframes_all_channels(self, fixture_dir): + frames = ndr_reader_smartspim().readframes([str(fixture_dir)], 1, [1, 2, 4]) + assert frames.shape == (106, 13, 2, 1, 3) + assert frames.dtype == np.uint16 + assert frames[0, 0, 0, 0, :].tolist() == [1000, 1001, 1003] + assert frames[8, 3, 1, 0, :].tolist() == [2000, 2001, 2003] + + def test_readframes_unordered_indices(self, fixture_dir): + frames = ndr_reader_smartspim().readframes([str(fixture_dir), _CH640], 1, [4, 2, 3, 3]) + assert frames.shape == (106, 13, 1, 1, 4) + assert frames[0, 0, 0, 0, :].tolist() == [2003, 2001, 2002, 2002] + + def test_readframes_select_c(self, fixture_dir): + frames = ndr_reader_smartspim().readframes([str(fixture_dir)], 1, [2], SelectC=[2]) + assert frames.shape == (106, 13, 1, 1, 1) + assert frames[0, 0, 0, 0, 0] == 2001 + + def test_readframes_default_reads_all(self, fixture_dir): + frames = ndr_reader_smartspim().readframes([str(fixture_dir)], 1, []) + assert frames.shape == (106, 13, 2, 1, 4) + + def test_readframes_out_of_range(self, fixture_dir): + with pytest.raises(IndexError): + ndr_reader_smartspim().readframes([str(fixture_dir)], 1, [1, 5]) + + def test_tile_read_unaffected(self, fixture_dir): + frames = ndr_reader_smartspim().readframes([str(fixture_dir), _CH640, _TILE1], 1, [1]) + assert frames.shape == (8, 10, 1, 1, 1) + assert frames[0, 0, 0, 0, 0] == 200 From 667274b1ef42a615e78076fd388324f606b64fcd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:49:44 +0000 Subject: [PATCH 2/3] bridge: sync readTileInfo to NDR-matlab 3e300e5 NDR-matlab fixed readTileInfo reporting int16 for TIFFs that store SampleFormat = 1 (imfinfo's 'Unsigned integer' contains 'signed integer'). The Python port takes the numpy dtype straight from tifffile and never had the bug -- checked against a hand-built TIFF carrying SampleFormat = 1 -- so only the sync hash and decision_log change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U33UPxYJWPpcQimNu5t5Cb --- src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml b/src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml index 2da6ea6..8ea41e9 100644 --- a/src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml +++ b/src/ndr/format/smartspim/ndr_matlab_python_bridge.yaml @@ -76,7 +76,7 @@ functions: - name: readTileInfo matlab_path: "+ndr/+format/+smartspim/readTileInfo.m" status: regular_port - matlab_last_sync_hash: "d329021" + matlab_last_sync_hash: "3e300e5" python_path: "ndr/format/smartspim/readTileInfo.py" decision_log: >- Cheap-per-tile probe: converts the tile id (forward-slash DIR_NAME) @@ -88,7 +88,11 @@ functions: string. The MATLAB helper accepts an uppercase-glob fallback for case-insensitive filesystems; Python's pathlib.suffix comparison is already case-insensitive against a lowercased tuple, so the - fallback collapses into one pass. + fallback collapses into one pass. MATLAB commit 3e300e5 fixed + tiffDtype reporting int16 for TIFFs that store SampleFormat = 1 + (imfinfo's 'Unsigned integer' contains 'signed integer'); the + Python port never had that bug because tifffile returns the numpy + dtype directly, so the sync needs no code change here. - name: readTileVolume matlab_path: "+ndr/+format/+smartspim/readTileVolume.m" From 0013532d388128f6de041dfafa4513e477b0d927 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:06:37 +0000 Subject: [PATCH 3/3] bridge: sync tiffstack to NDR-matlab 8f870c1 NDR-matlab fixed tiffclass reading float TIFFs ('IEEE floating point') as uint32. The Python port takes the dtype from tifffile and was never affected -- float32 and int16 stacks round-trip -- so only the sync hash and decision_log change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U33UPxYJWPpcQimNu5t5Cb --- src/ndr/reader/ndr_matlab_python_bridge.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ndr/reader/ndr_matlab_python_bridge.yaml b/src/ndr/reader/ndr_matlab_python_bridge.yaml index 2230336..2ac4c5c 100644 --- a/src/ndr/reader/ndr_matlab_python_bridge.yaml +++ b/src/ndr/reader/ndr_matlab_python_bridge.yaml @@ -1133,7 +1133,7 @@ classes: matlab_path: "+ndr/+reader/tiffstack.m" status: regular_port python_path: "ndr/reader/tiffstack.py" - matlab_last_sync_hash: "a2ffd8b" + matlab_last_sync_hash: "8f870c1" python_class: "ndr_reader_tiffstack" inherits: base decision_log: >- @@ -1143,7 +1143,10 @@ classes: so it is only required when a TIFF is actually read). MATLAB's tiffclass derives the class from BitsPerSample plus SampleFormat; tifffile already resolves each page to a numpy dtype, so the Python - tiffclass returns that dtype's name. + tiffclass returns that dtype's name. MATLAB commit 8f870c1 fixed + tiffclass reading float TIFFs (imfinfo 'IEEE floating point') as + uint32; the Python port never had that bug (checked float32 and + int16 round-trips), so the sync needs no code change here. methods: - name: imagefiles input_arguments: