Spaces:
Running on Zero
Running on Zero
File size: 7,389 Bytes
0a81d0b 5f7abe7 0a81d0b 448496b 0a81d0b 448496b 0a81d0b 448496b 0a81d0b 448496b 0a81d0b 448496b 0a81d0b 448496b 0a81d0b 448496b 0a81d0b 448496b 0a81d0b 5f7abe7 448496b 5f7abe7 0a81d0b 448496b 0a81d0b 5f7abe7 0a81d0b 5f7abe7 0a81d0b 5f7abe7 0a81d0b | 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 | """Per-cell fluorescence quantification reproducing the analyst's ImageJ workflow.
Columns (same order as the Excel sheet 'GTTR Project Measurements'):
Cell number, Total cell area, Nucleus area, Cytoplasm area, Total Cell Int Den, Nucleus Cell Int Den,
Cytoplasm Int Den, Mean Cytoplasm Fluorescence (MCF), Background, MCF - background
Areas are in um^2 (pixel count / k^2, k = pixels per micron). IntDen = sum of 8-bit red intensities
over the region / k^2 (this is ImageJ's IntDen = area * mean). MCF = cytoplasm IntDen / cytoplasm area,
i.e. the mean 8-bit red intensity over the cytoplasm, independent of calibration.
"""
import numpy as np
from scipy import ndimage as ndi
COLUMNS = ['Cell number', 'Total cell area', 'Nucleus area', 'Cytoplasm area', 'Total Cell Int Den',
'Nucleus Cell Int Den', 'Cytoplasm Int Den', 'Mean Cytoplasm Fluorescence (MCF)', 'Background',
'MCF - background', 'Cell mean intensity (8-bit)', 'Nucleus mean intensity (8-bit)',
'Cell mean intensity (ZEN scale)', 'Nucleus mean intensity (ZEN scale)']
ZEN_ESTIMATE_8BIT_EXPORT = {'mode': 'estimate', 'add': 1.5, 'mult': 256.0,
'desc': 'estimated as (8-bit mean + 1.5) x 256, assuming an 8-bit ZEN export of a 16-bit acquisition '
'(the export maps the raw value v to floor(v / 256) - 1); checked on a ZEN-annotated image: '
'nucleus means within -0.2% to +4.1% of ZEN'}
def zen_scale_value(mean8, zen):
"""ZEN-scale intensity mean from an 8-bit mean when the raw values are not available: zen = {'mode': 'estimate',
'add': a, 'mult': m} gives (mean8 + a) * m; any other mode gives NaN."""
if zen and zen.get('mode') == 'estimate' and mean8 == mean8:
return (mean8 + float(zen['add'])) * float(zen['mult'])
return float('nan')
def assign_nuclei(cells, nuclei):
"""Return a nucleus label map re-labelled with the id of the cell each nucleus belongs to.
A nucleus goes to the cell that covers most of it (>= 50 % of its pixels); others are dropped.
Nucleus pixels outside their cell are clipped to the cell."""
out = np.zeros_like(cells)
n_per_cell = {}
for nl in np.unique(nuclei):
if nl == 0: continue
m = nuclei == nl
labs, cnt = np.unique(cells[m], return_counts=True)
order = np.argsort(cnt)[::-1]
for o in order:
if labs[o] == 0: continue
if cnt[o] >= 0.5 * m.sum():
out[m & (cells == labs[o])] = labs[o]
n_per_cell[int(labs[o])] = n_per_cell.get(int(labs[o]), 0) + 1
break
return out, n_per_cell
DARKEST_PATCH_DESC = 'darkest empty 32x32 patch outside all detected cells'
FALLBACK_DESC = 'mean of all pixels outside the detected cells (no empty 32x32 patch in this image)'
def estimate_background(red, cells, erode_px=15, method='darkest_patch', return_method=False):
"""Background intensity from pixels outside every detected cell (eroded to avoid halos).
With return_method=True returns (value, description of the estimator actually used)."""
v, desc = _estimate_background(red, cells, erode_px, method)
return (v, desc) if return_method else v
def _estimate_background(red, cells, erode_px, method):
outside = cells == 0
if erode_px:
outside = ndi.binary_erosion(outside, iterations=erode_px)
vals = red[outside]
if vals.size < 100:
vals = red[cells == 0]
if vals.size == 0:
return float('nan'), 'no pixels outside cells'
if method == 'mode':
h = np.bincount(vals.astype(np.int64).ravel(), minlength=256)
return float(np.argmax(h)), 'mode of pixels outside cells'
if method == 'p05':
return float(np.percentile(vals, 5)), '5th percentile of pixels outside cells'
if method == 'p10':
return float(np.percentile(vals, 10)), '10th percentile of pixels outside cells'
if method == 'median':
return float(np.median(vals)), 'median of pixels outside cells'
if method == 'mean':
return float(vals.mean()), 'mean of pixels outside cells'
if method == 'darkest_patch':
# mean of the darkest 32x32 window that lies entirely outside every cell
# (square erosion by 16 px keeps window centres >= 17 px, Chebyshev, from any cell pixel)
sm = ndi.uniform_filter(red.astype(float), 32, mode='reflect')
clear = ndi.binary_erosion(cells == 0, structure=np.ones((3, 3), bool), iterations=16)
sm[~clear] = np.inf
if not np.isfinite(sm).any(): return float(vals.mean()), FALLBACK_DESC
return float(sm.min()), DARKEST_PATCH_DESC
raise ValueError(f'unknown background method {method!r}')
raise ValueError(method)
def measure(red, cells, nuclei, k_px_per_um, background=None, bg_method='darkest_patch', info=None, raw=None, zen=None):
"""red: (H,W) uint8 red channel; cells/nuclei: label maps; returns (rows, background, flags).
If info (dict) is given, info['background_method'] records how the background was obtained.
ZEN-scale means: exact means of `raw` (the acquisition values before the 8-bit mapping) when given, otherwise
zen_scale_value(mean8, zen)."""
red = red.astype(np.float64)
nuc_of_cell, n_per_cell = assign_nuclei(cells, nuclei)
if background is None:
background, bg_desc = estimate_background(red, cells, method=bg_method, return_method=True)
else:
bg_desc = 'entered value'
if info is not None:
info['background_method'] = bg_desc
if not np.isfinite(k_px_per_um) or k_px_per_um <= 0:
raise ValueError('calibration (pixels per micron) must be a positive number')
k2 = float(k_px_per_um) ** 2
rows, flags = [], {}
for i, lab in enumerate(np.unique(cells)):
if lab == 0: continue
cm = cells == lab
nm = nuc_of_cell == lab
cy = cm & ~nm
ta, na, ca = cm.sum() / k2, nm.sum() / k2, cy.sum() / k2
ti, ni, ci = red[cm].sum() / k2, red[nm].sum() / k2, red[cy].sum() / k2
mcf = ci / ca if ca > 0 else float('nan')
cell_mean = float(red[cm].mean()); nuc_mean = float(red[nm].mean()) if nm.any() else float('nan')
if raw is not None:
zc = float(raw[cm].mean()); zn = float(raw[nm].mean()) if nm.any() else float('nan')
else:
zc, zn = zen_scale_value(cell_mean, zen), zen_scale_value(nuc_mean, zen)
rows.append({'Cell number': int(lab), 'Total cell area': ta, 'Nucleus area': na, 'Cytoplasm area': ca,
'Total Cell Int Den': ti, 'Nucleus Cell Int Den': ni, 'Cytoplasm Int Den': ci,
'Mean Cytoplasm Fluorescence (MCF)': mcf, 'Background': background,
'MCF - background': mcf - background,
'Cell mean intensity (8-bit)': cell_mean, 'Nucleus mean intensity (8-bit)': nuc_mean,
'Cell mean intensity (ZEN scale)': zc, 'Nucleus mean intensity (ZEN scale)': zn})
nn = n_per_cell.get(int(lab), 0)
if nn == 0: flags[int(lab)] = 'no nucleus found for this cell'
elif nn > 1: flags[int(lab)] = f'{nn} nuclei merged'
if ca <= 0: flags[int(lab)] = (flags.get(int(lab), '') + '; ' if int(lab) in flags else '') + 'no cytoplasm (nucleus covers the whole cell)'
return rows, background, flags
|