第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 ◄───────────────┘
(输出给前端/交换格式时)
原则:
- 入库前转换:存储统一到目标 CRS(例如 4490 或 4547),避免每次查询都转换;
- 运算前确认单位:长度、面积、缓冲、容差都必须在米制下进行;
- 输出前逆变换:展示层需要经纬度时再转回去,并明确标注坐标系;
- 转换只做一次:反复正反变换会累积误差。
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 拆分策略
处理步骤:
- 检测:几何包络宽度接近 360,或坐标同时存在接近 +180 与 -180 的顶点,且它们在同一个环上相邻;
- 归一化:把每个负经度坐标加 360,使所有经度落在 [0, 360);
- 切分:用 [0, 180] 与 [180, 360] 两个矩形分别求交;
- 回移:把落在 [180, 360] 的部分平移 -360,恢复成 [-180, 0];
- 输出:得到 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 高纬数据处理原则
- 能投影到等积投影就不要用 Web Mercator:中国范围可用 Albers 等积圆锥投影;
- 必须用 Web Mercator 时,面积统计用
平面面积 / (1/cos²φ)做纬度修正(仅适合小范围、近似); -
极区( φ > 80°)不要用 Web Mercator,改用极方位投影或直接球面计算; - 缓冲半径: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 局部原点:精度与性能的双重收益
把大坐标平移成局部坐标有两个好处:
- 数值精度:坐标量级从 1e7 降到 1e4,ulp 小三个数量级,noding 更稳;
- 可读性:日志、调试、局部算法中的坐标更直观。
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 数据库存储
入库时的三个关键点:
- 字段类型带 SRID:PostGIS 的
geometry(Point, 4326)会强制校验 SRID,混入其它 SRID 会报错;只写geometry则什么都能存,埋下隐患; - 同一表/同一图层统一 CRS:混存 4326 与 3857 是最常见的“图层错位”来源;
- 入库前转换:不要依赖查询时
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 本章小结
- JTS 是平面几何引擎,不含 CRS、不做投影、不校验 SRID;所有坐标参考系工作由调用方负责。
- SRID 只是整数元数据:不参与运算、不携带参数、不自动转换;
setSRID不改变坐标。 - 各格式对 SRID 的支持不同:WKT 无、EWKB 有、GeoJSON 默认 4326;跨系统交换必须让 CRS 元数据随数据走。
- 投影转换应放在系统外围:入库前转换、运算在米制下进行、输出前按需逆变换,并显式设置 SRID。
- 经纬度上的长度/面积没有物理意义:1° 经度随纬度收缩,1°×1° 面积约
12,363 × cos φ平方公里。 - 距离计算按场景选方法:小范围投影平面、跨区域 Haversine、高精度椭球大地线;Web Mercator 平面距离会系统性偏大。
- 反经线会让平面模型下的多边形变成“宽 358° 的巨面”,必须检测并拆分。
- Web Mercator 等角不等积:局部比例尺
1/cos φ,面积放大1/cos²φ;只适合展示,不适合面积统计。 - 分幅/瓦片处理要外扩 buffer,避免跨界要素被切断;局部原点平移能同时改善精度与可读性。
- 小数位要匹配业务精度;WKT 输出可用 WKTWriter.setPrecisionModel 或 OrdinateFormat 控制。
- 四类高发事故——度当米、SRID 丢失、反经线、Mercator 面积——都能用“元数据校验 + 单位断言 + 范围检查”在入口拦截。
- 上线前逐条过一遍 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/