Skip to main content

mcraw_tui/
agx.rs

1// AgX tone mapping pipeline, spectral gamut mapping, and log/linear transfer functions.
2// References:
3//   - AgX: Troy Sobotka / AgX project (MIT license)
4//   - Kraken: Jed Smith / Troy Sobotka
5//   - Apple Log: Apple Log Profile White Paper, September 2023
6//   - Various camera log curves: colour-science/colour (BSD-3-Clause),
7//     https://github.com/colour-science/colour
8//   - CAT16 chromatic adaptation: CIE TC 1-90 (2016)
9
10use rayon::prelude::*;
11use std::f32::consts::PI;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Gamut {
15    Ap0, Ap1, P3D65, P3D60, Rec2020, Rec709,
16    Awg3, Awg4, Rwg, SGamut3, SGamut3Cine,
17    BlackmagicWg, CanonCinema, DaVinciWg, EGamut,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Transfer {
22    Linear,
23    AcesCct,
24    ArriLogC3,
25    ArriLogC4,
26    RedLog3G10,
27    SonySLog3,
28    SonySLog2,
29    BmFilmGen5,
30    CanonLog3,
31    CanonLog2,
32    DaVinciIntermediate,
33    FilmlightTLog,
34    AgxLogKraken,
35    VLog,
36    FLog2,
37    FLog2C,
38    AppleLog2,
39    HLG,
40    PQ,
41    DNG,
42    DI,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum OutputTransfer {
47    Linear,
48    SrgbInverseEotf,
49    Bt1886InverseEotf,
50}
51
52#[derive(Debug, Clone)]
53pub struct AgxConfig {
54    pub inset_red: f32,
55    pub inset_green: f32,
56    pub inset_blue: f32,
57    pub rotate_red: f32,
58    pub rotate_green: f32,
59    pub rotate_blue: f32,
60    pub outset_red: f32,
61    pub outset_green: f32,
62    pub outset_blue: f32,
63    pub toe_power: f32,
64    pub shoulder_power: f32,
65    pub slope: f32,
66    pub in_gamut: Gamut,
67    pub in_transfer: Transfer,
68    pub working_curve: Transfer,
69    pub working_mid_grey: f32,
70    pub out_gamut: Gamut,
71    pub out_transfer: OutputTransfer,
72    pub log_output: bool,
73}
74
75impl Default for AgxConfig {
76    fn default() -> Self {
77        Self {
78            inset_red: 0.2,
79            inset_green: 0.2,
80            inset_blue: 0.2,
81            rotate_red: 0.0,
82            rotate_green: 0.0,
83            rotate_blue: 0.0,
84            outset_red: 0.0,
85            outset_green: 0.0,
86            outset_blue: 0.0,
87            toe_power: 3.0,
88            shoulder_power: 3.25,
89            slope: 2.0,
90            in_gamut: Gamut::Rec709,
91            in_transfer: Transfer::Linear,
92            working_curve: Transfer::AgxLogKraken,
93            working_mid_grey: 0.606060,
94            out_gamut: Gamut::Rec709,
95            out_transfer: OutputTransfer::SrgbInverseEotf,
96            log_output: false,
97        }
98    }
99}
100
101pub struct AgxPipeline {
102    config: AgxConfig,
103    inset_mat: [f32; 9],
104    outset_mat: [f32; 9],
105    gamut_mat: [f32; 9],
106    out_gamma: f32,
107    log_floor: [f32; 3],
108    mid_grey_lin: f32,
109}
110
111impl AgxPipeline {
112    pub fn new(config: AgxConfig) -> Self {
113        let in_chrom = gamut_chromaticities(config.in_gamut);
114        let out_chrom = gamut_chromaticities(config.out_gamut);
115
116        let inset_chrom = inset_primaries(
117            in_chrom,
118            config.inset_red,
119            config.inset_green,
120            config.inset_blue,
121            config.rotate_red,
122            config.rotate_green,
123            config.rotate_blue,
124        );
125        let outset_chrom = inset_primaries(
126            in_chrom,
127            config.outset_red,
128            config.outset_green,
129            config.outset_blue,
130            config.rotate_red,
131            config.rotate_green,
132            config.rotate_blue,
133        );
134
135        let inset_mat = rgb_to_rgb(inset_chrom, in_chrom);
136        let outset_mat = rgb_to_rgb(in_chrom, outset_chrom);
137        let gamut_mat = rgb_to_rgb(in_chrom, out_chrom);
138
139        let out_gamma = match config.out_transfer {
140            OutputTransfer::Linear => 1.0,
141            OutputTransfer::SrgbInverseEotf => 2.2,
142            OutputTransfer::Bt1886InverseEotf => 2.4,
143        };
144
145        let log_floor = log_to_lin([0.0, 0.0, 0.0], config.in_transfer);
146
147        let mid_grey_lin = log_to_lin(
148            [config.working_mid_grey, config.working_mid_grey, config.working_mid_grey],
149            config.working_curve,
150        )[0];
151
152        Self {
153            config,
154            inset_mat,
155            outset_mat,
156            gamut_mat,
157            out_gamma,
158            log_floor,
159            mid_grey_lin,
160        }
161    }
162
163    #[inline]
164    pub fn process_pixel(&self, r: f32, g: f32, b: f32) -> [f32; 3] {
165        let mut rgb = [r, g, b];
166
167        rgb = log_to_lin(rgb, self.config.in_transfer);
168
169        rgb[0] = rgb[0].max(self.log_floor[0]);
170        rgb[1] = rgb[1].max(self.log_floor[1]);
171        rgb[2] = rgb[2].max(self.log_floor[2]);
172
173        rgb = mat_mul_vec3(&self.inset_mat, rgb);
174
175        rgb = lin_to_log(rgb, self.config.working_curve);
176        let log_rgb = rgb;
177
178        let mg = 0.5;
179        let lmg = self.config.working_mid_grey;
180        rgb[0] = tone_scale(rgb[0], self.config.shoulder_power, self.config.toe_power, self.config.slope, lmg, mg, 1.0, 0.0);
181        rgb[1] = tone_scale(rgb[1], self.config.shoulder_power, self.config.toe_power, self.config.slope, lmg, mg, 1.0, 0.0);
182        rgb[2] = tone_scale(rgb[2], self.config.shoulder_power, self.config.toe_power, self.config.slope, lmg, mg, 1.0, 0.0);
183
184        if self.config.log_output {
185            return log_rgb;
186        }
187
188        rgb[0] = rgb[0].powf(2.2);
189        rgb[1] = rgb[1].powf(2.2);
190        rgb[2] = rgb[2].powf(2.2);
191
192        let lum = (rgb[0] + rgb[1] + rgb[2]) * (1.0 / 3.0);
193        let max_ch = rgb[0].max(rgb[1]).max(rgb[2]);
194        if max_ch > 0.85 {
195            let t = ((max_ch - 0.85) / 0.15).min(1.0);
196            rgb[0] = rgb[0] + (lum - rgb[0]) * t;
197            rgb[1] = rgb[1] + (lum - rgb[1]) * t;
198            rgb[2] = rgb[2] + (lum - rgb[2]) * t;
199        }
200
201        rgb = mat_mul_vec3(&self.outset_mat, rgb);
202
203        rgb = mat_mul_vec3(&self.gamut_mat, rgb);
204
205        rgb = inverse_eotf(rgb, self.out_gamma);
206
207        rgb[0] = rgb[0].max(0.0);
208        rgb[1] = rgb[1].max(0.0);
209        rgb[2] = rgb[2].max(0.0);
210
211        rgb
212    }
213
214    pub fn process_frame(&self, pixels: &mut [f32]) {
215        pixels.par_chunks_exact_mut(3).for_each(|chunk| {
216            let out = self.process_pixel(chunk[0], chunk[1], chunk[2]);
217            chunk[0] = out[0];
218            chunk[1] = out[1];
219            chunk[2] = out[2];
220        });
221    }
222
223    pub fn config(&self) -> &AgxConfig {
224        &self.config
225    }
226}
227
228#[inline]
229fn mat_mul_vec3(m: &[f32; 9], v: [f32; 3]) -> [f32; 3] {
230    [
231        v[0] * m[0] + v[1] * m[3] + v[2] * m[6],
232        v[0] * m[1] + v[1] * m[4] + v[2] * m[7],
233        v[0] * m[2] + v[1] * m[5] + v[2] * m[8],
234    ]
235}
236
237#[derive(Clone, Copy)]
238struct Chromaticities {
239    r: [f32; 2],
240    g: [f32; 2],
241    b: [f32; 2],
242    w: [f32; 2],
243}
244
245fn gamut_chromaticities(g: Gamut) -> Chromaticities {
246    const D65: [f32; 2] = [0.3127, 0.3290];
247    match g {
248        Gamut::Ap0 => Chromaticities { r: [0.7347, 0.2653], g: [0.0000, 1.0000], b: [0.0001, -0.0770], w: D65 },
249        Gamut::Ap1 => Chromaticities { r: [0.7130, 0.2930], g: [0.1650, 0.8300], b: [0.1280, 0.0440], w: D65 },
250        Gamut::Rec709 => Chromaticities { r: [0.6400, 0.3300], g: [0.3000, 0.6000], b: [0.1500, 0.0600], w: D65 },
251        Gamut::Rec2020 => Chromaticities { r: [0.7080, 0.2920], g: [0.1700, 0.7970], b: [0.1310, 0.0460], w: D65 },
252        Gamut::P3D65 => Chromaticities { r: [0.6800, 0.3200], g: [0.2650, 0.6900], b: [0.1500, 0.0600], w: D65 },
253        Gamut::P3D60 => Chromaticities { r: [0.6800, 0.3200], g: [0.2650, 0.6900], b: [0.1500, 0.0600], w: D65 },
254        Gamut::SGamut3Cine => Chromaticities { r: [0.7660, 0.2750], g: [0.2250, 0.8000], b: [0.0890, -0.0870], w: D65 },
255        Gamut::Awg3 => Chromaticities { r: [0.6840, 0.3130], g: [0.2210, 0.8480], b: [0.0861, -0.1020], w: D65 },
256        Gamut::Awg4 => Chromaticities { r: [0.6800, 0.3150], g: [0.2200, 0.8500], b: [0.0860, -0.1000], w: D65 },
257        Gamut::Rwg => Chromaticities { r: [0.7300, 0.2800], g: [0.1400, 0.8550], b: [0.1000, -0.0900], w: D65 },
258        Gamut::SGamut3 => Chromaticities { r: [0.7500, 0.2700], g: [0.2100, 0.8000], b: [0.1000, -0.0500], w: D65 },
259        Gamut::BlackmagicWg => Chromaticities { r: [0.7500, 0.2700], g: [0.2100, 0.8000], b: [0.1000, -0.0500], w: D65 },
260        Gamut::CanonCinema => Chromaticities { r: [0.7400, 0.2700], g: [0.1700, 0.7900], b: [0.0800, -0.1000], w: D65 },
261        Gamut::DaVinciWg => Chromaticities { r: [0.7350, 0.2650], g: [0.2150, 0.8100], b: [0.1200, -0.0500], w: D65 },
262        Gamut::EGamut => Chromaticities { r: [0.7300, 0.2800], g: [0.1700, 0.8000], b: [0.1000, -0.0600], w: D65 },
263    }
264}
265
266fn rgb_to_rgb(src: Chromaticities, dst: Chromaticities) -> [f32; 9] {
267    let src_to_xyz = rgb_to_xyz(src);
268    let xyz_to_dst = xyz_to_rgb(dst);
269    mat_mul_3x3(&xyz_to_dst, &src_to_xyz)
270}
271
272fn rgb_to_xyz(c: Chromaticities) -> [f32; 9] {
273    let [rx, ry] = c.r;
274    let [gx, gy] = c.g;
275    let [bx, by] = c.b;
276    let [wx, wy] = c.w;
277
278    let rz = 1.0 - rx - ry;
279    let gz = 1.0 - gx - gy;
280    let bz = 1.0 - bx - by;
281    let wz = 1.0 - wx - wy;
282
283    let m = [
284        rx, gx, bx,
285        ry, gy, by,
286        rz, gz, bz,
287    ];
288    let inv = invert_3x3(&m);
289    let s = mat_mul_vec3(&inv, [wx / wy, 1.0, wz / wy]);
290
291    [
292        inv[0] * s[0], inv[1] * s[1], inv[2] * s[2],
293        inv[3] * s[0], inv[4] * s[1], inv[5] * s[2],
294        inv[6] * s[0], inv[7] * s[1], inv[8] * s[2],
295    ]
296}
297
298fn xyz_to_rgb(c: Chromaticities) -> [f32; 9] {
299    invert_3x3(&rgb_to_xyz(c))
300}
301
302fn mat_mul_3x3(a: &[f32; 9], b: &[f32; 9]) -> [f32; 9] {
303    let mut out = [0.0; 9];
304    for i in 0..3 {
305        for j in 0..3 {
306            out[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
307        }
308    }
309    out
310}
311
312fn invert_3x3(m: &[f32; 9]) -> [f32; 9] {
313    let det = m[0] * (m[4] * m[8] - m[5] * m[7])
314            - m[1] * (m[3] * m[8] - m[5] * m[6])
315            + m[2] * (m[3] * m[7] - m[4] * m[6]);
316    let inv_det = 1.0 / det;
317    [
318        (m[4] * m[8] - m[5] * m[7]) * inv_det,
319        (m[2] * m[7] - m[1] * m[8]) * inv_det,
320        (m[1] * m[5] - m[2] * m[4]) * inv_det,
321        (m[5] * m[6] - m[3] * m[8]) * inv_det,
322        (m[0] * m[8] - m[2] * m[6]) * inv_det,
323        (m[2] * m[3] - m[0] * m[5]) * inv_det,
324        (m[3] * m[7] - m[4] * m[6]) * inv_det,
325        (m[1] * m[6] - m[0] * m[7]) * inv_det,
326        (m[0] * m[4] - m[1] * m[3]) * inv_det,
327    ]
328}
329
330fn inset_primaries(
331    c: Chromaticities,
332    att_r: f32,
333    att_g: f32,
334    att_b: f32,
335    rot_r: f32,
336    rot_g: f32,
337    rot_b: f32,
338) -> Chromaticities {
339    fn shift(xy: [f32; 2], w: [f32; 2], attenuation: f32, rotation_deg: f32) -> [f32; 2] {
340        let dx = xy[0] - w[0];
341        let dy = xy[1] - w[1];
342        let dist = (dx * dx + dy * dy).sqrt();
343        if dist < 1e-6 {
344            return xy;
345        }
346        let scale = 1.0 - attenuation;
347        let angle = rotation_deg * PI / 180.0;
348        let cos_a = angle.cos();
349        let sin_a = angle.sin();
350        let rx = (dx * cos_a - dy * sin_a) * scale;
351        let ry = (dx * sin_a + dy * cos_a) * scale;
352        [w[0] + rx, w[1] + ry]
353    }
354    Chromaticities {
355        r: shift(c.r, c.w, att_r, rot_r),
356        g: shift(c.g, c.w, att_g, rot_g),
357        b: shift(c.b, c.w, att_b, rot_b),
358        w: c.w,
359    }
360}
361
362#[inline]
363fn log_to_lin(v: [f32; 3], tf: Transfer) -> [f32; 3] {
364    let f = |x: f32| -> f32 {
365        match tf {
366            Transfer::Linear => x,
367            Transfer::AcesCct => {
368                if x <= 0.155251141552511 {
369                    (x - 0.077415122655251) / 10.5402377416545
370                } else {
371                    2.0f32.powf((x - 0.413588402493492) * 17.52)
372                }
373            }
374            Transfer::SonySLog3 => {
375                if x < 0.01125 {
376                    (x - 0.030001222851889) / 0.01125
377                } else {
378                    10.0f32.powf((x - 0.42) / 0.24) * 0.18
379                }
380            }
381            Transfer::SonySLog2 => {
382                if x < 0.01018 {
383                    (x - 0.0245786) / 0.0183089
384                } else {
385                    10.0f32.powf((x - 0.384410) / 0.235443) * 0.18
386                }
387            }
388            Transfer::ArriLogC3 => {
389                if x <= 0.010591 {
390                    (x - 0.005228) / 0.047491
391                } else {
392                    0.18 * 2.0f32.powf((x - 0.385537) / 0.247190)
393                }
394            }
395            // ARRI LogC4 decoding (E' → E_scene). Source: ARRI
396            // "LogC4 Logarithmic Color Space SPECIFICATION" (2022, Cooper &
397            // Brendel). EI-independent; two-segment with a linear-to-log
398            // threshold at the decoding boundary E' = 0.
399            Transfer::ArriLogC4 => {
400                let a: f32 = ((1u32 << 18) as f32 - 16.0) / 117.45;
401                let b: f32 = (1023.0 - 95.0) / 1023.0;
402                let c: f32 = 95.0 / 1023.0;
403                let s: f32 = (7.0 * std::f32::consts::LN_2 * (7.0 - 14.0 * c / b).exp2()) / (a * b);
404                let t: f32 = ((14.0 * (-c / b) + 6.0).exp2() - 64.0) / a;
405                if x >= 0.0 {
406                    ((14.0 * ((x - c) / b) + 6.0).exp2() - 64.0) / a
407                } else {
408                    x * s + t
409                }
410            }
411            Transfer::RedLog3G10 => {
412                if x < 0.0 {
413                    0.0
414                } else {
415                    (10.0f32.powf(x * 17.52 - 14.0) - 0.001) / 9.999
416                }
417            }
418            Transfer::VLog => {
419                if x <= 0.010592 {
420                    (x - 0.005255) / 0.047120
421                } else {
422                    0.18 * 2.0f32.powf((x - 0.389583) / 0.244949)
423                }
424            }
425            Transfer::FLog2 => {
426                if x < 0.0 {
427                    0.0
428                } else {
429                    let a = 0.86445045;
430                    let b = 0.5779536;
431                    let c = 0.13967949;
432                    let d = 0.4174028;
433                    ((x - b) / a).powf(1.0 / c) - d
434                }
435            }
436            Transfer::FLog2C => {
437                if x < 0.0 {
438                    0.0
439                } else {
440                    let a = 0.86445045;
441                    let b = 0.5779536;
442                    let c = 0.13967949;
443                    let d = 0.4174028;
444                    ((x - b) / a).powf(1.0 / c) - d
445                }
446            }
447            Transfer::AppleLog2 => {
448                if x < 0.0 {
449                    0.0
450                } else {
451                    1.0 / (1.0 + (-4.0 * x).exp())
452                }
453            }
454            Transfer::BmFilmGen5 => {
455                if x < 0.125 {
456                    x * 4.0
457                } else {
458                    1.0 + (x - 0.5).ln() / 0.693147
459                }
460            }
461            Transfer::CanonLog3 => {
462                if x < 0.0149 {
463                    (x - 0.0721) / 3.5766
464                } else {
465                    0.18 * 10.0f32.powf((x - 0.406539) / 0.301940)
466                }
467            }
468            Transfer::CanonLog2 => {
469                if x < 0.0205 {
470                    (x - 0.078516) / 2.4397
471                } else {
472                    0.18 * 10.0f32.powf((x - 0.419398) / 0.283691)
473                }
474            }
475            Transfer::DaVinciIntermediate => {
476                if x < 0.0 {
477                    0.0
478                } else if x < 0.5 {
479                    x * x * 4.0
480                } else {
481                    1.0 + (x - 0.5).ln() / 0.693147
482                }
483            }
484            Transfer::FilmlightTLog => {
485                if x <= 0.0 {
486                    0.0
487                } else {
488                    let a = 1.0 / (10.0_f32.powf(0.002) - 1.0);
489                    (a + 1.0).powf(x - 1.0) - a
490                }
491            }
492            Transfer::AgxLogKraken => {
493                0.18 * 2.0f32.powf(x * 16.5 - 10.0)
494            }
495            Transfer::HLG => {
496                if x <= 0.5 {
497                    x * x * 4.0
498                } else {
499                    let beta = 1.09929682680944 - 1.0;
500                    let gamma = 0.5;
501                    ((x + beta - 1.0) / beta).powf(1.0 / gamma)
502                }
503            }
504            Transfer::PQ => {
505                if x < 0.0 {
506                    0.0
507                } else {
508                    let n = x.cbrt();
509                    let m = (10000.0_f32.powf(1.0 / 2.4) - 1.0) / 10000.0_f32.powf(1.0 / 2.4);
510                    ((n - m) / (1.0 - m)).powf(2.4)
511                }
512            }
513            Transfer::DNG => x,
514            Transfer::DI => x,
515        }
516    };
517    [f(v[0]), f(v[1]), f(v[2])]
518}
519
520#[inline]
521fn lin_to_log(v: [f32; 3], tf: Transfer) -> [f32; 3] {
522    let f = |x: f32| -> f32 {
523        match tf {
524            Transfer::Linear => x,
525            Transfer::AcesCct => {
526                if x <= 0.0078125 {
527                    x * 10.5402377416545 + 0.077415122655251
528                } else {
529                    (x / 2.0).log2() / 17.52 + 0.413588402493492
530                }
531            }
532            Transfer::SonySLog3 => {
533                if x < 0.0 {
534                    0.030001222851889
535                } else {
536                    0.24 * (x / 0.18).log10() + 0.42
537                }
538            }
539            Transfer::SonySLog2 => {
540                if x < 0.0 {
541                    0.0245786
542                } else {
543                    0.235443 * (x / 0.18).log10() + 0.384410
544                }
545            }
546            Transfer::ArriLogC3 => {
547                if x <= 0.005228 {
548                    x * 0.047491 + 0.005228
549                } else {
550                    0.247190 * (x / 0.18).log2() + 0.385537
551                }
552            }
553            // ARRI LogC4 encoding (E_scene → E'). Source: ARRI "LogC4
554            // Encoding Function" (2022, Cooper & Brendel). Two-segment
555            // with threshold at x = t ≈ -0.0180967.
556            Transfer::ArriLogC4 => {
557                let a: f32 = ((1u32 << 18) as f32 - 16.0) / 117.45;
558                let b: f32 = (1023.0 - 95.0) / 1023.0;
559                let c: f32 = 95.0 / 1023.0;
560                let s: f32 = (7.0 * std::f32::consts::LN_2 * (7.0 - 14.0 * c / b).exp2()) / (a * b);
561                let t: f32 = ((14.0 * (-c / b) + 6.0).exp2() - 64.0) / a;
562                if x >= t {
563                    ((a * x + 64.0_f32).log2() - 6.0) / 14.0 * b + c
564                } else {
565                    (x - t) / s
566                }
567            }
568            Transfer::RedLog3G10 => {
569                if x < 0.0 {
570                    0.0
571                } else {
572                    ((x * 9.999 + 0.001).log10() / 17.52) + 14.0
573                }
574            }
575            Transfer::VLog => {
576                if x <= 0.005255 {
577                    x * 0.047120 + 0.005255
578                } else {
579                    0.244949 * (x / 0.18).log2() + 0.389583
580                }
581            }
582            Transfer::FLog2 => {
583                if x < 0.0 {
584                    0.0
585                } else {
586                    let a = 0.86445045;
587                    let b = 0.5779536;
588                    let c = 0.13967949;
589                    let d = 0.4174028;
590                    a * (x + d).powf(c) + b
591                }
592            }
593            Transfer::FLog2C => {
594                if x < 0.0 {
595                    0.0
596                } else {
597                    let a = 0.86445045;
598                    let b = 0.5779536;
599                    let c = 0.13967949;
600                    let d = 0.4174028;
601                    a * (x + d).powf(c) + b
602                }
603            }
604            Transfer::AppleLog2 => {
605                (-(1.0 / x - 1.0).ln()) / 4.0
606            }
607            Transfer::BmFilmGen5 => {
608                if x < 0.5 {
609                    x / 4.0
610                } else {
611                    0.5 + 0.693147 * (x - 1.0).exp()
612                }
613            }
614            Transfer::CanonLog3 => {
615                if x < 0.00390625 {
616                    x * 3.5766 + 0.0721
617                } else {
618                    0.301940 * (x / 0.18).log10() + 0.406539
619                }
620            }
621            Transfer::CanonLog2 => {
622                if x < 0.00390625 {
623                    x * 2.4397 + 0.078516
624                } else {
625                    0.283691 * (x / 0.18).log10() + 0.419398
626                }
627            }
628            Transfer::DaVinciIntermediate => {
629                if x < 0.0 {
630                    0.0
631                } else if x < 0.25 {
632                    x.sqrt() / 2.0
633                } else {
634                    0.5 + 0.693147 * (x - 1.0).exp()
635                }
636            }
637            Transfer::FilmlightTLog => {
638                if x <= 0.0 {
639                    0.0
640                } else {
641                    let a = 1.0 / (10.0_f32.powf(0.002) - 1.0);
642                    1.0 + (x / a + 1.0).ln() / (a + 1.0).ln()
643                }
644            }
645            Transfer::AgxLogKraken => {
646                if x <= 0.0 {
647                    0.0
648                } else {
649                    let ev = (x / 0.18).log2().clamp(-10.0, 6.5);
650                    (ev + 10.0) / 16.5
651                }
652            }
653            Transfer::HLG => {
654                if x < 0.0 {
655                    0.0
656                } else if x <= 0.5 {
657                    x.sqrt() / 2.0
658                } else {
659                    let beta = 1.09929682680944 - 1.0;
660                    let gamma = 0.5;
661                    beta * (x).powf(gamma) + 1.0 - beta
662                }
663            }
664            Transfer::PQ => {
665                if x < 0.0 {
666                    0.0
667                } else {
668                    let m = (10000.0_f32.powf(1.0 / 2.4) - 1.0) / 10000.0_f32.powf(1.0 / 2.4);
669                    1.0 - (x.max(0.0).cbrt() * (1.0 - m) - m).abs()
670                }
671            }
672            Transfer::DNG => x,
673            Transfer::DI => x,
674        }
675    };
676    [f(v[0]), f(v[1]), f(v[2])]
677}
678
679#[inline]
680fn inverse_eotf(v: [f32; 3], gamma: f32) -> [f32; 3] {
681    if gamma == 1.0 {
682        return v;
683    }
684    let g = 1.0 / gamma;
685    [v[0].powf(g), v[1].powf(g), v[2].powf(g)]
686}
687
688#[inline]
689fn spowf(a: f32, b: f32) -> f32 {
690    let s = if a > 0.0 { 1.0 } else if a < 0.0 { -1.0 } else { 0.0 };
691    s * a.abs().powf(b)
692}
693
694#[inline]
695fn tone_scale(x: f32, shoulder: f32, toe: f32, slope: f32, lmg: f32, mg: f32, s0: f32, t0: f32) -> f32 {
696    let ss = spowf(
697        (spowf(slope * (s0 - lmg) / (1.0 - mg), shoulder) - 1.0) * spowf(slope * (s0 - lmg), -shoulder),
698        -1.0 / shoulder,
699    );
700    let ms = slope * (x - lmg) / ss;
701    let fs = ms / spowf(1.0 + spowf(ms, shoulder), 1.0 / shoulder);
702
703    let ts = spowf(
704        (spowf(slope * (lmg - t0) / mg, toe) - 1.0) * spowf(slope * (lmg - t0), -toe),
705        -1.0 / toe,
706    );
707    let mr = slope * (x - lmg) / (-ts);
708    let ft = mr / spowf(1.0 + spowf(mr, toe), 1.0 / toe);
709
710    if x >= lmg { ss * fs + mg } else { -ts * ft + mg }
711}
712
713impl From<crate::color::TransferFunction> for Transfer {
714    fn from(tf: crate::color::TransferFunction) -> Self {
715        match tf {
716            crate::color::TransferFunction::Linear => Transfer::Linear,
717            crate::color::TransferFunction::Rec709 => Transfer::Linear,
718            crate::color::TransferFunction::SLog3 => Transfer::SonySLog3,
719            crate::color::TransferFunction::VLog => Transfer::VLog,
720            crate::color::TransferFunction::ARRIlog3 => Transfer::ArriLogC3,
721            crate::color::TransferFunction::ARRIlog4 => Transfer::ArriLogC4,
722            crate::color::TransferFunction::CLog3 => Transfer::CanonLog3,
723            crate::color::TransferFunction::FLog2 => Transfer::FLog2,
724            crate::color::TransferFunction::AppleLog | crate::color::TransferFunction::AppleLog2 => Transfer::Linear,
725            crate::color::TransferFunction::ACESCCT => Transfer::AcesCct,
726            crate::color::TransferFunction::HLG => Transfer::HLG,
727            crate::color::TransferFunction::PQ => Transfer::PQ,
728            crate::color::TransferFunction::DaVinciIntermediate => Transfer::DaVinciIntermediate,
729            crate::color::TransferFunction::Gamma24 => Transfer::Linear,
730        }
731    }
732}
733
734impl From<crate::color::ColorSpace> for Gamut {
735    fn from(cs: crate::color::ColorSpace) -> Self {
736        match cs {
737            crate::color::ColorSpace::Rec709 => Gamut::Rec709,
738            crate::color::ColorSpace::Rec2020 => Gamut::Rec2020,
739            crate::color::ColorSpace::DciP3 => Gamut::P3D65,
740            crate::color::ColorSpace::Srgb => Gamut::Rec709,
741            crate::color::ColorSpace::SGamut3Cine => Gamut::SGamut3Cine,
742            crate::color::ColorSpace::SGamut3 => Gamut::SGamut3,
743            crate::color::ColorSpace::ARRIWideGamut3 => Gamut::Awg3,
744            crate::color::ColorSpace::ARRIWideGamut4 => Gamut::Awg4,
745            crate::color::ColorSpace::CanonCinemaGamut => Gamut::CanonCinema,
746            crate::color::ColorSpace::PanasonicVGamut => Gamut::Rwg,
747            crate::color::ColorSpace::ACESAP1 => Gamut::Ap1,
748            crate::color::ColorSpace::FGamut => Gamut::Rwg,
749            crate::color::ColorSpace::FGamutC => Gamut::Ap0,
750            crate::color::ColorSpace::DaVinciWideGamut => Gamut::DaVinciWg,
751            crate::color::ColorSpace::DisplayP3 => Gamut::P3D65,
752        }
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn test_agx_pipeline_creation() {
762        let cfg = AgxConfig::default();
763        let pipe = AgxPipeline::new(cfg);
764        assert!(pipe.inset_mat.iter().any(|&v| v != 0.0));
765    }
766
767    #[test]
768    fn test_mid_grey_pivot() {
769        let cfg = AgxConfig::default();
770        let pipe = AgxPipeline::new(cfg);
771        let out = pipe.process_pixel(0.18, 0.18, 0.18);
772        assert!((out[0] - 0.5).abs() < 0.01, "mid grey: expected ~0.5, got {}", out[0]);
773    }
774
775    #[test]
776    fn test_black_output() {
777        let cfg = AgxConfig::default();
778        let pipe = AgxPipeline::new(cfg);
779        let out = pipe.process_pixel(0.0, 0.0, 0.0);
780        assert!(out[0] < 0.001, "black: expected near 0, got {}", out[0]);
781    }
782
783    #[test]
784    fn test_white_clip() {
785        let cfg = AgxConfig::default();
786        let pipe = AgxPipeline::new(cfg);
787        let out = pipe.process_pixel(10.0, 10.0, 10.0);
788        assert!(out[0] < 1.0 && out[0] > 0.9, "white: expected near 1.0, got {}", out[0]);
789    }
790
791    #[test]
792    fn test_gamut_conversion_rec2020_to_rec709() {
793        let mut cfg = AgxConfig::default();
794        cfg.in_gamut = Gamut::Rec2020;
795        cfg.out_gamut = Gamut::Rec709;
796        
797        let pipe = AgxPipeline::new(cfg);
798        let out = pipe.process_pixel(0.5, 0.5, 0.5);
799        
800        assert!(out[0] >= 0.0 && out[0] <= 1.0);
801        assert!(out[1] >= 0.0 && out[1] <= 1.0);
802        assert!(out[2] >= 0.0 && out[2] <= 1.0);
803    }
804}