Turn fluorescence microscopy images into publication-ready quantitative data.
FluoroStats is a Python CLI tool and library that converts microscopy images — confocal z-stacks, widefield fluorescence, any major microscope format — into the numbers, statistics, and figures needed for publication. No ImageJ macros, no manual thresholding, no spreadsheet wrangling.
Fluorescence microscopy experiments often produce clear qualitative differences between conditions — more cells, denser networks, better coverage. But manuscripts require quantitative evidence: volume fractions, connectivity metrics, error bars, and p-values.
FluoroStats bridges that gap with a single command:
fluorostats quant3d --input ./my_confocal_data/ --output ./results/This produces:
- Per-file CSV with volume fraction, connectivity metrics, and skeleton analysis
- Per-condition summary with mean, std, and median across replicates
- QC overlays for visual verification of segmentation accuracy
- Publication plots — bar charts with SEM and boxplots with individual data points
- Statistical comparisons — Mann-Whitney U p-values between all condition pairs
Well suited for bioprinted constructs, tissue sections, organoids, and spheroids — any volumetric data where cell presence and network structure matter.
Metrics:
- Volume fraction — percentage of the volume containing cells
- Euler number — topological measure of network interconnectedness (more negative = more loops and tunnels)
- Largest component fraction — whether the structure forms one connected network (e.g., 97%) or many scattered clusters (e.g., 19%)
- Skeleton length, branches, junctions — extent and branching complexity of the cell network
- Depth-penetration profiles — mean intensity vs depth, blank-subtracted and surface-normalised, with area-under-curve over a physical depth window (probe diffusion / permeability assays)
Well suited for endothelial coverage, monolayer confluence, wound healing assays, and other planar cell coverage measurements.
Metrics:
- Area fraction — percentage of the surface covered by cells
- Cluster count and sizes — whether cells form one confluent sheet or many scattered patches
- Largest component fraction — degree of coverage fragmentation
pip install fluorostatsFor microscope-specific proprietary formats, add the corresponding extra:
pip install fluorostats[olympus] # .oib, .oif files
pip install fluorostats[zeiss] # .czi files
pip install fluorostats[nikon] # .nd2 files
pip install fluorostats[leica] # .lif files
pip install fluorostats[all] # all formatsTIFF, OME-TIFF, PNG, JPEG, and BMP are supported out of the box.
Each experimental condition should be in its own folder, with replicate files inside:
my_experiment/
GelMA/
sample1.oib
sample2.oib
sample3.oib
Hybrid/
sample1.oib
sample2.oib
sample3.oib
Control/
sample1.oib
sample2.oib
sample3.oib
# 3D confocal data
fluorostats quant3d --input ./my_experiment/ --output ./results_3d/
# 2D fluorescence images
fluorostats quant2d --input ./endothelial_images/ --output ./results_2d/
# depth-penetration / permeability (probe diffusion vs depth) — manifest driven
fluorostats depth ./my_experiment/manifest.jsonStart with the overlays/ folder — each image gets a QC overlay (grayscale intensity + magenta segmentation mask) for immediate visual verification.
Then inspect:
per_file.csv— measurements for every fileper_condition.csv— summary statistics grouped by conditionplots/summary_panel.png— all metrics in one composite figureplots/pvalues.csv— statistical comparisons (available when replicates are present)
Defaults are optimized for typical fluorescence microscopy, but two parameters are worth adjusting for specific datasets:
Threshold method — otsu (default for 3D) works well for bright, high-contrast signal. li (default for 2D) is better suited to dim or diffuse signal such as endothelial monolayers.
fluorostats quant3d --input ./data/ --output ./results/ --threshold liThreshold scale — scales the computed threshold by a multiplicative factor. Lower values capture more dim signal. The 3D default of 0.9 slightly favors sensitivity over specificity.
fluorostats quant3d --input ./data/ --output ./results/ --threshold-scale 0.8A recommended workflow: run with defaults, review the overlays, then adjust if needed.
fluorostats formats # check availability on the current system| Format | Microscope | Install |
|---|---|---|
.tif .tiff |
Universal / OME-TIFF / ImageJ | included |
.png .jpg .bmp |
Exported snapshots | included |
.npy |
NumPy arrays | included |
.oib .oif |
Olympus FluoView | fluorostats[olympus] |
.czi |
Zeiss ZEN | fluorostats[zeiss] |
.nd2 |
Nikon NIS-Elements | fluorostats[nikon] |
.lif |
Leica LAS X | fluorostats[leica] |
FluoroStats can also be used as a library for custom analysis workflows:
from fluorostats.io import load_auto
from fluorostats.preprocess import select_green_channel, denoise
from fluorostats.segment import binarize
from fluorostats.metrics_3d import volume_fraction, connectivity_metrics, skeleton_metrics
# Load any supported format (auto-detected)
arr, meta = load_auto("my_sample.czi")
# Extract the fluorescence channel (auto-detects 488nm, FITC, GFP, etc.)
green = select_green_channel(arr, meta["channel_names"])
green = denoise(green)
# Segment — otsu/li/isodata/triangle/yen/mean, or "auto" (picks per histogram) /
# "consensus" (majority vote of all six). Otsu under-segments dim/sparse signal;
# "auto" switches to Li there, "consensus" is a no-guess robust fallback.
mask = binarize(green, method="otsu", threshold_scale=0.9)
# Measure
print(f"Volume fraction: {volume_fraction(mask):.1%}")
print(f"Connectivity: {connectivity_metrics(mask)}")
print(f"Skeleton: {skeleton_metrics(mask, meta['voxel_size_um'])}")Beyond the per-file metrics, FluoroStats exposes the toolkit needed to run a full statistical analysis directly from the library — homogeneity, density normalisation, multi-group statistics, power planning, publication styling, and 3D / slide-style rendering.
from fluorostats.morphometry import (
lateral_homogeneity, depth_profile, depth_span, depth_centroid,
)
hom = lateral_homogeneity(green, tiles=8) # Gini + CV across an 8x8 XY grid
prof = depth_profile(green) # mean intensity vs z slice
span = depth_span(green, voxel_size_um=meta["voxel_size_um"])
cent = depth_centroid(green, voxel_size_um=meta["voxel_size_um"])Useful for "is the signal uniformly distributed?" and "how deep do cells penetrate?" questions without committing to a binary mask.
from fluorostats import depth
# mean intensity vs physical depth (slice 0 = surface)
sig = depth.intensity_depth_profile(vol, meta["voxel_size_um"][0])
blank = depth.intensity_depth_profile(no_fluo_vol, blank_meta["voxel_size_um"][0])
sig = depth.subtract_background(sig, blank) # depth-matched blank subtraction
sig = depth.normalize_to_surface(sig, n_surface=3) # surface (first 3 slices) = 1
auc = depth.auc_depth(sig.depth_um, sig.normalized, z_min=0, z_max=100) # µm·unitsTurns a z-stack into mean-intensity-vs-depth, subtracts a no-fluo control (interpolated depth-for-depth, so blank and signal need not share slicing), normalises to the near-surface signal, and integrates over a physical depth window. Built for probe-penetration / permeability assays (e.g. FITC-dextran diffusion, "does material A retain signal deeper than material B?").
For a batch, manifest-driven workflow — many stacks grouped by condition, matched blanks, multiple AUC windows, tidy CSVs + publication figures — run:
fluorostats depth manifest.json # or: from fluorostats.depth_batch import runThe JSON manifest lists the groups, stacks, blanks, and AUC windows (fully generic; nothing hardcoded). See fluorostats.depth_batch for the schema.
from fluorostats.objects import (
label_3d, equivalent_diameters_um, object_density_per_mm3, centroid_homogeneity,
)
labels, n = label_3d(mask, min_size=64)
diams_um = equivalent_diameters_um(labels, meta["voxel_size_um"])
density = object_density_per_mm3(n, mask.shape, meta["voxel_size_um"])
centroids = object_centroids(labels)
spatial = centroid_homogeneity(centroids, mask.shape, tiles=8)Right tool for nuclei sizing (DAPI), cluster-size distributions, and centroid-based homogeneity checks.
from fluorostats.metrics_3d import normalise_skeleton_metrics
skel = skeleton_metrics(mask, meta["voxel_size_um"])
skel = normalise_skeleton_metrics(skel, mask.shape, meta["voxel_size_um"])
# adds length_density_um_per_mm3, junction_density_per_mm3, branch_density_per_mm3Use whenever stacks have different voxel sizes — counts/lengths per FOV are not comparable, but per-mm³ densities are.
from fluorostats.skeleton import skeleton_metrics, prune_skeleton, n_junction_nodes
# Opt-in spur pruning (removes short branches like AnalyzeSkeleton/AngioTool/REAVER)
m = skeleton_metrics(mask, meta["voxel_size_um"], prune=True, min_branch_length_um=5)
m["n_junction_nodes"] # degree>=3 nodes — the AngioTool/REAVER branchpoint definition
m["n_junctions"] # junction-to-junction branches (skan convention)Works on 2D or 3D masks. prune=False (default) preserves raw skeleton behaviour; prune=True removes short spurs before counting so junction/branch counts match the conventions used by Fiji AnalyzeSkeleton, AngioTool, and REAVER. n_junction_nodes is the field-standard branchpoint count (degree ≥ 3 nodes).
from fluorostats.agreement import agreement_report, bland_altman, lins_ccc, icc
rep = agreement_report(fluorostats_values, ground_truth, "fluorostats", "manual")
# -> bias, 95% limits of agreement, Lin's CCC, ICC, Spearman, Pearson, MAPEFor validating fluorostats (or any method) against ground truth or another tool — Bland-Altman limits of agreement, Lin's concordance correlation coefficient, and ICC(A,1) absolute agreement in one call.
from fluorostats.viability import (
live_dead_fractions, live_dead_by_count, viability_depth_profile,
viability_2d_vs_3d, attenuation_correct,
)
frac = live_dead_fractions(live_channel, dead_channel) # area-based live/dead fraction + viability
prof = viability_depth_profile(live_channel, dead_channel) # per-z live/dead — depth gradient
cmp = viability_2d_vs_3d(live_channel) # how much a mid-plane / MIP overestimates the true 3D fraction
corr = attenuation_correct(volume) # per-z normalisation: biological death vs optical decay
# count-based viability — robust to crowding (area under-reports when cells overlap)
cnt = live_dead_by_count(live_channel, dead_channel, method="all")
# -> {'by_method': {cc/watershed/maxima}, 'consensus': median, 'spread': disagreement}Quantifies the Calcein-AM / PI (live/dead) assay in a depth-aware way. In thick 3D samples a single plane or a maximum-intensity projection overestimates viability and misses depth-dependent core death — viability_2d_vs_3d measures that overestimation and viability_depth_profile exposes the gradient. live_dead_by_count counts cells instead of area (via cc, watershed, prominence-maxima, a transparent auto, or all+consensus); on a published synthetic Live/Dead set the maxima mode matches the reference Fiji macro exactly (CCC 0.987). No single counting mode is universally best — maxima excels on crowded single-peak cells, cc is most noise-robust — so pick by regime or use all.
from fluorostats.validate import instance_f1, average_precision
f1 = instance_f1(pred_labels, gt_labels, iou_threshold=0.5) # F1, precision, recall, mean IoU
ap = average_precision(pred_labels, gt_labels) # mean AP over IoU 0.5–0.9 (DSB2018 convention)Score a labeled prediction against a labeled ground truth with the standard DSB2018 / Cell Tracking Challenge instance metrics — lets fluorostats validate itself (or any tool) against reference annotations in-library.
from fluorostats.objects import watershed_split, clear_border_labels
labels, n = watershed_split(mask, min_distance=5) # separate touching nuclei/cells
labels, n = clear_border_labels(labels) # drop partial edge objects for unbiased countsConnected-component labeling merges touching objects; watershed_split seeds a distance-transform watershed to separate them (the training-free fix for crowded fields), and clear_border_labels removes edge objects that would bias counts.
from fluorostats.stats import (
stratified_mann_whitney, bootstrap_fold_change_ci,
stouffer_combine, scheirer_ray_hare, cliffs_delta, bh_fdr,
)
# Stratified Mann-Whitney + BH-FDR across (region × metric)
stats_df = stratified_mann_whitney(
df, value_cols=["volume_fraction", "length_density_um_per_mm3"],
group_col="material", group_a="GelMA", group_b="Hybrid",
strata=["day", "region"],
)
# Distribution-free fold-change interval
ci = bootstrap_fold_change_ci(gelma_vf, hybrid_vf, n_boot=5000)
# {"fold_change_median": 9.5, "ci_low": 2.5, "ci_high": 38.0, ...}
# Pool independent evidence (e.g. across modalities)
pooled = stouffer_combine([p_live_dead, p_immuno])
# Non-parametric 2-way ANOVA on ranks (Scheirer-Ray-Hare)
anova = scheirer_ray_hare(df, value_col="lateral_gini",
factor_a="material", factor_b="day")from fluorostats.power import bootstrap_power, power_curve, fdr_power_curve
# How many replicates do I need to clear FDR at q < 0.05?
curve = power_curve(samples_a, samples_b,
ns=[4, 6, 8, 10, 12, 15, 20], n_sims=1000)
# Joint power under BH-FDR across multiple metrics
multi = fdr_power_curve(samples_per_metric_a, samples_per_metric_b,
ns=[4, 8, 12, 20], n_sims=500)from fluorostats.style import apply_style, PALETTE, material_color
apply_style() # clean typography, spines, grid, palette
color = material_color("Hybrid") # consistent per-condition colorsapply_style() opts every subsequent figure into a modern publication look — call it once at the top of a script. PALETTE / MATERIAL_COLORS give a consistent color story, and DARK_PALETTE drives the black-background 3D look below.
from fluorostats.render3d import render_isosurface, render_voxel_cloud, save_isosurface
# Light publication look on a physical-µm grid
save_isosurface(mask, "out/iso.png", voxel_size_um=meta["voxel_size_um"],
color="#d62728", downsample=(1, 4, 4), scalebar_um=100)
# Dark, smooth, shaded mesh (reference-figure style)
render_isosurface(mask, voxel_size_um=meta["voxel_size_um"],
style="dark", smooth_iter=6, shade=True,
color="#F5C518", scalebar_um=100)Marching-cubes isosurface on a physical-micrometre grid. style="dark" renders on black with a faint box and white scalebar; smooth_iter applies Laplacian mesh smoothing for continuous tubes; shade=True adds Lambertian per-face shading for depth. render_voxel_cloud is a chunky voxel variant.
from fluorostats.render3d import live_dead_mip, mip_grid, layer_split_mip, depth_coded_mip
# Two-channel raw MIP (green live + red dead), gamma + percentile clipped
live_dead_mip(live_channel, dead_channel, ax=ax)
# Grid of MIPs — any cell renderer, any {(row, col): kwargs} mapping
fig = mip_grid(cells, rows=["top", "middle", "bottom"], cols=conditions,
render_func=live_dead_mip)
# Top vs middle layer split of one volume (depth-resolved comparison)
layer_split_mip(volume, ax_top=ax1, ax_bot=ax2, split=0.5)
# Depth-coded MIP — single-panel 3D intuition via colormap
depth_coded_mip(volume, ax=ax, cmap="viridis")These build the qualitative image panels (raw Live/Dead grids, top-vs-middle layer views, depth maps) straight from the intensity volume — useful alongside the quantitative figures.
from fluorostats.plots import effect_size_heatmap, forest_plot, modality_panel
effect_size_heatmap(stats_df, "out/heatmap.png",
row_col="metric", col_col="region",
value_col="cliffs_delta", sig_col="sig_q05")
forest_plot(bootstrap_ci_df, "out/forest.png",
label_col="metric", center_col="fold_change_median",
lo_col="ci_low", hi_col="ci_high", log_scale=True)
modality_panel(df, metrics=["volume_fraction", "length_density_um_per_mm3"],
modality_col="modality", out_path="out/modality.png")fluorostats quant3d
| Option | Default | Description |
|---|---|---|
--input |
required | Folder containing volume files |
--output |
required | Output folder for results |
--condition-from |
parent |
Label source: parent folder, grandparent, or filename |
--channel |
auto | Force channel by index or name |
--threshold |
otsu |
Thresholding method: otsu or li |
--threshold-scale |
0.9 |
Scale threshold (lower = more sensitive) |
--min-size |
64 |
Min object size in voxels |
--sigma |
1.0 |
Gaussian blur sigma |
--bg-radius |
0 |
Background subtraction radius (0 = off) |
--no-skeleton |
off | Skip skeleton analysis (faster) |
--no-overlays |
off | Skip QC overlay images |
--no-plots |
off | Skip comparison plots |
fluorostats quant2d
| Option | Default | Description |
|---|---|---|
--input |
required | Folder containing image files |
--output |
required | Output folder for results |
--condition-from |
parent |
Label source: parent folder, grandparent, or filename |
--channel |
auto | Force channel by index or name |
--threshold |
li |
Thresholding method: otsu or li |
--threshold-scale |
1.0 |
Scale threshold (lower = more sensitive) |
--min-size |
64 |
Min object size in pixels |
--sigma |
1.0 |
Gaussian blur sigma |
--bg-radius |
15 |
Background subtraction radius (0 = off) |
--auto-crop |
on | Auto-crop microscope software borders |
--no-overlays |
off | Skip QC overlay images |
--no-plots |
off | Skip comparison plots |
MIT
