ARTICLE DETAIL

资讯详情

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

基于Django与Flask的遥感影像共享系统:从影像入库到空间检索

基于Django与Flask的遥感影像共享系统:从影像入库到空间检索 前阵子帮技术中心搭了一套遥感影像共享系统用到的是典型的 Python 后端加 Vue 前端的组合开发环境用 pycharm。标题里同时出现 django 和 flask 是因为方案评审阶段两个框架都做过原型最终采取了 django 为主、flask 用于影像处理微服务的混合架构。这套系统解决的核心问题很简单让几十个人不必再把七八个T的 GeoTIFF 拿硬盘互相拷而是打开网页就能按时间、按区域、按传感器检索影像在线预览然后按权限下载。如果你正打算做类似的东西——不管是遥感影像、地图数据还是其他大文件素材管理系统这篇内容应该能帮你少走不少弯路。1. 做一个遥感影像共享系统真正要解决的是什么1.1 一个真实场景部门内部的数据分发有多痛我记得项目启动会上的需求描述特别朴素领导带了三句话第一句是影像都在移动硬盘和NAS里乱放第二句是每次找数据都要问一圈谁那边有某某区域的影像第三句是下载分发全靠网盘人工审核。这三句话翻译成系统需求就是检索、预审、受控下载。遥感影像和普通文档的共享完全不一样普通文档几百K微信传一下就行但一景哨兵2影像通常几百MB一景高分影像动辄几个GB。如果连看都没法预览用户必须把整景下载下来再打开看带宽、磁盘、时间全是浪费。所以这个系统的第一性原理是先让用户看得见再让用户拿得到。能不能缩略展示能不能叠加到地图上能不能按云量和时间筛掉没用的数据直接决定这个系统是真好用还是另一个电子网盘。1.2 技术挑战拆解大数据、多格式、浏览器门槛遥感影像领域有几个天然门槛做系统前必须想清楚数据格式不友好。主导格式是 GeoTIFF带空间参考信息浏览器原生不支持打开连图片预览都需要后端处理。数据体积没有上限。入库动辄按TB算上传、存储、IO都是问题。数据间有关联关系。同一区域不同时间、同一时间不同波段、同一景影像的不同分辨率版本这些关系如果不在系统里建模用户就只能在文件名里碰运气。空间检索是刚需。用户通常问的是2024年5月到8月、经纬度118到119度的无云影像普通文件管理系统的关键字搜索根本顶不住。这些门槛决定了技术选型必须解决三件事空间数据建模、大文件处理、在线可视化。好消息是Python生态里这三块都有相当成熟的方案。1.3 把共享拆成四个核心能力我一般不给客户讲抽象的功能列表而是把共享拆成四个动词传进来、找得到、看得见、发出去。传进来是指有可控的入库流程不是扔到共享文件夹就完系统要自动提取元数据生成预览。找得到是指支持时间范围、空间范围、传感器、云量这些专业维度的组合检索。看得见是指不必下载原图就能看到缩略图、看金字塔切片、做简单对比。发出去是指可以生成受控的下载链接链接可以设置有效期也可以按权限组批量授权。四个能力对应四个技术模块后面内容我都按这个主线来写。2. 选型并不复杂django、flask、Vue、pycharm各自的位置2.1 为什么这类系统绕不开Python遥感数据处理领域Python早就一统天下了rasterio、GDAL、numpy、geopandas 这些库没有替代品。如果你用Java或Go做后端遇到影像预处理还得想办法调外部程序开发效率大打折扣。选Python后端的核心收益是语言层面的统一入库、元数据抽取、缩略图生成、切片、API输出可以用同一套代码链完成不用维护两套技术栈。2.2 django与flask的取舍逻辑标题同时出现django和flask对很多人来说是纠结但我的经验里它们其实分工很清晰django适合做业务系统主体。它内置ORM、Admin后台、认证授权、迁移工具做这种典型的元数据管理用户权限后台维护系统开发效率极高。特别是RBAC权限模型django自带的用户组和权限机制稍微扩展就能覆盖绝大部分共享场景。flask适合做独立的影像处理服务。影像入库时的重处理逻辑、波段运算、裁剪、格式转换这些任务和Web业务耦合度低用flask写一个小服务部署在内部职责单一想重启就重启不影响主业务。我在这个项目里的最终架构是django承载主站和APIflask单独处理切片生成和格式转换两个服务之间通过HTTP调用。如果你面对的是一套纯内部系统、人少、影像规模不大也可以直接用flask全包少一层服务部署成本。选型建议直接看表格对比维度django主导flask主导元数据CRUD和后台管理非常省事Admin直接生成需要自己拼工作量上浮30%权限模型内置扩展简单需要依赖扩展或自己实现影像处理服务逻辑和业务混在一起天然适合拆分团队熟悉度学习曲线稍陡上手快容易改本项目选择主站用django切片等处理用flask2.3 Vue负责的界面层和地图可视化Vue在这个系统里做两件事一是影像列表、检索表单、权限管理这些常规交互二是配合前端GIS库做在线预览。影像预览的交互不能用传统图片组件硬撑。地图级别的查看需要缩放、拖动、图层叠加这块主流方案是OpenLayers、MapLibre GL或Leaflet它们和Vue配合都很好。我这次用的是MapLibre因为矢量切片和栅格切片都支持且国产影像数据源叠加rgb波段效果不错。Vue还需要解决本地预览和地图预览两套逻辑的问题。检索结果列表用分页表格具体影像打开后用地图组件中间通过路由传参和store共享状态。这种结构用Vue搞起来非常自然换React也行但Vue的模板和响应式特性在这个场景下代码量更少。2.4 pycharm的定位不是必须但效率提升明显pycharm在这个项目里主要不是写代码而是调试。django的runserver、flask的app.run、前端构建好的静态文件服务都能在pycharm里直接配置成Run Configuration打断点看变量。特别是Django的ORM查询pycharm的Database面板可以直接看PostgreSQL表结构和索引排查关联查询结果非常直观。有一点我要说明pycharm社区版完全够用不需要纠结什么专业版之类的激活问题。社区版支持Python代码补全、调试、版本控制对django和flask项目足以胜任。3. 系统怎么搭从影像文件到浏览器像素的链路3.1 总体架构前后端分离一个API层管所有前端是Vue3 SPA打包后的静态文件由Nginx直接托管。后端是django提供一组RESTful API用django-rest-framework实现影像入库后产生的缩略图、切片由flask处理服务生成存到共享存储目录前端通过固定的URL规则访问。这里有一个容易掉的坑是静态文件与媒体文件的混淆。Vue打包出的js/css是静态文件由Nginx直接返回而影像本身、缩略图、切片这些是媒体文件必须走独立的存储路径和处理逻辑不能塞进django的static目录。项目里我把两者分开配置影像数据放到专门的数据盘而不是和代码放一起。3.2 数据存储设计文件不入库元数据必须入库影像文件本身绝不进数据库。我的做法是原文件存储在本地数据盘有条件就挂对象存储整个系统逻辑不用改数据库只记录路径和元数据。缩略图和金字塔切片同理生成后落盘API返回URL路径前端直接访问。很多人刚开始会疑惑那我数据库里到底存什么其实核心是元数据表。GeoTIFF头部自带的空间参考信息、拍摄时间、卫星传感器、经纬度范围、分辨率、云量这些才是共享系统检索和展示需要的数据。把它们抽取进PostgreSQL配合PostGIS扩展做空间查询是系统能否好用的大前提。3.3 模型设计的关键字段我设计的核心模型是影像信息表字段设计直接参考常规遥感元数据标准这里给出一个精简可用的版本from django.contrib.gis.db import models as gis_models from django.db import models class SatelliteImage(models.Model): name models.CharField(max_length255, verbose_name影像名称) sensor models.CharField(max_length100, verbose_name传感器) capture_time models.DateTimeField(verbose_name拍摄时间, db_indexTrue) cloud_cover models.FloatField(nullTrue, blankTrue, verbose_name云量百分比) resolution models.FloatField(verbose_name分辨率(米)) origin_path models.CharField(max_length500, verbose_name原始文件路径) thumbnail_url models.CharField(max_length500, blankTrue, verbose_name缩略图URL) tileset_dir models.CharField(max_length500, blankTrue, verbose_name切片目录) extent gis_models.PolygonField(srid4326, verbose_name空间范围, db_indexTrue) uploader models.ForeignKey( auth.User, on_deletemodels.SET_NULL, nullTrue, verbose_name上传人 ) created_at models.DateTimeField(auto_now_addTrue, verbose_name入库时间)注意我用了django.contrib.gis里的PolygonField这是PostGIS空间字段配合它才能做真正的空间范围查询和空间索引。如果纯用字符串存坐标范围查某个经纬度范围内的影像就会变成噩梦。3.4 元数据抽取用代码读tiff的头信息影像上传落盘后后端要做的第一件事是抽出元数据。rasterio是这一环节最顺手的工具import rasterio from datetime import datetime def extract_metadata(tiff_path): with rasterio.open(tiff_path) as src: bounds src.bounds crs src.crs extent_geom { type: Polygon, coordinates: [[ [bounds.left, bounds.bottom], [bounds.right, bounds.bottom], [bounds.right, bounds.top], [bounds.left, bounds.top], [bounds.left, bounds.bottom], ]] } data { width: src.width, height: src.height, crs: str(crs), resolution: abs(src.res[0]), bands: [src.indexes], } # 拍摄时间一般从文件名约定或tif标签里取不同供应商格式不同 return extent_geom, data这里处理顺序有讲究先拿到空间范围写进数据库再生成缩略图最后才处理切片。因为检索依赖的是空间范围用户上传完第一秒就能在列表里看到定位框而切片属于查看细节可以异步慢慢生成。4. 核心模块实战上传、预览、检索、共享、推送4.1 影像上传与异步入库上传接口我设计成两步前端先把文件POST到接口后端存到临时目录立刻返回一个任务ID随后后端异步处理元数据抽取、缩略图生成最后写入卫星影像表。why异步一景影像可能要几GB同步处理会让用户干等好几分钟体验太差。这一步用Celery也可以用更轻量的rq核心是任务队列和状态记录。上传接口的Django视图大致是这样一个思路class UploadSessionCreate(APIView): def post(self, request): upload_file request.FILES.get(file) if not upload_file: return Response({error: 缺少文件}, status400) # 校验文件头确认是tiff防止伪装文件 header upload_file.read(8) if header not in (bII*\x00, bMM\x00*): return Response({error: 不是有效的TIFF文件}, status400) # 保存到临时目录派发异步任务 task_id enqueue_ingest_task(upload_file) return Response({task_id: task_id})注意我绕过了django的FileField直接手动管理上传文件。原因是FileField默认存MEDIA_ROOT而实际项目要按日期、传感器分目录散列存储手动控制路径更灵活。校验文件头那段代码千万别省影像系统很容易被上传伪装成tiff的恶意文件检查头部字节是最基础的有效性校验。4.2 在线预览缩略图、切片地图、m3u8视频流在线预览我分成两档基础档是缩略图适合列表里快速浏览进阶档是金字塔切片适合完整查看大图。缩略图用rasterio直接缩采样生成import rasterio import numpy as np from PIL import Image def make_thumbnail(tiff_path, out_path, target_size(1024, 1024)): with rasterio.open(tiff_path) as src: # 只读前三个波段设为RGB预览 data src.read([1, 2, 3], out_shape(3, target_size[1], target_size[0])) data np.transpose(data, (1, 2, 0)) # 做简单归一化避免纯黑或过曝 data (data - data.min()) / (data.max() - data.min()) * 255 img Image.fromarray(data.astype(np.uint8)) img.save(out_path, quality85)如果影像文件本身带有金字塔或者包含概览也可以用二级抽样方式生成速度更快。金字塔切片的逻辑则是预先做成标准Web Mercator切片目录。这里可以直接用flask写一个切片生成服务拆成256x256的png按z/x/y坐标存放前端的MapLibre直接加载为栅格图层import maplibregl from maplibre-gl; const map new maplibregl.Map({ container: map, style: { version: 8, sources: { imagery: { type: raster, tiles: [ /api/images/${imageId}/tiles/{z}/{x}/{y}.png, ], tileSize: 256, }, }, layers: [ { id: imagery-layer, type: raster, source: imagery }, ], }, });热搜词里反复出现Vue播放m3u8的情况在影像系统里也有对应场景有些供应商会把动态变化影像处理成视频流或者在用户浏览长时序影像时把若干景图像序列转成m3u8视频流播放。实际在前端播放m3u8不需要借助Vue插件直接用hls.js这个库就能独立处理Vue只需要在mounted里初始化播放器即可。后面踩坑部分我还会再聊。4.3 空间检索时间、区域、传感器组合查询检索接口是这个系统用得最频繁的接口我用django的PostGIS能力直接做空间过滤。实现一个组合检索接口前端传四个可选参数开始时间、结束时间、传感器类型、区域bounding box。django这边用queryset链式拼接重点是空间范围使用了标准bbox过滤from django.contrib.gis.geos import Polygon from django.db.models import Q class ImageSearchView(APIView): def get(self, request): qs SatelliteImage.objects.all() start request.query_params.get(start) end request.query_params.get(end) sensor request.query_params.get(sensor) minx request.query_params.get(minx) miny request.query_params.get(miny) maxx request.query_params.get(maxx) maxy request.query_params.get(maxy) if start: qs qs.filter(capture_time__gtestart) if end: qs qs.filter(capture_time__lteend) if sensor: qs qs.filter(sensorsensor) if minx and miny and maxx and maxy: bbox Polygon.from_bbox((float(minx), float(miny), float(maxx), float(maxy))) qs qs.filter(extent__intersectsbbox) # 返回摘要信息避免把切片等信息量全塞进列表 serializer ImageListSerializer(qs[:200], manyTrue) return Response(serializer.data)注意两点一是extent字段必须建立GIST索引PostGIS的intersects查询如果没有空间索引表数据一多就挂实测几十万条数据全表扫描会到秒级以上加索引后降到毫秒级。二是列表接口千万别返回所有字段切片目录、文件路径这些字段动辄几百字符会极大拖慢响应我单独写了一个精简的ListSerializer。4.4 共享与权限登录、角色、可过期分享链接共享系统的共享落到代码上就是三件事谁能看、谁能下、链接多久有效。登录和角色直接建立在django认证体系上。默认用户没有检索权限需要管理员在后台分配角色。角色我用简单RBAC模型分了三个角色游客、高级用户、管理员。游客只能预览缩略图和水印图高级用户允许下载原图管理员负责入库和用户管理。分享链接是另一个核心功能。管理员或授权用户可以选择一批影像生成带token和有效期的分享链接import secrets from datetime import timedelta from django.utils import timezone from django.db import models class ShareLink(models.Model): token models.CharField(max_length64, uniqueTrue) images models.ManyToManyField(SatelliteImage) created_by models.ForeignKey(auth.User, on_deletemodels.CASCADE) expire_at models.DateTimeField() max_downloads models.IntegerField(default10) classmethod def create_share(cls, user, image_ids, hours48, max_downloads10): expire_at timezone.now() timedelta(hourshours) return cls.objects.create( tokensecrets.token_urlsafe(32), created_byuser, expire_atexpire_at, max_downloadsmax_downloads, )下载接口里校验token之后用StreamingHttpResponse流式返回文件避免大文件一次性读进内存。这是大文件下载包里最容易忽略的性能问题。4.5 处理进度的实时推送django websocket怎么用热搜词里django websocket实现后台有数据前端推送被搜了很多次可见这是很多人卡住的地方。django原来只支持HTTPwebsocket需要通道层我用的是django-channels。使用场景很清晰影像上传后元数据抽取要好几分钟前端要实时显示进度。做法是建立WebSocket连接任务处理过程中给那个连接所在的group发送状态消息。# consumers.py import json from channels.generic.websocket import WebsocketConsumer from asgiref.sync import async_to_sync class TaskConsumer(WebsocketConsumer): def connect(self): self.task_id self.scope[url_route][kwargs][task_id] self.accept() # 加入任务组后端任务执行时可以定向推送 async_to_sync(self.channel_layer.group_add)(self.task_id, self.channel_name) def receive(self, text_dataNone, bytes_dataNone): pass def task_progress(self, event): self.send(text_datajson.dumps({ status: event[status], progress: event[progress], message: event[message], }))视图里推送进度from asgiref.sync import async_to_sync from channels.layers import get_channel_layer def push_progress(task_id, status, progress, message): channel_layer get_channel_layer() async_to_sync(channel_layer.group_send)(task_id, { type: task_progress, status: status, progress: progress, message: message, })django-channels配置好ASGI应用和Redis channel layer后这套逻辑就通了。很多教程讲Channel Layer绕来绕去实际就是给任务建一个虚拟房间任务往房间喊话连接着的浏览器全部能听见。5. pycharm开发这类系统的实用配置和踩坑记录5.1 环境配置虚拟解释器与远程调试pycharm开发django项目首先要保证解释器正确。这个项目我建了独立的虚拟环境所有依赖比如django、djangorestframework、channels、rasterio都装在里面。pycharm里配置Project Interpreter指向虚拟环境的pythondjango项目的Run Configuration选Django server类型它会自动读取项目里的配置。调试关键是断点能不能打进去。django在pycharm里打断点默认就是好用的但flask微服务需要手动在启动脚本里加app.run(debugTrue)。我踩过的一个坑是在pycharm里直接运行flask的Debug模式时代码改了不会自动reload需要额外勾选Run with Python Console原因主要是flask的reloader在多线程下的兼容性不过这只是我的环境和版本组合下的情况不一定要照搬。如果影像存储在远程服务器上本地开发需要访问远程数据我建议给pycharm配一个远程解释器而不是把数据全部拉回本地。远程解释器模式下代码在本地编辑执行在远程数据访问路径不用改效率高很多。5.2 踩坑一django的static文件死活显示不了热词里vscode写img标签在django的static文件中显示不了是一个反复被问的问题。这类问题的排查链路基本是固定的第一步看STATIC_URL配置。默认值是/static/模板里写的引用路径必须以它开头我写的是{% static images/logo.png %}。第二步看STATICFILES_DIRS有没有包含模板引用的那个目录。第三步看开发模式下django有没有真正把static目录暴露出去runserver在DEBUGTrue时自动处理但如果DEBUGFalse开发模式下就不会提供static服务页面全是404。这个坑的本质是django在开发模式和生产模式对static的处理完全不一样不少人直接改了DEBUGFalse部署到Nginx前端CSS全没了却发现后面没有Nginx提供静态文件于是表现为显示不了。解决也很简单开发时DEBUG保持True生产时让Nginx接管staticdjango完全不碰它。5.3 踩坑二Vue播放m3u8免安装但要注意CORSVue播放m3u8的需求在热词里出现了多种变体我在影像视频流的分享模块也用到过。直接用video标签加src指向m3u8在Chrome和Firefox上大概率播放不了因为这些浏览器原生不支持HLS协议。替代方案是引入hls.jsimport Hls from hls.js; function playM3u8(videoEl, url) { if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(url); hls.attachMedia(videoEl); } else if (videoEl.canPlayType(application/vnd.apple.mpegurl)) { videoEl.src url; // Safari 原生支持 } }实际部署时最隐蔽的问题是CORS。m3u8文件本身和分片的.ts文件如果不在同一个服务域下或者服务器没给跨域头浏览器会拉取失败。解决方法是让Nginx给媒体目录统一加上add_header Access-Control-Allow-Origin *或者把前端和媒体服务放到同一域下。我后来把所有影像切片和m3u8都放到一个媒体子域名省了一堆麻烦。5.4 踩坑三flask绑定网页元素其实是理解偏差热词里flask如何绑定到网页元素这个词一看就知道是前端基础还没理顺的提问。flask是后端框架不可能直接绑定网页上的div或input。实际能做到的交互只有两种表单提交和Ajax调用。处理这个问题我推荐直接写一个极简示例给提问的人看button onclickloadData()加载数据/buttonasync function loadData() { const resp await fetch(/api/ping); const data await resp.json(); document.getElementById(result).innerText data.message; }flask对应的视图app.route(/api/ping) def ping(): return {message: pong}页面能不能显示pong核心在于JS有没有被执行而不是flask有没有绑定那个元素。把这个例子跑通之后再看Vue的v-model和事件绑定整个数据流就自然了。6. 部署上线我在类似项目里验证过的做法6.1 常规部署组合最终部署方案是一台Nginx做入口转发API请求到Gunicorn起的django进程也负责Vue打包后的静态页面flask处理服务独立跑在8100端口只对内网开放由django的服务端逻辑调用PostgreSQL用到了PostGIS扩展Redis用于channel层和Celery任务队列。Gunicorn启动那个命令看起来很简单但这几个参数很关键gunicorn config.wsgi:application \ --bind 127.0.0.1:8001 \ --workers 4 \ --timeout 120workers数量按CPU核数的2到4倍取timeout设大是因为影像检索某些复杂查询可能超过默认30秒。这里我贴的是常规配置实际workers数要结合服务器内存调整如果一台2核4G的机器跑4个worker加上celery内存容易吃紧反正部署完盯几天监控再收窄参数。6.2 大文件下载与并发下载接口是大文件场景最容易出问题的地方我的处理是下载接口必须满足三个要求断点续传。前端用axios下载时可能断掉后端需要支持Range头这个用django的响应对Range的支持通常自己处理我在项目里封装了一个FileResponse加上Last-Modified头简易支持断点。并发控制。分享链接设置了max_downloads但并发下载同一文件时也需要限制。我用Redis的Lua脚本做原子计数超过上限直接返回403。过载保护。下载接口如果无限速几十个人同时拉数据时Nginx和带宽都会被打满实际项目里我限了每IP每秒1MB虽然少了点但保住了核心业务。6.3 一个简单的验收测试系统上线前我做了一个简单的承受力验证拿1000景不同来源的模拟影像入库记录三个关键指标指标测试结果说明入库一条记录到可预览平均3秒左右元数据抽取缩略图生成空间范围时间组合检索平均300毫秒以内依赖GIST索引和多字段索引200个并发请求下载接口无连接失败受限于带宽每个请求排队慢启动这个测试结果不算极限数据但作为一套内部共享系统的验收已经够用。如果你要做更大规模的影像库建议事先在存储和切片上做负载测试别等到上线后再调。6.4 可以继续扩展的方向系统上线后有几个方向值得延伸都来自我实际看到的需求一是遥感影像的在线波段运算。用户有时想看NDVI不可能把原始影像下载下来再算前端传参数给flask服务做运算返回单波段结果叠加在地图上这个需求用户提的频率很高。二是元数据自动采集的联动。接入新的卫星数据源时不需要手工逐景填写元数据定时任务扫描指定的目录自动抽取入库用celery的beat机制就能实现。三是分享链接的审计。记录谁在什么时间下载了哪些影像配合用户的导入导出审计这类数据治理需求在遥感影像共享场景里越来越常遇到。最后分享一个我做这个项目最大的体会遥感影像共享系统的难点从来不是写接口而是把遥感数据的专业性和Web系统的通用性缝合到一起。文件存储路径、空间索引、异步任务队列、前端地图交互这些组件单独拿出来都不算难但串起来以后每一层都会踩一些不属于自己领域的坑。如果你正在做类似系统建议先把空间范围和检索这条主链路跑通再补权限和可视化不要一上来就铺太多功能。我最初那版demo就是急于做完所有模块结果上线前大部分时间都耗在调bug上其实真正决定系统价值的还是用户能不能快速找到自己想要的影像这一件事。
返回列表