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.
- pathseed-0.1.0/.github/workflows/publish.yml +35 -0
- pathseed-0.1.0/.gitignore +13 -0
- pathseed-0.1.0/CITATION.cff +8 -0
- pathseed-0.1.0/LICENSE +21 -0
- pathseed-0.1.0/PKG-INFO +169 -0
- pathseed-0.1.0/README.md +143 -0
- pathseed-0.1.0/experiments/midog/RESULTS.md +148 -0
- pathseed-0.1.0/experiments/midog/build_midog.py +111 -0
- pathseed-0.1.0/experiments/midog/calibrate.py +77 -0
- pathseed-0.1.0/experiments/midog/colab_ceiling2.sh +42 -0
- pathseed-0.1.0/experiments/midog/colab_loop_v3.sh +38 -0
- pathseed-0.1.0/experiments/midog/crosscheck_eval.py +114 -0
- pathseed-0.1.0/experiments/midog/full_bench.py +74 -0
- pathseed-0.1.0/experiments/midog/loop_calibrate.py +76 -0
- pathseed-0.1.0/experiments/midog/midog_dl_all.py +21 -0
- pathseed-0.1.0/experiments/midog/midogpp_split_ammeling.json +485 -0
- pathseed-0.1.0/experiments/midog/paper_midog.py +194 -0
- pathseed-0.1.0/experiments/midog/roi_eval.py +49 -0
- pathseed-0.1.0/pyproject.toml +33 -0
- pathseed-0.1.0/src/pathseed/__init__.py +16 -0
- pathseed-0.1.0/src/pathseed/app.py +103 -0
- pathseed-0.1.0/src/pathseed/budget.py +52 -0
- pathseed-0.1.0/src/pathseed/cli.py +218 -0
- pathseed-0.1.0/src/pathseed/folds.py +75 -0
- pathseed-0.1.0/src/pathseed/loop.py +198 -0
- pathseed-0.1.0/src/pathseed/manifest.py +213 -0
- pathseed-0.1.0/src/pathseed/metrics.py +131 -0
- pathseed-0.1.0/src/pathseed/models/__init__.py +52 -0
- pathseed-0.1.0/src/pathseed/models/encoders.py +176 -0
- pathseed-0.1.0/src/pathseed/models/prototype.py +87 -0
- pathseed-0.1.0/src/pathseed/models/retinanet.py +180 -0
- pathseed-0.1.0/src/pathseed/models/unet.py +233 -0
- pathseed-0.1.0/src/pathseed/oracle.py +47 -0
- pathseed-0.1.0/src/pathseed/plot.py +37 -0
- pathseed-0.1.0/src/pathseed/project.py +193 -0
- pathseed-0.1.0/src/pathseed/propose.py +81 -0
- pathseed-0.1.0/src/pathseed/review.py +421 -0
- pathseed-0.1.0/src/pathseed/roi_eval.py +176 -0
- pathseed-0.1.0/src/pathseed/seed.py +110 -0
- pathseed-0.1.0/src/pathseed/simulate.py +238 -0
- pathseed-0.1.0/src/pathseed/sweep.py +195 -0
- pathseed-0.1.0/src/pathseed/target.py +145 -0
- pathseed-0.1.0/src/pathseed/ui/dist/assets/index-C7j52u3Y.js +578 -0
- pathseed-0.1.0/src/pathseed/ui/dist/assets/index-p4NsU5kf.css +1 -0
- pathseed-0.1.0/src/pathseed/ui/dist/index.html +13 -0
- pathseed-0.1.0/src/pathseed/ui/review.html +232 -0
- pathseed-0.1.0/src/pathseed/wsi.py +105 -0
- pathseed-0.1.0/tests/conftest.py +62 -0
- pathseed-0.1.0/tests/test_core.py +166 -0
- pathseed-0.1.0/tests/test_review_seed.py +166 -0
- pathseed-0.1.0/ui-src/index.html +12 -0
- pathseed-0.1.0/ui-src/package.json +29 -0
- pathseed-0.1.0/ui-src/postcss.config.js +1 -0
- pathseed-0.1.0/ui-src/src/App.tsx +75 -0
- pathseed-0.1.0/ui-src/src/api.ts +57 -0
- pathseed-0.1.0/ui-src/src/components/FocusPanel.tsx +66 -0
- pathseed-0.1.0/ui-src/src/components/ReviewGrid.tsx +46 -0
- pathseed-0.1.0/ui-src/src/components/SeedEditor.tsx +190 -0
- pathseed-0.1.0/ui-src/src/components/SeedWorkbench.tsx +161 -0
- pathseed-0.1.0/ui-src/src/components/TileCanvas.tsx +86 -0
- pathseed-0.1.0/ui-src/src/components/WsiViewer.tsx +264 -0
- pathseed-0.1.0/ui-src/src/components/fx.tsx +79 -0
- pathseed-0.1.0/ui-src/src/index.css +13 -0
- pathseed-0.1.0/ui-src/src/main.tsx +10 -0
- pathseed-0.1.0/ui-src/src/pages/ProjectPage.tsx +191 -0
- pathseed-0.1.0/ui-src/src/pages/ReviewPage.tsx +53 -0
- pathseed-0.1.0/ui-src/src/pages/RoundsPage.tsx +72 -0
- pathseed-0.1.0/ui-src/src/pages/SeedPage.tsx +242 -0
- pathseed-0.1.0/ui-src/src/pages/SlidesPage.tsx +72 -0
- pathseed-0.1.0/ui-src/src/pages/TilesPage.tsx +71 -0
- pathseed-0.1.0/ui-src/tailwind.config.js +22 -0
- pathseed-0.1.0/ui-src/tsconfig.json +15 -0
- 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,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.
|
pathseed-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
pathseed-0.1.0/README.md
ADDED
|
@@ -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))
|