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.
This commit is contained in:
Alejandro González 2021-07-12 05:21:38 +02:00 committed by GitHub
parent c4e46b2f3e
commit 491d753edc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 37 additions and 16 deletions

View file

@ -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]

View file

@ -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);

View file

@ -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") {

View file

@ -185,15 +185,19 @@ fn reordered_palette(palette: &[RGBA8], palette_map: &[Option<u8>; 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<PngImage> {
pub fn reduce_color_type(png: &PngImage, grayscale_reduction: bool) -> Option<PngImage> {
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<PngImage> {
}
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;