实践教程展示如何整合照片和元数据生成自包含HTML故事,恢复拍摄时间序列和地理信息。对处理媒体数据的开发者有参考价值。
Agent Lab Journal
Guides Glossary
实用指南 · 初级
相册记录了孤立的画面,但通常会丢失它们之间的旅程。本指南将向您展示如何恢复时间顺序和大致路线、将旅行分为可读的章节、添加事实性的标题、压缩选定的照片,并将所有内容打包到一个无需服务器或互联网连接即可打开的 HTML 文件中。
难度:初级
阅读时间:35 分钟
成果:一个独立的 HTML 故事
一个具体的三天案例
一个具体的三天案例
准备安全的工作空间
准备安全的工作空间
检查照片元数据
检查照片元数据
创建编辑数据
创建编辑数据
重建路线
重建路线
运行并审查构建
运行并审查构建
处理常见故障
处理常见故障
理解局限性
理解局限性
完成的文件 travel-story.html 将包含:
标题、旅行日期、简介和总结;
标题、旅行日期、简介和总结;
一套有序的章节;
一套有序的章节;
直接绘制在文档中的简单路线;
直接绘制在文档中的简单路线;
按时间顺序排列的选定照片;
按时间顺序排列的选定照片;
捕获时间、可选坐标和手动审查的标题;
捕获时间、可选坐标和手动审查的标题;
响应式样式和一个文件中的所有压缩图像。
响应式样式和一个文件中的所有压缩图像。
事实基础是文件的元数据:捕获时间、相机型号、方向、尺寸,有时还有位置。JPEG 照片通常将这些字段存储为 EXIF 数据。具有位置功能的相机也可能记录 GPS 坐标。
路线将是从稀疏照片位置重建的,而不是逐转向的轨迹。如果在两小时的转移期间没有拍摄照片,该页面无法知道使用了哪条道路、铁路线或步行路径。它只能显示一个已知点先于另一个点。
编辑规则:自动化可能会对证据进行分类并暴露差距,但不得编造事件。当无法可靠地识别地点、活动或人物时,请使用中立的标题或自己补充缺失的事实。
案例:三天的旅行
想象一个包含 186 张照片的文件夹。第一天,旅客探索了一座城市。第二天,他们去了一个湖。第三天,他们通过一个小镇返回。有些图像来自带坐标的手机,而其他图像来自没有位置数据的相机。相机时钟慢了一小时,文件名如 IMG_8421.JPG 没有叙述意义。
按文件名顺序打印全部 186 张图像会产生一个更大的相册,而不是故事。一个有用的工作流程应该做以下事情:
在不修改原始文件的情况下创建技术清单;
在不修改原始文件的情况下创建技术清单;
在构建过程中纠正已知的时钟偏差;
在构建过程中纠正已知的时钟偏差;
将旅行分为天数和有意义的过渡;
将旅行分为天数和有意义的过渡;
选择代表性的照片;
选择代表性的照片;
为重要时刻添加经过审查的标题;
为重要时刻添加经过审查的标题;
仅压缩选定的图像;
仅压缩选定的图像;
将这些图像和路线嵌入到一个文档中。
将这些图像和路线嵌入到一个文档中。
编辑后的故事可能包含 32 张照片而不是 186 张。剩余的原始文件不会被删除。它们保留在存档中,而 HTML 文件成为一个深思熟虑的、切实可行的阅读、复制和保存的说明。
步骤 1. 准备安全的工作空间
永远不要在仅有的照片副本上进行实验。创建一个项目目录,并将工作副本放在 originals 中:
travel-story/
├── originals/
├── captions.csv
├── build_story.py
└── output/
本指南中的生成器读取 originals,从 captions.csv 中读取编辑决策,并将最终页面写入 output。它不会重写源照片。
安装 Python 和 Pillow
您需要 Python 3 和 Pillow。Pillow 读取图像、应用其方向、调整其大小并生成压缩的 JPEG 数据。创建一个单独的虚拟环境,以便项目的包保持隔离。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install Pillow
在 Windows PowerShell 上,使用以下命令激活环境:
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install Pillow
确认 Pillow 可以被导入:
python -c "from PIL import Image; print(Image.__version__)"
版本号表示导入成功。此命令不需要为下面的工作流程生成任何特定版本。
步骤 2. 检查照片实际包含的内容
在编写生成器之前,用 ExifTool 检查集合。这对最终构建是可选的,但对于查找缺失的捕获时间、缺失的位置、重复的导出和不一致的相机时钟非常有用。
从项目目录中,导出审查表:
exiftool -csv \
-FileName \
-Directory \
-DateTimeOriginal \
-CreateDate \
-OffsetTimeOriginal \
-Make \
-Model \
-GPSLatitude \
-GPSLongitude \
-Orientation \
originals > metadata.csv
在 PowerShell 中,将同一命令放在一行上:
exiftool -csv -FileName -Directory -DateTimeOriginal -CreateDate -OffsetTimeOriginal -Make -Model -GPSLatitude -GPSLongitude -Orientation originals > metadata.csv
在继续前审查表格
捕获时间:序列是否符合您记忆中的事件?
捕获时间:序列是否符合您记忆中的事件?
坐标:它们是否至少出现在某些照片中?
坐标:它们是否至少出现在某些照片中?
相机型号:你能区分时钟不正确的设备吗?
相机型号:你能区分时钟不正确的设备吗?
方向:纵向图像是否可能需要旋转?
方向:纵向图像是否可能需要旋转?
重复:原始文件和社交媒体导出是否都存在?
重复:原始文件和社交媒体导出是否都存在?
时钟偏差:你能用票券、消息或其他已知事件来确认吗?
时钟偏差:你能用票券、消息或其他已知事件来确认吗?
EXIF 时间戳通常没有时区。不要自动将这样的值解释为 UTC。在一次旅行中,保持正确的顺序通常比分配不受支持的全局时区更重要。
将更正表示为配置
如果一台相机的速度恰好慢一个小时,将该更正记录在生成器中,而不是更改每个原始文件:
CAMERA_TIME_SHIFTS = {
"Example Camera Model": 60
}
该值是构建期间添加的分钟数。将示例模型替换为文件报告的确切型号。如果您无法确定一致的规则,请不要应用整个集合的更正。
不要作为第一步覆盖元数据。错误的批量编辑可能会破坏最好的可用证据。在审查故事之前,将更正保持为代码或单独数据文件中的可重现。
步骤 3. 创建编辑数据
生成器可以发现日期和坐标,但它不应该猜测为什么一张照片很重要。将您的选择和标题放在 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."
使用确切的文件名,包括其扩展名和大小写。这些字段有不同的用途:
include 控制图像是否出现;
include 控制图像是否出现;
chapter 将图像分组到叙述部分;
chapter 将图像分组到叙述部分;
title 给一张图像一个简短的显示标题;
title 给一张图像一个简短的显示标题;
caption 记录经过事实审查的人工审查的上下文。
caption 记录经过事实审查的人工审查的上下文。
一个好的标题增加了像素中不明显的信息。"一栋建筑"很弱。"到达后不久的车站广场"将画面与旅程联系起来,而不声称未经验证的建筑名称。
按意义而不仅按日期选择章节
日期是一个可靠的起点,但章节应该描述旅程中一段连贯的体验。一天可能会拆分成“晨间市场”“登上观景台”和“傍晚返程”。相反,一段平静的两日停留也可能合并为一个章节。
可以将地点、活动、节奏或目标的变化作为章节边界。较长的时间间隔和较大的位置跳跃是很有用的信号,但它们仍然只是提示你进行编辑审核,而不是自动判定的确凿依据。
最稳妥的基础路线,是将带有地理位置标签的照片按校正后的拍摄时间排序后得到的序列。相邻地点之间用直线连接。这样既能展示已知地点的先后顺序,又不会假装掌握照片之间经过的确切道路。
错误的 GPS 读数可能会让一张照片偏移到数百公里之外。你可以使用 Haversine 公式比较相邻坐标,该公式用于估算地球表面两点之间的距离:
estimated speed = distance between points / elapsed time
不要使用一个通用的速度上限。对于步行而言不可能达到的速度,乘坐火车时可能很正常。下面的生成器会报告可疑的位置变化以供审核,而不会在没有提示的情况下删除证据。
只能进行谨慎的推断:
如果一张没有位置标签的图片位于两张位置相近且带有标签的图片之间,可以将它保留在该叙事序列中,但不要为它编造坐标。
如果一张没有位置标签的图片位于两张位置相近且带有标签的图片之间,可以将它保留在该叙事序列中,但不要为它编造坐标。
如果前后相邻图片的位置相同,并且时间间隔很短,那么将其归入同一章节或许是合理的,但仍然需要审核。
如果前后相邻图片的位置相同,并且时间间隔很短,那么将其归入同一章节或许是合理的,但仍然需要审核。
如果相邻地点相距很远,就让这张图片保持未定位状态。
如果相邻地点相距很远,就让这张图片保持未定位状态。
绝不要仅仅因为两张照片看起来相似,就复制它们的坐标。
绝不要仅仅因为两张照片看起来相似,就复制它们的坐标。
离线故事不需要外部地图服务商。生成器会将经纬度归一化,并绘制成内联 SVG 示意图。它不会显示道路、边界或地名,但能够保持可移植性,也不会把私密坐标发送给第三方。
创建 build_story.py,并写入以下代码。运行前,请修改文件顶部附近的旅行常量和相机时间校正配置。
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:
bac
嵌入的图片使用 Data URI:每张压缩后的 JPEG 图片都会被编码为 Base64,并直接放入图片的 src 属性中。这会增加 HTML 文件的字节大小,但也免去了分发独立图片目录的需要。
它不会根据坐标指定地名。
它不会根据坐标指定地名。
它不会根据图片内容推断说明文字。
它不会根据图片内容推断说明文字。
它不会修改原始文件。
它不会修改原始文件。
它不会悄无声息地丢弃可疑的路线点。
它不会悄无声息地丢弃可疑的路线点。
它不会将照片或坐标上传到任何地方。
它不会将照片或坐标上传到任何地方。
如有必要,请激活虚拟环境,然后运行:
python build_story.py
构建成功后,会报告输出路径、所包含照片的数量以及最终文件大小。它还可能输出需要审核的文件或路线变化。这些消息并不只是无关紧要的技术噪声:在将页面视为完成之前,请先解决这些问题。
直接从文件系统中打开生成结果:
output/travel-story.html
你可以双击该文件,也可以使用系统中可用的浏览器命令打开它。不需要本地 Web 服务器。
不要在确认文件能够打开后就停下来。请从头到尾阅读一遍,并思考:
开篇是否解释了这次旅行为什么重要?
开篇是否解释了这次旅行为什么重要?
每个章节是否都体现了明确可辨的变化?
每个章节是否都体现了明确可辨的变化?
是否有多张几乎相同的图片在争夺读者的注意力?
是否有多张几乎相同的图片在争夺读者的注意力?
每条说明文字是否都补充了上下文,而不是简单复述图片内容?
每条说明文字是否都补充了上下文,而不是简单复述图片内容?
是否存在需要用过渡句衔接的突兀跳转?
是否存在需要用过渡句衔接的突兀跳转?
路线呈现出的精确程度是否超出了现有证据所能支持的范围?
路线呈现出的精确程度是否超出了现有证据所能支持的范围?
编辑 captions.csv、调整图片选择,然后重新构建。由于编辑决策与生成的 HTML 相互分离,你可以反复执行这一过程,而不必手动编辑充满编码图片数据的文档。
验证工作应同时涵盖技术完整性和叙事准确性。
暂时断开网络连接,然后打开 travel-story.html。所有选中的图片和路线仍应正常显示。搜索生成的源代码,检查是否存在外部图片引用:
grep -Eo 'src="https?://[^"]+"' output/travel-story.html
使用本文展示的生成器时,预期不会产生任何输出。在 Windows PowerShell 中,请使用:
Select-String -Path output\travel-story.html -Pattern 'src="https?://'
grep -c 'data:image/jpeg;base64,' output/travel-story.html
该计数应与脚本报告的成功纳入页面的照片数量一致。这并不是适用于所有 HTML 文件的通用测试方法,但符合此生成器的输出约定。
ls -lh output/travel-story.html
Get-Item output\travel-story.html | Select-Object Name,Length
不存在 universally correct 的文件大小。实际限制取决于页面的传输和打开方式。如果页面感觉加载缓慢,请减小 MAX_IMAGE_EDGE、降低 JPEG_QUALITY,或者减少所选照片的数量,然后比较肉眼可见的画质差异。
至少使用两个可用的浏览器打开页面。检查第一张和最后一张图片、每个章节链接、移动端宽度下的布局以及打印预览。浏览器开发者工具不应报告缺失的本地文件,因为最终文档不存在任何图片依赖。
分别从旅程的开头、中间和结尾抽取样本进行检查。将显示的时间和坐标与 metadata.csv 以及你的修正规则进行对比。手动检查 REVIEW ROUTE 标记出的每一处路线跳变。
仅将 travel-story.html 复制到一个新的空目录或另一台设备上,然后打开这个副本。这样可以发现一些意外依赖;当输出文件仍与项目文件放在一起时,这些依赖很容易被忽略。
常见故障及其修复方法
脚本报告缺少拍摄时间
该文件可能是导出文件、扫描件、截图,或者是 EXIF 数据已被移除的编辑副本。不要将文件系统修改时间当作无须质疑的证据:复制和导出操作都可能改变它。请在 CSV 中添加经过审核的手动时间戳字段,或者在照片位置得到确认之前将其排除。
竖拍照片显示为横向
生成器会在调整图片尺寸前调用 ImageOps.exif_transpose。如果图片方向仍然错误,可能是因为其他编辑器已经错误地旋转过图片,导致其像素数据与方向标签不一致。请创建一个修正后的工作副本,同时保留原始文件。
路线中出现一条横跨大陆的连线
检查路线警告中输出的两个文件名。常见原因包括坐标损坏、纬度或经度参考方向错误、图片来自他人,或者照片是在设备尚未获得可靠位置时拍摄的。
只有在审核后才能从路线中排除该位置点。一种更稳健的扩展方案是添加单独的 include_in_route 列,使图片可以保留在故事中,同时避免提供具有误导性的位置信息。
两台相机拍摄的照片排序错误
先判断时间偏差是否恒定。如果其中一台相机的时间始终慢 60 分钟,请使用针对该型号的修正规则。如果时间差发生变化,则相机时钟可能在旅途中被调整过。应按文件夹或日期范围分别修正,而不是应用一个全局修正值。
CSV 中的某些文件名找不到
检查大小写、扩展名和子目录。基础版生成器要求列出的每个文件都直接位于 originals 目录内。你可以将工作副本扁平化存放,也可以扩展 CSV,使其包含相对路径。如果 CSV 将由其他人提供,请拒绝包含 .. 的路径。
HEIC 照片无法加载
标准的 Pillow 安装可能无法读取 HEIC。对于初学者而言,最简单的工作流是将工作副本导出为高质量 JPEG 文件,同时单独保留 HEIC 原始文件。转换后,请验证日期和坐标是否得到保留。
输出文件过大
Base64 编码会增加额外开销,而高分辨率图片占据了文档的大部分空间。请按以下顺序尝试这些更改:
移除重复的画面;
移除重复的画面;
将 MAX_IMAGE_EDGE 从 1600 降低到 1280;
将 MAX_IMAGE_EDGE 从 1600 降低到 1280;
将 JPEG_QUALITY 从 78 降低到 72;
将 JPEG_QUALITY 从 78 降低到 72;
将较长的旅程拆分为多个自包含的章节文件。
将较长的旅程拆分为多个自包含的章节文件。
每次更改后,都要检查人脸、文字、精细的建筑细节和暗部渐变。配置中的数值变小,并不能证明视觉效果仍然可以接受。
即使文件大小看起来可以接受,浏览器仍然变得很慢
解码后的图片比其压缩后的 JPEG 表示占用更多内存。因此,包含大量照片的页面可能会给手机或较旧的计算机带来压力。请减小图片尺寸,并减少每个页面中的图片数量;仅更改 JPEG 质量可能不足以显著降低解码后的内存占用。
说明文字中出现乱码或格式错误的标记
将 captions.csv 保存为 UTF-8。生成器会先转义标题和说明文字,再将其插入 HTML,因此 & 和 < 等字符会保持为文本,而不会变成标记。不要为了允许在说明文字中随意使用 HTML 而移除这种转义处理。
局限性、隐私与负责任的解读
元数据是证据,而不是绝对可靠的记录
相机时钟可能不准确。坐标可能已经过时或不够精确。编辑软件可能会删除或重写字段。截图和下载的图片可能携带与原始事件无关的日期。应保留其中的不确定性,不要将不完整的元数据转化为语气笃定的叙述。
路线在设计上就是示意性的
内联 SVG 展示的是经过验证的照片位置顺序。它不会沿着街道绘制路线,不会修正地图投影,不会说明交通方式,也不能证明每一段行程都是直接完成的。