From 455a059cb4216e2fce5df4e67c8f847ab4bfe4b7 Mon Sep 17 00:00:00 2001 From: scaramallion Date: Wed, 11 Mar 2020 15:49:25 +1100 Subject: [PATCH 1/7] Remove scripts, add decode --- .travis.yml | 34 ++++++-- pylibjpeg/__init__.py | 3 +- pylibjpeg/plugins.py | 8 ++ pylibjpeg/pydicom/pixel_data_handler.py | 43 +++++++--- pylibjpeg/scripts/get_pixel_data.py | 66 --------------- pylibjpeg/scripts/imshow.py | 53 ------------ pylibjpeg/scripts/xtest_decode.py | 85 ------------------- pylibjpeg/scripts/xtest_reconstruct.py | 42 ---------- pylibjpeg/tests/test_decode.py | 103 ++++++++++++++++++++++++ pylibjpeg/tests/test_plugins.py | 14 ++-- pylibjpeg/utils.py | 77 +++++++++++++++++- 11 files changed, 251 insertions(+), 277 deletions(-) delete mode 100755 pylibjpeg/scripts/get_pixel_data.py delete mode 100755 pylibjpeg/scripts/imshow.py delete mode 100755 pylibjpeg/scripts/xtest_decode.py delete mode 100755 pylibjpeg/scripts/xtest_reconstruct.py create mode 100644 pylibjpeg/tests/test_decode.py diff --git a/.travis.yml b/.travis.yml index be09865..cd95e74 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,21 +7,21 @@ language: python matrix: include: # No plugins + pydicom - - name: "Python 3.6, Ubuntu" + - name: "Python 3.6, Ubuntu + pydicom" os: linux dist: bionic python: "3.6" env: - INSTALL_PYDICOM=true - INSTALL_LIBJPEG=false - - name: "Python 3.7, Ubuntu" + - name: "Python 3.7, Ubuntu + pydicom" os: linux dist: bionic python: "3.7" env: - INSTALL_PYDICOM=true - INSTALL_LIBJPEG=false - - name: "Python 3.8, Ubuntu" + - name: "Python 3.8, Ubuntu + pydicom" os: linux dist: bionic python: "3.8" @@ -29,27 +29,49 @@ matrix: - INSTALL_PYDICOM=true - INSTALL_LIBJPEG=false # libjpeg + pydicom - - name: "Python 3.6, Ubuntu + libjpeg" + - name: "Python 3.6, Ubuntu + libjpeg + pydicom" os: linux dist: bionic python: "3.6" env: - INSTALL_PYDICOM=true - INSTALL_LIBJPEG=true - - name: "Python 3.7, Ubuntu + libjpeg" + - name: "Python 3.7, Ubuntu + libjpeg + pydicom" os: linux dist: bionic python: "3.7" env: - INSTALL_PYDICOM=true - INSTALL_LIBJPEG=true - - name: "Python 3.8, Ubuntu + libjpeg" + - name: "Python 3.8, Ubuntu + libjpeg + pydicom" os: linux dist: bionic python: "3.8" env: - INSTALL_PYDICOM=true - INSTALL_LIBJPEG=true + # libjpeg standalone + - name: "Python 3.6, Ubuntu + libjpeg" + os: linux + dist: bionic + python: "3.6" + env: + - INSTALL_PYDICOM=false + - INSTALL_LIBJPEG=true + - name: "Python 3.7, Ubuntu + libjpeg" + os: linux + dist: bionic + python: "3.7" + env: + - INSTALL_PYDICOM=false + - INSTALL_LIBJPEG=true + - name: "Python 3.8, Ubuntu + libjpeg" + os: linux + dist: bionic + python: "3.8" + env: + - INSTALL_PYDICOM=false + - INSTALL_LIBJPEG=true # Install dependencies and package diff --git a/pylibjpeg/__init__.py b/pylibjpeg/__init__.py index 81b4026..654e3f2 100644 --- a/pylibjpeg/__init__.py +++ b/pylibjpeg/__init__.py @@ -7,6 +7,7 @@ from ._version import __version__ from ._config import PLUGINS from .plugins import load_plugins +from .utils import decode # Setup default logging @@ -27,7 +28,7 @@ def debug_logger(): # TODO: remove this later -debug_logger() +#debug_logger() try: diff --git a/pylibjpeg/plugins.py b/pylibjpeg/plugins.py index 6038ffa..9538be3 100644 --- a/pylibjpeg/plugins.py +++ b/pylibjpeg/plugins.py @@ -104,6 +104,14 @@ def get_decoder(uid): def get_decoders(): + decoders = {} + for name in get_plugins(): + decoders[name] = getattr(globals()[name], 'decode') + + return decoders + + +def get_uid_decoders(): uids = get_transfer_syntaxes(decodable=True) decoders = {} dec, _ = get_plugin_coders() diff --git a/pylibjpeg/pydicom/pixel_data_handler.py b/pylibjpeg/pydicom/pixel_data_handler.py index 7386d4a..30dde83 100644 --- a/pylibjpeg/pydicom/pixel_data_handler.py +++ b/pylibjpeg/pydicom/pixel_data_handler.py @@ -36,37 +36,36 @@ """ +import logging + import numpy as np from pydicom.encaps import generate_pixel_data_frame from pydicom.pixel_data_handlers.util import pixel_dtype, get_expected_length -from pylibjpeg.plugins import get_decoders +from pylibjpeg.plugins import get_uid_decoders + +LOGGER = logging.getLogger(__name__) try: import pylibjpeg.plugins.libjpeg HAVE_LIBJPEG = True + LOGGER.debug("libjpeg available to the pixel data handler") except ImportError: HAVE_LIBJPEG = False + LOGGER.debug("libjpeg unavailable to the pixel data handler") try: import pylibjpeg.plugins.openjpeg HAVE_OPENJPEG = True + LOGGER.debug("openjpeg available to the pixel data handler") except ImportError: HAVE_OPENJPEG = False + LOGGER.debug("openjpeg unavailable to the pixel data handler") + HANDLER_NAME = 'pylibjpeg' -DEPENDENCIES = { - 'numpy': ('http://www.numpy.org/', 'NumPy'), - 'libjpeg': ( - 'http://github.com/pydicom/pylibjpeg-libjpeg/', 'libjpeg plugin' - ), - 'openjpeg': ( - 'http://github.com/pydicom/pylibjpeg-openjpeg/', 'openjpeg plugin' - ), -} - -_DECODERS = get_decoders() -SUPPORTED_TRANSFER_SYNTAXES = [ +_DECODERS = get_uid_decoders() +_LIBJPEG_SYNTAXES = [ '1.2.840.10008.1.2.4.50', '1.2.840.10008.1.2.4.51', '1.2.840.10008.1.2.4.57', @@ -74,6 +73,11 @@ '1.2.840.10008.1.2.4.80', '1.2.840.10008.1.2.4.81' ] +_OPENJPEG_SYNTAXES = [ + '1.2.840.10008.1.2.4.90', + '1.2.840.10008.1.2.4.91' +] +SUPPORTED_TRANSFER_SYNTAXES = _LIBJPEG_SYNTAXES + _OPENJPEG_SYNTAXES def is_available(): @@ -148,6 +152,18 @@ def get_pixeldata(ds): "is not supported by the pylibjpeg pixel data handler." ) + if tsyntax in _LIBJPEG_SYNTAXES and not HAVE_LIBJPEG: + raise RuntimeError( + "The libjpeg plugin is required to decode pixel data with a " + "transfer syntax of '{}'".format(tsyntax) + ) + + if tsyntax in _OPENJPEG_SYNTAXES and not HAVE_OPENJPEG: + raise RuntimeError( + "The openjpeg plugin is required to decode pixel data with a " + "transfer syntax of '{}'".format(tsyntax) + ) + # Check required elements required_elements = [ 'BitsAllocated', 'Rows', 'Columns', 'PixelRepresentation', @@ -178,6 +194,7 @@ def get_pixeldata(ds): arr = np.empty(expected_len, np.uint8) decoder = _DECODERS[tsyntax] + LOGGER.debug("Decoding {} Pixel Data using {}".format(tsyntax, decoder)) # Generators for the encoded JPG image frame(s) and insertion offsets generate_frames = generate_pixel_data_frame(ds.PixelData, nr_frames) diff --git a/pylibjpeg/scripts/get_pixel_data.py b/pylibjpeg/scripts/get_pixel_data.py deleted file mode 100755 index ac68a2b..0000000 --- a/pylibjpeg/scripts/get_pixel_data.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python - -import argparse -import sys - -from pydicom import dcmread -from pydicom.encaps import defragment_data, decode_data_sequence - - -def setup_argparse(): - parser = argparse.ArgumentParser( - description="Extract the pixel data from the DICOM dataset", - usage="get_pixel_data.py file" - ) - - # Parameters - req = parser.add_argument_group('Parameters') - req.add_argument( - "file", help="The DICOM dataset to extract pixel data from", type=str - ) - opts = parser.add_argument_group("Options") - opts.add_argument( - "-p", - help="The prefix to use for the extracted images", - type=str, - default="im" - ) - opts.add_argument( - "-v", - help="Print verbose output", - action="store_true" - ) - - return parser.parse_args() - - -if __name__ == "__main__": - args = setup_argparse() - - ds = dcmread(args.file) - if 'PixelData' not in ds: - sys.exit("No Pixel Data in input file") - - if args.v: - print(ds.file_meta['TransferSyntaxUID']) - keywords = [ - 'NumberOfFrames', 'BitsAllocated', 'BitsStored', 'SamplesPerPixel', - 'PixelRepresentation', 'Rows', 'Columns', - 'PhotometricInterpretation', 'PlanarConfiguration' - ] - for kw in keywords: - if kw in ds: print(ds[kw]) - - ext = 'jpg' if 'JPEG' in ds.file_meta.TransferSyntaxUID.name else 'bin' - - if getattr(ds, 'NumberOfFrames', 1) > 1: - pixel_data = decode_data_sequence(ds.PixelData) - for frame, ii in enumerate(frames): - fname = '{}_{:04d}.{}'.format(args.p, ii, ext) - with open(fname, 'wb') as out: - out.write(frame) - else: - pixel_data = defragment_data(ds.PixelData) - fname = '{}.{}'.format(args.p, ext) - with open(fname, 'wb') as out: - out.write(pixel_data) diff --git a/pylibjpeg/scripts/imshow.py b/pylibjpeg/scripts/imshow.py deleted file mode 100755 index a62e055..0000000 --- a/pylibjpeg/scripts/imshow.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python - -import argparse -import matplotlib.pyplot as plt -import sys - -from pydicom import dcmread -from pydicom.pixel_data_handlers.util import convert_color_space -from pylibjpeg import add_handler - -add_handler() - - -def setup_argparse(): - parser = argparse.ArgumentParser( - description="Extract the pixel data from the DICOM dataset", - usage="imshow.py file" - ) - - # Parameters - req = parser.add_argument_group('Parameters') - req.add_argument( - "file", help="The DICOM dataset to extract pixel data from", type=str - ) - opts = parser.add_argument_group("Options") - opts.add_argument( - "-i", "--index", - help="For multiframe pixel data, show this frame", - type=int, - default=0, - ) - - return parser.parse_args() - - -if __name__ == "__main__": - args = setup_argparse() - - ds = dcmread(args.file) - if 'PixelData' not in ds: - sys.exit("No Pixel Data in input file") - - arr = ds.pixel_array - if 'YBR' in ds.PhotometricInterpretation: - print('Converting to RGB') - arr = convert_color_space(arr, ds.PhotometricInterpretation, 'RGB') - - if getattr(ds, 'NumberOfFrames', 1) > 1: - plt.imshow(arr[args.index]) - else: - plt.imshow(arr) - - plt.show() diff --git a/pylibjpeg/scripts/xtest_decode.py b/pylibjpeg/scripts/xtest_decode.py deleted file mode 100755 index 5a49b7f..0000000 --- a/pylibjpeg/scripts/xtest_decode.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python - -import os -import sys - -import numpy as np -import matplotlib.pyplot as plt - -from pydicom import dcmread -from pydicom.encaps import defragment_data -from pydicom.pixel_data_handlers.util import ( - get_expected_length, reshape_pixel_array, pixel_dtype -) -from pydicom.jpeg import jpgread - -from pylibjpeg import decode -from pylibjpeg.data.manager import DATA_DIR - - -SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__)) - - -if __name__ == "__main__": - # OK - #dsfile = os.path.join(SCRIPT_DIR, 'JPGLosslessP14SV1_1s_1f_8b.dcm') - # OK - #dsfile = os.path.join(SCRIPT_DIR, 'JPGLS_1s_1f_16b.dcm') - # OK - #dsfile = os.path.join(SCRIPT_DIR, 'JPGExtended.dcm') - # Fails - bad JPEG; should have SOS:Se of 63 if actually sequential DCT - #dsfile = os.path.join(SCRIPT_DIR, 'JPEG-lossy.dcm') - # OK - #dsfile = os.path.join(SCRIPT_DIR, 'JPGBaseline_3s_1f_8b.dcm') - #dsfile = os.path.join(DATA_DIR, 'ds', 'JPGExtended_BAD.dcm') - with open(os.path.join(SCRIPT_DIR, 'temp.jpg'), 'rb') as fp: - jpg = jpgread(fp) - print(jpg) - print(jpg.uid) - sys.exit() - - ds = dcmread(dsfile) - - print(ds.file_meta['TransferSyntaxUID']) - keywords = [ - 'NumberOfFrames', 'BitsAllocated', 'BitsStored', 'SamplesPerPixel', - 'PixelRepresentation', 'Rows', 'Columns', - 'PhotometricInterpretation', 'PlanarConfiguration' - ] - for kw in keywords: - if kw in ds: print(ds[kw]) - - if 'JPEG' not in ds.file_meta.TransferSyntaxUID.name: - sys.exit('Not a supported Transfer Syntax') - - if getattr(ds, 'NumberOfFrames', 1) > 1: - sys.exit('Number of Frames > 1 not supported') - - pixel_data = defragment_data(ds.PixelData) - - #fname = os.path.join(SCRIPT_DIR, 'in.jpg') - #with open(fname, 'wb') as out: - # out.write(pixel_data) - - #with open(fname, 'rb') as fp: - # jpg = jpgread(fp) - # print(jpg) - - #infile = bytes(fname, 'utf-8') - #outfile = bytes(os.path.join(SCRIPT_DIR, 'out.pnm'), 'utf-8') - - #print( - # 'Expected length of pixel data in bytes: {}' - # .format(get_expected_length(ds)) - #) - expected_length = ds.Rows * ds.Columns * ds.SamplesPerPixel * (ds.BitsAllocated // 8) - - arr = np.frombuffer(pixel_data, dtype=np.uint8) - #print(expected_length) - out = decode(arr, expected_length).view(pixel_dtype(ds)) - print(out.shape, out, out.dtype) - out = reshape_pixel_array(ds, out) - #print(out.shape, out) - - plt.imshow(out) - plt.show() diff --git a/pylibjpeg/scripts/xtest_reconstruct.py b/pylibjpeg/scripts/xtest_reconstruct.py deleted file mode 100755 index a1ec81c..0000000 --- a/pylibjpeg/scripts/xtest_reconstruct.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python - -import os -import sys -from tempfile import NamedTemporaryFile - -import numpy as np -import matplotlib.pyplot as plt - -from pydicom import dcmread -from pydicom.encaps import defragment_data -from pydicom.pixel_data_handlers.util import ( - get_expected_length, reshape_pixel_array, pixel_dtype -) - -from pylibjpeg.libjpeg import reconstruct -from pylibjpeg.data.manager import get_datasets - - -if __name__ == "__main__": - fname = get_datasets('1.2.840.10008.1.2.4.50')[1] - ds = dcmread(fname) - - if getattr(ds, 'NumberOfFrames', 1) > 1: - sys.exit('Number of Frames > 1 not supported') - - pixel_data = defragment_data(ds.PixelData) - - tfin = NamedTemporaryFile('wb') - tfin.write(pixel_data) - tfin.seek(0) - - tfout = NamedTemporaryFile('rb+') - reconstruct(tfin.name, tfout.name) - tfout.seek(0) - - from io import BytesIO - data = BytesIO(tfout.read()) - - # Needs pillow, write a parser for numpy instead... - plt.imshow(plt.imread(data, format='PPM')) - plt.show() diff --git a/pylibjpeg/tests/test_decode.py b/pylibjpeg/tests/test_decode.py new file mode 100644 index 0000000..877e911 --- /dev/null +++ b/pylibjpeg/tests/test_decode.py @@ -0,0 +1,103 @@ +"""Tests for standalone decoding.""" + +from io import BytesIO +import os +from pathlib import Path +import platform +import sys + +import pytest + +from pylibjpeg import decode +from pylibjpeg.plugins import get_plugins +from pylibjpeg.data import JPEG_DIRECTORY + +HAS_PLUGINS = get_plugins() != [] + + +@pytest.mark.skipif(HAS_PLUGINS, reason="Plugins available") +class TestNoPlugins(object): + """Test interactions with no plugins.""" + def test_decode_str(self): + """Test passing a str to decode.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + assert isinstance(fpath, str) + with pytest.raises(RuntimeError, match=r"No decoders are available"): + decode(fpath) + + def test_decode_pathlike(self): + """Test passing a pathlike to decode.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + p = Path(fpath) + assert isinstance(p, os.PathLike) + with pytest.raises(RuntimeError, match=r"No decoders are available"): + decode(p) + + def test_decode_filelike(self): + """Test passing a filelike to decode.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + with open(fpath, 'rb') as f: + msg = r"No decoders are available" + with pytest.raises(RuntimeError, match=msg): + decode(f) + + def test_decode_bytes(self): + """Test passing bytes to decode.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + with open(fpath, 'rb') as f: + data = f.read() + + assert isinstance(data, bytes) + msg = r"No decoders are available" + with pytest.raises(RuntimeError, match=msg): + decode(data) + + +@pytest.mark.skipif(not HAS_PLUGINS, reason="Plugins unavailable") +class TestPlugins(object): + """Test decoding with plugins.""" + def test_decode_str(self): + """Test passing a str to decode.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + assert isinstance(fpath, str) + arr = decode(fpath) + + def test_decode_pathlike(self): + """Test passing a pathlike to decode.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + p = Path(fpath) + assert isinstance(p, os.PathLike) + arr = decode(p) + + def test_decode_filelike(self): + """Test passing a filelike to decode.""" + bs = BytesIO() + + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + with open(fpath, 'rb') as f: + arr = decode(f) + + with open(fpath, 'rb') as f: + bs.write(f.read()) + + bs.seek(0) + arr = decode(bs) + + def test_decode_bytes(self): + """Test passing bytes to decode.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + with open(fpath, 'rb') as f: + data = f.read() + + assert isinstance(data, bytes) + arr = decode(data) + + def test_failure(self): + """Test failure to decode.""" + pass + + def test_specify_decoder(self): + """Test specifying the decoder.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + assert isinstance(fpath, str) + arr = decode(fpath, decoder='libjpeg') diff --git a/pylibjpeg/tests/test_plugins.py b/pylibjpeg/tests/test_plugins.py index a1222c3..3d8f454 100644 --- a/pylibjpeg/tests/test_plugins.py +++ b/pylibjpeg/tests/test_plugins.py @@ -8,7 +8,7 @@ from pylibjpeg.plugins import ( get_plugin_coders, get_plugins, get_transfer_syntaxes, get_decoder, - get_encoder, get_decoders + get_encoder, get_uid_decoders ) # TODO: Switch this over to openjpeg @@ -55,9 +55,9 @@ def test_get_encoder_raises(self): with pytest.raises(NotImplementedError, match=msg): get_encoder('1.2.3') - def test_get_decoders(self): - """Test get_decoders().""" - assert {} == get_decoders() + def test_get_uid_decoders(self): + """Test get_uid_decoders().""" + assert {} == get_uid_decoders() @pytest.mark.skipif(not HAS_PLUGINS or not HAS_LIBJPEG, reason="No libjpeg") @@ -101,8 +101,8 @@ def test_get_decoder(self): """Test get_decoder().""" decoder = get_decoder('1.2.840.10008.1.2.4.50') - def test_get_decoders(self): - """Test get_decoders().""" + def test_get_uid_decoders(self): + """Test get_uid_decoders().""" reference = [ '1.2.840.10008.1.2.4.50', '1.2.840.10008.1.2.4.51', @@ -111,6 +111,6 @@ def test_get_decoders(self): '1.2.840.10008.1.2.4.80', '1.2.840.10008.1.2.4.81', ] - decoders = get_decoders() + decoders = get_uid_decoders() for uid in reference: assert uid in decoders diff --git a/pylibjpeg/utils.py b/pylibjpeg/utils.py index f193178..6425618 100644 --- a/pylibjpeg/utils.py +++ b/pylibjpeg/utils.py @@ -1,8 +1,13 @@ -#try: -from .pydicom import pixel_data_handler as handler -#except ImportError: -# pass +import logging +import os + +import numpy as np + +from .plugins import get_decoders + + +LOGGER = logging.getLogger(__name__) def add_handler(): @@ -13,12 +18,75 @@ def add_handler(): ImportError If *pydicom* is not available. """ + from .pydicom import pixel_data_handler as handler import pydicom.config if handler not in pydicom.config.pixel_data_handlers: pydicom.config.pixel_data_handlers.append(handler) +def decode(data, kwargs=None, decoder=None): + """Return the decoded JPEG image as a :class:`numpy.ndarray`. + + Parameters + ---------- + data : str, file-like, os.PathLike, or bytes + The data to decode. May be a path to a file (as ``str`` or + path-like), a file-like, or a ``bytes`` containing the encoded binary + data. + kwargs : dict + A ``dict`` containing keyword parameters to pass to the decoder. + decoder : callable, optional + The plugin to use when decoding the data. Should be ``'libjpeg'`` + or ``'openjpeg'``. If not used then all available decoders will be + tried. + + Returns + ------- + numpy.ndarray + An ``ndarray`` containing the decoded image data. + + Raises + ------ + RuntimeError + If `decoder` is not ``None`` and the corresponding plugin is not + available. + """ + decoders = get_decoders() + if not decoders: + raise RuntimeError("No decoders are available") + + if isinstance(data, (str, os.PathLike)): + with open(str(data), 'rb') as f: + data = np.frombuffer(f.read(), 'uint8') + elif isinstance(data, bytes): + data = np.frombuffer(data, 'uint8') + else: + # Try file-like + data = np.frombuffer(data.read(), 'uint8') + + kwargs = kwargs or {} + + # Test plugin is available + if decoder is not None: + try: + return decoders[decoder](data, **kwargs) + except KeyError: + raise ValueError( + "The '{}' decoder is not available".format(decoder) + ) + + for name, decoder in decoders.items(): + try: + return decoder(data, **kwargs) + except Exception as exc: + LOGGER.debug("Decoding with {} plugin failed".format(name)) + LOGGER.exception(exc) + + # If we made it here then we were unable to decode the data + raise ValueError("Unable to decode the data") + + def remove_handler(): """Remove the pixel data handler from *pydicom*. @@ -27,6 +95,7 @@ def remove_handler(): ImportError If *pydicom* is not available. """ + from .pydicom import pixel_data_handler as handler import pydicom.config if handler in pydicom.config.pixel_data_handlers: From 70be2189d13b56a84bf8262ec8d3749d982c6a0a Mon Sep 17 00:00:00 2001 From: scaramallion Date: Wed, 11 Mar 2020 15:53:53 +1100 Subject: [PATCH 2/7] Add missing dependencies --- pylibjpeg/pydicom/pixel_data_handler.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pylibjpeg/pydicom/pixel_data_handler.py b/pylibjpeg/pydicom/pixel_data_handler.py index 30dde83..6bd5b72 100644 --- a/pylibjpeg/pydicom/pixel_data_handler.py +++ b/pylibjpeg/pydicom/pixel_data_handler.py @@ -64,6 +64,16 @@ HANDLER_NAME = 'pylibjpeg' +DEPENDENCIES = { + 'numpy': ('http://www.numpy.org/', 'NumPy'), + 'libjpeg': ( + 'http://github.com/pydicom/pylibjpeg-libjpeg/', 'libjpeg plugin' + ), + 'openjpeg': ( + 'http://github.com/pydicom/pylibjpeg-openjpeg/', 'openjpeg plugin' + ), +} + _DECODERS = get_uid_decoders() _LIBJPEG_SYNTAXES = [ '1.2.840.10008.1.2.4.50', From 6d6a460720b2d1df444d0c29dbccf3ffe47a1f3a Mon Sep 17 00:00:00 2001 From: scaramallion Date: Wed, 11 Mar 2020 18:47:14 +1100 Subject: [PATCH 3/7] Add tests --- README.md | 21 +++++---- pylibjpeg/__init__.py | 14 ++---- pylibjpeg/tests/test_decode.py | 12 ++++- pylibjpeg/tests/test_environment.py | 16 +++++++ pylibjpeg/tests/test_plugins.py | 8 ++++ pylibjpeg/tests/test_pydicom.py | 72 ++++++++++++++++++++++++++++- 6 files changed, 123 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 37e5682..87f84f8 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,19 @@ ## pylibjpeg -A Python 3.6+ framework for decoding JPEG images, with a focus on providing -support for [pydicom](https://github.com/pydicom/pydicom). - -Linux, OSX and Windows are all supported. +A Python 3.6+ framework for decoding JPEG images, with a focus on providing JPEG support for [pydicom](https://github.com/pydicom/pydicom). ### Installation +#### Installing the current release + +``` +pip install pylibjpeg +``` + #### Installing the development version -Make sure [Python](https://www.python.org/) and [Git](https://git-scm.com/) are installed. +Make sure [Git](https://git-scm.com/) is installed, then ```bash git clone https://github.com/pydicom/pylibjpeg python -m pip install pylibjpeg @@ -21,11 +24,13 @@ python -m pip install pylibjpeg ### Plugins By itself *pylibjpeg* is unable to decode any JPEG images, which is where the -plugins come in. To support the given JPEG format you'll first have to install +plugins come in. To support a given JPEG format you first have to install the corresponding package: -* JPEG, JPEG-LS and JPEG XT: [pylibjpeg-libjpeg](https://github.com/pydicom/pylibjpeg-libjpeg) -* JPEG2000: To be implemented +| JPEG Format | Decode | Encode | Plugin | Based on | +|---|------|---|---|---| +| JPEG, JPEG-LS and JPEG XT | Yes | No | [pylibjpeg-libjpeg](https://github.com/pydicom/pylibjpeg-libjpeg) | [libjpeg](https://github.com/thorfdbg/libjpeg) | +| JPEG 2000 | No | No | To be implemented | [openjpeg](https://github.com/uclouvain/openjpeg)| ### Usage #### With pydicom diff --git a/pylibjpeg/__init__.py b/pylibjpeg/__init__.py index 654e3f2..b521d73 100644 --- a/pylibjpeg/__init__.py +++ b/pylibjpeg/__init__.py @@ -11,9 +11,9 @@ # Setup default logging -LOGGER = logging.getLogger('pynetdicom') -LOGGER.addHandler(logging.NullHandler()) -LOGGER.debug("pylibjpeg v{}".format(__version__)) +_logger = logging.getLogger('pynetdicom') +_logger.addHandler(logging.NullHandler()) +_logger.debug("pylibjpeg v{}".format(__version__)) def debug_logger(): @@ -27,16 +27,12 @@ def debug_logger(): logger.addHandler(handler) -# TODO: remove this later -#debug_logger() - - try: import data as _data globals()['data'] = _data # Add to cache - needed for pytest sys.modules['pylibjpeg.data'] = _data - LOGGER.debug('pylibjpeg-data module loaded') + _logger.debug('pylibjpeg-data module loaded') except ImportError: pass @@ -47,6 +43,6 @@ def debug_logger(): try: import pydicom add_handler() - LOGGER.debug('pydicom module loaded') + _logger.debug('pydicom module loaded') except ImportError: pass diff --git a/pylibjpeg/tests/test_decode.py b/pylibjpeg/tests/test_decode.py index 877e911..0eadea0 100644 --- a/pylibjpeg/tests/test_decode.py +++ b/pylibjpeg/tests/test_decode.py @@ -92,12 +92,20 @@ def test_decode_bytes(self): assert isinstance(data, bytes) arr = decode(data) - def test_failure(self): + def test_decode_failure(self): """Test failure to decode.""" - pass + with pytest.raises(ValueError, match=r"Unable to decode"): + decode(b'\x00\x00') def test_specify_decoder(self): """Test specifying the decoder.""" fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') assert isinstance(fpath, str) arr = decode(fpath, decoder='libjpeg') + + def test_specify_unknown_decoder(self): + """Test specifying an unknown decoder.""" + fpath = os.path.join(JPEG_DIRECTORY, '10918', 'p1', 'A1.JPG') + assert isinstance(fpath, str) + with pytest.raises(ValueError, match=r"The 'openjpeg' decoder"): + decode(fpath, decoder='openjpeg') diff --git a/pylibjpeg/tests/test_environment.py b/pylibjpeg/tests/test_environment.py index 5256f11..45a3fca 100644 --- a/pylibjpeg/tests/test_environment.py +++ b/pylibjpeg/tests/test_environment.py @@ -1,11 +1,27 @@ """Tests for the TravisCI testing environments""" +import logging import os import platform import sys import pytest +from pylibjpeg import debug_logger + + +def test_debug_logger(caplog): + """Test the debug logger works.""" + debug_logger() + logger = logging.getLogger(__name__) + with caplog.at_level(logging.DEBUG): + logger.debug("This is a test") + + assert 'This is a test' in caplog.text + + # Reset + logging.getLogger('pylibjpeg').handlers = [] + def get_envar(envar): """Return the value of the environmental variable `envar`. diff --git a/pylibjpeg/tests/test_plugins.py b/pylibjpeg/tests/test_plugins.py index 3d8f454..cc8c9f5 100644 --- a/pylibjpeg/tests/test_plugins.py +++ b/pylibjpeg/tests/test_plugins.py @@ -101,6 +101,14 @@ def test_get_decoder(self): """Test get_decoder().""" decoder = get_decoder('1.2.840.10008.1.2.4.50') + def test_get_decoder_missing(self): + """Test get_decoder() with no matching decoder.""" + msg = ( + r"No decoder is available for the Transfer Syntax UID - '1.2.3.4'" + ) + with pytest.raises(NotImplementedError, match=msg): + get_decoder('1.2.3.4') + def test_get_uid_decoders(self): """Test get_uid_decoders().""" reference = [ diff --git a/pylibjpeg/tests/test_pydicom.py b/pylibjpeg/tests/test_pydicom.py index 4bfc2ae..d710f8a 100644 --- a/pylibjpeg/tests/test_pydicom.py +++ b/pylibjpeg/tests/test_pydicom.py @@ -8,12 +8,15 @@ try: import pydicom + import pydicom.config + from pylibjpeg.pydicom import pixel_data_handler as handler HAS_PYDICOM = True except ImportError: HAS_PYDICOM = False -from pylibjpeg.plugins import get_plugins from pylibjpeg.data import get_indexed_datasets +from pylibjpeg.plugins import get_plugins +from pylibjpeg.utils import add_handler, remove_handler HAS_PLUGINS = get_plugins() != [] @@ -34,6 +37,41 @@ def test_pixel_array(self): with pytest.raises(RuntimeError, match=msg): ds.pixel_array + def test_get_pixeldata_no_syntax(self): + """Test exception raised if syntax not supported.""" + index = get_indexed_datasets('1.2.840.10008.1.2.4.50') + ds = index['JPEGBaseline_1s_1f_u_08_08.dcm']['ds'] + ds.file_meta.TransferSyntaxUID = '1.2.3.4' + msg = ( + r"Unable to convert the pixel data as the transfer syntax is not " + r"supported by the pylibjpeg pixel data handler" + ) + with pytest.raises(RuntimeError, match=msg): + handler.get_pixeldata(ds) + + def test_get_pixeldata_no_lj_syntax(self): + """Test exception raised if syntax not supported.""" + index = get_indexed_datasets('1.2.840.10008.1.2.4.50') + ds = index['JPEGBaseline_1s_1f_u_08_08.dcm']['ds'] + msg = ( + r"The libjpeg plugin is required to decode pixel data with a " + r"transfer syntax of '1.2.840.10008.1.2.4.50'" + ) + with pytest.raises(RuntimeError, match=msg): + handler.get_pixeldata(ds) + + def test_get_pixeldata_no_lj_syntax(self): + """Test exception raised if syntax not supported.""" + index = get_indexed_datasets('1.2.840.10008.1.2.4.50') + ds = index['JPEGBaseline_1s_1f_u_08_08.dcm']['ds'] + ds.file_meta.TransferSyntaxUID = '1.2.840.10008.1.2.4.90' + msg = ( + r"The openjpeg plugin is required to decode pixel data with a " + r"transfer syntax of '1.2.840.10008.1.2.4.90'" + ) + with pytest.raises(RuntimeError, match=msg): + handler.get_pixeldata(ds) + @pytest.mark.skipif(not HAS_PYDICOM or not HAS_PLUGINS, reason="No plugins") class TestPlugins(object): @@ -59,3 +97,35 @@ def test_pixel_array(self): assert 64 == arr[75, 50] assert 192 == arr[85, 50] assert 255 == arr[95, 50] + + def test_add_remove_handler(self): + """Test removing the pixel data handler.""" + assert handler in pydicom.config.pixel_data_handlers + remove_handler() + assert handler not in pydicom.config.pixel_data_handlers + add_handler() + assert handler in pydicom.config.pixel_data_handlers + + def test_should_change_PI(self): + """Pointless test.""" + result = handler.should_change_PhotometricInterpretation_to_RGB(None) + assert result is False + + def test_missing_required(self): + """Test missing required element raises.""" + index = get_indexed_datasets('1.2.840.10008.1.2.4.50') + ds = index['JPEGBaseline_1s_1f_u_08_08.dcm']['ds'] + del ds.SamplesPerPixel + msg = ( + r"Unable to convert the pixel data as the following required " + r"elements are missing from the dataset: SamplesPerPixel" + ) + with pytest.raises(AttributeError, match=msg): + ds.pixel_array + + def test_ybr_full_422(self): + """Test YBR_FULL_422 data decoded.""" + index = get_indexed_datasets('1.2.840.10008.1.2.4.50') + ds = index['SC_rgb_dcmtk_+eb+cy+np.dcm']['ds'] + assert 'YBR_FULL_422' == ds.PhotometricInterpretation + arr = ds.pixel_array From 0ef30893aa76bcc46b2a3a333eda478831c1b345 Mon Sep 17 00:00:00 2001 From: scaramallion Date: Thu, 12 Mar 2020 20:52:38 +1100 Subject: [PATCH 4/7] Add frame generator and tests --- pylibjpeg/__init__.py | 11 +-- pylibjpeg/plugins.py | 5 +- pylibjpeg/pydicom/utils.py | 143 ++++++++++++++++++++++++++++++++ pylibjpeg/tests/__init__.py | 14 ++++ pylibjpeg/tests/test_pydicom.py | 59 ++++++++++++- 5 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 pylibjpeg/pydicom/utils.py diff --git a/pylibjpeg/__init__.py b/pylibjpeg/__init__.py index b521d73..940a100 100644 --- a/pylibjpeg/__init__.py +++ b/pylibjpeg/__init__.py @@ -27,17 +27,10 @@ def debug_logger(): logger.addHandler(handler) -try: - import data as _data - globals()['data'] = _data - # Add to cache - needed for pytest - sys.modules['pylibjpeg.data'] = _data - _logger.debug('pylibjpeg-data module loaded') -except ImportError: - pass - load_plugins(PLUGINS) + +# Must be after loading the plugins from .utils import add_handler try: diff --git a/pylibjpeg/plugins.py b/pylibjpeg/plugins.py index 9538be3..fab51f6 100644 --- a/pylibjpeg/plugins.py +++ b/pylibjpeg/plugins.py @@ -10,6 +10,9 @@ LOGGER = logging.getLogger(__name__) +# TODO: this module is a mess, refactor + + def load_plugins(plugins): """Load the `plugins` and add them to the namespace.""" for plugin in plugins: @@ -43,7 +46,7 @@ def get_plugin_coders(): return decoders, encoders -def get_plugins(as_objects=False): +def get_plugins(): """Return the available plugins. Returns diff --git a/pylibjpeg/pydicom/utils.py b/pylibjpeg/pydicom/utils.py new file mode 100644 index 0000000..05ce2af --- /dev/null +++ b/pylibjpeg/pydicom/utils.py @@ -0,0 +1,143 @@ +"""Utilities for DICOM pixel data""" + +from pydicom.encaps import generate_pixel_data_frame +from pydicom.pixel_data_handlers.util import pixel_dtype + +from pylibjpeg.plugins import get_uid_decoders + + +def generate_frames(ds): + """Yield decompressed pixel data frames as :class:`numpy.ndarray`. + + Parameters + ---------- + ds : pydicom.dataset.Dataset + The dataset containing the pixel data. + + Yields + ------ + numpy.ndarray + A single frame of the decompressed pixel data. + """ + decoders = get_uid_decoders() + decode = decoders[ds.file_meta.TransferSyntaxUID] + + p_interp = ds.PhotometricInterpretation + nr_frames = getattr(ds, 'NumberOfFrames', 1) + for frame in generate_pixel_data_frame(ds.PixelData, nr_frames): + arr = decode(frame, p_interp).view(pixel_dtype(ds)) + yield reshape_frame(ds, arr) + + +def reshape_frame(ds, arr): + """Return a reshaped :class:`numpy.ndarray` `arr`. + + +------------------------------------------+-----------+----------+ + | Element | Supported | | + +-------------+---------------------+------+ values | | + | Tag | Keyword | Type | | | + +=============+=====================+======+===========+==========+ + | (0028,0002) | SamplesPerPixel | 1 | N > 0 | Required | + +-------------+---------------------+------+-----------+----------+ + | (0028,0006) | PlanarConfiguration | 1C | 0, 1 | Optional | + +-------------+---------------------+------+-----------+----------+ + | (0028,0010) | Rows | 1 | N > 0 | Required | + +-------------+---------------------+------+-----------+----------+ + | (0028,0011) | Columns | 1 | N > 0 | Required | + +-------------+---------------------+------+-----------+----------+ + + (0028,0006) *Planar Configuration* is required when (0028,0002) *Samples + per Pixel* is greater than 1. For certain compressed transfer syntaxes it + is always taken to be either 0 or 1 as shown in the table below. + + +---------------------------------------------+-----------------------+ + | Transfer Syntax | Planar Configuration | + +------------------------+--------------------+ | + | UID | Name | | + +========================+====================+=======================+ + | 1.2.840.10008.1.2.4.50 | JPEG Baseline | 0 | + +------------------------+--------------------+-----------------------+ + | 1.2.840.10008.1.2.4.57 | JPEG Lossless, | 0 | + | | Non-hierarchical | | + +------------------------+--------------------+-----------------------+ + | 1.2.840.10008.1.2.4.70 | JPEG Lossless, | 0 | + | | Non-hierarchical, | | + | | SV1 | | + +------------------------+--------------------+-----------------------+ + | 1.2.840.10008.1.2.4.80 | JPEG-LS Lossless | 1 | + +------------------------+--------------------+-----------------------+ + | 1.2.840.10008.1.2.4.81 | JPEG-LS Lossy | 1 | + +------------------------+--------------------+-----------------------+ + | 1.2.840.10008.1.2.4.90 | JPEG 2000 Lossless | 0 | + +------------------------+--------------------+-----------------------+ + | 1.2.840.10008.1.2.4.91 | JPEG 2000 Lossy | 0 | + +------------------------+--------------------+-----------------------+ + + Parameters + ---------- + ds : dataset.Dataset + The :class:`~pydicom.dataset.Dataset` containing the Image Pixel module + corresponding to the data in `arr`. + arr : numpy.ndarray + The 1D array containing the pixel data. + + Returns + ------- + numpy.ndarray + A reshaped array containing the pixel data. The shape of the array + depends on the contents of the dataset: + + * For single frame, single sample data (rows, columns) + * For single frame, multi-sample data (rows, columns, planes) + + References + ---------- + + * DICOM Standard, Part 3, + :dcm:`Annex C.7.6.3.1` + * DICOM Standard, Part 5, :dcm:`Section 8.2` + """ + # Transfer Syntax UIDs that are always Planar Configuration 0 + conf_zero = [ + '1.2.840.10008.1.2.4.50', + '1.2.840.10008.1.2.4.57', + '1.2.840.10008.1.2.4.70', + '1.2.840.10008.1.2.4.90', + '1.2.840.10008.1.2.4.91' + ] + # Transfer Syntax UIDs that are always Planar Configuration 1 + conf_one = [ + '1.2.840.10008.1.2.4.80', + '1.2.840.10008.1.2.4.81', + ] + + # Valid values for Planar Configuration are dependent on transfer syntax + nr_samples = ds.SamplesPerPixel + if nr_samples > 1: + transfer_syntax = ds.file_meta.TransferSyntaxUID + if transfer_syntax in conf_zero: + planar_configuration = 0 + elif transfer_syntax in conf_one: + planar_configuration = 1 + else: + planar_configuration = ds.PlanarConfiguration + + if planar_configuration not in [0, 1]: + raise ValueError( + "Unable to reshape the pixel array as a value of {} for " + "(0028,0006) 'Planar Configuration' is invalid." + .format(planar_configuration) + ) + + if nr_samples == 1: + # Single plane + arr = arr.reshape(ds.Rows, ds.Columns) # view + else: + # Multiple planes, usually 3 + if planar_configuration == 0: + arr = arr.reshape(ds.Rows, ds.Columns, nr_samples) # view + else: + arr = arr.reshape(nr_samples, ds.Rows, ds.Columns) + arr = arr.transpose(1, 2, 0) + + return arr diff --git a/pylibjpeg/tests/__init__.py b/pylibjpeg/tests/__init__.py index e69de29..8291960 100644 --- a/pylibjpeg/tests/__init__.py +++ b/pylibjpeg/tests/__init__.py @@ -0,0 +1,14 @@ + +import logging +import sys + +_logger = logging.getLogger(__name__) + +try: + import data as _data + globals()['data'] = _data + # Add to cache - needed for pytest + sys.modules['pylibjpeg.data'] = _data + _logger.debug('pylibjpeg-data module loaded') +except ImportError: + pass diff --git a/pylibjpeg/tests/test_pydicom.py b/pylibjpeg/tests/test_pydicom.py index d710f8a..7200a3a 100644 --- a/pylibjpeg/tests/test_pydicom.py +++ b/pylibjpeg/tests/test_pydicom.py @@ -10,8 +10,10 @@ import pydicom import pydicom.config from pylibjpeg.pydicom import pixel_data_handler as handler + from pylibjpeg.pydicom.utils import generate_frames, reshape_frame HAS_PYDICOM = True -except ImportError: +except ImportError as exc: + print(exc) HAS_PYDICOM = False from pylibjpeg.data import get_indexed_datasets @@ -129,3 +131,58 @@ def test_ybr_full_422(self): ds = index['SC_rgb_dcmtk_+eb+cy+np.dcm']['ds'] assert 'YBR_FULL_422' == ds.PhotometricInterpretation arr = ds.pixel_array + + +@pytest.mark.skipif(not HAS_PYDICOM or not HAS_PLUGINS, reason="No plugins") +class TestUtils(object): + """Test the pydicom.utils functions.""" + def test_generate_frames_single_1s(self): + """Test with single frame, 1 sample/px.""" + index = get_indexed_datasets('1.2.840.10008.1.2.4.50') + ds = index['JPEGBaseline_1s_1f_u_08_08.dcm']['ds'] + assert 1 == getattr(ds, 'NumberOfFrames', 1) + assert 1 == ds.SamplesPerPixel + frames = generate_frames(ds) + arr = next(frames) + with pytest.raises(StopIteration): + next(frames) + + assert arr.flags.writeable + assert 'uint8' == arr.dtype + assert (ds.Rows, ds.Columns) == arr.shape + assert 64 == arr[76, 22] + + def test_generate_frames_1s(self): + """Test with multiple frames, 1 sample/px.""" + index = get_indexed_datasets('1.2.840.10008.1.2.4.80') + ds = index['emri_small_jpeg_ls_lossless.dcm']['ds'] + assert ds.NumberOfFrames > 1 + assert 1 == ds.SamplesPerPixel + frames = generate_frames(ds) + arr = next(frames) + + assert arr.flags.writeable + assert 'uint16' == arr.dtype + assert (ds.Rows, ds.Columns) == arr.shape + assert 163 == arr[12, 23] + + def test_generate_frames_3s_0p(self): + """Test with multiple frames, 3 sample/px, 0 planar conf.""" + index = get_indexed_datasets('1.2.840.10008.1.2.4.50') + ds = index['color3d_jpeg_baseline.dcm']['ds'] + assert ds.NumberOfFrames > 1 + assert 3 == ds.SamplesPerPixel + assert 0 == ds.PlanarConfiguration + frames = generate_frames(ds) + arr = next(frames) + + assert arr.flags.writeable + assert 'uint8' == arr.dtype + assert (ds.Rows, ds.Columns, 3) == arr.shape + assert [48, 128, 128] == arr[159, 290, :].tolist() + + @pytest.mark.skip() + def test_generate_frames_3s_1p(self): + """Test 3 sample/px, 1 planar conf.""" + # No data + pass From d182c15521b0cb21b469a720a89e7fb17f8fa938 Mon Sep 17 00:00:00 2001 From: scaramallion Date: Thu, 12 Mar 2020 21:37:37 +1100 Subject: [PATCH 5/7] Update README --- README.md | 61 ++++++++++++++++++++++++++++++++++++------- pylibjpeg/__init__.py | 1 + pylibjpeg/utils.py | 2 +- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 87f84f8..c691557 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,37 @@ python -m pip install pylibjpeg ### Plugins By itself *pylibjpeg* is unable to decode any JPEG images, which is where the -plugins come in. To support a given JPEG format you first have to install -the corresponding package: +plugins come in. To support a given JPEG format or DICOM Transfer Syntax +you first have to install the corresponding package: -| JPEG Format | Decode | Encode | Plugin | Based on | +#### JPEG Format +| Format | Decode? | Encode? | Plugin | Based on | |---|------|---|---|---| -| JPEG, JPEG-LS and JPEG XT | Yes | No | [pylibjpeg-libjpeg](https://github.com/pydicom/pylibjpeg-libjpeg) | [libjpeg](https://github.com/thorfdbg/libjpeg) | -| JPEG 2000 | No | No | To be implemented | [openjpeg](https://github.com/uclouvain/openjpeg)| +| JPEG, JPEG-LS and JPEG XT | Yes | No | [pylibjpeg-libjpeg][1] | [libjpeg][2] | + +#### Transfer Syntax + +| UID | Description | Plugin | +|---|---|----| +| 1.2.840.10008.1.2.4.50 | JPEG Baseline (Process 1) | [pylibjpeg-libjpeg][1] | +| 1.2.840.10008.1.2.4.51 | JPEG Extended (Process 2 and 4) | [pylibjpeg-libjpeg][1]| +| 1.2.840.10008.1.2.4.57 | JPEG Lossless, Non-Hierarchical (Process 14) | [pylibjpeg-libjpeg][1]| +| 1.2.840.10008.1.2.4.70 | JPEG Lossless, Non-Hierarchical, First-Order Prediction
(Process 14, Selection Value 1) | [pylibjpeg-libjpeg][1]| +| 1.2.840.10008.1.2.4.80 | JPEG-LS Lossless | [pylibjpeg-libjpeg][1]| +| 1.2.840.10008.1.2.4.81 | JPEG-LS Lossy (Near-Lossless) Image Compression | [pylibjpeg-libjpeg][1]| +| 1.2.840.10008.1.2.4.90 | JPEG 2000 Image Compression (Lossless Only) | Not yet supported | +| 1.2.840.10008.1.2.4.91 | JPEG 2000 Image Compression | Not yet supported | + +If you're not sure what the Transfer Syntax UID is, it can be determined with: +```python +>>> from pydicom import dcmread +>>> ds = dcmread('path/to/dicom_file') +>>> ds.file_meta.TransferSyntaxUID.name +``` + +[1]: https://github.com/pydicom/pylibjpeg-libjpeg +[2]: https://github.com/thorfdbg/libjpeg + ### Usage #### With pydicom @@ -40,19 +64,38 @@ Assuming you already have *pydicom* installed: from pydicom import dcmread from pydicom.data import get_testdata_file -# With the pylibjpeg-libjpeg plugin +# With the pylibjpeg-libjpeg plugin installed import pylibjpeg ds = dcmread(get_testdata_file('JPEG-LL.dcm')) arr = ds.pixel_array ``` -#### Standalone +For datasets with multiple frames, each frame can be processed separately to save on memory usage: +```python +from pydicom import dcmread +from pydicom.data import get_testdata_file +from pylibjpeg import generate_frames + +ds = dcmread(get_testdata_file('color3d_jpeg_baseline.dcm')) +frames = generate_frames(ds) +arr = next(frames) +``` + +#### Standalone JPEG decoding +You can also just use *pylibjpeg* to decode JPEG images to a [numpy ndarray](https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.html), provided you have a suitable plugin installed: ```python from pylibjpeg import decode +# Can decode using the path to a JPG file as str or pathlike +arr = decode('filename.jpg') + +# Or a file-like... +with open('filename.jpg', 'rb') as f: + arr = decode(f) + +# Or bytes... with open('filename.jpg', 'rb') as f: - # Returns a numpy array - arr = decode(f.read()) + arr = decode(f.read()) ``` diff --git a/pylibjpeg/__init__.py b/pylibjpeg/__init__.py index 940a100..959421a 100644 --- a/pylibjpeg/__init__.py +++ b/pylibjpeg/__init__.py @@ -37,5 +37,6 @@ def debug_logger(): import pydicom add_handler() _logger.debug('pydicom module loaded') + from pylibjpeg.pydicom.utils import generate_frames except ImportError: pass diff --git a/pylibjpeg/utils.py b/pylibjpeg/utils.py index 6425618..b244480 100644 --- a/pylibjpeg/utils.py +++ b/pylibjpeg/utils.py @@ -25,7 +25,7 @@ def add_handler(): pydicom.config.pixel_data_handlers.append(handler) -def decode(data, kwargs=None, decoder=None): +def decode(data, decoder=None, kwargs=None): """Return the decoded JPEG image as a :class:`numpy.ndarray`. Parameters From e47fdc25c20f18536cbced65387ad522dcbd9040 Mon Sep 17 00:00:00 2001 From: scaramallion Date: Sat, 14 Mar 2020 13:13:57 +1100 Subject: [PATCH 6/7] Cleanup plugins utils --- pylibjpeg/plugins.py | 134 ++++------------- pylibjpeg/pydicom/pixel_data_handler.py | 7 +- pylibjpeg/pydicom/utils.py | 132 ++++++++++++++++- pylibjpeg/tests/test_plugins.py | 188 ++++++++++++++++-------- 4 files changed, 286 insertions(+), 175 deletions(-) diff --git a/pylibjpeg/plugins.py b/pylibjpeg/plugins.py index fab51f6..1fccbed 100644 --- a/pylibjpeg/plugins.py +++ b/pylibjpeg/plugins.py @@ -10,40 +10,28 @@ LOGGER = logging.getLogger(__name__) -# TODO: this module is a mess, refactor - - -def load_plugins(plugins): - """Load the `plugins` and add them to the namespace.""" - for plugin in plugins: +def get_decoders(): + """Return a :class:`dict` of {plugin: decoder func}.""" + decoders = {} + for name in get_plugins(): try: - LOGGER.debug("Importing {}".format(plugin)) - module = import_module(plugin) - except ImportError as exc: - LOGGER.debug("Failed to import {}".format(plugin)) - continue - - # Add successful imported modules to the namespace - globals()[plugin] = module - sys.modules['pylibjpeg.plugins.{}'.format(plugin)] = module + decoders[name] = getattr(globals()[name], 'decode') + except AttributeError: + pass + return decoders -def get_plugin_coders(): - """Return the available plugin decoders and encoders. - Returns - ------- - dict, dict - A ``dict`` containing the available (decoders, encoders) as - ``{plugin name : {UID : callable}}``. - """ - decoders = {} +def get_encoders(): + """Return a :class:`dict` of {plugin: encoder func}.""" encoders = {} for name in get_plugins(): - decoders[name] = getattr(globals()[name], 'DICOM_DECODERS') - encoders[name] = getattr(globals()[name], 'DICOM_ENCODERS') + try: + encoders[name] = getattr(globals()[name], 'encode') + except AttributeError: + pass - return decoders, encoders + return encoders def get_plugins(): @@ -57,86 +45,16 @@ def get_plugins(): return [nn for nn in PLUGINS if nn in globals()] -def get_transfer_syntaxes(decodable=False, encodable=False): - """Return a list of decodable or encodable *Transfer Syntax UIDs*. - - Parameters - ---------- - decodable : bool, optional - Return a list of decodable *Transfer Syntax UIDs*. - encodable : bool, optional - Return a list of encodable *Transfer Syntax UIDs*. - - Returns - ------- - list of str - A list containing unique *Transfer Syntax UIDs*. - """ - if not decodable and not encodable: - raise ValueError("Either 'decodable' or 'encodable' must be True") - - dec, enc = get_plugin_coders() - if decodable: - obj = dec - else: - obj = enc - - uids = [] - for name, uid_coder in obj.items(): - uids += uid_coder.keys() - - return list(set(uids)) - - -def get_decoder(uid): - """Return a callable function that can decode pixel data encoding using - the *Transfer Syntax UID* `uid`. - """ - decoders, _ = get_plugin_coders() - for name in decoders: - try: - return decoders[name][uid] - except KeyError: - pass - - msg = ( - "No decoder is available for the Transfer Syntax UID - '{}'" - .format(uid) - ) - raise NotImplementedError(msg) - - -def get_decoders(): - decoders = {} - for name in get_plugins(): - decoders[name] = getattr(globals()[name], 'decode') - - return decoders - - -def get_uid_decoders(): - uids = get_transfer_syntaxes(decodable=True) - decoders = {} - dec, _ = get_plugin_coders() - for name, uid_coder in dec.items(): - decoders.update(uid_coder) - - return decoders - - -def get_encoder(uid): - """Return a callable function that can encode pixel data using - the *Transfer Syntax UID* `uid`. - """ - _, encoders = get_plugin_coders() - for name in encoders: +def load_plugins(plugins): + """Load the `plugins` and add them to the namespace.""" + for plugin in plugins: try: - return encoders[name][uid] - except KeyError: - pass + LOGGER.debug("Importing {}".format(plugin)) + module = import_module(plugin) + except ImportError as exc: + LOGGER.debug("Failed to import {}".format(plugin)) + continue - msg = ( - "No encoder is available for the Transfer Syntax UID - '{}'" - .format(uid) - ) - raise NotImplementedError(msg) + # Add successful imported modules to the namespace + globals()[plugin] = module + sys.modules['pylibjpeg.plugins.{}'.format(plugin)] = module diff --git a/pylibjpeg/pydicom/pixel_data_handler.py b/pylibjpeg/pydicom/pixel_data_handler.py index 6bd5b72..207c03a 100644 --- a/pylibjpeg/pydicom/pixel_data_handler.py +++ b/pylibjpeg/pydicom/pixel_data_handler.py @@ -42,10 +42,12 @@ from pydicom.encaps import generate_pixel_data_frame from pydicom.pixel_data_handlers.util import pixel_dtype, get_expected_length -from pylibjpeg.plugins import get_uid_decoders +from pylibjpeg.pydicom.utils import get_uid_decoder_dict + LOGGER = logging.getLogger(__name__) + try: import pylibjpeg.plugins.libjpeg HAVE_LIBJPEG = True @@ -74,7 +76,8 @@ ), } -_DECODERS = get_uid_decoders() + +_DECODERS = get_uid_decoder_dict() _LIBJPEG_SYNTAXES = [ '1.2.840.10008.1.2.4.50', '1.2.840.10008.1.2.4.51', diff --git a/pylibjpeg/pydicom/utils.py b/pylibjpeg/pydicom/utils.py index 05ce2af..8358d7c 100644 --- a/pylibjpeg/pydicom/utils.py +++ b/pylibjpeg/pydicom/utils.py @@ -1,9 +1,62 @@ """Utilities for DICOM pixel data""" -from pydicom.encaps import generate_pixel_data_frame -from pydicom.pixel_data_handlers.util import pixel_dtype +from pylibjpeg.plugins import * -from pylibjpeg.plugins import get_uid_decoders + +def decoder_from_uid(uid): + """Return a JPEG decoder for `uid`. + + Parameters + ---------- + uid : str or pydicom.uid.UID + The *Transfer Syntax UID* that the pixel data has been encoded with. + + Returns + ------- + callable + A callable function that can be used to decode the JPEG format + corresponding to `uid`. + """ + decoders = get_dicom_decoders() + for name in decoders: + try: + return decoders[name][uid] + except KeyError: + pass + + msg = ( + "No decoder is available for the Transfer Syntax UID - '{}'" + .format(uid) + ) + raise NotImplementedError(msg) + + +def encoder_from_uid(uid): + """Return a JPEG encoder for `uid`. + + Parameters + ---------- + uid : str or pydicom.uid.UID + The *Transfer Syntax UID* that the pixel data will be encoded with. + + Returns + ------- + callable + A callable function that can be used to encode to the JPEG format + corresponding to `uid`. + """ + encoders = get_dicom_encoders() + for name in encoders: + try: + return encoders[name][uid] + except KeyError: + pass + + msg = ( + "No encoder is available for the Transfer Syntax UID - '{}'" + .format(uid) + ) + raise NotImplementedError(msg) def generate_frames(ds): @@ -19,7 +72,10 @@ def generate_frames(ds): numpy.ndarray A single frame of the decompressed pixel data. """ - decoders = get_uid_decoders() + from pydicom.encaps import generate_pixel_data_frame + from pydicom.pixel_data_handlers.util import pixel_dtype + + decoders = get_uid_decoder_dict() decode = decoders[ds.file_meta.TransferSyntaxUID] p_interp = ds.PhotometricInterpretation @@ -29,6 +85,74 @@ def generate_frames(ds): yield reshape_frame(ds, arr) +def get_decodable_uids(): + """Return a list of decodable *Transfer Syntax UIDs*.""" + uids = [] + for name, uid_coder in get_dicom_decoders().items(): + uids += uid_coder.keys() + + return list(set(uids)) + + +def get_dicom_decoders(): + """Return available plugins with DICOM decoders. + + Returns + ------- + dict + A ``dict`` containing the available DICOM decoders as + ``{plugin name : {UID : callable}}``. + """ + decoders = { + k: getattr(globals()[k], 'DICOM_DECODERS', {}) for k in get_plugins() + } + + return decoders + + +def get_dicom_encoders(): + """Return available plugins with DICOM encoders. + + Returns + ------- + dict + A ``dict`` containing the available DICOM encoders as + ``{plugin name : {UID : callable}}``. + """ + encoders = { + k: getattr(globals()[k], 'DICOM_ENCODERS', {}) for k in get_plugins() + } + + return encoders + + +def get_encodable_uids(): + """Return a list of encodable *Transfer Syntax UIDs*.""" + uids = [] + for name, uid_coder in get_dicom_encoders().items(): + uids += uid_coder.keys() + + return list(set(uids)) + + +def get_uid_decoder_dict(): + """Return a :class:`dict` of {UID: decoder}.""" + decoder_dict = {} + for _, uid_decoder_dict in get_dicom_decoders().items(): + decoder_dict.update(uid_decoder_dict) + + return decoder_dict + + +def get_uid_encoder_dict(): + """Return a :class:`dict` of {UID: encoder}.""" + encoder_dict = {} + for _, uid_encoder_dict in get_dicom_encoders().items(): + encoder_dict.update(uid_encoder_dict) + + return encoder_dict + + def reshape_frame(ds, arr): """Return a reshaped :class:`numpy.ndarray` `arr`. diff --git a/pylibjpeg/tests/test_plugins.py b/pylibjpeg/tests/test_plugins.py index cc8c9f5..e5b3cbe 100644 --- a/pylibjpeg/tests/test_plugins.py +++ b/pylibjpeg/tests/test_plugins.py @@ -6,83 +6,147 @@ import pytest -from pylibjpeg.plugins import ( - get_plugin_coders, get_plugins, get_transfer_syntaxes, get_decoder, - get_encoder, get_uid_decoders +from pylibjpeg.plugins import get_plugins, get_decoders, get_encoders +from pylibjpeg.pydicom.utils import ( + decoder_from_uid, encoder_from_uid, get_decodable_uids, + get_dicom_decoders, get_dicom_encoders, get_encodable_uids, + get_uid_decoder_dict, get_uid_encoder_dict ) -# TODO: Switch this over to openjpeg +# TODO: Switch this over to openjpeg plugin try: import libjpeg HAS_LIBJPEG = True except ImportError: HAS_LIBJPEG = False + HAS_PLUGINS = get_plugins() != [] @pytest.mark.skipif(HAS_PLUGINS, reason="Plugins available") class TestNoPlugins(object): """Tests for plugins without any available.""" - def test_check_plugins(self): - """Test check_plugins().""" - dec, enc = get_plugin_coders() - assert {} == dec - assert {} == enc - def test_get_plugins(self): """Test get_plugins().""" assert [] == get_plugins() - def test_get_transfer_syntaxes(self): - """Test get_plugins().""" - msg = r"Either 'decodable' or 'encodable' must be True" - with pytest.raises(ValueError, match=msg): - get_transfer_syntaxes() + def test_get_decoders(self): + """Test get_decoders().""" + assert {} == get_decoders() - assert [] == get_transfer_syntaxes(decodable=True) - assert [] == get_transfer_syntaxes(encodable=True) + def test_get_encoders(self): + """Test get_encoders().""" + assert {} == get_encoders() - def test_get_decoder_raises(self): - """Test get_decoder().""" - msg = r"No decoder is available for the Transfer Syntax UID - '1.2.3'" + def test_decoder_from_uid(self): + """Test decoder_from_uid().""" + msg = ( + r"No decoder is available for the Transfer Syntax UID - '1.2.3.4'" + ) with pytest.raises(NotImplementedError, match=msg): - get_decoder('1.2.3') + decoder_from_uid('1.2.3.4') - def test_get_encoder_raises(self): - """Test get_encoder().""" - msg = r"No encoder is available for the Transfer Syntax UID - '1.2.3'" + def test_encoder_from_uid(self): + """Test encoder_from_uid().""" + msg = ( + r"No encoder is available for the Transfer Syntax UID - '1.2.3.4'" + ) with pytest.raises(NotImplementedError, match=msg): - get_encoder('1.2.3') + encoder_from_uid('1.2.3.4') + + def test_get_decodable_uids(self): + """Test get_decodable_uids().""" + assert [] == get_decodable_uids() + + def test_get_encodable_uids(self): + """Test get_encodable_uids().""" + assert [] == get_encodable_uids() - def test_get_uid_decoders(self): - """Test get_uid_decoders().""" - assert {} == get_uid_decoders() + def test_get_dicom_decoders(self): + """Test get_dicom_decoders().""" + assert {} == get_dicom_decoders() + + def test_get_dicom_encoders(self): + """Test get_dicom_encoders().""" + assert {} == get_dicom_encoders() + + def test_get_uid_decoder_dict(self): + """Test get_uid_decoder_dict().""" + assert {} == get_uid_decoder_dict() + + def test_get_uid_encoder_dict(self): + """Test get_uid_encoder_dict().""" + assert {} == get_uid_encoder_dict() @pytest.mark.skipif(not HAS_PLUGINS or not HAS_LIBJPEG, reason="No libjpeg") class TestPlugins(object): """Tests for plugins with a plugin available.""" - def test_check_plugins(self): - """Test check_plugins().""" - dec, enc = get_plugin_coders() - assert {} != dec - assert {} != enc - - def test_check_plugins_dec_only(self): - """Test check_plugins() with only a decoder plugin.""" - dec, enc = get_plugin_coders() - assert 'libjpeg' in enc - assert {} == enc['libjpeg'] + def test_get_plugins(self): + """Test get_plugins().""" + assert ['libjpeg'] == get_plugins() + + def test_get_decoders(self): + """Test get_decoders().""" + assert 'libjpeg' in get_decoders() + + @pytest.mark.skip() + def test_get_decoders_none(self): + """Test get_decoders() with no decoders.""" + assert {} == get_decoders() + + @pytest.mark.skip() + def test_get_decoders(self): + """Test get_decoders().""" + assert 'libjpeg' in get_encoders() + + def test_get_encoders_none(self): + """Test get_encoders().""" + assert {} == get_encoders() + + def test_decoder_from_uid(self): + """Test decoder_from_uid().""" + func = decoder_from_uid('1.2.840.10008.1.2.4.50') + assert func is not None + + @pytest.mark.skip() + def test_encoder_from_uid(self): + """Test encoder_from_uid().""" + msg = ( + r"No encoder is available for the Transfer Syntax UID - '1.2.3.4'" + ) + with pytest.raises(NotImplementedError, match=msg): + encoder_from_uid('1.2.3.4') + + def test_get_dicom_decoders(self): + """Test get_dicom_decoders() with only a decoder plugin.""" + dec = get_dicom_decoders() assert 'libjpeg' in dec assert '1.2.840.10008.1.2.4.50' in dec['libjpeg'] - def test_get_transfer_syntaxes(self): - """Test get_plugins().""" - msg = r"Either 'decodable' or 'encodable' must be True" - with pytest.raises(ValueError, match=msg): - get_transfer_syntaxes() + @pytest.mark.skip() + def test_get_dicom_decoders_none(self): + """Test get_dicom_decoders() with only a decoder plugin.""" + dec = get_dicom_decoders() + assert 'libjpeg' in dec + assert {} == dec['libjpeg'] + + @pytest.mark.skip() + def test_get_dicom_encoders(self): + """Test get_dicom_encoders() with only a decoder plugin.""" + enc = get_dicom_encoders() + assert 'libjpeg' in enc + assert '1.2.840.10008.1.2.4.50' in enc['libjpeg'] + def test_get_dicom_encoders_none(self): + """Test get_dicom_encoders() with only a decoder plugin.""" + enc = get_dicom_encoders() + assert 'libjpeg' in enc + assert {} == enc['libjpeg'] + + def test_get_decodable_uids(self): + """Test get_decodable_uids().""" reference = [ '1.2.840.10008.1.2.4.50', '1.2.840.10008.1.2.4.51', @@ -91,26 +155,20 @@ def test_get_transfer_syntaxes(self): '1.2.840.10008.1.2.4.80', '1.2.840.10008.1.2.4.81', ] - syntaxes = get_transfer_syntaxes(decodable=True) + syntaxes = get_decodable_uids() for uid in syntaxes: assert uid in reference - assert [] == get_transfer_syntaxes(encodable=True) - - def test_get_decoder(self): - """Test get_decoder().""" - decoder = get_decoder('1.2.840.10008.1.2.4.50') - - def test_get_decoder_missing(self): - """Test get_decoder() with no matching decoder.""" - msg = ( - r"No decoder is available for the Transfer Syntax UID - '1.2.3.4'" - ) - with pytest.raises(NotImplementedError, match=msg): - get_decoder('1.2.3.4') + @pytest.mark.skip() + def test_get_encodable_uids(self): + """Test get_encodable_uids().""" + reference = [] + syntaxes = get_encodable_uids() + for uid in syntaxes: + assert uid in reference - def test_get_uid_decoders(self): - """Test get_uid_decoders().""" + def test_get_uid_decoder_dict(self): + """Test get_uid_decoder_dict().""" reference = [ '1.2.840.10008.1.2.4.50', '1.2.840.10008.1.2.4.51', @@ -119,6 +177,14 @@ def test_get_uid_decoders(self): '1.2.840.10008.1.2.4.80', '1.2.840.10008.1.2.4.81', ] - decoders = get_uid_decoders() + decoders = get_uid_decoder_dict() for uid in reference: assert uid in decoders + + @pytest.mark.skip() + def test_get_uid_encoder_dict(self): + """Test get_uid_encoder_dict().""" + reference = [] + encoders = get_uid_encoder_dict() + for uid in reference: + assert uid in encoders From 08847d6a864bd537039cdf840f0ae75de21145e3 Mon Sep 17 00:00:00 2001 From: scaramallion Date: Sat, 14 Mar 2020 13:20:58 +1100 Subject: [PATCH 7/7] Update README --- README.md | 7 +++++-- pylibjpeg/pydicom/utils.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c691557..70f14d9 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ you first have to install the corresponding package: | 1.2.840.10008.1.2.4.90 | JPEG 2000 Image Compression (Lossless Only) | Not yet supported | | 1.2.840.10008.1.2.4.91 | JPEG 2000 Image Compression | Not yet supported | -If you're not sure what the Transfer Syntax UID is, it can be determined with: +If you're not sure what the dataset's *Transfer Syntax UID* is, it can be +determined with: ```python >>> from pydicom import dcmread >>> ds = dcmread('path/to/dicom_file') @@ -71,7 +72,9 @@ ds = dcmread(get_testdata_file('JPEG-LL.dcm')) arr = ds.pixel_array ``` -For datasets with multiple frames, each frame can be processed separately to save on memory usage: +For datasets with multiple frames you can reduce your memory usage by +processing each frame separately using the ``generate_frames()`` generator +function: ```python from pydicom import dcmread from pydicom.data import get_testdata_file diff --git a/pylibjpeg/pydicom/utils.py b/pylibjpeg/pydicom/utils.py index 8358d7c..6592e5c 100644 --- a/pylibjpeg/pydicom/utils.py +++ b/pylibjpeg/pydicom/utils.py @@ -1,4 +1,4 @@ -"""Utilities for DICOM pixel data""" +"""Utilities for pydicom and DICOM pixel data""" from pylibjpeg.plugins import *