Skip to content

Commit 6b531a7

Browse files
authored
Merge pull request #151 from BioimageAnalysisCoreWEHI/ome-tiff-writer
- OME-Tiff writer so we can write compressed ome-tif. - Modified writer class and tiffwriter class so it can be written to a single ome-tif file - Handle zarr v2 and v3 deprecations
2 parents 24b5650 + 1a996d1 commit 6b531a7

6 files changed

Lines changed: 230 additions & 73 deletions

File tree

core/lls_core/models/output.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class OutputParams(FieldAccessModel):
3838
)
3939
save_type: SaveFileType = Field(
4040
default=SaveFileType.h5,
41-
description=f"The data type to save the result as. This will also be used to determine the file extension of the output files. Choices: `{enum_choices(SaveFileType)}`. Choices can alternatively be specifed as `str`, for example `'tiff'`."
41+
description=f"The data type to save the result as. This will also be used to determine the file extension of the output files. Choices: `{enum_choices(SaveFileType)}`. Choices can alternatively be specifed as `str`, for example `'tiff'`. Note: `tiff` is saved as a compressed OME-TIFF (`.ome.tif`)."
4242
)
4343
time_range: range = Field(
4444
default=None,
@@ -102,7 +102,9 @@ def file_extension(self):
102102
elif self.save_type == SaveFileType.omezarr:
103103
return "ome.zarr"
104104
else:
105-
return "tif"
105+
# TIFF is written as a compressed OME-TIFF by default. The legacy
106+
# uncompressed path renames itself back to plain ".tif".
107+
return "ome.tif"
106108

107109
def make_filepath(self, suffix: str) -> Path:
108110
"""

core/lls_core/models/results.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,7 @@ def save_image(self):
9595
Writer = self.lattice_data.get_writer()
9696
for roi, roi_results in groupby(self.slices, key=lambda it: it.roi_index):
9797
writer = Writer(self.lattice_data, roi_index=roi)
98-
for slice in roi_results:
99-
writer.write_slice(slice)
100-
writer.close()
98+
writer.write_all(roi_results)
10199

102100
class ProcessedWorkflowOutput(BaseModel, arbitrary_types_allowed=True):
103101
"""

core/lls_core/utils.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515

1616
from . import DeskewDirection, config
1717

18+
from packaging.version import parse as parse_version
19+
20+
#Get zarr major version here and single source of truth for rest of the codebase
21+
import zarr
22+
ZARR_MAJOR_VERSION = parse_version(zarr.__version__).major #get the major zarr vers
23+
1824
if TYPE_CHECKING:
1925
from xml.etree.ElementTree import Element
2026
from dask.array.core import Array as DaskArray
@@ -349,9 +355,7 @@ def get_zarr_compression():
349355
Returns zarr compression settings depending on zarr version
350356
zarrv3 does not accept numcodecs compressors directly whereas zarrv <3 does
351357
"""
352-
from packaging.version import parse
353-
import zarr
354-
if parse(zarr.__version__).major >= 3:
358+
if ZARR_MAJOR_VERSION >= 3:
355359
from zarr.codecs import BloscCodec
356360
return dict(
357361
compressors=[BloscCodec(cname="zstd", clevel=5, shuffle="shuffle")]

core/lls_core/writers.py

Lines changed: 195 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from abc import ABC, abstractmethod
44
from dataclasses import dataclass, field
5-
from typing import TYPE_CHECKING, List, Optional
5+
from typing import TYPE_CHECKING, Iterable, List, Optional
66

77
from lls_core.types import ArrayLike
88

@@ -15,9 +15,26 @@
1515
import numpy as np
1616
import zarr
1717

18-
from lls_core.utils import make_filename_suffix, get_zarr_compression
18+
from lls_core.utils import make_filename_suffix, get_zarr_compression, ZARR_MAJOR_VERSION
1919
RoiIndex = Optional[NonNegativeInt]
2020

21+
def resolve_output_dtype(dtype: np.dtype) -> np.dtype:
22+
"""Pick the output dtype: keep float and small ints, cast larger ints (int32, int64) to uint16."""
23+
dtype = np.dtype(dtype)
24+
if np.issubdtype(dtype, np.integer):
25+
return dtype if np.iinfo(dtype).max < np.iinfo(np.uint16).max else np.dtype(np.uint16)
26+
if np.issubdtype(dtype, np.floating):
27+
return dtype
28+
raise TypeError(f"Unsupported data dtype: {dtype}")
29+
30+
31+
def to_output_dtype(array: np.ndarray, out_dtype: np.dtype) -> np.ndarray:
32+
"""Cast to ``out_dtype``, clipping to range only when casting to uint16."""
33+
out_dtype = np.dtype(out_dtype)
34+
if out_dtype == np.uint16 and array.dtype != np.uint16:
35+
return np.clip(array, 0.0, 65535.0).astype(np.uint16)
36+
return array.astype(out_dtype, copy=False)
37+
2138
if TYPE_CHECKING:
2239
from lls_core.models.lattice_data import LatticeData
2340
import npy2bdv
@@ -49,6 +66,17 @@ def close(self):
4966
"""
5067
pass
5168

69+
def write_all(self, slices: Iterable[ProcessedSlice[ArrayLike]]) -> None:
70+
"""
71+
Write each slice, then finish the output.
72+
73+
By default, each slice is passed to ``write_slice`` in order, and
74+
``close`` is called at the end. Override this for custom behavior.
75+
"""
76+
for slice in slices:
77+
self.write_slice(slice)
78+
self.close()
79+
5280
@dataclass
5381
class BdvWriter(Writer):
5482
"""
@@ -95,45 +123,89 @@ def close(self):
95123
@dataclass
96124
class TiffWriter(Writer):
97125
"""
98-
A writer for for TIFF output format
126+
A writer for for TIFF output format.
127+
128+
By default write a deflate-compressed OME-TIFF (compression='zlib),
129+
which keeps the compresses empty/black space at borders of deskewed images keeping
130+
Fiji/Bio-Formats readability. Set compression=None to fall back to the legacy
131+
uncompressed ImageJ TIFF.
99132
"""
100133
pending_slices: List[ImageSlice] = field(default_factory=list, init=False)
101134
time: Optional[NonNegativeInt] = None
135+
#: tifffile compression codec for the OME-TIFF output. ``'zlib'`` (deflate) is
136+
#: the default; ``None`` writes an uncompressed ImageJ-TIFF as before.
137+
compression: Optional[str] = "zlib"
102138

103139
def __post_init__(self):
104140
self.pending_slices = []
105-
141+
106142
def flush(self):
107143
"Write out all pending slices"
108144
import numpy as np
109145
import tifffile
110-
if len(self.pending_slices) > 0:
111-
first_result = self.pending_slices[0]
112-
images_array = np.swapaxes(
113-
np.expand_dims([result.data for result in self.pending_slices], axis=0),
114-
1, 2
115-
).astype("uint16")
116-
# ImageJ TIFF can only handle 16-bit uints, not 32
117-
path = self.lattice.make_filepath(
118-
make_filename_suffix(
119-
channel=first_result.channel,
120-
time=first_result.time,
121-
roi_index=first_result.roi_index
122-
)
146+
if len(self.pending_slices) == 0:
147+
return
148+
149+
first_result = self.pending_slices[0]
150+
# One buffered slice per channel for this timepoint; each is a (Z, Y, X)
151+
# volume. flush() is called once the timepoint changes, so T == 1 here.
152+
channel_arrays = [np.asarray(result.data) for result in self.pending_slices]
153+
154+
# Holds every channel for this timepoint (TCZYX / TZCYX), so
155+
# name will just be by ROI and time
156+
path = self.lattice.make_filepath(
157+
make_filename_suffix(
158+
time=first_result.time,
159+
roi_index=first_result.roi_index
123160
)
161+
)
162+
163+
if self.compression is None:
164+
# Legacy uncompressed ImageJ-TIFF (TZCYX). ImageJ-TIFF cannot be
165+
# compressed, so this path only exists as a fallback. It is not an
166+
# OME-TIFF, so drop the ".ome" from the default ".ome.tif" name.
167+
if path.name.endswith(".ome.tif"):
168+
path = path.with_name(path.name[:-len(".ome.tif")] + ".tif")
169+
images_array = np.swapaxes(
170+
np.expand_dims(channel_arrays, axis=0), 1, 2
171+
).astype("uint16") # ImageJ TIFF can only handle 16-bit uints, not 32
124172
tifffile.imwrite(
125173
str(path),
126-
data = images_array,
174+
data=images_array,
127175
bigtiff=True,
128-
resolution=(1./self.lattice.dx, 1./self.lattice.dy),
176+
resolution=(1. / self.lattice.dx, 1. / self.lattice.dy),
129177
resolutionunit="MICROMETER",
130178
metadata={'spacing': self.lattice.new_dz, 'unit': 'um', 'axes': 'TZCYX'},
131179
imagej=True
132180
)
133-
self.written_files.append(path)
134-
135-
# Reinitialise
136-
self.pending_slices = []
181+
else:
182+
# Compressed OME-TIFF. The full timepoint is already buffered in
183+
# channel_arrays, so assemble the (T, C, Z, Y, X) array (T == 1) and
184+
# write it with tifffile's native OME support, which lays out the
185+
# pages and OME-XML correctly for any Z depth. imagej=True is
186+
# incompatible with both compression and OME; physical pixel sizes
187+
# and channel names travel in the OME-XML instead.
188+
189+
# Same dtype policy as OMEZarrWriter: preserve small ints and float,
190+
# standardise wider integers to uint16.
191+
out_dtype = resolve_output_dtype(np.result_type(*channel_arrays))
192+
stack = to_output_dtype(np.stack(channel_arrays, axis=0), out_dtype) # (C, Z, Y, X)
193+
data5d = stack[np.newaxis, ...] # (T=1, C, Z, Y, X), matches TCZYX
194+
195+
ome_metadata = {
196+
"axes": "TCZYX",
197+
"PhysicalSizeX": float(self.lattice.dx), "PhysicalSizeXUnit": "µm",
198+
"PhysicalSizeY": float(self.lattice.dy), "PhysicalSizeYUnit": "µm",
199+
"PhysicalSizeZ": float(self.lattice.new_dz), "PhysicalSizeZUnit": "µm",
200+
"Channel": {"Name": [str(result.channel) for result in self.pending_slices]},
201+
}
202+
with tifffile.TiffWriter(str(path), bigtiff=True, ome=True) as tw:
203+
tw.write(data5d, compression=self.compression, metadata=ome_metadata)
204+
205+
self.written_files.append(path)
206+
207+
# Reinitialise
208+
self.pending_slices = []
137209

138210
def write_slice(self, slice: ProcessedSlice[ArrayLike]):
139211
if slice.time != self.time:
@@ -145,6 +217,83 @@ def write_slice(self, slice: ProcessedSlice[ArrayLike]):
145217
def close(self):
146218
self.flush()
147219

220+
def write_all(self, slices: Iterable[ProcessedSlice[ArrayLike]]) -> None:
221+
"""
222+
Stream every timepoint/channel into a single compressed OME-TIFF.
223+
224+
tifffile only makes one OME series per ``write()`` call, so we stream
225+
planes from the slice iterator instead of buffering full volumes.
226+
The uncompressed path keeps the old per-timepoint behavior.
227+
"""
228+
# Legacy ImageJ-TIFF: unchanged, one file per timepoint.
229+
if self.compression is None:
230+
super().write_all(slices) # default behaviour
231+
return
232+
233+
import numpy as np
234+
import tifffile
235+
#get whole slice iterator for write_all
236+
it = iter(slices)
237+
238+
first = next(it, None)
239+
if first is None:
240+
# Empty ROI: write nothing.
241+
return
242+
243+
first_vol = np.asarray(first.data)
244+
if first_vol.ndim != 3:
245+
raise ValueError(f"Expected (Z, Y, X) slice, got shape {first_vol.shape}")
246+
247+
# Dtype policy matches OMEZarrWriter: fixed from the first slice.
248+
out_dtype = resolve_output_dtype(first_vol.dtype)
249+
z_len, y_len, x_len = (int(d) for d in first_vol.shape)
250+
t_len = len(self.lattice.time_range)
251+
c_len = len(self.lattice.channel_range)
252+
253+
# One file per ROI, named like the other writers (no timepoint/channel
254+
# in the name, since the file holds them all).
255+
suffix = f"_{make_filename_suffix(roi_index=str(self.roi_index))}" if self.roi_index is not None else ""
256+
path = self.lattice.make_filepath(suffix)
257+
258+
ome_metadata = {
259+
"axes": "TCZYX",
260+
"PhysicalSizeX": float(self.lattice.dx), "PhysicalSizeXUnit": "µm",
261+
"PhysicalSizeY": float(self.lattice.dy), "PhysicalSizeYUnit": "µm",
262+
"PhysicalSizeZ": float(self.lattice.new_dz), "PhysicalSizeZUnit": "µm",
263+
"Channel": {"Name": [str(ch) for ch in self.lattice.channel_range]},
264+
}
265+
266+
def plane_generator():
267+
# first slice is already loaded, so cast it once and write its
268+
# Z planes. Then load and cast the rest. Slices come in time order
269+
# and then channel order, so this matches the (t, c, z) order that
270+
# tifffile expects for shape=(T,C,Z,Y,X).
271+
first_cast = to_output_dtype(first_vol, out_dtype)
272+
#load and cast rest of slices
273+
for z in range(first_cast.shape[0]):
274+
yield first_cast[z]
275+
#stream rest of slices
276+
for sl in it:
277+
vol = to_output_dtype(np.asarray(sl.data), out_dtype)
278+
if vol.shape != (z_len, y_len, x_len):
279+
raise ValueError(
280+
f"Inconsistent slice shape {vol.shape}; expected {(z_len, y_len, x_len)}"
281+
)
282+
for z in range(vol.shape[0]):
283+
yield vol[z]
284+
285+
# A plane count != T*C*Z makes tw.write raise, so a partial or misordered
286+
# run fails loudly instead of writing a silently wrong file.
287+
with tifffile.TiffWriter(str(path), bigtiff=True, ome=True) as tw:
288+
tw.write(
289+
plane_generator(),
290+
shape=(t_len, c_len, z_len, y_len, x_len),
291+
dtype=out_dtype,
292+
compression=self.compression,
293+
metadata=ome_metadata,
294+
)
295+
self.written_files.append(path)
296+
148297
@dataclass
149298
class OMEZarrWriter(Writer):
150299
DEFAULT_CHUNK_ZYX = (64, 256, 256)
@@ -183,7 +332,7 @@ def __init__(
183332
self._zyx = None
184333
self._t_len = None
185334
self._c_len = None
186-
self._dtype = np.uint16 #We are enforcing 16-bit, but may change in future
335+
self._dtype = np.uint16 #Placeholder; resolved per data in write_slice
187336

188337
self._pix_z, self._pix_y, self._pix_x = (self.lattice.new_dz, self.lattice.dy, self.lattice.dx)
189338

@@ -196,26 +345,21 @@ def write_slice(self, slice) -> Path:
196345
if self._zyx is None:
197346
self._zyx = (int(data3d.shape[0]), int(data3d.shape[1]), int(data3d.shape[2]))
198347

199-
#if dtype of data is < uint16, use the data dtype
200-
if np.issubdtype(data3d.dtype, np.integer):
201-
self._dtype = (data3d.dtype
202-
if np.iinfo(data3d.dtype).max < np.iinfo(np.uint16).max
203-
else np.uint16)
204-
elif np.issubdtype(data3d.dtype, np.floating):
205-
#float data, so preserve dtype
206-
self._dtype = data3d.dtype
207-
else:
208-
raise TypeError(f"Unsupported data dtype: {data3d.dtype}")
209-
348+
# Same dtype policy as TiffWriter: preserve small ints and float,
349+
# standardise wider integers to uint16. Preserving float means label/
350+
# mask images (typically float32) keep their values instead of being
351+
# clipped to 16-bit.
352+
self._dtype = resolve_output_dtype(data3d.dtype)
353+
210354
t_idx = int(getattr(slice, "time_index", 0))
211355
c_idx = int(getattr(slice, "channel_index", 0))
212356
t_len, c_len = self._resolve_t_c_lengths(slice)
213357

214358
# If it's the first slice - initialize the full zarr array size
215359
if self._arr is None:
216360
self._root_group, self._arr = self._create_store(t_len, c_len, self._zyx, self._dtype)
217-
218-
self._arr[t_idx, c_idx, :, :, :] = np.clip(data3d, 0.0, 65535.0).astype(np.uint16)
361+
362+
self._arr[t_idx, c_idx, :, :, :] = to_output_dtype(data3d, self._arr.dtype)
219363
return self._root_path
220364

221365
# Optional hook if the framework ever calls it.
@@ -238,27 +382,26 @@ def _create_store(
238382
import shutil
239383
shutil.rmtree(self._root_path)
240384

241-
chunks = (1, 1, *self.chunk_zyx)
242-
243-
compression_kwargs = get_zarr_compression()
244-
#adding compatibility fix for zarr v2 and v3
245-
if int(zarr.__version__.split(".")[0]) >= 3:
246-
#zarr v3: group class cannot be constructed from path directly
247-
root = zarr.open_group(store=str(self._root_path), mode="a")
248-
else:
249-
store = zarr.DirectoryStore(str(self._root_path))
250-
root = zarr.group(store=store)
251-
252385
dataset_kwargs = {
253386
"shape": (t_len, c_len, zyx[0], zyx[1], zyx[2]),
254-
"chunks": chunks,
387+
"chunks": (1, 1, *self.chunk_zyx),
255388
"dtype": dtype,
256-
**compression_kwargs,
389+
**get_zarr_compression(),
257390
}
258-
if int(zarr.__version__.split(".")[0]) < 3:
391+
392+
# Single version check for both group and array creation
393+
if ZARR_MAJOR_VERSION >= 3:
394+
# zarr v3: group cannot be constructed from a path directly, and
395+
# create_array is the current API (create_dataset is deprecated).
396+
root = zarr.open_group(store=str(self._root_path), mode="a")
397+
arr = root.create_array("0", **dataset_kwargs)
398+
else:
399+
# zarr v2: build group from a DirectoryStore; the group has no
400+
# create_array, so create_dataset is the equivalent.
401+
root = zarr.group(store=zarr.DirectoryStore(str(self._root_path)))
259402
dataset_kwargs["overwrite"] = self.overwrite
403+
arr = root.create_dataset("0", **dataset_kwargs)
260404

261-
arr = root.create_dataset("0", **dataset_kwargs)
262405
self._write_ngff_attrs(root)
263406
return root, arr
264407

0 commit comments

Comments
 (0)