leanjpeg 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leanjpeg/__init__.py +188 -0
- leanjpeg/__pyinstaller/__init__.py +13 -0
- leanjpeg/__pyinstaller/hook-leanjpeg.py +29 -0
- leanjpeg/py.typed +0 -0
- leanjpeg/simple/__init__.py +16 -0
- leanjpeg/xl/__init__.py +16 -0
- leanjpeg-0.1.0.dist-info/METADATA +549 -0
- leanjpeg-0.1.0.dist-info/RECORD +12 -0
- leanjpeg-0.1.0.dist-info/WHEEL +5 -0
- leanjpeg-0.1.0.dist-info/entry_points.txt +2 -0
- leanjpeg-0.1.0.dist-info/licenses/LICENSE +33 -0
- leanjpeg-0.1.0.dist-info/top_level.txt +1 -0
leanjpeg/__init__.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""leanjpeg: lean JPEG and JPEG XL encoding/decoding for NumPy.
|
|
2
|
+
|
|
3
|
+
Two independently installable backends live behind this package:
|
|
4
|
+
|
|
5
|
+
``leanjpeg.simple``
|
|
6
|
+
The *fast path*: a fork of simplejpeg (https://github.com/jfolz/simplejpeg,
|
|
7
|
+
libjpeg-turbo) with free-threaded CPython (3.13t/3.14t) support and pooled
|
|
8
|
+
codec handles / output buffers so that repeated calls do not allocate.
|
|
9
|
+
Distribution: ``leanjpeg-simple`` (``pip install "leanjpeg[simple]"``).
|
|
10
|
+
|
|
11
|
+
``leanjpeg.xl``
|
|
12
|
+
The *offline path*: a light binding over libjxl (https://github.com/libjxl/libjxl)
|
|
13
|
+
mirroring the simplejpeg API, with lossy/lossless encoding, decoding and
|
|
14
|
+
bit-exact lossless JPEG -> JPEG XL recompression.
|
|
15
|
+
Distribution: ``leanjpeg-xl`` (``pip install "leanjpeg[xl]"``).
|
|
16
|
+
|
|
17
|
+
This root package only contains pure-Python glue: backend discovery,
|
|
18
|
+
format sniffing and a tiny format-agnostic ``decode``/``encode`` dispatcher.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
import importlib.util
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"BackendNotInstalled",
|
|
29
|
+
"backends",
|
|
30
|
+
"available_backends",
|
|
31
|
+
"require_backend",
|
|
32
|
+
"is_jpeg",
|
|
33
|
+
"is_jxl",
|
|
34
|
+
"sniff",
|
|
35
|
+
"decode",
|
|
36
|
+
"decode_header",
|
|
37
|
+
"encode",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: backend name -> importable distribution module
|
|
41
|
+
_BACKEND_MODULES = {"simple": "leanjpeg_simple", "xl": "leanjpeg_xl"}
|
|
42
|
+
_INSTALL_HINTS = {
|
|
43
|
+
"simple": 'pip install "leanjpeg[simple]" (distribution: leanjpeg-simple)',
|
|
44
|
+
"xl": 'pip install "leanjpeg[xl]" (distribution: leanjpeg-xl)',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
JPEG_SOI = b"\xff\xd8"
|
|
48
|
+
JPEG_EOI = b"\xff\xd9"
|
|
49
|
+
JXL_CODESTREAM_SIGNATURE = b"\xff\x0a"
|
|
50
|
+
JXL_CONTAINER_SIGNATURE = b"\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BackendNotInstalled(ImportError):
|
|
54
|
+
"""Raised when a leanjpeg backend is used but its distribution is missing."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, backend: str, cause: BaseException | None = None):
|
|
57
|
+
hint = _INSTALL_HINTS.get(backend, f"pip install leanjpeg-{backend}")
|
|
58
|
+
msg = f"leanjpeg.{backend} is not installed. Install it with: {hint}"
|
|
59
|
+
if cause is not None:
|
|
60
|
+
msg += f" (import failed with: {cause!r})"
|
|
61
|
+
super().__init__(msg)
|
|
62
|
+
self.backend = backend
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def backends() -> dict[str, bool]:
|
|
66
|
+
"""Return ``{'simple': bool, 'xl': bool}`` describing installed backends.
|
|
67
|
+
|
|
68
|
+
Only checks importability of the distribution modules (cheap, no import).
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
name: importlib.util.find_spec(module) is not None
|
|
72
|
+
for name, module in _BACKEND_MODULES.items()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def available_backends() -> list[str]:
|
|
77
|
+
"""Names of the installed backends, in preference order (``simple`` first)."""
|
|
78
|
+
return [name for name, ok in backends().items() if ok]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def require_backend(name: str):
|
|
82
|
+
"""Import and return the backend module ``leanjpeg.<name>``.
|
|
83
|
+
|
|
84
|
+
Raises :class:`BackendNotInstalled` with an install hint when missing.
|
|
85
|
+
"""
|
|
86
|
+
if name not in _BACKEND_MODULES:
|
|
87
|
+
raise ValueError(f"unknown backend {name!r}; expected one of {sorted(_BACKEND_MODULES)}")
|
|
88
|
+
try:
|
|
89
|
+
return importlib.import_module(f"leanjpeg.{name}")
|
|
90
|
+
except ImportError as e: # pragma: no cover - depends on installation
|
|
91
|
+
if isinstance(e, BackendNotInstalled):
|
|
92
|
+
raise
|
|
93
|
+
raise BackendNotInstalled(name, e) from e
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _head_tail(data, n: int = 12) -> tuple[bytes, bytes]:
|
|
97
|
+
"""First ``n`` and last 2 bytes of any bytes-like / buffer-protocol object."""
|
|
98
|
+
if isinstance(data, (bytes, bytearray)):
|
|
99
|
+
return bytes(data[:n]), bytes(data[-2:])
|
|
100
|
+
mv = memoryview(data)
|
|
101
|
+
if mv.ndim != 1 or mv.itemsize != 1:
|
|
102
|
+
mv = mv.cast("B")
|
|
103
|
+
return bytes(mv[:n]), bytes(mv[-2:])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_jpeg(data) -> bool:
|
|
107
|
+
"""True if ``data`` looks like a complete JPEG (JFIF/Exif) file.
|
|
108
|
+
|
|
109
|
+
Mirrors ``simplejpeg.is_jpeg``: checks the SOI marker at the start and
|
|
110
|
+
the EOI marker at the end, so truncated files return False.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
head, tail = _head_tail(data)
|
|
114
|
+
except TypeError:
|
|
115
|
+
return False
|
|
116
|
+
return head[:2] == JPEG_SOI and tail == JPEG_EOI
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_jxl(data) -> bool:
|
|
120
|
+
"""True if ``data`` starts with a JPEG XL codestream or container signature."""
|
|
121
|
+
try:
|
|
122
|
+
head, _ = _head_tail(data)
|
|
123
|
+
except TypeError:
|
|
124
|
+
return False
|
|
125
|
+
return head.startswith(JXL_CODESTREAM_SIGNATURE) or head.startswith(JXL_CONTAINER_SIGNATURE)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def sniff(data) -> str | None:
|
|
129
|
+
"""Return ``'jpeg'``, ``'jxl'`` or ``None`` based on the signature bytes."""
|
|
130
|
+
try:
|
|
131
|
+
head, _ = _head_tail(data)
|
|
132
|
+
except TypeError:
|
|
133
|
+
return None
|
|
134
|
+
if head[:2] == JPEG_SOI:
|
|
135
|
+
return "jpeg"
|
|
136
|
+
if head.startswith(JXL_CODESTREAM_SIGNATURE) or head.startswith(JXL_CONTAINER_SIGNATURE):
|
|
137
|
+
return "jxl"
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def decode(data, colorspace: str = "RGB", **kwargs):
|
|
142
|
+
"""Decode JPEG or JPEG XL bytes to a ``(height, width, channels)`` array.
|
|
143
|
+
|
|
144
|
+
The format is sniffed from the signature and dispatched to
|
|
145
|
+
``leanjpeg.simple.decode_jpeg`` or ``leanjpeg.xl.decode_jxl``.
|
|
146
|
+
Keyword arguments are passed through to the backend function.
|
|
147
|
+
"""
|
|
148
|
+
kind = sniff(data)
|
|
149
|
+
if kind == "jpeg":
|
|
150
|
+
return require_backend("simple").decode_jpeg(data, colorspace, **kwargs)
|
|
151
|
+
if kind == "jxl":
|
|
152
|
+
return require_backend("xl").decode_jxl(data, colorspace, **kwargs)
|
|
153
|
+
raise ValueError("data is neither JPEG nor JPEG XL (unknown signature)")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def decode_header(data, **kwargs):
|
|
157
|
+
"""Header-only decode; returns ``(height, width, colorspace, ...)`` tuples.
|
|
158
|
+
|
|
159
|
+
Dispatches to ``decode_jpeg_header`` / ``decode_jxl_header``. The first
|
|
160
|
+
three tuple elements have the same meaning for both backends.
|
|
161
|
+
"""
|
|
162
|
+
kind = sniff(data)
|
|
163
|
+
if kind == "jpeg":
|
|
164
|
+
return require_backend("simple").decode_jpeg_header(data, **kwargs)
|
|
165
|
+
if kind == "jxl":
|
|
166
|
+
return require_backend("xl").decode_jxl_header(data, **kwargs)
|
|
167
|
+
raise ValueError("data is neither JPEG nor JPEG XL (unknown signature)")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def encode(image, format: str = "jpeg", **kwargs) -> bytes:
|
|
171
|
+
"""Encode an array with the backend selected by ``format`` (``'jpeg'``/``'jxl'``)."""
|
|
172
|
+
fmt = format.lower()
|
|
173
|
+
if fmt in ("jpeg", "jpg"):
|
|
174
|
+
return require_backend("simple").encode_jpeg(image, **kwargs)
|
|
175
|
+
if fmt in ("jxl", "jpegxl", "jpeg-xl", "jpeg xl"):
|
|
176
|
+
return require_backend("xl").encode_jxl(image, **kwargs)
|
|
177
|
+
raise ValueError(f"unknown format {format!r}; expected 'jpeg' or 'jxl'")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def __getattr__(name: str):
|
|
181
|
+
# Lazy access: ``import leanjpeg; leanjpeg.simple`` / ``leanjpeg.xl``
|
|
182
|
+
if name in _BACKEND_MODULES:
|
|
183
|
+
return require_backend(name)
|
|
184
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def __dir__():
|
|
188
|
+
return sorted(set(globals()) | set(_BACKEND_MODULES))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""PyInstaller hooks shipped with the ``leanjpeg`` distribution.
|
|
2
|
+
|
|
3
|
+
PyInstaller finds this directory through the ``pyinstaller40`` entry point in
|
|
4
|
+
``pyproject.toml``, so freezing an application that uses leanjpeg needs no
|
|
5
|
+
configuration and no hook from pyinstaller-hooks-contrib.
|
|
6
|
+
|
|
7
|
+
Nothing here is imported at runtime; it only runs inside PyInstaller.
|
|
8
|
+
"""
|
|
9
|
+
import os.path as pt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_hook_dirs():
|
|
13
|
+
return [pt.dirname(__file__)]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""PyInstaller hook for the ``leanjpeg`` root package.
|
|
2
|
+
|
|
3
|
+
The root package resolves its backends dynamically: ``backends()`` asks
|
|
4
|
+
``importlib.util.find_spec`` and ``require_backend()`` calls
|
|
5
|
+
``importlib.import_module('leanjpeg.<name>')``. Neither spelling is visible to
|
|
6
|
+
a static analysis, so an unhooked build collects neither shim, every backend
|
|
7
|
+
reports as missing, and the first call raises ``BackendNotInstalled`` -- on a
|
|
8
|
+
machine where both backends are installed.
|
|
9
|
+
|
|
10
|
+
Only installed backends are added. A hidden import for a distribution that is
|
|
11
|
+
not there is a build warning, and a frozen app reporting ``{'xl': False}`` for
|
|
12
|
+
a backend the developer never installed is the right answer rather than a bug.
|
|
13
|
+
To leave an installed backend out of a bundle on purpose, exclude its shim:
|
|
14
|
+
|
|
15
|
+
pyinstaller --exclude-module leanjpeg.xl app.py
|
|
16
|
+
|
|
17
|
+
The backends' own hooks (shipped with ``leanjpeg-simple`` / ``leanjpeg-xl``)
|
|
18
|
+
take it from there.
|
|
19
|
+
"""
|
|
20
|
+
from PyInstaller.utils.hooks import can_import_module
|
|
21
|
+
|
|
22
|
+
hiddenimports = [
|
|
23
|
+
shim
|
|
24
|
+
for shim, distribution in (
|
|
25
|
+
('leanjpeg.simple', 'leanjpeg_simple'),
|
|
26
|
+
('leanjpeg.xl', 'leanjpeg_xl'),
|
|
27
|
+
)
|
|
28
|
+
if can_import_module(distribution)
|
|
29
|
+
]
|
leanjpeg/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""leanjpeg.simple: the fast path (libjpeg-turbo via the simplejpeg fork).
|
|
2
|
+
|
|
3
|
+
This module re-exports everything from the ``leanjpeg_simple`` distribution.
|
|
4
|
+
Install it with ``pip install "leanjpeg[simple]"``.
|
|
5
|
+
"""
|
|
6
|
+
try:
|
|
7
|
+
import leanjpeg_simple as _backend
|
|
8
|
+
except ImportError as _e: # pragma: no cover - depends on installation
|
|
9
|
+
from leanjpeg import BackendNotInstalled as _BackendNotInstalled
|
|
10
|
+
|
|
11
|
+
raise _BackendNotInstalled("simple", _e) from _e
|
|
12
|
+
|
|
13
|
+
from leanjpeg_simple import * # noqa: F401,F403
|
|
14
|
+
from leanjpeg_simple import __all__, __version__, __upstream_version__ # noqa: F401
|
|
15
|
+
|
|
16
|
+
__backend__ = _backend
|
leanjpeg/xl/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""leanjpeg.xl: the offline path (JPEG XL via libjxl).
|
|
2
|
+
|
|
3
|
+
This module re-exports everything from the ``leanjpeg_xl`` distribution.
|
|
4
|
+
Install it with ``pip install "leanjpeg[xl]"``.
|
|
5
|
+
"""
|
|
6
|
+
try:
|
|
7
|
+
import leanjpeg_xl as _backend
|
|
8
|
+
except ImportError as _e: # pragma: no cover - depends on installation
|
|
9
|
+
from leanjpeg import BackendNotInstalled as _BackendNotInstalled
|
|
10
|
+
|
|
11
|
+
raise _BackendNotInstalled("xl", _e) from _e
|
|
12
|
+
|
|
13
|
+
from leanjpeg_xl import * # noqa: F401,F403
|
|
14
|
+
from leanjpeg_xl import __all__, __version__, __libjxl_version__ # noqa: F401
|
|
15
|
+
|
|
16
|
+
__backend__ = _backend
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: leanjpeg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lean, allocation-conscious JPEG and JPEG XL encoding/decoding for NumPy: a simplejpeg fork (fast path) and a libjxl binding (offline path), free-threading ready.
|
|
5
|
+
Author: leanjpeg contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/vxlk/leanjpeg
|
|
8
|
+
Project-URL: Source, https://github.com/vxlk/leanjpeg
|
|
9
|
+
Project-URL: Issues, https://github.com/vxlk/leanjpeg/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/vxlk/leanjpeg/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: jpeg,jpeg xl,jxl,libjpeg-turbo,libjxl,numpy,free-threading
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
24
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
25
|
+
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.13
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Provides-Extra: simple
|
|
31
|
+
Requires-Dist: leanjpeg-simple==0.1.0; extra == "simple"
|
|
32
|
+
Provides-Extra: xl
|
|
33
|
+
Requires-Dist: leanjpeg-xl==0.1.0; extra == "xl"
|
|
34
|
+
Provides-Extra: all
|
|
35
|
+
Requires-Dist: leanjpeg-simple==0.1.0; extra == "all"
|
|
36
|
+
Requires-Dist: leanjpeg-xl==0.1.0; extra == "all"
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
39
|
+
Requires-Dist: numpy>=2.1; extra == "test"
|
|
40
|
+
Requires-Dist: pillow>=11; extra == "test"
|
|
41
|
+
Provides-Extra: bench
|
|
42
|
+
Requires-Dist: numpy>=2.1; extra == "bench"
|
|
43
|
+
Requires-Dist: pillow>=11; extra == "bench"
|
|
44
|
+
Requires-Dist: matplotlib>=3.9; extra == "bench"
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: leanjpeg[bench,test]; extra == "dev"
|
|
47
|
+
Requires-Dist: cython>=3.1; extra == "dev"
|
|
48
|
+
Requires-Dist: setuptools>=77; extra == "dev"
|
|
49
|
+
Requires-Dist: wheel; extra == "dev"
|
|
50
|
+
Requires-Dist: build; extra == "dev"
|
|
51
|
+
Requires-Dist: cibuildwheel>=3.2; extra == "dev"
|
|
52
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
53
|
+
Requires-Dist: validate-pyproject[all]; extra == "dev"
|
|
54
|
+
Dynamic: license-file
|
|
55
|
+
|
|
56
|
+
# leanjpeg
|
|
57
|
+
|
|
58
|
+
JPEG and JPEG XL for NumPy arrays, in two independently installable backends:
|
|
59
|
+
a **fast path** for real-time work and an **offline path** for archival.
|
|
60
|
+
|
|
61
|
+
| backend | codec | distribution | built for |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| `leanjpeg.simple` | libjpeg-turbo 3.2.0 | `leanjpeg-simple` | decode/encode in the hot loop: video frames, dataloaders, servers |
|
|
64
|
+
| `leanjpeg.xl` | libjxl 0.12.0 | `leanjpeg-xl` | smaller files offline: lossy at higher quality-per-byte, lossless, and **bit-exact JPEG recompression** |
|
|
65
|
+
|
|
66
|
+
`leanjpeg.simple` is a fork of [simplejpeg](https://github.com/jfolz/simplejpeg)
|
|
67
|
+
with two changes and nothing else: it supports **free-threaded CPython**
|
|
68
|
+
(3.13t / 3.14t), and it **stops allocating** in steady state by pooling codec
|
|
69
|
+
handles and output buffers. `leanjpeg.xl` is a new binding that mirrors the
|
|
70
|
+
same API over libjxl. Both statically link their codec, release the GIL around
|
|
71
|
+
all codec work, and are free-threading safe.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import leanjpeg
|
|
75
|
+
|
|
76
|
+
leanjpeg.available_backends() # ['simple', 'xl']
|
|
77
|
+
img = leanjpeg.decode(data) # sniffs JPEG vs JPEG XL, dispatches
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Install
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install "leanjpeg[simple]" # fast path only
|
|
86
|
+
pip install "leanjpeg[xl]" # offline path only
|
|
87
|
+
pip install "leanjpeg[all]" # both
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`leanjpeg` itself is pure Python (backend discovery, format sniffing, a small
|
|
91
|
+
dispatcher); the extras pull in the compiled distributions. Each backend also
|
|
92
|
+
installs on its own as `leanjpeg-simple` / `leanjpeg-xl` and imports as
|
|
93
|
+
`leanjpeg_simple` / `leanjpeg_xl` without the umbrella package.
|
|
94
|
+
|
|
95
|
+
Wheels are built for **CPython 3.13+**, free-threaded builds included, on
|
|
96
|
+
manylinux and musllinux (x86_64, aarch64), macOS (x86_64, arm64) and Windows
|
|
97
|
+
(AMD64, ARM64) - `cp313`, `cp313t`, `cp314` and `cp314t` for each. Nothing is
|
|
98
|
+
dynamically linked beyond libc, so there is no codec to install alongside.
|
|
99
|
+
Building from source needs CMake >= 3.16, a C/C++17 compiler, and NASM for
|
|
100
|
+
libjpeg-turbo's x86 SIMD kernels; the codecs are git submodules, compiled and
|
|
101
|
+
linked statically. See
|
|
102
|
+
[docs/PACKAGING.md](https://github.com/vxlk/leanjpeg/blob/main/docs/PACKAGING.md)
|
|
103
|
+
for the full matrix and for building or releasing it yourself.
|
|
104
|
+
|
|
105
|
+
A missing backend fails with an actionable error rather than an ImportError
|
|
106
|
+
traceback:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
>>> leanjpeg.xl.encode_jxl(img)
|
|
110
|
+
leanjpeg.BackendNotInstalled: leanjpeg.xl is not installed.
|
|
111
|
+
Install it with: pip install "leanjpeg[xl]" (distribution: leanjpeg-xl)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Quick start
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import numpy as np
|
|
118
|
+
from leanjpeg import simple as sj, xl
|
|
119
|
+
|
|
120
|
+
img = np.zeros((1080, 1920, 3), np.uint8)
|
|
121
|
+
|
|
122
|
+
# --- fast path -----------------------------------------------------------
|
|
123
|
+
jpg = sj.encode_jpeg(img, quality=85, colorsubsampling='420')
|
|
124
|
+
out = sj.decode_jpeg(jpg, colorspace='RGB')
|
|
125
|
+
h, w, colorspace, subsampling = sj.decode_jpeg_header(jpg) # ~3 us
|
|
126
|
+
sj.decode_jpeg(jpg, buffer=out) # decode into your array
|
|
127
|
+
|
|
128
|
+
# --- offline path --------------------------------------------------------
|
|
129
|
+
jxl_lossy = xl.encode_jxl(img, quality=90) # -> distance 1.0
|
|
130
|
+
jxl_lossy = xl.encode_jxl(img, distance=1.5, effort=7)
|
|
131
|
+
jxl_exact = xl.encode_jxl(img, lossless=True)
|
|
132
|
+
out = xl.decode_jxl(jxl_lossy, colorspace='RGB', num_threads=4)
|
|
133
|
+
hdr = xl.decode_jxl_header(jxl_lossy) # height, width, colorspace, ...
|
|
134
|
+
|
|
135
|
+
# --- recompress an existing JPEG, reversibly -----------------------------
|
|
136
|
+
smaller = xl.recompress_jpeg(jpg) # ~20-30 % smaller
|
|
137
|
+
assert xl.reconstruct_jpeg(smaller) == jpg # byte for byte
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The format-agnostic helpers on the root package sniff the signature and
|
|
141
|
+
dispatch: `leanjpeg.decode`, `leanjpeg.decode_header`, `leanjpeg.encode(img,
|
|
142
|
+
format='jxl')`, `leanjpeg.is_jpeg`, `leanjpeg.is_jxl`, `leanjpeg.sniff`.
|
|
143
|
+
|
|
144
|
+
## API
|
|
145
|
+
|
|
146
|
+
`leanjpeg.simple` is API-identical to simplejpeg 1.9.0 — `decode_jpeg`,
|
|
147
|
+
`decode_jpeg_header`, `encode_jpeg`, `encode_jpeg_yuv_planes`, `is_jpeg`, with
|
|
148
|
+
the same arguments, defaults, return types and error messages — so it is a
|
|
149
|
+
drop-in replacement. The fork adds `handle_pool_stats()`,
|
|
150
|
+
`get_handle_pool_size()`, `set_handle_pool_size(n)`, `clear_handle_pool()` and
|
|
151
|
+
`libjpeg_turbo_version()`, and fixes two upstream error paths (an unnamed
|
|
152
|
+
subsampling and empty input both used to raise the wrong thing — see
|
|
153
|
+
[UPSTREAM.md](https://github.com/vxlk/leanjpeg/blob/main/packages/leanjpeg-simple/UPSTREAM.md)).
|
|
154
|
+
|
|
155
|
+
`leanjpeg.xl` mirrors that shape:
|
|
156
|
+
|
|
157
|
+
| `leanjpeg.simple` | `leanjpeg.xl` | differences |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `decode_jpeg(data, colorspace, fastdct, fastupsample, min_height, min_width, min_factor, buffer, strict)` | `decode_jxl(data, colorspace, *, dtype, num_threads, buffer, keep_orientation, unpremultiply_alpha, unscaled)` | same colorspace names and `buffer=` semantics; no DCT-scaled decoding (that is a JPEG-only trick) |
|
|
160
|
+
| `decode_jpeg_header(data)` -> `(h, w, colorspace, subsampling)` | `decode_jxl_header(data)` -> `JxlHeader(height, width, colorspace, bit_depth, has_alpha, has_jpeg_reconstruction, has_container, has_animation, orientation)` | first three fields agree |
|
|
161
|
+
| `encode_jpeg(image, quality, colorspace, colorsubsampling, fastdct)` | `encode_jxl(image, quality, *, distance, lossless, effort, colorspace, decoding_speed, num_threads, use_container, modular, premultiplied_alpha, bits_per_sample)` | `quality` is translated to a libjxl distance (90 -> 1.0); `uint8`, `uint16` and `float32` input |
|
|
162
|
+
| `encode_jpeg_yuv_planes(Y, U, V, ...)` | — | libjxl takes interleaved RGB/gray only |
|
|
163
|
+
| `is_jpeg(data)` | `is_jxl(data)` | |
|
|
164
|
+
| — | `recompress_jpeg`, `reconstruct_jpeg`, `jpeg_dimensions` | lossless JPEG transcoding |
|
|
165
|
+
|
|
166
|
+
Colorspaces on both sides: `RGB`, `BGR`, `RGBX`, `BGRX`, `XBGR`, `XRGB`,
|
|
167
|
+
`RGBA`, `BGRA`, `ABGR`, `ARGB`, `GRAY`, and `GRAYA` on the JPEG XL side.
|
|
168
|
+
|
|
169
|
+
## JPEG XL feature coverage
|
|
170
|
+
|
|
171
|
+
The scope right now is encode/decode parity with the fast path, plus lossless
|
|
172
|
+
and JPEG recompression. Everything below the line is a deliberate omission,
|
|
173
|
+
not a limitation of the design — each is a small addition to
|
|
174
|
+
`_jxl_core.cpp` plus arguments on the existing functions.
|
|
175
|
+
|
|
176
|
+
| feature | status | notes |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| Lossy encode (`distance` / `quality`, `effort` 1-10) | **yes** | `JxlEncoderDistanceFromQuality`; effort maps to `JXL_ENC_FRAME_SETTING_EFFORT` |
|
|
179
|
+
| Lossless encode | **yes** | `lossless=True`, modular mode |
|
|
180
|
+
| Lossless JPEG recompression + bit-exact reconstruction | **yes** | `recompress_jpeg` / `reconstruct_jpeg`, `jbrd` box |
|
|
181
|
+
| Decode to RGB/BGR/RGBA/.../GRAY | **yes** | in-place swizzle, Rec.601 luma for `GRAY` |
|
|
182
|
+
| `uint8` / `uint16` / `float32` samples | **yes** | `dtype=` on decode, dtype-driven on encode |
|
|
183
|
+
| Custom bit depth (10/12/16-bit) | **yes** | `bits_per_sample=` on encode, `unscaled=` on decode |
|
|
184
|
+
| Alpha, premultiplied alpha | **yes** | `premultiplied_alpha=`, `unpremultiply_alpha=` |
|
|
185
|
+
| Output buffer reuse (`buffer=`) | **yes** | zero-allocation decode into your array |
|
|
186
|
+
| Container vs bare codestream | **yes** | `use_container=`; header reports `has_container` |
|
|
187
|
+
| EXIF orientation | **yes** | applied by default, `keep_orientation=True` to skip |
|
|
188
|
+
| Decoding-speed tier, modular toggle | **yes** | `decoding_speed=0..4`, `modular=` |
|
|
189
|
+
| Explicit thread control | **yes** | see [Threading](#threading) |
|
|
190
|
+
| — | | |
|
|
191
|
+
| Progressive / responsive decoding | *not yet* | `JxlDecoderSetProgressiveDetail` + `JxlDecoderFlushImage`; would add a callback or DC-preview API |
|
|
192
|
+
| Downscaled / DC-only decode | *not yet* | 1:8 preview from the DC groups |
|
|
193
|
+
| Region-of-interest decode | *not yet* | `JxlDecoderSetImageOutBuffer` on a crop |
|
|
194
|
+
| Animation (multi-frame) | *not yet* | header already reports `has_animation`; decoding one frame of an animation is unsupported |
|
|
195
|
+
| Extra channels (depth, spot, thermal) | *not yet* | `JxlEncoderSetExtraChannelInfo` |
|
|
196
|
+
| ICC profiles / colour management | *not yet* | currently sRGB in, sRGB out; `JxlEncoderSetICCProfile` / `JxlDecoderGetColorAsICCProfile` |
|
|
197
|
+
| HDR transfer functions (PQ / HLG), gain maps | *not yet* | needs the colour-encoding plumbing above |
|
|
198
|
+
| EXIF / XMP / JUMBF metadata passthrough | *not yet* | boxes are compiled in (`JPEGXL_ENABLE_BOXES=ON`), just not exposed |
|
|
199
|
+
| Streaming / chunked encode | *not yet* | `JXL_ENC_FRAME_SETTING_BUFFERING`, output-mode knobs |
|
|
200
|
+
| CMYK, >4 channels | *not yet* | |
|
|
201
|
+
|
|
202
|
+
JPEG (fast path) is feature-complete against simplejpeg; there is no roadmap
|
|
203
|
+
gap there.
|
|
204
|
+
|
|
205
|
+
## Performance
|
|
206
|
+
|
|
207
|
+
All numbers below come from `bench/` on **Python 3.14.3t (free-threaded)**,
|
|
208
|
+
Windows 10, a 4-core / 8-thread Intel mobile CPU, frames decoded from the
|
|
209
|
+
video fixtures with ffmpeg (nothing vendored). This is a thermally limited
|
|
210
|
+
laptop: read the *ratios*, not the absolute frame rates, and expect run-to-run
|
|
211
|
+
spread of a few tens of percent on the multi-second measurements. See
|
|
212
|
+
[bench/README.md](https://github.com/vxlk/leanjpeg/blob/main/bench/README.md) to reproduce.
|
|
213
|
+
|
|
214
|
+
### Fast path vs offline path
|
|
215
|
+
|
|
216
|
+

|
|
217
|
+
|
|
218
|
+
The two paths are two orders of magnitude apart in encode throughput, which is
|
|
219
|
+
the whole reason there are two of them. At 1080p the fast path encodes at
|
|
220
|
+
**120 fps** and decodes at **81 fps**; libjxl at its cheapest effort encodes at
|
|
221
|
+
**6.6 fps** and decodes at **20 fps**, and buys 0.74-0.81 bpp against JPEG's
|
|
222
|
+
0.78 bpp at the same nominal quality — i.e. at *equal effort settings* the
|
|
223
|
+
sizes are close, and JPEG XL's real advantage shows up as quality per byte
|
|
224
|
+
(below) rather than as raw compression at a fixed quality number.
|
|
225
|
+
|
|
226
|
+
| 1080p, single call | encode | decode | header |
|
|
227
|
+
|---|---|---|---|
|
|
228
|
+
| `leanjpeg.simple`, q85 4:2:0 | 120 fps | 81 fps (96 fps into a reused buffer) | 3.4 µs |
|
|
229
|
+
| `leanjpeg.xl`, effort 1 | 6.6 fps | 20.2 fps | — |
|
|
230
|
+
| `leanjpeg.xl`, effort 3 | 8.5 fps | 19.3 fps | — |
|
|
231
|
+
| `leanjpeg.xl`, effort 5 | 2.4 fps | 20.5 fps | — |
|
|
232
|
+
| `leanjpeg.xl`, effort 7 | 1.2 fps | 18.1 fps | — |
|
|
233
|
+
| `leanjpeg.xl`, lossless effort 5 | 1.4 fps | 5.7 fps | — |
|
|
234
|
+
|
|
235
|
+

|
|
236
|
+
|
|
237
|
+
Effort 3 is the best default for batch work: 7-9 % smaller than effort 1 for
|
|
238
|
+
about 20 % more encode time. Above that the returns stop: effort 5 is another
|
|
239
|
+
0.2-2 % smaller for three to four times the time, and at a *fixed distance*
|
|
240
|
+
effort 7 produced **larger** files than effort 5 on every image tested here —
|
|
241
|
+
+9 to +10 % on the fixtures, +0.4 to +4 % on photographic test images.
|
|
242
|
+
`distance` is a quality target rather than a size target, so this is not
|
|
243
|
+
"effort 7 compresses worse"; a slower encode can spend its bits differently at
|
|
244
|
+
the same nominal quality. It does mean the usual assumption that higher effort
|
|
245
|
+
is strictly smaller does not hold, so measure efforts on your own content
|
|
246
|
+
before paying for them.
|
|
247
|
+
|
|
248
|
+
### The fork against upstream simplejpeg
|
|
249
|
+
|
|
250
|
+
Comparing two separate benchmark processes on a laptop is meaningless — the
|
|
251
|
+
run-to-run spread is larger than the effect. `bench/ab_fork_vs_upstream.py`
|
|
252
|
+
therefore loads **both libraries into one process** and interleaves them pass
|
|
253
|
+
by pass, so drift hits both equally:
|
|
254
|
+
|
|
255
|
+

|
|
256
|
+
|
|
257
|
+
| single thread | encode | decode |
|
|
258
|
+
|---|---|---|
|
|
259
|
+
| 64×64 | **+30 %** (12063 vs 9276 fps) | **+20 %** (15106 vs 12610 fps) |
|
|
260
|
+
| 256×256 | **+13 %** (1218 vs 1075 fps) | +6 % (1172 vs 1105 fps) |
|
|
261
|
+
| 720p | +9 % (108 vs 99 fps) | −2 % (88 vs 91 fps) |
|
|
262
|
+
| 1080p | +21 % (37 vs 30 fps) | −7 % (25 vs 27 fps) |
|
|
263
|
+
| 2160p | +7 % (12.2 vs 11.4 fps) | +6 % (10.2 vs 9.6 fps) |
|
|
264
|
+
|
|
265
|
+
That is exactly the shape the change predicts. Upstream creates and destroys a
|
|
266
|
+
TurboJPEG handle on *every* call and lets TurboJPEG allocate and free the
|
|
267
|
+
output buffer on every encode; the fork keeps both in a pool. The saving is a
|
|
268
|
+
fixed per-call cost, so it dominates on small images (+30 % at 64×64, where
|
|
269
|
+
thumbnails, tiles and patch pipelines live) and fades into the pixel work on
|
|
270
|
+
large ones. Decode saves only the handle, so it sits at parity within noise.
|
|
271
|
+
|
|
272
|
+
Multi-threaded, both scale the same way — the codec releases the GIL either
|
|
273
|
+
way:
|
|
274
|
+
|
|
275
|
+

|
|
276
|
+
|
|
277
|
+
The difference on a free-threaded build is not throughput, it is that
|
|
278
|
+
**importing upstream simplejpeg re-enables the GIL for the entire process**:
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load
|
|
282
|
+
module 'simplejpeg._jpeg', which has not declared that it can run safely
|
|
283
|
+
without the GIL.
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Your JPEG calls still scale, because they release the GIL. Everything *else*
|
|
287
|
+
in your program stops scaling. leanjpeg's backends declare
|
|
288
|
+
`Py_MOD_GIL_NOT_USED` and leave the GIL disabled.
|
|
289
|
+
|
|
290
|
+
### Threading
|
|
291
|
+
|
|
292
|
+
`leanjpeg.simple` has no threading of its own: libjpeg-turbo is single
|
|
293
|
+
threaded per call, and you scale by calling it from several Python threads
|
|
294
|
+
(above) or processes.
|
|
295
|
+
|
|
296
|
+
`leanjpeg.xl` drives libjxl's `JxlResizableParallelRunner` and exposes one
|
|
297
|
+
argument, `num_threads`, on every call:
|
|
298
|
+
|
|
299
|
+
| `num_threads` | behaviour |
|
|
300
|
+
|---|---|
|
|
301
|
+
| `None` / `0` (default) | automatic: `min(SuggestThreads(w, h), get_max_threads())`, at least 1. libjxl suggests about one thread per 256×256 group, capped by the hardware concurrency |
|
|
302
|
+
| `1` | no worker threads at all; everything runs on the calling thread |
|
|
303
|
+
| `N` | exactly `N` threads *including* the caller (`N-1` workers) |
|
|
304
|
+
|
|
305
|
+

|
|
306
|
+
|
|
307
|
+
Encoding scales well inside one call (0.73 → 2.27 fps from 1 to 8 threads at
|
|
308
|
+
1080p, effort 5). Decoding saturates around 4 threads inside one call
|
|
309
|
+
(6.3 → 17.2 fps), and past that you get more from **Python-level** parallelism:
|
|
310
|
+
8 Python threads each calling with `num_threads=1` reach 27.8 fps aggregate
|
|
311
|
+
versus 18.3 fps for one call with `num_threads=8`. The rule of thumb:
|
|
312
|
+
|
|
313
|
+
* one image at a time (interactive, a single large file) → leave `num_threads`
|
|
314
|
+
automatic;
|
|
315
|
+
* many images (a batch, a dataloader, a server) → `num_threads=1` and
|
|
316
|
+
parallelise in Python, otherwise `N` Python threads × `M` libjxl workers
|
|
317
|
+
oversubscribes the machine.
|
|
318
|
+
|
|
319
|
+
`set_max_threads(n)` caps the automatic mode process-wide;
|
|
320
|
+
`suggest_num_threads(h, w)` and `effective_num_threads(h, w, n)` report what
|
|
321
|
+
the decision would be. Encoded bytes never depend on the thread count (there
|
|
322
|
+
is a test for that), so this is purely a performance knob.
|
|
323
|
+
|
|
324
|
+
Related knobs that interact with threading: `effort` (higher efforts add
|
|
325
|
+
sequential phases and gain less from threads) and `modular` (lossless mode
|
|
326
|
+
parallelises per modular group). Deliberately not exposed yet:
|
|
327
|
+
`MODULAR_GROUP_SIZE`, the buffering/output-mode streaming settings, and
|
|
328
|
+
`JxlThreadParallelRunner` (no advantage over the resizable runner here).
|
|
329
|
+
|
|
330
|
+
### Reduced allocations
|
|
331
|
+
|
|
332
|
+
Both backends pool their codec state, so a steady-state call allocates only
|
|
333
|
+
the object it returns. The counters are public — this is from the 1080p
|
|
334
|
+
benchmark run:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
>>> leanjpeg_simple.handle_pool_stats()
|
|
338
|
+
{'acquired': 2904, 'created': 16, 'destroyed': 0, 'scratch_reallocs': 10,
|
|
339
|
+
'cached_compress': 8, 'cached_decompress': 8, 'max_cached': 16,
|
|
340
|
+
'scratch_bytes': 68767744}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
2904 encode/decode calls created **16** TurboJPEG handles (one per concurrent
|
|
344
|
+
caller, then reused) and grew the encoder's output buffer **10** times, after
|
|
345
|
+
which the high-water mark held. `created` and `scratch_reallocs` going flat
|
|
346
|
+
while `acquired` keeps climbing is the property the tests assert.
|
|
347
|
+
`leanjpeg_xl.codec_pool_stats()` reports the same for the JPEG XL codecs
|
|
348
|
+
(encoder + decoder + thread pool + buffers per pooled entry).
|
|
349
|
+
|
|
350
|
+
Pool sizes are tunable — `set_handle_pool_size(n)` / `set_codec_pool_size(n)`,
|
|
351
|
+
and `clear_handle_pool()` / `clear_codec_pool()` to release everything (for
|
|
352
|
+
example before forking or when a long-lived process goes idle).
|
|
353
|
+
|
|
354
|
+
## Image quality
|
|
355
|
+
|
|
356
|
+
Same frame, encoded to the *same file size* by both codecs, so the comparison
|
|
357
|
+
is quality-at-a-budget rather than two different points. libjxl's distance is
|
|
358
|
+
found by bisection until it matches the JPEG's byte count (±2 %); PSNR is on
|
|
359
|
+
RGB, SSIM on luma. Full-resolution originals and the other clips are in
|
|
360
|
+
[`docs/quality/`](https://github.com/vxlk/leanjpeg/tree/main/docs/quality/).
|
|
361
|
+
|
|
362
|
+

|
|
363
|
+
|
|
364
|
+
| clip | JPEG quality | JPEG size | JPEG PSNR / SSIM | JPEG XL distance (same size) | JPEG XL PSNR / SSIM |
|
|
365
|
+
|---|---|---|---|---|---|
|
|
366
|
+
| broadcast_news_720p | 50 | 57.8 KiB (0.51 bpp) | 28.5 dB / 0.976 | 3.00 (57.5 KiB) | **30.4 dB / 0.984** |
|
|
367
|
+
| broadcast_news_720p | 75 | 85.1 KiB (0.76 bpp) | 31.1 dB / 0.987 | 1.67 (85.1 KiB) | **32.6 dB / 0.990** |
|
|
368
|
+
| broadcast_news_720p | 90 | 141.8 KiB (1.26 bpp) | 33.0 dB / 0.994 | 0.76 (139.0 KiB) | **36.5 dB / 0.994** |
|
|
369
|
+
| dense_text_1080p | 50 | 136.8 KiB (0.54 bpp) | 28.0 dB / 0.974 | 3.22 (135.2 KiB) | **29.8 dB / 0.982** |
|
|
370
|
+
| dense_text_1080p | 75 | 202.6 KiB (0.80 bpp) | 30.6 dB / 0.986 | 1.75 (204.5 KiB) | **32.3 dB / 0.990** |
|
|
371
|
+
| dense_text_1080p | 90 | 335.9 KiB (1.33 bpp) | 32.6 dB / 0.994 | 0.79 (335.7 KiB) | **36.3 dB / 0.994** |
|
|
372
|
+
| dashcam_720p | 50 | 58.2 KiB (0.52 bpp) | 28.7 dB / 0.975 | 2.93 (58.1 KiB) | **30.3 dB / 0.984** |
|
|
373
|
+
| dashcam_720p | 75 | 85.9 KiB (0.76 bpp) | 31.2 dB / 0.987 | 1.60 (86.6 KiB) | **32.4 dB / 0.990** |
|
|
374
|
+
| dashcam_720p | 90 | 145.4 KiB (1.29 bpp) | 33.3 dB / 0.994 | 0.69 (144.1 KiB) | **36.2 dB / 0.994** |
|
|
375
|
+
|
|
376
|
+
JPEG XL wins everywhere here, by 1.5-3.5 dB PSNR at the same bytes, with the
|
|
377
|
+
gap widest at high quality. Read that with one caveat: **the fixtures are
|
|
378
|
+
synthetic**. They are OCR test clips — a Mandelbrot render under broadcast,
|
|
379
|
+
dashcam and telemetry text overlays — so they are all hard edges, saturated
|
|
380
|
+
colours and smooth gradients, which is friendly territory for JPEG XL's
|
|
381
|
+
modular tools and hostile to 4:2:0 chroma subsampling. The margin on your
|
|
382
|
+
content will be different.
|
|
383
|
+
|
|
384
|
+
There is no photographic corpus with true (non-JPEG) originals in this
|
|
385
|
+
repository to quote instead, and re-encoding existing JPEGs is not a valid
|
|
386
|
+
substitute: re-encoding a JPEG with JPEG at a matching quality is close to an
|
|
387
|
+
identity operation — measured that way, JPEG scores 54-72 dB PSNR on several
|
|
388
|
+
of simplejpeg's test photos, for reasons that have nothing to do with codec
|
|
389
|
+
quality. Compare on your own originals:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
python bench/quality_compare.py --images a.png,b.png --out docs/quality
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Lossless
|
|
396
|
+
|
|
397
|
+
| clip | frame | PNG (Pillow, optimised) | JPEG XL lossless e7 | JPEG q90 | JPEG q90 → JPEG XL |
|
|
398
|
+
|---|---|---|---|---|---|
|
|
399
|
+
| broadcast_news_720p | 1280×720 | 622.1 KiB | 372.9 KiB (60 % of PNG) | 141.8 KiB | 113.9 KiB (**19.6 % smaller**) |
|
|
400
|
+
| dense_text_1080p | 1920×1080 | 1.40 MiB | 863.2 KiB (60 % of PNG) | 335.9 KiB | 269.9 KiB (**19.6 % smaller**) |
|
|
401
|
+
| dashcam_720p | 1280×720 | 709.0 KiB | 408.7 KiB (58 % of PNG) | 145.4 KiB | 116.8 KiB (**19.7 % smaller**) |
|
|
402
|
+
|
|
403
|
+
## Lossless JPEG recompression
|
|
404
|
+
|
|
405
|
+
The last column above is the feature to reach for if you have a JPEG archive.
|
|
406
|
+
`recompress_jpeg` re-entropy-codes the existing DCT coefficients — the image
|
|
407
|
+
is never decoded to pixels and never re-quantised — and stores a `jbrd` box
|
|
408
|
+
with everything needed to rebuild the original container. `reconstruct_jpeg`
|
|
409
|
+
returns the original file, byte for byte.
|
|
410
|
+
|
|
411
|
+

|
|
412
|
+
|
|
413
|
+
On 720p MJPEG frames (ffmpeg `-q:v 20`): **32.4 % smaller**, 41 fps to
|
|
414
|
+
recompress, 126 fps to reconstruct, and 128 fps to decode straight to pixels
|
|
415
|
+
without materialising the JPEG. Savings depend on how the original was
|
|
416
|
+
encoded — 32 % on those MJPEG frames, ~20 % on Pillow's q90 stills above.
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
jxl = xl.recompress_jpeg(jpeg_bytes)
|
|
420
|
+
assert xl.reconstruct_jpeg(jxl) == jpeg_bytes # bit-exact
|
|
421
|
+
assert xl.decode_jxl_header(jxl).has_jpeg_reconstruction # tells you it is reversible
|
|
422
|
+
pixels = xl.decode_jxl(jxl) # or go straight to pixels
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Round trips are verified bit-exact in the test suite for progressive,
|
|
426
|
+
optimised, 4:4:4 / 4:2:2 / 4:2:0 and grayscale JPEGs, for ffmpeg's MJPEG
|
|
427
|
+
output, and for the 21 photos in simplejpeg's own test corpus when that
|
|
428
|
+
submodule is checked out.
|
|
429
|
+
|
|
430
|
+
## Freezing with PyInstaller
|
|
431
|
+
|
|
432
|
+
All three distributions ship their own PyInstaller hook and advertise it
|
|
433
|
+
through the `pyinstaller40` entry point, so `pyinstaller app.py` just works --
|
|
434
|
+
no `--hidden-import`, no `--collect-all`, nothing from
|
|
435
|
+
pyinstaller-hooks-contrib.
|
|
436
|
+
|
|
437
|
+
The hooks earn their place. Backends are resolved through `importlib`, which
|
|
438
|
+
static analysis cannot follow, so an unhooked bundle silently reports every
|
|
439
|
+
backend as missing:
|
|
440
|
+
|
|
441
|
+
```python
|
|
442
|
+
>>> leanjpeg.backends() # frozen without the hook
|
|
443
|
+
{'simple': False, 'xl': False}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
and the compiled extensions import NumPy from machine code, which PyInstaller
|
|
447
|
+
only notices today because it parses the type stub sitting next to each
|
|
448
|
+
extension. The hooks state both outright.
|
|
449
|
+
|
|
450
|
+
To leave an installed backend out of a bundle, exclude its shim -- `backends()`
|
|
451
|
+
then honestly reports it as absent and `BackendNotInstalled` names it:
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
pyinstaller --exclude-module leanjpeg.xl app.py
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
`tests/test_pyinstaller.py` freezes and runs an application for each entry
|
|
458
|
+
point; it is marked `slow` and skips itself when PyInstaller is absent.
|
|
459
|
+
|
|
460
|
+
## Free-threaded CPython
|
|
461
|
+
|
|
462
|
+
Both extensions declare `Py_MOD_GIL_NOT_USED` (Cython's
|
|
463
|
+
`freethreading_compatible=True`), hold no unprotected global state — the pools
|
|
464
|
+
are lock-protected free lists — and release the GIL around every codec call.
|
|
465
|
+
Importing them on 3.13t / 3.14t leaves `sys._is_gil_enabled()` `False`.
|
|
466
|
+
|
|
467
|
+
```python
|
|
468
|
+
import sys, leanjpeg_simple, leanjpeg_xl
|
|
469
|
+
assert not sys._is_gil_enabled() # on a free-threaded build
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The test suite runs on both build kinds; the free-threading-specific tests
|
|
473
|
+
(concurrent encode/decode from many threads, pool behaviour under contention)
|
|
474
|
+
skip themselves on a GIL build.
|
|
475
|
+
|
|
476
|
+
## Building from source
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
git clone --recurse-submodules https://github.com/vxlk/leanjpeg
|
|
480
|
+
cd leanjpeg
|
|
481
|
+
pip install -e packages/leanjpeg-simple # needs CMake, a C compiler, NASM
|
|
482
|
+
pip install -e packages/leanjpeg-xl # needs CMake >= 3.16, C++17
|
|
483
|
+
pip install -e .
|
|
484
|
+
pytest
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Each package builds its codec out of tree into
|
|
488
|
+
`packages/<name>/build/<codec>_<os>_<arch>/prefix` and links it statically, so
|
|
489
|
+
a rebuild of the Python extension does not rebuild the codec. Source
|
|
490
|
+
distributions carry the vendored codec sources, so `pip install` from an sdist
|
|
491
|
+
needs no network and no git checkout; if the submodule is missing from a git
|
|
492
|
+
tree, `leanjpeg-simple` falls back to downloading a pinned, checksummed
|
|
493
|
+
libjpeg-turbo tarball. On Windows, build from a short path - past 260
|
|
494
|
+
characters cmake fails to detect the compiler.
|
|
495
|
+
|
|
496
|
+
`python tools/build_dists.py --all` builds every distribution for the current
|
|
497
|
+
platform; `.github/workflows/wheels.yml` builds all 64 wheels.
|
|
498
|
+
|
|
499
|
+
## Keeping up with upstream
|
|
500
|
+
|
|
501
|
+
Both codecs are pinned git submodules, and the simplejpeg fork is kept
|
|
502
|
+
mergeable on purpose. `packages/leanjpeg-simple/UPSTREAM.json` records the
|
|
503
|
+
upstream commit and a file-by-file map; `UPSTREAM.md` lists every intentional
|
|
504
|
+
difference. The helper does the routine work:
|
|
505
|
+
|
|
506
|
+
```bash
|
|
507
|
+
python tools/upstream_sync.py status # are we behind upstream?
|
|
508
|
+
python tools/upstream_sync.py diff # what did we change, per file?
|
|
509
|
+
python tools/upstream_sync.py merge # 3-way merge upstream changes into the fork
|
|
510
|
+
python tools/upstream_sync.py pin # record a new upstream commit
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Files the fork did not touch (`_color.c`, `_color.h`, the custom build
|
|
514
|
+
backend) are reported as identical, so a sync is only ever about the handful
|
|
515
|
+
of files that carry the two changes. Upgrading libjpeg-turbo or libjxl is a
|
|
516
|
+
submodule bump plus a version constant.
|
|
517
|
+
|
|
518
|
+
## Tests
|
|
519
|
+
|
|
520
|
+
```bash
|
|
521
|
+
pytest # everything that is installed
|
|
522
|
+
pytest -m "not ffmpeg" # skip the tests that shell out to ffmpeg
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
| suite | covers |
|
|
526
|
+
|---|---|
|
|
527
|
+
| `packages/leanjpeg-simple/tests` | simplejpeg's own decode/encode/YUV/util tests, ported unchanged |
|
|
528
|
+
| `packages/leanjpeg-xl/tests` | codec round trips across colorspaces, dtypes, bit depths, alpha; JPEG recompression; threading invariants |
|
|
529
|
+
| `tests/` | backend discovery and error messages, cross-backend parity, threading, ffmpeg-driven workflows, packaging metadata, PyInstaller freezes |
|
|
530
|
+
|
|
531
|
+
Suites skip themselves when their backend is not installed, so a
|
|
532
|
+
`leanjpeg[simple]`-only install still has a green run. The PyInstaller tests
|
|
533
|
+
build and run real executables, so they are marked `slow` and skip themselves
|
|
534
|
+
where PyInstaller is absent (`pytest -m "not slow"` skips them explicitly). The ffmpeg-marked tests
|
|
535
|
+
locate ffmpeg and the video fixtures via `LEANJPEG_FFMPEG` and
|
|
536
|
+
`LEANJPEG_VIDEOS`, a sibling `video-overlay-ocr` checkout, or `PATH`, and skip
|
|
537
|
+
when none is found.
|
|
538
|
+
|
|
539
|
+
CI runs that suite on Linux, macOS and Windows for 3.13, 3.13t, 3.14 and 3.14t,
|
|
540
|
+
and the release workflow re-runs each backend's suite against every built wheel
|
|
541
|
+
and against both source distributions unpacked outside the git checkout.
|
|
542
|
+
|
|
543
|
+
## Licences
|
|
544
|
+
|
|
545
|
+
leanjpeg is MIT. `leanjpeg-simple` also carries simplejpeg's MIT licence
|
|
546
|
+
(`LICENSE.simplejpeg`) and libjpeg-turbo's two BSD-style licences;
|
|
547
|
+
`leanjpeg-xl` statically links libjxl (BSD-3-Clause) and its dependencies —
|
|
548
|
+
highway (Apache-2.0), brotli (MIT) and skcms (BSD-3-Clause). ffmpeg is **not** vendored — the benchmarks call whatever
|
|
549
|
+
ffmpeg you point them at.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
leanjpeg/__init__.py,sha256=Idg4z1n6elmOo_2-dIOLqLk4cV2dnFFgvwdWnNG02UI,6703
|
|
2
|
+
leanjpeg/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
leanjpeg/__pyinstaller/__init__.py,sha256=78ZF3kg7XsNUofdnQfgnIev8MH1GHNm6PZUuJ41lqNk,430
|
|
4
|
+
leanjpeg/__pyinstaller/hook-leanjpeg.py,sha256=z6LfqT2N4STmQqVN98GF3ArpdWv4pW5ARNDTL69a_ow,1183
|
|
5
|
+
leanjpeg/simple/__init__.py,sha256=mbL1QpsinG5yIsKPiH67kurnkNaj8eZci2pzeaBsvJM,607
|
|
6
|
+
leanjpeg/xl/__init__.py,sha256=7cf_2SJdW7-eyTSUdyqOBlurd4csmh5v2wh2UXJYqwM,561
|
|
7
|
+
leanjpeg-0.1.0.dist-info/licenses/LICENSE,sha256=27SbVUnd2DwRPMFi-IBHBIuZ4aqab2kaRdvcEAFr6-I,1703
|
|
8
|
+
leanjpeg-0.1.0.dist-info/METADATA,sha256=z--OLnN1n0rkmeubIQmvWI3ZzFyr3596IF8khLntvqk,28053
|
|
9
|
+
leanjpeg-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
leanjpeg-0.1.0.dist-info/entry_points.txt,sha256=_u6JVer4A5G6NRzmNgMc9nhxmi7NTUf4vYmCa2CTowI,65
|
|
11
|
+
leanjpeg-0.1.0.dist-info/top_level.txt,sha256=WCtsTq_kPcgN_4XciKychpysEE6BCMxNGedJ-J3ok1w,9
|
|
12
|
+
leanjpeg-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 leanjpeg contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
--------------------------------------------------------------------------------
|
|
24
|
+
Third-party components
|
|
25
|
+
--------------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
leanjpeg-simple is a fork of simplejpeg (MIT License, Copyright (c) 2019
|
|
28
|
+
Joachim Folz) and statically links libjpeg-turbo (IJG License, Modified BSD
|
|
29
|
+
License, and zlib License). See packages/leanjpeg-simple/LICENSE.
|
|
30
|
+
|
|
31
|
+
leanjpeg-xl statically links libjxl (BSD 3-Clause License, Copyright (c) the
|
|
32
|
+
JPEG XL Project Authors), highway (Apache License 2.0), brotli (MIT License)
|
|
33
|
+
and skcms (BSD 3-Clause License). See packages/leanjpeg-xl/LICENSE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
leanjpeg
|