
网上经常能看到这样的运动记录家长陪孩子骑车随手扫了一辆共享单车或直接把儿子的儿童自行车骑出来一圈下来运动 App 却提示“该路段 PB 刷新”。看到这种记录很多人会下意识觉得数据有问题——儿童车连座椅高度都不合适怎么可能比自己的公路车更快但如果你打开运动 App 的“个人纪录”页面就会发现PB 的判定逻辑其实很单一App 只比较你在这条路线上的历史用时并不关心你骑的是哪辆车、当天是顺风还是逆风、中间是否停过车。换句话说PB 是一个“历史数据比较结果”而不是一个“运动能力认证结果”。既然规则不完整就会出现骑着儿子的小车也能刷出个人最佳的奇特场面。这篇文章不讨论这个 PB 算不算数而是从工程角度出发搭建一套属于自己的“运动数据 PB 识别系统”。我们会用 Python 解析 GPX 轨迹文件清洗 GPS 漂移点计算距离、速度、爬升等指标再结合 SQLite 保存历史成绩自动判断本次运动是否刷新了真正的 PB。文章适合以下几类读者有一定运动习惯想把自己的训练记录管起来的人刚开始接触 GPX、GPS 轨迹处理的开发者想学习 Python 文件解析、数据清洗、本地存储完整流程的初学者。读完本文后你可以拿到一套完整可运行的脚本会自己解析运动轨迹也会理解为什么 App 里的 PB 会出现“离谱”记录。1. 背景一次骑行记录为什么会涉及“PB 识别”问题先来说清楚 PB 是什么。PB 是 Personal Best 的缩写意思是个人最佳成绩。在跑步和骑行里常见的 PB 指标包括单次运动的总距离某条固定路段的完成时间平均速度或平均配速最大速度累计爬升。对于绝大多数运动 App 来说PB 刷新只是做一个很简单的判断你有没有在同一路段或者同一运动类型下超过历史最快成绩。它没有考虑以下条件使用的是什么类型的车车辆轮径、齿比、重量是否一致记录设备是否准确当天风速、路面、温度是否正常路段两端是否完整、GPS 信号是否稳定。正因为规则不够完善才会出现“骑儿子的儿童自行车刷新 PB”这类记录。问题本身不算严重但它暴露出一个重要事实如果只是依赖 App 的 PB 通知你并不知道这个成绩到底有没有参考价值。要回答“这次记录算不算 PB”需要至少完成四步工作从运动记录中提取轨迹点根据轨迹点计算实际运动距离和速度过滤掉漂移点、暂停点、定位异常点把可信的运动指标与历史记录做比较。这套流程完全可以用代码自动化。下面我们就动手实现一个简化版的 PB 识别工具。2. 一个可用的 PB 识别系统应该具备什么能力要区分“真实 PB”和“App 误报 PB”系统需要建立自己的数据规则。规则不需要多复杂但至少要覆盖运动数据的采集、清洗、计算、存储、比较五个环节。2.1 数据采集段运动 App 或码表通常支持导出 GPX 文件。GPX 是一种基于 XML 的轨迹格式记录了一连串的 GPS 点。每个点通常包含经纬度、海拔和时间信息。这一阶段只需要解决一个问题把 GPX 文件里的轨迹点读取到 Python 程序中。2.2 数据清洗段真实世界的 GPS 数据并不干净。常见问题包括手机在起点附近等待卫星定位时会产生大量原地漂移点进入隧道或高楼区域后信号丢失下一个点可能突然跳到几百米外设备暂停记录时两个相邻点的时间差很大儿童车或共享单车上的手机袋固定不稳轨迹会有抖动。如果没有清洗直接计算总的移动距离和平均速度很容易出现“人均冲刺世界冠军”的假数据。清洗策略有很多种本文采用最实用的两个规则过滤掉速度明显低于步行速度的片段例如低于 0.5km/h过滤掉速度高到物理上不合理的片段例如超过 80km/h过滤掉相邻点时间间隔过长的片段例如超过 10 分钟这种情况通常是停车休息或信号中断。2.3 指标计算段清洗完成后需要基于有效轨迹点计算本次运动的指标。常见指标包括总距离所有有效片段里程相加移动时间所有有效片段时间相加不包括停车平均速度总距离除以移动时间最大速度所有相邻片段速度的最大值累计爬升海拔逐点上升部分的总和。这套指标比运动 App 展示的“总用时平均速度”更接近真实运动状态因为去掉了停车和漂移带来的干扰。2.4 存储与比较段每次完成一次运动把指标写入 SQLite 数据库。数据库里保存两类数据历史运动记录表每一次运动的完整指标PB 记录表各个指标对应的历史最佳值。新一次运动导入后系统将当前指标与 PB 记录表逐项对比。只要当前值超过历史最佳值就认为某项指标刷新了 PB。3. 环境准备与 GPX 轨迹基础知识动手写代码前先把运行环境和 GPX 基本结构准备好。3.1 安装 Python 依赖示例代码基于 Python 3.8 以上版本使用了第三方库 gpxpy 来解析 GPX 文件。mkdir pb-tracker cd pb-tracker python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate激活虚拟环境后安装依赖pip install gpxpy本文尽量少用第三方库。SQLite 使用 Python 内置的 sqlite3 模块不需要额外安装。3.2 GPX 文件的基本结构GPX 文件是一段 XML。打开一个由运动 App 导出的 GPX 文件你会看到类似下面的结构?xml version1.0 encodingUTF-8? gpx version1.1 creatorexample trk name周末骑行/name trkseg trkpt lat31.2304 lon121.4737 ele10.2/ele time2025-06-20T08:30:00Z/time /trkpt trkpt lat31.2305 lon121.4738 ele10.5/ele time2025-06-20T08:30:01Z/time /trkpt /trkseg /trk /gpx关键节点含义trk一条轨迹表示一次完整运动trkseg轨道路段一次运动可能包含多个路段trkpt轨迹点属性 lat 表示纬度lon 表示经度ele当前点的海拔单位米time当前点的时间UTC 格式。理解了这些节点后面解析 GPX 时就不会觉得代码难懂。3.3 准备测试 GPX 文件你需要准备至少一个 GPX 文件作为实验数据。获取方式如下从跑步、骑行 App 中导出历史运动记录从运动手表或码表同步软件中导出如果现在没有记录可以在空旷路段用手机 App 随手记录一段 5 分钟以上的骑行或步行再导出 GPX。文件放入项目的 data 目录pb-tracker/ ├── data/ │ └── sample.gpx ├── config.py ├── database.py ├── gpx_parser.py ├── pb_service.py ├── main.py └── tools/ └── speed_up_gpx.py3.4 config.py 配置先创建一个统一的配置文件把会用到的路径、阈值都放在这里。from pathlib import Path BASE_DIR Path(__file__).resolve().parent DATA_DIR BASE_DIR / data DB_PATH BASE_DIR / pb_records.db # 运动类型ride / run / walk # 可以根据需要改成自己常用的运动类型 SPORT_TYPE ride # 用于 PB 判定的指标 PB_METRICS [ total_distance_km, avg_speed_kmh, max_speed_kmh, ] # GPS 过滤阈值 MIN_SPEED_FILTER_KMH 0.5 MAX_SPEED_FILTER_KMH 80.0 MAX_TIME_GAP_SECONDS 600 # 地球半径单位公里 EARTH_RADIUS_KM 6371.0这里的 SPORT_TYPE 目前只是配置项后面扩展时可以把运动类型作为 PB 对比的限定条件。如果你跑步和骑行数据混在一起建议用不同的运动类型区分避免跑步刷新了骑行 PB。4. 核心代码解析 GPX 并计算运动指标接下来实现整个项目中最关键的文件 gpx_parser.py。它负责读取 GPX、清洗数据、计算指标。4.1 使用 gpxpy 读取轨迹点首先读取 GPX 文件把所有轨迹段里的点收集到一个列表里。import math from datetime import datetime import gpxpy import config def haversine_distance_km(lat1, lon1, lat2, lon2): 根据两个点的经纬度计算球面距离单位公里。 if None in (lat1, lon1, lat2, lon2): return 0.0 d_lat math.radians(lat2 - lat1) d_lon math.radians(lon2 - lon1) lat1 math.radians(lat1) lat2 math.radians(lat2) a ( math.sin(d_lat / 2) ** 2 math.cos(lat1) * math.cos(lat2) * math.sin(d_lon / 2) ** 2 ) return 2 * config.EARTH_RADIUS_KM * math.asin(math.sqrt(a)) def parse_gpx_file(gpx_path): 解析 GPX 文件返回运动指标字典。 with open(gpx_path, r, encodingutf-8) as f: gpx gpxpy.parse(f) points [] for track in gpx.tracks: for segment in track.segments: for point in segment.points: points.append({ lat: point.latitude, lon: point.longitude, elevation: point.elevation, time: point.time, }) if not points: raise ValueError(GPX 文件中没有发现轨迹点) # 移除经纬度为空的点 valid_points [] for p in points: if p[lat] is not None and p[lon] is not None: valid_points.append(p) if len(valid_points) 2: raise ValueError(有效轨迹点少于 2 个无法计算运动指标)gpxpy 会帮我们处理好 XML 解析工作不需要自己遍历 trkpt 节点。解析完成后每个轨迹点被转换成一个字典方便后续计算。4.2 逐段计算距离、时间和速度得到连续轨迹点后逐段计算相邻两个点的距离、时间差和瞬时速度。segment_distances [] segment_seconds [] valid_speeds [] total_ascent_m 0.0 first_time valid_points[0][time] end_time valid_points[-1][time] for i in range(len(valid_points) - 1): p1 valid_points[i] p2 valid_points[i 1] if p1[time] is None or p2[time] is None: continue time_gap_seconds (p2[time] - p1[time]).total_seconds() if time_gap_seconds 0: continue distance_km haversine_distance_km( p1[lat], p1[lon], p2[lat], p2[lon] ) speed_kmh distance_km / (time_gap_seconds / 3600.0) # 过滤不合理点段 if speed_kmh config.MIN_SPEED_FILTER_KMH: continue if speed_kmh config.MAX_SPEED_FILTER_KMH: continue if time_gap_seconds config.MAX_TIME_GAP_SECONDS: continue segment_distances.append(distance_km) segment_seconds.append(time_gap_seconds) valid_speeds.append(speed_kmh) # 累计上升高度 if p1[elevation] is not None and p2[elevation] is not None: diff p2[elevation] - p1[elevation] if diff 0: total_ascent_m diff这里需要解释几个判断条件time_gap_seconds 0如果两个点时间相同说明设备重复记录无法计算速度speed_kmh 0.5速度过低的点段通常是等红绿灯、停车拍照、原地调试设备speed_kmh 80普通人力骑行几乎不可能稳定超过这个速度出现这种值大概率是 GPS 漂移time_gap_seconds 600相邻两个点时间相差过大中间可能发生了暂停或者无信号不能把这段距离算作连续的移动距离。4.3 汇总运动指标完成逐段计算后把结果汇总成字典total_distance_km sum(segment_distances) moving_time_min sum(segment_seconds) / 60.0 avg_speed_kmh 0.0 if moving_time_min 0: avg_speed_kmh total_distance_km / (moving_time_min / 60.0) max_speed_kmh max(valid_speeds) if valid_speeds else 0.0 if first_time is not None: activity_date first_time.date().isoformat() else: activity_date datetime.now().date().isoformat() return { source_file: str(gpx_path), activity_date: activity_date, total_distance_km: round(total_distance_km, 3), moving_time_min: round(moving_time_min, 1), avg_speed_kmh: round(avg_speed_kmh, 2), max_speed_kmh: round(max_speed_kmh, 2), total_ascent_m: round(total_ascent_m, 1), }这里计算的总距离是“有效移动距离”移动时间是“有效移动时间”因此平均速度反映的是真正在移动时的速度不会被停车时间稀释。距离计算采用了 haversine 公式。它把地球近似成球体适合几公里到几十公里的运动场景。对于本文这类个人运动记录工具来说精度已经足够。5. PB 记录库设计与核心判定逻辑数据解析完成后下一步是保存历史记录并实现 PB 判断逻辑。5.1 数据库表结构设计创建 database.py负责初始化 SQLite 数据库。import sqlite3 from contextlib import closing import config def