实战案例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。

解析流程:从文件到结构化数据

整个解析流程可以画成一张图,方便理解:

EXIF解析流程图 1. 读取JPEG文件头 2. 定位APP1标记 3. 解析TIFF头(字节序) 4a. 解析IFD0(主参数) 4b. 解析IFD1(缩略图) 5a. 提取拍摄参数 5b. 提取GPS信息

流程不复杂,核心就是找到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