导航菜单

EXIF:藏在图片里的秘密

我发现了一个隐私问题

上线第二周,一个用户给我发了一条消息:

我在你们平台上上传了一张在家门口拍的照片。结果发现,点击图片的”查看信息”后,显示了经纬度坐标。这是不是意味着别人能知道我住在哪里?

我的心一紧。我从来没想过这个问题。

我打开一张测试照片,用 Python 解析了它的 EXIF 数据:

from PIL.ExifTags import TAGS, GPSTAGS
from PIL import Image

def extract_exif(image_path):
    """提取图片的 EXIF 元数据"""
    img = Image.open(image_path)
    exif_data = img._getexif()
    
    if not exif_data:
        return "无 EXIF 数据"
    
    result = {}
    for tag_id, value in exif_data.items():
        tag_name = TAGS.get(tag_id, tag_id)
        result[tag_name] = value
    
    return result

exif = extract_exif('/var/www/photos/night_photo.jpg')

# 输出:
# {
#   'Make': 'SONY',                           # 相机品牌
#   'Model': 'ILCE-7RM5',                     # 相机型号
#   'LensModel': 'FE 24-70mm F2.8 GM II',     # 镜头型号
#   'DateTimeOriginal': '2024:06:15 20:30:15', # 拍摄时间
#   'FocalLength': 35.0,                       # 焦距
#   'FNumber': 2.8,                           # 光圈
#   'ExposureTime': 0.5,                      # 快门速度
#   'ISO': 400,                               # ISO 感光度
#   'GPSInfo': {                              # GPS 定位!
#     1: 'N',
#     2: (39.0, 54, 34.56),   # 纬度
#     3: 'E',
#     4: (116.0, 23, 45.67),  # 经度
#   },
#   'Software': 'Adobe Lightroom 7.0',        # 后期软件
#   'ImageWidth': 6000,
#   'ImageHeight': 4000,
# }

GPS 定位数据就在图片里。 精确到经纬度的小数点后两位——足够定位到一栋楼。

EXIF 到底包含什么?

我系统地研究了一下 EXIF(Exchangeable Image File Format)标准:

# EXIF 数据分类
EXIF_CATEGORIES = {
    '拍摄参数': {
        'ExposureTime': '快门速度(如 1/200s)',
        'FNumber': '光圈(如 f/2.8)',
        'ISO': '感光度(如 400)',
        'FocalLength': '焦距(如 35mm)',
        'WhiteBalance': '白平衡模式',
        'Flash': '闪光灯是否开启',
        'MeteringMode': '测光模式',
        'ExposureProgram': '曝光程序(光圈优先/快门优先等)',
    },
    '设备信息': {
        'Make': '制造商(如 SONY)',
        'Model': '型号(如 ILCE-7RM5)',
        'LensModel': '镜头型号',
        'Software': '处理软件',
    },
    '时间信息': {
        'DateTimeOriginal': '拍摄时间',
        'DateTimeDigitized': '数字化时间',
        'DateTime': '修改时间',
    },
    '位置信息': {
        'GPSLatitude': '纬度',
        'GPSLongitude': '经度',
        'GPSAltitude': '海拔',
        'GPSDestBearing': '拍摄方向',
    },
    '图片信息': {
        'ImageWidth': '宽度',
        'ImageHeight': '高度',
        'Orientation': '拍摄方向(横/竖)',
        'XResolution': '水平分辨率',
        'YResolution': '垂直分辨率',
        'ColorSpace': '色彩空间(sRGB/AdobeRGB)',
    },
}

这些信息对摄影师来说很有价值——他们可以在平台上展示拍摄参数,其他用户可以学习。

但 GPS 坐标是另一个故事。

我做了一个决定:剥离敏感 EXIF

我需要在”保留有价值的拍摄参数”和”保护用户隐私”之间找到平衡。

from PIL import Image
import io

# 需要保留的 EXIF 字段
SAFE_EXIF_TAGS = {
    'Make', 'Model', 'LensModel',
    'ExposureTime', 'FNumber', 'ISO', 'FocalLength',
    'DateTimeOriginal',
    'ImageWidth', 'ImageHeight', 'Orientation',
}

# 需要删除的 EXIF 字段(隐私敏感)
SENSITIVE_EXIF_TAGS = {
    'GPSInfo',           # GPS 定位
    'GPSTimeStamp',      # GPS 时间戳
    'GPSDateStamp',      # GPS 日期
    'UserComment',       # 用户注释
    'XPComment',         # Windows 注释
    'XPTitle',           # Windows 标题
    'XPAuthor',          # Windows 作者
    'XPKeywords',        # Windows 关键词
    'XPSubject',         # Windows 主题
}

def strip_sensitive_exif(input_path, output_path):
    """剥离敏感 EXIF 数据,保留安全的拍摄参数"""
    img = Image.open(input_path)
    
    # 获取原始 EXIF
    original_exif = img._getexif()
    if not original_exif:
        img.save(output_path)
        return
    
    # 过滤 EXIF:只保留安全的字段
    from PIL.ExifTags import TAGS
    safe_exif = {}
    for tag_id, value in original_exif.items():
        tag_name = TAGS.get(tag_id, tag_id)
        if tag_name in SAFE_EXIF_TAGS:
            safe_exif[tag_id] = value
    
    # 创建新图片,写入过滤后的 EXIF
    if safe_exif:
        from PIL.Image import Exif
        exif = Exif()
        exif_data = img.info.get('exif', b'')
        img.save(output_path, exif=exif.tobytes() if safe_exif else None)
    else:
        img.save(output_path)
    
    return {
        'original_tags': len(original_exif),
        'stripped_tags': len(original_exif) - len(safe_exif),
        'kept_tags': list(safe_exif.keys()),
    }

更简单的方案:缩略图直接不保留任何 EXIF。

def generate_thumbnail_no_exif(image_path, output_path, max_width=800, quality=80):
    """生成缩略图,完全不保留 EXIF"""
    img = Image.open(image_path)
    img.thumbnail((max_width, max_width), Image.LANCZOS)
    
    # 保存时不传入 exif 参数 = 不包含 EXIF
    img.save(output_path, 'WebP', quality=quality)
    
    # 缩略图文件大小对比:
    # 带 EXIF:82 KB
    # 不带 EXIF:78 KB
    # EXIF 数据通常很小(几 KB),对缩略图影响不大

我的最终策略

EXIF_STRATEGY = {
    'original': '保留全部 EXIF',      # 原图原样保存
    'thumbnail': '不保留任何 EXIF',    # 缩略图不需要 EXIF
    'detail': '保留安全字段',          # 详情页展示拍摄参数
    'download': '保留全部 EXIF',       # 下载原图时保留
}

EXIF 的另一个用途:智能旋转

处理 EXIF 时,我发现了一个有趣的问题。

小李上传的竖拍照片,在我的网站上显示成了横的——头是歪的

# 问题原因:EXIF 中的 Orientation 字段
# 相机拍摄时传感器是横的,竖拍时只是记录了 Orientation=6(旋转 90°)
# 浏览器如果不读 EXIF,就不知道该旋转

ORIENTATION_VALUES = {
    1: '正常',
    2: '水平翻转',
    3: '旋转 180°',
    4: '垂直翻转',
    5: '旋转 90° + 水平翻转',
    6: '旋转 90°',   # ← 小李的竖拍照片是这个
    7: '旋转 270° + 水平翻转',
    8: '旋转 270°',
}

# 解决方案:在生成缩略图时自动旋转
def auto_rotate_image(img):
    """根据 EXIF Orientation 自动旋转图片"""
    from PIL import Image
    
    try:
        exif = img._getexif()
        if not exif:
            return img
        
        from PIL.ExifTags import TAGS
        orientation = None
        for tag_id, value in exif.items():
            if TAGS.get(tag_id) == 'Orientation':
                orientation = value
                break
        
        if orientation == 3:
            return img.rotate(180, expand=True)
        elif orientation == 6:
            return img.rotate(270, expand=True)
        elif orientation == 8:
            return img.rotate(90, expand=True)
    except Exception:
        pass
    
    return img

这个问题很隐蔽——在本地电脑上图片显示正常(因为系统会读 EXIF 自动旋转),上传到网站后就歪了。

我的思考

思考 1

社交媒体平台(如微信朋友圈)为什么不对上传的图片保留 EXIF?这对摄影师社区来说是损失吗?

参考答案

社交媒体剥离 EXIF 的原因

  1. 隐私保护:EXIF 中包含 GPS 坐标,用户在家拍照上传后,可能暴露家庭住址。微信有 10 亿用户,一旦出现位置泄露事件,后果不堪设想。

  2. 存储成本:EXIF 数据虽然每张只有几 KB,但乘以每天几十亿张图片,就是几十 TB 的额外存储。CDN 传输这些数据也要花钱。

  3. 安全考虑:EXIF 中的设备信息可能被用于社会工程攻击——知道你用什么相机、在哪里拍的,可以推断你的消费水平、活动范围。

对摄影师社区的影响

摄影师确实需要 EXIF 数据来展示拍摄参数、交流技术。但这两个需求可以分离处理

方案 1:原样保存,展示时选择性显示
- 存储:保留完整 EXIF
- 展示:只显示拍摄参数(光圈、快门、ISO)
- 隐藏:GPS 坐标、设备序列号

方案 2:提取后单独存储
- 上传时提取 EXIF 到数据库
- 图片文件本身剥离 EXIF(减少文件大小)
- 展示时从数据库读取

方案 2 更好:图片文件更小,数据结构化后可以搜索和统计。

# 方案 2 实现
def process_upload(image_path):
    # 提取 EXIF 存入数据库
    exif_data = extract_exif(image_path)
    safe_exif = filter_safe_exif(exif_data)
    
    db.insert('photo_metadata', {
        'photo_id': photo_id,
        'camera': safe_exif.get('Model'),
        'lens': safe_exif.get('LensModel'),
        'aperture': safe_exif.get('FNumber'),
        'shutter_speed': safe_exif.get('ExposureTime'),
        'iso': safe_exif.get('ISO'),
        'focal_length': safe_exif.get('FocalLength'),
        'taken_at': safe_exif.get('DateTimeOriginal'),
    })
    
    # 生成不带 EXIF 的缩略图
    generate_thumbnails_no_exif(image_path)
    
    # 原图也剥离 GPS 后保存
    strip_gps_exif(image_path, original_storage_path)

搜索