位打包 Bit Packing

相机的 ADC 通常输出 10,12 或 14 位的数据,超过了一个字节,要存储这些数据,最简单的方法是 16 位整型(uint16)。但这样会浪费一些空间: 10 位数据会浪费 6 位,12 位数据会浪费 4 位,14 位数据会浪费 2 位。

即便不做任何高级压缩,仅仅通过位打包(bit packing)就可以节省不少空间。位打包的原理是将多个像素的数据紧密地排列在一起,不浪费高位深容器的空间。例如,4 个 10 位像素可以打包成 5 个字节(40 位),使用 16 位整型存储则需要 8 个字节(64 位),节省了 37.5% 的空间。

12 位和 14 位数据也可以类似地进行位打包:

  • 2 个 12 位像素可以打包成 3 个字节(24 位),节省 25% 的空间。
  • 4 个 14 位像素可以打包成 7 个字节(56 位),节省 12.5% 的空间。

DNG 是 TIFF 格式的扩展,规范要求使用位打包存储 8 和 16 位以外的像素数据来节省空间。

使用 Numpy 进行位打包

PiDNG 库提供了将 Numpy Array 转换为 DNG 格式的功能,其中就需要用到位打包。

PiDNG 中打包 14 位数据的代码有错误,试图将 6 个 14 位像素打包成 7 个字节,这段代码也被我用来测试 LLM 的编码能力。另外,其中打包 12 位时用到的位操作也存在瑕疵,目前还没有模型能发现这一点,具体请看 适用于色彩科学的 LLM 用法和测试题

接下来以最简单的 12 位打包为例,展示如何使用 Numpy 实现位打包。

def pack12(data: np.ndarray) -> np.ndarray:
    out = np.zeros((data.shape[0], int(data.shape[1] * 3 // 2)), dtype=np.uint8)
    out[:, ::3] = (data[:, ::2] & 0x0FF0) >> 4
    out[:, 1::3] = (data[:, ::2] & 0x000F) << 4 | (data[:, 1::2] & 0x0F00) >> 8
    out[:, 2::3] = data[:, 1::2] & 0x00FF
    return out

这个函数接受一个二维的 Numpy 数组 data,表示图像的像素值,每个像素是一个范围在 0 到 4095(12 位)的 16 位无符号整数,即只使用了低 12 位。

首先创建一个新的数组 out,其宽度是原始数组的 1.5 倍(因为每 2 个像素打包成 3 个字节),数据类型为 uint8,对应一个字节。

然后通过位运算和 Numpy 的切片,将每两个 12 位像素的数据拆分并存储到 out 数组中:

  • 第一个字节存储第一个像素的高 8 位,使用与操作获得高 8 位并右移 4 位。
  • 第二个字节的高 4 位存储第一个像素的低 4 位,低 4 位存储第二个像素的高 4 位,通过与操作和位移实现。
  • 第三个字节存储第二个像素的低 8 位,通过与操作获得。

这样的位运算与切片是向量化的操作,能够高效地处理整个数组。

填充 Padding

上面的代码看起来非常的优雅,但它假设了输入的图像宽度需要是偶数。如果是奇数宽度的图像,创建出的 out 数组的大小就不正确了,使得切片操作也出错。同理,10 位和 14 位图像的宽度需要是 4 的倍数。

回看位打包的原理,需要满足以下几点:

  • 连续比特流:一行的所有像素数据被视为一个连续的比特流。例如,10-bit 图像,第 1 个像素占用 0-9 位,第 2 个像素占用 10-19 位,以此类推。
  • 字节对齐:文件存储的最小单位是字节(8-bit)。
  • 行末填充:当一行的总比特数不能被 8 整除时,在行的末尾填充 0,直到凑满下一个字节。

为了解决这个问题,同时保持位操作的高效性,在输入图像的宽度为奇数时引入填充(padding),具体来说,是在输入数组的右侧添加一些额外的列,填充值为 0。打包完成之后,再将多余的字节去除。

假如有 101 宽度的 10 位图像,填充和打包的过程如下:

  1. 10 位的打包方式是每 4 个像素打包成 5 个字节,因此需要将宽度填充到最接近的能被 4 整除的数,即 104。
  2. 填充 3 列 0,使得输入数组的宽度变为 104。
  3. 进行位打包,每一行打包成 104 / 4 * 5 = 130 字节。
  4. 计算实际需要的字节数:101 个像素占用 1010 位,即 126.25 字节,向上取整为 127 字节。
  5. 最后从打包后的数组中截取前 127 字节,丢弃多余的 3 字节。

这样,既满足了位打包的要求,可以打包任意宽度的图像,又保持了具体实践中的高效性。

对 PiDNG 的改进:numpy2dng

PiDNG 库中的位打包没有考虑到填充,只能正确处理宽度为 4 的倍数(10 位)、2 的倍数(12 位)和 4 的倍数(14 位)的图像。对于其他宽度的图像,会在切片时出现错误。

除了位打包以外,PiDNG 还支持 JPEG92 进行无损压缩,使用的是 C 语言实现的 ljpeg92,虽然更加高效,但使得该库在安装时需要现场编译。如果环境比较复杂,可能会导致安装失败:

Resolved 15 packages in 15ms
  × Failed to build `pidng==4.0.9`
  ├─▶ The build backend returned an error
  ╰─▶ Call to `setuptools.build_meta:__legacy__.build_wheel` failed (exit code: 1)

还有一些小问题,比如代码中提供了一些 Raspberry Pi 特定的相机元数据,Git 仓库里放着一个比较大的 RAW 样本文件,14 位打包中的错误等。

因此,由 MiMo-V2-FlashGPT-5.2 携手,在 PiDNG 的基础上,暂时移除了 JPEG92 压缩,修正了位打包的问题,换用更现代的 uvhatchling 进行打包,移除不必要的文件,发布了一个新的库 numpy2dng,可以通过 pip 或 uv 直接安装:

pip install numpy2dng
# or
uv add numpy2dng

接口基本与 PiDNG 保持一致,可以方便地将 Numpy Array 保存为 DNG 文件。