From 3a72a873c0ecc64e0f84458d8fbe971e49a18560 Mon Sep 17 00:00:00 2001 From: Mark Harfouche Date: Sun, 9 Aug 2026 16:43:26 -0400 Subject: [PATCH] Add ZSTD support for ImageJ's native image TIFF reader As the title says. Thanks for considering. We love ImageJ, but the lack of zstd support forces us to "compromise" on what we think is great image compression. I know bioformats exists, but the extra popup really frustrates many of our new users. I think this should be quite safe to add. Please let me know what you think. It tries to take advantage of whatever zstd compressor is already bundled, or simply fails --- ij/io/FileInfo.java | 4 +- ij/io/ImageReader.java | 19 +++-- ij/io/TiffDecoder.java | 4 + ij/io/ZstdDecoder.java | 180 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 ij/io/ZstdDecoder.java diff --git a/ij/io/FileInfo.java b/ij/io/FileInfo.java index ad54a3ddd..e1a562e35 100644 --- a/ij/io/FileInfo.java +++ b/ij/io/FileInfo.java @@ -94,7 +94,9 @@ public class FileInfo implements Cloneable { public static final int PACK_BITS = 5; public static final int ZIP = 6; public static final int ZIP_WITH_DIFFERENCING = 7; - + public static final int ZSTD = 8; + public static final int ZSTD_WITH_DIFFERENCING = 9; + /* File format (TIFF, GIF_OR_JPG, BMP, etc.). Used by the File/Revert command */ public int fileFormat; diff --git a/ij/io/ImageReader.java b/ij/io/ImageReader.java index 609f0bb15..de51600b8 100644 --- a/ij/io/ImageReader.java +++ b/ij/io/ImageReader.java @@ -84,7 +84,7 @@ else if (i > 0) { byteArray = uncompress(byteArray); int length = byteArray.length; length = length - (length%fi.width); - if (fi.compression==FileInfo.LZW_WITH_DIFFERENCING||fi.compression==FileInfo.ZIP_WITH_DIFFERENCING) { + if (fi.compression==FileInfo.LZW_WITH_DIFFERENCING||fi.compression==FileInfo.ZIP_WITH_DIFFERENCING||fi.compression==FileInfo.ZSTD_WITH_DIFFERENCING) { for (int b=0; b 0) { for (int i=base,j=0; i0 && declared<=Integer.MAX_VALUE; + int capacity = exact?(int)declared:expectedSize; + if (capacity<=0) + capacity = Math.max(1<<16, input.length*4); + for (int attempt=0; ; attempt++) { + try { + byte[] output = new byte[capacity]; + int n = backend.decompress(input, output); + if (n<0 || n>capacity) + throw new IllegalStateException("bad decompressed length "+n); + return n==capacity?output:Arrays.copyOf(output, n); + } catch (OutOfMemoryError e) { + throw e; + } catch (Throwable e) { + // Only an estimated capacity is worth retrying; a declared size is + // authoritative, so a failure there is a genuinely corrupt frame. + if (exact || attempt>=6 || capacity>(1<<28)) { + IJ.log("ZSTD decompression failed: "+e); + return null; + } + capacity *= 4; + } + } + } + + private static void init() { + if (initialized) + return; + initialized = true; + try { + backend = new ZstdJni(); + return; + } catch (Throwable e) {} + try { + backend = new AirCompressor(); + } catch (Throwable e) {} + } + + private interface Backend { + String name(); + /** Decompressed size declared in the frame header, or 0 if not declared. */ + long declaredSize(byte[] input); + /** Decompresses into output; returns the number of bytes written. */ + int decompress(byte[] input, byte[] output) throws Exception; + } + + /** zstd-jni: static methods on com.github.luben.zstd.Zstd. */ + private static class ZstdJni implements Backend { + private final Method decompressByteArray, decompressedSize; + + ZstdJni() throws Exception { + Class c = Class.forName("com.github.luben.zstd.Zstd"); + decompressByteArray = c.getMethod("decompressByteArray", + byte[].class, int.class, int.class, byte[].class, int.class, int.class); + decompressedSize = c.getMethod("decompressedSize", byte[].class); + // Call a native method now, so that a jar without a binary for this + // platform makes us fall through to the pure-Java backend here rather + // than failing later, in the middle of reading an image. + c.getMethod("maxCompressionLevel").invoke(null); + } + + public String name() {return "zstd-jni";} + + public long declaredSize(byte[] input) { + try { + Object r = decompressedSize.invoke(null, (Object)input); + return ((Number)r).longValue(); + } catch (Throwable e) { + return 0L; + } + } + + public int decompress(byte[] input, byte[] output) throws Exception { + Object r = decompressByteArray.invoke(null, + output, Integer.valueOf(0), Integer.valueOf(output.length), + input, Integer.valueOf(0), Integer.valueOf(input.length)); + long n = ((Number)r).longValue(); + // zstd-jni signals errors by returning a large negative-coded value. + if (n<0 || n>output.length) + throw new java.io.IOException("zstd-jni error code "+n); + return (int)n; + } + } + + /** aircompressor: pure-Java io.airlift.compress.zstd.ZstdDecompressor. */ + private static class AirCompressor implements Backend { + private final Object instance; + private final Method decompress, getDecompressedSize; + + AirCompressor() throws Exception { + Class c = Class.forName("io.airlift.compress.zstd.ZstdDecompressor"); + instance = c.getConstructor().newInstance(); + decompress = c.getMethod("decompress", + byte[].class, int.class, int.class, byte[].class, int.class, int.class); + getDecompressedSize = c.getMethod("getDecompressedSize", + byte[].class, int.class, int.class); + } + + public String name() {return "aircompressor";} + + public long declaredSize(byte[] input) { + try { + Object r = getDecompressedSize.invoke(null, + input, Integer.valueOf(0), Integer.valueOf(input.length)); + return ((Number)r).longValue(); + } catch (Throwable e) { + return 0L; + } + } + + public int decompress(byte[] input, byte[] output) throws Exception { + Object r = decompress.invoke(instance, + input, Integer.valueOf(0), Integer.valueOf(input.length), + output, Integer.valueOf(0), Integer.valueOf(output.length)); + return ((Number)r).intValue(); + } + } + +}