实战案例18:图像元数据提取:解析EXIF数据、提取GPS信息、提取拍摄参数、批量处理
图像元数据提取,说白了就是读懂照片的「身份证」。每张数码照片里都藏着EXIF数据——相机型号、拍摄时间、光圈快门、甚至GPS坐标。我早年做照片管理工具时,就靠这招把几万张散乱照片按地点和时间自动归类。今天咱们就用C语言,手写一个轻量级的EXIF解析器。
EXIF数据长什么样?
EXIF数据嵌在JPEG文件头部,以TIFF格式组织。它是一串标签-值对,每个标签有固定ID。比如:
- 0x010F:相机厂商(字符串)
- 0x0110:相机型号(字符串)
- 0x9003:拍摄日期(字符串)
- 0x8827:ISO感光度(整数)
- 0x920A:GPS坐标(有理数数组)
嗯,这里要注意:EXIF数据是二进制存储的,字节序可能是大端或小端。我当年第一次解析时,没处理字节序,结果读出来的相机型号全是乱码——那叫一个尴尬。
核心数据结构
我们先定义几个关键结构体,用来承载EXIF信息:
// EXIF标签条目
typedef struct {
uint16_t tag; // 标签ID
uint16_t type; // 数据类型
uint32_t count; // 数据个数
uint32_t offset; // 数据偏移或值本身
} ExifEntry;
// 解析后的拍摄参数
typedef struct {
char make[64]; // 厂商
char model[64]; // 型号
char datetime[20]; // 拍摄时间 "YYYY:MM:DD HH:MM:SS"
uint16_t iso; // ISO
double aperture; // 光圈值
double shutter; // 快门速度(秒)
double focal_length; // 焦距(mm)
double gps_lat; // 纬度
double gps_lon; // 经度
char gps_ref_lat[2]; // N/S
char gps_ref_lon[2]; // E/W
} PhotoMetadata;
我个人习惯把GPS信息单独拎出来,因为它的存储格式比较特殊——是用三个有理数表示的度、分、秒。比如纬度39°54'26"存储为39/1, 54/1, 26/1。
解析流程:从文件到结构化数据
整个解析流程可以画成一张图,方便理解:
流程不复杂,核心就是找到APP1段,然后按TIFF格式解析。我建议把字节序检测放在最前面,因为后面所有数值读取都依赖它。
关键代码:解析IFD条目
IFD(Image File Directory)是EXIF的核心,里面按顺序排列着标签条目。每个条目12字节,结构固定。解析时要注意:如果数据长度超过4字节,offset字段存的是指向实际数据的指针。
// 解析单个IFD条目,返回标签值(字符串形式)
int parse_exif_entry(FILE *fp, uint32_t ifd_offset, int idx,
uint16_t *tag, char *value, size_t val_size) {
uint8_t entry[12];
fseek(fp, ifd_offset + idx * 12, SEEK_SET);
fread(entry, 1, 12, fp);
*tag = read_u16(entry, big_endian); // 标签ID
uint16_t type = read_u16(entry + 2, big_endian);
uint32_t count = read_u32(entry + 4, big_endian);
uint32_t offset = read_u32(entry + 8, big_endian);
// 计算数据总字节数
uint32_t byte_count = count * type_sizes[type];
if (byte_count <= 4) {
// 数据直接存在offset字段中
memcpy(value, entry + 8, byte_count);
} else {
// 数据在文件其他地方
fseek(fp, offset, SEEK_SET);
fread(value, 1, byte_count, fp);
}
value[byte_count] = '\0';
return 0;
}
这里有个坑:type_sizes数组需要提前定义好,每种数据类型占多少字节。比如字符串(type=2)每个字符1字节,有理数(type=5)每个8字节。我曾经漏掉了有理数类型,结果GPS坐标死活读不出来——排查了半天才发现。
提取GPS信息
GPS信息藏在IFD1的SubIFD里,标签ID是0x8825。它包含纬度、经度、海拔等,但存储格式比较绕:
- GPSLatitudeRef(标签1):"N"或"S"
- GPSLatitude(标签2):三个有理数,分别表示度、分、秒
- GPSLongitudeRef(标签3):"E"或"W"
- GPSLongitude(标签4):三个有理数
转换成十进制的公式很简单:
double gps_to_decimal(uint32_t deg, uint32_t min, uint32_t sec, char ref) {
double decimal = deg + min / 60.0 + sec / 3600.0;
if (ref == 'S' || ref == 'W') decimal = -decimal;
return decimal;
}
你想想看,GPS坐标精确到小数点后6位,就能定位到米级精度。我拿这个功能给照片打地理标签,出去玩拍的照片自动生成旅行地图,效果相当不错。
批量处理:遍历目录
单张照片解析搞定了,批量处理就是遍历目录。我习惯用opendir/readdir配合文件后缀过滤:
void batch_process(const char *dir_path) {
DIR *dir = opendir(dir_path);
if (!dir) {
perror("opendir");
return;
}
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
// 只处理.jpg和.jpeg文件
const char *ext = strrchr(entry->d_name, '.');
if (!ext) continue;
if (strcasecmp(ext, ".jpg") != 0 && strcasecmp(ext, ".jpeg") != 0)
continue;
char full_path[1024];
snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, entry->d_name);
PhotoMetadata meta;
if (parse_exif(full_path, &meta) == 0) {
printf("%s | %s | %s | %.4f, %.4f\n",
entry->d_name, meta.datetime, meta.model,
meta.gps_lat, meta.gps_lon);
}
}
closedir(dir);
}
批量处理时要注意内存和文件句柄管理。我见过有人一次打开几千个文件描述符不关闭,结果系统直接报错。建议每处理完一张就关闭文件,或者用内存映射提高效率。
避坑指南
我曾经踩过的坑:
- 字节序搞反:有些相机用大端,有些用小端。一定要先读TIFF头的前2字节,0x4D4D是大端,0x4949是小端。
- IFD嵌套:EXIF SubIFD和GPS SubIFD是通过指针引用的,别忘了递归解析。
- 字符串编码:有些厂商用ASCII,有些用UTF-16。我建议统一转成UTF-8再输出。
- 文件损坏:遇到截断的JPEG文件,要做好容错处理,别让程序崩溃。
实战效果展示
跑一下批量处理,输出结果类似这样:
IMG_001.jpg | 2024:03:15 14:30:22 | Canon EOS R5 | 39.9072, 116.3912
IMG_002.jpg | 2024:03:15 14:32:10 | Canon EOS R5 | 39.9075, 116.3918
IMG_003.jpg | 2024:03:16 09:15:44 | iPhone 15 Pro | 31.2304, 121.4737
...
有了这些数据,你可以做很多事情:按日期归档、按地点聚类、甚至分析摄影师的光圈使用习惯。我个人就写了个小工具,把GPS坐标映射到地图上,一眼看出照片是在哪些地方拍的。
小技巧:如果只需要提取少量信息(比如只取拍摄时间),可以提前退出解析,不用读完整个IFD链。这样处理百万张照片时,能省下不少时间。
好了,EXIF解析的核心思路和代码实现就这些。说白了就是:找到APP1段 → 解析TIFF头 → 遍历IFD条目 → 提取你关心的标签。批量处理时注意文件遍历和资源管理。这套方法我用了好几年,处理过上百万张照片,稳定可靠。
公众号:蓝海资料掘金营,微信deep3321