collective

Browse / github.com/storytold/photocraft / patch

fix: convert Indexed Color input to sRGB before quantization

fix: convert Indexed Color input to sRGB before quantization ## Scope Independent Indexed Color correctness fix, based on upstream `e6d6fb45a402`. No dependency on the palette backend, Palette Map filter, or closed UI redesigns. The compositor's RGB samples may still be encoded in its composite ICC profile. Indexed Color previously quantized those samples as sRGB while retaining the source profile. Convert the composite to encoded sRGB before palette construction/quantization and tag the resulting expanded RGB samples and color table consistently as sRGB. - Preserve alpha during conversion. - Retain the identity fast path for untagged/sRGB input. - K

Status
open
Score
0.00
Adopters
0 other operators
Author
Firstbober (imported from pull request #9882, open)
Base
e6d6fb45a40250f518d2e824236a8fdcaa4472c8
Size
+86 −1 in 3 files
Review
pending
Shared
2026-10-28
Id
8ebb46f5c9fae86941b8dbec

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/mode_cmds.rs crates/engine/src/mode_cmds/tests.rs docs/development.md

diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs--- a/crates/engine/src/mode_cmds.rs+++ b/crates/engine/src/mode_cmds.rs@@ -9,6 +9,8 @@  use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShape, PaletteKind}; use photocraft_algo::transform::{Homography, Interp};+use photocraft_cms::{Builtin, TransformOptions};+ use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size};@@ -169,6 +171,32 @@ fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> {     if opaque { buf.over_background([1.0, 1.0, 1.0]).px } else { buf.px } } +/// Indexed storage and its palette are sRGB. Convert RGB/gray composites before quantisation so+/// wide-gamut document samples are not interpreted as sRGB numbers by the quantizer.+fn composite_in_srgb(doc: &Document, opaque: bool) -> Result<Vec<[f32; 4]>> {+    const CMD: &str = "image.mode.indexedColor";+    let mut pixels = composite(doc, opaque);+    let channels = match doc.mode {+        ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => 1,+        ColorMode::Rgb | ColorMode::Indexed => 3,+        _ => return Ok(pixels),+    };+    let source = crate::color_cmds::document_profile(doc);+    let srgb = Builtin::Srgb.profile();+    if source.same_colors(srgb) {+        return Ok(pixels);+    }+    let transform =+        photocraft_cms::cached(&source, srgb, TransformOptions::default()).map_err(|e| bad(CMD, format!("could not convert the composite to sRGB: {e}")))?;+    let mut converted = [0.0f32; 16];+    for pixel in &mut pixels {+        let input = if channels == 1 { &pixel[..1] } else { &pixel[..3] };+        transform.eval(input, &mut converted);+        pixel[..3].copy_from_slice(&converted[..3]);+    }+    Ok(pixels)+}+ /// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format. fn single_layer(doc: &mut Document, active: &mut Option<LayerId>, mode: ColorMode, px: &[[f32; 4]], name: &str, background: bool) {     doc.mode = mode;@@ -242,7 +270,7 @@ fn indexed_color(s: &mut Session, p: &Value) -> Result<Value> {     let amount = num(p, "amount", 75.0).clamp(0.0, 100.0) / 100.0;     let transparency = p.get("transparency").and_then(Value::as_bool).unwrap_or(true);     let (pal_len, transparent) = s.edit("Indexed Color", |doc, active| {-        let mut px = composite(doc, !transparency);+        let mut px = composite_in_srgb(doc, !transparency)?;         let has_clear = transparency && px.iter().any(|q| q[3] < 0.5);         let mut pal = quantize::build_palette(&px, kind, if has_clear { colors.saturating_sub(1).max(2) } else { colors }, forced).map_err(|e| bad(CMD, e))?;         let transparent = if has_clear && pal.len() < 256 {@@ -254,6 +282,7 @@ fn indexed_color(s: &mut Session, p: &Value) -> Result<Value> {         let w = doc.size.width as usize;         quantize::quantize(&mut px, w, &pal, dither, amount, transparent);         single_layer(doc, active, ColorMode::Indexed, &px, if transparent.is_some() { "Index" } else { "Background" }, transparent.is_none());+        doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes());         doc.color_table = Some(ColorTable { colors: pal.clone(), transparent: transparent.map(|t| t as u8) });         Ok((pal.len(), transparent))     })?;diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs--- a/crates/engine/src/mode_cmds/tests.rs+++ b/crates/engine/src/mode_cmds/tests.rs@@ -194,6 +194,57 @@ fn indexed_color_palettes_and_dithers() {     } } +#[test]+fn indexed_wide_gamut_composites_in_srgb_before_quantizing() {+    for (profile_id, source) in [("display-p3", photocraft_cms::Builtin::DisplayP3), ("adobe-rgb-compat", photocraft_cms::Builtin::AdobeRgbCompat)] {+        for depth in [8, 16, 32] {+            let mut s = Session::new();+            s.execute("file.new", json!({"width": 2, "height": 1, "depth": depth, "mode": "rgb", "background": "transparent"})).unwrap();+            paint(&mut s, |x, _| if x == 0 { [0.13, 0.41, 0.79, 1.0] } else { [0.71, 0.27, 0.19, 0.6] });+            s.execute("edit.assignProfile", json!({"profile": profile_id})).unwrap();+            let source_bytes = source.profile().to_bytes();+            let source_profile = photocraft_cms::Profile::parse(&source_bytes).unwrap();+            let to_srgb =+                photocraft_cms::cached(&source_profile, photocraft_cms::Builtin::Srgb.profile(), photocraft_cms::TransformOptions::default()).unwrap();+            let expected = |rgb: [f32; 3]| {+                let mut out = [0.0f32; 16];+                to_srgb.eval(&rgb, &mut out);+                [0, 1, 2].map(|i| (out[i].clamp(0.0, 1.0) * 255.0).round() as u8)+            };+            let surface = doc(&s).layers[0].surface().unwrap();+            let stored_a = surface.rgba(0, 0);+            let stored_b = surface.rgba(1, 0);+            let expected_a = expected([stored_a[0], stored_a[1], stored_a[2]]);+            let expected_b = expected([stored_b[0], stored_b[1], stored_b[2]]);+            assert_ne!(expected_a, [33, 105, 201], "{profile_id}: the regression colours must move");+            assert_ne!(expected_b, [181, 69, 48], "{profile_id}: the regression colours must move");++            s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none"})).unwrap();+            let d = doc(&s);+            assert_eq!((d.mode, d.depth), (ColorMode::Indexed, SampleType::U8), "{profile_id} {depth}");+            assert_eq!(d.icc_profile.as_deref(), Some(&*photocraft_cms::Builtin::Srgb.profile().to_bytes()), "{profile_id} {depth}");+            let table = d.color_table.as_ref().unwrap();+            assert!(table.colors.contains(&expected_a), "{profile_id} {depth}: {expected_a:?} not in {:?}", table.colors);+            assert!(table.colors.contains(&expected_b), "{profile_id} {depth}: {expected_b:?} not in {:?}", table.colors);+            let surface = d.layers[0].surface().unwrap();+            for (x, (want, alpha)) in [(0, (expected_a, 1.0)), (1, (expected_b, 0.6))] {+                let got = surface.rgba(x, 0);+                let got8 = [0, 1, 2].map(|i| (got[i].clamp(0.0, 1.0) * 255.0).round() as u8);+                assert_eq!(got8, want, "{profile_id} {depth} x={x}");+                assert!((got[3] - alpha).abs() <= 1.0 / 255.0, "{profile_id} {depth} x={x}: {got:?}");+            }++            s.execute("edit.undo", json!({})).unwrap();+            assert_eq!(doc(&s).mode, ColorMode::Rgb);+            assert_eq!(doc(&s).icc_profile.as_deref(), Some(&*source_bytes));+            s.execute("edit.redo", json!({})).unwrap();+            assert_eq!(doc(&s).icc_profile.as_deref(), Some(&*photocraft_cms::Builtin::Srgb.profile().to_bytes()));+            assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0)[3], 1.0);+            assert!((doc(&s).layers[0].surface().unwrap().rgba(1, 0)[3] - 0.6).abs() <= 1.0 / 255.0);+        }+    }+}+ #[test] fn indexed_exact_transparency_and_sources() {     // Two colours + transparency: exact palette with a transparent entry.diff --git a/docs/development.md b/docs/development.md--- a/docs/development.md+++ b/docs/development.md@@ -482,6 +482,11 @@ How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop:  ## Indexed Color timings +Indexed Color converts the composited image from its composite ICC profile to encoded sRGB+before palette construction and quantization, preserving alpha. The resulting expanded RGB+samples and color table are tagged sRGB. Untagged/sRGB input retains the identity fast path;+the stock dialog and quantizer are unchanged.+ `cargo run --release -p photocraft-engine --example indexed_color_perf -- image.png` measures palette construction, Floyd–Steinberg diffusion, and the complete full-resolution CPU preview path (proxy copy, engine command, result composition; excludes GPU upload/presentation).