diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f21422f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 saharNooby (https://github.com/saharNooby) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..158cb43 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# qoi-java + +A pure Java 8 implementation of [Quite OK Image Format](https://github.com/phoboslab/qoi). + +This library has no runtime dependencies, including Java AWT. `BufferedImage` support is provided using separate module [qoi-java-awt](https://github.com/saharNooby/qoi-java-awt). + +**Production use warning**: QOI file format is not finalized yet. You can track progress [here in the original repository](https://github.com/phoboslab/qoi/issues/48). + +## How to Use + +### Build and Install + +You will need Git, Maven and JDK 8 or higher. + +```shell +git clone https://github.com/saharNooby/qoi-java.git +cd qoi-java +mvn clean install +``` + +Add this library as a dependency to your build system. Maven example: + +```xml + + me.saharnooby + qoi-java + 0.0.1 + +``` + +### Usage + +Use methods in class `me.saharnooby.qoi.QOIUtil`. + +Usage example: + +```java +// Read image from a file +QOIImage image = QOIUtil.readFile(new File("image.qoi")); + +System.out.println("Image size: " + image.getWidth() + " x " + image.getHeight()); + +// Access pixel data +System.out.println("Red channel is " + image.getPixelData()[0]); + +// Create new 1x1 RGBA image from raw pixel data +byte[] pixelData = {(byte) 255, 127, 0, (byte) 255}; + +QOIImage orangeImage = QOIUtil.createFromPixelData(pixelData, 1, 1, 4); + +// Write image to a file +QOIUtil.writeImage(orangeImage, new File("orange.qoi")); +``` + +### Use with `BufferedImage` + +To convert QOI images to BufferedImages and back, you need to also add [qoi-java-awt](https://github.com/saharNooby/qoi-java-awt) dependency. + +Use methods in class `me.saharnooby.qoi.QOIUtilAWT`. + +Usage example: + +```java +// Convert PNG to QOI +BufferedImage image = ImageIO.read(new File("image.png")); +QOIImage qoi = QOIUtilAWT.createFromBufferedImage(image); +QOIUtil.writeImage(qoi, new File("image.qoi")); + +// Convert QOI to PNG +QOIImage secondImage = QOIUtil.readFile(new File("second-image.qoi")); +BufferedImage convertedImage = QOIUtilAWT.convertToBufferedImage(secondImage); +ImageIO.write(convertedImage, "PNG", new File("second-image.png")); +``` + +## Compatibility + +No AWT classes are used, so it should be compatible with Android. Please report compatibility issues, if they arise. + +## Versioning + +This project uses semantic versioning. Until QOI file format is finalized, major version will remain 0. \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a241b1d --- /dev/null +++ b/pom.xml @@ -0,0 +1,96 @@ + + + 4.0.0 + + me.saharnooby + qoi-java + 0.0.1 + + qoi-java + A pure Java 8 implementation of Quite OK Image Format + https://github.com/saharNooby/qoi-java + + + + MIT License + http://www.opensource.org/licenses/mit-license.php + + + + + + saharNooby + https://saharnooby.me + + + + + UTF-8 + + 8 + 8 + + + + + org.projectlombok + lombok + 1.18.20 + provided + + + + org.junit.jupiter + junit-jupiter-api + 5.0.0 + test + + + + org.junit.jupiter + junit-jupiter-engine + 5.0.0 + test + + + + + clean install + + + + maven-surefire-plugin + 2.22.2 + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.2.0 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar + + + + + + + + \ No newline at end of file diff --git a/src/main/java/me/saharnooby/qoi/InvalidQOIStreamException.java b/src/main/java/me/saharnooby/qoi/InvalidQOIStreamException.java new file mode 100644 index 0000000..50d476c --- /dev/null +++ b/src/main/java/me/saharnooby/qoi/InvalidQOIStreamException.java @@ -0,0 +1,16 @@ +package me.saharnooby.qoi; + +import lombok.NonNull; + +import java.io.IOException; + +/** + * This exception is thrown when decoder detects invalid data in the input stream. + */ +public final class InvalidQOIStreamException extends IOException { + + InvalidQOIStreamException(@NonNull String message) { + super(message); + } + +} diff --git a/src/main/java/me/saharnooby/qoi/QOICodec.java b/src/main/java/me/saharnooby/qoi/QOICodec.java new file mode 100644 index 0000000..3fdab70 --- /dev/null +++ b/src/main/java/me/saharnooby/qoi/QOICodec.java @@ -0,0 +1,47 @@ +package me.saharnooby.qoi; + +/** + * Contains constants and utility methods for decoder and encoder. + */ +final class QOICodec { + + static final int QOI_SRGB = 0x00; + static final int QOI_SRGB_LINEAR_ALPHA = 0x01; + static final int QOI_LINEAR = 0x0F; + + static final int QOI_INDEX = 0x00; + static final int QOI_RUN_8 = 0x40; + static final int QOI_RUN_16 = 0x60; + static final int QOI_DIFF_8 = 0x80; + static final int QOI_DIFF_16 = 0xC0; + static final int QOI_DIFF_24 = 0xE0; + static final int QOI_COLOR = 0xF0; + + static final int QOI_MASK_2 = 0xC0; + static final int QOI_MASK_3 = 0xE0; + static final int QOI_MASK_4 = 0xF0; + + static final int QOI_MAGIC = 'q' << 24 | 'o' << 16 | 'i' << 8 | 'f'; + + static final int QOI_PADDING = 4; + + private static final int HASH_TABLE_SIZE = 64; + + static byte[] createHashTable() { + byte[] index = new byte[HASH_TABLE_SIZE * 4]; + + // Fill alpha with default value of 255 + for (int i = 3; i < index.length; i += 4) { + index[i] = (byte) 0xFF; + } + + return index; + } + + static int getHashTableIndex(int r, int g, int b, int a) { + int hash = r ^ g ^ b ^ a; + + return hash & (HASH_TABLE_SIZE - 1); + } + +} diff --git a/src/main/java/me/saharnooby/qoi/QOIColorSpace.java b/src/main/java/me/saharnooby/qoi/QOIColorSpace.java new file mode 100644 index 0000000..3d2f163 --- /dev/null +++ b/src/main/java/me/saharnooby/qoi/QOIColorSpace.java @@ -0,0 +1,21 @@ +package me.saharnooby.qoi; + +/** + * A color space that can be specified in a QOI file. + */ +public enum QOIColorSpace { + + /** + * sRGB color space. + */ + SRGB, + /** + * sRGB color space with linear alpha channel. + */ + SRGB_LINEAR_ALPHA, + /** + * All channels are linear. + */ + LINEAR + +} diff --git a/src/main/java/me/saharnooby/qoi/QOIDecoder.java b/src/main/java/me/saharnooby/qoi/QOIDecoder.java new file mode 100644 index 0000000..1893542 --- /dev/null +++ b/src/main/java/me/saharnooby/qoi/QOIDecoder.java @@ -0,0 +1,183 @@ +package me.saharnooby.qoi; + +import lombok.NonNull; + +import java.io.IOException; +import java.io.InputStream; + +import static me.saharnooby.qoi.QOICodec.*; + +/** + * Contains method that decodes data stream into raw pixel data. + */ +public final class QOIDecoder { + + /** + * Decodes data in the input stream into raw pixel data. + * @param in Input stream, should be buffered for optimal performance. + * @param channels Channel count. Allowed values are 3, 4 and 0 (read as many channels as actually stored). + * @return QOI image. + * @throws IllegalArgumentException If channel count is invalid. + * @throws InvalidQOIStreamException If provided data does not represent a valid QOI image. + * @throws IOException On any IO error. + */ + public static QOIImage decode(@NonNull InputStream in, int channels) throws IOException { + if (channels != 0 && channels != 3 && channels != 4) { + throw new IllegalArgumentException("Invalid channel count, must be 0, 3 or 4"); + } + + int headerMagic = read32(in); + + if (headerMagic != QOI_MAGIC) { + throw new InvalidQOIStreamException("Invalid magic value, probably not a QOI image"); + } + + int width = read32(in); + + if (width < 1) { + throw new InvalidQOIStreamException("Invalid image width"); + } + + int height = read32(in); + + if (height < 1) { + throw new InvalidQOIStreamException("Invalid image height"); + } + + int storedChannels = read(in); + + if (storedChannels != 3 && storedChannels != 4) { + throw new InvalidQOIStreamException("Invalid stored channel count"); + } + + if (channels == 0) { + channels = storedChannels; + } + + QOIColorSpace colorSpace = readColorSpace(in); + + int pixelDataLength = width * height * channels; + + byte[] pixelData = new byte[pixelDataLength]; + + byte[] index = createHashTable(); + + int pixelR = 0; + int pixelG = 0; + int pixelB = 0; + int pixelA = 0xFF; + + int run = 0; + + for (int pixelPos = 0; pixelPos < pixelDataLength; pixelPos += channels) { + if (run > 0) { + run--; + } else { + int b1 = read(in); + + if ((b1 & QOI_MASK_2) == QOI_INDEX) { + int indexPos = (b1 ^ QOI_INDEX) * 4; + + pixelR = index[indexPos] & 0xFF; + pixelG = index[indexPos + 1] & 0xFF; + pixelB = index[indexPos + 2] & 0xFF; + pixelA = index[indexPos + 3] & 0xFF; + } else if ((b1 & QOI_MASK_3) == QOI_RUN_8) { + run = b1 & 0x1F; + } else if ((b1 & QOI_MASK_3) == QOI_RUN_16) { + int b2 = read(in); + + run = (((b1 & 0x1F) << 8) | (b2)) + 32; + } else if ((b1 & QOI_MASK_2) == QOI_DIFF_8) { + pixelR += ((b1 >> 4) & 0x03) - 2; + pixelG += ((b1 >> 2) & 0x03) - 2; + pixelB += (b1 & 0x03) - 2; + } else if ((b1 & QOI_MASK_3) == QOI_DIFF_16) { + int b2 = read(in); + + pixelR += (b1 & 0x1F) - 16; + pixelG += (b2 >> 4) - 8; + pixelB += (b2 & 0x0F) - 8; + } else if ((b1 & QOI_MASK_4) == QOI_DIFF_24) { + int b2 = read(in); + int b3 = read(in); + + pixelR += (((b1 & 0x0F) << 1) | (b2 >> 7)) - 16; + pixelG += ((b2 & 0x7C) >> 2) - 16; + pixelB += (((b2 & 0x03) << 3) | ((b3 & 0xE0) >> 5)) - 16; + pixelA += (b3 & 0x1F) - 16; + } else if ((b1 & QOI_MASK_4) == QOI_COLOR) { + if ((b1 & 8) != 0) { + pixelR = read(in); + } + + if ((b1 & 4) != 0) { + pixelG = read(in); + } + + if ((b1 & 2) != 0) { + pixelB = read(in); + } + + if ((b1 & 1) != 0) { + pixelA = read(in); + } + } + + int indexPos = getHashTableIndex(pixelR, pixelG, pixelB, pixelA) * 4; + index[indexPos] = (byte) pixelR; + index[indexPos + 1] = (byte) pixelG; + index[indexPos + 2] = (byte) pixelB; + index[indexPos + 3] = (byte) pixelA; + } + + pixelData[pixelPos] = (byte) pixelR; + pixelData[pixelPos + 1] = (byte) pixelG; + pixelData[pixelPos + 2] = (byte) pixelB; + + if (channels == 4) { + pixelData[pixelPos + 3] = (byte) pixelA; + } + } + + for (int i = 0; i < QOI_PADDING; i++) { + read(in); + } + + return new QOIImage(width, height, channels, colorSpace, pixelData); + } + + private static int read(@NonNull InputStream in) throws IOException { + int read = in.read(); + + if (read < 0) { + throw new InvalidQOIStreamException("Unexpected end of stream"); + } + + return read & 0xFF; + } + + private static int read32(@NonNull InputStream in) throws IOException { + int a = read(in); + int b = read(in); + int c = read(in); + int d = read(in); + return (a << 24) | (b << 16) | (c << 8) | d; + } + + private static QOIColorSpace readColorSpace(@NonNull InputStream in) throws IOException { + int value = read(in); + + switch (value) { + case QOI_SRGB: + return QOIColorSpace.SRGB; + case QOI_SRGB_LINEAR_ALPHA: + return QOIColorSpace.SRGB_LINEAR_ALPHA; + case QOI_LINEAR: + return QOIColorSpace.LINEAR; + } + + throw new InvalidQOIStreamException("Invalid color space value " + value); + } + +} diff --git a/src/main/java/me/saharnooby/qoi/QOIEncoder.java b/src/main/java/me/saharnooby/qoi/QOIEncoder.java new file mode 100644 index 0000000..aeb184d --- /dev/null +++ b/src/main/java/me/saharnooby/qoi/QOIEncoder.java @@ -0,0 +1,170 @@ +package me.saharnooby.qoi; + +import lombok.NonNull; + +import java.io.IOException; +import java.io.OutputStream; + +import static me.saharnooby.qoi.QOICodec.*; + +/** + * Contains method that encodes raw pixel data into bytes. + */ +public final class QOIEncoder { + + /** + * Encodes raw pixel data into QOI image, which then is written into the provided output stream. + * @param image QOI image. + * @param out Output stream, should be buffered for optimal performance. + * @throws IOException On any IO error. + */ + public static void encode(@NonNull QOIImage image, @NonNull OutputStream out) throws IOException { + int channels = image.getChannels(); + + byte[] pixelData = image.getPixelData(); + + write32(out, QOI_MAGIC); + write32(out, image.getWidth()); + write32(out, image.getHeight()); + out.write(image.getChannels()); + + switch (image.getColorSpace()) { + case SRGB: + out.write(QOI_SRGB); + break; + case SRGB_LINEAR_ALPHA: + out.write(QOI_SRGB_LINEAR_ALPHA); + break; + case LINEAR: + out.write(QOI_LINEAR); + break; + default: + throw new IllegalStateException("Unsupported color space"); + } + + byte[] index = createHashTable(); + + int run = 0; + + int prevR = 0; + int prevG = 0; + int prevB = 0; + int prevA = 0xFF; + + int pixelR; + int pixelG; + int pixelB; + int pixelA = 0xFF; + + for (int pixelPos = 0; pixelPos < pixelData.length; pixelPos += channels) { + pixelR = pixelData[pixelPos] & 0xFF; + pixelG = pixelData[pixelPos + 1] & 0xFF; + pixelB = pixelData[pixelPos + 2] & 0xFF; + + if (channels == 4) { + pixelA = pixelData[pixelPos + 3] & 0xFF; + } + + boolean prevEqualsCurrent = equals(prevR, prevG, prevB, prevA, pixelR, pixelG, pixelB, pixelA); + + if (prevEqualsCurrent) { + run++; + } + + if (run > 0 && (run == 0x2020 || !prevEqualsCurrent || pixelPos + channels == pixelData.length)) { + if (run < 33) { + run -= 1; + out.write(QOI_RUN_8 | run); + } else { + run -= 33; + out.write(QOI_RUN_16 | run >> 8); + out.write(run); + } + + run = 0; + } + + if (!prevEqualsCurrent) { + int indexPos = getHashTableIndex(pixelR, pixelG, pixelB, pixelA) * 4; + + if (equals(pixelR, pixelG, pixelB, pixelA, index[indexPos], index[indexPos + 1], index[indexPos + 2], index[indexPos + 3])) { + out.write(QOI_INDEX | indexPos); + } else { + index[indexPos] = (byte) pixelR; + index[indexPos + 1] = (byte) pixelG; + index[indexPos + 2] = (byte) pixelB; + index[indexPos + 3] = (byte) pixelA; + + int dr = pixelR - prevR; + int dg = pixelG - prevG; + int db = pixelB - prevB; + int da = pixelA - prevA; + + if (smallDiff(dr) && smallDiff(dg) && smallDiff(db) && smallDiff(da)) { + if (da == 0 && smallestDiff(dr) && smallestDiff(dg) && smallestDiff(db)) { + out.write(QOI_DIFF_8 | ((dr + 2) << 4) | (dg + 2) << 2 | (db + 2)); + } else if (da == 0 && smallerDiff(dg) && smallerDiff(db)) { + out.write(QOI_DIFF_16 | (dr + 16)); + out.write((dg + 8) << 4 | (db + 8)); + } else { + out.write(QOI_DIFF_24 | (dr + 16) >> 1); + out.write((dr + 16) << 7 | (dg + 16) << 2 | (db + 16) >> 3); + out.write((db + 16) << 5 | (da + 16)); + } + } else { + out.write(QOI_COLOR | (dr != 0 ? 8 : 0) | (dg != 0 ? 4 : 0) | (db != 0 ? 2 : 0) | (da != 0 ? 1 : 0)); + + if (dr != 0) { + out.write(pixelR); + } + + if (dg != 0) { + out.write(pixelG); + } + + if (db != 0) { + out.write(pixelB); + } + + if (da != 0) { + out.write(pixelA); + } + } + } + } + + prevR = pixelR; + prevG = pixelG; + prevB = pixelB; + prevA = pixelA; + } + + for (int i = 0; i < QOI_PADDING; i++) { + out.write(0); + } + } + + private static void write32(@NonNull OutputStream out, int value) throws IOException { + out.write(value >> 24); + out.write(value >> 16); + out.write(value >> 8); + out.write(value); + } + + private static boolean smallDiff(int i) { + return i > -17 && i < 16; + } + + private static boolean smallerDiff(int i) { + return i > -9 && i < 8; + } + + private static boolean smallestDiff(int i) { + return i > -3 && i < 2; + } + + private static boolean equals(int r1, int g1, int b1, int a1, int r2, int g2, int b2, int a2) { + return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2; + } + +} diff --git a/src/main/java/me/saharnooby/qoi/QOIImage.java b/src/main/java/me/saharnooby/qoi/QOIImage.java new file mode 100644 index 0000000..b93084a --- /dev/null +++ b/src/main/java/me/saharnooby/qoi/QOIImage.java @@ -0,0 +1,40 @@ +package me.saharnooby.qoi; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +/** + * A bundle of QOI image metadata and raw pixel data. + * Use methods in {@link QOIUtil} to create instances of this class. + */ +@Data +@RequiredArgsConstructor(access = AccessLevel.PACKAGE) +public final class QOIImage { + + /** + * Image width. Positive value. + */ + private final int width; + /** + * Image height. Positive value. + */ + private final int height; + /** + * Channel count. Supported values are 3 (no alpha) and 4 (with alpha). + */ + private final int channels; + /** + * Color space of the image. + */ + @NonNull + private final QOIColorSpace colorSpace; + /** + * Raw pixel data in the form of [R, G, B, (A,) ...]. + * The array has (width * height * channels) elements. + * Alpha is present when channel count is 4. + */ + private final byte @NonNull [] pixelData; + +} diff --git a/src/main/java/me/saharnooby/qoi/QOIUtil.java b/src/main/java/me/saharnooby/qoi/QOIUtil.java new file mode 100644 index 0000000..09f351c --- /dev/null +++ b/src/main/java/me/saharnooby/qoi/QOIUtil.java @@ -0,0 +1,142 @@ +package me.saharnooby.qoi; + +import lombok.NonNull; + +import java.io.*; + +/** + * Contains public API methods of the library. + */ +@SuppressWarnings("unused") +public final class QOIUtil { + + /** + * Creates a QOI image from raw pixel data. + * Channel count is detected automatically. + * Data array is not copied. + * @param pixelData Pixel data array in the form of [R, G, B, (A,) ...]. + * @param width Image width, must be positive. + * @param height Image height, must be positive. + * @return QOI image. + * @throws IllegalArgumentException If any arguments are invalid. + */ + public static QOIImage createFromPixelData(byte @NonNull [] pixelData, int width, int height) { + return createFromPixelData(pixelData, width, height, pixelData.length / width / height); + } + + /** + * Creates a QOI image from raw pixel data. + * Data array is not copied. + * @param pixelData Pixel data array in the form of [R, G, B, (A,) ...]. Alpha must be present only if channel count is 4. + * @param width Image width, must be positive. + * @param height Image height, must be positive. + * @param channels Channel count, must be 3 of 4. + * @return QOI image. + * @throws IllegalArgumentException If any arguments are invalid. + */ + public static QOIImage createFromPixelData(byte @NonNull [] pixelData, int width, int height, int channels) { + return createFromPixelData(pixelData, width, height, channels, QOIColorSpace.SRGB); + } + + /** + * Creates a QOI image from raw pixel data. + * Data array is not copied. + * @param pixelData Pixel data array in the form of [R, G, B, (A,) ...]. Alpha must be present only if channel count is 4. + * @param width Image width, must be positive. + * @param height Image height, must be positive. + * @param channels Channel count, must be 3 or 4. + * @param colorSpace Color space. + * @return QOI image. + * @throws IllegalArgumentException If any arguments are invalid. + */ + public static QOIImage createFromPixelData(byte @NonNull [] pixelData, int width, int height, int channels, @NonNull QOIColorSpace colorSpace) { + if (width < 1) { + throw new IllegalArgumentException("Width must be positive"); + } + + if (height < 1) { + throw new IllegalArgumentException("Height must be positive"); + } + + if (channels != 3 && channels != 4) { + throw new IllegalArgumentException("3 or 4 channels are supported"); + } + + if (pixelData.length != width * height * channels) { + throw new IllegalArgumentException("Unexpected pixel data array length, must match width * height * channels"); + } + + return new QOIImage(width, height, channels, colorSpace, pixelData); + } + + /** + * Reads a QOI image from an input stream. + * @param in Input stream. + * @return QOI image. + * @throws InvalidQOIStreamException If provided data does not represent a valid QOI image. + * @throws IOException On any IO error. + */ + public static QOIImage readImage(@NonNull InputStream in) throws IOException { + return readImage(in, 0); + } + + /** + * Reads a QOI image from an input stream. + * @param in Input stream. + * @param channels Channel count, must be 0 (auto), 3 or 4. + * @return QOI image. + * @throws InvalidQOIStreamException If provided data does not represent a valid QOI image. + * @throws IOException On any IO error. + */ + public static QOIImage readImage(@NonNull InputStream in, int channels) throws IOException { + return QOIDecoder.decode(in, channels); + } + + /** + * Reads a QOI image from a file. + * @param file File. + * @return QOI image. + * @throws InvalidQOIStreamException If provided file does not represent a valid QOI image. + * @throws IOException On any IO error. + */ + public static QOIImage readFile(@NonNull File file) throws IOException { + return readFile(file, 0); + } + + /** + * Reads a QOI image from a file. + * @param file File. + * @param channels Channel count, must be 0 (auto), 3 or 4. + * @return QOI image. + * @throws InvalidQOIStreamException If provided file does not represent a valid QOI image. + * @throws IOException On any IO error. + */ + public static QOIImage readFile(@NonNull File file, int channels) throws IOException { + try (InputStream in = new BufferedInputStream(new FileInputStream(file))) { + return readImage(in, channels); + } + } + + /** + * Writes a QOI image into an output stream. + * @param image Image. + * @param out Output stream. + * @throws IOException On any IO error. + */ + public static void writeImage(@NonNull QOIImage image, @NonNull OutputStream out) throws IOException { + QOIEncoder.encode(image, out); + } + + /** + * Writes a QOI image into a file. + * @param image Image. + * @param file File. + * @throws IOException On any IO error. + */ + public static void writeImage(@NonNull QOIImage image, @NonNull File file) throws IOException { + try (OutputStream out = new BufferedOutputStream(new FileOutputStream(file))) { + writeImage(image, out); + } + } + +} diff --git a/src/test/java/me/saharnooby/qoi/QOITest.java b/src/test/java/me/saharnooby/qoi/QOITest.java new file mode 100644 index 0000000..43a2e9d --- /dev/null +++ b/src/test/java/me/saharnooby/qoi/QOITest.java @@ -0,0 +1,104 @@ +package me.saharnooby.qoi; + +import lombok.NonNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.*; +import java.util.Objects; +import java.util.Random; + +/** + * @author saharNooby + * @since 16:38 01.12.2021 + */ +class QOITest { + + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + @Test + void testEmptyImageRGB() throws Exception { + int size = 128; + + testEncodingDecoding(new byte[3 * size * size], size, size, 3); + } + + @Test + void testEmptyImageRGBA() throws Exception { + int size = 128; + + testEncodingDecoding(new byte[4 * size * size], size, size, 4); + } + + @Test + void testRandomImage() throws Exception { + int width = 800; + int height = 600; + + Random random = new Random("seed".hashCode()); + + for (int channels : new int[] {3, 4}) { + byte[] data = new byte[width * height * channels]; + + random.nextBytes(data); + + testEncodingDecoding(data, width, height, channels); + } + } + + @Test + void testNormalImage() throws Exception { + InputStream in = Objects.requireNonNull(getClass().getResourceAsStream("/orange-cross.qoi"), "Test image not found"); + + QOIImage rgb = QOIDecoder.decode(new BufferedInputStream(in), 3); + + in = Objects.requireNonNull(getClass().getResourceAsStream("/orange-cross.qoi"), "Test image not found"); + + QOIImage rgba = QOIDecoder.decode(new BufferedInputStream(in), 4); + + testEncodingDecoding(rgb.getPixelData(), rgb.getWidth(), rgb.getHeight(), 3); + testEncodingDecoding(rgba.getPixelData(), rgba.getWidth(), rgba.getHeight(), 4); + } + + /** + * Tests that after encoding and decoding back data stays the same. + */ + private void testEncodingDecoding(byte @NonNull [] pixelData, int width, int height, int channels) throws Exception { + this.out.reset(); + + QOIEncoder.encode(new QOIImage(width, height, channels, QOIColorSpace.SRGB, pixelData), this.out); + + QOIImage decoded = decode(channels); + + Assertions.assertArrayEquals(pixelData, decoded.getPixelData()); + + // Try read with different channel count + int differentChannels = channels == 3 ? 4 : 3; + + decoded = decode(differentChannels); + + for (int i = 0; i < width * height; i++) { + Assertions.assertEquals(pixelData[i * channels], decoded.getPixelData()[i * differentChannels]); + Assertions.assertEquals(pixelData[i * channels + 1], decoded.getPixelData()[i * differentChannels + 1]); + Assertions.assertEquals(pixelData[i * channels + 2], decoded.getPixelData()[i * differentChannels + 2]); + + if (differentChannels == 4) { + Assertions.assertEquals(0xFF, decoded.getPixelData()[i * differentChannels + 3] & 0xFF); + } + } + } + + /** + * Decodes current buffer into raw pixel data. + */ + private QOIImage decode(int channels) throws IOException { + ByteArrayInputStream in = new ByteArrayInputStream(this.out.toByteArray()); + + QOIImage decoded = QOIDecoder.decode(in, channels); + + Assertions.assertEquals(0, in.available(), "Expected input stream to be read fully"); + + return decoded; + } + +} \ No newline at end of file diff --git a/src/test/resources/orange-cross.qoi b/src/test/resources/orange-cross.qoi new file mode 100644 index 0000000..5eb0666 Binary files /dev/null and b/src/test/resources/orange-cross.qoi differ