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 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