Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d248605
Update device selection for translation and multiaxis.
gbuzzard Aug 10, 2026
f985a6e
Merge branch 'prerelease' into greg_dev
gbuzzard Aug 10, 2026
ce3646f
Remove pointer to stale reference.
gbuzzard Aug 10, 2026
1ba9b1b
Update device policy.
gbuzzard Aug 10, 2026
d4267d7
Merge branch 'prerelease' into greg_dev
gbuzzard Aug 10, 2026
921e5a5
Add the maintenance page (release procedure) and the citation cleanup
cabouman Aug 10, 2026
142b394
Improve multi-device performance.
gbuzzard Aug 11, 2026
a33c7e8
Improve parallel beam performance.
gbuzzard Aug 11, 2026
4a222c7
Change default to projecting vertical voxel columns: batches of 8192 …
gbuzzard Aug 11, 2026
3e9c183
Update device policy.
gbuzzard Aug 11, 2026
413aeb0
Reduce memory in cross device streaming.
gbuzzard Aug 11, 2026
e4e82c6
Merge remote-tracking branch 'origin/prerelease' into greg_dev
gbuzzard Aug 11, 2026
d8d3e02
The consolidated demo set: nine demos, reviewed by Charlie
cabouman Aug 11, 2026
2d2b99a
Interleave transfer and computation.
gbuzzard Aug 11, 2026
eaccf55
Merge branch 'prerelease' into greg_dev
gbuzzard Aug 11, 2026
511af63
Reduce transient memory.
gbuzzard Aug 11, 2026
72208bb
fdk_recon settles the device layout before allocating (the A2 gap)
cabouman Aug 11, 2026
7435b6d
Merge branch 'main' into prerelease
cabouman Aug 12, 2026
82698f3
Fail the watch run when pr create fails instead of reporting tee's ex…
cabouman Aug 12, 2026
89a0dca
Add Python 3.13, 3.14 and 3.15 to the CI test matrix
github-actions[bot] Aug 12, 2026
a4d2e47
Drop 3.15: torch ships wheels for it but GitHub runners do not provid…
cabouman Aug 12, 2026
36635ee
Propose only Python versions GitHub's runners install
cabouman Aug 12, 2026
badd957
Merge pull request #3 from cabouman/nightly/python-matrix-add-3.13-3.…
cabouman Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/python-versions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"test": ["3.11", "3.12"],
"test": [
"3.11",
"3.12",
"3.13",
"3.14"
],
"docs": "3.12"
}
1 change: 1 addition & 0 deletions .github/workflows/dependency_watch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
set -o pipefail
BRANCH="${{ steps.watch.outputs.branch }}"
# A pull request in any state for this branch is a standing answer.
if [ "$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" \
Expand Down
11 changes: 11 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
cff-version: 1.2.0
message: "If you use this software, please cite it as below."
title: "MBIRTorch: High-performance tomographic reconstruction using PyTorch"
authors:
- family-names: Buzzard
given-names: Gregery T.
- family-names: Bouman
given-names: Charles A.
year: 2026
url: "https://github.com/cabouman/mbirtorch"
license: BSD-3-Clause
49 changes: 45 additions & 4 deletions ci/dependency_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
import urllib.request

CPU_INDEX_URL = "https://download.pytorch.org/whl/cpu/torch/"
# The Python versions GitHub's hosted runners can install (setup-python's
# source of truth). A version torch supports but the runners lack cannot
# be tested and must not be proposed.
RUNNER_MANIFEST_URL = ("https://raw.githubusercontent.com/actions/"
"python-versions/main/versions-manifest.json")
REMOTE_RAW = "https://raw.githubusercontent.com/cabouman/mbirtorch/prerelease/"
VERSION_FILE = ".github/python-versions.json"
PYPROJECT = "pyproject.toml"
Expand Down Expand Up @@ -76,6 +81,22 @@ def parse_torch_index(html):
return newest, sorted(files[newest], key=lambda v: int(v.split(".")[1]))


def parse_runner_manifest(text):
"""The Python versions GitHub's runners install, as minors like "3.12".
Only entries marked stable count; release candidates do not make a
version testable."""
minors = set()
for entry in json.loads(text):
if not entry.get("stable"):
continue
parts = str(entry.get("version", "")).split(".")
if len(parts) >= 2 and parts[0] == "3" and parts[1].isdigit():
minors.add(f"3.{parts[1]}")
if not minors:
raise ValueError("no stable Python versions found in the runner manifest")
return minors


def parse_version_file(text):
"""The matrix from the version file. Returns (test_list, docs_version)."""
data = json.loads(text)
Expand All @@ -101,12 +122,18 @@ def _minor(v):
return tuple(int(x) for x in v.split(".")[:2])


def divergence(torch_release, torch_list, matrix, python_floor, torch_floor):
"""The divergence, as a dict. Versions below the Python floor are
reported informationally and never proposed."""
def divergence(torch_release, torch_list, matrix, python_floor, torch_floor,
runner_minors=None):
"""The divergence, as a dict. Versions below the Python floor, and
versions absent from ``runner_minors`` (the versions GitHub's runners
install), are reported informationally and never proposed."""
below_floor = [v for v in torch_list if _minor(v) < _minor(python_floor)]
eligible = [v for v in torch_list if _minor(v) >= _minor(python_floor)]
additions = [v for v in eligible if v not in matrix]
not_on_runners = []
if runner_minors is not None:
not_on_runners = [v for v in additions if v not in runner_minors]
additions = [v for v in additions if v in runner_minors]
removals = [v for v in matrix if v not in torch_list]
torch_newest_minor = ".".join(str(x) for x in _minor(torch_release))
torch_advance = (torch_newest_minor
Expand All @@ -118,6 +145,7 @@ def divergence(torch_release, torch_list, matrix, python_floor, torch_floor):
"python_floor": python_floor,
"torch_floor": torch_floor,
"below_floor": below_floor,
"not_on_runners": not_on_runners,
"additions": additions,
"removals": removals,
"torch_advance": torch_advance,
Expand Down Expand Up @@ -255,6 +283,15 @@ def main(argv=None):
torch_release, torch_list = parse_torch_index(fetch(CPU_INDEX_URL))
print(f"dependency-watch: torch {torch_release} supports {torch_list}")

try:
runner_minors = parse_runner_manifest(fetch(RUNNER_MANIFEST_URL))
except (OSError, urllib.error.URLError, ValueError) as e:
print(f"dependency-watch: RUNNER MANIFEST NOT READ "
f"({RUNNER_MANIFEST_URL}): {e}")
print("dependency-watch: verdict UNKNOWN (cannot tell which versions "
"the runners install; this is not 'no divergence')")
return 1

try:
matrix, docs_version = parse_version_file(read(vf_source))
except (OSError, urllib.error.URLError) as e:
Expand All @@ -265,13 +302,17 @@ def main(argv=None):
print(f"dependency-watch: matrix {matrix}, docs {docs_version} ({vf_source})")

python_floor, torch_floor = parse_pyproject(read(pp_source))
d = divergence(torch_release, torch_list, matrix, python_floor, torch_floor)
d = divergence(torch_release, torch_list, matrix, python_floor, torch_floor,
runner_minors=runner_minors)

if args.json:
print(json.dumps(d, indent=2))
if d["below_floor"]:
print(f"dependency-watch: below the {python_floor} floor, not proposed: "
f"{d['below_floor']}")
if d["not_on_runners"]:
print(f"dependency-watch: torch supports but GitHub runners do not "
f"install yet, not proposed: {d['not_on_runners']}")
if d["any"]:
print(f"dependency-watch: DIVERGENCE -> branch {branch_name(d)}")
if d["additions"]:
Expand Down
27 changes: 26 additions & 1 deletion ci/test_dependency_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from dependency_watch import (parse_torch_index, parse_version_file,
parse_pyproject, divergence, branch_name)
parse_pyproject, parse_runner_manifest,
divergence, branch_name)

INDEX_HTML = """
<html><body>
Expand Down Expand Up @@ -57,6 +58,30 @@ def test_parse_pyproject_floors():
assert torch_floor == "2.13"


RUNNER_MANIFEST_JSON = """[
{"version": "3.15.0-rc.2", "stable": false},
{"version": "3.14.2", "stable": true},
{"version": "3.14.0", "stable": true},
{"version": "3.13.9", "stable": true},
{"version": "3.12.12", "stable": true},
{"version": "3.11.14", "stable": true}
]"""


def test_parse_runner_manifest_stable_minors_only():
minors = parse_runner_manifest(RUNNER_MANIFEST_JSON)
assert minors == {"3.11", "3.12", "3.13", "3.14"} # 3.15 rc excluded


def test_version_on_torch_index_but_not_on_runners_is_not_proposed():
d = divergence("2.13.0", ["3.11", "3.12", "3.13", "3.14", "3.15"],
["3.11", "3.12"], "3.11", "2.13",
runner_minors={"3.11", "3.12", "3.13", "3.14"})
assert d["not_on_runners"] == ["3.15"] # informational only
assert d["additions"] == ["3.13", "3.14"]
assert branch_name(d) == "nightly/python-matrix-add-3.13-3.14"


def test_multi_version_addition_with_below_floor_exclusion():
d = divergence("2.13.0", ["3.10", "3.11", "3.12", "3.13", "3.14"],
["3.11", "3.12"], "3.11", "2.13")
Expand Down
42 changes: 42 additions & 0 deletions demo/demo_1_parallel_basics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Demo 1: the basic MBIRTorch pipeline.

Make a simple 3D phantom, forward project it to get a sinogram, and
reconstruct it with model-based iterative reconstruction (MBIR).

In a real application you would skip the phantom and load your measured
sinogram as a numpy array with axes in the order
(views, detector rows, detector channels).
"""

import numpy as np
import mbirtorch

# Problem size: small enough to run on a laptop CPU in about a minute.
num_views = 128
num_det_rows = 128
num_det_channels = 128

# Make a phantom and project it to get a synthetic sinogram.
phantom, sinogram, params = mbirtorch.generate_demo_data(
model_type='parallel', object_type='shepp-logan',
num_views=num_views, num_det_rows=num_det_rows,
num_det_channels=num_det_channels)

# The generator also returns the projection angles it used.
angles = params['angles']

# Build the reconstruction model from the sinogram shape and the angles.
ct_model = mbirtorch.ParallelBeamModel(sinogram.shape, angles)

# Reconstruct. Everything is at its default value. The one parameter worth
# trying first is sharpness (default 1.0): higher gives crisper edges, lower
# gives smoother images. To change it: ct_model.set_params(sharpness=1.5)
recon, recon_dict = ct_model.recon(sinogram)

# Compare the reconstruction to the phantom.
nrmse = np.linalg.norm(recon - phantom) / np.linalg.norm(phantom)
print(f'Normalized RMS error between reconstruction and phantom: {nrmse:.3f}')

# View them side by side. Use the sliders to change slice and intensity.
mbirtorch.slice_viewer(phantom, recon, data_dicts=[None, recon_dict], vmin=0.0,
title='Phantom (left) and MBIR reconstruction (right)')
84 changes: 0 additions & 84 deletions demo/demo_1_shepp_logan.py

This file was deleted.

76 changes: 76 additions & 0 deletions demo/demo_2_cone_beam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Demo 2: cone-beam reconstruction, with the practices real data needs.

This demo adds four things to the basic pipeline of demo 1:

1. Cone-beam geometry, which needs two distances: source to detector, and
source to the rotation axis.
2. Simulated measurement noise with the physically correct structure:
rays through dense material are noisier.
3. Noise weighting: the weights tell the reconstruction to trust the
noisier measurements less.
4. Saving the reconstruction to a file.
"""

import numpy as np
import mbirtorch

# Problem size.
num_views = 128
num_det_rows = 128
num_det_channels = 128

# Make a phantom and its cone-beam sinogram. target_max_attenuation scales
# the phantom so the sinogram is in attenuation units (the units of real
# -log(I/I0) data), roughly in the range [0, 6].
phantom, sinogram, params = mbirtorch.generate_demo_data(
model_type='cone', object_type='shepp-logan',
num_views=num_views, num_det_rows=num_det_rows,
num_det_channels=num_det_channels, target_max_attenuation=6.0)

# Add measurement noise. For a transmission scan with a dosage of
# lambda_0 input photons per measurement, the attenuation measurements are
# approximately
# y = ybar + sqrt(exp(ybar) / lambda_0) * W, W ~ N(0, 1),
# so the noise standard deviation grows with attenuation. (Bouman and
# Sauer, "A Unified Approach to Statistical Tomography Using Coordinate
# Descent Optimization," IEEE Trans. on Image Processing, 1996.)
dosage = 10000.0
noise_std = np.sqrt(np.exp(sinogram) / dosage)
rng = np.random.default_rng(0)
sinogram = sinogram + noise_std * rng.standard_normal(sinogram.shape).astype(np.float32)

# The generator also returns the geometry it used.
angles = params['angles']
source_detector_dist = params['source_detector_dist']
source_iso_dist = params['source_iso_dist']

# Build the cone-beam model. The two distances set the cone geometry.
ct_model = mbirtorch.ConeBeamModel(sinogram.shape, angles,
source_detector_dist=source_detector_dist,
source_iso_dist=source_iso_dist)

# Noise weights. The noise model above has variance exp(y) / lambda_0, so
# down-weighting by the transmission gives the noisier measurements less
# influence. For a first look at any new data set, weights=None is also fine.
weights = mbirtorch.gen_weights(sinogram, weight_type='transmission_root')

# Sharpness is the main image-quality control: higher gives crisper edges,
# lower gives smoother images. Typical useful range is about -1 to 2.
ct_model.set_params(sharpness=1.0)

# Reconstruct.
recon, recon_dict = ct_model.recon(sinogram, weights=weights)

nrmse = np.linalg.norm(recon - phantom) / np.linalg.norm(phantom)
print(f'Normalized RMS error between reconstruction and phantom: {nrmse:.3f}')

# View the phantom and the reconstruction side by side.
mbirtorch.slice_viewer(phantom, recon, data_dicts=[None, recon_dict], vmin=0.0,
title='Phantom (left) and cone-beam MBIR reconstruction (right)')

# Save the reconstruction and its settings to one file. The file can be
# reloaded later for viewing, or to continue from this result:
# recon, recon_dict = mbirtorch.TomographyModel.load_recon_hdf5(filepath)
filepath = './output/demo2_recon.h5'
ct_model.save_recon_hdf5(filepath, recon, recon_dict)
print(f'Reconstruction saved to {filepath}')
Loading
Loading