mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-07 12:12:47 +00:00
M2: shrink masked images a row at a time; keep near-1:1 scans sharp
Review of #3858 (aiongg): - An area-shrunk image with a soft mask, a stencil's ink included, built its full-resolution RGBA before shrinking. A 10718 x 13701 stencil at page size peaked at 1.16 GB (587 MB of RGBA). It is now premultiplied a source row at a time as it is averaged, and the shrink works a target row at a time, so it peaks at 1.03 GB, as the nearest-neighbour path did. Same bytes; about 5% slower per shrink. - An image whose f32 scale passed 1 only by rounding was smoothed bilinearly and blurred by half a pixel at a half-pixel offset. It is now smoothed only above 1.01. - A shrunk /ImageMask stencil keeping its thin strokes (#4800) is now tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5ed2f4ba4f
commit
c5065554a1
@@ -6814,6 +6814,59 @@ fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() {
|
||||
assert_eq!(grays[3], 255, "{grays:?}");
|
||||
}
|
||||
|
||||
/// Issue #4800: scanners save the text of a page as a 1-bit `/ImageMask` stencil, painted in the
|
||||
/// fill colour, and a stencil shrunk by nearest-neighbour lost the strokes thinner than the shrink
|
||||
/// factor. Here a one-column red stroke of an 8-column stencil is drawn 4 pixels wide: nearest
|
||||
/// reads columns 1, 3, 5 and 7 and misses it, while the area average makes it a half-covered red.
|
||||
#[test]
|
||||
fn a_shrunk_stencil_mask_keeps_thin_strokes() {
|
||||
// Sample 0 paints (the default `/Decode [0 1]`), so 0xDF is a stroke in column 2.
|
||||
let content = "q 1 0 0 rg 4 0 0 1 0 0 cm /Im0 Do Q";
|
||||
let pdf = format!(
|
||||
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ImageMask true /Filter /ASCIIHexDecode /Length 3 >> stream\nDF>\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF\n",
|
||||
content.len()
|
||||
);
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
assert_eq!((page.width, page.height), (4, 1));
|
||||
let px = page.rgba.as_chunks::<4>().0.to_vec();
|
||||
let [r, g, b, _] = px.get(1).copied().unwrap_or_default();
|
||||
assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got {px:?}");
|
||||
assert_eq!(px.get(3), Some(&[255, 255, 255, 255]), "{px:?}");
|
||||
}
|
||||
|
||||
/// A scan drawn at its own resolution keeps its hard pixels when its scale passes 1 only by
|
||||
/// rounding: `x_scale` is an `f32`, and a 192 dpi scan on a 2× display at 100% can come out a
|
||||
/// few ulps above 1. Smoothed bilinearly at a half-pixel offset, one-pixel stripes became gray.
|
||||
/// Here `600.0001` (an `f32` of 600.00012) draws 600 samples 2·10⁻⁷ wider than 1:1.
|
||||
#[test]
|
||||
fn a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp() {
|
||||
let content = "q 600.0001 0 0 600 0.5 0 cm /Im0 Do Q";
|
||||
let image: String = (0..600 * 600).map(|i| if i % 2 == 0 { "00" } else { "FF" }).collect();
|
||||
let pdf = format!(
|
||||
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 601 600] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /XObject /Subtype /Image /Width 600 /Height 600 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF\n",
|
||||
content.len(),
|
||||
image.len() + 1
|
||||
);
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
let row: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().skip(300 * page.width as usize).take(page.width as usize).map(|[r, ..]| *r).collect();
|
||||
let gray = row.iter().skip(10).take(580).filter(|v| (16..240).contains(*v)).count();
|
||||
assert_eq!(gray, 0, "one-pixel stripes were blurred: {:?}", row.get(10..30));
|
||||
}
|
||||
|
||||
/// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their
|
||||
/// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels
|
||||
/// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic
|
||||
|
||||
@@ -734,8 +734,8 @@ milestone = "M2"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["page_render"]
|
||||
evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"]
|
||||
notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes (it was nearest-neighbour, which dropped them). Enlarged, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat"
|
||||
evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"]
|
||||
notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat"
|
||||
|
||||
[[feature]]
|
||||
id = "view.render-shadings-patterns"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+211
-44
@@ -302,6 +302,12 @@ fn nearest_premultiplied_rgba(
|
||||
/// icons, QR codes and pixel art are usually far smaller and keep their hard pixels.
|
||||
const SMOOTHED_IMAGE_SIDE: u32 = 512;
|
||||
|
||||
/// PdfCraft patch: the scale above which such an image counts as enlarged. A scan drawn at its own
|
||||
/// resolution can come out a few ulps above 1 (the scale is an `f32`: a 192 dpi scan on a 2×
|
||||
/// display at 100%), and sampled bilinearly at a half-pixel offset it blurred by half a pixel.
|
||||
/// Below 1% nearest-neighbour repeats at most one sample in a hundred.
|
||||
const SMOOTHED_IMAGE_SCALE: f32 = 1.01;
|
||||
|
||||
/// PdfCraft patch: fixed-point unit of [`area_shrink`]'s weights.
|
||||
const AREA_ONE: u64 = 1 << 16;
|
||||
|
||||
@@ -365,60 +371,132 @@ impl AreaAxis {
|
||||
/// weighted by how much of it it covers. Nothing is invented between samples, which is what
|
||||
/// `/Interpolate` governs (ISO 32000-2 §8.9.5.3, for images drawn above their resolution), and
|
||||
/// nothing is dropped: nearest-neighbour keeps one sample in N per axis, so a scan shown at screen
|
||||
/// size lost the strokes of its text. Premultiplied input stays premultiplied. Work memory is one
|
||||
/// source row of `u32` accumulators, the horizontal taps (at most `sw + 2 × tw`) and a vertically
|
||||
/// shrunk copy no larger than the source; a side over `MAX_RESAMPLING_SOURCE_SIDE` is refused, so
|
||||
/// none of it exceeds a few times one source row. `None` if nothing shrinks, the sizes don't fit
|
||||
/// or a buffer cannot be allocated.
|
||||
/// size lost the strokes of its text. Premultiplied input stays premultiplied. `None` if nothing
|
||||
/// shrinks, the sizes don't fit or a buffer cannot be allocated.
|
||||
fn area_shrink<const N: usize>(data: &[u8], source: (u32, u32), target: (u32, u32)) -> Option<Vec<u8>> {
|
||||
if data.len() != source_byte_len(source.0, source.1, N, MAX_IMAGE_PIXELS)? {
|
||||
return None;
|
||||
}
|
||||
let row_len = (source.0 as usize).checked_mul(N)?;
|
||||
area_shrink_rows::<N>(source, target, |s, row| {
|
||||
let start = s.checked_mul(row_len)?;
|
||||
row.copy_from_slice(data.get(start..start.checked_add(row_len)?)?);
|
||||
Some(())
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch: [`area_shrink`] of a colour image under its same-size soft mask (a stencil's
|
||||
/// ink under the stencil, or an image under a mask sampled onto its grid), premultiplied a source
|
||||
/// row at a time as it is averaged. The result equals premultiplying the expanded RGBA and
|
||||
/// shrinking that, without building it: a 10718 × 13701 stencil shown at page size peaked at
|
||||
/// 1.16 GB with the full-resolution RGBA, which is 587 MB.
|
||||
fn area_shrink_premultiplied(
|
||||
color: &ImageData,
|
||||
alpha: &[u8],
|
||||
source: (u32, u32),
|
||||
target: (u32, u32),
|
||||
) -> Option<Vec<u8>> {
|
||||
let (samples, channels) = match color {
|
||||
ImageData::Luma(luma) => (luma.data.as_slice(), 1),
|
||||
ImageData::Rgb(rgb) => (rgb.data.as_slice(), 3),
|
||||
};
|
||||
if samples.len() != source_byte_len(source.0, source.1, channels, MAX_IMAGE_PIXELS)?
|
||||
|| alpha.len() != source_byte_len(source.0, source.1, 1, MAX_IMAGE_PIXELS)?
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let width = source.0 as usize;
|
||||
area_shrink_rows::<4>(source, target, |s, row| {
|
||||
let start = s.checked_mul(width)?;
|
||||
let end = start.checked_add(width)?;
|
||||
let colors = samples.get(start.checked_mul(channels)?..end.checked_mul(channels)?)?;
|
||||
let alphas = alpha.get(start..end)?;
|
||||
for ((out, px), a) in row.chunks_exact_mut(4).zip(colors.chunks_exact(channels)).zip(alphas) {
|
||||
let [r, g, b] = match px {
|
||||
[v] => [*v; 3],
|
||||
[r, g, b] => [*r, *g, *b],
|
||||
_ => return None,
|
||||
};
|
||||
out.copy_from_slice(&AlphaColor::from_rgba8(r, g, b, *a).premultiply().to_rgba8().to_u8_array());
|
||||
}
|
||||
Some(())
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the area shrink behind [`area_shrink`], reading source row `s` through
|
||||
/// `read_row(s, row)` into a buffer of one row. Each target row averages the source rows it
|
||||
/// covers, and that row is shrunk across at once with taps worked out once per image, so work
|
||||
/// memory is one source row of samples, one of `u32` accumulators, one shrunk row and the
|
||||
/// horizontal taps (at most `sw + 2 × tw`): never the image. A side over
|
||||
/// `MAX_RESAMPLING_SOURCE_SIDE` is refused, so none of it exceeds a few times one source row.
|
||||
fn area_shrink_rows<const N: usize>(
|
||||
source: (u32, u32),
|
||||
target: (u32, u32),
|
||||
mut read_row: impl FnMut(usize, &mut [u8]) -> Option<()>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let ((sw, sh), (tw, th)) = (source, target);
|
||||
// A side over MAX_RESAMPLING_SOURCE_SIDE would make the accumulator row and the tap table
|
||||
// several times larger than the samples (a 2^27 × 2 image of zeros is a tiny file).
|
||||
if tw == 0 || th == 0 || tw > sw || th > sh || sw > MAX_RESAMPLING_SOURCE_SIDE || sh > MAX_RESAMPLING_SOURCE_SIDE {
|
||||
return None;
|
||||
}
|
||||
let (sw_us, tw_us) = (sw as usize, tw as usize);
|
||||
if data.len() != source_byte_len(sw, sh, N, MAX_IMAGE_PIXELS)? {
|
||||
if tw == 0
|
||||
|| th == 0
|
||||
|| tw > sw
|
||||
|| th > sh
|
||||
|| (tw, th) == (sw, sh)
|
||||
|| sw > MAX_RESAMPLING_SOURCE_SIDE
|
||||
|| sh > MAX_RESAMPLING_SOURCE_SIDE
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let row_len = (sw as usize).checked_mul(N)?;
|
||||
let zeroed = |len: usize| {
|
||||
let mut v = image_buffer(len)?;
|
||||
v.resize(len, 0);
|
||||
Some(v)
|
||||
};
|
||||
let (mut source_row, mut shrunk_row) = (zeroed(row_len)?, image_buffer(row_len)?);
|
||||
let mut acc: Vec<u32> = Vec::new();
|
||||
acc.try_reserve_exact(row_len).ok()?;
|
||||
acc.resize(row_len, 0);
|
||||
// The taps depend only on the widths, so they are worked out once, not again for each row.
|
||||
let taps = if tw == sw { None } else { Some(AreaAxis::new(sw, tw)?) };
|
||||
let out_len = match taps {
|
||||
None => source_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?,
|
||||
Some(_) => image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?,
|
||||
};
|
||||
let mut out = image_buffer(out_len)?;
|
||||
// Weights sum to AREA_ONE (2^16) and samples are at most 255, so every sum fits in a u32.
|
||||
let round = |acc: u32| ((acc + (AREA_ONE as u32) / 2) >> 16).min(255) as u8;
|
||||
// Vertical pass first: groups of whole source rows averaged into each target row. Rows are
|
||||
// contiguous, so this pass is cheap, and it leaves the horizontal pass, which gathers samples
|
||||
// per target, a third as many rows when a scan shrinks threefold.
|
||||
let tall = if th == sh {
|
||||
None
|
||||
} else {
|
||||
let row_len = sw_us * N;
|
||||
let mut rows = image_buffer(source_byte_len(sw, th, N, MAX_IMAGE_PIXELS)?)?;
|
||||
let mut acc: Vec<u32> = Vec::new();
|
||||
acc.try_reserve_exact(row_len).ok()?;
|
||||
acc.resize(row_len, 0);
|
||||
for t in 0..th {
|
||||
// The last source row read: neighbouring target rows share the row on their boundary.
|
||||
let mut loaded = None;
|
||||
for t in 0..th {
|
||||
// Vertical: the whole source rows target row `t` covers, averaged.
|
||||
let row: &[u8] = if th == sh {
|
||||
read_row(t as usize, &mut source_row)?;
|
||||
&source_row
|
||||
} else {
|
||||
acc.fill(0);
|
||||
for (s, w) in area_taps(sh, th, t) {
|
||||
let row = data.get(s * row_len..(s + 1) * row_len)?;
|
||||
if loaded != Some(s) {
|
||||
read_row(s, &mut source_row)?;
|
||||
loaded = Some(s);
|
||||
}
|
||||
// A weight is at most AREA_ONE, so it fits in a u32.
|
||||
let w = w as u32;
|
||||
for (a, v) in acc.iter_mut().zip(row) {
|
||||
for (a, v) in acc.iter_mut().zip(&source_row) {
|
||||
*a += w * u32::from(*v);
|
||||
}
|
||||
}
|
||||
rows.extend(acc.iter().map(|a| round(*a)));
|
||||
}
|
||||
Some(rows)
|
||||
};
|
||||
if tw == sw {
|
||||
return tall;
|
||||
}
|
||||
let rows = tall.as_deref().unwrap_or(data);
|
||||
// Horizontal pass: each of those rows shrunk to `tw` samples. The taps depend only on the
|
||||
// widths, so they are worked out once, not again for each row.
|
||||
let taps = AreaAxis::new(sw, tw)?;
|
||||
let mut out = image_buffer(image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?)?;
|
||||
for row in rows.chunks_exact(sw_us * N) {
|
||||
for t in 0..tw_us {
|
||||
let (first, weights) = taps.get(t)?;
|
||||
shrunk_row.clear();
|
||||
shrunk_row.extend(acc.iter().map(|a| round(*a)));
|
||||
&shrunk_row
|
||||
};
|
||||
// Horizontal: that row shrunk to `tw` samples.
|
||||
let Some(taps) = &taps else {
|
||||
out.extend_from_slice(row);
|
||||
continue;
|
||||
};
|
||||
for x in 0..tw as usize {
|
||||
let (first, weights) = taps.get(x)?;
|
||||
let samples = row.get(first * N..(first + weights.len()) * N)?;
|
||||
let mut acc = [0_u32; N];
|
||||
for (w, px) in weights.iter().zip(samples.chunks_exact(N)) {
|
||||
@@ -1263,7 +1341,7 @@ impl Renderer {
|
||||
// nothing, and the stretched side would otherwise be drawn nearest-neighbour.
|
||||
if !interpolate
|
||||
&& !self.in_type3_glyph
|
||||
&& (x_scale > 1.0 || y_scale > 1.0)
|
||||
&& (x_scale > SMOOTHED_IMAGE_SCALE || y_scale > SMOOTHED_IMAGE_SCALE)
|
||||
&& source_width.min(source_height) >= SMOOTHED_IMAGE_SIDE
|
||||
{
|
||||
quality = ImageQuality::Medium;
|
||||
@@ -1295,9 +1373,12 @@ impl Renderer {
|
||||
};
|
||||
let needs_resize = (new_width, new_height) != (source_width, source_height);
|
||||
|
||||
// PdfCraft patch (#624): a minified Type 3 glyph image with a soft mask that is not
|
||||
// interpolated is nearest-sampled straight into premultiplied target RGBA. That skips the
|
||||
// full-resolution RGBA copy below and the premultiply pass after it.
|
||||
// PdfCraft patch (#624): a minified image with a soft mask that is neither interpolated nor
|
||||
// area-averaged (a Type 3 glyph image, or a source wider than MAX_RESAMPLING_SOURCE_SIDE)
|
||||
// is nearest-sampled straight into premultiplied target RGBA. That skips the
|
||||
// full-resolution RGBA copy below and the premultiply pass after it. PdfCraft patch: an
|
||||
// area-averaged one (a stencil's ink, or an image under its mask) skips them too, and is
|
||||
// premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`).
|
||||
let direct = match &alpha_data {
|
||||
Some(alpha) if needs_resize && !interpolate && !area => nearest_premultiplied_rgba(
|
||||
&image_data,
|
||||
@@ -1305,6 +1386,12 @@ impl Renderer {
|
||||
(source_width, source_height),
|
||||
(new_width, new_height),
|
||||
),
|
||||
Some(alpha) if needs_resize && area => area_shrink_premultiplied(
|
||||
&image_data,
|
||||
&alpha.data,
|
||||
(source_width, source_height),
|
||||
(new_width, new_height),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let mut premultiplied = direct.is_some();
|
||||
@@ -2935,9 +3022,89 @@ mod snap_tests {
|
||||
#[cfg(test)]
|
||||
mod area_shrink_tests {
|
||||
use super::{
|
||||
AREA_ONE, AreaAxis, LumaData, MAX_IMAGE_PIXELS, area_shrink, area_taps, mask_on_image_grid,
|
||||
AREA_ONE, AreaAxis, ImageData, LumaData, MAX_IMAGE_PIXELS, RgbData, area_shrink,
|
||||
area_shrink_premultiplied, area_taps, mask_on_image_grid, premultiply_rgba,
|
||||
};
|
||||
|
||||
/// Deterministic samples that vary from one to the next.
|
||||
fn noise(len: usize, seed: u32) -> Vec<u8> {
|
||||
let mut x = seed.wrapping_mul(2_654_435_761) | 1;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
(x >> 24) as u8
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The shrink as two whole passes, every row averaged down and then every row across, each
|
||||
/// rounded to a byte: what `area_shrink` did before it worked a row at a time.
|
||||
fn two_pass_shrink<const N: usize>(data: &[u8], (sw, sh): (u32, u32), (tw, th): (u32, u32)) -> Vec<u8> {
|
||||
let round = |acc: u64| ((acc + AREA_ONE / 2) >> 16).min(255) as u8;
|
||||
let row_len = sw as usize * N;
|
||||
let tall: Vec<u8> = (0..th)
|
||||
.flat_map(|t| {
|
||||
(0..row_len).map(move |i| {
|
||||
round(area_taps(sh, th, t).map(|(s, w)| w * u64::from(data[s * row_len + i])).sum())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
tall.chunks_exact(row_len)
|
||||
.flat_map(|row| {
|
||||
(0..tw).flat_map(move |t| {
|
||||
(0..N).map(move |c| round(area_taps(sw, tw, t).map(|(s, w)| w * u64::from(row[s * N + c])).sum()))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Working a row at a time gives the same bytes as two whole passes, in both directions, one
|
||||
/// direction alone, and with source rows shared by neighbouring targets.
|
||||
#[test]
|
||||
fn a_row_at_a_time_shrink_equals_two_whole_passes() {
|
||||
let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((13, 11), (1, 1)), ((31, 17), (12, 7))];
|
||||
for (i, (source, target)) in sizes.into_iter().enumerate() {
|
||||
let gray = noise((source.0 * source.1) as usize, i as u32);
|
||||
assert_eq!(area_shrink::<1>(&gray, source, target), Some(two_pass_shrink::<1>(&gray, source, target)), "{source:?} → {target:?}");
|
||||
let rgba = noise((source.0 * source.1 * 4) as usize, i as u32 + 99);
|
||||
assert_eq!(area_shrink::<4>(&rgba, source, target), Some(two_pass_shrink::<4>(&rgba, source, target)), "{source:?} → {target:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplying a row at a time as it is averaged gives the same bytes as premultiplying the
|
||||
/// whole expanded RGBA first and shrinking that, for gray and RGB colour.
|
||||
#[test]
|
||||
fn premultiplying_row_by_row_equals_premultiplying_the_whole_image() {
|
||||
let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((31, 17), (12, 7))];
|
||||
for (i, (source, target)) in sizes.into_iter().enumerate() {
|
||||
let (w, h) = source;
|
||||
let pixels = (w * h) as usize;
|
||||
let alpha = noise(pixels, i as u32 + 7);
|
||||
let gray = noise(pixels, i as u32 + 11);
|
||||
let rgb = noise(pixels * 3, i as u32 + 13);
|
||||
let colors = [
|
||||
ImageData::Luma(LumaData { data: gray.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }),
|
||||
ImageData::Rgb(RgbData { data: rgb.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }),
|
||||
];
|
||||
for color in &colors {
|
||||
let mut whole: Vec<u8> = match color {
|
||||
ImageData::Luma(_) => gray.iter().zip(&alpha).flat_map(|(g, a)| [*g, *g, *g, *a]).collect(),
|
||||
ImageData::Rgb(_) => rgb.chunks_exact(3).zip(&alpha).flat_map(|(c, a)| [c[0], c[1], c[2], *a]).collect(),
|
||||
};
|
||||
premultiply_rgba(&mut whole);
|
||||
assert_eq!(
|
||||
area_shrink_premultiplied(color, &alpha, source, target),
|
||||
area_shrink::<4>(&whole, source, target),
|
||||
"{source:?} → {target:?}"
|
||||
);
|
||||
}
|
||||
let short = alpha.get(1..).unwrap_or_default();
|
||||
assert_eq!(area_shrink_premultiplied(&colors[0], short, source, target), None, "alpha shorter than the image");
|
||||
}
|
||||
}
|
||||
|
||||
fn mask(data: &[u8], width: u32, height: u32) -> LumaData {
|
||||
LumaData { data: data.to_vec(), width, height, interpolate: false, scale_factors: (1.0, 1.0) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user