diff --git a/.coveragerc b/.coveragerc index f62cb3b..5ab6c83 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,8 +1,4 @@ [run] omit = pylibjpeg/tests/* - pylibjpeg/scripts/* pylibjpeg/tools/* - pylibjpeg-data/* - pylibjpeg-libjpeg/* - pydicom/* diff --git a/.github/workflows/release-deploy.yml b/.github/workflows/release-deploy.yml index 6ad954b..c1cbe5a 100644 --- a/.github/workflows/release-deploy.yml +++ b/.github/workflows/release-deploy.yml @@ -35,7 +35,18 @@ jobs: path: ./dist - name: Publish package to PyPi - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.PYPI_PASSWORD }} + environment: + name: pypi + url: https://pypi.org/project/pylibjpeg/ + permissions: + id-token: write + + steps: + - name: Download the wheels + uses: actions/download-artifact@v4 + with: + path: dist/ + merge-multiple: true + + - name: Publish package to PyPi + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 10b4c67..3e5dd30 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ __pycache__ .pytest_cache *.egg-info build +env*/ # Docs build docs/_build/* @@ -45,15 +46,14 @@ doc/reference/generated/* # PyCharm IDE files *.idea* - - # jupyter notebooks *.ipynb .ipynb_checkpoints/* tests/test_pixel.py # mypy -pydicom/.mypy_cache/* +.mypy_cache/ +.ruff_cache/ # vscode .vscode/* diff --git a/README.md b/README.md index fba5b14..555bd18 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ -[![codecov](https://codecov.io/gh/pydicom/pylibjpeg/branch/master/graph/badge.svg)](https://codecov.io/gh/pydicom/pylibjpeg) -[![Build Status](https://github.com/pydicom/pylibjpeg/workflows/build/badge.svg)](https://github.com/pydicom/pylibjpeg/actions?query=workflow%3Abuild) -[![PyPI version](https://badge.fury.io/py/pylibjpeg.svg)](https://badge.fury.io/py/pylibjpeg) -[![Python versions](https://img.shields.io/pypi/pyversions/pylibjpeg.svg)](https://img.shields.io/pypi/pyversions/pylibjpeg.svg) +

+Build status +Test coverage +PyPI versions +Python versions +Code style: black +

## pylibjpeg -A Python 3.10+ framework for decoding JPEG images and decoding/encoding RLE datasets, with a focus on providing support for [pydicom](https://github.com/pydicom/pydicom). +A Python 3.8+ framework for decoding JPEG images and decoding/encoding RLE datasets, with a focus on providing support for [pydicom](https://github.com/pydicom/pydicom). ### Installation @@ -42,26 +45,29 @@ python -m pip install pylibjpeg 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 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] | +#### Supported Image Formats +|Format |Decode?|Encode?|Plugin | License |Based on | +|--- |------ |--- |--- |--- |--- | +|JPEG, JPEG-LS and JPEG XT|Yes |No |[pylibjpeg-libjpeg][1] | GPLv3 |[libjpeg][2] | +|JPEG 2000 |Yes |No |[pylibjpeg-openjpeg][3]| MIT |[openjpeg][4]| +|RLE Lossless (PackBits) |Yes |Yes |[pylibjpeg-rle][5] | MIT |- | + +#### Supported DICOM Transfer Syntaxes + +|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][3]| +|1.2.840.10008.1.2.4.91 |JPEG 2000 Image Compression |[pylibjpeg-openjpeg][3]| +|1.2.840.10008.1.2.4.201|High-Throughput JPEG 2000 Image Compression (Lossless Only) |[pylibjpeg-openjpeg][3]| +|1.2.840.10008.1.2.4.202|High-Throughput JPEG 2000 with RPCL Options Image Compression (Lossless Only) |[pylibjpeg-openjpeg][3]| +|1.2.840.10008.1.2.4.203|High-Throughput JPEG 2000 Image Compression |[pylibjpeg-openjpeg][3]| +|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: @@ -103,19 +109,6 @@ ds.decompress("pylibjpeg") rle_arr = ds.pixel_array ``` -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 -from pydicom.pixel_data_handlers.pylibjpeg_handler 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 diff --git a/codecov.yml b/codecov.yml index 1c7eec4..d556470 100644 --- a/codecov.yml +++ b/codecov.yml @@ -13,8 +13,4 @@ coverage: ignore: - "pylibjpeg/tests" - - "pylibjpeg/scripts" - "pylibjpeg/tools" - - "pylibjpeg-libjpeg" - - "pylibjpeg-data" - - "pydicom" diff --git a/docs/plugins.md b/docs/plugins.md index 2f0f2e8..b8d8ac3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -58,14 +58,21 @@ the requirements of the transfer syntax: ```python def my_pixel_data_decoder( - src: bytes, ds: Optional[pydicom.dataset.Dataset] = None, **kwargs: Any -) -> numpy.ndarray: + src: bytes, + ds: pydicom.dataset.Dataset | None = None, + version: int = 1, + **kwargs: Any, +) -> numpy.ndarray | bytearray: """Return the encoded *src* as an unshaped numpy ndarray of uint8. - .. versionchanged:: 1.3 + .. versionchanged: 1.3 Added requirement to return little-endian ordered data by default. + .. versionchanged: 2.0 + + Added `version` keyword argument and support for returning :class:`bytearray` + Parameters ---------- src : bytes @@ -73,6 +80,12 @@ def my_pixel_data_decoder( ds : pydicom.dataset.Dataset, optional A dataset containing the group ``0x0028`` elements corresponding to the *Pixel Data*. If not used then *kwargs* must be supplied. + version : int, optional + + * If ``1`` (default) then either supplying either `ds` or `kwargs` is + required and the return type is a :class:`~numpy.ndarray` + * If ``2`` then `ds` will be ignored, `kwargs` is required and the return + type is :class:`bytearray` kwargs : Dict[str, Any] A dict containing relevant image pixel module elements: @@ -94,8 +107,10 @@ def my_pixel_data_decoder( Returns ------- - numpy.ndarray - A 1-dimensional ndarray of 'uint8' containing the little-endian ordered decoded pixel data. + numpy.ndarray | bytearray + Either a 1-dimensional ndarray of 'uint8' or a bytearray containing the + little-endian ordered decoded pixel data, depending on the value of + `version`. """ # Decoding happens here ``` @@ -206,7 +221,7 @@ The pixel data encoding function will be passed two required parameters: The function should return the encoded pixel data as `bytes`. ```python -def my_pixel_data_encoder(src: bytes, **kwargs) -> bytes: +def my_pixel_data_encoder(src: bytes, **kwargs: Any) -> bytes: """Return `src` as encoded bytes. Parameters diff --git a/docs/release_notes/v2.0.0.rst b/docs/release_notes/v2.0.0.rst index a08a364..604accb 100644 --- a/docs/release_notes/v2.0.0.rst +++ b/docs/release_notes/v2.0.0.rst @@ -7,3 +7,5 @@ * Switched to a ``pyproject.toml`` based project * Removed ``pydicom`` module * Supported Python versions are 3.8, 3.9, 3.10, 3.11 and 3.12 +* Added type hints +* Add support for version 2 of the pixel data interface diff --git a/pylibjpeg/tests/__init__.py b/pylibjpeg/tests/__init__.py index 68b1485..4983c28 100644 --- a/pylibjpeg/tests/__init__.py +++ b/pylibjpeg/tests/__init__.py @@ -1,7 +1,7 @@ import logging import sys -_logger = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) try: import ljdata as _data @@ -9,6 +9,6 @@ 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 diff --git a/pylibjpeg/tests/test_decode.py b/pylibjpeg/tests/test_decode.py index e21805d..1a79504 100644 --- a/pylibjpeg/tests/test_decode.py +++ b/pylibjpeg/tests/test_decode.py @@ -1,6 +1,7 @@ """Tests for standalone decoding.""" from io import BytesIO +import logging import os from pathlib import Path @@ -8,7 +9,7 @@ from pylibjpeg import decode from pylibjpeg.data import JPEG_DIRECTORY -from pylibjpeg.utils import get_decoders +from pylibjpeg.utils import get_decoders, get_pixel_data_decoders HAS_DECODERS = bool(get_decoders()) @@ -25,7 +26,7 @@ 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"): + with pytest.raises(RuntimeError, match=r"No JPEG decoders are available"): decode(fpath) def test_decode_pathlike(self): @@ -33,14 +34,14 @@ def test_decode_pathlike(self): 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"): + with pytest.raises(RuntimeError, match=r"No JPEG 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" + msg = r"No JPEG decoders are available" with pytest.raises(RuntimeError, match=msg): decode(f) @@ -51,13 +52,51 @@ def test_decode_bytes(self): data = f.read() assert isinstance(data, bytes) - msg = r"No decoders are available" + msg = r"No JPEG decoders are available" with pytest.raises(RuntimeError, match=msg): decode(data) def test_unknown_decoder_type(self): """Test unknown decoder type.""" - assert not get_decoders(decoder_type="TEST") + msg = "No matching plugin entry point for 'foo'" + with pytest.raises(KeyError, match=msg): + get_decoders(decoder_type="foo") + + def test_get_decoders(self, caplog): + """Tests for get_decoders()""" + with caplog.at_level(logging.DEBUG, logger="pylibjpeg"): + get_decoders() + assert ( + "No plugins found for entry point 'pylibjpeg.jpeg_decoders'" + ) in caplog.text + + caplog.clear() + with caplog.at_level(logging.DEBUG, logger="pylibjpeg"): + assert get_decoders("JPEG-LS") == {} + assert ( + "No plugins found for entry point 'pylibjpeg.jpeg_ls_decoders'" + ) in caplog.text + + def test_get_decoders_raises(self): + """Test exception raised if invalid decoder type.""" + msg = "No matching plugin entry point for 'JPEG XX'" + with pytest.raises(KeyError, match=msg): + get_decoders("JPEG XX") + + def test_get_pixel_data_decoders(self, caplog): + """Tests for get_pixel_data_decoders()""" + with caplog.at_level(logging.DEBUG, logger="pylibjpeg"): + get_pixel_data_decoders() + assert ( + "No plugins found for entry point 'pylibjpeg.pixel_data_decoders'" + ) in caplog.text + + caplog.clear() + with caplog.at_level(logging.DEBUG, logger="pylibjpeg"): + get_pixel_data_decoders(version=2) + assert ( + "No plugins found for entry point 'pylibjpeg.pixel_data_decoders'" + ) in caplog.text @pytest.mark.skipif(not RUN_JPEG, reason="No JPEG decoders available") @@ -189,12 +228,17 @@ def test_decode_str(self): assert isinstance(fpath, str) decode(fpath) - def test_decode_pathlike(self): + def test_decode_pathlike(self, caplog): """Test passing a pathlike to decode.""" fpath = os.path.join(self.basedir, "693.j2k") p = Path(fpath) assert isinstance(p, os.PathLike) - decode(p) + with caplog.at_level(logging.DEBUG, logger="pylibjpeg"): + decode(p) + assert ( + "Found plugin(s) 'openjpeg' for entry point " + "'pylibjpeg.jpeg_2000_decoders'" + ) in caplog.text def test_decode_filelike(self): """Test passing a filelike to decode.""" @@ -224,9 +268,39 @@ def test_specify_decoder(self): fpath = os.path.join(self.basedir, "693.j2k") decode(fpath, decoder="openjpeg") - @pytest.mark.skipif("libjpeg" in get_decoders(), reason="Have libjpeg") + @pytest.mark.skipif(RUN_JPEGLS, reason="Have libjpeg") def test_specify_unknown_decoder(self): """Test specifying an unknown decoder.""" fpath = os.path.join(self.basedir, "693.j2k") with pytest.raises(ValueError, match=r"The 'libjpeg' decoder"): decode(fpath, decoder="libjpeg") + + def test_v1_get_pixel_data_decoders(self, caplog): + """Test version 1 of get_pixel_data_decoders()""" + with caplog.at_level(logging.DEBUG, logger="pylibjpeg"): + decoders = get_pixel_data_decoders() + + assert "1.2.840.10008.1.2.4.90" in decoders + assert callable(decoders["1.2.840.10008.1.2.4.90"]) + assert ( + "Found plugin(s) for entry point 'pylibjpeg.pixel_data_decoders'" + ) in caplog.text + assert ( + "Found plugin 'openjpeg' for UID '1.2.840.10008.1.2.4.90'" + ) in caplog.text + + def test_v2_get_pixel_data_decoders(self, caplog): + """Test version 2 of get_pixel_data_decoders()""" + with caplog.at_level(logging.DEBUG, logger="pylibjpeg"): + decoders = get_pixel_data_decoders(version=2) + assert "1.2.840.10008.1.2.4.90" in decoders + assert "openjpeg" in decoders["1.2.840.10008.1.2.4.90"] + for plugin in decoders["1.2.840.10008.1.2.4.90"]: + assert callable(decoders["1.2.840.10008.1.2.4.90"][plugin]) + assert ( + f"Found plugin '{plugin}' for UID '1.2.840.10008.1.2.4.90'" + ) in caplog.text + + assert ( + "Found plugin(s) for entry point 'pylibjpeg.pixel_data_decoders'" + ) in caplog.text diff --git a/pylibjpeg/tests/test_encode.py b/pylibjpeg/tests/test_encode.py new file mode 100644 index 0000000..feb1249 --- /dev/null +++ b/pylibjpeg/tests/test_encode.py @@ -0,0 +1,47 @@ +"""Tests for standalone encoding.""" + +import pytest + +from pylibjpeg.utils import get_encoders, _encode, get_pixel_data_encoders + + +HAS_ENCODERS = bool(get_encoders()) +HAS_PIXEL_DATA_ENCODERS = bool(get_pixel_data_encoders()) + + +@pytest.mark.skipif(HAS_ENCODERS, reason="Encoders available") +class TestNoEncoders: + """Test interactions with no encoders.""" + + def test_encode_raises(self): + """Test encode raises if no encoders available.""" + with pytest.raises(RuntimeError, match=r"No encoders are available"): + _encode(None) + + def test_get_encoders(self): + """Tests for get_encoders()""" + assert get_encoders() == {} + + msg = "No matching plugin entry point for 'foo'" + with pytest.raises(KeyError, match=msg): + get_encoders("foo") + + +@pytest.mark.skipif(not HAS_PIXEL_DATA_ENCODERS, reason="No encoders available") +class TestEncoders: + """Test get_pixel_data_encoders()""" + + def test_v1_get_pixel_data_encoders(self): + """Test version 1 of get_pixel_data_encoders()""" + encoders = get_pixel_data_encoders(version=1) + assert encoders != {} + for encoder in encoders: + assert callable(encoders[encoder]) + + def test_v2_get_pixel_data_encoders(self): + """Test version 2 of get_pixel_data_encoders()""" + encoders = get_pixel_data_encoders(version=2) + assert encoders != {} + for encoder in encoders: + for plugin in encoders[encoder]: + assert callable(encoders[encoder][plugin]) diff --git a/pylibjpeg/tests/test_misc.py b/pylibjpeg/tests/test_misc.py new file mode 100644 index 0000000..204e50c --- /dev/null +++ b/pylibjpeg/tests/test_misc.py @@ -0,0 +1,24 @@ +"""Tests for standalone decoding.""" + +import logging + +from pylibjpeg import debug_logger + + +def test_debug_logger(): + """Test __init__.debug_logger().""" + logger = logging.getLogger("pylibjpeg") + assert len(logger.handlers) == 1 + assert isinstance(logger.handlers[0], logging.NullHandler) + + debug_logger() + + assert len(logger.handlers) == 1 + assert isinstance(logger.handlers[0], logging.StreamHandler) + + debug_logger() + + assert len(logger.handlers) == 1 + assert isinstance(logger.handlers[0], logging.StreamHandler) + + logger.handlers = [] diff --git a/pylibjpeg/tools/jpegio.py b/pylibjpeg/tools/jpegio.py index f3f1360..f859732 100644 --- a/pylibjpeg/tools/jpegio.py +++ b/pylibjpeg/tools/jpegio.py @@ -1,6 +1,5 @@ import logging import os -from pathlib import Path from typing import BinaryIO, Union, cast from .s10918 import parse, JPEG diff --git a/pylibjpeg/utils.py b/pylibjpeg/utils.py index 00b7c43..747e670 100644 --- a/pylibjpeg/utils.py +++ b/pylibjpeg/utils.py @@ -1,9 +1,10 @@ +from enum import IntEnum +from importlib import metadata import logging import os +from pathlib import Path import sys - -from importlib import metadata -from typing import BinaryIO, Any, Protocol, Union, Dict +from typing import BinaryIO, Any, Protocol, Union, Dict, Tuple, cast import numpy as np @@ -46,12 +47,17 @@ def __call__(self, src: np.ndarray, **kwargs: Any) -> Union[bytes, bytearray]: } -def decode(data: DecodeSource, decoder: str = "", **kwargs: Any) -> np.ndarray: +class Version(IntEnum): + v1 = 1 + v2 = 2 + + +def decode(src: DecodeSource, decoder: str = "", **kwargs: Any) -> np.ndarray: """Return the decoded JPEG image as a :class:`numpy.ndarray`. Parameters ---------- - data : str, file-like, os.PathLike, or bytes + src : 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. @@ -74,115 +80,46 @@ def decode(data: DecodeSource, decoder: str = "", **kwargs: Any) -> np.ndarray: """ decoders = get_decoders() if not decoders: - raise RuntimeError("No decoders are available") + raise RuntimeError( + "No JPEG decoders are available - have you installed any plugins?" + ) - if isinstance(data, (str, os.PathLike)): - with open(str(data), "rb") as f: + if isinstance(src, (str, os.PathLike)): + path = Path(src).resolve(strict=True) + with path.open("rb") as f: data = f.read() - elif isinstance(data, bytes): - pass + elif isinstance(src, bytes): + data = src else: - # Try file-like - data = data.read() + # BinaryIO + data = src.read() if decoder: try: return decoders[decoder](data, **kwargs) except KeyError: - raise ValueError(f"The '{decoder}' decoder is not available") + raise ValueError( + f"The '{decoder}' decoder is not available - have you installed " + "the plugin?" + ) + except Exception as exc: + LOGGER.debug(f"Decoding with the {decoder} plugin failed") + LOGGER.exception(exc) for name, func in decoders.items(): try: return func(data, **kwargs) except Exception as exc: - LOGGER.debug(f"Decoding with {name} plugin failed") + LOGGER.debug(f"Decoding with the {name} plugin failed") LOGGER.exception(exc) # If we made it here then we were unable to decode the data - raise ValueError("Unable to decode the data") - - -def get_decoders(decoder_type: str = "") -> Dict[str, Decoder]: - """Return a :class:`dict` of JPEG decoders as {package: callable}. - - Parameters - ---------- - decoder_type : str, optional - The class of decoders to return, one of: - - * ``"JPEG"`` - ISO/IEC 10918 JPEG decoders - * ``"JPEG XT"`` - ISO/IEC 18477 JPEG decoders - * ``"JPEG-LS"`` - ISO/IEC 14495 JPEG decoders - * ``"JPEG 2000"`` - ISO/IEC 15444 JPEG decoders - * ``"JPEG XS"`` - ISO/IEC 21122 JPEG decoders - * ``"JPEG XL"`` - ISO/IEC 18181 JPEG decoders - - If no `decoder_type` is used then all available decoders will be - returned. - - Returns - ------- - dict - A dict of ``{'package_name': }``. - """ - # TODO: Python 3.10 remove - if sys.version_info[:2] < (3, 10): - # {"package name": [EntryPoint(), ...]} - ep = metadata.entry_points() - if not decoder_type: - decoders = {} - for entry_point in DECODER_ENTRY_POINTS.values(): - if entry_point in ep: - decoders.update({val.name: val.load() for val in ep[entry_point]}) - - return decoders - - try: - return { - val.name: val.load() - for val in ep[DECODER_ENTRY_POINTS[decoder_type]] - } - except KeyError: - return {} - - if not decoder_type: - decoders = {} - for entry_point in DECODER_ENTRY_POINTS.values(): - result = metadata.entry_points(group=entry_point) - decoders.update({val.name: val.load() for val in result}) - - return decoders - - try: - result = metadata.entry_points(group=DECODER_ENTRY_POINTS[decoder_type]) - return {val.name: val.load() for val in result} - except KeyError: - return {} - - -def get_pixel_data_decoders() -> Dict[str, Decoder]: - """Return a :class:`dict` of ``{UID: callable}``.""" - # TODO: Python 3.10 remove - if sys.version_info[:2] < (3, 10): - ep = metadata.entry_points() - if "pylibjpeg.pixel_data_decoders" in ep: - return { - val.name: val.load() - for val in ep["pylibjpeg.pixel_data_decoders"] - } - - return {} - - try: - return { - val.name: val.load() - for val in metadata.entry_points(group="pylibjpeg.pixel_data_decoders") - } - except KeyError: - return {} + raise ValueError("Unable to decode the data with the available plugins") -def _encode(arr: np.ndarray, encoder: str = "", **kwargs: Any) -> Union[bytes, bytearray]: +def _encode( + arr: np.ndarray, encoder: str = "", **kwargs: Any +) -> Union[bytes, bytearray]: """Return the encoded `arr` as a :class:`bytes`. .. versionadded:: 1.3.0 @@ -217,22 +154,55 @@ def _encode(arr: np.ndarray, encoder: str = "", **kwargs: Any) -> Union[bytes, b return encoders[encoder](arr, **kwargs) except KeyError: raise ValueError(f"The '{encoder}' encoder is not available") + except Exception as exc: + LOGGER.debug(f"Encoding with the {encoder} plugin failed") + LOGGER.exception(exc) for name, func in encoders.items(): try: return func(arr, **kwargs) except Exception as exc: - LOGGER.debug(f"Encoding with {name} plugin failed") + LOGGER.debug(f"Encoding with the {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_decoders(decoder_type: str = "") -> Dict[str, Decoder]: + """Return a :class:`dict` of JPEG decoders as {package: callable}. + + Parameters + ---------- + decoder_type : str, optional + The class of decoders to return, one of: + + * ``"JPEG"`` - ISO/IEC 10918 JPEG decoders + * ``"JPEG XT"`` - ISO/IEC 18477 JPEG decoders + * ``"JPEG-LS"`` - ISO/IEC 14495 JPEG decoders + * ``"JPEG 2000"`` - ISO/IEC 15444 JPEG decoders + * ``"JPEG XS"`` - ISO/IEC 21122 JPEG decoders + * ``"JPEG XL"`` - ISO/IEC 18181 JPEG decoders + + If no `decoder_type` is used then all available decoders will be + returned. + + Returns + ------- + dict + A dict of ``{'package_name': }``. + """ + decoders = cast( + Dict[str, Decoder], + _get_plugins(DECODER_ENTRY_POINTS, decoder_type), + ) + return decoders + + def get_encoders(encoder_type: str = "") -> Dict[str, Encoder]: """Return a :class:`dict` of JPEG encoders as {package: callable}. - .. versionadded:: 1.3.0 + .. versionadded:: 1.3 Parameters ---------- @@ -254,57 +224,235 @@ def get_encoders(encoder_type: str = "") -> Dict[str, Encoder]: dict A dict of ``{'package_name': }``. """ - # TODO: Python 3.10 remove - if sys.version_info[:2] < (3, 10): - ep = metadata.entry_points() - if not encoder_type: - encoders = {} - for entry_point in ENCODER_ENTRY_POINTS.values(): - if entry_point in ep: - encoders.update({val.name: val.load() for val in ep[entry_point]}) + encoders = cast( + Dict[str, Encoder], + _get_plugins(ENCODER_ENTRY_POINTS, encoder_type), + ) + return encoders - return encoders - if encoder_type in ep: - return {val.name: val.load() for val in ep[encoder_type]} +def _get_plugins( + entry_points: Dict[str, str], plugin_type: str +) -> Dict[str, Union[Decoder, Encoder]]: + """Return a :class:`dict` of JPEG encoders/decoders as {package: callable}. - return {} + Parameters + ---------- + entry_points : dict[str, str] + A dict matching `plugin_type` to an entry point. + plugin_type : str + The class of encoders/decoders to return, one of: + + * ``"JPEG"`` - for ISO/IEC 10918 JPEG + * ``"JPEG XT"`` - for ISO/IEC 18477 JPEG + * ``"JPEG-LS"`` - for ISO/IEC 14495 JPEG + * ``"JPEG 2000"`` - for ISO/IEC 15444 JPEG + * ``"JPEG XS"`` - for ISO/IEC 21122 JPEG + * ``"JPEG XL"`` - for ISO/IEC 18181 JPEG + + If no `plugin_type` is used then all available encoders/decoders will + be returned. + """ + plugins = {} - if not encoder_type: - encoders = {} - for entry_point in ENCODER_ENTRY_POINTS.values(): - result = metadata.entry_points(group=entry_point) - encoders.update({val.name: val.load() for val in result}) + # Python 3.8, 3.9 + if sys.version_info[:2] < (3, 10): + # {"package name": [EntryPoint(), ...]} + ep = metadata.entry_points() + if not plugin_type: + for entry_point in entry_points.values(): + eps = cast(Tuple[metadata.EntryPoint], ep.get(entry_point, tuple())) + if eps: + names = sorted(list(set([f"'{x.name}'" for x in eps]))) + LOGGER.debug( + f"Found plugin(s) {', '.join(names)} for entry point " + f"'{entry_point}'" + ) + plugins.update({val.name: val.load() for val in eps}) + else: + LOGGER.debug(f"No plugins found for entry point '{entry_point}'") + + return plugins - return encoders + try: + entry_point = entry_points[plugin_type] + except KeyError: + raise KeyError(f"No matching plugin entry point for '{plugin_type}'") + + eps = cast(Tuple[metadata.EntryPoint], ep.get(entry_point, tuple())) + if eps: + names = sorted(list(set([f"'{x.name}'" for x in eps]))) + LOGGER.debug(f"Found plugin(s) {names} for entry point '{entry_point}'") + else: + LOGGER.debug(f"No plugins found for entry point '{entry_point}'") + + return {val.name: val.load() for val in eps} + + # Python 3.10+ + if not plugin_type: + for entry_point in entry_points.values(): + eps = metadata.entry_points(group=entry_point) + if eps: + names = sorted(list(set([f"'{x.name}'" for x in eps]))) + LOGGER.debug( + f"Found plugin(s) {', '.join(names)} for entry point " + f"'{entry_point}'" + ) + plugins.update({val.name: val.load() for val in eps}) + else: + LOGGER.debug(f"No plugins found for entry point '{entry_point}'") + + return plugins try: - result = metadata.entry_points(group=ENCODER_ENTRY_POINTS[encoder_type]) - return {val.name: val.load() for val in result} + entry_point = entry_points[plugin_type] except KeyError: - return {} + raise KeyError(f"No matching plugin entry point for '{plugin_type}'") + + eps = metadata.entry_points(group=entry_point) + if eps: + names = list(set([f"'{x.name}'" for x in eps])) + LOGGER.debug(f"Found plugin(s) {names} for entry point '{entry_point}'") + else: + LOGGER.debug(f"No plugins found for entry point '{entry_point}'") + + return {val.name: val.load() for val in eps} + + +def get_pixel_data_decoders( + version: int = Version.v1, +) -> Union[Dict[str, Decoder], Dict[str, Dict[str, Decoder]]]: + """Return a :class:`dict` of ``{UID: callable}``. + .. versionchanged:: 2.0 -def get_pixel_data_encoders() -> Dict[str, Encoder]: + Added the `version` parameter to support returning multiple decoders + for the same UID. + + Parameters + ---------- + version : int, optional + If ``1`` (default) then return available decoding functions as + ``{UID: func}``, otherwise return the available decoding functions as + ``{UID: {plugin name: func}}``. + + Returns + ------- + dict[str, Decoder] | dict[str, dict[str, Decoder]] + A dict containing the available plugins as: + + * ``{UID: decoding function}`` for `version` ``1`` + * ``{UID: {plugin name: decoding function}}`` for `version` ``2`` + """ + + entry_point = "pylibjpeg.pixel_data_decoders" + decoders = cast( + Union[Dict[str, Decoder], Dict[str, Dict[str, Decoder]]], + _get_pixel_data_plugins(entry_point, version), + ) + return decoders + + +def get_pixel_data_encoders( + version: int = Version.v1, +) -> Union[Dict[str, Encoder], Dict[str, Dict[str, Encoder]]]: """Return a :class:`dict` of ``{UID: callable}``. .. versionadded:: 1.3.0 + + .. versionchanged:: 2.0 + + Added the `version` parameter to support returning multiple encoders + for the same UID. + + Parameters + ---------- + version : int, optional + If ``1`` (default) then return available encoding functions as + ``{UID: func}``, otherwise return the available encoding functions as + ``{UID: {plugin name: func}}``. + + Returns + ------- + dict[str, Decoder] | dict[str, dict[str, Decoder]] + A dict containing the available plugins as: + + * ``{UID: encoding function}`` for `version` ``1`` + * ``{UID: {plugin name: encoding function}}`` for `version` ``2`` """ - # TODO: Python 3.10 remove + entry_point = "pylibjpeg.pixel_data_encoders" + encoders = cast( + Union[Dict[str, Encoder], Dict[str, Dict[str, Encoder]]], + _get_pixel_data_plugins(entry_point, version), + ) + return encoders + + +def _get_pixel_data_plugins( + entry_point: str, + version: int, +) -> Union[ + Dict[str, Union[Decoder, Encoder]], Dict[str, Dict[str, Union[Decoder, Encoder]]] +]: + """Return the available functions for `entry_point`. + + Parameters + ---------- + entry_point : str + The name of the entry point of the plugins. + version : int + If ``1`` then return available functions as ``{UID: func}``, + otherwise return the available functions as ``{UID: {plugin name: func}}``. + + Returns + ------- + dict[str, Decoder] | dict[str, dict[str, Decoder]] + A dict containing the available plugins as: + + * ``{UID: function}`` for `version` ``1`` + * ``{UID: {plugin name: function}}`` for `version` ``2`` + """ + plugins = {} + + # Python 3.8, 3.9 if sys.version_info[:2] < (3, 10): - ep = metadata.entry_points() - if "pylibjpeg.pixel_data_encoders" in ep: - return { - val.name: val.load() - for val in ep["pylibjpeg.pixel_data_encoders"] - } + entry_points = metadata.entry_points() + if entry_point not in entry_points: + LOGGER.debug(f"No plugins found for entry point '{entry_point}'") + return {} + # dict[str, Tuple[EntryPoint]], may be multiple EntryPoints for same UID + eps = entry_points[entry_point] + LOGGER.debug(f"Found plugin(s) for entry point '{entry_point}'") + for ep in set(eps): + name = ep.value.split(":")[0] + LOGGER.debug(f" Found plugin '{name}' for UID '{ep.name}'") + if version == Version.v1: + # Return {UID: encode/decode function} + plugins[ep.name] = ep.load() + else: + # Return {UID: {plugin name: encode/decode function}} + uid_plugins = plugins.setdefault(ep.name, {}) + uid_plugins[name] = ep.load() + + return plugins + + # Python 3.10+ + eps = metadata.entry_points(group=entry_point) + if not eps: + LOGGER.debug(f"No plugins found for entry point '{entry_point}'") return {} - try: - return { - val.name: val.load() - for val in metadata.entry_points(group="pylibjpeg.pixel_data_encoders") - } - except KeyError: - return {} + LOGGER.debug(f"Found plugin(s) for entry point '{entry_point}'") + for ep in set(eps): + name = ep.value.split(":")[0] + LOGGER.debug(f" Found plugin '{name}' for UID '{ep.name}'") + if version == Version.v1: + # Return {UID: encode/decode function} + plugins[ep.name] = ep.load() + else: + # Return {UID: {plugin name: encode/decode function}} + uid_plugins = plugins.setdefault(ep.name, {}) + uid_plugins[name] = ep.load() + + return plugins diff --git a/pyproject.toml b/pyproject.toml index e00f55f..086ee89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,21 +24,21 @@ classifiers=[ ] dependencies = ["numpy"] description = """\ - A Python framework for decoding JPEG and decoding/encoding DICOM\ - RLE data, with a focus on supporting pydicom\ +A Python framework for decoding JPEG and decoding/encoding DICOM \ +RLE data, with a focus on supporting pydicom\ """ -keywords = ["dicom, pydicom, python, imaging jpg jpeg jpg-ls jpeg-ls jpeg2k jpeg2000 rle"] +keywords = ["dicom pydicom python imaging jpg jpeg jpg-ls jpeg-ls jpeg2k jpeg2000 rle"] license = {text = "MIT"} name = "pylibjpeg" readme = "README.md" requires-python = ">=3.8" -version = "2.0.0.dev0" +version = "2.0.0" [project.optional-dependencies] dev = [ - "black==23.12.1", - "mypy==1.8.0", + "black>=23.12", + "mypy>=1.8", "pytest", "pytest-cov", ]