差分预测,英文名称为Differencing Predicor
,能够辅助极大地提供一些图像的压缩比。在 TIFF 文件中,该算法主要配合 LZW 算法或者 Deflate 算法使用,提高 TIFF 文件的压缩比。它在 TIFF 标准文件中对应的 TAG 值为317、数据类型为 Short,可选值有:
- 1:表示不使用差分预测算法
- 2:水平(或者横向)差分
- 3:浮点水平(或者横向)差分
1. 算法原理
大多数情况下对于连续色调的图像而言,相邻像素的值的变化不是很大,如果我们以某一些像素(基准像素)的值为标准,其他像素的值使用与基准像素的差异来表示,那么转换后的数据中会有很多相同的值,这些值可能是0、1、-1或者其他较小的值。
差分预测算法就是基于上面的思想而实现的,其实现原理就是在应用压缩算法之前对图像数据进行一些简单的数学操作,产生大量相同的数据,然后在结合压缩算法的特性,提供文件压缩比,尤其是和 LZW 算法或者 Deflate 算法配合使用是压缩效果更加明显。
2. TIFF 文件中的应用
在 TIFF 文件的编码过程中,一般是在应用 LZW 或者 Deflate 算法之前使用差分预测算法对原始像素值进行处理。TIFF 文件中差分预测算法的实现和像素相关的设置是分不开的,在遥感影像免切片1:TIFF 文件结构文章中,简单介绍了几个与像素表示相关的 TAG,分别是:
- SamplesPerPixel:表示像素的通道个数,例如,如果值为3,则表示每个像素有3个通道,需要使用3个值来表示每个像素的值。
- BitsPerSample:表示每个通道值的位深,例如,如果值为8,则表示该通道的值的取值范围为0~255。
- PhotometricInterpretation:表示颜色空间,例如:如果值为2,则表示当前文件中的像素使用 RGB 颜色空间来表示。
- PlanarConfiguration:表示如何存储每个像素的分量,也就是,如何存储每个像素的每个通道的值。
对于灰度图像,差分预测算法实现很简单,对应的伪代码如下,其中,nrows 表示 TIFF 文件的行数、ncols 表示 TIFF 文件的列数。
ini
image: int[][];
row: int;
col: int;
for (row = 0; row < nrows; row++)
for (col = ncols; col >= 1; col--)
image[row][col] -= image[row][col]
但是,对于通道不是8位位深的图像而言,为了更好地利用大多数 CPU 的架构,需要先对原始图像的数据执行一些额外的操作。
例如,对于每个像素只有1个通道且通道位深为4位的 TIFF 图像,当使用不是压缩算法时,每个字节存储2个像素值。为了更好地找到差异,首先需要将4位值扩展到8位(即,扩展到一个字节),然后再执行上面示例中所描述的水平差分过程。差分处理后,按照一个字节对应2个4位差分结果进行编码处理。如果每个通道的位深超过8位,通常是将通道的值扩展为16位的词(Word)而不是按照8位字节来处理。
对于真彩色的图像,差分预测过程是从红色分量值中减去红色,从绿色中减去绿色,从蓝色中减去蓝色,这样可以为 LZW 编码阶段提供大量的冗余数据,从而极大地提高文件压缩比。为了简化算法的实现复杂性以及提高执行效率,TIFF 标准中要求每个通道中的 BitsPerSampel 都是相同的。
TIFF 标准的起草单位做了差分预测算法的压缩率提升对比。在不使用差分预测算法、只使用 LZW 算法的情况下,对于1位位深图像、4位位深图像以及一些调色板颜色的图像(Palette-Color Image)都能有比较好的效果,但是,对于24位的真彩色图像和一些8位的灰度影像压缩效果不是很好。
尽管 LZW 与水平差分算法的组合并没有丢失任何数据,但是,在某些情况下,尤其对于8位位深的通道,在执行差分之前从图像数据中删除尽可能多的噪声可能是值得的,即便丢失了一些信息。去除噪声的最简单方法是屏蔽每个8位分量上的一个或者2个低阶比特。根据 TIFF 标准文件中描述的测试结果,使用差分 + LZW 组合算法压缩比为1.4:1;如果屏蔽每个分量的1个低阶比特,压缩比为1.8:1;如果屏蔽2个比特,压缩比为2.4:1;如果屏蔽3个比特,压缩比为3.4:1。当然,使用的掩码越多(也就是屏蔽的比特位数越多),丢失的数据也就越多。上面的测试结果只是一个参考,不同的图像需要找到合适自己的折中方案。
3. 代码实现
3.1 解压
java
public class Predictor {
private Predictor() {}
public static byte[] decode(byte[] stripCodeData, long width, long height, int[] bitsPerSample,
int planarConfig, int predictor) {
if (predictor == 1) return stripCodeData;
validateBitsPerSample(bitsPerSample);
int bytesPerSample = bitsPerSample[0] / 8;
int samples = planarConfig == 1 ? bitsPerSample.length : 1;
ByteBuffer stripCodeAsBuffer = ByteBuffer.wrap(stripCodeData);
try (FastByteArrayOutputStream fastByteArrayOutputStream = new FastByteArrayOutputStream()) {
for (int row = 0; row < height; row++) {
// 最后一个条带,当 height % stripHeight != 0 时会被截断
if (row * width * samples * bytesPerSample >= stripCodeAsBuffer.capacity()) {
break;
}
switch (predictor) {
case 2:
decodeHorizontal(fastByteArrayOutputStream, stripCodeAsBuffer, width, bytesPerSample, samples);
break;
case 3:
decodeFloatingPoint(fastByteArrayOutputStream, stripCodeAsBuffer, width, bytesPerSample, samples);
break;
default:
throw new IllegalArgumentException("不支持的predictor值:" + predictor);
}
}
return fastByteArrayOutputStream.toByteArray();
} catch (IOException exception) {
throw new RuntimeException("Predictor 解码出错了," + exception.getMessage());
}
}
/**
* 根据TIFF文件标准对BitPerSample做校验
* 1. 每个通道的值都必须相同
* 2. 每个值必须是8的整数倍
*
* @param bitsPerSample BitsPerSample TAG的值
*/
private static void validateBitsPerSample(int[] bitsPerSample) {
if (bitsPerSample == null || bitsPerSample.length == 0) {
throw new IllegalArgumentException("使用Predictor时,必须提供有效的BitsPerSample的值");
}
// 校验每个通道的值是否相同
boolean isSameValue = true;
for (int i = bitsPerSample.length - 1; i >= 1; i--) {
isSameValue = Objects.equals(bitsPerSample[i], bitsPerSample[i-1]);
}
if (!isSameValue) {
throw new IllegalArgumentException("使用Predictor时,每个通道的位深必须相同");
}
// 校验值是否为8的倍数
if (bitsPerSample[0] % 8 != 0) {
throw new IllegalArgumentException("使用Predictor时,通道的位深必须为8的整数倍");
}
}
/**
* 水平差分解码
*
* @param byteArrayOutputStream 输出结果流
* @param stripData 条带数据
* @param width 图像宽度
* @param bytesPerSample 每个通道的字节个数
* @param samples 通道个数
* @throws IOException 输出结果流写入数据时抛出的异常
*/
private static void decodeHorizontal(FastByteArrayOutputStream byteArrayOutputStream, ByteBuffer stripData,
long width, int bytesPerSample, int samples) throws IOException {
int[] previous = new int[samples];
for (int i = 0; i < width; i++) {
for (int j = 0; j < samples; j++) {
int temp = previous[j] + readValue(stripData, bytesPerSample);
previous[j] = temp;
byteArrayOutputStream.write(temp);
}
}
}
/**
* 浮点水平差分解码
*
* @param byteArrayOutputStream 输出结果流
* @param stripData 条带数据
* @param width 图像宽度
* @param bytesPerSample 每个通道的字节个数
* @param samples 通道个数
* @throws IOException 输出结果流写入数据时抛出的异常
*/
private static void decodeFloatingPoint(FastByteArrayOutputStream byteArrayOutputStream, ByteBuffer stripData,
long width, int bytesPerSample, int samples) throws IOException {
long samplesWidth = width * samples;
byte[] bytes = new byte[(int) (samplesWidth * bytesPerSample)];
byte[] previous = new byte[samples];
for (int sampleByte = 0; sampleByte < width * bytesPerSample; sampleByte++) {
for (int sample = 0; sample < samples; sample++) {
byte value = (byte) (readValue(stripData, bytesPerSample) + previous[sample]);
bytes[sampleByte * samples + sample] = value;
previous[sample] = value;
}
}
for (int widthSample = 0; widthSample < samplesWidth; widthSample++) {
for (int sampleByte = 0; sampleByte < bytesPerSample; sampleByte++) {
int index = (int) (((bytesPerSample - sampleByte - 1) * samplesWidth) + widthSample);
byteArrayOutputStream.write(bytes[index]);
}
}
}
private static int readValue(ByteBuffer byteBuffer, int bytesPerSample) {
int value;
switch (bytesPerSample) {
case 1:
value = byteBuffer.get();
break;
case 2:
value = byteBuffer.getShort();
break;
case 4:
value = byteBuffer.getInt();
break;
default:
throw new IllegalArgumentException("Predictor不支持当前输入的BytesPerSample值:" + bytesPerSample);
}
return value;
}
}
3.2 压缩
这里不在赘述差分预测算法压缩过程的代码实现,大家可以参照第2小节中的伪代码以解压的思路实现压缩效果。