↓快轉到主要內容
  1. 教學文章/

照片太大寄不出去?用 Python 批次縮圖與壓縮

·8 分鐘· loading · loading · ·
Python Pillow 照片縮圖 圖片壓縮 日常自動化
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 158: 本文

一. 前言:只是想寄幾張照片,不想先研究修圖軟體
#

要寄活動照片,郵件卻說附件太大。 改成一張一張傳,又開始跟進度條大眼瞪小眼。 你其實不需要把原始照片交出去,只要讓對方在螢幕上看清楚就好。

拍拍君今天做一個小工具:把 photos 裡的 JPEG、PNG, 縮成最長邊不超過 1200 像素的副本,放到另一個 share 資料夾。 原檔不動、不拉長照片,透明 PNG 也保留透明背景。

下載完整練習包,裡面有完整程式、 三張合成圖片與預期輸出;不必拿私人照片測試,也不必上傳任何網站。 想直接做出成果,依序完成安裝、產生練習檔,再執行批次程式即可。

這不是 AI 修圖,也不是把照片塞給模型。 先前的 本地圖片問答 曾簡短處理模型輸入尺寸; 這篇把焦點放回日常分享:尺寸、檔案大小、透明度,以及副本怎麼留。

二. 安裝:套件叫 Pillow,匯入時叫 PIL
#

本文以 Python 3.12、Pillow 13.0.0 實際執行。 先建立新的練習資料夾,在裡面開終端機:

python -m venv .venv

macOS / Linux 啟用環境:

source .venv/bin/activate

Windows PowerShell 則用:

.\.venv\Scripts\Activate.ps1

接著安裝:

python -m pip install Pillow==13.0.0

如果電腦只有 python3,建立環境時把 python 改成 python3。 啟用環境後,下面都使用 python。 已經習慣 uv 的讀者可以沿用自己的環境流程,參考 uv 教學。

安裝名稱是 Pillow,程式卻寫 from PIL import Image,這不是打錯字。 不要另外去安裝名叫 PIL 的老套件。

三. 先做出第一張縮圖
#

練習包已附 photos;如果想自己產生,將以下存成 make_demo.py。 它建立一張有細節的橫圖、一張小圖,以及透明背景的圓形圖案。 這些是程式合成圖,不是真實人物或實拍照片。

import random
from pathlib import Path
from PIL import Image, ImageDraw

folder = Path("photos")
folder.mkdir(exist_ok=True)
rng = random.Random(42)
size = (2400, 1600)
noise = Image.frombytes("RGB", size, rng.randbytes(size[0] * size[1] * 3))
scene = Image.new("RGB", size, "#b8def0")
draw = ImageDraw.Draw(scene)
draw.ellipse((1550, 120, 2000, 570), fill="#fff1ab")
draw.polygon([(0, 1300), (800, 500), (1650, 1300)], fill="#678975")
draw.rectangle((0, 1300, 2400, 1600), fill="#d5bd90")
scene = Image.blend(scene, noise, 0.15)
scene.save(folder / "landscape.jpg", quality=98)

small = Image.new("RGB", (400, 300), "#f0c7b5")
ImageDraw.Draw(small).rectangle((80, 60, 320, 240), fill="#776b91")
small.save(folder / "small.jpg", quality=95)

sticker = Image.new("RGBA", (1800, 1200), (0, 0, 0, 0))
ImageDraw.Draw(sticker).ellipse((400, 100, 1400, 1100), fill="#65b99b")
sticker.save(folder / "sticker.png")
print("已建立三張練習圖片")

執行一次:

python make_demo.py

只在新的練習資料夾執行產生器。 重跑會覆寫那三個同名練習檔。 換成自己的照片時,不要再執行它。

先建立 one_photo.py,只縮一張試試看:

from PIL import Image, ImageOps

with Image.open("photos/landscape.jpg") as source:
    image = ImageOps.exif_transpose(source)
    image.thumbnail((1200, 1200), Image.Resampling.LANCZOS)
    image.convert("RGB").save("preview.jpg", quality=85, optimize=True)
    print(image.size)
python one_photo.py

你會拿到 preview.jpg,並看到 (1200, 800)。 它不是 1200×1200 的正方形,因為照片仍然保留原本的長寬比例。 打開原圖和預覽,先確認內容沒有被裁掉、人物沒有被拉寬。 這個單張練習重跑會覆寫 preview.jpg,但不修改 photos 裡的原圖。

四. 完整程式:一次處理整個資料夾
#

接著建立 resize_photos.py。這一支已經包含完整流程, 不用把上一段單張範例拼進來:

from pathlib import Path
from PIL import Image, ImageOps, UnidentifiedImageError

INPUT = Path("photos")
OUTPUT = Path("share")
MAX_EDGE = 1200
JPEG_QUALITY = 85
EXTENSIONS = {".jpg", ".jpeg", ".png"}

if INPUT.resolve() == OUTPUT.resolve():
    raise SystemExit("來源和輸出必須是不同資料夾")
if not INPUT.is_dir():
    raise SystemExit("找不到 photos 資料夾")
OUTPUT.mkdir(exist_ok=True)
files = sorted(
    p for p in INPUT.iterdir()
    if p.is_file() and p.suffix.lower() in EXTENSIONS
)
if not files:
    raise SystemExit("沒有找到 JPEG 或 PNG 圖片")

completed = 0
for path in files:
    target = OUTPUT / path.name
    if target.exists():
        print(f"略過已存在:{target.name}")
        continue
    try:
        with Image.open(path) as source:
            if getattr(source, "n_frames", 1) != 1:
                print(f"略過多影格圖片:{path.name}")
                continue
            if source.format not in {"JPEG", "PNG"}:
                print(f"略過格式不符:{path.name}")
                continue
            image = ImageOps.exif_transpose(source)
            original_size = image.size
            if path.suffix.lower() == ".png":
                alpha = "A" in image.getbands() or "transparency" in image.info
                image = image.convert("RGBA" if alpha else "RGB")
                file_format = "PNG"
                options = {"optimize": True}
            else:
                image = image.convert("RGB")
                file_format = "JPEG"
                options = {"quality": JPEG_QUALITY, "optimize": True}
            image.thumbnail((MAX_EDGE, MAX_EDGE), Image.Resampling.LANCZOS)
            with target.open("xb") as output:
                image.save(output, format=file_format, **options)
            before = path.stat().st_size / 1024
            after = target.stat().st_size / 1024
            print(f"{path.name}: {original_size} → {image.size}; "
                  f"{before:.1f} → {after:.1f} KiB")
            completed += 1
    except (UnidentifiedImageError, OSError, ValueError) as error:
        print(f"未完成 {path.name}:{error}")

print(f"完成 {completed} 張;請到 {OUTPUT} 檢查結果")

執行:

python resize_photos.py

完成後的資料夾大致是:

photo-demo/
├── make_demo.py
├── resize_photos.py
├── photos/             # 原檔,保留不動
│   ├── landscape.jpg
│   ├── small.jpg
│   └── sticker.png
└── share/              # 要寄出去的副本
    ├── landscape.jpg
    ├── small.jpg
    └── sticker.png

輸出保留原檔名,所以 photo.jpg 和 photo.png 也不會被硬改成同一個 JPEG。 程式只找第一層資料夾,不會往下掃描相簿,也不會處理 HEIC、GIF 或 RAW。 大小寫副檔名如 .JPG 可以辨識;動畫 PNG 則略過,不偷偷只留下第一格。 若想多了解找檔案的部分,可以接著看 pathlib 教學。

五. 縮尺寸和調品質,是兩個不同的旋鈕
#

MAX_EDGE = 1200 控制像素數;JPEG_QUALITY = 85 控制 JPEG 編碼品質。 它們都可能影響檔案大小,但不是同一件事。

調整項目 改變什麼 什麼時候先調
最長邊 2400 → 1200 寬和高各減半,像素總數剩四分之一 原圖很大,只需要螢幕預覽
JPEG 品質 95 → 85 有損壓縮的取捨,像素尺寸不一定改變 尺寸夠用,但檔案還想再小一點
PNG 的 optimize 嘗試更合適的無損編碼方式 想保留透明或清楚的圖案邊緣

「像素剩四分之一」不等於「檔案一定剩四分之一」。 檔案還受到畫面內容、雜訊、編碼方式與原本壓縮程度影響。 Pillow 的 quality=85 也不是「保證保留 85% 畫質」。

thumbnail((1200, 1200)) 的意思是放進這個外框,保持比例,不放大原本的小圖。 2400×1600 變成 1200×800;直式照片則可能變成 800×1200。 400×300 的小圖仍是 400×300,但重新存成 JPEG 後,位元組和畫質仍可能改變。

因此我們永遠從 photos 的原圖產生新副本, 不要拿 share 的 JPEG 一輪一輪再壓縮,讓損失一直累積。 也不需要把 quality 設成 100:那不是無損模式,通常還會讓檔案大很多。

六. 手機照片橫躺、透明圖黑底,怎麼避開?
#

1. 先修正顯示方向,再縮尺寸
#

有些手機照片把方向存在 EXIF 裡:像素本身是橫的, 照片檢視器讀了方向標記,才顯示成直的。 ImageOps.exif_transpose(source) 先依標記旋轉或翻轉,移除該方向資訊, 再做縮圖,就比較不會出現寄出去後照片突然橫躺的情況。

這不是辨識哪邊是天空的 AI;如果原圖根本沒有正確方向標記, 程式不會自動猜到你想轉哪一邊。遇到這種照片,先手動轉正再處理。

2. PNG 留在 PNG,不把透明硬丟掉
#

JPEG 不支援透明背景,PNG 可以。 程式對含 alpha 或透明色資訊的 PNG 使用 RGBA,再以 PNG 儲存, 所以貼圖周圍仍然透明,不會因為一律轉 JPEG 而多出一塊底色。

RGBA 可以理解成紅、綠、藍,外加一個透明度通道。 範例也處理以調色盤透明色表示的 PNG,不只辨認檔案當下是不是 RGBA。 PNG 沒有使用這裡的 JPEG quality 參數;縮掉像素仍會改變影像內容, 只是儲存階段不像 JPEG 再引入同樣的有損壓縮。

這個版本針對一般螢幕分享,不是印刷色彩管理或原始照片封存工具。 不承諾完整保留 EXIF、ICC 等中繼資料,也不把它當作完整的隱私清除器。 若需要原始拍攝資訊、精準色彩或後續修圖,請保留原檔。

七. 檢查結果,再決定要不要壓得更小
#

練習包在本文環境的結果如下;KiB 是 1024 位元組。 不同 Pillow 或 JPEG 編碼函式庫版本,數字可能略有差異。

練習圖片 原尺寸 → 新尺寸 原大小 → 新大小
landscape.jpg 2400×1600 → 1200×800 2879.8 → 101.0 KiB
small.jpg 400×300 → 400×300 4.6 → 2.1 KiB
sticker.png 1800×1200 → 1200×800 14.0 → 33.2 KiB

合成橫圖刻意帶有細節與雜訊,用來觀察縮圖效果; 它不代表每張真實照片都會得到相同的壓縮比例。 非常小的圖重新編碼後也可能變大,所以程式印的是前後數字,不是保證省幾成。

先從 share 打開幾張:橫式、直式、透明 PNG 各挑一張。 看看方向是否正確、邊緣是否完整,再放大看文字或細節是否還能接受。 不要只看到終端機寫「完成」就把整包寄出去。

如果總大小還超過附件限制,可以把 MAX_EDGE 改成 1000, 或把 JPEG_QUALITY 改成 80,並把 OUTPUT 改成新的資料夾,例如 share-small。 預設遇到同名輸出會略過,不會因為改了參數就自動重做舊副本。 保留兩個資料夾對照,比憑印象猜畫質方便。

要留更多細節,可以把最長邊設為 1600,代價通常是較大的檔案。 文件截圖、文字密集圖片則優先留 PNG,別為了檔案小把字邊緣壓得糊糊的。 這份小工具沒有「每張剛好小於 500 KB」的保證,也不會自動搜尋最佳品質。

八. 換成自己的照片,只要注意幾件小事
#

另開一個工作資料夾,放入 resize_photos.py,再建立 photos, 先放兩三張自己的照片副本試跑;不要複製並執行練習圖產生器。 真正要分享的是 share,不是原始的 photos。

「找不到 photos」通常是終端機位置不對;路徑是相對於目前工作資料夾, 不是相對於程式檔所在位置。先切換到包含 photos 的那一層再執行。 「沒有找到圖片」則先檢查副檔名;HEIC 不在本文範圍,先用照片工具匯出 JPEG。

如果某張讀取失敗,程式會印出「未完成」,並繼續處理其他圖片。 最後的完成張數只算本次成功的新副本,略過的舊檔不算。 磁碟寫入途中出錯時,輸出可能只留下不完整檔案; 先確認錯誤原因,再刪掉那份失敗副本重跑,不要刪原圖。

圖片解碼仍需要記憶體,逐張處理不等於可以無限大。 遇到 Pillow 的大圖警告或明顯不合理的尺寸,先確認檔案來源, 不要為了讓程式繼續跑就直接關掉保護設定。

結語:原圖留給自己,輕巧副本拿來分享
#

拍拍君喜歡這種做完就能用的小工具。 不需要做出一個完整修圖軟體,只要掌握兩個旋鈕:像素尺寸與 JPEG 品質, 再把比例、方向、透明度顧好,就能少做很多重複點選。

先跑練習包,再換幾張自己的照片。 下次附件太大,不必一張張打開另存,也不用急著把原圖刪掉啦。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 158: 本文

相關文章

不想再複製貼上:用 Python 合併多個 Excel 表格
·8 分鐘· loading · loading
Python Openpyxl Excel 辦公室自動化
Python subprocess 超時治理:POSIX Process Group、升級終止與輸出收尾
·7 分鐘· loading · loading
Python Subprocess POSIX Process Groups Timeout Testing
Python SQLite 線上備份:WAL、Connection.backup 與還原驗收
·7 分鐘· loading · loading
Python SQLite WAL Backup Restore Testing
Polars Pivot / Unpivot:長寬表轉換、重複鍵與 Round-trip 契約
·7 分鐘· loading · loading
Python Polars Pivot Unpivot Data-Contracts Testing
Rich 報表匯出:Record Buffer、HTML/SVG 與可重現分享
·8 分鐘· loading · loading
Python Rich HTML SVG Reports Testing
Streamlit Form 提交生命週期:草稿、已套用狀態與失敗保留
·9 分鐘· loading · loading
Python Streamlit Forms Session State Validation Testing