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

Allow tiff writing to use an explicit photometric interpretation option #815

Closed
ben-manes opened this issue Sep 20, 2023 · 7 comments
Closed

Comments

@ben-manes
Copy link

ben-manes commented Sep 20, 2023

Is your feature request related to a use case or a problem you are working on? Please describe.
The TIFF writer defaults to BLACK_IS_ZERO for monochrome images. Our previous implementation, based on icafe, used WHITE_IS_ZERO by default. While the TIFF 6.0 standard makes this choice arbitrary and the reader must support it, a partner's AS/400 does not handle this correctly. They print white-on-black images which causes problems for our mutual customers. I would like to change the default on our side, since this legacy vendor is not flexible in making modifications.

Describe the solution you'd like
I attempted to add the exif metadata like,

TIFFEntry(TAG_PHOTOMETRIC_INTERPRETATION, PHOTOMETRIC_INTERPRETATION_WHITE_IS_ZERO)

but this is not honored, as stated by the following code snippet:

// TODO: Allow metadata to take precedence?
int photometricInterpretation = getPhotometricInterpretation(colorModel, compression);
entries.put(TIFF.TAG_PHOTOMETRIC_INTERPRETATION, new TIFFEntry(TIFF.TAG_PHOTOMETRIC_INTERPRETATION, TIFF.TYPE_SHORT, photometricInterpretation));

Describe alternatives you've considered
I am attempting to coerce the library to observe a color model that is inferred to WHITE_IS_ZERO by the logic below. Since the ImageTypeSpecifier.createFromRenderedImage infers all monochrome images to call createGrayscale, this has a fixed color space and is not inferred based on the buffered image's ColorModel. I did not see a way to coerce this to match your inference logic.

private int getPhotometricInterpretation(final ColorModel colorModel, int compression) {
if (colorModel.getPixelSize() == 1) {
if (colorModel instanceof IndexColorModel) {
if (colorModel.getRGB(0) == 0xFFFFFFFF && colorModel.getRGB(1) == 0xFF000000) {
return TIFFBaseline.PHOTOMETRIC_WHITE_IS_ZERO;
}
else if (colorModel.getRGB(0) != 0xFF000000 || colorModel.getRGB(1) != 0xFFFFFFFF) {
return TIFFBaseline.PHOTOMETRIC_PALETTE;
}
// Else, fall through to default, BLACK_IS_ZERO
}

I will explore next using Java native support for TIFF instead of this plugin to see if it honors the exif metadata setting.

Additional context
I was able to get the customer to confirm the compatibility problem by using imagemagick,

# metadata variations
convert input.tif -define quantum:polarity=min-is-black output.tif
convert input.tif -define quantum:polarity=min-is-white output.tif

# confirmation
identify -verbose input.tif | grep photometric
exiftool input.tif | grep Photometric

Here is the tiff conversion logic that I am using if helpful,

import static java.awt.AlphaComposite.Src;
import static java.awt.RenderingHints.KEY_DITHERING;
import static java.awt.RenderingHints.VALUE_DITHER_DISABLE;
import static java.nio.ByteOrder.BIG_ENDIAN;
import static javax.imageio.ImageWriteParam.MODE_EXPLICIT;
import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.TAG_X_RESOLUTION;
import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.TAG_Y_RESOLUTION;

import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageTypeSpecifier;

import org.apache.commons.io.FilenameUtils;

import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableList;
import com.twelvemonkeys.image.MonochromeColorModel;
import com.twelvemonkeys.imageio.metadata.tiff.Rational;
import com.twelvemonkeys.imageio.metadata.tiff.TIFFEntry;
import com.twelvemonkeys.imageio.plugins.tiff.TIFFImageMetadata;

import net.autobuilder.AutoBuilder;

/**
 * A utility that creates a multi-page tiff from a list of images.
 */
public final class MultipageTiff {

  private MultipageTiff() {}

  /** Creates a TIFF with a page per image. */
  public static void create(TiffSettings settings) {
    var images = new ArrayList<BufferedImage>();
    var writer = ImageIO.getImageWritersByFormatName("TIFF").next();
    try (var fileOutput = Files.newOutputStream(settings.destination());
         var output = ImageIO.createImageOutputStream(fileOutput)) {
      for (int i = 0; i < settings.images().size(); i++) {
        var image = ImageIO.read(settings.images().get(i).toFile());
        if (settings.monochrome()) {
          image = convertMonochrome(image);
        }
        images.add(image);
      }

      output.setByteOrder(settings.byteOrder());
      writer.setOutput(output);

      var params = writer.getDefaultWriteParam();
      params.setCompressionMode(MODE_EXPLICIT);
      params.setCompressionType(settings.compression());
      writer.prepareWriteSequence(/* stream metadata */ null);
      for (var image : images) {
        var type = ImageTypeSpecifier.createFromRenderedImage(image);
        var tiffMetadata = new TIFFImageMetadata(List.of(
            new TIFFEntry(TAG_X_RESOLUTION, new Rational(settings.xResolution())),
            new TIFFEntry(TAG_Y_RESOLUTION, new Rational(settings.yResolution()))));
        var metadata = writer.convertImageMetadata(tiffMetadata, type, params);
        writer.writeToSequence(new IIOImage(image, /* thumbnails */ null, metadata), params);
      }
      writer.endWriteSequence();
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    } finally {
      writer.dispose();
      images.forEach(image -> image.getGraphics().dispose());
    }
  }

  private static BufferedImage convertMonochrome(BufferedImage source) {
    if (IntStream.of(source.getSampleModel().getSampleSize()).sum() == 1) {
      return source;
    }

    var destination = new BufferedImage(source.getWidth(), source.getHeight(),
        BufferedImage.TYPE_BYTE_BINARY, MonochromeColorModel.getInstance());
    var graphics = destination.createGraphics();
    try {
      graphics.setComposite(Src);
      graphics.setRenderingHint(KEY_DITHERING, VALUE_DITHER_DISABLE);
      graphics.drawImage(source, 0, 0, null);
      return destination;
    } finally {
      graphics.dispose();
    }
  }

  @AutoValue @AutoBuilder
  public static abstract class TiffSettings {
    public abstract ImmutableList<Path> images();
    public abstract ByteOrder byteOrder();
    public abstract String compression();
    public abstract boolean monochrome();
    public abstract Path destination();
    public abstract int xResolution();
    public abstract int yResolution();

    public static MultipageTiff_TiffSettings_Builder group4() {
      return builder()
          .compression("CCITT T.6")
          .monochrome(true)
          .xResolution(200)
          .yResolution(200);
    }

    public static MultipageTiff_TiffSettings_Builder jpegColor() {
      return builder()
          .compression("JPEG")
          .xResolution(200)
          .yResolution(200);
    }

    public static MultipageTiff_TiffSettings_Builder lzwColor() {
      return builder()
          .compression("LZW")
          .xResolution(72)
          .yResolution(72);
    }

    public static MultipageTiff_TiffSettings_Builder builder() {
      return MultipageTiff_TiffSettings_Builder.builder()
          .byteOrder(BIG_ENDIAN);
    }
  }
}
@haraldk
Copy link
Owner

haraldk commented Sep 21, 2023

Hi Ben,

Thanks for reporting! I think I understand the issue.

Unfortunately, it's not as easy as just "allow ... explicit photometric interpretation", as that photometric may not be compatible with the actual image data. We could make an illegal combination of photometric and actual image data throw an exception, but that would make the library harder to use in some cases. Or we could automatically convert, but that's quite hard to implement robustly, and may make some writes surprisingly slow due to the implicit conversion. So I tried to keep things simple by just going with whatever the input was.

For the logic you mention, I use the following, to avoid the fixed color model, but instead use the color model supplied in the image:

// Can't use createFromRenderedImage in this case, as it does not consider palette for TYPE_BYTE_BINARY...
// TODO: Consider writing workaround in ImageTypeSpecifiers
ImageTypeSpecifier spec = new ImageTypeSpecifier(renderedImage);

So I think you should be able to get WHITE_IS_ZERO, if the IndexColorModel really has white at index 0 (which is opposite of the default BufferedImage.TYPE_BYTE_BINARY color model). From your test code, I see you use com.twelvemonkeys.image.MonochromeColorModel. This model has black in index 0. Try instead to use a color model like this:

new IndexColorModel(1, 2, new int[] {0xFFFFFFFF, 0xFF000000}, 0, false, -1, DataBuffer.TYPE_BYTE);

I'll create a test case to verify this (I thought I had that already), but feel free to try it out and report back in the mean time! 😀

@ben-manes
Copy link
Author

Thank you, @haraldk! I had seen your similar comment in #533 (comment). Unfortunately it didn't work for me because the color model was inferred from ImageTypeSpecifier.createFromRenderedImage, which always selects createGrayscale, and that ignores the image's to use instead a hard coded color model (see ImageTypeSpecifier.Grayscale). I hadn't realized that there was a constructor, new ImageTypeSpecifier(renderedImage), which then lets me pass through the image's original color model. That magical line fixes it and the output becomes WhiteIsZero! Thank you!!! 😃

@haraldk
Copy link
Owner

haraldk commented Sep 22, 2023

Excellent!

There's actually two constructors you can use:

  • public ImageTypeSpecifier(ColorModel colorModel, SampleModel sampleModel) which is kind of the "canonical" constructor
  • public ImageTypeSpecifier(RenderedImage image) (basically the same as ImageTypeSpecifier(image.getColorModel(), image.getSampleModel()))

But yes, it unfortunate that the many factory methods don't just do the right thing from the start. I've made my own ImageTypeSepcifiers class with factory methods for these situations. I'm also adding special handling for createFromRenderedImage now, to preservere palette/LUT for IndexColorModel.

PS: Studying your sample code some more, I don't think you actually need the lines:

var type = ImageTypeSpecifier.createFromRenderedImage(image); // ..or new ImageTypeSpecifier(image)
var tiffMetadata = new TIFFImageMetadata(List.of(
            new TIFFEntry(TAG_X_RESOLUTION, new Rational(settings.xResolution())),
            new TIFFEntry(TAG_Y_RESOLUTION, new Rational(settings.yResolution()))));
var metadata = writer.convertImageMetadata(tiffMetadata, type, params);
writer.writeToSequence(new IIOImage(image, /* thumbnails */ null, metadata), params);

It should be enough to just do:

var tiffMetadata = new TIFFImageMetadata(List.of(
            new TIFFEntry(TAG_X_RESOLUTION, new Rational(settings.xResolution())),
            new TIFFEntry(TAG_Y_RESOLUTION, new Rational(settings.yResolution()))));
writer.writeToSequence(new IIOImage(image, /* thumbnails */ null, tiffMetadata), params);

...as the writer will merge ("convert") the metadata and fill in/overwrite any necessary values in the write methods anyway.

@ben-manes
Copy link
Author

Thanks, I’ll give that a shot. Less code for me to screw up! 🙂

@ben-manes
Copy link
Author

That didn't work when using a jpeg output. I forget why, but those two lines resolves that issue.

Caused by: javax.imageio.IIOException: Metadata components != number of destination bands
	at java.desktop/com.sun.imageio.plugins.jpeg.JPEGImageWriter.checkSOFBands(JPEGImageWriter.java:1337)
	at java.desktop/com.sun.imageio.plugins.jpeg.JPEGImageWriter.writeOnThread(JPEGImageWriter.java:739)
	at java.desktop/com.sun.imageio.plugins.jpeg.JPEGImageWriter.write(JPEGImageWriter.java:384)
	at com.twelvemonkeys.imageio.plugins.jpeg.JPEGImageWriter.write(JPEGImageWriter.java:173)
	at com.twelvemonkeys.imageio.plugins.tiff.TIFFImageWriter.writePage(TIFFImageWriter.java:252)
	at com.twelvemonkeys.imageio.plugins.tiff.TIFFImageWriter.writeToSequence(TIFFImageWriter.java:964)

@haraldk
Copy link
Owner

haraldk commented Sep 23, 2023

Interesting... I need to investigate a little, but I don't think we should pass the TIFF image metadata to the JPEG writer, when writing a JPEG compressed TIFF. That's probably an oversight...

@haraldk
Copy link
Owner

haraldk commented Sep 23, 2023

Thanks for reporting!
Pushed a fix for that, should be in the latest snapshot and a proper release soon. 😀

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

No branches or pull requests

2 participants