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
@@ -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 };@@ -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 {