Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CCITT group 4 (Fax4) decoding support #229

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

stephenjudkins
Copy link

@stephenjudkins stephenjudkins commented Apr 5, 2024

This adds support for decoding CCIT group 4 tiff images by using the fax crate.

I've found that the photometric interpretation tag is ignored by all the extant decoders I've found, and attempting to correctly interpret it will break some subset of images. Unfortunate, but I suspect it's best to follow the herd here?

@s3bk
Copy link

s3bk commented Apr 5, 2024

Probably check Tag::PhotometricInterpretation to decide what values are black and white.

@stephenjudkins
Copy link
Author

OK. I've done some research and found that

  1. other image processors (imagemagick, Apple Preview) ignore the photometric interpretation tag and always assume that it's WhiteIsZero
  2. fax4-encoded images I've found in the wild (which are all, unfortunately, confidential images of business checks) have the tag set correctly WhiteIsZero
  3. imagemagick creates fax4-encoded tiffs with photometric interpretation incorrectly as BlackIsZero but that doesn't matter because everything that loads the files assumes WhiteIsZero.

To conform with how everything in the wild I'd argue we just keep this hardcoded the way it is?

@s3bk
Copy link

s3bk commented Apr 5, 2024

hahaha. Yea, I think just keep it as is.

@stephenjudkins stephenjudkins marked this pull request as ready for review April 5, 2024 21:45
Copy link
Contributor

@fintelia fintelia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments. I think might also be a missing check that fax compression is only used for bilevel images?

// all extant tiff/fax4 decoders I've found always assume that the photometric interpretation
// is `WhiteIsZero`, ignoring the tag. ImageMagick appears to generate fax4-encoded tiffs
// with the tag incorrectly set to `BlackIsZero`.
fax::decoder::decode_g4(buffer.into_iter(), width, Some(height), |transitions| {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you pass reader.take(compressed_length).bytes() here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be reader.take(compressed_length).bytes().map(|b| b.unwrap()) instead. Is that OK with you, in terms of

  • performance impact: you'd get a branch for every single byte
  • error handling: you'd get a panic instead of an Err, and I (a Rust neophyte) don't think there's a way around that

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bit reader in the fax decoder pulls single bytes anway. I started changing the api to accept an Iterator of Result<u8, E>. (so reader.take(compressed_bytes).bytes() will work)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could also see if taking a BufRead impl would improve performance over having to call into the reader for each individual byte. We're currently in the process of converting various parts of the image-rs APIs to use those over normal Read impls.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I do not think it is worth worrying too much about micro-optimizations here. It makes sense to handle malicious inputs without blowing up the heap, but in practice all of the images I'm seeing are crappy low-res scans of checks from god-knows-what device a regional bank purchased in 1993. Really doubt there are many images of this format in the wild that demand fully optimized best-case performance

Comment on lines 453 to 454
let width = u16::try_from(self.width)?;
let height = u16::try_from(self.height)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these be the chunk dimensions rather than the image dimensions?

CompressionMethod::Fax4 => {
let width = u16::try_from(self.width)?;
let height = u16::try_from(self.height)?;
let mut out: Vec<u8> = Vec::with_capacity(usize::from(width) * usize::from(height));
Copy link
Contributor

@fintelia fintelia Apr 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have two concerns here:

  1. This is likely to cause fuzzer OOMs because just setting a few header fields is enough to cause a ~4GB allocation.
  2. The size calculation assumes that each pixel takes one byte, but I'd expect Gray(1) to mean one bit per pixel

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Going to push back a bit on (2), but you're the library maintainer so LMK if I'm looking at this wrong. I expect that this crate reports back what the tiff file says in its header, which is Gray(1). Then in the outer crate (image) we'd do this:

diff --git a/src/codecs/tiff.rs b/src/codecs/tiff.rs
index 9f4dd735..256215c5 100644
--- a/src/codecs/tiff.rs
+++ b/src/codecs/tiff.rs
@@ -53,6 +53,7 @@ where
         };

         let color_type = match tiff_color_type {
+            tiff::ColorType::Gray(1) => ColorType::L8,

Does that make sense? The alternatives would be to alter the following to special case this and override what's coming from the header: https://github.com/image-rs/image-tiff/blob/master/src/decoder/image.rs#L351

Alternatively we could add an extra data type to represent the actual output.

I'm OK with any of these options, just want to lay them out. What would you like as both the author/maintainer of this library and the primary consumer of its API in the image crate?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This crate should already decode Gray(1) encoded images into packed 1-bit per sample outputs.

To support the image crate use case, I'd expect that this create should either exposes an optional flag to expand sub 8-bit channels (like PNG does), or else not perform any expansion and have code in the image crate to do the conversion from packed 1-bit per sample into L8 encoded.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working at getting packed 1-bit samples working here and, TBH, it's quite a bit of fiddly work. I've got the decoder portion outputting 1-bit samples but the rest of the crate seems to assume it's byte-addressable. I am looking at the chunking (de-chunking?) code in fn expand_chunk and there's a lot that assumes samples are individually addressable. Should I go and start altering this to change this assumption? Is this something you could provide more guidance on?

To step back a bit: especially if the image crate, presumably the main consumer of this, just goes and re-expands the samples back to individual bytes, how much does it offer to have this crate offer 1-bit samples? There's a pretty straightforward path to getting this working in the image crate with this decoder without worrying about any of this.

Copy link
Author

@stephenjudkins stephenjudkins Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I've looked a bit further and expand_chunk is the difficult part here.

There are three different code paths:

  • chunk is as wide as image but with no padding
  • there's right-padding and we're using a floating point predictor
  • other cases

All of these assume individually addressable pixels. What kind of changes to these abstractions do you propose we make to get this working?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized the reason I thought there was sub-byte sample support was because there's a stalled-out PR for it that I lost track of.

I'll hopefully have a bit more time this weekend, but briefly:

  • My recollection is that TIFF packs multiple samples per byte across pixels but each row is always an integer number of bytes (i.e. it is padded out to a multiple of 8 bits).
  • To handle the first case of the chunk width matching the image width, the byte layout should match what's in the file so you can just copy byte-by-byte.
  • The floating point predictor implies floating point samples, which are always 16-bit, 32-bit, or 64-bit. Thus you don't have to handle it.
  • The final case can only be hit for tiled images. For here, I think it would be reasonable to return an unsupported error if the tile_width * bit_depth isn't a multiple of 8. With that assumption I don't think the code should be too hard

A final note is to watch out for integer overflow when using u32 or usize. It only takes 512 MB to store 2^32 bits, so a 32-bit system could plausibly want to decode a TIFF file containing more pixels than fit in a usize. And even if the decoded image won't fit in RAM, we still want to be able to return a graceful error rather than panicking due to integer overflow

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. I'll be away all next week but I'd like to pick this up again after that. Thanks for the context here.

@@ -368,6 +369,7 @@ impl Image {
}

fn create_reader<'r, R: 'r + Read>(
&self,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please pass in the relevant width/height as arguments rather than taking self

@fintelia
Copy link
Contributor

fintelia commented Apr 7, 2024

Ideally, I'd like to see the create_reader act more lazily. The best way would be to have incremental decoding, but the fax crate doesn't seem to support that. An intermediate step might be to generate the list of color transitions, and then lazily expand that into the provided output buffer

@s3bk
Copy link

s3bk commented Apr 7, 2024

I don't see a reason why incremental decoding could not be implemented.

It would be pretty easy to write something like

impl Decoder<R: Read> {
  fn new(reader: R) -> Self;
  fn advance(&mut self) -> Result<(), io::Error>;
  fn pels(&self) -> Pels;
}

@fintelia
Copy link
Contributor

fintelia commented Apr 7, 2024

Yeah, I think an interface like that would work. Then it could be wrapped in another object that implemented Read by tracking how far into the row had been read and expanded Pels pixel by pixel

@stephenjudkins
Copy link
Author

Appreciate the feedback and all the back-and-forth here. I'm happy to help out as much as I can here to push this forward, but I don't want to step on @s3bk's feet if he's working on these changes.

@s3bk
Copy link

s3bk commented Apr 8, 2024

@stephenjudkins I pushed changes to the fax repo. There are strange differences with the last line again. I need to re-encode my samples with libtiff to check if the error is in the sample data or my code.

But you should be able to use the code to adapt this PR to the next version.

@inzanez
Copy link

inzanez commented Oct 30, 2024

What is the state of this? Anything one can do?

@s3bk
Copy link

s3bk commented Oct 30, 2024

I don't know, but from what a I remember, the fax tests are all passing now.

@inzanez
Copy link

inzanez commented Oct 30, 2024

Seems that something with the test_fax4 is still messed up, ...

@stephenjudkins
Copy link
Author

Sorry! I dropped this but would like to pick it back up. Can we confirm that the upstream fax crate can decode the Fax4 files that are included?

let width = u16::try_from(dimensions.0)?;
let height = u16::try_from(dimensions.1)?;

struct Group4Reader<R2> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to see this moved into decoder/stream.rs alongside the other decompressors

@@ -1066,7 +1066,10 @@ impl<R: Read + Seek> Decoder<R> {
let max_sample_bits = self.image().bits_per_sample;
match self.image().sample_format {
SampleFormat::Uint => match max_sample_bits {
n if n <= 8 => DecodingResult::new_u8(buffer_size, &self.limits),
n if n < 8 => {
DecodingResult::new_u8(buffer_size / 8 * usize::from(n), &self.limits)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly suspicious that this will round incorrectly if the buffer_size isn't a multiple of 8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants