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
            
            
                            
            
                            
#Ai #Tooling
Dev.to

Publisher

Originally by Михаил


0 Comments

Log in to join the conversation.

No comments yet. Be the first to share your thoughts.