diff --git a/benches/reductions.rs b/benches/reductions.rs index dc97effb..28803a38 100644 --- a/benches/reductions.rs +++ b/benches/reductions.rs @@ -158,7 +158,7 @@ fn reductions_rgba_to_grayscale_alpha_16(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| color::reduce_rgb_to_grayscale(&png.raw)); + b.iter(|| color::reduced_rgb_to_grayscale(&png.raw)); } #[bench] @@ -168,7 +168,7 @@ fn reductions_rgba_to_grayscale_alpha_8(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| color::reduce_rgb_to_grayscale(&png.raw)); + b.iter(|| color::reduced_rgb_to_grayscale(&png.raw)); } #[bench] @@ -179,7 +179,7 @@ fn reductions_rgba_to_grayscale_16(b: &mut Bencher) { let png = PngData::new(&input, false).unwrap(); b.iter(|| { - color::reduce_rgb_to_grayscale(&png.raw) + color::reduced_rgb_to_grayscale(&png.raw) .and_then(|r| alpha::reduced_alpha_channel(&r, false)) }); } @@ -192,7 +192,7 @@ fn reductions_rgba_to_grayscale_8(b: &mut Bencher) { let png = PngData::new(&input, false).unwrap(); b.iter(|| { - color::reduce_rgb_to_grayscale(&png.raw) + color::reduced_rgb_to_grayscale(&png.raw) .and_then(|r| alpha::reduced_alpha_channel(&r, false)) }); } @@ -204,7 +204,7 @@ fn reductions_rgb_to_grayscale_16(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| color::reduce_rgb_to_grayscale(&png.raw)); + b.iter(|| color::reduced_rgb_to_grayscale(&png.raw)); } #[bench] @@ -212,7 +212,7 @@ fn reductions_rgb_to_grayscale_8(b: &mut Bencher) { let input = test::black_box(PathBuf::from("tests/files/rgb_8_should_be_grayscale_8.png")); let png = PngData::new(&input, false).unwrap(); - b.iter(|| color::reduce_rgb_to_grayscale(&png.raw)); + b.iter(|| color::reduced_rgb_to_grayscale(&png.raw)); } #[bench] @@ -220,7 +220,7 @@ fn reductions_rgba_to_palette_8(b: &mut Bencher) { let input = test::black_box(PathBuf::from("tests/files/rgba_8_should_be_palette_8.png")); let png = PngData::new(&input, false).unwrap(); - b.iter(|| color::reduce_to_palette(&png.raw)); + b.iter(|| color::reduced_to_indexed(&png.raw)); } #[bench] @@ -228,7 +228,27 @@ fn reductions_rgb_to_palette_8(b: &mut Bencher) { let input = test::black_box(PathBuf::from("tests/files/rgb_8_should_be_palette_8.png")); let png = PngData::new(&input, false).unwrap(); - b.iter(|| color::reduce_to_palette(&png.raw)); + b.iter(|| color::reduced_to_indexed(&png.raw)); +} + +#[bench] +fn reductions_grayscale_8_to_palette_8(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/grayscale_8_should_be_palette_8.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| color::reduced_to_indexed(&png.raw)); +} + +#[bench] +fn reductions_palette_8_to_grayscale_8(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/palette_8_should_be_grayscale_8.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| color::indexed_to_channels(&png.raw)); } #[bench] @@ -238,7 +258,7 @@ fn reductions_palette_duplicate_reduction(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| palette::optimized_palette(&png.raw, false)); + b.iter(|| palette::reduced_palette(&png.raw, false)); } #[bench] @@ -248,7 +268,7 @@ fn reductions_palette_unused_reduction(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| palette::optimized_palette(&png.raw, false)); + b.iter(|| palette::reduced_palette(&png.raw, false)); } #[bench] @@ -258,7 +278,17 @@ fn reductions_palette_full_reduction(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| palette::optimized_palette(&png.raw, false)); + b.iter(|| palette::reduced_palette(&png.raw, false)); +} + +#[bench] +fn reductions_palette_sort(b: &mut Bencher) { + let input = test::black_box(PathBuf::from( + "tests/files/palette_8_should_be_palette_8.png", + )); + let png = PngData::new(&input, false).unwrap(); + + b.iter(|| palette::sorted_palette(&png.raw)); } #[bench] diff --git a/src/colors.rs b/src/colors.rs index 22f24f19..59cd589e 100644 --- a/src/colors.rs +++ b/src/colors.rs @@ -70,13 +70,21 @@ impl ColorType { matches!(self, ColorType::RGB { .. } | ColorType::RGBA) } + #[inline] + pub(crate) fn is_grayscale(&self) -> bool { + matches!( + self, + ColorType::Grayscale { .. } | ColorType::GrayscaleAlpha + ) + } + #[inline] pub(crate) fn has_alpha(&self) -> bool { matches!(self, ColorType::GrayscaleAlpha | ColorType::RGBA) } #[inline] - pub fn has_trns(&self) -> bool { + pub(crate) fn has_trns(&self) -> bool { match self { ColorType::Grayscale { transparent_shade } => transparent_shade.is_some(), ColorType::RGB { transparent_color } => transparent_color.is_some(), diff --git a/src/lib.rs b/src/lib.rs index c3927d45..4c43e287 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -564,7 +564,12 @@ fn optimize_png( // Do this first so that reductions can ignore certain chunks such as bKGD perform_strip(png, opts); - if let Some(new_png) = optimize_raw(png.raw.clone(), opts, deadline, Some(idat_original_size)) { + let max_size = if opts.force { + None + } else { + Some(png.estimated_output_size()) + }; + if let Some(new_png) = optimize_raw(png.raw.clone(), opts, deadline, max_size) { png.raw = new_png.raw; png.idat_data = new_png.idat_data; } @@ -611,7 +616,7 @@ fn optimize_raw( mut png: Arc, opts: &Options, deadline: Arc, - max_idat_size: Option, + max_size: Option, ) -> Option { // Must use normal (lazy) compression, as faster ones (greedy) are not representative let eval_compression = 5; @@ -673,15 +678,10 @@ fn optimize_raw( }; if trial.compression > 0 && trial.compression <= eval_compression { // No further compression required - let idat_data = eval_result.image.idat_data; - if opts.force || idat_data.len() < max_idat_size.unwrap_or(usize::MAX) { - Some((trial, idat_data)) - } else { - None - } + Some((trial, eval_result.image.idat_data)) } else { debug!("Trying: {}", trial.filter); - let best_size = AtomicMin::new(if opts.force { None } else { max_idat_size }); + let best_size = AtomicMin::new(max_size); perform_trial(&eval_result.image.filtered, opts, trial, &best_size) } } else { @@ -712,7 +712,7 @@ fn optimize_raw( debug!("Trying: {} filters", results.len()); - let best_size = AtomicMin::new(if opts.force { None } else { max_idat_size }); + let best_size = AtomicMin::new(max_size); let results_iter = results.into_par_iter().with_max_len(1); let best = results_iter.filter_map(|trial| { if deadline.passed() { @@ -730,34 +730,37 @@ fn optimize_raw( }) }; - if let Some((opts, idat_data)) = best { - debug!("Found better combination:"); - debug!( - " zc = {} f = {:8} {} bytes", - opts.compression, - opts.filter, - idat_data.len() - ); - return Some(PngData { + if let Some((trial, idat_data)) = best { + let image = PngData { raw: png, // The filtered data has not been retained here, but we don't need to return it filtered: vec![], idat_data, - }); + }; + if image.estimated_output_size() < max_size.unwrap_or(usize::MAX) { + debug!("Found better combination:"); + debug!( + " zc = {} f = {:8} {} bytes", + trial.compression, + trial.filter, + image.idat_data.len() + ); + return Some(image); + } } } else if let Some(result) = eval_result { // If idat_recoding is off and reductions were attempted but ended up choosing the baseline, // we should still check if the evaluator compressed the baseline smaller than the original. - let idat_data = &result.image.idat_data; - if idat_data.len() < max_idat_size.unwrap_or(usize::MAX) { + let image = result.image; + if image.estimated_output_size() < max_size.unwrap_or(usize::MAX) { debug!("Found better combination:"); debug!( " zc = {} f = {:8} {} bytes", eval_compression, result.filter, - idat_data.len() + image.idat_data.len() ); - return Some(result.image); + return Some(image); } } diff --git a/src/png/mod.rs b/src/png/mod.rs index dd37f69c..c25c52a9 100644 --- a/src/png/mod.rs +++ b/src/png/mod.rs @@ -186,28 +186,12 @@ impl PngData { match &self.raw.ihdr.color_type { ColorType::Indexed { palette } => { let mut palette_data = Vec::with_capacity(palette.len() * 3); - let mut max_palette_size = 1 << (self.raw.ihdr.bit_depth as u8); - // Ensure bKGD color doesn't get truncated from palette - if let Some(&idx) = self.raw.aux_headers.get(b"bKGD").and_then(|b| b.first()) { - max_palette_size = max_palette_size.max(idx as usize + 1); - } - for px in palette.iter().take(max_palette_size) { + for px in palette { palette_data.extend_from_slice(px.rgb().as_slice()); } write_png_block(b"PLTE", &palette_data, &mut output); - let num_transparent = palette.iter().take(max_palette_size).enumerate().fold( - 0, - |prev, (index, px)| { - if px.a == 255 { - prev - } else { - index + 1 - } - }, - ); - if num_transparent > 0 { - let trns_data: Vec<_> = - palette[0..num_transparent].iter().map(|px| px.a).collect(); + if let Some(last_trns) = palette.iter().rposition(|px| px.a != 255) { + let trns_data: Vec<_> = palette[0..=last_trns].iter().map(|px| px.a).collect(); write_png_block(b"tRNS", &trns_data, &mut output); } } diff --git a/src/reduction/color.rs b/src/reduction/color.rs index 6eb9700a..177ae1d6 100644 --- a/src/reduction/color.rs +++ b/src/reduction/color.rs @@ -1,103 +1,88 @@ use crate::colors::{BitDepth, ColorType}; use crate::headers::IhdrData; use crate::png::PngImage; -use indexmap::IndexMap; -use rgb::{ComponentMap, FromSlice, RGBA, RGBA8}; +use indexmap::IndexSet; +use rgb::alt::Gray; +use rgb::{ComponentMap, ComponentSlice, FromSlice, RGB, RGBA, RGBA8}; use rustc_hash::FxHasher; use std::hash::{BuildHasherDefault, Hash}; -type FxIndexMap = IndexMap>; +type FxIndexSet = IndexSet>; -fn reduce_scanline_to_palette( +/// Maximum size difference between indexed and channels to consider a candidate for evaluation +pub const INDEXED_MAX_DIFF: usize = 20000; + +fn build_palette( iter: impl IntoIterator, - palette: &mut FxIndexMap, reduced: &mut Vec, -) -> bool +) -> Option> where T: Eq + Hash, { + let mut palette = FxIndexSet::default(); + palette.reserve(257); for pixel in iter { - let idx = if let Some(&idx) = palette.get(&pixel) { - idx - } else { - let len = palette.len(); - if len == 256 { - return false; - } - let idx = len as u8; - palette.insert(pixel, idx); - idx - }; - reduced.push(idx); + let (idx, _) = palette.insert_full(pixel); + if idx == 256 { + return None; + } + reduced.push(idx as u8); } - true + Some(palette) } #[must_use] -pub fn reduce_to_palette(png: &PngImage) -> Option { - if png.ihdr.bit_depth != BitDepth::Eight || png.channels_per_pixel() == 1 { +pub fn reduced_to_indexed(png: &PngImage) -> Option { + if png.ihdr.bit_depth != BitDepth::Eight { return None; } - let mut raw_data = Vec::with_capacity(png.data.len()); - let mut palette = FxIndexMap::default(); - palette.reserve(257); - let ok = if let ColorType::RGB { transparent_color } = png.ihdr.color_type { - // Convert the RGB16 transparency to RGB8 - let transparency_pixel = transparent_color.map(|t| t.map(|c| c as u8)); - reduce_scanline_to_palette( - png.data.as_rgb().iter().cloned().map(|px| { - px.alpha(if Some(px) != transparency_pixel { - 255 - } else { - 0 + if matches!(png.ihdr.color_type, ColorType::Indexed { .. }) { + return None; + } + + let mut raw_data = Vec::with_capacity(png.data.len() / png.channels_per_pixel()); + let mut palette: Vec<_> = match png.ihdr.color_type { + ColorType::Grayscale { transparent_shade } => { + let pmap = build_palette(png.data.as_gray().iter().cloned(), &mut raw_data)?; + // Convert the Gray16 transparency to Gray8 + let transparency_pixel = transparent_shade.map(|t| Gray::from(t as u8)); + pmap.into_iter() + .map(|px| { + RGB::from(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, - ) + .collect() + } + ColorType::RGB { transparent_color } => { + let pmap = build_palette(png.data.as_rgb().iter().cloned(), &mut raw_data)?; + // Convert the RGB16 transparency to RGB8 + let transparency_pixel = transparent_color.map(|t| t.map(|c| c as u8)); + pmap.into_iter() + .map(|px| { + px.alpha(if Some(px) != transparency_pixel { + 255 + } else { + 0 + }) + }) + .collect() + } + ColorType::GrayscaleAlpha => { + let pmap = build_palette(png.data.as_gray_alpha().iter().cloned(), &mut raw_data)?; + pmap.into_iter().map(RGBA::from).collect() + } + ColorType::RGBA => { + let pmap = build_palette(png.data.as_rgba().iter().cloned(), &mut raw_data)?; + pmap.into_iter().collect() + } + _ => return None, }; - if !ok { - return None; - } - - let num_transparent = palette - .iter() - .filter_map(|(px, &idx)| { - if px.a != 255 { - Some(idx as usize + 1) - } else { - None - } - }) - .max(); - let trns_size = num_transparent.map_or(0, |n| n + 8); - - let headers_size = palette.len() * 3 + 8 + trns_size; - if raw_data.len() + headers_size > png.data.len() { - // Reduction would result in a larger image - return None; - } let mut aux_headers = png.aux_headers.clone(); - if let Some(bkgd_header) = png.aux_headers.get(b"bKGD") { + if let Some(bkgd_header) = aux_headers.remove(b"bKGD") { let bg = if png.ihdr.color_type.is_rgb() && bkgd_header.len() == 6 { // In bKGD 16-bit values are used even for 8-bit images Some(RGBA8::new( @@ -106,7 +91,7 @@ pub fn reduce_to_palette(png: &PngImage) -> Option { bkgd_header[5], 255, )) - } else if png.ihdr.color_type == ColorType::GrayscaleAlpha && bkgd_header.len() == 2 { + } else if png.ihdr.color_type.is_grayscale() && bkgd_header.len() == 2 { Some(RGBA8::new( bkgd_header[1], bkgd_header[1], @@ -117,16 +102,15 @@ pub fn reduce_to_palette(png: &PngImage) -> Option { None }; if let Some(bg) = bg { - let entry = if let Some(&entry) = palette.get(&bg) { - entry - } else if palette.len() < 256 { - let entry = palette.len() as u8; - palette.insert(bg, entry); - entry - } else { - return None; // No space in palette to store the bg as an index - }; - aux_headers.insert(*b"bKGD", vec![entry]); + let idx = palette.iter().position(|&px| px == bg).or_else(|| { + if palette.len() < 256 { + palette.push(bg); + Some(palette.len() - 1) + } else { + None // No space in palette to store the bg as an index + } + })?; + aux_headers.insert(*b"bKGD", vec![idx as u8]); } } @@ -135,17 +119,10 @@ pub fn reduce_to_palette(png: &PngImage) -> Option { aux_headers.insert(*b"sBIT", sbit_header.iter().cloned().take(3).collect()); } - let mut palette_vec = vec![RGBA8::new(0, 0, 0, 0); palette.len()]; - for (color, idx) in palette { - palette_vec[idx as usize] = color; - } - Some(PngImage { data: raw_data, ihdr: IhdrData { - color_type: ColorType::Indexed { - palette: palette_vec, - }, + color_type: ColorType::Indexed { palette }, ..png.ihdr }, aux_headers, @@ -153,7 +130,7 @@ pub fn reduce_to_palette(png: &PngImage) -> Option { } #[must_use] -pub fn reduce_rgb_to_grayscale(png: &PngImage) -> Option { +pub fn reduced_rgb_to_grayscale(png: &PngImage) -> Option { if !png.ihdr.color_type.is_rgb() { return None; } @@ -204,3 +181,67 @@ pub fn reduce_rgb_to_grayscale(png: &PngImage) -> Option { aux_headers, }) } + +/// Attempt to convert indexed to a different color type, returning the resulting image if successful +#[must_use] +pub fn indexed_to_channels(png: &PngImage) -> Option { + if png.ihdr.bit_depth != BitDepth::Eight { + return None; + } + let palette = match &png.ihdr.color_type { + ColorType::Indexed { palette } => palette, + _ => return None, + }; + + // Determine which channels are required + let is_gray = palette.iter().all(|c| c.r == c.g && c.g == c.b); + let has_alpha = palette.iter().any(|c| c.a != 255); + let color_type = match (is_gray, has_alpha) { + (false, true) => ColorType::RGBA, + (false, false) => ColorType::RGB { + transparent_color: None, + }, + (true, true) => ColorType::GrayscaleAlpha, + (true, false) => ColorType::Grayscale { + transparent_shade: None, + }, + }; + + // Don't proceed if output would be too much larger + let out_size = color_type.channels_per_pixel() as usize * png.data.len(); + if out_size - png.data.len() > INDEXED_MAX_DIFF { + return None; + } + + // Construct the new data + let black = RGBA::new(0, 0, 0, 255); + let ch_start = if is_gray { 2 } else { 0 }; + let ch_end = if has_alpha { 3 } else { 2 }; + let mut data = Vec::with_capacity(out_size); + for b in &png.data { + let color = palette.get(*b as usize).unwrap_or(&black); + data.extend_from_slice(&color.as_slice()[ch_start..=ch_end]); + } + + // Update bKGD if it exists + let mut aux_headers = png.aux_headers.clone(); + if let Some(idx) = aux_headers.remove(b"bKGD").and_then(|b| b.first().cloned()) { + if let Some(color) = palette.get(idx as usize) { + let bkgd = if is_gray { + vec![0, color.r] + } else { + vec![0, color.r, 0, color.g, 0, color.b] + }; + aux_headers.insert(*b"bKGD", bkgd); + } + } + + Some(PngImage { + ihdr: IhdrData { + color_type, + ..png.ihdr + }, + data, + aux_headers, + }) +} diff --git a/src/reduction/mod.rs b/src/reduction/mod.rs index e6888216..f5539096 100644 --- a/src/reduction/mod.rs +++ b/src/reduction/mod.rs @@ -51,7 +51,16 @@ pub(crate) fn perform_reductions( // Attempt to reduce RGB to grayscale // This is just removal of bytes and does not need to be evaluated if opts.color_type_reduction && !deadline.passed() { - if let Some(reduced) = reduce_rgb_to_grayscale(&png) { + if let Some(reduced) = reduced_rgb_to_grayscale(&png) { + png = Arc::new(reduced); + reduction_occurred = true; + } + } + + // Attempt to reduce the palette + // This may change bytes but should always be beneficial + if opts.palette_reduction && !deadline.passed() { + if let Some(reduced) = reduced_palette(&png, opts.optimize_alpha) { png = Arc::new(reduced); reduction_occurred = true; } @@ -65,9 +74,9 @@ pub(crate) fn perform_reductions( if opts.color_type_reduction && !deadline.passed() { if let Some(reduced) = reduced_alpha_channel(&png, opts.optimize_alpha) { png = Arc::new(reduced); - // If the reduction requires a tRNS chunk, enter this into the evaluator - // Otherwise it is just removal of bytes and should become the baseline - if png.ihdr.color_type.has_trns() { + // For small differences, if a tRNS chunk is required then enter this into the evaluator + // Otherwise it is mostly just removal of bytes and should become the baseline + if png.ihdr.color_type.has_trns() && baseline.data.len() - png.data.len() <= 1000 { eval.try_image(png.clone()); evaluation_added = true; } else { @@ -77,33 +86,52 @@ pub(crate) fn perform_reductions( } } - // Attempt to reduce the palette size + // Attempt to sort the palette if opts.palette_reduction && !deadline.passed() { - if let Some(reduced) = optimized_palette(&png, opts.optimize_alpha) { + if let Some(reduced) = sorted_palette(&png) { png = Arc::new(reduced); eval.try_image(png.clone()); evaluation_added = true; } } - // Attempt to reduce to palette + // Attempt to convert from indexed to channels + // This may give a better result due to dropping the PLTE chunk if opts.color_type_reduction && !deadline.passed() { - if let Some(reduced) = reduce_to_palette(&png) { - png = Arc::new(reduced); - // Make sure the palette gets sorted (ideally, this should be done within reduce_to_palette) - if let Some(reduced) = optimized_palette(&png, opts.optimize_alpha) { - png = Arc::new(reduced); - } - eval.try_image(png.clone()); + if let Some(reduced) = indexed_to_channels(&png) { + // This result should not be passed on to subsequent reductions + eval.try_image(Arc::new(reduced)); evaluation_added = true; } } + // Attempt to reduce to indexed + let mut indexed = None; + if opts.color_type_reduction && !deadline.passed() { + if let Some(reduced) = reduced_to_indexed(&png) { + // Make sure the palette gets sorted (but don't bother evaluating both results) + let new = Arc::new(sorted_palette(&reduced).unwrap_or(reduced)); + // For relatively small differences, enter this into the evaluator + // Otherwise we're confident enough for it to become the baseline + if png.data.len() - new.data.len() <= INDEXED_MAX_DIFF { + eval.try_image(new.clone()); + evaluation_added = true; + } else { + baseline = new.clone(); + reduction_occurred = true; + } + indexed = Some(new); + } + } + // Attempt to reduce to a lower bit depth if opts.bit_depth_reduction && !deadline.passed() { - if let Some(reduced) = reduced_bit_depth_8_or_less(&png, 1) { - png = Arc::new(reduced); - eval.try_image(png.clone()); + // Try reducing the previous png, falling back to the indexed one if it exists + // This allows a grayscale depth reduction to be preferred over an indexed depth reduction + let reduced = reduced_bit_depth_8_or_less(&png, 1) + .or_else(|| indexed.and_then(|png| reduced_bit_depth_8_or_less(&png, 1))); + if let Some(reduced) = reduced { + eval.try_image(Arc::new(reduced)); evaluation_added = true; } } diff --git a/src/reduction/palette.rs b/src/reduction/palette.rs index 081d43db..83c14e24 100644 --- a/src/reduction/palette.rs +++ b/src/reduction/palette.rs @@ -1,180 +1,201 @@ use crate::colors::{BitDepth, ColorType}; use crate::headers::IhdrData; use crate::png::PngImage; -use indexmap::map::{Entry::*, IndexMap}; +use indexmap::IndexSet; use rgb::RGBA8; -/// Attempt to shrink and sort the palette, returning the optimized image if successful +/// Attempt to reduce the number of colors in the palette, returning the reduced image if successful #[must_use] -pub fn optimized_palette(png: &PngImage, optimize_alpha: bool) -> Option { +pub fn reduced_palette(png: &PngImage, optimize_alpha: bool) -> Option { let palette = match &png.ihdr.color_type { - ColorType::Indexed { palette } => palette, - // Can't reduce if there is no palette + ColorType::Indexed { palette } if palette.len() > 1 => palette, _ => return None, }; - if png.ihdr.bit_depth == BitDepth::One { - // Gains from 1-bit images will be at most 1 byte - // Not worth the CPU time - return None; - } - let mut palette_map = [None; 256]; - let mut used = [false; 256]; - { - // Find palette entries that are never used - match png.ihdr.bit_depth { - BitDepth::Eight => { - for &byte in &png.data { - used[byte as usize] = true; - } - } - 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 used = get_used_entries(png); + + let black = RGBA8::new(0, 0, 0, 255); + let mut condensed = IndexSet::with_capacity(palette.len()); + let mut palette_map = [0; 256]; + let mut did_change = false; + for (i, used) in used.iter().enumerate() { + if !used { + continue; } - - let mut used_enumerated: Vec<(usize, &bool)> = used.iter().enumerate().collect(); - used_enumerated.sort_by(|a, b| { - //Sort by ascending alpha and descending luma. - let color_val = |i| { - let color = palette - .get(i) - .copied() - .unwrap_or_else(|| RGBA8::new(0, 0, 0, 255)); - ((color.a as i32) << 18) - // These are coefficients for standard sRGB to luma conversion - - i32::from(color.r) * 299 - - i32::from(color.g) * 587 - - i32::from(color.b) * 114 - }; - color_val(a.0).cmp(&color_val(b.0)) - }); - - // Make sure the background is also included, but only after sorting since it may not be used in idat - if let Some(&idx) = png.aux_headers.get(b"bKGD").and_then(|b| b.first()) { - if !used[idx as usize] { - used_enumerated.push((idx as usize, &true)); - } - } - - let mut next_index = 0_u16; - let mut seen = IndexMap::with_capacity(palette.len()); - for (i, used) in used_enumerated.iter().cloned() { - if !used { - continue; - } - // There are invalid files that use pixel indices beyond palette size - let mut color = palette - .get(i) - .cloned() - .unwrap_or_else(|| RGBA8::new(0, 0, 0, 255)); - // If there are multiple fully transparent entries, reduce them into one - if optimize_alpha && color.a == 0 { - color.r = 0; - color.g = 0; - color.b = 0; - } - match seen.entry(color) { - Vacant(new) => { - palette_map[i] = Some(next_index as u8); - new.insert(next_index as u8); - next_index += 1; - } - Occupied(remap_to) => palette_map[i] = Some(*remap_to.get()), - } + // There are invalid files that use pixel indices beyond palette size + let color = *palette.get(i).unwrap_or(&black); + palette_map[i] = add_color_to_set(color, &mut condensed, optimize_alpha); + if palette_map[i] as usize != i { + did_change = true; } } - do_palette_reduction(png, palette, &palette_map) -} - -#[must_use] -fn do_palette_reduction( - png: &PngImage, - palette: &[RGBA8], - palette_map: &[Option; 256], -) -> Option { - let byte_map = palette_map_to_byte_map(png, palette_map)?; - - // Reassign data bytes to new indices - let raw_data = png.data.iter().map(|b| byte_map[*b as usize]).collect(); - + // Update bKGD if it exists, ensuring it comes last in the palette if otherwise unused let mut aux_headers = png.aux_headers.clone(); - if let Some(bkgd_header) = png.aux_headers.get(b"bKGD") { - if let Some(Some(map_to)) = bkgd_header - .first() - .and_then(|&idx| palette_map.get(idx as usize)) - { - aux_headers.insert(*b"bKGD", vec![*map_to]); + if let Some(idx) = aux_headers.remove(b"bKGD").and_then(|b| b.first().cloned()) { + if let Some(&color) = palette.get(idx as usize) { + let idx = add_color_to_set(color, &mut condensed, optimize_alpha); + aux_headers.insert(*b"bKGD", vec![idx]); } } + let data = if did_change { + // Reassign data bytes to new indices + let byte_map = palette_map_to_byte_map(png.ihdr.bit_depth, &palette_map); + png.data.iter().map(|b| byte_map[*b as usize]).collect() + } else if condensed.len() < palette.len() { + // Data is unchanged but palette will be truncated + png.data.clone() + } else { + // Nothing has changed + return None; + }; + + let palette: Vec<_> = condensed.into_iter().collect(); + Some(PngImage { ihdr: IhdrData { - color_type: ColorType::Indexed { - palette: reordered_palette(palette, palette_map), - }, + color_type: ColorType::Indexed { palette }, ..png.ihdr }, - data: raw_data, + data, aux_headers, }) } -fn palette_map_to_byte_map(png: &PngImage, palette_map: &[Option; 256]) -> Option<[u8; 256]> { - if (0..256).all(|i| palette_map[i].map_or(true, |to| to == i as u8)) { - // No reduction necessary - return None; +fn add_color_to_set(mut color: RGBA8, set: &mut IndexSet, optimize_alpha: bool) -> u8 { + // If there are multiple fully transparent entries, reduce them into one + if optimize_alpha && color.a == 0 { + color.r = 0; + color.g = 0; + color.b = 0; } + let (idx, _) = set.insert_full(color); + idx as u8 +} - let mut byte_map = [0_u8; 256]; - - // low bit-depths can be pre-computed for every byte value +fn get_used_entries(png: &PngImage) -> [bool; 256] { + let mut used = [false; 256]; match png.ihdr.bit_depth { BitDepth::Eight => { - for byte in 0..=255usize { - byte_map[byte] = palette_map[byte].unwrap_or(0) + for &byte in &png.data { + used[byte as usize] = true; } } BitDepth::Four => { - for byte in 0..=255usize { - byte_map[byte] = palette_map[byte & 0x0F].unwrap_or(0) - | (palette_map[byte >> 4].unwrap_or(0) << 4); + for &byte in &png.data { + used[(byte & 0x0F) as usize] = true; + used[(byte >> 4) as usize] = true; } } BitDepth::Two => { - for byte in 0..=255usize { - byte_map[byte] = palette_map[byte & 0x03].unwrap_or(0) - | (palette_map[(byte >> 2) & 0x03].unwrap_or(0) << 2) - | (palette_map[(byte >> 4) & 0x03].unwrap_or(0) << 4) - | (palette_map[byte >> 6].unwrap_or(0) << 6); + 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; } } - _ => {} - } - - Some(byte_map) -} - -fn reordered_palette(palette: &[RGBA8], palette_map: &[Option; 256]) -> Vec { - let max_index = palette_map.iter().cloned().flatten().max().unwrap_or(0) as usize; - let mut new_palette = vec![RGBA8::new(0, 0, 0, 255); max_index + 1]; - for (&color, &map_to) in palette.iter().zip(palette_map.iter()) { - if let Some(map_to) = map_to { - new_palette[map_to as usize] = color; + BitDepth::One => { + // Only two options, don't bother checking which are actually used + used[0] = true; + used[1] = true; } - } - new_palette + _ => unreachable!(), + }; + used +} + +fn palette_map_to_byte_map(bit_depth: BitDepth, palette_map: &[u8; 256]) -> [u8; 256] { + // Low bit-depths can be pre-computed for every byte value + match bit_depth { + BitDepth::Eight => *palette_map, + BitDepth::Four => { + let mut byte_map = [0_u8; 256]; + for byte in 0..256 { + byte_map[byte] = palette_map[byte & 0x0F] | (palette_map[byte >> 4] << 4); + } + byte_map + } + BitDepth::Two => { + let mut byte_map = [0_u8; 256]; + for byte in 0..256 { + byte_map[byte] = palette_map[byte & 0x03] + | (palette_map[(byte >> 2) & 0x03] << 2) + | (palette_map[(byte >> 4) & 0x03] << 4) + | (palette_map[byte >> 6] << 6); + } + byte_map + } + _ => unreachable!(), + } +} + +/// Attempt to sort the colors in the palette, returning the sorted image if successful +#[must_use] +pub fn sorted_palette(png: &PngImage) -> Option { + if png.ihdr.bit_depth == BitDepth::One { + // Don't bother trying to sort a 1-bit image + return None; + } + let palette = match &png.ihdr.color_type { + ColorType::Indexed { palette } => palette, + _ => return None, + }; + + let mut enumerated: Vec<_> = palette.iter().enumerate().collect(); + + // If the background is the last entry in the palette we should make sure it stays last + // Otherwise an entry that's unused by the idat could prevent reduction to a lower depth + let mut aux_headers = png.aux_headers.clone(); + let bkgd_idx = aux_headers.remove(b"bKGD").and_then(|b| b.first().cloned()); + let bkgd_last = match bkgd_idx { + Some(idx) if idx as usize + 1 == palette.len() => enumerated.pop(), + _ => None, + }; + + // Sort the palette + enumerated.sort_by(|a, b| { + // Sort by ascending alpha and descending luma + let color_val = |color: &RGBA8| { + ((color.a as i32) << 18) + // These are coefficients for standard sRGB to luma conversion + - i32::from(color.r) * 299 + - i32::from(color.g) * 587 + - i32::from(color.b) * 114 + }; + color_val(a.1).cmp(&color_val(b.1)) + }); + + if let Some(bkgd) = bkgd_last { + enumerated.push(bkgd); + } + + // Extract the new palette and determine if anything changed + let (old_map, palette): (Vec<_>, Vec) = enumerated.into_iter().unzip(); + if old_map.iter().enumerate().all(|(a, b)| a == *b) { + return None; + } + + // Construct the palette and byte maps and convert the data + let mut new_map = [0; 256]; + for (i, &v) in old_map.iter().enumerate() { + new_map[v] = i as u8; + } + let byte_map = palette_map_to_byte_map(png.ihdr.bit_depth, &new_map); + let data = png.data.iter().map(|&b| byte_map[b as usize]).collect(); + + // Update bKGD if it exists + if let Some(idx) = bkgd_idx.map(|idx| new_map[idx as usize]) { + aux_headers.insert(*b"bKGD", vec![idx]); + } + + Some(PngImage { + ihdr: IhdrData { + color_type: ColorType::Indexed { palette }, + ..png.ihdr + }, + data, + aux_headers, + }) } diff --git a/tests/files/grayscale_8_should_be_grayscale_4.png b/tests/files/grayscale_8_should_be_grayscale_4.png index c561432e..e5e62cb1 100644 Binary files a/tests/files/grayscale_8_should_be_grayscale_4.png and b/tests/files/grayscale_8_should_be_grayscale_4.png differ diff --git a/tests/files/grayscale_8_should_be_palette_8.png b/tests/files/grayscale_8_should_be_palette_8.png new file mode 100644 index 00000000..ff94dfb8 Binary files /dev/null and b/tests/files/grayscale_8_should_be_palette_8.png differ diff --git a/tests/files/palette_8_should_be_rgb.png b/tests/files/palette_8_should_be_rgb.png new file mode 100644 index 00000000..cb6b1678 Binary files /dev/null and b/tests/files/palette_8_should_be_rgb.png differ diff --git a/tests/files/palette_8_should_be_rgba.png b/tests/files/palette_8_should_be_rgba.png new file mode 100644 index 00000000..351a7a1b Binary files /dev/null and b/tests/files/palette_8_should_be_rgba.png differ diff --git a/tests/flags.rs b/tests/flags.rs index 3690f21a..ebb2108e 100644 --- a/tests/flags.rs +++ b/tests/flags.rs @@ -427,8 +427,8 @@ fn interlacing_0_to_1_small_files() { }; assert_eq!(png.raw.ihdr.interlaced, Interlacing::Adam7); - assert_eq!(png.raw.ihdr.color_type.png_header_code(), INDEXED); - assert_eq!(png.raw.ihdr.bit_depth, BitDepth::One); + assert_eq!(png.raw.ihdr.color_type.png_header_code(), RGB); + assert_eq!(png.raw.ihdr.bit_depth, BitDepth::Eight); remove_file(output).ok(); } @@ -461,8 +461,8 @@ fn interlacing_1_to_0_small_files() { }; assert_eq!(png.raw.ihdr.interlaced, Interlacing::None); - assert_eq!(png.raw.ihdr.color_type.png_header_code(), INDEXED); - // the depth can't be asserted reliably, because on such small file different zlib implementations pick different depth as the best + assert_eq!(png.raw.ihdr.color_type.png_header_code(), RGB); + assert_eq!(png.raw.ihdr.bit_depth, BitDepth::Eight); remove_file(output).ok(); } diff --git a/tests/interlaced.rs b/tests/interlaced.rs index ff884b1a..52540a85 100644 --- a/tests/interlaced.rs +++ b/tests/interlaced.rs @@ -546,6 +546,17 @@ fn interlaced_palette_1_should_be_palette_1() { ); } +#[test] +fn interlaced_palette_8_should_be_grayscale_8() { + test_it_converts( + "tests/files/interlaced_palette_8_should_be_grayscale_8.png", + INDEXED, + BitDepth::Eight, + GRAYSCALE, + BitDepth::Eight, + ); +} + #[test] fn interlaced_grayscale_alpha_16_should_be_grayscale_alpha_16() { test_it_converts( @@ -651,8 +662,8 @@ fn interlaced_small_files() { "tests/files/interlaced_small_files.png", INDEXED, BitDepth::Eight, - INDEXED, - BitDepth::One, + RGB, + BitDepth::Eight, ); } diff --git a/tests/reduction.rs b/tests/reduction.rs index a7fe5e39..3d6577eb 100644 --- a/tests/reduction.rs +++ b/tests/reduction.rs @@ -556,6 +556,42 @@ fn palette_4_should_be_palette_2() { ); } +#[test] +fn palette_8_should_be_grayscale_8() { + test_it_converts( + "tests/files/palette_8_should_be_grayscale_8.png", + false, + INDEXED, + BitDepth::Eight, + GRAYSCALE, + BitDepth::Eight, + ); +} + +#[test] +fn palette_8_should_be_rgb() { + test_it_converts( + "tests/files/palette_8_should_be_rgb.png", + false, + INDEXED, + BitDepth::Eight, + RGB, + BitDepth::Eight, + ); +} + +#[test] +fn palette_8_should_be_rgba() { + test_it_converts( + "tests/files/palette_8_should_be_rgba.png", + false, + INDEXED, + BitDepth::Eight, + RGBA, + BitDepth::Eight, + ); +} + #[test] fn palette_2_should_be_palette_2() { test_it_converts( @@ -808,6 +844,54 @@ fn grayscale_2_should_be_grayscale_1() { ); } +#[test] +fn grayscale_8_should_be_palette_8() { + test_it_converts( + "tests/files/grayscale_8_should_be_palette_8.png", + false, + GRAYSCALE, + BitDepth::Eight, + INDEXED, + BitDepth::Eight, + ); +} + +#[test] +fn grayscale_8_should_be_palette_4() { + test_it_converts( + "tests/files/grayscale_8_should_be_palette_4.png", + false, + GRAYSCALE, + BitDepth::Eight, + INDEXED, + BitDepth::Four, + ); +} + +#[test] +fn grayscale_8_should_be_palette_2() { + test_it_converts( + "tests/files/grayscale_8_should_be_palette_2.png", + false, + GRAYSCALE, + BitDepth::Eight, + INDEXED, + BitDepth::Two, + ); +} + +#[test] +fn grayscale_8_should_be_palette_1() { + test_it_converts( + "tests/files/grayscale_8_should_be_palette_1.png", + false, + GRAYSCALE, + BitDepth::Eight, + INDEXED, + BitDepth::One, + ); +} + #[test] fn grayscale_alpha_16_should_be_grayscale_trns_16() { test_it_converts( @@ -834,33 +918,14 @@ fn grayscale_alpha_8_should_be_grayscale_trns_8() { #[test] fn small_files() { - let input = PathBuf::from("tests/files/small_files.png"); - let (output, opts) = get_opts(&input); - - let png = PngData::new(&input, opts.fix_errors).unwrap(); - - assert_eq!(png.raw.ihdr.color_type.png_header_code(), INDEXED); - assert_eq!(png.raw.ihdr.bit_depth, BitDepth::Eight); - - match oxipng::optimize(&InFile::Path(input), &output, &opts) { - Ok(_) => (), - Err(x) => panic!("{}", x), - }; - let output = output.path().unwrap(); - assert!(output.exists()); - - let png = match PngData::new(output, opts.fix_errors) { - Ok(x) => x, - Err(x) => { - remove_file(output).ok(); - panic!("{}", x) - } - }; - - assert_eq!(png.raw.ihdr.color_type.png_header_code(), INDEXED); - // depth varies depending on zlib implementation used - - remove_file(output).ok(); + test_it_converts( + "tests/files/small_files.png", + false, + INDEXED, + BitDepth::Eight, + RGB, + BitDepth::Eight, + ); } #[test]