본문 바로가기
목차
Python

EXIF와 IFD: Pillow로 이미지 메타데이터 읽기

by ds31x 2026. 7. 25.
728x90
반응형

이 글에서는 IFD의 구조를 정리하고, Pillow로 각 IFD의 정보를 읽는 방법을 다룸.

  • EXIF (EXchangeable Image File format) 는 디지털 카메라가 촬영 조건과 위치 등의 정보를 이미지 파일에 함께 기록하는 metadata 규격임.
  • 이 규격은 TIFF 구조를 그대로 차용하며, 실제 데이터는 IFD (Image File Directory) 라는 tag 묶음 단위로 저장됨.


1. EXIF는 독자 포맷이 아니라 TIFF의 재사용임

  • EXIF (EXchangeable Image File format)
    • 디지털 카메라가 기록하는 이미지 metadata 규격.
    • 자체적인 binary layout을 새로 정의한 것이 아님.
    • TIFF Rev. 6.0 의 metadata 구조를 그대로 차용함.
  • JPEG 파일에서의 위치
    • JPEG은 marker 기반 container 구조임.
    • EXIF는 APP1 marker (0xFFE1) 안에 들어감.
    • APP1 segment 내부는 "Exif\x00\x00" signature 6 byte 뒤에 TIFF의 metadata 구조 (TIFF header + IFD들) 가 그대로 들어감.
      • "TIFF image file 하나가 통째로 들어간다" 는 뜻이 아님. TIFF에서 metadata를 기술하는 부분만 차용한 것임.
      • 본문 image의 pixel data는 이 안에 없음. JPEG 자신의 compressed data stream에 있음.
      • 예외는 thumbnail임. thumbnail의 실제 JPEG data는 이 segment 안에 저장되고, IFD1이 그 offset과 length를 가리킴.
  • 따라서 성립하는 결론
    • EXIF를 parsing 한다는 것 = TIFF metadata 구조를 parsing 한다는 것.
    • TIFF의 metadata 단위가 바로 IFD임.
    • 즉, EXIF를 이해하려면 IFD를 이해해야 함.

PNG, WebP, HEIF 등도
각자의 chunk/box 안에
동일한 TIFF 구조를 넣는 방식으로
EXIF를 지원함.
container만 다르고 내용물은 같음.


2. IFD (Image File Directory)의 정의와 물리 구조

2.1. 정의

  • IFD는 tag 항목들을 모아 놓은 하나의 directory (record table) 임.
  • "IFD 하나 = 관련 있는 tag들의 묶음 하나" 로 이해하면 정확함.
  • 하나의 EXIF 안에 IFD가 여러 개 존재하며, 서로 계층 구조를 이룸.
    • 이 사실이 Pillow 사용 시 혼란의 대부분을 차지함.

2.2. 물리적 layout

TIFF header (8 byte)
├─ byte order  : "II" (little-endian) | "MM" (big-endian)   2 byte
├─ magic       : 42                                          2 byte
└─ IFD0 offset : 보통 8                                       4 byte

IFD0 (header의 IFD0 offset이 가리키는 위치)
├─ entry count : n                                           2 byte
├─ entry[0 .. n-1]                                          12 byte x n
│   ├─ tag ID (2 byte)
│   ├─ type   (2 byte)
│   ├─ count  (4 byte)
│   └─ value or offset (4 byte)
└─ next IFD offset : 0 이면 종료                              4 byte

  ※ 위 layout은 IFD0 전용이 아님. Exif IFD, GPS IFD, IFD1 등
     모든 IFD가 동일한 형식을 가짐. IFD0는 그중 진입점일 뿐임.
  • byte order
    • II = Intel = little-endian, MM = Motorola = big-endian.
    • 같은 EXIF라도 제조사에 따라 다름. 직접 parsing 시 반드시 처리해야 함.
  • type (주요 값)
    • 1 = BYTE, 2 = ASCII, 3 = SHORT (2 byte), 4 = LONG (4 byte)
    • 5 = RATIONAL (numerator 4 byte + denominator 4 byte = 8 byte)
    • 7 = UNDEFINED, 10 = SRATIONAL (signed)
  • value or offset 필드의 규칙
    • sizeof(type) * count <= 4 이면 값을 직접 저장함.
    • 초과하면 TIFF header 기준 offset을 저장하고, 실제 값은 다른 위치에 있음.
    • RATIONAL은 8 byte이므로 항상 offset 참조임.
      • Pillow에서 IFDRational 객체로 노출되는 값들이 여기에 해당함 (자세한 설명은 5.3).
      • 노출 시간, 초점 거리, GPS 좌표가 모두 RATIONAL임.

3. IFD가 계층을 이루는 두 가지 방식

3.1. chain 방식

  • IFD 말미의 next IFD offset이 다음 IFD를 가리킴.
  • IFD0 : IFD1 이 이 관계임.
  • 마지막 IFD는 이 값이 0임.

3.2. pointer tag 방식

  • 특정 tag의 값 자체가 하위 IFD의 offset인 경우임.
  • 이 방식으로 sub-IFD가 중첩됨.

3.3. 두 방식을 합친 전체 그림

  • 2.2의 그림은 IFD 하나의 내부 byte layout이고, 아래 그림은 IFD들 사이의 연결 관계임. 축이 다르므로 혼동하지 말 것.
  • 아래의 각 block은 2.2에서 본 것과 동일한 구조를 가짐. entry 나열 뒤에 next IFD offset이 오는 형태임.
  • 각 entry의 우변은 2.2의 value or offset 필드에 무엇이 들어 있는지를 표시한 것임.
[IFD0] 주 이미지의 속성
├─ 0x010F Make            : offset (ASCII, 문자열이 길어 4 byte 초과)
├─ 0x0112 Orientation     : 값     (SHORT 1개 = 2 byte)
├─ 0x8769 ExifOffset      : 값     (LONG 1개 = 4 byte, 그 값이 곧 주소) -> [Exif IFD]
├─ 0x8825 GPSInfoOffset   : 값     (LONG 1개 = 4 byte, 그 값이 곧 주소) -> [GPS IFD]
└─ next IFD offset : entry가 아니라 IFD 말미의 고정 4 byte              -> [IFD1]

[Exif IFD] 촬영 조건
├─ 0x9003 DateTimeOriginal : offset (ASCII 20 byte)
├─ 0x829A ExposureTime     : offset (RATIONAL = 8 byte)
├─ 0x8827 ISOSpeedRatings  : 값     (SHORT 1개 = 2 byte)
├─ 0xA005 InteropOffset    : 값     (LONG 1개 = 4 byte, 그 값이 곧 주소) -> [Interop IFD]
├─ 0x927C MakerNote        : offset (UNDEFINED, data가 커서 4 byte 초과) -> [MakerNote IFD]
└─ next IFD offset : 0

[GPS IFD] 위치
├─ 0x0001 GPSLatitudeRef : 값     (ASCII 2 byte : "N" + NUL)
├─ 0x0002 GPSLatitude    : offset (RATIONAL x 3 = 24 byte)
└─ next IFD offset : 0

[IFD1] thumbnail의 속성
├─ 0x0201 JPEGInterchangeFormat       : 값 (LONG, 그 값이 곧 주소) -> thumbnail의 JPEG data
├─ 0x0202 JPEGInterchangeFormatLength : 값 (LONG 1개 = 4 byte)
└─ next IFD offset : 0
  • 우변이 / offset 으로 갈리는 기준은 2.2의 sizeof(type) * count <= 4 규칙 그대로임.
    • Orientation, ISOSpeedRatings : SHORT 1개 = 2 byte 이므로 entry 안에 값이 그대로 들어감.
    • GPSLatitudeRef : ASCII이지만 "N" + NUL = 2 byte 이므로 값이 직접 들어감.
    • Make, DateTimeOriginal : 같은 ASCII라도 문자열 길이 + NUL 이 4 byte를 넘으므로 offset이 됨.
      • type이 ASCII인지 RATIONAL인지가 기준이 아니라, sizeof(type) * count 의 크기가 기준임.
      • 실제로 EXIF의 ASCII tag는 대부분 4 byte를 넘어서 offset 참조가 됨.
  • type과 count는 저장 방식과 무관하게 언제나 "실제 값"을 기술함.
    • Make의 값이 멀리 떨어져 있어도 그 entry의 type은 여전히 2 (ASCII) 이고, count도 문자열 길이 그대로임.
    • "이 4 byte는 값이 아니라 offset이다" 라고 알려 주는 flag는 존재하지 않음.
    • reader가 매번 sizeof(type) * count 를 계산해서 스스로 판정해야 함. 이것이 EXIF parsing이 번거로운 이유 중 하나임.
  • offset 이라는 말이 두 가지 의미로 쓰이므로 구분할 것.
    • (1) 저장상의 offset : 값이 4 byte에 안 들어가서 주소를 대신 넣은 경우.
      • Make, DateTimeOriginal, ExposureTime, GPSLatitude, MakerNote 가 해당함.
      • 주소는 parsing 과정에서 소비되고 사라짐. 사용자는 원래 값만 보게 됨.
    • (2) 의미상의 주소 : tag의 값 자체가 원래부터 주소인 경우.
      • ExifOffset (0x8769), GPSInfoOffset (0x8825), InteropOffset (0xA005), JPEGInterchangeFormat (0x0201) 이 해당함.
      • 이들은 LONG 1개 = 4 byte 이므로 오히려 (1)에 해당하지 않고 entry 안에 직접 들어감.
      • Pillow의 getexif() 결과에 34665 같은 정수가 그대로 보이는 것이 바로 이 경우임. 촬영 정보가 아니라 주소이므로 사람이 읽을 값이 아님.
  • 위 그림에서 읽어야 할 것
    • IFD0 : IFD1 만 chain (next IFD offset) 으로 연결됨.
    • Exif IFD, GPS IFD, Interop IFD, MakerNote IFD 는 전부 pointer tag로 연결됨.
    • sub-IFD들의 next IFD offset0임. 즉 chain은 IFD0 계열에서만 의미를 가짐.
  • 핵심
    • Pillow의 getexif()가 돌려주는 것은 이 그림의 [IFD0] 하나뿐임.
    • 나머지 block은 get_ifd()로 화살표를 따라가야 얻어짐.

3.4. IFD를 분리해서 유지해야 하는 이유

  • tag ID는 IFD 단위의 namespace를 가짐.
  • 동일한 ID가 IFD에 따라 전혀 다른 의미를 가짐.
    • 0x0001 : GPS IFD에서는 GPSLatitudeRef, Interop IFD에서는 InteropIndex.
  • 모든 IFD를 하나의 flat dict로 병합하면 ID 충돌로 정보가 소실됨.
  • Pillow 8.2.0에서 getexif()의 flatten 동작을 폐기하고 get_ifd()로 분리한 이유가 이것임.

4. 각 IFD에 들어 있는 정보

4.1. IFD0 - 파일 일반 속성

  • 이미지 파일 자체의 속성. 촬영 조건이 아님.
  • 주요 tag
    • Make (271), Model (272) : 제조사, 기종
    • Orientation (274) : 회전 정보 (1 ~ 8)
    • XResolution (282), YResolution (283), ResolutionUnit (296)
      • ResolutionUnit 의 값: 1(None), 2(inch), 3(centimeter)
      • 2 일 경우 dpi
      • 3일 경우 dpi환산하려면 2.54 곱해야함.
    • Software (305) : 생성/편집 software
    • DateTime (306) : 파일 최종 수정 시각
    • Artist (315), Copyright (33432), ImageDescription (270)
  • 주의
    • DateTime (306) 을 촬영 시각으로 오인하는 경우가 매우 흔함.
    • 실제 촬영 시각은 Exif IFD의 DateTimeOriginal (36867) 임.

4.2. Exif IFD (0x8769) - 촬영 조건

  • 실무에서 필요한 값의 대부분이 여기에 있음.
  • 노출 계열
    • ExposureTime (33434), FNumber (33437), ISOSpeedRatings (34855)
    • ExposureProgram (34850), ExposureBiasValue (37380)
    • MeteringMode (37383), Flash (37385)
    • ExposureMode (41986), WhiteBalance (41987)
  • 광학 계열
    • FocalLength (37386), FocalLengthIn35mmFilm (41989)
    • LensMake (42035), LensModel (42036), LensSpecification (42034)
  • 시각 계열
    • DateTimeOriginal (36867) : 실제 촬영 시각
    • DateTimeDigitized (36868) : digitize 시각
    • SubSecTimeOriginal (37521) : 소수점 이하 초
  • 이미지 특성
    • ColorSpace (40961), PixelXDimension (40962), PixelYDimension (40963)
    • SceneCaptureType (41990), ExifVersion (36864)
    • UserComment (37510) : 자유 기술 field
  • 주의
    • PixelXDimension / PixelYDimension 은 편집 software가 갱신하지 않는 경우가 많음.
    • 실제 크기는 img.size 로 확인할 것.

4.3. GPS IFD (0x8825) - 위치 정보

  • 독립된 namespace를 가지는 위치 관련 tag 집합.
  • 주요 tag
    • GPSLatitudeRef (0x01), GPSLatitude (0x02)
    • GPSLongitudeRef (0x03), GPSLongitude (0x04)
    • GPSAltitudeRef (0x05), GPSAltitude (0x06)
    • GPSTimeStamp (0x07), GPSDateStamp (0x1D) : UTC 기준
    • GPSImgDirection (0x11), GPSMapDatum (0x12)
    • GPSHPositioningError (0x1F)
  • 저장 형식
    • 좌표는 십진수가 아니라 degree, minute, second 3개의 RATIONAL tuple임.
    • 부호 (남북 / 동서) 는 값이 아니라 Ref tag의 ASCII 문자로 분리 저장됨.

4.4. Interop IFD (0xA005) - 호환성 표기

  • DCF 규격 준수 여부를 표시하는 매우 작은 IFD.
  • InteropIndex (0x0001), InteropVersion (0x0002)
  • RelatedImageFileFormat (0x1000), RelatedImageWidth (0x1001), RelatedImageHeight (0x1002)
  • InteropIndex 값은 통상 R98 (sRGB), THM (thumbnail), R03 (Adobe RGB) 중 하나임.
  • 분석 목적으로는 거의 쓰이지 않음.

4.5. IFD1 - thumbnail

  • IFD0의 chain으로 연결됨. 본문 이미지가 아니라 내장 thumbnail의 속성을 기술함.
  • Compression (259)
  • JPEGInterchangeFormat (513) : thumbnail data의 offset
  • JPEGInterchangeFormatLength (514) : byte 길이
  • 활용 및 주의
    • 위 offset과 length로 원본 파일에서 thumbnail JPEG을 직접 잘라낼 수 있음.
    • privacy 관점 주의 : 본문 이미지를 편집하거나 masking 해도 IFD1의 thumbnail은 편집 전 원본으로 남는 사례가 빈번함.

4.6. MakerNote (0x927C) - 제조사 전용

  • 제조사가 자유롭게 정의하는 비표준 binary 영역.
  • 촬영 mode, lens serial, AF point, 색 보정 parameter 등 정보량이 많음.
  • 다만 형식이 회사별, 기종별로 달라서 일반적인 parsing이 불가능함.
  • Pillow는 접근 경로만 제공하고 제조사별 해석은 보장하지 않음.
    • 정밀 분석이 필요하면 exiftool 계열 도구를 쓸 것.

5. Pillow로 읽기

2025.07.15 - [Python] - Pillow에서 Image 객체의 주요 attribute.

 

Pillow에서 Image 객체의 주요 attribute.

Pillow(PIL)의 Image 객체는 Pillow 라이브러리에서 이미지 처리의 핵심이 되는 클래스임.이는 이미지를 추상화 하며, 다음과 같은 주요 속성들을 가지고 있음:기본 정보 속성size이미지의 크기를 (width,

ds31x.tistory.com


5.1. 기본 API

  • Image.getexif()
    • PIL.Image.Exif instance를 반환함. MutableMapping 을 상속하므로 dict처럼 다룰 수 있음.
    • EXIF가 없어도 None이 아니라 Exif 객체를 반환함.
      • 존재 여부는 if not exif: 로 판정할 것.
    • 노출되는 것은 IFD0 뿐임.
  • Exif.get_ifd(tag)
    • sub-IFD를 dict로 반환함. 없으면 빈 dict.
    • ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo, ExifTags.IFD.Interop, ExifTags.IFD.IFD1, ExifTags.IFD.MakerNote 사용 가능.
  • name mapping
    • ExifTags.TAGS : tag ID : 이름 (IFD0, Exif IFD 용)
    • ExifTags.GPSTAGS : GPS tag ID : 이름
    • ExifTags.Base, ExifTags.GPS : IntEnum 형태. 가독성이 좋아서 권장됨.

5.2. 전체 IFD 덤프

from PIL import Image, ExifTags


def dump_exif(path):
    """모든 IFD를 순회하며 tag를 출력."""
    with Image.open(path) as img:
        exif = img.getexif()  # IFD0만 담긴 Exif 객체. EXIF 없어도 None이 아니라 빈 객체임
        if not exif:
            print("EXIF 없음")
            return

        # (label, sub-IFD tag, 이름 조회용 table) 목록.
        # ifd_id가 None인 IFD0만 exif 자체에서 바로 읽고, 나머지는 get_ifd()로 열어야 함.
        targets = [
            ("IFD0", None, ExifTags.TAGS),
            ("Exif", ExifTags.IFD.Exif, ExifTags.TAGS),
            ("GPSInfo", ExifTags.IFD.GPSInfo, ExifTags.GPSTAGS),  # GPS는 이름 table이 다름
            ("Interop", ExifTags.IFD.Interop, ExifTags.TAGS),
            ("IFD1", ExifTags.IFD.IFD1, ExifTags.TAGS),  # thumbnail의 속성
        ]

        for label, ifd_id, table in targets:
            # IFD0는 exif 자체, 나머지는 get_ifd()로 sub-IFD를 열어서 얻음.
            # 해당 IFD가 없으면 get_ifd()는 예외 없이 빈 dict를 돌려줌.
            data = exif if ifd_id is None else exif.get_ifd(ifd_id)
            print(f"[{label}] {len(data)} tags")
            for tag_id, value in data.items():
                # tag_id는 정수. table에 이름이 없으면 정수 그대로 표시함.
                name = table.get(tag_id, tag_id)
                # !r : IFDRational, bytes 등 실제 Python type이 그대로 드러나도록 repr 사용
                print(f"    {name} ({tag_id}) = {value!r}")
            print()


if __name__ == "__main__":
    dump_exif("sample.jpg")
  • 출력에서 ExifOffset (34665) = 123 처럼 정수 하나가 보인다면, 그것은 데이터가 아니라 sub-IFD의 offset임.
    • 실제 내용은 get_ifd() 로 열어야 함.
    • offset tag를 결과에서 빼고 싶으면 exif.hide_offsets() 를 호출하면 됨.

참고: IFDRational 이란

  • PIL.TiffImagePlugin 에 정의된 class로, EXIF/TIFF의 RATIONAL, SRATIONAL type 값을 표현하는 유리수 class임.
  • Python 표준 numbers.Rational 을 상속함.
    • 따라서 float(), int() 변환과 사칙연산, 비교 연산이 자연스럽게 동작함.
    • numerator, denominator property를 제공함.
    • limit_rational(max_denominator) 로 분모를 특정 한계 이하로 제한한 (numerator, denominator) tuple을 얻을 수 있음.
  • 일반 Fraction 과 다른 점은 분모 0 을 legal한 값으로 허용한다는 것임.
    • DigitalZoomRatio 태그가 "digital zoom 미사용"을 0.00/0.00 으로 표기하는 것처럼, EXIF 자체가 이런 관행을 갖고 있어서임.
    • 일반 Fraction 이었다면 이 시점에 바로 ZeroDivisionError 가 발생함.
    • 그래서 float() 변환 전에 denominator != 0 검사가 항상 필요함. (5.4의 normalize() 참고)
  • 버전에 따라 반환 형태가 다름.
    • Pillow 7.2.0 이전 : legacy ImageFileDirectory_v1 기반이라 RATIONAL/SRATIONAL 값이 (numerator, denominator) plain tuple로 반환됨.
    • Pillow 7.2.0 이후 : Image.ExifImageFileDirectory_v2 기반으로 바뀌면서 IFDRational instance로 반환됨.
    • getexif() 를 쓰는 현재 표준 경로에서는 ExposureTime, FNumber, FocalLength, GPSLatitude 의 각 성분처럼 RATIONAL type 태그가 전부 IFDRational 로 옴.

5.3. 값 type 정규화

  • EXIF 값의 type이 균일하지 않다는 점이 두 번째 함정임.
    • RATIONAL : IFDRational 객체. 5.3 참고. float() 변환 전 denominator != 0 검사가 필요함.
    • UNDEFINED (type 7) : bytes 그대로 반환됨. json.dumps() 에 넘기면 TypeError: Object of type bytes is not JSON serializable 가 발생함.
      • UserComment (37510), MakerNote (0x927C) 등 이 type에 해당함.
      • ASCII (type 2) 는 이것과 다름. Pillow가 내부적으로 latin-1 로 decode하여 str 로 반환하므로 여기서는 문제되지 않음.
  • DataFrame 적재나 JSON 직렬화 전에 정규화 단계를 두는 것이 안전함.
import json
from PIL import Image, ExifTags
from PIL.TiffImagePlugin import IFDRational


def normalize(value):
    """EXIF 값을 JSON-safe type으로 변환."""
    if isinstance(value, IFDRational):
        # 분모가 0인 legal case(예: DigitalZoomRatio 미사용) 방어.
        # 검사 없이 float()를 호출하면 이 경우 ZeroDivisionError가 발생함.
        return float(value) if value.denominator != 0 else None
    if isinstance(value, bytes):
        # UNDEFINED type (UserComment, MakerNote 등)이 여기로 들어옴.
        # 원본이 정확히 utf-8이 아닐 수 있으므로 errors="replace"로 방어함.
        return value.decode("utf-8", errors="replace")
    if isinstance(value, (tuple, list)):
        # GPSLatitude처럼 (deg, min, sec) 형태의 RATIONAL tuple을 원소 단위로 재귀 처리.
        return [normalize(v) for v in value]
    if isinstance(value, dict):
        # get_ifd()가 돌려주는 sub-IFD dict를 그대로 넣었을 경우를 대비.
        return {k: normalize(v) for k, v in value.items()}
    # int, str처럼 이미 JSON-safe한 값은 그대로 통과시킴.
    return value


def read_exif(path):
    """IFD0 + Exif IFD + GPS IFD 를 이름 기반 dict로 반환."""
    with Image.open(path) as img:
        exif = img.getexif()  # IFD0. EXIF 없으면 빈 Exif 객체 (None 아님)
        if not exif:
            return {}

        result = {"IFD0": {}, "Exif": {}, "GPS": {}}

        # IFD0 : 기종, 방향 등 파일 일반 속성. tag ID : 이름은 ExifTags.TAGS로 조회.
        for k, v in exif.items():
            result["IFD0"][ExifTags.TAGS.get(k, str(k))] = normalize(v)

        # Exif sub-IFD (0x8769) : 촬영 조건. 반드시 get_ifd()로 별도 조회해야 함.
        for k, v in exif.get_ifd(ExifTags.IFD.Exif).items():
            result["Exif"][ExifTags.TAGS.get(k, str(k))] = normalize(v)

        # GPS sub-IFD (0x8825) : 위치 정보. namespace가 달라 GPSTAGS로 이름을 조회함.
        for k, v in exif.get_ifd(ExifTags.IFD.GPSInfo).items():
            result["GPS"][ExifTags.GPSTAGS.get(k, str(k))] = normalize(v)

        return result


if __name__ == "__main__":
    # ensure_ascii=False : 한글 등 non-ASCII 문자를 \uXXXX로 escape하지 않고 그대로 출력.
    print(json.dumps(read_exif("sample.jpg"), indent=2, ensure_ascii=False))

5.4. 자주 쓰는 값만 추출

from PIL import Image, ExifTags


def summarize(path):
    """촬영 정보 요약."""
    with Image.open(path) as img:
        exif = img.getexif()  # IFD0 (Make, Model, Orientation 등)
        exif_ifd = exif.get_ifd(ExifTags.IFD.Exif)  # 촬영 조건은 반드시 sub-IFD에서
        size = img.size  # 실제 pixel 크기는 PixelXDimension이 아니라 이걸로 확인

    def as_float(v):
        """IFDRational : float. 값이 없거나(None) 분모가 0이면 None."""
        try:
            return float(v)
        except (TypeError, ZeroDivisionError):
            # TypeError : v가 None인 경우 (해당 tag가 EXIF에 아예 없음)
            # ZeroDivisionError : IFDRational(0, 0) 같은 legal 0/0 값인 경우
            return None

    return {
        # Make/Model/Orientation은 IFD0 소속. exif에서 바로 조회함.
        "camera": f"{exif.get(ExifTags.Base.Make)} {exif.get(ExifTags.Base.Model)}",
        # 아래는 모두 Exif sub-IFD 소속이므로 exif_ifd에서 조회함.
        "lens": exif_ifd.get(ExifTags.Base.LensModel),
        "datetime_original": exif_ifd.get(ExifTags.Base.DateTimeOriginal),  # 실제 촬영 시각
        "orientation": exif.get(ExifTags.Base.Orientation),
        "exposure_time_sec": as_float(exif_ifd.get(ExifTags.Base.ExposureTime)),  # 셔터 속도
        "f_number": as_float(exif_ifd.get(ExifTags.Base.FNumber)),  # 조리개 값
        "iso": exif_ifd.get(ExifTags.Base.ISOSpeedRatings),  # SHORT라 IFDRational 아님
        "focal_length_mm": as_float(exif_ifd.get(ExifTags.Base.FocalLength)),
        "size": size,
    }


if __name__ == "__main__":
    for k, v in summarize("sample.jpg").items():
        print(f"{k:20s}: {v}")
  • Make, Model, Orientation 은 IFD0에서, 나머지 촬영 조건은 Exif IFD에서 꺼내고 있음에 주목할 것.
  • 이 구분을 놓치면 "값이 없다" 는 결론에 도달하게 됨.

6. GPS 좌표 변환

  • 저장 형식이 십진수가 아니므로 별도 변환이 필요함.
  • degree $D$, minute $M$, second $S$ 와 방향 문자 $\text{Ref}$ 에 대해

$$
\text{decimal} = \left( D + \frac{M}{60} + \frac{S}{3600} \right) \times s,
\qquad
s =
\begin{cases}
+1 & \text{Ref} \in {\mathrm{N}, \mathrm{E}} \
-1 & \text{Ref} \in {\mathrm{S}, \mathrm{W}}
\end{cases}
$$

from PIL import Image, ExifTags


def to_decimal(dms, ref):
    """(deg, min, sec) tuple + Ref 문자 : 십진 좌표."""
    # dms의 각 성분은 IFDRational. float()로 변환해야 사칙연산이 안전함.
    deg, minute, sec = (float(v) for v in dms)
    value = deg + minute / 60.0 + sec / 3600.0
    # 남/서(S, W)는 음수로 뒤집음. 위에서 정의한 s 값과 동일함.
    return -value if ref in ("S", "W") else value


def get_coordinates(path):
    """(latitude, longitude) 반환. 없으면 None."""
    with Image.open(path) as img:
        # GPS IFD (0x8825)만 필요하므로 곧바로 get_ifd()로 꺼냄.
        gps = img.getexif().get_ifd(ExifTags.IFD.GPSInfo)

    if not gps:  # GPS IFD 자체가 없는 사진(GPS 미탑재 카메라 등)
        return None
    try:
        # GPSLatitude/GPSLongitude는 (deg, min, sec), Ref는 "N"/"S"/"E"/"W" 문자.
        lat = to_decimal(gps[ExifTags.GPS.GPSLatitude], gps[ExifTags.GPS.GPSLatitudeRef])
        lon = to_decimal(gps[ExifTags.GPS.GPSLongitude], gps[ExifTags.GPS.GPSLongitudeRef])
    except (KeyError, ValueError, ZeroDivisionError):
        # KeyError : 위경도 중 일부 tag만 기록된 불완전한 GPS IFD
        # ValueError : float() 변환 실패
        # ZeroDivisionError : 분모가 0인 IFDRational이 섞여 있는 경우
        return None
    return lat, lon


if __name__ == "__main__":
    print(get_coordinates("sample.jpg"))

7. getexif()_getexif()

  • _getexif() 는 PIL 시절부터 존재한 private method임.
    • JpegImagePlugin 등 일부 plugin에만 정의되어 있어 PNG 등에서는 AttributeError 발생.
    • EXIF가 없으면 None, 있으면 평범한 dict[int, Any] 반환.
    • 내부적으로 self.getexif()._get_merged_dict() 를 호출하여 IFD0와 Exif IFD를 하나의 flat dict로 병합함.
  • 그래서 발생하는 현상
    • _getexif() 에서는 DateTimeOriginal (36867) 이 바로 보였는데, getexif() 로 바꾸면 사라진 것처럼 보임.
    • 사라진 것이 아니라 Exif IFD 안에 그대로 있음. get_ifd(ExifTags.IFD.Exif) 로 접근하면 됨.
  • 선택 기준
    • 신규 코드는 getexif() + get_ifd() 조합을 쓸 것.
    • getexif() 만이 수정과 재저장을 지원함. exif.tobytes() 또는 img.save(path, exif=exif).
    • _getexif() 는 "highly experimental" 이라는 주석이 붙은 private API이므로, 레거시 호환 목적으로만 제한적으로 쓸 것.

8. 정리

  • EXIF는 TIFF 구조를 빌려 쓴 규격이고, TIFF의 metadata 단위가 IFD임.
  • IFD는 하나가 아니라 tree를 이루며, tag ID의 의미는 IFD 단위로 결정됨.
  • Pillow의 getexif() 는 root인 IFD0만 반환함. 나머지는 get_ifd() 로 열어야 함.
  • 정보의 위치를 요약하면 다음과 같음.
    • 기종, 방향, 파일 수정 시각 : IFD0
    • 촬영 시각, 셔터, 조리개, ISO, 렌즈 : Exif IFD (0x8769)
    • 위치 : GPS IFD (0x8825)
    • thumbnail : IFD1
  • 값 type은 균일하지 않으므로 IFDRationalbytes 처리를 전제로 코드를 작성할 것.

같이 보면 좋은 자료들

Pillow, Image module reference (PIL.Image.Exif) :
https://pillow.readthedocs.io/en/stable/reference/Image.html

 

Image module

The Image module provides a class with the same name which is used to represent a PIL image. The module also provides a number of factory functions, including functions to load images from files, a...

pillow.readthedocs.io

 

Pillow, ExifTags module reference :

https://pillow.readthedocs.io/en/stable/reference/ExifTags.html

 

ExifTags module

The ExifTags module exposes several enum.IntEnum classes which provide constants and clear-text names for various well-known EXIF tags. Two of these values are also exposed as dictionaries.

pillow.readthedocs.io

 

Pillow 8.2.0 release notes (getexif() 의 sub-IFD 분리) : https://pillow.readthedocs.io/en/stable/releasenotes/8.2.0.html

 

8.2.0 (2021-04-01)

Security: These issues were all found with OSS-Fuzz. CVE 2021-25287, CVE 2021-25288: OOB read in Jpeg2KDecode: For J2k images with multiple bands, it’s legal to have different widths for each band,...

pillow.readthedocs.io

 

2025.07.15 - [Python] - Pillow에서 Image 객체의 주요 attribute.

 

Pillow에서 Image 객체의 주요 attribute.

Pillow(PIL)의 Image 객체는 Pillow 라이브러리에서 이미지 처리의 핵심이 되는 클래스임.이는 이미지를 추상화 하며, 다음과 같은 주요 속성들을 가지고 있음:기본 정보 속성size이미지의 크기를 (width,

ds31x.tistory.com

 

https://exiv2.org/tags.html

 

Exiv2 - Image metadata library and tools

0x013b 315 Image Exif.Image.Artist Ascii This tag records the name of the camera owner, photographer or image creator. The detailed format is not specified, but it is recommended that the information be written as in the example below for ease of Interoper

exiv2.org

 

728x90

'Python' 카테고리의 다른 글

[Ex] 이미지 해상도·DPI 통계 분석 CLI 도구 작성  (0) 2026.07.25
[Ex] 구구단  (0) 2026.06.12
[Ex] 기본 Python CLI프로그램 구조  (0) 2026.06.12
Entry Point - Python 과 C  (0) 2026.06.12
Lock and GIL  (0) 2026.06.01