22
33from abc import ABC , abstractmethod
44from dataclasses import dataclass , field
5- from typing import TYPE_CHECKING , List , Optional
5+ from typing import TYPE_CHECKING , Iterable , List , Optional
66
77from lls_core .types import ArrayLike
88
1515import numpy as np
1616import 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
1919RoiIndex = 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+
2138if 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
5381class BdvWriter (Writer ):
5482 """
@@ -95,45 +123,89 @@ def close(self):
95123@dataclass
96124class 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
149298class 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