warpkit 1.2.0__tar.gz → 1.2.2__tar.gz

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.
Files changed (94) hide show
  1. {warpkit-1.2.0 → warpkit-1.2.2}/.github/workflows/build.yml +2 -1
  2. {warpkit-1.2.0 → warpkit-1.2.2}/CLAUDE.md +10 -7
  3. {warpkit-1.2.0 → warpkit-1.2.2}/LICENSE +4 -1
  4. {warpkit-1.2.0 → warpkit-1.2.2}/PKG-INFO +15 -4
  5. {warpkit-1.2.0 → warpkit-1.2.2}/README.md +10 -2
  6. warpkit-1.2.2/codecov.yml +10 -0
  7. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/voxel_quality.h +10 -37
  8. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/weights.h +49 -75
  9. {warpkit-1.2.0 → warpkit-1.2.2}/include/warps.h +24 -12
  10. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/build_bundle.py +15 -0
  11. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/bundle_README.md +6 -0
  12. {warpkit-1.2.0 → warpkit-1.2.2}/pyproject.toml +9 -4
  13. {warpkit-1.2.0 → warpkit-1.2.2}/src/warpkit.cpp +1 -1
  14. {warpkit-1.2.0 → warpkit-1.2.2}/tests/test_scripts.py +86 -3
  15. warpkit-1.2.2/warpkit/api.py +48 -0
  16. warpkit-1.2.2/warpkit/scripts/_metadata.py +162 -0
  17. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/scripts/_warp_io.py +26 -18
  18. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/scripts/apply_warp.py +155 -87
  19. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/scripts/compute_fieldmap.py +131 -85
  20. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/scripts/compute_jacobian.py +85 -29
  21. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/scripts/convert_fieldmap.py +157 -78
  22. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/scripts/convert_warp.py +134 -78
  23. warpkit-1.2.2/warpkit/scripts/medic.py +225 -0
  24. warpkit-1.2.2/warpkit/scripts/unwrap_phase.py +178 -0
  25. warpkit-1.2.0/compose.yml +0 -5
  26. warpkit-1.2.0/warpkit/scripts/medic.py +0 -171
  27. warpkit-1.2.0/warpkit/scripts/unwrap_phase.py +0 -152
  28. {warpkit-1.2.0 → warpkit-1.2.2}/.gitignore +0 -0
  29. {warpkit-1.2.0 → warpkit-1.2.2}/.pre-commit-config.yaml +0 -0
  30. {warpkit-1.2.0 → warpkit-1.2.2}/.python-version +0 -0
  31. {warpkit-1.2.0 → warpkit-1.2.2}/.vscode/c_cpp_properties.json +0 -0
  32. {warpkit-1.2.0 → warpkit-1.2.2}/CMakeLists.txt +0 -0
  33. {warpkit-1.2.0 → warpkit-1.2.2}/Dockerfile +0 -0
  34. {warpkit-1.2.0 → warpkit-1.2.2}/codecov +0 -0
  35. {warpkit-1.2.0 → warpkit-1.2.2}/codecov.SHA256SUM +0 -0
  36. {warpkit-1.2.0 → warpkit-1.2.2}/codecov.SHA256SUM.sig +0 -0
  37. {warpkit-1.2.0 → warpkit-1.2.2}/include/itk/itkModifiedInvertDisplacementFieldImageFilter.h +0 -0
  38. {warpkit-1.2.0 → warpkit-1.2.2}/include/itk/itkModifiedInvertDisplacementFieldImageFilter.hxx +0 -0
  39. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/LICENSE +0 -0
  40. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/README.md +0 -0
  41. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/algorithm.h +0 -0
  42. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/priority_queue.h +0 -0
  43. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/romeo.h +0 -0
  44. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/seed.h +0 -0
  45. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/unwrap.h +0 -0
  46. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/utility.h +0 -0
  47. {warpkit-1.2.0 → warpkit-1.2.2}/include/romeo/volume_view.h +0 -0
  48. {warpkit-1.2.0 → warpkit-1.2.2}/include/utilities.h +0 -0
  49. {warpkit-1.2.0 → warpkit-1.2.2}/notes/ROMEO_port_plan.md +0 -0
  50. {warpkit-1.2.0 → warpkit-1.2.2}/notes/fmap.png +0 -0
  51. {warpkit-1.2.0 → warpkit-1.2.2}/notes/phase.png +0 -0
  52. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/hooks/hook-warpkit.py +0 -0
  53. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/launchers/wk-apply-warp.py +0 -0
  54. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/launchers/wk-compute-fieldmap.py +0 -0
  55. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/launchers/wk-compute-jacobian.py +0 -0
  56. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/launchers/wk-convert-fieldmap.py +0 -0
  57. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/launchers/wk-convert-warp.py +0 -0
  58. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/launchers/wk-medic.py +0 -0
  59. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/launchers/wk-unwrap-phase.py +0 -0
  60. {warpkit-1.2.0 → warpkit-1.2.2}/packaging/pyinstaller/warpkit.spec +0 -0
  61. {warpkit-1.2.0 → warpkit-1.2.2}/scripts/check-gitmoji.sh +0 -0
  62. {warpkit-1.2.0 → warpkit-1.2.2}/scripts/regen-stub.sh +0 -0
  63. {warpkit-1.2.0 → warpkit-1.2.2}/tests/conftest.py +0 -0
  64. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/romeo/Mag.nii +0 -0
  65. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/romeo/Phase.nii +0 -0
  66. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-1_part-mag_bold.json +0 -0
  67. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-1_part-mag_bold.nii.gz +0 -0
  68. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-1_part-phase_bold.json +0 -0
  69. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-1_part-phase_bold.nii.gz +0 -0
  70. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-2_part-mag_bold.json +0 -0
  71. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-2_part-mag_bold.nii.gz +0 -0
  72. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-2_part-phase_bold.json +0 -0
  73. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-2_part-phase_bold.nii.gz +0 -0
  74. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-3_part-mag_bold.json +0 -0
  75. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-3_part-mag_bold.nii.gz +0 -0
  76. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-3_part-phase_bold.json +0 -0
  77. {warpkit-1.2.0 → warpkit-1.2.2}/tests/data/test_data/sub-a01_task-rest_acq-tr1800_echo-3_part-phase_bold.nii.gz +0 -0
  78. {warpkit-1.2.0 → warpkit-1.2.2}/tests/test_concurrency.py +0 -0
  79. {warpkit-1.2.0 → warpkit-1.2.2}/tests/test_distortion.py +0 -0
  80. {warpkit-1.2.0 → warpkit-1.2.2}/tests/test_model.py +0 -0
  81. {warpkit-1.2.0 → warpkit-1.2.2}/tests/test_romeo.py +0 -0
  82. {warpkit-1.2.0 → warpkit-1.2.2}/tests/test_unwrap.py +0 -0
  83. {warpkit-1.2.0 → warpkit-1.2.2}/tests/test_utilities.py +0 -0
  84. {warpkit-1.2.0 → warpkit-1.2.2}/tests/test_version.py +0 -0
  85. {warpkit-1.2.0 → warpkit-1.2.2}/uv.lock +0 -0
  86. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/__init__.py +0 -0
  87. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/concurrency.py +0 -0
  88. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/distortion.py +0 -0
  89. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/model.py +0 -0
  90. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/py.typed +0 -0
  91. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/scripts/__init__.py +0 -0
  92. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/unwrap.py +0 -0
  93. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/utilities.py +0 -0
  94. {warpkit-1.2.0 → warpkit-1.2.2}/warpkit/warpkit_cpp.pyi +0 -0
@@ -41,7 +41,8 @@ jobs:
41
41
  {version: '3.11', glob: cp311*},
42
42
  {version: '3.12', glob: cp312*},
43
43
  {version: '3.13', glob: cp313*},
44
- {version: '3.14', glob: cp314*}
44
+ {version: '3.14', glob: cp314-*},
45
+ {version: '3.14t', glob: cp314t*}
45
46
  ]
46
47
  name: Python ${{ matrix.python-versions.version }} wheel on ${{ matrix.os }}
47
48
  runs-on: ${{ matrix.os }}
@@ -10,7 +10,7 @@ DIstortion Correction). Phase unwrapping uses a self-contained C++17 port of
10
10
  **no Julia runtime is involved**; do not reintroduce one (no `julia.py`, no
11
11
  conda recipe, no `FindJulia.cmake`).
12
12
 
13
- Pre-print: <https://www.biorxiv.org/content/10.1101/2023.11.28.568744v1>.
13
+ Paper (Imaging Neuroscience): <https://doi.org/10.1162/IMAG.a.1262>.
14
14
 
15
15
  ## Layout
16
16
 
@@ -131,12 +131,15 @@ and call out anything CI-relevant (wheel matrix, pybind11 ABI, ITK).
131
131
 
132
132
  ## CI specifics
133
133
 
134
- GitHub Actions builds wheels for Python 3.11–3.14 on `ubuntu-latest`,
134
+ GitHub Actions builds wheels for Python 3.11–3.14 (both standard and free-threaded) on `ubuntu-latest`,
135
135
  `ubuntu-24.04-arm`, and `macos-latest` via cibuildwheel.
136
- `pyproject.toml`'s `[tool.cibuildwheel]` skips `*musllinux*` and the
137
- `cp314t-*` free-threaded build; re-enabling free-threaded support requires
138
- auditing the pybind11 + ITK code paths for the no-GIL ABI. The sdist job
139
- also runs `uv run coverage run` then `coverage report -m` — keep coverage
140
- healthy when adding code (the `[tool.coverage.report]` config in
136
+ `pyproject.toml`'s `[tool.cibuildwheel]` skips only `*musllinux*` builds.
137
+ Free-threaded support is enabled via `py::mod_gil_not_used()` in
138
+ `src/warpkit.cpp`. `PyErr_CheckSignals()` calls in `include/warps.h` are
139
+ wrapped in a `WARPKIT_CHECK_SIGNALS()` macro that compiles to a no-op under
140
+ `Py_GIL_DISABLED`, so GIL builds keep Ctrl+C interruptibility during long ITK
141
+ operations and the free-threaded build skips the GIL-requiring check. The
142
+ sdist job also runs `uv run coverage run` then `coverage report -m` — keep
143
+ coverage healthy when adding code (the `[tool.coverage.report]` config in
141
144
  `pyproject.toml` omits the test files). PyPI publish and the GHCR Docker
142
145
  image only run on a published GitHub release.
@@ -1,7 +1,10 @@
1
1
  Copyright (c) 2022 Washington University.
2
2
  Authored by: Andrew Van
3
3
 
4
- Please cite https://www.biorxiv.org/content/10.1101/2023.11.28.568744v1
4
+ Please cite:
5
+ Van AN, Montez DF, Laumann TO, Cho PN, Suljic V, Madison T, et al.
6
+ Frame-wise multi-echo distortion correction for superior functional MRI.
7
+ Imaging Neuroscience 2026; 4 IMAG.a.1262. doi: https://doi.org/10.1162/IMAG.a.1262
5
8
 
6
9
  warpkit is free for non-commercial use by academic, government,
7
10
  and non-profit/not-for-profit institutions. A commercial version of the software is
@@ -1,11 +1,14 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: warpkit
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: A python library for neuroimaging transformations
5
5
  Keywords: neuroimaging
6
6
  Author-Email: Andrew Van <vanandrew77@gmail.com>
7
+ License-File: LICENSE
8
+ License-File: include/romeo/LICENSE
7
9
  Classifier: Programming Language :: Python :: 3
8
10
  Project-URL: github, https://github.com/vanandrew/warpkit
11
+ Project-URL: paper, https://doi.org/10.1162/IMAG.a.1262
9
12
  Requires-Python: >=3.11
10
13
  Requires-Dist: nibabel>=4.0.2
11
14
  Requires-Dist: numpy>=1.23.3
@@ -19,10 +22,9 @@ Description-Content-Type: text/markdown
19
22
 
20
23
  [![Build](https://github.com/vanandrew/warpkit/actions/workflows/build.yml/badge.svg)](https://github.com/vanandrew/warpkit/actions)
21
24
  [![PyPI](https://img.shields.io/pypi/v/warpkit)](https://pypi.org/project/warpkit/)
22
- [![docker](https://ghcr-badge.egpl.dev/vanandrew/warpkit/latest_tag?trim=major&label=ghcr&nbsp;latest)](https://github.com/vanandrew/warpkit/pkgs/container/warpkit)
23
25
  [![codecov](https://codecov.io/gh/vanandrew/warpkit/graph/badge.svg?token=S6ZZKOAF8V)](https://codecov.io/gh/vanandrew/warpkit)
24
26
 
25
- A Python library for neuroimaging transforms, focused on the Multi-Echo DIstortion Correction (MEDIC) algorithm. The pre-print is available at <https://www.biorxiv.org/content/10.1101/2023.11.28.568744v1>.
27
+ A Python library for neuroimaging transforms, focused on the Multi-Echo DIstortion Correction (MEDIC) algorithm. The paper is published in *Imaging Neuroscience* at <https://doi.org/10.1162/IMAG.a.1262>.
26
28
 
27
29
  The phase-unwrapping core is a self-contained C++17 port of [ROMEO](https://github.com/korbinian90/ROMEO) — there is no Julia runtime to install, and binary wheels ship with the ITK pieces statically linked. If you used an older release of warpkit that required `julia` on `PATH`, that step is gone.
28
30
 
@@ -211,6 +213,15 @@ wk-convert-fieldmap \
211
213
  --output sub-01_run-01_fieldmap.nii
212
214
  ```
213
215
 
216
+ ## Citation
217
+
218
+ If you use warpkit, please cite:
219
+
220
+ > Van AN, Montez DF, Laumann TO, Cho PN, Suljic V, Madison T, et al.
221
+ > Frame-wise multi-echo distortion correction for superior functional MRI.
222
+ > *Imaging Neuroscience* 2026; 4 IMAG.a.1262.
223
+ > doi: <https://doi.org/10.1162/IMAG.a.1262>
224
+
214
225
  ## Authors
215
226
 
216
227
  Vahdeta Suljic &lt;suljic@wustl.edu&gt;, Andrew Van &lt;vanandrew77@gmail.com&gt;
@@ -2,10 +2,9 @@
2
2
 
3
3
  [![Build](https://github.com/vanandrew/warpkit/actions/workflows/build.yml/badge.svg)](https://github.com/vanandrew/warpkit/actions)
4
4
  [![PyPI](https://img.shields.io/pypi/v/warpkit)](https://pypi.org/project/warpkit/)
5
- [![docker](https://ghcr-badge.egpl.dev/vanandrew/warpkit/latest_tag?trim=major&label=ghcr&nbsp;latest)](https://github.com/vanandrew/warpkit/pkgs/container/warpkit)
6
5
  [![codecov](https://codecov.io/gh/vanandrew/warpkit/graph/badge.svg?token=S6ZZKOAF8V)](https://codecov.io/gh/vanandrew/warpkit)
7
6
 
8
- A Python library for neuroimaging transforms, focused on the Multi-Echo DIstortion Correction (MEDIC) algorithm. The pre-print is available at <https://www.biorxiv.org/content/10.1101/2023.11.28.568744v1>.
7
+ A Python library for neuroimaging transforms, focused on the Multi-Echo DIstortion Correction (MEDIC) algorithm. The paper is published in *Imaging Neuroscience* at <https://doi.org/10.1162/IMAG.a.1262>.
9
8
 
10
9
  The phase-unwrapping core is a self-contained C++17 port of [ROMEO](https://github.com/korbinian90/ROMEO) — there is no Julia runtime to install, and binary wheels ship with the ITK pieces statically linked. If you used an older release of warpkit that required `julia` on `PATH`, that step is gone.
11
10
 
@@ -194,6 +193,15 @@ wk-convert-fieldmap \
194
193
  --output sub-01_run-01_fieldmap.nii
195
194
  ```
196
195
 
196
+ ## Citation
197
+
198
+ If you use warpkit, please cite:
199
+
200
+ > Van AN, Montez DF, Laumann TO, Cho PN, Suljic V, Madison T, et al.
201
+ > Frame-wise multi-echo distortion correction for superior functional MRI.
202
+ > *Imaging Neuroscience* 2026; 4 IMAG.a.1262.
203
+ > doi: <https://doi.org/10.1162/IMAG.a.1262>
204
+
197
205
  ## Authors
198
206
 
199
207
  Vahdeta Suljic &lt;suljic@wustl.edu&gt;, Andrew Van &lt;vanandrew77@gmail.com&gt;
@@ -0,0 +1,10 @@
1
+ coverage:
2
+ status:
3
+ project:
4
+ default:
5
+ target: auto
6
+ threshold: 1%
7
+ patch:
8
+ default:
9
+ target: 90%
10
+ threshold: 1%
@@ -48,50 +48,23 @@ inline std::vector<T> voxel_quality(const T* phase4d, std::size_t nx, std::size_
48
48
 
49
49
  std::vector<T> qmap(vol, T(0));
50
50
 
51
- // Forward-edge sum: qmap[v] = Σ_d w[d, v] for the 3 dims.
52
- for (std::size_t idx = 0; idx < vol; ++idx) {
53
- qmap[idx] = w[0u + 3u * idx] + w[1u + 3u * idx] + w[2u + 3u * idx];
54
- }
55
-
56
- // Backward-edge contributions. Julia:
57
- // qmap[2:end,:,:] .+= weights[1, 1:end-1, :, :]
58
- // qmap[:,2:end,:] .+= weights[2, :, 1:end-1, :]
59
- // qmap[:,:,2:end] .+= weights[3, :, :, 1:end-1]
60
- // In 0-based: each interior voxel adds the forward-edge weight of its
61
- // previous neighbor along that dim.
62
- const std::ptrdiff_t sx = 1;
63
- const std::ptrdiff_t sy = static_cast<std::ptrdiff_t>(nx);
64
- const std::ptrdiff_t sz = static_cast<std::ptrdiff_t>(nx * ny);
65
-
51
+ // Single fused pass: each voxel sums its three forward edges plus the
52
+ // forward-edge weight of its previous neighbor along each axis (= the
53
+ // backward-edge contribution from Julia's three .+= expressions).
54
+ // Associativity matches the original four-pass form: forward triplet
55
+ // first, then back-x, then back-y, then back-z.
66
56
  for (std::size_t k = 0; k < nz; ++k) {
67
57
  for (std::size_t j = 0; j < ny; ++j) {
68
- for (std::size_t i = 1; i < nx; ++i) {
69
- const std::size_t idx = i + nx * (j + ny * k);
70
- const std::size_t prev = idx - static_cast<std::size_t>(sx);
71
- qmap[idx] += w[0u + 3u * prev];
72
- }
73
- }
74
- }
75
- for (std::size_t k = 0; k < nz; ++k) {
76
- for (std::size_t j = 1; j < ny; ++j) {
77
58
  for (std::size_t i = 0; i < nx; ++i) {
78
59
  const std::size_t idx = i + nx * (j + ny * k);
79
- const std::size_t prev = idx - static_cast<std::size_t>(sy);
80
- qmap[idx] += w[1u + 3u * prev];
60
+ T q = w[0u + 3u * idx] + w[1u + 3u * idx] + w[2u + 3u * idx];
61
+ if (i > 0) q += w[0u + 3u * (idx - 1)];
62
+ if (j > 0) q += w[1u + 3u * (idx - nx)];
63
+ if (k > 0) q += w[2u + 3u * (idx - nx * ny)];
64
+ qmap[idx] = q / T(6);
81
65
  }
82
66
  }
83
67
  }
84
- for (std::size_t k = 1; k < nz; ++k) {
85
- for (std::size_t j = 0; j < ny; ++j) {
86
- for (std::size_t i = 0; i < nx; ++i) {
87
- const std::size_t idx = i + nx * (j + ny * k);
88
- const std::size_t prev = idx - static_cast<std::size_t>(sz);
89
- qmap[idx] += w[2u + 3u * prev];
90
- }
91
- }
92
- }
93
-
94
- for (std::size_t i = 0; i < vol; ++i) qmap[i] /= T(6);
95
68
  return qmap;
96
69
  }
97
70
 
@@ -119,91 +119,65 @@ inline std::vector<OutT> calculate_weights_romeo_impl(const T* phase, std::size_
119
119
  if (phase2 == nullptr || TEs == nullptr) flags.phase_gradient_coherence = false;
120
120
 
121
121
  const std::size_t n = nx * ny * nz;
122
- const std::ptrdiff_t stride[3] = {1, static_cast<std::ptrdiff_t>(nx), static_cast<std::ptrdiff_t>(nx * ny)};
123
122
  const std::ptrdiff_t signed_n = static_cast<std::ptrdiff_t>(n);
124
-
125
- // ROMEO's `parsekwargs` multiplies mag by mask when both are present. We
126
- // do the same pre-pass so `maxmag` is computed over the masked region.
127
- std::vector<T> masked_mag;
128
- const T* mag_in_use = mag;
129
- if (mag != nullptr && mask != nullptr) {
130
- masked_mag.assign(mag, mag + n);
131
- for (std::size_t i = 0; i < n; ++i) {
132
- if (!mask[i]) masked_mag[i] = T(0);
133
- }
134
- mag_in_use = masked_mag.data();
135
- }
136
-
137
- // maxmag: `maximum(mag[isfinite.(mag)])` — infinities and NaNs excluded.
138
- T maxmag = T(0);
139
- bool has_finite_mag = false;
140
- if (mag_in_use != nullptr) {
141
- for (std::size_t i = 0; i < n; ++i) {
142
- T v = mag_in_use[i];
143
- if (std::isfinite(v) && (!has_finite_mag || v > maxmag)) {
144
- maxmag = v;
145
- has_finite_mag = true;
146
- }
147
- }
148
- }
149
- (void)maxmag; // only needed once magweight/magweight2 (flags 5-6) are ported.
150
- (void)has_finite_mag;
123
+ const std::ptrdiff_t sx = 1;
124
+ const std::ptrdiff_t sy = static_cast<std::ptrdiff_t>(nx);
125
+ const std::ptrdiff_t sz = static_cast<std::ptrdiff_t>(nx * ny);
151
126
 
152
127
  std::vector<OutT> weights(3 * n, zero_value);
153
128
 
154
- for (int dim = 0; dim < 3; ++dim) {
155
- const std::ptrdiff_t step = stride[dim];
156
- for (std::ptrdiff_t idx = 0; idx < signed_n; ++idx) {
157
- const std::ptrdiff_t jdx = idx + step;
158
- if (jdx >= signed_n) continue;
159
- if (mask != nullptr && !mask[idx]) continue;
129
+ // Mirrors ROMEO's `mag .* mask` pre-pass without materializing a copy: any
130
+ // masked-out voxel reads as 0 magnitude.
131
+ auto mag_at = [&](std::ptrdiff_t i) -> T {
132
+ if (mask != nullptr && !mask[i]) return T(0);
133
+ return mag[i];
134
+ };
160
135
 
161
- T w = T(1);
162
- if (flags.phase_coherence) {
163
- w *= T(0.1) + T(0.9) * detail::phase_coherence(phase[idx], phase[jdx]);
164
- }
165
- if (flags.phase_gradient_coherence) {
166
- w *= T(0.1) +
167
- T(0.9) * detail::phase_gradient_coherence(phase[idx], phase[jdx], phase2[idx], phase2[jdx],
168
- TEs[0], TEs[1]);
169
- }
170
- if (flags.phase_linearity) {
171
- w *= T(0.1) + T(0.9) * detail::phase_linearity(phase, idx, jdx, step, signed_n);
172
- }
173
- if (mag_in_use != nullptr) {
174
- T mi = mag_in_use[idx];
175
- T mj = mag_in_use[jdx];
176
- T small = std::min(mi, mj);
177
- T big = std::max(mi, mj);
178
- if (flags.mag_coherence) {
179
- w *= T(0.1) + T(0.9) * detail::mag_coherence(small, big);
180
- }
181
- // flags 5-6 (magweight, magweight2) not ported — warpkit uses :romeo, not :romeo6.
136
+ auto edge_weight = [&](std::ptrdiff_t idx, std::ptrdiff_t jdx, std::ptrdiff_t stride) -> T {
137
+ T w = T(1);
138
+ if (flags.phase_coherence) {
139
+ w *= T(0.1) + T(0.9) * detail::phase_coherence(phase[idx], phase[jdx]);
140
+ }
141
+ if (flags.phase_gradient_coherence) {
142
+ w *= T(0.1) + T(0.9) * detail::phase_gradient_coherence(phase[idx], phase[jdx], phase2[idx], phase2[jdx],
143
+ TEs[0], TEs[1]);
144
+ }
145
+ if (flags.phase_linearity) {
146
+ w *= T(0.1) + T(0.9) * detail::phase_linearity(phase, idx, jdx, stride, signed_n);
147
+ }
148
+ if (mag != nullptr) {
149
+ T mi = mag_at(idx);
150
+ T mj = mag_at(jdx);
151
+ T small = std::min(mi, mj);
152
+ T big = std::max(mi, mj);
153
+ if (flags.mag_coherence) {
154
+ w *= T(0.1) + T(0.9) * detail::mag_coherence(small, big);
182
155
  }
183
-
184
- // Column-major (3, nx, ny, nz): index = dim + 3*idx
185
- weights[static_cast<std::size_t>(dim) + 3u * static_cast<std::size_t>(idx)] = rescale_fn(w);
156
+ // flags 5-6 (magweight, magweight2) not ported — warpkit uses :romeo, not :romeo6.
186
157
  }
187
- }
158
+ return w;
159
+ };
188
160
 
189
- // Zero out the boundary edges that don't actually exist (Julia:
190
- // weights[1,end,:,:] .= 0 / [2,:,end,:] .= 0 / [3,:,:,end] .= 0).
161
+ // One pass over voxels in (k, j, i) order. Boundary edges are skipped by
162
+ // construction via the `+1 < n*` guards, so the trailing zero-fill pass
163
+ // from the Julia source is unnecessary — the value-init of `weights`
164
+ // already leaves those slots at `zero_value`.
191
165
  for (std::size_t k = 0; k < nz; ++k) {
192
166
  for (std::size_t j = 0; j < ny; ++j) {
193
- std::size_t idx = (nx - 1) + nx * (j + ny * k);
194
- weights[0u + 3u * idx] = zero_value;
195
- }
196
- }
197
- for (std::size_t k = 0; k < nz; ++k) {
198
- for (std::size_t i = 0; i < nx; ++i) {
199
- std::size_t idx = i + nx * ((ny - 1) + ny * k);
200
- weights[1u + 3u * idx] = zero_value;
201
- }
202
- }
203
- for (std::size_t j = 0; j < ny; ++j) {
204
- for (std::size_t i = 0; i < nx; ++i) {
205
- std::size_t idx = i + nx * (j + ny * (nz - 1));
206
- weights[2u + 3u * idx] = zero_value;
167
+ for (std::size_t i = 0; i < nx; ++i) {
168
+ const std::size_t idx = i + nx * (j + ny * k);
169
+ if (mask != nullptr && !mask[idx]) continue;
170
+ const std::ptrdiff_t sidx = static_cast<std::ptrdiff_t>(idx);
171
+ if (i + 1 < nx) {
172
+ weights[0u + 3u * idx] = rescale_fn(edge_weight(sidx, sidx + sx, sx));
173
+ }
174
+ if (j + 1 < ny) {
175
+ weights[1u + 3u * idx] = rescale_fn(edge_weight(sidx, sidx + sy, sy));
176
+ }
177
+ if (k + 1 < nz) {
178
+ weights[2u + 3u * idx] = rescale_fn(edge_weight(sidx, sidx + sz, sz));
179
+ }
180
+ }
207
181
  }
208
182
  }
209
183
 
@@ -19,6 +19,18 @@
19
19
 
20
20
  namespace py = pybind11;
21
21
 
22
+ // PyErr_CheckSignals() needs the GIL; free-threaded builds (Py_GIL_DISABLED)
23
+ // declare `py::mod_gil_not_used()` and skip the check, trading Ctrl+C
24
+ // interruptibility during long ITK operations for no-GIL execution.
25
+ #ifdef Py_GIL_DISABLED
26
+ #define WARPKIT_CHECK_SIGNALS() ((void)0)
27
+ #else
28
+ #define WARPKIT_CHECK_SIGNALS() \
29
+ do { \
30
+ if (PyErr_CheckSignals() != 0) throw py::error_already_set(); \
31
+ } while (0)
32
+ #endif
33
+
22
34
  /**
23
35
  * @brief Invert a displacement map
24
36
  *
@@ -139,9 +151,9 @@ py::array_t<T, py::array::f_style> invert_displacement_map(py::array_t<T, py::ar
139
151
 
140
152
  // Get output
141
153
  typename DisplacementMapType::Pointer inv_map = identity_filter->GetOutput();
142
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
154
+ WARPKIT_CHECK_SIGNALS();
143
155
  inv_map->Update();
144
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
156
+ WARPKIT_CHECK_SIGNALS();
145
157
  itk::ImageRegionConstIteratorWithIndex<DisplacementMapType> inv_map_it(inv_map,
146
158
  inv_map->GetLargestPossibleRegion());
147
159
  py::array_t<T, py::array::f_style> inverted_displacement_map(displacement_map);
@@ -170,7 +182,7 @@ py::array_t<T, py::array::f_style> invert_displacement_field(py::array_t<T, py::
170
182
  py::array_t<T, py::array::f_style> direction,
171
183
  py::array_t<T, py::array::f_style> spacing,
172
184
  ssize_t iterations, bool verbose) {
173
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
185
+ WARPKIT_CHECK_SIGNALS();
174
186
 
175
187
  // Get the displacement field shape
176
188
  const ssize_t* shape = displacement_field.shape();
@@ -227,9 +239,9 @@ py::array_t<T, py::array::f_style> invert_displacement_field(py::array_t<T, py::
227
239
 
228
240
  // Get output
229
241
  typename DisplacementFieldType::Pointer inv_field = invert_displacement_filter->GetOutput();
230
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
242
+ WARPKIT_CHECK_SIGNALS();
231
243
  inv_field->Update();
232
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
244
+ WARPKIT_CHECK_SIGNALS();
233
245
  itk::ImageRegionConstIteratorWithIndex<DisplacementFieldType> inv_field_it(inv_field,
234
246
  inv_field->GetLargestPossibleRegion());
235
247
  py::array_t<T, py::array::f_style> inverted_displacement_field(displacement_field);
@@ -261,7 +273,7 @@ py::array_t<T, py::array::f_style> compute_jacobian_determinant(py::array_t<T, p
261
273
  py::array_t<T, py::array::f_style> origin,
262
274
  py::array_t<T, py::array::f_style> direction,
263
275
  py::array_t<T, py::array::f_style> spacing) {
264
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
276
+ WARPKIT_CHECK_SIGNALS();
265
277
 
266
278
  // Get the displacement field shape
267
279
  const ssize_t* shape = displacement_field.shape();
@@ -315,9 +327,9 @@ py::array_t<T, py::array::f_style> compute_jacobian_determinant(py::array_t<T, p
315
327
  // Get the jacobian determinant fields
316
328
  typename DisplacementFieldJacobianDeterminantFilterType::OutputImagePointer jacobian_determinant =
317
329
  jacobian_filter->GetOutput();
318
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
330
+ WARPKIT_CHECK_SIGNALS();
319
331
  jacobian_determinant->Update();
320
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
332
+ WARPKIT_CHECK_SIGNALS();
321
333
 
322
334
  // Convert to numpy array
323
335
  py::array_t<T, py::array::f_style> jacobian_determinant_array({shape[0], shape[1], shape[2]});
@@ -461,9 +473,9 @@ py::array_t<T, py::array::f_style> resample(
461
473
 
462
474
  // Get the output
463
475
  typename OutputImageType::Pointer output_image = warp_filter->GetOutput();
464
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
476
+ WARPKIT_CHECK_SIGNALS();
465
477
  output_image->Update();
466
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
478
+ WARPKIT_CHECK_SIGNALS();
467
479
  itk::ImageRegionConstIteratorWithIndex<OutputImageType> output_iterator(output_image,
468
480
  output_image->GetLargestPossibleRegion());
469
481
  py::array_t<T, py::array::f_style> output_array({output_shape.at(0), output_shape.at(1), output_shape.at(2)});
@@ -481,7 +493,7 @@ T compute_hausdorff_distance(
481
493
  py::array_t<T, py::array::f_style> image1_direction, py::array_t<T, py::array::f_style> image1_spacing,
482
494
  py::array_t<T, py::array::f_style> image2, py::array_t<T, py::array::f_style> image2_origin,
483
495
  py::array_t<T, py::array::f_style> image2_direction, py::array_t<T, py::array::f_style> image2_spacing) {
484
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
496
+ WARPKIT_CHECK_SIGNALS();
485
497
 
486
498
  // Setup types
487
499
  using ImageType = typename itk::Image<T, 3>;
@@ -545,7 +557,7 @@ T compute_hausdorff_distance(
545
557
  // Get the hausdorff distance
546
558
  hausdorff_filter->Update();
547
559
  T hausdorff_distance = hausdorff_filter->GetAverageHausdorffDistance();
548
- if (PyErr_CheckSignals() != 0) throw py::error_already_set();
560
+ WARPKIT_CHECK_SIGNALS();
549
561
 
550
562
  // Return the hausdorff distance
551
563
  return hausdorff_distance;
@@ -102,6 +102,20 @@ def write_readme(bundle: Path, version: str, target: str) -> None:
102
102
  (bundle / "README.md").write_text(body, encoding="utf-8")
103
103
 
104
104
 
105
+ def write_licenses(bundle: Path) -> None:
106
+ # Bundle warpkit's own LICENSE plus the upstream ROMEO MIT notice. The
107
+ # ROMEO notice is required: the wk-* binaries link in compiled code derived
108
+ # from include/romeo/, which the MIT terms require we ship the notice with.
109
+ repo_root = Path(__file__).resolve().parents[2]
110
+ licenses_dir = bundle / "LICENSES"
111
+ licenses_dir.mkdir(exist_ok=True)
112
+ shutil.copy2(repo_root / "LICENSE", licenses_dir / "warpkit.LICENSE")
113
+ shutil.copy2(
114
+ repo_root / "include" / "romeo" / "LICENSE",
115
+ licenses_dir / "ROMEO.LICENSE",
116
+ )
117
+
118
+
105
119
  def make_zip(bundle: Path, out_zip: Path) -> None:
106
120
  # shutil.make_archive's base_dir keeps a tidy top-level folder inside the zip.
107
121
  base_name = str(out_zip.with_suffix(""))
@@ -144,6 +158,7 @@ def main() -> int:
144
158
 
145
159
  adhoc_sign_macos(versioned)
146
160
  write_readme(versioned, version, target)
161
+ write_licenses(versioned)
147
162
 
148
163
  out_zip = dist / f"warpkit-{version}-{target}.zip"
149
164
  make_zip(versioned, out_zip)
@@ -47,3 +47,9 @@ xattr -r -d com.apple.quarantine /opt/warpkit-@VERSION@
47
47
  - `wk-compute-jacobian` — compute the Jacobian determinant of a warp
48
48
 
49
49
  Run any of them with `--help` for usage.
50
+
51
+ ## Licenses
52
+
53
+ `LICENSES/warpkit.LICENSE` is the warpkit license. `LICENSES/ROMEO.LICENSE` is
54
+ the upstream MIT notice for the ROMEO phase-unwrapping algorithms compiled
55
+ into these binaries.
@@ -6,7 +6,11 @@ requires-python = ">=3.11"
6
6
  authors = [{ name = "Andrew Van", email = "vanandrew77@gmail.com" }]
7
7
  keywords = ["neuroimaging"]
8
8
  classifiers = ["Programming Language :: Python :: 3"]
9
- urls = { github = "https://github.com/vanandrew/warpkit" }
9
+ urls = { github = "https://github.com/vanandrew/warpkit", paper = "https://doi.org/10.1162/IMAG.a.1262" }
10
+ # include/romeo/LICENSE is the upstream MIT notice for the ROMEO C++ port; it
11
+ # travels with the headers in source distributions and (via PEP 639
12
+ # license-files) gets bundled into wheels under *.dist-info/licenses/.
13
+ license-files = ["LICENSE", "include/romeo/LICENSE"]
10
14
  dynamic = ["version"]
11
15
  dependencies = [
12
16
  "nibabel >= 4.0.2",
@@ -118,9 +122,10 @@ command_line = "-m pytest"
118
122
  omit = ["tests/*"]
119
123
 
120
124
  [tool.cibuildwheel]
121
- # Skip musllinux (we ship manylinux only) and free-threaded CPython builds
122
- # (cp31Xt-*) our pybind11 bindings + ITK haven't been audited for the no-GIL ABI.
123
- skip = "*musllinux* cp314t-*"
125
+ # Skip musllinux (we ship manylinux only). Free-threaded (cp31Xt-*) builds
126
+ # are kept: warps.h guards PyErr_CheckSignals() behind WARPKIT_CHECK_SIGNALS,
127
+ # which is a no-op under Py_GIL_DISABLED.
128
+ skip = "*musllinux*"
124
129
  build-frontend = "build"
125
130
  build-verbosity = 3
126
131
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  namespace py = pybind11;
7
7
 
8
- PYBIND11_MODULE(warpkit_cpp, m) {
8
+ PYBIND11_MODULE(warpkit_cpp, m, py::mod_gil_not_used()) {
9
9
  m.def("calculate_weights", &romeo::calculate_weights<float>,
10
10
  "ROMEO edge-weight map (3, nx, ny, nz) uint8. Exposed for port validation; not used by warpkit.",
11
11
  py::arg("phase"),
@@ -600,6 +600,91 @@ def test_unwrap_phase_noiseframes_consumes_all_frames(argv, capsys, tmp_path):
600
600
  assert "0 frames" in err
601
601
 
602
602
 
603
+ def test_unwrap_phase_metadata_accepts_echotime_only(argv, capsys, tmp_path):
604
+ """``wk-unwrap-phase`` only needs EchoTime; sidecars without
605
+ TotalReadoutTime / PhaseEncodingDirection must work. A mismatched phase
606
+ count forces a clean parser.error *after* the metadata loader resolves —
607
+ if the loader still required TRT/PED we'd see a KeyError instead."""
608
+ sidecar = tmp_path / "m1.json"
609
+ sidecar.write_text(json.dumps({"EchoTime": 0.014}))
610
+ argv(
611
+ [
612
+ "wk-unwrap-phase",
613
+ "--magnitude",
614
+ "m.nii",
615
+ "--phase",
616
+ "p1.nii",
617
+ "p2.nii",
618
+ "--metadata",
619
+ str(sidecar),
620
+ "--out-prefix",
621
+ str(tmp_path / "out"),
622
+ ]
623
+ )
624
+ with pytest.raises(SystemExit) as exc:
625
+ unwrap_phase_main()
626
+ assert exc.value.code == 2
627
+ err = capsys.readouterr().err
628
+ assert "must match" in err
629
+
630
+
631
+ def test_unwrap_phase_metadata_missing_echotime(argv, capsys, tmp_path):
632
+ """A sidecar without EchoTime must surface as a clean parser error, not a
633
+ KeyError from the metadata loader."""
634
+ sidecar = tmp_path / "m1.json"
635
+ sidecar.write_text(json.dumps({}))
636
+ argv(
637
+ [
638
+ "wk-unwrap-phase",
639
+ "--magnitude",
640
+ "m.nii",
641
+ "--phase",
642
+ "p.nii",
643
+ "--metadata",
644
+ str(sidecar),
645
+ "--out-prefix",
646
+ str(tmp_path / "out"),
647
+ ]
648
+ )
649
+ with pytest.raises(SystemExit) as exc:
650
+ unwrap_phase_main()
651
+ assert exc.value.code == 2
652
+ err = capsys.readouterr().err
653
+ assert "EchoTime" in err
654
+
655
+
656
+ def test_medic_noiseframes_consumes_all_frames(argv, capsys, tmp_path):
657
+ """``-f`` >= n_frames now raises a clean parser error in medic too: the
658
+ check moved into ``trim_noise_frames`` so every caller is protected from
659
+ silently producing an empty 4D series."""
660
+ mag = _write_nifti(tmp_path / "m.nii", (4, 4, 4, 5))
661
+ phase = _write_nifti(tmp_path / "p.nii", (4, 4, 4, 5))
662
+ argv(
663
+ [
664
+ "wk-medic",
665
+ "--magnitude",
666
+ mag,
667
+ "--phase",
668
+ phase,
669
+ "--TEs",
670
+ "14.0",
671
+ "--total-readout-time",
672
+ "0.05",
673
+ "--phase-encoding-direction",
674
+ "j",
675
+ "--out-prefix",
676
+ str(tmp_path / "out"),
677
+ "-f",
678
+ "5",
679
+ ]
680
+ )
681
+ with pytest.raises(SystemExit) as exc:
682
+ medic_main()
683
+ assert exc.value.code == 2
684
+ err = capsys.readouterr().err
685
+ assert "0 frames" in err
686
+
687
+
603
688
  # ---------------------------------------------------------------------------
604
689
  # compute_fieldmap --help / argument validation
605
690
  # ---------------------------------------------------------------------------
@@ -1693,14 +1778,12 @@ def test_bundle_frames_to_3d_series_clears_vector_intent():
1693
1778
 
1694
1779
  def test_write_output_per_frame_map_clears_vector_intent(tmp_path):
1695
1780
  """Per-frame map outputs must round-trip without a stale vector intent."""
1696
- import argparse
1697
-
1698
1781
  from warpkit.scripts._warp_io import write_output
1699
1782
 
1700
1783
  frames = [_vector_intent_frame() for _ in range(2)]
1701
1784
  out_paths = [str(tmp_path / "f1.nii"), str(tmp_path / "f2.nii")]
1702
1785
 
1703
- write_output(frames, out_paths, "map", argparse.ArgumentParser())
1786
+ write_output(frames, out_paths, "map")
1704
1787
 
1705
1788
  for p in out_paths:
1706
1789
  loaded = _load(p)