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.
@@ -0,0 +1,6 @@
1
+ """AtlasPatch module."""
2
+
3
+ from . import core, services
4
+
5
+ __version__ = "1.0.0.post1"
6
+ __all__ = ["core", "services", "__version__"]
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()