Metadata-Version: 2.4
Name: leanjpeg
Version: 0.1.0
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.
Author: leanjpeg contributors
License-Expression: MIT
Project-URL: Homepage, https://github.com/vxlk/leanjpeg
Project-URL: Source, https://github.com/vxlk/leanjpeg
Project-URL: Issues, https://github.com/vxlk/leanjpeg/issues
Project-URL: Changelog, https://github.com/vxlk/leanjpeg/blob/main/CHANGELOG.md
Keywords: jpeg,jpeg xl,jxl,libjpeg-turbo,libjxl,numpy,free-threading
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
Classifier: Typing :: Typed
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: simple
Requires-Dist: leanjpeg-simple==0.1.0; extra == "simple"
Provides-Extra: xl
Requires-Dist: leanjpeg-xl==0.1.0; extra == "xl"
Provides-Extra: all
Requires-Dist: leanjpeg-simple==0.1.0; extra == "all"
Requires-Dist: leanjpeg-xl==0.1.0; extra == "all"
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: numpy>=2.1; extra == "test"
Requires-Dist: pillow>=11; extra == "test"
Provides-Extra: bench
Requires-Dist: numpy>=2.1; extra == "bench"
Requires-Dist: pillow>=11; extra == "bench"
Requires-Dist: matplotlib>=3.9; extra == "bench"
Provides-Extra: dev
Requires-Dist: leanjpeg[bench,test]; extra == "dev"
Requires-Dist: cython>=3.1; extra == "dev"
Requires-Dist: setuptools>=77; extra == "dev"
Requires-Dist: wheel; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: cibuildwheel>=3.2; extra == "dev"
Requires-Dist: twine>=6; extra == "dev"
Requires-Dist: validate-pyproject[all]; extra == "dev"
Dynamic: license-file

# leanjpeg

JPEG and JPEG XL for NumPy arrays, in two independently installable backends:
a **fast path** for real-time work and an **offline path** for archival.

| backend | codec | distribution | built for |
|---|---|---|---|
| `leanjpeg.simple` | libjpeg-turbo 3.2.0 | `leanjpeg-simple` | decode/encode in the hot loop: video frames, dataloaders, servers |
| `leanjpeg.xl` | libjxl 0.12.0 | `leanjpeg-xl` | smaller files offline: lossy at higher quality-per-byte, lossless, and **bit-exact JPEG recompression** |

`leanjpeg.simple` is a fork of [simplejpeg](https://github.com/jfolz/simplejpeg)
with two changes and nothing else: it supports **free-threaded CPython**
(3.13t / 3.14t), and it **stops allocating** in steady state by pooling codec
handles and output buffers. `leanjpeg.xl` is a new binding that mirrors the
same API over libjxl. Both statically link their codec, release the GIL around
all codec work, and are free-threading safe.

```python
import leanjpeg

leanjpeg.available_backends()          # ['simple', 'xl']
img = leanjpeg.decode(data)            # sniffs JPEG vs JPEG XL, dispatches
```

---

## Install

```bash
pip install "leanjpeg[simple]"    # fast path only
pip install "leanjpeg[xl]"        # offline path only
pip install "leanjpeg[all]"       # both
```

`leanjpeg` itself is pure Python (backend discovery, format sniffing, a small
dispatcher); the extras pull in the compiled distributions. Each backend also
installs on its own as `leanjpeg-simple` / `leanjpeg-xl` and imports as
`leanjpeg_simple` / `leanjpeg_xl` without the umbrella package.

Wheels are built for **CPython 3.13+**, free-threaded builds included, on
manylinux and musllinux (x86_64, aarch64), macOS (x86_64, arm64) and Windows
(AMD64, ARM64) - `cp313`, `cp313t`, `cp314` and `cp314t` for each. Nothing is
dynamically linked beyond libc, so there is no codec to install alongside.
Building from source needs CMake >= 3.16, a C/C++17 compiler, and NASM for
libjpeg-turbo's x86 SIMD kernels; the codecs are git submodules, compiled and
linked statically. See
[docs/PACKAGING.md](https://github.com/vxlk/leanjpeg/blob/main/docs/PACKAGING.md)
for the full matrix and for building or releasing it yourself.

A missing backend fails with an actionable error rather than an ImportError
traceback:

```python
>>> leanjpeg.xl.encode_jxl(img)
leanjpeg.BackendNotInstalled: leanjpeg.xl is not installed.
Install it with: pip install "leanjpeg[xl]"  (distribution: leanjpeg-xl)
```

## Quick start

```python
import numpy as np
from leanjpeg import simple as sj, xl

img = np.zeros((1080, 1920, 3), np.uint8)

# --- fast path -----------------------------------------------------------
jpg = sj.encode_jpeg(img, quality=85, colorsubsampling='420')
out = sj.decode_jpeg(jpg, colorspace='RGB')
h, w, colorspace, subsampling = sj.decode_jpeg_header(jpg)   # ~3 us
sj.decode_jpeg(jpg, buffer=out)                              # decode into your array

# --- offline path --------------------------------------------------------
jxl_lossy = xl.encode_jxl(img, quality=90)          # -> distance 1.0
jxl_lossy = xl.encode_jxl(img, distance=1.5, effort=7)
jxl_exact = xl.encode_jxl(img, lossless=True)
out = xl.decode_jxl(jxl_lossy, colorspace='RGB', num_threads=4)
hdr = xl.decode_jxl_header(jxl_lossy)               # height, width, colorspace, ...

# --- recompress an existing JPEG, reversibly -----------------------------
smaller = xl.recompress_jpeg(jpg)                   # ~20-30 % smaller
assert xl.reconstruct_jpeg(smaller) == jpg          # byte for byte
```

The format-agnostic helpers on the root package sniff the signature and
dispatch: `leanjpeg.decode`, `leanjpeg.decode_header`, `leanjpeg.encode(img,
format='jxl')`, `leanjpeg.is_jpeg`, `leanjpeg.is_jxl`, `leanjpeg.sniff`.

## API

`leanjpeg.simple` is API-identical to simplejpeg 1.9.0 — `decode_jpeg`,
`decode_jpeg_header`, `encode_jpeg`, `encode_jpeg_yuv_planes`, `is_jpeg`, with
the same arguments, defaults, return types and error messages — so it is a
drop-in replacement. The fork adds `handle_pool_stats()`,
`get_handle_pool_size()`, `set_handle_pool_size(n)`, `clear_handle_pool()` and
`libjpeg_turbo_version()`, and fixes two upstream error paths (an unnamed
subsampling and empty input both used to raise the wrong thing — see
[UPSTREAM.md](https://github.com/vxlk/leanjpeg/blob/main/packages/leanjpeg-simple/UPSTREAM.md)).

`leanjpeg.xl` mirrors that shape:

| `leanjpeg.simple` | `leanjpeg.xl` | differences |
|---|---|---|
| `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) |
| `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 |
| `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 |
| `encode_jpeg_yuv_planes(Y, U, V, ...)` | — | libjxl takes interleaved RGB/gray only |
| `is_jpeg(data)` | `is_jxl(data)` | |
| — | `recompress_jpeg`, `reconstruct_jpeg`, `jpeg_dimensions` | lossless JPEG transcoding |

Colorspaces on both sides: `RGB`, `BGR`, `RGBX`, `BGRX`, `XBGR`, `XRGB`,
`RGBA`, `BGRA`, `ABGR`, `ARGB`, `GRAY`, and `GRAYA` on the JPEG XL side.

## JPEG XL feature coverage

The scope right now is encode/decode parity with the fast path, plus lossless
and JPEG recompression. Everything below the line is a deliberate omission,
not a limitation of the design — each is a small addition to
`_jxl_core.cpp` plus arguments on the existing functions.

| feature | status | notes |
|---|---|---|
| Lossy encode (`distance` / `quality`, `effort` 1-10) | **yes** | `JxlEncoderDistanceFromQuality`; effort maps to `JXL_ENC_FRAME_SETTING_EFFORT` |
| Lossless encode | **yes** | `lossless=True`, modular mode |
| Lossless JPEG recompression + bit-exact reconstruction | **yes** | `recompress_jpeg` / `reconstruct_jpeg`, `jbrd` box |
| Decode to RGB/BGR/RGBA/.../GRAY | **yes** | in-place swizzle, Rec.601 luma for `GRAY` |
| `uint8` / `uint16` / `float32` samples | **yes** | `dtype=` on decode, dtype-driven on encode |
| Custom bit depth (10/12/16-bit) | **yes** | `bits_per_sample=` on encode, `unscaled=` on decode |
| Alpha, premultiplied alpha | **yes** | `premultiplied_alpha=`, `unpremultiply_alpha=` |
| Output buffer reuse (`buffer=`) | **yes** | zero-allocation decode into your array |
| Container vs bare codestream | **yes** | `use_container=`; header reports `has_container` |
| EXIF orientation | **yes** | applied by default, `keep_orientation=True` to skip |
| Decoding-speed tier, modular toggle | **yes** | `decoding_speed=0..4`, `modular=` |
| Explicit thread control | **yes** | see [Threading](#threading) |
| — | | |
| Progressive / responsive decoding | *not yet* | `JxlDecoderSetProgressiveDetail` + `JxlDecoderFlushImage`; would add a callback or DC-preview API |
| Downscaled / DC-only decode | *not yet* | 1:8 preview from the DC groups |
| Region-of-interest decode | *not yet* | `JxlDecoderSetImageOutBuffer` on a crop |
| Animation (multi-frame) | *not yet* | header already reports `has_animation`; decoding one frame of an animation is unsupported |
| Extra channels (depth, spot, thermal) | *not yet* | `JxlEncoderSetExtraChannelInfo` |
| ICC profiles / colour management | *not yet* | currently sRGB in, sRGB out; `JxlEncoderSetICCProfile` / `JxlDecoderGetColorAsICCProfile` |
| HDR transfer functions (PQ / HLG), gain maps | *not yet* | needs the colour-encoding plumbing above |
| EXIF / XMP / JUMBF metadata passthrough | *not yet* | boxes are compiled in (`JPEGXL_ENABLE_BOXES=ON`), just not exposed |
| Streaming / chunked encode | *not yet* | `JXL_ENC_FRAME_SETTING_BUFFERING`, output-mode knobs |
| CMYK, >4 channels | *not yet* | |

JPEG (fast path) is feature-complete against simplejpeg; there is no roadmap
gap there.

## Performance

All numbers below come from `bench/` on **Python 3.14.3t (free-threaded)**,
Windows 10, a 4-core / 8-thread Intel mobile CPU, frames decoded from the
video fixtures with ffmpeg (nothing vendored). This is a thermally limited
laptop: read the *ratios*, not the absolute frame rates, and expect run-to-run
spread of a few tens of percent on the multi-second measurements. See
[bench/README.md](https://github.com/vxlk/leanjpeg/blob/main/bench/README.md) to reproduce.

### Fast path vs offline path

![Fast path vs offline path](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/fast_vs_offline.png)

The two paths are two orders of magnitude apart in encode throughput, which is
the whole reason there are two of them. At 1080p the fast path encodes at
**120 fps** and decodes at **81 fps**; libjxl at its cheapest effort encodes at
**6.6 fps** and decodes at **20 fps**, and buys 0.74-0.81 bpp against JPEG's
0.78 bpp at the same nominal quality — i.e. at *equal effort settings* the
sizes are close, and JPEG XL's real advantage shows up as quality per byte
(below) rather than as raw compression at a fixed quality number.

| 1080p, single call | encode | decode | header |
|---|---|---|---|
| `leanjpeg.simple`, q85 4:2:0 | 120 fps | 81 fps (96 fps into a reused buffer) | 3.4 µs |
| `leanjpeg.xl`, effort 1 | 6.6 fps | 20.2 fps | — |
| `leanjpeg.xl`, effort 3 | 8.5 fps | 19.3 fps | — |
| `leanjpeg.xl`, effort 5 | 2.4 fps | 20.5 fps | — |
| `leanjpeg.xl`, effort 7 | 1.2 fps | 18.1 fps | — |
| `leanjpeg.xl`, lossless effort 5 | 1.4 fps | 5.7 fps | — |

![JPEG XL encoder effort](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jxl_effort.png)

Effort 3 is the best default for batch work: 7-9 % smaller than effort 1 for
about 20 % more encode time. Above that the returns stop: effort 5 is another
0.2-2 % smaller for three to four times the time, and at a *fixed distance*
effort 7 produced **larger** files than effort 5 on every image tested here —
+9 to +10 % on the fixtures, +0.4 to +4 % on photographic test images.
`distance` is a quality target rather than a size target, so this is not
"effort 7 compresses worse"; a slower encode can spend its bits differently at
the same nominal quality. It does mean the usual assumption that higher effort
is strictly smaller does not hold, so measure efforts on your own content
before paying for them.

### The fork against upstream simplejpeg

Comparing two separate benchmark processes on a laptop is meaningless — the
run-to-run spread is larger than the effect. `bench/ab_fork_vs_upstream.py`
therefore loads **both libraries into one process** and interleaves them pass
by pass, so drift hits both equally:

![JPEG throughput, single thread](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jpeg_single_thread.png)

| single thread | encode | decode |
|---|---|---|
| 64×64 | **+30 %** (12063 vs 9276 fps) | **+20 %** (15106 vs 12610 fps) |
| 256×256 | **+13 %** (1218 vs 1075 fps) | +6 % (1172 vs 1105 fps) |
| 720p | +9 % (108 vs 99 fps) | −2 % (88 vs 91 fps) |
| 1080p | +21 % (37 vs 30 fps) | −7 % (25 vs 27 fps) |
| 2160p | +7 % (12.2 vs 11.4 fps) | +6 % (10.2 vs 9.6 fps) |

That is exactly the shape the change predicts. Upstream creates and destroys a
TurboJPEG handle on *every* call and lets TurboJPEG allocate and free the
output buffer on every encode; the fork keeps both in a pool. The saving is a
fixed per-call cost, so it dominates on small images (+30 % at 64×64, where
thumbnails, tiles and patch pipelines live) and fades into the pixel work on
large ones. Decode saves only the handle, so it sits at parity within noise.

Multi-threaded, both scale the same way — the codec releases the GIL either
way:

![JPEG throughput vs Python threads](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jpeg_threads.png)

The difference on a free-threaded build is not throughput, it is that
**importing upstream simplejpeg re-enables the GIL for the entire process**:

```
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load
module 'simplejpeg._jpeg', which has not declared that it can run safely
without the GIL.
```

Your JPEG calls still scale, because they release the GIL. Everything *else*
in your program stops scaling. leanjpeg's backends declare
`Py_MOD_GIL_NOT_USED` and leave the GIL disabled.

### Threading

`leanjpeg.simple` has no threading of its own: libjpeg-turbo is single
threaded per call, and you scale by calling it from several Python threads
(above) or processes.

`leanjpeg.xl` drives libjxl's `JxlResizableParallelRunner` and exposes one
argument, `num_threads`, on every call:

| `num_threads` | behaviour |
|---|---|
| `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 |
| `1` | no worker threads at all; everything runs on the calling thread |
| `N` | exactly `N` threads *including* the caller (`N-1` workers) |

![JPEG XL threading](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jxl_threads.png)

Encoding scales well inside one call (0.73 → 2.27 fps from 1 to 8 threads at
1080p, effort 5). Decoding saturates around 4 threads inside one call
(6.3 → 17.2 fps), and past that you get more from **Python-level** parallelism:
8 Python threads each calling with `num_threads=1` reach 27.8 fps aggregate
versus 18.3 fps for one call with `num_threads=8`. The rule of thumb:

* one image at a time (interactive, a single large file) → leave `num_threads`
  automatic;
* many images (a batch, a dataloader, a server) → `num_threads=1` and
  parallelise in Python, otherwise `N` Python threads × `M` libjxl workers
  oversubscribes the machine.

`set_max_threads(n)` caps the automatic mode process-wide;
`suggest_num_threads(h, w)` and `effective_num_threads(h, w, n)` report what
the decision would be. Encoded bytes never depend on the thread count (there
is a test for that), so this is purely a performance knob.

Related knobs that interact with threading: `effort` (higher efforts add
sequential phases and gain less from threads) and `modular` (lossless mode
parallelises per modular group). Deliberately not exposed yet:
`MODULAR_GROUP_SIZE`, the buffering/output-mode streaming settings, and
`JxlThreadParallelRunner` (no advantage over the resizable runner here).

### Reduced allocations

Both backends pool their codec state, so a steady-state call allocates only
the object it returns. The counters are public — this is from the 1080p
benchmark run:

```python
>>> leanjpeg_simple.handle_pool_stats()
{'acquired': 2904, 'created': 16, 'destroyed': 0, 'scratch_reallocs': 10,
 'cached_compress': 8, 'cached_decompress': 8, 'max_cached': 16,
 'scratch_bytes': 68767744}
```

2904 encode/decode calls created **16** TurboJPEG handles (one per concurrent
caller, then reused) and grew the encoder's output buffer **10** times, after
which the high-water mark held. `created` and `scratch_reallocs` going flat
while `acquired` keeps climbing is the property the tests assert.
`leanjpeg_xl.codec_pool_stats()` reports the same for the JPEG XL codecs
(encoder + decoder + thread pool + buffers per pooled entry).

Pool sizes are tunable — `set_handle_pool_size(n)` / `set_codec_pool_size(n)`,
and `clear_handle_pool()` / `clear_codec_pool()` to release everything (for
example before forking or when a long-lived process goes idle).

## Image quality

Same frame, encoded to the *same file size* by both codecs, so the comparison
is quality-at-a-budget rather than two different points. libjxl's distance is
found by bisection until it matches the JPEG's byte count (±2 %); PSNR is on
RGB, SSIM on luma. Full-resolution originals and the other clips are in
[`docs/quality/`](https://github.com/vxlk/leanjpeg/tree/main/docs/quality/).

![JPEG vs JPEG XL crops](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/quality/broadcast_news_720p_crops.png)

| clip | JPEG quality | JPEG size | JPEG PSNR / SSIM | JPEG XL distance (same size) | JPEG XL PSNR / SSIM |
|---|---|---|---|---|---|
| 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** |
| 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** |
| 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** |
| 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** |
| 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** |
| 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** |
| dashcam_720p | 50 | 58.2 KiB (0.52 bpp) | 28.7 dB / 0.975 | 2.93 (58.1 KiB) | **30.3 dB / 0.984** |
| dashcam_720p | 75 | 85.9 KiB (0.76 bpp) | 31.2 dB / 0.987 | 1.60 (86.6 KiB) | **32.4 dB / 0.990** |
| dashcam_720p | 90 | 145.4 KiB (1.29 bpp) | 33.3 dB / 0.994 | 0.69 (144.1 KiB) | **36.2 dB / 0.994** |

JPEG XL wins everywhere here, by 1.5-3.5 dB PSNR at the same bytes, with the
gap widest at high quality. Read that with one caveat: **the fixtures are
synthetic**. They are OCR test clips — a Mandelbrot render under broadcast,
dashcam and telemetry text overlays — so they are all hard edges, saturated
colours and smooth gradients, which is friendly territory for JPEG XL's
modular tools and hostile to 4:2:0 chroma subsampling. The margin on your
content will be different.

There is no photographic corpus with true (non-JPEG) originals in this
repository to quote instead, and re-encoding existing JPEGs is not a valid
substitute: re-encoding a JPEG with JPEG at a matching quality is close to an
identity operation — measured that way, JPEG scores 54-72 dB PSNR on several
of simplejpeg's test photos, for reasons that have nothing to do with codec
quality. Compare on your own originals:

```bash
python bench/quality_compare.py --images a.png,b.png --out docs/quality
```

### Lossless

| clip | frame | PNG (Pillow, optimised) | JPEG XL lossless e7 | JPEG q90 | JPEG q90 → JPEG XL |
|---|---|---|---|---|---|
| broadcast_news_720p | 1280×720 | 622.1 KiB | 372.9 KiB (60 % of PNG) | 141.8 KiB | 113.9 KiB (**19.6 % smaller**) |
| dense_text_1080p | 1920×1080 | 1.40 MiB | 863.2 KiB (60 % of PNG) | 335.9 KiB | 269.9 KiB (**19.6 % smaller**) |
| dashcam_720p | 1280×720 | 709.0 KiB | 408.7 KiB (58 % of PNG) | 145.4 KiB | 116.8 KiB (**19.7 % smaller**) |

## Lossless JPEG recompression

The last column above is the feature to reach for if you have a JPEG archive.
`recompress_jpeg` re-entropy-codes the existing DCT coefficients — the image
is never decoded to pixels and never re-quantised — and stores a `jbrd` box
with everything needed to rebuild the original container. `reconstruct_jpeg`
returns the original file, byte for byte.

![JPEG to JPEG XL recompression](https://raw.githubusercontent.com/vxlk/leanjpeg/main/docs/charts/jpeg_recompress.png)

On 720p MJPEG frames (ffmpeg `-q:v 20`): **32.4 % smaller**, 41 fps to
recompress, 126 fps to reconstruct, and 128 fps to decode straight to pixels
without materialising the JPEG. Savings depend on how the original was
encoded — 32 % on those MJPEG frames, ~20 % on Pillow's q90 stills above.

```python
jxl = xl.recompress_jpeg(jpeg_bytes)
assert xl.reconstruct_jpeg(jxl) == jpeg_bytes            # bit-exact
assert xl.decode_jxl_header(jxl).has_jpeg_reconstruction # tells you it is reversible
pixels = xl.decode_jxl(jxl)                              # or go straight to pixels
```

Round trips are verified bit-exact in the test suite for progressive,
optimised, 4:4:4 / 4:2:2 / 4:2:0 and grayscale JPEGs, for ffmpeg's MJPEG
output, and for the 21 photos in simplejpeg's own test corpus when that
submodule is checked out.

## Freezing with PyInstaller

All three distributions ship their own PyInstaller hook and advertise it
through the `pyinstaller40` entry point, so `pyinstaller app.py` just works --
no `--hidden-import`, no `--collect-all`, nothing from
pyinstaller-hooks-contrib.

The hooks earn their place. Backends are resolved through `importlib`, which
static analysis cannot follow, so an unhooked bundle silently reports every
backend as missing:

```python
>>> leanjpeg.backends()                  # frozen without the hook
{'simple': False, 'xl': False}
```

and the compiled extensions import NumPy from machine code, which PyInstaller
only notices today because it parses the type stub sitting next to each
extension. The hooks state both outright.

To leave an installed backend out of a bundle, exclude its shim -- `backends()`
then honestly reports it as absent and `BackendNotInstalled` names it:

```bash
pyinstaller --exclude-module leanjpeg.xl app.py
```

`tests/test_pyinstaller.py` freezes and runs an application for each entry
point; it is marked `slow` and skips itself when PyInstaller is absent.

## Free-threaded CPython

Both extensions declare `Py_MOD_GIL_NOT_USED` (Cython's
`freethreading_compatible=True`), hold no unprotected global state — the pools
are lock-protected free lists — and release the GIL around every codec call.
Importing them on 3.13t / 3.14t leaves `sys._is_gil_enabled()` `False`.

```python
import sys, leanjpeg_simple, leanjpeg_xl
assert not sys._is_gil_enabled()          # on a free-threaded build
```

The test suite runs on both build kinds; the free-threading-specific tests
(concurrent encode/decode from many threads, pool behaviour under contention)
skip themselves on a GIL build.

## Building from source

```bash
git clone --recurse-submodules https://github.com/vxlk/leanjpeg
cd leanjpeg
pip install -e packages/leanjpeg-simple    # needs CMake, a C compiler, NASM
pip install -e packages/leanjpeg-xl        # needs CMake >= 3.16, C++17
pip install -e .
pytest
```

Each package builds its codec out of tree into
`packages/<name>/build/<codec>_<os>_<arch>/prefix` and links it statically, so
a rebuild of the Python extension does not rebuild the codec. Source
distributions carry the vendored codec sources, so `pip install` from an sdist
needs no network and no git checkout; if the submodule is missing from a git
tree, `leanjpeg-simple` falls back to downloading a pinned, checksummed
libjpeg-turbo tarball. On Windows, build from a short path - past 260
characters cmake fails to detect the compiler.

`python tools/build_dists.py --all` builds every distribution for the current
platform; `.github/workflows/wheels.yml` builds all 64 wheels.

## Keeping up with upstream

Both codecs are pinned git submodules, and the simplejpeg fork is kept
mergeable on purpose. `packages/leanjpeg-simple/UPSTREAM.json` records the
upstream commit and a file-by-file map; `UPSTREAM.md` lists every intentional
difference. The helper does the routine work:

```bash
python tools/upstream_sync.py status     # are we behind upstream?
python tools/upstream_sync.py diff       # what did we change, per file?
python tools/upstream_sync.py merge      # 3-way merge upstream changes into the fork
python tools/upstream_sync.py pin        # record a new upstream commit
```

Files the fork did not touch (`_color.c`, `_color.h`, the custom build
backend) are reported as identical, so a sync is only ever about the handful
of files that carry the two changes. Upgrading libjpeg-turbo or libjxl is a
submodule bump plus a version constant.

## Tests

```bash
pytest                       # everything that is installed
pytest -m "not ffmpeg"       # skip the tests that shell out to ffmpeg
```

| suite | covers |
|---|---|
| `packages/leanjpeg-simple/tests` | simplejpeg's own decode/encode/YUV/util tests, ported unchanged |
| `packages/leanjpeg-xl/tests` | codec round trips across colorspaces, dtypes, bit depths, alpha; JPEG recompression; threading invariants |
| `tests/` | backend discovery and error messages, cross-backend parity, threading, ffmpeg-driven workflows, packaging metadata, PyInstaller freezes |

Suites skip themselves when their backend is not installed, so a
`leanjpeg[simple]`-only install still has a green run. The PyInstaller tests
build and run real executables, so they are marked `slow` and skip themselves
where PyInstaller is absent (`pytest -m "not slow"` skips them explicitly). The ffmpeg-marked tests
locate ffmpeg and the video fixtures via `LEANJPEG_FFMPEG` and
`LEANJPEG_VIDEOS`, a sibling `video-overlay-ocr` checkout, or `PATH`, and skip
when none is found.

CI runs that suite on Linux, macOS and Windows for 3.13, 3.13t, 3.14 and 3.14t,
and the release workflow re-runs each backend's suite against every built wheel
and against both source distributions unpacked outside the git checkout.

## Licences

leanjpeg is MIT. `leanjpeg-simple` also carries simplejpeg's MIT licence
(`LICENSE.simplejpeg`) and libjpeg-turbo's two BSD-style licences;
`leanjpeg-xl` statically links libjxl (BSD-3-Clause) and its dependencies —
highway (Apache-2.0), brotli (MIT) and skcms (BSD-3-Clause). ffmpeg is **not** vendored — the benchmarks call whatever
ffmpeg you point them at.
