leanjpeg 0.1.0__tar.gz

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.
@@ -0,0 +1,70 @@
1
+ # Changelog
2
+
3
+ All notable changes to the three `leanjpeg` distributions. They share one
4
+ version number: `leanjpeg` pins its backends exactly, and
5
+ `tools/bump_version.py` keeps every location in step.
6
+
7
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
8
+ and the project uses [semantic versioning](https://semver.org/spec/v2.0.0.html).
9
+
10
+ ## [Unreleased]
11
+
12
+ ## [0.1.0] - 2026-09-07
13
+
14
+ First release. Three distributions:
15
+
16
+ ### `leanjpeg`
17
+
18
+ * Pure-Python dispatcher: `sniff`, `is_jpeg`, `is_jxl`, and format-agnostic
19
+ `decode` / `decode_header` / `encode` that route to whichever backend is
20
+ installed, with `BackendNotInstalled` carrying the install command.
21
+ * Backends are optional dependencies, so a deployment pulls in only what it
22
+ uses: `leanjpeg[simple]`, `leanjpeg[xl]`, `leanjpeg[all]`.
23
+
24
+ ### `leanjpeg-simple` (fast path)
25
+
26
+ * Fork of [simplejpeg](https://github.com/jfolz/simplejpeg) 1.9.0 over
27
+ libjpeg-turbo 3.2.0, statically linked, with an unchanged API.
28
+ * **Free-threaded CPython support** (3.13t / 3.14t): the extension declares
29
+ `Py_MOD_GIL_NOT_USED`, has no unsynchronised global state and releases the
30
+ GIL around all codec work. Upstream simplejpeg re-enables the GIL on import.
31
+ * **Reduced allocations for repeated calls**: pooled TurboJPEG handles and a
32
+ high-water-mark output buffer, so steady-state calls allocate only the object
33
+ they return. 6-30 % faster encode than upstream in an interleaved
34
+ single-process A/B, the margin growing as images get smaller.
35
+ * Added: `handle_pool_stats()`, `get_handle_pool_size()`,
36
+ `set_handle_pool_size()`, `clear_handle_pool()`, `libjpeg_turbo_version()`.
37
+ * Two fixes on top of the unchanged upstream API (see `UPSTREAM.md`):
38
+ `decode_jpeg_header` names the subsamplings libjpeg-turbo 3.x added after
39
+ 4:1:1 instead of raising `KeyError`, and empty inputs raise a `ValueError`
40
+ that names the problem instead of being indexed out of range.
41
+
42
+ ### `leanjpeg-xl` (offline path)
43
+
44
+ * Light binding over [libjxl](https://github.com/libjxl/libjxl) 0.12.0,
45
+ statically linked, mirroring the simplejpeg API: `encode_jxl`, `decode_jxl`,
46
+ `decode_jxl_header`, `is_jxl`, including colorspace names and `buffer=`.
47
+ * Lossy (`quality` / `distance` / `effort`) and mathematically lossless modes;
48
+ `uint8`, `uint16` and `float32` samples; alpha.
49
+ * **Bit-exact lossless JPEG recompression**: `recompress_jpeg()` /
50
+ `reconstruct_jpeg()` store the `jbrd` reconstruction box, about 20 % smaller
51
+ and byte-for-byte reversible.
52
+ * Explicit threading: `num_threads=None` (libjxl's own suggestion, roughly one
53
+ thread per 256x256 group), `1`, or a fixed count, plus `set_max_threads()`.
54
+ * Pooled codecs, thread pools and buffers; GIL released around all libjxl work;
55
+ free-threading compatible.
56
+
57
+ ### Packaging
58
+
59
+ * **PyInstaller support out of the box**: each distribution ships a hook and
60
+ registers it through the `pyinstaller40` entry point, so `pyinstaller app.py`
61
+ bundles a working application with no flags. Without them a frozen app finds
62
+ no backends at all -- they are resolved through `importlib` -- and the
63
+ compiled extensions' NumPy import is invisible to static analysis.
64
+ * Wheels for CPython 3.13, 3.13t, 3.14 and 3.14t on manylinux and musllinux
65
+ (x86_64, aarch64), macOS (x86_64, arm64) and Windows (AMD64, ARM64).
66
+ * Source distributions carry the vendored libjpeg-turbo and libjxl sources, so
67
+ they build without network access or a git checkout.
68
+
69
+ [Unreleased]: https://github.com/vxlk/leanjpeg/compare/v0.1.0...HEAD
70
+ [0.1.0]: https://github.com/vxlk/leanjpeg/releases/tag/v0.1.0
leanjpeg-0.1.0/LICENSE ADDED
@@ -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,14 @@
1
+ # The root distribution is the pure-Python dispatcher. Its tests exercise both
2
+ # backends, so they are shipped together with the small helpers they import;
3
+ # everything that is repository-only (benchmarks, generated charts, vendored
4
+ # submodules, the two backend packages) stays out of the sdist.
5
+ include LICENSE README.md CHANGELOG.md
6
+ recursive-include src/leanjpeg *.py py.typed
7
+ recursive-include tests *.py
8
+ recursive-include tools *.py
9
+ prune docs
10
+ prune bench
11
+ prune packages
12
+ prune third_party
13
+ prune .github
14
+ global-exclude __pycache__ *.py[cod]
@@ -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
+ ![Fast path vs offline path](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/fast_vs_offline.png)
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
+ ![JPEG XL encoder effort](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jxl_effort.png)
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
+ ![JPEG throughput, single thread](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jpeg_single_thread.png)
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
+ ![JPEG throughput vs Python threads](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jpeg_threads.png)
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
+ ![JPEG XL threading](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jxl_threads.png)
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
+ ![JPEG vs JPEG XL crops](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/quality/broadcast_news_720p_crops.png)
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
+ ![JPEG to JPEG XL recompression](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jpeg_recompress.png)
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.