feat(radar): 三维交互精修 + 增益切换 + 视角导航(B#1/#2) + 全链路方案 spec

交互精修(雷达+反演通用):
- 切片拾取精确化: 光标射线 vs 切片真实矩形求交 + 可见数据(alpha)双判定, 去外扩
- 取消选中: 点体/空白/帘面即取消(精确命中判据) + Esc 兜底; 选中后上下拖动方向修正
- 滚轮步长: 按沿法向体素间距 x N(Shift 粗调), 不随体长跳变
- 双击正视: 缩放到切片(面内尺寸+视角框住), 不再又小又远
- 不透明度: 各向异性体用特征尺度(门控; 近立方反演维持原对角线)

视角导航(B 方案):
- #1 绕拾取点旋转: 无选中时绕光标射线穿体中段点(按下捕获/拖动固定), 不甩飞
- #2 沿线位置滑块: 雷达专属, 沿最长轴 dolly 到窗口(focusAlongLongAxis), 仅细长体显示

雷达显示增益: 右键切 AGC/保幅 tpow/关, 纯显示重建不动原始数据

spec: 落地 导入->处理->渲染 全链路方案(结合 POC 评估), 定预渲染可选->混合渲染源(IVolumeRenderSource)决策
This commit is contained in:
gaozheng 2026-06-30 18:58:42 +08:00
parent b2d130a7bf
commit 571a72701d
25 changed files with 794 additions and 74 deletions

View File

@ -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/现状) | 缺口 |
|---|---|---|
| 12 设备 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 源接进 appTrack 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、发现/注册、进程内 DLLABI/崩溃隔离风险vs 子进程。
2. **「处理 → 新 ds」管线**:血缘落树、预渲染 ds 的 store 路径/缓存/失效/磁盘占用、重处理**异步+进度+可取消**。
3. **设备/USB 接入**步12Windows 设备识别 + USB 盘浏览。最独立、与 POC 无关,可最后做;先跑通文件夹导入。
## 8. 风险排序
1. **中**插件框架架构骨架影响步4/6定义不好后面返工
2. **中**:预渲染 ds 的渲染/切片路由Track D 核心;但**引擎+缝已验证**,是"接线"风险非"能不能做"风险)。
3. **低–中**:处理管线异步/进度/缓存(工程量明确)。
4. **低**:设备 USBplumbing独立
## 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 接入范畴(未做)。

View File

@ -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();

View File

@ -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 拖动/平移)。

View File

@ -245,6 +245,38 @@ private:
std::vector<QWidget*> raiseAfter_; // 定位后再 raise 到 overlay 之上的常驻控件(工具条/提示)
};
// 底部条浮层定位器:把 overlay 钉在 hostQVTK 画布)底部、横向铺满(留边距)。
// 用于 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<double> 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<std::function<void()>>(
[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.71.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); };

View File

@ -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,

View File

@ -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);

View File

@ -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); });

View File

@ -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); // 切片→导出图片

View File

@ -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);

View File

@ -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) 移除旧 actor4) 若勾选中 → 异步用新增益重建体并重渲。
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

View File

@ -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_ 时退化为直连重建。

View File

@ -1,7 +1,10 @@
#include "data/GprVolumeRepository.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <limits>
#include <vector>
#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<std::int16_t>& src = built.vol.data();
std::vector<double>& dst = out.vol.data();
const double nan = std::numeric_limits<double>::quiet_NaN();
constexpr int kHistN = 65536; // int16 全域,桶 b ↔ 量化值 q = b - 32768
std::vector<std::uint32_t> 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<int>(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<double>(total);
std::uint64_t cum = 0;
for (int b = 0; b < kHistN; ++b) {
cum += hist[b];
if (static_cast<double>(cum) > half) { centerQ = b - 32768; break; }
}
}
// 自中位数对称外扩,覆盖 99% → 半宽 A。
const double need = 0.99 * static_cast<double>(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<double>(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<std::int16_t>(loQ));
out.vmax = built.quant.toPhys(static_cast<std::int16_t>(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<double> ps(n + 1, 0.0);
for (int k = 0; k < n; ++k) ps[k + 1] = ps[k] + col[k];
std::vector<double> 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<double> 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<double>(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<double>& d = g.vol.data();
auto idx = [nx, ny](int i, int j, int k) {
return (static_cast<std::size_t>(k) * ny + j) * nx + i;
};
std::vector<double> 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<double> 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<std::size_t>(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

View File

@ -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

View File

@ -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→whitecolorAt 才能给出连续灰阶。(反演等值面仍用稀疏 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<double>(i) / (kLevels - 1); // 0..1
const double val = vmin + (vmax - vmin) * f;
const auto g = static_cast<unsigned char>(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<VolumeGrid> 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 级连续(见 radarGrayScale3-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<VolumeGrid> g;
std::string err;
try {
g = std::make_shared<VolumeGrid>(
geopro::data::createRadarVolumeGrid(lineDir, linePrefix, coarse));
g = std::make_shared<VolumeGrid>(geopro::data::createRadarVolumeGrid(
lineDir, linePrefix, coarse, /*targetDy=*/0.025, gainMode));
} catch (const std::exception& e) {
err = e.what();
}

View File

@ -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<std::string, StoredVolume> volumes_; // dsId → 体
int volumeCounter_ = 0;

View File

@ -73,9 +73,18 @@ vtkSmartPointer<vtkVolume> 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<vtkVolume> assembleVolume(vtkImageData* img,
prop->SetScalarOpacity(opacity);
prop->SetInterpolationTypeToLinear();
prop->ShadeOff();
// 不透明度单位距离 = 包围盒对角 × kOpacityUnitFraction控制沿深度的累积速度使色阶「不透明度」滑块
// 有层次。取对角/10100%(每单位=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<vtkVolume>::New();
volume->SetMapper(mapper);

View File

@ -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<int>(slices_.size())) return false;
c = slices_[static_cast<std::size_t>(selected_)]->center();
return true;
if (selected_ >= 0 && selected_ < static_cast<int>(slices_.size())) {
c = slices_[static_cast<std::size_t>(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<double, 6> 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<vtkImageData> 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<int>(u * (dims[0] - 1) + 0.5);
int py = static_cast<int>(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<const unsigned char*>(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<std::size_t>(idx)]->center();
const Vec3 normal = slices_[static_cast<std::size_t>(idx)]->normal();
const double dist = cam->GetDistance(); // 保持当前观察距离
// 缩放到切片:按切片【面内尺寸】(法向两侧两轴的跨度)+ 相机视角算"刚好框住"的距离,
// 而非沿用当前(拉远看整条线时可能几百米)距离——否则正视后切片又小又远(用户实测)。
const VolumeImg* vol = volumeOf(slices_[static_cast<std::size_t>(idx)]->volumeDsId());
const std::array<double, 6> 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<int>(slices_.size())) return false;
const double step = wheelStep(imageBounds(selectedVolumeImage()), dir); // 选中切片所属体
// 步长按切片【法向上的体素间距 × N】算一格挪 N 个采样,与体总长无关——细长雷达体也不会
// 因长轴 375m 而步太大/跳过。Shift=粗调(×10);超长轴粗定位另靠沿线滑块(后续)。
const Vec3 n = slices_[static_cast<std::size_t>(selected_)]->normal();
std::array<double, 3> 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<std::size_t>(selected_)]->advance(step);
safeRender();
return true; // 消费滚轮(推进选中切片,不缩放)

View File

@ -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 交互回调用:触碰即选中)。

View File

@ -2,6 +2,7 @@
#include <chrono>
#include <cmath>
#include <cstring>
#include <vtkCallbackCommand.h>
#include <vtkCamera.h>
@ -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

View File

@ -30,6 +30,12 @@ public:
// 取当前旋转中心D39有选中三维体/切片→填其中心、返回 true否则 false绕默认焦点
// 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。
std::function<bool(Vec3& center)> getRotateCenter;
// 取消选中切片Esc 键触发)。拉近后满屏切片、点不到空白处取消时的可靠出口。
std::function<void()> onDeselect;
// 精确判定:当前光标【射线】是否穿过某张切片的真实矩形(origin/point1/point2)内。
// 不靠带容差/夹取的 picker 命中点 → 切片边界外不再误判命中(用户实测的外扩)。
// 点帘面/其它非切片物/边界外 → 返回 false → 单击即取消选中。
std::function<bool()> 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;

View File

@ -56,10 +56,12 @@ Vec3 clampToBounds(const Vec3& origin, const std::array<double, 6>& b) {
clamp1(origin[2], b[4], b[5])};
}
double wheelStep(const std::array<double, 6>& 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<double, 3>& 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);
}

View File

@ -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<double, 6>& bounds, int dir);
// 滚轮推进步长:按【沿切片法向的体素间距】× voxels × 方向(±1),即一格移动 voxels 个采样。
// 与体总长无关(不会因长轴 375m 而步太大、也不因比例而跳过内容)spacing=体的三轴间距(含纵向夸张)。
// voxels 小=细调;调用方可在 Shift 时传更大值做粗调。超长轴的粗定位另靠"沿线滑块",非滚轮。
double wheelStep(const std::array<double, 3>& spacing, const Vec3& normal, int voxels, int dir);
// 在切片中心列表中找离世界点最近的索引(按到平面的距离最小)。
// centers/normals 等长;空列表返回 -1。worldPoint 在哪张切片上→该索引。

View File

@ -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<vmax。
EXPECT_LT(g.vmin, g.vmax);
EXPECT_NEAR(0.5 * (g.vmin + g.vmax), 12.0, 1e-9); // 对称中点=中位数(基线→中灰)
EXPECT_TRUE(g.valid());
}
// 双极对称显示窗口:强离群(模拟原始 GPR 首波/路面/int16 饱和钳值)落在 1% 尾外应被裁剪,
// 窗口对称且中点落基线(中位数),而非被 ±极值撑满(否则结构压成中灰"灰板"或过饱和)。
TEST(GprVolumeRepositoryAdapter, RobustDisplayRangeClipsOutliers) {
geopro::core::BuiltI16 built;
built.vol = geopro::core::ScalarVolumeI16(1004, 1, 1);
built.quant.scale = 1.0; // phys = q
built.quant.offset = 0.0;
built.origin = {0.0, 0.0, 0.0};
built.spacing = {0.1, 0.1, 0.05};
built.vminPhys = -30000.0; // 全值域(含离群)——不应被采用为显示值域
built.vmaxPhys = 30000.0;
// 1000 个"结构"体素 q=-500..499(基线≈0) + 各 2 个 ±30000 强离群(共 1004离群 0.4% < 1% 尾)。
for (int i = 0; i < 1000; ++i)
built.vol.at(i, 0, 0) = static_cast<std::int16_t>(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,

View File

@ -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
}

View File

@ -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找点所在切片按到平面距离最小──