Count and reconstruct cFOS+ cells in thick, optically cleared tissue z-stacks. Nothing here is tied to one region, species or clearing protocol: any compact 3D sample works — ganglia, brain nuclei, organoids, cleared tissue blocks.
This repository contains code only — no microscopy images. Create a dataset folder under
data/<session>/and place your.lifor OME-TIFF files inraw_lif/orome_tif/respectively. Output directories (labels/,results/,viz/, etc.) are created automatically.
Every sample yields two numbers:
- A:
cFOS+ / DAPI(activation ratio) - B:
cFOS+ / mm³(absolute density, over the tissue volume actually analysed)
...plus a 3D reconstruction of the same objects that were counted, exportable as a looping HD spin video with calibrated scale bars.
Input is Leica .lif (optional Python batch conversion) or OME-TIFF already converted in Fiji.
Confocal stacks of thick cleared samples break the usual 2D counting workflow in three specific ways, and cfos3d is built around those three problems:
- Cells span many z-planes. Counting plane by plane double-counts them. Here each cell is a single 3D object across the whole stack.
- Acquisition settings drift between animals, and one marker channel cannot be thresholded automatically across groups. Instead of exposing raw parameter names, the tuner asks you to pick the sentence that matches what looks wrong — "dim positive cells are missed", "too many small bright specks" — and drag one strength slider. Settings are saved per image, so per-animal differences stay explicit and reproducible rather than hidden in a global threshold.
- "Cells per field of view" is meaningless when tissue fills only part of the stack. An automatic volumetric ROI follows the sample's own fluorescence, so density is per mm³ of tissue, not per mm³ of empty stack.
Reconstruction is a first-class output, not a screenshot afterthought: the counted objects are rendered as one soft mesh nucleus each inside a frosted cleared-tissue shell, with XY and Z scale bars measured from the OME voxel size and painted onto the exported frames.
Any nuclear-localised marker plus a nuclear counterstain will work. cfos and dapi are simply the channel names in config.yaml — none of the maths is cFOS-specific.
src/convert_lif.py:.lif → OME-TIFF (TZCYX)src/segment.py: segment cFOS + DAPI (defaultcontrast; alsoclassic/cellpose; auto or manual ROI)src/quantify.py: per-cell tables and summary (A ratio + B density)src/roi_utils.py: ROI resolution and automatic tissue masksrc/label_display.py: tuner display helper — keep each cell only on its brightest middle N z-planessrc/tissue_mesh.py: 3D visualization — cleared-tissue shell mesh, vertex shading, haze volumesrc/cell_mesh.py: 3D visualization — one soft, volume-exact nucleus mesh per cellsrc/video_export.py: HD mp4 writer (imageio-ffmpeg → system ffmpeg → OpenCV)src/scale_overlay.py: XY + Z scale bar painted onto exported framessrc/tuner.py: napari interactive tuner (symptom-oriented strength sliders → Save / Run / 3D / spin video)src/tuner_presets.py: strength 0–10 → parameter mappings for each symptom entrysrc/qc_napari.py: napari manual label QCconfig.yaml: all baseline parameters
conda env create -f environment.yml
conda activate cfos3dIf you hit GPU issues on Apple Silicon, set runtime.use_gpu: false in config.yaml.
Place OME-TIFF files under:
data/ome_tif/
python src/convert_lif.py --input data/raw_lif --output data/ome_tifOr select specific series:
python src/convert_lif.py --input data/raw_lif/sample.lif --output data/ome_tif --series 0,1,2Leica .lif coordinates are typically in meters. Conversion writes OME PhysicalSize* in µm with units. The reader also converts meter-like values, so density is not understated by 10¹⁸.
ROI resolution priority: manual ROI file → automatic tissue mask → full field of view.
- No ROI file: controlled by
roi.autoinconfig.yaml:roi.auto: true(default): build a volumetric tissue ROI from the sample’s own fluorescence (max of cFOS + DAPI). Good for compact, roughly ball-shaped samples — no need to draw per slice.roi.auto: false: use the entire FOV (all voxels).
- Manual ROI: place a file matching the image stem (
ZYX, same shape as that timepoint volume):data/roi/<image_stem>_roi.tifordata/roi/<image_stem>_roi.npy
segment.py writes the ROI actually used as data/labels/<image_stem>_roi_used.ome.tif for napari inspection; quantify.py reuses it by default so segmentation and counting share the same ROI.
Key auto-ROI parameters (roi section in config.yaml):
threshold_method:otsu(default) orpercentilethreshold_scale:<1expands the ROI,>1tightens itclosing_radius_vox/fill_holes/min_size_vox/keep_largest: morphological cleanup
Check config.yaml first:
channels.cfos/channels.dapi: channel indicesvoxel_um: fallback when OME metadata is missingsegmentation.method: defaultcontrast(local contrast + hysteresis); alternativesclassic/cellposesegmentation.*.diameter_px: nuclear diameter in pixels (only needed forcellpose)roi.auto: whether to auto-build a volumetric ROI when no ROI file is present
Pass a dataset folder; the runner derives ome_tif/, labels/, and results/ and runs segment → quantify:
python src/run.py data/260606Or pass only the name (looked up under data/):
python src/run.py 260606Common variants:
# Segment only / quantify only
python src/run.py data/260606 --steps segment
python src/run.py data/260606 --steps quantify
# Start from .lif (needs data/260606/raw_lif/): convert + segment + quantify
python src/run.py data/260606 --steps convert,segment,quantifyExpected dataset layout:
data/260606/
raw_lif/ # optional; .lif files (for the convert step)
ome_tif/ # OME-TIFF inputs
params/ # auto: per-image configs <stem>.yaml from the tuner (tune/batch)
labels/ # auto: segmentation outputs + *_roi_used
results/ # auto: CSVs (<sample>_summary.csv and batch summary_all.csv)
python src/segment.py --config config.yaml --input data/260606/ome_tif --output data/260606/labels
python src/quantify.py --config config.yaml --input data/260606/ome_tif --labels data/260606/labels --output data/260606/resultsMain outputs:
labels/*_cfos_labels.ome.tif,*_dapi_labels.ome.tif,*_roi_used.ome.tif(inspect ROI in napari)results/<sample>_cells.csv: one row per cell objectresults/summary.csv: per-sample A (ratio) + B (density)results/all_cells.csv: concatenated cell table
Acquisition settings differ across images, and with only the 488 channel there is no fully automatic cross-group solution. The tuner lets you tune each image by symptom, then run the rest of the pipeline.
python src/run.py 260606 --steps tune
# equivalent: python src/tuner.py data/260606- Pick a Sample. Method defaults to
contrast(alsoclassic). - Open a symptom entry that matches what you see, drag its Strength slider (0–10), then click Preview.
- Preview always segments the full z-stack (counts stay consistent).
- By default, label display = core planes: each kept cFOS object is shown only on its brightest middle 2 z-slices (view-only; does not change counts or saved labels). Switch to
all planesin Advanced if needed. - Expand Show individual parameters under an entry for fine control; the entry shows
customif you leave the preset.
- Expand DAPI nuclei (denominator) to tune DAPI. Check preview DAPI, then Preview again to see the
DAPI_previewoverlay (slower — nuclei are numerous). - Save params writes
data/<dataset>/params/<stem>.yaml(includes both cFOS and DAPI parameters). - Run this image runs segment + quantify for the current sample.
- Visualize in 3D builds an interactive napari 3D view. Display only — does not change counts. Click again (Back to 2D) to return.
- Tissue defaults to cleared tissue: a frosted grey mesh shell (
translucent_no_depth, never occludes cells) plus a faint additive internal haze, similar to an iDISCO-cleared sample. Advanced → 3D tissue can switch toglass shell(smooth mesh only),raw volume (MIP), ornone. tissue opacity controls the shell; tissue haze controls the internal glow. - cFOS cells default to nuclei: a real mesh (one soft, slightly irregular ellipsoid per cell) instead of identical CGI balls. Each nucleus gets a volume-preserving axis jitter, a random orientation and a smooth surface undulation, and every mesh is rescaled so it still encloses exactly the measured cell volume at
cell size x = 1.0. Shading is pure Lambert — ambient + diffuse with the specular term switched off — which is what removes the wet-plastic look and gives the chalky falloff of tissue. All nuclei share one uniform GFP-like green, so depth reads from shading, not color. Advanced → 3D cells can switch tospheres(the older, faster screen-space markers) orreal shape(iso labels). - The tissue shell is shaded the same way (highlight-free, ambient-dominated), because cleared tissue scatters light rather than reflecting it.
- tissue opacity / tissue haze / cell size x / z exaggeration adjust the view live. Status bar shows the true cells/tissue volume fraction (often ~20%).
cell size x = 1.0is true size;>1only for visibility and cubes the displayed fraction (does not change counts).z exaggerationdefaults to 1× true physical aspect — this dataset’s stack is often a flat lens (~580 × 543 × 92 µm); raise z exaggeration (~6×) if you want a rounder look.
- Tissue defaults to cleared tissue: a frosted grey mesh shell (
- Export spin video (after Preview; will enter 3D if needed) writes a seamless looping HD mp4 of one full 360° rotation around the stack Z axis. The camera is reset to a fixed view (Z vertical on screen,
movie tiltas a slight pitch); it does not follow the current preview orientation. Output isdata/<dataset>/viz/<stem>_spin_z6.mp4. Defaults: 12 s, 30 fps, 1080p 16:9, movie z exag = 6, tilt = +18° (negative tilt looks up). Encoder fallback:imageio-ffmpeg→ systemffmpeg(libx264, CRF 18) → OpenCVmp4v. The first and last frames are not duplicates, so looping playback is continuous. Livez exaggerationis restored after export.- Each video carries an L-shaped scale bar in the lower-left, built from the OME-TIFF voxel size. The horizontal bar is true XY microns; the vertical bar runs along the stack z axis, so with
movie z exag = 6it is drawn six times longer than the distance it is labelled with — that is deliberate, it makes the stretch you applied measurable instead of hidden. A third line prints the voxel size. Bar lengths are auto-rounded to 1/2/5/10/25/50/100… µm to fit the frame. - The bar is painted onto the finished frames (
src/scale_overlay.py), not added as scene layers, so thickness, font size, text width and line spacing are all exact. Scene text could not do this: napari sizes text through the canvas DPI (so it renders larger than requested), it collapses every text anchor tocenteroncendisplay == 3, and a 3D vector is meshed as two orthogonal quads — one degenerate, the other able to end up edge-on and invisible, which is why the z bar disappeared. Pixels-per-micron is measured, not modelled: two probe markers a known distance apart are rendered into one throw-away frame and located, which sidesteps the camera-zoom / device-pixel-ratio / supersampling chain. If that probe ever fails the export falls back to the camera zoom and says so in the status bar.
- Each video carries an L-shaped scale bar in the lower-left, built from the OME-TIFF voxel size. The horizontal bar is true XY microns; the vertical bar runs along the stack z axis, so with
- After saving several samples, Run all saved (same as
--steps batch) merges intoresults/summary_all.csv.
| Entry | Strength slider adjusts |
|---|---|
| Raw image too dark to judge | Display brightness only (does not change segmentation) |
| Dim positive cells are missed | Sensitivity (threshold_scale / k_high+k_low) |
| Too many small bright specks | Speck rejection (opening + min size filters) |
| Cells merged or over-split | Split aggressiveness (watershed on/off + peak distance) |
| Large blobs or autofluorescence counted | Large-object rejection (max_voxels) |
| Irregular (non-round) signals counted | Roundness requirement (min_xy_circularity, min_xy_solidity) |
| DAPI: nucleus size scale | DAPI matched-filter / peak distance (nuclei ≪ cFOS) |
| DAPI: nuclei missed (too dark) | DAPI sensitivity |
| DAPI: too many tiny noise dots | DAPI speck rejection (much smaller voxel thresholds) |
Advanced (collapsed): label display mode / core-plane N, show-all-z overlay, z-slab, 3D cell mode, tissue mode / opacity / haze, cell size, z exaggeration, spin-video settings, and a read-only summary of current effective values.
Notes:
- cFOS and DAPI are both tunable; DAPI preview is opt-in via preview DAPI.
- Strength level 5 on every cFOS symptom slider is the calibrated starting point (from per-image tuning). Drag left/right from there.
- Roundness is measured on each object’s largest XY slice (
4πA/P²circularity + solidity). 3D extent is unreliable under anisotropic z; defaults inconfig.yamlaremin_xy_circularity: 0.40,min_xy_solidity: 0.65(Roundness level 5). If real cells are dropped, lower Roundness requirement; if irregular blobs remain, raise it. - Per-cell CSV adds columns
xy_circularityandxy_solidity. - Core-plane label display and 3D visualization are view-only; on-disk labels and quantification always use the full 3D object.
- To reuse one saved YAML on another dataset without copying files:
python src/run.py data/260606_2 \
--config "data/260606/params/<stem>.ome.yaml" \
--steps segment,quantifyOr batch every sample that already has a params/*.yaml:
python src/run.py 260606 --steps batchOne-command open (paths are assembled automatically; channel order is read from config.yaml):
# Open the first sample in the dataset
python src/run.py 260606 --steps qc
# With multiple samples, select by --sample substring
python src/run.py 260606 --steps qc --sample 0603Equivalent full command:
python src/qc_napari.py \
--image data/260606/ome_tif/<sample>.ome.tif \
--cfos-label data/260606/labels/<sample>.ome_cfos_labels.ome.tif \
--dapi-label data/260606/labels/<sample>.ome_dapi_labels.ome.tif \
--cfos-channel 1 \
--dapi-channel 0After editing labels in napari, press Shift-S to save:
<sample>_cfos_labels_qc.ome.tif<sample>_dapi_labels_qc.ome.tif
Then re-quantify with the QC suffix (default quantify fails if labels are missing):
python src/run.py 260606 --steps quantify --label-suffix _qcThe per-sample summary records cfos_label_file, dapi_label_file, roi_source, and voxel_source for audit trails. roi_source is one of file (manual ROI), auto (tissue mask), full (entire FOV), or roi_used(legacy) for masks saved before provenance sidecars existed.
Headless regression tests (no napari/Qt required):
pip install -r requirements-ci.txt
pytest -qGitHub Actions runs the same suite on every push/PR to main. Tests cover pure-noise rejection, partial OME voxel metadata, QC label suffixes, stale output cleanup, and ROI provenance.
To compare counts before and after a segmentation fix on your local datasets:
python tools/before_after_report.py --out-dir data/_before_after_report- A (ratio):
cfos_over_dapi_ratio = (# cFOS+) / (# DAPI nuclei) - B (density):
cfos_density_per_mm3 = (# cFOS+) / analysis_volume_mm3, where analysis volume comes from the ROI actually used (auto tissue mask or manual ROI; full FOV if neither applies).
If you use this pipeline in published work, please cite the repository and acknowledge the Oka Lab. A formal publication citation will be added here when available.
MIT — see LICENSE.