feat: Lancsoz downsampler when target size is relatively too small

This commit is contained in:
Hojjat 2026-09-01 18:02:37 -06:00 • committed by Ashley Wulber
parent cc5ca1ce29
commit 511180679b
4 changed files with 233 additions and 2 deletions

View file

@ -1,6 +1,8 @@
//! Load and operate on images.
#[cfg(feature = "image")]
use crate::core::Bytes;
#[cfg(feature = "image")]
use crate::core::Size;
use crate::core::Color;
use crate::core::Radians;
@ -180,3 +182,105 @@ fn to_error(error: ::image::ImageError) -> image::Error {
error => image::Error::Invalid(Arc::new(error)),
}
}
/// resample a raster image to this target
#[cfg(feature = "image")]
pub fn downsample_target(
native: Size<u32>,
bounds: Size<f32>,
) -> Option<Size<u32>> {
if !(bounds.width >= 1.0 && bounds.height >= 1.0) {
return None;
}
// Round up to 4px so an animated resize does not make a copy per pixel
// TODO: maybe find a better approach
let quantize = |length: f32| (length.ceil() as u32).div_ceil(4) * 4;
let target = Size::new(
quantize(bounds.width).min(native.width),
quantize(bounds.height).min(native.height),
);
let minified = native.width as f32 >= target.width as f32 * 1.25
|| native.height as f32 >= target.height as f32 * 1.25;
// If the target is big then Lanczos will be too expensive, and sampler is good enough
let small = u64::from(target.width) * u64::from(target.height) <= 1 << 18;
(minified && small).then_some(target)
}
/// Resamples premultiplied pixels down to `target`.
#[cfg(feature = "image")]
pub fn downsample_premultiplied(
pixels: &[u8],
size: Size<u32>,
target: Size<u32>,
) -> Vec<u8> {
let image =
::image::RgbaImage::from_raw(size.width, size.height, pixels.to_vec())
.expect("pixels hold width * height RGBA pixels");
resize(&image, target).into_raw()
}
#[cfg(feature = "image")]
fn resize(image: &::image::RgbaImage, target: Size<u32>) -> ::image::RgbaImage {
use ::image::imageops::{self, FilterType};
use std::borrow::Cow;
// if image is too big compared to target, box average it to double the target
// and then do Lanczos. The final result is almost the same, but lanczos is too
// expensive on large images.
let image = if image.width() >= target.width * 4
&& image.height() >= target.height * 4
{
Cow::Owned(imageops::thumbnail(
image,
target.width * 2,
target.height * 2,
))
} else {
Cow::Borrowed(image)
};
imageops::resize(&*image, target.width, target.height, FilterType::Lanczos3)
}
/// Resamples RGBA pixels down to `target`.
///
/// Premultiplies the image, to avoid fringing the edges of an icon when interpolating
/// transparent pixels.
#[cfg(feature = "image")]
pub fn downsample(image: &Buffer, target: Size<u32>) -> ::image::RgbaImage {
let mut image = ::image::RgbaImage::from_raw(
image.width(),
image.height(),
image.as_raw().to_vec(),
)
.expect("buffer holds width * height RGBA pixels");
for pixel in image.pixels_mut() {
let alpha = u32::from(pixel[3]);
for channel in &mut pixel.0[..3] {
*channel = ((u32::from(*channel) * alpha + 127) / 255) as u8;
}
}
let mut image = resize(&image, target);
for pixel in image.pixels_mut() {
let alpha = u32::from(pixel[3]);
if alpha > 0 {
for channel in &mut pixel.0[..3] {
*channel = ((u32::from(*channel) * 255 + alpha / 2) / alpha)
.min(255) as u8;
}
}
}
image
}

View file

@ -51,7 +51,21 @@ impl Pipeline {
) {
let mut cache = self.cache.borrow_mut();
let Ok(mut image) = cache.allocate(handle) else {
let target = {
let Ok(image) = cache.allocate(handle) else {
return;
};
graphics::image::downsample_target(
Size::new(image.width(), image.height()),
bounds.size(),
)
};
let Ok(mut image) = (match target {
Some(target) => cache.allocate_resampled(handle, target),
None => cache.allocate(handle),
}) else {
return;
};
@ -110,6 +124,8 @@ impl Pipeline {
struct Cache {
entries: FxHashMap<raster::Id, Option<Entry>>,
hits: FxHashSet<raster::Id>,
resampled: FxHashMap<(raster::Id, u32, u32), Entry>,
resampled_hits: FxHashSet<(raster::Id, u32, u32)>,
}
impl Cache {
@ -166,9 +182,59 @@ impl Cache {
Ok(ret)
}
/// Like [`Self::allocate`], resampled to `target`. Call after
/// [`Self::allocate`] has decoded the image.
pub fn allocate_resampled(
&mut self,
handle: &raster::Handle,
target: Size<u32>,
) -> Result<tiny_skia::PixmapRef<'_>, raster::Error> {
let key = (handle.id(), target.width, target.height);
if !self.resampled.contains_key(&key) {
let native = self
.entries
.get(&handle.id())
.and_then(Option::as_ref)
.ok_or(raster::Error::Empty)?;
// Stored pixels are already premultiplied, so resample them as is.
let pixels = graphics::image::downsample_premultiplied(
bytemuck::cast_slice(&native.pixels),
Size::new(native.width, native.height),
target,
);
let _ = self.resampled.insert(
key,
Entry {
width: target.width,
height: target.height,
pixels: pixels
.chunks_exact(4)
.map(|p| u32::from_ne_bytes([p[0], p[1], p[2], p[3]]))
.collect(),
},
);
}
let _ = self.resampled_hits.insert(key);
let entry = &self.resampled[&key];
Ok(tiny_skia::PixmapRef::from_bytes(
bytemuck::cast_slice(&entry.pixels),
entry.width,
entry.height,
)
.expect("Build pixmap from image bytes"))
}
fn trim(&mut self) {
self.entries.retain(|key, _| self.hits.contains(key));
self.resampled
.retain(|key, _| self.resampled_hits.contains(key));
self.hits.clear();
self.resampled_hits.clear();
}
}

View file

@ -5,6 +5,8 @@ use crate::image::atlas::{self, Atlas};
#[cfg(all(feature = "image", not(target_arch = "wasm32")))]
use worker::Worker;
#[cfg(feature = "image")]
use rustc_hash::{FxHashMap, FxHashSet};
#[cfg(feature = "image")]
use std::collections::HashMap;
@ -38,6 +40,9 @@ impl Cache {
raster: Raster {
cache: crate::image::raster::Cache::default(),
pending: HashMap::new(),
resampled: FxHashMap::default(),
resampled_hits: FxHashSet::default(),
should_trim: false,
belt: wgpu::util::StagingBelt::new(
device.clone(),
2 * 1024 * 1024,
@ -209,7 +214,9 @@ impl Cache {
encoder: &mut wgpu::CommandEncoder,
belt: &mut wgpu::util::StagingBelt,
handle: &core::image::Handle,
bounds: Size<f32>,
) -> Option<(&atlas::Entry, &Arc<wgpu::BindGroup>)> {
use crate::graphics::image::{downsample, downsample_target, load};
use crate::image::raster::Memory;
self.receive();
@ -223,6 +230,34 @@ impl Cache {
None,
)?;
if let Some(target) = downsample_target(memory.dimensions(), bounds) {
let key = (handle.id(), target.width, target.height);
if !self.raster.resampled.contains_key(&key) {
let image = memory.host().or_else(|| load(handle).ok())?;
let image = downsample(&image, target);
let entry = self.atlas.upload(
device,
encoder,
belt,
target.width,
target.height,
&image,
)?;
let _ = self.raster.resampled.insert(key, entry);
self.raster.should_trim = true;
}
let _ = self.raster.resampled_hits.insert(key);
return Some((
self.raster.resampled.get(&key)?,
self.atlas.bind_group(),
));
}
if let Memory::Device {
entry, bind_group, ..
} = memory
@ -303,6 +338,23 @@ impl Cache {
#[cfg(not(target_arch = "wasm32"))]
self.worker.drop(_bind_group);
});
if self.raster.should_trim {
let hits = &self.raster.resampled_hits;
let atlas = &mut self.atlas;
self.raster.resampled.retain(|key, entry| {
let retain = hits.contains(key);
if !retain {
atlas.remove(entry);
}
retain
});
self.raster.resampled_hits.clear();
self.raster.should_trim = false;
}
}
#[cfg(feature = "svg")]
@ -376,6 +428,9 @@ impl Drop for Cache {
struct Raster {
cache: crate::image::raster::Cache,
pending: HashMap<core::image::Id, Vec<Callback>>,
resampled: FxHashMap<(core::image::Id, u32, u32), atlas::Entry>,
resampled_hits: FxHashSet<(core::image::Id, u32, u32)>,
should_trim: bool,
belt: wgpu::util::StagingBelt,
}

View file

@ -267,7 +267,13 @@ impl State {
clip_bounds,
} => {
if let Some((atlas_entry, bind_group)) = cache
.upload_raster(device, encoder, belt, &image.handle)
.upload_raster(
device,
encoder,
belt,
&image.handle,
bounds.size() * scale,
)
{
match atlas.as_mut() {
None => {