1. 先划清边界:JTS 算的到底是什么几何
接手过一个园区资产管理的数据核对需求,业务方的诉求很朴素:给一批设备坐标,判断每台设备落在哪个管理区块里,顺带算一下设备到区块边界的最近距离。数据库里坐标是经纬度,我图省事,直接用JTS把点的 WKT 和区块的 WKT 拼出来做contains判断,跑完发现 80% 的设备都被判定为"不在任何区块内",距离算出来是零点几。当时第一反应是数据脏,查了半天坐标,最后发现问题在我自己身上——JTS 是个纯平面欧氏几何库,它不知道什么是经度纬度,也不知道什么是米。你把经纬度直接丢进去,它就当成长度单位是"度"的笛卡尔坐标来算,那个0.0003的距离单位是度,不是你想要的米。
这件事是我用 JTS 这些年踩过最大的一个坑,也是我写这篇东西的直接动机。JTS(Java Topology Suite)是一套面向 Java 的二维空间几何算法库,核心能力可以拆成四块:几何对象的构建与序列化、空间关系谓词判断、空间叠加与缓冲区运算、空间索引与批量查询。它支撑的场景非常广——地图服务里的面面相交判断、轨迹数据处理中的缓冲区碰撞检测、后台的空间数据清洗、栅格化前的几何预处理,甚至一些非 GIS 领域的排布碰撞检测都能用。适合谁看?我觉得分两类:一类是刚接触空间数据的后端开发,被TopologyException和坐标系问题折磨得怀疑人生;另一类是已经用了几年但对精度、有效性、性能这些底层机制一直模模糊糊,想系统理一遍的同行。
2. 几何对象构建:从 WKT 到能参与运算的实体
2.1 依赖引入与包名这个历史包袱
先说个新手容易卡半天的点。JTS 经历过一次坐标迁移,老版本在com.vividsolutions.jts这个包下,1.15 之后迁到了org.locationtech.jts。你在网上搜到的示例代码,一半是老包名,直接抄会编译不过。现在正常的做法是引jts-core,版本用 1.18 或 1.19 都可以,1.19 在几何修复和索引查询 API 上做了不少易用性改进,我个人更推荐。
<dependency> <groupId>org.locationtech.jts</groupId> <artifactId>jts-core</artifactId> <version>1.19.0</version> </dependency>依赖本身很轻,没有传递依赖,这点在服务端项目里挺友好。如果你还要读写 GeoJSON 或者做坐标投影转换,那是另外的库的事,JTS 自己不干这个,别指望它。
2.2 六种基本类型与它们的维度约定
JTS 的几何类型族谱不复杂,理解清楚继承关系能省很多instanceof的功夫。Geometry是抽象基类,底下分Point(维度 0)、LineString(维度 1)、Polygon(维度 2),以及对应的多部件版本MultiPoint、MultiLineString、MultiPolygon,再加一个万能容器GeometryCollection。每种几何都有一个getDimension(),返回值就是 0、1、2,写通用逻辑时用它做分支比判断具体类型稳。
有几个细节值得单独拎出来。Polygon由外壳ExteriorRing和零到多个内洞InteriorRing组成,构造函数传的是LinearRing数组,第一个元素必须是外壳,顺序错了直接抛异常。LinearRing是一个首尾坐标必须闭合的LineString,构造时如果没闭合,GeometryFactory会自动帮你补一个首点,但如果你手写坐标数组又自己拼错了顺序,后面算面积可能出负数。至于MultiPolygon,它要求里面的每个Polygon互不相交(不能有共享边),这个约束很多人不知道,拼错了isValid()会返回 false。
2.3 GeometryFactory 与 PrecisionModel:精度先定,再谈几何
GeometryFactory是造几何的入口,几乎所有构建方法都挂在它上面。它有两个可以定制的构造参数,一个是PrecisionModel,一个是 SRID。这两个东西决定了后面所有运算的"地基",我建议在任何项目里都显式指定,不要用默认的new GeometryFactory()图省事。
PrecisionModel有三种模式:
| 模式 | 构造方式 | 适用场景 | 副作用 |
|---|---|---|---|
| FLOATING | new PrecisionModel() | 默认,不做取舍,保留 double 全精度 | 浮点误差会累积,长链条运算后可能出现极小缝隙 |
| FLOATING_SINGLE | new PrecisionModel(PrecisionModel.FLOATING_SINGLE) | 数据源本身是 float,或需要更强的容差 | 精度损失明显,坐标量级大时误差可观 |
| FIXED | new PrecisionModel(1000) | 固定小数位,比如毫米级、0.001 度级 | 坐标会被强制 snap 到网格,可能引入新的拓扑问题 |
选哪个?我的经验是:数据本身干净、运算链条短,用默认 FLOATING 就行;如果要做大量叠加运算、结果还要入库比对,用 FIXED 配合一个合理的网格尺度,能显著减少"差一点点就对不上"的问题。那个网格尺度怎么定?看你数据的实际精度,比如业务上坐标只到 0.000001 度(约 0.1 米),那就别把模型设成 1e9 那么细,设成 1e6 反而稳。
至于 SRID,这里必须强调一句:JTS 里的 SRID 只是一个整型标签,不参与任何计算,也不触发任何坐标转换。你给一个经纬度的几何标上 4326,再给一个投影坐标的几何标上 3857,两者做intersects,JTS 照样直接拿数字算,结果是垃圾,但不会报错。这个"静默失败"是最阴险的,因为没有任何异常提示。
PrecisionModel pm = new PrecisionModel(1000000); // 1e-6 网格 GeometryFactory gf = new GeometryFactory(pm, 4326); WKTReader reader = new WKTReader(gf); Polygon block = (Polygon) reader.read( "POLYGON ((116.30 39.90, 116.32 39.90, 116.32 39.92, 116.30 39.92, 116.30 39.90))");3. 空间关系判断:九种谓词里藏着的边界语义
3.1 DE-9IM:所有关系判断背后的统一模型
JTS 的关系判断不是各写各的,它们全部建立在DE-9IM这个模型上。简单说,任意两个几何 A 和 B,各自有内部(Interior)、边界(Boundary)、外部(Exterior)三个部分,两两组合成 3×3 共九个格子,每个格子记录"这两个部分的交集维度"(-1 表示不相交,0/1/2 表示点/线/面)。relate(A, B)返回的就是这九个数字拼成的字符串,比如"212101212"。
intersects、contains、within、touches、crosses、overlaps、covers、coveredBy、equals、disjoint这些谓词,本质上都是对 DE-9IM 矩阵的模式匹配。理解这一点,你在遇到"为什么这个点明明在面上,contains却返回 false"的时候,就知道该去查矩阵而不是怀疑数据。
调试时有个很实用的小技巧:把relate的结果打出来看。比如判断点在不在面内,point.relate(polygon)打出"0FFFFF212"说明点在面内部;打出"F0FFFF212"说明点落在外壳线上,也就是边界上。
3.2 contains 和 covers 的差别,害过太多人
这是我最想强调的一节。contains的定义是:B 的内部与 A 的内部有交集,且 B 的内部不与 A 的外部相交,且 B 不完全落在 A 的边界上。最后这半句是关键——如果一个点正好落在多边形的边线上,polygon.contains(point)返回false。
covers则宽松一些:只要 B 的任意部分(含边界)和 A 的内部或边界有交集,且 B 不在 A 外部,就返回 true。所以点落在边线上时,polygon.covers(point)返回true。
实战里怎么选?设备点位判断、围栏判断这类需求,我建议默认用covers或者intersects,因为业务方认为"在边上"就是"在里面"。只有当业务明确要求"必须在内部、不能压线"时,才用contains。这个坑我见过的最惨案例是某次统计报表,所有恰好落在区块边界线上(因为坐标被取整过)的设备全部被归到"区块外",报表数和现场对不上,排查了两个礼拜。
// 落在边线上的点 Point p = gf.createPoint(new Coordinate(116.30, 39.91)); boolean c1 = block.contains(p); // false —— 压线不算包含 boolean c2 = block.covers(p); // true —— 压线算覆盖 boolean c3 = block.intersects(p); // true —— 只要碰到就算3.3 关系判断的性能与批量场景的写法
单个intersects的开销其实很小,真正拖慢系统的是"两两比较"。一万个点对一万个面,暴力双循环是一个亿次判断,几秒钟跑不完,数据量再上一个量级直接不可用。这时候的思路是先做包络矩形(Envelope)预筛,把明显不相交的候选砍掉,再对剩下的做精确判断。JTS 提供了STRtree就是干这个的,后面单独一节说。
另外提一个常被忽略的点:Geometry对象不是线程安全的,JTS 官方文档里写得很含蓄,但实操中你如果在多线程里共享同一个Polygon实例去做运算,偶发的诡异结果会很难复现。稳妥做法是每个线程各自构建,或者加同步。还有,JTS 的isValid()和getArea()这类方法内部有缓存字段,虽然没有明显的并发问题,但为了省心,我还是倾向于不共享。
4. 叠加运算与缓冲区:参数选不对,结果全白费
4.1 buffer 的四个参数怎么定
buffer是用得最多的运算之一,做法是给几何向外扩一个距离,生成新的几何。它最少要传一个distance,但真正决定结果质量的是另外几个参数,很多人根本不知道它们存在。
- quadrantSegments:默认 8。它控制圆弧被切分成多少段,每四分之一圆分几段。段数越多越接近真圆,但顶点数暴涨。有个估算公式:近似圆与真圆的最大径向误差约等于
r × (1 - cos(π / (2 × quadrantSegments)))。代入默认值 8,误差大约是半径的 0.48%。如果你缓冲半径是 100 米,误差接近半米。要控到 1 厘米以内,需要quadrantSegments到 40 左右。别盲目调大,顶点数会跟着涨十几倍,后续相交运算的开销也要涨。 - endCapStyle:端点样式。
CAP_ROUND圆头(默认),CAP_FLAT平头,CAP_SQUARE方头。做路径覆盖范围时,平头和方头能省掉大量顶点,视觉上也更符合"路段"的业务预期。 - joinStyle:拐角样式。
JOIN_ROUND圆角(默认),JOIN_MITRE尖角,JOIN_BEVEL斜切。尖角在某些锐角拐弯处会向外戳得很远(mitre 长度有限制,JTS 内部有mitreLimit控制),做建筑轮廓时通常用尖角更贴合实际。 - simplifyFactor:对结果做抽稀的容差,默认不减。
BufferParameters bp = new BufferParameters(); bp.setQuadrantSegments(16); bp.setEndCapStyle(BufferParameters.CAP_FLAT); bp.setJoinStyle(BufferParameters.JOIN_MITRE); bp.setSimplifyFactor(0.01); Geometry buffered = geom.buffer(50, bp);distance的正负也有讲究:正数是外扩,负数是内缩。对多边形内缩时,如果距离超过多边形最窄处的半宽,结果会变成空几何(isEmpty()返回 true),这不是 bug,是数学上真实如此。做"退界"计算时务必先判断isEmpty(),否则后面拿空几何去做相交会得到一堆空结果,排查起来很费劲。
4.2 叠加分析:union、intersection、difference 的取舍
叠加运算有四兄弟:union(并)、intersection(交)、difference(差)、symDifference(对称差)。它们的语义很直白,坑主要在输入数据质量上。
最常见的异常是TopologyException: found non-noded intersection between LINESTRING ... and LINESTRING ...。这个报错的意思是:运算过程中发现两条线在某个位置相交了,但那个交点没有被显式地记录成一个顶点,拓扑上有歧义。触发原因通常是输入几何本身无效(自相交、重复点),或者浮点误差导致理论上重合的边实际差了几微米。
排查手段有两步,第一步对两个输入都跑isValid(),拿到 false 的那个用explain()看具体原因;第二步如果输入都有效,试着对参与运算的几何做一次GeometryPrecisionReducer,把坐标统一到某个网格上,很多时候浮点噪音吃掉就过了。还有一种粗暴但有效的办法:对输入先buffer(0),这个操作本身就是在做一次"清理自相交"的拓扑重建。
4.3 简化与抽稀:两种 simplifier 不能随便换
轨迹抽稀、轮廓压缩这类需求要用简化器。JTS 里有两个主要实现,名字很像但行为差很多:
| 简化器 | 策略 | 优点 | 缺点 |
|---|---|---|---|
| DouglasPeuckerSimplifier | 按点到弦的垂直距离做取舍 | 快,压缩率高,形状保真度好 | 可能产生自相交,多边形可能变无效 |
| TopologyPreservingSimplifier | 在 DP 基础上加拓扑约束 | 结果保证有效,不会自相交 | 慢一些,压缩率略低 |
我的建议很明确:只要你的几何是多边形或者需要保持有效性,一律用 TopologyPreservingSimplifier。用 DP 简化多边形然后拿去做面积统计,你会得到一堆无效几何和错误面积。有个折中做法是先简化再用GeometryFixer.fix()修一遍,但那就是绕远路了。
5. 空间索引与批量处理:STRtree 的正确打开方式
5.1 构建与查询的基本套路
回到前面点面判断的场景,一万个点对一万个面。正确姿势是给面建一个STRtree,然后每个点拿自己的包络矩形去查,只对命中的候选做精确判断。
STRtree index = new STRtree(); for (Polygon block : blocks) { index.insert(block.getEnvelopeInternal(), block); } index.build(); // 显式构建,之后查询才快 for (Point p : points) { List<?> candidates = index.query(p.getEnvelopeInternal()); for (Object o : candidates) { Polygon block = (Polygon) o; if (block.covers(p)) { // 命中,记录对应关系 break; } } }这里有几个细节。insert的第一个参数是Envelope,第二个是你要存的任意对象,索引本身不关心它是不是几何,你甚至可以存 ID 字符串。build()是可选的,不调用的话第一次查询时会自动构建,但显式调用能让构建时机可控,也避免多线程环境下第一次查询时的竞态。
query返回的是包络矩形相交的候选集,不是精确相交的结果。拿到候选后必须自己做精确判断,这一步千万不能省——省了就会得到一堆误判。我见过有人直接拿query的结果当最终答案,因为测试数据里所有几何的包络恰好都不重叠,上线后数据一乱套,结果全错。
5.2 一对一查找和批量查找的写法差异
如果需求是"A 集合里每个元素找到 B 集合里相交的那个",除了逐个query,还有一个更省事的思路:把 A 和 B 合并成一个GeometryCollection,用STRtree分别建两个索引,或者干脆用 JTS 的OverlayNG加上索引参数做批量叠加,效率能再提一截。不过这属于性能优化的深水区,数据量没到十万以上其实没必要。
真正需要提醒的是内存。STRtree会把所有几何的包络常驻在内存里,一个Envelope对象大概几十字节,十万个就是几兆,问题不大。但如果你把完整的Geometry也一起挂在索引里(通常会的),几何本身的顶点数据才是内存大头。百万级顶点量的多边形集合,堆内存直接吃掉几百兆,服务端跑着跑着就 OOM。这时候要么分片处理,要么先把几何做简化,别硬扛。
6. 常见问题与排查技巧实录
6.1 异常和错误结果速查表
这张表是我这些年攒下来的,遇到问题先对号入座,能省掉大量瞎猜时间:
| 现象 | 最可能的原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 距离算出来是零点几 | 用了经纬度直接算平面距离 | 检查坐标数值范围,纬度应在 -90~90 | 先做投影转换,或改用球面距离公式自算 |
contains返回 false 但视觉上点在面里 | 点在边线上 | 打relate矩阵确认 | 改用covers |
TopologyException: found non-noded intersection | 输入无效或浮点误差 | 对输入跑isValid()和explain() | buffer(0)清理,或统一精度模型 |
isValid()返回 false,explain说 Self-intersection | 多边形边自交 | 定位到具体坐标 | GeometryFixer.fix()修复 |
| 面积为负 | 环方向反了(外环顺时针) | 检查isCCW | 调normalize()或重排坐标 |
| 相交结果为空,但两者明显重叠 | SRID 不一致(一个投影一个经纬度) | 打印两者 SRID 和坐标范围 | 统一坐标系,这是脏数据问题 |
| 多线程下运算结果偶发异常 | 共享了 Geometry 实例 | 检查是否有静态复用 | 每线程独立构建,或用不可变副本 |
| 简化后几何无效 | 用了 DP 简化器 | 换实现 | 改用TopologyPreservingSimplifier |
| 点标注位置跑到多边形外面 | 用了getCentroid()打标签 | 凹多边形的重心可能在外部 | 改用getInteriorPoint() |
getCentroid和getInteriorPoint这个坑值得单独说一句。centroid是几何的质心,数学上正确但它可能落在多边形外部——一个 U 形多边形,重心正好在凹口里。做地图标注、POI 落位这类需求,必须用getInteriorPoint(),它保证返回的点一定在多边形内部。这是个一行代码的改动,但不知道的话会一直觉得是渲染库有问题。
6.2 数值精度:那些不报错但结果错的地方
浮点误差在空间运算里是系统性存在的,不是偶发。最典型的表现是"两个理论上应该完全重合的面,相交后边缘出现了一圈极细的碎面"。这种碎面面积接近 0,但如果你的逻辑是"相交面积大于 0 就算重叠",这些碎面会被全部判成重叠,报表数字直接翻倍。
处理办法有三个层次。第一层,判断面积时加一个极小阈值,比如if (area > 1e-9)再过,而不是> 0。这个阈值要根据你的坐标单位定,投影坐标下取 0.01 平方米合理,经纬度下就没法直接用了。第二层,在构建几何时就用 FIXED 精度模型把坐标 snap 到网格上,从源头减少噪音。第三层,对结果做一次GeometryPrecisionReducer.reduce(),把运算产生的细微偏差量化掉。
还有一类精度问题比较隐蔽:坐标数值量级太大。如果坐标系单位是米,原点又离你的数据区很远,X 坐标可能是1.2e7这种量级,double 在这个量级上做减法,可用的有效位数会少好几位。这时候的稳妥做法是先把坐标整体平移到原点附近做运算,算完再平移回去。这个技巧在大比例尺工程数据里很常见,值不值得一提但救过命。
7. WKT/WKB 序列化与跨系统交换
7.1 WKT 适合调试,WKB 适合传输
WKTReader和WKTWriter是最常用的序列化组合,优点是肉眼可读,日志里打出来一眼就能看出几何形状对不对。但 WKT 文本冗长,一个稍微复杂点的多边形字符串几十 KB,高频接口里传这个不合适。跨进程传输或者存数据库,用WKBWriter更合理,二进制体积能小一个量级。
WKBWriter writer = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN); byte[] bytes = writer.write(geom); WKBReader reader = new WKBReader(); Geometry restored = reader.read(bytes);构造WKBWriter时第一个参数是输出维度,传 2 只写 XY,传 3 会带上 Z。这里有个坑:如果你的几何里 Z 值是Coordinate.NULL_ORDINATE(也就是 NaN),写成 3 维会出问题,读回来的时候 Z 可能变成垃圾值。所以除非确实需要高程,一律用 2 维。
另外WKTWriter有个setTrim(true),能去掉坐标后面那些没用的零和小数尾巴,日志体积能小不少。还有个setPrecisionModel()可以直接在输出时做精度取舍,比先reduce再输出方便。
7.2 解析容错:别让一行脏数据搞崩整个任务
WKTReader.read()遇到格式不对的字符串会直接抛ParseException。批量处理时如果不用 try-catch 包住,一千条数据里有一条格式错误,整个批次全废。我的习惯是写一个包装方法,把异常转成业务上的"解析失败"结果,记录下来但继续往下走。
还有一种情况是read成功了但得到的几何是空的(POINT EMPTY),或者维度维度不对,这些不会抛异常,需要主动判isEmpty()和getNumPoints() == 0。别指望库帮你兜底,它只负责语法正确。
8. 一个完整的落地示例:设备落区判断
把前面的点串起来,给一个能直接抄的完整流程。需求是:读取区块 WKT 列表和设备坐标列表,输出每台设备所属区块和到区块边界的最近距离。
第一步,坐标统一。如果原始数据是经纬度,而距离需要以米为单位,必须先转投影,这一步 JTS 不管,得用单独的投影库,转完再进 JTS。第二步,构建几何时统一用一个GeometryFactory和同一个PrecisionModel。第三步,建STRtree索引。第四步,逐点查询,精确判断用covers。第五步,距离用geom.distance(point),注意这个距离是点到几何边界的最短距离,如果点在多边形内部,返回的是到最近边的距离,这个语义正好符合"到边界距离"的需求。
double dist = block.distance(p); // 到边界的最近距离 boolean inside = block.covers(p); // 是否在区块内(含边界) Point label = block.getInteriorPoint(); // 标注点,保证在内部整个流程里最容易出错的仍然是第一步和第二步。我现在的习惯是,在数据进 JTS 之前先做一个前置校验:抽样打印坐标范围,确认 X 落在合理的经度区间、Y 落在合理的纬度区间,或者投影坐标落在预期量级。这一步花两分钟,能省掉后面两天的排查。
最后分享一个我个人坚持的做法:任何涉及 JTS 的服务,日志里必须能打出原始 WKT 和运算结果 WKT。空间问题一旦出错,没有原始几何基本没法复现,尤其是那种只在特定数据组合下才触发的拓扑异常。日志多写两行,排查时就是救命稻草。