ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CharLS源码解析:JPEG-LS无损压缩编码链路与工程实践

CharLS源码解析:JPEG-LS无损压缩编码链路与工程实践 简介面向图像压缩算法研究者和C开发者的CharLS开源库1.0源码包专门实现JPEG-LS无损/近无损压缩标准提供编码解码、头文件接口及算法仿真分析所需的核心模块。压缩包共78个文件约4.48MB包括接口实现、核心jpegls算法、多个策略头文件以及jls测试图像、pgm/raw/bmp样本和工程配置文件便于快速编译与实验。已有313人学习。借助完整源码、头文件定义、conformance测试用例和多样测试图读者可深入理解JPEG-LS的扫描、上下文建模、颜色变换与解码策略也可基于接口修改或移植算法是学习图像无损压缩和开展二次开发的实用资料。1. 不是压缩率最高的 JPEG-LS为什么还有人在翻它的源码JPEG-LS 在无损压缩赛道里有种「不声不响但一直没被替代」的位置。医学影像、遥感数据和工业相机把像素完整性放在第一位JPEG-LS 凭借低复杂度、高吞吐和稳定的无损表现在这些场景里一跑就是十几年。CharLS 是这个标准最常用的开源实现而这个 source-1.0.zip 里几乎所有关键文件都平铺在面前header.cpp、jpegls.cpp、encoderstrategy.h、context.h还有现成的 conformance 测试样本。对想真正搞懂 JPEG-LS 内部预测、上下文切换和 Golomb 编码细节的人这份源码不是普通 Lib而是一份能逐行对照 ISO/IEC 14495-1 标准阅读的活教材。下面的内容会从源码骨架讲到数据流分析再落到近无损参数的实际边界中途所有命令和代码都基于 1.0 版本展开。2. 从 predictor 到 GolombCharLS 源码里的 JPEG-LS 编码链路2.1 JPEG-LS 的核心不是变换而是上下文预测JPEG 家族多数算法先做 DCT 或小波变换然后量化、熵编码。JPEG-LS 不走这条路它依赖的是无损压缩领域最经典的两个工具预测器和熵编码器。编码器对每个像素用左边、上边、左上角三个相邻像素构成一个局部梯度向量再通过梯度量化得到一个上下文编号。预测器给出一个参考值残差经过映射后用 Golomb 编码输出。由于无变换、无量化无损模式整个过程没有信息损失这也让它在硬件上的实现成本远低于 JPEG 2000。CharLS 的源码把这条链路拆得非常清晰。encoderstrategy.h和decoderstrategy.h定义的是策略接口编码器只负责把残差变成码流解码器只负责把码流还原成残差。这两者的对称性是 JPEG-LS 工程实现能保持低 bug 率的关键也是阅读源码时最值得先看的两个文件。2.2 把源码文件映射到编码流水线在 CharLS-source-1.0.zip 里每个文件都能在编码流水线上找到自己的位置。我一般建议按下表顺序阅读而不是从头到尾硬啃文件在流水线中的角色jpegls.cpp主状态机负责编码/解码的总体调度context.h上下文模型维护梯度量化表、预测修正量contextrunmode.h游程模式的核心控制逻辑encoderstrategy.h / decoderstrategy.h无损/近无损编码策略定义预测残差处理方式header.cpp / header.hJSON 级别的 marker 段解析与生成processline.h按行处理执行常规模式与游程模式的切换colortransform.hRGB 与 YCbCr 之间的色彩空间转换streams.h / util.h位级读写和公共工具函数对 JPEG-LS 不熟的人可以先读context.cpp对应的上下文部分。因为 JPEG-LS 的性能差异主要来自上下文建模是否准确。CharLS 在这里维护了一个有限状态机相邻像素的梯度差会被量化成 0~364 的上下文编号每个上下文独立维护残差的 Golomb 参数 k。context.h里的成员变量其实就是这张状态表理解它就理解了整个算法的信息论基础。2.3 游程模式与上下文模式切换的触发条件JPEG-LS 一个反直觉的设计是它专门为「连续相同像素」准备了一套跑得很快的旁路。当左边和上边的像素相等时编码器会进入游程模式统计当前像素与预测值连续相等的长度最后用 Golomb 编码输出这个长度。游程模式的编码非常便宜适合医学影像里大范围均匀背景或者文档扫描图像的白底区域。在contextrunmode.h里可以看到一个关键函数游程长度计数。它不是简单数相同像素而是同时追踪残余游程长度计数和中断点位置。一旦遇到不匹配像素立刻切回常规上下文模式。这个切换在processline.h里实现每处理一行都会重新评估是否要进入游程模式。理解这个切换逻辑后你再去看 CharLS 的压缩率测试就会明白为什么纯文本扫描图的压缩速度能接近内存拷贝速度。2.4 近无损路径在源码里的位置如果只读无损模式编码器和解码器对称性很好。但 CharLS 也支持近无损 JPEG-LS也就是说允许重建像素与原始像素存在一定误差。这个逻辑并不在 Golomb 编码部分而是在预测残差生成之后立即进行量化。encoderstrategy.h里会检查一个nearLossless阈值将残差量化到该阈值允许的区间。这种设计让无损与近无损共用同一套上下文建模区别只是残差是否经过可逆量化。近无损模式是 JPEG-LS 比传统 JPEG 无损模式更有实用价值的地方因为 n1 时压缩率就能明显提升而肉眼几乎看不出差别。3. 用 1.0 源码在本地编译出一个可用的 JPEG-LS 工具3.1 用 CMake 把源码变成可执行文件CharLS 1.0 已经提供了 CMakeLists.txt因此在 Linux 和 Windows 上都能用同一套流程构建。我习惯在 Ubuntu 上用如下命令unzip CharLS-source-1.0.zip cd CharLS-source-1.0 mkdir build cd build cmake -DCMAKE_BUILD_TYPERelease .. cmake --build . --target jlsimage第一行解压第二三行进入目录并新建构建目录第四行生成 Makefile第五行只构建jlsimage这个命令行工具。CMAKE_BUILD_TYPERelease必须设置否则默认的调试模式会让编码速度下降一个数量级。如果只想要库文件直接执行cmake --build .即可结束后在 build 目录里能找到 libcharls.a 或 .so。如果你是在 Windows 的 Visual Studio 里打开大概率会直接识别到目录里的CharLS.sln和CharLS.vcproj。这些是 1.0 时代残留的工程文件。用 CMake 重新生成解决方案通常更省事命令是cmake -G Visual Studio 16 2019 -DCMAKE_BUILD_TYPERelease ..需要注意的是1.0 的代码风格比较老新版本编译器偶尔会报std::auto_ptr或隐式转换警告。如果项目设置了“警告即错误”你需要在 CMake 里关闭/WX或者给目标单独加开关否则编译会停在中途。这个问题在源码里不是 bug纯粹是编译器演进带来的摩擦。3.2 用命令行工具打开和转换 jls 文件jlsimage是 1.0 自带的终端工具主要功能就是编码和解码。不同 fork 的参数风格略有差异第一次使用前先运行./jlsimage --help确认。常见形式是# 编码把 PNM 文件压缩为 jls ./jlsimage -e -i desktop.ppm -o desktop.jls # 解码把 jls 还原为可查看的图像 ./jlsimage -d -i desktop.jls -o restored.ppm-e表示编码-d表示解码。-i和-o分别指定输入输出路径。PNM/PPM 是 JPEG-LS 标准测试里最常见的格式CharLS 自带测试样本里就有TEST8.PPM、TEST16.PGM。如果你想处理 DICOM 里面的像素数据通常要先抽成裸数据再交给 CharLS因为 DICOM 封装层和 JPEG-LS 的码流层是两回事。3.3 在自己的代码中调用解码接口实际项目里不太可能每次都走命令行更多情况是把 CharLS 编译成库嵌进自己的程序。1.0 的解码接口在interface.h中暴露核心是一个JpegLSDecoder类。下面是一个可编译的最小示例用来打开一个.jls文件并还原原始像素#include interface.h #include vector #include fstream #include iostream int main() { std::ifstream in(banny_normal.jls, std::ios::binary); std::vectoruint8_t compressed((std::istreambuf_iteratorchar(in)), std::istreambuf_iteratorchar()); charls::JpegLSDecoder decoder; decoder.setSource(compressed.data(), compressed.size()); if (decoder.readHeader() charls::ApiResult::OK) { int width decoder.getWidth(); int height decoder.getHeight(); int bits decoder.getBitsPerSample(); int comp decoder.getComponentCount(); size_t bytesPerSample (bits 8) ? 2 : 1; size_t rawSize width * height * comp * bytesPerSample; std::vectoruint8_t raw(rawSize); decoder.decode(raw.data(), rawSize); std::cout width x height bits bits comps comp std::endl; } return 0; }代码流程是先用setSource把 jls 文件的字节数组交给解码器readHeader()解析所有 marker 段同时返回解析状态接着用四个 getter 取出图像尺寸、位深和分量数然后计算原始像素缓冲区大小最后decode()把数据填充到 buffer 中。这里最容易出错的是rawSize的计算。JPEG-LS 中 9 到 15 位的样本也按 2 字节存储所以只要bits 8bytesPerSample就是 2即使 bits 的值为 12。如果按像素数乘 1 字节分配解码时会越界写。另一个细节是decode()的第二个参数是字节数不是像素数传入raw.size()是最稳妥的。3.4 编码端接口与 JlsInfo 参数填充编码接口与解码对称但多了一个描述图像属性的JlsInfo结构。以下是编码示例的关键片段charls::JlsInfo info; info.width 512; info.height 512; info.bitsPerSample 8; info.componentCount 3; info.interleaveMode charls::InterleaveMode::Sample; info.colorTransform charls::ColorTransform::InverseYCoCg; charls::JpegLSEncoder encoder; encoder.setSource(rawData.data(), rawData.size(), info); encoder.setDestination(jlsData.data(), jlsDataCapacity); encoder.encode(); size_t jlsSize encoder.getBytesWritten();InterleaveMode::Sample表示像素交错即 R G B R G B 的顺序如果图像按平面存储需要改成Line或None并保证输入数据本身就是该顺序。colorTransform在 1.0 中支持无变换或 YCbCr/ YCoCg 家族的一种选择变换后压缩率会提升 5% 到 15%这对彩色照片是值得的但对 16 位医学灰度图并不适用因为灰度图没有颜色通道可变换。4. 手拆 JPEG-LS 数据流从 SOI 到 SOS 的 header 分析4.1 marker 段结构和 header.cpp 的解析状态机JPEG-LS 文件本质上是一个 marker 段序列。每个 marker 都是 0xFF 前缀加上一个字节的代码后面跟着两字节的大端长度字段。header.cpp在内部就是一个不停读取 marker 的状态机它根据代码跳转去解析帧头、扫描头或扩展参数段。常用的 marker 如下marker 码名称内容FFD8SOI图像起始FFF7SOF55JPEG-LS 帧头保存宽高、位深、分量数FFF8LSEJPEG-LS 扩展参数段可携带预设编码表FFDASOS扫描开始保存 interleave、near-lossless 参数FFD9EOI图像结束SOF55是 JPEG-LS 的身份证。普通 JPEG 的 SOF 是 FFC0 到 CFC3看到 FFF7 就能确定这是 JPEG-LS 码流而不是老式 JPEG。在header.cpp里解析完帧头后还会检查分量表JPEG-LS 中每个分量的采样因子必须为 1因为标准不允许子采样这也是它比 JPEG 更适合无损保真的原因之一。4.2 用十六进制方式读取一个真实 jls 文件的头用二进制查看器可以非常直观地确认 marker 分布。以源码包里的一个测试样本为例执行xxd -l 40 T8C0E0.JLS会看到类似下面的输出不同文件值不同结构一致00000000: ffd8 fff7 000b 0801 0100 0101 0001 ffda ................ 00000010: 000f 0b03 0100 0200 0103 0103 0101 0101 ................这里ffd8是 SOIfff7是 SOF55000b表示段长度 11 字节08表示位深 8后面四字节分别是高宽和低宽格式是大端再后面是分量数量 1。紧接着ffda是 SOS000f表示 15 字节长度最后的字节包含样本排列方式。这个十六进制观察法是排查文件头问题的利器比如某些 DICOM 用 jls 封装时会多出额外 marker先看十六进制再去看代码定位会快很多。4.3 用 Python 快速验证 jls 文件包含哪些 marker如果你只想在开发笔记本上确认一个文件是不是 JPEG-LS、有没有 LSE 扩展段不需要启动 C 工程。写一个几十行 Python 脚本遍历 marker 就够用了import struct def parse_jls(file_path): with open(file_path, rb) as f: data f.read() pos 0 markers [] while pos len(data): if data[pos] ! 0xFF: pos 1 continue while pos len(data) and data[pos] 0xFF: pos 1 if pos len(data): break marker data[pos] pos 1 if marker 0xD8 or marker 0xD9: markers.append((hex(0xFF00 | marker), 0, b)) continue if marker 0x01 or marker 0xD0 or marker 0xD7: markers.append((hex(0xFF00 | marker), 0, b)) continue length struct.unpack(H, data[pos:pos2])[0] payload data[pos2:poslength-2] markers.append((hex(0xFF00 | marker), length, payload)) pos length - 2 return markers for marker_id, length, payload in parse_jls(T8C0E0.JLS): print(f{marker_id} length{length} payload_len{len(payload)})脚本逻辑很直接从头扫描碰到 0xFF 就继续检查下一个 0xFF 是否要被跳过然后读取 marker 码。对于无长度的 marker比如 SOI 和 EOI直接记录对于有长度的 marker用大端无符号短整型读出总长度再跳过整个段。这里的关键点是pos length - 2因为 length 包含长度字段本身两字节而payload已经跳过这两个字节所以游标要回到段结束位置。运行后如果看到0xfff8就说明文件里带 LSE 段。LSE 段常用于保存 JPEG-LS 扩展预设参数比如自定义的最大残差、映射表或颜色变换标识。如果从网上找的 jls 文件无法正常打开优先核对 LSE 段的内容很多兼容性问题都是因为它。4.4 从数据流反推编码参数当你拿到一个未知的 jls 文件时通过 header 解析就能反推出编码器使用的关键参数。SOF55 的宽高位和位深可以直接读出SOS 段的高 4 位表示interleave mode低 4 位表示nearLossless值。下面这段 Python 可以从 SOS 中提取参数def read_jls_params(file_path): markers parse_jls(file_path) for marker_id, length, payload in markers: if marker_id 0xfff7: width struct.unpack(H, payload[2:4])[0] height struct.unpack(H, payload[4:6])[0] bits payload[0] comps payload[7] print(fframe: {width}x{height}, {bits}bit, {comps}comp) if marker_id 0xffda: # payload 最后一个字节前两位是 interleave后六位是 NEAR mode payload[-1] 6 near payload[-1] 0x3F print(fscan: interleave{mode}, nearLossless{near})输出示例frame: 512x512, 8bit, 1comp scan: interleave0, nearLossless0这种反推在实际调试中非常实用。尤其是当你的解码器打开别人的文件失败时先跑一遍这个脚本看看nearLossless是否为 0再确认interleave是否与你的假设一致。很多「解码错误」并不是算法实现的问题而是参数不匹配。5. 近无损调节、性能验证与三个容易踩的坑5.1 让nearLossless在压缩率和误差之间找到可量化的边界无损模式下nearLossless0残差不做任何量化。把该值设为 1表示允许相邻重建像素与原始像素最多相差 ±1。JPEG-LS 标准规定舍入后的误差不超过这个值所以不需要担心出现极端偏离。下面是典型 8 位自然图像上的一组参考值不同内容会偏移但趋势一致nearLossless压缩率PSNR适用场景02.1x∞医学影像、存档12.6x62 dB工业检测、预览23.0x55 dB摄影作品近无损处理33.4x50 dB缩略图前的临时中间格式在工程里我通常不会直接给用户暴露这个参数而是把它映射到业务语言例如「质量等级无损/标准/高压缩」。实际判断选择时最可靠的方法还是在自己的样本集上迭代因为 JPEG-LS 是预测编码内容相关性对结果的影响非常大纯文字图和纯噪声图的曲线完全是两条。5.2 性能验证要排除 IO 干扰评估 CharLS 解码性能时输入文件已经躺在内存中直接测解码时间是更公平的做法。我的基准写法是#include chrono auto t0 std::chrono::high_resolution_clock::now(); decoder.decode(raw.data(), raw.size()); auto t1 std::chrono::high_resolution_clock::now(); double ms std::chrono::durationdouble, std::milli(t1 - t0).count(); double mbps raw.size() / 1024.0 / 1024.0 / (ms / 1000.0); printf(decode: %.2f ms, %.2f MB/s\n, ms, mbps);这段代码把解码耗时限定在纯算法范围内文件读取已经被排除在外。判读结果时如果发现解码速度远低于预期先检查是否用 Release 编译再检查数据是否包含大量高纹理区域。JPEG-LS 的上下文模式比游程模式慢高噪声图会频繁切换上下文导致吞吐量下降一半以上这属于正常现象不是资源泄漏。5.3 三个高频坑第一个坑是位深判断错误。8 位与 16 位图像 buffer 大小差一倍解码越界通常不会立刻崩溃而是悄悄改写相邻内存。任何输入文件都应在readHeader()后按bitsPerSample 8计算字节数不能用像素数直接当字节数。第二个坑是彩色图交错模式不匹配。相同 jls 流像素交错和行交错解码出的像素通道顺序完全不同。遇到颜色错乱第一件事就是去确认 SOS 段里的 interleave mode不要怀疑颜色变换。第三个坑是 LSE 段被丢弃。某些自定义编码器把色彩空间信息放在 LSE 里如果你用了解析后不保留该段内容的旧版封装解码结果会默默变灰或变色。最后给你一个实用技巧不管项目里用的是 CharLS 1.0 还是后续版本在解码入口前加一道 jls 魔数校验即文件头必须是0xFF 0xD8 0xFF 0xF7。这四字节能过滤掉九成以上的错误输入让日志里的「解码失败」变成「输入不是 JPEG-LS」排查问题的定位成本会明显降低。本文还有配套的精品资源点击获取
返回列表