数字营销 · Web开发 · 基础设施

JPEG文件分析与无损压缩实践

从 JPEG 文件结构出发,分析 JPEG、JFIF、Exif 以及文件中的各类数据段,并通过 PHP 实践删除无用元数据,实现不改变图像质量的 JPEG 无损优化。

JPEG 是最常见的图片格式之一,它通过有损压缩在图片体积和视觉质量之间进行平衡,非常适合网络传输,因此被广泛应用于 Web 和数字影像领域。我们平时接触到的 JPEG 图片文件通常使用 .jpg.jpeg 作为扩展名。

需要注意的是,JPEG、JFIF 和 Exif 并不是完全相同的概念

JPEG 标准主要定义图像的压缩编码方式以及相关的码流结构,而 JFIF、Exif 等则是实际文件中常见的封装和应用规范。可以简单理解为:JPEG 解决“如何编码图像数据”的问题,而 JFIF、Exif 等进一步规定了文件中如何组织这些数据以及如何保存其它信息。

JPEG图片

JPEG 通常采用有损压缩。所谓有损压缩,就是通过舍弃部分图像信息来降低文件体积。我们最常见的操作就是在 Photoshop 中选择图片质量,质量越低,通常文件体积越小;质量越高,文件体积越大。

在 Web 应用中,我们经常需要对图片进行压缩,以减少带宽占用、加快页面加载速度。一些网站性能测试工具也会针对网页中的图片大小给出优化建议。最简单的方法通常是使用 Photoshop 调整图片尺寸,然后选择“存储为 Web 所用格式”并选择合适的质量。

那么问题来了:

经过 Photoshop 压缩之后,图片是不是就没有其它优化空间了?要弄清楚这个问题,我们需要先分析一下 JPEG 文件的内部结构。

JPEG文件结构

JPEG 文件并不是一整块连续的图像数据,而是由多个不同类型的数据段(Segment)组成。不同数据段之间通过两个字节组成的 Marker 进行标识。Marker 通常以 0xFF 开头,后面的字节用于表示具体的数据段类型。有些 Marker 只有两个字节,而另外一些 Marker 后面还会跟随长度信息和对应的数据。

JPEG文件结构

JPEG 中的一些元数据主要存放在 APPn 段中,注释数据则可以存放在 COM 段中。不同厂商和应用程序可以利用这些段保存自己的信息,例如相机型号、拍摄时间、GPS 信息、软件名称等。

哪些信息可以被移除?

COM 是 JPEG 的注释数据,而 APPn 中则可能包含各种应用程序使用的数据。这些数据通常并不参与 JPEG 图像本身的解码,因此在确定其内容不再需要之后,可以将其中部分元数据移除。例如我们拿一张经过 Photoshop 处理后的图片,用二进制工具打开:

EXIF数据

可以看到文件中包含大量 EXIF、软件信息以及其它元数据。

对于 Web 图片来说,这些信息很多时候并没有实际作用,因此可以考虑删除。

而且这种删除属于元数据层面的无损处理:只删除额外信息,不重新压缩图像的 JPEG 数据,因此不会因为这一步产生新的图像质量损失。

需要注意的是,并不是所有 APPn 段都可以简单删除。某些 JPEG 文件可能依赖其中的数据,例如颜色配置文件、特定应用信息等,因此实际处理时不能简单粗暴地把所有 APPn 都删除。

如何压缩

下面是我当时自己实现的一个简单 JPEG 处理程序,主要思路是解析 JPEG 文件中的各个 Marker,保留图像主体数据,同时去除部分元数据。

<?php

class JPEG{

// JPEG Metadata Structure
private $markerkey = array(
"soi" => 0xd8,
"sof0" => 0xc0,
"sof2" => 0xc2,
"dht" => 0xc4,
"dqt" => 0xdb,
"dri" => 0xdd,
"sos" => 0xda,
"rst" => array(
0xd0,0xd1,0xd2,0xd3,
0xd4,0xd5,0xd6,0xd7
),
"app" => array(
0xe0,0xe1,0xe2,0xe3,0xe4,
0xe5,0xe6,0xe7,0xe8,0xe9,
0xea,0xeb,0xec,0xed,0xee,0xef
),
"com" => 0xfe,
"eoi" => 0xd9
);

private $markerdata = array();
private $ImageScan;
private $Image;
private $PosPointer;
private $ImageSize;

public function __construct($argument){

if(file_exists($argument)){
$this->Image = fopen($argument,"rb+");
$this->ImageSize = filesize($argument);
}else{
die("File Not Exists");
}

if($this->GetByte(2) !== 0xd8ff){
die("Not a JPEG Image");
}

$this->Parse();
}

public function GetByte($len){

if(feof($this->Image)){
return NULL;
}

$tmp = fread($this->Image,$len);
$tmp2 = array();

$this->PosPointer = ftell($this->Image);

if($len == 1){
$tmp2 = unpack("C",$tmp);
}else if($len == 2){
$tmp2 = unpack("S",$tmp);
}else if($len == 4){
$tmp2 = unpack("L",$tmp);
}else{
$tmp2[1] = $tmp;
}

return $tmp2[1];
}

public function GetMarkerData(){

$buffer = "";

foreach($this->markerdata as $d){
foreach($d as $c){
$buffer .= $c;
}
}

return $buffer;
}

public function get_marker($byte){

foreach($this->markerkey as $meta => $bit){

if(is_array($bit)){
foreach($bit as $b){
if($byte == $b){
return $meta;
}
}
}

if($byte == $bit){
return $meta;
}
}

return NULL;
}

public function compress(){

// Remove COM Marker
$this->markerdata['com'] = array();

// Remove APP Marker
$this->markerdata['app'] = array();
}

public function Parse(){

while(($byte = $this->GetByte(1)) !== NULL){

$nxtbyte = @$this->GetByte(1);

if($byte == 0xff){

$marker = $this->get_marker($nxtbyte);

// Variable Size Marker
if(
$marker == "sof" ||
$marker == "sof2" ||
$marker == "dht" ||
$marker == "dqt" ||
$marker == "sof0" ||
$marker == "app" ||
$marker == "com"
){

$offset1 = $this->GetByte(1);
$offset2 = $this->GetByte(1);

$len = $offset1 * 256 + $offset2;
$data = $this->GetByte($len - 2);

// Full data includes Marker and length
$fulldata =
pack("C",0xff) .
pack("C",$nxtbyte) .
pack("C",$offset1) .
pack("C",$offset2) .
$data;

$this->markerdata[$marker][] = $fulldata;

}else if($marker == "dri"){

// pass

}else if($marker == "rst"){

// pass

}else if($marker == "sos"){

// SOS Segment
$len = $this->ImageSize - $this->PosPointer - 1;

$this->ImageScan =
pack("C",0xff) .
pack("C",0xda) .
$this->GetByte($len);
}
}
}
}

public function GetImageBin(){

return
pack("S",0xd8ff) .
$this->GetMarkerData() .
$this->ImageScan .
pack("S",0xd9ff);
}

public function Storage(){

fseek($this->Image,0);

fwrite(
$this->Image,
$this->GetImageBin()
);
}

public function __destruct(){

fclose($this->Image);
}
}

$image = new JPEG("test.jpg");
$image->compress();
$image->Storage();

这个程序的基本思想并不是重新对 JPEG 图像进行编码,而是解析 JPEG 文件结构,在保留图像主体数据的情况下删除部分元数据。

因此,它和 Photoshop 中调整 JPEG 质量的做法是两种不同的优化方式:

JPEG质量压缩
→ 重新编码图像
→ 可能产生图像质量损失
→ 文件体积进一步下降

去除元数据
→ 删除EXIF、注释等附加信息
→ 不重新编码图像
→ 图像质量不发生变化

当然,上面的代码只是一个实验性质的 JPEG 解析程序,并不能作为完整的 JPEG 优化工具。JPEG 文件存在很多不同的 Marker 和编码情况,实际生产环境中应该使用成熟的 JPEG/EXIF 处理库,而不是简单删除所有 APPn 段。

参考资料

  1. The Metadata in JPEG files
  2. Description of Exif file format
  3. The File Layout

评论0

欢迎分享你的看法,也欢迎补充不同的实践经验。