跳到正文
DEV Community· Anuj Kabra·· 3 小時前AI 評分56

文件照片如何壓至 100 KB 並保留小字清晰度

100 KB for a photo of a document: keeping the small print readable

AI 導讀

Anuj Kabra 實測以先拉正和裁切、再按小字選擇圖像寬度、最後調整 JPEG 品質的次序,可把文件照片壓至 100,000 bytes 以下並保留可讀性。

正文

Upload forms often want a scan or photo of a document "under 100 KB". A phone photo of a page is often a few megabytes, and the obvious fix, dragging a quality slider down until the number fits, tends to turn the small print into mush. The order you do things in matters more than the slider. Here's what I worked out, tested on dummy document photos I generated (no real documents).

100 KB is two different numbers

Some software counts 1 KB as 1,000 bytes and some as 1,024, so "100 KB" is either 100,000 or 102,400 bytes. A form rarely says which. If you aim for 100,000 bytes or less, the file passes under either reading, so that's the target I use below.

My test photos

I drew an A4 page with Pillow at 300 pixels per inch (2480 x 3508): a title, 30 lines of body text at about 11 pt, and 14 lines of "small print" at about 8 pt full of 12-digit reference numbers. That last block is the part that has to survive. Then I made it look like a phone photo: tilted 3 degrees, on a brown desk, 3024 x 4032 pixels, a little sensor noise and blur, saved at JPEG quality 92. Four versions:

shadowed.jpg      uneven light + paper grain   2,090,034 bytes
shadow-only.jpg   uneven light, smooth paper   1,610,917 bytes
texture-only.jpg  even light + paper grain     2,293,309 bytes
even.jpg          even light, smooth paper     1,745,211 bytes

The "shadow" is a smooth darkening towards the bottom right, down to about half brightness in the corner. The "grain" is low-level blurred noise across the paper.

Quality alone doesn't get there

At full size, even Pillow's lowest quality settings were too big:

3024 x 4032, quality 1 -> 118,201 bytes
3024 x 4032, quality 0 -> 118,201 bytes

Twelve million pixels cost bytes even when each one is stored badly. So the job is to remove pixels you don't need first, and only then spend what's left on quality.

Step 1: crop to the paper and straighten

The desk is pure waste, and a tilted page means the crop has to include desk corners. I straightened first, then cropped. ImageMagick can measure the tilt for you:

$ magick shadowed.jpg -deskew 40% -format '%[deskew:angle]' info:
3.0047113682709132

It found the 3 degrees I'd put in. Then rotate the other way and crop to the page. In Pillow, rotate() with a positive angle turns anticlockwise, so my page needed -3, and the crop box came from looking at pixel positions in an image viewer: 282,272,2742,3760, which gives a 2460 x 3488 page.

Cropped and straightened but still full size, it fits, at quality 2 in colour (97,435 bytes) or 3 in grey (89,455 bytes). That fits the number and nothing else.

Step 2: pick the width, then search for quality

This is the step that decides whether the small print survives. I wrote a script that does all the steps and then binary-searches the JPEG quality for the largest file under the target. Quality and file size go up together, so halving the range each time finds the answer in about 7 encodes instead of trying all 95:

# fit.py: python fit.py in.jpg out.jpg [--angle A] [--box l,t,r,b] [--width W] [--gray] [--flatten] [--target BYTES]
# Straighten, crop, resize, optionally grey and flatten the lighting,
# then binary-search the JPEG quality for the largest file that fits.
import argparse, io
from PIL import Image, ImageFilter, ImageMath

ap = argparse.ArgumentParser()
ap.add_argument("src"); ap.add_argument("out")
ap.add_argument("--angle", type=float, default=0)       # degrees, positive = anticlockwise
ap.add_argument("--box")                                # crop after rotating
ap.add_argument("--width", type=int)
ap.add_argument("--gray", action="store_true")
ap.add_argument("--flatten", action="store_true")
ap.add_argument("--target", type=int, default=100_000)  # 100,000 fits both readings of "100 KB"
a = ap.parse_args()

im = Image.open(a.src)
if a.angle:
    im = im.rotate(a.angle, resample=Image.BICUBIC, expand=False, fillcolor="white")
if a.box:
    im = im.crop(tuple(int(v) for v in a.box.split(",")))
if a.width and a.width < im.width:
    im = im.resize((a.width, round(im.height * a.width / im.width)), Image.LANCZOS)
if a.gray:
    im = im.convert("L")
if a.flatten:
    # estimate the paper brightness around each pixel (local max, then blur),
    # divide it out, then push near-white paper to pure white
    g = im.convert("L")
    bg = g.filter(ImageFilter.MaxFilter(15)).filter(ImageFilter.GaussianBlur(25))
    g = ImageMath.lambda_eval(lambda e: e["g"] * 255 / e["bg"], g=g, bg=bg).convert("L")
    im = g.point(lambda v: 255 if v > 215 else int(v * 255 / 215))

def encode(q):
    buf = io.BytesIO()
    im.save(buf, "JPEG", quality=q, optimize=True)
    return buf.getvalue()

lo, hi, best = 1, 95, None
while lo <= hi:                       # about 7 encodes instead of 95
    q = (lo + hi) // 2
    data = encode(q)
    print(f"  q={q:2d} -> {len(data):,} bytes")
    if len(data) <= a.target:
        best, lo = (q, data), q + 1
    else:
        hi = q - 1
if best is None:
    print(f"{a.out}: even quality 1 is over {a.target:,} bytes at {im.size}; use fewer pixels")
else:
    open(a.out, "wb").write(best[1])
    print(f"{a.out}: {im.size[0]}x{im.size[1]} {im.mode}, quality {best[0]}, {len(best[1]):,} bytes")

Same page, grey, different widths, each searched down to 100,000 bytes:

width  quality  bytes
2460      3     89,455
1600      8     94,578
1240     13     95,756
1000     21     99,899
 800     34     97,692
 600     66     98,873

Fewer pixels means each one gets more bytes. The question is where the text stops being readable, and it depends on how tall the letters end up in pixels. My 8 pt line is 33 pixels per em at 300 DPI, so it's about 13 pixels at 1000 wide, 11 at 800 and 8 at 600. I zoomed in on the reference numbers in each file:

  • 1240, 1000 and 800 px: every digit I checked read correctly. The low quality shows as block noise on the paper and smudges around the strokes, worst at 1240 with quality 13.
  • 600 px, quality 66: far less block noise, but the digits are too small. 3, 5 and 8 start to look alike.

So for this page, 800 to 1000 pixels wide was where the digits stayed clear with the most quality to spare. A page with larger print can go narrower; a page with tiny print in a table may need 1240 and a cleaner photo. Check the smallest digits, not the headings.

Grey vs colour: less difference than I expected

To compare fairly I fixed the width (1240) and quality (30) and only changed the input:

shadowed, colour       194,462
shadowed, grey         187,898

Going grey saved only 3.4% here (2.2% at quality 60). That surprised me until I checked how the colour file was stored: Pillow uses 4:2:0 chroma subsampling by default, so the two colour channels are kept at a quarter of the resolution, and on a nearly grey page they're almost flat and cost very little. Grey still helps, and it helps more if the paper is tinted or has coloured stamps, but it's not the big lever. Pixels are.

What actually eats the bytes

Same width and quality again, the four photos side by side:

                      quality 30   quality 60
even, grey              160,301      227,318
texture-only, grey      225,555      352,794
shadow-only, grey       135,349      195,370
shadowed, grey          187,898      304,753

The paper grain added 41% at quality 30 and 55% at quality 60. JPEG stores each 8 x 8 block as a sum of patterns; a flat block needs almost nothing, and a grainy block needs many patterns to describe the grain, even though nobody wants the grain.

My smooth shadow didn't cost bytes on its own. It actually saved some, because darkening a region lowers the contrast of the text in it, and lower contrast is cheaper to store. That's not a good trade: it's the text getting fainter. In a real photo a shadow usually comes with more sensor noise as well, since the camera has less light to work with there, and noise costs bytes the same way grain does. My synthetic shadow had no extra noise, so this test doesn't show that part.

So the advice is the same either way: shoot in even light, near a window or under a ceiling light, and hold the phone so it doesn't throw its own shadow on the page.

If the photo is already shadowed, the --flatten option in the script divides out the lighting and pushes near-white paper to pure white. At 1240 px and quality 30 the shadowed page went from 187,898 to 169,992 bytes (9.5% smaller), and in the size-searched 1240 px file the background behind the small print came out white instead of grey and grainy.

ImageMagick in one line

The same steps with ImageMagick 7, using its own tilt detection and a size limit:

$ magick shadowed.jpg -deskew 40% +repage -gravity center -crop 2460x3488+0+0 +repage \
    -colorspace Gray -resize 1000x -define jpeg:extent=100000B out.jpg
$ magick identify -format "%wx%h %[colorspace] q=%Q %B bytes\n" out.jpg
1000x1418 Gray q=20 93808 bytes

Two things I ran into:

  • Without the +repage after -deskew, the crop used the old canvas offsets and cut off the left edge of the page.
  • jpeg:extent doesn't fail when it can't hit the target. Pointed at the full 3024 x 4032 photo it wrote a 118,310 byte file and exited with status 0. Always check the size afterwards.

It also stopped a step early: with the 100,000 byte limit above it settled on quality 20 (93,808 bytes), though -quality 21 on the same command gives 98,018 bytes and would have fit.

Without the command line

  • Windows Photos: the crop tool trims the desk, and the three-dot menu has Resize image, where you set a width in pixels, move the Quality slider, and see the new file size before saving.
  • macOS Preview: crop with the selection tool, Tools > Adjust Size for the width, Tools > Adjust Color with Saturation at zero for grey, then File > Export and use the JPEG quality slider, which shows the file size as you move it.
  • Phone editors: the crop tool in most gallery apps has a straighten dial, and a mono or black-and-white filter does the grey step. Crop and straighten there, then resize and set the quality on a computer.

I tested everything above on a Linux box, so I haven't run these apps myself. The order is the part that carries over: crop, straighten, choose a width where the smallest digits are still clear, then lower quality until it fits, and zoom in before uploading.

A browser option

I built ToolOcean, and its compress-image-to-100kb page does the last step: it picks the JPEG quality, and the scale if needed, so the file lands at or under the ceiling, in the browser without uploading the photo. Its 100 KB is 102,400 bytes. I ran two of my test files through the live page in headless Chrome. The raw shadowed photo came back at 1701 x 2268, 100,078 bytes; my cropped 1000 px grey page came back at 1000 x 1418, 101,075 bytes, saved as a colour JPEG. Both are under 102,400 and both are over 100,000, so a form that counts 1 KB as 1,000 bytes would reject them. The page doesn't crop, straighten or convert to grey, so do those first: toolocean.co/compress-image-to-100kb.

Drafted with AI help, tested and checked by me.

來源:DEV Community · dev.to