Spaces:
Sleeping
Sleeping
| """ | |
| CZI -> TIFF converter. | |
| Reads a Carl Zeiss .czi microscopy file and writes one TIFF per | |
| (scene, time, channel, z) plane. Designed to run both as a CLI script and | |
| inside a Hugging Face Space (Gradio app at the bottom, guarded by __main__). | |
| Why TIFF (and why files can look "blank"): | |
| Microscopy data is usually 16-bit, but real signal often occupies only a small | |
| fraction of the 0..65535 range (e.g. peaks around a few hundred). If such a | |
| file is written with raw values and opened in an ordinary viewer (Preview, | |
| Windows Photos, browsers, ZIP thumbnails), the viewer maps 0..65535 onto | |
| 0..255, so a peak of ~800 becomes pixel value ~3: visually indistinguishable | |
| from black. The file is NOT empty; it just renders as black. | |
| To avoid that, this converter defaults to writing VIEWABLE TIFFs: each plane is | |
| linearly stretched to the full 16-bit range so it displays correctly in any | |
| viewer. The stretch is recorded so the transform is transparent. For | |
| quantitative work where absolute pixel values must be preserved bit-for-bit, | |
| pass viewable=False to get RAW output (native bit depth, no scaling); those | |
| files additionally carry ImageJ display-range tags so scientific tools | |
| (Fiji/ImageJ, napari, QuPath) auto-scale them on open. | |
| Dependencies: | |
| pip install czifile imagecodecs numpy tifffile pillow | |
| (Gradio app also needs: pip install gradio) | |
| """ | |
| import os | |
| import re | |
| import numpy as np | |
| import tifffile | |
| import czifile | |
| # ---------------------------------------------------------------------------- | |
| # Core conversion | |
| # ---------------------------------------------------------------------------- | |
| def _canonical_array(path): | |
| """ | |
| Return (array, axes_string) in the CZI canonical layout, NOT squeezed. | |
| czifile >= 2026 removed the public .axes/.shape attributes, so we read the | |
| full (unsqueezed) array which always follows the fixed canonical order | |
| 'SCTZYX0' (Scene, Channel, Time, Z, Y, X, Samples). We then map by name. | |
| """ | |
| czi = czifile.CziFile(path) | |
| try: | |
| # Force the full, predictable layout instead of relying on squeeze. | |
| czi._squeeze = False | |
| arr = czi.asarray() | |
| # Read the true axis order if exposed; otherwise fall back to the | |
| # observed canonical layout for this czifile version. | |
| axes = getattr(czi, "axes", None) | |
| finally: | |
| czi.close() | |
| # czifile's full (unsqueezed) array follows a fixed dimension order. For the | |
| # current library version this is S, C, T, Z, Y, X, Samples. The trailing | |
| # axis is samples-per-pixel (1=gray, 3=RGB, 4=RGBA). | |
| if not axes or len(axes) != arr.ndim: | |
| axes = "SCTZYX0" | |
| if arr.ndim != len(axes): | |
| # Fallback: pad/trim by leading singleton dims so the named map still works. | |
| while arr.ndim < len(axes): | |
| arr = arr[np.newaxis, ...] | |
| if arr.ndim > len(axes): | |
| # Collapse any extra leading singleton dims. | |
| extra = arr.ndim - len(axes) | |
| for _ in range(extra): | |
| if arr.shape[0] == 1: | |
| arr = arr[0] | |
| else: | |
| break | |
| return arr, axes | |
| def _to_raw_dtype(plane): | |
| """ | |
| Return the plane in a TIFF-writable dtype, PRESERVING NATIVE BIT DEPTH and | |
| ABSOLUTE INTENSITY. No contrast stretching, no normalization, no 8-bit | |
| down-conversion. uint8/uint16/uint32/float32 pass through unchanged. Other | |
| integer types are cast to the smallest standard container that holds them | |
| without altering values. float64 is preserved as float32. | |
| """ | |
| plane = np.asarray(plane) | |
| dt = plane.dtype | |
| if dt in (np.uint8, np.uint16, np.uint32, np.float32): | |
| return plane | |
| if dt == np.float64: | |
| return plane.astype(np.float32) | |
| if np.issubdtype(dt, np.integer): | |
| info = np.iinfo(dt) | |
| if info.min < 0: | |
| if info.bits <= 16: | |
| return plane.astype(np.int16) | |
| return plane.astype(np.int32) | |
| if info.bits <= 8: | |
| return plane.astype(np.uint8) | |
| if info.bits <= 16: | |
| return plane.astype(np.uint16) | |
| return plane.astype(np.uint32) | |
| return plane.astype(np.float32) | |
| def _stretch_to_viewable(plane): | |
| """ | |
| Linearly stretch a single-sample plane to the full unsigned range of an | |
| 8- or 16-bit container so it displays correctly in ANY image viewer. | |
| The output bit depth matches the input's natural container (8-bit stays | |
| 8-bit, everything else becomes 16-bit) so contrast is maximized without | |
| needlessly inflating file size. Returns (stretched_array, lo, hi) where | |
| lo/hi are the original min/max that were mapped to black/white. | |
| """ | |
| a = np.asarray(plane).astype(np.float64) | |
| lo = float(a.min()) | |
| hi = float(a.max()) | |
| # Choose an 8-bit container only for genuine 8-bit input; otherwise 16-bit. | |
| if np.asarray(plane).dtype == np.uint8: | |
| out_max, out_dtype = 255.0, np.uint8 | |
| else: | |
| out_max, out_dtype = 65535.0, np.uint16 | |
| if hi > lo: | |
| a = (a - lo) * (out_max / (hi - lo)) | |
| else: | |
| # Flat plane: leave at zero (truly empty) rather than divide by zero. | |
| a = np.zeros_like(a) | |
| return a.round().clip(0, out_max).astype(out_dtype), lo, hi | |
| def _channel_names(path, n_channels): | |
| """Best-effort channel names from metadata; falls back to C0, C1, ...""" | |
| names = [] | |
| try: | |
| czi = czifile.CziFile(path) | |
| try: | |
| md = czi.metadata() | |
| finally: | |
| czi.close() | |
| # Names can repeat in metadata; keep first occurrence order, deduped. | |
| found = re.findall(r'<Channel[^>]*?Name="([^"]+)"', md or "") | |
| seen = [] | |
| for f in found: | |
| if f not in seen: | |
| seen.append(f) | |
| names = seen | |
| except Exception: | |
| names = [] | |
| out = [] | |
| for i in range(n_channels): | |
| if i < len(names): | |
| safe = re.sub(r"[^A-Za-z0-9._-]+", "_", names[i]).strip("_") | |
| out.append(safe or f"C{i}") | |
| else: | |
| out.append(f"C{i}") | |
| return out | |
| def _write_plane(fpath, plane, photometric, viewable): | |
| """ | |
| Write a single plane to TIFF. | |
| viewable=True : contrast-stretch to full container range so the file is | |
| visible in any viewer. Stored as uint8/uint16. | |
| viewable=False : write raw values (native bit depth, absolute intensity | |
| preserved) and embed ImageJ display-range tags so | |
| scientific viewers auto-scale on open. | |
| """ | |
| if photometric == "rgb": | |
| # RGB/RGBA: write as-is. Color data already spans a sensible range and | |
| # per-channel stretching would shift colors, so we never stretch it. | |
| img = _to_raw_dtype(plane) | |
| tifffile.imwrite(fpath, img, photometric="rgb", compression="deflate") | |
| return | |
| if viewable: | |
| img, lo, hi = _stretch_to_viewable(plane) | |
| # Record the original intensity window we mapped from, for traceability. | |
| tifffile.imwrite( | |
| fpath, | |
| img, | |
| photometric="minisblack", | |
| compression="deflate", | |
| metadata={"original_min": lo, "original_max": hi, "stretched": True}, | |
| ) | |
| else: | |
| img = _to_raw_dtype(plane) | |
| lo, hi = float(np.asarray(img).min()), float(np.asarray(img).max()) | |
| # ImageJ tags let Fiji/napari/QuPath auto-scale without altering pixels. | |
| # ImageJ format requires uint8/uint16/float32; cast uint32 safely up. | |
| if img.dtype == np.uint32: | |
| img = img.astype(np.float32) | |
| tifffile.imwrite( | |
| fpath, | |
| img, | |
| imagej=True, | |
| metadata={"mode": "grayscale", "min": lo, "max": hi}, | |
| ) | |
| def convert_czi_to_tiff(input_path, output_dir, viewable=True): | |
| """ | |
| Convert one .czi file to a set of TIFF files. | |
| viewable=True (default): files are contrast-stretched so they display | |
| correctly in any viewer (fixes the "all files look blank" problem | |
| caused by low-intensity 16-bit microscopy data). | |
| viewable=False: raw absolute-intensity output for quantitative analysis, | |
| with ImageJ display-range tags for scientific viewers. | |
| Returns a list of written TIFF file paths. | |
| """ | |
| os.makedirs(output_dir, exist_ok=True) | |
| arr, axes = _canonical_array(input_path) | |
| idx = {ax: axes.index(ax) for ax in axes} | |
| nS = arr.shape[idx["S"]] | |
| nT = arr.shape[idx["T"]] | |
| nC = arr.shape[idx["C"]] | |
| nZ = arr.shape[idx["Z"]] | |
| n_samples = arr.shape[idx["0"]] # samples per pixel (1=gray, 3=RGB, 4=RGBA) | |
| ch_names = _channel_names(input_path, nC) | |
| base = os.path.splitext(os.path.basename(input_path))[0] | |
| written = [] | |
| for s in range(nS): | |
| for t in range(nT): | |
| for c in range(nC): | |
| for z in range(nZ): | |
| # Slice down to a single plane (Y, X, samples). | |
| sl = [slice(None)] * arr.ndim | |
| sl[idx["S"]] = s | |
| sl[idx["T"]] = t | |
| sl[idx["C"]] = c | |
| sl[idx["Z"]] = z | |
| plane = arr[tuple(sl)] # leaves Y, X, and samples axes | |
| plane = np.squeeze(plane) | |
| if plane.ndim == 3 and plane.shape[-1] in (3, 4): | |
| photometric = "rgb" | |
| out_plane = plane | |
| elif plane.ndim == 2: | |
| photometric = "minisblack" | |
| out_plane = plane | |
| else: | |
| # Unexpected extra dims: collapse to 2D defensively. | |
| photometric = "minisblack" | |
| out_plane = plane.reshape(plane.shape[-2], plane.shape[-1]) | |
| parts = [base] | |
| if nS > 1: | |
| parts.append(f"S{s}") | |
| if nT > 1: | |
| parts.append(f"T{t}") | |
| parts.append(ch_names[c]) | |
| if nZ > 1: | |
| parts.append(f"Z{z:02d}") | |
| fname = "_".join(parts) + ".tiff" | |
| fpath = os.path.join(output_dir, fname) | |
| _write_plane(fpath, out_plane, photometric, viewable) | |
| written.append(fpath) | |
| return written | |
| # ---------------------------------------------------------------------------- | |
| # Hugging Face Gradio app | |
| # ---------------------------------------------------------------------------- | |
| def _build_gradio_app(): | |
| import tempfile | |
| import zipfile | |
| import gradio as gr | |
| def _to_preview_uint8(img): | |
| """ | |
| 8-bit grayscale preview for on-screen display only. Independent of the | |
| saved files: it always stretches so the browser shows real content. | |
| """ | |
| a = np.asarray(img) | |
| if a.ndim == 3 and a.shape[-1] in (3, 4): | |
| a = a[..., :3].astype(np.float64) | |
| chans = [] | |
| for k in range(a.shape[-1]): | |
| ch = a[..., k] | |
| lo, hi = float(ch.min()), float(ch.max()) | |
| ch = (ch - lo) * (255.0 / (hi - lo)) if hi > lo else np.zeros_like(ch) | |
| chans.append(ch.clip(0, 255).astype(np.uint8)) | |
| return np.stack(chans, axis=-1) | |
| a = a.astype(np.float64) | |
| lo, hi = float(a.min()), float(a.max()) | |
| a = (a - lo) * (255.0 / (hi - lo)) if hi > lo else np.zeros_like(a) | |
| return a.clip(0, 255).astype(np.uint8) | |
| def _process(file_obj): | |
| if file_obj is None: | |
| return None, [], "Please upload a .czi file." | |
| tmp = tempfile.mkdtemp() | |
| out_dir = os.path.join(tmp, "tiff") | |
| # Raw output is now the default and only mode. | |
| tiffs = convert_czi_to_tiff(file_obj.name, out_dir, viewable=False) | |
| zip_path = os.path.join(tmp, "czi_tiffs.zip") | |
| with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for p in tiffs: | |
| zf.write(p, arcname=os.path.basename(p)) | |
| gallery = [] | |
| for p in tiffs: | |
| arr = tifffile.imread(p) | |
| gallery.append((_to_preview_uint8(arr), os.path.basename(p))) | |
| msg = (f"Converted {len(tiffs)} plane(s) to RAW TIFF " | |
| f"(absolute intensity preserved; ImageJ display tags embedded). " | |
| f"These may look black in generic viewers but open correctly in Fiji/napari.") | |
| return zip_path, gallery, msg | |
| grayscale_css = """ | |
| .gradio-container { background: #ffffff !important; color: #000000 !important; } | |
| .gradio-container *, .prose, .prose * { color: #000000 !important; } | |
| .gradio-container h1, .gradio-container h2, .gradio-container h3, | |
| .gradio-container p, .gradio-container span, .gradio-container label, | |
| .gradio-container .markdown, .gradio-container .markdown * { | |
| color: #000000 !important; | |
| } | |
| .gallery img, .grid-wrap img { filter: grayscale(100%); background: #000000; } | |
| button.primary, .primary button, #convert-btn, #convert-btn button { | |
| background: #000000 !important; | |
| background-image: none !important; | |
| color: #ffffff !important; | |
| border-color: #000000 !important; | |
| } | |
| #convert-btn *, .primary button * { color: #ffffff !important; } | |
| """ | |
| with gr.Blocks(title="CZI to TIFF Converter", css=grayscale_css, | |
| theme=gr.themes.Default()) as demo: | |
| gr.Markdown( | |
| "# CZI to TIFF Converter\n" | |
| "Upload a `.czi` file to get a ZIP of TIFFs (one per scene/time/" | |
| "channel/Z plane).\n\n" | |
| "Files are written as **raw** TIFFs that preserve absolute pixel " | |
| "intensities for quantitative analysis (they carry ImageJ display " | |
| "tags for Fiji/napari/QuPath). Real 16-bit microscopy signal is " | |
| "often very faint, so these raw files can render as solid black in " | |
| "generic viewers even though the pixel data is intact; the preview " | |
| "below is contrast-stretched for on-screen display only." | |
| ) | |
| inp = gr.File(label="CZI file", file_types=[".czi"]) | |
| btn = gr.Button("Convert", variant="primary", elem_id="convert-btn") | |
| out_msg = gr.Textbox(label="Status") | |
| out_gallery = gr.Gallery(label="Preview (8-bit, display only)", columns=4, height="auto") | |
| out_file = gr.File(label="Download TIFFs (ZIP)") | |
| btn.click(_process, inputs=[inp], outputs=[out_file, out_gallery, out_msg]) | |
| return demo | |
| if __name__ == "__main__": | |
| import sys | |
| # CLI mode: python czi_to_tiff.py input.czi [output_dir] [--viewable] | |
| args = [a for a in sys.argv[1:] if not a.startswith("--")] | |
| viewable_flag = "--viewable" in sys.argv | |
| if args and args[0].lower().endswith(".czi"): | |
| in_path = args[0] | |
| out_dir = args[1] if len(args) >= 2 else "tiff_output" | |
| files = convert_czi_to_tiff(in_path, out_dir, viewable=viewable_flag) | |
| mode = "viewable" if viewable_flag else "raw" | |
| print(f"Wrote {len(files)} TIFF file(s) ({mode}) to {out_dir}/") | |
| for f in files: | |
| print(" ", os.path.basename(f)) | |
| else: | |
| # App mode (Hugging Face Space) | |
| _build_gradio_app().launch() |