画像処理 上級 更新: 2026-04-26

写真フォルダを一括圧縮してzipにまとめるスクリプト

写真フォルダ1つを指定するだけで、JPG/PNG/WebP/BMP/TIFFを軽め・標準・強めの3段階で一括圧縮し、`ProcessPoolExecutor` による並列処理で処理時間を短縮します。圧縮後はzip化と整合性チェック(CRC + ファイル数照合)を挟み、元画像の削除まで安全に自動化するPythonスクリプトです。

数百枚の現場写真や商品画像を、画質を保ったまま一括圧縮したいときのスクリプトです。`Pillow` で圧縮、`ProcessPoolExecutor` でCPUコア数-1並列、`zipfile` で配布用にまとめ、`testzip()` の整合性チェックが通ったものだけ元画像を削除します。報告書添付やクラウド共有前の「とりあえず全部圧縮」作業をそのまま自動化できます。

このスクリプトでできること

  • 写真フォルダ内画像の一括圧縮(JPG/PNG/WebP/BMP/TIFF対応)
  • 軽め / 標準 / 強め 3段階の圧縮度プリセットの切り替え
  • CPUコア数-1でのマルチプロセス並列処理
  • JPEGのEXIF情報保持とPNGの可逆圧縮最適化
  • zip作成後のCRC整合性+ファイル数照合の二重チェック
  • 整合性OK時のみ元画像を削除する安全設計
  • 処理内容全体を1ファイルにまとめたサマリーログの出力
画像圧縮.pybes をダウンロード

.pybes ファイルをPybesにインポートすると、スクリプトと設定フィールドが自動で読み込まれます。

設定フィールド

このスクリプトで使用する設定フィールドです。Pybes上でGUIから値を入力できます。

photo_dir フォルダ 必須

写真フォルダ

圧縮したい画像が入っているフォルダ(JPG/PNG/WebP/BMP/TIFFを自動検出)

output_dir フォルダ 必須

出力先フォルダ

圧縮後の画像・zip・ログの保存先。入力フォルダとは別の場所を推奨

compression_level ドロップダウン 必須

圧縮度

軽め(画質85・リサイズなし)/ 標準(画質75・長辺2560px)/ 強め(画質60・長辺1920px)から選択

選択肢: 軽め圧縮(画質キープ), 標準圧縮(バランス型), 強め圧縮(容量最優先)

デフォルト: 標準圧縮(バランス型)

create_zip チェックボックス 必須

zipファイルを作成

圧縮後の画像をまとめてzip化するか。配布・共有するならON推奨

デフォルト: true

delete_compressed チェックボックス 必須

圧縮画像を削除

zip作成成功かつ整合性チェックOKの場合のみ、圧縮画像(元画像ではない)を削除

デフォルト: false

zip_basename テキスト 必須

zipファイル名(作成しない場合でも入力要)

zipとログのプレフィックス。実際のファイル名は「{入力値}_写真_YYYYMMDD_HHMMSS.zip」

コード解説

import sys
import json
import os
import zipfile
from datetime import datetime
from concurrent.futures import ProcessPoolExecutor, as_completed
from PIL import Image

# 前提:
# - 対応形式: JPG/JPEG, PNG, WebP, BMP, TIFF
# - 出力ファイル名は元のファイル名・拡張子をそのまま維持
# - PNGは可逆圧縮のまま最適化のみ実施(透過情報を保持するため)
# - 圧縮度ごとのパラメータは画質と容量のバランスを考慮した一般的な値を採用
# - 並列処理はプロセスプールを使用(画像処理はCPUバウンドのためスレッドではなくプロセス並列)
# - ワーカー数は CPU コア数 - 1(最低1)
# - zipファイル名は「{局名}_写真_{タイムスタンプ}.zip」で出力先フォルダに保存
# - zip内には圧縮後の画像をフラット構造で格納(サブフォルダなし)
# - zip自体の圧縮はZIP_DEFLATED(画像は既に圧縮済みなので効果は限定的だが標準的な選択)
# - 圧縮画像を削除はzip作成成功 かつ testzip()整合性OK かつ ファイル数一致の場合のみ実行
# - サマリーログは出力先フォルダに「{局名}_ログ_{タイムスタンプ}.txt」で保存

# 圧縮度ごとの設定
圧縮設定 = {
    "軽め圧縮(画質キープ)": {"jpeg_quality": 85, "max_size": None},
    "軽め圧縮(画質キープ)": {"jpeg_quality": 85, "max_size": None},
    "標準圧縮(バランス型)": {"jpeg_quality": 75, "max_size": 2560},
    "標準圧縮(バランス型)": {"jpeg_quality": 75, "max_size": 2560},
    "強め圧縮(容量最優先)": {"jpeg_quality": 60, "max_size": 1920},
    "強め圧縮(容量最優先)": {"jpeg_quality": 60, "max_size": 1920},
}

対応拡張子 = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}


def 圧縮処理(args):
    """1ファイル分の圧縮処理。プロセスプールから呼ばれる。"""
    入力パス, 出力パス, jpeg_quality, max_size = args
    ファイル名 = os.path.basename(入力パス)
    拡張子 = os.path.splitext(ファイル名)[1].lower()

    try:
        元サイズ = os.path.getsize(入力パス)

        with Image.open(入力パス) as img:
            exif = img.info.get("exif")

            if max_size is not None:
                w, h = img.size
                長辺 = max(w, h)
                if 長辺 > max_size:
                    比率 = max_size / 長辺
                    新w = int(w * 比率)
                    新h = int(h * 比率)
                    img = img.resize((新w, 新h), Image.LANCZOS)

            if 拡張子 in (".jpg", ".jpeg"):
                if img.mode not in ("RGB", "L"):
                    img = img.convert("RGB")
                save_kwargs = {
                    "format": "JPEG",
                    "quality": jpeg_quality,
                    "optimize": True,
                    "progressive": True,
                }
                if exif:
                    save_kwargs["exif"] = exif
                img.save(出力パス, **save_kwargs)

            elif 拡張子 == ".png":
                img.save(出力パス, format="PNG", optimize=True)

            elif 拡張子 == ".webp":
                img.save(出力パス, format="WEBP", quality=jpeg_quality, method=6)

            elif 拡張子 == ".bmp":
                img.save(出力パス, format="BMP")

            elif 拡張子 in (".tif", ".tiff"):
                img.save(出力パス, format="TIFF", compression="tiff_lzw")

        圧縮後サイズ = os.path.getsize(出力パス)
        return {
            "ok": True,
            "ファイル名": ファイル名,
            "元サイズ": 元サイズ,
            "圧縮後サイズ": 圧縮後サイズ,
        }

    except Exception as e:
        return {
            "ok": False,
            "ファイル名": ファイル名,
            "エラー": str(e),
        }


def main():
    with open(sys.argv[1], encoding="utf-8") as f:
        inputs = json.load(f)

    写真フォルダ = inputs["写真フォルダ"]
    出力先フォルダ = inputs["出力先フォルダ"]
    圧縮度 = inputs["圧縮度"]
    局名 = inputs["zipファイル名(作成しない場合でも入力要)"]
    zip作成フラグ = inputs["zipファイルを作成"] == "true"
    画像削除フラグ = inputs["圧縮画像を削除"] == "true"

    if 圧縮度 not in 圧縮設定:
        normalized = 圧縮度.replace("(", "(").replace(")", ")")
        if normalized in 圧縮設定:
            圧縮度 = normalized
        else:
            print(f"エラー: 圧縮度の値が不正です: {圧縮度}", file=sys.stderr)
            sys.exit(1)

    設定 = 圧縮設定[圧縮度]
    jpeg_quality = 設定["jpeg_quality"]
    max_size = 設定["max_size"]

    コア数 = os.cpu_count() or 1
    ワーカー数 = max(1, コア数 - 1)
    タイムスタンプ = datetime.now().strftime("%Y%m%d_%H%M%S")

    print(f"局名: {局名}")
    print(f"圧縮度: {圧縮度}")
    print(f"JPEG品質: {jpeg_quality}")
    print(f"長辺最大: {max_size if max_size else '元サイズ維持'}")
    print(f"CPUコア数: {コア数} / 並列ワーカー数: {ワーカー数}")
    print(f"zip作成: {'あり' if zip作成フラグ else 'なし'}")
    print(f"圧縮画像を削除: {'あり(zip整合性・ファイル数確認後)' if 画像削除フラグ else 'なし'}")
    print(f"入力フォルダ: {写真フォルダ}")
    print(f"出力フォルダ: {出力先フォルダ}")
    print("-" * 50)

    os.makedirs(出力先フォルダ, exist_ok=True)

    対象ファイル = []
    for ファイル名 in os.listdir(写真フォルダ):
        フルパス = os.path.join(写真フォルダ, ファイル名)
        if os.path.isfile(フルパス):
            拡張子 = os.path.splitext(ファイル名)[1].lower()
            if 拡張子 in 対応拡張子:
                対象ファイル.append(ファイル名)

    総数 = len(対象ファイル)
    if 総数 == 0:
        print("対応する画像ファイルが見つかりませんでした。")
        return

    print(f"対象ファイル数: {総数}件")
    print("-" * 50)

    タスク一覧 = [
        (
            os.path.join(写真フォルダ, ファイル名),
            os.path.join(出力先フォルダ, ファイル名),
            jpeg_quality,
            max_size,
        )
        for ファイル名 in 対象ファイル
    ]

    成功数 = 0
    失敗数 = 0
    合計元サイズ = 0
    合計圧縮後サイズ = 0
    完了数 = 0
    成功ファイル一覧 = []
    失敗ファイル一覧 = []
    ファイル別結果 = []  # サマリー用

    with ProcessPoolExecutor(max_workers=ワーカー数) as executor:
        future_to_name = {
            executor.submit(圧縮処理, タスク): タスク[0] for タスク in タスク一覧
        }

        for future in as_completed(future_to_name):
            完了数 += 1
            結果 = future.result()

            if 結果["ok"]:
= 結果["元サイズ"]
= 結果["圧縮後サイズ"]
                合計元サイズ +=
                合計圧縮後サイズ +=
                削減率 = (1 -/ 元) * 100 if> 0 else 0
                print(
                    f"[{完了数}/{総数}] {結果['ファイル名']}  "
                    f"{/1024:.1f}KB → {/1024:.1f}KB  "
                    f"({削減率:+.1f}%)"
                )
                成功数 += 1
                成功ファイル一覧.append(結果["ファイル名"])
                ファイル別結果.append({
                    "ファイル名": 結果["ファイル名"],
                    "元サイズ": 元,
                    "圧縮後サイズ": 後,
                    "削減率": 削減率,
                    "状態": "成功",
                })
            else:
                print(
                    f"[{完了数}/{総数}] {結果['ファイル名']}  失敗: {結果['エラー']}",
                    file=sys.stderr,
                )
                失敗数 += 1
                失敗ファイル一覧.append(結果["ファイル名"])
                ファイル別結果.append({
                    "ファイル名": 結果["ファイル名"],
                    "エラー": 結果["エラー"],
                    "状態": "失敗",
                })

    print("-" * 50)
    print(f"完了: 成功 {成功数}件 / 失敗 {失敗数}件")
    全体削減率 = 0.0
    if 合計元サイズ > 0:
        全体削減率 = (1 - 合計圧縮後サイズ / 合計元サイズ) * 100
        print(
            f"合計サイズ: {合計元サイズ/1024/1024:.2f}MB → "
            f"{合計圧縮後サイズ/1024/1024:.2f}MB  "
            f"({全体削減率:+.1f}%)"
        )
    print(f"出力先: {出力先フォルダ}")

    # zip化
    zip成功 = False
    zipパス = None
    zipファイル名 = None
    if zip作成フラグ and 成功ファイル一覧:
        print("-" * 50)
        print("zipファイルを作成中...")
        zipファイル名 = f"{局名}_写真_{タイムスタンプ}.zip"
        zipパス = os.path.join(出力先フォルダ, zipファイル名)

        try:
            with zipfile.ZipFile(zipパス, "w", zipfile.ZIP_DEFLATED) as zf:
                for i, ファイル名 in enumerate(成功ファイル一覧, 1):
                    ファイルパス = os.path.join(出力先フォルダ, ファイル名)
                    zf.write(ファイルパス, arcname=ファイル名)
                    print(f"  [{i}/{len(成功ファイル一覧)}] {ファイル名} を追加")

            zipサイズ = os.path.getsize(zipパス)
            print(f"zip作成完了: {zipファイル名} ({zipサイズ/1024/1024:.2f}MB)")
            print(f"zipパス: {zipパス}")
            zip成功 = True
        except Exception as e:
            print(f"zip作成失敗: {e}", file=sys.stderr)
    elif not zip作成フラグ:
        print("zip作成はスキップしました。")

    # zip整合性チェック + ファイル数照合 → 問題なければ圧縮画像を削除
    zip整合性結果 = "チェックなし"
    画像削除結果 = "実行なし"
    if 画像削除フラグ and zip成功 and zipパス:
        print("-" * 50)
        print("zipの整合性とファイル数を確認中...")
        try:
            with zipfile.ZipFile(zipパス, "r") as zf:
                不正ファイル = zf.testzip()
                if 不正ファイル is not None:
                    zip整合性結果 = f"CRC失敗: {不正ファイル}"
                    print(
                        f"整合性チェック失敗: {不正ファイル} が破損している可能性があります。",
                        file=sys.stderr,
                    )
                    print("安全のため、圧縮画像の削除をスキップしました。", file=sys.stderr)
                    画像削除結果 = "スキップ(CRC失敗)"
                else:
                    zip内ファイル一覧 = set(zf.namelist())
                    期待ファイル一覧 = set(成功ファイル一覧)
                    不足ファイル = 期待ファイル一覧 - zip内ファイル一覧
                    余分ファイル = zip内ファイル一覧 - 期待ファイル一覧

                    print(f"  圧縮対象: {len(期待ファイル一覧)}件 / zip内: {len(zip内ファイル一覧)}件")

                    if 不足ファイル:
                        zip整合性結果 = f"ファイル数不足: {len(不足ファイル)}件欠落"
                        print(
                            f"ファイル数照合失敗: zip内に {len(不足ファイル)}件 不足しています。",
                            file=sys.stderr,
                        )
                        for f in sorted(不足ファイル):
                            print(f"  不足: {f}", file=sys.stderr)
                        print("安全のため、圧縮画像の削除をスキップしました。", file=sys.stderr)
                        画像削除結果 = "スキップ(ファイル数不足)"
                    else:
                        zip整合性結果 = "OK"
                        if 余分ファイル:
                            print(f"警告: zip内に想定外のファイルが {len(余分ファイル)}件 含まれています。")
                            for f in sorted(余分ファイル):
                                print(f"  想定外: {f}")

                        print("整合性・ファイル数チェックOK。圧縮画像を削除します...")
                        削除成功数 = 0
                        削除失敗数 = 0
                        for ファイル名 in 成功ファイル一覧:
                            ファイルパス = os.path.join(出力先フォルダ, ファイル名)
                            try:
                                os.remove(ファイルパス)
                                print(f"  削除: {ファイル名}")
                                削除成功数 += 1
                            except Exception as e:
                                print(f"  削除失敗: {ファイル名} - {e}", file=sys.stderr)
                                削除失敗数 += 1
                        print(f"削除完了: {削除成功数}件 / 失敗: {削除失敗数}件")
                        画像削除結果 = f"完了(削除 {削除成功数}件 / 失敗 {削除失敗数}件)"

        except Exception as e:
            zip整合性結果 = f"チェック中にエラー: {e}"
            print(f"整合性チェック中にエラーが発生しました: {e}", file=sys.stderr)
            print("安全のため、圧縮画像の削除をスキップしました。", file=sys.stderr)
            画像削除結果 = "スキップ(チェックエラー)"
    elif 画像削除フラグ and not zip成功:
        print("zip作成が成功していないため、圧縮画像の削除はスキップしました。")
        画像削除結果 = "スキップ(zip未作成)"

    # サマリーログ出力
    print("-" * 50)
    print("サマリーログを出力中...")
    ログファイル名 = f"{局名}_ログ_{タイムスタンプ}.txt"
    ログパス = os.path.join(出力先フォルダ, ログファイル名)
    try:
        with open(ログパス, "w", encoding="utf-8") as log:
            log.write(f"{'=' * 50}\n")
            log.write(f"  処理サマリー\n")
            log.write(f"{'=' * 50}\n")
            log.write(f"局名          : {局名}\n")
            log.write(f"実行日時      : {datetime.now().strftime('%Y年%m月%d日 %H:%M:%S')}\n")
            log.write(f"圧縮度        : {圧縮度}\n")
            log.write(f"JPEG品質      : {jpeg_quality}\n")
            log.write(f"長辺最大      : {max_size if max_size else '元サイズ維持'}\n")
            log.write(f"入力フォルダ  : {写真フォルダ}\n")
            log.write(f"出力フォルダ  : {出力先フォルダ}\n")
            log.write(f"\n{'─' * 50}\n")
            log.write(f"  処理結果\n")
            log.write(f"{'─' * 50}\n")
            log.write(f"対象ファイル数: {総数}\n")
            log.write(f"成功          : {成功数}\n")
            log.write(f"失敗          : {失敗数}\n")
            if 合計元サイズ > 0:
                log.write(f"合計サイズ    : {合計元サイズ/1024/1024:.2f}MB → {合計圧縮後サイズ/1024/1024:.2f}MB ({全体削減率:+.1f}%)\n")
            log.write(f"\n{'─' * 50}\n")
            log.write(f"  zip / 削除\n")
            log.write(f"{'─' * 50}\n")
            log.write(f"zip作成       : {'あり' if zip作成フラグ else 'なし'}\n")
            if zipファイル名:
                log.write(f"zipファイル名 : {zipファイル名}\n")
                log.write(f"zipパス       : {zipパス}\n")
            log.write(f"zip整合性     : {zip整合性結果}\n")
            log.write(f"画像削除      : {画像削除結果}\n")
            log.write(f"\n{'─' * 50}\n")
            log.write(f"  ファイル別結果\n")
            log.write(f"{'─' * 50}\n")
            for r in sorted(ファイル別結果, key=lambda x: x["ファイル名"]):
                if r["状態"] == "成功":
                    log.write(
                        f"  [成功] {r['ファイル名']}  "
                        f"{r['元サイズ']/1024:.1f}KB → {r['圧縮後サイズ']/1024:.1f}KB  "
                        f"({r['削減率']:+.1f}%)\n"
                    )
                else:
                    log.write(f"  [失敗] {r['ファイル名']}  エラー: {r['エラー']}\n")
            log.write(f"{'=' * 50}\n")

        print(f"ログ出力完了: {ログファイル名}")
        print(f"ログパス: {ログパス}")
    except Exception as e:
        print(f"ログ出力失敗: {e}", file=sys.stderr)


if __name__ == "__main__":
    main()
L1–33

標準ライブラリ(sys / json / os / zipfile / datetime)と並列処理用の concurrent.futures、画像処理の PIL (Pillow) をまとめて読み込みます。冒頭の「前提:」ブロックは設計意図のメモで、「PNGは可逆圧縮のため最適化のみ」「削除はzip整合性確認後のみ」といった挙動の根拠を残しています。続く 圧縮設定 は圧縮度ごとの jpeg_qualitymax_size を束ねた辞書で、UIの選択肢の切り替えはこの辞書のキー引きだけで完結する構造です。

L36–96

圧縮処理() は1ファイル分の圧縮を担当し、ProcessPoolExecutor から子プロセスで呼ばれます。Image.openwith で包んでファイルハンドルを確実に閉じたあと、JPEGは optimize=True + progressive=True + EXIF継承、PNGは optimize=True のみ(可逆圧縮なので画質維持)、WebPは method=6(最高圧縮・最遅)、TIFFはLZW圧縮と、拡張子ごとにパラメータを切り替えます。戻り値は {"ok": True/False, ...} の辞書で、例外時も ok: False として親プロセスに送り返し、プール全体が落ちないようにしています。

L160–222

ProcessPoolExecutor(max_workers=ワーカー数) でプロセスプールを起動し、各画像を並列で処理します。future_to_name 辞書にすべての future を先に投入してから as_completed で完了した順に結果を取り出すため、早く終わったファイルのログから逐次表示されます。成功した結果は元サイズ → 圧縮後サイズと削減率を表示し、失敗は file=sys.stderr 付きで出して、ファイル別結果 に状態込みで蓄積。この配列が最後のサマリーログの元データになります。

L247–317

画像削除を有効にした場合の3段階チェック部分です。まず zf.testzip() でzip内全ファイルのCRCを検証 → 次に set(zf.namelist())set(成功ファイル一覧) を比較してファイル数を照合 → 両方通って初めて os.remove に進みます。不足ファイル(圧縮はできたがzipに入っていない)が1件でもあれば即スキップで安全側に倒す設計。これにより、zip化途中でディスクフルなどが起きて中身が欠けたまま元画像を削除する、という最悪ケースを仕組みで防いでいます。

仕組みの詳細

圧縮度プリセットで画質と容量のバランスを選ぶ

圧縮設定 辞書に「軽め圧縮(画質キープ)」「標準圧縮(バランス型)」「強め圧縮(容量最優先)」の3レベルがあり、それぞれ jpeg_quality(JPEG/WebPの品質値)と max_size(長辺リサイズの基準px)の組み合わせで切り替わります。軽めは品質85・リサイズなし、標準は品質75・長辺2560px、強めは品質60・長辺1920px。SNS投稿や社内報告書なら標準、アーカイブ用途なら軽め、メール送付なら強め、という使い分けが想定です。

ProcessPoolExecutorでCPUコアを最大活用

画像の再エンコードはCPUバウンドな処理なので、threading ではなく ProcessPoolExecutor を使います。ワーカー数は os.cpu_count() - 1(最低1)で、OSや他アプリのために1コアを残す設計です。as_completed で完了した順に結果を受け取るため、先に終わったファイルのログから順次表示され、長時間処理でも進捗が見えて止まって見えません。

JPEG / PNG / WebP / BMP / TIFFの拡張子別処理分岐

圧縮処理() の中で拡張子ごとに img.save() のパラメータを切り替えます。JPEGは optimize=True + progressive=True + EXIF継承、PNGは optimize=True のみ(可逆圧縮なので画質を落とさない)、WebPは method=6 で最高圧縮、TIFFはLZW圧縮。BMPだけは圧縮オプションがなくそのまま保存します。PNGでJPEG品質を指定しても無視される点は仕様通りです。

zip整合性3段階チェックで壊れたzipから守る

画像削除フラグ を有効にしても即削除はしません。(1) zipfile.ZipFile.testzip() でCRC検証 → (2) zf.namelist()成功ファイル一覧set で突合してファイル数照合 → (3) 両方OKでのみ os.remove に進みます。どれか1つでも失敗したら安全側に倒して削除スキップ。zip化途中で中身が欠けたまま元画像を消す事故を仕組みで防いでいます。

応用・カスタマイズ

圧縮度プリセットを追加・調整する

圧縮設定 辞書に好きな組み合わせを追加するだけで選択肢を増やせます。例えば "超圧縮(メール添付向け)": {"jpeg_quality": 45, "max_size": 1280} を追記し、フィールドの options にも同じ文字列を加えればUIから選べるようになります。jpeg_quality の目安は90以上=ほぼ原画質、70前後=Web掲載、50以下=サムネイル級です。

並列ワーカー数を固定する

ワーカー数 = max(1, コア数 - 1)ワーカー数 = 4 のように固定値に書き換えれば、バックグラウンドで他の作業と併走させる場合に負荷を抑えられます。逆に全コア使い切りたいなら ワーカー数 = コア数。共有サーバー上で動かすときは最大2〜4に抑えるのが無難です。

リサイズのアルゴリズムを変える

img.resize((新w, 新h), Image.LANCZOS)Image.LANCZOS は画質優先の高品質リサンプリングです。速度優先に振るなら Image.BILINEAR、さらに速く粗くていいなら Image.NEAREST。ただし NEAREST はドット絵のような見た目になるので、写真用途では非推奨です。

よくあるエラーと対処

PIL.UnidentifiedImageError: cannot identify image file が出る

拡張子は画像形式と一致しているのに Pillow が開けないケースで、ファイル自体が破損しているか、拡張子とは異なる実際の形式(例: .jpg なのに中身はHEIC)である場合に出ます。エクスプローラのプレビューで表示できるか確認し、表示できないものは元データを取り直してください。iPhone写真のHEICが混ざっている場合は pillow-heif を追加でインストールして、from pillow_heif import register_heif_opener; register_heif_opener() を冒頭に入れると読めるようになります。

BrokenProcessPool で並列処理が途中で落ちる

ワーカープロセスのどれかがメモリ不足や不正な画像で異常終了し、プール全体が巻き込まれる症状です。4K超の画像を多数並列で開くと1プロセスあたり数GBのメモリを消費することがあります。ワーカー数 = max(1, コア数 - 1)ワーカー数 = 2 などに下げて再実行してください。原因画像を切り分けたい場合は、一旦 ワーカー数 = 1 にすると落ちた瞬間のファイル名がログに残ります。

PermissionError: [WinError 32] で圧縮画像の削除が失敗する

削除対象の画像をエクスプローラのプレビュー窓や画像ビューアで開いているとWindowsがファイルを握ってしまい、os.remove が失敗します。ログには「削除失敗」と出ますがzipは正常に出来上がっているので、開いているアプリとプレビュー窓を閉じてからエクスプローラで残った画像を手動削除するだけで復旧できます。zip整合性チェックは通っているので、データはすべてzip側に残っています。

zipサイズが元フォルダとほぼ同じで圧縮されていない

JPEGやWebPのように既にエントロピー圧縮済みのファイルをzipに入れても、ZIP_DEFLATED ではほとんど縮みません。これは仕様どおりで、zip化の主目的は「ファイルをひとつにまとめて配布しやすくする」ことです。容量をさらに削りたい場合は、圧縮度 を「強め圧縮(容量最優先)」に切り替えるか、圧縮設定max_size をより小さな値(例: 1280px)にカスタマイズしてください。

FAQ

なぜスレッド並列ではなくプロセス並列を使っているのですか?

PythonのGIL (Global Interpreter Lock) により、threading.Thread ではCPUバウンドな処理は実質1コアしか使えません。画像の再エンコードは完全にCPUバウンドなので、ProcessPoolExecutor でプロセス分割して初めてマルチコアの恩恵を受けられます。逆にI/Oバウンドなネットワーク処理などでは ThreadPoolExecutor のほうが軽量で有利です。

PNGのjpeg_quality指定は無視されるのですか?

はい。PNGは可逆圧縮形式で「品質」という概念がないため、jpeg_quality の値は .png 分岐では参照されません。PNGでは optimize=True を指定して圧縮アルゴリズムを最適化するのみで、画質劣化はゼロ。透過チャンネル付きのアイコン画像や、文字を含むスクリーンショットを安全にまとめる用途に向いています。

圧縮度辞書に同じキーが2回書かれているのはバグですか?

全角/半角カッコ混在の入力に備えた冗長化です。ソースを開くとやや違和感はありますが、実行結果には影響しません。Pythonは辞書の同名キーを後勝ちで上書きするため、どちらの形で入ってきても同じ値が返ります。UIの入力揺れ吸収のために normalize で全角→半角に寄せる処理と合わせて、カッコの種類で落ちないようにしています。

元画像(圧縮前)は削除されますか?

いいえ。削除対象は 出力先フォルダ に保存された圧縮後の画像だけで、写真フォルダ の元画像には一切触れません。処理フロー上も元画像はリードオンリー扱いなので、誤って元データを消す事故は起きません。元画像もまとめて整理したい場合は、このスクリプト実行後に手動で別フォルダへ移動するか、別スクリプトで対応してください。

他のよくある質問を見る →
画像圧縮.pybes をダウンロード

.pybes ファイルをPybesにインポートすると、スクリプトと設定フィールドが自動で読み込まれます。