
第20篇数据可视化 — Matplotlib绘制粒径分布折线图一、为什么用折线图而非柱状图1.1 数据可视化的核心目标在深入讨论折线图与柱状图的选择之前我们需要先明确数据可视化的核心目标让观察者在最短时间内获取最多的有效信息。一张好的图表应该能够清晰、准确、直观地传达数据背后的规律和趋势。对于粒径分布数据而言我们关心的核心问题包括晶粒尺寸的分布范围是多少哪个尺寸区间的晶粒数量最多分布是对称的还是偏态的整体趋势是平缓还是陡峭不同样本之间的分布有何差异不同的图表类型在回答这些问题时效率和准确性各不相同。1.2 柱状图的特点与局限柱状图Histogram是展示分布数据最经典的方式它用一系列高度不等的纵向柱子来表示各区间的数据量。柱状图的优势在于直观反映数量对比柱子的高度直接对应数量高低一目了然符合大众认知几乎所有人都能看懂柱状图数学定义清晰统计学中直方图有严格的定义和用途但柱状图也有其局限性特别是在晶粒分析这种场景下视觉割裂感每个柱子是独立的矩形区间之间缺乏视觉上的连续性观察者需要在脑海中将离散的柱子连起来才能看出分布趋势。面积误导人眼对面积的感知不如对高度的感知准确。当柱子宽度不一致时很容易产生视觉误导。信息密度低柱状图占用的空间较大但每个柱子只能传达一个数据点的信息。多组对比困难当需要在同一张图上对比多组分布时柱状图会变得非常拥挤和混乱。1.3 折线图的优势折线图Line Chart用连续的线条连接各个数据点在展示分布趋势方面有其独特的优势趋势连续性强连续的线条让分布的整体趋势一目了然上升、下降、峰值、谷值都非常直观。观察者可以一眼看出分布的形态特征。信息密度高在相同的画布空间内折线图可以展示更多的数据点而且不会显得拥挤。多组对比方便多条不同颜色的折线可以在同一张图上和谐共存便于对比不同样本或不同条件下的分布差异。视觉简洁优雅相比柱状图的厚重感折线图更加轻盈简洁更适合用于报告和论文等正式场合。标注灵活可以方便地在数据点旁边添加数值标注而不会像柱状图那样显得拥挤。1.4 为什么GrainServer选择折线图在GrainServer项目中粒径分布的可视化最终选择了折线图而非柱状图这是基于以下几点考虑与检测线方法的契合GrainServer的粒径测量是基于10条水平检测线与晶界的交点计算的得到的是一组离散的粒径区间计数。折线图能够更好地展现这些离散点之间的趋势关系。输出场景的需求粒径分布图需要嵌入到检测报告中折线图的简洁风格更适合打印和文档展示。数据点数量适中粒径分布被划分为11个区间0-30, 30-60, …, 300这个数量级用折线图展示非常合适——既不会因为点太少而显得突兀也不会因为点太多而杂乱。标注的便利性每个数据点需要同时标注数量和百分比折线图的标注位置更加灵活可以放在点的旁边而不会相互遮挡。当然这并不意味着折线图在所有场景下都优于柱状图。在需要强调各区间绝对数量对比的场景下柱状图仍然是更好的选择。图表类型的选择永远是基于具体需求的权衡。二、Matplotlib的Agg后端2.1 什么是Matplotlib后端很多Matplotlib的初学者会困惑于后端这个概念。简单来说Matplotlib的后端负责将绘图指令如画一条线、写一个文字转化为实际的图像输出。Matplotlib的设计哲学是前端绘图逻辑与后端渲染分离。同样的绘图代码可以通过切换后端来输出到不同的载体显示在屏幕上、保存为图片文件、嵌入到GUI应用中等等。常见的后端类型包括交互式后端TkAgg基于Tkinter GUI工具包QtAgg基于Qt GUI框架GTK4Agg基于GTK4 GUI框架WxAgg基于wxWidgets GUI框架这些后端会弹出图形窗口用户可以与图表进行交互缩放、平移、保存等。非交互式后端AggAnti-Grain Geometry渲染引擎输出为栅格图像PNG、JPG等CairoCairo图形库渲染SVG输出为可缩放矢量图形PDF输出为PDF文件PS输出为PostScript文件这些后端不需要图形界面直接将绘图结果保存为文件。2.2 为什么选择Agg后端在GrainServer项目中我们在文件开头就显式指定了使用Agg后端importmatplotlib matplotlib.use(Agg)这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:11-13。这是一个非常重要的细节也是后端服务程序使用Matplotlib的标准做法。选择Agg后端的原因如下无界面环境需求GrainServer是一个后端服务程序运行在没有图形界面的环境中甚至可能是Windows服务模式。如果使用交互式后端如默认的TkAgg程序会尝试连接显示服务器在无界面环境下会直接报错。性能考虑Agg后端专注于文件渲染不需要处理交互事件渲染性能更高。对于后端服务来说我们只需要生成图片文件不需要交互功能。稳定性Agg后端是Matplotlib中最稳定、最成熟的后端之一跨平台兼容性好不会因为系统GUI库的差异而出现各种奇怪的问题。资源占用交互式后端需要维护窗口、事件循环等占用更多内存和CPU资源。Agg后端只做纯渲染资源占用更低。2.3 Agg后端的工作原理Agg后端基于Anti-Grain GeometryAGG渲染引擎这是一个高质量的2D图形渲染库。它的工作流程大致如下Matplotlib接收绘图指令生成图形的抽象表示点、线、文字、颜色等Agg后端将这些抽象元素转换为像素级的渲染指令AGG引擎执行反锯齿Anti-aliasing渲染生成高质量的栅格图像图像数据可以保存为PNG、JPG、BMP等格式正是因为有了Agg后端Matplotlib才能在服务器环境中默默工作为我们生成一张张精美的图表。2.4 常见的后端相关坑点在后端服务中使用Matplotlib有几个常见的坑点需要注意后端设置的时机matplotlib.use(Agg)必须在import matplotlib.pyplot之前调用。如果先导入pyplot再切换后端就会失效。GrainServer的代码严格遵守了这个顺序先导入matplotlib并设置后端再导入pyplot。多线程环境Matplotlib默认不是线程安全的。在多线程环境中使用Matplotlib时最好每个线程独立创建和销毁figure或者使用锁来保护绘图操作。内存泄漏如果不及时关闭figureMatplotlib可能会累积内存占用。GrainServer的代码中每次绘图后都调用了plt.close()这是一个很好的习惯。字体缓存首次使用中文字体时Matplotlib需要重建字体缓存可能会比较慢。可以通过配置字体路径来加速。三、中文字体设置3.1 Matplotlib的字体机制Matplotlib的字体处理是一个让很多中文用户头疼的问题。默认情况下Matplotlib使用的是英文字体直接显示中文会变成方块或乱码。要理解中文字体设置的原理需要先了解Matplotlib的字体查找机制当需要渲染文字时Matplotlib会根据字体族font family名称查找对应的字体文件字体族是一个逻辑名称比如serif衬线体、sans-serif无衬线体、monospace等宽体每个字体族对应一个字体列表Matplotlib会按顺序查找找到第一个可用的字体字体文件从系统字体目录和Matplotlib自带的字体目录中加载默认情况下sans-serif字体族的列表中只有DejaVu Sans等西文字体没有中文字体因此中文无法正常显示。3.2 SimSun宋体的选择在GrainServer项目中中文字体设置如下plt.rcParams[font.sans-serif][SimSun]# 设置中文字体为宋体这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:63。这里选择了**SimSun宋体**作为中文字体这是一个非常务实的选择原因如下Windows系统自带SimSun是Windows系统预装的中文字体几乎每台Windows电脑上都有。对于部署在Windows上的GrainServer来说不需要额外安装字体开箱即用。打印友好宋体是印刷业的标准字体之一打印效果清晰易读适合用于检测报告等正式文档。辨识度高宋体的笔画有粗细变化衬线设计使得小字号下仍然有较好的辨识度。正式规范在国内的科技论文、检测报告、技术文档中宋体是最常用的正文字体使用宋体符合国内用户的阅读习惯。3.3 字体设置的其他方法除了通过rcParams全局设置字体外还有几种设置字体的方法方法一全局配置GrainServer采用的方式plt.rcParams[font.sans-serif][SimSun]plt.rcParams[axes.unicode_minus]False# 解决负号显示问题优点设置一次全局生效缺点影响所有图表。方法二指定字体文件路径frommatplotlib.font_managerimportFontProperties fontFontProperties(fnameC:/Windows/Fonts/simsun.ttc,size12)plt.xlabel(粒径,fontpropertiesfont)优点精确控制不依赖系统字体配置缺点每个文字元素都需要单独设置。方法三在函数参数中指定plt.title(粒径分布,fontnameSimSun,fontsize14)优点灵活缺点每个元素都要设置代码冗余。GrainServer选择了全局配置的方式因为项目中所有的图表都需要中文支持全局设置一次即可代码最简洁。3.4 负号显示问题设置中文字体后还有一个常见问题坐标轴上的负号-会显示成方块。这是因为很多中文字体中没有包含负号这个字符。解决方法是添加一行配置plt.rcParams[axes.unicode_minus]False虽然GrainServer的代码中没有显式写这一行因为粒径都是正数不会出现负号但这是Matplotlib中文设置的标准知识点值得了解。四、渐变色线条与tab10色板4.1 为什么使用渐变色线条在粒径分布图中GrainServer采用了一种特殊的绘线方式不是用同一种颜色画一条连续的折线而是将每一段线段用不同的颜色绘制形成彩虹般的渐变效果。让我们看一下核心代码foriinrange(len(intervals)-1):plt.plot([intervals[i],intervals[i1]],[counts[i],counts[i1]],markero,colorplt.cm.tab10(i))# 使用tab10颜色映射这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:65-67。循环遍历每一对相邻的数据点为每一段线单独设置颜色颜色取自plt.cm.tab10(i)。这样做的好处有视觉区分度高不同的区间段用不同的颜色观察者可以快速定位到某个特定的尺寸区间。美观性渐变色线条比单一颜色的线条更具视觉吸引力让图表看起来更专业、更精美。信息编码颜色本身也可以作为一种信息编码维度。虽然在这个场景下颜色没有明确的语义但彩虹渐变隐含了从小到大的顺序感。丰富的视觉层次单一颜色的折线图有时会显得单调渐变色增加了视觉层次让图表更有质感。4.2 tab10色板详解plt.cm.tab10是Matplotlib内置的一个颜色映射Colormap属于分类色板Qualitative Colormap的一种。它包含10种辨识度高、对比鲜明的颜色。tab10的10种颜色依次是蓝色#1f77b4橙色#ff7f0e绿色#2ca02c红色#d62728紫色#9467bd棕色#8c564b粉色#e377c2灰色#7f7f7f黄绿色#bcbd22青色#17becf这些颜色是精心挑选的具有以下特点高辨识度任意两种颜色放在一起都能轻松区分不会混淆。色彩和谐虽然颜色丰富但整体色调协调不会显得刺眼或杂乱。印刷友好即使在黑白打印时这些颜色的灰度也有明显差异仍然可以区分。无障碍友好考虑了色盲友好性红绿色盲患者也能区分大部分颜色。在GrainServer项目中粒径分布有11个区间加上0点共12个点11段线但tab10只有10种颜色。当i超过9时tab10(i)会循环取色。这种小细节体现了代码的简洁性——不需要为了多1种颜色就换用更大的色板tab10的10种颜色已经足够区分大部分区间了。4.3 Matplotlib的色板体系Matplotlib提供了丰富的色板大致可以分为几类顺序色板Sequential颜色从浅到深渐变适合表示数值大小。如viridis、plasma、inferno、magma、cividis。发散色板Diverging中间浅两端深适合表示偏离中心值的程度。如coolwarm、RdBu、seismic。循环色板Cyclic首尾颜色相接适合表示周期性数据。如twilight、hsv。分类色板Qualitative多种不同的颜色用于区分类别。如tab10、tab20、Set1、Pastel1。了解这些色板的特点可以帮助我们在不同的可视化场景中做出合适的选择。五、数据点标注5.1 双标签设计GrainServer的粒径分布图在每个数据点旁边都有标注标注内容包含两个信息颗粒数量和百分比。让我们看一下标注的代码实现# 计算百分比proportionround(counts[i1]/sum(counts)*100,2)plt.text(intervals[i1],counts[i1],f{counts[i1]}({proportion}%),fontsize8,verticalalignmentbottom,horizontalalignmentright)这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:68-71。标注格式为数量 (百分比%)例如42 (15.3%)。这种双标签设计非常实用因为绝对值与相对值兼顾数量是绝对值告诉我们每个区间有多少个颗粒百分比是相对值告诉我们这个区间占总量的比例。两者结合信息才完整。快速定位与整体感知兼得看数量可以知道具体的颗粒数看百分比可以快速判断哪个区间是主流。报告友好检测报告中通常既需要绝对数量也需要百分比分布。一张图就能提供两种数据减少了图表数量。5.2 标注位置的精控标注的位置是一个细节但直接影响图表的可读性。如果位置放得不好文字可能会和线条重叠或者相互遮挡。GrainServer中使用了plt.text()函数来添加文字标注并通过两个参数精确控制位置verticalalignmentbottom,horizontalalignmentright这两个参数控制的是文字锚点相对于文字本身的位置horizontalalignment水平对齐left锚点在文字左侧center锚点在文字中心right锚点在文字右侧verticalalignment垂直对齐top锚点在文字顶部center锚点在文字中心bottom锚点在文字底部baseline锚点在文字基线GrainServer选择了horizontalalignmentright和verticalalignmentbottom意味着文字的右下角对齐到数据点的位置。也就是说文字位于数据点的左上方。这样的位置选择有几个考虑避免遮挡线条折线图的线条是从左向右延伸的数据点的左上方通常是空白区域把文字放在这里不会遮挡线条。阅读顺序自然从左到右看图表时眼睛看到数据点后自然会看向其上方的标注符合阅读习惯。统一对齐方式所有点都采用相同的对齐方式视觉上整齐划一。当然对于第一个数据点0.00代码做了特殊处理plt.text(intervals[0],counts[0],f{counts[0]}(0.0%),fontsize8,verticalalignmentbottom,horizontalalignmentright)这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:73-75。同样是右下角对齐但因为第一个点的左侧已经没有空间了所以文字其实会延伸到左边界之外。不过这个点的值始终是0标注的存在感不强影响不大。5.3 字号选择标注的字号设置为8号fontsize8这也是一个精心的选择足够清晰8号字在10x6英寸的图中足够清晰可辨不会太小看不清。不喧宾夺主标注是辅助信息不能比坐标轴标签、标题等主要文字还大。8号字比默认的坐标轴标签字号小主次分明。空间充足8号字的高度较小不会占用太多垂直空间避免了相邻点的标注相互重叠。六、图表元素详解6.1 标题与坐标轴标签一张完整的图表需要有清晰的标题和坐标轴标签让观察者一眼就能看懂图的内容。plt.xlabel(粒径u粒)plt.ylabel(颗粒数量)plt.title(粒径分布)这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:77-79。标题粒径分布简洁明了直接说明这张图展示的是什么。X轴标签粒径u粒说明横轴是颗粒尺寸单位是微米。这里的u粒应该是μm的简化写法在实际项目中可以考虑优化为标准符号。Y轴标签颗粒数量说明纵轴是颗粒的个数。这三个元素构成了图表的基本框架是任何专业图表都不可或缺的。6.2 刻度旋转plt.xticks(rotation45)这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:80。X轴刻度标签被旋转了45度。为什么要旋转因为粒径区间的标签比较长如120.00-150.00如果水平排列相邻的标签会相互重叠无法阅读。旋转45度是一个常用的技巧它有几个好处解决重叠问题标签倾斜后水平方向占用的空间变小不再重叠。保持可读性45度是一个折中的角度既节省空间又不至于太倾斜而难以阅读。视觉平衡倾斜的标签让图表底部有了动感比死板的水平排列更活泼一些。旋转的角度选择也是有讲究的旋转角度太小如30度节省的空间有限可能还是会重叠旋转角度太大如90度文字竖排阅读起来脖子疼45度是公认的黄金角度兼顾了空间效率和可读性6.3 Y轴刻度控制plt.yticks(range(0,max(counts)1,10))这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:82。Y轴的刻度不是自动生成的而是手动指定的从0开始到最大值结束步长为10。手动控制Y轴刻度有几个好处刻度整齐步长为10的整数刻度看起来更规范、更专业。网格线对齐如果显示网格网格线会正好在整数位置便于读数。避免奇怪的刻度值Matplotlib自动生成的刻度有时会出现47.5这样奇怪的值手动控制可以避免这种情况。可预测性不管数据怎么变Y轴刻度的间隔都是10用户有稳定的预期。当然这种固定步长的方式也有局限性。如果颗粒数量非常多比如超过1000步长10就太小了会有上百条刻度线非常密集。更完善的做法是根据数据范围动态选择合适的步长1、2、5、10、20、50、100…。6.4 网格线控制plt.grid(False)# 去掉背景的方格线这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:84。代码显式关闭了网格线显示。关于要不要显示网格线可视化领域一直有争论。支持网格线的理由便于读取数据点的精确数值提供视觉参考帮助判断趋势让图表看起来更专业反对网格线的理由增加视觉噪音分散注意力网格线会与数据线竞争视觉焦点对于折线图来说趋势比精确数值更重要GrainServer选择关闭网格线我认为是合理的。粒径分布图更关注整体的分布趋势而不是每个点的精确数值。没有网格线图表更加简洁折线本身的形态更加突出。而且每个数据点旁边已经有了数值标注观察者不需要通过网格线来估算数值网格线的作用已经被标注替代了。七、tight_layout防截断7.1 什么是布局截断问题在使用Matplotlib绘图时很多人都会遇到一个恼人的问题图表的标题、坐标轴标签或者刻度文字被画布边缘截断了显示不完整。这是因为Matplotlib的默认布局是基于坐标轴区域来计算的它假设标题和标签都在坐标轴区域之外但没有精确计算这些文字实际需要占用多少空间。当文字比较长或者字号比较大时就会超出画布边界。尤其是在X轴刻度被旋转后标签在垂直方向占用更多空间更容易被底部边缘截断。7.2 tight_layout的原理tight_layout()是Matplotlib提供的一个自动调整布局的函数它可以解决标签被截断的问题。plt.tight_layout()这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:85。tight_layout()的工作原理大致是计算所有文字元素标题、标签、刻度的实际边界框bounding box根据这些边界框重新调整子图axes的位置和大小确保所有元素都在画布范围内并且有适当的边距简单来说tight_layout会自动挤一挤图表的位置让所有内容都能完整显示出来。7.3 tight_layout的使用时机tight_layout()应该在所有绘图元素都添加完成之后、保存或显示之前调用。因为它需要知道所有元素的位置和大小才能做出正确的调整。GrainServer的代码中plt.tight_layout()放在plt.grid(False)之后、plt.savefig()之前这个顺序是正确的。7.4 其他布局调整方法除了tight_layout()Matplotlib还有其他调整布局的方法subplots_adjust()手动调整上下左右边距可以精确控制每个边的空白大小。plt.subplots_adjust(left0.1,right0.95,top0.9,bottom0.15)constrained_layout比tight_layout更智能的布局引擎在创建figure时启用。plt.figure(figsize(10,6),constrained_layoutTrue)GridSpec用于复杂的子图布局可以精确控制每个子图的位置和比例。对于单图的简单场景tight_layout()已经足够用了而且代码最简单。八、plot_length_distribution()代码精讲8.1 函数整体结构现在让我们把前面所有的知识点串联起来完整地解读plot_length_distribution()函数。先看完整的函数定义defplot_length_distribution(self,interval_counts,plot_output_path):intervals[0.00][f{intv[0]:.2f}-{intv[1]:.2f}ifintv[1]!float(inf)elsef{intv[0]:.2f}forintvininterval_counts.keys()]counts[0]list(interval_counts.values())plt.rcParams[font.sans-serif][SimSun]# 设置中文字体为宋体plt.figure(figsize(10,6))foriinrange(len(intervals)-1):plt.plot([intervals[i],intervals[i1]],[counts[i],counts[i1]],markero,colorplt.cm.tab10(i))# 使用tab10颜色映射# 计算百分比proportionround(counts[i1]/sum(counts)*100,2)plt.text(intervals[i1],counts[i1],f{counts[i1]}({proportion}%),fontsize8,verticalalignmentbottom,horizontalalignmentright)# Adding label for the point 0.00plt.text(intervals[0],counts[0],f{counts[0]}(0.0%),fontsize8,verticalalignmentbottom,horizontalalignmentright)plt.xlabel(粒径u粒)plt.ylabel(颗粒数量)plt.title(粒径分布)plt.xticks(rotation45)# 设置 y 轴刻度尺的详细程度plt.yticks(range(0,max(counts)1,10))# plt.ylim()plt.grid(False)# 去掉背景的方格线plt.tight_layout()plt.savefig(plot_output_path)plt.close()这段代码来自f:\grainserver\ImgProcessor\ImgUtils.py:59-87。8.2 输入数据处理函数接收两个参数interval_counts区间计数字典键是区间元组如(0, 30)值是该区间的颗粒数量plot_output_path图片输出路径函数首先对输入数据进行处理生成X轴标签和Y轴数值intervals[0.00][f{intv[0]:.2f}-{intv[1]:.2f}ifintv[1]!float(inf)elsef{intv[0]:.2f}forintvininterval_counts.keys()]counts[0]list(interval_counts.values())这里有几个细节起始点在所有区间之前加了一个0.00的虚拟点对应数量为0。这样折线图就从0开始视觉上有一个起势的感觉而不是从第一个区间突兀地开始。区间格式化每个区间格式化为起始值-结束值的字符串保留两位小数。对于最后一个区间上限为无穷大格式化为起始值的形式。数值列表在计数值列表前也加了一个0与0.00点对应。这个数据预处理的小技巧让折线图的视觉效果更好。8.3 绘图流程完整的绘图流程是设置中文字体 → 确保中文正常显示创建画布 → 指定尺寸10x6英寸循环绘制每一段线 → 渐变色效果循环添加每个点的标注 → 数量百分比单独添加第一个点的标注 → 特殊处理设置X轴标签 → 粒径设置Y轴标签 → 颗粒数量设置标题 → 粒径分布旋转X轴刻度 → 45度防重叠设置Y轴刻度 → 步长为10关闭网格 → 简洁风格自动调整布局 → 防止截断保存图片 → 输出到指定路径关闭画布 → 释放资源这个流程清晰有序每一步都有明确的目的。8.4 资源管理最后两行代码值得特别关注plt.savefig(plot_output_path)plt.close()绘图完成后调用了plt.close()来关闭画布。这是一个非常好的编程习惯。为什么要显式关闭释放内存每个figure对象都会占用一定的内存特别是当图像较大、数据较多时。如果不关闭内存会持续累积。避免干扰Matplotlib有一个当前figure的概念如果不关后续的绘图操作可能会误画到之前的图上。资源泄漏在长时间运行的服务程序中如果每次绘图都不关闭figure最终会耗尽内存导致程序崩溃。GrainServer作为一个后端服务可能需要处理大量的图像生成请求每次都及时关闭figure尤为重要。九、总结数据可视化是数据分析的最后一公里再好的分析结果如果不能清晰直观地展示出来价值也会大打折扣。GrainServer项目中的粒径分布折线图虽然只有短短几十行代码却蕴含了丰富的可视化设计思想。本文从为什么选择折线图讲起深入剖析了Matplotlib的Agg后端原理、中文字体设置、渐变色线条与tab10色板、数据点双标签设计、图表各元素的作用、tight_layout布局优化等多个技术点最后完整解读了plot_length_distribution()函数的实现细节。一张专业的图表是无数个小细节的积累。字体的选择、颜色的搭配、标注的位置、刻度的设置……每一个细节都在影响最终的呈现效果。正是对这些细节的打磨让一张简单的折线图超越了能用的层次达到了好用甚至好看的境界。在接下来的文章中我们将继续探索GrainServer项目的架构设计从多线程模型到日志系统看看一个稳健的后端服务是如何炼成的。