RustyWorks / Kuro — 技術解説

正しい色を、再生しながら

Kuro は macOS の動画プレイヤーですが、中身の大半はカラーコレクターです。 再生しながら映像を grade し、その結果が業務用ツールと同じ数字になるように作ってあります。 このページは、その「同じ数字になる」を何によって担保しているかの説明です。


作業空間をひとつに畳む

映像の色が合わないとき、原因のほとんどは演算そのものではなく どの空間で演算したかです。同じ「明るさ +10」でも、リニアで足すのと ガンマの掛かった符号値に足すのとでは結果が違います。

Kuro はパイプライン全体を Rec.709 / Gamma 2.4(BT.1886) に統一しています。 デコードは pow(v, 2.4) で、素通しの 128 が 128 のまま出ます。 以前は sRGB の EOTF を使っていて、これは中間調を約 7 コード持ち上げていました — 誰も操作していないのに絵が変わる、という状態です。廃止しました。

リニア Gamma 2.4 符号空間 素材 デコード 色温度 / ティント 1.5 符号化 Lift / Gamma / Gain 2 — 2.5 3-Way・コントラスト 3 — 7 復号 表示 709→P3 ホワイトバランスだけがリニアにある。チャンネル別の一定ゲインは、光源そのものの 強度比を直す操作だから — 符号空間で掛けると比が保たれない。
各演算がどの空間にいるか。リファレンス一致の根拠はここに集約されていて、 個々の式より先に決まっている。

その上で、各コントロールは DaVinci Resolve(Rec.709 / Gamma 2.4 タイムライン)に対して 1つずつスポットプローブとスコープで突き合わせ、実測でモデル化してあります。 推測した式ではありません。基準パッチでおおむね ±2 コードの一致です。

実測から出た形はどれも素直ではありません。ガンマは 0 中心表記を実効指数へ移す必要があり (G≥0: 1+4G / G<0: 1/(1−4G))、コントラストは e=(2−contrast)^-1.17、ピボットは 0.435。色相回転にいたっては 回転量が色相自身に依存していて、2次高調波でフィットしています (ΔH(H) = −25.63 − 7.43·cos2H + 15.96·sin2H)。 合わせるとはこういう作業です。

画面に届くまでに、もう一度ガンマが掛かる

ここまでは Kuro の中の話です。macOS には続きがあります。 CAMetalLayer の色空間を extendedLinearDisplayP3 に している以上、コンポジタが最後に sRGB 形状の OETF を掛けます。 パイプラインを 2.4 で統一しても、素直に出力したのでは 画面に届くのは 2.4 ではありません

そこで最終段は srgbEOTF(gamma24Encode(·)) を書き込みます。 コンポジタの sRGB OETF がこれを打ち消すので、 パネルに残るのは純粋な 2.4 のコードです。 2.4 をそのままパネルへ送る DaVinci のビューアと一致するのは、この一段があるからです。 「素通しの 128 が 128 で出る」の根拠も、実はここまで含めた話です。

設定で sRGB を選ぶと、リニアで書いてコンポジタに任せます。 Mac の色管理としてはこちらが正確で、そして Gamma 2.4 より暗く出ます(DaVinci と比べて シャドウが約 9 コード)。

ここは向きが分かりにくいところです。「2.2 のほうが明るいはずでは」と 思ったなら、その直感は正しく、それがそのまま理由です。 Gamma 2.4 の側は、2.4 で符号化したコードを ≒2.2 のパネルが解くので、 基準より明るく出ます。sRGB の側はリニアに戻してから渡すため、 BT.1886 が指定するとおりの光量 —— つまり暗いほうになります。 設定の名前は何を送っているかであって、何が掛かるかではありません。 差はシャドウで最も大きく、ハイライトではほとんど消えます。

そして Gamma 2.4 が DaVinci と一致するのは、色として正しいから ではありません。DaVinci のビューアも同じようにコードをパネルへ 直接渡すからで、パネルが何をしようと両者が同じ影響を受ける、というだけです。 どちらが正しいかではなく、何に合わせるかの選択。 リファレンスに合わせたいなら 2.4、Mac の他のアプリと揃えたいなら sRGB。


白を、平均ではなく証拠から決める

自動ホワイトバランスの古典的なやり方はグレーワールド — 画面全体の平均が 無彩色になるように直す、というものです。単純で、そして赤い壁や夕景で必ず外します。 画面の平均は光源の色ではないからです。

Kuro は違う道を取ります。画面を 16×16 = 256 のゾーンに分け、 各ゾーンの色度を計算し、黒体軌跡からどれだけ離れているか(Duv)ゾーンごとに採否を決めます。照明はほぼ軌跡の上に乗り、色のついた物体は乗らない — この一点だけで、平均では区別できなかったものが分かれます。

Duv +0.02 Duv −0.02 黒体軌跡 2000K 4200K 6500K 20000K 相関色温度(ミレッド等間隔) 電球 中立グレー 曇天 蛍光灯 芝生 赤い壁 青い看板
実測値。照明は最大でも Duv 0.017 に収まり、色のついた物体は最小でも 0.026 離れる。 その隙間に閾値を置ける — ホワイトマップの実体はこの一行だけです。
ゾーンの採否 — 実測
ゾーンCCTDuv判定
中立グレー6500K+0.0031採用
電球4175K+0.0075採用
曇天9125K+0.0027採用
蛍光灯6375K+0.0165採用
赤い壁2200K−0.0257除外
芝生6025K+0.0617除外
青い看板20000K−0.0415除外

計算の健全性は 1 行で確かめられます。R=G=B を入れて 6500K / Duv +0.0031 が 返ればよい。D65 が軌跡から +0.003 だけ緑側にあるのは既知の値なので、 これが出るなら実装は正しいという基準になります。

採用されたゾーンの意見がどれだけ揃っているかが、そのまま信頼度になります。 信頼度が低ければ補正は控えられます。手がかりが無いときに無理に決めないことも、 自動処理の仕事のうちです。


露出は、掛け算ではなく冪で動かす

暗い映像を明るくする一番素朴な方法はゲイン、つまり全体に定数を掛けることです。 これには避けられない副作用があります。1.0 に掛けると 1.0 を超える — 白が飛びます。持ち上げれば持ち上げるほど、上端から情報が消えていきます。

Kuro は露出をとして扱います。符号空間でガンマを動かすことは、 リニアで冪を取ることと同じで((L^(1/2.4))^e)^2.4 = L^e)、 0 は 0 のまま、1 は 1 のままで中間だけが動きます。端点が原理的に壊れません。

暗い画をゲインで持ち上げると、測定した 21 点のうち複数が 1.0 を超えて白飛びした。 同じ量をガンマで動かすと、1 点も飛ばなかった。

触らない、という判断のほうが多い

自動調整で難しいのは直すことより直さないことです。 雪景色を「明るすぎる」と判断して暗くしてしまえば、撮影者の意図を壊します。 実素材 1287 フレームで各機構がどれだけ働いたかを測りました。

発火率 — 1287 フレーム
機構プロ素材 807アマ素材 480
逆光と判定2.6%2.7%
レターボックス検出3.8%0%
平坦(グラフィック)0.4%2.9%
露出をほぼ触らない88.8%96.7%

レターボックス検出が、レターボックスのあるプロ素材にだけ 3.8% 出て、 アマチュア素材で 0% なのが分かりやすい例です。どの機構も枠は小さいが、空振りしていません。


HDR を、暗くせずに SDR の画面へ

HDR 映像を普通のプレイヤーで開くと、たいてい眠い絵になります。 1000 nits を前提に作られた信号を 100 nits の画面へ素直に押し込むからです。

Kuro は video-params/gamma が PQ / HLG を返した時点で経路を切り替え、 輝度にだけショルダーを掛けます。RGB を個別に潰さず、 輝度の比 y/Y を三成分に等しく掛けるので、 明るさを畳んでも色相が動きません

曲線は Reinhard のショルダー y = s·x/(1+s·x) · (1+s)/ss = 2.62)で、 入力は BT.2408 の基準白 203 nits で正規化した輝度 x = Y · 203/(ソースピーク)。ソースのピークがちょうど 1.0 に乗ります。

ここは当初 ITU-R BT.2390 の EETF でした。 実素材で二度試して、二度とも測定で落ちています。 EETF は 203 nits の拡散白を画面の 0.88 に置いてしまい、 絵の大半がその上の狭い範囲に詰まる — 眠さを直すはずの経路が眠さを作っていました。 2026-08-28 に削除しました。規格の名前がそのまま良い絵になるとは限りません。

ヘッドルームについても、期待と実測が違いました。 maximumPotentialExtendedDynamicRangeColorComponentValue は 手元の2台(INNOCN 32M2V / BenQ SW2700)とも 実測 1.00、EDR レイヤーを実際に画面へ出した状態でも 1.00 です。 使える余地が無いものを設計の前提には置けないので、 現行の曲線はヘッドルームを見ずに 1.0 へ写します。 ヘッドルームのある画面で本当にハイライトを SDR 白より上へ出すのは、別の設計として残しています。


できること

グレーディングが中心ですが、プレイヤーとしての足回りも同じ基準で作ってあります。 以下は無料で使える範囲と、Kuro PRO で開く範囲の実際の線引きです。

無料

何でも開く

libmpv が積んであるので、MKV・WebM・VP9・AV1・MPEG-1/2 まで、 AVFoundation が開けない形式もそのまま再生します。

無料

ワンクリック自動調整

ゾーン測光と軌跡ベースの AWB。シーンが変わるたびに測り直す 「シーン毎」も無料側です。

無料

スコープとプローブ

波形・ベクトルスコープ・ヒストグラムを Metal compute で描画。 画面上の一点の補正後 Rec.709 値も読めます。

無料

音声まわり

音声トラック切替、A/V 同期のずれ補正、静かなセリフを持ち上げて 大音量を抑えるナイトモード。

無料

手で追い込む

露出・黒レベル・ガンマ・コントラスト・色温度/ティント・彩度。 マスターの7本は無料側です。実測モデルはそのまま使えます。

無料

A/B 比較

全体切替と、画面を縦に割って左右で見比べるスライダー。 補正が何をしたのかを、記憶ではなく同時に見て確かめられます。

Kuro PRO

グレーディングの全段

カラーホイールとチャンネル別の Lift / Gamma / Gain、オフセット(ASC CDL)、 3-Way、コントラストのピボット、色相(HSL)、カーブ、トーンマップ。

Kuro PRO

AWB / AE スポイト

白い場所、適正にしたい場所を直接指すと、そこを基準に合わせます。 自動が迷う画で効きます。

Kuro PRO

LUT 書き出し

作ったグレードを .cube に。ここで作った色を、そのまま別の編集ツールへ 持っていけます。

Kuro PRO

Wiener デブラー

ぼけの逆畳み込み。フレーム毎に FFT は回さず、sigma と K が変わったときだけ CPU で空間カーネルを作り直して畳み込みます。

Kuro PRO

ノイズ低減とデバンド

エッジを保存する空間バイラテラルと、動きに追従する時間 NR。 空のグラデーションに出る縞にはデバンド。

Kuro PRO

アップスケール

MetalFX、Anime4K、Apple の低遅延 SR。mpv は等倍で描き、 Metal のパスで拡大します。

Kuro PRO

3D LUT 読み込み

.cube をテトラヘドラル補間で適用。強度も可変です。


App Store にいること自体が、設計上の制約だった

メディア再生の世界で広く使われている部品には、GPL のものが多くあります。 GPL のコンポーネントが 1 つでも入ると Mac App Store では配布できません。品質の問題ではなく、ライセンスの問題です。

Kuro は同梱する libmpv と FFmpeg を -Dgpl=false / --disable-gpl で 自前ビルドしています。GPL のフィルタが 1 つでも混ざれば配布できないので、 入場券として払っている制約です。Homebrew のビルドは使えません。

スコープは普通、再生ソフトではなく専用の監視ツールに付くものです。 再生しながら波形とベクトルスコープを見るという形は、 この制約を先に払ったうえで選んだものです。

RustyWorks / Kuro — How it works

The right color, while it plays

Kuro is a macOS video player, but most of what is inside it is a color corrector. It grades the picture during playback, and it is built so the result lands on the same numbers a professional tool produces. This page explains what makes “the same numbers” true.


Collapsing the working space into one

When colors do not match, the cause is usually not the arithmetic — it is which space the arithmetic happened in. “Brightness +10” means one thing added to linear light and another thing added to a gamma-encoded code value.

Kuro puts the whole pipeline in Rec.709 / Gamma 2.4 (BT.1886). Decode is pow(v, 2.4), so a passthrough 128 comes out as 128. It used to use the sRGB EOTF, whose toe lifted midtones by about 7 code values — the picture changed with nobody touching a control. That is gone.

linear Gamma 2.4 encoded source decode temp / tint 1.5 encode Lift / Gamma / Gain 2 — 2.5 3-Way, contrast 3 — 7 decode display 709→P3 White balance is the only stage in linear: a per-channel constant gain corrects the intensity ratio of the light itself, and that ratio is not preserved in an encoded space.
Which space each operation lives in. The claim to reference accuracy is concentrated here, and it is settled before any individual formula is.

On top of that, every control was matched against DaVinci Resolve (Rec.709 / Gamma 2.4 timeline) one at a time, with a spot probe and scopes, and modelled from the measurements. These are not guessed formulas. Agreement on the reference patches is roughly ±2 code values.

None of the shapes that came out of the measurements are tidy. Gamma needs its 0-centered value carried to an effective exponent (G≥0: 1+4G / G<0: 1/(1−4G)), contrast is e=(2−contrast)^-1.17 with a pivot of 0.435, and hue rotation has a rotation amount that depends on hue itself, fitted as a second harmonic (ΔH(H) = −25.63 − 7.43·cos2H + 15.96·sin2H). That is what matching actually consists of.

One more gamma, on the way to the screen

That was all inside Kuro. macOS has a postscript. As long as the CAMetalLayer colorspace is extendedLinearDisplayP3, the compositor applies an sRGB-shape OETF at the very end. Unifying the pipeline on 2.4 is not enough: write the value out plainly and what reaches the screen is not 2.4.

So the final stage writes srgbEOTF(gamma24Encode(·)). The compositor's sRGB OETF undoes that, and what lands on the panel is the pure 2.4-encoded code — which is why it matches DaVinci's viewer, a viewer that sends 2.4 straight to the display. “A passthrough 128 comes out as 128” only holds because of this step.

Choosing sRGB in the settings writes linear and lets the compositor do its thing. That is the colorimetrically accurate answer for the Mac panel, and it comes out darker than Gamma 2.4 — about 9 code values in the shadows.

The direction of that trips people up. If your first thought was “surely 2.2 is the brighter one”, you are right, and that is exactly the reason. On the Gamma 2.4 path, a 2.4-encoded code is decoded by a panel that is closer to 2.2, so it comes out brighter than the reference. The sRGB path converts back to linear first, so what the panel emits is the light BT.1886 actually specifies — the darker one. The name of the setting says what is being sent, not what gets applied to it. The gap is largest in the shadows and all but disappears in the highlights.

And Gamma 2.4 matches DaVinci not because it is colorimetrically right. It matches because DaVinci's viewer hands codes to the panel the same way, so whatever the panel does, it does to both. This is not a question of which is correct, but of what you are matching — 2.4 to match a reference, sRGB to match everything else on the Mac.


Deciding white from evidence, not from an average

The classic approach to auto white balance is gray world — correct so the average of the whole frame is neutral. It is simple, and it is reliably wrong on a red wall or a sunset, because the average of the frame is not the color of the light.

Kuro takes a different route. The frame is divided into 16×16 = 256 zones, each zone’s chromaticity is computed, and each zone is accepted or rejected on how far it sits from the blackbody locus (Duv). Illuminants land close to the locus and colored objects do not — that single fact separates what an average cannot.

Duv +0.02 Duv −0.02 blackbody locus 2000K 4200K 6500K 20000K correlated color temperature (even in mired) tungsten neutral gray overcast fluorescent grass red wall blue sign
Measured. Illuminants stay within Duv 0.017 at worst and colored objects are at least 0.026 away. There is a gap to put a threshold in — that one line is the whole of the white map.
Zone accept / reject — measured
ZoneCCTDuvVerdict
Neutral gray6500K+0.0031accept
Tungsten4175K+0.0075accept
Overcast9125K+0.0027accept
Fluorescent6375K+0.0165accept
Red wall2200K−0.0257reject
Grass6025K+0.0617reject
Blue sign20000K−0.0415reject

The soundness of the math can be checked in one line. Feed it R=G=B and it must return 6500K / Duv +0.0031. D65 sitting +0.003 to the green side of the locus is a known value, so getting it back is a real check on the implementation.

How well the accepted zones agree with each other becomes the confidence. When confidence is low, the correction is held back. Not deciding when there is nothing to decide from is part of an automatic system’s job.


Moving exposure with a power, not a multiply

The obvious way to brighten a dark shot is gain — multiply everything by a constant. That has a side effect you cannot avoid: multiply 1.0 and you go past 1.0 — highlights clip. The harder you lift, the more of the top end is thrown away.

Kuro treats exposure as a power. Moving gamma in the encoded space is the same as taking a power in linear (((L^(1/2.4))^e)^2.4 = L^e), so 0 stays 0 and 1 stays 1 and only the middle moves. The endpoints cannot break by construction.

Lifting a dark frame with gain pushed several of 21 measured patches past 1.0 and clipped them. Moving the same amount with gamma clipped none of them.

Most of the time, the decision is to leave it alone

The hard part of auto-adjust is not correcting — it is not correcting. Decide that a snow scene is “too bright” and darken it, and you have destroyed what the person shooting it intended. Across 1287 frames of real material, this is how often each mechanism actually fired.

Fire rate — 1287 frames
MechanismPro material, 807Amateur material, 480
Judged backlit2.6%2.7%
Letterbox detected3.8%0%
Flat (graphics)0.4%2.9%
Exposure left essentially alone88.8%96.7%

Letterbox detection is the legible one: 3.8% on professional material, which has letterboxing, and 0% on amateur material, which does not. Every mechanism has a small envelope, and none of them are swinging at nothing.


HDR onto an SDR screen, without making it dark

Open HDR footage in an ordinary player and it usually looks washed out: a signal authored for 1000 nits is being pushed straight into a 100-nit screen.

Kuro switches paths the moment video-params/gamma reports PQ or HLG, and applies a shoulder to luminance only. It does not crush R, G and B separately — it multiplies all three by the same luminance ratio y/Y, so folding the brightness does not move the hue.

The curve is a Reinhard shoulder, y = s·x/(1+s·x) · (1+s)/s with s = 2.62, taking a luminance normalised on the BT.2408 reference white of 203 nits, x = Y · 203/(source peak). The source peak lands exactly on 1.0.

This used to be the ITU-R BT.2390 EETF. It was tried on real material twice and rejected by measurement both times. The EETF puts 203-nit diffuse white at 0.88 of the display, packing most of the picture into the narrow band above it — the path that exists to fix washed-out was producing it. It was deleted on 2026-08-28. A standard’s name does not by itself make a good picture.

Headroom turned out differently from expectation too. maximumPotentialExtendedDynamicRangeColorComponentValue measures 1.00 on both displays here (INNOCN 32M2V and BenQ SW2700), and still 1.00 with a real EDR layer actually on screen. You cannot base a design on room that is not there, so the current curve maps to 1.0 without consulting headroom at all. Actually putting highlights above SDR white on a display that does have headroom is kept as a separate design.


Being in the App Store was itself a design constraint

Much of what the media-playback world is built on is GPL. One GPL component anywhere in a bundle and it cannot ship on the Mac App Store. That is a licensing fact, not a quality one.

Kuro builds its own libmpv and FFmpeg with -Dgpl=false / --disable-gpl. One GPL filter anywhere in the bundle and it cannot be distributed, so this is the constraint paid as an entry ticket. Homebrew’s builds cannot be used.

Scopes normally belong to a dedicated monitoring tool rather than to playback software. Watching a waveform and a vectorscope while the video plays is a shape that only becomes available once that constraint is paid up front.


What it does

Grading is the center of it, but the player underneath is built to the same standard. Below is the actual line between what is free and what Kuro PRO opens.

Free

Opens anything

libmpv is bundled, so MKV, WebM, VP9, AV1 and MPEG-1/2 play as they are, including what AVFoundation will not open.

Free

One-click auto-adjust

Zone metering and locus-based AWB. Re-metering on every scene change is on the free side too.

Free

Scopes and probe

Waveform, vectorscope and histogram drawn in Metal compute. The corrected Rec.709 value of any single point on screen is readable.

Free

Audio

Track switching, A/V sync offset, and a night mode that lifts quiet dialogue while holding loud passages down.

Free

Grade it by hand

Exposure, black level, gamma, contrast, temperature / tint, saturation. The seven master controls are on the free side, measured models and all.

Free

A/B compare

A whole-frame toggle, and a divider you drag across the picture. What the correction did, seen side by side rather than from memory.

Kuro PRO

The full grading stack

Color wheels and per-channel Lift / Gamma / Gain, offset (ASC CDL), 3-Way, the contrast pivot, hue (HSL), curves, tone map.

Kuro PRO

AWB / AE eyedroppers

Point at what should be white, or at what should be correctly exposed, and it matches to that. For the frames automation hesitates on.

Kuro PRO

LUT export

Write the grade to .cube and carry the color straight into another editor.

Kuro PRO

Wiener deblur

Deconvolution of blur. No per-frame FFT — the spatial kernel is rebuilt on the CPU only when sigma or K changes, then convolved.

Kuro PRO

Noise reduction and debanding

Edge-preserving spatial bilateral and motion-following temporal NR. Debanding for the rings in sky gradients.

Kuro PRO

Upscaling

MetalFX, Anime4K, and Apple’s low-latency SR. mpv draws at native size and a Metal pass scales it.

Kuro PRO

3D LUT import

.cube applied with tetrahedral interpolation, with variable strength.