kuko6 commited on
Commit
24c963e
·
1 Parent(s): 5dd4f7a

Finished analysis

Browse files
.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"