EXIF信息是可交换图像文件格式(Exchangeable Image File Format)的缩写,是在JPEG格式的基础上发展起来的,其中包含了一系列按照一定标准制定的有关图像拍摄信息的数据和索引,包括快门速度、光圈、ISO感光度、曝光偏移、日期和时间、闪光使用情况、焦距、GPS定位数据等。
在实际开发中,对于图片数据不论是缓存在本地磁盘还是上传到后端,都需要先对图片进行压缩处理。在图片压缩的过程中,为了减小文件大小,一些不重要的元数据(包括EXIF信息)可能会被移除或修改。如果图片经过压缩处理,其原始的EXIF信息可能会丢失或不完整。
EXIF信息附加于JPEG、TIFF、RIFF等文件之中,可以记录数码照片的属性信息和拍摄数据。比如记录以下信息:
项目 | 资讯(举例) |
制造厂商 | 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 |
有一些压缩工具或软件提供了保留EXIF信息的选项。在使用这些工具进行压缩时,可以选择保留EXIF信息,以确保压缩后的图片仍然包含完整的元数据。在实际开发中我们如何进行保留EXIF信息的同时进行图片压缩呢?
使用ExifInterface方案
ExifInterface是Android系统中用于描述多媒体文件(如JPG格式图片)附加信息的一个类。它主要涵盖了拍摄时的光圈、快门、白平衡、ISO、焦距、日期时间等各种拍摄条件,以及相机品牌、型号、色彩编码、拍摄时录制的声音以及全球定位系统(GPS)和缩略图等信息。简单来说,ExifInterface就是JPEG图像文件+拍摄参数的结合。
ExifInterface类主要提供了读取、写入和缩略图处理这三个方面的功能。通过ExifInterface,可以获取到图片的多种属性,如方向(orientation)、拍摄时间(dateTime)、设备制造商(make)、设备型号(model)等。
ExifInterface类只提供了 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 数据后处理是这样的:
- 将data缓存到磁盘,路径为oldFilePath;(IO)
- 将data转换成 bitmap 进行压缩、旋转、剪切等操作;
- 将处理后的 bitmap 缓存到磁盘,路径为newFilePath;(IO)
- 调用上面的 saveExif(oldFilePath, newFilePath) 方法; (IO)
能否只在内存中操作?发现有 ExifInterface (String filename) 和 ExifInterface (InputStream inputStream) 两种构造方法, 进行如下改造:
public static void saveExif(byte[] srcData, String destFilePath) throws Exception {
ExifInterface oldExif = new ExifInterface(new ByteArrayInputStream(srcData));
ExifInterface newExif = new ExifInterface(destFilePath);
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();
}
使用自定义方案
不管是图片还是其他文件,本质都是格式化的数据,都有专用的数据结构。研究下JPG的数据结构,找到 EXIF 数据块的起始索引,然后从源文件byte[]中复制插入到目标文件byte[]对应位置中就实现了。
图片
JPEG文件的内容都开始于一个二进制的值 '0xFFD8', 并结束于二进制值'0xFFD9'. 在JPEG的数据中有好几种类似于二进制 0xFFXX 的数据都统称作 "标记", 代表了一段JPEG的信息数据。
0xFFD8 的意思是 SOI图像起始(Start of image) ,是Jpeg文件的魔数(Magic Number)。每种格式的文件都有固定的Magic Number,比如.class 字节码文件的Magic Number是 “0xCAFEBABE”。0xFFD9 则表示 EOI图像结束 (End of image)。
0xFF+标记号(1个字节)+数据大小描述符(2个字节)+数据内容(n个字节)
对于EXIF数据,使用的是APP1标记,前两个字节固定为 0xFFE1,后面紧跟着两个字节记录的是EXIF数据内容的 length + 2,假设这两个字节的值是 24,那么EXIF数据内容的长度就是22字节。所以只要找到EXIF在数组中的起始索引,抠出来插入到新数组中去就完成了。
图片
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;
}
}
将原图的数据流和压缩处理后的数据流传入,调用cloneExif方法,返回附加了EXIF信息的数据流,将返回的数据流存储即得到一张带有原EXIF信息的压缩图片。
「注意」上述方法只针对JPEG格式图片,其他格式文件数据结构不同,方法可能无效。