DEV Community

Михаил
Михаил

Posted on • Originally published at agentlabjournal.online

How to Turn Trip Photos and Metadata into a Self-Contained HTML Story

How to Turn Trip Photos and Metadata into a Self-Contained HTML Story — Agent Lab Journal

  Agent Lab Journal

    Guides
    Glossary
Enter fullscreen mode Exit fullscreen mode

Practical guide · beginner

How to Turn Trip Photos and Metadata into a Self-Contained HTML Story

A photo gallery remembers isolated frames but usually loses the journey between them. This guide shows you how to recover the chronology and approximate route, divide a trip into readable chapters, add factual captions, compress the selected photographs, and package everything into one HTML file that opens without a server or internet connection.

      Level: beginner
      Reading time: 35 minutes
      Result: one self-contained HTML story
Enter fullscreen mode Exit fullscreen mode

Contents

  • What you will build

  • A concrete three-day case

  • Prepare a safe workspace

  • Inspect photo metadata

  • Create the editorial data

  • Reconstruct the route

  • Build the generator

  • Run and review the build

  • Verify the result

  • Handle common failures

  • Understand the limitations

What you will build

The finished file, travel-story.html, will contain:

  • a title, trip dates, introduction, and summary;

  • an ordered set of chapters;

  • a simple route drawn directly in the document;

  • selected photographs in chronological order;

  • capture times, optional coordinates, and manually reviewed captions;

  • responsive styling and all compressed images inside one file.

The factual foundation is the files’ metadata: capture time, camera model, orientation, dimensions, and sometimes location. JPEG photographs normally store these fields as EXIF data. Location-capable cameras may also record GPS coordinates.

The route will be a reconstruction from sparse photo locations, not a turn-by-turn track. If no photograph was taken during a two-hour transfer, the page cannot know which road, train line, or walking path was used. It can only show that one known point came before another.

      Editorial rule: automation may sort evidence and expose gaps, but it must not invent events. When a place, activity, or person cannot be identified reliably, use a neutral caption or supply the missing fact yourself.
Enter fullscreen mode Exit fullscreen mode

A concrete case: a three-day trip

Imagine a folder containing 186 photographs. On the first day, the traveler explored a city. On the second, they went to a lake. On the third, they returned through a small town. Some images came from a phone with coordinates, while others came from a camera without location data. The camera clock was one hour slow, and filenames such as IMG_8421.JPG carry no narrative meaning.

Printing all 186 images in filename order would produce a larger gallery, not a story. A useful workflow instead does the following:

  • creates a technical inventory without modifying the originals;

  • corrects the known clock offset in the build process;

  • separates the trip into days and meaningful transitions;

  • selects representative photographs;

  • adds reviewed captions to important moments;

  • compresses only the selected images;

  • embeds those images and the route in one document.

The edited story might contain 32 photographs rather than 186. The remaining originals are not deleted. They stay in the archive, while the HTML file becomes a deliberate account that is practical to read, copy, and preserve.

Step 1. Prepare a safe workspace

Never experiment on the only copy of your photographs. Create a project directory and place working copies in originals:

travel-story/
├── originals/
├── captions.csv
├── build_story.py
└── output/
Enter fullscreen mode Exit fullscreen mode

The generator in this guide reads originals, reads editorial decisions from captions.csv, and writes the final page to output. It does not rewrite the source photographs.

Install Python and Pillow

You need Python 3 and Pillow. Pillow reads the images, applies their orientation, resizes them, and produces compressed JPEG data. Create a separate virtual environment so the project’s packages remain isolated.

python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install Pillow
Enter fullscreen mode Exit fullscreen mode

On Windows PowerShell, activate the environment with:

.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install Pillow
Enter fullscreen mode Exit fullscreen mode

Confirm that Pillow can be imported:

python -c "from PIL import Image; print(Image.__version__)"
Enter fullscreen mode Exit fullscreen mode

A version number means the import succeeded. This command does not need to produce any particular version for the workflow below.

Step 2. Inspect what the photographs actually contain

Before writing a generator, inspect the collection with ExifTool. It is optional for the final build but extremely useful for finding missing capture times, absent locations, duplicated exports, and inconsistent camera clocks.

From the project directory, export a review table:

exiftool -csv \
  -FileName \
  -Directory \
  -DateTimeOriginal \
  -CreateDate \
  -OffsetTimeOriginal \
  -Make \
  -Model \
  -GPSLatitude \
  -GPSLongitude \
  -Orientation \
  originals > metadata.csv
Enter fullscreen mode Exit fullscreen mode

In PowerShell, put the same command on one line:

exiftool -csv -FileName -Directory -DateTimeOriginal -CreateDate -OffsetTimeOriginal -Make -Model -GPSLatitude -GPSLongitude -Orientation originals > metadata.csv
Enter fullscreen mode Exit fullscreen mode

Review the table before continuing

  • Capture time: does the sequence agree with events you remember?

  • Coordinates: are they present for at least some photographs?

  • Camera model: can you distinguish the device with the incorrect clock?

  • Orientation: are portrait images likely to require rotation?

  • Duplicates: are both originals and social-media exports present?

  • Clock offset: can you confirm it using a ticket, message, or another known event?

An EXIF timestamp often has no time zone. Do not automatically interpret such a value as UTC. Within one trip, preserving the correct order is generally more important than assigning an unsupported global time zone.

Represent corrections as configuration

If one camera was exactly one hour slow, record that correction in the generator rather than changing every original:

CAMERA_TIME_SHIFTS = {
    "Example Camera Model": 60
}
Enter fullscreen mode Exit fullscreen mode

The value is a number of minutes added during the build. Replace the example model with the exact model reported by your files. If you cannot identify a consistent rule, do not apply a collection-wide correction.

      Do not overwrite metadata as a first step. A mistaken bulk edit can destroy the best available evidence. Keep corrections reproducible in code or a separate data file until the story has been reviewed.
Enter fullscreen mode Exit fullscreen mode

Step 3. Create the editorial data

The generator can discover dates and coordinates, but it should not guess why a photograph matters. Put your selections and captions in captions.csv:

filename,include,chapter,title,caption
IMG_8421.JPG,yes,Arrival,First view,"The station square shortly after arrival."
IMG_8430.JPG,no,Arrival,,
IMG_8462.JPG,yes,Old Town,Morning streets,"A quiet side street before the shops opened."
IMG_8610.JPG,yes,The Lake,At the shore,"The first clear view of the lake from the eastern path."
IMG_8794.JPG,yes,Return,Last stop,"A short stop in the town on the way home."
Enter fullscreen mode Exit fullscreen mode

Use the exact filename, including its extension and capitalization. The fields have distinct jobs:

  • include controls whether the image appears;

  • chapter groups images into a narrative section;

  • title gives one image a short display title;

  • caption records the factual, human-reviewed context.

A good caption adds information not already obvious from the pixels. “A building” is weak. “The station square shortly after arrival” connects the frame to the journey without claiming an unverified architectural name.

Choose chapters by meaning, not only by date

Dates are a reliable starting point, but a chapter should describe a coherent part of the experience. A single day might become “Morning Market,” “Climb to the Viewpoint,” and “Evening Return.” Conversely, a quiet two-day stay might form one chapter.

Use changes in place, activity, pace, or objective as boundaries. Long time gaps and large location jumps are useful signals, but they remain prompts for editorial review rather than automatic proof.

Step 4. Reconstruct the route conservatively

The safest basic route is the sequence of geotagged photographs after sorting by corrected capture time. Adjacent points are joined with straight segments. This shows the order of known locations without pretending to know the exact road.

Detect suspicious jumps

A bad GPS reading can place one image hundreds of kilometers away. You can compare adjacent coordinates with the Haversine formula, which estimates the distance between two points on Earth:

estimated speed = distance between points / elapsed time
Enter fullscreen mode Exit fullscreen mode

Do not use one universal speed limit. A speed that is impossible on foot may be ordinary on a train. The generator below reports suspicious transitions for review instead of silently deleting evidence.

Handle images without coordinates

Use only cautious inference:

  • If an untagged image falls between two nearby tagged images, it can remain in that narrative sequence without receiving invented coordinates.

  • If both neighboring locations are the same and the time gap is short, the chapter assignment is probably reasonable, but still review it.

  • If neighboring points are far apart, leave the image unlocated.

  • Never copy coordinates merely because two photographs look similar.

An offline story does not require an external map provider. The generator normalizes latitude and longitude into an inline SVG diagram. It will not show roads, borders, or place names, but it remains portable and does not send private coordinates to a third party.

Step 5. Build the self-contained HTML generator

Create build_story.py with the following code. Change the trip constants and camera correction near the top before running it.

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 = {
    # Replace with the exact model from your own files:
    "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

    degrees = rational_to_float(dms[0])
    minutes = rational_to_float(dms[1])
    seconds = rational_to_float(dms[2])
    decimal = degrees + minutes / 60 + seconds / 3600

    ref = text_value(reference).upper()
    if ref in {"S", "W"}:
        decimal *= -1

    return decimal

def extract_coordinates(exif):
    gps = exif.get_ifd(Base.GPSInfo)
    if not gps:
        return None, None

    latitude = dms_to_decimal(
        gps.get(GPS.GPSLatitude),
        gps.get(GPS.GPSLatitudeRef),
    )
    longitude = dms_to_decimal(
        gps.get(GPS.GPSLongitude),
        gps.get(GPS.GPSLongitudeRef),
    )

    if latitude is not None and not -90 <= latitude <= 90:
        return None, None
    if longitude is not None and not -180 <= longitude <= 180:
        return None, None

    return latitude, longitude

def encode_image(path: Path) -> str:
    with Image.open(path) as image:
        image = ImageOps.exif_transpose(image)
        image.thumbnail(
            (MAX_IMAGE_EDGE, MAX_IMAGE_EDGE),
            Image.Resampling.LANCZOS,
        )

        if image.mode not in {"RGB", "L"}:
            background = Image.new("RGB", image.size, "white")
            if "A" in image.getbands():
                background.paste(image, mask=image.getchannel("A"))
            else:
                background.paste(image)
            image = background
        elif image.mode == "L":
            image = image.convert("RGB")

        buffer = io.BytesIO()
        image.save(
            buffer,
            format="JPEG",
            quality=JPEG_QUALITY,
            optimize=True,
            progressive=True,
        )

    encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
    return "data:image/jpeg;base64," + encoded

def load_photo(path: Path, editorial: Editorial) -> Photo:
    with Image.open(path) as image:
        exif = image.getexif()
        captured_at = parse_exif_datetime(exif)
        camera_model = text_value(exif.get(Base.Model))
        latitude, longitude = extract_coordinates(exif)

    if captured_at is None:
        raise ValueError("missing or unreadable capture time")

    shift = CAMERA_TIME_SHIFTS.get(camera_model, 0)
    captured_at += timedelta(minutes=shift)

    return Photo(
        path=path,
        captured_at=captured_at,
        camera_model=camera_model,
        latitude=latitude,
        longitude=longitude,
        chapter=editorial.chapter or "Trip",
        title=editorial.title or path.stem,
        caption=editorial.caption,
        data_uri=encode_image(path),
    )

def haversine_km(a: Photo, b: Photo) -> float:
    earth_radius_km = 6371.0088
    lat1 = math.radians(a.latitude)
    lat2 = math.radians(b.latitude)
    delta_lat = lat2 - lat1
    delta_lon = math.radians(b.longitude - a.longitude)

    value = (
        math.sin(delta_lat / 2) ** 2
        + math.cos(lat1)
        * math.cos(lat2)
        * math.sin(delta_lon / 2) ** 2
    )
    return 2 * earth_radius_km * math.asin(math.sqrt(value))

def report_route_jumps(photos: list[Photo]) -> None:
    located = [
        photo
        for photo in photos
        if photo.latitude is not None and photo.longitude is not None
    ]

    for previous, current in zip(located, located[1:]):
        hours = (
            current.captured_at - previous.captured_at
        ).total_seconds() / 3600

        if hours <= 0:
            continue

        distance = haversine_km(previous, current)
        speed = distance / hours

        if distance > 100 or speed > 140:
            print(
                "REVIEW ROUTE:",
                previous.path.name,
                "->",
                current.path.name,
                f"{distance:.1f} km, {speed:.1f} km/h",
            )

def route_svg(photos: list[Photo]) -> str:
    points = [
        (photo.longitude, photo.latitude, photo.title)
        for photo in photos
        if photo.latitude is not None and photo.longitude is not None
    ]

    if not points:
        return (
            '<p class="route-empty">'
            "No verified coordinates were available for this trip."
            "</p>"
        )

    if len(points) == 1:
        return (
            '<svg class="route-map" viewBox="0 0 800 280" '
            'role="img" aria-label="One verified trip location">'
            '<circle cx="400" cy="140" r="9"/>'
            "</svg>"
        )

    longitudes = [point[0] for point in points]
    latitudes = [point[1] for point in points]
    min_lon, max_lon = min(longitudes), max(longitudes)
    min_lat, max_lat = min(latitudes), max(latitudes)

    lon_span = max(max_lon - min_lon, 0.000001)
    lat_span = max(max_lat - min_lat, 0.000001)

    def project(lon, lat):
        x = 50 + ((lon - min_lon) / lon_span) * 700
        y = 230 - ((lat - min_lat) / lat_span) * 180
        return x, y

    projected = [project(lon, lat) for lon, lat, _ in points]
    line_points = " ".join(
        f"{x:.1f},{y:.1f}" for x, y in projected
    )

    circles = []
    for number, ((x, y), (_, _, title)) in enumerate(
        zip(projected, points), start=1
    ):
        safe_title = html.escape(title, quote=True)
        circles.append(
            f'<g><circle cx="{x:.1f}" cy="{y:.1f}" r="7">'
            f"<title>{number}. {safe_title}</title>"
            "</circle>"
            f'<text x="{x + 11:.1f}" y="{y + 5:.1f}">'
            f"{number}</text></g>"
        )

    return (
        '<svg class="route-map" viewBox="0 0 800 280" '
        'role="img" aria-label="Approximate route through verified '
        'photo locations">'
        '<rect width="800" height="280" rx="18"/>'
        f'<polyline points="{line_points}"/>'
        + "".join(circles)
        + "</svg>"
        '<p class="route-note">Straight segments connect verified '
        "photo locations in chronological order. They do not represent "
        "the exact roads or paths traveled.</p>"
    )

def photo_figure(photo: Photo) -> str:
    safe_title = html.escape(photo.title)
    safe_caption = html.escape(photo.caption)
    safe_filename = html.escape(photo.path.name)

    if photo.latitude is not None and photo.longitude is not None:
        location = (
            f"{photo.latitude:.5f}, {photo.longitude:.5f}"
        )
    else:
        location = "Location not recorded"

    metadata = (
        photo.captured_at.strftime("%B %d, %Y · %H:%M")
        + " · "
        + location
    )

    return f"""
      <figure class="story-photo">
        <img src="{photo.data_uri}"
             alt="{safe_caption or safe_title}"
             loading="lazy"
             decoding="async">
        <figcaption>
          <strong>{safe_title}</strong>
          {f'<span>{safe_caption}</span>' if safe_caption else ''}
          <small>{html.escape(metadata)} · {safe_filename}</small>
        </figcaption>
      </figure>
    """

def build_document(photos: list[Photo]) -> str:
    chapter_order = []
    chapters: dict[str, list[Photo]] = {}

    for photo in photos:
        if photo.chapter not in chapters:
            chapters[photo.chapter] = []
            chapter_order.append(photo.chapter)
        chapters[photo.chapter].append(photo)

    first_date = photos[0].captured_at.strftime("%B %d, %Y")
    last_date = photos[-1].captured_at.strftime("%B %d, %Y")
    date_range = (
        first_date if first_date == last_date
        else f"{first_date}{last_date}"
    )

    chapter_html = []
    for number, chapter_name in enumerate(chapter_order, start=1):
        safe_name = html.escape(chapter_name)
        figures = "".join(photo_figure(p) for p in chapters[chapter_name])
        chapter_html.append(
            f'<section class="chapter" id="chapter-{number}">'
            f"<p class=\"chapter-number\">Chapter {number}</p>"
            f"<h2>{safe_name}</h2>"
            f'<div class="photo-grid">{figures}</div>'
            "</section>"
        )

    navigation = "".join(
        f'<li><a href="#chapter-{number}">'
        f"{html.escape(name)}</a></li>"
        for number, name in enumerate(chapter_order, start=1)
    )

    safe_trip_title = html.escape(TRIP_TITLE)
    safe_intro = html.escape(TRIP_INTRO)

    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{safe_trip_title}</title>
  <style>
    :root {{
      color-scheme: light;
      --paper: #f6f1e7;
      --ink: #20201d;
      --muted: #6d685f;
      --accent: #a54f32;
      --line: #d8cdbd;
      --card: #fffdf8;
    }}
    * {{ box-sizing: border-box; }}
    html {{ scroll-behavior: smooth; }}
    body {{
      margin: 0;
      background: var(--paper);
      color: var(--ink);
      font: 18px/1.65 Georgia, "Times New Roman", serif;
    }}
    main {{ width: min(1080px, calc(100% - 32px)); margin: auto; }}
    .hero {{ padding: 12vh 0 7vh; max-width: 820px; }}
    .eyebrow, .chapter-number, small {{
      font: 700 .75rem/1.4 system-ui, sans-serif;
      letter-spacing: .09em;
      text-transform: uppercase;
      color: var(--muted);
    }}
    h1, h2 {{ line-height: 1.08; }}
    h1 {{ margin: .2em 0; font-size: clamp(2.5rem, 8vw, 6rem); }}
    h2 {{ font-size: clamp(2rem, 5vw, 3.7rem); }}
    .intro {{ max-width: 720px; font-size: 1.25rem; }}
    .summary {{
      display: flex;
      flex-wrap: wrap;
      gap: 12px 28px;
      margin: 2rem 0;
      color: var(--muted);
      font-family: system-ui, sans-serif;
    }}
    nav {{
      margin: 0 0 5rem;
      padding: 1.25rem 1.5rem;
      border: 1px solid var(--line);
      background: var(--card);
    }}
    nav ol {{ margin-bottom: 0; }}
    a {{ color: var(--accent); }}
    .route {{ margin: 3rem 0 7rem; }}
    .route-map {{ width: 100%; height: auto; }}
    .route-map rect {{ fill: #e9dfd0; }}
    .route-map polyline {{
      fill: none;
      stroke: var(--accent);
      stroke-width: 4;
      stroke-linecap: round;
      stroke-linejoin: round;
    }}
    .route-map circle {{ fill: var(--ink); }}
    .route-map text {{
      fill: var(--ink);
      font: 700 16px system-ui, sans-serif;
    }}
    .route-note, .route-empty {{
      color: var(--muted);
      font-size: .9rem;
    }}
    .chapter {{ padding: 2rem 0 7rem; border-top: 1px solid var(--line); }}
    .chapter-number {{ margin-bottom: 0; }}
    .chapter h2 {{ margin-top: .15em; }}
    .photo-grid {{
      display: grid;
      grid-template-columns: repeat(12, 1fr);
      gap: 2rem;
    }}
    .story-photo {{ grid-column: span 6; margin: 0; }}
    .story-photo:nth-child(3n) {{ grid-column: 3 / span 8; }}
    .story-photo img {{
      display: block;
      width: 100%;
      height: auto;
      border-radius: 4px;
      background: #ddd;
    }}
    figcaption {{ padding-top: .7rem; }}
    figcaption strong, figcaption span, figcaption small {{ display: block; }}
    figcaption span {{ margin-top: .25rem; }}
    figcaption small {{ margin-top: .55rem; }}
    footer {{
      margin-top: 4rem;
      padding: 2rem max(16px, calc((100% - 1080px) / 2));
      border-top: 1px solid var(--line);
      color: var(--muted);
    }}
    @media (max-width: 720px) {{
      body {{ font-size: 16px; }}
      .hero {{ padding-top: 8vh; }}
      .story-photo,
      .story-photo:nth-child(3n) {{ grid-column: 1 / -1; }}
    }}
    @media print {{
      body {{ background: white; }}
      nav {{ display: none; }}
      .chapter {{ break-before: page; }}
      .story-photo {{ break-inside: avoid; }}
    }}
  </style>
</head>
<body>
  <main>
    <header class="hero">
      <p class="eyebrow">Travel story · {date_range}</p>
      <h1>{safe_trip_title}</h1>
      <p class="intro">{safe_intro}</p>
      <div class="summary">
        <span>{len(photos)} photographs</span>
        <span>{len(chapter_order)} chapters</span>
        <span>Self-contained offline file</span>
      </div>
    </header>

    <nav aria-label="Story chapters">
      <strong>Chapters</strong>
      <ol>{navigation}</ol>
    </nav>

    <section class="route">
      <p class="chapter-number">Route overview</p>
      <h2>Known locations</h2>
      {route_svg(photos)}
    </section>

    {''.join(chapter_html)}
  </main>

  <footer>
    Generated locally from reviewed photographs and metadata.
  </footer>
</body>
</html>
"""

def main() -> None:
    if not SOURCE_DIR.is_dir():
        raise SystemExit(f"Missing directory: {SOURCE_DIR}")
    if not CAPTIONS_FILE.is_file():
        raise SystemExit(f"Missing file: {CAPTIONS_FILE}")

    editorial_rows = read_editorial()
    photos: list[Photo] = []
    errors: list[str] = []

    for filename, editorial in editorial_rows.items():
        if not editorial.include:
            continue

        path = SOURCE_DIR / filename

        if not path.is_file():
            errors.append(f"{filename}: file not found")
            continue
        if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
            errors.append(f"{filename}: unsupported format")
            continue

        try:
            photos.append(load_photo(path, editorial))
        except Exception as exc:
            errors.append(f"{filename}: {exc}")

    photos.sort(key=lambda photo: (photo.captured_at, photo.path.name))

    if errors:
        print("FILES REQUIRING REVIEW:")
        for error in errors:
            print(" -", error)

    if not photos:
        raise SystemExit("No selected photographs could be loaded.")

    report_route_jumps(photos)
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    OUTPUT_FILE.write_text(build_document(photos), encoding="utf-8")

    size_mb = OUTPUT_FILE.stat().st_size / (1024 * 1024)
    print(f"Wrote: {OUTPUT_FILE}")
    print(f"Photos: {len(photos)}")
    print(f"File size: {size_mb:.1f} MB")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The embedded images use a Data URI: each compressed JPEG is encoded as Base64 and placed directly in an image’s src attribute. This increases the HTML file’s byte size, but it removes the need to distribute a separate image directory.

What the generator deliberately does not do

  • It does not assign a place name from coordinates.

  • It does not infer captions from image content.

  • It does not alter the original files.

  • It does not silently discard suspicious route points.

  • It does not upload photographs or coordinates anywhere.

Step 6. Run the build and perform the editorial pass

Activate the virtual environment if necessary, then run:

python build_story.py
Enter fullscreen mode Exit fullscreen mode

A successful build reports the output path, number of included photographs, and final file size. It may also print files or route transitions that require review. Those messages are not merely technical noise: resolve them before treating the page as finished.

Open the result directly from the file system:

output/travel-story.html
Enter fullscreen mode Exit fullscreen mode

You can double-click the file, or use a browser command if one is available on your system. No local web server is required.

Review the story as a reader

Do not stop after confirming that the file opens. Read it from beginning to end and ask:

  • Does the opening explain why this trip matters?

  • Does each chapter represent a recognizable change?

  • Are several nearly identical images competing for attention?

  • Does every caption add context rather than restate the image?

  • Are there abrupt jumps that need a transition sentence?

  • Does the route imply more precision than the evidence supports?

Edit captions.csv, change selections, and rebuild. Because the editorial decisions are separate from generated HTML, you can repeat this process without manually editing a document full of encoded image data.

Step 7. Verify portability, correctness, and size

Verification should cover both technical integrity and narrative accuracy.

1. Confirm that the file is truly self-contained

Temporarily disconnect from the network and open travel-story.html. Every selected image and the route should still appear. Search the generated source for external image references:

grep -Eo 'src="https?://[^"]+"' output/travel-story.html
Enter fullscreen mode Exit fullscreen mode

No output is expected from the generator shown here. On Windows PowerShell, use:

Select-String -Path output\travel-story.html -Pattern 'src="https?://'
Enter fullscreen mode Exit fullscreen mode

2. Confirm that images were embedded

grep -c 'data:image/jpeg;base64,' output/travel-story.html
Enter fullscreen mode Exit fullscreen mode

The count should equal the number of successfully included photographs reported by the script. This is not a universal test for every possible HTML file, but it matches this generator’s output contract.

3. Inspect the final size

ls -lh output/travel-story.html
Enter fullscreen mode Exit fullscreen mode

On PowerShell:

Get-Item output\travel-story.html | Select-Object Name,Length
Enter fullscreen mode Exit fullscreen mode

There is no universally correct size. The practical limit depends on how the page will be transferred and opened. If it feels slow, reduce MAX_IMAGE_EDGE, lower JPEG_QUALITY, or select fewer photographs, then compare visible quality.

4. Validate the HTML structure

Open the page in at least two browsers available to you. Check the first and last image, every chapter link, mobile-width layout, and print preview. Browser developer tools should not report missing local files because the final document has no image dependencies.

5. Compare facts against the source material

Review a sample from the beginning, middle, and end of the trip. Compare displayed time and coordinates with metadata.csv and your correction rules. Manually inspect every transition flagged by REVIEW ROUTE.

6. Test the actual handoff

Copy only travel-story.html into a new empty directory or onto another device. Open that copy. This catches accidental dependencies that are easy to miss while the output remains next to the project files.

Common failure cases and how to fix them

The script reports a missing capture time

The file may be an export, scan, screenshot, or edited copy with stripped EXIF data. Do not use the filesystem modification time as unquestioned evidence: copying and exporting can change it. Add a reviewed manual timestamp field to your CSV or exclude the photograph until its position is known.

Portrait photographs appear sideways

The generator calls ImageOps.exif_transpose before resizing. If an image is still wrong, its pixel data and orientation tag may disagree because another editor already rotated it incorrectly. Create a corrected working copy while preserving the original.

The route contains a line across a continent

Inspect the two filenames printed in the route warning. Common causes include a corrupted coordinate, incorrect latitude or longitude reference, an image received from another person, or a photo captured before the device obtained a reliable location.

Exclude that point from the route only after review. A robust extension is to add a separate include_in_route column so the image can remain in the story without contributing a misleading location.

Two cameras produce the wrong order

Determine whether the offset is constant. If one camera is always 60 minutes slow, use a model-specific correction. If the gap changes, the camera clock may have been adjusted during the trip. Split the correction by folder or date range instead of applying one global value.

Some filenames from the CSV are not found

Check capitalization, extensions, and subdirectories. The basic generator expects each listed file directly inside originals. Either flatten the working copies or extend the CSV to contain relative paths. Reject paths containing .. if other people will supply the CSV.

HEIC photographs fail to load

A standard Pillow installation may not read HEIC. The simplest beginner workflow is to export working copies as high-quality JPEG files while keeping the HEIC originals separately. After conversion, verify whether dates and coordinates were preserved.

The output file is too large

Base64 encoding adds overhead, and high-resolution images dominate the document. Try these changes in order:

  • remove redundant frames;

  • reduce MAX_IMAGE_EDGE from 1600 to 1280;

  • reduce JPEG_QUALITY from 78 to 72;

  • split a long journey into several self-contained chapter files.

Inspect faces, text, fine architecture, and dark gradients after each change. A smaller number in the configuration is not proof that the visual result remains acceptable.

The browser becomes slow even though the file size seems acceptable

Decoded images consume more memory than their compressed JPEG representation. A page containing many photographs can therefore strain a phone or older computer. Reduce image dimensions and the number of images per page; changing only JPEG quality may not reduce decoded memory enough.

Captions contain broken characters or malformed markup

Save captions.csv as UTF-8. The generator escapes titles and captions before inserting them into HTML, so characters such as & and < remain text instead of becoming markup. Do not remove this escaping to allow ad hoc HTML in captions.

Limitations, privacy, and responsible interpretation

Metadata is evidence, not an infallible record

Camera clocks can be wrong. Coordinates can be stale or imprecise. Editing software may remove or rewrite fields. Screenshots and downloaded images can carry dates unrelated to the original event. Preserve uncertainty instead of turning incomplete metadata into confident prose.

The route is intentionally schematic

The inline SVG shows the order of verified photo locations. It does not follow streets, correct for map projection, explain transport, or prove that every segment was traveled directly. For a small regional trip it is a useful overview; for a global journey it may be visually distorted.

One file is portable but not always efficient

A conventional website can cache separate image files and load them independently. A self-contained document trades that efficiency for simple preservation and sharing. It is well suited to a personal archive, an offline presentation, or a finite travel essay, but less suited to thousands of photographs.

Embedded coordinates remain sensitive

Self-contained does not mean private. Anyone who receives the HTML can inspect its visible coordinates and encoded images. Remove home locations, children’s routines, private addresses, and other sensitive details before sharing. Consider rounding coordinates or omitting them from captions while keeping only the schematic route.

Image metadata is not retained by this generator

The resized JPEG copies are newly encoded, so most original EXIF fields are not carried into the embedded image bytes. The page displays selected facts separately. This improves privacy but also means the HTML is not an archival substitute for the original photographs.

Automatic place naming requires another data source

Turning coordinates into place names is called reverse geocoding. It normally requires a local geographic database or an external service. This guide omits it so the build stays offline and does not transmit location history. Enter verified place names manually in chapter titles and captions.

The repeatable workflow

  • Preserve the originals and create working copies.

  • Export and inspect metadata before writing rules.

  • Record clock corrections as explicit configuration.

  • Select photographs and write factual captions in CSV.

  • Sort by corrected capture time.

  • Build a route only from verified coordinates.

  • Compress selected images and embed them as Data URIs.

  • Read the result as a story, not merely as a successful build.

  • Test the single file offline and on another device.

  • Keep the HTML as a presentation artifact and the originals as the archive.

The important transformation is not JPEG to HTML. It is an unstructured gallery becoming a reviewed account with a visible chronology, explicit uncertainty, and a format that can survive outside the application that created it.

      Explore more practical guides →
      ·
      Open the glossary →






Agent Lab Journal
Real experiments. Verifiable conclusions.
Enter fullscreen mode Exit fullscreen mode

Original article: https://agentlabjournal.online/en/photo-metadata-to-travel-story.html?utm_source=devto&utm_medium=referral&utm_campaign=agentlabjournal-en-global-all&utm_content=article&utm_term=photo-metadata-to-travel-story

Top comments (0)