Практический гайд · с нуля
Как превратить фотографии и метаданные поездки в автономный HTML-рассказ
Галерея запоминает отдельные кадры, но почти всегда теряет путешествие между ними. В этом руководстве мы восстановим хронологию и маршрут по данным фотографий, разделим поездку на главы, добавим осмысленные подписи и соберём всё в один HTML-файл, который открывается без сервера и подключения к интернету.
Что именно мы собираем
Итоговый файл travel-story.html будет содержать:
- название, даты и краткое вступление;
- сводку поездки и список глав;
- маршрут, нарисованный прямо в странице без картографического сервиса;
- фотографии в хронологическом порядке;
- время, координаты и авторские подписи;
- все стили, сценарии и изображения внутри одного документа.
Источником фактов станет метаданные файлов: дата съёмки, модель камеры, ориентация и, когда камера их сохранила, координаты. В фотографиях эти сведения обычно записаны в формате EXIF. Координаты образуют редкие точки GPS, а не непрерывный трек, поэтому маршрут будет честной реконструкцией, а не записью каждого поворота.
Конкретный случай: поездка на три дня
Представим папку из 186 фотографий. В первый день автор гулял по городу, во второй ехал к озеру, в третий возвращался через небольшой посёлок. Часть кадров сделана телефоном с координатами, часть — камерой без GPS. Имена файлов вроде IMG_8421.JPG ничего не рассказывают, а время камеры отстаёт на час.
Задача состоит не в том, чтобы просто вывести 186 картинок подряд. Нужна редакционная структура:
- собрать технический каталог всех кадров;
- исправить известный сдвиг времени;
- отделить три дня и крупные перемещения;
- выбрать репрезентативные фотографии;
- добавить ручные подписи к ключевым эпизодам;
- сжать выбранные изображения и встроить их в страницу.
После отбора в рассказ может войти, например, 32 кадра. Это не «потеря» остальных: оригиналы остаются в архиве, а HTML становится осмысленным рассказом, который удобно читать и передавать.
Шаг 1. Подготовьте рабочую копию
Не работайте с единственным экземпляром фотографий. Создайте отдельную папку проекта и скопируйте туда исходники:
travel-story/
├── originals/
├── captions.csv
├── build_story.py
└── output/
В originals положите фотографии. Скрипт будет только читать эту папку и создаст результат в output.
Установите Python и Pillow
Для сборки понадобится Python 3 и библиотека Pillow, которая читает фотографии, исправляет ориентацию и создаёт уменьшенные копии. Создайте отдельное виртуальное окружение:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install Pillow
В Windows команда активации выглядит так:
.venv\Scripts\activate
python -m pip install --upgrade pip
python -m pip install Pillow
Проверьте установку:
python -c "from PIL import Image; print(Image.__version__)"
Команда должна вывести номер версии без сообщения об ошибке. Конкретное число не важно.
Шаг 2. Извлеките и проверьте данные
Перед автоматической сборкой полезно увидеть, что реально записано в файлах. Удобный инструмент для такой проверки — ExifTool. Он не обязателен для итогового генератора, но помогает быстро обнаружить отсутствующие даты, координаты и неверные часовые пояса.
После установки выполните команду из корня проекта:
exiftool -csv \
-FileName \
-DateTimeOriginal \
-CreateDate \
-GPSLatitude \
-GPSLongitude \
-Orientation \
originals > metadata.csv
В PowerShell ту же команду удобнее записать одной строкой:
exiftool -csv -FileName -DateTimeOriginal -CreateDate -GPSLatitude -GPSLongitude -Orientation originals > metadata.csv
Что проверить в таблице
- DateTimeOriginal: возрастает ли время в ожидаемом порядке?
- GPSLatitude и GPSLongitude: есть ли координаты хотя бы у части кадров?
- Orientation: не окажутся ли вертикальные кадры повёрнутыми?
- Дубликаты: нет ли одновременно оригиналов и экспортированных копий?
- Часовой сдвиг: совпадает ли время камеры с известными событиями — билетом, закатом или сообщением?
EXIF-дата часто не содержит часовой пояс. Поэтому не переводите её автоматически в UTC, если не знаете исходное смещение. Для рассказа внутри одной поездки важнее правильная последовательность кадров, чем формально универсальное время.
Не меняйте оригиналы ради исправления времени
Если камера отставала на час, запишите поправку в конфигурации генератора. Это сохраняет исходные файлы неизменными и делает преобразование воспроизводимым:
TIME_SHIFT_MINUTES = 60
Если ошибка относится только к одной камере, безопаснее разложить её фотографии по отдельной подпапке или добавить правило по модели камеры. Не применяйте общий сдвиг ко всей коллекции вслепую.
Шаг 3. Восстановите маршрут без выдуманных деталей
Самый простой маршрут — последовательность фотографий с координатами, отсортированная по времени. Соседние точки соединяются прямыми линиями. Такая линия показывает порядок посещения мест, но не утверждает, что человек двигался именно по ней.
Очистите очевидные ошибки
GPS может ошибиться на сотни километров. Для двух соседних точек посчитайте расстояние по формуле гаверсинусов и разделите его на разницу во времени. Получится оценка скорости:
скорость = расстояние_между_точками / прошедшее_время
Не стоит вводить один универсальный предел: 140 км/ч подозрительны для прогулки, но возможны в поезде. Вместо автоматического удаления отметьте такие переходы в отчёте и проверьте вручную.
Что делать с кадрами без координат
Используйте только осторожные правила:
- если кадр снят между двумя близкими по времени фотографиями с почти одинаковыми координатами, можно унаследовать место главы, но не точные координаты;
- если соседние GPS-точки находятся далеко друг от друга, место кадра остаётся неизвестным;
- если координат нет у всей поездки, сохраните хронологические главы и уберите карту;
- название города или объекта вводите вручную, если оно известно автору.
Для автономной страницы не нужна внешняя карта. Мы нормализуем широту и долготу в диапазон SVG и нарисуем SVG-линию. Она не покажет дороги и границы, зато откроется офлайн и не передаст координаты стороннему сервису.
Шаг 4. Превратите последовательность кадров в главы
Удобная отправная точка — одна глава на календарный день. Затем день можно разделить, если между соседними кадрами прошло много времени или произошёл заметный географический скачок.
Для первой версии используйте простые правила:
Новая глава начинается, если:
1. изменилась календарная дата; или
2. между кадрами прошло больше 4 часов.
Географический скачок отмечается для ручной проверки,
но сам по себе не создаёт главу.
Почему не стоит полностью доверять расстоянию? После долгого переезда первый снимок на новом месте может быть единственным кадром с GPS. Автоматическое правило создаст технически правильный разрыв, но название и смысл главы всё равно должен определить человек.
Подписи через CSV
Создайте файл captions.csv в кодировке UTF-8:
filename,chapter,title,caption,place,include
IMG_8421.JPG,Утро в старом городе,Первые улицы,Вышли до начала дневной жары.,Старый город,yes
IMG_8440.JPG,Утро в старом городе,Рынок,К полудню площадь стала заметно оживлённее.,Центральный рынок,yes
IMG_8517.JPG,Дорога к озеру,Остановка в пути,Короткая остановка перед последним участком дороги.,,yes
IMG_8588.JPG,Вечер у воды,После дождя,Облака разошлись уже перед закатом.,Озеро,yes
IMG_8590.JPG,,,,,no
Поле include управляет отбором. Значение no исключает кадр из рассказа, но не удаляет его. Если строка для файла отсутствует, генератор включит фотографию с нейтральными данными и автоматическим названием главы по дате.
Как писать подписи
- Опишите то, что знаете: момент, причину остановки, изменение погоды или связь с предыдущим кадром.
- Не пересказывайте буквально изображение: «гора на фоне неба» редко помогает повествованию.
- Не угадывайте названия зданий и людей.
- Сохраняйте единый голос: либо короткие заметки от первого лица, либо спокойное документальное описание.
- Одной-двух фраз на фотографию обычно достаточно.
Шаг 5. Соберите автономный HTML-файл
Автономность достигается с помощью Data URI: сжатые байты изображения кодируются в Base64 и помещаются прямо в атрибут src. Файл становится больше обычной веб-страницы, зато его можно скопировать, отправить и открыть без соседней папки.
Ниже — рабочая основа генератора. Сохраните её как build_story.py.
from __future__ import annotations
import base64
import csv
import html
import io
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from PIL import Image, ImageOps
from PIL.ExifTags import GPS, IFD
SOURCE = Path("originals")
OUTPUT = Path("output/travel-story.html")
CAPTIONS = Path("captions.csv")
STORY_TITLE = "Три дня между городом и озером"
STORY_LEAD = "Маршрут, восстановленный по фотографиям и заметкам."
TIME_SHIFT_MINUTES = 0
MAX_EDGE = 1800
JPEG_QUALITY = 78
ALLOWED = {".jpg", ".jpeg", ".png", ".webp"}
@dataclass
class Photo:
path: Path
taken_at: datetime
latitude: float | None
longitude: float | None
chapter: str
title: str
caption: str
place: str
def read_captions() -> dict[str, dict[str, str]]:
if not CAPTIONS.exists():
return {}
with CAPTIONS.open("r", encoding="utf-8-sig", newline="") as file:
rows = csv.DictReader(file)
return {
row["filename"]: {
key: (value or "").strip()
for key, value in row.items()
if key
}
for row in rows
if row.get("filename")
}
def rational_to_float(value) -> float:
return float(value.numerator) / float(value.denominator)
def dms_to_decimal(parts, reference: str) -> float:
degrees = rational_to_float(parts[0])
minutes = rational_to_float(parts[1])
seconds = rational_to_float(parts[2])
result = degrees + minutes / 60 + seconds / 3600
return -result if reference in {"S", "W"} else result
def read_gps(exif) -> tuple[float | None, float | None]:
try:
gps = exif.get_ifd(IFD.GPSInfo)
latitude = dms_to_decimal(
gps[GPS.GPSLatitude],
gps[GPS.GPSLatitudeRef],
)
longitude = dms_to_decimal(
gps[GPS.GPSLongitude],
gps[GPS.GPSLongitudeRef],
)
return latitude, longitude
except (KeyError, TypeError, ValueError, ZeroDivisionError):
return None, None
def read_datetime(exif, path: Path) -> datetime:
raw = None
try:
exif_ifd = exif.get_ifd(IFD.Exif)
raw = exif_ifd.get(36867) or exif_ifd.get(36868)
except (KeyError, TypeError):
pass
raw = raw or exif.get(306)
if raw:
try:
value = datetime.strptime(str(raw), "%Y:%m:%d %H:%M:%S")
return value + timedelta(minutes=TIME_SHIFT_MINUTES)
except ValueError:
pass
# Время файла — запасной, менее надёжный источник.
return datetime.fromtimestamp(path.stat().st_mtime)
def image_data_uri(path: Path) -> str:
with Image.open(path) as source:
image = ImageOps.exif_transpose(source).convert("RGB")
image.thumbnail((MAX_EDGE, MAX_EDGE))
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_photos() -> list[Photo]:
notes = read_captions()
photos: list[Photo] = []
for path in sorted(SOURCE.rglob("*")):
if not path.is_file() or path.suffix.lower() not in ALLOWED:
continue
note = notes.get(path.name, {})
if note.get("include", "yes").lower() in {"no", "false", "0"}:
continue
try:
with Image.open(path) as image:
exif = image.getexif()
taken_at = read_datetime(exif, path)
latitude, longitude = read_gps(exif)
except Exception as error:
print(f"Пропуск {path}: {error}")
continue
default_chapter = taken_at.strftime("%d.%m.%Y")
photos.append(
Photo(
path=path,
taken_at=taken_at,
latitude=latitude,
longitude=longitude,
chapter=note.get("chapter") or default_chapter,
title=note.get("title") or path.stem,
caption=note.get("caption") or "",
place=note.get("place") or "",
)
)
return sorted(photos, key=lambda item: (item.taken_at, item.path.name))
def route_svg(photos: list[Photo]) -> str:
points = [
(photo.longitude, photo.latitude)
for photo in photos
if photo.latitude is not None and photo.longitude is not None
]
if len(points) < 2:
return '<p class="empty">Для маршрута недостаточно GPS-точек.</p>'
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)
projected = []
for longitude, latitude in points:
x = 30 + ((longitude - min_lon) / lon_span) * 740
y = 330 - ((latitude - min_lat) / lat_span) * 300
projected.append((x, y))
polyline = " ".join(f"{x:.1f},{y:.1f}" for x, y in projected)
dots = "".join(
f'<circle cx="{x:.1f}" cy="{y:.1f}" r="5"/>'
for x, y in projected
)
return (
'<svg class="route" viewBox="0 0 800 360" '
'role="img" aria-label="Схема маршрута">'
'<polyline points="' + polyline + '"/>' +
dots +
'</svg>'
)
def render_photo(photo: Photo) -> str:
coordinates = ""
if photo.latitude is not None and photo.longitude is not None:
coordinates = (
f"{photo.latitude:.5f}, {photo.longitude:.5f}"
)
meta = " · ".join(
value for value in [
photo.taken_at.strftime("%d.%m.%Y, %H:%M"),
photo.place,
coordinates,
]
if value
)
return f"""
<figure class="photo">
<img
src="{image_data_uri(photo.path)}"
alt="{html.escape(photo.title)}"
loading="lazy"
>
<figcaption>
<strong>{html.escape(photo.title)}</strong>
<span class="photo-meta">{html.escape(meta)}</span>
{f'<p>{html.escape(photo.caption)}</p>' if photo.caption else ''}
</figcaption>
</figure>
"""
def build() -> None:
photos = load_photos()
if not photos:
raise SystemExit("В originals нет подходящих фотографий.")
chapter_names = list(dict.fromkeys(photo.chapter for photo in photos))
chapters = []
for chapter_name in chapter_names:
chapter_photos = [
photo for photo in photos
if photo.chapter == chapter_name
]
content = "".join(render_photo(photo) for photo in chapter_photos)
anchor = "chapter-" + str(len(chapters) + 1)
chapters.append(
f'<section id="{anchor}">'
f'<h2>{html.escape(chapter_name)}</h2>'
f'<div class="gallery">{content}</div>'
f'</section>'
)
navigation = "".join(
f'<li><a href="#chapter-{index}">'
f'{html.escape(name)}</a></li>'
for index, name in enumerate(chapter_names, start=1)
)
first_date = photos[0].taken_at.strftime("%d.%m.%Y")
last_date = photos[-1].taken_at.strftime("%d.%m.%Y")
document = """<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root {{
color-scheme: light;
--paper: #f5f0e6;
--ink: #25231f;
--muted: #686158;
--accent: #b54b2a;
--card: #fffdf7;
}}
* {{ box-sizing: border-box; }}
html {{ scroll-behavior: smooth; }}
body {{
margin: 0;
color: var(--ink);
background: var(--paper);
font: 18px/1.65 Georgia, serif;
}}
header, main, footer {{
width: min(1100px, calc(100% - 32px));
margin-inline: auto;
}}
header {{ padding: 72px 0 36px; }}
h1 {{
max-width: 900px;
margin: 0 0 16px;
font-size: clamp(2.3rem, 7vw, 5.5rem);
line-height: .96;
}}
h2 {{ margin-top: 64px; font-size: 2rem; }}
a {{ color: var(--accent); }}
.lead {{ max-width: 720px; font-size: 1.25rem; }}
.summary {{
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
color: var(--muted);
}}
nav {{
margin: 32px 0;
padding: 20px 28px;
background: var(--card);
border-radius: 14px;
}}
.route {{
width: 100%;
min-height: 220px;
background: var(--card);
border-radius: 14px;
}}
.route polyline {{
fill: none;
stroke: var(--accent);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}}
.route circle {{
fill: var(--paper);
stroke: var(--accent);
stroke-width: 3;
}}
.gallery {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
gap: 28px;
}}
.photo {{
margin: 0;
overflow: hidden;
background: var(--card);
border-radius: 14px;
box-shadow: 0 8px 30px rgb(40 30 20 / 8%);
}}
.photo img {{
display: block;
width: 100%;
height: auto;
background: #ddd;
}}
figcaption {{ padding: 18px 20px 22px; }}
figcaption strong {{ display: block; font-size: 1.15rem; }}
.photo-meta {{
display: block;
margin-top: 4px;
color: var(--muted);
font: .85rem/1.4 system-ui, sans-serif;
}}
figcaption p {{ margin-bottom: 0; }}
footer {{
padding: 72px 0 40px;
color: var(--muted);
font-size: .9rem;
}}
@media print {{
body {{ background: white; }}
nav {{ display: none; }}
.photo {{ break-inside: avoid; box-shadow: none; }}
}}
</style>
</head>
<body>
<header>
<h1>{title}</h1>
<p class="lead">{lead}</p>
<p class="summary">
<span>{first_date} — {last_date}</span>
<span>Фотографий: {photo_count}</span>
<span>Глав: {chapter_count}</span>
</p>
</header>
<main>
<nav aria-label="Главы">
<ol>{navigation}</ol>
</nav>
<section>
<h2>Маршрут</h2>
{route}
</section>
{chapters}
</main>
<footer>
Автономный фото-рассказ. Для просмотра не требуется интернет.
</footer>
</body>
</html>
""".format(
title=html.escape(STORY_TITLE),
lead=html.escape(STORY_LEAD),
first_date=first_date,
last_date=last_date,
photo_count=len(photos),
chapter_count=len(chapter_names),
navigation=navigation,
route=route_svg(photos),
chapters="".join(chapters),
)
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(document, encoding="utf-8")
print(f"Создано: {OUTPUT}")
print(f"Фотографий: {len(photos)}")
print(f"Размер: {OUTPUT.stat().st_size / 1024 / 1024:.1f} МБ")
if __name__ == "__main__":
build()
Запустите сборку:
python build_story.py
Если всё прошло успешно, скрипт сообщит путь, число фотографий и размер страницы. Откройте файл двойным щелчком либо командой:
python -m webbrowser output/travel-story.html
Как настроить качество и размер
Две константы сильнее всего влияют на вес результата:
MAX_EDGE = 1800
JPEG_QUALITY = 78
MAX_EDGE ограничивает длинную сторону фотографии. Для просмотра на ноутбуке обычно разумно начать с 1600–2000 пикселей. JPEG_QUALITY управляет балансом между деталями и размером. Не существует значения, подходящего всем изображениям: листва, мелкие надписи и ночной шум сжимаются хуже спокойных сцен.
Выберите пять разных кадров — портрет, пейзаж, ночную сцену, архитектуру и фотографию с мелкими деталями — и соберите несколько вариантов. Сравнивайте их глазами при масштабе 100%, а не только по размеру файла.
Фотографии с прозрачностью генератор переводит в RGB, поэтому прозрачный фон станет чёрным или будет обработан непредсказуемо в зависимости от исходника. Для обычной поездки это редко важно; для сканов билетов и схем лучше заранее создать версию с белым фоном.
Шаг 6. Проверьте результат
Проверка должна охватывать не только внешний вид, но и сохранность повествования.
Техническая проверка
- Временно отключите Wi-Fi и откройте
travel-story.html. - Переместите только этот файл в другую папку и снова откройте его.
- Убедитесь, что видны стили, маршрут и все фотографии.
- Проверьте страницу в двух браузерах.
- Откройте инструменты разработчика и убедитесь, что консоль не содержит ошибок.
- Проверьте размер файла и возможность передать его выбранным способом.
Проверить отсутствие внешних зависимостей можно поиском сетевых адресов:
grep -Eo 'https?://[^"< ]+' output/travel-story.html
Для полностью автономного результата команда не должна находить адреса. В PowerShell:
Select-String -Path output\travel-story.html -Pattern 'https?://'
Редакторская проверка
- Совпадает ли порядок кадров с реальным ходом поездки?
- Начинается ли каждая глава с понятного контекста?
- Есть ли лишние серии почти одинаковых кадров?
- Не повторяют ли подписи очевидное содержание фотографии?
- Отмечены ли реконструированные участки маршрута как приблизительные?
- Нет ли в тексте названий и событий, которых автор не может подтвердить?
Проверка координат
Выберите начальную, среднюю и конечную GPS-точки. Сверьте их с тем, что помнит автор поездки. Особенно внимательно проверяйте нулевые координаты, противоположное полушарие и отдельные точки далеко от основного маршрута.
Если маршрут выглядит зеркально или перевёрнуто, проверьте знаки широты и долготы. Южная широта и западная долгота должны быть отрицательными. Вертикальное отражение в SVG, напротив, нормально: экранная координата Y растёт сверху вниз, поэтому генератор инвертирует широту.
Типичные сбои и способы исправления
Все фотографии получили сегодняшнюю или случайную дату
Генератор не нашёл дату съёмки и использовал время изменения файла. Такое бывает после экспорта из мессенджера или редактора. Возьмите исходные файлы с телефона либо добавьте даты вручную в CSV и расширьте генератор отдельным полем taken_at.
Вертикальные кадры лежат на боку
Программа должна применить ориентацию до уменьшения. В примере это делает ImageOps.exif_transpose. Если проблема сохраняется, проверьте, не была ли картинка ранее повёрнута без обновления EXIF.
Маршрут пересекает полконтинента
В коллекцию попала ошибочная GPS-точка или кадр из другой поездки. Найдите фотографию перед длинным сегментом и после него, проверьте координаты и исключите неверный кадр из маршрута. Не удаляйте координаты из оригинала.
HTML не открывается или браузер зависает
Скорее всего, файл слишком велик для доступной памяти. Уменьшите MAX_EDGE, снизьте качество, сократите число фотографий или разделите рассказ на несколько файлов. Base64 дополнительно увеличивает объём бинарных данных примерно на треть.
Вместо русских букв видны неправильные символы
CSV должен быть сохранён в UTF-8, а HTML — содержать <meta charset="utf-8">. Генератор читает вариант UTF-8 с сигнатурой и без неё благодаря кодировке utf-8-sig.
Подпись не связалась с фотографией
Имя в первом столбце CSV должно полностью совпадать с именем файла, включая расширение. Если разные папки содержат одинаковые имена, используйте относительный путь вместо path.name и храните его в CSV.
Скрипт пропускает HEIC-файлы
Базовая установка Pillow не обязана читать HEIC. Самый предсказуемый путь для новичка — экспортировать рабочие копии в JPEG, сохранив оригиналы отдельно. После конвертации снова проверьте, остались ли дата и координаты.
Глава содержит слишком много кадров
Разделите её вручную через поле chapter. Автоматическая граница по четырём часам — лишь начальное правило. Смена темы, места или темпа часто важнее календарной даты.
Ограничения и приватность
Автономный файл удобен, но у подхода есть границы.
- Размер. Десятки встроенных фотографий могут превратить страницу в файл на сотни мегабайт.
- Нет настоящей карты. SVG показывает взаимное расположение точек, но не дороги, рельеф и названия.
- Неполная хронология. Время изменения файла не равно времени съёмки и используется только как запасной вариант.
- Нет точного трека. Прямые отрезки между фотографиями не описывают фактический путь.
- Ограниченное редактирование. После изменения подписи или изображения страницу нужно пересобрать.
- Совместимость. Очень большие Data URI могут плохо работать в старых браузерах и почтовых клиентах.
Координаты способны раскрыть адрес дома, место ночёвки и привычный маршрут. Даже если генератор удаляет исходный EXIF из сжатых JPEG, он явно печатает координаты в подписи и использует их для схемы. Перед публикацией:
- удалите домашние и другие чувствительные точки;
- при необходимости округлите координаты до меньшей точности;
- проверьте текстовый поиск по широте и долготе;
- не публикуйте исходный
metadata.csvвместе со страницей; - откройте итоговый файл как посторонний читатель и оцените, что он позволяет узнать.
Для публичной версии можно вообще не показывать числовые координаты. В функции render_photo уберите переменную coordinates, сохранив данные только для приблизительной схемы. Для частной версии оставьте точность, если она действительно нужна.
Минимальный воспроизводимый процесс
Если свести весь проект к короткой последовательности, она выглядит так:
- Скопировать исходники в
originals. - Просмотреть даты и координаты через ExifTool.
- Зафиксировать известный сдвиг времени.
- Заполнить
captions.csv, исключив повторы и неудачные кадры. - Запустить
python build_story.py. - Проверить порядок, главы, подписи и аномальные точки.
- Открыть HTML без сети и отдельно от папки проекта.
- Удалить чувствительные координаты перед передачей.
- Сохранить оригиналы, CSV и скрипт для будущей пересборки.
Хороший результат — не самый длинный файл и не самая подробная карта. Это страница, в которой читатель понимает, где начинается поездка, как меняется место и настроение, почему выбран каждый кадр и чем история заканчивается.
Что получилось
Мы превратили разрозненную галерею в воспроизводимый издательский процесс. Метаданные восстановили порядок и приблизительный маршрут, ручной CSV добавил факты и голос автора, а генератор сжал изображения и упаковал рассказ в один переносимый документ.
Продолжить работу можно в разделе практических гайдов. Определения EXIF, GPS, SVG, Base64 и других терминов собраны в глоссарии Agent Lab Journal.