[Astro] #169 PSD / PSB Viewer — レイヤー一覧ビューアを、自前パーサーで合成・マスク・書き出しまで作り直し、1GB超の PSB を開く
はじめに
前回は、サイトマップの仕上げでした。
[Astro] #168 サイトマップ #04 — 惑星に陰影とホログラムを足し、霧を 2層にして、VR の記事ボードを縦長にして完成 // PROTOCOL.LAIN
3D サイトマップの仕上げをしました。
lain-lab.com今回は、プロジェクトマップにあった「PSD / PSB Viewer」です。先に、自分で 1 つ作ってありました。ただ、できることは、レイヤーの名前と大きさを並べて、1 枚ずつ表示するところまででした。
それを、動かしながら読んで、悪いところを直して、足せる機能を足しました。作り直したあとは、体感で 10 倍以上は速くなり、1GB を超えるファイルも、一瞬で開きます。数字での計測は、していません。
今回の要点は、次の 6 つです。
- ファイル全体を読まずに、4MB の窓で読む
- プレビューに必要な行だけを、デコードする
- 合成画像(Photoshop が保存した結果)と、レイヤー合成を、切り替えられるようにする
- グループ、マスク、ブレンド、クリッピング、塗りレイヤー、ベクターマスク、アートボードを合成する
- 公開されているテストファイルと、1 枚ずつ比べる
- 未対応の機能は、画面に出す
完成画面
61 レイヤー + 15 グループの PSD
2048×2048、12.12MB、61 レイヤー + 15 グループの PSD です。右にレイヤーツリー、左にファイル情報と、未対応の機能の注意書きが出ます。
※公開されているVketちゃんのデータをお借りしています。
『Vketちゃん1号』VRSNS向けアバター - バーチャルマーケット公式 - BOOTH
バーチャルマーケット公式キャラクター「Vketちゃん1号」です。 バーチャルマーケット3開催時に、キャラクターデザイン投票で1位を獲得し、3D化しました。 キャラクターデザインは、深黎さん(https://x.com/@mirei_works) キャラクターモデリングは、春日部つくしさん(https://x.com/kasukaBe_nyoki) ◆アバターパフォーマンス OverallPerformance:VeryPoor PhysBoneComponents:16 SkinnedMeshRenderers:8 MaterialSlots:13
booth.pm20000×20000、1.13GB の PSB
テスト用に作った、20000×20000、1.13GB の PSB です。中身はノイズで、レイヤーはありません。4096px のプレビュー(元の 20%)で、すぐ表示されます。
元のコードで見つけた不具合
作り直す前に、元のコードを読んで、悪いところを洗い出しました。見つけたのは、次のとおりです。
- カラーモードの名前が、逆でした(1 が Grayscale で、2 が Indexed なのに、逆に表示していた)
- 圧縮は、RLE だけでした。ZIP と ZIP+Predict は、読めません
- レイヤーの行の位置を、毎回、先頭から足し算していました(O(H²)。大きなファイルで遅くなる)
- グループ、マスク、ブレンドモード、クリッピングを、考えていませんでした
- レイヤー名を、Unicode 名(
luni)ではなく、昔の文字列から読んでいました(日本語名が化ける) - 16/32bit、CMYK、Lab、Indexed、Bitmap に、対応していませんでした
- 合成画像のアルファを、白の上に焼かれたままで扱っていました
- 「互換性を最大にする」を切って保存したファイル(合成画像が、ダミー)を、見分けていませんでした
速さの差は、主に 3 つ目(行の位置)と、「必要な行だけ読む」作りの違いから来ていると考えていますが、どれがどれだけ効いたかは、分けて測っていません。
ファイルを、窓で読む
PSD は、先頭から順に、ヘッダー、カラーモード、画像リソース、レイヤー情報、合成画像、と並んでいます。PSB は、同じ並びで、長さを表す数値が、4 バイトではなく 8 バイトの場所があります。
1GB を超えるファイルを、arrayBuffer() で丸ごと読むと、メモリが足りなくなります。そこで、Blob.slice() で、必要な場所だけ、4MB 単位の窓で読みます。
- 窓の中にある範囲は、メモリから返す
- 窓の外なら、その場所から、新しく読み直す
- 読み終わった窓は、捨てる
読むのは、ヘッダーと、各レイヤーの情報(名前、位置、チャンネルの長さ)と、画面に出す分だけです。合成画像だけを見るときは、レイヤーの画素には触りません。レイヤー表示にしたときは、各レイヤーを、プレビューの大きさで順にデコードします(進み具合を表示)。
PSB の 8 バイト長
PSB で、長さが 8 バイトになるのは、レイヤー情報やチャンネルの長さ、それから、いくつかのタグ(LMsk、Lr16、Lr32、Layr など)です。タグの長さを読むところを、PSB のときだけ 8 バイトにしました。
// PSB の長いタグ(LMsk, Lr16, Lr32 など)は、長さが 8 バイト
s.pos -= 4;
len = Number((await s.view(8)).getBigUint64(0));
最初は、ここを、読み替えの途中で、ややこしい書き方にしていました。位置を 4 バイト戻して、8 バイトで読み直す形に、直しました。
圧縮の読み方
チャンネルの圧縮には、4 種類あります。
| 番号 | 方式 | 読み方 |
|---|---|---|
| 0 | Raw | そのまま |
| 1 | RLE(PackBits) | 行ごとのバイト数の表を読んで、行ごとに展開 |
| 2 | ZIP | DecompressionStream('deflate') |
| 3 | ZIP + Predict | ZIP のあと、差分を元に戻す |
RLE は、行の位置を足し込む
RLE は、最初に、各行の圧縮後のバイト数が並んでいます。行の位置は、これを先頭から足せば出ます。元のコードは、行ごとに先頭から足し直していたので、行数の 2 乗の時間がかかっていました。今回は、1 回だけ足して、表にしておきます。そうすると、欲しい行だけを、すぐに読めます。
PSB では、このバイト数が、2 バイトではなく 4 バイトです。
ZIP + Predict
ZIP + Predict は、画素を差分にしてから圧縮してあります。戻す方法は、ビット深度で違います。
- 8bit:同じ行の中で、前の値を足していく
- 16bit:同じ行の中で、16bit の単位で足していく
- 32bit:先に、バイトを並べ直す(上位バイトから順に並んでいる)。そのあと、バイトごとに足していく
32bit が、いちばん面倒でした。
ビット深度は、8bit にそろえる
画面に出すときは、全部 8bit にそろえます。
- 1bit:白黒に開く
- 16bit:上位 8bit を使う
- 32bit(浮動小数):sRGB のガンマをかけて、8bit にする
32bit は、ag-psd が 1/2.2 のガンマで変換しているので、sRGB の曲線と、完全には同じになりません。
CMYK と Lab
CMYK は、PSD の中で、反転して保存されています(255 が、インクなし)。そこで、c × k / 255 のような、単純な変換で RGB にしています。ICC プロファイルは、読み込みますが、適用はしていません。Lab は、D50 の白色点で、sRGB に変換します。
プレビューは、必要な行だけデコードする
20000×20000 の画像を、全部、展開すると、400MB(RGBA で 1.6GB)になります。画面に出すのは、4096px ほどです。そこで、デコードするときに、出力の座標から、元の座標を逆算して、必要な行と列だけを取り出します。
// 出力の i 番目の画素は、元画像の何番目か(範囲外は -1)
const map = makeMap(outN, docStart, scale, srcStart, srcLen);
- 使わない行は、展開しません(RLE は、行の位置の表があるので、読み飛ばせる)
- 取り出しは、最近傍です(平均はとらない)。そのため、細い線が、消えることがあります
- プレビューのサイズは、画面から、1024 / 2048 / 4096 などで選べます。大きくするほど、細かく見えます
画面には、「Preview is downscaled to 20%(nearest-neighbour)」と、はっきり出します。
合成画像と、レイヤー合成
PSD には、2 通りの絵が入っています。
- 合成画像:Photoshop が、保存するときに、合成した結果
- レイヤー:1 枚ずつの画素と、マスクや、ブレンドの設定
合成画像は、正確です。ただし、「互換性を最大にする」を切って保存すると、ダミー(真っ白など)が入ります。リソース 1057 の hasRealMergedData で、見分けられます。そのときは、自動でレイヤー合成に切り替えます。
レイヤー合成は、自分で、重ねます。こちらは、Photoshop の結果とは、少し違うことがあります。違いは、あとで書きます。
画面には、「Merged image only(exact, fast)」のチェックがあります。1GB の PSB は、これで、すぐ開きます。
白に焼かれたアルファ
合成画像にアルファがあるときは、白の上に合成された形で、保存されています(Photoshop の都合)。そのまま出すと、透明な所に、白い縁が出ます。そのため、白で割って、戻しています。
// 白の上に焼かれた色から、元の色を戻す
const ra = 255 / a, inv = 255 * (1 - ra);
rgba[o] = rgba[o] * ra + inv;
レイヤーのツリー
レイヤーは、フラットな配列で並んでいます。フォルダは、「区切り」のレイヤーで、表されます。
- フォルダの終わり(ファイルの順では、先に出る)が、type 3
- フォルダの始まり(開いているのが 1、閉じているのが 2)が、フォルダ自身のレコード
ファイルは、下のレイヤーから順に並んでいます。そこで、後ろから読んで、区切りで、入れ子にしていきます。グループのブレンドモードは、lsct のキーで、上書きされます。
グループは、隔離するか、しないか
「通過(Pass Through)」以外のグループは、中を、いったん別の画像に合成してから、下に重ねます(隔離)。通過は、中のレイヤーを、そのまま下の絵に重ねます。ただし、グループに、不透明度 100% 未満や、マスクがあるときは、隔離します。
レイヤーの合成
重ねるところは、canvas の globalCompositeOperation で、やっています。ブラウザでも、Node(@napi-rs/canvas)でも、同じコードで動きます。
- ブレンドモード:canvas の名前に対応づけています(Multiply、Screen、Overlay など)。対応する名前がないもの(Dissolve、Hard Mix、Subtract、Divide など)は、近い見た目で近似しています。注意書きに、「近似している」と出します
- 不透明度と、塗りの不透明度(
iOpa):別々に扱います - クリッピング:下のレイヤーの形で、切り抜きます。一時的な canvas に描いてから、
destination-inで切ります - レイヤーマスク:画素のアルファに、先に掛けてしまいます
グループにも、マスクがある
グループにも、マスクを付けられます。グループを、いったん別の画像に合成してから、マスクで切り抜いて、重ねます。
塗りレイヤーと、ベクターマスク
べた塗り(SoCo)と、グラデーション(GdFl)のレイヤーは、画素を持っていません。記述子(Descriptor)という、キーと値の入れ子の形式で、設定だけが入っています。これを読んで、自分で描きます。
- べた塗り:色を、そのまま塗る
- グラデーション:線形、放射、反射、円錐(Angular)を、canvas のグラデーションで描く。ひし形は、放射で近似する
形は、ベクターマスク(vmsk)で決まります。パスは、26 バイトのレコードの並びで、座標は、固定小数点(8.24)で、画像の幅と高さに対する割合です。
// 8.24 固定小数点 → 画像の座標
const fx = (o) => dv.getInt32(o) / 16777216;
// 順番は y, x。前の制御点、アンカー、後ろの制御点の順
const k = [fx(o + 4) * W, fx(o) * H, fx(o + 12) * W, fx(o + 8) * H, fx(o + 20) * W, fx(o + 16) * H];
パスの演算は、次のとおりです。
| 演算 | canvas の合成 |
|---|---|
| 合体 | source-over |
| 型抜き | destination-out |
| 交差 | destination-in |
| 除外 | xor |
アートボード
アートボードは、グループに、artb のタグが付いたものです。範囲と、背景の色が入っています。範囲の外は切り抜いて、背景の色があれば、先に塗ります。
テストと、見つけたバグ
自分の目で見るだけでは、見落としがあります。そこで、公開されている、2 つのリポジトリのテストファイルを使いました。
- ag-psd:
canvas.png(期待する合成結果)が付いている - psd-tools:テスト用の PSD / PSB が、たくさん入っている
Node で、同じ合成のコードを動かして、期待する画像と、画素ごとに比べました(誤差の平均を出す)。ブラウザでの操作は、Playwright(Chromium)で確認しました。
確認できたこと
- デコード(Raw / RLE / ZIP / ZIP+Predict、各ビット深度、PSB):レイヤー単体のアルファは、完全に一致
- マスクの濃度の、テスト用ファイル(
mask-density-*):直したあと、誤差は 0〜0.3 ほど - パス演算の、テスト用ファイル(
path-operations/*):誤差は、ほぼ 0 - アートボード(ag-psd の
artboards、psd-tools のartboard-bgcolor):完全に一致 - Chromium で、読み込み、選択、表示の切り替え、検索、ズーム、ピクセル確認、PNG と ZIP の書き出しまで、一通り動かして、エラーは出ませんでした
最後に直した 5 つのバグ
テストで、差が出たところを、1 つずつ見ていって、見つかりました。
- マスクの濃度を、読んでいなかった。濃度が低いマスクは、「効きが弱い」はずなのに、全開で効いていた
- マスクのパラメータを、読む順番が逆だった。「実マスク → パラメータ」の順で、並んでいた
- ベクターマスクがあるとき、本物のレイヤーマスク(チャンネル -3)を、使っていなかった
- 「他のデータから描かれたマスク」を、ベクターマスクと、二重に掛けていた(flags の bit 3)
- アートボードの背景色の、キーの名前が、
ClrではなくClr(末尾に空白)だった
5 つ目は、名前が 1 文字違っただけで、色が読めず、白になっていました。画面で見ても、「白い板」としか見えないので、画素を比べないと、気付けませんでした。
自分で入れたバグ
直す途中で、自分で入れたバグもありました。ベクターマスクの濃度を、「値が undefined でなければ、掛ける」としていたところで、マスクがない(null)ときも、通ってしまい、濃度 0 として、全面が表示されました。!= null に直しました。
テストを回し直して、見つかりました。直したあとにも、もう一度、回すことが必要でした。
ベクターマスクの「初期塗り」
ベクターマスクには、「初期の塗り」というフラグがあります。「全面から始めて、パスを引く」という意味に読めます。ただ、実際のファイルを見ると、先頭のパスが「合体」のときは、パスの形だけが残っていました(全面は、残らない)。
そこで、次の決まりにしました。
- 初期塗りがあって、パスがない:全面を表示
- 初期塗りがあって、先頭のパスが「型抜き」か「除外」:全面から始める
- それ以外:パスの形だけ
これは、手元の数ファイルの結果から決めた、経験則です。仕様書に、そう書いてあったわけではありません。外れる例が、あるかもしれません。
書き出し
- 合成画像を PNG で:いま表示している状態を、書き出す
- レイヤー 1 枚を PNG で:選んだレイヤーだけ
- 全レイヤーを ZIP で:1 枚ずつの PNG と、
layers.json(名前、グループ、位置、サイズ、不透明度、ブレンドモード)。ZIP は、自前で書いています
ZIP は、圧縮なし(stored)にしています。PNG が、すでに圧縮されているためです。
画面まわり
- ズーム:ホイール、100%、画面に合わせる。大きく拡大したときは、ドットが見えるように、補間を切る
- ピクセル確認:マウスの下の、RGBA と、16 進の色
- 透明の背景:市松模様、白、黒、などから選べる。市松は、ズームに合わせて、大きさを変える
- 選んだレイヤーの範囲:枠で表示
- レイヤーの検索、全部開く / 閉じる、全部表示 / 非表示、Alt + 目玉で、そのレイヤーだけ表示
使わなかった範囲を、見せる
ファイルに入っている情報が、画面に出ていないときは、そう伝えます。調整レイヤー、レイヤー効果、シェイプの線、パターン塗りがあれば、「適用していない」と、左の注意書きに出します。見た目が、Photoshop と違うときに、「バグなのか、未対応なのか」が、分かるようにしたかったからです。
未対応のこと
- 調整レイヤー(カーブ、レベルなど)
- レイヤー効果(ドロップシャドウ、光彩、境界線など)
- シェイプの線(塗りだけ描く)
- パターン塗り
- ノックアウト、ブレンド条件(Blend If)
- テキストとスマートオブジェクトの、描き直し(ファイルに入っているラスタを、そのまま出す)
- マスクのぼかし(値は読むが、適用しない)
- 一部のブレンドモードは、近似
- CMYK と Lab は、ICC プロファイルを使わない、簡易的な変換
差が残っているもの
- 32bit のファイルで、塗りレイヤーの色が、Photoshop の結果とずれる例がある(
vector-mask.psd)。理由は、まだ分かっていません - 4×4 のような、小さすぎるファイルのグラデーションは、角度が合わない例がある
どちらも、画像の数が少ないので、手元のファイルの範囲で、確認した結果です。
ハマりどころ
1. 見た目が合っていても、合っているとは限らない
アートボードの背景が白いままでも、画面で見ると、普通に見えます。期待する画像と、画素で比べて、初めて、色が違うと分かりました。
2. 「互換性を最大にする」が、切れているファイル
合成画像が、ダミーなので、「合成画像と、レイヤー合成を比べる」テストが、成り立ちません。比べる相手を、合成画像が本物のファイル(hasRealMergedData が true)に絞りました。
3. 差が大きく出ても、バグとは限らない
テストで、差が大きく出たファイルの多くは、調整レイヤーや、レイヤー効果を使っていました。未対応なので、差が出て当然です。それらを除いてから、残った差を、1 つずつ、調べました。
4. 小さなファイルは、ルールが読めない
4×4 のテスト用ファイルは、グラデーションの角度が、設定と合いませんでした。画素が、16 個しかないので、設定が正しくても、丸め方の違いが、大きく見えます。深追いをやめて、「合わない例がある」と、書くことにしました。
5. 仕様書にない所は、ファイルから決める
ベクターマスクの初期塗りのように、仕様書に書いていない所は、実際のファイルを見て、決めました。そのときは、「経験則」と、記録に残します。
おわりに
元は、レイヤーの一覧が出るだけでした。それが、重ねて、切って、塗って、書き出せる、ところまで来ました。1GB を超える PSB も、すぐ開きます。
一番、効いたのは、「全部を読まない」ことでした。窓で読んで、必要な行だけを展開して、合成画像だけを見るときは、レイヤーの画素に触りません。
次に足すなら、レイヤー効果(境界線とドロップシャドウ)と、調整レイヤー(レベルとカーブ)が、見た目の差を、いちばん減らせそうです。ただ、これは、見た目への効き目を、測ったものではなく、私の見込みです。
実装メモは、docs/psd-psb-viewer.md に、残してあります。