From a3b104a2edd7e7a7d2944d8ee06eacc77dc6da8c Mon Sep 17 00:00:00 2001 From: andrews05 Date: Fri, 23 Dec 2022 10:56:36 +1300 Subject: [PATCH] Faster reductions (#479) * Add grayscale depth tests and benches * Don't include filter byte in PngImage.data * Simplify reductions by not using scan lines * Faster grayscale reduction * Simplify reduce_color_type * Faster depth reduction --- benches/reductions.rs | 60 ++++++ src/interlace.rs | 57 ++---- src/png/mod.rs | 19 +- src/png/scan_lines.rs | 65 ++----- src/reduction/alpha.rs | 52 +++-- src/reduction/bit_depth.rs | 135 ++++++------- src/reduction/color.rs | 183 +++++------------- src/reduction/mod.rs | 92 ++++----- .../grayscale_2_should_be_grayscale_1.png | Bin 0 -> 1853 bytes .../grayscale_4_should_be_grayscale_1.png | Bin 0 -> 2023 bytes .../grayscale_4_should_be_grayscale_2.png | Bin 0 -> 4904 bytes .../grayscale_8_should_be_grayscale_1.png | Bin 0 -> 2462 bytes .../grayscale_8_should_be_grayscale_2.png | Bin 0 -> 5229 bytes .../grayscale_8_should_be_grayscale_4.png | Bin 0 -> 14680 bytes tests/reduction.rs | 72 +++++++ 15 files changed, 333 insertions(+), 402 deletions(-) create mode 100644 tests/files/grayscale_2_should_be_grayscale_1.png create mode 100644 tests/files/grayscale_4_should_be_grayscale_1.png create mode 100644 tests/files/grayscale_4_should_be_grayscale_2.png create mode 100644 tests/files/grayscale_8_should_be_grayscale_1.png create mode 100644 tests/files/grayscale_8_should_be_grayscale_2.png create mode 100644 tests/files/grayscale_8_should_be_grayscale_4.png diff --git a/benches/reductions.rs b/benches/reductions.rs index 774c346a..be9b6e06 100644 --- a/benches/reductions.rs +++ b/benches/reductions.rs @@ -75,6 +75,66 @@ fn reductions_2_to_1_bits(b: &mut Bencher) { b.iter(|| bit_depth::reduce_bit_depth(&png.raw, 1)); } +#[bench] +fn reductions_grayscale_8_to_4_bits(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/grayscale_8_should_be_grayscale_4.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| bit_depth::reduce_bit_depth(&png.raw, 1)); +} + +#[bench] +fn reductions_grayscale_8_to_2_bits(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/grayscale_8_should_be_grayscale_2.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| bit_depth::reduce_bit_depth(&png.raw, 1)); +} + +#[bench] +fn reductions_grayscale_8_to_1_bits(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/grayscale_8_should_be_grayscale_1.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| bit_depth::reduce_bit_depth(&png.raw, 1)); +} + +#[bench] +fn reductions_grayscale_4_to_2_bits(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/grayscale_4_should_be_grayscale_2.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| bit_depth::reduce_bit_depth(&png.raw, 1)); +} + +#[bench] +fn reductions_grayscale_4_to_1_bits(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/grayscale_4_should_be_grayscale_1.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| bit_depth::reduce_bit_depth(&png.raw, 1)); +} + +#[bench] +fn reductions_grayscale_2_to_1_bits(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/grayscale_2_should_be_grayscale_1.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| bit_depth::reduce_bit_depth(&png.raw, 1)); +} + #[bench] fn reductions_rgba_to_rgb_16(b: &mut Bencher) { let input = test::black_box(PathBuf::from("tests/files/rgba_16_should_be_rgb_16.png")); diff --git a/src/interlace.rs b/src/interlace.rs index 2ba1a104..8ac0fdc7 100644 --- a/src/interlace.rs +++ b/src/interlace.rs @@ -41,40 +41,7 @@ impl Display for Interlacing { pub fn interlace_image(png: &PngImage) -> PngImage { let mut passes: Vec> = vec![BitVec::new(); 7]; let bits_per_pixel = png.ihdr.bpp(); - for (index, line) in png.scan_lines().enumerate() { - match index % 8 { - // Add filter bytes to passes that will be in the output image - 0 => { - passes[0].extend_from_raw_slice(&[0]); - if png.ihdr.width >= 5 { - passes[1].extend_from_raw_slice(&[0]); - } - if png.ihdr.width >= 3 { - passes[3].extend_from_raw_slice(&[0]); - } - if png.ihdr.width >= 2 { - passes[5].extend_from_raw_slice(&[0]); - } - } - 4 => { - passes[2].extend_from_raw_slice(&[0]); - if png.ihdr.width >= 3 { - passes[3].extend_from_raw_slice(&[0]); - } - if png.ihdr.width >= 2 { - passes[5].extend_from_raw_slice(&[0]); - } - } - 2 | 6 => { - passes[4].extend_from_raw_slice(&[0]); - if png.ihdr.width >= 2 { - passes[5].extend_from_raw_slice(&[0]); - } - } - _ => { - passes[6].extend_from_raw_slice(&[0]); - } - } + for (index, line) in png.scan_lines(false).enumerate() { let bit_vec = line.data.view_bits::(); for (i, bit) in bit_vec.iter().by_vals().enumerate() { // Avoid moving padded 0's into new image @@ -148,15 +115,14 @@ pub fn deinterlace_image(png: &PngImage) -> PngImage { /// Deinterlace by bits, for images with less than 8bpp fn deinterlace_bits(png: &PngImage) -> Vec { let bits_per_pixel = png.ihdr.bpp(); - let bits_per_line = 8 + bits_per_pixel as usize * png.ihdr.width as usize; - // Initialize each output line with a starting filter byte of 0 - // as well as some blank data + let bits_per_line = bits_per_pixel as usize * png.ihdr.width as usize; + // Initialize each output line with blank data let mut lines: Vec> = vec![bitvec![u8, Msb0; 0; bits_per_line]; png.ihdr.height as usize]; let mut current_pass = 1; let mut pass_constants = interlaced_constants(current_pass); let mut current_y: usize = pass_constants.y_shift as usize; - for line in png.scan_lines() { + for line in png.scan_lines(false) { let bit_vec = line.data.view_bits::(); let bits_in_line = ((png.ihdr.width - u32::from(pass_constants.x_shift) + u32::from(pass_constants.x_step) @@ -170,8 +136,8 @@ fn deinterlace_bits(png: &PngImage) -> Vec { } let current_x: usize = pass_constants.x_shift as usize + (i / bits_per_pixel as usize) * pass_constants.x_step as usize; - // Copy this bit into the output line, offset by 8 because of filter byte - let index = 8 + (i % bits_per_pixel as usize) + current_x * bits_per_pixel as usize; + // Copy this bit into the output line + let index = (i % bits_per_pixel as usize) + current_x * bits_per_pixel as usize; lines[current_y].set(index, bit); } // Calculate the next line and move to next pass if necessary @@ -197,19 +163,18 @@ fn deinterlace_bits(png: &PngImage) -> Vec { /// Deinterlace by bytes, for images with at least 8bpp fn deinterlace_bytes(png: &PngImage) -> Vec { let bytes_per_pixel = png.ihdr.bpp() / 8; - let bytes_per_line = 1 + bytes_per_pixel as usize * png.ihdr.width as usize; - // Initialize each output line with a starting filter byte of 0 - // as well as some blank data + let bytes_per_line = bytes_per_pixel as usize * png.ihdr.width as usize; + // Initialize each output line with some blank data let mut lines: Vec> = vec![vec![0; bytes_per_line]; png.ihdr.height as usize]; let mut current_pass = 1; let mut pass_constants = interlaced_constants(current_pass); let mut current_y: usize = pass_constants.y_shift as usize; - for line in png.scan_lines() { + for line in png.scan_lines(false) { for (i, byte) in line.data.iter().enumerate() { let current_x: usize = pass_constants.x_shift as usize + (i / bytes_per_pixel as usize) * pass_constants.x_step as usize; - // Copy this byte into the output line, offset by 1 because of filter byte - let index = 1 + (i % bytes_per_pixel as usize) + current_x * bytes_per_pixel as usize; + // Copy this byte into the output line + let index = (i % bytes_per_pixel as usize) + current_x * bytes_per_pixel as usize; lines[current_y][index] = *byte; } // Calculate the next line and move to next pass if necessary diff --git a/src/png/mod.rs b/src/png/mod.rs index de7bf546..f3d712d0 100644 --- a/src/png/mod.rs +++ b/src/png/mod.rs @@ -18,7 +18,7 @@ use std::sync::Arc; pub(crate) mod scan_lines; -use self::scan_lines::{ScanLines, ScanLinesMut}; +use self::scan_lines::ScanLines; /// Compression level to use for the Brute filter strategy const BRUTE_LEVEL: i32 = 1; // 1 is fastest, 2-4 are not useful, 5 is slower but more effective @@ -29,7 +29,7 @@ const BRUTE_LINES: usize = 4; // Values over 8 are generally not useful pub struct PngImage { /// The headers stored in the IHDR chunk pub ihdr: IhdrData, - /// The uncompressed, optionally filtered data from the IDAT chunk + /// The uncompressed, unfiltered data from the IDAT chunk pub data: Vec, /// The palette containing colors used in an Indexed image /// Contains 3 bytes per color (R+G+B), up to 768 @@ -280,14 +280,8 @@ impl PngImage { /// Return an iterator over the scanlines of the image #[inline] - pub fn scan_lines(&self) -> ScanLines<'_> { - ScanLines::new(self) - } - - /// Return an iterator over the scanlines of the image - #[inline] - pub fn scan_lines_mut(&mut self) -> ScanLinesMut<'_> { - ScanLinesMut::new(self) + pub fn scan_lines(&self, has_filter: bool) -> ScanLines<'_> { + ScanLines::new(self, has_filter) } /// Reverse all filters applied on the image, returning an unfiltered IDAT bytestream @@ -297,7 +291,7 @@ impl PngImage { let mut last_line: Vec = Vec::new(); let mut last_pass = None; let mut unfiltered_buf = Vec::new(); - for line in self.scan_lines() { + for line in self.scan_lines(true) { if last_pass != line.pass { last_line.clear(); last_pass = line.pass; @@ -305,7 +299,6 @@ impl PngImage { last_line.resize(line.data.len(), 0); let filter = RowFilter::try_from(line.filter).map_err(|_| PngError::InvalidData)?; filter.unfilter_line(bpp, line.data, &last_line, &mut unfiltered_buf)?; - unfiltered.push(0); unfiltered.extend_from_slice(&unfiltered_buf); std::mem::swap(&mut last_line, &mut unfiltered_buf); unfiltered_buf.clear(); @@ -328,7 +321,7 @@ impl PngImage { let mut prev_line = Vec::new(); let mut prev_pass: Option = None; let mut f_buf = Vec::new(); - for line in self.scan_lines() { + for line in self.scan_lines(false) { if prev_pass != line.pass || line.data.len() != prev_line.len() { prev_line = vec![0; line.data.len()]; } diff --git a/src/png/scan_lines.rs b/src/png/scan_lines.rs index 42b0f4a9..509373b2 100644 --- a/src/png/scan_lines.rs +++ b/src/png/scan_lines.rs @@ -7,13 +7,16 @@ pub struct ScanLines<'a> { iter: ScanLineRanges, /// A reference to the PNG image being iterated upon raw_data: &'a [u8], + /// Whether the raw data contains filter bytes + has_filter: bool, } impl<'a> ScanLines<'a> { - pub fn new(png: &'a PngImage) -> Self { + pub fn new(png: &'a PngImage, has_filter: bool) -> Self { Self { - iter: ScanLineRanges::new(png), + iter: ScanLineRanges::new(png, has_filter), raw_data: &png.data, + has_filter, } } } @@ -25,43 +28,16 @@ impl<'a> Iterator for ScanLines<'a> { self.iter.next().map(|(len, pass)| { let (data, rest) = self.raw_data.split_at(len); self.raw_data = rest; - let (&filter, data) = data.split_first().unwrap(); + let (&filter, data) = if self.has_filter { + data.split_first().unwrap() + } else { + (&0, data) + }; ScanLine { filter, data, pass } }) } } -#[derive(Debug)] -/// An iterator over the scan lines of a PNG image -pub struct ScanLinesMut<'a> { - iter: ScanLineRanges, - /// A reference to the PNG image being iterated upon - raw_data: Option<&'a mut [u8]>, -} - -impl<'a> ScanLinesMut<'a> { - pub fn new(png: &'a mut PngImage) -> Self { - Self { - iter: ScanLineRanges::new(png), - raw_data: Some(&mut png.data), - } - } -} - -impl<'a> Iterator for ScanLinesMut<'a> { - type Item = ScanLineMut<'a>; - #[inline] - fn next(&mut self) -> Option { - self.iter.next().map(|(len, pass)| { - let tmp = self.raw_data.take().unwrap(); - let (data, rest) = tmp.split_at_mut(len); - self.raw_data = Some(rest); - let (&mut filter, data) = data.split_first_mut().unwrap(); - ScanLineMut { filter, data, pass } - }) - } -} - #[derive(Debug, Clone)] /// An iterator over the scan line locations of a PNG image struct ScanLineRanges { @@ -71,10 +47,11 @@ struct ScanLineRanges { width: u32, height: u32, left: usize, + has_filter: bool, } impl ScanLineRanges { - pub fn new(png: &PngImage) -> Self { + pub fn new(png: &PngImage, has_filter: bool) -> Self { Self { bits_per_pixel: png.ihdr.bit_depth.as_u8() * png.channels_per_pixel(), width: png.ihdr.width, @@ -85,6 +62,7 @@ impl ScanLineRanges { } else { None }, + has_filter, } } } @@ -166,8 +144,10 @@ impl Iterator for ScanLineRanges { (self.width, None) }; let bits_per_line = pixels_per_line * u32::from(self.bits_per_pixel); - let bytes_per_line = ((bits_per_line + 7) / 8) as usize; - let len = bytes_per_line + 1; + let mut len = ((bits_per_line + 7) / 8) as usize; + if self.has_filter { + len += 1; + } self.left = self.left.checked_sub(len)?; Some((len, current_pass)) } @@ -183,14 +163,3 @@ pub struct ScanLine<'a> { /// The current pass if the image is interlaced pub pass: Option, } - -#[derive(Debug)] -/// A scan line in a PNG image -pub struct ScanLineMut<'a> { - /// The filter type used to encode the current scan line (0-4) - pub filter: u8, - /// The byte data for the current scan line, encoded with the filter specified in the `filter` field - pub data: &'a mut [u8], - /// The current pass if the image is interlaced - pub pass: Option, -} diff --git a/src/reduction/alpha.rs b/src/reduction/alpha.rs index cd8952f6..cf8c22ad 100644 --- a/src/reduction/alpha.rs +++ b/src/reduction/alpha.rs @@ -16,14 +16,11 @@ pub fn cleaned_alpha_channel(png: &PngImage) -> Option { }; let mut reduced = Vec::with_capacity(png.data.len()); - for line in png.scan_lines() { - reduced.push(line.filter); - for pixel in line.data.chunks(bpp) { - if pixel.iter().skip(bpp - bpc).all(|b| *b == 0) { - reduced.resize(reduced.len() + bpp, 0); - } else { - reduced.extend_from_slice(pixel); - } + for pixel in png.data.chunks(bpp) { + if pixel.iter().skip(bpp - bpc).all(|b| *b == 0) { + reduced.resize(reduced.len() + bpp, 0); + } else { + reduced.extend_from_slice(pixel); } } @@ -54,18 +51,16 @@ pub fn reduced_alpha_channel(png: &PngImage, optimize_alpha: bool) -> Option Option { - raw_data.resize(raw_data.len() + colored_bytes, trns[1]); - } - _ => raw_data.extend_from_slice(&pixel[0..colored_bytes]), - }; - } + for pixel in png.data.chunks(bpp) { + match transparency_pixel { + Some(ref trns) if pixel.iter().skip(colored_bytes).all(|b| *b == 0) => { + raw_data.resize(raw_data.len() + colored_bytes, trns[1]); + } + _ => raw_data.extend_from_slice(&pixel[0..colored_bytes]), + }; } let mut aux_headers = png.aux_headers.clone(); diff --git a/src/reduction/bit_depth.rs b/src/reduction/bit_depth.rs index 7c8af41b..d48e0f58 100644 --- a/src/reduction/bit_depth.rs +++ b/src/reduction/bit_depth.rs @@ -1,28 +1,6 @@ use crate::colors::{BitDepth, ColorType}; use crate::headers::IhdrData; use crate::png::PngImage; -use bitvec::prelude::*; - -const ONE_BIT_PERMUTATIONS: [u8; 2] = [0b0000_0000, 0b1111_1111]; -const TWO_BIT_PERMUTATIONS: [u8; 4] = [0b0000_0000, 0b0101_0101, 0b1010_1010, 0b1111_1111]; -const FOUR_BIT_PERMUTATIONS: [u8; 16] = [ - 0b0000_0000, - 0b0001_0001, - 0b0010_0010, - 0b0011_0011, - 0b0100_0100, - 0b0101_0101, - 0b0110_0110, - 0b0111_0111, - 0b1000_1000, - 0b1001_1001, - 0b1010_1010, - 0b1011_1011, - 0b1100_1100, - 0b1101_1101, - 0b1110_1110, - 0b1111_1111, -]; /// Attempt to reduce the bit depth of the image /// Returns true if the bit depth was reduced, false otherwise @@ -37,31 +15,13 @@ pub fn reduce_bit_depth(png: &PngImage, minimum_bits: usize) -> Option } // Reduce from 16 to 8 bits per channel per pixel - let mut reduced = Vec::with_capacity( - (png.ihdr.width * png.ihdr.height * u32::from(png.channels_per_pixel()) + png.ihdr.height) - as usize, - ); - let mut high_byte = 0; - - for line in png.scan_lines() { - reduced.push(line.filter); - for (i, &byte) in line.data.iter().enumerate() { - if i % 2 == 0 { - // High byte - high_byte = byte; - } else { - // Low byte - if high_byte != byte { - // Can't reduce, exit early - return None; - } - reduced.push(byte); - } - } + if png.data.chunks(2).any(|pair| pair[0] != pair[1]) { + // Can't reduce + return None; } Some(PngImage { - data: reduced, + data: png.data.iter().step_by(2).cloned().collect(), ihdr: IhdrData { bit_depth: BitDepth::Eight, ..png.ihdr @@ -76,11 +36,14 @@ pub fn reduce_bit_depth(png: &PngImage, minimum_bits: usize) -> Option pub fn reduce_bit_depth_8_or_less(png: &PngImage, mut minimum_bits: usize) -> Option { assert!((1..8).contains(&minimum_bits)); let bit_depth: usize = png.ihdr.bit_depth.as_u8() as usize; - if minimum_bits >= bit_depth { + if minimum_bits >= bit_depth || bit_depth > 8 { return None; } - for line in png.scan_lines() { - if png.ihdr.color_type == ColorType::Indexed { + // Calculate the current number of pixels per byte + let ppb = 8 / bit_depth; + + if png.ihdr.color_type == ColorType::Indexed { + for line in png.scan_lines(false) { let line_max = line .data .iter() @@ -107,40 +70,62 @@ pub fn reduce_bit_depth_8_or_less(png: &PngImage, mut minimum_bits: usize) -> Op return None; } } - } else { - for &byte in line.data { - while minimum_bits < bit_depth { - let permutations: &[u8] = if minimum_bits == 1 { - &ONE_BIT_PERMUTATIONS - } else if minimum_bits == 2 { - &TWO_BIT_PERMUTATIONS - } else if minimum_bits == 4 { - &FOUR_BIT_PERMUTATIONS - } else { - return None; - }; - if permutations.iter().any(|perm| *perm == byte) { - break; + } + } else { + // Checking for grayscale depth reduction is quite different than for indexed + // Note: In rare cases, padding bits in the data may cause this to incorrectly return None + let mut mask = (1 << minimum_bits) - 1; + let mut divisions = 1..(bit_depth / minimum_bits); + for &b in &png.data { + if b == 0 || b == 255 { + continue; + } + 'try_depth: loop { + let mut byte = b; + // Loop over each pixel in the byte + for _ in 0..ppb { + // Align the first pixel division with the mask + byte = byte.rotate_left(minimum_bits as u32); + // Each potential division of this pixel must be identical to successfully reduce + let compare = byte & mask; + for _ in divisions.clone() { + // Align the next division with the mask + byte = byte.rotate_left(minimum_bits as u32); + if byte & mask != compare { + // This depth is not possible, try the next one up + minimum_bits <<= 1; + if minimum_bits == bit_depth { + return None; + } + mask = (1 << minimum_bits) - 1; + divisions = 1..(bit_depth / minimum_bits); + continue 'try_depth; + } } - minimum_bits <<= 1; } + break; } } } - let mut reduced = BitVec::::with_capacity(png.data.len() * 8); - for line in png.scan_lines() { - reduced.extend_from_raw_slice(&[line.filter]); - let bit_vec = line.data.view_bits::(); - for (i, bit) in bit_vec.iter().by_vals().enumerate() { - let bit_index = bit_depth - (i % bit_depth); - if bit_index <= minimum_bits { - reduced.push(bit); + let mut reduced = Vec::with_capacity(png.data.len()); + let mask = (1 << minimum_bits) - 1; + for line in png.scan_lines(false) { + // Loop over the data in chunks that will produce 1 byte of output + for chunk in line.data.chunks(bit_depth / minimum_bits) { + let mut new_byte = 0; + let mut shift = 8; + for &(mut byte) in chunk { + // Loop over each pixel in the byte + for _ in 0..ppb { + // Align the current pixel with the mask + byte = byte.rotate_left(bit_depth as u32); + shift -= minimum_bits; + // Take the low bits of the pixel and shift them into the output byte + new_byte |= (byte & mask) << shift; + } } - } - // Pad end of line to get 8 bits per byte - while reduced.len() % 8 != 0 { - reduced.push(false); + reduced.push(new_byte); } } @@ -167,7 +152,7 @@ pub fn reduce_bit_depth_8_or_less(png: &PngImage, mut minimum_bits: usize) -> Op } Some(PngImage { - data: reduced.as_raw_slice().to_vec(), + data: reduced, ihdr: IhdrData { bit_depth: BitDepth::from_u8(minimum_bits as u8), ..png.ihdr diff --git a/src/reduction/color.rs b/src/reduction/color.rs index 2ada69de..5e7135f8 100644 --- a/src/reduction/color.rs +++ b/src/reduction/color.rs @@ -2,83 +2,12 @@ use crate::colors::{BitDepth, ColorType}; use crate::headers::IhdrData; use crate::png::PngImage; use indexmap::IndexMap; -use itertools::Itertools; use rgb::{FromSlice, RGB8, RGBA, RGBA8}; use rustc_hash::FxHasher; use std::hash::{BuildHasherDefault, Hash}; type FxIndexMap = IndexMap>; -#[must_use] -pub fn reduce_rgba_to_grayscale_alpha(png: &PngImage) -> Option { - let mut reduced = Vec::with_capacity(png.data.len()); - let byte_depth = png.ihdr.bit_depth.as_u8() >> 3; - let bpp = 4 * byte_depth; - let bpp_mask = bpp - 1; - if 0 != bpp & bpp_mask { - return None; - } - let colored_bytes = bpp - byte_depth; - for line in png.scan_lines() { - reduced.push(line.filter); - let mut low_bytes = Vec::with_capacity(4); - let mut high_bytes = Vec::with_capacity(4); - let mut trans_bytes = Vec::with_capacity(byte_depth as usize); - for (i, byte) in line.data.iter().enumerate() { - if i as u8 & bpp_mask < colored_bytes { - if byte_depth == 1 || i % 2 == 1 { - low_bytes.push(*byte); - } else { - high_bytes.push(*byte); - } - } else { - trans_bytes.push(*byte); - } - - if (i as u8 & bpp_mask) == bpp - 1 { - if low_bytes.iter().unique().count() > 1 { - return None; - } - if byte_depth == 2 { - if high_bytes.iter().unique().count() > 1 { - return None; - } - reduced.push(high_bytes[0]); - high_bytes.clear(); - } - reduced.push(low_bytes[0]); - low_bytes.clear(); - reduced.extend_from_slice(&trans_bytes); - trans_bytes.clear(); - } - } - } - - let mut aux_headers = png.aux_headers.clone(); - if let Some(sbit_header) = png.aux_headers.get(b"sBIT") { - if let Some(&s) = sbit_header.first() { - aux_headers.insert(*b"sBIT", vec![s]); - } - } - - if let Some(bkgd_header) = png.aux_headers.get(b"bKGD") { - if let Some(b) = bkgd_header.get(0..2) { - aux_headers.insert(*b"bKGD", b.to_owned()); - } - } - - Some(PngImage { - data: reduced, - ihdr: IhdrData { - color_type: ColorType::GrayscaleAlpha, - ..png.ihdr - }, - palette: None, - transparency_pixel: None, - aux_headers, - }) -} - fn reduce_scanline_to_palette( iter: impl IntoIterator, palette: &mut FxIndexMap, @@ -117,42 +46,39 @@ pub fn reduce_to_palette(png: &PngImage) -> Option { .as_ref() .filter(|t| png.ihdr.color_type == ColorType::RGB && t.len() >= 6) .map(|t| RGB8::new(t[1], t[3], t[5])); - for line in png.scan_lines() { - raw_data.push(line.filter); - let ok = if png.ihdr.color_type == ColorType::RGB { - reduce_scanline_to_palette( - line.data.as_rgb().iter().cloned().map(|px| { - px.alpha(if Some(px) != transparency_pixel { - 255 - } else { - 0 - }) - }), - &mut palette, - &mut raw_data, - ) - } else if png.ihdr.color_type == ColorType::GrayscaleAlpha { - reduce_scanline_to_palette( - line.data.as_gray_alpha().iter().cloned().map(|px| RGBA { - r: px.0, - g: px.0, - b: px.0, - a: px.1, - }), - &mut palette, - &mut raw_data, - ) - } else { - debug_assert_eq!(png.ihdr.color_type, ColorType::RGBA); - reduce_scanline_to_palette( - line.data.as_rgba().iter().cloned(), - &mut palette, - &mut raw_data, - ) - }; - if !ok { - return None; - } + let ok = if png.ihdr.color_type == ColorType::RGB { + reduce_scanline_to_palette( + png.data.as_rgb().iter().cloned().map(|px| { + px.alpha(if Some(px) != transparency_pixel { + 255 + } else { + 0 + }) + }), + &mut palette, + &mut raw_data, + ) + } else if png.ihdr.color_type == ColorType::GrayscaleAlpha { + reduce_scanline_to_palette( + png.data.as_gray_alpha().iter().cloned().map(|px| RGBA { + r: px.0, + g: px.0, + b: px.0, + a: px.1, + }), + &mut palette, + &mut raw_data, + ) + } else { + debug_assert_eq!(png.ihdr.color_type, ColorType::RGBA); + reduce_scanline_to_palette( + png.data.as_rgba().iter().cloned(), + &mut palette, + &mut raw_data, + ) + }; + if !ok { + return None; } let num_transparent = palette @@ -218,36 +144,18 @@ pub fn reduce_to_palette(png: &PngImage) -> Option { #[must_use] pub fn reduce_rgb_to_grayscale(png: &PngImage) -> Option { let mut reduced = Vec::with_capacity(png.data.len()); - let byte_depth: u8 = png.ihdr.bit_depth.as_u8() >> 3; - let bpp: usize = 3 * byte_depth as usize; - let mut cur_pixel = Vec::with_capacity(bpp); - for line in png.scan_lines() { - reduced.push(line.filter); - for (i, byte) in line.data.iter().enumerate() { - cur_pixel.push(*byte); - if i % bpp == bpp - 1 { - if bpp == 3 { - if cur_pixel.iter().unique().count() > 1 { - return None; - } - reduced.push(cur_pixel[0]); - } else { - let pixel_bytes = cur_pixel - .iter() - .step_by(2) - .cloned() - .zip(cur_pixel.iter().skip(1).step_by(2).cloned()) - .unique() - .collect::>(); - if pixel_bytes.len() > 1 { - return None; - } - reduced.push(pixel_bytes[0].0); - reduced.push(pixel_bytes[0].1); - } - cur_pixel.clear(); + let byte_depth = png.ihdr.bit_depth.as_u8() as usize >> 3; + let bpp = png.channels_per_pixel() as usize * byte_depth; + let last_color = 2 * byte_depth; + for pixel in png.data.chunks(bpp) { + if byte_depth == 1 { + if pixel[0] != pixel[1] || pixel[1] != pixel[2] { + return None; } + } else if pixel[0..2] != pixel[2..4] || pixel[2..4] != pixel[4..6] { + return None; } + reduced.extend_from_slice(&pixel[last_color..]); } let transparency_pixel = if let Some(ref trns) = png.transparency_pixel { @@ -275,7 +183,10 @@ pub fn reduce_rgb_to_grayscale(png: &PngImage) -> Option { Some(PngImage { data: reduced, ihdr: IhdrData { - color_type: ColorType::Grayscale, + color_type: match png.ihdr.color_type { + ColorType::RGBA => ColorType::GrayscaleAlpha, + _ => ColorType::Grayscale, + }, ..png.ihdr }, aux_headers, diff --git a/src/reduction/mod.rs b/src/reduction/mod.rs index 4bf2ec6e..9bedcf62 100644 --- a/src/reduction/mod.rs +++ b/src/reduction/mod.rs @@ -34,29 +34,27 @@ pub fn reduced_palette(png: &PngImage, optimize_alpha: bool) -> Option let palette = png.palette.as_ref()?; // Find palette entries that are never used - for line in png.scan_lines() { - match png.ihdr.bit_depth { - BitDepth::Eight => { - for &byte in line.data { - used[byte as usize] = true; - } + match png.ihdr.bit_depth { + BitDepth::Eight => { + for &byte in &png.data { + used[byte as usize] = true; } - BitDepth::Four => { - for &byte in line.data { - used[(byte & 0x0F) as usize] = true; - used[(byte >> 4) as usize] = true; - } - } - BitDepth::Two => { - for &byte in line.data { - used[(byte & 0x03) as usize] = true; - used[((byte >> 2) & 0x03) as usize] = true; - used[((byte >> 4) & 0x03) as usize] = true; - used[(byte >> 6) as usize] = true; - } - } - _ => unreachable!(), } + BitDepth::Four => { + for &byte in &png.data { + used[(byte & 0x0F) as usize] = true; + used[(byte >> 4) as usize] = true; + } + } + BitDepth::Two => { + for &byte in &png.data { + used[(byte & 0x03) as usize] = true; + used[((byte >> 2) & 0x03) as usize] = true; + used[((byte >> 4) & 0x03) as usize] = true; + used[(byte >> 6) as usize] = true; + } + } + _ => unreachable!(), } let mut used_enumerated: Vec<(usize, &bool)> = used.iter().enumerate().collect(); @@ -117,15 +115,9 @@ pub fn reduced_palette(png: &PngImage, optimize_alpha: bool) -> Option #[must_use] fn do_palette_reduction(png: &PngImage, palette_map: &[Option; 256]) -> Option { let byte_map = palette_map_to_byte_map(png, palette_map)?; - let mut raw_data = Vec::with_capacity(png.data.len()); // Reassign data bytes to new indices - for line in png.scan_lines() { - raw_data.push(line.filter); - for byte in line.data { - raw_data.push(byte_map[*byte as usize]); - } - } + let raw_data = png.data.iter().map(|b| byte_map[*b as usize]).collect(); let mut aux_headers = png.aux_headers.clone(); if let Some(bkgd_header) = png.aux_headers.get(b"bKGD") { @@ -207,48 +199,40 @@ pub fn reduce_color_type( // Go down one step at a time // Maybe not the most efficient, but it's safe - if reduced.ihdr.color_type == ColorType::RGBA { - if let Some(r) = if grayscale_reduction { - reduce_rgba_to_grayscale_alpha(&reduced) - } else { - None - } - .or_else(|| reduced_alpha_channel(&reduced, optimize_alpha)) - { + if grayscale_reduction && matches!(reduced.ihdr.color_type, ColorType::RGBA | ColorType::RGB) { + if let Some(r) = reduce_rgb_to_grayscale(&reduced) { reduced = Cow::Owned(r); - } else if let Some(r) = reduce_to_palette(&reduced) { - reduced = Cow::Owned(r); - should_reduce_bit_depth = true; + should_reduce_bit_depth = reduced.ihdr.color_type == ColorType::Grayscale; } } + // Attempt grayscale alpha reduction before palette, as grayscale will typically be smaller than indexed if reduced.ihdr.color_type == ColorType::GrayscaleAlpha { - if let Some(r) = - reduced_alpha_channel(&reduced, optimize_alpha).or_else(|| reduce_to_palette(&reduced)) - { + if let Some(r) = reduced_alpha_channel(&reduced, optimize_alpha) { reduced = Cow::Owned(r); should_reduce_bit_depth = true; } } - if reduced.ihdr.color_type == ColorType::RGB { - if let Some(r) = if grayscale_reduction { - reduce_rgb_to_grayscale(&reduced) - } else { - None - } - .or_else(|| reduce_to_palette(&reduced)) - { + if matches!( + reduced.ihdr.color_type, + ColorType::RGBA | ColorType::RGB | ColorType::GrayscaleAlpha + ) { + if let Some(r) = reduce_to_palette(&reduced) { reduced = Cow::Owned(r); should_reduce_bit_depth = true; + + // Make sure that palette gets sorted. Ideally, this should be done within reduce_to_palette. + if let Some(r) = reduced_palette(&reduced, optimize_alpha) { + reduced = Cow::Owned(r); + } } } - //Make sure that palette gets sorted. Ideally, this should be done within reduced_color_to_palette. - if should_reduce_bit_depth && reduced.ihdr.color_type == ColorType::Indexed { - if let Some(r) = reduced_palette(&reduced, optimize_alpha) { + // Attempt RGBA alpha reduction after palette, so it can be skipped if palette was successful + if reduced.ihdr.color_type == ColorType::RGBA { + if let Some(r) = reduced_alpha_channel(&reduced, optimize_alpha) { reduced = Cow::Owned(r); - should_reduce_bit_depth = true; } } diff --git a/tests/files/grayscale_2_should_be_grayscale_1.png b/tests/files/grayscale_2_should_be_grayscale_1.png new file mode 100644 index 0000000000000000000000000000000000000000..c614d443643813771b68c12d926955d9b20df043 GIT binary patch literal 1853 zcmaJ?e@q)y9Dm1;%&{mOms?wNE}9H*Zt#`1(oRN$QGBy$flw~!d3~*48FT-;b@qz_j=!c zzn{nxZ-;*XxbR8suuy+v_+l>IMeq$06A5x%OiR0uTwsrGk9D0 zX2uZVg0d%IicnDG7|Dx9IDbH-B4(X}pr9@HYl-t0G0iEoB5Dr*6HLxD??o;6Jb{)1;kWZFhNYVV1xn3)}z5>SJttkrbq%2CSTFppBD{bPjvK$i-VfJ(>j;BbO zmUWr+tIS<%nHr0R3CloP1V(O47tj0Y^}5uAWuh`1*!dQ=wbSaA7+UUUJpmu1E8Wj3 zU4zMBE8&?Gn=tBeLQj;r(*3Lnu3|FScG#LNUdg*obONV#5ib)Zlw1fog*8}>A9?dA zCnNIyklboSl6*760wip~-`F{hw$Jce{FvVznnysKMm>MK>nhDQefyu^qMrpZns_ zJ)Zxf|AW?v2Ma$R zy=ocr8%`#<+oNBbX1`*f?&7^u`m+-^hnt!fyv4AUbGHO|N9>aci0!ujuH?%0yr*$FUw?X^V{Wq^ZXf`*y$7truO2%7A9e+4?EnA( literal 0 HcmV?d00001 diff --git a/tests/files/grayscale_4_should_be_grayscale_1.png b/tests/files/grayscale_4_should_be_grayscale_1.png new file mode 100644 index 0000000000000000000000000000000000000000..12d05931ef344aa566d0f3303cde2c474cada39d GIT binary patch literal 2023 zcmbtU4@?_X7=MLLCNME`CIZ>GbWw@3y+4c&Ztb~A9WAvhVcpWIfCUa#ZF|l2Haf^^ z;u0oC9n&brv2Ibi#^6Zi_~RDBf+RDd3(}z`&dn^_l!lQJQYAwceD8XNa$zRMclYkT z_kG{*`}e;0TFZBpu1(KL2LP-!8uS$aC}ME7t%3~jRX&yre-C>Nb{_y4kD;jmpL9F{ zz$$~&Y7=bc7wKBgt#;ILHLTk2_97I}`e{hIS;2w%-7b%h_Un|Q1`TP%#+8_;BD}6s z+RWux3CFXTMop**C6kU}n3k`rrz`Y3B{*E^l=}q1OXGNBW23sUK+W+DI9Xg=j1v@2 zQ7WjR@*VUD4!_Fd+Y~pFu%l;vwY<|SI5`i7>^f?={en)ZM2;3mJWkf{T=L}cNom0Z zaU{V>HG!`LgUK$&w+!WXcpnH7ob?azg(Pv9Sd4g#S`-0Y!nrtpx7Sh2GNe`vDw(yE zQCz#+>1SOwz0=Kld@y^akRT+GT(Tn4zJ%Pe9GN6ZLc%i8B|Pg8INr)}E=F|XMoX-m z=dc1brKYsFTuWxa%+)#T59%EP%TNT|SrWb)D^Wxf6irF+Bpn%Abk9m?QllbB6CQ$okMV};GHDsE#zjhfJTKtR+i}|*+H8}SFk?LwI6ld9*82sm&OeI;nJSPr%S zJ2Y7=LA5wKYe{D>ey~L@JlRY-EMwx9VA5}yu08f$Uy7m6EWlh@DDZq}!kjqDc)+kTh9@CE5?T^MA7fkWy{b z@38u-rmhXSZ*G57KQ^-cUc>q9@v6&*&t^nYzw4e&**8(LzV)G&^1&-zd2Fi?wJR%| zJ~FfipNxbW{?GDT?_6l!BDZwsbk^N3WR{1c?K7XB*mFUMZt65;p@gI{4~Dt(Mi|O) z^yk^Ym%sNO58jCNOijG(!K0|4;uyR&U^m5aZ#Q4suB{~B98e)zpdxT|Fs z!)Y5fT<`lZV9BEC0r%$nK~Z7voK80}G2M42*4*^N*bVpJuC$3ArAPYD#Ey4!Zv<4) z(%Ve5)bLz%$Wl3%%>@H9H&1k2>x*!c#ZNc%Pi+gI95~i<=#QA?&#qgo!4DRur@vm4 zhzHzhVt<+(8yd@->TM3p^fn*pIn?yh+&4Ll=GUxCgY$u&AJvsjEtv~>XQg7lG7{c6 z83|cJ)pz;G*39s%($qN`w%i{NnNF7FRoO$P(RI(6LNrpk zv`yL6z`3&Jo0b`_9tj`V8;yjHwlNLTN$r*!fv7XKx?P_!MPtd|{Wq`}tSj{0EZ{vKasX literal 0 HcmV?d00001 diff --git a/tests/files/grayscale_4_should_be_grayscale_2.png b/tests/files/grayscale_4_should_be_grayscale_2.png new file mode 100644 index 0000000000000000000000000000000000000000..eb4570ff2acfd7e99b649c02f6fcbcc7e82f1140 GIT binary patch literal 4904 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 literal 0 HcmV?d00001 diff --git a/tests/files/grayscale_8_should_be_grayscale_1.png b/tests/files/grayscale_8_should_be_grayscale_1.png new file mode 100644 index 0000000000000000000000000000000000000000..89248c6171c154c262f7eaf375454b64f68f6350 GIT binary patch literal 2462 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 literal 0 HcmV?d00001 diff --git a/tests/files/grayscale_8_should_be_grayscale_4.png b/tests/files/grayscale_8_should_be_grayscale_4.png new file mode 100644 index 0000000000000000000000000000000000000000..c561432ec2313d8fccfe51a6bb6df4358a7d9e76 GIT binary patch literal 14680 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 cdk9k4cHBl$Q4w