色の範囲ごとの色相・彩度・明るさを、HSLではなくOklabで計算する
著者: Anastasiia Butova, ComfyUI・拡散モデルエンジニア(ベオグラード) · 公開 2026-09-23
英語版からの翻訳: English
カーブは、同じトーンを共有するすべての色を変えます。この部分のツールは、一部の色だけを変えます。緑を黄色の方へ寄せる、空を暗くする、肌には触らない、といった具合です。写真編集ソフトは通常、この系統を HSL と呼びます。私のカラースタジオでは、この機能のどれも HSL では動きません。以下では、色の範囲がどう選ばれるか、編集がなぜ Oklab で行われるか、明るさが色相を引きずらずにどう動くか、肌とグレーがどう保護されるか、そして最初の版の数式が持っていた2つのバグを説明します。
コードの出所
エンジンは LUT Atelier で、私のプライベートなハブリポジトリにあった以前のエディタです。コミット fd96f16 でスタジオに固定されていて、その87個のファイルそれぞれに SHA-256 があります(そのうちの1つは同梱の nlohmann json.hpp です)。経緯は第1部で書いています。色の数学は、標準ライブラリを使う C++20 です。公開製品がインターフェースのアイデアをくれた場合は、その名前を挙げますが、そのアルゴリズムを再現しているとは主張しません。
HSL をやめた理由
HSL と HSV は、色を選ぶピッカーとしては良いのですが、編集には向きません。HSL の明るさは、エンコード済みのチャンネルに対する算術、(max + min) / 2 です。そのため、純粋な赤、黄、緑、シアン、青、マゼンタはすべて HSL の明るさが 50 % になります。Oklab では、その明るさは青の 0.45 から黄色の 0.97 までの範囲です。HSL で「明るさ +10」と約束するスライダーは、色相によって目に見えて違うことをします。また、HSL の明るさを一定にしたまま色相を回すと、明るい黄色が暗い青になってしまいます。

エンジンには rgbToHsl という関数があります。それをインポートしている唯一のモジュールはビューのコードで、どの編集もそれを呼びません。すべての範囲ツールは Oklab、つまり Björn Ottosson の空間で動きます。L は明るさで、(a, b) から彩度 C = √(a² + b²) と色相 h = atan2(b, a) が得られます。Ottosson は、まさにこの仕事を目標に挙げています。知覚される色相と明るさを保ったまま彩度を上げることです。そして、CIELAB は青の色相をうまく予測できないと指摘しています。青は、色相の編集の継ぎ目がよく見える場所です。
直接使っている性質が1つあります。Oklab は行列、立方根、そして2つ目の行列なので、線形 RGB を k 倍すると、L、a、b は k の立方根倍になります。比 C/L と色相は、露出によって変わりません。暖色の色で、k = 2 で確認しました。すべての座標は 1.2599 倍になり、C/L は 0.1531 のまま、色相は 53.42° のままでした。これが、後で出てくる肌の重みが C ではなく C/L を使う理由です。
境界が柔らかい6つの色相のセクター
範囲ツールには、明るさ用の6つのスライダーと、色相用の6つのスライダーがあります。その中心は、Oklab の角度の 0°、60°、…、300° です。これらは色の名前ではなく角度です。純粋な sRGB の赤は Oklab で 29°、黄色は 110°、青は 264° にあります。そのため、インターフェースは「赤」と呼ぶかわりに、各スライダーの下に本物の色相の帯を描きます。
各セクターには、中心から60°離れたところでゼロになる、raised-cosine の重みがあります。
w_i = 0.5 · (1 + cos(π · d_i / 60)) (d_i ≤ 60° のとき)、それ以外は 0
ここで d_i は、ピクセルの色相と中心との円周上の距離です。隣り合う2つの重みは常に合計1になるので、2つのセクターの間にある色は、ちょうど1回分の完全な編集を受け、それが2つのセクターに分けられます。60°で切ることが重要です。cos(π·d/60) は120°ごとに繰り返すので、切らなければ、0°のセクターは120°と240°でも全強度で作用してしまいます。

グレーの近くでは、色相の角度はノイズなので、編集はさらに彩度のゲート q(C) = smoothstep(0.015, 0.05, C) を掛けられます。わずかに偶然の色味を持つグレーは、オレンジ色であるかのように明るくされることはありません。color.cpp の創作の段のループは次のとおりです。
if (hasBands) {
const double chromaWeight = smoothstep(.015, .05, chroma);
double deltaHue = 0.;
for (int i = 0; i < 6; ++i) {
const double distance = hueDistance(hue, i * 60.);
if (distance > 60.) continue;
const double weight = .5 * (1. + std::cos(kPi * distance / 60.)) * chromaWeight * amount;
out[0] += grade.bands[i] * weight;
deltaHue += grade.hueBands[i] * weight;
}
if (deltaHue != 0.) {
const double angle = deltaHue * kPi / 180., cosine = std::cos(angle), sine = std::sin(angle);
const double a = out[1], b = out[2];
out[1] = a * cosine - b * sine; out[2] = a * sine + b * cosine;
}
}
ここには2つの判断が見えます。hue と chroma は、ベースの色から、他のどの創作的な編集よりも前に取られます。そのため、前の色相の回転が、別のスライダーの下で、あるピクセルを動かすことはありません。また、6つのセクターの色相のずれはすべて先に合計され、1回の回転として適用されます。この段階では、L と C は触りません。
色相を動かさない明るさ
明るさのスライダーは、Oklab の L に最大 ±0.2 を足し、a と b には触りません。色相のスライダー(±30°)は (a, b) を回転させ、L には触りません。グローバルな色相と彩度のコントロールも同じ規則に従います。彩度は中立軸からの距離をスケールし、色相は角度を回します。数学の検査(npm run check:math)がそれを測定します。グローバルな色相と彩度を編集した後、Oklab の L の変化はゼロで、彩度の誤差は 2.7 × 10⁻¹⁵ です。
これは創作の段階で成り立つことで、それ以上は主張しません。その後には、柔らかな色域圧縮、コントラスト、カーブ、フォトステージ、チャンネルミキサーが続きます。色域圧縮がオンのとき(Grid Lab は新しいグレードで有効にします)、それは、色域の外にある色を、同じ L のグレーの方へ線形 RGB の中で動かします。青の近くでは、固定された L と色相の Oklab の光線は安全ではないからです。理由は第1部で説明しています。少なくとも、グレーから色域の縁までの距離の最初の 80 % は正確なまま保たれます。その肘(knee)を越えると、圧縮はわずかに L と色相を動かします。ドキュメントは、その不変条件が生き残るふりをせず、そのことを明記しています。
肌とグレーの保護
保護には3種類あり、同じ強さではありません。
柔らかいスライダーは、創作の段階だけを弱めます。その重みは次のとおりです。
neutral = 1 − smoothstep(0.015, 0.12, C)
skin = exp(−(d(h, 35°) / 28°)²) · smoothstep(0.015, 0.06, C/L) · smoothstep(0.005, 0.025, L)
そして、創作的な編集は 1 − max(neutralStrength · neutral, skinStrength · skin) でスケールされます。以前の版では、肌は L = 0.35 のしきい値でゲートされていて、それが暗い肌を静かに保護しないままにしていました。C/L に切り替えてしきい値を取り除き、色相が信頼できない黒のすぐ近くの領域だけがフェードアウトするようにしました。その結果、保存済みのプロジェクトでは、肌の保護が暗い色をより強く保護するようになりました。この変更は意図的に行ったものです。これらの数字は、私のエンジニアリング上のヒューリスティックで、公開された肌のモデルでも分類器でもありません。暖色のブラウンのソファはローブの内側に入りますし、色付きの光の下の肌はローブの外に出ることもあります。テストは、サンプルの色に対する数学を検査するものであって、ポートレートのセットでの認識を検査するものではありません。
色相のロックはより強力です。それぞれに中心、完全保護の半幅、フェザー、最小彩度があり、最大12個まで作れます。Grid Lab のプリセットは、肌が 50°±25°、フェザー 15°、緑が 145°、空が 250° です。スポイトは ±12°、フェザー 8° の狭いロックを作ります。重みは元のピクセルから取られ、ブレンドによる戻しは最後に一度だけ行われます。
out = original + (processed − original) · strength · (1 − maxProtection)
そのため、ロックされた色は、LUT、ホワイトバランス、カーブが何をしたかに関係なく、元のソースの RGB に正確に戻ります。インポートした LUT が、どのピクセルが保護されたとみなされるかを変えることはできません。
最初のバグはここにありました。sRGB から Oklab に変換すると、純粋なグレーは小さな彩度、白では 3.7 × 10⁻⁸、暗いグレーではさらに小さい値になり、色相は 89.9° になりました。そのため、最小彩度 0 で範囲が90°をまたぐ色相のロックは、フレーム内のすべてのグレーを保護してしまいました。修正は、CSS Color 4 からしきい値を取りました。そこでは、C ≤ 0.000004 のとき、OkLCh の色相は意味を持たない(powerless)とされています。その値より下では、ピクセルは色相を持たず、色相のロックはそれを主張できません。検査では、256個の sRGB のグレーを変換します。彩度の最大値は 8.5 × 10⁻¹¹ で、すべてのロックの重みは 0 です。
グレーを突き抜けた弦
保護も、フェザー付きの選択範囲も、編集を「部分的に」適用します。最初の版は当たり前のことをしていました。完全に回転させた (a, b) を計算し、それを元の (a, b) と重みで混ぜるものです。その混合は、弧ではなく、色相の円の弦に沿って進みます。重み 0.5 では、180°の回転はちょうどグレーに着地し、90°の回転は彩度の29%を失います。半分の弦の長さは |cos(θ/2)| だからです。

9月22日に行った、エンジンの読み取り専用の独立レビュー5件で、この問題が2か所で見つかりました。グローバルな色相と彩度の保護の重み付けと、Selective Color のフェザーの縁です。どちらも今では、編集そのものを極座標でスケールします。角度は A·θ、彩度の係数は 1 + A·(s − 1) です。photo_color.cpp の Selective Color の版は次のとおりです。
// The feather scales the edit in polar form; a Cartesian mix cut across the hue circle.
const double newChroma = std::hypot(lab[1], lab[2]) * (1. + membership * selection.saturation);
const double angle = std::atan2(lab[2], lab[1]) + membership * selection.hue * kPi / 180.;
lab = {lab[0] + membership * selection.lightness, newChroma * std::cos(angle), newChroma * std::sin(angle)};
検査では、メンバーシップ 0.5 のとき、彩度の誤差が 3.9 × 10⁻¹⁶、色相は要求の正確に半分だけ回ることを確認しています。JavaScript の実装と C++ エンジンは、同じ入力で比較されます。極座標の保護は 6.1 × 10⁻¹⁵ で一致し、極座標の選択的カラーを含むフォトステージは 1.1 × 10⁻¹⁴ で一致します。
セクターではなく、色を選ぶ
セクターは意図的に粗くしてあります。「すべての緑ではなく、この緑」を指定するには、サンプルを使う2つのツールがあります。
選択的カラーは、最大8個のサンプルを取ります。それぞれに、Oklab の半径があり、その半径には L も a、b と同様に数えられます。内側の半径(既定ではその半分)の内側はメンバーシップが完全に1で、その内側の半径はフェザーのスライダーで決まります。半径では smoothstep で0までフェードアウトします。メンバーシップは、編集されていない元の色から一度だけ計算されるので、前の編集がどのピクセルがその範囲に属するかを変えることはありません。編集は、L に m·ΔL を足し、C に 1 + m·Δs を掛け、h に m·Δh を足すもので、任意で目標色へ引き寄せることもできます。保護された色は、編集のないサンプルのようなもので、その内側の半径の内側では、元の RGB が正確に戻ります。
A/B グリッドの色のピンは、さらに進みます。a/b 平面に最大24個のアンカーを置け、それぞれを動かすか、ロックするかを選べます。スタジオのページ用のインタラクティブなデザインのモックアップでは、アンカーごとに1つのコンパクトなカーネルを足して、それらを合計していました。半径の半分だけ離れた2つのアンカーがあり、A は Δa = 0.10 を求め、B は動かないことを求めるとします。するとBは 0.10 × φ(0.5) = 0.019 動きます。エンジンは代わりにカーネルの系を解きます。K c = d、K_ij = φ(‖p_i − p_j‖ / R)、そして Wendland の C² カーネル φ(t) = (1 − t)⁴ (4t + 1) です。そのフィールドは、要求されたすべての変位をちょうど通ります。ロックは、変位がゼロであることにほかなりません。この解は、n ≤ 24 に対して O(n³) で、ピクセルごとではなく編集ごとに一度だけ走ります。R の 0.001 倍より近いアンカー、あるいはピボットが 10⁻⁵ を下回る系は、雑に解くのではなく、エラーとして拒否されます。
限界
- ここにあるものはすべて、絵の中ではなく色空間の中で選択します。ベージュの壁とベージュの頬は、同じ扱いを受けます。同じ事実が、書き出し可能である理由にもなります。この部分のすべてのツールは入力 RGB だけの関数なので、
.cubeに焼き込めます。それは第6部です。 - 意味的な肌のマスクはなく、商用エディタの範囲境界を持つ8範囲の HSL パネルもありません。Lightroom Classic のカラーミキサーとポイントカラーは、影響を受ける範囲をスライダーの横に表示することがどれだけ役立つかを教えてくれました。取り入れたのはインターフェースのためのそのアイデアで、数式ではありません。
- 経路は、D65 で線形 sRGB から計算された Oklab による SDR の sRGB です。ログ、HDR、任意の ICC の入力はサポートしていません。
- 大きく動かしたピンのフィールドは、2つの色を1つに畳んでしまうことがあります。グリッドラボのリミッターは、彩度とマッチングに対してはフィールドを認証しますが、保護の重みに対してはまだしていません。その場合は、解決済みではなく、未解決のチケットです。
次の部分は、チャンネルミキサーです。RGB チャンネル上の3×3の行列で、この知覚的な仕組みは一切関係なく、数学はもっと単純で、もっと無骨になります。