Spaces:
Running on Zero
Running on Zero
File size: 7,164 Bytes
f86d93e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """
size_report.py - cell size distribution report for CellposeCellCounter (v6)
After the concentration, report the size of the counted cells the way a
commercial counter does: a Total / Live / Dead table (percentage, concentration,
count, average size) and a live/dead size histogram in micrometres.
Sizes are equivalent diameters (diameter of the circle with the mask's area),
converted to um with the 1 mm x 1 mm counting block, so they are only available
for squares cropped with the 4-point block crop. They are pooled over all
squares; the concentrations use the mean count per square, exactly as the
concentration block above them does.
Pure numpy; matplotlib for the histogram.
"""
import numpy as np
HIST_BIN_UM = 1.0 # histogram bin width, um
HIST_MAX_UM = 60.0 # right edge of the histogram (objects above go in the last bin)
def cell_diameters_um(masks, label_map, um_per_px):
"""{'live': [...], 'dead': [...]} equivalent diameters in um.
`label_map` is {cell_id: 0 live, 1 dead} from the viability step. Cells the
viability step did not score are left out, so the size counts always match
the live/dead counts.
"""
counts = np.bincount(np.asarray(masks).ravel().astype(np.int64))
live, dead = [], []
for cid, lab in (label_map or {}).items():
cid = int(cid)
if cid <= 0 or cid >= counts.size or counts[cid] == 0:
continue
d = 2.0 * np.sqrt(counts[cid] / np.pi) * float(um_per_px)
(dead if int(lab) == 1 else live).append(round(float(d), 3))
return {"live": live, "dead": dead, "um_per_px": float(um_per_px)}
def pool(results):
"""Pool per-square size dicts. Returns (live, dead, n_squares_with_sizes)."""
live, dead, n = [], [], 0
for r in results:
s = (r or {}).get("sizes")
if not s:
continue
live += list(s.get("live", []))
dead += list(s.get("dead", []))
n += 1
return np.asarray(live, float), np.asarray(dead, float), n
def _stats(a):
if a.size == 0:
return {"n": 0, "mean": None, "median": None, "sd": None}
return {"n": int(a.size), "mean": float(a.mean()),
"median": float(np.median(a)),
"sd": float(a.std(ddof=1)) if a.size > 1 else 0.0}
def _sci(x):
"""2.05 x 10^6 style."""
if not x:
return "0"
e = int(np.floor(np.log10(abs(x))))
return f"{x / 10 ** e:.2f} x 10^{e}"
def summarize(results, conc_presets):
"""Size summary over all squares.
Returns a dict with per-class stats plus `n_squares` (squares that had
sizes) and `mean_per_square` counts used for the concentrations, or None if
no square carries sizes.
"""
live, dead, n = pool(results)
if n == 0:
return None
total = np.concatenate([live, dead])
out = {"n_squares": n, "live": live, "dead": dead, "total": total,
"stats": {"Total": _stats(total), "Live": _stats(live),
"Dead": _stats(dead)},
"conc_presets": list(conc_presets)}
return out
def format_table(summary):
"""Monospace text table in the layout of the commercial counter report."""
if summary is None:
return ("Size distribution needs the 4-point block crop (it sets the "
"um scale). No square had one.")
st, n = summary["stats"], summary["n_squares"]
tot = st["Total"]["n"] or 0
presets = summary["conc_presets"]
head = f"{'':<7}{'Percent':>8}"
for name, mult in presets:
# chamber factor is 10^4 per mL, so the dilution is mult / 10^4
head += f"{('Conc (' + format(mult / 1e4, 'g') + 'x dil.)'):>19}"
head += f"{'Count':>7}{'Avg size':>11}{'Median':>9}{'SD':>7}"
lines = [head, "-" * len(head)]
for cls in ("Total", "Live", "Dead"):
s = st[cls]
pct = "" if cls == "Total" else (f"{100 * s['n'] / tot:.0f}%" if tot else "-")
row = f"{cls:<7}{pct:>8}"
for _, mult in presets:
row += f"{(_sci(s['n'] / n * mult) + ' /mL'):>19}"
row += f"{s['n']:>7}"
row += (f"{s['mean']:>9.2f}um{s['median']:>9.2f}{s['sd']:>7.2f}"
if s["mean"] is not None else f"{'-':>11}{'-':>9}{'-':>7}")
lines.append(row)
lines.append("")
lines.append(f"Count = cells in {n} square{'s' if n != 1 else ''} pooled; "
f"concentration = count / {n} x factor.")
lines.append("Size = equivalent diameter (circle of equal area), um, "
"scale from the 1 mm block.")
lines.append("These match the concentration above when every square "
"was block-cropped.")
return "\n".join(lines)
def histogram_counts(summary, bin_um=HIST_BIN_UM, max_um=HIST_MAX_UM):
"""(edges, live_counts, dead_counts); values above max_um fall in the last bin."""
edges = np.arange(0.0, max_um + bin_um, bin_um)
clip = lambda a: np.clip(a, 0, max_um - 1e-6)
lc, _ = np.histogram(clip(summary["live"]), bins=edges)
dc, _ = np.histogram(clip(summary["dead"]), bins=edges)
return edges, lc, dc
def render_histogram(summary, bin_um=HIST_BIN_UM, max_um=HIST_MAX_UM):
"""PIL image: live (green) and dead (red) size histogram, stacked."""
import io
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image
fig, ax = plt.subplots(figsize=(6.0, 3.2), dpi=120)
if summary is not None and summary["total"].size:
edges, lc, dc = histogram_counts(summary, bin_um, max_um)
centres = edges[:-1] + bin_um / 2
ax.bar(centres, lc, width=bin_um * 0.9, color="#2ca02c",
label=f"Live n={summary['stats']['Live']['n']}")
ax.bar(centres, dc, width=bin_um * 0.9, bottom=lc, color="#d62728",
label=f"Dead n={summary['stats']['Dead']['n']}")
for cls, col in (("Live", "#2ca02c"), ("Dead", "#d62728")):
m = summary["stats"][cls]["mean"]
if m is not None:
ax.axvline(m, color=col, lw=1, ls="--")
txt = " ".join(f"{c} avg {summary['stats'][c]['mean']:.2f} um"
for c in ("Live", "Dead")
if summary["stats"][c]["mean"] is not None)
ax.set_title(txt, fontsize=9)
ax.legend(fontsize=8, frameon=False)
else:
ax.text(0.5, 0.5, "no sized cells", ha="center", va="center",
transform=ax.transAxes, color="grey")
ax.set_xlim(0, max_um)
ax.set_xlabel("Cell size, equivalent diameter (um)")
ax.set_ylabel("Cell quantity")
for s in ("top", "right"):
ax.spines[s].set_visible(False)
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png")
plt.close(fig)
buf.seek(0)
return Image.open(buf).convert("RGB")
def csv_rows(summary, bin_um=HIST_BIN_UM, max_um=HIST_MAX_UM):
"""Histogram as rows for a CSV: bin_start_um, bin_end_um, live, dead, total."""
if summary is None:
return []
edges, lc, dc = histogram_counts(summary, bin_um, max_um)
return [[f"{edges[i]:g}", f"{edges[i + 1]:g}", int(lc[i]), int(dc[i]),
int(lc[i] + dc[i])] for i in range(len(lc))]
|