科研画图这件事,平时看着不起眼,真到自己动手做的时候才发现坑比想象中多。尤其是率失真曲线这类图,横轴是码率、纵轴是失真或峰值信噪比,数据点来自多次实验的统计结果,往往只有十几个点,直接连线就是折线图,显得很生硬;强行平滑又可能丢掉真实趋势。这篇就围绕“散点连接并平滑”这个需求,把Matlab和Python两条路都梳理清楚,讲一讲背后的原理、具体的实现代码、以及我实际踩过的坑。
1. 动手之前先分清:你是要“光滑”还是“保真”
很多人拿到散点第一反应就是上样条插值,结果画出来的曲线又弯又绕,看起来确实顺滑了,但跟实验数据本身的关系反而不直观。这里的关键在于你要搞清楚这张图的用途:是给自己的论文做趋势展示,还是要放到审稿人面前接受检验。
1.1 科学数据图的底线:插值不能改变数据语义
率失真曲线的横纵坐标都有明确物理意义。横轴是单位像素或单位帧的比特数,纵轴是重建失真或PSNR,理论上应该是一条单调递减或递减趋势明显的曲线。你可以用插值让曲线变平滑,但插值方法不能引入原本不存在的波动,尤其是不能在中段出现异常的“下凹”或“上凸”,否则审稿人一眼就能看出问题。
我之前见过有人用普通三次样条对一组只有8个点的率失真数据做插值,结果在码率中等区域出现了一个明显的“鼓包”,看起来像是算法在中码率性能更好,实际上完全是样条的过冲效应。这种图放到论文里,等于主动给审稿人递刀。所以选择平滑方法之前,先问自己一句:这条曲线的总体趋势我是否已经心里有数?如果答案是肯定的,再决定用什么插值。
1.2 保真优先的推荐路线:PCHIP优于普通Spline
保单调、保形状的插值方法里,PCHIP(分段三次Hermite插值)是科研绘图里最稳妥的选择。它不会像普通三次样条那样产生过冲,趋势转折点处也更克制。Matlab里用interp1指定pchip,Python里用scipy.interpolate.PCHIP即可。
如果你的数据量很少(比如只有5到10个点),我建议你先画一个不带平滑的散点图看看整体趋势,再叠加PCHIP曲线;如果数据点之间有明显的跳变,不要用插值隐藏它,那可能是实验条件不一致,需要回头查数据而不是硬画图。
2. 数据预处理:散点连接前的关键一步
率失真曲线的散点往往不是均匀分布的。码率低的时候点比较密,码率高的时候点比较稀,这是编码实验的常态。直接对原始散点做插值,高码率段的曲线容易被拉得不够平滑;直接连线,低码率段又显得锯齿感明显。
2.1 排序问题:x轴必须严格递增
这是最基础也最容易被忽略的一步。interp1、PCHIP这类插值函数都要求横坐标严格递增,如果你的散点是从多个日志文件里合并来的,顺序往往混乱,必须先按横轴排序。排序之后最好顺手做一次重复点检查,同一个码率点如果对应多个失真值,直接取平均或取中位数即可。
我之前处理某次实验数据时,因为两个测试序列的日志合并时没有去重,导致同一横坐标出现了两个不同的纵坐标,结果插值函数直接报错。后来养成习惯:任何散点进插值流程之前,先做“排序+去重+平均”三件套。
2.2 降采样不是偷懒,是为了让趋势更清楚
有时候你的点其实很多,比如做了几十个码率点的实验,这种数据画出来已经比较平滑了,不需要额外插值;但点太多也会让图显得拥挤,尤其在论文单栏排版里。这时候可以降采样,比如每3个点取1个,或者按码率区间均匀抽点。
我倾向的做法是:点少于15个,直接插值平滑;点多于30个,先降采样到15到20个点左右,再做插值。这样既保证曲线视觉平滑,又不至于让插值过程因为数据过密而出现过拟合式的弯曲。
3. Matlab实操:三种插值方法对比与代码实现
Matlab画率失真曲线,核心就是interp1函数,加上plot和errorbar的组合。
3.1 基础版本:PCHIP插值画出平滑曲线
% 原始数据(示例) bitrate = [0.5, 1, 2, 3, 4, 6, 8]; % 单位:Mbps或bpp psnr = [31.2, 33.5, 35.1, 35.9, 36.4, 36.8, 37.0]; % 单位:dB % 生成细密的横轴采样点 xq = linspace(min(bitrate), max(bitrate), 200); % PCHIP插值:保单调、无过冲 yq_pchip = interp1(bitrate, psnr, xq, 'pchip'); % 普通三次样条插值:对比用,看趋势差异 yq_spline = interp1(bitrate, psnr, xq, 'spline'); figure plot(bitrate, psnr, 'o', 'MarkerSize', 6, 'LineWidth', 1.2) hold on plot(xq, yq_pchip, '-', 'LineWidth', 1.5, 'Color', [0.2 0.4 0.8]) plot(xq, yq_spline, '--', 'LineWidth', 1.2, 'Color', [0.8 0.3 0.3]) xlabel('Bitrate (Mbps)') ylabel('PSNR (dB)') legend({'实验数据', 'PCHIP插值', '三次样条插值'}, 'Location', 'southeast') grid on这段代码跑出来你就能直观看到两种插值的区别:样条在中段可能产生轻微波动,而PCHIP基本贴着散点的自然趋势走。实测下来,论文里用PCHIP通常是最稳妥的。
3.2 带误差棒:展示多次实验的波动范围
率失真曲线很少只跑一次实验,一般会跑多个内容源或多个随机种子,这时候需要在散点上叠加误差棒,再对均值做插值。
% 假设每个码率点做了3次实验 bitrate = [0.5, 1, 2, 3, 4, 6, 8]; psnr_mean = [31.0, 33.2, 34.9, 35.8, 36.2, 36.6, 36.9]; psnr_std = [0.4, 0.3, 0.25, 0.3, 0.2, 0.15, 0.1]; xq = linspace(min(bitrate), max(bitrate), 200); yq = interp1(bitrate, psnr_mean, xq, 'pchip'); % 用误差棒上限和下限分别插值,绘制浅色置信带 yq_upper = interp1(bitrate, psnr_mean + psnr_std, xq, 'pchip'); yq_lower = interp1(bitrate, psnr_mean - psnr_std, xq, 'pchip'); figure fill([xq fliplr(xq)], [yq_upper fliplr(yq_lower)], ... [0.2 0.4 0.8], 'FaceAlpha', 0.15, 'EdgeColor', 'none') hold on plot(bitrate, psnr_mean, 'o-', 'MarkerSize', 6, 'LineWidth', 1.2, ... 'Color', [0.2 0.4 0.8]) errorbar(bitrate, psnr_mean, psnr_std, 'o', 'MarkerSize', 4, ... 'Color', [0.2 0.4 0.8], 'LineWidth', 0.8, 'CapSize', 3) xlabel('Bitrate (Mbps)') ylabel('PSNR (dB)') grid on这里有个关键细节:误差带不是直接连接散点上下界的包络,而是对“均值+标准差”和“均值-标准差”分别做插值。这样生成的带状区域才是自然的,不会出现拐来拐去的锯齿形阴影。
3.3 Matlab里的平滑降噪:smoothdata的适用场景
如果你的数据点很多且噪声大,可以考虑先对纵坐标做平滑再插值:
psnr_smoothed = smoothdata(psnr, 'gaussian', 5);但要注意,smoothdata默认的窗口大小需要根据点数调整,窗口太大容易把真实趋势也抹平。我一般只在点数超过20且趋势明显的前提条件下使用这招,否则宁可不平滑。
4. Python实操:SciPy插值与Pyplot美化
Python做率失真曲线,路线也是一样:numpy整理数据,scipy.interpolate做插值,matplotlib画图。
4.1 PCHIP插值代码模板
import numpy as np from scipy.interpolate import PCHIP import matplotlib.pyplot as plt # 原始数据 bitrate = np.array([0.5, 1., 2., 3., 4., 6., 8.]) psnr = np.array([31.2, 33.5, 35.1, 35.9, 36.4, 36.8, 37.0]) # 生成插值用的细密横坐标 xq = np.linspace(bitrate.min(), bitrate.max(), 200) # PCHIP插值 pchip = PCHIP(bitrate, psnr) yq_pchip = pchip(xq) # 对比普通三次样条 from scipy.interpolate import CubicSpline cs = CubicSpline(bitrate, psnr) yq_spline = cs(xq) fig, ax = plt.subplots(figsize=(6, 4.5)) ax.plot(bitrate, psnr, 'o', markersize=7, label='实验数据') ax.plot(xq, yq_pchip, '-', linewidth=1.8, label='PCHIP插值') ax.plot(xq, yq_spline, '--', linewidth=1.2, label='三次样条插值') ax.set_xlabel('Bitrate (Mbps)', fontsize=12) ax.set_ylabel('PSNR (dB)', fontsize=12) ax.legend(loc='lower right') ax.grid(True, linestyle='--', alpha=0.6) plt.tight_layout() plt.savefig('rd_curve_python.pdf', dpi=300, bbox_inches='tight') plt.show()Python版里PCHIP类用起来比Matlab还直观一点,可以直接调用pchip(xq)得到插值结果,不需要每次指定方法名。
4.2 置信带绘制:fill_between的正确姿势
和Matlab的fill对应的是ax.fill_between:
psnr_mean = np.array([31.0, 33.2, 34.9, 35.8, 36.2, 36.6, 36.9]) psnr_std = np.array([0.4, 0.3, 0.25, 0.3, 0.2, 0.15, 0.1]) yq_upper = PCHIP(bitrate, psnr_mean + psnr_std)(xq) yq_lower = PCHIP(bitrate, psnr_mean - psnr_std)(xq) fig, ax = plt.subplots(figsize=(6, 4.5)) ax.fill_between(xq, yq_lower, yq_upper, color='steelblue', alpha=0.15) ax.errorbar(bitrate, psnr_mean, yerr=psnr_std, fmt='o', color='steelblue', markersize=5, capsize=3, linewidth=1, label='实验均值$\\pm$标准差') ax.plot(xq, yq_pchip, '-', color='steelblue', linewidth=1.8, label='PCHIP插值曲线') ax.set_xlabel('Bitrate (Mbps)', fontsize=12) ax.set_ylabel('PSNR (dB)', fontsize=12) ax.legend(loc='lower right') ax.grid(True, linestyle='--', alpha=0.6) plt.tight_layout()这里注意errorbar和plot的线宽、标记大小要协调,否则图例里会显得粗细不一,看起来不够专业。
4.3 多个算法对比时的配色和线型管理
率失真曲线最常见的使用场景是多个算法横向对比。我推荐一组稳定的配置:
- 基准算法:黑色实线加圆点标记
- 改进算法1:蓝色实线加方块标记
- 改进算法2:红色虚线加三角标记
- 上界参考:灰色点线
styles = [ {'color': 'black', 'marker': 'o', 'linestyle': '-', 'label': 'Baseline'}, {'color': '#1f77b4', 'marker': 's', 'linestyle': '-', 'label': 'Method A'}, {'color': '#d62728', 'marker': '^', 'linestyle': '--', 'label': 'Method B'}, {'color': '#7f7f7f', 'marker': 'D', 'linestyle': ':', 'label': 'Upper Bound'}, ] for i, (bitrate_i, psnr_i) in enumerate(datasets): ax.plot(bitrate_i, psnr_i, **styles[i]) # 也可以叠加PCHIP插值曲线 xq_i = np.linspace(bitrate_i.min(), bitrate_i.max(), 200) yq_i = PCHIP(bitrate_i, psnr_i)(xq_i) ax.plot(xq_i, yq_i, linestyle=styles[i]['linestyle'], color=styles[i]['color'], linewidth=1.5)这样画出来的图即便打印成黑白版本,靠散点形状和线型也能区分不同的算法,避免审稿人在黑白打印稿里分不清曲线。
5. 进阶处理:横轴取对数、纵轴语义与多子图排版
很多率失真曲线的横轴跨度特别大,从0.1 Mbps到几十 Mbps,直接用线性坐标会导致低码率段的数据挤在一起,看不清楚。
5.1 对数横轴与插值顺序的关系
如果你决定把横轴改成对数坐标,要注意插值应该在原始数据空间做,还是在取对数后的空间做。我的经验是:先在原始数据空间做PCHIP插值,再用plt.xscale('log')修改显示方式。
fig, ax = plt.subplots() ax.plot(bitrate, psnr, 'o') xq = np.logspace(np.log10(bitrate.min()), np.log10(bitrate.max()), 200) yq = PCHIP(bitrate, psnr)(xq) ax.plot(xq, yq, '-') ax.set_xscale('log')这里使用logspace生成插值采样点,是因为在对数坐标显示时,横轴等距的采样点在视觉上才是均匀的。如果还是用linspace,对数坐标下曲线的前半段会显得稀疏,后半段密得挤在一起。
5.2 用双纵轴或归一化横轴表达不同语义
有时候你要对比的不是直接的PSNR,而是相对增益,比如“相对于基准算法的BD-Rate变化”。这种情况下把多个算法的曲线画在一张图上反而很难读,更适合的做法是用子图或归一化曲线。
归一化的方式很简单:把横轴除以基准算法的最大码率,纵轴除以基准算法的最低失真。这样可以突出相对趋势,但要注意图例和坐标轴的说明里写清楚归一化基准,避免读者误解。
5.3 多子图排版的推荐比例
论文里率失真曲线通常放在实验部分,一张图2到4条曲线即可。子图组合形态一般为上下两个图,上方是率失真曲线,下方是对应的增益柱状图,两者共享横轴。如果用matplotlib,可以用gridspec控制高度比例,建议曲线图占高度约65%,柱状图占35%。
from matplotlib import gridspec fig = plt.figure(figsize=(7, 6)) gs = gridspec.GridSpec(2, 1, height_ratios=[1.6, 1]) ax1 = fig.add_subplot(gs[0]) ax2 = fig.add_subplot(gs[1])这种布局比单图信息量大,word里排版也很方便。
6. 常见问题速查与避坑经验
6.1 典型问题排查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
插值报错x must be strictly increasing | 横坐标未排序或存在重复点 | 先sort,再对重复点取平均 |
| 曲线在中段出现不自然的凸起 | 普通三次样条的过冲效应 | 改用PCHIP插值 |
| 平滑后曲线偏离散点趋势 | 平滑窗口太大或插值采样点太密 | 减小窗口,或改用保形插值 |
| 对数坐标下曲线前半段稀疏 | 插值采样用了linspace而非logspace | 改用logspace生成采样点 |
| 误差带边界有锯齿 | 直接连接原始散点的上下界 | 对上下界分别插值再填充 |
| 图例遮挡曲线关键趋势 | loc设置不当 | 根据曲线走势选'lower right'或手工指定坐标 |
| 导出PDF后字体模糊 | 未嵌入字体或矢量格式设置不对 | 用pdf保存并在matplotlib里设置rcParams['pdf.fonttype']=42 |
| 黑白打印无法区分算法 | 只用颜色区分 | 增加标记样式和线型差异 |
6.2 独家经验:别忽视曲线端点的“边界细节”
插值曲线在端点区域最容易出问题。PCHIP在端点处虽然不会像样条那样发散,但如果你的数据在低码率端采样不足,插值曲线可能会在端点处出现斜率异常。
我通常会在低码率端额外加一个虚拟采样点,比如原点(0, 最低点PSNR)或第一个点往前一点的位置,这样能抑制端点区域的斜率失真。添加的点要在图注或代码里注明是虚拟点,不参与数值分析,只用于绘图。
6.3 标准化输出参数建议
我最后把自己的固定参数列在这里,方便你直接复制使用:
- 散点标记大小:6号,黑色边框渐变填充
- 曲线线宽:1.5到2.0之间
- 坐标轴字号:10号或12号,够大不抢戏
- 图例框架线框:关闭
- 网格线:虚线、透明度0.5以下
- 字体:论文要求如果是LaTeX用
font.family='Times New Roman',没有要求就用系统默认
这些参数看似琐碎,但全部统一之后,整篇论文的图表风格会非常一致。审稿人看多了风格混乱的图,遇到一套干净标准的图,第一印象就会好很多。
最后的实际体会
刚刚说的那个用虚拟点抑制端点斜率的办法,算是我自己踩过坑之后沉淀下来的经验。某次实验,我在低码率端只测了0.2 Mbps一个点,PCHIP曲线画出来后,低码率段掉头往下,看起来像算法在小码率性能骤降。后来加了虚拟起点,曲线回归正常趋势,审稿人看图和看数据对得上,就没有再刁难。类似的细节还有不少,比如误差带颜色透明度、图表导出格式、坐标轴留白比例,这些琐碎的东西单独看都不起眼,但合在一起就是一张图专不专业的分水岭。画率失真曲线,说白了不是算法难,而是你有没有把这些细节真正当回事。