diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 07d1a89..93fb5fb 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -247,7 +247,7 @@ namespace jdi * * @return 实际构建了新信号返回 true。 */ - bool ApplyPendingMeanFilterBuild(); + bool ApplyPendingDerivedBuild(); /** * @brief 把一条多阶均值滤波派生信号插入数据层并同步各视图。 @@ -258,8 +258,9 @@ namespace jdi * @param [IN] makeVisible 是否在当前标签页中勾选新信号。 * @return 构建并插入成功返回 true。 */ - bool BuildMeanFilterSignal(int fileIndex, int signalIndex, int points, int order, - bool makeVisible); + bool BuildDerivedSignal(int fileIndex, int signalIndex, int points, int order, + bool diffAmp, + bool makeVisible); /** * @brief 弹出文件选择对话框并加载所选文件。 diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 7707f65..ebc1db4 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1461,8 +1461,8 @@ namespace jdi return true; } - bool Application::BuildMeanFilterSignal(int fileIndex, int signalIndex, int points, int order, - bool makeVisible) + bool Application::BuildDerivedSignal(int fileIndex, int signalIndex, int points, int order, + bool diffAmp, bool makeVisible) { const DataFile* file = m_dataSet.FileAt(fileIndex); if (file == nullptr || signalIndex < 0 || signalIndex >= static_cast(file->signals.size())) @@ -1472,21 +1472,37 @@ namespace jdi const SignalData& source = file->signals[static_cast(signalIndex)]; const std::wstring baseName = source.Title(); - // 1. 计算多阶均值滤波采样值(与预览曲线共用同一实现)。 - std::vector filtered; - ComputeMovingAverageIterated(source.values, points, order, filtered); - - // 2. 组装新信号:名称形如「<原信号名称> (N点均值x阶)」。 - SignalData derived; - derived.name = baseName + L" (" + std::to_wstring(points) + L"点均值"; - if (order > 1) + // 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。 + std::vector derived_values; + if (diffAmp) { - derived.name += L"×" + std::to_wstring(order) + L"阶"; + ComputeDiffAmplify(source.values, points, order, derived_values); + } + else + { + ComputeMovingAverageIterated(source.values, points, order, derived_values); + } + + // 2. 组装新信号:均值滤波形如「<原信号名称> (N点均值×M阶)」, + // 差异放大形如「<原信号名称> (X点差异放大×N倍)」。 + SignalData derived; + if (diffAmp) + { + derived.name = baseName + L" (" + std::to_wstring(points) + L"点差异放大×" + + std::to_wstring(order) + L"倍)"; + } + else + { + derived.name = baseName + L" (" + std::to_wstring(points) + L"点均值"; + if (order > 1) + { + derived.name += L"×" + std::to_wstring(order) + L"阶"; + } + derived.name += L")"; } - derived.name += L")"; derived.rawHeader = derived.name; derived.unit = source.unit; - derived.values.assign(filtered.begin(), filtered.end()); + derived.values.assign(derived_values.begin(), derived_values.end()); // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 int newIndex = -1; @@ -1518,24 +1534,67 @@ namespace jdi return true; } - bool Application::ApplyPendingMeanFilterBuild() + bool Application::ApplyPendingDerivedBuild() { - int laneIndex = -1; - std::vector items; - if (!m_plot.TakePendingMeanFilterBuild(laneIndex, items)) + // 两类「构建为新信号」请求(均值滤波 / 差异放大)机制完全一致, + // 统一整理成同一份构建清单后按下标降序处理。 + struct BuildRequest + { + int fileIndex = -1; + int signalIndex = -1; + int first = 0; + int second = 0; + bool diffAmp = false; + }; + + std::vector requests; + { + int laneIndex = -1; + std::vector items; + if (m_plot.TakePendingMeanFilterBuild(laneIndex, items)) + { + for (const PlotView::MeanFilterBuild& item : items) + { + BuildRequest request; + request.fileIndex = item.fileIndex; + request.signalIndex = item.signalIndex; + request.first = item.points; + request.second = item.order; + request.diffAmp = false; + requests.push_back(request); + } + } + } + { + int laneIndex = -1; + std::vector items; + if (m_plot.TakePendingDiffAmpBuild(laneIndex, items)) + { + for (const PlotView::DiffAmpBuild& item : items) + { + BuildRequest request; + request.fileIndex = item.fileIndex; + request.signalIndex = item.signalIndex; + request.first = item.points; + request.second = item.gain; + request.diffAmp = true; + requests.push_back(request); + } + } + } + if (requests.empty()) { return false; } - if (m_tabs.empty() || laneIndex < 0 || - laneIndex >= static_cast(ActiveTab().Lanes().size())) + if (m_tabs.empty()) { return false; } // **同一文件内必须按下标降序处理**:每次插入只让插入点之后的信号下标 +1, // 降序处理可保证尚未处理的信号下标始终有效(升序会把新插入的信号当成原信号)。 - std::sort(items.begin(), items.end(), - [](const PlotView::MeanFilterBuild& a, const PlotView::MeanFilterBuild& b) + std::sort(requests.begin(), requests.end(), + [](const BuildRequest& a, const BuildRequest& b) { if (a.fileIndex != b.fileIndex) { @@ -1545,27 +1604,38 @@ namespace jdi }); int built = 0; - int lastPoints = 0; - int lastOrder = 1; - for (const PlotView::MeanFilterBuild& item : items) + int lastFirst = 0; + int lastSecond = 1; + bool lastDiffAmp = false; + for (const BuildRequest& request : requests) { - if (BuildMeanFilterSignal(item.fileIndex, item.signalIndex, item.points, item.order, true)) + if (BuildDerivedSignal(request.fileIndex, request.signalIndex, request.first, + request.second, request.diffAmp, true)) { ++built; - lastPoints = item.points; - lastOrder = item.order; + lastFirst = request.first; + lastSecond = request.second; + lastDiffAmp = request.diffAmp; } } if (built > 0) { - std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 " + - std::to_wstring(lastPoints) + L"点均值"; - if (lastOrder > 1) + std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 "; + if (lastDiffAmp) { - text += L"×" + std::to_wstring(lastOrder) + L"阶"; + text += std::to_wstring(lastFirst) + L"点差异放大×" + + std::to_wstring(lastSecond) + L"倍信号。"; + } + else + { + text += std::to_wstring(lastFirst) + L"点均值"; + if (lastSecond > 1) + { + text += L"×" + std::to_wstring(lastSecond) + L"阶"; + } + text += L"滤波信号。"; } - text += L"滤波信号。"; SetStatusMessage(text, false); } return built > 0; @@ -1698,6 +1768,12 @@ namespace jdi meanFilter.id = PLOT_CMD_LANE_MEAN_FILTER; meanFilter.checked = m_plot.LaneMeanFilter(laneIndex); experimental.submenu.push_back(meanFilter); + + MenuItem diffAmp; + diffAmp.label = L"差异放大"; + diffAmp.id = PLOT_CMD_LANE_DIFF_AMP; + diffAmp.checked = m_plot.LaneDiffAmp(laneIndex); + experimental.submenu.push_back(diffAmp); } items.push_back(experimental); @@ -1812,6 +1888,10 @@ namespace jdi // 「实验性功能 → 均值滤波」:切换均值滤波曲线与参数面板。 m_plot.ToggleLaneMeanFilter(laneIndex); break; + case PLOT_CMD_LANE_DIFF_AMP: + // 「实验性功能 → 差异放大」:切换差异放大曲线与参数面板。 + m_plot.ToggleLaneDiffAmp(laneIndex); + break; case PLOT_CMD_LANE_SPLIT: // 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。 m_plot.SplitLane(laneIndex); @@ -2311,7 +2391,7 @@ namespace jdi // 绘图区内的点击也可能提交图例改名,需要立刻落到数据层。 ApplyPendingRename(); // 「均值滤波 → 构建为新信号」按钮:立即构建新信号并同步各视图。 - ApplyPendingMeanFilterBuild(); + ApplyPendingDerivedBuild(); InvalidateRect(hwnd, nullptr, FALSE); } // 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次, diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 139779b..1cc6475 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -57,7 +57,9 @@ namespace jdi EDGE_CENTER_POINTS, /**< 编辑「中心轴边沿计数」的均值点数 X。 */ EDGE_CENTER_ORDER, /**< 编辑「中心轴边沿计数」的阶数 N。 */ MEAN_FILTER_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */ - MEAN_FILTER_ORDER /**< 编辑「均值滤波」的阶数 N。 */ + MEAN_FILTER_ORDER, /**< 编辑「均值滤波」的阶数 N。 */ + DIFF_AMP_POINTS, /**< 编辑「差异放大」的窗口点数 X。 */ + DIFF_AMP_ORDER /**< 编辑「差异放大」的放大倍数 N。 */ }; /** @@ -74,7 +76,8 @@ namespace jdi PLOT_CMD_LANE_EDGE_COUNT = 7, /**< 切换本网格的「实验性功能 → 边沿计数」开关。 */ PLOT_CMD_LANE_EDGE_CENTER = 8, /**< 切换本网格的「实验性功能 → 边沿计数(中心轴)」开关。 */ PLOT_CMD_LANE_SPLIT = 9, /**< 拆分当前网格:把网格内信号按个拆成独立网格并顺次下移编号。 */ - PLOT_CMD_LANE_MEAN_FILTER = 10 /**< 切换本网格的「实验性功能 → 均值滤波」开关。 */ + PLOT_CMD_LANE_MEAN_FILTER = 10, /**< 切换本网格的「实验性功能 → 均值滤波」开关。 */ + PLOT_CMD_LANE_DIFF_AMP = 11 /**< 切换本网格的「实验性功能 → 差异放大」开关。 */ }; /** @@ -84,7 +87,8 @@ namespace jdi { COUNT = 0, /**< 边沿计数。 */ CENTER = 1, /**< 边沿计数(中心轴)。 */ - MEAN = 2 /**< 均值滤波。 */ + MEAN = 2, /**< 均值滤波。 */ + DIFF_AMP = 3 /**< 差异放大。 */ }; /** @@ -518,6 +522,86 @@ namespace jdi */ bool TakePendingMeanFilterBuild(int& laneIndex, std::vector& items); + /** + * @brief 「实验性功能 → 差异放大」的「构建为新信号」单个请求 + * (一个被勾选的信号 + 自己的参数)。 + */ + struct DiffAmpBuild + { + int fileIndex = -1; /**< 信号所属文件下标。 */ + int signalIndex = -1; /**< 文件内信号下标。 */ + int points = 80; /**< 窗口点数 X(略大于纹波周期的一半)。 */ + int gain = 2; /**< 放大倍数 N。 */ + }; + + /** + * @brief 取出「差异放大 → 构建为新信号」请求(由面板底部按钮触发)。 + * @param [OUT] laneIndex 触发该请求的网格下标。 + * @param [OUT] items 需要构建的信号及其参数(按信号在网格内的顺序)。 + * @return 存在待处理请求返回 true(取出后清空)。 + */ + bool TakePendingDiffAmpBuild(int& laneIndex, std::vector& items); + + /** + * @brief 切换某网格的「差异放大」开关。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] enabled 是否开启。 + * @return 状态发生变化返回 true。 + */ + bool SetLaneDiffAmp(int laneIndex, bool enabled); + + /** + * @brief 查询某网格的「差异放大」开关。 + * @param [IN] laneIndex 网格下标。 + * @return 已开启返回 true。 + */ + bool LaneDiffAmp(int laneIndex) const; + + /** + * @brief 切换某网格的「差异放大」开关。 + * @param [IN] laneIndex 网格下标。 + * @return 切换后的状态。 + */ + bool ToggleLaneDiffAmp(int laneIndex); + + /** + * @brief 获取差异放大曲线(按「信号 + 窗口点数 + 放大倍数」缓存)。 + * @param [IN] signal 目标信号。 + * @param [IN] points 窗口点数 X。 + * @param [IN] gain 放大倍数 N。 + * @return 曲线数值数组(长度与 `signal.values` 相同;缺失处为 NaN)。 + */ + const std::vector& DiffAmpCurveFor(const SignalData& signal, int points, int gain) const; + + /** + * @brief 取某个时刻的差异放大曲线取值(用于纵向光标的取值标注)。 + * @param [IN] file 信号所属文件。 + * @param [IN] signal 目标信号。 + * @param [IN] points 窗口点数 X(略大于纹波周期的一半)。 + * @param [IN] gain 放大倍数 N。 + * @param [IN] time 采样时刻。 + * @param [OUT] value 输出取值。 + * @return 取到有效值返回 true。 + */ + bool DiffAmpValueAt(const DataFile& file, const SignalData& signal, int points, int gain, + double time, double& value) const; + + /** + * @brief 切换某个信号在「差异放大」面板中的预览勾选状态。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] ref 目标信号。 + * @return 切换后的勾选状态。 + */ + bool ToggleLaneDiffAmpSignal(int laneIndex, const SignalRef& ref); + + /** + * @brief 查询某个信号在「差异放大」面板中的预览勾选状态。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] ref 目标信号。 + * @return 已勾选返回 true。 + */ + bool LaneDiffAmpSignalEnabled(int laneIndex, const SignalRef& ref) const; + /** * @brief 直接把文本解析为网格 Y 轴上限/下限并应用(供菜单内联输入框提交使用)。 * @param [IN] laneIndex 网格下标。 @@ -1091,6 +1175,17 @@ namespace jdi void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, int points, int order) const; + /** + * @brief 绘制某条信号的差异放大曲线(与均值滤波曲线同一配色派生规则)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] resolved 信号视图。 + * @param [IN] lane 网格绘制信息。 + * @param [IN] points 窗口点数 X(局部差异强度统计半径)。 + * @param [IN] gain 放大倍数 N(最大增益)。 + */ + void PaintDiffAmpCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + int points, int gain) const; + /** * @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。 */ @@ -1305,7 +1400,9 @@ namespace jdi bool m_pendingRename = false; /**< 是否存在待应用的重命名请求。 */ int m_pendingMeanFilterLane = -1; /**< 待构建的均值滤波信号所属网格下标(-1 表示无请求)。 */ + int m_pendingDiffAmpLane = -1; /**< 待构建的差异放大信号所属网格下标(-1 表示无请求)。 */ std::vector m_pendingMeanFilterItems; /**< 待构建的信号及其参数。 */ + std::vector m_pendingDiffAmpItems; /**< 待构建的差异放大信号及其参数。 */ /** * @brief 实验性功能浮动面板的 ✕ 处于"已按下、等待抬起确认"状态。 @@ -1361,6 +1458,16 @@ namespace jdi }; mutable std::deque m_meanCurveCache; + /** 多阶均值滤波曲线缓存项(按「信号指针 + 均值点数 + 阶数」标识)。 */ + struct DiffAmpCurveEntry + { + const SignalData* signal = nullptr; + int points = 80; /**< 窗口点数 X。 */ + int gain = 2; /**< 放大倍数 N。 */ + std::vector values; /**< 与 signal->values 等长;缺失处为 NaN。 */ + }; + mutable std::deque m_diffAmpCurveCache; + /** 中心轴边沿统计缓存项(增量统计机制与 `EdgeCountEntry` 完全一致)。 */ struct EdgeCenterCountEntry { diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 992b464..c5dc3c3 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -70,7 +70,7 @@ namespace jdi const float kEdgePanelMeanEditHeight = 24.0f; /**< 逐条目面板中每条信号的参数行高度。 */ const float kEdgePanelMeanRowGap = 2.0f; /**< 名称行与参数行之间的间距。 */ const float kEdgePanelMeanCheckSize = 15.0f; /**< 逐条目面板中条目复选框尺寸。 */ - const wchar_t* const kMeanFilterBuildLabel = L"构建为新信号"; /**< 均值滤波面板底部按钮文案。 */ + const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 差异放大共用)。 */ const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */ /** @@ -228,8 +228,11 @@ namespace jdi * * 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。 */ - const EdgePanelKind kEdgePanelKinds[] = { - EdgePanelKind::COUNT, EdgePanelKind::CENTER, EdgePanelKind::MEAN}; + const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::DIFF_AMP}; + + /** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */ + const int kEdgePanelKindCount = static_cast(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])); /** * @brief 时间间隔的显示单位。 @@ -1126,6 +1129,7 @@ namespace jdi m_edgeCountCache.clear(); m_edgeCenterCountCache.clear(); m_meanCurveCache.clear(); + m_diffAmpCurveCache.clear(); // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 m_hoverSignal = SignalRef(); m_hoverLegend = SignalRef(); @@ -1700,6 +1704,96 @@ namespace jdi return m_tab->Lanes()[static_cast(laneIndex)].meanFilter; } + bool PlotView::LaneDiffAmp(int laneIndex) const + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + return m_tab->Lanes()[static_cast(laneIndex)].diffAmp; + } + + bool PlotView::SetLaneDiffAmp(int laneIndex, bool enabled) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (lane.diffAmp == enabled) + { + return false; + } + lane.diffAmp = enabled; + if (enabled) + { + // 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。 + if (lane.diffAmpItems.empty()) + { + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible) + { + lane.EnsureDiffAmpItem(ref); + break; + } + } + } + lane.diffAmpPanelOpen = true; + } + else + { + lane.diffAmpPanelOpen = false; + } + return true; + } + + bool PlotView::ToggleLaneDiffAmp(int laneIndex) + { + const bool enabled = !LaneDiffAmp(laneIndex); + SetLaneDiffAmp(laneIndex, enabled); + return enabled; + } + + bool PlotView::ToggleLaneDiffAmpSignal(int laneIndex, const SignalRef& ref) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (lane.DiffAmpEnabled(ref)) + { + lane.RemoveDiffAmpItem(ref); + return false; + } + lane.EnsureDiffAmpItem(ref); + return true; + } + + bool PlotView::LaneDiffAmpSignalEnabled(int laneIndex, const SignalRef& ref) const + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + return m_tab->Lanes()[static_cast(laneIndex)].DiffAmpEnabled(ref); + } + + bool PlotView::TakePendingDiffAmpBuild(int& laneIndex, std::vector& items) + { + if (m_pendingDiffAmpLane < 0) + { + return false; + } + laneIndex = m_pendingDiffAmpLane; + items = m_pendingDiffAmpItems; + m_pendingDiffAmpLane = -1; + m_pendingDiffAmpItems.clear(); + return true; + } + bool PlotView::SetLaneMeanFilter(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -1828,6 +1922,53 @@ namespace jdi return m_meanCurveCache.back().values; } + const std::vector& PlotView::DiffAmpCurveFor(const SignalData& signal, int points, + int gain) const + { + const int window = std::max(1, points); + const int multiplier = std::max(1, gain); + for (const DiffAmpCurveEntry& entry : m_diffAmpCurveCache) + { + if (entry.signal == &signal && entry.points == window && entry.gain == multiplier) + { + return entry.values; + } + } + + DiffAmpCurveEntry entry; + entry.signal = &signal; + entry.points = window; + entry.gain = multiplier; + // 与「构建为新信号」共用同一实现(Utilities 内的自由函数), + // 因此预览曲线与构建出的采样值逐位一致。 + ComputeDiffAmplify(signal.values, window, multiplier, entry.values); + + m_diffAmpCurveCache.push_back(std::move(entry)); + return m_diffAmpCurveCache.back().values; + } + + bool PlotView::DiffAmpValueAt(const DataFile& file, const SignalData& signal, int points, int gain, + double time, double& value) const + { + const int index = FindNearestSample(file, time); + if (index < 0) + { + return false; + } + const std::vector& curve = DiffAmpCurveFor(signal, points, gain); + if (static_cast(index) >= curve.size()) + { + return false; + } + const float sampled = curve[static_cast(index)]; + if (!std::isfinite(sampled)) + { + return false; + } + value = static_cast(sampled); + return true; + } + void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points, int order, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling) const @@ -2067,11 +2208,16 @@ namespace jdi const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); + const bool diffAmp = (kind == EdgePanelKind::DIFF_AMP); + // 「均值滤波」「中心轴边沿计数」「差异放大」都是逐条目面板(名称 + 输入框 [+ 统计])。 + const bool itemized = (centerAxis || meanFilter || diffAmp); - // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” + 光标区间尾缀)。 + // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “差异放大” + // + 光标区间尾缀)。 const std::wstring title = meanFilter ? std::wstring(L"均值滤波") - : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix()); + : (diffAmp ? std::wstring(L"差异放大") + : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + kEdgePanelTitleHeight * m_scale; @@ -2086,7 +2232,7 @@ namespace jdi continue; } const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()); - if (meanFilter || centerAxis) + if (itemized) { // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + @@ -2094,7 +2240,7 @@ namespace jdi } std::size_t rising = 0; std::size_t falling = 0; - if (meanFilter) + if (meanFilter || diffAmp) { continue; } @@ -2120,12 +2266,14 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); } - if (centerAxis || meanFilter) + if (centerAxis || meanFilter || diffAmp) { // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - // (「均值点数 X」/「阶数 N」的中心轴面板与均值滤波面板排布完全一致。) + // (「均值点数 X」/「阶数 N」的布局对三个逐条目面板通用:标签与数值框各占半行, + // 标签取较宽者以保证“放大倍数 N”这类更长的标签不被压住。) const float labelX = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); - const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"阶数 N"); + const float labelN = std::max(UiMeasureTextWidth(m_fonts->uiSmall, L"阶数 N"), + UiMeasureTextWidth(m_fonts->uiSmall, L"放大倍数 N")); const float fieldWidth = std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); @@ -2134,11 +2282,11 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } - if (meanFilter) + if (meanFilter || diffAmp) { // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 const float buttonWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterBuildLabel) + 28.0f * m_scale; + UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); } @@ -2661,10 +2809,12 @@ namespace jdi if (m_editTarget == PlotEditTarget::EDGE_CENTER_POINTS || m_editTarget == PlotEditTarget::EDGE_CENTER_ORDER || m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS || - m_editTarget == PlotEditTarget::MEAN_FILTER_ORDER) + m_editTarget == PlotEditTarget::MEAN_FILTER_ORDER || + m_editTarget == PlotEditTarget::DIFF_AMP_POINTS || + m_editTarget == PlotEditTarget::DIFF_AMP_ORDER) { // 「均值点数 X」/「阶数 N」:输入框就落在对应的字段矩形上 - //(中心轴面板与均值滤波面板的字段矩形含义相同)。 + //(中心轴、均值滤波与差异放大面板的字段矩形含义相同)。 if (m_editFieldRect.Width <= 0.0f) { return gd::RectF(); @@ -2831,30 +2981,37 @@ namespace jdi } // 「均值点数 X」/「阶数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。 - // 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用对象是它自己的条目列表。 + // 「中心轴边沿计数」「差异放大」用**完全相同的参数与语义**, + // 只是各自作用于自己的条目列表。 const bool centerTarget = (target == PlotEditTarget::EDGE_CENTER_POINTS || target == PlotEditTarget::EDGE_CENTER_ORDER); + const bool diffTarget = + (target == PlotEditTarget::DIFF_AMP_POINTS || target == PlotEditTarget::DIFF_AMP_ORDER); if (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::MEAN_FILTER_ORDER || - centerTarget) + centerTarget || diffTarget) { Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const bool isPoints = (target == PlotEditTarget::MEAN_FILTER_POINTS || - target == PlotEditTarget::EDGE_CENTER_POINTS); + target == PlotEditTarget::EDGE_CENTER_POINTS || + target == PlotEditTarget::DIFF_AMP_POINTS); const Lane::MeanFilterItem* existing = - centerTarget ? lane.FindEdgeCenterItem(m_editMeanRef) : lane.FindMeanFilterItem(m_editMeanRef); + centerTarget ? lane.FindEdgeCenterItem(m_editMeanRef) + : (diffTarget ? lane.FindDiffAmpItem(m_editMeanRef) + : lane.FindMeanFilterItem(m_editMeanRef)); if (existing == nullptr) { return true; } const int maximum = isPoints ? 1000000 : 1000; int value = static_cast(std::lround(parsed)); - // 阶数至少为 1(0 阶等于不做滤波,没有意义);均值点数可以为 0(不平滑)。 + // 阶数/倍数至少为 1(1 阶等于不做滤波、1 倍等于不放大);均值点数可以为 0。 value = isPoints ? std::max(0, std::min(value, maximum)) : std::max(1, std::min(value, maximum)); Lane::MeanFilterItem& item = centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef) - : lane.EnsureMeanFilterItem(m_editMeanRef); + : (diffTarget ? lane.EnsureDiffAmpItem(m_editMeanRef) + : lane.EnsureMeanFilterItem(m_editMeanRef)); if (isPoints) { item.points = value; @@ -2866,6 +3023,7 @@ namespace jdi // 参数变化会改变曲线:清掉派生缓存后重绘。 m_meanCurveCache.clear(); m_edgeCenterCountCache.clear(); + m_diffAmpCurveCache.clear(); InvalidateSceneCache(); return true; } @@ -3108,7 +3266,8 @@ namespace jdi { return LoadCursorW(nullptr, IDC_SIZEALL); } - if (kind == EdgePanelKind::MEAN && layout.button.Width > 0.0f && + if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::DIFF_AMP) && + layout.button.Width > 0.0f && UiPointInRect(layout.button, static_cast(x), static_cast(y))) { // 「构建为新信号」按钮:手型光标作为可点击提示。 @@ -3379,6 +3538,10 @@ namespace jdi lane.meanFilterPanelX = left; lane.meanFilterPanelY = top; break; + case EdgePanelKind::DIFF_AMP: + lane.diffAmpPanelX = left; + lane.diffAmpPanelY = top; + break; case EdgePanelKind::COUNT: default: lane.edgePanelX = left; @@ -3584,7 +3747,7 @@ namespace jdi // * 底部按钮 → 触发「构建为新信号」请求; // * 标题栏 → 拖动面板; // * 面板主体 → 消费点击,但**不拖动也不穿透**(用户明确要求只有标题栏可拖拽)。 - for (int pass = 0; pass < 3; ++pass) + for (int pass = 0; pass < kEdgePanelKindCount; ++pass) { const EdgePanelKind kind = kEdgePanelKinds[pass]; bool handledPanel = false; @@ -3607,17 +3770,23 @@ namespace jdi m_pendingPanelCloseKind = kind; } - // 「中心轴边沿计数」/「均值滤波」面板:逐信号条目(同一套排布与交互)。 - if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER) && m_tab != nullptr) + // 「中心轴边沿计数」/「均值滤波」/「差异放大」面板:逐信号条目 + // (同一套排布与交互,只是输入框绑定的编辑目标与条目列表不同)。 + if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || + kind == EdgePanelKind::DIFF_AMP) && + m_tab != nullptr) { const bool meanMode = (kind == EdgePanelKind::MEAN); + const bool diffMode = (kind == EdgePanelKind::DIFF_AMP); Lane& tabLane = m_tab->Lanes()[static_cast(index)]; for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; const auto findItem = [&]() -> const Lane::MeanFilterItem* { - return meanMode ? tabLane.FindMeanFilterItem(ref) : tabLane.FindEdgeCenterItem(ref); + return meanMode ? tabLane.FindMeanFilterItem(ref) + : (diffMode ? tabLane.FindDiffAmpItem(ref) + : tabLane.FindEdgeCenterItem(ref)); }; // 第一排右侧的复选框 → 切换该信号的预览勾选状态。 if (layout.rowChecks[rowIndex].Width > 0.0f && @@ -3627,25 +3796,33 @@ namespace jdi { ToggleLaneMeanFilterSignal(index, ref); } + else if (diffMode) + { + ToggleLaneDiffAmpSignal(index, ref); + } else { ToggleLaneEdgeCenterSignal(index, ref); } m_meanCurveCache.clear(); m_edgeCenterCountCache.clear(); + m_diffAmpCurveCache.clear(); InvalidateSceneCache(); m_dragMode = PlotDragMode::NONE; return true; } - // 第二排的两个输入框 → 就地编辑该信号的均值点数 X / 阶数 N。 + // 第二排的两个输入框 → 就地编辑该信号的均值点数 X / 阶数 N + //(差异放大面板是窗口点数 X / 放大倍数 N,交互完全一致)。 if (layout.rowPoints[rowIndex].Width > 0.0f && UiPointInRect(layout.rowPoints[rowIndex], fx, fy)) { const Lane::MeanFilterItem* item = findItem(); m_editMeanRef = ref; BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_POINTS - : PlotEditTarget::EDGE_CENTER_POINTS, - index, std::to_wstring(item != nullptr ? item->points : 5), + : (diffMode ? PlotEditTarget::DIFF_AMP_POINTS + : PlotEditTarget::EDGE_CENTER_POINTS), + index, + std::to_wstring(item != nullptr ? item->points : (diffMode ? 64 : 5)), layout.rowPoints[rowIndex]); return true; } @@ -3655,13 +3832,51 @@ namespace jdi const Lane::MeanFilterItem* item = findItem(); m_editMeanRef = ref; BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ORDER - : PlotEditTarget::EDGE_CENTER_ORDER, - index, std::to_wstring(item != nullptr ? item->order : 1), + : (diffMode ? PlotEditTarget::DIFF_AMP_ORDER + : PlotEditTarget::EDGE_CENTER_ORDER), + index, + std::to_wstring(item != nullptr ? item->order : (diffMode ? 2 : 1)), layout.rowOrder[rowIndex]); return true; } } + if (diffMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) + { + // 「差异放大 → 构建为新信号」:与均值滤波同一套请求机制。 + const Lane& lane = m_tab->Lanes()[static_cast(index)]; + m_pendingDiffAmpItems.clear(); + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + const Lane::DiffAmpItem* item = lane.FindDiffAmpItem(ref); + if (item == nullptr) + { + continue; + } + DiffAmpBuild build; + build.fileIndex = ref.fileIndex; + build.signalIndex = ref.signalIndex; + build.points = item->points; + build.gain = item->order; + m_pendingDiffAmpItems.push_back(build); + } + if (!m_pendingDiffAmpItems.empty()) + { + const DiffAmpBuild& first = m_pendingDiffAmpItems.front(); + SetHint(L"正在构建差异放大信号(" + std::to_wstring(first.points) + + L" 点 " + std::to_wstring(first.gain) + L" 倍,共 " + + std::to_wstring(m_pendingDiffAmpItems.size()) + L" 条)…"); + m_pendingDiffAmpLane = index; + } + m_dragMode = PlotDragMode::NONE; + return true; + } + if (meanMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { // 「构建为新信号」:只登记请求(含每个信号自己的 X/N), @@ -3844,6 +4059,9 @@ namespace jdi case EdgePanelKind::MEAN: SetLaneMeanFilter(lane, false); break; + case EdgePanelKind::DIFF_AMP: + SetLaneDiffAmp(lane, false); + break; case EdgePanelKind::COUNT: default: SetLaneEdgeCount(lane, false); @@ -4810,6 +5028,16 @@ namespace jdi mix(static_cast(item.points)); mix(static_cast(item.order)); } + // 「差异放大」的开关与每个被勾选信号各自的窗口点数 X / 放大倍数 N 同理。 + mix(Tab().Lanes()[laneIndex].diffAmp ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].diffAmpItems.size())); + for (const Lane::DiffAmpItem& item : Tab().Lanes()[laneIndex].diffAmpItems) + { + mix(static_cast(item.ref.fileIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.order)); + } mix(signals.size()); for (const SignalRef& ref : signals) { @@ -5622,6 +5850,9 @@ namespace jdi // 「均值滤波」开启时,为**被勾选**的信号各叠加一条多阶滑动平均曲线 //(均值点数 X、阶数 N 由该信号自己的条目给出)。 const bool meanFilterOn = Tab().Lanes()[static_cast(laneIndex)].meanFilter; + // 「差异放大」开启时同样只为**被勾选**的信号叠加曲线 + //(窗口点数 X、放大倍数 N 由该信号自己的条目给出)。 + const bool diffAmpOn = Tab().Lanes()[static_cast(laneIndex)].diffAmp; for (const SignalRef& ref : laneSignals) { @@ -5758,6 +5989,16 @@ namespace jdi PaintMeanFilterCurve(g, resolved, lane, item->points, item->order); } } + // 差异放大曲线:同样是仅用于预览的叠加曲线,只在被勾选时绘制。 + if (diffAmpOn) + { + const Lane::DiffAmpItem* item = + Tab().Lanes()[static_cast(laneIndex)].FindDiffAmpItem(ref); + if (item != nullptr) + { + PaintDiffAmpCurve(g, resolved, lane, item->points, item->order); + } + } } // g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。 @@ -6472,14 +6713,19 @@ namespace jdi DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); - // 「边沿计数(中心轴)」/「均值滤波」开启时,纵向光标还要额外标注**派生曲线** - // 在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 - // 两条曲线的参数与语义完全一致,故共用同一段绘制逻辑。 - const auto paintDerivedCapsule = [&](int first, int second, const std::wstring& suffix) + // 「边沿计数(中心轴)」/「均值滤波」/「差异放大」开启时,纵向光标还要额外标注 + // **派生曲线**在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 + // 三条曲线的参数与标注规则完全一致,故共用同一段绘制逻辑。 + const auto paintDerivedCapsule = [&](int first, int second, const std::wstring& suffix, + bool diffAmp) { double derivedValue = 0.0; - if (!MeanFilterValueAt(*resolved.file, *resolved.data, first, second, time, - derivedValue)) + const bool ok = + diffAmp ? DiffAmpValueAt(*resolved.file, *resolved.data, first, second, time, + derivedValue) + : MeanFilterValueAt(*resolved.file, *resolved.data, first, second, time, + derivedValue); + if (!ok) { return; } @@ -6498,8 +6744,12 @@ namespace jdi if (showDelta) { double otherDerived = 0.0; - if (MeanFilterValueAt(*resolved.file, *resolved.data, first, second, - otherTime, otherDerived)) + const bool otherOk = + diffAmp ? DiffAmpValueAt(*resolved.file, *resolved.data, first, second, + otherTime, otherDerived) + : MeanFilterValueAt(*resolved.file, *resolved.data, first, second, + otherTime, otherDerived); + if (otherOk) { const double delta = derivedValue - otherDerived; centerText += (delta < 0.0) ? L"-" : L"+"; @@ -6549,7 +6799,7 @@ namespace jdi m_tab->Lanes()[laneIndex].FindEdgeCenterItem(ref); if (centerItem != nullptr) { - paintDerivedCapsule(centerItem->points, centerItem->order, L"中心轴"); + paintDerivedCapsule(centerItem->points, centerItem->order, L"中心轴", false); } } if (m_tab->Lanes()[laneIndex].meanFilter) @@ -6559,7 +6809,17 @@ namespace jdi m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref); if (meanItem != nullptr) { - paintDerivedCapsule(meanItem->points, meanItem->order, L"均值滤波"); + paintDerivedCapsule(meanItem->points, meanItem->order, L"均值滤波", false); + } + } + if (m_tab->Lanes()[laneIndex].diffAmp) + { + // 差异放大曲线:同样是只标注被勾选的信号,尾缀为「差异化」。 + const Lane::DiffAmpItem* diffItem = + m_tab->Lanes()[laneIndex].FindDiffAmpItem(ref); + if (diffItem != nullptr) + { + paintDerivedCapsule(diffItem->points, diffItem->order, L"差异化", true); } } } @@ -6642,9 +6902,15 @@ namespace jdi const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); - const bool enabled = meanFilter ? lane.meanFilter : (centerAxis ? lane.edgeCenter : lane.edgeCount); + const bool diffAmp = (kind == EdgePanelKind::DIFF_AMP); + // 「均值滤波」与「差异放大」共用同一套「逐条目 + 底部按钮」排布。 + const bool itemized = (centerAxis || meanFilter || diffAmp); + const bool hasButton = (meanFilter || diffAmp); + const bool enabled = + meanFilter ? lane.meanFilter : (diffAmp ? lane.diffAmp : (centerAxis ? lane.edgeCenter : lane.edgeCount)); const bool open = meanFilter ? lane.meanFilterPanelOpen - : (centerAxis ? lane.edgeCenterPanelOpen : lane.edgePanelOpen); + : (diffAmp ? lane.diffAmpPanelOpen + : (centerAxis ? lane.edgeCenterPanelOpen : lane.edgePanelOpen)); if (!enabled || !open) { return layout; @@ -6669,11 +6935,10 @@ namespace jdi const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; - // 「中心轴边沿计数」与「均值滤波」共用同一套「逐条目」排布(三排/两排), + // 「中心轴边沿计数」「均值滤波」「差异放大」共用同一套「逐条目」排布(三排/两排), // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 - const float buttonHeight = meanFilter ? (kEdgePanelButtonHeight * m_scale) : 0.0f; + const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; - const bool itemized = meanFilter || centerAxis; // 逐条目面板:每个条目 = 第一排(信号名 + 复选框)+ 第二排(均值点数 X / 阶数 N) // + (中心轴)第三排(统计结果);后两排**只在该条目被勾选时才占位**, @@ -6692,7 +6957,9 @@ namespace jdi continue; } itemsHeight += meanRowHeight; - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) : lane.EdgeCenterEnabled(ref); + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) + : (diffAmp ? lane.DiffAmpEnabled(ref) + : lane.EdgeCenterEnabled(ref)); if (checked) { itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; @@ -6702,7 +6969,10 @@ namespace jdi } } } - height = titleHeight + pad + itemsHeight + pad + buttonHeight; + // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, + // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 + height = titleHeight + pad + itemsHeight + pad + buttonHeight + + (hasButton ? pad : 0.0f); } else { @@ -6711,9 +6981,11 @@ namespace jdi // 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。 const double storedX = meanFilter ? lane.meanFilterPanelX - : (centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX); + : (diffAmp ? lane.diffAmpPanelX + : (centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX)); const double storedY = meanFilter ? lane.meanFilterPanelY - : (centerAxis ? lane.edgeCenterPanelY : lane.edgePanelY); + : (diffAmp ? lane.diffAmpPanelY + : (centerAxis ? lane.edgeCenterPanelY : lane.edgePanelY)); float left = (storedX >= 0.0) ? static_cast(storedX) : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); @@ -6736,9 +7008,12 @@ namespace jdi if (itemized) { const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; - if (meanFilter) + if (hasButton) { - layout.button = gd::RectF(left + pad, top + height - buttonRowHeight, + // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 + //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); + // 面板高度已含这份 pad,因此这里只上移按钮行即可。 + layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, std::max(0.0f, width - pad * 2.0f), buttonRowHeight); } @@ -6762,7 +7037,8 @@ namespace jdi left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, checkSize)); const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : lane.EdgeCenterEnabled(ref); + : (diffAmp ? lane.DiffAmpEnabled(ref) + : lane.EdgeCenterEnabled(ref)); if (checked) { const float editTop = rowY + meanRowHeight; @@ -7132,6 +7408,17 @@ namespace jdi PaintDerivedCurve(g, resolved, lane, MeanFilterCurveFor(*resolved.data, points, order)); } + void PlotView::PaintDiffAmpCurve(gd::Graphics& g, const ResolvedSignal& resolved, + const LaneVisual& lane, int points, int gain) const + { + if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) + { + return; + } + // 派生曲线一律使用同一段绘制逻辑与配色规则(差异放大曲线同样如此)。 + PaintDerivedCurve(g, resolved, lane, DiffAmpCurveFor(*resolved.data, points, gain)); + } + void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) { if (m_tab == nullptr || m_dataSet == nullptr) @@ -7236,13 +7523,17 @@ namespace jdi } // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- - // 「边沿计数」「边沿计数(中心轴)」「均值滤波」共用同一套样式, - // 仅标题、内容行与底部编辑行/按钮不同。 - for (int pass = 0; pass < 3; ++pass) + // 「边沿计数」「边沿计数(中心轴)」「均值滤波」「差异放大」共用同一套样式, + // 仅标题、内容行与底部按钮不同。 + for (int pass = 0; pass < kEdgePanelKindCount; ++pass) { const EdgePanelKind kind = kEdgePanelKinds[pass]; const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); + const bool diffAmp = (kind == EdgePanelKind::DIFF_AMP); + // 「均值滤波」与「差异放大」共用「逐条目 + 底部按钮」排布。 + const bool itemized = (centerAxis || meanFilter || diffAmp); + const bool hasButton = (meanFilter || diffAmp); for (std::size_t index = 0; index < count; ++index) { const Lane& tabLane = tabLanes[index]; @@ -7277,7 +7568,9 @@ namespace jdi m_theme->text, TextAlign::CENTER); const std::wstring title = meanFilter ? std::wstring(L"均值滤波") - : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix()); + : (diffAmp ? std::wstring(L"差异放大") + : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + + EdgeRangeSuffix())); UiText(g, title, m_fonts->uiSmall, gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y, panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight, @@ -7305,10 +7598,10 @@ namespace jdi closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale); - // 「中心轴边沿计数」/「均值滤波」面板都采用「逐条目」排布: - // 第一排 = 名称 + 复选框,第二排 = 均值点数 X / 阶数 N(仅勾选的条目), + // 「中心轴边沿计数」/「均值滤波」/「差异放大」面板都采用「逐条目」排布: + // 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目), // 中心轴再多第三排 = 对应的统计结果(同样仅勾选的条目)。 - if (meanFilter || centerAxis) + if (itemized) { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { @@ -7321,7 +7614,8 @@ namespace jdi const std::uint32_t signalColor = StyleColor(*resolved.style); const gd::RectF& checkRect = layout.rowChecks[rowIndex]; const bool checked = meanFilter ? tabLane.MeanFilterEnabled(ref) - : tabLane.EdgeCenterEnabled(ref); + : (diffAmp ? tabLane.DiffAmpEnabled(ref) + : tabLane.EdgeCenterEnabled(ref)); // 名称:第一排左侧,避开右侧复选框。 const float nameWidth = @@ -7338,15 +7632,26 @@ namespace jdi continue; } - // 第二排:两个输入框,左侧标“均值点数 X”,右侧标“阶数 N”。 - const Lane::MeanFilterItem* item = meanFilter ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref); - const int shownPoints = (item != nullptr) ? item->points : 5; - const int shownOrder = (item != nullptr) ? item->order : 1; - const PlotEditTarget pointsTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS - : PlotEditTarget::EDGE_CENTER_POINTS; - const PlotEditTarget orderTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_ORDER - : PlotEditTarget::EDGE_CENTER_ORDER; + // 第二排:两个输入框。均值滤波是“均值点数 X / 阶数 N”, + // 差异放大是“窗口点数 X / 放大倍数 N”(同一套排版)。 + const Lane::MeanFilterItem* item = + meanFilter ? tabLane.FindMeanFilterItem(ref) + : (diffAmp ? tabLane.FindDiffAmpItem(ref) + : tabLane.FindEdgeCenterItem(ref)); + const int shownPoints = + (item != nullptr) ? item->points : (diffAmp ? 64 : 5); + const int shownOrder = + (item != nullptr) ? item->order : (diffAmp ? 2 : 1); + const wchar_t* pointsLabel = diffAmp ? L"窗口点数 X" : L"均值点数 X"; + const wchar_t* orderLabel = diffAmp ? L"放大倍数 N" : L"阶数 N"; + const PlotEditTarget pointsTarget = + meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS + : (diffAmp ? PlotEditTarget::DIFF_AMP_POINTS + : PlotEditTarget::EDGE_CENTER_POINTS); + const PlotEditTarget orderTarget = + meanFilter ? PlotEditTarget::MEAN_FILTER_ORDER + : (diffAmp ? PlotEditTarget::DIFF_AMP_ORDER + : PlotEditTarget::EDGE_CENTER_ORDER); const auto paintField = [&](const gd::RectF& rect, const wchar_t* label, int value, PlotEditTarget target) { @@ -7383,8 +7688,8 @@ namespace jdi m_theme->textStrong, TextAlign::CENTER); } }; - paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, pointsTarget); - paintField(layout.rowOrder[rowIndex], L"阶数 N", shownOrder, orderTarget); + paintField(layout.rowPoints[rowIndex], pointsLabel, shownPoints, pointsTarget); + paintField(layout.rowOrder[rowIndex], orderLabel, shownOrder, orderTarget); // 第三排(中心轴):该信号在当前参数下的上升沿/下降沿统计结果。 if (centerAxis && rowIndex < layout.rowStats.size() && @@ -7414,7 +7719,7 @@ namespace jdi { continue; } - if (meanFilter || centerAxis) + if (itemized) { // 逐条目绘制已在上面完成。 continue; @@ -7438,8 +7743,8 @@ namespace jdi rowY += rowHeight * 2.0f; } - // 均值滤波面板底部按钮行:「构建为新信号」(悬停高亮)。 - if (meanFilter) + // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮)。 + if (hasButton) { const gd::RectF& buttonRect = layout.button; if (buttonRect.Width > 0.0f) @@ -7453,7 +7758,7 @@ namespace jdi UiFillRoundRect(g, inner, 4.0f * m_scale, hot ? WithAlpha(m_theme->accent, 0x3A) : m_theme->cardBg); UiDrawRoundRect(g, inner, 4.0f * m_scale, m_theme->accent, 1.2f); - UiText(g, kMeanFilterBuildLabel, m_fonts->uiSmall, inner, m_theme->accent, + UiText(g, kBuildSignalLabel, m_fonts->uiSmall, inner, m_theme->accent, TextAlign::CENTER); } } diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index a20fd82..1718f52 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -373,6 +373,15 @@ namespace jdi /** 中心轴边沿计数的平滑参数:与均值滤波共用同一结构与同一套语义。 */ using EdgeCenterItem = MeanFilterItem; + /** + * @brief 「差异放大」中某个信号自己的参数。 + * + * 字段含义与均值滤波/中心轴不同(但同样是「一个信号 + 两个整数参数」, + * 因此复用同一结构以共享面板、缓存与引用重映射的实现): + * `points` = 窗口点数 X(趋势窗口半径),`order` = 放大倍数 N。 + */ + using DiffAmpItem = MeanFilterItem; + std::wstring name; /**< 网格名称。 */ std::vector signals; /**< 该网格包含的信号(按加入顺序)。 */ bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */ @@ -490,6 +499,48 @@ namespace jdi /** 均值滤波面板左上角纵坐标(<0 表示自动定位)。 */ double meanFilterPanelY = -1.0; + + /** + * @brief 「实验性功能 → 差异放大」开关。 + * + * 开启后为本网格内**被勾选**的信号额外绘制一条「差异放大」曲线: + * 把信号与局部趋势线的差值按“局部差异强度”归一化后放大, + * 使不明显的下凹(如纹波里的弱 M 形)变明显,而差异已经很大的区域**逐点不变**。 + * 这条曲线只是叠加显示,不会作为信号加入左侧列表。 + */ + bool diffAmp = false; + + /** + * @brief 参与差异放大预览的信号及其参数(只包含被勾选的信号)。 + * + * `points` = 窗口点数 X(局部差异强度统计半径,约取纹波周期的一半), + * `order` = 放大倍数 N(最大放大倍数)。 + */ + std::vector diffAmpItems; + + /** 取某个信号的差异放大参数(未勾选时返回空指针)。 */ + const DiffAmpItem* FindDiffAmpItem(const SignalRef& ref) const; + + /** 取得或创建某信号的差异放大参数(用于勾选)。 */ + DiffAmpItem& EnsureDiffAmpItem(const SignalRef& ref); + + /** 取消某信号的差异放大勾选。 */ + void RemoveDiffAmpItem(const SignalRef& ref); + + /** 取某信号的差异放大勾选状态。 */ + bool DiffAmpEnabled(const SignalRef& ref) const + { + return FindDiffAmpItem(ref) != nullptr; + } + + /** 差异放大浮动面板是否展开。 */ + bool diffAmpPanelOpen = true; + + /** 差异放大面板左上角位置(客户区像素;<0 表示自动定位)。 */ + double diffAmpPanelX = -1.0; + + /** 差异放大面板左上角纵坐标(<0 表示自动定位)。 */ + double diffAmpPanelY = -1.0; }; /** @@ -519,6 +570,45 @@ namespace jdi */ void ComputeMovingAverage(const std::vector& values, int radius, std::vector& out); + /** + * @brief 计算 N 点滑动平均(float 输入版本)。 + * + * 与 `ComputeMovingAverage(const std::vector&, ...)` 完全同一实现, + * 供「差异放大」在 float 缓冲上二次滑动平均使用:亿级采样下若用 double 中转 + * 会多占一倍内存,而曲线本身只要求 float 精度。 + * + * @param [IN] values 原始采样值。 + * @param [IN] radius 平滑半径 N(0 表示原样返回)。 + * @param [OUT] out 输出数组(长度与 values 相同)。 + */ + void ComputeMovingAverage(const std::vector& values, int radius, std::vector& out); + + /** + * @brief 「差异放大」:把不明显的局部差异放大,而差异已经很大的区域逐点不变。 + * + * 算法(三部分,全部 O(n)): + * 1. **趋势线** `trend` = 以 8×window 为半径的滑动平均 —— 把“整体”(低频成分)分离出来; + * 该半径必须远大于纹波周期,趋势线才不带着纹波。 + * 2. **局部差异强度** `strength` = |采样值 − 趋势线| 的滑动平均(半径 = window) + * —— 衡量该处“纹波有多明显”。窗口恰好覆盖一个纹波周期时该值与相位无关 + * (正弦为 2A/π),因此 `window` 应取**纹波周期的一半左右**; + * 3. **逐点增益** `gain = clamp(目标强度 / 该处强度, 1, 放大倍数)`, + * 目标强度取全信号差异强度的 75 分位(用直方图统计,O(1) 额外内存; + * 用均值会被个别尖峰抬高,用最大值则完全被最尖处主导)。 + * + * 输出 `out = trend + gain × (采样值 − trend)`。 + * **增益下界为 1**,所以差异强度不低于目标强度的采样点(即绝大多数“正常”数据点) + * 输出与原值逐位相同 —— 这正是“不影响其它数据点的振幅范围”的实现方式; + * 只有强度低于目标强度的区域(如被削弱的那个周期)才被放大,最多放大到 `gain` 倍。 + * + * @param [IN] values 原始采样值。 + * @param [IN] window 窗口点数 X(局部差异强度统计半径,应取纹波周期的一半左右)。 + * @param [IN] gain 放大倍数 N(最大增益,至少 1)。 + * @param [OUT] out 输出数组(长度与 values 相同;原值为 NaN 处保持 NaN)。 + */ + void ComputeDiffAmplify(const std::vector& values, int window, int gain, + std::vector& out); + /** * @brief 计算多阶(迭代)滑动平均:把 N 点滑动平均重复应用 `order` 次。 * diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index eb52db0..a5899a1 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -2,7 +2,10 @@ #include #include +#include +#include #include +#include #include #include "Theme.hpp" @@ -131,6 +134,11 @@ namespace jdi bool meanFilterPanelOpen = true; double meanFilterPanelX = -1.0; double meanFilterPanelY = -1.0; + bool diffAmp = false; + std::vector diffAmpItems; + bool diffAmpPanelOpen = true; + double diffAmpPanelX = -1.0; + double diffAmpPanelY = -1.0; }; // 先记下每个信号原来所在网格的网格自身状态: @@ -156,6 +164,11 @@ namespace jdi carry.meanFilterPanelOpen = lane.meanFilterPanelOpen; carry.meanFilterPanelX = lane.meanFilterPanelX; carry.meanFilterPanelY = lane.meanFilterPanelY; + carry.diffAmp = lane.diffAmp; + carry.diffAmpItems = lane.diffAmpItems; + carry.diffAmpPanelOpen = lane.diffAmpPanelOpen; + carry.diffAmpPanelX = lane.diffAmpPanelX; + carry.diffAmpPanelY = lane.diffAmpPanelY; for (const SignalRef& ref : lane.signals) { previousCarry.emplace_back(ref, carry); @@ -192,6 +205,11 @@ namespace jdi lane.meanFilterPanelOpen = carry.meanFilterPanelOpen; lane.meanFilterPanelX = carry.meanFilterPanelX; lane.meanFilterPanelY = carry.meanFilterPanelY; + lane.diffAmp = carry.diffAmp; + lane.diffAmpItems = carry.diffAmpItems; + lane.diffAmpPanelOpen = carry.diffAmpPanelOpen; + lane.diffAmpPanelX = carry.diffAmpPanelX; + lane.diffAmpPanelY = carry.diffAmpPanelY; }; // 收集全部可见信号引用。 @@ -893,86 +911,231 @@ namespace jdi // ============================ 采样辅助 ============================ - void ComputeMovingAverage(const std::vector& values, int radius, std::vector& out) + namespace { - const int window = std::max(0, radius); - const std::size_t count = values.size(); - out.assign(count, std::numeric_limits::quiet_NaN()); - if (count == 0 || window == 0) + /** + * @brief 滑动平均的共用实现(输入只要可转成 double)。 + * + * 窗口 = [max(0,i-N), min(count-1,i+N)],每前进一步只增减边界处的两个采样点, + * 整体 O(n),不额外占用前缀和内存。“差异放大”需要在 float 缓冲上再做一次 + * 滑动平均,若先转成 double 会因为亿级采样多占一倍内存,因此做成模板。 + */ + template + void MovingAverageImpl(const std::vector& values, int radius, std::vector& out); + /** + * @brief 滑动窗口最大值(只要最大值)。 + * + * 与 `SlidingExtrema` 同一个单调队列思路,但**不额外分配最小值数组**: + * 亿级采样下一次全量计算会多占 100 MB 以上,而“差异放大”只需要最大值。 + * + * @param [IN] values 采样值。 + * @param [IN] radius 邻域半径(窗口 = [i-radius, i+radius],两端按实际长度)。 + * @param [OUT] outMax 邻域最大值(整窗都是 NaN 时为 NaN)。 + */ + template + void SlidingMax(const std::vector& values, std::size_t radius, std::vector& outMax) { - // 半径 0 表示不平滑:原样复制(中心点无效处仍为 NaN)。 + const std::size_t count = values.size(); + outMax.assign(count, std::numeric_limits::quiet_NaN()); + if (count == 0) + { + return; + } + + std::deque maxQueue; // 递减:队首最大。 + std::size_t filled = 0; for (std::size_t index = 0; index < count; ++index) { - if (std::isfinite(values[index])) + const std::size_t wantHigh = std::min(count - 1, index + radius); + while (filled <= wantHigh) { - out[index] = static_cast(values[index]); + const double incoming = values[filled]; + if (std::isfinite(incoming)) + { + while (!maxQueue.empty() && values[maxQueue.back()] <= incoming) + { + maxQueue.pop_back(); + } + maxQueue.push_back(filled); + } + ++filled; } - } - return; - } - - // 滑动窗口求和:窗口 = [max(0,i-N), min(count-1,i+N)], - // 每前进一步只增减边界处的两个采样点,整体 O(n),不额外占用前缀和内存。 - double sum = 0.0; - std::size_t finiteCount = 0; - std::size_t windowLow = 0; - std::size_t windowHigh = 0; // 闭区间右端。 - - const auto addValue = [&sum, &finiteCount](double value) - { - if (std::isfinite(value)) - { - sum += value; - ++finiteCount; - } - }; - const auto removeValue = [&sum, &finiteCount](double value) - { - if (std::isfinite(value)) - { - sum -= value; - --finiteCount; - } - }; - - for (std::size_t index = 0; index < count; ++index) - { - const std::size_t low = (index > static_cast(window)) - ? (index - static_cast(window)) - : 0; - const std::size_t high = std::min(count - 1, index + static_cast(window)); - - if (index == 0) - { - windowLow = low; - windowHigh = high; - for (std::size_t i = windowLow; i <= windowHigh; ++i) + const std::size_t low = (index > radius) ? (index - radius) : 0; + while (!maxQueue.empty() && maxQueue.front() < low) { - addValue(values[i]); + maxQueue.pop_front(); } - } - else - { - while (windowLow < low) + if (!maxQueue.empty()) { - removeValue(values[windowLow]); - ++windowLow; + outMax[index] = static_cast(values[maxQueue.front()]); } - while (windowHigh < high) - { - ++windowHigh; - addValue(values[windowHigh]); - } - } - - // 中心点本身无效(NaN)时该处不产生有效结果。 - if (finiteCount > 0 && std::isfinite(values[index])) - { - out[index] = static_cast(sum / static_cast(finiteCount)); } } + } // namespace + + void ComputeMovingAverage(const std::vector& values, int radius, std::vector& out) + { + MovingAverageImpl(values, radius, out); } + void ComputeMovingAverage(const std::vector& values, int radius, std::vector& out) + { + MovingAverageImpl(values, radius, out); + } + + namespace + { + template + void MovingAverageImpl(const std::vector& values, int radius, std::vector& out) + { + const int window = std::max(0, radius); + const std::size_t count = values.size(); + out.assign(count, std::numeric_limits::quiet_NaN()); + if (count == 0 || window == 0) + { + // 半径 0 表示不平滑:原样复制(中心点无效处仍为 NaN)。 + for (std::size_t index = 0; index < count; ++index) + { + const double value = static_cast(values[index]); + if (std::isfinite(value)) + { + out[index] = static_cast(value); + } + } + return; + } + + // 滑动窗口求和:每前进一步只增减边界处的两个采样点,整体 O(n)。 + double sum = 0.0; + std::size_t finiteCount = 0; + std::size_t windowLow = 0; + std::size_t windowHigh = 0; // 闭区间右端。 + + const auto addValue = [&sum, &finiteCount](double value) + { + if (std::isfinite(value)) + { + sum += value; + ++finiteCount; + } + }; + const auto removeValue = [&sum, &finiteCount](double value) + { + if (std::isfinite(value)) + { + sum -= value; + --finiteCount; + } + }; + + for (std::size_t index = 0; index < count; ++index) + { + const std::size_t low = (index > static_cast(window)) + ? (index - static_cast(window)) + : 0; + const std::size_t high = + std::min(count - 1, index + static_cast(window)); + + if (index == 0) + { + windowLow = low; + windowHigh = high; + for (std::size_t i = windowLow; i <= windowHigh; ++i) + { + addValue(static_cast(values[i])); + } + } + else + { + while (windowLow < low) + { + removeValue(static_cast(values[windowLow])); + ++windowLow; + } + while (windowHigh < high) + { + ++windowHigh; + addValue(static_cast(values[windowHigh])); + } + } + + // 中心点本身无效(NaN)时该处不产生有效结果。 + const double center = static_cast(values[index]); + if (finiteCount > 0 && std::isfinite(center)) + { + out[index] = static_cast(sum / static_cast(finiteCount)); + } + } + } + + /** + * @brief 滑动窗口极值:一次求出每个中心点邻域(±radius)内的最小值与最大值。 + * + * 两个单调队列(最小值队列递增、最大值队列递减)同步滑动,O(n) 时间, + * 内存只有窗口长度。NaN 不参与(整窗都是 NaN 时输出 NaN)。 + * + * @param [IN] values 采样值。 + * @param [IN] radius 邻域半径(窗口 = [i-radius, i+radius],两端按实际长度)。 + * @param [OUT] outMin 邻域最小值。 + * @param [OUT] outMax 邻域最大值。 + */ + template + void SlidingExtrema(const std::vector& values, std::size_t radius, + std::vector& outMin, std::vector& outMax) + { + const std::size_t count = values.size(); + outMin.assign(count, std::numeric_limits::quiet_NaN()); + outMax.assign(count, std::numeric_limits::quiet_NaN()); + if (count == 0) + { + return; + } + + std::deque minQueue; // 递增:队首最小。 + std::deque maxQueue; // 递减:队首最大。 + std::size_t filled = 0; + for (std::size_t index = 0; index < count; ++index) + { + const std::size_t wantHigh = std::min(count - 1, index + radius); + while (filled <= wantHigh) + { + const double incoming = values[filled]; + if (std::isfinite(incoming)) + { + while (!minQueue.empty() && values[minQueue.back()] >= incoming) + { + minQueue.pop_back(); + } + minQueue.push_back(filled); + while (!maxQueue.empty() && values[maxQueue.back()] <= incoming) + { + maxQueue.pop_back(); + } + maxQueue.push_back(filled); + } + ++filled; + } + const std::size_t low = (index > radius) ? (index - radius) : 0; + while (!minQueue.empty() && minQueue.front() < low) + { + minQueue.pop_front(); + } + while (!maxQueue.empty() && maxQueue.front() < low) + { + maxQueue.pop_front(); + } + if (!minQueue.empty()) + { + outMin[index] = static_cast(values[minQueue.front()]); + } + if (!maxQueue.empty()) + { + outMax[index] = static_cast(values[maxQueue.front()]); + } + } + } + } // namespace + void ComputeMovingAverageIterated(const std::vector& values, int radius, int order, std::vector& out) { @@ -999,6 +1162,204 @@ namespace jdi } } + void ComputeDiffAmplify(const std::vector& values, int window, int gain, + std::vector& out) + { + const std::size_t count = values.size(); + out.assign(count, std::numeric_limits::quiet_NaN()); + if (count == 0) + { + return; + } + + // 窗口点数 X:局部“差异强度”的统计**窗口长度**(点数),窗口 = [i−r, i+r], + // r = (X−1)/2。窗口长度**应略大于纹波周期的一半**: + // 只有当窗口跨越的相位达到 180° 时,窗口内才必定包含一个波峰或波谷, + // “局部 |交流分量| 的峰值”因而恒等于纹波振幅 A,与相位无关 + //(正常区域恒为 A、弱区恒为 A/2,比值恰为 2);窗口再小就会跟随相位起伏 + //(把正常区域的波峰也一起放大),远大于一个周期时又会把弱区与邻近正常区一起统计掉。 + // 窗口越短、混叠区(弱区边界各 ±r)越窄,对“只有一两个周期(甚至半周期)变弱”的 + // 情形越有效。 + const int span = std::max(3, std::min(window, 1000000)); + const int strengthRadius = std::max(1, (span - 1) / 2); + // 趋势窗口取局部窗口的 2 倍(≈ 一个完整纹波周期):只有按整周期取平均, + // 趋势线才恰好是纹波的中心线(交流分量均值为零),待放大的弱区也就**不会** + // 因为自己变“扁”而把趋势线一起拉偏。 + const int trendRadius = std::max(1, strengthRadius * 2); + // 包络窗口固定取局部窗口的 8 倍:用来给出“周边本来的振幅范围”, + // 既作为放大基准(中心值)也作为输出的夹取上下界。 + const std::size_t envelopeRadius = + static_cast(std::max(1, std::min(strengthRadius * 8, 16384))); + const double maxGain = static_cast(std::max(1, gain)); + + // 1) 周边包络:邻域内采样值的最小/最大值(单调队列,O(n))。 + // 邻域**必须明显大于一个纹波周期**,否则包络会跟着纹波起伏, + // 放大后会把相邻正常区域的相位也一并扭曲。 + std::vector envMin; + std::vector envMax; + SlidingExtrema(values, envelopeRadius, envMin, envMax); + + // 2) 局部差异强度 = 邻域内 |采样值 − 趋势线| 的**最大值**(即局部纹波振幅)。 + // + // 这里刻意**不用**另外两种看起来更自然的做法: + // * “|采样值 − 趋势线| 的平均”:趋势线是低频量,会被待放大的弱区本身拉偏, + // 于是弱区周边本来正常的采样点也会被判成“差异偏小”而被一起放大; + // * “原始采样值的峰峰值”:峰峰值要同时覆盖波峰与波谷才与相位无关, + // 这要求窗口跨越 360°,窗口会大到把弱区与邻近正常区平均掉 + // (实测一个周期宽的弱区只能恢复三分之一)。 + // |交流分量| 的最大值只需要窗口跨越 180°(窗口约半个周期)就与相位无关, + // 既能准确识别弱区,又因为窗口很短而具备足够好的定位精度。 + // + // 又因为增益下界恒为 1(见第 4 步),正常区域的增益恰好是 1, + // 输出与输入**逐位相同**,因此对其它数据点真正没有任何影响。 + // + // 极值窗口按**实际长度**(端点截断)统计,端点因此会低估振幅、 + // 导致增益被高估;这一端点效应由第 4 步的收敛系数消除。 + std::vector trend; + ComputeMovingAverage(values, trendRadius, trend); + std::vector strength; + { + // `ac` 只在求局部最大值时用到:单独放在一个作用域里, + // 亿级采样下这一份 100 MB 级的中转数组能尽早归还给系统。 + std::vector ac(count, std::numeric_limits::quiet_NaN()); + for (std::size_t index = 0; index < count; ++index) + { + const double value = values[index]; + if (std::isfinite(value) && std::isfinite(trend[index])) + { + ac[index] = static_cast(std::fabs(value - static_cast(trend[index]))); + } + } + SlidingMax(ac, static_cast(strengthRadius), strength); + } + + // 3) 目标强度:差异强度的 75 分位。用直方图统计 → O(1) 额外内存, + // 且不会被个别尖峰抬高(用均值会),也不会被最尖处完全主导(用最大值会)。 + // + // ⚠️ 统计时**排除首尾的端点收敛区**(见第 4 步):那里的趋势窗口被截断, + // “|交流分量| 的最大值”整体偏大,会把分位数抬高、导致目标强度偏大。 + // 待统计的采样点数不足时退回全段统计。 + const std::size_t edgeGuard = + static_cast(2) * static_cast(trendRadius); + // 剩下足够多的采样点时才有必要排除端点区(短信号就直接全段统计)。 + constexpr std::size_t kEdgeGuardMinimum = 256; + const bool useGuard = (count > edgeGuard * 2 + kEdgeGuardMinimum); + const auto inGuard = [count, edgeGuard, useGuard](std::size_t index) + { + if (!useGuard) + { + return true; + } + return (index >= edgeGuard) && ((count - 1 - index) >= edgeGuard); + }; + + float maxStrength = 0.0f; + for (std::size_t index = 0; index < count; ++index) + { + const float value = strength[index]; + if (inGuard(index) && std::isfinite(value) && value > maxStrength) + { + maxStrength = value; + } + } + if (!(maxStrength > 0.0f)) + { + // 完全平坦(或全为 NaN)的信号:无差异可放大,等价于原样输出。 + for (std::size_t index = 0; index < count; ++index) + { + const double value = values[index]; + if (std::isfinite(value)) + { + out[index] = static_cast(value); + } + } + return; + } + + constexpr int kBinCount = 2048; + std::vector histogram(static_cast(kBinCount), 0); + std::size_t sampleCount = 0; + for (std::size_t index = 0; index < count; ++index) + { + const float value = strength[index]; + if (!inGuard(index) || !std::isfinite(value)) + { + continue; + } + int bin = static_cast(static_cast(value) / + static_cast(maxStrength) * kBinCount); + bin = std::max(0, std::min(bin, kBinCount - 1)); + ++histogram[static_cast(bin)]; + ++sampleCount; + } + double target = static_cast(maxStrength); + if (sampleCount > 0) + { + const std::size_t wanted = (sampleCount * 75) / 100; + std::size_t accumulated = 0; + for (int bin = 0; bin < kBinCount; ++bin) + { + accumulated += static_cast(histogram[static_cast(bin)]); + if (accumulated > wanted) + { + // 取桶中心,避免目标强度贴到桶的下边界而整体偏小。 + target = (static_cast(bin) + 0.5) / kBinCount * + static_cast(maxStrength); + break; + } + } + } + + // 4) 逐点增益并合成输出。 + // * 基准 = 趋势线(纹波的中心线,与信号整体同步,纹波在其中上下摆动); + // * 增益下界为 1 → 局部振幅不比目标值小的采样点**输出与原值逐位相同** + // (基准被 1 倍增益完全消掉,因此对其它数据点没有任何影响); + // * 放大后的取值再夹到周边包络内 → **任何一点都不会超出原有的振幅范围** + // (尤其重要:被放大区域两侧自身的振幅本来就很大,不应被继续抬升)。 + for (std::size_t index = 0; index < count; ++index) + { + const double value = values[index]; + if (!std::isfinite(value)) + { + continue; + } + const bool hasEnvelope = std::isfinite(envMin[index]) && std::isfinite(envMax[index]); + const double base = + std::isfinite(trend[index]) ? static_cast(trend[index]) : value; + + double pointGain = 1.0; + const float local = strength[index]; + if (std::isfinite(local) && local > 0.0f) + { + pointGain = target / static_cast(local); + } + pointGain = std::max(1.0, std::min(pointGain, maxGain)); + + // 端点收敛:首尾各 2r 个采样点的**趋势窗口与极值窗口都被截断** + //(趋势窗口半径 2r、极值窗口半径 r),局部振幅与中心线都被系统性估偏。 + // 因此这些采样点一律保持原样(增益恰为 1),再在接下来的 2r 个点内 + // 平滑过渡到完整增益,避免记录首尾被整片抬升。 + // + // ⚠️ 不能靠“镜像延拓”解决:镜像后的窗口覆盖的真实下标集合与截断窗口相同 + //(例如 i=16、r=40 时两者都只覆盖真实下标 0..56),因此不会带来任何新信息。 + const double taperStart = static_cast(trendRadius); + const double taperSpan = std::max(1.0, static_cast(trendRadius)); + const double distanceToEdge = + static_cast(std::min(index, count - 1 - index)); + const double taper = + std::max(0.0, std::min(1.0, (distanceToEdge - taperStart) / taperSpan)); + pointGain = 1.0 + (pointGain - 1.0) * taper; + + double amplified = base + pointGain * (value - base); + if (hasEnvelope) + { + amplified = std::max(static_cast(envMin[index]), + std::min(amplified, static_cast(envMax[index]))); + } + out[index] = static_cast(amplified); + } + } + const Lane::MeanFilterItem* Lane::FindMeanFilterItem(const SignalRef& ref) const { for (const MeanFilterItem& item : meanFilterItems) @@ -1069,6 +1430,46 @@ namespace jdi edgeCenterItems.end()); } + const Lane::DiffAmpItem* Lane::FindDiffAmpItem(const SignalRef& ref) const + { + for (const DiffAmpItem& item : diffAmpItems) + { + if (item.ref == ref) + { + return &item; + } + } + return nullptr; + } + + Lane::DiffAmpItem& Lane::EnsureDiffAmpItem(const SignalRef& ref) + { + for (DiffAmpItem& item : diffAmpItems) + { + if (item.ref == ref) + { + return item; + } + } + DiffAmpItem item; + item.ref = ref; + // 默认参数:窗口点数 X = 80(长度)、放大倍数 N = 2。 + // 窗口长度需略大于纹波周期的一半(见 ComputeDiffAmplify 的说明), + // 80 点可覆盖周期约 160 采样以内的纹波。 + item.points = 80; + item.order = 2; + diffAmpItems.push_back(item); + return diffAmpItems.back(); + } + + void Lane::RemoveDiffAmpItem(const SignalRef& ref) + { + diffAmpItems.erase( + std::remove_if(diffAmpItems.begin(), diffAmpItems.end(), + [&ref](const DiffAmpItem& item) { return item.ref == ref; }), + diffAmpItems.end()); + } + int FindNearestSample(const DataFile& file, double time) { const std::size_t count = file.time.size(); @@ -1313,6 +1714,7 @@ namespace jdi }; purgeRefs(lane.meanFilterItems); purgeRefs(lane.edgeCenterItems); + purgeRefs(lane.diffAmpItems); } // 被移除信号若独占了某个网格,该网格随之消失(与取消勾选的行为一致)。 m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(), @@ -1604,10 +2006,18 @@ namespace jdi lane.edgeCenterPanelOpen = source.edgeCenterPanelOpen; lane.edgeCenterPanelX = source.edgeCenterPanelX; lane.edgeCenterPanelY = source.edgeCenterPanelY; + lane.diffAmp = source.diffAmp; + lane.diffAmpPanelOpen = source.diffAmpPanelOpen; + lane.diffAmpPanelX = source.diffAmpPanelX; + lane.diffAmpPanelY = source.diffAmpPanelY; lane.meanFilter = source.meanFilter; - // 拆分后每个子网格只保留自己那个信号的均值滤波 / 中心轴条目。 + lane.meanFilterPanelOpen = source.meanFilterPanelOpen; + lane.meanFilterPanelX = source.meanFilterPanelX; + lane.meanFilterPanelY = source.meanFilterPanelY; + // 拆分后每个子网格只保留自己那个信号的均值滤波 / 中心轴 / 差异放大条目。 lane.meanFilterItems.clear(); lane.edgeCenterItems.clear(); + lane.diffAmpItems.clear(); for (const Lane::MeanFilterItem& item : source.meanFilterItems) { if (item.ref == ref) @@ -1622,13 +2032,16 @@ namespace jdi lane.edgeCenterItems.push_back(item); } } - lane.meanFilterPanelOpen = source.meanFilterPanelOpen; - lane.meanFilterPanelX = source.meanFilterPanelX; - lane.meanFilterPanelY = source.meanFilterPanelY; + for (const Lane::DiffAmpItem& item : source.diffAmpItems) + { + if (item.ref == ref) + { + lane.diffAmpItems.push_back(item); + } + } lane.signals.push_back(ref); pieces.push_back(std::move(lane)); } - // 用 pieces 替换原网格(插入到原位置,因此其后方的网格整体顺次后移)。 m_lanes.erase(m_lanes.begin() + laneIndex); m_lanes.insert(m_lanes.begin() + laneIndex, pieces.begin(), pieces.end()); @@ -1680,6 +2093,13 @@ namespace jdi ++item.ref.signalIndex; } } + for (Lane::DiffAmpItem& item : lane.diffAmpItems) + { + if (item.ref.fileIndex == fileIndex && item.ref.signalIndex >= insertPos) + { + ++item.ref.signalIndex; + } + } } }