[Python] EXIF(이미지 메타데이터)에서 GPS 정보 추출하기
목표:
사용자가 핸드폰으로 촬영한 이미지 파일을 서비스에 업로드할 때 GPS 정보를 추출하여 촬영 주소를 DB에 저장하고 싶다.
문제 접근:
- 업로드된 이미지 파일 bytes를 읽어오기
- EXIF 데이터를 가져오기
- EXIF 안에서 GPS 정보 찾기(GPSInfo)
- 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만으로 못읽는 이슈를 해결해보았다