happyin.work / ブログ

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

3D LUT:カラーグレードを .cube と Hald に焼き込み、ノードの間で何が失われるか

著者: Anastasiia Butova, ComfyUI・拡散モデルエンジニア(ベオグラード) · 公開 2026-09-23

英語版からの翻訳: English

このシリーズの前の部分で扱った、露出からチャンネルミキサーまでのすべてのツールは、古い色から新しい色を、その色だけを使って計算します。そのような関数は、テーブルとして保存できます。RGB の入力を格子上でサンプリングし、出力を保持し、その間を補間します。そのテーブルが 3D LUT で、グレードが私のカラースタジオを出て別のプログラムへ行く方法です。この部分では、2つの形式、補間、テーブルのサイズ、書き出しが拒否するもの、そして実際の焼き込みをエンジンと照合したときに測ったことを扱います。

LUT が保持できるもの

3D LUT は、サンプリングされた関数 T: [0,1]³ → [0,1]³ です。同じ RGB には、ピクセルがどこにあっても同じ答えを返します。顔の上でも、壁の上でも、別の写真の上でも同じです。そのため、露出、ホワイトバランス、カーブ、レベル補正、Oklab の範囲ツール、色相ロック、保護された色、ミキサーを保持できます。ピクセルの位置や隣接ピクセルを見る、テクスチャ、明瞭度、シャープ、デヘイズ、座標のマスクは保持できません。

エンジンは、インターフェースがそれを覚えていることを当てにしません。main.cpp の書き出しのハンドラーは、自分で拒否します。

if (type == "cube" || type == "hald" || type == "verify") {
  if (atelier::hasSpatialEdits(spatial)) fail("spatial edits cannot be exported as a 3D LUT");

空間的な編集が有効なとき、書き出しダイアログは、除外されるものを一覧にして、色だけのオプションのチェックを外したままにします。明示的にチェックしたときだけ、空間的な文脈を恒等にして送ります。ネイティブ側の拒否は最後の防護で、ダイアログのバグがシャープをこっそり落とした LUT を生むことがないようにしています。

.cube ファイルを厳密に読む

形式は、2013年の Adobe の Cube LUT Specification 1.0 です(Adobe の PDF のアーカイブ)。TITLE、LUT_3D_SIZE、任意の DOMAIN_MIN と DOMAIN_MAX を持つテキストファイルで、その後に N³ 行の3つの数字が並びます。赤が最も速く変わるので、行のインデックスは r + N·g + N²·b です。仕様は N を2〜256 として許可しています。私のパーサーは2〜65を受け入れます。3D のテーブルだけです。1D やシェイパーのファイルは拒否し、未知のディレクティブ、有限でない数字、行数の誤りも、問題を名指しするメッセージとともに拒否します。入力はエンコード済みの sRGB として受け取り、ファイル名から Log やカメラのプロファイルを推測することはしません。

9月22日の5回の独立した読み取り専用のレビューで、パーサーの3つのバグが見つかりました。その最初のものは、スタジオが自分の書き出しを読み戻したときに現れました。

  • 引用符の中の TITLE にある # が、コメントとして削除されていました。そのためスタジオは、自分の書き出しの一部を読み込めませんでした。
  • JavaScript と C++ のパーサーは、テストコーパスの50ファイルのうち9ファイルで食い違っていました。今は、両言語で、数について同じ正規表現の定義を持つ、1つの文法を共有しています。
  • 書き込み側が、TITLE に非 ASCII のバイトを渡していました。仕様が許すのは、空白からチルダまでの文字で、引用符は除きます。今では書き込み側はそれらを保持し、それ以外はすべて空白に変えます。

Hald:画像に偽装した LUT

Hald CLUT は、同じキューブを画像として保存します。レベル L では、キューブは軸ごとに N = L² 個の値を持ち、画像は一辺が L³ ピクセルです。1本の長い行として読むと、赤が最も速く、次に緑、次に青の順に変わります。ImageMagick の Hald の節が、私が従った主要な説明です。レベル 8 は、64³ の色を保持する 512 × 512 の画像です。

拡大したレベル3の単位 Hald 画像。27 × 27 ピクセルで、赤は1ピクセルごとに変わり、緑は9ピクセルごと、青は81ピクセルごとに変わることを示す注記つき

ワークフローは単純です。ニュートラルな Hald を保存し、色だけの操作で任意のエディタでグレードをかけ、サイズを保ったままにして、LUT として再び開きます。スタジオは、現在のグレードを Hald として書き出すこともできます。この形式には、それが Hald であることを証明する方法がないので、インポーターは形だけを検査します。正方形の PNG で、その一辺が完全な立方数であり、レベルが 2〜8 で、完全に不透明であること。512 × 512 のどの正方形の写真もこの検査を通ってしまい、無意味なものになります。だからこそ、通常の写真はインポートしないようにと、ドキュメントに明記しています。

2つの欠陥は、数学ではなく画像の経路にありました。Hald のインポートは、ブラウザの Canvas を通していました。そして Canvas は、gAMA、cHRM、iCCP のチャンクが sRGB 以外の何かを記述している PNG を、色変換してしまいます。タグ付きの Hald は、テーブルの値を1つも読む前に、最大136コードずれて戻ってきました。今、インポートは PNG を自分でデコードし、8ビットまたは16ビットのサンプルを数値として取ります。書き出しは8ビットだけでしたが、今では16ビットです。違いは、グレーディングの前から見えます。64レベルは8ビットのコードに整数では乗らないので、8ビットの恒等 Hald は、そのノードで最大 0.0019(0〜1の単位)ずれています。16ビットでは、同じ誤差は 7.3 × 10⁻⁶ です。

トリリニアか、テトラヘドラルか

ノードの間では、エンジンは2つの方法のどちらかで補間します。トリリニアは、セルの8つの角すべてをブレンドします。テトラヘドラルは、セル内の3つの小数座標を並べ替え、セル内の6つの四面体のうちの1つを選び、4つの角をブレンドします。cube.cpp から:

if (fr >= fg && fg >= fb) { p0=c000; p1=c100; p2=c110; p3=c111; t1=fr; t2=fg; t3=fb; }
else if (fr >= fg && fr >= fb) { p0=c000; p1=c100; p2=c101; p3=c111; t1=fr; t2=fb; t3=fg; }
else if (fr >= fg) { p0=c000; p1=c001; p2=c101; p3=c111; t1=fb; t2=fr; t3=fg; }
else if (fr >= fb) { p0=c000; p1=c010; p2=c110; p3=c111; t1=fg; t2=fr; t3=fb; }
else if (fg >= fb) { p0=c000; p1=c010; p2=c011; p3=c111; t1=fg; t2=fb; t3=fr; }
else { p0=c000; p1=c001; p2=c011; p3=c111; t1=fb; t2=fg; t3=fr; }
return {p0[0] + (p1[0] - p0[0]) * t1 + (p2[0] - p1[0]) * t2 + (p3[0] - p2[0]) * t3,
        p0[1] + (p1[1] - p0[1]) * t1 + (p2[1] - p1[1]) * t2 + (p3[1] - p2[1]) * t3,
        p0[2] + (p1[2] - p0[2]) * t1 + (p2[2] - p1[2]) * t2 + (p3[2] - p2[2]) * t3};

どちらも、任意のアフィン関数を正確に再現します。非線形のテーブルでは両者は異なり、どちらが常に勝つわけでもありません。点 (0.2, 0.5, 0.8) における f = r·g·b の 2³ のテーブルでは、トリリニアは正確な 0.08 を返し、テトラヘドラルは 0.2 を返します。そのため「テトラヘドラルのほうが正確だ」は法則ではありません。

それでもテトラヘドラルが既定です。その理由は、精度についてではありません。Adobe の仕様は、3D のテーブルにはテトラヘドラル補間を使うよう読み手に求めていて、OpenColorIO の補間の列挙型も、3D LUT の「best」をテトラヘドラルに対応させています。そのため、スタジオは、形式がそうサンプリングされることを想定した方法でファイルをサンプリングします。2つ目の理由は幾何学的なものです。6つの四面体はすべて、セルの対角線 c000 から c111 を辺に持つので、グレーの入力は、グレーのノードからしか補間されません。グレードがノードでグレーを中立に保つなら、テトラヘドラルはノードの間でも中立を保ちます。トリリニアは、色のついた角を混ぜてしまいます。

効果の大きさを見るために、合成のルック(Oklab L の S 字カーブ、彩度 ×1.5、色相 +12°、sRGB にクリップ)を、17³ のテーブルに焼き込み、エンジンのサンプラーを移植した Python で読みました。これは図であって、エンジンの測定値ではありません。

左:17³ の LUT の後のグレーのランプのチャンネルの広がり。トリリニアはグレーを最大1.7コード色づけするが、テトラヘドラルはゼロのまま。右:ティールからオレンジのグラデーションに沿った誤差。両者は似ていて、ルックがクリップするところで最大15コードのピークがある

トリリニアはグレーのランプを最大1.74コード色づけし、テトラヘドラルは 1.4 × 10⁻⁵ コードのままです。その残差は補間ではなく、10桁の Oklab の行列から来ています。色のついたグラデーションに沿うと、両者は近く、最悪でも 15.1 と 13.9 コードで、どちらのピークも、ルックのチャンネルの1つがゼロを横切ってクリップし始める場所にちょうど出ます。

テーブルはどれくらい大きい必要があるか

書き出しでは 17³、33³、65³ が選べます。それぞれ、compact、standard、dense とラベルされています。65³ の選択肢には以前は「exact」と書かれていました。焼き込みのレビューで違うと測定されたので、ラベルを変えました。

エンジンの検証「Verify export」は、テーブルを焼き込み、直列化して再び読み込み、その後、格子上にない 4096 個の決定論的な色で、直接の変換と比較します。

for (int index = 0; index < 4096; ++index) {
  const atelier::Vec3 rgb{((index * 73 + 17) % 4099) / 4098., ((index * 193 + 47) % 4093) / 4092., ((index * 503 + 29) % 4091) / 4090.};
  const atelier::Vec3 direct = transform(rgb);
  const atelier::Vec3 sampled = atelier::sampleCube(decoded, rgb, grade.interpolation);
  for (std::size_t channel = 0; channel < 3; ++channel) {
    const double error = std::abs(direct[channel] - sampled[channel]);
    maximum = std::max(maximum, error);
    sum += error * error;
  }
}

最大と RMS の誤差を報告します。これはサンプリングによる検査であって、上限でも、知覚的な ΔE でもありません。

9月12日に、意図的に重いグレードで実行しました。インポートした LUT とピン、暖色の色相ロック、Selective Color に至るまで、グローバルなコントロールをすべて同時に有効にしたものです。これは、第4部で説明した9月22日と23日の修正の前の、エンジン 0.3.0 でのものです。

テーブル ファイルサイズ ノードでの誤差 格子外の最大 格子外の RMS
33³ 1.16 MB 1.7 × 10⁻⁶ 0.529 0.0102
65³ 9.13 MB 1.7 × 10⁻⁶ 0.267 0.0054

ノード(33³ では 35,937 個すべて、65³ では各軸の4つごと)では、ファイルはエンジンを浮動小数点の精度で再現します。しかし、その間では再現しません。最悪のプローブは、色域の縁のほぼ黄色で、青チャンネルは 0.002 であるべきところ、33³ のテーブルは 0.531 を返しました。ノードが 1/32 ずつ離れたテーブルは、その間隔より狭い変化を表現できません。そして、保護の縁やクリップはまさにそのような変化です。解像度を2倍にすると誤差は半分になりましたが、なくなりはしませんでした。レシートはノードを PASS、その間を HOLD とマークし、私が書き出しについて今使っている結論を記録しています。これはグローバルな RGB の近似であり、このようなグレードを正確に再現する必要があるときは、担い手は PNG か、保存されたプロジェクトです。

合成のルックも同じように振る舞います。N を2倍にするたびに、RMS 誤差はおよそ半分になりますが、最大誤差は、クリップによって駆動されるため、より遅く下がります。

トリリニアとテトラヘドラルの補間について、LUT サイズ 5〜65 に対する最大誤差と RMS 誤差の対数グラフ。RMS は倍化ごとにおよそ2倍下がり、最大誤差はより遅く下がり、65 でも 12 コードを上回ったまま

スタックを焼き込む

焼き込み自体は、3重のネストしたループです。cube.cpp の bakeCube は、コンパイル済みの Transform を受け取ります。ピンのシステムはすでに解かれ、カーブはコンパイルされ、グレードは検証済みです。そして N³ のノードで、赤が最も速く変わる順序で評価し、浮動小数点で保存します。同じ Transform クラスが、プレビュー、フルサイズの PNG、テーブルをレンダリングするので、プレビュー、フルサイズの PNG、テーブルは、グレードの C++ の実装を1つだけ共有します。JavaScript のコピー(compileTransform、bakeCube)は、サムネイル、キューブのビュー、Grid Lab で今も使われています。

逆方向の教訓が1つあります。すべてをエンジンの中でテーブルに通すべきではありません。ColorChecker のソルバーで計算されたレベル補正は、以前は、サンプリングされた 65³ の LUT を通して適用されていて、それが黒の近くに最大 16.3 コードの誤差を入れていました。今では、入力で正確に評価され、検査は差を 1.1 × 10⁻¹⁶ と測定します。

色管理:書き出しがしないこと

スタジオの LUT は、エンコード済みの sRGB から、エンコード済みの sRGB へのもので、SDR であり、それ以外ではありません。メインウィンドウは、JPEG、WebP、8ビットの PNG をブラウザの Canvas を通して、ブラウザの sRGB としてデコードします。16ビットの PNG の経路は、Grid Lab にだけあります。タグのない PNG を sRGB として扱い、そのことを明示し、サポートされていないプロファイルは黙って変換するのではなく拒否します。ICC の読み取りはなく、PNG に ICC プロファイルを書き込むこともありません(16ビットの書き出しには sRGB と gAMA のチャンクだけが入ります)。Log、HDR、RAW の入力もありません。書き出しのヒントには、モニターのプロファイルは含まれないと書かれています。

プロファイルを意識した入力と書き出しは、候補のツールとして受け入れ条件つきで書かれていますが、実装されていません。その条件は次のとおりです。埋め込まれた ICC プロファイルを読むか、止まって尋ねること。sRGB を黙って仮定しないこと。宣言された線形の作業空間へ一度変換すること。選んだ出力プロファイルを PNG に書き込み、テストで読み戻すこと。表示の変換を作業ピクセルから切り離すこと。モニターのキャリブレーションの LUT が、CUBE、Hald、プロジェクトに漏れないこと。Photoshop は、ルックアップテーブルを CUBE、3DL、CSP だけでなく ICC プロファイルとしても書き出せます(Adobe: カラールックアップテーブルの書き出し)。私の書き出し機能は、CUBE と Hald だけを書きます。

ルックを別のフレームに移す

LUT が動かすのは関数であって、意図ではありません。2枚目のフレームが暖色の光の下で撮られていると、同じ肌は別の RGB に届くので、LUT の答えも変わります。次のバージョンのための私の研究メモでは、この修正を2段階として説明しています。フレーム i ごとの正規化 N_i は、一致するパッチに対して当てはめて固定し、その後で共有のルック T を適用して、フレーム i には T(N_i(x)) を適用します。現在のエンジンでは、インポートした LUT が先に来て、ホワイトバランスと露出はその後に来るので、その順序はまだ使えません。

1枚の前後の写真から LUT を復元することには、厳しい限界があります。写真のすべての色の R が 0.6 より下なら、T と T + δ·max(R − 0.6, 0)² は、観測されたすべてのピクセルで一致し、それ以外では食い違います。写真は、その写真が含んでいないキューブの部分については何も言いません。だからこそ、信頼できる担い手は Hald です。Hald は、すべてのノードを含んでいるからです。そして、グレードの付け方のルールも説明します。リサイズなし、JPEG なし、ローカルのマスクやシャープなし、Hald の上で自動的に再計算される自動補正もなし。写真で自動パラメータを固定し、同じ関数を Hald に適用してください。別のエディタの隠れた色プロファイルの変換は、抽出された LUT の一部になってしまいます。

LUT がどこで止まるか

位置に依存するものは、設計上、テーブルの外に残します。そして、実際のレタッチの多くはまさにそういう種類の作業です。覆い焼きと焼き込み、ボリューム、肌。それが、私が作るもう半分です。Happyin.ai の背後にあるモデルは、レタッチする人自身のマシンで、ピクセルごとのマップを作ります。そして C++ の Photoshop プラグインが、それらをレイヤーとして合成します。そのどれも、LUT として移動することはできません。

これでシリーズは終わります。露出とホワイトバランスから始まりました。それは、この部分がテーブルに変えているのと同じチェーンの最初の段です。

Colour tools, built from scratch

  1. 露出とホワイトバランス:線形光における3つの乗算
  2. レベル補正:黒点と白点、ガンマ、チャンネル、チャート
  3. カーブ:自然スプラインとPCHIP、オーバーシュート、ルマカーブ、LUT への焼き込み
  4. 色の範囲ごとの色相・彩度・明るさを、HSLではなくOklabで計算する
  5. チャンネルミキサー、3×3行列:何を保ち、どこでクリップし、どこで動くか
  6. 3D LUT:カラーグレードを .cube と Hald に焼き込み、ノードの間で何が失われるか

その他の記事

サイト内のその他のページ

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face