happyin.work / ブログ

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

カーブ:自然スプラインとPCHIP、オーバーシュート、ルマカーブ、LUT への焼き込み

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

英語版からの翻訳: English

カーブエディタは描画ツールのように見えますが、その下には、目に見える結果をもたらす1つの判断があります。置いた点を通る滑らかな線をどう引くか、です。私のカラーエンジンは3つの答えを用意していて、場所によって2つを使い分けています。この部分では、その理由、オーバーシュートが画像に何をするか、マスターの RGB カーブがルマカーブとどう違うか(エンジンの測定値つき)、そしてカーブが LUT に焼き込まれるときに何が起きるかを扱います。

このエンジンでのカーブとは

カーブは2〜16個の点を持ち、両軸は0〜1で、x は厳密に増加しなければなりません。両端は保持されます。クリックで点が追加され、矢印キーで選択中の点が 1/255 動き(Shift を押すと 10/255)、Delete で内側の点が削除されます。第1部のパイプラインでは、マスターのカーブがまずエンコード済みの各 RGB チャンネルに適用され、その後に R、G、B のカーブ、そして後のフォトステージでルマのカーブが適用されます。補間モードは3つあります。linear、smooth(PCHIP)、spline(自然3次)です。1つのモードがマスター、R、G、B をまとめて扱います。ルマのカーブは常に PCHIP です。モードのフィールドができる前に保存されたプロジェクトは linear のままになります。そのモードで作られていたからです。

係数はグレードごとに一度だけコンパイルされます。ピクセルのループには、最大15区間のセグメント探索と Horner の評価があり、メモリ確保もなく、検証もありません。

// native/src/color.cpp
double sample(double input) const {
  if (input <= x.front()) return y.front();
  if (input >= x.back()) return y.back();
  std::size_t index = 0;
  while (input > x[index + 1]) ++index;
  if (input == x[index + 1]) return y[index + 1];
  const double t = (input - x[index]) / (x[index + 1] - x[index]);
  if (mode == CurveMode::linear) return y[index] + (y[index + 1] - y[index]) * t;
  const double value = ((d[index] * t + c[index]) * t + b[index]) * t + y[index];
  return mode == CurveMode::spline ? clamp(value) : value;
}

最後の行が、この記事全体の話を1つの式に詰めたものです。クランプが必要なのはスプラインだけです。

自然3次スプラインと、その代償

スプラインのモードは、各点での2次導関数 M_i を解きます。h_i = x_(i+1) − x_i と、割線の傾き d_i を使うと、この系は三重対角になります。

h_(i−1)·M_(i−1) + 2(h_(i−1) + h_i)·M_i + h_i·M_(i+1) = 6(d_i − d_(i−1)),   M_0 = M_(n−1) = 0

エンジンは、点が変わるとトーマス法でこれを解き、区間ごとの3次係数を、局所的な t で保存します。値、1次導関数、2次導関数が、すべての点で連続です。そのため、点列を通す形の中で最も柔らかいものになります。新しい RGB カーブはデフォルトでこれを使います。外部の基準として、SciPy の自然な CubicSpline と照合しました。JavaScript の実装は 6.7e-16 で一致し、C++ エンジンは JavaScript の実装と 3e-13 で一致します。クリップするほど強いカーブも含めてです。

代償はオーバーシュートです。自然スプラインは、出力が 0〜1 に収まるべきことも、上昇する点を通るカーブは上昇し続けるべきことも知りません。

自然スプラインと PCHIP で描いた4つの制御点:スプラインは影で0を下回り、ハイライトで1を超える。クランプすると両方が平坦な領域になる

ブラウザでのスプラインモードの確認には、次の4点を使いました。(0, 0)、(0.36, 0.2)、(0.52, 0.68)、(1, 1) です。クランプ前、スプラインは −0.037 と 1.041 に達します。10,001個の入力からなる密なランプでは、入力が 0.235 以下のとき 0 を下回り、0.714 以上のとき 1 を超えます。SDR のクランプの後では、最初のおよそ60入力コードが平坦な黒になり、上位73コードが平坦な白になります。その平坦な領域の内側では、カーブはもう C² ではなく、厳密な増加でもありません。コード60未満の影の細部が、1つの値になってしまいます。このカーブでは、スプラインと PCHIP は入力 0.70 で最大31の出力コードだけ異なります。これは線の見た目のスタイルの違いではなく、まったく別の写真です。

PCHIP:新しい極値を作らず、導関数が1つ少ない

smooth モードは PCHIP です。Fritsch–Butland の接線(Fritsch and Butland, 1984)を持つ区分3次エルミート補間で、Fritsch and Carlson, 1980 の単調性の条件を満たします。内部の接線は、隣接する2つの割線の重み付き調和平均で、割線の符号が異なる場合や、どちらかがゼロの場合にはゼロに設定されます。

m_k = (w1 + w2) / (w1/d_(k−1) + w2/d_k),   w1 = 2h_k + h_(k−1),   w2 = h_k + 2h_(k−1)
m_k = 0  if d_(k−1)·d_k <= 0

端の接線は、符号と大きさに制限を付けた片側3点推定を使います。これは SciPy の PchipInterpolator が使う規則と同じです。結果はすべての点を通り、1次導関数は連続で、隣接する点の間に新しい極値を作りません。点が上昇していれば、カーブも上昇します。2次導関数は点で飛ぶことがあり、それが見出しの「導関数が1つ少ない」という意味です。実際には、わずかに丸みの少ない曲がり方として現れます。

数学のスイートでは、単調な PCHIP のカーブを1,025個の入力でサンプリングし、どのステップも下がらないことを確認しています。測定された最小のステップは 2.2e-6 です。図のカーブでは、PCHIP の最小ステップは 1.5e-6 で、クランプされたスプラインのステップはちょうど0です。エディタでは、PCHIP のモードに「オーバーシュートなし」と表示しています。

では、なぜ両方を残すのでしょうか。それは、両者が異なる要求に答えるからです。範囲の内側に十分収まった点を、ゆるやかに曲げる場合、スプラインのほうが柔らかい形になり、差は小さいです。黒と白に近い強い S 字のカーブでは、PCHIP のほうが、スプラインが捨ててしまう影とハイライトの階調を残します。モードは1つのスイッチで、プロジェクトに保存されたままになります。

マスターの RGB カーブと、ルマのカーブ

マスターの RGB カーブは、エンコード済みの R、G、B に同じ関数を適用します。グレーはグレーのままです。しかし、色はチャンネルの比率を保ちません。そのため、コントラストのカーブは彩度も上げ、色相もわずかに変わります。ルマのカーブは、「トーンは変えて、色は触らない」という要求への答えです。BT.709 の重み Y = 0.2126R + 0.7152G + 0.0722B で、線形の輝度に対して動きます。そして、新しい輝度がちょうど f(Y) になるように RGB を組み直します。

// native/src/photo_color.cpp
std::array<double, 3> linear{srgbToLinear(levelled[0]), srgbToLinear(levelled[1]), srgbToLinear(levelled[2])};
// Luma curve: RGB' = RGB * (f(Y) - f(0)) / Y + f(0), so Y' = f(Y) exactly (see photo-color.mjs).
const double y = lumaOf(linear);
const double mappedY = luma.sample(y);
if (y > kLumaFloor) {
  for (double& channel : linear) channel = channel * (mappedY - lumaBlack) / y + lumaBlack;
  linear = clipToLuminance(linear, mappedY);
} else {
  linear = {mappedY, mappedY, mappedY};
}

ゲインの前後の2行は、どちらも修正です。最初の版は RGB を f(Y)/Y でスケールしていました。黒が持ち上げられていると f(0) > 0 になり、そのゲインは黒の近くで爆発します。青のチャンネルが0より1コード上だったものが、255になってしまいました。持ち上げを中立のオフセットとして扱い、f(0) を3つのチャンネルすべてに足すようにすると、持ち上げのままになります。2つ目の修正は、0〜1の外に出るチャンネルについてです。チャンネルを1つずつクリップすると、目標の輝度を最大0.137外し、色相を最大13.6°回してしまいました。エンジンは今、PDF 仕様の非分離ブレンドモードの一部である ISO 32000-1 §11.3.5.3 の ClipColor の構成を使っています。範囲外のチャンネルは、同じ輝度のグレーの方向へ引き寄せられるので、あるチャンネルがクリップしそうな場合でも Y' = f(Y) が成り立ちます。数学のスイートは、ルマのカーブの輝度誤差を 1e-15 未満と測定しています。

これが何をもたらすかを見るために、同じ S 字カーブを両方の方法でエンジンに通しました。マスターのカーブは、エンコード済みの値に対する PCHIP の S 字カーブです。ルマのカーブは、グレーが 0.25 コード以内で同じになるように作りました。

同じ S 字カーブを受けた6色:マスターの RGB カーブは Oklab の彩度を15〜31%上げ、色相を最大4度回す。ルマのカーブは色相を変えない

6つのテスト色では、マスターの RGB カーブは Oklab の彩度を15〜31%上げ、色相を最大4.0°回しました。ルマのカーブは彩度を −8%〜+7%変え、色相は0.0°しか回しませんでした。彩度が少し動くのは、Oklab の彩度が色度を一定にしても明度に従うからです。Capture One は、同じ意図を Luma curve で文書化しています。彩度を上げずにコントラストを付けることです。Adobe の Camera Raw のポイントカーブは、コンポジットとチャンネルごとのカーブを提供しています。どちらも数式は公開していません。私のものは私自身の数式です。

インターフェースへの影響が1つあります。ルマのカーブは線形の輝度スケールの上にあるので、同じ点の座標でも、マスターのカーブとは違う階調を意味します。エディタはそれを独自のスケールで表示し、チャンネルのオーバーレイは描きません。

知覚軸のカーブ:設計済み、未実装

候補となっているカーブのツールは、文書には書かれていますが、パネルにもエンジンにもありません。Oklab の軸の上に3つのカーブを置く案です。L2 = fL(L)、a2 = fa(a)、b2 = fb(b) で、中心は固定されます。fa(0) = 0 かつ fb(0) = 0 なので、どの中立も中立のままです。この固定を解除できるのは、中立がずれることを警告する明示的な操作だけです。結果は、マスク重み m と強度 s を通して混ざります。out = (1 − s·m)·source + s·m·OklabToRgb(L2, a2, b2)。適用の前に、2つの検査が表示されます。中立の固定が壊れていないか、色が色域の外に出ていないかです。そこでの色域圧縮は、明示的なモードでなければならず、黙ったクランプであってはなりません。

カーブを LUT に焼き込む

グレードのグローバルな部分(トーン、色、カーブ、入力の LUT、色の保護)はすべて入力の RGB だけに依存するので、3D の CUBE や Hald 画像として書き出せます。明瞭度やデヘイズのような空間的な編集は、隣接ピクセルや位置に依存するので、エンジンはそれを LUT に入れることを拒否します。エディタは、そのような空間的編集を含むグレードの色だけの部分を、空間的編集が取り除かれることを明示的に確認した後にだけ書き出します。焼き込みは、N³ 個の格子点で完全な変換をサンプリングします。N は2〜65で、赤が最も速く変わる順番です。

格子点では、CUBE はエンジンと一致します。スプラインだけのグレードを 33³ で書き出すと 3e-8(浮動小数点の精度)、すべてのグローバルなコントロールを使うグレードでは 1.7e-6 で、レビューの 2e-6 のしきい値の内側です。格子点の間は補間なので、どれくらいかを正直に述べます。その完全なグレードでは、格子点から外れた4,096個のサンプルの RMS 誤差は、33³ で 0.010(約2.6コード)、65³ で 0.0054 でした。最悪の1サンプルははるかに悪く、33³ では、ほぼ黄色の入力の青チャンネルが 0.53 高く出ました。65³ でも、最悪のサンプルは 0.27 ずれていました。レビューは書き出しを HOLD と判定しました。これは、CUBE がこのグレードの近似であり、コピーではないという意味です。忠実な出力はネイティブ画像かプロジェクトです。エンジンには、書き出されるグレードに対して、まさにこの2つの数値(4,096サンプルにわたる最大値と RMS)を返す検証の呼び出しがあります。第2部では、1組のレベル補正で同じ効果を示しています。そこでは、65³ の格子が黒の近くで約23コードずれました。

精度

カラー変換は double で動き、8ビットまたは16ビットの出力を書くときに一度だけ丸めます。カーブはピクセルごとに解析的に評価され、内部の1D テーブルは通しません。メインウィンドウは、ブラウザの8ビット sRGB の Canvas で動きます。8ビットのソースに対する急なカーブは、256個の入力レベルをより広い範囲に広げます。それがバンディングの原因です。ラボのウィンドウは、Canvas を通さずに16ビットの PNG を読み、16ビットの PNG を書き出し、8ビットに減らす必要があるときは順序付きの8×8ディザを適用できます。そのディザはピクセルの位置に依存するので、LUT の一部になることは決してありません。これらすべてが、JPEG がもともと持っていなかった階調を復元するわけではありません。編集がそれ以上壊すのを止めるだけです。

カーブのドラッグには、別の問題がありました。コードの数ではなく、時間で測ったものです。最初の版では、61イベントのドラッグで、最初の移動から 3.56 秒後に、カーブが一度だけ描かれました。今では、グラフはアニメーションフレームごとに、処理とは独立して再描画されます。エディタはプレビューを一度に1つだけ C++ エンジンに送り、待機中のパラメータは最新のものだけを残します。カーブのヒストグラムはソース画像ごとにキャッシュされます。同じドラッグで、61のカーブのフレームが描かれ、最初のものは 33.9 ms後、最初の処理済み画像は 267 ms後でした。Undo を1回押せば、ドラッグ全体が元に戻ります。

限界

  • 自然スプラインは RGB カーブの既定で、端でクリップすることがあります。黒と白の近くの強いカーブには、PCHIP のモードを使ってください。
  • ルマは常に PCHIP で、線形の輝度上に作られ、Capture One や Adobe との一致は主張しません。
  • 知覚軸のカーブは、機能ではなく設計です。
  • 複雑なグレードの焼き込まれた CUBE は近似です。どこかに送る前に、最大誤差と RMS 誤差を確認してください。

次の部分は、色相、彩度、明るさを色ごとにです。色よりネイティブ側に関心があるなら、オンデバイスの C++ レタッチプラグインが happyin.ai にあります。

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