2026年8月31日 星期一

Python一下:用Pillow打造水井三寶地方圖卡處理工具

《Python一下:從風土資料到智慧生活》第 30 篇

用Pillow打造水井三寶地方圖卡處理工具

讓烏龜、白馬與姻緣花的照片,變成尺寸一致、文字清楚、可追溯來源的地方圖卡。

CH14 圖片處理Pillow水井三寶
學習目標
完成本篇後,你能安全開啟與驗證圖片、依EXIF修正方向、比較thumbnail與fit、加入繁體中文標題及半透明浮水印、正確輸出JPEG/PNG、批次產生圖卡與處理紀錄,並說明肖像權、著作權、位置資訊及AI生成標示。

一、地方圖卡不是「把字貼上去」而已

同一張水井三寶照片可能來自手機、相機、社區長者或AI創作,具有不同方向、尺寸、色彩模式、檔案格式及使用權。圖卡工具至少要回答四件事:

問題程式處理USR責任
圖片能否安全讀取?限制大小、辨識格式、捕捉錯誤不接受不明來源大檔直接進系統
版型是否一致?轉向、縮放、裁切與色彩模式不扭曲人物、工藝或地方物件
文字是否清楚?字型、文字框、對比與安全邊界名稱與故事先由社區確認
能否合法公開?輸出時移除不必要metadata確認授權、肖像同意及AI標示

二、安裝Pillow:安裝名稱與匯入名稱不同

import subprocess
import sys


subprocess.run(
    [sys.executable, "-m", "pip", "install", "Pillow"],
    check=True,
)

pip安裝名稱是Pillow,程式匯入名稱仍是PIL。請在專案虛擬環境安裝,並把實際驗證版本記錄在依賴檔中。

三、讀取圖片基本資訊

from pathlib import Path
from PIL import Image


source = Path("images/turtle.jpg")

with Image.open(source) as image:
    print("格式:", image.format)
    print("尺寸:", image.size)
    print("色彩模式:", image.mode)
    print("影格數:", getattr(image, "n_frames", 1))

Image.open()會延遲讀取像素,因此要使用with確保檔案關閉。不要只依副檔名判定內容;Pillow會由檔案內容辨識格式。

四、不可信圖片先驗證,再重新開啟

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


MAX_FILE_BYTES = 15 * 1024 * 1024
ALLOWED_FORMATS = {"JPEG", "PNG", "WEBP"}


def validate_image(path):
    path = Path(path)
    if not path.is_file():
        raise FileNotFoundError(path)
    if path.stat().st_size > MAX_FILE_BYTES:
        raise ValueError("檔案超過15 MB限制")

    with warnings.catch_warnings():
        warnings.simplefilter("error", Image.DecompressionBombWarning)
        try:
            with Image.open(path) as image:
                detected_format = image.format
                if detected_format not in ALLOWED_FORMATS:
                    raise ValueError("不支援的圖片格式")
                image.verify()
        except UnidentifiedImageError as error:
            raise ValueError("無法辨識圖片") from error

    return detected_format

verify()檢查後必須重新開啟圖片,不能繼續用同一物件處理像素。檔案大小與像素量是兩種不同風險;Pillow會對異常巨大像素數提出decompression bomb警告或錯誤,不應為方便直接關閉保護。

五、手機照片方向錯誤:套用EXIF Orientation

from PIL import Image, ImageOps


def open_oriented_rgb(path):
    validate_image(path)
    with Image.open(path) as image:
        oriented = ImageOps.exif_transpose(image)
        return oriented.convert("RGB")


image = open_oriented_rgb("images/turtle.jpg")
print(image.size, image.mode)

ImageOps.exif_transpose()依EXIF方向旋轉或鏡射像素,並處理方向資訊。若略過這一步,手機上直立的照片在輸出後可能橫躺。

六、thumbnail:完整保留、放進指定範圍

from pathlib import Path
from PIL import Image


image = open_oriented_rgb("images/turtle.jpg")
thumbnail = image.copy()
thumbnail.thumbnail(
    (800, 800),
    resample=Image.Resampling.LANCZOS,
)
Path("output").mkdir(parents=True, exist_ok=True)
thumbnail.save("output/turtle_thumbnail.jpg", quality=88)

print(thumbnail.size)

thumbnail()會原地修改圖片,保持長寬比並限制在指定框內,通常不會放大小圖。它適合完整保留照片,但輸出寬高不一定完全一致。

七、ImageOps.fit:裁成一致的社群圖卡

from PIL import Image, ImageOps


image = open_oriented_rgb("images/white_horse.jpg")
card_photo = ImageOps.fit(
    image,
    (1200, 800),
    method=Image.Resampling.LANCZOS,
    centering=(0.5, 0.45),
)
card_photo.save("output/white_horse_1200x800.jpg", quality=90)

print(card_photo.size)

fit()會等比例縮放後裁切,得到固定尺寸。centering可以調整保留重心;人物、樹木或工藝品不能一律置中,最好提供裁切預覽讓社區夥伴確認。

八、不要用resize硬拉成固定尺寸

from PIL import ImageOps, Image


def make_card_background(image, size=(1200, 1200), mode="crop"):
    if mode == "crop":
        return ImageOps.fit(
            image, size, method=Image.Resampling.LANCZOS
        )
    if mode == "contain":
        return ImageOps.pad(
            image,
            size,
            method=Image.Resampling.LANCZOS,
            color=(245, 240, 224),
        )
    raise ValueError("mode必須是crop或contain")

crop填滿版面但會裁掉邊緣;contain完整保留但可能出現留白。直接 resize((1200,1200))會改變比例,使姻緣花、烏龜或人物變形。

九、繁體中文字型要由專案明確提供

不同電腦的中文字型位置不同。最穩定做法是在取得合法再散布授權後,將指定字型作為專案資產,或由部署環境透過設定提供路徑。

import os
from pathlib import Path
from PIL import ImageFont


def load_font(size):
    font_path = Path(os.environ["SHUIJING_FONT_PATH"])
    if not font_path.is_file():
        raise FileNotFoundError("找不到指定中文字型")
    return ImageFont.truetype(str(font_path), size=size)


title_font = load_font(64)

不要假設Arial、微軟正黑體或Noto一定存在;也不要未確認授權就把系統字型複製到公開專案。

十、使用textbbox量字,再畫文字底板

from PIL import ImageDraw


def draw_title(image, title, font, margin=48):
    draw = ImageDraw.Draw(image, "RGBA")
    box = draw.textbbox((0, 0), title, font=font)
    text_width = box[2] - box[0]
    text_height = box[3] - box[1]

    x = margin
    y = image.height - text_height - margin * 2
    panel = (
        x - 20,
        y - 16,
        min(x + text_width + 20, image.width - margin),
        y + text_height + 20,
    )
    draw.rounded_rectangle(
        panel, radius=18, fill=(13, 66, 55, 205)
    )
    draw.text(
        (x, y), title, font=font,
        fill=(255, 255, 255, 255),
        stroke_width=1,
        stroke_fill=(0, 0, 0, 180),
    )


card = make_card_background(
    open_oriented_rgb("images/marriage_flower.jpg")
)
draw_title(card, "水井三寶|姻緣花", load_font(64))

textbbox()可先取得文字範圍,再決定底板大小。長標題仍可能超出畫面,下一節加入自動縮小。

十一、讓長標題自動縮小

from PIL import ImageDraw


def fit_font(draw, text, max_width, start=72, minimum=28):
    for size in range(start, minimum - 1, -2):
        font = load_font(size)
        left, top, right, bottom = draw.textbbox(
            (0, 0), text, font=font
        )
        if right - left <= max_width:
            return font
    raise ValueError("標題太長,請編輯文字或改用多行版型")

自動縮小仍應設最低可讀字級。若再縮就看不清楚,應改寫標題或設計多行文字,而不是讓字擠成一條。

十二、加入半透明來源標示

from PIL import Image, ImageDraw


def add_credit(image, text, font):
    base = image.convert("RGBA")
    layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    box = draw.textbbox((0, 0), text, font=font)
    width = box[2] - box[0]
    height = box[3] - box[1]
    position = (
        base.width - width - 30,
        24,
    )
    draw.text(
        position, text, font=font,
        fill=(255, 255, 255, 190),
        stroke_width=2,
        stroke_fill=(0, 0, 0, 150),
    )
    return Image.alpha_composite(base, layer)


credited = add_credit(
    card, "影像來源:經社區授權", load_font(28)
)

浮水印不能取代授權。文字要依真實來源填寫;AI生成或經AI大幅修改的圖片,也應依使用情境清楚標示。

十三、輸出JPEG或PNG前先處理色彩模式

from pathlib import Path


def save_for_web(image, destination):
    destination = Path(destination)
    destination.parent.mkdir(parents=True, exist_ok=True)
    suffix = destination.suffix.lower()

    if suffix in {".jpg", ".jpeg"}:
        image.convert("RGB").save(
            destination,
            format="JPEG",
            quality=88,
            optimize=True,
        )
    elif suffix == ".png":
        image.convert("RGBA").save(
            destination,
            format="PNG",
            optimize=True,
        )
    else:
        raise ValueError("輸出格式只允許JPEG或PNG")


save_for_web(credited, "output/marriage_flower_card.jpg")

JPEG不支援透明度,RGBA直接存JPEG會出錯;PNG適合透明圖示,但照片可能較大。重新建立輸出檔可避免原照片中的GPS等EXIF跟著公開,但若你主動傳入exif或其他metadata則另當別論。

十四、完成單張水井三寶圖卡函式

from PIL import ImageDraw


def create_local_card(
    source, destination, title, credit,
    size=(1200, 1200), crop_mode="crop"
):
    image = open_oriented_rgb(source)
    card = make_card_background(image, size, crop_mode)

    draw = ImageDraw.Draw(card, "RGBA")
    title_font = fit_font(
        draw, title, max_width=size[0] - 120
    )
    draw_title(card, title, title_font, margin=48)
    card = add_credit(card, credit, load_font(28))
    save_for_web(card, destination)

    return {
        "source": str(source),
        "destination": str(destination),
        "title": title,
        "size": size,
    }


result = create_local_card(
    "images/turtle.jpg",
    "output/turtle_card.jpg",
    "水井三寶|烏龜",
    "影像來源:經社區授權",
)
print(result)

十五、批次處理三張圖卡

JOBS = [
    {
        "source": "images/turtle.jpg",
        "destination": "output/turtle_card.jpg",
        "title": "水井三寶|烏龜",
    },
    {
        "source": "images/white_horse.jpg",
        "destination": "output/white_horse_card.jpg",
        "title": "水井三寶|白馬",
    },
    {
        "source": "images/marriage_flower.jpg",
        "destination": "output/marriage_flower_card.jpg",
        "title": "水井三寶|姻緣花",
    },
]

records = []
for job in JOBS:
    try:
        record = create_local_card(
            **job,
            credit="影像來源:經社區授權",
        )
        record["status"] = "success"
    except Exception as error:
        record = {
            "source": job["source"],
            "status": "failed",
            "error": type(error).__name__,
        }
    records.append(record)

for record in records:
    print(record)

批次工具不應因一張壞圖讓所有工作中止,但錯誤紀錄不要包含個資、秘密路徑或完整內部例外內容。

十六、用雜湊與尺寸驗證輸出

import hashlib
from pathlib import Path
from PIL import Image


def inspect_output(path, expected_size=(1200, 1200)):
    path = Path(path)
    digest = hashlib.sha256(path.read_bytes()).hexdigest()
    with Image.open(path) as image:
        image.load()
        if image.size != expected_size:
            raise ValueError("輸出尺寸不正確")
        return {
            "path": str(path),
            "format": image.format,
            "mode": image.mode,
            "size": image.size,
            "bytes": path.stat().st_size,
            "sha256": digest,
        }


print(inspect_output("output/turtle_card.jpg"))

雜湊可協助確認檔案是否變更,不能證明圖片內容正確。仍要人工檢查裁切、文字、對比、地方名稱與來源標示。

十七、發布前的人文與倫理檢查

檢查發布前要確認
著作權攝影者、插畫者或權利人是否同意使用及改作?
肖像與隱私可辨識人物是否同意?是否含門牌、車牌或位置資訊?
地方詮釋「水井三寶」名稱與故事是否經社區夥伴確認?
AI透明AI生成、補圖或大幅修改是否清楚標示?
可近用性網站是否提供替代文字,而非只把文字畫進圖片?
原圖要另外保管:批次程式輸出到獨立資料夾,不覆蓋社區提供的原始照片。若未取得公開同意,處理完成也不代表可以上網發布。

十八、課堂挑戰

挑戰A|基礎:用同一張照片分別產生thumbnail、crop與contain三種版本,說明各自適合的情境。
挑戰B|進階:讓長標題自動換成兩行,確保不低於32px,並為文字底板保留左右安全距離。
挑戰C|USR場域:和社區夥伴共同建立圖卡metadata表,記錄作品名、來源、授權範圍、人物同意、AI使用、審核者及下架日期。

十九、用AI協助檢查,但不要交出未授權照片

請擔任Python Pillow程式碼審查助教。
我要把社區授權照片製作成1200×1200地方圖卡。
請檢查:不可信圖片驗證、解壓縮炸彈、EXIF方向、
等比例縮放、裁切重心、中文字型授權、文字可讀性、
JPEG/PNG模式、metadata移除、錯誤處理與不覆蓋原圖。
不要要求我上傳未取得同意的人像或含GPS的原始照片。
請提供最小測試案例與人工審查清單。

二十、延伸閱讀

沒有留言:

張貼留言