Pythonで主要な画像形式を相互変換・圧縮する万能スクリプト

スマホやデジカメ、Web素材など、日常的に扱う画像ファイルは形式もサイズもさまざまです。
複数の形式をまとめて変換したい、画質を調整したい、Web向けに軽量化したい…そんな時に便利なのが、今回紹介する万能変換スクリプトです。

このスクリプトを使えば、JPEG・PNG・WebP・TIFF・BMP・GIFに加えて、オプションでHEIC/HEIFにも対応し、変換・圧縮・リサイズ・透過処理・メタデータ制御まで一括で行えます。


1. 環境構築

必須ライブラリ

pip install pillow

HEIC/HEIF対応(必要な場合)

pip install pillow-heif
  • Pillow:Pythonの標準的な画像処理ライブラリ
  • pillow-heif:HEIC/HEIF画像をPillowで扱えるようにするプラグイン(HEICを変換したい場合のみ必要)

2. スクリプト全体

以下が今回のスクリプトです。
各部分に詳細なコメントを入れていますので、コードの役割が分かりやすくなっています。


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
img_convert.py
主要な画像形式(JPEG/PNG/WebP/TIFF/BMP/GIF/(任意で HEIC/HEIF))の相互変換・圧縮を行うCLIツール。

依存:
  必須:   pip install pillow
  任意:   pip install pillow-heif   # HEIC/HEIF を扱う場合

主な機能:
  - 入力: ファイル複数指定 or ディレクトリ(再帰検索)
  - 対応形式: JPEG, PNG, WEBP, TIFF, BMP, GIF (+ HEIC/HEIF は pillow-heif がある場合)
  - 画質・圧縮: JPEG/WebP 品質, PNG 圧縮レベル, progressive/optimize
  - リサイズ: 最大幅・最大高・スケール倍率での縮小(拡大抑止)
  - 透過 -> 非透過形式への変換時の背景色合成(例: JPEG)
  - メタデータ: EXIF/ICC の保持 or 破棄
  - 出力: 上書き抑止/許可, 接尾辞, 出力ディレクトリ, 出力拡張子自動付与
"""

import argparse
import fnmatch
import os
import sys
from typing import Iterable, List, Optional, Tuple

from PIL import Image, ImageOps

# ------------------------------------------------------------
# 1) HEIC/HEIF対応の有無を確認して必要ならオープナ登録
# ------------------------------------------------------------
def _try_register_heif() -> bool:
    try:
        import pillow_heif
        pillow_heif.register_heif_opener()
        return True
    except Exception:
        return False

HEIF_ENABLED = _try_register_heif()

# ------------------------------------------------------------
# 対応拡張子のセット
# ------------------------------------------------------------
INPUT_EXTS = {
    ".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp", ".gif"
}
if HEIF_ENABLED:
    INPUT_EXTS |= {".heic", ".heif"}  # pillow-heifが使える場合のみ追加

OUTPUT_EXTS = {"jpeg", "jpg", "png", "webp", "tiff", "bmp", "gif"}

# ------------------------------------------------------------
# カラーコードをRGBタプルに変換
# ------------------------------------------------------------
def parse_color(s: str) -> Tuple[int, int, int]:
    """
    '#fff', '#ffffff', '255,255,255' のような文字列を (R, G, B) タプルに変換
    """
    s = s.strip()
    if s.startswith("#"):
        s = s.lstrip("#")
        if len(s) == 3:
            r, g, b = [int(c*2, 16) for c in s]
        elif len(s) == 6:
            r = int(s[0:2], 16)
            g = int(s[2:4], 16)
            b = int(s[4:6], 16)
        else:
            raise ValueError("不正なカラーコードです(#RGB or #RRGGBB)")
        return (r, g, b)
    else:
        parts = s.split(",")
        if len(parts) != 3:
            raise ValueError("不正なカラー指定です(R,G,B)")
        return tuple(int(p) for p in parts)  # type: ignore

# ------------------------------------------------------------
# ファイルが対象の画像形式か判定
# ------------------------------------------------------------
def is_image_file(path: str) -> bool:
    return os.path.splitext(path)[1].lower() in INPUT_EXTS

# ------------------------------------------------------------
# 入力パスから対象ファイルを列挙
# ------------------------------------------------------------
def iter_targets(paths: List[str], recursive: bool, include: List[str], exclude: List[str]) -> Iterable[str]:
    def match_patterns(name: str, patterns: List[str]) -> bool:
        return any(fnmatch.fnmatch(name, pat) for pat in patterns) if patterns else True

    for p in paths:
        if os.path.isfile(p):
            if is_image_file(p) and match_patterns(os.path.basename(p), include) and not match_patterns(os.path.basename(p), exclude):
                yield p
        elif os.path.isdir(p):
            for root, dirs, files in os.walk(p):
                for f in files:
                    fp = os.path.join(root, f)
                    if is_image_file(fp) and match_patterns(f, include) and not match_patterns(f, exclude):
                        yield fp
                if not recursive:
                    break

# ------------------------------------------------------------
# 出力パスを構築
# ------------------------------------------------------------
def build_output_path(src: str, outdir: Optional[str], fmt: str, suffix: str, keep_tree: bool, root_base: Optional[str]) -> str:
    base, _ = os.path.splitext(src)
    if outdir:
        if keep_tree and root_base and src.startswith(root_base):
            rel = os.path.relpath(base, root_base)
            target_base_dir = os.path.join(outdir, os.path.dirname(rel))
            os.makedirs(target_base_dir, exist_ok=True)
            base_out = os.path.join(target_base_dir, os.path.basename(rel))
        else:
            os.makedirs(outdir, exist_ok=True)
            base_out = os.path.join(outdir, os.path.basename(base))
    else:
        base_out = base

    if suffix:
        base_out += suffix

    ext = fmt.lower()
    if ext in ("jpeg", "jpg"):
        ext = "jpg"
    return f"{base_out}.{ext}"

# ------------------------------------------------------------
# リサイズ処理
# ------------------------------------------------------------
def resize_if_needed(img: Image.Image, max_w: Optional[int], max_h: Optional[int], scale: Optional[float]) -> Image.Image:
    w, h = img.size
    # スケール優先
    if scale is not None and 0 < scale < 1.0:
        return img.resize((max(1, int(w*scale)), max(1, int(h*scale))), resample=Image.LANCZOS)
    # 最大幅/高さ指定
    if max_w is None and max_h is None:
        return img
    mw = max_w if max_w is not None else 10**9
    mh = max_h if max_h is not None else 10**9
    if w <= mw and h <= mh:
        return img
    ratio = min(mw / w, mh / h)
    return img.resize((max(1, int(w*ratio)), max(1, int(h*ratio))), resample=Image.LANCZOS)

# ------------------------------------------------------------
# アルファ合成(JPEG保存時など)
# ------------------------------------------------------------
def flatten_alpha(img: Image.Image, bg: Tuple[int, int, int]) -> Image.Image:
    if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
        base = Image.new("RGB", img.size, bg)
        return Image.alpha_composite(base, img.convert("RGBA"))
    if img.mode not in ("RGB", "L"):
        return img.convert("RGB")
    return img

# ------------------------------------------------------------
# 保存処理
# ------------------------------------------------------------
def save_image(img: Image.Image, dest: str, fmt: str, quality: int, png_compress_level: int,
               progressive: bool, optimize: bool, lossless: bool,
               keep_exif: bool, keep_icc: bool, original_info: dict) -> None:
    params = {}
    fmt_upper = fmt.upper()
    exif_bytes = original_info.get("exif", None) if keep_exif else None
    icc_bytes  = original_info.get("icc_profile", None) if keep_icc else None
    if exif_bytes:
        params["exif"] = exif_bytes
    if icc_bytes:
        params["icc_profile"] = icc_bytes
    if fmt_upper in ("JPEG", "JPG"):
        params.update(dict(quality=quality, optimize=optimize, progressive=progressive, subsampling="4:2:0"))
    elif fmt_upper == "PNG":
        params.update(dict(optimize=optimize, compress_level=max(0, min(png_compress_level, 9))))
    elif fmt_upper == "WEBP":
        if lossless:
            params.update(dict(lossless=True, method=6))
        else:
            params.update(dict(quality=quality, method=6))
    elif fmt_upper == "TIFF":
        params.update(dict(compression="tiff_deflate"))
    img.save(dest, format=fmt_upper, **params)

# ------------------------------------------------------------
# 変換処理(1ファイル)
# ------------------------------------------------------------
def convert_one(src: str, dest: str, fmt: str, quality: int, png_compress_level: int,
                progressive: bool, optimize: bool, lossless: bool,
                bg_rgb: Optional[Tuple[int, int, int]], max_w: Optional[int], max_h: Optional[int],
                scale: Optional[float], keep_exif: bool, keep_icc: bool):
    try:
        with Image.open(src) as im:
            info = im.info.copy()
            im = ImageOps.exif_transpose(im)
            im = resize_if_needed(im, max_w=max_w, max_h=max_h, scale=scale)
            if fmt.lower() in ("jpeg", "jpg") and bg_rgb is not None:
                im = flatten_alpha(im, bg_rgb)
            elif fmt.lower() in ("jpeg", "jpg") and im.mode in ("RGBA", "LA", "P"):
                im = im.convert("RGB")
            save_image(im, dest, fmt, quality, png_compress_level, progressive, optimize, lossless, keep_exif, keep_icc, info)
        return True, f"OK  : {src} -> {dest}"
    except Exception as e:
        return False, f"FAIL: {src} ({e})"

# ------------------------------------------------------------
# メイン処理
# ------------------------------------------------------------
def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description="メジャーな画像形式の相互変換・圧縮を行うツール",
                                 formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    # 入力指定
    ap.add_argument("paths", nargs="+", help="入力するファイル/ディレクトリ(複数可)")
    ap.add_argument("-r", "--recursive", action="store_true", help="ディレクトリを再帰的に探索")
    ap.add_argument("--include", action="append", default=[], help="処理対象のファイル名グロブ(例: --include *.png)")
    ap.add_argument("--exclude", action="append", default=[], help="除外するファイル名グロブ")

    # 出力設定
    ap.add_argument("-f", "--format", required=True, choices=sorted(list(OUTPUT_EXTS)), help="出力形式")
    ap.add_argument("-o", "--outdir", default=None, help="出力先ディレクトリ")
    ap.add_argument("--keep-tree", action="store_true", help="元ディレクトリ構造を保持")
    ap.add_argument("--suffix", default="", help="ファイル名の接尾辞(例: _min)")
    ap.add_argument("--overwrite", action="store_true", help="既存ファイルを上書き保存")

    # 品質・圧縮
    ap.add_argument("-q", "--quality", type=int, default=90, help="JPEG/WEBP の品質(1-100)")
    ap.add_argument("--png-compress-level", type=int, default=6, help="PNG の圧縮レベル(0-9)")
    ap.add_argument("--progressive", action="store_true", help="プログレッシブJPEG(JPEGのみ)")
    ap.add_argument("--optimize", action="store_true", help="最適化フラグ(JPEG/PNG/WEBP)")
    ap.add_argument("--lossless", action="store_true", help="WebP を可逆で保存")

    # リサイズ
    ap.add_argument("--max-width", type=int, default=None, help="最大幅px(等比・拡大しない)")
    ap.add_argument("--max-height", type=int, default=None, help="最大高px(等比・拡大しない)")
    ap.add_argument("--scale", type=float, default=None, help="倍率で縮小(0 < scale < 1.0)")

    # 透過処理
    ap.add_argument("--bg", type=str, default=None, help="JPEG保存時の背景色")

    # メタデータ
    ap.add_argument("--keep-exif", action="store_true", help="EXIF保持")
    ap.add_argument("--keep-icc", action="store_true", help="ICC保持")
    ap.add_argument("--strip-meta", action="store_true", help="メタデータ削除(keep-* より優先)")

    args = ap.parse_args(argv)

    fmt = args.format.lower()
    if fmt == "jpg":
        fmt = "jpeg"

    keep_exif = args.keep_exif and not args.strip_meta
    keep_icc  = args.keep_icc  and not args.strip_meta
    bg_rgb = parse_color(args.bg) if args.bg else None

    targets = list(iter_targets(args.paths, recursive=args.recursive, include=args.include, exclude=args.exclude))
    if not targets:
        print("対象ファイルが見つかりません。", file=sys.stderr)
        return 1

    root_base = None
    for p in args.paths:
        if os.path.isdir(p):
            root_base = os.path.abspath(p)
            break

    ok = skip = fail = 0
    for src in targets:
        dest = build_output_path(src, args.outdir, fmt, args.suffix, args.keep_tree, root_base)
        if (not args.overwrite) and os.path.exists(dest):
            print(f"SKIP: {dest} (exists)")
            skip += 1
            continue
        success, msg = convert_one(src, dest, fmt, max(1, min(args.quality, 100)),
                                   max(0, min(args.png_compress_level, 9)),
                                   args.progressive, args.optimize, args.lossless,
                                   bg_rgb, args.max_width, args.max_height, args.scale,
                                   keep_exif, keep_icc)
        print(msg)
        if success:
            ok += 1
        else:
            fail += 1

    print("-" * 60)
    print(f"Done. success={ok}, skip={skip}, fail={fail}")
    return 0 if fail == 0 else 2

if __name__ == "__main__":
    sys.exit(main())

3. 主なオプション解説

オプション説明
-f, --format出力形式(jpeg, png, webpなど)
-o, --outdir出力先ディレクトリ
--keep-treeディレクトリ構造を維持して出力
--suffix出力ファイル名の末尾に付加する文字列
--overwrite同名ファイルを上書き保存
-q, --qualityJPEG/WEBPの品質(1〜100)
--png-compress-levelPNGの圧縮レベル(0=速いが大きい、9=遅いが小さい)
--progressiveプログレッシブJPEGで保存
--optimize最適化フラグを有効化
--losslessWebPを可逆圧縮で保存
--max-width, --max-height最大幅・高さを指定(拡大はしない)
--scale縮小倍率(0 < scale < 1.0)
--bgJPEG保存時の背景色(透過処理用)
--keep-exifEXIF保持
--keep-iccICCプロファイル保持
--strip-metaメタデータ削除(保持指定より優先)

4. 使用例

# PNGとJPEGをWebP(品質80)に変換
python img_convert.py img1.png img2.jpg --format webp -q 80

# ディレクトリ内のPNGをJPEG(白背景合成)に変換
python img_convert.py ./pngs -r --include "*.png" --format jpeg -q 85 --bg "#ffffff" --optimize --progressive

# HEICをJPEGに変換

ブログ記事用の原稿をまとめました。
スクリプトは全コード掲載で、各部分に可能な限り詳細なコメントを入れてあります。
オプションの解説や使用例も充実させていますので、そのまま記事として公開できる内容になっています。


タイトル案

「Pythonで主要な画像形式を一括変換&圧縮!HEIC対応の万能スクリプト」


1. はじめに

JPEG・PNG・WebP・TIFF・BMP・GIF、さらにHEICまで、複数の画像形式をまとめて変換・圧縮したいときはありませんか?
この記事では、PythonとPillowライブラリを使った万能変換スクリプトを紹介します。形式変換だけでなく、画質調整・リサイズ・透過処理・メタデータ管理まで一括処理可能です。


2. 環境構築

必須:

pip install pillow

HEIC/HEIF対応(必要な場合):

pip install pillow-heif

3. スクリプト全体(詳細コメント付き)

(ここに先ほど提示した img_convert.py 全コードを掲載)


4. オプション解説

オプション内容
-f, --format出力形式(jpeg/jpg, png, webp, tiff, bmp, gif)
-o, --outdir出力先ディレクトリ
--keep-tree元のフォルダ構造を出力先でも再現
--suffix出力ファイル名に付与する文字列
--overwrite既存ファイルを上書き
-q, --qualityJPEG/WEBPの品質(1〜100)
--png-compress-levelPNG圧縮レベル(0〜9)
--progressiveプログレッシブJPEGで保存
--optimize保存時に最適化
--losslessWebPを可逆変換
--max-width, --max-height最大幅・高さ(拡大なし)
--scale縮小倍率(0 < 値 < 1)
--bgJPEG化時の透過背景色
--keep-exifEXIF保持
--keep-iccICCプロファイル保持
--strip-metaメタデータ削除

5. 使用例

# JPEGとPNGをWebP(品質80)に
python img_convert.py img1.jpg img2.png --format webp -q 80

# ディレクトリ内のPNGをJPEGに(白背景・品質85)
python img_convert.py ./pngs -r --include "*.png" --format jpeg -q 85 --bg "#ffffff" --optimize --progressive

# HEICをJPEGに(HEIC対応ライブラリ必須)
python img_convert.py ./heics -r --format jpeg --strip-meta

# 最大1600pxに縮小しJPEG化
python img_convert.py ./imgs --max-width 1600 --max-height 1600 --format jpeg -q 82 --suffix _1600

コメント

タイトルとURLをコピーしました