Qt绘制史密斯圆图:基于QPainter的完整源码实现
2026/9/9 20:16:12 网站建设 项目流程

简介:面向射频微波电路设计、课程学习及Qt进阶开发人群,这份资源实现了基于QPainter的史密斯圆图绘制小程序,用于将txt文件中的复数阻抗数据直观映射到圆图上,辅助阻抗匹配分析。压缩包共5个文件,包括2个cpp、1个pro、1个h和1个ui,分别对应主窗口逻辑、控件绘制、工程配置、头文件和界面布局,整体仅6KB,代码量少,适合快速阅读与二次开发。已有819人学习下载。源码完整演示了使用QFile读取文本、坐标变换、调用drawLine/drawArc绘制圆图曲线以及drawText标注的完整流程,并封装了SmithChartWidget类,配合主窗口菜单即可打开数据文件,直接运行观察结果。对于想掌握Qt图形绘制、文件操作或深入理解史密斯圆图的开发者,这是一份轻量但完整的参考实现,兼具学习与工程价值。 做射频调试的人,应该都经历过这个尴尬:MATLAB里几行代码就能把S参数画成漂亮的史密斯圆图,可一旦想把圆图做进自己的工具软件、嵌入手持设备,或者跟串口数据实时对接,那套带授权费的方案就完全不灵了。我去年在做一套射频测试辅助工具时,需要把时域脉冲转成频谱,再从频域取阻抗值映射到圆图上,当时翻遍了现成的Qt控件库,没有一个能直接用的。试过拿QCustomPlot硬改,折腾两天之后放弃了——不如直接用QPainter从零画一个。

这篇文章就把“Qt绘制Smith chart源码”这件事彻底讲透:史密斯圆图两族曲线背后的数学关系、为什么最终选QPainter而不是QCustomPlot或QGraphicsView、核心类的源码怎么组织、以及接入Touchstone文件、串口数据和鼠标交互时那些容易踩的坑。适合想把圆图控件做进自己Qt工程,或者想彻底搞懂这张经典图表底层逻辑的人。

1. 为什么是Qt而不是MATLAB/Python:工具链的现实考量

很多人第一个问题是:画个圆图而已,至于自己写源码吗?这个问题的答案取决于你的使用场景。

如果只是出报告、做离线分析,那MATLAB或者Python的schelbert包确实更省事,输入一组S参数直接出图,谁都不会拦你。但我要做的是桌面级调试工具,需要满足三个额外条件:跨平台部署、跟C++采集代码无缝衔接、不用给运行时环境交授权费。Qt在这三个维度上都站得住,它的QPainter绘图引擎性能足够,一个圆图控件在普通嵌入式主板上也能跑出流畅的帧率,这在调试射频硬件时很关键。

还有一个很实际的原因:Qt控件可以被当成普通QWidget嵌进任何界面布局里。我可以把它放在QLabel旁边、塞进QSplitter、跟QCustomPlot的波形图并排显示,甚至直接编译进ARM板子的显示程序。对做射频硬件或者嵌入式开发的人来说,这种嵌入能力比绘图本身更值钱。

那为什么不干脆用QCustomPlot?它的确自带一些特殊轴类型和图层机制,看起来很适合做科学绘图。但我试验下来的结论是:QCustomPlot的设计目标还是“矩形坐标系下的函数曲线图”,它的坐标轴、刻度、网格机制都是围绕笛卡尔坐标体系做的。要改成史密斯圆图这种以单位圆为边界的非矩形坐标空间,需要绕很大一圈去骗坐标轴系统,换来换去反而容易出错。相比之下,QPainter提供的自由绘制能力恰恰是最直接的工具,圆图网格又不需要坐标轴刻度系统自己算,画起来一点都不亏。

技术栈上,我只用了Qt Widgets模块加QPainter,核心代码不依赖任何第三方库。主窗口里给QCustomPlot留一个位置显示时域波形,给自制控件留一个位置显示圆图,两边通过信号槽同步,整个工具跑起来很清爽。

2. 史密斯圆图的数学坐标体系:画图之前必须先算清楚的事

写源码之前,先把数学底子铺好。圆图不是随便画几圈弧线,它本质上是“反射系数平面”上的一个映射图。

反射系数的定义是:

Γ = (Z - Z0) / (Z + Z0)

Z是负载阻抗,Z0是参考阻抗(通常50Ω)。Γ是一个复数,可以拆成实部U和虚部V:

Γ = U + jV

把它们画在直角坐标系里,横轴是U,纵轴是V,整个圆图的范围是单位圆:|Γ| <= 1。单位圆内每一个点都对应一个唯一的阻抗值Z。史密斯圆图的巧妙之处在于,它把整个右半阻抗平面映射进了一个单位圆,这样一来,无限大的阻抗区域被压缩成有限大小,看阻抗匹配就非常直观。

网格上的每一组圆,其实是通过固定阻抗实部或虚部得到的曲线。设归一化阻抗z = r + jx,这里r是归一化电阻,x是归一化电抗,代入反射系数公式:

Γ = (z - 1) / (z + 1)

把实部虚部分开,固定r不变、让x变化,得到一族圆,这就是等电阻圆;固定x不变、让r变化,得到另一族圆,这就是等电抗圆。它们的具体公式为:

等电阻圆:

(U - r/(r+1))² + V² = (1/(r+1))²

所以圆心在(r/(r+1), 0),半径为1/(r+1)。

等电抗圆:

(U - 1)² + (V - 1/x)² = (1/x)²

所以圆心在(1, 1/x),半径为|1/x|。注意电抗为正值时圆心在单位圆上方,电抗为负值时在下方,而且所有等电抗圆都经过点(1, 0),也就是Γ = 1那个点,对应阻抗无穷大。

这几个参数直接决定了网格的绘制代码,我把常用值列成一张表,方便对照:

归一化电阻r等电阻圆圆心(U)等电阻圆半径
001
0.20.1670.833
0.50.3330.667
10.50.5
20.6670.333
50.8330.167
归一化电抗x等电抗圆圆心(1, 1/x)等电抗圆半径
0.2(1, 5)5
0.5(1, 2)2
1(1, 1)1
2(1, 0.5)0.5
5(1, 0.2)0.2

从表里能看出一个非常重要的现象:电抗越接近0,等电抗圆的半径越大,比如x=0.2时半径是5,圆心在纵轴方向上离单位圆很远。这就是为什么绘制网格时必须做裁剪,否则大圆弧会冲出控件边界。

坐标变换也是绕不开的一步。Qt的像素坐标系Y轴朝下,圆图的数学坐标Y轴朝上,二者正好相反。我最初直接手写转换函数,后来发现QPainter自带矩阵变换可以一步解决,这个技巧后面源码部分细说。

3. 三种绘图方案的取舍:QPainter、QCustomPlot与QGraphicsView

做控件前,我把Qt生态里能用的三条路线都试了一遍,这里直接说结论,省得你在同样的问题上浪费工时。

第一条路线是QPainter直接绘制。这是最底层、最灵活的方式,所有图形都在paintEvent里完成。网格是画圆,数据轨迹是画连续多段线,标记是画小圆点,它天然适合这种“图形元素不多但坐标系特殊”的场景。性能上,即使数据点达到几千个,用QPolygonF一次drawPolyline也能轻松跑满60帧。代价是所有交互逻辑都得自己写,缩放、悬停、拾取都没有现成的。

第二条路线是QCustomPlot。它的优势是坐标轴、刻度、图例、鼠标拖拽缩放一整套都做好了,特别适合时域波形和频谱图。但它的轴系统和数据映射方式高度绑定笛卡尔坐标,要拿它画史密斯圆图,得把圆图的数据“伪装”成在普通直角坐标里能显示的曲线,虽然最终也能画出来,但网格是假曲线拼的,坐标轴信息也对不上,改动成本比直接画高一个数量级。所以我最后的方案是:波形和频谱用QCustomPlot,圆图用QPainter,两者各司其职。

第三条路线是QGraphicsView。它是为“图形对象交互”设计的,每个圆、每条线都可以是独立的QGraphicsItem,天然支持选中、拖拽、捏合。听起来很美好,但实际开发中我发现两个问题:一是圆图网格里等电阻圆和等电抗圆数量不少,都用独立Item管理会徒增复杂度;二是QGraphicsView的坐标系统同样基于矩形场景,单位圆的特殊映射还是要绕。除非你要做教学演示软件,需要用户拖拽某个圆上的节点然后实时计算匹配网络,否则没必要上这个复杂度。

三条路线的对比我整理成表格:

方案绘制性能交互能力坐标轴支持定制自由度上手成本圆图匹配度
QPainter需手写最高
QCustomPlot强(笛卡尔)
QGraphicsView

实践上我推荐QPainter为主路线,如果你只是做数据展示,可以配合QCustomPlot画波形,但圆图部分不要勉强塞进去。

4. 核心源码拆解:从反射系数到像素坐标的完整链路

先看头文件里需要哪些成员。我这个SmithChartWidget的骨架是这样的:

// smithchartwidget.h #ifndef SMITHCHARTWIDGET_H #define SMITHCHARTWIDGET_H #include <QWidget> #include <QVector> #include <complex> class SmithChartWidget : public QWidget { Q_OBJECT public: explicit SmithChartWidget(QWidget *parent = nullptr); // 输入:一组复数阻抗,自动换算成反射系数 void setImpedanceData(const QVector<std::complex<double>>& zPoints, const QVector<double>& frequencies); // 输入:直接给反射系数轨迹(例如Touchstone里的S11) void setReflectionData(const QVector<std::complex<double>>& gammaPoints); void setGridVisible(bool visible); void setTraceVisible(bool visible); protected: void paintEvent(QPaintEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void wheelEvent(QWheelEvent *event) override; private: void drawUnitCircle(QPainter& painter, const QPointF& center, double R); void drawGrid(QPainter& painter, const QPointF& center, double R); void drawTrace(QPainter& painter, const QPointF& center, double R); void drawMarkersText(QPainter& painter, const QPointF& center, double R); std::complex<double> pixelToGamma(const QPointF& pos) const; QVector<std::complex<double>> m_gammaPoints; // 反射系数轨迹 QVector<double> m_frequencies; bool m_gridVisible = true; bool m_traceVisible = true; double m_zoomLevel = 1.0; }; #endif // SMITHCHARTWIDGET_H

接口设计上,控件只认反射系数,因为圆图的横纵坐标本来就是Γ的实部和虚部。如果上层拿到的是阻抗,通过下面这对转换函数换算:

// 阻抗转反射系数 std::complex<double> zToGamma(const std::complex<double>& z, double z0 = 50.0) { return (z - z0) / (z + z0); } // 反射系数转阻抗,鼠标悬停显示用 std::complex<double> gammaToZ(const std::complex<double>& gamma, double z0 = 50.0) { return z0 * (1.0 + gamma) / (1.0 - gamma); }

到这里公式还很简单,真正的关键在paintEvent里的坐标映射。我最开始是手动把每个数学坐标换算成像素坐标,后来发现QPainter自带的坐标变换能省掉大半工作量——先沿窗体中心翻转Y轴,之后所有绘制逻辑都直接用数学坐标:

void SmithChartWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.fillRect(rect(), QColor(250, 250, 250)); const double baseR = std::min(width(), height()) / 2.0 * 0.98; const double R = baseR * m_zoomLevel; const QPointF center(width() / 2.0, height() / 2.0); // 翻转Y轴坐标,之后画的坐标系以圆心为原点且Y轴向上 painter.save(); painter.translate(center); painter.scale(1.0, -1.0); painter.translate(-center); drawUnitCircle(painter, center, R); if (m_gridVisible) { drawGrid(painter, center, R); } if (m_traceVisible) { drawTrace(painter, center, R); } painter.restore(); // 文本标注必须在非翻转状态下绘制,否则字是镜像的 drawMarkersText(painter, center, R); }

这里必须多说一句:text在翻转的坐标系里画出来是反的,这是所有自绘控件最经典的坑。我的做法是把网格、曲线这些几何图形放在翻转状态下画,文字标注统一放到restore之后画,两侧坐标都是像素坐标,这样文字永远正着显示。

网格绘制照着那两张表来写就行:

void SmithChartWidget::drawGrid(QPainter& painter, const QPointF& center, double R) { // 等电阻圆,蓝色虚线 QColor rColor(30, 80, 200, 120); painter.setPen(QPen(rColor, 1.0, Qt::DotLine)); for (double r : {0.0, 0.2, 0.5, 1.0, 2.0, 5.0}) { double cx = r / (r + 1.0); double cy = 0.0; double radius = 1.0 / (r + 1.0); painter.drawEllipse(QPointF(center.x() + cx * R, center.y() + cy * R), radius * R, radius * R); } // 等电抗圆,红色虚线,注意区分正负电抗 QColor xColor(200, 50, 50, 120); painter.setPen(QPen(xColor, 1.0, Qt::DashLine)); for (double x : {0.2, 0.5, 1.0, 2.0, 5.0, -0.2, -0.5, -1.0, -2.0, -5.0}) { double cx = 1.0; double cy = 1.0 / x; double radius = std::abs(1.0 / x); painter.drawEllipse(QPointF(center.x() + cx * R, center.y() + cy * R), radius * R, radius * R); } }

可以看到,翻转坐标后的代码非常直观,几乎就是在照着数学公式写。等电抗圆没有裁剪的话,x=0.2时半径高达5R,画出来的弧线会跑出控件很远,所以绘制网格前一定要把绘制区域裁剪到单位圆内部。我通常这样加裁剪:

QPainterPath clipPath; clipPath.addEllipse(center, R, R); painter.setClipPath(clipPath);

裁剪要加在drawGrid里,但只对网格生效,数据轨迹我有时反而希望保留单位圆外的可见性,方便排查异常数据,所以一般把裁剪限制在网格绘制的小作用域里。

数据轨迹的绘制反而不复杂,把每个Γ点直接映射成逻辑坐标,丢进QPolygonF之后一次drawPolyline:

void SmithChartWidget::drawTrace(QPainter& painter, const QPointF& center, double R) { if (m_gammaPoints.isEmpty()) return; QPolygonF poly; poly.reserve(m_gammaPoints.size()); for (const auto& gamma : m_gammaPoints) { poly << QPointF(center.x() + gamma.real() * R, center.y() + gamma.imag() * R); } painter.setPen(QPen(QColor(0, 160, 0, 220), 2.0)); painter.drawPolyline(poly); // 起点和终点用不同颜色做标记 if (!poly.isEmpty()) { painter.setBrush(Qt::green); painter.drawEllipse(poly.first(), 5, 5); painter.setBrush(Qt::red); painter.drawEllipse(poly.last(), 5, 5); } }

很多初学者会在这里写循环然后逐个drawPoint,数据少时无所谓,数据一多性能就会很难看。用QPolygonF合并成一条多段线,是QPainter绘制大量连续点的标准做法。

5. 让圆图“活”起来:数据接入、实时刷新与交互操作

只有静态网格和一条静态曲线,圆图只是张图片。真正让它变成调试工具的是数据接入和交互。

最常见的输入数据源是Touchstone文件。网络分析仪导出的S1P文件里,S11本来就是反射系数,直接作为Γ点即可。我写了一个简化版解析函数:

bool loadTouchstoneS1P(const QString& filePath, QVector<double>& frequencies, QVector<std::complex<double>>& gamma) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return false; QTextStream in(&file); while (!in.atEnd()) { QString line = in.readLine().trimmed(); if (line.isEmpty() || line.startsWith('!') || line.startsWith('#')) continue; QStringList parts = line.split(QRegularExpression("\\s+")); if (parts.size() < 3) continue; double freq = parts[0].toDouble(); double mag = parts[1].toDouble(); double ang = parts[2].toDouble(); // 单位是度 double rad = qDegreesToRadians(ang); gamma.append(std::polar(mag, rad)); frequencies.append(freq); } return true; }

注意S1P文件头部的#行里可能标注了频率单位(Hz/kHz/MHz)和数据格式(DB/MA/RI),实际工程代码需要解析这个字段。上面这段只是让你先把流程跑通。

如果你是做嵌入式或硬件联调,数据一般走串口。我的做法是用QSerialPort接收字节流,按换行符拆帧,解析出频点和S11复数后,通过信号槽提交到主线程,调用setReflectionData然后update()。刷新频率控制在10-20Hz就够了,圆图变化本身是平滑的,没必要用太快的定时器去刷UI。

交互方面,我实现了鼠标悬停显示阻抗值和滚轮缩放两个基本功能。滚轮缩放要注意缩放中心,最简做法是以圆心为缩放中心:

void SmithChartWidget::wheelEvent(QWheelEvent *event) { double factor = event->angleDelta().y() > 0 ? 1.2 : 1.0 / 1.2; m_zoomLevel = std::clamp(m_zoomLevel * factor, 1.0, 20.0); update(); }

鼠标悬停时把像素坐标反算回Γ,再换算成阻抗,用QToolTip或状态栏显示:

void SmithChartWidget::mouseMoveEvent(QMouseEvent *event) { if (!this->rect().contains(event->pos())) return; QPointF p = event->pos(); double baseR = std::min(width(), height()) / 2.0 * 0.98; double px = (p.x() - width() / 2.0) / baseR; double py = (height() / 2.0 - p.y()) / baseR; // 注意Y轴反转 std::complex<double> gamma(px, py); std::complex<double> z = gammaToZ(gamma); setToolTip(QString("Γ=%1, %2 Z=%3%4Ω") .arg(gamma.real(), 0, 'f', 3) .arg(gamma.imag(), 0, 'f', 3) .arg(z.real(), 0, 'f', 1) .arg(z.imag() >= 0 ? "+j" : "-j") .arg(std::abs(z.imag()), 0, 'f', 1)); update(); }

需要注意:由于painter翻转了Y轴,手动把像素坐标换算回数学坐标时,Y方向也要手动取反,否则悬停显示的位置永远上下颠倒。

如果你的工具还需要显示时域波形和频谱,圆图控件旁边的QCustomPlot依然可以发挥价值。我之前在同一个面板里,左边放QCustomPlot显示时域波形,右边放自己写的SmithChartWidget显示阻抗轨迹,中间用kissfft做时域到频域转换。这样的结构下,每个控件只负责自己最擅长的一块,代码之间通过数据模型解耦,改起来很轻松。

6. 我踩过的几个坑与审查清单

这部分记录下来,是因为每一个坑都实际花掉过我几个小时。

第一个是Y轴方向导致的圆图上下镜像。这个问题在翻转Y轴时很容易被掩盖,但悬停显示那里如果你忘了手动取反,出来的数据就是错的。排查办法非常简单:鼠标放到单位圆上半平面,正常应该显示正电抗,如果显示负电抗,说明Y轴转换出问题了。

第二个是QPainter绘制大半径圆时的精度问题。等电抗圆x=0.2时半径是5R,用drawEllipse传QRectF时,矩形尺寸会非常大,个别情况下圆会渲染出明显的棱角甚至残缺。我的解决办法是绘制网格前先裁剪到单位圆内,此外把抗锯齿打开,视觉上会好很多。

第三个是文本镜像问题。翻转坐标系下drawText画出的字全部左右颠倒。曾经有段时间我为了让文字不镜像,在每个绘图状态下反复切换坐标变换,代码又乱又容易错。后来改成“几何图形在翻转态绘制,文字统一回正常坐标系绘制”这个约定,问题彻底消失。

第四个是曲线超出单位圆时的表现。反射系数的模应当小于等于1,但实际测量数据里经常混入噪声点,或者某些有源电路的|Γ|大于1,导致曲线尾巴伸到圆外。如果不做处理,圆图边界会有意料之外的长线,容易误导判断。我现在提供了开关,默认把轨迹裁剪到单位圆内,排查异常时可以临时关闭裁剪观察原始数据。

第五个是缩放后线宽和网格密度的问题。鼠标滚轮把网格放大后,如果网格线条数量不变,视觉上会显得特别疏。我暂时没有做动态增删网格线,而是把缩放上限控制在20倍,实测对绝大多数调试场景都够用。如果你确实需要深度缩放,可以考虑根据缩放级别动态选择要显示的电阻/电抗值集合。

最后检查清单列在这里,照着自查一遍基本不会翻车:

  • 网格线画完是否裁剪到单位圆内?
  • 悬停显示时Y轴是否手动翻转?
  • 文本绘制是否放在翻转坐标系之外?
  • 大半径电抗圆是否出现渲染残破?
  • 数据点超界时曲线是否具备可控的裁剪行为?
  • mouseTracking是否打开?没打开的话mouseMoveEvent不会持续触发。

做完这个控件后,我最大的感受是:史密斯圆图这种非常规坐标系的图形,真正难的从来不是Qt本身,而是动手之前有没有把数学关系理清楚。等电阻圆和等电抗圆的圆心、半径一旦写在纸上,后面所有代码都只是把公式翻译成QPainter调用而已。希望这些源码思路能帮你节省掉我当初踩坑的时间。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询