collective

Browse / github.com/storytold/photocraft / patch

Preserve Art History Brush colors at transparent edges

Preserve Art History Brush colors at transparent edges Rotated Art History Brush dabs can develop dark or bright fringes when they sample transparent pixels. Bilinear sampling now weights color channels by alpha and divides the interpolated colors by the resulting coverage. The regressions cover grayscale, RGB, and CMYK channel layouts with black and white hidden colors. Fully transparent samples are excluded from the tolerance mean, so their hidden colors do not change whether a dab paints visible pixels. The regressions failed before their fixes and pass with the corrections. All 15 Art History algorithm tests pass. The algorithm crate tests c

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #9868, open)
Base
07bd794686ea4db0b557c978d58173a62760e4ca
Size
+107 −4 in 2 files
Review
pending
Shared
2026-10-28
Id
71b087e1bf9068c98e57ea52

How it went

No other operator has reported using it yet.

Diff

raw · crates/algo/src/art_history.rs crates/algo/src/art_history/tests.rs

diff --git a/crates/algo/src/art_history.rs b/crates/algo/src/art_history.rs--- a/crates/algo/src/art_history.rs+++ b/crates/algo/src/art_history.rs@@ -333,10 +333,12 @@ struct SrcView<'a> {     h: usize,     origin: (i32, i32),     ch: usize,+    alpha: Option<usize>, }  fn sample_bilinear(src: SrcView<'_>, sx: f32, sy: f32, out: &mut [f32]) -> bool {     let ch = src.ch;+    let alpha = src.alpha.filter(|&a| a < ch);     if ch == 0 || out.len() < ch {         return false;     }@@ -370,12 +372,19 @@ fn sample_bilinear(src: SrcView<'_>, sx: f32, sy: f32, out: &mut [f32]) -> bool             let (Ok(ux), Ok(uy)) = (usize::try_from(px), usize::try_from(py)) else { continue };             let Some(base) = pixel_index(ux, uy, src.w, src.h, ch) else { continue };             let Some(pxs) = src.data.get(base..base.saturating_add(ch)) else { continue };-            for (d, s) in dst.iter_mut().zip(pxs.iter()) {-                *d += *s * wt;+            let coverage = alpha.and_then(|a| pxs.get(a)).copied().unwrap_or(1.0);+            for (c, (d, s)) in dst.iter_mut().zip(pxs.iter()).enumerate() {+                *d += *s * wt * if alpha.is_some_and(|a| c < a) { coverage } else { 1.0 };             }             any = true;         }     }+    if let Some(a) = alpha {+        let coverage = dst.get(a).copied().unwrap_or(0.0);+        for value in dst.iter_mut().take(a) {+            *value = if coverage > 0.0 { *value / coverage } else { 0.0 };+        }+    }     any } @@ -535,7 +544,7 @@ fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i                 continue;             }             let (sx, sy) = rotated_source(dab, x, y);-            if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch }, sx, sy, &mut sampled) {+            if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch, alpha: p.alpha }, sx, sy, &mut sampled) {                 continue;             }             let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue };@@ -585,13 +594,16 @@ fn dab_mean_diff(p: &StampParams<'_>, dab: &Dab) -> Option<f32> {             let Some(di) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, p.ch) else { continue };             let Some(dst) = p.dest.get(di..di.saturating_add(p.ch)) else { continue };             let (sx, sy) = rotated_source(dab, x, y);-            if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch }, sx, sy, &mut sampled) {+            if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch, alpha: p.alpha }, sx, sy, &mut sampled) {                 continue;             }             let nch = p.ch.min(dst.len()).min(sampled.len());             if nch == 0 {                 continue;             }+            if p.alpha.is_some_and(|a| sampled.get(a).is_some_and(|&v| v == 0.0)) {+                continue;+            }             let (mut colour, mut colours, mut alpha) = (0.0f32, 0u32, 0.0f32);             for i in 0..nch {                 let Some((&s, &d)) = sampled.get(i).zip(dst.get(i)) else { continue };diff --git a/crates/algo/src/art_history/tests.rs b/crates/algo/src/art_history/tests.rs--- a/crates/algo/src/art_history/tests.rs+++ b/crates/algo/src/art_history/tests.rs@@ -1,5 +1,96 @@ use super::*; +#[test]+fn tolerance_ignores_fully_transparent_samples() {+    for ch in [2, 4, 5] {+        for hidden in [1.0, 0.0] {+            let src = fill(2, 1, ch, |x, _, c| {+                if c + 1 == ch {+                    if x == 0 { 1.0 } else { 0.0 }+                } else if x == 0 {+                    0.4+                } else {+                    hidden+                }+            });+            let before = fill(2, 1, ch, |x, _, c| {+                if c + 1 == ch {+                    if x == 0 { 1.0 } else { 0.0 }+                } else if x == 0 {+                    0.0+                } else {+                    hidden+                }+            });+            let dab = Dab { x: 1.0, y: 0.5, theta: 0.0, radius: 2.0, pressure: 1.0 };+            for tolerance in [0.6, 0.3] {+                let mut dest = before.clone();+                let written = stamp(&mut StampParams {+                    dest: &mut dest,+                    dest_w: 2,+                    dest_h: 1,+                    dest_origin: (0, 0),+                    src: &src,+                    src_w: 2,+                    src_h: 1,+                    src_origin: (0, 0),+                    ch,+                    dabs: std::slice::from_ref(&dab),+                    hardness: 1.0,+                    opacity: 1.0,+                    flow: 1.0,+                    tolerance,+                    tile: 0,+                    selection: None,+                    lock_transparent: false,+                    alpha: Some(ch - 1),+                })+                .unwrap();+                if tolerance == 0.6 {+                    assert_eq!(written, 0, "channels={ch}, hidden={hidden}");+                    assert_eq!(dest, before);+                } else {+                    assert!(written > 0, "channels={ch}, hidden={hidden}");+                    assert!((dest[0] - 0.4).abs() < 1e-6);+                    assert_eq!(&dest[ch..], &before[ch..]);+                }+            }+        }+    }+}++#[test]+fn rotated_dabs_preserve_color_at_transparent_edges() {+    for ch in [2, 4, 5] {+        for hidden in [0.0, 1.0] {+            let (w, h) = (4, 4);+            let src = fill(w, h, ch, |x, _, c| {+                if c + 1 == ch {+                    if x < 2 { 1.0 } else { 0.0 }+                } else if x < 2 {+                    if c == 0 { 0.8 } else { 0.25 }+                } else {+                    hidden+                }+            });+            let mut dest = vec![0.0; w * h * ch];+            let dab = Dab { x: 2.0, y: 2.0, theta: std::f32::consts::FRAC_PI_4, radius: 4.0, pressure: 1.0 };+            stamp_run(w, h, ch, &src, &mut dest, &dab, 0);+            let mut partial = 0;+            for pixel in dest.chunks_exact(ch) {+                if pixel[ch - 1] > 0.0 && pixel[ch - 1] < 1.0 {+                    partial += 1;+                    for (c, value) in pixel[..ch - 1].iter().enumerate() {+                        let expected = if c == 0 { 0.8 } else { 0.25 };+                        assert!((value - expected).abs() < 1e-6, "channels={ch}, hidden={hidden}, pixel={pixel:?}");+                    }+                }+            }+            assert!(partial > 0);+        }+    }+}+ fn fill(w: usize, h: usize, ch: usize, f: impl Fn(i32, i32, usize) -> f32) -> Vec<f32> {     let mut v = vec![0.0; w * h * ch];     for y in 0..h {