一、小程序功能设计
1、支持多种格式数据文件
(1)功能背景:
①在实际工作中,可能会产生各种格式的数据文件,需要基于它们绘制图像,然而大多数图像绘制工具只支持部分格式的数据文件,并不能识别所有类别的数据文件,这就导致工程师需要下载多个图像绘制工具,并且部分图像绘制工具使用麻烦,功能不够丰富,各类图像绘制工具的操作方式可能也有不小的区别,这就导致工程师增加了学习使用各类工具的成本。
②为了解决以上问题,可以自行开发小程序实现多格式数据绘图功能,小程序能够解析各种格式的数据文件,并且操作统一,如果仍不满足需求,可以基于小程序源码做二次开发,继续增加数据文件的格式。
(2)功能定义:
①小程序支持解析CSV格式、MACP格式、MAT格式、PARQUT格式和ULG格式的数据文件,用户在导入文件前,需要选择导入数据文件的格式。

②选择导入的文件类型后,点击“打开文件”,即可选择数据文件导入,以下均以CSV格式的数据文件为例进行演示。

2、时间轴与信号列表配置
(1)时间轴配置;
①用户可以选择时间轴来源为“自动”,小程序默认以数据文件中的第一个信号列为时间信号。

②用户可以选择时间轴来源为“指定信号列”,并配置哪一列信号为时间信号,小程序以用户指定的信号列为时间信号。

③用户可以选择时间轴来源为“固定时间间隔”,并配置时间间隔,小程序把用户指定的时间间隔视为两组相邻数据的时间间隔进行绘图。

(2)信号配置:
①数据文件中的原始信号会解析在文件信号区域,用户需要使用某一原始信号绘图时,勾选它即可(可多选)。

②数据文件中只包含原始信号,并不包含信号与信号之间的运算结果,而在部分数据分析场景中,用户往往需要一同显示信号与信号之间的运算结果,因此小程序同时支持用户引用原始信号构造自定义信号。
[1]用户在下图所示的输入框中输入信号运算表达式和自定义信号名,然后点击“添加自定义信号”,即可构造一个自定义信号。如果不确定如何引用原始信号,可在引用信号的下拉框中找到需要使用的信号,然后点击“插入”,信号名就会追加在表达式输入框中。暂不支持引用自定义信号构建自定义信号,如用户需要,可根据小程序源码进行二次开发)

[2]自定义信号同样允许用户配置是否用于绘图,点击“绘制”栏变更属性即可。

[3]双击自定义信号,或者选中自定义信号然后点击“编辑选中”,即可对自定义信号进行编辑。

[4]选中自定义信号然后点击“移除选中”,即可删除自定义信号。

(3)CSV文件导出:
①考虑到部分用户可能有保存自定义信号的需求,或者是文件格式转换的需求,小程序支持数据文件保存功能,用户将需要保存的信号(原始信号以及自定义信号)勾选,然后点击“保存CSV”即可(时间轴由小程序自动生成,可以不重复勾选)。

②如果有保存为其它格式数据文件的需求,目前小程序暂不支持,如用户需要,可根据小程序源码进行二次开发。
3、数据文件绘图
(1)配置好时间轴与信号列表后,点击“绘制选中信号”,即可生成图像。

(2)图像显示窗口的操作比较简单,此处不详细赘述,可以根据小程序的提示尝试操作,小程序支持显示区域拖动、x轴y轴等比缩放、x轴缩放、y轴缩放、分辨率数值调节、视图复位、图像导出等功能。

二、小程序开发
1、开发环境
(1)安装numpy库、pandas库、pyqt5库、pyqtgraph库、mcap库、mcap-ros1-support库、mcap-ros2-support库、scipy库、pyulog库、pyarrow库,可在终端使用pip工具安装,也可使用pyCharm安装,总之能够成功安装即可。
(2)安装pyinstaller库,该库主要用于将.py打包成可执行文件.exe,使其运行能够脱离Python环境。
2、程序源码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""多格式数据绘图工具: CSV / MCAP(ROS1/2) / MAT / PARQUET / ULG
功能:
- 解析 CSV / MCAP / MAT / PARQUET / ULG 数据文件, 提取数值信号
- 时间轴可选: 自动(第一信号列) / 指定信号列 / 固定时间间隔
- 自定义信号: 用户命名 + 数学表达式, 可编辑/移除, 与文件信号分组区分
- 绘图: 白底, 曲线显示/隐藏, 分辨率调节, 滚轮缩放, 导出 PNG
- 导出 CSV: 勾选的信号列 + 自定义信号
"""
import os
import re
import sys
import time
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
try:
import numpy as np
except ImportError: # pragma: no cover
np = None
try:
import pandas as pd
except ImportError: # pragma: no cover
pd = None
try:
import pyqtgraph as pg
from pyqtgraph.Qt import QtWidgets, QtCore
from pyqtgraph.exporters import ImageExporter
except ImportError: # pragma: no cover
pg = None
QtWidgets = QtCore = ImageExporter = None
try:
from mcap.reader import make_reader
except ImportError: # pragma: no cover
make_reader = None
try:
from mcap_ros2.decoder import DecoderFactory as Ros2DecoderFactory
except ImportError: # pragma: no cover
Ros2DecoderFactory = None
try:
from mcap_ros1.decoder import DecoderFactory as Ros1DecoderFactory
except ImportError: # pragma: no cover
Ros1DecoderFactory = None
try:
from scipy.io import loadmat
except ImportError: # pragma: no cover
loadmat = None
try:
from pyulog import ULog
except ImportError: # pragma: no cover
ULog = None
# 曲线配色
COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#00aaaa", "#aa00aa", "#17becf", "#bcbd22", "#7f7f7f", "#e377c2"]
FILE_TYPES = [("csv", "CSV"), ("mcap", "MCAP"), ("mat", "MAT"), ("parquet", "PARQUET"), ("ulg", "ULG")]
TIME_MODES = {"auto": "自动(第一信号列)", "signal": "指定信号列", "fixed": "固定时间间隔"}
def setup_dpi_awareness():
"""统一进程 DPI 感知(System DPI aware), 避免 Tk/Qt 混用时窗口尺寸跳变"""
if sys.platform != "win32":
return
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1) # 1 = System DPI aware
except Exception:
try:
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
def get_dpi_scale() -> float:
"""返回当前 DPI 缩放比例(1.0 = 100%)"""
if sys.platform != "win32":
return 1.0
try:
import ctypes
dpi = ctypes.windll.user32.GetDpiForSystem()
return dpi / 96.0
except Exception:
return 1.0
def ensure_deps() -> bool:
"""检查必需依赖, 缺失时给出安装提示"""
missing = []
if np is None:
missing.append("numpy")
if pg is None:
missing.append("pyqtgraph + PyQt5")
if missing:
return False
return True
def extract_numeric_fields(obj, prefix: str, out_dict: dict):
"""递归提取消息所有数值字段, 生成完整路径 key: topic/a/b
兼容 __dict__ 与 __slots__ 两类消息对象(ROS2 消息类使用 __slots__)。
"""
if isinstance(obj, (str, bytes, bytearray)):
return
if isinstance(obj, (int, float, np.number)):
out_dict[prefix] = float(obj)
return
if isinstance(obj, np.ndarray):
if obj.ndim == 0:
out_dict[prefix] = float(obj)
else:
for idx, v in enumerate(obj.ravel()):
out_dict[f"{prefix}[{idx}]"] = float(v)
return
if isinstance(obj, dict):
for k, v in obj.items():
extract_numeric_fields(v, f"{prefix}/{k}", out_dict)
return
if isinstance(obj, (list, tuple)):
for idx, v in enumerate(obj):
extract_numeric_fields(v, f"{prefix}[{idx}]", out_dict)
return
if hasattr(obj, "__dict__"):
# 注意: 部分动态类(如 mcap_ros2 解码结果)存在 __dict__ 但为空,
# 字段实际存储在 __slots__ 中, 因此这里不 return, 继续检查 __slots__
for k, v in obj.__dict__.items():
extract_numeric_fields(v, f"{prefix}/{k}", out_dict)
slots = getattr(type(obj), "__slots__", None)
if slots:
for s in slots:
if s.startswith("_"):
continue
try:
v = getattr(obj, s)
except AttributeError:
continue
extract_numeric_fields(v, f"{prefix}/{s}", out_dict)
def preprocess_expr(expr: str, name_map: dict) -> str:
"""把表达式中的信号名替换为合法标识符(sig0/sig1...)
信号名可能含 / [ ] 等非法字符, 直接 eval 会语法错误。
按名字长度降序替换, 避免子串误替换。
"""
result = expr
for name in sorted(name_map, key=len, reverse=True):
pat = r"(?<![A-Za-z0-9_])" + re.escape(name) + r"(?![A-Za-z0-9_])"
result = re.sub(pat, name_map[name], result)
return result
class McapCsvPlotApp:
def __init__(self, root):
self.root = root
self.root.title("多格式数据绘图工具 (CSV/MCAP/MAT/PARQUET/ULG)")
scale = get_dpi_scale()
if scale > 1.0:
# DPI-aware 模式下按缩放比例放大字体与窗口, 保持视觉尺寸不变
root.tk.call("tk", "scaling", 96.0 * scale / 72.0)
self.root.geometry(f"{int(780 * scale)}x{int(760 * scale)}")
self.file_path = None
self.input_filetype = tk.StringVar(value="csv") # csv/mcap/mat/parquet/ulg
self.mcap_ros_ver = tk.StringVar(value="ros2") # ros1/ros2
self.file_signals = {} # {sig: 元信息} 扫描结果, 不存数据(懒加载)
self.file_meta = {} # {"format": ..., "path": ..., 各格式附加信息}
self.signal_names = [] # 文件信号名(保序)
self.custom_signals = [] # [{"name": str, "expr": str}]
self.sig_vars = {} # 文件信号勾选 {name: BooleanVar}
self.custom_vars = {} # 自定义信号勾选 {name: bool}
# 时间轴
self.time_mode = tk.StringVar(value="auto")
self.time_signal_var = tk.StringVar()
self.time_dt = tk.DoubleVar(value=0.01)
self._build_ui()
self.on_filetype_change()
# ================= UI 构建 =================
def _build_ui(self):
# ---------- 文件设置 ----------
top_frame = ttk.LabelFrame(self.root, text="文件设置")
top_frame.pack(fill=tk.X, padx=8, pady=4)
ft_frame = ttk.Frame(top_frame)
ft_frame.pack(fill=tk.X, padx=6, pady=4)
ttk.Label(ft_frame, text="导入类型:").pack(side=tk.LEFT)
for key, label in FILE_TYPES:
ttk.Radiobutton(ft_frame, text=label, variable=self.input_filetype, value=key, command=self.on_filetype_change).pack(side=tk.LEFT, padx=6)
# MCAP ROS 版本, 默认隐藏
self.rosver_frame = ttk.Frame(top_frame)
ttk.Label(self.rosver_frame, text="MCAP-ROS版本:").pack(side=tk.LEFT)
ttk.Radiobutton(self.rosver_frame, text="ROS2", variable=self.mcap_ros_ver, value="ros2").pack(side=tk.LEFT, padx=8)
ttk.Radiobutton(self.rosver_frame, text="ROS1", variable=self.mcap_ros_ver, value="ros1").pack(side=tk.LEFT)
file_btn_frame = ttk.Frame(top_frame)
file_btn_frame.pack(fill=tk.X, padx=6, pady=4)
ttk.Button(file_btn_frame, text="打开文件", command=self.load_file).pack(side=tk.LEFT)
self.file_label = ttk.Label(file_btn_frame, text="未选择文件")
self.file_label.pack(side=tk.LEFT, padx=10)
# ---------- 时间轴设置 ----------
time_frame = ttk.LabelFrame(self.root, text="时间轴设置")
time_frame.pack(fill=tk.X, padx=8, pady=4)
ttk.Label(time_frame, text="时间轴来源:").pack(side=tk.LEFT, padx=6, pady=4)
self.time_mode_combo = ttk.Combobox(time_frame, state="readonly", width=14, values=list(TIME_MODES.values()))
self.time_mode_combo.current(0)
self.time_mode_combo.bind("<<ComboboxSelected>>", self.on_time_mode_change)
self.time_mode_combo.pack(side=tk.LEFT, padx=4)
self.time_signal_combo = ttk.Combobox(time_frame, textvariable=self.time_signal_var, width=18, state="disabled")
self.time_signal_combo.pack(side=tk.LEFT, padx=4)
ttk.Label(time_frame, text="固定间隔(s):").pack(side=tk.LEFT, padx=6)
self.time_dt_entry = ttk.Entry(time_frame, textvariable=self.time_dt, width=8, state="disabled")
self.time_dt_entry.pack(side=tk.LEFT)
ttk.Label(time_frame, text="(第一列/首信号为默认时间轴)").pack(side=tk.LEFT, padx=8)
# ---------- 自定义信号 ----------
math_frame = ttk.LabelFrame(self.root, text="自定义信号 (表达式支持 sig1 - sig2、np.sin(x) 等, 信号名可直接引用)")
math_frame.pack(fill=tk.X, padx=8, pady=4)
row1 = ttk.Frame(math_frame)
row1.pack(fill=tk.X, padx=6, pady=3)
ttk.Label(row1, text="名称:").pack(side=tk.LEFT)
self.custom_name_var = tk.StringVar()
ttk.Entry(row1, textvariable=self.custom_name_var, width=16).pack(side=tk.LEFT, padx=4)
ttk.Label(row1, text="表达式:").pack(side=tk.LEFT)
self.custom_expr_var = tk.StringVar()
self.custom_expr_entry = ttk.Entry(row1, textvariable=self.custom_expr_var)
self.custom_expr_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
# 引用信号: 从下拉选择后点"插入", 完整信号名写入表达式光标处(含 / 与 [i] 均可直接引用)
ttk.Label(row1, text="引用信号:").pack(side=tk.LEFT, padx=(8, 0))
self.custom_ref_combo = ttk.Combobox(row1, state="readonly", width=16)
self.custom_ref_combo.pack(side=tk.LEFT, padx=4)
ttk.Button(row1, text="插入", command=self.on_insert_signal_ref).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(row1, text="添加自定义信号", command=self.add_custom).pack(side=tk.LEFT, padx=4)
# ---------- 文件信号列表 ----------
self.list_frame = ttk.LabelFrame(self.root, text="文件信号 (勾选绘制)")
self.list_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
self.scroll_widget = tk.Frame(self.list_frame)
self.canvas = tk.Canvas(self.scroll_widget, highlightthickness=0)
scrollbar = ttk.Scrollbar(self.scroll_widget, orient="vertical", command=self.canvas.yview)
self.check_container = ttk.Frame(self.canvas)
self.check_list_container = ttk.Frame(self.check_container)
self.check_list_container.pack(fill=tk.BOTH, expand=True)
self.check_container.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
self._list_canvas_item = self.canvas.create_window((0, 0), window=self.check_container, anchor="nw")
self.canvas.bind("<Configure>", self._sync_list_geometry)
self.canvas.bind("<Map>", self._sync_list_geometry)
self.canvas.configure(yscrollcommand=scrollbar.set)
self.scroll_widget.pack(fill=tk.BOTH, expand=True)
self.canvas.pack(side="left", fill=tk.BOTH, expand=True)
scrollbar.pack(side="right", fill=tk.Y)
# ---------- 自定义信号列表 ----------
custom_frame = ttk.LabelFrame(self.root, text="自定义信号列表 (与文件信号区分, 双击编辑)")
custom_frame.pack(fill=tk.X, padx=8, pady=4)
self.custom_tree = ttk.Treeview(custom_frame, columns=("check", "name", "expr"), show="headings", height=5)
self.custom_tree.heading("check", text="绘制")
self.custom_tree.heading("name", text="信号名")
self.custom_tree.heading("expr", text="表达式")
self.custom_tree.column("check", width=50, anchor="center", stretch=False)
self.custom_tree.column("name", width=160, anchor="w", stretch=False)
self.custom_tree.column("expr", width=380, anchor="w")
self.custom_tree.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6, pady=4)
self.custom_tree.bind("<Button-1>", self.on_custom_tree_click)
self.custom_tree.bind("<Double-1>", self.on_custom_tree_double)
btn_col = ttk.Frame(custom_frame)
btn_col.pack(side=tk.LEFT, padx=6)
ttk.Button(btn_col, text="编辑选中", command=self.edit_selected_custom).pack(pady=2, fill=tk.X)
ttk.Button(btn_col, text="移除选中", command=self.remove_selected_custom).pack(pady=2, fill=tk.X)
# ---------- 底部按钮 ----------
btn_frame = ttk.Frame(self.root)
btn_frame.pack(pady=6)
ttk.Button(btn_frame, text="绘制选中信号", command=self.plot_selected).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="保存CSV", command=self.save_csv).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="清空全部", command=self.clear_all).pack(side=tk.LEFT, padx=5)
# ================= 文件加载与解析 =================
def on_filetype_change(self):
# MCAP 解析自动注册 ROS1/ROS2 双解码器识别, 不再需要版本单选
pass
def on_time_mode_change(self, event=None):
# combo 选择同步到 self.time_mode
idx = self.time_mode_combo.current()
keys = list(TIME_MODES.keys())
if 0 <= idx < len(keys):
self.time_mode.set(keys[idx])
mode = self.time_mode.get()
if mode == "signal":
self.time_signal_combo.config(state="readonly")
self.time_dt_entry.config(state="disabled")
elif mode == "fixed":
self.time_signal_combo.config(state="disabled")
self.time_dt_entry.config(state="normal")
else:
self.time_signal_combo.config(state="disabled")
self.time_dt_entry.config(state="disabled")
def _filetypes(self):
ftype = self.input_filetype.get()
return {
"csv": [("CSV文件", "*.csv")],
"mcap": [("MCAP文件", "*.mcap")],
"mat": [("MATLAB文件", "*.mat")],
"parquet": [("Parquet文件", "*.parquet;*.parq")],
"ulg": [("ULog文件", "*.ulg")],
}[ftype]
def load_file(self):
ftype = self.input_filetype.get()
fp = filedialog.askopenfilename(filetypes=self._filetypes())
if not fp:
return
self.file_path = fp
self.file_label.config(text=os.path.basename(fp))
self.clear_signal_list_only()
try:
self.parse_file(fp, ftype)
except Exception as e:
messagebox.showerror("解析失败", f"文件可能已损坏或格式不受支持:\n{type(e).__name__}: {e}")
self.clear_signal_list_only()
return
if not self.file_signals:
messagebox.showerror("解析失败", "文件中未提取到任何数值信号, 文件可能已损坏")
self.clear_signal_list_only()
return
self.signal_names = list(self.file_signals.keys())
self.fill_signal_checkbox()
self.refresh_time_combo()
self.refresh_custom_list()
messagebox.showinfo("成功", f"解析成功, 共 {len(self.signal_names)} 个信号"f"\n耗时 {getattr(self, '_parse_cost', 0):.1f} 秒")
def parse_file(self, fp, ftype):
self.file_signals = {}
self.file_meta = {"format": ftype, "path": fp}
t0 = time.time()
if ftype == "csv":
self.parse_csv(fp)
elif ftype == "mcap":
self.parse_mcap(fp)
elif ftype == "mat":
self.parse_mat(fp)
elif ftype == "parquet":
self.parse_parquet(fp)
elif ftype == "ulg":
self.parse_ulg(fp)
else:
raise ValueError(f"不支持的文件类型: {ftype}")
self._parse_cost = time.time() - t0
def parse_csv(self, filepath):
if pd is None:
raise RuntimeError("缺少 pandas 依赖, 请执行: pip install pandas")
# 扫描模式: 只读表头 + 抽样判断数值列, 数据在绘图时按需加载
try:
head = pd.read_csv(filepath, nrows=0)
except Exception as e:
raise ValueError(f"CSV 解析失败: {e}") from e
if head.shape[1] < 1:
raise ValueError("CSV 文件为空或没有数据列")
cols = [str(c) for c in head.columns]
out = {}
try:
sample = pd.read_csv(filepath, nrows=5000)
except Exception:
sample = None
for name in cols:
if sample is not None and name in sample.columns:
v = pd.to_numeric(sample[name], errors="coerce").to_numpy()
if np.isnan(v).all():
continue # 抽样全部非数值, 排除
out[name] = {}
if not out:
raise ValueError("CSV 中没有数值列")
self.file_signals = out
def parse_mcap(self, filepath):
if not (make_reader and Ros2DecoderFactory and Ros1DecoderFactory):
raise RuntimeError("缺少 mcap 解析依赖, 请执行: pip install mcap mcap-ros2-support mcap-ros1-support")
# 同时注册 ROS1/ROS2 解码器: 文件可能混录两种版本, 单版本会让另一版本整 topic 丢失
factories = [Ros2DecoderFactory(), Ros1DecoderFactory()]
fname = os.path.basename(filepath)
msg_count = 0
failed = 0
fs = {} # 本轮新扫描结果, 末尾整体覆盖 self.file_signals
topic_counts = {} # {topic: 消息数}
topic_fields = {} # {topic: [信号全名...]}
topic_of = {} # {信号全名: topic}
# 每 topic 解码前若干条消息并合并字段集, 而非只解码首条: 动态数组
# (sequence) 首条可能为空/元素少, bool 等类型也只有解码实际消息才能
# 发现; 字段集连续 STALE_STOP 条无新增即认为稳定, MAX_DECODE 兜底
# 超大数组场景, MAX_FAIL 防损坏消息反复解码
state = {} # {topic: {"dec", "fields", "stale", "fails", "n"}}
STALE_STOP, MAX_DECODE, MAX_FAIL = 3, 200, 20
try:
with open(filepath, "rb") as f:
reader = make_reader(f, decoder_factories=factories)
for schema, channel, message in reader.iter_messages():
topic = channel.topic
topic_counts[topic] = topic_counts.get(topic, 0) + 1
st = state.get(topic)
if st is None:
st = state[topic] = {"dec": None, "fields": set(),
"stale": 0, "fails": 0, "n": 0}
msg_count += 1
# 字段集已稳定 / 连续失败过多 / 解码达上限: 只计数不再解码
if st["stale"] >= STALE_STOP or st["fails"] >= MAX_FAIL \
or st["n"] >= MAX_DECODE:
continue
if st["dec"] is None:
dec = None
for fac in factories:
dec = fac.decoder_for(channel.message_encoding, schema)
if dec is not None:
break
if dec is None:
st["fails"] += 1
failed += 1
continue
st["dec"] = dec
try:
ros_msg = st["dec"](message.data)
except Exception:
# 单条消息解码失败不判死: 继续尝试后续消息
st["fails"] += 1
failed += 1
continue
st["fails"] = 0
st["n"] += 1
field_dict = {}
extract_numeric_fields(ros_msg, topic, field_dict)
before = len(st["fields"])
st["fields"].update(field_dict)
st["stale"] = 0 if len(st["fields"]) > before else st["stale"] + 1
if msg_count % 200000 == 0:
self.file_label.config(text=f"扫描中... 已处理 {msg_count} 条消息")
self.root.update_idletasks()
finally:
self.file_label.config(text=fname)
for topic, st in state.items():
for sig in sorted(st["fields"]):
fs[sig] = {"count": topic_counts.get(topic, 0)}
topic_of[sig] = topic
topic_fields[topic] = sorted(st["fields"])
if not fs:
if failed:
raise ValueError("MCAP 文件中的消息均无法解码, 请检查文件是否为标准 ROS1/ROS2 录制")
raise ValueError("MCAP 文件中没有可解码的数值消息")
self.file_signals = fs # 整体覆盖(旧信号不残留, 与其它格式解析一致)
self.file_meta["mcap"] = {"topic_counts": topic_counts,
"topic_fields": topic_fields,
"topic_of": topic_of}
def parse_mat(self, filepath):
if loadmat is None:
raise RuntimeError("缺少 scipy 依赖, 请执行: pip install scipy")
try:
data = loadmat(filepath, squeeze_me=True, struct_as_record=False)
except Exception as e:
raise ValueError(f"MAT 解析失败: {e}") from e
out = {}
def add_signal(name, arr):
a = np.asarray(arr)
if not np.issubdtype(a.dtype, np.number):
return
a = np.atleast_1d(a)
if a.ndim == 1:
out[name] = {"count": int(a.size)}
elif a.ndim == 2:
for i in range(a.shape[1]):
out[f"{name}[{i}]"] = {"count": int(a.shape[0])}
else:
out[name] = {"count": int(a.size)}
def walk(obj, prefix):
if hasattr(obj, "_fieldnames"): # mat_struct 嵌套结构
for field in obj._fieldnames:
walk(getattr(obj, field), f"{prefix}/{field}" if prefix else field)
return
if isinstance(obj, dict):
for k, v in obj.items():
if str(k).startswith("__"):
continue
walk(v, f"{prefix}/{k}" if prefix else str(k))
return
a = np.asarray(obj)
if a.dtype == object: # cell 数组, 逐元素递归
for idx, v in enumerate(a.ravel()):
walk(v, f"{prefix}[{idx}]")
return
add_signal(prefix, a)
walk(data, "")
if not out:
raise ValueError("MAT 文件中没有数值变量")
self.file_signals = out
# loadmat 无法流式读取, 已全量载入内存; 保留引用供按需加载切片, 避免二次解析
self.file_meta["mat"] = {"data": data}
def parse_parquet(self, filepath):
try:
import pyarrow as pa
import pyarrow.parquet as pq
except ImportError as e:
raise RuntimeError("缺少 pyarrow 依赖, 请执行: pip install pyarrow") from e
try:
pf = pq.ParquetFile(filepath)
except Exception as e:
raise ValueError(f"Parquet 解析失败: {e}") from e
meta = pf.metadata
if meta is None or meta.num_rows < 1 or not pf.schema_arrow.names:
raise ValueError("Parquet 文件为空或没有数据列")
out = {}
for name, dtype in zip(pf.schema_arrow.names, pf.schema_arrow.types):
if (pa.types.is_string(dtype) or pa.types.is_large_string(dtype)
or pa.types.is_binary(dtype) or pa.types.is_large_binary(dtype)):
continue # 字符串/二进制列非数值, 排除
out[name] = {"count": meta.num_rows}
if not out:
raise ValueError("Parquet 中没有数值列")
self.file_signals = out
self.file_meta["parquet"] = {"num_rows": meta.num_rows}
def parse_ulg(self, filepath):
if ULog is None:
raise RuntimeError("缺少 pyulog 依赖, 请执行: pip install pyulog")
try:
ulog = ULog(filepath)
except Exception as e:
raise ValueError(f"ULog 解析失败: {e}") from e
out = {}
topics = {}
for d in ulog.data_list:
topic = d.name
if "timestamp" not in d.data:
continue
t_us = np.asarray(d.data["timestamp"], dtype=float)
if t_us.size == 0:
continue
topics[topic] = int(t_us.size)
for field, arr in d.data.items():
if field == "timestamp":
continue
v = np.asarray(arr, dtype=float)
if v.ndim == 1:
out[f"{topic}/{field}"] = {"count": int(v.size)}
elif v.ndim >= 2: # 数组字段, 每列一个信号
for i in range(v.shape[1]):
out[f"{topic}/{field}[{i}]"] = {"count": int(v.shape[0])}
if not out:
raise ValueError("ULog 文件中没有可解析的信号")
self.file_signals = out
self.file_meta["ulg"] = {"topics": topics}
# ================= 懒加载: 按需读取数据 =================
def _load_signals(self, needed):
"""按需加载信号数据(懒加载核心): 只读取 needed 中的信号
返回 {name: {"t": np.ndarray, "val": np.ndarray}}; needed 为空返回 {}
"""
if not needed:
return {}
ftype = self.file_meta.get("format")
loader = {
"csv": self._load_csv,
"mcap": self._load_mcap,
"mat": self._load_mat,
"parquet": self._load_parquet,
"ulg": self._load_ulg,
}.get(ftype)
if loader is None:
raise ValueError(f"不支持的文件类型: {ftype}")
self.file_label.config(text="加载数据中...")
self.root.update_idletasks()
try:
return loader(needed)
finally:
self.file_label.config(text=os.path.basename(self.file_path)
if self.file_path else "未选择文件")
def _load_csv(self, needed):
if pd is None:
raise RuntimeError("缺少 pandas 依赖, 请执行: pip install pandas")
try:
df = pd.read_csv(self.file_meta["path"], usecols=lambda c: c in needed)
except Exception as e:
raise ValueError(f"CSV 数据加载失败: {e}") from e
out = {}
for name in needed:
if name not in df.columns:
continue
v = pd.to_numeric(df[name], errors="coerce").to_numpy(dtype=float)
if np.isnan(v).all():
continue
out[name] = {"t": np.arange(v.size, dtype=float), "val": v}
if not out:
raise ValueError("CSV 中选中的信号没有数值数据")
return out
def _load_mcap(self, needed):
if not (make_reader and Ros2DecoderFactory and Ros1DecoderFactory):
raise RuntimeError("缺少 mcap 解析依赖, 请执行: pip install mcap mcap-ros2-support mcap-ros1-support")
factories = [Ros2DecoderFactory(), Ros1DecoderFactory()]
topic_of = self.file_meta.get("mcap", {}).get("topic_of", {})
topics = sorted({topic_of.get(sig, sig) for sig in needed})
fs = {sig: {"t": [], "val": []} for sig in needed}
msg_count = 0
with open(self.file_meta["path"], "rb") as f:
reader = make_reader(f, decoder_factories=factories)
for schema, channel, message, ros_msg in reader.iter_decoded_messages(topics=topics):
t_sec = message.log_time * 1e-9
field_dict = {}
try:
extract_numeric_fields(ros_msg, channel.topic, field_dict)
except Exception:
continue # 单条消息字段提取失败, 跳过
for sig_full, val in field_dict.items():
d = fs.get(sig_full)
if d is not None:
d["t"].append(t_sec)
d["val"].append(val)
msg_count += 1
if msg_count % 20000 == 0:
self.file_label.config(text=f"加载中... 已处理 {msg_count} 条消息")
self.root.update_idletasks()
out = {}
for sig, d in fs.items():
if d["t"]:
out[sig] = {"t": np.asarray(d["t"], dtype=float),
"val": np.asarray(d["val"], dtype=float)}
if not out:
raise ValueError("MCAP 中选中的信号没有数据")
return out
def _load_mat(self, needed):
if loadmat is None:
raise RuntimeError("缺少 scipy 依赖, 请执行: pip install scipy")
data = self.file_meta.get("mat", {}).get("data")
if data is None:
data = loadmat(self.file_meta["path"], squeeze_me=True, struct_as_record=False)
needed_set = set(needed)
out = {}
def add_signal(name, arr):
a = np.asarray(arr)
if not np.issubdtype(a.dtype, np.number):
return
a = np.atleast_1d(a)
if a.ndim == 1:
if name in needed_set:
out[name] = {"t": np.arange(a.size, dtype=float), "val": a.astype(float)}
elif a.ndim == 2:
for i in range(a.shape[1]):
col = a[:, i]
sig = f"{name}[{i}]"
if sig in needed_set:
out[sig] = {"t": np.arange(col.size, dtype=float),
"val": col.astype(float)}
else:
if name in needed_set:
flat = a.ravel()
out[name] = {"t": np.arange(flat.size, dtype=float), "val": flat.astype(float)}
def walk(obj, prefix):
if hasattr(obj, "_fieldnames"): # mat_struct 嵌套结构
for field in obj._fieldnames:
walk(getattr(obj, field), f"{prefix}/{field}" if prefix else field)
return
if isinstance(obj, dict):
for k, v in obj.items():
if str(k).startswith("__"):
continue
walk(v, f"{prefix}/{k}" if prefix else str(k))
return
a = np.asarray(obj)
if a.dtype == object: # cell 数组, 逐元素递归
for idx, v in enumerate(a.ravel()):
walk(v, f"{prefix}[{idx}]")
return
add_signal(prefix, a)
walk(data, "")
if not out:
raise ValueError("MAT 中选中的信号没有数据")
return out
def _load_parquet(self, needed):
if pd is None:
raise RuntimeError("缺少 pandas 依赖, 请执行: pip install pandas")
try:
df = pd.read_parquet(self.file_meta["path"], columns=list(needed))
except ImportError as e:
raise RuntimeError("缺少 pyarrow 依赖, 请执行: pip install pyarrow") from e
except Exception as e:
raise ValueError(f"Parquet 数据加载失败: {e}") from e
out = {}
for name in needed:
if name not in df.columns:
continue
v = pd.to_numeric(df[name], errors="coerce").to_numpy(dtype=float)
if np.isnan(v).all():
continue
out[name] = {"t": np.arange(v.size, dtype=float), "val": v}
if not out:
raise ValueError("Parquet 中选中的信号没有数值数据")
return out
def _load_ulg(self, needed):
if ULog is None:
raise RuntimeError("缺少 pyulog 依赖, 请执行: pip install pyulog")
# 从信号名推导所属 topic(最长前缀匹配), 只加载需要的 topic
known = list(self.file_meta.get("ulg", {}).get("topics", {}).keys())
topics = set()
for sig in needed:
t = next((k for k in sorted(known, key=len, reverse=True)
if sig == k or sig.startswith(k + "/")), None)
topics.add(t or sig.split("/", 1)[0])
try:
ulog = ULog(self.file_meta["path"], message_name_filter_list=sorted(topics))
except Exception as e:
raise ValueError(f"ULog 数据加载失败: {e}") from e
needed_set = set(needed)
out = {}
for d in ulog.data_list:
if "timestamp" not in d.data:
continue
t_us = np.asarray(d.data["timestamp"], dtype=float)
if t_us.size == 0:
continue
t_s = t_us / 1e6
for field, arr in d.data.items():
if field == "timestamp":
continue
v = np.asarray(arr, dtype=float)
if v.ndim == 1:
sig = f"{d.name}/{field}"
if sig in needed_set:
out[sig] = {"t": t_s, "val": v}
elif v.ndim >= 2:
for i in range(v.shape[1]):
sig = f"{d.name}/{field}[{i}]"
if sig in needed_set:
out[sig] = {"t": t_s, "val": v[:, i]}
if not out:
raise ValueError("ULG 中选中的信号没有数据")
return out
# ================= 信号列表 UI =================
def _sync_list_geometry(self, _event=None):
"""画布可视区变化时同步内嵌内容区尺寸(层级树撑满可视区且防几何自激)
Treeview 列宽/行高配置会改变其请求尺寸, 若内容区尺寸跟随请求尺寸
(画布内嵌区未固定), 会形成 "改列宽 -> 请求尺寸变 -> 实际尺寸变 ->
<Configure> -> 再改列宽" 的无限放大循环。层级树模式下把内嵌内容区
固定在画布可视区尺寸, Treeview 由 fill=BOTH 拉伸, 实际尺寸不再随
列宽联动, fit 改列宽即收敛; csv 复选框模式保持内容自适应(超高滚动)。
"""
if not self.canvas.winfo_ismapped():
return
cw, ch = self.canvas.winfo_width(), self.canvas.winfo_height()
if cw < 40 or ch < 40:
return
if (self.file_meta.get("format") == "mcap"
and getattr(self, "hier_tv", None) is not None):
self.canvas.itemconfigure(self._list_canvas_item, width=cw, height=ch)
# 内嵌区尺寸确定后立即适配列宽: after_idle 排队可能在窗口映射前
# 执行而早退(ismapped=False), 此后无 Configure 再触发, 列宽将停在
# 默认值(展开列 200px 过宽产生"勾选前空白")。此处必已映射, 直接调。
self._hier_fit_columns()
def fill_signal_checkbox(self):
# 内嵌内容区尺寸复位为按内容请求(csv 模式超高时画布滚动; 层级树模式
# 由 _sync_list_geometry 在可视区变化时重新撑满)
self.canvas.itemconfigure(self._list_canvas_item, width=0, height=0)
# 销毁列表区控件(含 check_list_container 内部), 但保留容器本体供重建
for w in self.check_container.winfo_children():
if w is not self.check_list_container:
w.destroy()
for w in self.check_list_container.winfo_children():
w.destroy()
self.sig_vars.clear()
self.list_frame.config(text=f"文件信号 (勾选绘制) — 共 {len(self.signal_names)} 个")
# MCAP 层级信号: 用可展开的树形勾选界面呈现
if (self.file_meta.get("format") == "mcap"
and self.file_meta.get("mcap", {}).get("topic_fields")):
self._show_hierarchy_ui()
return
# 操作行: 信号多于 20 个时显示搜索框, 始终提供全选/全不选
self.search_var = tk.StringVar()
op_frame = ttk.Frame(self.check_container)
op_frame.pack(fill=tk.X, padx=4, pady=2)
if len(self.signal_names) > 20:
ttk.Label(op_frame, text="搜索:").pack(side=tk.LEFT)
ent = ttk.Entry(op_frame, textvariable=self.search_var)
ent.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
ent.bind("<KeyRelease>", self.on_search_key)
ttk.Button(op_frame, text="清除", command=self.clear_search).pack(side=tk.LEFT, padx=2)
ttk.Button(op_frame, text="全选", command=lambda: self.set_all_signal_checked(True)).pack(side=tk.LEFT, padx=2)
ttk.Button(op_frame, text="全不选", command=lambda: self.set_all_signal_checked(False)).pack(side=tk.LEFT, padx=2)
self.rebuild_signal_checkboxes()
def set_all_signal_checked(self, checked):
for name in self.signal_names:
var = self.sig_vars.get(name)
if var is None:
var = tk.BooleanVar(value=False)
self.sig_vars[name] = var
var.set(checked)
self.rebuild_signal_checkboxes()
def rebuild_signal_checkboxes(self):
"""按搜索关键字重建信号勾选框(保留已勾选状态)"""
for w in self.check_list_container.winfo_children():
w.destroy()
kw = (self.search_var.get().strip().lower()
if getattr(self, "search_var", None) else "")
for name in self.signal_names:
if kw and kw not in name.lower():
continue
var = self.sig_vars.get(name)
if var is None:
var = tk.BooleanVar(value=False)
self.sig_vars[name] = var
cb = ttk.Checkbutton(self.check_list_container, text=name, variable=var)
cb.pack(anchor="w")
def on_search_key(self, event=None):
self.rebuild_signal_checkboxes()
def clear_search(self):
if getattr(self, "search_var", None):
self.search_var.set("")
self.rebuild_signal_checkboxes()
@staticmethod
def _is_checked(v):
"""兼容 checkbox 模式(BooleanVar)与 MCAP 层级模式(bool)"""
if isinstance(v, tk.BooleanVar):
return bool(v.get())
return bool(v)
# ================= MCAP 层级信号 UI =================
# 层级底色: 第一层黄/第二层绿/第三层蓝/第四层紫, 更深层级循环取色
_HIER_LV_COLORS = ("#FFF3C4", "#E3F2D6", "#D9E9F6", "#F0E2F5", "#F0F0F0")
def _show_hierarchy_ui(self):
"""MCAP 信号按 topic/字段层级, 用可展开的树形勾选界面呈现
行首箭头点击展开/收起子级; 勾选列点击切换信号(父节点级联其下全部子级);
每层用不同底色区分; 信号多于 20 个时提供关键字过滤; 行列随窗口自适应。
"""
self.hier_tree = self._build_hierarchy_tree()
self.hier_leaf_iids = set() # 信号 iid(含混合节点自身), sig_vars 以完整信号名为键
self._hier_pid_count = 0 # 纯父节点唯一 iid 计数器(避免与信号名路径冲突)
self.hier_disp_need = 0 # name 列所需最小宽度(最长显示名的像素宽)
self.hier_max_depth = 0 # 树最大层级(用于收紧展开箭头列宽度)
# 行高: 主题默认 20px 固定, 高 DPI 下字体放大后 y/g/p 等下行字母会被裁切,
# 按当前字体行高动态放大 Treeview 行高
from tkinter import font as tkfont
_style = ttk.Style(self.root)
_style.layout("Hier.Treeview", _style.layout("Treeview"))
_metric = tkfont.Font(root=self.root, font="TkDefaultFont").metrics("linespace")
self.hier_rowh = max(24, _metric + 6)
self.hier_font = tkfont.Font(root=self.root, font="TkDefaultFont")
_style.configure("Hier.Treeview", rowheight=self.hier_rowh)
op_row = ttk.Frame(self.check_list_container)
op_row.pack(fill=tk.X, padx=4, pady=(2, 0))
ttk.Label(op_row, text="分级选择: 点击箭头或行文字展开/收起, 点击勾选列选择信号").pack(side=tk.LEFT)
ttk.Button(op_row, text="全选", command=lambda: self._hier_set_all(True)).pack(side=tk.LEFT, padx=8)
ttk.Button(op_row, text="全不选", command=lambda: self._hier_set_all(False)).pack(side=tk.LEFT)
# 信号多于 20 个时提供关键字过滤(按完整信号路径模糊匹配)
self.hier_search_var = tk.StringVar()
if len(self.signal_names) > 20:
ttk.Label(op_row, text="搜索:").pack(side=tk.LEFT, padx=(10, 0))
_ent = ttk.Entry(op_row, textvariable=self.hier_search_var, width=18)
_ent.pack(side=tk.LEFT, padx=2)
_ent.bind("<KeyRelease>", self._hier_on_search_key)
ttk.Button(op_row, text="清除", command=self._hier_clear_search).pack(side=tk.LEFT, padx=2)
ttk.Label(self.check_list_container, text="提示: 勾选父节点自动勾选其下全部信号; 点击行文字展开/收起子级; 自定义表达式可直接引用完整信号名(如 /imu/data/x)", foreground="#888").pack(anchor="w", padx=4)
self.hier_tv = ttk.Treeview(self.check_list_container, columns=("check", "name"), show="tree headings", height=12, style="Hier.Treeview")
# 层级底色 tag: 行背景随树深度逐层区分
for i, color in enumerate(self._HIER_LV_COLORS, 1):
self.hier_tv.tag_configure(f"hier_lv{i}", background=color)
self.hier_tv.heading("check", text="勾选")
self.hier_tv.heading("name", text="信号层级")
self.hier_tv.column("check", width=56, anchor="center", stretch=False)
self.hier_tv.column("name", width=520, anchor="w", stretch=False)
# 纵向/横向滚动条: 叶子信号名过长时可左右拖动查看完整路径
tv_sb_y = ttk.Scrollbar(self.check_list_container, orient="vertical", command=self.hier_tv.yview)
tv_sb_x = ttk.Scrollbar(self.check_list_container, orient="horizontal", command=self.hier_tv.xview)
self.hier_tv.configure(yscrollcommand=tv_sb_y.set, xscrollcommand=tv_sb_x.set)
tv_sb_x.pack(side=tk.BOTTOM, fill=tk.X, padx=4, pady=(0, 2))
tv_sb_y.pack(side=tk.RIGHT, fill=tk.Y, pady=2)
self.hier_tv.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=4, pady=2)
# 列宽适配统一由 _sync_list_geometry(画布 Configure 触发, item 固定后)驱动,
# 不再绑 tv <Configure>: 它可能在 item 固定前的时序窗口内触发, 引发列宽
# 与请求尺寸互相放大的几何自激
self._hier_populate("", self.hier_tree, "")
self.hier_tv.bind("<Button-1>", self._hier_on_click)
# 默认展开第一层(topic 级)
for iid in self.hier_tv.get_children(""):
self.hier_tv.item(iid, open=True)
# 初次布局完成后: 内嵌区撑满画布可视区 + 按可视区适配列宽/展开列
self.root.after_idle(self._sync_list_geometry)
self.root.after_idle(self._hier_fit_columns)
def _hier_populate(self, parent_iid, node, path, depth=1, kw=""):
"""递归插入层级节点; 叶子/混合节点 iid = 信号全名, 纯父节点 iid = 唯一计数
topic 名与字段路径可能产生相同文本(如 topic /ctrl/pid 与 /ctrl 的字段 pid),
纯父节点用唯一 iid 避免与信号名 iid 冲突, 显示名仍为完整路径。
行背景带 hier_lvN 层级底色 tag; kw 非空时仅保留路径含 kw 的信号及其祖先链。
"""
tag = f"hier_lv{min(depth, len(self._HIER_LV_COLORS))}"
if depth > self.hier_max_depth:
self.hier_max_depth = depth
for key in sorted(k for k in node if k != "__leaf__"):
child = node[key]
path_iid = f"{path}/{key}" if path else key
if "__leaf__" in child:
# 叶子或混合节点: 本身是信号; 自身或子树命中关键字才显示
if kw and kw not in path_iid and not self._hier_sub_has(kw, child, path_iid):
continue
self.hier_leaf_iids.add(path_iid)
mark = "√" if self.sig_vars.get(path_iid) else "○"
self.hier_tv.insert(parent_iid, "end", iid=path_iid, values=(mark, path_iid), tags=(tag,))
self._hier_track_width(path_iid, depth)
if kw:
self.hier_tv.item(path_iid, open=True)
if any(k != "__leaf__" for k in child):
self._hier_populate(path_iid, child, path_iid, depth + 1, kw)
else:
# 纯父节点: 子树命中关键字才显示
if kw and not self._hier_sub_has(kw, child, path_iid):
continue
self._hier_pid_count += 1
p_iid = f"_p{self._hier_pid_count}"
self.hier_tv.insert(parent_iid, "end", iid=p_iid, values=("", path_iid), tags=(tag,))
self._hier_track_width(path_iid, depth)
if kw:
self.hier_tv.item(p_iid, open=True)
self._hier_populate(p_iid, child, path_iid, depth + 1, kw)
def _hier_track_width(self, name, depth):
"""记录 name 列所需最小宽度 = 最长显示名的像素宽(含逐层缩进余量)"""
w = self.hier_font.measure(name) + depth * 22 + 36
if w > self.hier_disp_need:
self.hier_disp_need = w
def _hier_sub_has(self, kw, node, path):
"""node 子树中是否存在显示路径含 kw 的叶子信号(含 node 自身)"""
if "__leaf__" in node and kw in path:
return True
for key in sorted(k for k in node if k != "__leaf__"):
child = node[key]
p2 = f"{path}/{key}" if path else key
if self._hier_sub_has(kw, child, p2):
return True
return False
def _hier_on_search_key(self, _event=None):
"""搜索框输入: 按关键字重建树, 仅保留匹配路径"""
self._hier_refill(self.hier_search_var.get().strip())
def _hier_clear_search(self):
self.hier_search_var.set("")
self._hier_refill("")
def _hier_refill(self, kw=""):
"""按关键字重建层级树; kw 为空恢复完整树(勾选状态保存在 sig_vars, 不受影响)"""
for iid in self.hier_tv.get_children(""):
self.hier_tv.delete(iid)
self.hier_leaf_iids.clear()
self._hier_pid_count = 0
self.hier_disp_need = 0
self.hier_max_depth = 0
self._hier_populate("", self.hier_tree, "", kw=kw)
if not kw:
for iid in self.hier_tv.get_children(""):
self.hier_tv.item(iid, open=True)
# 布局适配延迟到事件循环空闲时执行: 树重建后同步 update_idletasks 可能
# 触发 Tcl idle 死循环, 不能在此强制刷新; 窗口未显示时 fit 会自动跳过
self.root.after_idle(self._hier_fit_columns)
def _hier_fit_columns(self, _event=None):
"""列宽随窗口自适应: #0 展开箭头列按树深度收紧, name 列铺满可视区
name 列宽 = max(最长显示名, 可视宽 - 勾选列 - 展开列), 窗口拉宽时铺满
可视区(构图饱满); 信号名超出可视宽时出现横向滚动条, 可左右拖动浏览
完整路径。Treeview 实际宽高由 _sync_list_geometry 固定在画布可视区
(fill=BOTH 拉伸), 改列宽不影响实际尺寸, 因此收敛无死循环; 不再动态
设置行数(height), 行按可视高度自动填充。
"""
tv_w = self.hier_tv.winfo_width()
if tv_w < 80 or not self.hier_tv.winfo_ismapped():
# 树刚重建尚未布局/映射(如再次导入): 此后无 Configure/Map 驱动 fit,
# 定时重试(上限数次)直到布局完成; 窗口未显示时不轮询, 由 Map 驱动
if self.canvas.winfo_ismapped() \
and getattr(self, "_hier_fit_tries", 0) < 8:
self._hier_fit_tries = getattr(self, "_hier_fit_tries", 0) + 1
self.root.after(20, self._hier_fit_columns)
return
self._hier_fit_tries = 0
# 树重布局后实际宽可能滞后于 item 固定(首次映射瞬间读到旧尺寸), 刷新
# 一次几何取真实可视宽; item 已固定, 实际尺寸不再随列宽联动, 无自激
self.root.update_idletasks()
tv_w = self.hier_tv.winfo_width()
if tv_w < 80:
return
indent_w = min(200, max(40, 36 + self.hier_max_depth * 20))
if int(self.hier_tv.column("#0", "width")) != indent_w:
self.hier_tv.column("#0", width=indent_w, minwidth=40, stretch=False)
check_w = int(self.hier_tv.column("check", "width"))
self.hier_tv.column("name", width=max(self.hier_disp_need, tv_w - check_w - indent_w - 14), stretch=False)
def _hier_on_click(self, event):
"""勾选列(#1)点击切换勾选; 信号名列(#2)点击有子级行展开/收起"""
if self.hier_tv.identify_region(event.x, event.y) != "cell":
return
iid = self.hier_tv.identify_row(event.y)
if not iid:
return
col = self.hier_tv.identify_column(event.x)
if col == "#1":
self._hier_toggle(iid)
elif col == "#2" and self.hier_tv.get_children(iid):
# 深层子级的展开箭头小而隐蔽, 点击整行文字即可切换
self.hier_tv.item(iid, open=not bool(self.hier_tv.item(iid, "open")))
def _hier_toggle(self, iid):
if iid in self.hier_leaf_iids:
# 叶子/混合节点: 切换自身状态
self.sig_vars[iid] = not self.sig_vars.get(iid, False)
self._hier_set_mark(iid)
parent = self.hier_tv.parent(iid)
if parent:
self._hier_refresh_check(parent)
else:
# 纯父节点: 全部子级勾选状态取反(全勾 -> 全不勾, 否则全勾)
marks = [self.hier_tv.item(c, "values")[0] for c in self.hier_tv.get_children(iid)]
checked = not all(m == "√" for m in marks)
self._hier_set_subtree(iid, checked)
def _hier_set_subtree(self, iid, checked):
for c in self.hier_tv.get_children(iid):
if c in self.hier_leaf_iids:
self.sig_vars[c] = checked
self._hier_set_mark(c)
else:
self._hier_set_subtree(c, checked)
self._hier_refresh_check(iid)
def _hier_set_all(self, checked):
for sig in self.signal_names:
self.sig_vars[sig] = checked
self._hier_refresh_all_marks()
def _hier_refresh_all_marks(self):
def rec(iid):
for c in self.hier_tv.get_children(iid):
if c in self.hier_leaf_iids:
self._hier_set_mark(c)
rec(c)
self._hier_refresh_check(iid)
for iid in self.hier_tv.get_children(""):
rec(iid)
def _hier_set_mark(self, iid):
"""设置叶子/混合节点行的勾选显示(√/○)"""
mark = "√" if self.sig_vars.get(iid) else "○"
vals = list(self.hier_tv.item(iid, "values"))
vals[0] = mark
self.hier_tv.item(iid, values=tuple(vals))
def _hier_refresh_check(self, iid):
"""刷新节点勾选显示; 纯父节点按子级聚合(√ 全勾 / - 部分 / ○ 无), 并级联父链"""
if not self.hier_tv.exists(iid):
return
children = self.hier_tv.get_children(iid)
if children and iid not in self.hier_leaf_iids:
marks = [self.hier_tv.item(c, "values")[0] for c in children]
if all(m == "√" for m in marks):
mark = "√"
elif any(m in ("√", "-") for m in marks):
mark = "-"
else:
mark = "○"
vals = list(self.hier_tv.item(iid, "values"))
vals[0] = mark
self.hier_tv.item(iid, values=tuple(vals))
parent = self.hier_tv.parent(iid)
if parent:
self._hier_refresh_check(parent)
def _build_hierarchy_tree(self):
"""从信号全名构建层级树 {topic: {字段: {...}}, "__leaf__": True}
第一级按完整 topic 划分(ROS topic 本身可含 '/'),
之后按 '/' 划分字段层级; 信号同时是另一信号前缀时(如 a/b 与 a/b/c),
节点同时含 __leaf__ 与子级。
"""
topic_of = self.file_meta.get("mcap", {}).get("topic_of", {})
tree = {}
for sig in self.signal_names:
topic = topic_of.get(sig)
if topic:
rest = sig[len(topic):].lstrip("/")
else:
# 兜底(无映射): 第一段作为 topic(保留前导 /), 其余按 '/' 分层
stripped = sig.lstrip("/")
first, _, rest = stripped.partition("/")
topic = "/" + first if sig.startswith("/") else first
node = tree.setdefault(topic, {})
if rest:
for part in rest.split("/"):
node = node.setdefault(part, {})
node["__leaf__"] = True
return tree
def refresh_time_combo(self):
self.time_signal_combo["values"] = self.signal_names
self.custom_ref_combo["values"] = self.signal_names
if self.time_signal_var.get() not in self.signal_names:
self.time_signal_var.set("")
self.on_time_mode_change()
def on_insert_signal_ref(self):
"""把引用信号下拉中选择的信号全名插入表达式光标处"""
name = self.custom_ref_combo.get()
if not name:
messagebox.showinfo("提示", "请先在\"引用信号\"下拉框中选择信号")
return
try:
self.custom_expr_entry.insert(tk.INSERT, name)
except tk.TclError:
self.custom_expr_var.set(self.custom_expr_var.get() + name)
self.custom_ref_combo.set("")
def refresh_custom_list(self):
self.custom_tree.delete(*self.custom_tree.get_children())
for idx, cs in enumerate(self.custom_signals):
checked = self.custom_vars.get(cs["name"], True)
self.custom_vars[cs["name"]] = checked
self.custom_tree.insert("", "end", iid=str(idx), values=("√" if checked else "○", cs["name"], cs["expr"]))
def on_custom_tree_click(self, event):
region = self.custom_tree.identify_region(event.x, event.y)
col = self.custom_tree.identify_column(event.x)
row_id = self.custom_tree.identify_row(event.y)
if region == "cell" and col == "#1" and row_id:
idx = int(row_id)
cs = self.custom_signals[idx]
self.custom_vars[cs["name"]] = not self.custom_vars[cs["name"]]
self.refresh_custom_list()
def on_custom_tree_double(self, event):
row_id = self.custom_tree.identify_row(event.y)
if row_id:
self.edit_selected_custom(row_id)
def edit_selected_custom(self, row_id=None):
row_id = row_id or self.custom_tree.selection()[0] if self.custom_tree.selection() else None
if row_id is None:
messagebox.showwarning("提示", "请先在列表中选择要编辑的自定义信号")
return
idx = int(row_id)
cs = self.custom_signals[idx]
dlg = tk.Toplevel(self.root)
dlg.title("编辑自定义信号")
dlg.transient(self.root)
dlg.grab_set()
dlg.geometry("520x140")
name_var = tk.StringVar(value=cs["name"])
expr_var = tk.StringVar(value=cs["expr"])
ttk.Label(dlg, text="信号名:").grid(row=0, column=0, padx=8, pady=8, sticky="w")
ttk.Entry(dlg, textvariable=name_var, width=20).grid(row=0, column=1, padx=8, pady=8, sticky="we")
ttk.Label(dlg, text="表达式:").grid(row=1, column=0, padx=8, pady=8, sticky="w")
ttk.Entry(dlg, textvariable=expr_var).grid(row=1, column=1, padx=8, pady=8, sticky="we")
dlg.columnconfigure(1, weight=1)
def on_ok():
name = name_var.get().strip()
expr = expr_var.get().strip()
if not name or not expr:
messagebox.showwarning("提示", "名称和表达式不能为空", parent=dlg)
return
if (name in self.signal_names or any(c["name"] == name for c in self.custom_signals if c is not cs)):
messagebox.showerror("错误", f"信号名 {name} 已存在", parent=dlg)
return
try:
self.try_eval_custom(expr)
except Exception as e:
messagebox.showerror("表达式错误", str(e), parent=dlg)
return
cs["name"] = name
cs["expr"] = expr
self.refresh_custom_list()
dlg.destroy()
ttk.Button(dlg, text="确定", command=on_ok).grid(row=2, column=0, pady=10)
ttk.Button(dlg, text="取消", command=dlg.destroy).grid(row=2, column=1, pady=10, sticky="w")
def remove_selected_custom(self):
sel = self.custom_tree.selection()
if not sel:
messagebox.showwarning("提示", "请先在列表中选择要移除的自定义信号")
return
idx = int(sel[0])
cs = self.custom_signals[idx]
if messagebox.askyesno("确认", f"确定移除自定义信号 {cs['name']} ?"):
self.custom_signals.pop(idx)
self.custom_vars.pop(cs["name"], None)
self.refresh_custom_list()
# ================= 数据对齐与表达式 =================
def build_aligned_data(self, needed=None):
"""生成统一时间网格, 所有文件信号插值对齐
needed: 需要的信号名列表(懒加载按需读取); None 表示全部信号
返回 (t_axis, {name: aligned_val_array}); 无信号时返回 (None, {})
"""
if needed is None:
needed = list(self.signal_names)
needed = [n for n in needed if n in self.file_signals]
if not needed:
return None, {}
loaded = self._load_signals(needed)
sorted_sigs = {}
for name in needed:
d = loaded.get(name)
if d is None:
continue
order = np.argsort(d["t"])
sorted_sigs[name] = (d["t"][order], d["val"][order])
if not sorted_sigs:
raise ValueError("选中的信号没有可用的数据")
mode = self.time_mode.get()
if mode == "signal":
sname = self.time_signal_var.get()
if not sname or sname not in sorted_sigs:
raise ValueError("请选择作为时间轴的信号列")
t_axis = sorted_sigs[sname][0]
elif mode == "fixed":
dt = self.time_dt.get()
if dt <= 0:
raise ValueError("固定时间间隔必须大于 0")
first_t = sorted_sigs[needed[0]][0]
n_max = max(len(d["t"]) for d in loaded.values())
t_axis = first_t[0] + np.arange(n_max, dtype=float) * dt
else: # auto
t_axis = sorted_sigs[needed[0]][0]
if t_axis.size == 0:
raise ValueError("时间轴为空")
aligned = {}
for name, (t_old, v_old) in sorted_sigs.items():
aligned[name] = np.interp(t_axis, t_old, v_old)
return t_axis, aligned
def _expr_deps(self, expr):
"""提取表达式引用的文件信号名集合(按名字长度降序匹配, 与 preprocess_expr 规则一致)"""
deps = set()
for name in sorted(self.signal_names, key=len, reverse=True):
pat = r"(?<![A-Za-z0-9_])" + re.escape(name) + r"(?![A-Za-z0-9_])"
if re.search(pat, expr):
deps.add(name)
return deps
def try_eval_custom(self, expr, env=None, t_len=None):
"""对自定义信号表达式求值并校验长度, 失败抛异常"""
if env is None:
# 校验模式: 只加载表达式依赖的信号 + 时间轴信号, 避免全量读取
needed = set(self._expr_deps(expr))
mode = self.time_mode.get()
if mode == "signal":
sname = self.time_signal_var.get()
if sname:
needed.add(sname)
elif self.signal_names:
needed.add(self.signal_names[0])
if not needed:
needed = set(self.signal_names)
needed = [n for n in self.signal_names if n in needed]
t_axis, aligned = self.build_aligned_data(needed)
if t_axis is None:
raise ValueError("请先加载数据文件")
env = {n: aligned[n] for n in aligned}
t_len = t_axis.size
name_map = {}
safe_env = {}
for i, n in enumerate(env):
safe = f"sig{i}"
name_map[n] = safe
safe_env[safe] = env[n]
code = preprocess_expr(expr, name_map)
try:
v = np.asarray(eval(code, {"np": np, "__builtins__": {}}, safe_env), dtype=float)
except Exception as e:
raise ValueError(f"表达式求值失败: {e}") from e
if v.ndim != 1 or v.size != t_len:
raise ValueError(f"表达式结果长度 {v.size if v.ndim else 1} 与时间轴长度 {t_len} 不一致")
return v
# ================= 核心操作 =================
def add_custom(self):
name = self.custom_name_var.get().strip()
expr = self.custom_expr_var.get().strip()
if not name:
messagebox.showwarning("提示", "请输入自定义信号名称")
return
if not expr:
messagebox.showwarning("提示", "请输入表达式, 如 sig1 - sig2")
return
if name in self.signal_names or any(c["name"] == name for c in self.custom_signals):
messagebox.showerror("错误", f"信号名 {name} 已存在")
return
try:
self.try_eval_custom(expr)
except Exception as e:
messagebox.showerror("表达式错误", str(e))
return
self.custom_signals.append({"name": name, "expr": expr})
self.custom_vars[name] = True
self.custom_name_var.set("")
self.custom_expr_var.set("")
self.refresh_custom_list()
def get_plot_data(self):
"""按勾选状态收集绘图/导出数据 {name: (t, val)}"""
selected_file = [n for n, v in self.sig_vars.items() if self._is_checked(v)]
selected_custom = [c for c in self.custom_signals
if self.custom_vars.get(c["name"], True)]
if not selected_file and not selected_custom:
raise ValueError("请先勾选要处理的信号")
# 懒加载: 只读取勾选信号 + 自定义信号表达式的依赖 + 时间轴信号
needed = set(selected_file)
for c in selected_custom:
needed |= self._expr_deps(c["expr"])
mode = self.time_mode.get()
if mode == "signal":
sname = self.time_signal_var.get()
if sname:
needed.add(sname)
elif self.signal_names:
needed.add(self.signal_names[0])
needed = [n for n in self.signal_names if n in needed]
t_axis, aligned = self.build_aligned_data(needed)
if t_axis is None:
raise ValueError("请先加载数据文件")
env = {n: aligned[n] for n in aligned}
data = {}
for n in selected_file:
if n in aligned:
data[n] = (t_axis, aligned[n])
for c in selected_custom:
data[c["name"]] = (t_axis, self.try_eval_custom(c["expr"], env, t_axis.size))
return data
def plot_selected(self):
if not ensure_deps():
messagebox.showerror("缺少依赖", "请先安装: pip install numpy pyqtgraph PyQt5")
return
try:
plot_data = self.get_plot_data()
except Exception as e:
messagebox.showerror("错误", str(e))
return
# 白底绘图
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
app = get_qapp()
win = PlotWindow(plot_data, title=os.path.basename(self.file_path or "信号绘图"))
try:
win.show()
app.exec()
except Exception as e:
messagebox.showerror("绘图异常", str(e))
def save_csv(self):
if pd is None:
messagebox.showerror("缺少依赖", "请先安装: pip install pandas")
return
try:
plot_data = self.get_plot_data()
except Exception as e:
messagebox.showerror("错误", str(e))
return
t_axis = next(iter(plot_data.values()))[0]
time_col = "t"
if time_col in plot_data:
time_col = "time"
df = pd.DataFrame({time_col: t_axis})
for name, (t, v) in plot_data.items():
df[name] = v
default_name = (os.path.splitext(os.path.basename(self.file_path))[0]
if self.file_path else "export") + "_export.csv"
fp = filedialog.asksaveasfilename(defaultextension=".csv", initialfile=default_name, filetypes=[("CSV文件", "*.csv")])
if not fp:
return
try:
df.to_csv(fp, index=False)
except Exception as e:
messagebox.showerror("保存失败", str(e))
return
messagebox.showinfo("完成", f"已保存 {df.shape[0]} 行 × {df.shape[1]} 列 到:\n{fp}")
# ================= 清理 =================
def clear_signal_list_only(self):
self.file_signals = {}
self.file_meta = {}
self.signal_names = []
self.custom_signals.clear()
self.custom_vars.clear()
self.sig_vars.clear()
self.custom_name_var.set("")
self.custom_expr_var.set("")
self.time_signal_var.set("")
self.time_signal_combo["values"] = []
self.custom_ref_combo.set("")
self.custom_ref_combo["values"] = []
for w in self.check_container.winfo_children():
if w is not self.check_list_container:
w.destroy()
for w in self.check_list_container.winfo_children():
w.destroy()
self.custom_tree.delete(*self.custom_tree.get_children())
def clear_all(self):
self.file_path = None
self.file_label.config(text="未选择文件")
self.clear_signal_list_only()
_qapp = None
def get_qapp():
"""获取进程级 QApplication 单例(只创建一次, 避免反复创建/销毁导致偶发崩溃)"""
global _qapp
if _qapp is None:
_qapp = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
return _qapp
class PlotWindow(QtWidgets.QMainWindow if QtWidgets is not None else object):
"""绘图窗口: 白底, 曲线显隐控制, 分辨率调节, 缩放, 导出PNG"""
def __init__(self, plot_data: dict, title="信号绘图"):
super().__init__()
self.setWindowTitle(title)
self.setMinimumSize(860, 560)
self.plot_data = plot_data
self.curves = {}
splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
# ---------- 左侧控制面板 ----------
panel = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(panel)
lay.setContentsMargins(8, 8, 8, 8)
ttl = QtWidgets.QLabel("曲线显示控制")
ttl.setStyleSheet("font-weight: bold;")
lay.addWidget(ttl)
self.tree = QtWidgets.QTreeWidget()
self.tree.setHeaderLabels(["显示", "信号"])
self.tree.header().setStretchLastSection(True)
self.tree.setColumnWidth(0, 60)
self.tree.itemChanged.connect(self.on_item_changed)
for name in plot_data:
item = QtWidgets.QTreeWidgetItem()
item.setText(1, name)
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
item.setCheckState(0, QtCore.Qt.Checked)
self.tree.addTopLevelItem(item)
lay.addWidget(self.tree)
btn_row = QtWidgets.QHBoxLayout()
btn_all = QtWidgets.QPushButton("全选")
btn_none = QtWidgets.QPushButton("全不选")
btn_all.clicked.connect(lambda: self.set_all_checked(True))
btn_none.clicked.connect(lambda: self.set_all_checked(False))
btn_row.addWidget(btn_all)
btn_row.addWidget(btn_none)
lay.addLayout(btn_row)
# 分辨率(窗口/导出图像尺寸)
res_row = QtWidgets.QHBoxLayout()
res_row.addWidget(QtWidgets.QLabel("分辨率(宽×高):"))
self.spin_w = QtWidgets.QSpinBox()
self.spin_w.setRange(400, 5000)
self.spin_w.setValue(1200)
self.spin_h = QtWidgets.QSpinBox()
self.spin_h.setRange(300, 4000)
self.spin_h.setValue(700)
res_row.addWidget(self.spin_w)
res_row.addWidget(self.spin_h)
btn_res = QtWidgets.QPushButton("应用")
btn_res.clicked.connect(self.apply_resolution)
res_row.addWidget(btn_res)
lay.addLayout(res_row)
zoom_row = QtWidgets.QHBoxLayout()
btn_auto = QtWidgets.QPushButton("自动缩放")
btn_reset = QtWidgets.QPushButton("复位视图")
btn_auto.clicked.connect(self.auto_zoom)
btn_reset.clicked.connect(self.reset_view)
zoom_row.addWidget(btn_auto)
zoom_row.addWidget(btn_reset)
lay.addLayout(zoom_row)
btn_export = QtWidgets.QPushButton("导出 PNG 图像")
btn_export.clicked.connect(self.export_png)
lay.addWidget(btn_export)
tip = QtWidgets.QLabel("提示: 滚轮缩放, 左键拖拽平移, 右键框选放大")
tip.setStyleSheet("color: #666;")
lay.addWidget(tip)
# ---------- 右侧绘图区 ----------
self.gw = pg.GraphicsLayoutWidget()
self.plot_item = self.gw.addPlot()
self.plot_item.setLabel("bottom", "Time (s)")
self.plot_item.addLegend()
self.plot_item.showGrid(x=True, y=True, alpha=0.3)
for i, (name, (t, v)) in enumerate(plot_data.items()):
c = COLORS[i % len(COLORS)]
curve = self.plot_item.plot(t, v, pen=c, name=name)
self.curves[name] = curve
splitter.addWidget(panel)
splitter.addWidget(self.gw)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setSizes([280, 920])
self.setCentralWidget(splitter)
self.resize(1200, 700)
def on_item_changed(self, item, column):
if column != 0:
return
name = item.text(1)
if name in self.curves:
self.curves[name].setVisible(
item.checkState(0) == QtCore.Qt.Checked)
def set_all_checked(self, checked):
state = QtCore.Qt.Checked if checked else QtCore.Qt.Unchecked
for i in range(self.tree.topLevelItemCount()):
self.tree.topLevelItem(i).setCheckState(0, state)
def apply_resolution(self):
self.resize(self.spin_w.value(), self.spin_h.value())
def auto_zoom(self):
self.plot_item.enableAutoRange()
self.plot_item.vb.autoRange()
def reset_view(self):
self.plot_item.vb.setAspectLocked(False)
self.plot_item.vb.autoRange()
def export_png(self):
path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "导出 PNG 图像", "", "PNG 图像 (*.png)")
if not path:
return
try:
exporter = ImageExporter(self.plot_item)
exporter.params["width"] = self.spin_w.value()
exporter.params["height"] = self.spin_h.value()
exporter.export(path)
QtWidgets.QMessageBox.information(self, "完成", f"图像已保存到:\n{path}")
except Exception as e:
QtWidgets.QMessageBox.critical(self, "导出失败", str(e))
def main():
setup_dpi_awareness()
try:
if not ensure_deps():
tk.Tk().withdraw()
messagebox.showerror(
"缺少依赖",
"缺少必需依赖, 请先执行:\n"
"pip install numpy pyqtgraph PyQt5\n\n"
"可选格式依赖:\n"
" MCAP: pip install mcap mcap-ros2-support mcap-ros1-support\n"
" MAT : pip install scipy\n"
" PARQUET: pip install pyarrow\n"
" ULG : pip install pyulog")
return
root = tk.Tk()
app = McapCsvPlotApp(root)
root.mainloop()
except Exception:
import traceback
log_path = os.path.join(os.path.dirname(os.path.abspath(sys.executable)), "plotter_error.log")
try:
with open(log_path, "w", encoding="utf-8") as f:
traceback.print_exc(file=f)
except Exception:
pass
raise
if __name__ == "__main__":
main()
3、可执行程序生成及注意事项
在程序源码文件mcap_csv_plotter.py所在目录下(D:\software)打开终端,输入如下命令,即可生成可执行文件mcap_csv_plotter.py
$env:PYTHONPATH="D:\pyinst_new"
$env:TMP="D:\pip_tmp"; $env:TEMP="D:\pip_tmp"
python -m PyInstaller --clean --noconfirm mcap_csv_plotter.spec --distpath D:\software\dist

520

被折叠的 条评论
为什么被折叠?



