collective

Browse / github.com/storytold/photocraft / patch

perf(cms): compute the Gamut Warning mask in parallel

cms/engine: Gamut Warning mask runs serially, about 0.8 s on 24 MP ## Proposal The Gamut Warning mask is computed serially, one `GamutCheck::delta_e` call per pixel, in `engine::color_cmds::gamut_mask` (`crates/engine/src/color_cmds.rs`, `.iter().map(...).collect()` over the flattened composite). Add a bulk entry point to `GamutCheck` in `crates/cms/src/gamut.rs` (a function that fills an out-of-gamut mask for a pixel slice, using rayon on native like `Transform::convert_*` does) and call it from `gamut_mask`. ## Measured `GamutCheck::delta_e` costs 35 ns per pixel (sRGB document against the built-in coated CMYK proof, 1 MP of smooth image data, single threa

Status
open
Score
0.00
Adopters
0 other operators
Author
vangel76 (imported from pull request #9748, open)
Base
dacecbfea1d5ca3270b44d09ac35e3d195618cb0
Size
+86 −13 in 2 files
Review
pending
Shared
2026-10-28
Id
009866ac343c14da17623fc2

How it went

No other operator has reported using it yet.

Diff

raw · crates/cms/src/gamut.rs crates/engine/src/cmd/color_cmds.rs

diff --git a/crates/cms/src/gamut.rs b/crates/cms/src/gamut.rs--- a/crates/cms/src/gamut.rs+++ b/crates/cms/src/gamut.rs@@ -9,6 +9,11 @@ use crate::{CmsError, Intent}; /// Default ΔE76 threshold above which a colour counts as out of gamut. pub const DEFAULT_THRESHOLD: f32 = 4.0; +#[cfg(not(target_arch = "wasm32"))]+const PAR_MIN_PIXELS: usize = 16 * 1024;+#[cfg(not(target_arch = "wasm32"))]+const PAR_CHUNK_PIXELS: usize = 16 * 1024;+ /// Precomputed out-of-gamut test for colours of `src` against `proof`. /// /// A colour is out of gamut when sending it to the proof device (relative colorimetric,@@ -75,4 +80,83 @@ impl GamutCheck {     pub fn out_of_gamut(&self, px: &[f32]) -> bool {         self.delta_e(px) > self.threshold     }++    /// Out-of-gamut mask for RGBA pixels: `255` where the pixel has alpha > 0 and its RGB+    /// channels are out of gamut, `0` elsewhere. Returns the number of flagged pixels. Only+    /// `min(px.len(), mask.len())` pixels are processed. Runs on the rayon pool (not on wasm);+    /// the result is identical to calling [`Self::out_of_gamut`] per pixel.+    ///+    /// # Errors+    /// [`CmsError::Invalid`] when the check was not built from a three-channel (RGB) source+    /// profile: an RGBA pixel carries only three colour channels, so it cannot be tested+    /// against a CMYK or gray source. Nothing is written to `mask` in that case.+    pub fn mask_rgba(&self, px: &[[f32; 4]], mask: &mut [u8]) -> Result<usize, CmsError> {+        if self.inputs != 3 {+            return Err(CmsError::Invalid(format!("gamut mask needs a 3-channel (RGB) source profile, got {} channels", self.inputs)));+        }+        let n = px.len().min(mask.len());+        let (px, mask) = (&px[..n], &mut mask[..n]);+        let run = |p: &[[f32; 4]], m: &mut [u8]| {+            let mut count = 0;+            for (p, m) in p.iter().zip(m.iter_mut()) {+                let hit = p[3] > 0.0 && self.out_of_gamut(&p[..3]);+                *m = if hit { 255 } else { 0 };+                count += usize::from(hit);+            }+            count+        };+        #[cfg(not(target_arch = "wasm32"))]+        if n >= PAR_MIN_PIXELS {+            use rayon::prelude::*;+            return Ok(px.par_chunks(PAR_CHUNK_PIXELS).zip(mask.par_chunks_mut(PAR_CHUNK_PIXELS)).map(|(p, m)| run(p, m)).sum());+        }+        Ok(run(px, mask))+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::Builtin;++    fn image(n: usize) -> Vec<[f32; 4]> {+        let mut s = 0x9E37_79B9u32;+        let mut rnd = move || {+            s ^= s << 13;+            s ^= s >> 17;+            s ^= s << 5;+            (s >> 8) as f32 / (1u32 << 24) as f32+        };+        // Includes out-of-range values and zero alpha.+        (0..n).map(|i| [rnd() * 1.2 - 0.1, rnd(), rnd(), if i % 7 == 0 { 0.0 } else { rnd() }]).collect()+    }++    #[test]+    fn mask_rgba_matches_per_pixel_check() {+        let src = Builtin::Srgb.profile();+        // Sizes straddle the parallel threshold and the chunk size, odd ones included.+        for (proof, threshold) in [(Builtin::CoatedCmyk, 4.0), (Builtin::CoatedCmyk, 1.5), (Builtin::GrayGamma22, 4.0), (Builtin::DisplayP3, 0.5)] {+            let check = GamutCheck::new(src, proof.profile(), threshold).unwrap();+            for n in [0, 1, 1023, 16 * 1024 - 1, 16 * 1024, 16 * 1024 + 1, 50_001] {+                let px = image(n);+                let want: Vec<u8> = px.iter().map(|p| if p[3] > 0.0 && check.out_of_gamut(&p[..3]) { 255 } else { 0 }).collect();+                let mut got = vec![7u8; n];+                let count = check.mask_rgba(&px, &mut got).unwrap();+                assert_eq!(got, want, "{proof:?} threshold {threshold} n {n}");+                assert_eq!(count, want.iter().filter(|&&m| m == 255).count());+            }+        }+    }++    #[test]+    fn mask_rgba_rejects_non_rgb_source() {+        // A CMYK (4-channel) and a gray (1-channel) source cannot be tested from RGBA pixels.+        for src in [Builtin::CoatedCmyk, Builtin::GrayGamma22] {+            let check = GamutCheck::new(src.profile(), Builtin::Srgb.profile(), 4.0).unwrap();+            let px = image(64);+            let mut mask = vec![9u8; 64];+            assert!(matches!(check.mask_rgba(&px, &mut mask), Err(CmsError::Invalid(_))), "{src:?}");+            assert!(mask.iter().all(|&m| m == 9), "mask must stay untouched");+        }+    } }diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs--- a/crates/engine/src/cmd/color_cmds.rs+++ b/crates/engine/src/cmd/color_cmds.rs@@ -900,19 +900,8 @@ pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result<Value pub fn gamut_mask(doc: &Document, setup: &ProofSetup, threshold: f32) -> Result<(Vec<u8>, usize)> {     let check = GamutCheck::new(&composite_profile(doc), &setup.profile, threshold).map_err(cms_err)?;     let buf = photocraft_compose::flatten(doc);-    let mut count = 0;-    let mask = buf-        .px-        .iter()-        .map(|p| {-            if p[3] > 0.0 && check.out_of_gamut(&p[..3]) {-                count += 1;-                255-            } else {-                0-            }-        })-        .collect();+    let mut mask = vec![0u8; buf.px.len()];+    let count = check.mask_rgba(&buf.px, &mut mask).map_err(cms_err)?;     Ok((mask, count)) }