news 2026/9/13 11:30:23

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 到 Golomb:CharLS 源码里的 JPEG-LS 编码链路

2.1 JPEG-LS 的核心不是变换,而是上下文预测

JPEG 家族多数算法先做 DCT 或小波变换,然后量化、熵编码。JPEG-LS 不走这条路,它依赖的是无损压缩领域最经典的两个工具:预测器和熵编码器。编码器对每个像素,用左边、上边、左上角三个相邻像素构成一个局部梯度向量,再通过梯度量化得到一个上下文编号。预测器给出一个参考值,残差经过映射后,用 Golomb 编码输出。由于无变换、无量化(无损模式),整个过程没有信息损失,这也让它在硬件上的实现成本远低于 JPEG 2000。

CharLS 的源码把这条链路拆得非常清晰。encoderstrategy.hdecoderstrategy.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 无损模式更有实用价值的地方,因为 n=1 时压缩率就能明显提升,而肉眼几乎看不出差别。

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_TYPE=Release .. cmake --build . --target jlsimage

第一行解压,第二三行进入目录并新建构建目录,第四行生成 Makefile,第五行只构建jlsimage这个命令行工具。CMAKE_BUILD_TYPE=Release必须设置,否则默认的调试模式会让编码速度下降一个数量级。如果只想要库文件,直接执行cmake --build .即可,结束后在 build 目录里能找到 libcharls.a 或 .so。

如果你是在 Windows 的 Visual Studio 里打开,大概率会直接识别到目录里的CharLS.slnCharLS.vcproj。这些是 1.0 时代残留的工程文件。用 CMake 重新生成解决方案通常更省事,命令是:

cmake -G "Visual Studio 16 2019" -DCMAKE_BUILD_TYPE=Release ..

需要注意的是,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.PPMTEST16.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::vector<uint8_t> compressed((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); 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::vector<uint8_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 的顺序;如果图像按平面存储,需要改成LineNone,并保证输入数据本身就是该顺序。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是 SOI;fff7是 SOF55;000b表示段长度 11 字节;08表示位深 8;后面四字节分别是高宽和低宽,格式是大端;再后面是分量数量 1。紧接着ffda是 SOS,000f表示 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:pos+2])[0] payload = data[pos+2:pos+length-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(f"frame: {width}x{height}, {bits}bit, {comps}comp") if marker_id == '0xffda': # payload 最后一个字节前两位是 interleave,后六位是 NEAR mode = payload[-1] >> 6 near = payload[-1] & 0x3F print(f"scan: interleave={mode}, nearLossless={near}")

输出示例:

frame: 512x512, 8bit, 1comp scan: interleave=0, nearLossless=0

这种反推在实际调试中非常实用。尤其是当你的解码器打开别人的文件失败时,先跑一遍这个脚本,看看nearLossless是否为 0,再确认interleave是否与你的假设一致。很多「解码错误」并不是算法实现的问题,而是参数不匹配。

5. 近无损调节、性能验证与三个容易踩的坑

5.1 让nearLossless在压缩率和误差之间找到可量化的边界

无损模式下nearLossless=0,残差不做任何量化。把该值设为 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::duration<double, 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」,排查问题的定位成本会明显降低。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 11:29:41

AI+职业测评如何提升电商人才筛选效率

1. 电商人才筛选的痛点与破局之道 电商行业的人才争夺战早已进入白热化阶段。我见过太多企业HR每天筛选上百份简历&#xff0c;却依然找不到能真正带来业绩增长的人才。传统招聘方式存在三个致命缺陷&#xff1a; 简历注水严重&#xff1a;销售冠军的数据可能来自团队业绩 面…

作者头像 李华
网站建设 2026/9/13 11:29:35

PHP中的自动加载机制是什么?

自动加载机制是什么&#xff1f;在PHP中&#xff0c;自动加载机制允许你在需要时自动包含&#xff08;加载&#xff09;类文件&#xff0c;而无需在每个文件中手动使用require或include。这样&#xff0c;当你尝试使用一个还未被包含的类时&#xff0c;PHP会自动找到并包含这个…

作者头像 李华
网站建设 2026/9/13 11:26:25

AI落地别追热词,先问“场景效率”划不划算

最近这波AI热潮里&#xff0c;每天都有新概念冒出来&#xff1a;AI Agent、AI Infra、AI编程、AI应用开发、AI短剧、AI漫剧……搜索引擎的热搜词换得比手机壁纸还快。但真正在真实业务里摸爬滚打的人&#xff0c;心里都清楚一件事&#xff1a;刷榜的模型和发布会上的Demo&#…

作者头像 李华
网站建设 2026/9/13 11:26:23

Cilium 连通性故障排查:cilium connectivity 命令全解析与实战指南

Cilium 连通性故障排查&#xff1a;cilium connectivity 命令全解析与实战指南 【免费下载链接】cilium eBPF-based Networking, Security, and Observability 项目地址: https://gitcode.com/GitHub_Trending/ci/cilium 导读 cilium connectivity 是 Cilium CLI 提供的…

作者头像 李华
网站建设 2026/9/13 11:26:14

数据湖与数据仓库溯源技术对比与应用场景

1. 数据湖与数据仓库的本质差异数据湖和数据仓库作为现代数据管理的两大核心架构&#xff0c;其设计哲学和应用场景存在根本性差异。理解这些差异是掌握溯源技术区别的前提。数据仓库采用"写时模式"(Schema-on-Write)的设计理念&#xff0c;数据在入库前必须经过严格…

作者头像 李华