一、背景
日常的现场走访、河道巡查、乡村调研,都会拍下大量照片。照片躺在手机相册里,事后很难回答三个问题:这张拍在哪?同一个地方不同时间拍了什么?当时有什么说明?
现成的相册"地图视图"只能看点,没法导入自定义的行政区矢量边界,也不方便给每个地点写描述、按时间对比。于是决定自己做一个轻量的小程序:以金山区、奉贤区这样的行政区地图为底,把照片和文字挂在具体位置上,本地一条命令就能跑起来。
"C:\myApp\照片按照地理位置显示在矢量地图上\mapmark\app.py"

二、目标
核心功能
| 编号 | 功能 | 说明 |
|---|---|---|
| 1 | 地图导入 | 导入阿里 DataV 的行政区矢量图(GeoJSON),如金山区、奉贤区 |
| 2 | 标注与上传 | 地图上标点并放入照片,上传后压缩成体积小、仍能看清的格式 |
| 3 | 添加描述 | 给标注点写标题和文字描述 |
技术约束:前后端分离,前端用网页,后端用 Python,数据存 SQLite。
迭代中追加的需求:
- 一个地点可能有多张、不同时间拍摄的照片,要按时间排序,并用弹窗(modal)看大图;
- 行政区内的位置太模糊,需要街道、地标作为参照;
- 拖入照片后,按 EXIF 经纬度自动标点,距离相近的合并为一个标注。
三、方法
3.1 技术选型
| 层 | 选择 | 理由 |
|---|---|---|
| 后端 | Flask | 依赖少,十几个 REST 接口足够 |
| 数据库 | SQLite | 单文件、零配置,符合要求 |
| 图片处理 | Pillow | 缩放、压缩、读 EXIF 一站式 |
| 前端地图 | Leaflet | 轻量,原生支持 GeoJSON 渲染 |
| 底图 | 高德瓦片 | 与 DataV 同为 GCJ-02 坐标,叠加不偏移 |
| 地名搜索 | Nominatim(OSM) | 免 key,适合个人轻量使用 |
3.2 整体架构
浏览器(static/index.html:Leaflet + 原生 JS)
│ JSON / multipart(fetch)
▼
Flask(app.py)
├─ 地图接口:导入 / 列表 / 读取 / 删除
├─ 标注接口:增删改查(含拖动后更新坐标)
├─ 照片接口:上传压缩、改拍摄时间、删除
└─ 批量导入接口:读 GPS → 坐标转换 → 就近合并
│
├─ SQLite(data.db:maps / points / photos)
└─ uploads/(压缩后的 JPEG)
项目只有三个文件:app.py、static/index.html、requirements.txt。
3.3 数据模型
三张表,一对多级联:地图 → 标注点 → 照片。
sql
CREATE TABLE maps(id INTEGER PRIMARY KEY, name TEXT, adcode TEXT, geojson TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE points(id INTEGER PRIMARY KEY, map_id INTEGER REFERENCES maps(id) ON DELETE CASCADE,
lat REAL, lng REAL, title TEXT DEFAULT '', description TEXT DEFAULT '');
CREATE TABLE photos(id INTEGER PRIMARY KEY, point_id INTEGER REFERENCES points(id) ON DELETE CASCADE,
filename TEXT, taken_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP);
两个设计要点:
- GeoJSON 整体以文本存进
maps.geojson。行政区边界读多写少,没必要拆成空间表。 - 照片文件放磁盘,数据库只存文件名。要让
ON DELETE CASCADE生效,每个连接都必须执行PRAGMA foreign_keys=ON(SQLite 默认关闭)。代码在db()里统一打开了它。
3.4 接口一览
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/maps/import |
JSON 传 adcode 从 DataV 拉取,或 multipart 上传 GeoJSON 文件 |
| GET | /api/maps/<id> |
取地图 GeoJSON |
| GET/POST | /api/maps/<id>/points、/api/points |
列出 / 新建标注 |
| PUT | /api/points/<id> |
改标题描述,或拖动后改坐标 |
| POST | /api/points/<id>/photos |
上传并压缩照片 |
| PUT | /api/photos/<id> |
修改拍摄时间 |
| POST | /api/maps/<id>/import-photos |
按 GPS 批量标点并合并 |
3.5 三个关键设计决策
① 坐标系:GCJ-02 才是对的。 DataV 的 GeoJSON 和高德底图用 GCJ-02,而手机照片 EXIF 里的 GPS 是 WGS-84。如果直接用,位置会偏出几百米。所以后端把照片坐标统一转换成 GCJ-02 再入库。
② 拍摄时间有优先级。 EXIF 拍摄时间 → EXIF 修改时间 → 文件修改时间(前端传来)→ 上传时间,并允许用户手动改。
③ 合并用"就近归入"。 每张照片找最近的标注,距离不超过阈值就并入,否则新建,实现简单、结果可预期。
四、过程
4.1 地图导入与第一个坑
DataV 的地址形如 geo.datav.aliyun.com/areas_v3/bound/310116.json。最初我把地址写成了带 _full 的版本,结果金山区报 404。原因是:_full.json 只有含下级区划的省、市才有,区县级只有不带 _full 的文件 。修正后先尝试 _full,失败再退回普通版:
python
for code in (adcode + '_full', adcode): # 市级有 _full,区县级只有普通版
r = requests.get(DATAV_URL.format(code), timeout=15)
if r.ok: break
r.raise_for_status()
同时保留"上传 GeoJSON 文件"的入口,网络访问不了 DataV 时仍可导入。
4.2 照片压缩
压缩策略:长边不超过 1280px,JPEG 质量 75,渐进式,视觉上仍清晰,体积通常只有原图的几分之一。
python
def compress(stream, dest):
img = Image.open(stream)
taken = exif_time(img) # 先读拍摄时间
img = ImageOps.exif_transpose(img) # 按 EXIF 纠正方向,避免竖拍变横
if img.mode not in ('RGB', 'L'): img = img.convert('RGB')
img.thumbnail((1280, 1280), Image.LANCZOS)
img.save(dest, 'JPEG', quality=75, optimize=True, progressive=True)
return taken
保存时没有把 EXIF 写回,所以落盘的图片不再带原始 GPS,对隐私也友好。
4.3 同一地点多张照片:时间排序 + 弹窗查看
后端查询时用 COALESCE(taken_at, created_at) 排序,兼容升级前没有拍摄时间的旧数据;旧库则用启动时检查列、ALTER TABLE 的方式自动迁移:
python
if 'taken_at' not in [r[1] for r in c.execute('PRAGMA table_info(photos)')]:
c.execute('ALTER TABLE photos ADD COLUMN taken_at TEXT')
前端把查看大图做成页面内的遮罩层:缩略图下面显示日期,点击后弹出大图,可用左右键或按钮切换,点 ✕、按 Esc 或点空白处关闭;底部的时间输入框可以改拍摄时间,保存后列表自动重排。一个细节是:给标注绑定弹窗时传的是函数 bindPopup(()=>popup(p,mk)),每次打开才生成内容,所以照片增删后再打开总是最新状态。
4.4 "位置还是模糊":街道底图、搜索、拖动微调
只有行政区轮廓,用户很难判断点该放哪。解决办法有四个:
- 默认叠加高德街道图,可切换卫星图(带路名标注);行政区轮廓只做淡填充;
- 左侧搜索框调用 Nominatim,把结果的 WGS-84 坐标转成 GCJ-02 后飞到该处;
- 标记设为
draggable,拖动结束后用PUT /api/points/<id>保存新坐标; - 弹窗顶部反查并显示"附近:某某路 · 某某镇",用于确认点位。
为此后端的 PUT 接口改成"按字段更新":带 lat 就改坐标,带 title 才改文字,避免拖动时把标题清空。
4.5 核心:按 GPS 自动标点并合并
第一步,读取 GPS。 EXIF 的 GPS 是"度分秒 + 南北东西参考",换算成十进制度:
python
g = img.getexif().get_ifd(0x8825)
dms = lambda v: float(v[0]) + float(v[1]) / 60 + float(v[2]) / 3600
lat, lng = dms(g[2]), dms(g[4])
if str(g[1]).upper().startswith('S'): lat = -lat
if str(g[3]).upper().startswith('W'): lng = -lng
第二步,WGS-84 → GCJ-02。 采用业内通用的偏移算法,中国境外直接原样返回:
python
if not (73.66 < lng < 135.05 and 3.86 < lat < 53.55): return lat, lng
第三步,就近合并。 先把本批照片按拍摄时间排序,再逐张处理。距离用等距近似即可,几百米范围内误差可以忽略:
python
def meters(a, b):
return math.hypot((a['lng']-b['lng']) * 111320 * math.cos(math.radians(a['lat'])),
(a['lat']-b['lat']) * 110540)
for taken, (lat, lng), name in items:
me = dict(lat=lat, lng=lng)
best = min(pts, key=lambda p: meters(p, me), default=None)
if best is None or meters(best, me) > radius:
... # 新建标注,加入 pts
elif best['new']: # 本批新建的标注:位置取所含照片的平均值
best['lat'] = (best['lat'] * best['n'] + lat) / (best['n'] + 1)
best['lng'] = (best['lng'] * best['n'] + lng) / (best['n'] + 1)
best['n'] += 1
pts 里一开始就装着地图上已有的标注,所以新照片能并入旧标注;已有标注的位置保持不动(用户可能手动调过),只有本批新建的标注才取平均位置。没有 GPS 的照片不入库,名单返回给前端提示。
4.6 前端:拖放与分批上传
拖放区放在侧栏,同时支持把照片直接拖到地图上。必须先拦截 window 上的 dragover/drop,否则浏览器会直接打开图片:
js
['dragover','drop'].forEach(n => window.addEventListener(n, e => { e.preventDefault(); e.stopPropagation() }));
一次拖几十张照片容易超过请求大小限制,所以每 8 张发一个请求,并显示进度:
js
for (let i = 0; i < files.length; i += 8) {
const fd = new FormData(); fd.append('radius', $('rad').value);
files.slice(i, i + 8).forEach(f => { fd.append('photos', f); fd.append('mtimes', f.lastModified) });
const r = await api(`/api/maps/${curMap}/import-photos`, { method: 'POST', body: fd });
}
分批不影响合并:后面的批次会与前面批次已建好的标注继续比较。mtimes 带上文件修改时间,用于没有 EXIF 时间的照片。
4.7 踩坑记录
| 现象 | 原因 | 处理 |
|---|---|---|
python xxx.zip 报找不到文件 |
命令写错,且路径含空格和括号未加引号 | 先解压,再 python app.py |
| 打开页面显示 Not Found | index.html 没放进 static/ |
首页路由同时查找 static/ 与同级目录 |
| 导入报 404 | 区县级没有 _full.json |
见 4.1 |
| 微信里的图拖入后全被跳过 | 微信会抹掉 EXIF,GPS 丢失 | 用相册原图;已拍好的改用手动标注 |
五、结果
运行方式
bash
pip install -r requirements.txt
python app.py # 浏览器访问 http://127.0.0.1:5000
后端测试(用脚本和构造的测试图验证):
- 4000×3000 的图上传后变为 1280×960;
- 两张 GPS 相距约 10 米的照片合并为 1 个标注,相距约 1 公里的照片新建另一个标注,无 GPS 的照片被跳过并返回文件名;
- 第二批靠近已有标注的照片并入旧标注,没有新建;
- 拖动后的
PUT只改坐标,标题保持不变; - 修改拍摄时间后,列表会按新时间重新排序。
一个真实案例:用微信发来的一张河道照片测试,读出的 EXIF 为空,没有 GPS,也没有拍摄时间,程序正确地把它归入"无 GPS 已跳过"。这说明后续使用时应从相册导出原图,而不是经微信转发。
实际使用的效果:拖入一批带定位的照片,地图自动缩放到这些点所在范围,提示类似"已定位 12 张:新建 3 个标注,并入已有 1 个";点标记可看描述和按时间排序的缩略图,点缩略图在页面内弹窗看大图。
说明:DataV 在线拉取、高德瓦片、Nominatim 搜索都依赖外部网络;前端交互我只做了脚本语法检查,建议在本机完整走一遍流程再用于正式场景。
六、总结
收获
- 坐标系是地图类项目最容易踩的坑。 照片(WGS-84)与国内底图、DataV(GCJ-02)必须统一,否则点会系统性偏移。
- EXIF 是一把双刃剑。 它让"自动标点"成为可能,但也会被微信等应用抹掉;落盘前不写回 EXIF,则避免了原始位置泄露。
- 简单算法 + 清晰边界更可靠。 就近归入的聚类几十行就能写完,行为可预期;新旧标注的位置处理规则(旧的不动、新的取平均)也避免了覆盖用户手工调整的结果。
- 前后端分离在这个规模下很轻。 一个 Flask 文件加一个静态页面,接口按资源划分,后面扩展功能基本是"加字段、加接口"。
已知局限
- 就近归入是贪心算法,阈值太大时,沿路连拍的照片可能被串成一个点;
- HEIC 等格式当前读不了 EXIF,需先转成 JPEG;
- 没有登录与权限,只适合个人或内网;
- Nominatim 对国内小区、街道的覆盖有限,大量使用应换高德或百度的地理编码 API;
- Flask 自带的
debug服务器不适合上线,正式部署应使用 gunicorn 或 waitress 加反向代理。
后续可做
- 用 DBSCAN 之类的密度聚类,或按拍摄时间间隔拆分,替代贪心合并;
- 支持 HEIC(pillow-heif);
- 把无 GPS 的照片直接拖到某个已有标注上归档;
- 导出为 Excel / PDF 报告;
- 加入登录与多用户隔离。
如果你也有"按地点整理照片"的需求,这个项目可以当作起点,按自己的场景改造。