synthrad-sct 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.
- synthrad_sct/__init__.py +1 -0
- synthrad_sct/__main__.py +4 -0
- synthrad_sct/cli.py +307 -0
- synthrad_sct/environment.py +122 -0
- synthrad_sct/github.py +151 -0
- synthrad_sct/models.py +56 -0
- synthrad_sct/weights_repo.py +85 -0
- synthrad_sct-0.1.0.dist-info/METADATA +129 -0
- synthrad_sct-0.1.0.dist-info/RECORD +11 -0
- synthrad_sct-0.1.0.dist-info/WHEEL +4 -0
- synthrad_sct-0.1.0.dist-info/entry_points.txt +2 -0
synthrad_sct/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
synthrad_sct/__main__.py
ADDED
synthrad_sct/cli.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""synthrad-sct: install the team's MRI-to-sCT models in one command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.progress import (BarColumn, DownloadColumn, Progress, TextColumn,
|
|
14
|
+
TimeRemainingColumn, TransferSpeedColumn)
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from . import __version__, environment as env, weights_repo
|
|
18
|
+
from .github import Client, GitHubError, find_token, git_auth_env, prompt_token, sha256_file
|
|
19
|
+
from .models import DEFAULT_MODELS, GITHUB_HOST, MODELS, WEIGHTS_REPO, WEIGHTS_TAG
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def interactive(args):
|
|
25
|
+
return not args.yes and sys.stdin.isatty()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_token(args):
|
|
29
|
+
token, source = find_token()
|
|
30
|
+
if token:
|
|
31
|
+
console.print(f"[dim]Using GitHub token from {source}.[/]")
|
|
32
|
+
return token
|
|
33
|
+
if not interactive(args):
|
|
34
|
+
raise GitHubError(f"No GitHub token. Set SYNTHRAD_GITHUB_TOKEN or run "
|
|
35
|
+
f"`gh auth login --hostname {GITHUB_HOST}`.")
|
|
36
|
+
return prompt_token(console)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def models_table(repo=None):
|
|
40
|
+
table = Table(title="Registered models (44-patient AB test means)", title_justify="left")
|
|
41
|
+
for column in ("Key", "Model", "MAE (HU)", "PSNR (dB)", "MS-SSIM", "Evidence"):
|
|
42
|
+
table.add_column(column, justify="right" if column[0] in "MP" and column != "Model" else "left")
|
|
43
|
+
if repo:
|
|
44
|
+
table.add_column("Installed")
|
|
45
|
+
for m in MODELS.values():
|
|
46
|
+
row = [m.key, m.summary, f"{m.mae_hu:.2f}", f"{m.psnr_db:.2f}", f"{m.ms_ssim:.3f}", m.evidence]
|
|
47
|
+
if repo:
|
|
48
|
+
row.append("[green]yes[/]" if (repo / m.checkpoint).is_file() else "[dim]no[/]")
|
|
49
|
+
table.add_row(*row)
|
|
50
|
+
return table
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def choose(args):
|
|
54
|
+
"""Fill in models, device and previewer from flags, prompting for anything not given."""
|
|
55
|
+
import questionary
|
|
56
|
+
cuda = env.has_cuda()
|
|
57
|
+
if args.models is None:
|
|
58
|
+
if interactive(args):
|
|
59
|
+
console.print(models_table())
|
|
60
|
+
args.models = questionary.checkbox(
|
|
61
|
+
"Which models do you want?",
|
|
62
|
+
choices=[questionary.Choice(f"{m.key} {m.summary}", m.key, checked=m.key in DEFAULT_MODELS)
|
|
63
|
+
for m in MODELS.values()],
|
|
64
|
+
validate=lambda picked: bool(picked) or "Pick at least one model.").unsafe_ask()
|
|
65
|
+
else:
|
|
66
|
+
args.models = list(DEFAULT_MODELS)
|
|
67
|
+
if args.device is None:
|
|
68
|
+
detected = "cuda" if cuda else "cpu"
|
|
69
|
+
if interactive(args) and cuda:
|
|
70
|
+
args.device = questionary.select("Run inference on:", choices=["cuda", "cpu"],
|
|
71
|
+
default=detected).unsafe_ask()
|
|
72
|
+
else:
|
|
73
|
+
args.device = detected
|
|
74
|
+
if args.device == "cuda" and not cuda:
|
|
75
|
+
console.print("[yellow]No NVIDIA GPU detected; installing CUDA wheels anyway as requested.[/]")
|
|
76
|
+
has_arm = any(MODELS[k].previewer for k in args.models)
|
|
77
|
+
if args.previewer is None:
|
|
78
|
+
args.previewer = has_arm and interactive(args) and questionary.confirm(
|
|
79
|
+
"Also set up the web previewer (Gradio demo, B2/B3 only)?", default=True).unsafe_ask()
|
|
80
|
+
elif args.previewer and not has_arm:
|
|
81
|
+
console.print("[yellow]The previewer only runs B2 or B3; skipping it.[/]")
|
|
82
|
+
args.previewer = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def download(repo, keys, token, tag):
|
|
86
|
+
pending = [MODELS[k] for k in keys if not verified(repo / MODELS[k].checkpoint, MODELS[k].sha256)]
|
|
87
|
+
for k in set(keys) - {m.key for m in pending}:
|
|
88
|
+
console.print(f"[green]✓[/] {k} already downloaded and verified")
|
|
89
|
+
if not pending:
|
|
90
|
+
return
|
|
91
|
+
client = Client(token)
|
|
92
|
+
assets = client.release_assets(tag)
|
|
93
|
+
missing = [m.asset_name for m in pending if m.asset_name not in assets]
|
|
94
|
+
if missing:
|
|
95
|
+
raise GitHubError(f"Release {tag} is missing {', '.join(missing)}. Ask the team to run "
|
|
96
|
+
"`synthrad-sct publish` for those models.")
|
|
97
|
+
columns = (TextColumn("{task.description}"), BarColumn(), DownloadColumn(),
|
|
98
|
+
TransferSpeedColumn(), TimeRemainingColumn())
|
|
99
|
+
with Progress(*columns, console=console) as progress:
|
|
100
|
+
for m in pending:
|
|
101
|
+
asset = assets[m.asset_name]
|
|
102
|
+
task = progress.add_task(m.key, total=int(asset["size"]))
|
|
103
|
+
def advance(n, absolute=False, task=task):
|
|
104
|
+
progress.update(task, completed=n) if absolute else progress.advance(task, n)
|
|
105
|
+
client.download(asset, repo / m.checkpoint, m.sha256, advance)
|
|
106
|
+
progress.update(task, description=f"[green]✓[/] {m.key}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def verified(path, sha256):
|
|
110
|
+
return path.is_file() and sha256_file(path) == sha256
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def step(text):
|
|
114
|
+
console.rule(f"[bold]{text}", align="left")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_init(args):
|
|
118
|
+
console.print(Panel.fit(f"[bold]synthrad-sct[/] {__version__}\nAbdomen MRI → synthetic CT, "
|
|
119
|
+
"COMP3888 W11-04 P40", border_style="cyan"))
|
|
120
|
+
token = get_token(args)
|
|
121
|
+
repo = env.find_repo(args.dir)
|
|
122
|
+
if repo:
|
|
123
|
+
console.print(f"Using the repository at [bold]{repo}[/]")
|
|
124
|
+
else:
|
|
125
|
+
target = Path(args.dir or "COMP3888_W11_04_P40").resolve()
|
|
126
|
+
if interactive(args) and not args.dir:
|
|
127
|
+
import questionary
|
|
128
|
+
target = Path(questionary.path("Clone the repository into:", default=str(target),
|
|
129
|
+
only_directories=True).unsafe_ask()).expanduser().resolve()
|
|
130
|
+
step(f"Cloning into {target}")
|
|
131
|
+
repo = env.clone(target, git_auth_env(token))
|
|
132
|
+
choose(args)
|
|
133
|
+
console.print(f"\nModels: [bold]{' '.join(args.models)}[/] Device: [bold]{args.device}[/] "
|
|
134
|
+
f"Previewer: [bold]{'yes' if args.previewer else 'no'}[/]")
|
|
135
|
+
if interactive(args):
|
|
136
|
+
import questionary
|
|
137
|
+
if not questionary.confirm("Continue?", default=True).unsafe_ask():
|
|
138
|
+
return 1
|
|
139
|
+
python = None
|
|
140
|
+
if not args.skip_env:
|
|
141
|
+
step(f"Python {env.PYTHON_VERSION} environment in .venv")
|
|
142
|
+
python = env.create_venv(repo)
|
|
143
|
+
step(f"Installing dependencies ({args.device})")
|
|
144
|
+
env.install_dependencies(repo, python, args.device, args.previewer)
|
|
145
|
+
step(f"Downloading weights from {GITHUB_HOST}/{WEIGHTS_REPO} release {args.tag}")
|
|
146
|
+
download(repo, args.models, token, args.tag)
|
|
147
|
+
config = env.write_local_config(repo, args.models, args.device)
|
|
148
|
+
console.print(f"[green]✓[/] Wrote {config.relative_to(repo)}")
|
|
149
|
+
if python and not args.skip_smoke:
|
|
150
|
+
step("Smoke test: loading each checkpoint and predicting one patch")
|
|
151
|
+
env.smoke_test(repo, python, args.models, args.device)
|
|
152
|
+
next_steps(repo, args.models, args.previewer, python)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def next_steps(repo, keys, previewer, python):
|
|
157
|
+
py = os.path.relpath(python, repo) if python else "python"
|
|
158
|
+
activate = (r".venv\Scripts\activate" if os.name == "nt" else "source .venv/bin/activate")
|
|
159
|
+
lines = [f"cd {repo}", f"{activate}", "",
|
|
160
|
+
"# One MRI volume → sCT",
|
|
161
|
+
f"python ct_free_inference/generate.py --model {keys[0]} --mri path/to/mr.mha "
|
|
162
|
+
f"--config {env.LOCAL_CONFIG.as_posix()} --output-dir outputs/{keys[0]}"]
|
|
163
|
+
if previewer:
|
|
164
|
+
arm = next(k for k in keys if MODELS[k].previewer)
|
|
165
|
+
lines += ["", "# Web previewer on http://localhost:7860",
|
|
166
|
+
f"cd previewer && python app.py --arm {arm} --checkpoint ../{MODELS[arm].checkpoint}"]
|
|
167
|
+
lines += ["", "# Full docs: ct_free_inference/README.md"]
|
|
168
|
+
console.print(Panel("\n".join(lines), title="[green]Ready[/]", border_style="green", expand=False))
|
|
169
|
+
if not python:
|
|
170
|
+
console.print(f"[dim]Environment setup was skipped; use your own Python {env.PYTHON_VERSION} "
|
|
171
|
+
f"environment instead of {py}.[/]")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_download(args):
|
|
175
|
+
repo = env.find_repo(args.dir)
|
|
176
|
+
if not repo:
|
|
177
|
+
raise env.SetupError("Run this inside the repository, or pass --dir.")
|
|
178
|
+
keys = args.models or list(DEFAULT_MODELS)
|
|
179
|
+
download(repo, keys, get_token(args), args.tag)
|
|
180
|
+
env.write_local_config(repo, keys, args.device or ("cuda" if env.has_cuda() else "cpu"))
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_list(args):
|
|
185
|
+
console.print(models_table(env.find_repo(args.dir)))
|
|
186
|
+
console.print("[dim]B2/B3/B0 scores come from the earlier paired workflow; B6 from the MRI-only "
|
|
187
|
+
"pipeline. See AB_EXPERIMENTS_README.md.[/]")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_doctor(args):
|
|
192
|
+
repo = env.find_repo(args.dir)
|
|
193
|
+
checks = [("git", bool(shutil.which("git")), shutil.which("git") or "install from git-scm.com"),
|
|
194
|
+
("uv", bool(shutil.which("uv")), shutil.which("uv") or "optional, recommended"),
|
|
195
|
+
("GitHub token", bool(find_token()[0]), find_token()[1] or "none found"),
|
|
196
|
+
("NVIDIA GPU", env.has_cuda(), "CUDA available" if env.has_cuda() else "CPU only"),
|
|
197
|
+
("repository", bool(repo), str(repo) if repo else "not inside a clone")]
|
|
198
|
+
if repo:
|
|
199
|
+
python = env.venv_python(repo / ".venv")
|
|
200
|
+
checks.append((".venv", python.exists(), str(python)))
|
|
201
|
+
for m in MODELS.values():
|
|
202
|
+
path = repo / m.checkpoint
|
|
203
|
+
state = ("verified" if verified(path, m.sha256) else
|
|
204
|
+
"HASH MISMATCH" if path.is_file() else "not downloaded")
|
|
205
|
+
checks.append((f"weights {m.key}", state == "verified", state))
|
|
206
|
+
table = Table(show_header=False)
|
|
207
|
+
for name, ok, detail in checks:
|
|
208
|
+
table.add_row("[green]✓[/]" if ok else "[yellow]•[/]", name, detail)
|
|
209
|
+
console.print(table)
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_publish(args):
|
|
214
|
+
"""For the team: upload the epoch-400 checkpoints as release assets."""
|
|
215
|
+
files = {}
|
|
216
|
+
for item in args.checkpoints:
|
|
217
|
+
key, _, path = item.partition("=")
|
|
218
|
+
if key not in MODELS or not path:
|
|
219
|
+
raise env.SetupError(f"Expected KEY=PATH with KEY in {', '.join(MODELS)}; got {item!r}.")
|
|
220
|
+
path = Path(path).expanduser().resolve()
|
|
221
|
+
actual = sha256_file(path)
|
|
222
|
+
if actual != MODELS[key].sha256:
|
|
223
|
+
raise env.SetupError(f"{path} is not the evaluated {key} checkpoint "
|
|
224
|
+
f"(sha256 {actual[:12]}…, expected {MODELS[key].sha256[:12]}…).")
|
|
225
|
+
console.print(f"[green]✓[/] {key}: {path} matches the recorded hash")
|
|
226
|
+
files[key] = path
|
|
227
|
+
if args.dry_run:
|
|
228
|
+
return 0
|
|
229
|
+
if not shutil.which("gh"):
|
|
230
|
+
raise env.SetupError("Publishing needs the GitHub CLI: https://cli.github.com")
|
|
231
|
+
repo = f"{GITHUB_HOST}/{WEIGHTS_REPO}"
|
|
232
|
+
# Commit the text files first: an empty repository has no commit to tag a release on.
|
|
233
|
+
code = env.find_repo()
|
|
234
|
+
commit = code and subprocess.run(["git", "rev-parse", "HEAD"], cwd=code,
|
|
235
|
+
capture_output=True, text=True).stdout.strip()
|
|
236
|
+
for name, text in (("README.md", weights_repo.readme(args.tag, commit)),
|
|
237
|
+
("SHA256SUMS", weights_repo.sha256sums())):
|
|
238
|
+
try:
|
|
239
|
+
changed = weights_repo.push_file(name, text, f"Update {name} for {args.tag}")
|
|
240
|
+
except RuntimeError as error:
|
|
241
|
+
raise env.SetupError(str(error)) from None
|
|
242
|
+
console.print(f"[green]✓[/] {name} {'updated' if changed else 'already current'}")
|
|
243
|
+
if subprocess.run(["gh", "release", "view", args.tag, "-R", repo], capture_output=True).returncode:
|
|
244
|
+
env.run(["gh", "release", "create", args.tag, "-R", repo, "--title", "Model weights (epoch 400)",
|
|
245
|
+
"--notes", "Epoch-400 generator checkpoints for B0/B2/B3/B6. See README.md "
|
|
246
|
+
"for scores, hashes and the one-line installer."])
|
|
247
|
+
with tempfile.TemporaryDirectory() as staging:
|
|
248
|
+
# Assets are named after the file, and every checkpoint is called epoch_0400.pt.
|
|
249
|
+
named = []
|
|
250
|
+
for key, path in files.items():
|
|
251
|
+
link = Path(staging, MODELS[key].asset_name)
|
|
252
|
+
try:
|
|
253
|
+
link.symlink_to(path)
|
|
254
|
+
except OSError:
|
|
255
|
+
shutil.copy2(path, link)
|
|
256
|
+
named.append(str(link))
|
|
257
|
+
env.run(["gh", "release", "upload", args.tag, "-R", repo, "--clobber", *named])
|
|
258
|
+
console.print(f"[green]✓[/] Uploaded {', '.join(files)} to {repo} release {args.tag}")
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parser():
|
|
263
|
+
p = argparse.ArgumentParser(prog="synthrad-sct", description=__doc__)
|
|
264
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
265
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
266
|
+
|
|
267
|
+
def common(sp, models=True):
|
|
268
|
+
sp.add_argument("--dir", help="Repository directory (or clone target for init).")
|
|
269
|
+
sp.add_argument("--tag", default=WEIGHTS_TAG, help="Weights release tag.")
|
|
270
|
+
sp.add_argument("-y", "--yes", action="store_true", help="Accept defaults; never prompt.")
|
|
271
|
+
if models:
|
|
272
|
+
sp.add_argument("--models", nargs="+", choices=list(MODELS))
|
|
273
|
+
sp.add_argument("--device", choices=["cpu", "cuda"])
|
|
274
|
+
|
|
275
|
+
init = sub.add_parser("init", help="Clone, set up Python, download weights and smoke-test.")
|
|
276
|
+
common(init)
|
|
277
|
+
init.add_argument("--previewer", action=argparse.BooleanOptionalAction, default=None)
|
|
278
|
+
init.add_argument("--skip-env", action="store_true", help="Use your own environment.")
|
|
279
|
+
init.add_argument("--skip-smoke", action="store_true")
|
|
280
|
+
init.set_defaults(func=cmd_init)
|
|
281
|
+
dl = sub.add_parser("download", help="Download and verify weights only.")
|
|
282
|
+
common(dl)
|
|
283
|
+
dl.set_defaults(func=cmd_download)
|
|
284
|
+
ls = sub.add_parser("list", help="Show the models and their test results.")
|
|
285
|
+
ls.add_argument("--dir")
|
|
286
|
+
ls.set_defaults(func=cmd_list)
|
|
287
|
+
doc = sub.add_parser("doctor", help="Check tools, token, environment and weights.")
|
|
288
|
+
doc.add_argument("--dir")
|
|
289
|
+
doc.set_defaults(func=cmd_doctor)
|
|
290
|
+
pub = sub.add_parser("publish", help="(Team) upload checkpoints to the weights release.")
|
|
291
|
+
pub.add_argument("checkpoints", nargs="+", metavar="KEY=PATH")
|
|
292
|
+
pub.add_argument("--tag", default=WEIGHTS_TAG)
|
|
293
|
+
pub.add_argument("--dry-run", action="store_true", help="Only verify the hashes.")
|
|
294
|
+
pub.set_defaults(func=cmd_publish)
|
|
295
|
+
return p
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main(argv=None):
|
|
299
|
+
args = parser().parse_args(argv)
|
|
300
|
+
try:
|
|
301
|
+
return args.func(args)
|
|
302
|
+
except (GitHubError, env.SetupError) as error:
|
|
303
|
+
console.print(f"[red]✗[/] {error}")
|
|
304
|
+
return 1
|
|
305
|
+
except KeyboardInterrupt:
|
|
306
|
+
console.print("\n[yellow]Cancelled. Run the same command again to resume.[/]")
|
|
307
|
+
return 130
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Repository, Python environment and inference configuration setup."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .models import GITHUB_HOST, GITHUB_REPO, MODELS
|
|
13
|
+
|
|
14
|
+
PYTHON_VERSION = "3.11" # What every experiment's environment.yml pins.
|
|
15
|
+
# Match the training lock files, so inference runs on the versions the weights were trained with.
|
|
16
|
+
TORCH = "torch==2.5.1"
|
|
17
|
+
PINS = ["numpy==1.26.4", "SimpleITK==2.5.2", "scipy==1.14.1"]
|
|
18
|
+
TORCH_INDEX = {"cpu": "https://download.pytorch.org/whl/cpu",
|
|
19
|
+
"cuda": "https://download.pytorch.org/whl/cu118"}
|
|
20
|
+
MARKER = Path("ct_free_inference/model_registry.py")
|
|
21
|
+
LOCAL_CONFIG = Path("ct_free_inference/configs/inference.local.yaml")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SetupError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_repo(start=None):
|
|
29
|
+
start = Path(start or Path.cwd()).resolve()
|
|
30
|
+
for directory in (start, *start.parents):
|
|
31
|
+
if (directory / MARKER).is_file():
|
|
32
|
+
return directory
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clone(target, env):
|
|
37
|
+
if not shutil.which("git"):
|
|
38
|
+
raise SetupError("git is not installed. Install it from https://git-scm.com and retry.")
|
|
39
|
+
url = f"https://{GITHUB_HOST}/{GITHUB_REPO}.git"
|
|
40
|
+
run(["git", "clone", url, str(target)], env=env)
|
|
41
|
+
if not (target / MARKER).is_file():
|
|
42
|
+
raise SetupError(f"Cloned {url}, but {MARKER} is missing; is this the right repository?")
|
|
43
|
+
return target.resolve()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def has_cuda():
|
|
47
|
+
if platform.system() == "Darwin" or not shutil.which("nvidia-smi"):
|
|
48
|
+
return False
|
|
49
|
+
return subprocess.run(["nvidia-smi", "-L"], capture_output=True).returncode == 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def venv_python(venv):
|
|
53
|
+
return venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_venv(repo):
|
|
57
|
+
venv = repo / ".venv"
|
|
58
|
+
python = venv_python(venv)
|
|
59
|
+
if python.exists():
|
|
60
|
+
version = subprocess.run([str(python), "-c", "import sys;print('%d.%d'%sys.version_info[:2])"],
|
|
61
|
+
capture_output=True, text=True).stdout.strip()
|
|
62
|
+
if version == PYTHON_VERSION:
|
|
63
|
+
return python
|
|
64
|
+
raise SetupError(f"{venv} uses Python {version}, not {PYTHON_VERSION}. Remove it and run again.")
|
|
65
|
+
if shutil.which("uv"):
|
|
66
|
+
# uv downloads Python 3.11 itself when the machine does not have it.
|
|
67
|
+
run(["uv", "venv", "--python", PYTHON_VERSION, str(venv)])
|
|
68
|
+
else:
|
|
69
|
+
base = find_python311()
|
|
70
|
+
run([*base, "-m", "venv", str(venv)])
|
|
71
|
+
run([str(python), "-m", "pip", "install", "--upgrade", "pip"])
|
|
72
|
+
return python
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def find_python311():
|
|
76
|
+
if sys.version_info[:2] == (3, 11):
|
|
77
|
+
return [sys.executable]
|
|
78
|
+
for candidate in (["py", "-3.11"], ["python3.11"]):
|
|
79
|
+
if shutil.which(candidate[0]) and subprocess.run(
|
|
80
|
+
[*candidate, "--version"], capture_output=True).returncode == 0:
|
|
81
|
+
return candidate
|
|
82
|
+
raise SetupError("Python 3.11 was not found. Install uv (https://docs.astral.sh/uv/) "
|
|
83
|
+
"and run this through `uvx`, which provides Python automatically.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def install_dependencies(repo, python, device, previewer):
|
|
87
|
+
pip = (["uv", "pip", "install", "--python", str(python)] if shutil.which("uv")
|
|
88
|
+
else [str(python), "-m", "pip", "install"])
|
|
89
|
+
# macOS has only CPU/MPS wheels on PyPI; elsewhere pick the matching PyTorch index.
|
|
90
|
+
index = [] if platform.system() == "Darwin" else ["--index-url", TORCH_INDEX[device]]
|
|
91
|
+
run([*pip, TORCH, *index])
|
|
92
|
+
run([*pip, "-r", str(repo / "ct_free_inference/requirements.txt"), *PINS])
|
|
93
|
+
if previewer:
|
|
94
|
+
run([*pip, "-r", str(repo / "previewer/requirements.txt"), *PINS])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_local_config(repo, keys, device):
|
|
98
|
+
"""Copy the committed config, set the device and point each model at its downloaded weights."""
|
|
99
|
+
base = repo / "ct_free_inference/configs/inference.yaml"
|
|
100
|
+
config = yaml.safe_load(base.read_text(encoding="utf-8"))
|
|
101
|
+
config["device"] = device
|
|
102
|
+
config["use_amp"] = device == "cuda"
|
|
103
|
+
for key in keys:
|
|
104
|
+
# Relative to project_root, which read_config resolves from this file's directory.
|
|
105
|
+
config["models"][key]["checkpoint"] = str(MODELS[key].checkpoint)
|
|
106
|
+
path = repo / LOCAL_CONFIG
|
|
107
|
+
path.write_text("# Written by synthrad-sct. Ignored by Git; safe to edit.\n"
|
|
108
|
+
+ yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def smoke_test(repo, python, keys, device):
|
|
113
|
+
run([str(python), "ct_free_inference/smoke_models.py", "--device", device,
|
|
114
|
+
"--config", str(LOCAL_CONFIG), "--models", *keys,
|
|
115
|
+
"--output", "ct_free_inference/checks/model_smoke.json"], cwd=repo)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run(command, cwd=None, env=None):
|
|
119
|
+
result = subprocess.run(command, cwd=cwd, env=env)
|
|
120
|
+
if result.returncode:
|
|
121
|
+
shown = " ".join(c for c in command if "AUTHORIZATION" not in c)
|
|
122
|
+
raise SetupError(f"Command failed ({result.returncode}): {shown}")
|
synthrad_sct/github.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Release-asset downloads from the university GitHub Enterprise instance.
|
|
2
|
+
|
|
3
|
+
Every API call there needs a token, including reads of releases, so the token
|
|
4
|
+
is looked up once and reused for cloning and downloading.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import base64
|
|
8
|
+
import getpass
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .models import GITHUB_HOST, WEIGHTS_REPO
|
|
21
|
+
|
|
22
|
+
TOKEN_ENV = ("SYNTHRAD_GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN")
|
|
23
|
+
TOKEN_URL = f"https://{GITHUB_HOST}/settings/tokens/new?scopes=repo&description=synthrad-sct"
|
|
24
|
+
API = f"https://{GITHUB_HOST}/api/v3"
|
|
25
|
+
CHUNK = 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GitHubError(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_token(environ=os.environ, run=subprocess.run):
|
|
33
|
+
for name in TOKEN_ENV:
|
|
34
|
+
if environ.get(name):
|
|
35
|
+
return environ[name].strip(), name
|
|
36
|
+
if shutil.which("gh"):
|
|
37
|
+
result = run(["gh", "auth", "token", "--hostname", GITHUB_HOST],
|
|
38
|
+
capture_output=True, text=True)
|
|
39
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
40
|
+
return result.stdout.strip(), "gh CLI"
|
|
41
|
+
return None, None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def prompt_token(console):
|
|
45
|
+
console.print(f"\nThe weights live on [bold]{GITHUB_HOST}[/], which needs a personal access token.")
|
|
46
|
+
console.print(f"Create one with the [bold]repo[/] scope here:\n [link={TOKEN_URL}]{TOKEN_URL}[/link]")
|
|
47
|
+
console.print(f"[dim]Tip: set {TOKEN_ENV[0]} or run `gh auth login --hostname {GITHUB_HOST}` to skip this prompt.[/]\n")
|
|
48
|
+
token = getpass.getpass("Paste token (hidden): ").strip()
|
|
49
|
+
if not token:
|
|
50
|
+
raise GitHubError("No token given.")
|
|
51
|
+
return token
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def git_auth_env(token):
|
|
55
|
+
"""Pass the token to git through the environment, not argv or the saved config."""
|
|
56
|
+
basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
|
57
|
+
env = dict(os.environ)
|
|
58
|
+
count = int(env.get("GIT_CONFIG_COUNT", "0"))
|
|
59
|
+
env.update({"GIT_CONFIG_COUNT": str(count + 1),
|
|
60
|
+
f"GIT_CONFIG_KEY_{count}": f"http.https://{GITHUB_HOST}/.extraheader",
|
|
61
|
+
f"GIT_CONFIG_VALUE_{count}": f"AUTHORIZATION: basic {basic}"})
|
|
62
|
+
return env
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
|
66
|
+
# Asset downloads redirect to signed storage URLs, which reject a second credential.
|
|
67
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
68
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
69
|
+
if new is not None and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc:
|
|
70
|
+
new.remove_header("Authorization")
|
|
71
|
+
return new
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_opener = urllib.request.build_opener(_StripAuthOnRedirect)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Client:
|
|
78
|
+
def __init__(self, token, repo=WEIGHTS_REPO, opener=_opener):
|
|
79
|
+
self.token, self.repo, self.opener = token, repo, opener
|
|
80
|
+
|
|
81
|
+
def _request(self, url, accept="application/vnd.github+json", headers=None):
|
|
82
|
+
request = urllib.request.Request(url, headers={
|
|
83
|
+
"Authorization": f"token {self.token}", "Accept": accept,
|
|
84
|
+
"User-Agent": "synthrad-sct", **(headers or {})})
|
|
85
|
+
try:
|
|
86
|
+
return self.opener.open(request, timeout=60)
|
|
87
|
+
except urllib.error.HTTPError as error:
|
|
88
|
+
if error.code == 401:
|
|
89
|
+
raise GitHubError("The token was rejected (401). Check it has not expired.") from None
|
|
90
|
+
if error.code == 404:
|
|
91
|
+
raise GitHubError(f"Not found: {url}. The token may lack access to {self.repo}, "
|
|
92
|
+
"or the weights release has not been published yet.") from None
|
|
93
|
+
raise GitHubError(f"GitHub returned {error.code} for {url}.") from None
|
|
94
|
+
except urllib.error.URLError as error:
|
|
95
|
+
raise GitHubError(f"Could not reach {GITHUB_HOST}: {error.reason}. "
|
|
96
|
+
"Are you on the university network or VPN?") from None
|
|
97
|
+
|
|
98
|
+
def release_assets(self, tag):
|
|
99
|
+
with self._request(f"{API}/repos/{self.repo}/releases/tags/{tag}") as response:
|
|
100
|
+
release = json.load(response)
|
|
101
|
+
return {a["name"]: a for a in release.get("assets", [])}
|
|
102
|
+
|
|
103
|
+
def download(self, asset, destination, sha256, progress=None, attempts=4):
|
|
104
|
+
"""Resumable download to destination.part, renamed only after the hash matches."""
|
|
105
|
+
destination = Path(destination)
|
|
106
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
partial = destination.with_name(destination.name + ".part")
|
|
108
|
+
size = int(asset["size"])
|
|
109
|
+
for attempt in range(1, attempts + 1):
|
|
110
|
+
offset = partial.stat().st_size if partial.exists() else 0
|
|
111
|
+
if offset > size:
|
|
112
|
+
partial.unlink()
|
|
113
|
+
offset = 0
|
|
114
|
+
try:
|
|
115
|
+
if offset < size:
|
|
116
|
+
self._fetch(asset["url"], partial, offset, progress)
|
|
117
|
+
# A dropped connection can end the body early without an error.
|
|
118
|
+
if partial.stat().st_size < size:
|
|
119
|
+
raise OSError("connection closed before the download finished")
|
|
120
|
+
break
|
|
121
|
+
except (GitHubError, OSError) as error:
|
|
122
|
+
if attempt == attempts:
|
|
123
|
+
raise GitHubError(f"Download of {asset['name']} failed: {error}") from None
|
|
124
|
+
time.sleep(2 * attempt)
|
|
125
|
+
actual = sha256_file(partial)
|
|
126
|
+
if actual != sha256:
|
|
127
|
+
partial.unlink()
|
|
128
|
+
raise GitHubError(f"{asset['name']} hash mismatch: expected {sha256}, got {actual}. "
|
|
129
|
+
"The partial file was removed; run the command again.")
|
|
130
|
+
partial.replace(destination)
|
|
131
|
+
|
|
132
|
+
def _fetch(self, url, partial, offset, progress):
|
|
133
|
+
headers = {"Range": f"bytes={offset}-"} if offset else {}
|
|
134
|
+
with self._request(url, "application/octet-stream", headers) as response:
|
|
135
|
+
if offset and response.status != 206:
|
|
136
|
+
offset = 0 # Server ignored the range; start over.
|
|
137
|
+
if progress:
|
|
138
|
+
progress(offset, absolute=True)
|
|
139
|
+
with partial.open("ab" if offset else "wb") as f:
|
|
140
|
+
for chunk in iter(lambda: response.read(CHUNK), b""):
|
|
141
|
+
f.write(chunk)
|
|
142
|
+
if progress:
|
|
143
|
+
progress(len(chunk))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def sha256_file(path):
|
|
147
|
+
h = hashlib.sha256()
|
|
148
|
+
with Path(path).open("rb") as f:
|
|
149
|
+
for chunk in iter(lambda: f.read(CHUNK), b""):
|
|
150
|
+
h.update(chunk)
|
|
151
|
+
return h.hexdigest()
|
synthrad_sct/models.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
|
|
6
|
+
GITHUB_HOST = "github.sydney.edu.au"
|
|
7
|
+
GITHUB_REPO = "fyan0582/COMP3888_W11_04_P40"
|
|
8
|
+
# Weights are release assets in their own repository, so they can outlive the course repo.
|
|
9
|
+
WEIGHTS_REPO = os.environ.get("SYNTHRAD_WEIGHTS_REPO", "dmat0262/synthrad-sct-weights")
|
|
10
|
+
WEIGHTS_TAG = "weights-v1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Model:
|
|
15
|
+
key: str
|
|
16
|
+
folder: str
|
|
17
|
+
run: str
|
|
18
|
+
summary: str
|
|
19
|
+
sha256: str
|
|
20
|
+
mae_hu: float
|
|
21
|
+
psnr_db: float
|
|
22
|
+
ms_ssim: float
|
|
23
|
+
evidence: str
|
|
24
|
+
previewer: bool = False
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def asset_name(self):
|
|
28
|
+
return f"{self.key}_epoch_0400.pt"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def checkpoint(self):
|
|
32
|
+
# The default location model_registry.checkpoint_for already searches.
|
|
33
|
+
return PurePosixPath(self.folder, "checkpoints_AB_400epochs", self.run, "epoch_0400.pt")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Hashes are the epoch-400 checkpoints recorded in each experiment's results/ or metadata/.
|
|
37
|
+
# Metrics are 44-patient AB test means, quoted from the same files.
|
|
38
|
+
MODELS = {m.key: m for m in [
|
|
39
|
+
Model("B0", "2D-U-Net-PatchGAN", "B0_2D_UNet_PatchGAN_AB",
|
|
40
|
+
"2D U-Net + PatchGAN, centre slice, 256 crop",
|
|
41
|
+
"547feffff2a3f6b7c8c708eafb0068158df64e036d4735e6dd3a2ff42f567df6",
|
|
42
|
+
114.20, 24.91, 0.767, "historical paired workflow"),
|
|
43
|
+
Model("B2", "5-slices-2.5dU-Net-PatchGAN", "B2_5Slice_2.5D_UNet_PatchGAN_AB",
|
|
44
|
+
"2.5D U-Net + PatchGAN, five slices, 256 crop",
|
|
45
|
+
"0f742a3ac56db2bc030d0c1a9b4c975b42e5c6745e2f36e9225cc48e3556ecd3",
|
|
46
|
+
113.03, 24.97, 0.768, "historical paired workflow", previewer=True),
|
|
47
|
+
Model("B3", "5-slices-2.5dU-Net-PatchGAN-512Crop", "B3_5Slice_2.5D_UNet_PatchGAN_512Crop_AB",
|
|
48
|
+
"2.5D U-Net + PatchGAN, five slices, 512 crop",
|
|
49
|
+
"942529899f9e49be9bbc4e85389edb4a8fa2271ff93e4332535187a9fbb77935",
|
|
50
|
+
118.00, 24.68, 0.753, "historical paired workflow", previewer=True),
|
|
51
|
+
Model("B6", "5-slices-2.5dU-Net-PatchGAN-GAN001", "B6_5Slice_2.5D_UNet_PatchGAN_GAN001_AB",
|
|
52
|
+
"2.5D U-Net + PatchGAN, five slices, GAN weight 0.01",
|
|
53
|
+
"a2f5883b53a006726a515afabda9d58f5d05e47bb1281474c8783f2c70d81c13",
|
|
54
|
+
108.80, 25.04, 0.776, "MRI-only pipeline"),
|
|
55
|
+
]}
|
|
56
|
+
DEFAULT_MODELS = ("B6", "B2")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Text files committed to the weights repository, generated from the pinned model table."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from .models import GITHUB_HOST, GITHUB_REPO, MODELS, WEIGHTS_REPO
|
|
9
|
+
|
|
10
|
+
INSTALL = "uvx synthrad-sct init"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sha256sums():
|
|
14
|
+
return "".join(f"{m.sha256} {m.asset_name}\n" for m in MODELS.values())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def readme(tag, commit=None):
|
|
18
|
+
rows = "\n".join(f"| {m.key} | {m.summary} | {m.mae_hu:.2f} | {m.psnr_db:.2f} | "
|
|
19
|
+
f"{m.ms_ssim:.3f} | {m.evidence} |" for m in MODELS.values())
|
|
20
|
+
source = f"[`{commit[:12]}`](https://{GITHUB_HOST}/{GITHUB_REPO}/tree/{commit})" if commit else "main"
|
|
21
|
+
return f"""# synthrad-sct weights
|
|
22
|
+
|
|
23
|
+
Trained generator checkpoints for the abdomen MRI-to-synthetic-CT models in
|
|
24
|
+
[{GITHUB_REPO}](https://{GITHUB_HOST}/{GITHUB_REPO}) (COMP3888 W11-04 P40).
|
|
25
|
+
This repository holds no code. The checkpoints are files on the
|
|
26
|
+
[`{tag}` release](https://{GITHUB_HOST}/{WEIGHTS_REPO}/releases/tag/{tag}), not commits.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
Log in once, then let the installer clone the code, set up Python and fetch these weights:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gh auth login --hostname {GITHUB_HOST}
|
|
34
|
+
{INSTALL}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Models
|
|
38
|
+
|
|
39
|
+
All are epoch-400 generators trained on SynthRAD2025 Task 1 AB (abdomen) with the
|
|
40
|
+
frozen 114 / 17 / 44 patient split. Scores are 44-patient test means.
|
|
41
|
+
|
|
42
|
+
| Key | Model | MAE (HU) | PSNR (dB) | MS-SSIM | Evidence |
|
|
43
|
+
| --- | --- | ---: | ---: | ---: | --- |
|
|
44
|
+
{rows}
|
|
45
|
+
|
|
46
|
+
B0, B2 and B3 were scored by the earlier paired workflow; B6 by the MRI-only
|
|
47
|
+
pipeline. See `AB_EXPERIMENTS_README.md` in the code repository.
|
|
48
|
+
|
|
49
|
+
## Integrity
|
|
50
|
+
|
|
51
|
+
`SHA256SUMS` lists each file's hash, the same hashes the installer checks and the
|
|
52
|
+
code repository records with each model's results. After a manual download:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
sha256sum -c SHA256SUMS --ignore-missing
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Weights match code at {source}. A retrained model is published under a new tag;
|
|
59
|
+
files on an existing tag are never replaced.
|
|
60
|
+
|
|
61
|
+
## Limitations
|
|
62
|
+
|
|
63
|
+
Abdomen only, for MR protocols close to SynthRAD's centres. Research use, not a
|
|
64
|
+
clinical tool. Check the SynthRAD2025 data licence before redistributing.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def git_blob_sha(text):
|
|
69
|
+
data = text.encode()
|
|
70
|
+
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def push_file(path, text, message, run=subprocess.run):
|
|
74
|
+
"""Create or update one file through the contents API; returns False if it was already current."""
|
|
75
|
+
endpoint = f"repos/{WEIGHTS_REPO}/contents/{path}"
|
|
76
|
+
current = run(["gh", "api", "--hostname", GITHUB_HOST, endpoint], capture_output=True, text=True)
|
|
77
|
+
sha = json.loads(current.stdout)["sha"] if current.returncode == 0 else None
|
|
78
|
+
if sha == git_blob_sha(text):
|
|
79
|
+
return False
|
|
80
|
+
command = ["gh", "api", "--hostname", GITHUB_HOST, "-X", "PUT", endpoint, "-f", f"message={message}",
|
|
81
|
+
"-f", f"content={base64.b64encode(text.encode()).decode()}"]
|
|
82
|
+
result = run(command + (["-f", f"sha={sha}"] if sha else []), capture_output=True, text=True)
|
|
83
|
+
if result.returncode:
|
|
84
|
+
raise RuntimeError(f"Could not write {path} to {WEIGHTS_REPO}: {result.stderr.strip()}")
|
|
85
|
+
return True
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: synthrad-sct
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One-command installer for the COMP3888 P40 abdomen MRI-to-sCT models
|
|
5
|
+
Project-URL: Source, https://github.sydney.edu.au/fyan0582/COMP3888_W11_04_P40/tree/main/installer
|
|
6
|
+
Project-URL: Weights, https://github.sydney.edu.au/dmat0262/synthrad-sct-weights
|
|
7
|
+
Author: COMP3888 W11-04 P40
|
|
8
|
+
Keywords: installer,medical-imaging,mri,synthetic-ct,synthrad
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: pyyaml>=6
|
|
16
|
+
Requires-Dist: questionary>=2
|
|
17
|
+
Requires-Dist: rich>=13
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# synthrad-sct installer
|
|
21
|
+
|
|
22
|
+
One command that sets up the abdomen MRI-to-sCT models from this repository:
|
|
23
|
+
it clones the code, builds a Python 3.11 environment, downloads the trained
|
|
24
|
+
weights, checks them, and runs a smoke test. No retraining.
|
|
25
|
+
|
|
26
|
+
## For the client
|
|
27
|
+
|
|
28
|
+
You need [uv](https://docs.astral.sh/uv/getting-started/installation/),
|
|
29
|
+
git and the [GitHub CLI](https://cli.github.com), plus read access to this
|
|
30
|
+
repository and [dmat0262/synthrad-sct-weights](https://github.sydney.edu.au/dmat0262/synthrad-sct-weights)
|
|
31
|
+
on `github.sydney.edu.au` (on campus or VPN). You do not need to clone anything.
|
|
32
|
+
|
|
33
|
+
Log in once, so that uv, git and the installer reuse the same login and never ask for a token:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
gh auth login --hostname github.sydney.edu.au
|
|
37
|
+
uvx synthrad-sct init
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The installer asks a few questions:
|
|
41
|
+
|
|
42
|
+
1. **GitHub token**, only if you skipped `gh auth login`. Make one at
|
|
43
|
+
<https://github.sydney.edu.au/settings/tokens/new?scopes=repo>, or set
|
|
44
|
+
`SYNTHRAD_GITHUB_TOKEN`.
|
|
45
|
+
2. **Where to clone.** Skipped if you run it inside an existing clone.
|
|
46
|
+
3. **Which models.** B6 and B2 are pre-selected.
|
|
47
|
+
4. **CPU or CUDA.** Detected automatically. On macOS it is always CPU.
|
|
48
|
+
5. **Web previewer.** Optionally installs the Gradio demo, which supports B2 or B3.
|
|
49
|
+
|
|
50
|
+
Then it:
|
|
51
|
+
|
|
52
|
+
- creates `.venv` with Python 3.11, which uv downloads if needed;
|
|
53
|
+
- installs PyTorch 2.5.1 and the pinned NumPy/SimpleITK/SciPy versions the
|
|
54
|
+
weights were trained with;
|
|
55
|
+
- downloads each `epoch_0400.pt` and checks its SHA-256 against the hash
|
|
56
|
+
recorded with the test results;
|
|
57
|
+
- puts each file where the inference code already looks:
|
|
58
|
+
`<experiment>/checkpoints_AB_400epochs/<run>/epoch_0400.pt`;
|
|
59
|
+
- writes `ct_free_inference/configs/inference.local.yaml`;
|
|
60
|
+
- runs `ct_free_inference/smoke_models.py` on the downloaded weights.
|
|
61
|
+
|
|
62
|
+
If a download is interrupted, run the command again. It resumes and skips
|
|
63
|
+
anything already downloaded and checked.
|
|
64
|
+
|
|
65
|
+
| Model | What it is | Test MAE (HU) | Evidence |
|
|
66
|
+
| --- | --- | ---: | --- |
|
|
67
|
+
| B6 | 2.5D U-Net + PatchGAN, 5 slices, GAN weight 0.01 | 108.80 | MRI-only pipeline |
|
|
68
|
+
| B2 | 2.5D U-Net + PatchGAN, 5 slices, 256 crop | 113.03 | historical paired workflow |
|
|
69
|
+
| B0 | 2D U-Net + PatchGAN, centre slice | 114.20 | historical paired workflow |
|
|
70
|
+
| B3 | 2.5D U-Net + PatchGAN, 5 slices, 512 crop | 118.00 | historical paired workflow |
|
|
71
|
+
|
|
72
|
+
See [AB_EXPERIMENTS_README.md](https://github.sydney.edu.au/fyan0582/COMP3888_W11_04_P40/blob/main/AB_EXPERIMENTS_README.md) for what the
|
|
73
|
+
evidence columns mean.
|
|
74
|
+
|
|
75
|
+
### Other commands
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
synthrad-sct list # models, scores, which are installed
|
|
79
|
+
synthrad-sct doctor # check git/uv/token/GPU/.venv/weights
|
|
80
|
+
synthrad-sct download --models B3 B0 # add weights to an existing setup
|
|
81
|
+
synthrad-sct init -y --models B6 --device cpu --no-previewer # no prompts
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Prefix each with `uvx`, or install once with `uv tool install synthrad-sct`.
|
|
85
|
+
Without PyPI, the same CLI runs straight from Git:
|
|
86
|
+
`uvx --from "git+https://github.sydney.edu.au/fyan0582/COMP3888_W11_04_P40.git#subdirectory=installer" synthrad-sct init`.
|
|
87
|
+
|
|
88
|
+
## For the team: publishing the weights
|
|
89
|
+
|
|
90
|
+
The weights are files on the `weights-v1` release of
|
|
91
|
+
[dmat0262/synthrad-sct-weights](https://github.sydney.edu.au/dmat0262/synthrad-sct-weights),
|
|
92
|
+
kept apart from this course repository so they outlive it. They are never
|
|
93
|
+
committed to Git: GitHub rejects files over 100 MB. You need the [GitHub CLI](https://cli.github.com) logged in
|
|
94
|
+
with `gh auth login --hostname github.sydney.edu.au`.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cd installer
|
|
98
|
+
uv run synthrad-sct publish --dry-run B0=/path/B0/epoch_0400.pt B2=/path/B2/epoch_0400.pt \
|
|
99
|
+
B3=/path/B3/epoch_0400.pt B6=/path/B6/epoch_0400.pt
|
|
100
|
+
# then the same command without --dry-run
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`publish` first writes the weights repository's `README.md` (a model card)
|
|
104
|
+
and `SHA256SUMS` from the pinned table, then creates the release if needed and
|
|
105
|
+
uploads the files. It refuses any file whose SHA-256 differs from the checkpoint the
|
|
106
|
+
reported results came from. Hashes are pinned in
|
|
107
|
+
`src/synthrad_sct/models.py`, and the tests check them against `results/` and
|
|
108
|
+
`metadata/`. If a model is retrained, update the recorded results and the
|
|
109
|
+
pinned hash together, and publish under a new tag.
|
|
110
|
+
|
|
111
|
+
## Releasing to PyPI
|
|
112
|
+
|
|
113
|
+
Bump `version` in `pyproject.toml` and `__init__.py`, then:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
cd installer
|
|
117
|
+
rm -rf dist && uv build
|
|
118
|
+
UV_PUBLISH_TOKEN=pypi-… uv publish
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
If the package is ever removed from PyPI, switch the commands in this README and
|
|
122
|
+
`weights_repo.INSTALL` back to the Git form first.
|
|
123
|
+
|
|
124
|
+
## Tests
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
cd installer
|
|
128
|
+
uv run --with numpy --with SimpleITK python -m unittest discover -s tests -v
|
|
129
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
synthrad_sct/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
synthrad_sct/__main__.py,sha256=ev6t1FIlUhYLSYDceY1ixjUeIwJgI8TcP0DvCihqjz0,51
|
|
3
|
+
synthrad_sct/cli.py,sha256=-6k-iRwqQ_T7Edc9UN-yDT4HCSMb_gxTF9y8_CbP98k,14423
|
|
4
|
+
synthrad_sct/environment.py,sha256=GrJN3fJa3MViGRP-NgO5hT69zGh_JWD369pv9wQh194,4908
|
|
5
|
+
synthrad_sct/github.py,sha256=XkHRdGy1H2Nkqk5zZ3YDHSqxo8s8srzjKmVmxbbPUpo,6531
|
|
6
|
+
synthrad_sct/models.py,sha256=y7DmncIjF9dk4Ykvk7ZGtzziAnQF4zV3YB1UnxQD1Rs,2308
|
|
7
|
+
synthrad_sct/weights_repo.py,sha256=LVLDGYGUiK1RZCAMdsvCVW1Fd6mdH6mk4yFgu8jYI3E,3142
|
|
8
|
+
synthrad_sct-0.1.0.dist-info/METADATA,sha256=MBIzaSPcuhiLjZeOXlNkpAv5rk-MQHibCllm2Ryvjrg,5446
|
|
9
|
+
synthrad_sct-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
synthrad_sct-0.1.0.dist-info/entry_points.txt,sha256=q0nUNXIZWkJVwD2KIfuqljxIYEKu6Tdf0iQZXUoe62A,55
|
|
11
|
+
synthrad_sct-0.1.0.dist-info/RECORD,,
|