[Study] 개발 공부

[Python] EXIF(이미지 메타데이터)에서 GPS 정보 추출하기

지공A 2026. 1. 15. 08:42

목표:

사용자가 핸드폰으로 촬영한 이미지 파일을 서비스에 업로드할 때 GPS 정보를 추출하여 촬영 주소를 DB에 저장하고 싶다.


 

문제 접근:

  1. 업로드된 이미지 파일 bytes를 읽어오기
  2. EXIF 데이터를 가져오기
  3. EXIF 안에서 GPS 정보 찾기(GPSInfo)
  4. GPS 위도/경도 값은 보통 "도/분/초(DMS)"라서 "소수점(deg)"으로 변환하기

 

1. 먼저 EXIF의 GPS는 어떻게 저장되는가?

  • GPSLatitudeRef: "N" 또는 "S"
  • GPSLatitude: (도, 분, 초) (각 값이 Rational 형태)
  • GPSLongitudeRef: "E" 또는 "W"
  • GPSLongitude: (도, 분, 초)

핵심은 GPSLatitude(내가 필요한 정보) 값은 값이 소수점이 아니라 DMS 라는 점

deg = 도 + 분/60 + 초/3600

 

def _to_deg(value) -> float:
    d = float(value[0])
    m = float(value[1])
    s = float(value[2])
    return d + (m / 60.0) + (s / 3600.0)

 

2. 라이브러리(PIL)가 EXIF를 어떻게 주는지 확인

PIL(Pillow)는 img.getexif()를 주고, 그 안은 태그번호 → 값 형태

사람이 읽기 위해서 이 태그번호를 이름으로 바꿔야 한다.

 

  • PIL.ExifTags.TAGS → 일반 EXIF 태그 번호를 이름으로
  • PIL.ExifTags.GPSTAGS → GPSInfo 내부의 태그 번호를 이름으로

GPSInfo가 중첩구조기 때문에 두 번 디코딩 하는 작업이 필요하기 때문이다

 

  • 상위 EXIF: "GPSInfo"라는 태그
  • 그 안에는  {1: 'N', 2: ((33,1),(12,1),(1,1)), ...} 

1. EXIF 태그 번호를 이름으로 변경

from PIL import Image
from io import BytesIO
from PIL.ExifTags import TAGS

img = Image.open(BytesIO(image_bytes))
exif = img.getexif()

exif_data = {}
for tag_id, val in exif.items():
    exif_data[TAGS.get(tag_id, tag_id)] = val

print(exif_data.keys())

 여기서 "GPSInfo"를 확인한다

 

3. GPSInfo 내부 디코딩

from PIL.ExifTags import GPSTAGS

gps_info = exif_data.get("GPSInfo")
if gps_info:
    gps_data = {GPSTAGS.get(k, k): v for k, v in gps_info.items()}
    print(gps_data)

여기서 GPSLatitude, GPSLatituedRef, GPSLongitude 등 확인

 

3. 변환 공식 적용

 DES → 소수점 변환 후 Ref로 부호 처리



처음엔 Pillow 만으로 GPS를 추출하고자 했는데, PIL.Image.getexif()로는 GPS가 None으로 나오는 이슈가 있었다.

같은 파일은 exifread로 확인했더니 GPS 태그가 실제로 존재했기에,

PIL이 해당 JPG의 GPSInfo를 원하는 형태로 보여주지 못하는 케이스였다.

따라서 PIL로 먼저 시도하고, 실패하면 exifread로 GPS를 읽어오는 방식으로 변경했다.

 

4. 해결: exifread fallback으로 GPS 추출 안정화 

 

4-1. exifread DMS 변환

def _ratio_to_float(r) -> float:
    return float(r.num) / float(r.den)

def _dms_to_deg_exifread(dms) -> float:
    d = _ratio_to_float(dms[0])
    m = _ratio_to_float(dms[1])
    s = _ratio_to_float(dms[2])
    return d + (m / 60.0) + (s / 3600.0)

 

4-2. 최종 GPS 추출 함수

from io import BytesIO
from typing import Optional, Tuple

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

def extract_gps_from_image_bytes(image_bytes: bytes) -> Tuple[Optional[float], Optional[float]]:
    # 1) PIL 먼저 시도
    try:
        img = Image.open(BytesIO(image_bytes))
        exif = img.getexif()
        if exif:
            exif_data = {TAGS.get(tag_id, tag_id): val for tag_id, val in exif.items()}
            gps_info = exif_data.get("GPSInfo")
            if gps_info:
                gps_data = {GPSTAGS.get(k, k): v for k, v in gps_info.items()}

                lat_value = gps_data.get("GPSLatitude")
                lat_ref = gps_data.get("GPSLatitudeRef")
                lng_value = gps_data.get("GPSLongitude")
                lng_ref = gps_data.get("GPSLongitudeRef")

                if lat_value and lat_ref and lng_value and lng_ref:
                    lat = _to_deg(lat_value)
                    lng = _to_deg(lng_value)
                    if str(lat_ref).upper() == "S":
                        lat = -lat
                    if str(lng_ref).upper() == "W":
                        lng = -lng
                    return lat, lng
    except Exception:
        pass

    # 2) exifread fallback
    try:
        tags = exifread.process_file(BytesIO(image_bytes), details=False)

        lat_tag = tags.get("GPS GPSLatitude")
        lat_ref = tags.get("GPS GPSLatitudeRef")
        lng_tag = tags.get("GPS GPSLongitude")
        lng_ref = tags.get("GPS GPSLongitudeRef")

        if not (lat_tag and lat_ref and lng_tag and lng_ref):
            return None, None

        lat = _dms_to_deg_exifread(lat_tag.values)
        lng = _dms_to_deg_exifread(lng_tag.values)

        if str(lat_ref.values).upper().startswith("S"):
            lat = -lat
        if str(lng_ref.values).upper().startswith("W"):
            lng = -lng

        return lat, lng
    except Exception:
        return None, None

 

5. DB 저장 시 주의점: UploadFile은 스트림이라 seek(0)이 필요

 FastAPI UploadFile은 스트림 기반이라 await file.read()를 하면 포인터가 끝으로 간다

 이 상태로 업로드 로직이 다시 read()하면 빈 파일이 될 수 있다

 따라서 다음과 같은 패턴을 사용한다

  • bytes로 EXIF 읽기
  • seek(0)으로 되돌리기
  • 업로드 함수 호출
file.file.seek(0)
contents = await file.read()
file.file.seek(0)

lat, lng = extract_gps_from_image_bytes(contents)
capture.lat = lat
capture.lot = lng  # lot 컬럼이 longitude 의미인 경우

 

6. addr(주소) 저장은 EXIF가 아니라 역지오코딩으로 채운다

 EXIF에는 좌표값만 있기 때문에, 카카오 API로 역지오코딩하여 저장했다.


핸드폰으로 촬영한 사진에 GPS 태그가 있는 경우, 카카오톡/메일/ 전송 시 위치 정보가 사라질 수 있으니(보안 이슈) 주의하자

GPS 태그가 있어도 PIL만으로 못읽는 이슈를 해결해보았다