Mipmap support using ImageIO sequence API

This commit is contained in:
Harald Kuhr
2026-03-11 18:26:02 +01:00
parent 3f356a8197
commit 26ecf18c68
4 changed files with 262 additions and 31 deletions

View File

@@ -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;
}
}

View File

@@ -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 <a href="mailto:harald.kuhr@gmail.com">Harald Kuhr</a>
*/
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);

View File

@@ -49,7 +49,6 @@ class DDSImageWriteParamTest {
DDSType type = DDSType.valueOf(compressionType);
assertEquals(type, param.type());
assertEquals(type.compression, param.compression());
}
}
}

View File

@@ -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<DDSImageWriter> {
@Override
protected ImageWriterSpi createProvider() {
@@ -16,11 +42,135 @@ public class DDSImageWriterTest extends ImageWriterAbstractTest<DDSImageWriter>
@Override
protected List<BufferedImage> 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<BufferedImage> 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<BufferedImage> 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();
}
}
}