pathseed 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pathseed/__init__.py +16 -0
- pathseed/app.py +103 -0
- pathseed/budget.py +52 -0
- pathseed/cli.py +218 -0
- pathseed/folds.py +75 -0
- pathseed/loop.py +198 -0
- pathseed/manifest.py +213 -0
- pathseed/metrics.py +131 -0
- pathseed/models/__init__.py +52 -0
- pathseed/models/encoders.py +176 -0
- pathseed/models/prototype.py +87 -0
- pathseed/models/retinanet.py +180 -0
- pathseed/models/unet.py +233 -0
- pathseed/oracle.py +47 -0
- pathseed/plot.py +37 -0
- pathseed/project.py +193 -0
- pathseed/propose.py +81 -0
- pathseed/review.py +421 -0
- pathseed/roi_eval.py +176 -0
- pathseed/seed.py +110 -0
- pathseed/simulate.py +238 -0
- pathseed/sweep.py +195 -0
- pathseed/target.py +145 -0
- pathseed/ui/dist/assets/index-C7j52u3Y.js +578 -0
- pathseed/ui/dist/assets/index-p4NsU5kf.css +1 -0
- pathseed/ui/dist/index.html +13 -0
- pathseed/ui/review.html +232 -0
- pathseed/wsi.py +105 -0
- pathseed-0.1.0.dist-info/METADATA +169 -0
- pathseed-0.1.0.dist-info/RECORD +33 -0
- pathseed-0.1.0.dist-info/WHEEL +4 -0
- pathseed-0.1.0.dist-info/entry_points.txt +2 -0
- pathseed-0.1.0.dist-info/licenses/LICENSE +21 -0
pathseed/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""pathseed: from an unlabeled whole-slide image to a detector for a rare target.
|
|
2
|
+
|
|
3
|
+
The loop: seed (point prompts -> masks) -> train a proposal model -> sweep the
|
|
4
|
+
unlabeled pool -> expert review (accept / reject) -> retrain -> ...
|
|
5
|
+
|
|
6
|
+
Everything is parameterised by a `TargetSpec` (what the target is: which mask
|
|
7
|
+
channels, size window, a relation between channels, and how a proposal is matched
|
|
8
|
+
to ground truth), so the same code runs for a tau halo, a mitotic figure, or any
|
|
9
|
+
other rare object that a pathologist can point at.
|
|
10
|
+
"""
|
|
11
|
+
from .target import TargetSpec, Channel, Relation, Match
|
|
12
|
+
from .manifest import Manifest, Item, Label
|
|
13
|
+
from .budget import Budget
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = ["TargetSpec", "Channel", "Relation", "Match", "Manifest", "Item", "Label", "Budget"]
|
pathseed/app.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The one call a user makes:
|
|
2
|
+
|
|
3
|
+
from pathseed.app import start
|
|
4
|
+
start("/content/dataset", state="/content/state", slides_dir="/content/drive/MyDrive/svs", target="tau")
|
|
5
|
+
|
|
6
|
+
It builds (or reuses) the SAM 3 segmenter, stops a previous server on the same
|
|
7
|
+
port, picks a free port if needed, serves the app, and prints the URL, which on
|
|
8
|
+
Colab is the kernel-proxied one that opens straight from the notebook. Restart
|
|
9
|
+
as often as you like; the model stays loaded and the project bank is on disk.
|
|
10
|
+
|
|
11
|
+
stop() # stop every server started here
|
|
12
|
+
url() # the last URL again
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from . import target as T
|
|
21
|
+
from .review import serve, SERVERS
|
|
22
|
+
|
|
23
|
+
_SEGMENTERS: dict = {}
|
|
24
|
+
_LAST_URL: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _in_colab() -> bool:
|
|
28
|
+
try:
|
|
29
|
+
import google.colab # noqa: F401
|
|
30
|
+
return True
|
|
31
|
+
except ImportError:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _free_port(start: int) -> int:
|
|
36
|
+
for p in range(start, start + 50):
|
|
37
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
38
|
+
if s.connect_ex(("127.0.0.1", p)) != 0:
|
|
39
|
+
return p
|
|
40
|
+
raise RuntimeError("no free port found")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def segmenter(kind: str = "sam3", cache_dir: Optional[str] = None, model_id: str = "facebook/sam3", device: Optional[str] = None):
|
|
44
|
+
"""A cached segmenter: 'sam3' (needs the sam3 extra and the checkpoint),
|
|
45
|
+
'threshold' (no model, for dry runs) or None."""
|
|
46
|
+
if kind is None or kind == "none":
|
|
47
|
+
return None
|
|
48
|
+
key = (kind, cache_dir, model_id)
|
|
49
|
+
if key not in _SEGMENTERS:
|
|
50
|
+
if kind == "sam3":
|
|
51
|
+
from .seed import Sam3PointSegmenter
|
|
52
|
+
cache_dir = cache_dir or os.environ.get("PATHSEED_SAM3_CACHE")
|
|
53
|
+
print("loading SAM 3 …", flush=True)
|
|
54
|
+
_SEGMENTERS[key] = Sam3PointSegmenter(model_id=model_id, cache_dir=cache_dir,
|
|
55
|
+
local_files_only=bool(cache_dir), device=device)
|
|
56
|
+
elif kind == "threshold":
|
|
57
|
+
from .seed import ThresholdSegmenter
|
|
58
|
+
_SEGMENTERS[key] = ThresholdSegmenter()
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(kind)
|
|
61
|
+
return _SEGMENTERS[key]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def start(dataset: str, state: str, slides_dir: Optional[str] = None, target="tau", round_dir: Optional[str] = None,
|
|
65
|
+
port: int = 8765, seg: str = "sam3", sam3_cache: Optional[str] = None, host: str = "127.0.0.1", quiet: bool = False):
|
|
66
|
+
"""Serve the app and print its URL. Returns the server."""
|
|
67
|
+
global _LAST_URL
|
|
68
|
+
tgt = T.get(target) if isinstance(target, str) else target
|
|
69
|
+
os.makedirs(state, exist_ok=True)
|
|
70
|
+
if not os.path.exists(os.path.join(state, "target.json")):
|
|
71
|
+
tgt.save(os.path.join(state, "target.json"))
|
|
72
|
+
segm = segmenter(seg, cache_dir=sam3_cache)
|
|
73
|
+
if port in SERVERS:
|
|
74
|
+
stop(port)
|
|
75
|
+
if _free_port(port) != port:
|
|
76
|
+
newp = _free_port(port + 1)
|
|
77
|
+
print(f"port {port} is busy (not ours); using {newp}")
|
|
78
|
+
port = newp
|
|
79
|
+
srv = serve(dataset, round_dir, tgt, segmenter=segm, port=port, host=host, state=state, slides_dir=slides_dir)
|
|
80
|
+
_LAST_URL = url(port)
|
|
81
|
+
if not quiet:
|
|
82
|
+
print(f"pathseed is up → {_LAST_URL}")
|
|
83
|
+
return srv
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def url(port: int = 8765) -> str:
|
|
87
|
+
if _in_colab():
|
|
88
|
+
try:
|
|
89
|
+
from google.colab.output import eval_js
|
|
90
|
+
return str(eval_js(f"google.colab.kernel.proxyPort({port})"))
|
|
91
|
+
except Exception:
|
|
92
|
+
return f"(Colab) run: from google.colab.output import eval_js; eval_js('google.colab.kernel.proxyPort({port})')"
|
|
93
|
+
return f"http://127.0.0.1:{port}/"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def stop(port: Optional[int] = None) -> None:
|
|
97
|
+
for p in list(SERVERS):
|
|
98
|
+
if port is None or p == port:
|
|
99
|
+
s = SERVERS.pop(p)
|
|
100
|
+
try:
|
|
101
|
+
s.shutdown(); s.server_close()
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
pathseed/budget.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Annotator cost accounting. One unit = one annotator action: placing one point
|
|
2
|
+
prompt, or making one accept/reject decision. A wall-clock estimate is derived
|
|
3
|
+
with per-action seconds that can be re-fitted from a timed session.
|
|
4
|
+
|
|
5
|
+
Measured on the tau seed session (slide 20): 64 boxes, 968 points -> ~15 points
|
|
6
|
+
per instance. Review decisions in the grid UI are about 2 s each."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field, asdict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Budget:
|
|
14
|
+
seed_points: int = 0 # point prompts placed
|
|
15
|
+
seed_instances: int = 0 # instances masked from those prompts
|
|
16
|
+
seed_negatives: int = 0 # empty tiles picked as negatives
|
|
17
|
+
decisions: int = 0 # accept / reject decisions on proposals
|
|
18
|
+
accepted: int = 0
|
|
19
|
+
rejected: int = 0
|
|
20
|
+
sec_per_point: float = 3.0
|
|
21
|
+
sec_per_negative: float = 4.0
|
|
22
|
+
sec_per_decision: float = 2.0
|
|
23
|
+
log: list = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def actions(self) -> int:
|
|
27
|
+
return self.seed_points + self.seed_negatives + self.decisions
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def minutes(self) -> float:
|
|
31
|
+
return (self.seed_points * self.sec_per_point + self.seed_negatives * self.sec_per_negative
|
|
32
|
+
+ self.decisions * self.sec_per_decision) / 60.0
|
|
33
|
+
|
|
34
|
+
def add_seed(self, instances: int, points_per_instance: float, negatives: int = 0, note: str = "") -> None:
|
|
35
|
+
pts = int(round(instances * points_per_instance))
|
|
36
|
+
self.seed_points += pts
|
|
37
|
+
self.seed_instances += instances
|
|
38
|
+
self.seed_negatives += negatives
|
|
39
|
+
self.log.append({"step": "seed", "instances": instances, "points": pts, "negatives": negatives, "note": note})
|
|
40
|
+
|
|
41
|
+
def add_review(self, accepted: int, rejected: int, note: str = "") -> None:
|
|
42
|
+
self.decisions += accepted + rejected
|
|
43
|
+
self.accepted += accepted
|
|
44
|
+
self.rejected += rejected
|
|
45
|
+
self.log.append({"step": "review", "accepted": accepted, "rejected": rejected, "note": note})
|
|
46
|
+
|
|
47
|
+
def snapshot(self) -> dict:
|
|
48
|
+
d = asdict(self)
|
|
49
|
+
d.pop("log")
|
|
50
|
+
d["actions"] = self.actions
|
|
51
|
+
d["minutes"] = round(self.minutes, 1)
|
|
52
|
+
return d
|
pathseed/cli.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Command line.
|
|
2
|
+
|
|
3
|
+
pathseed folds --dataset D --out folds.json [--k 5] [--tau-fixed]
|
|
4
|
+
pathseed simulate --dataset D --target tau --folds folds.json --test-fold 0 --out runs/
|
|
5
|
+
[--model prototype|unet] [--seed-pos 30] [--seed-neg 30] [--rounds 2]
|
|
6
|
+
[--review-cap 300] [--seeds 0,1,2] [--random] [--full] [--opt k=v ...]
|
|
7
|
+
pathseed summarise --runs runs/ [--csv out.csv]
|
|
8
|
+
pathseed sweep --source slide.svs --model-path round_0/model.pt --target tau --out pool/ [--polys r.json]
|
|
9
|
+
pathseed review --dataset D --round round_0 --target tau [--sam3] [--port 8765]
|
|
10
|
+
pathseed bench --dataset D --target tau --folds folds.json --model-path m.pt --out results/
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import glob
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _opts(pairs):
|
|
22
|
+
out = {}
|
|
23
|
+
for p in pairs or []:
|
|
24
|
+
k, v = p.split("=", 1)
|
|
25
|
+
try:
|
|
26
|
+
v = json.loads(v)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
pass
|
|
29
|
+
out[k] = v
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_folds(a):
|
|
34
|
+
from .manifest import Manifest
|
|
35
|
+
from .folds import make_folds, save_folds, TAU_FIXED
|
|
36
|
+
man = Manifest.load(a.dataset)
|
|
37
|
+
folds = make_folds(man, a.k, TAU_FIXED if a.tau_fixed else None, by=a.by)
|
|
38
|
+
save_folds(a.out, folds)
|
|
39
|
+
for f in folds:
|
|
40
|
+
pos = sum(1 for i in f["test_ids"] if man.get(i).kind == "pos")
|
|
41
|
+
where = f.get("test_domains") or f["test_slides"]
|
|
42
|
+
print(f"fold {f['fold']}: {where if len(where) < 8 else str(len(where)) + ' slides'} tiles {len(f['test_ids'])} pos {pos}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _split(a):
|
|
46
|
+
from .manifest import Manifest
|
|
47
|
+
from .folds import load_folds
|
|
48
|
+
man = Manifest.load(a.dataset)
|
|
49
|
+
if getattr(a, "test_split", None): # manifest's own split column (e.g. MIDOG++ test)
|
|
50
|
+
test_ids = [it.id for it in man if it.split == a.test_split]
|
|
51
|
+
else:
|
|
52
|
+
folds = load_folds(a.folds)
|
|
53
|
+
test = next(f for f in folds if int(f["fold"]) == a.test_fold)
|
|
54
|
+
test_ids = list(test["test_ids"])
|
|
55
|
+
ts = set(test_ids)
|
|
56
|
+
pool_ids = [it.id for it in man if it.id not in ts]
|
|
57
|
+
return man, pool_ids, test_ids
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_simulate(a):
|
|
61
|
+
from . import target as T
|
|
62
|
+
from .simulate import SimConfig, run, random_baseline, upper_bound
|
|
63
|
+
man, pool, test = _split(a)
|
|
64
|
+
tgt = T.get(a.target)
|
|
65
|
+
opts = _opts(a.opt)
|
|
66
|
+
seeds = [int(s) for s in a.seeds.split(",")]
|
|
67
|
+
for s in seeds:
|
|
68
|
+
cfg = SimConfig(model=a.model, model_opts=opts, seed_pos=a.seed_pos, seed_neg=a.seed_neg, rounds=a.rounds,
|
|
69
|
+
review_cap=a.review_cap, review_mix=a.review_mix, seed=s, pool_limit=a.pool_limit,
|
|
70
|
+
test_limit=a.test_limit, save_models=a.save_models)
|
|
71
|
+
res = run(man, tgt, cfg, pool, test, out_dir=a.out)
|
|
72
|
+
if a.random:
|
|
73
|
+
for rec in res["records"]:
|
|
74
|
+
random_baseline(man, tgt, cfg, pool, test, actions=rec["budget"]["actions"], out_dir=a.out)
|
|
75
|
+
if a.full:
|
|
76
|
+
cfg = SimConfig(model=a.model, model_opts=opts, seed=seeds[0], test_limit=a.test_limit)
|
|
77
|
+
upper_bound(man, tgt, cfg, pool, test, out_dir=a.out)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_summarise(a):
|
|
81
|
+
from .simulate import summarise
|
|
82
|
+
results = []
|
|
83
|
+
for p in sorted(glob.glob(os.path.join(a.runs, "*.json"))):
|
|
84
|
+
with open(p) as f:
|
|
85
|
+
results.append(json.load(f))
|
|
86
|
+
rows = summarise(results)
|
|
87
|
+
hdr = ["kind", "model", "seed_pos", "review_cap", "round", "n", "actions", "object_f1", "object_sd", "object_ap",
|
|
88
|
+
"tile_f1", "tile_sd", "tile_ap", "found", "positives", "false_alarms"]
|
|
89
|
+
print("\t".join(hdr))
|
|
90
|
+
for r in rows:
|
|
91
|
+
print("\t".join(f"{r[h]:.3f}" if isinstance(r[h], float) else str(r[h]) for h in hdr))
|
|
92
|
+
if a.csv:
|
|
93
|
+
import csv
|
|
94
|
+
with open(a.csv, "w", newline="") as f:
|
|
95
|
+
w = csv.DictWriter(f, fieldnames=hdr); w.writeheader()
|
|
96
|
+
for r in rows:
|
|
97
|
+
w.writerow({h: r[h] for h in hdr})
|
|
98
|
+
if a.plot:
|
|
99
|
+
from .plot import plot_curves
|
|
100
|
+
plot_curves(rows, a.plot)
|
|
101
|
+
print(f"plot -> {a.plot}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _load_model(path):
|
|
105
|
+
if path.endswith(".pt"):
|
|
106
|
+
from .models.unet import UNetModel
|
|
107
|
+
return UNetModel.load(path)
|
|
108
|
+
from .models.prototype import PrototypeModel
|
|
109
|
+
return PrototypeModel.load(path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_sweep(a):
|
|
113
|
+
from . import target as T
|
|
114
|
+
from .sweep import open_source, sweep, load_polys
|
|
115
|
+
tgt = T.get(a.target)
|
|
116
|
+
model = _load_model(a.model_path)
|
|
117
|
+
src = open_source(a.source)
|
|
118
|
+
sid = a.slide_id or os.path.splitext(os.path.basename(a.source))[0]
|
|
119
|
+
polys = load_polys(a.polys) if a.polys else None
|
|
120
|
+
sweep(src, model, tgt, sid, a.out, polys=polys, batch=a.batch, max_tiles=a.max_tiles)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cmd_review(a):
|
|
124
|
+
from . import target as T
|
|
125
|
+
from .review import serve
|
|
126
|
+
tgt = T.get(a.target)
|
|
127
|
+
seg = None
|
|
128
|
+
if a.sam3:
|
|
129
|
+
from .seed import Sam3PointSegmenter
|
|
130
|
+
seg = Sam3PointSegmenter(cache_dir=a.sam3_cache, local_files_only=bool(a.sam3_cache))
|
|
131
|
+
elif a.threshold_segmenter:
|
|
132
|
+
from .seed import ThresholdSegmenter
|
|
133
|
+
seg = ThresholdSegmenter()
|
|
134
|
+
serve(a.dataset, a.round, tgt, segmenter=seg, port=a.port, host=a.host, block=True, state=a.state, slides_dir=a.slides)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_encoders(a):
|
|
138
|
+
from .models.encoders import describe
|
|
139
|
+
for spec in a.specs:
|
|
140
|
+
try:
|
|
141
|
+
d = describe(spec, a.size)
|
|
142
|
+
print(f"{spec}: {d['params_M']} M params; " + ", ".join(f"/{l['stride']}:{l['channels']}ch {l['shape'][0]}x{l['shape'][1]}" for l in d["levels"]))
|
|
143
|
+
except Exception as e:
|
|
144
|
+
print(f"{spec}: FAILED {type(e).__name__}: {e}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_bench(a):
|
|
148
|
+
from . import target as T
|
|
149
|
+
from .folds import load_folds
|
|
150
|
+
from .manifest import Manifest
|
|
151
|
+
from .metrics import score, fold_record
|
|
152
|
+
from .simulate import _predict_proposals
|
|
153
|
+
tgt = T.get(a.target)
|
|
154
|
+
man = Manifest.load(a.dataset)
|
|
155
|
+
model = _load_model(a.model_path)
|
|
156
|
+
os.makedirs(a.out, exist_ok=True)
|
|
157
|
+
for f in load_folds(a.folds):
|
|
158
|
+
if a.fold is not None and int(f["fold"]) != a.fold:
|
|
159
|
+
continue
|
|
160
|
+
props = _predict_proposals(model, man, tgt, f["test_ids"], a.batch, keep_masks=True)
|
|
161
|
+
sc = score(props, man, f["test_ids"], tgt)
|
|
162
|
+
rec = fold_record(a.name, f["fold"], f["test_slides"], {"target": tgt.to_dict()}, sc)
|
|
163
|
+
with open(os.path.join(a.out, f"fold{f['fold']}.json"), "w") as fh:
|
|
164
|
+
json.dump(rec, fh, indent=2)
|
|
165
|
+
print(f"fold {f['fold']}: object {sc['object']} tile {sc['tile']}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main(argv=None):
|
|
169
|
+
ap = argparse.ArgumentParser(prog="pathseed")
|
|
170
|
+
sp = ap.add_subparsers(dest="cmd", required=True)
|
|
171
|
+
|
|
172
|
+
p = sp.add_parser("folds"); p.add_argument("--dataset", required=True); p.add_argument("--out", required=True)
|
|
173
|
+
p.add_argument("--k", type=int, default=5); p.add_argument("--tau-fixed", action="store_true")
|
|
174
|
+
p.add_argument("--by", choices=["slide", "domain"], default="slide", help="domain = all slides of a tumor type / scanner in one fold")
|
|
175
|
+
p.set_defaults(fn=cmd_folds)
|
|
176
|
+
|
|
177
|
+
p = sp.add_parser("simulate")
|
|
178
|
+
p.add_argument("--dataset", required=True); p.add_argument("--target", default="tau")
|
|
179
|
+
p.add_argument("--folds"); p.add_argument("--test-fold", type=int, default=0)
|
|
180
|
+
p.add_argument("--test-split", help="use manifest items with this split value as the test set instead of a fold")
|
|
181
|
+
p.add_argument("--out", required=True); p.add_argument("--model", default="prototype")
|
|
182
|
+
p.add_argument("--seed-pos", type=int, default=30); p.add_argument("--seed-neg", type=int, default=30)
|
|
183
|
+
p.add_argument("--rounds", type=int, default=2); p.add_argument("--review-cap", type=int, default=300)
|
|
184
|
+
p.add_argument("--review-mix", type=float, default=1.0, help="fraction of the review queue from the top of the ranking; rest from the uncertain bottom")
|
|
185
|
+
p.add_argument("--seeds", default="0"); p.add_argument("--random", action="store_true")
|
|
186
|
+
p.add_argument("--full", action="store_true"); p.add_argument("--pool-limit", type=int, default=0)
|
|
187
|
+
p.add_argument("--test-limit", type=int, default=0); p.add_argument("--save-models", action="store_true")
|
|
188
|
+
p.add_argument("--opt", nargs="*", help="model options k=v (json values), e.g. epochs=20 encoder=fastvit_t8 freeze_encoder=true")
|
|
189
|
+
p.set_defaults(fn=cmd_simulate)
|
|
190
|
+
|
|
191
|
+
p = sp.add_parser("summarise"); p.add_argument("--runs", required=True); p.add_argument("--csv")
|
|
192
|
+
p.add_argument("--plot"); p.set_defaults(fn=cmd_summarise)
|
|
193
|
+
|
|
194
|
+
p = sp.add_parser("sweep"); p.add_argument("--source", required=True); p.add_argument("--model-path", required=True)
|
|
195
|
+
p.add_argument("--target", default="tau"); p.add_argument("--out", required=True); p.add_argument("--polys")
|
|
196
|
+
p.add_argument("--slide-id"); p.add_argument("--batch", type=int, default=64); p.add_argument("--max-tiles", type=int, default=0)
|
|
197
|
+
p.set_defaults(fn=cmd_sweep)
|
|
198
|
+
|
|
199
|
+
p = sp.add_parser("review"); p.add_argument("--dataset", required=True); p.add_argument("--round")
|
|
200
|
+
p.add_argument("--target", default="tau"); p.add_argument("--sam3", action="store_true"); p.add_argument("--sam3-cache")
|
|
201
|
+
p.add_argument("--threshold-segmenter", action="store_true"); p.add_argument("--port", type=int, default=8765)
|
|
202
|
+
p.add_argument("--host", default="127.0.0.1"); p.add_argument("--state", help="project state dir (project.db)")
|
|
203
|
+
p.add_argument("--slides", help="directory of slides the server may open"); p.set_defaults(fn=cmd_review)
|
|
204
|
+
|
|
205
|
+
p = sp.add_parser("encoders", help="show the pyramid each encoder gives the decoder")
|
|
206
|
+
p.add_argument("specs", nargs="+"); p.add_argument("--size", type=int, default=256); p.set_defaults(fn=cmd_encoders)
|
|
207
|
+
|
|
208
|
+
p = sp.add_parser("bench"); p.add_argument("--dataset", required=True); p.add_argument("--target", default="tau")
|
|
209
|
+
p.add_argument("--folds", required=True); p.add_argument("--model-path", required=True); p.add_argument("--out", required=True)
|
|
210
|
+
p.add_argument("--name", default="model"); p.add_argument("--fold", type=int); p.add_argument("--batch", type=int, default=64)
|
|
211
|
+
p.set_defaults(fn=cmd_bench)
|
|
212
|
+
|
|
213
|
+
a = ap.parse_args(argv)
|
|
214
|
+
a.fn(a)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
sys.exit(main())
|
pathseed/folds.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Slide-held-out folds: every slide lives wholly in one fold. Greedy assignment
|
|
2
|
+
by positive count then total count keeps the folds roughly balanced even when a
|
|
3
|
+
single slide carries a third of the positives (as slide 17 does for tau)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .manifest import Manifest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def slide_fold_map(manifest: Manifest, k: int = 5, fixed: Optional[dict] = None) -> dict[str, int]:
|
|
13
|
+
counts: dict[str, dict] = {}
|
|
14
|
+
for it in manifest:
|
|
15
|
+
c = counts.setdefault(it.slide, {"pos": 0, "bg": 0})
|
|
16
|
+
c[it.kind] += 1
|
|
17
|
+
if fixed and set(fixed) == set(counts):
|
|
18
|
+
return {s: int(f) for s, f in fixed.items()}
|
|
19
|
+
fold = [{"pos": 0, "tot": 0} for _ in range(k)]
|
|
20
|
+
out = {}
|
|
21
|
+
for s, c in sorted(counts.items(), key=lambda kv: (kv[1]["pos"], kv[1]["pos"] + kv[1]["bg"]), reverse=True):
|
|
22
|
+
i = min(range(k), key=lambda j: (fold[j]["pos"], fold[j]["tot"]))
|
|
23
|
+
out[s] = i
|
|
24
|
+
fold[i]["pos"] += c["pos"]
|
|
25
|
+
fold[i]["tot"] += c["pos"] + c["bg"]
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def domain_fold_map(manifest: Manifest, k: Optional[int] = None) -> dict[str, int]:
|
|
30
|
+
"""Every slide of a domain in the same fold. k=None -> one fold per domain
|
|
31
|
+
(leave-one-domain-out); otherwise domains are packed greedily into k folds."""
|
|
32
|
+
dom_of = {}; counts: dict[str, dict] = {}
|
|
33
|
+
for it in manifest:
|
|
34
|
+
d = it.domain or "?"
|
|
35
|
+
dom_of[it.slide] = d
|
|
36
|
+
c = counts.setdefault(d, {"pos": 0, "tot": 0}); c["pos"] += it.kind == "pos"; c["tot"] += 1
|
|
37
|
+
doms = sorted(counts, key=lambda d: -counts[d]["pos"])
|
|
38
|
+
if k is None or k >= len(doms):
|
|
39
|
+
dfold = {d: i for i, d in enumerate(doms)}
|
|
40
|
+
else:
|
|
41
|
+
load = [0] * k; dfold = {}
|
|
42
|
+
for d in doms:
|
|
43
|
+
i = min(range(k), key=lambda j: load[j]); dfold[d] = i; load[i] += counts[d]["pos"]
|
|
44
|
+
return {s: dfold[d] for s, d in dom_of.items()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def make_folds(manifest: Manifest, k: int = 5, fixed: Optional[dict] = None, by: str = "slide") -> list[dict]:
|
|
48
|
+
if by == "domain":
|
|
49
|
+
fm = domain_fold_map(manifest, k)
|
|
50
|
+
k = max(fm.values()) + 1
|
|
51
|
+
else:
|
|
52
|
+
fm = slide_fold_map(manifest, k, fixed)
|
|
53
|
+
out = []
|
|
54
|
+
for f in range(k):
|
|
55
|
+
slides = sorted([s for s, ff in fm.items() if ff == f], key=lambda s: (0, int(s)) if s.isdigit() else (1, s))
|
|
56
|
+
ids = [it.id for it in manifest if fm[it.slide] == f]
|
|
57
|
+
doms = sorted({it.domain for it in manifest if fm[it.slide] == f and it.domain})
|
|
58
|
+
out.append({"fold": f, "test_slides": slides, "test_ids": ids, **({"test_domains": doms} if doms else {})})
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_folds(path: str, folds: list[dict]) -> None:
|
|
63
|
+
with open(path, "w") as f:
|
|
64
|
+
json.dump({"folds": folds}, f)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_folds(path: str) -> list[dict]:
|
|
68
|
+
with open(path) as f:
|
|
69
|
+
d = json.load(f)
|
|
70
|
+
return d["folds"] if isinstance(d, dict) else d
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# the published 17-slide tau assignment, so results stay comparable
|
|
74
|
+
TAU_FIXED = {"3": 0, "9": 0, "17": 0, "1": 1, "2": 1, "5": 1, "10": 1, "4": 2, "8": 2, "11": 2,
|
|
75
|
+
"14": 3, "16": 3, "20": 3, "6": 4, "7": 4, "13": 4, "18": 4}
|
pathseed/loop.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""The real loop, with a person in the review seat. State lives in one directory:
|
|
2
|
+
|
|
3
|
+
<state>/target.json the TargetSpec
|
|
4
|
+
<state>/labels.json the labelled set (grows every round)
|
|
5
|
+
<state>/budget.json annotator cost so far
|
|
6
|
+
<state>/round_<k>/model.* proposal model trained on the labelled set
|
|
7
|
+
<state>/round_<k>/proposals.json what the sweep found on the pool
|
|
8
|
+
<state>/round_<k>/review.json the reviewer's decisions (written by the UI)
|
|
9
|
+
|
|
10
|
+
Tiles (images + masks) live in a dataset directory with the layout of
|
|
11
|
+
`pathseed.manifest`; the seeding step and the sweep both append to it, so the
|
|
12
|
+
labelled set only ever refers to tile ids.
|
|
13
|
+
|
|
14
|
+
loop = Loop.init(state, dataset, target)
|
|
15
|
+
loop.add_labels(seed_labels) # from pathseed.seed
|
|
16
|
+
loop.train(k) # -> round_k/model
|
|
17
|
+
loop.propose(k, pool_ids) # -> round_k/proposals.json
|
|
18
|
+
... review in the browser (pathseed.review.serve) ...
|
|
19
|
+
loop.ingest_review(k) # decisions -> labels.json, budget.json
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from dataclasses import asdict
|
|
26
|
+
from typing import Iterable, Optional
|
|
27
|
+
|
|
28
|
+
from .budget import Budget
|
|
29
|
+
from .manifest import Manifest, Label, save_labels, load_labels
|
|
30
|
+
from .models import make as make_model
|
|
31
|
+
from .propose import proposals_from_probs
|
|
32
|
+
from .target import TargetSpec
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Loop:
|
|
36
|
+
def __init__(self, state: str, dataset: str, target: TargetSpec):
|
|
37
|
+
self.state, self.dataset_dir, self.target = state, dataset, target
|
|
38
|
+
self.manifest = Manifest.load(dataset)
|
|
39
|
+
self.labels: list[Label] = load_labels(self.labels_path) if os.path.exists(self.labels_path) else []
|
|
40
|
+
self.budget = self._load_budget()
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------ paths
|
|
43
|
+
@property
|
|
44
|
+
def labels_path(self):
|
|
45
|
+
return os.path.join(self.state, "labels.json")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def budget_path(self):
|
|
49
|
+
return os.path.join(self.state, "budget.json")
|
|
50
|
+
|
|
51
|
+
def round_dir(self, k: int) -> str:
|
|
52
|
+
d = os.path.join(self.state, f"round_{k}")
|
|
53
|
+
os.makedirs(d, exist_ok=True)
|
|
54
|
+
return d
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------ init/io
|
|
57
|
+
@classmethod
|
|
58
|
+
def init(cls, state: str, dataset: str, target: TargetSpec) -> "Loop":
|
|
59
|
+
os.makedirs(state, exist_ok=True)
|
|
60
|
+
target.save(os.path.join(state, "target.json"))
|
|
61
|
+
if not os.path.exists(os.path.join(dataset, "manifest.json")):
|
|
62
|
+
Manifest(dataset, [], tile=target.tile).save()
|
|
63
|
+
return cls(state, dataset, target)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def open(cls, state: str, dataset: str) -> "Loop":
|
|
67
|
+
return cls(state, dataset, TargetSpec.load(os.path.join(state, "target.json")))
|
|
68
|
+
|
|
69
|
+
def _load_budget(self) -> Budget:
|
|
70
|
+
if os.path.exists(self.budget_path):
|
|
71
|
+
with open(self.budget_path) as f:
|
|
72
|
+
d = json.load(f)
|
|
73
|
+
b = Budget(**{k: v for k, v in d.items() if k in Budget.__dataclass_fields__})
|
|
74
|
+
return b
|
|
75
|
+
return Budget()
|
|
76
|
+
|
|
77
|
+
def save(self) -> None:
|
|
78
|
+
save_labels(self.labels_path, self.labels)
|
|
79
|
+
with open(self.budget_path, "w") as f:
|
|
80
|
+
json.dump(asdict(self.budget), f, indent=1)
|
|
81
|
+
|
|
82
|
+
def reload_manifest(self) -> None:
|
|
83
|
+
self.manifest = Manifest.load(self.dataset_dir)
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------ project bank
|
|
86
|
+
@property
|
|
87
|
+
def project(self):
|
|
88
|
+
from .project import Project
|
|
89
|
+
if not hasattr(self, "_project"):
|
|
90
|
+
self._project = Project(os.path.join(self.state, "project.db"))
|
|
91
|
+
return self._project
|
|
92
|
+
|
|
93
|
+
def sync_project(self) -> dict:
|
|
94
|
+
"""Pull the labelled set from the project bank (what the browser
|
|
95
|
+
recorded: seed tiles, review decisions) and its budget ledger."""
|
|
96
|
+
self.reload_manifest()
|
|
97
|
+
labs = self.project.labels(self.dataset_dir, self.target.channels)
|
|
98
|
+
have = {l.id for l in self.labels}
|
|
99
|
+
new = [l for l in labs if l.id not in have]
|
|
100
|
+
self.labels.extend(new)
|
|
101
|
+
b = self.project.budget()
|
|
102
|
+
self.budget.seed_points = b["seed_points"]; self.budget.seed_instances = b["seed_instances"]
|
|
103
|
+
self.budget.seed_negatives = b["seed_negatives"]; self.budget.decisions = b["decisions"]
|
|
104
|
+
self.budget.accepted = b["accepted"]; self.budget.rejected = b["rejected"]
|
|
105
|
+
self.save()
|
|
106
|
+
return {"added": len(new), "labelled": len(self.labels), "budget": self.budget.snapshot()}
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------ steps
|
|
109
|
+
def add_labels(self, labels: Iterable[Label], seed_instances: int = 0, seed_negatives: int = 0) -> None:
|
|
110
|
+
have = {l.id for l in self.labels}
|
|
111
|
+
new = [l for l in labels if l.id not in have]
|
|
112
|
+
self.labels.extend(new)
|
|
113
|
+
if seed_instances or seed_negatives:
|
|
114
|
+
self.budget.add_seed(seed_instances, self.target.points_per_instance, seed_negatives, note="seed")
|
|
115
|
+
self.save()
|
|
116
|
+
|
|
117
|
+
def train(self, k: int, model: str = "unet", log=print, **opts):
|
|
118
|
+
if log is None:
|
|
119
|
+
log = lambda *a: None # noqa: E731
|
|
120
|
+
self.reload_manifest()
|
|
121
|
+
n_pos = sum(1 for l in self.labels if l.kind == "pos")
|
|
122
|
+
if os.path.exists(os.path.join(self.state, "project.db")):
|
|
123
|
+
self.project.start_round(k, model, len(self.labels), n_pos)
|
|
124
|
+
m = make_model(model, **opts).fit(self.manifest, self.target, self.labels, log=log)
|
|
125
|
+
m.save(os.path.join(self.round_dir(k), "model." + ("pt" if model in ("unet", "dualmask") else "json")))
|
|
126
|
+
if os.path.exists(os.path.join(self.state, "project.db")):
|
|
127
|
+
self.project.finish_round(k, {})
|
|
128
|
+
with open(os.path.join(self.round_dir(k), "train.json"), "w") as f:
|
|
129
|
+
json.dump({"model": model, "opts": opts, "labelled": len(self.labels),
|
|
130
|
+
"labelled_pos": sum(1 for l in self.labels if l.kind == "pos")}, f, indent=1)
|
|
131
|
+
return m
|
|
132
|
+
|
|
133
|
+
def load_model(self, k: int):
|
|
134
|
+
d = self.round_dir(k)
|
|
135
|
+
if os.path.exists(os.path.join(d, "model.pt")):
|
|
136
|
+
from .models.unet import UNetModel
|
|
137
|
+
return UNetModel.load(os.path.join(d, "model.pt"))
|
|
138
|
+
from .models.prototype import PrototypeModel
|
|
139
|
+
return PrototypeModel.load(os.path.join(d, "model.json"))
|
|
140
|
+
|
|
141
|
+
def propose(self, k: int, pool_ids: Optional[Iterable[str]] = None, model=None, cap: int = 0,
|
|
142
|
+
batch: int = 64, mix: float = 1.0, log=print) -> dict:
|
|
143
|
+
"""Score the pool (default: every dataset tile not yet labelled), keep tiles
|
|
144
|
+
that fire, rank by best proposal score, write round_k/proposals.json."""
|
|
145
|
+
self.reload_manifest()
|
|
146
|
+
model = model or self.load_model(k)
|
|
147
|
+
labelled = {l.id for l in self.labels}
|
|
148
|
+
pool = [i for i in (pool_ids if pool_ids is not None else (it.id for it in self.manifest)) if i not in labelled]
|
|
149
|
+
tiles = {}
|
|
150
|
+
for tid, probs in model.predict(self.manifest, pool, batch=batch):
|
|
151
|
+
props = proposals_from_probs(tid, probs, self.target, keep_masks=False)
|
|
152
|
+
if props:
|
|
153
|
+
tiles[tid] = [p.to_dict() for p in props]
|
|
154
|
+
from .simulate import select_for_review
|
|
155
|
+
ranked = sorted(tiles, key=lambda t: -max(p["score"] for p in tiles[t]))
|
|
156
|
+
if cap:
|
|
157
|
+
ranked = [t for _, t in select_for_review([(max(p["score"] for p in tiles[t]), t) for t in ranked], cap, mix)]
|
|
158
|
+
out = {"round": k, "pool": len(pool), "fired": len(tiles), "shown": len(ranked),
|
|
159
|
+
"tiles": [{"id": t, "score": max(p["score"] for p in tiles[t]), "proposals": tiles[t]} for t in ranked]}
|
|
160
|
+
with open(os.path.join(self.round_dir(k), "proposals.json"), "w") as f:
|
|
161
|
+
json.dump(out, f)
|
|
162
|
+
log(f"round {k}: pool {len(pool)} tiles, {len(tiles)} fired, {len(ranked)} queued for review")
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
def ingest_review(self, k: int, accepted_source: str = "gt") -> dict:
|
|
166
|
+
"""Read round_k/review.json ({id: "accept"|"reject"}) into the labelled set.
|
|
167
|
+
Accepted tiles take masks from `accepted_source`: "gt" when the dataset
|
|
168
|
+
holds (corrected) masks for them, else the proposal masks saved by the
|
|
169
|
+
review server under round_k/masks/<channel>/<id>.png."""
|
|
170
|
+
d = self.round_dir(k)
|
|
171
|
+
self.reload_manifest()
|
|
172
|
+
with open(os.path.join(d, "review.json")) as f:
|
|
173
|
+
rev = json.load(f)
|
|
174
|
+
have = {l.id for l in self.labels}
|
|
175
|
+
acc = rej = 0
|
|
176
|
+
for tid, dec in rev.items():
|
|
177
|
+
if tid in have:
|
|
178
|
+
continue
|
|
179
|
+
if dec == "accept":
|
|
180
|
+
if accepted_source == "gt":
|
|
181
|
+
self.labels.append(Label(tid, "pos", "gt", origin="review", round=k + 1))
|
|
182
|
+
else:
|
|
183
|
+
# the proposal masks: written by the sweep into the dataset's channel dirs
|
|
184
|
+
# (or, when a reviewer edited them, under round_k/masks/)
|
|
185
|
+
files = {}
|
|
186
|
+
for c in self.target.channels:
|
|
187
|
+
edited = os.path.join(d, "masks", c.dir, f"{tid}.png")
|
|
188
|
+
files[c.name] = edited if os.path.exists(edited) else self.manifest.mask_path(c.dir, tid)
|
|
189
|
+
if not os.path.exists(files[c.name]):
|
|
190
|
+
raise FileNotFoundError(f"accepted tile {tid} has no {c.name} mask at {files[c.name]}")
|
|
191
|
+
self.labels.append(Label(tid, "pos", "files", files, origin="review", round=k + 1))
|
|
192
|
+
acc += 1
|
|
193
|
+
elif dec == "reject":
|
|
194
|
+
self.labels.append(Label(tid, "bg", "empty", origin="review", round=k + 1))
|
|
195
|
+
rej += 1
|
|
196
|
+
self.budget.add_review(acc, rej, note=f"round {k} review")
|
|
197
|
+
self.save()
|
|
198
|
+
return {"accepted": acc, "rejected": rej, "labelled": len(self.labels), "budget": self.budget.snapshot()}
|