sharp 與 Pillow 在相同 JPEG 品質設定下,檔案大小為何不同
sharp vs Pillow at the same JPEG quality: why the bytes differ
sharp 與 Pillow 即使使用相同 JPEG 品質數值,也會因 Huffman 最佳化的預設值不同而產生不同檔案大小,sharp 的 mozjpeg 選項亦會改變量化表等設定。
sharp and Pillow write different JPEG sizes at the same quality because of one default: Huffman optimisation. sharp turns it on, Pillow leaves it off. At quality 80 on a 1920 × 1076 photo, Pillow wrote 190.3 KB and sharp wrote 185.3 KB. Turn on optimize=True in Pillow and the two files carry the same compressed image data, byte for byte.
The quantisation tables and chroma subsampling were the same in both. The bigger gap comes from sharp's mozjpeg option, which changes the tables.
Why does the same quality number give different file sizes?
A JPEG quality number only picks the quantisation tables. Everything after that is up to the encoder: how the entropy coding is tuned, whether scans are progressive, which metadata goes in the header. Two libraries can agree on quality 80 and still disagree on those.
Here are the defaults that matter, from each library's own docs:
| Setting | sharp jpeg()
|
Pillow save(..., 'JPEG')
|
|---|---|---|
| Default quality | 80 | 75 |
| Optimised Huffman tables | on (optimiseCoding: true) |
off unless optimize=True
|
| Chroma subsampling | 4:2:0 | set by libjpeg (4:2:0 here) |
| mozjpeg tuning | off (mozjpeg: false) |
not available |
Sources: the sharp output options and Pillow's JPEG saving section. Pillow's docs give quality 75 as its default, so a call that leaves quality out is also comparing 75 against 80.
How do you compare sharp and Pillow fairly?
Feed both the same pixels. I resized a camera photo to 1920 pixels wide once, saved it as a lossless PNG, and encoded that PNG in each library.
// enc.mjs
import sharp from 'sharp';
const variants = {
default: {},
'optimiseCoding off': { optimiseCoding: false },
mozjpeg: { mozjpeg: true },
};
for (const q of [75, 80, 90]) {
for (const [name, opts] of Object.entries(variants)) {
const out = await sharp('src.png').jpeg({ quality: q, ...opts }).toBuffer();
console.log(`sharp q${q} ${name.padEnd(20)} ${(out.length / 1000).toFixed(1)} KB`);
}
}
# enc.py
import io
from PIL import Image
im = Image.open('src.png').convert('RGB')
variants = {
'default': {},
'optimize': {'optimize': True},
'optimize + progressive': {'optimize': True, 'progressive': True},
'subsampling 4:4:4': {'subsampling': 0},
}
for q in (75, 80, 90):
for name, opts in variants.items():
buf = io.BytesIO()
im.save(buf, 'JPEG', quality=q, **opts)
print(f"Pillow q{q} {name:<24} {len(buf.getvalue()) / 1000:.1f} KB")
What printed:
sharp q80 default 185.3 KB
sharp q80 optimiseCoding off 190.3 KB
sharp q80 mozjpeg 153.4 KB
Pillow q80 default 190.3 KB
Pillow q80 optimize 185.4 KB
Pillow q80 optimize + progressive 184.6 KB
Pillow q80 subsampling 4:4:4 253.4 KB
Are sharp and Pillow really writing the same JPEG?
Nearly. The exact byte counts at quality 80 were 185,336 for sharp and 185,354 for Pillow with optimize=True. I walked the JPEG markers of both files. Pillow writes a 16-byte JFIF APP0 segment; sharp does not. Skip those 18 bytes (the marker plus the segment) and the rest of the two files is identical:
a = open('sharp-q80-default.jpg', 'rb').read()
b = open('pil-q80-optimize.jpg', 'rb').read()
print(a[2:] == b[20:])
True
Both libraries call the same libjpeg-style quality scaling, so they build the same tables. The first four luma table entries were [6, 4, 4, 6] in every non-mozjpeg file. With Huffman optimisation off in both, the 18-byte gap held again: 190,260 against 190,278.
What does mozjpeg change?
mozjpeg: true switches sharp to different quantisation tables, trellis quantisation and progressive scans. The first luma entries became [6, 6, 6, 7]. The result at each quality:
| Quality | sharp default | sharp mozjpeg | Saved |
|---|---|---|---|
| 75 | 158.7 KB | 125.8 KB | 20.7% |
| 80 | 185.3 KB | 153.4 KB | 17.2% |
| 90 | 297.3 KB | 245.7 KB | 17.4% |
PSNR against the source PNG went from 38.74 dB to 38.85 dB at quality 80. So the smaller file was not a worse picture by that score. The cost is time: a median of 12 ms for sharp's default encode against 103 ms with mozjpeg, from raw pixels.
Note: "Quality 80" does not mean the same thing across encoders. With mozjpeg's tables, quality 80 is a different file, not a better compressor at the same setting. Compare encoders at equal bytes or equal PSNR, never at an equal quality number.
Which settings should you use?
-
In Pillow: always pass
optimize=True. It cost 10 ms against 4 ms here and saved 2.6% at quality 80 with no change in pixels. Addprogressive=Truefor another 0.8 KB at quality 80 and 6.4 KB at quality 90. -
In sharp: leave
optimiseCodingon. Addmozjpeg: truewhen the encode is not on a hot path. - Across both: set quality explicitly. The defaults are 75 and 80, and that gap alone moves the file more than optimisation does.
- Keep 4:2:0 for photos. Pillow's 4:4:4 file was 33% larger at quality 80 for 1.67 dB more PSNR.
If you want to see the same photo through a full compressor, how we measure our numbers lays out the photos and the method behind every figure on the site.
How I measured
- Photo:
apps/web/public/images/index/image-compressor-sample-camera-photo.jpeg(4964 × 2782), resized by sharp to 1920 × 1076 and saved as a lossless PNG. Both libraries read that PNG. - sharp 0.35.4 with libvips 8.18.6 on Node 24.22.0. Pillow 11.3.0 with libjpeg-turbo 6.2 (as
features.version('jpg')reports it) and NumPy 2.0.2, on Python 3.9.6. - Byte counts from the encoded buffers. PSNR over RGB in NumPy against the PNG. Tables and sampling read with Pillow's
quantizationandJpegImagePlugin.get_sampling. - Time: encode only, median of 5 runs, in process.
- Apple M1 Pro, macOS, 2026-10-17.
What I did not test
One photo only. I did not test Pillow builds linked against mozjpeg, or sharp built against plain libjpeg-turbo, which change these results.
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.
來源:DEV Community · dev.to
