Skip to content

Commit 40aa4f1

Browse files
authored
Merge pull request #333 from ImagingDataCommons/bug/read_from_bytes
Allow reading from bytes objects
2 parents 9dc8154 + 102d8e0 commit 40aa4f1

9 files changed

Lines changed: 244 additions & 56 deletions

File tree

src/highdicom/ann/sop.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from typing_extensions import Self
1313

1414
import numpy as np
15-
from pydicom import dcmread
1615
from pydicom.dataset import Dataset
1716
from pydicom.sr.coding import Code
1817
from pydicom.uid import (
@@ -30,6 +29,7 @@
3029
)
3130
from highdicom.ann.content import AnnotationGroup
3231
from highdicom.base import SOPClass, _check_little_endian
32+
from highdicom.io import _wrapped_dcmread
3333
from highdicom.sr.coding import CodedConcept
3434
from highdicom.valuerep import check_person_name, _check_code_string
3535

@@ -478,6 +478,6 @@ def annread(
478478
479479
"""
480480
return MicroscopyBulkSimpleAnnotations.from_dataset(
481-
dcmread(fp),
481+
_wrapped_dcmread(fp),
482482
copy=False
483483
)

src/highdicom/image.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515
from typing_extensions import Self
1616

1717
import numpy as np
18-
from pydicom import Dataset, dcmread
18+
from pydicom import Dataset
1919
from pydicom.encaps import get_frame
2020
from pydicom.tag import BaseTag
2121
from pydicom.datadict import (
2222
get_entry,
2323
tag_for_keyword,
2424
)
25+
from pydicom.filebase import DicomIO, DicomBytesIO
2526
from pydicom.multival import MultiValue
2627
from pydicom.sr.coding import Code
2728
from pydicom.uid import ParametricMapStorage
@@ -37,7 +38,7 @@
3738
CoordinateSystemNames,
3839
)
3940
from highdicom.frame import decode_frame
40-
from highdicom.io import ImageFileReader
41+
from highdicom.io import ImageFileReader, _wrapped_dcmread
4142
from highdicom.pixels import (
4243
_check_rescale_dtype,
4344
_get_combined_palette_color_lut,
@@ -4781,17 +4782,18 @@ def from_file(
47814782
47824783
"""
47834784
if lazy_frame_retrieval:
4784-
if not isinstance(fp, (str, PathLike)):
4785-
raise TypeError(
4786-
"Argument 'fp' may not be of type bytes or BinaryIO "
4787-
"if using 'lazy_frame_retrieval'."
4788-
)
4785+
if isinstance(fp, bytes):
4786+
fp = DicomBytesIO(fp)
4787+
elif not isinstance(fp, (str, PathLike, DicomIO)):
4788+
# General BinaryIO object, wrap in DicomIO
4789+
fp = DicomIO(fp)
4790+
47894791
reader = ImageFileReader(fp)
47904792
metadata = reader._change_metadata_ownership()
47914793
image = cls.from_dataset(metadata, copy=False)
47924794
image._file_reader = reader
47934795
else:
4794-
image = cls.from_dataset(dcmread(fp), copy=False)
4796+
image = cls.from_dataset(_wrapped_dcmread(fp), copy=False)
47954797

47964798
return image
47974799

src/highdicom/io.py

Lines changed: 93 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
"""Input/Output of datasets based on DICOM Part10 files."""
22
import logging
3+
from os import PathLike
34
import sys
45
import traceback
56
from typing_extensions import Self
67
from pathlib import Path
78
import weakref
9+
from typing import BinaryIO
810

911
import numpy as np
1012
import pydicom
1113
from pydicom.dataset import Dataset, FileDataset
1214
from pydicom.encaps import parse_basic_offsets
13-
from pydicom.filebase import DicomFile, DicomFileLike, DicomBytesIO
15+
from pydicom.filebase import (
16+
DicomBytesIO,
17+
DicomFile,
18+
DicomIO,
19+
ReadableBuffer,
20+
)
1421
from pydicom.filereader import (
1522
data_element_offset_to_value,
1623
dcmread,
1724
read_file_meta_info,
1825
read_partial
1926
)
20-
from pydicom.tag import TupleTag, ItemTag, SequenceDelimiterTag
27+
from pydicom.tag import (
28+
ItemTag,
29+
SequenceDelimiterTag,
30+
TagListType,
31+
TupleTag,
32+
)
2133
from pydicom.uid import UID, DeflatedExplicitVRLittleEndian
2234

2335
from highdicom.frame import decode_frame
@@ -39,13 +51,40 @@
3951
_END_MARKERS = {_JPEG_EOI_MARKER, _JPEG2000_EOC_MARKER}
4052

4153

42-
def _get_bot(fp: DicomFileLike, number_of_frames: int) -> list[int]:
54+
def _wrapped_dcmread(
55+
fp: str | PathLike | BinaryIO | ReadableBuffer | bytes,
56+
defer_size: str | int | float | None = None,
57+
stop_before_pixels: bool = False,
58+
force: bool = False,
59+
specific_tags: TagListType | None = None,
60+
) -> pydicom.Dataset:
61+
"""A wrapper around dcmread to support reading from bytes.
62+
63+
Parameters match those of dcmread, but additional `fp` may be a raw bytes
64+
object containing the contents of a DICOM file.
65+
66+
"""
67+
if isinstance(fp, bytes):
68+
_fp = DicomBytesIO(fp)
69+
else:
70+
_fp = fp
71+
72+
return dcmread(
73+
_fp,
74+
defer_size=defer_size,
75+
stop_before_pixels=stop_before_pixels,
76+
force=force,
77+
specific_tags=specific_tags,
78+
)
79+
80+
81+
def _get_bot(fp: DicomIO, number_of_frames: int) -> list[int]:
4382
"""Tries to read the value of the Basic Offset Table (BOT) item and builds
4483
it in case it is empty.
4584
4685
Parameters
4786
----------
48-
fp: pydicom.filebase.DicomFileLike
87+
fp: pydicom.filebase.DicomIO
4988
Pointer for DICOM PS3.10 file stream positioned at the first byte of
5089
the Pixel Data element
5190
number_of_frames: int
@@ -85,12 +124,12 @@ def _get_bot(fp: DicomFileLike, number_of_frames: int) -> list[int]:
85124
return basic_offset_table
86125

87126

88-
def _read_bot(fp: DicomFileLike) -> list[int]:
127+
def _read_bot(fp: DicomIO) -> list[int]:
89128
"""Read Basic Offset Table (BOT) item of encapsulated Pixel Data element.
90129
91130
Parameters
92131
----------
93-
fp: pydicom.filebase.DicomFileLike
132+
fp: pydicom.filebase.DicomIO
94133
Pointer for DICOM PS3.10 file stream positioned at the first byte of
95134
the Pixel Data element
96135
@@ -125,12 +164,12 @@ def _read_bot(fp: DicomFileLike) -> list[int]:
125164
return offsets
126165

127166

128-
def _build_bot(fp: DicomFileLike, number_of_frames: int) -> list[int]:
167+
def _build_bot(fp: DicomIO, number_of_frames: int) -> list[int]:
129168
"""Build Basic Offset Table (BOT) item of encapsulated Pixel Data element.
130169
131170
Parameters
132171
----------
133-
fp: pydicom.filebase.DicomFileLike
172+
fp: pydicom.filebase.DicomIO
134173
Pointer for DICOM PS3.10 file stream positioned at the first byte of
135174
the Pixel Data element following the empty Basic Offset Table (BOT)
136175
number_of_frames: int
@@ -295,33 +334,41 @@ class with lazy frame retrieval (e.g. as output by the
295334
296335
"""
297336

298-
def __init__(self, filename: str | Path | DicomFileLike):
337+
def __init__(self, filename: str | Path | DicomIO):
299338
"""
300339
Parameters
301340
----------
302-
filename: Union[str, pathlib.Path, pydicom.filebase.DicomfileLike]
341+
filename: Union[str, pathlib.Path, pydicom.filebase.DicomIO]
303342
DICOM Part10 file containing a dataset of an image SOP Instance
304343
305344
"""
306-
if isinstance(filename, (DicomFileLike, DicomBytesIO)):
345+
if isinstance(filename, DicomIO):
307346
fp = filename
308347
self._fp = fp
309-
if isinstance(filename, DicomBytesIO):
310-
self._filename = None
311-
else:
348+
if hasattr(filename, "name") and filename.name is not None:
312349
self._filename = Path(fp.name)
350+
else:
351+
self._filename = None
352+
353+
# Since we did not open the file-like object, we should not close
354+
# it
355+
self._should_close = False
313356
elif isinstance(filename, (str, Path)):
314357
self._filename = Path(filename)
315358
self._fp = None
359+
360+
# Since we did open the file-like object, we should close it
361+
self._should_close = True
316362
else:
317363
raise TypeError(
318-
'Argument "filename" must be either an open DICOM file object '
364+
'Argument "filename" must be either an open DicomIO object '
319365
'or the path to a DICOM file stored on disk.'
320366
)
321367
self._metadata: Dataset | weakref.ReferenceType | None = None
322368
self._voi_lut = None
323369
self._palette_color_lut = None
324370
self._modality_lut = None
371+
self._enter_depth = 0
325372

326373
def _change_metadata_ownership(self) -> FileDataset:
327374
"""Set the metadata using a weakref.
@@ -356,24 +403,25 @@ def filename(self) -> str:
356403
return str(self._filename)
357404

358405
def __enter__(self) -> Self:
359-
self.open()
406+
if self._enter_depth == 0:
407+
self.open()
408+
self._enter_depth += 1
360409
return self
361410

362411
def __exit__(self, except_type, except_value, except_trace) -> None:
363-
try:
364-
self._fp.close()
365-
except AttributeError:
366-
pass
367-
else:
368-
self._fp = None
369-
if except_value:
370-
sys.stderr.write(
371-
f'Error while accessing file "{self._filename}":\n'
372-
f'{except_value}'
373-
)
374-
for tb in traceback.format_tb(except_trace):
375-
sys.stderr.write(tb)
376-
raise
412+
self._enter_depth -= 1
413+
if self._enter_depth < 1:
414+
if self._should_close:
415+
self._fp.close()
416+
self._fp = None
417+
if except_value:
418+
sys.stderr.write(
419+
f'Error while accessing file "{self._filename}":\n'
420+
f'{except_value}'
421+
)
422+
for tb in traceback.format_tb(except_trace):
423+
sys.stderr.write(tb)
424+
raise
377425

378426
def open(self) -> None:
379427
"""Open file for reading.
@@ -406,19 +454,21 @@ def open(self) -> None:
406454
raise OSError(
407455
f'Could not open file for reading: "{self._filename}"'
408456
) from e
409-
is_little_endian, is_implicit_VR = self._check_file_format(self._fp)
410-
self._fp.is_little_endian = is_little_endian
411-
self._fp.is_implicit_VR = is_implicit_VR
457+
if not hasattr(self._fp, 'is_implicit_VR'):
458+
self._fp.seek(0)
459+
is_little_endian, is_implicit_VR = self._check_file_format(self._fp)
460+
self._fp.is_little_endian = is_little_endian
461+
self._fp.is_implicit_VR = is_implicit_VR
412462

413463
def _check_file_format(
414464
self,
415-
fp: DicomFileLike
465+
fp: DicomIO
416466
) -> tuple[bool, bool]:
417467
"""Check whether file object represents a DICOM Part 10 file.
418468
419469
Parameters
420470
----------
421-
fp: pydicom.filebase.DicomFileLike
471+
fp: pydicom.filebase.DicomIO
422472
DICOM file object
423473
424474
Returns
@@ -626,10 +676,11 @@ def _bytes_per_frame_uncompressed(self) -> int:
626676

627677
def close(self) -> None:
628678
"""Closes file."""
629-
try:
630-
self._fp.close()
631-
except AttributeError:
632-
return
679+
if self._should_close:
680+
try:
681+
self._fp.close()
682+
except AttributeError:
683+
return
633684

634685
def read_frame_raw(self, index: int) -> bytes:
635686
"""Reads the raw pixel data of an individual frame item.
@@ -748,7 +799,7 @@ def read_frame(self, index: int, correct_color: bool = True) -> np.ndarray:
748799
@property
749800
def number_of_frames(self) -> int:
750801
"""int: Number of frames"""
751-
try:
752-
return int(self.metadata.NumberOfFrames)
753-
except AttributeError:
802+
if 'NumberOfFrames' in self.metadata:
803+
return self.metadata.NumberOfFrames
804+
else:
754805
return 1

src/highdicom/spatial.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3811,7 +3811,7 @@ def sort_datasets(
38113811
38123812
Returns
38133813
-------
3814-
List[Dataset]
3814+
List[pydicom.Dataset]
38153815
List of datasets sorted according to spatial position, using the
38163816
convention specified by the input parameters.
38173817

src/highdicom/sr/sop.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from collections.abc import Generator, Mapping, Sequence
1414
from typing_extensions import Self
1515

16-
from pydicom import dcmread
1716
from pydicom.dataset import Dataset
1817
from pydicom.sr.coding import Code
1918
from pydicom.uid import (
@@ -31,6 +30,7 @@
3130
)
3231

3332
from highdicom.base import SOPClass, _check_little_endian
33+
from highdicom.io import _wrapped_dcmread
3434
from highdicom.sr.coding import CodedConcept
3535
from highdicom.sr.enum import ValueTypeValues
3636
from highdicom.sr.templates import MeasurementReport
@@ -939,7 +939,7 @@ def srread(
939939
ComprehensiveSRStorage: ComprehensiveSR,
940940
Comprehensive3DSRStorage: Comprehensive3DSR,
941941
}
942-
dcm = dcmread(fp)
942+
dcm = _wrapped_dcmread(fp)
943943

944944
sop_class_uid = dcm.SOPClassUID
945945
if sop_class_uid in class_map:

0 commit comments

Comments
 (0)