ImpactReg / Generic_Rigid /install.py
Valentin Boussot
Restructure IMPACT-Reg into per-preset KonfAI apps
c8cdf2d
Raw
History Blame Contribute Delete
11.3 kB
# Copyright (c) 2025 Valentin Boussot
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
import argparse
import platform
import re
import shutil
import stat
import subprocess # nosec B404
import zipfile
from pathlib import Path
import requests
from tqdm import tqdm
# -----------------------------------------------------------------------------
# Elastix + IMPACT binary assets hosted on GitHub Releases.
#
# Key format: (OS, ARCH, FLAVOR)
# - OS : platform.system() -> "Linux", "Windows", "Darwin"
# - ARCH : normalized architecture -> "x86_64"
# - FLAVOR : "cpu" or "cu128"
#
# CPU assets are standalone (LibTorch CPU bundled).
# CUDA assets do NOT bundle LibTorch (too large for GitHub limits).
# -----------------------------------------------------------------------------
ELX_ASSET_TEMPLATE = {
("Linux", "x86_64", "cpu"): "elastix-impact-linux-x86_64-cpu.zip",
("Linux", "x86_64", "cu128"): "elastix-impact-linux-x86_64-cu128.zip",
("Windows", "x86_64", "cpu"): "elastix-impact-windows-x86_64-cpu.zip",
("Windows", "x86_64", "cu128"): "elastix-impact-windows-x86_64-cu128.zip",
("Darwin", "x86_64", "cpu"): "elastix-impact-macos-14-x86_64-cpu.zip",
}
# -----------------------------------------------------------------------------
# Official LibTorch downloads (PyTorch).
#
# IMPORTANT:
# - Version MUST match the one used at build time (ABI compatibility).
# - Using "shared-with-deps" ensures CUDA runtime libraries are included
# (except the NVIDIA driver, which must be installed system-wide).
# -----------------------------------------------------------------------------
LIBTORCH_URL = {
(
"Linux",
"x86_64",
"cpu",
): "https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-2.8.0%2Bcpu.zip",
(
"Windows",
"x86_64",
"cpu",
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
(
"Darwin",
"arm64",
"cpu",
): "https://download.pytorch.org/libtorch/cpu/libtorch-win-shared-with-deps-2.8.0%2Bcpu.zip",
(
"Linux",
"x86_64",
"cu128",
): "https://download.pytorch.org/libtorch/cu128/libtorch-shared-with-deps-2.8.0%2Bcu128.zip",
(
"Windows",
"x86_64",
"cu128",
): "https://download.pytorch.org/libtorch/cu128/libtorch-win-shared-with-deps-2.8.0%2Bcu128.zip",
}
# -----------------------------------------------------------------------------
# Minimum NVIDIA driver versions required for CUDA 12.8.
#
# The CUDA Toolkit itself is NOT required.
# Only a sufficiently recent NVIDIA driver must be installed.
# -----------------------------------------------------------------------------
CUDA128_MIN_DRIVER_LINUX = (570, 26)
CUDA128_MIN_DRIVER_WINDOWS = (570, 65)
GITHUB_OWNER = "vboussot"
GITHUB_REPO = "ImpactElastix"
GITHUB_TAG = "1.0.0"
DEFAULT_PREFIX = Path.cwd() / "elastix-impact"
def run_cmd(cmd: list[str]) -> str:
return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", errors="replace") # nosec B603
def detect_nvidia_driver() -> tuple[bool, tuple[int, int] | None]:
"""
Detect presence of an NVIDIA GPU and extract the driver version.
Returns:
(True, (major, minor)) if detected
(False, None) if nvidia-smi is not available
"""
try:
nvidia_smi = shutil.which("nvidia-smi")
if nvidia_smi is None:
raise RuntimeError("nvidia-smi not found in PATH")
out = (
subprocess.check_output(
[nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], stderr=subprocess.DEVNULL
) # nosec B603
.decode("utf-8")
.strip()
)
except Exception:
return (False, None)
# Exemple: "575.64"
m = re.match(r"([0-9]+)\.([0-9]+)", out)
if not m:
return (True, None)
return (True, (int(m.group(1)), int(m.group(2))))
def driver_ok_for_cuda(os_name: str, drv: tuple[int, int] | None) -> bool:
"""
Check whether the detected NVIDIA driver satisfies the minimum
requirement for CUDA 12.8 on the given operating system.
"""
if drv is None:
return False
if os_name == "Linux":
return drv >= CUDA128_MIN_DRIVER_LINUX
if os_name == "Windows":
return drv >= CUDA128_MIN_DRIVER_WINDOWS
return False
def normalize_arch(machine: str) -> str:
"""
Normalize platform.machine() output across operating systems.
Examples:
AMD64 -> x86_64
aarch64 -> arm64
"""
m = machine.lower()
if m in ("x86_64", "amd64"):
return "x86_64"
if m in ("aarch64", "arm64"):
return "arm64"
return machine
def download_file(url: str, dst: Path) -> None:
"""
Download a file from the given URL with a progress indicator.
"""
dst.parent.mkdir(parents=True, exist_ok=True)
print(f"Downloading: {url}", flush=True)
print(f" to: {dst}", flush=True)
try:
with requests.get(url, stream=True, timeout=60) as r:
r.raise_for_status()
total = int(r.headers.get("content-length", 0))
with open(dst, "wb") as f:
with tqdm(
total=total,
unit="B",
unit_scale=True,
desc=f"Downloading {dst.name}",
) as pbar:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
pbar.update(len(chunk))
except Exception as e:
raise e
def extract_archive(archive: Path, dst_dir: Path) -> None:
"""
Extract a ZIP archive to the destination directory.
"""
dst_dir.mkdir(parents=True, exist_ok=True)
print(f"Extracting: {archive} -> {dst_dir}", flush=True)
with zipfile.ZipFile(archive, "r") as z:
z.extractall(dst_dir)
archive.unlink()
def install_elastix_impact(install_path: Path, force_cuda: bool, force_cpu: bool):
os_name = platform.system()
arch = normalize_arch(platform.machine())
has_nvidia, drv = detect_nvidia_driver()
if os_name not in ("Linux", "Windows", "Darwin"):
raise NameError(f"Unsupported OS: {os_name}")
if arch not in ("x86_64", "arm64"):
raise NameError(f"Unsupported arch: {arch} (expected x86_64, arm64)")
flavor = "cpu"
if force_cuda:
if not has_nvidia or not driver_ok_for_cuda(os_name, drv):
raise NameError(
"CUDA forced but NVIDIA driver/GPU not suitable. Detected: has_nvidia={has_nvidia}, driver={drv}"
)
flavor = "cu128"
elif not force_cpu:
if has_nvidia and driver_ok_for_cuda(os_name, drv):
flavor = "cu128"
else:
flavor = "cpu"
print(f"System: {os_name} {arch}", flush=True)
print(f"NVIDIA: {has_nvidia}, driver={drv}", flush=True)
print(f"Selected flavor: {flavor}", flush=True)
key = (os_name, arch, flavor)
if key not in ELX_ASSET_TEMPLATE:
raise NameError(f"No elastix asset configured for {key}")
if flavor != "cpu" and key not in LIBTORCH_URL:
raise NameError(f"No libtorch url configured for {key}")
install_path = install_path.resolve()
install_path.mkdir(parents=True, exist_ok=True)
elx_asset = ELX_ASSET_TEMPLATE[key]
elx_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{GITHUB_TAG}/{elx_asset}"
elx_archive = install_path / elx_asset
download_file(elx_url, elx_archive)
extract_archive(elx_archive, install_path)
# -------------------------------------------------------------------------
# ZIP archives may drop executable permissions.
# Ensure elastix and transformix are executable on Unix platforms.
# -------------------------------------------------------------------------
if os_name in ("Linux", "Darwin"):
for exe in ("elastix", "transformix"):
p = install_path / "bin" / exe
if p.exists():
p.chmod(p.stat().st_mode | stat.S_IEXEC)
# ---------------------------------------------------------------------
# - Download matching LibTorch 12.8 with or without CUDA 12.8
# - Extract it
# - Move runtime libraries to a location visible to the dynamic loader
#
# Linux / macOS : prefix/lib
# Windows : prefix (next to elastix.exe)
# ---------------------------------------------------------------------
lt_url = LIBTORCH_URL[key]
lt_archive = install_path / Path(lt_url).name
download_file(lt_url, lt_archive)
extract_archive(lt_archive, install_path)
for p in (install_path / "libtorch" / "lib").iterdir():
if ".so" in p.name or ".dll" in p.name or ".dylib" in p.name:
shutil.move(p, (install_path if os_name == "Windows" else install_path / "lib") / p.name)
shutil.rmtree(install_path / "libtorch")
if os_name == "Linux" and flavor == "cu128":
shutil.copy(install_path / "lib" / "libcudart-c3a75b33.so.12", install_path / "lib" / "libcudart.so.12")
def get_elastix_bin(install_path: Path) -> Path:
return install_path / ("elastix.exe" if platform.system() == "Windows" else (Path("bin") / "elastix"))
def try_elastix(install_path: Path) -> None:
try:
subprocess.run(
[str(get_elastix_bin(install_path)), "-h"],
capture_output=True,
text=True,
check=True,
) # nosec B603
except subprocess.CalledProcessError as e:
msg = "Elastix execution failed.\n\n"
msg += f"Command:\n{' '.join(e.cmd)}\n"
msg += f"Return code: {e.returncode}\n\n"
if e.stderr:
msg += "Error output:\n"
msg += e.stderr.strip()
raise NameError(msg)
except OSError as e:
msg = (
"Elastix could not be started.\n\n"
"This is usually caused by missing shared libraries "
"(e.g. LibTorch or CUDA runtime).\n\n"
f"System error:\n{str(e)}"
)
raise NameError(msg)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument(
"--install-path", type=Path, default=DEFAULT_PREFIX, help="Install directory (default: ./elastix-impact)"
)
group = ap.add_mutually_exclusive_group()
group.add_argument("--force-cpu", action="store_true", help="Force CPU install even if NVIDIA present")
group.add_argument("--force-cuda", action="store_true", help="Force CUDA install (fails if no suitable driver)")
args = vars(ap.parse_args())
install_elastix_impact(**args)
if __name__ == "__main__":
main()