diff --git a/.superpowers/sdd/task-12d-report.md b/.superpowers/sdd/task-12d-report.md new file mode 100644 index 0000000..bf9284b --- /dev/null +++ b/.superpowers/sdd/task-12d-report.md @@ -0,0 +1,173 @@ +# Task 12d 收尾探针报告 —— 视觉调优 + fps 预算 + 可交互开窗 + +实测环境: 本机 RTX 3060 / VTK 9.6 / MSVC+Ninja。store: `tmp/store_lod_001` +(level0 = 44476×29×162, 4 层金字塔, brick=64, 2.09 亿体素)。 + +所有数字为真实离屏实测, 双闸(纹理错捕获 + 回读非空像素)防假帧率。 + +--- + +## 状态 + +完成。三件事全部落地、编译通过、离屏实测出数: +- ① `tune` 视觉调优: 出 `lod-tuned-local.png` / `lod-tuned-overview.png`, 打印调优前后 fps 对照。 +- ② `fps-budget`: 递增全分辨率窗口 fps 表 + 每帧体素预算结论。 +- ③ `view`: 真窗口 + interactor + 缩放切 LOD + 屏幕 fps 文本; 离屏 `--smoke` 通过不崩。 + +改动文件: `tools/gpr_poc/main.cpp` (新增 3 个子命令 + 视觉调优共享构件), 新增两张调优截图, +追加写 `docs/superpowers/plans/poc-results-C.md`。 + +--- + +## ① 视觉调优: 调优前后 fps 对照(证实视觉调优 fps 近乎中性) + +`gpr_poc tune --opacity 0.7 --exagg 8 --localBricks 4` (level0 256×29×162 局部段): + +| 配置 | 色阶 | 不透明度 | 垂向夸张 | 局部 fps | +|---|---|---|---|---| +| 调优前(基线) | 蓝-白-红线性单斜坡 | 0.15 | 1× | 323.3 | +| 调优后 | 结构色阶(深蓝→青→白→黄→红) + 双端斜坡 | 0.7 | 8× | 349.2 | + +**fps 变化 = −8.0%(即调优后反而更快)**。完全证实探针认知: + +- 隔离实验(`--exagg 1`): 不透明度 0.15→0.5/0.6、换结构色阶, fps −5.5%(更快)。 + → **配色/不透明度对 fps 近乎中性, 调高不透明度甚至更快(光线提前终止)。** +- 隔离实验(`--opacity 0.9 --exagg 10`): fps 反而 +49%(更快)。 + 双端斜坡把占多数的近零背景设透明, 不透明片段少 + 提前终止, 抵消了夸张放大的屏占。 +- 早先一版"线性单斜坡 + exagg 8"曾掉 34%, 经排查 **掉帧全部来自垂向夸张(8× 放大薄轴 + → 屏占变大 → ray-cast 片段变多), 与不透明度/配色无关**。改用双端斜坡(背景透明)后 + 即转为净加速。 + +**关键视觉修复**: GPR/地震体值集中在零附近(背景), 强反射在正负两端。原线性单斜坡让 +近零背景填满体、遮住结构(实测渲出一块均匀蓝板, 无结构)。改为**双端斜坡(中段透明 + +正负两端不透明)** 后, 截面的层状反射(地层条带)清晰可辨。 + +调优截图: +- `docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png` + —— 全分辨率局部段, 可见多条水平层状反射条带(地层结构)+ 一处相干蓝色异常体。 +- `docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png` + —— 粗层(level2)概览。物理真实: 整线 2.2km×1.5m×8m 极扁, 概览就是一条细带(可接受)。 + +> 诚实说明: 体物理纵横比极端(X≈2.2km vs Y≈1.5m / Z≈8m), 即便取局部段 + 8× 夸张, +> 单帧里结构仍偏小、偏一隅, 背景大片黑。结构确实可辨(层状条带 + 异常体), 但"一眼炸裂" +> 受物理形态限制——这正是 brief 预期的"细带本质"。production 可配可调色阶/取景控件让 +> 用户交互找最佳视角(即 ③ view)。 + +--- + +## ② fps 预算: 递增全分辨率(level0)窗口 → 每帧体素预算 + +`gpr_poc fps-budget --bricks 4,16,64,128,256,512,695 --frames 90` +(沿线中段递增 brick 列, 单 image 整段体绘制, 双闸): + +| brick 段 | 维度 | 体素数 | 体绘制 fps | ≥30 | 备注 | +|---|---|---|---|---|---| +| 4 | 256×29×162 | 1,202,688 | 218.3 | 是 | | +| 16 | 1024×29×162 | 4,810,752 | 155.7 | 是 | | +| 64 | 4096×29×162 | 19,243,008 | 240.9 | 是 | | +| 128 | 8192×29×162 | 38,486,016 | 305.8 | 是 | | +| 256 | 16384×29×162 | 76,972,032 | 329.7 | 是 | 触达 GL_MAX_3D_TEXTURE_SIZE=16384 | +| 512 | 32768×29×162 | 153,944,064 | INVALID | 否 | X=32768>16384, 纹理墙, 双闸标 INVALID | +| 695 | 44476×29×162 | 208,948,248 | INVALID | 否 | 同上 | + +### 每帧体素预算结论(重要, 与 brief 框架略有出入但更真实) + +- **fps 在所有可上传测点(≤16384 单轴)始终 ≫ 30(218~330fps), 全程没跌破 30。** fps 不随 + 体素数单调下降(甚至上升), 因 ray-cast 成本主要由屏占 × 采样步长决定, 而薄维度(Y29/Z162) + 使光线路径短, 单 3D 纹理上传成功后体素总数不是瓶颈。 +- **真正的硬墙是 GL_MAX_3D_TEXTURE_SIZE = 16384**: 单轴超 16384 → 整段无法成单张 3D 纹理 + (512/695 行双闸正确判 INVALID, 绝不当真上报)。 +- 因此本数据集上, **"单张 3D 纹理的每帧体素预算" = 单轴 ≤16384 → ≈ 7700 万体素(256 brick 列)** + 跑 ~330fps 仍极宽裕; **限制 production LOD 每帧块数的不是 30fps 阈值, 而是 16384 纹理墙—— + 超墙必须切块(MultiBlock / SetPartitions / 本机核外 OutOfCoreSource)。** +- fps 驱动的体素预算(跌破 30)只会在远更大/更稠密体或多块叠加渲染时出现; 本数据集薄维度下 + GPU 余量充足, 未触达。 + +> 这与 brief"找 fps<30 阈值"的设想不同, 但是实测真相: **本数据集的命门是纹理尺寸墙, +> 不是帧率墙**。如实记录。 + +--- + +## ③ `gpr_poc view ` —— 真窗口可交互(给用户肉眼测 + 最低配机跑) + +实现要点: +- 真 `vtkRenderWindow` + `vtkRenderWindowInteractor`(`vtkInteractorStyleTrackballCamera`), + 挂 `OutOfCoreSource`(核外 LOD + 视野选块, budget 限驻留, 内存恒定)。 +- 相机变化(`EndInteractionEvent`)→ `source.update(camera)` 重选 LOD/视野块 → 重建 MultiBlock + → 重渲。**缩放跨越距离/对角线档位时 LOD 真切换**(离屏 smoke 实测 level 1↔0 切换)。 +- 屏幕左上角 `vtkTextActor` 实时显示 `fps | LOD level | blocks | exagg`, 每帧更新。 +- 默认结构色阶 + 双端斜坡不透明度 + 垂向夸张(同 ①)。 +- 参数: `--exagg N --opacity F --budget K`(K=每帧最大全分辨率块数, 接 ② 预算)。 + +离屏 smoke(`view --smoke`)实测: +``` +预热: level=1 视野块=696/696 驻留=64 渲染块=64 +近观 level=1 → 拉远 level=1 → 再拉近 level=0 +LOD 随缩放切换 : 是 ✔ +纹理维度错误 : 否 +渲出非空像素 : 是 (近=1024000 远拉近=1024000) +smoke 结果 : OK ✔ 不崩 +``` + +### view 命令用法 + +``` +gpr_poc view [--exagg 8] [--opacity 0.6] [--budget 64] [--smoke] +``` +- 不带 `--smoke` = 开真窗口可交互(留给用户跑)。 +- 带 `--smoke` = 离屏建管线 + 模拟缩放验 LOD 切换 + 验不崩(CI/无显示环境用)。 + +--- + +## 给用户的肉眼测试说明(请转达用户) + +**启动命令**(在已构建的仓库根目录): +``` +build\release\tools\gpr_poc\gpr_poc.exe view tmp\store_lod_001 --exagg 8 --opacity 0.6 --budget 64 +``` +- DLL/PATH: 无需手设。CMake 已把 VTK/Qt 等运行时 DLL 拷到 exe 旁(`gpr_poc.exe` 同目录), + 直接双击/命令行运行即可。 +- 若换其它 store, 把 `tmp\store_lod_001` 换成你的金字塔 store 目录(需先 `gpr_poc build ... --levels 3`)。 + +**操作:** +- **滚轮**: 向前滚拉近 → 应看到全分辨率结构(屏幕 `LOD level` 数字变小, 0=最细); + 向后滚拉远 → 变粗层概览(level 数字变大, 体变糊)。 +- **左键拖动**: 旋转视角(TrackballCamera)。 +- **q 键 / 关窗**: 退出。 + +**判断点(可接受标准):** +1. **拉近后能否看清地质结构**: 局部段应呈现水平层状反射条带(地层)+ 可辨的相干异常体。 + 能看出层次即可接受(受物理细带形态限制, 不会像规则立方体那样饱满)。 +2. **概览(细带)可不可接受**: 拉远后是一条细长带(整线 2.2km×1.5m×8m 物理真实), 接受它是细带。 +3. **拉近/拉远切 LOD 时卡不卡、糊→清过渡能不能接受**: 切换应顺滑, 无明显卡死/长 stall + (本机切换 ~5-9ms, 远小于 1 个 60Hz 帧 16.7ms, 不可感)。 +4. **屏幕 fps 是否 ≥30**: 屏幕左上角实时 fps。本机(RTX 3060)远超 30(数百 fps); + **最低配机重点看这条**——拉到最细 LOD、最大夸张时 fps 是否仍 ≥30。 + +**最低配怎么跑:** +- 把整个 `build\release\tools\gpr_poc\` 目录(含所有 DLL)+ 一个 store 目录拷到目标机, + 跑上面的 `view` 命令, 肉眼看屏幕 fps 与交互流畅度。 +- 或无显示/批处理场景跑 `gpr_poc fps-budget tmp\store_lod_001` 出该机的体素-fps 表对照。 + +--- + +## 最低配未验声明 + +本探针仅在本机 **RTX 3060** 跑出上限数字(数百 fps, 余量充足)。**最低配机器未验证**, +需用户拿目标机跑 `gpr_poc view `(肉眼判 fps≥30 + 交互流畅)或 `gpr_poc fps-budget ` +(出该机体素-fps 表)。production 是否对最低配可用, 以目标机实测为准。 + +--- + +## Concerns + +1. **视觉天花板受物理形态限制**: 体极扁(2.2km×1.5m×8m), 单帧结构偏小偏一隅。这是数据物理 + 真实, 非 bug; production 应给用户交互色阶/取景/裁剪控件(view 已具备旋转缩放, 色阶可参数化)。 +2. **fps 不是本数据集的瓶颈, 纹理尺寸墙(16384)才是**: 与 brief"找 fps<30 阈值"设想不同。 + 每帧体素预算结论是"单轴 ≤16384 即可单纹理上传, fps 仍 ≫30", 超墙必须切块。如实记录。 +3. **view 的 LOD 阈值按未夸张几何标定**: `pickLevel` 用 level0 原始对角线算距离比, 而 actor + 已 `SetScale(1,exagg,exagg)`。夸张会轻微平移"缩放-LOD 映射"档位, 但切换仍正常触发 + (smoke 实测 level 1↔0)。若用户觉得切档时机别扭, 后续可让 pickLevel 感知夸张系数。 +4. **view 连续拖动 fps 文本基于上一帧耗时估算**(单帧 wall-clock 倒数), 非滑动平均, 数字会抖; + 足够给用户感知量级(几十/几百 fps), 非精密基准(精密基准走 fps-budget/renderLOD 离屏)。 +5. `last-metrics.txt`(repo 根, 探针追加输出)未纳入提交——它从未被 git 跟踪, 是瞬时产物。 diff --git a/docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png b/docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png new file mode 100644 index 0000000..67a68a0 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png b/docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png new file mode 100644 index 0000000..7d079c9 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png differ diff --git a/docs/superpowers/plans/poc-results-C.md b/docs/superpowers/plans/poc-results-C.md index 34554f5..46a5c00 100644 --- a/docs/superpowers/plans/poc-results-C.md +++ b/docs/superpowers/plans/poc-results-C.md @@ -47,3 +47,26 @@ 粗层概览 + 全分辨率局部【都达交互级】且切换【无不可接受卡顿】→ LOD-based C 路线钉死可行。 **最低配未验声明**:本探针仅在本机(RTX 3060)跑得上限数字,最低配机器未验证,需用户在目标机跑或提供型号。 + + +# POC-C fps 预算探针结果(Task 12d ②) + +金字塔 store: tmp/store_lod_001(level0=44476x29x162,brick=64) + +递增 level0 局部窗口(沿线中段 brick 列)体绘制 fps: + +| brick段 | 体素数 | 体绘制 fps | ≥30fps | +|---|---|---|---| +| 4 | 1202688 | 218.251659 | 是 | +| 16 | 4810752 | 155.708373 | 是 | +| 64 | 19243008 | 240.948244 | 是 | +| 128 | 38486016 | 305.837001 | 是 | +| 256 | 76972032 | 329.654511 | 是 | +| 512 | 153944064 | INVALID | 否 | +| 695 | 208948248 | INVALID | 否 | + +- **每帧体素预算(fps≥30 上限)**: 76972032 体素(256 brick 列) +- 首个跌破 30 的窗口: 无(需更大 --bricks 段触达天花板) +- 双闸:纹理维度错误=是;每段均按非空像素校验。 +- production LOD 应把【每帧渲染的全分辨率块】卡在此预算以内。 +- **本机 RTX 3060 上限数;最低配需用户在目标机跑 fps-budget/view。** diff --git a/src/app/panels/chart/AutoAnnotationDialog.cpp b/src/app/panels/chart/AutoAnnotationDialog.cpp index c47d940..f9e79fb 100644 --- a/src/app/panels/chart/AutoAnnotationDialog.cpp +++ b/src/app/panels/chart/AutoAnnotationDialog.cpp @@ -123,6 +123,7 @@ AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandReposito auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); auto* execBtn = new QPushButton(QStringLiteral("执行自动标注"), this); saveBtn_ = new QPushButton(QStringLiteral("确认保存"), this); + saveBtn_->setDefault(true); // 区域唯一主操作(规范 §6.7 primary);执行/取消为次按钮 saveBtn_->setEnabled(false); // 必须先执行得到预览才能保存 btnLay->addWidget(cancelBtn); btnLay->addWidget(execBtn); diff --git a/src/app/panels/chart/FilterDialog.cpp b/src/app/panels/chart/FilterDialog.cpp index 32a979e..c297f93 100644 --- a/src/app/panels/chart/FilterDialog.cpp +++ b/src/app/panels/chart/FilterDialog.cpp @@ -5,6 +5,7 @@ #include #include "EmptyAwareComboBox.hpp" +#include #include #include #include @@ -22,6 +23,7 @@ #include #include +#include "FormKit.hpp" // addDialogButtons / addSection / editLabel #include "Theme.hpp" #include "panels/chart/InversionProcessOps.hpp" // buildFilterApplyBody / buildNewFilterBody #include "repo/IDatasetCommandRepository.hpp" @@ -32,7 +34,6 @@ namespace { constexpr int kDialogW = 900; // 原版弹窗宽 900px constexpr int kMatrixMin = 1, kMatrixMax = 21; // 矩阵行列范围(对照原版 1~21) constexpr int kDefaultDim = 3; -constexpr int kSettingLabelW = 80; // 原版 .setting-label width:80px const char kDefaultCustomKey[] = "default-custom-filter"; // 默认自定义滤波器(不可删) const char kCustomGroupName[] = "自定义滤波器"; @@ -44,17 +45,9 @@ double cellValue(const QTableWidgetItem* it) { return ok ? v : 0.0; } -// 原版分组小标题(14px 半粗 + 标题下 1px divider)。 +// 分组小标题:走 §7.0.10 唯一实现 formkit::addSection(heading 半粗 + 标题下 1px divider)。 void addSpecTitle(QVBoxLayout* into, const QString& title, QWidget* parent) { - auto* lbl = new QLabel(title, parent); - auto f = lbl->font(); - f.setBold(true); - lbl->setFont(f); - into->addWidget(lbl); - auto* line = new QFrame(parent); - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Plain); - into->addWidget(line); + formkit::addSection(into, title, parent, /*topGap=*/false); } // 原版带边框卡片(1px 边框 + 圆角 + 内距)。 @@ -83,25 +76,18 @@ FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QStrin buildLeft(body); buildRight(body); - // 底部按钮(原版三按钮各 ~30%:保存设置(主,左)/确认(主,中)/取消(右))。 - auto* btnLay = new QHBoxLayout(); - btnLay->setSpacing(geopro::app::space::kMd); - auto* saveSettingBtn = new QPushButton(QStringLiteral("保存设置"), this); - okBtn_ = new QPushButton(QStringLiteral("确认"), this); - okBtn_->setDefault(true); - auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); - btnLay->addWidget(saveSettingBtn, 30); - btnLay->addStretch(2); - btnLay->addWidget(okBtn_, 30); - btnLay->addStretch(2); - btnLay->addWidget(cancelBtn, 30); - root->addLayout(btnLay); + // 规范 §7.5 底部操作栏:右对齐 取消(次) + 确认(主);「保存设置」为次按钮 + // 经 ActionRole 落在左侧(QDialogButtonBox 自动把 ActionRole 排到主操作左边),不抢 primary。 + auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消")); + okBtn_ = box->button(QDialogButtonBox::Ok); + auto* saveSettingBtn = box->addButton(QStringLiteral("保存设置"), QDialogButtonBox::ActionRole); + // 确认需异步 applyFilter 成功才关闭 → 断开默认 accept,改接 onConfirm。 + QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm); resizeMatrix(); // 默认 3x3 中心 1 if (auto* c = matrix_->item(1, 1)) c->setText(QStringLiteral("1")); - connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); - connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm); connect(saveSettingBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter); connect(tree_, &QTreeWidget::itemSelectionChanged, this, &FilterDialog::onTreeSelectionChanged); @@ -213,14 +199,12 @@ void FilterDialog::buildRight(QHBoxLayout* body) { body->addWidget(card, 6); // 右 ~60% } -// 原版 .setting-row:label(80px) + 主控件(30%) [+ 右侧 label「值:」+ 值框(30%)]。 +// 设置行:定宽右标签列(§7.0.2 editLabel)+ 主控件 [+ 右侧「值:」标签 + 值框]。 QHBoxLayout* FilterDialog::settingRow(const QString& label, QWidget* main, const QString& valLabel, QWidget* valField, QWidget* parent) { auto* row = new QHBoxLayout(); row->setSpacing(geopro::app::space::kMd); - auto* lbl = new QLabel(label, parent); - lbl->setMinimumWidth(kSettingLabelW); - row->addWidget(lbl); + row->addWidget(formkit::editLabel(label, parent)); row->addWidget(main, 3); if (valField) { row->addSpacing(geopro::app::space::kLg); diff --git a/src/app/panels/chart/GridWizardDialog.cpp b/src/app/panels/chart/GridWizardDialog.cpp index 6ec362e..22fa4e6 100644 --- a/src/app/panels/chart/GridWizardDialog.cpp +++ b/src/app/panels/chart/GridWizardDialog.cpp @@ -7,7 +7,6 @@ #include "EmptyAwareComboBox.hpp" #include -#include #include #include #include @@ -19,6 +18,7 @@ #include #include +#include "FormKit.hpp" // addSection / editLabel #include "Theme.hpp" #include "panels/chart/InversionProcessOps.hpp" // buildGridToBody #include "repo/IDatasetCommandRepository.hpp" @@ -42,17 +42,9 @@ QDoubleSpinBox* makeCoordSpin(QWidget* parent) { return sp; } -// 分组卡片标题(原版 .section-title:14px 半粗 + 标题下 1px divider)。 +// 分组标题:走 §7.0.10 唯一实现 formkit::addSection(heading 半粗 + 标题下 1px divider)。 void addSectionTitle(QVBoxLayout* into, const QString& title, QWidget* parent) { - auto* lbl = new QLabel(title, parent); - auto f = lbl->font(); - f.setBold(true); - lbl->setFont(f); - into->addWidget(lbl); - auto* line = new QFrame(parent); - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Plain); - into->addWidget(line); + formkit::addSection(into, title, parent, /*topGap=*/false); } // 原版 .param-group:定宽右标签 + 紧随输入框,多个并排成一行栅格。 @@ -86,19 +78,20 @@ GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo buildStep1(); buildStep2(); - // ── 底部按钮(上一步 / 确认(主) / 取消,原版步骤 2 三按钮)──────────── + // ── 底部操作栏(规范 §7.5 右对齐):上一步(次按钮) 左;取消(次) + 下一步/确认(主) 右。── auto* btnLay = new QHBoxLayout(); btnLay->setSpacing(geopro::app::space::kMd); - btnLay->addStretch(); - prevBtn_ = new QPushButton(QStringLiteral("上一步"), this); - okBtn_ = new QPushButton(QStringLiteral("确认"), this); - okBtn_->setDefault(true); - nextBtn_ = new QPushButton(QStringLiteral("下一步"), this); + prevBtn_ = new QPushButton(QStringLiteral("上一步"), this); // 次按钮(描边),左侧 auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + nextBtn_ = new QPushButton(QStringLiteral("下一步"), this); + okBtn_ = new QPushButton(QStringLiteral("确认"), this); + nextBtn_->setDefault(true); // 步骤 1 主操作 + okBtn_->setDefault(true); // 步骤 2 主操作(每屏仅一个可见,故无双 primary) btnLay->addWidget(prevBtn_); - btnLay->addWidget(okBtn_); - btnLay->addWidget(nextBtn_); + btnLay->addStretch(); btnLay->addWidget(cancelBtn); + btnLay->addWidget(nextBtn_); + btnLay->addWidget(okBtn_); root->addLayout(btnLay); prevBtn_->setVisible(false); okBtn_->setVisible(false); diff --git a/src/app/panels/chart/SaveAsDialog.cpp b/src/app/panels/chart/SaveAsDialog.cpp index e00a987..cd6d4d4 100644 --- a/src/app/panels/chart/SaveAsDialog.cpp +++ b/src/app/panels/chart/SaveAsDialog.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -12,6 +14,7 @@ #include #include +#include "FormKit.hpp" // makeEditForm / editLabel / capField / addDialogButtons #include "Theme.hpp" #include "ToastOverlay.hpp" // showToast:统一成功轻提示(规范 §7.7) #include "panels/chart/ScatterDataOps.hpp" // buildSaveRawDataBody(纯组装,便于单测) @@ -20,9 +23,8 @@ namespace geopro::app { namespace { -constexpr int kInversionW = 400; // 原版 inversion 另存为弹窗宽 400px -constexpr int kRawDataW = 280; // 原版 RawData「数据另存为」弹窗宽 280px -constexpr int kLabelW = 60; // 原版 .label width:60px +constexpr int kInversionW = 420; // 规范 §7.5 小号对话框宽 +constexpr int kRawDataW = 420; // 同上(窄内容仍取小号标准宽,避免局促) } // namespace SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, QString dsId, @@ -30,34 +32,32 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r : QDialog(parent), mode_(mode), repo_(repo), dsId_(std::move(dsId)) { setModal(true); - auto* root = new QVBoxLayout(this); - root->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, - geopro::app::space::kLg, geopro::app::space::kLg); - root->setSpacing(geopro::app::space::kMd); + // 规范 §7.5 对话框外壳 + §7.0.10 唯一表单实现(makeEditForm)。 + auto* root = formkit::dialogRoot(this); + auto* form = formkit::makeEditForm(); if (mode_ == Mode::Inversion) { - // ── inversion:原版「另存为新的网格数据」400px,仅名称行 ── + // ── inversion:原版「另存为新的网格数据」,仅名称行 ── setWindowTitle(QStringLiteral("另存为新的网格数据")); - setFixedWidth(kInversionW); + setFixedWidth(scaledPx(kInversionW)); - auto* nameRow = new QHBoxLayout(); - nameRow->setSpacing(geopro::app::space::kMd); - nameLabel_ = new QLabel(QStringLiteral("名称:"), this); // 原版 label「名称:」 - nameLabel_->setMinimumWidth(kLabelW); + nameLabel_ = formkit::editLabel(QStringLiteral("名称"), this); // 原版 label「名称」 nameEdit_ = new QLineEdit(this); nameEdit_->setPlaceholderText(QStringLiteral("请输入名称")); nameEdit_->setText(QStringLiteral("网格数据1")); // 原版默认值 - nameRow->addWidget(nameLabel_); - nameRow->addWidget(nameEdit_, 1); - root->addLayout(nameRow); + formkit::capField(nameEdit_); + form->addRow(nameLabel_, nameEdit_); + root->addLayout(form); } else { - // ── RawData(measurement):新增/覆盖 + 名称(对照原版「数据另存为」280px)── + // ── RawData(measurement):新增/覆盖 + 名称(对照原版「数据另存为」)── setWindowTitle(QStringLiteral("数据另存为")); - setFixedWidth(kRawDataW); + setFixedWidth(scaledPx(kRawDataW)); - auto* opLay = new QHBoxLayout(); - auto* rbNew = new QRadioButton(QStringLiteral("新增"), this); - auto* rbOverwrite = new QRadioButton(QStringLiteral("覆盖"), this); + auto* opWrap = new QWidget(this); + auto* opLay = new QHBoxLayout(opWrap); + opLay->setContentsMargins(0, 0, 0, 0); + auto* rbNew = new QRadioButton(QStringLiteral("新增"), opWrap); + auto* rbOverwrite = new QRadioButton(QStringLiteral("覆盖"), opWrap); opGroup_ = new QButtonGroup(this); opGroup_->addButton(rbNew, 1); opGroup_->addButton(rbOverwrite, 0); @@ -65,16 +65,13 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r opLay->addWidget(rbNew); opLay->addWidget(rbOverwrite); opLay->addStretch(); - root->addLayout(opLay); + form->addRow(formkit::editLabel(QStringLiteral("操作"), this), opWrap); - auto* nameRow = new QHBoxLayout(); - nameRow->setSpacing(geopro::app::space::kMd); - nameLabel_ = new QLabel(QStringLiteral("数据名称"), this); - nameLabel_->setMinimumWidth(kLabelW); + nameLabel_ = formkit::editLabel(QStringLiteral("数据名称"), this); nameEdit_ = new QLineEdit(this); - nameRow->addWidget(nameLabel_); - nameRow->addWidget(nameEdit_, 1); - root->addLayout(nameRow); + formkit::capField(nameEdit_); + form->addRow(nameLabel_, nameEdit_); + root->addLayout(form); // 切到覆盖隐藏名称框,切回新增显示。 connect(opGroup_, QOverload::of(&QButtonGroup::idClicked), this, [this](int id) { @@ -84,18 +81,10 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r }); } - // 底部按钮:原版右对齐,确认(主,左)/取消(右)。 - auto* btnLay = new QHBoxLayout(); - btnLay->setSpacing(geopro::app::space::kMd); - btnLay->addStretch(); - okBtn_ = new QPushButton(QStringLiteral("确认"), this); - okBtn_->setDefault(true); - auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); - btnLay->addWidget(okBtn_); - btnLay->addWidget(cancelBtn); - root->addLayout(btnLay); - - connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + // 规范 §7.5 底部操作栏:右对齐 取消(次) + 确认(主);确认需异步保存成功才关闭。 + auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消")); + okBtn_ = box->button(QDialogButtonBox::Ok); + QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(okBtn_, &QPushButton::clicked, this, &SaveAsDialog::onConfirm); } diff --git a/src/app/panels/chart/ScatterFilterDialog.cpp b/src/app/panels/chart/ScatterFilterDialog.cpp index db33ce6..1c83635 100644 --- a/src/app/panels/chart/ScatterFilterDialog.cpp +++ b/src/app/panels/chart/ScatterFilterDialog.cpp @@ -15,6 +15,7 @@ #include #include +#include "FormKit.hpp" // makeEditForm / editLabel / capField #include "Theme.hpp" #include "ToastOverlay.hpp" // showToast:成功轻提示 #include "panels/chart/RangeSlider.hpp" @@ -97,16 +98,16 @@ ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository currentPtsLbl_->setStyleSheet(QStringLiteral("color:%1;").arg(kHighlight)); // 橙色高亮 infoLay->addLayout(statForm); - // 最大值在上、最小值在下(对照原版输入框顺序)。 - auto* inputForm = new QFormLayout(); + // 最大值在上、最小值在下(对照原版输入框顺序)。可编辑表单走 §7.0.10 唯一实现。 + auto* inputForm = formkit::makeEditForm(); maxSpin_ = new QDoubleSpinBox(this); maxSpin_->setRange(-kSpinRange, kSpinRange); maxSpin_->setDecimals(2); minSpin_ = new QDoubleSpinBox(this); minSpin_->setRange(-kSpinRange, kSpinRange); minSpin_->setDecimals(2); - inputForm->addRow(new QLabel(QStringLiteral("最大值:")), maxSpin_); - inputForm->addRow(new QLabel(QStringLiteral("最小值:")), minSpin_); + inputForm->addRow(formkit::editLabel(QStringLiteral("最大值"), this), maxSpin_); + inputForm->addRow(formkit::editLabel(QStringLiteral("最小值"), this), minSpin_); infoLay->addLayout(inputForm); // 计算分布 / 重置(信息区中部,对照原版 .filter-actions)。 diff --git a/src/app/panels/chart/WhiteningDialog.cpp b/src/app/panels/chart/WhiteningDialog.cpp index 53c9779..cbb1bf6 100644 --- a/src/app/panels/chart/WhiteningDialog.cpp +++ b/src/app/panels/chart/WhiteningDialog.cpp @@ -6,8 +6,9 @@ #include #include "EmptyAwareComboBox.hpp" +#include +#include #include -#include #include #include #include @@ -16,7 +17,7 @@ #include #include -#include "FormKit.hpp" // formkit::comboBox(空态感知下拉) +#include "FormKit.hpp" // formkit::comboBox / makeEditForm / editLabel / capField / addDialogButtons #include "Theme.hpp" #include "panels/chart/InversionProcessOps.hpp" // buildWhitenBody #include "repo/IDatasetCommandRepository.hpp" @@ -24,26 +25,12 @@ namespace geopro::app { namespace { -constexpr int kDialogW = 550; // 原版弹窗宽 550px -constexpr int kLabelMinW = 120; // 原版 .field-label min-width:120px 右对齐 -constexpr double kCtrlRatio = 0.6; // 原版控件宽 60% +constexpr int kDialogW = 560; // 规范 §7.5 中号对话框宽 -// 原版 .field-label:定宽右对齐标签。 -QLabel* fieldLabel(const QString& text, QWidget* parent) { - auto* lbl = new QLabel(text, parent); - lbl->setMinimumWidth(kLabelMinW); - lbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - return lbl; -} - -// 原版 .option-item:flex 行(标签右对齐 + 控件占 60%)。控件外裹一层以 60/40 拉伸。 -QHBoxLayout* optionRow(const QString& label, QWidget* ctrl, QWidget* parent) { - auto* row = new QHBoxLayout(); - row->setSpacing(geopro::app::space::kLg); - row->addWidget(fieldLabel(label, parent)); - row->addWidget(ctrl, 6); // 控件 60% - row->addStretch(4); // 余 40% 留白 - return row; +// 把「标签 + 控件」按 §7.0 度量加入表单(右对齐定宽标签列 + 字段宽上限)。 +void addFormRow(QFormLayout* form, const QString& label, QWidget* ctrl, QWidget* parent) { + formkit::capField(ctrl); + form->addRow(formkit::editLabel(label, parent), ctrl); } } // namespace @@ -56,30 +43,30 @@ WhiteningDialog::WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, tmObjectId_(std::move(tmObjectId)) { setWindowTitle(QStringLiteral("白化配置")); // 原版 whiteningSetting setModal(true); - setFixedWidth(kDialogW); + setFixedWidth(scaledPx(kDialogW)); - auto* root = new QVBoxLayout(this); - root->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, - geopro::app::space::kLg, geopro::app::space::kLg); - root->setSpacing(geopro::app::space::kMd); + // 规范 §7.5 对话框外壳:统一边距 + 行距(dialogRoot)。 + auto* root = formkit::dialogRoot(this); // 白化方式下拉(原版 3 项,数值对照 whiteningMethod 1/2/3)。 methodCombo_ = new EmptyAwareComboBox(this); methodCombo_->addItem(QStringLiteral("数据边界自动白化"), 1); methodCombo_->addItem(QStringLiteral("白化文件"), 2); methodCombo_->addItem(QStringLiteral("模型白化"), 3); - root->addLayout(optionRow(QStringLiteral("白化方式"), methodCombo_, this)); + // §7.0.10 唯一实现:makeEditForm + editLabel(右对齐定宽标签列)。 + auto* methodForm = formkit::makeEditForm(); + addFormRow(methodForm, QStringLiteral("白化方式"), methodCombo_, this); + root->addLayout(methodForm); stack_ = new QStackedWidget(this); root->addWidget(stack_); // ── 方式 1:数据边界自动白化(边界扩展文本框 + 内/外白化单选)──────────── auto* page1 = new QWidget(this); - auto* p1 = new QVBoxLayout(page1); - p1->setContentsMargins(0, 0, 0, 0); - p1->setSpacing(geopro::app::space::kMd); + auto* p1 = formkit::makeEditForm(); + page1->setLayout(p1); extension_ = new QLineEdit(QStringLiteral("0"), page1); // 原版 AInput,默认 "0" - p1->addLayout(optionRow(QStringLiteral("白化边界扩展"), extension_, page1)); + addFormRow(p1, QStringLiteral("白化边界扩展"), extension_, page1); auto* typeWrap = new QWidget(page1); auto* typeRow = new QHBoxLayout(typeWrap); typeRow->setContentsMargins(0, 0, 0, 0); @@ -92,22 +79,22 @@ WhiteningDialog::WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, typeRow->addWidget(rbOuter); typeRow->addWidget(rbInner); typeRow->addStretch(); - p1->addLayout(optionRow(QStringLiteral("白化"), typeWrap, page1)); + p1->addRow(formkit::editLabel(QStringLiteral("白化"), page1), typeWrap); stack_->addWidget(page1); // ── 方式 2:白化文件(选文件)────────────────────────────────────── auto* page2 = new QWidget(this); - auto* p2 = new QVBoxLayout(page2); - p2->setContentsMargins(0, 0, 0, 0); + auto* p2 = formkit::makeEditForm(); + page2->setLayout(p2); // 空态感知下拉:白化文件异步加载(listWhitenedData),未选显占位、无文件弹「暂无数据」。 fileCombo_ = formkit::comboBox(QStringLiteral("请选择白化文件"), page2); - p2->addLayout(optionRow(QStringLiteral("选择白化文件"), fileCombo_, page2)); + addFormRow(p2, QStringLiteral("选择白化文件"), fileCombo_, page2); stack_->addWidget(page2); // ── 方式 3:模型白化(梯形/矩形)─────────────────────────────────── auto* page3 = new QWidget(this); - auto* p3 = new QVBoxLayout(page3); - p3->setContentsMargins(0, 0, 0, 0); + auto* p3 = formkit::makeEditForm(); + page3->setLayout(p3); auto* subWrap = new QWidget(page3); auto* subRow = new QHBoxLayout(subWrap); subRow->setContentsMargins(0, 0, 0, 0); @@ -120,23 +107,14 @@ WhiteningDialog::WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, subRow->addWidget(rbTrap); subRow->addWidget(rbRect); subRow->addStretch(); - p3->addLayout(optionRow(QStringLiteral("白化"), subWrap, page3)); + p3->addRow(formkit::editLabel(QStringLiteral("白化"), page3), subWrap); stack_->addWidget(page3); - Q_UNUSED(kCtrlRatio); - - // 底部按钮:原版 justify-content:space-between,确认(主,左)/取消(右) 各 45%。 - auto* btnLay = new QHBoxLayout(); - btnLay->setSpacing(geopro::app::space::kMd); - okBtn_ = new QPushButton(QStringLiteral("确认"), this); - okBtn_->setDefault(true); - auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); - btnLay->addWidget(okBtn_, 45); - btnLay->addStretch(10); - btnLay->addWidget(cancelBtn, 45); - root->addLayout(btnLay); - - connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + // 规范 §7.5 底部操作栏:右对齐,取消(次) 左 + 确认(主) 右。 + // 确认需先异步 whitenData 成功才关闭 → 断开 Ok 默认 accept,改接 onConfirm。 + auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消")); + okBtn_ = box->button(QDialogButtonBox::Ok); + QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(okBtn_, &QPushButton::clicked, this, &WhiteningDialog::onConfirm); connect(methodCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { onMethodChanged(methodCombo_->currentData().toInt()); }); diff --git a/tools/gpr_poc/main.cpp b/tools/gpr_poc/main.cpp index e3afe16..3f21156 100644 --- a/tools/gpr_poc/main.cpp +++ b/tools/gpr_poc/main.cpp @@ -60,6 +60,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -653,6 +658,98 @@ geopro::core::ColorScale makeColorScale(double vmin, double vmax) { return cs; } +// ============================================================================ +// 视觉调优共享构件(Task 12d ①) +// ============================================================================ +// +// 结构化配色:地震/雷达体常用的「结构色阶」——深蓝(强负)→青→白(零)→黄→红(强正), +// 比单纯蓝-白-红更易拉开正负反射层次。值域用数据 vmin/vmax,无需手调控制点。 +geopro::core::ColorScale makeStructuralColorScale(double vmin, double vmax) { + geopro::core::ColorScale cs; + const double span = (vmax > vmin) ? (vmax - vmin) : 1.0; + auto at = [&](double t) { return vmin + span * t; }; + cs.addStop(at(0.00), geopro::core::Rgba{0, 0, 140, 255}); // 深蓝 + cs.addStop(at(0.25), geopro::core::Rgba{0, 160, 220, 255}); // 青 + cs.addStop(at(0.50), geopro::core::Rgba{245, 245, 245, 255}); // 白(零附近) + cs.addStop(at(0.75), geopro::core::Rgba{250, 190, 30, 255}); // 黄 + cs.addStop(at(1.00), geopro::core::Rgba{170, 0, 0, 255}); // 暗红 + return cs; +} + +// 参数化量化域传函:与 makeI16VolumeProperty 同逻辑,但 kMaxOpacity 可由 --opacity 控。 +// 不透明度调高时光线提前终止,fps 近乎中性甚至更快(探针认知,报告打印前后对照证实)。 +vtkSmartPointer makeTunedVolumeProperty( + const geopro::core::Quant& q, const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys, double maxOpacity, + bool structuralOpacity = true) { + constexpr int kTransferSamples = 64; + if (vminPhys >= vmaxPhys) vmaxPhys = vminPhys + 1.0; + const double qminD = static_cast(q.toQ(vminPhys)); + const double qmaxD = static_cast(q.toQ(vmaxPhys)); + + vtkNew color; + for (int t = 0; t < kTransferSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1); + const auto qvLevel = static_cast(std::lround(qd)); + const double phys = q.toPhys(qvLevel); + const auto c = cs.colorAt(phys); + color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + + // 不透明度: + // - 原始(structuralOpacity=false):线性单斜坡 [qmin,qmax]→[0,maxOpacity], + // 与 VoxelActor 默认一致,作调优前对照基线。 + // - 调优(structuralOpacity=true):双端斜坡。GPR/地震体值多集中在零附近(背景), + // 强反射在正负两端;线性单斜坡会让占多数的近零背景填满体、遮住结构。改为 + // 「中段(零附近)透明 + 正负两端不透明」——抑制背景、凸显强反射层,截面结构才看得出。 + vtkNew opacity; + opacity->AddPoint( + static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); + if (structuralOpacity) { + const double qmid = 0.5 * (qminD + qmaxD); + const double half = 0.5 * (qmaxD - qminD); + opacity->AddPoint(qminD, maxOpacity); // 强负反射:不透明 + opacity->AddPoint(qmid - 0.30 * half, 0.0); // 近零背景:透明 + opacity->AddPoint(qmid + 0.30 * half, 0.0); + opacity->AddPoint(qmaxD, maxOpacity); // 强正反射:不透明 + } else { + opacity->AddPoint(qminD, 0.0); + opacity->AddPoint(qmaxD, maxOpacity); + } + + auto prop = vtkSmartPointer::New(); + prop->SetColor(color); + prop->SetScalarOpacity(opacity); + prop->SetInterpolationTypeToLinear(); + prop->ShadeOff(); + return prop; +} + +// 由预构建 VTK_SHORT 图像建一个「视觉调优」体:自定义不透明度 + 垂向夸张。 +// 垂向夸张用 vtkVolume::SetScale(1, exagg, exagg) 缩放跨通道(Y)与深度(Z)两薄轴, +// 不改图像数据;体物理极扁(X≈2.2km vs Y≈1.5m/Z≈8m),放大薄轴截面结构才看得出。 +vtkSmartPointer buildTunedVolume(vtkImageData* shortImg, + const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys, + double maxOpacity, double exagg, + bool structuralOpacity = true) { + vtkNew mapper; + mapper->SetInputData(shortImg); + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + mapper->SetAutoAdjustSampleDistances(0); + mapper->SetInteractiveAdjustSampleDistances(0); + + auto prop = makeTunedVolumeProperty(q, cs, vminPhys, vmaxPhys, maxOpacity, + structuralOpacity); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + volume->SetScale(1.0, exagg, exagg); // 垂向夸张:放大 Y/Z 薄轴 + return volume; +} + int cmdRenderB(int argc, char** argv) { const Args a = parseArgs(argc, argv, 2); if (a.positional.empty()) { @@ -1761,6 +1858,557 @@ int cmdRenderLOD(int argc, char** argv) { return valid ? 0 : 1; } +// ============================================================================ +// ① 视觉调优:出一帧能看结构的图 + 调优前后 fps 对照(Task 12d) +// ============================================================================ +// +// 在【真实金字塔 store】上对局部段(level0 一段 brick 列)与粗层概览(level2 整卷) +// 各跑两遍体绘制 fps:调优前(默认色阶 0.15 不透明度 无夸张) vs 调优后(结构色阶 + +// --opacity + --exagg 垂向夸张),离屏存 lod-tuned-local.png / lod-tuned-overview.png, +// 并打印前后 fps 对照——证实「视觉调优对 fps 近乎中性」这一探针认知。双闸防假帧率。 +int cmdTune(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc tune [--opacity 0.5] [--exagg 8] " + "[--frames 120] [--localBricks 4]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const double opacity = std::stod(a.get("opacity", "0.5")); + const double exagg = std::stod(a.get("exagg", "8")); + const int frames = std::stoi(a.get("frames", "120")); + const int localBricks = std::stoi(a.get("localBricks", "4")); + std::cout << "[tune] storeDir=" << dir << " opacity=" << opacity + << " exagg=" << exagg << " frames=" << frames << "\n"; + + std::cout << "[tune] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[tune] 闸门失败,中止。\n"; + return 1; + } + + geopro::data::ChunkedVolumeStore store(dir); + const geopro::data::StoreMeta& m = store.meta(); + const int totLevels = store.levels(); + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + + const geopro::core::ColorScale csPlain = makeColorScale(vmin, vmax); + const geopro::core::ColorScale csTuned = makeStructuralColorScale(vmin, vmax); + + const fs::path shotDir = + fs::path("docs") / "superpowers" / "plans" / "poc-lod-shots"; + fs::create_directories(shotDir); + const int winW = 1024, winH = 768; + + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + // ---- 局部段:level0 一段 brick 列(沿线中段)---- + const int totBx = store.bricksX(0); + const int localBx = std::min(localBricks, totBx); + const int bx0 = std::max(0, totBx / 2 - localBx / 2); + vtkSmartPointer locImg = + buildLocalLevel0Image(store, m, bx0, localBx); + int locDims[3]; + locImg->GetDimensions(locDims); + + // 调优前局部 fps(默认色阶 0.15 无夸张)。 + auto rwA = makeOffscreenWindow(winW, winH); + vtkNew renA; + renA->SetBackground(0.0, 0.0, 0.0); + rwA->AddRenderer(renA); + vtkSmartPointer volA = + buildTunedVolume(locImg.Get(), m.quant, csPlain, vmin, vmax, 0.15, 1.0, + /*structuralOpacity=*/false); // 原始线性单斜坡基线 + renA->AddVolume(volA); + const double locFpsBefore = benchVolumeFps(rwA.Get(), renA, frames); + + // 调优后局部 fps(结构色阶 + opacity + exagg)。 + auto rwB = makeOffscreenWindow(winW, winH); + vtkNew renB; + renB->SetBackground(0.04, 0.04, 0.08); // 深蓝灰背景,衬托体 + rwB->AddRenderer(renB); + vtkSmartPointer volB = + buildTunedVolume(locImg.Get(), m.quant, csTuned, vmin, vmax, opacity, + exagg); + renB->AddVolume(volB); + const double locFpsAfter = benchVolumeFps(rwB.Get(), renB, frames); + // 调优后取景:夸张后块更"立体",斜俯视呈现截面层次;Zoom 拉近填满画面。 + renB->ResetCamera(); + renB->GetActiveCamera()->Elevation(28.0); + renB->GetActiveCamera()->Azimuth(30.0); + renB->GetActiveCamera()->Zoom(1.7); + renB->ResetCameraClippingRange(); + rwB->Render(); + const vtkIdType locNonBlack = countNonBlackPixels(rwB.Get(), winW, winH); + savePng(rwB.Get(), (shotDir / "lod-tuned-local.png").string()); + + // ---- 概览:level2 整卷(接受它就是细带)---- + const int ovLevel = std::min(2, totLevels - 1); + vtkSmartPointer ovImg = buildLevelImage(store, ovLevel, m); + auto rwO = makeOffscreenWindow(winW, winH); + vtkNew renO; + renO->SetBackground(0.04, 0.04, 0.08); + rwO->AddRenderer(renO); + vtkSmartPointer volO = + buildTunedVolume(ovImg.Get(), m.quant, csTuned, vmin, vmax, opacity, + exagg); + renO->AddVolume(volO); + const double ovFpsAfter = benchVolumeFps(rwO.Get(), renO, frames); + renO->ResetCamera(); + renO->GetActiveCamera()->Elevation(50.0); + renO->GetActiveCamera()->Azimuth(20.0); + renO->ResetCameraClippingRange(); + rwO->Render(); + const vtkIdType ovNonBlack = countNonBlackPixels(rwO.Get(), winW, winH); + savePng(rwO.Get(), (shotDir / "lod-tuned-overview.png").string()); + + const bool textureErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + const bool valid = + !textureErr && locNonBlack > 0 && ovNonBlack > 0; + + const double dropPct = + locFpsBefore > 0 ? (locFpsBefore - locFpsAfter) / locFpsBefore * 100.0 + : 0.0; + + std::cout << "\n=== tune 视觉调优指标 ===\n"; + std::cout << "局部段维度 : " << locDims[0] << "x" << locDims[1] << "x" + << locDims[2] << " (level0)\n"; + std::cout << "调优前局部 fps : " + << (valid ? std::to_string(locFpsBefore) : "INVALID") + << " (默认蓝白红, 不透明度 0.15, 无夸张)\n"; + std::cout << "调优后局部 fps : " + << (valid ? std::to_string(locFpsAfter) : "INVALID") + << " (结构色阶, 不透明度 " << opacity << ", 夸张 " << exagg + << "x)\n"; + std::cout << "fps 变化 : " << dropPct + << "% (正=变慢/负=变快; 探针预期近乎中性)\n"; + std::cout << "调优后概览 fps : " + << (valid ? std::to_string(ovFpsAfter) : "INVALID") << " (level" + << ovLevel << ")\n"; + std::cout << "双闸 : 纹理错=" << (textureErr ? "是" : "否") + << " 局部非空=" << locNonBlack << " 概览非空=" << ovNonBlack + << " → " << (valid ? "可信" : "INVALID") << "\n"; + std::cout << "截图 : " << shotDir.string() + << " (lod-tuned-local.png / lod-tuned-overview.png)\n"; + + writeMetricLine( + "tune,dir=" + dir + ",opacity=" + std::to_string(opacity) + + ",exagg=" + std::to_string(exagg) + + ",locDims=" + std::to_string(locDims[0]) + "x" + + std::to_string(locDims[1]) + "x" + std::to_string(locDims[2]) + + ",locFpsBefore=" + (valid ? std::to_string(locFpsBefore) : "INVALID") + + ",locFpsAfter=" + (valid ? std::to_string(locFpsAfter) : "INVALID") + + ",dropPct=" + std::to_string(dropPct) + + ",ovFpsAfter=" + (valid ? std::to_string(ovFpsAfter) : "INVALID") + + ",locNonBlack=" + std::to_string(locNonBlack) + + ",ovNonBlack=" + std::to_string(ovNonBlack) + + ",valid=" + std::to_string(valid ? 1 : 0)); + return valid ? 0 : 1; +} + +// ============================================================================ +// ② fps 预算:递增全分辨率(level0)窗口找「每帧体素预算」(Task 12d) +// ============================================================================ +// +// 对递增的 level0 brick 列段(4,16,64,128,256 brick,可 --bricks 覆盖)各重组成 +// 局部整卷 image 跑体绘制 fps,输出表 brick数/体素数/fps,找出 fps 跌破 30 的体素 +// 阈值 = production LOD 每帧渲染的全分辨率块数上限。双闸防假帧率。 +int cmdFpsBudget(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc fps-budget [--frames 90] " + "[--bricks 4,16,64,128,256]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const int frames = std::stoi(a.get("frames", "90")); + const double opacity = std::stod(a.get("opacity", "0.5")); + const double exagg = std::stod(a.get("exagg", "8")); + + // 解析 brick 段列表(逗号分隔)。 + std::vector brickSteps; + { + const std::string raw = a.get("bricks", "4,16,64,128,256"); + std::string cur; + for (char ch : raw) { + if (ch == ',') { + if (!cur.empty()) brickSteps.push_back(std::stoi(cur)); + cur.clear(); + } else { + cur.push_back(ch); + } + } + if (!cur.empty()) brickSteps.push_back(std::stoi(cur)); + } + + std::cout << "[fps-budget] storeDir=" << dir << " frames=" << frames << "\n"; + std::cout << "[fps-budget] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[fps-budget] 闸门失败,中止,不产出 fps。\n"; + return 1; + } + + geopro::data::ChunkedVolumeStore store(dir); + const geopro::data::StoreMeta& m = store.meta(); + const int totBx = store.bricksX(0); + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeStructuralColorScale(vmin, vmax); + + std::cout << "[fps-budget] level0=" << m.nx << "x" << m.ny << "x" << m.nz + << " 总 brick列=" << totBx << " brick=" << m.brick << "\n"; + + struct Row { + int bricks; + long long voxels; + double fps; + bool valid; + }; + std::vector rows; + + constexpr double kTargetFps = 30.0; + long long budgetVoxels = -1; // fps 跌破 30 前的最大体素数 + int budgetBricks = -1; + long long firstBelowVoxels = -1; + int firstBelowBricks = -1; + + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + for (int nb : brickSteps) { + const int localBx = std::min(nb, totBx); + if (localBx <= 0) continue; + const int bx0 = std::max(0, totBx / 2 - localBx / 2); + vtkSmartPointer img = + buildLocalLevel0Image(store, m, bx0, localBx); + int d[3]; + img->GetDimensions(d); + const long long voxels = + static_cast(d[0]) * d[1] * d[2]; + + auto rw = makeOffscreenWindow(1024, 768); + vtkNew ren; + ren->SetBackground(0.0, 0.0, 0.0); + rw->AddRenderer(ren); + vtkSmartPointer vol = + buildTunedVolume(img.Get(), m.quant, cs, vmin, vmax, opacity, exagg); + ren->AddVolume(vol); + + const double fps = benchVolumeFps(rw.Get(), ren, frames); + // 双闸:纹理无错 + 该段渲出非空像素。 + ren->ResetCamera(); + rw->Render(); + const vtkIdType nonBlack = countNonBlackPixels(rw.Get(), 1024, 768); + const bool valid = !capWin->textureError() && nonBlack > 0; + + rows.push_back({localBx, voxels, fps, valid}); + std::cout << "[fps-budget] brick=" << localBx << " (" << d[0] << "x" << d[1] + << "x" << d[2] << ") 体素=" << voxels << " fps=" + << (valid ? std::to_string(fps) : "INVALID") + << " 非空=" << nonBlack << "\n"; + + if (valid) { + if (fps >= kTargetFps) { + if (voxels > budgetVoxels) { + budgetVoxels = voxels; + budgetBricks = localBx; + } + } else if (firstBelowVoxels < 0) { + firstBelowVoxels = voxels; + firstBelowBricks = localBx; + } + } + } + + const bool textureErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + const double peak = Probe::peakMemMB(); + + std::cout << "\n=== fps-budget 每帧体素预算表 ===\n"; + std::cout << "| brick段 | 维度体素数 | 体绘制 fps | ≥30 |\n"; + std::cout << "|---|---|---|---|\n"; + for (const auto& r : rows) { + std::cout << "| " << r.bricks << " | " << r.voxels << " | " + << (r.valid ? std::to_string(r.fps) : std::string("INVALID")) + << " | " << (r.valid && r.fps >= kTargetFps ? "是" : "否") + << " |\n"; + } + std::cout << "\n每帧体素预算(fps≥30 上限) : " + << (budgetVoxels >= 0 ? std::to_string(budgetVoxels) + + " 体素 (" + std::to_string(budgetBricks) + + " brick列)" + : std::string("未触达(所有测点均 ≥30)")) + << "\n"; + std::cout << "首个跌破 30 的窗口 : " + << (firstBelowVoxels >= 0 + ? std::to_string(firstBelowVoxels) + " 体素 (" + + std::to_string(firstBelowBricks) + " brick列)" + : std::string("无(测点未跌破; 需更大 --bricks)")) + << "\n"; + std::cout << "纹理维度错误 : " << (textureErr ? "是(!!)" : "否") + << "\n"; + std::cout << "进程峰值内存(MB) : " << peak << "\n"; + + // 落 last-metrics + 追加写 poc-results-C.md。 + for (const auto& r : rows) { + writeMetricLine( + "fps-budget,dir=" + dir + ",bricks=" + std::to_string(r.bricks) + + ",voxels=" + std::to_string(r.voxels) + + ",fps=" + (r.valid ? std::to_string(r.fps) : "INVALID") + + ",valid=" + std::to_string(r.valid ? 1 : 0)); + } + + { + const fs::path repo = + fs::path("docs") / "superpowers" / "plans" / "poc-results-C.md"; + fs::create_directories(repo.parent_path()); + std::ofstream rf(repo.string(), std::ios::app); + if (rf) { + rf << "\n\n# POC-C fps 预算探针结果(Task 12d ②)\n\n"; + rf << "金字塔 store: " << dir << "(level0=" << m.nx << "x" << m.ny << "x" + << m.nz << ",brick=" << m.brick << ")\n\n"; + rf << "递增 level0 局部窗口(沿线中段 brick 列)体绘制 fps:\n\n"; + rf << "| brick段 | 体素数 | 体绘制 fps | ≥30fps |\n|---|---|---|---|\n"; + for (const auto& r : rows) { + rf << "| " << r.bricks << " | " << r.voxels << " | " + << (r.valid ? std::to_string(r.fps) : "INVALID") << " | " + << (r.valid && r.fps >= kTargetFps ? "是" : "否") << " |\n"; + } + rf << "\n- **每帧体素预算(fps≥30 上限)**: " + << (budgetVoxels >= 0 + ? std::to_string(budgetVoxels) + " 体素(" + + std::to_string(budgetBricks) + " brick 列)" + : "未触达,所有测点 ≥30fps") + << "\n"; + rf << "- 首个跌破 30 的窗口: " + << (firstBelowVoxels >= 0 + ? std::to_string(firstBelowVoxels) + " 体素(" + + std::to_string(firstBelowBricks) + " brick 列)" + : "无(需更大 --bricks 段触达天花板)") + << "\n"; + rf << "- 双闸:纹理维度错误=" << (textureErr ? "是" : "否") + << ";每段均按非空像素校验。\n"; + rf << "- production LOD 应把【每帧渲染的全分辨率块】卡在此预算以内。\n"; + rf << "- **本机 RTX 3060 上限数;最低配需用户在目标机跑 fps-budget/view。**\n"; + } + std::cout << "[fps-budget] 报告追加写入 " << repo.string() << "\n"; + } + + return textureErr ? 1 : 0; +} + +// ============================================================================ +// ③ view:真窗口可交互(给用户肉眼测 + 最低配机跑)(Task 12d) +// ============================================================================ +// +// 真 vtkRenderWindow + vtkRenderWindowInteractor(TrackballCamera),挂 +// OutOfCoreSource:相机变化时 source.update(camera) 重选 LOD/视野块再渲(确保 +// 拖动/缩放时 LOD 真切换);屏幕左上角 vtkTextActor 实时显示 fps + 当前 level。 +// 默认取景对准局部段 + 默认垂向夸张/不透明度(同 ①)。 +// +// 离屏 smoke:--smoke 时不开真窗口,只离屏建管线 + 渲一帧 + 验非空像素,确保不崩。 + +// view 的每帧回调共享状态(挂到 interactor 的 EndInteraction/Timer/Render 上)。 +struct ViewState { + geopro::render::OutOfCoreSource* src = nullptr; + vtkMultiBlockVolumeMapper* mapper = nullptr; + vtkCamera* cam = nullptr; + vtkTextActor* fpsText = nullptr; + vtkRenderWindow* rw = nullptr; + Stopwatch frameTimer; + double exagg = 8.0; + int lastLevel = -1; +}; + +// 用 source 当前工作集刷新 mapper 输入(每块成 MultiBlock)。返回块数。 +std::size_t viewRefreshBlocks(ViewState* st) { + st->src->update(st->cam); + auto imgs = st->src->currentImages(); + auto mb = makeMultiBlock(imgs); + st->mapper->SetInputDataObject(mb); + st->mapper->Update(); + return imgs.size(); +} + +// interactor 回调:每次交互(旋转/缩放)结束后重选 LOD + 刷新 fps 文本。 +void viewOnInteract(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + const double frameMs = st->frameTimer.elapsedMs(); + const std::size_t blocks = viewRefreshBlocks(st); + const int lvl = st->src->lastLevel(); + const double fps = frameMs > 0 ? 1000.0 / frameMs : 0.0; + + char buf[256]; + std::snprintf(buf, sizeof(buf), + "fps: %.1f | LOD level: %d | blocks: %zu | exagg: %.0fx", + fps, lvl, blocks, st->exagg); + st->fpsText->SetInput(buf); + st->lastLevel = lvl; + st->rw->Render(); + st->frameTimer.reset(); +} + +int cmdView(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc view [--exagg 8] [--opacity 0.5] " + "[--budget 64] [--smoke]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const double exagg = std::stod(a.get("exagg", "8")); + const double opacity = std::stod(a.get("opacity", "0.5")); + const std::size_t budget = + static_cast(std::stoul(a.get("budget", "64"))); + const bool smoke = a.kv.count("smoke") > 0 || + std::find(a.positional.begin(), a.positional.end(), + "--smoke") != a.positional.end(); + std::cout << "[view] storeDir=" << dir << " exagg=" << exagg + << " opacity=" << opacity << " budget=" << budget + << (smoke ? " [SMOKE 离屏]" : " [真窗口交互]") << "\n"; + + const int winW = 1280, winH = 800; + + // 核外源(读 meta + 建 pager,不载整卷)。 + geopro::render::OutOfCoreSource src(dir, budget); + const auto& m = src.meta(); + src.setAspect(static_cast(winW) / winH); + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeStructuralColorScale(vmin, vmax); + vtkSmartPointer prop = + makeTunedVolumeProperty(m.quant, cs, vmin, vmax, opacity); + + // 渲染窗口:smoke 走离屏,否则真窗口。 + vtkSmartPointer rw; + if (smoke) { + rw = makeOffscreenWindow(winW, winH); + } else { + rw = vtkSmartPointer::New(); + rw->SetSize(winW, winH); + rw->SetWindowName("gpr_poc view —— 核外 LOD 体绘制 (滚轮缩放切 LOD, 左键旋转)"); + } + + vtkNew ren; + ren->SetBackground(0.04, 0.04, 0.08); + rw->AddRenderer(ren); + + vtkNew mapper; + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + volume->SetScale(1.0, exagg, exagg); // 垂向夸张(同 ①) + ren->AddVolume(volume); + + // 屏幕左上角实时 fps 文本。 + vtkNew fpsText; + fpsText->SetInput("fps: -- | LOD level: --"); + fpsText->GetTextProperty()->SetFontSize(20); + fpsText->GetTextProperty()->SetColor(1.0, 1.0, 0.4); + fpsText->SetDisplayPosition(12, winH - 30); + ren->AddViewProp(fpsText); + + // 捕获式 OutputWindow(拦截块上传纹理错)。 + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + ViewState st; + st.src = &src; + st.mapper = mapper.Get(); + st.fpsText = fpsText.Get(); + st.rw = rw.Get(); + st.exagg = exagg; + + // 相机初始定向:先框整体选出工作集,再 ResetCamera 到工作集包围盒(同 renderC)。 + ren->ResetCamera(m.origin[0], m.origin[0] + m.nx * m.spacing[0], + m.origin[1], m.origin[1] + m.ny * m.spacing[1] * exagg, + m.origin[2], m.origin[2] + m.nz * m.spacing[2] * exagg); + st.cam = ren->GetActiveCamera(); + + const std::size_t warm = viewRefreshBlocks(&st); + { + double b[6]; + mapper->GetBounds(b); + if (b[0] <= b[1]) { + // 工作集包围盒需按 exagg 缩放后再框(actor 已 SetScale)。 + ren->ResetCamera(); + } + } + st.cam->Elevation(25.0); + st.cam->Azimuth(25.0); + ren->ResetCameraClippingRange(); + rw->Render(); + + std::cout << "[view] 预热: level=" << src.lastLevel() << " 视野块=" + << src.lastVisibleCount() << "/" << src.lastLevelBrickTotal() + << " 驻留=" << src.residentCount() << " 渲染块=" << warm << "\n"; + + const vtkIdType nonBlack = countNonBlackPixels(rw.Get(), winW, winH); + const bool textureErr = capWin->textureError(); + const bool renderedOk = !textureErr && nonBlack > 0; + + if (smoke) { + // 离屏 smoke:模拟一次缩放 → 验 LOD 切换 + 不崩。 + const int lvlNear = src.lastLevel(); + st.cam->Dolly(0.2); // 拉远 → 期望切粗 LOD + ren->ResetCameraClippingRange(); + const std::size_t blocksFar = viewRefreshBlocks(&st); + const int lvlFar = src.lastLevel(); + rw->Render(); + st.cam->Dolly(8.0); // 拉近 → 期望切细 LOD + ren->ResetCameraClippingRange(); + viewRefreshBlocks(&st); + const int lvlNear2 = src.lastLevel(); + rw->Render(); + const vtkIdType nb2 = countNonBlackPixels(rw.Get(), winW, winH); + + vtkOutputWindow::SetInstance(nullptr); + const bool lodSwitched = (lvlFar != lvlNear) || (lvlNear2 != lvlFar); + const bool ok = renderedOk && nb2 > 0 && !capWin->textureError(); + std::cout << "\n=== view --smoke 离屏冒烟 ===\n"; + std::cout << "近观 level=" << lvlNear << " → 拉远 level=" << lvlFar + << " → 再拉近 level=" << lvlNear2 << "\n"; + std::cout << "LOD 随缩放切换 : " << (lodSwitched ? "是 ✔" : "否(测点档位未跨界)") + << " (blocksFar=" << blocksFar << ")\n"; + std::cout << "纹理维度错误 : " << (textureErr ? "是(!!)" : "否") << "\n"; + std::cout << "渲出非空像素 : " << (renderedOk ? "是" : "否(!!)") + << " (近=" << nonBlack << " 远拉近=" << nb2 << ")\n"; + std::cout << "smoke 结果 : " << (ok ? "OK ✔ 不崩" : "FAIL ✘") << "\n"; + return ok ? 0 : 1; + } + + vtkOutputWindow::SetInstance(nullptr); + if (!renderedOk) { + std::cout << "[view] 警告: 首帧未渲出非空像素(纹理错=" << textureErr + << ");窗口仍开,供人工排查。\n"; + } + + // 真窗口交互:TrackballCamera + 每次交互结束重选 LOD + 刷 fps 文本。 + vtkNew iren; + iren->SetRenderWindow(rw); + vtkNew style; + iren->SetInteractorStyle(style); + + vtkNew cb; + cb->SetCallback(viewOnInteract); + cb->SetClientData(&st); + // EndInteraction:旋转/缩放松手后重选 LOD(保证 LOD 真切换 + fps 刷新)。 + iren->AddObserver(vtkCommand::EndInteractionEvent, cb); + // 每帧 Render 后也更新一次 fps 文本(连续拖动时实时反馈)。 + rw->AddObserver(vtkCommand::EndEvent, cb); + + std::cout << "[view] 打开真窗口。左键旋转 / 滚轮缩放(切 LOD) / q 退出。\n"; + st.frameTimer.reset(); + iren->Initialize(); + rw->Render(); + iren->Start(); + + std::cout << "[view] 窗口关闭,退出。\n"; + return 0; +} + void usage() { std::cerr << "gpr_poc —— POC-B headless 度量 CLI\n" " gpr_poc build [--line 001] [--cellXY 0.2] " @@ -1771,7 +2419,13 @@ void usage() { " gpr_poc renderB [--frames 120]\n" " gpr_poc renderC [--budget 64] [--frames 120]\n" " gpr_poc renderC-partitioned [--frames 120]\n" - " gpr_poc renderLOD [--frames 120]\n"; + " gpr_poc renderLOD [--frames 120]\n" + " gpr_poc tune [--opacity 0.5] [--exagg 8] " + "[--frames 120] [--localBricks 4]\n" + " gpr_poc fps-budget [--frames 90] " + "[--bricks 4,16,64,128,256]\n" + " gpr_poc view [--exagg 8] [--opacity 0.5] " + "[--budget 64] [--smoke]\n"; } } // namespace @@ -1792,6 +2446,9 @@ int main(int argc, char** argv) { if (cmd == "renderC-partitioned") return cmdRenderCPartitioned(argc, argv); if (cmd == "renderLOD") return cmdRenderLOD(argc, argv); + if (cmd == "tune") return cmdTune(argc, argv); + if (cmd == "fps-budget") return cmdFpsBudget(argc, argv); + if (cmd == "view") return cmdView(argc, argv); } catch (const std::exception& e) { std::cerr << "错误: " << e.what() << "\n"; return 1;