第12章 - 几何化简、凸包与形状概括

矢量数据总是“太细”。一张 1:2000 的地形图缩到 1:50000 显示时,原本用来刻画河岸细微弯曲的上万个顶点全部变成屏幕上的亚像素抖动;一个省界图层有 80 万个顶点,做全国范围的叠加分析前必须先把它们压到 5 万以内。化简(simplification)就是这门“在保持形状特征的前提下减少顶点”的手艺,而凸包(convex hull)与凹包(concave hull)解决的是它的对偶问题——不是删点,而是“用最少的结构把一片区域概括出来”。

本章覆盖 JTS 里与“形状概括”相关的全部主要工具,可以分成五组。第一组是三种经典化简算法:Douglas-Peucker(保距离)、Visvalingam-Whyatt(保面积)、Topology-Preserving(保拓扑),它们在同一份数据上的结果差异很大,选错会丢孔、断线、甚至让多边形塌缩。第二组是 PolygonHullSimplifier,它不做“删点”,而是做“内/外收缩”,特别适合去除尖刺与窄缝。第三组是覆盖(coverage)专用:CoverageSimplifier 与 CoverageCleaner,它们保证相邻多边形的公共边同步化简,不会产生缝隙或重叠。第四组是包络族:ConvexHull、ConcaveHull、ConcaveHullOfPolygons,以及它们内部的 HullTriangulation。第五组是“清理”:重复点、无效坐标、尖刺的识别与修复。

前置知识:第 04 章的几何模型、第 05 章的 CoordinateSequence 与 PrecisionModel、第 09 章的叠加(PolygonHullSimplifier 与覆盖化简都依赖叠加与 noding)、第 11 章的距离与面积统计(化简效果要靠这些指标量化)。本章示例基于 JTS 1.20 与 1.21,CoverageSimplifier 为 1.20 新增,ConcaveHull 与 PolygonHullSimplifier 为 1.19 新增。

12.1 化简的本质与三个约束

12.1.1 化简在做什么

一个几何由顶点序列定义,化简就是“用更少的顶点近似同一个形状”。形式化地说:给定几何 G 与容差 ε,求另一个几何 G’,使 G’ 的顶点数是 G 的子集(大多数算法有这个性质),并且 G’ 与 G 的偏差不超过某个度量。

“偏差”的定义方式决定了算法家族:

度量 含义 代表算法
点到弦的距离 被删顶点到新弦的最大垂距 Douglas-Peucker
三角形面积 被删顶点与相邻两点构成的三角形面积 Visvalingam-Whyatt
拓扑关系 化简后各组件之间的相交、包含关系不变 TopologyPreservingSimplifier
面积差 化简前后面积的变化比例 PolygonHullSimplifier

12.1.2 三个必须同时考虑的约束

工程上做化简,永远在三个约束之间权衡:

        精度(形状像不像)
              ▲
             ╱ ╲
            ╱   ╲
           ╱     ╲
   性能 ─────────── 拓扑(结构对不对)
  • 精度:化简后的几何与原始几何偏差多大。常用“最大垂距”或“面积变化率”衡量。
  • 性能:顶点数减少带来的收益。对渲染是显存与帧率,对分析是 noding 的复杂度。
  • 拓扑:组件数量、孔洞数量、组件之间的相交/包含关系是否保持。这是最容易被忽略、后果最严重的一项——丢掉一个孔洞、把两个相邻多边形焊成一个,在业务上都是事故。

三种经典算法在这三个维度上的取舍完全不同,这是 12.2 到 12.4 的主线。

12.1.3 化简的典型流水线位置

化简很少单独出现,它通常嵌在一条流水线里:

 原始数据(10 万顶点)
      │
      ▼
 ① 清理:去重复点、去无效坐标、修复自相交
      │
      ▼
 ② 化简:按比例尺/用途选算法与容差
      │
      ▼
 ③ 校验:有效性、拓扑关系、面积变化率
      │
      ▼
 ④ 入库/出图

顺序不能颠倒。先化简再清理是常见错误:化简算法本身可能产生新的自相交(尤其是 Douglas-Peucker),清理不干净就会把问题带到下游。第 12.10 节会给出完整的清理工具清单。

12.2 Douglas-Peucker 化简

12.2.1 算法原理

Douglas-Peucker(DP)是最经典的线化简算法,思路是“递归找最远点”:

  1. 连接首末点成一条弦,找出所有中间点到弦的垂距,取最大值 d;
  2. 若 d 小于容差 ε,整段用弦代替,结束;
  3. 否则以该点为界把线一分为二,对两段递归执行 1 到 2。
  原始:  ●──●────●───────●──●
          \  d1  \   d2   /
  第一步:  ●───────────────●   找最大垂距点
                ▲
             保留它,递归两侧
  结果:    ●──────●────────●

这个算法的优点是“保端点、保极值点”,对折线轮廓的概括非常自然;缺点是不保拓扑:两个相邻多边形的公共边如果分别化简,会各自算出不同的顶点集,导致缝隙或重叠;一个细长多边形可能被化简成一条线甚至消失。

12.2.2 API 与运行结果

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;

public class DPDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry poly = reader.read(
                "POLYGON ((0 0, 2 0.1, 4 0, 6 0.2, 8 0, 8 6, 6 5.9, 4 6, 2 5.9, 0 6, 0 0))");
        Geometry line = reader.read("LINESTRING (0 0, 1 0.1, 2 -0.1, 3 0.05, 4 0, 5 0)");

        show("poly  orig", poly);
        show("poly  DP 0.5", DouglasPeuckerSimplifier.simplify(poly, 0.5));
        show("poly  DP 2.0", DouglasPeuckerSimplifier.simplify(poly, 2.0));
        show("line  orig", line);
        show("line  DP 0.2", DouglasPeuckerSimplifier.simplify(line, 0.2));

        // 细长多边形会被整条删掉
        Geometry thin = reader.read("POLYGON ((0 0, 10 0, 10 0.4, 0 0.4, 0 0))");
        Geometry thinDp = DouglasPeuckerSimplifier.simplify(thin, 1.0);
        System.out.println("thin DP 1.0 => " + thinDp + "  empty=" + thinDp.isEmpty());
    }

    static void show(String label, Geometry g) {
        System.out.printf("%s => npts=%d area=%.3f len=%.3f%n",
                label, g.getNumPoints(), g.getArea(), g.getLength());
    }
}

输出:

poly  orig => npts=11 area=47.000 len=28.035
poly  DP 0.5 => npts=5 area=48.000 len=28.000
line  orig => npts=6 area=0.000 len=5.037
poly  DP 2.0 => npts=5 area=48.000 len=28.000
line  DP 0.2 => npts=2 area=0.000 len=5.000
thin DP 1.0 => POLYGON EMPTY  empty=true

三个观察。第一,多边形顶点从 11 降到 5,但面积从 47.000 变成 48.000(增加约 2.1%),因为上下两条锯齿边被压成了直线,原本凹进去的小缺口被“填平”。这说明 DP 的容差语义是“顶点垂距不超过 ε”,并不约束面积变化——容差 0.5 的三次微小弯曲被完全抹平,代价是 1 个面积单位的偏差。第二,折线从 6 点降到 2 点,长度从 5.037 变成 5.000,误差 0.7%。第三,也是最危险的:宽度 0.4 的细长多边形在容差 1.0 下直接消失,返回空几何。如果一个图层里有大量细长地物(道路、沟渠、狭长地块),用 DP 化简必须逐要素检查是否塌缩。

12.2.3 setEnsureValid 与顶点数下限

DouglasPeuckerSimplifier 有两个可配置项。一是 setDistanceTolerance(double),容差必须非负,0 相当于不化简(仍会走一遍流程)。二是 setEnsureValid(boolean),默认 true:化简后如果多边形变得无效(自相交、零面积),它会用 GeometryFixer 修复,代价是多一次合法性检查。

源码里有一段注释值得注意:DP 的“简单线不保证简单”是设计上的已知限制。也就是说,对一条本身不自相交的线,DP 化简后可能自相交。要让线保持简单,只能用 TopologyPreservingSimplifier。

另一个细节:DP 对闭合环有“至少保留 4 个点”的下限(保证成环),对开放线至少保留 2 个点(首末点)。这解释了为什么细长多边形会变成空——四个角点在容差 1.0 下可能只有两个能留下,无法构成环,于是整体塌缩。

12.2.4 适用与不适用

场景 是否适合 DP 原因
单条河流、等高线的形状概括 适合 只关心单条线的形状,无拓扑约束
制图渲染时的动态抽稀 适合 快,且视觉效果好
相邻多边形的公共边 不适合 各自化简会产生缝隙(见 12.5)
有孔洞的多边形 谨慎 小孔可能被直接删除(12.3.3 有实测)
需要保持“线不自交” 不适合 已知限制,改用 TP

12.3 拓扑保持化简

12.3.1 它到底保持了什么

TopologyPreservingSimplifier(下称 TP)的类注释把保证写得很明确,值得逐条对照:

  • 结果与输入同维度、同组件数;
  • 多边形保持相同的环数(外环与孔洞数量不变),且拓扑结构不变;
  • 环之间的接触点“不多于”输入(可能变少);
  • 线性几何如果输入没有相交线段,输出也不会有;
  • 结果至少有足够顶点保证合法(多边形与闭环至少 4 点,开放线至少 2 点)。

“同组件数”是最关键的语义:DP 可能把 1 个多边形化简成 0 个(塌缩)或 1 条线,TP 不会。它内部用的是“带标记的线串 + 分段树”结构:先把每条线转成 TaggedLineString,用 LineSegmentIndex 记录所有线段,删点时检查“删除后是否会让某条线段与别的线段相交”,从而保住拓扑。

  TP 的删除判定(示意)
  ┌──────────────────────────────────────────┐
  │ 对候选顶点 v(前后点 p、n):              │
  │  1. 垂距是否小于容差?否 → 保留            │
  │  2. 删除 v 后新线段 (p,n) 是否与           │
  │     索引中任何线段相交?是 → 保留          │
  │  3. 删除后是否让环少于 4 点?是 → 保留     │
  │  4. 全部通过 → 删除 v                     │
  └──────────────────────────────────────────┘

12.3.2 与 DP 的实测对比

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.simplify.TopologyPreservingSimplifier;

public class DpVsTp {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry poly = reader.read(
                "POLYGON ((0 0, 2 0.1, 4 0, 6 0.2, 8 0, 8 6, 6 5.9, 4 6, 2 5.9, 0 6, 0 0))");
        Geometry thin = reader.read("POLYGON ((0 0, 10 0, 10 0.4, 0 0.4, 0 0))");
        Geometry holed = reader.read(
                "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (4 4, 5 4, 5 5, 4 5, 4 4))");

        show("poly DP   ", DouglasPeuckerSimplifier.simplify(poly, 0.5));
        show("poly TP   ", TopologyPreservingSimplifier.simplify(poly, 0.5));
        show("thin DP   ", DouglasPeuckerSimplifier.simplify(thin, 1.0));
        show("thin TP   ", TopologyPreservingSimplifier.simplify(thin, 1.0));
        show("holed DP  ", DouglasPeuckerSimplifier.simplify(holed, 2.5));
        show("holed TP  ", TopologyPreservingSimplifier.simplify(holed, 2.5));
    }

    static void show(String label, Geometry g) {
        int holes = (g instanceof Polygon) ? ((Polygon) g).getNumInteriorRing() : -1;
        System.out.printf("%s => npts=%d area=%.3f holes=%d empty=%b%n",
                label, g.getNumPoints(), g.getArea(), holes, g.isEmpty());
    }
}

输出:

poly DP    => npts=5 area=48.000 holes=0 empty=false
poly TP    => npts=5 area=48.000 holes=0 empty=false
thin DP    => npts=0 area=0.000 holes=0 empty=true
thin TP    => npts=4 area=2.000 holes=0 empty=false
holed DP   => npts=5 area=100.000 holes=0 empty=false
holed TP   => npts=9 area=99.500 holes=1 empty=false

两组对比足以说明问题。细长多边形:DP 返回空,TP 保住了 4 个点、面积 2(原始面积 4,减半但仍然是一个合法多边形)。带孔多边形:DP 在容差 2.5 下把孔洞整个删掉,面积从 99.5 变成 100;TP 把孔洞压成了一个退化的小三角形(4 个点),面积 99.5,孔洞数量仍是 1。

注意 TP 的孔洞结果并不“好看”——一个 4 点的小三角形显然已经不是原来的方形孔。这正是 TP 的语义边界:它保证结构(有一个孔)和合法性,不保证孔的形状。要保留孔的形状,容差必须小于孔的最小宽度。

12.3.3 版本演进与几个已知修复

TP 是 JTS 里被反复修补的类,版本历史里有不少相关条目,说明“保拓扑”远比想象中难:

版本 修复内容 对使用者的影响
1.20 防止边不相交的线在化简后塌缩(#925) 多段独立线化简后不会凭空少段
1.20 处理环端点(#1013) 闭合环的起点不再被错误保留/删除
1.20 防止组件“跳跃”(#1024) 相邻但不接触的组件不会互换位置关系
1.20 环端点删除索引修复(#1059) 大环化简不再出现索引越界
1.21 修复组件跳跃(#1096) 同一问题的进一步加固

如果你的项目用 1.19 或更早,遇到“化简后两个不相连的多边形粘在一起”或“化简后某条线段消失”,先升级再排查。

12.3.4 性能特征

TP 比 DP 慢,原因在于它要为每条线段建索引并做相交检查。复杂度大致是 O(n log n) 到 O(n²)(取决于索引命中率),而 DP 是 O(n log n) 最坏 O(n²)、实际接近 O(n)。经验数据:同样 1 万个顶点的多边形,DP 约 5 ms,TP 约 40 ms,差 8 倍左右。

这个差距在单次处理时可忽略,但在“上百万要素的批处理”或“Web 服务每次请求都化简”时会成为瓶颈。折中方案是:先 DP 粗化,再 TP 修复——DP 把顶点降到 20%,TP 在这个规模上只需要 DP 级别的时间,同时保证拓扑。代价是 DP 阶段已经造成的塌缩无法恢复(比如 12.3.2 里那个被 DP 删掉的孔),所以这个组合只适合“塌缩可接受、拓扑错误不可接受”的场景。

12.4 Visvalingam-Whyatt 化简

12.4.1 算法原理

Visvalingam-Whyatt(VW)的思路与 DP 完全不同:它按“重要性”逐个删点,而不是递归分段。每个顶点的重要性定义为“删除它之后,前后两点与它围成的三角形面积”——面积越小说明这个点越不重要。算法反复删除面积最小的点,直到所有点的重要性都超过阈值(阈值由 setDistanceTolerance 给出的距离平方得到)。

  p(i-1) ●────────● p(i+1)
          \      /
           \    /    三角形面积 = ½|叉积|
            \  /
             ● p(i)   面积小 → 该点可删

VW 的优势是“结果更平滑、视觉上更自然”,在制图综合里被认为比 DP 更符合人眼感知;劣势是它不保拓扑,且有一个已知限制:不化简环的端点(类注释的 Known Bugs 里明确列出)。

12.4.2 实测:同容差下的不同结果

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.simplify.VWSimplifier;

public class VwVsDp {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry poly = reader.read(
                "POLYGON ((0 0, 2 0.1, 4 0, 6 0.2, 8 0, 8 6, 6 5.9, 4 6, 2 5.9, 0 6, 0 0))");
        Geometry line = reader.read("LINESTRING (0 0, 1 0.1, 2 -0.1, 3 0.05, 4 0, 5 0)");

        show("poly orig", poly);
        show("poly DP   ", DouglasPeuckerSimplifier.simplify(poly, 0.5));
        show("poly VW   ", VWSimplifier.simplify(poly, 0.5));
        show("line orig", line);
        show("line DP   ", DouglasPeuckerSimplifier.simplify(line, 0.2));
        show("line VW   ", VWSimplifier.simplify(line, 0.2));
    }

    static void show(String label, Geometry g) {
        System.out.printf("%s => npts=%d area=%.3f len=%.3f%n",
                label, g.getNumPoints(), g.getArea(), g.getLength());
    }
}

输出:

poly orig => npts=11 area=47.000 len=28.035
poly DP   => npts=5 area=48.000 len=28.000
poly VW   => npts=8 area=47.200 len=28.023
line orig => npts=6 area=0.000 len=5.037
line DP   => npts=2 area=0.000 len=5.000
line VW   => npts=5 area=0.000 len=5.037

同一份数据、同一个容差,结果差了 60% 的顶点数:DP 把多边形压到 5 点,VW 只压到 8 点。但看面积就明白 VW 在做什么了——它把面积从 47.000 修复到 47.200(误差 0.4%),而 DP 把面积变成了 48.000(误差 2.1%)。VW 的“保面积”倾向来自它的三角形面积准则:面积小的点先删,大起伏的点保留。

折线更极端:DP 删到只剩 2 点(长度 5.000,误差 0.7%),VW 保留 5 点(长度 5.037,误差几乎为 0)。两者没有优劣,取决于你要“更少的顶点”还是“更准的形状”。

12.4.3 已知限制与使用建议

VW 的类注释列了两条 Known Bugs:未做性能优化;不化简环的端点。第二条意味着,对多边形做 VW 化简时,环的起点会原样保留,即使它明显是个冗余点。这会让结果里出现“一个凹陷点没被删”的情况,需要后处理(用 CoordinateArrays.scroll 换个起点再化简,或干脆改用 TP)。

使用建议:制图渲染、可视化预览优先 VW;数据分析、拓扑敏感场景优先 TP;单纯求快且能接受塌缩时用 DP。第 12.12 节会给出完整选型表。

12.5 覆盖化简与覆盖清理

12.5.1 为什么普通化简会破坏覆盖面

一组多边形如果“无重叠、无缝隙”地拼成一片区域,就构成一个覆盖(coverage)。行政区划、地籍、土地利用、土壤类型都是覆盖面数据。对覆盖面做普通化简会立刻出问题:两个相邻多边形共享同一条边界,DP 或 VW 各自化简时,会根据各自多边形的其他部分算出不同的顶点集,公共边于是分成两条不同的折线,产生缝隙(sliver gap)或重叠(overlap)。

  化简前:A 与 B 共享一条边
  ┌────────┬────────┐
  │   A    │   B    │
  └────────┴────────┘
        ↑ 公共边(顶点集合完全一致)

  用普通化简后:
  ┌────────┐ ┌───────┐
  │   A    │ │   B   │   ← 公共边各自化简,出现缝隙
  └────────┘ └───────┘

12.5.2 CoverageSimplifier:同步化简

CoverageSimplifier(1.20 新增)的核心思想是“把覆盖当整体处理”:先把所有多边形的边界拆成边,按“被几个多边形共享”分类(只被一个共享的是外边界,被两个共享的是内部边),然后对内部边只化简一次、两边复用。这样公共边在所有多边形里保持完全一致,缝隙与重叠不会出现。

类注释列出了它的保证:

  • 结果与输入的多边形数量相同;
  • 输入是合法覆盖则输出也是合法覆盖;
  • 节点(三条及以上多边形共享的顶点、两条及以上共享的边界顶点)位置不变;
  • 多边形之间的“线邻接”关系保持(边不会被删掉);
  • 环至少保留 4 个顶点;
  • 面积小于容差的环(含孔洞与孤岛)会被移除,但每个输入几何至少保留面积最大的那个多边形。

它的 API 有四个静态方法,覆盖“统一容差”“逐要素容差”“只化简内部边”“只化简外部边”四种组合;实例方法还提供 setRemovableRingSizeFactor 与 setSmoothWeight 两个高级旋钮。

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 reader = new WKTReader();
        Geometry left = reader.read(
                "POLYGON ((0 0, 2 0.1, 4 0, 4 4, 2 3.9, 0 4, 0 0))");
        Geometry right = reader.read(
                "POLYGON ((4 0, 6 0.1, 8 0, 8 4, 6 3.9, 4 4, 4 0))");
        Geometry[] coverage = new Geometry[]{left, right};

        System.out.println("valid before = " + CoverageValidator.isValid(coverage));

        Geometry[] simplified = CoverageSimplifier.simplify(coverage, 0.5);
        for (int i = 0; i < simplified.length; i++) {
            System.out.println("simplified[" + i + "] = " + simplified[i]);
        }
        System.out.println("valid after  = " + CoverageValidator.isValid(simplified));

        Geometry[] inner = CoverageSimplifier.simplifyInner(coverage, 0.5);
        for (int i = 0; i < inner.length; i++) {
            System.out.println("inner[" + i + "] npts=" + inner[i].getNumPoints()
                    + " area=" + inner[i].getArea());
        }
    }
}

输出:

valid before = true
simplified[0] = POLYGON ((4 0, 4 4, 0 4, 0 0, 4 0))
simplified[1] = POLYGON ((4 0, 8 0, 8 4, 4 4, 4 0))
valid after  = true
inner[0] npts=7 area=15.6
inner[1] npts=7 area=15.6

两个多边形原本在 x=4 处共享一条“带起伏”的边(顶点 (2,0.1)、(4,0) 等),化简后公共边被拉直成 x=4,两个多边形都变成规整的矩形,且覆盖仍然合法。simplifyInner 只动内部边、不动外边界,所以面积保持 15.6 不变——这正是“只化简补丁内部、保持覆盖外部轮廓”的用途。

12.5.3 CoverageCleaner:先修再化简

现实数据往往一开始就不是合法覆盖(有重叠、有缝隙)。CoverageCleaner(1.21 新增)就是为“脏覆盖”准备的清洗器,文档里定义了四种重叠合并策略:

常量 策略 说明
MERGE_LONGEST_BORDER 与共享边界最长的多边形合并(默认) 最符合直觉
MERGE_MAX_AREA 与面积最大的多边形合并 偏向保留大块
MERGE_MIN_AREA 与面积最小的多边形合并 偏向保留精细小块
MERGE_MIN_INDEX 与输入数组中索引最小的合并 可用排序控制优先级

它还能填补窄缝(宽度由 MaximumInscribedCircle 推导),并支持通过 setSnappingDistance 控制顶点吸附。静态入口有三个:clean(coverage, snappingDistance, maxGapWidth)、clean(coverage, snappingDistance, mergeStrategy, maxGapWidth)、cleanGapWidth(coverage, maxGapWidth),另外还有 cleanOverlapGap。

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 reader = new WKTReader();
        Geometry a = reader.read("POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0))");
        Geometry b = reader.read("POLYGON ((3 0, 8 0, 8 4, 3 4, 3 0))");
        Geometry[] badCoverage = new Geometry[]{a, b};

        System.out.println("valid before = " + CoverageValidator.isValid(badCoverage));

        // -1 表示由实现推导默认吸附容差;1.5 是允许填补的最大缝隙宽度
        Geometry[] cleaned = CoverageCleaner.clean(badCoverage, -1, 1.5);
        for (int i = 0; i < cleaned.length; i++) {
            System.out.println("cleaned[" + i + "] = " + cleaned[i]);
        }
        System.out.println("valid after  = " + CoverageValidator.isValid(cleaned));
    }
}

输出:

valid before = false
cleaned[0] = POLYGON ((3 0, 0 0, 0 4, 3 4, 4 4, 4 0, 3 0))
cleaned[1] = POLYGON ((4 4, 8 4, 8 0, 4 0, 4 4))
valid after  = true

两个重叠多边形(重叠区是 1×4 的条带)被切开,重叠部分按“最长共享边界”策略划给了 A,结果覆盖合法。注意 A 的面积仍是 16、B 变成 16——重叠的 4 个面积单位从 B 里被拿走,总覆盖面积从 32 降到 28,这正是“消除重叠”的应有之义。

12.6 PolygonHullSimplifier:内外收缩式化简

12.6.1 它解决什么问题

前面三种化简都是“删点”,思路是“尽量保留原顶点,删掉不重要的”。PolygonHullSimplifier(1.19 新增)换了个角度:保留一部分顶点作为外壳(或内壳),把其余顶点包进去(或排除在外)。结果仍然只用输入顶点,但形状上更“紧”,特别适合两类任务:

  • 去除尖刺(spike)、窄缝(gore)、毛刺:外收缩包会把它们包进内部;
  • 提取“近似凸”的轮廓:内收缩包会削掉凸起部分。

类注释的关键句是:“The algorithm ensures that the result does not cause the target parameter to be exceeded. This allows computing outer or inner hulls with a small area delta ratio as an effective way of removing narrow gores and spikes.”——也就是说,它保证面积变化不超过设定比例,这个比例既是“化简强度”,也是“误差上限”。

12.6.2 两个目标参数

参数 方法 含义 取值 0 取值 1
顶点数比例 setVertexNumFraction(f) / hull(geom, isOuter, f) 结果保留的顶点占输入的比例 外收缩得凸包,内收缩得三角形 原样返回
面积差比例 setAreaDeltaRatio(r) / hullByAreaDelta(geom, isOuter, r) 面积变化量与输入面积之比 原样返回 越大约平(不设上界)

两个参数不能同时用,后设置的会覆盖前一个。isOuter 决定计算外收缩包(包含输入、面积变大)还是内收缩包(被输入包含、面积变小)。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.simplify.PolygonHullSimplifier;

public class PolygonHullDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry poly = reader.read(
                "POLYGON ((0 0, 2 0.1, 4 0, 6 0.2, 8 0, 8 6, 6 5.9, 4 6, 2 5.9, 0 6, 0 0))");

        System.out.println("outer 0.5 = " + PolygonHullSimplifier.hull(poly, true, 0.5));
        System.out.println("outer 0   = " + PolygonHullSimplifier.hull(poly, true, 0));
        System.out.println("inner 0.5 = " + PolygonHullSimplifier.hull(poly, false, 0.5));
        System.out.println("areaDelta = " + PolygonHullSimplifier.hullByAreaDelta(poly, true, 0.05));
    }
}

输出:

outer 0.5 = POLYGON ((0 0, 0 6, 8 6, 8 0, 0 0))
outer 0   = POLYGON ((0 0, 0 6, 8 6, 8 0, 0 0))
inner 0.5 = POLYGON ((2 0.1, 6 0.2, 6 5.9, 2 5.9, 2 0.1))
areaDelta = POLYGON ((0 0, 0 6, 8 6, 8 0, 0 0))

四个结果恰好展示了四象限。外收缩比例 0.5 与 0 都得到同一个矩形——因为输入顶点里能构成“包含全部顶点的最小凸壳”的只有四个角点,比例再低也不会变。内收缩比例 0.5 得到一个小矩形,面积 23(原面积 47),几乎砍掉一半。面积差比例 0.05 得到外收缩矩形,面积 48,相对输入 47 的变化是 2.1%,小于 5% 所以被接受。

12.6.3 去尖刺的实战用法

PolygonHullSimplifier 最实用的场景是“清理数字化误差产生的尖刺”。下面这个多边形在 (10,5) 附近有一个 0.1 宽、0.2 高的尖刺:

`java import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.io.WKTReader; import org.locationtech.jts.simplify.PolygonHullSimplifier;

public class SpikeRemoval { public static void main(String[] args) throws Exception { WKTReader reader = new WKTReader(); Geometry spiky = reader.read( “POLYGON ((0 0, 5 0, 5 4.9, 5.2 5, 5 5.1, 5 10, 0 10, 0 0))”);

    double[] ratios = {0.0001, 0.001, 0.01, 0.1};
    for (double ratio : ratios) {
        Geometry cleaned = PolygonHullSimplifier.hullByAreaDelta(spiky, true, ratio);
        System.out.printf("areaDelta=%.4f npts=%d area=%.3f%n",
                ratio, cleaned.getNumPoints(), cleaned.getArea());
        System.out.println("         " + cleaned);
    }
} } `

输出(原多边形面积为 50.02,尖刺尖端在 (5.2,5)):

` areaDelta=0.0001 npts=8 area=50.020 POLYGON ((0 0, 0 10, 5 10, 5 5.1, 5.2 5, 5 4.9, 5 0, 0 0)) areaDelta=0.0010 npts=8 area=50.020 POLYGON ((0 0, 0 10, 5 10, 5 5.1, 5.2 5, 5 4.9, 5 0, 0 0)) areaDelta=0.0100 npts=7 area=50.510 POLYGON ((0 0, 0 10, 5 10, 5.2 5, 5 4.9, 5 0, 0 0)) areaDelta=0.1000 npts=6 area=51.000 POLYGON ((0 0, 0 10, 5 10, 5.2 5, 5 0, 0 0)) `

规律很清楚:比例小于 0.001 时尖刺完整保留;0.01 时削掉了一半((5,5.1) 这个点被包进壳内);0.1 时尖刺只剩 (5.2,5) 一个外凸点,面积增加到 51.000。这个“用面积变化率控制去刺强度”的方式比“设一个几何容差再化简”直观得多,是 PolygonHullSimplifier 最值得推广的用法。这个“用面积变化率控制去刺强度”的方式比“设一个几何容差再化简”直观得多,是 PolygonHullSimplifier 最值得推广的用法。

12.6.4 与 DP/TP 的分工

需求 推荐工具
保留原有轮廓的细节起伏,只减顶点 DP / VW
必须保持拓扑与组件数 TP
去尖刺、去毛刺、去窄缝 PolygonHullSimplifier.hullByAreaDelta
得到“更紧”的外轮廓(近似凸) PolygonHullSimplifier.hull(geom, true, f)
得到“更紧”的内轮廓(内缩壳) PolygonHullSimplifier.hull(geom, false, f)

12.7 Densifier:反向操作

化简是减点,Densifier 是加点。它把每条线段按最大长度切分,插入等距顶点,常用于:

  • 让线在投影变换、曲线拟合前有足够采样密度;
  • 让“按顶点序号插值”的算法(如某些线性参考实现)更准确;
  • 制图时让线的渲染更平滑(配合曲线符号)。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.densify.Densifier;

public class DensifyDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry line = reader.read("LINESTRING (0 0, 10 0)");
        Geometry densified = Densifier.densify(line, 2.5);
        System.out.println("densified = " + densified);
        System.out.println("npts = " + densified.getNumPoints());

        Geometry poly = reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
        Geometry densifiedPoly = Densifier.densify(poly, 4.0);
        System.out.println("poly npts = " + densifiedPoly.getNumPoints());
        System.out.println("poly valid = " + densifiedPoly.isValid());
    }
}

输出:

densified = LINESTRING (0 0, 2.5 0, 5 0, 7.5 0, 10 0)
npts = 5
poly npts = 13
poly valid = true

注意 Densifier 的容差语义是“最大段长”而不是“段数”:densify(line, 2.5) 保证所有段不超过 2.5;边长 10 的正方形按 4.0 加密,每条边被切成 3 段、每段 2.5,顶点数从 5 变成 13(4 边 × 3 段 + 闭合点)。插入的坐标会尊重输入几何的 PrecisionModel(固定精度下会舍入)。类注释还提到默认会对多边形结果做合法性检查,可用 setValidate(false) 关闭——对大数据量、且确信结果合法时可以省一次检查。

12.8 凸包

12.8.1 定义与退化情况

凸包(convex hull)是“包含输入所有点的最小凸多边形”。JTS 的 ConvexHull 用 Graham 扫描算法,并做了两处优化:一是先判断退化情况(全共线、点数不足 3),二是对大输入先做包络裁剪。

凸包的返回值类型取决于输入:

输入 返回 说明
3 个及以上不共线点 Polygon 正常情形
2 个不同点,或全部共线 LineString 退化为线段
1 个不同点 Point 退化为点
空几何 空 GeometryCollection 无点可包
import org.locationtech.jts.algorithm.ConvexHull;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;

public class ConvexHullDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry cloud = reader.read(
                "MULTIPOINT ((0 0), (10 0), (10 10), (0 10), (5 5), (3 7))");
        Geometry twoPts = reader.read("MULTIPOINT ((0 0), (10 0))");
        Geometry onePt = reader.read("MULTIPOINT ((3 3))");
        Geometry collinear = reader.read("MULTIPOINT ((0 0), (5 0), (10 0), (7 0))");
        Geometry empty = reader.read("MULTIPOINT EMPTY");

        show("cloud    ", new ConvexHull(cloud).getConvexHull());
        show("twoPts   ", new ConvexHull(twoPts).getConvexHull());
        show("onePt    ", new ConvexHull(onePt).getConvexHull());
        show("collinear", new ConvexHull(collinear).getConvexHull());
        show("empty    ", new ConvexHull(empty).getConvexHull());
        show("via method", cloud.convexHull());
    }

    static void show(String label, Geometry g) {
        System.out.printf("%s => %-16s npts=%d area=%.1f%n",
                label, g.getGeometryType(), g.getNumPoints(), g.getArea());
    }
}

输出:

cloud     => Polygon          npts=5 area=100.0
twoPts    => LineString       npts=2 area=0.0
onePt     => Point            npts=1 area=0.0
collinear => LineString       npts=2 area=0.0
empty     => GeometryCollection npts=0 area=0.0
via method => Polygon         npts=5 area=100.0

五个退化分支都被覆盖到了。工程上最容易出事的是“共线退化成线”——如果下游代码假定 convexHull() 一定返回 Polygon 并强转,就会抛 ClassCastException。稳妥写法是先判断 getDimension() == 2。

12.8.2 凸包的两个陷阱

第一个陷阱是凸包会丢掉所有凹部信息。用一个 C 形或月牙形做凸包,得到的是一块“补齐缺口”的凸多边形,面积可能远大于原几何。所以凸包只适合“求最小外包”,不适合“概括形状”。

第二个陷阱是凸包不保留孔洞与多组件结构。一个 MultiPolygon 的凸包是一个单一 Polygon,所有孔洞都没了。如果业务需要“每个组件各自求凸包”,必须遍历组件分别求,再用 GeometryFactory.buildGeometry 合起来。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.io.WKTReader;

public class MultiConvexHull {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry mp = reader.read(
                "MULTIPOLYGON (((0 0, 4 0, 4 4, 0 4, 0 0)), ((10 0, 14 0, 14 4, 10 4, 10 0)))");

        Geometry whole = mp.convexHull();
        System.out.println("whole hull = " + whole + "  area=" + whole.getArea());

        GeometryFactory gf = new GeometryFactory();
        Geometry[] parts = new Geometry[mp.getNumGeometries()];
        for (int i = 0; i < parts.length; i++) {
            parts[i] = mp.getGeometryN(i).convexHull();
        }
        Geometry perPart = gf.buildGeometry(java.util.Arrays.asList(parts));
        System.out.println("per part   = " + perPart + "  area=" + perPart.getArea());
    }
}

输出:

whole hull = POLYGON ((0 0, 0 4, 14 4, 14 0, 0 0))  area=56.0
per part   = MULTIPOLYGON (((0 0, 0 4, 4 4, 4 0, 0 0)), ((10 0, 10 4, 14 4, 14 0, 10 0)))  area=32.0

整体凸包面积 56,把两个方块之间的空白也包了进去;逐组件凸包面积 32,正好是两个方块之和。选哪个取决于业务语义:“外包范围”用整体,“每个对象各自的外包”用逐组件。

12.9 凹包

12.9.1 凹包的三种目标参数

凹包(concave hull)的目标是“包住所有输入点,但允许凹陷”。ConcaveHull(1.19 新增)的实现思路是:先对点集做 Delaunay 三角剖分,然后从外向内逐个删除“太大”的边界三角形,直到所有边界三角形都满足条件。所谓“太大”由三种参数之一定义:

参数 方法 含义 值越小 值越大
最大边长 concaveHullByLength(geom, maxLength) 边界三角形的最长边不超过该值 越凹(可能碎) 越接近凸包
最大边长比例 concaveHullByLengthRatio(geom, ratio) 以上下限差值归一化,无尺度 0 最凹 1 得凸包
Alpha alphaShape(geom, alpha, holesAllowed) 删除外接圆半径大于 alpha 的三角形 0 最凹 越大越凸

类注释明确推荐“最大边长比例”,因为它是无量纲、局部的——不需要知道数据尺度,也不受整体形状影响。1.20 又补充了 alphaShape 静态方法(#952)。

import org.locationtech.jts.algorithm.hull.ConcaveHull;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;

public class ConcaveHullDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        // C 形点云:中间空出一块
        Geometry cloud = reader.read("MULTIPOINT ((0 0), (2 0), (4 0), (6 0), (8 0), (10 0),"
                + " (10 2), (10 4), (10 6), (10 8), (10 10), (8 10), (6 10), (4 10), (2 10),"
                + " (0 10), (0 8), (0 6), (0 4), (0 2), (4 4), (6 4), (4 6), (6 6))");

        Geometry hull = ConcaveHull.concaveHullByLengthRatio(cloud, 0.2);
        Geometry convex = cloud.convexHull();
        Geometry alpha = ConcaveHull.alphaShape(cloud, 2, false);

        System.out.println("convex hull area = " + convex.getArea()
                + " npts=" + convex.getNumPoints());
        System.out.println("concave(ratio .2) area = " + hull.getArea()
                + " npts=" + hull.getNumPoints());
        System.out.println("alphaShape(2) area = " + alpha.getArea()
                + " npts=" + alpha.getNumPoints());
        System.out.println("uniformGridEdgeLength = " + ConcaveHull.uniformGridEdgeLength(cloud));
    }
}

输出:

convex hull area = 100.0 npts=5
concave(ratio .2) area = 100.0 npts=21
alphaShape(2) area = 84.0 npts=25
uniformGridEdgeLength = 2.041241452319315

本例中 concaveHullByLengthRatio(0.2) 仍给出 100 的面积(与凸包相同),因为点云边界上的点太密,比例 0.2 还不足以切出凹陷;换成 alphaShape(cloud, 2, false) 才切出 84 的面积——去掉的 16 正好是内部那块“C 形缺口”。这个例子说明:参数不是越大越安全,而是要与点密度匹配。

uniformGridEdgeLength 提供了“点密度”的参考值:假设同样多的点均匀铺满凸包,每格边长是多少。类注释建议取它的 2 到 4 倍作为最大边长阈值。本例得到 2.04,取 4 到 8 作为 maxLength 比较合理。

12.9.2 ConcaveHullOfPolygons:面集合的凹包

ConcaveHullOfPolygons(1.19 新增)处理的是“一组多边形”而不是点集。它的语义很特别:计算出的凹包会填补多边形之间的空隙,但不与任何多边形的内部相交。输入必须是合法的 MultiPolygon(互不重叠、不共边),否则先用 union() 合并。

两个 API 族值得区分:

  • concaveHullByLength / concaveHullByLengthRatio:返回“原多边形 + 填补空隙”的完整凹包;
  • concaveFillByLength / concaveFillByLengthRatio:只返回填补的那块空隙。
import org.locationtech.jts.algorithm.hull.ConcaveHullOfPolygons;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;

public class HullOfPolygonsDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry polys = reader.read("MULTIPOLYGON (
            "((0 0, 4 0, 4 4, 0 4, 0 0)),"
            + " ((6 0, 10 0, 10 4, 6 4, 6 0)),"
            + " ((6 6, 10 6, 10 10, 6 10, 6 6)),"
            + " ((0 6, 4 6, 4 10, 0 10, 0 6)))");

        Geometry hull = ConcaveHullOfPolygons.concaveHullByLength(polys, 5);
        Geometry fill = ConcaveHullOfPolygons.concaveFillByLength(polys, 5);

        System.out.println("hull area = " + hull.getArea() + " npts=" + hull.getNumPoints());
        System.out.println("fill area = " + fill.getArea() + " npts=" + fill.getNumPoints());
        System.out.println("fill      = " + fill);
    }
}

输出:

hull area = 100.0 npts=13
fill area = 36.0 npts=13
fill      = POLYGON ((0 6, 4 6, 4 10, 6 10, 6 6, 10 6, 10 4, 6 4, 6 0, 4 0, 4 4, 0 4, 0 6))

四个 4×4 的方块摆成田字格,中间留出十字形空隙。凹包总面积 100(四个方块共 64 加空隙 36),填补区面积 36,正好是那个十字形。

ConcaveHullOfPolygons 还有两个高级开关:setHolesAllowed(boolean) 允许结果含孔洞;setTight(boolean) 让结果紧贴“背对其他多边形”的外边界。1.21 修复了嵌套外环处理(#1081),如果数据有“多边形里套多边形”的结构,务必用 1.21 以上。

12.9.3 内部实现:HullTriangulation 与 HullTri

凹包的实现依赖两个包级私有的类:HullTri 与 HullTriangulation。HullTri 继承自 org.locationtech.jts.triangulate.tri.Tri,额外记录了“最长边长度”与一个标记位,用来判断它是否是边界三角形;HullTriangulation 负责把 Delaunay 三角剖分转换成 List,并提供“合并三角形列表成多边形”“追踪边界环”等工具。

这两个类不是公开 API(类声明没有 public),只能通过 ConcaveHull 与 ConcaveHullOfPolygons 间接使用。这一点对写扩展很重要:如果想自己实现“按自定义准则删三角形”的凹包,只能复制它们的源码逻辑,或直接用 DelaunayTriangulationBuilder(第 15 章)自己处理三角网。

12.10 几何清理

12.10.1 重复点与无效坐标

数据清洗的第一步永远是“去重复点和无效坐标”。JTS 在 CoordinateArrays 里提供了成对的判断与清除方法:

方法 作用 备注
hasRepeatedPoints(Coordinate[]) 是否含连续重复点 只查相邻
removeRepeatedPoints(Coordinate[]) 去掉连续重复点 保留首个
hasRepeatedOrInvalidPoints(Coordinate[]) 是否含重复或 NaN、Inf 1.21 修过首点检查(#1157)
removeRepeatedOrInvalidPoints(Coordinate[]) 一并清除 构造几何前的标准动作
import java.util.Arrays;

import org.locationtech.jts.geom.CoordinateArrays;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;

public class CleanCoords {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry rep = reader.read("LINESTRING (0 0, 0 0, 5 0, 5 0, 5 5)");
        Geometry nanLine = reader.read("LINESTRING (0 0, 5 5, NaN 5, 10 0)");

        System.out.println("hasRepeatedPoints = "
                + CoordinateArrays.hasRepeatedPoints(rep.getCoordinates()));
        System.out.println("removed = " + Arrays.toString(
                CoordinateArrays.removeRepeatedPoints(rep.getCoordinates())));
        System.out.println("hasRepeatedOrInvalid = "
                + CoordinateArrays.hasRepeatedOrInvalidPoints(nanLine.getCoordinates()));
        System.out.println("removed + invalid = " + Arrays.toString(
                CoordinateArrays.removeRepeatedOrInvalidPoints(nanLine.getCoordinates())));
    }
}

输出:

hasRepeatedPoints = true
removed = [(0.0, 0.0, NaN), (5.0, 0.0, NaN), (5.0, 5.0, NaN)]
hasRepeatedOrInvalid = true
removed + invalid = [(0.0, 0.0, NaN), (5.0, 5.0, NaN), (10.0, 0.0, NaN)]

含 NaN 的折线被清成 3 个有效点。这条清理非常关键:JTS 的绝大多数算法遇到 NaN 坐标会给出未定义行为(不是抛异常,而是算出古怪结果),所以读数据后第一件事就该是清理。

12.10.2 GeometryFixer 与 buffer(0)

重复点只是“脏”的一种。更麻烦的是自相交、环方向错误、孔洞跑到外环外。JTS 提供了两个修理工:buffer(0)(第 10 章)与 GeometryFixer(1.18 起增强)。两者的差别是:

工具 行为 优点 缺点
geom.buffer(0) 按最大有向面积重新构面 快、简单 丢弃面积较小的“瓣”
GeometryFixer.fix(geom) 拆分自相交、修正环、去无效坐标 尽可能保留全部区域 稍慢,结果可能是 MultiPolygon
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.util.GeometryFixer;
import org.locationtech.jts.io.WKTReader;

public class FixDemo {
    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))");
        Geometry nanLine = reader.read("LINESTRING (0 0, 5 5, NaN 5, 10 0)");

        Geometry fixed = GeometryFixer.fix(bowtie);
        System.out.println("bowtie fixed = " + fixed);
        System.out.println("type=" + fixed.getGeometryType()
                + " area=" + fixed.getArea() + " valid=" + fixed.isValid());

        Geometry fixedLine = GeometryFixer.fix(nanLine);
        System.out.println("nan line fixed = " + fixedLine);
    }
}

输出:

bowtie fixed = MULTIPOLYGON (((5 5, 10 10, 10 0, 5 5)), ((0 0, 0 10, 5 5, 0 0)))
type=MultiPolygon area=50.0 valid=true
nan line fixed = LINESTRING (0 0, 5 5, 10 0)

对比第 10 章的 buffer(0):同一只蝴蝶结,buffer(0) 只留下面积 25 的一瓣,GeometryFixer 保留了两瓣共 50。需要“尽量不丢数据”的修复场景一律用 GeometryFixer。它还有一个 setKeepCollapsed(boolean):开启后,塌缩成线的面会被保留为线,而不是直接丢弃。

12.10.3 尖刺与窄缝的识别

尖刺(spike)是一个“进出几乎同一条路径”的退化凸起;窄缝(gore)是两条边几乎平行形成的细长凹槽。两者都是数字化与格式转换的常见副产物,检测思路相同:找长度小于阈值、或夹角接近 0 或 180 度的相邻边。

JTS 没有专门的“尖刺检测器”,但有两个可组合的手段:

  • 用 PolygonHullSimplifier.hullByAreaDelta 做去刺(12.6.3),它按面积变化率工作,天然忽略尖刺;
  • 用 CoverageCleaner(12.5.3)处理覆盖面里的窄缝,它按宽度合并缝隙。

自己写检测器时,判据可以用“相邻三点构成的三角形面积”与“两条边的长度比”:面积接近 0 且两边长度都大于阈值,基本就是尖刺。检测到之后,删掉中间点即可。

12.11 选型对照表与流水线建议

12.11.1 一页选型表

需求 推荐工具 关键参数 拓扑保证 典型耗时量级
单条线/多边形快速抽稀 DouglasPeuckerSimplifier distanceTolerance 无 最快
制图视觉优先的抽稀 VWSimplifier distanceTolerance 无 中
必须保持组件数、孔洞数 TopologyPreservingSimplifier distanceTolerance 完整 慢(约 DP 的 8 倍)
去尖刺、去窄缝、求紧壳 PolygonHullSimplifier areaDeltaRatio 保持合法 中
覆盖面同步化简 CoverageSimplifier tolerance 保持覆盖合法 慢
覆盖面清洗 CoverageCleaner snappingDistance、maxGapWidth 输出合法覆盖 慢
最小外包凸多边形 ConvexHull 无 返回类型随输入退化 快
点集凹包 ConcaveHull maxLengthRatio 或 alpha 单连通多边形 中
面集合补空隙 ConcaveHullOfPolygons maxLength、setTight 不应与输入相交 中
加密 Densifier distanceTolerance 保持形状 快
修复无效几何 GeometryFixer setKeepCollapsed 输出合法 中

12.11.2 一条可复用的处理流水线

下面这条流水线适用于“把外部数据整理成可入库的干净图层”,顺序经过实践验证:

 ① 读取 → 检查几何是否为 null / empty
 ② CoordinateArrays.removeRepeatedOrInvalidPoints 去脏点
 ③ GeometryFixer.fix 修自相交、修环方向
 ④ IsValidOp 复查;不合法则记录并跳过
 ⑤ 按用途化简:
      · 出图 → VW 或 DP(容差取屏幕像素对应地图距离)
      · 分析 → TP(容差取业务精度)
      · 覆盖数据 → CoverageSimplifier
 ⑥ 复查:面积变化率、组件数、孔洞数是否在阈值内
 ⑦ 入库 / 出图

第 ⑥ 步最容易被跳过,但它是唯一能拦住“化简把孔洞删了”的关卡。建议把“面积变化率 > 5%”“组件数变化”“孔洞数变化”作为告警条件写进批处理日志。

12.11.3 容差怎么定

容差的物理含义是“允许的偏差距离”,工程上有三种定法:

定法 公式 适用
按地图比例尺 容差 = 屏幕 0.3 毫米 × 比例尺分母 制图出图
按业务精度 容差 = 数据采集精度的 1 到 2 倍 分析、入库
按目标顶点数 二分搜索容差,使顶点数降到目标值 性能受限场景

第一种最常用。举例:1:50000 地图,屏幕 0.3 毫米对应实地 15 米,所以容差取 15(米制坐标下)。0.3 毫米是印刷行业公认的“人眼可分辨的最小距离”,小于它的起伏在图上无论如何都看不出来。

12.12 综合示例:一次完整的化简评估

把本章的工具串起来:给定一个多边形,比较三种化简在“顶点数、面积变化、合法性、耗时”四个维度上的表现,据此选择。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.simplify.PolygonHullSimplifier;
import org.locationtech.jts.simplify.TopologyPreservingSimplifier;
import org.locationtech.jts.simplify.VWSimplifier;

public class SimplifyBenchmark {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry g = reader.read("POLYGON ((0 0, 2 0.1, 4 0, 6 0.2, 8 0, 8 6,"
                + " 6 5.9, 4 6, 2 5.9, 0 6, 0 0))");
        double tol = 0.5;

        report("原始        ", g, g.getArea());
        report("DP          ", DouglasPeuckerSimplifier.simplify(g, tol), g.getArea());
        report("TP          ", TopologyPreservingSimplifier.simplify(g, tol), g.getArea());
        report("VW          ", VWSimplifier.simplify(g, tol), g.getArea());
        report("Hull(0.05)  ", PolygonHullSimplifier.hullByAreaDelta(g, true, 0.05), g.getArea());
    }

    static void report(String label, Geometry r, double origArea) {
        double delta = Math.abs(r.getArea() - origArea) / origArea * 100;
        System.out.printf("%s npts=%2d area=%7.3f delta=%5.2f%% valid=%b%n",
                label, r.getNumPoints(), r.getArea(), delta, r.isValid());
    }
}

输出:

原始         npts=11 area= 47.000 delta= 0.00% valid=true
DP           npts= 5 area= 48.000 delta= 2.13% valid=true
TP           npts= 5 area= 48.000 delta= 2.13% valid=true
VW           npts= 8 area= 47.200 delta= 0.43% valid=true
Hull(0.05)   npts= 5 area= 48.000 delta= 2.13% valid=true

结论:同一容差下,VW 的面积保真最好(0.43%),DP 与 TP 面积变化 2.13% 但顶点更少(5 对 8)。如果业务要求面积精度优先,选 VW;要求顶点数最少且能接受 2% 面积误差,选 DP;要求保持拓扑结构(本例没有孔洞,所以 TP 与 DP 结果相同),选 TP。

这个小型基准可以直接改成你自己的评估脚本:换数据、换容差、加上耗时统计,几分钟就能得到针对你数据的结论,比照文档猜“哪个更好”可靠得多。

12.13 常见问题与避坑

问题 1:DP 化简后多边形不见了。 细长要素在容差大于其宽度时会塌缩成空(见 12.2.2 的实测)。改用 TP(保组件数),或先按宽度筛选出细长要素单独处理。

问题 2:带孔多边形的孔洞消失。 DP 与 VW 都可能删掉小孔(见 12.3.2 实测)。要保孔洞用 TP,或把容差压到小于孔洞最小宽度。

问题 3:相邻多边形化简后出现缝隙或重叠。 普通化简各自处理公共边必然出问题。用 CoverageSimplifier 同步化简(12.5.2),或先合并再化简再切分。

问题 4:TP 化简后两个组件粘在一起。 1.20 之前存在“组件跳跃”问题(#1024、#1096 修复)。升级版本;若不能升级,把容差压到小于组件间距的一半。

问题 5:convexHull() 返回的不是 Polygon,强转崩溃。 共线输入返回 LineString、单点返回 Point、空输入返回 GeometryCollection(12.8.1)。转之前判断 getDimension() == 2。

问题 6:凹包的参数无法调出想要的效果。 先用 ConcaveHull.uniformGridEdgeLength(geom) 拿到点密度参考值,再取它的 2 到 4 倍作为最大边长。比例参数(ratio)比绝对长度更稳。

问题 7:ConcaveHullOfPolygons 报输入不合法。 它要求输入的 MultiPolygon 互不重叠、不共边(12.9.2)。先 polys.union() 合并成合法 MultiPolygon 再调用。

问题 8:化简后面积变化很大,但顶点确实减少了。 这是容差语义决定的:DP/TP 保“最大垂距”,不保面积。要用面积差作为约束,选 PolygonHullSimplifier.hullByAreaDelta。

问题 9:Densifier 之后顶点暴增,内存吃紧。 密度的容差是“最大段长”,调大即可;另外默认会做合法性检查,可用 setValidate(false) 关闭(确认结果合法时)。

问题 10:GeometryFixer 修复后类型变了。 它可能把 Polygon 拆成 MultiPolygon(自相交时),也可能把塌缩的面降维成线(开启 setKeepCollapsed 时)。下游代码必须按新类型处理,或先统一 buffer(0)。

12.14 本章小结

本章覆盖了 JTS 里“形状概括”的全部主要工具,要点可以浓缩成十二条:

  1. 化简的本质是“用更少顶点近似形状”,必须同时权衡精度、性能、拓扑三个约束,其中拓扑最容易出事故。
  2. Douglas-Peucker 保端点与极值点、速度快,但不保拓扑:可能让细长多边形塌缩为空、可能删掉孔洞。
  3. DouglasPeuckerSimplifier.setEnsureValid(true)(默认)会在化简后修复几何,但修不了“整个要素消失”。
  4. TopologyPreservingSimplifier 保证同维度、同组件数、同环数,代价是约 8 倍的耗时;它保结构不保形状细节。
  5. VWSimplifier 按三角形面积删点,视觉更自然、面积保真更好,但同样不保拓扑,且不化简环端点。
  6. 覆盖面数据必须用 CoverageSimplifier 同步化简公共边,否则必然产生缝隙或重叠;脏覆盖先用 CoverageCleaner 清洗。
  7. PolygonHullSimplifier 不是“删点”而是“内外收缩壳”,用顶点比例或面积差比例控制强度,是去尖刺、去窄缝的首选。
  8. Densifier 是化简的反向操作,容差语义是“最大段长”,插入的坐标尊重输入精度模型。
  9. 凸包的返回类型随输入退化(面、线、点、空集合),下游必须判类型;凸包会丢掉所有凹部信息与孔洞。
  10. 凹包用 Delaunay 三角剖分删边界三角形实现,三种目标参数中“最大边长比例”最稳;ConcaveHullOfPolygons 专用于面集合补空隙。
  11. HullTri 与 HullTriangulation 是包级私有类,不能直接调用;自定义准则只能借助 DelaunayTriangulationBuilder 自己实现。
  12. 清理是化简的前置步骤:removeRepeatedOrInvalidPoints 去脏点、GeometryFixer 修拓扑、PolygonHullSimplifier 去尖刺,三者的顺序不能颠倒。

至此“几何运算”这一组章节告一段落:第 09 章讲叠加(面与面怎么组合),第 10 章讲缓冲(面的外扩与内缩),第 11 章讲度量(多远、多大、多像),本章讲概括(怎么用更少的信息表达同一个形状)。下一章进入空间索引,讨论“怎么在上百万个几何里快速找到相关的那几个”。

相关资源

  • JTS 官方 Javadoc:https://locationtech.github.io/jts/javadoc/ ,重点读 DouglasPeuckerSimplifier、TopologyPreservingSimplifier、VWSimplifier、PolygonHullSimplifier、CoverageSimplifier、ConvexHull、ConcaveHull 的类注释;本章的保证与限制均引自这些注释。
  • 源码位置:modules/core/src/main/java/org/locationtech/jts/simplify/(三种化简与多边形壳)、coverage/(覆盖化简与清理)、algorithm/ConvexHull.java、algorithm/hull/(凹包)、densify/Densifier.java、geom/util/GeometryFixer.java、geom/CoordinateArrays.java。
  • 版本历史:doc/JTS_Version_History.md。凹包与多边形壳见 1.19 条目(#823、#861、#870);覆盖化简见 1.20 条目(#911、#1060);CoverageCleaner 与 MinimumBoundingTriangle 见 1.21 条目(#1126、#1160);TP 的多处拓扑修复见 1.20 与 1.21 条目(#925、#1013、#1024、#1059、#1096)。
  • 本站第 09 章《叠加分析详解》:https://znlgis.github.io/gis/tutorial/jts/第09章-叠加分析详解/
  • 本站第 11 章《距离度量与几何统计》:https://znlgis.github.io/gis/tutorial/jts/第11章-距离度量与几何统计/
  • 本站第 13 章《空间索引体系》:https://znlgis.github.io/gis/tutorial/jts/第13章-空间索引体系/
  • 本站第 15 章《三角剖分与 Voronoi》:https://znlgis.github.io/gis/tutorial/jts/第15章-三角剖分与Voronoi/