From a539187f60b77419b5d5fbd0beb3a96057bff7d7 Mon Sep 17 00:00:00 2001 From: scaramallion Date: Sun, 2 May 2021 12:59:27 +1000 Subject: [PATCH] Add encoding interface for plugins (#62) --- README.md | 63 ++++++--- docs/plugins.md | 107 ++++++++++++++-- docs/{changes => release_notes}/v1.0.0.rst | 0 docs/{changes => release_notes}/v1.1.0.rst | 0 docs/{changes => release_notes}/v1.2.0.rst | 0 docs/release_notes/v1.3.0.rst | 9 ++ pylibjpeg/_version.py | 2 +- ...test_pydicom.py => test_decode_pydicom.py} | 0 pylibjpeg/tests/test_encode.py | 0 pylibjpeg/tests/test_encode_pydicom.py | 0 pylibjpeg/utils.py | 121 +++++++++++++++++- setup.py | 9 +- 12 files changed, 267 insertions(+), 44 deletions(-) rename docs/{changes => release_notes}/v1.0.0.rst (100%) rename docs/{changes => release_notes}/v1.1.0.rst (100%) rename docs/{changes => release_notes}/v1.2.0.rst (100%) create mode 100644 docs/release_notes/v1.3.0.rst rename pylibjpeg/tests/{test_pydicom.py => test_decode_pydicom.py} (100%) create mode 100644 pylibjpeg/tests/test_encode.py create mode 100644 pylibjpeg/tests/test_encode_pydicom.py diff --git a/README.md b/README.md index 97fa0de..9c38200 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## pylibjpeg -A Python 3.6+ framework for decoding JPEG images and RLE datasets, with a focus on providing support for [pydicom](https://github.com/pydicom/pydicom). +A Python 3.6+ framework for decoding JPEG images and decoding/encoding RLE datasets, with a focus on providing support for [pydicom](https://github.com/pydicom/pydicom). ### Installation @@ -25,29 +25,29 @@ python -m pip install pylibjpeg ### Plugins -One or more plugins are required before *pylibjpeg* is able to decode JPEG images or RLE datasets. To decode a given format or DICOM Transfer Syntax +One or more plugins are required before *pylibjpeg* is able to handle JPEG images or RLE datasets. To handle a given format or DICOM Transfer Syntax you first have to install the corresponding package: #### Supported Formats -| Format | Decode? | Encode? | Plugin | Based on | -|---|------|---|---|---| -| JPEG, JPEG-LS and JPEG XT | Yes | No | [pylibjpeg-libjpeg][1] | [libjpeg][2] | -| JPEG 2000 | Yes | No | [pylibjpeg-openjpeg][3] | [openjpeg][4] | -| RLE (PackBits) | Yes | No | [pylibjpeg-rle][5] | - | +|Format |Decode?|Encode?|Plugin |Based on | +|--- |------ |--- |--- |--- | +|JPEG, JPEG-LS and JPEG XT|Yes |No |[pylibjpeg-libjpeg][1] |[libjpeg][2] | +|JPEG 2000 |Yes |No |[pylibjpeg-openjpeg][3]|[openjpeg][4]| +|RLE Lossless (PackBits) |Yes |Yes |[pylibjpeg-rle][5] |- | #### DICOM 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) | [pylibjpeg-openjpeg][4] | -| 1.2.840.10008.1.2.4.91 | JPEG 2000 Image Compression | [pylibjpeg-openjpeg][4] | -| 1.2.840.10008.1.2.5 | RLE Lossless | [pylibjpeg-rle][5] | +|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) |[pylibjpeg-openjpeg][4]| +|1.2.840.10008.1.2.4.91|JPEG 2000 Image Compression |[pylibjpeg-openjpeg][4]| +|1.2.840.10008.1.2.5 |RLE Lossless |[pylibjpeg-rle][5] | If you're not sure what the dataset's *Transfer Syntax UID* is, it can be determined with: @@ -65,8 +65,9 @@ determined with: ### Usage -#### With pydicom -Assuming you already have *pydicom* v2.1+ and suitable plugins installed: +#### Decoding +##### With pydicom +Assuming you have *pydicom* v2.1+ and suitable plugins installed: ```python from pydicom import dcmread @@ -101,7 +102,7 @@ frames = generate_frames(ds) arr = next(frames) ``` -#### Standalone JPEG decoding +##### 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 @@ -117,3 +118,23 @@ with open('filename.jpg', 'rb') as f: with open('filename.jpg', 'rb') as f: arr = decode(f.read()) ``` + +#### Encoding +##### With pydicom + +Assuming you have *pydicom* v2.2+ and suitable plugins installed: + +```python +from pydicom import dcmread +from pydicom.data import get_testdata_file +from pydicom.uid import RLELossless + +ds = dcmread(get_testdata_file("CT_small.dcm")) + +# Encode in-place using RLE Lossless and update the dataset +# Updates the Pixel Data, Transfer Syntax UID and Planar Configuration +ds.compress(uid) + +# Save compressed +ds.save_as("CT_small_rle.dcm") +``` diff --git a/docs/plugins.md b/docs/plugins.md index ab4ee26..8ba4ced 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -24,25 +24,38 @@ setup( #### Decoder function signature -The pixel data decoding function will be passed two arguments; a single encoded -image frame as [bytes](https://docs.python.org/3/library/stdtypes.html#bytes) and a *pydicom* [Dataset](https://pydicom.github.io/pydicom/stable/reference/generated/pydicom.dataset.Dataset.html) object containing the (0028,eeee) elements corresponding to the pixel data. The function should return the decoded pixel data as a one-dimensional numpy [ndarray](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html) of `'uint8'`: +The pixel data decoding function will be passed two required parameters: + +* *src*: a single encoded image frame as [bytes](https://docs.python.org/3/library/stdtypes.html#bytes) +* *ds*: a *pydicom* [Dataset](https://pydicom.github.io/pydicom/stable/reference/generated/pydicom.dataset.Dataset.html) object containing the (0028,eeee) elements corresponding to the pixel data + +The function should return the decoded pixel data as a one-dimensional numpy [ndarray](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html) of little-endian ordered `'uint8'`, with the data ordered from left-to-right, top-to-bottom (i.e. the first byte corresponds to the upper left pixel and the last byte corresponds to the lower-right pixel) and a planar configuration that matches +the requirements of the transfer syntax: ```python -def my_pixel_data_decoder(data, ds): - """Return the encoded `data` as an unshaped numpy ndarray of uint8. +def my_pixel_data_decoder( + src: bytes, ds: pydicom.dataset.Dataset, **kwargs +) -> numpy.ndarray: + """Return the encoded `src` as an unshaped numpy ndarray of uint8. + + .. versionchanged:: 1.3 + + Added requirement to return little-endian ordered data by default. Parameters ---------- - data : bytes + src : bytes A single frame of the encoded *Pixel Data*. ds : pydicom.dataset.Dataset A dataset containing the group ``0x0028`` elements corresponding to the *Pixel Data*. + kwargs + Optional keyword parameters for the decoder. Returns ------- numpy.ndarray - A 1-dimensional ndarray of 'uint8' containing the decoded pixel data. + A 1-dimensional ndarray of 'uint8' containing the little-endian ordered decoded pixel data. """ # Decoding happens here ``` @@ -78,19 +91,18 @@ Possible entry points for JPEG decoding are: #### Decoder function signature -The JPEG decoding function will be passed the encoded JPEG *data* as -[bytes](https://docs.python.org/3/library/stdtypes.html#bytes) and a +The JPEG decoding function will be passed the encoded JPEG *data* as [bytes](https://docs.python.org/3/library/stdtypes.html#bytes) and a [dict](https://docs.python.org/3/library/stdtypes.html#dict) containing keyword arguments passed to the function. The function should return the decoded image data as a numpy [ndarray](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html) with a dtype and shape matching the image format and dimensions: ```python -def my_jpeg_decoder(data, **kwarg): - """Return the encoded JPEG `data` as an numpy ndarray. +def my_jpeg_decoder(src, **kwargs): + """Return the encoded JPEG `src` as an numpy ndarray. Parameters ---------- - data : bytes + src : bytes The encoded JPEG data. - kwarg + kwargs Keyword arguments passed to the decoder. Returns @@ -100,3 +112,74 @@ def my_jpeg_decoder(data, **kwarg): """ # Decoding happens here ``` + +### DICOM Pixel Data encoders +#### Encoder plugin registration + +Plugins that encode DICOM *Pixel Data* should register their encoding functions using the corresponding *Transfer Syntax UID* as the entry point name. For example, if the `my_plugin` plugin supported encoding *RLE Lossless* (1.2.840.10008.1.2.5) with the encoding function `encode_rle_lossless()` then it should include the following in its `setup.py`: + +```python +from setuptools import setup + +setup( + ..., + entry_points={ + "pylibjpeg.pixel_data_encoders": [ + "1.2.840.10008.1.2.5 = my_plugin:encode_rle_lossless", + ], + } +) +``` + +#### Encoder function signature + +The pixel data encoding function will be passed two required parameters: + +* *src*: a single unencoded image frame as `bytes`, with the data ordered from + left-to-right, top-to-bottom (i.e. the first byte corresponds to the upper + left pixel and the last byte corresponds to the lower-right pixel) and a + planar configuration of 0 if more than 1 sample per pixel is used +* *kwargs*: a dict with at least the following keys + + * `'transfer_syntax_uid': pydicom.uid.UID` - the intended + *Transfer Syntax UID* of the encoded data. + * `'byteorder': str` - the byte ordering used by *src*, `'<'` + for little-endian (the default), `'>'` for big-endian. + * `'rows': int` - the number of rows of pixels in the *src*. + * `'columns': int` - the number of columns of pixels in the + *src*. + * `'samples_per_pixel': int` - the number of samples used per + pixel, e.g. `1` for grayscale images or `3` for RGB. + * `'number_of_frames': int` - the number of image frames + contained in *src*. + * `'bits_allocated': int` - the number of bits used to contain + each pixel in *src*, should be 8, 16, 32 or 64. + * `'bits_stored': int` - the number of bits actually used by + each pixel in *src*, e.g. 12-bit pixel data (range 0 to 4095) will be + contained by 16-bits (range 0 to 65535). + * `'pixel_representation': int` - the type of data in *src*, + `0` for unsigned integers, `1` for 2's complement (signed) + integers. + * `'photometric_interpretation: str` - the intended colorspace + of the encoded data, such as `'YBR'`. + +The function should return the encoded pixel data as `bytes`. + +```python +def my_pixel_data_encoder(src: bytes, **kwargs) -> bytes: + """Return `src` as encoded bytes. + + Parameters + ---------- + src : bytes + A single frame of the encoded *Pixel Data*. + **kwargs + Required and optional parameters for the encoder. + + Returns + ------- + bytes + The encoded image data. + """ + # Encoding happens here +``` diff --git a/docs/changes/v1.0.0.rst b/docs/release_notes/v1.0.0.rst similarity index 100% rename from docs/changes/v1.0.0.rst rename to docs/release_notes/v1.0.0.rst diff --git a/docs/changes/v1.1.0.rst b/docs/release_notes/v1.1.0.rst similarity index 100% rename from docs/changes/v1.1.0.rst rename to docs/release_notes/v1.1.0.rst diff --git a/docs/changes/v1.2.0.rst b/docs/release_notes/v1.2.0.rst similarity index 100% rename from docs/changes/v1.2.0.rst rename to docs/release_notes/v1.2.0.rst diff --git a/docs/release_notes/v1.3.0.rst b/docs/release_notes/v1.3.0.rst new file mode 100644 index 0000000..4cb8e39 --- /dev/null +++ b/docs/release_notes/v1.3.0.rst @@ -0,0 +1,9 @@ +.. _v1.3.0: + +1.3.0 +===== + +* Added interface for encoding pixel data +* Added :func:`~pylibjpeg.utils.get_encoders` and :func:`~pylibjpeg.utils.get_pixel_data_encoders` +* Updated the requirements for plugins to be more explicit about the format of the + data sent to and received from decoding functions diff --git a/pylibjpeg/_version.py b/pylibjpeg/_version.py index 98c8432..0845555 100644 --- a/pylibjpeg/_version.py +++ b/pylibjpeg/_version.py @@ -3,7 +3,7 @@ import re -__version__ = '1.2.0' +__version__ = '1.3.0' VERSION_PATTERN = r""" diff --git a/pylibjpeg/tests/test_pydicom.py b/pylibjpeg/tests/test_decode_pydicom.py similarity index 100% rename from pylibjpeg/tests/test_pydicom.py rename to pylibjpeg/tests/test_decode_pydicom.py diff --git a/pylibjpeg/tests/test_encode.py b/pylibjpeg/tests/test_encode.py new file mode 100644 index 0000000..e69de29 diff --git a/pylibjpeg/tests/test_encode_pydicom.py b/pylibjpeg/tests/test_encode_pydicom.py new file mode 100644 index 0000000..e69de29 diff --git a/pylibjpeg/utils.py b/pylibjpeg/utils.py index e8e7f51..c2cf3fd 100644 --- a/pylibjpeg/utils.py +++ b/pylibjpeg/utils.py @@ -55,15 +55,13 @@ def decode(data, decoder=None, kwargs=None): try: return decoders[decoder](data, **kwargs) except KeyError: - raise ValueError( - "The '{}' decoder is not available".format(decoder) - ) + raise ValueError(f"The '{decoder}' decoder is not available") for name, decoder in decoders.items(): try: return decoder(data, **kwargs) except Exception as exc: - LOGGER.debug("Decoding with {} plugin failed".format(name)) + LOGGER.debug(f"Decoding with {name} plugin failed") LOGGER.exception(exc) # If we made it here then we were unable to decode the data @@ -116,12 +114,123 @@ def get_decoders(decoder_type=None): for val in iter_entry_points(entry_points[decoder_type]) } except KeyError: - raise ValueError("Unknown decoder_type '{}'".format(decoder_type)) + raise ValueError(f"Unknown decoder_type '{decoder_type}'") def get_pixel_data_decoders(): - """Return a :class:`dict` of {UID: callable}.""" + """Return a :class:`dict` of ``{UID: callable}``.""" return { val.name: val.load() for val in iter_entry_points('pylibjpeg.pixel_data_decoders') } + + +def _encode(arr, encoder=None, kwargs=None): + """Return the encoded `arr` as a :class:`bytes`. + + .. versionadded:: 1.3.0 + + Parameters + ---------- + data : numpy.ndarray + The image data to encode as a :class:`~numpy.ndarray`. + decoder : callable, optional + The plugin to use when encoding the data. If not used then all + available encoders will be tried. + kwargs : dict + A ``dict`` containing keyword parameters to pass to the encoder. + + Returns + ------- + bytes + The encoded image data. + + Raises + ------ + RuntimeError + If `encoder` is not ``None`` and the corresponding plugin is not + available. + """ + encoders = get_encoders() + if not encoders: + raise RuntimeError("No encoders are available") + + kwargs = kwargs or {} + + if encoder is not None: + try: + return encoders[encoder](data, **kwargs) + except KeyError: + raise ValueError(f"The '{encoder}' encoder is not available") + + for name, encoders in encoders.items(): + try: + return encoders(data, **kwargs) + except Exception as exc: + LOGGER.debug(f"Encoding with {name} plugin failed") + LOGGER.exception(exc) + + # If we made it here then we were unable to encode the data + raise ValueError("Unable to encode the data") + + +def get_encoders(encoder_type=None): + """Return a :class:`dict` of JPEG encoders as {package: callable}. + + .. versionadded:: 1.3.0 + + Parameters + ---------- + encoder_type : str, optional + The class of decoders to return, one of: + + * ``"JPEG"`` - ISO/IEC 10918 JPEG encoders + * ``"JPEG XT"`` - ISO/IEC 18477 JPEG encoders + * ``"JPEG-LS"`` - ISO/IEC 14495 JPEG encoders + * ``"JPEG 2000"`` - ISO/IEC 15444 JPEG encoders + * ``"JPEG XS"`` - ISO/IEC 21122 JPEG encoders + * ``"JPEG XL"`` - ISO/IEC 18181 JPEG encoders + + If no `encoder_type` is used then all available encoders will be + returned. + + Returns + ------- + dict + A dict of ``{'package_name': }``. + """ + entry_points = { + "JPEG" : "pylibjpeg.jpeg_encoders", + "JPEG XT" : "pylibjpeg.jpeg_xt_encoders", + "JPEG-LS" : "pylibjpeg.jpeg_ls_encoders", + "JPEG 2000" : "pylibjpeg.jpeg_2000_encoders", + "JPEG XR" : "pylibjpeg.jpeg_xr_encoders", + "JPEG XS" : "pylibjpeg.jpeg_xs_encoders", + "JPEG XL" : "pylibjpeg.jpeg_xl_encoders", + } + if encoder_type is None: + encoders = {} + for entry_point in entry_points.values(): + encoders.update({ + val.name: val.load() for val in iter_entry_points(entry_point) + }) + return encoders + + try: + return { + val.name: val.load() + for val in iter_entry_points(entry_points[encoder_type]) + } + except KeyError: + raise ValueError(f"Unknown encoder_type '{encoder_type}'") + + +def get_pixel_data_encoders(): + """Return a :class:`dict` of ``{UID: callable}``. + + .. versionadded:: 1.3.0 + """ + return { + val.name: val.load() + for val in iter_entry_points('pylibjpeg.pixel_data_encoders') + } diff --git a/setup.py b/setup.py index b72017d..5b4bb8f 100644 --- a/setup.py +++ b/setup.py @@ -15,8 +15,8 @@ setup( name = 'pylibjpeg', description = ( - "A Python framework for decoding JPEG files, with a focus on " - "supporting pydicom" + "A Python framework for decoding JPEG and decoding/encoding DICOM " + "RLE data, with a focus on supporting pydicom" ), long_description = long_description, long_description_content_type = 'text/markdown', @@ -27,7 +27,8 @@ license = "MIT", keywords = ( "dcm dicom pydicom python medicalimaging radiology radiotherapy " - "oncology imaging jpg jpeg jpg-ls jpeg-ls libjpeg pylibjpeg " + "oncology imaging jpg jpeg jpg-ls jpeg-ls jpeg2k jpeg2000 rle " + "libjpeg pylibjpeg " ), classifiers = [ "License :: OSI Approved :: MIT License", @@ -47,7 +48,7 @@ "Topic :: Software Development :: Libraries", ], packages = find_packages(), - install_requires = ['numpy', 'pylibjpeg-openjpeg'], + install_requires = ['numpy', 'pylibjpeg-openjpeg', 'pylibjpeg-rle'], include_package_data = True, zip_safe = False, python_requires = ">=3.6",