JPEG圖片壓縮后保留Exif信息(java實(shí)現(xiàn))

問(wèn)題來(lái)源:

在進(jìn)行Android camera相關(guān)的開發(fā)時(shí),對(duì)于圖片數(shù)據(jù)不論是緩存在本地磁盤還是上傳到后端,都需要先對(duì)圖片進(jìn)行壓縮處理。但是JPG(JPEG)圖片在壓縮后原圖的EXIF信息也會(huì)丟失。那如果想保留exif數(shù)據(jù)該怎么處理?

關(guān)鍵詞描述

EXIF:可交換圖像文件格式(英語(yǔ):Exchangeable image file format,官方簡(jiǎn)稱Exif),是專門為數(shù)碼相機(jī)的照片設(shè)定的,可以附加于JPEG、TIFF、RIFF等文件之中,可以記錄數(shù)碼照片的屬性信息和拍攝數(shù)據(jù)。比如記錄以下信息:

項(xiàng)目 資訊(舉例)
制造廠商 Canon
相機(jī)型號(hào) Canon EOS-1Ds Mark III
影像方向 正常(upper-left)
影像解析度X 300
影像解析度Y 300
解析度單位 dpi
軟件 Adobe Photoshop CS Macintosh
最后異動(dòng)時(shí)間 2005:10:06 12:53:19
YCbCrPositioning 2
曝光時(shí)間 0.00800 (1/125) sec
光圈 F22
拍攝模式 光圈優(yōu)先
ISO感光值 100
Exif資訊版本 30,32,32,31
影像拍攝時(shí)間 2005:09:25 15:00:18
影像存入時(shí)間 2005:09:25 15:00:18
曝光補(bǔ)償(EV+-) 0
測(cè)光模式 點(diǎn)測(cè)光(Spot)
閃光燈 關(guān)閉
鏡頭實(shí)體焦長(zhǎng) 12 mm
Flashpix版本 30,31,30,30
影像色域空間 sRGB
影像尺寸X 5616 pixel
影像尺寸Y 3744 pixel

現(xiàn)已有方案

利用Google提供的 android.support.media.ExifInterface 對(duì)圖片的exif進(jìn)行讀寫設(shè)置

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.

但是這個(gè)封裝類只提供了 getXXX()setAttributes(String tag, String value) 這種操作單個(gè)屬性的方法,如果想將原圖片文件中的所有exif信息完整復(fù)制到另一個(gè)圖片中會(huì)非常繁瑣。因此有人通過(guò)反射,對(duì)所有屬性名進(jìn)行遍歷,從而實(shí)現(xiàn)了批量操作。也算是一種解決方案,具體如下:

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);
                }
            }
        }
        //將內(nèi)存中的修改寫入磁盤(IO操作)
        newExif.saveAttributes();
 }

但是以上方案弊端也很明顯,就是需要對(duì)文件進(jìn)行多次IO操作。為什么這么說(shuō)?
首先觀察上面方法中的兩個(gè)參數(shù)都是文件路徑,意思就是我們?cè)谂耐暾胀ㄟ^(guò) onPictureTaken(byte[] data, Camera camera) 回調(diào)方法拿到圖片的 byte[] data 數(shù)據(jù)后的workflow是這樣的:

  1. 將data緩存到磁盤,路徑為oldFilePath;(IO)
  2. 將data轉(zhuǎn)換成 bitmap 進(jìn)行壓縮、旋轉(zhuǎn)、剪切等操作;
  3. 將處理后的 bitmap 緩存到磁盤,路徑為newFilePath;(IO)
  4. 調(diào)用上面的 saveExif(oldFilePath, newFilePath) 方法; (IO)

能否只在內(nèi)存中操作?發(fā)現(xiàn)有 ExifInterface (String filename) 和 ExifInterface (InputStream inputStream) 兩種構(gòu)造方法, 所以我嘗試進(jìn)行如下改造:

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();
 }

然鵝并沒有什么卵用, 直接拋異常,后研究源碼發(fā)現(xiàn) saveAttributes() 的流程是這樣的:

  1. 校驗(yàn)構(gòu)造方法中傳入的 fileName 是否為空,若為空則拋異常;假設(shè)我們 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 文件中的數(shù)據(jù)加上修改后的exif 存入到新建的 /a/b/picture.jpg 文件中;
  5. 刪除 /a/b/picture.jpg.tmp;

由此可見, saveAttributes() 必然是IO操作,而且對(duì)于EXIF的修改只能使用第一種構(gòu)造方式,即必須傳入文件路徑. 否則必然拋出異常。所以進(jìn)一步改造如下:

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

結(jié)果可行,而且少了一次IO (第一步); 但是我覺得還不夠優(yōu)雅。。。

我的解決方案

我的目標(biāo)是將所有有關(guān)圖片的操作都放到內(nèi)存中完成,最后只緩存一份圖片數(shù)據(jù)。

思路很簡(jiǎn)單,不管是圖片還是其他文件,其本質(zhì)都是格式化的數(shù)據(jù),都有其專用的數(shù)據(jù)結(jié)構(gòu)。那么就去研究下JPG的數(shù)據(jù)結(jié)構(gòu)好了,只要找到 exif 數(shù)據(jù)塊的起始索引,然后從源文件byte[]中復(fù)制插入到目標(biāo)文件byte[]對(duì)應(yīng)位置中不就ok了。

JPG數(shù)據(jù)格式

如上圖所示,每一個(gè)JPEG文件的內(nèi)容都開始于一個(gè)二進(jìn)制的值 '0xFFD8', 并結(jié)束與二進(jìn)制值'0xFFD9'. 在JPEG的數(shù)據(jù) 中有好幾種類似于二進(jìn)制 0xFFXX 的數(shù)據(jù), 它們都統(tǒng)稱作 "標(biāo)記", 并且它們代表了一段JPEG的 信息數(shù)據(jù).
0xFFD8 的意思是 SOI圖像起始(Start of image) ,是Jpeg文件的魔數(shù)(Magic Number)。每種格式的文件都有固定的Magic Number,比如.class 字節(jié)碼文件的Magic Number是 “0xCAFEBABE”;基于安全性考慮,Unix like 系統(tǒng)的應(yīng)用程序都是基于Magic Number 來(lái)區(qū)分不同的文件格式,而不是采用用戶可隨意更改的文件擴(kuò)展名。
0xFFD9 則表示 EOI圖像結(jié)束 (End of image).
這兩個(gè)特殊的標(biāo)記的后面都不跟隨數(shù)據(jù), 而其他的標(biāo)記在后面則會(huì)附帶數(shù)據(jù). 標(biāo)記的基本格式如下.

0xFF+標(biāo)記號(hào)(1個(gè)字節(jié))+數(shù)據(jù)大小描述符(2個(gè)字節(jié))+數(shù)據(jù)內(nèi)容(n個(gè)字節(jié))

而對(duì)于EXIF數(shù)據(jù),使用的是APP1標(biāo)記,前兩個(gè)字節(jié)固定為 0xFFE1,后面緊跟著兩個(gè)字節(jié)記錄的是exif數(shù)據(jù)內(nèi)容的 length + 2,假設(shè)這兩個(gè)字節(jié)的值是 24,那么exif數(shù)據(jù)內(nèi)容的長(zhǎng)度就是22字節(jié).
了解了JPG的數(shù)據(jù)格式后,剩下的就是動(dòng)手操作數(shù)組了,找到EXIF在數(shù)組中的起始索引,把它摳出來(lái)插入到新數(shù)組中去!


image.png
  /**
     * 將原圖片中的EXIF復(fù)制到目標(biāo)圖片中
     * 僅限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);
            //目標(biāo)圖片中已有exif信息, 需要先刪除
            int exifStartIndex = destImageHeaderParser.getExifStartIndex();
            //構(gòu)建新數(shù)組
            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);
            //目標(biāo)圖片中沒有exif信息
            byte[] newDestData = new byte[srcExifBlock.length + destData.length];
            //copy 1st block (前兩個(gè)字節(jié))
            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 數(shù)據(jù)后,整個(gè)workflow就簡(jiǎn)化成:

  1. 將srcData轉(zhuǎn)換成 bitmap 進(jìn)行壓縮、旋轉(zhuǎn)、剪切等操作,后再轉(zhuǎn)成 byte[] destData;
  2. 調(diào)用上面的 cloneExif(srcData, destData) 方法,將原圖的exif復(fù)制到壓縮處理后的圖片中;
  3. 將壓縮處理后的含有exif的圖片data 緩存到磁盤;(IO)

只進(jìn)行一次IO操作~

附:ImageHeaderParser 全部代碼實(shí)現(xiàn)(參考Glide庫(kù))

github

參考:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容