From 26ecf18c689ed52e586008567e80c500d5bc4fb9 Mon Sep 17 00:00:00 2001 From: Harald Kuhr Date: Wed, 11 Mar 2026 18:26:02 +0100 Subject: [PATCH] Mipmap support using ImageIO sequence API --- .../plugins/dds/DDSImageWriteParam.java | 11 -- .../imageio/plugins/dds/DDSImageWriter.java | 129 ++++++++++++--- .../plugins/dds/DDSImageWriteParamTest.java | 1 - .../plugins/dds/DDSImageWriterTest.java | 152 +++++++++++++++++- 4 files changed, 262 insertions(+), 31 deletions(-) diff --git a/imageio/imageio-dds/src/main/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriteParam.java b/imageio/imageio-dds/src/main/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriteParam.java index 18ec5a53..6b0ea889 100644 --- a/imageio/imageio-dds/src/main/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriteParam.java +++ b/imageio/imageio-dds/src/main/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriteParam.java @@ -42,16 +42,6 @@ public final class DDSImageWriteParam extends ImageWriteParam { return writeDXT10; } - BlockCompression compression() { - DDSType type = type(); - - if (type != null) { - return type.compression; - } - - return null; - } - DDSType type() { if (compressionType == null || compressionType.equals("None")) { return null; @@ -68,6 +58,5 @@ public final class DDSImageWriteParam extends ImageWriteParam { } return DXGI.DXGI_FORMAT_UNKNOWN; - } } diff --git a/imageio/imageio-dds/src/main/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriter.java b/imageio/imageio-dds/src/main/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriter.java index fca98b6a..690fa371 100644 --- a/imageio/imageio-dds/src/main/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriter.java +++ b/imageio/imageio-dds/src/main/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriter.java @@ -12,6 +12,7 @@ import javax.imageio.metadata.IIOMetadata; import javax.imageio.spi.ImageWriterSpi; import javax.imageio.stream.MemoryCacheImageOutputStream; +import java.awt.Dimension; import java.awt.image.Raster; import java.awt.image.RenderedImage; import java.io.File; @@ -27,6 +28,13 @@ import java.nio.file.Paths; * @author Harald Kuhr */ class DDSImageWriter extends ImageWriterBase { + + private long startPos; + // TODO: Create a SequenceSupport class that handles sequence prepare/write/end + private int mipmapIndex = -1; + private DDSType mipmapType; + private Dimension mipmapDimension; + protected DDSImageWriter(ImageWriterSpi provider) { super(provider); } @@ -36,9 +44,12 @@ class DDSImageWriter extends ImageWriterBase { return new DDSImageWriteParam(); } - // TODO: Suppport MipMaps using sequence methods - // This involves seeking backwards, updating the mipmap flag and mipmapcount in the header... :-/ - // + ensuring that each level is half the size of the previous, but still a multiple of 4... + @Override + protected void resetMembers() { + mipmapIndex = -1; + mipmapType = null; + mipmapDimension = null; + } @Override public boolean canWriteRasters() { @@ -46,36 +57,92 @@ class DDSImageWriter extends ImageWriterBase { } @Override - public void write(IIOMetadata streamMetadata, IIOImage image, ImageWriteParam param) throws IOException { + public boolean canWriteSequence() { + return true; + } + + @Override + public void prepareWriteSequence(IIOMetadata streamMetadata) throws IOException { assertOutput(); + if (mipmapIndex >= 0) { + throw new IllegalStateException("writeSequence already started"); + } + mipmapIndex = 0; + + startPos = imageOutput.getStreamPosition(); + imageOutput.setByteOrder(ByteOrder.LITTLE_ENDIAN); + imageOutput.writeInt(DDS.MAGIC); + imageOutput.flush(); + } + + @Override + public void endWriteSequence() throws IOException { + assertOutput(); + + if (mipmapIndex < 0) { + throw new IllegalStateException("prepareWriteSequence not called"); + } + + // Go back and update hader + updateHeader(mipmapIndex); + + mipmapIndex = -1; + mipmapType = null; + mipmapDimension = null; + + imageOutput.flush(); + } + + @Override + public void write(IIOMetadata streamMetadata, IIOImage image, ImageWriteParam param) throws IOException { + prepareWriteSequence(streamMetadata); + writeToSequence(image, param); + endWriteSequence(); + } + + @Override + public void writeToSequence(IIOImage image, ImageWriteParam param) throws IOException { + if (mipmapIndex < 0) { + throw new IllegalStateException("prepareWriteSequence not called"); + } + Raster raster = getRaster(image); - ensureTextureSize(raster); ensureImageChannels(raster); + ensureTextureDimension(raster); DDSImageWriteParam ddsParam = param instanceof DDSImageWriteParam ? ((DDSImageWriteParam) param) : IIOUtil.copyStandardParams(param, getDefaultWriteParam()); - if (ddsParam.compression() == null) { + DDSType type = ddsParam.type(); + if (mipmapType == null) { + mipmapType = type; + } + else if (type != mipmapType) { + processWarningOccurred(mipmapIndex, "All images in DDS MipMap must use same pixel format and compression"); + } + if (mipmapType == null) { throw new IIOException("Only compressed DDS using DXT1-5 or DXT10 with block compression is currently supported"); } - imageOutput.setByteOrder(ByteOrder.LITTLE_ENDIAN); - imageOutput.writeInt(DDS.MAGIC); - - writeHeader(raster.getWidth(), raster.getHeight(), ddsParam.type(), ddsParam.isWriteDXT10()); - if (ddsParam.isWriteDXT10()) { - writeDXT10Header(ddsParam.getDxgiFormat()); + if (mipmapIndex == 0) { + writeHeader(raster.getWidth(), raster.getHeight(), mipmapType, ddsParam.isWriteDXT10()); + if (ddsParam.isWriteDXT10()) { + writeDXT10Header(ddsParam.getDxgiFormat()); + } } - processImageStarted(0); + processImageStarted(mipmapIndex); processImageProgress(0f); - DDSImageDataEncoder.writeImageData(imageOutput, raster, ddsParam.compression()); - processImageProgress(100f); - imageOutput.flush(); + DDSImageDataEncoder.writeImageData(imageOutput, raster, mipmapType.compression); + + processImageProgress(100f); processImageComplete(); + + mipmapDimension = new Dimension(raster.getWidth(), raster.getHeight()); + mipmapIndex++; } private static Raster getRaster(IIOImage image) throws IIOException { @@ -113,9 +180,9 @@ class DDSImageWriter extends ImageWriterBase { /** * Checking if an image can be evenly divided into blocks of 4x4, ideally a power of 2. - * e.g. 16x16, 32x32, 512x128, 512x512, 1024x512, 1024x1024, 2048x1024, ... + * e.g. 16x16, 32x32, 512x128, 512x512, 1024x512, 1024x1024, 2048x1024... */ - private void ensureTextureSize(Raster raster) throws IIOException { + private void ensureTextureDimension(Raster raster) throws IIOException { int width = raster.getWidth(); int height = raster.getHeight(); @@ -123,6 +190,13 @@ class DDSImageWriter extends ImageWriterBase { if (width % 4 != 0 || height % 4 != 0) { throw new IIOException(String.format("Image dimensions must be dividable by 4, ideally a power of 2; got %dx%d", width, height)); } + + if (mipmapDimension != null && (mipmapDimension.width != width * 2|| mipmapDimension.height != height * 2)) { + throw new IIOException( + String.format("For mipmap, image dimensions must be exactly half of previous (%dx%d); got %dx%d", + mipmapDimension.width, mipmapDimension.height, width, height) + ); + } } private void writeHeader(int width, int height, DDSType type, boolean writeDXT10) throws IOException { @@ -149,6 +223,25 @@ class DDSImageWriter extends ImageWriterBase { imageOutput.write(new byte[12]); } + private void updateHeader(int mipmapCount) throws IOException { + if (mipmapCount == 1) { + // Fast case, nothing to do + return; + } + + long streamPosition = imageOutput.getStreamPosition(); + imageOutput.seek(startPos + 8); // Seek back to start + 4 magic + 4 header size + + int flags = imageOutput.readInt(); + imageOutput.seek(imageOutput.getStreamPosition() - 4); + imageOutput.writeInt(flags | DDS.FLAG_MIPMAPCOUNT); + + imageOutput.seek(imageOutput.getStreamPosition() + 16); + imageOutput.writeInt(mipmapCount); + + imageOutput.seek(streamPosition); // Restore pos + } + //https://learn.microsoft.com/en-us/windows/win32/direct3ddds/dds-pixelformat private void writePixelFormat(DDSType type, boolean writeDXT10) throws IOException { imageOutput.writeInt(DDS.PIXELFORMAT_SIZE); diff --git a/imageio/imageio-dds/src/test/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriteParamTest.java b/imageio/imageio-dds/src/test/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriteParamTest.java index cf509ff9..beeb2a59 100644 --- a/imageio/imageio-dds/src/test/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriteParamTest.java +++ b/imageio/imageio-dds/src/test/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriteParamTest.java @@ -49,7 +49,6 @@ class DDSImageWriteParamTest { DDSType type = DDSType.valueOf(compressionType); assertEquals(type, param.type()); - assertEquals(type.compression, param.compression()); } } } diff --git a/imageio/imageio-dds/src/test/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriterTest.java b/imageio/imageio-dds/src/test/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriterTest.java index 39e2d2ae..8182ab14 100644 --- a/imageio/imageio-dds/src/test/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriterTest.java +++ b/imageio/imageio-dds/src/test/java/com/twelvemonkeys/imageio/plugins/dds/DDSImageWriterTest.java @@ -1,12 +1,38 @@ package com.twelvemonkeys.imageio.plugins.dds; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + import com.twelvemonkeys.imageio.util.ImageWriterAbstractTest; +import javax.imageio.IIOException; +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.event.IIOWriteWarningListener; import javax.imageio.spi.ImageWriterSpi; +import javax.imageio.stream.ImageInputStream; +import javax.imageio.stream.ImageOutputStream; + +import java.awt.Image; import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.Arrays; import java.util.List; +import org.junit.jupiter.api.Test; + public class DDSImageWriterTest extends ImageWriterAbstractTest { @Override protected ImageWriterSpi createProvider() { @@ -16,11 +42,135 @@ public class DDSImageWriterTest extends ImageWriterAbstractTest @Override protected List getTestData() { return Arrays.asList( - new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB_PRE), + new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB), new BufferedImage(128, 128, BufferedImage.TYPE_INT_ARGB), new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB), new BufferedImage(32, 32, BufferedImage.TYPE_4BYTE_ABGR), new BufferedImage(16, 16, BufferedImage.TYPE_3BYTE_BGR) ); } + + @Test + void writeMipmap() throws IOException { + ImageWriter writer = createWriter(); + + try { + List testData = getTestData(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int previousSize = 0; + + try (ImageOutputStream stream = ImageIO.createImageOutputStream(buffer)) { + writer.setOutput(stream); + writer.prepareWriteSequence(null); + ImageWriteParam param = writer.getDefaultWriteParam(); + + assertTrue(buffer.size() > previousSize); + previousSize = buffer.size(); + + for (BufferedImage image : testData) { + writer.writeToSequence(new IIOImage(drawSomething(image), null, null), param); + } + + writer.endWriteSequence(); + assertTrue(buffer.size() > previousSize, "No image data written"); + } + catch (IOException e) { + throw new AssertionError(e.getMessage(), e); + } + + // Verify that we can read the file back... + ImageReader reader = ImageIO.getImageReader(writer); + + try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(buffer.toByteArray()))) { + stream.seek(0); + reader.setInput(stream); + + assertEquals(testData.size(), reader.getNumImages(false)); + + for (int i = 0; i < testData.size(); i++) { + BufferedImage image = reader.read(i, null); + + assertNotNull(image); + assertEquals(testData.get(i).getWidth(), image.getWidth()); + assertEquals(testData.get(i).getHeight(), image.getHeight()); + } + } + finally { + reader.dispose(); + } + + } + finally { + writer.dispose(); + } + } + + @Test + void writeMipmapDifferentCompression() throws IOException { + ImageWriter writer = createWriter(); + + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + try (ImageOutputStream stream = ImageIO.createImageOutputStream(buffer)) { + IIOWriteWarningListener listener = mock(); + writer.addIIOWriteWarningListener(listener); + writer.setOutput(stream); + + writer.prepareWriteSequence(null); + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionType("DXT2"); + + // Write first with DXT2 + List testData = getTestData(); + writer.writeToSequence(new IIOImage(drawSomething(testData.get(0)), null, null), param); + + // Repeat with different type + IIOImage image = new IIOImage(drawSomething(testData.get(1)), null, null); + param.setCompressionType("DXT1"); + + writer.writeToSequence(image, param); + + // Verify warning is issued + verify(listener).warningOccurred(eq(writer), eq(1), anyString()); + verifyNoMoreInteractions(listener); + } + catch (IOException e) { + throw new AssertionError(e.getMessage(), e); + } + } + finally { + writer.dispose(); + } + } + + @Test + void writeMipmapUnexpectedSize() throws IOException { + ImageWriter writer = createWriter(); + + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + try (ImageOutputStream stream = ImageIO.createImageOutputStream(buffer)) { + writer.setOutput(stream); + + writer.prepareWriteSequence(null); + ImageWriteParam param = writer.getDefaultWriteParam(); + BufferedImage testData = getTestData().get(0); + + IIOImage image = new IIOImage(drawSomething(testData), null, null); + writer.writeToSequence(image, param); + + // Repeat with same size... boom. + assertThrows(IIOException.class, () -> writer.writeToSequence(image, param)); + } + catch (IOException e) { + throw new AssertionError(e.getMessage(), e); + } + } + finally { + writer.dispose(); + } + } }