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.
- {mlarray-0.0.54 → mlarray-1.0.0}/.github/workflows/workflow.yml +9 -5
- {mlarray-0.0.54 → mlarray-1.0.0}/PKG-INFO +52 -71
- {mlarray-0.0.54 → mlarray-1.0.0}/README.md +45 -68
- mlarray-1.0.0/bench/README.md +8 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/bench/benchmark_formats.py +30 -25
- mlarray-1.0.0/docs/api.md +47 -0
- mlarray-1.0.0/docs/cli.md +52 -0
- mlarray-1.0.0/docs/design/followups.md +40 -0
- mlarray-1.0.0/docs/design/rework-plan.md +361 -0
- mlarray-1.0.0/docs/design/spec.md +477 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/docs/optimization.md +23 -19
- mlarray-1.0.0/docs/schema.md +152 -0
- mlarray-1.0.0/docs/usage.md +246 -0
- mlarray-1.0.0/examples/example_bboxes_only.py +36 -0
- mlarray-1.0.0/examples/example_channel.py +32 -0
- mlarray-1.0.0/examples/example_compress_decompress.py +50 -0
- mlarray-1.0.0/examples/example_compressed_vs_uncompressed.py +44 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/examples/example_in_memory_constructors.py +6 -4
- mlarray-1.0.0/examples/example_metadata_only.py +21 -0
- mlarray-1.0.0/examples/example_non_spatial.py +33 -0
- mlarray-1.0.0/examples/example_open.py +28 -0
- mlarray-1.0.0/examples/example_save_load.py +41 -0
- mlarray-1.0.0/mlarray/__init__.py +77 -0
- mlarray-1.0.0/mlarray/blosc2_layout_strategies.py +202 -0
- mlarray-1.0.0/mlarray/cli.py +105 -0
- mlarray-1.0.0/mlarray/constructors.py +964 -0
- mlarray-1.0.0/mlarray/fingerprint.py +81 -0
- mlarray-1.0.0/mlarray/metadata.py +1313 -0
- mlarray-1.0.0/mlarray/metadata_io.py +125 -0
- mlarray-1.0.0/mlarray/mlarray.py +1180 -0
- mlarray-1.0.0/mlarray/schemas/mlarray-1.0.json +204 -0
- mlarray-1.0.0/mlarray/schemas/profile-medical-1.0.json +14 -0
- mlarray-1.0.0/mlarray/schemas/profile-ml-1.0.json +44 -0
- mlarray-1.0.0/mlarray/schemas/units-1.0.json +31 -0
- mlarray-1.0.0/mlarray/storage.py +332 -0
- mlarray-1.0.0/mlarray/units.py +24 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/PKG-INFO +52 -71
- {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/SOURCES.txt +32 -10
- {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/requires.txt +4 -1
- {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/scm_file_list.json +22 -8
- mlarray-1.0.0/mlarray.egg-info/scm_version.json +8 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/pyproject.toml +8 -4
- mlarray-1.0.0/tests/nnunet_reference.py +197 -0
- mlarray-1.0.0/tests/test_asarray.py +145 -0
- mlarray-1.0.0/tests/test_cli.py +164 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_compress_decompress.py +37 -23
- {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_constructors.py +26 -2
- {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_create.py +22 -2
- mlarray-1.0.0/tests/test_layout.py +208 -0
- mlarray-1.0.0/tests/test_metadata_model.py +345 -0
- mlarray-1.0.0/tests/test_metadata_storage.py +252 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_open.py +47 -4
- {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_optimization.py +57 -34
- mlarray-1.0.0/tests/test_pickle.py +117 -0
- mlarray-1.0.0/tests/test_read_ahead.py +64 -0
- mlarray-1.0.0/tests/test_save.py +191 -0
- mlarray-1.0.0/tests/test_schemas.py +84 -0
- mlarray-1.0.0/tests/test_spec_examples.py +119 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/tests/test_usage.py +77 -79
- mlarray-0.0.54/MANIFEST.in +0 -1
- mlarray-0.0.54/bench/README.md +0 -56
- mlarray-0.0.54/bench/bench_convert_nii_to_mla_random_read.py +0 -586
- mlarray-0.0.54/bench/bench_io_blosc2_layouts.py +0 -1178
- mlarray-0.0.54/docs/api.md +0 -37
- mlarray-0.0.54/docs/cli.md +0 -59
- mlarray-0.0.54/docs/schema.md +0 -219
- mlarray-0.0.54/docs/usage.md +0 -270
- mlarray-0.0.54/examples/example_bboxes_only.py +0 -36
- mlarray-0.0.54/examples/example_channel.py +0 -34
- mlarray-0.0.54/examples/example_compress_decompress.py +0 -56
- mlarray-0.0.54/examples/example_compressed_vs_uncompressed.py +0 -42
- mlarray-0.0.54/examples/example_metadata_only.py +0 -31
- mlarray-0.0.54/examples/example_non_spatial.py +0 -42
- mlarray-0.0.54/examples/example_open.py +0 -36
- mlarray-0.0.54/examples/example_save_load.py +0 -34
- mlarray-0.0.54/mlarray/__init__.py +0 -85
- mlarray-0.0.54/mlarray/blosc2_layout_strategies.py +0 -766
- mlarray-0.0.54/mlarray/cli.py +0 -226
- mlarray-0.0.54/mlarray/meta.py +0 -1394
- mlarray-0.0.54/mlarray/mlarray.py +0 -2099
- mlarray-0.0.54/mlarray/utils.py +0 -17
- mlarray-0.0.54/mlarray.egg-info/scm_version.json +0 -8
- mlarray-0.0.54/tests/test_asarray.py +0 -85
- mlarray-0.0.54/tests/test_bboxes.py +0 -67
- mlarray-0.0.54/tests/test_cli.py +0 -420
- mlarray-0.0.54/tests/test_meta_safety.py +0 -173
- mlarray-0.0.54/tests/test_metadata.py +0 -153
- {mlarray-0.0.54 → mlarray-1.0.0}/.gitignore +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/LICENSE +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/assets/banner.png +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/bench/.gitignore +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/bench/helper/print_mla_layouts.py +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/docs/index.md +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/docs/why.md +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/examples/example_asarray.py +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/mkdocs.yml +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/dependency_links.txt +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/entry_points.txt +0 -0
- {mlarray-0.0.54 → mlarray-1.0.0}/mlarray.egg-info/top_level.txt +0 -0
- {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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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)
|
|
120
|
-
print(image.
|
|
121
|
-
|
|
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.
|
|
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
|
-
|
|
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("
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
`
|
|
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
|
|
264
|
-
mlarray_convert sample.mla
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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)
|
|
86
|
-
print(image.
|
|
87
|
-
|
|
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.
|
|
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
|
-
|
|
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("
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
`
|
|
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
|
|
230
|
-
mlarray_convert sample.mla
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|
|
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":
|
|
348
|
-
"disk_mb": round(total_disk_mb(
|
|
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:
|
|
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
|
-
|
|
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
|