Skip to main content

pfx_post/
grade.rs

1use crate::color::{self, REC709};
2use crate::hash::{self, grain_hash};
3use crate::image::{self, Image};
4use crate::tone::Tone;
5
6#[derive(Clone, Copy, Debug)]
7pub struct Saturation {
8    pub amount: f32,
9    pub weights: [f32; 3],
10}
11
12impl Saturation {
13    pub fn rec709(amount: f32) -> Self {
14        Self {
15            amount,
16            weights: REC709,
17        }
18    }
19}
20
21#[derive(Clone, Copy, Debug)]
22pub enum Contrast {
23    Pivot { amount: f32, pivot: f32 },
24    Power { power: f32, lift: f32 },
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum WarmthKind {
29    Linear,
30    LumaCurve,
31}
32
33#[derive(Clone, Copy, Debug)]
34pub struct Warmth {
35    pub amount: f32,
36    pub kind: WarmthKind,
37    pub low: [f32; 2],
38    pub high: [f32; 2],
39    pub shadow: f32,
40    pub highlight: f32,
41}
42
43impl Warmth {
44    pub fn linear(amount: f32) -> Self {
45        Self {
46            amount,
47            kind: WarmthKind::Linear,
48            low: [1.0; 2],
49            high: [1.0; 2],
50            shadow: 0.0,
51            highlight: 1.0,
52        }
53    }
54
55    pub fn luma_curve(
56        amount: f32,
57        low: [f32; 2],
58        high: [f32; 2],
59        shadow: f32,
60        highlight: f32,
61    ) -> Self {
62        Self {
63            amount,
64            kind: WarmthKind::LumaCurve,
65            low,
66            high,
67            shadow,
68            highlight,
69        }
70    }
71}
72
73#[derive(Clone, Debug)]
74pub struct Lut {
75    pub size: u32,
76    pub values: Vec<[f32; 3]>,
77}
78
79impl Lut {
80    pub fn sample(&self, c: [f32; 3]) -> [f32; 3] {
81        let n = self.size;
82        let need = (n as usize)
83            .saturating_mul(n as usize)
84            .saturating_mul(n as usize);
85        if n < 2 || self.values.len() < need {
86            return c;
87        }
88        let scale = (n - 1) as f32;
89        let coord = |v: f32| {
90            let x = v.clamp(0.0, 1.0) * scale;
91            let i = (x.floor() as u32).min(n - 1);
92            let j = (i + 1).min(n - 1);
93            (i, j, x - i as f32)
94        };
95        let (x0, x1, tx) = coord(c[0]);
96        let (y0, y1, ty) = coord(c[1]);
97        let (z0, z1, tz) = coord(c[2]);
98        let at = |x, y, z| self.values[(x + n * (y + n * z)) as usize];
99        let mix = |a: [f32; 3], b: [f32; 3], t: f32| color::lerp3(a, b, t);
100        let c00 = mix(at(x0, y0, z0), at(x1, y0, z0), tx);
101        let c01 = mix(at(x0, y0, z1), at(x1, y0, z1), tx);
102        let c10 = mix(at(x0, y1, z0), at(x1, y1, z0), tx);
103        let c11 = mix(at(x0, y1, z1), at(x1, y1, z1), tx);
104        let c0 = mix(c00, c10, ty);
105        let c1 = mix(c01, c11, ty);
106        mix(c0, c1, tz)
107    }
108}
109
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum VignetteKind {
112    Power,
113    Smoothstep,
114}
115
116#[derive(Clone, Copy, Debug)]
117pub struct Vignette {
118    pub strength: f32,
119    pub kind: VignetteKind,
120    pub power: f32,
121    pub aspect: f32,
122    pub scale: f32,
123    pub inner: f32,
124    pub outer: f32,
125}
126
127impl Vignette {
128    pub fn power(strength: f32, power: f32, aspect: f32, scale: f32) -> Self {
129        Self {
130            strength,
131            kind: VignetteKind::Power,
132            power,
133            aspect,
134            scale,
135            inner: 0.0,
136            outer: 1.0,
137        }
138    }
139
140    pub fn smoothstep(strength: f32, aspect: f32, inner: f32, outer: f32) -> Self {
141        Self {
142            strength,
143            kind: VignetteKind::Smoothstep,
144            power: 1.0,
145            aspect,
146            scale: 1.0,
147            inner,
148            outer,
149        }
150    }
151}
152
153#[derive(Clone, Copy, Debug)]
154pub struct Grain {
155    pub strength: f32,
156    pub response: f32,
157    pub clamp: bool,
158}
159
160impl Grain {
161    pub fn clamped(strength: f32, response: f32) -> Self {
162        Self {
163            strength,
164            response,
165            clamp: true,
166        }
167    }
168}
169
170#[derive(Clone, Copy, Debug)]
171pub struct Dither {
172    pub amplitude: f32,
173}
174
175pub fn exposure(image: &Image, amount: f32) -> Image {
176    if (amount - 1.0).abs() < 1e-8 {
177        return image.clone();
178    }
179    image.map_rgb(|_, _, c| [c[0] * amount, c[1] * amount, c[2] * amount])
180}
181
182pub fn black(image: &Image, black: f32) -> Image {
183    let denom = 1.0 - black;
184    image.map_rgb(|_, _, c| {
185        if denom.abs() < 1e-6 {
186            [0.0; 3]
187        } else {
188            [
189                ((c[0] - black) / denom).max(0.0),
190                ((c[1] - black) / denom).max(0.0),
191                ((c[2] - black) / denom).max(0.0),
192            ]
193        }
194    })
195}
196
197pub fn saturation(image: &Image, pass: &Saturation) -> Image {
198    image.map_rgb(|_, _, c| color::saturate(c, pass.amount, pass.weights))
199}
200
201pub fn contrast(image: &Image, pass: &Contrast) -> Image {
202    image.map_rgb(|_, _, c| match *pass {
203        Contrast::Pivot { amount, pivot } => [
204            (c[0] - pivot) * amount + pivot,
205            (c[1] - pivot) * amount + pivot,
206            (c[2] - pivot) * amount + pivot,
207        ],
208        Contrast::Power { power, lift } => [
209            (color::safe_pow(c[0].max(0.0), power) * lift).clamp(0.0, 1.0),
210            (color::safe_pow(c[1].max(0.0), power) * lift).clamp(0.0, 1.0),
211            (color::safe_pow(c[2].max(0.0), power) * lift).clamp(0.0, 1.0),
212        ],
213    })
214}
215
216pub fn warmth(image: &Image, pass: &Warmth) -> Image {
217    image.map_rgb(|_, _, c| match pass.kind {
218        WarmthKind::Linear => {
219            let w = pass.amount;
220            [c[0] * (1.0 + w), c[1], c[2] * (1.0 - w)]
221        }
222        WarmthKind::LumaCurve => {
223            let l = color::luma(c, REC709).clamp(0.0, 1.0);
224            let t = color::smoothstep(pass.shadow, pass.highlight, l);
225            let warm = color::lerp3(
226                [pass.low[0], 1.0, pass.low[1]],
227                [pass.high[0], 1.0, pass.high[1]],
228                t,
229            );
230            let scale = color::lerp3([1.0; 3], warm, pass.amount);
231            [
232                (c[0] * scale[0]).max(0.0),
233                (c[1] * scale[1]).max(0.0),
234                (c[2] * scale[2]).max(0.0),
235            ]
236        }
237    })
238}
239
240pub fn lut(image: &Image, table: &Lut) -> Image {
241    image.map_rgb(|_, _, c| table.sample(c))
242}
243
244pub fn tone(image: &Image, curve: &Tone) -> Image {
245    image.map_rgb(|_, _, c| curve.map(c))
246}
247
248pub fn encode(image: &Image) -> Image {
249    image.map_rgb(|_, _, c| {
250        [
251            color::encode_channel(c[0]),
252            color::encode_channel(c[1]),
253            color::encode_channel(c[2]),
254        ]
255    })
256}
257
258pub fn vignette(image: &Image, pass: &Vignette) -> Image {
259    if pass.strength.abs() < 1e-8 {
260        return image.clone();
261    }
262    let w = image.width.max(1) as f32;
263    let h = image.height.max(1) as f32;
264    image.map_rgb(|x, y, c| {
265        let st_x = (x as f32 + 0.5) / w;
266        let st_y = (y as f32 + 0.5) / h;
267        let factor = match pass.kind {
268            VignetteKind::Power => {
269                let uv_x = st_x * 2.0 - 1.0;
270                let uv_y = st_y * 2.0 - 1.0;
271                let r = (uv_x * uv_x + (uv_y * pass.aspect).powi(2)).sqrt() / pass.scale.max(1e-4);
272                1.0 - pass.strength * color::safe_pow(r.max(0.0), pass.power)
273            }
274            VignetteKind::Smoothstep => {
275                let dx = st_x - 0.5;
276                let dy = (st_y - 0.5) * pass.aspect;
277                let v = (dx * dx + dy * dy).sqrt();
278                1.0 - pass.strength * color::smoothstep(pass.inner, pass.outer, v)
279            }
280        };
281        [c[0] * factor, c[1] * factor, c[2] * factor]
282    })
283}
284
285pub fn grain(image: &Image, pass: &Grain, frame: u32, seed: u32) -> Image {
286    if pass.strength.abs() < 1e-8 && !pass.clamp {
287        return image.clone();
288    }
289    let mixed = hash::mixed(seed, frame);
290    image.map_rgb(|x, y, c| {
291        let n = grain_hash(x, y, mixed ^ 7) + grain_hash(x, y, mixed ^ 19) - 1.0;
292        let l = color::luma(c, REC709);
293        let add = n * pass.strength * (1.0 - pass.response * l);
294        let out = [c[0] + add, c[1] + add, c[2] + add];
295        if pass.clamp {
296            [out[0].max(0.0), out[1].max(0.0), out[2].max(0.0)]
297        } else {
298            out
299        }
300    })
301}
302
303pub fn dither(image: &Image, pass: &Dither, frame: u32, seed: u32) -> Image {
304    if pass.amplitude.abs() < 1e-8 {
305        return image.clone();
306    }
307    image.map_rgb(|x, y, c| {
308        let h = hash::hash21(
309            x as f32 + (seed % 1024) as f32,
310            y as f32 + (frame % 1024) as f32,
311        );
312        let d = (h - 0.5) * pass.amplitude / 255.0;
313        [c[0] + d, c[1] + d, c[2] + d]
314    })
315}
316
317pub fn sample_bilinear(image: &Image, x: f32, y: f32) -> [f32; 4] {
318    image.sample(x, y)
319}
320
321pub fn separable(image: &Image, radius: u32, spread: f32) -> Image {
322    let radius = radius.min(64) as i32;
323    let spread = if spread <= 1e-4 { 1.0 } else { spread };
324    let horizontal = blur_axis(image, radius, spread, true);
325    blur_axis(&horizontal, radius, spread, false)
326}
327
328fn kernel(radius: i32, spread: f32) -> Vec<f32> {
329    let mut weights = Vec::with_capacity((radius * 2 + 1) as usize);
330    let mut total = 0.0;
331    for k in -radius..=radius {
332        let w = (-(k * k) as f32 / spread).exp();
333        weights.push(w);
334        total += w;
335    }
336    if total > 0.0 {
337        for w in &mut weights {
338            *w /= total;
339        }
340    }
341    weights
342}
343
344fn blur_axis(image: &Image, radius: i32, spread: f32, horizontal: bool) -> Image {
345    let weights = kernel(radius, spread);
346    let mut out = image.clone();
347    for y in 0..image.height {
348        for x in 0..image.width {
349            let mut acc = [0.0; 4];
350            for (i, k) in (-radius..=radius).enumerate() {
351                let (sx, sy) = if horizontal {
352                    (x as i32 + k, y as i32)
353                } else {
354                    (x as i32, y as i32 + k)
355                };
356                let p = image.get(sx, sy);
357                let w = weights[i];
358                for ch in 0..4 {
359                    acc[ch] += p[ch] * w;
360                }
361            }
362            out.pixels[image.index(x, y)] = acc;
363        }
364    }
365    out
366}
367
368pub fn keep_rgb(rgb: [f32; 3], threshold: f32, knee: f32, knee_width: f32, soft: f32) -> [f32; 3] {
369    let l = rgb[0].max(rgb[1]).max(rgb[2]);
370    let hard = if l > threshold {
371        (l - threshold).max(0.0) / l.max(1e-4)
372    } else {
373        0.0
374    };
375    let knee_t = if knee_width > 1e-6 {
376        ((l - knee) / knee_width).clamp(0.0, 1.0)
377    } else {
378        0.0
379    };
380    let keep = hard + knee_t * knee_t * soft;
381    [rgb[0] * keep, rgb[1] * keep, rgb[2] * keep]
382}
383
384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
385pub enum Downsample {
386    Box,
387    Tent,
388}
389
390pub fn downsample(image: &Image, factor: u32, filter: Downsample) -> Image {
391    let factor = factor.max(1);
392    let w = (image.width / factor).max(1);
393    let h = (image.height / factor).max(1);
394    Image::from_fn(w, h, |x, y| {
395        let c = if filter == Downsample::Tent {
396            let mut acc = [0.0; 3];
397            let base_x = x as f32 * factor as f32 + factor as f32 * 0.5 - 0.5;
398            let base_y = y as f32 * factor as f32 + factor as f32 * 0.5 - 0.5;
399            for dy in [-1.0, 1.0] {
400                for dx in [-1.0, 1.0] {
401                    let s = image.sample(base_x + dx, base_y + dy);
402                    acc[0] += s[0];
403                    acc[1] += s[1];
404                    acc[2] += s[2];
405                }
406            }
407            [acc[0] * 0.25, acc[1] * 0.25, acc[2] * 0.25]
408        } else {
409            let base_x = x as i32 * factor as i32;
410            let base_y = y as i32 * factor as i32;
411            let mut acc = [0.0; 3];
412            let mut n = 0.0;
413            let span = factor.min(64) as i32;
414            for dy in 0..span {
415                for dx in 0..span {
416                    let s = image.get(base_x + dx, base_y + dy);
417                    acc[0] += s[0];
418                    acc[1] += s[1];
419                    acc[2] += s[2];
420                    n += 1.0;
421                }
422            }
423            [acc[0] / n, acc[1] / n, acc[2] / n]
424        };
425        image::px(c, 1.0)
426    })
427}