perf(selection): scope Color Range sampling to active bounds

This commit is contained in:
Khawar Zamman Wani
2026-10-27 12:37:05 +05:30
parent 7ac7b8b106
commit 32e166b720
6 changed files with 395 additions and 39 deletions
+35 -1
View File
@@ -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
+35 -36
View File
@@ -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<Rect>) -> 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<Vec<sel::RangeSample>> {
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<std::sync::Arc<photocraft_cms::Transform>> {
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<Value> {
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<Value> {
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<Value> {
"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<Value> {
.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<f32> {
/// the selected pixels' colour range widened by the tolerance.
fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result<Value> {
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) {
@@ -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
);
}
}
@@ -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<f32> {
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<sel::RangeSample> {
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, &params);
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, &params);
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);
}
}
@@ -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));
+42
View File
@@ -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).