From ffa6132a5443ec2399ef606c90b45e2395e68dfe Mon Sep 17 00:00:00 2001 From: Konstantin Date: Fri, 3 Jul 2026 16:43:58 +0200 Subject: [PATCH 1/2] add jpeg codec and testcases --- .../zarr/zarrjava/v3/codec/CodecBuilder.java | 14 + .../zarr/zarrjava/v3/codec/CodecRegistry.java | 1 + .../zarrjava/v3/codec/core/JpegCodec.java | 253 ++++++++++++++++++ .../java/dev/zarr/zarrjava/ZarrV3Test.java | 145 ++++++++++ 4 files changed, 413 insertions(+) create mode 100644 src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java index 5c3487ce..bd3d70dc 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java @@ -106,6 +106,20 @@ public CodecBuilder withZstd() { return withZstd(5, true); } + public CodecBuilder withJpeg(int quality) { + try { + codecs.add(new JpegCodec(new JpegCodec.Configuration(quality))); + } catch (ZarrException e) { + throw new RuntimeException(e); + } + return this; + } + + public CodecBuilder withJpeg() { + codecs.add(new JpegCodec()); + return this; + } + public CodecBuilder withZstd(int level) { return withZstd(level, true); } diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java index ad249e08..b8f88b15 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java @@ -16,6 +16,7 @@ public class CodecRegistry { addType("blosc", BloscCodec.class); addType("gzip", GzipCodec.class); addType("zstd", ZstdCodec.class); + addType("jpeg", JpegCodec.class); addType("crc32c", Crc32cCodec.class); addType("sharding_indexed", ShardingIndexedCodec.class); } diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java b/src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java new file mode 100644 index 00000000..48c984d4 --- /dev/null +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java @@ -0,0 +1,253 @@ +package dev.zarr.zarrjava.v3.codec.core; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.zarr.zarrjava.ZarrException; +import dev.zarr.zarrjava.core.codec.ArrayBytesCodec; +import dev.zarr.zarrjava.utils.Utils; +import dev.zarr.zarrjava.v3.ArrayMetadata; +import dev.zarr.zarrjava.v3.DataType; +import dev.zarr.zarrjava.v3.codec.Codec; +import ucar.ma2.Array; + +import javax.annotation.Nullable; +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageInputStream; +import javax.imageio.stream.ImageOutputStream; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferByte; +import java.awt.image.Raster; +import java.awt.image.WritableRaster; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Iterator; + +/** + * Encodes a {@code uint8} chunk as a 1- or 3-channel baseline JPEG image, compatible with + * neuroglancer's {@code precomputed} "jpeg" chunk encoding. + * + *

The innermost (last) chunk axis is interpreted as the interleaved channel axis when its + * extent is 3 (RGB); otherwise the chunk is treated as single-channel (grayscale). The remaining + * axes, flattened in C-order (last-axis-fastest), form the pixel raster. This matches + * neuroglancer's requirement that the pixels read row-by-row equal the chunk flattened in + * {@code [x, y, z]} Fortran order with channels as interleaved image components, provided the + * chunk is laid out as C-order {@code [z, y, x, channel]} (grayscale = {@code [z, y, x]}). Chunks + * whose channel axis sits elsewhere can be reordered with a {@code transpose} codec placed before + * this codec in the pipeline. + * + *

JPEG is lossy, so this codec must not be used where exact values matter (e.g. segmentation). + */ +public class JpegCodec extends ArrayBytesCodec implements Codec { + + /** JPEG images may not exceed this size along either dimension. */ + private static final int MAX_JPEG_DIMENSION = 65535; + + @JsonIgnore + public final String name = "jpeg"; + @Nullable + public final Configuration configuration; + + @JsonCreator + public JpegCodec( + @JsonProperty(value = "configuration") Configuration configuration) { + this.configuration = configuration; + } + + public JpegCodec() { + this((Configuration) null); + } + + public JpegCodec(int quality) throws ZarrException { + this(new Configuration(quality)); + } + + private int quality() { + return configuration == null ? Configuration.DEFAULT_QUALITY : configuration.quality; + } + + private int numChannels() { + int[] shape = arrayMetadata.chunkShape; + int lastAxis = shape[shape.length - 1]; + return lastAxis == 3 ? 3 : 1; + } + + /** + * Factor {@code numPixels} into a {@code width x height} such that {@code width * height == + * numPixels} and both are within the JPEG dimension limit. The exact factorization is + * irrelevant for round-tripping (decode reads all pixels regardless of shape). + */ + private static int[] factorWidthHeight(int numPixels) throws ZarrException { + if (numPixels <= MAX_JPEG_DIMENSION) { + return new int[]{numPixels, 1}; + } + for (int height = 2; height <= numPixels; height++) { + if (numPixels % height == 0) { + int width = numPixels / height; + if (width <= MAX_JPEG_DIMENSION && height <= MAX_JPEG_DIMENSION) { + return new int[]{width, height}; + } + } + } + throw new ZarrException( + "Chunk with " + numPixels + " pixels cannot be represented as a JPEG image " + + "(no width x height factorization fits within " + MAX_JPEG_DIMENSION + ")."); + } + + @Override + public ByteBuffer encode(Array chunkArray) throws ZarrException { + if (arrayMetadata.dataType != DataType.UINT8) { + throw new ZarrException( + "The jpeg codec only supports the uint8 data type, got " + arrayMetadata.dataType + "."); + } + int numChannels = numChannels(); + int totalElements = (int) chunkArray.getSize(); + int numPixels = totalElements / numChannels; + int[] wh = factorWidthHeight(numPixels); + int width = wh[0]; + int height = wh[1]; + + ByteBuffer src = chunkArray.getDataAsByteBuffer(ByteOrder.BIG_ENDIAN); + byte[] data = new byte[totalElements]; + src.get(data); + + try { + if (numChannels == 1) { + return ByteBuffer.wrap(encodeGray(data, width, height)); + } + return ByteBuffer.wrap(encodeRgb(data, width, height)); + } catch (IOException ex) { + throw new ZarrException("Error in encoding jpeg.", ex); + } + } + + private byte[] encodeGray(byte[] data, int width, int height) throws IOException { + DataBufferByte dataBuffer = new DataBufferByte(data, data.length); + WritableRaster raster = Raster.createInterleavedRaster( + dataBuffer, width, height, width, 1, new int[]{0}, null); + return writeJpeg(new IIOImage(raster, null, null)); + } + + private byte[] encodeRgb(byte[] data, int width, int height) throws IOException { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + for (int pixel = 0; pixel < width * height; pixel++) { + int r = data[pixel * 3] & 0xff; + int g = data[pixel * 3 + 1] & 0xff; + int b = data[pixel * 3 + 2] & 0xff; + image.setRGB(pixel % width, pixel / width, (r << 16) | (g << 8) | b); + } + return writeJpeg(new IIOImage(image, null, null)); + } + + private byte[] writeJpeg(IIOImage image) throws IOException { + ImageWriter writer = getJpegWriter(); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream(outputStream)) { + writer.setOutput(imageOutputStream); + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(quality() / 100f); + writer.write(null, image, param); + imageOutputStream.flush(); + return outputStream.toByteArray(); + } finally { + writer.dispose(); + } + } + + @Override + public Array decode(ByteBuffer chunkBytes) throws ZarrException { + int numChannels = numChannels(); + try { + byte[] data = numChannels == 1 + ? decodeGray(Utils.toArray(chunkBytes)) + : decodeRgb(Utils.toArray(chunkBytes)); + return Array.factory( + DataType.UINT8.getMA2DataType(), arrayMetadata.chunkShape, ByteBuffer.wrap(data)); + } catch (IOException ex) { + throw new ZarrException("Error in decoding jpeg.", ex); + } + } + + private byte[] decodeGray(byte[] jpegBytes) throws IOException { + ImageReader reader = getJpegReader(); + try (ImageInputStream imageInputStream = ImageIO.createImageInputStream( + new ByteArrayInputStream(jpegBytes))) { + reader.setInput(imageInputStream); + // Read the raw raster to avoid any color-space conversion for single-channel data. + Raster raster = reader.readRaster(0, null); + int numPixels = raster.getWidth() * raster.getHeight(); + int[] samples = raster.getSamples(0, 0, raster.getWidth(), raster.getHeight(), 0, + new int[numPixels]); + byte[] data = new byte[numPixels]; + for (int i = 0; i < numPixels; i++) { + data[i] = (byte) samples[i]; + } + return data; + } finally { + reader.dispose(); + } + } + + private byte[] decodeRgb(byte[] jpegBytes) throws IOException { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(jpegBytes)); + if (image == null) { + throw new IOException("Could not decode jpeg image."); + } + int width = image.getWidth(); + int height = image.getHeight(); + byte[] data = new byte[width * height * 3]; + for (int pixel = 0; pixel < width * height; pixel++) { + int rgb = image.getRGB(pixel % width, pixel / width); + data[pixel * 3] = (byte) ((rgb >> 16) & 0xff); + data[pixel * 3 + 1] = (byte) ((rgb >> 8) & 0xff); + data[pixel * 3 + 2] = (byte) (rgb & 0xff); + } + return data; + } + + private static ImageWriter getJpegWriter() throws IOException { + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + if (!writers.hasNext()) { + throw new IOException("No jpeg ImageWriter available."); + } + return writers.next(); + } + + private static ImageReader getJpegReader() throws IOException { + Iterator readers = ImageIO.getImageReadersByFormatName("jpeg"); + if (!readers.hasNext()) { + throw new IOException("No jpeg ImageReader available."); + } + return readers.next(); + } + + @Override + public long computeEncodedSize(long inputByteLength, + ArrayMetadata.CoreArrayMetadata arrayMetadata) throws ZarrException { + throw new ZarrException("Not implemented for Jpeg codec."); + } + + public static final class Configuration { + + static final int DEFAULT_QUALITY = 90; + + public final int quality; + + @JsonCreator + public Configuration( + @JsonProperty(value = "quality", defaultValue = "90") int quality) throws ZarrException { + if (quality < 0 || quality > 100) { + throw new ZarrException("'quality' needs to be between 0 and 100."); + } + this.quality = quality; + } + } +} diff --git a/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java b/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java index 614a2b90..395a6f6d 100644 --- a/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java +++ b/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java @@ -224,6 +224,151 @@ public void testZstdCodecReadWrite(int level, boolean checksum) throws ZarrExcep Assertions.assertArrayEquals(testData, (int[]) result.get1DJavaArray(ucar.ma2.DataType.UINT)); } + /** Maximum absolute difference between two uint8 buffers (compared as unsigned). */ + private static int maxAbsDiff(byte[] expected, byte[] actual) { + Assertions.assertEquals(expected.length, actual.length); + int max = 0; + for (int i = 0; i < expected.length; i++) { + int diff = Math.abs((expected[i] & 0xff) - (actual[i] & 0xff)); + if (diff > max) { + max = diff; + } + } + return max; + } + + @ParameterizedTest + @CsvSource({"75", "90", "100"}) + public void testJpegCodecGrayscaleReadWrite(int quality) throws ZarrException, IOException { + int[] shape = {8, 16, 16}; + int n = shape[0] * shape[1] * shape[2]; + byte[] testData = new byte[n]; + // Smooth global ramp so the lossy JPEG stays close to the original. + for (int i = 0; i < n; i++) { + testData[i] = (byte) (255 * i / n); + } + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegGray", "q" + quality); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(8, 16, 16) + .withDataType(DataType.UINT8) + .withChunkShape(8, 16, 16) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(quality)); + Array writeArray = Array.create(storeHandle, builder.build()); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); + + Array readArray = Array.open(storeHandle); + ucar.ma2.Array result = readArray.read(); + + byte[] roundTripped = (byte[]) result.copyTo1DJavaArray(); + Assertions.assertTrue(maxAbsDiff(testData, roundTripped) <= 4, + "grayscale round-trip differs too much at quality " + quality); + } + + @ParameterizedTest + @CsvSource({"90", "100"}) + public void testJpegCodecRgbReadWrite(int quality) throws ZarrException, IOException { + int[] shape = {2, 16, 16, 3}; + int numPixels = shape[0] * shape[1] * shape[2]; + byte[] testData = new byte[numPixels * 3]; + // Smooth per-channel ramps. + for (int p = 0; p < numPixels; p++) { + testData[p * 3] = (byte) (255 * p / numPixels); + testData[p * 3 + 1] = (byte) (255 - 255 * p / numPixels); + testData[p * 3 + 2] = (byte) (128 * p / numPixels); + } + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRgb", "q" + quality); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(2, 16, 16, 3) + .withDataType(DataType.UINT8) + .withChunkShape(2, 16, 16, 3) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(quality)); + Array writeArray = Array.create(storeHandle, builder.build()); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); + + Array readArray = Array.open(storeHandle); + ucar.ma2.Array result = readArray.read(); + + byte[] roundTripped = (byte[]) result.copyTo1DJavaArray(); + // RGB goes through the standard YCbCr transform (and possibly chroma subsampling), so a + // looser tolerance than grayscale is expected. + Assertions.assertTrue(maxAbsDiff(testData, roundTripped) <= 24, + "rgb round-trip differs too much at quality " + quality); + } + + @Test + public void testJpegCodecLargeChunkReadWrite() throws ZarrException, IOException { + // More than 65535 pixels forces the width x height factorization. + int[] shape = {1, 300, 300}; + int n = shape[0] * shape[1] * shape[2]; + byte[] testData = new byte[n]; + for (int i = 0; i < n; i++) { + testData[i] = (byte) (255 * i / n); + } + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegLargeChunk"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(1, 300, 300) + .withDataType(DataType.UINT8) + .withChunkShape(1, 300, 300) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(100)); + Array writeArray = Array.create(storeHandle, builder.build()); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); + + Array readArray = Array.open(storeHandle); + ucar.ma2.Array result = readArray.read(); + + byte[] roundTripped = (byte[]) result.copyTo1DJavaArray(); + Assertions.assertTrue(maxAbsDiff(testData, roundTripped) <= 4); + } + + @Test + public void testJpegCodecRejectsNonUint8() throws ZarrException, IOException { + int[] testData = new int[8 * 8 * 8]; + Arrays.setAll(testData, p -> p); + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRejectsNonUint8"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(8, 8, 8) + .withDataType(DataType.UINT32) + .withChunkShape(8, 8, 8) + .withFillValue(0) + .withCodecs(c -> c.withJpeg()); + Array writeArray = Array.create(storeHandle, builder.build()); + // The parallel writer wraps the codec's ZarrException in a RuntimeException. + RuntimeException ex = assertThrows(RuntimeException.class, + () -> writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UINT, new int[]{8, 8, 8}, testData))); + Assertions.assertTrue(ex.getMessage().contains("uint8"), ex.getMessage()); + } + + @Test + public void testJpegCodecMetadataRoundTrip() throws ZarrException, IOException { + int[] shape = {4, 4, 4}; + byte[] testData = new byte[shape[0] * shape[1] * shape[2]]; + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegMetadata"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(4, 4, 4) + .withDataType(DataType.UINT8) + .withChunkShape(4, 4, 4) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(75)); + Array writeArray = Array.create(storeHandle, builder.build()); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); + + String zarrJson = new String(Files.readAllBytes( + Paths.get("testoutput", "testJpegMetadata", ZARR_JSON))); + Assertions.assertTrue(zarrJson.contains("\"name\" : \"jpeg\""), zarrJson); + Assertions.assertTrue(zarrJson.contains("\"quality\" : 75"), zarrJson); + + // Re-opening exercises deserialization of the jpeg codec from metadata. + Assertions.assertNotNull(Array.open(storeHandle).read()); + } + @Test public void testShardingWithZstdCodecReadWrite() throws ZarrException, IOException { int[] testData = new int[16 * 16 * 16]; From 5f0f5d383760ff91ef8150c66848c0c3011b590a Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 14 Jul 2026 13:24:52 +0200 Subject: [PATCH 2/2] adjusted jpeg implementation --- .../zarr/zarrjava/v3/codec/CodecBuilder.java | 14 + .../zarrjava/v3/codec/core/JpegCodec.java | 456 +++++++++++++++--- .../java/dev/zarr/zarrjava/ZarrV3Test.java | 235 +++++++-- 3 files changed, 611 insertions(+), 94 deletions(-) diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java index bd3d70dc..77453616 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java @@ -115,6 +115,20 @@ public CodecBuilder withJpeg(int quality) { return this; } + public CodecBuilder withJpeg(int quality, String encodedColorSpace) { + return withJpeg(quality, encodedColorSpace, null); + } + + public CodecBuilder withJpeg(int quality, String encodedColorSpace, int[][] subsampling) { + try { + codecs.add(new JpegCodec( + new JpegCodec.Configuration(quality, encodedColorSpace, subsampling))); + } catch (ZarrException e) { + throw new RuntimeException(e); + } + return this; + } + public CodecBuilder withJpeg() { codecs.add(new JpegCodec()); return this; diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java b/src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java index 48c984d4..24b1099a 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java @@ -9,47 +9,67 @@ import dev.zarr.zarrjava.v3.ArrayMetadata; import dev.zarr.zarrjava.v3.DataType; import dev.zarr.zarrjava.v3.codec.Codec; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; import ucar.ma2.Array; import javax.annotation.Nullable; import javax.imageio.IIOImage; import javax.imageio.ImageIO; import javax.imageio.ImageReader; +import javax.imageio.ImageTypeSpecifier; import javax.imageio.ImageWriteParam; import javax.imageio.ImageWriter; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataNode; import javax.imageio.stream.ImageInputStream; import javax.imageio.stream.ImageOutputStream; +import java.awt.color.ColorSpace; import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; import java.awt.image.DataBufferByte; import java.awt.image.Raster; import java.awt.image.WritableRaster; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.Arrays; import java.util.Iterator; /** - * Encodes a {@code uint8} chunk as a 1- or 3-channel baseline JPEG image, compatible with - * neuroglancer's {@code precomputed} "jpeg" chunk encoding. + * Encodes a {@code uint8} chunk as a baseline (sequential DCT, Huffman-coded) JPEG image, + * compatible with neuroglancer's {@code precomputed} "jpeg" chunk encoding. * - *

The innermost (last) chunk axis is interpreted as the interleaved channel axis when its - * extent is 3 (RGB); otherwise the chunk is treated as single-channel (grayscale). The remaining - * axes, flattened in C-order (last-axis-fastest), form the pixel raster. This matches - * neuroglancer's requirement that the pixels read row-by-row equal the chunk flattened in - * {@code [x, y, z]} Fortran order with channels as interleaved image components, provided the - * chunk is laid out as C-order {@code [z, y, x, channel]} (grayscale = {@code [z, y, x]}). Chunks - * whose channel axis sits elsewhere can be reordered with a {@code transpose} codec placed before - * this codec in the pipeline. + *

The component count is derived strictly from the chunk shape: a 2D shape {@code (H, W)} or a + * 3D shape {@code (H, W, 1)} is grayscale (1 component); a 3D shape {@code (H, W, 3)} is color + * (3 components). Any other shape is rejected — reshape or transpose the data with a preceding + * codec so the (single or triple) channel axis is innermost. The chunk is read in C order, so the + * trailing spatial axes give the image height {@code H} and width {@code W}, each limited to + * 65535 by the JPEG format. This codec never reshapes or resamples the data itself. + * + *

For 3-component data the {@code encoded_color_space} is required and selects how the samples + * are stored: {@code ycbcr} converts RGB to YCbCr (the JFIF color space, enabling chroma + * subsampling), while {@code rgb} stores the three components without conversion and writes an + * APP14 Adobe marker with an "unknown" transform so decoders do not apply an inverse YCbCr + * transform. The inverse transform on decode is determined by the stream's markers, not by the + * configuration. * *

JPEG is lossy, so this codec must not be used where exact values matter (e.g. segmentation). */ public class JpegCodec extends ArrayBytesCodec implements Codec { - /** JPEG images may not exceed this size along either dimension. */ + /** JPEG images may not exceed this size along either dimension (stored as a 16-bit value). */ private static final int MAX_JPEG_DIMENSION = 65535; + /** APP14 Adobe color transform code for "unknown" (i.e. no YCbCr transform). */ + private static final int ADOBE_TRANSFORM_UNKNOWN = 0; + /** APP14 Adobe color transform code for YCbCr. */ + private static final int ADOBE_TRANSFORM_YCBCR = 1; + @JsonIgnore public final String name = "jpeg"; @Nullable @@ -69,36 +89,92 @@ public JpegCodec(int quality) throws ZarrException { this(new Configuration(quality)); } - private int quality() { + public JpegCodec(int quality, @Nullable String encodedColorSpace, @Nullable String subsampling) + throws ZarrException { + this(new Configuration(quality, encodedColorSpace, subsampling)); + } + + private int getQualityInternal() { return configuration == null ? Configuration.DEFAULT_QUALITY : configuration.quality; } - private int numChannels() { - int[] shape = arrayMetadata.chunkShape; - int lastAxis = shape[shape.length - 1]; - return lastAxis == 3 ? 3 : 1; + @Nullable + private String getEncodedColorSpace() { + return configuration == null ? null : configuration.encodedColorSpace; + } + + @Nullable + private int[][] getSubsampling() { + return configuration == null ? null : configuration.subsampling; } /** - * Factor {@code numPixels} into a {@code width x height} such that {@code width * height == - * numPixels} and both are within the JPEG dimension limit. The exact factorization is - * irrelevant for round-tripping (decode reads all pixels regardless of shape). + * Derive the JPEG component count (1 or 3) from the chunk shape, rejecting any shape that JPEG + * cannot represent. {@code (H, W)} and {@code (H, W, 1)} are grayscale; {@code (H, W, 3)} is + * color; everything else (1D, an unsupported channel count, or 4D and higher) is an error. */ - private static int[] factorWidthHeight(int numPixels) throws ZarrException { - if (numPixels <= MAX_JPEG_DIMENSION) { - return new int[]{numPixels, 1}; - } - for (int height = 2; height <= numPixels; height++) { - if (numPixels % height == 0) { - int width = numPixels / height; - if (width <= MAX_JPEG_DIMENSION && height <= MAX_JPEG_DIMENSION) { - return new int[]{width, height}; - } + private int determineNumChannels() throws ZarrException { + int[] shape = arrayMetadata.chunkShape; + if (shape.length == 2) { + return 1; + } + if (shape.length == 3) { + if (shape[2] == 1) { + return 1; + } + if (shape[2] == 3) { + return 3; } } throw new ZarrException( - "Chunk with " + numPixels + " pixels cannot be represented as a JPEG image " - + "(no width x height factorization fits within " + MAX_JPEG_DIMENSION + ")."); + "The jpeg codec only supports chunk shapes (H, W), (H, W, 1) or (H, W, 3), got " + + Arrays.toString(shape) + + ". Reshape or transpose the data so the channel axis (of extent 1 or 3) is " + + "innermost before the jpeg codec."); + } + + /** + * Validate that the configured color-space parameters are consistent with the chunk's channel + * count, per the codec specification. This rejects invalid configurations rather than silently + * ignoring them. + */ + private void validateConfiguration(int numChannels) throws ZarrException { + String encodedColorSpace = getEncodedColorSpace(); + int[][] subsampling = getSubsampling(); + if (numChannels == 1) { + if (encodedColorSpace != null) { + throw new ZarrException( + "'encoded_color_space' must not be set for grayscale (1-component) data."); + } + if (subsampling != null) { + throw new ZarrException( + "'subsampling' must not be set for grayscale (1-component) data."); + } + } else { + if (encodedColorSpace == null) { + throw new ZarrException( + "'encoded_color_space' is required for 3-component data; set it to \"ycbcr\" " + + "(natural color images) or \"rgb\" (independent scientific channels)."); + } + if (subsampling != null && subsampling.length != numChannels) { + throw new ZarrException( + "'subsampling' must have one entry per component (" + numChannels + "), got " + + subsampling.length + "."); + } + } + } + + /** + * Resolve the luma component's sampling factors ({@code [horizontal, vertical]}) from the + * configured subsampling, which is expressed as per-component JPEG sampling factors (chroma is + * always {@code [1, 1]}). Defaults to {@code [2, 2]} (the 4:2:0 scheme) when unset. + */ + private int[] lumaSamplingFactors() { + int[][] subsampling = getSubsampling(); + if (subsampling == null) { + return Configuration.DEFAULT_LUMA_SAMPLING.clone(); + } + return new int[]{subsampling[0][0], subsampling[0][1]}; } @Override @@ -107,13 +183,19 @@ public ByteBuffer encode(Array chunkArray) throws ZarrException { throw new ZarrException( "The jpeg codec only supports the uint8 data type, got " + arrayMetadata.dataType + "."); } - int numChannels = numChannels(); - int totalElements = (int) chunkArray.getSize(); - int numPixels = totalElements / numChannels; - int[] wh = factorWidthHeight(numPixels); - int width = wh[0]; - int height = wh[1]; + int numChannels = determineNumChannels(); + validateConfiguration(numChannels); + int[] shape = arrayMetadata.chunkShape; + int height = shape[0]; + int width = shape[1]; + if (height > MAX_JPEG_DIMENSION || width > MAX_JPEG_DIMENSION) { + throw new ZarrException( + "JPEG image dimensions must not exceed " + MAX_JPEG_DIMENSION + ", got height=" + + height + ", width=" + width + "."); + } + + int totalElements = (int) chunkArray.getSize(); ByteBuffer src = chunkArray.getDataAsByteBuffer(ByteOrder.BIG_ENDIAN); byte[] data = new byte[totalElements]; src.get(data); @@ -122,20 +204,36 @@ public ByteBuffer encode(Array chunkArray) throws ZarrException { if (numChannels == 1) { return ByteBuffer.wrap(encodeGray(data, width, height)); } - return ByteBuffer.wrap(encodeRgb(data, width, height)); + if ("rgb".equals(getEncodedColorSpace())) { + return ByteBuffer.wrap(encodeRgbNoConversion(data, width, height)); + } + int[] luma = lumaSamplingFactors(); + return ByteBuffer.wrap(encodeYCbCr(data, width, height, luma[0], luma[1])); } catch (IOException ex) { throw new ZarrException("Error in encoding jpeg.", ex); } } + /** Encode single-component grayscale by writing the raw raster (no color transform). */ private byte[] encodeGray(byte[] data, int width, int height) throws IOException { DataBufferByte dataBuffer = new DataBufferByte(data, data.length); WritableRaster raster = Raster.createInterleavedRaster( dataBuffer, width, height, width, 1, new int[]{0}, null); - return writeJpeg(new IIOImage(raster, null, null)); + ImageWriter writer = getJpegWriter(); + try { + return writeJpeg(writer, new IIOImage(raster, null, null), jpegWriteParam(writer)); + } finally { + writer.dispose(); + } } - private byte[] encodeRgb(byte[] data, int width, int height) throws IOException { + /** + * Encode three components as YCbCr with the given luma sampling factors. The data is handed to + * the writer as an sRGB {@link BufferedImage} so that the writer performs the RGB to YCbCr + * conversion and writes a JFIF marker; the sampling factors are set on the stream metadata. + */ + private byte[] encodeYCbCr(byte[] data, int width, int height, int hSampling, int vSampling) + throws IOException { BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); for (int pixel = 0; pixel < width * height; pixel++) { int r = data[pixel * 3] & 0xff; @@ -143,32 +241,83 @@ private byte[] encodeRgb(byte[] data, int width, int height) throws IOException int b = data[pixel * 3 + 2] & 0xff; image.setRGB(pixel % width, pixel / width, (r << 16) | (g << 8) | b); } - return writeJpeg(new IIOImage(image, null, null)); + ImageWriter writer = getJpegWriter(); + try { + ImageWriteParam param = jpegWriteParam(writer); + IIOMetadata metadata = + writer.getDefaultImageMetadata(new ImageTypeSpecifier(image), param); + String format = metadata.getNativeMetadataFormatName(); + Node tree = metadata.getAsTree(format); + setLumaSamplingFactors(tree, hSampling, vSampling); + metadata.setFromTree(format, tree); + return writeJpeg(writer, new IIOImage(image, null, metadata), param); + } finally { + writer.dispose(); + } } - private byte[] writeJpeg(IIOImage image) throws IOException { + /** + * Encode three components without any color transform: the raw raster is written as-is and an + * APP14 Adobe marker with an "unknown" transform is added so that decoders do not apply an + * inverse YCbCr transform. Chroma subsampling is not applicable, so 4:4:4 (1x1) is used. + */ + private byte[] encodeRgbNoConversion(byte[] data, int width, int height) throws IOException { + DataBufferByte dataBuffer = new DataBufferByte(data, data.length); + WritableRaster raster = Raster.createInterleavedRaster( + dataBuffer, width, height, width * 3, 3, new int[]{0, 1, 2}, null); ImageWriter writer = getJpegWriter(); + try { + ImageWriteParam param = jpegWriteParam(writer); + ImageTypeSpecifier its = ImageTypeSpecifier.createInterleaved( + ColorSpace.getInstance(ColorSpace.CS_sRGB), new int[]{0, 1, 2}, DataBuffer.TYPE_BYTE, + false, false); + IIOMetadata metadata = writer.getDefaultImageMetadata(its, param); + String format = metadata.getNativeMetadataFormatName(); + Node tree = metadata.getAsTree(format); + setLumaSamplingFactors(tree, 1, 1); + setAdobeTransform(tree, ADOBE_TRANSFORM_UNKNOWN); + metadata.setFromTree(format, tree); + return writeJpeg(writer, new IIOImage(raster, null, metadata), param); + } finally { + writer.dispose(); + } + } + + private ImageWriteParam jpegWriteParam(ImageWriter writer) { + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(getQualityInternal() / 100f); + return param; + } + + private byte[] writeJpeg(ImageWriter writer, IIOImage image, ImageWriteParam param) + throws IOException { try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream(outputStream)) { writer.setOutput(imageOutputStream); - ImageWriteParam param = writer.getDefaultWriteParam(); - param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); - param.setCompressionQuality(quality() / 100f); writer.write(null, image, param); imageOutputStream.flush(); return outputStream.toByteArray(); - } finally { - writer.dispose(); } } @Override public Array decode(ByteBuffer chunkBytes) throws ZarrException { - int numChannels = numChannels(); + int numChannels = determineNumChannels(); + validateConfiguration(numChannels); try { - byte[] data = numChannels == 1 - ? decodeGray(Utils.toArray(chunkBytes)) - : decodeRgb(Utils.toArray(chunkBytes)); + byte[] jpegBytes = Utils.toArray(chunkBytes); + byte[] data; + if (numChannels == 1) { + data = decodeRaster(jpegBytes); + } else if (streamStoresYCbCr(jpegBytes)) { + // A YCbCr (JFIF) stream needs the inverse color transform, which the high-level + // reader applies based on the stream's markers. + data = decodeYCbCr(jpegBytes); + } else { + // An "rgb" (APP14 transform=0) stream stores the components directly; read them raw. + data = decodeRaster(jpegBytes); + } return Array.factory( DataType.UINT8.getMA2DataType(), arrayMetadata.chunkShape, ByteBuffer.wrap(data)); } catch (IOException ex) { @@ -176,19 +325,26 @@ public Array decode(ByteBuffer chunkBytes) throws ZarrException { } } - private byte[] decodeGray(byte[] jpegBytes) throws IOException { + /** Decode the raw stored samples (no color transform) as an interleaved byte array. */ + private byte[] decodeRaster(byte[] jpegBytes) throws IOException { ImageReader reader = getJpegReader(); try (ImageInputStream imageInputStream = ImageIO.createImageInputStream( new ByteArrayInputStream(jpegBytes))) { reader.setInput(imageInputStream); - // Read the raw raster to avoid any color-space conversion for single-channel data. Raster raster = reader.readRaster(0, null); - int numPixels = raster.getWidth() * raster.getHeight(); - int[] samples = raster.getSamples(0, 0, raster.getWidth(), raster.getHeight(), 0, - new int[numPixels]); - byte[] data = new byte[numPixels]; - for (int i = 0; i < numPixels; i++) { - data[i] = (byte) samples[i]; + int width = raster.getWidth(); + int height = raster.getHeight(); + int numBands = raster.getNumBands(); + byte[] data = new byte[width * height * numBands]; + int[] pixel = new int[numBands]; + int index = 0; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + raster.getPixel(x, y, pixel); + for (int b = 0; b < numBands; b++) { + data[index++] = (byte) pixel[b]; + } + } } return data; } finally { @@ -196,7 +352,8 @@ private byte[] decodeGray(byte[] jpegBytes) throws IOException { } } - private byte[] decodeRgb(byte[] jpegBytes) throws IOException { + /** Decode a YCbCr stream to interleaved RGB, letting the reader apply the inverse transform. */ + private byte[] decodeYCbCr(byte[] jpegBytes) throws IOException { BufferedImage image = ImageIO.read(new ByteArrayInputStream(jpegBytes)); if (image == null) { throw new IOException("Could not decode jpeg image."); @@ -213,6 +370,110 @@ private byte[] decodeRgb(byte[] jpegBytes) throws IOException { return data; } + /** + * Inspect the JPEG markers to determine whether a 3-component stream stores its samples as + * YCbCr (and therefore needs an inverse color transform on decode). Follows the usual + * libjpeg/Adobe conventions: an APP14 Adobe marker's transform code wins; otherwise a JFIF + * marker implies YCbCr; otherwise component ids of 'R','G','B' mean RGB and anything else + * defaults to YCbCr. + */ + private static boolean streamStoresYCbCr(byte[] jpegBytes) throws IOException { + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(jpegBytes))) { + if (in.readUnsignedShort() != 0xFFD8) { // SOI + return false; + } + boolean jfif = false; + Integer adobeTransform = null; + int numComponents = 0; + int[] componentIds = new int[4]; + while (in.available() > 0) { + int marker = in.readUnsignedShort(); + if (marker == 0xFFDA) { // SOS: start of scan, header is complete + break; + } + int length = in.readUnsignedShort(); + byte[] segment = new byte[length - 2]; + in.readFully(segment); + if (marker == 0xFFE0 && segment.length >= 4 + && segment[0] == 'J' && segment[1] == 'F' && segment[2] == 'I' && segment[3] == 'F') { + jfif = true; + } else if (marker == 0xFFEE && segment.length >= 12) { // APP14 Adobe + adobeTransform = segment[11] & 0xff; + } else if (marker >= 0xFFC0 && marker <= 0xFFC3) { // SOF0..SOF3 + numComponents = segment[5] & 0xff; + for (int c = 0; c < numComponents && c < componentIds.length; c++) { + componentIds[c] = segment[6 + c * 3] & 0xff; + } + } + } + if (numComponents != 3) { + return false; + } + if (adobeTransform != null) { + return adobeTransform == ADOBE_TRANSFORM_YCBCR; + } + if (jfif) { + return true; + } + boolean rgbIds = + componentIds[0] == 'R' && componentIds[1] == 'G' && componentIds[2] == 'B'; + return !rgbIds; + } + } + + /** Set the luma component sampling factors; chroma components are kept at 1x1. */ + private static void setLumaSamplingFactors(Node tree, int hSampling, int vSampling) { + Node sof = findNode(tree, "sof"); + if (sof == null) { + return; + } + NodeList children = sof.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (!"componentSpec".equals(child.getNodeName())) { + continue; + } + NamedNodeMap attrs = child.getAttributes(); + boolean luma = "1".equals(attrs.getNamedItem("componentId").getNodeValue()); + attrs.getNamedItem("HsamplingFactor").setNodeValue(String.valueOf(luma ? hSampling : 1)); + attrs.getNamedItem("VsamplingFactor").setNodeValue(String.valueOf(luma ? vSampling : 1)); + } + } + + /** Insert (or update) an APP14 Adobe marker with the given color transform code. */ + private static void setAdobeTransform(Node tree, int transform) { + Node existing = findNode(tree, "app14Adobe"); + if (existing != null) { + existing.getAttributes().getNamedItem("transform").setNodeValue(String.valueOf(transform)); + return; + } + Node markerSequence = findNode(tree, "markerSequence"); + if (markerSequence == null) { + return; + } + IIOMetadataNode adobe = new IIOMetadataNode("app14Adobe"); + adobe.setAttribute("version", "100"); + adobe.setAttribute("flags0", "0"); + adobe.setAttribute("flags1", "0"); + adobe.setAttribute("transform", String.valueOf(transform)); + markerSequence.insertBefore(adobe, markerSequence.getFirstChild()); + } + + @Nullable + private static Node findNode(Node node, String name) { + if (name.equals(node.getNodeName())) { + return node; + } + NodeList children = node.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node found = findNode(children.item(i), name); + if (found != null) { + return found; + } + } + return null; + } + private static ImageWriter getJpegWriter() throws IOException { Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); if (!writers.hasNext()) { @@ -238,16 +499,89 @@ public long computeEncodedSize(long inputByteLength, public static final class Configuration { static final int DEFAULT_QUALITY = 90; + /** Default luma sampling factors when subsampling is unset (the 4:2:0 scheme). */ + static final int[] DEFAULT_LUMA_SAMPLING = {2, 2}; public final int quality; + @Nullable + @JsonProperty("encoded_color_space") + public final String encodedColorSpace; + /** + * Per-component JPEG sampling factors, one {@code [horizontal, vertical]} pair per component + * (e.g. {@code [[2, 2], [1, 1], [1, 1]]} for the common 4:2:0 scheme), or {@code null} for the + * default. Chroma components must be {@code [1, 1]}. + */ + @Nullable + public final int[][] subsampling; + + public Configuration(int quality) throws ZarrException { + this(quality, null, null); + } @JsonCreator public Configuration( - @JsonProperty(value = "quality", defaultValue = "90") int quality) throws ZarrException { + @JsonProperty(value = "quality", defaultValue = "90") int quality, + @Nullable @JsonProperty("encoded_color_space") String encodedColorSpace, + @Nullable @JsonProperty("subsampling") int[][] subsampling) throws ZarrException { if (quality < 0 || quality > 100) { throw new ZarrException("'quality' needs to be between 0 and 100."); } + if (encodedColorSpace != null + && !"ycbcr".equals(encodedColorSpace) && !"rgb".equals(encodedColorSpace)) { + throw new ZarrException( + "'encoded_color_space' must be \"ycbcr\" or \"rgb\", got \"" + encodedColorSpace + "\"."); + } + if (subsampling != null) { + validateSubsampling(subsampling); + if ("rgb".equals(encodedColorSpace) && !isNoSubsampling(subsampling)) { + throw new ZarrException( + "'subsampling' must be [[1, 1], [1, 1], [1, 1]] (or omitted) with " + + "encoded_color_space \"rgb\", since those components are independent and " + + "must not be subsampled."); + } + } this.quality = quality; + this.encodedColorSpace = encodedColorSpace; + this.subsampling = subsampling; + } + + /** + * Validate the structural constraints on the sampling factors that do not depend on the + * channel count: each entry is a {@code [horizontal, vertical]} pair with factors in 1..4, + * the chroma components (all but the first) are {@code [1, 1]}, and each luma factor is at + * least the corresponding chroma factor. + */ + private static void validateSubsampling(int[][] subsampling) throws ZarrException { + for (int[] factors : subsampling) { + if (factors.length != 2) { + throw new ZarrException( + "each 'subsampling' entry must be a [horizontal, vertical] pair."); + } + for (int factor : factors) { + if (factor < 1 || factor > 4) { + throw new ZarrException("'subsampling' factors must be between 1 and 4."); + } + } + } + for (int i = 1; i < subsampling.length; i++) { + if (subsampling[i][0] != 1 || subsampling[i][1] != 1) { + throw new ZarrException("the chroma components' 'subsampling' factor must be [1, 1]."); + } + } + if (subsampling.length > 1 + && (subsampling[0][0] < 1 || subsampling[0][1] < 1)) { + throw new ZarrException( + "each luma 'subsampling' factor must be >= the corresponding chroma factor."); + } + } + + private static boolean isNoSubsampling(int[][] subsampling) { + for (int[] factors : subsampling) { + if (factors.length != 2 || factors[0] != 1 || factors[1] != 1) { + return false; + } + } + return true; } } } diff --git a/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java b/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java index 395a6f6d..425f3a10 100644 --- a/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java +++ b/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java @@ -14,6 +14,7 @@ import dev.zarr.zarrjava.v3.codec.CodecBuilder; import dev.zarr.zarrjava.v3.codec.core.BloscCodec; import dev.zarr.zarrjava.v3.codec.core.BytesCodec; +import dev.zarr.zarrjava.v3.codec.core.JpegCodec; import dev.zarr.zarrjava.v3.codec.core.ShardingIndexedCodec; import dev.zarr.zarrjava.v3.codec.core.TransposeCodec; import org.junit.jupiter.api.Assertions; @@ -240,8 +241,9 @@ private static int maxAbsDiff(byte[] expected, byte[] actual) { @ParameterizedTest @CsvSource({"75", "90", "100"}) public void testJpegCodecGrayscaleReadWrite(int quality) throws ZarrException, IOException { - int[] shape = {8, 16, 16}; - int n = shape[0] * shape[1] * shape[2]; + // A 2D (H, W) chunk is grayscale. + int[] shape = {16, 16}; + int n = shape[0] * shape[1]; byte[] testData = new byte[n]; // Smooth global ramp so the lossy JPEG stays close to the original. for (int i = 0; i < n; i++) { @@ -250,9 +252,9 @@ public void testJpegCodecGrayscaleReadWrite(int quality) throws ZarrException, I StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegGray", "q" + quality); ArrayMetadataBuilder builder = Array.metadataBuilder() - .withShape(8, 16, 16) + .withShape(16, 16) .withDataType(DataType.UINT8) - .withChunkShape(8, 16, 16) + .withChunkShape(16, 16) .withFillValue(0) .withCodecs(c -> c.withJpeg(quality)); Array writeArray = Array.create(storeHandle, builder.build()); @@ -266,44 +268,94 @@ public void testJpegCodecGrayscaleReadWrite(int quality) throws ZarrException, I "grayscale round-trip differs too much at quality " + quality); } - @ParameterizedTest - @CsvSource({"90", "100"}) - public void testJpegCodecRgbReadWrite(int quality) throws ZarrException, IOException { - int[] shape = {2, 16, 16, 3}; - int numPixels = shape[0] * shape[1] * shape[2]; + @Test + public void testJpegCodecGrayscaleWithChannelAxisReadWrite() throws ZarrException, IOException { + // A 3D (H, W, 1) chunk is grayscale too (so data can be sharded over a size-1 channel axis). + int[] shape = {16, 16, 1}; + int n = shape[0] * shape[1] * shape[2]; + byte[] testData = new byte[n]; + for (int i = 0; i < n; i++) { + testData[i] = (byte) (255 * i / n); + } + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegGrayChannelAxis"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(16, 16, 1) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16, 1) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(100)); + Array writeArray = Array.create(storeHandle, builder.build()); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); + + ucar.ma2.Array result = Array.open(storeHandle).read(); + byte[] roundTripped = (byte[]) result.copyTo1DJavaArray(); + Assertions.assertTrue(maxAbsDiff(testData, roundTripped) <= 4); + } + + private static byte[] rgbRampData(int numPixels) { byte[] testData = new byte[numPixels * 3]; - // Smooth per-channel ramps. + // Smooth per-channel ramps so the lossy JPEG stays close to the original. for (int p = 0; p < numPixels; p++) { testData[p * 3] = (byte) (255 * p / numPixels); testData[p * 3 + 1] = (byte) (255 - 255 * p / numPixels); testData[p * 3 + 2] = (byte) (128 * p / numPixels); } + return testData; + } + + @ParameterizedTest + @CsvSource({"90", "100"}) + public void testJpegCodecYCbCrReadWrite(int quality) throws ZarrException, IOException { + int[] shape = {16, 16, 3}; + byte[] testData = rgbRampData(shape[0] * shape[1]); - StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRgb", "q" + quality); + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegYCbCr", "q" + quality); ArrayMetadataBuilder builder = Array.metadataBuilder() - .withShape(2, 16, 16, 3) + .withShape(16, 16, 3) .withDataType(DataType.UINT8) - .withChunkShape(2, 16, 16, 3) + .withChunkShape(16, 16, 3) .withFillValue(0) - .withCodecs(c -> c.withJpeg(quality)); + .withCodecs(c -> c.withJpeg(quality, "ycbcr")); Array writeArray = Array.create(storeHandle, builder.build()); writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); - Array readArray = Array.open(storeHandle); - ucar.ma2.Array result = readArray.read(); - + ucar.ma2.Array result = Array.open(storeHandle).read(); byte[] roundTripped = (byte[]) result.copyTo1DJavaArray(); - // RGB goes through the standard YCbCr transform (and possibly chroma subsampling), so a + // RGB goes through the YCbCr transform and (by default) 4:2:0 chroma subsampling, so a // looser tolerance than grayscale is expected. Assertions.assertTrue(maxAbsDiff(testData, roundTripped) <= 24, - "rgb round-trip differs too much at quality " + quality); + "ycbcr round-trip differs too much at quality " + quality); + } + + @Test + public void testJpegCodecRgbColorSpaceReadWrite() throws ZarrException, IOException { + // encoded_color_space "rgb" stores the three components without any color transform, so at + // quality 100 the round-trip is near-lossless (only DCT quantization loss). + int[] shape = {16, 16, 3}; + byte[] testData = rgbRampData(shape[0] * shape[1]); + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRgbColorSpace"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(16, 16, 3) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16, 3) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(100, "rgb")); + Array writeArray = Array.create(storeHandle, builder.build()); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); + + ucar.ma2.Array result = Array.open(storeHandle).read(); + byte[] roundTripped = (byte[]) result.copyTo1DJavaArray(); + Assertions.assertTrue(maxAbsDiff(testData, roundTripped) <= 4, + "rgb (no color transform) round-trip differs too much"); } @Test public void testJpegCodecLargeChunkReadWrite() throws ZarrException, IOException { - // More than 65535 pixels forces the width x height factorization. - int[] shape = {1, 300, 300}; - int n = shape[0] * shape[1] * shape[2]; + // A chunk with more than 65535 pixels is stored as-is (no width x height factorization). + int[] shape = {300, 300}; + int n = shape[0] * shape[1]; byte[] testData = new byte[n]; for (int i = 0; i < n; i++) { testData[i] = (byte) (255 * i / n); @@ -311,9 +363,9 @@ public void testJpegCodecLargeChunkReadWrite() throws ZarrException, IOException StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegLargeChunk"); ArrayMetadataBuilder builder = Array.metadataBuilder() - .withShape(1, 300, 300) + .withShape(300, 300) .withDataType(DataType.UINT8) - .withChunkShape(1, 300, 300) + .withChunkShape(300, 300) .withFillValue(0) .withCodecs(c -> c.withJpeg(100)); Array writeArray = Array.create(storeHandle, builder.build()); @@ -328,35 +380,149 @@ public void testJpegCodecLargeChunkReadWrite() throws ZarrException, IOException @Test public void testJpegCodecRejectsNonUint8() throws ZarrException, IOException { - int[] testData = new int[8 * 8 * 8]; + int[] testData = new int[16 * 16]; Arrays.setAll(testData, p -> p); StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRejectsNonUint8"); ArrayMetadataBuilder builder = Array.metadataBuilder() - .withShape(8, 8, 8) + .withShape(16, 16) .withDataType(DataType.UINT32) - .withChunkShape(8, 8, 8) + .withChunkShape(16, 16) .withFillValue(0) .withCodecs(c -> c.withJpeg()); Array writeArray = Array.create(storeHandle, builder.build()); // The parallel writer wraps the codec's ZarrException in a RuntimeException. RuntimeException ex = assertThrows(RuntimeException.class, - () -> writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UINT, new int[]{8, 8, 8}, testData))); + () -> writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UINT, new int[]{16, 16}, testData))); Assertions.assertTrue(ex.getMessage().contains("uint8"), ex.getMessage()); } + @Test + public void testJpegCodecRejectsUnsupportedShape() throws ZarrException, IOException { + // (H, W, 2) has neither 1 nor 3 in the channel axis and must be rejected. + byte[] testData = new byte[16 * 16 * 2]; + Arrays.fill(testData, (byte) 1); // non-fill data so the chunk is actually encoded + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRejectsShape"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(16, 16, 2) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16, 2) + .withFillValue(0) + .withCodecs(c -> c.withJpeg()); + Array writeArray = Array.create(storeHandle, builder.build()); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, new int[]{16, 16, 2}, testData))); + Assertions.assertTrue(ex.getMessage().contains("chunk shapes"), ex.getMessage()); + } + + @Test + public void testJpegCodecRejectsColorWithoutColorSpace() throws ZarrException, IOException { + // 3-component data requires encoded_color_space. + byte[] testData = new byte[16 * 16 * 3]; + Arrays.fill(testData, (byte) 1); // non-fill data so the chunk is actually encoded + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRejectsNoColorSpace"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(16, 16, 3) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16, 3) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(90)); + Array writeArray = Array.create(storeHandle, builder.build()); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, new int[]{16, 16, 3}, testData))); + Assertions.assertTrue(ex.getMessage().contains("encoded_color_space"), ex.getMessage()); + } + + @Test + public void testJpegCodecRejectsColorSpaceForGrayscale() throws ZarrException, IOException { + // encoded_color_space must not be set for grayscale data. + byte[] testData = new byte[16 * 16]; + Arrays.fill(testData, (byte) 1); // non-fill data so the chunk is actually encoded + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRejectsGrayColorSpace"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(16, 16) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(90, "ycbcr")); + Array writeArray = Array.create(storeHandle, builder.build()); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, new int[]{16, 16}, testData))); + Assertions.assertTrue(ex.getMessage().contains("encoded_color_space"), ex.getMessage()); + } + + @Test + public void testJpegCodecYCbCr440ReadWrite() throws ZarrException, IOException { + // The 4:4:0 scheme ([[1, 2], [1, 1], [1, 1]]) subsamples chroma vertically only. + int[] shape = {16, 16, 3}; + byte[] testData = rgbRampData(shape[0] * shape[1]); + + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegYCbCr440"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(16, 16, 3) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16, 3) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(100, "ycbcr", new int[][]{{1, 2}, {1, 1}, {1, 1}})); + Array writeArray = Array.create(storeHandle, builder.build()); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); + + ucar.ma2.Array result = Array.open(storeHandle).read(); + byte[] roundTripped = (byte[]) result.copyTo1DJavaArray(); + Assertions.assertTrue(maxAbsDiff(testData, roundTripped) <= 24); + } + + @Test + public void testJpegCodecRejectsRgbWithSubsampling() { + // subsampling other than [[1,1],[1,1],[1,1]] is invalid with encoded_color_space "rgb". + ZarrException ex = assertThrows(ZarrException.class, + () -> new JpegCodec.Configuration(90, "rgb", new int[][]{{2, 2}, {1, 1}, {1, 1}})); + Assertions.assertTrue(ex.getMessage().contains("rgb"), ex.getMessage()); + } + + @Test + public void testJpegCodecRejectsSubsampledChroma() { + // The chroma components must have sampling factor [1, 1]. + ZarrException ex = assertThrows(ZarrException.class, + () -> new JpegCodec.Configuration(90, "ycbcr", new int[][]{{2, 2}, {2, 2}, {1, 1}})); + Assertions.assertTrue(ex.getMessage().contains("chroma"), ex.getMessage()); + } + + @Test + public void testJpegCodecRejectsSubsamplingLengthMismatch() throws ZarrException, IOException { + // A subsampling array whose length does not match the component count is rejected. + byte[] testData = new byte[16 * 16 * 3]; + Arrays.fill(testData, (byte) 1); + + StoreHandle storeHandle = + new FilesystemStore(TESTOUTPUT).resolve("testJpegRejectsSubsamplingLength"); + ArrayMetadataBuilder builder = Array.metadataBuilder() + .withShape(16, 16, 3) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16, 3) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(90, "ycbcr", new int[][]{{2, 2}, {1, 1}})); + Array writeArray = Array.create(storeHandle, builder.build()); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, new int[]{16, 16, 3}, testData))); + Assertions.assertTrue(ex.getMessage().contains("one entry per component"), ex.getMessage()); + } + @Test public void testJpegCodecMetadataRoundTrip() throws ZarrException, IOException { - int[] shape = {4, 4, 4}; - byte[] testData = new byte[shape[0] * shape[1] * shape[2]]; + int[] shape = {16, 16, 3}; + byte[] testData = rgbRampData(shape[0] * shape[1]); StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegMetadata"); ArrayMetadataBuilder builder = Array.metadataBuilder() - .withShape(4, 4, 4) + .withShape(16, 16, 3) .withDataType(DataType.UINT8) - .withChunkShape(4, 4, 4) + .withChunkShape(16, 16, 3) .withFillValue(0) - .withCodecs(c -> c.withJpeg(75)); + .withCodecs(c -> c.withJpeg(75, "ycbcr", new int[][]{{2, 1}, {1, 1}, {1, 1}})); Array writeArray = Array.create(storeHandle, builder.build()); writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.UBYTE, shape, testData)); @@ -364,6 +530,9 @@ public void testJpegCodecMetadataRoundTrip() throws ZarrException, IOException { Paths.get("testoutput", "testJpegMetadata", ZARR_JSON))); Assertions.assertTrue(zarrJson.contains("\"name\" : \"jpeg\""), zarrJson); Assertions.assertTrue(zarrJson.contains("\"quality\" : 75"), zarrJson); + Assertions.assertTrue(zarrJson.contains("\"encoded_color_space\" : \"ycbcr\""), zarrJson); + Assertions.assertTrue(zarrJson.replaceAll("\\s+", "").contains("\"subsampling\":[[2,1],[1,1],[1,1]]"), + zarrJson); // Re-opening exercises deserialization of the jpeg codec from metadata. Assertions.assertNotNull(Array.open(storeHandle).read());