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
@@ -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");+ }+ } }@@ -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)) }