"""Numeric functions ported from p63_thresholding_test.py. CPython 3 only.""" import numpy as np # skimage.measure.regionprops is preferred; fall back to scipy.ndimage if unavailable try: from skimage.measure import regionprops _HAVE_SKIMAGE = True except ImportError: _HAVE_SKIMAGE = False def extract_red(img, channel): """Extract channel index `channel` from a multi-channel image array; return a 2-D float64 ndarray normalised to [0, 1]. Handles both (C, H, W) and (H, W, C) layouts: if the first dimension is smaller than the last, it is treated as the channel axis (C, H, W); otherwise the last axis is used (H, W, C). """ if img.ndim == 2: ch = img elif img.ndim == 3: # Channel axis is first when its size is smaller than the spatial dims if img.shape[0] < img.shape[2]: ch = img[channel] else: ch = img[:, :, channel] else: raise ValueError("img must be 2-D or 3-D, got shape {}".format(img.shape)) ch = ch.astype(np.float64) return ch / 255.0 if ch.max() > 1.0 else ch def get_cell_data(masks, red_channel): """Compute per-cell mean intensity in red_channel for each labelled region in masks. Returns a list of dicts with keys: label, pixels, mean, coords. Background (label == 0) is ignored. """ cells = [] if _HAVE_SKIMAGE: for rp in regionprops(masks): rr, cc = rp.coords[:, 0], rp.coords[:, 1] pixels = red_channel[rr, cc] cells.append({ "label": rp.label, "pixels": pixels, "mean": float(np.mean(pixels)), "coords": (rr, cc), }) else: # scipy.ndimage fallback — slower but avoids skimage dependency import scipy.ndimage as ndi labels = np.unique(masks) labels = labels[labels != 0] for lbl in labels: coords = np.where(masks == lbl) pixels = red_channel[coords] cells.append({ "label": int(lbl), "pixels": pixels, "mean": float(np.mean(pixels)), "coords": coords, }) return cells def otsu_threshold(values, n_bins=254, cap=None): """Compute Otsu threshold over a 1-D array of intensities in [0, 1]; return the bin-centre float. Exact logic from p63_thresholding_test.py: background (v > 0) exclusion, n_bins=254 bins, sigma_b2[-1] forced to 0 to avoid last-bin artefact. Parameters ---------- values : array-like of intensities n_bins : number of histogram bins (default 254 to match validated results) cap : hard ceiling applied before histogram; activates T2/T14 attenuation variants """ v = np.asarray(values, dtype=np.float64).ravel() v = v[v > 0] # exclude background / masked pixels if cap is not None: v = np.clip(v, 0.0, cap) if v.size == 0: return 0.0 upper = cap if cap is not None else 1.0 edges = np.linspace(0.0, upper, n_bins + 1) counts, _ = np.histogram(v, bins=edges) total = counts.sum() if total == 0: return 0.0 p = counts / total omega = np.cumsum(p) # cumulative class probabilities mu = np.cumsum(p * np.arange(len(p))) # cumulative weighted means mu_t = mu[-1] # overall mean denom = omega * (1.0 - omega) sigma_b2 = (mu_t * omega - mu) ** 2 / (denom + 1e-12) sigma_b2[-1] = 0.0 # avoid artefact at last bin k = int(np.argmax(sigma_b2)) bin_centres = (edges[:-1] + edges[1:]) / 2.0 return float(bin_centres[k]) def compute_thresholds(cells, attenuation_cap=0.7, n_bins=254): """Compute T1, T2, T7, T14 thresholds from per-cell data dicts; return dict of floats in [0, 1]. T1 = pixel-level Otsu, full range T2 = pixel-level Otsu, capped at attenuation_cap T7 = cell-mean-level Otsu, full range T14 = cell-mean-level Otsu, capped at attenuation_cap (default p63+ call) """ all_pixels = np.concatenate([c["pixels"] for c in cells]) if cells else np.array([]) cell_means = np.array([c["mean"] for c in cells]) return { "T1": otsu_threshold(all_pixels, n_bins=n_bins), "T2": otsu_threshold(all_pixels, n_bins=n_bins, cap=attenuation_cap), "T7": otsu_threshold(cell_means, n_bins=n_bins), "T14": otsu_threshold(cell_means, n_bins=n_bins, cap=attenuation_cap), } def count_p63_positive(cells, threshold): """Return the count of cells whose mean red intensity >= threshold.""" return sum(1 for c in cells if c["mean"] >= threshold)