Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
147 changes: 147 additions & 0 deletions .agents/skills/loop-kit/SKILL.md

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions .github/scripts/build_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def parse_junit(xml_path: Path) -> dict:
"skipped": skipped,
"available": True,
}
except Exception as exc:
except (ET.ParseError, OSError, ValueError) as exc:
return {
"tests": 0,
"passed": 0,
Expand Down Expand Up @@ -118,7 +118,7 @@ def parse_coverage(json_path: Path) -> dict:
if pct is not None:
return {"percent": float(pct), "available": True}
return {"percent": None, "available": False}
except Exception as exc:
except (OSError, ValueError, TypeError) as exc:
Comment thread
aylward marked this conversation as resolved.
return {"percent": None, "available": False, "parse_error": str(exc)}


Expand Down Expand Up @@ -168,9 +168,9 @@ def build_html(data: dict) -> str:
ts = data["timestamp"]

try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
dt = datetime.fromisoformat(ts)
ts_display = dt.strftime("%Y-%m-%d %H:%M UTC")
except Exception:
except ValueError:
ts_display = ts

# Determine overall badge
Expand Down Expand Up @@ -313,9 +313,9 @@ def build_summary_md(data: dict) -> str:
ts = data["timestamp"]

try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
dt = datetime.fromisoformat(ts)
ts_display = dt.strftime("%Y-%m-%d %H:%M UTC")
except Exception:
except ValueError:
ts_display = ts

if not junit.get("available"):
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,11 @@ jobs:
$env:CL = "/std:c++20"
python -m uv pip install -e ".[dev_cuda13]" --no-build-isolation-package torch-scatter

- name: Install FireANTs
# --no-deps: FireANTs pins would otherwise override the CUDA torch stack.
run: |
python -m uv pip install fireants --no-deps

- name: Assert CUDA is accessible
run: |
python -c "
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/nightly-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ jobs:
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
python -m uv pip install -e ".[dev_cuda13]" --no-build-isolation-package torch-scatter
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
python -m uv pip install fireants --no-deps
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

- name: Assert CUDA is accessible
run: |
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test-slow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
# Invoke via python -m uv so uv targets the active venv interpreter.
run: |
python -m uv pip install -e ".[dev_cuda13]" --no-build-isolation-package torch-scatter
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
python -m uv pip install fireants --no-deps
Comment thread
aylward marked this conversation as resolved.

- name: Assert CUDA is accessible
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ build
__pycache__
_version.py
htmlcov
.vscode
.vscode/*
!.vscode/settings.json

Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
repos:
# Ruff - fast linter and formatter (replaces black, isort, flake8)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20
rev: v0.16.6
hooks:
# Run the linter with auto-fixes
- id: ruff-check
Expand Down
4 changes: 4 additions & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
[lint]
# Ignore UP007/UP045: Use Optional[X] instead of X | None for itk.Image compatibility
ignore = ["UP007", "UP045"]

[lint.per-file-ignores]
# Simpleware loads this script by file name, so the module name is fixed.
"src/monai_physio/simpleware_medical/SimplewareScript_heart_segmentation.py" = ["N999"]
1 change: 1 addition & 0 deletions data/TCIA-4DLung/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def convert_series(series_dir, root, patient_name, pct_counts):
[DCM2NIIX, "-f", "%d", "-b", "n", "-z", "y", "-o", tmp_dir, series_dir],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
print(f" FAILED: {series_dir}\n{result.stderr}")
Expand Down
4 changes: 2 additions & 2 deletions docs/assets/tutorial_13_combined_motion_ct.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/assets/tutorial_13_combined_motion_usd.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import sys
import tomllib
import warnings
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import MagicMock

Expand All @@ -30,7 +30,7 @@ def __getattr__(cls, name):

# -- Project information -----------------------------------------------------
project = "MONAI Physio"
copyright = f"{datetime.now().year}, Stephen R. Aylward, NVIDIA Corporation"
copyright = f"{datetime.now(tz=UTC).year}, Stephen R. Aylward, NVIDIA Corporation"
author = "Stephen R. Aylward"
_repo_root = Path(__file__).resolve().parents[1]
_pyproject = tomllib.loads((_repo_root / "pyproject.toml").read_text(encoding="utf-8"))
Expand Down
32 changes: 32 additions & 0 deletions docs/developer/migration_next.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,35 @@ new_call(argument, required_option="value")

**Automated conversion:** `<path to script or CLI>`, or `None needed`.
````

## InferPhysicsNeMoBase, InferPhysicsNeMoMGN, InferPhysicsNeMoMLP - `model_tag` is now a constructor argument

**Change:** `model_tag` moved from a class attribute to the first `__init__`
argument of `InferPhysicsNeMoBase` and its MGN and MLP subclasses
(defaults `"base"`, `"mgn"`, `"mlp"`). `log_level` is now the second
positional argument. A subclass that only sets a class-level `model_tag`
now gets the constructor default instead, because `__init__` assigns the
instance attribute.

**Why:** the checkpoint tag must match the training method that wrote the
weights (for example `"physics_informed_motion"`). It can now be chosen per
instance without defining a subclass.

**Before**

```python
class InferPhysicsInformed(InferPhysicsNeMoMGN):
model_tag = "physics_informed_motion"


method = InferPhysicsInformed("DEBUG")
```

**After**

```python
method = InferPhysicsNeMoMGN(model_tag="physics_informed_motion", log_level="DEBUG")
```

**Automated conversion:** `None needed`. Pass `log_level` by keyword and
replace class-level `model_tag` overrides with the constructor argument.
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
print(f" Original mesh: {mesh.n_cells} cells, {mesh.n_points} points")
print(f" Surface mesh: {surface.n_cells} cells, {surface.n_points} points")

except Exception as e:
except Exception as e: # noqa: BLE001 - report and go on to the next mesh
print(f" Error processing {vtk_file}: {e!s}")

print("\n" + "=" * 50)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,9 +432,13 @@ def crop_image_to_mask(
output_dir = _HERE / f"results{cond}"
detail_landmarks_file = output_dir / "registration_landmarks_init.csv"
detail_dice_file = output_dir / "registration_dice_init.csv"
if subject_index == 0 and image_index == 0 and cond_index == 0:
if output_dir.exists():
shutil.rmtree(output_dir)
if (
subject_index == 0
and image_index == 0
and cond_index == 0
and output_dir.exists()
):
shutil.rmtree(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)

for method_index, method_name in enumerate(methods):
Expand Down
3 changes: 2 additions & 1 deletion experiments/Heart-GatedCT_To_USD/2-generate_segmentation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# %%
import os
import sys

import itk
import numpy as np
Expand Down Expand Up @@ -36,7 +37,7 @@
)
except (FileNotFoundError, OSError):
print("No image found. Aborting. Please run 1-generate_images.ipynb first.")
exit(1)
sys.exit(1)
arr = itk.array_from_image(image)
print(arr.shape)
arr = np.where(arr == 0, -1000, arr)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@
print("Please check the error messages above for details.")
result = None

except Exception as e:
except Exception as e: # noqa: BLE001 - last-resort handler for the script
print(f"\nUnexpected error: {e}")
result = None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def _ensure_mgn_inference_assets(
reference_image=reference_image,
fitted_reference_mesh=registered_mesh_file,
)
pct = int(round(stage * 100))
pct = round(stage * 100)
field_path = output_dir / f"deformation_field_s{pct:03d}.mha"
normal_path = output_dir / f"surface_normal_field_s{pct:03d}.mha"
surface_path = output_dir / f"deformed_heart_surface_s{pct:03d}.vtp"
Expand All @@ -185,7 +185,7 @@ def _ensure_mgn_inference_assets(
normal_files.append(normal_path)
surface_files.append(surface_path)
deformed_surfaces.append(result["deformed_surface"])
logging.info("stage %.2f -> %s", stage, field_path.name)
logging.getLogger(__name__).info("stage %.2f -> %s", stage, field_path.name)

# Assemble the ordered per-stage surfaces into one animated 4D USD. The
# stages loop over one RR interval, so play them back at one beat per second.
Expand Down
4 changes: 2 additions & 2 deletions experiments/Lung-VesselsAirways/0-GenData.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# %%
from datetime import datetime
from datetime import UTC, datetime

import itk
import matplotlib.pyplot as plt
Expand Down Expand Up @@ -296,7 +296,7 @@ def __getitem__(self, _):
optimizer.step()
running_loss += loss.item()
print(f"Epoch {epoch + 1}/{num_epochs}, Loss: {running_loss / len(dataloader)}")
date = datetime.now().strftime("%Y.%m.%d")
date = datetime.now(tz=UTC).strftime("%Y.%m.%d")
model_path = f"vessel_seg_resnet18_{date}.pth"
torch.save(model, model_path)

Expand Down
96 changes: 96 additions & 0 deletions experiments/Registration-Fireants_vs_Greedy/affine_diagnostic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Affine-only diagnostic: FireANTs vs Greedy on selected heart phases.

Registers each listed phase to the heart reference frame with an Affine-only
registrar (FireANTs class defaults vs the tutorial Greedy registrar) and
records NCC/MSE of the warped image plus the affine matrix summary.

Usage:
py affine_diagnostic.py --out experiments/Registration-Fireants_vs_Greedy/results/affine_diagnostic.json
"""

from __future__ import annotations

import argparse
import json
import logging
import sys
import time
from pathlib import Path
from typing import Any

import itk
import numpy as np
import torch

sys.path.insert(0, str(Path(__file__).resolve().parent))

import benchmark


def affine_summary(transform: itk.Transform) -> dict[str, Any]:
"""Matrix determinant, singular values and translation of an affine."""
matrix = itk.array_from_matrix(transform.GetMatrix())
singular = np.linalg.svd(matrix, compute_uv=False)
return {
"determinant": float(np.linalg.det(matrix)),
"singular_values": [float(v) for v in singular],
"translation_mm": [float(v) for v in transform.GetTranslation()],
}


def main() -> None:
"""Run the diagnostic and write the JSON."""
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--phases", default="3,13,15")
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
logging.basicConfig(level=logging.WARNING)

files, reference_index = benchmark.load_dataset("heart", False)
images = [itk.imread(str(path)) for path in files]
reference = images[reference_index]
fixed_array = itk.array_from_image(reference)
identity = itk.IdentityTransform[itk.D, 3].New()

result: dict[str, Any] = {"dataset": "heart", "reference_frame": reference_index}
rows: dict[str, list[dict[str, Any]]] = {"greedy": [], "fireants": []}
for method in ("greedy", "fireants"):
registrar = benchmark.build_registrar("heart", method, False, {})
registrar.set_transform_type("Affine")
registrar.set_fixed_image(reference)
registrar.register(images[0])
for phase in (int(p) for p in args.phases.split(",")):
moving = images[phase]
if method == "fireants":
torch.cuda.synchronize()
start = time.perf_counter()
out = registrar.register(moving)
if method == "fireants":
torch.cuda.synchronize()
seconds = time.perf_counter() - start
forward = out["fixed_to_moving_transform"]
warped = itk.array_from_image(
benchmark.resample(moving, forward, reference)
)
unregistered = itk.array_from_image(
benchmark.resample(moving, identity, reference)
)
rows[method].append(
{
"phase": phase,
"seconds": seconds,
"ncc": benchmark.ncc(warped, fixed_array),
"mse": benchmark.mse(warped, fixed_array),
"ncc_unregistered": benchmark.ncc(unregistered, fixed_array),
"mse_unregistered": benchmark.mse(unregistered, fixed_array),
"affine": affine_summary(forward),
}
)
result["greedy"] = rows["greedy"]
result["fireants"] = rows["fireants"]
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, indent=2), encoding="utf-8")


if __name__ == "__main__":
main()
Loading
Loading