Skip to content

Commit fd4096c

Browse files
committed
avoid autosplit channel axis for plain tiff with no channel metadata. Add tests too
1 parent eb5b915 commit fd4096c

3 files changed

Lines changed: 137 additions & 19 deletions

File tree

plugin/napari_lattice/fields.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,25 @@ class BackgroundSource(StrEnum):
8888
SecondLast = "Second Last"
8989
Custom = "Custom"
9090

91+
def dimension_order_options(ndims: Optional[int]) -> List[str]:
92+
"""
93+
Dimension-order choices offered for an image stack of the given dimensionality,
94+
including the "Get from Metadata" option. Kept as a separate function so
95+
we can test interaction between the reader -> GUI without a Qt widget: For example, a
96+
single-channel stack that the reader keeps 5D must offer the TCZYX/CTZYX orders.
97+
"""
98+
default = ["Get from Metadata"]
99+
if ndims is None:
100+
return default
101+
elif ndims == 3:
102+
return ["ZYX"] + default
103+
elif ndims == 4:
104+
return ["TZYX", "CZYX"] + default
105+
elif ndims == 5:
106+
return ["TCZYX", "CTZYX"] + default
107+
else:
108+
raise Exception("Only 3-5 dimensional arrays are supported")
109+
91110
def enable_field(field: MagicField, enabled: bool = True) -> None:
92111
"""
93112
Enable the widget associated with a field
@@ -226,18 +245,8 @@ def _get_dimension_options(self, _) -> List[str]:
226245
"""
227246
Returns the list of dimension order options that might be possible for the current image stack
228247
"""
229-
default = ["Get from Metadata"]
230248
ndims = max([len(layer.data.shape) for layer in self.img_layer.value], default=None)
231-
if ndims is None:
232-
return default
233-
elif ndims == 3:
234-
return ["ZYX"] + default
235-
elif ndims == 4:
236-
return ["TZYX", "CZYX"] + default
237-
elif ndims == 5:
238-
return ["TCZYX", "CTZYX"] + default
239-
else:
240-
raise Exception("Only 3-5 dimensional arrays are supported")
249+
return dimension_order_options(ndims)
241250

242251
# --- Input / interpretation ---
243252
img_layer = field(List[Image], widget_type='Select').with_options(

plugin/napari_lattice/reader.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@
2828
from bioio import ImageLike
2929
from xarray import DataArray
3030

31+
import re
32+
# bioio can invent placeholder channel names: "Channel:<image>:<channel>"
33+
# (e.g. "Channel:0:0") for files that with no channel metadata, such
34+
# as plain TIFFs. The TCZYX order is guessed from the array shape.
35+
# `test_reader` pins thisformat so a future bioio rename surfaces as a test failure instead of silently
36+
# re-enabling channel splitting. Genuine acquisitions have descriptive names ("LLS1").
37+
_AUTO_CHANNEL_NAME = re.compile(r"^Channel:\d+:\d+$")
38+
39+
def _has_real_channel_metadata(image: BioImage) -> bool:
40+
"""
41+
Whether the image's channel axis is trustworthy enough to split a napari layer on.
42+
43+
We trust it only when the channel names are descriptive, rather than bioio's
44+
"Channel:i:j" placeholders. This is best-effort: it cannot recognise a real channel
45+
axis that simply has no names, but it covers every observed file format.
46+
"""
47+
names = image.channel_names
48+
if not names:
49+
return False
50+
return not all(_AUTO_CHANNEL_NAME.match(str(name)) for name in names)
51+
3152
class NapariImageParams(TypedDict):
3253
data: DataArray
3354
physical_pixel_sizes: DefinedPixelSizes
@@ -249,19 +270,36 @@ def bioio_reader(path: str | list[str]) -> List[Tuple[Any, dict, str]]:
249270
getattr(pixel_sizes, dim, 1.0) or 1.0 for dim in image.dims.order
250271
]
251272

252-
# Set the channel axis
253-
if "C" in image.dims.order:
254-
c_idx = image.dims.order.index("C")
273+
# Set the channel axis.
274+
# Only split the layer into one image per channel when the channel axis is
275+
# genuine: it must have more than one channel AND come with real channel
276+
# metadata. bioio pads plain TIFFs out to a guessed TCZYX order (size-1 C, or a
277+
# frame axis mislabelled as "C"), so splitting on that guess would collapse a
278+
# single-channel stack to a lower dimensionality or carve a frame axis into
279+
# spurious channels - in both cases hiding the TCZYX/CTZYX dimension-order
280+
# options the user needs to correct the interpretation downstream.
281+
c_idx = image.dims.order.index("C") if "C" in image.dims.order else None
282+
channel_size = image.dims.shape[c_idx] if c_idx is not None else 1
283+
split_channels = (
284+
c_idx is not None and channel_size > 1 and _has_real_channel_metadata(image)
285+
)
286+
if split_channels:
255287
add_kwargs["channel_axis"] = c_idx
256288
#remove channel idx from scale
257289
scale.pop(c_idx)
258290
dim_order.pop(c_idx)
259-
if image.channel_names:
260-
if len(scenes) > 1 and scene is not None:
261-
add_kwargs["name"] = [f"{scene} - {ch}" for ch in image.channel_names]
262-
else:
263-
add_kwargs["name"] = image.channel_names
291+
if len(scenes) > 1 and scene is not None:
292+
add_kwargs["name"] = [f"{scene} - {ch}" for ch in image.channel_names]
293+
else:
294+
add_kwargs["name"] = image.channel_names
264295
else:
296+
if c_idx is not None and channel_size > 1:
297+
logger.info(
298+
f"Not splitting the guessed channel axis of '{os.path.basename(path)}' "
299+
f"(size {channel_size}) into separate layers, because the file has no "
300+
f"genuine channel metadata. If this axis is not really the channel "
301+
f"dimension, set the dimension order manually in the Deskew tab."
302+
)
265303
if len(scenes) > 1 and scene is not None:
266304
add_kwargs["name"] = f"{os.path.splitext(os.path.basename(path))[0]} - {scene}"
267305
else:

plugin/tests/test_reader.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Tests for napari_lattice.reader's channel-axis splitting decision and the reader -> GUI
3+
dimension-order contract.
4+
5+
bioio fabricates a full ``TCZYX`` order for plain TIFFs (padding missing axes with
6+
size-1 and auto-naming channels ``Channel:i:j``). The reader must only split a layer
7+
along its channel axis when that axis is genuine; otherwise single-channel stacks
8+
collapse in dimensionality and frame axes that bioio mislabelled "C" get carved into
9+
spurious channels - which is what hides the TCZYX/CTZYX dimension-order options the user
10+
needs to correct the interpretation.
11+
"""
12+
from __future__ import annotations
13+
14+
import numpy as np
15+
import pytest
16+
import tifffile
17+
from bioio import BioImage
18+
from importlib_resources import as_file
19+
20+
from lls_core.sample import resources
21+
from napari_lattice.fields import dimension_order_options
22+
from napari_lattice.reader import _AUTO_CHANNEL_NAME, bioio_reader
23+
24+
25+
def _plain_tiff(tmp_path, shape) -> str:
26+
p = tmp_path / "plain.tif"
27+
tifffile.imwrite(str(p), np.zeros(shape, dtype=np.uint16))
28+
return str(p)
29+
30+
31+
@pytest.mark.parametrize("on_disk", [
32+
pytest.param((5, 1, 30, 16, 16), id="single_channel"), # bioio -> TCZYX, C=1
33+
pytest.param((4, 30, 16, 16), id="frames_mislabelled_C"), # bioio -> TCZYX, C=4
34+
pytest.param((3, 2, 30, 16, 16), id="multichannel_no_meta"), # placeholder names
35+
])
36+
def test_plain_tiff_is_not_split(tmp_path, on_disk):
37+
# No genuine channel metadata -> keep the layer whole so the GUI can offer the
38+
# full TCZYX/CTZYX dimension-order options for the user to correct.
39+
layers = bioio_reader(_plain_tiff(tmp_path, on_disk))
40+
assert len(layers) == 1
41+
_data, add_kwargs, _ = layers[0]
42+
assert "channel_axis" not in add_kwargs
43+
44+
45+
def test_genuine_multichannel_is_split():
46+
# A real acquisition (a format-specific reader with descriptive channel names) is
47+
# still split into one layer per channel for convenient per-channel viewing.
48+
with as_file(resources / "LLS7_t1_ch3.czi") as p:
49+
image = BioImage(str(p))
50+
layers = bioio_reader(str(p))
51+
_data, add_kwargs, _ = layers[0]
52+
assert add_kwargs["channel_axis"] == image.dims.order.index("C")
53+
assert add_kwargs["name"] == list(image.channel_names)
54+
# The channel axis is dropped from the per-channel dimension metadata.
55+
assert "C" not in add_kwargs["metadata"]["dimensions"]
56+
57+
58+
def test_single_channel_tiff_yields_5d_dimension_options(tmp_path):
59+
# The reader -> GUI contract: a single-channel stack stays 5D, and the dimension
60+
# dropdown must then offer the 5D orders so the user can set/confirm the order.
61+
data, _add_kwargs, _ = bioio_reader(_plain_tiff(tmp_path, (5, 1, 30, 16, 16)))[0]
62+
options = dimension_order_options(len(data.shape))
63+
assert "TCZYX" in options and "CTZYX" in options
64+
65+
66+
def test_bioio_placeholder_channel_name_format_unchanged(tmp_path):
67+
# Canary: our split decision relies on recognising bioio's placeholder channel
68+
# names for plain TIFFs. If a bioio upgrade changes this format, fail loudly here
69+
# rather than silently reverting to splitting every plain TIFF.
70+
names = BioImage(_plain_tiff(tmp_path, (3, 2, 30, 16, 16))).channel_names
71+
assert names and all(_AUTO_CHANNEL_NAME.match(str(n)) for n in names), names

0 commit comments

Comments
 (0)