Merge PR #8465: perf(camera-raw): exposure with all scopes under its 10 ms budget (P36), same output

This commit is contained in:
David Mann
2026-10-22 23:38:39 +02:00
3 changed files with 103 additions and 29 deletions
+57
View File
@@ -28,6 +28,18 @@ impl RgbHistogram {
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
// Camera Raw counts its whole proxy (~450k pixels) on every slider step, so the count
// runs in blocks across cores (#211, P36). Integer sums don't depend on the order the
// blocks finish in, so the result is the same as one pass over all pixels.
#[cfg(not(target_arch = "wasm32"))]
if pixels.len() >= PARALLEL_PIXELS {
use rayon::prelude::*;
return pixels.par_chunks(PARALLEL_PIXELS / 2).map(Self::count).reduce(Self::default, Self::merge);
}
Self::count(pixels)
}
fn count(pixels: &[[f32; 4]]) -> Self {
let mut h = Self::default();
for pixel in pixels {
if !pixel[3].is_finite() {
@@ -68,8 +80,29 @@ impl RgbHistogram {
}
h
}
/// Sum of two block counts. Saturating like the counters themselves.
#[cfg(not(target_arch = "wasm32"))]
fn merge(mut self, other: Self) -> Self {
let add = |a: &mut [u64], b: &[u64]| a.iter_mut().zip(b).for_each(|(a, b)| *a = a.saturating_add(*b));
for (a, b) in self.channels.iter_mut().zip(&other.channels) {
add(a, b);
}
add(&mut self.underflow, &other.underflow);
add(&mut self.overflow, &other.overflow);
add(&mut self.shadows, &other.shadows);
add(&mut self.highlights, &other.highlights);
self.samples = self.samples.saturating_add(other.samples);
self.transparent = self.transparent.saturating_add(other.transparent);
self.invalid = self.invalid.saturating_add(other.invalid);
self
}
}
/// Below this many pixels one pass is faster than splitting the count across cores.
#[cfg(not(target_arch = "wasm32"))]
const PARALLEL_PIXELS: usize = 64 * 1024;
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
/// Transparent and non-finite pixels clip nothing.
@@ -94,6 +127,30 @@ pub fn clipping(p: [f32; 4]) -> (u8, u8) {
mod tests {
use super::*;
// The parallel path, and its threshold, exist only off wasm32.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn matches_the_single_pass_on_every_domain_and_across_block_edges() {
// Every special value at every channel, plus a dense ramp through each bin's rounding
// edge; lengths around the parallel threshold and its block size.
let special = [0.0, -0.0, 1.0, -1e-7, 1.0 + 1e-7, 0.5 / 255.0, 254.5 / 255.0, -3.0, 4.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MIN_POSITIVE];
let mut pool: Vec<[f32; 4]> = Vec::new();
for (i, &v) in special.iter().enumerate() {
for alpha in [1.0, 0.5, 0.0, -1.0, f32::NAN] {
pool.push([v, special[(i + 3) % special.len()], 0.25, alpha]);
pool.push([0.75, v, special[(i + 5) % special.len()], alpha]);
}
}
pool.extend((0..4096).map(|i| {
let v = i as f32 / 4095.0 * 1.2 - 0.1;
[v, 1.0 - v, (v * 7.0).fract(), 1.0]
}));
for len in [0, 1, PARALLEL_PIXELS / 2 + 1, PARALLEL_PIXELS - 1, PARALLEL_PIXELS, 3 * PARALLEL_PIXELS + 17] {
let pixels: Vec<_> = (0..len).map(|i| pool[(i * 7919) % pool.len()]).collect();
assert_eq!(RgbHistogram::from_rgba(&pixels), RgbHistogram::count(&pixels), "{len} pixels");
}
}
#[test]
fn exact_bins_and_channel_totals() {
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
+33 -28
View File
@@ -619,36 +619,41 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
} else if mode != ClippingMode::None {
if d.scope.overlay_key != Some(key) || d.scope.overlay_detail {
d.scope.overlay_detail = false;
let colors = d
.pixels()
.iter()
.map(|p| {
let (lo, hi) = clipping(*p);
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
return Color32::TRANSPARENT;
}
match mode {
ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo),
ClippingMode::HighlightChannels => {
if hi == 0 {
t.histogram_background()
} else {
t.histogram_color(hi)
}
let color = |p: &[f32; 4]| {
let (lo, hi) = clipping(*p);
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
return Color32::TRANSPARENT;
}
match mode {
ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo),
ClippingMode::HighlightChannels => {
if hi == 0 {
t.histogram_background()
} else {
t.histogram_color(hi)
}
ClippingMode::Warnings(warnings) => {
if warnings & 2 != 0 && hi != 0 {
t.histogram_color(1)
} else if warnings & 1 != 0 && lo != 0 {
t.histogram_color(4)
} else {
Color32::TRANSPARENT
}
}
ClippingMode::None => Color32::TRANSPARENT,
}
})
.collect();
ClippingMode::Warnings(warnings) => {
if warnings & 2 != 0 && hi != 0 {
t.histogram_color(1)
} else if warnings & 1 != 0 && lo != 0 {
t.histogram_color(4)
} else {
Color32::TRANSPARENT
}
}
ClippingMode::None => Color32::TRANSPARENT,
}
};
// Rebuilt for every new preview while a warning is on: one colour per proxy pixel,
// each independent, so it runs across cores (#211, P36).
#[cfg(not(target_arch = "wasm32"))]
let colors = {
use rayon::prelude::*;
d.pixels().par_iter().with_min_len(crate::camera_raw_ui::PARALLEL_MIN_PIXELS).map(color).collect()
};
#[cfg(target_arch = "wasm32")]
let colors = d.pixels().iter().map(color).collect();
let image = egui::ColorImage::new([d.pw, d.ph], colors);
match &mut d.scope.overlay_tex {
Some(t) => t.set(image, egui::TextureOptions::NEAREST),
+13 -1
View File
@@ -26,6 +26,10 @@ use crate::theme::Tokens;
use crate::widgets;
const PROXY_SIDE: usize = 900;
/// Smallest run of proxy pixels one rayon task takes in the per-pixel passes: below it the
/// split costs more than the work.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const PARALLEL_MIN_PIXELS: usize = 16 * 1024;
const PANEL_W: f32 = 330.0;
const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
@@ -292,11 +296,19 @@ impl ProxyJob {
}
photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float);
if let Some(mask) = &self.selection {
for ((out, original), coverage) in px.iter_mut().zip(self.proxy.iter()).zip(mask.iter()) {
let blend = |(out, (original, coverage)): (&mut [f32; 4], (&[f32; 4], &f32))| {
for (out, original) in out.iter_mut().zip(original) {
*out = *original + (*out - *original) * coverage;
}
};
// Each pixel is independent, so bands of the proxy blend in parallel (#211, P36).
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
px.par_iter_mut().zip(self.proxy.par_iter().zip(mask.par_iter())).with_min_len(PARALLEL_MIN_PIXELS).for_each(blend);
}
#[cfg(target_arch = "wasm32")]
px.iter_mut().zip(self.proxy.iter().zip(mask.iter())).for_each(blend);
}
let histogram_start = crate::gpu_canvas::now_ms();
let histogram = RgbHistogram::from_rgba(&px);