atlas-patch 1.0.0.post1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atlas_patch/__init__.py +6 -0
- atlas_patch/cli.py +696 -0
- atlas_patch-1.0.0.post1.dist-info/METADATA +722 -0
- atlas_patch-1.0.0.post1.dist-info/RECORD +8 -0
- atlas_patch-1.0.0.post1.dist-info/WHEEL +5 -0
- atlas_patch-1.0.0.post1.dist-info/entry_points.txt +2 -0
- atlas_patch-1.0.0.post1.dist-info/licenses/LICENSE +437 -0
- atlas_patch-1.0.0.post1.dist-info/top_level.txt +1 -0
atlas_patch/__init__.py
ADDED
atlas_patch/cli.py
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import torch
|
|
9
|
+
from tqdm import tqdm
|
|
10
|
+
|
|
11
|
+
from atlas_patch.core.config import (
|
|
12
|
+
AppConfig,
|
|
13
|
+
ExtractionConfig,
|
|
14
|
+
FeatureExtractionConfig,
|
|
15
|
+
OutputConfig,
|
|
16
|
+
ProcessingConfig,
|
|
17
|
+
SegmentationConfig,
|
|
18
|
+
VisualizationConfig,
|
|
19
|
+
)
|
|
20
|
+
from atlas_patch.core.models import Slide
|
|
21
|
+
from atlas_patch.models.patch import PatchFeatureExtractorRegistry, build_default_registry
|
|
22
|
+
from atlas_patch.models.patch.custom import register_feature_extractors_from_module
|
|
23
|
+
from atlas_patch.orchestration.runner import ProcessingRunner
|
|
24
|
+
from atlas_patch.services.extraction import PatchExtractionService
|
|
25
|
+
from atlas_patch.services.feature_embedding import PatchFeatureEmbeddingService, resolve_feature_dtype
|
|
26
|
+
from atlas_patch.services.mpp import CSVMPPResolver
|
|
27
|
+
from atlas_patch.services.segmentation import SAM2SegmentationService
|
|
28
|
+
from atlas_patch.services.visualization import DefaultVisualizationService
|
|
29
|
+
from atlas_patch.services.wsi_loader import DefaultWSILoader
|
|
30
|
+
from atlas_patch.utils import (
|
|
31
|
+
configure_logging,
|
|
32
|
+
install_embedding_log_filter,
|
|
33
|
+
parse_feature_list,
|
|
34
|
+
)
|
|
35
|
+
from atlas_patch.utils.params import get_wsi_files
|
|
36
|
+
from atlas_patch.utils.visualization import visualize_mask_on_thumbnail
|
|
37
|
+
|
|
38
|
+
logging.basicConfig(
|
|
39
|
+
level=logging.WARNING,
|
|
40
|
+
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
41
|
+
)
|
|
42
|
+
logger = logging.getLogger("atlas_patch.cli")
|
|
43
|
+
install_embedding_log_filter()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_config_path() -> Path:
|
|
47
|
+
return Path(__file__).resolve().parent / "configs" / "sam2.1_hiera_t.yaml"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
FEATURE_EXTRACTOR_CHOICES = build_default_registry(device="cpu").available()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Shared option sets -----------------------------------------------------------
|
|
54
|
+
_COMMON_OPTIONS: list = [
|
|
55
|
+
click.argument("wsi_path", type=click.Path(exists=True)),
|
|
56
|
+
click.option(
|
|
57
|
+
"--output",
|
|
58
|
+
"-o",
|
|
59
|
+
type=click.Path(),
|
|
60
|
+
required=True,
|
|
61
|
+
help="Output directory root for generated artifacts.",
|
|
62
|
+
),
|
|
63
|
+
click.option(
|
|
64
|
+
"--patch-size", type=int, required=True, help="Patch size at target magnification."
|
|
65
|
+
),
|
|
66
|
+
click.option(
|
|
67
|
+
"--step-size",
|
|
68
|
+
type=int,
|
|
69
|
+
default=None,
|
|
70
|
+
help="Stride between patches; defaults to patch size when omitted.",
|
|
71
|
+
),
|
|
72
|
+
click.option(
|
|
73
|
+
"--target-mag",
|
|
74
|
+
type=click.IntRange(1, 120),
|
|
75
|
+
required=True,
|
|
76
|
+
help="Target magnification (e.g., 20, 40).",
|
|
77
|
+
),
|
|
78
|
+
click.option(
|
|
79
|
+
"--device",
|
|
80
|
+
type=str,
|
|
81
|
+
default="cuda",
|
|
82
|
+
show_default=True,
|
|
83
|
+
help="Segmentation device (e.g., cuda, cuda:0, cpu).",
|
|
84
|
+
),
|
|
85
|
+
click.option(
|
|
86
|
+
"--tissue-thresh",
|
|
87
|
+
type=float,
|
|
88
|
+
default=0.0,
|
|
89
|
+
show_default=True,
|
|
90
|
+
help="Minimum tissue area fraction.",
|
|
91
|
+
),
|
|
92
|
+
click.option(
|
|
93
|
+
"--white-thresh",
|
|
94
|
+
type=int,
|
|
95
|
+
default=15,
|
|
96
|
+
show_default=True,
|
|
97
|
+
help="Saturation threshold for white filtering.",
|
|
98
|
+
),
|
|
99
|
+
click.option(
|
|
100
|
+
"--black-thresh",
|
|
101
|
+
type=int,
|
|
102
|
+
default=50,
|
|
103
|
+
show_default=True,
|
|
104
|
+
help="RGB threshold for black filtering.",
|
|
105
|
+
),
|
|
106
|
+
click.option(
|
|
107
|
+
"--seg-batch-size", type=int, default=1, show_default=True, help="Segmentation batch."
|
|
108
|
+
),
|
|
109
|
+
click.option(
|
|
110
|
+
"--write-batch", type=int, default=8192, show_default=True, help="HDF5 write batch."
|
|
111
|
+
),
|
|
112
|
+
click.option(
|
|
113
|
+
"--patch-workers",
|
|
114
|
+
type=int,
|
|
115
|
+
default=None,
|
|
116
|
+
show_default=True,
|
|
117
|
+
help="Parallel worker threads for per-slide patch extraction; defaults to CPU count.",
|
|
118
|
+
),
|
|
119
|
+
click.option(
|
|
120
|
+
"--max-open-slides",
|
|
121
|
+
type=int,
|
|
122
|
+
default=200,
|
|
123
|
+
show_default=True,
|
|
124
|
+
help="Upper bound on simultaneously open slides (segmentation + extraction).",
|
|
125
|
+
),
|
|
126
|
+
click.option(
|
|
127
|
+
"--fast-mode/--no-fast-mode",
|
|
128
|
+
default=True,
|
|
129
|
+
show_default=True,
|
|
130
|
+
help="fast-mode skips per-patch content filtering; use --no-fast-mode to enable filtering.",
|
|
131
|
+
),
|
|
132
|
+
click.option("--save-images", is_flag=True, help="Export individual patch PNGs."),
|
|
133
|
+
click.option("--visualize-grids", is_flag=True, help="Render patch grid overlay."),
|
|
134
|
+
click.option("--visualize-mask", is_flag=True, help="Render predicted mask overlay."),
|
|
135
|
+
click.option("--visualize-contours", is_flag=True, help="Render contour overlay."),
|
|
136
|
+
click.option("--recursive", is_flag=True, help="Recursively search directories for WSIs."),
|
|
137
|
+
click.option(
|
|
138
|
+
"--mpp-csv", type=click.Path(exists=True), default=None, help="CSV with custom MPP."
|
|
139
|
+
),
|
|
140
|
+
click.option(
|
|
141
|
+
"--skip-existing/--force", default=True, show_default=True, help="Skip existing H5."
|
|
142
|
+
),
|
|
143
|
+
click.option("--verbose", "-v", is_flag=True, help="Enable debug logging."),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
_FEATURE_OPTIONS: list = [
|
|
147
|
+
click.option(
|
|
148
|
+
"--feature-device",
|
|
149
|
+
type=str,
|
|
150
|
+
default=None,
|
|
151
|
+
help="Device for feature extraction; e.g. cuda, cuda:0, cpu. Defaults to --device.",
|
|
152
|
+
),
|
|
153
|
+
click.option(
|
|
154
|
+
"--feature-extractors",
|
|
155
|
+
required=True,
|
|
156
|
+
type=str,
|
|
157
|
+
help="Space/comma separated feature extractors to run (available: "
|
|
158
|
+
+ ", ".join(FEATURE_EXTRACTOR_CHOICES)
|
|
159
|
+
+ "; add more via --feature-plugin).",
|
|
160
|
+
),
|
|
161
|
+
click.option(
|
|
162
|
+
"--feature-batch-size",
|
|
163
|
+
type=int,
|
|
164
|
+
default=32,
|
|
165
|
+
show_default=True,
|
|
166
|
+
help="Batch size used when embedding patches.",
|
|
167
|
+
),
|
|
168
|
+
click.option(
|
|
169
|
+
"--feature-num-workers",
|
|
170
|
+
type=int,
|
|
171
|
+
default=4,
|
|
172
|
+
show_default=True,
|
|
173
|
+
help="DataLoader worker count for feature extraction.",
|
|
174
|
+
),
|
|
175
|
+
click.option(
|
|
176
|
+
"--feature-precision",
|
|
177
|
+
type=click.Choice(["float32", "float16", "bfloat16"], case_sensitive=False),
|
|
178
|
+
default="float16",
|
|
179
|
+
show_default=True,
|
|
180
|
+
help="Computation precision for feature extraction.",
|
|
181
|
+
),
|
|
182
|
+
click.option(
|
|
183
|
+
"--feature-plugin",
|
|
184
|
+
"feature_plugins",
|
|
185
|
+
type=click.Path(exists=True),
|
|
186
|
+
multiple=True,
|
|
187
|
+
help=(
|
|
188
|
+
"Path(s) to Python modules that register custom feature extractors via "
|
|
189
|
+
"register_feature_extractors(registry, device, dtype, num_workers)."
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _apply_options(func, options: list):
|
|
196
|
+
for opt in reversed(options):
|
|
197
|
+
func = opt(func)
|
|
198
|
+
return func
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def common_options(func):
|
|
202
|
+
return _apply_options(func, _COMMON_OPTIONS)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def feature_options(func):
|
|
206
|
+
return _apply_options(func, _FEATURE_OPTIONS)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _run_pipeline(
|
|
210
|
+
*,
|
|
211
|
+
wsi_path: str,
|
|
212
|
+
output: str,
|
|
213
|
+
patch_size: int,
|
|
214
|
+
step_size: int | None,
|
|
215
|
+
target_mag: int,
|
|
216
|
+
device: str,
|
|
217
|
+
tissue_thresh: float,
|
|
218
|
+
white_thresh: int,
|
|
219
|
+
black_thresh: int,
|
|
220
|
+
seg_batch_size: int,
|
|
221
|
+
write_batch: int,
|
|
222
|
+
patch_workers: int | None,
|
|
223
|
+
max_open_slides: int | None,
|
|
224
|
+
fast_mode: bool,
|
|
225
|
+
save_images: bool,
|
|
226
|
+
visualize_grids: bool,
|
|
227
|
+
visualize_mask: bool,
|
|
228
|
+
visualize_contours: bool,
|
|
229
|
+
recursive: bool,
|
|
230
|
+
mpp_csv: str | None,
|
|
231
|
+
skip_existing: bool,
|
|
232
|
+
verbose: bool,
|
|
233
|
+
feature_cfg: FeatureExtractionConfig | None = None,
|
|
234
|
+
registry: PatchFeatureExtractorRegistry | None = None,
|
|
235
|
+
) -> tuple[list, list]:
|
|
236
|
+
configure_logging(verbose)
|
|
237
|
+
|
|
238
|
+
processing_cfg = ProcessingConfig(
|
|
239
|
+
input_path=Path(wsi_path),
|
|
240
|
+
recursive=recursive,
|
|
241
|
+
mpp_csv=Path(mpp_csv) if mpp_csv else None,
|
|
242
|
+
)
|
|
243
|
+
segmentation_cfg = SegmentationConfig(
|
|
244
|
+
checkpoint_path=None,
|
|
245
|
+
config_path=_default_config_path(),
|
|
246
|
+
device=device.lower(),
|
|
247
|
+
batch_size=seg_batch_size,
|
|
248
|
+
)
|
|
249
|
+
extraction_cfg = ExtractionConfig(
|
|
250
|
+
patch_size=patch_size,
|
|
251
|
+
step_size=step_size,
|
|
252
|
+
target_magnification=target_mag,
|
|
253
|
+
tissue_threshold=tissue_thresh,
|
|
254
|
+
white_threshold=white_thresh,
|
|
255
|
+
black_threshold=black_thresh,
|
|
256
|
+
fast_mode=fast_mode,
|
|
257
|
+
write_batch=write_batch,
|
|
258
|
+
workers=patch_workers,
|
|
259
|
+
max_open_slides=max_open_slides,
|
|
260
|
+
)
|
|
261
|
+
output_cfg = OutputConfig(
|
|
262
|
+
output_root=Path(output),
|
|
263
|
+
save_images=save_images,
|
|
264
|
+
visualize_grids=visualize_grids,
|
|
265
|
+
visualize_mask=visualize_mask,
|
|
266
|
+
visualize_contours=visualize_contours,
|
|
267
|
+
skip_existing=skip_existing,
|
|
268
|
+
)
|
|
269
|
+
app_cfg = AppConfig(
|
|
270
|
+
processing=processing_cfg,
|
|
271
|
+
segmentation=segmentation_cfg,
|
|
272
|
+
extraction=extraction_cfg,
|
|
273
|
+
output=output_cfg,
|
|
274
|
+
visualization=VisualizationConfig(),
|
|
275
|
+
features=feature_cfg,
|
|
276
|
+
device=device.lower(),
|
|
277
|
+
).validated()
|
|
278
|
+
|
|
279
|
+
segmentation_service = SAM2SegmentationService(app_cfg.segmentation)
|
|
280
|
+
extractor_service = PatchExtractionService(app_cfg.extraction, app_cfg.output)
|
|
281
|
+
visualizer_service = None
|
|
282
|
+
if visualize_grids or visualize_mask or visualize_contours:
|
|
283
|
+
visualizer_service = DefaultVisualizationService(
|
|
284
|
+
app_cfg.output, app_cfg.extraction, app_cfg.visualization
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
mpp_resolver = CSVMPPResolver(app_cfg.processing.mpp_csv)
|
|
288
|
+
wsi_loader = DefaultWSILoader()
|
|
289
|
+
|
|
290
|
+
runner = ProcessingRunner(
|
|
291
|
+
config=app_cfg,
|
|
292
|
+
segmentation=segmentation_service,
|
|
293
|
+
extractor=extractor_service,
|
|
294
|
+
visualizer=visualizer_service,
|
|
295
|
+
mpp_resolver=mpp_resolver,
|
|
296
|
+
wsi_loader=wsi_loader,
|
|
297
|
+
show_progress=not verbose,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
results: list
|
|
301
|
+
failures: list
|
|
302
|
+
try:
|
|
303
|
+
results, failures = runner.run()
|
|
304
|
+
finally:
|
|
305
|
+
segmentation_service.close()
|
|
306
|
+
|
|
307
|
+
click.echo("Segmentation and patch coordinate extraction complete.")
|
|
308
|
+
|
|
309
|
+
if app_cfg.features is not None:
|
|
310
|
+
feature_service = PatchFeatureEmbeddingService(
|
|
311
|
+
app_cfg.extraction, app_cfg.output, app_cfg.features, registry=registry
|
|
312
|
+
)
|
|
313
|
+
total_units = len(results) * len(app_cfg.features.extractors)
|
|
314
|
+
feature_progress = tqdm(
|
|
315
|
+
total=total_units,
|
|
316
|
+
desc="Feature embedding",
|
|
317
|
+
disable=verbose or total_units == 0,
|
|
318
|
+
)
|
|
319
|
+
try:
|
|
320
|
+
failures.extend(
|
|
321
|
+
feature_service.embed_all(results, wsi_loader=wsi_loader, progress=feature_progress)
|
|
322
|
+
)
|
|
323
|
+
finally:
|
|
324
|
+
feature_progress.close()
|
|
325
|
+
|
|
326
|
+
return results, failures
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _run_tissue_visualization(
|
|
330
|
+
*,
|
|
331
|
+
wsi_path: str,
|
|
332
|
+
output: str,
|
|
333
|
+
device: str,
|
|
334
|
+
seg_batch_size: int,
|
|
335
|
+
recursive: bool,
|
|
336
|
+
mpp_csv: str | None,
|
|
337
|
+
verbose: bool,
|
|
338
|
+
) -> tuple[list[tuple[Slide, Path]], list[tuple[Slide, Exception | str]]]:
|
|
339
|
+
configure_logging(verbose)
|
|
340
|
+
|
|
341
|
+
processing_cfg = ProcessingConfig(
|
|
342
|
+
input_path=Path(wsi_path),
|
|
343
|
+
recursive=recursive,
|
|
344
|
+
mpp_csv=Path(mpp_csv) if mpp_csv else None,
|
|
345
|
+
).validated()
|
|
346
|
+
segmentation_cfg = (
|
|
347
|
+
SegmentationConfig(
|
|
348
|
+
checkpoint_path=None,
|
|
349
|
+
config_path=_default_config_path(),
|
|
350
|
+
device=device.lower(),
|
|
351
|
+
batch_size=seg_batch_size,
|
|
352
|
+
)
|
|
353
|
+
.validated()
|
|
354
|
+
)
|
|
355
|
+
vis_cfg = VisualizationConfig().validated()
|
|
356
|
+
|
|
357
|
+
slide_paths = get_wsi_files(str(processing_cfg.input_path), recursive=processing_cfg.recursive)
|
|
358
|
+
|
|
359
|
+
output_root = Path(output)
|
|
360
|
+
output_root.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
vis_dir = output_root / "visualization"
|
|
362
|
+
|
|
363
|
+
mpp_resolver = CSVMPPResolver(processing_cfg.mpp_csv)
|
|
364
|
+
wsi_loader = DefaultWSILoader()
|
|
365
|
+
segmentation_service = SAM2SegmentationService(segmentation_cfg)
|
|
366
|
+
|
|
367
|
+
results: list[tuple[Slide, Path]] = []
|
|
368
|
+
failures: list[tuple[Slide, Exception | str]] = []
|
|
369
|
+
progress = tqdm(total=len(slide_paths), disable=verbose, desc="Tissue detection")
|
|
370
|
+
|
|
371
|
+
def _process_batch(batch: list[tuple[Slide, object]]) -> None:
|
|
372
|
+
if not batch:
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
wsis = [w for _, w in batch]
|
|
376
|
+
slides_batch = [s for s, _ in batch]
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
masks = (
|
|
380
|
+
segmentation_service.segment_batch(wsis)
|
|
381
|
+
if len(wsis) > 1
|
|
382
|
+
else [segmentation_service.segment_thumbnail(wsis[0])]
|
|
383
|
+
)
|
|
384
|
+
except Exception as e: # noqa: BLE001
|
|
385
|
+
for slide, wsi in batch:
|
|
386
|
+
failures.append((slide, e))
|
|
387
|
+
try:
|
|
388
|
+
wsi.cleanup()
|
|
389
|
+
except Exception:
|
|
390
|
+
pass
|
|
391
|
+
progress.update(1)
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
for slide, wsi, mask in zip(slides_batch, wsis, masks):
|
|
395
|
+
try:
|
|
396
|
+
out_path = visualize_mask_on_thumbnail(
|
|
397
|
+
mask=mask.data,
|
|
398
|
+
wsi=wsi,
|
|
399
|
+
output_dir=vis_dir,
|
|
400
|
+
thumbnail_size=vis_cfg.thumbnail_size,
|
|
401
|
+
)
|
|
402
|
+
results.append((slide, out_path))
|
|
403
|
+
except Exception as e: # noqa: BLE001
|
|
404
|
+
failures.append((slide, e))
|
|
405
|
+
finally:
|
|
406
|
+
try:
|
|
407
|
+
wsi.cleanup()
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
progress.update(1)
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
batch: list[tuple[Slide, object]] = []
|
|
414
|
+
for path_str in slide_paths:
|
|
415
|
+
base_slide = Slide(path=Path(path_str))
|
|
416
|
+
mpp_val = mpp_resolver.resolve(base_slide)
|
|
417
|
+
slide = Slide(path=base_slide.path, mpp=mpp_val, backend=base_slide.backend)
|
|
418
|
+
try:
|
|
419
|
+
wsi = wsi_loader.open(slide)
|
|
420
|
+
except Exception as e: # noqa: BLE001
|
|
421
|
+
failures.append((slide, e))
|
|
422
|
+
progress.update(1)
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
batch.append((slide, wsi))
|
|
426
|
+
if len(batch) >= segmentation_cfg.batch_size:
|
|
427
|
+
_process_batch(batch)
|
|
428
|
+
batch = []
|
|
429
|
+
|
|
430
|
+
if batch:
|
|
431
|
+
_process_batch(batch)
|
|
432
|
+
finally:
|
|
433
|
+
try:
|
|
434
|
+
segmentation_service.close()
|
|
435
|
+
finally:
|
|
436
|
+
progress.close()
|
|
437
|
+
|
|
438
|
+
return results, failures
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _echo_results(
|
|
442
|
+
results: list, failures: list, verbose: bool, feature_cfg: FeatureExtractionConfig | None
|
|
443
|
+
) -> None:
|
|
444
|
+
click.echo(f"Completed {len(results)} slide(s), failures: {len(failures)}")
|
|
445
|
+
if verbose:
|
|
446
|
+
for res in results:
|
|
447
|
+
feature_note = f" features={','.join(feature_cfg.extractors)}" if feature_cfg else ""
|
|
448
|
+
click.echo(
|
|
449
|
+
f"[OK] {res.slide.path.name} -> {res.h5_path} (patches={res.num_patches}){feature_note}"
|
|
450
|
+
)
|
|
451
|
+
for slide, err in failures:
|
|
452
|
+
click.echo(f"[FAIL] {slide.path.name}: {err}", err=True)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _echo_mask_results(
|
|
456
|
+
results: list[tuple[Slide, Path]], failures: list[tuple[Slide, Exception | str]], verbose: bool
|
|
457
|
+
) -> None:
|
|
458
|
+
click.echo(f"Created {len(results)} mask overlay(s), failures: {len(failures)}")
|
|
459
|
+
if verbose:
|
|
460
|
+
for slide, path in results:
|
|
461
|
+
click.echo(f"[OK] {slide.path.name} -> {path}")
|
|
462
|
+
for slide, err in failures:
|
|
463
|
+
click.echo(f"[FAIL] {slide.path.name}: {err}", err=True)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@click.group()
|
|
467
|
+
@click.version_option(version="0.2.0")
|
|
468
|
+
def cli():
|
|
469
|
+
"""AtlasPatch CLI.
|
|
470
|
+
|
|
471
|
+
Processes WSI files by segmenting tissue with SAM2, extracting patches, and
|
|
472
|
+
optionally exporting images/visualizations.
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@cli.command()
|
|
477
|
+
@common_options
|
|
478
|
+
def segment_and_get_coords(
|
|
479
|
+
wsi_path: str,
|
|
480
|
+
output: str,
|
|
481
|
+
patch_size: int,
|
|
482
|
+
step_size: int | None,
|
|
483
|
+
target_mag: int,
|
|
484
|
+
device: str,
|
|
485
|
+
tissue_thresh: float,
|
|
486
|
+
white_thresh: int,
|
|
487
|
+
black_thresh: int,
|
|
488
|
+
seg_batch_size: int,
|
|
489
|
+
write_batch: int,
|
|
490
|
+
patch_workers: int | None,
|
|
491
|
+
max_open_slides: int | None,
|
|
492
|
+
fast_mode: bool,
|
|
493
|
+
save_images: bool,
|
|
494
|
+
visualize_grids: bool,
|
|
495
|
+
visualize_mask: bool,
|
|
496
|
+
visualize_contours: bool,
|
|
497
|
+
recursive: bool,
|
|
498
|
+
mpp_csv: str | None,
|
|
499
|
+
skip_existing: bool,
|
|
500
|
+
verbose: bool,
|
|
501
|
+
):
|
|
502
|
+
"""Segment, patchify, and optionally visualize WSI files."""
|
|
503
|
+
results, failures = _run_pipeline(
|
|
504
|
+
wsi_path=wsi_path,
|
|
505
|
+
output=output,
|
|
506
|
+
patch_size=patch_size,
|
|
507
|
+
step_size=step_size,
|
|
508
|
+
target_mag=target_mag,
|
|
509
|
+
device=device,
|
|
510
|
+
tissue_thresh=tissue_thresh,
|
|
511
|
+
white_thresh=white_thresh,
|
|
512
|
+
black_thresh=black_thresh,
|
|
513
|
+
seg_batch_size=seg_batch_size,
|
|
514
|
+
write_batch=write_batch,
|
|
515
|
+
patch_workers=patch_workers,
|
|
516
|
+
max_open_slides=max_open_slides,
|
|
517
|
+
fast_mode=fast_mode,
|
|
518
|
+
save_images=save_images,
|
|
519
|
+
visualize_grids=visualize_grids,
|
|
520
|
+
visualize_mask=visualize_mask,
|
|
521
|
+
visualize_contours=visualize_contours,
|
|
522
|
+
recursive=recursive,
|
|
523
|
+
mpp_csv=mpp_csv,
|
|
524
|
+
skip_existing=skip_existing,
|
|
525
|
+
verbose=verbose,
|
|
526
|
+
feature_cfg=None,
|
|
527
|
+
)
|
|
528
|
+
_echo_results(results, failures, verbose, None)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@cli.command()
|
|
532
|
+
@click.argument("wsi_path", type=click.Path(exists=True))
|
|
533
|
+
@click.option(
|
|
534
|
+
"--output",
|
|
535
|
+
"-o",
|
|
536
|
+
type=click.Path(),
|
|
537
|
+
required=True,
|
|
538
|
+
help="Output directory root for generated artifacts.",
|
|
539
|
+
)
|
|
540
|
+
@click.option(
|
|
541
|
+
"--device",
|
|
542
|
+
type=str,
|
|
543
|
+
default="cuda",
|
|
544
|
+
show_default=True,
|
|
545
|
+
help="Segmentation device (e.g., cuda, cuda:0, cpu).",
|
|
546
|
+
)
|
|
547
|
+
@click.option(
|
|
548
|
+
"--seg-batch-size",
|
|
549
|
+
type=click.IntRange(1, None),
|
|
550
|
+
default=1,
|
|
551
|
+
show_default=True,
|
|
552
|
+
help="Segmentation batch size for thumbnail inference.",
|
|
553
|
+
)
|
|
554
|
+
@click.option("--recursive", is_flag=True, help="Recursively search directories for WSIs.")
|
|
555
|
+
@click.option(
|
|
556
|
+
"--mpp-csv", type=click.Path(exists=True), default=None, help="CSV with custom MPP."
|
|
557
|
+
)
|
|
558
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
|
|
559
|
+
def detect_tissue(
|
|
560
|
+
wsi_path: str,
|
|
561
|
+
output: str,
|
|
562
|
+
device: str,
|
|
563
|
+
seg_batch_size: int,
|
|
564
|
+
recursive: bool,
|
|
565
|
+
mpp_csv: str | None,
|
|
566
|
+
verbose: bool,
|
|
567
|
+
):
|
|
568
|
+
"""Run tissue segmentation only and export mask overlays."""
|
|
569
|
+
results, failures = _run_tissue_visualization(
|
|
570
|
+
wsi_path=wsi_path,
|
|
571
|
+
output=output,
|
|
572
|
+
device=device,
|
|
573
|
+
seg_batch_size=seg_batch_size,
|
|
574
|
+
recursive=recursive,
|
|
575
|
+
mpp_csv=mpp_csv,
|
|
576
|
+
verbose=verbose,
|
|
577
|
+
)
|
|
578
|
+
_echo_mask_results(results, failures, verbose)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@cli.command()
|
|
582
|
+
@feature_options
|
|
583
|
+
@common_options
|
|
584
|
+
def process(
|
|
585
|
+
wsi_path: str,
|
|
586
|
+
output: str,
|
|
587
|
+
patch_size: int,
|
|
588
|
+
step_size: int | None,
|
|
589
|
+
target_mag: int,
|
|
590
|
+
device: str,
|
|
591
|
+
feature_device: str | None,
|
|
592
|
+
feature_extractors: str,
|
|
593
|
+
feature_batch_size: int,
|
|
594
|
+
feature_num_workers: int,
|
|
595
|
+
feature_precision: str,
|
|
596
|
+
tissue_thresh: float,
|
|
597
|
+
white_thresh: int,
|
|
598
|
+
black_thresh: int,
|
|
599
|
+
seg_batch_size: int,
|
|
600
|
+
write_batch: int,
|
|
601
|
+
patch_workers: int | None,
|
|
602
|
+
max_open_slides: int | None,
|
|
603
|
+
fast_mode: bool,
|
|
604
|
+
save_images: bool,
|
|
605
|
+
visualize_grids: bool,
|
|
606
|
+
visualize_mask: bool,
|
|
607
|
+
visualize_contours: bool,
|
|
608
|
+
recursive: bool,
|
|
609
|
+
mpp_csv: str | None,
|
|
610
|
+
skip_existing: bool,
|
|
611
|
+
verbose: bool,
|
|
612
|
+
feature_plugins: tuple[str, ...],
|
|
613
|
+
):
|
|
614
|
+
"""Run segmentation, patch extraction, and feature embedding into a single H5."""
|
|
615
|
+
feat_device = feature_device.lower() if feature_device else device.lower()
|
|
616
|
+
torch_device = torch.device(feat_device)
|
|
617
|
+
dtype = resolve_feature_dtype(torch_device, feature_precision.lower())
|
|
618
|
+
registry = build_default_registry(
|
|
619
|
+
device=torch_device, num_workers=feature_num_workers, dtype=dtype
|
|
620
|
+
)
|
|
621
|
+
for plugin in feature_plugins:
|
|
622
|
+
register_feature_extractors_from_module(
|
|
623
|
+
plugin,
|
|
624
|
+
registry=registry,
|
|
625
|
+
device=torch_device,
|
|
626
|
+
dtype=dtype,
|
|
627
|
+
num_workers=feature_num_workers,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
available_extractors = registry.available()
|
|
631
|
+
feats = parse_feature_list(feature_extractors, choices=available_extractors)
|
|
632
|
+
feature_cfg = FeatureExtractionConfig(
|
|
633
|
+
extractors=feats,
|
|
634
|
+
batch_size=feature_batch_size,
|
|
635
|
+
device=feat_device,
|
|
636
|
+
num_workers=feature_num_workers,
|
|
637
|
+
precision=feature_precision.lower(),
|
|
638
|
+
plugins=[Path(p) for p in feature_plugins],
|
|
639
|
+
)
|
|
640
|
+
results, failures = _run_pipeline(
|
|
641
|
+
wsi_path=wsi_path,
|
|
642
|
+
output=output,
|
|
643
|
+
patch_size=patch_size,
|
|
644
|
+
step_size=step_size,
|
|
645
|
+
target_mag=target_mag,
|
|
646
|
+
device=device,
|
|
647
|
+
tissue_thresh=tissue_thresh,
|
|
648
|
+
white_thresh=white_thresh,
|
|
649
|
+
black_thresh=black_thresh,
|
|
650
|
+
seg_batch_size=seg_batch_size,
|
|
651
|
+
write_batch=write_batch,
|
|
652
|
+
patch_workers=patch_workers,
|
|
653
|
+
max_open_slides=max_open_slides,
|
|
654
|
+
fast_mode=fast_mode,
|
|
655
|
+
save_images=save_images,
|
|
656
|
+
visualize_grids=visualize_grids,
|
|
657
|
+
visualize_mask=visualize_mask,
|
|
658
|
+
visualize_contours=visualize_contours,
|
|
659
|
+
recursive=recursive,
|
|
660
|
+
mpp_csv=mpp_csv,
|
|
661
|
+
skip_existing=skip_existing,
|
|
662
|
+
verbose=verbose,
|
|
663
|
+
feature_cfg=feature_cfg,
|
|
664
|
+
registry=registry,
|
|
665
|
+
)
|
|
666
|
+
_echo_results(results, failures, verbose, feature_cfg)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@cli.command()
|
|
670
|
+
def info():
|
|
671
|
+
"""Display supported formats and output structure."""
|
|
672
|
+
click.echo(
|
|
673
|
+
"Supported WSI formats (OpenSlide): .svs, .tif, .tiff, .ndpi, .vms, .vmu, .scn, .mrxs, .bif, .dcm"
|
|
674
|
+
)
|
|
675
|
+
click.echo("Image formats: .png, .jpg, .jpeg, .bmp, .webp, .gif")
|
|
676
|
+
click.echo(
|
|
677
|
+
"Outputs: HDF5 per slide under patches/<stem>.h5; optional PNGs under images/<stem>; visualizations under visualization/."
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def main():
|
|
682
|
+
try:
|
|
683
|
+
cli()
|
|
684
|
+
except click.ClickException as e:
|
|
685
|
+
click.echo(f"Error: {e}", err=True)
|
|
686
|
+
sys.exit(1)
|
|
687
|
+
except KeyboardInterrupt:
|
|
688
|
+
click.echo("\nInterrupted by user", err=True)
|
|
689
|
+
sys.exit(130)
|
|
690
|
+
except Exception as e: # noqa: BLE001
|
|
691
|
+
click.echo(f"Unexpected error: {e}", err=True)
|
|
692
|
+
sys.exit(1)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
if __name__ == "__main__":
|
|
696
|
+
main()
|