diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e76c76ba1..35decea07a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ on: types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: + color_range: + description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)" + type: boolean + default: false selection_morphology: description: "Compare selection morphology on 36 MP (Linux, exact outputs)" type: boolean @@ -424,6 +428,36 @@ jobs: name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }} path: crates/${{ matrix.target.crate }}/fuzz/artifacts + color-range-performance: + if: inputs.color_range == true + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RAYON_NUM_THREADS: "4" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Linux build dependencies + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: color-range-perf + - name: Compare exact outputs and release timings on 24 MP + run: | + set -o pipefail + cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: color-range-performance + path: color-range-perf.txt + selection-morphology: if: inputs.selection_morphology == true runs-on: ubuntu-latest @@ -459,7 +493,7 @@ jobs: path: selection-morphology-perf.txt selection-morphology-quick-perf: - if: inputs.selection_morphology == true + if: inputs.selection_morphology == true || inputs.color_range == true # Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by # bevel_bench, so its required GPU rows cannot be measured there. runs-on: macos-15 diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 362e889fa9..d247349a7b 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -13,6 +13,11 @@ use crate::{EngineError, Result, Session}; mod modify_window; +#[cfg(test)] +mod color_range_performance; +#[cfg(test)] +mod color_range_window_tests; + fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } @@ -45,15 +50,18 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM } /// RGBA pixels the tools sample: the composite, or the active layer. -fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> { +fn sample_pixels(s: &Session, all_layers: bool, window: Option) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?; - let area = d.doc.bounds(); + let area = window.map_or(d.doc.bounds(), |r| r.intersect(&d.doc.bounds())); // Match the decoders' raster budget before either the layer or composite path allocates. let len = area.size().area(); let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 { return Err(EngineError::Other("the canvas is too large to sample for a selection".into())); } + if area.is_empty() { + return Ok((area, Vec::new())); + } let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface()); match (all_layers, layer) { (false, Some(surf)) => { @@ -149,31 +157,6 @@ fn parse_color(p: &Value) -> [f32; 3] { } } -/// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point -/// and where it was picked (area-local, for Localized Color Clusters). -fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result> { - let Some(pts) = p.get(key) else { return Ok(Vec::new()) }; - let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?; - let w = area.width() as usize; - let mut out = Vec::with_capacity(pts.len().min(4096)); - for pt in pts { - let xy = pt.as_array().and_then(|a| match a.as_slice() { - [x, y] => Some((x.as_f64()?, y.as_f64()?)), - _ => None, - }); - let Some((x, y)) = xy.filter(|(x, y)| x.is_finite() && y.is_finite()) else { - return Err(bad(format!("bad point {pt} in `{key}` (want [x, y])"))); - }; - let (xi, yi) = (x.floor() as i32, y.floor() as i32); - let q = if area.contains(xi, yi) { px.get((yi - area.y0) as usize * w + (xi - area.x0) as usize) } else { None }; - let Some(&[r, g, b, _]) = q else { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); - }; - out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) }); - } - Ok(out) -} - /// Composite RGB (the compositor's output profile for the document) → CIE Lab D50. fn lab_transform(s: &Session) -> Result> { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -346,6 +329,23 @@ impl ColorRangeSamples { } } +/// Replace refines the existing selection; no output outside its nonzero bounds survives. +fn color_range_area(doc: &Document, m: SelectionMode) -> Rect { + let canvas = doc.bounds(); + let Some(selection) = doc.selection.as_ref().filter(|_| m == SelectionMode::Replace) else { return canvas }; + // content_bounds describes differences from the default, not implicit selected pixels. + if selection.default_pixel().first().is_some_and(|v| *v != 0.0) { + return canvas; + } + // content_bounds orders raw tile coordinates by their differences. Reject coordinates + // outside the pixel-addressable range before those differences can overflow. + let valid = (i32::MIN / photocraft_geom::TILE_SIZE)..=(i32::MAX / photocraft_geom::TILE_SIZE); + if selection.tiles().any(|(tile, _)| !valid.contains(&tile.tx) || !valid.contains(&tile.ty)) { + return canvas; + } + selection.content_bounds().intersect(&canvas) +} + fn color_range(s: &mut Session, p: &Value) -> Result { const CMD: &str = "select.colorRange"; let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; @@ -357,17 +357,16 @@ fn color_range(s: &mut Session, p: &Value) -> Result { if b(p, "detectFaces", false) { return Err(bad("detectFaces: face detection isn't available yet; select without it".into())); } + let area = color_range_area(&s.active().ok_or(EngineError::NoDocument)?.doc, selection_mode); let (area, mut mask) = match preset { "sampledColors" => { - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; let w = area.width() as usize; // The dialog's eyedroppers: colours picked on the image, at their positions. // Like Photoshop's, they may sample anywhere on the canvas: an active selection only // limits the result (scope_color_range), not where colours are picked (#5244). - let plus = eyedropper_samples(p, "points", area, &px, &bad)?; - let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; - let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?; - let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]); + let query = ColorRangeSamples::new(s, p)?; + let mask = query.coverage(&px, w, 1.0, [area.x0 as f32, area.y0 as f32]); (area, mask) } "reds" | "yellows" | "greens" | "cyans" | "blues" | "magentas" => { @@ -379,11 +378,11 @@ fn color_range(s: &mut Session, p: &Value) -> Result { "blues" => 240.0, _ => 300.0, }; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::hue_range(&px, center)) } "skinTones" => { - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::skin_tones(&px, fuzz(40.0).min(200.0))) } "highlights" | "midtones" | "shadows" => { @@ -406,7 +405,7 @@ fn color_range(s: &mut Session, p: &Value) -> Result { .filter(|(l, h)| l <= h) .ok_or_else(|| bad(format!("tonalRange: want [low, high] levels 0..255 (got {v})")))?, }; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::tone_range(&px, lo, hi, fuzz(20.0).min(100.0) / 100.0 * 255.0)) } "outOfGamut" => { @@ -545,7 +544,7 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec { /// the selected pixels' colour range widened by the tolerance. fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { let tol = f(p, "tolerance", 32.0) / 255.0; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false), None)?; let (_, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); for (q, v) in px.iter().zip(&m) { diff --git a/crates/engine/src/cmd/selection_cmds/color_range_performance.rs b/crates/engine/src/cmd/selection_cmds/color_range_performance.rs new file mode 100644 index 0000000000..639b7af4c6 --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/color_range_performance.rs @@ -0,0 +1,70 @@ +//! Opt-in release comparison; full-canvas buffers belong on a CI runner. +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Layer, LayerContent, Size}; +use std::time::Instant; + +fn reference(s: &mut Session, p: &Value) { + let (canvas, px) = sample_pixels(s, true, None).unwrap(); + // Previous path: eyedropper colour came from the full composite already in memory. + let [r, g, b, _] = px[1500 * canvas.width() as usize + 2000]; + let point = sel::RangeSample { color: [r, g, b], at: Some((2000.0, 1500.0)) }; + let query = ColorRangeSamples::with_samples(s, p, canvas, &[point], &[]).unwrap(); + let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [0.0, 0.0]); + drop(px); + scope_color_range(&mut mask, &s.active().unwrap().doc, canvas, SelectionMode::Replace); + set_selection(s, "Color Range", canvas, mask, SelectionMode::Replace).unwrap(); +} + +#[test] +#[ignore = "24 MP release comparison: run on GitHub Actions"] +fn color_range_release_comparison() { + let mut doc = Document::new("Color Range performance", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8); + let mut pixels = Surface::with_default(PixelFormat::RGBA8, &[0.65, 0.3, 0.2, 1.0]); + pixels.fill_rect(Rect::new(3000, 2000, 3600, 2400), &[0.2, 0.3, 0.65, 1.0]); + doc.layers.push(Layer::new("image", LayerContent::Raster(pixels))); + let p = json!({"points": [[2000, 1500]], "fuzziness": 40, "localized": true, "range": 80}); + // Small, 1%, 4%, and full selections: include dense fallback cost in the comparison. + for bounds in [Rect::new(2975, 1975, 3025, 2025), Rect::new(2700, 1800, 3300, 2200), Rect::new(2400, 1600, 3600, 2400), doc.bounds()] { + let mut selection = Surface::new(PixelFormat::GRAY8); + selection.fill_rect(bounds, &[128.0 / 255.0]); + doc.selection = Some(selection); + let mut old_times = Vec::new(); + let mut new_times = Vec::new(); + let mut expected = None; + // One warmup pair, followed by three alternating measured pairs. + for round in 0..4 { + for old in if round % 2 == 0 { [true, false] } else { [false, true] } { + let mut session = Session::new(); + session.add_document(doc.clone(), None); + let now = Instant::now(); + if old { + reference(&mut session, &p); + } else { + session.execute("select.colorRange", p.clone()).unwrap(); + } + let elapsed = now.elapsed().as_secs_f64() * 1000.0; + if round > 0 { + if old { old_times.push(elapsed) } else { new_times.push(elapsed) } + } + // Outside timing, compare every stored sample over the whole canvas. + let actual = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()); + if let Some(ref expected) = expected { + assert_eq!(&actual, expected); + } else { + expected = Some(actual); + } + } + } + old_times.sort_by(f64::total_cmp); + new_times.sort_by(f64::total_cmp); + let (old, new) = (old_times[1], new_times[1]); + println!( + "24 MP Color Range {}x{}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x rgba_bytes={} exact=true", + bounds.width(), + bounds.height(), + old / new, + bounds.size().area() * 16 + ); + } +} diff --git a/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs b/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs new file mode 100644 index 0000000000..d14ecc7e31 --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs @@ -0,0 +1,211 @@ +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Size; +use serde_json::json; + +fn fixture(depth: SampleType, p3: bool) -> Session { + let mut s = Session::new(); + let mut doc = Document::with_background("color range", Size::new(64, 48), ColorMode::Rgb, depth, photocraft_doc::Color::WHITE); + if p3 { + doc.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes()); + } else { + doc.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); + } + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(0, 0, 64, 48), &[0.92, 0.92, 0.92, 1.0]); + bg.fill_rect(Rect::new(7, 8, 22, 23), &[0.85, 0.12, 0.08, 1.0]); + bg.fill_rect(Rect::new(35, 8, 50, 23), &[0.12, 0.72, 0.18, 1.0]); + bg.fill_rect(Rect::new(7, 29, 22, 44), &[0.12, 0.20, 0.86, 1.0]); + bg.fill_rect(Rect::new(35, 29, 50, 44), &[0.72, 0.48, 0.31, 1.0]); + s.add_document(doc, None); + s +} + +fn coverage_at(s: &Session, x: i32, y: i32) -> f32 { + s.active().and_then(|d| d.doc.selection.as_ref()).map_or(0.0, |m| m.sample_channel(x, y, 0)) +} + +fn full_sampled_reference(s: &Session, params: &Value) -> Vec { + let doc = &s.active().unwrap().doc; + let canvas = doc.bounds(); + let (_, px) = sample_pixels(s, b(params, "sampleAllLayers", true), None).unwrap(); + // Reproduce the previous command: read eyedropper colours from the full composite. + let samples = |key: &str| -> Vec { + params + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|point| { + let x = point[0].as_f64().unwrap().floor() as usize; + let y = point[1].as_f64().unwrap().floor() as usize; + let pixel = px[y * canvas.width() as usize + x]; + sel::RangeSample { color: [pixel[0], pixel[1], pixel[2]], at: Some((x as f32, y as f32)) } + }) + .collect() + }; + let query = ColorRangeSamples::with_samples(s, params, canvas, &samples("points"), &samples("subtractPoints")).unwrap(); + let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [canvas.x0 as f32, canvas.y0 as f32]); + if b(params, "invert", false) { + mask.iter_mut().for_each(|v| *v = 1.0 - *v); + } + scope_color_range(&mut mask, doc, canvas, mode(params)); + sel::mask_from_surface(sel::combine(doc.selection.as_ref(), &mask, canvas, mode(params)).as_ref(), canvas) +} + +#[test] +fn sampled_color_cropped_results_match_full_canvas_for_depths_profiles_and_sample_options() { + for (depth, p3) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|depth| [false, true].map(|p3| (depth, p3))) { + for params in [ + json!({"points": [[40, 15]], "fuzziness": 12}), + json!({"points": [[40, 15]], "subtractPoints": [[10, 15]], "order": ["-", "+"], "fuzziness": 18}), + json!({"points": [[40, 15]], "localized": true, "range": 80, "invert": true}), + json!({"points": [[10, 15]], "localized": true, "range": 25}), + json!({"points": [[40, 15]], "sampleAllLayers": false, "fuzziness": 0}), + ] { + let mut s = fixture(depth, p3); + s.execute("select.rect", json!({"x": 3, "y": 5, "width": 22, "height": 25})).unwrap(); + let expected = full_sampled_reference(&s, ¶ms); + s.execute("select.colorRange", params.clone()).unwrap(); + let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()); + assert_eq!(actual, expected, "depth={depth:?} p3={p3} params={params}"); + } + } +} + +#[test] +fn range_presets_are_scoped_and_non_replace_modes_keep_full_canvas_semantics() { + for preset in ["reds", "yellows", "greens", "cyans", "blues", "magentas", "highlights", "midtones", "shadows", "skinTones"] { + let mut s = fixture(SampleType::U8, false); + s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap(); + s.execute("select.colorRange", json!({"select": preset})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 0.0, "preset {preset} escaped selection"); + } + let mut s = fixture(SampleType::U8, false); + s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap(); + s.execute("select.colorRange", json!({"points": [[40, 15]], "fuzziness": 0, "mode": "add"})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 1.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 0.0); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 1.0); +} + +#[test] +fn window_defaults_and_hostile_tiles_fall_back_and_empty_selection_is_safe() { + let canvas = Rect::new(0, 0, 64, 48); + let mut doc = Document::new("window", Size::new(64, 48), ColorMode::Rgb, SampleType::U8); + doc.selection = Some(Surface::with_default(PixelFormat::GRAY8, &[1.0])); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas); + doc.selection = Some(Surface::new(PixelFormat::GRAY8)); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), Rect::new(0, 0, 0, 0)); + use photocraft_geom::TileCoord; + use std::sync::Arc; + let mut hostile = Surface::new(PixelFormat::GRAY8); + let tile = hostile.solid_tile(&[1.0]); + hostile.put_tiles([(TileCoord::new(i32::MIN, i32::MIN), Arc::clone(&tile)), (TileCoord::new(i32::MAX, i32::MAX), tile)]); + doc.selection = Some(hostile); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas); + assert_eq!(color_range_area(&doc, SelectionMode::Add), canvas); + doc.selection = Some(Surface::new(PixelFormat::GRAY8)); + let mut s = Session::new(); + s.add_document(doc, None); + assert!(sample_pixels(&s, true, Some(color_range_area(&s.active().unwrap().doc, SelectionMode::Replace))).unwrap().1.is_empty()); + let err = s.execute("select.colorRange", json!({"select": "shadows", "tonalRange": -1})).unwrap_err(); + assert!(err.to_string().contains("tonalRange")); + for params in [json!({"points": [[64, 0]]}), json!({"points": "invalid"}), json!({"localized": true}), json!({"order": ["+"]})] { + assert!(s.execute("select.colorRange", params).is_err()); + } + s.execute("select.colorRange", json!({"points": [[1, 1]], "invert": true})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn sparse_large_selection_samples_only_its_clipped_window() { + let mut doc = Document::new("sparse", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8); + let selection = sel::mask_to_surface(&vec![1.0; 24 * 20], Rect::new(2990, 1990, 3014, 2010)); + doc.selection = Some(selection); + let mut s = Session::new(); + s.add_document(doc, None); + let win = color_range_area(&s.active().unwrap().doc, SelectionMode::Replace); + assert_eq!(win, Rect::new(2990, 1990, 3014, 2010)); + let (area, pixels) = sample_pixels(&s, true, Some(win)).unwrap(); + assert_eq!(pixels.len(), area.size().area() as usize); + s.execute("select.colorRange", json!({"select": "highlights"})).unwrap(); +} + +#[test] +fn cropped_range_matches_full_canvas_combinations_with_soft_holes_and_clipped_masks() { + let canvas = Rect::new(0, 0, 64, 48); + for case in 0..6 { + for selection_mode in ["replace", "add", "subtract", "intersect"] { + for preset in ["sampledColors", "reds", "greens", "blues", "highlights", "midtones", "shadows", "skinTones"] { + for invert in [false, true] { + let mut s = fixture(SampleType::U16, true); + let mut selection = match case { + 0 => Surface::new(PixelFormat::GRAY8), + 1 => Surface::with_default(PixelFormat::GRAY8, &[128.0 / 255.0]), + _ => sel::mask_to_surface(&vec![128.0 / 255.0; 30 * 30], Rect::new(-5, 5, 25, 35)), + }; + if case == 3 { + selection.fill_rect(Rect::new(8, 10, 15, 20), &[0.0]); + } + if case == 5 { + selection = Surface::new(PixelFormat::GRAY8); + } + if case == 4 { + selection = sel::mask_to_surface(&[1.0; 4], Rect::new(-8, -8, -6, -6)); + } + let original = (case != 0).then_some(selection); + let mut prior = s.active().unwrap().doc.as_ref().clone(); + prior.selection = original; + let before = sel::mask_from_surface(prior.selection.as_ref(), canvas); + let mut unselected = prior.clone(); + unselected.selection = None; + let mut full = Session::new(); + full.add_document(unselected, None); + let params = json!({"select": preset, "points": [[40, 15]], "fuzziness": 40, "invert": invert}); + full.execute("select.colorRange", params.clone()).unwrap(); + let mut mask = sel::mask_from_surface(full.active().unwrap().doc.selection.as_ref(), canvas); + let m = SelectionMode::parse(selection_mode); + scope_color_range(&mut mask, &prior, canvas, m); + let expected = sel::combine(prior.selection.as_ref(), &mask, canvas, m); + s.edit("prior selection", |doc, _| { + doc.selection = prior.selection.clone(); + Ok(()) + }) + .unwrap(); + let mut params = params; + params["mode"] = json!(selection_mode); + s.execute("select.colorRange", params).unwrap(); + let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas); + assert_eq!(actual, sel::mask_from_surface(expected.as_ref(), canvas), "case {case}, {selection_mode}, {preset}, invert {invert}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), actual); + } + } + } + } +} + +#[test] +fn cropped_samples_match_full_composite_with_translucent_layers_and_active_layer_sampling() { + use photocraft_doc::{Layer, LayerContent}; + for all_layers in [false, true] { + let mut s = fixture(SampleType::F32, true); + s.edit("overlay", |doc, _| { + let mut pixels = Surface::new(PixelFormat::RGBA32F); + pixels.fill_rect(Rect::new(0, 0, 64, 48), &[0.3, 0.1, 0.6, 0.35]); + doc.layers.push(Layer::new("overlay", LayerContent::Raster(pixels))); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 6, "width": 20, "height": 30})).unwrap(); + let params = json!({"points": [[40, 15], [10, 15]], "subtractPoints": [[40, 35]], "sampleAllLayers": all_layers, "localized": true, "range": 70}); + let expected = full_sampled_reference(&s, ¶ms); + s.execute("select.colorRange", params).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()), expected); + } +} diff --git a/crates/engine/src/cmd/selection_cmds/tests.rs b/crates/engine/src/cmd/selection_cmds/tests.rs index 0b28681172..4cb1b62cbe 100644 --- a/crates/engine/src/cmd/selection_cmds/tests.rs +++ b/crates/engine/src/cmd/selection_cmds/tests.rs @@ -60,7 +60,7 @@ fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() { None, ); for all_layers in [false, true] { - assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample")); + assert!(sample_pixels(&s, all_layers, None).unwrap_err().to_string().contains("too large to sample")); } assert!(!s.active().unwrap().history.can_undo()); } @@ -275,7 +275,7 @@ fn color_range_prepared_query_samples_visible_layers_not_the_active_layer() { clear_selection(&mut s); let p = json!({"points": [[7, 7]], "sampleAllLayers": all_layers, "fuzziness": 0}); let query = ColorRangeSamples::new(&s, &p).unwrap(); - let (area, px) = sample_pixels(&s, all_layers).unwrap(); + let (area, px) = sample_pixels(&s, all_layers, None).unwrap(); let shown = query.coverage(&px, area.width() as usize, 1.0, [0.0, 0.0]); s.execute("select.colorRange", p).unwrap(); assert_eq!(shown[7 * 40 + 7], coverage(&s, 7, 7)); diff --git a/docs/color-range-performance.md b/docs/color-range-performance.md new file mode 100644 index 0000000000..939673e1c1 --- /dev/null +++ b/docs/color-range-performance.md @@ -0,0 +1,42 @@ +# Color Range selection sampling + +`select.colorRange` in Replace mode refines an existing selection. Its sampled-colour, +hue, skin-tone and tonal queries render/read only `selection.content_bounds()` clipped +to the canvas. RGBA buffers, Lab conversion, coverage, selection clipping and replacement +use that same rectangle. Invert happens before clipping to the old coverage, including +soft edges and holes. + +Eyedropper points and subtract points still sample anywhere on the original canvas. +Localized Color Clusters retain the full canvas's range and sample positions; the +coverage query receives the sampled rectangle's origin. Without a selection, or with +Add, Subtract or Intersect, sampling covers the full canvas. Nonzero implicit selection +defaults and malformed tile coordinates also use the full canvas. An explicitly empty +selection produces an empty result while still validating query parameters. + +The Out of Gamut preset uses the existing proof gamut-mask path and is not cropped. +Interactive preview resolution and scheduling are unchanged. + +On a 6000×4000 canvas, the RGBA f32 buffer normally contains 24,000,000 pixels +(384,000,000 bytes). A 50×50 selection uses 2,500 pixels (40,000 bytes); a 600×400 +selection uses 240,000 pixels (3,840,000 bytes). Lab input/output and mask buffers +shrink by the same area ratio. These are buffer sizes, not measured peak RSS. + +## Reproduce the comparison + +On a CI runner, run: + +```sh +RAYON_NUM_THREADS=4 cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture +``` + +Or dispatch `ci.yml` with `color_range=true`. It runs the Linux release comparison, +full normal CI, and the general quick performance suite on macOS. The release fixture +is 24 MP with two colour regions and localized eyedropper sampling. It compares the +full-canvas reference with the cropped command for 50×50, 600×400, 1200×800 and +full-canvas soft selections. Each has a warm-up pair and three alternating timed +pairs; reported timings are medians. Both include the undoable selection edit, and the +cropped path includes command dispatch. Every stored coverage sample is compared over +the whole canvas outside timing. No GUI frame latency is measured. + +This optimization does not change an existing scorecard scenario or baseline. The +request's P5 label is unrelated to scorecard P5 (layer visibility).