# shoots.page photo derivatives: Pillow vs ImageMagick (vs libvips)
The job being measured is exactly what processor/decode.py does per upload: decode, EXIF orient, ICC to sRGB, 2000 px preview (q88) + 480 px thumb (q82). Watermarking measured separately.
Contents
# Results
9 real camera JPEGs at native resolution, 1 pinned core (prod container is 1 vCPU), best of 3. CPU is whole-process time, so the Python rows include ~95 ms interpreter startup that the long-lived prod server never pays.
| Pipeline | CPU/photo (mean) | Worst (61 MP) | Peak RAM | SSIMULACRA2 | Butteraugli p3 | Preview size |
|---|---|---|---|---|---|---|
| Pillow, prod as-is | 2.08 s | 3.39 s | 496 MB | 82.69 | 0.941 | 477 KB |
| Pillow, reordered | 0.69 s | 0.83 s | 207 MB | 82.52 | 0.950 | 475 KB |
| ImageMagick 7 Q16, tuned | 1.31 s | 1.38 s | 266 MB | 82.24 | 0.953 | 471 KB |
| ImageMagick 7 Q16, naive | 2.26 s | 2.41 s | 522 MB | 82.32 | 0.952 | 473 KB |
| ImageMagick 7 HDRI (official build), tuned | 3.49 s | 3.34 s | 272 MB | 82.21 | 0.955 | 472 KB |
| ImageMagick 7 HDRI (official build), naive | 8.50 s | 10.84 s | 1040 MB | 82.33 | 0.952 | 473 KB |
| libvips (control) | 0.53 s | 1.04 s | 148 MB | 81.78 | 1.057 | 475 KB |
- Quality is a dead heat. Everything lands within 1 SSIMULACRA2 point; the q88 JPEG step dominates. Pillow scores highest against all three references, including ImageMagick’s own. Ranking is identical across references.
- Pillow isn’t slow, our order of operations is. Prod decodes the full frame and converts ICC on all 24-61 MP before resizing. On the 45 MP Adobe RGB file: decode+orient 556 ms, full-res ICC 1691 ms, resize+encode 409 ms. Reordered: half-scale decode 283 ms, orient+resize 175 ms, ICC on 2000 px 102 ms, encode 30 ms.
- ImageMagick only gets decent with a hint. Without
-define jpeg:size=it is as slow as prod today and peaks at 522 MB. The official build is HDRI (float pixels): 2.7x slower than Debian’s Q16, up to 1 GB RAM. - ImageMagick gotcha: it copies the camera’s chroma subsampling (4:4:4 on 7 of 9 files), so default previews come out 19% bigger on average (5-32%). Forced to 4:2:0 here for a fair comparison.
# Watermark
Overlay (tiled diagonal studio name, 30% white) built once per gallery, center-cropped per photo.
| Per photo (composite + encode wm preview + wm thumb) | One-off overlay build | |
|---|---|---|
| Pillow | 41 ms | 177 ms |
| libvips | 43 ms | 73 ms |
| ImageMagick | +242 ms (reloads overlay every process) | 3.3 s |
Watermarked preview is ~7% bigger (508 KB vs 475 KB).
# Setup
- Corpus (Wikimedia Commons, native res): 24 MP x2 (Capture One, sRGB), 33 MP Sony A7 IV (no ICC), 45 MP Canon R5 x2 (Adobe RGB), 45 MP Nikon Z8 (Nikon sRGB ICC), 61 MP Sony A7R V (no ICC), 61 MP Lightroom portrait, plus a 45 MP Adobe RGB file with EXIF orientation 6.
- Versions: Pillow 12.3.0 + numpy 2.5.3 (prod pins, Python 3.13), ImageMagick 7.1.1-43 Q16 (Debian), ImageMagick 7.1.2-32 Q16-HDRI (official AppImage), libvips 8.18.7. All encode via libjpeg-turbo at 4:2:0 with the same sRGB ICC.
- Pipelines: prod = current
write_derivatives()called directly. Reordered = same libs,draft()half-scale JPEG decode when the source is at least 2x the target, orient + resize first, ICC on the 2000 px raster. IM tuned =-define jpeg:size=<2x target> -auto-orient -resize 2000x2000> -profile sRGB -strip -profile sRGB -sampling-factor 4:2:0, preview + thumb in one process. IM naive = same without the hint. libvips =thumbnail()with shrink-on-load and export profile. - Quality: SSIMULACRA2 and Butteraugli (libjxl 0.12) against three independent float-precision Lanczos references from the full-res original (libvips, ImageMagick HDRI, Pillow float). The references agree with each other at 94.5, so tool-to-tool resampling differences are far below the JPEG loss.
- Not covered: ImageMagick 6 (what bookworm apt ships), HEIC/RAW/PNG/TIFF inputs, mozjpeg.
End