Skip to main content

atmos/os_lib/css/
color_space.rs

1//! CIE Lab/LCH と広色域 RGB の sRGB 変換(純粋モジュール)。
2//!
3//! CSS Color Module Level 4 の以下を担当する。
4//!
5//! - `lab(L a b)` / `lch(L C H)` … CIE Lab(**白色点 D50**)
6//! - `color(display-p3 …)` / `a98-rgb` / `prophoto-rgb` / `rec2020`
7//! - `color(xyz …)` / `xyz-d65` / `xyz-d50`
8//!
9//! いずれもハードウェアにもグローバル状態にも依存しない数値変換だけなので、
10//! QEMU 抜きでホスト側から検証できる。
11//!
12//! # 白色点が 2 種類あることに注意
13//!
14//! CSS の `lab()`/`lch()` と `xyz-d50`/`prophoto-rgb` は **D50**、
15//! sRGB・display-p3・rec2020・a98-rgb・`xyz`/`xyz-d65` は **D65** を使う。
16//! D50 の値をそのまま D65 の行列へ入れると色がずれるので、
17//! 途中で **Bradford 色順応**(`d50_to_d65`)を必ず挟む。
18//! 仕様の参照実装(csswg-drafts の `conversions.js`)と同じ順序にしてある。
19//!
20//! # 色域外の扱い
21//!
22//! display-p3 等は sRGB より広いので、変換結果が 0-1 を外れることがある
23//! (例: 純粋な P3 の緑)。厳密な gamut mapping は行わず素直にクランプする。
24//! これは既存の `oklab()`/`oklch()` 実装と同じ割り切りで、
25//! 「色は出るが最も近い sRGB へ丸められる」挙動になる。
26
27extern crate alloc;
28
29/// sRGB のガンマ符号化(線形 0-1 → 表示値 0-1)。
30///
31/// 負値は色域外なので 0 側へクランプしてから符号化する。
32pub fn linear_to_srgb(c: f32) -> f32 {
33    let c = c.clamp(0.0, 1.0);
34    if c <= 0.003_130_8 {
35        c * 12.92
36    } else {
37        1.055 * libm::powf(c, 1.0 / 2.4) - 0.055
38    }
39}
40
41/// sRGB のガンマ復号(表示値 0-1 → 線形 0-1)。
42pub fn srgb_to_linear(c: f32) -> f32 {
43    let c = c.clamp(0.0, 1.0);
44    if c <= 0.040_45 {
45        c / 12.92
46    } else {
47        libm::powf((c + 0.055) / 1.055, 2.4)
48    }
49}
50
51/// CIE Lab(D50)→ XYZ(D50)。
52///
53/// `L` は 0-100、`a`/`b` はおおむね -125〜125。
54pub fn lab_to_xyz_d50(l: f32, a: f32, b: f32) -> (f32, f32, f32) {
55    // CIE 標準の定数。κ = 24389/27、ε = 216/24389。
56    const KAPPA: f32 = 24389.0 / 27.0;
57    const EPSILON: f32 = 216.0 / 24389.0;
58    // D50 の白色点(CSS 仕様が採用する値)。
59    const WHITE_D50: [f32; 3] = [0.964_2, 1.0, 0.824_9];
60
61    let fy = (l + 16.0) / 116.0;
62    let fx = a / 500.0 + fy;
63    let fz = fy - b / 200.0;
64
65    let cube = |t: f32| t * t * t;
66    let x = if cube(fx) > EPSILON {
67        cube(fx)
68    } else {
69        (116.0 * fx - 16.0) / KAPPA
70    };
71    let y = if l > KAPPA * EPSILON {
72        cube((l + 16.0) / 116.0)
73    } else {
74        l / KAPPA
75    };
76    let z = if cube(fz) > EPSILON {
77        cube(fz)
78    } else {
79        (116.0 * fz - 16.0) / KAPPA
80    };
81
82    (x * WHITE_D50[0], y * WHITE_D50[1], z * WHITE_D50[2])
83}
84
85/// Bradford 色順応で XYZ の白色点を D50 から D65 へ移す。
86///
87/// これを飛ばすと `lab()` の色が全体に黄色寄りにずれる。
88pub fn d50_to_d65(x: f32, y: f32, z: f32) -> (f32, f32, f32) {
89    (
90        0.955_473_4 * x - 0.023_098_1 * y + 0.063_259_3 * z,
91        -0.028_369_7 * x + 1.009_943_9 * y + 0.021_041_0 * z,
92        0.012_314_2 * x - 0.020_483_0 * y + 1.330_889_0 * z,
93    )
94}
95
96/// XYZ(D65)→ 線形 sRGB。
97pub fn xyz_d65_to_linear_srgb(x: f32, y: f32, z: f32) -> (f32, f32, f32) {
98    (
99        3.240_97 * x - 1.537_383 * y - 0.498_610_8 * z,
100        -0.969_243_6 * x + 1.875_967_5 * y + 0.041_555_1 * z,
101        0.055_630_8 * x - 0.203_976_9 * y + 1.056_971_5 * z,
102    )
103}
104
105/// `color()` が受け付ける色空間。
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ColorSpace {
108    /// ガンマ符号化済み sRGB。
109    Srgb,
110    /// 線形 sRGB。
111    SrgbLinear,
112    /// Display P3(sRGB と同じ伝達関数、より広い原色)。
113    DisplayP3,
114    /// Adobe RGB (1998)。伝達関数は単純な γ=563/256。
115    A98Rgb,
116    /// ProPhoto RGB(**D50**)。
117    ProPhotoRgb,
118    /// ITU-R BT.2020。
119    Rec2020,
120    /// CIE XYZ(D65)。`xyz` は `xyz-d65` の別名。
121    XyzD65,
122    /// CIE XYZ(D50)。
123    XyzD50,
124}
125
126/// 色空間名を解決する。未知の名前は `None`。
127pub fn parse_color_space(name: &str) -> Option<ColorSpace> {
128    match name.trim() {
129        "srgb" => Some(ColorSpace::Srgb),
130        "srgb-linear" => Some(ColorSpace::SrgbLinear),
131        "display-p3" => Some(ColorSpace::DisplayP3),
132        "a98-rgb" => Some(ColorSpace::A98Rgb),
133        "prophoto-rgb" => Some(ColorSpace::ProPhotoRgb),
134        "rec2020" => Some(ColorSpace::Rec2020),
135        // `xyz` は仕様上 `xyz-d65` の別名。
136        "xyz" | "xyz-d65" => Some(ColorSpace::XyzD65),
137        "xyz-d50" => Some(ColorSpace::XyzD50),
138        _ => None,
139    }
140}
141
142/// `color(<space> c1 c2 c3)` の 3 成分を **線形 sRGB** へ変換する。
143///
144/// 返り値は 0-1 にクランプしていない(色域外は呼び出し側でクランプする)。
145pub fn components_to_linear_srgb(space: ColorSpace, c1: f32, c2: f32, c3: f32) -> (f32, f32, f32) {
146    match space {
147        ColorSpace::Srgb => (srgb_to_linear(c1), srgb_to_linear(c2), srgb_to_linear(c3)),
148        ColorSpace::SrgbLinear => (c1, c2, c3),
149        ColorSpace::DisplayP3 => {
150            // 伝達関数は sRGB と同一。原色だけが違う。
151            let (r, g, b) = (srgb_to_linear(c1), srgb_to_linear(c2), srgb_to_linear(c3));
152            // P3(linear) → XYZ(D65) → linear sRGB を 1 つに畳んだ行列。
153            (
154                1.224_940_2 * r - 0.224_940_18 * g,
155                -0.042_056_95 * r + 1.042_056_9 * g,
156                -0.019_637_39 * r - 0.078_636_04 * g + 1.098_273_4 * b,
157            )
158        }
159        ColorSpace::A98Rgb => {
160            // Adobe RGB の伝達関数は γ=563/256(≒2.199)の単純べき乗。
161            let dec = |v: f32| -> f32 {
162                let s = if v < 0.0 { -1.0 } else { 1.0 };
163                s * libm::powf(libm::fabsf(v), 563.0 / 256.0)
164            };
165            let (r, g, b) = (dec(c1), dec(c2), dec(c3));
166            (
167                1.398_240_9 * r - 0.398_240_9 * g,
168                0.999_999_9 * g,
169                -0.043_306_2 * g + 1.043_306_2 * b,
170            )
171        }
172        ColorSpace::ProPhotoRgb => {
173            // ProPhoto は D50 基準。伝達関数は γ=1.8、線形部の閾値は 16/512。
174            let dec = |v: f32| -> f32 {
175                let s = if v < 0.0 { -1.0 } else { 1.0 };
176                let a = libm::fabsf(v);
177                if a <= 16.0 / 512.0 {
178                    v / 16.0
179                } else {
180                    s * libm::powf(a, 1.8)
181                }
182            };
183            let (r, g, b) = (dec(c1), dec(c2), dec(c3));
184            // ProPhoto(linear) → XYZ(D50)
185            let x = 0.797_675_3 * r + 0.135_192_2 * g + 0.031_353_6 * b;
186            let y = 0.288_040_4 * r + 0.711_874_2 * g + 0.000_085_4 * b;
187            let z = 0.825_210_5 * b;
188            // D50 → D65 → linear sRGB
189            let (x, y, z) = d50_to_d65(x, y, z);
190            xyz_d65_to_linear_srgb(x, y, z)
191        }
192        ColorSpace::Rec2020 => {
193            // BT.2020 の伝達関数(α, β は仕様の定数)。
194            const A: f32 = 1.098_729_0;
195            const B: f32 = 0.018_053_97;
196            let dec = |v: f32| -> f32 {
197                let s = if v < 0.0 { -1.0 } else { 1.0 };
198                let a = libm::fabsf(v);
199                if a < B * 4.5 {
200                    v / 4.5
201                } else {
202                    s * libm::powf((a + A - 1.0) / A, 1.0 / 0.45)
203                }
204            };
205            let (r, g, b) = (dec(c1), dec(c2), dec(c3));
206            (
207                1.660_496_1 * r - 0.587_656_1 * g - 0.072_840_0 * b,
208                -0.124_547_4 * r + 1.132_895_9 * g - 0.008_348_5 * b,
209                -0.018_154_1 * r - 0.100_597_0 * g + 1.118_751_1 * b,
210            )
211        }
212        ColorSpace::XyzD65 => xyz_d65_to_linear_srgb(c1, c2, c3),
213        ColorSpace::XyzD50 => {
214            let (x, y, z) = d50_to_d65(c1, c2, c3);
215            xyz_d65_to_linear_srgb(x, y, z)
216        }
217    }
218}
219
220/// `lab(L a b)` を **線形 sRGB** へ変換する。
221pub fn lab_to_linear_srgb(l: f32, a: f32, b: f32) -> (f32, f32, f32) {
222    let (x, y, z) = lab_to_xyz_d50(l, a, b);
223    let (x, y, z) = d50_to_d65(x, y, z);
224    xyz_d65_to_linear_srgb(x, y, z)
225}
226
227/// `lch(L C H)` の極座標を `lab()` の直交座標へ直す。
228///
229/// `h` は度。負や 360 以上でも `libm` の三角関数がそのまま扱えるが、
230/// 実装の意図を明示するため呼び出し側で正規化してから渡すのが望ましい。
231pub fn lch_to_lab(l: f32, c: f32, h_deg: f32) -> (f32, f32, f32) {
232    let rad = h_deg * core::f32::consts::PI / 180.0;
233    (l, c * libm::cosf(rad), c * libm::sinf(rad))
234}
235
236/// 線形 sRGB の 3 成分を 0-255 の ARGB 各チャンネルへ符号化する。
237///
238/// 丸めは四捨五入。単純な切り捨てだと行列係数の丸め誤差で
239/// 255 になるべき白が 254 へ落ちる(`oklab()` 実装で実際に起きた)。
240pub fn linear_srgb_to_rgb8(r: f32, g: f32, b: f32) -> (u32, u32, u32) {
241    let enc = |c: f32| -> u32 {
242        libm::roundf((linear_to_srgb(c) * 255.0).clamp(0.0, 255.0)) as u32
243    };
244    (enc(r), enc(g), enc(b))
245}