简介:面向需要处理GPS轨迹噪点、异常点识别与轨迹平滑的开发者及数据分析人员,这套Python工具包围绕高德猎鹰、百度鹰眼轨迹处理接口封装,以及基于轨迹点间距离分布的降噪脚本,给出从在线轨迹清洗到本地异常点剔除的可运行代码参考。压缩包共3个文件,均为py脚本,大小约7KB,分别对应降噪算法实现与两家地图平台接口调用封装,结构轻量,便于直接查阅、复用和按业务阈值二次改造。已有153人学习,在位置服务与GPS数据处理场景中具备一定参考价值。读者可获得接口鉴权与请求组织思路、按距离阈值识别异常点的实现方式,以及将在线轨迹清洗能力落到本地脚本的实践样例,适合智能交通、物流跟踪、户外运动记录等场景快速验证与方案对照。
1. GPS 轨迹噪点剔除:从一堆漂移点里还原真实路线
做物流轨迹回放或者运动记录类应用的人,早晚会撞上一个问题:设备采回来的 GPS 点,画出来是一条毛刺乱飞的锯齿线,而不是那条顺滑的路。静止时定位在几十米范围内来回跳,进隧道信号丢失后突然补一个离谱的远点,城市峡谷里多径反射导致坐标整体偏移——这些就是轨迹噪点。这份资源给的是 GPS 轨迹噪点剔除(降噪)的 Python 实现,带 API 封装,接收一串经纬度点,输出一条干净可用的路线,适合做轨迹回放、里程统计、电子围栏、运动轨迹分析这类需要干净坐标序列的场景。算法层面不是单一手段,常见的做法是异常点剔除加平滑滤波两段式,下面把它拆开看。
2. 项目结构与技术选型:为什么用 Python 做轨迹清洗
先把技术选型说清楚,这决定了后面代码怎么读。轨迹清洗本身不属于重计算任务,单条轨迹从几百到几万个点,纯 Python 配合 NumPy 完全够用,除非要上实时高并发流处理才需要考虑别的方案。用 Python 的另一个理由是地理计算生态成熟,坐标系转换、球面距离、点线关系这些都有现成库,不用自己造轮子。
2.1 目录组织与模块职责划分
这类项目的目录一般不会有太多层,核心逻辑集中在一两个文件里更利于移植。我拆过的同类项目大多是下面这种结构:
gps_track_cleaner/ ├── api/ │ ├── server.py # HTTP 接口层,接收点列返回清洗结果 │ └── schema.py # 请求/响应字段校验 ├── core/ │ ├── outlier.py # 异常点检测与剔除 │ ├── smoother.py # 轨迹平滑滤波 │ ├── distance.py # 球面距离与速度计算 │ └── pipeline.py # 清洗流水线编排 ├── config/ │ └── params.yaml # 阈值参数集中配置 ├── tests/ │ └── test_pipeline.py # 用构造轨迹验证清洗效果 └── requirements.txt职责划分的关键是pipeline.py,它是整个清洗流程的编排入口,把异常点剔除和平滑两个阶段串起来。outlier.py只负责判断"哪个点该丢",smoother.py只负责"留下来的点该怎么挪",两者互不感知。这种拆分的好处是调参时你能快速定位问题出在剔除阶段还是平滑阶段,而不是在一大坨函数里靠打印日志瞎猜。血泪经验是很多人把这两步写在一起,最后出现"点被平滑到了错误位置"还是"该删的点没删掉"完全分不清。
2.2 依赖选择与参数外置
依赖通常控制在很少几个:numpy做向量化计算,scipy提供滤波器和插值能力,pydantic做接口层的数据校验,fastapi或flask挂 HTTP 服务。如果还需要把经纬度换算成平面坐标做几何运算,会加一个pyproj,但要注意投影方式选错会让距离计算整体偏移,一般是按轨迹所在纬度带做局部投影,或者干脆全程用球面公式避免投影误差。
参数外置这点值得单独强调。降噪阈值是强场景相关的:城市配送车和越野车的速度阈值能差出一个量级。把所有阈值集中在params.yaml里,配合环境变量覆盖,线上调参不用改代码重新发版。
# config/params.yaml outlier: max_speed_mps: 35.0 # 超过该速度视为异常跳点,单位米/秒 max_accuracy_m: 50.0 # 定位精度半径超过该值直接丢弃 max_jump_m: 2000.0 # 相邻两点距离硬上限,兜底用 smoother: window_size: 5 # 滑动窗口点数,奇数 method: savgol # savgol / moving_average / kalman polyorder: 2 # savgol 多项式阶数上面三个异常点判断维度是有层级的:精度半径是设备自己上报的置信信息,优先级最高;速度阈值是物理约束,用来抓"瞬移";硬距离上限是兜底,防止前两个都失效时仍有离谱点混进来。平滑侧的窗口大小和多项式阶数需要联合调,阶数不能大于等于窗口点数,否则拟合会失败报错,这是实际调试时第一个会撞上的问题。
3. 核心算法拆解:异常点判定与轨迹平滑怎么落地
这一章把两段核心代码逐段过一遍。读的时候关注两个问题:判定条件为什么这么设,以及每步的输入输出数据结构长什么样。轨迹清洗的输入输出我一般统一成点列表,每个点是字典或带字段的对象,至少包含经度、纬度、时间戳,可选精度半径和速度。
3.1 异常点剔除:速度、精度、跳距三重判定
异常点剔除的朴素做法是算相邻点的球面距离除以时间差得到速度,超过阈值就丢。但直接这么干会翻车:如果前一个点本身就是噪点,那么"下一个点相对这个噪点"算出来的速度也是错的,会连带误删正常点。稳妥的流程是先按精度和硬距离过一遍粗筛,再用速度做细筛。
import numpy as np EARTH_R = 6371000.0 # 地球平均半径,米 def haversine(lon1, lat1, lon2, lat2): """球面两点距离,输入经纬度(度),输出米。 用球面公式而非平面勾股,避免高纬度地区距离被低估。""" lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2]) dlon = lon2 - lon1 dlat = lat2 - lat1 a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2 return 2 * EARTH_R * np.arcsin(np.sqrt(a)) def filter_outliers(points, max_speed=35.0, max_accuracy=50.0, max_jump=2000.0): """返回清洗后的点列表,保持原有时间顺序。 points: [{'lon':.., 'lat':.., 'ts':.., 'acc':..}, ...] ts 为秒级时间戳 """ if len(points) < 2: return list(points) kept = [points[0]] for p in points[1:]: prev = kept[-1] # 1) 精度筛:设备自报误差过大,直接丢 if p.get('acc') is not None and p['acc'] > max_accuracy: continue # 2) 硬距离兜底:无论时间多长,距离离谱就丢 dist = haversine(prev['lon'], prev['lat'], p['lon'], p['lat']) if dist > max_jump: continue # 3) 速度筛:对 kept 里最后一个点算相对速度 dt = p['ts'] - prev['ts'] if dt <= 0: # 时间戳重复或倒序,无法算速度,保留但记录 kept.append(p) continue speed = dist / dt if speed > max_speed: continue kept.append(p) return kept这段代码有三个设计点需要说明。第一,kept列表在遍历过程中是动态的,速度判定永远基于"上一个被保留的点",而不是"上一个原始点",这样避免了噪点污染判定的连锁反应,代价是连续多个噪点后可能会把恢复正常位置的第一个点也误判为跳点,实际调参时可以把max_jump设得宽松些来缓解。第二,dt <= 0的分支必须处理,真实数据里时间戳重复、乱序、甚至缺失都很常见,不处理会直接除零或算出负速度。第三,参数max_speed单位是米/秒,35 大致对应 126 km/h,城市场景偏低可以调到 25 左右,高速场景则要放宽到 45 以上。
提示:精度字段
acc不是所有设备都上报,缺失时不能当成 0 也不能当成无穷大,代码里用is not None判断后跳过该维度,这是兼容不同数据源的关键。
另外,如果轨迹是整段突移(比如信号丢失后重新定位,整段坐标偏了几百米),上面这种逐点方法会删掉偏移段的所有点,导致轨迹断开。更稳的做法是先按时间间隔切段,间隔超过阈值(比如 60 秒)视为轨迹中断,分段清洗后再拼接,不做跨段的速度判定。
3.2 滑动窗口平滑:Savitzky-Golay 与卡尔曼的取舍
异常点删掉后,剩下的点虽然都在物理上合理,但坐标仍会小幅抖动,画出来还是毛毛的。平滑这一步本质是让每个点的坐标参考邻域内的点做修正。常用两种:滑动窗口拟合类和状态递推类。
滑动窗口里 Savitzky-Golay 滤波器是性价比很高的选择,它对窗口内的点做低阶多项式最小二乘拟合,取拟合曲线在中心点的值作为输出。相比简单移动平均,它能更好地保留轨迹的弯道形态,不会把转弯抹平。缺点是窗口两端处理麻烦,且对突变敏感。
from scipy.signal import savgol_filter import numpy as np def smooth_track(points, window=5, polyorder=2): """对经纬度序列分别做 Savitzky-Golay 平滑。 注意:经纬度各自平滑后点位不再严格落在原路网上, 精度要求高的场景需要加约束或改用投影坐标。 """ if len(points) < window: return list(points) lons = np.array([p['lon'] for p in points], dtype=float) lats = np.array([p['lat'] for p in points], dtype=float) # 偶数窗口 scipy 会报错,强行转奇数 if window % 2 == 0: window += 1 # 阶数必须小于窗口长度 polyorder = min(polyorder, window - 1) lons_s = savgol_filter(lons, window_length=window, polyorder=polyorder) lats_s = savgol_filter(lats, window_length=window, polyorder=polyorder) out = [] for p, lon_s, lat_s in zip(points, lons_s, lats_s): q = dict(p) q['lon'] = float(lon_s) q['lat'] = float(lat_s) out.append(q) return outwindow_length必须是奇数,这是 scipy 的硬性要求,偶数会直接抛异常,代码里做了自动纠正。polyorder必须小于window_length,同样在代码里兜住了。窗口大小怎么选:窗口越大平滑越狠,但弯道细节丢失越多,5 到 9 是常见区间;多项式阶数 2 或 3 足够表达局部曲率,再高会开始拟合噪声。
对经纬度分别平滑有个隐患:球面上经度和纬度的实际距离尺度不一样,同一度经度在赤道约 111 km,在纬度 60 度处只有约 55 km,分开平滑会让高纬度地区的东西向过度平滑、南北向平滑不足。严谨做法是先把点投影到局部平面坐标(比如该轨迹中心点处的横轴墨卡托),平滑后再反投影回经纬度。这一步多写十几行,但对高纬度场景是必要的。
另一种思路是卡尔曼滤波,它把位置和速度作为状态量递推更新,对轨迹中断后的恢复更友好,还能顺带输出每个点的置信度。代价是要调过程噪声和观测噪声两个协方差参数,调不好会出现轨迹"追不上"真实转弯的滞后现象。经验上:轨迹连续、抖动为主用 Savitzky-Golay;有大量信号丢失和重定位、需要在线处理用卡尔曼。
3.3 流水线编排与 HTTP 接口封装
两段算法串起来的编排层要保持简单,别在这里加业务判断。流水线的职责就是:排序时间戳、切段、逐段剔除、逐段平滑、拼接,最后返回统计信息。
def clean_track(points, cfg): """完整清洗流程。cfg 为 params.yaml 解析后的字典。""" if not points: return [] # 时间升序,重复时间戳保留后出现的 points = sorted(points, key=lambda x: x['ts']) # 按时间间隔切段,间隔过大视为轨迹中断 segments, cur = [], [points[0]] gap_limit = cfg['gap_limit_s'] for p in points[1:]: if p['ts'] - cur[-1]['ts'] > gap_limit: segments.append(cur) cur = [p] else: cur.append(p) segments.append(cur) result = [] for seg in segments: seg = filter_outliers( seg, max_speed=cfg['outlier']['max_speed_mps'], max_accuracy=cfg['outlier']['max_accuracy_m'], max_jump=cfg['outlier']['max_jump_m'], ) seg = smooth_track( seg, window=cfg['smoother']['window_size'], polyorder=cfg['smoother']['polyorder'], ) result.extend(seg) return result接口层用 FastAPI 写起来最短,请求体接收点数组,响应返回清洗后的点数组加删除点数、分段数这些统计字段,方便前端展示"本次清理掉了多少噪点"。
from fastapi import FastAPI from pydantic import BaseModel from typing import List, Optional app = FastAPI() class Point(BaseModel): lon: float lat: float ts: float acc: Optional[float] = None class CleanRequest(BaseModel): points: List[Point] config_key: str = 'default' # 对应前端传来的场景档位 @app.post('/api/track/clean') def clean(req: CleanRequest): cfg = load_config(req.config_key) # 从配置文件按档位读取 raw = [p.model_dump() for p in req.points] cleaned = clean_track(raw, cfg) return { 'code': 0, 'data': cleaned, 'stats': {'input': len(raw), 'output': len(cleaned)} }config_key的设计是为了支持多场景档位:配送、网约车、运动记录各有一套阈值,前端或调用方按业务传入,后端不用为每个场景写一个接口。model_dump()把 Pydantic 对象转成字典,保证和核心算法层的纯字典数据结构对齐,避免两层之间出现对象类型混用。
4. 跑起来:本地验证与参数调试的完整步骤
算法讲完,落到能跑。这一节按顺序给出可复现的步骤,每一步都说明做完之后该看到什么,不然读者跑完不知道自己对不对。
4.1 环境准备与启动
依赖安装用虚拟环境隔离,避免和系统里的老版本 NumPy 冲突。
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install fastapi uvicorn numpy scipy pydantic pyyaml启动服务:
uvicorn api.server:app --host 0.0.0.0 --port 8000 --reload--reload只在开发时开,改代码自动重启;上线上换成--workers 4之类的多进程参数,注意多进程下配置文件是各进程独立加载的,改配置要重启。启动成功后访问/docs能看到自动生成的接口文档,这是 FastAPI 的便利之处,可以直接在页面上发请求测试。
4.2 用构造数据验证清洗效果
真实 GPS 数据不好找,最省事的验证方式是手工造一条已知噪点的轨迹:一条直线加点抖动,再插入几个明显的跳点,跑完看这些跳点是否被删掉、直线是否还直。
import numpy as np def make_fake_track(n=100, noise=5e-5, outliers=3): """沿经度方向匀速前进,叠加高斯噪声,随机插入跳点。""" lons = np.linspace(116.30, 116.35, n) lats = np.full(n, 39.90) lons += np.random.normal(0, noise, n) lats += np.random.normal(0, noise, n) ts = np.arange(n) * 1.0 # 每秒一个点 pts = [{'lon': float(lo), 'lat': float(la), 'ts': float(t), 'acc': 5.0} for lo, la, t in zip(lons, lats, ts)] idx = np.random.choice(range(5, n - 5), outliers, replace=False) for i in idx: pts[i]['lon'] += 0.01 # 约 800 米偏移,必然被剔除 return pts跑通之后检查三件事:输出点数等于输入点数减去被删点数;保留下来的点经度单调性与原始一致;平滑后的点与原始直线最大偏差在噪声量级内。如果跳点没被删,先看它的acc是否被设成了超过max_accuracy的值,导致在第一步就被精度筛丢了——这也算删掉了,但统计上会算在精度维度而不是速度维度。想看细分统计就把剔除函数改成返回删除原因。
4.3 阈值怎么调:三个场景的参数档位
参数调优没有万能值,下面给出三个典型场景的起始档位,实际从这些值出发再微调:
| 参数 | 城市配送 | 高速运输 | 户外徒步 |
|---|---|---|---|
| max_speed_mps | 22 | 45 | 8 |
| max_accuracy_m | 30 | 50 | 20 |
| max_jump_m | 1000 | 3000 | 500 |
| window_size | 5 | 7 | 9 |
| polyorder | 2 | 2 | 3 |
徒步场景窗口开大是因为步速慢、相邻点间距小,抖动占信号比例高,需要更强的平滑;高速场景窗口不能太大,否则过弯时轨迹会切内角。这套档位我一般会先跑一天的真实数据,把清洗后轨迹和地图路网叠加看,偏得厉害就动窗口,点还在飞就动速度阈值。
5. 避坑排查:轨迹清洗里最容易翻车的五件事
阈值和算法都摆在那,真正花时间的往往是排查。下面五条是我在同一类项目里反复撞到的,按现象、原因、解决记录。
5.1 整条轨迹被清空
现象:接口返回的data是空数组,stats显示输入几十个点、输出零个。原因:第一个点本身的acc超标或时间戳异常,导致进入循环后每个点都在和它比较,速度全部超限,连锁全删。解决:剔除逻辑里加一条保底——每段至少保留首尾各一个点,或者当删除比例超过 70% 时降级为只做硬距离筛选,并打日志告警。数据质量问题靠算法修不完,必须有降级路径。
5.2 时间戳单位混用导致速度全错
现象:清洗结果和原始数据几乎一样,噪点一个没少。原因:ts有的是秒级时间戳,有的是毫秒级,毫秒当秒用会让dt大一千倍,速度算出来贴近零,永远不触发阈值。解决:入口层统一校验时间戳量级,大于 1e11 的按毫秒除以 1000 转秒,转换前打印样本值确认。这个坑不报错,最隐蔽。
5.3 平滑后轨迹整体偏移
现象:清洗后的点系统性偏离真实道路几十米,但形状是对的。原因:经纬度分开平滑且未做投影,或者投影坐标系选错带号,高纬度或跨带轨迹会被拉伸。解决:跑之前用三个已知坐标点验证投影反投影是否闭合,误差应在厘米级;跨带轨迹按中心点选带,或者干脆改用球面插值方案。
5.4 静止时的密集点画出一坨毛球
现象:车辆停靠、设备持续上报,几十个点挤在几十米内乱跳,平滑后变成一个毛球。原因:完全静止时 GPS 抖动幅度和低速移动无法区分,速度筛不会删,平滑会把这些抖动当信号处理。解决:先做驻留点识别,检测到某段时间内点都在一个几十米半径内,直接把这段压缩成一个代表点(取平均坐标),再做后续处理。这一步大幅减少点数,也顺带解决了里程被虚增的问题。
5.5 接口高并发下内存涨得快
现象:服务跑一段时间内存持续上涨,重启才恢复。原因:单次请求点列长度没限制,有人传了几十万个点,或者savgol_filter在大数组上分配临时内存过多,配合多 worker 内存翻倍。解决:接口层加最大点数校验(一般单次几万点上限),超限返回明确错误而不是硬算;大数组改用分批平滑,每批之间保留重叠窗口避免接缝处突变。这是把算法包成 API 后最容易被忽视的一环,算法本身没问题,是被调用方式拖垮的。
6. 二次开发:把降噪能力嵌进你自己的轨迹系统
拿到这份东西后最常见的需求不是单独跑一个清洗服务,而是把它嵌进已有系统:订单轨迹表里存量数据要批量清洗重算,或者实时上报的数据流要边收边清。改造的着力点有两处,一是数据接入形态,二是算法输出的利用方式。
接入形态上,如果你的数据在数据库里,没必要走 HTTP 绕一圈,直接把filter_outliers和smooth_track两个纯函数 import 进批处理脚本更高效。批量处理要注意分页读,别一次SELECT全表,按时间区间分片,每片处理完单独提交事务。带状态更新的场景要留幂等,同一段轨迹重复清洗结果必须一致,否则重跑会反复改写数据。
# 批量重算示例:按天分片,清洗后落回新表 def batch_recompute(day, db, cfg): rows = db.query( "SELECT id, lon, lat, ts, acc FROM track_raw WHERE dt = %s ORDER BY ts", (day,) ) pts = [{'lon': r.lon, 'lat': r.lat, 'ts': r.ts, 'acc': r.acc, 'id': r.id} for r in rows] cleaned = clean_track(pts, cfg) # 先删后插,保证幂等 db.execute("DELETE FROM track_clean WHERE dt = %s", (day,)) db.executemany( "INSERT INTO track_clean (dt, lon, lat, ts) VALUES (%s, %s, %s, %s)", [(day, p['lon'], p['lat'], p['ts']) for p in cleaned] ) db.commit()输出利用上,不要只把清洗后的坐标存一遍就完事。平滑前后坐标的差值序列本身就是有价值的信息:差值持续偏大说明该时段定位质量差,可以作为设备异常的监控指标;某段被删点数占比高,可以标记录入数据质量表,后续按段排查设备。我一般会把stats里的删点数和分段数一起落库,出问题时能快速定位是数据源坏了还是参数配错了。
还有一个技巧值得试试:把参数调优从手工改成回测。拿一批人工确认过干净轨迹的数据当基准,用不同参数组合跑清洗,算清洗结果与基准的均方根偏差,选偏差最小的参数档位。这比凭肉眼调参靠谱得多,也方便做参数变更的回归验证。
注意:卡尔曼方案里协方差矩阵初值不要随手填零,初值设错会导致前几十个点收敛慢,轨迹开头出现明显滞后,调参时先把前段单独截出来看。
最开始做这类功能时我吃过一次亏:把异常点剔除和平滑参数一起改,结果轨迹变好看了,但里程统计比实际少了一大截,回头排查半天才发现是平滑窗口开太大把绕行路段抹直了。从那以后我每次改清洗参数都强制先把里程、最高速、路径总长这三个指标和原始数据对一遍再上线,指标对不上绝不发版。希望帮到你。
本文还有配套的精品资源,点击获取