ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

轻量级WebGIS开发实战:Flask+Leaflet构建林业遥感影像系统

轻量级WebGIS开发实战:Flask+Leaflet构建林业遥感影像系统 三四月份实验室接了个林业调查队的小项目——把一片林区的高分辨率遥感影像、森林资源调查样地和解译图斑集成到一个网页系统里让一线人员在野外平板上能看图、打点、查属性办公室电脑上也能同步操作。要求很简单也很苛刻系统轻、部署快、不引入重型GIS服务器最好一台普通4核8G的Linux机器就能跑起来。我花了一周调研方案最后选定Flask做后端、Leaflet做前端地图库用一个多月把从数据切片、接口设计、地图交互到nginx部署的完整链路跑通了。这篇文章是完整复盘把关键技术决定、数据处理时的坑、接口设计思路和部署细节都写出来。如果你也在做林业遥感、自然资源相关的小系统或者想在Flask项目里接入Leaflet地图可以参考这条路少踩几个坑。1. 为什么这个项目选了FlaskLeaflet而不是重型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更稳。我把两个框架按项目场景做了个对比对比项FlaskFastAPI并发模型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包本身只有几十KBAPI直观官方文档和社区例子很多。我后期如果想上三维或者做时光轴动画数据层不用动换个前端框架滚一层就行。先把这个二维底座打扎实比一开始追求大而全的方案更明智。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_keyTrue) name db.Column(db.String(128), nullableFalse) tile_dir db.Column(db.String(256), nullableFalse) bounds db.Column(db.Text, nullableFalse) # JSON数组字符串 zoom_min db.Column(db.Integer, default0) zoom_max db.Column(db.Integer, default18) capture_time db.Column(db.DateTime) created_at db.Column(db.DateTime, defaultdatetime.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_keyTrue) scene_id db.Column(db.Integer, db.ForeignKey(scenes.id)) lon db.Column(db.Float, nullableFalse) lat db.Column(db.Float, nullableFalse) label db.Column(db.String(64), default) remark db.Column(db.Text, default) created_at db.Column(db.DateTime, defaultdatetime.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_id1查询标注列表接口代码很简单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_directoryfrom 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核数×21的经验值应该是9但遥感瓦片接口很多是IO读文件worker太多反而抢内存3到4个足够没有开--threaded同步worker应对低并发够了开了线程反而增加调试复杂度5.2 Nginx反向代理和瓦片静态缓存瓦片请求如果全部打到GunicornPython进程处理静态文件非常浪费。我的做法是在Nginx里单独配置/tiles/的alias让Nginx直接读静态文件不进Flaskserver { 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里带一个版本号参数比如?v20250101改版本号就会重新请求。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] DescriptionForest Remote Web Afternetwork.target [Service] WorkingDirectory/opt/forest_remote ExecStart/usr/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app Restartalways RestartSec5 [Install] WantedBymulti-user.target启动三步sudo systemctl daemon-reload sudo systemctl enable forest-remote sudo systemctl start forest-remoteRestartalways很有必要遥感瓦片后台生成时系统内存猛涨进程一旦被OOM killsystemd会自动把它拉起来不会让整个服务静默挂掉。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, shellFalse) return jsonify({task_id: task_id})前端定期轮询任务状态完成后自动刷新场景列表。这才是真正可用的上传体验。还要记得在Flask里设置上传大小上限app.config[MAX_CONTENT_LENGTH] 2 * 1024 * 1024 * 1024 # 2GB超过上限直接返回413错误避免用户不小心传个几十GB的文件进来把内存和磁盘一起拖垮。最后分享一点个人体会。做这个项目之前我以为难的是Leaflet交互或Flask接口真正跑下来才发现90%的时间花在数据预处理坐标系转换、瓦片切分、范围校准、命名规范。技术栈选对了能省掉一半的无谓劳动数据规范做不好后期功能扩展都得在原子上补洞。如果让我再给谁提个建议手头有三五张影像和一份小班矢量图的话先别急着追求花哨功能。把“影像切片→Leaflet展示→点击标注→部署上线”这条主链路跑通再往里加东西。这条链路通了以后卫星影像、无人机正射影像、样地数据、甚至三维倾斜模型本质上都是往这条链路上加节点的事。
返回列表