用Python玩转GPS数据:NMEA 0183协议解析实战(附完整代码)
用Python玩转GPS数据:从串口到地图的全链路实战解析
你是否曾想过,自己动手打造一个能实时追踪位置、绘制轨迹的小型系统?无论是为无人机项目添加精准定位,还是为户外探险设备记录行进路线,甚至是为物流小车开发监控后台,处理GPS数据都是核心的一环。市面上很多教程要么停留在理论层面,要么只给几行零散的代码片段,真正想动手时却发现无从下手——串口怎么连?数据流怎么处理?解析出来的坐标又该怎么用?
这篇文章就是为你准备的。我们不谈空泛的理论,直接从硬件连接开始,一步步带你用Python搭建一个完整的GPS数据处理流水线。你会看到如何从一块几十元的GPS模块里“挖”出经纬度、速度、时间,如何用代码验证数据的可靠性,最后还能把枯燥的数字变成直观的地图轨迹。整个过程就像搭积木,每个环节都有清晰的代码和解释,哪怕你之前没接触过串口编程,也能跟着做出来。
1. 硬件准备与串口通信:打通数据的第一公里
在写第一行解析代码之前,我们得先让电脑“听到”GPS模块在说什么。这第一步,往往是最容易卡住新手的地方。
1.1 选择合适的GPS模块与连接
对于大多数开发者和物联网项目,UART(通用异步收发传输器)接口的GPS模块是性价比和易用性的最佳平衡点。像常见的NEO-6M、NEO-7M系列模块,价格亲民,性能稳定,通过几根杜邦线就能和电脑或树莓派这样的开发板对话。
连接非常简单,核心就三根线:
- VCC:接3.3V或5V电源(具体看模块规格)。
- GND:接地。
- TX:模块的发送端,接电脑或开发板的RX(接收)引脚。
注意:很多新手会搞混TX和RX的连接。记住一个原则:数据发送方(TX)要接接收方(RX)。GPS模块的TX输出数据,所以要接到你电脑串口的RX引脚上。
连接好后,模块上的LED指示灯通常会开始闪烁,表示正在搜索卫星。把它放到窗户边或户外,耐心等待几分钟,直到指示灯变为规律的慢闪或常亮,这意味着已经成功定位。
1.2 使用PySerial建立稳定的数据流
在Python的世界里,与串口设备通信的首选库是 pyserial。它的API设计直观,能很好地处理二进制数据流。首先确保安装它:
pip install pyserial
接下来,我们写一个简单的串口监听器,看看GPS模块到底在“说”什么。这里有个关键点:串口参数必须与GPS模块的出厂设置匹配。绝大多数模块的默认设置是 9600波特率、8位数据位、无奇偶校验、1位停止位(即 9600,8,N,1)。
import serial
import time
def listen_to_gps(port='COM3', baudrate=9600, timeout=2):
"""
监听指定串口,实时打印原始的NMEA 0183语句。
这是一个基础的诊断工具,用于确认硬件连接和通信是否正常。
"""
try:
# 创建串口连接对象
with serial.Serial(port, baudrate, timeout=timeout) as ser:
print(f"已成功连接到 {port},波特率 {baudrate}")
print("正在接收原始数据(按Ctrl+C中断)...\n")
buffer = ""
while True:
# 每次读取串口缓冲区中的所有字节
raw_data = ser.read(ser.in_waiting or 1)
if raw_data:
# 解码为字符串并追加到缓冲区
buffer += raw_data.decode('ascii', errors='ignore')
# 按行分割并处理完整的句子
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if line.startswith('$'): # 只处理有效的NMEA语句
print(f"[RAW] {line}")
time.sleep(0.01) # 短暂休眠,避免CPU占用过高
except serial.SerialException as e:
print(f"无法打开串口 {port}: {e}")
print("请检查:1. 端口号是否正确? 2. 设备是否被其他程序占用?")
except KeyboardInterrupt:
print("\n监听已由用户中断。")
if __name__ == '__main__':
# 你需要将 'COM3' 替换成你电脑上的实际端口
# 在Windows上是COMx,在Linux/macOS上是 /dev/ttyUSBx 或 /dev/ttyACMx
listen_to_gps(port='/dev/ttyUSB0')
运行这个脚本,如果你的连接正确,终端会开始滚动输出类似下面的文本:
[RAW] $GPGGA,092725.00,4005.12345,N,11625.67890,E,1,08,1.2,56.8,M,-8.0,M,,*6B
[RAW] $GPRMC,092725.00,A,4005.12345,N,11625.67890,E,0.12,185.23,140923,,,A*7D
[RAW] $GPVTG,185.23,T,,M,0.12,N,0.22,K,A*3C
恭喜你,数据管道已经打通了!这些以$开头、看似杂乱无章的字符串,就是标准的NMEA 0183协议数据。每一行都包含了一组特定的定位信息。接下来,我们要做的就是理解这套“语言”的语法,并把它翻译成我们程序能理解的字典或对象。
2. 深入NMEA 0183:协议解析的核心原理
NMEA 0183协议设计得非常“人类可读”,它本质是一种基于ASCII文本的逗号分隔值(CSV)格式。理解它的结构,是写出健壮解析器的前提。
2.1 语句结构与校验机制
每一条有效的NMEA语句都遵循一个严格的格式:
$AABBB,data1,data2,...,dataN*CC<CR><LF>
我们来拆解每个部分:
$:语句起始符,所有语句都以它开头。AABBB:5个字符的语句标识符。AA:谈话者标识。GP表示GPS(美国全球定位系统),GL表示GLONASS(俄罗斯格洛纳斯),GN表示多系统混合数据。现在很多模块都输出GN开头的语句。BBB:语句格式标识。定义了这句话包含什么信息,比如GGA是核心定位信息,RMC是最简推荐数据。
data1...dataN:由逗号分隔的数据字段。字段内容可以是数字、字符,也可能是空的(两个连续的逗号,,)。*CC:校验和。这是保证数据在传输过程中不被篡改或出错的关键。CC是一个两位的十六进制数。<CR><LF>:回车换行符,表示语句结束。
校验和的计算是解析前必须验证的一步。它的算法是对$和*之间的所有字符(不包括$和*本身)进行连续的异或(XOR) 运算。
def calculate_nmea_checksum(sentence_part):
"""
计算NMEA语句部分的校验和。
:param sentence_part: 字符串,即$和*之间的部分,例如"GPGGA,092725.00,..."
:return: 两位十六进制字符串,例如"6B"
"""
checksum = 0
for char in sentence_part:
checksum ^= ord(char) # 将字符转换为ASCII码并进行异或运算
return f"{checksum:02X}" # 格式化为两位大写十六进制
def validate_nmea_checksum(full_sentence):
"""
验证一条完整NMEA语句的校验和是否正确。
:param full_sentence: 完整的NMEA语句,如 "$GPGGA,...*6B"
:return: True如果校验和正确,否则False
"""
if '*' not in full_sentence:
return False
try:
# 分离数据部分和声明的校验和
data_part, declared_checksum = full_sentence.split('*')
# 数据部分是$之后的所有字符
data_for_check = data_part[1:] # 去掉开头的$
calculated = calculate_nmea_checksum(data_for_check)
return calculated == declared_checksum.upper().strip()
except (ValueError, IndexError):
return False
# 测试一下
test_sentence = "$GPGGA,092725.00,4005.12345,N,11625.67890,E,1,08,1.2,56.8,M,-8.0,M,,*6B"
print(f"校验和验证结果: {validate_nmea_checksum(test_sentence)}") # 应该输出 True
为什么校验和如此重要? 在真实的硬件环境中,电磁干扰、连接松动都可能导致数据传输中出现错位或乱码。一个失败的校验和检查,能让你果断丢弃这行不可信的数据,避免把错误的位置信息用于后续计算。
2.2 关键语句深度解读:GGA与RMC
NMEA定义了数十种语句,但对于大多数应用,掌握其中两三种就足够了。GPGGA和GPRMC是最常用、信息最核心的两种。
GPGGA - 全球定位系统定位数据 这是精度最高、信息最全的定位语句。它直接告诉你“你在哪里,定位质量如何”。
| 字段序号 | 字段含义 | 示例值 | 说明与处理要点 |
|---|---|---|---|
| 0 | 语句ID | GPGGA |
固定标识 |
| 1 | UTC时间 | 092725.00 |
hhmmss.ss格式,需转换为秒或时间对象 |
| 2 | 纬度 | 4005.12345 |
度分格式 ddmm.mmmmm,需要转换 |
| 3 | 纬度半球 | N |
N(北纬) 或 S(南纬) |
| 4 | 经度 | 11625.67890 |
度分格式 dddmm.mmmmm,需要转换 |
| 5 | 经度半球 | E |
E(东经) 或 W(西经) |
| 6 | 定位质量指示 | 1 |
0=无效,1=单点定位,2=差分定位,这是判断数据是否可用的关键 |
| 7 | 使用卫星数 | 08 |
参与解算的卫星数量,越多通常精度越高 |
| 8 | HDOP | 1.2 |
水平精度因子,数值越小,水平定位精度越高 |
| 9 | 海拔高度 | 56.8 |
单位:米 |
| 10 | 高度单位 | M |
米 |
| 11 | 大地水准面高 | -8.0 |
单位:米,相对于椭球面的高度差 |
| 12 | 高度单位 | M |
米 |
| 13 | 差分龄期 | (空) | DGPS数据年龄,单点定位时为空 |
| 14 | 差分站ID | (空) | DGPS基准站ID,单点定位时为空 |
| 15 | 校验和 | 6B |
十六进制 |
GPRMC - 推荐最小定位信息 这条语句信息更紧凑,但包含了时间、日期、位置、速度、航向等移动相关的核心数据,非常适合轨迹记录和导航应用。
| 字段序号 | 字段含义 | 示例值 | 说明与处理要点 |
|---|---|---|---|
| 0 | 语句ID | GPRMC |
固定标识 |
| 1 | UTC时间 | 092725.00 |
hhmmss.ss格式 |
| 2 | 状态 | A |
A=数据有效,V=导航接收器警告(数据无效) |
| 3 | 纬度 | 4005.12345 |
度分格式,同GGA |
| 4 | 纬度半球 | N |
同GGA |
| 5 | 经度 | 11625.67890 |
度分格式,同GGA |
| 6 | 经度半球 | E |
同GGA |
| 7 | 地面速度 | 0.12 |
单位是节(knots),1节=1.852公里/小时 |
| 8 | 地面航向 | 185.23 |
单位是度,以真北为参考,0°表示北,90°表示东 |
| 9 | UTC日期 | 140923 |
ddmmyy格式,需要与时间字段结合得到完整时间戳 |
| 10 | 磁偏角 | (空) | 单位是度,东偏为正,西偏为负 |
| 11 | 磁偏角方向 | (空) | E或W |
| 12 | 模式指示 | A |
A=自主定位,D=差分,E=估算,N=数据无效 |
| 13 | 校验和 | 7D |
十六进制 |
关键点:
GGA的第6字段(定位质量)和RMC的第2字段(状态)是判断当前GPS数据是否可用的首要依据。只有GGA质量指示为1或2,且RMC状态为A时,位置信息才是可靠的。
2.3 坐标转换:从“度分”到“十进制度”
GPS模块输出的经纬度是“度分”(DDMM.MMMMM)格式,而绝大多数地图API和地理信息系统(如Google Maps, Leaflet, 百度地图)使用的是“十进制度”(DD.DDDDD)格式。这个转换是必须掌握的。
转换公式非常简单: 十进制度 = 度数 + 分数 / 60
例如,纬度 4005.12345 N:
- 度数是前两位(或三位,经度是三位):
40 - 分数是剩余部分:
05.12345 - 十进制纬度 =
40 + 5.12345 / 60 ≈ 40.0853908
经度 11625.67890 E:
- 度数是前三位:
116 - 分数是剩余部分:
25.67890 - 十进制经度 =
116 + 25.67890 / 60 ≈ 116.4279817
同时,需要根据半球标识(N/S, E/W)决定正负。通常,北纬和东经为正,南纬和西经为负。
def dms_to_decimal(dms_str, hemisphere):
"""
将度分格式字符串转换为十进制度。
:param dms_str: 如 "4005.12345" 或 "11625.67890"
:param hemisphere: 方向标识,'N', 'S', 'E', 'W'
:return: 十进制度数值(浮点数)
"""
if not dms_str or dms_str == '':
return None
# 判断是纬度(ddmm.mmmm)还是经度(dddmm.mmmm)
# 简单规则:如果字符串长度>5且前两位可以转换为小于90的数,则可能是纬度(度数为2位)
# 更通用的方法是根据hemisphere判断,但这里采用更稳健的浮点数除法
try:
dms_float = float(dms_str)
except ValueError:
return None
# 获取度数部分:纬度是前2位,经度是前3位。但通过浮点数运算更安全。
# 例如 4005.12345 -> 度数是 40,分数是 5.12345
degrees = int(dms_float / 100) # 整除100得到度数
minutes = dms_float - (degrees * 100) # 减去度数部分得到分数
decimal_value = degrees + (minutes / 60.0)
# 根据半球调整正负号
if hemisphere in ['S', 'W']:
decimal_value = -decimal_value
return decimal_value
# 测试转换
lat_dms = "4005.12345"
lon_dms = "11625.67890"
lat_decimal = dms_to_decimal(lat_dms, 'N')
lon_decimal = dms_to_decimal(lon_dms, 'E')
print(f"原始纬度: {lat_dms} N -> 十进制: {lat_decimal:.6f}")
print(f"原始经度: {lon_dms} E -> 十进制: {lon_decimal:.6f}")
3. 构建健壮的Python解析器:从类设计到异常处理
有了理论基础,我们现在可以动手构建一个既清晰又健壮的解析器类。好的设计应该遵循单一职责原则,并且能优雅地处理各种边界情况和错误数据。
3.1 核心解析器类设计
我们将设计一个NMEAParser类,它不仅能解析多种语句,还能方便地扩展。
class NMEAParser:
"""
一个健壮、可扩展的NMEA 0183协议解析器。
支持GGA, RMC, VTG, GSA, GSV等常见语句的解析。
"""
def __init__(self):
# 注册不同语句类型的解析方法
self.parsers = {
'GGA': self._parse_gga,
'RMC': self._parse_rmc,
'VTG': self._parse_vtg,
'GSA': self._parse_gsa,
'GSV': self._parse_gsv,
'GLL': self._parse_gll,
}
# 用于存储最后一次有效定位数据
self.last_fix = {
'latitude': None,
'longitude': None,
'altitude': None,
'speed': None,
'course': None,
'timestamp': None,
'satellites': 0,
'fix_quality': 0
}
def parse(self, raw_sentence):
"""
解析单条NMEA语句的主入口。
1. 验证校验和
2. 提取语句类型
3. 分发给对应的解析方法
:param raw_sentence: 原始字符串,如 "$GPGGA,...*6B"
:return: 解析后的字典,如果失败则返回None
"""
# 1. 基础清洗和验证
sentence = raw_sentence.strip()
if not sentence.startswith('$'):
# 可能是不完整的行或非NMEA数据
return None
# 2. 校验和验证
if not self._validate_checksum(sentence):
# 在实际应用中,可以考虑记录校验和错误日志
# print(f"校验和失败: {sentence}")
return None
# 3. 分割字段并提取类型
try:
# 去掉$和*CC部分,按逗号分割
data_part = sentence.split('*')[0] # 获取$...*之前的部分
fields = data_part[1:].split(',') # 去掉$,按逗号分割
if len(fields) < 1:
return None
# 语句标识符如 GPGGA,取后三位 GGA
talker_id = fields[0][:2] # GP, GL, GN等
sentence_type = fields[0][2:] # GGA, RMC等
# 4. 调用对应的解析器
if sentence_type in self.parsers:
result = self.parsers[sentence_type](fields)
if result:
result['talker_id'] = talker_id
result['raw'] = sentence # 保留原始语句,便于调试
self._update_last_fix(result) # 更新状态
return result
else:
# 遇到不支持的语句类型,返回基础信息
return {'type': sentence_type, 'talker_id': talker_id, 'raw': sentence}
except (IndexError, ValueError, AttributeError) as e:
# 捕获分割、转换过程中可能出现的任何异常
# 在实际项目中,这里可以记录到日志文件
# print(f"解析过程出错: {e}, 原始数据: {sentence}")
return None
def _validate_checksum(self, sentence):
"""内部方法:校验和验证(复用之前的函数)"""
# 实现略,同前文 validate_nmea_checksum 函数
pass
def _parse_gga(self, fields):
"""解析GGA语句"""
try:
# 字段索引参考前文表格
fix_quality = int(fields[6]) if fields[6] else 0
# 只有定位有效时才解析详细位置
if fix_quality == 0:
return {'type': 'GGA', 'fix_quality': 0, 'status': 'Invalid'}
data = {
'type': 'GGA',
'timestamp': self._parse_time(fields[1]),
'latitude': dms_to_decimal(fields[2], fields[3]),
'longitude': dms_to_decimal(fields[4], fields[5]),
'fix_quality': fix_quality,
'satellites': int(fields[7]) if fields[7] else 0,
'hdop': float(fields[8]) if fields[8] else 99.9,
'altitude': float(fields[9]) if fields[9] else 0.0,
'geoid_height': float(fields[11]) if fields[11] else 0.0,
}
return data
except (ValueError, IndexError, TypeError) as e:
# 字段缺失或转换错误时,返回一个标记错误的结果
return {'type': 'GGA', 'error': str(e), 'raw_fields': fields}
def _parse_rmc(self, fields):
"""解析RMC语句"""
try:
status = fields[2]
if status != 'A': # 状态不为'A'(有效)
return {'type': 'RMC', 'status': 'V', 'warning': 'Navigation receiver warning'}
data = {
'type': 'RMC',
'timestamp': self._parse_time(fields[1]),
'status': status,
'latitude': dms_to_decimal(fields[3], fields[4]),
'longitude': dms_to_decimal(fields[5], fields[6]),
'speed_knots': float(fields[7]) if fields[7] else 0.0,
'speed_kmh': float(fields[7]) * 1.852 if fields[7] else 0.0, # 节转公里/小时
'course': float(fields[8]) if fields[8] else 0.0,
'date': self._parse_date(fields[9]),
'datetime': self._combine_datetime(fields[9], fields[1]), # 组合日期时间
'magnetic_variation': self._parse_magnetic_var(fields[10], fields[11]) if len(fields) > 11 else None,
'mode': fields[12] if len(fields) > 12 else 'N'
}
return data
except (ValueError, IndexError, TypeError) as e:
return {'type': 'RMC', 'error': str(e), 'raw_fields': fields}
def _parse_time(self, time_str):
"""将hhmmss.ss格式的时间转换为自午夜起的秒数(浮点)"""
if not time_str or len(time_str) < 6:
return 0.0
try:
hours = int(time_str[0:2])
minutes = int(time_str[2:4])
seconds = float(time_str[4:]) if len(time_str) > 5 else 0.0
return hours * 3600 + minutes * 60 + seconds
except ValueError:
return 0.0
def _parse_date(self, date_str):
"""将ddmmyy格式的日期转换为YYYY-MM-DD字符串"""
if not date_str or len(date_str) != 6:
return None
try:
day = date_str[0:2]
month = date_str[2:4]
year = f"20{date_str[4:6]}" # 假设是2000年之后
return f"{year}-{month}-{day}"
except (ValueError, IndexError):
return None
def _combine_datetime(self, date_str, time_str):
"""组合日期和时间,返回一个datetime对象(需要datetime库)"""
from datetime import datetime
date_part = self._parse_date(date_str)
time_seconds = self._parse_time(time_str)
if date_part and time_seconds:
try:
# 将秒数转换为时、分、秒
hours = int(time_seconds // 3600)
minutes = int((time_seconds % 3600) // 60)
seconds = int(time_seconds % 60)
microseconds = int((time_seconds - int(time_seconds)) * 1e6)
dt_str = f"{date_part} {hours:02d}:{minutes:02d}:{seconds:02d}"
return datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
except Exception:
return None
return None
def _parse_magnetic_var(self, var_str, dir_str):
"""解析磁偏角,考虑方向(E/W)"""
if not var_str or not dir_str:
return None
try:
var = float(var_str)
if dir_str.upper() == 'W':
var = -var
return var
except ValueError:
return None
def _update_last_fix(self, parsed_data):
"""用最新解析的有效数据更新内部状态"""
if parsed_data.get('type') == 'GGA' and parsed_data.get('fix_quality', 0) > 0:
self.last_fix.update({
'latitude': parsed_data.get('latitude'),
'longitude': parsed_data.get('longitude'),
'altitude': parsed_data.get('altitude'),
'satellites': parsed_data.get('satellites', 0),
'fix_quality': parsed_data.get('fix_quality', 0),
'timestamp': parsed_data.get('timestamp')
})
elif parsed_data.get('type') == 'RMC' and parsed_data.get('status') == 'A':
self.last_fix.update({
'latitude': parsed_data.get('latitude'),
'longitude': parsed_data.get('longitude'),
'speed': parsed_data.get('speed_kmh'),
'course': parsed_data.get('course'),
'timestamp': parsed_data.get('timestamp')
})
# 其他语句的解析方法(VTG, GSA, GSV, GLL)可以根据需要类似实现
def _parse_vtg(self, fields):
"""解析VTG(地面速度信息)"""
# 实现略
return {'type': 'VTG'}
def _parse_gsa(self, fields):
"""解析GSA(当前卫星信息)"""
# 实现略
return {'type': 'GSA'}
def _parse_gsv(self, fields):
"""解析GSV(可见卫星信息)"""
# 实现略
return {'type': 'GSV'}
def _parse_gll(self, fields):
"""解析GLL(地理定位信息)"""
# 实现略
return {'type': 'GLL'}
这个类的设计有几个值得注意的亮点:
- 字典派发:使用
parsers字典将语句类型映射到对应的解析方法,使得添加对新语句的支持变得非常简单,只需在字典中添加新条目并实现方法即可。 - 健壮的错误处理:在每个解析方法内部使用
try...except,确保单个字段的解析错误不会导致整个程序崩溃,而是返回一个包含错误信息的字典。 - 状态保持:
last_fix字典持续跟踪最后一次有效的定位信息,这对于需要随时获取最新位置的应用(如实时追踪)非常有用。 - 数据丰富化:在
_parse_rmc中,我们不仅解析了速度(节),还立即计算了更常用的公里/小时单位,方便后续直接使用。
3.2 实时数据流处理与多线程
在实际应用中,GPS模块是持续输出数据的。我们需要一个能持续读取、解析并处理这些数据的机制。这里,多线程是一个经典的选择:一个线程负责阻塞式地读取串口数据,另一个线程(主线程)可以用于用户交互、数据可视化或网络传输。
import threading
import queue
import time
from collections import deque
class RealTimeGPSProcessor:
"""
实时GPS数据处理中心。
使用生产者-消费者模型:串口读取线程为生产者,解析与处理在消费者线程或主线程中进行。
"""
def __init__(self, port, baudrate=9600, max_queue_size=1000):
self.port = port
self.baudrate = baudrate
self.serial_conn = None
self.parser = NMEAParser()
# 使用队列作为线程间通信的缓冲区
self.raw_data_queue = queue.Queue(maxsize=max_queue_size)
self.parsed_data_queue = queue.Queue(maxsize=max_queue_size)
# 控制线程运行的标志
self.is_running = False
self.read_thread = None
self.process_thread = None
# 历史数据存储(例如用于绘制轨迹)
self.position_history = deque(maxlen=5000) # 最多保存5000个点
def start(self):
"""启动串口读取和数据处理线程"""
if self.is_running:
print("处理器已在运行中。")
return
try:
self.serial_conn = serial.Serial(self.port, self.baudrate, timeout=1)
print(f"串口 {self.port} 已打开。")
except serial.SerialException as e:
print(f"无法打开串口: {e}")
return
self.is_running = True
# 启动数据读取线程
self.read_thread = threading.Thread(target=self._read_from_serial, daemon=True)
self.read_thread.start()
# 启动数据处理线程
self.process_thread = threading.Thread(target=self._process_raw_data, daemon=True)
self.process_thread.start()
print("GPS实时处理器已启动。")
def _read_from_serial(self):
"""生产者线程:从串口持续读取数据,按行放入队列"""
buffer = ""
while self.is_running:
try:
if self.serial_conn.in_waiting > 0:
raw_bytes = self.serial_conn.read(self.serial_conn.in_waiting)
buffer += raw_bytes.decode('ascii', errors='ignore')
# 按换行符分割完整句子
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if line:
# 将原始数据放入队列,非阻塞式,如果队列满则丢弃最旧数据
try:
self.raw_data_queue.put_nowait(line)
except queue.Full:
# 队列已满,丢弃一条数据(可根据需求调整策略)
try:
self.raw_data_queue.get_nowait() # 丢弃一条
self.raw_data_queue.put_nowait(line) # 放入新的
except queue.Empty:
pass
else:
time.sleep(0.01) # 避免空转消耗CPU
except (serial.SerialException, UnicodeDecodeError) as e:
print(f"串口读取错误: {e}")
time.sleep(0.5) # 出错后等待一段时间再重试
def _process_raw_data(self):
"""消费者线程:从队列取出原始数据,解析,并放入解析后队列"""
while self.is_running:
try:
# 等待最多1秒获取一条原始数据
raw_line = self.raw_data_queue.get(timeout=1)
parsed = self.parser.parse(raw_line)
if parsed:
# 将解析结果放入另一个队列,供主线程或其他消费者使用
try:
self.parsed_data_queue.put_nowait(parsed)
except queue.Full:
pass
# 如果是有效定位数据,存入历史记录
if parsed.get('type') in ['GGA', 'RMC']:
lat = parsed.get('latitude')
lon = parsed.get('longitude')
if lat is not None and lon is not None:
timestamp = time.time() # 使用系统时间作为记录时间
self.position_history.append((timestamp, lat, lon))
self.raw_data_queue.task_done() # 标记任务完成
except queue.Empty:
# 队列为空是正常情况,继续循环
continue
except Exception as e:
print(f"数据处理错误: {e}")
def get_latest_fix(self):
"""获取最后一次有效的定位信息(线程安全)"""
# 这里简单返回解析器内部状态,实际应用中可能需要加锁
return self.parser.last_fix.copy()
def get_position_history(self):
"""获取历史轨迹点(线程安全)"""
# 返回副本以避免在迭代时被修改
return list(self.position_history)
def stop(self):
"""停止所有线程并关闭串口"""
self.is_running = False
if self.read_thread:
self.read_thread.join(timeout=2)
if self.process_thread:
self.process_thread.join(timeout=2)
if self.serial_conn and self.serial_conn.is_open:
self.serial_conn.close()
print("GPS处理器已停止。")
def run_demo(self, duration=30):
"""
运行一个简单的演示:打印接收到的有效定位信息。
:param duration: 演示运行秒数
"""
self.start()
print(f"开始 {duration} 秒演示,按Ctrl+C可提前终止。")
end_time = time.time() + duration
try:
while time.time() < end_time and self.is_running:
try:
# 从解析队列中获取数据
parsed_data = self.parsed_data_queue.get(timeout=0.5)
if parsed_data.get('type') == 'GGA' and parsed_data.get('fix_quality', 0) > 0:
lat = parsed_data.get('latitude')
lon = parsed_data.get('longitude')
alt = parsed_data.get('altitude')
sats = parsed_data.get('satellites')
print(f"[GGA] 位置: ({lat:.6f}, {lon:.6f}), 海拔: {alt:.1f}m, 卫星: {sats}")
elif parsed_data.get('type') == 'RMC' and parsed_data.get('status') == 'A':
speed = parsed_data.get('speed_kmh', 0)
course = parsed_data.get('course', 0)
print(f"[RMC] 速度: {speed:.2f} km/h, 航向: {course:.1f}°")
self.parsed_data_queue.task_done()
except queue.Empty:
# 没有新数据,继续循环
continue
except KeyboardInterrupt:
print("\n演示被用户中断。")
finally:
self.stop()
# 使用示例
if __name__ == '__main__':
# 请将 '/dev/ttyUSB0' 替换为你的实际端口
processor = RealTimeGPSProcessor(port='/dev/ttyUSB0', baudrate=9600)
processor.run_demo(duration=60) # 运行60秒演示
这个RealTimeGPSProcessor类实现了一个典型的生产者-消费者模式:
- 生产者线程 (
_read_from_serial):专心从串口读取数据,分割成完整的NMEA句子,放入原始数据队列。它不负责解析,因此即使解析逻辑复杂或耗时,也不会阻塞数据接收。 - 消费者线程 (
_process_raw_data):从原始数据队列取出句子,调用解析器进行解析,将结果放入解析数据队列,并更新历史轨迹。解析和业务逻辑在这里处理。 - 主线程:可以自由地从
parsed_data_queue获取解析后的数据,用于显示、存储或上传,而不用担心阻塞串口读取。
这种设计解耦了数据接收、解析和业务处理,使得程序结构清晰,响应迅速,并且能轻松扩展到更复杂的应用场景,比如同时进行数据可视化、文件记录和网络传输。
4. 从数据到洞察:可视化与实战应用
解析出经纬度只是第一步,让数据“活”起来才是最终目的。无论是绘制实时轨迹、在地图上标记位置,还是进行简单的数据分析,Python都有丰富的库可以帮我们实现。
4.1 使用Matplotlib绘制实时轨迹
对于桌面应用或简单的数据分析,matplotlib的动画功能可以轻松实现轨迹的实时绘制。
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib.patches import Circle
import numpy as np
class RealtimeTrajectoryPlotter:
"""
使用matplotlib实时绘制GPS轨迹。
适合在Jupyter Notebook或独立的GUI窗口中运行。
"""
def __init__(self, gps_processor, history_points=200):
"""
:param gps_processor: RealTimeGPSProcessor 实例
:param history_points: 图表上显示的历史点数
"""
self.processor = gps_processor
self.history_points = history_points
# 初始化图形
self.fig, self.ax = plt.subplots(figsize=(10, 8))
self.ax.set_title('实时GPS轨迹')
self.ax.set_xlabel('经度')
self.ax.set_ylabel('纬度')
self.ax.grid(True, alpha=0.3)
# 初始化轨迹线(空列表开始)
self.lon_data, self.lat_data = [], []
self.trajectory_line, = self.ax.plot([], [], 'b-', linewidth=1.5, alpha=0.7, label='轨迹')
self.current_point, = self.ax.plot([], [], 'ro', markersize=10, label='当前位置')
# 用于显示信息的文本对象
self.info_text = self.ax.text(0.02, 0.98, '', transform=self.ax.transAxes,
verticalalignment='top',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
self.ax.legend(loc='lower right')
def _update_plot(self, frame):
"""动画的更新函数,每帧调用一次"""
# 从处理器获取最新的位置和历史
latest = self.processor.get_latest_fix()
history = self.processor.get_position_history()
if latest.get('latitude') and latest.get('longitude'):
lat, lon = latest['latitude'], latest['longitude']
# 更新数据列表
self.lat_data.append(lat)
self.lon_data.append(lon)
# 只保留最近的历史点
if len(self.lat_data) > self.history_points:
self.lat_data.pop(0)
self.lon_data.pop(0)
# 更新轨迹线
self.trajectory_line.set_data(self.lon_data, self.lat_data)
# 更新当前点
self.current_point.set_data([lon], [lat])
# 更新信息文本
info_str = (f"纬度: {lat:.6f}°\n"
f"经度: {lon:.6f}°\n"
f"卫星数: {latest.get('satellites', 'N/A')}\n"
f"速度: {latest.get('speed', 0):.1f} km/h")
self.info_text.set_text(info_str)
# 自动调整坐标轴范围,以当前位置为中心,显示一定范围
if len(self.lon_data) > 1:
lon_margin = (max(self.lon_data) - min(self.lon_data)) * 0.1 or 0.001
lat_margin = (max(self.lat_data) - min(self.lat_data)) * 0.1 or 0.001
self.ax.set_xlim(min(self.lon_data) - lon_margin,
max(self.lon_data) + lon_margin)
self.ax.set_ylim(min(self.lat_data) - lat_margin,
max(self.lat_data) + lat_margin)
else:
# 如果只有一个点,设置一个小的固定范围
self.ax.set_xlim(lon - 0.001, lon + 0.001)
self.ax.set_ylim(lat - 0.001, lat + 0.001)
return self.trajectory_line, self.current_point, self.info_text
def start_animation(self, interval=500):
"""
启动实时动画。
:param interval: 更新间隔(毫秒)
"""
# 确保GPS处理器正在运行
if not self.processor.is_running:
self.processor.start()
# 创建动画
ani = animation.FuncAnimation(self.fig, self._update_plot,
interval=interval, blit=True,
cache_frame_data=False)
plt.tight_layout()
plt.show()
# 注意:plt.show()是阻塞的。关闭窗口后需要停止处理器。
self.processor.stop()
# 使用示例(通常在Jupyter notebook或独立脚本中运行)
# processor = RealTimeGPSProcessor(port='/dev/ttyUSB0')
# plotter = RealtimeTrajectoryPlotter(processor, history_points=500)
# plotter.start_animation(interval=1000) # 每秒更新一次
这个绘图类创建了一个动态更新的图表,蓝色线条是历史轨迹,红点是当前位置,左上角的信息框实时显示关键数据。FuncAnimation会定期调用_update_plot函数,从GPSProcessor获取最新数据并刷新图表。
4.2 集成Folium生成交互式HTML地图
如果你希望生成一个可以分享、能在浏览器中交互查看的轨迹地图,folium库(基于Leaflet.js)是绝佳选择。它生成的HTML文件包含了所有地理数据,无需后端服务器即可查看。
import folium
from folium.plugins import TimestampedGeoJson
import json
from datetime import datetime, timedelta
def create_interactive_trajectory_map(position_history, output_file='gps_trajectory.html'):
"""
根据历史位置数据,创建一个带有时间轴的交互式轨迹地图。
:param position_history: 列表,元素为(timestamp, lat, lon)元组
:param output_file: 输出的HTML文件名
:return: folium地图对象
"""
if not position_history:
print("历史数据为空,无法创建地图。")
return None
# 1. 计算轨迹的中心点,作为地图初始视图
lats = [p[1] for p in position_history if p[1] is not None]
lons = [p[2] for p in position_history if p[2] is not None]
if not lats or not lons:
print("没有有效的经纬度数据。")
return None
center_lat = sum(lats) / len(lats)
center_lon = sum(lons) / len(lons)
# 2. 创建基础地图
m = folium.Map(location=[center_lat, center_lon],
zoom_start=15,
tiles='OpenStreetMap') # 也可以使用 'CartoDB positron' 等
# 3. 准备Timeline插件所需的数据格式
features = []
for i, (ts, lat, lon) in enumerate(position_history):
if lat is None or lon is None:
continue
# 将时间戳转换为ISO格式字符串
# 这里假设timestamp是Unix时间戳(秒)。如果是datetime对象,直接格式化。
if isinstance(ts, (int, float)):
time_obj = datetime.fromtimestamp(ts)
else:
time_obj = ts # 假设已经是datetime对象
time_str = time_obj.isoformat() + 'Z' # ISO格式,Z表示UTC
# 为每个点创建一个GeoJSON特征
feature = {
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [lon, lat] # GeoJSON是[经度, 纬度]顺序
},
'properties': {
'time': time_str,
'popup': f'点 #{i+1}<br>时间: {time_obj.strftime("%Y-%m-%d %H:%M:%S")}<br>坐标: ({lat:.5f}, {lon:.5f})',
'icon': 'circle',
'iconstyle': {
'fillColor': '#3388ff' if i != len(position_history)-1 else '#ff0000', # 最后一个点用红色
'fillOpacity': 0.7,
'stroke': False,
'radius': 5 if i != len(position_history)-1 else 8
}
}
}
features.append(feature)
# 4. 添加轨迹线(作为一个单独的特征)
line_coordinates = [[p[2], p[1]] for p in position_history if p[1] is not None and p[2] is not None]
if len(line_coordinates) > 1:
line_feature = {
'type': 'Feature',
'geometry': {
'type': 'LineString',
'coordinates': line_coordinates
},
'properties': {
'times': [datetime.fromtimestamp(p[0]).isoformat() + 'Z' for p in position_history if p[1] is not None],
'style': {
'color': '#FF5500',
'weight': 4,
'opacity': 0.7
},
'popup': f'轨迹线,共{len(line_coordinates)}个点'
}
}
features.append(line_feature)
# 5. 创建完整的GeoJSON数据
geojson_data = {
'type': 'FeatureCollection',
'features': features
}
# 6. 添加时间轴插件
TimestampedGeoJson(
geojson_data,
period='PT1M', # 时间轴上每格代表1分钟。可调:PT10S, PT1H等
add_last_point=True, # 在时间轴末尾添加一个永久点
auto_play=False, # 不自动播放
loop=False, # 不循环
max_speed=10, # 最大播放速度
loop_button=True, # 显示循环按钮
date_options='YYYY/MM/DD HH:mm:ss', # 时间显示格式
time_slider_drag_update=True, # 拖动时更新
duration='PT1M' # 每个特征显示的时间长度
).add_to(m)
# 7. 添加一个全屏按钮,方便查看
folium.plugins.Fullscreen().add_to(m)
# 8. 添加一个测量工具
folium.plugins.MeasureControl(position='topright',
primary_length_unit='meters',
secondary_length_unit='kilometers').add_to(m)
# 9. 保存为HTML文件
m.save(output_file)
print(f"交互式地图已保存至: {output_file}")
print(f"用浏览器打开此文件,你可以:")
print(f" - 使用左下角的时间轴播放轨迹")
print(f" - 点击地图上的点查看详情")
print(f" - 使用右上角的工具测量距离")
print(f" - 全屏查看")
return m
# 使用示例:先收集数据,再生成地图
def collect_data_and_generate_map(port, collect_seconds=300):
"""收集指定时长的GPS数据,然后生成地图"""
processor = RealTimeGPSProcessor(port=port)
processor.start()
print(f"开始收集 {collect_seconds} 秒的GPS数据...")
time.sleep(collect_seconds)
print("数据收集完成,停止接收...")
processor.stop()
# 获取历史轨迹
history = processor.get_position_history()
print(f"共收集到 {len(history)} 个位置点。")
if len(history) > 10: # 至少有10个点才生成地图
map_obj = create_interactive_trajectory_map(history, 'my_gps_track.html')
return map_obj
else:
print("收集到的有效数据点不足,未生成地图。")
return None
# 运行(假设你的GPS模块在COM3或/dev/ttyUSB0)
# 注意:这个函数会阻塞collect_seconds指定的时间
# collect_data_and_generate_map('/dev/ttyUSB0', collect_seconds=120) # 收集2分钟数据
生成的HTML文件是一个完全自包含的交互式地图。你可以用浏览器打开它,拖动时间轴来“回放”你的移动轨迹,点击任意点查看详细信息,使用测量工具计算路径长度,甚至切换到卫星视图。这对于户外活动记录、车辆测试数据分析或教学演示来说,是一个非常直观的工具。
4.3 数据持久化:存储到文件或数据库
对于长期监测或数据分析项目,你需要将GPS数据保存下来。根据数据量和查询需求,可以选择不同的存储方案。
方案一:CSV文件(简单轻量) 适合快速记录、后期用Excel或Pandas分析。
import csv
from datetime import datetime
class CSVLogger:
"""将GPS数据记录到CSV文件"""
def __init__(self, filename='gps_log.csv'):
self.filename = filename
self._init_file()
def _init_file(self):
"""初始化CSV文件,写入表头"""
with open(self.filename, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow([
'timestamp', 'datetime', 'latitude', 'longitude',
'altitude', 'speed_kmh', 'course', 'satellites',
'fix_quality', 'hdop', 'sentence_type', 'raw_data'
])
def log_parsed_data(self, parsed_data):
"""将一条解析后的数据记录到CSV"""
if not parsed_data or 'type' not in parsed_data:
return
# 准备行数据
row = {
'timestamp': datetime.now().isoformat(),
'datetime': '',
'latitude': '',
'longitude': '',
'altitude': '',
'speed_kmh': '',
'course': '',
'satellites': '',
'fix_quality': '',
'hdop': '',
'sentence_type': parsed_data.get('type', ''),
'raw_data': parsed_data.get('raw', '')[:100] # 只存前100字符
}
# 根据语句类型填充数据
if parsed_data['type'] == 'GGA':
row.update({
'latitude': parsed_data.get('latitude'),
'longitude': parsed_data.get('longitude'),
'altitude': parsed_data.get('altitude'),
'satellites': parsed_data.get('satellites'),
'fix_quality': parsed_data.get('fix_quality'),
'hdop': parsed_data.get('hdop'),
})
elif parsed_data['type'] == 'RMC':
row.update({
'latitude': parsed_data.get('latitude'),
'longitude': parsed_data.get('longitude'),
'speed_kmh': parsed_data.get('speed_kmh'),
'course': parsed_data.get('course'),
'datetime': parsed_data.get('datetime'),
})
# 写入文件
with open(self.filename, 'a', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow([row[col] for col in [
'timestamp', 'datetime', 'latitude', 'longitude',
'altitude', 'speed_kmh', 'course', 'satellites',
'fix_quality', 'hdop', 'sentence_type', 'raw_data'
]])
方案二:SQLite数据库(结构化查询) 适合需要复杂查询、数据量较大的应用。
import sqlite3
from contextlib import contextmanager
class GPSDatabase:
"""使用SQLite存储GPS数据"""
def __init__(self, db_file='gps_data.db'):
self.db_file = db_file
self._init_database()
@contextmanager
def _get_connection(self):
"""获取数据库连接的上下文管理器"""
conn = sqlite3.connect(self.db_file)
conn.row_factory = sqlite3.Row # 使返回的行像字典一样访问
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _init_database(self):
"""初始化数据库表结构"""
with self._get_connection() as conn:
cursor = conn.cursor()
# 主表,存储所有解析后的有效定位点
cursor.execute('''
CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL, -- Unix时间戳
datetime TEXT, -- 可读的日期时间
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
speed_kmh REAL,
course REAL,
satellites INTEGER,
fix_quality INTEGER,
hdop REAL,
source_sentence TEXT, -- 原始语句类型,如GGA, RMC
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 创建索引以提高查询效率
cursor.execute('CREATE INDEX IF NOT EXISTS idx_timestamp ON positions(timestamp)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_location ON positions(latitude, longitude)')
# 原始数据表(用于调试或原始数据存档)
cursor.execute('''
CREATE TABLE IF NOT EXISTS raw_sentences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_text TEXT NOT NULL,
checksum_valid INTEGER DEFAULT 0,
parsed_successfully INTEGER DEFAULT 0,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
def insert_position(self, parsed_data):
"""插入一条解析后的位置数据"""
if not parsed_data or 'type' not in parsed_data:
return False
# 只插入包含经纬度的有效数据
lat = parsed_data.get('latitude')
lon = parsed_data.get('longitude')
if lat is None or lon is None:
return False
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO positions
(timestamp, datetime, latitude, longitude, altitude, speed_kmh,
course, satellites, fix_quality, hdop, source_sentence)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
parsed_data.get('timestamp') or time.time(),
str(parsed_data.get('datetime', '')),
lat,
lon,
parsed_data.get('altitude'),
parsed_data.get('speed_kmh'),
parsed_data.get('course'),
parsed_data.get('satellites'),
parsed_data.get('fix_quality'),
parsed_data.get('hdop'),
parsed_data.get('type', '')
))
return cursor.lastrowid
def insert_raw_sentence(self, raw_sentence, checksum_valid=False, parsed_ok=False):
"""插入一条原始NMEA语句(用于调试)"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO raw_sentences (raw_text, checksum_valid, parsed_successfully)
VALUES (?, ?, ?)
''', (raw_sentence[:500], 1 if checksum_valid else 0, 1 if parsed_ok else 0))
def get_positions_in_range(self, start_time, end_time, limit=1000):
"""查询指定时间范围内的位置数据"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM positions
WHERE timestamp BETWEEN ? AND ?
ORDER BY timestamp ASC
LIMIT ?
''', (start_time, end_time, limit))
return [dict(row) for row in cursor.fetchall()]
def get_track_summary(self):
"""获取轨迹统计摘要"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) as point_count, MIN(timestamp) as start_time, MAX(timestamp) as end_time FROM positions')
summary = dict(cursor.fetchone())
if summary['point_count'] > 0:
cursor.execute('SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat, MIN(longitude) as min_lon, MAX(longitude) as max_lon FROM positions')
bounds = dict(cursor.fetchone())
summary.update(bounds)
return summary
# 集成到实时处理器中
class GPSProcessorWithDB(RealTimeGPSProcessor):
"""增强版的实时处理器,增加数据库存储功能"""
def __init__(self, port, baudrate=9600, db_file='gps_data.db'):
super().__init__(port, baudrate)
self.db = GPSDatabase(db_file)
def _process_raw_data(self):
"""重写数据处理方法,增加数据库存储"""
while self.is_running:
try:
raw_line = self.raw_data_queue.get(timeout=1)
# 记录原始语句(可选,调试时开启)
# checksum_ok = validate_nmea_checksum(raw_line)
# self.db.insert_raw_sentence(raw_line, checksum_ok, False)
parsed = self.parser.parse(raw_line)
if parsed:
try:
self.parsed_data_queue.put_nowait(parsed)
except queue.Full:
pass
# 存储到数据库
if parsed.get('type') in ['GGA', 'RMC'] and parsed.get('latitude') is not None:
row_id = self.db.insert_position(parsed)
# 可以在这里记录成功插入的日志
# 更新历史记录(父类方法)
if parsed.get('type') in ['GGA', 'RMC']:
lat = parsed.get('latitude')
lon = parsed.get('longitude')
if lat is not None and lon is not None:
timestamp = time.time()
self.position_history.append((timestamp, lat, lon))
self.raw_data_queue.task_done()
except queue.Empty:
continue
except Exception as e:
print(f"数据处理错误: {e}")
将数据库功能集成后,你的GPS数据采集系统就具备了持久化能力。你可以让它在树莓派上连续运行数天甚至数周,记录车辆轨迹、人员活动路径等。之后,可以用SQL查询特定时间段的数据,用Pandas进行统计分析(比如计算平均速度、总里程),或者导出为GPX格式供其他专业软件使用。
4.4 实战案例:构建一个简单的车辆轨迹记录仪
让我们把上面所有的模块组合起来,构建一个功能相对完整的“车辆轨迹记录仪”原型。这个程序会:
- 持续从GPS模块读取数据。
- 解析并验证数据。
- 将有效数据存储到SQLite数据库。
- 每隔一段时间(例如每10秒)将最新位置显示在终端。
- 在程序退出时,自动生成本次行程的轨迹地图。
import time
import signal
import sys
class VehicleTracker:
"""
一个简单的车辆轨迹记录仪。
组合了实时处理、数据存储和基本状态显示。
"""
def __init__(self, port, db_name='trip.db', map_output='trip_map.html'):
self.processor = GPSProcessorWithDB(port=port, db_file=db_name)
self.db = self.processor.db # 数据库接口
self.map_output = map_output
self.is_tracking = False
# 用于优雅退出的信号处理
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
def signal_handler(self, signum, frame):
"""处理Ctrl+C等中断信号"""
print(f"\n接收到信号 {signum},正在停止追踪...")
self.stop_tracking()
sys.exit(0)
def start_tracking(self):
"""开始追踪"""
print("=" * 50)
print("车辆轨迹记录仪启动")
print(f"数据库文件: {self.processor.db.db_file}")
print(f"地图将保存至: {self.map_output}")
print("按 Ctrl+C 停止追踪并生成报告")
print("=" * 50)
self.is_tracking = True
self.processor.start()
last_display_time = 0
display_interval = 10 # 每10秒显示一次状态
try:
while self.is_tracking:
current_time = time.time()
# 定期显示状态
if current_time - last_display_time > display_interval:
self._display_status()
last_display_time = current_time
# 这里可以添加其他逻辑,比如检查速度超限等
# 简单起见,我们只睡眠一小段时间
time.sleep(0.1)
except KeyboardInterrupt:
print("\n用户请求停止。")
self.stop_tracking()
def _display_status(self):
"""在终端显示当前状态摘要"""
latest = self.processor.get_latest_fix()
history = self.processor.get_position_history()
print(f"\n[{time.strftime('%H:%M:%S')}] 状态更新:")
if latest.get('latitude'):
print(f" 当前位置: {latest['latitude']:.6f}, {latest['longitude']:.6f}")
print(f" 海拔: {latest.get('altitude', 'N/A'):.1f}m | 卫星: {latest.get('satellites', 'N/A')}")
print(f" 速度: {latest.get('speed', 0):.1f} km/h | 航向: {latest.get('course', 0):.1f}°")
else:
print(" 等待有效GPS定位...")
print(f" 已记录轨迹点: {len(history)}")
# 从数据库获取一些统计信息
summary = self.db.get_track_summary()
if summary['point_count'] > 0:
duration = summary['end_time'] - summary['start_time']
hours = duration / 3600
print(f" 记录时长: {hours:.2f} 小时")
print(f" 经度范围: [{summary.get('min_lon', 0):.4f}, {summary.get('max_lon', 0):.4f}]")
print(f" 纬度范围: [{summary.get('min_lat', 0):.4f}, {summary.get('max_lat', 0):.4f}]")
def stop_tracking(self):
"""停止追踪并生成报告"""
if not self.is_tracking:
return
print("\n" + "=" * 50)
print("停止追踪,正在生成报告...")
self.is_tracking = False
self.processor.stop()
# 等待一小段时间确保最后的数据被处理
time.sleep(1)
# 获取所有历史点
history = self.processor.get_position_history()
if len(history) > 10:
print(f"共记录 {len(history)} 个轨迹点,正在生成交互式地图...")
map_obj = create_interactive_trajectory_map(history, self.map_output)
if map_obj:
print(f"地图已生成: {self.map_output}")
else:
print("记录的点数太少,未生成地图。")
# 打印数据库摘要
summary = self.db.get_track_summary()
print(f"\n数据库摘要:")
print(f" 总记录数: {summary.get('point_count', 0)}")
if summary.get('start_time'):
start_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(summary['start_time']))
end_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(summary['end_time']))
print(f" 时间范围: {start_str} 到 {end_str}")
print("=" * 50)
print("轨迹记录仪已关闭。")
# 主程序入口
if __name__ == '__main__':
# 配置你的串口
SERIAL_PORT = '/dev/ttyUSB0' # Linux/macOS
# SERIAL_PORT = 'COM3' # Windows
# 创建追踪器实例
tracker = VehicleTracker(
port=SERIAL_PORT,
db_name=f'trip_{time.strftime("%Y%m%d_%H%M%S")}.db',
map_output=f'trip_{time.strftime("%Y%m%d_%H%M%S")}.html'
)
# 开始追踪
tracker.start_tracking()
这个VehicleTracker类提供了一个完整的应用框架。运行它,连接好GPS模块,你就可以带着笔记本或树莓派上车,开始记录行程。程序会在后台安静地工作,每隔10秒在终端打印一次状态。当你到达目的地,按下Ctrl+C,它会自动停止数据采集,生成一个包含所有轨迹点的SQLite数据库文件,以及一个可以直接在浏览器中打开的、带有时间轴播放功能的交互式地图。
通过这个项目,你不仅学会了如何解析NMEA协议,更掌握了一套从硬件交互、数据处理到可视化呈现的完整方法论。这套方法可以轻松迁移到其他串口传感器(如气象站、惯性测量单元IMU)的数据处理上。
更多推荐


所有评论(0)