news 2026/9/17 19:58:20

Serial Studio 级联包络环(Cascading Envelope Rings)解析:时间轴绘图历史数据的分层降采样金字塔

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Serial Studio 级联包络环(Cascading Envelope Rings)解析:时间轴绘图历史数据的分层降采样金字塔

Serial Studio 级联包络环(Cascading Envelope Rings)解析:时间轴绘图历史数据的分层降采样金字塔

【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio

Serial Studio 的时间轴(Time-Axis)绘图把历史数据保存在一个有界的(time, value)环形缓冲中,并在摄取时按绝对时间网格做 min/max 包络降采样。本指南以规格文档 doc/claude/specs/0057-cascading-envelope-rings/ 的 plan/spec/tasks/report 四份文档为核心,讲解该项改进引入的DSP::EnvelopeRing级联包络环:在原有TimeRing之上叠加 16 倍逐级粗化的包络金字塔,让宽时间窗口的渲染成本从 O(窗口中样本数) 降到 O(像素数),同时完整保留 level 0 的细节历史。读完本文,你将理解金字塔的写入/读取路径、层级选择算法、内存开销数学、设计权衡与对应的 ctest 验证套件。

背景与动机:为什么"单层绝对时间网格"不够用

时间轴绘图的历史数据保存在一个有界的(time, value)环形缓冲里,摄取时在单条绝对时间网格上做降采样(每个网格单元保留一个 min/max 包络对)。从"只有一张网格"这个事实出发,spec.md归纳出两个固有问题:

  1. 渲染成本随环的规模缩放,而不是随屏幕缩放。每次绘制都要遍历可见窗口内所有保留样本,再重新分桶到像素列。以 44.1 kHz 的流式(stream-lane)源为例,一个 12.5 s 窗口的环约有 55 万个槽位(在字节上限处最多可达 400 万);一个 1000 像素的图以 60 Hz 刷新,每条曲线每秒要遍历全部样本 60 次。这正是环容量上限(ceiling)和 drain 预算存在的原因,也是宽窗口 + 高密度源"感觉沉重"的根源。
  2. 网格分辨率必须在摄取时根据源速率猜测。细节保留粒度在摄取那一刻就被固定:网格比源粗,细节永远丢失;网格比内存预算细,又无法覆盖整个窗口。streamRingCapacitygrowTimeRing/resizeCapacitykMaxRateSizedRingSamples这些机制都是为了在布局时或显示 tick 上"猜"一个合适的粒度。

本方案借鉴示波器/逻辑分析仪查看器的思路:构建一个min/max 包络层级金字塔,每层比下一层粗 16 倍,增量追加,渲染器选择"单元格仍细于一个像素"的最粗层级,只读取可见跨度。由于 Serial Studio 可能连续运行数天,每一层都必须是有限环,绝不能是无界追加日志

总体设计:EnvelopeRing = 原 TimeRing + 16 倍粗化包络层

plan.md给出了一个关键决策:金字塔作为一个包含TimeRing的新结构体存在(而非改造TimeRing本身),这样 level 0 与今天的环逐字节一致(行为、尺寸都不变),扫描/触发(sweep)引擎可以继续原样使用普通TimeRing。仓库中对应实现在 core/Pipeline/DSP.h(规格文档写作时路径为app/src/DSP.h,现已迁移到core/Pipeline/):

  • EnvelopeCell(DSP.h#L565):单个包络单元,{t0, v0, t1, v1}四个 double(32 字节),按时间顺序存放两个极值,因此整层可以读成一条单调的(time, value)序列,长度恰好是2 * cells——这正是现有列累加器消费的数据形状。
  • EnvelopeLevel(DSP.h#L577):一个粗化层 = 一个FixedQueue<EnvelopeCell>有界环 +openIndex(该层"开口单元"的索引,即环的 back)+shift(= 4k,见下)。openValid标记是否已有开口单元。
  • EnvelopeRing(DSP.h#L597):金字塔本体,字段为level0(原有TimeRing)、std::vector<EnvelopeLevel> levelsopenCell/openCellValid(当前开口 level-0 单元的索引)。常量kLevelShift = 4kMaxCoarseLevels = 9kMinLevelCells = 3

层级几何与"单元身份"

level k 的每个单元覆盖16^k个 level-0 网格单元。单元身份不是浮点边界时间,而是level-0 单元索引的整数右移cellIndex(t) = floor(t / interval),粗化层归属cellIndex(t) >> 4kplan.md特别强调这样做的三个好处:网格精确嵌套、环绕(wraparound)一致性天然成立、完全不依赖浮点边界取整,且可以用整数算术做可测试性验证。selectLevel里正是用cellIndex(front.t0) >> shiftoldestId >> shift比较来判断覆盖范围。

内存开销:粗化层合计仅 1.067 倍

spec.md给出了精确的字节账:level 0 有C0个槽位(每槽 16 字节,饱和网格cells0 = C0/2个单元);level k 持有ceil(cells0 / 16^k) + 1个 32 字节单元。全部粗化层合计:

32 × (C0/2) × (1/16 + 1/256 + …) = 16 × C0 / 15 ≈ 1.067 × C0 的字节

spec.md中给出了验证数字:C0 = 262144时 level 0 为 4.19 MB、粗化层 0.28 MB(共 5 层);C0 = 4M时为 67.1 MB + 4.47 MB(共 6 层);C0 = 1024时共 3 层、1.070 倍。层级在"某层容纳不足两个真实单元"时停止增长,最多 9 个粗化层(加上 level 0 共 10 层)。这一点也被测试套件固定下来:tst_envelope_ringring(4096, 1.0)得到coarseLevelCount() == 2,且断言coarseBytes * 14 <= level0Bytes(见 app/tests/tst_envelope_ring.cpp#L200-L208)。

写入路径:append 的增量维护,从不重扫

上游数据流不变

plan.md明确:Dashboard上游完全不动。数据块在显示 tick 到达(applyBlock);帧车道(frame lane)按不规则样本喂给updateLineSeries/feedMultiRings;流车道(stream lane)按均匀网格列喂给applyBlockColumn;回放 seek 喂给fillSeekPlot*。所有这些入口调用环的 append,其余不变。整条链路都在GUI 线程、单写者、无锁——与今天TimeRing的纪律完全相同。

opensCell:一行谓词,让金字塔与 level 0 天然对齐

TimeRing新增一行谓词opensCell(t)(DSP.h#L474-L477):

[[nodiscard]] bool opensCell(double t) const noexcept { return time.size() == 0 || t >= nextEmit; }

appendDecimated原本就在内联地评估这个条件,现在把它提成公共谓词,"这个样本是否开启一个新的 level-0 单元"对金字塔和 level 0 而言由构造保证一致(这是 tasks.md 中的 T1)。

EnvelopeRing::appendDecimated 主流程

虽然plan.md里写作append(t, v),但 report.md 记录了一个落地细节:仓库的 lint 会把热路径翻译单元(TU)上的.append(当作 Qt 容器分配来标记,因此金字塔保留了 level 0 的方法名appendDecimated。实现见 DSP.h#L706-L733:

  1. 消毒(sanitise):拒绝任何非有限(NaN/Inf)的时间或值,在任何层级被触碰之前就返回——与 level 0 现状一致(需求 R6)。时间回退小于一个interval时钳到最新样本,跨过整个单元则视为时钟重启并clear()
  2. 检测新单元:若level0.opensCell(t)为真([[unlikely]]分支),先把刚完成的 level-0 单元折叠进所有粗化层——level0.acc*累加器恰好就是这个单元的极值——再记录新的openCell = cellIndex(t)
  3. 委托:调用level0.appendDecimated(t, v),与今天完全相同的降采样逻辑。

折叠规则:一次完成、摊还 O(1)

折叠核心是foldOpenCellfoldCell(DSP.h#L758-L788):

void foldCell(std::int64_t index0, const EnvelopeCell& cell) { for (auto& level : levels) { const std::int64_t index = index0 >> level.shift; if (level.openValid && index == level.openIndex && level.cells.size() > 0) { mergeCell(level.cells[level.cells.size() - 1], cell); // 同一开口单元:合并极值 continue; } level.cells.push(cell); // 新开口单元:直接压入 level.openIndex = index; level.openValid = true; } }

mergeCell(DSP.h#L793-L822)把新单元的最小/最大极值并入目标单元,并保持{t0,v0,t1,v1}时间有序。之所以是"完成的 level-0 单元折叠进每一个更粗的开口单元"而不是"k 层完成才折叠进 k+1 层",plan.md的权衡表说明得很清楚:后者会让 level k 滞后最多一个 level-(k-1) 的跨度(在 level 5 处可达秒级),粗化渲染会缺最新数据;前者让每一层都"最新"到不超过一个 level-0 单元的误差,代价只是每个 level-0 单元完成时做一次**有界(≤ 9 次)**的合并循环,摊还仍是 O(1)。

无分配、无拷贝

append 路径零分配:粗化层在构造/重建时一次定型,push只是"普通存储 + 索引自增";不复制FrameopensCell分支是预测不命中的单分支。这是 report.md 里"counterfactual check"验证的核心规则:追加路径只增加一个分支,pipeline 线程完全未动(grep EnvelopeRing app/src只命中DSP.hUI/Dashboard.*、两个绘图控件与一个 API handler)。

读取路径:selectLevel 选层 + downsampleTimeWindow 重载

层级选择规则

EnvelopeRing::selectLevel(spanSec, pixels, oldestSec)(DSP.h#L644-L666)按以下规则挑选渲染层:

pixelSec = span / pixels 从 level 1 向上走: 若 levelSpanSec(k) > pixelSec —— 单元格已粗于一个像素:停止 或 cells.size() < 2 —— 层太稀疏:停止 或 该层最老单元仍晚于需要的 oldest —— 覆盖不住窗口:停止 否则 chosen = k 最后一级合格者胜出,默认 level 0

即:返回单元格跨度 ≤ 一个像素时间仍能覆盖请求跨度的最粗层级;否则回退到更细层,level 0 是永远合格的兜底(需求 R4)。

dsTimeWindowCore 提取与共享

重构把downsampleTimeWindow的访问器之后的全部主体提取为模板dsTimeWindowCore(n, xAbs, yAt, newest, xLo, xHi, w, h, out, ws)(core/Pipeline/DSPDownsample.h#L407-L470):窗口解析为两次二分查找,切片只走一遍,列桶落在以 newest 为锚的绝对列宽格点上(修复抖动/shimmer)。原有downsampleTimeWindow(const AxisData&, const AxisData&, ...)重载改为调用它(输出逐语句不变)。

新的金字塔重载(DSPDownsample.h#L511-L554):

  1. newest = level0.time[n0-1]oldest = max(xLo + newest, level0.time[0])
  2. level = ring.selectLevel(xHi - xLo, w, oldest);若level <= 0或超出层数,直接走 level 0 的普通重载;
  3. 否则把所选层的cells展开成2 * cells个极端点(偶数下标取t0/v0,奇数取t1/v1),rebase 到 level 0 的最新样本,喂给dsTimeWindowCore

这里有一个值得注意的细节:plan.md的权衡表解释了为什么 rebase 必须用level 0 的最新样本而不是粗化层自己的最后时间——坐标轴的 0 是"现在",粗化层最后一个极值比最新样本滞后最多一个 level-0 单元(按选择规则构造上小于 1/16 像素),若用粗化层自己的时间做锚点,整条曲线会向右偏移这个滞后量。

调用点

  • core/Ui/UI/Widgets/Plot.cpp#L616-L624:时间轴绘制路径m_dashboard.plotTimeRing(m_index)后调用DSP::downsampleTimeWindow(ring, xLo, xHi, m_dataW, m_dataH, m_data, &ws)
  • core/Ui/UI/Widgets/MultiPlot.cpp#L624:MultiPlot 每曲线同一调用;
  • 两条路径都发生在updateData()(60 Hz 绘制)。

窄窗口因此读 level 0、显示与旧版本完全相同的细节;宽窗口读粗化层、只遍历 O(pixels) 个包络单元——这就是渲染成本与 level-0 密度解耦的关键。

容量重建、快照与生命周期

  • 构建层级buildLevels()(DSP.h#L741-L753)从 level 0 容量推导:cells0 = capacity / 2,level k 的容量为ceil(cells0 / 16^k) + 1,只要它 ≥kMinLevelCells(3)就继续,最多kMaxCoarseLevels(9)层。
  • 重建:只有resizeCapacity(显示 tick 的 grow 路径或时间范围变更)会触发:level 0 保留最新样本(TimeRing::resizeCapacity),随后按新C0重定粗化层尺寸,并把 level 0 保留的槽位逐个折叠成单样本单元回填(rebuildLevelsFromLevel0,DSP.h#L829-L842)。复杂度 O(C0 × K),且"按构造很罕见"。
  • 快照/恢复plan.md的数据模型章节明确没有持久化,环只是运行时对象。布局重建时的 snapshot/restore 保持既有契约:level-0 容量与 interval 相同则整座金字塔随FixedQueue的别名共享语义整体搬移(恢复路径检查level0.time.raw());形状不同则保留的 level-0 槽位重放appendDecimated(重建粗化层只是副作用)。
  • API / SDK / QML:三处均无变化。dashboard.tailFrames继续返回 level 0 样本;绘图控件之外的读取者不受影响(需求 R5)。

热路径与线程影响

plan.md对热路径影响逐条做了结论:

  • 是否触碰热路径?否。pipeline 线程零改动;Dashboard 摄取(GUI 线程、显示 tick)每个追加样本多一次谓词 + 一个预测不命中分支,以及罕见的 level-0 单元完成时的有界(≤ 9 次)折叠。append 不分配(层级在构造/重建时定型)、不复制Frame--benchmark-hotpath的 gated 层按构造不受影响(它们不运行 dashboard);未 gated 的lua+dashboard读数可能只在噪声范围内波动。
  • 新跨线程信号/槽?无。
  • 新输入到缓存的 hotpath 标志?无。
  • 时间戳所有权:不变——环仍消费它之前消费的显示时钟时间。

设计权衡:plan 中的决策表

plan.md用一张决策表记录了六个关键取舍:

决策点备选方案选择及理由
金字塔放在哪(a) 塞进TimeRing;(b) 新结构体包含一个TimeRing;(c) Dashboard 里放并行 map(b):level 0 与原环逐字节一致(sweep 引擎可原样继续使用),金字塔按使用点可选启用,且由单一类型持有全部不变量
折叠拓扑(a) 完成的 k 层单元只折叠进 k+1 层;(b) 完成的 level-0 单元折叠进每个更粗开口单元(b):否则 level k 会滞后最多一个 level-(k-1) 跨度(level 5 处可达秒级),粗化渲染缺最新数据;(b) 让每层最新到误差 ≤ 1 个 level-0 单元,代价是每个 level-0 完成时一次有界 ≤ 9 路循环,内容相同、摊还 O(1)
单元身份(a) 浮点单元起始时间;(b) 整数 level-0 单元索引 >> 4k(b):精确嵌套、环绕一致、无边界取整,且可用整数算术测试
粗化层容量(a)C0 / 16^k(完整覆盖稀疏 level 0,1.13 倍);(b)(C0/2) / 16^k + 1(覆盖饱和网格,1.067 倍)(b):可见轴是T而环窗口是1.25 T;稀疏 level 0 下粗化层按到达数据自然延展窗口,且读者覆盖检查会在粗层不够时回退到更细层
growTimeRing/resizeCapacity(a) 删除;(b) 仅为 level 0 保留;(c) 改成每条曲线固定字节预算(b):渲染成本侧的猜测理由已消失,但内存侧理由仍在,且无人值守地改内存策略不合规矩;(c) 记录为后续项(见 spec.md 的 open questions)
粗化读取的最新时间 rebase(a) 对齐粗化层自己的最后时间;(b) 对齐 level 0 的最新样本(b):坐标轴 0 是"现在",粗层最后极值滞后最新样本最多一个 level-0 单元(按选择规则构造上小于 1 像素),(a) 会让曲线右移该滞后量

风险与缓解

plan.md列出五项风险及对策:

  • 粗化层右缘滞后小于一个 level-0 单元(按选择规则 < 1/16 像素)。接受,并在头文件注释中记录。
  • 快照拷贝共享存储FixedQueue拷贝别名其数组,与现有代码依赖的属性相同)。EnvelopeRing是这类队列的普通聚合,拷贝/移动语义与今天TimeRing一致;恢复路径检查level0.time.raw()
  • bulkLoadPlotWindow把时间归一化到 0 结尾,单元索引会变负;C++20 中负std::int64_t>>是算术(向下取整)右移,正好得到想要的嵌套。
  • 空静态回退环kEmpty,容量 1)不构建任何粗化层,直接按 level 0 读取——与今天相同。
  • 静默破坏类:缓存的 hotpath 标志与 push 表都不变;环在使用时仍按 widget 索引寻址,与之前完全相同。

测试与验证:AC1–AC7 与 ctest 套件

spec.md定义了七条验收标准,tasks.md的 T6 与 app/tests/CMakeLists.txt#L549 注册了tst_envelope_ring(见 app/tests/tst_envelope_ring.cpp)。该测试以 Qt Test 的 8 个槽覆盖 AC1–AC4 及粗化读取:

  • AC1 逐层暴力比对rampMatchesBruteForce):喂入既漂移又振荡的确定性波形(waveform,保证一个单元内 min 与 max 落在不同样本上),对每个已关闭的粗化单元,用记录在案的样本做暴力 min/max,逐层逐单元比对(checkLevels/bruteForce)。例如ring(4096, 1.0)喂 1500 个单元 × 每单元 5 样本后,level0.time.size() == 3000levels[0].cells.size() == 94levels[1].cells.size() == 6
  • AC2 层级选择selectLevel对给定 seconds-per-pixel 返回期望层;窄窗口回退 level 0;粗层覆盖不足时回退更细层(如selectLevel(wide, 100, time[0]) == 1,而tooOld== 0)。
  • AC3 环绕一致性wrapKeepsLevelsConsistent):level 0 与两级粗化层都环绕多次后,每个闭合粗化单元仍等于其覆盖范围的暴力极值、单元保持时间有序,且最粗层保留的历史从不短于 level 0 的。
  • AC4 非有限拒绝rejectsNonFinite):NaN/±Inf 的时间或值在触碰任何层级前被拒绝,level0、每个粗化层与openCell记账状态原样不动。
  • 粗化读取downsampleReadsCoarseLevel):窄窗口下金字塔重载与普通重载输出逐点相等;宽窗口下输出点全部落在请求窗口内、有限、数量 ≤w * 4,且selectLevel == 1
  • 尺寸/层级跨度:验证 262144 容量下coarseBytes * 14 <= level0Bytes,以及levelSpanSec(1) == interval * 16levelSpanSec(3) == interval * 4096

此外:集成测试无需新增(tests/integration/的 dashboard 测试经由dashboard.tailFrames验证,其输出不变);热路径用--benchmark-hotpath复验(gated 层必须不动,报告lua+dashboard行);静态检查对每个改动文件跑python3 scripts/code-verify.py --check(AC7)。AC5(维护者,运行应用:44.1 kHz 音频源 + 120 s 时间范围下宽视图 CPU 明显下降、缩放到任意 1 s 切片细节不变)与 AC6(--benchmark-hotpath)需要维护者在构建后手工确认,report.md的 "For the maintainer" 一节给出了操作清单:构建 →ctest -R tst_envelope_ring--benchmark-hotpathqt-cpp-review。report.md 还记录了一个有意思的验证:新测试"未在当晚编译",算术正确性通过 phase-0 的 Python 模拟(envelope_sim.py,3 层 0 处不一致、1.067 倍内存)交叉核对。

集成点速览

  • Dashboard 持有金字塔:core/Ui/UI/Dashboard.cpp 中makeHistoryRing(L114-L121)按源类型返回DSP::EnvelopeRing(streamRingCapacity(window, sampleRateHz), window)DSP::EnvelopeRing(timeRingCapacity(window), window);两张 ring mapm_plotTimeRings/m_multiplotTimeRings、getter(plotTimeRingL955、multiplotTimeRingsL969)、snapshot/restore/replay、growTimeRing(L456)全部改用EnvelopeRing;sweep 引擎的环保持普通TimeRing
  • API 读取 level 0:core/Ui/ApiHandlers/DashboardHandler.cpp#L811 的tailFrames通过dashboard.plotTimeRing(i).level0取时间轴样本,行为与旧版完全一致。
  • 架构文档同步:doc/claude/architecture/dashboard.md#L197-L251 的 TimeRing 段落补上了金字塔结构、折叠规则、选择规则与内存上界。
  • 范围边界:sample-axis、dataset-X、FFT、GPS、3D 环,以及 sweep/trigger 环(见SweepEngine/SweepSegment)均不涉及;level 0 的 grow-on-saturation 路径保留,因为"渲染成本"侧的猜测理由已消失而"内存"侧的理由没有——是否把省下的渲染预算投给更大的 level 0 上限、或用固定字节预算替代 grow 策略,是留给维护者的后续问题(spec.md的 Open Questions)。

小结

EnvelopeRing用"每层 16 倍粗化的包络金字塔 + 整数单元身份 + 完成即折叠"三件事,把 Serial Studio 时间轴绘图的宽窗口渲染从 O(样本数) 变为 O(像素数),内存代价仅为 level 0 的 1.067 倍,且完全不动 pipeline 线程、不引入新依赖(纯头文件,DSP.h/DSPDownsample.h)、不改变任何 API。level 0 细节在窄窗口缩放时与旧版本逐点一致,而粗化层的正确性由 ctest 套件以暴力比对方式钉死。对想在自研遥测/示波器类应用里复刻"包络 mipmap"的读者,plan.md的决策表与 core/Pipeline/DSP.h 的实现是现成的高质量参照。

延伸阅读:规格四件套 spec.md(WHAT/WHY、需求 R1–R7、验收 AC1–AC7)、plan.md(HOW、决策与风险)、tasks.md(T1–T7 落地清单)、report.md(实现记录与维护者清单)。

【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 19:57:22

DataHub 核心概念全解析:URN、策略、角色与元数据建模模型

DataHub 核心概念全解析&#xff1a;URN、策略、角色与元数据建模模型 【免费下载链接】datahub The Context Platform for your Data and AI Stack 项目地址: https://gitcode.com/GitHub_Trending/da/datahub 数据资产治理的第一步是理解平台的核心抽象。DataHub&…

作者头像 李华
网站建设 2026/9/17 19:56:57

SolidWorks渐开线齿轮参数化建模全流程详解

我第一次用SolidWorks画齿轮是大学做减速器课程设计的时候。那时候年轻图省事&#xff0c;直接拉伸一个圆柱体&#xff0c;再等分出几个矩形槽&#xff0c;远远看过去和教材里的齿轮图几乎一模一样。结果拿去装配仿真&#xff0c;两个齿轮齿面一接触就卡死&#xff0c;运动仿真…

作者头像 李华
网站建设 2026/9/17 19:55:53

CMake构建产物优雅清理:从缓存到跨平台脚本的完整指南

先问大家一个问题&#xff1a;你上一个项目里的build目录到底有多大&#xff1f;里面躺了多少文件&#xff1f;如果现在让你把它清理干净&#xff0c;你会怎么做&#xff1f;这个问题看起来简单得很&#xff0c;但我实际见过太多人在这里翻车。有人跑make clean&#xff0c;发现…

作者头像 李华
网站建设 2026/9/17 19:55:50

Plano 配置参考:详解 plano_config.yml 完整字段与网关行为控制

Plano 配置参考&#xff1a;详解 plano_config.yml 完整字段与网关行为控制 【免费下载链接】plano Plano is an AI-native proxy server and data plane for agentic apps. Smart LLM routing, observability, agent orchestration, and guardrails so you stay focused on yo…

作者头像 李华
网站建设 2026/9/17 19:52:43

医学影像AI落地三重关:DICOM预处理、临床验证与PACS集成

简介&#xff1a;本资源是一篇聚焦深度学习在医学影像合成领域前沿进展的综述论文&#xff0c;面向医学AI方向的本科生毕业设计、研究生科研入门及临床工程技术人员&#xff0c;旨在系统梳理伪CT、合成MRI与合成PET三大核心任务的技术路径与挑战。全文基于2018–2023年主流研究…

作者头像 李华