Skip to main content

csp_format/
correction.rs

1//! Correction layers (色調補正レイヤー): `Layer.FilterLayerInfo` holds
2//! `u32 type, u32 size` and the settings; CSP stores no pixels and applies
3//! the correction to the image below the layer when it draws.
4//!
5//! Big endian. Types and payloads:
6//!
7//! ```text
8//! 1 brightness/contrast   i32 brightness, contrast (-100..100)
9//! 2 levels                32 slots × u16 [input black, gamma point, input
10//!                         white, output black, output white] (0..0xffff);
11//!                         slot 0 is the master, 1..3 red, green, blue
12//! 3 tone curve            32 slots × (u16 count, 32 × (u16 x, u16 y))
13//! 4 hue/saturation/value  i32 hue (degrees), saturation, value (-100..100)
14//! 5 colour balance        i32 preserve luminosity, then shadows, midtones
15//!                         and highlights as i32 R, G, B (-100..100)
16//! 6 invert                (nothing)
17//! 7 posterize             i32 levels
18//! 8 threshold             i32 threshold
19//! 9 gradient map          a GradationData (see crate::gradient)
20//! ```
21//!
22//! The rules were measured against CSP exports of generated test files
23//! (`docs/validation/V6b-generated-layers.md`). Colour balance without
24//! "preserve luminosity" is an empirical fit (within 3 levels at every
25//! tested setting); with it, it is exact per channel (within 1).
26//! [`Correction::is_approximate`] says when a setting falls outside what
27//! was measured closely.
28
29use crate::curve::Spline;
30use crate::error::{Error, Result};
31use crate::gradient::{Mixing, Ramp};
32
33/// The settings of a correction layer, by type.
34#[derive(Debug, Clone, PartialEq)]
35#[non_exhaustive]
36pub enum Correction {
37    /// 明るさ・コントラスト (type 1).
38    BrightnessContrast {
39        /// -100..100.
40        brightness: i32,
41        /// -100..100.
42        contrast: i32,
43    },
44    /// レベル補正 (type 2). Per slot: input black, gamma point, input
45    /// white, output black, output white (0..1).
46    Levels(Vec<[f64; 5]>),
47    /// トーンカーブ (type 3). Per slot: control points (0..1).
48    ToneCurve(Vec<Vec<(f64, f64)>>),
49    /// 色相・彩度・明度 (type 4).
50    HueSaturationValue {
51        /// Degrees.
52        hue: i32,
53        /// -100..100.
54        saturation: i32,
55        /// -100..100.
56        value: i32,
57    },
58    /// カラーバランス (type 5).
59    ColorBalance {
60        /// 輝度を保持.
61        preserve_luminosity: bool,
62        /// R, G, B shift of the shadows (-100..100).
63        shadows: [i32; 3],
64        /// R, G, B shift of the midtones (-100..100).
65        midtones: [i32; 3],
66        /// R, G, B shift of the highlights (-100..100).
67        highlights: [i32; 3],
68    },
69    /// 階調の反転 (type 6).
70    Invert,
71    /// ポスタリゼーション (type 7): the number of levels.
72    Posterize(i32),
73    /// 2値化 (type 8): the threshold.
74    Threshold(i32),
75    /// グラデーションマップ (type 9).
76    GradientMap(Ramp),
77}
78
79fn int(data: &[u8], index: usize) -> Result<i32> {
80    data.get(4 * index..4 * index + 4)
81        .map(|b| i32::from_be_bytes(b.try_into().expect("4")))
82        .ok_or_else(|| Error::format("FilterLayerInfo is cut short"))
83}
84
85fn short(data: &[u8], offset: usize) -> Result<f64> {
86    data.get(offset..offset + 2)
87        .map(|b| u16::from_be_bytes([b[0], b[1]]) as f64 / 65535.0)
88        .ok_or_else(|| Error::format("FilterLayerInfo is cut short"))
89}
90
91impl Correction {
92    /// Parse a `FilterLayerInfo` blob.
93    pub fn parse(data: &[u8]) -> Result<Self> {
94        let kind = int(data, 0)?;
95        let payload = data.get(8..).unwrap_or_default();
96        Ok(match kind {
97            1 => Correction::BrightnessContrast { brightness: int(payload, 0)?, contrast: int(payload, 1)? },
98            2 => Correction::Levels(
99                (0..4)
100                    .map(|slot| -> Result<[f64; 5]> {
101                        let at = slot * 10;
102                        Ok([
103                            short(payload, at)?,
104                            short(payload, at + 2)?,
105                            short(payload, at + 4)?,
106                            short(payload, at + 6)?,
107                            short(payload, at + 8)?,
108                        ])
109                    })
110                    .collect::<Result<_>>()?,
111            ),
112            3 => Correction::ToneCurve(
113                (0..4)
114                    .map(|slot| -> Result<Vec<(f64, f64)>> {
115                        let at = slot * 130;
116                        let count = (short(payload, at)? * 65535.0).round() as usize;
117                        (0..count.min(32))
118                            .map(|i| Ok((short(payload, at + 2 + 4 * i)?, short(payload, at + 4 + 4 * i)?)))
119                            .collect()
120                    })
121                    .collect::<Result<_>>()?,
122            ),
123            4 => Correction::HueSaturationValue {
124                hue: int(payload, 0)?,
125                saturation: int(payload, 1)?,
126                value: int(payload, 2)?,
127            },
128            5 => {
129                let v = |i| int(payload, i);
130                Correction::ColorBalance {
131                    preserve_luminosity: v(0)? != 0,
132                    shadows: [v(1)?, v(2)?, v(3)?],
133                    midtones: [v(4)?, v(5)?, v(6)?],
134                    highlights: [v(7)?, v(8)?, v(9)?],
135                }
136            }
137            6 => Correction::Invert,
138            7 => Correction::Posterize(int(payload, 0)?),
139            8 => Correction::Threshold(int(payload, 0)?),
140            9 => Correction::GradientMap(Ramp::parse(payload)?),
141            other => return Err(Error::format(format!("FilterLayerInfo type {other}"))),
142        })
143    }
144
145    /// True when the result is an approximation: a gradient map in the
146    /// 知覚的 mode.
147    pub fn is_approximate(&self) -> bool {
148        match self {
149            Correction::GradientMap(ramp) => ramp.mixing == Mixing::Perceptual,
150            _ => false,
151        }
152    }
153
154    /// Prepare for applying to many pixels.
155    pub fn filter(&self) -> Filter<'_> {
156        let tables = match self {
157            Correction::BrightnessContrast { brightness, contrast } => {
158                Some([brightness_contrast(*brightness, *contrast); 3])
159            }
160            Correction::Levels(slots) => {
161                let table = |slot: usize| slots.get(slot).map(|s| levels(*s)).unwrap_or_else(identity);
162                Some(compose(table(0), [table(1), table(2), table(3)]))
163            }
164            Correction::ToneCurve(slots) => {
165                let table = |slot: usize| match slots.get(slot) {
166                    Some(points) if !points.is_empty() => Spline::new(points).table(),
167                    _ => identity(),
168                };
169                Some(compose(table(0), [table(1), table(2), table(3)]))
170            }
171            Correction::ColorBalance { preserve_luminosity: false, shadows, midtones, highlights } => {
172                Some(std::array::from_fn(|c| balance(shadows[c], midtones[c], highlights[c])))
173            }
174            Correction::ColorBalance { preserve_luminosity: true, shadows, midtones, highlights } => {
175                Some(std::array::from_fn(|c| balance_preserving(c, *shadows, *midtones, *highlights)))
176            }
177            Correction::Invert => Some([std::array::from_fn(|v| 255.0 - v as f32); 3]),
178            Correction::Posterize(levels) => {
179                let n = (*levels).max(2) as f32;
180                Some([std::array::from_fn(|v| ((v as f32 * n / 256.0).floor() * 255.0 / (n - 1.0)).floor()); 3])
181            }
182            _ => None,
183        };
184        Filter { correction: self, tables }
185    }
186}
187
188/// A [`Correction`] ready to apply: per-channel tables where the
189/// correction is one, otherwise the per-pixel rule.
190pub struct Filter<'a> {
191    correction: &'a Correction,
192    tables: Option<[[f32; 256]; 3]>,
193}
194
195impl Filter<'_> {
196    /// The corrected colour of a straight sRGB colour (0..255 per channel).
197    pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
198        if let Some(tables) = &self.tables {
199            return std::array::from_fn(|c| lookup(&tables[c], rgb[c]));
200        }
201        let out = match self.correction {
202            Correction::HueSaturationValue { hue, saturation, value } => {
203                let rgb = rgb.map(|v| v as f64);
204                let rgb = value_op(rgb, *value as f64 / 100.0);
205                let rgb = hue_op(rgb, *hue as f64);
206                saturation_op(rgb, *saturation as f64 / 100.0).map(|v| v as f32)
207            }
208            Correction::Threshold(t) => {
209                let [r, g, b] = rgb.map(|v| v.round().clamp(0.0, 255.0) as i32);
210                let y = (306 * r + 601 * g + 117 * b) >> 10;
211                [if y >= *t { 255.0 } else { 0.0 }; 3]
212            }
213            Correction::GradientMap(ramp) => {
214                let y = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
215                let c = ramp.color((y / 255.0) as f64);
216                let a = c[3] as f32;
217                std::array::from_fn(|i| c[i] as f32 * 255.0 * a + rgb[i] * (1.0 - a))
218            }
219            _ => rgb,
220        };
221        out.map(|v| v.clamp(0.0, 255.0))
222    }
223}
224
225fn identity() -> [f32; 256] {
226    std::array::from_fn(|v| v as f32)
227}
228
229fn lookup(table: &[f32; 256], v: f32) -> f32 {
230    let v = v.clamp(0.0, 255.0);
231    let i = (v.floor() as usize).min(254);
232    let f = v - i as f32;
233    table[i] + (table[i + 1] - table[i]) * f
234}
235
236/// Each channel's own table, then the master's.
237fn compose(master: [f32; 256], channels: [[f32; 256]; 3]) -> [[f32; 256]; 3] {
238    channels.map(|table| table.map(|v| lookup(&master, v)))
239}
240
241/// Measured: contrast scales around 127.5; brightness is added before a
242/// contrast increase and after a decrease; contrast 100 is a threshold.
243fn brightness_contrast(brightness: i32, contrast: i32) -> [f32; 256] {
244    let (b, c) = (brightness as f64, contrast as f64);
245    std::array::from_fn(|v| {
246        let v = v as f64;
247        let out = if c >= 100.0 {
248            if v + b > 127.5 { 255.0 } else { 0.0 }
249        } else if c > 0.0 {
250            (v + b - 127.5) / (1.0 - c / 100.0) + 127.5
251        } else {
252            (v - 127.5) * (1.0 + c / 100.0) + 127.5 + b
253        };
254        out.clamp(0.0, 255.0) as f32
255    })
256}
257
258/// Levels: out = ob + (ow - ob) · t^g with t the input between black and
259/// white and g putting the gamma point at one half.
260fn levels([black, gamma_point, white, out_black, out_white]: [f64; 5]) -> [f32; 256] {
261    let range = (white - black).max(1e-6);
262    let middle = ((gamma_point - black) / range).clamp(1e-6, 1.0 - 1e-6);
263    let g = 0.5f64.ln() / middle.ln();
264    std::array::from_fn(|v| {
265        let t = ((v as f64 / 255.0 - black) / range).clamp(0.0, 1.0);
266        ((out_black + (out_white - out_black) * t.powf(g)) * 255.0).clamp(0.0, 255.0) as f32
267    })
268}
269
270/// Colour balance of one channel, as levels: shadows below zero raise the
271/// input black, highlights above zero lower the input white, and every
272/// setting bends the gamma. The coefficients are fitted to CSP exports at
273/// ±30, ±50 and ±100 (`docs/validation/V6b-generated-layers.md`).
274fn balance(shadows: i32, midtones: i32, highlights: i32) -> [f32; 256] {
275    let (s, m, h) = (shadows as f64, midtones as f64, highlights as f64);
276    let (mut log_gamma, mut black, mut white) = (0.0, 0.0, 255.0);
277    if s > 0.0 {
278        log_gamma -= 0.002_25 * s;
279    } else if s < 0.0 {
280        log_gamma += 0.0034 * -s + 3.4e-6 * s * s;
281        black = -s * (0.99 - 0.000_32 * -s);
282    }
283    if m > 0.0 {
284        log_gamma -= 0.0055 * m;
285    } else if m < 0.0 {
286        log_gamma += 0.004_66 * -m + 1.88e-5 * m * m;
287    }
288    if h > 0.0 {
289        log_gamma -= 0.003_31 * h;
290        white = 255.0 - 0.9575 * h;
291    } else if h < 0.0 {
292        log_gamma += 0.0024 * -h;
293    }
294    let gamma = log_gamma.exp();
295    std::array::from_fn(|v| {
296        let t = ((v as f64 - black) / (white - black)).clamp(0.0, 1.0);
297        (255.0 * t.powf(gamma)) as f32
298    })
299}
300
301/// Colour balance with "preserve luminosity", for channel `c`: still one
302/// table per channel, but a shift toward a colour moves the other
303/// channels the opposite way. Shadows raise a channel's black point by
304/// its own negative amount plus the other channels' positive amounts;
305/// highlights lower its white point by its own positive amount plus the
306/// others' negative amounts; midtones set a gamma from the channel's
307/// amount less the mean of all three.
308fn balance_preserving(c: usize, shadows: [i32; 3], midtones: [i32; 3], highlights: [i32; 3]) -> [f32; 256] {
309    let others = |values: [i32; 3], own: fn(i32) -> i32, other: fn(i32) -> i32| -> f64 {
310        (0..3).map(|i| if i == c { own(values[i]) } else { other(values[i]) }).sum::<i32>() as f64
311    };
312    let black = others(shadows, |v| (-v).max(0), |v| v.max(0)).min(254.0);
313    let white = (255.0 - others(highlights, |v| v.max(0), |v| (-v).max(0))).max(black + 1.0);
314    let mean = midtones.iter().sum::<i32>() as f64 / 3.0;
315    let gamma = (-0.0064 * (midtones[c] as f64 - mean)).exp();
316    std::array::from_fn(|v| {
317        let t = ((v as f64 - black) / (white - black)).clamp(0.0, 1.0);
318        (255.0 * t.powf(gamma)) as f32
319    })
320}
321
322fn max3(c: [f64; 3]) -> f64 {
323    c[0].max(c[1]).max(c[2])
324}
325
326fn min3(c: [f64; 3]) -> f64 {
327    c[0].min(c[1]).min(c[2])
328}
329
330/// 明度: below zero every channel scales down; above zero the HSV value
331/// moves toward 255 and the HSV saturation shrinks by the same amount.
332fn value_op(c: [f64; 3], t: f64) -> [f64; 3] {
333    if t <= 0.0 {
334        return c.map(|v| v * (1.0 + t));
335    }
336    let max = max3(c);
337    let new_max = max + (255.0 - max) * t;
338    c.map(|v| {
339        let below = if max > 0.0 { (max - v) / max } else { 0.0 };
340        new_max - below * (1.0 - t) * new_max
341    })
342}
343
344/// 色相: rotation of the HSL hue, keeping HSL saturation and lightness.
345fn hue_op(c: [f64; 3], degrees: f64) -> [f64; 3] {
346    if degrees == 0.0 {
347        return c;
348    }
349    let (max, min) = (max3(c), min3(c));
350    let chroma = max - min;
351    if chroma <= 0.0 {
352        return c;
353    }
354    let [r, g, b] = c;
355    let sector = if max == r {
356        ((g - b) / chroma).rem_euclid(6.0)
357    } else if max == g {
358        (b - r) / chroma + 2.0
359    } else {
360        (r - g) / chroma + 4.0
361    };
362    let h = (sector + degrees / 60.0).rem_euclid(6.0);
363    let x = chroma * (1.0 - (h % 2.0 - 1.0).abs());
364    let (r, g, b) = match h as u32 {
365        0 => (chroma, x, 0.0),
366        1 => (x, chroma, 0.0),
367        2 => (0.0, chroma, x),
368        3 => (0.0, x, chroma),
369        4 => (x, 0.0, chroma),
370        _ => (chroma, 0.0, x),
371    };
372    [r + min, g + min, b + min]
373}
374
375/// 彩度. Below zero: HSV saturation × (1 + t), then all channels × (1 +
376/// t·s/2). Above zero: HSV saturation s + (1 - s)·u and value × (1 + u)
377/// with u = t·(1 - s), u limited so the value stays within 255; greys
378/// stay grey.
379fn saturation_op(c: [f64; 3], t: f64) -> [f64; 3] {
380    let (max, min) = (max3(c), min3(c));
381    let chroma = max - min;
382    if t == 0.0 || chroma <= 0.0 || max <= 0.0 {
383        return c;
384    }
385    let s = chroma / max;
386    if t < 0.0 {
387        let scale = 1.0 + t * s / 2.0;
388        return c.map(|v| (max - (max - v) * (1.0 + t)) * scale);
389    }
390    let room = if s < 1.0 { (255.0 / max - 1.0) / (1.0 - s) } else { f64::INFINITY };
391    let u = t.min(room) * (1.0 - s);
392    let (new_max, new_s) = (max * (1.0 + u), s + u);
393    let new_min = new_max * (1.0 - new_s);
394    c.map(|v| new_min + (v - min) / chroma * (new_max - new_min))
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn blob(kind: i32, ints: &[i32]) -> Vec<u8> {
402        let mut data = kind.to_be_bytes().to_vec();
403        data.extend(((ints.len() * 4) as u32).to_be_bytes());
404        for v in ints {
405            data.extend(v.to_be_bytes());
406        }
407        data
408    }
409
410    fn apply(kind: i32, ints: &[i32], rgb: [f32; 3]) -> [f32; 3] {
411        let correction = Correction::parse(&blob(kind, ints)).unwrap();
412        correction.filter().apply(rgb).map(|v| v.round())
413    }
414
415    /// Within one level of CSP (its own rounding differs from ours).
416    fn near(kind: i32, ints: &[i32], rgb: [f32; 3], csp: [f32; 3]) {
417        let ours = apply(kind, ints, rgb);
418        assert!(ours.iter().zip(csp).all(|(a, b)| (a - b).abs() <= 1.0), "{ours:?} vs CSP {csp:?}");
419    }
420
421    // Expected values are CSP exports of the generated test files.
422
423    #[test]
424    fn invert_posterize_threshold() {
425        assert_eq!(apply(6, &[], [0.0, 100.0, 255.0]), [255.0, 155.0, 0.0]);
426        assert_eq!(apply(7, &[4], [63.0, 64.0, 255.0]), [0.0, 85.0, 255.0]);
427        assert_eq!(apply(8, &[128], [128.0, 128.0, 128.0]), [255.0; 3]);
428    }
429
430    #[test]
431    fn brightness_contrast_rule() {
432        // bc4 (-20, 40): grey 127 → 93.
433        near(1, &[-20, 40], [127.0; 3], [93.0; 3]);
434        // contrast 100 is a threshold at 127.5 - brightness.
435        assert_eq!(apply(1, &[-18, 100], [145.0, 146.0, 0.0]), [0.0, 255.0, 0.0]);
436    }
437
438    #[test]
439    fn hue_saturation_value_rules() {
440        // Value -30 scales; +30 on (55, 6, 6) gives (114, 42, 42).
441        assert_eq!(apply(4, &[0, 0, -30], [100.0, 50.0, 0.0]), [70.0, 35.0, 0.0]);
442        near(4, &[0, 0, 30], [55.0, 6.0, 6.0], [114.0, 42.0, 42.0]);
443        // Saturation -50 on (55, 6, 6) gives (43, 23, 23); +50 on (128, 96, 64) gives (160, 100, 40).
444        near(4, &[0, -50, 0], [55.0, 6.0, 6.0], [43.0, 23.0, 23.0]);
445        near(4, &[0, 50, 0], [128.0, 96.0, 64.0], [160.0, 100.0, 40.0]);
446        // Saturation stops where the value reaches 255.
447        assert_eq!(apply(4, &[0, 100, 0], [250.0, 233.0, 217.0])[2], 216.0);
448        // Hue +120 turns red green.
449        assert_eq!(apply(4, &[120, 0, 0], [200.0, 0.0, 0.0]), [0.0, 200.0, 0.0]);
450        assert_eq!(apply(4, &[0, 100, 0], [90.0; 3]), [90.0; 3]);
451    }
452
453    #[test]
454    fn colour_balance_fit() {
455        // Red shadows -100: input black near 96; midtones +100: 128 → 172.
456        let shadows = apply(5, &[0, -100, 0, 0, 0, 0, 0, 0, 0, 0], [96.0, 96.0, 96.0]);
457        assert_eq!(shadows, [0.0, 96.0, 96.0]);
458        near(5, &[0, 0, 0, 0, 100, 0, 0, 0, 0, 0], [128.0; 3], [172.0, 128.0, 128.0]);
459    }
460
461    #[test]
462    fn colour_balance_preserving_luminosity() {
463        // CSP at 128: red midtones +50, red shadows -50, red highlights +50,
464        // red shadows +50 (the other channels' black point moves).
465        near(5, &[1, 0, 0, 0, 50, 0, 0, 0, 0, 0], [128.0; 3], [146.0, 118.0, 118.0]);
466        near(5, &[1, -50, 0, 0, 0, 0, 0, 0, 0, 0], [128.0; 3], [97.0, 128.0, 128.0]);
467        near(5, &[1, 0, 0, 0, 0, 0, 0, 50, 0, 0], [128.0; 3], [159.0, 128.0, 128.0]);
468        near(5, &[1, 50, 0, 0, 0, 0, 0, 0, 0, 0], [128.0; 3], [128.0, 97.0, 97.0]);
469    }
470
471    #[test]
472    fn tone_curve_channel_then_master() {
473        let mut payload = Vec::new();
474        let slots: [&[(u16, u16)]; 2] =
475            [&[(0, 0), (0x8000, 0x4000), (0xffff, 0xffff)], &[(0, 0x4000), (0xffff, 0xffff)]];
476        for slot in 0..32 {
477            let points: &[(u16, u16)] = slots.get(slot).copied().unwrap_or(&[(0, 0), (0xffff, 0xffff)]);
478            payload.extend((points.len() as u16).to_be_bytes());
479            for i in 0..32 {
480                let (x, y) = points.get(i).copied().unwrap_or((0, 0));
481                payload.extend(x.to_be_bytes());
482                payload.extend(y.to_be_bytes());
483            }
484        }
485        let mut data = 3i32.to_be_bytes().to_vec();
486        data.extend((payload.len() as u32).to_be_bytes());
487        data.extend(payload);
488        let correction = Correction::parse(&data).unwrap();
489        let out = correction.filter().apply([0.0, 128.0, 255.0]);
490        // Red: 0 → 64 by its own curve, then the master y = (x + x²)/2 → 40.
491        assert!((out[0] - 40.0).abs() < 1.0, "{out:?}");
492        assert!((out[1] - 96.0).abs() < 1.0 && out[2] > 254.0, "{out:?}");
493    }
494}