Repository: planetmarshall/pillow-jpls (language: python)

Issue:
Initial Version



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml
new file mode 100644
index 0000000..3630da2
--- /dev/null
+++ b/.github/workflows/build_deploy.yml
@@ -0,0 +1,89 @@
+name: CI
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+  release:
+    types:
+      - created
+
+  workflow_dispatch:
+
+jobs:
+  build_wheels:
+    name: Build wheels on ${{ matrix.os }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ ubuntu-20.04, windows-2016, macos-10.15 ]
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v2
+        with:
+          lfs: true
+
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.8
+
+      - name: Static Analysis
+        run: |
+          python -m pip install --upgrade pip
+          pip install flake8
+          flake8 --count --max-line-length=120 --statistics pillow_jpls
+
+      - name: Build wheels with cibuildwheel
+        uses: joerick/cibuildwheel@v1.9.0
+        env:
+           # Python 2.7 not supported
+           # Python 3.5 Deprecated
+           # Pillow requires zlib to compile for PyPy on Windows
+           CIBW_SKIP: cp27-* cp35-* pp27-* pp36-win32 pp37-win32
+           # numpy > 1.18 requires OpenBlas on macos
+           CIBW_TEST_REQUIRES: pytest numpy==1.18
+           CIBW_TEST_COMMAND: pytest -v {project}/test
+           # Pybind std::variant support requires 10.14+
+           MACOSX_DEPLOYMENT_TARGET: 10.15
+
+      - name: Upload wheel files
+        uses: actions/upload-artifact@v2
+        with:
+            name: pillow_jpls_wheels
+            path: ./wheelhouse/*.whl
+
+  deploy_pypi:
+    name: Deploy to PyPi
+    if: ${{ github.event.release.created || github.ref == 'refs/heads/pillow-jpls' }}
+    needs: build_wheels
+    runs-on: ubuntu-20.04
+    steps:
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.8
+
+      - name: Install Preprequisites
+        run: |
+          python -m pip install --upgrade pip
+          pip install twine
+
+      - name: Download built wheels
+        uses: actions/download-artifact@v2
+        with:
+          name: pillow_jpls_wheels
+          path: dist
+
+      - name: Upload to PyPi
+        run: |
+          echo ${GITHUB_REF}
+          [[ ${GITHUB_REF} == "refs/heads/pillow-jpls" ]] && PYPI_REPOSITORY="--repository testpypi"
+          twine upload ${PYPI_REPOSITORY} dist/*
+        env:
+          TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }}
+          TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }}
diff --git a/.github/workflows/profiling.yml b/.github/workflows/profiling.yml
new file mode 100644
index 0000000..38173fe
--- /dev/null
+++ b/.github/workflows/profiling.yml
@@ -0,0 +1,48 @@
+name: Profiling
+
+on:
+  workflow_dispatch:
+
+jobs:
+  valgrind:
+    name: Valgrind Massif
+    runs-on: ubuntu-20.04
+    env:
+      CC: clang
+      CXX: clang++
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v2
+
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.8
+
+      - name: Install prerequisites and test data
+        run: |
+          sudo apt-get install valgrind
+          python -m pip install pip --upgrade
+          pip install -r requirements.txt
+          mkdir -p test/data/profiling
+          wget -q https://imagecompression.info/test_images/rgb8bit.zip
+          unzip -d test/data/profiling rgb8bit.zip
+          rm rgb8bit.zip
+          cat test/data/profiling/readme.txt
+
+      - name: Build and install package for Valgrind
+        run: |
+          python setup.py --build-type=RelWithDebInfo install
+
+      - name: Run Valgrind Massif
+        run: |
+          MASSIF_FILE=massif.out.${GITHUB_SHA:0:8}
+          valgrind --tool=massif --massif-out-file=${MASSIF_FILE} \
+            pytest -v --profile --data test/data/profiling test/test_profile.py
+          ms_print ${MASSIF_FILE}
+
+      - name: Upload log files
+        uses: actions/upload-artifact@v2
+        with:
+          path: massif.*
diff --git a/.gitignore b/.gitignore
index 378eac2..c5da6d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,1 +1,21 @@
+# Common build folder locations
 build
+cmake-build-debug
+cmake-build-release
+
+# Python
+_skbuild
+.venv
+*.egg-info
+**/__pycache__
+.ipynb_checkpoints
+
+
+# Binaries
+*.so
+
+# IDE files
+.idea
+
+# Vim backup files
+.*~
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d83af13..2986d1e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,6 +1,51 @@
-cmake_minimum_required(VERSION 3.10)
+cmake_minimum_required(VERSION 3.18)
 
-project(SampleProject)
+project(Pycharls VERSION 1.0.0)
+
+list(APPEND CMAKE_MODULE_PATH ${CMAKE_BINARY_DIR})
+if(SKBUILD)
+    set(PYCHARLS_CONAN_COMMAND conan CACHE STRING "conan command")
+    set(Python_EXECUTABLE "${PYTHON_EXECUTABLE}")
+    set(Python_INCLUDE_DIR "${PYTHON_INCLUDE_DIR}")
+    set(DUMMY "${PYTHON_VERSION_STRING}")  # Not needed, silences a warning
+    find_package(Python REQUIRED COMPONENTS Interpreter Development.Module)
+
+    execute_process(
+            COMMAND
+            "${Python3_EXECUTABLE}" -c
+            "import pybind11; print(pybind11.get_cmake_dir())"
+            OUTPUT_VARIABLE _tmp_dir
+            OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ECHO STDOUT)
+    list(APPEND CMAKE_PREFIX_PATH "${_tmp_dir}")
+
+    if(NOT EXISTS "${CMAKE_BINARY_DIR}/conan.cmake")
+        message(STATUS "Downloading conan.cmake from https://github.com/conan-io/cmake-conan")
+        file(DOWNLOAD "https://github.com/conan-io/cmake-conan/raw/v0.15/conan.cmake" "${CMAKE_BINARY_DIR}/conan.cmake")
+    endif()
+    include(${CMAKE_BINARY_DIR}/conan.cmake)
+    conan_cmake_run(
+            CONANFILE conanfile.txt
+            BASIC_SETUP
+            PROFILE_AUTO
+            CONAN_COMMAND ${PYCHARLS_CONAN_COMMAND}
+            BUILD outdated
+    )
+else()
+    find_package(Python REQUIRED COMPONENTS Interpreter Development.Module)
+    include(${CMAKE_BINARY_DIR}/conanbuildinfo.cmake)
+    conan_basic_setup()
+endif()
+
+find_package(pybind11 REQUIRED)
+find_package(charls REQUIRED)
+find_package(fmt REQUIRED)
+find_package(Eigen3 REQUIRED)
+
+if (MSVC)
+    add_compile_options(/Wall /permissive-)
+else()
+    add_compile_options(-Wall -Wextra -Wpedantic)
+endif()
 
 add_subdirectory(src)
-add_subdirectory(app)
+
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..faaff25
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,17 @@
+Copyright 2021 Andrew Marshall
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1f8bc23
--- /dev/null
+++ b/README.md
@@ -0,0 +1,81 @@
+JPEG-LS plugin for Python Pillow
+================================
+
+![Build status](https://github.com/planetmarshall/pillow-jpls/actions/workflows/build_deploy.yml/badge.svg)
+
+
+A plugin for the Python [Pillow](https://pillow.readthedocs.io/en/stable/) imaging library for the
+JPEG-LS codec,
+based on the [Charls](https://github.com/team-charls/charls) JPEG-LS implemetation. 
+Python bindings implemented using [pybind11](https://pybind11.readthedocs.io/en/stable/).
+
+Usage
+-----
+
+```
+pip install .
+```
+
+### With Pillow
+```.py
+import pillow_jpls
+from PIL import Image
+from io import BytesIO
+
+img = Image.open("image.jls")
+img.save("image_copy.jls)
+
+# Saving to a buffer
+buffer = BytesIO()
+img.save(buffer, "JPEG-LS")
+```
+
+### Options
+
+The encoder supports the following options. See the specification for details, and the tests for 
+example usage.
+
+* `near_lossless` : `0` is lossless - the default.
+* `interleave` : one of `none`, `line` or `sample` 
+* `bits_per_component` : override the number of bits encoded per component, otherwise taken from the image format
+* `maxval`: override the maximum value of any component, otherwise taken from `bits_per_component`
+* `t1`
+* `t2`
+* `t3`
+* `reset`
+
+Build
+-----
+
+The build is driven by [PEP 517](https://www.python.org/dev/peps/pep-0517/) 
+and [Scikit Build](https://scikit-build.readthedocs.io/en/latest/). 
+[cibuildwheel](https://github.com/joerick/cibuildwheel) is used to generate packages using Github Actions.
+
+```
+pip install pep517
+python -m pep517.build --binary .
+```
+
+Tests
+-----
+
+A suite of tests covering the applicable conformance tests from the specification is provided.
+You will need [git-lfs](https://git-lfs.github.com/) to clone the data.
+
+```
+pip install pytest .
+pytest -v test
+```
+
+Limitations
+-----------
+
+16 bit multichannel images are not supported, as these are not supported in Pillow.
+16bit greyscale images are supported, however.
+
+
+References
+----------
+
+* [JPEG-LS on Wikipedia](https://en.wikipedia.org/wiki/Lossless_JPEG#JPEG-LS)
+* [The ITU T.87 Specification](https://www.itu.int/rec/T-REC-T.87-199806-I/en)
diff --git a/Readme.md b/Readme.md
deleted file mode 100644
index 66434a9..0000000
--- a/Readme.md
+++ /dev/null
@@ -1,10 +0,0 @@
-Sample C++ Project
-==================
-
-A sample startup project based on modern C++ guidelines
-
-References
-----------
-
-[An introduction to modern CMake](https://cliutils.gitlab.io/modern-cmake/)
-[Effective Modern CMake](https://gist.github.com/mbinna/c61dbb39bca0e4fb7d1f73b0d66a4fd1)
diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt
deleted file mode 100644
index 0608749..0000000
--- a/app/CMakeLists.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-add_executable(app
-    main.cpp)
-
-target_link_libraries(app
-    Sample::Core
-    )
diff --git a/app/main.cpp b/app/main.cpp
deleted file mode 100644
index 3bde22a..0000000
--- a/app/main.cpp
+++ /dev/null
@@ -1,8 +0,0 @@
-#include <sample/version.hpp>
-
-#include <iostream>
-
-int main(int argc, char* argv[]) {
-    std::cout << "Sample version: " << sample::version() << std::endl;
-    return 0;
-}
diff --git a/conanfile.txt b/conanfile.txt
new file mode 100644
index 0000000..9d59e74
--- /dev/null
+++ b/conanfile.txt
@@ -0,0 +1,12 @@
+[requires]
+charls/2.1.0
+pybind11/2.6.2
+fmt/7.1.3
+eigen/3.3.9
+
+[generators]
+cmake
+cmake_find_package
+
+[options]
+charls:shared=False
diff --git a/include/sample/version.hpp b/include/sample/version.hpp
deleted file mode 100644
index 903302e..0000000
--- a/include/sample/version.hpp
+++ /dev/null
@@ -1,7 +0,0 @@
-#pragma once
-
-#include <string>
-
-namespace sample {
-    std::string version();
-}
diff --git a/pillow_jpls/__init__.py b/pillow_jpls/__init__.py
new file mode 100644
index 0000000..7a8dbb0
--- /dev/null
+++ b/pillow_jpls/__init__.py
@@ -0,0 +1,17 @@
+from PIL import Image
+from .jpls_image_file import JplsImageFile, accept
+from .jpls_image_encoder import save
+from .jpls_image_decoder import JplsImageDecoder
+
+from ._pycharls import ( # noqa
+    SpiffProfileId,
+    SpiffResolutionUnits,
+    SpiffColorSpace,
+    SpiffCompressionType
+)
+
+Image.register_open(JplsImageFile.format, JplsImageFile, accept)
+Image.register_extensions(JplsImageFile.format, [".jls", ".jpls"])
+Image.register_save(JplsImageFile.format, save)
+Image.register_decoder("jpeg_ls", JplsImageDecoder)
+Image.register_mime(JplsImageFile.format, "image/jls")
diff --git a/pillow_jpls/jpls_image_decoder.py b/pillow_jpls/jpls_image_decoder.py
new file mode 100644
index 0000000..8f568db
--- /dev/null
+++ b/pillow_jpls/jpls_image_decoder.py
@@ -0,0 +1,25 @@
+from PIL.ImageFile import PyDecoder
+
+from . import _pycharls
+from ._pycharls import SpiffColorSpace
+
+
+class JplsImageDecoder(PyDecoder):
+    def __init__(self, mode, *args):
+        super().__init__(mode, *args)
+        self._pulls_fd = True  # we decode the entire image in one shot
+        self.color_space = args[0].get("color_space")
+
+    def decode(self, buffer):
+        buffer = self.fd.read()
+        errcode = 0
+        try:
+            decoded = _pycharls.decode(buffer)
+            raw_mode = None
+            if self.color_space in [SpiffColorSpace.BiLevelWhite, SpiffColorSpace.BiLevelBlack]:
+                raw_mode = "1;8"
+            self.set_as_raw(bytes(decoded), rawmode=raw_mode)
+        except RuntimeError:
+            errcode = -2
+
+        return -1, errcode
diff --git a/pillow_jpls/jpls_image_encoder.py b/pillow_jpls/jpls_image_encoder.py
new file mode 100644
index 0000000..4341221
--- /dev/null
+++ b/pillow_jpls/jpls_image_encoder.py
@@ -0,0 +1,73 @@
+from PIL import Image
+
+from . import _pycharls
+from ._pycharls import (
+    SpiffProfileId,
+    SpiffCompressionType,
+    SpiffColorSpace,
+    SpiffResolutionUnits,
+    SpiffHeader
+)
+
+
+_mode_table = {
+    "1": (1, 8, SpiffColorSpace.BiLevelBlack),
+    "L": (1, 8, SpiffColorSpace.Grayscale),
+    "P": (1, 8, SpiffColorSpace.Grayscale),
+    "RGB": (3, 8, SpiffColorSpace.Rgb),
+    "RGBA": (4, 8, SpiffColorSpace.Rgb),
+    "LAB": (3, 8, SpiffColorSpace.CieLab),
+    "CMYK": (4, 8, SpiffColorSpace.Cmyk),
+    "YCbCr": (3, 8, SpiffColorSpace.YCbCrItuBt601Video),
+    "I;16": (1, 16, SpiffColorSpace.Grayscale),
+    "RGB;16": (3, 16, SpiffColorSpace.Rgb),
+}
+
+
+def modes_to_str():
+    return ", ".join(_mode_table.keys())
+
+
+def _spiff_header(image: Image, spiff_header):
+    component_count, bits_per_sample, default_color_space = _mode_table[image.mode]
+    spiff = SpiffHeader()
+    spiff.profile_id = spiff_header.get("profile_id", SpiffProfileId.NotSpecified)
+    spiff.component_count = component_count
+    spiff.height = image.height
+    spiff.width = image.width
+    spiff.color_space = spiff_header.get("color_space", default_color_space)
+    spiff.bits_per_sample = max(bits_per_sample, 2)
+    spiff.compression_type = SpiffCompressionType.JpegLs
+    spiff.resolution_units = spiff_header.get("resolution_units", SpiffResolutionUnits.DotsPerInch)
+    spiff.vertical_resolution = spiff_header.get("vertical_resolution", 96)
+    spiff.horizontal_resolution = spiff_header.get("horizontal_resolution", 96)
+    return spiff
+
+
+def save(image: Image, fp, file_name):
+    if image.mode not in _mode_table:
+        raise SyntaxError(
+            f"Image mode {image.mode} not supported. Convert to one of the supported modes: {modes_to_str()}.")
+
+    component_count, bits_per_sample, _ = _mode_table[image.mode]
+    frame_info = _pycharls.FrameInfo()
+    frame_info.width = image.width
+    frame_info.height = image.height
+    frame_info.component_count = component_count
+    frame_info.bits_per_sample = max(image.encoderinfo.get("bits_per_sample", bits_per_sample), 2)
+
+    spiff_header = image.encoderinfo.get("spiff", {})
+    header = None
+    if spiff_header is not None:
+        header = _spiff_header(image, spiff_header)
+        header.bits_per_sample = frame_info.bits_per_sample
+
+    image.encoderinfo["spiff"] = header
+    if image.mode == "1":
+        image.encoderinfo["maxval"] = 1
+        # bitmap images are stored in a compressed representation if you use tobytes()
+        data = bytes(image.getdata())
+    else:
+        data = image.tobytes()
+
+    fp.write(_pycharls.encode(data, frame_info, **image.encoderinfo))
diff --git a/pillow_jpls/jpls_image_file.py b/pillow_jpls/jpls_image_file.py
new file mode 100644
index 0000000..86c0f69
--- /dev/null
+++ b/pillow_jpls/jpls_image_file.py
@@ -0,0 +1,75 @@
+from PIL.ImageFile import ImageFile
+
+from . import _pycharls
+from ._pycharls import SpiffColorSpace
+
+
+def accept(header):
+    magic_number = header[:4]
+    spiff_header = b"\xff\xd8\xff\xe8"
+    jpeg_header = b"\xff\xd8\xff\xf7"
+    return magic_number == spiff_header or magic_number == jpeg_header
+
+
+def _mode(num_components, bits_per_component):
+    if bits_per_component > 8:
+        bits_per_component = 16
+
+    mode_table = {
+        (1, 1): "1",
+        (3, 8): "RGB",
+        (4, 8): "RGBA",
+        (1, 8): "L",
+        (1, 16): "I;16"
+    }
+    return mode_table.get((num_components, bits_per_component))
+
+
+def _metadata(header):
+    meta = {
+        "bits_per_sample": header.bits_per_sample,
+        "component_count": header.component_count,
+        "width": header.width,
+        "height": header.height
+    }
+    if hasattr(header, "profile_id"):
+        meta.update({
+            "color_space": header.color_space,
+            "profile_id": header.profile_id,
+            "compression_type": header.compression_type,
+            "resolution_units": header.resolution_units,
+            "vertical_resolution": header.vertical_resolution,
+            "horizontal_resolution": header.horizontal_resolution
+        })
+    return meta
+
+
+class JplsImageFile(ImageFile):
+    format = "JPEG-LS"
+    format_description = "Lossless and near-lossless compression of continuous-tone still images"
+
+    def _open(self):
+        max_header_bytes = 128
+        buffer = self.fp.read(max_header_bytes)
+        header = _pycharls.read_header(buffer)
+        mode = _mode(header.component_count, header.bits_per_sample)
+        if mode is None:
+            raise IOError(f"Mode not supported: components: {header.component_count}, bits: {header.bits_per_sample}")
+
+        meta = _metadata(header)
+        color_space = meta.get("color_space")
+        if color_space in [SpiffColorSpace.BiLevelBlack, SpiffColorSpace.BiLevelWhite]:
+            mode = "1"
+        elif color_space == SpiffColorSpace.CieLab:
+            mode = "LAB"
+        elif color_space == SpiffColorSpace.Cmyk:
+            mode = "CMYK"
+        elif color_space in [SpiffColorSpace.YCbCrItuBt601Video,
+                             SpiffColorSpace.YCbCrItuBt601Rgb,
+                             SpiffColorSpace.YCbCrItuBt709Video]:
+            mode = "YCbCr"
+
+        self.info.update(_metadata(header))
+        self._size = (header.width, header.height)
+        self.mode = mode
+        self.tile = [("jpeg_ls", (0, 0) + self.size, 0, (meta, ))]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8204648
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,12 @@
+[build-system]
+requires = [
+    "setuptools>=42",
+    "wheel",
+    "pybind11~=2.6.0",
+    "cmake",
+    "conan",
+    "ninja",
+    "scikit-build",
+]
+
+build-backend = "setuptools.build_meta"
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..287491f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,10 @@
+numpy
+Pillow
+pytest
+setuptools>=42
+wheel
+pybind11~=2.6.0
+cmake
+conan
+ninja
+scikit-build
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..53ef199
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,54 @@
+import sys
+from os import path
+from setuptools import find_packages
+import re
+
+
+_semver_regex = r"""(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"""
+_project_regex = r"^project\(.*$"
+
+try:
+    from skbuild import setup
+except ImportError:
+    print(
+        "Please update pip, you need pip 10 or greater,\n"
+        " or you need to install the PEP 518 requirements in pyproject.toml yourself",
+        file=sys.stderr,
+    )
+    raise
+
+
+def _current_directory():
+    return path.abspath(path.dirname(__file__))
+
+
+def _long_description():
+    with open(path.join(_current_directory(), 'README.md'), encoding='utf-8') as fp:
+        return fp.read()
+
+
+def _version():
+    with open(path.join(_current_directory(), 'CMakeLists.txt'), encoding='utf-8') as fp:
+        text = fp.read()
+        project_line = re.search(_project_regex, text, re.MULTILINE | re.IGNORECASE)
+        semver = re.search(_semver_regex, project_line.group())
+        return "{major}.{minor}.{patch}".format(**semver.groupdict())
+
+
+setup(
+    name="pillow-jpls",
+    version=_version(),
+    description="A JPEG-LS plugin for the Pillow imaging library",
+    author="Andrew Marshall",
+    author_email="andrew@algodynamic.com",
+    url="https://github.com/planetmarshall/pillow-jpls",
+    license="BSD-3-Clause",
+    long_description=_long_description(),
+    long_description_content_type="text/markdown",
+    packages=find_packages(),
+    cmake_install_dir="pillow_jpls",
+    install_requires=[
+        "Pillow"
+    ],
+    python_requires=">=3.6"
+)
\ No newline at end of file
diff --git a/src/.clang-format b/src/.clang-format
new file mode 100644
index 0000000..1aafffc
--- /dev/null
+++ b/src/.clang-format
@@ -0,0 +1,149 @@
+---
+Language:        Cpp
+# BasedOnStyle:  LLVM
+AccessModifierOffset: -2
+AlignAfterOpenBracket: Align
+AlignConsecutiveMacros: false
+AlignConsecutiveAssignments: false
+AlignConsecutiveBitFields: false
+AlignConsecutiveDeclarations: false
+AlignEscapedNewlines: Right
+AlignOperands:   Align
+AlignTrailingComments: true
+AllowAllArgumentsOnNextLine: true
+AllowAllConstructorInitializersOnNextLine: true
+AllowAllParametersOfDeclarationOnNextLine: true
+AllowShortEnumsOnASingleLine: true
+AllowShortBlocksOnASingleLine: Never
+AllowShortCaseLabelsOnASingleLine: false
+AllowShortFunctionsOnASingleLine: All
+AllowShortLambdasOnASingleLine: All
+AllowShortIfStatementsOnASingleLine: Never
+AllowShortLoopsOnASingleLine: false
+AlwaysBreakAfterDefinitionReturnType: None
+AlwaysBreakAfterReturnType: None
+AlwaysBreakBeforeMultilineStrings: false
+AlwaysBreakTemplateDeclarations: MultiLine
+BinPackArguments: true
+BinPackParameters: true
+BraceWrapping:
+  AfterCaseLabel:  false
+  AfterClass:      false
+  AfterControlStatement: Never
+  AfterEnum:       false
+  AfterFunction:   false
+  AfterNamespace:  false
+  AfterObjCDeclaration: false
+  AfterStruct:     false
+  AfterUnion:      false
+  AfterExternBlock: false
+  BeforeCatch:     false
+  BeforeElse:      false
+  BeforeLambdaBody: false
+  BeforeWhile:     false
+  IndentBraces:    false
+  SplitEmptyFunction: true
+  SplitEmptyRecord: true
+  SplitEmptyNamespace: true
+BreakBeforeBinaryOperators: None
+BreakBeforeBraces: Attach
+BreakBeforeInheritanceComma: false
+BreakInheritanceList: BeforeColon
+BreakBeforeTernaryOperators: true
+BreakConstructorInitializersBeforeComma: false
+BreakConstructorInitializers: BeforeColon
+BreakAfterJavaFieldAnnotations: false
+BreakStringLiterals: true
+ColumnLimit:     120
+CommentPragmas:  '^ IWYU pragma:'
+CompactNamespaces: false
+ConstructorInitializerAllOnOneLineOrOnePerLine: false
+ConstructorInitializerIndentWidth: 4
+ContinuationIndentWidth: 4
+Cpp11BracedListStyle: true
+DeriveLineEnding: true
+DerivePointerAlignment: false
+DisableFormat:   false
+ExperimentalAutoDetectBinPacking: false
+FixNamespaceComments: true
+ForEachMacros:
+  - foreach
+  - Q_FOREACH
+  - BOOST_FOREACH
+IncludeBlocks:   Preserve
+IncludeCategories:
+  - Regex:           '^"(llvm|llvm-c|clang|clang-c)/'
+    Priority:        2
+    SortPriority:    0
+  - Regex:           '^(<|"(gtest|gmock|isl|json)/)'
+    Priority:        3
+    SortPriority:    0
+  - Regex:           '.*'
+    Priority:        1
+    SortPriority:    0
+IncludeIsMainRegex: '(Test)?$'
+IncludeIsMainSourceRegex: ''
+IndentCaseLabels: false
+IndentCaseBlocks: false
+IndentGotoLabels: true
+IndentPPDirectives: None
+IndentExternBlock: AfterExternBlock
+IndentWidth:     4
+IndentWrappedFunctionNames: false
+InsertTrailingCommas: None
+JavaScriptQuotes: Leave
+JavaScriptWrapImports: true
+KeepEmptyLinesAtTheStartOfBlocks: true
+MacroBlockBegin: ''
+MacroBlockEnd:   ''
+MaxEmptyLinesToKeep: 1
+NamespaceIndentation: None
+ObjCBinPackProtocolList: Auto
+ObjCBlockIndentWidth: 2
+ObjCBreakBeforeNestedBlockParam: true
+ObjCSpaceAfterProperty: false
+ObjCSpaceBeforeProtocolList: true
+PenaltyBreakAssignment: 2
+PenaltyBreakBeforeFirstCallParameter: 19
+PenaltyBreakComment: 300
+PenaltyBreakFirstLessLess: 120
+PenaltyBreakString: 1000
+PenaltyBreakTemplateDeclaration: 10
+PenaltyExcessCharacter: 1000000
+PenaltyReturnTypeOnItsOwnLine: 60
+PointerAlignment: Right
+ReflowComments:  true
+SortIncludes:    true
+SortUsingDeclarations: true
+SpaceAfterCStyleCast: false
+SpaceAfterLogicalNot: false
+SpaceAfterTemplateKeyword: true
+SpaceBeforeAssignmentOperators: true
+SpaceBeforeCpp11BracedList: false
+SpaceBeforeCtorInitializerColon: true
+SpaceBeforeInheritanceColon: true
+SpaceBeforeParens: ControlStatements
+SpaceBeforeRangeBasedForLoopColon: true
+SpaceInEmptyBlock: false
+SpaceInEmptyParentheses: false
+SpacesBeforeTrailingComments: 1
+SpacesInAngles:  false
+SpacesInConditionalStatement: false
+SpacesInContainerLiterals: true
+SpacesInCStyleCastParentheses: false
+SpacesInParentheses: false
+SpacesInSquareBrackets: false
+SpaceBeforeSquareBrackets: false
+Standard:        Latest
+StatementMacros:
+  - Q_UNUSED
+  - QT_REQUIRE_VERSION
+TabWidth:        4
+UseCRLF:         false
+UseTab:          Never
+WhitespaceSensitiveMacros:
+  - STRINGIZE
+  - PP_STRINGIZE
+  - BOOST_PP_STRINGIZE
+...
+
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 901b1f3..020e98c 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -1,13 +1,16 @@
-add_library(
-    sample_core
-    core/version.cpp
-)
+pybind11_add_module(_pycharls MODULE pycharls.cpp)
 
-add_library( Sample::Core ALIAS sample_core)
+target_compile_features(_pycharls PRIVATE cxx_std_17)
 
-target_include_directories(
-    sample_core
-    PUBLIC
-    ${CMAKE_SOURCE_DIR}/include
-)
+target_link_libraries( _pycharls
+    PRIVATE
+        Python::Module
+        pybind11::headers
+        charls::charls
+        Eigen3::Eigen3
+        fmt::fmt
+        )
 
+install(TARGETS _pycharls
+        LIBRARY DESTINATION .
+        )
\ No newline at end of file
diff --git a/src/core/version.cpp b/src/core/version.cpp
deleted file mode 100644
index 87054d6..0000000
--- a/src/core/version.cpp
+++ /dev/null
@@ -1,7 +0,0 @@
-#include "sample/version.hpp"
-
-#include <string>
-
-std::string sample::version() {
-    return "1.0.0";
-}
diff --git a/src/pycharls.cpp b/src/pycharls.cpp
new file mode 100644
index 0000000..8d9e534
--- /dev/null
+++ b/src/pycharls.cpp
@@ -0,0 +1,277 @@
+#include <charls/charls.h>
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+#include <fmt/format.h>
+#include <Eigen/Core>
+
+#include <vector>
+#include <variant>
+
+namespace py = pybind11;
+using namespace py::literals;
+using Mx = Eigen::Array<uint8_t, Eigen::Dynamic, Eigen::Dynamic>;
+using MxView = Eigen::Map<Mx>;
+
+namespace pybind11 {
+
+// Bytes object that supports resizing and the buffer protocol
+class bytearray_ : public buffer {
+  public:
+  PYBIND11_OBJECT_CVT(bytearray_, buffer, PyByteArray_Check, PyByteArray_FromObject)
+
+    bytearray_(const char *c, size_t n)
+        : buffer(PyByteArray_FromStringAndSize(c, (ssize_t) n), stolen_t{}) {
+        if (!m_ptr) pybind11_fail("Could not allocate bytes object!");
+    }
+
+    bytearray_()
+        : bytearray_("", 0) {
+    }
+
+    void resize(size_t len) { PyByteArray_Resize(m_ptr, len); }
+
+     size_t size() const { return static_cast<size_t>(PyByteArray_Size(m_ptr)); }
+
+    explicit operator std::string() const {
+        char *buffer = PyByteArray_AS_STRING(m_ptr);
+        ssize_t size = PyByteArray_GET_SIZE(m_ptr);
+        return std::string(buffer, static_cast<size_t>(size));
+    }
+};
+}
+
+template<typename T>
+T value_or(const py::dict & kwargs, const char * key, const T & default_value) {
+    if (kwargs.contains(key)) {
+        return kwargs[key].cast<T>();
+    }
+    return default_value;
+}
+
+charls::interleave_mode interleave_mode(const py::dict & kwargs) {
+    if (!kwargs.contains("interleave")) {
+        return charls::interleave_mode::sample;
+    }
+
+    auto mode = kwargs["interleave"].cast<std::string>();
+    if (mode == "none") {
+        return charls::interleave_mode::none;
+    }
+    if (mode == "sample") {
+        return charls::interleave_mode::sample;
+    }
+    if (mode == "line") {
+        return charls::interleave_mode::line;
+    }
+    throw std::runtime_error(fmt::format(FMT_STRING("interleave mode '{}' not recognized"), mode));
+}
+
+constexpr int32_t clamp(int32_t i, int32_t j, int32_t maxval) {
+    if (i > maxval) {
+        return j;
+    }
+    if (i < j) {
+        return j;
+    }
+    return i;
+}
+
+charls::jpegls_pc_parameters preset_coding_parameters(const py::dict & kwargs, int32_t bits_per_sample, int32_t near) {
+    // Defaults have to be set for all the parameters, or they all need to be set to zero
+    // so we handle missing defaults here - see https://github.com/team-charls/charls/issues/84
+    charls::jpegls_pc_parameters params{0, 0, 0, 0, 0};
+    const std::vector<std::string> keys{ "maxval", "t1", "t2", "t3", "reset" };
+    auto iter = std::find_first_of(
+        kwargs.begin(),
+        kwargs.end(),
+        keys.begin(),
+        keys.end(),
+        []( const auto & param, const auto & key) {
+            return key == std::string(py::str(param.first));
+        });
+
+    if (iter == kwargs.end() && bits_per_sample <= 12) {
+        return params;
+    }
+
+    params.maximum_sample_value = value_or<int32_t>(kwargs, "maxval", std::pow(2, bits_per_sample) - 1);
+    params.reset_value = value_or<int32_t>(kwargs, "reset", 64);
+
+    const int32_t basic_t1 = 3;
+    const int32_t basic_t2 = 7;
+    const int32_t basic_t3 = 21;
+
+    if (params.maximum_sample_value == 255 && near == 0) {
+        params.threshold1 = value_or<int32_t>(kwargs, "t1", basic_t1);
+        params.threshold2 = value_or<int32_t>(kwargs, "t2", basic_t2);
+        params.threshold3 = value_or<int32_t>(kwargs, "t3", basic_t3);
+
+        return params;
+    }
+
+    if (params.maximum_sample_value >= 128) {
+        int32_t factor = (std::min(params.maximum_sample_value, 4095) + 128) / 256;
+        params.threshold1 = value_or<int32_t>(kwargs, "t1", clamp(factor * (basic_t1 - 2) + 2 + 3 * near, near + 1, params.maximum_sample_value));
+        params.threshold2 = value_or<int32_t>(kwargs, "t2", clamp(factor * (basic_t2 - 3) + 3 + 5 * near, params.threshold1, params.maximum_sample_value));
+        params.threshold3 = value_or<int32_t>(kwargs, "t3", clamp(factor * (basic_t3 - 4) + 4 + 7 * near, params.threshold2, params.maximum_sample_value));
+
+        return params;
+    }
+
+    int32_t factor = 256 / (params.maximum_sample_value + 1);
+    params.threshold1 = value_or<int32_t>(kwargs, "t1", clamp(std::max(2, basic_t1 / factor + 3 * near), near + 1, params.maximum_sample_value));
+    params.threshold2 = value_or<int32_t>(kwargs, "t2", clamp(std::max(3, basic_t2 / factor + 5 * near), params.threshold1, params.maximum_sample_value));
+    params.threshold3 = value_or<int32_t>(kwargs, "t3", clamp(std::max(4, basic_t3 / factor + 7 * near), params.threshold2, params.maximum_sample_value));
+
+    return params;
+}
+
+PYBIND11_MODULE(_pycharls, module) {
+    module.doc() = "Python bindings for CharLS using pybind11";
+    py::class_<charls::frame_info>(module, "FrameInfo")
+        .def(py::init())
+        .def_readwrite("width", &charls::frame_info::width)
+        .def_readwrite("height", &charls::frame_info::height)
+        .def_readwrite("bits_per_sample", &charls::frame_info::bits_per_sample)
+        .def_readwrite("component_count", &charls::frame_info::component_count);
+
+    py::enum_<charls::spiff_profile_id>(module, "SpiffProfileId")
+        .value("NotSpecified", charls::spiff_profile_id::none)
+        .value("BiLevelFacsimile", charls::spiff_profile_id::bi_level_facsimile)
+        .value("ContinuousToneBase", charls::spiff_profile_id::continuous_tone_base)
+        .value("ContinuousToneFacsimile", charls::spiff_profile_id::continuous_tone_facsimile)
+        .value("ContinuousToneProgressive", charls::spiff_profile_id::continuous_tone_progressive)
+        .export_values();
+
+    py::enum_<charls::spiff_color_space>(module, "SpiffColorSpace")
+        .value("NotSpecified", charls::spiff_color_space::none)
+        .value("BiLevelBlack", charls::spiff_color_space::bi_level_black)
+        .value("BiLevelWhite", charls::spiff_color_space::bi_level_white)
+        .value("CieLab", charls::spiff_color_space::cie_lab)
+        .value("Cmy", charls::spiff_color_space::cmy)
+        .value("Cmyk", charls::spiff_color_space::cmyk)
+        .value("Grayscale", charls::spiff_color_space::grayscale)
+        .value("PhotoYcc", charls::spiff_color_space::photo_ycc)
+        .value("Rgb", charls::spiff_color_space::rgb)
+        .value("YCbCrItuBt601Rgb", charls::spiff_color_space::ycbcr_itu_bt_601_1_rgb)
+        .value("YCbCrItuBt601Video", charls::spiff_color_space::ycbcr_itu_bt_601_1_video)
+        .value("YCbCrItuBt709Video", charls::spiff_color_space::ycbcr_itu_bt_709_video)
+        .value("Ycck", charls::spiff_color_space::ycck)
+        .export_values();
+
+    py::enum_<charls::spiff_compression_type>(module, "SpiffCompressionType")
+        .value("Uncompressed", charls::spiff_compression_type::uncompressed)
+        .value("Jbig", charls::spiff_compression_type::jbig)
+        .value("Jpeg", charls::spiff_compression_type::jpeg)
+        .value("JpegLs", charls::spiff_compression_type::jpeg_ls)
+        .value("ModifiedHuffman", charls::spiff_compression_type::modified_huffman)
+        .value("ModifiedModifiedRead", charls::spiff_compression_type::modified_modified_read)
+        .value("ModifiedRead", charls::spiff_compression_type::modified_read)
+        .export_values();
+
+    py::enum_<charls::spiff_resolution_units>(module, "SpiffResolutionUnits")
+        .value("AspectRatio", charls::spiff_resolution_units::aspect_ratio)
+        .value("DotsPerInch", charls::spiff_resolution_units::dots_per_inch)
+        .value("DotsPerCentimeter", charls::spiff_resolution_units::dots_per_centimeter)
+        .export_values();
+
+    py::class_<charls::spiff_header>(module, "SpiffHeader")
+        .def(py::init())
+        .def_readwrite("horizontal_resolution", &charls::spiff_header::horizontal_resolution)
+        .def_readwrite("vertical_resolution", &charls::spiff_header::vertical_resolution)
+        .def_readwrite("resolution_units", &charls::spiff_header::resolution_units)
+        .def_readwrite("compression_type", &charls::spiff_header::compression_type)
+        .def_readwrite("bits_per_sample", &charls::spiff_header::bits_per_sample)
+        .def_readwrite("color_space", &charls::spiff_header::color_space)
+        .def_readwrite("width", &charls::spiff_header::width)
+        .def_readwrite("height", &charls::spiff_header::height)
+        .def_readwrite("component_count", &charls::spiff_header::component_count)
+        .def_readwrite("profile_id", &charls::spiff_header::profile_id);
+
+    module.def(
+        "encode",
+        [](const py::buffer &src_buffer, const charls::frame_info &frame_info, const std::optional<charls::spiff_header> & spiff, const py::kwargs &kwargs) {
+            charls::jpegls_encoder encoder;
+
+            auto ilv = frame_info.component_count == 1 ? charls::interleave_mode::none : interleave_mode(kwargs);
+            auto near = value_or<int32_t>(kwargs, "near_lossless", 0);
+            encoder.frame_info(frame_info)
+                .interleave_mode(ilv)
+                .near_lossless(near)
+                .preset_coding_parameters(preset_coding_parameters(kwargs, frame_info.bits_per_sample, near));
+
+            py::bytearray_ destination_buffer;
+            destination_buffer.resize(encoder.estimated_destination_size() * 2);
+            {
+              auto destination_buffer_info = destination_buffer.request();
+              encoder.destination(destination_buffer_info.ptr, destination_buffer_info.size);
+            }
+
+            if (spiff) {
+                  encoder.write_spiff_header(*spiff);
+            }
+
+            auto src_buffer_info = src_buffer.request();
+            if (ilv == charls::interleave_mode::none && frame_info.component_count > 1) {
+                size_t elements_per_component = frame_info.width * frame_info.height;
+                Mx planar_buffer = MxView(
+                    static_cast<uint8_t*>(src_buffer_info.ptr),
+                    frame_info.component_count,
+                    elements_per_component
+                );
+                planar_buffer.transposeInPlace();
+                const auto bytes_encoded = encoder.encode(planar_buffer.data(), planar_buffer.size());
+                destination_buffer.resize(bytes_encoded);
+                return destination_buffer;
+            }
+
+            auto bytes_encoded = encoder.encode(src_buffer_info.ptr, src_buffer_info.size);
+            destination_buffer.resize(bytes_encoded);
+            return destination_buffer;
+        },
+        "encode a buffer to JPEG-LS",
+        py::arg("src_buffer"),
+        py::arg("frame_info"),
+        py::arg("spiff") = py::none()
+        );
+
+    module.def("read_header",
+        [](const py::buffer & src_buffer) -> std::variant<charls::frame_info, charls::spiff_header> {
+            charls::jpegls_decoder decoder;
+            auto src_buffer_info = src_buffer.request();
+            decoder.source(src_buffer_info.ptr, src_buffer_info.size);
+            bool spiff_header_present{};
+            auto header = decoder.read_spiff_header(spiff_header_present);
+            if (!spiff_header_present) {
+                return decoder.read_header().frame_info();
+            }
+
+            return header;
+        },
+        "Read header info from a JPEG-LS stream");
+
+    module.def("decode", [] (const py::buffer & src_buffer) {
+        charls::jpegls_decoder decoder;
+        auto src_buffer_info = src_buffer.request();
+        decoder.source(src_buffer_info.ptr, src_buffer_info.size).read_header();
+        auto frame_info = decoder.frame_info();
+        auto interleave_mode = decoder.interleave_mode();
+
+        py::bytearray_ destination_buffer;
+        destination_buffer.resize(decoder.destination_size());
+        auto destination_buffer_info = destination_buffer.request();
+
+        if (interleave_mode == charls::interleave_mode::none && frame_info.component_count > 1) {
+            size_t elements_per_component = frame_info.width * frame_info.height;
+            Mx planar_buffer(elements_per_component, frame_info.component_count);
+            decoder.decode(planar_buffer.data(), planar_buffer.size());
+            planar_buffer.transposeInPlace();
+            std::copy(planar_buffer.data(), planar_buffer.data() + planar_buffer.size(), static_cast<uint8_t *>(destination_buffer_info.ptr));
+            return destination_buffer;
+        }
+
+        decoder.decode(destination_buffer_info.ptr, destination_buffer_info.size);
+        return destination_buffer;
+    }, "Decode a JPEG-LS stream");
+
+}


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..79d2366
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,30 @@
+import os
+
+import pytest
+
+
+_profile_keyword = "profile"
+
+
+def pytest_addoption(parser):
+    default_data_path = os.path.realpath(os.path.join(os.path.dirname(__file__), "data"))
+    parser.addoption( f"--{_profile_keyword}", action="store_true", default=False, help="run profiling tests" )
+    parser.addoption( "--data", action="store", default=default_data_path, help="data folder location")
+
+
+def pytest_configure(config):
+    config.addinivalue_line("markers", f"{_profile_keyword}: mark test as profiling test")
+
+
+def pytest_collection_modifyitems(config, items):
+    if config.getoption(f"--{_profile_keyword}"):
+        return
+    skip_profiling = pytest.mark.skip(reason="profiling test")
+    for item in items:
+        if _profile_keyword in item.keywords:
+            item.add_marker(skip_profiling)
+
+
+@pytest.fixture
+def data(request):
+    return os.path.realpath(request.config.getoption("--data"))
diff --git a/test/data/.gitattributes b/test/data/.gitattributes
new file mode 100644
index 0000000..a34d326
--- /dev/null
+++ b/test/data/.gitattributes
@@ -0,0 +1,20 @@
+TEST8B.png filter=lfs diff=lfs merge=lfs -text
+TEST8.png filter=lfs diff=lfs merge=lfs -text
+T8C0E0.JLS filter=lfs diff=lfs merge=lfs -text
+T8C1E0.JLS filter=lfs diff=lfs merge=lfs -text
+T8C2E0.JLS filter=lfs diff=lfs merge=lfs -text
+T8NDE3.JLS filter=lfs diff=lfs merge=lfs -text
+T16E3.png filter=lfs diff=lfs merge=lfs -text
+TEST8GR4.png filter=lfs diff=lfs merge=lfs -text
+TEST8R.png filter=lfs diff=lfs merge=lfs -text
+T8C1E3.JLS filter=lfs diff=lfs merge=lfs -text
+TEST8BS2.png filter=lfs diff=lfs merge=lfs -text
+T16E3.JLS filter=lfs diff=lfs merge=lfs -text
+T8C0E3.JLS filter=lfs diff=lfs merge=lfs -text
+T8C2E3.JLS filter=lfs diff=lfs merge=lfs -text
+T8NDE0.JLS filter=lfs diff=lfs merge=lfs -text
+T8SSE0.JLS filter=lfs diff=lfs merge=lfs -text
+T8SSE3.JLS filter=lfs diff=lfs merge=lfs -text
+TEST16.png filter=lfs diff=lfs merge=lfs -text
+TEST8G.png filter=lfs diff=lfs merge=lfs -text
+T16E0.JLS filter=lfs diff=lfs merge=lfs -text
diff --git a/test/data/README_IMG.TXT b/test/data/README_IMG.TXT
new file mode 100644
index 0000000..8828fb6
--- /dev/null
+++ b/test/data/README_IMG.TXT
@@ -0,0 +1,90 @@
+JPEG-LS TEST IMAGES
+===================
+
+The images included in this package constitute the data set used for 
+implementing the JPEG-LS conformance test as described in Section 8
+and Annex E of the JPEG-LS standard document (ITU-T Rec. T.87|ISO/IEC 14495-1).
+
+Test images:
+------------
+(See explanation of PPM and PGM formats below)
+
+TEST8.PPM    - 8-bit color test image (photo+graphics+text+random data).
+TEST8R.PGM   - "red" component of TEST8
+TEST8G.PGM   - "green" component of TEST8
+TEST8B.PGM   - "blue" component of TEST8
+TEST8GR4.PGM - "green" component of TEST8, subsampled 4X in the vertical 
+               direction
+TEST8BS2.PGM - "blue" component of TEST8, subsampled 2X in both directions
+TEST16.PGM   - 16-bit test gray-scale image (actually 12-bit)
+
+Compressed images:
+------------------
+	T8CxEy.JLS  x=0,1,2  y=0,3
+	   Image TEST8 compressed in color mode x with near-lossless error y
+	   (y=0 means lossless).
+
+	T16Ey.JLS            y=0,3
+	   Image TEST16 compressed with near-lossless error y.
+
+	T8SSE0.JLS
+	T8SSE3.JLS
+           Image TEST8 compressed in line-interleaved mode, with near-lossless
+	   error 0 and 3 (resp.), and with color components subsampled 
+	   as follows:
+	   R: not subsampled (256 cols x 256 rows)
+	   G: subsampled 4x in the vertical direction (256 cols x 64 rows)
+	   B: subsampled 2x in both directions (128 cols x 128 rows)
+
+	T8NDE0.JLS
+	T8NDE3.JLS
+           Image TEST8 compressed in line-interleaved mode, with near-lossless
+	   error 0 and 3 (resp.), and with NON-DEFAULT parameters T1=T2=T3=9,
+	   RESET=31.
+
+The compressed images are provided to help testers/developers debug their 
+JPEG-LS implementations. The test images contain a mixture of data that will
+exercise many paths in the compression/decompression algorithms. There is
+no guarantee, however, that every path of the algorithm is tested.
+THE TEST IMAGES SHOULD NOT BE USED TO BENCHMARK COMPRESSION CAPABILITY.
+THEY ARE DESIGNED TO BE "HARD" ON THE JPEG-LS COMPRESSOR.  JPEG-LS
+MAY DO WORSE THAN YOUR FAVORITE COMPRESSOR ON THE TEST IMAGES.
+
+All compressed images have standard JPEG-LS headers (which follow the
+standard JPEG marker syntax).  All compressed images were produced using 
+default parameters, except for T8NDE0.JLS and T8NDE3.JLS as noted above.
+
+
+
+PPM and PGM INPUT IMAGE FORMATS
+-------------------------------
+
+Uncompressed test images images are in either PGM (grayscale) or 
+PPM (3-color) format.  This is of course NOT part of the JPEG-LS 
+standard.
+
+These formats have an ASCII header consisting of 3 lines of the 
+following form
+
+* PGM (single component):
+
+    P5
+    cols rows
+    maxval
+
+* PPM (3 components)
+
+    P6
+    cols rows
+    maxval
+
+    
+For PGM, the header is followed by cols*rows samples in binary 
+format, where cols and rows are the number of columns and rows, 
+respectively.  A test image "cmpnd2g.pgm" is included in the 
+archive. Samples have 8 bits if maxval < 256, or 16 bits if 
+256 <= maxval < 65536. 
+
+For PPM, the header is followed by cols*rows TRIPLETS of symbols in
+binary format. Each symbol in a triplet represents a color component
+value (viewers usually interpret PPM triplets as RGB).
diff --git a/test/data/T16E0.JLS b/test/data/T16E0.JLS
new file mode 100644
index 0000000..df2b33a
--- /dev/null
+++ b/test/data/T16E0.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0169aab6eb839925cc781016e3c3ed19d323fadee99d9747375e787b88e4d23f
+size 60077
diff --git a/test/data/T16E3.JLS b/test/data/T16E3.JLS
new file mode 100644
index 0000000..94be19d
--- /dev/null
+++ b/test/data/T16E3.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e3b7327d232247949bd6aa4520d3a2627bb60c952ff23d700c92900a70863813
+size 42189
diff --git a/test/data/T8C0E0.JLS b/test/data/T8C0E0.JLS
new file mode 100644
index 0000000..6ecf12b
--- /dev/null
+++ b/test/data/T8C0E0.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8c564fbd3a8667bd071cc8d994952fdfae3d62db5c359be4b6d6734e89acea6d
+size 102248
diff --git a/test/data/T8C0E3.JLS b/test/data/T8C0E3.JLS
new file mode 100644
index 0000000..8b880ad
--- /dev/null
+++ b/test/data/T8C0E3.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6356737dbf5168000cebc5e4056e04eb687664cd15797de324fa0845eb407dc3
+size 63645
diff --git a/test/data/T8C1E0.JLS b/test/data/T8C1E0.JLS
new file mode 100644
index 0000000..4abcf5d
--- /dev/null
+++ b/test/data/T8C1E0.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fdd6fa22f94135f7c3db7932da2154aefc79085fec3b3f65da8a62d6964b8078
+size 100615
diff --git a/test/data/T8C1E3.JLS b/test/data/T8C1E3.JLS
new file mode 100644
index 0000000..61dec76
--- /dev/null
+++ b/test/data/T8C1E3.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:be41c9c2687542d452171ae629c76905b7af7073d9db56f9a549b6323df6ed1e
+size 63005
diff --git a/test/data/T8C2E0.JLS b/test/data/T8C2E0.JLS
new file mode 100644
index 0000000..7c870a5
--- /dev/null
+++ b/test/data/T8C2E0.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2cbf1d38b9d186a06ea7b19cc74df6259d238c789f49ed7329a8e34afd6ba5ae
+size 99734
diff --git a/test/data/T8C2E3.JLS b/test/data/T8C2E3.JLS
new file mode 100644
index 0000000..27dfc22
--- /dev/null
+++ b/test/data/T8C2E3.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:df1fa8e1ac3256a2ea226996d27c8bd504a7ca08385674aedf77b6edd42be8de
+size 62300
diff --git a/test/data/T8NDE0.JLS b/test/data/T8NDE0.JLS
new file mode 100644
index 0000000..80f52a8
--- /dev/null
+++ b/test/data/T8NDE0.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c3e1244dfc035626cbdea7a89a8120fde3ae4deb22847695928cfbd5f36884ae
+size 9421
diff --git a/test/data/T8NDE3.JLS b/test/data/T8NDE3.JLS
new file mode 100644
index 0000000..9f32347
--- /dev/null
+++ b/test/data/T8NDE3.JLS
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0597c16d6d60d89f0aa9e71a8fd6bbf982ef1ae22d4b8afc897dafa68efd90e8
+size 6111
diff --git a/test/data/TEST16.png b/test/data/TEST16.png
new file mode 100644
index 0000000..4caaf87
--- /dev/null
+++ b/test/data/TEST16.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0b97afd3116172685db7fa0728551a55af908845da87b3c46abbdff6f117ddd6
+size 74128
diff --git a/test/data/TEST8.png b/test/data/TEST8.png
new file mode 100644
index 0000000..71850be
--- /dev/null
+++ b/test/data/TEST8.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:38758e3189d0b35825084571d30de565c5371836f85603d4fc8cf9932f5c1c96
+size 100654
diff --git a/test/data/TEST8BS2.png b/test/data/TEST8BS2.png
new file mode 100644
index 0000000..b4f17f4
--- /dev/null
+++ b/test/data/TEST8BS2.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6008001a6e1eea17472f57ff1680492eafe94963e3b69dc3b9f93f4470388b7b
+size 10309
diff --git a/test/test_conformance.py b/test/test_conformance.py
new file mode 100644
index 0000000..27f1b02
--- /dev/null
+++ b/test/test_conformance.py
@@ -0,0 +1,128 @@
+from io import BytesIO
+import os
+
+from PIL import Image
+import pytest
+import numpy as np
+
+import pillow_jpls # noqa # pylint: disable=unused-import
+
+# Conformance tests from Annexe E of the `ITU Spec <https://www.itu.int/rec/T-REC-T.87/en>`_.
+# Tests 7 and 8 are omitted as subsampling is not supported by CharLS (and is rarely used)
+
+
+def _encode_to_array(file_name, mode=None, **kwargs):
+    src = Image.open(file_name)
+    if mode is not None:
+        src = src.convert(mode)
+
+    buffer = BytesIO()
+    src.save(buffer, format="JPEG-LS", spiff=None, **kwargs)
+    return np.frombuffer(buffer.getvalue(), dtype=np.uint8).astype(np.int32)
+
+
+def _decode_to_array(file_name, mode=None):
+    image = Image.open(file_name)
+    if image.mode == "I":
+        image.convert("I;16")
+    elif mode is not None:
+        image = image.convert(mode)
+
+    return np.array(image, dtype=np.int32)
+
+
+def _load_encoded_data(file_name):
+    with open(file_name, "rb") as fp:
+        return np.frombuffer(fp.read(), dtype=np.uint8).astype(dtype=np.int32)
+
+
+def assert_array_equal(expected: np.ndarray, actual: np.ndarray, epsilon=0):
+    assert np.linalg.norm(expected.ravel() - actual.ravel(), ord=np.inf) <= epsilon
+
+
+def test_01_compress_lossless_no_interleave(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST8.png"), interleave="none")
+    expected_data = _load_encoded_data(os.path.join(data, "T8C0E0.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_02_compress_lossless_line_interleave(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST8.png"), interleave="line")
+    expected_data = _load_encoded_data(os.path.join(data, "T8C1E0.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_03_compress_lossless_sample_interleave(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST8.png"))
+    expected_data = _load_encoded_data(os.path.join(data, "T8C2E0.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_04_compress_lossy_no_interleave(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST8.png"), near_lossless=3, interleave="none")
+    expected_data = _load_encoded_data(os.path.join(data, "T8C0E3.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_05_compress_lossy_line_interleave(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST8.png"), near_lossless=3, interleave="line")
+    expected_data = _load_encoded_data(os.path.join(data, "T8C1E3.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_06_compress_lossy_sample_interleave(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST8.png"), near_lossless=3)
+    expected_data = _load_encoded_data(os.path.join(data, "T8C2E3.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_09_compress_lossless_custom_threshold(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST8BS2.png"), t1=9, t2=9, t3=9, reset=31)
+    expected_data = _load_encoded_data(os.path.join(data, "T8NDE0.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_10_compress_lossy_custom_threshold(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST8BS2.png"), near_lossless=3, t1=9, t2=9, t3=9, reset=31)
+    expected_data = _load_encoded_data(os.path.join(data, "T8NDE3.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_11_compress_lossless_16(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST16.png"), mode="I;16", bits_per_sample=12)
+    expected_data = _load_encoded_data(os.path.join(data, "T16E0.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+def test_12_compress_lossy_16(data):
+    encoded_data = _encode_to_array(os.path.join(data, "TEST16.png"), mode="I;16", near_lossless=3, bits_per_sample=12)
+    expected_data = _load_encoded_data(os.path.join(data, "T16E3.JLS"))
+
+    assert_array_equal(expected_data, encoded_data)
+
+
+@pytest.mark.parametrize("encoded, decoded, epsilon", [
+    ("T8C0E0.JLS", "TEST8.png", 0),
+    ("T8C1E0.JLS", "TEST8.png", 0),
+    ("T8C2E0.JLS", "TEST8.png", 0),
+    ("T8C0E3.JLS", "TEST8.png", 3),
+    ("T8C1E3.JLS", "TEST8.png", 3),
+    ("T8C2E3.JLS", "TEST8.png", 3),
+    ("T8NDE0.JLS", "TEST8BS2.png", 0),
+    ("T8NDE3.JLS", "TEST8BS2.png", 3),
+    ("T16E0.JLS", "TEST16.png", 0),
+    ("T16E3.JLS", "TEST16.png", 3)])
+def test_decompress(data, encoded, decoded, epsilon):
+    jls_decoded = _decode_to_array(os.path.join(data, encoded))
+    raw_decoded = _decode_to_array(os.path.join(data, decoded))
+
+    assert_array_equal(raw_decoded, jls_decoded, epsilon=epsilon)
diff --git a/test/test_image_formats.py b/test/test_image_formats.py
new file mode 100644
index 0000000..7d33abb
--- /dev/null
+++ b/test/test_image_formats.py
@@ -0,0 +1,68 @@
+from io import BytesIO
+
+from PIL import Image
+import numpy as np
+import pytest
+
+from pillow_jpls import (
+    SpiffProfileId,
+    SpiffResolutionUnits,
+    SpiffColorSpace,
+    SpiffCompressionType
+)
+
+_rng = np.random.default_rng()
+
+
+def test_bilevel_image():
+    width = 300
+    height = 200
+    data = _rng.integers(2, size=(height, width), dtype=np.uint8)
+    buffer = BytesIO()
+    Image.fromarray(data * 255, mode="L").convert("1").save(buffer, format="JPEG-LS")
+
+    buffer.seek(0)
+    decoded_data = np.array(Image.open(buffer)).astype(np.uint8)
+
+    assert np.linalg.norm(decoded_data - data, ord=np.inf) == 0
+
+
+@pytest.mark.parametrize("shape, bits, maxval, dtype, mode, colorspace",[
+    ((42, 23), 8, 255, np.uint8, "L", SpiffColorSpace.Grayscale),
+    ((18, 6), 8, 255, np.uint8, "P", SpiffColorSpace.Grayscale),
+    ((8, 7, 3), 8, 255, np.uint8, "RGB", SpiffColorSpace.Rgb),
+    ((365, 7, 4), 8, 255, np.uint8, "RGBA", SpiffColorSpace.Rgb),
+    ((12, 47, 3), 8, 255, np.uint8, "LAB", SpiffColorSpace.CieLab),
+    ((12, 507, 4), 8, 255, np.uint8, "CMYK", SpiffColorSpace.Cmyk),
+    ((19, 87, 3), 8, 255, np.uint8, "YCbCr", SpiffColorSpace.YCbCrItuBt601Video),
+    ((52, 13), 16, 65535, np.uint16, "I;16", SpiffColorSpace.Grayscale),
+    ((3, 4), 12, 4095, np.uint16, "I;16", SpiffColorSpace.Grayscale)
+])
+def test_encode_with_spiff_header(shape, bits, maxval, dtype, mode, colorspace):
+    width = shape[1]
+    height = shape[0]
+    component_count = 1 if len(shape) == 2 else shape[-1]
+    data = _rng.integers(maxval + 1, size=shape, dtype=dtype)
+
+    buffer = BytesIO()
+    Image.fromarray(data, mode).save(buffer, format="JPEG-LS", bits_per_sample=bits)
+
+    buffer.seek(0)
+    decoded_image = Image.open(buffer)
+    decoded_data = np.array(decoded_image, dtype=dtype)
+
+    expected_header = {
+        "profile_id": SpiffProfileId.NotSpecified,
+        "component_count": component_count,
+        "width": width,
+        "height": height,
+        "color_space": colorspace,
+        "bits_per_sample": max(bits, 2),
+        "compression_type": SpiffCompressionType.JpegLs,
+        "resolution_units": SpiffResolutionUnits.DotsPerInch,
+        "vertical_resolution": 96,
+        "horizontal_resolution": 96
+    }
+
+    assert decoded_image.info == expected_header
+    assert np.linalg.norm(data.ravel() - decoded_data.ravel(), ord=np.inf) == 0
diff --git a/test/test_profile.py b/test/test_profile.py
new file mode 100644
index 0000000..8a2d5e8
--- /dev/null
+++ b/test/test_profile.py
@@ -0,0 +1,23 @@
+import os
+from io import BytesIO
+
+import pillow_jpls # noqa
+from PIL import Image
+import pytest
+import numpy as np
+
+
+@pytest.mark.profile
+def test_roundtrip(data):
+    src = Image.open(os.path.join(data, "artificial.ppm"))
+
+    buffer = BytesIO()
+    src.save(buffer, format="JPEG-LS")
+
+    buffer.seek(0)
+    dest = Image.open(buffer)
+
+    src_data = np.array(src, dtype=np.int32)
+    dest_data = np.array(dest, dtype=np.int32)
+
+    assert np.linalg.norm(src_data.ravel() - dest_data.ravel(), ord=np.inf) == 0