笔记:JavaScript 读取 EXIF 的 Orientation


 拍摄于 2017-03-20,最近在用 IQOS,图上的是一些「烟弹」,IQOS 电子烟每一发的子弹。
拍摄于 2017-03-20,最近在用 IQOS,图上的是一些「烟弹」,IQOS 电子烟每一发的子弹。

问题在哪里?需要 EXIF.Orientation 出现的场景

前端图片剪裁上传, 利用画布(canvas)将原始图片绘制到 2d content 上后,进行旋转,但旋转成了错误的角度。对这个挺常见的场景步骤的还原:

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

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

上传到服务器的图片,或者生成在前台预览的图片的方向错误
上传到服务器的图片,或者生成在前台预览的图片的方向错误。

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

  1. 首先来看看在电脑里这张图片是什么样子,通过 Mac 里的 preview.app 打开结果是正确的:
通过 Mac 里的 preview.app 打开后,正确显示的图片。
  1. 接着在浏览器里直接打开这张图是什么样子?结果同样正确的:
  1. 接着尝试在 html 的 <img> 元素中引用这张图片,结果会怎样?错误出现1
  1. 看到这个结果的时候,大概可以意识到:旋转角度错误的问题出现,可能不是上传的时候我们做错了什么导致图片的方向错了,而是我们少做了什么,没有正确将图片旋转成我们需要的方向

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

检查器。

在「通用」下有「方向」,对应的值是 6(逆时针旋转 90°);对比图片,猜测这图片是不是被逆时针旋转 90°,所以为了得到正确的显示结果,需要顺时针旋转图片 90°?

Google 了一堆后发现,这里的「方向」指的就是图片 Exif 信息中的 Orientation 数据,而我们没有做的,就是 preview.app(或者说操作系统) 已经帮我们做了的事情:根据 Exif 信息中的 Orientation 不同的值,旋转所要查看的图片,将其以正确的方式显示在浏览器的网页之中,放到我们的需求中,就是在 canvas 上 draw 新图的时候,没有按照正确的方向去 draw。

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 中并没有,至少并没有为我们自动作出旋转2

Orientation Tag 的值和对应的角度

Orientation Tag 有八个值,对应不同的翻转角度:1,2,3,4,5,6,7,8 3

  • 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」**上的上下左右,理解起来就是平时用到图片查看软件,具体的上下左右所指其实和我们日常中的并无出入4

「a display device」
「a display device」

关于 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 度。

这些值有一张更好的图可以说明它们之间的相互关系5[1, 6, 3, 8] 是相互一次顺时针 90 度方向的关系,而 [2, 5, 4, 7] 则对应了 [1, 6 ,3, 8] 的水平镜像 67

[1, 6, 3, 8] 是相互一次顺时针 90 度方向的关系,而 [2, 5, 4, 7] 则对应了 [1, 6 ,3, 8] 的水平镜像
[1, 6, 3, 8] 是相互一次顺时针 90 度方向的关系,而 [2, 5, 4, 7] 则对应了 [1, 6 ,3, 8] 的水平镜像

不同设备之间的区别

调用系统摄像头:

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 信息的;别的没有做太多测试。

解决问题:CSS Way: image-orientation 属性:

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

解决问题:JavaScript Way,步骤、 demo 和原理:

步骤:

假设已经得到了 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 or crazy happened,中止;
  5. 找到 IFD0;

    1. 有 -> 读取其中的 entry 的数量。得到 entry 入口偏移量;
    2. 无 -> 中指;
  6. 找到 IFD0 中的 entries,循环读取查找是否有 tag number 为 0x0112 的 entry:

    1. 有 -> 继续读取 format,components,value 的值,计算得出 tag 真正的值;
    2. 无 -> 没有 orientation 信息。

伪代码:

获取 EXIF.orientation:

最对应的旋转:


D is for Demonstration:

CodePen 链接:Get JPG/JPEG image’s embedded orientation information89

CodePen 链接

附录

针对文章开头问题的解决方案没有多少行,但要得出这个结论绕不过去的一个疑问就是「 JPEG 文件内部存了什么?」。附录就是来解决这个疑问,内部原理是怎么样的。

附录部分包含两部分内容,「JPEG 中有些什么?」和 《Description of Exif file format》的部分翻译。显然,第一部分的结论基于第二部分的翻译和一些手动的测试10

JPEG 文件中有些什么?

JPEG 文件的线性结构

Brief on JPEG:

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

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

表中的 marker 一列是不同 APP 开头部分的标志,但找到它们并不能完全确定之后的就是 APP 内容,还需要对内容部分做出筛选来过滤 marker 的真假。

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:
    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。

《Description of Exif file format》 截至 IFD data structure 的翻译

Description of Exif file format》 截至 IFD data structure 的翻译。(不算是直译,可能是会夹杂一些自己的理解,句子写长或者缩短,推荐对照原文阅读)


  1. 这里补充一个 hack,如果在 HTML 中创建一个 iframe,再从中引用这张 img,图片会以正确的方向显示。 ↩︎

  2. Exif Orientation 这一部分参考、翻译自《The most evil feature ever conceived: the Exif Orientation Tag》。 ↩︎

  3. 出自 PDF:Exif Version 2.3 的第三十页 ,《4.6.4 TIFF Rev. 6.0 Attribute Information》。 ↩︎

  4. 出自 PDF:Exif Version 2.3 的第三十二页。 ↩︎

  5. 出自 PDF:Exif Version 2.3 的第三十五页,《Relationship between the orientation tag and rotation processing to display image data on a screen》。 ↩︎

  6. 参考文章中说,Exif 的 Orientation 的值为 2, 5, 4, 7 的时候属于 rare case,测试完全不会出现在手持设备的拍摄图片中,任何角度的前置后置摄像图拍摄的图片都没这几个值。 ↩︎

  7. 旋转角度(Orientation Tag)这一块理解起来有点抽象,参考《ImpulseAdventure - JPEG / Exif Orientation and Rotation》和《图片Exif 信息中Orientation的理解和对此的处理》。 ↩︎

  8. CodePen 测试的时候,不一定每张图都有 Exif 信息,或者各家厂商写入会不会格式不同我没兼容到错误,可以前去 图虫 EXIF 查看器 alpha 版 查看图片本身是否有信息,Orientation 信息一般都是在 IFD0 中。 ↩︎

  9. 除了证明 Exif Orientation 值确实被提取出来的 CodePen 之外,还制作了一个根据不同 Orientation 值来对图片进行对应旋转、镜像的 Codepen:ctx.transform ↩︎

  10. 推荐个软件 Hex Friend,debug 这些二进制数据读取操作挺有帮助的;cmd+L 定位到 offset,cmd + F 寻找 text 或者 hex 内容。知乎上找到一个答案,对JPEG、APPn、IFD 的结构也说得挺好,提到了一个 windows 平台的软件 MagicEXIF,打开图片可以得到比较直观的感受)。 ↩︎

  11. 参考《JPEG JFIF》。 ↩︎

感谢阅读

你们好, 2018 年初把小站从 Jekyll 迁移到 Hugo 的过程中,删除了评论区放的 Disqus 插件,考虑有二:首先无论评论、还是对笔记内容的进一步讨论,读者们更喜欢通过邮件、或者 Twitter 私信的方式来沟通;其次一年多以来 Disqus 后台能看到几乎都是垃圾留言(spam),所以这里直接贴一下邮件、以及 Twitter 账户 地址。

技术发展迭代很快,所以这些笔记内容也有类似新闻的时效性,不免有过时、或者错误的地方,欢迎指正 ^_^。

BEST
Lien(A.K.A 胡椒)
本站总访问量 本站总访客量 本文总阅读量