pr28416 commited on
Commit
b6b0463
·
0 Parent(s):

first commit

Browse files
Files changed (5) hide show
  1. .gitignore +151 -0
  2. README.md +38 -0
  3. app.py +529 -0
  4. main.py +794 -0
  5. requirements.txt +10 -0
.gitignore ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+ MANIFEST
27
+
28
+ # PyInstaller
29
+ *.manifest
30
+ *.spec
31
+
32
+ # Installer logs
33
+ pip-log.txt
34
+ pip-delete-this-directory.txt
35
+
36
+ # Unit test / coverage reports
37
+ htmlcov/
38
+ .tox/
39
+ .nox/
40
+ .coverage
41
+ .coverage.*
42
+ .cache
43
+ nosetests.xml
44
+ coverage.xml
45
+ *.cover
46
+ .hypothesis/
47
+ .pytest_cache/
48
+
49
+ # Translations
50
+ *.mo
51
+ *.pot
52
+
53
+ # Django stuff:
54
+ *.log
55
+ local_settings.py
56
+ db.sqlite3
57
+
58
+ # Flask stuff:
59
+ instance/
60
+ .webassets-cache
61
+
62
+ # Scrapy stuff:
63
+ .scrapy
64
+
65
+ # Sphinx documentation
66
+ docs/_build/
67
+
68
+ # PyBuilder
69
+ target/
70
+
71
+ # Jupyter Notebook
72
+ .ipynb_checkpoints
73
+
74
+ # IPython
75
+ profile_default/
76
+ ipython_config.py
77
+
78
+ # pyenv
79
+ .python-version
80
+
81
+ # celery beat schedule file
82
+ celerybeat-schedule
83
+
84
+ # SageMath parsed files
85
+ *.sage.py
86
+
87
+ # Environments
88
+ .env
89
+ .venv
90
+ env/
91
+ venv/
92
+ ENV/
93
+ env.bak/
94
+ venv.bak/
95
+
96
+ # Spyder project settings
97
+ .spyderproject
98
+ .spyproject
99
+
100
+ # Rope project settings
101
+ .ropeproject
102
+
103
+ # mkdocs documentation
104
+ /site
105
+
106
+ # mypy
107
+ .mypy_cache/
108
+ .dmypy.json
109
+ dmypy.json
110
+
111
+ # Pyre type checker
112
+ .pyre/
113
+
114
+ # OS generated files
115
+ .DS_Store
116
+ .DS_Store?
117
+ ._*
118
+ .Spotlight-V100
119
+ .Trashes
120
+ ehthumbs.db
121
+ Thumbs.db
122
+
123
+ # IDE files
124
+ .vscode/
125
+ .idea/
126
+ *.swp
127
+ *.swo
128
+ *~
129
+
130
+ # Streamlit
131
+ .streamlit/
132
+
133
+ # Temporary files and directories
134
+ tmp/
135
+ temp/
136
+ *.tmp
137
+ *.bak
138
+
139
+ # Image files (if you don't want to commit test images)
140
+ *.tif
141
+ *.tiff
142
+ *.png
143
+ *.jpg
144
+ *.jpeg
145
+
146
+ # Preview directories
147
+ *__previews/
148
+ cortex__previews/
149
+
150
+ # Logs
151
+ *.log
README.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Visual Cortex - Cell Detection Tool
2
+
3
+ A Streamlit web application for automated detection and counting of circular cells in microscopy images.
4
+
5
+ ## Features
6
+
7
+ - **Multi-channel TIFF support**: Load and preview 4-channel microscopy images
8
+ - **Interactive parameter tuning**: Real-time adjustment of detection parameters
9
+ - **Slice preview**: Test settings on small image regions for fast iteration
10
+ - **Advanced detection pipeline**: Uses thresholding, morphological operations, and watershed segmentation
11
+ - **Export results**: Download annotated images and detection data as CSV
12
+
13
+ ## Usage
14
+
15
+ 1. Upload a .tif/.tiff microscopy image
16
+ 2. Preview different channels to select the best one for analysis
17
+ 3. Adjust detection parameters using the settings panel
18
+ 4. Test on a small slice first for quick feedback
19
+ 5. Run full detection when satisfied with parameters
20
+ 6. Download results (overlay image + CSV data)
21
+
22
+ ## Detection Parameters
23
+
24
+ - **Threshold method**: How to separate cells from background (percentile/otsu/sauvola)
25
+ - **Cell separation**: Split touching cells using watershed segmentation
26
+ - **Filtering**: Remove false positives based on shape and contrast
27
+ - **Size constraints**: Set minimum cell diameter in microns
28
+
29
+ ## Local Development
30
+
31
+ ```bash
32
+ pip install -r requirements.txt
33
+ streamlit run app.py
34
+ ```
35
+
36
+ ## Deployment
37
+
38
+ This app is designed to run on Streamlit Community Cloud with up to 1GB file uploads supported.
app.py ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import tempfile
4
+
5
+ # typing imports removed
6
+
7
+ import numpy as np # type: ignore # noqa: F401
8
+ import streamlit as st # type: ignore
9
+ import imageio.v3 as iio # type: ignore
10
+ import plotly.express as px # type: ignore
11
+ from skimage.transform import resize # type: ignore
12
+ from streamlit_cropper import st_cropper # type: ignore
13
+ from PIL import Image # type: ignore
14
+
15
+ from main import inspect_and_preview, _count_dots_on_preview
16
+
17
+
18
+ st.set_page_config(page_title="Visual Cortex - Circle Detector", layout="wide")
19
+ st.title("Visual Cortex - Circle Detection")
20
+
21
+ # Upload first
22
+ uploaded = st.file_uploader("Upload .tif/.tiff image", type=["tif", "tiff"])
23
+
24
+
25
+ # Helper to render settings panel next to slice preview
26
+ def render_settings_panel():
27
+ st.subheader("Settings")
28
+
29
+ # Basic image parameters
30
+ with st.expander("📏 Image dimensions", expanded=True):
31
+ col1, col2 = st.columns(2)
32
+ with col1:
33
+ width_um = st.number_input(
34
+ "Width (µm)", value=1705.6, help="Physical width of the scan."
35
+ )
36
+ with col2:
37
+ height_um = st.number_input(
38
+ "Height (µm)", value=1706.81, help="Physical height of the scan."
39
+ )
40
+
41
+ col3, col4 = st.columns(2)
42
+ with col3:
43
+ min_diam_um = st.number_input(
44
+ "Min diameter (µm)",
45
+ value=10.0,
46
+ help="Ignore circles smaller than this size.",
47
+ )
48
+ with col4:
49
+ downsample = st.slider(
50
+ "Speed",
51
+ 1,
52
+ 4,
53
+ 2,
54
+ help="Higher = faster preview, slightly less detail.",
55
+ )
56
+
57
+ # Detection parameters
58
+ with st.expander("🎯 Detection", expanded=True):
59
+ threshold_mode = st.selectbox(
60
+ "Threshold method",
61
+ ["percentile", "otsu", "sauvola"],
62
+ help="How we separate bright cells from background.",
63
+ )
64
+
65
+ col1, col2 = st.columns(2)
66
+ with col1:
67
+ thresh_percent = st.slider(
68
+ "Percentile (%)",
69
+ 50,
70
+ 99,
71
+ 72,
72
+ help="Lower to include dimmer cells (percentile mode).",
73
+ )
74
+ with col2:
75
+ threshold_scale = st.slider(
76
+ "Threshold scale",
77
+ 0.5,
78
+ 1.5,
79
+ 0.8,
80
+ help="Fine‑tune sensitivity around the threshold.",
81
+ )
82
+
83
+ # Cell separation parameters
84
+ with st.expander("✂️ Cell separation", expanded=False):
85
+ seed_mode = st.selectbox(
86
+ "Split method",
87
+ ["both", "distance", "log"],
88
+ help="How centers are found to split touching cells.",
89
+ )
90
+
91
+ col1, col2 = st.columns(2)
92
+ with col1:
93
+ ws_footprint = st.slider(
94
+ "Split tightness",
95
+ 1,
96
+ 9,
97
+ 4,
98
+ help="Larger splits clustered cells more aggressively.",
99
+ )
100
+ min_sep_px = st.slider(
101
+ "Seed spacing", 0, 6, 2, help="Minimum spacing between seeds."
102
+ )
103
+ with col2:
104
+ log_threshold = st.slider(
105
+ "Seed strength", 0.0, 0.1, 0.02, help="Raise to reduce spurious seeds."
106
+ )
107
+ closing_radius = st.slider(
108
+ "Fill gaps", 0, 5, 2, help="Fills tiny holes along cell edges."
109
+ )
110
+
111
+ # Filtering parameters
112
+ with st.expander("🔍 Filtering", expanded=False):
113
+ col1, col2 = st.columns(2)
114
+ with col1:
115
+ circularity_min = st.slider(
116
+ "Roundness filter",
117
+ 0.0,
118
+ 1.0,
119
+ 0.18,
120
+ help="Lower accepts more irregular shapes.",
121
+ )
122
+ with col2:
123
+ min_contrast = st.slider(
124
+ "Contrast filter",
125
+ 0.0,
126
+ 0.2,
127
+ 0.03,
128
+ help="Raise to keep only high‑contrast cells.",
129
+ )
130
+
131
+ debug = st.checkbox(
132
+ "💾 Save debug images", value=True, help="Save step-by-step processing images"
133
+ )
134
+
135
+ # Reset button
136
+ st.divider()
137
+ if st.button(
138
+ "🔄 Reset to recommended settings",
139
+ help="Restore all parameters to recommended defaults",
140
+ ):
141
+ # Clear session state to trigger reset on next render
142
+ if "_reset_settings" in st.session_state:
143
+ del st.session_state["_reset_settings"]
144
+ st.session_state["_reset_settings"] = True
145
+ st.rerun()
146
+
147
+ # Apply reset if requested
148
+ if st.session_state.get("_reset_settings", False):
149
+ st.session_state["_reset_settings"] = False
150
+ # Return default values
151
+ return (
152
+ 1705.6, # width_um
153
+ 1706.81, # height_um
154
+ 10.0, # min_diam_um
155
+ 2, # downsample
156
+ "percentile", # threshold_mode
157
+ 72, # thresh_percent
158
+ 0.8, # threshold_scale
159
+ 2, # closing_radius
160
+ "both", # seed_mode
161
+ 4, # ws_footprint
162
+ 2, # min_sep_px
163
+ 0.02, # log_threshold
164
+ 0.18, # circularity_min
165
+ 0.03, # min_contrast
166
+ True, # debug
167
+ )
168
+
169
+ return (
170
+ width_um,
171
+ height_um,
172
+ min_diam_um,
173
+ downsample,
174
+ threshold_mode,
175
+ thresh_percent,
176
+ threshold_scale,
177
+ closing_radius,
178
+ seed_mode,
179
+ ws_footprint,
180
+ min_sep_px,
181
+ log_threshold,
182
+ circularity_min,
183
+ min_contrast,
184
+ debug,
185
+ )
186
+
187
+
188
+ if uploaded is not None:
189
+ # Persist upload to a stable session temp folder to avoid regenerating on each rerun
190
+ if "_work_dir" not in st.session_state:
191
+ st.session_state["_work_dir"] = tempfile.mkdtemp()
192
+ upload_sig = (uploaded.name, getattr(uploaded, "size", None))
193
+ if st.session_state.get("_upload_sig") != upload_sig:
194
+ st.session_state["_upload_sig"] = upload_sig
195
+ in_path = os.path.join(st.session_state["_work_dir"], uploaded.name)
196
+ with open(in_path, "wb") as f:
197
+ f.write(uploaded.read())
198
+ st.session_state["_input_path"] = in_path
199
+ # Reset previews ready flag
200
+ st.session_state["_previews_ready"] = False
201
+ in_path = st.session_state.get("_input_path")
202
+
203
+ # Preview generation
204
+ st.subheader("Channel previews")
205
+
206
+ @st.cache_data(show_spinner=False)
207
+ def generate_previews(input_path: str):
208
+ return inspect_and_preview(input_path)
209
+
210
+ if not st.session_state.get("_previews_ready"):
211
+ with st.status("Generating channel previews...", expanded=True) as status:
212
+ t0 = time.time()
213
+ saved = generate_previews(in_path)
214
+ t1 = time.time()
215
+ st.session_state["_previews_ready"] = True
216
+ status.update(
217
+ label=f"Generated {len(saved)} preview images in {t1 - t0:.2f}s",
218
+ state="complete",
219
+ expanded=False,
220
+ )
221
+ else:
222
+ # Ensure previews exist without recomputation (cache hit)
223
+ _ = generate_previews(in_path)
224
+
225
+ # Find previews and show a single zoomable viewer with channel selector
226
+ prev_dir = os.path.splitext(in_path)[0] + "__previews"
227
+ options = []
228
+ paths = {}
229
+ for i in range(4):
230
+ p = os.path.join(prev_dir, f"channel{i}.png")
231
+ if os.path.exists(p):
232
+ key = f"channel{i}"
233
+ options.append(key)
234
+ paths[key] = p
235
+ comp = os.path.join(prev_dir, "composite_RGB.png")
236
+ if os.path.exists(comp):
237
+ options.append("composite_RGB")
238
+ paths["composite_RGB"] = comp
239
+
240
+ @st.cache_data(show_spinner=False)
241
+ def load_preview(path: str, max_dim: int = 2048):
242
+ img = iio.imread(path)
243
+ h, w = img.shape[:2]
244
+ scale = max(h, w) / max_dim if max(h, w) > max_dim else 1.0
245
+ if scale > 1.0:
246
+ nh, nw = int(h / scale), int(w / scale)
247
+ img = resize(img, (nh, nw), preserve_range=True, anti_aliasing=True).astype(
248
+ img.dtype
249
+ )
250
+ return img
251
+
252
+ if options:
253
+ st.subheader("Image viewer")
254
+ sel = st.selectbox("Channel", options, index=min(1, len(options) - 1))
255
+ img = load_preview(paths[sel])
256
+ fig = px.imshow(img, color_continuous_scale="gray", origin="upper")
257
+ fig.update_layout(margin=dict(l=0, r=0, t=0, b=0))
258
+ st.plotly_chart(fig, use_container_width=True)
259
+
260
+ # Slice + Settings side-by-side
261
+ left, right = st.columns([2, 1], gap="large")
262
+
263
+ # Slice selection (left)
264
+ with left:
265
+ st.subheader("Slice preview")
266
+ st.caption(
267
+ "Drag to select a slice (100–1024 px) of the current channel to preview with your settings."
268
+ )
269
+ current_path = paths.get(sel or "", None)
270
+ if current_path:
271
+ pil_img = Image.open(current_path).convert("L")
272
+ slice_img = st_cropper(pil_img, aspect_ratio=None, box_color="#00FF00")
273
+ snp = np.array(slice_img)
274
+ h, w = snp.shape[:2]
275
+ if h < 100 or w < 100:
276
+ st.warning(
277
+ "Selected slice is too small. Increase selection to at least 100×100."
278
+ )
279
+ else:
280
+ if max(h, w) > 1024:
281
+ scale = max(h, w) / 1024.0
282
+ new_h, new_w = int(h / scale), int(w / scale)
283
+ snp = resize(snp, (new_h, new_w), preserve_range=True).astype(
284
+ np.uint8
285
+ )
286
+ roi_path = os.path.join(prev_dir, "slice.png")
287
+ iio.imwrite(roi_path, snp)
288
+ if st.button("Preview on slice"):
289
+ # Get settings from session state if available, fallback to defaults
290
+ s = st.session_state.get("_settings", {})
291
+ width_um = s.get("width_um", 1705.6)
292
+ height_um = s.get("height_um", 1706.81)
293
+ min_diam_um = s.get("min_diam_um", 10.0)
294
+ downsample = s.get("downsample", 2)
295
+ threshold_mode = s.get("threshold_mode", "percentile")
296
+ thresh_percent = s.get("thresh_percent", 72.0)
297
+ threshold_scale = s.get("threshold_scale", 0.8)
298
+ closing_radius = s.get("closing_radius", 2)
299
+ seed_mode = s.get("seed_mode", "both")
300
+ ws_footprint = s.get("ws_footprint", 4)
301
+ min_sep_px = s.get("min_sep_px", 2)
302
+ log_threshold = s.get("log_threshold", 0.02)
303
+ circularity_min = s.get("circularity_min", 0.18)
304
+ min_contrast = s.get("min_contrast", 0.03)
305
+ debug = s.get("debug", True)
306
+
307
+ with st.spinner("Detecting on slice..."):
308
+ t0 = time.time()
309
+ slice_count, _ = _count_dots_on_preview(
310
+ preview_png_path=roi_path,
311
+ min_sigma=1.5,
312
+ max_sigma=6.0,
313
+ num_sigma=10,
314
+ threshold=0.03,
315
+ overlap=0.5,
316
+ downsample=downsample,
317
+ width_um=width_um,
318
+ height_um=height_um,
319
+ min_diam_um=min_diam_um,
320
+ threshold_mode=threshold_mode,
321
+ thresh_percent=float(thresh_percent),
322
+ threshold_scale=float(threshold_scale),
323
+ ws_footprint=int(ws_footprint),
324
+ circularity_min=float(circularity_min),
325
+ min_area_px=9,
326
+ max_diam_um=None,
327
+ debug=debug,
328
+ closing_radius=int(closing_radius),
329
+ min_contrast=float(min_contrast),
330
+ hmax=0.0,
331
+ seed_mode=seed_mode,
332
+ min_sep_px=int(min_sep_px),
333
+ log_threshold=float(log_threshold),
334
+ save_csv=False,
335
+ )
336
+ t1 = time.time()
337
+ st.success(
338
+ f"🎯 Found **{slice_count} cells** in slice ({t1 - t0:.2f}s)"
339
+ )
340
+ st.image(
341
+ os.path.join(prev_dir, "circles_overlay.png"),
342
+ caption="Slice overlay",
343
+ width="stretch",
344
+ )
345
+
346
+ # Settings panel (right)
347
+ with right:
348
+ (
349
+ width_um,
350
+ height_um,
351
+ min_diam_um,
352
+ downsample,
353
+ threshold_mode,
354
+ thresh_percent,
355
+ threshold_scale,
356
+ closing_radius,
357
+ seed_mode,
358
+ ws_footprint,
359
+ min_sep_px,
360
+ log_threshold,
361
+ circularity_min,
362
+ min_contrast,
363
+ debug,
364
+ ) = render_settings_panel()
365
+ # Persist settings for later use (e.g., full run)
366
+ st.session_state["_settings"] = dict(
367
+ width_um=width_um,
368
+ height_um=height_um,
369
+ min_diam_um=min_diam_um,
370
+ downsample=downsample,
371
+ threshold_mode=threshold_mode,
372
+ thresh_percent=float(thresh_percent),
373
+ threshold_scale=float(threshold_scale),
374
+ closing_radius=int(closing_radius),
375
+ seed_mode=seed_mode,
376
+ ws_footprint=int(ws_footprint),
377
+ min_sep_px=int(min_sep_px),
378
+ log_threshold=float(log_threshold),
379
+ circularity_min=float(circularity_min),
380
+ min_contrast=float(min_contrast),
381
+ debug=bool(debug),
382
+ )
383
+
384
+ # Full run (only when options/settings are active)
385
+ if options:
386
+ st.subheader("Full run")
387
+ if st.button("Run full detection with selected settings"):
388
+ # Load latest settings from session (ensures variables are defined)
389
+ s = st.session_state.get("_settings", {})
390
+ width_um = s.get("width_um", 1705.6)
391
+ height_um = s.get("height_um", 1706.81)
392
+ min_diam_um = s.get("min_diam_um", 10.0)
393
+ downsample = s.get("downsample", 2)
394
+ threshold_mode = s.get("threshold_mode", "percentile")
395
+ thresh_percent = s.get("thresh_percent", 72.0)
396
+ threshold_scale = s.get("threshold_scale", 0.8)
397
+ closing_radius = s.get("closing_radius", 2)
398
+ seed_mode = s.get("seed_mode", "both")
399
+ ws_footprint = s.get("ws_footprint", 4)
400
+ min_sep_px = s.get("min_sep_px", 2)
401
+ log_threshold = s.get("log_threshold", 0.02)
402
+ circularity_min = s.get("circularity_min", 0.18)
403
+ min_contrast = s.get("min_contrast", 0.03)
404
+ debug = s.get("debug", True)
405
+ c1_path = os.path.join(prev_dir, "channel1.png")
406
+ if not os.path.exists(c1_path):
407
+ st.error("channel1.png not found in previews")
408
+ else:
409
+ prog = st.progress(0)
410
+ prog.progress(5)
411
+ with st.spinner("Running detection..."):
412
+ t0 = time.time()
413
+ full_count, _ = _count_dots_on_preview(
414
+ preview_png_path=c1_path,
415
+ min_sigma=1.5,
416
+ max_sigma=6.0,
417
+ num_sigma=10,
418
+ threshold=0.03,
419
+ overlap=0.5,
420
+ downsample=downsample,
421
+ width_um=width_um,
422
+ height_um=height_um,
423
+ min_diam_um=min_diam_um,
424
+ threshold_mode=threshold_mode,
425
+ thresh_percent=float(thresh_percent),
426
+ threshold_scale=float(threshold_scale),
427
+ ws_footprint=int(ws_footprint),
428
+ circularity_min=float(circularity_min),
429
+ min_area_px=9,
430
+ max_diam_um=None,
431
+ debug=debug,
432
+ closing_radius=int(closing_radius),
433
+ min_contrast=float(min_contrast),
434
+ hmax=0.0,
435
+ seed_mode=seed_mode,
436
+ min_sep_px=int(min_sep_px),
437
+ log_threshold=float(log_threshold),
438
+ save_csv=True,
439
+ )
440
+ prog.progress(95)
441
+ t1 = time.time()
442
+ # Mark detection as completed and store results
443
+ overlay_path = os.path.join(prev_dir, "circles_overlay.png")
444
+ csv_path = os.path.join(prev_dir, "detections.csv")
445
+
446
+ # Read and store file data in session state to persist across reruns
447
+ st.session_state["_detection_completed"] = True
448
+ st.session_state["_detection_time"] = t1 - t0
449
+ st.session_state["_cell_count"] = full_count
450
+ st.session_state["_overlay_path"] = overlay_path
451
+
452
+ if os.path.exists(overlay_path):
453
+ with open(overlay_path, "rb") as f:
454
+ st.session_state["_overlay_data"] = f.read()
455
+
456
+ if os.path.exists(csv_path):
457
+ with open(csv_path, "rb") as f:
458
+ st.session_state["_csv_data"] = f.read()
459
+
460
+ # Show results if detection has been completed (persistent across reruns)
461
+ if st.session_state.get("_detection_completed", False):
462
+ overlay_path = st.session_state.get("_overlay_path")
463
+ csv_path = st.session_state.get("_csv_path")
464
+ detection_time = st.session_state.get("_detection_time", 0)
465
+ cell_count = st.session_state.get("_cell_count", 0)
466
+
467
+ if overlay_path and os.path.exists(overlay_path):
468
+ st.success(
469
+ f"✅ Detection completed: **{cell_count} cells found** ({detection_time:.2f}s)"
470
+ )
471
+
472
+ # Results section with better styling
473
+ st.subheader("Results")
474
+ col1, col2 = st.columns([3, 1])
475
+
476
+ with col1:
477
+ st.image(
478
+ overlay_path,
479
+ caption="Detection overlay - circles show detected cells",
480
+ width="stretch",
481
+ )
482
+
483
+ with col2:
484
+ st.markdown("### 📥 Downloads")
485
+ st.markdown("Click to download your results:")
486
+
487
+ # Download overlay image
488
+ overlay_data = st.session_state.get("_overlay_data")
489
+ if overlay_data:
490
+ st.download_button(
491
+ "🖼️ Overlay image",
492
+ data=overlay_data,
493
+ file_name="cell_detection_overlay.png",
494
+ mime="image/png",
495
+ help="Download the annotated image with detected circles",
496
+ )
497
+
498
+ # Download CSV data
499
+ csv_data = st.session_state.get("_csv_data")
500
+ if csv_data:
501
+ st.download_button(
502
+ "📊 Detection data",
503
+ data=csv_data,
504
+ file_name="cell_detection_data.csv",
505
+ mime="text/csv",
506
+ help="Download CSV with cell coordinates and properties",
507
+ )
508
+
509
+ # Clear results button
510
+ st.markdown("---")
511
+ if st.button(
512
+ "🗑️ Clear results", help="Clear detection results to run again"
513
+ ):
514
+ st.session_state["_detection_completed"] = False
515
+ # Clear all detection-related session state
516
+ for key in [
517
+ "_overlay_path",
518
+ "_csv_path",
519
+ "_detection_time",
520
+ "_overlay_data",
521
+ "_csv_data",
522
+ "_cell_count",
523
+ ]:
524
+ if key in st.session_state:
525
+ del st.session_state[key]
526
+ st.rerun()
527
+
528
+ else:
529
+ st.info("Upload a .tif to begin.")
main.py ADDED
@@ -0,0 +1,794 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from typing import List, Optional, Sequence, Tuple
5
+
6
+ import numpy as np # type: ignore
7
+ from tifffile import TiffFile # type: ignore
8
+ from skimage.exposure import rescale_intensity # type: ignore
9
+ from skimage.feature import peak_local_max, blob_log # type: ignore
10
+ from skimage.color import gray2rgb # type: ignore
11
+ from skimage.draw import circle_perimeter # type: ignore
12
+ from skimage.util import img_as_float # type: ignore
13
+ from skimage.filters import threshold_otsu, gaussian, threshold_sauvola # type: ignore
14
+ from skimage.morphology import ( # type: ignore
15
+ remove_small_objects,
16
+ remove_small_holes,
17
+ binary_closing,
18
+ disk,
19
+ )
20
+ from skimage.measure import regionprops # type: ignore
21
+ from skimage.segmentation import watershed # type: ignore
22
+ from skimage.segmentation import find_boundaries # type: ignore
23
+ import scipy.ndimage as ndi # type: ignore
24
+ from PIL import Image, ImageDraw, ImageFont # type: ignore
25
+ import imageio.v3 as iio # type: ignore
26
+
27
+
28
+ def _select_series_and_level(
29
+ series_list: Sequence,
30
+ preferred_series_index: int,
31
+ preferred_level_index: Optional[int],
32
+ max_dim: int = 2048,
33
+ ):
34
+ """
35
+ Choose a series and pyramid level that will fit comfortably in memory.
36
+
37
+ - If preferred options are provided and valid, use them.
38
+ - Otherwise choose the first series with a level whose max(Y,X) <= max_dim,
39
+ falling back to the coarsest available level.
40
+ """
41
+
42
+ if not series_list:
43
+ raise ValueError("No series found in the TIFF file.")
44
+
45
+ # Try to honor the user's preferred series and level first
46
+ if 0 <= preferred_series_index < len(series_list):
47
+ series = series_list[preferred_series_index]
48
+ levels = getattr(series, "levels", None) or [series]
49
+ if preferred_level_index is not None:
50
+ if 0 <= preferred_level_index < len(levels):
51
+ return preferred_series_index, preferred_level_index
52
+ else:
53
+ raise ValueError(
54
+ f"Requested level {preferred_level_index} is out of range for series {preferred_series_index}"
55
+ )
56
+ # Auto-pick a level for this series
57
+ best_level = _choose_level_index(levels, max_dim=max_dim)
58
+ return preferred_series_index, best_level
59
+
60
+ # Otherwise, search across series to find a good level
61
+ for s_idx, s in enumerate(series_list):
62
+ levels = getattr(s, "levels", None) or [s]
63
+ level_idx = _choose_level_index(levels, max_dim=max_dim)
64
+ if level_idx is not None:
65
+ return s_idx, level_idx
66
+
67
+ # Fallback: use the first series, coarsest level
68
+ levels = getattr(series_list[0], "levels", None) or [series_list[0]]
69
+ return 0, len(levels) - 1
70
+
71
+
72
+ def _choose_level_index(levels: Sequence, max_dim: int = 2048) -> Optional[int]:
73
+ """Pick the smallest level whose largest spatial dimension <= max_dim."""
74
+ chosen = None
75
+ for idx, level in enumerate(levels):
76
+ shape = level.shape
77
+ axes = getattr(level, "axes", None) or ""
78
+ # Determine Y, X dims
79
+ y_idx = axes.find("Y") if "Y" in axes else None
80
+ x_idx = axes.find("X") if "X" in axes else None
81
+ if y_idx is None or x_idx is None:
82
+ continue
83
+ y, x = shape[y_idx], shape[x_idx]
84
+ if max(y, x) <= max_dim:
85
+ chosen = idx
86
+ break
87
+ return chosen if chosen is not None else (len(levels) - 1 if levels else None)
88
+
89
+
90
+ def _axis_index(axes: str, axis_label: str) -> Optional[int]:
91
+ return axes.find(axis_label) if axis_label in axes else None
92
+
93
+
94
+ def _ensure_channel_first_2d(
95
+ data: np.ndarray,
96
+ axes: str,
97
+ keep_time_index: int = 0,
98
+ projection_mode: str = "max",
99
+ ) -> Tuple[np.ndarray, List[str]]:
100
+ """
101
+ Return data shaped as (C, Y, X) for preview generation.
102
+ - Select a single timepoint (if T present)
103
+ - Project Z using max or take middle slice
104
+ """
105
+ arr = data
106
+ axes_str = axes
107
+
108
+ # Handle time
109
+ t_idx = _axis_index(axes_str, "T")
110
+ if t_idx is not None and arr.shape[t_idx] > 1:
111
+ indexer = [slice(None)] * arr.ndim
112
+ indexer[t_idx] = min(keep_time_index, arr.shape[t_idx] - 1)
113
+ arr = arr[tuple(indexer)]
114
+ axes_str = axes_str.replace("T", "")
115
+
116
+ # Handle Z projection or middle slice
117
+ z_idx = _axis_index(axes_str, "Z")
118
+ if z_idx is not None and arr.shape[z_idx] > 1:
119
+ if projection_mode == "max":
120
+ arr = arr.max(axis=z_idx)
121
+ else:
122
+ mid = arr.shape[z_idx] // 2
123
+ arr = np.take(arr, indices=mid, axis=z_idx)
124
+ axes_str = axes_str.replace("Z", "")
125
+
126
+ # Ensure axes has Y and X
127
+ if "Y" not in axes_str or "X" not in axes_str:
128
+ raise ValueError(f"Cannot identify spatial axes in order: {axes_str}")
129
+
130
+ # Move channel axis to front if present; otherwise create a singleton channel
131
+ c_idx = _axis_index(axes_str, "C")
132
+ if c_idx is None:
133
+ # Insert a channel dimension at front
134
+ # Current order likely YX or others; move Y,X to last two positions
135
+ y_idx = _axis_index(axes_str, "Y")
136
+ x_idx = _axis_index(axes_str, "X")
137
+ perm = [i for i in range(arr.ndim) if i not in (y_idx, x_idx)] + [y_idx, x_idx]
138
+ arr = np.transpose(arr, perm)
139
+ r = arr[np.newaxis, ...] # (1, Y, X)
140
+ channel_names = ["channel0"]
141
+ return r, channel_names
142
+
143
+ # Reorder to C, Y, X
144
+ # Determine positions of C,Y,X in current array
145
+ current_axes = list(axes_str)
146
+ order = [c_idx, current_axes.index("Y"), current_axes.index("X")]
147
+ arr = np.transpose(arr, order)
148
+
149
+ # Try to name channels 0..C-1; OME metadata parsing could improve this later
150
+ num_c = arr.shape[0]
151
+ channel_names = [f"channel{idx}" for idx in range(num_c)]
152
+ return arr, channel_names
153
+
154
+
155
+ def _contrast_stretch(
156
+ img: np.ndarray,
157
+ low_percentile: float = 1.0,
158
+ high_percentile: float = 99.9,
159
+ ) -> np.ndarray:
160
+ """Apply percentile-based contrast stretching per-channel to uint8 range."""
161
+ if img.ndim == 2:
162
+ lo, hi = np.percentile(img, [low_percentile, high_percentile])
163
+ if hi <= lo:
164
+ return np.zeros_like(img, dtype=np.uint8)
165
+ return rescale_intensity(img, in_range=(lo, hi), out_range=(0, 255)).astype(
166
+ np.uint8
167
+ )
168
+
169
+ if img.ndim == 3:
170
+ # Assume (C, Y, X)
171
+ out = np.empty((img.shape[0], img.shape[1], img.shape[2]), dtype=np.uint8)
172
+ for c in range(img.shape[0]):
173
+ lo, hi = np.percentile(img[c], [low_percentile, high_percentile])
174
+ if hi <= lo:
175
+ out[c] = 0
176
+ else:
177
+ out[c] = rescale_intensity(
178
+ img[c], in_range=(lo, hi), out_range=(0, 255)
179
+ ).astype(np.uint8)
180
+ return out
181
+
182
+ raise ValueError("Expected 2D or 3D array for contrast stretching")
183
+
184
+
185
+ def _save_previews(
186
+ arr_cyx: np.ndarray,
187
+ channel_names: List[str],
188
+ output_dir: str,
189
+ base_name: str,
190
+ ) -> List[str]:
191
+ """Save one PNG per channel and an RGB composite if possible. Returns file paths."""
192
+ os.makedirs(output_dir, exist_ok=True)
193
+ saved_paths: List[str] = []
194
+
195
+ # Save per-channel grayscale previews
196
+ for c_idx, ch_name in enumerate(channel_names):
197
+ img8 = _contrast_stretch(arr_cyx[c_idx])
198
+ # Save without original image name prefix
199
+ out_path = os.path.join(output_dir, f"{ch_name}.png")
200
+ iio.imwrite(out_path, img8)
201
+ saved_paths.append(out_path)
202
+
203
+ # If at least 3 channels, make an RGB composite using first three channels
204
+ if arr_cyx.shape[0] >= 3:
205
+ r = _contrast_stretch(arr_cyx[0])
206
+ g = _contrast_stretch(arr_cyx[1])
207
+ b = _contrast_stretch(arr_cyx[2])
208
+ rgb = np.stack([r, g, b], axis=-1) # (Y, X, 3)
209
+ out_path = os.path.join(output_dir, "composite_RGB.png")
210
+ iio.imwrite(out_path, rgb)
211
+ saved_paths.append(out_path)
212
+
213
+ return saved_paths
214
+
215
+
216
+ def inspect_and_preview(
217
+ filepath: str,
218
+ series_index: int = 0,
219
+ level_index: Optional[int] = None,
220
+ keep_time_index: int = 0,
221
+ projection_mode: str = "max",
222
+ preview_max_dim: int = 2048,
223
+ output_dir: Optional[str] = None,
224
+ ) -> List[str]:
225
+ """
226
+ Inspect a TIFF/OME-TIFF and save quicklook previews.
227
+ Returns list of saved image paths.
228
+ """
229
+ if not os.path.exists(filepath):
230
+ raise FileNotFoundError(f"File not found: {filepath}")
231
+
232
+ with TiffFile(filepath) as tf:
233
+ print(f"Path: {filepath}")
234
+ print(f"Is OME-TIFF: {getattr(tf, 'is_ome', False)}")
235
+ print(f"Pages: {len(tf.pages)} Series: {len(tf.series)}")
236
+ for idx, s in enumerate(tf.series):
237
+ axes = getattr(s, "axes", "")
238
+ shape = getattr(s, "shape", None)
239
+ levels = getattr(s, "levels", None)
240
+ lvl_str = f" levels={len(levels)}" if levels else ""
241
+ print(f" Series {idx}: shape={shape} axes='{axes}'{lvl_str}")
242
+
243
+ # Choose series and level
244
+ s_idx, l_idx = _select_series_and_level(
245
+ tf.series, series_index, level_index, max_dim=preview_max_dim
246
+ )
247
+ series = tf.series[s_idx]
248
+ levels = getattr(series, "levels", None) or [series]
249
+ level = levels[l_idx]
250
+ print(
251
+ f"Using series {s_idx}, level {l_idx}: shape={level.shape} axes='{level.axes}'"
252
+ )
253
+
254
+ # Read the selected level into memory
255
+ arr = level.asarray()
256
+ print(f"Loaded array dtype={arr.dtype} shape={arr.shape}")
257
+
258
+ # Reorder and project to (C, Y, X)
259
+ arr_cyx, channel_names = _ensure_channel_first_2d(
260
+ arr,
261
+ level.axes,
262
+ keep_time_index=keep_time_index,
263
+ projection_mode=projection_mode,
264
+ )
265
+ print(f"Preview array shape (C,Y,X) = {arr_cyx.shape}")
266
+
267
+ # Define output directory
268
+ if output_dir is None:
269
+ parent = os.path.dirname(filepath)
270
+ stem = os.path.splitext(os.path.basename(filepath))[0]
271
+ output_dir = os.path.join(parent, f"{stem}__previews")
272
+
273
+ base_name = os.path.splitext(os.path.basename(filepath))[0]
274
+ saved = _save_previews(arr_cyx, channel_names, output_dir, base_name)
275
+ print("Saved previews:")
276
+ for p in saved:
277
+ print(f" {p}")
278
+ return saved
279
+
280
+
281
+ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
282
+ parser = argparse.ArgumentParser(
283
+ description="Inspect TIFF/OME-TIFF and export quicklook previews, or count dots on a preview image."
284
+ )
285
+ # Inspection args
286
+ parser.add_argument("--input", required=False, help="Path to .tif/.tiff file")
287
+ parser.add_argument(
288
+ "--series", type=int, default=0, help="Series index (default 0)"
289
+ )
290
+ parser.add_argument(
291
+ "--level", type=int, default=None, help="Pyramid level index; default auto"
292
+ )
293
+ parser.add_argument(
294
+ "--time", type=int, default=0, help="Time index to preview if T present"
295
+ )
296
+ parser.add_argument(
297
+ "--zproject",
298
+ choices=["max", "mid"],
299
+ default="max",
300
+ help="Z handling: maximum projection or middle slice",
301
+ )
302
+ parser.add_argument(
303
+ "--max-dim",
304
+ type=int,
305
+ default=2048,
306
+ help="Target max spatial dimension for preview level selection",
307
+ )
308
+ parser.add_argument(
309
+ "--output-dir", type=str, default=None, help="Output directory for previews"
310
+ )
311
+
312
+ # Dot counting on a PNG preview
313
+ parser.add_argument(
314
+ "--count-image",
315
+ type=str,
316
+ default=None,
317
+ help="Path to a grayscale preview PNG to count dots on",
318
+ )
319
+ parser.add_argument(
320
+ "--min-sigma",
321
+ type=float,
322
+ default=1.5,
323
+ help="Minimum sigma for LoG blob detection",
324
+ )
325
+ parser.add_argument(
326
+ "--max-sigma",
327
+ type=float,
328
+ default=6.0,
329
+ help="Maximum sigma for LoG blob detection",
330
+ )
331
+ parser.add_argument(
332
+ "--num-sigma",
333
+ type=int,
334
+ default=10,
335
+ help="Number of sigma levels between min and max",
336
+ )
337
+ parser.add_argument(
338
+ "--threshold",
339
+ type=float,
340
+ default=0.03,
341
+ help="Absolute threshold for LoG detection (0-1 after normalization)",
342
+ )
343
+ parser.add_argument(
344
+ "--overlap",
345
+ type=float,
346
+ default=0.5,
347
+ help="Blob overlap merging parameter (0-1)",
348
+ )
349
+ parser.add_argument(
350
+ "--downsample",
351
+ type=int,
352
+ default=1,
353
+ help="Integer downsample factor before detection (speedup)",
354
+ )
355
+ # Thresholding controls
356
+ parser.add_argument(
357
+ "--threshold-mode",
358
+ choices=["otsu", "percentile", "sauvola"],
359
+ default="otsu",
360
+ help="How to compute the foreground threshold",
361
+ )
362
+ parser.add_argument(
363
+ "--thresh-percent",
364
+ type=float,
365
+ default=85.0,
366
+ help="If percentile mode, use this intensity percentile (0-100)",
367
+ )
368
+ parser.add_argument(
369
+ "--threshold-scale",
370
+ type=float,
371
+ default=1.0,
372
+ help="Scale the computed threshold (e.g., 0.9 to include dimmer objects)",
373
+ )
374
+ parser.add_argument(
375
+ "--ws-footprint",
376
+ type=int,
377
+ default=5,
378
+ help="Footprint (square side) for peak-local-max in watershed splitting",
379
+ )
380
+ parser.add_argument(
381
+ "--closing-radius",
382
+ type=int,
383
+ default=0,
384
+ help="Radius for morphological closing (0 disables)",
385
+ )
386
+ parser.add_argument(
387
+ "--seed-mode",
388
+ choices=["distance", "log", "both"],
389
+ default="both",
390
+ help="How to generate watershed seeds: distance map peaks, LoG blobs, or both",
391
+ )
392
+ parser.add_argument(
393
+ "--min-sep-px",
394
+ type=int,
395
+ default=3,
396
+ help="Minimum separation (in detection pixels) between seeds",
397
+ )
398
+ parser.add_argument(
399
+ "--log-threshold",
400
+ type=float,
401
+ default=0.02,
402
+ help="LoG detection threshold (relative to image scale)",
403
+ )
404
+ parser.add_argument(
405
+ "--circularity-min",
406
+ type=float,
407
+ default=0.25,
408
+ help="Minimum circularity (4*pi*area/perimeter^2) to accept a region",
409
+ )
410
+ parser.add_argument(
411
+ "--max-diam-um",
412
+ type=float,
413
+ default=None,
414
+ help="Maximum acceptable circle diameter in microns (optional)",
415
+ )
416
+ parser.add_argument(
417
+ "--min-contrast",
418
+ type=float,
419
+ default=0.0,
420
+ help="Minimum center-minus-ring contrast (0-1 normalized) to keep a detection",
421
+ )
422
+ parser.add_argument(
423
+ "--hmax",
424
+ type=float,
425
+ default=0.0,
426
+ help="h value for h-maxima on distance map to generate more watershed markers (0 disables)",
427
+ )
428
+ parser.add_argument(
429
+ "--min-area-px",
430
+ type=int,
431
+ default=9,
432
+ help="Minimum region area in pixels (detection scale) before measurements",
433
+ )
434
+ parser.add_argument(
435
+ "--debug",
436
+ action="store_true",
437
+ help="Save intermediate images (mask, distance) to the output folder",
438
+ )
439
+ # Physical units
440
+ parser.add_argument(
441
+ "--width-um",
442
+ type=float,
443
+ default=None,
444
+ help="Image width in microns (for physical-size filtering)",
445
+ )
446
+ parser.add_argument(
447
+ "--height-um",
448
+ type=float,
449
+ default=None,
450
+ help="Image height in microns (for physical-size filtering)",
451
+ )
452
+ parser.add_argument(
453
+ "--min-diam-um",
454
+ type=float,
455
+ default=None,
456
+ help="Minimum acceptable circle diameter in microns",
457
+ )
458
+ return parser.parse_args(argv)
459
+
460
+
461
+ def _count_dots_on_preview(
462
+ preview_png_path: str,
463
+ min_sigma: float,
464
+ max_sigma: float,
465
+ num_sigma: int,
466
+ threshold: float,
467
+ overlap: float,
468
+ downsample: int,
469
+ width_um: Optional[float] = None,
470
+ height_um: Optional[float] = None,
471
+ min_diam_um: Optional[float] = None,
472
+ threshold_mode: str = "otsu",
473
+ thresh_percent: float = 85.0,
474
+ threshold_scale: float = 1.0,
475
+ ws_footprint: int = 5,
476
+ circularity_min: float = 0.25,
477
+ min_area_px: int = 9,
478
+ max_diam_um: Optional[float] = None,
479
+ debug: bool = False,
480
+ closing_radius: int = 0,
481
+ min_contrast: float = 0.0,
482
+ hmax: float = 0.0,
483
+ seed_mode: str = "both",
484
+ min_sep_px: int = 3,
485
+ log_threshold: float = 0.02,
486
+ save_csv: bool = True,
487
+ ) -> Tuple[int, str]:
488
+ if not os.path.exists(preview_png_path):
489
+ raise FileNotFoundError(f"Preview image not found: {preview_png_path}")
490
+
491
+ img_uint8 = iio.imread(preview_png_path)
492
+ if img_uint8.ndim == 3:
493
+ # if RGB, convert to grayscale by taking luminance-like mean
494
+ img_uint8 = img_uint8.mean(axis=2).astype(np.uint8)
495
+
496
+ # Keep full-resolution image for overlay drawing
497
+ img_full = img_as_float(img_uint8)
498
+
499
+ # Build detection image (optionally downsampled for speed)
500
+ if downsample > 1:
501
+ det_img = img_full[::downsample, ::downsample]
502
+ scale_factor = float(downsample)
503
+ else:
504
+ det_img = img_full
505
+ scale_factor = 1.0
506
+ # 1) Smooth and threshold to remove dark background
507
+ sm = gaussian(det_img, sigma=1.0, truncate=2.0)
508
+ # Compute threshold
509
+ out_dir_dbg = os.path.dirname(preview_png_path)
510
+ if debug:
511
+ iio.imwrite(
512
+ os.path.join(out_dir_dbg, "smooth_debug.png"),
513
+ (np.clip(sm, 0, 1) * 255).astype(np.uint8),
514
+ )
515
+ if threshold_mode == "percentile":
516
+ t = np.percentile(sm, np.clip(thresh_percent, 0.0, 100.0))
517
+ t = t * float(threshold_scale)
518
+ mask = sm > max(t, 0.0)
519
+ elif threshold_mode == "sauvola":
520
+ # Adaptive local threshold; large window to capture soft edges
521
+ window_size = max(15, int(min(sm.shape) * 0.03))
522
+ if window_size % 2 == 0:
523
+ window_size += 1
524
+ sau_t = threshold_sauvola(sm, window_size=window_size)
525
+ mask = sm > sau_t
526
+ else:
527
+ try:
528
+ t = threshold_otsu(sm)
529
+ except Exception:
530
+ t = np.percentile(sm, 90)
531
+ t = t * float(threshold_scale)
532
+ mask = sm > max(t, 0.0)
533
+ if debug:
534
+ # Save thresholded map and mask
535
+ if threshold_mode != "sauvola":
536
+ thr_img = (sm > max(t, 0.0)).astype(np.uint8) * 255
537
+ iio.imwrite(os.path.join(out_dir_dbg, "threshold_map_debug.png"), thr_img)
538
+ iio.imwrite(
539
+ os.path.join(out_dir_dbg, "mask_debug.png"), (mask.astype(np.uint8) * 255)
540
+ )
541
+ mask = remove_small_objects(mask, min_size=max(1, int(min_area_px)))
542
+ mask = remove_small_holes(mask, area_threshold=16)
543
+ if closing_radius and closing_radius > 0:
544
+ mask = binary_closing(mask, footprint=disk(int(closing_radius)))
545
+
546
+ # 2) Distance transform and watershed to split touching objects
547
+ distance = ndi.distance_transform_edt(mask)
548
+ if debug:
549
+ dm_vis = (255 * (distance / (distance.max() + 1e-6))).astype(np.uint8)
550
+ iio.imwrite(os.path.join(out_dir_dbg, "distance_debug.png"), dm_vis)
551
+
552
+ # Build seeds per seed_mode
553
+ seeds_mask = np.zeros_like(mask, dtype=bool)
554
+ if seed_mode in ("distance", "both"):
555
+ coords = peak_local_max(
556
+ distance,
557
+ footprint=np.ones((max(1, int(ws_footprint)), max(1, int(ws_footprint)))),
558
+ labels=mask,
559
+ )
560
+ if coords.size > 0:
561
+ seeds_mask[tuple(coords.T)] = True
562
+
563
+ if seed_mode in ("log", "both"):
564
+ # Estimate sigma range from physical diameter if available; otherwise fallback to generic
565
+ sigma_min = 1.5
566
+ sigma_max = 6.0
567
+ if min_diam_um is not None and width_um is not None and height_um is not None:
568
+ H_full, W_full = img_full.shape
569
+ px_x = width_um / float(W_full)
570
+ px_y = height_um / float(H_full)
571
+ px_um = np.sqrt(px_x * px_y)
572
+ min_rad_px_full = (min_diam_um / px_um) / 2.0
573
+ max_rad_px_full = min_rad_px_full * 2.5
574
+ # account for downsample
575
+ min_rad_px = min_rad_px_full / scale_factor
576
+ max_rad_px = max_rad_px_full / scale_factor
577
+ sigma_min = float(max(1.0, float(min_rad_px) / np.sqrt(2.0)))
578
+ sigma_max = float(max(sigma_min + 0.5, float(max_rad_px) / np.sqrt(2.0)))
579
+ blobs = blob_log(
580
+ sm,
581
+ min_sigma=sigma_min,
582
+ max_sigma=sigma_max,
583
+ num_sigma=10,
584
+ threshold=log_threshold,
585
+ )
586
+ # Enforce min separation by writing to seeds_mask with strides around each seed
587
+ for yx in blobs[:, :2]:
588
+ y, x = int(yx[0]), int(yx[1])
589
+ y0 = max(0, y - min_sep_px)
590
+ y1 = min(seeds_mask.shape[0], y + min_sep_px + 1)
591
+ x0 = max(0, x - min_sep_px)
592
+ x1 = min(seeds_mask.shape[1], x + min_sep_px + 1)
593
+ seeds_mask[y0:y1, x0:x1] = False
594
+ if mask[y, x]:
595
+ seeds_mask[y, x] = True
596
+
597
+ markers = ndi.label(seeds_mask & mask)[0]
598
+ if debug:
599
+ iio.imwrite(
600
+ os.path.join(out_dir_dbg, "markers_debug.png"),
601
+ (seeds_mask.astype(np.uint8) * 255),
602
+ )
603
+ # Watershed on negative smoothed intensity to better split touching bright blobs
604
+ labels_ws = watershed(-sm, markers, mask=mask)
605
+ if debug:
606
+ mark_vis = (markers > 0).astype(np.uint8) * 255
607
+ iio.imwrite(os.path.join(out_dir_dbg, "markers_debug.png"), mark_vis)
608
+ bounds = find_boundaries(labels_ws, mode="outer")
609
+ bvis = bounds.astype(np.uint8) * 255
610
+ iio.imwrite(os.path.join(out_dir_dbg, "boundaries_debug.png"), bvis)
611
+
612
+ # 3) Measure regions and filter by circularity and size
613
+ detections = []
614
+ regions = regionprops(labels_ws)
615
+ # Compute pixel size if physical dimensions provided
616
+ px_size_y_um = None
617
+ px_size_x_um = None
618
+ if width_um is not None and height_um is not None:
619
+ H_full, W_full = img_full.shape
620
+ px_size_x_um = width_um / float(W_full)
621
+ px_size_y_um = height_um / float(H_full)
622
+ min_radius_px = None
623
+ if (
624
+ min_diam_um is not None
625
+ and px_size_x_um is not None
626
+ and px_size_y_um is not None
627
+ ):
628
+ # Use geometric mean pixel size to convert diameter to pixels (full-res)
629
+ px_size_um = np.sqrt(px_size_x_um * px_size_y_um)
630
+ min_radius_px = (min_diam_um / px_size_um) / 2.0
631
+ # Convert threshold into detection-scale pixels if we downsampled
632
+ if downsample > 1:
633
+ min_radius_px = min_radius_px / float(downsample)
634
+ for r in regions:
635
+ if r.area < max(1, int(min_area_px)):
636
+ continue
637
+ perim = r.perimeter if r.perimeter > 0 else 1.0
638
+ circ = 4.0 * np.pi * (r.area / (perim * perim))
639
+ if circ < circularity_min:
640
+ continue
641
+ cy, cx = r.centroid
642
+ rad = np.sqrt(r.area / np.pi)
643
+ # Physical min size filter
644
+ if min_radius_px is not None and rad < min_radius_px:
645
+ continue
646
+ # Physical max size filter (optional)
647
+ if (
648
+ max_diam_um is not None
649
+ and px_size_x_um is not None
650
+ and px_size_y_um is not None
651
+ ):
652
+ px_size_um = np.sqrt(px_size_x_um * px_size_y_um)
653
+ max_radius_px = (max_diam_um / px_size_um) / 2.0
654
+ if downsample > 1:
655
+ max_radius_px = max_radius_px / float(downsample)
656
+ if rad > max_radius_px:
657
+ continue
658
+ # Intensity contrast test: mean(center) - mean(ring)
659
+ if min_contrast and min_contrast > 0:
660
+ r_in = int(max(1, rad * 0.8))
661
+ r_out = int(max(r_in + 1, rad * 1.3))
662
+ cyi, cxi = int(cy), int(cx)
663
+ # Extract a local patch to avoid scanning the full image
664
+ pad = int(max(r_out + 1, 8))
665
+ y0 = max(0, cyi - pad)
666
+ y1 = min(det_img.shape[0], cyi + pad + 1)
667
+ x0 = max(0, cxi - pad)
668
+ x1 = min(det_img.shape[1], cxi + pad + 1)
669
+ patch = det_img[y0:y1, x0:x1]
670
+ py, px = np.ogrid[y0:y1, x0:x1]
671
+ dist = np.sqrt((py - cyi) ** 2 + (px - cxi) ** 2)
672
+ center_mask = dist <= r_in
673
+ ring_mask = (dist > r_in) & (dist <= r_out)
674
+ if center_mask.any() and ring_mask.any():
675
+ contrast = float(patch[center_mask].mean() - patch[ring_mask].mean())
676
+ gmin, gmax = float(det_img.min()), float(det_img.max())
677
+ denom = max(1e-6, gmax - gmin)
678
+ contrast /= denom
679
+ if contrast < min_contrast:
680
+ continue
681
+ detections.append((cy, cx, rad))
682
+
683
+ count = len(detections)
684
+
685
+ # 4) Create overlay visualization and draw green circle borders
686
+ base = gray2rgb((img_full * 255).astype(np.uint8))
687
+ overlay = base.copy()
688
+ dets_full_res = []
689
+ for y, x, r in detections:
690
+ yf, xf, rf = float(y), float(x), float(r)
691
+ if downsample > 1:
692
+ yf = yf * float(scale_factor)
693
+ xf = xf * float(scale_factor)
694
+ rf = rf * float(scale_factor)
695
+ rr, cc = circle_perimeter(
696
+ int(yf), int(xf), max(int(rf), 1), shape=overlay.shape[:2]
697
+ )
698
+ overlay[rr, cc] = [0, 255, 0]
699
+ dets_full_res.append((yf, xf, rf))
700
+
701
+ # 5) Draw total count at top-right
702
+ pil_img = Image.fromarray(overlay)
703
+ draw = ImageDraw.Draw(pil_img)
704
+ text = str(count)
705
+ try:
706
+ font = ImageFont.load_default()
707
+ except Exception:
708
+ font = None
709
+ try:
710
+ bbox = draw.textbbox((0, 0), text, font=font)
711
+ text_w = bbox[2] - bbox[0]
712
+ text_h = bbox[3] - bbox[1]
713
+ except Exception:
714
+ # Fallback dimensions
715
+ text_w, text_h = (len(text) * 8, 12)
716
+ pad = 10
717
+ _, W = overlay.shape[0], overlay.shape[1]
718
+ x0 = W - text_w - pad
719
+ y0 = pad
720
+ draw.rectangle([x0 - 4, y0 - 2, x0 + text_w + 4, y0 + text_h + 2], fill=(0, 0, 0))
721
+ draw.text((x0, y0), text, fill=(0, 255, 0), font=font)
722
+ overlay = np.array(pil_img)
723
+
724
+ out_dir = os.path.dirname(preview_png_path)
725
+ # Write CSV of detections (full-res coordinates) if requested
726
+ if save_csv:
727
+ try:
728
+ csv_path = os.path.join(out_dir, "detections.csv")
729
+ with open(csv_path, "w") as f:
730
+ f.write("y,x,r\n")
731
+ for yf, xf, rf in dets_full_res:
732
+ f.write(f"{yf:.3f},{xf:.3f},{rf:.3f}\n")
733
+ except Exception:
734
+ pass
735
+ out_path = os.path.join(out_dir, "circles_overlay.png")
736
+ iio.imwrite(out_path, overlay)
737
+ print(f"Circle count: {count}")
738
+ print(f"Overlay saved: {out_path}")
739
+ return count, out_path
740
+
741
+
742
+ def main(argv: Optional[Sequence[str]] = None) -> int:
743
+ args = parse_args(argv)
744
+ try:
745
+ # Dot counting mode if --count-image is provided
746
+ if args.count_image:
747
+ _count_dots_on_preview(
748
+ preview_png_path=args.count_image,
749
+ min_sigma=args.min_sigma,
750
+ max_sigma=args.max_sigma,
751
+ num_sigma=args.num_sigma,
752
+ threshold=args.threshold,
753
+ overlap=args.overlap,
754
+ downsample=args.downsample,
755
+ width_um=args.width_um,
756
+ height_um=args.height_um,
757
+ min_diam_um=args.min_diam_um,
758
+ threshold_mode=args.threshold_mode,
759
+ thresh_percent=args.thresh_percent,
760
+ threshold_scale=args.threshold_scale,
761
+ ws_footprint=args.ws_footprint,
762
+ circularity_min=args.circularity_min,
763
+ min_area_px=args.min_area_px,
764
+ debug=args.debug,
765
+ closing_radius=args.closing_radius,
766
+ min_contrast=args.min_contrast,
767
+ hmax=args.hmax,
768
+ max_diam_um=args.max_diam_um,
769
+ )
770
+ return 0
771
+
772
+ # Otherwise, require --input for inspection
773
+ if not args.input:
774
+ raise ValueError(
775
+ "Either --input (TIFF) or --count-image (PNG) must be provided."
776
+ )
777
+
778
+ inspect_and_preview(
779
+ filepath=args.input,
780
+ series_index=args.series,
781
+ level_index=args.level,
782
+ keep_time_index=args.time,
783
+ projection_mode=args.zproject,
784
+ preview_max_dim=args.max_dim,
785
+ output_dir=args.output_dir,
786
+ )
787
+ return 0
788
+ except Exception as exc:
789
+ print(f"Error: {exc}")
790
+ return 1
791
+
792
+
793
+ if __name__ == "__main__":
794
+ sys.exit(main())
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit>=1.28.0
2
+ numpy>=1.24.0
3
+ tifffile>=2023.7.0
4
+ imagecodecs>=2023.1.0
5
+ scikit-image>=0.20.0
6
+ scipy>=1.10.0
7
+ Pillow>=9.5.0
8
+ imageio>=2.28.0
9
+ plotly>=5.14.0
10
+ streamlit-cropper>=0.2.1