inv2dcm 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.
- inv2dcm-1.0.0/LICENSE +23 -0
- inv2dcm-1.0.0/PKG-INFO +208 -0
- inv2dcm-1.0.0/README.md +182 -0
- inv2dcm-1.0.0/inv2dcm.egg-info/PKG-INFO +208 -0
- inv2dcm-1.0.0/inv2dcm.egg-info/SOURCES.txt +10 -0
- inv2dcm-1.0.0/inv2dcm.egg-info/dependency_links.txt +1 -0
- inv2dcm-1.0.0/inv2dcm.egg-info/entry_points.txt +2 -0
- inv2dcm-1.0.0/inv2dcm.egg-info/requires.txt +3 -0
- inv2dcm-1.0.0/inv2dcm.egg-info/top_level.txt +1 -0
- inv2dcm-1.0.0/inv2dcm.py +563 -0
- inv2dcm-1.0.0/pyproject.toml +51 -0
- inv2dcm-1.0.0/setup.cfg +4 -0
inv2dcm-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Boost Software License - Version 1.0 - August 17th, 2003
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person or organization
|
|
4
|
+
obtaining a copy of the software and accompanying documentation covered by
|
|
5
|
+
this license (the "Software") to use, reproduce, display, distribute,
|
|
6
|
+
execute, and transmit the Software, and to prepare derivative works of the
|
|
7
|
+
Software, and to permit third-parties to whom the Software is furnished to
|
|
8
|
+
do so, all subject to the following:
|
|
9
|
+
|
|
10
|
+
The copyright notices in the Software and this entire statement, including
|
|
11
|
+
the above license grant, this restriction and the following disclaimer,
|
|
12
|
+
must be included in all copies of the Software, in whole or in part, and
|
|
13
|
+
all derivative works of the Software, unless such copies or derivative
|
|
14
|
+
works are solely in the form of machine-executable object code generated by
|
|
15
|
+
a source language processor.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
|
|
20
|
+
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
|
|
21
|
+
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
|
|
22
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
23
|
+
DEALINGS IN THE SOFTWARE.
|
inv2dcm-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inv2dcm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Convert InVivo .inv dental CBCT volumes to Enhanced CT DICOM
|
|
5
|
+
Author: mrexodia
|
|
6
|
+
License-Expression: BSL-1.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/mrexodia/inv2dcm
|
|
8
|
+
Project-URL: Repository, https://github.com/mrexodia/inv2dcm
|
|
9
|
+
Project-URL: Issues, https://github.com/mrexodia/inv2dcm/issues
|
|
10
|
+
Keywords: inv,dicom,cbct,cone beam ct,dental imaging,pydicom,enhanced ct,jpeg 2000
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: imagecodecs>=2024.12.30
|
|
23
|
+
Requires-Dist: numpy>=2.0.0
|
|
24
|
+
Requires-Dist: pydicom>=3.0.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# inv2dcm
|
|
28
|
+
|
|
29
|
+
**inv2dcm** is a Python package and CLI for converting **InVivo `.inv` files** into **Enhanced CT DICOM (`.dcm`)**.
|
|
30
|
+
|
|
31
|
+
It is built for **dental CBCT / cone beam CT** data associated with **InVivoDentalViewer**, **CliniView**, and **Anatomage** workflows, including scans from systems such as **Instrumentarium Dental ORTHOPANTOMOGRAPH OP 3D Pro**.
|
|
32
|
+
|
|
33
|
+
The `.inv` files handled here use the **`INVFile`** XML container format, with an `AppendedData` payload containing JPEG 2000-compressed voxel data.
|
|
34
|
+
|
|
35
|
+
If you are searching for an **INV to DICOM converter**, **InVivo INV file reader**, **dental CBCT to DICOM tool**, **cone beam CT to pydicom**, or a way to convert **`.inv` to 3D Slicer / VolView compatible DICOM**, this package is intended for that use case.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- Converts proprietary **`.inv`** volume files to **Enhanced CT DICOM**
|
|
40
|
+
- Outputs a **single multi-frame DICOM file**
|
|
41
|
+
- Reads scan metadata from the INV XML header
|
|
42
|
+
- Decodes embedded **JPEG 2000 codestreams** in pure Python via `imagecodecs`
|
|
43
|
+
- Produces output readable by:
|
|
44
|
+
- `pydicom`
|
|
45
|
+
- **3D Slicer**
|
|
46
|
+
- **VolView**
|
|
47
|
+
- other DICOM-capable medical imaging tools
|
|
48
|
+
- Includes both a Python library API and a CLI command: `inv2dcm`
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
### From source
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Development install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install -e .
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## CLI usage
|
|
65
|
+
|
|
66
|
+
After installation, the console script is available as:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
inv2dcm
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Convert a file
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
inv2dcm scan.inv
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This writes `<input-stem>.dcm` next to the source file.
|
|
79
|
+
|
|
80
|
+
### Convert to a specific output path
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
inv2dcm scan.inv output/scan.dcm
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Control DICOM window metadata
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
inv2dcm scan.inv output/scan.dcm --window auto
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Available modes:
|
|
93
|
+
|
|
94
|
+
- `auto` — derive a sane window center/width from the volume data
|
|
95
|
+
- `source` — preserve the source INV window metadata
|
|
96
|
+
- `none` — omit DICOM window center/width metadata
|
|
97
|
+
|
|
98
|
+
## Python library usage
|
|
99
|
+
|
|
100
|
+
`inv2dcm` is importable as a normal Python library, so it is suitable for publishing on **PyPI** and embedding in larger imaging workflows.
|
|
101
|
+
|
|
102
|
+
### Simple conversion
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from pathlib import Path
|
|
106
|
+
from inv2dcm import convert_inv_to_dicom
|
|
107
|
+
|
|
108
|
+
convert_inv_to_dicom(Path("scan.inv"), Path("scan.dcm"), window_mode="auto")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Inspect decoded metadata and volume data
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from pathlib import Path
|
|
115
|
+
from inv2dcm import load_inv
|
|
116
|
+
|
|
117
|
+
inv = load_inv(Path("scan.inv"))
|
|
118
|
+
print(inv.metadata)
|
|
119
|
+
print(inv.volume.shape)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Build the `pydicom` dataset yourself
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from pathlib import Path
|
|
126
|
+
from inv2dcm import build_enhanced_ct, load_inv
|
|
127
|
+
|
|
128
|
+
inv = load_inv(Path("scan.inv"))
|
|
129
|
+
ds = build_enhanced_ct(inv, Path("scan.dcm"))
|
|
130
|
+
print(ds.SOPClassUID)
|
|
131
|
+
print(ds.NumberOfFrames)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Public API
|
|
135
|
+
|
|
136
|
+
- `load_inv(path)`
|
|
137
|
+
- `build_enhanced_ct(inv, output_path, window_mode="auto")`
|
|
138
|
+
- `convert_inv_to_dicom(input_path, output_path, window_mode="auto")`
|
|
139
|
+
|
|
140
|
+
## Verify with pydicom
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import pydicom
|
|
144
|
+
|
|
145
|
+
ds = pydicom.dcmread("scan.dcm")
|
|
146
|
+
print(ds.NumberOfFrames)
|
|
147
|
+
print(ds.Rows, ds.Columns)
|
|
148
|
+
print(ds.pixel_array.shape)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## View the converted DICOM in the browser
|
|
152
|
+
|
|
153
|
+
You can drag the generated `.dcm` file directly into **VolView**:
|
|
154
|
+
|
|
155
|
+
- https://volview.kitware.app/
|
|
156
|
+
|
|
157
|
+
This is a convenient way to inspect the converted dental CBCT volume in the browser without installing a desktop viewer.
|
|
158
|
+
|
|
159
|
+
## What kind of INV files are supported?
|
|
160
|
+
|
|
161
|
+
This project targets **InVivo dental CBCT `.inv` files** using the **`INVFile`** container format.
|
|
162
|
+
|
|
163
|
+
In practice, that means files with:
|
|
164
|
+
|
|
165
|
+
- an XML document rooted at `INVFile`
|
|
166
|
+
- bytes that begin with `<INVFile`
|
|
167
|
+
- an `AppendedData` block
|
|
168
|
+
- JPEG 2000 codestream containers storing the voxel data
|
|
169
|
+
|
|
170
|
+
That matches the structure documented by reverse-engineering work on InVivo / CliniView / Anatomage-related CBCT archives.
|
|
171
|
+
|
|
172
|
+
## Reverse-engineering references
|
|
173
|
+
|
|
174
|
+
This package was informed by prior work on the InVivo / CliniView `.inv` format and related dental CBCT tooling:
|
|
175
|
+
|
|
176
|
+
- holland.sh — **Digital CBCT scans**: https://holland.sh/post/digital-cbct-scans/
|
|
177
|
+
- InvivoConvert: https://github.com/AstrisCantCode/InvivoConvert
|
|
178
|
+
- InvivoExtractor: https://github.com/Bostwickenator/InvivoExtractor
|
|
179
|
+
- Reverse engineering my head: https://dev-with-alex.blogspot.com/2018/03/reverse-engineering-my-head.html
|
|
180
|
+
|
|
181
|
+
## Output format
|
|
182
|
+
|
|
183
|
+
The generated DICOM file uses:
|
|
184
|
+
|
|
185
|
+
- **SOP Class**: Enhanced CT Image Storage
|
|
186
|
+
- **Transfer Syntax**: Explicit VR Little Endian
|
|
187
|
+
- **Photometric Interpretation**: `MONOCHROME2`
|
|
188
|
+
- **Multi-frame volume storage** for easy loading in Python and modern viewers
|
|
189
|
+
|
|
190
|
+
## Why this package exists
|
|
191
|
+
|
|
192
|
+
The **`.inv`** format is awkward to use in standard Python medical imaging workflows. `inv2dcm` converts proprietary **InVivo CBCT** data into standard **DICOM** so it can be opened with **pydicom**, reviewed in **3D Slicer**, or inspected in **VolView**.
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
The CLI entry point is:
|
|
197
|
+
|
|
198
|
+
- `inv2dcm` → `inv2dcm.py`
|
|
199
|
+
|
|
200
|
+
You can also run it directly during development:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
python inv2dcm.py scan.inv scan.dcm
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Keywords
|
|
207
|
+
|
|
208
|
+
INV to DICOM, InVivo INV converter, INVFile parser, dental CBCT DICOM converter, cone beam CT DICOM, JPEG 2000 medical imaging, pydicom INV import, InVivoDentalViewer export, CliniView INV parser, Anatomage InVivo, VolView DICOM viewer.
|
inv2dcm-1.0.0/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# inv2dcm
|
|
2
|
+
|
|
3
|
+
**inv2dcm** is a Python package and CLI for converting **InVivo `.inv` files** into **Enhanced CT DICOM (`.dcm`)**.
|
|
4
|
+
|
|
5
|
+
It is built for **dental CBCT / cone beam CT** data associated with **InVivoDentalViewer**, **CliniView**, and **Anatomage** workflows, including scans from systems such as **Instrumentarium Dental ORTHOPANTOMOGRAPH OP 3D Pro**.
|
|
6
|
+
|
|
7
|
+
The `.inv` files handled here use the **`INVFile`** XML container format, with an `AppendedData` payload containing JPEG 2000-compressed voxel data.
|
|
8
|
+
|
|
9
|
+
If you are searching for an **INV to DICOM converter**, **InVivo INV file reader**, **dental CBCT to DICOM tool**, **cone beam CT to pydicom**, or a way to convert **`.inv` to 3D Slicer / VolView compatible DICOM**, this package is intended for that use case.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Converts proprietary **`.inv`** volume files to **Enhanced CT DICOM**
|
|
14
|
+
- Outputs a **single multi-frame DICOM file**
|
|
15
|
+
- Reads scan metadata from the INV XML header
|
|
16
|
+
- Decodes embedded **JPEG 2000 codestreams** in pure Python via `imagecodecs`
|
|
17
|
+
- Produces output readable by:
|
|
18
|
+
- `pydicom`
|
|
19
|
+
- **3D Slicer**
|
|
20
|
+
- **VolView**
|
|
21
|
+
- other DICOM-capable medical imaging tools
|
|
22
|
+
- Includes both a Python library API and a CLI command: `inv2dcm`
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
### From source
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Development install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -e .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CLI usage
|
|
39
|
+
|
|
40
|
+
After installation, the console script is available as:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
inv2dcm
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Convert a file
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
inv2dcm scan.inv
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This writes `<input-stem>.dcm` next to the source file.
|
|
53
|
+
|
|
54
|
+
### Convert to a specific output path
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
inv2dcm scan.inv output/scan.dcm
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Control DICOM window metadata
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
inv2dcm scan.inv output/scan.dcm --window auto
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Available modes:
|
|
67
|
+
|
|
68
|
+
- `auto` — derive a sane window center/width from the volume data
|
|
69
|
+
- `source` — preserve the source INV window metadata
|
|
70
|
+
- `none` — omit DICOM window center/width metadata
|
|
71
|
+
|
|
72
|
+
## Python library usage
|
|
73
|
+
|
|
74
|
+
`inv2dcm` is importable as a normal Python library, so it is suitable for publishing on **PyPI** and embedding in larger imaging workflows.
|
|
75
|
+
|
|
76
|
+
### Simple conversion
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
from inv2dcm import convert_inv_to_dicom
|
|
81
|
+
|
|
82
|
+
convert_inv_to_dicom(Path("scan.inv"), Path("scan.dcm"), window_mode="auto")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Inspect decoded metadata and volume data
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from pathlib import Path
|
|
89
|
+
from inv2dcm import load_inv
|
|
90
|
+
|
|
91
|
+
inv = load_inv(Path("scan.inv"))
|
|
92
|
+
print(inv.metadata)
|
|
93
|
+
print(inv.volume.shape)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Build the `pydicom` dataset yourself
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
from inv2dcm import build_enhanced_ct, load_inv
|
|
101
|
+
|
|
102
|
+
inv = load_inv(Path("scan.inv"))
|
|
103
|
+
ds = build_enhanced_ct(inv, Path("scan.dcm"))
|
|
104
|
+
print(ds.SOPClassUID)
|
|
105
|
+
print(ds.NumberOfFrames)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Public API
|
|
109
|
+
|
|
110
|
+
- `load_inv(path)`
|
|
111
|
+
- `build_enhanced_ct(inv, output_path, window_mode="auto")`
|
|
112
|
+
- `convert_inv_to_dicom(input_path, output_path, window_mode="auto")`
|
|
113
|
+
|
|
114
|
+
## Verify with pydicom
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import pydicom
|
|
118
|
+
|
|
119
|
+
ds = pydicom.dcmread("scan.dcm")
|
|
120
|
+
print(ds.NumberOfFrames)
|
|
121
|
+
print(ds.Rows, ds.Columns)
|
|
122
|
+
print(ds.pixel_array.shape)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## View the converted DICOM in the browser
|
|
126
|
+
|
|
127
|
+
You can drag the generated `.dcm` file directly into **VolView**:
|
|
128
|
+
|
|
129
|
+
- https://volview.kitware.app/
|
|
130
|
+
|
|
131
|
+
This is a convenient way to inspect the converted dental CBCT volume in the browser without installing a desktop viewer.
|
|
132
|
+
|
|
133
|
+
## What kind of INV files are supported?
|
|
134
|
+
|
|
135
|
+
This project targets **InVivo dental CBCT `.inv` files** using the **`INVFile`** container format.
|
|
136
|
+
|
|
137
|
+
In practice, that means files with:
|
|
138
|
+
|
|
139
|
+
- an XML document rooted at `INVFile`
|
|
140
|
+
- bytes that begin with `<INVFile`
|
|
141
|
+
- an `AppendedData` block
|
|
142
|
+
- JPEG 2000 codestream containers storing the voxel data
|
|
143
|
+
|
|
144
|
+
That matches the structure documented by reverse-engineering work on InVivo / CliniView / Anatomage-related CBCT archives.
|
|
145
|
+
|
|
146
|
+
## Reverse-engineering references
|
|
147
|
+
|
|
148
|
+
This package was informed by prior work on the InVivo / CliniView `.inv` format and related dental CBCT tooling:
|
|
149
|
+
|
|
150
|
+
- holland.sh — **Digital CBCT scans**: https://holland.sh/post/digital-cbct-scans/
|
|
151
|
+
- InvivoConvert: https://github.com/AstrisCantCode/InvivoConvert
|
|
152
|
+
- InvivoExtractor: https://github.com/Bostwickenator/InvivoExtractor
|
|
153
|
+
- Reverse engineering my head: https://dev-with-alex.blogspot.com/2018/03/reverse-engineering-my-head.html
|
|
154
|
+
|
|
155
|
+
## Output format
|
|
156
|
+
|
|
157
|
+
The generated DICOM file uses:
|
|
158
|
+
|
|
159
|
+
- **SOP Class**: Enhanced CT Image Storage
|
|
160
|
+
- **Transfer Syntax**: Explicit VR Little Endian
|
|
161
|
+
- **Photometric Interpretation**: `MONOCHROME2`
|
|
162
|
+
- **Multi-frame volume storage** for easy loading in Python and modern viewers
|
|
163
|
+
|
|
164
|
+
## Why this package exists
|
|
165
|
+
|
|
166
|
+
The **`.inv`** format is awkward to use in standard Python medical imaging workflows. `inv2dcm` converts proprietary **InVivo CBCT** data into standard **DICOM** so it can be opened with **pydicom**, reviewed in **3D Slicer**, or inspected in **VolView**.
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
The CLI entry point is:
|
|
171
|
+
|
|
172
|
+
- `inv2dcm` → `inv2dcm.py`
|
|
173
|
+
|
|
174
|
+
You can also run it directly during development:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
python inv2dcm.py scan.inv scan.dcm
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Keywords
|
|
181
|
+
|
|
182
|
+
INV to DICOM, InVivo INV converter, INVFile parser, dental CBCT DICOM converter, cone beam CT DICOM, JPEG 2000 medical imaging, pydicom INV import, InVivoDentalViewer export, CliniView INV parser, Anatomage InVivo, VolView DICOM viewer.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inv2dcm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Convert InVivo .inv dental CBCT volumes to Enhanced CT DICOM
|
|
5
|
+
Author: mrexodia
|
|
6
|
+
License-Expression: BSL-1.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/mrexodia/inv2dcm
|
|
8
|
+
Project-URL: Repository, https://github.com/mrexodia/inv2dcm
|
|
9
|
+
Project-URL: Issues, https://github.com/mrexodia/inv2dcm/issues
|
|
10
|
+
Keywords: inv,dicom,cbct,cone beam ct,dental imaging,pydicom,enhanced ct,jpeg 2000
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: imagecodecs>=2024.12.30
|
|
23
|
+
Requires-Dist: numpy>=2.0.0
|
|
24
|
+
Requires-Dist: pydicom>=3.0.0
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# inv2dcm
|
|
28
|
+
|
|
29
|
+
**inv2dcm** is a Python package and CLI for converting **InVivo `.inv` files** into **Enhanced CT DICOM (`.dcm`)**.
|
|
30
|
+
|
|
31
|
+
It is built for **dental CBCT / cone beam CT** data associated with **InVivoDentalViewer**, **CliniView**, and **Anatomage** workflows, including scans from systems such as **Instrumentarium Dental ORTHOPANTOMOGRAPH OP 3D Pro**.
|
|
32
|
+
|
|
33
|
+
The `.inv` files handled here use the **`INVFile`** XML container format, with an `AppendedData` payload containing JPEG 2000-compressed voxel data.
|
|
34
|
+
|
|
35
|
+
If you are searching for an **INV to DICOM converter**, **InVivo INV file reader**, **dental CBCT to DICOM tool**, **cone beam CT to pydicom**, or a way to convert **`.inv` to 3D Slicer / VolView compatible DICOM**, this package is intended for that use case.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- Converts proprietary **`.inv`** volume files to **Enhanced CT DICOM**
|
|
40
|
+
- Outputs a **single multi-frame DICOM file**
|
|
41
|
+
- Reads scan metadata from the INV XML header
|
|
42
|
+
- Decodes embedded **JPEG 2000 codestreams** in pure Python via `imagecodecs`
|
|
43
|
+
- Produces output readable by:
|
|
44
|
+
- `pydicom`
|
|
45
|
+
- **3D Slicer**
|
|
46
|
+
- **VolView**
|
|
47
|
+
- other DICOM-capable medical imaging tools
|
|
48
|
+
- Includes both a Python library API and a CLI command: `inv2dcm`
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
### From source
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Development install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install -e .
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## CLI usage
|
|
65
|
+
|
|
66
|
+
After installation, the console script is available as:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
inv2dcm
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Convert a file
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
inv2dcm scan.inv
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This writes `<input-stem>.dcm` next to the source file.
|
|
79
|
+
|
|
80
|
+
### Convert to a specific output path
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
inv2dcm scan.inv output/scan.dcm
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Control DICOM window metadata
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
inv2dcm scan.inv output/scan.dcm --window auto
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Available modes:
|
|
93
|
+
|
|
94
|
+
- `auto` — derive a sane window center/width from the volume data
|
|
95
|
+
- `source` — preserve the source INV window metadata
|
|
96
|
+
- `none` — omit DICOM window center/width metadata
|
|
97
|
+
|
|
98
|
+
## Python library usage
|
|
99
|
+
|
|
100
|
+
`inv2dcm` is importable as a normal Python library, so it is suitable for publishing on **PyPI** and embedding in larger imaging workflows.
|
|
101
|
+
|
|
102
|
+
### Simple conversion
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from pathlib import Path
|
|
106
|
+
from inv2dcm import convert_inv_to_dicom
|
|
107
|
+
|
|
108
|
+
convert_inv_to_dicom(Path("scan.inv"), Path("scan.dcm"), window_mode="auto")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Inspect decoded metadata and volume data
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from pathlib import Path
|
|
115
|
+
from inv2dcm import load_inv
|
|
116
|
+
|
|
117
|
+
inv = load_inv(Path("scan.inv"))
|
|
118
|
+
print(inv.metadata)
|
|
119
|
+
print(inv.volume.shape)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Build the `pydicom` dataset yourself
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from pathlib import Path
|
|
126
|
+
from inv2dcm import build_enhanced_ct, load_inv
|
|
127
|
+
|
|
128
|
+
inv = load_inv(Path("scan.inv"))
|
|
129
|
+
ds = build_enhanced_ct(inv, Path("scan.dcm"))
|
|
130
|
+
print(ds.SOPClassUID)
|
|
131
|
+
print(ds.NumberOfFrames)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Public API
|
|
135
|
+
|
|
136
|
+
- `load_inv(path)`
|
|
137
|
+
- `build_enhanced_ct(inv, output_path, window_mode="auto")`
|
|
138
|
+
- `convert_inv_to_dicom(input_path, output_path, window_mode="auto")`
|
|
139
|
+
|
|
140
|
+
## Verify with pydicom
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import pydicom
|
|
144
|
+
|
|
145
|
+
ds = pydicom.dcmread("scan.dcm")
|
|
146
|
+
print(ds.NumberOfFrames)
|
|
147
|
+
print(ds.Rows, ds.Columns)
|
|
148
|
+
print(ds.pixel_array.shape)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## View the converted DICOM in the browser
|
|
152
|
+
|
|
153
|
+
You can drag the generated `.dcm` file directly into **VolView**:
|
|
154
|
+
|
|
155
|
+
- https://volview.kitware.app/
|
|
156
|
+
|
|
157
|
+
This is a convenient way to inspect the converted dental CBCT volume in the browser without installing a desktop viewer.
|
|
158
|
+
|
|
159
|
+
## What kind of INV files are supported?
|
|
160
|
+
|
|
161
|
+
This project targets **InVivo dental CBCT `.inv` files** using the **`INVFile`** container format.
|
|
162
|
+
|
|
163
|
+
In practice, that means files with:
|
|
164
|
+
|
|
165
|
+
- an XML document rooted at `INVFile`
|
|
166
|
+
- bytes that begin with `<INVFile`
|
|
167
|
+
- an `AppendedData` block
|
|
168
|
+
- JPEG 2000 codestream containers storing the voxel data
|
|
169
|
+
|
|
170
|
+
That matches the structure documented by reverse-engineering work on InVivo / CliniView / Anatomage-related CBCT archives.
|
|
171
|
+
|
|
172
|
+
## Reverse-engineering references
|
|
173
|
+
|
|
174
|
+
This package was informed by prior work on the InVivo / CliniView `.inv` format and related dental CBCT tooling:
|
|
175
|
+
|
|
176
|
+
- holland.sh — **Digital CBCT scans**: https://holland.sh/post/digital-cbct-scans/
|
|
177
|
+
- InvivoConvert: https://github.com/AstrisCantCode/InvivoConvert
|
|
178
|
+
- InvivoExtractor: https://github.com/Bostwickenator/InvivoExtractor
|
|
179
|
+
- Reverse engineering my head: https://dev-with-alex.blogspot.com/2018/03/reverse-engineering-my-head.html
|
|
180
|
+
|
|
181
|
+
## Output format
|
|
182
|
+
|
|
183
|
+
The generated DICOM file uses:
|
|
184
|
+
|
|
185
|
+
- **SOP Class**: Enhanced CT Image Storage
|
|
186
|
+
- **Transfer Syntax**: Explicit VR Little Endian
|
|
187
|
+
- **Photometric Interpretation**: `MONOCHROME2`
|
|
188
|
+
- **Multi-frame volume storage** for easy loading in Python and modern viewers
|
|
189
|
+
|
|
190
|
+
## Why this package exists
|
|
191
|
+
|
|
192
|
+
The **`.inv`** format is awkward to use in standard Python medical imaging workflows. `inv2dcm` converts proprietary **InVivo CBCT** data into standard **DICOM** so it can be opened with **pydicom**, reviewed in **3D Slicer**, or inspected in **VolView**.
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
The CLI entry point is:
|
|
197
|
+
|
|
198
|
+
- `inv2dcm` → `inv2dcm.py`
|
|
199
|
+
|
|
200
|
+
You can also run it directly during development:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
python inv2dcm.py scan.inv scan.dcm
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Keywords
|
|
207
|
+
|
|
208
|
+
INV to DICOM, InVivo INV converter, INVFile parser, dental CBCT DICOM converter, cone beam CT DICOM, JPEG 2000 medical imaging, pydicom INV import, InVivoDentalViewer export, CliniView INV parser, Anatomage InVivo, VolView DICOM viewer.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inv2dcm
|
inv2dcm-1.0.0/inv2dcm.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import re
|
|
6
|
+
import struct
|
|
7
|
+
import sys
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
import imagecodecs
|
|
14
|
+
import numpy as np
|
|
15
|
+
from pydicom.dataset import Dataset, FileDataset, FileMetaDataset
|
|
16
|
+
from pydicom.sequence import Sequence
|
|
17
|
+
from pydicom.tag import Tag
|
|
18
|
+
from pydicom.uid import ExplicitVRLittleEndian, generate_uid
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__version__ = "1.0.0"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"InvMetadata",
|
|
24
|
+
"InvData",
|
|
25
|
+
"load_inv",
|
|
26
|
+
"build_enhanced_ct",
|
|
27
|
+
"convert_inv_to_dicom",
|
|
28
|
+
"parse_args",
|
|
29
|
+
"main",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
APPENDED_DATA_OPEN_RE = re.compile(br"<AppendedData\b[^>]*>", re.IGNORECASE)
|
|
34
|
+
APPENDED_DATA_CLOSE = b"</AppendedData>"
|
|
35
|
+
IMPLEMENTATION_CLASS_UID = "1.2.826.0.1.3680043.8.498.20260314.1"
|
|
36
|
+
IMPLEMENTATION_VERSION_NAME = "INV2DCM_1"
|
|
37
|
+
ENHANCED_CT_SOP_CLASS_UID = "1.2.840.10008.5.1.4.1.1.2.1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_ds(value: float | int | str) -> str:
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
return value
|
|
43
|
+
return format(value, ".12g")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class InvMetadata:
|
|
48
|
+
patient_name: str
|
|
49
|
+
patient_id: str
|
|
50
|
+
patient_birth_date: str
|
|
51
|
+
study_date: str
|
|
52
|
+
study_time: str
|
|
53
|
+
modality: str
|
|
54
|
+
manufacturer: str
|
|
55
|
+
model_name: str
|
|
56
|
+
device_serial_number: str
|
|
57
|
+
patient_position: str
|
|
58
|
+
kvp: str
|
|
59
|
+
image_type: list[str]
|
|
60
|
+
study_instance_uid: str
|
|
61
|
+
series_instance_uid: str
|
|
62
|
+
frame_of_reference_uid: str
|
|
63
|
+
sop_instance_uid: str
|
|
64
|
+
sop_class_uid: str
|
|
65
|
+
scalar_type: str
|
|
66
|
+
dimensions: tuple[int, int, int]
|
|
67
|
+
pixel_spacing: tuple[float, float]
|
|
68
|
+
slice_spacing: float
|
|
69
|
+
origin: tuple[float, float, float]
|
|
70
|
+
image_orientation_patient: tuple[float, float, float, float, float, float]
|
|
71
|
+
samples_per_pixel: int
|
|
72
|
+
photometric_interpretation: str
|
|
73
|
+
bits_allocated: int
|
|
74
|
+
bits_stored: int
|
|
75
|
+
high_bit: int
|
|
76
|
+
pixel_representation: int
|
|
77
|
+
rescale_intercept: float
|
|
78
|
+
rescale_slope: float
|
|
79
|
+
rescale_type: str
|
|
80
|
+
window_width: float | None
|
|
81
|
+
window_center: float | None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True)
|
|
85
|
+
class InvData:
|
|
86
|
+
metadata: InvMetadata
|
|
87
|
+
volume: np.ndarray
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def decode_length_prefixed_text(binary_value: str | None) -> str:
|
|
91
|
+
if not binary_value:
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
raw = base64.b64decode(binary_value)
|
|
95
|
+
if len(raw) >= 4:
|
|
96
|
+
declared_size = struct.unpack_from("<I", raw, 0)[0]
|
|
97
|
+
raw = raw[4 : 4 + declared_size]
|
|
98
|
+
|
|
99
|
+
for encoding in ("utf-8", "cp1250", "cp1252", "latin1"):
|
|
100
|
+
try:
|
|
101
|
+
return raw.decode(encoding).strip("\x00 ")
|
|
102
|
+
except UnicodeDecodeError:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
return raw.decode("latin1", errors="replace").strip("\x00 ")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def clean_text(value: str | None) -> str:
|
|
109
|
+
if not value:
|
|
110
|
+
return ""
|
|
111
|
+
return value.replace("\x00", "").strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def split_numbers(value: str | None) -> list[str]:
|
|
115
|
+
cleaned = clean_text(value)
|
|
116
|
+
if not cleaned:
|
|
117
|
+
return []
|
|
118
|
+
return [part for part in re.split(r"[\\\s,]+", cleaned) if part]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_ints(value: str | None) -> tuple[int, ...]:
|
|
122
|
+
return tuple(int(part) for part in split_numbers(value))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_floats(value: str | None) -> tuple[float, ...]:
|
|
126
|
+
return tuple(float(part) for part in split_numbers(value))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_element(root: ET.Element, tag: str) -> ET.Element | None:
|
|
130
|
+
return root.find(f".//{tag}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_value(root: ET.Element, tag: str, *, binary_first: bool = False) -> str:
|
|
134
|
+
element = get_element(root, tag)
|
|
135
|
+
if element is None:
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
value_attr = clean_text(element.attrib.get("Value"))
|
|
139
|
+
binary_attr = clean_text(element.attrib.get("BinaryValue"))
|
|
140
|
+
|
|
141
|
+
if binary_first:
|
|
142
|
+
decoded = decode_length_prefixed_text(binary_attr)
|
|
143
|
+
return clean_text(decoded or value_attr)
|
|
144
|
+
|
|
145
|
+
return value_attr or clean_text(decode_length_prefixed_text(binary_attr))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_patient_name(raw_name: str) -> str:
|
|
149
|
+
raw_name = clean_text(raw_name)
|
|
150
|
+
if not raw_name:
|
|
151
|
+
return "Anonymous"
|
|
152
|
+
if "^" in raw_name:
|
|
153
|
+
return raw_name
|
|
154
|
+
if "," in raw_name:
|
|
155
|
+
family_name, given_name = raw_name.split(",", 1)
|
|
156
|
+
return f"{family_name}^{given_name}"
|
|
157
|
+
return raw_name
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_inv_file(path: Path) -> tuple[ET.Element, bytes]:
|
|
161
|
+
data = path.read_bytes()
|
|
162
|
+
|
|
163
|
+
stripped = data.lstrip(b"\xef\xbb\xbf\r\n\t ")
|
|
164
|
+
if not stripped.startswith(b"<INVFile"):
|
|
165
|
+
raise ValueError(f"{path} does not start with <INVFile")
|
|
166
|
+
|
|
167
|
+
match = APPENDED_DATA_OPEN_RE.search(data)
|
|
168
|
+
if match is None:
|
|
169
|
+
raise ValueError(f"Could not find <AppendedData> in {path}")
|
|
170
|
+
|
|
171
|
+
raw_start = match.end()
|
|
172
|
+
raw_end = data.index(APPENDED_DATA_CLOSE, raw_start)
|
|
173
|
+
appended = data[raw_start:raw_end]
|
|
174
|
+
|
|
175
|
+
xml_bytes = data[:raw_start] + APPENDED_DATA_CLOSE + data[raw_end + len(APPENDED_DATA_CLOSE) :]
|
|
176
|
+
root = ET.fromstring(xml_bytes)
|
|
177
|
+
return root, appended
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_metadata(root: ET.Element) -> InvMetadata:
|
|
181
|
+
volume_element = get_element(root, "Volume")
|
|
182
|
+
if volume_element is None:
|
|
183
|
+
raise ValueError("INV XML is missing the <Volume> element")
|
|
184
|
+
|
|
185
|
+
dimensions = parse_ints(volume_element.attrib.get("Dimensions"))
|
|
186
|
+
if len(dimensions) != 3:
|
|
187
|
+
raise ValueError(f"Unexpected volume dimensions: {volume_element.attrib.get('Dimensions')!r}")
|
|
188
|
+
|
|
189
|
+
spacing = parse_floats(volume_element.attrib.get("Spacing"))
|
|
190
|
+
if len(spacing) != 3:
|
|
191
|
+
raise ValueError(f"Unexpected volume spacing: {volume_element.attrib.get('Spacing')!r}")
|
|
192
|
+
|
|
193
|
+
origin = parse_floats(volume_element.attrib.get("Origin"))
|
|
194
|
+
if len(origin) != 3:
|
|
195
|
+
origin = (0.0, 0.0, 0.0)
|
|
196
|
+
|
|
197
|
+
scalar_type = clean_text(volume_element.attrib.get("ScalarType")) or "UInt16"
|
|
198
|
+
|
|
199
|
+
window_level = parse_floats(volume_element.attrib.get("WindowLevel"))
|
|
200
|
+
window_width = window_level[0] if len(window_level) >= 1 else None
|
|
201
|
+
window_center = window_level[1] if len(window_level) >= 2 else None
|
|
202
|
+
|
|
203
|
+
image_type = split_numbers(get_value(root, "ImageType"))
|
|
204
|
+
if not image_type:
|
|
205
|
+
image_type = ["ORIGINAL", "PRIMARY", "VOLUME", "NONE"]
|
|
206
|
+
|
|
207
|
+
orientation = parse_floats(get_value(root, "ImageOrientationPatient"))
|
|
208
|
+
if len(orientation) != 6:
|
|
209
|
+
orientation = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0)
|
|
210
|
+
|
|
211
|
+
pixel_spacing = parse_floats(get_value(root, "PixelSpacing"))
|
|
212
|
+
if len(pixel_spacing) != 2:
|
|
213
|
+
pixel_spacing = spacing[:2]
|
|
214
|
+
|
|
215
|
+
patient_birth_date = clean_text(get_value(root, "PatientBirthDay", binary_first=True))
|
|
216
|
+
if not patient_birth_date:
|
|
217
|
+
patient_birth_date = clean_text(get_value(root, "PatientBirthDate", binary_first=True))
|
|
218
|
+
|
|
219
|
+
bits_allocated = int(clean_text(get_value(root, "BitsAllocated")) or 16)
|
|
220
|
+
bits_stored = int(clean_text(get_value(root, "BitsStore")) or 16)
|
|
221
|
+
high_bit = int(clean_text(get_value(root, "HighBit")) or (bits_stored - 1))
|
|
222
|
+
pixel_representation = 1 if scalar_type.lower() == "int16" else 0
|
|
223
|
+
|
|
224
|
+
rescale_intercept_text = clean_text(get_value(root, "RescaleIntercept"))
|
|
225
|
+
rescale_slope_text = clean_text(get_value(root, "RescaleSlope"))
|
|
226
|
+
rescale_type = clean_text(get_value(root, "RescaleType"))
|
|
227
|
+
|
|
228
|
+
return InvMetadata(
|
|
229
|
+
patient_name=parse_patient_name(get_value(root, "PatientName", binary_first=True)),
|
|
230
|
+
patient_id=clean_text(get_value(root, "PatientID", binary_first=True)),
|
|
231
|
+
patient_birth_date=patient_birth_date,
|
|
232
|
+
study_date=clean_text(get_value(root, "StudyDate")),
|
|
233
|
+
study_time=clean_text(get_value(root, "StudyTime")),
|
|
234
|
+
modality=clean_text(get_value(root, "Modality")) or "CT",
|
|
235
|
+
manufacturer=clean_text(get_value(root, "Manufacture")) or "Unknown",
|
|
236
|
+
model_name=clean_text(get_value(root, "MaufacturModelName")) or clean_text(get_value(root, "ManufacturerModelName")),
|
|
237
|
+
device_serial_number=clean_text(get_value(root, "DeviceSerialNumber")),
|
|
238
|
+
patient_position=clean_text(get_value(root, "PatientPostion")) or clean_text(get_value(root, "PatientPosition")) or "HFS",
|
|
239
|
+
kvp=clean_text(get_value(root, "KV")),
|
|
240
|
+
image_type=image_type,
|
|
241
|
+
study_instance_uid=clean_text(get_value(root, "StudyInstanceUID")) or generate_uid(),
|
|
242
|
+
series_instance_uid=clean_text(get_value(root, "SeriesInstanceUID")) or generate_uid(),
|
|
243
|
+
frame_of_reference_uid=clean_text(get_value(root, "FrameOfReferenceUID")) or generate_uid(),
|
|
244
|
+
sop_instance_uid=clean_text(get_value(root, "SOPInstanceUID")) or generate_uid(),
|
|
245
|
+
sop_class_uid=clean_text(get_value(root, "SOPClassUID")) or ENHANCED_CT_SOP_CLASS_UID,
|
|
246
|
+
scalar_type=scalar_type,
|
|
247
|
+
dimensions=(dimensions[0], dimensions[1], dimensions[2]),
|
|
248
|
+
pixel_spacing=(pixel_spacing[0], pixel_spacing[1]),
|
|
249
|
+
slice_spacing=float(spacing[2]),
|
|
250
|
+
origin=(float(origin[0]), float(origin[1]), float(origin[2])),
|
|
251
|
+
image_orientation_patient=(
|
|
252
|
+
float(orientation[0]),
|
|
253
|
+
float(orientation[1]),
|
|
254
|
+
float(orientation[2]),
|
|
255
|
+
float(orientation[3]),
|
|
256
|
+
float(orientation[4]),
|
|
257
|
+
float(orientation[5]),
|
|
258
|
+
),
|
|
259
|
+
samples_per_pixel=int(clean_text(get_value(root, "SamplePerPixel")) or 1),
|
|
260
|
+
photometric_interpretation=clean_text(get_value(root, "PhotometricInterpretation")) or "MONOCHROME2",
|
|
261
|
+
bits_allocated=bits_allocated,
|
|
262
|
+
bits_stored=bits_stored,
|
|
263
|
+
high_bit=high_bit,
|
|
264
|
+
pixel_representation=pixel_representation,
|
|
265
|
+
rescale_intercept=float(rescale_intercept_text or 0),
|
|
266
|
+
rescale_slope=float(rescale_slope_text or 1),
|
|
267
|
+
rescale_type=rescale_type,
|
|
268
|
+
window_width=window_width,
|
|
269
|
+
window_center=window_center,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def parse_appended_volume(appended: bytes, expected_frames: int, scalar_type: str = "UInt16") -> np.ndarray:
|
|
274
|
+
if len(appended) < 16:
|
|
275
|
+
raise ValueError("AppendedData is too small to contain a valid header")
|
|
276
|
+
|
|
277
|
+
magic, container_count, components_per_container, components_in_last = struct.unpack_from("<IIII", appended, 0)
|
|
278
|
+
if magic != 0x5F202020:
|
|
279
|
+
raise ValueError(f"Unexpected AppendedData magic: 0x{magic:08x}")
|
|
280
|
+
if container_count == 0:
|
|
281
|
+
raise ValueError("No JPEG 2000 containers found in AppendedData")
|
|
282
|
+
|
|
283
|
+
header_size = 16 + 4 * container_count
|
|
284
|
+
if len(appended) < header_size:
|
|
285
|
+
raise ValueError("AppendedData ends inside the JPEG 2000 size table")
|
|
286
|
+
|
|
287
|
+
codestream_sizes = struct.unpack_from(f"<{container_count}I", appended, 16)
|
|
288
|
+
offset = header_size
|
|
289
|
+
frames: list[np.ndarray] = []
|
|
290
|
+
|
|
291
|
+
for index, codestream_size in enumerate(codestream_sizes, start=1):
|
|
292
|
+
codestream = appended[offset : offset + codestream_size]
|
|
293
|
+
if len(codestream) != codestream_size:
|
|
294
|
+
raise ValueError(f"JPEG 2000 container {index} is truncated")
|
|
295
|
+
|
|
296
|
+
decoded = imagecodecs.jpeg2k_decode(codestream)
|
|
297
|
+
decoded = np.asarray(decoded)
|
|
298
|
+
|
|
299
|
+
if decoded.ndim == 2:
|
|
300
|
+
decoded = decoded[np.newaxis, :, :]
|
|
301
|
+
elif decoded.ndim == 3:
|
|
302
|
+
decoded = np.moveaxis(decoded, -1, 0)
|
|
303
|
+
else:
|
|
304
|
+
raise ValueError(f"Unexpected decoded shape for container {index}: {decoded.shape}")
|
|
305
|
+
|
|
306
|
+
frames.append(np.asarray(decoded, dtype=np.uint16))
|
|
307
|
+
offset += codestream_size
|
|
308
|
+
|
|
309
|
+
volume = np.concatenate(frames, axis=0)
|
|
310
|
+
|
|
311
|
+
expected_from_header = (container_count - (1 if components_in_last else 0)) * components_per_container + components_in_last
|
|
312
|
+
if volume.shape[0] != expected_from_header:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
"Decoded frame count does not match AppendedData header: "
|
|
315
|
+
f"decoded={volume.shape[0]}, header={expected_from_header}"
|
|
316
|
+
)
|
|
317
|
+
if volume.shape[0] != expected_frames:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
f"Decoded frame count does not match XML dimensions: decoded={volume.shape[0]}, xml={expected_frames}"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if scalar_type.lower() == "int16":
|
|
323
|
+
volume = volume.view(np.int16)
|
|
324
|
+
elif scalar_type.lower() == "uint16":
|
|
325
|
+
volume = volume.view(np.uint16)
|
|
326
|
+
|
|
327
|
+
return np.ascontiguousarray(volume)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def load_inv(path: Path) -> InvData:
|
|
331
|
+
root, appended = parse_inv_file(path)
|
|
332
|
+
metadata = parse_metadata(root)
|
|
333
|
+
volume = parse_appended_volume(appended, metadata.dimensions[2], metadata.scalar_type)
|
|
334
|
+
|
|
335
|
+
expected_rows = metadata.dimensions[1]
|
|
336
|
+
expected_cols = metadata.dimensions[0]
|
|
337
|
+
if volume.shape != (metadata.dimensions[2], expected_rows, expected_cols):
|
|
338
|
+
raise ValueError(
|
|
339
|
+
"Decoded volume shape does not match XML dimensions: "
|
|
340
|
+
f"decoded={volume.shape}, xml={(metadata.dimensions[2], expected_rows, expected_cols)}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return InvData(metadata=metadata, volume=volume)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def choose_window(inv: InvData, mode: str) -> tuple[float | None, float | None]:
|
|
347
|
+
meta = inv.metadata
|
|
348
|
+
|
|
349
|
+
if mode == "none":
|
|
350
|
+
return None, None
|
|
351
|
+
if mode == "source":
|
|
352
|
+
return meta.window_center, meta.window_width
|
|
353
|
+
if mode != "auto":
|
|
354
|
+
raise ValueError(f"Unsupported window mode: {mode}")
|
|
355
|
+
|
|
356
|
+
values = inv.volume.astype(np.float32) * float(meta.rescale_slope) + float(meta.rescale_intercept)
|
|
357
|
+
low = float(np.percentile(values, 1.0))
|
|
358
|
+
high = float(np.percentile(values, 99.5))
|
|
359
|
+
if not np.isfinite(low) or not np.isfinite(high) or high <= low:
|
|
360
|
+
return meta.window_center, meta.window_width
|
|
361
|
+
|
|
362
|
+
center = (low + high) / 2.0
|
|
363
|
+
width = max(high - low, 1.0)
|
|
364
|
+
return center, width
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def build_enhanced_ct(inv: InvData, output_path: Path, *, window_mode: str = "auto") -> FileDataset:
|
|
368
|
+
meta = inv.metadata
|
|
369
|
+
volume = inv.volume
|
|
370
|
+
frame_count, rows, cols = volume.shape
|
|
371
|
+
window_center, window_width = choose_window(inv, window_mode)
|
|
372
|
+
|
|
373
|
+
file_meta = FileMetaDataset()
|
|
374
|
+
file_meta.FileMetaInformationVersion = b"\x00\x01"
|
|
375
|
+
file_meta.MediaStorageSOPClassUID = ENHANCED_CT_SOP_CLASS_UID
|
|
376
|
+
file_meta.MediaStorageSOPInstanceUID = meta.sop_instance_uid or generate_uid()
|
|
377
|
+
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
|
|
378
|
+
file_meta.ImplementationClassUID = IMPLEMENTATION_CLASS_UID
|
|
379
|
+
file_meta.ImplementationVersionName = IMPLEMENTATION_VERSION_NAME
|
|
380
|
+
|
|
381
|
+
ds = FileDataset(str(output_path), {}, file_meta=file_meta, preamble=b"\x00" * 128)
|
|
382
|
+
ds.is_implicit_VR = False
|
|
383
|
+
ds.is_little_endian = True
|
|
384
|
+
|
|
385
|
+
ds.SpecificCharacterSet = "ISO_IR 192"
|
|
386
|
+
|
|
387
|
+
ds.PatientName = meta.patient_name or "Anonymous"
|
|
388
|
+
ds.PatientID = meta.patient_id or "UNKNOWN"
|
|
389
|
+
if meta.patient_birth_date:
|
|
390
|
+
ds.PatientBirthDate = meta.patient_birth_date
|
|
391
|
+
|
|
392
|
+
ds.StudyDate = meta.study_date
|
|
393
|
+
ds.StudyTime = meta.study_time
|
|
394
|
+
ds.SeriesDate = meta.study_date
|
|
395
|
+
ds.ContentDate = meta.study_date
|
|
396
|
+
ds.SeriesTime = meta.study_time
|
|
397
|
+
ds.ContentTime = meta.study_time
|
|
398
|
+
ds.AccessionNumber = ""
|
|
399
|
+
ds.ReferringPhysicianName = ""
|
|
400
|
+
ds.StudyInstanceUID = meta.study_instance_uid
|
|
401
|
+
ds.StudyID = "1"
|
|
402
|
+
|
|
403
|
+
ds.Modality = meta.modality or "CT"
|
|
404
|
+
ds.SeriesInstanceUID = meta.series_instance_uid
|
|
405
|
+
ds.SeriesNumber = 1
|
|
406
|
+
ds.InstanceNumber = 1
|
|
407
|
+
|
|
408
|
+
ds.ImageType = meta.image_type
|
|
409
|
+
ds.FrameOfReferenceUID = meta.frame_of_reference_uid
|
|
410
|
+
ds.PositionReferenceIndicator = ""
|
|
411
|
+
|
|
412
|
+
ds.Manufacturer = meta.manufacturer or "Unknown"
|
|
413
|
+
if meta.model_name:
|
|
414
|
+
ds.ManufacturerModelName = meta.model_name
|
|
415
|
+
if meta.device_serial_number:
|
|
416
|
+
ds.DeviceSerialNumber = meta.device_serial_number
|
|
417
|
+
ds.SoftwareVersions = ["inv2dcm"]
|
|
418
|
+
|
|
419
|
+
ds.PatientPosition = meta.patient_position or "HFS"
|
|
420
|
+
ds.BurnedInAnnotation = "NO"
|
|
421
|
+
ds.LossyImageCompression = "00"
|
|
422
|
+
|
|
423
|
+
ds.SamplesPerPixel = meta.samples_per_pixel
|
|
424
|
+
ds.PhotometricInterpretation = meta.photometric_interpretation
|
|
425
|
+
ds.Rows = rows
|
|
426
|
+
ds.Columns = cols
|
|
427
|
+
ds.BitsAllocated = meta.bits_allocated
|
|
428
|
+
ds.BitsStored = meta.bits_stored
|
|
429
|
+
ds.HighBit = meta.high_bit
|
|
430
|
+
ds.PixelRepresentation = meta.pixel_representation
|
|
431
|
+
ds.NumberOfFrames = str(frame_count)
|
|
432
|
+
|
|
433
|
+
ds.ImageOrientationPatient = [format_ds(value) for value in meta.image_orientation_patient]
|
|
434
|
+
ds.PixelSpacing = [format_ds(meta.pixel_spacing[0]), format_ds(meta.pixel_spacing[1])]
|
|
435
|
+
ds.SliceThickness = format_ds(meta.slice_spacing)
|
|
436
|
+
ds.SpacingBetweenSlices = format_ds(meta.slice_spacing)
|
|
437
|
+
|
|
438
|
+
ds.RescaleIntercept = format_ds(meta.rescale_intercept)
|
|
439
|
+
ds.RescaleSlope = format_ds(meta.rescale_slope)
|
|
440
|
+
if meta.rescale_type:
|
|
441
|
+
ds.RescaleType = meta.rescale_type
|
|
442
|
+
if meta.kvp:
|
|
443
|
+
ds.KVP = meta.kvp
|
|
444
|
+
if window_center is not None:
|
|
445
|
+
ds.WindowCenter = format_ds(window_center)
|
|
446
|
+
if window_width is not None:
|
|
447
|
+
ds.WindowWidth = format_ds(window_width)
|
|
448
|
+
|
|
449
|
+
ds.SOPClassUID = ENHANCED_CT_SOP_CLASS_UID
|
|
450
|
+
ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
|
|
451
|
+
|
|
452
|
+
dimension_organization_uid = generate_uid()
|
|
453
|
+
dim_org = Dataset()
|
|
454
|
+
dim_org.DimensionOrganizationUID = dimension_organization_uid
|
|
455
|
+
ds.DimensionOrganizationSequence = Sequence([dim_org])
|
|
456
|
+
|
|
457
|
+
dim_index = Dataset()
|
|
458
|
+
dim_index.DimensionOrganizationUID = dimension_organization_uid
|
|
459
|
+
dim_index.DimensionIndexPointer = Tag(0x0020, 0x0032)
|
|
460
|
+
dim_index.FunctionalGroupPointer = Tag(0x0020, 0x9113)
|
|
461
|
+
dim_index.DimensionDescriptionLabel = "Image Position Patient Z"
|
|
462
|
+
ds.DimensionIndexSequence = Sequence([dim_index])
|
|
463
|
+
|
|
464
|
+
shared = Dataset()
|
|
465
|
+
|
|
466
|
+
pixel_measures = Dataset()
|
|
467
|
+
pixel_measures.PixelSpacing = [format_ds(meta.pixel_spacing[0]), format_ds(meta.pixel_spacing[1])]
|
|
468
|
+
pixel_measures.SliceThickness = format_ds(meta.slice_spacing)
|
|
469
|
+
pixel_measures.SpacingBetweenSlices = format_ds(meta.slice_spacing)
|
|
470
|
+
shared.PixelMeasuresSequence = Sequence([pixel_measures])
|
|
471
|
+
|
|
472
|
+
plane_orientation = Dataset()
|
|
473
|
+
plane_orientation.ImageOrientationPatient = [format_ds(value) for value in meta.image_orientation_patient]
|
|
474
|
+
shared.PlaneOrientationSequence = Sequence([plane_orientation])
|
|
475
|
+
|
|
476
|
+
ct_frame_type = Dataset()
|
|
477
|
+
ct_frame_type.FrameType = meta.image_type
|
|
478
|
+
ct_frame_type.PixelPresentation = "MONOCHROME"
|
|
479
|
+
ct_frame_type.VolumetricProperties = "VOLUME"
|
|
480
|
+
ct_frame_type.VolumeBasedCalculationTechnique = "NONE"
|
|
481
|
+
shared.CTImageFrameTypeSequence = Sequence([ct_frame_type])
|
|
482
|
+
|
|
483
|
+
pixel_value_transform = Dataset()
|
|
484
|
+
pixel_value_transform.RescaleIntercept = format_ds(meta.rescale_intercept)
|
|
485
|
+
pixel_value_transform.RescaleSlope = format_ds(meta.rescale_slope)
|
|
486
|
+
if meta.rescale_type:
|
|
487
|
+
pixel_value_transform.RescaleType = meta.rescale_type
|
|
488
|
+
shared.PixelValueTransformationSequence = Sequence([pixel_value_transform])
|
|
489
|
+
|
|
490
|
+
if window_center is not None and window_width is not None:
|
|
491
|
+
frame_voi_lut = Dataset()
|
|
492
|
+
frame_voi_lut.WindowCenter = format_ds(window_center)
|
|
493
|
+
frame_voi_lut.WindowWidth = format_ds(window_width)
|
|
494
|
+
shared.FrameVOILUTSequence = Sequence([frame_voi_lut])
|
|
495
|
+
|
|
496
|
+
ds.SharedFunctionalGroupsSequence = Sequence([shared])
|
|
497
|
+
|
|
498
|
+
per_frame_items: list[Dataset] = []
|
|
499
|
+
origin_x, origin_y, origin_z = meta.origin
|
|
500
|
+
for frame_index in range(frame_count):
|
|
501
|
+
frame_group = Dataset()
|
|
502
|
+
|
|
503
|
+
plane_position = Dataset()
|
|
504
|
+
plane_position.ImagePositionPatient = [
|
|
505
|
+
format_ds(origin_x),
|
|
506
|
+
format_ds(origin_y),
|
|
507
|
+
format_ds(origin_z + frame_index * meta.slice_spacing),
|
|
508
|
+
]
|
|
509
|
+
frame_group.PlanePositionSequence = Sequence([plane_position])
|
|
510
|
+
|
|
511
|
+
frame_content = Dataset()
|
|
512
|
+
frame_content.FrameAcquisitionNumber = frame_index + 1
|
|
513
|
+
frame_content.DimensionIndexValues = [frame_index + 1]
|
|
514
|
+
frame_group.FrameContentSequence = Sequence([frame_content])
|
|
515
|
+
|
|
516
|
+
per_frame_items.append(frame_group)
|
|
517
|
+
|
|
518
|
+
ds.PerFrameFunctionalGroupsSequence = Sequence(per_frame_items)
|
|
519
|
+
ds.PixelData = np.asarray(volume, dtype="<u2", order="C").tobytes()
|
|
520
|
+
return ds
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def convert_inv_to_dicom(input_path: Path, output_path: Path, *, window_mode: str = "auto") -> Path:
|
|
524
|
+
inv = load_inv(input_path)
|
|
525
|
+
ds = build_enhanced_ct(inv, output_path, window_mode=window_mode)
|
|
526
|
+
ds.save_as(output_path, enforce_file_format=True)
|
|
527
|
+
return output_path
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
|
|
531
|
+
parser = argparse.ArgumentParser(description="Convert an InVivo .inv CBCT file to an Enhanced CT DICOM file.")
|
|
532
|
+
parser.add_argument("input", help="Path to the .inv file.")
|
|
533
|
+
parser.add_argument("output", nargs="?", help="Output .dcm path. Defaults to <input-stem>.dcm")
|
|
534
|
+
parser.add_argument(
|
|
535
|
+
"--window",
|
|
536
|
+
choices=("auto", "source", "none"),
|
|
537
|
+
default="auto",
|
|
538
|
+
help="How to write DICOM window center/width metadata. Default: auto.",
|
|
539
|
+
)
|
|
540
|
+
return parser.parse_args(list(argv) if argv is not None else None)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def main(argv: Iterable[str] | None = None) -> int:
|
|
544
|
+
try:
|
|
545
|
+
args = parse_args(argv)
|
|
546
|
+
input_path = Path(args.input)
|
|
547
|
+
output_path = Path(args.output) if args.output else input_path.with_suffix(".dcm")
|
|
548
|
+
|
|
549
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
550
|
+
convert_inv_to_dicom(input_path, output_path, window_mode=args.window)
|
|
551
|
+
except KeyboardInterrupt:
|
|
552
|
+
print("Cancelled.", file=sys.stderr)
|
|
553
|
+
return 130
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
556
|
+
return 1
|
|
557
|
+
|
|
558
|
+
print(f"Converted {input_path} -> {output_path}")
|
|
559
|
+
return 0
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
if __name__ == "__main__":
|
|
563
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "inv2dcm"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Convert InVivo .inv dental CBCT volumes to Enhanced CT DICOM"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "BSL-1.0"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "mrexodia" },
|
|
15
|
+
]
|
|
16
|
+
keywords = [
|
|
17
|
+
"inv",
|
|
18
|
+
"dicom",
|
|
19
|
+
"cbct",
|
|
20
|
+
"cone beam ct",
|
|
21
|
+
"dental imaging",
|
|
22
|
+
"pydicom",
|
|
23
|
+
"enhanced ct",
|
|
24
|
+
"jpeg 2000",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Intended Audience :: Science/Research",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
]
|
|
36
|
+
dependencies = [
|
|
37
|
+
"imagecodecs>=2024.12.30",
|
|
38
|
+
"numpy>=2.0.0",
|
|
39
|
+
"pydicom>=3.0.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/mrexodia/inv2dcm"
|
|
44
|
+
Repository = "https://github.com/mrexodia/inv2dcm"
|
|
45
|
+
Issues = "https://github.com/mrexodia/inv2dcm/issues"
|
|
46
|
+
|
|
47
|
+
[project.scripts]
|
|
48
|
+
inv2dcm = "inv2dcm:main"
|
|
49
|
+
|
|
50
|
+
[tool.setuptools]
|
|
51
|
+
py-modules = ["inv2dcm"]
|
inv2dcm-1.0.0/setup.cfg
ADDED