Skip to content

Repository files navigation

cfos3d — 3D single-cell cFOS counting and reconstruction

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 .lif or OME-TIFF files in raw_lif/ or ome_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.

Why this exists

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.

Layout

  • src/convert_lif.py: .lif → OME-TIFF (TZCYX)
  • src/segment.py: segment cFOS + DAPI (default contrast; also classic / 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 mask
  • src/label_display.py: tuner display helper — keep each cell only on its brightest middle N z-planes
  • src/tissue_mesh.py: 3D visualization — cleared-tissue shell mesh, vertex shading, haze volume
  • src/cell_mesh.py: 3D visualization — one soft, volume-exact nucleus mesh per cell
  • src/video_export.py: HD mp4 writer (imageio-ffmpeg → system ffmpeg → OpenCV)
  • src/scale_overlay.py: XY + Z scale bar painted onto exported frames
  • src/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 entry
  • src/qc_napari.py: napari manual label QC
  • config.yaml: all baseline parameters

1) Environment

conda env create -f environment.yml
conda activate cfos3d

If you hit GPU issues on Apple Silicon, set runtime.use_gpu: false in config.yaml.

2) Data preparation

Option A: Convert to OME-TIFF in Fiji (already common)

Place OME-TIFF files under:

data/ome_tif/

Option B: Batch-convert .lif in Python

python src/convert_lif.py --input data/raw_lif --output data/ome_tif

Or select specific series:

python src/convert_lif.py --input data/raw_lif/sample.lif --output data/ome_tif --series 0,1,2

Leica .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¹⁸.

3) ROI is optional

ROI resolution priority: manual ROI file → automatic tissue mask → full field of view.

  • No ROI file: controlled by roi.auto in config.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.tif or data/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) or percentile
  • threshold_scale: <1 expands the ROI, >1 tightens it
  • closing_radius_vox / fill_holes / min_size_vox / keep_largest: morphological cleanup

4) Configuration

Check config.yaml first:

  • channels.cfos / channels.dapi: channel indices
  • voxel_um: fallback when OME metadata is missing
  • segmentation.method: default contrast (local contrast + hysteresis); alternatives classic / cellpose
  • segmentation.*.diameter_px: nuclear diameter in pixels (only needed for cellpose)
  • roi.auto: whether to auto-build a volumetric ROI when no ROI file is present

5) One-command run (recommended)

Pass a dataset folder; the runner derives ome_tif/, labels/, and results/ and runs segment → quantify:

python src/run.py data/260606

Or pass only the name (looked up under data/):

python src/run.py 260606

Common 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,quantify

Expected 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)

Run steps individually (equivalent)

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/results

Main 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 object
  • results/summary.csv: per-sample A (ratio) + B (density)
  • results/all_cells.csv: concatenated cell table

6) Interactive tuner (recommended for per-image thresholds)

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

How to use

  1. Pick a Sample. Method defaults to contrast (also classic).
  2. 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 planes in Advanced if needed.
    • Expand Show individual parameters under an entry for fine control; the entry shows custom if you leave the preset.
  3. Expand DAPI nuclei (denominator) to tune DAPI. Check preview DAPI, then Preview again to see the DAPI_preview overlay (slower — nuclei are numerous).
  4. Save params writes data/<dataset>/params/<stem>.yaml (includes both cFOS and DAPI parameters).
  5. Run this image runs segment + quantify for the current sample.
  6. 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 to glass shell (smooth mesh only), raw volume (MIP), or none. 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 to spheres (the older, faster screen-space markers) or real 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.0 is true size; >1 only for visibility and cubes the displayed fraction (does not change counts). z exaggeration defaults 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.
  7. 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 tilt as a slight pitch); it does not follow the current preview orientation. Output is data/<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 → system ffmpeg (libx264, CRF 18) → OpenCV mp4v. The first and last frames are not duplicates, so looping playback is continuous. Live z exaggeration is 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 = 6 it 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 to center once ndisplay == 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.
  8. After saving several samples, Run all saved (same as --steps batch) merges into results/summary_all.csv.

Symptom entries (English UI)

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 in config.yaml are min_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_circularity and xy_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,quantify

Or batch every sample that already has a params/*.yaml:

python src/run.py 260606 --steps batch

7) Manual QC in napari (optional)

One-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 0603

Equivalent 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 0

After 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 _qc

The 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.

8) Tests and CI

Headless regression tests (no napari/Qt required):

pip install -r requirements-ci.txt
pytest -q

GitHub 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

Metrics

  • 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).

Citation

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.

License

MIT — see LICENSE.

About

3D single-cell cFOS counting and reconstruction in thick, optically cleared tissue z-stacks

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages