Initial commit: GPS轨迹数据处理工具
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
# 忽略 HTML 文件(生成的地图文件)
|
||||
*.html
|
||||
|
||||
# 忽略 GPX 文件(原始轨迹数据文件)
|
||||
*.gpx
|
||||
|
||||
# Python 缓存
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.egg-info/
|
||||
|
||||
# 环境文件
|
||||
.env
|
||||
venv/
|
||||
.venv/
|
||||
@@ -0,0 +1,410 @@
|
||||
import gpxpy
|
||||
import folium
|
||||
import os
|
||||
import math
|
||||
import glob
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 定义可用的地图源
|
||||
MAP_TILES = {
|
||||
"高德卫星图": "http://webst02.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}",
|
||||
"高德街道图": "http://webrd02.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}"
|
||||
}
|
||||
# 地图源的属性信息
|
||||
MAP_ATTRIBUTION = {
|
||||
"高德卫星图": '© <a href="http://ditu.amap.com/">高德地图</a>',
|
||||
"高德街道图": '© <a href="http://ditu.amap.com/">高德地图</a>'
|
||||
}
|
||||
|
||||
def wgs84_to_gcj02(lng, lat):
|
||||
"""
|
||||
WGS84转GCJ02(火星坐标系)
|
||||
将GPS的WGS84坐标转换为高德地图使用的GCJ02坐标
|
||||
"""
|
||||
a = 6378245.0 # 长半轴
|
||||
ee = 0.00669342162296594323 # 扁率
|
||||
|
||||
# 判断是否在国内
|
||||
if (lng < 72.004 or lng > 137.8347) or (lat < 0.8293 or lat > 55.8271):
|
||||
return lng, lat
|
||||
|
||||
dlat = _transform_lat(lng - 105.0, lat - 35.0)
|
||||
dlng = _transform_lng(lng - 105.0, lat - 35.0)
|
||||
|
||||
radlat = lat / 180.0 * math.pi
|
||||
magic = math.sin(radlat)
|
||||
magic = 1 - ee * magic * magic
|
||||
sqrtmagic = math.sqrt(magic)
|
||||
|
||||
dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * math.pi)
|
||||
dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * math.pi)
|
||||
|
||||
mglat = lat + dlat
|
||||
mglng = lng + dlng
|
||||
|
||||
return mglng, mglat
|
||||
|
||||
def _transform_lat(lng, lat):
|
||||
ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * math.sqrt(abs(lng))
|
||||
ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * math.sin(lat * math.pi) + 40.0 * math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0
|
||||
ret += (160.0 * math.sin(lat / 12.0 * math.pi) + 320 * math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0
|
||||
return ret
|
||||
|
||||
def _transform_lng(lng, lat):
|
||||
ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * math.sqrt(abs(lng))
|
||||
ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * math.sin(lng * math.pi) + 40.0 * math.sin(lng / 3.0 * math.pi)) * 2.0 / 3.0
|
||||
ret += (150.0 * math.sin(lng / 12.0 * math.pi) + 300.0 * math.sin(lng / 30.0 * math.pi)) * 2.0 / 3.0
|
||||
return ret
|
||||
|
||||
def format_datetime(dt):
|
||||
"""
|
||||
格式化日期时间,去掉时区信息
|
||||
"""
|
||||
if dt:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return "未知"
|
||||
|
||||
def format_duration(seconds):
|
||||
"""
|
||||
格式化持续时间
|
||||
"""
|
||||
if seconds is None:
|
||||
return "未知"
|
||||
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}小时{minutes}分{secs}秒"
|
||||
elif minutes > 0:
|
||||
return f"{minutes}分{secs}秒"
|
||||
else:
|
||||
return f"{secs}秒"
|
||||
|
||||
def parse_gpx_file(gpx_file_path):
|
||||
"""
|
||||
解析GPX文件,优化了速度平滑处理和运动时间计算(自动剔除暂停时间)
|
||||
"""
|
||||
# 配置参数
|
||||
MAX_SPEED_THRESHOLD = 50.0 # 最高时速上限 (km/h),超过此值可能是漂移
|
||||
PAUSE_THRESHOLD_SECONDS = 3 # 采样间隔超过3秒视为暂停
|
||||
SPEED_WINDOW_SIZE = 3 # 速度平滑窗口大小
|
||||
|
||||
try:
|
||||
with open(gpx_file_path, 'r', encoding='utf-8') as gpx_file:
|
||||
gpx = gpxpy.parse(gpx_file)
|
||||
|
||||
points = []
|
||||
total_distance = 0
|
||||
moving_time_seconds = 0 # 净运动时间
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
# 临时存储速度用于平滑处理
|
||||
recent_speeds = []
|
||||
|
||||
for track in gpx.tracks:
|
||||
for segment in track.segments:
|
||||
previous_point = None
|
||||
|
||||
for i, point in enumerate(segment.points):
|
||||
# 坐标转换
|
||||
gcj_lng, gcj_lat = wgs84_to_gcj02(point.longitude, point.latitude)
|
||||
|
||||
segment_distance = 0
|
||||
raw_speed = 0
|
||||
time_diff = 0
|
||||
|
||||
if previous_point:
|
||||
# 计算距离 (使用WGS84原始坐标计算距离更准确,再转换用于显示)
|
||||
segment_distance = gpxpy.geo.haversine_distance(
|
||||
previous_point['raw_lat'], previous_point['raw_lng'],
|
||||
point.latitude, point.longitude
|
||||
)
|
||||
total_distance += segment_distance
|
||||
|
||||
# 计算时间差
|
||||
if point.time and previous_point['time']:
|
||||
time_diff = (point.time - previous_point['time']).total_seconds()
|
||||
|
||||
# --- 改进2:暂停检测逻辑 ---
|
||||
# 如果时间间隔在阈值内,计入运动时间
|
||||
if 0 < time_diff < PAUSE_THRESHOLD_SECONDS:
|
||||
moving_time_seconds += time_diff
|
||||
|
||||
# 计算原始速度
|
||||
if time_diff > 0:
|
||||
raw_speed = (segment_distance / time_diff) * 3.6 # km/h
|
||||
|
||||
# --- 改进1:速度平滑处理 ---
|
||||
# 剔除极端错误的数字
|
||||
if raw_speed > MAX_SPEED_THRESHOLD:
|
||||
raw_speed = recent_speeds[-1] if recent_speeds else 0
|
||||
|
||||
recent_speeds.append(raw_speed)
|
||||
if len(recent_speeds) > SPEED_WINDOW_SIZE:
|
||||
recent_speeds.pop(0)
|
||||
|
||||
# 取窗口平均值作为当前点的瞬时速度
|
||||
smoothed_speed_kmh = sum(recent_speeds) / len(recent_speeds)
|
||||
|
||||
# 记录时间
|
||||
if i == 0 and point.time and not start_time:
|
||||
start_time = point.time
|
||||
|
||||
duration_from_start = (point.time - start_time).total_seconds() if start_time and point.time else 0
|
||||
|
||||
point_data = {
|
||||
'latitude': gcj_lat,
|
||||
'longitude': gcj_lng,
|
||||
'raw_lat': point.latitude, # 保留原始坐标用于距离计算
|
||||
'raw_lng': point.longitude,
|
||||
'elevation': point.elevation,
|
||||
'time': point.time,
|
||||
'cumulative_distance': total_distance,
|
||||
'instantaneous_speed': smoothed_speed_kmh / 3.6, # 转回 m/s 保持统一
|
||||
'duration_from_start': duration_from_start,
|
||||
'segment_distance': segment_distance
|
||||
}
|
||||
points.append(point_data)
|
||||
previous_point = point_data
|
||||
|
||||
if not points: return None
|
||||
|
||||
end_time = points[-1]['time']
|
||||
|
||||
# 计算总时长(包含暂停)
|
||||
total_duration_hours = (end_time - start_time).total_seconds() / 3600 if start_time and end_time else 0
|
||||
# 计算运动时长(剔除暂停)
|
||||
moving_duration_hours = moving_time_seconds / 3600
|
||||
|
||||
# 其他统计逻辑 (海拔等) 保持不变...
|
||||
elevations = [p['elevation'] for p in points if p['elevation'] is not None]
|
||||
elevation_gain = 0
|
||||
if len(elevations) > 1:
|
||||
for i in range(1, len(elevations)):
|
||||
if elevations[i] > elevations[i-1]:
|
||||
elevation_gain += elevations[i] - elevations[i-1]
|
||||
|
||||
speeds_kmh = [p['instantaneous_speed'] * 3.6 for p in points]
|
||||
max_speed = max(speeds_kmh) if speeds_kmh else 0
|
||||
avg_speed = (total_distance / 1000) / moving_duration_hours if moving_duration_hours > 0 else 0
|
||||
|
||||
return {
|
||||
'points': points,
|
||||
'total_distance': total_distance / 1000,
|
||||
'start_time': format_datetime(start_time),
|
||||
'end_time': format_datetime(end_time),
|
||||
'num_points': len(points),
|
||||
'min_elevation': min(elevations) if elevations else None,
|
||||
'max_elevation': max(elevations) if elevations else None,
|
||||
'avg_elevation': sum(elevations)/len(elevations) if elevations else None,
|
||||
'elevation_gain': elevation_gain,
|
||||
'duration_hours': moving_duration_hours, # 这里改为返回运动时间
|
||||
'total_elapsed_hours': total_duration_hours, # 保留一个总耗时
|
||||
'avg_speed': avg_speed,
|
||||
'max_speed': max_speed
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"解析GPX文件时出错: {e}")
|
||||
return None
|
||||
|
||||
def create_map_with_track(gpx_data, output_file='gpx_track_map.html'):
|
||||
"""
|
||||
使用folium创建带有轨迹的地图,默认提供多个地图源选项
|
||||
添加鼠标悬停显示路程、速度和时间功能
|
||||
轨迹始终显示,没有选择框控制
|
||||
"""
|
||||
if not gpx_data or not gpx_data['points']:
|
||||
print("没有轨迹数据可显示")
|
||||
return
|
||||
|
||||
points = gpx_data['points']
|
||||
|
||||
# 计算地图中心点
|
||||
center_lat = sum(p['latitude'] for p in points) / len(points)
|
||||
center_lon = sum(p['longitude'] for p in points) / len(points)
|
||||
|
||||
# 创建地图 - 使用高德街道图作为默认地图
|
||||
m = folium.Map(
|
||||
location=[center_lat, center_lon],
|
||||
zoom_start=13,
|
||||
tiles=None # 不设置默认瓦片
|
||||
)
|
||||
|
||||
# 添加所有地图源作为可选图层
|
||||
for tile_name, tile_url in MAP_TILES.items():
|
||||
folium.TileLayer(
|
||||
tiles=tile_url,
|
||||
attr=MAP_ATTRIBUTION[tile_name],
|
||||
name=tile_name
|
||||
).add_to(m)
|
||||
|
||||
# 创建轨迹线的坐标列表和对应的工具提示数据
|
||||
track_coordinates = []
|
||||
tooltip_data = []
|
||||
|
||||
for point in points:
|
||||
track_coordinates.append((point['latitude'], point['longitude']))
|
||||
|
||||
# 准备工具提示信息
|
||||
distance_km = point['cumulative_distance'] / 1000
|
||||
speed_kmh = point['instantaneous_speed'] * 3.6 # 转换为km/h
|
||||
|
||||
tooltip = f"""
|
||||
<div style="font-size: 12px; font-family: Arial, sans-serif;">
|
||||
<b>位置信息</b><br>
|
||||
路程: {distance_km:.2f} km<br>
|
||||
速度: {speed_kmh:.1f} km/h<br>
|
||||
时间: {format_datetime(point['time'])}<br>
|
||||
持续时间: {format_duration(point['duration_from_start'])}
|
||||
</div>
|
||||
"""
|
||||
tooltip_data.append(tooltip)
|
||||
|
||||
# 创建轨迹线 - 直接添加到地图,不使用FeatureGroup
|
||||
track_line = folium.PolyLine(
|
||||
track_coordinates,
|
||||
color='red',
|
||||
weight=5,
|
||||
opacity=0.8,
|
||||
popup='GPS轨迹'
|
||||
)
|
||||
track_line.add_to(m)
|
||||
|
||||
# 添加透明的多边形覆盖物用于捕获鼠标事件
|
||||
for i in range(len(track_coordinates) - 1):
|
||||
# 创建两个相邻点之间的小线段
|
||||
segment_coords = [track_coordinates[i], track_coordinates[i + 1]]
|
||||
|
||||
# 使用半透明的多边形来捕获悬停事件
|
||||
hover_polygon = folium.PolyLine(
|
||||
segment_coords,
|
||||
color='transparent',
|
||||
weight=15, # 较宽的透明线以便于悬停
|
||||
opacity=0.01, # 几乎完全透明
|
||||
tooltip=folium.Tooltip(
|
||||
tooltip_data[i + 1], # 显示后一个点的信息
|
||||
sticky=False,
|
||||
permanent=False
|
||||
)
|
||||
)
|
||||
hover_polygon.add_to(m)
|
||||
|
||||
# 添加起点标记
|
||||
start_point = points[0]
|
||||
folium.Marker(
|
||||
[start_point['latitude'], start_point['longitude']],
|
||||
popup=f"起点\n时间: {format_datetime(start_point['time'])}",
|
||||
tooltip="起点",
|
||||
icon=folium.Icon(color='green', icon='play', prefix='fa')
|
||||
).add_to(m)
|
||||
|
||||
# 添加终点标记
|
||||
end_point = points[-1]
|
||||
folium.Marker(
|
||||
[end_point['latitude'], end_point['longitude']],
|
||||
popup=f"终点\n时间: {format_datetime(end_point['time'])}",
|
||||
tooltip="终点",
|
||||
icon=folium.Icon(color='red', icon='stop', prefix='fa')
|
||||
).add_to(m)
|
||||
|
||||
# 在轨迹上添加关键点标记(每25%的距离添加一个)
|
||||
num_key_points = 4
|
||||
for i in range(1, num_key_points):
|
||||
target_distance = (gpx_data['total_distance'] * 1000) * (i / num_key_points)
|
||||
|
||||
# 找到最接近目标距离的点
|
||||
closest_point = min(points, key=lambda x: abs(x['cumulative_distance'] - target_distance))
|
||||
|
||||
folium.CircleMarker(
|
||||
[closest_point['latitude'], closest_point['longitude']],
|
||||
radius=4,
|
||||
popup=f"路程: {closest_point['cumulative_distance']/1000:.2f} km",
|
||||
tooltip=f"{i*25}% 路程点",
|
||||
color='blue',
|
||||
fill=True,
|
||||
fillColor='blue'
|
||||
).add_to(m)
|
||||
|
||||
# 添加信息框 - 缩小尺寸并添加最高速度
|
||||
info_html = f"""
|
||||
<div style="position: fixed;
|
||||
top: 10px; left: 10px; width: 280px; height: auto;
|
||||
background-color: white; border:2px solid grey; z-index:9999;
|
||||
padding: 8px; font-size:12px; border-radius: 5px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);">
|
||||
<b style="font-size:13px;">轨迹信息</b><br>
|
||||
总距离: {gpx_data['total_distance']:.2f} km<br>
|
||||
轨迹点数: {gpx_data['num_points']}<br>
|
||||
开始时间: {gpx_data['start_time']}<br>
|
||||
结束时间: {gpx_data['end_time']}<br>
|
||||
"""
|
||||
|
||||
# 添加海拔信息(如果存在)
|
||||
if gpx_data['min_elevation'] is not None:
|
||||
info_html += f"最低海拔: {gpx_data['min_elevation']:.1f} m<br>"
|
||||
info_html += f"最高海拔: {gpx_data['max_elevation']:.1f} m<br>"
|
||||
info_html += f"平均海拔: {gpx_data['avg_elevation']:.1f} m<br>"
|
||||
info_html += f"累计爬升: {gpx_data['elevation_gain']:.0f} m<br>"
|
||||
|
||||
# 添加时间和速度信息
|
||||
if gpx_data['duration_hours'] > 0:
|
||||
hours = int(gpx_data['duration_hours'])
|
||||
minutes = int((gpx_data['duration_hours'] - hours) * 60)
|
||||
info_html += f"持续时间: {hours}时{minutes}分<br>"
|
||||
info_html += f"平均速度: {gpx_data['avg_speed']:.1f} km/h<br>"
|
||||
info_html += f"<b>最高速度: {gpx_data['max_speed']:.1f} km/h</b><br>"
|
||||
|
||||
info_html += "<br><b>使用提示:</b> 鼠标悬停轨迹查看详情"
|
||||
info_html += "</div>"
|
||||
m.get_root().html.add_child(folium.Element(info_html))
|
||||
|
||||
# 添加图层控制(只控制地图源,不控制轨迹)
|
||||
folium.LayerControl().add_to(m)
|
||||
|
||||
# 保存地图
|
||||
m.save(output_file)
|
||||
return m
|
||||
|
||||
def batch_process_gpx_files(folder_path):
|
||||
"""
|
||||
批量处理文件夹中的所有GPX文件
|
||||
"""
|
||||
gpx_files = glob.glob(os.path.join(folder_path, "*.gpx"))
|
||||
|
||||
if not gpx_files:
|
||||
print("在指定文件夹中未找到GPX文件")
|
||||
return
|
||||
|
||||
processed_count = 0
|
||||
|
||||
for gpx_file in gpx_files:
|
||||
print(f"正在处理: {os.path.basename(gpx_file)}")
|
||||
gpx_data = parse_gpx_file(gpx_file)
|
||||
|
||||
if gpx_data:
|
||||
output_filename = os.path.splitext(gpx_file)[0] + '_map.html'
|
||||
create_map_with_track(gpx_data, output_filename)
|
||||
processed_count += 1
|
||||
print(f" 已生成: {os.path.basename(output_filename)}")
|
||||
|
||||
return processed_count
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("GPX轨迹可视化工具")
|
||||
print("正在处理当前文件夹中的GPX文件...")
|
||||
|
||||
# 获取当前程序所在文件夹路径
|
||||
current_folder = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 批量处理当前文件夹中的所有GPX文件
|
||||
processed_count = batch_process_gpx_files(current_folder)
|
||||
|
||||
print(f"处理完成!共生成 {processed_count} 个地图文件")
|
||||
print("请在浏览器中打开生成的HTML文件查看轨迹地图")
|
||||
print("提示:您可以在右上角切换不同的地图图层")
|
||||
print("新功能:鼠标悬停在轨迹线上可以查看实时路程、速度和时间信息")
|
||||
@@ -0,0 +1,500 @@
|
||||
import gpxpy
|
||||
import folium
|
||||
import os
|
||||
import math
|
||||
import glob
|
||||
import html
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# 定义可用的地图源
|
||||
MAP_TILES = {
|
||||
"高德卫星图": "http://webst02.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}",
|
||||
"高德街道图": "http://webrd02.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}"
|
||||
}
|
||||
# 地图源的属性信息
|
||||
MAP_ATTRIBUTION = {
|
||||
"高德卫星图": '© <a href="http://ditu.amap.com/">高德地图</a>',
|
||||
"高德街道图": '© <a href="http://ditu.amap.com/">高德地图</a>'
|
||||
}
|
||||
|
||||
# 性能配置:超过此点数不生成逐点悬停层,避免浏览器卡顿
|
||||
# MAX_POINTS_FOR_HOVER = 500
|
||||
|
||||
def wgs84_to_gcj02(lng, lat):
|
||||
"""
|
||||
WGS84转GCJ02(火星坐标系)
|
||||
将GPS的WGS84坐标转换为高德地图使用的GCJ02坐标
|
||||
"""
|
||||
a = 6378245.0 # 长半轴
|
||||
ee = 0.00669342162296594323 # 扁率
|
||||
|
||||
# 判断是否在国内
|
||||
if (lng < 72.004 or lng > 137.8347) or (lat < 0.8293 or lat > 55.8271):
|
||||
return lng, lat
|
||||
|
||||
dlat = _transform_lat(lng - 105.0, lat - 35.0)
|
||||
dlng = _transform_lng(lng - 105.0, lat - 35.0)
|
||||
|
||||
radlat = lat / 180.0 * math.pi
|
||||
magic = math.sin(radlat)
|
||||
magic = 1 - ee * magic * magic
|
||||
sqrtmagic = math.sqrt(magic)
|
||||
|
||||
dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * math.pi)
|
||||
dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * math.pi)
|
||||
|
||||
mglat = lat + dlat
|
||||
mglng = lng + dlng
|
||||
|
||||
return mglng, mglat
|
||||
|
||||
def _transform_lat(lng, lat):
|
||||
ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * math.sqrt(abs(lng))
|
||||
ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * math.sin(lat * math.pi) + 40.0 * math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0
|
||||
ret += (160.0 * math.sin(lat / 12.0 * math.pi) + 320 * math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0
|
||||
return ret
|
||||
|
||||
def _transform_lng(lng, lat):
|
||||
ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * math.sqrt(abs(lng))
|
||||
ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * math.sin(lng * math.pi) + 40.0 * math.sin(lng / 3.0 * math.pi)) * 2.0 / 3.0
|
||||
ret += (150.0 * math.sin(lng / 12.0 * math.pi) + 300.0 * math.sin(lng / 30.0 * math.pi)) * 2.0 / 3.0
|
||||
return ret
|
||||
|
||||
def format_datetime(dt):
|
||||
"""
|
||||
格式化日期时间,去掉时区信息
|
||||
"""
|
||||
if dt:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return "未知"
|
||||
|
||||
def format_duration(seconds):
|
||||
"""
|
||||
格式化持续时间
|
||||
"""
|
||||
if seconds is None:
|
||||
return "未知"
|
||||
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}小时{minutes}分{secs}秒"
|
||||
elif minutes > 0:
|
||||
return f"{minutes}分{secs}秒"
|
||||
else:
|
||||
return f"{secs}秒"
|
||||
|
||||
def parse_gpx_file(gpx_file_path):
|
||||
"""
|
||||
解析GPX文件,优化了速度平滑处理和运动时间计算(自动剔除暂停时间)
|
||||
"""
|
||||
# 配置参数
|
||||
MAX_SPEED_THRESHOLD = 50.0 # 最高时速上限 (km/h),超过此值可能是漂移
|
||||
PAUSE_THRESHOLD_SECONDS = 3 # 采样间隔超过3秒视为暂停
|
||||
SPEED_WINDOW_SIZE = 3 # 速度平滑窗口大小
|
||||
|
||||
try:
|
||||
# 尝试多种编码,避免非UTF-8文件导致崩溃
|
||||
gpx_content = None
|
||||
for encoding in ['utf-8', 'utf-8-sig', 'gbk', 'gb2312', 'latin-1']:
|
||||
try:
|
||||
with open(gpx_file_path, 'r', encoding=encoding) as f:
|
||||
gpx_content = f.read()
|
||||
break
|
||||
except (UnicodeDecodeError, UnicodeError):
|
||||
continue
|
||||
if gpx_content is None:
|
||||
print(f"无法解码文件: {gpx_file_path}")
|
||||
return None
|
||||
gpx = gpxpy.parse(gpx_content)
|
||||
|
||||
points = []
|
||||
total_distance = 0
|
||||
moving_time_seconds = 0 # 净运动时间
|
||||
point_moving_time = 0 # 每个轨迹点的累计运动时间(排除暂停)
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
# 临时存储速度用于平滑处理
|
||||
recent_speeds = []
|
||||
|
||||
for track in gpx.tracks:
|
||||
for segment in track.segments:
|
||||
previous_point = None
|
||||
|
||||
for i, point in enumerate(segment.points):
|
||||
# 坐标转换
|
||||
gcj_lng, gcj_lat = wgs84_to_gcj02(point.longitude, point.latitude)
|
||||
|
||||
segment_distance = 0
|
||||
raw_speed = 0
|
||||
time_diff = 0
|
||||
|
||||
if previous_point:
|
||||
# 计算距离 (使用WGS84原始坐标计算距离更准确,再转换用于显示)
|
||||
segment_distance = gpxpy.geo.haversine_distance(
|
||||
previous_point['raw_lat'], previous_point['raw_lng'],
|
||||
point.latitude, point.longitude
|
||||
)
|
||||
total_distance += segment_distance
|
||||
|
||||
# 计算时间差
|
||||
if point.time and previous_point['time']:
|
||||
time_diff = (point.time - previous_point['time']).total_seconds()
|
||||
|
||||
# --- 改进2:暂停检测逻辑 ---
|
||||
# 如果时间间隔在阈值内,计入运动时间
|
||||
if 0 < time_diff < PAUSE_THRESHOLD_SECONDS:
|
||||
moving_time_seconds += time_diff
|
||||
point_moving_time += time_diff
|
||||
|
||||
# 计算原始速度
|
||||
if time_diff > 0:
|
||||
raw_speed = (segment_distance / time_diff) * 3.6 # km/h
|
||||
|
||||
# --- 改进1:速度平滑处理 ---
|
||||
# 剔除极端错误的数字,用0替代而非前一个值(防止异常值传播)
|
||||
if raw_speed > MAX_SPEED_THRESHOLD:
|
||||
raw_speed = 0.0
|
||||
|
||||
recent_speeds.append(raw_speed)
|
||||
if len(recent_speeds) > SPEED_WINDOW_SIZE:
|
||||
recent_speeds.pop(0)
|
||||
|
||||
# 取窗口平均值作为当前点的瞬时速度
|
||||
smoothed_speed_kmh = sum(recent_speeds) / len(recent_speeds) if recent_speeds else 0.0
|
||||
|
||||
# 记录时间
|
||||
if i == 0 and point.time and not start_time:
|
||||
start_time = point.time
|
||||
|
||||
point_data = {
|
||||
'latitude': gcj_lat,
|
||||
'longitude': gcj_lng,
|
||||
'raw_lat': point.latitude, # 保留原始坐标用于距离计算
|
||||
'raw_lng': point.longitude,
|
||||
'elevation': point.elevation,
|
||||
'time': point.time,
|
||||
'cumulative_distance': total_distance,
|
||||
'instantaneous_speed': smoothed_speed_kmh / 3.6, # 转回 m/s 保持统一
|
||||
'duration_from_start': point_moving_time,
|
||||
'segment_distance': segment_distance
|
||||
}
|
||||
points.append(point_data)
|
||||
previous_point = point_data
|
||||
|
||||
if not points: return None
|
||||
|
||||
# 安全获取结束时间(点可能没有时间戳)
|
||||
end_time = None
|
||||
for p in reversed(points):
|
||||
if p['time'] is not None:
|
||||
end_time = p['time']
|
||||
break
|
||||
|
||||
# 如果仍未找到开始时间,从第一个有时间戳的点获取
|
||||
if start_time is None:
|
||||
for p in points:
|
||||
if p['time'] is not None:
|
||||
start_time = p['time']
|
||||
break
|
||||
|
||||
# 计算总时长(包含暂停),只有两者都不为None才计算
|
||||
total_duration_hours = 0.0
|
||||
if start_time and end_time:
|
||||
total_duration_hours = (end_time - start_time).total_seconds() / 3600
|
||||
# 计算运动时长(剔除暂停)
|
||||
moving_duration_hours = moving_time_seconds / 3600
|
||||
|
||||
# 其他统计逻辑 (海拔等) 保持不变...
|
||||
elevations = [p['elevation'] for p in points if p['elevation'] is not None]
|
||||
elevation_gain = 0
|
||||
if len(elevations) > 1:
|
||||
for i in range(1, len(elevations)):
|
||||
if elevations[i] > elevations[i-1]:
|
||||
elevation_gain += elevations[i] - elevations[i-1]
|
||||
|
||||
speeds_kmh = [p['instantaneous_speed'] * 3.6 for p in points]
|
||||
max_speed = max(speeds_kmh) if speeds_kmh else 0
|
||||
avg_speed = (total_distance / 1000) / moving_duration_hours if moving_duration_hours > 0 else 0
|
||||
|
||||
return {
|
||||
'points': points,
|
||||
'total_distance': total_distance / 1000,
|
||||
'start_time': format_datetime(start_time),
|
||||
'end_time': format_datetime(end_time),
|
||||
'num_points': len(points),
|
||||
'min_elevation': min(elevations) if elevations else None,
|
||||
'max_elevation': max(elevations) if elevations else None,
|
||||
'avg_elevation': sum(elevations)/len(elevations) if elevations else None,
|
||||
'elevation_gain': elevation_gain,
|
||||
'duration_hours': moving_duration_hours, # 这里改为返回运动时间
|
||||
'total_elapsed_hours': total_duration_hours, # 保留一个总耗时
|
||||
'avg_speed': avg_speed,
|
||||
'max_speed': max_speed
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"解析GPX文件时出错: {e}")
|
||||
return None
|
||||
|
||||
def create_map_with_track(gpx_data, output_file='gpx_track_map.html'):
|
||||
"""
|
||||
使用folium创建带有轨迹的地图,默认提供多个地图源选项
|
||||
添加鼠标悬停显示路程、速度和时间功能
|
||||
轨迹始终显示,没有选择框控制
|
||||
"""
|
||||
if not gpx_data or not gpx_data['points']:
|
||||
print("没有轨迹数据可显示")
|
||||
return
|
||||
|
||||
points = gpx_data['points']
|
||||
|
||||
# 计算地图中心点
|
||||
center_lat = sum(p['latitude'] for p in points) / len(points)
|
||||
center_lon = sum(p['longitude'] for p in points) / len(points)
|
||||
|
||||
# 创建地图 - 使用高德街道图作为默认地图
|
||||
m = folium.Map(
|
||||
location=[center_lat, center_lon],
|
||||
zoom_start=13,
|
||||
tiles=None # 不设置默认瓦片
|
||||
)
|
||||
|
||||
# 添加所有地图源作为可选图层
|
||||
for tile_name, tile_url in MAP_TILES.items():
|
||||
folium.TileLayer(
|
||||
tiles=tile_url,
|
||||
attr=MAP_ATTRIBUTION[tile_name],
|
||||
name=tile_name
|
||||
).add_to(m)
|
||||
|
||||
# 创建轨迹线的坐标列表和对应的工具提示数据
|
||||
track_coordinates = []
|
||||
tooltip_data = []
|
||||
|
||||
for point in points:
|
||||
track_coordinates.append((point['latitude'], point['longitude']))
|
||||
|
||||
# 准备工具提示信息(使用 html.escape 防止 XSS)
|
||||
distance_km = point['cumulative_distance'] / 1000
|
||||
speed_kmh = point['instantaneous_speed'] * 3.6 # 转换为km/h
|
||||
|
||||
tooltip = (
|
||||
f'<div style="font-size:12px;font-family:Arial,sans-serif;">'
|
||||
f'<b>位置信息</b><br>'
|
||||
f'路程: {distance_km:.2f} km<br>'
|
||||
f'速度: {speed_kmh:.1f} km/h<br>'
|
||||
f'时间: {html.escape(format_datetime(point["time"]))}<br>'
|
||||
f'持续时间: {html.escape(format_duration(point["duration_from_start"]))}'
|
||||
f'</div>'
|
||||
)
|
||||
tooltip_data.append(tooltip)
|
||||
|
||||
# 创建轨迹线 - 直接添加到地图
|
||||
track_line = folium.PolyLine(
|
||||
track_coordinates,
|
||||
color='red',
|
||||
weight=5,
|
||||
opacity=0.8,
|
||||
popup='GPS轨迹',
|
||||
tooltip='鼠标悬停查看详情'
|
||||
)
|
||||
track_line.add_to(m)
|
||||
|
||||
# 使用 JavaScript 实现高效的鼠标悬停(替代创建大量透明PolyLine)
|
||||
# 只在点数合理时启用逐点悬停,否则降级为简单提示
|
||||
# if len(points) <= MAX_POINTS_FOR_HOVER:
|
||||
if True: # 始终启用逐点悬停功能
|
||||
# 将坐标和提示数据注入 JS
|
||||
coords_json = json.dumps([[p['latitude'], p['longitude']] for p in points])
|
||||
tooltips_json = json.dumps(tooltip_data, ensure_ascii=False)
|
||||
|
||||
hover_js = f"""
|
||||
<script>
|
||||
(function() {{
|
||||
var trackCoords = {coords_json};
|
||||
var trackTooltips = {tooltips_json};
|
||||
|
||||
function getMapInstance() {{
|
||||
for (var key in window) {{
|
||||
try {{
|
||||
if (window[key] instanceof L.Map) return window[key];
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
var mapEl = document.querySelector('.folium-map');
|
||||
if (mapEl) {{
|
||||
var inner = mapEl.querySelector('div[id]');
|
||||
if (inner) {{
|
||||
var id = inner.id;
|
||||
if (window[id] && window[id] instanceof L.Map) return window[id];
|
||||
if (window['map_' + id] && window['map_' + id] instanceof L.Map) return window['map_' + id];
|
||||
}}
|
||||
}}
|
||||
return null;
|
||||
}}
|
||||
|
||||
function findNearestPoint(latlng) {{
|
||||
var minDist = Infinity, minIdx = 0;
|
||||
for (var i = 0; i < trackCoords.length; i++) {{
|
||||
var dLat = trackCoords[i][0] - latlng.lat;
|
||||
var dLng = trackCoords[i][1] - latlng.lng;
|
||||
var d = dLat * dLat + dLng * dLng;
|
||||
if (d < minDist) {{ minDist = d; minIdx = i; }}
|
||||
}}
|
||||
return minIdx;
|
||||
}}
|
||||
|
||||
function initHover() {{
|
||||
var mapInstance = getMapInstance();
|
||||
if (!mapInstance) {{
|
||||
setTimeout(initHover, 300);
|
||||
return;
|
||||
}}
|
||||
|
||||
var hoverTooltip = L.tooltip({{className: 'track-hover-tooltip', direction: 'top', offset: [0, -10]}});
|
||||
|
||||
var hoverLine = L.polyline(trackCoords, {{
|
||||
color: 'transparent',
|
||||
weight: 25,
|
||||
opacity: 0.01,
|
||||
interactive: true
|
||||
}}).addTo(mapInstance);
|
||||
|
||||
hoverLine.on('mousemove', function(e) {{
|
||||
var idx = findNearestPoint(e.latlng);
|
||||
hoverTooltip
|
||||
.setLatLng(trackCoords[idx])
|
||||
.setContent(trackTooltips[idx])
|
||||
.addTo(mapInstance);
|
||||
}});
|
||||
|
||||
hoverLine.on('mouseout', function() {{
|
||||
try {{ mapInstance.removeLayer(hoverTooltip); }} catch (e) {{}}
|
||||
}});
|
||||
}}
|
||||
|
||||
setTimeout(initHover, 200);
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
m.get_root().html.add_child(folium.Element(hover_js))
|
||||
|
||||
# 添加起点标记
|
||||
start_point = points[0]
|
||||
folium.Marker(
|
||||
[start_point['latitude'], start_point['longitude']],
|
||||
popup=f"起点\n时间: {format_datetime(start_point['time'])}",
|
||||
tooltip="起点",
|
||||
icon=folium.Icon(color='green', icon='play', prefix='fa')
|
||||
).add_to(m)
|
||||
|
||||
# 添加终点标记
|
||||
end_point = points[-1]
|
||||
folium.Marker(
|
||||
[end_point['latitude'], end_point['longitude']],
|
||||
popup=f"终点\n时间: {format_datetime(end_point['time'])}",
|
||||
tooltip="终点",
|
||||
icon=folium.Icon(color='red', icon='stop', prefix='fa')
|
||||
).add_to(m)
|
||||
|
||||
# 在轨迹上添加关键点标记(每25%的距离添加一个)
|
||||
num_key_points = 4
|
||||
for i in range(1, num_key_points):
|
||||
target_distance = (gpx_data['total_distance'] * 1000) * (i / num_key_points)
|
||||
|
||||
# 找到最接近目标距离的点
|
||||
closest_point = min(points, key=lambda x: abs(x['cumulative_distance'] - target_distance))
|
||||
|
||||
folium.CircleMarker(
|
||||
[closest_point['latitude'], closest_point['longitude']],
|
||||
radius=4,
|
||||
popup=f"路程: {closest_point['cumulative_distance']/1000:.2f} km",
|
||||
tooltip=f"{i*25}% 路程点",
|
||||
color='blue',
|
||||
fill=True,
|
||||
fillColor='blue'
|
||||
).add_to(m)
|
||||
|
||||
# 添加信息框 - 缩小尺寸并添加最高速度
|
||||
info_html = f"""
|
||||
<div style="position: fixed;
|
||||
top: 10px; left: 10px; width: 280px; height: auto;
|
||||
background-color: white; border:2px solid grey; z-index:9999;
|
||||
padding: 8px; font-size:12px; border-radius: 5px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);">
|
||||
<b style="font-size:13px;">轨迹信息</b><br>
|
||||
总距离: {gpx_data['total_distance']:.2f} km<br>
|
||||
轨迹点数: {gpx_data['num_points']}<br>
|
||||
开始时间: {gpx_data['start_time']}<br>
|
||||
结束时间: {gpx_data['end_time']}<br>
|
||||
"""
|
||||
|
||||
# 添加海拔信息(如果存在)
|
||||
if gpx_data['min_elevation'] is not None:
|
||||
info_html += f"最低海拔: {gpx_data['min_elevation']:.1f} m<br>"
|
||||
info_html += f"最高海拔: {gpx_data['max_elevation']:.1f} m<br>"
|
||||
info_html += f"平均海拔: {gpx_data['avg_elevation']:.1f} m<br>"
|
||||
info_html += f"累计爬升: {gpx_data['elevation_gain']:.0f} m<br>"
|
||||
|
||||
# 添加时间和速度信息
|
||||
if gpx_data['duration_hours'] > 0:
|
||||
hours = int(gpx_data['duration_hours'])
|
||||
minutes = int((gpx_data['duration_hours'] - hours) * 60)
|
||||
info_html += f"持续时间: {hours}时{minutes}分<br>"
|
||||
info_html += f"平均速度: {gpx_data['avg_speed']:.1f} km/h<br>"
|
||||
info_html += f"<b>最高速度: {gpx_data['max_speed']:.1f} km/h</b><br>"
|
||||
|
||||
info_html += "<br><b>使用提示:</b> 鼠标悬停轨迹查看详情"
|
||||
info_html += "</div>"
|
||||
m.get_root().html.add_child(folium.Element(info_html))
|
||||
|
||||
# 添加图层控制(只控制地图源,不控制轨迹)
|
||||
folium.LayerControl().add_to(m)
|
||||
|
||||
# 保存地图
|
||||
m.save(output_file)
|
||||
return m
|
||||
|
||||
def batch_process_gpx_files(folder_path):
|
||||
"""
|
||||
批量处理文件夹中的所有GPX文件
|
||||
"""
|
||||
gpx_files = glob.glob(os.path.join(folder_path, "*.gpx"))
|
||||
|
||||
if not gpx_files:
|
||||
print("在指定文件夹中未找到GPX文件")
|
||||
return
|
||||
|
||||
processed_count = 0
|
||||
|
||||
for gpx_file in gpx_files:
|
||||
print(f"正在处理: {os.path.basename(gpx_file)}")
|
||||
gpx_data = parse_gpx_file(gpx_file)
|
||||
|
||||
if gpx_data:
|
||||
output_filename = os.path.splitext(gpx_file)[0] + '_map.html'
|
||||
create_map_with_track(gpx_data, output_filename)
|
||||
processed_count += 1
|
||||
print(f" 已生成: {os.path.basename(output_filename)}")
|
||||
|
||||
return processed_count
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("GPX轨迹可视化工具")
|
||||
print("正在处理当前文件夹中的GPX文件...")
|
||||
|
||||
# 获取当前程序所在文件夹路径
|
||||
current_folder = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 批量处理当前文件夹中的所有GPX文件
|
||||
processed_count = batch_process_gpx_files(current_folder)
|
||||
|
||||
print(f"处理完成!共生成 {processed_count} 个地图文件")
|
||||
print("请在浏览器中打开生成的HTML文件查看轨迹地图")
|
||||
print("提示:您可以在右上角切换不同的地图图层")
|
||||
print("新功能:鼠标悬停在轨迹线上可以查看实时路程、速度和时间信息")
|
||||
Reference in New Issue
Block a user