stevafernandes commited on
Commit
99bfbca
·
verified ·
1 Parent(s): cb664a2

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +222 -0
  2. czi_to_png.py +222 -0
  3. requirements.txt +5 -0
app.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CZI -> PNG converter.
3
+
4
+ Reads a Carl Zeiss .czi microscopy file and writes one PNG per
5
+ (scene, time, channel, z) plane. Designed to run both as a CLI script and
6
+ inside a Hugging Face Space (Gradio app at the bottom, guarded by __main__).
7
+
8
+ Dependencies:
9
+ pip install czifile imagecodecs numpy pillow
10
+ (Gradio app also needs: pip install gradio)
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import numpy as np
16
+ from PIL import Image
17
+ import czifile
18
+
19
+
20
+ # ----------------------------------------------------------------------------
21
+ # Core conversion
22
+ # ----------------------------------------------------------------------------
23
+ def _canonical_array(path):
24
+ """
25
+ Return (array, axes_string) in the CZI canonical layout, NOT squeezed.
26
+
27
+ czifile >= 2026 removed the public .axes/.shape attributes, so we read the
28
+ full (unsqueezed) array which always follows the fixed canonical order
29
+ 'STCZYX0' (Scene, Time, Channel, Z, Y, X, Samples). We then map by name.
30
+ """
31
+ czi = czifile.CziFile(path)
32
+ try:
33
+ # Force the full, predictable layout instead of relying on squeeze.
34
+ czi._squeeze = False
35
+ arr = czi.asarray()
36
+ # Read the true axis order if exposed; otherwise fall back to the
37
+ # observed canonical layout for this czifile version.
38
+ axes = getattr(czi, "axes", None)
39
+ finally:
40
+ czi.close()
41
+
42
+ # czifile's full (unsqueezed) array follows a fixed dimension order. For the
43
+ # current library version this is S, C, T, Z, Y, X, Samples. The trailing
44
+ # axis is samples-per-pixel (1=gray, 3=RGB, 4=RGBA).
45
+ if not axes or len(axes) != arr.ndim:
46
+ axes = "SCTZYX0"
47
+ if arr.ndim != len(axes):
48
+ # Fallback: pad/trim by leading singleton dims so the named map still works.
49
+ while arr.ndim < len(axes):
50
+ arr = arr[np.newaxis, ...]
51
+ if arr.ndim > len(axes):
52
+ # Collapse any extra leading singleton dims.
53
+ extra = arr.ndim - len(axes)
54
+ for _ in range(extra):
55
+ if arr.shape[0] == 1:
56
+ arr = arr[0]
57
+ else:
58
+ break
59
+ return arr, axes
60
+
61
+
62
+ def _to_uint8(plane):
63
+ """
64
+ Scale a 2D (or 2D+samples) plane to uint8 for PNG output.
65
+
66
+ Uses per-plane min/max contrast stretch, which is the most useful default
67
+ for fluorescence microscopy where raw intensities rarely span the full range.
68
+ """
69
+ plane = np.asarray(plane)
70
+ if plane.dtype == np.uint8:
71
+ return plane
72
+
73
+ plane = plane.astype(np.float64)
74
+ lo = float(plane.min())
75
+ hi = float(plane.max())
76
+ if hi <= lo:
77
+ return np.zeros(plane.shape, dtype=np.uint8)
78
+ scaled = (plane - lo) / (hi - lo) * 255.0
79
+ return scaled.round().clip(0, 255).astype(np.uint8)
80
+
81
+
82
+ def _channel_names(path, n_channels):
83
+ """Best-effort channel names from metadata; falls back to C0, C1, ..."""
84
+ names = []
85
+ try:
86
+ czi = czifile.CziFile(path)
87
+ try:
88
+ md = czi.metadata()
89
+ finally:
90
+ czi.close()
91
+ # Names can repeat in metadata; keep first occurrence order, deduped.
92
+ found = re.findall(r'<Channel[^>]*?Name="([^"]+)"', md or "")
93
+ seen = []
94
+ for f in found:
95
+ if f not in seen:
96
+ seen.append(f)
97
+ names = seen
98
+ except Exception:
99
+ names = []
100
+
101
+ out = []
102
+ for i in range(n_channels):
103
+ if i < len(names):
104
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "_", names[i]).strip("_")
105
+ out.append(safe or f"C{i}")
106
+ else:
107
+ out.append(f"C{i}")
108
+ return out
109
+
110
+
111
+ def convert_czi_to_png(input_path, output_dir):
112
+ """
113
+ Convert one .czi file to a set of PNG files.
114
+
115
+ Returns a list of written PNG file paths.
116
+ """
117
+ os.makedirs(output_dir, exist_ok=True)
118
+ arr, axes = _canonical_array(input_path)
119
+
120
+ idx = {ax: axes.index(ax) for ax in axes}
121
+ nS = arr.shape[idx["S"]]
122
+ nT = arr.shape[idx["T"]]
123
+ nC = arr.shape[idx["C"]]
124
+ nZ = arr.shape[idx["Z"]]
125
+ n_samples = arr.shape[idx["0"]] # samples per pixel (1=gray, 3=RGB, 4=RGBA)
126
+
127
+ ch_names = _channel_names(input_path, nC)
128
+ base = os.path.splitext(os.path.basename(input_path))[0]
129
+ written = []
130
+
131
+ for s in range(nS):
132
+ for t in range(nT):
133
+ for c in range(nC):
134
+ for z in range(nZ):
135
+ # Slice down to a single plane (Y, X, samples).
136
+ sl = [slice(None)] * arr.ndim
137
+ sl[idx["S"]] = s
138
+ sl[idx["T"]] = t
139
+ sl[idx["C"]] = c
140
+ sl[idx["Z"]] = z
141
+ plane = arr[tuple(sl)] # leaves Y, X, and samples axes
142
+
143
+ plane = np.squeeze(plane)
144
+ if plane.ndim == 3 and plane.shape[-1] in (3, 4):
145
+ img8 = np.stack(
146
+ [_to_uint8(plane[..., k]) for k in range(plane.shape[-1])],
147
+ axis=-1,
148
+ )
149
+ mode = "RGB" if img8.shape[-1] == 3 else "RGBA"
150
+ elif plane.ndim == 2:
151
+ img8 = _to_uint8(plane)
152
+ mode = "L"
153
+ else:
154
+ # Unexpected extra dims: collapse to 2D defensively.
155
+ img8 = _to_uint8(plane.reshape(plane.shape[-2], plane.shape[-1]))
156
+ mode = "L"
157
+
158
+ parts = [base]
159
+ if nS > 1:
160
+ parts.append(f"S{s}")
161
+ if nT > 1:
162
+ parts.append(f"T{t}")
163
+ parts.append(ch_names[c])
164
+ if nZ > 1:
165
+ parts.append(f"Z{z:02d}")
166
+ fname = "_".join(parts) + ".png"
167
+ fpath = os.path.join(output_dir, fname)
168
+
169
+ Image.fromarray(img8, mode=mode).save(fpath)
170
+ written.append(fpath)
171
+
172
+ return written
173
+
174
+
175
+ # ----------------------------------------------------------------------------
176
+ # Hugging Face Gradio app
177
+ # ----------------------------------------------------------------------------
178
+ def _build_gradio_app():
179
+ import tempfile
180
+ import zipfile
181
+ import gradio as gr
182
+
183
+ def _process(file_obj):
184
+ if file_obj is None:
185
+ return None, "Please upload a .czi file."
186
+ tmp = tempfile.mkdtemp()
187
+ out_dir = os.path.join(tmp, "png")
188
+ pngs = convert_czi_to_png(file_obj.name, out_dir)
189
+
190
+ zip_path = os.path.join(tmp, "czi_pngs.zip")
191
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
192
+ for p in pngs:
193
+ zf.write(p, arcname=os.path.basename(p))
194
+
195
+ msg = f"Converted {len(pngs)} plane(s) to PNG."
196
+ return zip_path, msg
197
+
198
+ with gr.Blocks(title="CZI to PNG Converter") as demo:
199
+ gr.Markdown("# CZI to PNG Converter\nUpload a `.czi` file to get a ZIP of PNGs (one per channel/Z plane).")
200
+ inp = gr.File(label="CZI file", file_types=[".czi"])
201
+ btn = gr.Button("Convert", variant="primary")
202
+ out_file = gr.File(label="Download PNGs (ZIP)")
203
+ out_msg = gr.Textbox(label="Status")
204
+ btn.click(_process, inputs=inp, outputs=[out_file, out_msg])
205
+
206
+ return demo
207
+
208
+
209
+ if __name__ == "__main__":
210
+ import sys
211
+
212
+ # CLI mode: python czi_to_png.py input.czi [output_dir]
213
+ if len(sys.argv) >= 2 and sys.argv[1].lower().endswith(".czi"):
214
+ in_path = sys.argv[1]
215
+ out_dir = sys.argv[2] if len(sys.argv) >= 3 else "png_output"
216
+ files = convert_czi_to_png(in_path, out_dir)
217
+ print(f"Wrote {len(files)} PNG file(s) to {out_dir}/")
218
+ for f in files:
219
+ print(" ", os.path.basename(f))
220
+ else:
221
+ # App mode (Hugging Face Space)
222
+ _build_gradio_app().launch()
czi_to_png.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CZI -> PNG converter.
3
+
4
+ Reads a Carl Zeiss .czi microscopy file and writes one PNG per
5
+ (scene, time, channel, z) plane. Designed to run both as a CLI script and
6
+ inside a Hugging Face Space (Gradio app at the bottom, guarded by __main__).
7
+
8
+ Dependencies:
9
+ pip install czifile imagecodecs numpy pillow
10
+ (Gradio app also needs: pip install gradio)
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import numpy as np
16
+ from PIL import Image
17
+ import czifile
18
+
19
+
20
+ # ----------------------------------------------------------------------------
21
+ # Core conversion
22
+ # ----------------------------------------------------------------------------
23
+ def _canonical_array(path):
24
+ """
25
+ Return (array, axes_string) in the CZI canonical layout, NOT squeezed.
26
+
27
+ czifile >= 2026 removed the public .axes/.shape attributes, so we read the
28
+ full (unsqueezed) array which always follows the fixed canonical order
29
+ 'STCZYX0' (Scene, Time, Channel, Z, Y, X, Samples). We then map by name.
30
+ """
31
+ czi = czifile.CziFile(path)
32
+ try:
33
+ # Force the full, predictable layout instead of relying on squeeze.
34
+ czi._squeeze = False
35
+ arr = czi.asarray()
36
+ # Read the true axis order if exposed; otherwise fall back to the
37
+ # observed canonical layout for this czifile version.
38
+ axes = getattr(czi, "axes", None)
39
+ finally:
40
+ czi.close()
41
+
42
+ # czifile's full (unsqueezed) array follows a fixed dimension order. For the
43
+ # current library version this is S, C, T, Z, Y, X, Samples. The trailing
44
+ # axis is samples-per-pixel (1=gray, 3=RGB, 4=RGBA).
45
+ if not axes or len(axes) != arr.ndim:
46
+ axes = "SCTZYX0"
47
+ if arr.ndim != len(axes):
48
+ # Fallback: pad/trim by leading singleton dims so the named map still works.
49
+ while arr.ndim < len(axes):
50
+ arr = arr[np.newaxis, ...]
51
+ if arr.ndim > len(axes):
52
+ # Collapse any extra leading singleton dims.
53
+ extra = arr.ndim - len(axes)
54
+ for _ in range(extra):
55
+ if arr.shape[0] == 1:
56
+ arr = arr[0]
57
+ else:
58
+ break
59
+ return arr, axes
60
+
61
+
62
+ def _to_uint8(plane):
63
+ """
64
+ Scale a 2D (or 2D+samples) plane to uint8 for PNG output.
65
+
66
+ Uses per-plane min/max contrast stretch, which is the most useful default
67
+ for fluorescence microscopy where raw intensities rarely span the full range.
68
+ """
69
+ plane = np.asarray(plane)
70
+ if plane.dtype == np.uint8:
71
+ return plane
72
+
73
+ plane = plane.astype(np.float64)
74
+ lo = float(plane.min())
75
+ hi = float(plane.max())
76
+ if hi <= lo:
77
+ return np.zeros(plane.shape, dtype=np.uint8)
78
+ scaled = (plane - lo) / (hi - lo) * 255.0
79
+ return scaled.round().clip(0, 255).astype(np.uint8)
80
+
81
+
82
+ def _channel_names(path, n_channels):
83
+ """Best-effort channel names from metadata; falls back to C0, C1, ..."""
84
+ names = []
85
+ try:
86
+ czi = czifile.CziFile(path)
87
+ try:
88
+ md = czi.metadata()
89
+ finally:
90
+ czi.close()
91
+ # Names can repeat in metadata; keep first occurrence order, deduped.
92
+ found = re.findall(r'<Channel[^>]*?Name="([^"]+)"', md or "")
93
+ seen = []
94
+ for f in found:
95
+ if f not in seen:
96
+ seen.append(f)
97
+ names = seen
98
+ except Exception:
99
+ names = []
100
+
101
+ out = []
102
+ for i in range(n_channels):
103
+ if i < len(names):
104
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "_", names[i]).strip("_")
105
+ out.append(safe or f"C{i}")
106
+ else:
107
+ out.append(f"C{i}")
108
+ return out
109
+
110
+
111
+ def convert_czi_to_png(input_path, output_dir):
112
+ """
113
+ Convert one .czi file to a set of PNG files.
114
+
115
+ Returns a list of written PNG file paths.
116
+ """
117
+ os.makedirs(output_dir, exist_ok=True)
118
+ arr, axes = _canonical_array(input_path)
119
+
120
+ idx = {ax: axes.index(ax) for ax in axes}
121
+ nS = arr.shape[idx["S"]]
122
+ nT = arr.shape[idx["T"]]
123
+ nC = arr.shape[idx["C"]]
124
+ nZ = arr.shape[idx["Z"]]
125
+ n_samples = arr.shape[idx["0"]] # samples per pixel (1=gray, 3=RGB, 4=RGBA)
126
+
127
+ ch_names = _channel_names(input_path, nC)
128
+ base = os.path.splitext(os.path.basename(input_path))[0]
129
+ written = []
130
+
131
+ for s in range(nS):
132
+ for t in range(nT):
133
+ for c in range(nC):
134
+ for z in range(nZ):
135
+ # Slice down to a single plane (Y, X, samples).
136
+ sl = [slice(None)] * arr.ndim
137
+ sl[idx["S"]] = s
138
+ sl[idx["T"]] = t
139
+ sl[idx["C"]] = c
140
+ sl[idx["Z"]] = z
141
+ plane = arr[tuple(sl)] # leaves Y, X, and samples axes
142
+
143
+ plane = np.squeeze(plane)
144
+ if plane.ndim == 3 and plane.shape[-1] in (3, 4):
145
+ img8 = np.stack(
146
+ [_to_uint8(plane[..., k]) for k in range(plane.shape[-1])],
147
+ axis=-1,
148
+ )
149
+ mode = "RGB" if img8.shape[-1] == 3 else "RGBA"
150
+ elif plane.ndim == 2:
151
+ img8 = _to_uint8(plane)
152
+ mode = "L"
153
+ else:
154
+ # Unexpected extra dims: collapse to 2D defensively.
155
+ img8 = _to_uint8(plane.reshape(plane.shape[-2], plane.shape[-1]))
156
+ mode = "L"
157
+
158
+ parts = [base]
159
+ if nS > 1:
160
+ parts.append(f"S{s}")
161
+ if nT > 1:
162
+ parts.append(f"T{t}")
163
+ parts.append(ch_names[c])
164
+ if nZ > 1:
165
+ parts.append(f"Z{z:02d}")
166
+ fname = "_".join(parts) + ".png"
167
+ fpath = os.path.join(output_dir, fname)
168
+
169
+ Image.fromarray(img8, mode=mode).save(fpath)
170
+ written.append(fpath)
171
+
172
+ return written
173
+
174
+
175
+ # ----------------------------------------------------------------------------
176
+ # Hugging Face Gradio app
177
+ # ----------------------------------------------------------------------------
178
+ def _build_gradio_app():
179
+ import tempfile
180
+ import zipfile
181
+ import gradio as gr
182
+
183
+ def _process(file_obj):
184
+ if file_obj is None:
185
+ return None, "Please upload a .czi file."
186
+ tmp = tempfile.mkdtemp()
187
+ out_dir = os.path.join(tmp, "png")
188
+ pngs = convert_czi_to_png(file_obj.name, out_dir)
189
+
190
+ zip_path = os.path.join(tmp, "czi_pngs.zip")
191
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
192
+ for p in pngs:
193
+ zf.write(p, arcname=os.path.basename(p))
194
+
195
+ msg = f"Converted {len(pngs)} plane(s) to PNG."
196
+ return zip_path, msg
197
+
198
+ with gr.Blocks(title="CZI to PNG Converter") as demo:
199
+ gr.Markdown("# CZI to PNG Converter\nUpload a `.czi` file to get a ZIP of PNGs (one per channel/Z plane).")
200
+ inp = gr.File(label="CZI file", file_types=[".czi"])
201
+ btn = gr.Button("Convert", variant="primary")
202
+ out_file = gr.File(label="Download PNGs (ZIP)")
203
+ out_msg = gr.Textbox(label="Status")
204
+ btn.click(_process, inputs=inp, outputs=[out_file, out_msg])
205
+
206
+ return demo
207
+
208
+
209
+ if __name__ == "__main__":
210
+ import sys
211
+
212
+ # CLI mode: python czi_to_png.py input.czi [output_dir]
213
+ if len(sys.argv) >= 2 and sys.argv[1].lower().endswith(".czi"):
214
+ in_path = sys.argv[1]
215
+ out_dir = sys.argv[2] if len(sys.argv) >= 3 else "png_output"
216
+ files = convert_czi_to_png(in_path, out_dir)
217
+ print(f"Wrote {len(files)} PNG file(s) to {out_dir}/")
218
+ for f in files:
219
+ print(" ", os.path.basename(f))
220
+ else:
221
+ # App mode (Hugging Face Space)
222
+ _build_gradio_app().launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ czifile
2
+ imagecodecs
3
+ numpy
4
+ Pillow
5
+ gradio