여행 사진과 메타데이터를 하나의 독립된 HTML 스토리로 만드는 방법
요약
여행 사진의 EXIF 메타데이터를 활용하여 연대기적 경로와 캡션이 포함된 단일 HTML 스토리 파일을 생성하는 워크플로를 소개합니다. 사진 압축, 경로 재구성, 반응형 스타일링을 통해 서버 없이도 작동하는 독립적인 디지털 기록물을 만드는 방법을 다룹니다.
핵심 포인트
- EXIF 메타데이터를 활용한 촬영 시간 및 위치 정보 추출
- 연대기적 순서에 따른 챕터 구분 및 경로 재구성 방법
- 이미지 압축 및 단일 HTML 파일 패키징 기술
- 데이터의 무결성을 유지하는 편집 규칙 및 워크플로
여행 사진과 메타데이터를 하나의 독립된 HTML 스토리로 만드는 방법 — Agent Lab Journal
Agent Lab Journal
Guides
...
실용 가이드 · 초급
여행 사진과 메타데이터를 하나의 독립된 HTML 스토리로 만드는 방법
사진 갤러리는 개별적인 프레임들을 기억하지만, 대개 그 사이의 여정은 놓치곤 합니다. 이 가이드는 연대기적 순서와 대략적인 경로를 복구하고, 여행을 읽기 쉬운 챕터로 나누며, 사실적인 캡션(caption)을 추가하고, 선택된 사진들을 압축하여, 서버나 인터넷 연결 없이도 열 수 있는 하나의 HTML 파일로 패키징하는 방법을 보여줍니다.
레벨: 초급
읽기 시간: 35분
결과물: 하나의 독립된 HTML 스토리
목차
-
무엇을 만들 것인가
-
구체적인 3일간의 사례
-
안전한 작업 공간 준비
-
사진 메타데이터(metadata) 검사
-
편집 데이터 생성
-
경로 재구성
-
생성기(generator) 구축
-
빌드 실행 및 검토
-
결과 확인
-
일반적인 오류 처리
-
한계점 이해
무엇을 만들 것인가
완성된 파일인 travel-story.html에는 다음 내용이 포함됩니다:
-
제목, 여행 날짜, 서론 및 요약;
-
순서가 정해진 챕터 세트;
-
문서 내에 직접 그려진 간단한 경로;
-
연대기 순으로 배치된 선택된 사진들;
-
촬영 시간, 선택적 좌표, 그리고 수동으로 검토된 캡션;
-
반응형 스타일링(responsive styling) 및 하나의 파일 안에 포함된 모든 압축 이미지.
사실적 토대는 파일의 메타데이터(metadata)입니다: 촬영 시간, 카메라 모델, 방향(orientation), 크기(dimensions), 그리고 때로는 위치 정보입니다. JPEG 사진은 보통 이러한 필드들을 EXIF 데이터로 저장합니다. 위치 정보 기능이 있는 카메라는 GPS 좌표를 기록할 수도 있습니다.
경로는 턴 바이 턴 (turn-by-turn) 트랙이 아니라, 드문드문 있는 사진 위치 정보를 바탕으로 재구성된 경로가 될 것입니다. 만약 2시간의 이동 시간 동안 사진이 한 장도 찍히지 않았다면, 페이지는 어떤 도로, 철도 노선, 또는 도보 경로를 이용했는지 알 수 없습니다. 단지 한 지점이 다른 지점보다 먼저 발생했다는 사실만을 보여줄 수 있을 뿐입니다.
편집 규칙: 자동화는 증거를 분류하고 공백을 드러낼 수는 있지만, 사건을 지어내서는 안 됩니다. 장소, 활동, 또는 인물을 신뢰할 수 있게 식별할 수 없는 경우, 중립적인 캡션 (caption)을 사용하거나 누락된 사실을 직접 제공하십시오.
구체적인 사례: 3일간의 여행
186장의 사진이 들어 있는 폴더를 상상해 보세요. 첫째 날, 여행자는 도시를 탐험했습니다. 둘째 날에는 호수로 갔습니다. 셋째 날에는 작은 마을을 거쳐 돌아왔습니다. 일부 이미지는 좌표 정보가 있는 휴대폰에서 가져온 것이고, 다른 이미지들은 위치 데이터가 없는 카메라에서 가져온 것입니다. 카메라 시계는 한 시간 느렸고, IMG_8421.JPG와 같은 파일 이름에는 서사적인 의미가 담겨 있지 않습니다.
186장의 이미지를 모두 파일 이름 순서대로 출력하면 이야기가 아닌, 그저 커다란 갤러리가 될 것입니다. 대신 유용한 워크플로 (workflow)는 다음과 같은 작업을 수행합니다:
-
원본을 수정하지 않고 기술적인 인벤토리 (inventory) 생성;
-
빌드 프로세스 (build process) 중에 알려진 시계 오프셋 (clock offset) 수정;
-
여행을 일자별 및 의미 있는 전환점으로 분리;
-
대표적인 사진 선택;
-
중요한 순간에 검토된 캡션 (caption) 추가;
-
선택된 이미지만 압축;
-
해당 이미지들과 경로를 하나의 문서에 임베드 (embed).
편집된 스토리는 186장이 아닌 32장의 사진을 포함할 수 있습니다. 남은 원본들은 삭제되지 않습니다. 원본은 아카이브 (archive)에 그대로 유지되며, HTML 파일은 읽고, 복사하고, 보존하기에 실용적인 의도된 기록이 됩니다.
1단계. 안전한 작업 공간 준비
사진의 유일한 복사본을 대상으로 절대 실험하지 마세요. 프로젝트 디렉토리 (directory)를 생성하고 originals 폴더에 작업용 복사본을 넣으세요:
travel-story/
├── originals/
├── captions.csv
...
이 가이드의 생성기(generator)는 originals 폴더를 읽고, captions.csv로부터 편집 결정 사항을 읽어 들인 뒤, 최종 페이지를 output에 작성합니다. 원본 사진을 다시 쓰는 작업은 수행하지 않습니다.
Python 및 Pillow 설치
Python 3와 Pillow가 필요합니다. Pillow는 이미지를 읽고, 방향(orientation)을 적용하며, 크기를 조정하고, 압축된 JPEG 데이터를 생성합니다. 프로젝트 패키지가 격리된 상태로 유지될 수 있도록 별도의 가상 환경(virtual environment)을 생성하세요.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
...
Windows PowerShell에서는 다음 명령으로 환경을 활성화합니다:
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install Pillow
Pillow를 임포트(import)할 수 있는지 확인하세요:
python -c "from PIL import Image; print(Image.__version__)"
버전 번호가 출력되면 임포트에 성공한 것입니다. 이 명령어가 아래 워크플로우를 위해 반드시 특정 버전을 생성할 필요는 없습니다.
단계 2. 사진에 실제로 무엇이 포함되어 있는지 검사하기
생성기를 작성하기 전에, ExifTool을 사용하여 컬렉션을 검사하세요. 최종 빌드에는 선택 사항이지만, 누락된 촬영 시간, 누락된 위치 정보, 중복된 내보내기 파일, 그리고 일치하지 않는 카메라 시계를 찾는 데 매우 유용합니다.
프로젝트 디렉토리에서 검토용 테이블을 내보냅니다:
exiftool -csv \
-FileName \
-Directory \
...
PowerShell에서는 동일한 명령어를 한 줄로 작성합니다:
exiftool -csv -FileName -Directory -DateTimeOriginal -CreateDate -OffsetTimeOriginal -Make -Model -GPSLatitude -GPSLongitude -Orientation originals > metadata.csv
계속하기 전에 테이블을 검토하세요
-
촬영 시간 (Capture time): 시퀀스가 당신이 기억하는 사건들과 일치합니까?
-
좌표 (Coordinates): 최소한 일부 사진에는 좌표가 포함되어 있습니까?
-
카메라 모델 (Camera model): 시계가 잘못 설정된 장치를 구분할 수 있습니까?
-
방향 (Orientation): 세로 이미지가 회전이 필요할 가능성이 있습니까?
-
중복 (Duplicates): 원본과 소셜 미디어 내보내기 파일이 모두 존재합니까?
-
시계 오차 (Clock offset): 티켓, 메시지 또는 다른 알려진 사건을 사용하여 이를 확인할 수 있습니까?
EXIF 타임스탬프 (timestamp)에는 시간대 (time zone) 정보가 없는 경우가 많습니다. 그러한 값을 자동으로 UTC로 해석하지 마십시오. 한 번의 여행 내에서는 지원되지 않는 글로벌 시간대를 할당하는 것보다 올바른 순서를 유지하는 것이 일반적으로 더 중요합니다.
수정 사항을 설정으로 표현하기
만약 카메라 하나가 정확히 한 시간 느렸다면, 모든 원본을 수정하는 대신 생성기 (generator)에 해당 수정 사항을 기록하십시오:
CAMERA_TIME_SHIFTS = {
"Example Camera Model": 60
}
이 값은 빌드 (build) 중에 추가될 분 (minutes) 단위의 숫자입니다. 예시 모델을 파일에 보고된 정확한 모델로 교체하십시오. 일관된 규칙을 식별할 수 없다면, 컬렉션 전체에 수정을 적용하지 마십시오.
첫 번째 단계로 메타데이터 (metadata)를 덮어쓰지 마십시오. 잘못된 일괄 편집은 사용 가능한 최선의 증거를 파괴할 수 있습니다. 스토리가 검토될 때까지 수정 사항을 코드나 별도의 데이터 파일 내에서 재현 가능하게 유지하십시오.
단계 3. 편집 데이터 생성하기
생성기가 날짜와 좌표를 찾아낼 수는 있지만, 왜 특정 사진이 중요한지는 추측해서는 안 됩니다. 당신의 선택과 캡션 (caption)을 captions.csv에 넣으십시오:
filename,include,chapter,title,caption
IMG_8421.JPG,yes,Arrival,First view,"도착 직후의 역 광장."
IMG_8430.JPG,no,Arrival,,
...
확장자와 대소문자를 포함하여 정확한 파일명을 사용하십시오. 각 필드는 고유한 역할을 수행합니다:
-
include는 이미지가 나타날지 여부를 결정합니다.
-
chapter는 이미지들을 하나의 서사적 섹션(narrative section)으로 그룹화합니다.
-
title은 특정 이미지에 짧은 표시 제목을 부여합니다.
-
caption은 사실에 기반하여 사람이 검토한 맥락을 기록합니다.
좋은 캡션(caption)은 픽셀만으로는 이미 명확히 알 수 없는 정보를 추가합니다. "건물"이라는 표현은 약합니다. "도착 직후의 역 광장"이라는 표현은 검증되지 않은 건축물 이름을 주장하지 않으면서도, 해당 프레임을 여정(journey)과 연결해 줍니다.
날짜뿐만 아니라 의미에 따라 챕터(chapter)를 선택하세요
날짜는 신뢰할 수 있는 시작점이지만, 챕터는 경험의 일관된 부분을 설명해야 합니다. 하루라는 시간은 "아침 시장", "전망대로의 등반", "저녁 귀가"와 같이 나뉠 수 있습니다. 반대로, 조용한 이틀간의 체류는 하나의 챕터로 구성될 수 있습니다.
장소, 활동, 속도 또는 목적의 변화를 경계(boundary)로 사용하십시오. 긴 시간 간격과 큰 위치 이동은 유용한 신호이지만, 이는 자동적인 증거라기보다 편집 검토를 위한 프롬프트(prompt)로 남겨두어야 합니다.
4단계. 경로를 보수적으로 재구성하기
가장 안전한 기본 경로는 수정된 촬영 시간(capture time)순으로 정렬한 후, 지오태깅(geotagged)된 사진들의 순서입니다. 인접한 지점들은 직선 구간으로 연결됩니다. 이는 정확한 도로를 안다고 가장하지 않으면서, 알려진 장소들의 순서를 보여줍니다.
의심스러운 도약 탐지하기
잘못된 GPS 판독값은 이미지 하나를 수백 킬로미터 떨어진 곳에 위치시킬 수 있습니다. 지구상의 두 지점 사이의 거리를 추정하는 하버사인 공식(Haversine formula)을 사용하여 인접한 좌표를 비교할 수 있습니다:
추정 속도 = 지점 간 거리 / 경과 시간
단 하나의 보편적인 속도 제한을 사용하지 마십시오. 도보로는 불가능한 속도가 기차에서는 평범할 수 있습니다. 아래의 생성기(generator)는 증거를 조용히 삭제하는 대신, 검토를 위해 의심스러운 전환(transition)을 보고합니다.
좌표가 없는 이미지 처리하기
신중한 추론만을 사용하십시오:
-
태그가 지정되지 않은 이미지가 인접한 두 개의 태그된 이미지 사이에 위치하는 경우, 임의의 좌표를 부여하지 않고 해당 서사적 순서(narrative sequence)에 그대로 둘 수 있습니다.
-
인접한 두 위치가 동일하고 시간 간격이 짧다면, 챕터 할당(chapter assignment)이 적절할 가능성이 높지만 여전히 검토는 필요합니다.
-
인접한 지점들이 서로 멀리 떨어져 있다면, 이미지의 위치를 지정하지 않은 상태로 둡니다.
-
단순히 두 사진이 비슷해 보인다는 이유만으로 좌표를 복사해서는 안 됩니다.
오프라인 스토리는 외부 지도 제공업체를 필요로 하지 않습니다. 생성기(generator)는 위도(latitude)와 경도(longitude)를 인라인 SVG 다이어그램으로 정규화(normalize)합니다. 도로, 국경 또는 지명은 표시되지 않지만, 휴대성이 유지되며 개인적인 좌표를 제3자에게 전송하지 않습니다.
단계 5. 독립형 HTML 생성기 구축하기
다음 코드로 build_story.py를 생성합니다. 실행하기 전에 상단에 있는 여행 상수(trip constants)와 카메라 보정(camera correction) 값을 변경하십시오.
from __future__ import annotations
import base64
import csv
import html
import io
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from PIL import Image, ImageOps
from PIL.ExifTags import Base, GPS
PROJECT = Path(__file__).resolve().parent
SOURCE_DIR = PROJECT / "originals"
CAPTIONS_FILE = PROJECT / "captions.csv"
OUTPUT_DIR = PROJECT / "output"
OUTPUT_FILE = OUTPUT_DIR / "travel-story.html"
TRIP_TITLE = "Three Days: City, Lake, and the Road Home"
TRIP_INTRO = (
"A compact account reconstructed from selected photographs, "
"capture times, and reviewed location data."
)
MAX_IMAGE_EDGE = 1600
JPEG_QUALITY = 78
CAMERA_TIME_SHIFTS = {
# 본인의 파일에 있는 정확한 모델명으로 교체하십시오:
"Example Camera Model": 60,
}
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
@dataclass
class Editorial:
include: bool
chapter: str
title: str
caption: str
@dataclass
class Photo:
path: Path
captured_at: datetime
camera_model: str
latitude: Optional[float]
longitude: Optional[float]
chapter: str
title: str
caption: str
data_uri: str
def read_editorial() -> dict[str, Editorial]:
rows: dict[str, Editorial] = {}
with CAPTIONS_FILE.open(
"r", encoding="utf-8-sig", newline=""
) as handle:
for row in csv.DictReader(handle):
filename = (row.get("filename") or "").strip()
if not filename:
continue
include = (row.get("include") or "").strip().lower()
rows[filename] = Editorial(
include=include in {"yes", "true", "1", "include"},
chapter=(row.get("chapter") or "Trip").strip(),
title=(row.get("title") or "").strip(),
caption=(row.get("caption") or "").strip(),
)
return rows
def text_value(value) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace").strip()
return str(value).strip()
def parse_exif_datetime(exif) -> Optional[datetime]:
raw = exif.get(Base.DateTimeOriginal) or exif.get(Base.DateTime)
if not raw:
return None
try:
return datetime.strptime(text_value(raw), "%Y:%m:%d %H:%M:%S")
except ValueError:
return None
def rational_to_float(value) -> float:
try:
return float(value)
except (TypeError, ValueError, ZeroDivisionError):
return value.numerator / value.denominator
def dms_to_decimal(dms, reference) -> Optional[float]:
if not dms or len(dms) != 3:
return None
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기