Amazonで商品を見るセール会場へ

psd-toolsの出力がPhotoshopと違うとき|プレビュー・再合成・対応限界の確認

psd-toolsの出力を調べるときは、PSD内の保存済み合成画像を取り出したのか、レイヤーから再合成したのかを分けます。保存済み画像が取り出せても、レイヤーの表示を変更した結果や、効果を再現した結果まで確認したことにはなりません。

最初に使用する版を記録し、代表PSDで保存済み画像と再合成の出力を比較します。基本の読み込み・PNG保存・レイヤーの取得はpsd-toolsの使い方で説明しています。

目次

保存済み画像と再合成の使い分け

呼び出し 確認する対象
psd.topil() PSD内の保存済み合成画像を取り出す。画像が得られるか、寸法・色・透明度を確認する
psd.composite() 保存済み画像を使う場合と再合成する場合がある。結果だけで経路を判断しない
psd.composite(ignore_preview=True) 保存済みプレビューを使わず、レイヤーから合成する
psd.composite(ignore_preview=True, force=True) 再合成とベクトル描画を明示して比較する。未対応機能を再現できるという指定ではない

今回読んだ1.21.0の実装では、ignore_preview、force、layer_filter の指定や、文書が更新された状態などで、保存済み画像を使う分岐が変わります。現在のPSDImage APIと実行する版を照合します。

保存済み合成画像は、PSDを保存した時点の表示を確認するために使います。Photoshopでの保存設定や、別のソフトで作成・変更したPSDの内容によって条件が異なるため、「プレビューがあれば必ずPhotoshopと同じ」とは扱いません。別ソフトで生成した今回のサンプルでも透明度の差を確認しました。

対応の表示と実際の描画を分ける

PSDの要素 確認する内容
ピクセルレイヤー・グループ 表示状態、親グループ、マスク、描画モードと合成結果
調整レイヤー 実行版の対応種類、色モード、パラメーターと見た目
シェイプ・塗り・効果 追加依存関係の有無と、対応している描画の範囲
文字 保存された画素の取得と、文字・フォントの描き直しを区別する
スマートオブジェクト レイヤーの保存画素と、埋め込み・リンク先データの取得を区別する
CMYK・16bit・32bit 同じ条件の代表PSDで、色と出力の対応を個別に確認する

公式のFeatures一覧には非対応と書かれた項目があり、Usage・合成APIでは一部の調整レイヤーへの対応も案内されています。2026年10月3日に読んだ文書間でも説明の粒度が一致しないため、一つの表だけで合成可否を決めません。公式Features、Usage、合成APIと実際の出力を照合します。

当PCの1.21.0には、明るさ・コントラスト、レベル、カーブ、露光量、反転、ポスタリゼーション、2階調化、色相・彩度の処理が登録されていました。処理が存在することと、そのPSDの色モード・設定でPhotoshopと一致することは別の確認です。今回、これらの調整レイヤーをPhotoshopと比較する試験は行っていません。

追加パッケージで解決する範囲

ベクトル描画などに必要な依存関係を要求された場合は、実行中のPython環境へ composite の追加依存関係を入れます。

python -m pip install "psd-tools[composite]"

公式文書ではaggdraw・scipy・scikit-imageを案内しています。これらを導入して読み込みエラーが消えても、未対応のレイヤー効果やフォント描画がすべて再現されるわけではありません。エラー文、Pythonとライブラリの版、対象PSDを記録し、出力画像も確認します。

変更履歴には描画・レイヤー状態の修正も含まれます。アップデート前後は同じPSDを別の出力先で比較し、以前の結果を上書きせず残します。

保存済み画像と再合成を比較するコード

次のコードを compare_psd.py として保存します。入力PSDは書き換えず、新しい出力フォルダーを作成し、画像2点と条件・結果のJSONを保存します。既にあるフォルダーを指定した場合は処理を止めます。

"""Compare cached preview and recomposition; never edit input or follow external links."""
import argparse
import hashlib
import json
import platform
from importlib.metadata import version
from pathlib import Path
import numpy as np
from PIL import Image
from psd_tools import PSDImage

def compare(source, output):
    source = Path(source)
    output = Path(output)
    digest = hashlib.sha256(source.read_bytes()).hexdigest()
    psd = PSDImage.open(source)
    output.mkdir(parents=True, exist_ok=False)
    report = {
        "source_sha256": digest,
        "python": platform.python_version(),
        "psd_tools": version("psd-tools"),
        "pillow": version("Pillow"),
        "numpy": version("numpy"),
        "size": list(psd.size),
        "has_preview": psd.has_preview(),
        "layers": [],
        "results": {},
    }
    for layer in psd.descendants():
        report["layers"].append({
            "name": layer.name, "kind": layer.kind,
            "visible": layer.is_visible(), "has_effects": layer.has_effects(),
        })
    images = {}
    operations = {
        "preview": lambda: psd.topil(apply_icc=True),
        "recomposed": lambda: psd.composite(ignore_preview=True, force=True, apply_icc=True),
    }
    for name, operation in operations.items():
        try:
            image = operation()
            if image is None:
                report["results"][name] = {"status": "no_image"}
                continue
            image.save(output / (name + ".png"))
            images[name] = image.convert("RGBA")
            report["results"][name] = {"status": "saved", "mode": image.mode, "size": list(image.size)}
        except Exception as error:
            report["results"][name] = {"status": "error", "type": type(error).__name__, "message": str(error)}
    if len(images) == 2:
        a, b = images["preview"], images["recomposed"]
        if a.size != b.size:
            report["comparison"] = {"status": "size_mismatch"}
        else:
            def on_white(image):
                bg = Image.new("RGBA", image.size, (255, 255, 255, 255))
                return np.asarray(Image.alpha_composite(bg, image).convert("RGB"), dtype=np.int16)
            delta = np.abs(on_white(a) - on_white(b))
            alpha_delta = np.abs(np.asarray(a.getchannel("A"), dtype=np.int16) -
                                 np.asarray(b.getchannel("A"), dtype=np.int16))
            report["comparison"] = {
                "status": "compared", "background": "white",
                "rgb_mae_0_255": float(delta.mean()), "rgb_max_0_255": int(delta.max()),
                "rgb_changed_pixel_percent": float(np.any(delta != 0, axis=2).mean() * 100),
                "alpha_mae_0_255": float(alpha_delta.mean()), "alpha_max_0_255": int(alpha_delta.max()),
            }
    report["source_unchanged"] = hashlib.sha256(source.read_bytes()).hexdigest() == digest
    (output / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    return report

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("input_psd")
    parser.add_argument("new_output_folder")
    args = parser.parse_args()
    report = compare(args.input_psd, args.new_output_folder)
    print(json.dumps(report.get("comparison", {"status": "not_compared"}), ensure_ascii=True))
python compare_psd.py sample.psd compare-output-01

report.json の results は、画像の保存・画像なし・処理エラーを区別します。両画像の寸法が同じ場合だけ、白背景に重ねたRGBの平均絶対差・最大差・差のある画素割合と、アルファの平均差・最大差を記録します。透明部分のRGBだけを比較すると、見えない画素の色が差として数えられるため、背景と透明度を分けました。

平均差0は、この比較条件で同じ画素値だったという意味です。Photoshopの表示との一致や、色管理を含む納品条件を保証する数値ではありません。平均だけでは小さい領域の差を見落とすため、透明部分、文字、影、境界線などの箇所も画像で確認します。寸法が異なる場合は画像を勝手に拡大・縮小せず、原因を調べます。

今回のサンプルで確認した結果

2026年10月3日にWindows 11、Python 3.13.13、psd-tools 1.21.0、Pillow 12.3.0、NumPy 2.5.3でコードを実行しました。入力は当方がPythonで作成したRGB・8bitのサンプルです。Photoshopで作成したPSDやPhotoshopのスクリーンショットではありません。

入力RGB平均差α最大差
単色(32×24px)00
透明な周囲・非表示グループ(640×480px)約221.80255

RGBの平均差は白背景に重ねた画像の値、αの最大差はアルファの値です。単色サンプルはこの条件で一致し、透明な周囲を持つサンプルは保存済み画像が不透明・再合成が透明で差が出ました。

この2例は、同じ比較コードが一致と差を区別できるかを確認したものです。入力ハッシュの不変、既存フォルダーへの処理拒否、既存レポートの不変を含む6項目を確認しました。実画像は入門記事の実行結果にも掲載しています。

文字・調整レイヤー・複雑なマスクや効果・スマートオブジェクト・16/32bit・Photoshopとの表示一致は今回の試験に含めていません。記事全体の対応表をこの2例で検証したとはしていません。

スマートオブジェクトの表示と中身

layer.topil() などで保存画素を取り出す処理と、layer.smart_object から中身のデータを読む処理は異なります。埋め込みならPSD内にデータがあり、リンクなら外部ファイルが必要になる場合があります。保存画素が取り出せるだけでは、リンク先を読めたことにはなりません。

現在のSmartObject APIは、リンク先を読むディレクトリの指定も案内しています。引数とパスの扱いは版で異なるため、今回の1.21.0とlatest文書を同じ条件として転用しないでください。今回の比較コードはリンク先を開きません。

過去の検証記録と今回の結果

旧記事には、2026年9月29日に1.21.0で公式リポジトリのPSD167個を試し、プレビューなし9個、シェイプを含む7個の依存関係エラーなどを確認したという記録がありました。以下の測定表と出力例は、その過去の掲載内容を保持した資料です。

今回の作業フォルダーでは167ファイル分の元ログ・入力ハッシュ・条件を取得していないため、同じ試験を再実行した結果や、現在の版全体の成功率として使っていません。過去の差の平均値と、今回の白背景RGB・アルファを分けた指標も同一の測定条件とはしていません。

過去の測定表とスマートオブジェクトの記録を表示(今回の再試験ではありません)
PSD の中身色の差の平均見た目
トーンカーブ(RGB)0.77ほぼ同じ
レベル補正(RGB)0.69ほぼ同じ
色相・彩度(RGB)0.87ほぼ同じ
明るさ・コントラスト(RGB)3.08少し違う
2階調化(RGB)0.00同じ
2階調化(CMYK)68.73大きく違う
調整レイヤー16種類+グラデーション・パターンの効果20.92大きく違う
文字に10種類のレイヤー効果(ドロップシャドウ・光彩・ベベルなど)7.38違う
シェイプ+グラデーションオーバーレイ・境界線13.40違う
境界線(シェイプなし)0.00〜0.21ほぼ同じ
レイヤー種類(smart_object.kind)中身を読めたか
embedded-pngdata(埋め込み)読めた(17,272 バイト)
linked-pngexternal(リンク)FileNotFoundError
linked-psdexternal(リンク)FileNotFoundError

Photoshopへ戻して確認する場合

合成画像を取得できない、必要な効果が欠ける、代表PSDで見た目が変わる場合は、Photoshopで開いて確認します。元PSDを保存し直す必要があるときは複製を使い、互換性に関する保存設定と、保存後の合成画像の有無を確認してください。互換性の設定だけで、すべての色・透明度・他ソフトでの描画を保証するわけではありません。

PSDごとの合成画像をPhotoshopで出力する手順はフォルダー内PSDのJPG・PNG出力へ、外部CLIで保存済み合成画像を変換する手順はImageMagickの記事へ進めます。目的に合わせて処理を選び、同じ代表PSDで出力を照合します。

旧掲載コードの比較資料

旧コード・コマンド・出力例の原文です。今回の実行結果と区別して参照してください。

旧コード5ブロックを表示(原文・全例の現行版動作は未検証)
ImportError: Vector shape rendering requires: aggdraw
pip install "psd-tools[composite]"
import sys
from psd_tools import PSDImage
# psd-tools 1.21.0 が合成できる調整レイヤー(README の7種類+色相・彩度)
OK_ADJ = {"brightnesscontrast", "levels", "curves", "exposure", "invert", "posterize", "threshold", "huesaturation"}
ADJ = {"brightnesscontrast","levels","curves","exposure","invert","posterize","threshold","huesaturation","colorbalance",
       "blackandwhite","photofilter","channelmixer","colorlookup","gradientmap","selectivecolor","vibrance"}
FILL_FX = {"ColorOverlay", "GradientOverlay", "PatternOverlay"}

def inspect(path):
    psd = PSDImage.open(path)
    print(f"■ {path}  {psd.width}x{psd.height}  プレビュー画像: {'あり' if psd.has_preview() else 'なし'}")
    for layer in psd.descendants():
        notes = []
        if layer.kind in ADJ and layer.kind not in OK_ADJ:
            notes.append(f"調整レイヤー {layer.kind}(合成は未対応)")
        if layer.kind == "type":
            notes.append("文字レイヤー(フォントの描画は未対応)")
        if layer.kind == "smartobject":
            notes.append("スマートオブジェクト(中身の編集は未対応)")
        if layer.kind == "shape":
            notes.append("シェイプ(描くには composite の追加パッケージが要る)")
        if layer.has_effects():
            fx = [e.__class__.__name__ for e in layer.effects if e.enabled]
            other = [f for f in fx if f not in FILL_FX]
            if other:
                notes.append("レイヤー効果 " + ",".join(other) + "(多くは合成が未対応)")
        if notes:
            print(f"  - {layer.name!r}: " + " / ".join(notes))

for p in sys.argv[1:]:
    inspect(p)
python inspect_psd.py design.psd
■ repo/tests/psd_files/fill_adjustments.psd  256x256  プレビュー画像: あり
  - 'Rectangle 1': シェイプ(描くには composite の追加パッケージが要る)
  - 'Vibrance 1': 調整レイヤー vibrance(合成は未対応)
  - 'Color Balance 1': 調整レイヤー colorbalance(合成は未対応)
  - 'Black & White 1': 調整レイヤー blackandwhite(合成は未対応)
  - 'Gradient Map 1': 調整レイヤー gradientmap(合成は未対応)
■ repo/tests/psd_files/text.psd  400x400  プレビュー画像: あり
  - 'Line 1 Line 2 Line 3 and text': 文字レイヤー(フォントの描画は未対応)

次に学ぶ・作業環境を選ぶ

学習を続けたい方や、作業環境を整えたい方は、目的に合うガイドをご覧ください。

Photoshopのおすすめ書籍

動作要件とPCの確認項目

周辺機器の優先度

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次