简介:基于Java的高标准农田监管平台遥感监测源码,聚焦遥感图像中的AI识别与地物分类,适合智慧农业开发者、遥感算法工程师及农业信息化研究人员学习参考。资源共26个文件,压缩包约28.23MB,含6个Java核心源文件、7个PNG图像示例、4个XML与4个YAML配置文件,以及Git忽略文件、说明文档和项目配置等,目录结构清晰,便于按模块查看代码与配置。源码将AI识别用于作物长势、病虫害等关键信息提取,地物分类可区分水体、植被、建筑物等地表类型,为高标准农田的精准监管提供技术支撑。通过pom.xml等工程配置可快速了解依赖关系,imgs目录下的遥感图像示例便于对照地物分类效果。目前已有356人学习下载,从中可了解项目整体架构、数据配置方式与图像结果组织方法,也可为同类遥感监测平台开发提供参考。
1. 高标准农田监管平台是干什么的:从“一张卫片”到“一块耕地”的 AI 化改造
去县里做高标准农田验收,最怕听到的是“这一片我们上个月刚核实过”。改种没改种、撂荒没撂荒、有没有人偷着盖了房,全靠人腿和台账。换成遥感监测之后,卫星或无人机把影像飞回来,Java 服务接收数据,AI 识别模型跑一遍,新增建设用地、疑似撂荒、大棚房、施工车辆自动标出来,配合地物分类统计耕地、园地、水体的面积变化,监管平台才算真正踩在了地图上。这套方案适合农业信息化、自然资源监测、GIS 相关团队落地:主服务用 Java,训练用 PyTorch,推理用 ONNX Runtime,模型工程师和后端工程师各管一段,是我见过长期维护成本最低的分工方式。下面按技术链拆开讲。
2. 遥感监测与地物分类的技术选型:为什么主服务必须用 Java、训练推理交给 Python
2.1 遥感监测系统落地:Java 做中台、Python 做训练的职责边界
遥感影像最麻烦的不是“图大”,而是“图又大又多”。一景 16 位 GeoTIFF 动辄几个 GB,一批监测任务覆盖一个县就是几十 GB,还要做任务调度、并发切片、结果落库、工单流转。这类苦活,Java 的线程池、Spring 的调度、PostGIS 的空间索引接得都很顺。Python 在数据探索和模型训练上确实强,但做成在线服务后,内存回收、异常隔离、多实例部署都要额外花力气。所以行业里最常见的分工是:Python 侧负责标注、训练、导出 ONNX 模型,Java 侧负责读影像、调 ONNX Runtime 推理、把识别结果变成业务数据。两边只通过“模型文件 + 推理输入输出约定”通信,模型迭代不阻塞平台开发。
这个边界想清楚之后,再选具体组件就顺了。Java 侧我一般会用到四样东西:Spring Boot 做接口和调度,GDAL 的 Java 绑定读 GeoTIFF 和获取坐标变换参数,PostGIS 存空间数据,ONNX Runtime 跑模型。有些团队会用 GeoServer 或自研切片服务出图,这个可以后补,第一步先打通“影像进、结果出”的管线。另外多说一句,持久层用 Spring Data JPA 还是 MyBatis 都行,看团队习惯,但空间字段建议直接上 Hibernate Spatial 或者 MyBatis 配 PostGIS 的 geometry 类型,别把经纬度当两个 double 存,后面做空间关联会非常痛苦。
2.2 基于 Spring Boot + GDAL + PostGIS 的最小工程骨架
新建工程时,Maven 依赖我建议按下面这个组合配。GDAL 的 Java 绑定比较特殊:org.gdal:gdal 在多数版本不会直接进 Maven Central,需要先从服务器上安装的系统 GDAL 目录里找到 gdal.jar,用 install-file 命令安装到本地私服。第一次做这个问题的人十有八九在这卡住,提前有个心理准备。ONNX Runtime 的 Java 包倒是直接可以从 Maven Central 拉。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-spatial</artifactId> </dependency> <dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>按项目实际使用的版本填写</version> </dependency>GDAL 的 jar 包不在中央仓库,按上面说的先从系统 GDAL 安装目录里找。工程目录按“接口层 / 服务层 / 推理层 / 数据层”拆,重点说推理层,它单独放一个 module 比较合适。推理层里放三样东西:模型目录(ONNX 文件)、影像读取工具类(封装 GDAL)、推理执行器(封装 ONNX Runtime)。模型文件不要打进 jar 包,放在配置中心或本地磁盘目录,用配置项指向路径,这样换模型版本不用重新发布服务。
我一般会把 GDAL 的 native 库路径和 ONNX Runtime 的 native 库路径在启动脚本里用 java.library.path 显式指定,别指望系统自动找到。这里再强调一遍:GDAL 的 Open、ReadRaster 方法都是小写开头,这是 Java 绑定的 API 风格,和 C++ 的 GDALOpen 完全不一样,写代码时别凭习惯把方法名写成大写。
2.3 影像与业务数据表结构:TIF 放对象存储、图斑放 PostGIS
原始影像文件放对象存储或 NAS,数据库只存 TIF 的路径、坐标范围、波段数、成像时间这些元信息,不要让数据库扛文件。业务数据用 PostgreSQL + PostGIS,核心建表 SQL 大致是这样的:
CREATE TABLE sensing_task ( id BIGSERIAL PRIMARY KEY, task_name VARCHAR(255), region_geojson JSONB, status VARCHAR(20) DEFAULT 'PENDING', model_version VARCHAR(64), created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now() ); CREATE INDEX idx_task_status ON sensing_task(status); CREATE TABLE detect_result ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL, target_type VARCHAR(50), confidence DOUBLE PRECISION, wkt_geom GEOMETRY(POLYGON, 4326), image_time TIMESTAMP, created_at TIMESTAMP DEFAULT now() ); CREATE INDEX idx_detect_result_task ON detect_result(task_id); CREATE INDEX idx_detect_result_geom ON detect_result USING GIST(wkt_geom);任务表里的 status 字段是核心。多个服务实例同时跑任务时,最怕两个实例抢到同一个任务,我用的是 PostgreSQL 的 SKIP LOCKED 语法来解决任务分配的一致性:
UPDATE sensing_task SET status = 'RUNNING', updated_at = now() WHERE id = ( SELECT id FROM sensing_task WHERE status = 'PENDING' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING id;FOR UPDATE SKIP LOCKED 的意思是:锁住这一行,但如果这行被其他事务锁了,就跳过它取下一行。这样不用在 Java 代码里做分布式锁,也能保证多个实例不会重复拉同一个任务。做过多实例部署的人应该能感受到这个写法的价值——比 Redis 锁轻量得多。
3. 遥感 AI 识别的 Java 端推理:目标检测与变化识别的最小实现路径
3.1 选择 YOLO 系列作为识别模型的理由与训练产物的导出规范
遥感 AI 识别这里,“AI 识别”在项目里通常指两类任务:一类是目标检测,比如识别违规建筑、施工车辆、秸秆堆放点、设施农业大棚;另一类是变化检测,拿前后两个时相的影像对比,找出哪里多了一栋房子、哪里耕地变建设用地了。落地时目标检测用 YOLO 系列最多,原因很实在:遥感影像里小目标多,YOLO 的 SPPF 结构对中等尺度目标友好,而且 YOLOv5/YOLOv8 的导出链路成熟,转 ONNX 后 Java 端直接能跑。训练脚本放在 Python 侧,数据标注格式用 YOLO 的 txt 格式,每个检测框记录 class_id、中心点坐标、宽高,坐标是相对于图片宽高的归一化值。
训练完导出 ONNX 时,有两个参数必须盯住。一是 opset 版本别太低,建议 17;二是输入张量的高宽要指定成模型训练时的尺寸,我一般用 1280 而不是 640,遥感影像里的目标太小,640 会把小目标直接压没。导出命令大致长这样:
python export.py --weights best.pt --include onnx --opset 17 --img-size 1280 1280导出时把 batch 设成固定 1,简化 Java 端张量处理。模型输出张量的形状是 1×84×8400,8400 是三个尺度特征图融合出来的候选目标数量,84 = 80 个 COCO 类别 加上 4 个坐标 加上 1 个置信度(如果只训自己的类别,比如 5 类,那就变成 1×(5+5)×8400)。这个形状在 Java 端解析时经常搞错,后面避坑章节细说。
3.2 用 ONNX Runtime 在 Java 里做检测推理:完整代码与参数说明
Java 端推理链路分四步:GDAL 读影像像素、像素数组转成模型输入张量、ONNX Runtime 跑模型、解析输出做 NMS。下面这段是核心代码,我按顺序拆开讲。
// 1. 用 GDAL 读取影像,拿到第一波段的像素数组 Dataset dataset = gdal.Open(filePath, gdalconst.GA_ReadOnly); int width = dataset.getRasterXSize(); int height = dataset.getRasterYSize(); int[] bandData = new int[width * height]; Band band = dataset.GetRasterBand(1); band.ReadRaster(0, 0, width, height, bandData); // 2. 把 int 数组转成检测模型的 float 输入 // 模型期望 NCHW,即 1 张图,3 通道,高 1280,宽 1280 // 实际部署时先用 GDAL 重采样到 1280x1280,这里省略重采样细节 float[] inputData = new float[3 * 1280 * 1280]; for (int i = 0; i < 3; i++) { for (int j = 0; j < 1280 * 1280; j++) { // 此处简化为灰度图复制到 3 个通道,真实场景要按波段组合填充 inputData[i * 1280 * 1280 + j] = (bandData[j] / 255.0f - 0.485f) / 0.229f; } } // 3. ONNX Runtime 推理 try (OrtEnvironment env = OrtEnvironment.getEnvironment(); OrtSession session = env.createSession(modelPath, new OrtSession.SessionOptions())) { OnnxTensor inputTensor = OnnxTensor.createTensor(env, inputData, new long[]{1, 3, 1280, 1280}); OrtSession.Result result = session.run(Collections.singletonMap("images", inputTensor)); float[][][] outputs = (float[][][]) result.get(0).getValue(); // outputs[0].length = 84,outputs[0][i].length = 8400 }归一化的均值和标准差不能乱填。YOLO 官方训练时用的归一化是直接除 255,不是用 ImageNet 的均值方差,如果你导出模型时做了特殊处理,那推理端必须和训练端完全对齐。我在项目里被这个坑过一次,后端同事按 ImageNet 的均值和标准差归一化,结果检测出来的目标全是错的,最后对了一遍 Python 推理脚本才发现两边不一致。
NMS 后处理这部分代码比较繁琐,但逻辑固定:先按置信度阈值过滤,比如 0.25,然后对同一类别的框按 IoU 做抑制,阈值设 0.45。置信度阈值调低会增加误报,调高会增加漏报,遥感监测场景我一般倾向于 0.3 到 0.35,因为漏报一个违建比多报一个更麻烦。
3.3 检测结果如何接回监管事件:置信度、时相与空间位置的记录
Java 推理拿到的是像素坐标的检测框,要变成能下发给乡镇核查的工单,关键一步是把像素坐标转成经纬度。这一步靠 GDAL 的 GeoTransform 参数完成,六个参数定义了像素行列号和地理坐标的关系:
double[] gt = dataset.GetGeoTransform(); double lon = gt[0] + col * gt[1] + row * gt[2]; double lat = gt[3] + col * gt[4] + row * gt[5];然后用 JTS 库把经纬度坐标构造成 Polygon,调用 PostGIS 的 ST_Contains 判断这个检测框落在哪个监管地块里,再把目标类型、置信度、影像时相、地块编号拼成一条待核查记录。这里要注意:检测框的四个角都要转成经纬度,不能只取中心点,因为一个框可能跨两个地块,中心点落在里面不代表整个框都在里面。项目里常见的做法是计算检测框和地块的交叠面积占比,超过 30% 才算关联上,这个比例可以根据实际业务调整。
4. 地物分类在 Java 服务里的实现:语义分割输出、逐像元统计与面积换算
4.1 地物分类的类别体系与样本组织:别只建“水体/农田”两级
地物分类在监管平台里的角色,是给每一块地打上“身份标签”。分类体系设计得好不好,直接决定模型训练难度和验收口径。我见过最失败的做法是只分“农田、非农田”两级,结果大棚、裸土、苗圃全混在非农田里,业务上完全没法用。做高标准农田监管,至少应该分出八到十类,按我常用的体系来:水田、旱地、园地、林地、草地、水体、建筑与设施用地、道路、裸地、其他。
样本组织上有一个容易踩的坑:样本的时相必须和监测影像的时相大致对齐。别拿夏天的影像样本去跑冬天的影像,季相差异会让分类精度掉一大截,尤其是旱地和水田的区分,冬季很多旱地地表裸露,和裸地几乎没法区分。此外,易混淆的类别要刻意加大样本量,最典型的是大棚和房屋建筑,从正上方看,大棚的白色覆膜和彩钢屋顶非常像,只能靠纹理和周边环境区分。
4.2 Java 推理端输出 RGB 分类专题图与面积统计:核心代码
地物分类模型一般用语义分割网络,输出的是每个像素属于各个类别的概率。常见做法是训练 DeepLabV3 或 PSPNet,导出 ONNX 后输入是 1×3×H×W,输出是 1×numClasses×H×W。Java 端拿着这个概率张量,逐个像素取最大概率对应的类别,再映射成颜色,生成一张 PNG 专题图。下面是简化版的核心逻辑:
// 模型输出的概率张量 probs,形状为 [1][numClasses][H][W] float[][][] probs = (float[][][]) result.get(0).getValue(); int numClasses = probs.length; int pixels = probs[0].length; // 每个类别的 RGB 颜色,按类别索引对应 int[] classColors = new int[]{ 0xFF38761D, // 水田 - 深绿 0xFF6AA84F, // 旱地 - 浅绿 0xFFF6B26B, // 园地 - 橘黄 0xFF38761D, // 林地 - 深绿 0xFFD9EAD3, // 草地 - 淡绿 0xFF3C78D8, // 水体 - 蓝色 0xFFE06666, // 建筑 - 红色 0xFF999999, // 道路 - 灰色 0xFFC4A882, // 裸地 - 土黄 0xFF000000 // 其他 - 黑色 }; int[] classIndex = new int[pixels]; float[] maxProb = new float[pixels]; // 逐像素取最大概率的类别 for (int p = 0; p < pixels; p++) { classIndex[p] = 0; maxProb[p] = 0.0f; for (int c = 0; c < numClasses; c++) { if (probs[c][p] > maxProb[p]) { maxProb[p] = probs[c][p]; classIndex[p] = c; } } }这里有个细节:语义分割模型的输出层通常带 softmax,输出的概率之和为 1。如果某一个像素所有类别的概率都低于 0.7,说明模型“拿不准”,这种情况下我不建议硬赋一个类别,而是把它标记为“待人工复核”。监管平台里宁可多一点人工核查,也不要让错误分类直接进统计报表。
面积换算这块,最容易出错。分类结果里统计的是“像元个数”,要换算成面积必须知道影像分辨率。比如高分二号影像分辨率是 0.8 米,那么一个像元就是 0.64 平方米;先统计每个类别的像元数,乘以单像元面积,再除以 666.67 就是亩数。如果影像做过重采样,千万不要继续用原始分辨率去算面积,必须以重采样后的实际分辨率算。
4.3 分类结果的落库与更新:按行政区汇总的统计 SQL
地物分类的栅格结果要落库,有两条路。一条是在 Java 里逐像元生成多边形,批量插入 PostGIS;另一条是用 GDALPolygonize 在服务端直接矢量化,效率更高但要处理复杂度。考虑到大部分团队的地块边界都是矢量,我的习惯是把分类结果先转成 WKT 字符串,按县或乡镇维度聚合,只把每个多边形和类别写进表里。
统计时按行政区汇总的 SQL 大概是这样:
SELECT b.district_name, c.class_name, SUM(ST_Area(ST_Transform(b.geom, 3857))) AS area_m2 FROM landcover_polygon b JOIN landcover_class c ON b.class_id = c.id WHERE b.task_id = ? GROUP BY b.district_name, c.class_name ORDER BY area_m2 DESC;这里必须用 ST_Transform 转到 3857 投影坐标系再算面积,不能直接用 4326 的经纬度坐标算。4326 是地理坐标系,单位是度,直接算面积完全没有意义。很多刚接触 PostGIS 的同事会在这里翻车,算出来的“面积”小得离谱,其实就是单位搞错了。投影坐标系的选型和参数,建议在项目启动时就定下来,别等统计阶段再返工。
5. 遥感监测项目落地避坑:影像读取、坐标、内存与面积一致性的 4 个血泪经验
5.1 Java 读遥感影像“全黑”或“偏移”
现象:用 GDAL 读一景影像,用 BufferedImage 直接显示,图片是全黑的,或者整个图上下颠倒、位置偏移。
原因:遥感影像很多是 16 位存储,像元值范围 0 到 65535,而 BufferedImage 的 TYPE_BYTE_GRAY 只接受 0 到 255。把几万的值直接塞进 0 到 255 的范围内,超过 255 的全变成白色,大部分是黑色,显示出来自然就是黑乎乎一片。上下颠倒则是因为遥感影像的坐标原点在左上角,但像素行方向是从上到下,部分显示库会做反转,两边不一致就会出现竖直方向的镜像效果。
解决:显示用的影像在读取后做线性拉伸,按 2% 到 98% 的直方图截断,把剩余范围映射到 0 到 255。原始 16 位数据保留在内存里用于地物分类的逐像元统计,显示归显示,统计归统计,两套数据不要混用。如果做在线底图服务,可以直接用 GDAL 转成 COG 或 8 位 PNG 瓦片,不要在 Java 后端每次请求都现场转。
5.2 ONNX Runtime 推理时报 shape 不匹配或启动失败
现象:启动服务时 ONNX Runtime 报“shape mismatch”,或者运行时输入张量的维度对不上模型预期。
原因:绝大多数情况是导出模型时没有开动态轴。训练时输入是 1280×1280,导出后模型的输入 shape 被固定成 [1,3,1280,1280],你在 Java 端传一张 1024×1024 的图,直接被拒。另一个常见原因是 NCHW 顺序搞反,模型期望的输入是 [batch, channel, height, width],Java 端如果按 [batch, height, width, channel] 填充,模型跑出来的结果完全不对,但不报错,这种问题排查起来特别费劲。
解决:导出模型时用 dynamic_axes 把 height 和 width 设成动态,同时保持 batch 固定为 1,这样 Java 端可以传任意尺寸的输入。启动失败的问题,检查一下 ONNX Runtime 的 native 库和 JDK 版本是否匹配,ONNX Runtime 对 JDK 版本有最低要求,项目里用 JDK 8 的话要选对应版本的 onnxruntime 包。这个坑的典型场景是:本地 Windows 跑得好好的,部署到 Linux 服务器上启动报 UnsatisfiedLinkError,大概率是只拷了 jar 包,没拷 native so 文件。
5.3 瓦片推理拼接出现缝隙与色调断层
现象:一张大影像切成多个瓦片分别推理,拼回去之后,检测框在瓦片边界处断掉,或者地物分类图在瓦片接缝处出现一条明显的颜色断层。
原因:模型推理时必须把图像缩放到固定尺寸,瓦片边缘的目标被压缩损失了上下文信息;分类模型的归一化如果每个瓦片单独做均值方差,各瓦片的色调统计不一致,拼在一起就会出现明显的接缝。
解决:推理时给每个瓦片加 context 重叠区,通常加 40 到 50 像素。也就是说,瓦片本身要覆盖的区域是 1024×1024,但读入模型的是 1104×1104,推理完成后把边缘的 context 裁掉,只保留中间的有效区域。这样做能大幅减少边缘检测框断裂问题。归一化方面,不要再每个瓦片单独算统计值,应该先读完整景影像,算一次全局的均值和方差,所有瓦片都用这一个统计值做归一化。
5.4 并发跑监测任务时线程池 OOM
现象:一个批次下发几百个瓦片任务,线程池一放开,服务内存直接飙到 90% 以上,最后 OOM 被杀,整个系统不可用。
原因:遥感影像瓦片本身就占内存,一个 1024×1024×3 的输入 float 数组大约要 12MB,再加上 GDAL 读取产生的中间数组、ONNX Runtime 推理时的临时张量,单个任务峰值能到 200MB 以上。线程池默认配置是 CPU 核数加一,8 核机器同时跑 9 个任务,内存瞬间就爆了。ONNX Runtime 的 Session 是重量级对象,每次推理都新建 Session 更是雪上加霜。
解决:第一,ONNX Runtime 的 Session 要复用,整个服务启动时创建一次,推理时只用 run 方法,Session 不是线程安全的就加锁或用对象池;第二,线程池的线程数设置成 2 到 4,队列容量也限制住,不要用无界队列;第三,给每个任务加一个内存预估,超过阈值就拒绝新任务,等当前任务完成释放内存后继续。配置大概这样:
task: executors: 3 queue-capacity: 20 max-batch-per-task: 50这三个数字按服务器内存调,16G 内存的机器可以放宽到 4 和 30,8G 的机器建议 2 和 10。做遥感监测服务,内存规划永远要按“极端峰值”来算,而不是平均量。
6. 精度复核的进阶技巧:抽样回放、错分误差下钻与模型版本回滚
模型上线只是开始,监管平台真正要用的指标是“AI 识别结果能不能直接作为验收依据”。我常用的方法是做一个抽图复核工具:每个监测任务完成后,自动抽取 5% 到 10% 的影像瓦片,把 AI 分类图和原始影像上下排列生成对比图,分发给乡镇工作人员在 Web 端勾选“确认、误检、漏检”。这个流程一天能复核几百个瓦片,比人工跑现场跑一遍快一个量级。
抽样不是随机抽,要按地理网格分层抽。把整个任务区划成 1 公里乘 1 公里的网格,每个网格至少要抽到一个瓦片,保证复核结果对空间分布有代表性。复核数据回收之后,重点看两个指标:错分误差和漏分误差。错分误差是“AI 说是旱地但其实不是旱地”的比例,漏分误差是“实际上是旱地但 AI 没分出来”的比例。这两个指标要下钻到类别对上,比如发现“水体错分成建筑”的比例特别高,那就去补水体样本,专门采一些干净水体和建筑并存的场景。分类混淆对可以用一个 10×10 的矩阵导出来,哪个格子数值大就去补哪个类别的样本。
模型版本管理这块,我习惯把模型文件名带上版本号和训练日期,比如 landcover_v3_20240511.onnx,配置项指向当前生效的模型路径。上线前拿两个模型做一次背靠背比对,跑同一批影像看结果差异,避免“新版模型修好一个类别导致另一个类别崩了”的情况。线上发现问题时,把配置项改回上一个版本路径、重启服务,就是后悔药,不用重新发布代码。
最后说一个我的个人习惯:模型上线前,一定要拿两个模型从未见过的乡镇影像做盲测,一个地形接近训练集,一个地形差异大。地形差异大的乡镇如果精度下降超过 15%,说明模型泛化能力不够,强行上线只会让基层天天打电话投诉。这套流程走顺了,遥感监测平台的价值才能真正体现出来,希望帮到你。
本文还有配套的精品资源,点击获取