美文网首页
JPEG图片压缩后保留Exif信息(java实现)

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

作者: Jessewo | 来源:发表于2018-12-01 16:22 被阅读123次

    问题来源:

    在进行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), 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库)

    /**
     * Created by Jessewo on 2018/11/29.
     *
     * A class for parsing the exif orientation and other data from an image header.
     * <a href="http://www.cppblog.com/lymons/archive/2010/02/23/108266.aspx">Exif文件格式描述</a>
     * <p>
     * --------------------------------------------------------------------------------------------------------------------------
     * | SOI 标记 | 标记 XX 的大小=SSSS          | 标记 YY 的大小=TTTT          | SOS 标记 的大小=UUUU   | 图像数据流     | EOI 标记
     * --------------------------------------------------------------------------------------------------------------------------
     * | FFD8    | FFXX SSSS    DDDD......     | FFYY   TTTT    DDDD......   | FFDA UUUU DDDD....   | I I I I....   | FFD9
     * --------------------------------------------------------------------------------------------------------------------------
     */
    public class ImageHeaderParser {
        private static final String TAG = "ImageHeaderParser";
        private int magicNumber;
    
        /**
         * The format of the image data including whether or not the image may include transparent pixels.
         */
        public enum ImageType {
            /**
             * GIF type.
             */
            GIF(true),
            /**
             * JPG type.
             */
            JPEG(false),
            /**
             * PNG type with alpha.
             */
            PNG_A(true),
            /**
             * PNG type without alpha.
             */
            PNG(false),
            /**
             * Unrecognized type.
             */
            UNKNOWN(false);
            private final boolean hasAlpha;
    
            ImageType(boolean hasAlpha) {
                this.hasAlpha = hasAlpha;
            }
    
            public boolean hasAlpha() {
                return hasAlpha;
            }
        }
    
        private static final int GIF_HEADER = 0x474946;
        private static final int PNG_HEADER = 0x89504E47;
        //JPEG 起始标记字节
        private static final int EXIF_MAGIC_NUMBER = 0xFFD8;
        // "MM".
        private static final int MOTOROLA_TIFF_MAGIC_NUMBER = 0x4D4D;
        // "II".
        private static final int INTEL_TIFF_MAGIC_NUMBER = 0x4949;
        // EXIF数据内容的第一部分
        private static final String JPEG_EXIF_SEGMENT_PREAMBLE = "Exif\0\0";
        private static final byte[] JPEG_EXIF_SEGMENT_PREAMBLE_BYTES;
        //JPEG SOS数据流的起始(Start of stream) 标记
        private static final int SEGMENT_SOS = 0xDA;
        //JPEG 图像结束标记字节
        private static final int MARKER_EOI = 0xD9;
    
        private static final int SEGMENT_START_ID = 0xFF;
        //EXIF用的APP1标记位, 其后两个字节记录 EXIF 的大小(需要-2)
        private static final int EXIF_SEGMENT_TYPE = 0xE1;
        private static final int ORIENTATION_TAG_TYPE = 0x0112;
        private static final int[] BYTES_PER_FORMAT = {0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8};
    
        private final StreamReader streamReader;
        private ImageType imageType;
        private byte[] exifBlock;
        private int exifStartIndex;
    
        static {
            byte[] bytes = new byte[0];
            try {
                bytes = JPEG_EXIF_SEGMENT_PREAMBLE.getBytes("UTF-8");
            } catch (UnsupportedEncodingException e) {
                // Ignore.
            }
            JPEG_EXIF_SEGMENT_PREAMBLE_BYTES = bytes;
        }
    
        public ImageHeaderParser(byte[] data) {
            this(new ByteArrayInputStream(data));
        }
    
        public ImageHeaderParser(InputStream is) {
            streamReader = new StreamReader(is);
            parse();
        }
    
        private void parse() {
            try {
                final int magicNumber = streamReader.getUInt16();
                this.magicNumber = magicNumber;
                ImageType imageType;
                // JPEG.
                if (magicNumber == EXIF_MAGIC_NUMBER) {
                    imageType = JPEG;
                    parseExifBlock();
                } else {
                    final int firstFourBytes = magicNumber << 16 & 0xFFFF0000 | streamReader.getUInt16() & 0xFFFF;
                    // PNG.
                    if (firstFourBytes == PNG_HEADER) {
                        // See: http://stackoverflow.com/questions/2057923/how-to-check-a-png-for-grayscale-alpha-color-type
                        streamReader.skip(25 - 4);
                        int alpha = streamReader.getByte();
                        // A RGB indexed PNG can also have transparency. Better safe than sorry!
                        imageType = alpha >= 3 ? PNG_A : PNG;
                    } else if (firstFourBytes >> 8 == GIF_HEADER) {
                        // GIF from first 3 bytes.
                        imageType = GIF;
                    } else {
                        imageType = UNKNOWN;
                    }
                }
                this.imageType = imageType;
    
    
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        // 0xD0A3C68 -> <htm
        // 0xCAFEBABE -> <!DOCTYPE...
        public boolean hasAlpha() {
            return getType().hasAlpha();
        }
    
        public ImageType getType() {
            return this.imageType;
        }
    
        public byte[] getExifBlock() {
            return this.exifBlock;
        }
    
        public byte[] getExifContent() {
            return this.exifBlock != null && this.exifBlock.length > 4 ? Arrays.copyOfRange(this.exifBlock, 4, this.exifBlock.length - 1) : null;
        }
    
        public int getExifStartIndex() {
            return this.exifStartIndex;
        }
    
        /**
         * 将原图片中的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;
            }
    
        }
    
        private void parseExifBlock() throws IOException {
            short segmentId, segmentType;
            int segmentLength;
            int index = 2;
            while (true) {
                segmentId = streamReader.getUInt8();
    
                if (segmentId != SEGMENT_START_ID) {
                    if (Log.isLoggable(TAG, Log.DEBUG)) {
                        Log.d(TAG, "Unknown segmentId=" + segmentId);
                    }
                    return;
                }
    
                segmentType = streamReader.getUInt8();
    
                if (segmentType == SEGMENT_SOS) {
                    return;
                } else if (segmentType == MARKER_EOI) {
                    if (Log.isLoggable(TAG, Log.DEBUG)) {
                        Log.d(TAG, "Found MARKER_EOI in exif segment");
                    }
                    return;
                }
    
                // Segment length includes bytes for segment length.
                segmentLength = streamReader.getUInt16() - 2;
    
                if (segmentType != EXIF_SEGMENT_TYPE) {
                    //跳过所有的非exif标记块
                    long skipped = streamReader.skip(segmentLength);
                    if (skipped != segmentLength) {
                        if (Log.isLoggable(TAG, Log.DEBUG)) {
                            Log.d(TAG, "Unable to skip enough data"
                                    + ", type: " + segmentType
                                    + ", wanted to skip: " + segmentLength
                                    + ", but actually skipped: " + skipped);
                        }
                        return;
                    }
                    index += (4 + segmentLength);
                } else {
                    //找到exif block
                    byte[] segmentData = new byte[segmentLength];
                    int read = streamReader.read(segmentData);
    
                    byte[] block = new byte[2 + 2 + read];
                    block[0] = (byte) SEGMENT_START_ID;
                    block[1] = (byte) EXIF_SEGMENT_TYPE;
                    int length = read + 2;
                    block[2] = (byte) ((length >> 8) & 0xFF);
                    block[3] = (byte) (length & 0xFF);
    
                    if (read != segmentLength) {
                        if (Log.isLoggable(TAG, Log.DEBUG)) {
                            Log.d(TAG, "Unable to read segment data"
                                    + ", type: " + segmentType
                                    + ", length: " + segmentLength
                                    + ", actually read: " + read);
                        }
                    } else {
                        System.arraycopy(segmentData, 0, block, 4, read);
                    }
                    this.exifBlock = block;
                    this.exifStartIndex = index;
                    return;
                }
            }
        }
    
        /**
         * Parse the orientation from the image header. If it doesn't handle this image type (or this is not an image)
         * it will return a default value rather than throwing an exception.
         *
         * @return The exif orientation if present or -1 if the header couldn't be parsed or doesn't contain an orientation
         * @throws IOException
         */
        public int getOrientation() {
            if (!handles(this.magicNumber)) {
                return -1;
            } else {
                byte[] exifData = getExifContent();
                boolean hasJpegExifPreamble = exifData != null
                        && exifData.length > JPEG_EXIF_SEGMENT_PREAMBLE_BYTES.length;
    
                if (hasJpegExifPreamble) {
                    for (int i = 0; i < JPEG_EXIF_SEGMENT_PREAMBLE_BYTES.length; i++) {
                        if (exifData[i] != JPEG_EXIF_SEGMENT_PREAMBLE_BYTES[i]) {
                            hasJpegExifPreamble = false;
                            break;
                        }
                    }
                }
    
                if (hasJpegExifPreamble) {
                    return parseExifSegment(new RandomAccessReader(exifData));
                } else {
                    return -1;
                }
            }
        }
    
        private static int calcTagOffset(int ifdOffset, int tagIndex) {
            return ifdOffset + 2 + 12 * tagIndex;
        }
    
        private static boolean handles(int imageMagicNumber) {
            return (imageMagicNumber & EXIF_MAGIC_NUMBER) == EXIF_MAGIC_NUMBER
                    || imageMagicNumber == MOTOROLA_TIFF_MAGIC_NUMBER
                    || imageMagicNumber == INTEL_TIFF_MAGIC_NUMBER;
        }
        
       /**
         * InputStream的包装类
         *
         */
        private static class StreamReader {
            private final InputStream is;
            //motorola / big endian byte order
    
            public StreamReader(InputStream is) {
                this.is = is;
            }
    
            /**
             * 读两个字节, 并将两个byte转化为 int
             *
             * @return
             * @throws IOException
             */
            public int getUInt16() throws IOException {
                return (is.read() << 8 & 0xFF00) | (is.read() & 0xFF);
            }
    
            /**
             * 读一个字节,并将一个byte转化为short
             *
             * @return
             * @throws IOException
             */
            public short getUInt8() throws IOException {
                return (short) (is.read() & 0xFF);
            }
    
            public long skip(long total) throws IOException {
                if (total < 0) {
                    return 0;
                }
    
                long toSkip = total;
                while (toSkip > 0) {
                    long skipped = is.skip(toSkip);
                    if (skipped > 0) {
                        toSkip -= skipped;
                    } else {
                        // Skip has no specific contract as to what happens when you reach the end of
                        // the stream. To differentiate between temporarily not having more data and
                        // having finished the stream, we read a single byte when we fail to skip any
                        // amount of data.
                        int testEofByte = is.read();
                        if (testEofByte == -1) {
                            break;
                        } else {
                            toSkip--;
                        }
                    }
                }
                return total - toSkip;
            }
    
            public int read(byte[] buffer) throws IOException {
                int toRead = buffer.length;
                int read;
                while (toRead > 0 && ((read = is.read(buffer, buffer.length - toRead, toRead)) != -1)) {
                    toRead -= read;
                }
                return buffer.length - toRead;
            }
    
            public int getByte() throws IOException {
                return is.read();
            }
        }
    }
    

    参考:

    相关文章

      网友评论

          本文标题:JPEG图片压缩后保留Exif信息(java实现)

          本文链接:https://www.haomeiwen.com/subject/wvnlcqtx.html