ARTICLE DETAIL

资讯详情

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

JPEG-LS无损压缩原理与LOCO-I算法工程实现

JPEG-LS无损压缩原理与LOCO-I算法工程实现 简介一份JPEG-LS无损图像压缩标准的C源代码实现适合图像处理开发者、研究人员及数字音视频课程学员用于理解算法与二次开发。该标准由ISO/IEC 14495-1定义在医学影像、遥感存档等需要精确保存的场景中具有实用价值。压缩包共5个文件、约179KB包含2个cpp源文件编码主程序与解码主程序、2个txt辅助说明以及1个raw格式的lena512标准测试图像结构精简便于快速定位预测器、自适应算术编码/解码、位流处理与图像缓冲管理等核心模块。已有1166人学习下载。源码中预测与熵编码两条主线清晰可直接编译运行对比原始图像与解码图像验证无损特性也可作为开发自有无损图像编码工具的基础框架值得深入学习。1. JPEG-LSPNG 之外那条更划算的无损压缩路线JPEG-LS 是一条常被忽略的无损图像压缩标准同样一张 8 位灰度图它的压缩率通常比 PNG 高 20% 到 40%计算量却低一个量级医学影像、卫星遥感和工业检测里大量在用。标题里的 JPEG_LS.rar 多半是一份 LOCO-I 参考实现的源码包解压后面对一堆*.c和 Makefile没有算法底子很难读进去。这篇文章不打算逐行注释源码而是先把 LOCO-I 的预测、上下文建模和 Golomb-Rice 编码讲透再给出一个能直接跑的最小 Python 实现然后和 PNG、JPEG 2000 无损模式做横向对比最后落在 CharLS 集成、MCU 移植和无损校验这几个工程动作上。适合正在做选型、要把图像压缩代码移植到 ARM、或者单纯想读懂参考实现的人。2. LOCO-I 算法骨架中值预测、梯度上下文与 Golomb-Rice 怎么配合JPEG-LS 的标准编号是 ISO/IEC 14495-1对应 ITU-T T.871999 年定稿。算法核心来自 HP Labs 的 LOCO-ILow Complexity Lossless Compression for Images名字里的 Low Complexity 不是谦虚设计目标是把压缩率做到接近当时学界最强的 CALIC同时把计算量压到和 JPEG baseline 一个量级。这一章把这条流水线拆成三段看预测器怎么选、上下文怎么建、熵编码怎么自适应。2.1 为什么 JPEG 自己的无损模式没人用要单独立一个 JPEG-LSJPEG 标准内部其实留了一个无损模式SOF3用 8 种预测器和 Huffman 编码但实际产品里几乎没人用。原因是它没有上下文建模预测误差的分布会随像素局部形态剧烈变化静态 Huffman 表只有全局统计白白浪费比特。JPEG-LS 的改进是两条每个上下文单独维护统计量熵编码参数随统计实时推导平坦区域直接切游程模式连熵编码都绕开。很多人最早是在 MATLAB 实验里实现 JPEG 压缩中的 Huffman 编码建树、传表、查表那一套流程很熟悉到了 JPEG-LS 这里这套经验直接失效——它不用 Huffman 表不用 DCT而是用 Golomb-Rice 码参数由每个上下文实时推导。这一条值得先记住JPEG-LS 的位流里不传任何码表解码端靠同样的算法状态把码重新算出来。2.2 MED 中值预测器三个邻居决定三种走向预测用三个已经解码重建的邻居a是左邻b是上邻c是左上邻。注意这里用的是重建值而不是原像素编码端和解码端必须看到同一个预测上下文这是所有无损编码器能闭环的前提。def med_predict(a, b, c): # a: 左邻重建值, b: 上邻重建值, c: 左上邻重建值 if c max(a, b): return min(a, b) if c min(a, b): return max(a, b) return a b - c三个分支对应三种局部形态左上邻居值很大说明当前点大概率在水平边缘上取两个邻居里较小的左上邻居值很小说明在垂直边缘上取较大的a b - c是对倾斜渐变平面的线性外推正好落在水平梯度和垂直梯度之间。整个预测器只有比较和加减没有乘法这是 LOCO-I 能在 MCU 上跑的前提之一。2.3 梯度量化把预测误差环境压缩成 365 个桶预测之外JPEG-LS 还引入三个梯度描述局部变化形态g1 d - bd是右上邻、g2 b - c、g3 c - a。每个梯度量化到 -4 到 4 共九级。8 位灰度、NEAR0 时的默认阈值为 T13、T27、T321量化规则如下梯度 g 的区间量化值g ≤ -T3-4-T3 g ≤ -T2-3-T2 g ≤ -T1-2-T1 g 0-1g 000 g T11T1 ≤ g T22T2 ≤ g T33g ≥ T34T1, T2, T3 3, 7, 21 def quantize(g): if g -T3: return -4 if g -T2: return -3 if g -T1: return -2 if g 0: return -1 if g 0: return 0 if g T1: return 1 if g T2: return 2 if g T3: return 3 return 4注意边界条件在负数侧是小于等于正数侧是小于这是标准原样。移植时不要好心改成对称版本改完位流就和所有标准实现不互通了。三个梯度都量化后得到一个三元组理论上有 9³729 种JPEG-LS 把符号相反的两个三元组合并成一个上下文记录一个符号位因为误差分布经过预测修正后是中心对称的。去掉全零的平坦项后实际上下文数是(729 - 1) / 2 1 365个。上下文分得越细统计越精准但每个上下文要养自己的统计量太细又养不熟——365 这个数字是 LOCO-I 在复杂度和压缩率之间试出来的平衡点。2.4 Golomb-Rice 参数怎么自适应为什么不用 Huffman每个上下文维护五个状态量A绝对误差累积、B有偏误差累积、C预测修正量、N出现次数、KGolomb 参数。编码时先把预测误差映射成非负整数err 0映射为2*errerr 0映射为-2*err - 1然后写 Golomb-Rice 码先写merr k个 0 再接一个 1最后补k位低位。参数k由(N k) A推出本质是取平均误差幅度的对数。误差分布近似双边几何分布时Golomb-Rice 是渐近最优的。对比 Huffman 就清楚多了Huffman 需要建树、需要把表写进位流Golomb-Rice 只需要一个整数k。JPEG-LS 有 365 个上下文如果每个都挂一张 Huffman 表表开销直接吞掉压缩收益。这就是 JPEG-LS 敢用大量上下文、而 JPEG 无损模式不敢的原因。游程模式也是这个设计哲学的延伸当三个梯度量化值全为 0说明进入平坦区域编码器不再逐像素预测而是对连续相等的像素长度编码细节放在第 3 章。3. 用 Python 手写 JPEG-LS 编码器从 MED 预测到上下文编码的最小实现写一个能跑的教学版 JPEG-LS 编码器去掉标准里的射程限制、跨行游程和近无损误差修正保留核心压缩通路MED 预测、梯度量化、365 上下文、Golomb-Rice 自适应。这个版本的位流格式和标准不兼容但算法行为和参考实现LOCOI.C的常规模式一致跑出来的压缩率已经能说明问题。3.1 位流写入、梯度量化与对称上下文表先准备基础组件MSB 优先的位流写入器、9 级梯度量化器、MED 预测器以及一个预生成的对称上下文查表。上下文表的生成用穷举加去重的办法比手写索引映射少出错。import sys, itertools import numpy as np from PIL import Image T1, T2, T3 3, 7, 21 GLIMIT 255 # 一元部分长度上限防止病态长串 class BitWriter: MSB 优先的位流写入器。 def __init__(self): self.buf bytearray() self.acc 0 self.nbits 0 def put(self, bit): self.acc (self.acc 1) | (bit 1) self.nbits 1 if self.nbits 8: self.buf.append(self.acc) self.acc 0 self.nbits 0 def put_bits(self, val, n): for i in range(n - 1, -1, -1): self.put((val i) 1) def flush(self): if self.nbits: self.buf.append(self.acc (8 - self.nbits)) self.nbits 0 def quantize(g): # 9 级梯度量化8-bit 默认阈值 T13, T27, T321 if g -T3: return -4 if g -T2: return -3 if g -T1: return -2 if g 0: return -1 if g 0: return 0 if g T1: return 1 if g T2: return 2 if g T3: return 3 return 4 def med_predict(a, b, c): # a: 左邻重建值, b: 上邻重建值, c: 左上邻重建值 if c max(a, b): return min(a, b) if c min(a, b): return max(a, b) return a b - c # 对称合并方向相反的三元组共享同一个上下文索引 CTX {} for q1, q2, q3 in itertools.product(range(-4, 5), repeat3): if q1 0 and q2 0 and q3 0: continue t (q1, q2, q3) n (-q1, -q2, -q3) if t not in CTX: CTX[t] len(CTX) 1 CTX[n] CTX[t]CTX 字典长度是 728因为正负两个方向的键映射到同一个索引实际上下文是 364 个加平坦区索引 0总共 365 个。查询时先做符号归约再查表CTX里存的就是归约后的索引。3.2 主编码循环五个数组怎么协同编码主循环里维护五个数组初始值和更新策略如下解码端必须逐字节复现这套更新数组作用初始值更新策略A[ctx]绝对误差累积1加上 |bounded|N 到 64 时减半B[ctx]有偏误差累积0加上 bounded同步减半C[ctx]预测修正量0bounded ≤ -N 时减 INCREbounded 0 时加 INCREN[ctx]上下文计数1自增到 64 时减半并修正 A/BK[ctx]Golomb 参数0由 (N k) A 循环推导def jpegls_encode(img): pixels np.asarray(img, dtypenp.int32) h, w pixels.shape MAXVAL 255 RANGE MAXVAL 1 # 重建平面四周补 0省掉行列边界判断 rc np.zeros((h 1, w 2), dtypenp.int32) A np.ones(365, dtypenp.int32) B np.zeros(365, dtypenp.int32) C np.zeros(365, dtypenp.int32) N np.ones(365, dtypenp.int32) K np.zeros(365, dtypenp.int32) bw BitWriter() bw.put_bits(w, 16) bw.put_bits(h, 16) inc max(1, RANGE // 32) for y in range(h): for x in range(w): a int(rc[y 1, x]) # 左邻 b int(rc[y, x 1]) # 上邻 c int(rc[y, x]) # 左上邻 d int(rc[y, x 2]) # 右上邻 q1 quantize(d - b) q2 quantize(b - c) q3 quantize(c - a) ctx, sign 0, 1 if q1 ! 0 or q2 ! 0 or q3 ! 0: # 首个非零分量为负时翻转符号归约到对称上下文 if q1 0 or (q1 0 and q2 0) or (q1 0 and q2 0 and q3 0): q1, q2, q3 -q1, -q2, -q3 sign -1 ctx CTX[(q1, q2, q3)] px med_predict(a, b, c) C[ctx] px max(0, min(MAXVAL, px)) err (int(pixels[y, x]) - px) * sign bounded max(-RANGE 1, min(RANGE - 1, err)) # 误差映射为非负整数负误差变奇数正误差变偶数 merr -2 * bounded - 1 if bounded 0 else 2 * bounded # Golomb-Rice 写码 k K[ctx] q merr k if q GLIMIT: for _ in range(q): bw.put(0) bw.put(1) if k: bw.put_bits(merr ((1 k) - 1), k) else: for _ in range(GLIMIT): bw.put(0) bw.put_bits(merr, 16) # 上下文统计更新 A[ctx] abs(bounded) B[ctx] bounded if bounded -N[ctx]: C[ctx] - inc elif bounded 0: C[ctx] inc N[ctx] 1 if N[ctx] 64: A[ctx] 1 B[ctx] 1 N[ctx] 1 while (N[ctx] K[ctx]) A[ctx]: K[ctx] 1 # 无损路径重建值就是原像素 rc[y 1, x 1] int(pixels[y, x]) bw.flush() return bytes(bw.buf)几个关键点展开说。第一重建平面补了一圈 0首行首列自动当作 0 邻居不用在循环里写边界分支参考实现里那堆if (x 0)就是为了省这圈内存教学版不学它。第二err乘上sign之后再做编码和统计这样对称上下文里的误差分布是统一的C[ctx]负责修正系统偏差sign由解码端从同样的梯度三元组还原。第三N到 64 时全体减半让统计量能跟上局部内容变化避免旧的统计拖累新区域。3.3 游程模式的触发条件与写法梯度全零的上下文ctx0说明局部非常平坦逐像素走 Golomb 编码是浪费标准做法是切到游程模式连续数多少个像素等于当前重建值a把长度单独编码。# 仅示意主循环里梯度全 0 时先数游程 if q1 0 and q2 0 and q3 0: run 0 while (x run) w and pixels[y, x run] a: run 1 encode_run_length(bw, run) # 标准用专门的游程 Golomb 编码 x run continue标准里游程可以跨行遇到游程中断时还有一个中断残差要单独处理教学版只在本行内数。参考实现 LOCOI.C 里最绕的一段就是常规模式和游程模式的切换逻辑理解了先判梯度全零再进 run 分支这个结构读源码就不会迷路。3.4 命令行跑起来pip install pillow numpy python jpegls_min.py lena_gray.png out.jls输出格式是 16 位宽、16 位高、裸位流末尾打印压缩比。对自然照片这个教学版大致和 PNG 打平或略好要拿到标准 JPEG-LS 那 20% 到 40% 的优势需要补上游程模式、射程限制和完整的 NEAR 逻辑工程上直接用 CharLS 更划算。运行后对一张平滑渐变图可以看到明显小于 PNG 的输出——这正是预测加游程对低复杂度图像的效果。4. 实测与选型JPEG-LS、PNG、JPEG 2000 无损模式到底差多少选型不能靠感觉把三种编码器放在同一组图上跑看压缩率和数量级。这里给出可复现的对比脚本读者在自己的图像上跑一遍结果更可信。4.1 同一组图跑三种编码器import io, sys from PIL import Image from jpegls_min import jpegls_encode for path in sys.argv[1:]: im Image.open(path).convert(L) raw im.width * im.height buf io.BytesIO(); im.save(buf, PNG); png len(buf.getvalue()) buf io.BytesIO(); im.save(buf, JPEG2000, losslessTrue); j2k len(buf.getvalue()) jls len(jpegls_encode(im)) print(f{path}: raw{raw} PNG{png} J2K{j2k} minJLS{jls} JLS/PNG{jls/png:.2f})Pillow 的 JPEG 2000 无损走losslessTrue参数后端依赖 openjpeg个别发行版编译时没带会直接抛异常先确认PIL.features.check(jpg_2000)。本节的minJLS是我们的教学版比标准 JPEG-LS 略差下面的表格给的是标准 JPEG-LSCharLS 实测在公开基准上的典型区间方便对照数量级。图像类型PNG (bpp)JPEG 2000 无损 (bpp)JPEG-LS (bpp)自然照片8 位灰度5.0–6.53.8–4.53.9–4.6平滑渐变、截屏0.5–2.01.2–2.50.3–1.2传感器噪声图6.5–7.56.2–7.26.4–7.4结论很清楚自然照片上 JPEG 2000 和 JPEG-LS 互有胜负差距通常在 5% 以内PNG 明显落后。平滑图、扫描文档、医学断层这类图JPEG-LS 的游程模式优势巨大能比 JPEG 2000 再省 20% 以上。高噪声图大家都差不多预测和变换都失效时熵编码的天花板就在那里。4.2 差距从哪里来预测、变换、字典三条路线PNG 是每行选一个滤波器做一维预测再交给 DEFLATE 字典压缩上下文能力弱但实现遍地都是。JPEG 2000 无损用 5/3 整数小波加 MQ 算术编码建模能力强代价是计算量和内存大约高一个量级。JPEG-LS 用局部上下文加游程复杂度贴近 JPEG baseline压缩率贴近 CALIC是三者里性价比最极端的一个。如果业务上听到 JPEG XS注意那是另一条路线ISO/IEC 21122面向实时传输的视觉无损编码追求的是低延迟和低复杂度不保证像素级还原。JPEG-LS 要的是绝对无损JPEG XS 要的是人眼看不出区别需求不同不要放进同一个选型池。4.3 NEAR 参数从无损滑向近无损JPEG-LS 原生支持近无损参数 NEAR 表示单像素最大允许误差。NEAR0 是无损大于 0 时预测修正、误差映射、重建值计算全部按 NEAR 联动所以解码端必须知道 NEAR 才能重建图像。经验取值如下NEAR单像素最大误差适用场景相对无损的压缩收益典型00医学存档、遥感原始数据基准1±1传感器噪声预处理10%–20%3±3显示预览、缩略图25%–40%7±7移动端浏览40%–60%NEAR3 时 PSNR 通常还在 45dB 以上人眼基本无感但压缩率能再压掉三分之一。调参时从 NEAR0 起步逐步加 1观察 PSNR 和体积的拐点医疗场景建议压到 NEAR1 为止超过 3 就回不去了。5. 工程落地的三件事CharLS 集成、MCU 硬编解码误区与无损校验5.1 CharLS 集成与 DICOM 传输语法CharLS 是 JPEG-LS 最常用的开源 C 实现医学影像里的事实标准。经典 C 接口用法如下参数集中在JlsParameters里#include charls/charls.h JlsParameters params {}; params.bitsPerSample 8; // 每个分量位深 params.components 1; // 灰度图RGB 传 3 params.interleaveMode ILV_NONE; params.nearLossless 0; // 0 无损0 近无损 int err 0; size_t dstSize /* 先传 0 查询目标长度再分配 */; JlsEncode(dst, dstSize, src, srcSize, params, err);两个注意点nearLossless不会写进位流存文件或在 DICOM 里归档时必须随图像保存DICOM 封装 JPEG-LS 有专用传输语法UID含义1.2.840.10008.1.2.4.80JPEG-LS 无损NEAR01.2.840.10008.1.2.4.81JPEG-LS 近无损NEAR0和 PACS 对接时这两个 UID 直接决定读端用多少容差去重建写错一个字节解出来就是花的。5.2 STM32H743 的硬件 JPEG 模块压不了 JPEG-LSSTM32H743 内置的硬件 JPEG 编解码器只支持基线顺序 DCT也就是常规 JPEG完全不认 JPEG-LS 的预测加游程结构。别在硬件 JPEG 加速的思路上找 JPEG-LS 的捷径硬件模块帮不上忙。正确的做法是跑软件实现JPEG-LS 参考实现本身是分行流式的不需要整帧缓冲RAM 占用大概是O(行宽)加几张上下文表H743 的 RAM 完全够。裁剪时优先砍掉近无损分支和跨行游程保留常规模式压缩率损失很小。5.3 无损校验解码闭环与逐像素比对收尾前必须验证无损链路。给教学编码器配一个最小解码器解码逻辑和编码完全镜像同一个 MED、同一个上下文更新、同一个K[ctx]推导。class BitReader: def __init__(self, data): self.data data self.pos 0 self.nbit 0 self.cur data[0] if data else 0 def get_bit(self): b (self.cur (7 - self.nbit)) 1 self.nbit 1 if self.nbit 8: self.nbit 0 self.pos 1 self.cur self.data[self.pos] if self.pos len(self.data) else 0 return b def get_bits(self, n): v 0 for _ in range(n): v (v 1) | self.get_bit() return v def decode_golomb(br, k): zeros 0 while zeros GLIMIT and br.get_bit() 0: zeros 1 if zeros GLIMIT: return br.get_bits(16) return (zeros k) | br.get_bits(k)解码循环里逐像素读出merr反推bounded乘上上下文符号还原误差重建值写回rc平面供后续预测。无损路径下重建值等于原像素所以编码端直接拿原像素填rc解码端拿重建值填两个平面最终一致。python jpegls_decode.py out.jls dec.png python - PY import numpy as np from PIL import Image a np.array(Image.open(lena_gray.png).convert(L)) b np.array(Image.open(dec.png).convert(L)) print(lossless OK if np.array_equal(a, b) else MISMATCH) PY这个逐像素比对只对教学格式有效。接 CharLS 做无损链路验收时更省事的做法是对原始像素数组和重建像素数组分别做 sha256比对哈希而不是一张张存图近无损场景再补一个 PSNR 计算脚本mse np.mean((a.astype(float) - b.astype(float))**2)PSNR 低于预期时回头查nearLossless参数是否在编码和解码两端用了同一个值。这套哈希加 PSNR 的双通道校验落地到 CI 里就是最基础的无损回归测试。本文还有配套的精品资源点击获取
返回列表