Spaces:
Sleeping
Sleeping
Finished analysis
Browse files- .gitignore +5 -1
- analysis_nbs/ihc_analysis_a498.ipynb +0 -0
- analysis_nbs/ihc_analysis_oboje_a498.ipynb +0 -0
- analysis_nbs/ihc_analysis_oboje_skrc52.ipynb +0 -0
- analysis_nbs/ihc_analysis_skrc52.ipynb +0 -0
- ihc_qc.py +246 -0
- ihc_quantification.ipynb +0 -0
- ihc_quantification_gpt.py +853 -0
- ihc_quantification_simplified.py +504 -0
- pyproject.toml +3 -0
- requirements.txt +2 -0
- uv.lock +41 -0
.gitignore
CHANGED
|
@@ -1,4 +1,8 @@
|
|
| 1 |
.DS_Store
|
| 2 |
__pycache__
|
| 3 |
-
out/
|
| 4 |
data/test
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
.DS_Store
|
| 2 |
__pycache__
|
| 3 |
+
out*/
|
| 4 |
data/test
|
| 5 |
+
results*
|
| 6 |
+
test_results
|
| 7 |
+
data/dab_quantification_test
|
| 8 |
+
full_data
|
analysis_nbs/ihc_analysis_a498.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
analysis_nbs/ihc_analysis_oboje_a498.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
analysis_nbs/ihc_analysis_oboje_skrc52.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
analysis_nbs/ihc_analysis_skrc52.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
ihc_qc.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""QC visualization for spheroid segmentation and DAB quantification."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
DEFAULT_QC_MAX_DIMENSION = 1200
|
| 10 |
+
DEFAULT_DAB_VMAX = 0.3
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _resize(
|
| 14 |
+
image: np.ndarray,
|
| 15 |
+
max_dimension: int,
|
| 16 |
+
*,
|
| 17 |
+
nearest: bool = False,
|
| 18 |
+
) -> np.ndarray:
|
| 19 |
+
height, width = image.shape[:2]
|
| 20 |
+
scale = min(1.0, max_dimension / max(height, width))
|
| 21 |
+
if scale == 1:
|
| 22 |
+
return image.copy()
|
| 23 |
+
|
| 24 |
+
size = (round(width * scale), round(height * scale))
|
| 25 |
+
interpolation = cv2.INTER_NEAREST if nearest else cv2.INTER_AREA
|
| 26 |
+
return cv2.resize(image, size, interpolation=interpolation)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _draw_spheroids(
|
| 30 |
+
image: np.ndarray,
|
| 31 |
+
labels: np.ndarray,
|
| 32 |
+
boundary_spheroid_ids: set[int],
|
| 33 |
+
) -> np.ndarray:
|
| 34 |
+
output = image.copy()
|
| 35 |
+
|
| 36 |
+
for spheroid_id in np.unique(labels):
|
| 37 |
+
if spheroid_id == 0:
|
| 38 |
+
continue
|
| 39 |
+
|
| 40 |
+
spheroid = labels == spheroid_id
|
| 41 |
+
contours, _ = cv2.findContours(
|
| 42 |
+
spheroid.astype(np.uint8),
|
| 43 |
+
cv2.RETR_EXTERNAL,
|
| 44 |
+
cv2.CHAIN_APPROX_SIMPLE,
|
| 45 |
+
)
|
| 46 |
+
outline_color = (
|
| 47 |
+
(255, 165, 0)
|
| 48 |
+
if spheroid_id in boundary_spheroid_ids
|
| 49 |
+
else (0, 255, 255)
|
| 50 |
+
)
|
| 51 |
+
cv2.drawContours(output, contours, -1, outline_color, 3)
|
| 52 |
+
|
| 53 |
+
y, x = np.nonzero(spheroid)
|
| 54 |
+
center = (round(x.mean()), round(y.mean()))
|
| 55 |
+
text = str(spheroid_id)
|
| 56 |
+
cv2.circle(output, center, 18, (0, 0, 0), cv2.FILLED)
|
| 57 |
+
cv2.putText(
|
| 58 |
+
output,
|
| 59 |
+
text,
|
| 60 |
+
(center[0] - 7 * len(text), center[1] + 7),
|
| 61 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 62 |
+
0.65,
|
| 63 |
+
(255, 255, 255),
|
| 64 |
+
2,
|
| 65 |
+
cv2.LINE_AA,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
return output
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _draw_debris_outlines(
|
| 72 |
+
image: np.ndarray,
|
| 73 |
+
debris_mask: np.ndarray,
|
| 74 |
+
) -> np.ndarray:
|
| 75 |
+
output = image.copy()
|
| 76 |
+
contours, _ = cv2.findContours(
|
| 77 |
+
debris_mask.astype(np.uint8),
|
| 78 |
+
cv2.RETR_EXTERNAL,
|
| 79 |
+
cv2.CHAIN_APPROX_SIMPLE,
|
| 80 |
+
)
|
| 81 |
+
cv2.drawContours(output, contours, -1, (255, 0, 255), 1)
|
| 82 |
+
return output
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _add_title(image: np.ndarray, title: str) -> np.ndarray:
|
| 86 |
+
titled = cv2.copyMakeBorder(
|
| 87 |
+
image,
|
| 88 |
+
54,
|
| 89 |
+
0,
|
| 90 |
+
0,
|
| 91 |
+
0,
|
| 92 |
+
cv2.BORDER_CONSTANT,
|
| 93 |
+
value=(28, 28, 28),
|
| 94 |
+
)
|
| 95 |
+
cv2.putText(
|
| 96 |
+
titled,
|
| 97 |
+
title,
|
| 98 |
+
(18, 36),
|
| 99 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 100 |
+
0.8,
|
| 101 |
+
(255, 255, 255),
|
| 102 |
+
2,
|
| 103 |
+
cv2.LINE_AA,
|
| 104 |
+
)
|
| 105 |
+
return titled
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def create_qc_image(
|
| 109 |
+
preview: np.ndarray,
|
| 110 |
+
labels: np.ndarray,
|
| 111 |
+
debris_mask: np.ndarray,
|
| 112 |
+
dab: np.ndarray,
|
| 113 |
+
*,
|
| 114 |
+
boundary_spheroid_ids: set[int] | None = None,
|
| 115 |
+
positive_threshold: float | None = None,
|
| 116 |
+
dab_vmax: float = DEFAULT_DAB_VMAX,
|
| 117 |
+
max_dimension: int = DEFAULT_QC_MAX_DIMENSION,
|
| 118 |
+
) -> np.ndarray:
|
| 119 |
+
"""Return an RGB QC image with segmentation, debris, and DAB panels."""
|
| 120 |
+
if preview.shape[:2] != labels.shape:
|
| 121 |
+
raise ValueError("preview and labels must have the same height and width.")
|
| 122 |
+
if labels.shape != debris_mask.shape or labels.shape != dab.shape:
|
| 123 |
+
raise ValueError("labels, debris_mask, and dab must have the same shape.")
|
| 124 |
+
if max_dimension <= 0:
|
| 125 |
+
raise ValueError("max_dimension must be positive.")
|
| 126 |
+
if positive_threshold is not None and not np.isfinite(positive_threshold):
|
| 127 |
+
raise ValueError("positive_threshold must be finite.")
|
| 128 |
+
if not np.isfinite(dab_vmax) or dab_vmax <= 0:
|
| 129 |
+
raise ValueError("dab_vmax must be finite and positive.")
|
| 130 |
+
|
| 131 |
+
preview_small = _resize(preview, max_dimension)
|
| 132 |
+
labels_small = _resize(labels, max_dimension, nearest=True)
|
| 133 |
+
debris_small = _resize(
|
| 134 |
+
(debris_mask > 0).astype(np.uint8),
|
| 135 |
+
max_dimension,
|
| 136 |
+
nearest=True,
|
| 137 |
+
).astype(bool)
|
| 138 |
+
dab_small = _resize(dab, max_dimension)
|
| 139 |
+
if boundary_spheroid_ids is None:
|
| 140 |
+
boundary_spheroid_ids = {
|
| 141 |
+
int(spheroid_id)
|
| 142 |
+
for spheroid_id in np.unique(
|
| 143 |
+
np.concatenate(
|
| 144 |
+
(
|
| 145 |
+
labels[0, :],
|
| 146 |
+
labels[-1, :],
|
| 147 |
+
labels[:, 0],
|
| 148 |
+
labels[:, -1],
|
| 149 |
+
)
|
| 150 |
+
)
|
| 151 |
+
)
|
| 152 |
+
if spheroid_id != 0
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
debris_outline = _draw_debris_outlines(preview_small, debris_small)
|
| 156 |
+
debris_outline = _draw_spheroids(
|
| 157 |
+
debris_outline,
|
| 158 |
+
labels_small,
|
| 159 |
+
boundary_spheroid_ids,
|
| 160 |
+
)
|
| 161 |
+
debris_outline = _add_title(
|
| 162 |
+
debris_outline,
|
| 163 |
+
"cyan: within edge tolerance | orange: exceeds tolerance | magenta: excluded",
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
valid = (labels_small > 0) & ~debris_small
|
| 167 |
+
heatmap = np.full((*dab_small.shape, 3), 235, dtype=np.uint8)
|
| 168 |
+
if valid.any():
|
| 169 |
+
scaled_dab = np.clip(dab_small / dab_vmax, 0, 1)
|
| 170 |
+
colors = cv2.applyColorMap(
|
| 171 |
+
(scaled_dab * 255).astype(np.uint8),
|
| 172 |
+
cv2.COLORMAP_INFERNO,
|
| 173 |
+
)
|
| 174 |
+
colors = cv2.cvtColor(colors, cv2.COLOR_BGR2RGB)
|
| 175 |
+
heatmap[valid] = colors[valid]
|
| 176 |
+
|
| 177 |
+
heatmap[debris_small & (labels_small > 0)] = (255, 0, 255)
|
| 178 |
+
heatmap = _draw_spheroids(
|
| 179 |
+
heatmap,
|
| 180 |
+
labels_small,
|
| 181 |
+
boundary_spheroid_ids,
|
| 182 |
+
)
|
| 183 |
+
heatmap = _add_title(
|
| 184 |
+
heatmap,
|
| 185 |
+
(
|
| 186 |
+
f"DAB signal (fixed 0-{dab_vmax:g}) | "
|
| 187 |
+
"orange: exceeds edge tolerance"
|
| 188 |
+
),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
panels = [debris_outline, heatmap]
|
| 192 |
+
if positive_threshold is not None:
|
| 193 |
+
positive = (
|
| 194 |
+
(labels > 0)
|
| 195 |
+
& (debris_mask == 0)
|
| 196 |
+
& (dab >= positive_threshold)
|
| 197 |
+
)
|
| 198 |
+
positive_small = _resize(
|
| 199 |
+
positive.astype(np.uint8),
|
| 200 |
+
max_dimension,
|
| 201 |
+
nearest=True,
|
| 202 |
+
).astype(bool)
|
| 203 |
+
|
| 204 |
+
binary_positive = np.zeros((*labels_small.shape, 3), dtype=np.uint8)
|
| 205 |
+
binary_positive[positive_small] = (255, 255, 255)
|
| 206 |
+
binary_positive = _add_title(
|
| 207 |
+
binary_positive,
|
| 208 |
+
f"white: DAB-positive | threshold >= {positive_threshold:.4g}",
|
| 209 |
+
)
|
| 210 |
+
panels.append(binary_positive)
|
| 211 |
+
|
| 212 |
+
return np.concatenate(panels, axis=1)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def save_qc_image(
|
| 216 |
+
output_path: str | Path,
|
| 217 |
+
preview: np.ndarray,
|
| 218 |
+
labels: np.ndarray,
|
| 219 |
+
debris_mask: np.ndarray,
|
| 220 |
+
dab: np.ndarray,
|
| 221 |
+
*,
|
| 222 |
+
boundary_spheroid_ids: set[int] | None = None,
|
| 223 |
+
positive_threshold: float | None = None,
|
| 224 |
+
dab_vmax: float = DEFAULT_DAB_VMAX,
|
| 225 |
+
max_dimension: int = DEFAULT_QC_MAX_DIMENSION,
|
| 226 |
+
) -> Path:
|
| 227 |
+
"""Create and save the RGB QC image, returning its output path."""
|
| 228 |
+
output_path = Path(output_path)
|
| 229 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 230 |
+
qc_image = create_qc_image(
|
| 231 |
+
preview,
|
| 232 |
+
labels,
|
| 233 |
+
debris_mask,
|
| 234 |
+
dab,
|
| 235 |
+
boundary_spheroid_ids=boundary_spheroid_ids,
|
| 236 |
+
positive_threshold=positive_threshold,
|
| 237 |
+
dab_vmax=dab_vmax,
|
| 238 |
+
max_dimension=max_dimension,
|
| 239 |
+
)
|
| 240 |
+
saved = cv2.imwrite(
|
| 241 |
+
str(output_path),
|
| 242 |
+
cv2.cvtColor(qc_image, cv2.COLOR_RGB2BGR),
|
| 243 |
+
)
|
| 244 |
+
if not saved:
|
| 245 |
+
raise OSError(f"Could not write QC image: {output_path}")
|
| 246 |
+
return output_path
|
ihc_quantification.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
ihc_quantification_gpt.py
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
ihc_quantification_simplified.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import re
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import tifffile as tif
|
| 9 |
+
from scipy.ndimage import binary_fill_holes
|
| 10 |
+
from skimage.color import hdx_from_rgb, separate_stains
|
| 11 |
+
from skimage.segmentation import watershed
|
| 12 |
+
from skimage.util import img_as_float32, img_as_ubyte
|
| 13 |
+
|
| 14 |
+
from cleaning import INPAINT_RADIUS, _as_rgb_uint8, create_debris_mask
|
| 15 |
+
from ihc_qc import save_qc_image
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
IMAGE_EXTENSIONS = {".tif", ".tiff"}
|
| 19 |
+
DEFAULT_OUTPUT_PATH = Path("out/quantification/ihc_quantification.csv")
|
| 20 |
+
MAX_BOUNDARY_CONTACT_RATIO = 0.3
|
| 21 |
+
WATERSHED_MARKER_CORE_FRACTION = 0.55
|
| 22 |
+
WATERSHED_MINIMUM_RELATIVE_REGION_AREA = 0.5
|
| 23 |
+
CONCENTRATION_PATTERN = re.compile(
|
| 24 |
+
r"^\d+(?:[.,]\d+)?(?:pM|nM|uM|µM|μM|mM)$",
|
| 25 |
+
re.IGNORECASE,
|
| 26 |
+
)
|
| 27 |
+
MAGNIFICATION_PATTERN = re.compile(r"^\d+(?:[.,]\d+)?x$", re.IGNORECASE)
|
| 28 |
+
MARKER_PATTERN = re.compile(r"^M\d+(?:[.,]\d+)?$", re.IGNORECASE)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def parse_filename(image_path: str | Path) -> dict:
|
| 32 |
+
parts = Path(image_path).stem.split()
|
| 33 |
+
if len(parts) < 5:
|
| 34 |
+
raise ValueError(
|
| 35 |
+
f"Could not read metadata from filename: {Path(image_path).name}"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
magnification_index = next(
|
| 39 |
+
(
|
| 40 |
+
index
|
| 41 |
+
for index in range(len(parts) - 1, 1, -1)
|
| 42 |
+
if MAGNIFICATION_PATTERN.fullmatch(parts[index])
|
| 43 |
+
),
|
| 44 |
+
None,
|
| 45 |
+
)
|
| 46 |
+
if magnification_index is None or magnification_index < 3:
|
| 47 |
+
raise ValueError(
|
| 48 |
+
f"Could not read magnification from filename: {Path(image_path).name}"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
trailing_tokens = parts[magnification_index + 1 :]
|
| 52 |
+
number_indices = [
|
| 53 |
+
index for index, token in enumerate(trailing_tokens) if token.isdigit()
|
| 54 |
+
]
|
| 55 |
+
if len(number_indices) != 1:
|
| 56 |
+
raise ValueError(
|
| 57 |
+
f"Could not read a unique image number from filename: "
|
| 58 |
+
f"{Path(image_path).name}"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
number_index = number_indices[0]
|
| 62 |
+
image_number = int(trailing_tokens[number_index])
|
| 63 |
+
image_note = " ".join(
|
| 64 |
+
token for index, token in enumerate(trailing_tokens) if index != number_index
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
metadata_end = magnification_index
|
| 68 |
+
if MARKER_PATTERN.fullmatch(parts[magnification_index - 1]):
|
| 69 |
+
metadata_end -= 1
|
| 70 |
+
metadata_tokens = parts[2:metadata_end]
|
| 71 |
+
treatment_tokens = [
|
| 72 |
+
token
|
| 73 |
+
for token in metadata_tokens
|
| 74 |
+
if (token == "+" or not token.startswith("+"))
|
| 75 |
+
and not CONCENTRATION_PATTERN.fullmatch(token)
|
| 76 |
+
]
|
| 77 |
+
if not treatment_tokens:
|
| 78 |
+
raise ValueError(
|
| 79 |
+
f"Could not read treatment from filename: {Path(image_path).name}"
|
| 80 |
+
)
|
| 81 |
+
treatment = re.sub(r"\s*\+\s*", "+", " ".join(treatment_tokens))
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
"image_name": Path(image_path).name,
|
| 85 |
+
"image_path": str(Path(image_path).resolve()),
|
| 86 |
+
"treatment": treatment,
|
| 87 |
+
"image_note": image_note,
|
| 88 |
+
"image_number": image_number,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def read_original_rgb(image_path: str) -> np.ndarray:
|
| 93 |
+
image_path = Path(image_path)
|
| 94 |
+
if image_path.suffix.lower() in {".tif", ".tiff"}:
|
| 95 |
+
return tif.imread(image_path)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def extract_dab(rgb: np.ndarray) -> np.ndarray:
|
| 99 |
+
stains = separate_stains(rgb, hdx_from_rgb)
|
| 100 |
+
dab = stains[..., 1].astype(np.float32)
|
| 101 |
+
|
| 102 |
+
return dab
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def split_touching_spheroids(
|
| 106 |
+
tissue_mask: np.ndarray,
|
| 107 |
+
min_area: int,
|
| 108 |
+
marker_core_fraction: float = WATERSHED_MARKER_CORE_FRACTION,
|
| 109 |
+
minimum_relative_region_area: float = WATERSHED_MINIMUM_RELATIVE_REGION_AREA,
|
| 110 |
+
) -> np.ndarray:
|
| 111 |
+
if not 0 < marker_core_fraction <= 1:
|
| 112 |
+
raise ValueError(
|
| 113 |
+
"marker_core_fraction must be greater than 0 and at most 1."
|
| 114 |
+
)
|
| 115 |
+
if not 0 <= minimum_relative_region_area <= 1:
|
| 116 |
+
raise ValueError(
|
| 117 |
+
"minimum_relative_region_area must be between 0 and 1."
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
count, components, stats, _ = cv2.connectedComponentsWithStats(
|
| 121 |
+
tissue_mask.astype(np.uint8),
|
| 122 |
+
connectivity=8,
|
| 123 |
+
)
|
| 124 |
+
kept_components = [
|
| 125 |
+
component
|
| 126 |
+
for component in range(1, count)
|
| 127 |
+
if stats[component, cv2.CC_STAT_AREA] >= min_area
|
| 128 |
+
]
|
| 129 |
+
filtered_mask = np.isin(components, kept_components)
|
| 130 |
+
|
| 131 |
+
distance = cv2.distanceTransform(
|
| 132 |
+
filtered_mask.astype(np.uint8),
|
| 133 |
+
cv2.DIST_L2,
|
| 134 |
+
5,
|
| 135 |
+
)
|
| 136 |
+
marker_mask = np.zeros(filtered_mask.shape, dtype=np.uint8)
|
| 137 |
+
|
| 138 |
+
for component in kept_components:
|
| 139 |
+
component_mask = components == component
|
| 140 |
+
maximum_distance = distance[component_mask].max()
|
| 141 |
+
marker_mask[
|
| 142 |
+
component_mask & (distance >= marker_core_fraction * maximum_distance)
|
| 143 |
+
] = 1
|
| 144 |
+
|
| 145 |
+
_, markers = cv2.connectedComponents(marker_mask, connectivity=8)
|
| 146 |
+
candidate_labels = watershed(
|
| 147 |
+
-distance,
|
| 148 |
+
markers,
|
| 149 |
+
mask=filtered_mask,
|
| 150 |
+
).astype(np.int32)
|
| 151 |
+
|
| 152 |
+
labels = np.zeros(candidate_labels.shape, dtype=np.int32)
|
| 153 |
+
next_label = 1
|
| 154 |
+
|
| 155 |
+
for component in kept_components:
|
| 156 |
+
component_mask = components == component
|
| 157 |
+
regions = [
|
| 158 |
+
region
|
| 159 |
+
for region in np.unique(candidate_labels[component_mask])
|
| 160 |
+
if region != 0
|
| 161 |
+
]
|
| 162 |
+
region_areas = [
|
| 163 |
+
int((candidate_labels[component_mask] == region).sum())
|
| 164 |
+
for region in regions
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
split_is_balanced = len(regions) > 1 and min(
|
| 168 |
+
region_areas
|
| 169 |
+
) >= minimum_relative_region_area * max(region_areas)
|
| 170 |
+
if not split_is_balanced:
|
| 171 |
+
labels[component_mask] = next_label
|
| 172 |
+
next_label += 1
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
for region in regions:
|
| 176 |
+
labels[component_mask & (candidate_labels == region)] = next_label
|
| 177 |
+
next_label += 1
|
| 178 |
+
|
| 179 |
+
return labels
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def segment_spheroids(
|
| 183 |
+
preview: np.ndarray,
|
| 184 |
+
marker_core_fraction: float = WATERSHED_MARKER_CORE_FRACTION,
|
| 185 |
+
minimum_relative_region_area: float = WATERSHED_MINIMUM_RELATIVE_REGION_AREA,
|
| 186 |
+
) -> np.ndarray:
|
| 187 |
+
gray = cv2.cvtColor(preview, cv2.COLOR_RGB2GRAY)
|
| 188 |
+
|
| 189 |
+
# thresholding
|
| 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 |
+
)
|
| 197 |
+
|
| 198 |
+
# filtering
|
| 199 |
+
opening_kernel = 5 # largest noise I want removed
|
| 200 |
+
opening_kernel = cv2.getStructuringElement(
|
| 201 |
+
cv2.MORPH_ELLIPSE,
|
| 202 |
+
(opening_kernel, opening_kernel),
|
| 203 |
+
)
|
| 204 |
+
tissue_thresh = cv2.morphologyEx(
|
| 205 |
+
tissue_thresh,
|
| 206 |
+
cv2.MORPH_OPEN,
|
| 207 |
+
opening_kernel,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
closing_kernel = 51 # smallest element I want to connect
|
| 211 |
+
closing_kernel = cv2.getStructuringElement(
|
| 212 |
+
cv2.MORPH_ELLIPSE,
|
| 213 |
+
(closing_kernel, closing_kernel),
|
| 214 |
+
)
|
| 215 |
+
tissue_thresh = cv2.morphologyEx(
|
| 216 |
+
tissue_thresh,
|
| 217 |
+
cv2.MORPH_CLOSE,
|
| 218 |
+
closing_kernel,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
tissue_filled = binary_fill_holes(tissue_thresh > 0).astype(np.uint8) * 255
|
| 222 |
+
|
| 223 |
+
min_area_fraction = (
|
| 224 |
+
0.0025 # component must occupy at least 0.25% of the complete image
|
| 225 |
+
)
|
| 226 |
+
min_area = round(preview.shape[0] * preview.shape[1] * min_area_fraction)
|
| 227 |
+
split_labels = split_touching_spheroids(
|
| 228 |
+
tissue_filled > 0,
|
| 229 |
+
min_area,
|
| 230 |
+
marker_core_fraction=marker_core_fraction,
|
| 231 |
+
minimum_relative_region_area=minimum_relative_region_area,
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
components = []
|
| 235 |
+
for component in np.unique(split_labels):
|
| 236 |
+
if component == 0:
|
| 237 |
+
continue
|
| 238 |
+
|
| 239 |
+
y, x = np.nonzero(split_labels == component)
|
| 240 |
+
if len(x) >= min_area:
|
| 241 |
+
components.append((component, y.mean(), x.mean()))
|
| 242 |
+
|
| 243 |
+
components.sort(key=lambda component: (component[1], component[2]))
|
| 244 |
+
|
| 245 |
+
labels = np.zeros(split_labels.shape, dtype=np.int32)
|
| 246 |
+
for spheroid_id, (component, _, _) in enumerate(components, start=1):
|
| 247 |
+
labels[split_labels == component] = spheroid_id
|
| 248 |
+
|
| 249 |
+
return labels
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def measure_spheroids(
|
| 253 |
+
labels: np.ndarray,
|
| 254 |
+
debris_mask: np.ndarray,
|
| 255 |
+
dab: np.ndarray,
|
| 256 |
+
positive_threshold: None | float = None,
|
| 257 |
+
max_boundary_contact_ratio: float = MAX_BOUNDARY_CONTACT_RATIO,
|
| 258 |
+
) -> list[dict]:
|
| 259 |
+
if max_boundary_contact_ratio < 0:
|
| 260 |
+
raise ValueError("max_boundary_contact_ratio must be nonnegative.")
|
| 261 |
+
|
| 262 |
+
measurements = []
|
| 263 |
+
debris = debris_mask > 0
|
| 264 |
+
image_boundary = np.zeros(labels.shape, dtype=bool)
|
| 265 |
+
image_boundary[[0, -1], :] = True
|
| 266 |
+
image_boundary[:, [0, -1]] = True
|
| 267 |
+
|
| 268 |
+
for spheroid_id in np.unique(labels):
|
| 269 |
+
if spheroid_id == 0:
|
| 270 |
+
continue
|
| 271 |
+
|
| 272 |
+
spheroid = labels == spheroid_id
|
| 273 |
+
valid = spheroid & ~debris
|
| 274 |
+
|
| 275 |
+
spheroid_area = int(spheroid.sum())
|
| 276 |
+
valid_area = int(valid.sum())
|
| 277 |
+
debris_area = int((spheroid & debris).sum())
|
| 278 |
+
boundary_contact_px = int((spheroid & image_boundary).sum())
|
| 279 |
+
equivalent_diameter = float(2 * np.sqrt(spheroid_area / np.pi))
|
| 280 |
+
boundary_contact_ratio = boundary_contact_px / equivalent_diameter
|
| 281 |
+
touches_image_boundary = boundary_contact_px > 0
|
| 282 |
+
exceeds_boundary_tolerance = bool(
|
| 283 |
+
boundary_contact_ratio > max_boundary_contact_ratio
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
if valid_area == 0:
|
| 287 |
+
continue
|
| 288 |
+
|
| 289 |
+
dab_values = dab[valid]
|
| 290 |
+
|
| 291 |
+
result = {
|
| 292 |
+
"spheroid_id": int(spheroid_id),
|
| 293 |
+
"touches_image_boundary": touches_image_boundary,
|
| 294 |
+
"exceeds_boundary_tolerance": exceeds_boundary_tolerance,
|
| 295 |
+
"boundary_contact_ratio": boundary_contact_ratio,
|
| 296 |
+
"spheroid_area_px": spheroid_area,
|
| 297 |
+
"valid_area_px": valid_area,
|
| 298 |
+
"debris_area_px": debris_area,
|
| 299 |
+
"debris_fraction": debris_area / spheroid_area,
|
| 300 |
+
"mean_dab": dab_values.mean(),
|
| 301 |
+
"median_dab": np.median(dab_values),
|
| 302 |
+
"p75_dab": np.percentile(dab_values, 75),
|
| 303 |
+
"p90_dab": np.percentile(dab_values, 90),
|
| 304 |
+
"p95_dab": np.percentile(dab_values, 95),
|
| 305 |
+
"p99_dab": np.percentile(dab_values, 99),
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
if positive_threshold is not None:
|
| 309 |
+
positive = valid & (dab >= positive_threshold)
|
| 310 |
+
positive_values = dab[positive]
|
| 311 |
+
|
| 312 |
+
result["positive_fraction"] = positive.sum() / valid.sum()
|
| 313 |
+
result["positive_area_px"] = positive.sum()
|
| 314 |
+
result["positive_mean"] = (
|
| 315 |
+
positive_values.mean() if positive_values.size else np.nan
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
measurements.append(result)
|
| 319 |
+
|
| 320 |
+
return measurements
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def quantify_image(
|
| 324 |
+
image_path: str | Path,
|
| 325 |
+
qc_path: str | Path | None = None,
|
| 326 |
+
positive_threshold: float | None = None,
|
| 327 |
+
max_boundary_contact_ratio: float = MAX_BOUNDARY_CONTACT_RATIO,
|
| 328 |
+
marker_core_fraction: float = WATERSHED_MARKER_CORE_FRACTION,
|
| 329 |
+
minimum_relative_region_area: float = WATERSHED_MINIMUM_RELATIVE_REGION_AREA,
|
| 330 |
+
) -> list[dict]:
|
| 331 |
+
metadata = parse_filename(image_path)
|
| 332 |
+
original = read_original_rgb(image_path)
|
| 333 |
+
processing_preview = _as_rgb_uint8(original)
|
| 334 |
+
qc_preview = img_as_ubyte(original)
|
| 335 |
+
|
| 336 |
+
debris_mask = create_debris_mask(qc_preview)
|
| 337 |
+
cleaned_preview = cv2.inpaint(
|
| 338 |
+
processing_preview,
|
| 339 |
+
debris_mask,
|
| 340 |
+
INPAINT_RADIUS,
|
| 341 |
+
cv2.INPAINT_TELEA,
|
| 342 |
+
)
|
| 343 |
+
spheroid_labels = segment_spheroids(
|
| 344 |
+
cleaned_preview,
|
| 345 |
+
marker_core_fraction=marker_core_fraction,
|
| 346 |
+
minimum_relative_region_area=minimum_relative_region_area,
|
| 347 |
+
)
|
| 348 |
+
if not spheroid_labels.max():
|
| 349 |
+
raise ValueError(
|
| 350 |
+
"No spheroids were detected. Review the QC segmentation settings."
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
float_img = img_as_float32(original)
|
| 354 |
+
dab = extract_dab(float_img)
|
| 355 |
+
measurements = measure_spheroids(
|
| 356 |
+
spheroid_labels,
|
| 357 |
+
debris_mask,
|
| 358 |
+
dab,
|
| 359 |
+
positive_threshold=positive_threshold,
|
| 360 |
+
max_boundary_contact_ratio=max_boundary_contact_ratio,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
if qc_path is not None:
|
| 364 |
+
boundary_spheroid_ids = {
|
| 365 |
+
measurement["spheroid_id"]
|
| 366 |
+
for measurement in measurements
|
| 367 |
+
if measurement["exceeds_boundary_tolerance"]
|
| 368 |
+
}
|
| 369 |
+
save_qc_image(
|
| 370 |
+
qc_path,
|
| 371 |
+
qc_preview,
|
| 372 |
+
spheroid_labels,
|
| 373 |
+
debris_mask,
|
| 374 |
+
dab,
|
| 375 |
+
boundary_spheroid_ids=boundary_spheroid_ids,
|
| 376 |
+
positive_threshold=positive_threshold,
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
return [{**metadata, **measurement} for measurement in measurements]
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def find_images(data_path: str | Path) -> list[Path]:
|
| 383 |
+
data_path = Path(data_path)
|
| 384 |
+
if data_path.is_file():
|
| 385 |
+
if data_path.suffix.lower() not in IMAGE_EXTENSIONS:
|
| 386 |
+
raise ValueError(f"Input is not a TIFF image: {data_path}")
|
| 387 |
+
image_paths = [data_path]
|
| 388 |
+
elif data_path.is_dir():
|
| 389 |
+
image_paths = sorted(
|
| 390 |
+
path
|
| 391 |
+
for path in data_path.rglob("*")
|
| 392 |
+
if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
|
| 393 |
+
)
|
| 394 |
+
else:
|
| 395 |
+
raise FileNotFoundError(f"Data path does not exist: {data_path}")
|
| 396 |
+
|
| 397 |
+
if not image_paths:
|
| 398 |
+
raise ValueError(f"No TIFF images found in: {data_path}")
|
| 399 |
+
|
| 400 |
+
return image_paths
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def quantify_data(
|
| 404 |
+
data_path: str | Path,
|
| 405 |
+
qc_dir: str | Path | None = None,
|
| 406 |
+
positive_threshold: float | None = None,
|
| 407 |
+
max_boundary_contact_ratio: float = MAX_BOUNDARY_CONTACT_RATIO,
|
| 408 |
+
marker_core_fraction: float = WATERSHED_MARKER_CORE_FRACTION,
|
| 409 |
+
minimum_relative_region_area: float = WATERSHED_MINIMUM_RELATIVE_REGION_AREA,
|
| 410 |
+
) -> pd.DataFrame:
|
| 411 |
+
measurements = []
|
| 412 |
+
qc_dir = Path(qc_dir) if qc_dir is not None else None
|
| 413 |
+
|
| 414 |
+
for image_path in find_images(data_path):
|
| 415 |
+
qc_path = qc_dir / f"{image_path.stem}_qc.png" if qc_dir is not None else None
|
| 416 |
+
image_measurements = quantify_image(
|
| 417 |
+
image_path,
|
| 418 |
+
qc_path,
|
| 419 |
+
positive_threshold=positive_threshold,
|
| 420 |
+
max_boundary_contact_ratio=max_boundary_contact_ratio,
|
| 421 |
+
marker_core_fraction=marker_core_fraction,
|
| 422 |
+
minimum_relative_region_area=minimum_relative_region_area,
|
| 423 |
+
)
|
| 424 |
+
measurements.extend(image_measurements)
|
| 425 |
+
message = f"{image_path}: {len(image_measurements)} spheroids"
|
| 426 |
+
if qc_path is not None:
|
| 427 |
+
message += f"; QC: {qc_path}"
|
| 428 |
+
print(message)
|
| 429 |
+
|
| 430 |
+
if not measurements:
|
| 431 |
+
raise ValueError("No valid spheroid measurements were produced.")
|
| 432 |
+
|
| 433 |
+
return pd.DataFrame(measurements)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def parse_args() -> argparse.Namespace:
|
| 437 |
+
parser = argparse.ArgumentParser(
|
| 438 |
+
description="Quantify spheroid DAB intensity and save one row per spheroid."
|
| 439 |
+
)
|
| 440 |
+
parser.add_argument(
|
| 441 |
+
"data_path",
|
| 442 |
+
type=Path,
|
| 443 |
+
help="TIFF image or directory containing TIFF images.",
|
| 444 |
+
)
|
| 445 |
+
parser.add_argument(
|
| 446 |
+
"--output",
|
| 447 |
+
type=Path,
|
| 448 |
+
default=DEFAULT_OUTPUT_PATH,
|
| 449 |
+
help=f"Output CSV path (default: {DEFAULT_OUTPUT_PATH}).",
|
| 450 |
+
)
|
| 451 |
+
parser.add_argument(
|
| 452 |
+
"--positive-threshold",
|
| 453 |
+
type=float,
|
| 454 |
+
default=None,
|
| 455 |
+
help="Optional DAB-positive threshold used for measurements and QC.",
|
| 456 |
+
)
|
| 457 |
+
parser.add_argument(
|
| 458 |
+
"--max-boundary-contact-ratio",
|
| 459 |
+
type=float,
|
| 460 |
+
default=MAX_BOUNDARY_CONTACT_RATIO,
|
| 461 |
+
help=(
|
| 462 |
+
"Maximum tolerated boundary contact ratio before a spheroid is flagged "
|
| 463 |
+
f"(default: {MAX_BOUNDARY_CONTACT_RATIO})."
|
| 464 |
+
),
|
| 465 |
+
)
|
| 466 |
+
parser.add_argument(
|
| 467 |
+
"--watershed-marker-core-fraction",
|
| 468 |
+
type=float,
|
| 469 |
+
default=WATERSHED_MARKER_CORE_FRACTION,
|
| 470 |
+
help=(
|
| 471 |
+
"Distance-transform fraction used to create watershed markers "
|
| 472 |
+
f"(default: {WATERSHED_MARKER_CORE_FRACTION})."
|
| 473 |
+
),
|
| 474 |
+
)
|
| 475 |
+
parser.add_argument(
|
| 476 |
+
"--watershed-minimum-relative-region-area",
|
| 477 |
+
type=float,
|
| 478 |
+
default=WATERSHED_MINIMUM_RELATIVE_REGION_AREA,
|
| 479 |
+
help=(
|
| 480 |
+
"Smallest accepted watershed region relative to the largest region "
|
| 481 |
+
f"(default: {WATERSHED_MINIMUM_RELATIVE_REGION_AREA})."
|
| 482 |
+
),
|
| 483 |
+
)
|
| 484 |
+
return parser.parse_args()
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def main() -> None:
|
| 488 |
+
args = parse_args()
|
| 489 |
+
qc_dir = args.output.parent / "qc"
|
| 490 |
+
results = quantify_data(
|
| 491 |
+
args.data_path,
|
| 492 |
+
qc_dir,
|
| 493 |
+
positive_threshold=args.positive_threshold,
|
| 494 |
+
max_boundary_contact_ratio=args.max_boundary_contact_ratio,
|
| 495 |
+
marker_core_fraction=args.watershed_marker_core_fraction,
|
| 496 |
+
minimum_relative_region_area=args.watershed_minimum_relative_region_area,
|
| 497 |
+
)
|
| 498 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 499 |
+
results.to_csv(args.output, index=False)
|
| 500 |
+
print(f"Saved {len(results)} spheroids to {args.output}")
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
if __name__ == "__main__":
|
| 504 |
+
main()
|
pyproject.toml
CHANGED
|
@@ -73,7 +73,9 @@ dependencies = [
|
|
| 73 |
"notebook-shim==0.2.4",
|
| 74 |
"numpy==2.4.6",
|
| 75 |
"opencv-python==4.13.0.92",
|
|
|
|
| 76 |
"packaging==26.2",
|
|
|
|
| 77 |
"pandocfilters==1.5.1",
|
| 78 |
"parso==0.8.7",
|
| 79 |
"pexpect==4.9.0",
|
|
@@ -99,6 +101,7 @@ dependencies = [
|
|
| 99 |
"rpds-py==2026.5.1",
|
| 100 |
"scikit-image==0.26.0",
|
| 101 |
"scipy==1.17.1",
|
|
|
|
| 102 |
"send2trash==2.1.0",
|
| 103 |
"setuptools==82.0.1",
|
| 104 |
"six==1.17.0",
|
|
|
|
| 73 |
"notebook-shim==0.2.4",
|
| 74 |
"numpy==2.4.6",
|
| 75 |
"opencv-python==4.13.0.92",
|
| 76 |
+
"openpyxl>=3.1.5",
|
| 77 |
"packaging==26.2",
|
| 78 |
+
"pandas==3.0.3",
|
| 79 |
"pandocfilters==1.5.1",
|
| 80 |
"parso==0.8.7",
|
| 81 |
"pexpect==4.9.0",
|
|
|
|
| 101 |
"rpds-py==2026.5.1",
|
| 102 |
"scikit-image==0.26.0",
|
| 103 |
"scipy==1.17.1",
|
| 104 |
+
"seaborn>=0.13.2",
|
| 105 |
"send2trash==2.1.0",
|
| 106 |
"setuptools==82.0.1",
|
| 107 |
"six==1.17.0",
|
requirements.txt
CHANGED
|
@@ -2,3 +2,5 @@ numpy==2.2.6
|
|
| 2 |
opencv-python-headless==4.13.0.92
|
| 3 |
imagecodecs==2026.6.26
|
| 4 |
tifffile==2025.5.10
|
|
|
|
|
|
|
|
|
| 2 |
opencv-python-headless==4.13.0.92
|
| 3 |
imagecodecs==2026.6.26
|
| 4 |
tifffile==2025.5.10
|
| 5 |
+
scikit-image==0.26.0
|
| 6 |
+
pandas==3.0.3
|
uv.lock
CHANGED
|
@@ -552,6 +552,15 @@ wheels = [
|
|
| 552 |
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
|
| 553 |
]
|
| 554 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
[[package]]
|
| 556 |
name = "executing"
|
| 557 |
version = "2.2.1"
|
|
@@ -905,7 +914,9 @@ dependencies = [
|
|
| 905 |
{ name = "notebook-shim" },
|
| 906 |
{ name = "numpy" },
|
| 907 |
{ name = "opencv-python" },
|
|
|
|
| 908 |
{ name = "packaging" },
|
|
|
|
| 909 |
{ name = "pandocfilters" },
|
| 910 |
{ name = "parso" },
|
| 911 |
{ name = "pexpect" },
|
|
@@ -931,6 +942,7 @@ dependencies = [
|
|
| 931 |
{ name = "rpds-py" },
|
| 932 |
{ name = "scikit-image" },
|
| 933 |
{ name = "scipy" },
|
|
|
|
| 934 |
{ name = "send2trash" },
|
| 935 |
{ name = "setuptools" },
|
| 936 |
{ name = "six" },
|
|
@@ -1024,7 +1036,9 @@ requires-dist = [
|
|
| 1024 |
{ name = "notebook-shim", specifier = "==0.2.4" },
|
| 1025 |
{ name = "numpy", specifier = "==2.4.6" },
|
| 1026 |
{ name = "opencv-python", specifier = "==4.13.0.92" },
|
|
|
|
| 1027 |
{ name = "packaging", specifier = "==26.2" },
|
|
|
|
| 1028 |
{ name = "pandocfilters", specifier = "==1.5.1" },
|
| 1029 |
{ name = "parso", specifier = "==0.8.7" },
|
| 1030 |
{ name = "pexpect", specifier = "==4.9.0" },
|
|
@@ -1050,6 +1064,7 @@ requires-dist = [
|
|
| 1050 |
{ name = "rpds-py", specifier = "==2026.5.1" },
|
| 1051 |
{ name = "scikit-image", specifier = "==0.26.0" },
|
| 1052 |
{ name = "scipy", specifier = "==1.17.1" },
|
|
|
|
| 1053 |
{ name = "send2trash", specifier = "==2.1.0" },
|
| 1054 |
{ name = "setuptools", specifier = "==82.0.1" },
|
| 1055 |
{ name = "six", specifier = "==1.17.0" },
|
|
@@ -1925,6 +1940,18 @@ wheels = [
|
|
| 1925 |
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
|
| 1926 |
]
|
| 1927 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1928 |
[[package]]
|
| 1929 |
name = "orjson"
|
| 1930 |
version = "3.11.9"
|
|
@@ -2802,6 +2829,20 @@ wheels = [
|
|
| 2802 |
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
|
| 2803 |
]
|
| 2804 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2805 |
[[package]]
|
| 2806 |
name = "semantic-version"
|
| 2807 |
version = "2.10.0"
|
|
|
|
| 552 |
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
|
| 553 |
]
|
| 554 |
|
| 555 |
+
[[package]]
|
| 556 |
+
name = "et-xmlfile"
|
| 557 |
+
version = "2.0.0"
|
| 558 |
+
source = { registry = "https://pypi.org/simple" }
|
| 559 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
| 560 |
+
wheels = [
|
| 561 |
+
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
| 562 |
+
]
|
| 563 |
+
|
| 564 |
[[package]]
|
| 565 |
name = "executing"
|
| 566 |
version = "2.2.1"
|
|
|
|
| 914 |
{ name = "notebook-shim" },
|
| 915 |
{ name = "numpy" },
|
| 916 |
{ name = "opencv-python" },
|
| 917 |
+
{ name = "openpyxl" },
|
| 918 |
{ name = "packaging" },
|
| 919 |
+
{ name = "pandas" },
|
| 920 |
{ name = "pandocfilters" },
|
| 921 |
{ name = "parso" },
|
| 922 |
{ name = "pexpect" },
|
|
|
|
| 942 |
{ name = "rpds-py" },
|
| 943 |
{ name = "scikit-image" },
|
| 944 |
{ name = "scipy" },
|
| 945 |
+
{ name = "seaborn" },
|
| 946 |
{ name = "send2trash" },
|
| 947 |
{ name = "setuptools" },
|
| 948 |
{ name = "six" },
|
|
|
|
| 1036 |
{ name = "notebook-shim", specifier = "==0.2.4" },
|
| 1037 |
{ name = "numpy", specifier = "==2.4.6" },
|
| 1038 |
{ name = "opencv-python", specifier = "==4.13.0.92" },
|
| 1039 |
+
{ name = "openpyxl", specifier = ">=3.1.5" },
|
| 1040 |
{ name = "packaging", specifier = "==26.2" },
|
| 1041 |
+
{ name = "pandas", specifier = "==3.0.3" },
|
| 1042 |
{ name = "pandocfilters", specifier = "==1.5.1" },
|
| 1043 |
{ name = "parso", specifier = "==0.8.7" },
|
| 1044 |
{ name = "pexpect", specifier = "==4.9.0" },
|
|
|
|
| 1064 |
{ name = "rpds-py", specifier = "==2026.5.1" },
|
| 1065 |
{ name = "scikit-image", specifier = "==0.26.0" },
|
| 1066 |
{ name = "scipy", specifier = "==1.17.1" },
|
| 1067 |
+
{ name = "seaborn", specifier = ">=0.13.2" },
|
| 1068 |
{ name = "send2trash", specifier = "==2.1.0" },
|
| 1069 |
{ name = "setuptools", specifier = "==82.0.1" },
|
| 1070 |
{ name = "six", specifier = "==1.17.0" },
|
|
|
|
| 1940 |
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
|
| 1941 |
]
|
| 1942 |
|
| 1943 |
+
[[package]]
|
| 1944 |
+
name = "openpyxl"
|
| 1945 |
+
version = "3.1.5"
|
| 1946 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1947 |
+
dependencies = [
|
| 1948 |
+
{ name = "et-xmlfile" },
|
| 1949 |
+
]
|
| 1950 |
+
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
| 1951 |
+
wheels = [
|
| 1952 |
+
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
| 1953 |
+
]
|
| 1954 |
+
|
| 1955 |
[[package]]
|
| 1956 |
name = "orjson"
|
| 1957 |
version = "3.11.9"
|
|
|
|
| 2829 |
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
|
| 2830 |
]
|
| 2831 |
|
| 2832 |
+
[[package]]
|
| 2833 |
+
name = "seaborn"
|
| 2834 |
+
version = "0.13.2"
|
| 2835 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2836 |
+
dependencies = [
|
| 2837 |
+
{ name = "matplotlib" },
|
| 2838 |
+
{ name = "numpy" },
|
| 2839 |
+
{ name = "pandas" },
|
| 2840 |
+
]
|
| 2841 |
+
sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" }
|
| 2842 |
+
wheels = [
|
| 2843 |
+
{ url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" },
|
| 2844 |
+
]
|
| 2845 |
+
|
| 2846 |
[[package]]
|
| 2847 |
name = "semantic-version"
|
| 2848 |
version = "2.10.0"
|