第17章 - 有效性与 Coverage 质检
17.1 质检的两个层次:几何有效性与覆盖率有效性
17.1.1 为什么“能算”不等于“算对”
在 GIS 工程里,最容易被忽略的一类事故是:数据本身拓扑错误,但算法仍然返回了结果。JTS 的大多数空间操作假设输入几何是有效的(valid,符合 OGC Simple Features 规范);当输入无效时,叠加分析、缓冲区、三角剖分等算法并不会主动报错,而是产生难以解释的结果——面积偏大、孔洞消失、多边形被劈成碎片,甚至抛出 TopologyException。
更隐蔽的是第二类问题:单个要素有效,但要素之间关系错误。相邻的两个行政区块都各自有效,却存在 1 厘米的缝隙或 2 厘米的重叠;单独检查每个多边形都通过,叠加成一张图时就无法得到无缝无叠的覆盖率。JTS 1.20 引入的 coverage 包专门解决这一类“要素间质检”。
本章围绕这两层质检展开:
- 第一层:单要素有效性,用 IsValidOp、IsSimpleOp、RepeatedPointTester 检查,用 GeometryFixer 修复;
- 第二层:要素间覆盖有效性,用 CoverageValidator、CoveragePolygonValidator、CoverageGapFinder 检查缝隙与重叠,用 CoverageCleaner 清理、CoverageSimplifier 化简、CoverageUnion 高效合并。
读完后你应当能够:独立设计一条“解析 → 单要素校验 → 修复 → 覆盖率校验 → 清理 → 入库”的质检流水线,并解释每条检查背后的算法与代价。
17.1.2 三层质检模型
工程上通常把质检拆成三层,越往下越贵:
┌──────────────────────────────────────────────────────────────┐
│ 第 1 层:语法与数值 │
│ 坐标是否有限值(非 NaN / 非 Inf)、几何类型是否支持、 │
│ 环是否闭合、点数是否足够 │
│ 工具:IsValidOp.isValid(Coordinate)、IsValidOp、WKTReader │
├──────────────────────────────────────────────────────────────┤
│ 第 2 层:单要素拓扑 │
│ 环是否自相交、孔是否在壳内、孔是否嵌套、内部是否连通、 │
│ 多部件是否嵌套 / 重复环 │
│ 工具:IsValidOp.getValidationError、GeometryFixer │
├──────────────────────────────────────────────────────────────┤
│ 第 3 层:要素间关系(覆盖率) │
│ 内部是否互不相交、公共边是否精确匹配、是否存在窄缝 / 重叠 │
│ 工具:CoverageValidator、CoverageGapFinder、CoverageCleaner │
└──────────────────────────────────────────────────────────────┘
三层检查的成本差异很大。第 1 层基本是线性扫描;第 2 层需要对环做 noding(先求交再判拓扑),复杂度与顶点数近似 O(n log n);第 3 层要对整个数据集建 STRtree 索引并做相邻要素间的线段匹配,成本与要素数量、共享边数量都相关。因此质检系统的设计原则是:便宜的先做,昂贵的按需做,检查结果可缓存。
| 检查层次 | 典型 API | 输入 | 典型耗时(1 万顶点多边形) | 能否自动修复 |
|---|---|---|---|---|
| 数值合法性 | IsValidOp.isValid(Coordinate) | 单个坐标 | 纳秒级 | 可(删除非法点) |
| 单要素有效性 | IsValidOp.isValid(Geometry) | 单个几何 | 毫秒级 | 多数可(GeometryFixer) |
| 简单性 | IsSimpleOp.isSimple(Geometry) | 线或面 | 毫秒级 | 部分可 |
| 覆盖率有效性 | CoverageValidator.validate(Geometry[]) | 一组面 | 与相邻对数成正比 | 可(CoverageCleaner) |
| 窄缝检测 | CoverageGapFinder.findGaps | 一组面 | 较高(需先 Union) | 可(CoverageCleaner) |
17.2 OGC SFS 有效性规则
17.2.1 点与线的有效性
JTS 的有效性定义直接对应 OGC Simple Features 规范,判断入口是 Geometry.isValid(),内部委托给 IsValidOp。规则可以按几何类型逐条列出。
Point:坐标必须是有限值。IsValidOp.isValid(Coordinate) 会显式检查 x、y 是否为 NaN 或 Infinite,任一为真即判定无效,错误码为 INVALID_COORDINATE。
MultiPoint:每个点有效即可。注意,MultiPoint 里存在完全重复的点仍然是有效的——重复点属于“简单性”范畴,由 IsSimpleOp 处理,而不是有效性。
LineString:坐标有效,且去重后的点数至少为 2。源码中常量 MIN_SIZE_LINESTRING = 2,检查函数是 isNonRepeatedSizeAtLeast:连续重复的点不计数。
LinearRing:坐标有效、首尾坐标相同(闭合)、去重后至少 4 个点(三点才能围出面积,加上闭合点共 4 个)、且环自身不能自相交(端点重合是允许的)。这四条依次对应错误码 INVALID_COORDINATE、RING_NOT_CLOSED、TOO_FEW_POINTS、RING_SELF_INTERSECTION。
GeometryCollection:所有子要素都有效。
17.2.2 多边形的有效性
多边形是质检的主战场。一个 Polygon 有效,当且仅当:
- 所有环坐标有效;
- 所有环闭合;
- 所有环去重后点数足够;
- 任意两个环之间不存在“非法相交”——不穿越、不共线重叠、不产生重复环;
- 每个孔都落在壳的内部(允许在边界上接触,但不能穿出);
- 孔与孔之间不嵌套;
- 多边形的内部是连通的(不能因为孔与壳相切把内部切成两块)。
下面这张示意图列出最常见的四类无效多边形:
自相交(蝴蝶结) 孔在壳外
┌──────────────┐ ┌──────────────┐
│ ╱╲ ╱╲ │ │ ┌────┐ │
│ ╱ ╲ ╱ ╲ │ │ │壳 │ ┌──┴──┐
│ ╱ ╲╱ ╲ │ │ └────┘ │ 孔 │
│ ╲ ╱╲ ╱ │ │ └──┬──┘
│ ╲ ╱ ╲ ╱ │ │ │
│ ╲╱ ╲╱ │ └──────────────┘
└──────────────┘ 错误码 2:HOLE_OUTSIDE_SHELL
错误码 6:RING_SELF_INTERSECTION
孔嵌套 内部不连通(孔相切成“细腰”)
┌──────────────┐ ┌──────────────┐
│ ┌──────────┐ │ │ ┌──────┐ │
│ │ ┌──────┐ │ │ │ │ 孔 A │ │
│ │ │ 内孔 │ │ │ │ └──┬───┘ │
│ │ └──────┘ │ │ │ │ │
│ └──────────┘ │ │ ┌──┴───┐ │
│ │ │ │ 孔 B │ │
└──────────────┘ │ └──────┘ │
错误码 3:NESTED_HOLES └──────────────┘
错误码 4:DISCONNECTED_INTERIOR
MultiPolygon 在此基础上增加三条要素间规则:
- 不同多边形的壳不能部分重叠,也不能沿边重叠(SELF_INTERSECTION,由 PolygonTopologyAnalyzer 报告);
- 一个多边形的壳不能整体落在另一个多边形的内部(NESTED_SHELLS,由 IndexedNestedPolygonTester 报告);
- 不能出现完全相同的环(DUPLICATE_RINGS)。
下表把错误码与触发条件、典型成因对应起来,便于质检报告落地:
| 错误码 | 常量名 | 含义 | 典型成因 |
|---|---|---|---|
| 0 | ERROR | 保留但未使用 | 历史遗留 |
| 1 | REPEATED_POINT | 已废弃 | SFS 认为重复点合法 |
| 2 | HOLE_OUTSIDE_SHELL | 孔部分或全部在壳外 | 编辑时拖动孔、数据合并错位 |
| 3 | NESTED_HOLES | 孔嵌套在另一个孔内部 | 挖洞操作叠加、面转环 |
| 4 | DISCONNECTED_INTERIOR | 内部被切成多块 | 孔与壳相切形成“细腰” |
| 5 | SELF_INTERSECTION | 两个环相交 | 多部件重叠、共享边未精确匹配 |
| 6 | RING_SELF_INTERSECTION | 单个环自相交 | 手工数字化打结、坐标舍入 |
| 7 | NESTED_SHELLS | 多部件的壳嵌套 | 岛被重复录入为大面的一部分 |
| 8 | DUPLICATE_RINGS | 两个环完全相同 | 重复要素、复制粘贴 |
| 9 | TOO_FEW_POINTS | 线或环点数不足 | 退化几何、抽稀过度 |
| 10 | INVALID_COORDINATE | 坐标为 NaN 或 Inf | 计算溢出、序列化异常 |
| 11 | RING_NOT_CLOSED | 环首尾不闭合 | 手工拼坐标、裁剪未闭合 |
17.2.3 空几何与“合法但荒谬”
空几何(EMPTY)一律有效,源码第一行就是 if (g.isEmpty()) return true;。这一点在批量质检时要特别小心:一个被误删成 EMPTY 的面要素不会在有效性检查中暴露,却会造成面积缺失。质检规则里应当额外加入“空值检查”和“面积阈值检查”,这两项 JTS 不负责。
同理,JTS 只回答“是否符合 SFS”,不回答“是否合理”。一个面积为 1e-9 平方米的碎片多边形完全有效,但它更可能是数字化噪声。业务质检必须在有效性之外叠加尺寸、面积、比例尺、属性完整性等规则。
17.3 IsValidOp:从布尔值到错误定位
17.3.1 两种调用姿势
最简单的用法是静态方法 IsValidOp.isValid(Geometry),只返回布尔值。如果要定位问题,需要构造实例并调用 getValidationError(),它返回 TopologyValidationError,其中包含错误码、错误消息和错误位置坐标。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.valid.IsValidOp;
import org.locationtech.jts.operation.valid.TopologyValidationError;
public class IsValidDemo {
public static void main(String[] args) throws Exception {
WKTReader reader = new WKTReader();
// 蝴蝶结形状:环自相交
Geometry bowtie = reader.read(
"POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))");
System.out.println("isValid = " + bowtie.isValid()); // false
IsValidOp op = new IsValidOp(bowtie);
TopologyValidationError err = op.getValidationError();
if (err != null) {
System.out.println("错误码 = " + err.getErrorType()); // 6
System.out.println("错误消息 = " + err.getMessage());
// Ring Self-intersection
Coordinate pt = err.getCoordinate();
System.out.println("错误位置 = " + pt); // (5, 5)
System.out.println("完整描述 = " + err);
// Ring Self-intersection at or near point (5.0, 5.0, NaN)
}
}
}
需要注意:getValidationError() 内部会重新执行一次完整校验。如果同时需要布尔值和错误详情,不要先调 isValid() 再调 getValidationError(),直接取错误对象再判空即可,避免重复计算。
17.3.2 内部检查顺序与短路
IsValidOp 按几何类型分派,遇到第一个错误立即返回(短路)。多边形的检查顺序在源码中固定为:
- 坐标有效性(对应
checkCoordinatesValid); - 环闭合性(
checkRingsClosed); - 环点数(
checkRingsPointSize); - 环间相交分析(构造 PolygonTopologyAnalyzer,调用
checkAreaIntersections); - 孔在壳内(
checkHolesInShell); - 孔不嵌套(
checkHolesNotNested,内部用 IndexedNestedHoleTester); - 内部连通(
checkInteriorConnected)。
顺序不是随意排的,而是前置条件决定的:只有先确认环之间不相交,后面才能用“取孔上一个点做点在面内判断”这种廉价方法判断孔是否在壳内;否则孔穿出壳外的情形可能被误判。源码注释里也明确写了 checkHolesInShell 依赖“孔已经过测试,确认不与壳相交”。
IsValidOp.isValid(Geometry)
│
▼
┌───────────────────┐
│ 按类型分派 │
└─────────┬─────────┘
▼
┌───────────────────┐ 否 ┌──────────────┐
│ 坐标为有限值? ├────────►│ 错误码 10 │
└─────────┬─────────┘ └──────────────┘
▼
┌───────────────────┐ 否 ┌──────────────┐
│ 环首尾闭合? ├────────►│ 错误码 11 │
└─────────┬─────────┘ └──────────────┘
▼
┌───────────────────┐ 否 ┌──────────────┐
│ 去重后点数足够? ├────────►│ 错误码 9 │
└─────────┬─────────┘ └──────────────┘
▼
┌───────────────────────────┐ 是 ┌──────────────┐
│ 环之间有非法相交? ├───►│ 错误码 5 / 6 │
│ (PolygonTopologyAnalyzer)│ │ 8 / 4 │
└─────────┬─────────────────┘ └──────────────┘
│ 否
▼
┌───────────────────┐ 是 ┌──────────────┐
│ 孔在壳外? ├────────►│ 错误码 2 │
└─────────┬─────────┘ └──────────────┘
▼
┌───────────────────┐ 是 ┌──────────────┐
│ 孔嵌套? ├────────►│ 错误码 3 │
└─────────┬─────────┘ └──────────────┘
▼
┌───────────────────┐ 是 ┌──────────────┐
│ 内部不连通? ├────────►│ 错误码 4 │
└─────────┬─────────┘ └──────────────┘
▼ 否
判定有效
17.3.3 自接触环:ESRI 兼容开关
默认情况下,两种“自接触成洞”的环被判为无效:
- inverted shell:壳自接触,接触点围出一个贴在壳上的洞;
- exverted hole:孔自接触,一个孔被分成两个仅在一点相接触的孔。
调用 setSelfTouchingRingFormingHoleValid(true) 后,这两类情况按 ESRI SDE 模型视为合法。但要注意源码注释里的限制:只要自接触导致多边形内部不连通,仍然无效,包括蝴蝶结壳、与壳另一点再接触的倒置壳、形成孤岛的 C 形孔等。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.valid.IsValidOp;
public class SelfTouchingDemo {
public static void main(String[] args) throws Exception {
WKTReader reader = new WKTReader();
// 壳自接触形成“倒置壳”(JTS 单元测试 ValidSelfTouchingRingTest 用例)
Geometry g = reader.read(
"POLYGON ((10 90, 90 90, 90 10, 10 10, 10 90, 20 70, 30 70,"
+ " 30 50, 40 50, 40 70, 30 70, 30 80, 10 90))");
IsValidOp strict = new IsValidOp(g);
System.out.println("默认判定 = " + strict.isValid()); // false
IsValidOp esri = new IsValidOp(g);
esri.setSelfTouchingRingFormingHoleValid(true);
System.out.println("ESRI 兼容 = " + esri.isValid()); // true
}
}
这个开关只影响“自接触成洞”一种情形,不会放过其他错误。与 PostGIS 的 ST_IsValid(geom, 1) 中“允许自接触环成洞”的旗标语义一致,跨库迁移数据时可用来对齐判定口径。
17.4 TopologyValidationError 全部错误码详解
17.4.1 错误对象的结构
TopologyValidationError 是一个很薄的值对象,只有两个字段:错误类型 errorType 与错误位置 pt。构造时会对坐标做一次 copy(),避免调用方后续修改坐标影响错误记录。对外 API 如下:
| 方法 | 返回 | 说明 |
|---|---|---|
getErrorType() |
int | 错误码,对应类中定义的常量 |
getMessage() |
String | 错误消息,取自 errMsg 数组,不含位置 |
getCoordinate() |
Coordinate | 错误位置;构造时允许为 null |
toString() |
String | 消息 + “at or near point …”,便于日志 |
errMsg 数组的下标就是错误码,所以 getMessage() 直接用 errMsg[errorType] 取值。如果拿到一个不认识的错误码,说明用了不匹配的 JTS 版本——错误码是公开常量,跨大版本保持稳定,但 0 与 1 两个码已经废弃。
17.4.2 逐码解析与复现
错误码 2:HOLE_OUTSIDE_SHELL(孔在壳外)。判定逻辑是先比包络:孔的包络若不能被壳的包络覆盖,直接判定孔在外;否则用 PolygonTopologyAnalyzer.isRingNested 判断孔是否被壳包含。错误位置取孔的首坐标(源码里是 holePt0),因此它可能只是“孔上离壳最近方向的一个点”,不一定是穿出点。复现:
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.valid.IsValidOp;
import org.locationtech.jts.operation.valid.TopologyValidationError;
public class HoleOutsideDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
// 壳是 0..10 的方块,孔却放在 20..25
Geometry g = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0),"
+ " (20 20, 25 20, 25 25, 20 25, 20 20))");
TopologyValidationError e = new IsValidOp(g).getValidationError();
System.out.println(e.getErrorType()); // 2
System.out.println(e); // Hole lies outside shell at or near point (20, 20, NaN)
}
}
错误码 3:NESTED_HOLES(孔嵌套)。检查由 IndexedNestedHoleTester 完成:对每个孔在孔集合中查询包络相交的候选,再用点在面内判断是否被另一个孔包含。注意这个检查的前提是“孔之间不交叉”,交叉情形会先被错误码 5 拦截。
错误码 4:DISCONNECTED_INTERIOR(内部不连通)。这是最容易被忽视的错误码。两种典型形态:
- 孔与壳在两点接触,接触点把多边形内部夹成一个“细腰”,几何上不连通;
- 多个孔与壳相切,形成一条把内部一分为二的“洞链”。
前者的位置由 PolygonIntersectionAnalyzer 记录的双接触点给出;后者由 PolygonRing.findHoleCycleLocation 在孔-壳接触图上找环得到。源码中 isInteriorDisconnected() 先看已有双接触点,再按 isInvertedRingValid 决定是否做自接触检查,最后做洞环检查。
错误码 5:SELF_INTERSECTION(环间相交)。用于多边形内部两个不同环(壳-孔、孔-孔)或 MultiPolygon 不同部件的环之间穿越、共线重叠的情形。错误码 6:RING_SELF_INTERSECTION(环自相交) 只针对单个环自身。两者由同一次 noding 分析产生,靠参与相交的环身份区分。
错误码 7:NESTED_SHELLS(壳嵌套)。MultiPolygon 中一个部件整体落在另一个部件内部。用 IndexedNestedPolygonTester 检测,位置取被嵌套多边形的内部点或首点。
错误码 8:DUPLICATE_RINGS(重复环)。两个环的顶点序列完全相同,noding 后表现为两组完全重合的线段。
错误码 9:TOO_FEW_POINTS(点数不足)。线去重后少于 2 点,或环去重后少于 4 点。注意“去重后”三个字:LINESTRING (0 0, 0 0, 0 0) 看起来有 3 个点,去重后只剩 1 个,判无效。
错误码 10:INVALID_COORDINATE(非法坐标)。x 或 y 为 NaN / Infinite。JTS 1.21 起 WKTReader 支持读入 Inf 与 -Inf(大小写不敏感),这意味着这类数据现在能进入内存,质检层必须显式拦截,而不是指望解析失败。
错误码 11:RING_NOT_CLOSED(环未闭合)。首个坐标与最后一个坐标不相等。注意 WKT 里写 POLYGON ((...)) 时,很多工具会自动补闭合点,但手工拼数组不会。用 GeometryFactory.createLinearRing(Coordinate[]) 时也不强制闭合,必须自己保证首尾一致。
17.4.3 错误定位的精度边界
TopologyValidationError 的坐标是“at or near”,不是精确的故障点。原因在于 JTS 在发现错误时只保留了一个代表性坐标:孔在壳外取孔首点,环自相交取交点,内部不连通取接触点。工程上若要精确定位,推荐两种做法:
- 把错误坐标做成一个 POINT 输出到质检图层,配合原始要素渲染,由人工判读;
- 对报错要素做“二分裁剪”:按包络四等分后分别校验,逐步缩小到具体子区域,再定位到坐标段。
第二种做法在超大要素(数十万顶点)上很实用,因为 IsValidOp 本身不提供错误所在环的索引。
17.5 自相交与环拓扑分析:PolygonTopologyAnalyzer
17.5.1 为什么不能只做两两求交
判断多边形有效性时,如果对每对环都调用一次 intersection(),复杂度会随环数平方增长,而且叠加运算本身也有鲁棒性风险。JTS 1.18.2 之后重写了这部分逻辑,改为把所有环的线段当成一个整体做一次 noding,在 noding 过程中用 SegmentIntersector 同步判定拓扑关系。核心类就是 PolygonTopologyAnalyzer。
它位于 org.locationtech.jts.operation.valid 包,是包私有类(源码声明为 class PolygonTopologyAnalyzer,没有 public),普通调用方拿不到。理解它的意义在于:读源码、调性能、解释错误码。
17.5.2 分析流程
输入 Polygon / MultiPolygon
│
▼
createSegmentStrings()
为每个环创建 SegmentString,并附带 PolygonRing 元信息
(壳 or 孔、所属多边形编号、是否倒置)
│
▼
PolygonIntersectionAnalyzer(实现 SegmentIntersector)
│
├── 记录穿越(crossing)→ 错误码 5 / 6
├── 记录共线重叠(collinear overlap)
│ ├── 同向且完全相等 → 错误码 8
│ └── 部分重叠、非同向 → 错误码 5
├── 记录自接触节点(self-touch node)
│ ├── 允许倒置环 → 记录,供连通性检查
│ └── 不允许 → 错误码 6
└── 记录双接触(double touch)→ 错误码 4
│
▼
MCIndexNoder 完成 noding
│
▼
isInteriorDisconnected()
├── 双接触点已存在 → 不连通
├── 自接触检查(仅倒置环合法时)
└── 洞环检查(PolygonRing 接触图找环)
17.5.3 PolygonIntersectionAnalyzer 的判定规则
noding 过程中,每对相交线段都会回调 processIntersections,分析器据两侧的 PolygonRing 身份与相交类型给出结论。规则可以整理为下表:
| 相交形态 | 环关系 | 判定 |
|---|---|---|
| 线段在内部穿越 | 任意两个环 | 错误码 5(多部件时也是 5) |
| 交点恰为端点且两侧都是顶点 | 同一环相邻段 | 合法(正常折点) |
| 线段重合且方向相反、长度相等 | 壳与孔 / 两个孔 | 合法匹配边 |
| 线段重合且方向相同、长度相等 | 任意 | 错误码 8(重复环)或 5 |
| 线段共线但只有部分重叠 | 任意 | 错误码 5 |
| 环与自身在非相邻段相交 | 同一环 | 错误码 6 |
| 环与自身在非相邻段仅端点接触 | 同一环 | 默认错误码 6(自接触) |
“方向”在这里很关键。JTS 不要求环的方向符合右手规则,因此判断“合法匹配边”时必须比较两条共线线段在各自环中的走向:真实覆盖率里相邻多边形的公共边走向相反;若同向,说明两块面朝同一侧铺开,必然是重叠或重复。
17.5.4 内部连通性与“洞环”
一个多边形内部不连通,本质是“孔与壳的接触图里出现了把内部隔断的环”。把每个环看成一个节点,环与环的每个接触点看成一条边,若在这个图上能找到环(cycle),那么多边形内部就被切开了。源码中 PolygonRing.findHoleCycleLocation 负责在孔集合里找这种环,并返回一个接触点作为错误位置。
这也解释了一个常见困惑:两个孔只在一点相切,为什么多边形无效? 因为相切点把内部夹断成两块,面积为正的两块互不连通,违反了“内部连通”规则。把两个孔稍微拉开,或合并成一个大孔,即可修复。
17.5.5 源码视角:1.18.2 的重构
1.18.2 的版本历史里有一组相关条目:“Improve design and performance of IsValidOp”“Fix IsValidOp for repeated node points”(1.19)。重构前的实现基于 GeometryGraph 与 EdgeEnd 的拓扑图,内存与常数开销都大;重构后用 noding + SegmentIntersector 的流式分析,顺带把“重复节点导致误报”的老问题修掉。对使用者的直接影响是:
- IsValidOp 更快,尤其是多部件面;
- 重复点不再导致误报,但会在 noding 前被“跳过”,因此错误位置可能落在重复点附近;
- 内部类名变化,不要依赖
operation.valid里的包私有实现类做扩展。
17.6 IsSimpleOp 与 RepeatedPointTester
17.6.1 简单性的定义
简单性(simplicity) 与有效性是两套独立的概念:
- Point 永远简单;
- MultiPoint 简单当且仅当没有重复点;
- LineString 简单当且仅当除端点外不自交;闭合线串(环)只在端点重合时仍算简单;
- MultiLineString 简单当且仅当每个元素简单,且元素之间的交点都必须是两个元素的边界点(边界规则可配置);
- Polygonal 没有严格意义的简单性定义,IsSimpleOp 退化为“检查所有环是否简单”,因此 不能 用它替代
isValid(); - GeometryCollection 简单当且仅当所有子要素简单。
import java.util.List;
import org.locationtech.jts.algorithm.BoundaryNodeRule;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.valid.IsSimpleOp;
public class IsSimpleDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
// 一条自己穿过自己的线
Geometry line = r.read("LINESTRING (0 0, 10 10, 10 0, 0 10)");
System.out.println(IsSimpleOp.isSimple(line)); // false
Coordinate bad = IsSimpleOp.getNonSimpleLocation(line);
System.out.println(bad); // (5, 5, NaN)
// 找出全部非简单点
IsSimpleOp op = new IsSimpleOp(line);
op.setFindAllLocations(true);
List<Coordinate> pts = op.getNonSimpleLocations();
System.out.println("非简单点数量 = " + pts.size());
// 线性网络:使用端点边界规则,允许端点相接
Geometry net = r.read(
"MULTILINESTRING ((0 0, 5 0), (5 0, 10 0))");
System.out.println("Mod-2 规则 = " + IsSimpleOp.isSimple(net)); // true
IsSimpleOp endpointRule =
new IsSimpleOp(net, BoundaryNodeRule.ENDPOINT_BOUNDARY_RULE);
System.out.println("端点规则 = " + endpointRule.isSimple()); // true
}
}
17.6.2 边界节点规则
简单性的边界判定由 BoundaryNodeRule 决定,JTS 内置四种:
| 常量 | 规则 | 适用场景 |
|---|---|---|
MOD2_BOUNDARY_RULE |
端点出现奇数次算边界(OGC SFS 默认) | 通用简单性判断 |
ENDPOINT_BOUNDARY_RULE |
所有端点都算边界 | 线网连通性、管网拓扑 |
MULTIVALENT_ENDPOINT_BOUNDARY_RULE |
端点出现两次及以上算边界 | 特殊网络模型 |
MONOVALENT_ENDPOINT_BOUNDARY_RULE |
端点只出现一次才算边界 | 悬挂线检测 |
默认构造 new IsSimpleOp(geom) 使用 Mod-2 规则。注意 Mod-2 下闭合线串没有边界点,因此两条闭合线在端点处相切会被判为不简单;如果业务上认为“环端点相切可以接受”,改用端点规则。
17.6.3 实现要点
IsSimpleOp 的实现路径是:提取 SegmentString(extractSegmentStrings)→ 用 MCIndexNoder 做线段相交检测 → NonSimpleIntersectionFinder 作为 SegmentIntersector 判定每个交点是否“非简单”。判定逻辑有几层:
- 跳过同一线段与自身;
- 内部交点 → 非简单;
- 两条线段完全相等(交点数为 2)→ 非简单;
- 同一 SegmentString 的相邻线段交点 → 允许;
- 交点不是两个 SegmentString 的端点 → 非简单;
- 两个端点都在各自的闭合环上,且分属不同线 → 按
isClosedEndpointsInInterior判定。
源码还专门处理了“重复端点”问题:trimRepeatedPoints 会去掉首尾连续的重复点,否则LINESTRING (0 0, 0 0, 5 5)这类数据会误报。这是 1.19 修复的问题(版本历史 #851)。
17.6.4 RepeatedPointTester:重复点检测
RepeatedPointTester 只做一件事:找出连续重复坐标。在 OGC SFS 中,重复点不再导致几何无效(错误码 1 已废弃),但重复点仍值得质检:
- 会让 WKB/WKT 体积膨胀;
- 会让某些算法的“边”为零长度,增加 noding 负担;
- 是数字化设备的常见产物,可作为数据质量指标。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.valid.RepeatedPointTester;
public class RepeatedPointDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry g = r.read("LINESTRING (0 0, 5 5, 5 5, 10 10)");
RepeatedPointTester t = new RepeatedPointTester();
System.out.println("有重复点 = " + t.hasRepeatedPoint(g)); // true
Coordinate c = t.getCoordinate();
System.out.println("重复坐标 = " + c); // (5, 5, NaN)
// 也可以直接检查坐标数组
boolean has = t.hasRepeatedPoint(g.getCoordinates());
System.out.println(has); // true
}
}
注意:RepeatedPointTester 用 Coordinate.equals 判断相等,而 Coordinate 的 equals 等价于 equals2D,即只看 x、y。Z 值不同但平面位置相同的两个连续点,仍会被判为重复点。
17.7 GeometryFixer:无效几何的修复策略
17.7.1 设计目标与语义规则
GeometryFixer(1.18.2 引入,位于 org.locationtech.jts.geom.util)的定位是“把无效几何变成有效几何,并尽量保留形状与位置”。它不追求“最小编辑”,而是追求确定性:同样的输入永远得到同样的输出,且输出一定是合法几何。
源码 Javadoc 列出的语义规则可以视为修复契约:
- x 或 y 为非有限值的顶点被删除(对应
Coordinate.isValid()); - 连续重复点被压缩为一个点;
- 空的原子几何原样返回;
- 集合中的空元素被移除;
- Point:保留合法坐标,否则变成 EMPTY;
- LineString:修正坐标序列;
- LinearRing:修正坐标;能保留环就保留,否则降级为 LineString;
- Polygon:转换为有效 Polygon 或 MultiPolygon,尽量保留范围与顶点;与壳相交的孔从壳中减去;壳外的孔转换为独立多边形;
- MultiPolygon:逐个修复后再做 union,消除部件间重叠;
- GeometryCollection:逐元素修复;
- 塌缩(collapse)处理由
keepCollapsed控制:默认 false 转为空并从集合移除;true 转为低维几何。
17.7.2 两个关键参数
| 参数 | 默认值 | 含义 | 典型用法 |
|---|---|---|---|
keepCollapsed |
false | 塌缩的线/面是否保留为低维几何 | 需要保留“退化成线的面”时设为 true |
keepMulti |
true | 修复后只剩一个元素的 MULTI 是否仍返回 MULTI | 需要保持图层几何类型一致时保留默认 |
为什么默认不保留塌缩? 因为一个塌缩成线的多边形在业务上通常意味着数字化错误,转成 EMPTY 后被移除,能让问题在统计上暴露(要素数变少),而不是悄悄变成一条谁也不会注意的线。反之,如果下游系统的几何类型是强约束的(比如入库表要求 geometry(MultiPolygon, 4326)),保留 MULTI 更安全。
17.7.3 修复是怎么实现的
GeometryFixer 的实现可以拆成三个动作:
- 清理坐标:
fixCoordinates调用 CoordinateArrays.removeRepeatedOrInvalidPoints,再copyDeep复制,保证返回新数组; - 修复环:
fixRing把环包装成Polygon,调用 BufferOp.bufferByZero(poly, true)。零距离缓冲区会执行一次“输入简化 + 环规范化”,能把自相交环整理成合法的环序列;这是 JTS 里最稳健的“环修复”手段之一; - 处理孔与外环关系:
fixHoles修复每个孔,然后classifyHoles用 PreparedGeometry.intersects 判断孔与壳是否相交——相交的孔作为“要减去的洞”,不相交的孔作为“独立壳”;最后用 OverlayNGRobust.difference 从壳中减洞,再用 OverlayNGRobust.union 合并所有壳。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.util.GeometryFixer;
import org.locationtech.jts.io.WKTReader;
public class FixerDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
// 孔在壳外:修复后会得到一个 MultiPolygon(壳 + 由孔变成的面)
Geometry bad1 = r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0),"
+ " (20 20, 25 20, 25 25, 20 25, 20 20))");
Geometry fixed1 = GeometryFixer.fix(bad1);
System.out.println(fixed1.getGeometryType()); // MultiPolygon
System.out.println(fixed1.isValid()); // true
System.out.println(fixed1.getNumGeometries()); // 2
// 蝴蝶结:修复为合法面(可能被拆成多个部件)
Geometry bad2 = r.read("POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))");
Geometry fixed2 = GeometryFixer.fix(bad2);
System.out.println(fixed2.isValid()); // true
System.out.println(fixed2.getArea()); // 50.0
// 塌缩控制:一个退化成线的面
Geometry degenerate =
r.read("POLYGON ((0 0, 1 1, 2 2, 0 0))");
System.out.println(GeometryFixer.fix(degenerate).getGeometryType());
// Polygon:默认 keepCollapsed=false,塌缩部件被清空为 EMPTY 面
GeometryFixer fixer = new GeometryFixer(degenerate);
fixer.setKeepCollapsed(true); // 保留为低维几何
fixer.setKeepMulti(false); // 单元素 MULTI 降为单几何
Geometry kept = fixer.getResult();
System.out.println(kept.isValid()); // true
}
}
上例中“退化成线的面”的默认输出在不同版本与输入细节下可能是空面或线,工程上不要依赖具体类型,应当始终按 isValid() 与维度判断,而不是断言几何类型。
17.7.4 修复的代价:必须复检与对比
GeometryFixer 的输入即使有效也会被处理并返回新对象(源码明确写着“Input geometries are always processed”)。因此:
- 不要把它当成免费的规范化步骤放在每次操作前,开销相当于一次 buffer(0) 加若干次 overlay;
- 修复前后必须对比:面积变化率、顶点数变化率、包络变化、部件数变化,超过阈值要进人工复核队列;
- 孔在壳外时会被转成独立多边形,这意味着面积可能“变多”,地籍业务中必须拦截;
- 修复不做坐标吸附(snapping),1 厘米的错位依旧错位,要靠 CoverageCleaner 或专门的 snapping 处理;
- 修复后要重新跑 IsValidOp,虽然设计上保证有效,但上游若含非有限坐标或极端数值,仍建议复检。
17.8 Coverage 质量模型:从要素有效性到覆盖率有效性
17.8.1 什么是“有效的覆盖率”
Polygonal coverage(多边形覆盖率) 是一组满足两条规则的多边形:
- 所有多边形的内部互不相交(可以沿边相邻,不能重叠,也不能完全相同);
- 若两个多边形的边界相交,则相交处的顶点与线段必须精确匹配——公共边两侧的顶点集合完全一致,不能一边有节点、另一边没有。
注意规则里没有“容差”二字。JTS 的覆盖率有效性是零容差判定:两个多边形共享边相差 1e-12 也会被判为无效。这是它与许多商业软件“带容差拓扑检查”的最大区别。
合法覆盖率 非法覆盖率(缝隙)
┌───────┬───────┐ ┌───────┐┌───────┐
│ A │ B │ │ A ││ B │
│ │ │ │ ││ │
├───────┼───────┤ └───────┘└───────┘
│ C │ D │ 中间出现细缝
└───────┴───────┘
非法覆盖率(重叠) 非法覆盖率(节点不匹配)
┌───────┐ ┌───────┬───┬───┐
│ A │ │ A │ B │ │
│ ┌────┴───┐ │ ├───┘ │
│ │ B │ │ │ │
└──┴────────┘ └───────┴───────┘
A、B 内部相交 B 边上有节点,A 边没有
覆盖率有效允许空洞(没有面覆盖的区域),也允许覆盖率由多个互不相连的区域组成。是否把空洞视为“缝隙错误”取决于业务与宽度阈值。
17.8.2 CoverageValidator:整组校验
CoverageValidator(1.20 新增,位于 org.locationtech.jts.coverage)是整组覆盖率校验的入口。核心 API:
| 方法 | 说明 |
|---|---|
isValid(Geometry[] coverage) |
静态方法,返回整组是否有效 |
isValid(Geometry[] coverage, double gapWidth) |
同时检查窄缝 |
validate(Geometry[] coverage) |
返回与输入等长的数组,无效处为线几何,有效处为 null |
validate(Geometry[] coverage, double gapWidth) |
带窄缝检测 |
hasInvalidResult(Geometry[] result) |
判断结果数组中是否有非 null 项 |
setGapWidth(double) |
实例方法,设置窄缝宽度阈值 |
实现思路很直接:先把所有多边形插入一个 STRtree,然后逐个多边形查询包络相交(并按 gapWidth 外扩)的邻居,交给 CoveragePolygonValidator 做局部校验,最后汇总成数组。
import java.util.List;
import org.locationtech.jts.coverage.CoverageValidator;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.io.WKTWriter;
public class CoverageValidateDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry[] coverage = new Geometry[] {
// 两个正方形,共享边在 x = 10
r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"),
r.read("POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))")
};
System.out.println(CoverageValidator.isValid(coverage)); // true
// 人为制造 0.1 的缝隙
Geometry[] gapped = new Geometry[] {
r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"),
r.read("POLYGON ((10.1 0, 20 0, 20 10, 10.1 10, 10.1 0))")
};
// 注意:不带 gapWidth 时,互不相交的缝隙不算“无效覆盖率”
System.out.println(CoverageValidator.isValid(gapped)); // true
System.out.println(CoverageValidator.isValid(gapped, 0.2)); // false(缝隙宽 0.1 < 0.2)
CoverageValidator v = new CoverageValidator(gapped);
v.setGapWidth(0.15);
Geometry[] errs = v.validate();
WKTWriter w = new WKTWriter();
for (int i = 0; i < errs.length; i++) {
if (errs[i] != null) {
System.out.println("要素 " + i + " 无效段: " + w.write(errs[i]));
}
}
// 输出类似:要素 0 无效段: LINESTRING (10 0, 10 10)
}
}
17.8.3 CoveragePolygonValidator:单面视角与六类错误
CoveragePolygonValidator 校验“一个目标多边形 + 一组相邻多边形”是否构成合法覆盖率。它的结果是一条线几何:目标多边形上导致无效的边界段;完全合法时返回空 LineString。
源码 Javadoc 明确列出它能检测的六类错误:
| 序号 | 错误类型 | 判定方式 |
|---|---|---|
| 1 | 目标多边形与另一个多边形重复 | 规范化线段完全相同且方向相同 |
| 2 | 边界段与相邻段相等且同向 | 意味着两个面朝同一侧铺开,内部重叠 |
| 3 | 边界段共线重叠但长度不等 | 部分重叠 |
| 4 | 边界段在相邻段的非顶点处接触 | 节点不匹配(T 型接触) |
| 5 | 边界段穿入相邻多边形 | 穿越 |
| 6 | 边界段完全落在相邻多边形内部 | 包含 |
实现上分两步:先“标记匹配段”,再“标记无效段”。
- 标记匹配段:把所有目标环和相邻环的线段做归一化(按坐标排序,方向可翻转),放入 HashMap。命中相同 key 时,比较两侧在环中的方向;方向相反是合法匹配,方向相同则是错误 1/2。匹配成功的线段标记为“已知”,后续不再检查——这让合规数据的校验非常快,因为大部分内部边都被提前标记。
- 标记无效段:对剩余未匹配段,用 MCIndexSegmentSetMutualIntersector(带
gapWidth作为距离容差)与相邻环做相交检测,由 InvalidSegmentDetector 标记错误 3/4/5;再对每个未匹配段检查端点是否落在相邻多边形内部,标记错误 6。为提升性能,源码按RING_SECTION_STRIDE = 1000把环切成 1000 段一段的“节”,按节的包络先筛相邻多边形,避免逐点做点在面内。
17.8.4 关于“匹配容差”的重要澄清
很多同学会问:能不能给 CoverageValidator 设置一个 0.001 米的匹配容差,让“差不多对齐”的边也算匹配?
不能。 源码里没有任何匹配容差参数,唯一与距离有关的参数是 gapWidth,而它只用于额外识别窄缝,不用于放宽边界匹配:
- 边界匹配是精确的坐标比较(线段端点相等);
gapWidth的作用是把查询包络外扩,并把“距离小于阈值的窄缝”也标记为无效;- 若数据存在系统性错位(例如两次数字化偏移 2 厘米),正确做法是先用 CoverageCleaner 的 snapping 把线work 吸附到一起,再做校验。
这一点在工程上极其重要:把期望寄托在“容差参数”上,会导致质检报告与真实拓扑状态不一致。
17.8.5 性能与分区
CoverageValidator 内部已有 STRtree,但整组校验仍然是一次性把所有面放进内存。面对百万级图斑,建议按行政区/图幅分区,每区单独校验,并注意:
- 分区边界处的共享边只属于一侧,校验时不会报错,但跨区错位会被漏检;因此要么给分区加 buffer 并把边界带纳入两侧,要么按“图幅 + 邻接图幅”成对校验;
gapWidth越大,外扩查询越多,性能下降;建议按数据精度设定(例如 1:500 数据取 0.05 米,1:2000 取 0.2 米);- 无效段结果数组与输入等长,100 万要素对应 100 万引用,内存可控(null 占一个引用),但若序列化成 WKT 输出要分批。
17.9 CoverageGapFinder:窄缝检测
17.9.1 与 Validator 内建窄缝检测的区别
CoverageValidator 的 gapWidth 是一种启发式检测:它把“未匹配段 + 近距离相邻段”标记为无效。它快,但有两个已知问题(源码 Javadoc 自己写明):
gapWidth设得较大时,某些窄缝反而检测不到;- 可能误报:把实际更宽的缝隙的一部分线work 标成缝隙。
CoverageGapFinder 是更准确的做法:先用 CoverageUnion 把整组覆盖合并成一张面,再检查合并结果里的洞,对每个洞计算最大内切圆半径,半径小于 gapWidth / 2 的洞判定为窄缝。
17.9.2 原理与限制
coverage 数组
│
▼
CoverageUnion.union() → 一张合并面(可能带洞)
│
▼
遍历每个洞(内环)
│
▼
MaximumInscribedCircle.isRadiusWithin(洞面, gapWidth / 2)
│
├── true → 该洞是“窄缝”,输出为面
└── false → 正常空洞,忽略
限制(源码明确说明):
- 输入覆盖率应当先通过 CoverageValidator;无效覆盖率可能导致漏报甚至异常;
- 把覆盖率分成两个互不相连区域的“缝”不会被识别(因为 Union 后它不是一个洞);
- gore(三角楔形缝)不被识别为 gap;
- 需要 JTS 1.20 及以上。
17.9.3 用法示例
import org.locationtech.jts.coverage.CoverageGapFinder;
import org.locationtech.jts.coverage.CoverageValidator;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
public class GapFinderDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
// 四个多边形拼成外框,中间留一个 2×2 的封闭空洞(合法覆盖率)
Geometry[] coverage = new Geometry[] {
r.read("POLYGON ((0 0, 10 0, 10 4, 6 4, 4 4, 0 4, 0 0))"), // 下
r.read("POLYGON ((0 6, 4 6, 6 6, 10 6, 10 10, 0 10, 0 6))"), // 上
r.read("POLYGON ((0 4, 4 4, 4 6, 0 6, 0 4))"), // 左
r.read("POLYGON ((6 4, 10 4, 10 6, 6 6, 6 4))") // 右
};
System.out.println(CoverageValidator.isValid(coverage)); // true
// 空洞最大内切圆半径 = 1,宽度 = 2 < 2.5,判定为窄缝
Geometry gaps = CoverageGapFinder.findGaps(coverage, 2.5);
System.out.println(gaps.getGeometryType()); // Polygon(或 MultiPolygon)
System.out.println(gaps.getArea()); // 4.0(2×2 的空洞)
// 注意:不封闭、开口于覆盖边界的缝隙不会被识别(见 17.9.2)
}
}
得到的缝面可以直接作为质检图层输出,用于人工修复,也可以交给 CoverageCleaner 自动合并。
17.10 CoverageCleaner:缝隙与重叠的自动清理
17.10.1 能力与处理流程
CoverageCleaner 是 1.21 新增的类(1.20 及以前没有),目标是:输入一组各自有效但彼此存在重叠、缝隙、节点不匹配的多边形,输出一组一对一的合法覆盖率多边形。注意“一对一”:输出数组与输入等长,但个别位置可能是 null。
输入 coverage(各自有效,彼此可能重叠/有缝)
│
▼
① 线work 吸附(Snapping)
默认按输入范围直径的一小部分自动确定距离,
消除小错位、保证公共边节点一致
│
▼
② 重新多边形化 + 分类
判断每个结果面是“单个父要素”“多个父要素重叠”“无父要素缝隙”
│
├── 重叠 → 按合并策略并入某个父要素
├── 缝隙(宽度 < maxGapWidth)→ 并入共享边最长的相邻面
└── 单一面 → 直接归属
│
▼
③ 输出与输入一一对应的合法覆盖率
17.10.2 四种重叠合并策略
| 常量 | 值 | 策略 | 适用场景 |
|---|---|---|---|
MERGE_LONGEST_BORDER |
0 | 并入公共边界最长的多边形(默认) | 通用,语义最自然 |
MERGE_MAX_AREA |
1 | 并入面积最大的多边形 | 大面优先,减少碎面 |
MERGE_MIN_AREA |
2 | 并入面积最小的多边形 | 小面优先,保持小要素完整 |
MERGE_MIN_INDEX |
3 | 并入输入数组中下标最小的多边形 | 有优先级顺序的数据(先入为主) |
“最长公共边界”策略对地籍很友好:两块地重叠一条边时,重叠部分通常应归给与它共享边界更长的那块,而不是简单按面积。
17.10.3 参数与调用示例
静态方法有三个常用重载:
clean(Geometry[] coverage, double snappingDistance, int overlapMergeStrategy, double maxGapWidth):全参数;clean(Geometry[] coverage, double snappingDistance, double maxGapWidth):默认最长边界策略;cleanOverlapGap(Geometry[] coverage, int overlapMergeStrategy, double maxGapWidth):使用默认吸附距离;cleanGapWidth(Geometry[] coverage, double maxGapWidth):默认吸附 + 默认策略。
snappingDistance 传 -1 表示自动计算(源码内部把初始值置为“待计算”,按输入范围推算);传 0 关闭吸附。关闭吸附可能导致共线数据无法正确 noding,源码注释专门提醒了这一点。
import org.locationtech.jts.coverage.CoverageCleaner;
import org.locationtech.jts.coverage.CoverageValidator;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
public class CoverageCleanDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry[] dirty = new Geometry[] {
r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"),
r.read("POLYGON ((9.98 0, 20 0, 20 10, 9.98 10, 9.98 0))") // 重叠 0.02
};
System.out.println(CoverageValidator.isValid(dirty)); // false
// 吸附距离 0.001,最长边界合并,缝隙宽度阈值 0.05
Geometry[] clean = CoverageCleaner.clean(
dirty, 0.001, CoverageCleaner.MERGE_LONGEST_BORDER, 0.05);
System.out.println(clean.length); // 2(与输入等长)
System.out.println(CoverageValidator.isValid(clean)); // true
System.out.println(clean[0].getArea()); // 约 100
System.out.println(clean[1].getArea()); // 约 100
// 需要知道哪些重叠被合并、哪些缝隙被填了吗?
CoverageCleaner cc = new CoverageCleaner(dirty);
cc.setSnappingDistance(0.001);
cc.setGapMaximumWidth(0.05);
cc.clean();
System.out.println("合并的重叠面数 = " + cc.getOverlaps().size());
System.out.println("填平的缝隙数 = " + cc.getMergedGaps().size());
}
}
17.10.4 结果语义与已知问题
- 输出数组与输入一一对应;某个输入若为非面或空、被吸附塌缩、或被另一个输入完全覆盖,对应输出为 null。入库前要处理 null(删除或标记)。
- 输出保证通过
CoverageValidator.isValid,但不保证与输入面积完全一致;缝隙被填、重叠被分,面积必然变化。业务上要设置面积变化阈值(例如 ±2%),超出转人工。 - 已知问题:又长又窄、且与多个多边形相邻的缝隙在合并后可能形成“尖刺”(归属给单个面后拉出一条细长凸起)。量产地籍数据若遇尖刺,可在清理后跑一遍 CoverageSimplifier 或在缝隙中部打断。
- 源码列出的未来增强包括:按面积设缝隙阈值、把长缝分段后再归属、支持每个多边形的优先级值。这意味着当前版本的合并策略是“全局统一”的,无法表达“这块地优先”。
17.11 CoverageSimplifier 与 CoverageUnion
17.11.1 拓扑保持化简:CoverageSimplifier
普通 DouglasPeuckerSimplifier 逐要素化简,会把共享边两侧简成不同折线,直接破坏覆盖率。CoverageSimplifier(1.20 增强)把整组覆盖作为输入,共享边只化简一次,因此化简结果仍然是合法覆盖率。
它使用类似 Visvalingam-Whyatt 的面积法:每次移除面积增量最小的顶点,直到面积容差用尽;对外暴露的 tolerance 是这个面积容差的平方根,量纲与坐标一致,可以粗略理解为“线最多偏移多少距离”。
关键特性(源码 Javadoc):
- 输出要素数与输入相同;输入合法则输出合法;
- 节点(三个及以上多边形共享的顶点、两个及以上共享的边界顶点)保持不动;
- 多边形保持线邻接关系,边不会被删除;
- 环最少保留 4 个顶点;
- 小于面积容差的环(洞或孤岛)会被移除,但每个输入几何至少保留面积最大的一个部件;
- 支持分别设置内部边与外部边的容差,从而只化简“补丁内部”而不动外边界。
import org.locationtech.jts.coverage.CoverageSimplifier;
import org.locationtech.jts.coverage.CoverageValidator;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
public class CoverageSimplifyDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry[] cov = new Geometry[] {
r.read("POLYGON ((0 0, 5 0, 10 0, 10 5, 10 10, 5 10, 0 10, 0 5, 0 0))"),
r.read("POLYGON ((10 0, 15 0, 20 0, 20 10, 15 10, 10 10, 10 5, 10 0))")
};
// 统一容差
Geometry[] s1 = CoverageSimplifier.simplify(cov, 1.0);
System.out.println(CoverageValidator.isValid(s1)); // true
// 只化简内部边(共享边),外边界不动
Geometry[] s2 = CoverageSimplifier.simplifyInner(cov, 0.5);
System.out.println(s2[0].getNumPoints() <= cov[0].getNumPoints()); // true
// 实例方式:控制小环移除与平滑权重
CoverageSimplifier cs = new CoverageSimplifier(cov);
cs.setRemovableRingSizeFactor(1.0);
cs.setSmoothWeight(0.5); // 取值 0..1,越大越平滑
Geometry[] s3 = cs.simplify(0.5, 2.0); // 内部 0.5、外部 2.0
System.out.println(s3.length); // 2
}
}
注意:化简的容差单位是坐标单位。经纬度数据上传入 0.5 意味着 0.5 度,约 55 公里,会直接把小面抹平。这一点与第 19 章的坐标系陷阱直接相关。
17.11.2 CoverageUnion:覆盖率专用合并
覆盖率合并(把相邻面融成一张大面)有两种写法:
CoverageUnion.union(Geometry[]):org.locationtech.jts.coverage包下的便捷入口;org.locationtech.jts.operation.overlayng.CoverageUnion.union(GeometryCollection):底层实现。
import org.locationtech.jts.coverage.CoverageUnion;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
public class CoverageUnionDemo {
public static void main(String[] args) throws Exception {
WKTReader r = new WKTReader();
Geometry[] cov = new Geometry[] {
r.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"),
r.read("POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))")
};
Geometry all = CoverageUnion.union(cov);
System.out.println(all.getGeometryType()); // Polygon
System.out.println(all.getArea()); // 200.0
}
}
它比逐对 union() 快得多,因为利用了“公共边精确匹配”这一前提,直接把覆盖率边界链拼起来,不需要完整叠加。代价是不做任何有效性检查:输入不是合法覆盖率时,有时会抛 TopologyException,有时(更糟)会静默产出错误结果。所以生产流程一定是“先校验,后 Union”。
17.12 工程案例:地籍/行政区/国土调查质检流水线
17.12.1 整体流程
原始数据(WKT/GeoJSON/SHP 转换后)
│
▼
① 解析与坐标检查
- 解析失败 / 空几何 / 非有限坐标 → 拒收
- SRID 一致性检查(见第 19 章)
│
▼
② 单要素有效性检查(IsValidOp)
- 记录错误码与位置 → 质检报告
│
├── 可修复(自相交、重复点、孔越界)→ GeometryFixer
│ └── 修复后复检 + 面积变化阈值
└── 不可接受 → 退回作业员
│
▼
③ 上传/解析到要素池,按行政区(或图幅)分组
│
▼
④ 覆盖率校验(CoverageValidator)
- 要素间重叠 / 节点不匹配 / 窄缝
- 输出无效段图层,定位到责任要素
│
▼
⑤ 自动清理(CoverageCleaner)
- 吸附距离按成图比例尺设定
- 缝隙阈值按精度指标设定
- 输出 null 与面积变化超限的要素进人工队列
│
▼
⑥ 复核与入库
- 复检覆盖率 + 单要素有效性
- 汇总行政单元面积,与台账核对
- 通过后写 PostGIS / 文件,保留质检报告
17.12.2 质检规则表
| 检查项 | 规则/阈值(示例) | 工具 | 不合格处置 |
|---|---|---|---|
| 坐标合法 | 无 NaN/Inf | IsValidOp.isValid(Coordinate) | 拒收 |
| 环闭合 | 首尾坐标相等 | IsValidOp | 自动修复 |
| 自相交 | 无 | IsValidOp(错误码 5/6) | GeometryFixer + 人工复核 |
| 孔越界/嵌套 | 无 | IsValidOp(2/3) | GeometryFixer |
| 重复点 | 数量占比 < 1% | RepeatedPointTester | 批量去重 |
| 最小面积 | 地籍 ≥ 1 平方米(按比例尺) | Geometry.getArea | 退回 |
| 要素间重叠 | 面积 > 0.001 平方米 | CoverageValidator | CoverageCleaner |
| 公共边匹配 | 零容差 | CoverageValidator | CoverageCleaner(吸附) |
| 窄缝 | 宽度 ≤ 0.05 米(1:500) | CoverageGapFinder / Validator | CoverageCleaner 填平 |
| 行政单元面积 | 与台账偏差 ≤ 0.1% | 汇总后比对 | 人工核查 |
17.12.3 批量实现骨架
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.coverage.CoverageCleaner;
import org.locationtech.jts.coverage.CoverageGapFinder;
import org.locationtech.jts.coverage.CoverageValidator;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.util.GeometryFixer;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.valid.IsValidOp;
import org.locationtech.jts.operation.valid.TopologyValidationError;
public class QcPipeline {
static class Report {
int total;
int invalidInput;
int fixed;
int stillInvalid;
int nullAfterClean;
boolean coverageValid;
double gapArea;
}
public static Report check(String[] wkts) throws Exception {
WKTReader reader = new WKTReader();
List<Geometry> fixedList = new ArrayList<Geometry>();
Report rep = new Report();
for (String wkt : wkts) {
rep.total++;
Geometry g = reader.read(wkt);
IsValidOp op = new IsValidOp(g);
TopologyValidationError err = op.getValidationError();
if (err != null) {
rep.invalidInput++;
Geometry fixed = GeometryFixer.fix(g);
if (fixed.isValid()) {
rep.fixed++;
fixedList.add(fixed);
} else {
rep.stillInvalid++;
}
} else {
fixedList.add(g);
}
}
Geometry[] coverage = fixedList.toArray(new Geometry[0]);
rep.coverageValid = CoverageValidator.isValid(coverage);
Geometry gaps = CoverageGapFinder.findGaps(coverage, 0.05);
rep.gapArea = gaps.getArea();
Geometry[] clean = CoverageCleaner.clean(
coverage, 0.001, CoverageCleaner.MERGE_LONGEST_BORDER, 0.05);
int nulls = 0;
for (Geometry c : clean) {
if (c == null) nulls++;
}
rep.nullAfterClean = nulls;
rep.coverageValid = CoverageValidator.isValid(clean);
return rep;
}
public static void main(String[] args) throws Exception {
String[] tiles = new String[] {
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
"POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))"
};
Report rep = check(tiles);
System.out.println("总数 = " + rep.total);
System.out.println("输入无效 = " + rep.invalidInput);
System.out.println("修复成功 = " + rep.fixed);
System.out.println("覆盖率有效 = " + rep.coverageValid);
}
}
17.12.4 报告与回归
质检系统必须留下可追溯的报告,否则修复动作无法解释。建议至少记录:
- 每个要素的质检结论、错误码、错误坐标;
- 修复前后面积、周长、顶点数、部件数;
- 覆盖率无效段的 WKT(可直接渲染);
- 清理阶段的吸附距离、缝隙阈值、合并策略,以及哪些要素被置 null;
- 运行版本(JTS 版本、规则版本),便于复现。
回归测试的做法是维护一个“金标准”小数据集:每次升级 JTS 或调整阈值,跑一遍检查,对比错误码数量、无效段长度、面积变化分布。JTS 大版本之间这些算法有改进(例如 1.19 修 IsValidOp 重复点、1.21 修 BoundaryChainNoder 自接触),输出可能变化,靠回归集才能发现。
17.13 常见问题与避坑
17.13.1 十个高频坑
| 序号 | 坑 | 现象 | 正确做法 |
|---|---|---|---|
| 1 | 用 isSimple() 判多边形有效 |
自相交面被判“有效” | 有效性用 isValid(),简单性用 IsSimpleOp |
| 2 | 以为重复点无效 | 质检不报错,但 WKB 变大 | 用 RepeatedPointTester 单独统计 |
| 3 | 每次操作前都 isValid() |
吞吐量骤降 | 只在入口校验/修复,结果缓存 |
| 4 | GeometryFixer 之后不复检 | 修复后面积暴增 | 修复后复检 + 面积变化阈值 |
| 5 | 把 CoverageValidator 的 gapWidth 当匹配容差 | 质检永远不通过 | 用 CoverageCleaner 吸附 |
| 6 | 覆盖率校验只查单要素 | 缝隙没人管 | 成组 CoverageValidator |
| 7 | 对无效覆盖率直接 CoverageUnion | 偶发异常或静默错误结果 | 先校验后 Union |
| 8 | 坐标是经纬度却用米做缝隙阈值 | 阈值形同虚设 | 阈值与坐标单位一致(见第 19 章) |
| 9 | 分区校验忽略边界要素 | 跨区错位漏检 | 分区带 buffer 或成对校验 |
| 10 | 修复时 keepCollapsed 默认值误判 | 面悄悄消失 | 显式设置参数并统计空几何 |
17.13.2 性能清单
- 批量化:CoverageValidator 一次传数组,别循环调单面版本;
- 先便宜后昂贵:先坐标与结构检查,再有效性,最后覆盖率;
- 缓存:有效性、覆盖率只依赖几何本身,可在入库时算一次存字段;
- 分区:按行政单元或图幅切分,并行度按 CPU 核数但注意内存;
- 避免 WKT 反复序列化:无效段结果直接传对象,落盘时再一次性写;
- 注意
gapWidth与吸附距离不要设大:会显著增加相交检测量。
17.14 本章小结
- JTS 质检分两层:单要素有效性(IsValidOp)与要素间覆盖率有效性(CoverageValidator),两者判定规则完全不同,不可互相替代。
- 有效性遵循 OGC SFS:坐标有限、环闭合、点数足够、环不自交、孔在壳内、孔不嵌套、内部连通;错误码 0~11 中 0 与 1 已废弃。
- IsValidOp.getValidationError() 才带错误码与位置;它与
isValid()都会完整执行校验,不要重复调用。 - isSimple() 不是有效性检查;Polygonal 的简单性只检查环,不检查孔/嵌套/连通性。
- 重复点在 SFS 下合法,但仍应作为数据质量指标用 RepeatedPointTester 统计。
- GeometryFixer 是确定性的“转合法”工具:清理非法/重复坐标、零距离 buffer 修环、孔越界转面、多部件 union 消重叠;要显式设置
keepCollapsed与keepMulti并复检。 - 覆盖率有效性是零容差的:内部不重叠 + 公共边节点精确匹配;
gapWidth只用于窄缝识别,不是匹配容差。 - CoveragePolygonValidator 能定位六类要素间错误并返回导致错误的边界线;CoverageValidator 用 STRtree 做整组校验。
- CoverageGapFinder 用“合并后洞的最大内切圆”识别窄缝,比 Validator 的启发式更准,但识别不了把覆盖分成两块的缝。
- CoverageCleaner(1.21 新增)吸附 + 合并重叠 + 填窄缝,输出与输入一一对应的合法覆盖率;CoverageSimplifier 拓扑保持化简;CoverageUnion 高效合并但要求输入合法。
- 工程落地要有“检查 → 修复 → 复检 → 报告 → 回归”的闭环,并对面积/部件数变化设阈值,防止自动修复把业务数据改坏。
17.15 相关资源
- 系列目录:JTS 教程目录
- 几何对象模型:第 04 章(了解
Geometry与子类型是理解错误码的前提) - 空间关系与 DE-9IM:第 08 章(理解“内部/边界/外部”才能理解连通性判定)
- 叠加分析:第 09 章(OverlayNG 是修复与清理的底层引擎)
- 几何化简:第 12 章(对比普通化简与覆盖率保持化简)
- 精度模型与数值鲁棒性:第 18 章(修复与校验失败的数值根源)
- 坐标系与工程陷阱:第 19 章(缝隙阈值的单位问题)
- 综合实战:第 24 章(完整数据治理流程)
- JTS 官方 Javadoc:https://locationtech.github.io/jts/javadoc/
- 源码路径:
modules/core/src/main/java/org/locationtech/jts/operation/valid、org/locationtech/jts/coverage、org/locationtech/jts/geom/util/GeometryFixer.java