Merge pull request #9077 from ArjenS-LA/fix/average-smart-filters

Execute Average on Smart Objects and imported PSD smart filters
This commit is contained in:
David Mann
2026-11-09 14:23:56 +01:00
committed by GitHub
12 changed files with 488 additions and 36 deletions
+135
View File
@@ -0,0 +1,135 @@
//! Average changes colour, never coverage. Keep the existing RGB conversion and selection
//! weighting; Photoshop RGB8 observations are documented in docs/average-filter.md.
use photocraft_color::{PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface, from_rgba_into, to_rgba};
use crate::{Ctx, Image};
#[derive(Default)]
struct Mean {
sum: [f64; 3],
weight: f64,
}
impl Mean {
fn add(&mut self, fmt: &PixelFormat, px: &[f32], coverage: f32) {
let rgba = to_rgba(fmt, px);
let w = f64::from(coverage);
for (sum, value) in self.sum.iter_mut().zip(rgba) {
*sum += f64::from(value) * w;
}
self.weight += w;
}
fn colour(&self) -> Option<[f32; 3]> {
(self.weight > 0.0).then(|| self.sum.map(|s| (s / self.weight) as f32))
}
}
fn mix(fmt: &PixelFormat, px: &mut [f32], colour: [f32; 3], coverage: f32) {
if coverage <= 0.0 {
return;
}
let mut rgba = to_rgba(fmt, px);
for (value, mean) in rgba.iter_mut().zip(colour) {
*value += (mean - *value) * coverage;
}
from_rgba_into(fmt, rgba, px);
}
// Rect's public coordinates can span more than i32::MAX. Reject these before raster helpers
// do their signed coordinate arithmetic, and cap a single operation's sample count.
fn valid_area(area: Rect, channels: usize) -> bool {
area.width() <= i32::MAX as u32
&& area.height() <= i32::MAX as u32
&& u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(channels as u64) <= 512 * 1024 * 1024
}
pub(crate) fn apply(surface: &Surface, area: Rect, selection: Option<&Surface>, ctl: &Interrupt<'_>) -> Option<Surface> {
let fmt = surface.format();
let n = fmt.channels();
if !valid_area(area, n) || ctl.cancelled() {
return None;
}
if area.is_empty() {
return Some(surface.clone());
}
let mut raw = surface.try_read_region(area).ok()?;
let width = area.width() as usize;
let coverage = |i: usize| selection.map_or(1.0, |s| s.sample_channel(area.x0 + (i % width) as i32, area.y0 + (i / width) as i32, 0));
let mut mean = Mean::default();
for (i, px) in raw.chunks_exact(n).enumerate() {
if i % width == 0 && ctl.cancelled() {
return None;
}
mean.add(&fmt, px, coverage(i));
}
let Some(colour) = mean.colour() else { return Some(surface.clone()) };
for (i, px) in raw.chunks_exact_mut(n).enumerate() {
if i % width == 0 && ctl.cancelled() {
return None;
}
mix(&fmt, px, colour, coverage(i));
}
let mut out = surface.clone();
if ctl.cancelled() {
return None;
}
out.try_write_region(area, &raw).ok()?;
out.prune();
ctl.progress(1.0);
Some(out)
}
// The direct kernel API has no selection. Its mean is over the reference bounds, even when
// the requested output is only a tile; the surface dispatcher above also handles selections.
pub(crate) fn kernel(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let fmt = PixelFormat::new(ctx.mode, SampleType::F32, ctx.alpha);
let n = fmt.channels();
if src.ch != n || !valid_area(src.rect, n) || !valid_area(out, n) {
return Vec::new();
}
let read = |x: i32, y: i32| -> Option<&[f32]> {
if !src.rect.contains(x, y) {
return None;
}
let i = (i64::from(y) - i64::from(src.rect.y0)) as usize;
let j = (i64::from(x) - i64::from(src.rect.x0)) as usize;
let start = i.checked_mul(src.rect.width() as usize)?.checked_add(j)?.checked_mul(n)?;
src.data.get(start..start.checked_add(n)?)
};
let bounds = ctx.bounds.intersect(&src.rect);
let mut mean = Mean::default();
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
if let Some(px) = read(x, y) {
mean.add(&fmt, px, 1.0);
}
}
}
let len = (out.width() as usize).checked_mul(out.height() as usize).and_then(|s| s.checked_mul(n));
let Some(len) = len else { return Vec::new() };
let mut result = Vec::new();
if result.try_reserve_exact(len).is_err() {
return result;
}
let colour = mean.colour();
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
let mut px = [0.0; 5];
let Some(pixel) = px.get_mut(..n) else { return Vec::new() };
if let Some(source) = read(x, y) {
pixel.copy_from_slice(source);
}
if let Some(colour) = colour {
mix(&fmt, pixel, colour, 1.0);
}
result.extend_from_slice(pixel);
}
}
result
}
#[cfg(test)]
mod tests;
+59
View File
@@ -0,0 +1,59 @@
use super::*;
use photocraft_color::ColorMode;
#[test]
fn preserves_alpha_and_averages_hidden_rgb_at_each_depth() {
let bounds = Rect::new(0, 0, 2, 1);
for sample in SampleType::ALL {
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
let mut surface = Surface::new(fmt);
surface.write_region(bounds, &[1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0]);
let out = apply(&surface, bounds, None, &Interrupt::NONE).unwrap();
for (i, p) in out.read_region(bounds).as_chunks::<4>().0.iter().enumerate() {
assert!((p[0] - 0.5).abs() <= 1.0 / 255.0);
assert_eq!(p[1], 0.0);
assert!((p[2] - 0.5).abs() <= 1.0 / 255.0);
assert_eq!(p[3], if i == 0 { 1.0 } else { 0.0 });
}
}
}
#[test]
fn direct_kernel_uses_reference_bounds_for_each_output_tile() {
let bounds = Rect::new(-1, 0, 1, 1);
let src = Image { rect: bounds, ch: 4, data: vec![1.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.75] };
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
assert_eq!(kernel(&src, Rect::new(-1, 0, 0, 1), &ctx), vec![0.5, 0.0, 0.5, 0.25]);
assert_eq!(kernel(&src, Rect::new(0, 0, 1, 1), &ctx), vec![0.5, 0.0, 0.5, 0.75]);
}
#[test]
fn rejects_extreme_geometry_and_honours_pre_cancel() {
let surface = Surface::new(PixelFormat::RGBA8);
let huge = Rect::new(i32::MIN, 0, i32::MAX, 1);
assert!(apply(&surface, huge, None, &Interrupt::NONE).is_none());
assert!(apply(&surface, Rect::new(0, 0, 5, 5), None, &Interrupt::cancel_only(&|| true)).is_none());
assert_eq!(surface.tile_count(), 0);
}
#[test]
fn soft_selection_weights_the_mean_and_output_mix() {
let bounds = Rect::new(0, 0, 2, 1);
for sample in SampleType::ALL {
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
surface.write_region(bounds, &[1.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.75]);
let before = surface.read_region(bounds);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
selection.write_region(bounds, &[0.25, 1.0]);
let out = apply(&surface, bounds, Some(&selection), &Interrupt::NONE).unwrap();
// Unequal coverage makes the mean [0.2, 0, 0.8]. Each pixel then mixes
// toward it using its own coverage, independently of its original alpha.
for (i, expected) in [[0.8, 0.0, 0.2], [0.2, 0.0, 0.8]].iter().enumerate() {
let pixel = out.pixel(i as i32, 0);
for (actual, expected) in pixel.iter().zip(expected) {
assert!((actual - expected).abs() <= 1.0 / 255.0, "{sample:?}: {pixel:?}");
}
assert_eq!(pixel[3], before[i * 4 + 3]);
}
}
}
+13 -3
View File
@@ -21,6 +21,7 @@
pub mod art_history;
mod artistic;
mod artistic_fx;
mod average;
mod blur;
mod blur2;
pub mod camera_raw;
@@ -208,6 +209,8 @@ pub enum PolarMode {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "filter", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum FilterParams {
/// Mean colour over the selected area, retaining each pixel's alpha.
Average,
/// Radius = Gaussian standard deviation in pixels (0.1–1000).
GaussianBlur {
radius: f32,
@@ -592,7 +595,8 @@ impl FilterParams {
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
FilterParams::FindEdges => Halo::Radius(1),
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
FilterParams::RadialBlur { .. }
FilterParams::Average
| FilterParams::RadialBlur { .. }
| FilterParams::Offset { .. }
| FilterParams::Twirl { .. }
| FilterParams::Pinch { .. }
@@ -634,14 +638,16 @@ impl FilterParams {
}
/// Whether the filter places and sizes its pattern by the canvas even with a selection
/// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result.
/// (Clouds, Fibers), or limits its working area to the canvas (Average). The output
/// area is still clipped to the selection; Average also weights its mean by that coverage.
pub fn sizes_by_canvas(&self) -> bool {
matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
matches!(self, FilterParams::Average | FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
}
/// Human-readable name.
pub fn label(&self) -> &'static str {
match self {
FilterParams::Average => "Average",
FilterParams::GaussianBlur { .. } => "Gaussian Blur",
FilterParams::BoxBlur { .. } => "Box Blur",
FilterParams::MotionBlur { .. } => "Motion Blur",
@@ -766,6 +772,7 @@ pub struct Ctx {
/// the halo, or the bounds for global filters). Returns interleaved samples.
pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
match params {
FilterParams::Average => average::kernel(src, out, ctx),
FilterParams::GaussianBlur { radius } => blur::gaussian(src, out, ctx, *radius),
FilterParams::BoxBlur { radius } => blur::boxed(src, out, ctx, *radius),
FilterParams::MotionBlur { angle, distance } => blur::motion(src, out, ctx, *angle, *distance),
@@ -1141,6 +1148,9 @@ fn apply_tiled_with_impl(
if area.is_empty() {
return Some(out);
}
if matches!(params, FilterParams::Average) {
return average::apply(surface, extent.map_or(area, |e| area.intersect(&e)), selection, ctl);
}
#[cfg(not(target_arch = "wasm32"))]
if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) {
return result;
+3 -29
View File
@@ -74,13 +74,9 @@ pub(crate) fn step_key(s: &Session, what: &str) -> String {
/// area, the pixels, and the selection coverage per pixel; results are blended back by coverage,
/// and transparency stays locked when the layer locks it. When `p` targets an alpha channel, the
/// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that
/// grayscale surface is rewritten instead, as the core filters do (#935).
fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result<Value> {
map_pixels_in(s, label, p, false, f)
}
/// [`map_pixels`], or with `whole` the whole canvas: `f` still gets the selection coverage, and
/// every pixel takes its result in full.
/// grayscale surface is rewritten instead, as the core filters do (#935). With `whole` the whole
/// canvas is rewritten: `f` still gets the selection coverage, and every pixel takes its result
/// in full.
fn map_pixels_in(s: &mut Session, label: &str, p: &Value, whole: bool, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result<Value> {
let id = layer_param(s, p)?;
let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels;
@@ -275,27 +271,6 @@ fn equalize(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Filter › Blur › Average: the selection (or layer) filled with its mean colour.
fn average(s: &mut Session, p: &Value) -> Result<Value> {
map_pixels(s, "Average", p, |_, px, cover| {
let (mut sum, mut wsum, mut asum, mut n) = ([0f64; 3], 0f64, 0f64, 0f64);
for (p, &k) in px.iter().zip(cover) {
let w = (p[3] * k) as f64;
for c in 0..3 {
sum[c] += p[c] as f64 * w;
}
wsum += w;
asum += (p[3] * k) as f64;
n += k as f64;
}
if wsum <= 0.0 {
return;
}
let avg = [(sum[0] / wsum) as f32, (sum[1] / wsum) as f32, (sum[2] / wsum) as f32, (asum / n.max(1e-9)) as f32];
px.fill(avg);
})
}
// ---------- transform presets ----------
/// The presets' params: like Free Transform, they follow the Channels and Layers panel target.
@@ -1119,7 +1094,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_layer,
show_only
),
spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average),
]
}
+63 -1
View File
@@ -24,7 +24,7 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
s.tools.background = [1.0, 1.0, 1.0, 1.0];
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
let p = match target {
let mut p = match target {
"quickMask" => {
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
json!({"seed": 1})
@@ -39,6 +39,10 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
json!({"seed": 1, "target": "mask"})
}
};
// Average has no seed parameter; only Clouds uses it.
if cmd == "filter.blur.average" {
p.as_object_mut().unwrap().remove("seed");
}
// Two levels in the target, so Equalize and Average have something to change.
let edit_target = json!({ "target": if target == "alpha" { json!({"channel": 0}) } else { json!(target) } });
s.edit("levels", |doc, active| {
@@ -65,6 +69,18 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
let case = format!("{cmd} on {target} at {depth}");
assert_eq!(layer1, layer0, "{case}: the layer is untouched");
assert_ne!(target1, target0, "{case}: the target changed");
if cmd == "filter.blur.average" {
// The equal-sized halves contribute equally, using their stored sample values.
let mean = (f64::from(target0[0]) + f64::from(target0[20])) / 2.0;
let tolerance = match depth {
8 => 1.0 / 255.0,
16 => 1.0 / 65535.0,
_ => 1e-6,
};
for value in &target1 {
assert!((f64::from(*value) - mean).abs() <= tolerance, "{case}: {value}, expected {mean}");
}
}
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}");
s.undo();
assert_eq!(read(&s).1, target0, "{case}: undo restores the target");
@@ -556,6 +572,52 @@ fn clouds_in_a_selection_are_the_canvas_clouds() {
}
}
#[test]
fn average_preserves_transparency_and_averages_hidden_colours() {
// Photoshop 26.6.1, RGB8 PNG with opaque red and transparent blue halves:
// Average produces (128, 0, 128), retaining each pixel's original alpha.
for depth in [8, 16, 32] {
let mut s = session(depth);
paint_square(&mut s, Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 1.0]);
paint_square(&mut s, Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 0.0]);
let bounds = Rect::new(0, 0, 40, 30);
let before = active(&s).surface().unwrap().read_region(bounds);
let steps = s.active().unwrap().history.past_len();
s.execute("filter.blur.average", json!({})).unwrap();
let after = active(&s).surface().unwrap().read_region(bounds);
for (old, new) in before.as_chunks::<4>().0.iter().zip(after.as_chunks::<4>().0) {
assert_eq!(new[3], old[3], "depth {depth}: Average must preserve alpha");
assert!((new[0] - 0.5).abs() < 0.003 && new[1] == 0.0 && (new[2] - 0.5).abs() < 0.003, "{depth}: {new:?}");
}
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
s.undo();
assert_eq!(active(&s).surface().unwrap().read_region(bounds), before);
s.redo();
assert_eq!(active(&s).surface().unwrap().read_region(bounds), after);
}
}
#[test]
fn average_preserves_partial_alpha_and_pixels_outside_selection() {
for depth in [8, 16, 32] {
let mut s = session(depth);
paint_square(&mut s, Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 0.25]);
paint_square(&mut s, Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 0.75]);
s.execute("select.rect", json!({"x": 10, "y": 0, "width": 15, "height": 30})).unwrap();
let before: Vec<_> = (0..40).map(|x| pixel(&s, x, 15)).collect();
s.execute("filter.blur.average", json!({})).unwrap();
for x in 0..40 {
let p = pixel(&s, x, 15);
assert_eq!(p[3], before[x as usize][3], "depth {depth}, x {x}");
if (10..25).contains(&x) {
assert!((p[0] - 2.0 / 3.0).abs() < 0.003 && (p[2] - 1.0 / 3.0).abs() < 0.003, "{depth}: {p:?}");
} else {
assert_eq!(p, before[x as usize]);
}
}
}
}
#[test]
fn average_and_clouds() {
let mut s = session(8);
+2
View File
@@ -84,6 +84,7 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
return Some(fp);
}
Some(match id {
"filter.blur.average" => FilterParams::Average,
"filter.blur.gaussianBlur" => FilterParams::GaussianBlur { radius: f(p, "radius", 1.0).clamp(0.1, 1000.0) },
"filter.blur.boxBlur" => FilterParams::BoxBlur { radius: f(p, "radius", 1.0).clamp(1.0, 2000.0) },
"filter.blur.motionBlur" => FilterParams::MotionBlur { angle: f(p, "angle", 0.0), distance: f(p, "distance", 10.0).clamp(1.0, 2000.0) },
@@ -471,6 +472,7 @@ macro_rules! filter_cmd {
pub fn specs() -> Vec<CommandSpec> {
vec![
filter_cmd!("filter.blur.gaussianBlur", "Gaussian Blur…", ["Filter", "Blur"], r##"{"radius":0.1..1000=1}"##),
filter_cmd!("filter.blur.average", "Average", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.blur.blur", "Blur", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.blur.blurMore", "Blur More", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.sharpen.sharpen", "Sharpen", ["Filter", "Sharpen"], "{}"),
+2 -1
View File
@@ -94,7 +94,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
("vertical", Number(f64::MIN, f64::MAX)),
("undefinedAreas", Choice("wrap|repeat|transparent")),
],
"filter.blur.blur"
"filter.blur.average"
| "filter.blur.blur"
| "filter.blur.blurMore"
| "filter.sharpen.sharpen"
| "filter.sharpen.sharpenMore"
+124
View File
@@ -0,0 +1,124 @@
//! Exercise Average through the smart-filter renderer, not an imported cache.
use photocraft_doc::LayerContent;
use photocraft_engine::{Session, filters, smart_cmds};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn image(s: &Session) -> Surface {
let d = s.active().unwrap();
let l = d.doc.layer(d.active_layer.unwrap()).unwrap();
match &l.content {
LayerContent::Raster(r) => r.clone(),
LayerContent::Smart(sm) => sm.cache.clone().unwrap(),
_ => panic!("expected pixels"),
}
}
fn input(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let d = s.active_mut().unwrap();
let l = std::sync::Arc::make_mut(&mut d.doc).layer_mut(d.active_layer.unwrap()).unwrap();
let surf = l.surface_mut().unwrap();
for y in 0..16 {
for x in 0..16 {
surf.write_region(Rect::new(x, y, x + 1, y + 1), if x < 8 { &[1.0, 0.0, 0.0, 1.0] } else { &[0.0, 0.0, 1.0, 1.0] });
}
}
s
}
fn purple(surf: &Surface) {
for p in surf.read_region(Rect::new(0, 0, 16, 16)).as_chunks::<4>().0 {
assert!((p[0] - 0.5).abs() <= 1.0 / 255.0, "{p:?}");
assert_eq!(p[1], 0.0);
assert!((p[2] - 0.5).abs() <= 1.0 / 255.0, "{p:?}");
assert_eq!(p[3], 1.0);
}
}
#[test]
fn average_stored_filter_executes() {
for depth in [8, 16, 32] {
let s = input(depth);
let bounds = s.active().unwrap().doc.bounds();
let out = filters::apply_filter_to_surface("filter.blur.average", &json!({}), &image(&s), bounds, None, bounds)
.unwrap()
.expect("Average must be executable, not treated as an unknown filter");
purple(&out);
}
}
#[test]
fn average_smart_command_rerenders_and_undoes() {
for depth in [8, 16, 32] {
let mut s = input(depth);
s.execute("filter.convertForSmartFilters", json!({})).unwrap();
let before = image(&s);
let steps = s.active().unwrap().history.past_len();
assert!(s.is_enabled("filter.blur.average"));
s.execute("filter.blur.average", json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
purple(&image(&s));
assert!(s.undo());
assert_eq!(image(&s), before);
assert!(s.redo());
purple(&image(&s));
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap();
assert_eq!(image(&s), before);
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap();
let d = s.active_mut().unwrap();
let snapshot = d.doc.clone();
let layer = std::sync::Arc::make_mut(&mut d.doc).layer_mut(d.active_layer.unwrap()).unwrap();
if let LayerContent::Smart(sm) = &mut layer.content {
assert_eq!(sm.smart_filters.len(), 1);
assert_eq!(sm.smart_filters[0].command, "filter.blur.average");
sm.cache = None;
}
smart_cmds::purge_stack_cache();
assert!(smart_cmds::refresh_layer(&snapshot, layer).unwrap());
purple(&image(&s));
}
}
#[test]
fn average_rejects_bad_params_without_an_edit() {
let mut s = input(8);
let before = image(&s);
let steps = s.active().unwrap().history.past_len();
for params in [json!("bad"), json!({"unknown": 1}), json!({"layer": -1}), json!({"target": false})] {
assert!(s.execute("filter.blur.average", params).is_err());
assert_eq!(image(&s), before);
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
}
/// Optional local oracle: generate a 16×16 red/blue Smart Object in Photoshop, apply Average,
/// save as PSD, then set PHOTOCRAFT_AVERAGE_PSD to that file. Never accept cached pixels as proof.
#[test]
#[ignore = "requires a locally generated Photoshop Average PSD; see docs/average-filter.md"]
fn average_photoshop_psd_rerenders_from_source() {
let path = std::env::var("PHOTOCRAFT_AVERAGE_PSD").expect("set PHOTOCRAFT_AVERAGE_PSD");
let bytes = std::fs::read(&path).unwrap();
let mut doc = photocraft_io::import(&path, &bytes).unwrap().document;
let snapshot = doc.clone();
let ids: Vec<_> = snapshot
.walk()
.into_iter()
.filter_map(|(_, _, l)| match &l.content {
LayerContent::Smart(sm) if sm.smart_filters.iter().any(|f| f.command == "filter.blur.average") => Some(l.id),
_ => None,
})
.collect();
assert_eq!(ids.len(), 1);
let layer = doc.layer_mut(ids[0]).unwrap();
if let LayerContent::Smart(sm) = &mut layer.content {
sm.cache = None;
}
smart_cmds::purge_stack_cache();
assert!(smart_cmds::refresh_layer(&snapshot, layer).unwrap());
let LayerContent::Smart(sm) = &layer.content else { panic!("expected smart object") };
purple(sm.cache.as_ref().unwrap());
}
+1
View File
@@ -28,6 +28,7 @@ mod atn_stroke_width;
mod atn_type_path;
mod atn_v12;
mod atn_video_frames;
mod average_smart;
mod background_and_mask_depth;
mod background_layer_consistency;
mod brush_smoothing;
+74
View File
@@ -0,0 +1,74 @@
# Average filter: transparency reference
The Average filter replaces colour while retaining each pixel's transparency.
Two synthetic cases were executed in Photoshop **26.6.1 on Windows x64** on
2026-10-23. These are observations of RGB8 raster layers, without a selection or
transparency lock; they are not a claim of parity in other colour modes or at
other bit depths.
Each input is a 16 × 16 RGBA PNG. Its left eight columns are red and its right
eight columns are blue. Open it in Photoshop, run Filter › Blur › Average, and
save a PNG copy. Inspect the file's RGBA values, including invisible colours.
| Input left / right RGBA | Output left / right RGBA |
|---|---|
| `(255,0,0,255)` / `(0,0,255,0)` | `(128,0,128,255)` / `(128,0,128,0)` |
| `(255,0,0,64)` / `(0,0,255,192)` | `(128,0,128,64)` / `(128,0,128,192)` |
An opaque control produces `(128,0,128,255)` throughout. Thus transparency is
neither a weight in this colour average nor a channel to replace with its mean.
PhotoCraft previously alpha-weighted the RGB mean and replaced alpha with its
mean. On the first case that predicts translucent red throughout, rather than
purple pixels with their original transparency. The fix uses selection coverage
for the existing mean and leaves each pixel's alpha alone. Selection mixing and
the existing colour conversion path are unchanged. In particular, this does not
establish Photoshop parity for feathered selection weighting or CMYK/Lab means.
The engine tests construct their own pixels; no Photoshop binary, proprietary
code, or reference image is included. They cover transparent and partially
transparent pixels, selection boundaries, one history step, undo and redo at
8/16/32-bit storage. The higher-depth tests establish PhotoCraft invariants;
the external reference measurements above are RGB8 only.
Run the focused regressions with:
```sh
cargo test -p photocraft-engine --lib average_preserves
```
## Smart filters
The PSD importer already recognizes the optionless `Avrg` descriptor (see
[#6297](https://github.com/storytold/photocraft/pull/6297)). Recognition alone did
not execute it: the shared renderer returned `None`, leaving the source pixels
unchanged, and the raster-only command was disabled on Smart Objects.
Average now uses the shared filter command and algorithm. It can be added to a
Smart Object, toggled, undone and redone, or re-rendered from an imported record.
The raster path retains its canvas limit, selection weighting and alpha behavior.
Like other core filters, new invocations validate parameters before editing.
The integration tests create their own red/blue Smart Objects at RGB8/16/32f.
They check the dispatcher, command enablement, one history step, visibility,
undo/redo, and re-rendering after clearing both the layer and stage caches:
```sh
cargo test -p photocraft-engine --test it average_smart
```
An additional, explicitly ignored test accepts a local Photoshop oracle. To
reproduce it in Photoshop, create an opaque 16 × 16 RGB8 image with eight red
columns followed by eight blue columns, convert the layer to a Smart Object,
apply Average and save a PSD. Photoshop 26.6.1 on Windows produces purple
`(128,0,128,255)` throughout. Then run, in PowerShell:
```powershell
$env:PHOTOCRAFT_AVERAGE_PSD = 'C:\path\to\average-smart.psd'
cargo test -p photocraft-engine --test it average_photoshop_psd_rerenders_from_source -- --ignored --nocapture
```
This test discards the imported layer cache and the smart-filter stage cache,
then rebuilds from the embedded source. Merely importing or flattening the PSD
would reuse Photoshop's saved pixels and would not prove execution. The local
PSD is not committed and the opt-in test does not raise a corpus floor.
+4 -1
View File
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1,6 +1,6 @@
# PhotoCraft vs Adobe Photoshop: target-app parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; Equalize with a selection asks Selected Area Only or Entire Image Based On Selected Area; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026; Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切;
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; Equalize with a selection asks Selected Area Only or Entire Image Based On Selected Area; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026; Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切;
The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The
one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md);
@@ -162,6 +162,11 @@ people send ×0.95 (JPEG, PNG, HEIC, WebP, PSD open; AVIF and PDF don't, #463, #
## Feature areas
Average now executes through the shared filter pipeline on Smart Objects and imported
PSD records. [Focused evidence](average-filter.md#smart-filters) covers synthetic
RGB8/16/32f cases and an opt-in Photoshop 26.6.1 RGB8 re-render after clearing caches;
this does not raise the broad filter estimate or any corpus floor.
Percent is ready-for-real-work for that area (depth and fidelity, not presence). Hours are to full
parity for the area and are already contained in the dimension rows above.
@@ -607,6 +612,8 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md).
| 2026-10-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged |
| 2026-10-23 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged |
| 2026-10-23 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged |
| 2026-10-23 | minor | Average runs on Smart Objects and imported `Avrg` records; synthetic depth tests and an opt-in cold Photoshop PSD re-render are documented in [average-filter.md](average-filter.md). Corpus floors and percentages unchanged. |
| 2026-10-23 | minor | Average preserves transparency; synthetic Photoshop 26.6.1 RGB8 observations and 8/16/32-bit engine regressions in [average-filter.md](average-filter.md). Broader colour-model and feathering parity remains unmeasured; percentages and corpus floors unchanged. |
| 2026-10-22 | minor | Platforms: Help › Check for Updates and an opt-out check once per launch (#5485), DIST-220-1 partial; percentages unchanged |
| 2026-10-22 | minor | Print: Japanese paper sizes (はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切) in the Print dialog, `file.print` and the PDF page, by their customary names; no percentage changed |
| 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged |