diff --git a/docs/superpowers/specs/2026-06-30-radar-import-process-render-pipeline.md b/docs/superpowers/specs/2026-06-30-radar-import-process-render-pipeline.md new file mode 100644 index 0000000..1c9fac0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-radar-import-process-render-pipeline.md @@ -0,0 +1,115 @@ +# 三维雷达 导入→处理→渲染 全链路方案(结合 POC 评估) + +> 2026-06-30。本文把用户给出的雷达产品目标落成方案,并结合 POC(明星路 13G)已验证的资产, +> 评估复用/缺口、定关键架构缝与决策、排风险与分期。范围限定**三维雷达**(渲染/切片/异常 + +> 其上游的导入/处理管线);不含 2D 雷达图、不含后端反演链。 + +## 1. 用户目标(七步) + +1. 设备经 USB 接到用户电脑。 +2. 客户端「设备连接」功能:自动识别设备、打开 USB 存储,用户选文件导入。 +3. 另一导入分支:文件已在用户电脑,经**文件夹选择**导入。 +4. 导入过程按文件类型(不同型号雷达)**自动加载插件**,对数据做 **ds 标准化转换**。 +5. 导入完成自动形成 **项目 / GS / TM / ds** 结构(建 GS/TM 的「方案」待细化)。 +6. 数据集详情页:用**数据处理插件**处理原始数据,插件支持多种方法(用户勾选); + 另**固定加入两个客户端内置处理方法**:**插值**、**预渲染**(即为 LOD 做准备)。 + 处理后**存为新 ds**。 +7. VTK 视图:选三维雷达 ds 渲染、切片等;**所选 ds 可能是未处理原数据,也可能是处理后数据**(不同 ds)。 + +## 2. 关键决策(用户已拍板) + +- **D1 — 预渲染(LOD 烘焙)是可选的。** 默认勾选,但用户可取消。 + → **渲染路径必须同时支持「未预渲染」与「已预渲染」两类 ds**(不能假设所有大体都已烘焙 LOD)。 + → 采用**混合渲染源**(见 §4):原/插值 ds 走整卷源;预渲染 ds 走 LOD 源。 + +## 3. POC ↔ 目标 映射(复用 vs 缺口) + +**结论:算法基本齐(POC/app 已有标准化、插值、增益、LOD 引擎、渲染源抽象);缺的是三层"框架/管线" ++ 设备接入。** + +| 步骤 | 已有(POC/现状) | 缺口 | +|---|---|---| +| 1–2 设备 USB/存储 | 无 | **全新**:Windows 设备识别 + USB 盘浏览(与 POC 无关,纯平台 plumbing) | +| 3 文件夹导入 | 已有导入入口、`tools/radar_convert` malamira 转换器 | 文件夹选择 + 批量 | +| 4 按型号插件标准化 | 转换算法有(malamira→规范化 `.head/.data`、`RadarVolumeAssembler`、int16 量化) | **导入插件框架**(按文件类型注册 reader);现写死一种 | +| 5 项目/GS/TM/ds 结构 | ds 树(`sourceShowParentId` 派生嵌套)已在 | 自动建 GS/TM 的「方案」 | +| 6 处理插件 + 两内置 | 两内置**算法都有**:插值=`createRadarVolumeGrid` 通道插值(targetDy);预渲染=`ChunkedVolumeStore::write`+`buildPyramidStreaming`。增益(dewow/AGC/tpow)亦有 | **处理插件框架** + 「处理→存为新 ds」管线 + 多方法勾选 UI | +| 7 选 ds 渲染/切片 | **渲染源抽象 `IVolumeRenderSource`(整卷/LOD 多态,含 `sliceSource()`)** + 整卷渲染 + 切片 + 异常 | 把 app 雷达路径迁到 `IVolumeRenderSource`;LOD 源接进 app(Track D) | + +## 4. 架构缝:`IVolumeRenderSource`(已设计好,最低风险) + +POC 已建好渲染源抽象(`src/render/source/IVolumeRenderSource.hpp`):上层(控制器/`SliceTool`)只认此接口, +运行时在两种实现间切换: + +- **`WholeVolumeSource`(整卷)** —— 给**未预渲染**的原/插值 ds(小体,单纹理够用)。 +- **`ViewAdaptiveVolumeSource`(核外金字塔 LOD)** —— 给**已预渲染**的 ds(大体,按相机选层/选块重组单纹理)。 + +接口自带 `update(vtkCamera)`、`currentImages()`、**`sliceSource()`**(切片/异常的 reslice 基底也走它), +故"切片在两种源上都能切"是接口内建能力,不需两套切片代码。 + +> **D1 落到这里**:选 ds 渲染时按"该 ds 是否带 LOD store"路由到对应源。未预渲染 → 整卷源(现有内存 +> 体路径迁入即可);已预渲染 → LOD 源(Track D 接入)。 + +## 5. 处理与数据血缘模型 + +- 处理一律**产出新 ds**,挂在源 ds 下(复用现有派生树 `sourceShowParentId`): + ``` + 原始 ds ─[插值]→ 插值 ds ─[预渲染]→ 预渲染 ds(LOD store) + └─[增益/migration/…(可多选)]→ 处理 ds + ``` +- **两个内置处理方法**(client 自带、固定加入): + - **插值**:线内通道插值(读真实道偏移、目标横向间距如 2.5cm,**绝不跨线**)。算法=`createRadarVolumeGrid` 的 targetDy 路径。 + - **预渲染(LOD 烘焙)**:把体烘成 **`ChunkedVolumeStore` 分块金字塔**(int16 量化、64³ brick、qCompress、 + 逐级 2× 降采样、每块 min/max;流式 `buildPyramidStreaming` 不持整卷)。产出 = 一个 **store 目录**, + 不是普通稠密体 → 该 ds 须带「类型=LOD store + 路径」标记,供 §4 渲染路由。 +- 顺序:通常先插值再预渲染(烘焙插值后的体);模型支持任选基底(也可直接烘原始)。 + +## 6. 预渲染专用落盘格式(LOD 前置,已实现于 POC) + +`ChunkedVolumeStore`(一个目录,非单文件): +- `meta.json`:几何 + 量化(scale/offset) + 逐块索引(offset/压缩长/**每块 min/max**); +- `data.bin`:逐块 int16 → qCompress;块内 i 最快、k 最慢;偏移全 64 位(卷 >2GB); +- `data_L1.bin…`:金字塔各级(逐级 2× 降采样)。 + +构建:整卷 `write` 或流式 `StreamingVolumeWriter`(逐块写不持整卷)+ `buildPyramid(Streaming)`。 +渲染:`ViewAdaptiveVolumeSource` 打开 store,`update(相机)`→选层+选块→`readBrick`→重组单 `vtkImageData`, +**内存恒定、绕 16384 纹理墙**。 + +## 7. 需要新建的三块骨架 + +1. **插件框架(两类,别混)** + - **导入插件**(步4):按文件类型/型号 → 标准化成 ds 的 reader 注册表。 + - **处理插件**(步6):吃一个 ds → 产出新 ds 的 transform,可多选串联;两内置(插值、预渲染)即自带处理插件。 + - 待定:插件接口(输入 ds/参数 → 输出 ds)、发现/注册、进程内 DLL(ABI/崩溃隔离风险)vs 子进程。 +2. **「处理 → 新 ds」管线**:血缘落树、预渲染 ds 的 store 路径/缓存/失效/磁盘占用、重处理**异步+进度+可取消**。 +3. **设备/USB 接入**(步1–2):Windows 设备识别 + USB 盘浏览。最独立、与 POC 无关,可最后做;先跑通文件夹导入。 + +## 8. 风险排序 + +1. **中**:插件框架(架构骨架,影响步4/6,定义不好后面返工)。 +2. **中**:预渲染 ds 的渲染/切片路由(Track D 核心;但**引擎+缝已验证**,是"接线"风险非"能不能做"风险)。 +3. **低–中**:处理管线异步/进度/缓存(工程量明确)。 +4. **低**:设备 USB(plumbing,独立)。 + +## 9. 分期建议 + +- **P0 验证最高技术风险**:把 app 雷达渲染迁到 `IVolumeRenderSource`,使 + - 未预渲染 ds → `WholeVolumeSource`(迁现有内存体路径); + - 预渲染 ds → `ViewAdaptiveVolumeSource`; + 南同大道先烘一个小 store 验"选 ds→按是否预渲染路由→渲染+切片"全链路。**验通则整个方案立住。** +- **P1 插件框架**:先定**处理插件**接口(含两内置),跑通"原 ds→插值→预渲染→渲染";导入插件框架并行。 +- **P2 处理管线 UI/异步**:详情页多方法勾选、进度、新 ds 落树。 +- **P3 设备 USB**:最后接。 + +## 10. 现状基线(本轮已落地的交互/渲染精修,作为接入前的稳定底座) + +- 切片拾取**精确化**:光标射线 vs 切片真实矩形求交 + 可见数据(alpha)双判定,去除外扩(雷达+反演通用)。 +- 取消选中:点击体/空白/帘面即取消(精确"命中切片"判据)+ Esc 兜底。 +- 滚轮步长:按**沿法向体素间距 × N**(Shift 粗调),不随体长跳变。 +- 双击正视:缩放到切片(按面内尺寸+视角框住),不再"又小又远"。 +- 不透明度:各向异性体用特征尺度(门控;近立方反演维持原对角线)。 +- **B 方案视角导航**:#1 绕拾取点旋转(无选中时绕光标射线穿体中段点,不甩飞); + #2 沿线位置滑块(雷达专属,沿最长轴 dolly 到窗口;仅细长体显示)。 +- 雷达显示**增益模式**右键切换(AGC/保幅 tpow/关),纯显示重建、不动原始数据。 + +> 这些是**单内存体 + 渲染期采样距自适应**底座;多分辨率/视锥 LOD 仍属 §4/§9 的 Track D 接入范畴(未做)。 diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index b9e10cc..11526e9 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -216,14 +216,22 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume volumeOwnerDs_ = dsId; volumes_[dsId] = VolumeRec{image, cs, vol.vmin, vol.vmax, volume}; // 多体并发:登记本体 image+actor - // G3 等值面:在值域高段(0.7)抽不透明实心异常体(参考图红块)。挂同一 dsProps_ → 随体一并移除。 - const double isoVal = vol.vmin + 0.7 * (vol.vmax - vol.vmin); - auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal); - if (iso) { - iso->PickableOff(); // 不参与拾取(同体 actor,避免串选) - iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏 - scene_.addActor(iso); - dsProps_[dsId].push_back(iso); + // G3 等值面:在值域高段(0.7)抽不透明实心异常体(参考图红块)——【反演专属】。 + // 雷达体(registerRadarDataset 产的 "radar-" id)跳过:振幅体的 0.7 阈值面=强反射层, + // 既无地球物理含义、又是 SetOpacity(1.0) 实色 actor【不受体不透明度控制】(用户实测: + // 体不透明度调 0 仍见灰色实面=就是它)。"radar-" 是该体唯一生产者指定的稳定 id。 + // 注:impulse-GPR("vol-")同为振幅体、亦不该有等值面,但 "vol-" 与反演共用前缀, + // 待 ddCode 贯通 addVolume 后再统一按类型门控(见 spec §11)。 + const bool isRadarVolume = dsId.rfind("radar-", 0) == 0; + if (!isRadarVolume) { + const double isoVal = vol.vmin + 0.7 * (vol.vmax - vol.vmin); + auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal); + if (iso) { + iso->PickableOff(); // 不参与拾取(同体 actor,避免串选) + iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏 + scene_.addActor(iso); + dsProps_[dsId].push_back(iso); + } } if (onVolumeChanged) onVolumeChanged(); } @@ -359,6 +367,39 @@ void VtkSceneView::applyCameraView(geopro::controller::ViewDir dir) { if (onCameraChanged) onCameraChanged(); // 相机变了 → 底图按新视锥重算覆盖 } +void VtkSceneView::focusAlongLongAxis(double t, double windowFrac) { + double b[6]; + if (!computeDataBounds(b) || !scene_.renderer()) return; + const double ex = b[1] - b[0], ey = b[3] - b[2], ez = b[5] - b[4]; + const int ax = (ex >= ey && ex >= ez) ? 0 : (ey >= ez ? 1 : 2); // 最长轴 + const double lo = b[2 * ax], hi = b[2 * ax + 1], len = hi - lo; + if (len <= 0.0) return; + if (t < 0.0) t = 0.0; + if (t > 1.0) t = 1.0; + if (windowFrac <= 0.0) windowFrac = 0.12; + const double half = 0.5 * windowFrac * len; + const double center = lo + t * len; + double sub[6] = {b[0], b[1], b[2], b[3], b[4], b[5]}; // 短轴满幅 + sub[2 * ax] = (center - half < lo) ? lo : center - half; // 长轴只取窗口段 + sub[2 * ax + 1] = (center + half > hi) ? hi : center + half; + scene_.renderer()->ResetCamera(sub); // 保持朝向,仅重定位+缩放到该窗口 + scene_.renderer()->ResetCameraClippingRange(); + if (renderWindow_) renderWindow_->Render(); + if (onCameraChanged) onCameraChanged(); // 底图随新视锥重算 +} + +double VtkSceneView::longAxisElongation() const { + double b[6]; + if (!computeDataBounds(b)) return 0.0; + const double ex = std::abs(b[1] - b[0]), ey = std::abs(b[3] - b[2]), ez = std::abs(b[5] - b[4]); + double mx = ex, mn = ex; + if (ey > mx) mx = ey; + if (ez > mx) mx = ez; + if (ey < mn) mn = ey; + if (ez < mn) mn = ez; + return (mn > 0.0) ? mx / mn : 0.0; +} + void VtkSceneView::zoom(double factor) { geopro::render::zoomBy(scene_.renderer(), factor); if (renderWindow_) renderWindow_->Render(); diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index 6b1c8a9..abd63d1 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -94,6 +94,13 @@ public: void setAnalysisMode2D(bool is2D); bool isAnalysisMode2D() const { return analysisMode2D_; } + // ── B 方案#2:沿线位置巡航(雷达超长测线)────────────────────────────────────── + // t∈[0,1] 沿数据【最长轴】定位;取景到该位置一段【窗口】(windowFrac=窗口占长轴比例), + // 保持当前朝向(ResetCamera 只重定位+缩放、不转向)→ 像滚动读长 radargram。短轴满幅、长轴只取一段。 + void focusAlongLongAxis(double t, double windowFrac); + // 数据包围盒长短轴比(max/min 跨度)。用于判是否细长(雷达)→ 决定沿线滑块显隐。无数据返回 0。 + double longAxisElongation() const; + // ── 二维分析改造 B 期:选中 2D 足迹沿高程 Z 拖动 ─────────────────────────────── // 仅二维分析下用。pickMapLineAt:在屏幕(x,y)拾取足迹(只考虑可见足迹,不被地形/底图干扰);命中则 // 选中(additive=Ctrl 多选切换,否则单选替换)并高亮,返回是否有选中(交互样式据此决定 Z 拖动/平移)。 diff --git a/src/app/main.cpp b/src/app/main.cpp index c757f91..9d3a598 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -245,6 +245,38 @@ private: std::vector raiseAfter_; // 定位后再 raise 到 overlay 之上的常驻控件(工具条/提示) }; +// 底部条浮层定位器:把 overlay 钉在 host(QVTK 画布)底部、横向铺满(留边距)。 +// 用于 B 方案#2 雷达沿线位置滑块。仅外观,无 Q_OBJECT/moc。 +class BottomBarOverlay : public QObject { +public: + BottomBarOverlay(QWidget* overlay, QWidget* host, int margin = 12, int barHeight = 34) + : QObject(host), overlay_(overlay), host_(host), margin_(margin), barHeight_(barHeight) + { + host_->installEventFilter(this); + } + void reposition() + { + const QSize h = host_->size(); + const int w = std::max(160, h.width() - 2 * margin_); + overlay_->resize(w, barHeight_); + overlay_->move(margin_, std::max(0, h.height() - barHeight_ - margin_)); + if (overlay_->isVisible()) overlay_->raise(); // GL 子控件须 raise 才可见 + } + +protected: + bool eventFilter(QObject* obj, QEvent* e) override + { + if (obj == host_ && (e->type() == QEvent::Resize || e->type() == QEvent::Show)) reposition(); + return QObject::eventFilter(obj, e); + } + +private: + QWidget* overlay_; + QWidget* host_; + int margin_; + int barHeight_; +}; + // 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。 double median(std::vector v) { @@ -471,6 +503,39 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re elevHint->show(); elevHint->raise(); }); + // ── B 方案#2:雷达沿线位置滑块(超长测线巡航)──────────────────────────────────── + // 拖动 → 相机沿数据最长轴 dolly 到该位置的一段窗口(focusAlongLongAxis)。仅细长(雷达)体显示。 + auto* alongLineBar = new QWidget(vtkWidget); + alongLineBar->setObjectName(QStringLiteral("alongLineBar")); + auto* alongLineSlider = new QSlider(Qt::Horizontal, alongLineBar); + { + auto* lay = new QHBoxLayout(alongLineBar); + lay->setContentsMargins(12, 4, 12, 4); + lay->setSpacing(10); + auto* lbl = new QLabel(QStringLiteral("沿线位置"), alongLineBar); + lbl->setObjectName(QStringLiteral("alongLineLbl")); + alongLineSlider->setRange(0, 1000); + alongLineSlider->setValue(0); + lay->addWidget(lbl); + lay->addWidget(alongLineSlider, 1); + } + geopro::app::applyTokenizedStyleSheet( + alongLineBar, + QStringLiteral("QWidget#alongLineBar{background:#0E1A2D;" + "border:1px solid {{border/default}};border-radius:8px;}" + "QLabel#alongLineLbl{color:#E6ECF5;border:none;}")); + alongLineBar->hide(); // 默认隐藏,仅细长(雷达)体显示 + auto* alongLineOverlay = new BottomBarOverlay(alongLineBar, vtkWidget); + QObject::connect(alongLineSlider, &QSlider::valueChanged, vtkWidget, + [sceneView](int v) { sceneView->focusAlongLongAxis(v / 1000.0, 0.12); }); + // 显隐刷新:仅三维分析 + 细长(长短轴比≥4,即雷达)体时显示沿线滑块。 + auto refreshAlongLineBar = std::make_shared>( + [sceneView, alongLineBar, alongLineOverlay]() { + const bool show = !sceneView->isAnalysisMode2D() && sceneView->longAxisElongation() >= 4.0; + alongLineBar->setVisible(show); + if (show) alongLineOverlay->reposition(); + }); + if (auto* style = interactionMgr->pickStyle()) { // 命中可见足迹→选中(Ctrl 多选)并返回是否进入 Z 拖动;未命中(返回 false)→交互样式回退平移。 style->onPick2D = [sceneView](int x, int y, bool additive) { @@ -567,7 +632,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }; // 体素变化(重建/清场)后把体素 image 推给 InteractionManager(切片基底),并调和已保存切片 + 异常。 - sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices, refreshAnomalies]() { + sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices, refreshAnomalies, + refreshAlongLineBar]() { // 多体并发:先移除 interactionMgr 中已不再渲染的体(关其切片),再 upsert 当前所有已渲染体 image。 for (const std::string& id : interactionMgr->volumeIds()) if (!sceneView->isVolumeRendered(id)) interactionMgr->removeVolumeImage(id); @@ -576,6 +642,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re kv.second.vmin, kv.second.vmax); syncSlices(); // 体到场/移除后调和各体下已勾选切片(多体并存) refreshAnomalies(); // 同步重载异常 actor + 刷新异常列表 + (*refreshAlongLineBar)(); // 体增删 → 据是否细长(雷达)刷新沿线滑块显隐(B#2) }; // ── 抽屉信号 → 控制器/交互(Task 7/12 接线)────────────────────────────── @@ -1001,9 +1068,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QStringLiteral("输入测线前缀(如 南同大道_000):"), QLineEdit::Normal, QString(), &ok); if (!ok || prefix.isEmpty()) return; // structParentId 暂空(P0 挂三维体段根;P1 接 TM 归属)。 + // coarse=1 全分辨率(沿线不抽稀):验收期要肉眼判读反射/双曲线/通道连续性, + // 不能被沿线抽稀糊掉。单线峰值内存 ~0.7–1.5GB(spec §8.4);若 OOM 退回 2。 const std::string newId = scene3dRepo->registerRadarDataset( dir.toLocal8Bit().toStdString(), prefix.toLocal8Bit().toStdString(), - prefix.toStdString(), /*structParentId=*/std::string(), /*coarse=*/4); + prefix.toStdString(), /*structParentId=*/std::string(), /*coarse=*/1); { const QSignalBlocker block(analysisTab); refreshAnalysis(); } // DS 进三维体段(不触发渲染) const QString qid = QString::fromStdString(newId); analysisTab->setItemChecked(qid, true); // 勾选 → addDatasetAsync → loadVolume 后台建体渲染 @@ -1196,6 +1265,17 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QMessageBox::warning(&window, QStringLiteral("导出"), QStringLiteral("导出失败。")); }); + // 雷达体显示增益模式切换(右键「增益模式」):仓储切模式+清缓存 → 控制器清缓存/旧色阶并(勾选中) + // 异步用新增益重建体重渲。0=关(原始) 1=AGC(显深部) 2=保幅 tpow(判振幅)。纯显示,不动原始 .data。 + QObject::connect( + analysisTab, &geopro::app::CategoryAnalysisTab::radarGainModeRequested, &window, + [scene3dRepo, sceneCtrl](const QString& qid, int mode) { + const auto m = (mode == 0) ? geopro::data::RadarGainMode::Off + : (mode == 2) ? geopro::data::RadarGainMode::Tpow + : geopro::data::RadarGainMode::Agc; + if (scene3dRepo->setRadarGainMode(qid.toStdString(), m)) + sceneCtrl->rebuildRadarVolume(qid.toStdString()); + }); // 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。 // 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。 QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::colorScaleRequested, &window, @@ -1340,11 +1420,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 渲染 [VtkSceneView]。顺序:先 ①②(都不渲染),最后 ③ 收尾统一渲染。只翻可见标志、不清空/重建 → // 切换瞬时;地形+底图常驻。 QObject::connect(drawer, &geopro::app::ColumnDrawer::analysisModeChanged, &window, - [interactionMgr, sceneCtrl, sceneView, viewToolbar](bool is2D) { + [interactionMgr, sceneCtrl, sceneView, viewToolbar, refreshAlongLineBar](bool is2D) { interactionMgr->setMode2D(is2D); sceneCtrl->onAnalysisModeChanged(is2D); sceneView->setAnalysisMode2D(is2D); viewToolbar->setAnalysisMode2D(is2D); // 二维下禁用 6 向快捷视图 + (*refreshAlongLineBar)(); // 二维隐藏沿线滑块、三维细长体显示(B#2) }); // 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置 @@ -1436,7 +1517,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget); // 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。 - emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint}); + emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint, alongLineBar}); emptyCentering->reposition(); // 引导层隐藏器就位(见 pushChecked 处声明):场景(剖面∪三维分析)有勾选 → 隐藏不透明引导层、露出渲染。 *setSceneEmptyVisible = [emptyState](bool empty) { emptyState->setVisible(empty); }; diff --git a/src/app/panels/columns/CategoryAnalysisTab.cpp b/src/app/panels/columns/CategoryAnalysisTab.cpp index 63a1740..2680571 100644 --- a/src/app/panels/columns/CategoryAnalysisTab.cpp +++ b/src/app/panels/columns/CategoryAnalysisTab.cpp @@ -54,6 +54,8 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d &CategoryAnalysisTab::deleteDatasetRequested); connect(sec, &CategorySection::sliceRequested, this, &CategoryAnalysisTab::sliceRequested); connect(sec, &CategorySection::colorScaleRequested, this, &CategoryAnalysisTab::colorScaleRequested); + connect(sec, &CategorySection::radarGainModeRequested, this, + &CategoryAnalysisTab::radarGainModeRequested); connect(sec, &CategorySection::sliceSaveRequested, this, &CategoryAnalysisTab::sliceSaveRequested); connect(sec, &CategorySection::sliceSaveAsRequested, this, &CategoryAnalysisTab::sliceSaveAsRequested); connect(sec, &CategorySection::sliceExportImageRequested, this, diff --git a/src/app/panels/columns/CategoryAnalysisTab.hpp b/src/app/panels/columns/CategoryAnalysisTab.hpp index a47120a..eb00c5a 100644 --- a/src/app/panels/columns/CategoryAnalysisTab.hpp +++ b/src/app/panels/columns/CategoryAnalysisTab.hpp @@ -45,6 +45,7 @@ signals: // ── 三维体段操作转发(迁自旧 Column3DAnalysis,全接)── void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); void colorScaleRequested(const QString& dsId); + void radarGainModeRequested(const QString& dsId, int mode); // 雷达体显示增益模式(0关/1AGC/2保幅) void sliceSaveRequested(const QString& dsId); void sliceSaveAsRequested(const QString& dsId); void sliceExportImageRequested(const QString& dsId); diff --git a/src/app/panels/columns/CategorySection.cpp b/src/app/panels/columns/CategorySection.cpp index ceaa57f..ef66c92 100644 --- a/src/app/panels/columns/CategorySection.cpp +++ b/src/app/panels/columns/CategorySection.cpp @@ -427,6 +427,15 @@ void CategorySection::showContextMenu(const QPoint& pos) { sl->addAction(QStringLiteral("左右"), this, [this, id] { emit sliceRequested(SliceAxis::LeftRight, id); }); sl->addAction(QStringLiteral("任意"), this, [this, id] { emit sliceRequested(SliceAxis::Oblique, id); }); menu.addAction(QStringLiteral("色阶"), this, [this, id] { emit colorScaleRequested(id); }); + if (ddCode == QStringLiteral("dd_radar_3d")) { // 雷达体:显示增益模式切换(纯显示,重建体) + QMenu* gm = menu.addMenu(QStringLiteral("增益模式")); + gm->addAction(QStringLiteral("AGC(显深部·找目标)"), this, + [this, id] { emit radarGainModeRequested(id, 1); }); + gm->addAction(QStringLiteral("保幅 tpow(判振幅·复核)"), this, + [this, id] { emit radarGainModeRequested(id, 2); }); + gm->addAction(QStringLiteral("关(原始振幅)"), this, + [this, id] { emit radarGainModeRequested(id, 0); }); + } } else if (ddCode == QStringLiteral("dd_slice")) { // 切片(列表中均为已保存=定稿锁定,无保存/另存) QMenu* ex = menu.addMenu(QStringLiteral("导出")); ex->addAction(QStringLiteral("图片"), this, [this, id] { emit sliceExportImageRequested(id); }); diff --git a/src/app/panels/columns/CategorySection.hpp b/src/app/panels/columns/CategorySection.hpp index 86a0d97..1ce1714 100644 --- a/src/app/panels/columns/CategorySection.hpp +++ b/src/app/panels/columns/CategorySection.hpp @@ -57,6 +57,7 @@ signals: // ── 三维体段右键操作(迁自旧 Column3DAnalysis,全接)── void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); // 体→生成切片(轴+目标体) void colorScaleRequested(const QString& dsId); // 体/切片→色阶 + void radarGainModeRequested(const QString& dsId, int mode); // 雷达体→显示增益模式(0关/1AGC/2保幅tpow) void sliceSaveRequested(const QString& dsId); // 切片→保存位姿 void sliceSaveAsRequested(const QString& dsId); // 切片→另存 void sliceExportImageRequested(const QString& dsId); // 切片→导出图片 diff --git a/src/controller/DatasetViewState.hpp b/src/controller/DatasetViewState.hpp index ad0b4ce..72b22f0 100644 --- a/src/controller/DatasetViewState.hpp +++ b/src/controller/DatasetViewState.hpp @@ -39,6 +39,10 @@ public: if (!scales_.contains(dsId)) scales_.insert(dsId, cs); } + // 清除某 ds 的色阶真源(不广播):体被以新参数重建(如雷达切增益模式→值域大变)时调, + // 让下次 seedColorScale 用重建后的新色阶,而非沿用旧窗口。 + void clearColorScale(const QString& dsId) { scales_.remove(dsId); } + signals: void colorScaleChanged(const QString& dsId); diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp index c41ef4b..a2ec377 100644 --- a/src/controller/VtkSceneController.cpp +++ b/src/controller/VtkSceneController.cpp @@ -323,6 +323,18 @@ void VtkSceneController::setVolumeColorScale(const std::string& dsId, view_.renderIncremental(); } +void VtkSceneController::rebuildRadarVolume(const std::string& dsId) { + // 仓储已切增益模式并失效其 cachedGrid(setRadarGainMode)。此处: + // 1) 清控制器缓存(否则命中旧体);2) 清旧色阶真源(增益后值域大变,须用重建后新窗口); + // 3) 移除旧 actor;4) 若勾选中 → 异步用新增益重建体并重渲。 + volumeCache_.erase(dsId); + volumeScaleCache_.erase(dsId); + if (state_) state_->clearColorScale(QString::fromStdString(dsId)); + view_.removeDataset(dsId); + if (isChecked(dsId)) addDatasetAsync(dsId, rebuildGeneration_); + view_.renderIncremental(); +} + void VtkSceneController::setAxesMode(AxesMode mode) { axesMode_ = mode; rebuildInternal(); // 坐标轴随场景重建(clear 会移除旧坐标轴 prop) diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp index 5a03f63..2521c0d 100644 --- a/src/controller/VtkSceneController.hpp +++ b/src/controller/VtkSceneController.hpp @@ -57,6 +57,9 @@ public slots: // 三维体透明度调节(工具条滑块):运行时更新已渲染体的不透明度,并作为后续新体默认(0~1)。 void setVolumeOpacity(double maxOpacity); void rebuild(); // 主题切换等外部触发的重渲染 + // 雷达体增益模式切换后重建:仓储已切模式+清缓存(setRadarGainMode),此处清控制器缓存/旧色阶 + // 并(若勾选中)异步用新增益重建体、重渲。 + void rebuildRadarVolume(const std::string& dsId); // 色阶编辑器「确定」:写入色阶真源(state_),经 colorScaleChanged 统一就地重着色(体/帘面 + 切片)。 // 兼容旧调用点;真正的重着色在 recolorDataset()。无 state_ 时退化为直连重建。 diff --git a/src/data/GprVolumeRepository.cpp b/src/data/GprVolumeRepository.cpp index b256969..12ca216 100644 --- a/src/data/GprVolumeRepository.cpp +++ b/src/data/GprVolumeRepository.cpp @@ -1,7 +1,10 @@ #include "data/GprVolumeRepository.hpp" +#include #include +#include #include +#include #include "core/model/ScalarVolumeI16.hpp" #include "io/gpr/Gpr3dvVolumeBridge.hpp" @@ -18,20 +21,63 @@ VolumeGrid builtI16ToVolumeGrid(const geopro::core::BuiltI16& built) { out.vol = geopro::core::ScalarVolume(nx, ny, nz); out.origin = built.origin; out.spacing = built.spacing; - out.vmin = built.vminPhys; - out.vmax = built.vmaxPhys; - // 逐体素反量化(布局一致:i 最快、k 最慢)。 + // 逐体素反量化(布局一致:i 最快、k 最慢) + 同遍累计 int16 直方图(零额外遍历)。 // kBlank → NaN:下游 render::buildVoxel 把 NaN 映射到 [vmin,vmax] 外的哨兵 → - // 传递函数置全透明(与 float 路径空值语义一致)。 + // 传递函数置全透明(与 float 路径空值语义一致)。GPR 稠密体无 kBlank,直方图即全体素。 const std::vector& src = built.vol.data(); std::vector& dst = out.vol.data(); const double nan = std::numeric_limits::quiet_NaN(); + constexpr int kHistN = 65536; // int16 全域,桶 b ↔ 量化值 q = b - 32768 + std::vector hist(kHistN, 0); + std::uint64_t total = 0; for (std::size_t idx = 0; idx < src.size(); ++idx) { const std::int16_t q = src[idx]; - dst[idx] = (q == geopro::core::ScalarVolumeI16::kBlank) - ? nan - : built.quant.toPhys(q); + if (q == geopro::core::ScalarVolumeI16::kBlank) { + dst[idx] = nan; + continue; + } + dst[idx] = built.quant.toPhys(q); + ++hist[static_cast(q) + 32768]; + ++total; + } + + // 显示值域 = 【双极对称】99% 窗口(GPR 标准 B-scan),而非全 min/max、也非非对称分位。 + // GPR 振幅正负振荡:基线(零反射)应=中灰、强负=黑、强正=白,且窗口对称否则色调失衡。 + // 做法:以【中位数=基线】为中心向两侧等距扩张,直到覆盖 99% 样本 → ±A。强反射/饱和值 + // (原始数据实测有 int16 下限 -32768 的钳值)落在 1% 尾外 → clamp 到端色,结构铺开如实显示。 + // 对齐独立 Python radargram(双极对称 ±p99);用全值域=灰板,用非对称 2/98=过饱和+灰点偏移。 + // 退化(全同值)兜底回全值域。 + out.vmin = built.vminPhys; + out.vmax = built.vmaxPhys; + if (total > 0) { + // 中位数桶(基线≈零振幅)。 + int centerQ = 32767; + { + const double half = 0.5 * static_cast(total); + std::uint64_t cum = 0; + for (int b = 0; b < kHistN; ++b) { + cum += hist[b]; + if (static_cast(cum) > half) { centerQ = b - 32768; break; } + } + } + // 自中位数对称外扩,覆盖 99% → 半宽 A。 + const double need = 0.99 * static_cast(total); + const int ci = centerQ + 32768; + std::uint64_t cum = (ci >= 0 && ci < kHistN) ? hist[ci] : 0; + int A = 0; + for (int r = 1; r < kHistN; ++r) { + const int lo = ci - r, hi = ci + r; + if (lo >= 0 && lo < kHistN) cum += hist[lo]; + if (hi >= 0 && hi < kHistN) cum += hist[hi]; + if (static_cast(cum) >= need) { A = r; break; } + } + if (A > 0) { + const int loQ = std::max(-32768, centerQ - A); + const int hiQ = std::min(32767, centerQ + A); + out.vmin = built.quant.toPhys(static_cast(loQ)); + out.vmax = built.quant.toPhys(static_cast(hiQ)); + } } return out; } @@ -48,15 +94,83 @@ VolumeGrid createGprVolumeGrid(const std::string& lineDir, return builtI16ToVolumeGrid(built); } +namespace { +// 单道(沿深度 n)增益:先 dewow(减滑动均值去低频 DC),再按模式 AGC(除滑窗 RMS,显弱反射但抹相对幅) +// 或 Tpow(×(k+1)^幂,保幅补衰减)。前缀和 O(n)。 +void gainTraceInPlace(double* col, int n, RadarGainMode mode, int dewowWin, int agcWin, + double tpowPower) { + if (n <= 0 || mode == RadarGainMode::Off) return; + if (dewowWin > 1) { + const int h = dewowWin / 2; + std::vector ps(n + 1, 0.0); + for (int k = 0; k < n; ++k) ps[k + 1] = ps[k] + col[k]; + std::vector o(n); + for (int k = 0; k < n; ++k) { + const int lo = std::max(0, k - h), hi = std::min(n, k + h + 1); + o[k] = col[k] - (ps[hi] - ps[lo]) / (hi - lo); + } + for (int k = 0; k < n; ++k) col[k] = o[k]; + } + if (mode == RadarGainMode::Agc && agcWin > 1) { + const int h = agcWin / 2; + std::vector ps2(n + 1, 0.0); + for (int k = 0; k < n; ++k) ps2[k + 1] = ps2[k] + col[k] * col[k]; + for (int k = 0; k < n; ++k) { + const int lo = std::max(0, k - h), hi = std::min(n, k + h + 1); + const double rms = std::sqrt((ps2[hi] - ps2[lo]) / (hi - lo)) + 1e-6; + col[k] /= rms; + } + } else if (mode == RadarGainMode::Tpow && tpowPower > 0.0) { + for (int k = 0; k < n; ++k) + col[k] *= std::pow(static_cast(k + 1), tpowPower); // 保幅:随深度放大、不抹相对强弱 + } +} + +// 逐道显示增益 + 增益后重取双极对称 99% 窗口。纯显示:只改 app 渲染体 g.vol,原始 .data 不变。 +void applyRadarDisplayGain(VolumeGrid& g, RadarGainMode mode, int dewowWin, int agcWin, + double tpowPower) { + const int nx = g.vol.nx(), ny = g.vol.ny(), nz = g.vol.nz(); + if (nx <= 0 || ny <= 0 || nz <= 0) return; + std::vector& d = g.vol.data(); + auto idx = [nx, ny](int i, int j, int k) { + return (static_cast(k) * ny + j) * nx + i; + }; + std::vector col(nz); + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + for (int k = 0; k < nz; ++k) col[k] = d[idx(i, j, k)]; + gainTraceInPlace(col.data(), nz, mode, dewowWin, agcWin, tpowPower); + for (int k = 0; k < nz; ++k) d[idx(i, j, k)] = col[k]; + } + // 增益后重取窗口(中位数中心、对称 99%),否则旧窗口与增益后量纲不符。 + std::vector a(d.begin(), d.end()); + if (!a.empty()) { + const std::size_t m = a.size() / 2; + std::nth_element(a.begin(), a.begin() + m, a.end()); + const double med = a[m]; + for (double& x : a) x = std::abs(x - med); + const std::size_t q = static_cast(0.99 * (a.size() - 1)); + std::nth_element(a.begin(), a.begin() + q, a.end()); + const double A = a[q] > 0.0 ? a[q] : 1.0; + g.vmin = med - A; + g.vmax = med + A; + } +} +} // namespace + VolumeGrid createRadarVolumeGrid(const std::string& lineDir, const std::string& linePrefix, int coarse, - double targetDy) { + double targetDy, RadarGainMode gainMode) { // 走规范化测线链(io::gpr) 读 .head/.data → int16 量化体 → 反量化为 app 的 float 体。 // 与 createGprVolumeGrid 同适配器(builtI16ToVolumeGrid),仅上游数据源不同。 const geopro::core::BuiltI16 built = geopro::io::gpr::buildLineVolumeFromNormalized(lineDir, linePrefix, coarse, targetDy); - return builtI16ToVolumeGrid(built); + VolumeGrid g = builtI16ToVolumeGrid(built); + // 显示增益(纯显示):Agc 显深部弱反射 / Tpow 保幅。原始数据不变;窗口随增益重取。 + if (gainMode != RadarGainMode::Off) + applyRadarDisplayGain(g, gainMode, /*dewowWin=*/30, /*agcWin=*/50, /*tpowPower=*/1.5); + return g; } } // namespace geopro::data diff --git a/src/data/GprVolumeRepository.hpp b/src/data/GprVolumeRepository.hpp index 0f576d1..4737e91 100644 --- a/src/data/GprVolumeRepository.hpp +++ b/src/data/GprVolumeRepository.hpp @@ -40,9 +40,17 @@ VolumeGrid createGprVolumeGrid(const std::string& lineDir, // 上游数据源走规范化 .head/.data 而非 .iprh/.iprb。 // coarse(≥1)沿测线下采样;targetDy(米,>0 启用)线内通道插值(读 .head CH_X_OFFSETS)。 // 失败(加载失败/解析失败)→ 抛 std::runtime_error(由 io::gpr 链抛出,原样透传)。 +// 雷达显示增益模式(纯显示、逐道沿深度、不动原始 .data;窗口随增益重取): +// Off = 原始振幅; +// Agc = dewow + 滑窗 RMS 归一:最大化显出深部弱反射(找目标),但【抹相对振幅】; +// Tpow = dewow + ×时间^幂:保幅增益(补几何扩散/衰减),【保留相对强弱】——判振幅/复核异常用。 +// (审阅者点③:标深部目标前应在 Tpow 保幅下也确认,不只信 Agc。) +enum class RadarGainMode { Off, Agc, Tpow }; + VolumeGrid createRadarVolumeGrid(const std::string& lineDir, const std::string& linePrefix, int coarse = 4, - double targetDy = 0.025); + double targetDy = 0.025, + RadarGainMode gainMode = RadarGainMode::Off); } // namespace geopro::data diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index bceb73f..5c4649c 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -29,6 +29,22 @@ namespace geopro::data { namespace { constexpr const char* kNotReady = "后端三维端点未就绪"; + +// 雷达中性灰度色阶。⚠ core::ColorScale 是【阶梯/分段常数】(colorAt 取下界 stop,不插值): +// 只放 3 个 stop(黑/灰/白) → 整个 [mid,vmax) 正振幅全塌成一级灰、[vmin,mid) 全黑,连续 GPR +// 数据被压成 3 级(深部 DC 渲成恒灰、丢层理)——实测确诊的真 bug。故铺 256 级平滑斜坡 +// black→white,colorAt 才能给出连续灰阶。(反演等值面仍用稀疏 stop 的阶梯,故意离散,不动。) +core::ColorScale radarGrayScale(double vmin, double vmax) { + core::ColorScale cs; + constexpr int kLevels = 256; + for (int i = 0; i < kLevels; ++i) { + const double f = static_cast(i) / (kLevels - 1); // 0..1 + const double val = vmin + (vmax - vmin) * f; + const auto g = static_cast(std::lround(f * 255.0)); + cs.addStop(val, core::Rgba{g, g, g, 255}); + } + return cs; +} } // namespace Api3dRepository::Api3dRepository(IAsyncDatasetRepository& dsRepo, @@ -130,12 +146,8 @@ std::string Api3dRepository::createGprVolume(const std::string& lineDir, const std::string& name, int coarse) { // 走 io::gpr 逐线管线(含线内通道插值)直接产体(抛异常透传给调用方)。 VolumeGrid grid = geopro::data::createGprVolumeGrid(lineDir, linePrefix, coarse); - // 简易灰度色阶(负→暗、零→灰、正→亮)覆盖体值域,使体素渲染可见。 - core::ColorScale scale; - const double mid = 0.5 * (grid.vmin + grid.vmax); - scale.addStop(grid.vmin, core::Rgba{20, 24, 40, 255}); - scale.addStop(mid, core::Rgba{140, 140, 150, 255}); - scale.addStop(grid.vmax, core::Rgba{235, 232, 220, 255}); + // 中性灰度(GPR 标准 B-scan),256 级连续——3-stop 会被 colorAt 阶梯压成 3 级(见 radarGrayScale)。 + const core::ColorScale scale = radarGrayScale(grid.vmin, grid.vmax); const std::string id = "vol-" + std::to_string(++volumeCounter_); StoredVolume sv; @@ -167,6 +179,15 @@ std::string Api3dRepository::registerRadarDataset(const std::string& lineDir, return id; } +bool Api3dRepository::setRadarGainMode(const std::string& dsId, RadarGainMode mode) { + auto it = volumes_.find(dsId); + if (it == volumes_.end() || it->second.ddCode != "dd_radar_3d") return false; + if (it->second.gainMode == mode) return true; // 未变化也算成功(调用方可跳过重渲) + it->second.gainMode = mode; + it->second.cachedGrid.reset(); // 失效缓存体 → 下次 loadVolume 用新增益模式重建 + return true; +} + const VoxelGenerateRequest* Api3dRepository::lastVoxelRequest(const std::string& dsId) const { const auto it = volumes_.find(dsId); return (it != volumes_.end() && it->second.request) ? &*it->second.request : nullptr; @@ -372,28 +393,26 @@ void Api3dRepository::loadVolume(const std::string& dsId, if (!sv.linePrefix.empty()) { // 雷达体 DS:后台建体,避免阻塞 UI(与 finalizeVolume 同范式) const std::string lineDir = sv.lineDir, linePrefix = sv.linePrefix; const int coarse = sv.coarse; + const RadarGainMode gainMode = sv.gainMode; // 显示增益模式(右键可切,切时清缓存重建) auto deliver = [this, dsId, onOk, onErr](std::shared_ptr g, std::string err) { if (!g) { onErr("Api3dRepository::loadVolume(radar): " + err); return; } - core::ColorScale scale; // 简易灰度色阶(负→暗、零→灰、正→亮),使体素渲染可见。 - const double mid = 0.5 * (g->vmin + g->vmax); - scale.addStop(g->vmin, core::Rgba{20, 24, 40, 255}); - scale.addStop(mid, core::Rgba{140, 140, 150, 255}); - scale.addStop(g->vmax, core::Rgba{235, 232, 220, 255}); + // 中性灰度,256 级连续(见 radarGrayScale:3-stop 会被 colorAt 阶梯压成 3 级)。 + const core::ColorScale scale = radarGrayScale(g->vmin, g->vmax); if (auto it2 = volumes_.find(dsId); it2 != volumes_.end()) { it2->second.cachedGrid = *g; // 缓存 → 下次命中直渲 it2->second.cachedScale = scale; } onOk(*g, scale); }; - auto compute = [lineDir, linePrefix, coarse]() { + auto compute = [lineDir, linePrefix, coarse, gainMode]() { std::shared_ptr g; std::string err; try { - g = std::make_shared( - geopro::data::createRadarVolumeGrid(lineDir, linePrefix, coarse)); + g = std::make_shared(geopro::data::createRadarVolumeGrid( + lineDir, linePrefix, coarse, /*targetDy=*/0.025, gainMode)); } catch (const std::exception& e) { err = e.what(); } diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp index 929aef5..e5df097 100644 --- a/src/data/api/Api3dRepository.hpp +++ b/src/data/api/Api3dRepository.hpp @@ -9,6 +9,7 @@ #include "dto/Vtk3dRequests.hpp" #include "geo/GeoLocalFrame.hpp" +#include "GprVolumeRepository.hpp" // RadarGainMode(雷达显示增益模式) #include "repo/I3dSceneRepository.hpp" #include "repo/VolumeBuildParams.hpp" @@ -54,6 +55,9 @@ public: std::string registerRadarDataset(const std::string& lineDir, const std::string& linePrefix, const std::string& name, const std::string& structParentId, int coarse = 4); + // 切换雷达体显示增益模式(右键菜单):设新模式 + 清缓存体(强制下次 loadVolume 用新模式重建)。 + // 返回 true=是雷达体且已切换;false=非雷达体(忽略)。调用方随后清控制器缓存并重渲该 dsId。 + bool setRadarGainMode(const std::string& dsId, RadarGainMode mode); // 取回某三维体组装的请求体(测试/联调);非本 repo 创建或无 request 时返回 nullptr。 const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const; // 清空内存态三维体/切片/异常(切换项目时调;否则上个项目的体/切片/异常残留在新项目列表)。 @@ -155,6 +159,7 @@ private: std::string ddCode = "dd_voxel"; // 数据字典码(雷达体="dd_radar_3d",其余默认 dd_voxel) std::string structParentId; // 结构归属(生成位置);雷达体无 request 时由此提供 int coarse = 4; // 沿测线下采样因子(控内存) + RadarGainMode gainMode = RadarGainMode::Agc; // 显示增益模式(右键切换;默认 AGC 显深部) }; std::map volumes_; // dsId → 体 int volumeCounter_ = 0; diff --git a/src/render/actors/VoxelActor.cpp b/src/render/actors/VoxelActor.cpp index 60842ff..eaa8d38 100644 --- a/src/render/actors/VoxelActor.cpp +++ b/src/render/actors/VoxelActor.cpp @@ -73,9 +73,18 @@ vtkSmartPointer assembleVolume(vtkImageData* img, std::min({std::abs(sp[0]), std::abs(sp[1]), std::abs(sp[2])}); // 最细体素维度 double bnd[6]; img->GetBounds(bnd); - const double diag = std::sqrt((bnd[1] - bnd[0]) * (bnd[1] - bnd[0]) + - (bnd[3] - bnd[2]) * (bnd[3] - bnd[2]) + - (bnd[5] - bnd[4]) * (bnd[5] - bnd[4])); // 包围盒对角(最长穿越路径) + const double ex = std::abs(bnd[1] - bnd[0]), ey = std::abs(bnd[3] - bnd[2]), + ez = std::abs(bnd[5] - bnd[4]); + const double diag = std::sqrt(ex * ex + ey * ey + ez * ez); + // 不透明度单位距离的尺度基准: + // - 【各向异性】体(细长,如雷达 375×1.4×5m):对角线被长轴主宰(375m)→ 单位距离过大 → + // 不透明度只在 100% 才实心、稍降即很透(用户实测)。改用【特征尺度=三轴几何平均 cbrt】, + // 对各向异性稳健。 + // - 【近立方】体(反演):维持原对角线,观感不变(门控:长短轴比 ≤ kAnisoRatio 走对角线)。 + constexpr double kAnisoRatio = 4.0; + const double maxE = std::max({ex, ey, ez}), minE = std::min({ex, ey, ez}); + const bool anisotropic = (minE > 0.0) && (maxE / minE > kAnisoRatio); + const double charLen = anisotropic ? std::cbrt(ex * ey * ez) : diag; // 大体(如雷达:24M 体素、深度采样距 mm 级 → 单条光线上千采样步)开启【交互期】采样距自适应: // 旋转时 VTK 自动加大采样步(变粗)保流畅,停手即恢复设定的细采样距(0.3×minSp)出全质量帧。 @@ -112,11 +121,10 @@ vtkSmartPointer assembleVolume(vtkImageData* img, prop->SetScalarOpacity(opacity); prop->SetInterpolationTypeToLinear(); prop->ShadeOff(); - // 不透明度单位距离 = 包围盒对角 × kOpacityUnitFraction:控制沿深度的累积速度,使色阶「不透明度」滑块 - // 有层次。取对角/10:100%(每单位=1.0)→沿体累积到≈实心、10% 很淡。太大(=整条对角)→100% 也偏透; - // 太小(=体素)→ 低不透明度也累积到全不透明。 + // 不透明度单位距离 = 尺度基准(charLen:各向异性=特征尺度 / 近立方=对角线) × kOpacityUnitFraction。 + // 控制累积速度使「不透明度」滑块有层次;细长体走特征尺度后不再"只有 100% 实心、99% 即很透"。 constexpr double kOpacityUnitFraction = 0.1; - if (diag > 0) prop->SetScalarOpacityUnitDistance(kOpacityUnitFraction * diag); + if (charLen > 0) prop->SetScalarOpacityUnitDistance(kOpacityUnitFraction * charLen); auto volume = vtkSmartPointer::New(); volume->SetMapper(mapper); diff --git a/src/render/interact/InteractionManager.cpp b/src/render/interact/InteractionManager.cpp index 0fd34d1..35b11d8 100644 --- a/src/render/interact/InteractionManager.cpp +++ b/src/render/interact/InteractionManager.cpp @@ -58,11 +58,22 @@ void InteractionManager::installStyle() { style_->onPick = [this](const Vec3& w) { onPicked(w); }; style_->onDoubleClick = [this](const Vec3& w) { onDoubleClicked(w); }; style_->onWheelStep = [this](int dir) { return onWheel(dir); }; + // Esc 取消选中:清选中+高亮 + 同步列表清选 + 重渲(拉近后点不到空白处取消时的可靠出口)。 + style_->onDeselect = [this]() { + if (selected_ < 0) return; + deselectSlice(); + if (onSliceSelectionChanged) onSliceSelectionChanged(std::string{}); + safeRender(); + }; + // 精确"命中切片"判定:光标射线 vs 切片真实矩形求交(点帘面/非切片物/边界外都不算)。 + style_->hitTestSlice = [this]() { return pointOnSlice(); }; // D39: 提供旋转中心 = 选中切片中心(有选中→true)。style 在按下拖动时据此绕选中切片旋转。 style_->getRotateCenter = [this](Vec3& c) { - if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return false; - c = slices_[static_cast(selected_)]->center(); - return true; + if (selected_ >= 0 && selected_ < static_cast(slices_.size())) { + c = slices_[static_cast(selected_)]->center(); // 选中切片→绕其中心 + return true; + } + return rayVolumePivot(c); // 无选中→绕光标射线穿过的体中段点(不甩飞);无体命中→false(默认焦点) }; interactor_->SetInteractorStyle(style_); @@ -83,6 +94,8 @@ void InteractionManager::uninstallStyle() { style_->onDoubleClick = nullptr; style_->onWheelStep = nullptr; style_->getRotateCenter = nullptr; + style_->onDeselect = nullptr; + style_->hitTestSlice = nullptr; } // 摘除右键观察者(this 即将析构)。 if (interactor_ && rightBtnTag_ != 0) { @@ -470,6 +483,114 @@ int InteractionManager::nearestSlice(const Vec3& worldPoint) const { return idx; } +bool InteractionManager::cursorRay(double nearP[3], double dir[3]) const { + if (!interactor_ || !renderer_) return false; + const int* pos = interactor_->GetEventPosition(); + if (!pos) return false; + // 屏幕点在近/远裁剪面的世界坐标 → 连成视线(相机透视/正交均适用)。 + auto toWorld = [this](int x, int y, double z, double out[3]) { + renderer_->SetDisplayPoint(x, y, z); + renderer_->DisplayToWorld(); + double w[4]; + renderer_->GetWorldPoint(w); + const double iw = (w[3] != 0.0) ? 1.0 / w[3] : 1.0; + out[0] = w[0] * iw; + out[1] = w[1] * iw; + out[2] = w[2] * iw; + }; + double farP[3]; + toWorld(pos[0], pos[1], 0.0, nearP); + toWorld(pos[0], pos[1], 1.0, farP); + dir[0] = farP[0] - nearP[0]; + dir[1] = farP[1] - nearP[1]; + dir[2] = farP[2] - nearP[2]; + return true; +} + +bool InteractionManager::rayVolumePivot(Vec3& out) const { + double nearP[3], d[3]; + if (!cursorRay(nearP, d)) return false; + bool found = false; + double bestEnter = 0.0; + for (const auto& kv : volumes_) { + if (!kv.second.image) continue; + const std::array b = imageBounds(kv.second.image); + double tEnter = -1e300, tExit = 1e300; // slab 法求 ray∩包围盒 [tEnter,tExit] + bool ok = true; + for (int i = 0; i < 3; ++i) { + const double lo = b[2 * i], hi = b[2 * i + 1]; + if (std::abs(d[i]) < 1e-12) { + if (nearP[i] < lo || nearP[i] > hi) { + ok = false; + break; + } + } else { + double t1 = (lo - nearP[i]) / d[i], t2 = (hi - nearP[i]) / d[i]; + if (t1 > t2) { + const double tmp = t1; + t1 = t2; + t2 = tmp; + } + if (t1 > tEnter) tEnter = t1; + if (t2 < tExit) tExit = t2; + } + } + if (!ok || tExit < tEnter || tExit < 0.0) continue; + const double tin = (tEnter > 0.0 ? tEnter : 0.0); + if (!found || tin < bestEnter) { // 多体取最近命中 + bestEnter = tin; + const double tmid = 0.5 * (tin + tExit); // 体内中段点 → 稳定支点 + out = {nearP[0] + d[0] * tmid, nearP[1] + d[1] * tmid, nearP[2] + d[2] * tmid}; + found = true; + } + } + return found; +} + +bool InteractionManager::pointOnSlice() const { + double nearP[3], d[3]; + if (!cursorRay(nearP, d)) return false; + for (const auto& sp : slices_) { + double o[3], p1[3], p2[3]; + sp->planePoints(o, p1, p2); // 切片真实矩形:o + u·e1 + v·e2 (u,v∈[0,1]) + const double e1[3] = {p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]}; + const double e2[3] = {p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]}; + const double n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], + e1[0] * e2[1] - e1[1] * e2[0]}; + const double denom = d[0] * n[0] + d[1] * n[1] + d[2] * n[2]; + if (std::abs(denom) < 1e-12) continue; // 视线平行于切面 + const double t = + ((o[0] - nearP[0]) * n[0] + (o[1] - nearP[1]) * n[1] + (o[2] - nearP[2]) * n[2]) / denom; + if (t < 0.0) continue; // 交点在相机后方 + const double h[3] = {nearP[0] + d[0] * t, nearP[1] + d[1] * t, nearP[2] + d[2] * t}; + const double hd[3] = {h[0] - o[0], h[1] - o[1], h[2] - o[2]}; + const double e1sq = e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2]; + const double e2sq = e2[0] * e2[0] + e2[1] * e2[1] + e2[2] * e2[2]; + if (e1sq <= 0.0 || e2sq <= 0.0) continue; + const double u = (hd[0] * e1[0] + hd[1] * e1[1] + hd[2] * e1[2]) / e1sq; + const double v = (hd[0] * e2[0] + hd[1] * e2[1] + hd[2] * e2[2]) / e2sq; + if (u < 0.0 || u > 1.0 || v < 0.0 || v > 1.0) continue; // 不在切片矩形内 + // 矩形命中后还需该点处切片有【可见数据】(不透明)——切片矩形=体网格截面,常比可见数据大 + // (反演有大片空值网格、雷达边缘空采样)。落在矩形但透明(空值/外区)处不算"在切片上", + // 否则点 ds 边缘空白区(如截图左侧标尺处)仍误判命中 → 治用户实测的外扩。 + const vtkSmartPointer rgba = sp->coloredResliceImage(); + if (!rgba) return true; // 无着色图(理论不至) → 退化为矩形命中 + int dims[3]; + rgba->GetDimensions(dims); + if (dims[0] < 1 || dims[1] < 1 || rgba->GetNumberOfScalarComponents() < 4) + return true; // 无 alpha 通道 → 退化为矩形命中 + // 着色输出像素 (i,j) 沿 e1/e2 方向 → (u,v)·(dim-1)(vtkImagePlaneWidget reslice 轴约定)。 + int px = static_cast(u * (dims[0] - 1) + 0.5); + int py = static_cast(v * (dims[1] - 1) + 0.5); + px = px < 0 ? 0 : (px > dims[0] - 1 ? dims[0] - 1 : px); + py = py < 0 ? 0 : (py > dims[1] - 1 ? dims[1] - 1 : py); + const auto* pix = static_cast(rgba->GetScalarPointer(px, py, 0)); + if (pix && pix[3] > 10) return true; // alpha>阈值 = 该点切片可见 → 在切片上 + // 透明(空值/外区) → 不算在此切片上,继续看其它切片 + } + return false; +} + void InteractionManager::onPicked(const Vec3& worldPoint) { // 单击 = 选中命中切片;点在切片外(如点到体/帘面)→ 取消选中(idx=-1)。**不动相机**。 // 解决"选了切片无法取消":点击切片之外即清选中,滚轮恢复缩放(见 onWheel)。 @@ -497,7 +618,20 @@ void InteractionManager::faceSlice(int idx) { if (!cam) return; const Vec3 focal = slices_[static_cast(idx)]->center(); const Vec3 normal = slices_[static_cast(idx)]->normal(); - const double dist = cam->GetDistance(); // 保持当前观察距离 + // 缩放到切片:按切片【面内尺寸】(法向两侧两轴的跨度)+ 相机视角算"刚好框住"的距离, + // 而非沿用当前(拉远看整条线时可能几百米)距离——否则正视后切片又小又远(用户实测)。 + const VolumeImg* vol = volumeOf(slices_[static_cast(idx)]->volumeDsId()); + const std::array b = imageBounds(vol ? vol->image : nullptr); + const double ext[3] = {b[1] - b[0], b[3] - b[2], b[5] - b[4]}; + const double an[3] = {std::abs(normal[0]), std::abs(normal[1]), std::abs(normal[2])}; + const int ax = (an[0] >= an[1] && an[0] >= an[2]) ? 0 : (an[1] >= an[2] ? 1 : 2); // 法向主轴 + double inMax = 0.0; + for (int i = 0; i < 3; ++i) + if (i != ax) inMax = (ext[i] > inMax ? ext[i] : inMax); // 面内最大尺寸 + double dist = cam->GetDistance(); // 兜底(无体边界) + const double vaRad = cam->GetViewAngle() * 3.14159265358979323846 / 180.0; + if (inMax > 0.0 && vaRad > 0.0) + dist = (0.5 * inMax) / std::tan(0.5 * vaRad) * 1.1; // 框住面内最大尺寸 + 10% 余量 const FaceOnCamera face = faceOnCamera(focal, normal, dist); cam->SetFocalPoint(focal[0], focal[1], focal[2]); cam->SetPosition(face.position[0], face.position[1], face.position[2]); @@ -513,7 +647,17 @@ bool InteractionManager::onWheel(int dir) { // 配合 onPicked 的"点击切片外取消选中":取消后滚轮即恢复缩放,解决"选了切片无法缩放"。 // (不采用"悬停即推进":推进时鼠标难持续压在移动的切片上,且过敏感。) if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return false; - const double step = wheelStep(imageBounds(selectedVolumeImage()), dir); // 选中切片所属体 + // 步长按切片【法向上的体素间距 × N】算:一格挪 N 个采样,与体总长无关——细长雷达体也不会 + // 因长轴 375m 而步太大/跳过。Shift=粗调(×10);超长轴粗定位另靠沿线滑块(后续)。 + const Vec3 n = slices_[static_cast(selected_)]->normal(); + std::array sp{1.0, 1.0, 1.0}; + if (vtkImageData* img = selectedVolumeImage()) { + double s[3]; + img->GetSpacing(s); + sp = {s[0], s[1], s[2]}; + } + const int voxels = (interactor_ && interactor_->GetShiftKey()) ? 20 : 2; // Shift=粗调 + const double step = wheelStep(sp, n, voxels, dir); slices_[static_cast(selected_)]->advance(step); safeRender(); return true; // 消费滚轮(推进选中切片,不缩放) diff --git a/src/render/interact/InteractionManager.hpp b/src/render/interact/InteractionManager.hpp index 244b64a..d32d0d6 100644 --- a/src/render/interact/InteractionManager.hpp +++ b/src/render/interact/InteractionManager.hpp @@ -142,6 +142,14 @@ private: // 找离世界点最近的切片索引;无切片返回 -1。 int nearestSlice(const Vec3& worldPoint) const; + // 精确判定:当前光标【射线】是否穿过某张切片真实矩形(origin/point1/point2)内。 + // 用射线-矩形求交(非带容差的 picker 点)→ 切片边界外不再误判命中(治外扩)。点帘面也判 false。 + bool pointOnSlice() const; + // 当前光标射线:近/远裁剪面世界点 → nearP + 方向 dir。无 interactor/renderer 返回 false。 + bool cursorRay(double nearP[3], double dir[3]) const; + // 旋转支点(B 方案#1):无选中切片时,取光标射线穿过的体【中段点】(进/出包围盒中点),多体取最近命中。 + // 绕它旋转→当前所视区域居中、不甩飞(治长体旋转丢目标)。无体命中返回 false(回退默认绕焦点)。 + bool rayVolumePivot(Vec3& out) const; // 在当前鼠标屏幕位置拾取 → 命中的切片索引;未命中切片返回 -1。 int pickSliceAtCursor() const; // 按 SliceTool 指针设为选中(widget 交互回调用:触碰即选中)。 diff --git a/src/render/interact/PickInteractorStyle.cpp b/src/render/interact/PickInteractorStyle.cpp index 533576a..7194ec6 100644 --- a/src/render/interact/PickInteractorStyle.cpp +++ b/src/render/interact/PickInteractorStyle.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -66,14 +67,17 @@ void PickInteractorStyle::OnLeftButtonDown() { return; } Vec3 world; - const bool hit = pickWorld(world); + const bool hit = pickWorld(world); // 仍用于取选中所需世界点(onPick) + // 命中切片【精确判定】:光标射线穿过某切片真实矩形内才算(不靠带容差的 picker 点)。 + // 边界外/帘面/其它物 → false → 抬键单击即取消。 + downHitSlice_ = hitTestSlice && hitTestSlice(); // 手动双击判定(GetRepeatCount 在 QVTK+Windows 不可靠,评审 M5): // 两次左键按下间隔 < 阈值且屏幕位置相近 → 双击。 const double now = nowMs(); const int* pos = iren ? iren->GetEventPosition() : nullptr; bool isDouble = false; - if (hit && pos && lastDownTime_ >= 0.0) { + if (downHitSlice_ && pos && lastDownTime_ >= 0.0) { // 仅命中切片才判双击(正视) const double dtMs = now - lastDownTime_; const int dx = pos[0] - lastDownPos_[0]; const int dy = pos[1] - lastDownPos_[1]; @@ -91,21 +95,24 @@ void PickInteractorStyle::OnLeftButtonDown() { lastDownTime_ = -1.0; // 重置,避免三击连判 return; // 不进入拖动旋转 } - if (hit) { - // 单击命中 → 选中所在切片(onPick 内仅选中, 不动相机)。 + if (downHitSlice_ && hit) { + // 单击命中【切片】→ 选中(onPick 内仅选中, 不动相机)。点帘面/非切片物/边界外不选中→抬键即取消。 if (onPick) onPick(world); } - // 不在按下时动相机(动相机=跳);绕选中物旋转在 Rotate() 内做(增量绕支点,不跳)。 + // 捕获本次拖动的旋转支点【一次】(在 onPick 选中之后取,故能用新选中切片):拖动中光标会移动, + // 不能每帧重取(否则支点漂)。选中切片→其中心;否则→光标射线穿过的体中段点;无→默认绕焦点。 + hasRotatePivot_ = (getRotateCenter && getRotateCenter(rotatePivot_)); + // 不在按下时动相机(动相机=跳);绕支点旋转在 Rotate() 内做(增量绕支点,不跳)。 Superclass::OnLeftButtonDown(); } void PickInteractorStyle::Rotate() { if (lock2D_) return; // 二维分析禁旋转(仅平移+缩放) - Vec3 c; - if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) { - Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转 + if (!this->CurrentRenderer || !hasRotatePivot_) { + Superclass::Rotate(); // 无支点 → 默认绕焦点旋转 return; } + const Vec3 c = rotatePivot_; // 按下时已捕获、拖动中固定 auto* rwi = this->Interactor; auto* cam = this->CurrentRenderer->GetActiveCamera(); if (!rwi || !cam) return; @@ -114,8 +121,10 @@ void PickInteractorStyle::Rotate() { const int* size = this->CurrentRenderer->GetRenderWindow()->GetSize(); if (size[0] <= 0 || size[1] <= 0) return; // 与 TrackballCamera 同口径的角度映射。 + // 俯仰:本类绕 right=cross(DOP,up) 旋转,而 VTK 默认 Elevation 绕 cross(-DOP,up)=-right → + // 轴反向。故 elevation 取 +20(非 -20)抵消,使选中切片后上下方向与未选中(默认)时一致。 const double azimuth = dx * (-20.0 / size[0]) * this->MotionFactor; - const double elevation = dy * (-20.0 / size[1]) * this->MotionFactor; + const double elevation = dy * (20.0 / size[1]) * this->MotionFactor; double up[3], dop[3], right[3]; cam->GetViewUp(up); @@ -185,6 +194,17 @@ void PickInteractorStyle::OnLeftButtonUp() { if (onDrag2DEnd) onDrag2DEnd(); return; } + // 单击(抬键位移<阈值=非拖动)且按下未命中切片 → 取消选中(点空/点体;体 PickableOff 故点体也 hit=false)。 + // 拖空白旋转:抬键位移大 → 不取消,保留"绕选中切片旋转"。Esc 仍是完全拉近时的兜底。 + if (!lock2D_ && !downHitSlice_ && onDeselect) { + auto* iren = this->GetInteractor(); + const int* up = iren ? iren->GetEventPosition() : nullptr; + if (up) { + const int dx = up[0] - lastDownPos_[0], dy = up[1] - lastDownPos_[1]; + if (dx * dx + dy * dy <= kClickSlopPx2) onDeselect(); // 是单击 → 取消 + } + } + hasRotatePivot_ = false; // 拖动结束 → 清支点(下次按下重新捕获) Superclass::OnLeftButtonUp(); // 平移/旋转/缩放等由基类按 State 收尾 } @@ -205,4 +225,15 @@ void PickInteractorStyle::OnMouseWheelBackward() { Superclass::OnMouseWheelBackward(); } +void PickInteractorStyle::OnKeyPress() { + // Esc → 取消选中切片(拉近后满屏切片、点不到空白处取消时的可靠出口)。其它键走默认。 + auto* iren = this->GetInteractor(); + const char* sym = iren ? iren->GetKeySym() : nullptr; + if (sym && std::strcmp(sym, "Escape") == 0) { + if (onDeselect) onDeselect(); + return; + } + Superclass::OnKeyPress(); +} + } // namespace geopro::render::interact diff --git a/src/render/interact/PickInteractorStyle.hpp b/src/render/interact/PickInteractorStyle.hpp index 5c9b41f..81089be 100644 --- a/src/render/interact/PickInteractorStyle.hpp +++ b/src/render/interact/PickInteractorStyle.hpp @@ -30,6 +30,12 @@ public: // 取当前旋转中心(D39):有选中三维体/切片→填其中心、返回 true;否则 false(绕默认焦点)。 // 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。 std::function getRotateCenter; + // 取消选中切片(Esc 键触发)。拉近后满屏切片、点不到空白处取消时的可靠出口。 + std::function onDeselect; + // 精确判定:当前光标【射线】是否穿过某张切片的真实矩形(origin/point1/point2)内。 + // 不靠带容差/夹取的 picker 命中点 → 切片边界外不再误判命中(用户实测的外扩)。 + // 点帘面/其它非切片物/边界外 → 返回 false → 单击即取消选中。 + std::function hitTestSlice; // 二维分析锁:开 → 左键拖动改为平移、禁旋转(仅平移+缩放);关 → 恢复三维拾取/旋转交互。 void setLock2D(bool on) { lock2D_ = on; } @@ -51,6 +57,7 @@ public: void OnLeftButtonDown() override; void OnMouseWheelForward() override; void OnMouseWheelBackward() override; + void OnKeyPress() override; // Esc → onDeselect(取消选中切片) // 绕选中物中心旋转(D39):有 getRotateCenter 时, 绕该中心增量旋转整个相机(位置+焦点+up), // 中心在世界/屏幕都不动→不跳; 否则回退默认(绕焦点)。 void Rotate() override; @@ -68,6 +75,15 @@ private: // 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。 double lastDownTime_ = -1.0; // 单调时钟(毫秒),-1=无 int lastDownPos_[2] = {0, 0}; + // 左键按下时是否命中【切片】(精确:经 hitTestSlice 判点在切片矩形内,非"任意可拾取物")。 + // 抬键若为单击(无拖动)且未命中切片 → 取消选中(点空/点体/帘面/其它非切片物)。 + // 体 PickableOff、帘面虽可拾取但 hitTestSlice 判其非切片 → 都走取消。拖动则不取消(保留旋转)。 + bool downHitSlice_ = false; + + // 旋转支点:按下(拖动起点)时经 getRotateCenter 捕获一次,拖动中固定不漂(光标会动→不可每帧重取)。 + // 选中切片=其中心;否则=光标射线穿过的体中段点。无则 hasRotatePivot_=false→默认绕焦点。 + Vec3 rotatePivot_{}; + bool hasRotatePivot_ = false; // 二维分析模式:左键=平移、禁旋转(仅平移+缩放)。由 InteractionManager 在切 tab 时设。 bool lock2D_ = false; diff --git a/src/render/interact/SlicePlaneMath.cpp b/src/render/interact/SlicePlaneMath.cpp index 9def0c2..edf5815 100644 --- a/src/render/interact/SlicePlaneMath.cpp +++ b/src/render/interact/SlicePlaneMath.cpp @@ -56,10 +56,12 @@ Vec3 clampToBounds(const Vec3& origin, const std::array& b) { clamp1(origin[2], b[4], b[5])}; } -double wheelStep(const std::array& b, int dir) { - const double dx = b[1] - b[0], dy = b[3] - b[2], dz = b[5] - b[4]; - const double diag = std::sqrt(dx * dx + dy * dy + dz * dz); - const double mag = diag * 0.02; // 一次滚轮 ≈ 1/50 对角线 +double wheelStep(const std::array& spacing, const Vec3& normal, int voxels, int dir) { + // 沿法向的体素间距 = 各轴间距在法向上的投影绝对值和(轴向切片即取对应轴间距)。 + // 一格 = voxels 个采样,与体总长无关 → 超长轴(雷达沿线)也只挪几个采样、不跳过、不过冲。 + const double sn = std::abs(spacing[0] * normal[0]) + std::abs(spacing[1] * normal[1]) + + std::abs(spacing[2] * normal[2]); + const double mag = (sn > 0.0 ? sn : 1.0) * (voxels > 0 ? voxels : 1); return (dir >= 0 ? mag : -mag); } diff --git a/src/render/interact/SlicePlaneMath.hpp b/src/render/interact/SlicePlaneMath.hpp index 2beac40..49d741c 100644 --- a/src/render/interact/SlicePlaneMath.hpp +++ b/src/render/interact/SlicePlaneMath.hpp @@ -43,9 +43,10 @@ struct FaceOnCamera { }; FaceOnCamera faceOnCamera(const Vec3& focal, const Vec3& normal, double dist); -// 滚轮推进步长:取包围盒对角线长度的固定比例 × 方向(±1)。 -// 使一次滚轮在体内移动适中(约 1/50 对角线);dir>0 沿法向、dir<0 反向。 -double wheelStep(const std::array& bounds, int dir); +// 滚轮推进步长:按【沿切片法向的体素间距】× voxels × 方向(±1),即一格移动 voxels 个采样。 +// 与体总长无关(不会因长轴 375m 而步太大、也不因比例而跳过内容);spacing=体的三轴间距(含纵向夸张)。 +// voxels 小=细调;调用方可在 Shift 时传更大值做粗调。超长轴的粗定位另靠"沿线滑块",非滚轮。 +double wheelStep(const std::array& spacing, const Vec3& normal, int voxels, int dir); // 在切片中心列表中找离世界点最近的索引(按到平面的距离最小)。 // centers/normals 等长;空列表返回 -1。worldPoint 在哪张切片上→该索引。 diff --git a/tests/data/test_gpr_volume_repository.cpp b/tests/data/test_gpr_volume_repository.cpp index a2e05e7..0b45bc1 100644 --- a/tests/data/test_gpr_volume_repository.cpp +++ b/tests/data/test_gpr_volume_repository.cpp @@ -57,12 +57,42 @@ TEST(GprVolumeRepositoryAdapter, DequantDimsSpacingAndBlank) { EXPECT_DOUBLE_EQ(g.spacing[1], 1.37); EXPECT_DOUBLE_EQ(g.spacing[2], 0.05); - // 物理值域 = BuiltI16 的 vminPhys/vmaxPhys。 - EXPECT_DOUBLE_EQ(g.vmin, 5.0); - EXPECT_DOUBLE_EQ(g.vmax, 20.0); + // 显示值域 = 双极对称窗口(以中位数为中心),非全 vminPhys/vmaxPhys。3 个有效体素 {9,12,20}: + // 中位数=12 → 窗口对称、【中点=中位数 12】(灰点落基线)。vmin(i - 500); + built.vol.at(1000, 0, 0) = 30000; + built.vol.at(1001, 0, 0) = 30000; + built.vol.at(1002, 0, 0) = -30000; + built.vol.at(1003, 0, 0) = -30000; + + const geopro::data::VolumeGrid g = geopro::data::builtI16ToVolumeGrid(built); + + // 对称 99% 窗口裁掉两端 0.4% 离群 → 显示窗落在结构范围(±500)内,远离 ±30000。 + EXPECT_GT(g.vmin, -1000.0); // 负向离群被裁 + EXPECT_LT(g.vmax, 1000.0); // 正向离群被裁 + EXPECT_NEAR(0.5 * (g.vmin + g.vmax), 0.0, 5.0); // 对称:中点≈基线(中位数≈0) + // 数据本身仍保留离群(只是显示窗收窄)——抽查离群体素反量化值未被改动。 + EXPECT_DOUBLE_EQ(g.vol.at(1000, 0, 0), 30000.0); +} + // 写一个合成通道:.iprh 文本头 + .iprb 纯 int16 波形([trace*samples + s],s 最快)。 // 与 test_gpr3dv_volume_bridge 同口径,确保 createGprVolumeGrid 走真 P1/P2 链。 void writeSyntheticChannel(const fs::path& iprhPath, int samples, int traces, diff --git a/tests/io/gpr/test_radar_volume_assembler.cpp b/tests/io/gpr/test_radar_volume_assembler.cpp index 312cc1d..6afee13 100644 --- a/tests/io/gpr/test_radar_volume_assembler.cpp +++ b/tests/io/gpr/test_radar_volume_assembler.cpp @@ -37,3 +37,56 @@ TEST(RadarVolumeAssembler, CoarseDownsamplesTracesAndScalesDx) { EXPECT_DOUBLE_EQ(b.spacing[0], 0.2); EXPECT_NEAR(b.quant.toPhys(b.vol.at(1, 0, 0)), 20.0, b.quant.scale); // 输出道1 = 源道2 } + +// ── 合成靶标:在【装配出的体】里验通道插值的几何忠实度 ────────────────────── +// 现有 test_gpr_geometry 只验 planChannelInterpolation 的【行规划】;这两个测试验 +// assembleRadarVolume 把规划【应用到体】是否正确——即用户要判断的"通道插值在体里 +// 对不对、会不会造缝"。 +// 布局:3 通道偏移 {0, 0.10, 0.20},targetDy=0.05 → ny=round(0.2/0.05)+1=5: +// 行 j0=ch0 / j1=blend(ch0,ch1,0.5) / j2=ch1 / j3=blend(ch1,ch2,0.5) / j4=ch2。 +namespace { +RadarCubeDesc make3ChDesc() { + RadarCubeDesc d; + d.channels = 3; d.traces = 2; d.samples = 3; + d.dxBase = 0.1; d.dz = 0.05; + d.chXOffsets = {0.0, 0.10, 0.20}; // 触发通道插值 + return d; +} +} // namespace + +// 平层反射(同一深度 s=2 全通道等值 500)穿过通道插值后【全部行仍等于 500】—— +// 不出现"插值行衰减/锯齿/横向缝"(用户最担心的 10cm 缝就是这条若失败)。 +TEST(RadarVolumeAssembler, FlatReflectorStaysFlatAcrossInterpolatedRows) { + const RadarCubeDesc d = make3ChDesc(); + // s==2:平层反射(全通道 500);s==0:逐通道阶梯(ch0=100/ch1=200/ch2=300)验混合;其余 0。 + auto sampler = [](int c, int /*t*/, int s) { + if (s == 2) return 500.0; + if (s == 0) return 100.0 * (c + 1); + return 0.0; + }; + const geopro::core::BuiltI16 b = assembleRadarVolume(d, sampler, /*coarse=*/1, /*targetDy=*/0.05); + ASSERT_EQ(b.vol.ny(), 5); // 3 通道 → 5 行(含 2 条插值) + ASSERT_EQ(b.vol.nx(), 2); + ASSERT_EQ(b.vol.nz(), 3); + EXPECT_DOUBLE_EQ(b.spacing[1], 0.05); // 插值后 dy=targetDy + // 平层在【每一行、每一道】都应保持 500(插值不破坏横向连续)。 + for (int j = 0; j < b.vol.ny(); ++j) + for (int to = 0; to < b.vol.nx(); ++to) + EXPECT_NEAR(b.quant.toPhys(b.vol.at(to, j, 2)), 500.0, b.quant.scale) + << "行 j=" << j << " 道 to=" << to << " 处平层被插值破坏"; +} + +// 插值行 = 相邻两通道的正确线性混合(j1 在 ch0=100/ch1=200 之间 wb=0.5 → 150); +// 纯通道行 = 原通道值(j0=100 / j2=200 / j4=300)。验"插值没造假峰、没错位"。 +TEST(RadarVolumeAssembler, InterpolatedRowIsCorrectLinearBlend) { + const RadarCubeDesc d = make3ChDesc(); + auto sampler = [](int c, int /*t*/, int s) { return s == 0 ? 100.0 * (c + 1) : 0.0; }; + const geopro::core::BuiltI16 b = assembleRadarVolume(d, sampler, /*coarse=*/1, /*targetDy=*/0.05); + ASSERT_EQ(b.vol.ny(), 5); + const double tol = b.quant.scale; + EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 0, 0)), 100.0, tol); // j0 = ch0 + EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 1, 0)), 150.0, tol); // j1 = blend(100,200,0.5) + EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 2, 0)), 200.0, tol); // j2 = ch1 + EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 3, 0)), 250.0, tol); // j3 = blend(200,300,0.5) + EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 4, 0)), 300.0, tol); // j4 = ch2 +} diff --git a/tests/render/test_slice_plane_math.cpp b/tests/render/test_slice_plane_math.cpp index 44dd2b2..4c082ae 100644 --- a/tests/render/test_slice_plane_math.cpp +++ b/tests/render/test_slice_plane_math.cpp @@ -95,17 +95,22 @@ TEST(SlicePlaneMath, FaceOnNormalizesNormal) { expectVec(cam.position, 0, 6, 0); } -// ── wheelStep:滚轮推进步长(按对角线比例 × 方向)── +// ── wheelStep:步长 = 沿法向体素间距 × voxels × 方向(spacing=三轴间距,X法向取X间距)── TEST(SlicePlaneMath, WheelStepForwardPositive) { - EXPECT_GT(wheelStep({0, 10, 0, 0, 0, 0}, +1), 0.0); + EXPECT_GT(wheelStep({0.1, 0.1, 0.05}, {1, 0, 0}, 2, +1), 0.0); } TEST(SlicePlaneMath, WheelStepBackwardNegative) { - EXPECT_LT(wheelStep({0, 10, 0, 0, 0, 0}, -1), 0.0); + EXPECT_LT(wheelStep({0.1, 0.1, 0.05}, {1, 0, 0}, 2, -1), 0.0); } -TEST(SlicePlaneMath, WheelStepScalesWithBounds) { - const double small = wheelStep({0, 10, 0, 0, 0, 0}, 1); - const double big = wheelStep({0, 100, 0, 0, 0, 0}, 1); - EXPECT_GT(big, small); // 体越大步长越大 +TEST(SlicePlaneMath, WheelStepScalesWithVoxels) { + const double fine = wheelStep({0.1, 0.1, 0.05}, {1, 0, 0}, 1, 1); + const double coarse = wheelStep({0.1, 0.1, 0.05}, {1, 0, 0}, 10, 1); + EXPECT_GT(coarse, fine); // voxels 越大步长越大(Shift 粗调) +} +// 只取法向那条轴的【间距】(非总长):长轴间距大也不影响 Z 法向步长;1 体素 = Z 间距 0.05。 +TEST(SlicePlaneMath, WheelStepUsesNormalAxisSpacing) { + const double zStep = wheelStep({100.0, 0.1, 0.05}, {0, 0, 1}, 1, 1); // Z 法向 → 取 Z 间距 0.05 + EXPECT_NEAR(zStep, 0.05, 1e-9); // 与 X 间距 100 无关 } // ── nearestPlane:找点所在切片(按到平面距离最小)──