Skip to content

Commit

Permalink
Add TIFF compression (chunky-dev#1659)
Browse files Browse the repository at this point in the history
* move IO utils into their own package

* refactor TIFF writing to be more performant and easier to extend in the future,
add BufferedFileChannelOutputStream with some useful file writing enhancements

* add fallback for non-FileOutputStreams
  • Loading branch information
Maximilian Stiede committed Nov 1, 2023
1 parent 24eda0e commit 990bdc5
Show file tree
Hide file tree
Showing 18 changed files with 849 additions and 287 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@
*/
package se.llbit.chunky.renderer.export;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

import se.llbit.chunky.renderer.scene.Scene;
import se.llbit.imageformats.tiff.CompressionType;
import se.llbit.imageformats.tiff.TiffFileWriter;
import se.llbit.util.TaskTracker;

Expand Down Expand Up @@ -50,9 +59,37 @@ public boolean isTransparencySupported() {

@Override
public void write(OutputStream out, Scene scene, TaskTracker taskTracker) throws IOException {
try (TaskTracker.Task task = taskTracker.task("Writing TIFF");
TiffFileWriter writer = new TiffFileWriter(out)) {
writer.export(scene, task);
try (TaskTracker.Task task = taskTracker.task("Writing TIFF")) {
if (out instanceof FileOutputStream) {
write(((FileOutputStream) out).getChannel(), scene, task);
} else {
// fallback for the case, that the output stream was not created on a file
Path tempFile = Files.createTempFile(scene.name + "-", getExtension());
try (FileChannel fileChannel = FileChannel.open(tempFile, StandardOpenOption.DELETE_ON_CLOSE,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.READ, StandardOpenOption.WRITE
)) {
write(fileChannel, scene, task);
// rewind channel
fileChannel.position(0);
try (InputStream inputStream = Channels.newInputStream(fileChannel)) {
// copy temp file to output
inputStream.transferTo(out);
}
}
}
}
}

/**
* Note: does not (!) close the file channel after writing
*/
private void write(FileChannel fileChannel, Scene scene, TaskTracker.Task task) throws IOException {
TiffFileWriter writer = new TiffFileWriter(
fileChannel,
CompressionType.DEFLATE
);
writer.export(scene, task);
writer.doFinalization();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
package se.llbit.chunky.renderer.renderdump;

import se.llbit.chunky.renderer.scene.Scene;
import se.llbit.util.IsolatedOutputStream;
import se.llbit.util.io.IsolatedOutputStream;
import se.llbit.util.TaskTracker;

import java.io.DataInputStream;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package se.llbit.chunky.renderer.renderdump;

import se.llbit.chunky.renderer.scene.Scene;
import se.llbit.util.IsolatedOutputStream;
import se.llbit.util.io.IsolatedOutputStream;

import java.io.DataInputStream;
import java.io.DataOutputStream;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package se.llbit.chunky.renderer.renderdump;

import se.llbit.chunky.renderer.scene.Scene;
import se.llbit.util.IsolatedOutputStream;
import se.llbit.util.io.IsolatedOutputStream;

import java.io.DataInputStream;
import java.io.DataOutputStream;
Expand Down
2 changes: 2 additions & 0 deletions chunky/src/java/se/llbit/chunky/renderer/scene/Scene.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
import se.llbit.nbt.Tag;
import se.llbit.util.*;
import se.llbit.util.annotation.NotNull;
import se.llbit.util.io.PositionalInputStream;
import se.llbit.util.io.ZipExport;
import se.llbit.util.mojangapi.MinecraftProfile;

import java.io.*;
Expand Down
50 changes: 50 additions & 0 deletions chunky/src/java/se/llbit/imageformats/tiff/BasicIFD.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package se.llbit.imageformats.tiff;

import java.io.IOException;

public class BasicIFD extends ImageFileDirectory {
final CompressionType compressionType;

public BasicIFD(
int width, int height,
CompressionType compressionType
) throws IOException {
this.compressionType = compressionType;

// RGB full color
addTag(IFDTag.TAG_PHOTOMETRIC_INTERPRETATION, (short) 2);
// Store pixel components contiguously [RGBRGBRGB...]
addTag(IFDTag.TAG_PLANAR_CONFIGURATION, (short) 1);

assert (width <= Short.MAX_VALUE);
addTag(IFDTag.TAG_IMAGE_WIDTH, (short) width);
assert (height <= Short.MAX_VALUE);
addTag(IFDTag.TAG_IMAGE_HEIGHT, (short) height);
// The 0th row represents the visual top of the image, and the 0th column represents the visual left-hand side.
addTag(IFDTag.TAG_ORIENTATION, (short) 1);

// No compression, but pack data into bytes as tightly as possible, leaving no unused
// bits (except at the end of a row). The component values are stored as an array of
// type BYTE. Each scan line (row) is padded to the next BYTE boundary.
addTag(IFDTag.TAG_COMPRESSION_TYPE, compressionType.id);

// Image does not have a physical size
addTag(IFDTag.TAG_RESOLUTION_UNIT, (short) 1); // not an absolute unit
addMultiTag(IFDTag.TAG_X_RESOLUTION, new int[]{1, 1});
addMultiTag(IFDTag.TAG_Y_RESOLUTION, new int[]{1, 1});

// "Compressed or uncompressed image data can be stored almost anywhere in a
// TIFF file. TIFF also supports breaking an image into separate strips for increased
// editing flexibility and efficient I/O buffering."
// We will use exactly 1 strip, therefore the relevant tags have only 1 entry with all rows in 1 strip.
addTag(IFDTag.TAG_ROWS_PER_STRIP, height);
}

@Override
void writePixelData(
FinalizableBFCOutputStream out,
PixelDataWriter writer
) throws IOException {
compressionType.writePixelData(out, writer);
}
}
37 changes: 37 additions & 0 deletions chunky/src/java/se/llbit/imageformats/tiff/CompressionType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package se.llbit.imageformats.tiff;

import java.io.DataOutputStream;
import java.io.IOException;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;

public enum CompressionType {
NONE(0x0001),
DEFLATE(0x80B2);

final short id;

CompressionType(int id) {
this.id = (short) id;
}

void writePixelData(
FinalizableBFCOutputStream out,
ImageFileDirectory.PixelDataWriter writer
) throws IOException {
switch (this) {
case NONE:
writer.writePixelData(out);
out.flush();
break;

case DEFLATE:
Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION, false);
DeflaterOutputStream deflOut = new DeflaterOutputStream(out, deflater, 16 * 1024, true);
writer.writePixelData(new DataOutputStream(deflOut));
deflOut.finish();
deflater.end();
break;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package se.llbit.imageformats.tiff;

import se.llbit.util.io.BufferedFileChannelOutputStream;

import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.ArrayDeque;
import java.util.Deque;

class FinalizableBFCOutputStream extends BufferedFileChannelOutputStream {

private static final Deque<UnfinalizedData<?>> finalizationQueue = new ArrayDeque<>();

public FinalizableBFCOutputStream(FileChannel fileChannel) {
super(fileChannel);
}

void ensureAlignment() throws IOException {
if ((position() & 0b1) != 0)
write((byte) 0);
}

UnfinalizedData.Int writeUnfinalizedInt() throws IOException {
return writeUnfinalized(new UnfinalizedData.Int((int) position()), 4);
}
private <T extends UnfinalizedData<?>> T writeUnfinalized(T ud, int byteCount) throws IOException {
finalizationQueue.add(ud);
skip(byteCount);
return ud;
}

/**
* writes remaining unfinalized data
*/
public void doFinalization() throws IOException {
for(UnfinalizedData<?> data : finalizationQueue) {
data.write(this);
}
finalizationQueue.clear();
}

/**
* does finalization, then closes the output stream
*/
@Override
public void close() throws IOException {
doFinalization();
super.close();
}

static abstract class UnfinalizedData<T> {
final long position;
protected T data;

UnfinalizedData(long position) {
this.position = position;
}

public void setData(T data) {
this.data = data;
}

public T getData() {
return data;
}

public void write(FinalizableBFCOutputStream out) throws IOException {
out.position(position);
if(data != null) {
writeData(out);
}
}

abstract void writeData(FinalizableBFCOutputStream out) throws IOException;

static class Int extends UnfinalizedData<Integer> {
Int(long position) {
super(position);
}

@Override
void writeData(FinalizableBFCOutputStream out) throws IOException {
out.writeInt(data);
}
}
}
}
Loading

0 comments on commit 990bdc5

Please sign in to comment.