happyin.work / ブログ

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

チャンネルミキサー、3×3行列:何を保ち、どこでクリップし、どこで動くか

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

英語版からの翻訳: English

第4部の知覚的な範囲ツールの後に来るのが、チャンネルミキサーです。カラースタジオの中でいちばん無骨な道具です。出力チャンネルは3つ、それぞれが入力の3チャンネルの重み付き和で、数は全部で9つだけです。コードは数行しかありません。面白いのはその周りの問いです。どの RGB を混ぜるのか、「保つ」とは何を意味するのか、0〜1の外で何が起きるのか、チェーンのどこで動くのか。9月20日にスタジオへ追加しました。以下に仕組みを、そして同じくらい率直に、何をしないのかを書きます。

操作の全体

[ R′ ]   [ m00 m01 m02 ] [ R ]
[ G′ ] = [ m10 m11 m12 ] [ G ]
[ B′ ]   [ m20 m21 m22 ] [ B ]

すべての係数は有限で、[−4, 4] の範囲内になければなりません。デフォルトは単位行列です。C++ エンジン(color.cpp)では、この乗算とその段は次のように書かれています。

Vec3 applyChannelMatrix(const std::array<double, 9>& matrix, const Vec3& encodedRgb) {
  return {
      matrix[0] * encodedRgb[0] + matrix[1] * encodedRgb[1] + matrix[2] * encodedRgb[2],
      matrix[3] * encodedRgb[0] + matrix[4] * encodedRgb[1] + matrix[5] * encodedRgb[2],
      matrix[6] * encodedRgb[0] + matrix[7] * encodedRgb[1] + matrix[8] * encodedRgb[2],
  };
}
// ... inside Transform::apply, after curves and the photo stage:
if (hasChannelMatrix) {
  out = applyChannelMatrix(grade.channelMatrix, out);
  for (double& value : out) value = clamp(value);
}

hasChannelMatrix は、厳密な単位行列のときは false です。そのため、触っていないミキサーはコストがかからず、何も変えません。ミキサーができる前に保存されたプロジェクトには、channelMatrix フィールドがそもそもありません。パーサーはそれを単位行列として読みます。専用のチェックで、そのような古いグレードが、JavaScript 実装と C++ エンジンの両方で、差分ちょうど0でレンダリングされることを確認しています。

どの RGB を混ぜるか

行列はエンコード済み sRGB に対して動きます。行列は線形光ではなく、エンコード済み sRGB(PNG や JPEG が保存しているのと同じ0〜1の値)に対して動きます。これはツールの契約に「エンコード済み sRGB → エンコード済み sRGB」と書かれています。そこから目に見える結果が出ます。エンコード済みの値を混ぜることは、光を混ぜることではありません。

純粋な赤と、行 R′ = 0.5 R + 0.5 G を考えます。エンコード済み sRGB では、赤の出力は 0.5 です。同じ混合を線形光で行い、再エンコードすると 0.735 になります。エンコード済みの混合は、目に見えて暗くなります。そして、そのずれは混ぜるチャンネル同士の差が大きいほど広がります。現在のエンジンには線形光のモードがないので、線形ミキサー用に書かれたレシピは、数値としてはそのまま移りません。

行の和が保つもの、保たないもの

すべての行の和が1なら、グレーの入力 [x, x, x] は [x, x, x] のまま残ります。M·1 = 1 だからです。インターフェースは、行和の3つの値を行列の横に表示し、中立が残るかどうかを言葉で示します。数学的な検査では、0.18、0.54、1のグレーで、行が (0.70, 0.20, 0.10)、(0.15, 0.70, 0.15)、(0.20, 0.20, 0.60) の行列を使い、誤差 1.1 × 10⁻¹⁶ 以内で確認しています。

これは中立の保存であり、明るさの保存と読んでしまいやすいものです。しかし、そうではありません。ルマは w·RGB で、w = (0.2126, 0.7152, 0.0722) です。すべての色のルマを保つには wᵀM = wᵀ が必要です。これは行ではなく、w で重み付けした列に関する条件です。赤と青を入れ替えると行のルールは完全に満たされ、すべてのグレーも保たれますが、純粋な赤はルマ 0.2126 から 0.0722 に落ちます。ミキサーにはルマを保つモードがありません。報告する不変量は行和だけです。

逆方向の間違いも同じくらい起こしやすいものです。R′ = R + 0.20 G にすると、赤の行和は 1.2 になり、下の合成チャートではグレーのランプが赤方向へ最大38コードの色味を帯びます。行を R′ = 0.80 R + 0.20 G に再スケールしても、緑の一部は赤チャンネルに入り込みますが、グレーは正確にその場所に留まります。

合成カラーチャートの5つのバージョン:単位行列、赤の行に緑0.2を加えたもの(グレーがピンクに色づく)、和が1になるように再スケールしたもの(グレーは保たれる)、3つの等しいルマ行によるモノクロ、そして1.6 R − 0.6 G(グレーは保たれるが、72個のチャンネル値のうち8個をクリップする)

オフセット列はない

Photoshop のチャンネルミキサーには、出力チャンネルを暗くしたり明るくしたりする「定数」スライダーがあり、範囲は −200 % から +200 % です。ソースのスライダーは ±200 % に制限されています(Adobe: カラーチャンネルを混ぜる)。私のものにはオフセットがありません。純粋な3×3の写像であり、3×4のアフィン写像ではありません。係数は ±4、つまり ±400 % まで行けます。エンジンのフォトステージにはチャンネルごとのレベル補正があり、R、G、B それぞれの出力の黒と白を持っていますが、インターフェースのコントロールはまだそれを書き込みません(第2部でその点に触れています)。また、それらはミキサーの後ではなく前で動きます。そのため、混合後にチャンネルを持ち上げたり潰したりすることは、今のところできません。Photoshop や他のどのクローズドソースのエディタとも、数値の一致を主張するつもりはありません。この行列は、私が宣言した独自の契約です。

チェックボックスなしのモノクロ

モノクロのスイッチはありません。3つの同一の行で、モノクロ画像になります。行の値が見た目を決めます。どの行にも (0.2126, 0.7152, 0.0722) を入れると、出力は、エンコード済みの値に対して計算した Rec. 709 のルマ、つまり Y′ になります。相対輝度ではありません。重みは標準のもので、定義域はガンマでエンコードされたものです。行和は1なので、グレーはそのまま留まり、チャートはきれいなグレースケールになります。

Photoshop のチャンネルミキサーも、そのモノクロのプリセットを同じように、重みの3つ組として文書化しています(同じ名前の一部を共有する別の「白黒」調整とは区別してください)。イエローフィルターのプリセットは、赤34 %、緑66 %、青0 % で、赤外線のものは、赤 −70 %、緑 200 %、青 −30 % です。そのような3つ組を3つの行すべてに入力すると、ここでも近い混合になります。ただ、Photoshop の出力とピクセルを比較したことはないので、一致すると約束はしません。

クリッピングと色域

M·RGB のあと、各出力チャンネルは [0, 1] にクランプされます。エンジンのコメントでは、これを宣言された SDR の挙動として書いています。行は表示範囲を意図的に超えることがあり、そのためクリップする場合がある、というものです。そうすることで、以前の Oklab の色域圧縮の意味が変わることを避けています。負の係数は、そこへ行く典型的な方法です。R′ = 1.6 R − 0.6 G はグレーを保ちます(行和が1だから)が、それでもチャートの72個のチャンネル値のうち8個を範囲外に押し出します。それらはクリップされ、そこの細部は失われます。これを検証するチェックは、[0.8, 0.3, 0.4] を、(2, 0, 0) の赤の行に通し、[1, 0.3, 0.4] になることを確認します。クランプは明示的であり、出力エンコーダの偶然の結果ではありません。

ミキサーは、Oklab の段の柔らかな色域圧縮の後で動きます。そのため、Oklab の段が sRGB の中へ穏やかに戻した色でも、行列によって再び外へ押し出され、ハードにクリップされることがあります。コントラストのコントロールも同じ性質を持ちます。コントラスト 1.5 では、そのクランプが256個の入力コードのうち86個を平坦にします。これは解決済みの問題ではなく、未解決のチケットです。

パイプラインのどこにあるか

color.cpp の Transform::apply の中の順序は次のとおりです。

  1. 入力の段:任意のチェッカーのレベル補正、その後にインポートした LUT(独自の強度でブレンド)。
  2. 線形光:ホワイトバランスのゲイン、色温度と色かぶり、露出。
  3. Oklab の創作の段:参照マッチ、色相と彩度、ピン、色相帯域。その後、任意で柔らかな色域圧縮と、エンコード済み sRGB への復帰。
  4. エンコード済み sRGB で 0.5 を中心にしたコントラスト。
  5. マスターのカーブ、その後に R、G、B のカーブ。
  6. フォトステージ:レベル補正、ルマのカーブ、トーンのコントロール、ビブランス、グレーディングホイール、Selective Color。
  7. チャンネルミキサー、その後にクランプ。
  8. 保護された色と色相ロック。元のピクセルの方向へブレンドして戻す。
  9. グローバルの強度。結果全体を元のピクセルとブレンドする。

この順序から2つのことが言えます。保護はミキサーの後に来るので、ロックされた肌の色相は、行列がその周りのチャンネルを入れ替えても、元のソースの RGB を正確に保ちます。そして、ミキサーまでのすべての段は、ピクセル自身の RGB だけに依存し、位置や隣接ピクセルには依存しません。そのため、ミキサーは他のグローバルなチェーンと一緒に、CUBE と Hald のエクスポートに焼き込めます。

どう検証しているか

  • 数学のファミリーが、行列をその定義に対して実行し、中立行、明示的なクリップ、旧バージョンのグレードを検査し、5という係数を名前付きのエラーで拒否します。
  • ネイティブエンジンは、旧バージョンのグレードを差分0でレンダリングします。
  • 600個のランダムな有効グレードに対するシード付きの JavaScript と C++ のパリティ実行(それぞれ30%の確率で摂動したミキサーを含む)は、196,200個のチャンネル値にわたって 8.7 × 10⁻¹⁴ で一致します。

これらが証明するのは、演算と契約です。与えられた行列が与えられた写真で良く見えるかどうかは示しません。そのような主張の根拠として使うつもりはありません。

限界

  • エンコード済み sRGB のみ。線形光のモードはありません。
  • オフセットの列はなく、モノクロのスイッチもなく、ルマを保つオプションもありません。行和は表示されますが、ルマは表示されません。
  • 行列の後は [0, 1] へのハードクランプで、独自の色域圧縮はありません。
  • SDR、メインウィンドウでは8ビットの Canvas デコード。16ビットの経路は Grid Lab にだけあります。

次の最後の部分は、3D LUTについてです。このグローバルなチェーン(ミキサーを含む)が、.cube や Hald 画像へどう焼き込まれ、ノードの間でどれだけ残るかを扱います。

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