pathseed 0.1.0__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.
Files changed (73) hide show
  1. pathseed-0.1.0/.github/workflows/publish.yml +35 -0
  2. pathseed-0.1.0/.gitignore +13 -0
  3. pathseed-0.1.0/CITATION.cff +8 -0
  4. pathseed-0.1.0/LICENSE +21 -0
  5. pathseed-0.1.0/PKG-INFO +169 -0
  6. pathseed-0.1.0/README.md +143 -0
  7. pathseed-0.1.0/experiments/midog/RESULTS.md +148 -0
  8. pathseed-0.1.0/experiments/midog/build_midog.py +111 -0
  9. pathseed-0.1.0/experiments/midog/calibrate.py +77 -0
  10. pathseed-0.1.0/experiments/midog/colab_ceiling2.sh +42 -0
  11. pathseed-0.1.0/experiments/midog/colab_loop_v3.sh +38 -0
  12. pathseed-0.1.0/experiments/midog/crosscheck_eval.py +114 -0
  13. pathseed-0.1.0/experiments/midog/full_bench.py +74 -0
  14. pathseed-0.1.0/experiments/midog/loop_calibrate.py +76 -0
  15. pathseed-0.1.0/experiments/midog/midog_dl_all.py +21 -0
  16. pathseed-0.1.0/experiments/midog/midogpp_split_ammeling.json +485 -0
  17. pathseed-0.1.0/experiments/midog/paper_midog.py +194 -0
  18. pathseed-0.1.0/experiments/midog/roi_eval.py +49 -0
  19. pathseed-0.1.0/pyproject.toml +33 -0
  20. pathseed-0.1.0/src/pathseed/__init__.py +16 -0
  21. pathseed-0.1.0/src/pathseed/app.py +103 -0
  22. pathseed-0.1.0/src/pathseed/budget.py +52 -0
  23. pathseed-0.1.0/src/pathseed/cli.py +218 -0
  24. pathseed-0.1.0/src/pathseed/folds.py +75 -0
  25. pathseed-0.1.0/src/pathseed/loop.py +198 -0
  26. pathseed-0.1.0/src/pathseed/manifest.py +213 -0
  27. pathseed-0.1.0/src/pathseed/metrics.py +131 -0
  28. pathseed-0.1.0/src/pathseed/models/__init__.py +52 -0
  29. pathseed-0.1.0/src/pathseed/models/encoders.py +176 -0
  30. pathseed-0.1.0/src/pathseed/models/prototype.py +87 -0
  31. pathseed-0.1.0/src/pathseed/models/retinanet.py +180 -0
  32. pathseed-0.1.0/src/pathseed/models/unet.py +233 -0
  33. pathseed-0.1.0/src/pathseed/oracle.py +47 -0
  34. pathseed-0.1.0/src/pathseed/plot.py +37 -0
  35. pathseed-0.1.0/src/pathseed/project.py +193 -0
  36. pathseed-0.1.0/src/pathseed/propose.py +81 -0
  37. pathseed-0.1.0/src/pathseed/review.py +421 -0
  38. pathseed-0.1.0/src/pathseed/roi_eval.py +176 -0
  39. pathseed-0.1.0/src/pathseed/seed.py +110 -0
  40. pathseed-0.1.0/src/pathseed/simulate.py +238 -0
  41. pathseed-0.1.0/src/pathseed/sweep.py +195 -0
  42. pathseed-0.1.0/src/pathseed/target.py +145 -0
  43. pathseed-0.1.0/src/pathseed/ui/dist/assets/index-C7j52u3Y.js +578 -0
  44. pathseed-0.1.0/src/pathseed/ui/dist/assets/index-p4NsU5kf.css +1 -0
  45. pathseed-0.1.0/src/pathseed/ui/dist/index.html +13 -0
  46. pathseed-0.1.0/src/pathseed/ui/review.html +232 -0
  47. pathseed-0.1.0/src/pathseed/wsi.py +105 -0
  48. pathseed-0.1.0/tests/conftest.py +62 -0
  49. pathseed-0.1.0/tests/test_core.py +166 -0
  50. pathseed-0.1.0/tests/test_review_seed.py +166 -0
  51. pathseed-0.1.0/ui-src/index.html +12 -0
  52. pathseed-0.1.0/ui-src/package.json +29 -0
  53. pathseed-0.1.0/ui-src/postcss.config.js +1 -0
  54. pathseed-0.1.0/ui-src/src/App.tsx +75 -0
  55. pathseed-0.1.0/ui-src/src/api.ts +57 -0
  56. pathseed-0.1.0/ui-src/src/components/FocusPanel.tsx +66 -0
  57. pathseed-0.1.0/ui-src/src/components/ReviewGrid.tsx +46 -0
  58. pathseed-0.1.0/ui-src/src/components/SeedEditor.tsx +190 -0
  59. pathseed-0.1.0/ui-src/src/components/SeedWorkbench.tsx +161 -0
  60. pathseed-0.1.0/ui-src/src/components/TileCanvas.tsx +86 -0
  61. pathseed-0.1.0/ui-src/src/components/WsiViewer.tsx +264 -0
  62. pathseed-0.1.0/ui-src/src/components/fx.tsx +79 -0
  63. pathseed-0.1.0/ui-src/src/index.css +13 -0
  64. pathseed-0.1.0/ui-src/src/main.tsx +10 -0
  65. pathseed-0.1.0/ui-src/src/pages/ProjectPage.tsx +191 -0
  66. pathseed-0.1.0/ui-src/src/pages/ReviewPage.tsx +53 -0
  67. pathseed-0.1.0/ui-src/src/pages/RoundsPage.tsx +72 -0
  68. pathseed-0.1.0/ui-src/src/pages/SeedPage.tsx +242 -0
  69. pathseed-0.1.0/ui-src/src/pages/SlidesPage.tsx +72 -0
  70. pathseed-0.1.0/ui-src/src/pages/TilesPage.tsx +71 -0
  71. pathseed-0.1.0/ui-src/tailwind.config.js +22 -0
  72. pathseed-0.1.0/ui-src/tsconfig.json +15 -0
  73. pathseed-0.1.0/ui-src/vite.config.ts +11 -0
@@ -0,0 +1,35 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.11"
16
+ - run: python -m pip install --upgrade pip build pytest
17
+ - run: python -m build
18
+ - run: python -m pip install dist/*.whl && python -m pytest -q tests
19
+ - uses: actions/upload-artifact@v4
20
+ with:
21
+ name: dist
22
+ path: dist/
23
+
24
+ publish:
25
+ needs: build
26
+ runs-on: ubuntu-latest
27
+ environment: pypi
28
+ permissions:
29
+ id-token: write
30
+ steps:
31
+ - uses: actions/download-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ build/
6
+ dist/
7
+ !src/pathseed/ui/dist/
8
+ ui-src/node_modules/
9
+ ui-src/dist/
10
+ runs/
11
+ *.pt
12
+ *.ckpt
13
+ .DS_Store
@@ -0,0 +1,8 @@
1
+ cff-version: 1.2.0
2
+ title: "pathseed: from an unlabeled whole-slide image to a detector for a rare target"
3
+ message: "If you use this software, please cite the accompanying paper (reference to come)."
4
+ type: software
5
+ version: 0.1.0
6
+ date-released: 2026-09-08
7
+ license: MIT
8
+ repository-code: https://github.com/<org>/pathseed
pathseed-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the pathseed authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: pathseed
3
+ Version: 0.1.0
4
+ Summary: From an unlabeled whole-slide image to a detector for a rare target: seed with point prompts, propose, review, retrain.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: numpy>=1.24
9
+ Requires-Dist: pillow>=9.0
10
+ Requires-Dist: scikit-image>=0.21
11
+ Requires-Dist: scipy>=1.10
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == 'dev'
14
+ Provides-Extra: plot
15
+ Requires-Dist: matplotlib>=3.7; extra == 'plot'
16
+ Provides-Extra: sam3
17
+ Requires-Dist: torch>=2.1; extra == 'sam3'
18
+ Requires-Dist: transformers>=5.12; extra == 'sam3'
19
+ Provides-Extra: torch
20
+ Requires-Dist: timm>=1.0; extra == 'torch'
21
+ Requires-Dist: torch>=2.1; extra == 'torch'
22
+ Provides-Extra: wsi
23
+ Requires-Dist: openslide-bin; extra == 'wsi'
24
+ Requires-Dist: openslide-python>=1.3; extra == 'wsi'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pathseed
28
+
29
+ From an unlabeled whole-slide image to a detector for a rare target, with the
30
+ expert's time spent where it counts.
31
+
32
+ ```
33
+ seed expert points at ~30 instances; SAM 3 turns each point set into a mask
34
+ train a small proposal network learns from those tiles (+ ~30 empty tiles)
35
+ sweep the network scans the unlabeled tissue and ranks what fires
36
+ review expert accepts / rejects the top proposals (2 s each); rejects = hard negatives
37
+ retrain … repeat once or twice, then sweep every slide
38
+ ```
39
+
40
+ Everything is parameterised by a `TargetSpec`: which mask channels the target
41
+ has, its size window, an optional relation between channels ("the ring must
42
+ enclose a nucleus"), and how a proposal is matched to ground truth (mask overlap
43
+ or point distance). The same code runs for a tau halo (two masks, relation) and
44
+ a mitotic figure (one mask, point ground truth).
45
+
46
+ The package also contains the **oracle simulation** that produces the
47
+ label-efficiency curves: on a benchmark that already has ground truth, the
48
+ oracle plays the pathologist, so the loop can be run from many seeds with a
49
+ measured annotator budget (points placed + decisions made) against held-out
50
+ slide F1, next to a random-tile baseline and the full-data upper bound.
51
+
52
+ ## Layout
53
+
54
+ ```
55
+ src/pathseed/
56
+ target.py TargetSpec (+ presets: tau, mitosis)
57
+ manifest.py dataset layout (images/ + one mask dir per channel + manifest.json), Label
58
+ propose.py probability maps -> proposals (components, size window, relation rule)
59
+ metrics.py object / tile detection F1, per-fold result record
60
+ folds.py slide-held-out folds
61
+ budget.py annotator cost accounting
62
+ oracle.py simulated reviewer from ground truth
63
+ simulate.py the label-efficiency experiment (+ random baseline, upper bound)
64
+ loop.py the real loop: state dir, train / propose / ingest_review
65
+ seed.py point prompts -> masks (SAM 3 adapter; threshold fallback)
66
+ sweep.py whole-slide / large-image sweep -> pool dataset
67
+ review.py browser UI server (review grid + focus panel + seeding canvas), stdlib only
68
+ app.py start()/stop() for Colab or local use (server + cached SAM 3)
69
+ project.py SQLite bank of slides, tiles, prompts, decisions (what the UI writes)
70
+ wsi.py whole-slide access (openslide) for the seeding viewer
71
+ roi_eval.py MIDOG-style region evaluation: sweep, distance NMS, 7.5 um matching, F1 / AP / FROC
72
+ ui/dist/ the built React UI (source in ui-src/; `npm install && npm run build` there)
73
+ models/ prototype (numpy floor), unet (pluggable encoder + U-Net decoder, K channels), encoders.py,
74
+ retinanet (torchvision reference detector under the same proposal contract)
75
+ plot.py F1-vs-budget figure
76
+ cli.py
77
+ tests/ synthetic benchmark; runs without torch
78
+ experiments/midog/ the paper experiments on MIDOG++ (see below)
79
+ ```
80
+
81
+ ## Experiments (MIDOG++)
82
+
83
+ `experiments/midog/` holds everything behind the MIDOG++ numbers in `experiments/midog/RESULTS.md`:
84
+ `build_midog.py` (ROIs -> tiles with point ground truth), `paper_midog.py` (the label-efficiency loop with the
85
+ ground truth as reviewer, one JSON row per round), `full_bench.py` (full-training-set ceilings),
86
+ `roi_eval.py` (region-level scoring), `calibrate.py` (validation-selected threshold),
87
+ `crosscheck_eval.py` (the same detections scored with the official MIDOG 2025 evaluation code),
88
+ `midogpp_split_ammeling.json` (the patient-level split of Ammeling et al. 2026), and the Colab runners
89
+ `colab_loop_v3.sh` / `colab_ceiling2.sh`. The seed tiles, detections and models are on Zenodo (link to come).
90
+
91
+ ## UI
92
+
93
+ `pathseed.review.serve()` serves a React app (Vite, Tailwind, framer-motion; source in
94
+ `ui-src/`, prebuilt into `src/pathseed/ui/dist/`). Review mode: card grid with masks,
95
+ focus panel with zoom and per-proposal measurements, keyboard `a`/`r`/`u`, arrows,
96
+ `m` toggles masks (per-card ◐ toggles one tile), `f` hides the panel. Seed mode: tile
97
+ list with filter, click = positive point, shift-click = negative, `1`/`2` switch channel,
98
+ `z` undo, `s` save, `e` save as empty. Rebuild after editing `ui-src/`:
99
+ `cd ui-src && npm install && npm run build`.
100
+
101
+ ## Install
102
+
103
+ ```
104
+ pip install -e . # core: numpy, scipy, pillow, scikit-image
105
+ pip install -e '.[torch]' # proposal network (torch, timm)
106
+ pip install -e '.[sam3]' # SAM 3 seeding (transformers)
107
+ pip install -e '.[wsi,plot,dev]'
108
+ pytest
109
+ ```
110
+
111
+ ## Encoders
112
+
113
+ The network is a U-Net decoder over a pluggable encoder; output channels come
114
+ from the target (one by default). Multi-scale timm models work by name
115
+ (`mobilenetv4_conv_small`, `fastvit_t8`, `tiny_vit_5m_224`, `convnext_tiny`,
116
+ `resnet18`, ...); plain ViTs get a ViTDet-style feature pyramid
117
+ (`vit:vit_small_patch16_224`, `vit:samvit_base_patch16`, `vit:hf-hub:MahmoodLab/UNI`).
118
+ `freeze_encoder=True` trains only the decoder. `pathseed encoders <spec>...` prints
119
+ what each one hands the decoder. Pathology foundation models have short names
120
+ (`uni`, `uni2h`, `hoptimus0`, `hoptimus1`, `virchow2`); they are gated on the
121
+ Hugging Face Hub: accept the terms on the model page, then log in once
122
+ (`huggingface-cli login`, or `huggingface_hub.login(token)`; on Colab keep the
123
+ token in Secrets and call login from a cell). Each carries its own input
124
+ normalisation.
125
+
126
+ ## Simulation on the tau benchmark
127
+
128
+ ```
129
+ pathseed folds --dataset macrophage_100k_curated --out folds.json --tau-fixed
130
+ pathseed simulate --dataset macrophage_100k_curated --target tau --folds folds.json --test-fold 0 \
131
+ --model unet --seed-pos 30 --seed-neg 30 --rounds 2 --review-cap 300 \
132
+ --seeds 0,1,2 --random --full --out runs/ --opt epochs=20
133
+ pathseed summarise --runs runs/ --csv runs.csv --plot curve.png
134
+ ```
135
+
136
+ ## Start the app (Colab or local)
137
+
138
+ ```python
139
+ from pathseed.app import start, stop
140
+ start("/content/dataset", state="/content/state",
141
+ slides_dir="/content/drive/MyDrive/svs", target="tau",
142
+ sam3_cache="/content/drive/MyDrive/sam3/checkpoint") # prints the URL (Colab proxy or localhost)
143
+ ```
144
+
145
+ `start()` loads SAM 3 once and reuses it, replaces a previous server on the same
146
+ port, and picks a free port if that one is taken. `stop()` shuts everything down.
147
+ `seg="threshold"` runs without a model (dry run), `seg=None` disables seeding.
148
+
149
+ ## The real loop
150
+
151
+ ```python
152
+ from pathseed import target
153
+ from pathseed.loop import Loop
154
+ from pathseed.review import serve
155
+ from pathseed.seed import Sam3PointSegmenter
156
+
157
+ tgt = target.tau()
158
+ loop = Loop.init("state", "dataset", tgt)
159
+ serve("dataset", None, tgt, segmenter=Sam3PointSegmenter(), port=8765) # seed tab: place points, save
160
+ # ... then add the saved seed labels:
161
+ import json; from pathseed.manifest import Label
162
+ loop.add_labels([Label.from_dict(d) for d in json.load(open("dataset/seed_labels.json"))], seed_instances=30, seed_negatives=30)
163
+ loop.train(0, model="unet", epochs=20) # encoder="fastvit_t8", freeze_encoder=True ...
164
+ # sweep slides into the same dataset dir (pathseed sweep ...), then
165
+ loop.propose(0, cap=300)
166
+ serve("dataset", "state/round_0", tgt, port=8765) # review tab
167
+ loop.ingest_review(0, accepted_source="files")
168
+ loop.train(1, model="unet", epochs=20)
169
+ ```
@@ -0,0 +1,143 @@
1
+ # pathseed
2
+
3
+ From an unlabeled whole-slide image to a detector for a rare target, with the
4
+ expert's time spent where it counts.
5
+
6
+ ```
7
+ seed expert points at ~30 instances; SAM 3 turns each point set into a mask
8
+ train a small proposal network learns from those tiles (+ ~30 empty tiles)
9
+ sweep the network scans the unlabeled tissue and ranks what fires
10
+ review expert accepts / rejects the top proposals (2 s each); rejects = hard negatives
11
+ retrain … repeat once or twice, then sweep every slide
12
+ ```
13
+
14
+ Everything is parameterised by a `TargetSpec`: which mask channels the target
15
+ has, its size window, an optional relation between channels ("the ring must
16
+ enclose a nucleus"), and how a proposal is matched to ground truth (mask overlap
17
+ or point distance). The same code runs for a tau halo (two masks, relation) and
18
+ a mitotic figure (one mask, point ground truth).
19
+
20
+ The package also contains the **oracle simulation** that produces the
21
+ label-efficiency curves: on a benchmark that already has ground truth, the
22
+ oracle plays the pathologist, so the loop can be run from many seeds with a
23
+ measured annotator budget (points placed + decisions made) against held-out
24
+ slide F1, next to a random-tile baseline and the full-data upper bound.
25
+
26
+ ## Layout
27
+
28
+ ```
29
+ src/pathseed/
30
+ target.py TargetSpec (+ presets: tau, mitosis)
31
+ manifest.py dataset layout (images/ + one mask dir per channel + manifest.json), Label
32
+ propose.py probability maps -> proposals (components, size window, relation rule)
33
+ metrics.py object / tile detection F1, per-fold result record
34
+ folds.py slide-held-out folds
35
+ budget.py annotator cost accounting
36
+ oracle.py simulated reviewer from ground truth
37
+ simulate.py the label-efficiency experiment (+ random baseline, upper bound)
38
+ loop.py the real loop: state dir, train / propose / ingest_review
39
+ seed.py point prompts -> masks (SAM 3 adapter; threshold fallback)
40
+ sweep.py whole-slide / large-image sweep -> pool dataset
41
+ review.py browser UI server (review grid + focus panel + seeding canvas), stdlib only
42
+ app.py start()/stop() for Colab or local use (server + cached SAM 3)
43
+ project.py SQLite bank of slides, tiles, prompts, decisions (what the UI writes)
44
+ wsi.py whole-slide access (openslide) for the seeding viewer
45
+ roi_eval.py MIDOG-style region evaluation: sweep, distance NMS, 7.5 um matching, F1 / AP / FROC
46
+ ui/dist/ the built React UI (source in ui-src/; `npm install && npm run build` there)
47
+ models/ prototype (numpy floor), unet (pluggable encoder + U-Net decoder, K channels), encoders.py,
48
+ retinanet (torchvision reference detector under the same proposal contract)
49
+ plot.py F1-vs-budget figure
50
+ cli.py
51
+ tests/ synthetic benchmark; runs without torch
52
+ experiments/midog/ the paper experiments on MIDOG++ (see below)
53
+ ```
54
+
55
+ ## Experiments (MIDOG++)
56
+
57
+ `experiments/midog/` holds everything behind the MIDOG++ numbers in `experiments/midog/RESULTS.md`:
58
+ `build_midog.py` (ROIs -> tiles with point ground truth), `paper_midog.py` (the label-efficiency loop with the
59
+ ground truth as reviewer, one JSON row per round), `full_bench.py` (full-training-set ceilings),
60
+ `roi_eval.py` (region-level scoring), `calibrate.py` (validation-selected threshold),
61
+ `crosscheck_eval.py` (the same detections scored with the official MIDOG 2025 evaluation code),
62
+ `midogpp_split_ammeling.json` (the patient-level split of Ammeling et al. 2026), and the Colab runners
63
+ `colab_loop_v3.sh` / `colab_ceiling2.sh`. The seed tiles, detections and models are on Zenodo (link to come).
64
+
65
+ ## UI
66
+
67
+ `pathseed.review.serve()` serves a React app (Vite, Tailwind, framer-motion; source in
68
+ `ui-src/`, prebuilt into `src/pathseed/ui/dist/`). Review mode: card grid with masks,
69
+ focus panel with zoom and per-proposal measurements, keyboard `a`/`r`/`u`, arrows,
70
+ `m` toggles masks (per-card ◐ toggles one tile), `f` hides the panel. Seed mode: tile
71
+ list with filter, click = positive point, shift-click = negative, `1`/`2` switch channel,
72
+ `z` undo, `s` save, `e` save as empty. Rebuild after editing `ui-src/`:
73
+ `cd ui-src && npm install && npm run build`.
74
+
75
+ ## Install
76
+
77
+ ```
78
+ pip install -e . # core: numpy, scipy, pillow, scikit-image
79
+ pip install -e '.[torch]' # proposal network (torch, timm)
80
+ pip install -e '.[sam3]' # SAM 3 seeding (transformers)
81
+ pip install -e '.[wsi,plot,dev]'
82
+ pytest
83
+ ```
84
+
85
+ ## Encoders
86
+
87
+ The network is a U-Net decoder over a pluggable encoder; output channels come
88
+ from the target (one by default). Multi-scale timm models work by name
89
+ (`mobilenetv4_conv_small`, `fastvit_t8`, `tiny_vit_5m_224`, `convnext_tiny`,
90
+ `resnet18`, ...); plain ViTs get a ViTDet-style feature pyramid
91
+ (`vit:vit_small_patch16_224`, `vit:samvit_base_patch16`, `vit:hf-hub:MahmoodLab/UNI`).
92
+ `freeze_encoder=True` trains only the decoder. `pathseed encoders <spec>...` prints
93
+ what each one hands the decoder. Pathology foundation models have short names
94
+ (`uni`, `uni2h`, `hoptimus0`, `hoptimus1`, `virchow2`); they are gated on the
95
+ Hugging Face Hub: accept the terms on the model page, then log in once
96
+ (`huggingface-cli login`, or `huggingface_hub.login(token)`; on Colab keep the
97
+ token in Secrets and call login from a cell). Each carries its own input
98
+ normalisation.
99
+
100
+ ## Simulation on the tau benchmark
101
+
102
+ ```
103
+ pathseed folds --dataset macrophage_100k_curated --out folds.json --tau-fixed
104
+ pathseed simulate --dataset macrophage_100k_curated --target tau --folds folds.json --test-fold 0 \
105
+ --model unet --seed-pos 30 --seed-neg 30 --rounds 2 --review-cap 300 \
106
+ --seeds 0,1,2 --random --full --out runs/ --opt epochs=20
107
+ pathseed summarise --runs runs/ --csv runs.csv --plot curve.png
108
+ ```
109
+
110
+ ## Start the app (Colab or local)
111
+
112
+ ```python
113
+ from pathseed.app import start, stop
114
+ start("/content/dataset", state="/content/state",
115
+ slides_dir="/content/drive/MyDrive/svs", target="tau",
116
+ sam3_cache="/content/drive/MyDrive/sam3/checkpoint") # prints the URL (Colab proxy or localhost)
117
+ ```
118
+
119
+ `start()` loads SAM 3 once and reuses it, replaces a previous server on the same
120
+ port, and picks a free port if that one is taken. `stop()` shuts everything down.
121
+ `seg="threshold"` runs without a model (dry run), `seg=None` disables seeding.
122
+
123
+ ## The real loop
124
+
125
+ ```python
126
+ from pathseed import target
127
+ from pathseed.loop import Loop
128
+ from pathseed.review import serve
129
+ from pathseed.seed import Sam3PointSegmenter
130
+
131
+ tgt = target.tau()
132
+ loop = Loop.init("state", "dataset", tgt)
133
+ serve("dataset", None, tgt, segmenter=Sam3PointSegmenter(), port=8765) # seed tab: place points, save
134
+ # ... then add the saved seed labels:
135
+ import json; from pathseed.manifest import Label
136
+ loop.add_labels([Label.from_dict(d) for d in json.load(open("dataset/seed_labels.json"))], seed_instances=30, seed_negatives=30)
137
+ loop.train(0, model="unet", epochs=20) # encoder="fastvit_t8", freeze_encoder=True ...
138
+ # sweep slides into the same dataset dir (pathseed sweep ...), then
139
+ loop.propose(0, cap=300)
140
+ serve("dataset", "state/round_0", tgt, port=8765) # review tab
141
+ loop.ingest_review(0, accepted_source="files")
142
+ loop.train(1, model="unet", epochs=20)
143
+ ```
@@ -0,0 +1,148 @@
1
+ # pathseed results (living document, updated 2026-09-07 evening)
2
+
3
+ ## Protocol v3 (current): MIDOG++ on the Ammeling patient split, validation-selected threshold
4
+
5
+ Split of Ammeling et al. 2026 (arXiv 2607.28007, "Beyond classification: pathology foundation models as
6
+ detection encoders for mitotic figures"): the 475 MIDOG++ ROIs with >= 1 mitotic figure, sorted, shuffled with
7
+ seed 42; 71 test ROIs (1,714 figures), 71 validation (1,511), 333 train (8,712). Reproduced in
8
+ `experiments/midog/midogpp_split_ammeling.json`. Every model is trained on the train ROIs only; the score threshold is chosen
9
+ on the validation ROIs (grid 0.05–0.995) and applied once to the test ROIs. Matching: a detection counts if its
10
+ centre lies within 7.5 um (30 px) of an unclaimed figure; we also report the 25 px radius Ammeling uses (no
11
+ difference in the third decimal). FROC = area of sensitivity vs FP/ROI on [0, 8] as in the MIDOG 2025 evaluation
12
+ container (max 8). Our scoring was cross-checked on the same detections with the official MIDOG 2025 `evaluate.py`
13
+ code path (evalutils matching, torchmetrics AP): F1 agrees to the third decimal (`experiments/midog/crosscheck_eval.py`).
14
+
15
+ ### Full-training-set ceilings (all 34,944 train tiles, 20 epochs) — `ceilings/`
16
+
17
+ | model | params | thr (val) | test F1 | P | R | AP | FROC [0,8] (official code) |
18
+ |---|---|---|---|---|---|---|---|
19
+ | U-Net, TinyViT-5M encoder (ours) | 5.8 M | 0.975 | **0.763** | 0.740 | 0.789 | 0.809 | 4.40 (5.02*) |
20
+ | RetinaNet, ResNet-50 FPN (torchvision, inside our pipeline: same tiles, sweep, scoring) | 32 M | 0.25 | 0.762 | 0.784 | 0.742 | 0.816 | 5.26 (5.26*) |
21
+ | U-Net, UNI2-h encoder (ViT-H, encoder lr 1e-5) | 690 M | | pending | | | | |
22
+ | U-Net, UNI encoder (ViT-L, encoder lr 1e-5), original ViT neck | 308 M | 0.985 | 0.770 | 0.745 | 0.796 | 0.757 | 4.70* |
23
+ | U-Net, UNI encoder, 4x larger ViT-matched decoder (strides 2–16, learned final up) | 324 M | 0.985 | 0.767 | 0.719 | 0.823 | 0.734 | 4.54* |
24
+
25
+ \* FROC area computed from the full ranking (`calibrate.py`); the official code samples 40 thresholds and gives a lower value for saturated score scales (TinyViT 4.40 vs 5.02).
26
+
27
+ Ammeling et al., same test ROIs, their code (1008 px patches, 100 epochs, frozen FM backbones except ResNet-50):
28
+ ResNet-50 RetinaNet end-to-end 0.792 (P 0.808 R 0.776, FROC 5.69), H-optimus-0 + RetinaNet 0.772, H-optimus-1 +
29
+ Faster R-CNN 0.772, Virchow + RetinaNet 0.762, UNI2-h + Faster R-CNN 0.756, UNI (all heads) 0.45–0.71.
30
+
31
+ Reading: a standard detector run through our pipeline lands 3 points under its published number (smaller tiles,
32
+ 5x fewer epochs, lighter augmentation), so the sweep and the metric are sound; our 5.8 M-parameter U-Net matches it
33
+ in F1 and AP. The U-Net's score scale is saturated (best threshold 0.975; a grid capped at 0.95 would cost it 13
34
+ points), and its top-ranked candidates contain more false positives (lower FROC) — a consequence of pos_weight 10.
35
+
36
+ ### Label-efficiency rounds under protocol v3 — `midog_v3/`
37
+
38
+ Seed = the hand-annotated set minus ROI 244 (which falls in val): 25 figures (42 points, SAM 3 masks) + 27 empty tiles,
39
+ 69 actions ≈ 4 min. Sweep pool = 60 of the 333 train ROIs (seed 0, round-robin over tumour types, seed ROIs excluded);
40
+ 500 decisions per round (half top-scored, half uncertain), oracle reviewer, accepted label = 14 px disc, +1,500 free
41
+ unfired background tiles per round; foundation encoders fine-tuned with encoder lr 1e-5 (decoder 3e-4), ImageNet
42
+ encoders with 3e-4 throughout; 20 epochs per round. Test = the 71 Ammeling test ROIs (1,714 figures). **F1 = test F1 at
43
+ the threshold selected on the 71 validation ROIs** (F1@best, the test-selected value, differs by ≤ 0.01 in every row and
44
+ is no longer reported). Minutes = 3 s per point, 4 s per empty tile, 2.5 s per decision.
45
+
46
+ | encoder | ceiling F1 | round 0 (69 actions, 4 min) | round 1 (569, 25 min) | round 2 (1,069, 46 min) | round 3 (1,569, 66 min) |
47
+ |---|---|---|---|---|---|
48
+ | H-optimus-0 (ViT-g, 1.1 B) | 0.792 | 0.640 | 0.725 | 0.749 | **0.759** |
49
+ | UNI2-h (ViT-H, 690 M) | 0.798 | 0.630 | 0.750 | 0.749 | 0.755 |
50
+ | UNI (ViT-L, 308 M) | 0.770 | 0.386 | 0.594 | 0.682 | 0.695 |
51
+ | SAM ViT-H (632 M, natural images) | 0.762 | 0.211 | 0.421 | 0.585 | 0.648 |
52
+ | TinyViT-5M | 0.763 | 0.350 | 0.575 | 0.627 | 0.674 |
53
+ | ResNet-50 (26 M) | – | 0.398 | 0.554 | 0.588 | 0.622 |
54
+ | FastViT-T8 | – | 0.180 | 0.429 | 0.570 | 0.607 |
55
+ | MobileNetV4-S | – | 0.129 | 0.330 | 0.499 | 0.516 |
56
+ | ConvNeXt-T | – | 0.106 | 0.256 | 0.374 | 0.467 |
57
+
58
+ AP at round 3 — H-optimus-0 0.723, UNI2-h 0.727, UNI 0.6x, SAM ViT-H 0.582, TinyViT 0.659, ResNet-50 0.592, FastViT 0.543, MNv4 0.451, ConvNeXt-T 0.350.
59
+ Earlier UNI2-h run (lost when the Colab session ended; models for rounds 1–2 not synced): 0.664 / 0.746 / 0.773 F1@best.
60
+
61
+ Reading: with a pathology foundation encoder the seed model alone (4 min of clicks) reaches ~80 % of its full-data
62
+ ceiling, one round of 500 decisions (25 min) ~91 %, two rounds ~94 %; the 5.8 M ImageNet encoder needs three rounds
63
+ (66 min) to reach 88 % of its own, lower, ceiling. Proposal acceptance rises round by round (FM: 238 → 255 → 303 of 500).
64
+ ---
65
+
66
+ # Protocol v2 (superseded 2026-09-07): official MIDOG++ csv split, threshold selected on test
67
+
68
+ All MIDOG numbers are the MIDOG metric: every test ROI (111 ROIs, 2,467 mitotic
69
+ figures of the MIDOG++ test split) swept end to end, a detection counts when its
70
+ centre is within 7.5 µm of a labelled figure; F1 at the 0.5 operating point,
71
+ F1 at the best score threshold (grid to 0.995; optimistic, chosen on test), AP over the score
72
+ ranking, FROC = mean sensitivity at 0.5/1/2/4/8 FP per ROI.
73
+ Literature: full MIDOG++ training set ≈ 0.77–0.79 F1 (RetinaNet / FM encoders); MIDOG 2025 test 0.740.
74
+
75
+ The full-training-set ceiling of our U-Net (TinyViT-5M) is F1 0.764 / AP 0.812, i.e. at the literature level,
76
+ so the decoder is not the limiting factor; the label-efficiency rounds are measured against that ceiling.
77
+
78
+ ## MIDOG++ — the three numbers (hand-in-the-loop, real clicks)
79
+
80
+ Annotator: 30 figures clicked (50 points, SAM 3 masks) + 30 empty tiles = 80 actions, 4.5 min;
81
+ then 500 accept/reject decisions on proposals from a sweep of 60 training ROIs = 21 min total.
82
+
83
+ | step | labelled tiles | actions | encoder | F1@0.5 | F1@best (thr) | AP | FROC | found / 2,467 | FP |
84
+ |---|---|---|---|---|---|---|---|---|---|
85
+ | seed: 30 masks + 30 empty | 60 | 80 | TinyViT-5M | 0.149 | – | 0.139 | 0.076 | 1,400 | 14,865 |
86
+ | seed: 30 masks + 30 empty | 60 | 80 | MobileNetV4-S | 0.073 | – | 0.034 | 0.035 | 637 | 14,310 |
87
+ | + round 1: 500 decisions (126 ✓ 374 ✗), human reviewer | 560 | 580 | TinyViT-5M | 0.299 | 0.417 (0.95) | 0.373 | 0.205 | 1,799 | 7,777 |
88
+
89
+ The paper run (`midog/`) repeats this with the ground truth as reviewer, three rounds of 500,
90
+ for TinyViT-5M, FastViT-T8, ConvNeXt-T, MobileNetV4-S, UNI, UNI2-h, H-optimus-0 (fine-tuned),
91
+ then frozen UNI and H-optimus-0; rows are appended here as they complete.
92
+ | full training set (ceiling) | 40,592 | – | TinyViT-5M | 0.365 | **0.764** (0.975) | **0.812** | 0.84 | 2,401 | 8,297 |
93
+ | full training set (ceiling) | 40,592 | – | FastViT-T8 | 0.328 | 0.751 (0.975) | 0.790 | 0.81 | 2,369 | 9,590 |
94
+ | full training set (ceiling) | 40,592 | – | MobileNetV4-S | 0.173 | 0.680 (0.975) | 0.714 | 0.71 | 2,384 | 22,680 |
95
+
96
+ ### Oracle-reviewed rounds from the same 30 hand masks (TinyViT-5M; accepted label = 14 px disc at the top proposal)
97
+
98
+ | review rounds | actions | +1,500 free background tiles / round | | | none | | |
99
+ |---|---|---|---|---|---|---|---|
100
+ | | | F1@0.5 | F1@best | AP | F1@0.5 | F1@best | AP |
101
+ | seed only | 80 | 0.170 | 0.280 | 0.170 | – | – | – |
102
+ | 1 × 500 decisions | 580 | 0.462 | 0.560 | 0.497 | 0.560* | 0.609* | 0.539* |
103
+ | 2 × 500 decisions | 1,080 | **0.472** | **0.597** | 0.560 | 0.378 | 0.587 | 0.573 |
104
+ | 3 × 500 decisions | 1,580 | **0.486** | **0.626** | **0.600** | 0.319 | 0.588 | 0.581 |
105
+ | 4 × 500 decisions | 2,080 | **0.506** | **0.668** | **0.665** | 0.262 | 0.596 | 0.591 |
106
+ | 5 × 500 decisions | 2,580 | 0.531 | 0.684 | 0.674 | – | – | – |
107
+ | 6 × 500 decisions | 3,080 | 0.511 | 0.692 | **0.688** | – | – | – |
108
+ | 8 × 500 decisions | 3,987 | 0.524 | **0.700** | 0.678 | – | – | – |
109
+
110
+ Plateau from round 6 on: ≈ 2 h of expert time reaches 85 % of the ceiling AP and 92 % of the ceiling F1.
111
+ Human-started line (your 500 decisions, then oracle), rounds 2–4: F1@best 0.607 / 0.646 / 0.666, AP 0.563 / 0.638 / 0.644.
112
+
113
+ ### Other encoders, same recipe, round 4 (2,080 actions): F1@best / AP
114
+ FastViT-T8 0.596 / 0.568 · MobileNetV4-S 0.488 / 0.422 · ConvNeXt-T 0.398 / 0.281 · UNI (ViT-L, fully fine-tuned at lr 3e-4) 0.278 / 0.167 · UNI2-h cut at round 1 (0.171 / 0.077).
115
+ Full fine-tuning of the large foundation encoders at the decoder's learning rate destroys the pretrained representation on 60 tiles; rerun with encoder lr 1e-5 pending.
116
+
117
+ \* the no-background control resumed from a state that already held one reviewed round, so its rows are shifted by one round (its "seed" row is really 1 × 500).
118
+ Reading: with free background every metric rises monotonically (AP 0.17 → 0.50 → 0.56 → 0.60, FP stays ~3.7k); without it the ranking still improves slowly (AP 0.59 at round 4) but the 0.5 operating point collapses (12k FP). Human round 1 under the same recipe: F1 0.437 / AP 0.435 vs oracle 0.462 / 0.497 — within training noise. Ceiling AP 0.81, F1 0.76.
119
+
120
+ Scale of the alternative: MIDOG++ itself holds 26,286 candidate annotations over 503 ROIs, each judged by two experts (a third on disagreement) after an exhaustive screen; ≈53,000 decisions plus ~300,000 tile views, i.e. on the order of 100 expert-hours. Three review rounds here cost 1,580 actions ≈ 67 minutes.
121
+
122
+ Round 1 by tumour type (F1@0.5): mast cell 0.59, lymphosarcoma 0.54, melanoma 0.28, breast 0.21, lung 0.19, soft-tissue sarcoma 0.16, neuroendocrine 0.13.
123
+
124
+ Review agreement with ground truth (blue hint rings): 125 correct accepts, 1 wrong accept,
125
+ 12 figures rejected (mask not on the figure), 362 correct rejects.
126
+
127
+ ## Tau (macrophage_100k, fold 0 = slides 3, 9, 17; published protocol, object F1 at 30 % mask overlap)
128
+
129
+ Oracle simulation, 3 seeds, MobileNetV4-S, 20 epochs; loop = 30 GT masks + 30 empty, then review rounds of 300.
130
+ Published references on fold 0: MNv4 full data 0.920 tile F1, GBM 0.942.
131
+
132
+ | step | actions | tile F1 | object F1 |
133
+ |---|---|---|---|
134
+ | seed 30 + 30 | 480 | 0.869 ± .015 | 0.803 |
135
+ | round 1 | 780 | 0.944 ± .003 | 0.894 |
136
+ | round 2 | 1,080 | 0.952 ± .005 | 0.905 |
137
+
138
+ Tau is easy at the tile level (13 random positives already give 0.92); the rerun with the
139
+ final protocol (30 + 300 empties, full-data ceiling, TinyViT) is queued.
140
+
141
+ ## Files (gdrive `MyDrive/macrophage/seedloop_paper/`)
142
+
143
+ - `RESULTS.md` this document
144
+ - `db/` MIDOG++ annotations (COCO), the official csv split, `midogpp_split_ammeling.json` (the split used from protocol v3 on)
145
+ - `seed/` the annotator's hand-labelled tiles (30 figures + 30 empties, SAM 3 masks, project.db); the loops use it minus ROI 244
146
+ - `ceilings/` protocol-v3 full-training-set models: `<model>.pt`, `<model>.json`, `roi_{val,test}_<model>*.json` (detections), `calib_<model>.json` (val-selected threshold → test), `crosscheck_*.json` (official MIDOG 2025 scorer on the same detections)
147
+ - `midog_v3/` protocol-v3 label-efficiency loops (renamed `loops/` once the queues finish): `<encoder>.json` (one row per round) + `<encoder>/` (state, per-round models, proposals, decisions, detections)
148
+ - `MyDrive/macrophage/_archive/seedloop_paper_oldsplit_2026-09-07/` everything from the official-csv split (protocol v2), the deep-decoder trial, the partial UNI2-h loop
@@ -0,0 +1,111 @@
1
+ """MIDOG++ -> a pathseed dataset (tiles + point ground truth + disc masks).
2
+
3
+ python experiments/midog/build_midog.py --images /content/midog/images --coco /content/MIDOGpp/databases/MIDOG++.json \
4
+ --out /content/data/midogpp --tile 256 --bg-per-roi 60 --radius 14 --jobs 8
5
+
6
+ Every ROI is cut into a tile grid. A tile is positive when >= 1 mitotic figure
7
+ centre (category 1) lies inside it (>= `--margin` px from the border); its
8
+ `points` hold the centres in tile coordinates and its `mitosis` mask is a disc
9
+ of `--radius` px around each (the segmentation-style stand-in for a click mask;
10
+ `pathseed` can later replace these with SAM 3 masks). Tiles holding only
11
+ "imposters" (category 2) are kept as background with `imposter=1` (hard
12
+ negatives); `--bg-per-roi` further random tissue tiles per ROI are background.
13
+ `slide` = ROI id, `domain` = tumor type (for leave-one-domain-out folds)."""
14
+ import argparse, json, os, random
15
+ from concurrent.futures import ProcessPoolExecutor
16
+
17
+ import numpy as np
18
+ from PIL import Image
19
+
20
+ Image.MAX_IMAGE_PIXELS = None
21
+
22
+ ap = argparse.ArgumentParser()
23
+ ap.add_argument("--images", required=True); ap.add_argument("--coco", required=True); ap.add_argument("--out", required=True)
24
+ ap.add_argument("--tile", type=int, default=256); ap.add_argument("--margin", type=int, default=8)
25
+ ap.add_argument("--bg-per-roi", type=int, default=60); ap.add_argument("--radius", type=int, default=14)
26
+ ap.add_argument("--tissue-max", type=float, default=225, help="mean gray above this = empty glass, skipped")
27
+ ap.add_argument("--jobs", type=int, default=8); ap.add_argument("--limit", type=int, default=0, help="debug: first N ROIs")
28
+ ap.add_argument("--seed", type=int, default=0)
29
+ ap.add_argument("--split-csv", default=None, help="MIDOGpp/datasets_xvalidation.csv: Slide;Dataset;Tumor;Scanner;... -> item.split")
30
+ a = ap.parse_args()
31
+ split_of, scanner_of = {}, {}
32
+ if a.split_csv:
33
+ import csv
34
+ for row in csv.DictReader(open(a.split_csv), delimiter=";"):
35
+ split_of[int(row["Slide"])] = row["Dataset"].strip(); scanner_of[int(row["Slide"])] = row.get("Scanner", "").strip()
36
+
37
+ coco = json.load(open(a.coco))
38
+ imgs = {im["id"]: im for im in coco["images"]}
39
+ ann = {}
40
+ for an in coco["annotations"]:
41
+ x0, y0, x1, y1 = an["bbox"]
42
+ ann.setdefault(an["image_id"], []).append(((x0 + x1) / 2, (y0 + y1) / 2, an["category_id"]))
43
+ T, M, R = a.tile, a.margin, a.radius
44
+ for d in ("images", "mitosis"):
45
+ os.makedirs(os.path.join(a.out, d), exist_ok=True)
46
+
47
+
48
+ def disc(h, w, cx, cy, r):
49
+ yy, xx = np.mgrid[:h, :w]
50
+ return (xx - cx) ** 2 + (yy - cy) ** 2 <= r * r
51
+
52
+
53
+ def one(image_id):
54
+ im = imgs[image_id]
55
+ path = os.path.join(a.images, im["file_name"])
56
+ if not os.path.exists(path):
57
+ return []
58
+ arr = np.asarray(Image.open(path).convert("RGB"))
59
+ H, W = arr.shape[:2]
60
+ pts = ann.get(image_id, [])
61
+ rng = random.Random(a.seed + image_id)
62
+ items = []; bg_candidates = []
63
+ for y in range(0, H - T + 1, T):
64
+ for x in range(0, W - T + 1, T):
65
+ crop = arr[y:y + T, x:x + T]
66
+ if crop.mean() > a.tissue_max:
67
+ continue
68
+ mf = [(px - x, py - y) for px, py, c in pts if c == 1 and x + M <= px < x + T - M and y + M <= py < y + T - M]
69
+ imp = [(px - x, py - y) for px, py, c in pts if c == 2 and x <= px < x + T and y <= py < y + T]
70
+ tid = f"{image_id}_{x}_{y}"
71
+ if mf:
72
+ mask = np.zeros((T, T), bool)
73
+ for px, py in mf:
74
+ mask |= disc(T, T, px, py, R)
75
+ Image.fromarray(crop).save(os.path.join(a.out, "images", f"{tid}.png"))
76
+ Image.fromarray((mask * 255).astype(np.uint8)).save(os.path.join(a.out, "mitosis", f"{tid}.png"))
77
+ items.append({"id": tid, "kind": "pos", "slide": str(image_id), "domain": im["tumor_type"], "x": x, "y": y,
78
+ "points": [[round(px, 1), round(py, 1)] for px, py in mf], "n_imposter": len(imp),
79
+ "split": split_of.get(image_id), "scanner": scanner_of.get(image_id)})
80
+ elif any(c == 1 and x <= px < x + T and y <= py < y + T for px, py, c in pts):
81
+ continue # a figure cut by the border: neither pos nor clean bg
82
+ else:
83
+ bg_candidates.append((tid, x, y, crop, len(imp)))
84
+ # hard negatives (imposter tiles) always kept; plus a random sample of the rest
85
+ hard = [b for b in bg_candidates if b[4] > 0]
86
+ rest = [b for b in bg_candidates if b[4] == 0]
87
+ rng.shuffle(rest)
88
+ for tid, x, y, crop, nimp in hard + rest[: a.bg_per_roi]:
89
+ Image.fromarray(crop).save(os.path.join(a.out, "images", f"{tid}.png"))
90
+ items.append({"id": tid, "kind": "bg", "slide": str(image_id), "domain": im["tumor_type"], "x": x, "y": y, "n_imposter": nimp,
91
+ "split": split_of.get(image_id), "scanner": scanner_of.get(image_id)})
92
+ return items
93
+
94
+
95
+ if __name__ == "__main__":
96
+ ids = sorted(imgs)
97
+ if a.limit:
98
+ ids = ids[: a.limit]
99
+ items = []
100
+ with ProcessPoolExecutor(a.jobs) as ex:
101
+ for k, res in enumerate(ex.map(one, ids), 1):
102
+ items.extend(res)
103
+ if k % 25 == 0 or k == len(ids):
104
+ print(f" {k}/{len(ids)} ROIs -> {len(items)} tiles", flush=True)
105
+ pos = sum(1 for it in items if it["kind"] == "pos"); hard = sum(1 for it in items if it["kind"] == "bg" and it.get("n_imposter"))
106
+ man = {"dataset": "MIDOG++", "tile": T, "radius": R, "coco": a.coco, "items": items,
107
+ "domains": sorted({im["tumor_type"] for im in imgs.values()}),
108
+ "summary": {"rois": len(ids), "tiles": len(items), "positive": pos, "background": len(items) - pos, "imposter_tiles": hard,
109
+ "figures": sum(len(it.get("points", [])) for it in items)}}
110
+ json.dump(man, open(os.path.join(a.out, "manifest.json"), "w"))
111
+ print(json.dumps(man["summary"], indent=1))