Image Size keeps the canvas border opaque instead of fading it (#646)
Resampling read the pixels beyond a layer's content as the default pixel (transparent for layers, white for masks). For content that reaches the canvas edge, such as a Background or any full-canvas layer, the filter taps past the border mixed that in, so after Image Size the outermost rows and columns were translucent: a white Background enlarged 200 % came out with alpha 162/255 in the corners and 203/255 along the edges. Export As at any scale other than 100 %, and Generate Image Assets at 200 %, run Image Size first, so every such export had a translucent frame. Channels, the quick mask and the selection faded the same way. Image Size now resamples layers, masks, channels and the selection with resize_surface_in_canvas: taps past the canvas edge (or past a layer's own pixels where they extend beyond the canvas) read the nearest edge sample. Inside the canvas nothing changes, so a layer away from the edge keeps its soft border. resize_surface itself, used by transforms and placing, is unchanged. Tests: canvas_edges_repeat_instead_of_fading (algo: every filter, up and down, 8, 16 and 32 bits; inner layers unchanged) and image_size_keeps_canvas_edges_opaque (engine; fails before: corner alpha 0.635), which also checks a full selection stays full to the edge. Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
06ec4044ea
commit
135ae8cf96
|
|
@ -71,15 +71,17 @@ struct Taps {
|
|||
}
|
||||
|
||||
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source
|
||||
/// via `u = (o + 0.5) / scale - 0.5`.
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> Vec<Taps> {
|
||||
/// via `u = (o + 0.5) / scale - 0.5`. With `edge` (`[first, last]` source index), taps outside it
|
||||
/// read the nearest sample inside it: the image repeats its edge instead of fading out.
|
||||
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i32)>) -> Vec<Taps> {
|
||||
let stretch = if scale < 1.0 { 1.0 / scale } else { 1.0 };
|
||||
let support = filter.support() * stretch;
|
||||
let clamp = |i: i32| edge.map_or(i, |(a, b)| i.clamp(a, b));
|
||||
(dst0..dst1)
|
||||
.map(|o| {
|
||||
let u = (o as f64 + 0.5) / scale - 0.5;
|
||||
if filter == Resample::Nearest && scale >= 1.0 {
|
||||
return Taps { start: (u + 0.5).floor() as i32, weights: vec![1.0] };
|
||||
return Taps { start: clamp((u + 0.5).floor() as i32), weights: vec![1.0] };
|
||||
}
|
||||
let lo = (u - support).ceil() as i32;
|
||||
let hi = (u + support).floor() as i32;
|
||||
|
|
@ -90,7 +92,15 @@ fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> Vec<Taps> {
|
|||
*v /= s;
|
||||
}
|
||||
}
|
||||
Taps { start: lo, weights: w.into_iter().map(|v| v as f32).collect() }
|
||||
// Fold the weights of taps beyond the edge onto the edge sample.
|
||||
let start = clamp(lo);
|
||||
let mut folded = vec![0.0f32; (clamp(hi) - start + 1).max(1) as usize];
|
||||
for (i, v) in (lo..=hi).zip(w) {
|
||||
if let Some(slot) = folded.get_mut((clamp(i) - start) as usize) {
|
||||
*slot += v as f32;
|
||||
}
|
||||
}
|
||||
Taps { start, weights: folded }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -106,6 +116,19 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect {
|
|||
/// Resizes a surface by `(sx, sy)` about the document origin. The surface's
|
||||
/// default pixel is kept (masks keep their "reveal all" background).
|
||||
pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface {
|
||||
resize(s, sx, sy, filter, None)
|
||||
}
|
||||
|
||||
/// [`resize_surface`] for a layer, mask or channel of a document whose canvas is `canvas`
|
||||
/// (Image Size): at the canvas edges the pixels repeat outwards, so content that reaches an edge
|
||||
/// keeps it (an opaque layer stays opaque to the border) instead of fading into the default pixel.
|
||||
/// Pixels beyond the canvas count as the surface's own, and inside the canvas nothing changes.
|
||||
pub fn resize_surface_in_canvas(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Rect) -> Surface {
|
||||
let area = s.content_bounds().union(&canvas);
|
||||
resize(s, sx, sy, filter, (!area.is_empty()).then_some(area))
|
||||
}
|
||||
|
||||
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
|
||||
let fmt = s.format();
|
||||
let mut out = Surface::with_default(fmt, &s.default_pixel());
|
||||
let src = s.content_bounds();
|
||||
|
|
@ -115,8 +138,8 @@ pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surfac
|
|||
}
|
||||
let n = fmt.channels();
|
||||
let alpha = fmt.alpha;
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter);
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter);
|
||||
let hx = taps(dst.x0, dst.x1, sx, filter, edge.map(|r| (r.x0, r.x1 - 1)));
|
||||
let vy = taps(dst.y0, dst.y1, sy, filter, edge.map(|r| (r.y0, r.y1 - 1)));
|
||||
let x_lo = hx.iter().map(|t| t.start).min().unwrap_or(0);
|
||||
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(0);
|
||||
let dw = dst.width() as usize;
|
||||
|
|
@ -268,6 +291,38 @@ mod tests {
|
|||
assert!(edge[3] > 0.0 && (edge[0] - 1.0).abs() < 0.05 && edge[1].abs() < 0.05, "{edge:?}");
|
||||
}
|
||||
|
||||
/// Image Size of a layer that fills the canvas: its edges stay opaque and keep their colour,
|
||||
/// up or down, at every depth; a layer away from the edges keeps its soft border.
|
||||
#[test]
|
||||
fn canvas_edges_repeat_instead_of_fading() {
|
||||
let canvas = Rect::new(0, 0, 30, 20);
|
||||
let colour = [0.2, 0.6, 1.0, 1.0];
|
||||
let filters = [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos, Resample::PreserveDetails];
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(canvas, &colour);
|
||||
for f in filters {
|
||||
for k in [2.0, 1.37, 0.5] {
|
||||
let o = resize_surface_in_canvas(&s, k, k, f, canvas);
|
||||
let dst = scaled_rect(canvas, k, k);
|
||||
assert_eq!(o.content_bounds(), dst, "{sample:?} {f:?} {k}");
|
||||
for (x, y) in [(0, 0), (dst.x1 - 1, 0), (0, dst.y1 - 1), (dst.x1 - 1, dst.y1 - 1), (dst.x1 / 2, 0), (0, dst.y1 / 2)] {
|
||||
let p = o.pixel(x, y);
|
||||
let off = p.iter().zip(colour).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(off < 0.01, "{sample:?} {f:?} {k} ({x},{y}): {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inside the canvas the default pixel is still what lies around a layer.
|
||||
let mut inner = Surface::new(fmt);
|
||||
inner.fill_rect(Rect::new(10, 5, 20, 15), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let r = Rect::new(0, 0, 60, 40);
|
||||
let a = resize_surface_in_canvas(&inner, 1.7, 1.7, Resample::Lanczos, canvas);
|
||||
assert_eq!(a.read_region(r), resize_surface(&inner, 1.7, 1.7, Resample::Lanczos).read_region(r), "{sample:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_keep_default_pixel() {
|
||||
let fmt = PixelFormat::GRAY8;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and
|
||||
//! bit depth) conversions, Duplicate.
|
||||
|
||||
use photocraft_algo::resample::{Resample, crop_surface, resize_surface, translate_surface};
|
||||
use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, Size};
|
||||
use photocraft_geom::Rect;
|
||||
|
|
@ -125,16 +125,18 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 {
|
||||
return Ok(());
|
||||
}
|
||||
// Content that reaches the canvas edge keeps it: a Background stays opaque to the border.
|
||||
let canvas = Rect::new(0, 0, ow as i32, oh as i32);
|
||||
for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| {
|
||||
*surf = resize_surface(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter });
|
||||
*surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas);
|
||||
});
|
||||
let k = ((sx + sy) / 2.0) as f32;
|
||||
for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k));
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = resize_surface(&ch.surface, sx, sy, Resample::Bilinear);
|
||||
ch.surface = resize_surface_in_canvas(&ch.surface, sx, sy, Resample::Bilinear, canvas);
|
||||
}
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(resize_surface(sel, sx, sy, Resample::Bilinear));
|
||||
doc.selection = Some(resize_surface_in_canvas(sel, sx, sy, Resample::Bilinear, canvas));
|
||||
}
|
||||
// Vector geometry, guides and marks scale with the pixels; vectors re-render sharp.
|
||||
crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] });
|
||||
|
|
@ -467,6 +469,34 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The canvas border stays opaque after Image Size (it used to fade into transparency, so a
|
||||
/// Background or a 200 % export got a translucent frame), and a full selection stays full.
|
||||
#[test]
|
||||
fn image_size_keeps_canvas_edges_opaque() {
|
||||
for depth in [8, 16, 32] {
|
||||
for (w, resample) in [(60, "bicubic"), (41, "lanczos"), (15, "bilinear"), (77, "preserveDetails")] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 30, "height": 20, "depth": depth, "background": "#336699"})).unwrap();
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.execute("image.imageSize", json!({"width": w, "resample": resample})).unwrap();
|
||||
let d = doc(&s);
|
||||
let flat = photocraft_compose::flatten(d);
|
||||
let (w, h) = (flat.rect.width() as usize, flat.rect.height() as usize);
|
||||
let mid = flat.px[(h / 2) * w + w / 2];
|
||||
assert!((mid[3] - 1.0).abs() < 1e-3, "{depth} {resample}: {mid:?}");
|
||||
for p in &flat.px {
|
||||
let off = p.iter().zip(mid).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(off < 0.01, "{depth} {resample} {w}: {p:?} vs {mid:?}");
|
||||
}
|
||||
let sel = d.selection.as_ref().unwrap();
|
||||
let (x1, y1) = (d.size.width as i32 - 1, d.size.height as i32 - 1);
|
||||
for (x, y) in [(0, 0), (x1, 0), (0, y1), (x1, y1)] {
|
||||
assert!(sel.pixel(x, y)[0] > 0.99, "{depth} {resample}: selection at ({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_only_change() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
Loading…
Reference in New Issue