From ce8a9f4100ec42ee75e28c05f96fda99083f2bac Mon Sep 17 00:00:00 2001 From: tctco Date: Sun, 30 Aug 2026 00:23:36 +0800 Subject: [PATCH 1/2] Release dcccpy 0.1.7 for DCCCcore 4.3.0 --- python/dcccpy/README.md | 10 ++++- python/dcccpy/pyproject.toml | 2 +- python/dcccpy/scripts/vendor_dccccore.py | 2 +- python/dcccpy/src/dcccpy/__init__.py | 2 + python/dcccpy/src/dcccpy/core.py | 39 ++++++++++++++++ python/dcccpy/src/dcccpy/runtime.py | 2 +- python/dcccpy/tests/test_core.py | 57 ++++++++++++++++++++++-- 7 files changed, 106 insertions(+), 8 deletions(-) diff --git a/python/dcccpy/README.md b/python/dcccpy/README.md index 442fd35..c9f5278 100644 --- a/python/dcccpy/README.md +++ b/python/dcccpy/README.md @@ -79,6 +79,12 @@ dcccpy.centiloid("amyloid.nii", suvr=True) dcccpy.centaurz("tau.nii", report_detailed_regions=True) dcccpy.fillstates("fdg.nii", tracer="fdg") dcccpy.normalize("pet.nii", iterative=True) +dcccpy.pet_motion_correct( + "dynamic_pet.nii.gz", + "averaged_pet.nii.gz", + save_corrected_dynamic="corrected.nii.gz", + motion_output="motion.tsv", +) dcccpy.run(["centiloid", "--input", "a.nii", "--output", "b.nii"]) ``` @@ -142,7 +148,7 @@ the calculation again. Release wheels should vendor the matching `DCCCcore` runtime tree before build: ```bash -python scripts/vendor_dccccore.py --version 4.2.4 --release-platform ubuntu-latest-x64 +python scripts/vendor_dccccore.py --version 4.3.0 --release-platform ubuntu-latest-x64 python -m build --wheel ``` @@ -150,7 +156,7 @@ The Linux ARM64 runtime package uses the corresponding release asset: ```bash cd python/dcccpy-linux-arm64-runtime -python scripts/vendor_dccccore.py --version 4.2.4 --release-platform ubuntu-latest-arm64 +python scripts/vendor_dccccore.py --version 4.3.0 --release-platform ubuntu-latest-arm64 python -m build --wheel ``` diff --git a/python/dcccpy/pyproject.toml b/python/dcccpy/pyproject.toml index bfaa541..56e1b8c 100644 --- a/python/dcccpy/pyproject.toml +++ b/python/dcccpy/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dcccpy" -version = "0.1.6" +version = "0.1.7" description = "Python wrapper for the DCCCcore PET biomarker command-line tool" readme = "README.md" requires-python = ">=3.9" diff --git a/python/dcccpy/scripts/vendor_dccccore.py b/python/dcccpy/scripts/vendor_dccccore.py index 5ce16f4..4902317 100644 --- a/python/dcccpy/scripts/vendor_dccccore.py +++ b/python/dcccpy/scripts/vendor_dccccore.py @@ -15,7 +15,7 @@ REPO = "tctco/DCCCSlicer" -DEFAULT_VERSION = "4.2.4" +DEFAULT_VERSION = "4.3.0" DEFAULT_RELEASE_PLATFORM = "ubuntu-latest-x64" DEFAULT_VENDOR_PLATFORM = "linux-x86_64" diff --git a/python/dcccpy/src/dcccpy/__init__.py b/python/dcccpy/src/dcccpy/__init__.py index 6273d9e..e0e6c60 100644 --- a/python/dcccpy/src/dcccpy/__init__.py +++ b/python/dcccpy/src/dcccpy/__init__.py @@ -13,6 +13,7 @@ fillstates, normalize, parse_metrics, + pet_motion_correct, rigid, run, suvr, @@ -36,6 +37,7 @@ "fillstates", "normalize", "parse_metrics", + "pet_motion_correct", "rigid", "run", "suvr", diff --git a/python/dcccpy/src/dcccpy/core.py b/python/dcccpy/src/dcccpy/core.py index cb66653..9b7fe49 100644 --- a/python/dcccpy/src/dcccpy/core.py +++ b/python/dcccpy/src/dcccpy/core.py @@ -420,3 +420,42 @@ def rigid( **kwargs: object, ) -> DCCCResult: return _spatial_command("rigid", input, output, **kwargs) + + +def pet_motion_correct( + input: object, + output: str | os.PathLike[str] | None = None, + *, + save_corrected_dynamic: str | os.PathLike[str] | None = None, + motion_output: str | os.PathLike[str] | None = None, + check: bool = True, + executable: str | os.PathLike[str] | None = None, +) -> DCCCResult: + """Rigidly align a multi-frame PET image and return its averaged 3D output.""" + + input_path, actual_output, temp_dir = _prepare_io(input, output) + args = [ + "pet-motion-correct", + "--input", + input_path, + "--output", + os.fspath(actual_output), + ] + if save_corrected_dynamic is not None: + corrected_path = _resolve_user_path(save_corrected_dynamic) + args.extend(["--save-corrected-dynamic", os.fspath(corrected_path)]) + if motion_output is not None: + motion_path = _resolve_user_path(motion_output) + args.extend(["--motion-output", os.fspath(motion_path)]) + + result = run(args, check=check, executable=executable, output=actual_output) + return DCCCResult( + args=result.args, + returncode=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + output=actual_output, + temp_dir=temp_dir, + executable=result.executable, + metrics=result.metrics, + ) diff --git a/python/dcccpy/src/dcccpy/runtime.py b/python/dcccpy/src/dcccpy/runtime.py index 2b8f715..4f87f05 100644 --- a/python/dcccpy/src/dcccpy/runtime.py +++ b/python/dcccpy/src/dcccpy/runtime.py @@ -12,7 +12,7 @@ from typing import Iterable -DCCCCORE_VERSION = "4.2.4" +DCCCCORE_VERSION = "4.3.0" RELEASE_REPO = "tctco/DCCCSlicer" diff --git a/python/dcccpy/tests/test_core.py b/python/dcccpy/tests/test_core.py index 80c3005..f40e4e1 100644 --- a/python/dcccpy/tests/test_core.py +++ b/python/dcccpy/tests/test_core.py @@ -9,7 +9,14 @@ import pytest import dcccpy -from dcccpy.runtime import DCCCcoreNotFoundError, dccccore_path, find_existing_dccccore, release_platform +from dcccpy.runtime import ( + DCCCCORE_VERSION, + DCCCcoreNotFoundError, + dccccore_path, + find_existing_dccccore, + release_platform, + release_url, +) def make_fake_dccccore(tmp_path: Path) -> Path: @@ -135,7 +142,7 @@ def test_dccccore_path_can_disable_auto_download(tmp_path: Path, monkeypatch: py def test_dccccore_path_auto_downloads_to_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - asset_root = tmp_path / "asset" / "DCCCcore-4.2.4-ubuntu-latest-x64" + asset_root = tmp_path / "asset" / "DCCCcore-4.3.0-ubuntu-latest-x64" asset_root.mkdir(parents=True) exe = asset_root / "DCCCcore" exe.write_text("#!/usr/bin/env sh\nprintf 'fake dccccore\\n'\n") @@ -143,7 +150,7 @@ def test_dccccore_path_auto_downloads_to_cache(tmp_path: Path, monkeypatch: pyte archive = tmp_path / "DCCCcore.zip" with zipfile.ZipFile(archive, "w") as zf: - zf.write(exe, "DCCCcore-4.2.4-ubuntu-latest-x64/DCCCcore") + zf.write(exe, "DCCCcore-4.3.0-ubuntu-latest-x64/DCCCcore") monkeypatch.delenv("DCCCPY_DCCCCORE", raising=False) monkeypatch.setenv("DCCCPY_AUTO_DOWNLOAD", "1") @@ -179,6 +186,13 @@ def test_linux_arm64_release_platform() -> None: assert release_platform("linux-arm64") == "ubuntu-latest-arm64" +def test_default_release_targets_dccccore_430() -> None: + assert DCCCCORE_VERSION == "4.3.0" + assert release_url(platform_name="ubuntu-latest-x64").endswith( + "/releases/download/v4.3.0/DCCCcore-4.3.0-ubuntu-latest-x64.zip" + ) + + def test_find_existing_dccccore_checks_linux_arm64_runtime_package( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -211,6 +225,43 @@ def test_cli_forwards_raw_args(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, assert "Metric: Centiloid" in capsys.readouterr().out +def test_pet_motion_correct_forwards_optional_outputs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_args = tmp_path / "args.txt" + fake_exe = make_fake_dccccore(tmp_path) + input_path = tmp_path / "dynamic.nii.gz" + output_path = tmp_path / "average.nii.gz" + corrected_path = tmp_path / "corrected.nii.gz" + motion_path = tmp_path / "motion.tsv" + input_path.write_text("fake dynamic PET") + monkeypatch.setenv("DCCCPY_DCCCCORE", str(fake_exe)) + monkeypatch.setenv("DCCCPY_FAKE_ARGS", str(fake_args)) + + result = dcccpy.pet_motion_correct( + input_path, + output_path, + save_corrected_dynamic=corrected_path, + motion_output=motion_path, + ) + + assert result.returncode == 0 + assert result.output == output_path + assert output_path.exists() + assert fake_args.read_text().splitlines() == [ + "pet-motion-correct", + "--input", + str(input_path), + "--output", + str(output_path), + "--save-corrected-dynamic", + str(corrected_path), + "--motion-output", + str(motion_path), + ] + + def test_run_adds_macos_security_hint_for_blocked_runtime( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 7e1e4b237153ff89d44f0bc97c87d05e02067b7a Mon Sep 17 00:00:00 2001 From: tctco Date: Sun, 30 Aug 2026 00:40:40 +0800 Subject: [PATCH 2/2] Preserve NIfTI compound extensions on Windows --- localizer/src/core/common/PathUtils.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/localizer/src/core/common/PathUtils.cpp b/localizer/src/core/common/PathUtils.cpp index 9d27220..a7d1e3a 100644 --- a/localizer/src/core/common/PathUtils.cpp +++ b/localizer/src/core/common/PathUtils.cpp @@ -22,6 +22,14 @@ namespace Common::path { namespace { +#ifdef _WIN32 +bool containsNonAscii(const std::string& value) { + return std::any_of(value.begin(), value.end(), [](unsigned char ch) { + return ch > 0x7F; + }); +} +#endif + std::string normalizeDirectory(const std::filesystem::path& directory) { if (directory.empty()) { return {}; @@ -103,6 +111,15 @@ std::string toUtf8(const std::filesystem::path& value) { std::string legacyFileName(const std::filesystem::path& value) { #ifdef _WIN32 + const std::string utf8Path = toUtf8(value); + // ITK selects an ImageIO from the filename extension. Windows 8.3 paths + // collapse a compound NIfTI extension such as .nii.gz to .GZ, so keep + // ordinary ASCII paths intact and only fall back to a short path when it + // is needed for a non-ASCII filename. + if (!containsNonAscii(utf8Path)) { + return utf8Path; + } + if (const std::wstring shortPath = shortPathForExistingPath(value); !shortPath.empty()) { return wideToUtf8(shortPath); }