JPEG图片压缩后保留Exif信息(java实现)

问题来源:

在进行Android camera相关的开发时,对于图片数据不论是缓存在本地磁盘还是上传到后端,都需要先对图片进行压缩处理。但是JPG(JPEG)图片在压缩后原图的EXIF信息也会丢失。那如果想保留exif数据该怎么处理?

关键词描述

EXIF:可交换图像文件格式(英语:Exchangeable image file format,官方简称Exif),是专门为数码相机的照片设定的,可以附加于JPEGTIFFRIFF等文件之中,可以记录数码照片的属性信息和拍摄数据。比如记录以下信息:

项目 资讯(举例)
制造厂商 Canon
相机型号 Canon EOS-1Ds Mark III
影像方向 正常(upper-left)
影像解析度X 300
影像解析度Y 300
解析度单位 dpi
软件 Adobe Photoshop CS Macintosh
最后异动时间 2005:10:06 12:53:19
YCbCrPositioning 2
曝光时间 0.00800 (1/125) sec
光圈 F22
拍摄模式 光圈优先
ISO感光值 100
Exif资讯版本 30,32,32,31
影像拍摄时间 2005:09:25 15:00:18
影像存入时间 2005:09:25 15:00:18
曝光补偿(EV+-) 0
测光模式 点测光(Spot)
闪光灯 关闭
镜头实体焦长 12 mm
Flashpix版本 30,31,30,30
影像色域空间 sRGB
影像尺寸X 5616 pixel
影像尺寸Y 3744 pixel

现已有方案

利用Google提供的 android.support.media.ExifInterface 对图片的exif进行读写设置

This is a class for reading and writing Exif tags in a JPEG file or a RAW image file.
Supported formats are: JPEG, DNG, CR2, NEF, NRW, ARW, RW2, ORF, PEF, SRW and RAF.
Attribute mutation is supported for JPEG image files.

但是这个封装类只提供了 getXXX()setAttributes(String tag, String value) 这种操作单个属性的方法,如果想将原图片文件中的所有exif信息完整复制到另一个图片中会非常繁琐。因此有人通过反射,对所有属性名进行遍历,从而实现了批量操作。也算是一种解决方案,具体如下:

public static void saveExif(String oldFilePath, String newFilePath) throws Exception {
        ExifInterface oldExif = new ExifInterface(oldFilePath);
        ExifInterface newExif = new ExifInterface(newFilePath);
        Class<ExifInterface> cls = ExifInterface.class;
        Field[] fields = cls.getFields();
        for (int i = 0; i < fields.length; i++) {
            String fieldName = fields[i].getName();
            if (!TextUtils.isEmpty(fieldName) && fieldName.startsWith("TAG")) {
                String fieldValue = fields[i].get(cls).toString();
                String attribute = oldExif.getAttribute(fieldValue);
                if (attribute != null) {
                    newExif.setAttribute(fieldValue, attribute);
                }
            }
        }
        //将内存中的修改写入磁盘(IO操作)
        newExif.saveAttributes();
 }

但是以上方案弊端也很明显,就是需要对文件进行多次IO操作。为什么这么说?
首先观察上面方法中的两个参数都是文件路径,意思就是我们在拍完照通过 onPictureTaken(byte[] data, Camera camera) 回调方法拿到图片的 byte[] data 数据后的workflow是这样的:

  1. 将data缓存到磁盘,路径为oldFilePath;(IO)
  2. 将data转换成 bitmap 进行压缩、旋转、剪切等操作;
  3. 将处理后的 bitmap 缓存到磁盘,路径为newFilePath;(IO)
  4. 调用上面的 saveExif(oldFilePath, newFilePath) 方法; (IO)

能否只在内存中操作?发现有 ExifInterface (String filename) 和 ExifInterface (InputStream inputStream) 两种构造方法, 所以我尝试进行如下改造:

public static void saveExif(byte[] srcData, byte[] destData) throws Exception {
        ExifInterface oldExif = new ExifInterface(new ByteArrayInputStream(srcData));
        ExifInterface newExif = new ExifInterface(new ByteArrayInputStream(destData));
        ...
        newExif.saveAttributes();
 }

然鹅并没有什么卵用, 直接抛异常,后研究源码发现 saveAttributes() 的流程是这样的:

  1. 校验构造方法中传入的 fileName 是否为空,若为空则抛异常;假设我们 new ExifInterface (“/a/b/picture.jpg”),即 fileName/a/b/picture.jpg,;
  2. /a/b/picture.jpg 重命名为 /a/b/picture.jpg.tmp
  3. 新建 /a/b/picture.jpg 文件;
  4. /a/b/picture.jpg.tmp 文件中的数据加上修改后的exif 存入到新建的 /a/b/picture.jpg 文件中;
  5. 删除 /a/b/picture.jpg.tmp

由此可见, saveAttributes() 必然是IO操作,而且对于EXIF的修改只能使用第一种构造方式,即必须传入文件路径. 否则必然抛出异常。所以进一步改造如下:

public static void saveExif(byte[] srcData, String destFilePath) throws Exception {
        ExifInterface oldExif = new ExifInterface(new ByteArrayInputStream(srcData));
        ExifInterface newExif = new ExifInterface(destFilePath);
        ...
        newExif.saveAttributes();
 }

结果可行,而且少了一次IO (第一步); 但是我觉得还不够优雅。。。

我的解决方案

我的目标是将所有有关图片的操作都放到内存中完成,最后只缓存一份图片数据。

思路很简单,不管是图片还是其他文件,其本质都是格式化的数据,都有其专用的数据结构。那么就去研究下JPG的数据结构好了,只要找到 exif 数据块的起始索引,然后从源文件byte[]中复制插入到目标文件byte[]对应位置中不就ok了。

JPG数据格式

如上图所示,每一个JPEG文件的内容都开始于一个二进制的值 '0xFFD8', 并结束与二进制值'0xFFD9'. 在JPEG的数据 中有好几种类似于二进制 0xFFXX 的数据, 它们都统称作 "标记", 并且它们代表了一段JPEG的 信息数据.
0xFFD8 的意思是 SOI图像起始(Start of image) ,是Jpeg文件的魔数(Magic Number)。每种格式的文件都有固定的Magic Number,比如.class 字节码文件的Magic Number是 “0xCAFEBABE”;基于安全性考虑,Unix like 系统的应用程序都是基于Magic Number 来区分不同的文件格式,而不是采用用户可随意更改的文件扩展名。
0xFFD9 则表示 EOI图像结束 (End of image).
这两个特殊的标记的后面都不跟随数据, 而其他的标记在后面则会附带数据. 标记的基本格式如下.

0xFF+标记号(1个字节)+数据大小描述符(2个字节)+数据内容(n个字节)

而对于EXIF数据,使用的是APP1标记,前两个字节固定为 0xFFE1,后面紧跟着两个字节记录的是exif数据内容的 length + 2,假设这两个字节的值是 24,那么exif数据内容的长度就是22字节.
了解了JPG的数据格式后,剩下的就是动手操作数组了,找到EXIF在数组中的起始索引,把它抠出来插入到新数组中去!


image.png
  /**
     * 将原图片中的EXIF复制到目标图片中
     * 仅限JPEG
     * @param srcData
     * @param destData
     * @return
     */
    public static byte[] cloneExif(byte[] srcData, byte[] destData) {
        if (srcData == null || srcData.length == 0 || destData == null || destData.length == 0) return null;

        ImageHeaderParser srcImageHeaderParser = new ImageHeaderParser(srcData);
        byte[] srcExifBlock = srcImageHeaderParser.getExifBlock();
        if (srcExifBlock == null || srcExifBlock.length <= 4) return null;

        LOG.d(TAG, "pictureData src: %1$s KB; dest: %2$s KB", srcData.length / 1024, destData.length / 1024);
        LOG.d(TAG, "srcExif: %s B", srcExifBlock.length);
        ImageHeaderParser destImageHeaderParser = new ImageHeaderParser(destData);
        byte[] destExifBlock = destImageHeaderParser.getExifBlock();
        if (destExifBlock != null && destExifBlock.length > 0) {
            LOG.d(TAG, "destExif: %s B", destExifBlock.length);
            //目标图片中已有exif信息, 需要先删除
            int exifStartIndex = destImageHeaderParser.getExifStartIndex();
            //构建新数组
            byte[] newDestData = new byte[srcExifBlock.length + destData.length - destExifBlock.length];
            //copy 1st block
            System.arraycopy(destData, 0, newDestData, 0, exifStartIndex);
            //copy 2rd block (exif)
            System.arraycopy(srcExifBlock, 0, newDestData, exifStartIndex, srcExifBlock.length);
            //copy 3th block
            int srcPos = exifStartIndex + destExifBlock.length;
            int destPos = exifStartIndex + srcExifBlock.length;
            System.arraycopy(destData, srcPos, newDestData, destPos, destData.length - srcPos);
            LOG.d(TAG, "output image Data with exif: %s KB", newDestData.length / 1024);
            return newDestData;
        } else {
            LOG.d(TAG, "destExif: %s B", 0);
            //目标图片中没有exif信息
            byte[] newDestData = new byte[srcExifBlock.length + destData.length];
            //copy 1st block (前两个字节)
            System.arraycopy(destData, 0, newDestData, 0, 2);
            //copy 2rd block (exif)
            System.arraycopy(srcExifBlock, 0, newDestData, 2, srcExifBlock.length);
            //copy 3th block
            int srcPos = 2;
            int destPos = 2 + srcExifBlock.length;
            System.arraycopy(destData, srcPos, newDestData, destPos, destData.length - srcPos);
            LOG.d(TAG, "output image Data with exif: %s KB", newDestData.length / 1024);
            return newDestData;
        }

    }

如此,拿到图片的 byte[] srcData 数据后,整个workflow就简化成:

  1. 将srcData转换成 bitmap 进行压缩、旋转、剪切等操作,后再转成 byte[] destData;
  2. 调用上面的 cloneExif(srcData, destData) 方法,将原图的exif复制到压缩处理后的图片中;
  3. 将压缩处理后的含有exif的图片data 缓存到磁盘;(IO)

只进行一次IO操作~

附:ImageHeaderParser 全部代码实现(参考Glide库)

github

参考:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容