Skip to content

Commit ffa6132

Browse files
committed
add jpeg codec and testcases
1 parent d5552e7 commit ffa6132

4 files changed

Lines changed: 413 additions & 0 deletions

File tree

src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ public CodecBuilder withZstd() {
106106
return withZstd(5, true);
107107
}
108108

109+
public CodecBuilder withJpeg(int quality) {
110+
try {
111+
codecs.add(new JpegCodec(new JpegCodec.Configuration(quality)));
112+
} catch (ZarrException e) {
113+
throw new RuntimeException(e);
114+
}
115+
return this;
116+
}
117+
118+
public CodecBuilder withJpeg() {
119+
codecs.add(new JpegCodec());
120+
return this;
121+
}
122+
109123
public CodecBuilder withZstd(int level) {
110124
return withZstd(level, true);
111125
}

src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public class CodecRegistry {
1616
addType("blosc", BloscCodec.class);
1717
addType("gzip", GzipCodec.class);
1818
addType("zstd", ZstdCodec.class);
19+
addType("jpeg", JpegCodec.class);
1920
addType("crc32c", Crc32cCodec.class);
2021
addType("sharding_indexed", ShardingIndexedCodec.class);
2122
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package dev.zarr.zarrjava.v3.codec.core;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonIgnore;
5+
import com.fasterxml.jackson.annotation.JsonProperty;
6+
import dev.zarr.zarrjava.ZarrException;
7+
import dev.zarr.zarrjava.core.codec.ArrayBytesCodec;
8+
import dev.zarr.zarrjava.utils.Utils;
9+
import dev.zarr.zarrjava.v3.ArrayMetadata;
10+
import dev.zarr.zarrjava.v3.DataType;
11+
import dev.zarr.zarrjava.v3.codec.Codec;
12+
import ucar.ma2.Array;
13+
14+
import javax.annotation.Nullable;
15+
import javax.imageio.IIOImage;
16+
import javax.imageio.ImageIO;
17+
import javax.imageio.ImageReader;
18+
import javax.imageio.ImageWriteParam;
19+
import javax.imageio.ImageWriter;
20+
import javax.imageio.stream.ImageInputStream;
21+
import javax.imageio.stream.ImageOutputStream;
22+
import java.awt.image.BufferedImage;
23+
import java.awt.image.DataBufferByte;
24+
import java.awt.image.Raster;
25+
import java.awt.image.WritableRaster;
26+
import java.io.ByteArrayInputStream;
27+
import java.io.ByteArrayOutputStream;
28+
import java.io.IOException;
29+
import java.nio.ByteBuffer;
30+
import java.nio.ByteOrder;
31+
import java.util.Iterator;
32+
33+
/**
34+
* Encodes a {@code uint8} chunk as a 1- or 3-channel baseline JPEG image, compatible with
35+
* neuroglancer's {@code precomputed} "jpeg" chunk encoding.
36+
*
37+
* <p>The innermost (last) chunk axis is interpreted as the interleaved channel axis when its
38+
* extent is 3 (RGB); otherwise the chunk is treated as single-channel (grayscale). The remaining
39+
* axes, flattened in C-order (last-axis-fastest), form the pixel raster. This matches
40+
* neuroglancer's requirement that the pixels read row-by-row equal the chunk flattened in
41+
* {@code [x, y, z]} Fortran order with channels as interleaved image components, provided the
42+
* chunk is laid out as C-order {@code [z, y, x, channel]} (grayscale = {@code [z, y, x]}). Chunks
43+
* whose channel axis sits elsewhere can be reordered with a {@code transpose} codec placed before
44+
* this codec in the pipeline.
45+
*
46+
* <p>JPEG is lossy, so this codec must not be used where exact values matter (e.g. segmentation).
47+
*/
48+
public class JpegCodec extends ArrayBytesCodec implements Codec {
49+
50+
/** JPEG images may not exceed this size along either dimension. */
51+
private static final int MAX_JPEG_DIMENSION = 65535;
52+
53+
@JsonIgnore
54+
public final String name = "jpeg";
55+
@Nullable
56+
public final Configuration configuration;
57+
58+
@JsonCreator
59+
public JpegCodec(
60+
@JsonProperty(value = "configuration") Configuration configuration) {
61+
this.configuration = configuration;
62+
}
63+
64+
public JpegCodec() {
65+
this((Configuration) null);
66+
}
67+
68+
public JpegCodec(int quality) throws ZarrException {
69+
this(new Configuration(quality));
70+
}
71+
72+
private int quality() {
73+
return configuration == null ? Configuration.DEFAULT_QUALITY : configuration.quality;
74+
}
75+
76+
private int numChannels() {
77+
int[] shape = arrayMetadata.chunkShape;
78+
int lastAxis = shape[shape.length - 1];
79+
return lastAxis == 3 ? 3 : 1;
80+
}
81+
82+
/**
83+
* Factor {@code numPixels} into a {@code width x height} such that {@code width * height ==
84+
* numPixels} and both are within the JPEG dimension limit. The exact factorization is
85+
* irrelevant for round-tripping (decode reads all pixels regardless of shape).
86+
*/
87+
private static int[] factorWidthHeight(int numPixels) throws ZarrException {
88+
if (numPixels <= MAX_JPEG_DIMENSION) {
89+
return new int[]{numPixels, 1};
90+
}
91+
for (int height = 2; height <= numPixels; height++) {
92+
if (numPixels % height == 0) {
93+
int width = numPixels / height;
94+
if (width <= MAX_JPEG_DIMENSION && height <= MAX_JPEG_DIMENSION) {
95+
return new int[]{width, height};
96+
}
97+
}
98+
}
99+
throw new ZarrException(
100+
"Chunk with " + numPixels + " pixels cannot be represented as a JPEG image "
101+
+ "(no width x height factorization fits within " + MAX_JPEG_DIMENSION + ").");
102+
}
103+
104+
@Override
105+
public ByteBuffer encode(Array chunkArray) throws ZarrException {
106+
if (arrayMetadata.dataType != DataType.UINT8) {
107+
throw new ZarrException(
108+
"The jpeg codec only supports the uint8 data type, got " + arrayMetadata.dataType + ".");
109+
}
110+
int numChannels = numChannels();
111+
int totalElements = (int) chunkArray.getSize();
112+
int numPixels = totalElements / numChannels;
113+
int[] wh = factorWidthHeight(numPixels);
114+
int width = wh[0];
115+
int height = wh[1];
116+
117+
ByteBuffer src = chunkArray.getDataAsByteBuffer(ByteOrder.BIG_ENDIAN);
118+
byte[] data = new byte[totalElements];
119+
src.get(data);
120+
121+
try {
122+
if (numChannels == 1) {
123+
return ByteBuffer.wrap(encodeGray(data, width, height));
124+
}
125+
return ByteBuffer.wrap(encodeRgb(data, width, height));
126+
} catch (IOException ex) {
127+
throw new ZarrException("Error in encoding jpeg.", ex);
128+
}
129+
}
130+
131+
private byte[] encodeGray(byte[] data, int width, int height) throws IOException {
132+
DataBufferByte dataBuffer = new DataBufferByte(data, data.length);
133+
WritableRaster raster = Raster.createInterleavedRaster(
134+
dataBuffer, width, height, width, 1, new int[]{0}, null);
135+
return writeJpeg(new IIOImage(raster, null, null));
136+
}
137+
138+
private byte[] encodeRgb(byte[] data, int width, int height) throws IOException {
139+
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
140+
for (int pixel = 0; pixel < width * height; pixel++) {
141+
int r = data[pixel * 3] & 0xff;
142+
int g = data[pixel * 3 + 1] & 0xff;
143+
int b = data[pixel * 3 + 2] & 0xff;
144+
image.setRGB(pixel % width, pixel / width, (r << 16) | (g << 8) | b);
145+
}
146+
return writeJpeg(new IIOImage(image, null, null));
147+
}
148+
149+
private byte[] writeJpeg(IIOImage image) throws IOException {
150+
ImageWriter writer = getJpegWriter();
151+
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
152+
ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream(outputStream)) {
153+
writer.setOutput(imageOutputStream);
154+
ImageWriteParam param = writer.getDefaultWriteParam();
155+
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
156+
param.setCompressionQuality(quality() / 100f);
157+
writer.write(null, image, param);
158+
imageOutputStream.flush();
159+
return outputStream.toByteArray();
160+
} finally {
161+
writer.dispose();
162+
}
163+
}
164+
165+
@Override
166+
public Array decode(ByteBuffer chunkBytes) throws ZarrException {
167+
int numChannels = numChannels();
168+
try {
169+
byte[] data = numChannels == 1
170+
? decodeGray(Utils.toArray(chunkBytes))
171+
: decodeRgb(Utils.toArray(chunkBytes));
172+
return Array.factory(
173+
DataType.UINT8.getMA2DataType(), arrayMetadata.chunkShape, ByteBuffer.wrap(data));
174+
} catch (IOException ex) {
175+
throw new ZarrException("Error in decoding jpeg.", ex);
176+
}
177+
}
178+
179+
private byte[] decodeGray(byte[] jpegBytes) throws IOException {
180+
ImageReader reader = getJpegReader();
181+
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(
182+
new ByteArrayInputStream(jpegBytes))) {
183+
reader.setInput(imageInputStream);
184+
// Read the raw raster to avoid any color-space conversion for single-channel data.
185+
Raster raster = reader.readRaster(0, null);
186+
int numPixels = raster.getWidth() * raster.getHeight();
187+
int[] samples = raster.getSamples(0, 0, raster.getWidth(), raster.getHeight(), 0,
188+
new int[numPixels]);
189+
byte[] data = new byte[numPixels];
190+
for (int i = 0; i < numPixels; i++) {
191+
data[i] = (byte) samples[i];
192+
}
193+
return data;
194+
} finally {
195+
reader.dispose();
196+
}
197+
}
198+
199+
private byte[] decodeRgb(byte[] jpegBytes) throws IOException {
200+
BufferedImage image = ImageIO.read(new ByteArrayInputStream(jpegBytes));
201+
if (image == null) {
202+
throw new IOException("Could not decode jpeg image.");
203+
}
204+
int width = image.getWidth();
205+
int height = image.getHeight();
206+
byte[] data = new byte[width * height * 3];
207+
for (int pixel = 0; pixel < width * height; pixel++) {
208+
int rgb = image.getRGB(pixel % width, pixel / width);
209+
data[pixel * 3] = (byte) ((rgb >> 16) & 0xff);
210+
data[pixel * 3 + 1] = (byte) ((rgb >> 8) & 0xff);
211+
data[pixel * 3 + 2] = (byte) (rgb & 0xff);
212+
}
213+
return data;
214+
}
215+
216+
private static ImageWriter getJpegWriter() throws IOException {
217+
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
218+
if (!writers.hasNext()) {
219+
throw new IOException("No jpeg ImageWriter available.");
220+
}
221+
return writers.next();
222+
}
223+
224+
private static ImageReader getJpegReader() throws IOException {
225+
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("jpeg");
226+
if (!readers.hasNext()) {
227+
throw new IOException("No jpeg ImageReader available.");
228+
}
229+
return readers.next();
230+
}
231+
232+
@Override
233+
public long computeEncodedSize(long inputByteLength,
234+
ArrayMetadata.CoreArrayMetadata arrayMetadata) throws ZarrException {
235+
throw new ZarrException("Not implemented for Jpeg codec.");
236+
}
237+
238+
public static final class Configuration {
239+
240+
static final int DEFAULT_QUALITY = 90;
241+
242+
public final int quality;
243+
244+
@JsonCreator
245+
public Configuration(
246+
@JsonProperty(value = "quality", defaultValue = "90") int quality) throws ZarrException {
247+
if (quality < 0 || quality > 100) {
248+
throw new ZarrException("'quality' needs to be between 0 and 100.");
249+
}
250+
this.quality = quality;
251+
}
252+
}
253+
}

0 commit comments

Comments
 (0)