mlarray 0.0.54__tar.gz → 1.0.0__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 (100) hide show
  1. {mlarray-0.0.54 → mlarray-1.0.0}/.github/workflows/workflow.yml +9 -5
  2. {mlarray-0.0.54 → mlarray-1.0.0}/PKG-INFO +52 -71
  3. {mlarray-0.0.54 → mlarray-1.0.0}/README.md +45 -68
  4. mlarray-1.0.0/bench/README.md +8 -0
  5. {mlarray-0.0.54 → mlarray-1.0.0}/bench/benchmark_formats.py +30 -25
  6. mlarray-1.0.0/docs/api.md +47 -0
  7. mlarray-1.0.0/docs/cli.md +52 -0
  8. mlarray-1.0.0/docs/design/followups.md +40 -0
  9. mlarray-1.0.0/docs/design/rework-plan.md +361 -0
  10. mlarray-1.0.0/docs/design/spec.md +477 -0
  11. {mlarray-0.0.54 → mlarray-1.0.0}/docs/optimization.md +23 -19
  12. mlarray-1.0.0/docs/schema.md +152 -0
  13. mlarray-1.0.0/docs/usage.md +246 -0
  14. mlarray-1.0.0/examples/example_bboxes_only.py +36 -0
  15. mlarray-1.0.0/examples/example_channel.py +32 -0
  16. mlarray-1.0.0/examples/example_compress_decompress.py +50 -0
  17. mlarray-1.0.0/examples/example_compressed_vs_uncompressed.py +44 -0
  18. {mlarray-0.0.54 → mlarray-1.0.0}/examples/example_in_memory_constructors.py +6 -4
  19. mlarray-1.0.0/examples/example_metadata_only.py +21 -0
  20. mlarray-1.0.0/examples/example_non_spatial.py +33 -0
  21. mlarray-1.0.0/examples/example_open.py +28 -0
  22. mlarray-1.0.0/examples/example_save_load.py +41 -0
  23. mlarray-1.0.0/mlarray/__init__.py +77 -0
  24. mlarray-1.0.0/mlarray/blosc2_layout_strategies.py +202 -0
  25. mlarray-1.0.0/mlarray/cli.py +105 -0
  26. mlarray-1.0.0/mlarray/constructors.py +964 -0
  27. mlarray-1.0.0/mlarray/fingerprint.py +81 -0
  28. mlarray-1.0.0/mlarray/metadata.py +1313 -0
  29. mlarray-1.0.0/mlarray/metadata_io.py +125 -0
  30. mlarray-1.0.0/mlarray/mlarray.py +1180 -0
  31. mlarray-1.0.0/mlarray/schemas/mlarray-1.0.json +204 -0
  32. mlarray-1.0.0/mlarray/schemas/profile-medical-1.0.json +14 -0
  33. mlarray-1.0.0/mlarray/schemas/profile-ml-1.0.json +44 -0
  34. mlarray-1.0.0/mlarray/schemas/units-1.0.json +31 -0
  35. mlarray-1.0.0/mlarray/storage.py +332 -0
  36. mlarray-1.0.0/mlarray/units.py +24 -0
  37. {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/PKG-INFO +52 -71
  38. {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/SOURCES.txt +32 -10
  39. {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/requires.txt +4 -1
  40. {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/scm_file_list.json +22 -8
  41. mlarray-1.0.0/mlarray.egg-info/scm_version.json +8 -0
  42. {mlarray-0.0.54 → mlarray-1.0.0}/pyproject.toml +8 -4
  43. mlarray-1.0.0/tests/nnunet_reference.py +197 -0
  44. mlarray-1.0.0/tests/test_asarray.py +145 -0
  45. mlarray-1.0.0/tests/test_cli.py +164 -0
  46. {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_compress_decompress.py +37 -23
  47. {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_constructors.py +26 -2
  48. {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_create.py +22 -2
  49. mlarray-1.0.0/tests/test_layout.py +208 -0
  50. mlarray-1.0.0/tests/test_metadata_model.py +345 -0
  51. mlarray-1.0.0/tests/test_metadata_storage.py +252 -0
  52. {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_open.py +47 -4
  53. {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_optimization.py +57 -34
  54. mlarray-1.0.0/tests/test_pickle.py +117 -0
  55. mlarray-1.0.0/tests/test_read_ahead.py +64 -0
  56. mlarray-1.0.0/tests/test_save.py +191 -0
  57. mlarray-1.0.0/tests/test_schemas.py +84 -0
  58. mlarray-1.0.0/tests/test_spec_examples.py +119 -0
  59. {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_usage.py +77 -79
  60. mlarray-0.0.54/MANIFEST.in +0 -1
  61. mlarray-0.0.54/bench/README.md +0 -56
  62. mlarray-0.0.54/bench/bench_convert_nii_to_mla_random_read.py +0 -586
  63. mlarray-0.0.54/bench/bench_io_blosc2_layouts.py +0 -1178
  64. mlarray-0.0.54/docs/api.md +0 -37
  65. mlarray-0.0.54/docs/cli.md +0 -59
  66. mlarray-0.0.54/docs/schema.md +0 -219
  67. mlarray-0.0.54/docs/usage.md +0 -270
  68. mlarray-0.0.54/examples/example_bboxes_only.py +0 -36
  69. mlarray-0.0.54/examples/example_channel.py +0 -34
  70. mlarray-0.0.54/examples/example_compress_decompress.py +0 -56
  71. mlarray-0.0.54/examples/example_compressed_vs_uncompressed.py +0 -42
  72. mlarray-0.0.54/examples/example_metadata_only.py +0 -31
  73. mlarray-0.0.54/examples/example_non_spatial.py +0 -42
  74. mlarray-0.0.54/examples/example_open.py +0 -36
  75. mlarray-0.0.54/examples/example_save_load.py +0 -34
  76. mlarray-0.0.54/mlarray/__init__.py +0 -85
  77. mlarray-0.0.54/mlarray/blosc2_layout_strategies.py +0 -766
  78. mlarray-0.0.54/mlarray/cli.py +0 -226
  79. mlarray-0.0.54/mlarray/meta.py +0 -1394
  80. mlarray-0.0.54/mlarray/mlarray.py +0 -2099
  81. mlarray-0.0.54/mlarray/utils.py +0 -17
  82. mlarray-0.0.54/mlarray.egg-info/scm_version.json +0 -8
  83. mlarray-0.0.54/tests/test_asarray.py +0 -85
  84. mlarray-0.0.54/tests/test_bboxes.py +0 -67
  85. mlarray-0.0.54/tests/test_cli.py +0 -420
  86. mlarray-0.0.54/tests/test_meta_safety.py +0 -173
  87. mlarray-0.0.54/tests/test_metadata.py +0 -153
  88. {mlarray-0.0.54 → mlarray-1.0.0}/.gitignore +0 -0
  89. {mlarray-0.0.54 → mlarray-1.0.0}/LICENSE +0 -0
  90. {mlarray-0.0.54 → mlarray-1.0.0}/assets/banner.png +0 -0
  91. {mlarray-0.0.54 → mlarray-1.0.0}/bench/.gitignore +0 -0
  92. {mlarray-0.0.54 → mlarray-1.0.0}/bench/helper/print_mla_layouts.py +0 -0
  93. {mlarray-0.0.54 → mlarray-1.0.0}/docs/index.md +0 -0
  94. {mlarray-0.0.54 → mlarray-1.0.0}/docs/why.md +0 -0
  95. {mlarray-0.0.54 → mlarray-1.0.0}/examples/example_asarray.py +0 -0
  96. {mlarray-0.0.54 → mlarray-1.0.0}/mkdocs.yml +0 -0
  97. {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/dependency_links.txt +0 -0
  98. {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/entry_points.txt +0 -0
  99. {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/top_level.txt +0 -0
  100. {mlarray-0.0.54 → mlarray-1.0.0}/setup.cfg +0 -0
@@ -18,7 +18,7 @@ jobs:
18
18
  strategy:
19
19
  fail-fast: false
20
20
  matrix:
21
- python-version: ["3.10"]
21
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
22
22
  steps:
23
23
  - name: Checkout
24
24
  uses: actions/checkout@v4
@@ -35,7 +35,13 @@ jobs:
35
35
 
36
36
  - name: Run tests
37
37
  run: |
38
- pytest || if [ $? -eq 5 ]; then echo "No tests collected; skipping."; exit 0; else exit $?; fi
38
+ status=0
39
+ pytest || status=$?
40
+ if [ "$status" -eq 5 ]; then
41
+ echo "No tests collected; skipping."
42
+ exit 0
43
+ fi
44
+ exit "$status"
39
45
 
40
46
  publish:
41
47
  needs: tests
@@ -61,10 +67,8 @@ jobs:
61
67
  - name: Check metadata
62
68
  run: python -m twine check dist/*
63
69
 
64
- - name: Publish to PyPI
70
+ - name: Publish to PyPI (trusted publishing)
65
71
  uses: pypa/gh-action-pypi-publish@release/v1
66
- with:
67
- password: ${{ secrets.PYPI_API_TOKEN }}
68
72
 
69
73
  docs:
70
74
  needs: tests
@@ -1,25 +1,29 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlarray
3
- Version: 0.0.54
3
+ Version: 1.0.0
4
4
  Summary: Array format specialized for Machine Learning with Blosc2 backend and standardized metadata.
5
5
  Author-email: Karol Gotkowski <karol.gotkowski@dkfz.de>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/MIC-DKFZ/mlarray
8
8
  Project-URL: Source, https://github.com/MIC-DKFZ/mlarray
9
9
  Project-URL: Issues, https://github.com/MIC-DKFZ/mlarray/issues
10
- Keywords: copier,template,python
10
+ Keywords: machine learning,medical imaging,blosc2,array,file format
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
15
16
  Classifier: Operating System :: OS Independent
16
17
  Requires-Python: >=3.10
17
18
  Description-Content-Type: text/markdown
18
19
  License-File: LICENSE
19
20
  Requires-Dist: blosc2>=3
20
21
  Requires-Dist: numpy>=2
22
+ Requires-Dist: msgpack>=1.0
23
+ Requires-Dist: xxhash>=3.0
21
24
  Provides-Extra: dev
22
25
  Requires-Dist: build>=1.0; extra == "dev"
26
+ Requires-Dist: jsonschema>=4.18; extra == "dev"
23
27
  Requires-Dist: pytest>=7.0; extra == "dev"
24
28
  Requires-Dist: twine>=4.0; extra == "dev"
25
29
  Requires-Dist: setuptools_scm[toml]>=8.0; extra == "dev"
@@ -29,7 +33,7 @@ Requires-Dist: mkdocs-include-markdown-plugin>=6.0; extra == "docs"
29
33
  Requires-Dist: mkdocstrings[python]>=0.25; extra == "docs"
30
34
  Requires-Dist: pymdown-extensions>=10.0; extra == "docs"
31
35
  Provides-Extra: all
32
- Requires-Dist: medvol; extra == "all"
36
+ Requires-Dist: medvol>=1.0.0; extra == "all"
33
37
  Dynamic: license-file
34
38
 
35
39
  <p align="center">
@@ -55,7 +59,7 @@ You can install mlarray via [pip](https://pypi.org/project/mlarray/):
55
59
  pip install mlarray
56
60
  ```
57
61
 
58
- To enable the `mlarray_convert` CLI command, install MLArray with the necessary extra dependencies:
62
+ To enable the `mlarray_convert` CLI command (conversion with MedVol 1.0 or newer), install MLArray with the extra dependencies:
59
63
  ```bash
60
64
  pip install mlarray[all]
61
65
  ```
@@ -101,7 +105,15 @@ image = MLArray.create("sample.mla", shape=array.shape, dtype=array.dtype, mmap_
101
105
  image[...] = array # Modify image in memory and disk
102
106
  ```
103
107
 
104
- ### Metadata inspection and manipulation
108
+ ### Fast reading
109
+
110
+ `MLArray.open` reads lazily: `image[...]` decompresses only the chunks a patch touches. The first open in a process also turns on blosc2's read-ahead (4 threads), which fetches those chunks in parallel and makes reads of data not in the page cache several times faster, especially on network storage. Change it with `mlarray.set_read_ahead(n)` (`0` turns it off) or the `BLOSC_PREFETCH` environment variable. Files are written with nnU-Net's layout and compression settings; see [ML Optimization](docs/optimization.md) for details.
111
+
112
+ ### Metadata
113
+
114
+ Metadata is explicit and domain-neutral: what each axis is, where the array sits in physical space, what
115
+ its values mean, optional domain profiles, and your own metadata. Geometry is never implied: spacing,
116
+ origin and direction always refer to a declared space.
105
117
 
106
118
  ```python
107
119
  import numpy as np
@@ -110,63 +122,44 @@ from mlarray import MLArray
110
122
  array = np.random.random((64, 128, 128))
111
123
  image = MLArray(
112
124
  array,
113
- spacing=(1.0, 1.0, 1.5),
114
- origin=(10.0, 10.0, 30.0),
115
- direction=[[1, 0, 0], [0, 1, 0], [0, 0, 1]],
116
- meta={"patient_id": "123", "modality": "CT"}, # Any metadata from the original image source (for example raw DICOM metadata)
125
+ axes=["z", "y", "x"],
126
+ spacing=(1.5, 1.0, 1.0), # per array axis, in array order
127
+ origin=(10.0, 10.0, 30.0), # centre of index (0, 0, 0)
128
+ direction=[[0, 0, 1], [0, 1, 0], [1, 0, 0]], # column c: unit vector of array axis c
129
+ space="patient", # DICOM patient frame (LPS)
130
+ values={"kind": "measurement", "unit": "hounsfield_unit"},
131
+ user={"patient_id": "123"}, # any metadata of your own
117
132
  )
118
133
 
119
- print(image.spacing) # [1.0, 1.0, 1.5]
120
- print(image.origin) # [10.0, 10.0, 30.0]
121
- print(image.meta.source) # {"patient_id": "123", "modality": "CT"}
122
-
123
- image.spacing[1] = 5.3
124
- image.meta.source["study_id"] = "study-001"
134
+ print(image.spacing) # (1.5, 1.0, 1.0)
135
+ print(image.letter_codes()) # ('S', 'P', 'L')
136
+ image.set_geometry(spacing=(1.5, 5.3, 1.0))
137
+ image.user["study_id"] = "study-001"
125
138
  image.save("with-metadata.mla")
126
139
 
127
140
  # Open memory-mapped
128
- image = MLArray.open("with-metadata.mla", mmap_mode='r+')
129
- image.meta.source["study_id"] = "new-study" # Modify metadata
141
+ image = MLArray.open("with-metadata.mla", mmap_mode='r+')
142
+ image.user["study_id"] = "new-study" # Modify metadata
130
143
  image.close() # Close and save metadata, only necessary to save modified metadata
131
144
  ```
132
145
 
133
- ### Copy metadata with overrides
146
+ Segmentations, ML profiles and derived images:
134
147
 
135
148
  ```python
136
149
  import numpy as np
137
150
  from mlarray import MLArray
138
151
 
139
- base = MLArray("sample.mla")
140
- array = np.random.random(base.shape)
141
-
142
- image = MLArray(
143
- array,
144
- spacing=(0.8, 0.8, 1.0),
145
- copy=base, # Copies all non-explicitly set arguments from base
152
+ base = MLArray("with-metadata.mla")
153
+ seg = MLArray(
154
+ np.zeros(base.shape, dtype=np.uint8),
155
+ copy=base, # geometry, values, profiles and user metadata of base (never its source)
156
+ values={"kind": "label", "labels": {"0": {"name": "background"}, "1": {"name": "liver"}}},
157
+ profiles={"ml": {"split": "train"}},
146
158
  )
147
-
148
- image.save("copied-metadata.mla")
159
+ seg.save("seg.mla")
149
160
  ```
150
161
 
151
- ### Standardized metadata usage
152
-
153
- ```python
154
- import numpy as np
155
- from mlarray import MLArray, Meta
156
-
157
- array = np.random.random((64, 128, 128))
158
- image = MLArray(
159
- array,
160
- meta=Meta(source={"patient_id": "123", "modality": "CT"}, is_seg=True), # Add metadata in a pre-defined format
161
- )
162
-
163
- print(image.meta.source) # {"patient_id": "123", "modality": "CT"}
164
- print(image.meta.is_seg) # True
165
-
166
- image.meta.source["study_id"] = "study-001"
167
- image.meta.is_seg = False
168
- image.save("with-metadata.mla")
169
- ```
162
+ See [Usage](docs/usage.md) and [Schema](docs/schema.md) for the full metadata model.
170
163
 
171
164
  ### Patch size variants
172
165
 
@@ -228,7 +221,8 @@ image.save("blosc2-auto.mla")
228
221
 
229
222
  ### mlarray_header
230
223
 
231
- Print the metadata header from a `.mla` file.
224
+ Print the metadata of a `.mla` file as JSON (`--source` and `--user` add the original metadata of
225
+ converted files and the user metadata).
232
226
 
233
227
  ```bash
234
228
  mlarray_header sample.mla
@@ -236,34 +230,21 @@ mlarray_header sample.mla
236
230
 
237
231
  ### mlarray_convert
238
232
 
239
- Convert between MLArray and NIfTI/NRRD files.
240
-
241
- When converting from NIfTI/NRRD to MLArray, source metadata is copied into
242
- `meta.source`.
243
-
244
- When converting from MLArray to NIfTI/NRRD, only `meta.source` is copied into
245
- the output header. Spatial metadata (`spacing`, `origin`, `direction`) is set
246
- explicitly from `meta.spatial`.
247
-
248
- `meta.spatial.coord_system` is propagated conservatively. For NRRD input, the
249
- CLI reads explicit NRRD space metadata when available and maps
250
- `right-anterior-superior` to `RAS` and `left-posterior-superior` to `LPS`,
251
- while preserving other explicit NRRD space strings verbatim. For NIfTI input,
252
- the CLI sets `coord_system` to `LPS` based on the current
253
- `MedVol -> SimpleITK/ITK` import path, where the imported geometry is
254
- represented in the ITK physical-space convention. MedVol's reindexing to NumPy
255
- layout does not change that world-space convention. On NRRD output, `RAS` and
256
- `LPS` are written back as NRRD `space` metadata when possible; arbitrary custom
257
- `coord_system` strings are not emitted as NRRD `space` declarations unless they
258
- already match a supported explicit NRRD space string. NIfTI output preserves
259
- geometry, but does not export `coord_system` as an explicit NIfTI metadata
260
- field.
233
+ Convert between MLArray and NIfTI, NRRD, MetaImage, MGH/MGZ and DICOM with
234
+ [MedVol](https://github.com/MIC-DKFZ/MedVol) 1.0 or newer (`pip install "mlarray[all]"`). Nothing is reoriented, rescaled or
235
+ dropped silently: the array keeps its stored order and dtype, the geometry is taken from the file's own fields,
236
+ the original header is kept in `source` so the file can be written back faithfully, and every choice is reported.
261
237
 
262
238
  ```bash
263
- mlarray_convert sample.nii.gz output.mla
264
- mlarray_convert sample.mla output.nii.gz
239
+ mlarray_convert sample.nii.gz sample.mla
240
+ mlarray_convert sample.mla roundtrip.nii.gz
241
+ mlarray_convert dicom_dir/ ct.mla --dicom-tilt exact
242
+ mlarray_convert ct.mla out_dir/ --target-format dicom # re-emits the original DICOM series
265
243
  ```
266
244
 
245
+ Ambiguities are resolved by explicit MedVol policies, e.g. `--nifti-form sform` when a NIfTI's qform and sform
246
+ disagree, or `--world-frame ras` to store the patient space in RAS instead of DICOM's LPS.
247
+
267
248
  ## Contributing
268
249
 
269
250
  Contributions are welcome! Please open a pull request with clear changes and add tests when appropriate.
@@ -21,7 +21,7 @@ You can install mlarray via [pip](https://pypi.org/project/mlarray/):
21
21
  pip install mlarray
22
22
  ```
23
23
 
24
- To enable the `mlarray_convert` CLI command, install MLArray with the necessary extra dependencies:
24
+ To enable the `mlarray_convert` CLI command (conversion with MedVol 1.0 or newer), install MLArray with the extra dependencies:
25
25
  ```bash
26
26
  pip install mlarray[all]
27
27
  ```
@@ -67,7 +67,15 @@ image = MLArray.create("sample.mla", shape=array.shape, dtype=array.dtype, mmap_
67
67
  image[...] = array # Modify image in memory and disk
68
68
  ```
69
69
 
70
- ### Metadata inspection and manipulation
70
+ ### Fast reading
71
+
72
+ `MLArray.open` reads lazily: `image[...]` decompresses only the chunks a patch touches. The first open in a process also turns on blosc2's read-ahead (4 threads), which fetches those chunks in parallel and makes reads of data not in the page cache several times faster, especially on network storage. Change it with `mlarray.set_read_ahead(n)` (`0` turns it off) or the `BLOSC_PREFETCH` environment variable. Files are written with nnU-Net's layout and compression settings; see [ML Optimization](docs/optimization.md) for details.
73
+
74
+ ### Metadata
75
+
76
+ Metadata is explicit and domain-neutral: what each axis is, where the array sits in physical space, what
77
+ its values mean, optional domain profiles, and your own metadata. Geometry is never implied: spacing,
78
+ origin and direction always refer to a declared space.
71
79
 
72
80
  ```python
73
81
  import numpy as np
@@ -76,63 +84,44 @@ from mlarray import MLArray
76
84
  array = np.random.random((64, 128, 128))
77
85
  image = MLArray(
78
86
  array,
79
- spacing=(1.0, 1.0, 1.5),
80
- origin=(10.0, 10.0, 30.0),
81
- direction=[[1, 0, 0], [0, 1, 0], [0, 0, 1]],
82
- meta={"patient_id": "123", "modality": "CT"}, # Any metadata from the original image source (for example raw DICOM metadata)
87
+ axes=["z", "y", "x"],
88
+ spacing=(1.5, 1.0, 1.0), # per array axis, in array order
89
+ origin=(10.0, 10.0, 30.0), # centre of index (0, 0, 0)
90
+ direction=[[0, 0, 1], [0, 1, 0], [1, 0, 0]], # column c: unit vector of array axis c
91
+ space="patient", # DICOM patient frame (LPS)
92
+ values={"kind": "measurement", "unit": "hounsfield_unit"},
93
+ user={"patient_id": "123"}, # any metadata of your own
83
94
  )
84
95
 
85
- print(image.spacing) # [1.0, 1.0, 1.5]
86
- print(image.origin) # [10.0, 10.0, 30.0]
87
- print(image.meta.source) # {"patient_id": "123", "modality": "CT"}
88
-
89
- image.spacing[1] = 5.3
90
- image.meta.source["study_id"] = "study-001"
96
+ print(image.spacing) # (1.5, 1.0, 1.0)
97
+ print(image.letter_codes()) # ('S', 'P', 'L')
98
+ image.set_geometry(spacing=(1.5, 5.3, 1.0))
99
+ image.user["study_id"] = "study-001"
91
100
  image.save("with-metadata.mla")
92
101
 
93
102
  # Open memory-mapped
94
- image = MLArray.open("with-metadata.mla", mmap_mode='r+')
95
- image.meta.source["study_id"] = "new-study" # Modify metadata
103
+ image = MLArray.open("with-metadata.mla", mmap_mode='r+')
104
+ image.user["study_id"] = "new-study" # Modify metadata
96
105
  image.close() # Close and save metadata, only necessary to save modified metadata
97
106
  ```
98
107
 
99
- ### Copy metadata with overrides
108
+ Segmentations, ML profiles and derived images:
100
109
 
101
110
  ```python
102
111
  import numpy as np
103
112
  from mlarray import MLArray
104
113
 
105
- base = MLArray("sample.mla")
106
- array = np.random.random(base.shape)
107
-
108
- image = MLArray(
109
- array,
110
- spacing=(0.8, 0.8, 1.0),
111
- copy=base, # Copies all non-explicitly set arguments from base
114
+ base = MLArray("with-metadata.mla")
115
+ seg = MLArray(
116
+ np.zeros(base.shape, dtype=np.uint8),
117
+ copy=base, # geometry, values, profiles and user metadata of base (never its source)
118
+ values={"kind": "label", "labels": {"0": {"name": "background"}, "1": {"name": "liver"}}},
119
+ profiles={"ml": {"split": "train"}},
112
120
  )
113
-
114
- image.save("copied-metadata.mla")
121
+ seg.save("seg.mla")
115
122
  ```
116
123
 
117
- ### Standardized metadata usage
118
-
119
- ```python
120
- import numpy as np
121
- from mlarray import MLArray, Meta
122
-
123
- array = np.random.random((64, 128, 128))
124
- image = MLArray(
125
- array,
126
- meta=Meta(source={"patient_id": "123", "modality": "CT"}, is_seg=True), # Add metadata in a pre-defined format
127
- )
128
-
129
- print(image.meta.source) # {"patient_id": "123", "modality": "CT"}
130
- print(image.meta.is_seg) # True
131
-
132
- image.meta.source["study_id"] = "study-001"
133
- image.meta.is_seg = False
134
- image.save("with-metadata.mla")
135
- ```
124
+ See [Usage](docs/usage.md) and [Schema](docs/schema.md) for the full metadata model.
136
125
 
137
126
  ### Patch size variants
138
127
 
@@ -194,7 +183,8 @@ image.save("blosc2-auto.mla")
194
183
 
195
184
  ### mlarray_header
196
185
 
197
- Print the metadata header from a `.mla` file.
186
+ Print the metadata of a `.mla` file as JSON (`--source` and `--user` add the original metadata of
187
+ converted files and the user metadata).
198
188
 
199
189
  ```bash
200
190
  mlarray_header sample.mla
@@ -202,34 +192,21 @@ mlarray_header sample.mla
202
192
 
203
193
  ### mlarray_convert
204
194
 
205
- Convert between MLArray and NIfTI/NRRD files.
206
-
207
- When converting from NIfTI/NRRD to MLArray, source metadata is copied into
208
- `meta.source`.
209
-
210
- When converting from MLArray to NIfTI/NRRD, only `meta.source` is copied into
211
- the output header. Spatial metadata (`spacing`, `origin`, `direction`) is set
212
- explicitly from `meta.spatial`.
213
-
214
- `meta.spatial.coord_system` is propagated conservatively. For NRRD input, the
215
- CLI reads explicit NRRD space metadata when available and maps
216
- `right-anterior-superior` to `RAS` and `left-posterior-superior` to `LPS`,
217
- while preserving other explicit NRRD space strings verbatim. For NIfTI input,
218
- the CLI sets `coord_system` to `LPS` based on the current
219
- `MedVol -> SimpleITK/ITK` import path, where the imported geometry is
220
- represented in the ITK physical-space convention. MedVol's reindexing to NumPy
221
- layout does not change that world-space convention. On NRRD output, `RAS` and
222
- `LPS` are written back as NRRD `space` metadata when possible; arbitrary custom
223
- `coord_system` strings are not emitted as NRRD `space` declarations unless they
224
- already match a supported explicit NRRD space string. NIfTI output preserves
225
- geometry, but does not export `coord_system` as an explicit NIfTI metadata
226
- field.
195
+ Convert between MLArray and NIfTI, NRRD, MetaImage, MGH/MGZ and DICOM with
196
+ [MedVol](https://github.com/MIC-DKFZ/MedVol) 1.0 or newer (`pip install "mlarray[all]"`). Nothing is reoriented, rescaled or
197
+ dropped silently: the array keeps its stored order and dtype, the geometry is taken from the file's own fields,
198
+ the original header is kept in `source` so the file can be written back faithfully, and every choice is reported.
227
199
 
228
200
  ```bash
229
- mlarray_convert sample.nii.gz output.mla
230
- mlarray_convert sample.mla output.nii.gz
201
+ mlarray_convert sample.nii.gz sample.mla
202
+ mlarray_convert sample.mla roundtrip.nii.gz
203
+ mlarray_convert dicom_dir/ ct.mla --dicom-tilt exact
204
+ mlarray_convert ct.mla out_dir/ --target-format dicom # re-emits the original DICOM series
231
205
  ```
232
206
 
207
+ Ambiguities are resolved by explicit MedVol policies, e.g. `--nifti-form sform` when a NIfTI's qform and sform
208
+ disagree, or `--world-frame ras` to store the patient space in RAS instead of DICOM's LPS.
209
+
233
210
  ## Contributing
234
211
 
235
212
  Contributions are welcome! Please open a pull request with clear changes and add tests when appropriate.
@@ -0,0 +1,8 @@
1
+ # Benchmark Scripts
2
+
3
+ - `benchmark_formats.py`: compares MLArray with other image file formats.
4
+
5
+ The benchmark behind MLArray's storage defaults (layout, codec, read mode and
6
+ read-ahead, measured on a compute cluster with nnU-Net-style loading) lives on
7
+ the `feat/bench-suite` branch under `bench/`; `docs/optimization.md` summarizes
8
+ its results.
@@ -24,7 +24,6 @@ Usage:
24
24
 
25
25
  import argparse
26
26
  import json
27
- import math
28
27
  import os
29
28
  import random
30
29
  import shutil
@@ -148,11 +147,11 @@ def convert_blosc2(arr: np.ndarray, out_path: Path) -> None:
148
147
  blosc2.asarray(arr, urlpath=str(out_path), mode="w")
149
148
 
150
149
 
151
- def convert_mlarray(arr: np.ndarray, out_path: Path) -> None:
152
- """MLArray with patch-size optimized chunk/block sizes via comp_blosc2_params."""
150
+ def convert_mlarray(arr: np.ndarray, out_path: Path, patch_size: tuple) -> None:
151
+ """MLArray with chunk/block sizes optimized for the benchmark patch size via comp_blosc2_params."""
153
152
  if out_path.exists():
154
153
  out_path.unlink()
155
- mla = MLArray.asarray(arr)
154
+ mla = MLArray.asarray(arr, patch_size=list(patch_size))
156
155
  mla.save(out_path)
157
156
 
158
157
 
@@ -166,7 +165,7 @@ CONVERTERS = {
166
165
  }
167
166
 
168
167
 
169
- def _convert_one(src: Path, output_dir: Path, source_dir: Path, formats: list[str]) -> None:
168
+ def _convert_one(src: Path, output_dir: Path, source_dir: Path, formats: list[str], patch_size: tuple) -> None:
170
169
  """Convert a single source file to all requested formats. Top-level for pickling."""
171
170
  arr = load_source_array(src)
172
171
  stem = flat_stem(src, source_dir)
@@ -175,7 +174,10 @@ def _convert_one(src: Path, output_dir: Path, source_dir: Path, formats: list[st
175
174
  out_path = output_dir / fmt / (stem + suffix)
176
175
  if out_path.exists():
177
176
  continue
178
- converter(arr, out_path)
177
+ if fmt == "mlarray":
178
+ converter(arr, out_path, patch_size)
179
+ else:
180
+ converter(arr, out_path)
179
181
 
180
182
 
181
183
  def run_conversion(
@@ -184,11 +186,12 @@ def run_conversion(
184
186
  output_dir: Path,
185
187
  formats: list[str],
186
188
  num_workers: int,
189
+ patch_size: tuple,
187
190
  ) -> None:
188
191
  for fmt in formats:
189
192
  (output_dir / fmt).mkdir(parents=True, exist_ok=True)
190
193
 
191
- fn = partial(_convert_one, output_dir=output_dir, source_dir=source_dir, formats=formats)
194
+ fn = partial(_convert_one, output_dir=output_dir, source_dir=source_dir, formats=formats, patch_size=patch_size)
192
195
  tqdmp(fn, source_files, num_workers, desc=" Converting")
193
196
 
194
197
 
@@ -257,10 +260,11 @@ READERS = {
257
260
  }
258
261
 
259
262
 
260
- def collect_files(output_dir: Path, fmt: str) -> list[Path]:
263
+ def collect_files(output_dir: Path, fmt: str, stems: list[str]) -> list[Path]:
264
+ """Converted files of the selected source stems that exist on disk."""
261
265
  _, suffix = CONVERTERS[fmt]
262
266
  fmt_dir = output_dir / fmt
263
- return sorted(fmt_dir.glob(f"*{suffix}"))
267
+ return [p for p in (fmt_dir / (stem + suffix) for stem in sorted(stems)) if p.exists()]
264
268
 
265
269
 
266
270
  def get_shape(filepath: Path, fmt: str) -> tuple:
@@ -295,9 +299,8 @@ def disk_size_mb(path: Path) -> float:
295
299
  return path.stat().st_size / 1e6
296
300
 
297
301
 
298
- def total_disk_mb(output_dir: Path, fmt: str) -> float:
299
- fmt_dir = output_dir / fmt
300
- return sum(disk_size_mb(p) for p in fmt_dir.iterdir())
302
+ def total_disk_mb(files: list[Path]) -> float:
303
+ return sum(disk_size_mb(p) for p in files)
301
304
 
302
305
 
303
306
  def run_benchmark(
@@ -305,13 +308,14 @@ def run_benchmark(
305
308
  formats: list[str],
306
309
  n_reads: int,
307
310
  patch_size: tuple,
308
- rng: random.Random,
311
+ stems: list[str],
312
+ seed: int,
309
313
  ) -> dict:
310
314
  results = {}
311
315
 
312
316
  for fmt in formats:
313
317
  reader = READERS[fmt]
314
- files = collect_files(output_dir, fmt)
318
+ files = collect_files(output_dir, fmt, stems)
315
319
  if not files:
316
320
  print(f" [{fmt}] No converted files found, skipping.")
317
321
  continue
@@ -320,8 +324,10 @@ def run_benchmark(
320
324
  file_shapes = [(f, get_shape(f, fmt)) for f in files]
321
325
  print("done")
322
326
 
327
+ # Fresh RNG per format so every format reads the same files/patches.
328
+ rng = random.Random(seed)
323
329
  times_ms = []
324
- patch_bytes = None
330
+ bytes_read = 0
325
331
 
326
332
  with tqdm(total=n_reads, desc=f" [{fmt}]", unit="read", leave=True, file=sys.stdout) as pbar:
327
333
  for _ in range(n_reads):
@@ -330,22 +336,20 @@ def run_benchmark(
330
336
  starts = random_starts(shape, ep, rng)
331
337
  sl = slices_for(starts, ep)
332
338
 
333
- if patch_bytes is None:
334
- patch_bytes = math.prod(ep) * 4 # float32
335
-
336
339
  t0 = time.perf_counter()
337
- _ = reader(filepath, sl)
340
+ patch = reader(filepath, sl)
338
341
  t1 = time.perf_counter()
339
342
  times_ms.append((t1 - t0) * 1000.0)
343
+ bytes_read += patch.nbytes
340
344
  pbar.update(1)
341
345
 
342
346
  times_arr = np.array(times_ms)
343
- throughput_mbs = (patch_bytes / 1e6) / (np.mean(times_arr) / 1000.0)
347
+ throughput_mbs = (bytes_read / 1e6) / (np.sum(times_arr) / 1000.0)
344
348
 
345
349
  results[fmt] = {
346
350
  "n_reads": n_reads,
347
- "patch_bytes": patch_bytes,
348
- "disk_mb": round(total_disk_mb(output_dir, fmt), 2),
351
+ "patch_bytes": round(bytes_read / n_reads), # mean bytes per read
352
+ "disk_mb": round(total_disk_mb(files), 2),
349
353
  "mean_ms": round(float(np.mean(times_arr)), 3),
350
354
  "median_ms": round(float(np.median(times_arr)), 3),
351
355
  "std_ms": round(float(np.std(times_arr)), 3),
@@ -446,7 +450,7 @@ def parse_args() -> argparse.Namespace:
446
450
  )
447
451
  parser.add_argument("--max_images", type=int, default=None, help="Maximum number of images to use. The full list is shuffled then sliced. Default: all images.")
448
452
  parser.add_argument("--skip_convert", action="store_true", help="Skip Stage 1 (conversion).")
449
- parser.add_argument("--num_workers", type=int, default=12, help="Parallel workers for Stage 1 conversion. Default: all CPU cores.")
453
+ parser.add_argument("--num_workers", type=int, default=12, help="Parallel workers for Stage 1 conversion. Default: 12.")
450
454
  parser.add_argument("--seed", type=int, default=42, help="Random seed. Default: 42.")
451
455
  return parser.parse_args()
452
456
 
@@ -488,7 +492,7 @@ def main() -> None:
488
492
  # ------------------------------------------------------------------
489
493
  if not args.skip_convert:
490
494
  print("\n=== Stage 1: Converting ===")
491
- run_conversion(source_files, source_dir, output_dir, args.formats, args.num_workers)
495
+ run_conversion(source_files, source_dir, output_dir, args.formats, args.num_workers, patch_size)
492
496
  print("Conversion complete.")
493
497
  else:
494
498
  print("\nStage 1 skipped (--skip_convert).")
@@ -500,7 +504,8 @@ def main() -> None:
500
504
  print(
501
505
  "Note: results reflect OS page cache state. Files will warm in cache during the run."
502
506
  )
503
- results = run_benchmark(output_dir, args.formats, args.n_reads, patch_size, rng)
507
+ stems = [flat_stem(f, source_dir) for f in source_files]
508
+ results = run_benchmark(output_dir, args.formats, args.n_reads, patch_size, stems, args.seed)
504
509
 
505
510
  # ------------------------------------------------------------------
506
511
  # Results
@@ -0,0 +1,47 @@
1
+ # API Reference
2
+
3
+ ## MLArray Module
4
+
5
+ ::: mlarray.mlarray.MLArray
6
+ options:
7
+ inherited_members: true
8
+
9
+ ::: mlarray.mlarray.set_read_ahead
10
+
11
+ ## Layout Module
12
+
13
+ ::: mlarray.blosc2_layout_strategies.comp_blosc2_params
14
+
15
+ ::: mlarray.blosc2_layout_strategies.select_blosc2_filter
16
+
17
+ ## Metadata Module
18
+
19
+ ::: mlarray.metadata.Meta
20
+
21
+ ::: mlarray.metadata.Axis
22
+
23
+ ::: mlarray.metadata.Space
24
+
25
+ ::: mlarray.metadata.SpaceAxis
26
+
27
+ ::: mlarray.metadata.FromArray
28
+
29
+ ::: mlarray.metadata.Values
30
+
31
+ ::: mlarray.metadata.Storage
32
+
33
+ ::: mlarray.metadata.SourceEntry
34
+
35
+ ::: mlarray.metadata.space_from_geometry
36
+
37
+ ::: mlarray.metadata.MetadataError
38
+
39
+ ## Fingerprint Module
40
+
41
+ ::: mlarray.fingerprint.compute
42
+
43
+ ::: mlarray.fingerprint.matches
44
+
45
+ ## Units Module
46
+
47
+ ::: mlarray.units.unit_code