dicomforge 0.6.0__py3-none-any.whl
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.
- dicomforge/__init__.py +143 -0
- dicomforge/adapt.py +514 -0
- dicomforge/anonymize.py +363 -0
- dicomforge/api.py +451 -0
- dicomforge/codecs.py +83 -0
- dicomforge/dataset.py +113 -0
- dicomforge/dicomweb.py +577 -0
- dicomforge/errors.py +21 -0
- dicomforge/io.py +192 -0
- dicomforge/network.py +597 -0
- dicomforge/pixels.py +420 -0
- dicomforge/tags.py +305 -0
- dicomforge/transfer_syntax.py +148 -0
- dicomforge/uids.py +108 -0
- dicomforge-0.6.0.dist-info/METADATA +273 -0
- dicomforge-0.6.0.dist-info/RECORD +18 -0
- dicomforge-0.6.0.dist-info/WHEEL +4 -0
- dicomforge-0.6.0.dist-info/licenses/LICENSE +21 -0
dicomforge/__init__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Lightweight DICOM processing primitives."""
|
|
2
|
+
|
|
3
|
+
from dicomforge.anonymize import (
|
|
4
|
+
AnonymizationAction,
|
|
5
|
+
AnonymizationEvent,
|
|
6
|
+
AnonymizationPlan,
|
|
7
|
+
AnonymizationReport,
|
|
8
|
+
AuditEvent,
|
|
9
|
+
AuditReport,
|
|
10
|
+
PrivateTagAction,
|
|
11
|
+
Rule,
|
|
12
|
+
UidRemapper,
|
|
13
|
+
)
|
|
14
|
+
from dicomforge.api import DicomFile, batch_anonymize, quick_anonymize, validate_dataset
|
|
15
|
+
from dicomforge.codecs import Codec, CodecRegistry
|
|
16
|
+
from dicomforge.dataset import DicomDataset
|
|
17
|
+
from dicomforge.dicomweb import (
|
|
18
|
+
DicomwebClient,
|
|
19
|
+
DicomwebError,
|
|
20
|
+
DicomwebResponse,
|
|
21
|
+
DicomwebTransport,
|
|
22
|
+
MultipartPart,
|
|
23
|
+
QidoQuery,
|
|
24
|
+
UrllibDicomwebTransport,
|
|
25
|
+
build_multipart_related,
|
|
26
|
+
dataset_from_dicom_json,
|
|
27
|
+
dataset_to_dicom_json,
|
|
28
|
+
datasets_from_dicom_json,
|
|
29
|
+
parse_multipart_related,
|
|
30
|
+
)
|
|
31
|
+
from dicomforge.errors import (
|
|
32
|
+
DicomForgeError,
|
|
33
|
+
DicomValidationError,
|
|
34
|
+
MissingBackendError,
|
|
35
|
+
UnsupportedTransferSyntaxError,
|
|
36
|
+
)
|
|
37
|
+
from dicomforge.network import (
|
|
38
|
+
Association,
|
|
39
|
+
AssociationClosedError,
|
|
40
|
+
AssociationRejectedError,
|
|
41
|
+
AssociationRequest,
|
|
42
|
+
DimseServer,
|
|
43
|
+
DimseStatus,
|
|
44
|
+
NetworkError,
|
|
45
|
+
open_association,
|
|
46
|
+
start_dimse_server,
|
|
47
|
+
)
|
|
48
|
+
from dicomforge.pixels import (
|
|
49
|
+
FrameMetadata,
|
|
50
|
+
PixelCapability,
|
|
51
|
+
PixelMetadataError,
|
|
52
|
+
VoiLut,
|
|
53
|
+
apply_voi_window,
|
|
54
|
+
apply_voi_window_from_dataset,
|
|
55
|
+
assert_pixel_data_length,
|
|
56
|
+
check_pixel_capability,
|
|
57
|
+
expected_samples_per_pixel,
|
|
58
|
+
is_monochrome,
|
|
59
|
+
needs_inversion,
|
|
60
|
+
normalize_photometric_interpretation,
|
|
61
|
+
rescale_from_dataset,
|
|
62
|
+
rescale_value,
|
|
63
|
+
rescale_values,
|
|
64
|
+
voi_window_bounds,
|
|
65
|
+
)
|
|
66
|
+
from dicomforge.tags import Tag
|
|
67
|
+
from dicomforge.transfer_syntax import TransferSyntax
|
|
68
|
+
from dicomforge.uids import DimseStatusCode, ImplementationUID, SopClassUID, TransferSyntaxUID
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# Anonymization
|
|
72
|
+
"AnonymizationAction",
|
|
73
|
+
"AnonymizationEvent",
|
|
74
|
+
"AnonymizationPlan",
|
|
75
|
+
"AnonymizationReport",
|
|
76
|
+
"AuditEvent",
|
|
77
|
+
"AuditReport",
|
|
78
|
+
"PrivateTagAction",
|
|
79
|
+
"Rule",
|
|
80
|
+
"UidRemapper",
|
|
81
|
+
# High-level API
|
|
82
|
+
"DicomFile",
|
|
83
|
+
"batch_anonymize",
|
|
84
|
+
"quick_anonymize",
|
|
85
|
+
"validate_dataset",
|
|
86
|
+
# Codecs
|
|
87
|
+
"Codec",
|
|
88
|
+
"CodecRegistry",
|
|
89
|
+
# Core types
|
|
90
|
+
"DicomDataset",
|
|
91
|
+
"Tag",
|
|
92
|
+
"TransferSyntax",
|
|
93
|
+
# Errors
|
|
94
|
+
"DicomForgeError",
|
|
95
|
+
"DicomValidationError",
|
|
96
|
+
"MissingBackendError",
|
|
97
|
+
"NetworkError",
|
|
98
|
+
"PixelMetadataError",
|
|
99
|
+
"UnsupportedTransferSyntaxError",
|
|
100
|
+
# DICOMweb
|
|
101
|
+
"DicomwebClient",
|
|
102
|
+
"DicomwebError",
|
|
103
|
+
"DicomwebResponse",
|
|
104
|
+
"DicomwebTransport",
|
|
105
|
+
"MultipartPart",
|
|
106
|
+
"QidoQuery",
|
|
107
|
+
"UrllibDicomwebTransport",
|
|
108
|
+
"build_multipart_related",
|
|
109
|
+
"dataset_from_dicom_json",
|
|
110
|
+
"dataset_to_dicom_json",
|
|
111
|
+
"datasets_from_dicom_json",
|
|
112
|
+
"parse_multipart_related",
|
|
113
|
+
# Networking
|
|
114
|
+
"Association",
|
|
115
|
+
"AssociationClosedError",
|
|
116
|
+
"AssociationRejectedError",
|
|
117
|
+
"AssociationRequest",
|
|
118
|
+
"DimseServer",
|
|
119
|
+
"DimseStatus",
|
|
120
|
+
"DimseStatusCode",
|
|
121
|
+
"open_association",
|
|
122
|
+
"start_dimse_server",
|
|
123
|
+
# Pixels
|
|
124
|
+
"FrameMetadata",
|
|
125
|
+
"PixelCapability",
|
|
126
|
+
"VoiLut",
|
|
127
|
+
"apply_voi_window",
|
|
128
|
+
"apply_voi_window_from_dataset",
|
|
129
|
+
"assert_pixel_data_length",
|
|
130
|
+
"check_pixel_capability",
|
|
131
|
+
"expected_samples_per_pixel",
|
|
132
|
+
"is_monochrome",
|
|
133
|
+
"needs_inversion",
|
|
134
|
+
"normalize_photometric_interpretation",
|
|
135
|
+
"rescale_from_dataset",
|
|
136
|
+
"rescale_value",
|
|
137
|
+
"rescale_values",
|
|
138
|
+
"voi_window_bounds",
|
|
139
|
+
# UIDs
|
|
140
|
+
"ImplementationUID",
|
|
141
|
+
"SopClassUID",
|
|
142
|
+
"TransferSyntaxUID",
|
|
143
|
+
]
|
dicomforge/adapt.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Adoption-layer integration adapters.
|
|
2
|
+
|
|
3
|
+
Bridges DicomDataset with the wider Python ecosystem:
|
|
4
|
+
|
|
5
|
+
- **pydicom** — bidirectional conversion so you can adopt DicomForge
|
|
6
|
+
alongside an existing pydicom codebase without rewriting everything.
|
|
7
|
+
- **numpy** — extract pixel arrays from uncompressed PixelData with
|
|
8
|
+
correct dtype, shape, and optional rescale/window application.
|
|
9
|
+
- **Pillow** — convert a DICOM frame to a PIL Image ready for display
|
|
10
|
+
or export; handles MONOCHROME inversion automatically.
|
|
11
|
+
- **JSON** — round-trip through the DICOM JSON Model (PS3.18 Annex F).
|
|
12
|
+
|
|
13
|
+
No backend is imported at module level. Each function raises
|
|
14
|
+
``MissingBackendError`` with a ``pip install`` hint when the backend is
|
|
15
|
+
absent, so the core stays dependency-free.
|
|
16
|
+
|
|
17
|
+
Example
|
|
18
|
+
-------
|
|
19
|
+
Convert a pydicom Dataset you already have::
|
|
20
|
+
|
|
21
|
+
from dicomforge.adapt import from_pydicom, to_pydicom
|
|
22
|
+
|
|
23
|
+
ds_forge = from_pydicom(raw_pydicom_dataset)
|
|
24
|
+
raw_back = to_pydicom(ds_forge)
|
|
25
|
+
|
|
26
|
+
Extract a numpy array (requires ``pip install dicomforge[pixels]``)::
|
|
27
|
+
|
|
28
|
+
from dicomforge.adapt import pixel_array
|
|
29
|
+
arr = pixel_array(ds_forge, frame=0)
|
|
30
|
+
|
|
31
|
+
Display a DICOM frame via Pillow::
|
|
32
|
+
|
|
33
|
+
from dicomforge.adapt import to_pil_image
|
|
34
|
+
to_pil_image(ds_forge).show()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json as _json
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Optional, Union
|
|
42
|
+
|
|
43
|
+
from dicomforge.dataset import DicomDataset
|
|
44
|
+
from dicomforge.dicomweb import dataset_from_dicom_json, dataset_to_dicom_json
|
|
45
|
+
from dicomforge.errors import MissingBackendError
|
|
46
|
+
from dicomforge.pixels import (
|
|
47
|
+
FrameMetadata,
|
|
48
|
+
apply_voi_window,
|
|
49
|
+
is_monochrome,
|
|
50
|
+
needs_inversion,
|
|
51
|
+
rescale_value,
|
|
52
|
+
)
|
|
53
|
+
from dicomforge.tags import Tag
|
|
54
|
+
from dicomforge.transfer_syntax import TransferSyntax
|
|
55
|
+
|
|
56
|
+
PathLike = Union[str, Path]
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# pydicom adapters
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def from_pydicom(raw: Any) -> DicomDataset:
|
|
64
|
+
"""Convert a ``pydicom.Dataset`` (or ``FileDataset``) to a :class:`DicomDataset`.
|
|
65
|
+
|
|
66
|
+
Both the main dataset and the ``file_meta`` group-0002 elements are
|
|
67
|
+
copied. Nested sequences are converted recursively.
|
|
68
|
+
|
|
69
|
+
Raises
|
|
70
|
+
------
|
|
71
|
+
MissingBackendError
|
|
72
|
+
If pydicom is not installed (used only for type-checking here, not
|
|
73
|
+
for the actual import — pass in whatever pydicom gave you).
|
|
74
|
+
"""
|
|
75
|
+
dataset = DicomDataset()
|
|
76
|
+
file_meta = getattr(raw, "file_meta", None)
|
|
77
|
+
if file_meta is not None:
|
|
78
|
+
_copy_pydicom_to_forge(file_meta, dataset)
|
|
79
|
+
_copy_pydicom_to_forge(raw, dataset)
|
|
80
|
+
return dataset
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def to_pydicom(dataset: DicomDataset, *, write_like_original: bool = False) -> Any:
|
|
84
|
+
"""Convert a :class:`DicomDataset` to a ``pydicom.Dataset``.
|
|
85
|
+
|
|
86
|
+
Requires ``pip install dicomforge[pydicom]``.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
dataset:
|
|
91
|
+
The DicomForge dataset to convert.
|
|
92
|
+
write_like_original:
|
|
93
|
+
Passed through to ``pydicom.Dataset`` — has no effect on the
|
|
94
|
+
returned object type but is available for callers who pass the
|
|
95
|
+
result to ``pydicom.dcmwrite``.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
pydicom.Dataset
|
|
100
|
+
A new pydicom Dataset populated with the same tags and values.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
import pydicom # type: ignore[import-not-found]
|
|
104
|
+
except ImportError as exc:
|
|
105
|
+
raise MissingBackendError(
|
|
106
|
+
"to_pydicom() requires the optional pydicom backend. "
|
|
107
|
+
"Install with `pip install dicomforge[pydicom]`."
|
|
108
|
+
) from exc
|
|
109
|
+
|
|
110
|
+
from dicomforge.io import _vr_for_tag # local import to avoid circular
|
|
111
|
+
|
|
112
|
+
raw = pydicom.Dataset()
|
|
113
|
+
for tag, value in dataset.items():
|
|
114
|
+
vr = _vr_for_tag(tag, dataset)
|
|
115
|
+
converted = _forge_value_to_pydicom(value, pydicom)
|
|
116
|
+
raw.add_new((tag.group, tag.element), vr, converted)
|
|
117
|
+
return raw
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# numpy adapters
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pixel_array(
|
|
126
|
+
dataset: DicomDataset,
|
|
127
|
+
*,
|
|
128
|
+
frame: int = 0,
|
|
129
|
+
apply_rescale: bool = False,
|
|
130
|
+
registry: Any = None,
|
|
131
|
+
) -> Any:
|
|
132
|
+
"""Extract a frame from uncompressed PixelData as a ``numpy.ndarray``.
|
|
133
|
+
|
|
134
|
+
The returned array has shape ``(rows, columns)`` for single-sample images
|
|
135
|
+
or ``(rows, columns, samples)`` for colour images, and the dtype matches
|
|
136
|
+
``BitsAllocated`` (``uint8``, ``uint16``, ``int16`` for signed data).
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
dataset:
|
|
141
|
+
Dataset containing ``PixelData`` and pixel-metadata tags.
|
|
142
|
+
frame:
|
|
143
|
+
Zero-based frame index. Defaults to 0 (first frame).
|
|
144
|
+
apply_rescale:
|
|
145
|
+
When *True*, apply ``RescaleSlope`` / ``RescaleIntercept`` and return
|
|
146
|
+
``float64``. Useful for CT Hounsfield Units.
|
|
147
|
+
registry:
|
|
148
|
+
Optional :class:`~dicomforge.codecs.CodecRegistry` to use when
|
|
149
|
+
checking codec support. Defaults to the built-in uncompressed registry.
|
|
150
|
+
|
|
151
|
+
Raises
|
|
152
|
+
------
|
|
153
|
+
MissingBackendError
|
|
154
|
+
If numpy is not installed.
|
|
155
|
+
PixelMetadataError
|
|
156
|
+
If required pixel-metadata tags are missing or inconsistent.
|
|
157
|
+
UnsupportedTransferSyntaxError
|
|
158
|
+
If the transfer syntax requires a codec not in *registry*.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
import numpy as np # type: ignore[import-not-found]
|
|
162
|
+
except ImportError as exc:
|
|
163
|
+
raise MissingBackendError(
|
|
164
|
+
"pixel_array() requires numpy. "
|
|
165
|
+
"Install with `pip install dicomforge[pixels]`."
|
|
166
|
+
) from exc
|
|
167
|
+
|
|
168
|
+
from dicomforge.codecs import default_registry
|
|
169
|
+
from dicomforge.pixels import check_pixel_capability
|
|
170
|
+
|
|
171
|
+
active_registry = registry or default_registry()
|
|
172
|
+
cap = check_pixel_capability(dataset, registry=active_registry)
|
|
173
|
+
meta = cap.frame_metadata
|
|
174
|
+
|
|
175
|
+
pixel_data = dataset.require(Tag.PixelData)
|
|
176
|
+
if isinstance(pixel_data, bytes):
|
|
177
|
+
raw_bytes = pixel_data
|
|
178
|
+
elif isinstance(pixel_data, bytearray):
|
|
179
|
+
raw_bytes = bytes(pixel_data)
|
|
180
|
+
else:
|
|
181
|
+
raise TypeError(f"PixelData must be bytes or bytearray, got {type(pixel_data).__name__}")
|
|
182
|
+
|
|
183
|
+
dtype = _numpy_dtype(meta)
|
|
184
|
+
total_values = meta.frame_values * meta.number_of_frames
|
|
185
|
+
flat = np.frombuffer(raw_bytes[: total_values * meta.bytes_per_sample], dtype=dtype)
|
|
186
|
+
|
|
187
|
+
frame_start = frame * meta.frame_values
|
|
188
|
+
frame_end = frame_start + meta.frame_values
|
|
189
|
+
frame_flat = flat[frame_start:frame_end]
|
|
190
|
+
|
|
191
|
+
if meta.samples_per_pixel == 1:
|
|
192
|
+
arr = frame_flat.reshape(meta.rows, meta.columns)
|
|
193
|
+
else:
|
|
194
|
+
if meta.planar_configuration == 1:
|
|
195
|
+
# Plane-interleaved: [R…R, G…G, B…B] → reshape then transpose
|
|
196
|
+
arr = frame_flat.reshape(meta.samples_per_pixel, meta.rows, meta.columns)
|
|
197
|
+
arr = arr.transpose(1, 2, 0)
|
|
198
|
+
else:
|
|
199
|
+
# Pixel-interleaved: [RGB, RGB, …]
|
|
200
|
+
arr = frame_flat.reshape(meta.rows, meta.columns, meta.samples_per_pixel)
|
|
201
|
+
|
|
202
|
+
if apply_rescale:
|
|
203
|
+
slope = float(dataset.get(Tag.RescaleSlope) or 1)
|
|
204
|
+
intercept = float(dataset.get(Tag.RescaleIntercept) or 0)
|
|
205
|
+
return arr.astype(np.float64) * slope + intercept
|
|
206
|
+
|
|
207
|
+
return arr.copy()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Pillow adapters
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def to_pil_image(
|
|
216
|
+
dataset: DicomDataset,
|
|
217
|
+
*,
|
|
218
|
+
frame: int = 0,
|
|
219
|
+
apply_window: bool = True,
|
|
220
|
+
window_center: Optional[float] = None,
|
|
221
|
+
window_width: Optional[float] = None,
|
|
222
|
+
) -> Any:
|
|
223
|
+
"""Convert a DICOM frame to a ``PIL.Image.Image``.
|
|
224
|
+
|
|
225
|
+
For monochrome images the output mode is ``'L'`` (8-bit greyscale).
|
|
226
|
+
For colour images the output mode is ``'RGB'``. YBR colour spaces
|
|
227
|
+
(``YBR_FULL``, ``YBR_FULL_422``, ``YBR_PARTIAL_422``) are converted to
|
|
228
|
+
RGB automatically so the returned image is always display-correct.
|
|
229
|
+
|
|
230
|
+
``MONOCHROME1`` (bright = air) is automatically inverted to display
|
|
231
|
+
convention (bright = high density).
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
dataset:
|
|
236
|
+
Dataset containing ``PixelData`` and required metadata tags.
|
|
237
|
+
frame:
|
|
238
|
+
Zero-based frame index.
|
|
239
|
+
apply_window:
|
|
240
|
+
Apply ``WindowCenter`` / ``WindowWidth`` VOI windowing when *True*.
|
|
241
|
+
Pass explicit *window_center* and *window_width* to override the
|
|
242
|
+
dataset values.
|
|
243
|
+
window_center:
|
|
244
|
+
Override ``WindowCenter``. Ignored when *apply_window* is *False*.
|
|
245
|
+
window_width:
|
|
246
|
+
Override ``WindowWidth``. Ignored when *apply_window* is *False*.
|
|
247
|
+
|
|
248
|
+
Raises
|
|
249
|
+
------
|
|
250
|
+
MissingBackendError
|
|
251
|
+
If Pillow (PIL) or numpy is not installed.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
from PIL import Image # type: ignore[import-not-found]
|
|
255
|
+
except ImportError as exc:
|
|
256
|
+
raise MissingBackendError(
|
|
257
|
+
"to_pil_image() requires Pillow. "
|
|
258
|
+
"Install with `pip install dicomforge[pixels]`."
|
|
259
|
+
) from exc
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
import numpy as np # type: ignore[import-not-found]
|
|
263
|
+
except ImportError as exc:
|
|
264
|
+
raise MissingBackendError(
|
|
265
|
+
"to_pil_image() requires numpy. "
|
|
266
|
+
"Install with `pip install dicomforge[pixels]`."
|
|
267
|
+
) from exc
|
|
268
|
+
|
|
269
|
+
arr = pixel_array(dataset, frame=frame)
|
|
270
|
+
meta = FrameMetadata.from_dataset(dataset)
|
|
271
|
+
photometric = meta.photometric_interpretation
|
|
272
|
+
|
|
273
|
+
if is_monochrome(photometric):
|
|
274
|
+
arr_float = arr.astype(np.float64)
|
|
275
|
+
if apply_window:
|
|
276
|
+
center = window_center
|
|
277
|
+
width = window_width
|
|
278
|
+
if center is None:
|
|
279
|
+
raw_center = dataset.get(Tag.WindowCenter)
|
|
280
|
+
center = float(raw_center) if raw_center is not None else float(arr_float.mean())
|
|
281
|
+
if width is None:
|
|
282
|
+
raw_width = dataset.get(Tag.WindowWidth)
|
|
283
|
+
width = float(raw_width) if raw_width is not None else float(arr_float.max() - arr_float.min()) or 1.0
|
|
284
|
+
arr_windowed = _apply_window_numpy(arr_float, center, width, np)
|
|
285
|
+
else:
|
|
286
|
+
vmin = float(arr_float.min())
|
|
287
|
+
vmax = float(arr_float.max())
|
|
288
|
+
span = vmax - vmin or 1.0
|
|
289
|
+
arr_windowed = ((arr_float - vmin) / span * 255.0)
|
|
290
|
+
|
|
291
|
+
arr_uint8 = arr_windowed.clip(0, 255).astype(np.uint8)
|
|
292
|
+
if needs_inversion(photometric):
|
|
293
|
+
arr_uint8 = 255 - arr_uint8
|
|
294
|
+
return Image.fromarray(arr_uint8, mode="L")
|
|
295
|
+
|
|
296
|
+
# Colour path — convert YBR colour spaces to RGB before PIL sees the data.
|
|
297
|
+
# PIL treats every (rows, cols, 3) uint8 array as RGB; passing raw YBR
|
|
298
|
+
# samples would produce heavily tinted or incorrect images.
|
|
299
|
+
arr = _ybr_to_rgb_if_needed(arr, photometric, np)
|
|
300
|
+
|
|
301
|
+
# Ensure uint8 for PIL
|
|
302
|
+
if arr.dtype != np.uint8:
|
|
303
|
+
vmin = float(arr.min())
|
|
304
|
+
vmax = float(arr.max())
|
|
305
|
+
span = vmax - vmin or 1.0
|
|
306
|
+
arr = ((arr.astype(np.float64) - vmin) / span * 255).clip(0, 255).astype(np.uint8)
|
|
307
|
+
return Image.fromarray(arr, mode="RGB")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# JSON adapters
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def to_json(dataset: DicomDataset, *, indent: Optional[int] = None) -> str:
|
|
316
|
+
"""Serialize a :class:`DicomDataset` to a DICOM JSON Model string (PS3.18 Annex F).
|
|
317
|
+
|
|
318
|
+
The output is a single JSON object whose keys are 8-character uppercase
|
|
319
|
+
tag strings (e.g. ``"00100010"``).
|
|
320
|
+
|
|
321
|
+
Parameters
|
|
322
|
+
----------
|
|
323
|
+
dataset:
|
|
324
|
+
The dataset to serialize.
|
|
325
|
+
indent:
|
|
326
|
+
JSON indentation. ``None`` produces compact output.
|
|
327
|
+
"""
|
|
328
|
+
return _json.dumps(dataset_to_dicom_json(dataset), indent=indent)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def from_json(data: Union[str, bytes]) -> DicomDataset:
|
|
332
|
+
"""Deserialize a DICOM JSON Model string into a :class:`DicomDataset`.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
data:
|
|
337
|
+
A JSON string or UTF-8 bytes in DICOM JSON Model format.
|
|
338
|
+
"""
|
|
339
|
+
raw = _json.loads(data)
|
|
340
|
+
return dataset_from_dicom_json(raw)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
# pynetdicom bridge helpers
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def from_pynetdicom_event(event: Any) -> DicomDataset:
|
|
349
|
+
"""Extract the :class:`DicomDataset` from a ``pynetdicom`` event.
|
|
350
|
+
|
|
351
|
+
Handles C-STORE, N-SET, N-CREATE events that carry a ``dataset``
|
|
352
|
+
attribute, as well as C-FIND events that carry a ``identifier``
|
|
353
|
+
attribute.
|
|
354
|
+
|
|
355
|
+
Parameters
|
|
356
|
+
----------
|
|
357
|
+
event:
|
|
358
|
+
A ``pynetdicom.events.Event`` instance.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
DicomDataset
|
|
363
|
+
The event payload converted to a DicomForge dataset.
|
|
364
|
+
|
|
365
|
+
Raises
|
|
366
|
+
------
|
|
367
|
+
MissingBackendError
|
|
368
|
+
If pynetdicom is not installed.
|
|
369
|
+
AttributeError
|
|
370
|
+
If the event carries neither a ``dataset`` nor ``identifier``.
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
import pynetdicom # type: ignore[import-not-found] # noqa: F401
|
|
374
|
+
except ImportError as exc:
|
|
375
|
+
raise MissingBackendError(
|
|
376
|
+
"from_pynetdicom_event() requires pynetdicom. "
|
|
377
|
+
"Install with `pip install pynetdicom`."
|
|
378
|
+
) from exc
|
|
379
|
+
|
|
380
|
+
raw = getattr(event, "dataset", None) or getattr(event, "identifier", None)
|
|
381
|
+
if raw is None:
|
|
382
|
+
raise AttributeError(
|
|
383
|
+
"Event has neither a 'dataset' nor an 'identifier' attribute. "
|
|
384
|
+
"Check that the event type carries a DICOM dataset payload."
|
|
385
|
+
)
|
|
386
|
+
return from_pydicom(raw)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
# Internal helpers
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _copy_pydicom_to_forge(source: Any, dataset: DicomDataset) -> None:
|
|
395
|
+
for element in source:
|
|
396
|
+
value = _pydicom_value_to_forge(element.value)
|
|
397
|
+
dataset.set((element.tag.group, element.tag.element), value)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _pydicom_value_to_forge(value: Any) -> Any:
|
|
401
|
+
"""Recursively convert pydicom value types to plain Python equivalents."""
|
|
402
|
+
type_name = type(value).__name__
|
|
403
|
+
module = type(value).__module__ or ""
|
|
404
|
+
if "pydicom" in module:
|
|
405
|
+
if type_name == "Dataset":
|
|
406
|
+
child = DicomDataset()
|
|
407
|
+
_copy_pydicom_to_forge(value, child)
|
|
408
|
+
return child
|
|
409
|
+
if type_name == "Sequence":
|
|
410
|
+
return [_pydicom_value_to_forge(item) for item in value]
|
|
411
|
+
if type_name == "PersonName":
|
|
412
|
+
return str(value)
|
|
413
|
+
if type_name == "UID":
|
|
414
|
+
return str(value)
|
|
415
|
+
if type_name == "DSfloat" or type_name == "DSdecimal":
|
|
416
|
+
return float(value)
|
|
417
|
+
if type_name == "IS":
|
|
418
|
+
return int(value)
|
|
419
|
+
if isinstance(value, list):
|
|
420
|
+
return [_pydicom_value_to_forge(item) for item in value]
|
|
421
|
+
return value
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _forge_value_to_pydicom(value: Any, pydicom: Any) -> Any:
|
|
425
|
+
"""Recursively convert DicomForge value types to pydicom equivalents."""
|
|
426
|
+
if isinstance(value, DicomDataset):
|
|
427
|
+
from dicomforge.io import _vr_for_tag
|
|
428
|
+
|
|
429
|
+
raw = pydicom.Dataset()
|
|
430
|
+
for tag, v in value.items():
|
|
431
|
+
raw.add_new((tag.group, tag.element), _vr_for_tag(tag, value), _forge_value_to_pydicom(v, pydicom))
|
|
432
|
+
return pydicom.Sequence([raw])
|
|
433
|
+
if isinstance(value, list):
|
|
434
|
+
return [_forge_value_to_pydicom(item, pydicom) for item in value]
|
|
435
|
+
return value
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _numpy_dtype(meta: FrameMetadata) -> Any:
|
|
439
|
+
import numpy as np # type: ignore[import-not-found]
|
|
440
|
+
|
|
441
|
+
if meta.bits_allocated == 8:
|
|
442
|
+
return np.int8 if meta.is_signed else np.uint8
|
|
443
|
+
if meta.bits_allocated == 16:
|
|
444
|
+
return np.int16 if meta.is_signed else np.uint16
|
|
445
|
+
if meta.bits_allocated == 32:
|
|
446
|
+
return np.int32 if meta.is_signed else np.uint32
|
|
447
|
+
raise ValueError(f"Unsupported BitsAllocated={meta.bits_allocated} for numpy dtype mapping.")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _ybr_to_rgb_if_needed(arr: Any, photometric: str, np: Any) -> Any:
|
|
451
|
+
"""Convert a YBR colour array to RGB when required for display.
|
|
452
|
+
|
|
453
|
+
DICOM allows colour pixel data to be stored in several YCbCr variants.
|
|
454
|
+
PIL expects RGB, so we must convert before constructing the Image.
|
|
455
|
+
|
|
456
|
+
Handled variants
|
|
457
|
+
----------------
|
|
458
|
+
YBR_FULL
|
|
459
|
+
Full-range ITU-R BT.601 YCbCr. Y in [0, 255], Cb/Cr in [0, 255]
|
|
460
|
+
with a 128 offset (0 = −128 after centering).
|
|
461
|
+
YBR_FULL_422
|
|
462
|
+
Same encoding as YBR_FULL; chroma subsampling is resolved at the
|
|
463
|
+
pixel-decode stage so the array already has one sample per pixel.
|
|
464
|
+
YBR_PARTIAL_422
|
|
465
|
+
Partial-range (studio-swing) YCbCr. Y in [16, 235],
|
|
466
|
+
Cb/Cr in [16, 240]. Less common in DICOM but must not be
|
|
467
|
+
displayed as-is.
|
|
468
|
+
|
|
469
|
+
RGB and unknown variants are returned unchanged.
|
|
470
|
+
"""
|
|
471
|
+
normalized = photometric.strip().upper()
|
|
472
|
+
|
|
473
|
+
if normalized in {"YBR_FULL", "YBR_FULL_422"}:
|
|
474
|
+
# ITU-R BT.601 full-range: Y∈[0,255], Cb/Cr∈[0,255] centred at 128
|
|
475
|
+
f = arr.astype(np.float64)
|
|
476
|
+
y = f[..., 0]
|
|
477
|
+
cb = f[..., 1] - 128.0
|
|
478
|
+
cr = f[..., 2] - 128.0
|
|
479
|
+
r = y + 1.402 * cr
|
|
480
|
+
g = y - 0.344136 * cb - 0.714136 * cr
|
|
481
|
+
b = y + 1.772 * cb
|
|
482
|
+
rgb = np.stack([r, g, b], axis=-1).clip(0, 255).astype(np.uint8)
|
|
483
|
+
return rgb
|
|
484
|
+
|
|
485
|
+
if normalized == "YBR_PARTIAL_422":
|
|
486
|
+
# ITU-R BT.601 partial-range (studio swing)
|
|
487
|
+
f = arr.astype(np.float64)
|
|
488
|
+
y = (f[..., 0] - 16.0) * (255.0 / 219.0)
|
|
489
|
+
cb = (f[..., 1] - 128.0) * (255.0 / 224.0)
|
|
490
|
+
cr = (f[..., 2] - 128.0) * (255.0 / 224.0)
|
|
491
|
+
r = y + 1.402 * cr
|
|
492
|
+
g = y - 0.344136 * cb - 0.714136 * cr
|
|
493
|
+
b = y + 1.772 * cb
|
|
494
|
+
rgb = np.stack([r, g, b], axis=-1).clip(0, 255).astype(np.uint8)
|
|
495
|
+
return rgb
|
|
496
|
+
|
|
497
|
+
# RGB or any other variant — return as-is
|
|
498
|
+
return arr
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _apply_window_numpy(arr: Any, center: float, width: float, np: Any) -> Any:
|
|
502
|
+
"""Vectorised linear VOI window application."""
|
|
503
|
+
lower = center - 0.5 - (width - 1) / 2.0
|
|
504
|
+
upper = center - 0.5 + (width - 1) / 2.0
|
|
505
|
+
out = np.where(
|
|
506
|
+
arr <= lower,
|
|
507
|
+
0.0,
|
|
508
|
+
np.where(
|
|
509
|
+
arr > upper,
|
|
510
|
+
255.0,
|
|
511
|
+
((arr - (center - 0.5)) / (width - 1) + 0.5) * 255.0,
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
return out
|