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 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()}