import argparse import re from pathlib import Path import cv2 import numpy as np import pandas as pd import tifffile as tif from scipy.ndimage import binary_fill_holes from skimage.color import hdx_from_rgb, separate_stains from skimage.segmentation import watershed from skimage.util import img_as_float32, img_as_ubyte from cleaning import INPAINT_RADIUS, _as_rgb_uint8, create_debris_mask from ihc_qc import save_qc_image IMAGE_EXTENSIONS = {".tif", ".tiff"} DEFAULT_OUTPUT_PATH = Path("out/quantification/ihc_quantification.csv") MAX_BOUNDARY_CONTACT_RATIO = 0.3 WATERSHED_MARKER_CORE_FRACTION = 0.55 WATERSHED_MINIMUM_RELATIVE_REGION_AREA = 0.5 CONCENTRATION_PATTERN = re.compile( r"^\d+(?:[.,]\d+)?(?:pM|nM|uM|µM|μM|mM)$", re.IGNORECASE, ) MAGNIFICATION_PATTERN = re.compile(r"^\d+(?:[.,]\d+)?x$", re.IGNORECASE) MARKER_PATTERN = re.compile(r"^M\d+(?:[.,]\d+)?$", re.IGNORECASE) def parse_filename(image_path: str | Path) -> dict: parts = Path(image_path).stem.split() if len(parts) < 5: raise ValueError( f"Could not read metadata from filename: {Path(image_path).name}" ) magnification_index = next( ( index for index in range(len(parts) - 1, 1, -1) if MAGNIFICATION_PATTERN.fullmatch(parts[index]) ), None, ) if magnification_index is None or magnification_index < 3: raise ValueError( f"Could not read magnification from filename: {Path(image_path).name}" ) trailing_tokens = parts[magnification_index + 1 :] number_indices = [ index for index, token in enumerate(trailing_tokens) if token.isdigit() ] if len(number_indices) != 1: raise ValueError( f"Could not read a unique image number from filename: " f"{Path(image_path).name}" ) number_index = number_indices[0] image_number = int(trailing_tokens[number_index]) image_note = " ".join( token for index, token in enumerate(trailing_tokens) if index != number_index ) metadata_end = magnification_index if MARKER_PATTERN.fullmatch(parts[magnification_index - 1]): metadata_end -= 1 metadata_tokens = parts[2:metadata_end] treatment_tokens = [ token for token in metadata_tokens if (token == "+" or not token.startswith("+")) and not CONCENTRATION_PATTERN.fullmatch(token) ] if not treatment_tokens: raise ValueError( f"Could not read treatment from filename: {Path(image_path).name}" ) treatment = re.sub(r"\s*\+\s*", "+", " ".join(treatment_tokens)) return { "image_name": Path(image_path).name, "image_path": str(Path(image_path).resolve()), "treatment": treatment, "image_note": image_note, "image_number": image_number, } def read_original_rgb(image_path: str) -> np.ndarray: image_path = Path(image_path) if image_path.suffix.lower() in {".tif", ".tiff"}: return tif.imread(image_path) def extract_dab(rgb: np.ndarray) -> np.ndarray: stains = separate_stains(rgb, hdx_from_rgb) dab = stains[..., 1].astype(np.float32) return dab def split_touching_spheroids( tissue_mask: np.ndarray, min_area: int, marker_core_fraction: float = WATERSHED_MARKER_CORE_FRACTION, minimum_relative_region_area: float = WATERSHED_MINIMUM_RELATIVE_REGION_AREA, ) -> np.ndarray: if not 0 < marker_core_fraction <= 1: raise ValueError( "marker_core_fraction must be greater than 0 and at most 1." ) if not 0 <= minimum_relative_region_area <= 1: raise ValueError( "minimum_relative_region_area must be between 0 and 1." ) count, components, stats, _ = cv2.connectedComponentsWithStats( tissue_mask.astype(np.uint8), connectivity=8, ) kept_components = [ component for component in range(1, count) if stats[component, cv2.CC_STAT_AREA] >= min_area ] filtered_mask = np.isin(components, kept_components) distance = cv2.distanceTransform( filtered_mask.astype(np.uint8), cv2.DIST_L2, 5, ) marker_mask = np.zeros(filtered_mask.shape, dtype=np.uint8) for component in kept_components: component_mask = components == component maximum_distance = distance[component_mask].max() marker_mask[ component_mask & (distance >= marker_core_fraction * maximum_distance) ] = 1 _, markers = cv2.connectedComponents(marker_mask, connectivity=8) candidate_labels = watershed( -distance, markers, mask=filtered_mask, ).astype(np.int32) labels = np.zeros(candidate_labels.shape, dtype=np.int32) next_label = 1 for component in kept_components: component_mask = components == component regions = [ region for region in np.unique(candidate_labels[component_mask]) if region != 0 ] region_areas = [ int((candidate_labels[component_mask] == region).sum()) for region in regions ] split_is_balanced = len(regions) > 1 and min( region_areas ) >= minimum_relative_region_area * max(region_areas) if not split_is_balanced: labels[component_mask] = next_label next_label += 1 continue for region in regions: labels[component_mask & (candidate_labels == region)] = next_label next_label += 1 return labels def segment_spheroids( preview: np.ndarray, marker_core_fraction: float = WATERSHED_MARKER_CORE_FRACTION, minimum_relative_region_area: float = WATERSHED_MINIMUM_RELATIVE_REGION_AREA, ) -> np.ndarray: gray = cv2.cvtColor(preview, cv2.COLOR_RGB2GRAY) # thresholding C = 15 block_size = round(min(gray.shape) * 0.14) block_size = block_size - 1 if block_size % 2 == 0 else block_size tissue_thresh = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size, C ) # filtering opening_kernel = 5 # largest noise I want removed opening_kernel = cv2.getStructuringElement( cv2.MORPH_ELLIPSE, (opening_kernel, opening_kernel), ) tissue_thresh = cv2.morphologyEx( tissue_thresh, cv2.MORPH_OPEN, opening_kernel, ) closing_kernel = 51 # smallest element I want to connect closing_kernel = cv2.getStructuringElement( cv2.MORPH_ELLIPSE, (closing_kernel, closing_kernel), ) tissue_thresh = cv2.morphologyEx( tissue_thresh, cv2.MORPH_CLOSE, closing_kernel, ) tissue_filled = binary_fill_holes(tissue_thresh > 0).astype(np.uint8) * 255 min_area_fraction = ( 0.0025 # component must occupy at least 0.25% of the complete image ) min_area = round(preview.shape[0] * preview.shape[1] * min_area_fraction) split_labels = split_touching_spheroids( tissue_filled > 0, min_area, marker_core_fraction=marker_core_fraction, minimum_relative_region_area=minimum_relative_region_area, ) components = [] for component in np.unique(split_labels): if component == 0: continue y, x = np.nonzero(split_labels == component) if len(x) >= min_area: components.append((component, y.mean(), x.mean())) components.sort(key=lambda component: (component[1], component[2])) labels = np.zeros(split_labels.shape, dtype=np.int32) for spheroid_id, (component, _, _) in enumerate(components, start=1): labels[split_labels == component] = spheroid_id return labels def measure_spheroids( labels: np.ndarray, debris_mask: np.ndarray, dab: np.ndarray, positive_threshold: None | float = None, max_boundary_contact_ratio: float = MAX_BOUNDARY_CONTACT_RATIO, ) -> list[dict]: if max_boundary_contact_ratio < 0: raise ValueError("max_boundary_contact_ratio must be nonnegative.") measurements = [] debris = debris_mask > 0 image_boundary = np.zeros(labels.shape, dtype=bool) image_boundary[[0, -1], :] = True image_boundary[:, [0, -1]] = True for spheroid_id in np.unique(labels): if spheroid_id == 0: continue spheroid = labels == spheroid_id valid = spheroid & ~debris spheroid_area = int(spheroid.sum()) valid_area = int(valid.sum()) debris_area = int((spheroid & debris).sum()) boundary_contact_px = int((spheroid & image_boundary).sum()) equivalent_diameter = float(2 * np.sqrt(spheroid_area / np.pi)) boundary_contact_ratio = boundary_contact_px / equivalent_diameter touches_image_boundary = boundary_contact_px > 0 exceeds_boundary_tolerance = bool( boundary_contact_ratio > max_boundary_contact_ratio ) if valid_area == 0: continue dab_values = dab[valid] result = { "spheroid_id": int(spheroid_id), "touches_image_boundary": touches_image_boundary, "exceeds_boundary_tolerance": exceeds_boundary_tolerance, "boundary_contact_ratio": boundary_contact_ratio, "spheroid_area_px": spheroid_area, "valid_area_px": valid_area, "debris_area_px": debris_area, "debris_fraction": debris_area / spheroid_area, "mean_dab": dab_values.mean(), "median_dab": np.median(dab_values), "p75_dab": np.percentile(dab_values, 75), "p90_dab": np.percentile(dab_values, 90), "p95_dab": np.percentile(dab_values, 95), "p99_dab": np.percentile(dab_values, 99), } if positive_threshold is not None: positive = valid & (dab >= positive_threshold) positive_values = dab[positive] result["positive_fraction"] = positive.sum() / valid.sum() result["positive_area_px"] = positive.sum() result["positive_mean"] = ( positive_values.mean() if positive_values.size else np.nan ) measurements.append(result) return measurements def quantify_image( image_path: str | Path, qc_path: str | Path | None = None, positive_threshold: float | None = None, max_boundary_contact_ratio: float = MAX_BOUNDARY_CONTACT_RATIO, marker_core_fraction: float = WATERSHED_MARKER_CORE_FRACTION, minimum_relative_region_area: float = WATERSHED_MINIMUM_RELATIVE_REGION_AREA, ) -> list[dict]: metadata = parse_filename(image_path) original = read_original_rgb(image_path) processing_preview = _as_rgb_uint8(original) qc_preview = img_as_ubyte(original) debris_mask = create_debris_mask(qc_preview) cleaned_preview = cv2.inpaint( processing_preview, debris_mask, INPAINT_RADIUS, cv2.INPAINT_TELEA, ) spheroid_labels = segment_spheroids( cleaned_preview, marker_core_fraction=marker_core_fraction, minimum_relative_region_area=minimum_relative_region_area, ) if not spheroid_labels.max(): raise ValueError( "No spheroids were detected. Review the QC segmentation settings." ) float_img = img_as_float32(original) dab = extract_dab(float_img) measurements = measure_spheroids( spheroid_labels, debris_mask, dab, positive_threshold=positive_threshold, max_boundary_contact_ratio=max_boundary_contact_ratio, ) if qc_path is not None: boundary_spheroid_ids = { measurement["spheroid_id"] for measurement in measurements if measurement["exceeds_boundary_tolerance"] } save_qc_image( qc_path, qc_preview, spheroid_labels, debris_mask, dab, boundary_spheroid_ids=boundary_spheroid_ids, positive_threshold=positive_threshold, ) return [{**metadata, **measurement} for measurement in measurements] def find_images(data_path: str | Path) -> list[Path]: data_path = Path(data_path) if data_path.is_file(): if data_path.suffix.lower() not in IMAGE_EXTENSIONS: raise ValueError(f"Input is not a TIFF image: {data_path}") image_paths = [data_path] elif data_path.is_dir(): image_paths = sorted( path for path in data_path.rglob("*") if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS ) else: raise FileNotFoundError(f"Data path does not exist: {data_path}") if not image_paths: raise ValueError(f"No TIFF images found in: {data_path}") return image_paths def quantify_data( data_path: str | Path, qc_dir: str | Path | None = None, positive_threshold: float | None = None, max_boundary_contact_ratio: float = MAX_BOUNDARY_CONTACT_RATIO, marker_core_fraction: float = WATERSHED_MARKER_CORE_FRACTION, minimum_relative_region_area: float = WATERSHED_MINIMUM_RELATIVE_REGION_AREA, ) -> pd.DataFrame: measurements = [] qc_dir = Path(qc_dir) if qc_dir is not None else None for image_path in find_images(data_path): qc_path = qc_dir / f"{image_path.stem}_qc.png" if qc_dir is not None else None image_measurements = quantify_image( image_path, qc_path, positive_threshold=positive_threshold, max_boundary_contact_ratio=max_boundary_contact_ratio, marker_core_fraction=marker_core_fraction, minimum_relative_region_area=minimum_relative_region_area, ) measurements.extend(image_measurements) message = f"{image_path}: {len(image_measurements)} spheroids" if qc_path is not None: message += f"; QC: {qc_path}" print(message) if not measurements: raise ValueError("No valid spheroid measurements were produced.") return pd.DataFrame(measurements) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Quantify spheroid DAB intensity and save one row per spheroid." ) parser.add_argument( "data_path", type=Path, help="TIFF image or directory containing TIFF images.", ) parser.add_argument( "--output", type=Path, default=DEFAULT_OUTPUT_PATH, help=f"Output CSV path (default: {DEFAULT_OUTPUT_PATH}).", ) parser.add_argument( "--positive-threshold", type=float, default=None, help="Optional DAB-positive threshold used for measurements and QC.", ) parser.add_argument( "--max-boundary-contact-ratio", type=float, default=MAX_BOUNDARY_CONTACT_RATIO, help=( "Maximum tolerated boundary contact ratio before a spheroid is flagged " f"(default: {MAX_BOUNDARY_CONTACT_RATIO})." ), ) parser.add_argument( "--watershed-marker-core-fraction", type=float, default=WATERSHED_MARKER_CORE_FRACTION, help=( "Distance-transform fraction used to create watershed markers " f"(default: {WATERSHED_MARKER_CORE_FRACTION})." ), ) parser.add_argument( "--watershed-minimum-relative-region-area", type=float, default=WATERSHED_MINIMUM_RELATIVE_REGION_AREA, help=( "Smallest accepted watershed region relative to the largest region " f"(default: {WATERSHED_MINIMUM_RELATIVE_REGION_AREA})." ), ) return parser.parse_args() def main() -> None: args = parse_args() qc_dir = args.output.parent / "qc" results = quantify_data( args.data_path, qc_dir, positive_threshold=args.positive_threshold, max_boundary_contact_ratio=args.max_boundary_contact_ratio, marker_core_fraction=args.watershed_marker_core_fraction, minimum_relative_region_area=args.watershed_minimum_relative_region_area, ) args.output.parent.mkdir(parents=True, exist_ok=True) results.to_csv(args.output, index=False) print(f"Saved {len(results)} spheroids to {args.output}") if __name__ == "__main__": main()