Spaces:
Sleeping
Sleeping
Cleaned up
Browse files
test.ipynb → cleaning_test.ipynb
RENAMED
|
File without changes
|
test copy.ipynb → cleaning_test2.ipynb
RENAMED
|
File without changes
|
ihc_quantification_simplified.py → ihc_quantification.py
RENAMED
|
@@ -190,7 +190,6 @@ def segment_spheroids(
|
|
| 190 |
C = 15
|
| 191 |
block_size = round(min(gray.shape) * 0.14)
|
| 192 |
block_size = block_size - 1 if block_size % 2 == 0 else block_size
|
| 193 |
-
gray = cv2.cvtColor(preview, cv2.COLOR_RGB2GRAY)
|
| 194 |
tissue_thresh = cv2.adaptiveThreshold(
|
| 195 |
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size, C
|
| 196 |
)
|
|
|
|
| 190 |
C = 15
|
| 191 |
block_size = round(min(gray.shape) * 0.14)
|
| 192 |
block_size = block_size - 1 if block_size % 2 == 0 else block_size
|
|
|
|
| 193 |
tissue_thresh = cv2.adaptiveThreshold(
|
| 194 |
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size, C
|
| 195 |
)
|
ihc_quantification_gpt.py
DELETED
|
@@ -1,853 +0,0 @@
|
|
| 1 |
-
"""Quantify DAB staining per spheroid while excluding debris.
|
| 2 |
-
|
| 3 |
-
The source image is never inpainted. Spheroids and debris are detected on an
|
| 4 |
-
8-bit preview, while H-DAB stain separation is performed on the original image
|
| 5 |
-
values. Results from multiple runs are merged into one wide CSV: each
|
| 6 |
-
spheroid is a column and each measurement is a row.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
import argparse
|
| 12 |
-
import csv
|
| 13 |
-
import hashlib
|
| 14 |
-
import os
|
| 15 |
-
import re
|
| 16 |
-
import tempfile
|
| 17 |
-
from dataclasses import dataclass
|
| 18 |
-
from pathlib import Path
|
| 19 |
-
|
| 20 |
-
import cv2
|
| 21 |
-
import numpy as np
|
| 22 |
-
import tifffile
|
| 23 |
-
from skimage.color import hdx_from_rgb, separate_stains
|
| 24 |
-
from skimage.morphology import remove_small_holes
|
| 25 |
-
from skimage.util import img_as_float32
|
| 26 |
-
|
| 27 |
-
from cleaning import _as_rgb_uint8, create_debris_mask
|
| 28 |
-
from ihc_qc import DEFAULT_QC_MAX_DIMENSION, save_qc_image
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
IMAGE_EXTENSIONS = {".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff"}
|
| 32 |
-
DEFAULT_OUTPUT_DIR = Path("out/quantification")
|
| 33 |
-
DEFAULT_ADAPTIVE_BLOCK_FRACTION = 0.14
|
| 34 |
-
DEFAULT_ADAPTIVE_OFFSET = 15.0
|
| 35 |
-
DEFAULT_OPENING_RADIUS = 2
|
| 36 |
-
DEFAULT_CLOSING_RADIUS = 25
|
| 37 |
-
DEFAULT_MIN_SPHEROID_AREA_FRACTION = 0.0025
|
| 38 |
-
DEFAULT_MAX_HOLE_AREA_FRACTION = 0.00025
|
| 39 |
-
CONCENTRATION_PATTERN = re.compile(
|
| 40 |
-
r"^(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>pM|nM|uM|µM|μM|mM)$",
|
| 41 |
-
re.IGNORECASE,
|
| 42 |
-
)
|
| 43 |
-
MAGNIFICATION_PATTERN = re.compile(r"^\d+(?:[.,]\d+)?x$", re.IGNORECASE)
|
| 44 |
-
CSV_METRICS = (
|
| 45 |
-
"image_name",
|
| 46 |
-
"source_path",
|
| 47 |
-
"cell_line",
|
| 48 |
-
"culture_model",
|
| 49 |
-
"treatment",
|
| 50 |
-
"concentration_value",
|
| 51 |
-
"concentration_unit",
|
| 52 |
-
"antibody",
|
| 53 |
-
"magnification",
|
| 54 |
-
"image_number",
|
| 55 |
-
"spheroid_id",
|
| 56 |
-
"touches_image_border",
|
| 57 |
-
"centroid_x_px",
|
| 58 |
-
"centroid_y_px",
|
| 59 |
-
"total_spheroid_area_px",
|
| 60 |
-
"segmented_tissue_area_px",
|
| 61 |
-
"valid_measured_area_px",
|
| 62 |
-
"debris_excluded_area_px",
|
| 63 |
-
"debris_excluded_fraction",
|
| 64 |
-
"mean_dab_intensity",
|
| 65 |
-
"median_dab_intensity",
|
| 66 |
-
"integrated_dab_intensity",
|
| 67 |
-
"positive_dab_fraction",
|
| 68 |
-
"dab_positive_threshold",
|
| 69 |
-
"stain_separation",
|
| 70 |
-
"background_correction",
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
@dataclass(frozen=True)
|
| 75 |
-
class FilenameMetadata:
|
| 76 |
-
cell_line: str = ""
|
| 77 |
-
culture_model: str = ""
|
| 78 |
-
treatment: str = ""
|
| 79 |
-
concentration_value: float | None = None
|
| 80 |
-
concentration_unit: str = ""
|
| 81 |
-
antibody: str = ""
|
| 82 |
-
magnification: str = ""
|
| 83 |
-
image_number: int | None = None
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
@dataclass(frozen=True)
|
| 87 |
-
class SpheroidMeasurement:
|
| 88 |
-
spheroid_id: int
|
| 89 |
-
touches_border: bool
|
| 90 |
-
centroid_x: float
|
| 91 |
-
centroid_y: float
|
| 92 |
-
envelope_area: int
|
| 93 |
-
tissue_area: int
|
| 94 |
-
valid_area: int
|
| 95 |
-
debris_area: int
|
| 96 |
-
debris_fraction: float
|
| 97 |
-
mean_dab: float
|
| 98 |
-
median_dab: float
|
| 99 |
-
integrated_dab: float
|
| 100 |
-
positive_fraction: float | None
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
@dataclass(frozen=True)
|
| 104 |
-
class ImageResult:
|
| 105 |
-
image_path: Path
|
| 106 |
-
image_id: str
|
| 107 |
-
metadata: FilenameMetadata
|
| 108 |
-
labels: np.ndarray
|
| 109 |
-
debris_mask: np.ndarray
|
| 110 |
-
dab: np.ndarray
|
| 111 |
-
measurements: tuple[SpheroidMeasurement, ...]
|
| 112 |
-
dab_positive_threshold: float | None
|
| 113 |
-
background_corrected: bool
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def parse_filename(image_path: str | Path) -> FilenameMetadata:
|
| 117 |
-
"""Extract experimental metadata from a conventionally named image."""
|
| 118 |
-
tokens = Path(image_path).stem.split()
|
| 119 |
-
magnification_index = next(
|
| 120 |
-
(
|
| 121 |
-
index
|
| 122 |
-
for index, token in enumerate(tokens)
|
| 123 |
-
if MAGNIFICATION_PATTERN.fullmatch(token)
|
| 124 |
-
),
|
| 125 |
-
None,
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
if magnification_index is None or magnification_index < 3:
|
| 129 |
-
return FilenameMetadata()
|
| 130 |
-
|
| 131 |
-
number_index = magnification_index + 1
|
| 132 |
-
if number_index != len(tokens) - 1 or not tokens[number_index].isdigit():
|
| 133 |
-
return FilenameMetadata()
|
| 134 |
-
|
| 135 |
-
experiment_tokens = tokens[2 : magnification_index - 1]
|
| 136 |
-
concentration_value = None
|
| 137 |
-
concentration_unit = ""
|
| 138 |
-
treatment_tokens: list[str] = []
|
| 139 |
-
|
| 140 |
-
for token in experiment_tokens:
|
| 141 |
-
concentration_match = CONCENTRATION_PATTERN.fullmatch(token)
|
| 142 |
-
if concentration_match and concentration_value is None:
|
| 143 |
-
concentration_value = float(
|
| 144 |
-
concentration_match.group("value").replace(",", ".")
|
| 145 |
-
)
|
| 146 |
-
unit = concentration_match.group("unit").replace("µ", "u").replace(
|
| 147 |
-
"μ", "u"
|
| 148 |
-
)
|
| 149 |
-
concentration_unit = {
|
| 150 |
-
"pm": "pM",
|
| 151 |
-
"nm": "nM",
|
| 152 |
-
"um": "uM",
|
| 153 |
-
"mm": "mM",
|
| 154 |
-
}[unit.lower()]
|
| 155 |
-
else:
|
| 156 |
-
treatment_tokens.append(token)
|
| 157 |
-
|
| 158 |
-
return FilenameMetadata(
|
| 159 |
-
cell_line=tokens[0],
|
| 160 |
-
culture_model=tokens[1],
|
| 161 |
-
treatment=" ".join(treatment_tokens),
|
| 162 |
-
concentration_value=concentration_value,
|
| 163 |
-
concentration_unit=concentration_unit,
|
| 164 |
-
antibody=tokens[magnification_index - 1],
|
| 165 |
-
magnification=tokens[magnification_index],
|
| 166 |
-
image_number=int(tokens[number_index]),
|
| 167 |
-
)
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def _select_rgb_plane(image: np.ndarray) -> np.ndarray:
|
| 171 |
-
"""Select a single RGB plane without changing the source bit depth."""
|
| 172 |
-
image = np.asarray(image)
|
| 173 |
-
|
| 174 |
-
while image.ndim > 2 and 1 in image.shape:
|
| 175 |
-
image = np.squeeze(image)
|
| 176 |
-
|
| 177 |
-
if image.ndim != 3:
|
| 178 |
-
raise ValueError("Quantification requires a 2D RGB image.")
|
| 179 |
-
|
| 180 |
-
if image.shape[-1] in {3, 4}:
|
| 181 |
-
image = image[..., :3]
|
| 182 |
-
elif image.shape[0] in {3, 4}:
|
| 183 |
-
image = np.moveaxis(image[:3], 0, -1)
|
| 184 |
-
else:
|
| 185 |
-
raise ValueError("Quantification requires an RGB image with 3 channels.")
|
| 186 |
-
|
| 187 |
-
return image
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
def read_original_rgb(image_path: str | Path) -> np.ndarray:
|
| 191 |
-
"""Read an RGB image while preserving its original numeric values."""
|
| 192 |
-
image_path = Path(image_path)
|
| 193 |
-
|
| 194 |
-
if image_path.suffix.lower() in {".tif", ".tiff"}:
|
| 195 |
-
return _select_rgb_plane(tifffile.imread(image_path))
|
| 196 |
-
|
| 197 |
-
image = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
|
| 198 |
-
if image is None:
|
| 199 |
-
raise FileNotFoundError(f"Could not read image: {image_path}")
|
| 200 |
-
|
| 201 |
-
image = _select_rgb_plane(image)
|
| 202 |
-
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
def _odd_block_size(shape: tuple[int, int], requested: int | None) -> int:
|
| 206 |
-
shortest_side = min(shape)
|
| 207 |
-
if shortest_side < 3:
|
| 208 |
-
raise ValueError("Image is too small for adaptive segmentation.")
|
| 209 |
-
|
| 210 |
-
if requested is None:
|
| 211 |
-
block_size = round(shortest_side * DEFAULT_ADAPTIVE_BLOCK_FRACTION)
|
| 212 |
-
else:
|
| 213 |
-
block_size = requested
|
| 214 |
-
|
| 215 |
-
block_size = max(3, min(block_size, shortest_side))
|
| 216 |
-
if block_size % 2 == 0:
|
| 217 |
-
block_size -= 1
|
| 218 |
-
return block_size
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def segment_spheroids(
|
| 222 |
-
preview_rgb: np.ndarray,
|
| 223 |
-
*,
|
| 224 |
-
adaptive_block_size: int | None = None,
|
| 225 |
-
adaptive_offset: float = DEFAULT_ADAPTIVE_OFFSET,
|
| 226 |
-
opening_radius: int = DEFAULT_OPENING_RADIUS,
|
| 227 |
-
closing_radius: int = DEFAULT_CLOSING_RADIUS,
|
| 228 |
-
min_area_fraction: float = DEFAULT_MIN_SPHEROID_AREA_FRACTION,
|
| 229 |
-
max_hole_area_fraction: float = DEFAULT_MAX_HOLE_AREA_FRACTION,
|
| 230 |
-
) -> np.ndarray:
|
| 231 |
-
"""Return row-major spheroid labels using local contrast segmentation."""
|
| 232 |
-
if not 0 < min_area_fraction < 1:
|
| 233 |
-
raise ValueError("min_area_fraction must be between 0 and 1.")
|
| 234 |
-
if opening_radius < 0 or closing_radius < 0:
|
| 235 |
-
raise ValueError("Morphology radii must be non-negative.")
|
| 236 |
-
if not 0 <= max_hole_area_fraction < 1:
|
| 237 |
-
raise ValueError("max_hole_area_fraction must be between 0 and 1.")
|
| 238 |
-
|
| 239 |
-
gray = cv2.cvtColor(preview_rgb, cv2.COLOR_RGB2GRAY)
|
| 240 |
-
block_size = _odd_block_size(gray.shape, adaptive_block_size)
|
| 241 |
-
tissue_seed = cv2.adaptiveThreshold(
|
| 242 |
-
gray,
|
| 243 |
-
255,
|
| 244 |
-
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 245 |
-
cv2.THRESH_BINARY_INV,
|
| 246 |
-
block_size,
|
| 247 |
-
adaptive_offset,
|
| 248 |
-
)
|
| 249 |
-
|
| 250 |
-
# Remove isolated dark specks before closing so they cannot form bridges
|
| 251 |
-
# between a spheroid and unrelated debris or vignetted image edges.
|
| 252 |
-
if opening_radius:
|
| 253 |
-
kernel_size = 2 * opening_radius + 1
|
| 254 |
-
opening_kernel = cv2.getStructuringElement(
|
| 255 |
-
cv2.MORPH_ELLIPSE,
|
| 256 |
-
(kernel_size, kernel_size),
|
| 257 |
-
)
|
| 258 |
-
tissue_seed = cv2.morphologyEx(
|
| 259 |
-
tissue_seed,
|
| 260 |
-
cv2.MORPH_OPEN,
|
| 261 |
-
opening_kernel,
|
| 262 |
-
)
|
| 263 |
-
|
| 264 |
-
if closing_radius:
|
| 265 |
-
kernel_size = 2 * closing_radius + 1
|
| 266 |
-
closing_kernel = cv2.getStructuringElement(
|
| 267 |
-
cv2.MORPH_ELLIPSE,
|
| 268 |
-
(kernel_size, kernel_size),
|
| 269 |
-
)
|
| 270 |
-
tissue_seed = cv2.morphologyEx(
|
| 271 |
-
tissue_seed,
|
| 272 |
-
cv2.MORPH_CLOSE,
|
| 273 |
-
closing_kernel,
|
| 274 |
-
)
|
| 275 |
-
|
| 276 |
-
max_hole_area = round(
|
| 277 |
-
preview_rgb.shape[0]
|
| 278 |
-
* preview_rgb.shape[1]
|
| 279 |
-
* max_hole_area_fraction
|
| 280 |
-
)
|
| 281 |
-
if max_hole_area:
|
| 282 |
-
tissue_seed = remove_small_holes(
|
| 283 |
-
tissue_seed.astype(bool),
|
| 284 |
-
max_size=max_hole_area,
|
| 285 |
-
connectivity=2,
|
| 286 |
-
).astype(np.uint8)
|
| 287 |
-
|
| 288 |
-
count, initial_labels, stats, centroids = cv2.connectedComponentsWithStats(
|
| 289 |
-
tissue_seed,
|
| 290 |
-
connectivity=8,
|
| 291 |
-
)
|
| 292 |
-
min_area = round(preview_rgb.shape[0] * preview_rgb.shape[1] * min_area_fraction)
|
| 293 |
-
components = [
|
| 294 |
-
component
|
| 295 |
-
for component in range(1, count)
|
| 296 |
-
if stats[component, cv2.CC_STAT_AREA] >= min_area
|
| 297 |
-
]
|
| 298 |
-
components.sort(
|
| 299 |
-
key=lambda component: (
|
| 300 |
-
centroids[component, 1],
|
| 301 |
-
centroids[component, 0],
|
| 302 |
-
)
|
| 303 |
-
)
|
| 304 |
-
|
| 305 |
-
labels = np.zeros(initial_labels.shape, dtype=np.int32)
|
| 306 |
-
for spheroid_id, component in enumerate(components, start=1):
|
| 307 |
-
labels[initial_labels == component] = spheroid_id
|
| 308 |
-
|
| 309 |
-
return labels
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def _quadratic_design(x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
| 313 |
-
return np.column_stack((np.ones_like(x), x, y, x * x, x * y, y * y))
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
def spheroid_envelope(labels: np.ndarray) -> np.ndarray:
|
| 317 |
-
"""Fill each spheroid's external contour for size and background fitting."""
|
| 318 |
-
envelope = np.zeros(labels.shape, dtype=np.uint8)
|
| 319 |
-
for spheroid_id in range(1, int(labels.max()) + 1):
|
| 320 |
-
component = (labels == spheroid_id).astype(np.uint8)
|
| 321 |
-
contours, _ = cv2.findContours(
|
| 322 |
-
component,
|
| 323 |
-
cv2.RETR_EXTERNAL,
|
| 324 |
-
cv2.CHAIN_APPROX_SIMPLE,
|
| 325 |
-
)
|
| 326 |
-
cv2.drawContours(envelope, contours, -1, spheroid_id, cv2.FILLED)
|
| 327 |
-
return envelope
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
def _fit_background_coefficients(
|
| 331 |
-
rgb: np.ndarray,
|
| 332 |
-
background_mask: np.ndarray,
|
| 333 |
-
*,
|
| 334 |
-
target_samples: int = 100_000,
|
| 335 |
-
) -> np.ndarray | None:
|
| 336 |
-
"""Fit a robust quadratic illumination surface to empty background."""
|
| 337 |
-
height, width = background_mask.shape
|
| 338 |
-
stride = max(1, int(np.sqrt(background_mask.size / target_samples)))
|
| 339 |
-
sampled_mask = background_mask[::stride, ::stride]
|
| 340 |
-
sampled_y, sampled_x = np.nonzero(sampled_mask)
|
| 341 |
-
if sampled_x.size < 1_000:
|
| 342 |
-
return None
|
| 343 |
-
|
| 344 |
-
sampled_y = sampled_y * stride
|
| 345 |
-
sampled_x = sampled_x * stride
|
| 346 |
-
x = sampled_x.astype(np.float64) / max(width - 1, 1) * 2 - 1
|
| 347 |
-
y = sampled_y.astype(np.float64) / max(height - 1, 1) * 2 - 1
|
| 348 |
-
design = _quadratic_design(x, y)
|
| 349 |
-
|
| 350 |
-
coefficients = np.zeros((3, design.shape[1]), dtype=np.float64)
|
| 351 |
-
for channel in range(3):
|
| 352 |
-
values = rgb[sampled_y, sampled_x, channel].astype(np.float64)
|
| 353 |
-
keep = np.ones(values.shape, dtype=bool)
|
| 354 |
-
|
| 355 |
-
for _ in range(4):
|
| 356 |
-
coefficients[channel], *_ = np.linalg.lstsq(
|
| 357 |
-
design[keep],
|
| 358 |
-
values[keep],
|
| 359 |
-
rcond=None,
|
| 360 |
-
)
|
| 361 |
-
residuals = values - design @ coefficients[channel]
|
| 362 |
-
center = np.median(residuals[keep])
|
| 363 |
-
mad = np.median(np.abs(residuals[keep] - center))
|
| 364 |
-
if mad <= np.finfo(np.float64).eps:
|
| 365 |
-
break
|
| 366 |
-
robust_sigma = 1.4826 * mad
|
| 367 |
-
new_keep = (
|
| 368 |
-
(residuals >= center - 2.5 * robust_sigma)
|
| 369 |
-
& (residuals <= center + 3.5 * robust_sigma)
|
| 370 |
-
)
|
| 371 |
-
if new_keep.sum() < 1_000 or np.array_equal(new_keep, keep):
|
| 372 |
-
break
|
| 373 |
-
keep = new_keep
|
| 374 |
-
|
| 375 |
-
return coefficients
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
def correct_background_illumination(
|
| 379 |
-
rgb: np.ndarray,
|
| 380 |
-
tissue_mask: np.ndarray,
|
| 381 |
-
debris_mask: np.ndarray,
|
| 382 |
-
) -> tuple[np.ndarray, bool]:
|
| 383 |
-
"""Normalize smooth per-channel illumination using empty background."""
|
| 384 |
-
background_mask = ~tissue_mask & ~debris_mask
|
| 385 |
-
coefficients = _fit_background_coefficients(rgb, background_mask)
|
| 386 |
-
if coefficients is None:
|
| 387 |
-
return rgb, False
|
| 388 |
-
|
| 389 |
-
height, width = tissue_mask.shape
|
| 390 |
-
x = np.linspace(-1, 1, width, dtype=np.float32)[None, :]
|
| 391 |
-
y = np.linspace(-1, 1, height, dtype=np.float32)[:, None]
|
| 392 |
-
corrected = rgb.copy()
|
| 393 |
-
|
| 394 |
-
for channel in range(3):
|
| 395 |
-
c0, cx, cy, cxx, cxy, cyy = coefficients[channel]
|
| 396 |
-
surface = (
|
| 397 |
-
c0
|
| 398 |
-
+ cx * x
|
| 399 |
-
+ cy * y
|
| 400 |
-
+ cxx * x * x
|
| 401 |
-
+ cxy * x * y
|
| 402 |
-
+ cyy * y * y
|
| 403 |
-
).astype(np.float32)
|
| 404 |
-
surface = np.clip(surface, 0.05, 1.5)
|
| 405 |
-
corrected[..., channel] = np.clip(
|
| 406 |
-
corrected[..., channel] / surface,
|
| 407 |
-
0,
|
| 408 |
-
1,
|
| 409 |
-
)
|
| 410 |
-
|
| 411 |
-
return corrected, True
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def extract_dab(rgb: np.ndarray) -> np.ndarray:
|
| 415 |
-
"""Extract the DAB component with scikit-image's fixed H-DAB matrix."""
|
| 416 |
-
stains = separate_stains(rgb, hdx_from_rgb)
|
| 417 |
-
dab = stains[..., 1].astype(np.float32)
|
| 418 |
-
return dab
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
def _touches_border(component_mask: np.ndarray) -> bool:
|
| 422 |
-
return bool(
|
| 423 |
-
component_mask[0].any()
|
| 424 |
-
or component_mask[-1].any()
|
| 425 |
-
or component_mask[:, 0].any()
|
| 426 |
-
or component_mask[:, -1].any()
|
| 427 |
-
)
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
def measure_spheroids(
|
| 431 |
-
labels: np.ndarray,
|
| 432 |
-
debris_mask: np.ndarray,
|
| 433 |
-
dab: np.ndarray,
|
| 434 |
-
dab_positive_threshold: float | None,
|
| 435 |
-
) -> tuple[SpheroidMeasurement, ...]:
|
| 436 |
-
measurements: list[SpheroidMeasurement] = []
|
| 437 |
-
debris = debris_mask.astype(bool)
|
| 438 |
-
envelope_labels = spheroid_envelope(labels)
|
| 439 |
-
|
| 440 |
-
for spheroid_id in range(1, int(labels.max()) + 1):
|
| 441 |
-
spheroid = labels == spheroid_id
|
| 442 |
-
envelope = envelope_labels == spheroid_id
|
| 443 |
-
valid = spheroid & ~debris
|
| 444 |
-
tissue_area = int(spheroid.sum())
|
| 445 |
-
envelope_area = int(envelope.sum())
|
| 446 |
-
valid_area = int(valid.sum())
|
| 447 |
-
debris_area = tissue_area - valid_area
|
| 448 |
-
if not valid_area:
|
| 449 |
-
continue
|
| 450 |
-
|
| 451 |
-
y, x = np.nonzero(spheroid)
|
| 452 |
-
dab_values = dab[valid]
|
| 453 |
-
positive_fraction = None
|
| 454 |
-
if dab_positive_threshold is not None:
|
| 455 |
-
positive_fraction = float(
|
| 456 |
-
np.count_nonzero(dab_values >= dab_positive_threshold) / valid_area
|
| 457 |
-
)
|
| 458 |
-
|
| 459 |
-
measurements.append(
|
| 460 |
-
SpheroidMeasurement(
|
| 461 |
-
spheroid_id=spheroid_id,
|
| 462 |
-
touches_border=_touches_border(spheroid),
|
| 463 |
-
centroid_x=float(x.mean()),
|
| 464 |
-
centroid_y=float(y.mean()),
|
| 465 |
-
envelope_area=envelope_area,
|
| 466 |
-
tissue_area=tissue_area,
|
| 467 |
-
valid_area=valid_area,
|
| 468 |
-
debris_area=debris_area,
|
| 469 |
-
debris_fraction=debris_area / tissue_area,
|
| 470 |
-
mean_dab=float(dab_values.mean()),
|
| 471 |
-
median_dab=float(np.median(dab_values)),
|
| 472 |
-
integrated_dab=float(dab_values.sum(dtype=np.float64)),
|
| 473 |
-
positive_fraction=positive_fraction,
|
| 474 |
-
)
|
| 475 |
-
)
|
| 476 |
-
|
| 477 |
-
return tuple(measurements)
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
def _safe_identifier(value: str) -> str:
|
| 481 |
-
identifier = re.sub(r"[^A-Za-z0-9._ -]+", "_", value).strip(" ._")
|
| 482 |
-
return identifier or "image"
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
def quantify_image(
|
| 486 |
-
image_path: str | Path,
|
| 487 |
-
*,
|
| 488 |
-
dab_positive_threshold: float | None = None,
|
| 489 |
-
adaptive_block_size: int | None = None,
|
| 490 |
-
adaptive_offset: float = DEFAULT_ADAPTIVE_OFFSET,
|
| 491 |
-
opening_radius: int = DEFAULT_OPENING_RADIUS,
|
| 492 |
-
closing_radius: int = DEFAULT_CLOSING_RADIUS,
|
| 493 |
-
min_area_fraction: float = DEFAULT_MIN_SPHEROID_AREA_FRACTION,
|
| 494 |
-
max_hole_area_fraction: float = DEFAULT_MAX_HOLE_AREA_FRACTION,
|
| 495 |
-
background_correction: bool = True,
|
| 496 |
-
) -> tuple[ImageResult, np.ndarray]:
|
| 497 |
-
image_path = Path(image_path)
|
| 498 |
-
original = read_original_rgb(image_path)
|
| 499 |
-
preview = _as_rgb_uint8(original)
|
| 500 |
-
labels = segment_spheroids(
|
| 501 |
-
preview,
|
| 502 |
-
adaptive_block_size=adaptive_block_size,
|
| 503 |
-
adaptive_offset=adaptive_offset,
|
| 504 |
-
opening_radius=opening_radius,
|
| 505 |
-
closing_radius=closing_radius,
|
| 506 |
-
min_area_fraction=min_area_fraction,
|
| 507 |
-
max_hole_area_fraction=max_hole_area_fraction,
|
| 508 |
-
)
|
| 509 |
-
if not labels.max():
|
| 510 |
-
raise ValueError(
|
| 511 |
-
"No spheroids were detected. Review the QC segmentation settings."
|
| 512 |
-
)
|
| 513 |
-
|
| 514 |
-
debris_mask = create_debris_mask(preview).astype(bool)
|
| 515 |
-
rgb = img_as_float32(original)
|
| 516 |
-
was_corrected = False
|
| 517 |
-
if background_correction:
|
| 518 |
-
rgb, was_corrected = correct_background_illumination(
|
| 519 |
-
rgb,
|
| 520 |
-
spheroid_envelope(labels) > 0,
|
| 521 |
-
debris_mask,
|
| 522 |
-
)
|
| 523 |
-
|
| 524 |
-
dab = extract_dab(rgb)
|
| 525 |
-
measurements = measure_spheroids(
|
| 526 |
-
labels,
|
| 527 |
-
debris_mask,
|
| 528 |
-
dab,
|
| 529 |
-
dab_positive_threshold,
|
| 530 |
-
)
|
| 531 |
-
if not measurements:
|
| 532 |
-
raise ValueError("No spheroid contained valid pixels after debris exclusion.")
|
| 533 |
-
|
| 534 |
-
result = ImageResult(
|
| 535 |
-
image_path=image_path.resolve(),
|
| 536 |
-
image_id=_safe_identifier(image_path.name),
|
| 537 |
-
metadata=parse_filename(image_path),
|
| 538 |
-
labels=labels,
|
| 539 |
-
debris_mask=debris_mask,
|
| 540 |
-
dab=dab,
|
| 541 |
-
measurements=measurements,
|
| 542 |
-
dab_positive_threshold=dab_positive_threshold,
|
| 543 |
-
background_corrected=was_corrected,
|
| 544 |
-
)
|
| 545 |
-
return result, preview
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
def _format_float(value: float | None) -> str:
|
| 549 |
-
if value is None:
|
| 550 |
-
return ""
|
| 551 |
-
return f"{value:.8g}"
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
def _measurement_values(
|
| 555 |
-
result: ImageResult,
|
| 556 |
-
measurement: SpheroidMeasurement,
|
| 557 |
-
) -> dict[str, str]:
|
| 558 |
-
metadata = result.metadata
|
| 559 |
-
return {
|
| 560 |
-
"image_name": result.image_path.name,
|
| 561 |
-
"source_path": str(result.image_path),
|
| 562 |
-
"cell_line": metadata.cell_line,
|
| 563 |
-
"culture_model": metadata.culture_model,
|
| 564 |
-
"treatment": metadata.treatment,
|
| 565 |
-
"concentration_value": _format_float(metadata.concentration_value),
|
| 566 |
-
"concentration_unit": metadata.concentration_unit,
|
| 567 |
-
"antibody": metadata.antibody,
|
| 568 |
-
"magnification": metadata.magnification,
|
| 569 |
-
"image_number": (
|
| 570 |
-
str(metadata.image_number) if metadata.image_number is not None else ""
|
| 571 |
-
),
|
| 572 |
-
"spheroid_id": str(measurement.spheroid_id),
|
| 573 |
-
"touches_image_border": str(measurement.touches_border).lower(),
|
| 574 |
-
"centroid_x_px": _format_float(measurement.centroid_x),
|
| 575 |
-
"centroid_y_px": _format_float(measurement.centroid_y),
|
| 576 |
-
"total_spheroid_area_px": str(measurement.envelope_area),
|
| 577 |
-
"segmented_tissue_area_px": str(measurement.tissue_area),
|
| 578 |
-
"valid_measured_area_px": str(measurement.valid_area),
|
| 579 |
-
"debris_excluded_area_px": str(measurement.debris_area),
|
| 580 |
-
"debris_excluded_fraction": _format_float(measurement.debris_fraction),
|
| 581 |
-
"mean_dab_intensity": _format_float(measurement.mean_dab),
|
| 582 |
-
"median_dab_intensity": _format_float(measurement.median_dab),
|
| 583 |
-
"integrated_dab_intensity": _format_float(measurement.integrated_dab),
|
| 584 |
-
"positive_dab_fraction": _format_float(measurement.positive_fraction),
|
| 585 |
-
"dab_positive_threshold": _format_float(result.dab_positive_threshold),
|
| 586 |
-
"stain_separation": "scikit-image H-DAB (hdx_from_rgb)",
|
| 587 |
-
"background_correction": (
|
| 588 |
-
"quadratic empty-background fit"
|
| 589 |
-
if result.background_corrected
|
| 590 |
-
else "none"
|
| 591 |
-
),
|
| 592 |
-
}
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
def _read_existing_results(
|
| 596 |
-
csv_path: Path,
|
| 597 |
-
) -> tuple[list[str], dict[str, dict[str, str]], list[str]]:
|
| 598 |
-
if not csv_path.exists():
|
| 599 |
-
return [], {}, []
|
| 600 |
-
|
| 601 |
-
with csv_path.open(newline="", encoding="utf-8") as handle:
|
| 602 |
-
reader = csv.reader(handle)
|
| 603 |
-
rows = list(reader)
|
| 604 |
-
|
| 605 |
-
if not rows:
|
| 606 |
-
return [], {}, []
|
| 607 |
-
if not rows[0] or rows[0][0] != "metric":
|
| 608 |
-
raise ValueError(
|
| 609 |
-
f"Existing results file does not use the expected wide format: {csv_path}"
|
| 610 |
-
)
|
| 611 |
-
|
| 612 |
-
columns = rows[0][1:]
|
| 613 |
-
values: dict[str, dict[str, str]] = {}
|
| 614 |
-
metric_order: list[str] = []
|
| 615 |
-
for row in rows[1:]:
|
| 616 |
-
if not row:
|
| 617 |
-
continue
|
| 618 |
-
metric = row[0]
|
| 619 |
-
metric_order.append(metric)
|
| 620 |
-
padded = row[1:] + [""] * max(0, len(columns) - len(row[1:]))
|
| 621 |
-
values[metric] = dict(zip(columns, padded[: len(columns)], strict=True))
|
| 622 |
-
|
| 623 |
-
return columns, values, metric_order
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
def update_results_csv(csv_path: str | Path, results: list[ImageResult]) -> None:
|
| 627 |
-
"""Replace reprocessed images and append new spheroid columns atomically."""
|
| 628 |
-
csv_path = Path(csv_path)
|
| 629 |
-
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
| 630 |
-
columns, values, previous_metric_order = _read_existing_results(csv_path)
|
| 631 |
-
source_paths = {str(result.image_path) for result in results}
|
| 632 |
-
source_row = values.get("source_path", {})
|
| 633 |
-
removed_columns = {
|
| 634 |
-
column for column in columns if source_row.get(column) in source_paths
|
| 635 |
-
}
|
| 636 |
-
columns = [column for column in columns if column not in removed_columns]
|
| 637 |
-
for metric_values in values.values():
|
| 638 |
-
for column in removed_columns:
|
| 639 |
-
metric_values.pop(column, None)
|
| 640 |
-
|
| 641 |
-
for result in results:
|
| 642 |
-
base_prefix = result.image_id
|
| 643 |
-
proposed_columns = [
|
| 644 |
-
f"{base_prefix}::spheroid_{measurement.spheroid_id:03d}"
|
| 645 |
-
for measurement in result.measurements
|
| 646 |
-
]
|
| 647 |
-
if any(column in columns for column in proposed_columns):
|
| 648 |
-
digest = hashlib.sha1(
|
| 649 |
-
str(result.image_path).encode("utf-8")
|
| 650 |
-
).hexdigest()[:8]
|
| 651 |
-
base_prefix = f"{base_prefix} [{digest}]"
|
| 652 |
-
|
| 653 |
-
for measurement in result.measurements:
|
| 654 |
-
column = f"{base_prefix}::spheroid_{measurement.spheroid_id:03d}"
|
| 655 |
-
columns.append(column)
|
| 656 |
-
for metric, value in _measurement_values(result, measurement).items():
|
| 657 |
-
values.setdefault(metric, {})[column] = value
|
| 658 |
-
|
| 659 |
-
metric_order = list(CSV_METRICS)
|
| 660 |
-
metric_order.extend(
|
| 661 |
-
metric
|
| 662 |
-
for metric in previous_metric_order
|
| 663 |
-
if metric not in metric_order
|
| 664 |
-
)
|
| 665 |
-
metric_order.extend(
|
| 666 |
-
metric for metric in values if metric not in metric_order
|
| 667 |
-
)
|
| 668 |
-
|
| 669 |
-
with tempfile.NamedTemporaryFile(
|
| 670 |
-
mode="w",
|
| 671 |
-
newline="",
|
| 672 |
-
encoding="utf-8",
|
| 673 |
-
dir=csv_path.parent,
|
| 674 |
-
prefix=f".{csv_path.name}.",
|
| 675 |
-
suffix=".tmp",
|
| 676 |
-
delete=False,
|
| 677 |
-
) as handle:
|
| 678 |
-
temporary_path = Path(handle.name)
|
| 679 |
-
writer = csv.writer(handle)
|
| 680 |
-
writer.writerow(["metric", *columns])
|
| 681 |
-
for metric in metric_order:
|
| 682 |
-
metric_values = values.get(metric, {})
|
| 683 |
-
writer.writerow(
|
| 684 |
-
[metric, *(metric_values.get(column, "") for column in columns)]
|
| 685 |
-
)
|
| 686 |
-
|
| 687 |
-
os.replace(temporary_path, csv_path)
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
def discover_images(inputs: list[str]) -> list[Path]:
|
| 691 |
-
images: list[Path] = []
|
| 692 |
-
seen: set[Path] = set()
|
| 693 |
-
|
| 694 |
-
for input_value in inputs:
|
| 695 |
-
input_path = Path(input_value)
|
| 696 |
-
if input_path.is_dir():
|
| 697 |
-
candidates = sorted(
|
| 698 |
-
path
|
| 699 |
-
for path in input_path.rglob("*")
|
| 700 |
-
if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
|
| 701 |
-
)
|
| 702 |
-
elif input_path.is_file():
|
| 703 |
-
if input_path.suffix.lower() not in IMAGE_EXTENSIONS:
|
| 704 |
-
raise ValueError(f"Unsupported image extension: {input_path}")
|
| 705 |
-
candidates = [input_path]
|
| 706 |
-
else:
|
| 707 |
-
raise FileNotFoundError(f"Input does not exist: {input_path}")
|
| 708 |
-
|
| 709 |
-
for candidate in candidates:
|
| 710 |
-
resolved = candidate.resolve()
|
| 711 |
-
if resolved not in seen:
|
| 712 |
-
images.append(candidate)
|
| 713 |
-
seen.add(resolved)
|
| 714 |
-
|
| 715 |
-
if not images:
|
| 716 |
-
raise ValueError("No supported images were found.")
|
| 717 |
-
return images
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
def parse_args() -> argparse.Namespace:
|
| 721 |
-
parser = argparse.ArgumentParser(
|
| 722 |
-
description=(
|
| 723 |
-
"Quantify H-DAB signal per spheroid and update a wide results CSV."
|
| 724 |
-
)
|
| 725 |
-
)
|
| 726 |
-
parser.add_argument(
|
| 727 |
-
"images",
|
| 728 |
-
nargs="+",
|
| 729 |
-
help="Image files or directories. Directories are searched recursively.",
|
| 730 |
-
)
|
| 731 |
-
parser.add_argument(
|
| 732 |
-
"--output-dir",
|
| 733 |
-
type=Path,
|
| 734 |
-
default=DEFAULT_OUTPUT_DIR,
|
| 735 |
-
help=f"Results directory (default: {DEFAULT_OUTPUT_DIR}).",
|
| 736 |
-
)
|
| 737 |
-
parser.add_argument(
|
| 738 |
-
"--dab-positive-threshold",
|
| 739 |
-
type=float,
|
| 740 |
-
default=None,
|
| 741 |
-
help=(
|
| 742 |
-
"Fixed H-DAB threshold derived from negative controls. If omitted, "
|
| 743 |
-
"positive fractions are left blank."
|
| 744 |
-
),
|
| 745 |
-
)
|
| 746 |
-
parser.add_argument(
|
| 747 |
-
"--adaptive-block-size",
|
| 748 |
-
type=int,
|
| 749 |
-
default=None,
|
| 750 |
-
help="Odd local-threshold window (default: 14%% of shortest image side).",
|
| 751 |
-
)
|
| 752 |
-
parser.add_argument(
|
| 753 |
-
"--adaptive-offset",
|
| 754 |
-
type=float,
|
| 755 |
-
default=DEFAULT_ADAPTIVE_OFFSET,
|
| 756 |
-
help=f"Local-threshold offset (default: {DEFAULT_ADAPTIVE_OFFSET:g}).",
|
| 757 |
-
)
|
| 758 |
-
parser.add_argument(
|
| 759 |
-
"--opening-radius",
|
| 760 |
-
type=int,
|
| 761 |
-
default=DEFAULT_OPENING_RADIUS,
|
| 762 |
-
help=f"Speck-removal radius in pixels (default: {DEFAULT_OPENING_RADIUS}).",
|
| 763 |
-
)
|
| 764 |
-
parser.add_argument(
|
| 765 |
-
"--closing-radius",
|
| 766 |
-
type=int,
|
| 767 |
-
default=DEFAULT_CLOSING_RADIUS,
|
| 768 |
-
help=(
|
| 769 |
-
"Spheroid-mask closing radius in pixels "
|
| 770 |
-
f"(default: {DEFAULT_CLOSING_RADIUS})."
|
| 771 |
-
),
|
| 772 |
-
)
|
| 773 |
-
parser.add_argument(
|
| 774 |
-
"--min-spheroid-area-fraction",
|
| 775 |
-
type=float,
|
| 776 |
-
default=DEFAULT_MIN_SPHEROID_AREA_FRACTION,
|
| 777 |
-
help=(
|
| 778 |
-
"Minimum spheroid area as a fraction of image area "
|
| 779 |
-
f"(default: {DEFAULT_MIN_SPHEROID_AREA_FRACTION:g})."
|
| 780 |
-
),
|
| 781 |
-
)
|
| 782 |
-
parser.add_argument(
|
| 783 |
-
"--max-hole-area-fraction",
|
| 784 |
-
type=float,
|
| 785 |
-
default=DEFAULT_MAX_HOLE_AREA_FRACTION,
|
| 786 |
-
help=(
|
| 787 |
-
"Fill internal mask holes up to this fraction of image area "
|
| 788 |
-
f"(default: {DEFAULT_MAX_HOLE_AREA_FRACTION:g})."
|
| 789 |
-
),
|
| 790 |
-
)
|
| 791 |
-
parser.add_argument(
|
| 792 |
-
"--no-background-correction",
|
| 793 |
-
action="store_true",
|
| 794 |
-
help="Disable quadratic empty-background illumination correction.",
|
| 795 |
-
)
|
| 796 |
-
parser.add_argument(
|
| 797 |
-
"--qc-max-dimension",
|
| 798 |
-
type=int,
|
| 799 |
-
default=DEFAULT_QC_MAX_DIMENSION,
|
| 800 |
-
help=(
|
| 801 |
-
"Maximum width or height of each QC panel "
|
| 802 |
-
f"(default: {DEFAULT_QC_MAX_DIMENSION})."
|
| 803 |
-
),
|
| 804 |
-
)
|
| 805 |
-
return parser.parse_args()
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
def main() -> None:
|
| 809 |
-
args = parse_args()
|
| 810 |
-
image_paths = discover_images(args.images)
|
| 811 |
-
results: list[ImageResult] = []
|
| 812 |
-
qc_dir = args.output_dir / "qc"
|
| 813 |
-
|
| 814 |
-
if args.dab_positive_threshold is None:
|
| 815 |
-
print(
|
| 816 |
-
"Note: positive_dab_fraction will be blank until a fixed "
|
| 817 |
-
"--dab-positive-threshold is supplied."
|
| 818 |
-
)
|
| 819 |
-
|
| 820 |
-
for image_path in image_paths:
|
| 821 |
-
result, preview = quantify_image(
|
| 822 |
-
image_path,
|
| 823 |
-
dab_positive_threshold=args.dab_positive_threshold,
|
| 824 |
-
adaptive_block_size=args.adaptive_block_size,
|
| 825 |
-
adaptive_offset=args.adaptive_offset,
|
| 826 |
-
opening_radius=args.opening_radius,
|
| 827 |
-
closing_radius=args.closing_radius,
|
| 828 |
-
min_area_fraction=args.min_spheroid_area_fraction,
|
| 829 |
-
max_hole_area_fraction=args.max_hole_area_fraction,
|
| 830 |
-
background_correction=not args.no_background_correction,
|
| 831 |
-
)
|
| 832 |
-
qc_path = qc_dir / f"{_safe_identifier(result.image_path.stem)}_qc.png"
|
| 833 |
-
save_qc_image(
|
| 834 |
-
qc_path,
|
| 835 |
-
preview,
|
| 836 |
-
result.labels,
|
| 837 |
-
result.debris_mask,
|
| 838 |
-
result.dab,
|
| 839 |
-
max_dimension=args.qc_max_dimension,
|
| 840 |
-
)
|
| 841 |
-
results.append(result)
|
| 842 |
-
print(
|
| 843 |
-
f"{image_path}: {len(result.measurements)} spheroids; "
|
| 844 |
-
f"QC: {qc_path}"
|
| 845 |
-
)
|
| 846 |
-
|
| 847 |
-
csv_path = args.output_dir / "ihc_quantification.csv"
|
| 848 |
-
update_results_csv(csv_path, results)
|
| 849 |
-
print(f"Updated results: {csv_path}")
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
if __name__ == "__main__":
|
| 853 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|