JPEG 是最常见的图片格式之一,它通过有损压缩在图片体积和视觉质量之间进行平衡,非常适合网络传输,因此被广泛应用于 Web 和数字影像领域。我们平时接触到的 JPEG 图片文件通常使用 .jpg 或 .jpeg 作为扩展名。
需要注意的是,JPEG、JFIF 和 Exif 并不是完全相同的概念。
JPEG 标准主要定义图像的压缩编码方式以及相关的码流结构,而 JFIF、Exif 等则是实际文件中常见的封装和应用规范。可以简单理解为:JPEG 解决“如何编码图像数据”的问题,而 JFIF、Exif 等进一步规定了文件中如何组织这些数据以及如何保存其它信息。

JPEG 通常采用有损压缩。所谓有损压缩,就是通过舍弃部分图像信息来降低文件体积。我们最常见的操作就是在 Photoshop 中选择图片质量,质量越低,通常文件体积越小;质量越高,文件体积越大。
在 Web 应用中,我们经常需要对图片进行压缩,以减少带宽占用、加快页面加载速度。一些网站性能测试工具也会针对网页中的图片大小给出优化建议。最简单的方法通常是使用 Photoshop 调整图片尺寸,然后选择“存储为 Web 所用格式”并选择合适的质量。
那么问题来了:
经过 Photoshop 压缩之后,图片是不是就没有其它优化空间了?要弄清楚这个问题,我们需要先分析一下 JPEG 文件的内部结构。
JPEG文件结构
JPEG 文件并不是一整块连续的图像数据,而是由多个不同类型的数据段(Segment)组成。不同数据段之间通过两个字节组成的 Marker 进行标识。Marker 通常以 0xFF 开头,后面的字节用于表示具体的数据段类型。有些 Marker 只有两个字节,而另外一些 Marker 后面还会跟随长度信息和对应的数据。

JPEG 中的一些元数据主要存放在 APPn 段中,注释数据则可以存放在 COM 段中。不同厂商和应用程序可以利用这些段保存自己的信息,例如相机型号、拍摄时间、GPS 信息、软件名称等。
哪些信息可以被移除?
COM 是 JPEG 的注释数据,而 APPn 中则可能包含各种应用程序使用的数据。这些数据通常并不参与 JPEG 图像本身的解码,因此在确定其内容不再需要之后,可以将其中部分元数据移除。例如我们拿一张经过 Photoshop 处理后的图片,用二进制工具打开:

可以看到文件中包含大量 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 段。
评论0
欢迎分享你的看法,也欢迎补充不同的实践经验。