Repository: shssoichiro/oxipng
Pull request #516 (2023-06-02 07:28:26)

Expand low depths to 8-bit
This PR adds a depth expansion step during reductions. There are two benefits to this:
1. We can now evaluate existing low depth images at 8-bit and alternative colour types, which can be better sometimes.
2. Some reductions can be simplified as they no longer need to work with low depths. This is particularly helpful for #514 where the new palette sorting method only supports 8-bit.

issue-82.png saves 26 bytes as a result of this.

Tracking whether reduction actually occurred became a little tricky with this, so I've simplified it by just checking if anything changed after reductions are performed. I also added some tests for some of the flags that disable certain reductions.


Patch:
diff --git a/benches/reductions.rs b/benches/reductions.rs
index a5a7b7d8..fedecf5f 100644
--- a/benches/reductions.rs
+++ b/benches/reductions.rs
@@ -31,7 +31,7 @@ fn reductions_8_to_4_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -41,7 +41,7 @@ fn reductions_8_to_2_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -51,7 +51,7 @@ fn reductions_8_to_1_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -61,7 +61,7 @@ fn reductions_4_to_2_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -71,7 +71,7 @@ fn reductions_4_to_1_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -81,7 +81,7 @@ fn reductions_2_to_1_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -91,7 +91,7 @@ fn reductions_grayscale_8_to_4_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -101,7 +101,7 @@ fn reductions_grayscale_8_to_2_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -111,7 +111,7 @@ fn reductions_grayscale_8_to_1_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -121,7 +121,7 @@ fn reductions_grayscale_4_to_2_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -131,7 +131,7 @@ fn reductions_grayscale_4_to_1_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
@@ -141,7 +141,7 @@ fn reductions_grayscale_2_to_1_bits(b: &mut Bencher) {
     ));
     let png = PngData::new(&input, &Options::default()).unwrap();
 
-    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw, 1));
+    b.iter(|| bit_depth::reduced_bit_depth_8_or_less(&png.raw));
 }
 
 #[bench]
diff --git a/src/evaluate.rs b/src/evaluate.rs
index 8c05fde7..0d187198 100644
--- a/src/evaluate.rs
+++ b/src/evaluate.rs
@@ -25,7 +25,6 @@ pub struct Candidate {
     pub idat_data: Vec<u8>,
     pub filtered: Vec<u8>,
     pub filter: RowFilter,
-    pub is_reduction: bool,
     // first wins tie-breaker
     nth: usize,
 }
@@ -95,11 +94,6 @@ impl Evaluator {
         self.eval_best_candidate.into_inner()
     }
 
-    /// Set baseline image. It will be used only to measure minimum compression level required
-    pub fn set_baseline(&self, image: Arc<PngImage>) {
-        self.try_image_inner(image, false)
-    }
-
     /// Set best size, if known in advance
     pub fn set_best_size(&self, size: usize) {
         self.best_candidate_size.set_min(size);
@@ -107,10 +101,6 @@ impl Evaluator {
 
     /// Check if the image is smaller than others
     pub fn try_image(&self, image: Arc<PngImage>) {
-        self.try_image_inner(image, true)
-    }
-
-    fn try_image_inner(&self, image: Arc<PngImage>, is_reduction: bool) {
         let nth = self.nth.fetch_add(1, SeqCst);
         // These clones are only cheap refcounts
         let deadline = self.deadline.clone();
@@ -150,7 +140,6 @@ impl Evaluator {
                         idat_data,
                         filtered,
                         filter,
-                        is_reduction,
                         nth,
                     };
 
diff --git a/src/lib.rs b/src/lib.rs
index bbb9919e..d870da55 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -615,7 +615,7 @@ fn optimize_png(
 
 /// Perform optimization on the input image data using the options provided
 fn optimize_raw(
-    mut png: Arc<PngImage>,
+    image: Arc<PngImage>,
     opts: &Options,
     deadline: Arc<Deadline>,
     max_size: Option<usize>,
@@ -631,16 +631,14 @@ fn optimize_raw(
         eval_compression,
         false,
     );
-    let (baseline, mut reduction_occurred) =
-        perform_reductions(png.clone(), opts, &deadline, &eval);
-    png = baseline;
+    let mut png = perform_reductions(image.clone(), opts, &deadline, &eval);
     let mut eval_result = eval.get_best_candidate();
     if let Some(ref result) = eval_result {
-        if result.is_reduction {
-            png = result.image.clone();
-            reduction_occurred = true;
-        }
+        png = result.image.clone();
     }
+    let reduction_occurred = png.ihdr.color_type != image.ihdr.color_type
+        || png.ihdr.bit_depth != image.ihdr.bit_depth
+        || png.ihdr.interlaced != image.ihdr.interlaced;
 
     if reduction_occurred {
         report_format("Reducing image to ", &png);
diff --git a/src/png/scan_lines.rs b/src/png/scan_lines.rs
index 1c612f89..0ea33ae1 100644
--- a/src/png/scan_lines.rs
+++ b/src/png/scan_lines.rs
@@ -25,7 +25,7 @@ impl<'a> Iterator for ScanLines<'a> {
     type Item = ScanLine<'a>;
     #[inline]
     fn next(&mut self) -> Option<Self::Item> {
-        self.iter.next().map(|(len, pass)| {
+        self.iter.next().map(|(len, pass, num_pixels)| {
             let (data, rest) = self.raw_data.split_at(len);
             self.raw_data = rest;
             let (&filter, data) = if self.has_filter {
@@ -33,7 +33,12 @@ impl<'a> Iterator for ScanLines<'a> {
             } else {
                 (&0, data)
             };
-            ScanLine { filter, data, pass }
+            ScanLine {
+                filter,
+                data,
+                pass,
+                num_pixels,
+            }
         })
     }
 }
@@ -68,7 +73,7 @@ impl ScanLineRanges {
 }
 
 impl Iterator for ScanLineRanges {
-    type Item = (usize, Option<u8>);
+    type Item = (usize, Option<u8>, usize);
     fn next(&mut self) -> Option<Self::Item> {
         if self.left == 0 {
             return None;
@@ -149,7 +154,7 @@ impl Iterator for ScanLineRanges {
             len += 1;
         }
         self.left = self.left.checked_sub(len)?;
-        Some((len, current_pass))
+        Some((len, current_pass, pixels_per_line as usize))
     }
 }
 
@@ -162,4 +167,6 @@ pub struct ScanLine<'a> {
     pub data: &'a [u8],
     /// The current pass if the image is interlaced
     pub pass: Option<u8>,
+    /// The number of pixels in the current scan line
+    pub num_pixels: usize,
 }
diff --git a/src/reduction/bit_depth.rs b/src/reduction/bit_depth.rs
index 7a4a1ca5..e36536b7 100644
--- a/src/reduction/bit_depth.rs
+++ b/src/reduction/bit_depth.rs
@@ -61,60 +61,48 @@ pub fn scaled_bit_depth_16_to_8(png: &PngImage) -> Option<PngImage> {
     })
 }
 
-/// Attempt to reduce an 8/4/2-bit image to a lower bit depth, returning the reduced image if successful
+/// Attempt to reduce an 8-bit image to a lower bit depth, returning the reduced image if successful
 #[must_use]
-pub fn reduced_bit_depth_8_or_less(png: &PngImage, mut minimum_bits: usize) -> Option<PngImage> {
-    assert!((1..8).contains(&minimum_bits));
-    let bit_depth = png.ihdr.bit_depth as usize;
-    if minimum_bits >= bit_depth || bit_depth > 8 || png.channels_per_pixel() != 1 {
+pub fn reduced_bit_depth_8_or_less(png: &PngImage) -> Option<PngImage> {
+    if png.ihdr.bit_depth != BitDepth::Eight || png.channels_per_pixel() != 1 {
         return None;
     }
-    // Calculate the current number of pixels per byte
-    let ppb = 8 / bit_depth;
+
+    let mut minimum_bits = 1;
 
     if let ColorType::Indexed { palette } = &png.ihdr.color_type {
         // We can easily determine minimum depth by the palette size
-        let required_bits = match palette.len() {
+        minimum_bits = match palette.len() {
             0..=2 => 1,
             3..=4 => 2,
             5..=16 => 4,
-            _ => 8,
+            _ => return None,
         };
-        if required_bits >= bit_depth {
-            // Not reducable
-            return None;
-        } else if required_bits > minimum_bits {
-            minimum_bits = required_bits;
-        }
     } else {
         // Finding minimum depth for grayscale is much more complicated
-        let mut mask = (1 << minimum_bits) - 1;
-        let mut divisions = 1..(bit_depth / minimum_bits);
+        let mut mask = 1;
+        let mut divisions = 1..8;
         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
+                // Align the first pixel division with the mask
+                let mut byte = b.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);
-                    // 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;
+                    if byte & mask != compare {
+                        // This depth is not possible, try the next one up
+                        minimum_bits <<= 1;
+                        if minimum_bits == 8 {
+                            return None;
                         }
+                        mask = (1 << minimum_bits) - 1;
+                        divisions = 1..(8 / minimum_bits);
+                        continue 'try_depth;
                     }
                 }
                 break;
@@ -126,18 +114,13 @@ pub fn reduced_bit_depth_8_or_less(png: &PngImage, mut minimum_bits: usize) -> O
     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) {
+        for chunk in line.data.chunks(8 / 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;
-                }
+            for byte in chunk {
+                shift -= minimum_bits;
+                // Take the low bits of the pixel and shift them into the output byte
+                new_byte |= (byte & mask) << shift;
             }
             reduced.push(new_byte);
         }
@@ -148,11 +131,11 @@ pub fn reduced_bit_depth_8_or_less(png: &PngImage, mut minimum_bits: usize) -> O
         transparent_shade: Some(trans),
     } = png.ihdr.color_type
     {
-        let reduced_trans = (trans & 0xFF) >> (bit_depth - minimum_bits);
+        let reduced_trans = (trans & 0xFF) >> (8 - minimum_bits);
         // Verify the reduction is valid by restoring back to original bit depth
         let mut check = reduced_trans;
         let mut bits = minimum_bits;
-        while bits < bit_depth {
+        while bits < 8 {
             check = check << bits | check;
             bits <<= 1;
         }
@@ -177,3 +160,67 @@ pub fn reduced_bit_depth_8_or_less(png: &PngImage, mut minimum_bits: usize) -> O
         },
     })
 }
+
+/// Expand a 1/2/4-bit image to 8-bit, returning the expanded image if successful
+#[must_use]
+pub fn expanded_bit_depth_to_8(png: &PngImage) -> Option<PngImage> {
+    let bit_depth = png.ihdr.bit_depth as u32;
+    if bit_depth >= 8 {
+        return None;
+    }
+    // Calculate the current number of pixels per byte
+    let ppb = 8 / bit_depth;
+    let is_gray = matches!(png.ihdr.color_type, ColorType::Grayscale { .. });
+
+    let mut reduced = Vec::with_capacity((png.ihdr.width * png.ihdr.height) as usize);
+    let mut length = 0;
+    let mask = (1 << bit_depth) - 1;
+    for line in png.scan_lines(false) {
+        for &(mut byte) in line.data {
+            // Loop over each pixel in the byte
+            for _ in 0..ppb {
+                // Align the current pixel with the mask
+                byte = byte.rotate_left(bit_depth);
+                let mut val = byte & mask;
+                if is_gray {
+                    // Expand gray by repeating the bits
+                    let mut bits = bit_depth;
+                    while bits < 8 {
+                        val = val << bits | val;
+                        bits <<= 1;
+                    }
+                }
+                reduced.push(val);
+            }
+        }
+        // Trim any overflow
+        length += line.num_pixels;
+        reduced.truncate(length);
+    }
+
+    // If the image is grayscale we also need to expand the transparency pixel
+    let color_type = if let ColorType::Grayscale {
+        transparent_shade: Some(mut trans),
+    } = png.ihdr.color_type
+    {
+        let mut bits = bit_depth;
+        while bits < 8 {
+            trans = trans << bits | trans;
+            bits <<= 1;
+        }
+        ColorType::Grayscale {
+            transparent_shade: Some(trans),
+        }
+    } else {
+        png.ihdr.color_type.clone()
+    };
+
+    Some(PngImage {
+        data: reduced,
+        ihdr: IhdrData {
+            color_type,
+            bit_depth: BitDepth::Eight,
+            ..png.ihdr
+        },
+    })
+}
diff --git a/src/reduction/mod.rs b/src/reduction/mod.rs
index a5ea3c90..bb464739 100644
--- a/src/reduction/mod.rs
+++ b/src/reduction/mod.rs
@@ -18,15 +18,13 @@ pub(crate) fn perform_reductions(
     opts: &Options,
     deadline: &Deadline,
     eval: &Evaluator,
-) -> (Arc<PngImage>, bool) {
-    let mut reduction_occurred = false;
+) -> Arc<PngImage> {
     let mut evaluation_added = false;
 
     // Interlacing must be processed first in order to evaluate the rest correctly
     if let Some(interlacing) = opts.interlace {
         if let Some(reduced) = png.change_interlacing(interlacing) {
             png = Arc::new(reduced);
-            reduction_occurred = true;
         }
     }
 
@@ -35,7 +33,6 @@ pub(crate) fn perform_reductions(
     if opts.optimize_alpha && !deadline.passed() {
         if let Some(reduced) = cleaned_alpha_channel(&png) {
             png = Arc::new(reduced);
-            // This does not count as a reduction
         }
     }
 
@@ -44,7 +41,6 @@ pub(crate) fn perform_reductions(
     if opts.bit_depth_reduction && !deadline.passed() {
         if let Some(reduced) = reduced_bit_depth_16_to_8(&png, opts.scale_16) {
             png = Arc::new(reduced);
-            reduction_occurred = true;
         }
     }
 
@@ -53,7 +49,14 @@ pub(crate) fn perform_reductions(
     if opts.color_type_reduction && opts.grayscale_reduction && !deadline.passed() {
         if let Some(reduced) = reduced_rgb_to_grayscale(&png) {
             png = Arc::new(reduced);
-            reduction_occurred = true;
+        }
+    }
+
+    // Attempt to expand the bit depth to 8
+    // This does need to be evaluated but will be done so later when it gets reduced again
+    if opts.bit_depth_reduction && !deadline.passed() {
+        if let Some(reduced) = expanded_bit_depth_to_8(&png) {
+            png = Arc::new(reduced);
         }
     }
 
@@ -62,7 +65,6 @@ pub(crate) fn perform_reductions(
     if opts.palette_reduction && !deadline.passed() {
         if let Some(reduced) = reduced_palette(&png, opts.optimize_alpha) {
             png = Arc::new(reduced);
-            reduction_occurred = true;
         }
     }
 
@@ -81,7 +83,6 @@ pub(crate) fn perform_reductions(
                 evaluation_added = true;
             } else {
                 baseline = png.clone();
-                reduction_occurred = true;
             }
         }
     }
@@ -118,7 +119,6 @@ pub(crate) fn perform_reductions(
                 evaluation_added = true;
             } else {
                 baseline = new.clone();
-                reduction_occurred = true;
             }
             indexed = Some(new);
         }
@@ -128,8 +128,8 @@ pub(crate) fn perform_reductions(
     if opts.bit_depth_reduction && !deadline.passed() {
         // 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)));
+        let reduced = reduced_bit_depth_8_or_less(&png)
+            .or_else(|| indexed.and_then(|png| reduced_bit_depth_8_or_less(&png)));
         if let Some(reduced) = reduced {
             eval.try_image(Arc::new(reduced));
             evaluation_added = true;
@@ -137,7 +137,7 @@ pub(crate) fn perform_reductions(
     }
 
     if evaluation_added {
-        eval.set_baseline(baseline.clone());
+        eval.try_image(baseline.clone());
     }
-    (baseline, reduction_occurred)
+    baseline
 }
diff --git a/src/reduction/palette.rs b/src/reduction/palette.rs
index 8c610d03..08340013 100644
--- a/src/reduction/palette.rs
+++ b/src/reduction/palette.rs
@@ -7,16 +7,22 @@ use rgb::RGBA8;
 /// Attempt to reduce the number of colors in the palette, returning the reduced image if successful
 #[must_use]
 pub fn reduced_palette(png: &PngImage, optimize_alpha: bool) -> Option<PngImage> {
+    if png.ihdr.bit_depth != BitDepth::Eight {
+        return None;
+    }
     let palette = match &png.ihdr.color_type {
         ColorType::Indexed { palette } if palette.len() > 1 => palette,
         _ => return None,
     };
 
-    let used = get_used_entries(png);
+    let mut used = [false; 256];
+    for &byte in &png.data {
+        used[byte as usize] = true;
+    }
 
     let black = RGBA8::new(0, 0, 0, 255);
     let mut condensed = IndexSet::with_capacity(palette.len());
-    let mut palette_map = [0; 256];
+    let mut byte_map = [0; 256];
     let mut did_change = false;
     for (i, used) in used.iter().enumerate() {
         if !used {
@@ -24,15 +30,14 @@ pub fn reduced_palette(png: &PngImage, optimize_alpha: bool) -> Option<PngImage>
         }
         // 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 {
+        byte_map[i] = add_color_to_set(color, &mut condensed, optimize_alpha);
+        if byte_map[i] as usize != i {
             did_change = true;
         }
     }
 
     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
@@ -64,68 +69,10 @@ fn add_color_to_set(mut color: RGBA8, set: &mut IndexSet<RGBA8>, optimize_alpha:
     idx as u8
 }
 
-fn get_used_entries(png: &PngImage) -> [bool; 256] {
-    let mut used = [false; 256];
-    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;
-            }
-        }
-        BitDepth::One => {
-            // Only two options, don't bother checking which are actually used
-            used[0] = true;
-            used[1] = true;
-        }
-        _ => 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<PngImage> {
-    if png.ihdr.bit_depth == BitDepth::One {
-        // Don't bother trying to sort a 1-bit image
+    if png.ihdr.bit_depth != BitDepth::Eight {
         return None;
     }
     let palette = match &png.ihdr.color_type {
@@ -154,12 +101,11 @@ pub fn sorted_palette(png: &PngImage) -> Option<PngImage> {
         return None;
     }
 
-    // Construct the palette and byte maps and convert the data
-    let mut new_map = [0; 256];
+    // Construct the new mapping and convert the data
+    let mut byte_map = [0; 256];
     for (i, &v) in old_map.iter().enumerate() {
-        new_map[v] = i as u8;
+        byte_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();
 
     Some(PngImage {


Test patch:
diff --git a/tests/flags.rs b/tests/flags.rs
index 7d146a31..2df4ea0b 100644
--- a/tests/flags.rs
+++ b/tests/flags.rs
@@ -604,6 +604,57 @@ fn fix_errors() {
     remove_file(output).ok();
 }
 
+#[test]
+fn no_color_type_change() {
+    let input = PathBuf::from("tests/files/palette_8_should_be_rgb.png");
+    let (output, mut opts) = get_opts(&input);
+    opts.color_type_reduction = false;
+
+    test_it_converts(
+        input,
+        &output,
+        &opts,
+        INDEXED,
+        BitDepth::Eight,
+        INDEXED,
+        BitDepth::One,
+    );
+}
+
+#[test]
+fn no_grayscale_change() {
+    let input = PathBuf::from("tests/files/rgb_8_should_be_grayscale_8.png");
+    let (output, mut opts) = get_opts(&input);
+    opts.grayscale_reduction = false;
+
+    test_it_converts(
+        input,
+        &output,
+        &opts,
+        RGB,
+        BitDepth::Eight,
+        INDEXED,
+        BitDepth::Eight,
+    );
+}
+
+#[test]
+fn no_bit_depth_change() {
+    let input = PathBuf::from("tests/files/palette_4_should_be_palette_2.png");
+    let (output, mut opts) = get_opts(&input);
+    opts.bit_depth_reduction = false;
+
+    test_it_converts(
+        input,
+        &output,
+        &opts,
+        INDEXED,
+        BitDepth::Four,
+        INDEXED,
+        BitDepth::Four,
+    );
+}
+
 #[test]
 fn scale_16() {
     let input = PathBuf::from("tests/files/rgb_16_should_be_rgb_16.png");
diff --git a/tests/regression.rs b/tests/regression.rs
index c84a25dd..9382f27a 100644
--- a/tests/regression.rs
+++ b/tests/regression.rs
@@ -262,7 +262,7 @@ fn issue_82() {
         INDEXED,
         BitDepth::Four,
         INDEXED,
-        BitDepth::Four,
+        BitDepth::Eight,
     );
 }