Spaces:
Runtime error
Runtime error
Commit ·
b6b0463
0
Parent(s):
first commit
Browse files- .gitignore +151 -0
- README.md +38 -0
- app.py +529 -0
- main.py +794 -0
- 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
|