From ffa6132a5443ec2399ef606c90b45e2395e68dfe Mon Sep 17 00:00:00 2001 From: Konstantin Date: Fri, 3 Jul 2026 16:43:58 +0200 Subject: [PATCH 1/4] 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/4] 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()); From 719d91395c357cf70b2fbd1e337e09ab6507e871 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Thu, 24 Sep 2026 13:51:52 +0200 Subject: [PATCH 3/4] fix JpegCodec constructor subsampling type Co-Authored-By: Claude Opus 5.5 --- src/main/java/dev/zarr/zarrjava/v3/codec/core/JpegCodec.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 24b1099a..1ced4f84 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 @@ -89,7 +89,7 @@ public JpegCodec(int quality) throws ZarrException { this(new Configuration(quality)); } - public JpegCodec(int quality, @Nullable String encodedColorSpace, @Nullable String subsampling) + public JpegCodec(int quality, @Nullable String encodedColorSpace, @Nullable int[][] subsampling) throws ZarrException { this(new Configuration(quality, encodedColorSpace, subsampling)); } From 7062cacd329eac0ab021ad9ffdc639a5c27a32f7 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Thu, 24 Sep 2026 14:03:07 +0200 Subject: [PATCH 4/4] make jpeg codec configuration match the spec quality, subsampling and the configuration itself are now required instead of being silently defaulted (a missing quality used to become 0). The written zarr.json always contains every required parameter, and grayscale data accepts the spec-mandated subsampling [[1, 1]]. Co-Authored-By: Claude Opus 5.5 --- .../zarr/zarrjava/v3/codec/CodecBuilder.java | 15 ++++- .../zarrjava/v3/codec/core/JpegCodec.java | 64 +++++++++---------- .../java/dev/zarr/zarrjava/ZarrV3Test.java | 62 ++++++++++++++++++ 3 files changed, 107 insertions(+), 34 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 77453616..85ec57b6 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java @@ -115,8 +115,15 @@ public CodecBuilder withJpeg(int quality) { return this; } + /** + * Adds a 3-component jpeg codec with the conventional subsampling for the color space: 4:2:0 + * for {@code ycbcr}, none for {@code rgb}. + */ public CodecBuilder withJpeg(int quality, String encodedColorSpace) { - return withJpeg(quality, encodedColorSpace, null); + int[][] subsampling = "rgb".equals(encodedColorSpace) + ? new int[][]{{1, 1}, {1, 1}, {1, 1}} + : new int[][]{{2, 2}, {1, 1}, {1, 1}}; + return withJpeg(quality, encodedColorSpace, subsampling); } public CodecBuilder withJpeg(int quality, String encodedColorSpace, int[][] subsampling) { @@ -130,7 +137,11 @@ public CodecBuilder withJpeg(int quality, String encodedColorSpace, int[][] subs } public CodecBuilder withJpeg() { - codecs.add(new JpegCodec()); + try { + codecs.add(new JpegCodec()); + } catch (ZarrException e) { + throw new RuntimeException(e); + } 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 1ced4f84..480a1411 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 @@ -72,17 +72,19 @@ public class JpegCodec extends ArrayBytesCodec implements Codec { @JsonIgnore public final String name = "jpeg"; - @Nullable public final Configuration configuration; @JsonCreator public JpegCodec( - @JsonProperty(value = "configuration") Configuration configuration) { + @JsonProperty(value = "configuration") Configuration configuration) throws ZarrException { + if (configuration == null) { + throw new ZarrException("The jpeg codec requires a 'configuration'."); + } this.configuration = configuration; } - public JpegCodec() { - this((Configuration) null); + public JpegCodec() throws ZarrException { + this(Configuration.DEFAULT_QUALITY); } public JpegCodec(int quality) throws ZarrException { @@ -95,17 +97,16 @@ public JpegCodec(int quality, @Nullable String encodedColorSpace, @Nullable int[ } private int getQualityInternal() { - return configuration == null ? Configuration.DEFAULT_QUALITY : configuration.quality; + return configuration.quality; } @Nullable private String getEncodedColorSpace() { - return configuration == null ? null : configuration.encodedColorSpace; + return configuration.encodedColorSpace; } - @Nullable private int[][] getSubsampling() { - return configuration == null ? null : configuration.subsampling; + return configuration.subsampling; } /** @@ -146,9 +147,9 @@ private void validateConfiguration(int numChannels) throws ZarrException { throw new ZarrException( "'encoded_color_space' must not be set for grayscale (1-component) data."); } - if (subsampling != null) { + if (subsampling.length != 1 || !Configuration.isNoSubsampling(subsampling)) { throw new ZarrException( - "'subsampling' must not be set for grayscale (1-component) data."); + "'subsampling' must be [[1, 1]] for grayscale (1-component) data."); } } else { if (encodedColorSpace == null) { @@ -156,7 +157,7 @@ private void validateConfiguration(int numChannels) throws 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) { + if (subsampling.length != numChannels) { throw new ZarrException( "'subsampling' must have one entry per component (" + numChannels + "), got " + subsampling.length + "."); @@ -167,13 +168,10 @@ private void validateConfiguration(int numChannels) throws ZarrException { /** * 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. + * always {@code [1, 1]}). */ private int[] lumaSamplingFactors() { int[][] subsampling = getSubsampling(); - if (subsampling == null) { - return Configuration.DEFAULT_LUMA_SAMPLING.clone(); - } return new int[]{subsampling[0][0], subsampling[0][1]}; } @@ -499,8 +497,6 @@ 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 @@ -508,21 +504,27 @@ public static final class Configuration { 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]}. + * (e.g. {@code [[2, 2], [1, 1], [1, 1]]} for the common 4:2:0 scheme, or {@code [[1, 1]]} + * for grayscale). Chroma components must be {@code [1, 1]}. */ - @Nullable public final int[][] subsampling; + /** A grayscale (1-component) configuration with the given quality. */ public Configuration(int quality) throws ZarrException { - this(quality, null, null); + this(quality, null, new int[][]{{1, 1}}); } @JsonCreator public Configuration( - @JsonProperty(value = "quality", defaultValue = "90") int quality, + @JsonProperty("quality") Integer quality, @Nullable @JsonProperty("encoded_color_space") String encodedColorSpace, - @Nullable @JsonProperty("subsampling") int[][] subsampling) throws ZarrException { + @JsonProperty("subsampling") int[][] subsampling) throws ZarrException { + if (quality == null) { + throw new ZarrException("'quality' is required for the jpeg codec."); + } + if (subsampling == null) { + throw new ZarrException("'subsampling' is required for the jpeg codec."); + } if (quality < 0 || quality > 100) { throw new ZarrException("'quality' needs to be between 0 and 100."); } @@ -531,14 +533,12 @@ public Configuration( 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."); - } + validateSubsampling(subsampling); + if ("rgb".equals(encodedColorSpace) && !isNoSubsampling(subsampling)) { + throw new ZarrException( + "'subsampling' must be [[1, 1], [1, 1], [1, 1]] with encoded_color_space " + + "\"rgb\", since those components are independent and must not be " + + "subsampled."); } this.quality = quality; this.encodedColorSpace = encodedColorSpace; @@ -575,7 +575,7 @@ private static void validateSubsampling(int[][] subsampling) throws ZarrExceptio } } - private static boolean isNoSubsampling(int[][] subsampling) { + static boolean isNoSubsampling(int[][] subsampling) { for (int[] factors : subsampling) { if (factors.length != 2 || factors[0] != 1 || factors[1] != 1) { return false; diff --git a/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java b/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java index 425f3a10..d3240bde 100644 --- a/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java +++ b/src/test/java/dev/zarr/zarrjava/ZarrV3Test.java @@ -538,6 +538,68 @@ public void testJpegCodecMetadataRoundTrip() throws ZarrException, IOException { Assertions.assertNotNull(Array.open(storeHandle).read()); } + @Test + public void testJpegCodecDefaultWritesFullConfiguration() throws ZarrException, IOException { + // All required parameters are written explicitly, even for the default grayscale codec. + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegDefaultMetadata"); + Array.create(storeHandle, Array.metadataBuilder() + .withShape(16, 16) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16) + .withFillValue(0) + .withCodecs(c -> c.withJpeg()) + .build()); + + String zarrJson = new String(Files.readAllBytes( + Paths.get("testoutput", "testJpegDefaultMetadata", ZARR_JSON))).replaceAll("\\s+", ""); + Assertions.assertTrue(zarrJson.contains("\"quality\":90"), zarrJson); + Assertions.assertTrue(zarrJson.contains("\"subsampling\":[[1,1]]"), zarrJson); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "\"quality\":90,|''", + "\"subsampling\":[[1,1]]|''", + "\"configuration\":{\"quality\":90,\"subsampling\":[[1,1]]}|\"configuration\":null"}) + public void testJpegCodecRejectsMissingRequiredParameter(String present, String replacement) + throws ZarrException, IOException { + // quality, subsampling and the configuration itself are required and must not be defaulted. + String name = "testJpegRejectsMissing" + Math.abs(present.hashCode()); + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve(name); + Array.create(storeHandle, Array.metadataBuilder() + .withShape(16, 16) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(90)) + .build()); + + Path zarrJsonPath = Paths.get("testoutput", name, ZARR_JSON); + String zarrJson = new String(Files.readAllBytes(zarrJsonPath)).replaceAll("\\s+", ""); + Assertions.assertTrue(zarrJson.contains(present), zarrJson); + Files.write(zarrJsonPath, zarrJson.replace(present, replacement).getBytes()); + + assertThrows(Exception.class, () -> Array.open(storeHandle)); + } + + @Test + public void testJpegCodecRejectsSubsampledGrayscale() throws ZarrException, IOException { + // Grayscale data must use subsampling [[1, 1]]. + byte[] testData = new byte[16 * 16]; + Arrays.fill(testData, (byte) 1); + StoreHandle storeHandle = new FilesystemStore(TESTOUTPUT).resolve("testJpegRejectsGraySubsampling"); + Array writeArray = Array.create(storeHandle, Array.metadataBuilder() + .withShape(16, 16) + .withDataType(DataType.UINT8) + .withChunkShape(16, 16) + .withFillValue(0) + .withCodecs(c -> c.withJpeg(90, null, new int[][]{{2, 2}})) + .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("[[1, 1]]"), ex.getMessage()); + } + @Test public void testShardingWithZstdCodecReadWrite() throws ZarrException, IOException { int[] testData = new int[16 * 16 * 16];