diff --git a/LICENCE.txt b/LICENCE.txt index f6a83eb..b631719 100644 --- a/LICENCE.txt +++ b/LICENCE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 scaramallion and pylibjpeg contributors +Copyright (c) 2020-2024 scaramallion and pylibjpeg contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7f6558b..fba5b14 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## pylibjpeg -A Python 3.7+ 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.10+ framework for decoding JPEG images and decoding/encoding RLE datasets, with a focus on providing support for [pydicom](https://github.com/pydicom/pydicom). ### Installation diff --git a/pylibjpeg/__init__.py b/pylibjpeg/__init__.py index 647568c..d7cc8b0 100644 --- a/pylibjpeg/__init__.py +++ b/pylibjpeg/__init__.py @@ -3,19 +3,18 @@ import logging from pylibjpeg._version import __version__ -from pylibjpeg.pydicom.utils import generate_frames # deprecated -from pylibjpeg.utils import decode +from pylibjpeg.utils import decode # noqa: F401 # Setup default logging -_logger = logging.getLogger("pylibjpeg") +_logger = logging.getLogger(__name__) _logger.addHandler(logging.NullHandler()) -_logger.debug("pylibjpeg v{}".format(__version__)) +_logger.debug(f"pylibjpeg v{__version__}") -def debug_logger(): +def debug_logger() -> None: """Setup the logging for debugging.""" - logger = logging.getLogger("pylibjpeg") + logger = logging.getLogger(__name__) logger.handlers = [] handler = logging.StreamHandler() logger.setLevel(logging.DEBUG) diff --git a/pylibjpeg/_version.py b/pylibjpeg/_version.py index 2f5899a..8e528c1 100644 --- a/pylibjpeg/_version.py +++ b/pylibjpeg/_version.py @@ -1,55 +1,5 @@ """Version information for pylibjpeg based on PEP396 and 440.""" -import re +from importlib.metadata import version - -__version__ = "1.4.0" - - -VERSION_PATTERN = r""" - v? - (?: - (?:(?P[0-9]+)!)? # epoch - (?P[0-9]+(?:\.[0-9]+)*) # release segment - (?P
                                          # pre-release
-            [-_\.]?
-            (?P(a|b|c|rc|alpha|beta|pre|preview))
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-        (?P                                         # post release
-            (?:-(?P[0-9]+))
-            |
-            (?:
-                [-_\.]?
-                (?Ppost|rev|r)
-                [-_\.]?
-                (?P[0-9]+)?
-            )
-        )?
-        (?P                                          # dev release
-            [-_\.]?
-            (?Pdev)
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-    )
-    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
-"""
-
-
-def is_canonical(version):
-    """Return True if `version` is a PEP440 conformant version."""
-    match = re.match(
-        r"^([1-9]\d*!)?(0|[1-9]\d*)"
-        r"(\.(0|[1-9]\d*))"
-        r"*((a|b|rc)(0|[1-9]\d*))"
-        r"?(\.post(0|[1-9]\d*))"
-        r"?(\.dev(0|[1-9]\d*))?$",
-        version,
-    )
-
-    return match is not None
-
-
-assert is_canonical(__version__)
+__version__: str = version("pylibjpeg")
diff --git a/pylibjpeg/pydicom/__init__.py b/pylibjpeg/py.typed
similarity index 100%
rename from pylibjpeg/pydicom/__init__.py
rename to pylibjpeg/py.typed
diff --git a/pylibjpeg/pydicom/utils.py b/pylibjpeg/pydicom/utils.py
deleted file mode 100644
index df625cf..0000000
--- a/pylibjpeg/pydicom/utils.py
+++ /dev/null
@@ -1,209 +0,0 @@
-"""Utilities for pydicom and DICOM pixel data
-
-.. deprecated:: 1.2
-
-    Use pydicom's pylibjpeg pixel data handler instead.
-"""
-
-from pylibjpeg.utils import get_pixel_data_decoders
-
-
-def generate_frames(ds):
-    """Yield decompressed pixel data frames as :class:`numpy.ndarray`.
-
-    .. deprecated:: 1.2
-
-        Use
-        :func:`~pydicom.pixel_data_handlers.pylibjpeg_handler.generate_frames`
-        instead
-
-    Parameters
-    ----------
-    ds : pydicom.dataset.Dataset
-        The dataset containing the pixel data.
-
-    Yields
-    ------
-    numpy.ndarray
-        A single frame of the decompressed pixel data.
-    """
-    try:
-        import pydicom
-    except ImportError:
-        raise RuntimeError("'generate_frames' requires the pydicom package")
-
-    from pydicom.encaps import generate_pixel_data_frame
-    from pydicom.pixel_data_handlers.util import pixel_dtype
-
-    decoders = get_pixel_data_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, ds.group_dataset(0x0028)).view(pixel_dtype(ds))
-        yield reshape_frame(ds, arr)
-
-
-def reshape_frame(ds, arr):
-    """Return a reshaped :class:`numpy.ndarray` `arr`.
-
-    .. deprecated:: 1.2
-
-        Use pydicom instead.
-
-    +------------------------------------------+-----------+----------+
-    | 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
-
-
-def get_j2k_parameters(codestream):
-    """Return some of the JPEG 2000 component sample's parameters in `stream`.
-
-    .. deprecated:: 1.2
-
-        Use :func:`~pydicom.pixel_data_handlers.utils.get_j2k_parameters`
-        instead
-
-    Parameters
-    ----------
-    codestream : bytes
-        The JPEG 2000 (ISO/IEC 15444-1) codestream data to be parsed.
-
-    Returns
-    -------
-    dict
-        A dict containing the JPEG 2000 parameters for the first component
-        sample, will be empty if `codestream` doesn't contain JPEG 2000 data or
-        if unable to parse the data.
-    """
-    try:
-        # First 2 bytes must be the SOC marker - if not then wrong format
-        if codestream[0:2] != b"\xff\x4f":
-            return {}
-
-        # SIZ is required to be the second marker - Figure A-3 in 15444-1
-        if codestream[2:4] != b"\xff\x51":
-            return {}
-
-        # See 15444-1 A.5.1 for format of the SIZ box and contents
-        ssiz = ord(codestream[42:43])
-        parameters = {}
-        if ssiz & 0x80:
-            parameters["precision"] = (ssiz & 0x7F) + 1
-            parameters["is_signed"] = True
-        else:
-            parameters["precision"] = ssiz + 1
-            parameters["is_signed"] = False
-
-        return parameters
-
-    except (IndexError, TypeError):
-        return {}
diff --git a/pylibjpeg/tests/test_decode.py b/pylibjpeg/tests/test_decode.py
index 089fbb2..64be9c7 100644
--- a/pylibjpeg/tests/test_decode.py
+++ b/pylibjpeg/tests/test_decode.py
@@ -3,8 +3,6 @@
 from io import BytesIO
 import os
 from pathlib import Path
-import platform
-import sys
 
 import pytest
 
@@ -20,7 +18,7 @@
 
 
 @pytest.mark.skipif(HAS_DECODERS, reason="Decoders available")
-class TestNoDecoders(object):
+class TestNoDecoders:
     """Test interactions with no decoders."""
 
     def test_decode_str(self):
@@ -64,21 +62,21 @@ def test_unknown_decoder_type(self):
 
 
 @pytest.mark.skipif(not RUN_JPEG, reason="No JPEG decoders available")
-class TestJPEGDecoders(object):
+class TestJPEGDecoders:
     """Test decoding."""
 
     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)
+        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)
+        decode(p)
 
     def test_decode_filelike(self):
         """Test passing a filelike to decode."""
@@ -86,13 +84,13 @@ def test_decode_filelike(self):
 
         fpath = os.path.join(JPEG_DIRECTORY, "10918", "p1", "A1.JPG")
         with open(fpath, "rb") as f:
-            arr = decode(f)
+            decode(f)
 
         with open(fpath, "rb") as f:
             bs.write(f.read())
 
         bs.seek(0)
-        arr = decode(bs)
+        decode(bs)
 
     def test_decode_bytes(self):
         """Test passing bytes to decode."""
@@ -101,7 +99,7 @@ def test_decode_bytes(self):
             data = f.read()
 
         assert isinstance(data, bytes)
-        arr = decode(data)
+        decode(data)
 
     def test_decode_failure(self):
         """Test failure to decode."""
@@ -112,7 +110,7 @@ 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")
+        decode(fpath, decoder="libjpeg")
 
     @pytest.mark.skipif("openjpeg" in get_decoders(), reason="Have openjpeg")
     def test_specify_unknown_decoder(self):
@@ -124,24 +122,24 @@ def test_specify_unknown_decoder(self):
 
 
 @pytest.mark.skipif(not RUN_JPEGLS, reason="No JPEG-LS decoders available")
-class TestJPEGLSDecoders(object):
+class TestJPEGLSDecoders:
     """Test decoding JPEG-LS files."""
 
-    def setup(self):
+    def setup_method(self):
         self.basedir = os.path.join(JPEG_DIRECTORY, "14495", "JLS")
 
     def test_decode_str(self):
         """Test passing a str to decode."""
         fpath = os.path.join(self.basedir, "T8C0E0.JLS")
         assert isinstance(fpath, str)
-        arr = decode(fpath)
+        decode(fpath)
 
     def test_decode_pathlike(self):
         """Test passing a pathlike to decode."""
         fpath = os.path.join(self.basedir, "T8C0E0.JLS")
         p = Path(fpath)
         assert isinstance(p, os.PathLike)
-        arr = decode(p)
+        decode(p)
 
     def test_decode_filelike(self):
         """Test passing a filelike to decode."""
@@ -149,13 +147,13 @@ def test_decode_filelike(self):
 
         fpath = os.path.join(self.basedir, "T8C0E0.JLS")
         with open(fpath, "rb") as f:
-            arr = decode(f)
+            decode(f)
 
         with open(fpath, "rb") as f:
             bs.write(f.read())
 
         bs.seek(0)
-        arr = decode(bs)
+        decode(bs)
 
     def test_decode_bytes(self):
         """Test passing bytes to decode."""
@@ -164,12 +162,12 @@ def test_decode_bytes(self):
             data = f.read()
 
         assert isinstance(data, bytes)
-        arr = decode(data)
+        decode(data)
 
     def test_specify_decoder(self):
         """Test specifying the decoder."""
         fpath = os.path.join(self.basedir, "T8C0E0.JLS")
-        arr = decode(fpath, decoder="libjpeg")
+        decode(fpath, decoder="libjpeg")
 
     @pytest.mark.skipif("openjpeg" in get_decoders(), reason="Have openjpeg")
     def test_specify_unknown_decoder(self):
@@ -180,24 +178,24 @@ def test_specify_unknown_decoder(self):
 
 
 @pytest.mark.skipif(not RUN_JPEG2K, reason="No JPEG 2000 decoders available")
-class TestJPEG2KDecoders(object):
+class TestJPEG2KDecoders:
     """Test decoding JPEG 2000 files."""
 
-    def setup(self):
+    def setup_method(self):
         self.basedir = os.path.join(JPEG_DIRECTORY, "15444", "2KLS")
 
     def test_decode_str(self):
         """Test passing a str to decode."""
         fpath = os.path.join(self.basedir, "693.j2k")
         assert isinstance(fpath, str)
-        arr = decode(fpath)
+        decode(fpath)
 
     def test_decode_pathlike(self):
         """Test passing a pathlike to decode."""
         fpath = os.path.join(self.basedir, "693.j2k")
         p = Path(fpath)
         assert isinstance(p, os.PathLike)
-        arr = decode(p)
+        decode(p)
 
     def test_decode_filelike(self):
         """Test passing a filelike to decode."""
@@ -205,13 +203,13 @@ def test_decode_filelike(self):
 
         fpath = os.path.join(self.basedir, "693.j2k")
         with open(fpath, "rb") as f:
-            arr = decode(f)
+            decode(f)
 
         with open(fpath, "rb") as f:
             bs.write(f.read())
 
         bs.seek(0)
-        arr = decode(bs)
+        decode(bs)
 
     def test_decode_bytes(self):
         """Test passing bytes to decode."""
@@ -220,12 +218,12 @@ def test_decode_bytes(self):
             data = f.read()
 
         assert isinstance(data, bytes)
-        arr = decode(data)
+        decode(data)
 
     def test_specify_decoder(self):
         """Test specifying the decoder."""
         fpath = os.path.join(self.basedir, "693.j2k")
-        arr = decode(fpath, decoder="openjpeg")
+        decode(fpath, decoder="openjpeg")
 
     @pytest.mark.skipif("libjpeg" in get_decoders(), reason="Have libjpeg")
     def test_specify_unknown_decoder(self):
diff --git a/pylibjpeg/tests/test_decode_pydicom.py b/pylibjpeg/tests/test_decode_pydicom.py
deleted file mode 100644
index 2206713..0000000
--- a/pylibjpeg/tests/test_decode_pydicom.py
+++ /dev/null
@@ -1,300 +0,0 @@
-"""Tests for interacting with pydicom."""
-
-import os
-import platform
-import sys
-
-import pytest
-
-try:
-    import pydicom
-    import pydicom.config
-
-    HAS_PYDICOM = True
-except ImportError as exc:
-    HAS_PYDICOM = False
-
-from pylibjpeg.data import get_indexed_datasets
-from pylibjpeg.pydicom.utils import get_j2k_parameters, generate_frames
-from pylibjpeg.utils import get_pixel_data_decoders
-
-
-decoders = get_pixel_data_decoders()
-HAS_PLUGINS = bool(decoders)
-HAS_JPEG_PLUGIN = "1.2.840.10008.1.2.4.50" in decoders
-HAS_JPEG_LS_PLUGIN = "1.2.840.10008.1.2.4.80" in decoders
-HAS_JPEG_2K_PLUGIN = "1.2.840.10008.1.2.4.90" in decoders
-
-RUN_JPEG = HAS_JPEG_PLUGIN and HAS_PYDICOM
-RUN_JPEGLS = HAS_JPEG_LS_PLUGIN and HAS_PYDICOM
-RUN_JPEG2K = HAS_JPEG_2K_PLUGIN and HAS_PYDICOM
-
-PY_VERSION = sys.version[:2]
-
-
-@pytest.mark.skipif(not HAS_PYDICOM or HAS_PLUGINS, reason="Plugins available")
-class TestNoPlugins:
-    """Test interactions with no plugins."""
-
-    def test_pixel_array(self):
-        # Should basically just not mess up the usual pydicom behaviour
-        index = get_indexed_datasets("1.2.840.10008.1.2.4.50")
-        ds = index["JPEGBaseline_1s_1f_u_08_08.dcm"]["ds"]
-        msg = (
-            r"Unable to convert the Pixel Data as the 'pylibjpeg-libjpeg' "
-            r"plugin is not installed"
-        )
-        with pytest.raises(RuntimeError, match=msg):
-            ds.pixel_array
-
-
-@pytest.mark.skipif(not HAS_PYDICOM, reason="No pydicom")
-class TestPlugins:
-    """Test interaction with plugins."""
-
-    @pytest.mark.skipif(not RUN_JPEG, reason="No JPEG plugin")
-    def test_pixel_array(self):
-        # Should basically just not mess up the usual pydicom behaviour
-        index = get_indexed_datasets("1.2.840.10008.1.2.4.50")
-        ds = index["JPEGBaseline_1s_1f_u_08_08.dcm"]["ds"]
-        arr = ds.pixel_array
-
-        assert arr.flags.writeable
-        assert "uint8" == arr.dtype
-        assert (ds.Rows, ds.Columns) == arr.shape
-
-        # Reference values from GDCM handler
-        assert 76 == arr[5, 50]
-        assert 167 == arr[15, 50]
-        assert 149 == arr[25, 50]
-        assert 203 == arr[35, 50]
-        assert 29 == arr[45, 50]
-        assert 142 == arr[55, 50]
-        assert 1 == arr[65, 50]
-        assert 64 == arr[75, 50]
-        assert 192 == arr[85, 50]
-        assert 255 == arr[95, 50]
-
-    @pytest.mark.skipif(not RUN_JPEG, reason="No JPEG plugin")
-    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"'FileDataset' object has no attribute 'SamplesPerPixel'"
-        with pytest.raises(AttributeError, match=msg):
-            ds.pixel_array
-
-    @pytest.mark.skipif(not RUN_JPEG, reason="No JPEG plugin")
-    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
-
-
-@pytest.mark.skipif(not RUN_JPEG, reason="No JPEG plugin")
-class TestJPEGPlugin:
-    """Test interaction with plugins that support JPEG."""
-
-    uid = "1.2.840.10008.1.2.4.50"
-
-    def test_pixel_array(self):
-        index = get_indexed_datasets(self.uid)
-        ds = index["JPEGBaseline_1s_1f_u_08_08.dcm"]["ds"]
-        assert self.uid == ds.file_meta.TransferSyntaxUID
-
-        arr = ds.pixel_array
-        assert arr.flags.writeable
-        assert "uint8" == arr.dtype
-        assert (ds.Rows, ds.Columns) == arr.shape
-
-        # Reference values from GDCM handler
-        assert 76 == arr[5, 50]
-        assert 167 == arr[15, 50]
-        assert 149 == arr[25, 50]
-        assert 203 == arr[35, 50]
-        assert 29 == arr[45, 50]
-        assert 142 == arr[55, 50]
-        assert 1 == arr[65, 50]
-        assert 64 == arr[75, 50]
-        assert 192 == arr[85, 50]
-        assert 255 == arr[95, 50]
-
-
-@pytest.mark.skipif(not RUN_JPEGLS, reason="No JPEG-LS plugin")
-class TestJPEGLSPlugin:
-    """Test interaction with plugins that support JPEG-LS."""
-
-    uid = "1.2.840.10008.1.2.4.80"
-
-    def test_pixel_array(self):
-        index = get_indexed_datasets(self.uid)
-        ds = index["MR_small_jpeg_ls_lossless.dcm"]["ds"]
-        assert self.uid == ds.file_meta.TransferSyntaxUID
-
-        arr = ds.pixel_array
-        assert arr.flags.writeable
-        assert "int16" == arr.dtype
-        assert (ds.Rows, ds.Columns) == arr.shape
-
-        # Reference values from GDCM handler
-        assert [1194, 879, 127, 661, 1943, 1885, 1857, 1746, 1699] == (
-            arr[55:65, 38].tolist()
-        )
-
-
-@pytest.mark.skipif(not RUN_JPEG2K, reason="No JPEG 2000 plugin")
-class TestJPEG2KPlugin:
-    """Test interaction with plugins that support JPEG 2000."""
-
-    uid = "1.2.840.10008.1.2.4.90"
-
-    def test_pixel_array(self):
-        index = get_indexed_datasets(self.uid)
-        ds = index["US1_J2KR.dcm"]["ds"]
-
-        arr = ds.pixel_array
-        assert arr.flags.writeable
-        assert "uint8" == arr.dtype
-        assert (ds.Rows, ds.Columns, ds.SamplesPerPixel) == arr.shape
-
-        # Values checked against GDCM
-        assert [
-            [180, 26, 0],
-            [172, 15, 0],
-            [162, 9, 0],
-            [152, 4, 0],
-            [145, 0, 0],
-            [132, 0, 0],
-            [119, 0, 0],
-            [106, 0, 0],
-            [87, 0, 0],
-            [37, 0, 0],
-            [0, 0, 0],
-            [50, 0, 0],
-            [100, 0, 0],
-            [109, 0, 0],
-            [122, 0, 0],
-            [135, 0, 0],
-            [145, 0, 0],
-            [155, 5, 0],
-            [165, 11, 0],
-            [175, 17, 0],
-        ] == arr[175:195, 28, :].tolist()
-
-    # FIXME
-    def test_pixel_representation_mismatch(self):
-        """Test mismatch between Pixel Representation and the J2K data."""
-        index = get_indexed_datasets(self.uid)
-        ds = index["J2K_pixelrep_mismatch.dcm"]["ds"]
-
-        msg = (
-            r"value '1' \(signed\) in the dataset does not match the format "
-            r"of the values found in the JPEG 2000 data 'unsigned'"
-        )
-        with pytest.warns(UserWarning, match=msg):
-            arr = ds.pixel_array
-        assert arr.flags.writeable
-        assert "int16" == arr.dtype
-        assert (512, 512) == arr.shape
-
-        assert -2000 == arr[0, 0]
-        assert [621, 412, 138, -193, -520, -767, -907, -966, -988, -995] == (
-            arr[47:57, 279].tolist()
-        )
-        assert [-377, -121, 141, 383, 633, 910, 1198, 1455, 1638, 1732] == (
-            arr[328:338, 106].tolist()
-        )
-
-
-# Deprecated
-class TestPydicomUtils:
-    """Test the pydicom.utils functions."""
-
-    @pytest.mark.skipif(not RUN_JPEG2K, reason="No JPEG 2000 plugin")
-    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.90")
-        ds = index["693_J2KR.dcm"]["ds"]
-        assert 1 == getattr(ds, "NumberOfFrames", 1)
-        assert 1 == ds.SamplesPerPixel
-        frame_gen = generate_frames(ds)
-        arr = next(frame_gen)
-        with pytest.raises(StopIteration):
-            next(frame_gen)
-
-        assert arr.flags.writeable
-        assert "int16" == arr.dtype
-        assert (ds.Rows, ds.Columns) == arr.shape
-        assert [1022, 1051, 1165, 1442, 1835, 2096, 2074, 1868, 1685, 1603] == arr[
-            290, 135:145
-        ].tolist()
-
-    @pytest.mark.skipif(not RUN_JPEG2K, reason="No JPEG 2000 plugin")
-    def test_generate_frames_1s(self):
-        """Test with multiple frames, 1 sample/px."""
-        index = get_indexed_datasets("1.2.840.10008.1.2.4.90")
-        ds = index["emri_small_jpeg_2k_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]
-
-    @pytest.mark.skipif(not RUN_JPEG, reason="No JPEG plugin")
-    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()
-
-
-# Deprecated
-class TestGetJ2KParameters:
-    """Tests for get_j2k_parameters."""
-
-    def test_parameters(self):
-        """Test getting the parameters for a JPEG2K codestream."""
-        base = b"\xff\x4f\xff\x51" + b"\x00" * 38
-        # Signed
-        for ii in range(135, 143):
-            params = get_j2k_parameters(base + bytes([ii]))
-            assert ii - 127 == params["precision"]
-            assert params["is_signed"]
-
-        # Unsigned
-        for ii in range(7, 17):
-            params = get_j2k_parameters(base + bytes([ii]))
-            assert ii + 1 == params["precision"]
-            assert not params["is_signed"]
-
-    def test_not_j2k(self):
-        """Test result when no JPEG2K SOF marker present"""
-        base = b"\xff\x4e\xff\x51" + b"\x00" * 38
-        assert {} == get_j2k_parameters(base + b"\x8F")
-
-    def test_no_siz(self):
-        """Test result when no SIZ box present"""
-        base = b"\xff\x4f\xff\x52" + b"\x00" * 38
-        assert {} == get_j2k_parameters(base + b"\x8F")
-
-    def test_short_bytestream(self):
-        """Test result when no SIZ box present"""
-        assert {} == get_j2k_parameters(b"")
-        assert {} == get_j2k_parameters(b"\xff\x4f\xff\x51" + b"\x00" * 20)
diff --git a/pylibjpeg/tests/test_encode.py b/pylibjpeg/tests/test_encode.py
deleted file mode 100644
index e69de29..0000000
diff --git a/pylibjpeg/tests/test_encode_pydicom.py b/pylibjpeg/tests/test_encode_pydicom.py
deleted file mode 100644
index e69de29..0000000
diff --git a/pylibjpeg/tools/jpegio.py b/pylibjpeg/tools/jpegio.py
index 9f4e5c5..2c2da21 100644
--- a/pylibjpeg/tools/jpegio.py
+++ b/pylibjpeg/tools/jpegio.py
@@ -1,15 +1,15 @@
 import logging
+import os
+from typing import BinaryIO
 
 from .s10918 import parse, JPEG
 
 
-LOGGER = logging.getLogger("pylibjpeg.tools.jpegio")
-PARSERS = {
-    "10918": (parse, JPEG),
-}
+LOGGER = logging.getLogger(__name__)
+PARSERS = {"10918": (parse, JPEG)}
 
 
-def get_specification(fp):
+def get_specification(fp: BinaryIO) -> str:
     """ """
     if fp.read(1) != b"\xff":
         raise ValueError("File is not JPEG")
@@ -27,24 +27,25 @@ def get_specification(fp):
         fp.seek(0)
         return "10918"
 
+    s = "".join(f"{x:02X}" for x in marker)
     raise NotImplementedError(
-        "Reading a JPEG file with first marker '0x{}' is not supported".format(marker)
+        f"Reading a JPEG file with first marker '{s}' is not supported"
     )
 
 
-def jpgread(fpath):
+def jpgread(path: str | os.PathLike[str] | BinaryIO) -> JPEG:
     """Return a represention of the JPEG file at `fpath`."""
-    LOGGER.debug("Reading file: {}".format(fpath))
-    if isinstance(fpath, str):
-        with open(fpath, "rb") as fp:
+    LOGGER.debug(f"Reading file: {path}")
+    if isinstance(path, str | os.PathLike[str]):
+        with open(path, "rb") as fp:
             jpg_format = get_specification(fp)
             parser, jpg_class = PARSERS[jpg_format]
             meta = parser(fp)
             LOGGER.debug("File parsed successfully")
     else:
-        jpg_format = get_specification(fpath)
+        jpg_format = get_specification(path)
         parser, jpg_class = PARSERS[jpg_format]
-        meta = parser(fpath)
+        meta = parser(path)
         LOGGER.debug("File parsed successfully")
 
     return jpg_class(meta)
diff --git a/pylibjpeg/tools/s10918/__init__.py b/pylibjpeg/tools/s10918/__init__.py
index 9b62df1..6f0fc29 100644
--- a/pylibjpeg/tools/s10918/__init__.py
+++ b/pylibjpeg/tools/s10918/__init__.py
@@ -1,2 +1,2 @@
-from .io import parse
-from .rep import JPEG
+from .io import parse  # noqa: F401
+from .rep import JPEG  # noqa: F401
diff --git a/pylibjpeg/tools/s10918/_markers.py b/pylibjpeg/tools/s10918/_markers.py
index 1ef9fd1..66b43a4 100644
--- a/pylibjpeg/tools/s10918/_markers.py
+++ b/pylibjpeg/tools/s10918/_markers.py
@@ -1,8 +1,11 @@
 """JPEG 10918 markers"""
 
-from ._parsers import *
+from typing import Callable
 
-MARKERS = {}
+from ._parsers import APP, COM, DAC, DHT, DNL, DQT, DRI, EXP, SOF, SOS
+
+
+MARKERS: dict[int, tuple[str, str, None | Callable]] = {}
 # JPEG reserved markers
 for _marker in range(0xFF02, 0xFFBF + 1):
     MARKERS[_marker] = ("RES", "Reserved", None)
diff --git a/pylibjpeg/tools/s10918/_parsers.py b/pylibjpeg/tools/s10918/_parsers.py
index 81734f1..2958a3a 100644
--- a/pylibjpeg/tools/s10918/_parsers.py
+++ b/pylibjpeg/tools/s10918/_parsers.py
@@ -51,11 +51,12 @@
 """
 
 from struct import unpack
+from typing import BinaryIO, Any, cast
 
 from pylibjpeg.tools.utils import split_byte
 
 
-def APP(fp):
+def APP(fp: BinaryIO) -> dict[str, int | bytes]:
     """Return a dict containing APP data.
 
     See ISO/IEC 10918-1 Section B.2.4.6.
@@ -82,7 +83,7 @@ def APP(fp):
     return {"Lp": length, "Ap": fp.read(length - 2)}
 
 
-def COM(fp):
+def COM(fp: BinaryIO) -> dict[str, int | str]:
     """Return a dict containing COM data.
 
     See ISO/IEC 10918-1 Section B.2.4.5.
@@ -110,7 +111,7 @@ def COM(fp):
     return {"Lc": length, "Cm": comment}
 
 
-def DAC(fp):
+def DAC(fp: BinaryIO) -> dict[str, int | list[int]]:
     """Return a dict containing DAC segment data.
 
     See ISO/IEC 10918-1 Section B.2.4.3.
@@ -149,7 +150,7 @@ def DAC(fp):
     return {"La": length, "Tc": tc, "Tb": tb, "Cs": cs}
 
 
-def DHT(fp):
+def DHT(fp: BinaryIO) -> dict[str, int | list[int] | Any]:
     """Return a dict containing DHT segment data.
 
     See ISO/IEC 10918-1 Section B.2.4.2.
@@ -181,7 +182,7 @@ def DHT(fp):
     bytes_to_read = length - 2
 
     tc, th, li = [], [], []
-    vij = {}
+    vij: dict[tuple[int, int], dict[int, tuple[int]]] = {}
     while bytes_to_read > 0:
         _tc, _th = split_byte(fp.read(1))
         tc.append(_tc)
@@ -199,7 +200,7 @@ def DHT(fp):
         for ii in range(16):
             nr = _li[ii]
             if nr:
-                _vij[ii + 1] = unpack(">{}B".format(nr), fp.read(nr))
+                _vij[ii + 1] = unpack(f">{nr}B", fp.read(nr))
                 bytes_to_read -= nr
 
         li.append(_li)
@@ -208,7 +209,7 @@ def DHT(fp):
     return {"Lh": length, "Tc": tc, "Th": th, "Li": li, "Vij": vij}
 
 
-def DNL(fp):
+def DNL(fp: BinaryIO) -> dict[str, int | list[int]]:
     """Return a dict containing DNL segment data.
 
     See ISO/IEC 10918-1 Section B.2.5.
@@ -236,7 +237,7 @@ def DNL(fp):
     return {"Ld": length, "NL": nr_lines}
 
 
-def DQT(fp):
+def DQT(fp: BinaryIO) -> dict[str, int | list[int] | list[list[int]]]:
     """Return a dict containing DQT segment data.
 
     See ISO/IEC 10918-1 Section B.2.4.1.
@@ -272,18 +273,16 @@ def DQT(fp):
         tq.append(table_id)
 
         if precision not in (0, 1):
-            raise ValueError(
-                "JPEG 10918 - DQT: invalid precision '{}'".format(precision)
-            )
+            raise ValueError(f"JPEG 10918 - DQT: invalid precision '{precision}'")
 
         # If Pq is 0, Qk is 8-bit, if Pq is 1, Qk is 16-bit
         Q_k = []
         for ii in range(64):
             if precision == 0:
-                Q_k.append(unpack(">B", fp.read(1))[0])
+                Q_k.append(cast(int, unpack(">B", fp.read(1))[0]))
                 bytes_to_read -= 1
             elif precision == 1:
-                Q_k.append(unpack(">H", fp.read(2))[0])
+                Q_k.append(cast(int, unpack(">H", fp.read(2))[0]))
                 bytes_to_read -= 2
 
         qk.append(Q_k)
@@ -291,7 +290,7 @@ def DQT(fp):
     return {"Lq": length, "Pq": pq, "Tq": tq, "Qk": qk}
 
 
-def DRI(fp):
+def DRI(fp: BinaryIO) -> dict[str, int]:
     """Return a dict containing DRI segment data.
 
     See ISO/IEC 10918-1 Section B.2.4.4.
@@ -316,7 +315,7 @@ def DRI(fp):
     return {"Lr": unpack(">H", fp.read(2))[0], "Ri": unpack(">H", fp.read(2))[0]}
 
 
-def EXP(fp):
+def EXP(fp: BinaryIO) -> dict[str, int]:
     """Return a dict containing EXP segment data.
 
     See ISO/IEC 10918-1 Section B.3.3.
@@ -345,7 +344,7 @@ def EXP(fp):
     return {"Le": length, "Eh": eh, "Ev": ev}
 
 
-def SOF(fp):
+def SOF(fp: BinaryIO) -> dict[str, int | dict[int, dict[str, int]]]:
     """Return a dict containing SOF header data.
 
     See ISO/IEC 10918-1 Section B.2.2.
@@ -406,7 +405,7 @@ def SOF(fp):
     }
 
 
-def SOS(fp):
+def SOS(fp: BinaryIO) -> dict[str, int | list[int]]:
     """Return a dict containing SOS header data.
 
     See ISO/IEC 10918-1 Section B.2.3.
@@ -443,7 +442,7 @@ def SOS(fp):
     """
     (length, nr_components) = unpack(">HB", fp.read(3))
 
-    csj, tdj, taj, tmj = [], [], [], []
+    csj, tdj, taj = [], [], []
     for ii in range(nr_components):
         _cs = unpack(">B", fp.read(1))[0]
         csj.append(_cs)
@@ -467,7 +466,7 @@ def SOS(fp):
     }
 
 
-def skip(fp):
+def skip(fp: BinaryIO) -> None:
     """Skip the next N - 2 bytes.
 
     See ISO/IEC 10918-1 Section ???
diff --git a/pylibjpeg/tools/s10918/_printers.py b/pylibjpeg/tools/s10918/_printers.py
index 1072313..c85022d 100644
--- a/pylibjpeg/tools/s10918/_printers.py
+++ b/pylibjpeg/tools/s10918/_printers.py
@@ -18,6 +18,7 @@
 """
 
 from struct import unpack
+from typing import Any, cast
 
 
 ZIGZAG = [
@@ -98,18 +99,14 @@
 }
 
 
-def _print_app(marker, offset, info):
+def _print_app(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for an APP segment."""
-    _, _, info = info
+    _, _, sub = info
 
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Lp"] + 2)
-        )
-    )
+    header = f"{marker} marker at offset {offset}, length {sub['Lp'] + 2}"
+    ss = [f"\n {header:-^63} "]
 
-    app_data = info["Ap"]
+    app_data = cast(bytes, sub["Ap"])
     if app_data[:5] == b"\x4a\x46\x49\x46\x00":
         # JFIF https://en.wikipendia.org/wiki/JPEG_File_Interchange_Format
         version = (app_data[5], app_data[6])
@@ -138,43 +135,40 @@ def _print_app(marker, offset, info):
             ss.append("JFIF v{}.{}, no thumbnail".format(*version))
 
         if thumbnail:
-            data = " ".join(["{:02x}".format(cc) for cc in thumbnail])
+            data = " ".join(f"{cc:02X}" for cc in thumbnail)
             for ii in range(0, len(data), 60):
-                ss.append("  {}".format(data[ii : ii + 60]))
+                ss.append(f"  {data[ii : ii + 60]}")
 
     elif app_data[:6] == b"\x45\x78\x69\x66\x00\x00":
         ss.append("EXIF:")
-        data = " ".join(["{:02x}".format(cc) for cc in app_data[6:]])
+        data = " ".join(f"{cc:02X}" for cc in app_data[6:])
         for ii in range(0, len(data), 60):
-            ss.append("  {}".format(data[ii : ii + 60]))
+            ss.append(f"  {data[ii : ii + 60]}")
     elif app_data[:6] == b"\x41\x64\x6f\x62\x65\x00":
         # Adobe
         ss.append("Adobe v{}:".format(app_data[6]))
-        data = " ".join(["{:02x}".format(cc) for cc in app_data[6:]])
+        data = " ".join(f"{cc:02X}" for cc in app_data[6:])
         for ii in range(0, len(data), 60):
-            ss.append("  {}".format(data[ii : ii + 60]))
+            ss.append(f"  {data[ii : ii + 60]}")
     else:
         # Unknown
         ss.append("Unknown APP data")
-        data = ["{:02x}".format(cc) for cc in app_data]
-        for ii in range(0, len(data), 20):
-            ss.append("  {}".format(" ".join(data[ii : ii + 20])))
+        ldata = [f"{cc:02X}" for cc in app_data]
+        for ii in range(0, len(ldata), 20):
+            ss.append(f"  {' '.join(ldata[ii : ii + 20])}")
 
     return "\n".join(ss)
 
 
-def _print_com(marker, offset, info):
+def _print_com(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a COM segment."""
-    _m, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Lc"] + 2)
-        )
-    )
-
-    comment = "'" + info["Cm"].decode("utf-8") + "'"
-    ss.append("{}".format(comment[:47]))
+    _m, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Lc'] + 2}"
+    ss = [f"\n {header:-^63} "]
+
+    comment = f"'{sub['Cm'].decode('utf-8')}'"
+    ss.append(f"{comment[:47]}")
     comment = comment[47:]
 
     while True:
@@ -183,101 +177,90 @@ def _print_com(marker, offset, info):
         line = comment[:63]
         comment = comment[63:]
 
-        ss.append("         {}".format(line))
+        ss.append(f"         {line}")
 
     return "\n".join(ss)
 
 
-def _print_dac(marker, offset, info):
+def _print_dac(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a DAC segment."""
-    m_bytes, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["La"] + 2)
-        )
-    )
-    ss.append("Tc={}, Tb={}, Cs={}".format(info["Tc"], info["Tb"], info["Cs"]))
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['La'] + 2}"
+    ss = [f"\n {header:-^63} "]
+    ss.append(f"Tc={sub['Tc']}, Tb={sub['Tb']}, Cs={sub['Cs']}")
 
     return "\n".join(ss)
 
 
-def _print_dhp(marker, offset, info):
+def _print_dhp(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a DHP segment."""
-    m_bytes, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Lf"] + 2)
-        )
-    )
-
-    ss.append("Sample size (px): {} x {}".format(info["X"], info["Y"]))
-    ss.append("Sample precision (bits): {}".format(info["P"]))
-    ss.append("Number of component images: {}".format(info["Nf"]))
-
-    for ci, vv in info["Ci"].items():
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Lf'] + 2}"
+    ss = [f"\n {header:-^63} "]
+
+    ss.append(f"Sample size (px): {sub['X']} x {sub['Y']}")
+    ss.append(f"Sample precision (bits): {sub['P']}")
+    ss.append(f"Number of component images: {sub['Nf']}")
+
+    for ci, vv in sub["Ci"].items():
         h, v, tqi = vv["Hi"], vv["Vi"], vv["Tqi"]
         try:
             ci = _COMMON_COMPONENT_IDS[ci]
         except KeyError:
             pass
-        ss.append("  Component ID: {}".format(ci))
-        ss.append("    Horizontal sampling factor: {}".format(h))
-        ss.append("    Vertical sampling factor: {}".format(v))
-        ss.append("    Quantization table destination: {}".format(tqi))
+
+        ss.append(f"  Component ID: {ci}")
+        ss.append(f"    Horizontal sampling factor: {h}")
+        ss.append(f"    Vertical sampling factor: {v}")
+        ss.append(f"    Quantization table destination: {tqi}")
 
     return "\n".join(ss)
 
 
-def _print_dht(marker, offset, info):
+def _print_dht(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a DHT segment."""
-    _m, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Lh"] + 2)
-        )
-    )
-
-    for tc, th, li in zip(info["Tc"], info["Th"], info["Li"]):
-        vij = info["Vij"][(tc, th)]
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Lh'] + 2}"
+    ss = [f"\n {header:-^63} "]
+
+    for tc, th, li in zip(sub["Tc"], sub["Th"], sub["Li"]):
+        vij = sub["Vij"][(tc, th)]
         if tc == 0:
-            ss.append("Lossless/DC Huffman, table ID: {}".format(th))
+            ss.append(f"Lossless/DC Huffman, table ID: {th}")
         elif tc == 1:
-            ss.append("AC Huffman, table ID: {}".format(th))
+            ss.append(f"AC Huffman, table ID: {th}")
         else:
             raise NotImplementedError
 
         ss.append("   1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16")
-        nr_values = " ".join(["{:>02x}".format(val) for val in li])
-        ss.append("  {} : # codes".format(nr_values))
+        nr_values = " ".join(f"{val:>02X}" for val in li)
+        ss.append(f"  {nr_values} : # codes")
 
         for ii, (kk, values) in enumerate(vij.items()):
             if values is not None:
                 for jj in range(0, len(values), 16):
-                    vals = ["{:>02x}".format(vv) for vv in values[jj : jj + 16]]
-                    val = " ".join(vals)
-                    ss.append("  {:<47} : L = {}".format(val, kk))
+                    vals = [f"{vv:>02X}" for vv in values[jj : jj + 16]]
+                    ss.append(f"  {' '.join(vals):<47} : L = {kk}")
 
     return "\n".join(ss)
 
 
-def _print_dqt(marker, offset, info):
+def _print_dqt(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a DQT segment."""
-    _m, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Lq"] + 2)
-        )
-    )
-    for pq, tq, qk in zip(info["Pq"], info["Tq"], info["Qk"]):
-        ss.append("Table destination ID: {}".format(tq))
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Lq'] + 2}"
+    ss = [f"\n {header:-^63} "]
+
+    for pq, tq, qk in zip(sub["Pq"], sub["Tq"], sub["Qk"]):
+        ss.append(f"Table destination ID: {tq}")
         if pq == 0:
-            ss.append("Table precision: {} (8-bit)".format(pq))
+            ss.append(f"Table precision: {pq} (8-bit)")
         else:
-            ss.append("Table precision: {} (16-bit)".format(pq))
+            ss.append(f"Table precision: {pq} (16-bit)")
 
         new_qk = []
         for index in ZIGZAG:
@@ -287,71 +270,64 @@ def _print_dqt(marker, offset, info):
         for ii in range(0, 64, 8):
             if not pq:
                 # 8-bit
-                table_rows = ["{:>2}".format(qq) for qq in new_qk[ii : ii + 8]]
+                table_rows = [f"{qq:>2}" for qq in new_qk[ii : ii + 8]]
             else:
                 # 16-bit
-                table_rows = ["{:>3}".format(qq) for qq in new_qk[ii : ii + 8]]
+                table_rows = [f"{qq:>3}" for qq in new_qk[ii : ii + 8]]
 
-            ss.append("  {}".format("  ".join(table_rows)))
+            ss.append(f"  {'  '.join(table_rows)}")
 
     return "\n".join(ss)
 
 
-def _print_dnl(marker, offset, info):
+def _print_dnl(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a DNL segment."""
-    m_bytes, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Ld"] + 2)
-        )
-    )
-    ss.append("NL={}".format(info["NL"]))
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Ld'] + 2}"
+    ss = [f"\n {header:-^63} "]
+    ss.append(f"NL={sub['NL']}")
 
     return "\n".join(ss)
 
 
-def _print_dri(marker, offset, info):
+def _print_dri(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a DRI segment."""
-    m_bytes, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Lr"] + 2)
-        )
-    )
-    ss.append("Ri={}".format(info["Ri"]))
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Lr'] + 2}"
+    ss = [f"\n {header:-^63} "]
+    ss.append(f"Ri={sub['Ri']}")
+
     return "\n".join(ss)
 
 
-def _print_eoi(marker, offset, info):
+def _print_eoi(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for an EOI segment."""
-    return "\n{:=^63}".format(" {} marker at offset {} ".format(marker, offset))
+    m_bytes, fill, sub = info
+    header = f"{marker} marker at offset {offset}"
+    ss = [f"\n {header:-^63} "]
+
+    return "\n".join(ss)
 
 
-def _print_exp(marker, offset, info):
+def _print_exp(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for an EXP segment."""
-    m_bytes, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Le"] + 2)
-        )
-    )
-    ss.append("Eh={}, Ev={}".format(info["Eh"], info["Ev"]))
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Le'] + 2}"
+    ss = [f"\n {header:-^63} "]
+    ss.append(f"Eh={sub['Eh']}, Ev={sub['Ev']}")
 
     return "\n".join(ss)
 
 
-def _print_sof(marker, offset, info):
+def _print_sof(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a SOF segment."""
-    m_bytes, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Lf"] + 2)
-        )
-    )
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Lf'] + 2}"
+    ss = [f"\n {header:-^63} "]
 
     sof_type = {
         0xFFC0: "Baseline sequential DCT",  # SOF0
@@ -370,66 +346,70 @@ def _print_sof(marker, offset, info):
     }
 
     try:
-        ss.append("{}".format(sof_type[m_bytes]))
+        ss.append(sof_type[m_bytes])
     except KeyError:
-        ss.append("Unknown SOF type: {}".format(hex(m_bytes)))
+        ss.append(f"Unknown SOF type: {hex(m_bytes)}")
 
-    ss.append("Sample size (px): {} x {}".format(info["X"], info["Y"]))
-    ss.append("Sample precision (bits): {}".format(info["P"]))
-    ss.append("Number of component images: {}".format(info["Nf"]))
+    ss.append(f"Sample size (px): {sub['X']} x {sub['Y']}")
+    ss.append(f"Sample precision (bits): {sub['P']}")
+    ss.append(f"Number of component images: {sub['Nf']}")
 
-    for ci, vv in info["Ci"].items():
+    for ci, vv in sub["Ci"].items():
         h, v, tqi = vv["Hi"], vv["Vi"], vv["Tqi"]
         try:
             ci = _COMMON_COMPONENT_IDS[ci]
         except KeyError:
             pass
-        ss.append("  Component ID: {}".format(ci))
-        ss.append("    Horizontal sampling factor: {}".format(h))
-        ss.append("    Vertical sampling factor: {}".format(v))
-        ss.append("    Quantization table destination: {}".format(tqi))
+        ss.append(f"  Component ID: {ci}")
+        ss.append(f"    Horizontal sampling factor: {h}")
+        ss.append(f"    Vertical sampling factor: {v}")
+        ss.append(f"    Quantization table destination: {tqi}")
 
     return "\n".join(ss)
 
 
-def _print_soi(marker, offset, info):
+def _print_soi(marker: str, offset: int, info: tuple[int, int, dict[str, Any]]) -> str:
     """String output for a SOI segment."""
-    return "\n{:=^63}".format(" {} marker at offset {} ".format(marker, offset))
+    m_bytes, fill, sub = info
 
+    header = f"{marker} marker at offset {offset}"
+    ss = [f"\n {header:-^63} "]
 
-def _print_sos(marker, offset, info):
+    return "\n".join(ss)
+
+
+def _print_sos(
+    marker: str,
+    offset: int,
+    info: tuple[int, int, dict[str | tuple[str, int], Any]],
+) -> str:
     """String output for a SOS segment."""
-    _m, fill, info = info
-    ss = []
-    ss.append(
-        "\n{:-^63}".format(
-            " {} marker at offset {}, length {} ".format(marker, offset, info["Ls"] + 2)
-        )
-    )
-    ss.append("Number of image components: {}".format(info["Ns"]))
-
-    for csk, td, ta in zip(info["Csj"], info["Tdj"], info["Taj"]):
+    m_bytes, fill, sub = info
+
+    header = f"{marker} marker at offset {offset}, length {sub['Ls'] + 2}"
+    ss = [f"\n {header:-^63} "]
+    ss.append(f"Number of image components: {sub['Ns']}")
+
+    for csk, td, ta in zip(sub["Csj"], sub["Tdj"], sub["Taj"]):
         try:
             csk = _COMMON_COMPONENT_IDS[csk]
         except KeyError:
             pass
-        ss.append("  Component: {}, DC table: {}, AC table: {}".format(csk, td, ta))
+        ss.append(f"  Component: {csk}, DC table: {td}, AC table: {ta}")
 
-    ss.append("Spectral selectors start-end: {}-{}".format(info["Ss"], info["Se"]))
-    ss.append(
-        "Successive approximation bit high-low: {}-{}".format(info["Ah"], info["Al"])
-    )
+    ss.append(f"Spectral selectors start-end: {sub['Ss']}-{sub['Se']}")
+    ss.append(f"Successive approximation bit high-low: {sub['Ah']}-{sub['Al']}")
 
     # Write RST and encoded data lengths
     remove = ["Ls", "Ns", "Csj", "Tdj", "Taj", "Ss", "Se", "Ah", "Al"]
-    keys = [kk for kk in info if kk not in remove]
+    keys = [kk for kk in sub if kk not in remove]
     for key in keys:
         if key[0] == "ENC":
-            ss.append("\n{:.^63}".format(" ENC marker at offset {}".format(key[1])))
-            ss.append("\n{} bytes of entropy-coded data".format(len(info[key])))
+            ss.append(f"\n{' ENC marker at offset {key[1]}':.^63}")
+            ss.append(f"\n{len(sub[key])} bytes of entropy-coded data")
         else:
-            (name, offset) = key
-            ss.append("{:<7}{}({})".format(offset, name, "ffd{}".format(name[-1])))
+            (name, offset) = cast(tuple[str, int], key)
+            ss.append(f"{offset:<7}{name}(FFD{name[-1]})")
 
     return "\n".join(ss)
 
diff --git a/pylibjpeg/tools/s10918/io.py b/pylibjpeg/tools/s10918/io.py
index f09825f..05d1039 100644
--- a/pylibjpeg/tools/s10918/io.py
+++ b/pylibjpeg/tools/s10918/io.py
@@ -1,16 +1,16 @@
 """"""
 
-from functools import partial
 import logging
 from struct import unpack
+from typing import BinaryIO, Any, Callable, cast
 
 from ._markers import MARKERS
 
 
-LOGGER = logging.getLogger("jpg")
+LOGGER = logging.getLogger(__name__)
 
 
-def parse(fp):
+def parse(fp: BinaryIO) -> dict[tuple[str, int], Any]:
     """Return a JPEG but don't decode yet."""
     _fill_bytes = 0
     while fp.read(1) == b"\xff":
@@ -23,7 +23,9 @@ def parse(fp):
     if fp.read(2) != b"\xFF\xD8":
         raise ValueError("SOI marker not found")
 
-    info = {("SOI", fp.tell() - 2): (unpack(">H", b"\xFF\xD8")[0], _fill_bytes, {})}
+    info: dict[tuple[str, int], tuple[int, int, Any]] = {
+        ("SOI", fp.tell() - 2): (unpack(">H", b"\xFF\xD8")[0], _fill_bytes, {})
+    }
 
     END_OF_FILE = False
 
@@ -55,12 +57,12 @@ def parse(fp):
 
                 info[key] = (_marker, _fill_bytes, handler(fp))
 
-            elif name is "SOS":
+            elif name == "SOS":
                 # SOS's info dict contains an extra 'encoded_data' keys
                 # which use RSTN@offset and ENC@offset
-                info[key] = [_marker, _fill_bytes, handler(fp)]
+                handler = cast(Callable, handler)
+                info[key] = (_marker, _fill_bytes, handler(fp))
 
-                sos_info = {}
                 encoded_data = bytearray()
                 _enc_start = fp.tell()
 
@@ -127,7 +129,7 @@ def parse(fp):
                     fp.seek(-2 - _sos_fill_bytes, 1)
                     break
 
-            elif name is "EOI":
+            elif name == "EOI":
                 info[key] = (_marker, _fill_bytes, {})
                 break
 
diff --git a/pylibjpeg/tools/s10918/rep.py b/pylibjpeg/tools/s10918/rep.py
index 1ac43c9..92753b4 100644
--- a/pylibjpeg/tools/s10918/rep.py
+++ b/pylibjpeg/tools/s10918/rep.py
@@ -1,7 +1,9 @@
+from typing import Any, cast
+
 from ._printers import PRINTERS
 
 
-class JPEG(object):
+class JPEG:
     """A representation of an ISO/IEC 10918-1 JPEG file.
 
     **Non-hierarchical**
@@ -51,7 +53,7 @@ class JPEG(object):
 
     """
 
-    def __init__(self, meta):
+    def __init__(self, meta: dict[tuple[str, int], Any]) -> None:
         """Initialise a new JPEG.
 
         Parameters
@@ -62,57 +64,23 @@ def __init__(self, meta):
         self.info = meta
 
     @property
-    def columns(self):
+    def columns(self) -> int:
         """Return the number of columns in the image as an int."""
         keys = self.get_keys("SOF")
         if keys:
-            return self.info[keys[0]][2]["X"]
+            return cast(int, self.info[keys[0]][2]["X"])
 
         raise ValueError(
             "Unable to get the number of columns in the image as no SOFn "
             "marker was found"
         )
 
-    def _decode(self):
-        """Decode the JPEG image data in place.
-
-        Raises
-        ------
-        NotImplementedError
-            If the JPEG image data is of a type for which decoding is not
-            supported.
-        """
-        if not self.is_decodable:
-            raise NotImplementedError(
-                "Unable to decode the JPEG image data as it's of a type "
-                "for which decoding is not supported"
-            )
-
-        if self.is_process1:
-            decoder = decode_baseline
-        # elif self.is_process2:
-        #    decoder = decode_extended_8
-        # elif self.is_process4:
-        #    decoder = decode_extended_12
-        # elif self.is_process14:
-        #    decoder = decode_lossless
-        # elif self.is_process14_sv1:
-        #    decoder = decode_lossless
-
-        try:
-            self._array = decoder(self)
-            self._array_id = id(self._array)
-        except Exception as exc:
-            self._array = None
-            self._array_id = None
-            raise exc
-
-    def get_keys(self, name):
+    def get_keys(self, name: str) -> list[Any]:
         """Return a list of keys with marker containing `name`."""
         return [mm for mm in self._keys if name in mm[0]]
 
     @property
-    def is_baseline(self):
+    def is_baseline(self) -> bool:
         """Return True if the JPEG is baseline, False otherwise.
 
         Baseline process
@@ -131,7 +99,7 @@ def is_baseline(self):
         return "SOF0" in self.markers
 
     @property
-    def is_extended(self):
+    def is_extended(self) -> bool:
         """Return True if the JPEG is extended, False otherwise.
 
         Extended DCT-based processess
@@ -154,7 +122,7 @@ def is_extended(self):
         return False
 
     @property
-    def is_hierarchical(self):
+    def is_hierarchical(self) -> bool:
         """Return True if the JPEG is hierarchical, False otherwise.
 
         Hierarchical processess
@@ -166,7 +134,7 @@ def is_hierarchical(self):
         return "DHP" in self.markers
 
     @property
-    def is_lossless(self):
+    def is_lossless(self) -> bool:
         """Return True if the JPEG is lossless, False otherwise.
 
         Lossless processess
@@ -188,101 +156,26 @@ def is_lossless(self):
 
         return False
 
-    # TODO: Remove
-    @property
-    def is_process1(self):
-        """Return True if the JPEG is Process 1, False otherwise."""
-        if not self.is_hierarchical and self.is_baseline:
-            return True
-
-        return False
-
-    # TODO: Remove
-    @property
-    def is_process2(self):
-        """Return True if the JPEG is Process 2, False otherwise."""
-        try:
-            precision = self.precision
-        except ValueError:
-            return False
-
-        if not self.is_hierarchical and self.is_extended and precision == 8:
-            return True
-
-        return False
-
-    # TODO: Remove
-    @property
-    def is_process4(self):
-        """Return True if the JPEG is Process 4, False otherwise."""
-        try:
-            precision = self.precision
-        except ValueError:
-            return False
-
-        if not self.is_hierarchical and self.is_extended and precision == 12:
-            return True
-
-        return False
-
-    # TODO: Remove
-    @property
-    def is_process14(self):
-        """Return True if the JPEG is Process 14, False otherwise."""
-        if "SOF3" not in self.markers:
-            return False
-
-        if not self.is_hierarchical and self.is_lossless:
-            return True
-
-        raise False
-
-    # TODO: Remove
-    @property
-    def is_process14_sv1(self):
-        """Return True if the JPEG is Process 14 SV1, False otherwise.
-
-        Returns
-        -------
-        bool
-            True if JPEG is process 14, first-order prediction, selection
-            value 1, False otherwise.
-        """
-        if "SOF3" not in self.markers:
-            return False
-
-        if self.is_hierarchical or not self.is_lossless:
-            return False
-
-        if self.selection_value == 1:
-            return True
-
-        return False
-
     @property
-    def is_sequential(self):
+    def is_sequential(self) -> bool:
         return not self.is_hierarchical
 
     @property
-    def is_spectral(self):
-        pass
-
-    @property
-    def _keys(self):
+    def _keys(self) -> list[tuple[str, int]]:
         """Return a list of the info keys, ordered by offset."""
         return sorted(self.info.keys(), key=lambda x: x[1])
 
     @property
-    def markers(self):
+    def markers(self) -> list[str]:
         """Return a list of the found JPEG markers, ordered by offset."""
         return [mm[0] for mm in self._keys]
 
     @property
-    def precision(self):
+    def precision(self) -> int:
         """Return the precision of the sample as an int."""
         keys = self.get_keys("SOF")
         if keys:
-            return self.info[keys[0]][2]["P"]
+            return cast(int, self.info[keys[0]][2]["P"])
 
         raise ValueError(
             "Unable to get the sample precision of the image as no SOFn "
@@ -290,66 +183,11 @@ def precision(self):
         )
 
     @property
-    def process(self):
-        """Return the process number as :class:`int`."""
-        prec = self.precision
-        process = None
-
-        if self.is_baseline:
-            # Baseline sequential DCT, 8-bit
-            return 1
-
-        if self.is_extended:
-            # Extended sequential DCT
-            if self.is_huffman and prec == 8:
-                process = 2
-            elif self.is_arithmetic and prec == 8:
-                process = 3
-            elif self.is_huffman and prec == 12:
-                process = 4
-            elif self.is_arithmetic and prec == 12:
-                process = 5
-        elif self.is_spectral:
-            # Spectral selection only
-            if self.is_huffman and prec == 8:
-                process = 6
-            elif self.is_arithmetic and prec == 8:
-                process = 7
-            elif self.is_huffman and prec == 12:
-                process = 8
-            elif self.is_arithmetic and prec == 12:
-                process = 9
-        elif self.full_progression:
-            # Full progression
-            if self.is_huffman and prec == 8:
-                process = 10
-            elif self.is_arithmetic and prec == 8:
-                process = 11
-            elif self.is_huffman and prec == 12:
-                process = 12
-            elif self.is_arithmetic and prec == 12:
-                process = 13
-        elif self.is_lossless:
-            # Lossless
-            if self.is_huffman and 2 <= prec <= 16:
-                process = 14
-            elif self.is_arithmetic and 2 <= prec <= 16:
-                process = 15
-
-        if process is None:
-            raise ValueError("Unable to determine the JPEG process")
-
-        if self.is_sequential:
-            return process
-        elif self.is_hierarchical:
-            return process + 14
-
-    @property
-    def rows(self):
+    def rows(self) -> int:
         """Return the number of rows in the image as an int."""
         keys = self.get_keys("SOF")
         if keys:
-            return self.info[keys[0]][2]["Y"]
+            return cast(int, self.info[keys[0]][2]["Y"])
 
         raise ValueError(
             "Unable to get the number of rows in the image as no SOFn "
@@ -357,11 +195,11 @@ def rows(self):
         )
 
     @property
-    def samples(self):
+    def samples(self) -> int:
         """Return the number of components in the JPEG as an int."""
         keys = self.get_keys("SOF")
         if keys:
-            return self.info[keys[0]][2]["Nf"]
+            return cast(int, self.info[keys[0]][2]["Nf"])
 
         raise ValueError(
             "Unable to get the number of components in the image as no SOFn "
@@ -369,7 +207,7 @@ def samples(self):
         )
 
     @property
-    def selection_value(self):
+    def selection_value(self) -> int:
         """Return the JPEG lossless selection value.
 
         Returns
@@ -389,9 +227,9 @@ def selection_value(self):
             raise ValueError("Selection value is only available for lossless JPEG")
 
         sos_markers = [mm for mm in self._keys if "SOS" in mm]
-        return self.info[sos_markers[0]][2]["Ss"]
+        return cast(int, self.info[sos_markers[0]][2]["Ss"])
 
-    def __str__(self):
+    def __str__(self) -> str:
         """"""
         ss = []
         for marker, offset in self.info:
@@ -400,28 +238,3 @@ def __str__(self):
             ss.append(printer(marker, offset, info))
 
         return "\n".join(ss)
-
-    @property
-    def uid(self):
-        """Return the DICOM UID corresponding to the JPEG.
-
-        Returns
-        -------
-        uid.UID
-            The DICOM transfer syntax UID corresponding to the JPEG.
-
-        Raises
-        ------
-        ValueError
-            If the JPEG doesn't correspond to a DICOM transfer syntax.
-        """
-        if self.is_process1:
-            return JPEGBaseline
-        elif self.is_process2 or self.is_process4:
-            return JPEGExtended
-        elif self.is_process14_sv1:
-            return JPEGLossless
-        elif self.is_process14:
-            return JPEGLosslessP14
-
-        raise ValueError("JPEG doesn't correspond to a DICOM UID")
diff --git a/pylibjpeg/tools/utils.py b/pylibjpeg/tools/utils.py
index 0fbe4b3..30fa7e0 100644
--- a/pylibjpeg/tools/utils.py
+++ b/pylibjpeg/tools/utils.py
@@ -1,7 +1,7 @@
 """Utility functions."""
 
 
-def get_bit(byte, index):
+def get_bit(byte: bytes, index: int) -> int:
     """Return the value of the bit at `index` of `byte`.
 
     Parameters
@@ -17,14 +17,14 @@ def get_bit(byte, index):
     int
         The value of the bit (0 or 1).
     """
-    byte = ord(byte[:1])
+    value = ord(byte[:1])
     if not (-1 < index < 8):
         raise ValueError("'index' must be between 0 and 7, inclusive")
 
-    return (byte >> (7 - index)) & 1
+    return (value >> (7 - index)) & 1
 
 
-def split_byte(byte):
+def split_byte(byte: bytes) -> tuple[int, int]:
     """Return the 8-bit `byte` as two 4-bit unsigned integers.
 
     Parameters
@@ -39,5 +39,5 @@ def split_byte(byte):
         The (4 most significant, 4 least significant) bits of `byte` as ``(int,
         int)``.
     """
-    byte = ord(byte[:1])
-    return byte >> 4, 0b00001111 & byte
+    value = ord(byte[:1])
+    return value >> 4, 0b00001111 & value
diff --git a/pylibjpeg/utils.py b/pylibjpeg/utils.py
index fb425dd..4fd4d91 100644
--- a/pylibjpeg/utils.py
+++ b/pylibjpeg/utils.py
@@ -1,7 +1,9 @@
 import logging
 import os
-from pkg_resources import iter_entry_points
-from struct import unpack
+
+# from pkg_resources import iter_entry_points
+from importlib import metadata
+from typing import BinaryIO, Any, Protocol
 
 import numpy as np
 
@@ -9,7 +11,42 @@
 LOGGER = logging.getLogger(__name__)
 
 
-def decode(data, decoder=None, kwargs=None):
+DecodeSource = str | os.PathLike[str] | BinaryIO | bytes
+
+
+class Decoder(Protocol):
+    def __call__(self, src: bytes, **kwargs: Any) -> np.ndarray:
+        ...  # pragma: no cover
+
+
+DECODER_ENTRY_POINTS = {
+    "JPEG": "pylibjpeg.jpeg_decoders",
+    "JPEG XT": "pylibjpeg.jpeg_xt_decoders",
+    "JPEG-LS": "pylibjpeg.jpeg_ls_decoders",
+    "JPEG 2000": "pylibjpeg.jpeg_2000_decoders",
+    "JPEG XR": "pylibjpeg.jpeg_xr_decoders",
+    "JPEG XS": "pylibjpeg.jpeg_xs_decoders",
+    "JPEG XL": "pylibjpeg.jpeg_xl_decoders",
+}
+
+
+class Encoder(Protocol):
+    def __call__(self, src: np.ndarray, **kwargs: Any) -> bytes | bytearray:
+        ...  # pragma: no cover
+
+
+ENCODER_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",
+}
+
+
+def decode(data: DecodeSource, decoder: str = "", **kwargs: Any) -> np.ndarray:
     """Return the decoded JPEG image as a :class:`numpy.ndarray`.
 
     Parameters
@@ -18,9 +55,9 @@ def decode(data, decoder=None, kwargs=None):
         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.
-    decoder : callable, optional
-        The plugin to use when decoding the data. If not used then all
-        available decoders will be tried.
+    decoder : str, optional
+        The name of the plugin to use when decoding the data. If not used
+        then all available decoders will be tried.
     kwargs : dict
         A ``dict`` containing keyword parameters to pass to the decoder.
 
@@ -48,17 +85,15 @@ def decode(data, decoder=None, kwargs=None):
         # Try file-like
         data = data.read()
 
-    kwargs = kwargs or {}
-
-    if decoder is not None:
+    if decoder:
         try:
             return decoders[decoder](data, **kwargs)
         except KeyError:
             raise ValueError(f"The '{decoder}' decoder is not available")
 
-    for name, decoder in decoders.items():
+    for name, func in decoders.items():
         try:
-            return decoder(data, **kwargs)
+            return func(data, **kwargs)
         except Exception as exc:
             LOGGER.debug(f"Decoding with {name} plugin failed")
             LOGGER.exception(exc)
@@ -67,7 +102,7 @@ def decode(data, decoder=None, kwargs=None):
     raise ValueError("Unable to decode the data")
 
 
-def get_decoders(decoder_type=None):
+def get_decoders(decoder_type: str = "") -> dict[str, Decoder]:
     """Return a :class:`dict` of JPEG decoders as {package: callable}.
 
     Parameters
@@ -90,41 +125,32 @@ def get_decoders(decoder_type=None):
     dict
         A dict of ``{'package_name': }``.
     """
-    entry_points = {
-        "JPEG": "pylibjpeg.jpeg_decoders",
-        "JPEG XT": "pylibjpeg.jpeg_xt_decoders",
-        "JPEG-LS": "pylibjpeg.jpeg_ls_decoders",
-        "JPEG 2000": "pylibjpeg.jpeg_2000_decoders",
-        "JPEG XR": "pylibjpeg.jpeg_xr_decoders",
-        "JPEG XS": "pylibjpeg.jpeg_xs_decoders",
-        "JPEG XL": "pylibjpeg.jpeg_xl_decoders",
-    }
-    if decoder_type is None:
+    # print(metadata.entry_points())
+    # Return all decoders
+    if not decoder_type:
         decoders = {}
-        for entry_point in entry_points.values():
-            decoders.update(
-                {val.name: val.load() for val in iter_entry_points(entry_point)}
-            )
+        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:
-        return {
-            val.name: val.load()
-            for val in iter_entry_points(entry_points[decoder_type])
-        }
+        result = metadata.entry_points(group=DECODER_ENTRY_POINTS[decoder_type])
+        return {val.name: val.load() for val in result}
     except KeyError:
         raise ValueError(f"Unknown decoder_type '{decoder_type}'")
 
 
-def get_pixel_data_decoders():
+def get_pixel_data_decoders() -> dict[str, Decoder]:
     """Return a :class:`dict` of ``{UID: callable}``."""
     return {
         val.name: val.load()
-        for val in iter_entry_points("pylibjpeg.pixel_data_decoders")
+        for val in metadata.entry_points(group="pylibjpeg.pixel_data_decoders")
     }
 
 
-def _encode(arr, encoder=None, kwargs=None):
+def _encode(arr: np.ndarray, encoder: str = "", **kwargs: Any) -> bytes | bytearray:
     """Return the encoded `arr` as a :class:`bytes`.
 
     .. versionadded:: 1.3.0
@@ -133,9 +159,9 @@ def _encode(arr, encoder=None, kwargs=None):
     ----------
     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.
+    decoder : str, optional
+        The name of 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.
 
@@ -154,17 +180,15 @@ def _encode(arr, encoder=None, kwargs=None):
     if not encoders:
         raise RuntimeError("No encoders are available")
 
-    kwargs = kwargs or {}
-
-    if encoder is not None:
+    if encoder:
         try:
-            return encoders[encoder](data, **kwargs)
+            return encoders[encoder](arr, **kwargs)
         except KeyError:
             raise ValueError(f"The '{encoder}' encoder is not available")
 
-    for name, encoders in encoders.items():
+    for name, func in encoders.items():
         try:
-            return encoders(data, **kwargs)
+            return func(arr, **kwargs)
         except Exception as exc:
             LOGGER.debug(f"Encoding with {name} plugin failed")
             LOGGER.exception(exc)
@@ -173,7 +197,7 @@ def _encode(arr, encoder=None, kwargs=None):
     raise ValueError("Unable to encode the data")
 
 
-def get_encoders(encoder_type=None):
+def get_encoders(encoder_type: str = "") -> dict[str, Encoder]:
     """Return a :class:`dict` of JPEG encoders as {package: callable}.
 
     .. versionadded:: 1.3.0
@@ -198,38 +222,27 @@ def get_encoders(encoder_type=None):
     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:
+    if not encoder_type:
         encoders = {}
-        for entry_point in entry_points.values():
-            encoders.update(
-                {val.name: val.load() for val in iter_entry_points(entry_point)}
-            )
+        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})
+
         return encoders
 
     try:
-        return {
-            val.name: val.load()
-            for val in iter_entry_points(entry_points[encoder_type])
-        }
+        result = metadata.entry_points(group=ENCODER_ENTRY_POINTS[encoder_type])
+        return {val.name: val.load() for val in result}
     except KeyError:
         raise ValueError(f"Unknown encoder_type '{encoder_type}'")
 
 
-def get_pixel_data_encoders():
+def get_pixel_data_encoders() -> dict[str, Encoder]:
     """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")
+        for val in metadata.entry_points(group="pylibjpeg.pixel_data_encoders")
     }
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..10d1205
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,63 @@
+[build-system]
+requires = ["flit_core >=3.2,<4"]
+build-backend = "flit_core.buildapi"
+
+[project]
+authors = [
+    {name = "pylibjpeg contributors"}
+]
+classifiers=[
+"License :: OSI Approved :: MIT License",
+"Intended Audience :: Developers",
+"Intended Audience :: Healthcare Industry",
+"Intended Audience :: Science/Research",
+"Development Status :: 5 - Production/Stable",
+"Natural Language :: English",
+"Programming Language :: Python :: 3.10",
+"Programming Language :: Python :: 3.11",
+"Programming Language :: Python :: 3.12",
+"Operating System :: OS Independent",
+"Topic :: Scientific/Engineering :: Medical Science Apps.",
+"Topic :: Software Development :: Libraries",
+]
+dependencies = []
+description = """\
+    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"]
+license = {text = "MIT"}
+name = "pylibjpeg"
+readme = "README.md"
+requires-python = ">=3.10"
+version = "2.0.0.dev0"
+
+
+[project.optional-dependencies]
+dev = [
+    "black==23.12.1",
+    "mypy==1.8.0",
+    "pytest",
+    "pytest-cov",
+]
+
+
+[project.urls]
+download = "https://github.com/pydicom/pylibjpeg/archive/main.zip"
+homepage = "https://github.com/pydicom/pylibjpeg"
+repository = "https://github.com/pydicom/pylibjpeg"
+
+
+[tool.mypy]
+python_version = "3.10"
+files = "pylibjpeg"
+exclude = ["pylibjpeg/tests", "pylibjpeg/tools/tests"]
+show_error_codes = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+warn_return_any = true
+warn_unreachable = false
+ignore_missing_imports = true
+disallow_untyped_calls = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 8b13789..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 526aeb2..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[bdist_wheel]
-universal = 0
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 7725891..0000000
--- a/setup.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import os
-from pathlib import Path
-from setuptools import setup, find_packages
-import sys
-
-
-PACKAGE_DIR = Path(__file__).parent / "pylibjpeg"
-
-
-with open(PACKAGE_DIR / "_version.py") as f:
-    exec(f.read())
-
-with open("README.md", "r") as f:
-    long_description = f.read()
-
-setup(
-    name="pylibjpeg",
-    description=(
-        "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",
-    version=__version__,
-    author="scaramallion",
-    author_email="scaramallion@users.noreply.github.com",
-    url="https://github.com/pydicom/pylibjpeg",
-    license="MIT",
-    keywords=(
-        "dcm dicom pydicom python imaging jpg jpeg jpg-ls jpeg-ls jpeg2k "
-        "jpeg2000 rle libjpeg pylibjpeg "
-    ),
-    classifiers=[
-        "License :: OSI Approved :: MIT License",
-        "Intended Audience :: Developers",
-        "Intended Audience :: Healthcare Industry",
-        "Intended Audience :: Science/Research",
-        "Development Status :: 5 - Production/Stable",
-        "Natural Language :: English",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Operating System :: OS Independent",
-        "Topic :: Scientific/Engineering :: Medical Science Apps.",
-        "Topic :: Software Development :: Libraries",
-    ],
-    packages=find_packages(),
-    extras_require={
-        "rle": ["pylibjpeg-rle"],
-        "openjpeg": ["pylibjpeg-openjpeg"],
-        "libjpeg": ["pylibjpeg-libjpeg"],
-        "all": ["pylibjpeg-libjpeg", "pylibjpeg-openjpeg", "pylibjpeg-rle"],
-    },
-    install_requires=["numpy"],
-    include_package_data=True,
-    zip_safe=False,
-    python_requires=">=3.7",
-)