刚接到这种需求的时候,我第一反应是“这不就是个多边形面积计算嘛”,上手后才发现坑不少。标题里这几个词:JAVA、快速统计、受灾区域面积、行政区划名称,看起来是地图图形处理的活儿,但真正难的不是算面积,而是把经纬度坐标下的多边形面积算准、再和行政边界空间叠加找出“哪些区划被覆盖了”。本文是系列的第五篇,聊聊我用纯JAVA做这件事的完整思路、关键代码和踩过的坑,内容适合后端开发、应急系统建设、保险定损系统研发的同学参考。
先说清楚最终要交付什么:系统输入一个“受灾区域”的图形(通常以GeoJSON或WKT形式给到,比如卫星解译出的水体范围、洪涝淹没面、地震影响圈),输出两样东西——受淹/受影响总面积(单位平方公里),以及涉及到的行政区划名称及各区划内受影响面积。看起来很简单,但既要保证面积计算精度,又要处理行政区划边界的空间关系,忽略任何一个细节,结果都会差得很离谱。
1. 整体设计:先拆解成一数学一空间两件事
1.1 不要被“图形”迷惑,面积和归属是两套算法
很多同学拿到GeoJSON以后,第一反应是把经纬度坐标当成平面坐标,用鞋带公式直接算出结果。这个思路在“地理投影”四字面前是错的。先记住一个结论:同一个多边形,经纬度坐标直接算面积,和真实地面面积会差出一大截,纬度越高偏差越离谱。
行政区划名称匹配更是另一套空间算法。这不是“查一下字符串包含”,而是判断“受灾多边形”和“行政区划多边形”的相交关系,两者相交部分显然才是该区划内的受灾面积。空间计算要用的核心操作叫intersection,求两个多边形交集,再对交集图形求面积。
所以整体架构被我拆成了三个模块:
- 坐标解析与格式统一层:负责把GeoJSON/WKT解析成几何对象;
- 球面面积计算模块:负责精确计算经纬度坐标系下的多边形面积;
- 空间叠加匹配模块:负责把受灾区域与行政区划边界做交集,按区划分组汇总。
这种拆分的好处在于职责非常单一。面积算错了,问题一定在模块2;匹配不上,问题一定在模块3;两边都不对,再回头查模块1的坐标系处理。我调试的时候按这个思路排查,基本没有再被绕进去过。
1.2 为什么技术选型落在JTS上
Java生态里做空间计算,绕不开的地基是JTS(Java Topology Suite)。它的现状是事实上的工业标准,几乎所有Java GIS框架底层都在用。我见过有人想手写射线法判断点在多边形内、手写多边形相交算法,最后都被各种边界情况折磨得放弃。JTS封装好了这几十年业界沉淀的几何算法,稳定可靠,没有理由不用。
有人会问,为什么不直接用GeoTools?我的回答是:GeoTools功能全面但也相当重,引入了proj4、referencing等一堆依赖,对于一个“只算面积+空间叠加”的轻量服务来说过重了。JTS只依赖自身,干净利落。
还有人会问,数据库有PostGIS为什么不用?如果你项目里恰好有PostGIS并且允许用,那直接用SQL做空间分析肯定更省事。但如果你的系统是纯Java部署、没有GIS数据库、或者边界数据需要频繁从接口拉取更新,用JTS在应用层计算更合适。我这次就是要在微服务内核里提供一个轻量接口,没必要为这个功能专门搭一套GIS环境。
1.3 数据源与坐标系:这步错了后面全白干
这个项目里最阴险的坑是坐标系不统一。中国境内能碰到的常见坐标系至少有三种:WGS84(GPS原始坐标)、GCJ-02(国内绝大部分在线地图API加密坐标)、CGCS2000(现国家大地坐标系)。如果你拿到的行政区划边界数据是GCJ-02加密的,而受灾区域图形是WGS84的卫星解译结果,直接叠加会导致几百米的偏移,轻则面积偏差,重则根本匹配不上区划。
我的建议是开工之前必须做三件事:
- 确认受灾区域图形的坐标基准,问上游数据提供方要书面说明,别靠猜;
- 确认行政区划边界数据是什么坐标系、现势性如何,来源文档必须留档;
- 一旦确认两边坐标系不一致,写一个统一的坐标转换工具类,在解析层率先完成统一,后续业务逻辑全部基于统一坐标系。
实务中如果双方都是WGS84或CGCS2000(两者差异在米级以内,应急评估场景可接受),通常不用转换。一旦出现GCJ-02,一定要先做纠偏。我见过太多项目栽在这上面,所以后面单独开了个章节说排查方法。
2. 核心算法与实现细节
2.1 球面面积计算:经度数不等于满地跑
先说为什么不能直接用经纬度平面计算。平面鞋带公式算的是“经纬度坐标系下的面积”,单位根本不算“平方公里”,因为1度经度随纬度变化实际长度不同。在赤道上1度经线约111公里,北纬60度处1度经度只有约55.8公里,纬度越高,同样的“1度间隔”代表的实际面积越小。如果傻乎乎直接算纬度高地区的面积,结果偏大得超出你想象。
正确做法是用等积圆柱投影把经纬度坐标映射到平面,再按鞋带公式计算。这个投影的思路很生活化:把地球表面想象成一张圆柱面,圆柱轴对齐地球自转轴,让投影后面积不变(即保面积投影)。几何意义上来讲,微小面元满足:
dx dy = R² cos(φ) dλ dφ
这正是球面上的面元,所以投影前后的面积是严格相等的。
由此能得到一个通用的球面面积近似公式,R为地球平均半径,φ为纬度,λ为经度:
S ≈ R² × | Σ (λ(i+1) - λ(i-1)) × sin(φ(i)) | / 2
这个公式推导自等积圆柱投影下的Green公式,工程实现上极为方便——把顶点坐标塞进去算个累加和就行。
2.2 面积计算的Java实现
因为JTS的Geometry类已经提供getArea()方法,但它默认按几何对象所用的坐标系计算平面面积。所以不要直接调用disasterGeometry.getArea(),而是要自己读取多边形顶点坐标,走一遍等积算法。
我贴一下我自己写过的核心工具方法:
public class SphericalAreaUtil { /** WGS84长半轴,单位米 */ private static final double EARTH_RADIUS = 6378137.0; public static double areaOnSphere(Geometry geom) { double area = 0; for (int i = 0; i < geom.getNumGeometries(); i++) { Geometry sub = geom.getGeometryN(i); if (sub instanceof Polygon) { area += areaOfPolygon((Polygon) sub); } } return area; } private static double areaOfPolygon(Polygon polygon) { double area = 0; area += areaOfRing(polygon.getExteriorRing()); for (int i = 0; i < polygon.getNumInteriorRing(); i++) { // 内环是“洞”,从外环面积里减去 area -= areaOfRing(polygon.getInteriorRingN(i)); } return area; } private static double areaOfRing(LineString ring) { Coordinate[] coords = ring.getCoordinates(); double sum = 0; for (int i = 0; i < coords.length - 1; i++) { double lon1 = coords[i].x; double lat1 = coords[i].y; double lon2 = coords[i + 1].x; double lat2 = coords[i + 1].y; sum += (Math.toRadians(lon2) - Math.toRadians(lon1)) * (Math.sin(Math.toRadians(lat2)) + Math.sin(Math.toRadians(lat1))); } return Math.abs(sum) / 2.0 * EARTH_RADIUS * EARTH_RADIUS; } }这里有一个容易忽略的细节:多边形的“洞”不能直接用getArea()相减,要逐环累计然后加减。JTS里Polygon.getArea()已经处理了洞,但既然我们自己实现了环面积算法,洞的逻辑必须自己带上。AEROSPIKE?不对,这里还是要提醒自己检查。
返回面积单位是平方米,如果项目需要平方公里,除以1_000_000即可,顺手用BigDecimal控制一下小数位数:
double sqKm = areaOnSphere(geom) / 1_000_000.0; BigDecimal result = BigDecimal.valueOf(sqKm).setScale(2, RoundingMode.HALF_UP);2.3 空间叠加:判断受灾区域覆盖了哪些行政区
有了面积计算,第二步是找到“哪些行政区划与受灾区域相交”,以及“每个区划内受灾面积多大”。这里我直接用的是JTS的Geometry.intersection(Geometry)方法,它返回两个几何图形交集的新几何对象。
但这中间有个性能问题:全国县级行政区划有近三千个,如果每次都把所有区划边界拉出来逐一做intersection,即使单次计算只有几毫秒,累计也会卡到秒级。更别提乡镇级几万个多边形。所以不能暴力遍历。
正确姿势分两步优化:先用STRtree空间索引筛掉明显不相交的对象,再用PreparedGeometry提升交集判断效率。
import org.locationtech.jts.index.strtree.STRtree; import org.locationtech.jts.geom.prep.PreparedGeometry; import org.locationtech.jts.geom.prep.PreparedGeometryFactory; // 构建索引 STRtree index = new STRtree(); Map<Object, PreparedGeometry> preparedCache = new HashMap<>(); for (BoundaryFeature bf : boundaryList) { index.insert(bf.getGeometry().getEnvelopeInternal(), bf); PreparedGeometry pg = PreparedGeometryFactory.prepare(bf.getGeometry()); preparedCache.put(bf.getId(), pg); } index.build(); // 查询候选集 List<BoundaryFeature> candidates = new ArrayList<>(); index.query(disasterGeom.getEnvelopeInternal(), candidates::add); // 候选集中精确判断 StatResult[] results = candidates.parallelStream() .filter(bf -> preparedCache.get(bf.getId()).intersects(disasterGeom)) .map(bf -> { Geometry inter = bf.getGeometry().intersection(disasterGeom); if (inter.isEmpty()) return null; double area = SphericalAreaUtil.areaOnSphere(inter) / 1_000_000.0; return new StatResult(bf.getAdcode(), bf.getName(), area); }) .filter(Objects::nonNull) .toArray(StatResult[]::new);PreparedGeometry是JTS为高频空间关系判断设计的预计算结构。它会预先构建内部索引,让intersects、contains这类判断比裸调geom.intersects(disaster)快一个数量级。我们实际测下来,几千个区划候选集被索引筛掉之后只剩十来个,再去掉不相交的,通常剩三五个,整个过程在毫秒级。
2.4 结果聚合:同名同码的区划要合并
行政区划数据并不是一张干净的表。同一个县如果有主体区域和飞地,就会对应两个甚至多个多边形Feature。所以按区划名称直接输出会重复,正确做法是用行政区划代码(adcode)做分组聚合,代码唯一且规范。
Map<String, StatResult> merged = new HashMap<>(); for (StatResult r : results) { merged.merge(r.getAdcode(), r, (a, b) -> new StatResult(a.getAdcode(), a.getName(), a.getAreaKm2() + b.getAreaKm2())); }结果按面积倒序排个序,接口返回就长这样:
[ {"adcode":"340100","name":"示例市","areaKm2":120.45,"ratio":0.6501,"damaged":true}, {"adcode":"341100","name":"示例县","areaKm2":58.2,"ratio":0.3144,"damaged":true}, {"adcode":"342200","name":"示例区","areaKm2":6.67,"ratio":0.036,"damaged":true} ]ratio表示该区划受灾面积占受灾总面积的比例,对决策分组很有用。顺便算一个受灾面积/区划总面积占比,还能辅助判断受损严重程度。
3. 实操过程:从零写一个可运行的统计工具
3.1 Maven依赖与工程结构
新建一个普通Maven工程,引入JTS和Jackson,两个依赖就够用:
<dependency> <groupId>org.locationtech.jts</groupId> <artifactId>jts-core</artifactId> <version>1.19.0</version> </dependency> <dependency> <groupId>org.locationtech.jts</groupId> <artifactId>jts-io-common</artifactId> <version>1.19.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency>jts-io-common里面有GeoJsonReader,可以直接解析GeoJSON几何对象,省得自己写解析器。工程里我建议建三个包:parse、calc、service,分别放格式解析、球面面积计算、业务匹配逻辑,后续加测试也好组织。
3.2 加载受灾区域GeoJSON
GeoJSON格式读进来的核心就一行:
GeoJsonReader jtsReader = new GeoJsonReader(); Geometry disasterGeom = jtsReader.read(disasterGeoJsonString);需要注意,GeoJsonReader只能读单个Geometry,不能直接读FeatureCollection。所以如果灾情数据是FeatureCollection形式,要么先取出features[0].geometry字段再喂给Reader,要么自己用Jackson读取。
我通常的做法是先提取节点:
ObjectNode root = (ObjectNode) new ObjectMapper().readTree(disasterGeoJsonString); String geometryJson = root.path("geometry").toString(); Geometry geom = jtsReader.read(geometryJson);拿到geom后建议顺手做一层正规化:转成统一坐标系、必要时兜底修复非法几何,保证后续计算不会撞到异常。
3.3 加载行政区划数据
行政区划边界数据一般是FeatureCollection,每个Feature的properties里有name、adcode等字段,geometry是Polygon或MultiPolygon。我封装了一个内部类:
public class BoundaryFeature { private String adcode; private String name; private Geometry geometry; // 构造器、getter、setter略 }加载逻辑很简单,但有个效率点:别在生产环境每次请求都全量加载边界数据。行政区划边界基本是周粒度更新的,启动时加载到内存、定时刷新才是正道。如果数据量大,可以按省分片加载,需要哪个省加载哪个省。
3.4 组装主流程
实战里的Service层核心方法,我抽出来给大家看全貌:
public DisasterStatResult statDisaster(String disasterGeoJson) { // 1 解析受灾范围 Geometry disasterGeom = parseGeometry(disasterGeoJson); // 2 获取候选行政区划 List<BoundaryFeature> candidates = queryCandidates(disasterGeom); // 3 精确叠加计算 List<StatResult> results = computeOverlapAreas(disasterGeom, candidates); // 4 合并飞地与多面体 Map<String, StatResult> merged = mergeByAdcode(results); // 5 计算总受灾面积与占比 double totalArea = disasterArea(disasterGeom); List<StatResult> finalList = new ArrayList<>(merged.values()); finalList.sort((a, b) -> Double.compare(b.getAreaKm2(), a.getAreaKm2())); for (StatResult item : finalList) { item.setRatio(item.getAreaKm2() / totalArea); } return new DisasterStatResult(totalArea, finalList); }整个流程非常顺溜。在真正部署前,我用一个已知面积的矩形做验证:假设某矩形面积为100.5平方公里,程序返回100.47,误差在千分之三以内,这符合应急场景的精度需求。
3.5 实际效果与验证方法
验证方法我建议分三步走:
- 用简单形状(矩形、三角形)在已知纬度做理论计算,和跑出来的结果比对;
- 用行政区内完整多边形的受灾范围做全包含测试,此时区划内受灾面积应该约等于灾情范围总面积;
- 用两个相邻区划交界处的图形测试,确认公共边不会造成面积双算。
前面说到的测试里有个很有意思的点:同一个形状,在纬度21度和纬度52度直接调用getArea()的结果差异能达到2到3倍,这也是为什么我只信任SphericalAreaUtil返回的面积。
4. 常见问题与排查指南
4.1 面积单位诡异:为什么算出来大了十倍百倍
最常见的原因就是直接把经纬度当平面坐标用getArea()了。这种算法的结果不是平方米也不是平方公里,只是“平方度”。平方米换算的话,赤道附近1平方度约12300平方公里,纬度60度处1平方度约3000平方公里,完全没有参考价值。
房子装修时如果有人用厕纸当卷尺量长宽,你一定会崩,这不是精度问题,是量纲问题。换算法不会增加多少代码量,别再图省事了。如果确认算法没问题但数值还是偏大,看看是不是坐标系发生了GCJ-02到WGS84的未转换叠加。
4.2 行政区划匹配不上或偏移明显
出现“明明是受灾区却没匹配到任何区划”的情况,优先怀疑坐标系。验证方法很粗暴:打印受灾图形和行政区划图形的getEnvelopeInternal(),看两个外包矩形是否在空间上重叠。如果不重叠,八成是坐标系不一致,或者边界数据有错误。
我可以提供一个快速判断的经验:如果两者的外围轮廓看起来大概对,但没法精确相交,试着把受灾图形整体向北或向东偏移几十到几百米,如果能匹配上,就能确定是参考基准差异。GCJ-02相对于WGS84大约偏移几十到几百米,做一次纠偏一般能恢复。
4.3 多边形无效导致面积或交集结果异常
JTS对非法几何(自交多边形、重复点、断环等)的情绪是直接抛异常的效果不多,但会悄悄返回错误计算结果。开工之前建议统一修复:
// 使用JTS的buffer(0)技巧修复自交问题,原理是用零宽度的缓冲区“擦”掉自交部分 Geometry repaired = geom.buffer(0);buffer(0)是几何界经典偏方,它会重构多边形边界,把自交点拆开,同时精度损失在可接受范围。特别提醒:这个操作会轻微改变边界,如果对边界保真要求极高,建议在源头治理数据质量,只把修复作为兜底方案。
另一个小坑:无法直接通过getCoordinates()取的环坐标判断方向(顺逆时针)。GeoJSON规范要求外环逆时针,但很多工具产出的数据并不守规矩,JTS一般能容忍,但如果碰到极端的“环方向全部颠倒”数据,先调一下环方向再计算。
4.4 边界重叠把面积算重复
两个相邻区划的边界如果数据不干净(存在缝隙或重叠),叠加计算时同一个地块可能同时算进A县和B县,导致总和大于总受灾面积。这是行政区划数据常见的“拓扑不一致”问题。
实用的办法是聚合阶段做一次去重:从脏数据源拿到重叠部分往往不值得,我在工程里直接用市县级边界做“融合”再切片。更稳妥的经验是找权威发布数据源,在数据接入时就监督拓扑一致性,而不是靠后置计算时手动纠错。因为像飞地、岛屿、插花地这类情况,光靠算法兜底很难彻底解决。
4.5 全量数据加载后内存告急
乡镇级行政区划全国有大概四万多条,如果全部加载到内存做STRtree,内存轻轻松松上几百兆,在服务器里就会成为隐患。我的方案是两级缓存:省级目录在内存中常驻,乡镇级只在需要时按省份注入。这也是空间索引设计里常见的按区域分片思想。
STRtree构建时有个注意点,它要求加入的元素必须在调用build()前全部插入完成,之后再插入会无效,后续查询会丢数据。我早期踩过一次,不加build()也能用但性能退化到全表扫。
5. 结合真实场景的补充建议
写完这套工具后,我习惯在接口层再加一层“范围合理性校验”。比如受灾总面积如果小于0.01平方公里(约1公顷)就标记为极低置信,不进入正式报表;如果与历史同类灾害均值相差十倍以上就要报警,防止上游传入的图形本身有问题。这种业务侧的二次校验,往往比技术侧算法更早发现异常。
还有一个小技巧,关于结果输出格式。为了兼容后续GIS可视化,我会额外返回受灾图形与行政区划的交集多边形本身,而不仅仅是一个面积数值。这样前端可以直接在天地图上叠加展示“哪个区的哪个部分被淹了”,而不是只能看到干巴巴的数字。为此StatResult里再加一个Geometry intersectionGeom字段,序列化时转成GeoJSON即可,对生产系统很有价值。
最后想提醒的是:纯Java方案虽然灵活,但也不是所有场景都该抢着用。如果数据量到了百万级网格颗粒度,或者要频繁做复杂的空间变换,我个人会把计算任务交给区域内已有的空间数据服务,让JTS专注做小数据量、实时性要求高的计算。工具选型从来不是越高级越好,而是放在合适的位置发挥最大价值。这套代码我们已经在一个应急信息服务的轻量后端跑了一阵子,稳定性和性能都过得了关,整体思路足够清晰,可以直接抄作业。但任何代码迁移到具体业务,都一定要自己重测坐标系和边界数据,这俩是绝对绕不过去的质量关口。