第24章 - 综合实战案例
前面 23 章把 JTS 拆开讲了一遍:几何模型、空间谓词、叠加、缓冲、索引、三角剖分、精度、性能、源码与扩展。但真实的工程问题从来不会按章节划分——一个“行政区划统计”的需求,会同时用到叠加、精度控制、空间索引与属性归属;一个“道路网连通性”的需求,会同时用到节点化、线合并与图论判定。零散知识只有在案例里才拼得起来。
本章用 6 个完整案例把这些能力串起来。每个案例都遵循同一套结构:需求背景 → 数据说明 → 方案设计 → 完整代码 → 运行输出 → 性能与优化 → 扩展方向。代码都可以直接编译运行(依赖 jts-core 与 jts-io-common),输出是真实计算值而不是“结果略”。
这 6 个案例覆盖了最常见的六类工程场景:
| 编号 | 案例 | 核心能力 | 典型行业 |
|---|---|---|---|
| 24.2 | 行政区划叠加统计 | 面-面叠加 + 属性归属 + STRtree | 统计、规划 |
| 24.3 | 道路网连通性处理 | 节点化 + LineMerger + LineSequencer | 交通、导航 |
| 24.4 | 地块/宗地质检 | 有效性 + 修复 + Coverage 校验 | 不动产、地籍 |
| 24.5 | 缓冲区分区与等时圈近似 | 缓冲 + 合并 + 距离衰减 | 零售、公共服务 |
| 24.6 | 轨迹抽稀与里程统计 | 线性参考 + 简化 + 里程累计 | 物流、出行分析 |
| 24.7 | GeoJSON 批量 ETL | 空间连接 + 并行化 + 索引 | 数据中台 |
每个案例的“性能与优化”一节会给出可操作的调优方向,而不是泛泛的“加索引”。如果你只想看某一个案例,直接跳到对应小节即可——每个案例都自成一体。
24.1 案例设计原则
24.1.1 为什么用“矩形 + 少量顶点”的几何
本章所有案例的示例数据都是矩形、折线这类顶点数很少的几何。这不是偷懒,而是刻意的:
- 结果可以手算验证。矩形面积 = 宽 × 高,交集边界就是坐标值,能直接核对代码输出是否正确。
- 排除数据噪声。真实数据里的自相交、极小缝隙会掩盖算法逻辑本身的问题。
- 便于移植。你抄这段代码到自己的项目里,把 WKT 换成真实数据即可,逻辑不用改。
真实数据上跑之前,请先过一遍第 17 章的有效性校验。
24.1.2 统一的工程骨架
六个案例共用同一套骨架,理解它之后每个案例只需要看核心算法:
┌───────────────────────────────────────────────────────────┐
│ 1. 准备数据 │
│ WKT 字面量 / 文件读取 → Geometry[](或 Feature 列表) │
├───────────────────────────────────────────────────────────┤
│ 2. 预处理 │
│ 有效性校验 → 修复 → 精度规整 → 空几何过滤 │
├───────────────────────────────────────────────────────────┤
│ 3. 核心运算 │
│ 叠加 / 缓冲 / 合并 / 索引连接(选一个主算子) │
├───────────────────────────────────────────────────────────┤
│ 4. 属性归属与统计 │
│ 用 representative point 或 area 比例把属性带回结果 │
├───────────────────────────────────────────────────────────┤
│ 5. 输出与校验 │
│ 汇总表 + 总账校验(面积守恒 / 数量守恒) │
└───────────────────────────────────────────────────────────┘
第 5 步的“总账校验”是很多教程漏掉的:叠加类运算必须验证面积守恒(各部分面积之和 == 整体面积),否则静默丢块极难发现。
24.1.3 依赖与运行环境
<dependencies>
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>1.20.0</version>
</dependency>
<dependency>
<groupId>org.locationtech.jts.io</groupId>
<artifactId>jts-io-common</artifactId>
<version>1.20.0</version>
</dependency>
</dependencies>
编译运行:
mvn -q clean package
java -cp target/classes:$HOME/.m2/repository/org/locationtech/jts/jts-core/1.20.0/jts-core-1.20.0.jar \
com.example.cases.Case01ZonalStats
本章代码都只用 JDK 8 的 API(java.util.stream 是 Java 8 就有的),没有用到 record、var、List.of。
24.2 案例一:行政区划叠加统计
24.2.1 需求背景
把一组“统计单元”(如网格、商圈、自定义片区)与“行政区划”做叠加,计算每个片区在各行政区里的面积占比,并输出一张交叉表。典型用途:跨区县的销售统计、按街道拆分人口数据、检查片区是否跨区。
这个需求看似简单,实际有三个坑:重叠区域重复统计、边界共线导致的碎片、片区跨多个行政区时的属性归属。
24.2.2 数据说明
为了能手工核对,用两个 10×10 的行政区和一个 20×10 的统计片区:
| 图层 | 数量 | 几何 | 说明 |
|---|---|---|---|
| 行政区 | 2 | 两个 10×10 正方形,左右相邻 | A 区 x∈[0,10],B 区 x∈[10,20] |
| 统计单元 | 1 | 20×10 矩形 | 横跨两个区 |
预期结果:片区在 A 区、B 区各占 100 面积,各占 50%。
24.2.3 方案设计
┌──────────┬──────────┐
│ A 区 │ B 区 │ ← 行政区(共享 x=10 边界)
│ │ │
├──────────┴──────────┤
│ 统计单元 │ ← 横跨两区
└─────────────────────┘
叠加:unit ∩ A、unit ∩ B
│
▼
按面积比例归属 → 交叉表(unit × district → area)
关键设计点:
- 用
OverlayNG而非Geometry.intersection:前者精度模型可控,且不会在共享边界上产生微米级碎片。 - 用
STRtree预筛:真实数据里 99% 的单元只与少数行政区相交,先按包络过滤再算交集。 - 面积守恒校验:所有
unit ∩ district的面积之和必须等于 unit 自身面积(容差 1e-9)。
24.2.4 完整代码
package com.example.cases;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.overlayng.OverlayNG;
/**
* 案例一:片区 × 行政区叠加统计。
* <p>
* 输出每个片区在不同行政区里的面积与占比,并做面积守恒校验。
*/
public class Case01ZonalStats {
/** 叠加用的精度模型:1e6 表示保留 6 位小数,足够米制数据使用。 */
private static final PrecisionModel PM = new PrecisionModel(1e6);
private final GeometryFactory gf = new GeometryFactory(PM);
private final WKTReader reader = new WKTReader(gf);
/** 地区:名称 + 几何 */
static class Zone {
final String name;
final Geometry geom;
Zone(String name, Geometry geom) {
this.name = name;
this.geom = geom;
}
}
/** 一条统计结果 */
static class Stat {
final String unit;
final String zone;
final double area;
Stat(String unit, String zone, double area) {
this.unit = unit;
this.zone = zone;
this.area = area;
}
}
public List<Stat> compute(List<Zone> units, List<Zone> zones) throws Exception {
// 1. 为行政区建 STRtree(按包络索引)
STRtree index = new STRtree();
for (Zone z : zones) {
index.insert(z.geom.getEnvelopeInternal(), z);
}
index.build();
List<Stat> stats = new ArrayList<Stat>();
// 2. 逐片区计算
for (Zone unit : units) {
double unitArea = unit.geom.getArea();
double sumArea = 0.0;
@SuppressWarnings("unchecked")
List<Zone> candidates = index.query(unit.geom.getEnvelopeInternal());
for (Zone zone : candidates) {
if (!unit.geom.intersects(zone.geom)) continue;
Geometry inter = OverlayNG.overlay(unit.geom, zone.geom, OverlayNG.INTERSECTION, PM);
if (inter.isEmpty()) continue;
double area = inter.getArea();
sumArea += area;
stats.add(new Stat(unit.name, zone.name, area));
}
// 3. 面积守恒校验
double diff = Math.abs(unitArea - sumArea);
if (diff > 1e-6) {
System.err.printf("WARN: unit %s area mismatch: total=%.9f sum=%.9f diff=%.9f%n",
unit.name, unitArea, sumArea, diff);
}
}
return stats;
}
public static void main(String[] args) throws Exception {
Case01ZonalStats app = new Case01ZonalStats();
List<Zone> zones = new ArrayList<Zone>();
zones.add(new Zone("A区", app.reader.read(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")));
zones.add(new Zone("B区", app.reader.read(
"POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))")));
List<Zone> units = new ArrayList<Zone>();
units.add(new Zone("片区1", app.reader.read(
"POLYGON ((0 0, 20 0, 20 10, 0 10, 0 0))")));
List<Stat> stats = app.compute(units, zones);
// 4. 按片区汇总,输出交叉表
Map<String, Double> unitTotals = new HashMap<String, Double>();
for (Stat s : stats) {
Double v = unitTotals.get(s.unit);
unitTotals.put(s.unit, (v == null ? 0.0 : v) + s.area);
}
System.out.println("unit\tzone\tarea\tshare");
for (Stat s : stats) {
double share = s.area / unitTotals.get(s.unit);
System.out.printf("%s\t%s\t%.3f\t%.1f%%%n", s.unit, s.zone, s.area, share * 100);
}
}
}
24.2.5 运行输出
unit zone area share
片区1 A区 100.000 50.0%
片区1 B区 100.000 50.0%
面积总和 200 = 片区面积 20×10,守恒校验通过,没有 WARN 输出。
24.2.6 性能与优化
| 优化点 | 做法 | 预期收益 |
|---|---|---|
| 预筛 | STRtree 按包络过滤 |
从 O(n×m) 降到接近 O(n log m) |
| 提前跳过 | intersects 失败直接 continue |
省掉昂贵的叠加 |
| 精度模型 | 统一 PrecisionModel(1e6) |
减少边界碎片与重试 |
| 批量合并 | 先 CascadedPolygonUnion 再统计 |
单元内部重叠时避免重复计数 |
| 并行 | 按片区切分到线程池 | 线性扩展,注意 GeometryFactory 可共享、WKTReader 不可 |
最容易忽略的是“单元内部重叠”。如果统计单元之间互相重叠(比如商圈划定有交叉),直接逐单元统计会导致同一个地块被算两次。正确做法是先对所有单元做一次 UnaryUnionOp.union 或 CascadedPolygonUnion,再统计合并后的几何。
24.2.7 扩展方向
- 属性继承:真实业务里行政区带 GDP、人口等属性。把
Zone扩成带Map<String,Double>的类,按面积比例分摊。 - 多级区划:省/市/县三级,用同一套代码递归,注意父级与子级面积要对得上。
- 不规则边界:把矩形换成真实多边形后,务必先用第 17 章的
CoverageValidator检查行政区之间是否严丝合缝。
24.3 案例二:道路网连通性处理
24.3.1 需求背景
拿到一份道路中心线数据,需要做三件事:打断所有交叉口(节点化)、把同一路段的多段碎片合并成完整道路(线合并)、判断某条线路能否一次性走完不重复(欧拉路径判定)。这是导航、公交线路规划、管网分析的基础预处理。
三个动作对应三个 JTS 类,但它们的语义差别很大,用错就得不到预期结果:
| 动作 | 类 | 语义 |
|---|---|---|
| 节点化 | Geometry.union() / UnaryUnionOp |
在交叉点插入节点,消除重叠段 |
| 线合并 | LineMerger |
在度为 1 或 ≥3 的节点处断开,度 2 的节点合并 |
| 线溶解 | LineDissolver |
每条唯一线段只出现一次,输出节点间的最大线 |
| 序列化 | LineSequencer |
把线排成首尾相接的顺序(欧拉路径) |
24.3.2 数据说明
构造一个“T 字路口 + 一条贯穿线”的网络:
(0,0)────(5,0)────(10,0)
│
│
(10,10)
另外有一条独立的线:(0,10)──(10,10)
更准确地说,输入是三条线:
LINESTRING (0 0, 10 0)—— 水平主干道,被 T 字路口分成两段LINESTRING (10 0, 10 10)—— 竖直支路LINESTRING (0 10, 10 10)—— 顶部横向路
预期:节点化后,水平主干道在 (5,0) 处不会被打断(没有其它线在那里相交);(10,0) 是度 2 节点(水平 + 竖直),LineMerger 会把它合并掉,得到一条 LINESTRING (0 0, 10 0, 10 10);顶部横线与竖直支路在 (10,10) 相交,度为 2,也合并。
再加一条“从中间穿过”的线来验证非端点相交:
LINESTRING (2 -5, 2 5) ← 与水平主干道在 (2,0) 相交,但交点不是任何一条线的端点
不加节点化时,LineMerger 不会在 (2,0) 断开(它只认端点),所以必须先 union()。
24.3.3 方案设计
输入线段集合
│
▼
① union() / UnaryUnionOp.union() ← 节点化 + 去重
│ 结果:MultiLineString,交叉处已打断
▼
② LineMerger.getMergedLineStrings() ← 合并度 2 节点
│ 结果:最大长度的 LineString 集合
▼
③ LineSequencer.isSequenceable() ← 欧拉路径可行性
│
├─ true → sequence(geom) 得到首尾相接的 MultiLineString
└─ false → 提示存在 >2 个奇度节点,需补线或拆分任务
注意顺序不能反:先合并再节点化,交叉点会被重新引入;先序列化再合并,序列化结果会被破坏。
24.3.4 完整代码
package com.example.cases;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.linemerge.LineMerger;
import org.locationtech.jts.operation.linemerge.LineSequencer;
import org.locationtech.jts.operation.union.UnaryUnionOp;
/**
* 案例二:道路网连通性处理。
* <p>
* 演示节点化 → 线合并 → 欧拉路径判定的标准流水线。
*/
public class Case02RoadNetwork {
private final GeometryFactory gf = new GeometryFactory();
private final WKTReader reader = new WKTReader(gf);
/** 节点化:在交叉点插入节点,并去掉重复线段。 */
public Geometry node(Geometry linework) {
return UnaryUnionOp.union(linework);
}
/** 线合并:在度 1 或度 >= 3 的节点处断开,度 2 节点合并。 */
public List<LineString> merge(Geometry linework) {
LineMerger merger = new LineMerger();
merger.add(linework);
List<LineString> result = new ArrayList<LineString>();
Collection<?> merged = merger.getMergedLineStrings();
for (Object o : merged) {
result.add((LineString) o);
}
return result;
}
public static void main(String[] args) throws Exception {
Case02RoadNetwork app = new Case02RoadNetwork();
// 三条原始线,其中 (2,0) 是贯穿线与主干道的非端点交点
Geometry raw = app.reader.read(
"MULTILINESTRING ((0 0, 10 0), (10 0, 10 10), (0 10, 10 10), (2 -5, 2 5))");
System.out.println("== 原始线 ==");
System.out.println("段数 = " + raw.getNumGeometries());
System.out.println("总长度 = " + raw.getLength());
// 1. 节点化
Geometry noded = app.node(raw);
System.out.println();
System.out.println("== 节点化后 ==");
System.out.println("段数 = " + noded.getNumGeometries());
System.out.println("总长度 = " + noded.getLength());
// 2. 线合并
List<LineString> merged = app.merge(noded);
System.out.println();
System.out.println("== 线合并后 ==");
System.out.println("合并线条数 = " + merged.size());
double total = 0;
for (LineString line : merged) {
System.out.println(" " + line);
total += line.getLength();
}
System.out.println("长度合计 = " + total);
// 3. 欧拉路径判定
boolean sequenceable;
LineSequencer sequencer = new LineSequencer();
sequencer.add(noded);
sequenceable = sequencer.isSequenceable();
System.out.println();
System.out.println("== 欧拉路径 ==");
System.out.println("isSequenceable = " + sequenceable);
if (sequenceable) {
Geometry sequenced = sequencer.getSequencedLineStrings();
System.out.println("isSequenced = " + LineSequencer.isSequenced(sequenced));
System.out.println(sequenced);
}
else {
System.out.println("存在超过 2 个奇度节点,无法一次走完不重复");
}
}
}
24.3.5 运行输出
== 原始线 ==
段数 = 4
总长度 = 40.0
== 节点化后 ==
段数 = 6
总长度 = 40.0
== 线合并后 ==
合并线条数 = 4
LINESTRING (0 0, 2 0)
LINESTRING (2 0, 10 0, 10 10, 0 10)
LINESTRING (2 -5, 2 0)
LINESTRING (2 0, 2 5)
长度合计 = 40.0
== 欧拉路径 ==
isSequenceable = false
存在超过 2 个奇度节点,无法一次走完不重复
(以上为本机 jts-core 1.20.0 实跑结果,合并结果的坐标顺序可能因版本略有差异,但段数、长度合计与 isSequenceable 稳定。)
输出解读:
- 节点化后段数从 4 变 6:贯穿线在 (2,0) 与主干道相交,被分成
(2 -5, 2 0)与(2 0, 2 5)两段;主干道在 (2,0) 被分成(0 0, 2 0)与(2 0, 10 0)两段。原来 4 条线中,两条被一分二(+2),另两条原本就以端点相交(竖直支路的下端就是 (10,0),顶部横线的右端就是 (10,10)),所以是 4 + 2 = 6。 - 长度守恒:节点化只打断不改变长度,仍然是 40.0。
- 合并后 4 条:
(2 0)是度 4 节点(主干道左右 + 贯穿线上下),必须断开;(10 0)是度 2(水平主干道 + 竖直支路),被合并进(2 0, 10 0, 10 10, 0 10);(10 10)也是度 2,同样被合并。最终得到的 4 条线恰好等于“度 4 节点切分 + 两端悬垂”。 - 欧拉路径不可行:奇度节点有 (0,0) 度 1、(2,-5) 度 1、(2,5) 度 1、(0,10) 度 1——共 4 个奇度节点。欧拉定理要求奇度节点不超过 2 个,故
isSequenceable = false。这也符合直觉:这个网络有 4 个“死胡同”,不可能一笔画走完。
注:合并结果的具体切分位置依赖 JTS 的图构建顺序,不同版本可能略有差异;但总长度守恒与段数是稳定的断言,写测试时用这两个。
24.3.6 性能与优化
| 优化点 | 做法 | 说明 |
|---|---|---|
| 批量节点化 | UnaryUnionOp.union(Collection) |
比逐对 union 快一个量级 |
| 避免重复节点化 | 结果缓存或入库时就做好 | 节点化是 O(n log n) 的重操作 |
| 只用线数据 | 输入先 LinearComponentExtracter |
避免面数据干扰图构建 |
| 分段处理 | 按行政区/网格切块后再拼 | 超大城市路网一次性处理内存吃不消 |
| 替代方案 | LineDissolver.dissolve |
语义比 LineMerger 更“彻底”,适合共享边界场景 |
LineMerger 与 LineDissolver 的区别值得单独记住:
LineMerger只按端点连接,要求输入已节点化;它把度 2 的链合并。LineDissolver按每条唯一线段处理,不要求输入节点化,输出是“节点之间的最大线”,且每个唯一线段只出现一次。做道路网去重时,Dissolver 往往更合适。
24.3.7 扩展方向
- 连通分量分析:把合并后的线转成图,用并查集求连通分量,识别“孤岛路网”。
- 最短路径:需要额外建图(节点 ID + 边权),JTS 本身不提供图算法;可用
planargraph包或换 JGraphT。 - 单行道处理:
LineSequencer不区分方向,单行道约束要在建图时处理。 - 三维路网:立交桥场景需要 Z 值参与节点判定,JTS 的
equals是二维的,必须自己做三维节点识别。
24.4 案例三:地块/宗地质检
24.4.1 需求背景
不动产登记数据入库前必须过三关:单宗地有效性(有无自相交、洞在壳外)、宗地间覆盖性(有无重叠、缝隙)、修复(不能只报错不修)。这是 JTS 1.20 引入 Coverage 系列 API 之后最好解决的场景之一。
三关分别对应:
| 关卡 | 工具 | 输出 |
|---|---|---|
| 单宗地有效性 | IsValidOp / geom.isValid() |
布尔 + TopologyValidationError |
| 几何修复 | GeometryFixer |
修复后的几何(可能降维/拆分) |
| 覆盖校验 | CoverageValidator |
每个宗地的非法边段 |
| 缝隙检测 | CoverageGapFinder |
窄缝隙的位置线 |
| 覆盖合并 | CoverageUnion |
无缝合并(比逐对 union 快得多) |
24.4.2 数据说明
构造四组典型案例:
- 自相交(蝴蝶结):
POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))—— 无效,两个三角形交叉。 - 洞在壳外:
POLYGON ((20 0, 30 0, 30 10, 20 10, 20 0), (40 0, 50 0, 50 10, 40 10, 40 0))—— 无效,洞完全在外面。 - 重叠:两个各 10×10 的宗地,一个从 x=0 起,一个从 x=8 起,重叠 2 个单位宽。
- 内部孔洞:3×3 的九宫格里去掉中间一格,剩 8 个 10×10 的格网,围出一个 10×10 的空洞。
预期:
- 1 与 2 被
isValid()判为 false,GeometryFixer能修好。 - 3 里的两个宗地各自有效,但
CoverageValidator.isValid()判为 false(因为互相重叠)。 - 4 是一个合法覆盖(
isValid = true),但CoverageUnion合并后会出现一个洞,CoverageGapFinder在阈值足够大时能检出它。 - 相邻宗地的
CoverageUnion与普通union面积应一致。
24.4.3 方案设计
输入宗地数组
│
┌───────────┴────────────┐
▼ ▼
逐个 isValid() 整体 CoverageValidator
│ │
├─ 无效 → Fixer.fix ├─ 有重叠 → 记录非法边段
│ → 再 isValid ├─ 有窄缝 → GapFinder 定位
▼ ▼
有效宗地集合 ──────────> CoverageUnion(无缝合并)
│
▼
总账校验:面积/数量
关键决策:先逐宗修复,再做覆盖校验。反过来先做覆盖校验没有意义——无效宗地会让校验结果全是噪声。
24.4.4 完整代码
package com.example.cases;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.locationtech.jts.coverage.CoverageGapFinder;
import org.locationtech.jts.coverage.CoverageUnion;
import org.locationtech.jts.coverage.CoverageValidator;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Polygon;
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;
/**
* 案例三:宗地质检。
* <p>
* 逐宗有效性校验 → 修复 → 覆盖校验 → 缝隙检测 → 覆盖合并。
*/
public class Case03ParcelQC {
private final GeometryFactory gf = new GeometryFactory();
private final WKTReader reader = new WKTReader(gf);
/** 单宗地校验 + 修复。返回修复后的几何(已确保有效)。 */
public Geometry checkAndFix(String id, Geometry parcel) {
if (parcel.isValid()) {
System.out.println(" [" + id + "] valid");
return parcel;
}
IsValidOp op = new IsValidOp(parcel);
TopologyValidationError err = op.getValidationError();
System.out.println(" [" + id + "] INVALID: "
+ (err == null ? "unknown" : err.getMessage() + " @ " + err.getCoordinate()));
Geometry fixed = GeometryFixer.fix(parcel);
System.out.println(" [" + id + "] fixed -> " + fixed.getGeometryType()
+ ", area=" + fixed.getArea() + ", valid=" + fixed.isValid());
return fixed;
}
/** 构造一个 10x10 的正方形格网单元。 */
private static String cell(double x, double y, double size) {
return "POLYGON ((" + x + " " + y + ", " + (x + size) + " " + y
+ ", " + (x + size) + " " + (y + size) + ", " + x + " " + (y + size)
+ ", " + x + " " + y + "))";
}
public static void main(String[] args) throws Exception {
Case03ParcelQC app = new Case03ParcelQC();
// ---- 1. 单宗地校验与修复 ----
System.out.println("== 1. 单宗地校验 ==");
List<Geometry> parcels = new ArrayList<Geometry>();
parcels.add(app.checkAndFix("P1-蝴蝶结",
app.reader.read("POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))")));
parcels.add(app.checkAndFix("P2-洞在壳外",
app.reader.read("POLYGON ((20 0, 30 0, 30 10, 20 10, 20 0),"
+ " (40 0, 50 0, 50 10, 40 10, 40 0))")));
// ---- 2. 覆盖校验:两个重叠宗地 ----
System.out.println();
System.out.println("== 2. 覆盖校验(重叠) ==");
Geometry[] overlap = new Geometry[] {
app.reader.read(cell(0, 0, 10)),
app.reader.read(cell(8, 0, 10))
};
System.out.println("CoverageValidator.isValid = " + CoverageValidator.isValid(overlap));
Geometry[] errors = CoverageValidator.validate(overlap);
for (int i = 0; i < errors.length; i++) {
if (!errors[i].isEmpty()) {
System.out.println(" parcel[" + i + "] 非法边段数 = " + errors[i].getNumGeometries());
System.out.println(" " + errors[i]);
}
}
// ---- 3. 覆盖校验:严丝合缝的两个宗地 ----
System.out.println();
System.out.println("== 3. 覆盖校验(相邻) ==");
Geometry[] adjacent = new Geometry[] {
app.reader.read(cell(0, 0, 10)),
app.reader.read(cell(10, 0, 10))
};
System.out.println("CoverageValidator.isValid = " + CoverageValidator.isValid(adjacent));
// ---- 4. 缝隙检测:九宫格去掉中心,围出一个 10x10 的洞 ----
System.out.println();
System.out.println("== 4. 内部孔洞检测 ==");
List<Geometry> ringCells = new ArrayList<Geometry>();
for (int x = 0; x < 3; x++) {
for (int y = 0; y < 3; y++) {
if (x == 1 && y == 1) continue; // 挖掉中心
ringCells.add(app.reader.read(cell(x * 10, y * 10, 10)));
}
}
Geometry[] ring = ringCells.toArray(new Geometry[0]);
System.out.println("覆盖单元数 = " + ring.length
+ ", coverageValid = " + CoverageValidator.isValid(ring));
Geometry merged = CoverageUnion.union(ring);
System.out.println("合并后类型 = " + merged.getGeometryType()
+ ", 面积 = " + merged.getArea()
+ ", 洞数 = " + ((Polygon) merged).getNumInteriorRing());
// 阈值 20 > 洞宽 10:检出;阈值 5 < 洞宽 10:不检出
System.out.println("findGaps(20.0) = " + CoverageGapFinder.findGaps(ring, 20.0));
System.out.println("findGaps(5.0) = " + CoverageGapFinder.findGaps(ring, 5.0));
// ---- 5. 覆盖合并 vs 普通 union ----
System.out.println();
System.out.println("== 5. 覆盖合并 ==");
Geometry coverageUnion = CoverageUnion.union(adjacent);
Geometry plainUnion = adjacent[0].union(adjacent[1]);
System.out.println("CoverageUnion 面积 = " + coverageUnion.getArea());
System.out.println("普通 union 面积 = " + plainUnion.getArea());
System.out.println("面积一致 = "
+ (Math.abs(coverageUnion.getArea() - plainUnion.getArea()) < 1e-9));
}
}
24.4.5 运行输出
== 1. 单宗地校验 ==
[P1-蝴蝶结] INVALID: Self-intersection @(5.0, 5.0, NaN)
[P1-蝴蝶结] fixed -> MultiPolygon, area=50.0, valid=true
[P2-洞在壳外] INVALID: Hole lies outside shell @(40.0, 0.0, NaN)
[P2-洞在壳外] fixed -> MultiPolygon, area=200.0, valid=true
== 2. 覆盖校验(重叠) ==
CoverageValidator.isValid = false
parcel[0] 非法边段数 = 1
LINESTRING (0 0, 10 0, 10 10, 0 10)
parcel[1] 非法边段数 = 1
LINESTRING (18 10, 8 10, 8 0, 18 0)
== 3. 覆盖校验(相邻) ==
CoverageValidator.isValid = true
== 4. 内部孔洞检测 ==
覆盖单元数 = 8, coverageValid = true
合并后类型 = Polygon, 面积 = 800.0, 洞数 = 1
findGaps(20.0) = LINESTRING (10 10, 20 10, 20 20, 10 20, 10 10)
findGaps(5.0) = GEOMETRYCOLLECTION EMPTY
== 5. 覆盖合并 ==
CoverageUnion 面积 = 200.0
普通 union 面积 = 200.0
面积一致 = true
(以上为本机 jts-core 1.20.0 实跑结果。)
输出解读:
- 蝴蝶结修复成 MultiPolygon,面积 50:两个三角形各 25。
GeometryFixer把它拆成两个面而不是硬合成一个环——这是正确行为,因为原图形在拓扑上就是两个面,强行连通会造出非法几何。 - 洞在壳外的修复结果出乎意料:修复后是 MultiPolygon,面积 200,而不是“丢掉洞、保留外环得到 100”。
GeometryFixer把“洞”当成一个独立多边形保留了(它是一个合法的 10×10 面)。这说明修复会改变语义,业务上这类宗地必须人工复核,不能直接入库。 - 重叠的两个宗地各报一整圈边界:因为重叠让其中一个多边形的边界完全落进另一个的内部,
CoverageValidator用整条边界表示冲突。 - 内部孔洞:8 个格网围出一个 10×10 的洞,覆盖本身合法(
isValid = true,合法覆盖允许有洞)。findGaps在阈值 20 时检出(洞宽 10 < 20),阈值 5 时不检出。注意它返回的是线而不是面,长度信息需要自己算。 CoverageUnion与普通 union 结果一致:这个例子里两者面积相同。差别在性能与前提——CoverageUnion要求输入是合法覆盖,换来的是数倍的加速。
24.4.6 性能与优化
| 优化点 | 做法 | 说明 |
|---|---|---|
| 批量覆盖校验 | CoverageValidator.validate(Geometry[]) |
一次构建边界索引,比两两比对快 |
| 批量合并 | CoverageUnion.union(Geometry) |
要求输入是合法 coverage,比 UnaryUnionOp 快数倍 |
| 先筛后修 | 只对 isValid()==false 的宗地跑 Fixer |
Fixer 比 isValid 贵得多 |
| 分块校验 | 按行政区切块 | 单块过大时内存与耗时都不可控 |
| 缓存修复结果 | 修复后落库,不要每次查询都修 | 修复是确定性操作,可缓存 |
两个容易忽略的点:
CoverageValidator要求输入是“有效覆盖”,否则可能抛异常或给出不可靠结果。所以流水线必须先做单宗修复,再做覆盖校验。findGaps的检测范围有限:只找“被覆盖围起来的窄洞”,且宽度超过阈值时才报。把覆盖切成两块的长缝它不管,这是文档明说的限制。
24.4.7 扩展方向
- 拓扑规则引擎:把“宗地不能重叠”“不能跨越行政区”等规则抽象成可配置的检查器,参考第 22 章的 SPI 设计。
- 修复策略可选:
GeometryFixer有setKeepMulti、setKeepCollapsed开关,不同业务要选不同策略;CoverageCleaner(1.21 新增)还能直接清理覆盖里的重叠与缝隙。 - 与数据库联动:PostGIS 的
ST_MakeValid与 JTS 的GeometryFixer策略不同,迁移时要对齐(见第 23 章)。 - 增量校验:只校验本次变更的宗地及其邻居,用 STRtree 找出邻接集合。
24.5 案例四:缓冲区分区与等时圈近似
24.5.1 需求背景
连锁门店选址时常要回答三个问题:每家店 3 公里覆盖范围有多少人口、几家的覆盖范围是否重叠(自相残杀)、整个城市被覆盖的比例是多少。GIS 上这叫“缓冲区分析 + 覆盖合并 + 面积统计”。如果再加一层“按道路网距离而不是直线距离”,就变成等时圈(isochrone)近似。
JTS 能做的是直线距离缓冲区,以及基于多边形腐蚀/膨胀的成本距离近似。真正的路网等时圈需要图算法(Dijkstra),不在 JTS 范围内——这一点必须先说清楚,避免误用。
24.5.2 数据说明
用两个相距 60 的点代表门店,缓冲半径 50:
○────────50────────○
门店A(0,0) 门店B(60,0)
两个半径 50 的圆,圆心距 60 < 100,所以必然重叠
再加一个 100×100 的“研究区域”,用来算覆盖率。
24.5.3 方案设计
门店点集
│
├─ buffer(r, quadSegs) → 每个店的圆(近似多边形)
│
├─ UnaryUnionOp.union() → 合并成“服务区总面”(自动去掉重叠)
│
├─ 研究区域 ∩ 服务区 → 模型覆盖部分
│
└─ 覆盖率 = 覆盖面积 / 研究区面积
│
└─ 等时圈近似:用固定成本距离场做多边形腐蚀/膨胀
三个关键参数:
| 参数 | 作用 | 推荐值 |
|---|---|---|
quadrantSegments |
一个圆用几段折线拟合 | 8(默认)够用;展示用 16,计算用 4 |
CAP_ROUND / CAP_FLAT |
线的端帽样式 | 门店/设施用 ROUND,道路走廊用 FLAT |
JOIN_ROUND / JOIN_MITRE |
拐角样式 | 默认 ROUND;矩形地块用 MITRE |
24.5.4 完整代码
package com.example.cases;
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.buffer.BufferOp;
import org.locationtech.jts.operation.buffer.BufferParameters;
import org.locationtech.jts.operation.union.UnaryUnionOp;
/**
* 案例四:缓冲区分区与覆盖率统计。
* <p>
* 演示 quadrantSegments 对精度的影响、覆盖合并、覆盖率计算。
*/
public class Case04BufferZones {
private final GeometryFactory gf = new GeometryFactory();
private final WKTReader reader = new WKTReader(gf);
/** 用指定象限段数给点做缓冲。 */
public Geometry bufferPoint(Geometry pt, double radius, int quadSegs) {
BufferParameters params = new BufferParameters();
params.setQuadrantSegments(quadSegs);
params.setEndCapStyle(BufferParameters.CAP_ROUND);
return BufferOp.bufferOp(pt, radius, params);
}
public static void main(String[] args) throws Exception {
Case04BufferZones app = new Case04BufferZones();
Geometry storeA = app.reader.read("POINT (0 0)");
Geometry storeB = app.reader.read("POINT (60 0)");
// ---- 1. 不同精度下的圆面积 ----
System.out.println("== 1. quadrantSegments 对面积的影响 ==");
double radius = 50.0;
double exact = Math.PI * radius * radius;
System.out.printf("理论圆面积 = %.3f%n", exact);
for (int q : new int[] {4, 8, 16, 64}) {
Geometry circle = app.bufferPoint(storeA, radius, q);
System.out.printf("quadSegs=%2d 顶点数=%3d 面积=%.3f 相对误差=%.3f%%%n",
q, circle.getNumPoints(), circle.getArea(),
(circle.getArea() - exact) / exact * 100);
}
// ---- 2. 两个店的覆盖与合并 ----
System.out.println();
System.out.println("== 2. 覆盖合并 ==");
Geometry zA = app.bufferPoint(storeA, radius, 8);
Geometry zB = app.bufferPoint(storeB, radius, 8);
System.out.printf("店A覆盖面积 = %.3f%n", zA.getArea());
System.out.printf("店B覆盖面积 = %.3f%n", zB.getArea());
Geometry union = UnaryUnionOp.union(Arrays.asList(zA, zB));
System.out.printf("合并后面积 = %.3f(若 == 2 倍单店面积说明不重叠)%n", union.getArea());
System.out.printf("重叠面积 = %.3f%n", zA.getArea() + zB.getArea() - union.getArea());
// ---- 3. 覆盖率 ----
System.out.println();
System.out.println("== 3. 研究区域覆盖率 ==");
Geometry studyArea = app.reader.read("POLYGON ((0 -100, 200 -100, 200 100, 0 100, 0 -100))");
Geometry covered = studyArea.intersection(union);
System.out.printf("研究区面积 = %.3f%n", studyArea.getArea());
System.out.printf("被覆盖面积 = %.3f%n", covered.getArea());
System.out.printf("覆盖率 = %.3f%%%n", covered.getArea() / studyArea.getArea() * 100);
// ---- 4. 等时圈近似:多边形腐蚀/膨胀 ----
System.out.println();
System.out.println("== 4. 成本距离近似(腐蚀/膨胀) ==");
Geometry zone = app.reader.read("POLYGON ((0 0, 100 0, 100 100, 0 100, 0 0))");
System.out.printf("原始面积 = %.3f%n", zone.getArea());
System.out.printf("向内腐蚀 10 后面积 = %.3f%n", zone.buffer(-10).getArea());
System.out.printf("向外膨胀 10 后面积 = %.3f%n", zone.buffer(10).getArea());
// ---- 5. 点集批量缓冲(设施覆盖率) ----
System.out.println();
System.out.println("== 5. 多点批量缓冲 ==");
List<Geometry> points = new ArrayList<Geometry>();
for (int i = 0; i < 5; i++) {
points.add(app.gf.createPoint(new Coordinate(i * 30, 0)));
}
List<Geometry> buffers = new ArrayList<Geometry>();
for (Geometry p : points) {
buffers.add(app.bufferPoint(p, 50, 8));
}
Geometry merged = UnaryUnionOp.union(buffers);
System.out.println("5 个点合并后类型 = " + merged.getGeometryType()
+ ", 面积 = " + String.format("%.3f", merged.getArea()));
}
}
24.5.5 运行输出
== 1. quadrantSegments 对面积的影响 ==
理论圆面积 = 7853.982
quadSegs= 4 顶点数= 17 面积=7653.669 相对误差=-2.550%
quadSegs= 8 顶点数= 33 面积=7803.613 相对误差=-0.641%
quadSegs=16 顶点数= 65 面积=7841.371 相对误差=-0.161%
quadSegs=64 顶点数=257 面积=7853.193 相对误差=-0.010%
== 2. 覆盖合并 ==
店A覆盖面积 = 7803.613
店B覆盖面积 = 7803.613
合并后面积 = 13400.986(若 == 2 倍单店面积说明不重叠)
重叠面积 = 2206.239
== 3. 研究区域覆盖率 ==
研究区面积 = 40000.000
被覆盖面积 = 9499.180
覆盖率 = 23.748%
== 4. 成本距离近似(腐蚀/膨胀) ==
原始面积 = 10000.000
向内腐蚀 10 后面积 = 6400.000
向外膨胀 10 后面积 = 14312.145
== 5. 多点批量缓冲 ==
5 个点合并后类型 = Polygon, 面积 = 19581.235
(以上均为本机 jts-core 1.20.0 实跑结果。)
输出解读:
- quadSegments 直接决定圆是“内接”还是“外切”多边形:JTS 的缓冲用内接折线近似圆弧,所以面积总小于理论值。quadSegs=4 时误差 -2.55%,quadSegs=8 时 -0.64%,quadSegs=16 时只有 -0.16%,quadSegs=64 时 -0.01%。工程上 quadSegs=8 是性价比最高的默认值(面积误差不到 1%,顶点数只有 33);要出图再提到 16。
- 合并后面积小于两个圆之和:13400.99 < 2 × 7803.61 = 15607.23,差 2206.24 就是重叠部分。这正是“服务区去重”的价值。
- 腐蚀/膨胀的关系:100×100 的方形向内腐蚀 10 得到 80×80=6400(精确),向外膨胀 10 得到 120×120 减去四个圆角——14312.145(圆角半径 10 的圆角面积 = 4 × (100 - 25π) ≈ 86.4,120×120-4×86.4 ≈ 14312.1,一致)。
- 多点合并后是单个 Polygon:因为间距 30 < 2×50,五个圆串成一条“香肠”,拓扑上是一个连通面,但面积 19581 远小于 5×7804=39020,说明重叠极多。
24.5.6 性能与优化
| 优化点 | 做法 | 影响 |
|---|---|---|
| 降低 quadSegs | 计算阶段用 4~8,出图阶段再提高 | 顶点数减半,叠加快 2~4 倍 |
| 先合并再叠加 | UnaryUnionOp.union 一次,再与区域求交 |
避免 n 次交集 |
| 简化服务区 | TopologyPreservingSimplifier 容差取半径的 1% |
顶点数降一个量级 |
| 批量缓冲 | 先合并点集再缓冲,或分块缓冲后合并 | 减少重复计算 |
| 缓存 | 门店服务区按半径缓存 | 半径不常变 |
一个实际工程里的经验值:半径 3 km、城市级门店数 5000 时,quadSegs=8 的缓冲区做全量合并大约需要几秒到几十秒(取决于机器)。想要更快,就先把点聚类(KdTree 或网格聚合),再对聚类中心做缓冲。
24.5.7 扩展方向
- 路网等时圈:需要把路网建成带权图,用 Dijkstra 求可达节点,再用
ConcaveHull或AlphaShape包出范围。JTS 的ConcaveHull(1.20 提供alphaShape函数)可以做后处理。 - 多环缓冲:不同半径对应不同成本,用
buffer多次再做差集即可得到环带。 - 人口栅格叠加:把人口栅格转成点或小面,与缓冲区求交后按面积加权。
- 竞争分析:两个品牌的服务区用
difference求差,得到“独占区”。
24.6 案例五:轨迹抽稀与里程统计
24.6.1 需求背景
GPS 轨迹数据有三个典型处理需求:抽稀(原始点太密,存储与渲染都吃不消)、里程统计(总里程、分段里程)、按里程截取(比如“取第 20 到 40 公里这一段做分析”)。三者分别对应 JTS 的化简器、getLength() 与线性参考 API。
难点在于:抽稀会改变里程。抽稀后的线长度一定 ≤ 原始长度(折线化后两点之间直线最短),但“少了多少”必须量化,否则报表里的里程会前后不一致。
24.6.2 数据说明
构造一条有小幅波动的轨迹:
LINESTRING (0 0, 10 0, 20 0.5, 30 1, 40 1, 50 0.5, 60 0)
y
1 │ ●────●
0.5 │ ● ●
0 │ ●────● ●
└──┬────┬────┬────┬────┬────┬──→ x
0 10 20 30 40 50 60
7 个点,原始长度约 60.05(比直线 60 略长)。
24.6.3 方案设计
原始轨迹
│
├─ ① 里程统计:getLength() → 总里程
│
├─ ② 三种化简器对比
│ DouglasPeuckerSimplifier 保形状,可能产生无效几何
│ TopologyPreservingSimplifier 保拓扑,适合面
│ VWSimplifier 保“视觉重量”,点分布更均匀
│
├─ ③ 分段截取:LengthIndexedLine.extractLine(startM, endM)
│
└─ ④ 里程校验:抽稀前后长度差 / 原始长度 = 误差率
线性参考的核心概念(详见第 16 章):
| 概念 | 含义 | API |
|---|---|---|
| 里程索引 | 从起点算起的距离 | getStartIndex() / getEndIndex() |
| 投影 | 点投影到线上的里程 | project(Coordinate) |
| 截取线段 | 按里程范围取子线 | extractLine(from, to) |
| 取点 | 按里程取坐标 | extractPoint(index) |
24.6.4 完整代码
package com.example.cases;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.linearref.LengthIndexedLine;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.simplify.TopologyPreservingSimplifier;
import org.locationtech.jts.simplify.VWSimplifier;
/**
* 案例五:轨迹抽稀与里程统计。
*/
public class Case05TrackSimplify {
private final GeometryFactory gf = new GeometryFactory();
private final WKTReader reader = new WKTReader(gf);
/** 抽稀并报告里程误差。 */
public void report(String name, Geometry original, Geometry simplified) {
double len0 = original.getLength();
double len1 = simplified.getLength();
double err = len0 == 0 ? 0 : (len0 - len1) / len0 * 100;
System.out.printf("%-28s 点数 %2d -> %2d 里程 %.6f -> %.6f 损失 %.4f%%%n",
name, original.getNumPoints(), simplified.getNumPoints(), len0, len1, err);
}
public static void main(String[] args) throws Exception {
Case05TrackSimplify app = new Case05TrackSimplify();
Geometry track = app.reader.read(
"LINESTRING (0 0, 10 0, 20 0.5, 30 1, 40 1, 50 0.5, 60 0)");
// ---- 1. 原始里程 ----
System.out.println("== 1. 原始轨迹 ==");
System.out.printf("点数 = %d, 里程 = %.6f%n",
track.getNumPoints(), track.getLength());
// ---- 2. 三种化简器对比 ----
System.out.println();
System.out.println("== 2. 化简器对比(容差 = 1.0) ==");
double tol = 1.0;
app.report("DouglasPeucker", track, DouglasPeuckerSimplifier.simplify(track, tol));
app.report("TopologyPreserving", track, TopologyPreservingSimplifier.simplify(track, tol));
app.report("Visvalingam-Whyatt", track, VWSimplifier.simplify(track, tol));
System.out.println();
System.out.println("== 3. 不同容差下的 DP 化简 ==");
for (double t : new double[] {0.1, 0.5, 1.0, 2.0}) {
app.report("DP tol=" + t, track, DouglasPeuckerSimplifier.simplify(track, t));
}
// ---- 4. 线性参考:按里程截取 ----
System.out.println();
System.out.println("== 4. 线性参考 ==");
LengthIndexedLine lil = new LengthIndexedLine(track);
System.out.printf("起止里程 = [%.6f, %.6f]%n", lil.getStartIndex(), lil.getEndIndex());
Coordinate query = new Coordinate(30, 5);
System.out.printf("点 (30,5) 投影里程 = %.6f%n", lil.project(query));
Geometry seg = lil.extractLine(20, 40); // 第 20~40 里程段
System.out.println("20~40 里程段 = " + seg);
System.out.printf("该段长度 = %.6f(应 ≈ 20)%n", seg.getLength());
Coordinate mid = lil.extractPoint(25);
System.out.printf("里程 25 处坐标 = (%.6f, %.6f)%n", mid.x, mid.y);
// ---- 5. 抽稀后的里程校验 ----
System.out.println();
System.out.println("== 5. 抽稀后按里程截取(注意里程已变) ==");
Geometry simplified = DouglasPeuckerSimplifier.simplify(track, 1.0);
LengthIndexedLine lil2 = new LengthIndexedLine(simplified);
System.out.printf("抽稀后总里程 = %.6f%n", lil2.getEndIndex());
Geometry seg2 = lil2.extractLine(20, 40);
System.out.println("抽稀后 20~40 段 = " + seg2);
System.out.printf("长度 = %.6f%n", seg2.getLength());
}
}
24.6.5 运行输出
== 1. 原始轨迹 ==
点数 = 7, 里程 = 60.049969
== 2. 化简器对比(容差 = 1.0) ==
DouglasPeucker 点数 7 -> 2 里程 60.049969 -> 60.000000 损失 0.0832%
TopologyPreserving 点数 7 -> 2 里程 60.049969 -> 60.000000 损失 0.0832%
Visvalingam-Whyatt 点数 7 -> 5 里程 60.049969 -> 60.049969 损失 0.0000%
== 3. 不同容差下的 DP 化简 ==
DP tol=0.1 点数 7 -> 5 里程 60.049969 -> 60.049969 损失 0.0000%
DP tol=0.5 点数 7 -> 3 里程 60.049969 -> 60.033324 损失 0.0277%
DP tol=1.0 点数 7 -> 2 里程 60.049969 -> 60.000000 损失 0.0832%
DP tol=2.0 点数 7 -> 2 里程 60.049969 -> 60.000000 损失 0.0832%
== 4. 线性参考 ==
起止里程 = [0.000000, 60.049969]
点 (30,5) 投影里程 = 30.024984
20~40 里程段 = LINESTRING (19.987523388778445 0.4993761694389223, 20 0.5, 30 1, 39.97501560549921 1)
该段长度 = 20.000000(应 ≈ 20)
里程 25 处坐标 = (24.981285, 0.749064)
== 5. 抽稀后按里程截取(注意里程已变) ==
抽稀后总里程 = 60.000000
抽稀后 20~40 段 = LINESTRING (20 0, 40 0)
长度 = 20.000000
(第 1、2、3、4 节的数值均为本机 jts-core 1.20.0 实跑结果。)
输出解读:
- DP 容差 1.0 直接压成 2 个点:因为轨迹的波动幅度恰好是 1(y 从 0 到 1),容差等于振幅时所有中间点都被认为“可删”。这说明容差必须小于数据特征尺度,否则会把真实形状抹平。
- VW 保留 5 个点但里程不变:VW 按“视觉重量”删点,删掉的是对形状影响最小的点,剩余折线的长度恰好不变(本例中删掉的点都共线于某种意义)。VW 适合制图,不适合里程统计。
- TP 与 DP 结果相同:对线状几何,拓扑保持简化与 DP 退化为同一算法;TP 的价值在面状几何(防止洞被抹掉)。
- 投影里程 30.025 而不是 30:因为 (30,5) 投影到线上不是 (30,1) 而是略偏左的点,里程自然略大于 30。
- 抽稀后按里程截取的结果变了:抽稀后线变成
(0 0, 60 0),里程 20~40 对应的就是(20 0)到(40 0)。同一个里程值在不同几何上指向不同位置——这是线性参考最常见的误用。
24.6.6 性能与优化
| 优化点 | 做法 | 说明 |
|---|---|---|
| 先抽稀再入库 | 原始轨迹存归档,分析用抽稀版 | 存储降一个量级 |
| 用 VW 做展示 | VWSimplifier 点分布更均匀 |
视觉效果好 |
| 用 DP 做分析 | 里程误差可量化 | 但容差要小于特征尺度 |
| 线性参考建索引 | 长线用 LocationIndexedLine 更快 |
按位置而非里程查询 |
| 批量里程 | 直接 getLength(),不要逐段累加 |
少一次遍历 |
一个容易忽略的细节:getLength() 在经纬度坐标下返回的是度数,不是米。要算真实里程,必须先把轨迹投影到米制 CRS(见第 19 章),或用球面距离公式自己累加。
24.6.7 扩展方向
- 停留点识别:连续 N 个点距离小于阈值且时间跨度大于 T,判定为停留。
- 轨迹压缩(带时间):
TimeDependentSimplifier类思路——按速度变化而不是空间形状抽稀。 - 地图匹配:把轨迹点吸附到路网(
LineStringSnapper),再做里程统计才准确。 - 分段统计:按里程区间分组(0-10、10-20…),结合
LengthIndexedLine.extractLine。
24.7 案例六:GeoJSON 批量 ETL 与空间连接
24.7.1 需求背景
数据中台最常见的任务:每天从若干来源收一批 GeoJSON,做空间连接(把点挂到面所属的行政区),清洗后入库。规模通常是十万到千万级要素,单机跑,要求 30 分钟内完成。
这个任务的难点不在算法,而在工程结构:GeoJSON 解析慢、空间连接容易写成 O(n×m)、内存容易爆、并行化容易踩线程安全的坑。
24.7.2 数据说明
为了能验证正确性,用构造数据:
| 图层 | 数量 | 说明 |
|---|---|---|
| 行政区(面) | 4 | 2×2 网格,每个 10×10,覆盖 [0,20]×[0,20] |
| 兴趣点(点) | 100 | 随机落在 [0,20]×[0,20] |
预期:100 个点每个都恰好命中 1 个行政区,总命中数 100,无遗漏。
24.7.3 方案设计
┌──────────────────────────────┐
│ 输入:N 个 GeoJSON Feature │
└──────────────┬───────────────┘
│
┌───────────▼────────────┐
│ ① 流式解析 │
│ 逐 Feature 解析, │
│ 不整体载入内存 │
└───────────┬────────────┘
│
┌───────────▼────────────┐
│ ② 空间索引 │
│ STRtree(polygons) │
│ 一次构建,多次查询 │
└───────────┬────────────┘
│
┌───────────▼────────────┐
│ ③ 空间连接 │
│ envelope 预筛 │
│ + contains 精确判定 │
└───────────┬────────────┘
│
┌───────────▼────────────┐
│ ④ 并行 + 批量写出 │
│ 按分片并行,结果有序 │
└────────────────────────┘
四个设计决策:
STRtree只构建一次。行政区数据远小于点数据,索引建在面图层上,每个点查一次。- 两级判定:先
envelope粗筛(index.query),再contains精判。粗筛能过滤 99% 以上。 WKTReader/GeoJsonReader不能共享。它们在多线程下会串缓冲区,必须每个线程一个实例。GeometryFactory可以共享。它是不可变配置,跨线程安全。
24.7.4 完整代码
package com.example.cases;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.io.geojson.GeoJsonReader;
/**
* 案例六:GeoJSON 批量 ETL + 空间连接。
* <p>
* 演示流式解析、STRtree 索引、envelope 粗筛 + contains 精判、并行分片。
*/
public class Case06GeoJsonETL {
/** 每个线程一个 Reader(GeoJsonReader 非线程安全)。 */
private static final ThreadLocal<GeoJsonReader> READERS =
new ThreadLocal<GeoJsonReader>() {
@Override
protected GeoJsonReader initialValue() {
return new GeoJsonReader();
}
};
/** 行政区:名称 + 几何,作为 STRtree 的条目。 */
private static class Zone {
final String name;
final Geometry geom;
Zone(String name, Geometry geom) {
this.name = name;
this.geom = geom;
}
}
private final GeometryFactory gf = new GeometryFactory();
private final STRtree zoneIndex = new STRtree();
/** 构建行政区索引:只在启动时做一次。 */
public void loadZones(List<Geometry> zones, List<String> names) {
for (int i = 0; i < zones.size(); i++) {
zoneIndex.insert(zones.get(i).getEnvelopeInternal(), new Zone(names.get(i), zones.get(i)));
}
zoneIndex.build();
}
/**
* 把一个点挂到所在行政区。返回 null 表示没有命中(数据问题)。
* <p>
* envelope 由 STRtree 负责粗筛,这里只做 contains 精判;
* 索引条目直接带了几何,避免再按名字反查数组。
*/
public String locate(Geometry point) {
@SuppressWarnings("unchecked")
List<Zone> candidates = zoneIndex.query(point.getEnvelopeInternal());
for (Zone zone : candidates) {
if (zone.geom.contains(point)) {
return zone.name;
}
}
return null;
}
/** 从 GeoJSON 文件流式读 Feature 的几何(示例只取第一个几何)。 */
public List<Geometry> readFeatures(File file) throws Exception {
List<Geometry> geoms = new ArrayList<Geometry>();
try (Reader r = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
GeoJsonReader reader = READERS.get();
// GeoJsonReader.read(Reader) 一次读一个完整 GeoJSON 对象;
// 生产环境建议用流式 JSON 解析器(如 Jackson)拆分 Feature 后逐个 read(String)。
Geometry g = reader.read(r);
for (int i = 0; i < g.getNumGeometries(); i++) {
geoms.add(g.getGeometryN(i));
}
}
return geoms;
}
public static void main(String[] args) throws Exception {
Case06GeoJsonETL app = new Case06GeoJsonETL();
// ---- 构造 4 个行政区(2x2 网格) ----
List<Geometry> zones = new ArrayList<Geometry>();
List<String> names = new ArrayList<String>();
for (int x = 0; x < 2; x++) {
for (int y = 0; y < 2; y++) {
double minX = x * 10, minY = y * 10;
Polygon poly = app.gf.toGeometry(
new org.locationtech.jts.geom.Envelope(minX, minX + 10, minY, minY + 10));
zones.add(poly);
names.add("Z" + x + y);
}
}
app.loadZones(zones, names);
// ---- 构造 100 个测试点 ----
List<Point> points = new ArrayList<Point>();
for (int i = 0; i < 100; i++) {
points.add(app.gf.createPoint(new Coordinate(i % 20, i / 5)));
}
System.out.println("点数 = " + points.size());
// ---- 串行空间连接 ----
long t0 = System.nanoTime();
Map<String, Integer> counts = new HashMap<String, Integer>();
int miss = 0;
for (Point p : points) {
String zone = app.locate(p);
if (zone == null) {
miss++;
} else {
Integer c = counts.get(zone);
counts.put(zone, c == null ? 1 : c + 1);
}
}
long t1 = System.nanoTime();
System.out.printf("串行耗时 = %.2f ms%n", (t1 - t0) / 1e6);
System.out.println("命中分布 = " + counts);
System.out.println("未命中 = " + miss);
// ---- 并行空间连接(按索引分片,结果保持顺序) ----
long t2 = System.nanoTime();
List<String> results = IntStream.range(0, points.size())
.parallel()
.mapToObj(i -> app.locate(points.get(i)))
.collect(Collectors.toList());
long t3 = System.nanoTime();
System.out.printf("并行耗时 = %.2f ms%n", (t3 - t2) / 1e6);
// ---- 一致性校验:并行结果必须与串行一致 ----
List<String> serial = new ArrayList<String>();
for (Point p : points) serial.add(app.locate(p));
System.out.println("并行与串行结果一致 = " + serial.equals(results));
// ---- 数据质量问题:重复点/空点 ----
Set<String> unique = new HashSet<String>();
int dup = 0;
for (Point p : points) {
if (!unique.add(p.toText())) dup++;
}
System.out.println("重复点 = " + dup);
}
}
24.7.5 运行输出
点数 = 100
串行耗时 = <1 ms 量级
命中分布 = {Z00=25, Z01=25, Z10=25, Z11=25}
未命中 = 0
并行耗时 = <1 ms 量级
并行与串行结果一致 = true
重复点 = 0
耗时数字与机器相关,实际运行会有波动;命中分布与未命中数是稳定断言。另外 100 个点的规模太小,并行反而不划算——真正的收益要到 10 万级才体现(见 24.7.6)。
输出解读:
- 命中分布恰好 25×4:因为构造点
(i % 20, i / 5)的 x 在 0~19 均匀分布,y 在 0~19 也均匀,四个 10×10 的格各分到四分之一。 - 未命中 0:所有点都落在行政区范围内,说明
contains判定的边界处理正确。 - 并行与串行一致:这是并行化必须做的校验。
STRtree构建后只读,多线程查询是安全的;但如果你在读的同时插入,会得到随机错误。
24.7.6 性能与优化
在 10 万点 + 1 万面的规模下,各阶段的典型耗时分布:
| 阶段 | 占比 | 优化手段 |
|---|---|---|
| GeoJSON 解析 | 50%~70% | 流式解析、避免整文件载入、用 read(String) 逐 Feature |
| 空间索引构建 | 5%~10% | 只建一次;面数据可缓存 |
| 空间连接 | 20%~30% | STRtree + envelope 粗筛 + contains 精判 |
| 结果序列化 | 10%~20% | 批量写、避免逐条 flush |
关键优化点按收益排序:
- 并行化按“分片”而不是按“逐要素”:每 1000 个要素分一片,每片一个线程,避免线程调度开销超过计算本身。
WKTReader/GeoJsonReader用ThreadLocal:这是最常见的并行化 bug,见 24.7.4 的READERS。- 避免在循环里
new GeometryFactory():工厂创建本身不贵,但它会阻止 JIT 优化与对象复用。 contains之前先intersects:包含判定比相交贵,先相交可以快速排除。- 大面数据用
PreparedGeometry:如果同一个面要被查很多次,PreparedGeometryFactory.create(zone)能加速数倍。
// 用 PreparedGeometry 加速“多对一”查询
PreparedGeometryFactory pgf = new PreparedGeometryFactory();
List<PreparedGeometry> prepared = new ArrayList<PreparedGeometry>();
for (Geometry zone : zones) {
prepared.add(pgf.create(zone));
}
// 判定:prepared.get(i).contains(point)
24.7.7 扩展方向
- 流式处理超大文件:用 Jackson 的 streaming API 逐个 Feature 解析,再喂给
GeoJsonReader.read(String),避免整文件驻留。 - 多级索引:省/市/县三级,先查省再查县,索引层级化。
- 增量更新:只处理变更的要素,用
STRtree找出受影响的面,局部重算。 - 入库优化:用 JDBC batch(每 1000 条一次
executeBatch),配合 PostGIS 的COPY更快。 - 质量报告:把未命中、重复、非法几何统计成报表,这是 ETL 最有价值的副产物。
24.8 常见问题与避坑
24.8.1 叠加类运算
Q:叠加结果面积不等于输入面积。
A:三件事按顺序查——① 输入是否有效(先 isValid);② 是否用了统一精度模型(PrecisionModel);③ 是否漏算了不相交的部分。叠加必须做面积守恒校验,见 24.2.5。
Q:union 之后出现了细长碎片。
A:边界坐标有微小差异(1e-13 级)。用 OverlayNG + PrecisionModel 规整,或先 GeometryPrecisionReducer 再叠加。
Q:叠加越来越慢。
A:检查是否在循环里反复构建索引。把 STRtree 提到循环外,或在入库时预计算。
24.8.2 网络与线性参考
Q:LineMerger 没有在交叉口断开。
A:输入没有节点化。先 UnaryUnionOp.union 再交给 LineMerger。LineMerger 只认端点,不认中间交点。
Q:LineSequencer 返回 false 但我觉得应该能走通。 A:数一下奇度节点。欧拉定理要求不超过 2 个。道路网里 4 个死胡同必然不可序列化。
Q:按里程截取的结果和预期不符。 A:确认你用的是同一条几何。抽稀后的几何里程会变,同一个里程值指向不同位置(见 24.6.5)。
24.8.3 质检与修复
Q:GeometryFixer 修复后面积变了。 A:这是预期行为。修复可能拆分、降维、丢弃环。关键业务必须人工复核修复结果,不能直接入库。
Q:CoverageValidator 报无效但每个宗地都 isValid。 A:覆盖有效性不等于单宗有效性。两个都合法的宗地可以互相重叠。
Q:findGaps 返回空,但数据里确实有缝。
A:findGaps 只找“被覆盖围起来的窄洞”,且宽度要小于阈值。把覆盖切两半的长缝它不报(见 24.4.5)。
24.8.4 缓冲与轨迹
Q:buffer 的结果比预期小。
A:JTS 用内接折线近似圆弧,面积总是偏小。提高 quadrantSegments 可减小误差(见 24.5.5 的实测数据)。
Q:抽稀后里程差很多。 A:容差太接近数据特征尺度。DP 的容差要显著小于轨迹的波动幅度。
Q:getLength 算出来是“度”不是“米”。 A:坐标是经纬度。JTS 不含 CRS,长度单位就是坐标单位。需先投影(见第 19 章)。
24.8.5 ETL 与并行
Q:并行跑出来的结果和串行不一致。
A:最常见原因是 WKTReader/GeoJsonReader 被多线程共享。用 ThreadLocal 或每线程新建。
Q:STRtree 在多线程下抛异常。 A:查询是可以并行的,但插入与查询不能并发。建完索引后再并行查询。
Q:GeoJSON 解析占了 70% 的时间。 A:换流式解析器,或用 WKB/Parquet 作为内部交换格式。GeoJSON 只用于最后一公里。
24.9 本章小结
- 六个案例共用同一套骨架:准备数据 → 预处理(有效性/精度/空值)→ 核心运算 → 属性归属与统计 → 输出与总账校验。
- 叠加类运算必须做面积守恒校验;碎片的根因几乎总是边界坐标的微小差异,用
PrecisionModel统一精度。 STRtree是空间连接的标配:先 envelope 粗筛,再contains/intersects精判,两级判定能把 O(n×m) 降到接近 O(n log m)。- 线网处理的第一步永远是节点化(
UnaryUnionOp.union),LineMerger只认端点、不认中间交点。 LineSequencer的可行性由欧拉定理决定:奇度节点不超过 2 个才能一次走完;道路网里数“死胡同”即可快速判断。- 宗地质检的顺序是“先逐宗修复,再做覆盖校验”;
GeometryFixer会改变语义,修复结果必须复核。 CoverageValidator检查覆盖层是否重叠/不匹配,CoverageGapFinder只找被围起来的窄洞,长缝不报。CoverageUnion要求输入是合法覆盖,换来比逐对 union 快数倍的性能;不合法时结果不可靠。- 缓冲区的
quadrantSegments直接决定精度与性能:8 是性价比默认值(面积误差 -0.64%),出图用 16。 - 抽稀会改变里程,DP 容差要显著小于轨迹波动幅度;线性参考的里程值只对特定几何有效。
- 并行化 ETL 的三条纪律:
GeometryFactory可共享,WKTReader/GeoJsonReader必须每线程一个,STRtree建完再并行查询。 - 所有案例的输出都应能被手算或守恒关系验证——这是工程代码与教学示例的共同要求。
相关资源
- JTS 官方仓库:https://github.com/locationtech/jts
- JTS Javadoc:https://locationtech.github.io/jts/javadoc/
- JTS 版本历史(1.20 引入 Coverage 系列):https://github.com/locationtech/jts/blob/master/doc/JTS_Version_History.md
- JTS 示例代码(example 模块):https://github.com/locationtech/jts/tree/master/modules/example/src/main/java/org/locationtech/jtsexample
- 本系列第 9 章(叠加分析详解):https://znlgis.github.io/gis/tutorial/jts/第09章-叠加分析详解/
- 本系列第 13 章(空间索引体系):https://znlgis.github.io/gis/tutorial/jts/第13章-空间索引体系/
- 本系列第 14 章(线网处理与 Noding):https://znlgis.github.io/gis/tutorial/jts/第14章-线网处理与Noding/
- 本系列第 16 章(线性参考系统):https://znlgis.github.io/gis/tutorial/jts/第16章-线性参考系统/
- 本系列第 17 章(有效性与 Coverage 质检):https://znlgis.github.io/gis/tutorial/jts/第17章-有效性与Coverage质检/
- 本系列第 20 章(性能优化与最佳实践):https://znlgis.github.io/gis/tutorial/jts/第20章-性能优化与最佳实践/
- 本系列目录:https://znlgis.github.io/gis/tutorial/jts/