笔记:使用 JavaScript 读取 JPEG 文件 EXIF 信息中的 Orientation 值

update: 2018-02-24 有了自己的域名,更换笔记对应的 Blog 链接:


提到的这篇 《Description of Exif file format》 的翻译,为了方便阅读对比,也单独开了一篇:



最近在做处理 JPEG 中 EXIF 数据相关的项目,ongoing 的状态中填了以前剪裁上传图片里图片旋转的坑,好奇查了点相关资料,记录一下;Already a snippet given here,but there is a why and how。



笔记目录:


  1. 需要 EXIF.Orientation 出现的场景;
  2. 为什么会出现图片旋转角度的错误;
  3. EXIF 和 Orientation Tag 的一些历史;
  4. Orientation Tag 的值和对应的角度;
  5. 不同设备之间的区别;
  6. 解决问题:
    1. CSS Way: {image-orientation: from-image;};
    2. JavaScript Way,步骤、 demo 和原理;
      1. 步骤;
      2. 伪代码;
      3. DEMO;
    3. 原理:
      1. Brief on JPEG;
      2. Brief on APP1;
      3. Brief on TIFF header;
      4. Brief on Exif's IFD(Image File Directory);
      5. Brief on entry of IFD(Image File Directory);


---

1. 需要 EXIF.Orientation 出现的场景:

前端图片剪裁上传, canvas draw 2d context 之后图像旋转成了错误的角度。对这个挺常见的场景步骤的还原:

  1. input[type=“file“].onchange = (e) 获得 file,FileReader 将 file 转换成 base64;
  2. new image(),在它的 onload() 事件中将获取到的 base64 赋值到 document.createElement(‘canvas’) 上,给 canvas 赋值新的 width 和 height;
  3. 通过 context(‘2d’) 在 canvas 上绘制,获得一张同比例不同尺寸的新图;
  4. 最后 canvas.toDataURL() 得到需要的新数据进行上传 base 64 数据。


问题在第 4 步,导致的结果直观的体现为:上传到服务器的图片,或者生成在前台预览的图片的方向错误。(比如,使用 iPhone 拍摄,竖持手机 Home 键在下,得到的图片会逆时针转 90 度),如下图:

---

2. 为什么会出现图片旋转角度的错误:

1. 首先来看看在电脑里这张图片是什么样子,通过 Mac 里的 preview.app 打开结果是正确的:

2. 接着在浏览器里直接打开这张图是什么样子?结果同样正确的:

3. 接着尝试在 html 的 <img> 元素中引用这张图片,结果会怎样?错误出现。(这里补充一个 hack,如果在 html 中创建一个 iframe,再从中引用这张 img,图片会以正确的方向显示


4. 看到这个结果的时候,大概可以意识到:旋转角度错误的问题出现,可能不是上传的时候我们做错了什么导致图片的方向错了,而是我们少做了什么,没有正确将图片旋转成我们需要的方向

那么怎么样确认如何才是正确的方向?首先在 Mac 上可以通过 preview.app 来获得线索,在 preview 中打开 tools -> show inspector(工具-显示检查器),第二栏的「通用」如下:

在「通用」下有「方向」,对应的值是 6(逆时针旋转 90°);对比图片,猜测这图片是不是被逆时针旋转 90°,所以为了得到正确的显示结果,需要顺时针旋转图片 90°?Google 了一堆后发现,这里的「方向」指的就是图片 Exif 信息中的 Orientation 数据,而我们没有做的,就是 preview.app(或者说操作系统) 已经帮我们做了的事情:根据这个属性的不同,旋转所要查看的图片,将其以正确的方式显示在浏览器的网页之中,放到我们的需求中,就是在 canvas 上 draw 新图的时候,没有按照正确的方向去 draw

---

3. EXIF 和 Orientation Tag 的一些历史:

早期的数码相机所拍摄的图片,图片的 metadata(EXIF)信息中并没有 Orientation Tag,只会按照相机本身设备的默认方向存储图片,比如使用默认为 landscape 的相机,竖持设备,拍出来的图片就被旋转了 90°;早期的图片查看软件,可以暂时将图片旋转成正确的角度以供查看,如果需要一劳永逸的解决角度问题,必须手动修改。

用户手动修改图片的过程:decompress JPEG -> rotate -> re-compress JPEG again,可能会导致再一次的有损压缩,但当时的软件基本可以做到无损的解码-旋转-重新编码,所以旋转问题并不是一个特别严重的问题。

同时,一些相机厂商意识到这个角度问题,想要解决,所以他们在相机产品中加入了 orientation sensor 去识别拍摄图片的角度,这里产生了一个问题:生成图片的 image signal processing chips (ISPs) 并不能按照 orientation sensor 识别的到的角度直接生成一张图片。

于是,厂商们就决定将这些数据写入图片的 metadata(EXIF) 之中,所以实际上相机存储的图片实际上本身就可能为错误的旋转角度(默认 landscape 的设备,拍摄了 portrait 的图片),而图片自带的数据里包含了图片本身正确旋转角度对应的值。导致:如果图片查看软件对 orientation tag 做了支持,显示的图片会以适当的角度呈现在我们面前,否则我们看到的图片,(对设备来说正确的)角度在我们看来就是错的

结合上文中将图片在各种环境下(操作系统,浏览器,html)打开、查看的例子,很显然操作系统、浏览器本身是对 orientation tag 做了支持的,而在 DOM 中并没有,至少并没有为我们自动作出旋转。

参考:


---

4. Orientation Tag 的值和对应的角度:

Orientation Tag 有八个值,对应不同的翻转角度:1,2,3,4,5,6,7,8 。在这本 PDF:Exif Version 2.3 的 《4.6.4 TIFF Rev. 6.0 Attribute Information》 对其 (Page.30) 的值做了相关描述,摘抄一下:


  • case 1: The 0th row is at the visual top of the image, and the 0th column is the visual left-hand side.
  • case 2: The 0th row is at the visual top of the image, and the 0th column is the visual right-hand side.
  • case 3: The 0th row is at the visual bottom of the image, and the 0th column is the visual right-hand side.
  • case 4: The 0th row is at the visual bottom of the image, and the 0th column is the visual left-hand side.
  • case 5: The 0th row is the visual left-hand side of the image, and the 0th column is the visual top.
  • case 6: The 0th row is the visual right-hand side of the image, and the 0th column is the visual top.
  • case 7: The 0th row is the visual right-hand side of the image, and the 0th column is the visual bottom.
  • case 8: The 0th row is the visual left-hand side of the image, and the 0th column is the visual bottom.
  • Other: reserved


描述中出现的 「visual top」 等,原文中说是指「a display device」上的上下左右,理解起来就是平时用到图片查看软件,具体的上下左右所指其实和我们日常中的并无出入(图出自上文提到的 PDF 的 Page.32):

关于 0th row 和 0th column ,大致的理解是,一张 JPEG 文件,在 compress 和 uncompressed 的时候会由很多的行 row 和 column 组成, 0th row 和 0th column 代表的是拍摄的景物(captured scene)的 上和左,对照如下表格,以 iPhone 拍摄和 case 6 来做个例子:

  1. 首先 case 6 的 0th row 在显示设备的右,0th column 在显示器的上面;
  2. 接着,想象一下 iPhone 拍摄,当 home 键在右边的时候,case 为 1,也就是拍摄的和显示 top = 0th row,left = 0th column;
  3. 然后把 iPhone 旋转成 home 键在下的竖持位置,这个时候,对比 case 1 时候设备 top 和 left,top 被转到了右,left 被转到了上,即顺时针转了 90 度;
  4. 所以,此时生成出来的 JPEG 在没有自动读取 EXIF 进行旋转的图片查看器里,就是被逆时针旋转了 90 度;
  5. 于是,相机就在图片的 EXIF 中 Orientation Tag 上加了 6 的值,告诉图片查看器需要顺时针旋转 90 度。

这些值有一张更好的图可以说明它们之间的相互关系(图出自上文提到的 PDF 的 Page.35,《Relationship between the orientation tag and rotation processing to display image data on a screen》),即 :

[1, 6, 3, 8] 是相互一次顺时针 90 度方向的关系,而 [2, 5, 4, 7] 则对应了 [1, 6 ,3, 8] 的水平镜像 [2, 5, 4, 7] 参考文章中说是 rare case,测试完全不会出现在手持设备的拍摄图片中,任何角度的前置后置摄像图拍摄和粗来的都没这几个值

参考:


---

5. 不同设备之间的区别:

调用系统摄像头:

EXIF 的问题主要出现在移动端 iPhone 拍摄的图片上,但是使用 iPhone 前后置摄像头进行旋转各种角度拍摄的结果都只在 [1, 6, 3, 8] 之间,不太确定什么情况才会产生 [2, 5, 4, 7];

Android 设备手边能够测试的不多,几台下来,有的是直接没有写入 EXIF.Orientation 信息(比如小米);值得一提的是:貌似部分 Android 手机会无论以什么角度旋转来拍摄图片,在生成图片的时候都会把图片旋转成正确的角度,然后在 orientation 打上 1 的值(比如华为)即:

  • 普通设备(包括 iPhone)的拍摄图片到生成图片的过程:拍摄 -> 生成图片 -> 根据角度给 EXIF.orientation 打上 [1, 6, 3, 8] 间不同的值;
  • 部分 Android (测试华为手机)设备:拍摄 -> 根据设备所持旋转角度生成正确角度的图片 -> 给 EXIF.orientation 打上 1 的值。

App 内调用摄像头:

比如微信对话内拍摄发送给对方的图,是没有 EXIF 信息的;别的没有做太多测试。


---

6. 解决问题:

6.1 CSS Way: {image-orientation: from-image;}:

W3C 已经有了相关的 CSS3 Candidate RecommendationWorking Draft;不过实际中浏览器提供的支持比较差,各家中只有 Firefox 和 iOS Safari 的 latest 的版本对这个属性有支持

---

6.2 JavaScript Way,步骤、 demo 和原理:

6.2.1 步骤:

假设已经得到了 file,并且通过 readAsArrayBuffer 后得到了我们需要的 view,那么获得 orientation 的值的获取步骤是:

  1. 检查 JPEG 的 SOI maker:0xFFD8 是否存在?继续 :中止;
  2. 检查 APP1 的 marker:0xFFE1 是否存在 ?继续 :中止;
  3. 检查 Exif header 的开始是否为「Exif」(ascii:0x45786966)?继续 :中止;
  4. 找到 TIFF header,通过开头两个字节的数据判定字节序:
    1. 「II」0x4949 -> little-endian;
    2. 「MM」0x4D4D -> big-endian;
    3. 两者都不是,something wrong || crazy happened,中止;
  5. 找到 IFD0;
    1. 有 -> 读取其中的 entry 的数量。得到 entry 入口偏移量;
    2. 无 -> 中指
  6. 找到 IFD0 中的 entries,循环读取查找是否有 tag number 为 0x0112 的 entry:
    1. 有 -> 继续读取 format,components,value 的值,计算得出 tag 真正的值;
    2. 无 -> 没有 orientation 信息。

---


6.2.2 伪代码:

获取 EXIF.orientation:

let offset = 0
  , len = view.byteLength

// SOI marker
if (view.getUint16(0, false) != 0xFFD8) reject('不是 JPEG 文件');

// APP1 marker
while (offset < len) {
    if (view.getUint16(offset, false) == 0xFFE1) break;
    else offset += 2;
}

if (offset >= len) reject('没找到 APP1 标识');

// now offset point to APP1 marker 0xFFD8
let APP1_offset = offset;

// offset + 4 point offset to EXIF Header
let EXIF_offset = APP1_offset + 4;

// check if  have 'Exif' ascii string: 0x45786966
if (view.getUint32(EXIF_offset, false) != 0x45786966) reject('无 EXIF');

let TIFF_offset = EXIF_offset + 6;

// 0x4d4d: big endian, 0x4949: little endian
let little = view.getUint16(TIFF_offset, false)  == 0x4949 ? true : false

let IFD0_offset = TIFF_offset + view.getUint32(TIFF_offset + 4);

let entries_count =  view.getUint16(IFD0_offset, little);
let entries_offset = IFD0_offset + 2;

for (let i = 0; i < entries_count; i++ ) {
    let tag_offset = entries_offset + (i * 12);
    if (view.getUint16(tag_offset, little) == 0x0112) {
      let resolve_value = view.getUint16(tag_offset + 8, little);
      resolve(resolve_value);
    } 
}
reject('没有 orientation 信息');

做对应旋转:

switch (EXIF.orientation) {
    case 2: ctx.transform(-1, 0, 0, 1, width, 0); break;
    case 3: ctx.transform(-1, 0, 0, -1, width, height ); break;
    case 4: ctx.transform(1, 0, 0, -1, 0, height ); break;
    case 5: ctx.transform(0, 1, 1, 0, 0, 0); break;
    case 6: ctx.transform(0, 1, -1, 0, height , 0); break;
    case 7: ctx.transform(0, -1, -1, 0, height , width); break;
    case 8: ctx.transform(0, -1, 1, 0, 0, width); break;
    default: ctx.transform(1, 0, 0, 1, 0, 0);
}

---

6.2.3 DEMO:

CodePen 链接:Get JPG/JPEG image's embedded orientation informationdemo 测试的时候有个问题是不一定每个图片都有 EXIF 信息,或者各家厂商写入会不会格式不同我没兼容到错误,可以前去图虫EXIF查看器 alpha 版查看图片本身是否有信息,orientation 信息一般都是在 IFD0 中。):

---

6.2.4 原理:

根据资料,对 JPEG 文件中存了什么和 Exif 是什么以及在哪里有个概念,然后一路找。

下面会提到很多 xxxx's structure,但其实一张文件里面就是一整段 binary,不是树形结构,是线形从头到尾,比如 APP0,APP0 content,APP1,APP1 content,只是:

APP0 - APP0 content - APP1 - APP1 content

并不是:

JPEG
|_APP0_APP0 content
|_APP1_APP1 content


推荐个软件 Hex Friend,如上图;debug 这些二进制数据读取操作挺有帮助的;cmd+L 定位到 offset,cmd + F 寻找 text 或者 hex 内容。

---

Brief on JPEG:

  1. JPEG(Joint Photographic Experts Group) 指一种对图像压缩标准的简称,日常口中提到的 JPEG 文件,更多的是指 JPEG/JFIF(JPEG File Interchange Format) 文件。参考:链接
  2. JPEG 中的数据均是 Big-Endian 格式;
  3. JPEG 开头的一部分数据,对于其中包含的图像数据的解码并没有作用;
  4. Exif 信息就包含这些信息中,内容主要是不同软件,硬件制造商在图片上写入的数据,比如拍摄的环境、设备信息,或者用了什么软件。
  5. 如表所示,这些数据,会被划分到不同的 APP 区块(application segment);

其中 APP0 是 JFIF application segment, APP1 里面则对应的是 Exif 数据(也就是我们需要找的)APP 13 对应 PhotoShop 写入的一些数据

知乎上找到一个答案,对JPEG、APPn、IFD 的结构也说得挺好,提到了一个 windows 平台的软件 MagicEXIF,打开图片可以得到比较直观的感受);表中的 marker 一列是不同 APP 开头部分的标志,但找到它们并不能完全确定之后的就是 APP 内容,还需要对内容部分做出筛选来过滤 marker 的真假。

表格中列出了 APP0 到 APP2,因为需要处理的 Exif 数据都在 APP1 之内,剩余的使用 APPn 表示,如果的有兴趣了解,可以参考:维基ExifTool 相关的数据列表页、或者这里也对所有 application segment 做了总结。

---

Brief on APP1:

  1. 开头是 APP1 maker: 0xFFE1:
  2. 紧跟的两个字节表示的内容的是 APP1 数据长度的字节数;
  3. Exif header 部分开始:
    1. 首先是「Exif」四个字母的 ascii string: 45 78 69 66
    2. 之后是两个字节的 0:0x0000;
  4. TIFF header 开始:
    1. byte order mark;
    2. TIFF marker;
    3. 从 TIFF header 到第一个 Image File Directory 的偏移量;
  5. 之后依次是,IFD0(第一个 Image File Directory)、ExifSubIFD、GPSIFD。而我们需要的 Orientation 信息就在这个 IFD0 之中。

---

Brief on TIFF header:

  1. Exif(Exchangeable image file format)建立在 TIFF(Tagged Image File Format)格式之上。所以开头有 8 个字节长度的 TIFF Header,用来表示其中的数据信息和结构;
  2. 最开始的两个字节代表 TIFF 中数据的 byte order(上文说道 JPEG 中数据都是 Big-Endian 的 byte order,但是这里因为包含了 TIFF 数据格式,所以每次读取的时候都需要检查,数据可能是 little-endian,也可能是 big-endian):
    1. 如果是 「II」(0x4949),数据是 Little-Endian;
    2. 如果是「MM」(0x4d4d),则是 Big-Endian;
  3. 之后的两个字节是 TIFF marker,按照数据格式 byte order 的不同,可能会显示为 0x2A00 或者 0x002A;
  4. 之后的四个字节表示从 TIFF header 的偏移量到 Exif header 的偏移量。一般 TIFF header 后面紧跟的就是 Exif header,所以这个偏移量一般都是 8(0x00000008)。

---

Brief on Exif's IFD(Image File Directory):

  1. TIFF header 之后通常就是 APP1 中所有 IFD(Image File Directory);
  2. 每个 IFD 开始的两个字节,表示其中 entry 的数量;
  3. 每个 entry 可以理解成针对不同属性值包裹的文件夹,包含了属性,格式,数量等值。

---

Brief on entry of IFD(Image File Directory):

  1. 每一个 entry 由四个部分组成:tag number,format,count,value;一共 12 个字节长;
  2. tag number 就是属性的 code name,比如说想找 Orientation,对应的 code 是 0x0112(hex)
  3. format 是数据的格式;
  4. components 对应数据值的长度;
  5. value 的值,但不一定是 tag 的值,也有可能是 tag 的值的偏移量;这里有一个计算,format 对应了一个 bytes per component 的值,需要与 components 的值相乘,得到真正数据值(val)的数据长度(bytes length),如果这个数据长度大于 4 个字节,那么 value 的值是 val 的偏移量,如果小于 4,则 value 就是 val。
  6. 最后取得 tag 值(val)的数据,对应不同数据格式(format),进行 decode。

关于第五点,需要的是 orientation tag(0x112)的值的 format 对应的计算:它的 format value 是 3,对应的 format 是 unsigned short,每个值由 2 个字节组成,使用 DataView.getUint16(offset, little) 直接读取就可以。

format,数据格式对应表格:


Hope these may help somehow.???

#EOF

编辑于 2018-03-14 08:49