Qt绘制Smith圆图实战:从坐标系到S参数轨迹
简介面向射频和微波电路设计者这份资源提供了一个基于Qt 5.11.3与QPainter的史密斯圆图绘制小程序源码。程序能够读取txt文件中一连串复阻抗数据经过字符串拆分、浮点转换与坐标变换后在圆图上标出对应阻抗点辅助完成阻抗匹配分析。压缩包内含5个文件2个cpp分别实现主窗口逻辑与SmithChartWidget自定义控件1个pro管理构建配置1个h声明数据读取和绘制接口1个ui定义窗口布局整体仅6KB结构紧凑包含完整工程所需要素适合快速研读和二次开发。目前已有821人学习下载。通过源码可以深入学习QFile与QTextStream读取文本、复数值到史密斯圆图坐标的几何换算、drawLine/drawCircle/drawText等绘制接口的使用以及重写paintEvent、结合菜单和界面交互刷新图形的完整模式。无论是想掌握Qt 2D图形绘制还是需要搭建射频阻抗可视化小工具这份代码都能提供直观的参考起点。1. 为什么自己用 Qt 画一张 Smith 圆图而不是继续用现成工具自己动手用 Qt 绘制 Smith chart 源码这件事看起来有点“玄学”但真做成了你的射频调试工具链就再也不用被现成控件绑住。这套源码的核心是用 QPainter 从零画出阻抗圆图加上鼠标交互能直接作为一个 QWidget 小部件拖进你的 Qt 工程画等电阻圆、等电抗圆再把 S1P/S2P 扫频数据或某个阻抗点叠加上去。适合手里有 Qt 项目、又不想为了看匹配轨迹再开一个专用软件的开发者也适合想搞清楚圆图背后坐标变换的学生和工程师。下面从坐标系开始把每一行代码拆开讲。2. 把反射系数变成像素坐标系变换与圆的参数方程2.1 为什么这张图建立在“单位圆”上Smith 圆图本质上画的是反射系数复平面 Γ所有能落到图上的阻抗点都必须落在单位圆内。归一化阻抗 zZ/Z0rjx 与反射系数的关系是Γ(z−1)/(z1)z(1Γ)/(1−Γ)Z0 通常取 50Ω。这个映射有意思的地方在于等电阻 r 的轨迹在 Γ 平面上是一个圆等电抗 x 的轨迹也是一个圆只是圆心不在原点。等电阻圆的圆心是 (r/(r1), 0)半径是 1/(r1)。等电抗圆的圆心是 (1, 1/x)半径是 1/|x|。注意这里所有坐标都是 Γ 平面的实部、虚部数值范围在 −1 到 1 之间。把这个数学关系吃透后面的 QPainter 绘制就只是把一个数学平面映射到画布坐标而已。实际做 QPainter 绘制时必须先固定“单位长度”的像素值。很多人第一次画出来圆变成椭圆就是因为横纵两个方向的缩放系数不一致。屏幕坐标系的 x 向右、y 向下而 Γ 平面的虚部习惯向上这意味着纵轴要做一次翻转。这些细节都在下面这段代码里体现。2.2 坐标系换算先算好画布几何再谈画图我把控件做成了一个可复用的SmithChartWidget核心成员只有三个圆心、缩放系数和指示数据是否就绪的 flag。缩放系数在resizeEvent里计算而不是在paintEvent里临时算这样鼠标交互与绘制共用同一套几何不会出现“画在 A 处、取点在 B 处”的错位。// SmithChartWidget.h #include QWidget #include QPointF class SmithChartWidget : public QWidget { Q_OBJECT public: explicit SmithChartWidget(QWidget* parent nullptr); protected: void paintEvent(QPaintEvent* event) override; void resizeEvent(QResizeEvent* event) override; private: QPointF gammaToPixel(const QPointF gamma) const; QPointF pixelToGamma(const QPointF pos) const; QPointF m_center; // 单位圆圆心在画布上的位置 double m_scale 1.0; // 单位反射系数对应的像素数 };resizeEvent 里只做一件事保证坐标系不变形// SmithChartWidget.cpp void SmithChartWidget::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); // 留出大约 5% 的边距给外圈刻度取短边作为可用直径 double side qMin(width(), height()) * 0.95; // 单位圆在 Γ 平面上的直径是 2实部从 -1 到 1 m_scale side / 2.0; m_center rect().center(); }说明m_scale的含义是“单位反射系数等于多少像素”。如果窗口短边是 800 像素缩放后圆的直径约 760 像素m_scale约为 380。这个参数是绘制与反算共同的基准。画布中心m_center对应的正是 Γ0 的点也就是 50Ω 阻抗点。接着是正变换函数QPointF SmithChartWidget::gammaToPixel(const QPointF gamma) const { return QPointF(m_center.x() gamma.x() * m_scale, m_center.y() - gamma.y() * m_scale); }参数说明gamma.x()是反射系数实部gamma.y()是虚部。屏幕 y 向下所以虚部取减号。在 Qt 里如果不做这一步翻转等电抗圆的圆弧方向会镜像50Ω 点仍然在中心但感性和容性区域会左右颠倒。2.3 等电阻圆与等电抗圆别硬算圆弧先画圆再裁剪等电阻圆是完整圆可以直接drawEllipse。等电抗圆在 Γ 平面上也是完整圆但只有落在单位圆内部的那一段才是圆图上可见的圆弧。常见做法是硬调drawArc的角度我建议反过来先构造单位圆裁剪路径然后直接画整圆让裁剪把圆图外部切掉。这样代码简洁而且永远不会有圆弧端点接不上的问题。void SmithChartWidget::drawPolarGrid(QPainter painter) { painter.save(); // 单位圆边界作为裁剪区域所有等电阻圆/等电抗圆都不会画到圆外 QPainterPath clipPath; clipPath.addEllipse(m_center, m_scale, m_scale); painter.setClipPath(clipPath); QPen gridPen(QColor(0, 0, 0, 60), 1.0); gridPen.setCosmetic(true); // 缩放窗口时线宽不跟随变化 painter.setPen(gridPen); // 等电阻圆r 0, 0.2, 0.5, 1.0, 2.0, 5.0 for (double r : {0.0, 0.2, 0.5, 1.0, 2.0, 5.0}) { double cx r / (r 1.0); // Γ 平面上的圆心 x double radius 1.0 / (r 1.0); // Γ 平面上的半径 QPointF center gammaToPixel(QPointF(cx, 0.0)); painter.drawEllipse(center, radius * m_scale, radius * m_scale); } painter.restore(); }逻辑说明等电阻圆全部与实轴相切于 Γ1 这个点。r0 时圆心在原点、半径也是 1正好是单位圆本身。setCosmetic(true)是可选的但我在实际工程里几乎都会加否则窗口拖大时网格线跟着变粗界面质感会下降。等电抗圆的画法类似只是圆心不在实轴上void SmithChartWidget::drawReactanceArcs(QPainter painter) { painter.save(); QPainterPath clipPath; clipPath.addEllipse(m_center, m_scale, m_scale); painter.setClipPath(clipPath); QPen arcPen(QColor(0, 0, 0, 80), 1.0); arcPen.setCosmetic(true); painter.setPen(arcPen); for (double x : {0.2, 0.5, 1.0, 2.0}) { double cy 1.0 / x; double radius 1.0 / x; // 正电抗感性圆心在单位圆上方屏幕 y 方向上 QPointF centerUp(m_center.x() m_scale, m_center.y() - cy * m_scale); painter.drawEllipse(centerUp, radius * m_scale, radius * m_scale); // 负电抗容性圆心在单位圆下方屏幕 y 方向下 QPointF centerDown(m_center.x() m_scale, m_center.y() cy * m_scale); painter.drawEllipse(centerDown, radius * m_scale, radius * m_scale); } painter.restore(); }参数对照表参数圆心Γ 平面半径图上特征r0(0, 0)1单位圆边界最外圈r1(0.5, 0)0.5经过圆心50Ω 线r2(0.667, 0)0.333靠近右侧短路点x1(1, 1)1经过 (0, 0) 与 (1, 1) 的圆弧x−1(1, −1)1下半平面对称圆弧裁剪路径用save/restore包住否则会影响后面数据轨迹的绘制。这里有个隐蔽问题setClipPath设置的是一个像素级的路径依赖当前的 painter 状态所以裁剪完记得恢复。3. 交互式取值把鼠标坐标反算成阻抗3.1 反变换别漏掉那个负号绘图是 Γ→像素交互取值是像素→Γ→阻抗。反变换代码只有三行但方向错误会让整个交互废掉QPointF SmithChartWidget::pixelToGamma(const QPointF pos) const { QPointF gamma((pos.x() - m_center.x()) / m_scale, (m_center.y() - pos.y()) / m_scale); return gamma; }注意m_center.y() - pos.y()因为屏幕 y 向下鼠标在中心下方时虚部为正对应感性区。然后由 Γ 求归一化阻抗#include complex std::complexdouble gammaToZ(std::complexdouble gamma) { if (std::abs(gamma) 0.999999) { return {1e9, 1e9}; // 靠近短路/开路点时给个极大值避免除零 } return (1.0 gamma) / (1.0 - gamma); }鼠标移动时实时算 VSWR 和 r、xvoid SmithChartWidget::mouseMoveEvent(QMouseEvent* event) { if (!(event-buttons() Qt::NoButton)) return; QPointF g pixelToGamma(event-pos()); double rho std::sqrt(g.x() * g.x() g.y() * g.y()); if (rho 1.0) return; // 鼠标在圆外不刷新读数 std::complexdouble z gammaToZ({g.x(), g.y()}); double vswr (1.0 rho) / (1.0 - rho); // 更新状态栏或者某个 QLabel emit cursorMoved(z.real(), z.imag(), rho, vswr); }这里emit信号比直接改 UI 好圆图控件不需要知道外面接的是 QLabel 还是表格保持单职责。3.2 把等 VSWR 圆与 1/4 波长匹配一起做进去在圆图上画一个给定 VSWR 的圆相当于画固定 |Γ| 的轨迹void SmithChartWidget::drawVSWRMarker(QPainter painter, double vswr) { double rho (vswr - 1.0) / (vswr 1.0); if (rho 0.0 || rho 1.0) return; painter.save(); QPen pen(QColor(0, 150, 0), 1.2); pen.setStyle(Qt::DashLine); painter.setPen(pen); painter.drawEllipse(m_center, rho * m_scale, rho * m_scale); painter.restore(); }常见用法是配合“1/4 波长阻抗变换”做验证把某个阻抗点绕圆心旋转 180°就得到变换后的阻抗。绕圆心旋转 180°在 Γ 平面上等价于 Γ→−Γ实现上连三角函数都不用直接改符号。我一般会在交互区放一个提示选中一个数据点后显示原始点与变换后点的坐标。3.3 事件与局部刷新不要把 paintEvent 变成性能黑洞开启setMouseTracking(true)后鼠标不按键移动也会触发mouseMoveEvent。如果每次移动都调用update()全量重绘数据点很多时会有可见卡顿。更合适的做法是分区域刷新void SmithChartWidget::mouseMoveEvent(QMouseEvent* event) { // ... 计算阻抗 ... // 只需要刷新十字线/读数提示所在的区域 QRect dirty QRect(event-pos() - QPoint(20, 20), QSize(40, 40)); update(dirty); }update(rect)会让窗口系统只重绘该区域。但要注意如果网格线没有用setCosmetic(true)局部刷新会留下残影因为底色不是纯色的。建议把网格先绘制到一个离屏 QPixmap数据层交互时才局部更新。这是老做法但有效。离屏绘制的代码大致是QPixmap gridCache; void SmithChartWidget::rebuildGridCache() { gridCache QPixmap(size()); gridCache.fill(Qt::white); QPainter p(gridCache); p.setRenderHint(QPainter::Antialiasing); drawPolarGrid(p); drawReactanceArcs(p); } void SmithChartWidget::paintEvent(QPaintEvent* event) { QPainter painter(this); painter.drawPixmap(0, 0, gridCache); // 之后只画数据线、标记点 }每次 resize 后重建一次gridCache鼠标移动只刷数据层。数据点上千个时这能省掉大量网格重绘开销。4. 让 S 参数滚到圆图上数据解析与轨迹管理4.1 Touchstone 文件解析S1P 的两种常见格式工程里最常碰到的是 Touchstone.s1p文件。第一行以!开头是注释以#开头是关键字行指明频率单位、数据格式和参考阻抗。常见格式有两种格式关键字数据列含义备注# GHZ S RI R 50freq, Re(S11), Im(S11)直角坐标直接用# GHZ S MA R 50freq, Mag(S11), Ang(S11)幅度角度角度单位度批量解析时不要逐行猜格式先扫描#行确定是 RI 还是 MA。#include QFile #include QTextStream #include QVector #include QPointF #include cmath struct S1PData { QVectordouble freqGHz; QVectorQPointF gamma; // 反射系数实部虚部 }; bool loadS1P(const QString path, S1PData out) { QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return false; QTextStream in(file); bool magPhase false; while (!in.atEnd()) { QString line in.readLine().trimmed(); if (line.isEmpty()) continue; if (line.startsWith(!)) continue; // 注释 if (line.startsWith(#)) { // 简单解析# GHZ S MA R 50 或 # MHZ S RI R 50 QStringList parts line.split(QRegExp([\\s]), Qt::SkipEmptyParts); if (parts.size() 5) magPhase (parts.at(3) MA); continue; } QStringList fields line.split(QRegExp([\\s,]), Qt::SkipEmptyParts); if (fields.size() 3) continue; double freq fields.at(0).toDouble(); double re, im; if (magPhase) { double mag fields.at(1).toDouble(); double angDeg fields.at(2).toDouble(); double rad angDeg * M_PI / 180.0; re mag * std::cos(rad); im mag * std::sin(rad); } else { re fields.at(1).toDouble(); im fields.at(2).toDouble(); } out.freqGHz.push_back(freq); out.gamma.push_back(QPointF(re, im)); } return out.gamma.size() 0; }#行的MA与RI判断是以空格分隔的第四段。如果第 4 章避坑部分还没有讲到这里先提醒一个容易翻车的地方很多文件头写的是# MHZ S RI R 50频率单位是 MHz代码里若不做单位换算数据全会落到低频角落。可以约定文件解析后统一转成 GHz以第一行#里的GHZ或MHZ为准。4.2 轨迹绘制与多数据序列管理S 参数数据点通常不是几十个而是几千个QPainter 画折线时直接用drawPolyline比循环drawLine更快。颜色管理我习惯用一个固定色表而不是随机色保证多轨迹对比时观感稳定static const QColor kTraceColors[] { QColor(0, 0, 180), QColor(200, 0, 0), QColor(0, 120, 0), QColor(160, 80, 0), }; void SmithChartWidget::drawTrace(QPainter painter, const QVectorQPointF gammaPts, QColor color, bool isS11) { QVectorQPointF pixels; pixels.reserve(gammaPts.size()); for (const QPointF g : gammaPts) pixels.append(gammaToPixel(g)); painter.save(); QPen pen(color, 2.0); pen.setCosmetic(true); painter.setPen(pen); painter.drawPolyline(pixels.constData(), pixels.size()); painter.restore(); }说明drawPolyline接受的是QPointF*和点数先把所有数学坐标一次性映射成像素坐标再让 QPainter 画线避免在绘制循环里做除法。setCosmetic(true)保证数据线不会因为窗口缩放变粗。多文件加载用拖放最方便void SmithChartWidget::dragEnterEvent(QDragEnterEvent* event) { if (event-mimeData()-hasUrls()) event-acceptProposedAction(); } void SmithChartWidget::dropEvent(QDropEvent* event) { for (const QUrl url : event-mimeData()-urls()) { S1PData data; if (loadS1P(url.toLocalFile(), data)) { m_traces.append(data); update(); } } }4.3 图例与标记点绘制多轨迹叠加后图例必须画在圆图之外或者角落。圆图内部本身信息密度很高我把图例放在左上角外圈并绘制成带背景的半透明圆角矩形。QPainter 画带背景的文本块时用QFontMetrics先量宽度void SmithChartWidget::drawLegend(QPainter painter) { QFontMetrics fm(painter.font()); int lineH fm.height() 6; int y 10; for (int i 0; i m_traces.size(); i) { QString text QString(Trace %1 %2 - %3 GHz) .arg(i 1) .arg(m_traces.at(i).freqGHz.first()) .arg(m_traces.at(i).freqGHz.last()); QRect bg(10, y 2, fm.horizontalAdvance(text) 12, lineH); painter.fillRect(bg, QColor(255, 255, 255, 200)); QColor c kTraceColors[i % 4]; painter.setPen(c); painter.drawText(bg.adjusted(8, 0, 0, 0), Qt::AlignVCenter, text); y lineH; } }标记点绘制在第六节细说但这里先提一个规则数据点靠近鼠标时只画最近一个点不要把所有点都放大。否则凑近看时屏幕上全是圈。5. Smith 圆图绘制的五个经典坑避坑与排查清单5.1 圆变成椭圆坐标缩放没保持等比现象等电阻圆在窗口宽度大于高度时变成横向椭圆窗口拉伸后更明显。原因绘制时把 Γ 平面 x、y 方向分别按width()和height()缩放两个方向的像素密度不一致。解决以短边为基准计算同一个m_scale并让圆心等于矩形中心处理方式见 2.2 节。如果一定要留边距边距要加在计算side之前而不是加在m_center上。验证方法画完 r1 的圆后把窗口拖成 1000×500量一下圆直径在横纵方向是否一致。5.2 等电抗圆弧端点接不上或整段消失现象x0.2 的弧应该靠近右端短路点但画出来要么缺一截要么贯穿整个圆图。原因一种是手算drawArc的起始角与跨度角度单位搞错另一种是在没有裁剪路径的情况下画了完整圆把单位圆外的线条也画了出来。解决我最后选择了“画整圆 单位圆裁剪路径”。裁剪路径需要在save/restore之间生效否则会影响后面的数据线。如果你对圆弧包络很有把握也可以用QPainterPath::arcTo但我自己的经验是裁剪方案在各种 r、x 组合下都不会出现端点跳动。5.3 高频数据点“飞线”从圆图一侧直线穿到另一侧现象扫频数据画的折线在单位圆边缘附近出现一条很长的直线把左右两个区域连起来。原因反射系数接近 1 时相位随频率快速变化相邻两个频点的 Γ 可能一个在 170°一个在 −170°直接连线就是一条穿过圆图内部的直线。这不是数据错误是相位缠绕。解决绘制折线前先检查相邻点距离若距离超过 1.5 倍圆半径就认为应该断开路径void drawTraceDetectJump(QPainter painter, const QVectorQPointF gammaPts) { QPainterPath path; bool first true; for (const QPointF g : gammaPts) { QPointF px m_center QPointF(g.x() * m_scale, -g.y() * m_scale); if (first) { path.moveTo(px); first false; } else if (QLineF(prevPx, px).length() 1.5 * m_scale) { path.moveTo(px); // 跨越大圆边界重新起笔 } else { path.lineTo(px); } prevPx px; } painter.drawPath(path); }阈值 1.5 倍半径不是凭空的正常扫频点间距较小Γ 轨迹相邻点距离通常远小于半径一旦超过 1.5 倍就说明相位翻转了。5.4 resize 后圆图缩放但交互取值全部漂移现象窗口拉大后鼠标点到某个位置显示的阻抗与原来完全不同。原因resizeEvent里更新了m_scale但mouseMoveEvent里用的还是旧的几何参数。尤其当数据轨迹已经映射成像素坐标缓存后坐标系一变缓存全部失效。解决把m_center、m_scale的计算抽成recalcGeometry()在resizeEvent和任何updateGeometry()调用后强制重跑并同时清掉网格缓存。5.5 高清屏下圆和文字发虚现象在 Retina 或 125% 缩放的 Windows 显示下圆边界有锯齿文字模糊。原因Qt 默认没有为高分屏启用像素密度感知也没有开启抗锯齿渲染。解决在构造函数里设置setAttribute(Qt::WA_EnableHighDpiScaling)在paintEvent里统一开启抗锯齿painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::TextAntialiasing, true);如果某些线条需要像素级对齐可以单独关闭抗锯齿但网格线建议总是开着。6. 让这张圆图真正进工程导出矢量图与刻度贴合6.1 用 QSvgGenerator 导出 SVG交付报告或写技术文档时截图不够尤其缩印后全是糊点。Qt 自带 SVG 模块导出圆图为矢量图只需把绘制逻辑抽到一个公共入口bool exportToSvg(const QString path, SmithChartWidget* widget) { QSvgGenerator generator; generator.setFileName(path); generator.setSize(QSize(1024, 1024)); generator.setViewBox(QRect(0, 0, 1024, 1024)); QPainter painter(generator); painter.setRenderHint(QPainter::Antialiasing, true); // 关键让绘制例程不依赖窗口的 m_scale // 而是临时套用新的几何参数 widget-drawScene(painter, 1024); painter.end(); return true; }drawScene内部把网格、数据、图例全画一遍窗口上的paintEvent也调用它。这样可以保证导出的 SVG 和屏幕上看到的完全一致不存在“屏幕好好的导出后少一块”的问题。6.2 刻度与频率标注的贴合圆图外圈通常是角度刻度但射频工程师更关心的是频点附近标注。我习惯在 hover 时把最近采样点的频率画到轨迹旁字号比图例小一号。频率字符串用%1 GHz格式化避免显示 1.4999999 这种数字。6.3 最后的一点习惯有一次交付某跨平台系统屏幕上数据轨迹、网格一切正常导出的 SVG 里等电阻圆却全变成椭圆。查了半天发现是drawScene被单独调用时没有重新计算几何直接沿用了窗口的m_scale而屏幕绘制前又在resizeEvent里重新算了一次。从那以后我强制把“几何计算”和“绘制”拆成两段任何导出入口都必须先走一遍recalcGeometry()再进入drawScene。这套 Qt Smith chart 源码的做法也是同样的结构你拿过去改交互、加 S2P 差分参数都不需要动底层绘制。希望帮到你。本文还有配套的精品资源点击获取