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:
EthanSuquet 2026-10-07 16:33:04 -04:00 committed by GitHub
parent 06ec4044ea
commit 135ae8cf96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 95 additions and 10 deletions

View File

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

View File

@ -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();