From 491d753edc4a5e77e99a54327035e5a842498574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Mon, 12 Jul 2021 05:21:38 +0200 Subject: [PATCH] Add option to skip grayscale reduction of RGB(A) images (#409) For RGB(A) images that contain gray colors, this reduction can achieve significant space savings. However, in the absence of gamma correction data, some PNG decoders assume more exotic color spaces for grayscale images instead of the ubiquitous sRGB. This results in gamma miscorrection, and for the end user this means that colors will look wrong, like "washed-out". Java's ImageIO class, which is popular in the JVM world to read PNG files, uses rather unconventional defaults, as explained in this StackOverflow question: https://stackoverflow.com/questions/31312645/java-imageio-grayscale-png-issue Gamma miscorrection problems aside, OxiPNG currently tries hard to reduce RGB(A) images to grayscale, because it expects that reduction to be quite effective. However, in some cases, OxiPNG generates smaller PNG files when reducing grasycale RGB(A) images to paletted color than actual grayscale color. For example, let's say that "~/gray.png" is a 256x256 RGBA image entirely filled with (119, 119, 119, 255) pixels. OxiPNG, by default, reduces this image to grayscale and achieves a 68.23% decrease: $ cargo build --release && target/release/oxipng -omax --out ~/out.png ~/gray.png Processing: /home/user/gray.png 256x256 pixels, PNG format 4x8 bits/pixel, RGBA IDAT size = 604 bytes File size = 661 bytes Reducing image to 1x4 bits/pixel, Grayscale Trying: 144 combinations Found better combination: zc = 6 zs = 0 f = 0 153 bytes IDAT size = 153 bytes (451 bytes decrease) file size = 210 bytes (451 bytes = 68.23% decrease) Output: /home/user/out.png However, if the --ng option that this commit adds is used to skip the grayscale reduction step, OxiPNG reduces to a single color palette instead, which is much more efficient, achieving a 84.42% decrease: $ cargo build --release && target/release/oxipng -omax --ng --out ~/out.png ~/gray.png Processing: /home/alejandro/gray.png 256x256 pixels, PNG format 4x8 bits/pixel, RGBA IDAT size = 604 bytes File size = 661 bytes Reducing image to 1 bits/pixel, 1 colors in palette Trying: 144 combinations Found better combination: zc = 3 zs = 3 f = 0 31 bytes IDAT size = 31 bytes (573 bytes decrease) file size = 103 bytes (558 bytes = 84.42% decrease) Output: /home/alejandro/out.png While OxiPNG should arguably be made smarter to better handle these cases, in the meantime, adding an option to manually skip that grayscale reduction can't hurt. In fact, it may even help users achieving the most out of current versions of OxiPNG, and developers reasoning about what makes a grayscale-like RGB(A) image compress better with a color palette. Due to the reasons stated above, this adds a simple "grayscale_reduction" option to the Options struct, and a "no-grayscale-reduction" command line switch, that makes OxiPNG not try this problematic grayscale reduction on RGB(A) images. --- benches/reductions.rs | 20 ++++++++++---------- src/lib.rs | 7 ++++++- src/main.rs | 8 ++++++++ src/reduction/mod.rs | 18 +++++++++++++----- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/benches/reductions.rs b/benches/reductions.rs index e94d8280..30eebe17 100644 --- a/benches/reductions.rs +++ b/benches/reductions.rs @@ -80,7 +80,7 @@ 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")); let png = PngData::new(&input, false).unwrap(); - b.iter(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -88,7 +88,7 @@ fn reductions_rgba_to_rgb_8(b: &mut Bencher) { let input = test::black_box(PathBuf::from("tests/files/rgba_8_should_be_rgb_8.png")); let png = PngData::new(&input, false).unwrap(); - b.iter(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -98,7 +98,7 @@ fn reductions_rgba_to_grayscale_alpha_16(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -108,7 +108,7 @@ fn reductions_rgba_to_grayscale_alpha_8(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -118,7 +118,7 @@ fn reductions_rgba_to_grayscale_16(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -128,7 +128,7 @@ fn reductions_rgba_to_grayscale_8(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -138,7 +138,7 @@ fn reductions_rgb_to_grayscale_16(b: &mut Bencher) { )); let png = PngData::new(&input, false).unwrap(); - b.iter(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -146,7 +146,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(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -154,7 +154,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(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] @@ -162,7 +162,7 @@ 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(|| reduce_color_type(&png.raw)); + b.iter(|| reduce_color_type(&png.raw, true)); } #[bench] diff --git a/src/lib.rs b/src/lib.rs index 0b6fc479..239968de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -169,6 +169,10 @@ pub struct Options { /// /// Default: `true` pub palette_reduction: bool, + /// Whether to attempt grayscale reduction + /// + /// Default: `true` + pub grayscale_reduction: bool, /// Whether to perform IDAT recoding /// /// If any type of reduction is performed, IDAT recoding will be performed @@ -305,6 +309,7 @@ impl Default for Options { bit_depth_reduction: true, color_type_reduction: true, palette_reduction: true, + grayscale_reduction: true, idat_recoding: true, strip: Headers::None, deflate: Deflaters::Zlib { @@ -741,7 +746,7 @@ fn perform_reductions( } if opts.color_type_reduction { - if let Some(reduced) = reduce_color_type(&png) { + if let Some(reduced) = reduce_color_type(&png, opts.grayscale_reduction) { png = Arc::new(reduced); eval.try_image(png.clone()); report_reduction(&png); diff --git a/src/main.rs b/src/main.rs index 20c8f16b..1a269dd4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,6 +184,9 @@ fn main() { .arg(Arg::with_name("no-palette-reduction") .help("No palette reduction") .long("np")) + .arg(Arg::with_name("no-grayscale-reduction") + .help("No grayscale reduction") + .long("ng")) .arg(Arg::with_name("no-reductions") .help("No reductions") .long("nx")) @@ -433,10 +436,15 @@ fn parse_opts_into_struct( opts.palette_reduction = false; } + if matches.is_present("no-grayscale-reduction") { + opts.grayscale_reduction = false; + } + if matches.is_present("no-reductions") { opts.bit_depth_reduction = false; opts.color_type_reduction = false; opts.palette_reduction = false; + opts.grayscale_reduction = false; } if matches.is_present("no-recoding") { diff --git a/src/reduction/mod.rs b/src/reduction/mod.rs index e6f4bf7d..6b89b839 100644 --- a/src/reduction/mod.rs +++ b/src/reduction/mod.rs @@ -185,15 +185,19 @@ fn reordered_palette(palette: &[RGBA8], palette_map: &[Option; 256]) -> Vec< /// Attempt to reduce the color type of the image /// Returns true if the color type was reduced, false otherwise -pub fn reduce_color_type(png: &PngImage) -> Option { +pub fn reduce_color_type(png: &PngImage, grayscale_reduction: bool) -> Option { let mut should_reduce_bit_depth = false; let mut reduced = Cow::Borrowed(png); // 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) = - reduce_rgba_to_grayscale_alpha(&reduced).or_else(|| reduced_alpha_channel(&reduced)) + if let Some(r) = if grayscale_reduction { + reduce_rgba_to_grayscale_alpha(&reduced) + } else { + None + } + .or_else(|| reduced_alpha_channel(&reduced)) { reduced = Cow::Owned(r); } else if let Some(r) = reduced_color_to_palette(&reduced) { @@ -210,8 +214,12 @@ pub fn reduce_color_type(png: &PngImage) -> Option { } if reduced.ihdr.color_type == ColorType::RGB { - if let Some(r) = - reduce_rgb_to_grayscale(&reduced).or_else(|| reduced_color_to_palette(&reduced)) + if let Some(r) = if grayscale_reduction { + reduce_rgb_to_grayscale(&reduced) + } else { + None + } + .or_else(|| reduced_color_to_palette(&reduced)) { reduced = Cow::Owned(r); should_reduce_bit_depth = true;