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 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.
@@ -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,10 @@
1
+ LICENSE
2
+ README.md
3
+ inv2dcm.py
4
+ pyproject.toml
5
+ inv2dcm.egg-info/PKG-INFO
6
+ inv2dcm.egg-info/SOURCES.txt
7
+ inv2dcm.egg-info/dependency_links.txt
8
+ inv2dcm.egg-info/entry_points.txt
9
+ inv2dcm.egg-info/requires.txt
10
+ inv2dcm.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ inv2dcm = inv2dcm:main
@@ -0,0 +1,3 @@
1
+ imagecodecs>=2024.12.30
2
+ numpy>=2.0.0
3
+ pydicom>=3.0.0
@@ -0,0 +1 @@
1
+ inv2dcm
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+