ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Pandas和Matplotlib分析骑行数据:从CSV清洗到可视化复盘

用Pandas和Matplotlib分析骑行数据:从CSV清洗到可视化复盘 作为一个骑车骑到膝盖疼还忍不住刷数据的人我每次骑完车最期待的事情之一就是把码表导出的骑行记录变成一张张图表。这篇文章想写一个非常通用的实战流程用 Python 读取码表导出的 CSV 骑行数据通过 Pandas 完成清洗、类型转换和聚合统计再用 Matplotlib 把心率、速度、功率、里程这些指标可视化出来。项目本身不复杂但几乎覆盖了日常数据分析的完整链路很适合刚学完 Pandas 和 Matplotlib 基础语法、想找一个真实项目练手的读者也适合经常骑车、想把自己的训练数据真正用起来的朋友。1. 整体设计从码表记录到训练复盘1.1 先理清这条数据链路题目虽然叫骑行数据分析但核心链路可以拆成四段采集、清洗、计算、呈现。采集不是重点现代码表基本都能导出或者同步到骑行平台再导出真正花时间的是第二步和第三步。很多人把数据导出来就急着画图结果画出来的趋势线全是异常值这就是跳过清洗环节的典型结果。我用过三种处理方式Excel 打开直接做透视表、用 Jupyter Notebook 写 Pandas、写到一半换成 Pyecharts。最后稳定下来的其实是最朴素的 Pandas Matplotlib。为什么因为这套组合能让我把一个 60 公里骑行、每秒一条记录的数据在前五分钟内完成从原始文件到可视图表的全流程而且所有步骤都可以脚本化、自动化不用手动挪数据。这个项目的最终目标也不是画几张漂亮的图而是回答几个训练问题这次骑行强度分布如何心率区间有没有跑偏连续几个月的里程趋势是上升还是下降数据可视化只是手段复盘才是目的。1.2 骑行数据从哪来先说数据源。常见码表品牌Garmin、Wahoo、迈金还有手机上的行者、Strava导出格式基本围绕 CSV、GPX、TCX、FIT 这几种。对入门来说只要能导出一个带时间戳的 CSV就足以完成接下来的所有步骤GPX 更偏 GPS 轨迹用来算累计爬升也可以但字段没有 CSV 丰富。在导出之前先检查一整趟骑行里有没有这几个关键字段时间戳、距离、速度、心率、海拔、踏频、功率。不同软件叫法可能不一样比如时间栏叫 Time 或 timestamp距离叫 Distance 或 distance_km。我的处理习惯是无论原始字段名多奇怪都先列一份字段解释表再进入清洗。常见字段大致是这样原始字段名含义常见单位timestamp / time记录时间戳/distance / distance_km累计骑行距离kmspeed / speed_kmh瞬时速度km/hheartrate / heartrate_bpm瞬时心率bpmcadence / cadence_rpm踏频rpmaltitude / alt_m海拔高度mpower / power_w瞬时功率W这里有个很容易踩的坑很多码表 CSV 里的 distance 是累计距离不是每秒产生的增量。后面计算里程、爬升时不能直接对整列求和而是要用差分再聚合这个细节我在第四节单独讲。1.3 为什么选 Pandas Matplotlib 而不是别的Excel 做透视表确实方便但要自定义一张带心率区间标注、双坐标轴、配色统一的图操作起来很别扭而且当数据量到数万行、需要跨多文件批量处理时Excel 会明显卡顿。Pyecharts 这类交互式图表库适合做网页报表视觉效果确实更炫但一旦涉及分组聚合、异常值清洗这类操作你还是得回到 Pandas 里处理最后再传给 Pyecharts。对一次骑行数据复盘来说Matplotlib 的静态图完全够用还能直接保存成 PNG/SVG 贴进训练周报。所以选型逻辑很简单数据处理用 Pandas静态出图用 Matplotlib两者和 Python 数据分析生态无缝衔接。如果你之后想加个 Web 前端看板清洗和聚合的代码可以原封不动迁移只是把绘图层换掉而已。2. 环境准备安装依赖和准备样例数据2.1 Python 环境怎么装如果你电脑上还没有 Python直接去官网下载 3.10 或 3.11 版本即可安装时记得勾选“Add Python to PATH”否则后面在命令行里敲 python 会找不到命令。已经装过 Python 但版本很旧的话建议先升级一下Pandas 和 Matplotlib 对老版本的支持在逐步收缩。依赖安装用 pip 就好python -m pip install --upgrade pip pip install pandas matplotlib国内网络慢的时候我习惯加清华源速度会快很多pip install pandas matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple个人强烈建议建一个虚拟环境不要一股脑装进全局 Python。虚拟环境能隔离项目依赖避免不同项目的包版本互相打架。python -m venv .venv # Windows .venv\Scripts\activate # macOS / Linux source .venv/bin/activate装完检查一下版本确认两个库都能正常导入import pandas as pd import matplotlib.pyplot as plt print(pd.__version__) print(matplotlib.__version__)很多“无法导入 pandas”的报错本质是包根本没装进当前激活的环境。在 PyCharm 里尤其常见右下角解释器用的是全局环境但终端里用 pip 安装进了虚拟环境。装完别急着跑代码先确认解释器路径和 pip 环境一致。2.2 准备一份可以直接跑的样例数据真实码表导出的 CSV 通常长这样我截取前几行做演示timestamp,distance_km,speed_kmh,cadence_rpm,heartrate_bpm,altitude_m,power_w 2025-03-01 07:00:00,0.000,0.0,0,86,50.0,0 2025-03-01 07:00:01,0.004,15.8,76,91,50.1,55 2025-03-01 07:00:02,0.009,18.2,79,94,50.3,62 2025-03-01 07:00:03,0.015,21.6,83,98,50.4,68 2025-03-01 07:00:04,0.022,25.0,88,105,50.2,83 2025-03-01 07:00:05,0.030,28.3,87,112,50.0,97 2025-03-01 07:00:06,0.039,32.1,89,121,49.8,112 2025-03-01 07:00:07,0.049,35.4,92,128,49.9,126注意这里的 distance_km 是累计距离第一行是 0后面每一秒增加一小段。如果你是某个软件导出的分段增量就需要自己用累加把它变成累计距离否则后面算总里程时会直接错。如果没有真实数据也可以先用这段样例构造一个小文件练习。整篇文章的代码逻辑不依赖特定品牌只要是带时间戳、距离、心率、速度这些列的 CSV 都可以套用。2.3 工具链选择脚本还是 Notebook我自己的习惯是分两层探索阶段用 Jupyter Notebook把数据读进来、画几张小图、确认字段含义和边界确认逻辑没问题后再整理成干净的 .py 脚本方便以后每周跑一次。Notebook 的好处是代码块和输出挨在一起改一行参数能立刻看到结果脚本的好处是可复用、可自动化、不容易被误操作打乱状态。对新手来说直接在 PyCharm 或 VS Code 里写 .py 文件也没问题。如果画图后窗口不弹出来检查一下是不是把 plt.show() 漏了或者在 Jupyter 里需要加一行%matplotlib inline。这些都是小事但确实会挡住不少初学者。3. 数据读取与清洗画图前的必要准备3.1 用 read_csv 读取原始记录读取这一步没什么玄学关键是把时间列解析对import pandas as pd df pd.read_csv( ride.csv, encodingutf-8-sig, parse_dates[timestamp] ) print(df.head()) print(df.info()) print(df.isnull().sum())这里有两个细节很容易踩一是很多码表软件导出的 CSV 是 UTF-8-BOM 编码如果不加 encodingutf-8-sig第一列列名会变成\ufefftimestamp后面所有按列名的操作全部失灵二是 parse_dates 参数能提前把时间列转成 datetime 类型省得后面再手动 to_datetime。读完数据后别急着画图先看 df.info() 输出的列名、非空数量和数据类型。一次 60 公里骑行、每秒一条记录大概有七八千行Pandas 处理起来毫无压力但如果在 info 里看到某列全是 object 类型说明读进来的数字被当成字符串了后面统计会出问题。3.2 数据类型校正CSV 读进来之后数字列偶尔会被识别成 object尤其是存在空值或者个别异常字符时。稳妥起见我会对所有数值列做一次强制转换df[distance_km] pd.to_numeric(df[distance_km], errorscoerce) df[speed_kmh] pd.to_numeric(df[speed_kmh], errorscoerce) df[heartrate_bpm] pd.to_numeric(df[heartrate_bpm], errorscoerce) df[cadence_rpm] pd.to_numeric(df[cadence_rpm], errorscoerce) df[altitude_m] pd.to_numeric(df[altitude_m], errorscoerce) df[power_w] pd.to_numeric(df[power_w], errorscoerce)errorscoerce 的含义是转不了的值直接变成 NaN。这样做的好处是后续所有清洗逻辑统一用 NaN 来处理脏值而不是让字符串混在数值里。这就是热词里常说的“pandas 数据类型转换”本质上是在给数据定一个干净的基础类型。时间列如果上面没用 parse_dates这里也可以补df[timestamp] pd.to_datetime(df[timestamp], errorscoerce)转完后再用 df.dtypes 确认一遍数值列是 float64时间列是 datetime64[ns]这样后面的差分、聚合、画图才不会报错。3.3 缺失值处理策略骑行数据里的 NaN 出现场景很典型隧道里 GPS 信号丢失速度列和海拔列同时断档心率带没戴紧心率列出现一大段空值功率计没校准功率列长时间为 0 或空。如果一律 dropna时间序列会被切断画出来的曲线有缺口爬升计算也会跟着错。我的处理方式分三类速度、海拔这种位置相关的字段用线性插值补因为相邻时刻的物理量变化是连续的。心率这种人体生理信号用前向填充并限制连续填充数因为心率不会瞬间突变但也不能让丢失半小时的数据被错误填充。踏频和功率在静止时本来就该是 0可以直接用 fillna(0)。对应代码df[speed_kmh] df[speed_kmh].interpolate(methodlinear) df[altitude_m] df[altitude_m].interpolate(methodlinear) df[heartrate_bpm] df[heartrate_bpm].ffill(limit10) df[cadence_rpm] df[cadence_rpm].fillna(0) df[power_w] df[power_w].fillna(0)ffill(limit10) 表示最多向前填充 10 秒超过 10 秒的缺口保持 NaN画图时这个位置会留空反而能提示你这段信号质量差。这个细节是我在跑了一段山里信号丢失严重的路线后总结出来的不限制长度的话整段爬坡的心率都会被填充成同一个值统计意义直接报废。3.4 异常值过滤清洗的第二件事是过滤物理上不合理的值。骑行瞬时速度超过 120 km/h 基本是 GPS 漂移心率超过 220 也几乎不可能是真实的除非你正在参加极为特殊的冲刺测试。我用业务常识来设置阈值而不是靠统计算法df df[df[speed_kmh].between(0, 120)] df df[df[heartrate_bpm].between(30, 210)]注意这里的 210 是随便举的例子。更合理的做法是按自己的最大心率来设比如df[heartrate_bpm] max_hr 10。过滤完后检查一下删掉了多少行如果删掉的比例超过 5%先怀疑数据本身有问题而不是急着继续分析。还有一个典型的坑有些码表在停车等红绿灯时不记录速度只记录位置差分算出来的速度可能是 0 甚至负值。负速度直接用 .abs() 或者 .clip(lower0) 处理不要保留负数否则里程统计会少算。3.5 清洗后的数据固化清洗这步重复性最高也最容易被反复执行。我的办法是把它写成函数并且每次清洗完先存一份干净文件df.to_csv(ride_clean.csv, indexFalse, encodingutf-8-sig)为什么要存中间结果因为画图阶段的调试往往要跑很多次如果每次调试都重新读原始 CSV、重新清洗一遍浪费时间不说还有可能因为中间步骤不一致导致前后图表对不上。把清洗结果固化下来之后所有画图代码都从 ride_clean.csv 开始读逻辑更清晰也方便对比不同可视化方案。4. 指标计算从原始记录到训练特征4.1 用 describe 做一次快速体检清洗完数据后先对核心指标做一个整体体检summary df[[speed_kmh, heartrate_bpm, cadence_rpm, power_w]].describe() print(summary)describe 会一次性给出数量、均值、标准差、四分位数和最值。第一次看到这个表的时候先别急着兴奋重点看几个东西最大速度是不是合理心率 75% 分位数和最大值差距大不大功率列的数值是否有大面积为零。这些信息能告诉你这次的骑行是大体稳健还是有明显的信号问题。平均心率这个指标很有用但只看平均不够。两小时的骑行如果一个人的心率一直在 Z2 区间徘徊另一个人在 Z4 和 Z1 之间反复横跳平均心率可能完全一样但训练刺激完全不同。所以要往下拆心率区间。4.2 心率区间分布比平均心率更有价值心率区间是把训练强度按最大心率的百分比划分的常见五区模型大致如下区间强度心率范围最大心率百分比Z1恢复区60% 以下Z2耐力区60% - 70%Z3节奏区70% - 80%Z4阈值区80% - 90%Z5无氧区90% - 100%用 pd.cut 可以轻松打标签import numpy as np max_hr 190 # 按自己实际情况改 bins [0, max_hr * 0.6, max_hr * 0.7, max_hr * 0.8, max_hr * 0.9, np.inf] labels [Z1, Z2, Z3, Z4, Z5] df[hr_zone] pd.cut(df[heartrate_bpm], binsbins, labelslabels, rightFalse) zone_seconds df[hr_zone].value_counts().sort_index() print(zone_seconds)得到的是每个区间停留的秒数。再除以 60 就是分钟数。我通常会顺手算一个百分比结果放在表格里zone_pct (zone_seconds / df[hr_zone].notna().sum()) * 100这个分析对于有氧耐力和强度训练特别有用。比如我周末拉一次长距离如果 Z1 占比过高说明强度没上去骑了等于白骑如果 Z4 占比过高说明可能是把自己拉爆了周一就得安排恢复。4.3 距离、爬升、时长三大基础指标骑行记录里最核心的产出是总里程、总爬升和运动时长。计算这些不太能直接 sum因为 distance_km 列是累计值。我先做差分再求和df[delta_km] df[distance_km].diff().abs().clip(lower0) total_distance_km df[delta_km].sum() df[elev_gain_m] df[altitude_m].diff().clip(lower0) total_elev_gain_m df[elev_gain_m].sum()爬升只统计海拔正向变化所以对差分结果取 clip(lower0)只保留上升部分。这个逻辑很简单但非常实用甚至比某些码表 App 的爬升算法更直观。运动时长有两种口径一是总耗时也就是最后一个时间戳减第一个时间戳二是实际骑行时长也就是速度大于 0 的时间。前者包含红绿灯和休息后者更接近真正的运动刺激。total_duration (df[timestamp].iloc[-1] - df[timestamp].iloc[0]).total_seconds() / 60 active_duration (df[speed_kmh] 0).sum() / 60我两个都算报告里也分开写。只看总耗时容易高估训练量只看运动时长又会忽略停下来的恢复节奏两个合起来才能反映一场骑行的结构。4.4 按周/月聚合单次记录升级成长期趋势单次骑行图只是切片月度趋势才有真正的训练负荷意义。如果你有连续多天的记录文件先用 pd.concat 合并files [20250301.csv, 20250308.csv, 20250315.csv] frames [pd.read_csv(f, parse_dates[timestamp]) for f in files] df_all pd.concat(frames, ignore_indexTrue)合并后统一跑一遍清洗逻辑和差分计算然后按月聚合df_all[month] df_all[timestamp].dt.to_period(M) monthly ( df_all.groupby(month) .agg( total_km(delta_km, sum), total_elev(elev_gain_m, sum), avg_hr(heartrate_bpm, mean), active_min(speed_kmh, lambda x: (x 0).sum() / 60) ) .reset_index() ) print(monthly)这段代码里的 lambda 表达式稍微绕一点但它算的是当月实际骑行分钟数。聚合之后每一行就代表一个月的训练概况后面画柱状图、看趋势都用这个表。到这里数据层面的准备工作已经全部完成接下来进入真正出图的环节。5. Matplotlib 可视化实战把数字变成能读懂的图5.1 中文字体与全局样式设置Matplotlib 默认字体不支持中文画出来全是方块。每次新建画图脚本我第一件事就是设置全局字体import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] # Windows # macOS/Linux 可以换成 [Noto Sans CJK SC] 或 [PingFang SC] plt.rcParams[axes.unicode_minus] False plt.rcParams[figure.dpi] 100axes.unicode_minus False 也很关键否则坐标轴上的负号会显示成方块。如果你的系统里找不到可用的中文字体可以下载 Noto Sans CJK然后用 font_manager 指定路径加载from matplotlib import font_manager font_manager.fontManager.addfont(/path/to/NotoSansCJKsc-Regular.otf) plt.rcParams[font.sans-serif] [Noto Sans CJK SC]这一步是中文画图报错的头号来源也是一个每次都会出现的细节建议直接写进你自己的通用配置里省得每个脚本都重复踩一遍。5.2 骑行过程曲线里程为主轴心率-速度双轴图单次骑行过程里最有信息量的图是心率、速度随里程变化的曲线。我用累计里程做横轴而不是时间原因很朴素骑行路上有红绿灯、有休息拍照时间轴会把静止片段拉得很长明明是爬坡强度却看不到用里程轴x 轴距离均匀推进能够更准确地反映体感变化。fig, ax1 plt.subplots(figsize(14, 5)) ax1.plot( df[distance_km], df[heartrate_bpm], color#d62728, lw1.2, label心率 ) ax1.set_xlabel(累计里程 (km)) ax1.set_ylabel(心率 (bpm)) ax2 ax1.twinx() ax2.plot( df[distance_km], df[speed_kmh], color#1f77b4, lw0.8, alpha0.7, label速度 ) ax2.set_ylabel(速度 (km/h)) fig.legend(locupper left) plt.savefig(ride_profile.png, dpi150, bbox_inchestight) plt.show()twinx() 生成第二个 y 轴让心率和速度的尺度互不干扰。红色线是心率蓝色线是速度。你会发现速度掉下来时心率往往往上走这正是爬坡路段速度平稳但心率缓慢上升则是典型的耐力输出区间。如果数据是以每秒一条记录的密度进来几千个点直接画线会显得非常密。我通常用df.iloc[::10]降采样后再画效果不会差且渲染速度明显更快。5.3 功率-心率散点图看状态而不是看数值装了功率计的话功率和心率的配对关系非常值得画。散点图比曲线更擅长展示分布fig, ax plt.subplots(figsize(7, 6)) sc ax.scatter( df[power_w], df[heartrate_bpm], s8, alpha0.35, cdf[speed_kmh], cmapviridis ) ax.set_xlabel(功率 (W)) ax.set_ylabel(心率 (bpm)) plt.colorbar(sc, label速度 (km/h)) plt.tight_layout() plt.savefig(power_hr_scatter.png, dpi150) plt.show()这里用颜色的深浅表示速度散点的透明度设为 0.35能避免几千个点叠在一起变成黑坨。我常拿这张图做状态判断同样的心率下功率分布越靠右上说明输出效率越高反过来低功率却能撑起高心率那多半是状态疲劳的表现。Matplotlib 的颜色体系支持英文颜色名、十六进制色号和 colormap比如上图用 viridis 做连续色阶。我的建议是关键数据线用高对比颜色次要信息用透明度降低存在感别把默认配色里面的红红绿绿全部堆上去。5.4 月度里程与爬升柱状图加一条趋势线月度聚合表做好后画柱状图就是常规操作fig, ax plt.subplots(figsize(10, 5)) ax.bar(monthly[month].astype(str), monthly[total_km], color#4C72B0) ax.set_ylabel(骑行里程 (km)) ax.set_xlabel(月份) for idx, val in enumerate(monthly[total_km]): ax.text(idx, val 5, f{val:.0f}, hacenter) plt.xticks(rotation45) plt.tight_layout() plt.savefig(monthly_km.png, dpi150) plt.show()如果你还想在同一张图里叠加累计里程线可以用 twinx 再来一层ax2 ax.twinx() ax2.plot(monthly[month].astype(str), monthly[total_km].cumsum(), color#C44E52, markero, label累计里程) ax2.set_ylabel(累计里程 (km))柱状图的高度代表单月总量折线的斜率代表趋势。斜率向上说明训练量在稳步增加斜率平缓甚至向下的时候就要警惕是不是进入了低谷期。这是我月底复盘时最先看的一张图。5.5 画图的细节优化从能看变成好看基础图出来之后我会花十分钟做细节调整让图能直接贴进训练周报而不是只在自己电脑上能看。dpi 存图plt.savefig(xxx.png, dpi150, bbox_inchestight)150 dpi 足够清晰bbox_inchestight 能裁掉多余的空白。网格线ax.grid(True, alpha0.3)让数据更容易对齐坐标轴但透明度别太高。图例位置不要默认的 “best”手动指定更稳定比如locupper left否则数据跑到某个区域时图例会飘。坐标轴精度速度、踏频这类指标的小数位控制在 1 位以内图表看起来更干净。还有一个容易被忽略的细节保存图片时同时存一份 SVG 矢量图。PNG 适合贴网页和聊天窗口SVG 放进文档或打印时不会模糊。这个习惯我保持了很久临时要做一个汇报时不用重新跑代码。6. 常见问题与排查技巧实录6.1 高频报错与解决方案速查表我整理了一份骑行数据可视化过程中最常见的报错表基本覆盖了新手到入门会遇到的大多数问题现象常见原因解决办法pandas 导入失败包没装进当前环境检查虚拟环境和解释器用清华源重新安装中文显示成方块字体不支持中文设置 font.sans-serif 或手动加载中文字体负号显示成方块axes.unicode_minus 未设置设置 plt.rcParams[axes.unicode_minus] False横坐标时间标签太密集刻度太多或标签过长每隔 N 个显示一次、rotation45、FIG 调大宽度画完图没有任何窗口弹出漏了 plt.show() 或后端不对检查交互后端Jupyter 加 %matplotlib inlineparse_dates 没有生效时间列名不对或格式不识别检查列名必要时传 format%Y-%m-%d %H:%M:%Sseaborn 风格报错新版 seaborn 风格名改版用 seaborn-v0_8-whitegrid 或 ggplot 代替这里面最烦人的其实是横坐标太密集。当你的时间序列是按秒记录的默认刻度会尝试把每一个小时都标出来标签挤在一起根本没法看。我的处理方法是设置间隔ax.set_xticks(ax.get_xticks()[::10])或者直接用plt.xticks(range(0, len(df), 300), rotation45)按数据行数来稀疏刻度。6.2 数据问题比代码问题更常见很多新手花两小时调试绘图代码最后发现根源其实是数据清洗阶段的问题。比如累计距离列里有几行是 0差分后出现负数比如心率带没戴好前半段全是 0导致整图看起来像心率突然从 0 跳到 150比如不同文件的时间格式不统一pd.concat 后时间列变成 object。我的排查顺序是固定的先打印df.info()和df.head(20)看看列名、数据量、缺失值画一张最简单的plt.plot(df[speed_kmh])看看整段曲线是否符合预期确认数据的物理边界后再去调样式。跳过了数据层面而在样式层面猛调是性价比最低的排障方式。有一次我画出来的速度曲线在高频抖动后来发现是 GPS 信号在城市高架桥下反复飘移瞬时速度在 15 和 60 之间跳来跳去。这时单纯看单点数据没有意义我用了滚动平均df[speed_smooth] df[speed_kmh].rolling(window30, min_periods1).mean()用滚动窗口把短时抖动抹平画出来的曲线才真正反映速度趋势。这个技巧在心理上也很实用看到平滑曲线后你不会再被 GPS 噪声带乱节奏。6.3 性能别用循环去算累计值码表每秒一条记录60 公里骑行也就几千行最多几万行Pandas 处理起来毫无压力。但有一种写法一定会卡用 for 循环逐行 append 去算累计距离。不要写这样的代码# 反面教材 distance_list [] acc 0 for row in df.itertuples(): acc row.delta_km distance_list.append(acc) df[cum_distance] distance_list应该用向量化操作df[cum_distance] df[delta_km].cumsum()Pandas 的 diff、cumsum、resample 这些操作底层是经过高度优化的 C 实现比 Python 逐行循环快几十倍到上百倍。对于几万行数据循环可能只慢一两秒但一旦扩展到全年几十个 CSV 合并后的几十万行差距会非常明显。养成用向量化操作的习惯后续做任何数据量更大的分析都会受益。写完这套流程后我再多说两句先把清洗和聚合逻辑固化成函数比如clean_ride_data(path)和build_monthly_report(folder)以后每次骑完把码表导出文件丢进文件夹跑一次脚本就得到一张表和一摞图。这套流程跑顺之后你会发现数据不再是躺在码表里没用的数字而是能直接指导下周该怎么练的工具。最后分享一个小细节导出图片时别忘了同时存 PNG 和 SVG 两个版本PNG 用于随手分享SVG 用于做文档和打印。画图时先关注图能不能回答你的训练问题再去调颜色和样式顺序反了软件修得再漂亮也很难真正辅助训练。下一次骑完别再让数据躺在码表里花十分钟跑一遍脚本你会对自己当前的状态有更清楚的判断。
返回列表