kuko6 commited on
Commit
ef6dbdd
·
1 Parent(s): eab7c24

Cleaned up

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