三四月份,实验室接了个林业调查队的小项目——把一片林区的高分辨率遥感影像、森林资源调查样地和解译图斑集成到一个网页系统里,让一线人员在野外平板上能看图、打点、查属性,办公室电脑上也能同步操作。要求很简单也很苛刻:系统轻、部署快、不引入重型GIS服务器,最好一台普通4核8G的Linux机器就能跑起来。
我花了一周调研方案,最后选定Flask做后端、Leaflet做前端地图库,用一个多月把从数据切片、接口设计、地图交互到nginx部署的完整链路跑通了。这篇文章是完整复盘,把关键技术决定、数据处理时的坑、接口设计思路和部署细节都写出来。如果你也在做林业遥感、自然资源相关的小系统,或者想在Flask项目里接入Leaflet地图,可以参考这条路少踩几个坑。
1. 为什么这个项目选了Flask+Leaflet,而不是重型GIS平台
1.1 先看清林业遥感小系统的真实需求
市面上典型的WebGIS方案是GeoServer做地图服务、PostGIS做空间库、前端用OpenLayers或者ArcGIS JS API。这套组合能力很强,但放在林业遥感的小场景里,有点“杀鸡用牛刀”。
做选型之前,我先列了项目的实际约束:
- 用户规模:调查队十几个人,同时在线最多几十个
- 数据体量:影像切片后几十GB,矢量图斑、样地点就几百万行
- 功能范围:图层切换、缩放漫游、点击查询、标注保存、基本量测
- 部署环境:内网普通服务器,甚至可能是临时装在项目现场的一台笔记本
GeoServer需要JVM和Tomcat,内存占用会随图层数量上涨,配置学习成本也不低。ArcGIS那套更不用说,授权和运维都重。对比之后,一个Flask进程管接口,Leaflet在浏览器里渲染瓦片,已经能覆盖全部需求。Linux上装个Python环境就能跑,几乎没有维护负担。
1.2 Flask和FastAPI,我最后选了Flask
做后端选型时,实验室同事建议过FastAPI,也有人觉得Flask更稳。我把两个框架按项目场景做了个对比:
| 对比项 | Flask | FastAPI |
|---|---|---|
| 并发模型 | WSGI同步,靠多进程/多线程 | ASGI原生异步 |
| 高并发能力 | 需要多worker配合 | 单进程并发处理更强 |
| 生态与GIS库 | SQLAlchemy、GeoAlchemy2、Flask-Migrate很成熟 | Pydantic舒服,GIS生态相对弱一点 |
| 部署运维 | Gunicorn/uWSGI方案极其成熟 | 需要Uvicorn,差别不大 |
| 二次开发成本 | 老框架资料多,遇到问题马上能搜到答案 | 类型提示和依赖注入需要重新习惯 |
这个项目是典型的内网低并发场景,接口无非是查库返回JSON、读取本地文件、偶尔跑一个瓦片生成任务,属于IO密集型而不是计算密集型。Flask承受这些绰绰有余。而且实验室已有的积累都在Flask这边,SQLAlchemy模型、配置文件、部署脚本都能直接复用,没必要为了“异步性能”去给FastAPI重新配一套体系。如果以后真要做实时推送,比如野外队员位置共享、火点热力刷新,再往FastAPI迁也不迟,毕竟数据层和接口逻辑是可替换的。
1.3 Leaflet在前端地图里的生态优势
前端备选有OpenLayers、Cesium和Leaflet。我的判断是:二维平面、影像叠加、矢量标注、测量,这些Leaflet的插件生态最丰富,体积也最轻。OpenLayers功能完整但概念多,写一个小功能往往要创建Map、View、Layer、Source一串对象,学习曲线比Leaflet陡。Cesium是三维地球场景才需要的重量级方案,对WebGL版本有要求,调查队员的平板不一定带得动。
Leaflet包本身只有几十KB,API直观,官方文档和社区例子很多。我后期如果想上三维或者做时光轴动画,数据层不用动,换个前端框架滚一层就行。先把这个二维底座打扎实,比一开始追求大而全的方案更明智。
2. 数据准备才是整个系统里最耗时的环节
2.1 遥感影像为什么不能直接丢给浏览器显示
拿到手的林业影像通常是GeoTIFF或IMG格式,里面存储的是多波段反射率数据,浏览器根本读不了,更别说GeoTIFF动不动就是几百MB到几GB的体量。要让地图能拖动、缩放,必须把大影像切分成256x256或512x512的PNG/JPEG瓦片,并按金字塔结构组织。Leaflet本身就是干这个的——它通过瓦片地址按需加载当前视野内的图片,看着像在加载一张完整地图,其实每次只拉屏幕范围内的瓦片。
我第一次做的时候,试图把整景影像直接丢给Leaflet加载,地图拖起来卡成PPT。后来老老实实切成瓦片才意识到,遥感web开发里大部分“性能卡顿”,其实在数据准备阶段就已经注定了。底图数据切不好,前端优化再狠也没用。
2.2 用gdal2tiles把影像切成Web瓦片
切片工具我用的是GDAL自带的gdal2tiles.py,配合gdalwarp做坐标转换。命令很简洁:
# 先把影像转成Web墨卡托投影 gdalwarp -t_srs EPSG:3857 -r bilinear -of GTiff input_4326.tif input_3857.tif # 生成0-18级瓦片 gdal2tiles.py -p mercator -z 0-18 -r average -w none input_3857.tif ./tiles/scene001这几个参数解释一下:
-p mercator:生成Web墨卡托瓦片,Leaflet默认的CRS就是EPSG:3857-z 0-18:生成0到18级金字塔。如果源影像分辨率只有5米,生成到18级纯粹是浪费磁盘,建议先算一下每个层级对应的地面分辨率再定范围-r average:重采样用平均法,默认最近邻在放大时锯齿非常明显-w none:不生成Leaflet预览HTML,目录里少一个多余网页文件
用gdal_translate裁剪到目标范围后,一片10km×10km、0.5米分辨率的影像,从12级生成到18级大约要产出几千到几万张瓦片,磁盘占用几百MB。对现代服务器不算事,但要注意小瓦片文件数量多,如果放在机械硬盘的旧机器上,随机IO会成为瓶颈,尽量放SSD。
2.3 坐标系和位置对不上的经典坑
最隐蔽的坑是坐标系。很多林业原始影像是WGS84经纬度(EPSG:4326),如果直接用gdal2tiles默认输出,Leaflet加载后要么位置偏移,要么整片空白,因为Leaflet默认把瓦片当成3857投影来计算。
解决办法就是上面那一行gdalwarp -t_srs EPSG:3857,先把影像重投影再切片。有些影像连坐标系都没写,这时还需要先借助影像自带的角点坐标做二次定义。这里强烈建议在项目里建立一份“数据规范化清单”,每景影像记录原始投影、目标投影、分辨率、波段数、云量这些元数据。数据一乱,后面业务功能全跟着乱。
如果只处理小范围、单景影像,还可以直接用rasterio在Python里干,读取瓦片块后调用rio warp转投影,本质和gdalwarp一样,但能顺手接入已有的Python处理流程。
3. Flask后端设计:核心是给前端喂干净的数据
3.1 项目目录与模块规划
Flask项目不需要搞太复杂的微服务架构,但也不能所有路由全堆在app.py里。我按功能模块拆了一下:
forest_remote/ ├── app.py # Flask入口 ├── models.py # SQLAlchemy模型 ├── api/ │ ├── scenes.py # 影像元数据接口 │ └── annotations.py # 样地标注接口 ├── static/ │ ├── index.html │ ├── css/leaflet.css │ ├── js/leaflet.js │ └── js/main.js ├── tiles/ │ ├── scene001/ │ └── scene002/ └── requirements.txt目录拆分的理由是:影像元数据接口和标注接口是两个后续大概率会扩展的领域,现在分开,以后加“影像上传”“瓦片任务查询”这些功能时,不用往同一个路由文件里无限堆接口。
3.2 元数据表和标注表的SQLAlchemy模型
小项目用SQLite或MySQL就能跑,没必要为了空间查询一开始就上PostGIS。等数据量确实大了,再迁移也不迟。
from flask_sqlalchemy import SQLAlchemy from datetime import datetime import json db = SQLAlchemy() class Scene(db.Model): __tablename__ = 'scenes' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), nullable=False) tile_dir = db.Column(db.String(256), nullable=False) bounds = db.Column(db.Text, nullable=False) # JSON数组字符串 zoom_min = db.Column(db.Integer, default=0) zoom_max = db.Column(db.Integer, default=18) capture_time = db.Column(db.DateTime) created_at = db.Column(db.DateTime, default=datetime.utcnow) def to_dict(self): return { 'id': self.id, 'name': self.name, 'bounds': json.loads(self.bounds), 'zoom_min': self.zoom_min, 'zoom_max': self.zoom_max, 'capture_time': self.capture_time.strftime('%Y-%m-%d %H:%M') if self.capture_time else '' } class Annotation(db.Model): __tablename__ = 'annotations' id = db.Column(db.Integer, primary_key=True) scene_id = db.Column(db.Integer, db.ForeignKey('scenes.id')) lon = db.Column(db.Float, nullable=False) lat = db.Column(db.Float, nullable=False) label = db.Column(db.String(64), default='') remark = db.Column(db.Text, default='') created_at = db.Column(db.DateTime, default=datetime.utcnow)bounds字段用JSON字符串而不是geometry类型,是因为只需要在列表页显示“这个场景覆盖了哪个范围”,做范围判断交给前端Leaflet的L.latLngBounds就行。等到要做空间交叠查询,再引入空间字段也不迟。
3.3 核心接口一览与示例代码
后端实际跑的接口就这五类:
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | /api/v1/scenes | 列出所有场景 |
| GET | /api/v1/scenes/{id} | 获取单场景详情 |
| GET | /tiles/{scene}/z/x/y.png | 获取瓦片 |
| POST | /api/v1/annotations | 新增标注 |
| GET | /api/v1/annotations?scene_id=1 | 查询标注 |
列表接口代码很简单:
from flask import jsonify @app.route('/api/v1/scenes') def list_scenes(): scenes = Scene.query.order_by(Scene.capture_time.desc()).all() return jsonify([s.to_dict() for s in scenes])这样前端首页就能动态渲染场景卡片,用户点击某张卡片后,用返回的bounds和zoom_max去初始化Leaflet地图。
3.4 瓦片访问的安全坑:路径穿越问题
瓦片文件是静态资源,直接给Flask静态目录也能读,但我遇到的坑是路径穿越:如果直接open(f"/tiles/{scene_name}/{z}/{x}/{y}.png"),用户提交一个../../etc/passwd之类的路径,就可能把服务器上无关文件读出来。
正确的做法是用send_from_directory:
from flask import send_from_directory @app.route('/tiles/<scene_name>/<int:z>/<int:x>/<int:y>.png') def get_tile(scene_name, z, x, y): tile_root = os.path.join(BASE_DIR, 'tiles') return send_from_directory( os.path.join(tile_root, scene_name), f"{z}/{x}/{y}.png" )send_from_directory会自动拒绝包含..的路径,保证只访问tiles目录内的文件。这个小细节,在真实部署到公网前一定要处理,否则服务器就是敞开的。
4. Leaflet前端交互:从加载瓦片到图斑标注
4.1 地图初始化和多图层叠加
前端主流程很简单,先初始化地图,再把瓦片图层加进去:
const map = L.map('map', { zoomControl: true, maxZoom: 18 }).setView([41.25, 118.85], 13); L.tileLayer('/tiles/scene001/{z}/{x}/{y}.png', { maxZoom: 18, tileSize: 256, opacity: 0.95 }).addTo(map);Leaflet的{z}/{x}/{y}是内置URL占位符,会自动替换为当前视野的层级和行列号。加载多个时相的影像做对比,在林业里很常用——比如2021年和2023年的影像放在同一地图上,用图层控制控件切换:
const baseLayers = { '2023年影像': L.tileLayer('/tiles/scene001/{z}/{x}/{y}.png'), '2021年影像': L.tileLayer('/tiles/scene002/{z}/{x}/{y}.png') }; L.control.layers(baseLayers).addTo(map);这样调查队员可以直观看出哪些区域改了树种、哪些地方采伐了,业务价值比单纯的“看遥感图”高得多。
4.2 地图旋转:插件能用,但正式场景别用
“Leaflet地图旋转”这个话题不少人在搜。先说结论:Leaflet默认不支持地图旋转,网上能找到的“旋转”操作,大多是对地图容器做CSS的transform: rotate。
我实际试过一次:对map容器旋转后,视觉上确实转了,但所有鼠标点击事件的坐标都会相对旋转中心发生偏移,你还得额外做一次反旋转变换。更麻烦的是,浏览器地图瓦片在非90度旋转时会露出边缘,栅格瓦片拼接区域会出现空白和不连续。
所以我的处理是:如果遥感影像本身带偏转角,在前端旋转没有任何意义,应该在切片之前做几何校正。比如无人机倾斜影像给了外方位角,可以用gdalwarp做仿射变换或TPS校正,把影像转成正北方向后再切片。前端只保留一个“临时软旋转”按钮,给领导演示时看看效果,真实提交的坐标一律以未旋转版为准。否则存到库里的坐标是旋转过的,后面做空间统计分析全错。
4.3 点击地图添加样地标注
林业调查里最常用的是点样地:在影像上找到目标地块,点一下,记录树种和生长情况。Leaflet的原生点击事件配合后端POST接口就能实现:
let annotationLayer = L.layerGroup().addTo(map); map.on('click', async (e) => { const { lat, lng } = e.latlng; const res = await fetch('/api/v1/annotations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scene_id: currentSceneId, lat, lng, label: '临时样地', remark: '' }) }); if (res.ok) { refreshAnnotations(); } });刷新时从后端拉取该场景所有标注,重新渲染到annotationLayer上。为了避免每次刷新全量重绘,可以在后端接口里加一个updated_after参数,只返回新增或修改的记录。
4.4 解译图斑的GeoJSON渲染
图斑数据我存的是GeoJSON文件或PostgreSQL里的GeoJSON字段。前端用L.geoJSON加载并绑定Popup属性:
fetch('/api/v1/scenes/1/parcels') .then(res => res.json()) .then(geoJsonData => { const layer = L.geoJSON(geoJsonData, { onEachFeature: (feature, l) => { l.bindPopup( `小班号:${feature.properties.bh}<br>` + `树种:${feature.properties.sz}<br>` + `郁闭度:${feature.properties.ybd}` ); } }).addTo(map); });这里有个性能小建议:图斑数量多的时候,不要一次性加载全部feature,可以先按当前视野范围请求后端裁剪再渲染。我最初把几千个图斑全部塞给前端,页面卡了四五秒,后来后端加了bbox参数做过滤,加载速度从秒级降到毫秒级。
5. 部署阶段最容易翻车的地方,逐个排查
5.1 Gunicorn应该怎么起
部署我用的是Gunicorn。命令行很简单:
gunicorn -w 3 -b 127.0.0.1:8000 app:app这里三个细节要注意:
- 绑定了
127.0.0.1而不是0.0.0.0:前面有Nginx做反向代理,Flask直连外网端口不安全,也浪费性能 - worker数取了3:这台服务器4核,按
CPU核数×2+1的经验值应该是9,但遥感瓦片接口很多是IO读文件,worker太多反而抢内存,3到4个足够 - 没有开
--threaded:同步worker应对低并发够了,开了线程反而增加调试复杂度
5.2 Nginx反向代理和瓦片静态缓存
瓦片请求如果全部打到Gunicorn,Python进程处理静态文件非常浪费。我的做法是在Nginx里单独配置/tiles/的alias,让Nginx直接读静态文件,不进Flask:
server { listen 80; server_name forest.example.com; client_max_body_size 2G; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /tiles/ { alias /opt/forest_remote/tiles/; expires 7d; add_header Cache-Control "public"; access_log off; } }expires 7d让浏览器把影像瓦片缓存一周,队员来回切换图层时体验会好很多。如果担心影像更新后缓存不刷新,URL里带一个版本号参数,比如?v=20250101,改版本号就会重新请求。
5.3 最隐蔽的坑:Flask所有路径要基于项目根目录
这是我在部署阶段遇到的最折磨人的问题。本地开发一切正常,部署到服务器后,静态CSS和JS全部404。一开始以为是Nginx配置错误,查了半天才发现,根因是Flask的静态文件路径用了相对路径,而systemd启动服务时WorkingDirectory指向的是/root,所以./static就变成了/root/static。
修复方式是在app.py最顶上定义:
import os BASE_DIR = os.path.dirname(os.path.abspath(__file__))之后所有涉及文件路径的地方都用os.path.join(BASE_DIR, ...)拼:
app.static_folder = os.path.join(BASE_DIR, 'static')排查链路也分享给你:发现问题后先看Gunicorn的错误日志,发现路径不对,再去print(os.getcwd())确认进程工作目录,最后用绝对路径固定。这个看似不起眼的细节,能省掉部署阶段至少一半的排查时间。
5.4 systemd服务与开机自启
部署机器的重启是常见的事,不能每次手工敲命令,我用systemd把它管理起来:
[Unit] Description=Forest Remote Web After=network.target [Service] WorkingDirectory=/opt/forest_remote ExecStart=/usr/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app Restart=always RestartSec=5 [Install] WantedBy=multi-user.target启动三步:
sudo systemctl daemon-reload sudo systemctl enable forest-remote sudo systemctl start forest-remoteRestart=always很有必要,遥感瓦片后台生成时系统内存猛涨,进程一旦被OOM kill,systemd会自动把它拉起来,不会让整个服务静默挂掉。
5.5 影像上传与瓦片生成的异步化
如果系统里要支持上传新影像并自动切片,直接同步调用gdal2tiles会导致HTTP请求阻塞好几分钟,前端等得绝望。我的方案是用subprocess.Popen把切片任务丢到后台:
import subprocess, uuid @app.post('/api/v1/tasks') def create_slice_task(): task_id = str(uuid.uuid4()) cmd = [ 'gdal2tiles.py', '-p', 'mercator', '-z', '0-18', task_file, f'{BASE_DIR}/tiles/{task_id}' ] subprocess.Popen(cmd, shell=False) return jsonify({'task_id': task_id})前端定期轮询任务状态,完成后自动刷新场景列表。这才是真正可用的上传体验。
还要记得在Flask里设置上传大小上限:
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 # 2GB超过上限直接返回413错误,避免用户不小心传个几十GB的文件进来把内存和磁盘一起拖垮。
最后分享一点个人体会。做这个项目之前,我以为难的是Leaflet交互或Flask接口,真正跑下来才发现,90%的时间花在数据预处理:坐标系转换、瓦片切分、范围校准、命名规范。技术栈选对了,能省掉一半的无谓劳动;数据规范做不好,后期功能扩展都得在原子上补洞。
如果让我再给谁提个建议,手头有三五张影像和一份小班矢量图的话,先别急着追求花哨功能。把“影像切片→Leaflet展示→点击标注→部署上线”这条主链路跑通,再往里加东西。这条链路通了以后,卫星影像、无人机正射影像、样地数据、甚至三维倾斜模型,本质上都是往这条链路上加节点的事。