Image Compression To A Target Size
Fit JPEG images under a size ceiling (1 MB by default) by trading away JPEG
quality first and resolution only as a last resort. The picture the viewer sees
stays the same: same subject, same framing, same aspect ratio, same colours.
Originals are never overwritten — results land in a compressed/ folder.
When To Use
- The user wants an image reduced in file size: 压缩图片, 图片太大, 缩小体积, 压到 1M 以下.
- An upload or form rejects a file for being too large.
- A batch of photos needs to be made upload-ready at once.
When Not To Use
- The request is about image generation, editing, retouching, background
removal, or watermark removal — that is a different tool. - The user wants a different format, a crop, or a creative re-frame. This skill
deliberately only changes encoding, never composition. - The input is not a JPEG. The script reports non-JPEG inputs as skipped — see
"When The Input Is A PNG" below for how to handle those deliberately.
Running It
The script lives at scripts/compress_jpeg.py inside this skill directory.
# One file - default 1 MB ceiling
python3 <SKILL_DIR>/scripts/compress_jpeg.py "/abs/path/photo.jpg"
# Several files
python3 <SKILL_DIR>/scripts/compress_jpeg.py "/abs/a.jpg" "/abs/b.jpg"
# A whole tree, recursing into subfolders
python3 <SKILL_DIR>/scripts/compress_jpeg.py "/abs/path/to/folder"
# A different ceiling, default 1 MB
python3 <SKILL_DIR>/scripts/compress_jpeg.py "/abs/photo.jpg" --target-mb 2
# Certificates, scans, screenshots - keep coloured text and seals sharp
python3 <SKILL_DIR>/scripts/compress_jpeg.py "/abs/certs" --subsampling 0
# Machine-readable summary for further processing
python3 <SKILL_DIR>/scripts/compress_jpeg.py "/abs/folder" --json /tmp/result.json
Results go to a compressed/ subfolder beside each input, mirroring the
subfolder layout when a directory is passed. Pass --output-dir NAME to rename
that folder.
Options
| Flag | Default | Meaning |
|---|---|---|
--target-mb N |
1.0 |
Size ceiling in decimal MB |
--output-dir NAME |
compressed |
Result subfolder name |
--min-quality N |
40 |
Lowest quality tried before falling back to downscaling |
--min-dimension N |
640 |
Long-edge floor in pixels when downscaling |
--subsampling N |
2 |
0=4:4:4, 1=4:2:2, 2=4:2:0 |
--progressive |
off | Progressive JPEG; better on slow links but ~4% larger |
--strip-metadata |
off | Drop EXIF/ICC for extra savings |
--include-ok |
off | Copy files that already fit, instead of skipping them |
--json PATH |
– | Write a machine-readable summary |
--quiet |
off | Print only the summary line |
Exit code is 0 when every file reached the target and 1 when at least one
failed.
How "Unchanged" Is Guaranteed
Two details matter and are easy to get wrong:
- The ceiling carries a 2% safety margin. A "1 MB" limit is ambiguous —
some consumers mean 1,000,000 bytes, others 1,048,576. Results are held to
98% of the stated target so the file clears either reading. - EXIF orientation is baked into the pixels before anything else happens.
A portrait iPhone photo stores landscape pixels plus a "rotate me" flag.
Re-encoding tools that drop the flag silently turn the picture sideways —
and macOSsipsdoes exactly that as soon as it resizes. The script applies
the orientation to the pixels up front, so a portrait photo stays portrait.
Verify this on any change to the imaging code.
A 12 MP photo typically lands at quality ~90 with the original pixel dimensions
intact. Downscaling only kicks in when quality alone cannot reach the target.
The quality search runs from --min-quality up to 100, then takes the
highest level that still fits. When an image has size to spare the extra budget
goes into fidelity rather than being thrown away.
Documents, Screenshots And Certificates
Pass --subsampling 0 for anything whose value is fine text or a seal: scanned
documents, certificates, screenshots, forms. The default 4:2:0 halves colour
resolution, which smears the coloured edges of red text and official stamps.
Measured on a 1179x1667 training certificate (source was q100 4:4:4, 1.02 MB)
against a 1 MB ceiling:
| Setting | Result | PSNR |
|---|---|---|
--subsampling 2 (default) |
0.35 MB, q≈95 | 44.8 dB |
--subsampling 0 |
0.85 MB, q=99 | 53.9 dB |
Both fit the ceiling, so the 4:2:0 run simply gave away 9 dB of fidelity for
space nobody asked for. For photographs the default remains the right trade.
When The Input Is A PNG
The script walks past PNGs, so handle them deliberately rather than assuming a
format conversion is free. Measure before choosing — the right answer depends
entirely on how much fine texture the picture carries.
Worked example, a 4.97 MB screenshot of a certificate at 2686x1870 with 17,674
distinct colours (woven paper texture behind the text):
| Attempt | Result |
|---|---|
PNG re-save, lossless optimize |
4.59 MB — nearly no gain |
| PNG palette, 256 colours | 2.41 MB — still over |
| PNG palette, 128 colours | 2.16 MB — still over |
| JPEG 4:4:4 q90 at full resolution | 1.52 MB — still over |
| JPEG 4:4:4 q80 at full resolution | 0.95 MB — fits, but text softens |
| JPEG 4:4:4 q92 at 1800 px | 0.80 MB — fits, text stays crisp |
| PNG palette 256 at 1400 px | 0.86 MB — fits, keeps .png, visibly softer |
The texture is what blocks every lossless route, so the choice reduces to
"lower quality at full size" versus "slightly smaller pixels at high quality".
For text the second wins clearly: compare both at the same on-screen size
before deciding, and tell the user which one you picked and why.
Two things to flag to the user in this case: converting to JPEG changes the file
extension, so confirm the destination accepts it; and a downsized page is a real
change in resolution even though the wording and layout are untouched.
Environment
Requires Python 3.9+ and Pillow. Nothing else — no imagemagick, no
jpegoptim, no network access.
The script bootstraps itself: if the interpreter you launched it with lacks
Pillow, it probes, in order, WorkBuddy-managed virtualenvs, managed runtimes,
and python3 on PATH, then re-executes under the first one that has Pillow.
No path to any user's home directory is baked in, so the same file works after
being copied to another machine.
When editing that bootstrap, keep candidate paths as found and never call
realpath() on them. A virtualenv's bin/python is a symlink onto its base
interpreter; resolving it and running the target silently leaves the
virtualenv, so its site-packages — and Pillow — disappear. Deduplicate on the
literal path instead.
If none of them has Pillow, install it once:
python3 -m pip install Pillow
The default PyPI index is sometimes unreachable behind a corporate network.
If the install hangs or reports No matching distribution found, pass a mirror
explicitly:
python3 -m pip install \
--index-url https://pypi.tuna.tsinghua.edu.cn/simple Pillow
Use an isolated environment rather than the system Python, so the install does
not disturb anything else:
python3 -m venv ~/.venvs/imgcompress
~/.venvs/imgcompress/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple Pillow
~/.venvs/imgcompress/bin/python <SKILL_DIR>/scripts/compress_jpeg.py "/abs/photo.jpg"
Verify a transplant with a one-liner before trusting it on real work:
python3 -c "import PIL; print('Pillow', PIL.__version__)"
Reporting Back
Relay the real numbers from the run rather than a generic success message:
- original size → final size and the percentage saved,
- the quality level used,
- whether the pixel dimensions were reduced,
- anything that failed, with its actual error.
Present the produced files so the user can inspect them.
Reading The Summary
OK— compressed to within the target.COPY— already compliant, copied byte-for-byte (--include-okonly).SKIP— already compliant and left alone, or not a JPEG.FAIL— could not be brought under the target, or the file is corrupt. The
reason is printed; report it instead of retrying blindly.
Files already under the ceiling are still opened and validated, so a truncated
or corrupt JPEG is reported as FAIL rather than waved through.