slide2vec 5.9.0__tar.gz → 5.9.2__tar.gz
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.
- {slide2vec-5.9.0 → slide2vec-5.9.2}/PKG-INFO +2 -1
- {slide2vec-5.9.0 → slide2vec-5.9.2}/README.md +1 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/pyproject.toml +2 -2
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/__init__.py +1 -1
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/data/tile_reader.py +7 -7
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/prism.py +2 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/batching.py +24 -15
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/persistence.py +35 -47
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/PKG-INFO +2 -1
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/SOURCES.txt +4 -0
- slide2vec-5.9.2/tests/test_benchmark_tooling.py +193 -0
- slide2vec-5.9.2/tests/test_inference_benchmark.py +140 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_pooled_geometry.py +33 -0
- slide2vec-5.9.2/tests/test_prism.py +52 -0
- slide2vec-5.9.2/tests/test_process_list_performance.py +54 -0
- slide2vec-5.9.2/tests/test_runtime_batching.py +117 -0
- slide2vec-5.9.0/tests/test_runtime_batching.py +0 -53
- {slide2vec-5.9.0 → slide2vec-5.9.2}/LICENSE +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/setup.cfg +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/__main__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/api.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/artifacts.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/cli.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/configs/__init__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/configs/default.yaml +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/configs/resources.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/data/__init__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/data/dataset.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/data/tile_store.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/__init__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/dense_image_worker.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/dense_worker.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/direct_embed_worker.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/image_worker.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/pipeline_worker.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/distributed/worker_entry.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/__init__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/base.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/__init__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/conch.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/dinov2.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/genbio.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/gigapath.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/gpfm.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/hibou.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/hoptimus.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/isight.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/lunit.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/midnight.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/__init__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/blocks.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/case.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/loading.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/slide.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/moozy/types.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/mstar.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/musk.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/phikon.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/prism2.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/prost40m.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/rudolfv2.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/titan.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/uni.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/virchow.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/models/waiv.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/registry.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/encoders/validation.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/inference.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/progress.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/__init__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/artifacts_collect.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/cpu_budget.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_encode.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_encoder_input.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_image_reading.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_image_recipe.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_image_shard.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_image_stage.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_regions.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_shard.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_sliding.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/dense_stage.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/distributed.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/distributed_stage.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/effective_encoder_input.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/embedding.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/embedding_persist.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/embedding_pipeline.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/encoder_input_contract.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/hierarchical.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/image_shard.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/image_specs.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/image_stage.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/manifest.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/model_settings.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/patient_pipeline.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/persist_callbacks.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/pooled_encoder_input.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/preprocessing.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/process_list.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/progress_bridge.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/registry.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/serialization.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/sharding.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/slide_encode.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/tiling.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/tiling_pipeline.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/types.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/runtime/worker_io.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/__init__.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/config.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/coordinates.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/log_utils.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/tiling_io.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec/utils/utils.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/dependency_links.txt +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/entry_points.txt +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/not-zip-safe +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/requires.txt +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/slide2vec.egg-info/top_level.txt +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_architecture_runtime_split.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_attention_extraction.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_encode_kit.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_encoder_input.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_extraction.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_image_reading.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_image_resume.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_image_shard.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_image_stage.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_regions.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_shard.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_sliding.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_source_spacing.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_stage.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dense_worker.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_dinov2_natimage.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_capabilities.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_input_contract.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_plugins.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_provider_failures.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_encoder_registry.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_gpfm_genbio_heavy.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_hs2p_package_cutover.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_image_shard.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_image_stage.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_isight.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_load_model_hf_auth.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_mascaret.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_on_slide_persisted.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_output_consistency.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_patch_size_metadata.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_patient_manifest.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_phaet.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_pooled_encoder_input.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_prism2.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_progress.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_regression_core.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_regression_inference.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_regression_models.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_rudolfv2.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_sharding.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_slide_coordinate_preparation.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_soma_migration.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_tile_store.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_tiling_pipeline.py +0 -0
- {slide2vec-5.9.0 → slide2vec-5.9.2}/tests/test_titan.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slide2vec
|
|
3
|
-
Version: 5.9.
|
|
3
|
+
Version: 5.9.2
|
|
4
4
|
Summary: Embedding of whole slide images with Foundation Models
|
|
5
5
|
Author-email: Clément Grisi <clement.grisi@radboudumc.nl>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -276,4 +276,5 @@ docker run --rm -it \
|
|
|
276
276
|
- [API guide](https://clemsgrs.github.io/slide2vec/api.html)
|
|
277
277
|
- [CLI guide](https://clemsgrs.github.io/slide2vec/cli.html)
|
|
278
278
|
- [Model zoo](https://clemsgrs.github.io/slide2vec/models.html)
|
|
279
|
+
- [Performance benchmarks and QA](docs/performance.md)
|
|
279
280
|
- [`tutorials/api_walkthrough.ipynb`](tutorials/api_walkthrough.ipynb) for a notebook walkthrough of the API
|
|
@@ -160,4 +160,5 @@ docker run --rm -it \
|
|
|
160
160
|
- [API guide](https://clemsgrs.github.io/slide2vec/api.html)
|
|
161
161
|
- [CLI guide](https://clemsgrs.github.io/slide2vec/cli.html)
|
|
162
162
|
- [Model zoo](https://clemsgrs.github.io/slide2vec/models.html)
|
|
163
|
+
- [Performance benchmarks and QA](docs/performance.md)
|
|
163
164
|
- [`tutorials/api_walkthrough.ipynb`](tutorials/api_walkthrough.ipynb) for a notebook walkthrough of the API
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "slide2vec"
|
|
7
|
-
version = "5.9.
|
|
7
|
+
version = "5.9.2"
|
|
8
8
|
description = "Embedding of whole slide images with Foundation Models"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -180,7 +180,7 @@ no_implicit_reexport = true
|
|
|
180
180
|
max-line-length = 160
|
|
181
181
|
|
|
182
182
|
[tool.bumpver]
|
|
183
|
-
current_version = "5.9.
|
|
183
|
+
current_version = "5.9.2"
|
|
184
184
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
185
185
|
commit = false
|
|
186
186
|
tag = false
|
|
@@ -510,14 +510,14 @@ def _unfold_region_tensor_uint8(region_tensor: torch.Tensor, tile_size: int) ->
|
|
|
510
510
|
return torch.empty((0, 0, 3, tile_size, tile_size), dtype=torch.uint8)
|
|
511
511
|
if int(region_tensor.shape[-1]) % tile_size != 0 or int(region_tensor.shape[-2]) % tile_size != 0:
|
|
512
512
|
raise ValueError("Region tensor dimensions must be divisible by the tile size")
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
513
|
+
batch, channels, height, width = region_tensor.shape
|
|
514
|
+
# Disjoint tiles need only a layout change. Keep pixels as bytes instead of
|
|
515
|
+
# materializing float regions and im2col buffers, then converting them back.
|
|
516
|
+
return (
|
|
517
|
+
region_tensor.reshape(batch, channels, height // tile_size, tile_size, width // tile_size, tile_size)
|
|
518
|
+
.permute(0, 2, 4, 1, 3, 5)
|
|
519
|
+
.reshape(batch, -1, channels, tile_size, tile_size)
|
|
517
520
|
)
|
|
518
|
-
unfolded = unfolded.transpose(1, 2)
|
|
519
|
-
reshaped = unfolded.reshape(region_tensor.shape[0], -1, region_tensor.shape[1], tile_size, tile_size)
|
|
520
|
-
return reshaped.round().clamp(0, 255).to(torch.uint8)
|
|
521
521
|
|
|
522
522
|
|
|
523
523
|
def _area_resize_tile_batch(
|
|
@@ -46,5 +46,7 @@ class PrismSlideEncoder(SlideEncoder):
|
|
|
46
46
|
) -> torch.Tensor:
|
|
47
47
|
if tile_features.ndim == 2:
|
|
48
48
|
tile_features = tile_features.unsqueeze(0)
|
|
49
|
+
# Stored feature precision can differ from PRISM's weights; CPU has no autocast.
|
|
50
|
+
tile_features = tile_features.to(dtype=self._model.dtype)
|
|
49
51
|
reprs = self._model.slide_representations(tile_features)
|
|
50
52
|
return reprs["image_embedding"].squeeze(0)
|
|
@@ -110,7 +110,8 @@ class BatchPrefetcher:
|
|
|
110
110
|
self.loaded = loaded
|
|
111
111
|
self.batch_preprocessor = batch_preprocessor
|
|
112
112
|
self.copy_stream = self._make_copy_stream()
|
|
113
|
-
self.
|
|
113
|
+
self.defer_preload = self.copy_stream is not None and batch_preprocessor is not None
|
|
114
|
+
self._exhausted = False
|
|
114
115
|
self._next_batch: PreparedBatch | None = None
|
|
115
116
|
self._preload()
|
|
116
117
|
|
|
@@ -132,18 +133,10 @@ class BatchPrefetcher:
|
|
|
132
133
|
return image
|
|
133
134
|
if image.device.type != "cpu" or image.is_pinned():
|
|
134
135
|
return image
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
):
|
|
140
|
-
self._pinned_host_buffer = torch.empty(
|
|
141
|
-
image.shape,
|
|
142
|
-
dtype=image.dtype,
|
|
143
|
-
pin_memory=True,
|
|
144
|
-
)
|
|
145
|
-
self._pinned_host_buffer.copy_(image)
|
|
146
|
-
return self._pinned_host_buffer
|
|
136
|
+
# A shared staging buffer could be overwritten by the next preload while
|
|
137
|
+
# its asynchronous H2D copy is still pending. PyTorch tracks the lifetime
|
|
138
|
+
# of each pinned allocation until the transfer completes.
|
|
139
|
+
return image.pin_memory()
|
|
147
140
|
|
|
148
141
|
def _prepare_batch(self, image):
|
|
149
142
|
preprocess_start = time.perf_counter()
|
|
@@ -160,10 +153,13 @@ class BatchPrefetcher:
|
|
|
160
153
|
return prepared, preprocess_ms
|
|
161
154
|
|
|
162
155
|
def _preload(self) -> None:
|
|
156
|
+
if self._exhausted or self._next_batch is not None:
|
|
157
|
+
return
|
|
163
158
|
wait_start = time.perf_counter()
|
|
164
159
|
try:
|
|
165
160
|
batch = next(self.iterator)
|
|
166
161
|
except StopIteration:
|
|
162
|
+
self._exhausted = True
|
|
167
163
|
self._next_batch = None
|
|
168
164
|
return
|
|
169
165
|
loader_wait_ms = (time.perf_counter() - wait_start) * 1000.0
|
|
@@ -206,6 +202,7 @@ class BatchPrefetcher:
|
|
|
206
202
|
return self
|
|
207
203
|
|
|
208
204
|
def __next__(self) -> PreparedBatch:
|
|
205
|
+
self._preload()
|
|
209
206
|
if self._next_batch is None:
|
|
210
207
|
raise StopIteration
|
|
211
208
|
current = self._next_batch
|
|
@@ -213,8 +210,12 @@ class BatchPrefetcher:
|
|
|
213
210
|
ready_start = time.perf_counter()
|
|
214
211
|
current_stream = torch.cuda.current_stream(device=self.loaded.device)
|
|
215
212
|
current_stream.wait_stream(self.copy_stream)
|
|
213
|
+
if torch.is_tensor(current.image) and current.image.is_cuda:
|
|
214
|
+
current.image.record_stream(current_stream)
|
|
216
215
|
current.ready_wait_ms = (time.perf_counter() - ready_start) * 1000.0
|
|
217
|
-
self.
|
|
216
|
+
self._next_batch = None
|
|
217
|
+
if not self.defer_preload:
|
|
218
|
+
self._preload()
|
|
218
219
|
return current
|
|
219
220
|
|
|
220
221
|
|
|
@@ -254,8 +255,16 @@ def iter_forward_batches(
|
|
|
254
255
|
image = prepared_batch.image
|
|
255
256
|
_record_encoder_input_size(loaded, image)
|
|
256
257
|
forward_start = time.perf_counter()
|
|
257
|
-
embedding = loaded.model.encode_tiles(image).detach()
|
|
258
|
+
embedding = loaded.model.encode_tiles(image).detach()
|
|
258
259
|
forward_ms = (time.perf_counter() - forward_start) * 1000.0
|
|
260
|
+
if prefetcher.defer_preload:
|
|
261
|
+
# Launch CUDA work before blocking on the next reader batch. CPU
|
|
262
|
+
# and itemwise transforms retain their original execution order.
|
|
263
|
+
prefetcher._preload()
|
|
264
|
+
result_start = time.perf_counter()
|
|
265
|
+
embedding = embedding.cpu()
|
|
266
|
+
# Residual host time excludes the overlapping preload, not GPU time.
|
|
267
|
+
forward_ms += (time.perf_counter() - result_start) * 1000.0
|
|
259
268
|
current_indices = torch.as_tensor(prepared_batch.indices, dtype=torch.long).detach().cpu()
|
|
260
269
|
processed += int(embedding.shape[0])
|
|
261
270
|
batch_index += 1
|
|
@@ -230,44 +230,42 @@ def update_process_list_after_embedding(
|
|
|
230
230
|
feature_success_ids = {slide.sample_id for slide in successful_slides}
|
|
231
231
|
feature_success_keys = set(feature_path_by_key)
|
|
232
232
|
annotation_aware = any(annotation is not None for _, annotation in feature_success_keys)
|
|
233
|
+
successful_ids = {slide.sample_id for slide in successful_slides}
|
|
233
234
|
row_annotations = _row_annotation_series(df)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
df.loc[mask, "aggregation_status"] = (
|
|
269
|
-
"success" if slide.sample_id in slide_success_ids else "error"
|
|
270
|
-
)
|
|
235
|
+
status_rows, statuses = [], []
|
|
236
|
+
feature_rows, feature_paths = [], []
|
|
237
|
+
aggregation_rows, aggregation_statuses = [], []
|
|
238
|
+
# Visit each CSV row once, including duplicate rows. The old per-slide masks
|
|
239
|
+
# repeatedly converted/scanned the whole table during every checkpoint flush.
|
|
240
|
+
for index, sample_id, annotation in zip(df.index, df["sample_id"].astype(str), row_annotations):
|
|
241
|
+
if sample_id not in successful_ids:
|
|
242
|
+
continue
|
|
243
|
+
key = (sample_id, _normalized_annotation(annotation) if annotation_aware else None)
|
|
244
|
+
mapped_feature_path = feature_path_by_key.get(key)
|
|
245
|
+
if not annotation_aware or mapped_feature_path is not None:
|
|
246
|
+
status_rows.append(index)
|
|
247
|
+
statuses.append("success" if sample_id in feature_success_ids else "error")
|
|
248
|
+
if mapped_feature_path is not None:
|
|
249
|
+
feature_rows.append(index)
|
|
250
|
+
feature_paths.append(mapped_feature_path)
|
|
251
|
+
# An incremental annotation update leaves unfinished sibling classes alone.
|
|
252
|
+
if include_slide_embeddings and (not annotation_aware or key in slide_success_keys):
|
|
253
|
+
aggregation_rows.append(index)
|
|
254
|
+
aggregation_statuses.append("success" if sample_id in slide_success_ids else "error")
|
|
255
|
+
|
|
256
|
+
if status_rows:
|
|
257
|
+
df.loc[status_rows, "feature_status"] = statuses
|
|
258
|
+
if feature_rows:
|
|
259
|
+
# Empty CSV columns are inferred as floats. Explicit object columns also
|
|
260
|
+
# allow provenance to be filled on pandas versions that reject upcasting.
|
|
261
|
+
for column in ("feature_path", "encoder_name", "output_variant", "feature_kind"):
|
|
262
|
+
df[column] = df[column].astype(object)
|
|
263
|
+
df.loc[feature_rows, "feature_path"] = feature_paths
|
|
264
|
+
df.loc[feature_rows, "encoder_name"] = encoder_name
|
|
265
|
+
df.loc[feature_rows, "output_variant"] = output_variant
|
|
266
|
+
df.loc[feature_rows, "feature_kind"] = feature_kind
|
|
267
|
+
if aggregation_rows:
|
|
268
|
+
df.loc[aggregation_rows, "aggregation_status"] = aggregation_statuses
|
|
271
269
|
atomic_write_dataframe_csv(df, process_list_path)
|
|
272
270
|
|
|
273
271
|
|
|
@@ -291,13 +289,3 @@ def _row_annotation_series(df: pd.DataFrame) -> pd.Series:
|
|
|
291
289
|
if "annotation" not in df.columns:
|
|
292
290
|
return pd.Series([np.nan] * len(df), index=df.index, dtype=object)
|
|
293
291
|
return df["annotation"].map(lambda value: _normalized_annotation(value) or np.nan)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def _row_annotations(row_annotations: pd.Series, mask: pd.Series) -> list[str | None]:
|
|
297
|
-
"""Distinct normalized annotations present in the masked rows (``None`` for flat rows)."""
|
|
298
|
-
seen: list[str | None] = []
|
|
299
|
-
for value in row_annotations[mask].tolist():
|
|
300
|
-
normalized = None if value is None or (isinstance(value, float) and pd.isna(value)) else value
|
|
301
|
-
if normalized not in seen:
|
|
302
|
-
seen.append(normalized)
|
|
303
|
-
return seen
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slide2vec
|
|
3
|
-
Version: 5.9.
|
|
3
|
+
Version: 5.9.2
|
|
4
4
|
Summary: Embedding of whole slide images with Foundation Models
|
|
5
5
|
Author-email: Clément Grisi <clement.grisi@radboudumc.nl>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -276,4 +276,5 @@ docker run --rm -it \
|
|
|
276
276
|
- [API guide](https://clemsgrs.github.io/slide2vec/api.html)
|
|
277
277
|
- [CLI guide](https://clemsgrs.github.io/slide2vec/cli.html)
|
|
278
278
|
- [Model zoo](https://clemsgrs.github.io/slide2vec/models.html)
|
|
279
|
+
- [Performance benchmarks and QA](docs/performance.md)
|
|
279
280
|
- [`tutorials/api_walkthrough.ipynb`](tutorials/api_walkthrough.ipynb) for a notebook walkthrough of the API
|
|
@@ -111,6 +111,7 @@ slide2vec/utils/tiling_io.py
|
|
|
111
111
|
slide2vec/utils/utils.py
|
|
112
112
|
tests/test_architecture_runtime_split.py
|
|
113
113
|
tests/test_attention_extraction.py
|
|
114
|
+
tests/test_benchmark_tooling.py
|
|
114
115
|
tests/test_dense_encode_kit.py
|
|
115
116
|
tests/test_dense_encoder_input.py
|
|
116
117
|
tests/test_dense_extraction.py
|
|
@@ -134,6 +135,7 @@ tests/test_gpfm_genbio_heavy.py
|
|
|
134
135
|
tests/test_hs2p_package_cutover.py
|
|
135
136
|
tests/test_image_shard.py
|
|
136
137
|
tests/test_image_stage.py
|
|
138
|
+
tests/test_inference_benchmark.py
|
|
137
139
|
tests/test_isight.py
|
|
138
140
|
tests/test_load_model_hf_auth.py
|
|
139
141
|
tests/test_mascaret.py
|
|
@@ -144,7 +146,9 @@ tests/test_patient_manifest.py
|
|
|
144
146
|
tests/test_phaet.py
|
|
145
147
|
tests/test_pooled_encoder_input.py
|
|
146
148
|
tests/test_pooled_geometry.py
|
|
149
|
+
tests/test_prism.py
|
|
147
150
|
tests/test_prism2.py
|
|
151
|
+
tests/test_process_list_performance.py
|
|
148
152
|
tests/test_progress.py
|
|
149
153
|
tests/test_regression_core.py
|
|
150
154
|
tests/test_regression_inference.py
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Benchmark configuration stays compatible with the public pipeline API."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize("module_name,builder,reuses_coordinates", [
|
|
11
|
+
("benchmark_end_to_end_paths", "_build_pipeline_from_config_dict", False),
|
|
12
|
+
("benchmark_tile_read_strategies", "_build_pipeline_from_config_dict", True),
|
|
13
|
+
("benchmark_embedding_throughput", "_build_model_pipeline_from_config", True),
|
|
14
|
+
])
|
|
15
|
+
def test_benchmark_config_preserves_requested_pipeline_settings(module_name, builder, reuses_coordinates):
|
|
16
|
+
module = importlib.import_module(f"scripts.{module_name}")
|
|
17
|
+
pipeline = getattr(module, builder)({
|
|
18
|
+
"output_dir": "/tmp/benchmark-config-only",
|
|
19
|
+
"device": "cpu",
|
|
20
|
+
"model": {"name": "phikonv2", "batch_size": 8},
|
|
21
|
+
"speed": {"num_dataloader_workers": 2, "precision": "fp32"},
|
|
22
|
+
"tiling": {
|
|
23
|
+
"backend": "openslide",
|
|
24
|
+
"params": {"requested_spacing_um": 0.5, "requested_tile_size_px": 224},
|
|
25
|
+
"masks": {"min_coverage": {"tissue": 0.3}},
|
|
26
|
+
"preview": {"save": False},
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
assert pipeline.execution.batch_size == 8
|
|
30
|
+
assert pipeline.execution.num_workers_per_gpu == 2
|
|
31
|
+
assert pipeline.execution.num_gpus == 1
|
|
32
|
+
assert pipeline.execution.precision == "fp32"
|
|
33
|
+
assert pipeline.preprocessing.backend == "openslide"
|
|
34
|
+
assert pipeline.preprocessing.requested_tile_size_px == 224
|
|
35
|
+
assert pipeline.preprocessing.requested_spacing_um == 0.5
|
|
36
|
+
assert pipeline.preprocessing.masks["min_coverage"]["tissue"] == 0.3
|
|
37
|
+
assert pipeline.preprocessing.preview["save_mask_preview"] is False
|
|
38
|
+
assert pipeline.preprocessing.read_coordinates_from == (
|
|
39
|
+
Path("/tmp/benchmark-config-only/coordinates") if reuses_coordinates else None
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("module_name,builder", [
|
|
44
|
+
("benchmark_end_to_end_paths", "_build_pipeline_from_config_dict"),
|
|
45
|
+
("benchmark_tile_read_strategies", "_build_pipeline_from_config_dict"),
|
|
46
|
+
("benchmark_embedding_throughput", "_build_model_pipeline_from_config"),
|
|
47
|
+
])
|
|
48
|
+
@pytest.mark.parametrize("process_rows", [
|
|
49
|
+
"broken,error,,10\n",
|
|
50
|
+
"broken,success,error,10\n",
|
|
51
|
+
"empty,success,,0\n",
|
|
52
|
+
])
|
|
53
|
+
def test_benchmark_harness_rejects_failed_or_empty_work(tmp_path, monkeypatch, module_name, builder, process_rows):
|
|
54
|
+
import json
|
|
55
|
+
from types import SimpleNamespace
|
|
56
|
+
|
|
57
|
+
module = importlib.import_module(f"scripts.{module_name}")
|
|
58
|
+
(tmp_path / "process_list.csv").write_text(
|
|
59
|
+
"sample_id,tiling_status,feature_status,num_tiles\n" + process_rows
|
|
60
|
+
)
|
|
61
|
+
config_path = tmp_path / "config.yaml"
|
|
62
|
+
config_path.write_text(f"output_dir: {tmp_path}\ncsv: unused.csv\n")
|
|
63
|
+
pipeline = SimpleNamespace(run=lambda **kwargs: SimpleNamespace(tile_artifacts=[object()], slide_artifacts=[]))
|
|
64
|
+
monkeypatch.setattr(module, builder, lambda config: pipeline)
|
|
65
|
+
args = SimpleNamespace(
|
|
66
|
+
harness_config=config_path, config_file=config_path,
|
|
67
|
+
metrics_json=tmp_path / "metrics.json", progress_jsonl=tmp_path / "progress.jsonl",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert module._run_internal_harness(args) == 1
|
|
71
|
+
metrics = json.loads(args.metrics_json.read_text())
|
|
72
|
+
assert metrics["success"] is False
|
|
73
|
+
assert metrics["tiles_per_second"] == 0.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_benchmark_accepts_completed_hierarchical_artifacts():
|
|
77
|
+
from types import SimpleNamespace
|
|
78
|
+
from scripts.benchmark_common import validate_completed_work
|
|
79
|
+
|
|
80
|
+
validate_completed_work(
|
|
81
|
+
{"failed_slides": 0, "total_tiles": 4},
|
|
82
|
+
SimpleNamespace(tile_artifacts=[], slide_artifacts=[], hierarchical_artifacts=[object()]),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.skipif(
|
|
87
|
+
os.environ.get("SLIDE2VEC_PERF_SMOKE") != "1",
|
|
88
|
+
reason="Set SLIDE2VEC_PERF_SMOKE=1 for the real WSI reader and CPU encoder smoke test",
|
|
89
|
+
)
|
|
90
|
+
def test_benchmark_real_fixture_cpu_pipeline(tmp_path, monkeypatch):
|
|
91
|
+
"""Run real reading, batching, persistence and metrics without pretrained weights."""
|
|
92
|
+
import json
|
|
93
|
+
from types import SimpleNamespace
|
|
94
|
+
|
|
95
|
+
import torch
|
|
96
|
+
from torchvision import transforms
|
|
97
|
+
import yaml
|
|
98
|
+
|
|
99
|
+
from slide2vec.api import Model
|
|
100
|
+
from slide2vec.runtime.types import LoadedModel
|
|
101
|
+
from scripts.benchmark_end_to_end_paths import _run_internal_harness
|
|
102
|
+
|
|
103
|
+
class MeanEncoder:
|
|
104
|
+
encoder = SimpleNamespace(pretrained_cfg={})
|
|
105
|
+
|
|
106
|
+
def encode_tiles(self, image):
|
|
107
|
+
return image.mean(dim=(-2, -1))
|
|
108
|
+
|
|
109
|
+
loaded = LoadedModel(
|
|
110
|
+
name="phikonv2", level="tile", model=MeanEncoder(),
|
|
111
|
+
transforms=transforms.Compose([transforms.ToTensor()]),
|
|
112
|
+
feature_dim=3, device=torch.device("cpu"),
|
|
113
|
+
)
|
|
114
|
+
monkeypatch.setattr(Model, "_load_backend", lambda self: loaded)
|
|
115
|
+
monkeypatch.setattr(Model, "_load_backend_without_transform", lambda self: loaded)
|
|
116
|
+
fixtures = Path(__file__).parent / "fixtures" / "input"
|
|
117
|
+
manifest = tmp_path / "slides.csv"
|
|
118
|
+
manifest.write_text(
|
|
119
|
+
f'sample_id,image_path,mask_path\ntest-wsi,{fixtures / "test-wsi.tif"},{fixtures / "test-mask.tif"}\n'
|
|
120
|
+
)
|
|
121
|
+
config = {
|
|
122
|
+
"csv": str(manifest), "output_dir": str(tmp_path), "device": "cpu",
|
|
123
|
+
"model": {"name": "phikonv2", "batch_size": 32},
|
|
124
|
+
"speed": {"num_dataloader_workers": 0, "num_preprocessing_workers": 1, "num_gpus": 1, "precision": "fp32"},
|
|
125
|
+
"tiling": {
|
|
126
|
+
"backend": "openslide", "mask_backend": "asap", "on_the_fly": True, "use_supertiles": False,
|
|
127
|
+
"params": {"requested_spacing_um": 0.5, "requested_tile_size_px": 224, "tolerance": 0.07},
|
|
128
|
+
"preview": {"save": False},
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
config_path = tmp_path / "config.yaml"
|
|
132
|
+
config_path.write_text(yaml.safe_dump(config))
|
|
133
|
+
args = SimpleNamespace(
|
|
134
|
+
harness_config=config_path, metrics_json=tmp_path / "metrics.json", progress_jsonl=tmp_path / "progress.jsonl",
|
|
135
|
+
)
|
|
136
|
+
original_threads = torch.get_num_threads()
|
|
137
|
+
try:
|
|
138
|
+
torch.set_num_threads(1)
|
|
139
|
+
assert _run_internal_harness(args) == 0
|
|
140
|
+
finally:
|
|
141
|
+
torch.set_num_threads(original_threads)
|
|
142
|
+
metrics = json.loads(args.metrics_json.read_text())
|
|
143
|
+
assert metrics["success"] is True
|
|
144
|
+
assert metrics["failed_slides"] == 0
|
|
145
|
+
assert metrics["total_tiles"] == 474
|
|
146
|
+
assert metrics["tile_artifacts"] == 1
|
|
147
|
+
assert metrics["timed_batches"] == 15
|
|
148
|
+
features = torch.load(tmp_path / "tile_embeddings" / "test-wsi.pt", weights_only=True)
|
|
149
|
+
assert features.shape == (474, 3)
|
|
150
|
+
assert bool(torch.isfinite(features).all())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.parametrize("module_name,builder,expected_workers", [
|
|
154
|
+
("benchmark_end_to_end_paths", "_build_pipeline_from_config_dict", 2),
|
|
155
|
+
("benchmark_tile_read_strategies", "_build_pipeline_from_config_dict", 2),
|
|
156
|
+
("benchmark_embedding_throughput", "_build_model_pipeline_from_config", 7),
|
|
157
|
+
])
|
|
158
|
+
def test_benchmark_worker_override_beats_legacy_config(module_name, builder, expected_workers):
|
|
159
|
+
module = importlib.import_module(f"scripts.{module_name}")
|
|
160
|
+
pipeline = getattr(module, builder)({
|
|
161
|
+
"output_dir": "/tmp/benchmark-config-only", "device": "cpu",
|
|
162
|
+
"model": {"name": "phikonv2"},
|
|
163
|
+
"speed": {"num_dataloader_workers": 2, "num_workers_embedding": 7},
|
|
164
|
+
})
|
|
165
|
+
assert pipeline.execution.num_workers_per_gpu == expected_workers
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_runtime_benchmark_checks_expected_pixels_and_rejects_changed_baseline(tmp_path, monkeypatch):
|
|
169
|
+
import hashlib
|
|
170
|
+
import json
|
|
171
|
+
import sys
|
|
172
|
+
from scripts import benchmark_runtime
|
|
173
|
+
|
|
174
|
+
output = tmp_path / 'result.json'
|
|
175
|
+
arguments = ['benchmark_runtime.py', '--case', 'hierarchical', '--regions', '1',
|
|
176
|
+
'--region-size', '4', '--tile-size', '2', '--repeat', '1', '--output', str(output)]
|
|
177
|
+
monkeypatch.setattr(sys, 'argv', arguments)
|
|
178
|
+
benchmark_runtime.main()
|
|
179
|
+
report = json.loads(output.read_text())
|
|
180
|
+
# Four RGB tiles, channel-first inside each tile, row-major across the region.
|
|
181
|
+
expected_pixels = bytes([
|
|
182
|
+
0, 3, 12, 15, 1, 4, 13, 16, 2, 5, 14, 17,
|
|
183
|
+
6, 9, 18, 21, 7, 10, 19, 22, 8, 11, 20, 23,
|
|
184
|
+
24, 27, 36, 39, 25, 28, 37, 40, 26, 29, 38, 41,
|
|
185
|
+
30, 33, 42, 45, 31, 34, 43, 46, 32, 35, 44, 47,
|
|
186
|
+
])
|
|
187
|
+
assert report['output_sha256'] == hashlib.sha256(expected_pixels).hexdigest()
|
|
188
|
+
baseline = tmp_path / 'before.json'
|
|
189
|
+
report['output_sha256'] = 'changed-output'
|
|
190
|
+
baseline.write_text(json.dumps(report))
|
|
191
|
+
monkeypatch.setattr(sys, 'argv', [*arguments, '--compare', str(baseline)])
|
|
192
|
+
with pytest.raises(ValueError, match='Baseline output differs'):
|
|
193
|
+
benchmark_runtime.main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Behavioral checks for the fixed-coordinate inference measurement workflow."""
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.mark.parametrize("field,value,message", [
|
|
8
|
+
("batch_size", 0, "batch_size must be positive"),
|
|
9
|
+
("modes", ["unknown"], "Unknown inference mode"),
|
|
10
|
+
("cache_policy", "server-cold", "Unknown cache policy"),
|
|
11
|
+
])
|
|
12
|
+
def test_inference_benchmark_rejects_invalid_workloads(field, value, message):
|
|
13
|
+
from scripts.benchmark_inference import run_inference_benchmark
|
|
14
|
+
|
|
15
|
+
args = SimpleNamespace(batch_size=2, tile_size=224, repeat=1, warmup=0,
|
|
16
|
+
workers=0, threads=1, modes=["cached"], cache_policy="warm")
|
|
17
|
+
setattr(args, field, value)
|
|
18
|
+
with pytest.raises(ValueError, match=message):
|
|
19
|
+
run_inference_benchmark(args)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_inference_modes_preserve_coordinates_batches_and_embeddings(tmp_path, monkeypatch):
|
|
23
|
+
import itertools
|
|
24
|
+
import json
|
|
25
|
+
import numpy as np
|
|
26
|
+
import torch
|
|
27
|
+
from torchvision.transforms import Compose, ToTensor
|
|
28
|
+
import slide2vec.inference
|
|
29
|
+
import slide2vec.data.tile_reader
|
|
30
|
+
from slide2vec.runtime.types import LoadedModel
|
|
31
|
+
from scripts.benchmark_inference import run_inference_benchmark
|
|
32
|
+
import scripts.benchmark_inference as benchmark
|
|
33
|
+
|
|
34
|
+
snapshots = itertools.cycle([
|
|
35
|
+
{"parent_minor_faults": 10, "parent_major_faults": 1,
|
|
36
|
+
"cgroup_memory_psi_us": {"some": 100, "full": 50}, "host_memory_psi_us": None},
|
|
37
|
+
{"parent_minor_faults": 17, "parent_major_faults": 3,
|
|
38
|
+
"cgroup_memory_psi_us": {"some": 120, "full": 55}, "host_memory_psi_us": None},
|
|
39
|
+
])
|
|
40
|
+
monkeypatch.setattr(benchmark, "_resource_snapshot", lambda: next(snapshots))
|
|
41
|
+
|
|
42
|
+
class Encoder:
|
|
43
|
+
def encode_tiles(self, images):
|
|
44
|
+
return images.mean(dim=(2, 3))
|
|
45
|
+
|
|
46
|
+
class Reader:
|
|
47
|
+
def read_region(self, location, level, size):
|
|
48
|
+
return np.full((size[1], size[0], 3), location[0], dtype=np.uint8)
|
|
49
|
+
|
|
50
|
+
def close(self):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
monkeypatch.setattr(slide2vec.inference, "load_model", lambda **kwargs: LoadedModel(
|
|
54
|
+
name="phikonv2", level="tile", model=Encoder(), transforms=Compose([ToTensor()]),
|
|
55
|
+
feature_dim=3, device=torch.device("cpu"),
|
|
56
|
+
))
|
|
57
|
+
monkeypatch.setattr(slide2vec.data.tile_reader, "_open_wsi_backend", lambda *a: Reader())
|
|
58
|
+
slide = tmp_path / "slide.tif"
|
|
59
|
+
slide.write_bytes(b"mock slide")
|
|
60
|
+
coordinates = tmp_path / "coordinates.npz"
|
|
61
|
+
np.savez(coordinates, x=np.array([255, 0, 255]), y=np.array([0, 0, 224]))
|
|
62
|
+
output = tmp_path / "result.json"
|
|
63
|
+
args = SimpleNamespace(
|
|
64
|
+
model="phikonv2", slide=slide, coordinates=coordinates, tile_size=224,
|
|
65
|
+
batch_size=2, workers=0, backend="openslide", modes=["model-only", "cached", "wsi"],
|
|
66
|
+
repeat=1, warmup=0, threads=1, output=output, profile=False,
|
|
67
|
+
use_supertiles=False, cache_policy="fresh-reader", device="cpu", precision="fp32",
|
|
68
|
+
)
|
|
69
|
+
result = run_inference_benchmark(args)
|
|
70
|
+
assert set(result["modes"]) == {"model-only", "cached", "wsi"}
|
|
71
|
+
expected = torch.tensor([[1., 1., 1.], [0., 0., 0.], [1., 1., 1.]])
|
|
72
|
+
for mode, measured in result["modes"].items():
|
|
73
|
+
assert len(measured["samples_seconds"]) == 1
|
|
74
|
+
assert measured["max_abs_error"] == 0
|
|
75
|
+
assert measured["sample_resources"] == [{
|
|
76
|
+
"parent_minor_faults": 7, "parent_major_faults": 2,
|
|
77
|
+
"cgroup_memory_psi_us": {"some": 20, "full": 5}, "host_memory_psi_us": None,
|
|
78
|
+
}]
|
|
79
|
+
torch.testing.assert_close(torch.load(measured["embeddings_path"], weights_only=True), expected)
|
|
80
|
+
assert json.loads(output.read_text())["parameters"]["num_tiles"] == 3
|
|
81
|
+
args.compare = output
|
|
82
|
+
args.output = tmp_path / "after.json"
|
|
83
|
+
comparison = run_inference_benchmark(args)
|
|
84
|
+
for measured in comparison["modes"].values():
|
|
85
|
+
assert measured["baseline_max_abs_error"] == 0
|
|
86
|
+
assert "speedup" in measured
|
|
87
|
+
baseline_payload = (tmp_path / "result-cached.pt").read_bytes()
|
|
88
|
+
args.output = tmp_path / "result.txt"
|
|
89
|
+
with pytest.raises(ValueError, match="preserve the baseline embeddings"):
|
|
90
|
+
run_inference_benchmark(args)
|
|
91
|
+
assert (tmp_path / "result-cached.pt").read_bytes() == baseline_payload
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("available", [True, False])
|
|
95
|
+
def test_resource_snapshot_reads_pressure_totals_or_reports_unavailable(monkeypatch, available):
|
|
96
|
+
import resource
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
from scripts.benchmark_inference import _resource_snapshot
|
|
99
|
+
|
|
100
|
+
def usage(_):
|
|
101
|
+
if not available:
|
|
102
|
+
raise OSError("unavailable")
|
|
103
|
+
return SimpleNamespace(ru_minflt=13, ru_majflt=2)
|
|
104
|
+
|
|
105
|
+
def read(path):
|
|
106
|
+
if not available:
|
|
107
|
+
raise OSError("unavailable")
|
|
108
|
+
if str(path) == "/sys/fs/cgroup/memory.pressure":
|
|
109
|
+
return "some avg10=0.00 avg60=0.00 avg300=0.00 total=123\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=45\n"
|
|
110
|
+
assert str(path) == "/proc/pressure/memory"
|
|
111
|
+
return "some avg10=0.00 total=789\nfull avg10=0.00 total=67\n"
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr(resource, "getrusage", usage)
|
|
114
|
+
monkeypatch.setattr(Path, "read_text", read)
|
|
115
|
+
assert _resource_snapshot() == {
|
|
116
|
+
"parent_minor_faults": 13 if available else None,
|
|
117
|
+
"parent_major_faults": 2 if available else None,
|
|
118
|
+
"cgroup_memory_psi_us": {"some": 123, "full": 45} if available else None,
|
|
119
|
+
"host_memory_psi_us": {"some": 789, "full": 67} if available else None,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_inference_comparison_rejects_different_coordinates_before_model_load(tmp_path):
|
|
124
|
+
import json
|
|
125
|
+
import numpy as np
|
|
126
|
+
from scripts.benchmark_inference import run_inference_benchmark
|
|
127
|
+
|
|
128
|
+
coordinates = tmp_path / "coordinates.npz"
|
|
129
|
+
np.savez(coordinates, x=np.array([0]), y=np.array([0]))
|
|
130
|
+
slide = tmp_path / "slide.tif"
|
|
131
|
+
slide.write_bytes(b"mock slide")
|
|
132
|
+
baseline = tmp_path / "baseline.json"
|
|
133
|
+
baseline.write_text(json.dumps({"parameters": {"coordinates_sha256": "different"}}))
|
|
134
|
+
with pytest.raises(ValueError, match="different parameters"):
|
|
135
|
+
run_inference_benchmark(SimpleNamespace(
|
|
136
|
+
model="never-load-this-model", slide=slide, coordinates=coordinates, tile_size=224,
|
|
137
|
+
batch_size=2, workers=0, backend="openslide", modes=["wsi"],
|
|
138
|
+
repeat=1, warmup=0, threads=1, output=tmp_path / "after.json", profile=False,
|
|
139
|
+
use_supertiles=False, cache_policy="warm", device="cpu", precision="fp32", compare=baseline,
|
|
140
|
+
))
|