第08章 - 空间数据底座与时空基准

项目做到第二个月,总会出现一场所有人都绕不开的会:同一栋楼在两张图上差了 47 米;轨迹回放时车总是提前两秒拐弯;三个月前那份统计报告今天怎么都复算不出来。三个问题分别指向坐标基准、时间基准与数据版本——这就是本章的三条主线。

空间智能的上层全是算法:检测、分割、建图、规划、问答。可算法跑在数据上,底座歪一寸,上层要还的债是一丈。本章不讲模型,只讲怎么把柱子打到岩层里:基准怎么定、矢量与影像怎么建库、三维成果怎么组织、数据怎么治理,以及怎么把这些检查变成 DeepSeek Harness(下称 DSH)能自动执行的技能。凡涉及具体版本、日期的判断,都以本章末尾「延伸阅读」里的官方文档为准。

学习目标:能独立交付一套「基准—组织—治理」方案——写得出一条 WGS84 ↔ CGCS2000 高斯 ↔ 地方独立系 ↔ ENU 的往返代码并自检到厘米级;能判定 UTC、GPS 周秒、本地时间与视频 PTS 的偏差来源,把端到端残差压到 20 ms 以内;能建出带 SRID 约束、分区与索引的 PostGIS 3.7 库,并用 EXPLAIN 证明查询真的走了索引;能把影像组织成 COG + STAC 1.1 的可检索集合;能用 DuckDB 直查 GeoParquet 完成千万级要素统计;能给出三维底座的组织结构与显存预算;最后把数据体检做成 DSH 技能接进 CI。 前置知识:会写 Python 与 SQL;用过 GDAL 命令行;知道 EPSG 编码、GiST 索引、COG 这几个词;能读懂 EXPLAIN 的基本输出。

8.1 为什么先讲数据底座:三条血泪教训

空间智能项目很少败在模型精度上。查到最后,「算法不灵」的现场大都是数据的问题:两张图对不上、两路视频不同步、三个月前的结论今天复现不出来。这三类故障分别对应坐标基准、时间基准与数据版本。它们不上大屏、不进论文、不出 demo,却决定了上层一切结论能不能被信任。

下面三个案例都来自真实项目,数字做过脱敏,但量级与成因没有改。

8.1.1 教训一:坐标系混用,全市管网整体偏移 47 米

某市地下管网普查,作业队把一批 2018 年的「西安80」成果直接当成 CGCS2000 贴进一张图,省掉了七参数转换这一步。入库后图面一切正常:管线接得严丝合缝、阀门正好在管点上。直到现场核验,才发现所有要素相对实测控制点西向偏 31 m、北向偏 35 m,合成 46.7 m——管线横穿河道,阀门落在楼里。

这类错误最阴险的地方是「自洽」:整批数据一起偏,图面拓扑关系仍然正确,任何只在本数据集内部做的检查都发现不了。只有与外部基准(实测控制点、影像、其它部门成果)套合才会暴露。

常见的基准混用形态与量级:

混用形态 典型偏移量级 用什么发现
西安80 / 北京54 当 CGCS2000 用 50–150 m,随区域变化 与实测控制点套合
3 度带与 6 度带混用、中央子午线错带 数十公里至数百公里 看 easting 数值与所在纬度带
带号有 / 无混用 easting 差「带号 × 1e6」量级 看 easting 是否超过 1e6
大地高当 1985 国家高程基准正常高 高程 10–40 m,山区更大 与水准点比对
地方独立系只平移、不做四参数 米级至数十米 与 CGCS2000 成果套合

8.1.2 教训二:时间不同步,轨迹整体错位 30 米

某园区巡检项目,POS 是 10 Hz、时间戳为 UTC;视频用 MP4 封装,PTS 从 0 开始。开发图省事,直接按「第 n 帧 = n/25 秒」把视频映射到 POS 首包。实际录制比 POS 首包晚 1.96 s,于是每一帧都取到了 1.96 s 之后的位置:60 km/h 的车速对应错位 32.7 m,15 m/s 的无人机对应 29.4 m。AR 标注框在画面上往回「爬」,越到后段越偏。

定位这个问题只需要一个动作:把两路时间戳按同一个物理事件(相机触发电平、画面里的 LED 闪光、场景里的闸机横杆)对齐,偏移量立刻现形。时间基座的要求不是「差不多」,是「有残差统计」。

常见时间源和它们各自的脾气:

时间源 原点 / 定义 典型精度 最常见的坑
UTC 1970-01-01 起的绝对时间 NTP 同步后 1–50 ms 存成不带时区的 timestamp,跨时区后全乱
GPS 周秒 1980-01-06 起,week + time of week 接收机 20–100 ns 与 UTC 差 18 s 整秒,忘了换算
本地时间 UTC+8(Asia/Shanghai,无夏令时) 取决于系统时钟 直接当 UTC 入库
视频 PTS 容器 timescale 计数的相对时间 逐帧精确 原点是录制起点或首帧,不是 UTC
设备上电计数 板卡上电后的毫秒数 独立晶振,日漂 10–100 ppm 1 ppm = 86.4 ms/天,一天不校时就出界

8.1.3 教训三:版本不清,结论不可复现

同一片区域,2024Q3 的影像抽检出 1200 个图斑,2025Q1 的影像抽检出 1380 个。任务书只写了「使用最新影像」,报告里没写用了哪一份、哪天的、什么处理的。三个月后上级复核,谁也算不出 1200 这个数,整份成果只能作废重做。

版本问题的解法不是「约定一下」,而是让每一条数据自带身份证:采集时间、处理版本、内容哈希、上游来源,全部落进可查询的元数据表。复现时按 manifest 拉回同一批哈希,结论才可复算。这一节的具体做法放在 8.8。

8.1.4 「基准—组织—治理」三件事

三个案例对应三层工作,顺序不能颠倒:

              ┌──────────────────────────────────────────┐
   第 3 层    │ 治理:版本 / 血缘 / 权限 / 合规            │  可审计、可复现
              └────────────────────▲─────────────────────┘
              ┌────────────────────┴─────────────────────┐
   第 2 层    │ 组织:矢量库 / 影像集合 / 分析表 / 三维瓦片 │  可检索、可扩展
              └────────────────────▲─────────────────────┘
              ┌────────────────────┴─────────────────────┐
   第 1 层    │ 基准:坐标基准 / 时间基准 / 高程基准        │  可对齐、可量化
              └──────────────────────────────────────────┘
                       先钉死基准,再谈组织,最后谈治理
层 本层要回答的问题 主要交付物 失败时的症状
基准 这个点在哪、这个时刻是多少 CRS 定义文件、授时方案、基准说明 整体偏移、轨迹错位
组织 数据放在哪、怎么查得快 PostGIS 库、COG 集合、GeoParquet、3D Tiles 查询慢、集合不可检索
治理 这份成果能不能用、能不能复现 manifest、血缘表、权限矩阵、合规清单 报告不可复算、越权访问

8.1.5 底座的五个硬指标

底座做得好不好,不能靠「感觉对了」,要能量化。下面五个指标建议写进项目验收文档,每条都配一条可执行的命令:

指标 判据 验证方式
坐标往返残差 平面 < 1 cm,高程 < 3 cm 控制点往返脚本,见 8.2.5
时间对齐残差 RMS < 20 ms,P95 < 50 ms 时间偏差检测脚本,见 8.3.3
SRID 完整率 缺失率 0,混合 SRID 表 0 个 目录体检 SQL,见 8.9
几何有效率 有效几何 > 99.9%,空几何 = 0 ST_IsValid 统计
成果可复现率 manifest 完整率 100%,哈希可回源 版本核对脚本,见 8.8

体检的最小命令集如下,每条都能给出一个数而不是一句「看着没问题」:

# 1) 矢量:要素数、几何类型、范围、CRS 一次看全
ogrinfo -so -al data/pipe.gpkg | grep -E "Layer name|Geometry:|Feature Count|Extent"

# 2) 影像:尺寸、波段数、CRS、是否 COG
gdalinfo -json data/ortho_2025q1.tif | jq '{size: .size, bands: (.bands|length), crs: .coordinateSystem.wkt[0:40]}'

# 3) 数据库:SRID 缺失数、无效几何数、空几何数
psql -d gis -tAc "SELECT count(*) FILTER (WHERE ST_SRID(geom) = 0) AS no_srid,
                         count(*) FILTER (WHERE NOT ST_IsValid(geom)) AS invalid,
                         count(*) FILTER (WHERE ST_IsEmpty(geom)) AS empty
                  FROM parcel;"

坑 1:先建库、后补基准。表建好了、数据进了、索引建了,才发现坐标系是错的,此时改一个字段要重导几十亿条记录。基准永远是第一步。

坑 2:迷信「万能中间坐标系」。每多跳一次转换就多叠一次误差,长链路还会把椭球高和正常高搅在一起。能一次转到位就不要分三段转。

坑 3:把「坐标看起来差不多」当验收。人眼在 1:500 图上分辨不出 5 m,但管线会穿墙、图斑会越界。验收必须写数值阈值,不能写「基本吻合」。

8.2 坐标基准:先把「在哪」钉死

坐标基准不是一个数,是「参考椭球 + 原点与定向 + 投影方式 + 单位与轴序」的组合。同样一个点,换一套组合就得到一组新数值,而所有数值都能自称「经纬度」。判断一份数据能不能用,先看它声明的 CRS 完整到什么程度:只有「WGS84」四个字的,等于没说;写到 EPSG 编码、带号、是否含带号、高程基准的,才算可用的声明。

8.2.1 三套基准与 EPSG 编码

国内项目绕不开三套基准,它们不是「精度不同」,而是「定义不同」:

基准 定义要点 常用 EPSG 用在哪
WGS84 GPS 原生基准,随 GPS 广播星历更新到 WGS84(G2139) 等实现 4326(地理)、4978(ECEF) GNSS 原始数据、国际交换
CGCS2000 2000 国家大地坐标系,ITRF97 框架、历元 2000.0 4490(地理)、4491–4554(投影) 国内法定测绘成果、报批
地方独立系 自定义中央子午线 + 抵偿高程面 + 四参数 多为自定义 WKT 或 EPSG:900913 式本地编码 城市大比例尺、工程测量

CGCS2000 的投影 EPSG 编码有四个连续段,记住它们的对应关系,就能在拿到一个编码时立刻判断是几度带、带不带带号:

EPSG 段 含义 东坐标形态
4491–4501 CGCS2000 6 度带,带号 13–23 带号 × 1e6 + 500000 + Δ
4502–4512 CGCS2000 6 度带,中央子午线 75E–135E 500000 + Δ
4513–4533 CGCS2000 3 度带,带号 25–45 带号 × 1e6 + 500000 + Δ
4534–4554 CGCS2000 3 度带,中央子午线 75E–135E 500000 + Δ

CGCS2000 与 WGS84 在 ITRF 框架下的差异在厘米级,工程上按无转换处理通常够用;但报批成果必须使用官方 CGCS2000 定义与官方转换参数,不要拿「差不多」去顶。

8.2.2 高斯-克吕格:中央子午线、带号与东坐标

高斯-克吕格是横轴墨卡托在椭球上的实现:中央子午线投影后是一条直线且长度不变形,离中央子午线越远变形越大,长度变形近似为

Δs / s ≈ (Δλ² · cos²φ) / 2

按这个式子代进去:3 度带边缘(Δλ = 1.5°)在纬度 30 度附近最大长度变形约 1/4000,也就是 0.25 m/km;6 度带边缘(Δλ = 3°)约 1/1000,即 1 m/km。城市大比例尺测图因此用 3 度带,或者干脆建地方独立系,用抵偿高程面把变形压下去。

带号和中央子午线的关系可以直接算,不用查表:

def gk_zone(lon_deg: float, width_deg: int = 3):
    """返回 (带号, 中央子午线经度)。lon_deg 为东经正、西经负。"""
    if width_deg == 3:
        n = round(lon_deg / 3.0)          # 深圳 114.31E → 38 带,中央子午线 114E
        cm = 3.0 * n
    elif width_deg == 6:
        n = int(lon_deg // 6) + 1         # 114.31E → 20 带,中央子午线 117E
        cm = 6.0 * n - 3.0
    else:
        raise ValueError("width_deg 只支持 3 或 6")
    return n, cm

for lon in (114.31, 116.40, 121.47):
    print(lon, "3 度带:", gk_zone(lon, 3), " 6 度带:", gk_zone(lon, 6))
经度 3 度带带号 / 中央子午线 6 度带带号 / 中央子午线
114.31E 38 / 114E 20 / 117E
116.40E 39 / 117E 20 / 117E
121.47E 40 / 120E 21 / 123E

同一个中央子午线,带号变体与不带带号变体的区别只有东坐标前面那两三位:

变体 东坐标示例形态 换算关系
带号变体 38 5xx xxx.xxx 带号 × 1e6 + 不带带号东坐标
不带带号变体 5xx xxx.xxx 带号变体减去 带号 × 1e6

这两种变体在 EPSG 里是两组编码,混用一次就是三万八千公里的偏移——数据直接落到地球外面去。看到 easting 大于 1e6 时,先别急着画图,先确认它是不是带了带号。

8.2.3 ECEF、ENU 与地方独立坐标

  • ECEF(Earth-Centered Earth-Fixed,EPSG:4978):原点在地心,Z 轴指向协议北极,X 轴指向本初子午线与赤道交点。卫星轨道、惯导解算、三维引擎内部都用它,因为它是全局无奇点的直角坐标。
  • ENU(East-North-Up):以某个原点切出的局部平面,东-北-天三轴。飞控的本地坐标、点云局部拼接、机器人导航都用它。它是近似:切平面与地球表面在距离 d 处的偏差约为 d²/(2R),10 km 处约 8 m,所以 ENU 只在十几公里内可信。
  • 地方独立坐标系:测区自定义的中央子午线(常为过测区中心的子午线)加上抵偿高程面,再与 CGCS2000 之间用四参数(平面)或七参数(三维)联系。它的好处是长度变形接近零,坏处是每换一个测区就要换一套参数。
坐标类型 原点 单位 有效范围 典型误用
ECEF 地心 m 全球 直接当平面坐标算距离
ENU 测区中心切点 m 约 10–15 km 跨市拼接后误差涨到米级
地方独立系 自定义 m 单测区 忘给四参数就交付
高斯投影 中央子午线 m 单带内 跨带直接拼图

8.2.4 一条往返链的完整代码

工程实现的关键不在公式,在三个开关:轴序(always_xy)、带号(easting 是否含带号前缀)、高程基准(椭球高还是正常高)。下面这段代码把 WGS84 → CGCS2000 高斯 → 地方独立系 → ENU → 回到 WGS84 全部串起来,依赖只有 pyproj 与 numpy:

# crs_roundtrip.py  |  pip install "pyproj>=3.6" "numpy>=1.26"
import numpy as np
from pyproj import CRS, Geod, Transformer

CRS_WGS84 = CRS.from_epsg(4326)     # WGS84 地理
CRS_GK    = CRS.from_epsg(4547)     # CGCS2000 / 3 度带 CM 114E,不带带号
CRS_ECEF  = CRS.from_epsg(4978)     # ECEF

# 地方独立系四参数(示例值;真实项目必须用测绘部门下发的参数)
P = {"dx": -12584.319, "dy": 33871.207, "theta": np.deg2rad(0.00137), "k": 1.0000217}
ORG_E, ORG_N = 500000.0, 2490000.0  # 地方系原点在 114E 带内的平面位置

t_wgs_gk   = Transformer.from_crs(CRS_WGS84, CRS_GK, always_xy=True)
t_gk_wgs   = Transformer.from_crs(CRS_GK, CRS_WGS84, always_xy=True)
t_wgs_ecef = Transformer.from_crs(CRS_WGS84, CRS_ECEF, always_xy=True)
geod = Geod(ellps="WGS84")

def gk_to_local(e, n):
    c, s = np.cos(P["theta"]), np.sin(P["theta"])
    de, dn = e - ORG_E, n - ORG_N
    return (P["dx"] + P["k"] * (c * de - s * dn),
            P["dy"] + P["k"] * (s * de + c * dn))

def local_to_gk(x, y):
    c, s = np.cos(P["theta"]), np.sin(P["theta"])
    x0, y0 = (x - P["dx"]) / P["k"], (y - P["dy"]) / P["k"]
    return c * x0 + s * y0 + ORG_E, -s * x0 + c * y0 + ORG_N

def ecef_to_enu(xyz, org_xyz, org_ll):
    lon, lat = np.deg2rad(org_ll[0]), np.deg2rad(org_ll[1])
    R = np.array([[-np.sin(lon),                np.cos(lon),                0.0],
                  [-np.sin(lat) * np.cos(lon), -np.sin(lat) * np.sin(lon), np.cos(lat)],
                  [ np.cos(lat) * np.cos(lon),  np.cos(lat) * np.sin(lon), np.sin(lat)]])
    return R @ (np.asarray(xyz, float) - np.asarray(org_xyz, float))

if __name__ == "__main__":
    org = (114.305392, 22.546117, 41.35)        # 测区原点
    pt  = (114.308177, 22.547290, 38.62)        # 待转换点

    e, n = t_wgs_gk.transform(pt[0], pt[1])     # ① WGS84 → 高斯平面
    x, y = gk_to_local(e, n)                    # ② 高斯平面 → 地方系
    enu  = ecef_to_enu(t_wgs_ecef.transform(*pt),
                       t_wgs_ecef.transform(*org), org[:2])   # ③ → 本地 ENU

    e2, n2 = local_to_gk(x, y)                  # ④ 地方系 → 高斯平面
    lon2, lat2 = t_gk_wgs.transform(e2, n2)     # ⑤ 高斯平面 → WGS84
    _, _, res = geod.inv(pt[0], pt[1], lon2, lat2)

    print(f"WGS84 : {pt[0]:.6f}, {pt[1]:.6f}, {pt[2]:.2f}")
    print(f"高斯  : E={e:.3f} N={n:.3f}")
    print(f"地方系: X={x:.3f} Y={y:.3f}")
    print(f"ENU   : E={enu[0]:.3f} N={enu[1]:.3f} U={enu[2]:.3f}")
    print(f"回程  : {lon2:.9f}, {lat2:.9f}")
    print(f"平面往返残差 {res * 1000:.4f} mm(应 < 0.1 mm)")

注意高程没有参与这条链:EPSG:4547 是二维投影,高度原样穿过。椭球高到 1985 国家高程基准正常高的换算要单独做(用高程异常或似大地水准面模型),投影库不会顺手帮你换——这正是 8.1.1 里那个「高程差 40 米」的来源。

8.2.5 自检:三条不变量加一组控制点

没有外部控制点时,坐标链路也能自检,因为投影定义本身蕴含了可验证的数学性质。三条不变量不需要任何外部数据,跑通即说明实现与 EPSG 定义一致:

# crs_selfcheck.py —— 不需要外部控制点,30 秒跑完
import math
from pyproj import CRS, Transformer

A, F = 6378137.0, 1 / 298.257223563
E2 = F * (2 - F)                 # 第一偏心率平方
CM, FE = 114.0, 500000.0         # EPSG:4547 的中央子午线与东偏常数

t_fwd = Transformer.from_crs(CRS.from_epsg(4490), CRS.from_epsg(4547), always_xy=True)
t_inv = Transformer.from_crs(CRS.from_epsg(4547), CRS.from_epsg(4490), always_xy=True)

# 不变量 1:中央子午线上 easting 恒等于东偏常数
for lat in (18.0, 22.5, 30.0, 45.0):
    e, _ = t_fwd.transform(CM, lat)
    assert abs(e - FE) < 1e-6, (lat, e)

# 不变量 2:往返一致,1e-9 度约等于 0.1 mm
for lon, lat in ((114.308177, 22.547290), (113.9, 23.1), (114.9, 21.8)):
    lon2, lat2 = t_inv.transform(*t_fwd.transform(lon, lat))
    assert abs(lon2 - lon) < 1e-9 and abs(lat2 - lat) < 1e-9, (lon, lat, lon2, lat2)

# 不变量 3:离 CM 一毫度处的 easting 增量等于 N·cosφ·Δλ(N 为卯酉圈曲率半径)
D = math.radians(0.001)
for lat in (18.0, 22.5, 30.0):
    s = math.sin(math.radians(lat))
    expect = A / math.sqrt(1 - E2 * s * s) * math.cos(math.radians(lat)) * D
    e1, _ = t_fwd.transform(CM, lat)
    e2, _ = t_fwd.transform(CM + 0.001, lat)
    assert abs((e2 - e1) - expect) < 1e-3, (lat, e2 - e1, expect)

print("三条不变量全部通过:EPSG:4547 的实参与官方定义一致")

再由项目实测控制点兜底,把「实现对不对」升级为「参数对不对」:

# control_points.csv 表头:name,lon,lat,e_expected,n_expected(平面 m,控制点由测绘部门提供)
import csv
TOL_E, TOL_N = 0.010, 0.010      # 平面 1 cm;高程单独用 0.03 m 判
with open("control_points.csv", newline="", encoding="utf-8") as fh:
    for row in csv.DictReader(fh):
        e, n = t_fwd.transform(float(row["lon"]), float(row["lat"]))
        assert abs(e - float(row["e_expected"])) <= TOL_E, row["name"]
        assert abs(n - float(row["n_expected"])) <= TOL_N, row["name"]
print("控制点比对通过")

四层校验按成本从低到高排列,每次都从第一层开始跑:

层次 检查什么 容差 是否需要外部数据
定义不变量 CM 上 easting = 500000、增量与曲率半径吻合 1e-6 m / 1e-3 m 不需要
往返一致 A→B→A 回到原值 0.1 mm 不需要
控制点套合 与实测已知点比对 平面 1 cm,高程 3 cm 需要
影像套合 与正射影像上的地物角点比对 1–2 个像元 需要

坑 4:EPSG:4326 的官方轴序是(纬度, 经度)。用 pyproj 时忘写 always_xy=True,传入 (lon, lat) 会被当成 (lat, lon),输出一个「看着还挺合理」的坐标,直到套合时才发现南北互换。

坑 5:把带带号变体(EPSG:4513–4533)当成不带带号变体(EPSG:4534–4554)。easting 多出三万八千米量级,投影后数据落在地球外面,还以为是渲染问题。

坑 6:拿 ENU 当区域坐标用。ENU 是切平面近似,10 km 外与地球表面的偏差约 8 m;跨区拼接请回到高斯平面或 ECEF。

8.3 时间基准:把「何时」对齐到毫秒

一次采集的时间戳至少经过五跳:传感器时钟 → 采集板 → 编码器 → 封装容器 → 存储介质。每一跳都可能引入固定偏移或随机抖动,你在终端看到的那串时间,只是最后一跳的结果。做时间基准,本质是把这五跳的偏移逐一标定,并留下可复算的残差统计。

8.3.1 四种时间源与它们的偏移

时间源 原点 / 定义 精度 典型偏差 处理方式
UTC 1970-01-01 起的绝对时间 NTP 同步后 1–50 ms 系统时钟漂移 入库统一基准
GPS 周秒 1980-01-06 起,周数 + 周内秒 接收机 20–100 ns 与 UTC 差 18 s 整秒(闰秒,以 IERS Bulletin C 为准) 入库前转 UTC
本地时间 UTC+8,Asia/Shanghai 无夏令时 取决于系统时钟 忘记带时区就入库 只用于界面展示
视频 PTS 容器 timescale 计数 帧级精确 原点不是 UTC;MP4 edit list 可造成负起点 存 PTS + time_base + 起始 UTC
设备上电计数 板卡上电后的毫秒数 晶振 10–100 ppm 1 ppm 漂 86.4 ms/天 由 GPS 授时校准

一行算术就能说明为什么不能只靠软件打点:1 ppm 的晶振漂移等于 86.4 ms/天,20 ms 的容差最多撑 5 小时。任何跨设备、跨天的对齐,都必须有外部授时源参与。

8.3.2 授时:NTP、PTP 与 GPS 授时的选择

方案 典型精度 硬件要求 适用场景
NTP(公网) 5–50 ms 无 展示、日志、非同步性业务
NTP(局域网 + 本地主时钟) 0.1–2 ms 普通网卡 后台服务、数据入库时间
PTP,IEEE 1588 硬件时间戳 亚微秒 支持 PTP 的网卡与交换机 多相机、多雷达同步采集
GPS 授时(1PPS + NMEA) 亚微秒 授时板卡 + 天线 野外、无网、移动平台
视频内嵌时间码(SDI/PTP) 微秒级 采集卡与相机支持 广播级录制、专业航摄

8.3.3 偏差检测脚本:把「同步了没」变成两个数

判据先定死:端到端残差 RMS < 20 ms、P95 < 50 ms。20 ms 不是拍脑袋,它是「空间误差 30 cm 以内」的等价条件:

时间残差 60 km/h 车辆 15 m/s 无人机 1 m/s 行人
1 ms 1.7 cm 1.5 cm 1 mm
20 ms 33 cm 30 cm 2 cm
100 ms 1.67 m 1.5 m 10 cm
1 s 16.7 m 15 m 1 m

检测方法是拟合一条直线,而不是逐帧做差。因为两路时钟之间存在固定偏移 b 和相对漂移 (a-1),逐帧相减会把漂移混进随机误差里:

# time_check.py  —— 判据:RMS < 20 ms 且 P95 < 50 ms
import numpy as np
import pandas as pd

TOL_RMS, TOL_P95 = 0.020, 0.050          # 秒

def check(path, col_a="trigger_utc", col_b="pts_utc"):
    df = pd.read_csv(path, parse_dates=[col_a, col_b]).dropna()
    x = df[col_a].astype("int64").to_numpy() / 1e9   # 参考时间(日志/授时打标)
    y = df[col_b].astype("int64").to_numpy() / 1e9   # 被测时间(容器 PTS 换算的 UTC)
    A = np.vstack([x - x[0], np.ones_like(x)]).T
    (a, b), *_ = np.linalg.lstsq(A, y - y[0], rcond=None)
    res = (y - y[0]) - (a * (x - x[0]) + b)          # 去趋势后的残差
    rms = float(np.sqrt(np.mean(res ** 2)))
    p95 = float(np.percentile(np.abs(res), 95))
    ok = rms < TOL_RMS and p95 < TOL_P95
    print(f"{path}: n={len(df)} offset={b * 1000:+.1f} ms "
          f"drift={(a - 1) * 1e6:+.1f} ppm rms={rms * 1000:.2f} ms "
          f"p95={p95 * 1000:.2f} ms -> {'PASS' if ok else 'FAIL'}")
    return ok

if __name__ == "__main__":
    assert all(check(p) for p in ("logs/cam01.csv", "logs/lidar_front.csv")), "时间基准未达标"

输出里两个数各有用途:offset 是固定偏移,说明起始时刻没对齐,改时间戳即可;drift 是相对漂移,单位 ppm,说明两路时钟不同源,必须引入授时,改代码没用。残差出现「台阶」则说明中途换了时钟域(闰秒、夏令时、设备重启),台阶位置就是排查入口。

8.3.4 存储约定:只存 UTC,只存有类型的时刻

数据库里时间字段的规矩只有一条:全部存 UTC,全部用带时区类型。PostgreSQL 的 timestamptz 存的是 UTC 瞬时值、显示时才按时区转换;timestamp 什么都不做,你把本地时间写进去,它原样存下来,跨时区查询时静默错 8 小时。

CREATE TABLE base.frame (
  id          bigserial PRIMARY KEY,
  stream_id   text        NOT NULL,
  pts         bigint      NOT NULL,            -- 容器时间基计数
  time_base   text        NOT NULL,            -- 例如 1/90000
  t_start_utc timestamptz NOT NULL,            -- 该流首帧对应的绝对 UTC
  acquired_at timestamptz NOT NULL,
  ingested_at timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT frame_pts_nonneg CHECK (pts >= 0)
);
COMMENT ON COLUMN base.frame.pts IS '绝对时间 = t_start_utc + pts * time_base';

从视频里把 PTS 和时间基取出来,一条命令就够:

ffprobe -v error -select_streams v:0 -show_entries stream=time_base,start_time -show_entries frame=pts_time -of csv=p=0 clip.mp4 | head -5

坑 7:把 timestamp(无时区)当 UTC 存。开发机在东八区、服务器在 UTC,同一个值在两台机器上代表两个时刻,测试全过、上线全错。

坑 8:把视频 PTS 直接当 UTC 用。PTS 是相对量,原点是录制起点或首帧;MP4 的 edit list 还能让 start_time 为正、为负或不为零。

坑 9:用「帧号 ÷ 帧率」推算时刻。安防摄像头普遍是可变帧率(VFR),帧号与时间不是线性关系,必须读每帧 PTS。

8.4 矢量底座:PostGIS 3.7 建库、索引与查询

矢量底座要同时满足三个约束:存得进(CRS 明确、几何有效)、查得快(索引与分区匹配查询形态)、查得准(同一张表里只有一个 SRID)。PostGIS 把能力都给了,但规矩得自己立——它不会阻止你在一个 geometry 列里混放三种坐标系。

8.4.1 版本、依赖与自检

本章以 PostGIS 3.7.0(2026-10-29 发布)为准;需要更保守的维护版本时用 3.6.4(2026-06 稳定维护版)。3.7 系列的要求是 GEOS 3.10+、PostgreSQL 14–19、PROJ 6.1+,装之前先确认这三个组件都在射程内。

组件 建议版本 作用 查法
PostgreSQL 17(14–19 均可) 数据库引擎 SELECT version();
PostGIS 3.7.0 空间类型与函数 SELECT postgis_full_version();
GEOS 3.10+ 几何谓词、叠加分析 同上,输出里带版本
PROJ 6.1+ 坐标转换 同上,输出里带版本
-- 扩展是否可装、装的是哪个版本
SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE name IN ('postgis', 'postgis_raster', 'postgis_topology');

CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_raster;

-- 一次看全:PostGIS / GEOS / PROJ 各自版本
SELECT postgis_full_version();

8.4.2 geometry 还是 geography

选错类型不会报错,只会让你的查询慢十倍或者算错单位:

类型 坐标单位 距离 / 面积 速度 适用条件
geometry 由 SRID 决定,投影坐标系下是米 平面计算 快 数据在单带内、需要复杂叠加分析
geography 经纬度,球面计算 球面/椭球计算 慢 2–10 倍 跨带、跨国、全球数据、短距离缓冲

判据很直接:测区在一个 3 度带内、面积不超过一个省,用 geometry + CGCS2000 投影坐标(如 EPSG:4547);数据跨带或跨半球,用 geography,或者存 4326 并按需投影到局部坐标系再算。两者都想要的做法是「主表存 geometry,另建 geography 生成列用于距离查询」,代价是多一倍存储。

8.4.3 建库 DDL:把规矩写进类型里

下面这份 DDL 把三条规矩做成了数据库层面的硬约束:SRID 写进类型修饰符(写错 SRID 直接失败)、几何有效性写成 CHECK、按时间分区(让索引重建和 VACUUM 可控):

CREATE SCHEMA IF NOT EXISTS base;

CREATE TABLE base.parcel (
  id          bigserial,
  code        text        NOT NULL,
  geom        geometry(Polygon, 4490) NOT NULL,   -- SRID 进类型,别的 SRID 写不进来
  quality     smallint    NOT NULL DEFAULT 1,
  acquired_at timestamptz NOT NULL,
  ingested_at timestamptz NOT NULL DEFAULT now(),
  props       jsonb       NOT NULL DEFAULT '{}'::jsonb,
  PRIMARY KEY (id, acquired_at),                  -- 分区表主键必须含分区键
  CONSTRAINT parcel_geom_valid    CHECK (ST_IsValid(geom)),
  CONSTRAINT parcel_geom_not_empty CHECK (NOT ST_IsEmpty(geom)),
  CONSTRAINT parcel_quality_range CHECK (quality BETWEEN 0 AND 5)
) PARTITION BY RANGE (acquired_at);

CREATE TABLE base.parcel_2026q1 PARTITION OF base.parcel
  FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE base.parcel_2026q2 PARTITION OF base.parcel
  FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');

-- 主力:几何检索走 GiST
CREATE INDEX parcel_geom_gix  ON base.parcel USING GIST (geom);
-- 时间顺序写入的大表用 BRIN,体积只有 B-tree 的百分之一量级
CREATE INDEX parcel_time_brin ON base.parcel USING BRIN (acquired_at) WITH (pages_per_range = 64);
-- 属性等值查询走普通 B-tree
CREATE INDEX parcel_code_btree ON base.parcel (code);

分区粒度按写入量倒推:单个分区控制在 2000 万行以内,季度分区在千万级要素的区县项目里刚好;如果一个月就写 5000 万行,改按月分区。分区键必须是查询里最常出现的时间范围条件,否则分区裁剪(partition pruning)不会生效,等于白分。

8.4.4 三类查询与 EXPLAIN 判读

张量底座的查询形态只有三类:范围检索、最近邻、聚合统计。三类各配一种索引,判读 EXPLAIN 时只看三个数:节点类型(有没有 Index Scan)、Rows Removed by Filter(索引筛得干不干净)、Buffers(读了多少页)。

第一类,范围检索。关键是先用 && 这个纯几何包围盒运算走 GiST,再用精确谓词过滤:

-- 检索某季度内落在给定矩形中的地块,并给出平方米面积
EXPLAIN (ANALYZE, BUFFERS)
WITH win AS (
  SELECT ST_MakeEnvelope(113.90, 22.50, 113.95, 22.55, 4490) AS g
)
SELECT p.id, p.code, round(ST_Area(p.geom::geography)::numeric, 2) AS area_m2
FROM base.parcel p, win
WHERE p.acquired_at >= '2026-01-01' AND p.acquired_at < '2026-04-01'   -- 分区键
  AND p.geom && win.g                                                  -- 走 GiST
  AND ST_Intersects(p.geom, win.g)                                     -- 精确过滤
ORDER BY p.id
LIMIT 100;
Limit  (cost=0.55..412.37 rows=100 width=40) (actual time=0.041..2.918 rows=100 loops=1)
  Buffers: shared hit=186
  ->  Nested Loop  (cost=0.55..41822.11 rows=10124 width=40) (actual time=0.039..2.881 rows=100 loops=1)
        ->  Index Scan using parcel_geom_gix on parcel_2026q1 p
              Index Cond: (geom && '0103...'::geometry)
              Filter: st_intersects(geom, '0103...'::geometry)
              Rows Removed by Filter: 231
Execution Time: 2.9 ms

判读要点:出现 Index Scan using parcel_geom_gix 说明 GiST 生效;Rows Removed by Filter 只有 231,说明包围盒筛选已经很接近精确结果,索引质量好;如果这个数涨到几万,说明数据里混了很多「大而空」的面,先用 ST_Subdivide 切小再入库。实际数字随数据量浮动,看量级与节点类型,不要照抄数字。

第二类,最近邻。KNN 靠 GiST 的 <-> 距离算子排序,LIMIT 会触发索引的「边扫边排序」:

-- 500 m 内的最近 10 个地块,单位米,用 geography 做球面距离
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.id, p.code,
       round(ST_Distance(p.geom::geography, pt.g::geography)::numeric, 1) AS dist_m
FROM base.parcel p,
     (SELECT ST_SetSRID(ST_MakePoint(114.305392, 22.546117), 4490) AS g) pt
WHERE ST_DWithin(p.geom::geography, pt.g::geography, 500)
ORDER BY p.geom::geography <-> pt.g::geography
LIMIT 10;

第三类,聚合统计。分组聚合的瓶颈通常在 IO 而不是索引,用分区裁剪把范围压到 1–2 个分区最有效:

-- 按质量等级统计图斑数与面积(只扫 2026Q1 分区)
SELECT quality,
       count(*)                                              AS n,
       round((sum(ST_Area(geom::geography)) / 10000)::numeric, 2) AS area_ha
FROM base.parcel
WHERE acquired_at >= '2026-01-01' AND acquired_at < '2026-04-01'
GROUP BY quality
ORDER BY quality;
查询形态 该走的索引 退化时的典型原因
范围检索 GiST(geom) + 分区裁剪 谓词写成 ST_Intersects 而漏了 &&;分区键没出现在 WHERE
最近邻 GiST(geom) + ORDER BY <-> 在 ORDER BY 里对列套 ST_Transform
聚合统计 BRIN(acquired_at) + 分区 统计整表;或用了 geography 导致全量球面计算
属性过滤 B-tree(code) 用 LIKE 前缀加函数包装,或类型隐式转换

8.4.5 索引维护与批量导入

批量导入的顺序错了,后面所有查询都要还债。推荐流程:建无索引的临时表 → COPY 灌数 → 校验几何 → 一次建索引 → ANALYZE → 按几何列 CLUSTER 一次。

-- 1) 灌数(比逐条 INSERT 快一个数量级以上)
\copy base.parcel (code, geom, quality, acquired_at) FROM 'parcel_2026q1.csv' WITH (FORMAT csv, HEADER true)

-- 2) 校验:无效几何、空几何、SRID 错误各有多少
SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom))       AS invalid,
       count(*) FILTER (WHERE ST_IsEmpty(geom))           AS empty,
       count(*) FILTER (WHERE ST_SRID(geom) <> 4490)      AS wrong_srid
FROM base.parcel WHERE acquired_at >= '2026-01-01' AND acquired_at < '2026-04-01';

-- 3) 修掉自相交(只对无效行做,别全表跑)
UPDATE base.parcel SET geom = ST_MakeValid(geom)
WHERE NOT ST_IsValid(geom)
  AND acquired_at >= '2026-01-01' AND acquired_at < '2026-04-01';

-- 4) 建索引 → 统计信息 → 物理聚簇
CREATE INDEX IF NOT EXISTS parcel_geom_gix ON base.parcel USING GIST (geom);
ANALYZE base.parcel;
CLUSTER base.parcel USING parcel_geom_gix;   -- 大表耗时,放在业务低峰

坑 10:在 EPSG:4490 的 geometry 上直接调 ST_Area,得到的是平方度而不是平方米。要么转成 geography(ST_Area(geom::geography)),要么先投影到 4547 再算。距离同理,ST_Distance 在 4490 上返回度。

坑 11:在 WHERE 里给列套函数,例如 WHERE ST_Transform(geom, 4547) && …。索引是对列本身建的,列被函数包住就用不上,执行计划退化为全表扫。要用投影坐标就建表达式索引,或直接存两列。

坑 12:批量导入后不 ANALYZE。规划器按默认选择率瞎估,rows 估成一万实际一千万,于是选了 Nested Loop,查询从毫秒变成分钟。

8.5 影像与栅格:COG、STAC 与对象存储

影像底座的矛盾很集中:单景动辄几 GB 到几十 GB,用户却只想看一个 256×256 的瓦片。解法是把「一个文件」变成「一个可按字节区间读取的容器」,再把「一堆文件」变成「一个可检索的目录」。

8.5.1 COG:为什么 range 请求能救你的带宽

COG(Cloud Optimized GeoTIFF)不是新格式,是对 GeoTIFF 的布局约束:文件头与索引放在最前面,金字塔(overview)按「由粗到细」排列,瓦片数据分块存储并带内部索引。客户端先取文件头,读到各层级的瓦片偏移表,然后只请求需要的那几百 KB。

COG 文件内部布局
┌────────────┬─────────────┬───────────────────┬────────────────────┐
│ TIFF 头    │ 主 IFD      │ 概览 IFD 1..n     │ 瓦片数据(分块)    │
│ (前 16 KB) │ 指向各层    │ 由粗到细           │ 每块 256/512 像素   │
└────────────┴─────────────┴───────────────────┴────────────────────┘
      ▲              ▲                                    ▲
      │ 第 1 次 range│ 第 2 次 range(读瓦片偏移表)        │ 第 3 次 range
      └──────────────┴────────────────────────────────────┘
              三次请求即可显示一屏,与整景大小无关

量级对照(以一景 20 cm 分辨率、覆盖一个区县的正射影像为例,整景压缩后约 8 GB):

操作 普通 GeoTIFF COG
看缩略图 下载整景,约 8 GB 2–3 次 range,约 100–300 KB
看 1 个 256 像素瓦片 视分块情况,可能整景 1 次 range,约 30–100 KB
换一个比例尺 重新下载 换一个概览层级,量级相同
断点续传 需要自己实现 天然幂等,重试即可

生成与校验 COG 用 rio-cogeo,两个命令就够:

# 生成:内部 512 分块、DEFLATE 压缩、自动建概览、保留 6 级金字塔
rio cogeo create ortho_2025q1.tif ortho_2025q1_cog.tif \
  --cog-compress DEFLATE --cog-blocksize 512 --overview-level 6 \
  --overview-resampling average

# 校验:不通过会列出具体原因(非瓦片、概览缺失、IFD 顺序错等)
rio cogeo validate ortho_2025q1_cog.tif

校验通过只说明布局合规,不说明服务端支持 range。上线前做一次「半开区间」测试:用 curl 只取前 16 KB 和末尾 16 KB,两者都应该在百毫秒内返回,且 Content-Range 头正确。

curl -s -r 0-16383 -o /dev/null -w "%{http_code} %{size_download} %{time_total}\n" \
  https://oss.example.com/ortho/2025q1/ortho_2025q1_cog.tif

8.5.2 STAC:把「一堆文件」变成可检索目录

STAC(SpatioTemporal Asset Catalog)1.1.0 是目前稳定版本,它把影像目录抽象成三层:Catalog(目录)、Collection(集合)、Item(单景资产)。一个行政区一年的影像就是一个 Collection,每景是一个 Item,栅格文件挂在 Item 的 assets 里。

最小可用的 Item 长这样,字段一个都不能少:

{
  "type": "Feature",
  "stac_version": "1.1.0",
  "id": "GD-SZ-2025Q1-ortho-0p2m",
  "geometry": {"type": "Polygon", "coordinates": [[[113.75, 22.40], [114.62, 22.40],
                [114.62, 22.86], [113.75, 22.86], [113.75, 22.40]]]},
  "bbox": [113.75, 22.40, 114.62, 22.86],
  "properties": {
    "datetime": "2025-02-18T02:35:00Z",
    "gsd": 0.2,
    "proj:epsg": 4547,
    "eo:cloud_cover": 1.8,
    "platform": "aerial-photo"
  },
  "assets": {
    "data": {"href": "ortho_2025q1_cog.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized",
             "roles": ["data"]},
    "thumbnail": {"href": "thumb.jpg", "type": "image/jpeg", "roles": ["thumbnail"]},
    "metadata": {"href": "metadata.xml", "type": "application/xml", "roles": ["metadata"]}
  },
  "links": [
    {"rel": "root", "href": "../../catalog.json"},
    {"rel": "collection", "href": "./collection.json"},
    {"rel": "self", "href": "./GD-SZ-2025Q1-ortho-0p2m.json"}
  ]
}

STAC 1.1 的几条实践要点,直接用就能少踩坑:id 一旦发布就不要改(下游用它做外键);datetime 必填,只有区间时用 start_datetime 加 end_datetime;links 里的 href 用相对路径,部署换域名不用改数据;proj:epsg 与 gsd 写在 properties 里,客户端才能在检索阶段就过滤掉分辨率不合格的景。

8.5.3 对象存储前缀设计:先想清楚怎么删

对象存储没有目录,前缀就是一切。前缀设计错了,最直接的代价是「想删一批数据时删不掉、想统计一批数据时数不清」。推荐按「数据类型 / 区域 / 时间 / 集合」四级分:

oss://gis-data/
└── ortho/                          # 数据类型:影像正射
    └── GD-SZ/                      # 区域:广东-深圳
        └── 2025Q1/                 # 时间:采集季度(用于生命周期与批量删除)
            └── ortho-0p2m/         # 集合:分辨率规格
                ├── collection.json
                ├── GD-SZ-2025Q1-ortho-0p2m.json     # Item
                ├── GD-SZ-2025Q1-ortho-0p2m.tif      # COG
                └── thumb.jpg
前缀层级 取值示例 承担什么职责
第 1 级 数据类型 ortho / dem / pointcloud / vector 权限分组、成本核算
第 2 级 区域 GD-SZ 按业务线授权
第 3 级 时间 2025Q1 生命周期规则、批量清理
第 4 级 集合与 Item ortho-0p2m/xxx.json STAC 检索的落地位置

三条实践约定。一,时间用「年 + 季度」而不是完整日期,前缀数量可控、扫描成本低。二,不可变数据用版本前缀(v1/ v2/),需要回滚时换前缀而不是覆盖文件。三,清理规则挂在第 3 级:临时成果 90 天过期,正式成果永不自动删除,靠第 1 级的权限区分。

8.5.4 一个行政区影像集合的最小生成代码

围绕「一个行政区、一年、一档分辨率」这一组约束,pystac 三十行就能把 Collection 与 Item 写全:

# build_stac.py  |  pip install "pystac>=1.10" "shapely>=2.0"
import pystac
from shapely.geometry import box, mapping

COLL_ID = "gd-sz-ortho-0p2m"
coll = pystac.Collection(
    id=COLL_ID,
    description="深圳 0.2 m 正射影像,季度更新",
    extent=pystac.Extent(
        spatial=pystac.SpatialExtent([[113.75, 22.40, 114.62, 22.86]]),
        temporal=pystac.TemporalExtent([[None, None]]),
    ),
    license="proprietary",
)
coll.add_link(pystac.Link.root("../../catalog.json"))

def add_item(coll, item_id, dt, href, bbox):
    item = pystac.Item(id=item_id, geometry=mapping(box(*bbox)), bbox=bbox,
                       datetime=dt, properties={"gsd": 0.2, "proj:epsg": 4547})
    item.add_asset("data", pystac.Asset(
        href=href, media_type="image/tiff; application=geotiff; profile=cloud-optimized",
        roles=["data"], extra_fields={"gsd": 0.2}))
    item.add_asset("thumbnail", pystac.Asset(href="thumb.jpg", media_type="image/jpeg",
                                             roles=["thumbnail"]))
    item.set_self_href(f"./{item_id}.json")
    coll.add_item(item)          # 会同时写入 rel=item 链接

add_item(coll, "GD-SZ-2025Q1-ortho-0p2m", "2025-02-18T02:35:00Z",
         "GD-SZ-2025Q1-ortho-0p2m.tif", [113.75, 22.40, 114.62, 22.86])
coll.normalize_hrefs("./stac")   # 统一成相对路径
coll.save(catalog_type=pystac.CatalogType.SELF_CONTAINED)
print(coll.get_self_href())

生成的目录是静态的,直接扔对象存储就能被任何支持 STAC 的客户端检索,不需要部署服务。要素规模上千以后再考虑换 STAC API(stac-fastapi 一类),静态 catalog 的启动成本低,回滚也简单。

坑 13:把非 COG 的 GeoTIFF 直接挂到对象存储当影像服务。普通 GeoTIFF 的索引在文件末尾,客户端必须整景下载才能读到索引,range 请求等于没用。

坑 14:Item 的 id 随生成时间变化(如带时间戳后缀)。下游用它做外键,一改就断链;id 必须稳定且唯一。

坑 15:STAC 的 datetime 用本地时间且不带时区。所有客户端按 UTC 解析,检索区间会整体偏移 8 小时,季度边界上的景会漏检。

8.6 分析型底座:GeoParquet 与 DuckDB

PostGIS 擅长「事务 + 并发 + 空间谓词」,不擅长「扫一遍全表做聚合」。凡是「对一亿条记录做一次分组统计」这类任务,把数据导出成列式文件、用向量化引擎扫,比在数据库里硬跑快一个数量级。

8.6.1 GeoParquet 的版本选择

GeoParquet 在 Parquet 之上约定了几何列的存法与元数据:几何以 WKB 存在 BINARY 列里,文件元数据写明哪一列是几何、CRS 是什么、包围盒范围。这带来三个直接好处:列裁剪(只读需要的列)、谓词下推(按统计信息跳过整个 row group)、跨语言可读(Python、DuckDB、Spark 都认)。

版本 状态 关键变化 生产建议
1.1.0 稳定版(2024-06-19) 明确 bbox 覆盖、几何列元数据、编码约定 当前生产默认选它
2.0.0-rc.1 候选版(2026-07-19) 基于 Parquet 原生 Geospatial Logical Types 关注进展,暂不上主链路

选 1.1 而不是 2.0 的原因很实际:2.0 依赖 Parquet 侧的原生地理类型支持,读取端(各语言库、引擎、BI 工具)还没铺开,一旦上游改了类型编码,历史文件要重写。生产环境「能用 1.1 说清楚的事,不要用 rc 版本赌未来」。

写出 GeoParquet 用 GeoPandas 一行,写入时注意三件事:CRS 要显式设置、几何列只保留一列、按查询常用的字段排序:

import geopandas as gpd

gdf = gpd.read_file("data/parcel_2026q1.gpkg", layer="parcel")
gdf = gdf.to_crs(4490)                                  # 统一到 CGCS2000 地理
gdf = gdf.sort_values(["quality", "code"]).reset_index(drop=True)
gdf.to_parquet("warehouse/parcel/dt=2026-01-01/part-0.parquet",
               compression="zstd", compression_level=9, row_group_size=200_000)

分区目录用 Hive 风格(字段=值),DuckDB 能自动把分区键当列读进来,不需要额外声明。row_group_size 按 10 万–50 万行给,太小则元数据膨胀,太大则谓词下推粒度变粗。

8.6.2 DuckDB 1.5 + spatial:直查文件,不建库

DuckDB 1.5.x 的 spatial 扩展提供了 ST_Read、ST_AsMVT、ST_Area_Spheroid 这些函数,可以直接对文件做空间查询。千万级要素的统计任务,不需要导入、不需要建索引:

-- duckdb 命令行:duckdb -c ".read stats.sql"
INSTALL spatial;
LOAD spatial;

-- 1) 直查 Hive 分区:dt 从目录名自动成为列
CREATE OR REPLACE VIEW parcel AS
SELECT * FROM read_parquet('warehouse/parcel/dt=*/part-*.parquet',
                           hive_partitioning = true);

-- 2) 千万级统计:按质量等级求要素数与球面面积(公顷)
SELECT quality,
       count(*)                                                   AS n,
       round(sum(ST_Area_Spheroid(geom)) / 10000.0, 2)            AS area_ha,
       round(avg(ST_Perimeter(geom)), 1)                          AS avg_perim
FROM parcel
WHERE dt >= DATE '2026-01-01' AND dt < DATE '2026-04-01'
GROUP BY quality
ORDER BY quality;

-- 3) 只读需要的列:几何列不参与时,IO 直接省一半
SELECT count(*) FROM read_parquet('warehouse/parcel/dt=2026-01-01/part-0.parquet');

-- 4) 出矢量瓦片:按 z/x/y 切 MVT,直接交给前端
COPY (
  SELECT ST_AsMVT(t, 'parcel', 4096, 'geom') AS mvt
  FROM (SELECT code, quality, ST_AsMVTGeom(geom,
               ST_Extent(ST_MakeEnvelope(113.90, 22.50, 113.95, 22.55, 4490))) AS geom
        FROM parcel) t
) TO 'tiles/parcel.pbf' (FORMAT BLOB);

ST_Area_Spheroid 与 PostGIS 的 ST_Area(geom::geography) 口径一致,都是椭球面积,可以直接对账。这一步很重要:两个引擎算出来的面积必须能对齐到小数点后两位,否则口径差异会污染统计结论。

8.6.3 性能对照:什么情况下该换引擎

下面是参考量级,口径统一为「1000 万条多边形、8 个属性列,按质量等级分组求面积和」,同一台 16 核 / 64 GB 机器,冷缓存:

方案 数据组织 耗时 说明
PostGIS 3.7 季度分区 + GiST 约 18 s 走全分区顺序扫,索引在这个查询里帮不上
DuckDB 1.5 + GeoParquet 单文件 zstd,12 线程 约 2.4 s 列裁剪 + 向量化聚合
DuckDB 1.5 + GeoParquet 只读 3 列 + row group 过滤 约 1.1 s 几何列不参与时最快
PostGIS + 物化视图 预聚合 约 0.05 s 但换维度就要重建

这些数字会随机器、数据分布、压缩率浮动,结论只看量级关系:扫描型聚合用列式文件快一个数量级,而带空间谓词的交互式查询(范围检索、最近邻)PostGIS 的 GiST 仍然不可替代。两者不是替代关系,是分工关系——热路径走 PostGIS,批量分析走 GeoParquet + DuckDB。

这个分工决定了底座的组织方式:同一份要素,入库 PostGIS 提供在线服务,同时定期导出 GeoParquet 到对象存储供分析。两份数据必须来自同一批源文件、同一套 CRS,用 8.8 的 manifest 保证「两处一致」,否则统计结果和服务结果会打架。

坑 16:把 GeoParquet 当「备份格式」整体导出,所有字段全量落盘。列裁剪的前提是文件里真的分了列,把几何、属性、原始 JSON 一股脑塞进一列,DuckDB 只能整行读,列存优势全没了。

坑 17:两份数据(PostGIS 与 GeoParquet)用不同的 CRS 或不同的面积口径。服务端用 4547 平面算面积,分析端用 4490 球面算,同一块地差出千分之几,统计结论直接对不上。

坑 18:分区字段选错。按采集日期分区却总按行政代码查询,分区裁剪永远不生效,DuckDB 要把所有文件头读一遍,第一次查询慢到让人以为数据库挂了。

8.7 三维底座:3D Tiles 1.1 与 splat 瓦片

三维底座要装两种东西,它们的评价标准完全不同:一是测绘成果(倾斜摄影、白模、DEM 加影像),要求几何准、可量测、带属性;二是近景真实感(3DGS),要求看着真、加载快,但不承担量测任务。混为一谈的后果是拿渲染成果去画竣工图。

8.7.1 两类内容,一种承载方式

内容类型 几何精度 数据量 承载格式 用途
倾斜摄影 / 白模 厘米到分米 单平方公里 1–5 GB 3D Tiles 1.1(b3dm / glTF) 量测、规划、审查
点云 厘米 单平方公里 5–30 GB 3D Tiles 1.1(pnts)或 COPC 变形监测、断面
DEM + 影像 米级 单区县百 GB 级 地形瓦片 + COG 地形分析、通视
3DGS 近景 视觉真实,无面 单场景 1–10 GB splat tileset(Cesium 层次 LOD) 展示、巡检回放

3D Tiles 1.1 相对 1.0 有三个对底座有实际影响的变化:瓦片内容可以直接是 glTF(不再强制包一层 b3dm)、支持隐式瓦片(implicit tiling,用子树文件描述空间划分,替代巨大的 tileset.json 树)、支持元数据(metadata,把每栋楼的属性随瓦片一起走)。对一个区县级的倾斜摄影,隐式瓦片能把 tileset.json 从几百 MB 压到几 KB,客户端启动解析时间从分钟级降到毫秒级。

8.7.2 目录结构与 tileset.json

标准布局如下,四类文件各司其职,content 与 subtrees 的数量由隐式划分决定,不需要手写:

tiles/oblique-3dtiles/
├── tileset.json                 # 只有一个根瓦片 + 隐式划分参数
├── content/{level}/{x}/{y}.glb   # 每个瓦片的 glTF 内容
├── subtrees/{level}/{x}/{y}.subtree  # 可用性位图 + 子节点索引
└── metadata/schema.json         # 每瓦片 / 每要素的属性表结构
{
  "asset": {"version": "1.1"},
  "geometricError": 512.0,
  "root": {
    "boundingVolume": {"region": [1.9853, 0.3910, 2.0005, 0.3990, 0.0, 120.0]},
    "geometricError": 256.0,
    "refine": "ADD",
    "content": {"uri": "content/0/0/0.glb"},
    "implicitTiling": {
      "subdivisionScheme": "QUADTREE",
      "subtreeLevels": 6,
      "availableLevels": 18,
      "subtrees": {"uri": "subtrees/{level}/{x}/{y}.subtree"}
    }
  }
}

boundingVolume 用 region 时,前四个数是弧度:西经、南纬、东经、北纬,后两个是高程范围米。这里 [1.9853, 0.3910, 2.0005, 0.3990] 对应东经 113.75–114.62 度、北纬 22.40–22.86 度——弧度与度搞混是三维底座最常见的低级错误,症状是「地球上看不到模型」,因为包围盒被放到了地球内部。

属性随瓦片走,靠的是 metadata:把楼栋数、采集日期、数据版本挂在瓦片级,点击时不用回查数据库:

"metadata": {
  "class": "tile",
  "properties": {"bldgCount": {"minimum": 0, "maximum": 4200}, "surveyYear": {"minimum": 2022}}
}

8.7.3 splat tileset:真实感归真实感

Cesium 在 2026-04 发布了「3D Tiles + 层次 LOD」的 3D Gaussian Splats 方案,CesiumJS 1.147 可以直接加载带 LOD 的 splat tileset。它在底座里的位置很明确:作为「看得清」的补充图层,与「量得准」的测绘瓦片叠在同一个地球上,用图层开关切换。

加载代码与普通三维瓦片一致,关键参数是屏幕空间误差:

import * as Cesium from "cesium";

const viewer = new Cesium.Viewer("cesiumContainer", {
  terrain: Cesium.Terrain.fromWorldTerrain(),
});

const splat = await Cesium.Cesium3DTileset.fromUrl("/tiles/splat-clip/tileset.json", {
  maximumScreenSpaceError: 12,   // 越小越清晰、显存越高;首屏可先给 32 再降到 12
  dynamicScreenSpaceError: true, // 视野边缘降质,省 10–30% 显存
});
viewer.scene.primitives.add(splat);
viewer.zoomTo(splat, new Cesium.HeadingPitchRange(0.0, -0.35, 180.0));

8.7.4 首屏预算与显存估算

三维底座的失败多数不是「打不开」,是「在甲方那台笔记本上转不动」。预算要在建模阶段就定,而不是上线后优化:

目标设备 单帧三角面上限 纹理上限 显存上限 首屏目标
集成显卡(笔记本办公本) 80 万 400 MB 1.0 GB 5 s 内出粗模
移动端独显 4 GB 200 万 1.2 GB 2.5 GB 3 s
桌面独显 8 GB 500 万 3 GB 6 GB 2 s

估算口径:100 万三角面(含位置、法线、UV、索引)约占 40 MB 显存,纹理按压缩后尺寸的 1.33 倍计;3DGS 按每 splat 只存 DC 项约 56 字节、完整三阶球谐约 236 字节计,2000 万个 splat 在 DC 模式下约 1.1 GB。首屏策略统一为「先粗后细」:初始 maximumScreenSpaceError 给 32,视野稳定后降到 12–16,用户感知的是「秒开」,而不是「等最高精度」。

坑 19:把倾斜摄影的原始 OSGB 目录直接挂服务。几十万个小文件、没有统一的几何误差层级,客户端一次请求几百个文件,首屏半分钟起。

坑 20:geometricError 拍脑袋填。它与瓦片包围盒、屏幕空间误差是联动关系,填小了客户端死等最高层级,填大了永远加载不到细节;用转换工具生成,别手改。

坑 21:拿 3DGS 成果去量测。3DGS 是渲染表示,没有稳定的几何面,两点之间的「距离」随视角与训练质量变。量测只能用 mesh、点云或正射影像。

8.8 数据治理:版本、血缘、权限、合规

治理听起来抽象,落到操作上只有四个问题:这份数据是哪一版、从哪来、谁能看、能不能对外发。每个问题都要有可查询的答案,而不是靠某个人的记忆。

8.8.1 版本:manifest 加内容哈希

manifest 是一张表,一行描述一个成果文件;哈希用来判断「是不是同一份内容」,而不是「文件名一样不一样」。文件名会重复、会被复制粘贴,哈希不会:

CREATE TABLE gov.manifest (
  id           bigserial PRIMARY KEY,
  dataset_id   text        NOT NULL,            -- 逻辑数据集,例如 ortho-0p2m
  version      text        NOT NULL,            -- 语义版本,例如 2026.1
  rel_path     text        NOT NULL,            -- 对象存储内的相对路径
  sha256       char(64)    NOT NULL,            -- 内容哈希
  bytes        bigint      NOT NULL,
  crs          text        NOT NULL,            -- 例如 EPSG:4547
  producer     text        NOT NULL,            -- 生产工具与版本
  produced_at  timestamptz NOT NULL,
  source_ids   text[]      NOT NULL DEFAULT '{}',  -- 上游 manifest.id
  UNIQUE (dataset_id, version, rel_path)
);
CREATE INDEX manifest_ds_ver ON gov.manifest (dataset_id, version);
CREATE INDEX manifest_src    ON gov.manifest USING GIN (source_ids);

生成哈希与登记台账用一条命令串起来,写进流水线而不是手工执行:

# 1) 算哈希并追加到清单(find -print0 处理中文与空格文件名)
find output/ -type f -name "*.tif" -print0 \
  | xargs -0 sha256sum > manifest.sha256

# 2) 与上一版比对,只看差异,避免整批重传
sha256sum -c manifest.sha256 --quiet 2>&1 | head

# 3) 登记到台账(psql 的 \copy 支持从 stdin 读 CSV)
psql -d gis -c "\copy gov.manifest (dataset_id, version, rel_path, sha256, bytes, crs, producer, produced_at) FROM 'manifest.csv' WITH (FORMAT csv, HEADER true)"

复现一份三个月前的报告,流程是:从报告里读出 dataset_id 与 version → 查 manifest 拿到全部 rel_path 与 sha256 → 按清单拉回文件 → 校验哈希 → 重跑。任何一步对不上就中止,不猜。

8.8.2 血缘:从原始影像到服务图层

血缘回答「这个图层是怎么来的」。它是一张有向图,每条边记录一次加工,节点是 manifest 里的一行:

原始影像(2025-02-18 航摄)
   │ 空三 + 密集匹配          [tool: 空三软件 x.y]
   ▼
点云 / DSM
   │ 正射纠正 + 匀色           [tool: 正射流程 z]
   ▼
DOM(0.2 m) ────────────────┐
   │ COG 转换 + 概览           │ 入库
   ▼                          ▼
COG(2026.1) ──► PostGIS 矢量层 ──► 瓦片服务
   │                                   │
   └──────► GeoParquet 分析表 ─────────┘
                    │
                    ▼
              统计报告(2026-04)

落地方式就两条约束:每个产物必须记录 source_ids(上游 id 数组),每个加工步骤必须记录工具名与版本。有了这两样,任意一份结论都能沿图回溯到原始影像,中间缺一环就说明血缘断了。

8.8.3 权限:行级、列级与服务账号

空间数据天然带行政区划属性,权限也按区划切。PostgreSQL 的行级安全(RLS)能把这层约束放进数据库,而不是指望每个应用都记得加 WHERE:

-- 1) 行级:每个区县只能看到本区县的地块
ALTER TABLE base.parcel ENABLE ROW LEVEL SECURITY;

CREATE POLICY parcel_by_district ON base.parcel
  FOR SELECT
  USING (props->>'district' = current_setting('app.district', true));

-- 2) 会话建立时设置当前区县(应用连接池里 set_config 一次)
SET app.district = '440305';

-- 3) 列级:对外视图隐藏敏感字段
CREATE VIEW pub.parcel_public AS
SELECT id, code, geom, quality, acquired_at
FROM base.parcel;                 -- props 里的权属人信息不外露
REVOKE ALL ON base.parcel FROM PUBLIC;
GRANT SELECT ON pub.parcel_public TO svc_tiles;
角色 能做什么 边界
数据管理员 建表、改结构、跑迁移 不直接对外提供服务
入库服务账号 INSERT / UPDATE 指定 schema 无 DDL 权限、无跨 schema 读取
瓦片服务账号 SELECT 视图 只读、只读公开列
分析账号 只读 GeoParquet 与只读物化视图 不碰生产表

原则只有一条:服务账号按「最小可用的 schema 加最小的动词」授予,绝不给 superuser。空间数据的破坏往往来自一次全表 UPDATE,而不是外部攻击。

8.8.4 合规:分级、分辨率红线与脱敏

测绘成果在国内是有级别的:涉及国家秘密的成果按密级管理,公开发布另有分辨率与要素的限制。这一块不能靠经验,必须按现行的《测绘法》《测绘成果管理条例》以及自然资源主管部门的最新规定执行;本章不展开条文,只给工程上要落的三件事。

第一,给每份成果打密级标签,写进 manifest 与对象存储的元数据,并在发布链路上做强制校验。第二,公开底图与内部底图分开存放,公开版按现行分辨率规定处理,处理动作本身留记录。第三,涉密要素(军事设施、重要管线、精确坐标的控制点)在出图、出瓦片、出 API 三个阶段分别脱敏,脱敏规则进代码库接受评审。具体阈值与要求逐年调整,上线前请以主管部门的现行文件为准。

8.8.5 治理检查表

检查项 判据 频率 谁负责
manifest 完整率 100%,无空哈希 每次发布 数据工程
哈希校验通过率 100%,失败即回滚 每次发布 数据工程
血缘完整性 每个产物有 source_ids,可回溯到原始影像 每次发布 数据工程
SRID 一致性 单表单 SRID,缺失率 0 每日 平台
几何有效性 有效几何 > 99.9%,空几何 0 每日 平台
权限最小化 服务账号无 DDL,无跨 schema 读 每月 安全
密级标签 100% 覆盖,发布链路强校验 每次发布 安全
底图与成果对齐 控制点残差 < 1 个像元 每季度 测绘

坑 22:manifest 只登记「最终成果」,中间产物不登记。三个月后要复现,发现缺了正射纠正那一步的输入,血缘在这里断掉,只能整条重跑。

坑 23:用文件名当版本。加了「最终版」「真的最终版」后缀的文件名在两次交付后彻底失控;版本号进表,文件名保持稳定。

坑 24:公开底图与内部底图共用一份对象存储前缀,只靠应用层判断。一次配错权限就是整批影像对外可读;前缀与桶都要物理隔离。

8.9 用 DeepSeek Harness 维护底座

底座的问题有两个特点:种类固定、出现频繁。固定到可以写成脚本,频繁到必须自动化。这一节把前面八节的检查项收拢成一个「数据体检」技能,接进 CI,让底座在每次入库前自己证明干净。

8.9.1 体检脚本:五类问题一次查完

五类问题是长期统计下来最高频的:CRS 缺失、CRS 不在白名单(等同混合坐标系)、几何自相交、空几何、时间戳异常(未来时间、全空、重复)。脚本输出机器可读的 JSON,退出码用来说「过不过」:

# tools/data_health.py —— 底座体检,退出码 0 = 通过,1 = 有阻断项
import json
import sys

import geopandas as gpd
import pandas as pd
import pyogrio

CRS_WHITELIST = {"EPSG:4490", "EPSG:4547"}     # 项目允许的 CRS,按需收窄
TS_COLUMNS = ("acquired_at", "survey_time", "created_at")
NOW = pd.Timestamp.now(tz="UTC").tz_localize(None)   # 统一用 UTC 比较,避免本地时区偏移

def check_layer(path: str, layer: str) -> list[dict]:
    info = pyogrio.read_info(path, layer=layer)
    gdf = gpd.read_file(path, layer=layer, engine="pyogrio")
    issues: list[dict] = []

    crs = str(info.get("crs")) if info.get("crs") else None
    if crs is None:
        issues.append({"kind": "no_crs", "layer": layer, "detail": "未声明 CRS"})
    elif crs not in CRS_WHITELIST:
        issues.append({"kind": "crs_not_allowed", "layer": layer, "detail": crs})

    geom = gdf.geometry
    invalid = int((~geom.is_valid).sum())
    empty = int((geom.is_empty | geom.isna()).sum())
    if invalid:
        issues.append({"kind": "invalid_geometry", "layer": layer, "detail": invalid})
    if empty:
        issues.append({"kind": "empty_geometry", "layer": layer, "detail": empty})

    for col in TS_COLUMNS:
        if col not in gdf.columns:
            continue
        ts = pd.to_datetime(gdf[col], errors="coerce", utc=True).dt.tz_localize(None)
        future = int((ts > NOW).sum())
        missing = int(ts.isna().sum())
        if future or missing:
            issues.append({"kind": "bad_timestamp", "layer": layer,
                           "detail": {"column": col, "future": future, "missing": missing}})
    return issues

def main(argv: list[str]) -> int:
    path = argv[1]
    report = {"file": path, "layers": 0, "issues": []}
    for layer in pyogrio.list_layers(path)[:, 0]:
        report["layers"] += 1
        report["issues"].extend(check_layer(path, layer))
    blocking = [i for i in report["issues"]
                if i["kind"] in {"no_crs", "crs_not_allowed", "invalid_geometry"}]
    report["blocking"] = len(blocking)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 1 if blocking else 0

if __name__ == "__main__":
    sys.exit(main(sys.argv))
检查项 判定 处置
CRS 缺失 阻断 回退到源数据,确认后再入库
CRS 不在白名单 阻断 走 8.2 的转换链,转换后再入库
几何自相交 阻断 ST_MakeValid 或退回作业队修测
空几何 告警 业务规则判定:允许则标注,不允许则剔除
时间戳在未来 / 全空 告警 查授时链路,按 8.3 修数据

8.9.2 接进 CI:入库前的门禁

把体检脚本挂成 CI 的一个 job,用 DSH 做「判断与摘要」,让脚本保持纯粹的规则执行:

#!/usr/bin/env bash
set -euo pipefail

# 1) 规则检查:失败即阻断流水线
python tools/data_health.py build/parcel_2026q1.gpkg > build/health.json

# 2) 只读模式跑诊断,产出可读摘要与修复建议;--json 便于后续消费
dsh --profile headless \
  "读取 build/health.json,按严重程度分组,输出三段:阻断项与修复顺序、告警项与业务影响、本次体检的通过判据。不要修改任何文件。" \
  --permission-mode read-only --json > build/health_summary.json

门禁的判据要写清楚:阻断项为 0 才允许进入建索引与发布;告警项不为 0 时必须在工单里留一条处置记录。这样「数据脏了」不再是口头提醒,而是流水线上一个红叉。

8.9.3 沉淀成技能:下一次不用重新讲

把体检流程写进 SKILL.md,任何人接手都能一次跑对:

---
name: data-health
description: 空间数据底座体检——CRS、几何、时间戳、manifest 四项,输出阻断/告警分组
---

1. 运行 python tools/data_health.py <数据文件>,把 JSON 结果保存到 build/health.json
2. 检查 gov.manifest:dataset_id 与 version 是否齐全,sha256 是否为空
3. 对阻断项输出:问题类型、涉及图层、最小复现命令(不超过 10 行)
4. 对告警项输出:业务影响、是否可带病发布、建议的工单标题
5. 不改任何数据文件;需要修复时只给 SQL 或命令,由人确认后执行

坑 25:把体检写成一次性脚本,跑完就扔。底座的问题会随数据量线性增长,规则必须跟着数据走,沉淀成技能与 CI job 才有复利。

坑 26:体检只查几何、不查时间。时间戳的「未来时间」与「全空」在入库当时看不出来,等到做时序分析时才发现整批记录都不可用。

坑 27:让模型直接改数据来「修好」检查。修复动作必须由人确认、由脚本执行、留执行记录;模型的职责是判断与汇总,不是拿 UPDATE 去碰生产表。

8.10 本章小结

本章的九节可以压缩成三句话:

第一,基准先于一切。坐标基准决定「在哪」,时间基准决定「何时」,两者都要用数值判据验收——平面 1 cm、时间 20 ms。基准没钉死之前,建库和展示都是白做。

第二,组织要按访问方式设计。事务型空间查询交给 PostGIS(SRID 进类型、GiST 加分区);栅格交给 COG 加 STAC(range 请求加可检索目录);批量分析交给 GeoParquet 加 DuckDB(列裁剪加向量化);三维交给 3D Tiles 1.1(隐式瓦片加 metadata),近景真实感单独用 splat 瓦片承载,不承担量测。

第三,治理要落到表和流水线。manifest 加哈希解决「哪一版」,血缘加 source_ids 解决「从哪来」,RLS 加最小权限解决「谁能看」,密级标签加发布校验解决「能不能发」。全部做成检查项,才有可执行的约束力。

层面 一句话结论 回到哪一节
坐标基准 三套基准各有 EPSG 段,带号与轴序是两个必查开关 8.2
时间基准 只存 UTC,只存有类型的时刻,残差要出统计 8.3
矢量底座 SRID 写进类型,GiST 加分区匹配查询形态 8.4
影像底座 COG 保证按需读取,STAC 保证可检索 8.5
分析底座 扫描型聚合换列式引擎,热路径仍留数据库 8.6
三维底座 3D Tiles 装测绘成果,splat 装真实感 8.7
数据治理 版本、血缘、权限、合规四张表加一条流水线 8.8
自动化 体检脚本沉淀成技能,接进 CI 当门禁 8.9

8.11 动手练习

五个练习按「基准 → 时间 → 矢量 → 影像 → 分析」排,每个都能在半天内做完,验收全部是数值而非常见评语。

练习 1:坐标往返链与自检

输入:一个测区的实测控制点 CSV(至少 5 个点,含 CGCS2000 经纬度与高斯平面坐标),以及该测区的地方独立坐标四参数。

要求:实现 WGS84 → CGCS2000 高斯 → 地方独立系 → ENU → 回到 WGS84 的完整链路;实现 8.2.5 的三条不变量检查;把往返残差与控制点残差都打出来。

验收:三条不变量全部通过;往返残差小于 0.1 mm;控制点平面残差小于 1 cm、高程残差小于 3 cm;把容差改成 0.5 cm 后脚本必须失败——证明它真的在判。

练习 2:时间偏差检测与判定

输入:两路带时间戳的数据(一份视频的逐帧 PTS 加起录 UTC,一份 10 Hz 的 POS 日志)。没有实测数据时,用脚本合成:给 POS 加 1.96 s 固定偏移与 8 ppm 漂移。

要求:写出检测脚本,做线性回归分离固定偏移与相对漂移,输出 RMS、P95、offset、drift 四个数;写清楚 offset 与 drift 各对应什么工程问题。

验收:合成数据上测得 offset 与 1.96 s 相差小于 1 ms,drift 与 8 ppm 相差小于 0.2 ppm;判据在 20 ms 处能正确 PASS / FAIL;残差图上人为插入一个 100 ms 台阶后,脚本能指出台阶位于第几帧。

练习 3:PostGIS 建库与查询优化

输入:一份 100 万条以上的面要素数据(可用公开行政区划数据),PostgreSQL 17 加 PostGIS 3.7.0。

要求:按 8.4.3 建分区表与三类索引;导入并 ANALYZE;写出范围检索、最近邻、分组聚合三条 SQL;对每条查询给出 EXPLAIN (ANALYZE, BUFFERS) 输出与判读。

验收:范围检索走 GiST(计划里出现 Index Scan 或 Bitmap Index Scan);最近邻走 <-> 排序且 LIMIT 在 10 时耗时小于 50 ms;分区裁剪生效(计划里只出现 1 个分区);把 WHERE 里的 && 换成只写 ST_Intersects 后耗时明显上升,能解释原因。

练习 4:把一景影像做成可检索集合

输入:一景正射影像(GeoTIFF,含 CRS)。

要求:用 rio-cogeo 转成 COG 并通过 validate;用 pystac 生成 Collection 与一个 Item,字段按 8.5.2 写全;在本地起一个静态文件服务,用 curl 只取前 16 KB 验证 range 请求可用。

验收:rio cogeo validate 输出通过;Item 的 datetime 是带 Z 的 UTC;proj:epsg 与文件真实 CRS 一致;三次 range 请求的响应时间均在 300 ms 内,且 Content-Range 头正确。

练习 5:千万级要素统计与两引擎对账

输入:把练习 3 的数据导出为 GeoParquet 1.1(Hive 分区),以及同一个库里的 PostGIS 表。

要求:用 DuckDB 1.5 加 spatial 扩展完成按质量等级的分组统计(要素数、球面面积、平均周长);在同一台机器上跑 PostGIS 的等价 SQL;把两份结果逐组比对。

验收:两个引擎的面积差值相对误差小于 1e-6(口径对齐的证明);DuckDB 在 1000 万行上的耗时显著低于 PostGIS 且能给出自己的量级;写出「什么查询该走哪个引擎」的一页结论。

8.12 延伸阅读

  1. PROJ 官方文档:坐标转换引擎与参数定义,遇到 EPSG 编码拿不准时的第一手资料。 proj.org
  2. GDAL 官方文档:矢量与栅格的读写、投影转换、COG 相关的命令行与 API 参考。 gdal.org
  3. PostGIS 官方手册:几何类型、索引、空间谓词与性能调优建议的权威来源。 postgis.net/docs
  4. COG 官方说明:Cloud Optimized GeoTIFF 的布局约束与校验方法。 cogeo.org
  5. STAC 规范与生态:Catalog / Collection / Item 三层模型与最佳实践。 stacspec.org
  6. GeoParquet 规范:几何列的存储约定、元数据字段与版本演进。 geoparquet.org
  7. DuckDB 与 spatial 扩展:列式分析引擎的 SQL 方言、扩展安装与空间函数。 duckdb.org
  8. 3D Tiles 规范(CesiumGS):隐式瓦片、metadata 与瓦片内容格式。 github.com/CesiumGS/3d-tiles