2. 航迹几何特征提取与分段

为什么要在清洗后立即做几何特征提取

例1输出的干净航迹只是一串按时间排列的经纬度点,它本身不携带“船在往哪走、走多快、是否在转弯”的信息。后续的分段优化、约束建模都依赖这些几何量,因此本例的任务是:对清洗后的点序列逐点计算航向 COG、航速 SOG、曲率 κ 与转角率 ω,再依据转角率与航向变化识别转向点,把整条航迹切成若干单调、近似直线的航段。

四个特征的定义与差分方式必须统一,否则分段结果会随实现漂移。设第 i 个点为 Pi=(λi, φi, ti),经度 λ、纬度 φ 单位为度,t 为秒:

  • 航向:θi = atan2(Δλ·cos φ̄, Δφ),其中 φ̄ 为两点平均纬度,结果归一到 [0°, 360°)。
  • 航速:vi = di / Δti,di 用 haversine 公式计算的大圆距离(米),v 单位 m/s,展示时换算为节(1 kn = 0.5144 m/s)。
  • 转角率:ωi = Δθi / Δti,Δθi 必须做角度环绕处理(把差值折算到 (−180°, 180°]),否则 359°→1° 会被误判为 −358° 的大转向。
  • 曲率:κi = |Δθi| / di,单位 rad/m,反映单位航程上的方向改变量,与航速无关,适合做几何分段判据。

特征计算与转向点识别的实现

下面是一段可直接运行的 Python 实现,输入为例1输出的干净航迹(CSV,含 lon、lat、timestamp 三列,timestamp 为 Unix 秒),输出带特征的 DataFrame 与分段结果。示例参数取 AIS 常见上报间隔 10 s、转向判定阈值 ωth=0.5 °/s、航向累计变化阈值 Δθth=15°,这些是教学示例值,实际应按船型与采样率调整。

import numpy as np
import pandas as pd

R_EARTH = 6371000.0  # 米

def haversine(lon1, lat1, lon2, lat2):
    lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
    dlon, dlat = lon2 - lon1, lat2 - lat1
    a = np.sin(dlat/2)**2 + np.cos(lat1)*np.cos(lat2)*np.sin(dlon/2)**2
    return 2 * R_EARTH * np.arcsin(np.sqrt(a))

def wrap180(deg):
    """把角度差折算到 (-180, 180]"""
    return (deg + 180.0) % 360.0 - 180.0

def extract_features(df, omega_th=0.5, dtheta_th=15.0):
    df = df.sort_values("timestamp").reset_index(drop=True)
    lon, lat, t = df["lon"].values, df["lat"].values, df["timestamp"].values

    # 相邻点差分
    dlon = np.diff(lon); dlat = np.diff(lat); dt = np.diff(t)
    lat_mid = np.radians((lat[:-1] + lat[1:]) / 2.0)

    # 航向(度),归一到 [0,360)
    cog = np.degrees(np.arctan2(dlon * np.cos(lat_mid), dlat)) % 360.0
    # 大圆距离与航速
    dist = haversine(lon[:-1], lat[:-1], lon[1:], lat[1:])
    sog = dist / np.maximum(dt, 1e-6)          # m/s
    # 转角率与曲率
    dtheta = wrap180(np.diff(cog))              # 相邻航向差,度
    omega = dtheta / np.maximum(dt[1:], 1e-6)   # °/s
    kappa = np.abs(np.radians(dtheta)) / np.maximum(dist[1:], 1e-6)  # rad/m

    # 对齐到点序列(首点无前向差分,置 NaN)
    df["cog"] = np.r_[np.nan, cog]
    df["sog_kn"] = np.r_[np.nan, sog / 0.5144]
    df["omega"] = np.r_[np.nan, np.nan, omega]
    df["kappa"] = np.r_[np.nan, np.nan, kappa]

    # 转向点:转角率超阈值,或窗口内航向累计变化超阈值
    turn = np.zeros(len(df), dtype=bool)
    turn[2:] = np.abs(omega) > omega_th
    # 合并密集转向点,保留窗口内航向变化最大的点
    idx = np.where(turn)[0]
    if len(idx) > 1:
        keep = [idx[0]]
        for j in idx[1:]:
            if j - keep[-1] <= 3:               # 3 点窗口内视为同一次转向
                if abs(omega[j-2]) > abs(omega[keep[-1]-2]):
                    keep[-1] = j
            else:
                keep.append(j)
        turn[:] = False
        turn[keep] = True

    # 分段:转向点作为航段边界
    seg_id = np.cumsum(turn)
    df["is_turn"] = turn
    df["seg_id"] = seg_id
    return df

# 使用示例
# df = pd.read_csv("clean_track.csv")
# out = extract_features(df)
# print(out[["timestamp","cog","sog_kn","omega","kappa","is_turn","seg_id"]].head(20))
# print("航段数:", out["seg_id"].nunique())

关键点有三处:其一,wrap180 必须在求转角率之前调用,否则跨 0°/360° 的航向会被算成巨大跳变;其二,np.diff 使特征序列比点序列短 1~2 个元素,用 np.r_ 前置 NaN 对齐,保证每行点都能查到自己的特征;其三,转向点合并窗口(示例取 3 点)用于抑制 AIS 抖动造成的连续误判,窗口内只保留转角率最大的点作为该次转向的代表。

特征与分段的判读关系

下图把一条航迹的四个特征沿航程展开,并标出转向点如何把航迹切成航段。可以看到:直航段内 κ 接近 0、ω 在 0 附近小幅波动;进入转向时 ω 与 κ 同步抬升,越过阈值的位置即为分段边界;航速在转向段通常略有下降,可作为辅助佐证但不能单独作为分段依据。

航迹几何特征沿航程展开与转向点分段 示例:AIS 采样间隔 10 s,ω 阈值 0.5 °/s,Δθ 阈值 15° 航迹平面示意 航段 0 航段 1 航段 2 航段 3 T1 T2 T3 航程 转角率 ω (°/s) 0 +0.5 −0.5 曲率 κ (rad/m) 0 航速 SOG (kn) 0 T1 T2 T3 判读要点 · 直航段 ω≈0、κ≈0 · 转向处 ω、κ 同步抬升 · 越过阈值即分段边界 · SOG 下降仅作辅助佐证

分段结果的验证与输出

分段是否合理,不能只看航段数量,要回到几何量上核对。建议对每个航段统计三项指标:航段内航向标准差(应小于约 5°,说明该段近似直线)、航段长度(过短的航段往往是抖动误判,可合并到相邻段)、以及航段首尾航向差(应接近该次转向的实际转角)。下面给出一个可直接套用的航段汇总与校验片段:

def summarize_segments(df):
    rows = []
    for sid, g in df.groupby("seg_id"):
        g = g.dropna(subset=["cog"])
        if len(g) < 2:
            continue
        # 航向标准差(环绕处理后再算)
        c = np.radians(g["cog"].values)
        mean_c = np.arctan2(np.sin(c).mean(), np.cos(c).mean())
        std_deg = np.degrees(np.sqrt(-2*np.log(np.hypot(np.sin(c).mean(), np.cos(c).mean()))))
        length = haversine(g["lon"].iloc[0], g["lat"].iloc[0],
                           g["lon"].iloc[-1], g["lat"].iloc[-1])
        rows.append({
            "seg_id": sid,
            "点数": len(g),
            "航段长度_m": round(length, 1),
            "航向标准差_deg": round(std_deg, 2),
            "首航向": round(g["cog"].iloc[0], 1),
            "末航向": round(g["cog"].iloc[-1], 1),
            "平均航速_kn": round(g["sog_kn"].mean(), 2),
        })
    return pd.DataFrame(rows)

# seg = summarize_segments(out)
# print(seg)
# 校验:航向标准差 > 5° 的航段需复查是否漏判转向
# 校验:航段长度 < 200 m 的航段考虑与相邻段合并

通过条件建议设为:所有航段航向标准差 ≤ 5°、无长度小于 200 m 的碎片航段、航段数在合理范围(示例航迹 4 段)。若某航段航向标准差明显偏大,说明该段内还藏着未被识别的转向,应下调 ω 阈值或缩小合并窗口后重跑;若碎片航段过多,则说明阈值过敏感,应上调 ω 阈值或扩大合并窗口。最终把带 seg_id 与四个特征的 DataFrame 落盘为 CSV,即为例3约束建模与后续分段优化的输入。

转角率与曲率的分工:ω 依赖时间,采样间隔变化时阈值需同步调整;κ 依赖航程,对采样率不敏感,跨数据源复用时优先用 κ 做分段判据,ω 作为辅助。
航向差必须做 (−180°, 180°] 环绕处理。若直接对 0~360° 的航向做差,跨越正北的航段会被算成接近 360° 的转向,导致在直航段上凭空切出航段。