从零实现“照片按地理位置显示在矢量地图上”:Flask + SQLite + Leaflet 全栈实战

一、背景

日常的现场走访、河道巡查、乡村调研,都会拍下大量照片。照片躺在手机相册里,事后很难回答三个问题:这张拍在哪?同一个地方不同时间拍了什么?当时有什么说明?

现成的相册"地图视图"只能看点,没法导入自定义的行政区矢量边界,也不方便给每个地点写描述、按时间对比。于是决定自己做一个轻量的小程序:以金山区、奉贤区这样的行政区地图为底,把照片和文字挂在具体位置上,本地一条命令就能跑起来。

"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 搜索都依赖外部网络;前端交互我只做了脚本语法检查,建议在本机完整走一遍流程再用于正式场景。

六、总结

收获

  1. 坐标系是地图类项目最容易踩的坑。 照片(WGS-84)与国内底图、DataV(GCJ-02)必须统一,否则点会系统性偏移。
  2. EXIF 是一把双刃剑。 它让"自动标点"成为可能,但也会被微信等应用抹掉;落盘前不写回 EXIF,则避免了原始位置泄露。
  3. 简单算法 + 清晰边界更可靠。 就近归入的聚类几十行就能写完,行为可预期;新旧标注的位置处理规则(旧的不动、新的取平均)也避免了覆盖用户手工调整的结果。
  4. 前后端分离在这个规模下很轻。 一个 Flask 文件加一个静态页面,接口按资源划分,后面扩展功能基本是"加字段、加接口"。

已知局限

  • 就近归入是贪心算法,阈值太大时,沿路连拍的照片可能被串成一个点;
  • HEIC 等格式当前读不了 EXIF,需先转成 JPEG;
  • 没有登录与权限,只适合个人或内网;
  • Nominatim 对国内小区、街道的覆盖有限,大量使用应换高德或百度的地理编码 API;
  • Flask 自带的 debug 服务器不适合上线,正式部署应使用 gunicorn 或 waitress 加反向代理。

后续可做

  • 用 DBSCAN 之类的密度聚类,或按拍摄时间间隔拆分,替代贪心合并;
  • 支持 HEIC(pillow-heif);
  • 把无 GPS 的照片直接拖到某个已有标注上归档;
  • 导出为 Excel / PDF 报告;
  • 加入登录与多用户隔离。

如果你也有"按地点整理照片"的需求,这个项目可以当作起点,按自己的场景改造。

相关推荐
外收内放1 小时前
Python基础语法练习题(72)
开发语言·python
171320330计算机毕设编程1 小时前
计算机毕业设计 基于Hadoop的新能源汽车发展前景分析 LW PPT
java·大数据·hadoop·python·算法·随机森林·线性回归
haluhalu.2 小时前
深入理解 AI Agent(1.2):Harness 工程——模型之外的竞争力
前端·数据结构·人工智能·python·langchain·个人开发
东莞市云毅网络有限公司2 小时前
把网页表格与列表还原成结构化条目:抽取与对账实现
python·文本处理·数据抽取
夜瞬2 小时前
从零微调 Qwen3-8B:基于 LoRA 与 LLaMA-Factory 打造专属 AI 编程助教
人工智能·python·llama
小小张说故事2 小时前
Python 字典为什么这么快?一次查找背后的哈希表、扰动函数和开放寻址
python
障碍的枫子3 小时前
我的第一个Agent
开发语言·python·langchain
打工仔折腾 AI3 小时前
260+异构同步链路实战:如何用语义化自动翻译破解CDC DDL同步痛点
人工智能·后端·python·自然语言处理·性能优化·langchain·机器翻译
用户7624117567053 小时前
第十三章 网络编程
python