mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-01 05:47:31 -06:00
perf(selection): scope Color Range sampling to active bounds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, ¶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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user