小心BW的数字水印

前文的一个小小的补充。

在评论区有人提出用上文所述的脚本提取出来的封面和试阅版的封面hash不一致。我一开始没当回事,说不定两张图根本只是单纯在服务器压缩了2次而已,hash不一样的可能性太多了。不过为了谨慎起见,还是测试了一下。没想到一测,就发现还真的有点东西。

留言的网友询问的是 BW 台湾站,那就拿台湾站来测试。现在角川所有的服务器都已经升级到了最新版的JS,所以直接改下US的@match就行。

检查无混淆的图

我随便找了本漫画,先是下载了试读版——试读版和正式版不同,是所有页面都没有混淆的,所以其实你控制台直接下载也行,和用我的脚本是一样的。因为试读版不登录就可以查看,所以应该是没有什么账号信息的。

然后使用网友提供的账号下载同一本作品的完整版——但是我偷懒没有下所有页数啦,得益于脚本的更新,现在可以选择页数了,我就下了前10页。

和BW日本站一样,封面是无混淆的图,后面的页面混淆。我们的重点是这个没有混淆的cover页。理论上,这个图应该和试读版一样,然而两者的文件大小错了有整整20KB。

那么就是一系列的对比啦,我用了下面这些步骤。

第一步最简单的,对比图像数据。我手头有一个我自己写的Py小脚本来对比两张图的像素,也可以用拖进PS->两个图层叠加->计算差值->合并->查看对比度的笨方法。嗯,两者确实是逐像素相等的。

那么第二部就是打开XnView MP查看元数据:

Image

(我一般properties和ExifTool都看一遍)也没有任何区别。

于是我打开Beyond Compare想直接二进制对比,结果发现两者有巨大差别,打了个我个措手不及。明明逐像素相等,为什么图像数据的部分字节也对不上?没什么头绪的时候,突然想起之前研究JPEG spec的神器,JPEGsnoop,赶紧掏出来。

一比就发现了为什么二进制错那么多:原来两张图的霍夫曼表完全不一样——一个“优化”了(正式版),一个没有优化(试读版),具体区别可以查看旧文。难道这就是两者的唯一区别了吗?在我即将关闭JPEGSnoop之时,看到一个非常重要的信息:

Image

好家伙,在EOF后面藏东西,那基本可以猜到是啥了:

Image

基本不用猜,这32bytes 的数据肯定是用户的ID了。我赶紧测试了下其他的图,结果很意外地发现那些混淆后的图反而没有这串水印——不过确实也没啥意义吧?毕竟你加什么水印只要不是加到图像内容里,被我们重新拼图之后都消失了。

我又进行了以下测试:

  • 用同账号下载别的完整版书籍——封面图依然有水印而且ID一致;
  • 用我自己申请的新账号购买了同一本书——果然水印ID不一样。

那么基本可以99%肯定这就是角川加进来用来反查用户的数字水印了。而且这种加法很容易,服务器给文件的时候直接在文件后面append就行。他服务器也不需要存多份文件。

移除水印

移除这水印很简单,可以直接二进制删掉最后32 bytes。如果不熟悉二进制操作,我测试了下XnView MP自带的清理元数据功能:

Image
Image

其实这里勾啥都行,因为任何操作都会导致他重新生成一次JPEG文件架构,从而删掉EOF后面的多余字节。这里如果勾选第一个优化霍夫曼列表,最后出来的文件大小就是类似于完整版,否则就是类似于试读版。我们正好可以验证一下,圈中所有三个文件(试读版,账号1的完整版,账号2的完整版)clean一波,出来的三个文件 md5 完全一致。

当然,你也可以直接再存成PNG,那肯定啥水印都没了。虽然没必要,徒增体积。

混淆过的图的检查

上面已经说过混淆过的图并没有这个二进制的ID水印。不过内容我们还是检查下吧。检查很简单——先把账号1和账号2的图都解码,然后互相对比:结果是逐像素相等。那就说明没有任何可以识别出下载者账号的内容水印

我然后和预览版的对比——预想来说是不可能逐像素相等的,因为毕竟服务器给的资源是把原图(JPEG)拆成32px的块之后混淆后再存了一次JPEG,那自然有新的JPEG artifact引入。虽然我们还原时是用了bmp/png没有再引入新的JPEG artifact,但是也无法去掉第二次的。

结果一对比还挺出乎我意料的:居然除了最右边一排别的都是逐像素相等?怎么做到再存了一次JPEG没引入新的JPEG artifact的?如果认真学习过(误)我之前写的JPEG spec文章的应该就懂,JPEG的最小编码单元(MCU)是8×8的,所以是可以以最小8×8的尺度对原图进行无损重新组合的(或者旋转)。而角川的混淆的块儿是32×32,所以完全可以。没想到角川居然真的用到了JPEG的这个特性,混淆图片时直接是对原始JPEG流的MCU进行的swap操作!太低估他们了。

这还意味着什么呢?我们目前在 Python 里进行的还原混淆的canvas操作,其实也完全可以采用直接交换JPEG流的MCU的方式,来实现完美还原原始JPEG——好吧应该说90%完美,因为边缘非8的倍数的部分还是没法100%还原,这部分在角川生成混淆JPEG的时候已经给padding到了8的倍数了(也因此引入了二次压缩,所以上面也观察到最右边一排还是没能逐像素相等)。

Hmm,想了想意义很小而且好像很麻烦,就懒得折腾了。留作以后的课题。

结语

虽然上面都是说台湾站,但是日本站也是一样的,至少我随便看了下,无混淆图也是有ID的。

虽然我自己是无所谓,因为我dump BW全都只是自己收藏而已,但是如果要拿出去分享尤其是大范围分享,那确实得小心一点了。我更新了下之前的bw.py,现在可以自动移除数字水印了。

关于JPEG的那点事儿 Part 2:JPEG原理

前言

本文其实于差不多正好1年前写成,是关于JPEG的那点事儿的补充。但是由于实战篇一直烂尾,拖到现在。前几天看到Google发了个JPEG新算法,说是可以将JPEG的体积同质量情况下再压缩35%,突然想起了这文了。为了说清楚Google为什么能在古老的JPEG上压榨出新的空间,我觉得还是有必要先讲清楚JPEG的原理。但是本文成文之后实在太长,所以我想了想还是把和Google算法相关的、以及一个TL;DR版的JPEG原理单独发文(大概明天8点发w)。另外,前面提到的“实战篇”也会分割放送,减少文章长度。

序言

有一位朋友看了上文后问到,为什么步进(progressive)JPEG可以提高压缩率?

严格来讲,步进(Progressive)和交错(Interlacing,虽然“交错”是最常用的翻译,但是我是在无法完全理解这两个字的汉字想表达啥…)并不是一个概念。要讲步进,得先讲讲交错。

交错指的是图像解码时(以及存储时)并不是按照某个逐个像素依顺序解码——而是采用跳跃的方法:例如将整个图像分割成九个区域,先解码出每个区域的大体形状,然后再逐步解码细节。一般而言,这样的层级解码会分不止2层,例如在PNG使用的Adam7算法中,一共有7个子图像会被存储起来。维基百科上这张图可能会更直观一些:

https://upload.wikimedia.org/wikipedia/commons/2/27/Adam7_passes.gif

(From Wikimedia Commons)

这种方式的好处是,在图像加载过程中,图像会由模糊(准确地说是马赛克状)逐渐变清晰,而不是从上到下一行行地显示。这样在图像加载中途读者就可以对图像大概有个概念,而不是只能看到上面完全看不到下面:观感上加载速度会变快,而且也更方便一些。

你肯定要问了,这么做不是相当于在原图上在集成几个不同尺寸的略缩图,体积不增加就不错了,怎么会减小呢?事实上,对于其他图片格式,例如PNG和GIF,如果开启“交错”选项,确实图像会变大。但是对于JPEG的具体实现,所谓的“ progressive”,情况又不太一样。用IJG官方的FAQ里的话说“Basically, progressive JPEG is just a rearrangement of the same data into a more complicated order.”。但是具体技术上的实现方式是什么?

要说清这事儿可能还真得从头说一下JPEG的压缩过程。既然要讲,那就讲的详细一点。接下来我将把JPEG压缩的每个细节步骤(除了DCT的数学原理,这个我真不行……)都讲清楚。如果只是想了解大概,维基百科就写的不错了:但是如果真想做到自己写一个解码编码器的程度,有些细节不厘清还真不行。

JPEG编码基本原理

在DCT之前

JPEG编码主要分成三步,DCT、量化以及无损压缩。不过,在DCT之前,还要先色彩空间转换和色度抽样。色彩空间转换干的就是将RGB转换成YCbCr——即将亮度(Luma)和色度(Chroma)分离开,其理念是人眼对亮度的变化远敏感于色度变化等一大套感知视觉理论,这里不再赘述。顺便一提,色彩空间转换并不是完全无损的——因为转换前后都是整数,自然不可避免会有舍入误差。

转换之后,既然我们知道亮度更敏感,那就有做文章的空间。所谓色度抽样,就是对色度的部分进行抽样/缩小。主流的抽样方法有4:2:2、4:2:0,在JPEG的语境下更多叫做2×1和2×2,前者指水平分辨率抽样一半,垂直不变,后者指水平垂直各抽样一半。完全无抽样的叫做4:4:4,或者1×1。至于具体抽样缩图的算法JPEG里好像没有定义,一般都是直接将相邻两个像素求个平均了事(这里可能会导致图像处理界另一个著名的历史遗留问题:线性vs非线性色彩空间,以后抽空再单独写一下)。

抽样完毕后,终于可以进行到真正的编码部分了。JPEG压缩时,先将原图分割成8×8的block进行编码,又叫“最小编码单元(Minimum Coded Unit,MCU)”。当然,如果你有用色度抽样,MCU的大小也会相应放大。例如,如果你用了4:2:0的抽样,那么MCU就会变成16×16——但是Cr和Cb的实质大小其实依然只有1个8×8,只是塞进去了4个8×8的Y罢了。在编码的时候,每个通道也是分开的,所以这样的MCU可以理解成6个不同的blocks就行(但是压缩完之后的数据顺序又有讲究,后叙)。下面,我用“block”来特指单个通道的8×8的单元,来和MCU区分。

DCT

接下来,我们要对每个block的像素值(8bit图像就是0-255了,接下来全部以最常见的8bit为例。JPEG标准额外支持12bit图像,但是主要用于医疗领域,普通情况极少有人用)进行偏置128之后(使其集中在0两侧,加快DCT运算)做2D DCT转换成频域。转换出的结果依然是一个8×8的矩阵,只不过每个数据点代表的是不同频率(准确地说是一组不同的pattern(见下图))的强度:左上是低频,越往右下越高频。

File:Dctjpeg.png

(From Wikimedia Commons)

所以DCT说白了就是把原图分拆成这些pattern的线性叠加。其中左上角的DC分量,可以近似理解为整个block的强度均值,剩下的则是高频(或称AC)分量。

量化

DCT这步数学上来讲(不考虑舍入误差)是可逆的,真正的有损编码的是下面的量化步骤。量化在这个语境下其实就是拿一个预设的系数矩阵(量化表)去逐一除之前得出的DCT矩阵——介于人类对低频比较敏感,细节可以适当丢失,这个表的原则是越往左上系数越小,越往右下系数越大。至于具体的数据,据说都是实验出来的,不同的软件可能不同。主流的libjpeg根据JPEG标准的推荐,提供了一套0(1?)-100质量分别对应的表,可能也是最常见的系数。当然,你也可以自定义系数,例如Photoshop内置的量化表就和别的软件大多不一样。ImpulseAdventure的作者提供了一份非常详尽的市面上常见软件、相机的内置系数表。表格里数据的总体大小决定了JPEG的质量——系数越大,质量越低。高质量的表可能系数都只有个位数(事实上,100%质量的量化表全是1),而低质量的,例如拿一个JPEG 50%质量的量化矩阵来说,左上角的DC分量的除数有16,而右下区的甚至高达100左右。想象一下去拿这个表去除DCT矩阵,除出来的结果再近似到整数,考虑到右下的高频AC分量本来强度就不高,除以100之后基本都肯定小于0.5了,也就是会被约成0。这么搞下来,整个表就会变成一个右下区几乎都是0,而其他区域数值也很小的矩阵。

Y和Cb/Cr会有不同的量化表(可以猜到,色度那张压缩更狠),这个表会被嵌到JPEG文件的头部中。通过表格的数据可以估算JPEG的当时编码时的质量。

无损压缩

之后就到了无损压缩的部分,也是整个JPEG编码中最麻烦的部分。首先又得学个新词儿——Interleaving。这个一般也翻译成“交错”……但是和上面提到过的Interlacing不是一回事。我们前面知道,每个MCU有少至3个、多至6个8×8的blocks。编码的时候,我们既可以按照MCU分类,一次编码完整个MCU的所有block再进行下一个、也可以采用别的方式,这里按下不表。不过,最常用的、baseline的方法是先按MCU归类,然后按照一个固定的顺序读取。如果是无色度抽样的3 blocks MCU,那就是YCbCr的顺序,如果有多个Y,那就是Y00/Y01/Y10/Y11(左上,右上,左下,右下)/Cb/Cr的顺序。

sequence_2x2

(From ImpulseAdventure)

这种“不停地在不同components交替取数据编码”的方式(components在这个语境下就是不同的通道,Y、Cb和Cr都分别是一个component)就叫做“interleaving”,这样的JPEG叫做interleaved JPEG。可以看到,绝大部分的baseline JPEG都是interleaved的。

让我们回到每个block里面。首先要将我们的64个分量1D化。我们这里并不是按行或者列的顺序排队,而是通过斜对角蛇形的方式,从左上逐渐跑到右下。

600px-jpeg_zigzag-svg

(From Wikimedia Commons)

其原因是:越靠近左上的频率越低,量化压缩之后也越可能不是0,这样排序之后便于下面的游程编码(RLE,run-length encoding)进行。

游程编码

所谓RLE,是一种无损压缩高重复率数据的算法,还是直接引用维基的例子好了:“举例来说,一组资料串”AAAABBBCCDEEEE”,由4个A、3个B、2个C、1个D、4个E组成,经过变动长度编码法可将资料压缩为4A3B2C1D4E(由14个单位转成10个单位)。”

至于JPEG中的实现说起来则比较麻烦。简单概括,对于AC分量,我们只描述非零的强度和他们的位置。具体来讲,就是把非零的coefficient转换成“前置0的数量+强度的比特数”的分类单元+具体强度的形式。而强度是0的AC,自然被包含在那“前置0”里面了。这样说可能太抽象了,举个例子。假设我们的AC1和AC3(分别是矩阵第一行第二排和第三行第一排)分别是-2和3,而AC2是零。那么,AC1就会被表示成

(0,2)(-2)

(0,2)乃是分类单元(categories),0表示前面有0个0(毕竟这是第一个),2表示后面跟的数据(-2)需要用两位(2bit)才能表示(下述)。同理,AC3前面有1个0(AC2),那么就会被表示成

(1,2)(3)

或者我们把前面俩改写成16进制,共同占用1个字节:因为后面的数据不会超过15位(F)(实际上不会超过14位,高位F的部分除了F0特殊定义之外,并用不到),零的数量虽然确实会超过15个,但是我们特别指定(15,0)(0xF0,有的地方称作ZLF,“zero run length”的意思)为用来表示16个连续的0。如果某个block最后部分全是零,可以提前输出(0x00,End-of-Block,EoB,无需跟强度值)来结束这个block。

DC分量处理

对于DC量,首先我们用和前一个block的DC分量的差分的方式来记录——这样做可以节省一部分体积,因为图像大多是连续的,相邻block的DC分量一般相差不大,这么操作可以大幅度降低DC分量(一般是最大的一个数,也需要最多比特)的比特长度。这种编码方式叫做Differential pulse-code modulation

另外,我们也要像AC一样,对每个强度量先指定其位长的分类单元(否则我们怎么知道读到哪里算完呢?):0表示该DC量是0、1表示该DC量的数据只有1位、2表示有两位、……直到15(或者F),然后再写我们的数值。例如,如果一个DC量是15,那么就会被表示成

(4)(15)

即(位长)(数据)的形式。因为15需要4位才能编码下(下述),所以是4。

编码为二进制及霍夫曼编码

接下来,我们要进行最后的终极编码,也就是将上面这一堆劳什子转换成二进制。

对于强度的部分,很简单,按照之前分配好的位数转换即可。但是别忘了,我们的数值是有符号整数,要转成无符号的0和1,所以要稍微偏移(shift)一下。如果数据是0,对于AC自然就直接跳过了,DC会被分配“0位”,也就是只有分类单元的部分,而并没有数据。分配1位时,0代表-1,1代表1;分配2位时,用00表示-3、01表示-2、10表示2、11表示3,以此类推。这个编码方式是JPEG标准指定的,所有JPEG都一样,不能自定义。其实,它是用补码(二补数)推算出来的:如果数据是正的,那么就取补码最后N位(位数前面分配了);如果数据是负的,就取补码最后N位再减一。举例子的话,15被分配4位,15的补码是0…00001111(具体有多少个前置零取决于你的比特数,但是这里不影响),取最后四位就是1111;如果是-15,补码是1..1110001,取最后4位再减一就是0000。到这里也应该看出来了,我们的位数也不是随便分配的,说白了就是对于绝对值处在[2^N, 2^(N+1))之间的数,我们分配N位来表示。具体可以参照这文中的Table 5。

不过对于前面的分类单元部分,则不是直接转换成二进制就算完了,我们要充分利用霍夫曼编码的方法进一步压缩。如果有不了解霍夫曼的,其实就是简单地重排数据编码方式,出现频率高的用更少的比特编码,频率低的用更多的来编码,并最终得到一个总比特数更短的二进制码。如果我们不用霍夫曼,可以看到上面的分类单元对于AC量每个有8位,对于DC也有4位。霍夫曼编码后,其长度变成2至十几位不等。

JPEG默认就是启用霍夫曼的,其标准中也有个推荐的霍夫曼码表。但是和量化表一样,你也可以自定义——事实上,有人就发现,Photoshop就有一套自己单独搞的霍夫曼码表,而且根据JPEG的质量不同还稍有不同,以求达到最佳的压缩效果。另外,DC和AC、Luma和Chroma都可以分别使用不同的霍夫曼表,也就是说一般会有四张霍夫曼表。该表显然也会和量化表一样存在header中,否则无法解码。

不过,最优的霍夫曼(在JPEG文件结构的限制内。有研究称JPEG的霍夫曼从设计上就不可达到最优)自然是对于每个JPEG单独统计每个分类单元出现频率然后构建霍夫曼表了:——这也就是一般软件保存JPEG时的“优化霍夫曼”或者“Optimize”的意思了。不过很显然,这样做会要求先对所有MCU进行一遍扫描,自然会降低编码速度(也需要更大的buffer,这点在设计encoder的时候需要注意)。

最后,这些纯二进制的数据会按照MCU1-Y-DC、MCU1-Y-AC、MCU1-Cb-DC、MCU1-Cb-AC、MCU1-Cr-DC、MCU1-Cr-AC、MCU2-Y-DC……的顺序拼在一起。整个数据块必须结束在整字节里,最后不足的部分补1。另外一个特殊之处在于,如果数据中某个字节出现了0xFF(1111 1111),为了防止和JPEG的marker(标示各个组成部分开始的标示)混淆(全部以x0FF+非0字节组成),会加入padding 0来改写成0xFF00。

讲到现在,终于把普通的baseline、sequential的JPEG编码原理讲完了。

Baseline?Sequential?

插播一段:关于“baseline”的定义,其实非常含糊。根据ITU的standard的术语表,baseline其实是和“extended”相对应的,而不是progressive——sequential才是。“extended”是指每次Scan(下述)可以有高达8张霍夫曼表、并且每个component的可以是12位等等的扩展格式(极为罕见)。但是,在统一标准中后文中又出现了baseline和progressive的相对应……总而言之,连ITU自己的文档里术语都不是很严谨。在民间使用时,baseline多用来和progressive相对(虽然sequential更合适一点),这点要搞明白。

步进(Progressive)JPEG

我们还完全没提到最重要的步进(progressive)到底是怎么回事。不过看到上一段我们应该能想到,由于DCT的特性,高低频的数据都已经分离出来了。如果我们在存储时并不是按照完全按照MCU的顺序,而是先把DC和一些序号较小的AC分量挑出来先存储,这样加载的时候不就可以做到从模糊到清晰的效果了吗?没错,这就是progressive JPEG的基本原理了。而且,这样做我们只是调整了同样数据的位置而已,理论上并不会增加体积。

在具体实现上,得先讲一下Scan的概念。JPEG中经过DCT和量化之后的那堆系数(就是那些8×8矩阵),可以分为多成多个Scan来保存——每个Scan中只分配、存储部分数据。具体的分配方式,可以分为三种:

  1. 按照component分开。还记得上面提过的interleaved的概念吗?一般的JPEG,是将三个components(Y、Cb、Cr)全部混在一起编码的。你也可以全部分开——每个Scan只处理一个component。
  2. 按照8×8 block中的序列号(依然是蛇形顺序)分开。
  3. 按照编码后(二进制)的强度量的比特位置分开。

其中,后两个又称作“progression”,采用这种方式来分scan的JPEG就是progressive scan了。

Spectral selection

采用第2种方式的,叫做“Spectral selection”。例如,我们可以单独压缩0(DC),然后是AC1-AC6,然后剩下的AC7-63再一起。这样在传输图像时,DC部分在第一个scan就会扫描到,后面的慢慢读取。不过具体分配方式并不是任意的,有以下规定(ITU T.81 G.1.1.1.1):1)DC和AC必须分开;2)只有DC的Scan可以是interleaved(包含多个components,也就是色度亮度一起编码),AC的Scan必须是只含有一个component。所以,更现实的分配方式可以是

# Interleaved DC scan for Y,Cb,Cr:
0,1,2: 0-0, 0, 0 ;
# AC scans:
0: 1-2, 0, 0 ; # First two Y AC coefficients
0: 3-5, 0, 0 ; # Three more
1: 1-63, 0, 0 ; # All AC coefficients for Cb
2: 1-63, 0, 0 ; # All AC coefficients for Cr
0: 6-9, 0, 0 ; # More Y coefficients
0: 10-63, 0, 0 ; # Remaining Y coefficients

这个范例引用自 libjpeg-turbo的wizard.txt,这也是cjpeg.exe支持的scan file的格式。其中的0,0部分,下面马上提到。

Successive approximation

采用第3种的,叫做“Successive approximation”(有的地方叫做Successive renement,而把两者共用叫做Successive approximation……没错就是这么混乱)——所谓按照比特位置,就是把每个分量系数(coefficient)的二进制强度(或者称值)按照高位低位分开。例如,假设我们的值都是8 bit,我们可以在第一次scan只传输前7位,最后一次scan再把最低一位(Least significant bit,LSB)传输——可想而知,其效果就是图片精度逐渐变高了。上图的0,0部分的意思就是没有successive approximation。

这么说可能还是觉得有点迷茫,ITU T.81标准中的这张图可能是最直观的了:

itu-t81 124

(From ITU T.81)

Spectral selection和Successive approximation可以结合起来一起用。例如,cjpeg默认的-progressive采用以下这样的scan file:

# Initial DC scan for Y,Cb,Cr (lowest bit not sent)
0,1,2: 0-0, 0, 1 ;
# First AC scan: send first 5 Y AC coefficients, minus 2 lowest bits:
0: 1-5, 0, 2 ;
# Send all Cr,Cb AC coefficients, minus lowest bit:
# (chroma data is usually too small to be worth subdividing further;
# but note we send Cr first since eye is least sensitive to Cb)
2: 1-63, 0, 1 ;
1: 1-63, 0, 1 ;
# Send remaining Y AC coefficients, minus 2 lowest bits:
0: 6-63, 0, 2 ;
# Send next-to-lowest bit of all Y AC coefficients:
0: 1-63, 2, 1 ;
# At this point we’ve sent all but the lowest bit of all coefficients.
# Send lowest bit of DC coefficients
0,1,2: 0-0, 1, 0 ;
# Send lowest bit of AC coefficients
2: 1-63, 1, 0 ;
1: 1-63, 1, 0 ;
# Y AC lowest bit scan is last; it’s usually the largest scan
0: 1-63, 1, 0 ;

可以看出,整个过程有高达10个Scan。第一个Scan输送所有component的DC分量(除了最后一个bit,即LSB);然后输送Y通道的前5个AC分量,不过不包含最后两个bit;接下来是Chrma的所有AC分量,但是不包含最后一个bit(如上所述,在progressive中除了DC分量其他的都不允许interleaving,所以分了两次scan;而且,这里选择了先scan了Cr,因为人眼对Cb最不敏感)。再接下来是Y通道的后面6-63个AC分量,依然不包含最后两个bit。再下来是Y通道所有AC分量(1-63)的倒数第二个bit。

最后4个scan就是重复上面的顺序将所有的通道、分量的最后一个bit给传送了。

其他细节

至于在具体实现方式上,Spectral selection倒是蛮好理解的,每处理到某个MCU只要只处理其中一部分分量就是了。但是由于每次Scan现在只含有少数几个分量,对于编号比较大的的高频(例如:6-63)可能会有大量block完全是0。为了进一步节省字节,在EOB的基础上,我们又重新定义了一组EOBn控制符,来表示之后n个block都是空(纯0)的。当然这些控制符也会被霍夫曼编码了,就像EOB和ZLF一样。

Successive approximation说起来就有点复杂了。如果上面的scan file有仔细看,就会发现每个部分(DC或者每个component的AC)第一次Scan发送的比特数不一,但是之后每次Scan都只输送一个bit。这也是JPEG的规定。对于DC,很容易理解:假设我们某个MCU的某个component是7好了,那么二进制就是111,三位。如果我们分成两次Scan,第一次只传送前两位——也就是11,那么很显然,其“分类单元”应该是0x02;在第二次Scan的时候,因为很显然只有一位,那么分类单元那部分就可以省略掉了,直接把最后的bit补齐即可。

结语

那么,回到最开头的问题,为什么用progressive模式,就会体积减小呢?这里我没有一个确定的答案(…),但是可以看到,和DC/AC全部interleave在一起的Sequential模式相比,其最大的优势是把各个MCU的相似的分量都排在了一起;可以想到,这么做绝对有利于含有预测性质的游程编码,乃至后面的霍夫曼编码。另外,单纯把数据分成好几份(好几个Scan)然后设定不同的霍夫曼表这件事本身可能就能提升不少效率。

 

Word转PDF如何保障图像质量

Word文档转PDF大抵有以下几种方法:

  1. 用Word内置的另存为PDF;
  2. 用Adobe Acrobat、PDFcreator之类的软件带的PDF Printer打印成PDF;
  3. 用Adobe Acrobat等软件转换成PDF。

Word内置的另存为PDF

一言以蔽之,Word内置的转PDF尽量避免。不过,在研究过程中发现Word这个PDF另存为有很多吊诡的地方,所以详细谈一谈。

首先,图片分辨率大于等于一定PPI阈值的会被压缩到200。注意,这里的PPI是真实PPI——也就是说用图像的像素数你在Word里设置的尺寸,而不是图像metatag里的名义DPI。譬如说,你有个3000px宽的图名义DPI是96,但是你放到Word里设成宽度2″,那他的真实PPI其实是3000/2=1500。而你另存为PDF之后,这个图就会变成一个200*2=400px宽,外加metatag为200DPI的图。那么这个阈值是多少呢?想当然的话是200,然而并不是。经过简单的测试,阈值位于(250,300]区间,因为我测试了250PPI是不会被二压尺寸的,而300的则会被二压到200PPI。具体多少没有测,可能就是300。Word的缩放算法比较一般,估计是平均像素,也不算问题很大。

其次,保存成PDF时,无损的图有可能会继续保留为无损,JPEG则必然是JPEG(所以自然会2压)。为什么说可能?在我测试过程中,出现过以下现象:

  • 一张不会被二压的图用画笔涂了几笔之后(尺寸啊DPI啊完全不变,不过这会导致PNG文件大小增加),立刻就会被二压;
  • 某个图单独帖进Word文档存PDF不会二压,结果和另一张图一起就会全都被二压;
  • 同时帖进5张尺寸不一的PNG,会出现有些被二压,有些不被二压的现象。

我的推测是,二压与否首先与图像的文件大小(可能是绝对也可能是相对)有关。凡是超过一定阈值,就会被二压(这可以解释上面第一条、第三条);另外可能还和文件总大小或者每页大小有关:超过一定阈值就全部二压,这能解释上面的第二条。

其JPEG压缩率也偏高,通过JPEGSnoop看量化表,是75%质量的4:2:0色度抽样JPEG。顺便一提,任何JPEG图像,插入Word的一瞬间就会被二压(90%质量,4:2:2)。所以请尽量避免在Word中贴JPEG图片,尤其是图表之类的用无损效果又好体积又比JPEG小。

另外,我几乎可以很确定地说,Word PDF输出的图像质量/分辨率是无法直接更改的。这里谣言终结一下几个网上经常提的方法:

另存为时右下角的工具-压缩图片:

先厘清一点:这个工具只有在你点的时候才会使用——也就是说,默认是“不使用”状态,也就是不压缩图片(相当于这个工具里的最后一个选项)的状态,而不是你点开这个工具时的默认选项。另外,这个工具其实是独立于保存/另存为的,什么意思?就是说实际过程是先压缩图片,然后另存为/保存;而不是“保存时压缩图片”。这也产生一个问题:在你点下这个工具并且选了一个非最后一个选项并确定的时候,你的当前文档里的图片就被压缩了!哪怕你最后另存为框点的是“取消”,你的图片也已经压缩过了——而且无法Ctrl+Z!所以,千万要小心不要因此把你文档里的图片毁掉!不过MS估计也想到这个问题,如果你点了压缩图片又另存为PDF之后(或者取消另存为框后)你会发现你的图变糊了,这时候不进行任何操作立刻关闭该文档,那么恭喜你不会压缩你的图片。但是如果你之后又对文档进行了修改再保存,你Word里的图就全毁掉撸(当然你可以选择关闭不保存……那就变成你压缩图片之前没保存的修改全没了)。而且不要忘了,如果是JPEG,图片会被二压多少次你自己算算:

原始图JPEG->复制进Word(二压90%)->压缩图片PPI(缩图外加再二压一次90%)->另存为PDF(再二压75%)

这效果不用我说了吧,基本处于毛都看不见的状态,这里贴个样图:

word-%e5%b7%a5%e5%85%b7-150

总而言之,压缩图片这个工具慎用!如果一定要用,先保存你的所有修改然后备份你的文档!

选项-高级-图像大小和质量里的几个选项:

这里有俩选项有关,一个是“不压缩文件中的图像”,一个是“将默认目标输出设置为:”,选项有330、220、150和96PPI。

和上面的大杀器压缩图片一样,这个选项和保存PDF没直接关系。这个选项的实际作用是,如果你没有勾选第一个(也就是你想要压缩),那么每次你保存并退出该文档时,所有超过你选的PPI的图会被压缩成你选的PPI。这里(还有上面那个压缩图片)据我短暂的观察应该是不会有无损图变JPEG的劳什子,不过还是小心为妙。

我不清楚这个选项的默认状态是啥,不过我强烈推荐直接将“不压缩文件中的图像”勾上,完全不压缩你插入图像的分辨率。这样可以最大限度地保存图像的质量。真有需要,最后输出的时候再另存为一份修改该选项就是。

可以看到,这俩方法的本质都是直接修改你文档里图的分辨率,而不是仅仅修改输出的PDF里图片的分辨率。而且都有很大的局限性:依然无法解决75% JPEG的质量问题;依然无法输出大于200 PPI的图片。所以,想靠Word自带的另存为PDF来输出高质量图片的PDF是行不通的。这其实挺可惜的,因为据我观察对原始Word文档还原度最高的还是Word自带的另存为,Adobe家的多少有点出入(虽然很小啦)。

PDF打印机

PDF打印机算是一种比较万能的制作PDF的方法,这里也适用。下面以Adobe PDF printer为例,其他软件应该大同小异。

调整图像的质量是在打印-打印机-打印机属性处进行的。打开该对话框之后,在“布局”选项卡下方的高级里是可以调DPI,不过那个是给一般的物理打印机用的,这里没必要去那里改(而且那里改了也没用,那不是图片的质量)。直接进入第三个选项卡:

qq%e6%88%aa%e5%9b%be20161104002615

这里第一个Setting就是选质量了。点“Edit…”里面有详情:

qq%e6%88%aa%e5%9b%be20161104002838

这里展示的是自带的“High Quality Print”选项(Maximum=92%质量,4:4:4无色度抽样),其实一般用途已经非常不错。最好不要用默认的Standard,分辨率是150PPI不谈,那个压缩率也高了点。当然,如果还是觉得不满意,甚至可以直接把Downsample和Compression关掉,会得到你Word里原汁原味的图。

不过,PDF Printer有个问题。PDF Printer设计的目的并不是为了制作电子阅览版的PDF,而是用来打印的PDF。所以,你会发现有个问题:打印出来的PDF,图片会被拆分成一块一块的:

qq%e6%88%aa%e5%9b%be20161104003233

其原因我猜大概是为了真·打印机的Buffer大小着想。当然,单纯用来看没大问题,不过总是觉得挺别扭的,而且没法再提取完整的图像了。

另外,今天研究过程中发现了一个非常奇怪的BUG:无论我怎么选,在各种地方选,我那个Letter尺寸的文档打出来的PDF都是A4。甚至我直接把页面改成个正方形的之后,PDF打印出来还是A4。在我抓狂之际,在Adobe论坛发现了原因。原来Word里有个在我看来相当脑残的选项,叫做“缩放内容以适应 A4 或 8.5″ x 11″ 纸张大小”(后者就是Letter)在“高级”里。这个选项默认是勾选的,结果就是不管你文档是什么尺寸,打印出来永远是A4或是Letter(美国地区)。当然为啥我学校的英文版Word也是强制A4而不是Letter我就不得而知了。

另存为Adobe PDF

其实这个是我今天第一个试的方案……但是说来惭愧,当时一时没找到哪里改质量(汗)。后来发现从Word里的话就是在Acrobat那个附加工具栏里,先Preferences里选一下就是(界面和上面那个一样)。如果直接用Acrobat软件界面来搞,就要进设置里找到Convert to PDF对应Word的选项了。

这个方法的优点就是没有上面的说的图像分块的问题,每个图像还是完整的一坨。也是最推荐的。

P.S. 文中所有的JPEG图像质量检测都是通过PDF Image
Extraction Wizard(就是xpdf这个开源命令行工具的GUI)直接从PDF中无损提取图像检测。

P.S. 2 Acrobat算是我现在少数几个离不开的盗版软件了……再算上PS,Adobe你好嘢!

关于JPEG的那点事儿

将此文献给自己掉进兔子洞里消失的几小时光阴(笑)

今天有友人随便问了个问题:为什么Leanify这款软件能无损在已经压缩率很高的JPEG再压缩个5%-10%。当然直接的答案很简单:mozjpeg压缩算法。不过这并没有解决我们的疑问,具体的实现方式呢?尤其是无损优化是怎么做到的?

把Leanify的源代码拖下来看了下,首先一部分优化是来自于删掉metadata——大概能节省下去十几到几十KB而已。剩下的就是来自Mozjpeg的部分了。如果你有像我蛋疼地研究过jpeg的specification或者压缩原理(提示:Wikipedia就是个好去处,没必要去啃ISO的标准……),应该知道就和所有的压缩程序一样,虽然JPEG算法核心是有损压缩,但是为了最大化压缩效果,最后肯定会来一步熵编码(在JPEG语境下,又分霍夫曼和步进[progressive]编码)。所以我的猜想就是mozjpeg用了什么黑科技来优化熵编码。在浪费了1个小时调试Leanify的源代码后,我失望地发现mozjpeg的熵编码本身并无特殊之处——事实上,mozjpeg的核心代码来自libjpeg-turbo,而我拿libjpeg-turbo的原始文件替换相应的熵编码部分后(其实这部分本来就没被Mozilla修改),mozjpeg的再压缩能力丝毫没有受到影响:我用XnView编码出的已经开启霍夫曼和步进编码的jpeg依然可以有不小的(~5%)大小缩减。

柳暗花明,随便浏览之前打开的相关网页时看到了libjpeg-turbo作者对mozjpeg的回应——里面其实已经有了正确答案。引用作者原话:

mozjpeg relies on three technologies (progressive JPEG encoding, jpgcrush, and trellis quantization) to reduce the size of JPEG images.

mozjpeg其实用了三个技术来优化JPEG——强制开启前面提到过的步进编码(光这最基本的一步就能优化不少了,很多家用的图像软件却没这选项)、jpgcrush和trellis quantization(网格量化?)。jpgcrush是由x264主要开发者Loren Merritt闲着没事儿(大误)写的perl脚本源代码),被mozjpeg在首发版本中集成到libjpeg-turbo中。其工作原理其实也很简单——在步进编码模式下不断尝试,直至找到最好的配置。至于我为啥看代码没发现这块?呃这算法的实现好像混在一堆叫scan的逻辑判断和循环之中,我以为是无关的代码就略过了……当然主要原因是我的三脚猫功夫啦。在mozjpeg 2.0中引入的Trellis quantization则是一种“更智能的来决定应该丢掉哪些信息的算法”(是不是耳熟?想想音频压缩),是从人的感知出发来在DCT部分做手脚,所以这个其实并不是无损压缩的范畴了,而是提供一种更优良的兼容于JPEG框架内的编码算法,在我们的问题中(jpeg->jpeg无损[逐像素]转换)不涉及。顺便一提,trellis quantization的实现又是最早出现在视频编码中——再想想webp也是来自于Google在VP系列视频codec的研究成果,现在图像编码这方面的开发已经完全是视频带着图像走了。

在该文中,libjpeg-turbo的作者同时对mozjpeg的压缩效率以及必要性产生了一些质疑。用作者的话说,mozjpeg虽然可以增加少许压缩率不过速度比libjpeg-turbo慢了几十倍。作者同时抱怨了随着新功能的引入,mozjpeg也破坏了兼容性。作为被fork的本体,libjpeg-turbo不会跟进这些更新。不过作者同时也对其开发目的表示了理解(极限压缩),只是两者用途不同,外加自己本来就是义务开发又被众多开源计划使用(Mozilla家的Firefox都依然在继续用libjpeg-turbo),稳定性第一,实验功能可以再观察观察。虽然话不好听,相信两者之间应该还是比较愉快的,毕竟在mozjpeg 3.0的更新中就专门加强了ABI(应用二进制接口)上对libjpeg-turbo的兼容性。

有人可能忍不住了,libjpeg-turbo到底是什么鬼?这说来又话长了。JPEG是“联合图像专家小组”的意思,其实就是这种编码方法的创始者。不过,JPEG编码的具体实现,libjpeg,则是由一个独立的开发小组——(IJG,Independent JPEG Group)在1991年发布的,这甚至比JPEG的初版标准出炉还早1年。其领衔开发者是Tom Lane,这家伙也是开源界神人一枚,光在图像界就至少参与了JPEG、PNG和TIFF的开发。随着时间的发展,libjpeg逐渐成为了JPEG的标准实现,与此同时JPEG也慢慢成为使用最多的图像格式。不过libjpeg的开发在1998年引入了上面提过多次的步进编码之后就停滞在了ver. 6b上。在整整11年之后,更换了领导的IJG终于发布了ver. 7——其中一个修改是引入了算术编码的新熵编码方式,相比霍夫曼它可以再提升百分之几的无损压缩率。其实这不是什么新东西,JPEG最初的标准里就有,但是这玩意曾经有大批专利掌握在IBM手中,所以直到专利过期才敢加入进来。IJG的脚步并没有就此停止,在最近几年其接连发布了版本8、版本9,增加了一些诸如无损JPEG(同算术编码,无损JPEG也是最初JPEG标准里就有的东西,但是一直没有被实现)、动态“智能”调整DCT block大小(原先是固定8×8)之类的功能。然而,社群对这些新功能并不怎么感冒——因为他们和已经用了十几年的6.x并不兼容,而且性能非常值得怀疑。连IJG的创始人,前面提过的Lane,都出来发声反对。更糟糕的是,关于将动态DCT block等功能加入标准的提案也被管理JPEG的标准化组织之一ITU-T拒绝——这意味着,libjpeg已经在自创一种JPEG之上的新标准了(虽然这种生米煮成熟饭的事儿在IT界屡见不鲜):说得好听叫更先进,说的难听叫(无人支持的)自主标准。libjpeg的新领头人Guido Vollbeding也相当高调,经常在社群里和别人打嘴仗,甚至在维基百科的libjpeg条目中和别人打编辑战

就在IJG重启开发的同一时期,一个名叫libjpeg-turbo的新fork出现,其最初目的在提升libjpeg的性能。对于libjpeg新版本的争议,libjpeg-turbo的作者则决定依然基于6b开发,同时提供了一些对7/8的ABI/API模拟。作者也写了文章表明,libjpeg这些新功能性能提升很细微,尤其是无损JPEG无论是质量还是速度都劣于webp,甚至在大部分时候都不如PNG。很快,开源社区就全面转向了libjpeg-turbo。不过这里顺便提一句,虽然turbo已经支持了算术编码,但是绝大部分开源社区默认都不使用甚至移除相关代码,为了规避可能的专利风险。在libjpeg v9出来之后,turbo的作者更明确表达对libejpeg开发思路的不满,决定不再去模拟9版本,从此和libjpeg划清界限。

故事到这里也算告一段落了。libjpeg-turbo继续作为新的事实标准,使用在各大OS发行版以及其他重量级软件(如Chrome、Firefox)中;IJG也继续在开发他们的libjpeg的新版本,今年年初才公布了9b;mozjpeg在去年更新3.1之后则暂时消停了,不过这种东西本来也没有快速更新的必要。说到底,作为快三十岁的老格式,JPEG还能散发活力就已经很了不起了。

于是又几个小时的光阴没有了

Part2:关于JPEG的那点事儿 Part 2:JPEG原理