From 04a39158e54278d6ec14f90f9037ba80e29ca91b Mon Sep 17 00:00:00 2001 From: Harald Kuhr Date: Fri, 1 Jul 2016 19:32:35 +0200 Subject: [PATCH] #257, #229: Fixed LZW writing for < 8 bit, fixed StripByteCounts for uncompressed < 8 bit, disabled Predictor for < 8 bit. Bonus rework of sequence writing and restored writing of uncompressed data for less fseeking. --- .../imageio/util/ImageReaderAbstractTest.java | 2 +- .../imageio/plugins/tiff/TIFFImageWriter.java | 227 ++++---- .../plugins/tiff/TIFFImageWriterTest.java | 503 ++++++++++++++++-- .../src/test/resources/tiff/a33.tif | Bin 0 -> 26886 bytes 4 files changed, 581 insertions(+), 151 deletions(-) create mode 100644 imageio/imageio-tiff/src/test/resources/tiff/a33.tif diff --git a/imageio/imageio-core/src/test/java/com/twelvemonkeys/imageio/util/ImageReaderAbstractTest.java b/imageio/imageio-core/src/test/java/com/twelvemonkeys/imageio/util/ImageReaderAbstractTest.java index e559406a..bccf599c 100644 --- a/imageio/imageio-core/src/test/java/com/twelvemonkeys/imageio/util/ImageReaderAbstractTest.java +++ b/imageio/imageio-core/src/test/java/com/twelvemonkeys/imageio/util/ImageReaderAbstractTest.java @@ -1605,7 +1605,7 @@ public abstract class ImageReaderAbstractTest { /** * Slightly fuzzy RGB equals method. Variable tolerance. */ - protected void assertRGBEquals(String message, int expectedRGB, int actualRGB, int tolerance) { + public static void assertRGBEquals(String message, int expectedRGB, int actualRGB, int tolerance) { assertEquals(message, (expectedRGB >>> 24) & 0xff, (actualRGB >>> 24) & 0xff, 0); assertEquals(message, (expectedRGB >> 16) & 0xff, (actualRGB >> 16) & 0xff, tolerance); assertEquals(message, (expectedRGB >> 8) & 0xff, (actualRGB >> 8) & 0xff, tolerance); diff --git a/imageio/imageio-tiff/src/main/java/com/twelvemonkeys/imageio/plugins/tiff/TIFFImageWriter.java b/imageio/imageio-tiff/src/main/java/com/twelvemonkeys/imageio/plugins/tiff/TIFFImageWriter.java index 15d934fa..17e75864 100644 --- a/imageio/imageio-tiff/src/main/java/com/twelvemonkeys/imageio/plugins/tiff/TIFFImageWriter.java +++ b/imageio/imageio-tiff/src/main/java/com/twelvemonkeys/imageio/plugins/tiff/TIFFImageWriter.java @@ -196,39 +196,30 @@ public final class TIFFImageWriter extends ImageWriterBase { @Override public void write(final IIOMetadata streamMetadata, final IIOImage image, final ImageWriteParam param) throws IOException { // TODO: Validate input - assertOutput(); // TODO: streamMetadata? - // TODO: Consider writing TIFF header, offset to IFD0 (leave blank), write image data with correct - // tiling/compression/etc, then write IFD0, go back and update IFD0 offset? - - // Write minimal TIFF header (required "Baseline" fields) - // Use EXIFWriter to write leading metadata (TODO: consider rename to TTIFFWriter, again...) // TODO: Make TIFFEntry and possibly TIFFDirectory? public EXIFWriter exifWriter = new EXIFWriter(); exifWriter.writeTIFFHeader(imageOutput); - long IFD0Pos = imageOutput.getStreamPosition(); - writePage(image, param, exifWriter, IFD0Pos); - imageOutput.writeInt(0); // EOF + + writePage(image, param, exifWriter, imageOutput.getStreamPosition()); + imageOutput.flush(); } - private long writePage(IIOImage image, ImageWriteParam param, EXIFWriter exifWriter, long lastIFDPointer) + private long writePage(IIOImage image, ImageWriteParam param, EXIFWriter exifWriter, long lastIFDPointerOffset) throws IOException { RenderedImage renderedImage = image.getRenderedImage(); + + TIFFImageMetadata metadata = image.getMetadata() != null + ? convertImageMetadata(image.getMetadata(), ImageTypeSpecifier.createFromRenderedImage(renderedImage), param) + : getDefaultImageMetadata(ImageTypeSpecifier.createFromRenderedImage(renderedImage), param); + ColorModel colorModel = renderedImage.getColorModel(); - int numComponents = colorModel.getNumComponents(); - - TIFFImageMetadata metadata; - if (image.getMetadata() != null) { - metadata = convertImageMetadata(image.getMetadata(), ImageTypeSpecifier.createFromRenderedImage(renderedImage), param); - } - else { - metadata = initMeta(null, ImageTypeSpecifier.createFromRenderedImage(renderedImage), param); - } - SampleModel sampleModel = renderedImage.getSampleModel(); + int numBands = sampleModel.getNumBands(); + int pixelSize = computePixelSize(sampleModel); int[] bandOffsets; int[] bitOffsets; @@ -248,18 +239,19 @@ public final class TIFFImageWriter extends ImageWriterBase { throw new IllegalArgumentException("Unknown bit/bandOffsets for sample model: " + sampleModel); } + // TODO: There shouldn't be necessary to create a separate map here, this should be handled in the + // convertImageMetadata/getDefaultImageMetadata methods.... Map entries = new LinkedHashMap<>(); entries.put(TIFF.TAG_IMAGE_WIDTH, new TIFFEntry(TIFF.TAG_IMAGE_WIDTH, renderedImage.getWidth())); entries.put(TIFF.TAG_IMAGE_HEIGHT, new TIFFEntry(TIFF.TAG_IMAGE_HEIGHT, renderedImage.getHeight())); - // entries.add(new TIFFEntry(TIFF.TAG_ORIENTATION, 1)); // (optional) + entries.put(TIFF.TAG_ORIENTATION, new TIFFEntry(TIFF.TAG_ORIENTATION, 1)); // (optional) entries.put(TIFF.TAG_BITS_PER_SAMPLE, new TIFFEntry(TIFF.TAG_BITS_PER_SAMPLE, asShortArray(sampleModel.getSampleSize()))); + // If numComponents > numColorComponents, write ExtraSamples - if (numComponents > colorModel.getNumColorComponents()) { + if (numBands > colorModel.getNumColorComponents()) { // TODO: Write per component > numColorComponents if (colorModel.hasAlpha()) { - entries.put(TIFF.TAG_EXTRA_SAMPLES, new TIFFEntry(TIFF.TAG_EXTRA_SAMPLES, colorModel.isAlphaPremultiplied() - ? TIFFBaseline.EXTRASAMPLE_ASSOCIATED_ALPHA - : TIFFBaseline.EXTRASAMPLE_UNASSOCIATED_ALPHA)); + entries.put(TIFF.TAG_EXTRA_SAMPLES, new TIFFEntry(TIFF.TAG_EXTRA_SAMPLES, colorModel.isAlphaPremultiplied() ? TIFFBaseline.EXTRASAMPLE_ASSOCIATED_ALPHA : TIFFBaseline.EXTRASAMPLE_UNASSOCIATED_ALPHA)); } else { entries.put(TIFF.TAG_EXTRA_SAMPLES, new TIFFEntry(TIFF.TAG_EXTRA_SAMPLES, TIFFBaseline.EXTRASAMPLE_UNSPECIFIED)); @@ -275,6 +267,7 @@ public final class TIFFImageWriter extends ImageWriterBase { else { compression = TIFFImageWriteParam.getCompressionType(param); } + entries.put(TIFF.TAG_COMPRESSION, new TIFFEntry(TIFF.TAG_COMPRESSION, compression)); // TODO: Let param/metadata control predictor @@ -283,29 +276,39 @@ public final class TIFFImageWriter extends ImageWriterBase { case TIFFExtension.COMPRESSION_ZLIB: case TIFFExtension.COMPRESSION_DEFLATE: case TIFFExtension.COMPRESSION_LZW: - entries.put(TIFF.TAG_PREDICTOR, new TIFFEntry(TIFF.TAG_PREDICTOR, TIFFExtension.PREDICTOR_HORIZONTAL_DIFFERENCING)); + if (pixelSize >= 8) { + entries.put(TIFF.TAG_PREDICTOR, new TIFFEntry(TIFF.TAG_PREDICTOR, TIFFExtension.PREDICTOR_HORIZONTAL_DIFFERENCING)); + } + break; + case TIFFExtension.COMPRESSION_CCITT_T4: Entry group3options = metadata.getIFD().getEntryById(TIFF.TAG_GROUP3OPTIONS); + if (group3options == null) { group3options = new TIFFEntry(TIFF.TAG_GROUP3OPTIONS, (long) TIFFExtension.GROUP3OPT_2DENCODING); } + entries.put(TIFF.TAG_GROUP3OPTIONS, group3options); + break; + case TIFFExtension.COMPRESSION_CCITT_T6: Entry group4options = metadata.getIFD().getEntryById(TIFF.TAG_GROUP4OPTIONS); + if (group4options == null) { group4options = new TIFFEntry(TIFF.TAG_GROUP4OPTIONS, 0L); } + entries.put(TIFF.TAG_GROUP4OPTIONS, group4options); + break; + default: } // TODO: We might want to support CMYK in JPEG as well... Pending JPEG CMYK write support. - int photometric = compression == TIFFExtension.COMPRESSION_JPEG ? - TIFFExtension.PHOTOMETRIC_YCBCR : - getPhotometricInterpretation(colorModel); + int photometric = compression == TIFFExtension.COMPRESSION_JPEG ? TIFFExtension.PHOTOMETRIC_YCBCR : getPhotometricInterpretation(colorModel); entries.put(TIFF.TAG_PHOTOMETRIC_INTERPRETATION, new TIFFEntry(TIFF.TAG_PHOTOMETRIC_INTERPRETATION, photometric)); if (photometric == TIFFBaseline.PHOTOMETRIC_PALETTE && colorModel instanceof IndexColorModel) { @@ -313,11 +316,7 @@ public final class TIFFImageWriter extends ImageWriterBase { entries.put(TIFF.TAG_SAMPLES_PER_PIXEL, new TIFFEntry(TIFF.TAG_SAMPLES_PER_PIXEL, 1)); } else { - if (colorModel.getPixelSize() == 1) { - numComponents = 1; - } - - entries.put(TIFF.TAG_SAMPLES_PER_PIXEL, new TIFFEntry(TIFF.TAG_SAMPLES_PER_PIXEL, numComponents)); + entries.put(TIFF.TAG_SAMPLES_PER_PIXEL, new TIFFEntry(TIFF.TAG_SAMPLES_PER_PIXEL, numBands)); // Note: Assuming sRGB to be the default RGB interpretation ColorSpace colorSpace = colorModel.getColorSpace(); @@ -332,11 +331,10 @@ public final class TIFFImageWriter extends ImageWriterBase { } // TODO: Float values! + // TODO: Again, this should be handled in the metadata conversion.... // Get Software from metadata, or use default Entry software = metadata.getIFD().getEntryById(TIFF.TAG_SOFTWARE); - entries.put(TIFF.TAG_SOFTWARE, software != null - ? software - : new TIFFEntry(TIFF.TAG_SOFTWARE, "TwelveMonkeys ImageIO TIFF writer " + originatingProvider.getVersion())); + entries.put(TIFF.TAG_SOFTWARE, software != null ? software : new TIFFEntry(TIFF.TAG_SOFTWARE, "TwelveMonkeys ImageIO TIFF writer " + originatingProvider.getVersion())); // Copy metadata to output int[] copyTags = { @@ -366,36 +364,50 @@ public final class TIFFImageWriter extends ImageWriterBase { Entry yRes = metadata.getIFD().getEntryById(TIFF.TAG_Y_RESOLUTION); entries.put(TIFF.TAG_Y_RESOLUTION, yRes != null ? yRes : new TIFFEntry(TIFF.TAG_Y_RESOLUTION, STANDARD_DPI)); Entry resUnit = metadata.getIFD().getEntryById(TIFF.TAG_RESOLUTION_UNIT); - entries.put(TIFF.TAG_RESOLUTION_UNIT, - resUnit != null ? resUnit : new TIFFEntry(TIFF.TAG_RESOLUTION_UNIT, TIFFBaseline.RESOLUTION_UNIT_DPI)); + entries.put(TIFF.TAG_RESOLUTION_UNIT, resUnit != null ? resUnit : new TIFFEntry(TIFF.TAG_RESOLUTION_UNIT, TIFFBaseline.RESOLUTION_UNIT_DPI)); // TODO: RowsPerStrip - can be entire image (or even 2^32 -1), but it's recommended to write "about 8K bytes" per strip - entries.put(TIFF.TAG_ROWS_PER_STRIP, new TIFFEntry(TIFF.TAG_ROWS_PER_STRIP, Integer.MAX_VALUE)); // TODO: Allowed but not recommended + entries.put(TIFF.TAG_ROWS_PER_STRIP, new TIFFEntry(TIFF.TAG_ROWS_PER_STRIP, renderedImage.getHeight())); // - StripByteCounts - for no compression, entire image data... (TODO: How to know the byte counts prior to writing data?) - TIFFEntry dummyStripByteCounts = new TIFFEntry(TIFF.TAG_STRIP_BYTE_COUNTS, -1); - entries.put(TIFF.TAG_STRIP_BYTE_COUNTS, dummyStripByteCounts); // Updated later + entries.put(TIFF.TAG_STRIP_BYTE_COUNTS, new TIFFEntry(TIFF.TAG_STRIP_BYTE_COUNTS, -1)); // Updated later // - StripOffsets - can be offset to single strip only (TODO: but how large is the IFD data...???) - TIFFEntry dummyStripOffsets = new TIFFEntry(TIFF.TAG_STRIP_OFFSETS, -1); - entries.put(TIFF.TAG_STRIP_OFFSETS, dummyStripOffsets); // Updated later + entries.put(TIFF.TAG_STRIP_OFFSETS, new TIFFEntry(TIFF.TAG_STRIP_OFFSETS, -1)); // Updated later // TODO: If tiled, write tile indexes etc // Depending on param.getTilingMode - long nextIFDPointer = -1; - long stripOffset = -1; - long stripByteCount = 0; + long nextIFDPointerOffset = -1; if (compression == TIFFBaseline.COMPRESSION_NONE) { - long ifdOffset = exifWriter.computeIFDOffsetSize(entries.values()); - long dataLength = renderedImage.getWidth() * renderedImage.getHeight() * numComponents; - long pointerPos = imageOutput.getStreamPosition() + dataLength + 4 + ifdOffset; - imageOutput.writeInt((int) pointerPos); + // This implementation, allows semi-streaming-compatible uncompressed TIFFs + long streamPosition = imageOutput.getStreamPosition(); + + long ifdSize = exifWriter.computeIFDSize(entries.values()); + long stripOffset = streamPosition + 4 + ifdSize + 4; + long stripByteCount = (renderedImage.getWidth() * renderedImage.getHeight() * pixelSize + 7) / 8; + + entries.put(TIFF.TAG_STRIP_OFFSETS, new TIFFEntry(TIFF.TAG_STRIP_OFFSETS, stripOffset)); + entries.put(TIFF.TAG_STRIP_BYTE_COUNTS, new TIFFEntry(TIFF.TAG_STRIP_BYTE_COUNTS, stripByteCount)); + + long ifdPointer = exifWriter.writeIFD(entries.values(), imageOutput); // NOTE: Writer takes case of ordering tags + nextIFDPointerOffset = imageOutput.getStreamPosition(); + + // If we have a previous IFD, update pointer + if (streamPosition > lastIFDPointerOffset) { + imageOutput.seek(lastIFDPointerOffset); + imageOutput.writeInt((int) ifdPointer); + imageOutput.seek(nextIFDPointerOffset); + } + + imageOutput.writeInt(0); // Update next IFD pointer later } else { - imageOutput.writeInt(0); // Update IFD Pointer later + imageOutput.writeInt(0); // Update current IFD pointer later } - stripOffset = imageOutput.getStreamPosition(); + long stripOffset = imageOutput.getStreamPosition(); + // TODO: Create compressor stream per Tile/Strip + // TODO: Cache JPEGImageWriter, dispose in dispose() method if (compression == TIFFExtension.COMPRESSION_JPEG) { Iterator writers = ImageIO.getImageWritersByFormatName("JPEG"); @@ -415,40 +427,45 @@ public final class TIFFImageWriter extends ImageWriterBase { } else { // Write image data - writeImageData(createCompressorStream(renderedImage, param, entries), renderedImage, numComponents, bandOffsets, - bitOffsets); + writeImageData(createCompressorStream(renderedImage, param, entries), renderedImage, numBands, bandOffsets, bitOffsets); } - stripByteCount = imageOutput.getStreamPosition() - stripOffset; + + long stripByteCount = imageOutput.getStreamPosition() - stripOffset; // Update IFD0-pointer, and write IFD if (compression != TIFFBaseline.COMPRESSION_NONE) { - entries.remove(dummyStripOffsets); entries.put(TIFF.TAG_STRIP_OFFSETS, new TIFFEntry(TIFF.TAG_STRIP_OFFSETS, stripOffset)); - entries.remove(dummyStripByteCounts); entries.put(TIFF.TAG_STRIP_BYTE_COUNTS, new TIFFEntry(TIFF.TAG_STRIP_BYTE_COUNTS, stripByteCount)); - long idfOffset = exifWriter.writeIFD(entries.values(), imageOutput); // NOTE: Writer takes case of ordering tags - nextIFDPointer = imageOutput.getStreamPosition(); - imageOutput.seek(lastIFDPointer); - imageOutput.writeInt((int) idfOffset); - imageOutput.seek(nextIFDPointer); - imageOutput.flush(); - } - else { - entries.remove(dummyStripOffsets); - entries.put(TIFF.TAG_STRIP_OFFSETS, new TIFFEntry(TIFF.TAG_STRIP_OFFSETS, stripOffset)); - entries.remove(dummyStripByteCounts); - entries.put(TIFF.TAG_STRIP_BYTE_COUNTS, new TIFFEntry(TIFF.TAG_STRIP_BYTE_COUNTS, stripByteCount)); + long ifdPointer = exifWriter.writeIFD(entries.values(), imageOutput); // NOTE: Writer takes case of ordering tags - exifWriter.writeIFD(entries.values(), imageOutput); // NOTE: Writer takes case of ordering tags - nextIFDPointer = imageOutput.getStreamPosition(); - imageOutput.flush(); + nextIFDPointerOffset = imageOutput.getStreamPosition(); + + // TODO: This is slightly duped.... + // However, need to update here, because to the writeIFD method writes the pointer, but at the incorrect offset + // TODO: Refactor writeIFD to take an offset + imageOutput.seek(lastIFDPointerOffset); + imageOutput.writeInt((int) ifdPointer); + imageOutput.seek(nextIFDPointerOffset); + + imageOutput.writeInt(0); // Next IFD pointer updated later } - return nextIFDPointer; + return nextIFDPointerOffset; } - private DataOutput createCompressorStream(RenderedImage image, ImageWriteParam param, Map entries) { + // TODO: Candidate util method + private int computePixelSize(final SampleModel sampleModel) { + int size = 0; + + for (int i = 0; i < sampleModel.getNumBands(); i++) { + size += sampleModel.getSampleSize(i); + } + + return size; + } + + private DataOutput createCompressorStream(final RenderedImage image, final ImageWriteParam param, final Map entries) { /* 36 MB test data: @@ -513,57 +530,56 @@ public final class TIFFImageWriter extends ImageWriterBase { stream = new EncoderStream(stream, new PackBitsEncoder(), true); // NOTE: PackBits + Predictor is possible, but not generally supported, disable it by default // (and probably not even allow it, see http://stackoverflow.com/questions/20337400/tiff-packbits-compression-with-predictor-step) -// stream = new HorizontalDifferencingStream(stream, image.getTileWidth(), image.getTile(0, 0).getNumBands(), image.getColorModel().getComponentSize(0), imageOutput.getByteOrder()); return new DataOutputStream(stream); case TIFFExtension.COMPRESSION_ZLIB: case TIFFExtension.COMPRESSION_DEFLATE: + // NOTE: This interpretation does the opposite of the JAI TIFFImageWriter, but seems more correct. + // API Docs says: + // A compression quality setting of 0.0 is most generically interpreted as "high compression is important," + // while a setting of 1.0 is most generically interpreted as "high image quality is important." + // However, the JAI TIFFImageWriter uses: + // if (param & compression etc...) { + // float quality = param.getCompressionQuality(); + // deflateLevel = (int)(1 + 8*quality); + // } else { + // deflateLevel = Deflater.DEFAULT_COMPRESSION; + // } + // (in other words, 0.0 means 1 == BEST_SPEED, 1.0 means 9 == BEST_COMPRESSION) + // PS: PNGImageWriter just uses hardcoded BEST_COMPRESSION... :-P int deflateSetting = Deflater.BEST_SPEED; // This is consistent with default compression quality being 1.0 and 0 meaning max compression... if (param.getCompressionMode() == ImageWriteParam.MODE_EXPLICIT) { - // TODO: Determine how to interpret compression quality... - // Docs says: - // A compression quality setting of 0.0 is most generically interpreted as "high compression is important," - // while a setting of 1.0 is most generically interpreted as "high image quality is important." - // Is this what JAI TIFFImageWriter (TIFFDeflater) does? No, it does: - /* - if (param & compression etc...) { - float quality = param.getCompressionQuality(); - deflateLevel = (int)(1 + 8*quality); - } else { - deflateLevel = Deflater.DEFAULT_COMPRESSION; - } - */ - // PS: PNGImageWriter just uses hardcoded BEST_COMPRESSION... :-P - deflateSetting = 9 - Math.round(8 * (param.getCompressionQuality())); // This seems more correct + deflateSetting = Deflater.BEST_COMPRESSION - Math.round((Deflater.BEST_COMPRESSION - 1) * param.getCompressionQuality()); } stream = IIOUtil.createStreamAdapter(imageOutput); stream = new DeflaterOutputStream(stream, new Deflater(deflateSetting), 1024); - stream = new HorizontalDifferencingStream(stream, image.getTileWidth(), image.getTile(0, 0).getNumBands(), image.getColorModel().getComponentSize(0), imageOutput.getByteOrder()); + if (entries.containsKey(TIFF.TAG_PREDICTOR) && entries.get(TIFF.TAG_PREDICTOR).getValue().equals(TIFFExtension.PREDICTOR_HORIZONTAL_DIFFERENCING)) { + stream = new HorizontalDifferencingStream(stream, image.getTileWidth(), image.getTile(0, 0).getNumBands(), image.getColorModel().getComponentSize(0), imageOutput.getByteOrder()); + } return new DataOutputStream(stream); case TIFFExtension.COMPRESSION_LZW: stream = IIOUtil.createStreamAdapter(imageOutput); - stream = new EncoderStream(stream, new LZWEncoder((image.getTileWidth() * image.getTileHeight() - * image.getTile(0, 0).getNumBands() * image.getColorModel().getComponentSize(0) + 7) / 8)); - stream = new HorizontalDifferencingStream(stream, image.getTileWidth(), image.getTile(0, 0).getNumBands(), - image.getColorModel().getComponentSize(0), imageOutput.getByteOrder()); + stream = new EncoderStream(stream, new LZWEncoder((image.getTileWidth() * image.getTileHeight() * image.getColorModel().getPixelSize() + 7) / 8)); + if (entries.containsKey(TIFF.TAG_PREDICTOR) && entries.get(TIFF.TAG_PREDICTOR).getValue().equals(TIFFExtension.PREDICTOR_HORIZONTAL_DIFFERENCING)) { + stream = new HorizontalDifferencingStream(stream, image.getTileWidth(), image.getTile(0, 0).getNumBands(), image.getColorModel().getComponentSize(0), imageOutput.getByteOrder()); + } return new DataOutputStream(stream); + case TIFFBaseline.COMPRESSION_CCITT_MODIFIED_HUFFMAN_RLE: case TIFFExtension.COMPRESSION_CCITT_T4: case TIFFExtension.COMPRESSION_CCITT_T6: long option = 0L; + if (compression != TIFFBaseline.COMPRESSION_CCITT_MODIFIED_HUFFMAN_RLE) { - option = (long) entries.get(compression == TIFFExtension.COMPRESSION_CCITT_T4 - ? TIFF.TAG_GROUP3OPTIONS - : TIFF.TAG_GROUP4OPTIONS).getValue(); + option = (long) entries.get(compression == TIFFExtension.COMPRESSION_CCITT_T4 ? TIFF.TAG_GROUP3OPTIONS : TIFF.TAG_GROUP4OPTIONS).getValue(); } + Entry fillOrderEntry = entries.get(TIFF.TAG_FILL_ORDER); - int fillOrder = (int) (fillOrderEntry != null - ? fillOrderEntry.getValue() - : TIFFBaseline.FILL_LEFT_TO_RIGHT); + int fillOrder = (int) (fillOrderEntry != null ? fillOrderEntry.getValue() : TIFFBaseline.FILL_LEFT_TO_RIGHT); stream = IIOUtil.createStreamAdapter(imageOutput); stream = new CCITTFaxEncoderStream(stream, image.getTileWidth(), image.getTileHeight(), compression, fillOrder, option); @@ -908,6 +924,11 @@ public final class TIFFImageWriter extends ImageWriterBase { if (!isWritingSequence) { throw new IllegalStateException("prepareWriteSequence() must be called before writeToSequence()!"); } + + if (sequenceLastIFDPos > 0) { + imageOutput.flushBefore(sequenceLastIFDPos); + } + sequenceLastIFDPos = writePage(image, param, sequenceExifWriter, sequenceLastIFDPos); } @@ -916,7 +937,7 @@ public final class TIFFImageWriter extends ImageWriterBase { if (!isWritingSequence) { throw new IllegalStateException("prepareWriteSequence() must be called before endWriteSequence()!"); } - imageOutput.writeInt(0); // EOF + isWritingSequence = false; sequenceExifWriter = null; sequenceLastIFDPos = -1; diff --git a/imageio/imageio-tiff/src/test/java/com/twelvemonkeys/imageio/plugins/tiff/TIFFImageWriterTest.java b/imageio/imageio-tiff/src/test/java/com/twelvemonkeys/imageio/plugins/tiff/TIFFImageWriterTest.java index 86d6e135..fdd925a0 100644 --- a/imageio/imageio-tiff/src/test/java/com/twelvemonkeys/imageio/plugins/tiff/TIFFImageWriterTest.java +++ b/imageio/imageio-tiff/src/test/java/com/twelvemonkeys/imageio/plugins/tiff/TIFFImageWriterTest.java @@ -34,8 +34,12 @@ import com.twelvemonkeys.imageio.metadata.exif.EXIFReader; import com.twelvemonkeys.imageio.metadata.exif.Rational; import com.twelvemonkeys.imageio.metadata.exif.TIFF; import com.twelvemonkeys.imageio.stream.ByteArrayImageInputStream; +import com.twelvemonkeys.imageio.util.ImageReaderAbstractTest; import com.twelvemonkeys.imageio.util.ImageWriterAbstractTestCase; +import com.twelvemonkeys.io.FastByteArrayOutputStream; import org.junit.Test; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; import javax.imageio.*; import javax.imageio.metadata.IIOMetadata; @@ -46,12 +50,18 @@ import javax.imageio.stream.ImageOutputStream; import java.awt.*; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; -import java.io.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import java.util.List; import static com.twelvemonkeys.imageio.plugins.tiff.TIFFImageMetadataTest.createTIFFFieldNode; +import static com.twelvemonkeys.imageio.util.ImageReaderAbstractTest.assertRGBEquals; import static org.junit.Assert.*; +import static org.junit.Assume.assumeNotNull; /** * TIFFImageWriterTest @@ -277,62 +287,461 @@ public class TIFFImageWriterTest extends ImageWriterAbstractTestCase { } @Test - public void testSequenceWriter() throws IOException { + public void testWriterCanWriteSequence() { + ImageWriter writer = createImageWriter(); + assertTrue("Writer should support sequence writing", writer.canWriteSequence()); + } + + // TODO: Test Sequence writing without prepare/end sequence + + @Test + public void testWriteSequence() throws IOException { + BufferedImage[] images = new BufferedImage[] { + new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB), + new BufferedImage(110, 100, BufferedImage.TYPE_INT_RGB), + new BufferedImage(120, 100, BufferedImage.TYPE_INT_RGB), + new BufferedImage(130, 100, BufferedImage.TYPE_INT_RGB) + }; + + Color[] colors = new Color[] {Color.RED, Color.GREEN, Color.BLUE, Color.ORANGE}; + + for (int i = 0; i < images.length; i++) { + BufferedImage image = images[i]; + Graphics2D g2d = image.createGraphics(); + try { + g2d.setColor(colors[i]); + g2d.fillRect(0, 0, 100, 100); + } + finally { + g2d.dispose(); + } + } + ImageWriter writer = createImageWriter(); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - ImageOutputStream stream = ImageIO.createImageOutputStream(buffer); - writer.setOutput(stream); + try (ImageOutputStream output = ImageIO.createImageOutputStream(buffer)) { + writer.setOutput(output); - Graphics2D g2d = null; - BufferedImage image[] = new BufferedImage[] { - new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB), - new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB), - new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB) - }; - g2d = image[0].createGraphics(); - g2d.setColor(Color.red); - g2d.fillRect(0,0,100,100); - g2d.dispose(); - g2d = image[1].createGraphics(); - g2d.setColor(Color.green); - g2d.fillRect(0,0,100,100); - g2d.dispose(); - g2d = image[2].createGraphics(); - g2d.setColor(Color.blue); - g2d.fillRect(0,0,100,100); - g2d.dispose(); + ImageWriteParam params = writer.getDefaultWriteParam(); + params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + try { + writer.prepareWriteSequence(null); - ImageWriteParam params = writer.getDefaultWriteParam(); - params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + params.setCompressionType("JPEG"); + writer.writeToSequence(new IIOImage(images[0], null, null), params); - assertTrue("", writer.canWriteSequence()); + params.setCompressionType("None"); + writer.writeToSequence(new IIOImage(images[1], null, null), params); - try { - writer.prepareWriteSequence(null); + params.setCompressionType("None"); + writer.writeToSequence(new IIOImage(images[2], null, null), params); - params.setCompressionType("JPEG"); - writer.writeToSequence(new IIOImage(image[0], null, null), params); - params.setCompressionType("None"); - writer.writeToSequence(new IIOImage(image[1], null, null), params); - params.setCompressionType("JPEG"); - writer.writeToSequence(new IIOImage(image[2], null, null), params); - g2d.dispose(); - writer.endWriteSequence(); - } - catch (IOException e) { - fail(e.getMessage()); - } - finally { - stream.close(); // Force data to be written + params.setCompressionType("PackBits"); + writer.writeToSequence(new IIOImage(images[3], null, null), params); + + writer.endWriteSequence(); + } + catch (IOException e) { + fail(e.getMessage()); + } } - ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(buffer.toByteArray())); - ImageReader reader = ImageIO.getImageReaders(input).next(); - reader.setInput(input); - assertEquals("wrong image count", 3, reader.getNumImages(true)); - for(int i = 0; i < reader.getNumImages(true); i++){ - reader.read(i); + try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(buffer.toByteArray()))) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + + assertEquals("wrong image count", images.length, reader.getNumImages(true)); + + for (int i = 0; i < reader.getNumImages(true); i++) { + BufferedImage image = reader.read(i); + + assertEquals(images[i].getWidth(), image.getWidth()); + assertEquals(images[i].getHeight(), image.getHeight()); + + assertRGBEquals("RGB differ", images[i].getRGB(0, 0), image.getRGB(0, 0), 5); // Allow room for JPEG compression + } + } + } + + @Test + public void testReadWriteRead1BitLZW() throws IOException { + // Read original LZW compressed TIFF + IIOImage original; + + try (ImageInputStream input = ImageIO.createImageInputStream(getClass().getResource("/tiff/a33.tif"))) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + + original = reader.readAll(0, null); + reader.dispose(); + } + + assumeNotNull(original); + + // Write it back, using same compression (copied from metadata) + FastByteArrayOutputStream buffer = new FastByteArrayOutputStream(32768); + + try (ImageOutputStream output = ImageIO.createImageOutputStream(buffer)) { + ImageWriter writer = createImageWriter(); + writer.setOutput(output); + + writer.write(original); + writer.dispose(); + } + + // Try re-reading the same TIFF + try (ImageInputStream input = ImageIO.createImageInputStream(buffer.createInputStream())) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + BufferedImage image = reader.read(0); + + BufferedImage orig = (BufferedImage) original.getRenderedImage(); + + int maxH = Math.min(300, image.getHeight()); + for (int y = 0; y < maxH; y++) { + for (int x = 0; x < image.getWidth(); x++) { + assertRGBEquals("Pixel differ: ", orig.getRGB(x, y), image.getRGB(x, y), 0); + } + } + + IIOMetadata metadata = reader.getImageMetadata(0); + IIOMetadataNode tree = (IIOMetadataNode) metadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName); + IIOMetadataNode compression = (IIOMetadataNode) tree.getElementsByTagName("CompressionTypeName").item(0); + assertEquals("LZW", compression.getAttribute("value")); + + boolean softwareFound = false; + NodeList textEntries = tree.getElementsByTagName("TextEntry"); + for (int i = 0; i < textEntries.getLength(); i++) { + IIOMetadataNode textEntry = (IIOMetadataNode) textEntries.item(i); + if ("Software".equals(textEntry.getAttribute("keyword"))) { + softwareFound = true; + assertEquals("IrfanView", textEntry.getAttribute("value")); + } + } + + assertTrue("Software metadata not found", softwareFound); + } + } + + @Test + public void testReadWriteRead1BitDeflate() throws IOException { + // Read original LZW compressed TIFF + IIOImage original; + + try (ImageInputStream input = ImageIO.createImageInputStream(getClass().getResource("/tiff/a33.tif"))) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + + original = reader.readAll(0, null); + reader.dispose(); + } + + assumeNotNull(original); + + // Write it back, using same compression (copied from metadata) + FastByteArrayOutputStream buffer = new FastByteArrayOutputStream(32768); + + try (ImageOutputStream output = ImageIO.createImageOutputStream(buffer)) { + ImageWriter writer = createImageWriter(); + writer.setOutput(output); + + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionType("Deflate"); + + writer.write(null, original, param); + writer.dispose(); + } + + // Try re-reading the same TIFF + try (ImageInputStream input = ImageIO.createImageInputStream(buffer.createInputStream())) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + BufferedImage image = reader.read(0); + + BufferedImage orig = (BufferedImage) original.getRenderedImage(); + + int maxH = Math.min(300, image.getHeight()); + for (int y = 0; y < maxH; y++) { + for (int x = 0; x < image.getWidth(); x++) { + assertRGBEquals("Pixel differ: ", orig.getRGB(x, y), image.getRGB(x, y), 0); + } + } + + IIOMetadata metadata = reader.getImageMetadata(0); + IIOMetadataNode tree = (IIOMetadataNode) metadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName); + IIOMetadataNode compression = (IIOMetadataNode) tree.getElementsByTagName("CompressionTypeName").item(0); + assertEquals("Deflate", compression.getAttribute("value")); + + boolean softwareFound = false; + NodeList textEntries = tree.getElementsByTagName("TextEntry"); + for (int i = 0; i < textEntries.getLength(); i++) { + IIOMetadataNode textEntry = (IIOMetadataNode) textEntries.item(i); + if ("Software".equals(textEntry.getAttribute("keyword"))) { + softwareFound = true; + assertEquals("IrfanView", textEntry.getAttribute("value")); + } + } + + assertTrue("Software metadata not found", softwareFound); + } + } + + @Test + public void testReadWriteRead1BitNone() throws IOException { + // Read original LZW compressed TIFF + IIOImage original; + + try (ImageInputStream input = ImageIO.createImageInputStream(getClass().getResource("/tiff/a33.tif"))) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + + original = reader.readAll(0, null); + reader.dispose(); + } + + assumeNotNull(original); + + // Write it back, using same compression (copied from metadata) + FastByteArrayOutputStream buffer = new FastByteArrayOutputStream(32768); + + try (ImageOutputStream output = ImageIO.createImageOutputStream(buffer)) { + ImageWriter writer = createImageWriter(); + writer.setOutput(output); + + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionType("None"); + + writer.write(null, original, param); + writer.dispose(); + } + + // Try re-reading the same TIFF + try (ImageInputStream input = ImageIO.createImageInputStream(buffer.createInputStream())) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + BufferedImage image = reader.read(0); + + BufferedImage orig = (BufferedImage) original.getRenderedImage(); + + int maxH = Math.min(300, image.getHeight()); + for (int y = 0; y < maxH; y++) { + for (int x = 0; x < image.getWidth(); x++) { + assertRGBEquals("Pixel differ: ", orig.getRGB(x, y), image.getRGB(x, y), 0); + } + } + + IIOMetadata metadata = reader.getImageMetadata(0); + IIOMetadataNode tree = (IIOMetadataNode) metadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName); + NodeList compressions = tree.getElementsByTagName("CompressionTypeName"); + IIOMetadataNode compression = (IIOMetadataNode) compressions.item(0); + assertEquals("None", compression.getAttribute("value")); + + boolean softwareFound = false; + NodeList textEntries = tree.getElementsByTagName("TextEntry"); + for (int i = 0; i < textEntries.getLength(); i++) { + IIOMetadataNode textEntry = (IIOMetadataNode) textEntries.item(i); + if ("Software".equals(textEntry.getAttribute("keyword"))) { + softwareFound = true; + assertEquals("IrfanView", textEntry.getAttribute("value")); + } + } + + assertTrue("Software metadata not found", softwareFound); + } + } + + @Test + public void testReadWriteRead24BitLZW() throws IOException { + // Read original LZW compressed TIFF + IIOImage original; + + try (ImageInputStream input = ImageIO.createImageInputStream(getClass().getResource("/tiff/quad-lzw.tif"))) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + + original = reader.readAll(0, null); + reader.dispose(); + } + + assumeNotNull(original); + + // Write it back, using same compression (copied from metadata) + FastByteArrayOutputStream buffer = new FastByteArrayOutputStream(32768); + + try (ImageOutputStream output = ImageIO.createImageOutputStream(buffer)) { + ImageWriter writer = createImageWriter(); + writer.setOutput(output); + + writer.write(original); + writer.dispose(); + } + + // Try re-reading the same TIFF + try (ImageInputStream input = ImageIO.createImageInputStream(buffer.createInputStream())) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + BufferedImage image = reader.read(0); + + BufferedImage orig = (BufferedImage) original.getRenderedImage(); + + int maxH = Math.min(300, image.getHeight()); + for (int y = 0; y < maxH; y++) { + for (int x = 0; x < image.getWidth(); x++) { + assertRGBEquals("Pixel differ: ", orig.getRGB(x, y), image.getRGB(x, y), 0); + } + } + + IIOMetadata metadata = reader.getImageMetadata(0); + IIOMetadataNode tree = (IIOMetadataNode) metadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName); + IIOMetadataNode compression = (IIOMetadataNode) tree.getElementsByTagName("CompressionTypeName").item(0); + assertEquals("LZW", compression.getAttribute("value")); + + boolean softwareFound = false; + NodeList textEntries = tree.getElementsByTagName("TextEntry"); + for (int i = 0; i < textEntries.getLength(); i++) { + IIOMetadataNode textEntry = (IIOMetadataNode) textEntries.item(i); + if ("Software".equals(textEntry.getAttribute("keyword"))) { + softwareFound = true; + assertTrue(textEntry.getAttribute("value").startsWith("TwelveMonkeys ImageIO TIFF")); + } + } + + assertTrue("Software metadata not found", softwareFound); + } + } + + @Test + public void testReadWriteRead24BitDeflate() throws IOException { + // Read original LZW compressed TIFF + IIOImage original; + + try (ImageInputStream input = ImageIO.createImageInputStream(getClass().getResource("/tiff/quad-lzw.tif"))) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + + original = reader.readAll(0, null); + reader.dispose(); + } + + assumeNotNull(original); + + // Write it back, using same compression (copied from metadata) + FastByteArrayOutputStream buffer = new FastByteArrayOutputStream(32768); + + try (ImageOutputStream output = ImageIO.createImageOutputStream(buffer)) { + ImageWriter writer = createImageWriter(); + writer.setOutput(output); + + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionType("Deflate"); + + writer.write(null, original, param); + writer.dispose(); + } + + // Try re-reading the same TIFF + try (ImageInputStream input = ImageIO.createImageInputStream(buffer.createInputStream())) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + BufferedImage image = reader.read(0); + + BufferedImage orig = (BufferedImage) original.getRenderedImage(); + + int maxH = Math.min(300, image.getHeight()); + for (int y = 0; y < maxH; y++) { + for (int x = 0; x < image.getWidth(); x++) { + assertRGBEquals("Pixel differ: ", orig.getRGB(x, y), image.getRGB(x, y), 0); + } + } + + IIOMetadata metadata = reader.getImageMetadata(0); + IIOMetadataNode tree = (IIOMetadataNode) metadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName); + IIOMetadataNode compression = (IIOMetadataNode) tree.getElementsByTagName("CompressionTypeName").item(0); + assertEquals("Deflate", compression.getAttribute("value")); + + boolean softwareFound = false; + NodeList textEntries = tree.getElementsByTagName("TextEntry"); + for (int i = 0; i < textEntries.getLength(); i++) { + IIOMetadataNode textEntry = (IIOMetadataNode) textEntries.item(i); + if ("Software".equals(textEntry.getAttribute("keyword"))) { + softwareFound = true; + assertTrue(textEntry.getAttribute("value").startsWith("TwelveMonkeys ImageIO TIFF")); + } + } + + assertTrue("Software metadata not found", softwareFound); + } + } + + @Test + public void testReadWriteRead24BitNone() throws IOException { + // Read original LZW compressed TIFF + IIOImage original; + + try (ImageInputStream input = ImageIO.createImageInputStream(getClass().getResource("/tiff/quad-lzw.tif"))) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + + original = reader.readAll(0, null); + reader.dispose(); + } + + assumeNotNull(original); + + // Write it back, using same compression (copied from metadata) + FastByteArrayOutputStream buffer = new FastByteArrayOutputStream(32768); + + try (ImageOutputStream output = ImageIO.createImageOutputStream(buffer)) { + ImageWriter writer = createImageWriter(); + writer.setOutput(output); + + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionType("None"); + + writer.write(null, original, param); + writer.dispose(); + } + +// Path tempFile = Files.createTempFile("test-", ".tif"); +// Files.write(tempFile, buffer.toByteArray()); +// System.out.println("open " + tempFile.toAbsolutePath()); + + // Try re-reading the same TIFF + try (ImageInputStream input = ImageIO.createImageInputStream(buffer.createInputStream())) { + ImageReader reader = ImageIO.getImageReaders(input).next(); + reader.setInput(input); + BufferedImage image = reader.read(0); + + BufferedImage orig = (BufferedImage) original.getRenderedImage(); + + int maxH = Math.min(300, image.getHeight()); + for (int y = 0; y < maxH; y++) { + for (int x = 0; x < image.getWidth(); x++) { + assertRGBEquals("Pixel differ: ", orig.getRGB(x, y), image.getRGB(x, y), 0); + } + } + + IIOMetadata metadata = reader.getImageMetadata(0); + IIOMetadataNode tree = (IIOMetadataNode) metadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName); + IIOMetadataNode compression = (IIOMetadataNode) tree.getElementsByTagName("CompressionTypeName").item(0); + assertEquals("None", compression.getAttribute("value")); + + boolean softwareFound = false; + NodeList textEntries = tree.getElementsByTagName("TextEntry"); + for (int i = 0; i < textEntries.getLength(); i++) { + IIOMetadataNode textEntry = (IIOMetadataNode) textEntries.item(i); + if ("Software".equals(textEntry.getAttribute("keyword"))) { + softwareFound = true; + assertTrue(textEntry.getAttribute("value").startsWith("TwelveMonkeys ImageIO TIFF")); + } + } + + assertTrue("Software metadata not found", softwareFound); } } } diff --git a/imageio/imageio-tiff/src/test/resources/tiff/a33.tif b/imageio/imageio-tiff/src/test/resources/tiff/a33.tif new file mode 100644 index 0000000000000000000000000000000000000000..ffda08c74f08e87b3fbcfe3c54111ab5e1b6ff5d GIT binary patch literal 26886 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