odx 0.3.0__tar.gz → 0.3.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 (111) hide show
  1. {odx-0.3.0 → odx-0.3.2}/Cargo.lock +2 -2
  2. {odx-0.3.0 → odx-0.3.2}/Cargo.toml +1 -1
  3. {odx-0.3.0 → odx-0.3.2}/PKG-INFO +3 -1
  4. {odx-0.3.0 → odx-0.3.2}/README.md +3 -1
  5. {odx-0.3.0 → odx-0.3.2}/SPECIFICATION.md +6 -4
  6. {odx-0.3.0 → odx-0.3.2}/docs/cli.md +3 -2
  7. {odx-0.3.0 → odx-0.3.2}/python/README.md +2 -0
  8. {odx-0.3.0 → odx-0.3.2}/python/src/lib.rs +23 -6
  9. {odx-0.3.0 → odx-0.3.2}/python/tests/test_foreign_io.py +37 -0
  10. {odx-0.3.0 → odx-0.3.2}/src/formats/dsistudio.rs +478 -49
  11. {odx-0.3.0 → odx-0.3.2}/src/header.rs +54 -1
  12. {odx-0.3.0 → odx-0.3.2}/src/interop.rs +96 -17
  13. {odx-0.3.0 → odx-0.3.2}/src/io/directory.rs +7 -2
  14. {odx-0.3.0 → odx-0.3.2}/src/io/zip.rs +5 -2
  15. {odx-0.3.0 → odx-0.3.2}/tests/cli.rs +60 -0
  16. {odx-0.3.0 → odx-0.3.2}/tests/pam_io.rs +49 -0
  17. {odx-0.3.0 → odx-0.3.2}/.github/workflows/ci.yml +0 -0
  18. {odx-0.3.0 → odx-0.3.2}/.github/workflows/release.yml +0 -0
  19. {odx-0.3.0 → odx-0.3.2}/.gitignore +0 -0
  20. {odx-0.3.0 → odx-0.3.2}/LICENSE-APACHE +0 -0
  21. {odx-0.3.0 → odx-0.3.2}/LICENSE-MIT +0 -0
  22. {odx-0.3.0 → odx-0.3.2}/LICENSE-MRTRIX +0 -0
  23. {odx-0.3.0 → odx-0.3.2}/LICENSE-NIBABEL +0 -0
  24. {odx-0.3.0 → odx-0.3.2}/TODO.md +0 -0
  25. {odx-0.3.0 → odx-0.3.2}/assets/dsistudio_odf8_faces.txt +0 -0
  26. {odx-0.3.0 → odx-0.3.2}/assets/dsistudio_odf8_vertices.txt +0 -0
  27. {odx-0.3.0 → odx-0.3.2}/benches/dsistudio_io.rs +0 -0
  28. {odx-0.3.0 → odx-0.3.2}/benches/sphere_lookup.rs +0 -0
  29. {odx-0.3.0 → odx-0.3.2}/docs/compare.md +0 -0
  30. {odx-0.3.0 → odx-0.3.2}/docs/dsistudio_mrtrix_conversion_workflows.md +0 -0
  31. {odx-0.3.0 → odx-0.3.2}/docs/fixel_qc.md +0 -0
  32. {odx-0.3.0 → odx-0.3.2}/docs/template.md +0 -0
  33. {odx-0.3.0 → odx-0.3.2}/examples/afd_validate.rs +0 -0
  34. {odx-0.3.0 → odx-0.3.2}/examples/check_rotation.rs +0 -0
  35. {odx-0.3.0 → odx-0.3.2}/examples/convert_dsistudio.rs +0 -0
  36. {odx-0.3.0 → odx-0.3.2}/examples/convert_mrtrix_to_fz.rs +0 -0
  37. {odx-0.3.0 → odx-0.3.2}/examples/inspect_h5.rs +0 -0
  38. {odx-0.3.0 → odx-0.3.2}/odx/__init__.py +0 -0
  39. {odx-0.3.0 → odx-0.3.2}/odx/adapters/__init__.py +0 -0
  40. {odx-0.3.0 → odx-0.3.2}/odx/adapters/dipy.py +0 -0
  41. {odx-0.3.0 → odx-0.3.2}/odx/adapters/nibabel.py +0 -0
  42. {odx-0.3.0 → odx-0.3.2}/odx/spheres.py +0 -0
  43. {odx-0.3.0 → odx-0.3.2}/pyproject.toml +0 -0
  44. {odx-0.3.0 → odx-0.3.2}/python/.cargo/config.toml +0 -0
  45. {odx-0.3.0 → odx-0.3.2}/python/Cargo.toml +0 -0
  46. {odx-0.3.0 → odx-0.3.2}/python/tests/test_basic.py +0 -0
  47. {odx-0.3.0 → odx-0.3.2}/python/tests/test_coherence.py +0 -0
  48. {odx-0.3.0 → odx-0.3.2}/python/tests/test_dipy_adapter.py +0 -0
  49. {odx-0.3.0 → odx-0.3.2}/python/tests/test_graddev.py +0 -0
  50. {odx-0.3.0 → odx-0.3.2}/python/tests/test_nibabel_adapter.py +0 -0
  51. {odx-0.3.0 → odx-0.3.2}/python/tests/test_peak_finder.py +0 -0
  52. {odx-0.3.0 → odx-0.3.2}/setting_up_cs_and_benchmarking.md +0 -0
  53. {odx-0.3.0 → odx-0.3.2}/src/bin/odx.rs +0 -0
  54. {odx-0.3.0 → odx-0.3.2}/src/cli_support.rs +0 -0
  55. {odx-0.3.0 → odx-0.3.2}/src/combine.rs +0 -0
  56. {odx-0.3.0 → odx-0.3.2}/src/compare.rs +0 -0
  57. {odx-0.3.0 → odx-0.3.2}/src/data_array.rs +0 -0
  58. {odx-0.3.0 → odx-0.3.2}/src/densify.rs +0 -0
  59. {odx-0.3.0 → odx-0.3.2}/src/descoteaux_sh.rs +0 -0
  60. {odx-0.3.0 → odx-0.3.2}/src/dtype.rs +0 -0
  61. {odx-0.3.0 → odx-0.3.2}/src/error.rs +0 -0
  62. {odx-0.3.0 → odx-0.3.2}/src/fixel_match.rs +0 -0
  63. {odx-0.3.0 → odx-0.3.2}/src/fmls.rs +0 -0
  64. {odx-0.3.0 → odx-0.3.2}/src/formats/dsistudio_odf8.rs +0 -0
  65. {odx-0.3.0 → odx-0.3.2}/src/formats/mat4.rs +0 -0
  66. {odx-0.3.0 → odx-0.3.2}/src/formats/mif.rs +0 -0
  67. {odx-0.3.0 → odx-0.3.2}/src/formats/mod.rs +0 -0
  68. {odx-0.3.0 → odx-0.3.2}/src/formats/mrtrix.rs +0 -0
  69. {odx-0.3.0 → odx-0.3.2}/src/formats/pam.rs +0 -0
  70. {odx-0.3.0 → odx-0.3.2}/src/formats/pam_repulsion724_hemisphere_f32.bin +0 -0
  71. {odx-0.3.0 → odx-0.3.2}/src/formats/pyafq_aodf.rs +0 -0
  72. {odx-0.3.0 → odx-0.3.2}/src/formats/tortoise_mapmri.rs +0 -0
  73. {odx-0.3.0 → odx-0.3.2}/src/graddev.rs +0 -0
  74. {odx-0.3.0 → odx-0.3.2}/src/icosphere.rs +0 -0
  75. {odx-0.3.0 → odx-0.3.2}/src/io/archive_edit.rs +0 -0
  76. {odx-0.3.0 → odx-0.3.2}/src/io/filename.rs +0 -0
  77. {odx-0.3.0 → odx-0.3.2}/src/io/mod.rs +0 -0
  78. {odx-0.3.0 → odx-0.3.2}/src/lib.rs +0 -0
  79. {odx-0.3.0 → odx-0.3.2}/src/mmap_backing.rs +0 -0
  80. {odx-0.3.0 → odx-0.3.2}/src/mrtrix_sh.rs +0 -0
  81. {odx-0.3.0 → odx-0.3.2}/src/nifti_canon.rs +0 -0
  82. {odx-0.3.0 → odx-0.3.2}/src/nifti_export.rs +0 -0
  83. {odx-0.3.0 → odx-0.3.2}/src/odx_file.rs +0 -0
  84. {odx-0.3.0 → odx-0.3.2}/src/peak_finder.rs +0 -0
  85. {odx-0.3.0 → odx-0.3.2}/src/qc.rs +0 -0
  86. {odx-0.3.0 → odx-0.3.2}/src/reference_affine.rs +0 -0
  87. {odx-0.3.0 → odx-0.3.2}/src/sh_basis_evaluator.rs +0 -0
  88. {odx-0.3.0 → odx-0.3.2}/src/sh_basis_evaluator.rs.orig +0 -0
  89. {odx-0.3.0 → odx-0.3.2}/src/sphere_lookup.rs +0 -0
  90. {odx-0.3.0 → odx-0.3.2}/src/stream.rs +0 -0
  91. {odx-0.3.0 → odx-0.3.2}/src/template.rs +0 -0
  92. {odx-0.3.0 → odx-0.3.2}/src/transform/mod.rs +0 -0
  93. {odx-0.3.0 → odx-0.3.2}/src/transform/resample.rs +0 -0
  94. {odx-0.3.0 → odx-0.3.2}/src/transform/sh_apsf.rs +0 -0
  95. {odx-0.3.0 → odx-0.3.2}/src/transform/source_volume.rs +0 -0
  96. {odx-0.3.0 → odx-0.3.2}/src/transform/upsample.rs +0 -0
  97. {odx-0.3.0 → odx-0.3.2}/src/typed_view.rs +0 -0
  98. {odx-0.3.0 → odx-0.3.2}/src/validate.rs +0 -0
  99. {odx-0.3.0 → odx-0.3.2}/tests/archive_mutation.rs +0 -0
  100. {odx-0.3.0 → odx-0.3.2}/tests/common/mod.rs +0 -0
  101. {odx-0.3.0 → odx-0.3.2}/tests/fibgz_round_trip.rs +0 -0
  102. {odx-0.3.0 → odx-0.3.2}/tests/graddev.rs +0 -0
  103. {odx-0.3.0 → odx-0.3.2}/tests/interop.rs +0 -0
  104. {odx-0.3.0 → odx-0.3.2}/tests/load_options.rs +0 -0
  105. {odx-0.3.0 → odx-0.3.2}/tests/mif_reader.rs +0 -0
  106. {odx-0.3.0 → odx-0.3.2}/tests/mrtrix_io.rs +0 -0
  107. {odx-0.3.0 → odx-0.3.2}/tests/qc.rs +0 -0
  108. {odx-0.3.0 → odx-0.3.2}/tests/round_trip.rs +0 -0
  109. {odx-0.3.0 → odx-0.3.2}/tests/transform.rs +0 -0
  110. {odx-0.3.0 → odx-0.3.2}/tests/transform_mrtrix_compat.rs +0 -0
  111. {odx-0.3.0 → odx-0.3.2}/tests/upsample.rs +0 -0
@@ -1042,7 +1042,7 @@ dependencies = [
1042
1042
 
1043
1043
  [[package]]
1044
1044
  name = "odx-py"
1045
- version = "0.3.0"
1045
+ version = "0.3.2"
1046
1046
  dependencies = [
1047
1047
  "bytemuck",
1048
1048
  "ndarray",
@@ -1054,7 +1054,7 @@ dependencies = [
1054
1054
 
1055
1055
  [[package]]
1056
1056
  name = "odx-rs"
1057
- version = "0.3.0"
1057
+ version = "0.3.2"
1058
1058
  dependencies = [
1059
1059
  "assert_cmd",
1060
1060
  "bytemuck",
@@ -11,7 +11,7 @@ members = [".", "python"]
11
11
  # release workflow has a verify-version step that fails if the tag and
12
12
  # this value disagree.
13
13
  [workspace.package]
14
- version = "0.3.0"
14
+ version = "0.3.2"
15
15
  edition = "2021"
16
16
  # Bulk of the crate is dual MIT OR Apache-2.0 (the typical Rust ecosystem
17
17
  # license). Two files (src/peak_finder.rs, src/mrtrix_sh.rs) port code from
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: odx
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: License :: OSI Approved :: Apache Software License
@@ -69,6 +69,8 @@ peaked.save_mrtrix("mrtrix_out/") # → MRtrix mif (auto-converts basis)
69
69
 
70
70
  # Round-trip back to dipy if you want
71
71
  pam = odx.to_peaks_and_metrics(peaked)
72
+
73
+ wmfod = odx.from_mrtrix("wmfod.mif") # bare SH image (.mif[.gz] or 4-D .nii[.gz])
72
74
  ```
73
75
 
74
76
  ## Features
@@ -39,7 +39,9 @@ Some formats are composite:
39
39
 
40
40
  - MRtrix fixel directories may be paired with `--sh <path>`
41
41
  - MRtrix SH images may be paired with `--fixel-dir <path>`
42
- - DSI Studio `fib.gz` files may use `--reference-affine <mif-or-nifti>`
42
+ - DSI Studio `fib.gz` files may use `--reference-affine <mif-or-nifti>`: the
43
+ DWI the file was reconstructed from, whose affine odx reorders to DSI
44
+ Studio's LPS voxel order
43
45
  - TORTOISE MAP-MRI input (`--input-format tortoise-mapmri-nifti`) pairs a
44
46
  coefficient NIfTI given as `<input>` with `--mapmri-tensor <nifti>` and
45
47
  `--mapmri-uvec <nifti>`
@@ -49,7 +49,7 @@ data.odx/
49
49
 
50
50
  sphere/ # ODF sampling sphere (optional)
51
51
  vertices.3.{dtype} # unit directions (NB_SPHERE_VERTICES x 3)
52
- faces.3.uint32 # triangle indices (NB_SPHERE_FACES x 3)
52
+ faces.3.uint32 # triangle indices (NB_SPHERE_FACES x 3, optional)
53
53
 
54
54
  odf/ # ODF amplitudes (optional)
55
55
  amplitudes.{ndirs}.{dtype} # (NB_VOXELS x NB_SPHERE_VERTICES)
@@ -104,7 +104,7 @@ data.odx/
104
104
  | Field | Present when | Type | Description |
105
105
  |-------|-------------|------|-------------|
106
106
  | `NB_SPHERE_VERTICES` | `sphere/` exists | `u64` | Number of ODF sampling directions. |
107
- | `NB_SPHERE_FACES` | `sphere/` exists | `u64` | Number of triangles in the sphere mesh. |
107
+ | `NB_SPHERE_FACES` | `sphere/faces` exists | `u64` | Number of triangles in the sphere mesh. |
108
108
  | `SH_ORDER` | `sh/` exists | `u64` | Maximum spherical harmonic order. |
109
109
  | `SH_BASIS` | `sh/` exists | `string` | SH basis convention: `"descoteaux07"` (Dipy) or `"tournier07"` (MRtrix). |
110
110
  | `SH_FULL_BASIS` | optional | `bool` | When `true`, the SH array uses the full basis with both even and odd ℓ (ncoeffs = `(SH_ORDER + 1)²`); when `false` or absent, the symmetric even-only basis is used (ncoeffs = `(SH_ORDER + 1)(SH_ORDER + 2)/2`). Full basis is required for asymmetric ODFs (e.g. pyAFQ aodf). |
@@ -182,7 +182,9 @@ RAS+mm orientation.
182
182
  ### `sphere/faces.3.uint32`
183
183
 
184
184
  Shape `(NB_SPHERE_FACES, 3)`. Triangle indices into the vertex array, for
185
- mesh visualization and interpolation.
185
+ mesh visualization and interpolation. Optional: a sphere known only by its
186
+ sampling directions (e.g. one imported from a dipy PAM5 file) has vertices but
187
+ no faces, and readers must accept a `sphere/` without `faces`.
186
188
 
187
189
  The sphere directory is required when `odf/` data is present on a noncanonical
188
190
  sphere. It may be omitted when `SPHERE_ID` identifies a standard sphere. It is
@@ -361,7 +363,7 @@ This applies to:
361
363
 
362
364
  | Source | Transform |
363
365
  |--------|-----------|
364
- | DSI Studio (LPS+) | Negate x and y components: `x_ras = -x_lps`, `y_ras = -y_lps`, `z_ras = z_lps` |
366
+ | DSI Studio (voxel frame of its LPS+-reordered grid) | `d_ras = R · d_dsi`, with `R` the 3×3 linear part of `trans` with its columns normalized; this is `diag(-1, -1, 1)` (negate x and y) only for a non-oblique image. Applies to `odf_vertices`, `dir{p}` and so to `index{p}`. A `.fib.gz` without `trans` uses the reference DWI affine reordered to LPS+ voxel order. |
365
367
  | MRtrix (RAS) | No change needed for directions. Affine from NIfTI header. |
366
368
  | Dipy (RAS) | No change needed. Affine from NIfTI header. |
367
369
 
@@ -474,7 +474,8 @@ can). The loader also reads them back in preference to `index{p}`.
474
474
  Useful options:
475
475
 
476
476
  - `--reference-affine <nifti>`: required for a native-space `.fib.gz`/`.fz`
477
- without a `trans` record (e.g. the preprocessed DWI it was built from).
477
+ without a `trans` record (e.g. the preprocessed DWI it was built from); it
478
+ is reordered to DSI Studio's LPS voxel order automatically.
478
479
  - `--odf-lmax <even-int>`, `--apsf-dirs <N>`: accuracy knobs for the dense-ODF
479
480
  and SH paths.
480
481
  - `--json`: machine-readable summary including the correction report
@@ -504,7 +505,7 @@ Useful options:
504
505
  Some formats are composite:
505
506
 
506
507
  - MRtrix fixel directories may be accompanied by an SH image.
507
- - DSI Studio `fib.gz` inputs may need `--reference-affine` when the file does not contain a usable transform.
508
+ - DSI Studio `fib.gz` inputs may need `--reference-affine` when the file does not contain a usable transform (no `trans` record). Pass the DWI NIfTI the file was reconstructed from: DSI Studio reorders voxels to LPS on import, and odx reorders the reference affine the same way (as nibabel's `io_orientation` + `ornt_transform` + `inv_ornt_aff` would), so a LAS or otherwise ordered DWI lands its voxels correctly. An affine that is already LPS-ordered is used as is.
508
509
  - ODX may hold sparse fixels, dense ODFs, and SH together in one dataset.
509
510
 
510
511
  The CLI therefore treats `<input>` as the primary object and accepts companion inputs as flags instead of requiring a manifest format.
@@ -32,6 +32,8 @@ peaked.save_mrtrix("mrtrix_out/") # → MRtrix mif (auto-converts basis)
32
32
 
33
33
  # Round-trip back to dipy if you want
34
34
  pam = odx.to_peaks_and_metrics(peaked)
35
+
36
+ wmfod = odx.from_mrtrix("wmfod.mif") # bare SH image (.mif[.gz] or 4-D .nii[.gz])
35
37
  ```
36
38
 
37
39
  ## Features
@@ -1720,6 +1720,11 @@ fn from_fz(path: PathBuf, skip_odf: bool) -> PyResult<PyOdx> {
1720
1720
 
1721
1721
  /// Load a DSI Studio `.fib.gz`. `skip_odf=True` discards the dense ODFs
1722
1722
  /// while decompressing, for fixel-only work.
1723
+ ///
1724
+ /// `affine` is only used when the file has no `trans` record: pass the
1725
+ /// affine of the DWI NIfTI the file was reconstructed from. DSI Studio
1726
+ /// reorders voxels to LPS on import, and the affine is reordered the same
1727
+ /// way (a no-op for an affine already in LPS order).
1723
1728
  #[pyfunction]
1724
1729
  #[pyo3(signature = (path, affine=None, *, skip_odf=false))]
1725
1730
  fn from_fibgz(
@@ -1733,13 +1738,25 @@ fn from_fibgz(
1733
1738
  Ok(PyOdx::from_dataset(dataset))
1734
1739
  }
1735
1740
 
1736
- /// Load an MRtrix dataset from a directory containing a fixel directory and
1737
- /// (optionally) a sibling SH `.mif` file. If `sh_filename` is provided the
1738
- /// SH is loaded from `directory/sh_filename`; otherwise common defaults are
1739
- /// tried (`wmfod.mif`, `WM_FODs.mif`).
1741
+ /// Load MRtrix data. `path` is either a bare SH image (`.mif`, `.mif.gz`,
1742
+ /// `.nii`, `.nii.gz`; voxel order is canonicalized to RAS+) or a directory
1743
+ /// containing a fixel directory and (optionally) a sibling SH `.mif` file.
1744
+ /// For a directory, if `sh_filename` is provided the SH is loaded from
1745
+ /// `path/sh_filename`; otherwise common defaults are tried (`wmfod.mif`,
1746
+ /// `WM_FODs.mif`). `sh_filename` is not accepted with a bare SH image.
1740
1747
  #[pyfunction]
1741
- #[pyo3(signature = (directory, sh_filename=None))]
1742
- fn from_mrtrix(directory: PathBuf, sh_filename: Option<String>) -> PyResult<PyOdx> {
1748
+ #[pyo3(signature = (path, sh_filename=None))]
1749
+ fn from_mrtrix(path: PathBuf, sh_filename: Option<String>) -> PyResult<PyOdx> {
1750
+ if path.is_file() {
1751
+ if sh_filename.is_some() {
1752
+ return Err(PyValueError::new_err(
1753
+ "from_mrtrix: sh_filename is only valid when path is a directory",
1754
+ ));
1755
+ }
1756
+ let dataset = odx_rs::mrtrix::load_mrtrix_sh(&path).map_err(map_io)?;
1757
+ return Ok(PyOdx::from_dataset(dataset));
1758
+ }
1759
+ let directory = path;
1743
1760
  let candidates: Vec<PathBuf> = match sh_filename {
1744
1761
  Some(name) => vec![directory.join(&name), directory.join(format!("{name}.gz"))],
1745
1762
  None => vec![
@@ -42,6 +42,43 @@ def test_save_mif_then_from_mrtrix_roundtrips_sh(tmp_path):
42
42
  np.testing.assert_allclose(loaded_sh, src_sh, atol=1e-3)
43
43
 
44
44
 
45
+ def test_from_mrtrix_accepts_a_bare_sh_image(tmp_path):
46
+ src = _make_dataset()
47
+ # save_mif gzips by default and writes "fod.mif.gz" even when given "fod.mif".
48
+ path = tmp_path / "fod.mif.gz"
49
+ src.save_mif(path, which="sh")
50
+ assert path.is_file()
51
+ loaded = odx.from_mrtrix(path)
52
+ # The bare-image loader derives its own mask from the SH, so compare on
53
+ # the full grid rather than masked rows.
54
+ np.testing.assert_allclose(
55
+ np.asarray(loaded.densify_sh()), np.asarray(src.densify_sh()), atol=1e-3
56
+ )
57
+ np.testing.assert_array_equal(np.asarray(loaded.affine), np.asarray(src.affine))
58
+ assert loaded.dimensions == src.dimensions
59
+
60
+
61
+ def test_from_mrtrix_accepts_a_bare_nifti_sh_image(tmp_path):
62
+ nib = pytest.importorskip("nibabel")
63
+ src = _make_dataset()
64
+ dense = np.asarray(src.densify_sh(), dtype=np.float32)
65
+ path = tmp_path / "fod.nii.gz"
66
+ nib.save(nib.Nifti1Image(dense, np.eye(4)), path)
67
+ loaded = odx.from_mrtrix(path)
68
+ np.testing.assert_allclose(np.asarray(loaded.densify_sh()), dense, atol=1e-3)
69
+ np.testing.assert_array_equal(np.asarray(loaded.affine), np.eye(4))
70
+ assert loaded.dimensions == dense.shape[:3]
71
+
72
+
73
+ def test_from_mrtrix_rejects_sh_filename_with_a_file_path(tmp_path):
74
+ src = _make_dataset()
75
+ path = tmp_path / "fod.mif.gz"
76
+ src.save_mif(path, which="sh")
77
+ assert path.is_file()
78
+ with pytest.raises(ValueError, match="sh_filename"):
79
+ odx.from_mrtrix(path, sh_filename="wmfod.mif")
80
+
81
+
45
82
  # ─── DSI Studio FZ ────────────────────────────────────────────────────────────
46
83
 
47
84