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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ sys.exit(main())
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ synthrad-sct = synthrad_sct.cli:main