第19章 - 坐标系、单位与工程陷阱

19.1 JTS 与 CRS:边界在哪里

19.1.1 JTS 只管平面拓扑

JTS 是一个纯平面几何引擎。它把坐标 (x, y) 当作平面直角坐标,用欧几里得距离、平面多边形面积、平面方位关系做计算。它不知道、也不关心:

  • 这些数字代表经度纬度还是投影米;
  • 数据基于 WGS84、CGCS2000 还是地方独立坐标系;
  • 坐标轴顺序是 (x, y) 还是 (lat, lon);
  • 地球曲率、投影变形、基准面差异。

这不是缺陷,而是设计边界。 把拓扑算法与坐标参考系解耦,让 JTS 能被 GeoTools、PostGIS 的几何引擎、GeoServer、Elasticsearch 等不同系统复用。代价是:所有 CRS 相关的工作必须由调用方完成,而且 JTS 不会提醒你漏了。

19.1.2 SRID 的真相

JTS 的几何确实有一个 SRID 字段(Geometry.getSRID() / setSRID(int)),但它只是一个整数标签:

  • 它不参与任何几何运算;
  • 它不携带基准面、投影参数、单位、轴顺序;
  • JTS 不会校验两个参与运算的几何 SRID 是否一致;
  • 运算结果的 SRID 来自构造结果的 GeometryFactory,与输入标签没有自动的语义关联。

换句话说:SRID 是贴在几何上的便利贴,不是坐标系本身。 知道“这是 4326”并不能让 JTS 算出正确的米制距离;要算米制距离,必须先把坐标投影成米。

19.1.3 常见误解

误解 事实 后果
“JTS 会根据 SRID 自动投影” 完全不会 度坐标下 buffer(100) 得到 100 度
“SRID 相同的几何就一定在同一坐标系” 标签可被随意设置 两个 4326 标签的数据可能一个经纬度、一个墨卡托
“setSRID 会转换坐标” 只改标签 坐标系错位却“看起来合法”
“WKT 里带 SRID” 标准 WKT 不带;EWKT 是 PostGIS 扩展 跨系统交换丢 CRS
“都是经纬度,直接算距离就行” 度不是长度单位 长度/面积无物理意义

19.2 SRID 在各格式中的命运

19.2.1 WKT:标准里没有 SRID

OGC WKT 只描述几何形状,不含坐标参考系。JTS 的 WKTReader 用构造时传入的 GeometryFactory 决定 SRID(通常为 0),WKTWriter 也不输出 SRID。PostGIS 的 SRID=4326;POINT(...) 是 EWKT 扩展,不是标准 WKT;JTS 标准 WKTReader 不认这个前缀。

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

public class WktSridDemo {
    public static void main(String[] args) throws Exception {
        GeometryFactory gf4326 =
            new GeometryFactory(new PrecisionModel(PrecisionModel.FLOATING), 4326);
        WKTReader reader = new WKTReader(gf4326);

        Geometry g = reader.read("POINT (116.4074 39.9042)");
        System.out.println(g.getSRID());   // 4326(来自工厂,不是 WKT)

        WKTReader defaultReader = new WKTReader(); // 默认工厂 SRID = 0
        Geometry g0 = defaultReader.read("POINT (116.4074 39.9042)");
        System.out.println(g0.getSRID());  // 0

        // 解析 EWKT 前缀会失败(JTS 标准 reader 不支持)
        try {
            defaultReader.read("SRID=4326;POINT (116.4074 39.9042)");
        } catch (Exception ex) {
            System.out.println("EWKT 前缀不被识别: " + ex.getClass().getSimpleName());
        }
    }
}

19.2.2 WKB 与 EWKB

标准 WKB 同样不含 SRID。JTS 的 WKBWriter 有两种模式:

  • 默认 includeSRID = false:输出标准 WKB,SRID 丢失;
  • new WKBWriter(2, true) 或 setIncludeSRID:输出 EWKB,用类型标志位 0x20000000 标记“带 SRID”,并在几何头后写入 4 字节 SRID。只有顶层几何带 SRID,集合内元素假定与顶层一致。

WKBReader 能识别这个标志位并把 SRID 恢复到几何上;若 WKB 没带 SRID,则回落到 GeometryFactory 的 SRID。

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKBReader;
import org.locationtech.jts.io.WKBWriter;

public class WkbSridDemo {
    public static void main(String[] args) throws Exception {
        GeometryFactory gf =
            new GeometryFactory(new PrecisionModel(), 4490);
        Geometry g = gf.createPoint(new org.locationtech.jts.geom.Coordinate(116.4, 39.9));

        // 1) 标准 WKB:SRID 丢失
        byte[] plain = new WKBWriter().write(g);
        Geometry back1 = new WKBReader().read(plain);
        System.out.println(back1.getSRID());       // 0(默认工厂)

        // 2) EWKB:SRID 保留
        byte[] ewkb = new WKBWriter(2, true).write(g);
        Geometry back2 = new WKBReader().read(ewkb);
        System.out.println(back2.getSRID());       // 4490
    }
}

19.2.3 GeoJSON:默认 4326

JTS 的 GeoJsonReader 在不带 CRS 信息时,默认按 WGS84 经纬度(SRID 4326) 构造几何;如果 GeoJSON 里有旧式 crs 对象,会尝试解析其中的 SRID。GeoJsonWriter 则可以把几何 SRID 写成 crs 属性。而 RFC 7946 已把 crs 标记为废弃,规定 GeoJSON 坐标必须是 WGS84 经纬度、且顺序为经度在前。

于是产生一个经典错位:GeoJSON 没有官方位置放 SRID。把 CGCS2000 投影数据写成 GeoJSON,读回来会被默认标成 4326,标签与坐标实际含义不符。工程上必须“外部约定 + 显式 setSRID”。

格式 是否携带 CRS/SRID JTS 行为 工程建议
WKT 否 由 GeometryFactory 决定 外部元数据表记录 SRID
EWKT 是(前缀) 标准 WKTReader 不支持 用 PostGIS/GDAL 或自行剥离前缀
WKB 否 SRID 回落工厂 跨系统传输用 EWKB 或外部元数据
EWKB 是(标志位+字段) WKBWriter(…, true) / WKBReader 支持 需要保留 SRID 时使用
GeoJSON 旧式 crs(已废弃) 无 CRS 时默认 4326 遵守 RFC 7946,外部约定 SRID
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.geojson.GeoJsonReader;
import org.locationtech.jts.io.geojson.GeoJsonWriter;

public class GeoJsonSridDemo {
    public static void main(String[] args) throws Exception {
        GeoJsonReader reader = new GeoJsonReader();
        Geometry g = reader.read(
            "{\"type\":\"Point\",\"coordinates\":[116.4074,39.9042]}");
        System.out.println(g.getSRID());   // 4326(GeoJSON 默认)

        // 若数据实际是投影坐标,读取后必须显式改标签(但坐标不会转换!)
        g.setSRID(4547);
        System.out.println(g.getSRID());   // 4547

        GeoJsonWriter writer = new GeoJsonWriter();
        System.out.println(writer.write(g).contains("crs"));
        // 是否输出 crs 取决于 SRID 与 writer 配置
    }
}

19.3 坐标参考系基础

19.3.1 地理坐标系与投影坐标系

  • 地理坐标系(GCS):用经度、纬度(单位:度)描述位置,基于某个椭球/基准面。中国的常用基准是 CGCS2000 与 WGS84,两者在米级以内接近但不完全等价。
  • 投影坐标系(PCS):把球面按某种投影展开到平面,单位通常是米。中国常用高斯-克吕格(横轴墨卡托)分带投影,3 度带或 6 度带。

JTS 对两者一视同仁,都是 (x, y) 数对。区别只存在于调用方的认知里。

19.3.2 常见 CRS 速查

CRS EPSG 类型 单位 典型用途
WGS 84 4326 地理 度 GPS、GeoJSON、国际交换
CGCS2000 4490 地理 度 中国国家标准地理坐标
CGCS2000 / 3-degree GK CM 114E 4547 投影 米 中国中东部测图/入库
WGS 84 / Pseudo-Mercator 3857 投影 米(名义) Web 地图瓦片、可视化
CGCS2000 / Gauss-Kruger zone 等 4513~4554 区间 投影 米 分带测图,按带选码

提醒:EPSG 代码不要凭记忆写死。不同带号、不同中央经线对应不同代码,写错不会报错,只会让数据悄悄偏移几十到几百公里。上线前用 EPSG Registry 或 projinfo 核对。

19.3.3 轴顺序:一个跨库事故高发点

JTS 的 Coordinate(x, y) 固定是 x 在前、y 在后。而地理坐标系的“官方轴顺序”可以是纬度在前、经度在后(EPSG:4326 的权威定义就是 lat, lon)。不同库的处理方式不同:

库 默认轴顺序 处理方式
JTS 无 CRS 概念 调用方保证 x=经度、y=纬度
GeoTools 遵循权威轴顺序 CRS.decode("EPSG:4326", true) 强制经度优先
PROJ 遵循权威轴顺序 projinfo / cs2cs 需注意 +axis
PostGIS 经度优先 ST_X 是经度
GeoJSON 经度优先 RFC 7946 明确 lon, lat

工程策略:在系统边界统一成“经度在前”,内部传递时带明确的元数据说明;对接 GeoTools/PROJ 时显式关闭轴顺序归一化,避免“转一圈回来经纬度互换”。

19.4 投影转换的落地方案

19.4.1 架构:转换在外围,JTS 在中心

   ┌─────────────┐   ┌──────────────────┐   ┌─────────────┐
   │ 源数据       │   │ 坐标转换层         │   │ 平面运算层   │
   │ (4326 度)    │──►│ GeoTools/Proj4J/  │──►│ JTS          │
   │              │   │ PROJ/数据库       │   │ (米制坐标)   │
   └─────────────┘   └──────────────────┘   └─────────────┘
                              │                     │
                              │                     ▼
                              │              ┌─────────────┐
                              │              │ 结果(米制) │
                              │              └──────┬──────┘
                              ▼                     │
                       逆变换回 4326 ◄───────────────┘
                       (输出给前端/交换格式时)

原则:

  1. 入库前转换:存储统一到目标 CRS(例如 4490 或 4547),避免每次查询都转换;
  2. 运算前确认单位:长度、面积、缓冲、容差都必须在米制下进行;
  3. 输出前逆变换:展示层需要经纬度时再转回去,并明确标注坐标系;
  4. 转换只做一次:反复正反变换会累积误差。

19.4.2 方案对比

方案 优点 缺点 适用
GeoTools 与 JTS 无缝,CRS 元数据完整 依赖较重 Java 服务端
Proj4J 轻量、纯 Java 基准面/网格支持有限 简单投影转换
PROJ(命令行/库) 权威、支持网格与最新 EPSG 需要本地安装或 JNI 数据生产流水线
PostGIS 数据库内转换,SQL 简单 依赖数据库 已在库的数据
GDAL/ogr2ogr 批量转换能力强 外部进程 离线批处理

19.4.3 GeoTools 示例(推荐的服务端方案)

GeoTools 封装了 CRS 查找、轴顺序与 JTS 联动,最省心。核心 API 是 CRS.decode、CRS.findMathTransform 与 JTS.transform:

import org.geotools.geometry.jts.JTS;
import org.geotools.referencing.CRS;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.operation.MathTransform;

public class GeoToolsTransformDemo {
    public static void main(String[] args) throws Exception {
        WKTReader reader = new WKTReader();
        Geometry lonLat = reader.read("POINT (116.4074 39.9042)");

        // true = 强制经度在前,避免 lat/lon 轴顺序问题
        CoordinateReferenceSystem src = CRS.decode("EPSG:4326", true);
        CoordinateReferenceSystem dst = CRS.decode("EPSG:4547", true);

        MathTransform mt = CRS.findMathTransform(src, dst, true);
        Geometry projected = JTS.transform(lonLat, mt);
        System.out.println(projected);            // 投影后的米制坐标
        System.out.println(projected.getSRID());  // 0:JTS.transform 不会自动设置 SRID

        // 逆变换回经纬度
        MathTransform inverse = mt.inverse();
        Geometry back = JTS.transform(projected, inverse);
        System.out.println(back);
    }
}

注意最后两行的提示:JTS.transform 只转换坐标,不设置 SRID。转换后必须手动 setSRID,否则下游无法知道这是哪个坐标系。

19.4.4 Proj4J 核心类

Proj4J 是轻量纯 Java 方案,核心类包括:

  • org.locationtech.proj4j.CRSFactory:按名称/EPSG 创建 CoordinateReferenceSystem;
  • org.locationtech.proj4j.CoordinateReferenceSystem:目标坐标系;
  • org.locationtech.proj4j.CoordinateTransformFactory:创建转换器;
  • org.locationtech.proj4j.CoordinateTransform:执行转换;
  • org.locationtech.proj4j.ProjCoordinate:坐标容器。
import org.locationtech.proj4j.CRSFactory;
import org.locationtech.proj4j.CoordinateReferenceSystem;
import org.locationtech.proj4j.CoordinateTransform;
import org.locationtech.proj4j.CoordinateTransformFactory;
import org.locationtech.proj4j.ProjCoordinate;

public class Proj4jDemo {
    public static void main(String[] args) {
        CRSFactory crsFactory = new CRSFactory();
        CoordinateReferenceSystem src = crsFactory.createFromName("EPSG:4326");
        CoordinateReferenceSystem dst = crsFactory.createFromName("EPSG:3857");

        CoordinateTransformFactory ctFactory = new CoordinateTransformFactory();
        CoordinateTransform transform = ctFactory.createTransform(src, dst);

        ProjCoordinate in = new ProjCoordinate(116.4074, 39.9042);
        ProjCoordinate out = new ProjCoordinate();
        transform.transform(in, out);
        System.out.println(out.x + ", " + out.y);
        // 约 1.296e7, 4.85e6(Web Mercator 米)
    }
}

Proj4J 的版本差异较大(包名从 org.osgeo.proj4j 演进到 org.locationtech.proj4j),上线前请以所用版本 Javadoc 为准;复杂基准面与网格偏移场景建议直接用 PROJ。

19.4.5 分带与带号

高斯-克吕格按经度分带:3 度带从 1.5°E 开始每 3° 一带,6 度带从 0° 开始每 6° 一带。跨带数据不能直接拼在一张平面图上,必须先选统一投影(例如统一到某个中央经线)或分别成图。

带号前缀是另一个坑:某些数据把带号写进东坐标前两位(如 38500000 表示 38 带、500000 米东偏移)。JTS 只看到 3850 万这个数,所有距离/面积都会错得离谱。入库前必须判断并剥离带号。

19.5 度数坐标下的面积与长度失真

19.5.1 度数不是长度单位

对经纬度数据调用 Geometry.getLength(),返回值的单位是度;调用 Geometry.getArea(),返回值的单位是平方度。这两个数字只有数学意义,没有物理意义:

  • 1 度经度在赤道约 111.32 公里,在北纬 60 度约 55.66 公里;
  • 1 度纬度各地差别小,平均约 110.57 公里;
  • 纬线周长随纬度收缩,经线长度基本不变。

因此“这个面面积 0.001”不能说明大小;“buffer(0.01)”会得到一个形状南北窄、东西宽的椭圆状多边形(因为同样的度数在东西方向对应的实际距离更短)。

19.5.2 面积换算

球面近似下,纬度 φ1 到 φ2、经度 λ1 到 λ2 的球面四边形面积为:

  A = R^2 * (λ2 - λ1) * (sin φ2 - sin φ1)      // 弧度制

工程上更常用粗略估算:1 度 × 1 度的网格,面积约 12,363 × cos φ 平方公里(取网格中心纬度)。下表给出几组数值,便于快速心算:

纬度带 cos φ 1°×1° 面积(约) 备注
0°~1° 1.000 12,363 km² 赤道附近最大
30°~31° 0.866 10,655 km² 中国中部
40°~41° 0.766 9,401 km² 北京/华北
50°~51° 0.643 7,912 km² 东北北部
60°~61° 0.500 6,150 km² 高纬

反过来,如果只知道“平方度”数值,换算成平方公里要乘上 111.32 × 110.57 × cos φ ≈ 12,309 × cos φ。注意这个换算是近似:网格越大、纬度越高,误差越大;需要精确值时应当用球面公式或投影后计算。

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

public class DegreeAreaDemo {
    public static void main(String[] args) throws Exception {
        WKTReader r = new WKTReader();
        // 北纬 40~41 度、东经 116~117 度的一个方块
        Geometry box = r.read(
            "POLYGON ((116 40, 117 40, 117 41, 116 41, 116 40))");

        double areaDeg2 = box.getArea();
        System.out.println("平面面积 = " + areaDeg2 + " 平方度"); // 1.0

        double latMid = Math.toRadians(40.5);
        double km2PerDeg2 = 111.32 * 110.57 * Math.cos(latMid);
        System.out.println("近似面积 = " + (areaDeg2 * km2PerDeg2) + " 平方公里");
        // 约 9365 平方公里(球面精确值约 9401)
    }
}

19.5.3 长度失真

平面长度同样不能用“度”。一条从 (116, 40) 到 (117, 41) 的线,getLength() 返回 sqrt(2) ≈ 1.414 度,但实际距离约:

  纬向 1 度 ≈ 110.57 km
  经向 1 度 ≈ 111.32 × cos(40.5°) ≈ 84.6 km
  实际距离 ≈ sqrt(110.57^2 + 84.6^2) ≈ 139.2 km

而“1.414 × 111.32 ≈ 157.4 公里”这种把度直接乘常数的做法,高估约 13%。纬度越高、经向分量越大,误差越大。

工程结论:对经纬度数据做任何长度/面积/缓冲/容差运算前,先投影。如果只需要粗略统计,可以用上面的换算公式,但要在报告里明确标注“近似值”。

19.6 距离近似:haversine 与平面距离

19.6.1 球面距离公式

Haversine 公式计算球面上两点的大圆距离:

  a = sin^2(Δφ/2) + cos φ1 * cos φ2 * sin^2(Δλ/2)
  c = 2 * asin(sqrt(a))
  d = R * c

其中 R 取平均地球半径 6,371,008.8 米(IUGG 平均半径)。Haversine 在短距离上有较好的数值稳定性,且计算简单,适合作为“无需投影的近似距离”。

19.6.2 与平面算距离的对比

以北京(116.4074, 39.9042)到上海(121.4737, 31.2304)为例:

方法 结果 与真实大圆距离的偏差
Haversine(球面) 约 1,067 km 基准(球面近似,椭球差异约 0.1%~0.5%)
度平面欧氏距离 × 111.32 约 1,118 km 高估约 4.8%
度平面欧氏距离 × cos(平均纬度) 修正 约 1,069 km 高估约 0.2%(本例巧合般接近)
Web Mercator 平面距离 约 1,290 km 高估约 21%(随方向与纬度变化)
椭球大地线(Vincenty/GeographicLib) 约 1,067 km 最接近真实

注意第三行的“cos 修正”只适用于小范围、且方向不极端的场景;跨纬度大范围时不能用一个平均纬度蒙混过关。

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;

public class HaversineDemo {
    static final double R = 6371008.8; // 平均地球半径,米

    public static double haversine(Coordinate p1, Coordinate p2) {
        double lat1 = Math.toRadians(p1.y);
        double lat2 = Math.toRadians(p2.y);
        double dLat = lat2 - lat1;
        double dLon = Math.toRadians(p2.x - p1.x);
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
                 + Math.cos(lat1) * Math.cos(lat2)
                 * Math.sin(dLon / 2) * Math.sin(dLon / 2);
        double c = 2 * Math.asin(Math.sqrt(a));
        return R * c;
    }

    public static void main(String[] args) {
        Coordinate beijing = new Coordinate(116.4074, 39.9042);
        Coordinate shanghai = new Coordinate(121.4737, 31.2304);

        GeometryFactory gf = new GeometryFactory();
        Geometry line = gf.createLineString(new Coordinate[] {beijing, shanghai});

        System.out.println("度平面长度 = " + line.getLength() + " 度");
        System.out.println("Haversine = " + haversine(beijing, shanghai) / 1000 + " km");
        System.out.println("度乘 111.32 = " + line.getLength() * 111.32 + " km");
    }
}

预期输出:

度平面长度 = 10.045166... 度
Haversine = 1067.4... km
度乘 111.32 = 1118.2... km

19.6.3 什么时候可以用平面距离

场景 可用平面距离? 说明
小范围投影坐标(米) 是 投影变形在可接受范围
经纬度、点间距离 < 1 km 近似可用 误差约 (d/R)² 量级,厘米~米级
经纬度、跨城市 否 误差百分比级
全国/全球统计 否 必须球面或椭球
Web Mercator 数据 谨慎 局部比例尺 1/cos φ,需按纬度修正

JTS 本身不提供球面距离 API(DistanceOp 是平面距离),需要球面/椭球距离时自行实现或使用 GeoTools/GeographicLib。GeoTools 中可用 org.geotools.referencing.GeodeticCalculator 计算大地线距离与方位角(以所用版本 API 为准)。

19.7 反经线(antimeridian)跨越

19.7.1 问题:多边形“绕地球一圈”

经度在 ±180° 处回绕。一个从 179°E 延伸到 179°W 的小岛多边形,顶点可能是:

POLYGON ((179 10, -179 10, -179 12, 179 12, 179 10))

JTS 看到的是 x 从 -179 到 179 的矩形,宽 358 度,几乎覆盖整个地球。任何叠加、缓冲、面积计算都会得出荒谬结果。这不是 JTS 的 bug,而是平面模型与球面回绕的本质冲突。

   期望:跨过 180° 的小区域            实际:平面模型中的巨大矩形
        ┌──────┬──────┐                    ┌─────────────────────┐
   179E │ 小岛 │ 179W │               -179│        跨 358 度     │179
        └──────┴──────┘                    └─────────────────────┘
           180°

19.7.2 拆分策略

处理步骤:

  1. 检测:几何包络宽度接近 360,或坐标同时存在接近 +180 与 -180 的顶点,且它们在同一个环上相邻;
  2. 归一化:把每个负经度坐标加 360,使所有经度落在 [0, 360);
  3. 切分:用 [0, 180] 与 [180, 360] 两个矩形分别求交;
  4. 回移:把落在 [180, 360] 的部分平移 -360,恢复成 [-180, 0];
  5. 输出:得到 MultiPolygon(两块),分别位于东西半球。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.util.AffineTransformation;
import org.locationtech.jts.io.WKTReader;

public class AntimeridianDemo {
    // 1) 逐坐标归一化:负经度加 360,统一到 [0, 360)
    static Geometry normalize0360(Geometry g) {
        Geometry copy = g.copy();
        copy.apply(new org.locationtech.jts.geom.CoordinateFilter() {
            public void filter(Coordinate c) {
                if (c.x < 0) c.x += 360;
            }
        });
        return copy;
    }

    public static Geometry[] split(Geometry crossing) {
        GeometryFactory gf = crossing.getFactory();
        Geometry shifted = normalize0360(crossing);

        // 2) 用两个半球矩形切分
        Geometry west = shifted.intersection(
            gf.toGeometry(new Envelope(0, 180, -90, 90)));   // 0~180
        Geometry east = shifted.intersection(
            gf.toGeometry(new Envelope(180, 360, -90, 90))); // 180~360

        // 3) 把东半部分移回 [-180, 0]
        if (!east.isEmpty()) {
            east = AffineTransformation.translationInstance(-360, 0).transform(east);
        }
        return new Geometry[] {west, east};
    }

    public static void main(String[] args) throws Exception {
        WKTReader r = new WKTReader();
        Geometry island = r.read(
            "POLYGON ((179 10, -179 10, -179 12, 179 12, 179 10))");

        System.out.println(island.getEnvelopeInternal().getWidth()); // 358.0

        Geometry[] parts = split(island);
        System.out.println(parts[0]);
        System.out.println(parts[1]);
        System.out.println(parts[0].getArea() + parts[1].getArea()); // 4.0 平方度(2×2)
    }
}

19.7.3 反经线的连带影响

  • 包络:跨经线的几何包络会覆盖全球,导致空间索引查询命中过多,性能骤降;
  • 瓦片:Web 地图瓦片在 180° 处断开,跨线要素通常需要在瓦片侧复制(buffer 后分别裁切);
  • 距离:跨线两点的经度差应取“跨线方向”的短弧,直接相减会得到 358 度而不是 2 度;
  • 栅格化/渲染:部分渲染器会在 180° 处画出一条贯穿地图的横线。

存储策略:在数据入口就拆分(split)为两块,或把经度规范化到 [0,360) 并对“跨线”打专用标记;不要指望下游算法自动处理。

19.8 极区与 Web Mercator 的精度陷阱

19.8.1 Web Mercator 公式

Web Mercator(EPSG:3857)把球面按等角圆柱投影展开:

  x = R * λ                       // λ 为经度(弧度)
  y = R * ln(tan(π/4 + φ/2))      // φ 为纬度(弧度),R = 6378137

纬度趋近 ±90° 时 y → ±∞,因此标准实现把纬度钳制到 ±85.05112878°,对应 y = ±20,037,508.34 米,正好拼成一张正方形世界地图。

19.8.2 尺度与面积变形

Web Mercator 是等角投影(局部形状不变),但面积严重失真,且随纬度急剧放大:

  • 某点局部比例尺 k = 1/cos φ;
  • 面积比例尺 k² = 1/cos²φ。
纬度 局部比例尺 面积放大倍数 说明
0° 1.000 1.00 赤道无变形
30° 1.155 1.33 变形开始明显
40° 1.305 1.70 北京位置
50° 1.556 2.42 东北
60° 2.000 4.00 高纬
80° 5.759 33.16 接近钳制线

这就是著名的“格陵兰看起来比非洲还大”。在 Web Mercator 地图上量面积、做缓冲、算距离,结果会系统性偏大,必须按纬度修正或改用等积投影。

public class MercatorDistortionDemo {
    static final double R = 6378137.0;

    public static double[] forward(double lonDeg, double latDeg) {
        double lat = Math.max(-85.05112878, Math.min(85.05112878, latDeg));
        double x = R * Math.toRadians(lonDeg);
        double y = R * Math.log(Math.tan(Math.PI / 4 + Math.toRadians(lat) / 2));
        return new double[] {x, y};
    }

    public static void main(String[] args) {
        double[][] pts = { {116.4074, 39.9042}, {0, 0}, {0, 60}, {0, 85} };
        for (double[] p : pts) {
            double[] xy = forward(p[0], p[1]);
            double k = 1.0 / Math.cos(Math.toRadians(p[1]));
            System.out.printf("lat=%.1f  x=%.1f  y=%.1f  scale=%.3f  area=%.3f%n",
                p[1], xy[0], xy[1], k, k * k);
        }
        // 北京附近:scale≈1.305,面积放大≈1.70 倍
    }
}

19.8.3 高纬数据处理原则

  1. 能投影到等积投影就不要用 Web Mercator:中国范围可用 Albers 等积圆锥投影;
  2. 必须用 Web Mercator 时,面积统计用 平面面积 / (1/cos²φ) 做纬度修正(仅适合小范围、近似);
  3. 极区( φ > 80°)不要用 Web Mercator,改用极方位投影或直接球面计算;
  4. 缓冲半径:Web Mercator 里 buffer(1000) 在 40° 纬度实际只有约 766 米地面距离(因为投影把地面距离放大了 1.305 倍)。

19.9 分幅、瓦片与局部坐标

19.9.1 瓦片编号

Web 地图瓦片使用 XYZ 编号:z 为层级,x 从西到东,y 从北到南。经纬度到瓦片的换算:

  n = 2^z
  xtile = floor((lon + 180) / 360 * n)
  latRad = lat * π / 180
  ytile = floor((1 - ln(tan(latRad) + 1/cos(latRad)) / π) / 2 * n)

典型层级:z=10 约 1:50 万,z=14 约 1:3.5 万,z=18 约 1:2000。

19.9.2 局部原点:精度与性能的双重收益

把大坐标平移成局部坐标有两个好处:

  1. 数值精度:坐标量级从 1e7 降到 1e4,ulp 小三个数量级,noding 更稳;
  2. 可读性:日志、调试、局部算法中的坐标更直观。

JTS 中用 AffineTransformation.translationInstance(dx, dy) 或 Geometry.apply(CoordinateSequenceFilter) 实现。注意平移后要同步更新 SRID 的语义(它不再是“标准投影坐标”,而是“局部坐标”),必须在元数据中记录原点和单位。

import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.util.AffineTransformation;

public class TileDemo {
    static final double R = 6378137.0;

    public static double[] lonLatToMercator(double lon, double lat) {
        double clamped = Math.max(-85.05112878, Math.min(85.05112878, lat));
        double x = R * Math.toRadians(lon);
        double y = R * Math.log(Math.tan(Math.PI / 4 + Math.toRadians(clamped) / 2));
        return new double[] {x, y};
    }

    public static int[] lonLatToTile(double lon, double lat, int z) {
        int n = 1 << z;
        int xt = (int) Math.floor((lon + 180.0) / 360.0 * n);
        double latRad = Math.toRadians(lat);
        int yt = (int) Math.floor(
            (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
        return new int[] {xt, yt};
    }

    public static void main(String[] args) {
        double lon = 116.4074;
        double lat = 39.9042;
        int z = 14;
        int[] tile = lonLatToTile(lon, lat, z);
        System.out.println("tile = " + tile[0] + "/" + tile[1] + "/" + z);

        // 瓦片中心点作为局部原点,把几何平移到局部坐标
        double[] origin = lonLatToMercator(lon, lat);
        GeometryFactory gf = new GeometryFactory();
        Geometry local = gf.toGeometry(new Envelope(-1000, 1000, -1000, 1000));
        AffineTransformation toLocal =
            AffineTransformation.translationInstance(-origin[0], -origin[1]);
        Geometry shifted = toLocal.transform(local);
        System.out.println(shifted.getEnvelopeInternal());
        // Envelope 现在以 (0,0) 为中心,数值量级从 1e7 降到 1e3
    }
}

19.9.3 瓦片缓冲与跨界要素

瓦片化处理时,只裁切本瓦片范围会把跨界要素切断,导致:

  • 边界处的缓冲区/叠加结果不完整(缺了瓦片外的那部分影响);
  • 相邻瓦片结果拼不回原样(接缝错位)。

标准做法:查询/处理范围外扩 buffer,处理完再裁回瓦片。buffer 距离应大于最大运算影响半径(例如 buffer 半径 + 线宽)。

   实际处理范围(外扩 buffer)        输出范围(瓦片)
   ┌──────────────────────────┐    ┌──────────────┐
   │   ┌──────────────────┐   │    │              │
   │   │                  │   │    │              │
   │   │    瓦片输出区     │   │    │              │
   │   │                  │   │    │              │
   │   └──────────────────┘   │    │              │
   └──────────────────────────┘    └──────────────┘
   多出来的边环保证跨界运算正确

19.10 精度、显示与入库

19.10.1 小数位数不等于精度

一个常见误区是“小数位越多越精确”。实际上小数位只决定表示分辨率,真实精度由采集方式与坐标系决定。对经纬度数据:

小数位 纬度方向分辨率 经度方向(北纬 40 度) 适用场景
4 位 约 11 米 约 8.5 米 城市级展示
5 位 约 1.1 米 约 0.85 米 一般 GIS 分析
6 位 约 0.11 米 约 0.085 米 测绘入库常用
7 位 约 1.1 厘米 约 0.85 厘米 高精度测量
8 位 约 1.1 毫米 约 0.85 毫米 已接近无意义
9 位 约 0.11 毫米 约 0.085 毫米 超出数据真实精度

对投影米制数据,3 位小数就是毫米级。按业务精度定小数位,而不是“能存多少存多少”:多余小数会增大存储、拖慢索引、制造“伪精度”的假象,还会让两份本应相同的数据因末位不同而无法匹配。

19.10.2 WKT 输出与数字格式化

JTS 提供两种格式化控制:

  • WKTWriter.setPrecisionModel(PrecisionModel):设置写出时使用的精度模型;
  • OrdinateFormat:独立的数字格式化器,可用 OrdinateFormat.create(maximumFractionDigits) 限定小数位。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.OrdinateFormat;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.io.WKTWriter;

public class WriteFormatDemo {
    public static void main(String[] args) throws Exception {
        WKTReader r = new WKTReader();
        Geometry g = r.read("POINT (116.40738654 39.90416789)");

        // 1) 默认输出:保留可表示精度
        System.out.println(new WKTWriter().write(g));

        // 2) 按 6 位有效数字精度模型输出
        WKTWriter w1 = new WKTWriter();
        w1.setPrecisionModel(new PrecisionModel(1000000)); // 6 位小数
        System.out.println(w1.write(g));

        // 3) 用 OrdinateFormat 单独格式化坐标值
        OrdinateFormat fmt = OrdinateFormat.create(6);
        System.out.println(fmt.format(116.40738654)); // 116.407387
        System.out.println(fmt.format(39.90416789));  // 39.904168

        // 4) 特殊值有固定文本
        System.out.println(OrdinateFormat.DEFAULT.format(Double.NaN));          // NaN
        System.out.println(OrdinateFormat.DEFAULT.format(Double.POSITIVE_INFINITY)); // Inf
    }
}

注意 REP_POS_INF = "Inf"、REP_NEG_INF = "-Inf"、REP_NAN = "NaN" 是 JTS 定义的文本形式,与 Java 默认的 "Infinity" 不同。跨系统交换时,对方未必认 Inf,建议在输出前就把非法值清理掉。

19.10.3 数据库存储

入库时的三个关键点:

  1. 字段类型带 SRID:PostGIS 的 geometry(Point, 4326) 会强制校验 SRID,混入其它 SRID 会报错;只写 geometry 则什么都能存,埋下隐患;
  2. 同一表/同一图层统一 CRS:混存 4326 与 3857 是最常见的“图层错位”来源;
  3. 入库前转换:不要依赖查询时 ST_Transform,那会让每个查询都付出转换成本,且空间索引可能用不上。
-- 推荐:字段级 SRID 约束
CREATE TABLE parcel (
    id      bigserial PRIMARY KEY,
    geom    geometry(MultiPolygon, 4490) NOT NULL
);

-- 写入前显式转换并校验
INSERT INTO parcel (geom)
SELECT ST_Multi(ST_Transform(ST_SetSRID(geom, 4547), 4490))
FROM staging;

-- 检查混入的错误 SRID
SELECT ST_SRID(geom) AS srid, count(*)
FROM parcel
GROUP BY ST_SRID(geom);

如果业务需要米制距离/面积查询,可以把字段设为 geography(PostGIS 会按椭球计算),或额外存一份投影几何;不要既存经纬度又假设 ST_Distance 返回米。

19.10.4 显示层

Web 地图前端(Leaflet、OpenLayers、Mapbox GL 等)通常使用 Web Mercator 瓦片(EPSG:3857),而 GeoJSON 规范要求 WGS84 经纬度(EPSG:4326)。正确的分层是:

  • 后端内部统一用投影米制做运算;
  • 对外 API 需要 GeoJSON 时,转成 4326 并设置 crs/文档说明;
  • 前端把 4326 交给地图库做显示转换,不要把 3857 的米制坐标塞进 GeoJSON——地图库会把它当经纬度,位置飞到几万度以外。

显示精度上,6 位小数(约 0.1 米)对屏幕像素已足够;传 9 位小数只会增加传输量。

19.11 生产事故复盘

19.11.1 案例一:把“度”当“米”做缓冲

现象:某个“找出地铁站 500 米内小区”的功能上线后,结果覆盖了整座城市。

根因:数据是 EPSG:4326 经纬度,代码调用 station.buffer(500)。JTS 按平面坐标处理,得到半径 500 度的圆,几乎覆盖全球;再与城市数据求交,自然全中。更隐蔽的版本是 buffer(0.0045)(开发者以为 0.0045 度≈500 米),实际在纬度 40 度只有约 383 米,且东西向与南北向不等距。

修复:先把站点与小区投影到米制(如 EPSG:4547),再 buffer(500),最后按需逆变换。

预防:在几何工具层封装 bufferMeters(geom, meters) 并在内部断言坐标系是投影米制;代码评审时把“裸 buffer”列为检查项。

19.11.2 案例二:SRID 丢失导致图层整体偏移

现象:从 A 系统导出的 WKB 数据导入 B 系统后,图层整体向西南偏移数百米。

根因:A 系统用 WKB 标准格式导出(WKBWriter 默认 includeSRID=false),SRID 标签丢失;B 系统按默认 SRID 0 或另一个基准解析,坐标数值没变,但语义变了。也可能是 A 用 CGCS2000、B 按 WGS84 处理,基准差异叠加投影参数差异。

修复:导出改用 EWKB(new WKBWriter(2, true)),或在交换协议里显式携带 SRID 元数据;导入端强制 setSRID 并做范围校验。

预防:跨系统交换必须“数据 + CRS 元数据”成对传输;解析后立刻校验坐标范围(经纬度应在 [-180,180]×[-90,90],投影坐标量级应符合预期)。

19.11.3 案例三:反经线多边形覆盖全球

现象:太平洋某个岛国的行政区面在叠加分析后变成覆盖全球的巨大面,面积统计爆表。

根因:多边形跨 180° 经线,顶点从 179 直接到 -179;JTS 按平面处理,得到一个宽 358° 的矩形。

修复:在数据入口检测包络宽度异常并拆分(见 19.7.2),或把经度统一到 [0,360) 并在展示时回绕。

预防:对“包络宽度 > 180°”的要素打标记并进人工复核;全球数据的入库规则里明确反经线处理策略。

19.11.4 案例四:Web Mercator 上统计面积虚高

现象:运营报表里“北方某省绿化面积”比测绘部门的数据大 60% 以上。

根因:前端用 3857 瓦片底图,后端直接拿 3857 坐标算 getArea()。在北纬 45 度附近,面积比例尺约 1/cos²45° = 2,虚高约一倍;不同纬度叠加后整体偏差没有规律。

修复:面积统计改用等积投影(如 Albers)或球面公式;展示用 3857 不影响,但统计口径必须独立。

预防:报表口径文档化“面积基于哪个投影”;代码中面积计算入口统一封装,禁止对 3857 几何直接 getArea()。

19.11.5 事故共性

事故 共同点 一句话教训
度当米 单位意识缺失 先问“坐标单位是什么”
SRID 丢失 元数据与数据分离 CRS 必须随数据走
反经线 球面模型与平面模型冲突 边界情况要显式处理
Mercator 面积 投影变形未修正 面积用等积投影

19.12 规避清单

序号 检查项 通过标准
1 数据 CRS 是否已知 每个数据集有明确 EPSG 或参数
2 SRID 是否随数据传递 WKB 用 EWKB,接口带 CRS 字段
3 运算前是否投影 长度/面积/缓冲/容差都在米制下
4 轴顺序是否统一 全系统“经度在前”
5 带号前缀是否剥离 东坐标不含带号,或明确约定
6 反经线是否处理 跨线要素已拆分或标记
7 Web Mercator 是否只用于展示 统计另用等积投影
8 极区是否避开 Mercator 高纬用方位/等积投影
9 小数位是否符合业务精度 经纬度 ≤ 7 位,米制 ≤ 3 位
10 入库字段是否约束 SRID geometry(…, SRID) 类型
11 转换是否只做一次 流水线中正反变换有明确位置
12 转换后是否设置 SRID setSRID 或工厂指定
13 局部坐标是否有原点元数据 记录原点、单位、原 CRS
14 前端 GeoJSON 是否为 4326 坐标范围在经纬度区间
15 面积/长度口径是否文档化 明确投影、单位、算法

19.12.1 一个 CRS 守卫工具

import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryCollection;

public final class CrsGuard {
    private CrsGuard() {}

    /** 判断包络是否像经纬度数据(范围 + 非投影量级) */
    public static boolean looksLikeLonLat(Geometry g) {
        if (g.isEmpty()) return true;
        Envelope env = g.getEnvelopeInternal();
        return env.getMinX() >= -180 && env.getMaxX() <= 180
            && env.getMinY() >= -90 && env.getMaxY() <= 90;
    }

    /** 判断是否存在反经线风险:包络宽度过大 */
    public static boolean crossesAntimeridian(Geometry g) {
        return !g.isEmpty() && g.getEnvelopeInternal().getWidth() > 180;
    }

    /** 基础元数据校验:SRID 非 0、坐标有限、类型一致 */
    public static void assertUsable(Geometry g, int expectedSrid) {
        if (g == null) throw new IllegalArgumentException("几何为 null");
        if (g.getSRID() != expectedSrid) {
            throw new IllegalArgumentException(
                "SRID 不匹配: 期望 " + expectedSrid + ",实际 " + g.getSRID());
        }
        if (g instanceof GeometryCollection) {
            for (int i = 0; i < g.getNumGeometries(); i++) {
                assertUsable(g.getGeometryN(i), expectedSrid);
            }
        }
        if (!looksLikeFinite(g)) {
            throw new IllegalArgumentException("坐标含 NaN/Inf");
        }
    }

    private static boolean looksLikeFinite(Geometry g) {
        for (int i = 0; i < g.getNumPoints(); i++) {
            double x = g.getCoordinates()[i].x;
            double y = g.getCoordinates()[i].y;
            if (Double.isNaN(x) || Double.isInfinite(x)) return false;
            if (Double.isNaN(y) || Double.isInfinite(y)) return false;
        }
        return true;
    }
}

这个工具不判断投影是否“正确”(JTS 无从判断),但能拦住最常见的几类元数据错误。

19.13 常见问题与避坑

问:JTS 能不能直接告诉我两个经纬度点之间的距离? 不能。DistanceOp / Geometry.distance 都是平面距离。需要球面距离请自己实现 Haversine,或用 GeoTools 的 GeodeticCalculator / GeographicLib。

问:为什么 getArea() 结果是 0.0001 这种小数? 因为坐标是度,面积单位是平方度。要得到平方米,先投影到米制;或用 19.5.2 的近似公式换算并标注“近似”。

问:SRID 设成 4326 后 buffer(1000) 是 1000 米吗? 不是,是 1000 度。SRID 只是标签,不改变运算语义。

问:为什么设置了 setSRID 之后坐标没变? setSRID 只改标签,不做转换。要转换坐标必须调用投影库。

问:GeoJSON 读进来为什么都是 4326? GeoJsonReader 在无 CRS 信息时默认 4326,这是规范倾向。若数据实际不是 4326,读取后必须显式改正并记录来源。

问:Web Mercator 数据能直接算面积吗? 不能,除非做纬度修正且范围很小。面积统计请用等积投影或球面公式。

问:跨 180° 经线的数据入库前怎么处理? 按 19.7.2 拆分为两块,或统一到 [0,360) 并记录“回绕”约定;不要原样交给 JTS。

问:投影转换后精度为什么变差了? 转换本身有误差(尤其基准面转换),反复正反变换会累积。尽量只转一次,并在转换后做范围/形状校验。

19.14 本章小结

  1. JTS 是平面几何引擎,不含 CRS、不做投影、不校验 SRID;所有坐标参考系工作由调用方负责。
  2. SRID 只是整数元数据:不参与运算、不携带参数、不自动转换;setSRID 不改变坐标。
  3. 各格式对 SRID 的支持不同:WKT 无、EWKB 有、GeoJSON 默认 4326;跨系统交换必须让 CRS 元数据随数据走。
  4. 投影转换应放在系统外围:入库前转换、运算在米制下进行、输出前按需逆变换,并显式设置 SRID。
  5. 经纬度上的长度/面积没有物理意义:1° 经度随纬度收缩,1°×1° 面积约 12,363 × cos φ 平方公里。
  6. 距离计算按场景选方法:小范围投影平面、跨区域 Haversine、高精度椭球大地线;Web Mercator 平面距离会系统性偏大。
  7. 反经线会让平面模型下的多边形变成“宽 358° 的巨面”,必须检测并拆分。
  8. Web Mercator 等角不等积:局部比例尺 1/cos φ,面积放大 1/cos²φ;只适合展示,不适合面积统计。
  9. 分幅/瓦片处理要外扩 buffer,避免跨界要素被切断;局部原点平移能同时改善精度与可读性。
  10. 小数位要匹配业务精度;WKT 输出可用 WKTWriter.setPrecisionModel 或 OrdinateFormat 控制。
  11. 四类高发事故——度当米、SRID 丢失、反经线、Mercator 面积——都能用“元数据校验 + 单位断言 + 范围检查”在入口拦截。
  12. 上线前逐条过一遍 19.12 的规避清单,并把这个清单固化进代码评审与质检规则。

19.15 相关资源

  • 系列目录:JTS 教程目录
  • 几何对象模型:第 04 章(Geometry 与几何工厂)
  • WKT 与 WKB:第 06 章(SRID 在序列化中的丢失与保留)
  • GeoJSON 与 TWKB:第 07 章(GeoJSON 的 CRS 约定与默认 4326)
  • 距离度量:第 11 章(平面距离 API 全貌)
  • 缓冲区:第 10 章(缓冲半径的单位问题)
  • 有效性与覆盖率质检:第 17 章(缝隙阈值的单位)
  • 精度模型与数值鲁棒性:第 18 章(坐标量级与网格)
  • 综合实战:第 24 章(全国数据如何处理投影与分幅)
  • JTS 官方 Javadoc:https://locationtech.github.io/jts/javadoc/
  • EPSG Registry:https://epsg.org/
  • PROJ 官方文档:https://proj.org/