From ad6699d48d9a8233c80ef4b781f1f1da75d8d7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=98=9F?= <10947742+xu-xing9@user.noreply.gitee.com> Date: Wed, 1 Jul 2026 09:00:50 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(app):=20=E6=B7=BB=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=9B=B7=E8=BE=BE=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E5=AE=8C=E5=96=84B-Scan=E5=89=96?= =?UTF-8?q?=E9=9D=A2=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增ImportLocalRadarDialog对话框,支持选择文件夹扫描雷达测线数据 - 在ObjectTreePanel中添加"导入本地雷达数据"菜单选项 - 实现LocalRadarDatasetStore和LocalRadarScanner数据存储和扫描组件 - 扩展DatasetDetailController支持fileUrl参数传递,用于本地文件加载 - 更新ApiDatasetRepository支持雷达数据文件(.data/.iprb/.head/.iprh)解析 - 完善BScanProfileView视图,实现灰度B-Scan图像渲染和通道切换功能 - 添加多通道交错数据读取支持,兼容单文件多通道和多文件单通道模式 - 在数据集列表中存储fileUrl信息,用于本地雷达数据集识别和加载 BREAKING CHANGE: DatasetDetailController.openDataset方法增加fileUrl参数 ``` --- src/app/CMakeLists.txt | 1 + src/app/ImportLocalRadarDialog.cpp | 160 ++++++++++++ src/app/ImportLocalRadarDialog.hpp | 42 ++++ src/app/main.cpp | 39 ++- src/app/panels/DatasetListPanel.cpp | 1 + src/app/panels/ObjectTreePanel.cpp | 1 + src/app/panels/chart/BScanProfileView.cpp | 118 ++++++++- src/app/panels/chart/BScanProfileView.hpp | 12 +- src/controller/DatasetDetailController.cpp | 8 +- src/controller/DatasetDetailController.hpp | 3 +- src/data/CMakeLists.txt | 4 +- src/data/LocalRadarDatasetStore.cpp | 25 ++ src/data/LocalRadarDatasetStore.hpp | 22 ++ src/data/LocalRadarScanner.cpp | 130 ++++++++++ src/data/LocalRadarScanner.hpp | 38 +++ src/data/api/ApiDatasetRepository.cpp | 233 ++++++++++++++++-- src/data/api/ApiDatasetRepository.hpp | 10 +- src/data/repo/IAsyncDatasetRepository.hpp | 4 +- src/io/gpr/IprbReader.cpp | 57 +++++ src/io/gpr/IprbReader.hpp | 6 + .../test_dataset_detail_controller.cpp | 2 +- tests/data/test_3d_repo.cpp | 3 +- tests/io/gpr/test_ipr_header_extended.cpp | 2 +- 23 files changed, 872 insertions(+), 49 deletions(-) create mode 100644 src/app/ImportLocalRadarDialog.cpp create mode 100644 src/app/ImportLocalRadarDialog.hpp create mode 100644 src/data/LocalRadarDatasetStore.cpp create mode 100644 src/data/LocalRadarDatasetStore.hpp create mode 100644 src/data/LocalRadarScanner.cpp create mode 100644 src/data/LocalRadarScanner.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index a98b38a..c19e2e4 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -90,6 +90,7 @@ add_executable(geopro_desktop WIN32 ProjectListDialog.cpp ObjectFormDialog.cpp ImportDatasetDialog.cpp + ImportLocalRadarDialog.cpp ExportDatasetDialog.cpp AnomalySaveDialog.cpp AnomalyPropertiesDialog.cpp diff --git a/src/app/ImportLocalRadarDialog.cpp b/src/app/ImportLocalRadarDialog.cpp new file mode 100644 index 0000000..9a9b7ca --- /dev/null +++ b/src/app/ImportLocalRadarDialog.cpp @@ -0,0 +1,160 @@ +#include "ImportLocalRadarDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "data/LocalRadarDatasetStore.hpp" +#include "data/LocalRadarScanner.hpp" + +namespace geopro::app { + +ImportLocalRadarDialog::ImportLocalRadarDialog(const QString& tmObjectId, + QWidget* parent) + : QDialog(parent), tmObjectId_(tmObjectId) { + setWindowTitle(QStringLiteral("导入本地雷达数据")); + setMinimumSize(560, 380); + + auto* lay = new QVBoxLayout(this); + lay->setSpacing(12); + + // 路径选择行 + auto* pathLay = new QHBoxLayout(); + pathLay->addWidget(new QLabel(QStringLiteral("雷达数据文件夹:"), this)); + pathEdit_ = new QLineEdit(this); + pathEdit_->setReadOnly(true); + pathEdit_->setPlaceholderText(QStringLiteral("请选择包含 .data/.head 的文件夹")); + pathLay->addWidget(pathEdit_, 1); + auto* browseBtn = new QPushButton(QStringLiteral("浏览…"), this); + pathLay->addWidget(browseBtn); + lay->addLayout(pathLay); + + // 扫描按钮 + scanBtn_ = new QPushButton(QStringLiteral("扫描测线"), this); + scanBtn_->setEnabled(false); + lay->addWidget(scanBtn_); + + // 扫描结果树 + resultTree_ = new QTreeWidget(this); + resultTree_->setHeaderLabels( + QStringList() << QStringLiteral("测线名称") << QStringLiteral("类型") + << QStringLiteral("通道数") << QStringLiteral("采样点数") + << QStringLiteral("道数") << QStringLiteral("频率(MHz)")); + resultTree_->header()->setStretchLastSection(false); + resultTree_->setColumnWidth(0, 180); + resultTree_->setColumnWidth(1, 70); + resultTree_->setColumnWidth(2, 60); + resultTree_->setColumnWidth(3, 80); + resultTree_->setColumnWidth(4, 60); + resultTree_->setColumnWidth(5, 80); + lay->addWidget(resultTree_, 1); + + // 状态标签 + statusLabel_ = new QLabel(this); + statusLabel_->setWordWrap(true); + lay->addWidget(statusLabel_); + + // 底部按钮 + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + okBtn_ = new QPushButton(QStringLiteral("导入"), this); + okBtn_->setEnabled(false); + okBtn_->setDefault(true); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + btnLay->addWidget(okBtn_); + btnLay->addWidget(cancelBtn); + lay->addLayout(btnLay); + + QObject::connect(browseBtn, &QPushButton::clicked, this, + &ImportLocalRadarDialog::chooseFolder); + QObject::connect(scanBtn_, &QPushButton::clicked, this, + &ImportLocalRadarDialog::performScan); + QObject::connect(okBtn_, &QPushButton::clicked, this, + &ImportLocalRadarDialog::onConfirm); + QObject::connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); +} + +void ImportLocalRadarDialog::chooseFolder() { + const QString dir = QFileDialog::getExistingDirectory( + this, QStringLiteral("选择雷达数据文件夹"), QString(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (!dir.isEmpty()) { + sourceDir_ = dir; + pathEdit_->setText(dir); + scanBtn_->setEnabled(true); + okBtn_->setEnabled(false); + resultTree_->clear(); + statusLabel_->clear(); + } +} + +void ImportLocalRadarDialog::performScan() { + if (sourceDir_.isEmpty()) return; + resultTree_->clear(); + scanResults_ = geopro::data::LocalRadarScanner::scan(sourceDir_); + + for (const auto& s : scanResults_) { + auto* item = new QTreeWidgetItem(resultTree_); + item->setText(0, s.surveyName); + item->setText(1, s.channelCount == 1 ? QStringLiteral("2D") : QStringLiteral("3D")); + item->setText(2, QString::number(s.channelCount)); + item->setText(3, QString::number(s.meta.samples)); + item->setText(4, QString::number(s.meta.lastTrace)); + item->setText(5, QString::number(s.meta.frequency)); + } + + statusLabel_->setText( + QStringLiteral("发现 %1 条测线").arg(scanResults_.size())); + okBtn_->setEnabled(!scanResults_.empty()); +} + +void ImportLocalRadarDialog::copyDirectory(const QString& src, const QString& dst) { + QDir srcDir(src); + if (!srcDir.exists()) return; + QDir dstDir(dst); + if (!dstDir.exists()) dstDir.mkpath(QStringLiteral(".")); + + for (const QString& entry : srcDir.entryList(QDir::Files)) { + QFile::copy(srcDir.filePath(entry), dstDir.filePath(entry)); + } + for (const QString& subdir : srcDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + copyDirectory(srcDir.filePath(subdir), dstDir.filePath(subdir)); + } +} + +void ImportLocalRadarDialog::onConfirm() { + if (scanResults_.empty()) return; + + // 目标目录:/geopro/local_radar// + const QString baseDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/local_radar/"); + const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_hhmmss")); + const QString targetDir = baseDir + ts + QStringLiteral("/"); + + // 复制源文件夹到目标目录 + copyDirectory(sourceDir_, targetDir); + + // 更新扫描结果的 folderPath 为复制后的路径 + for (auto& s : scanResults_) { + s.folderPath = targetDir; + } + + // 生成 DsRow + auto rows = geopro::data::LocalRadarScanner::toDsRows(scanResults_); + for (auto& row : rows) { + row.structParentId = tmObjectId_.toStdString(); + } + + emit imported(tmObjectId_, rows); + accept(); +} + +} // namespace geopro::app diff --git a/src/app/ImportLocalRadarDialog.hpp b/src/app/ImportLocalRadarDialog.hpp new file mode 100644 index 0000000..f2d98f5 --- /dev/null +++ b/src/app/ImportLocalRadarDialog.hpp @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include +#include "data/LocalRadarScanner.hpp" +#include "repo/RepoTypes.hpp" + +class QLineEdit; +class QPushButton; +class QTreeWidget; +class QLabel; + +namespace geopro::app { + +// 本地雷达数据导入对话框 +class ImportLocalRadarDialog : public QDialog { + Q_OBJECT +public: + explicit ImportLocalRadarDialog(const QString& tmObjectId, + QWidget* parent = nullptr); + +signals: + void imported(const QString& tmObjectId, const std::vector& rows); + +private: + void chooseFolder(); + void performScan(); + void onConfirm(); + void copyDirectory(const QString& src, const QString& dst); + + QString tmObjectId_; + QString sourceDir_; + std::vector scanResults_; + + QLineEdit* pathEdit_ = nullptr; + QPushButton* scanBtn_ = nullptr; + QPushButton* okBtn_ = nullptr; + QTreeWidget* resultTree_ = nullptr; + QLabel* statusLabel_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index af88f1e..faaae99 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -92,6 +92,9 @@ #include "model/ColorScale.hpp" #include "model/Field.hpp" #include "repo/LocalSampleRepository.hpp" +#include "data/LocalRadarDatasetStore.hpp" +#include "data/LocalRadarScanner.hpp" +#include "ImportLocalRadarDialog.hpp" #include "ApiClient.hpp" #include "AuthService.hpp" @@ -312,6 +315,9 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re geopro::controller::DatasetDetailController& detailCtrl, const QString& sessionToken) { + // 本地雷达数据集存储(测试项目用)。 + geopro::data::LocalRadarDatasetStore localRadarStore; + // ── 世界系:启动取一次 grid1 的 lat/lon,用中位数作 GeoLocalFrame 原点 ── // 全项目共享(shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。 const auto baseGrid = repo.loadGrid("grid1"); @@ -1662,7 +1668,9 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // tmObjectId(白化 structParentId)从行读出透传,使白化模板列表非空。 const QString tmObjectId = item->data(0, geopro::app::kDsTmObjectIdRole).toString(); - if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId); + const QString fileUrl = + item->data(0, geopro::app::kDsFileUrlRole).toString(); + if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId, fileUrl); }); // ── 控制器信号 → 详情面板(tab 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ── @@ -2010,6 +2018,18 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re nav.switchProject(nav.currentProjectId()); }); dlg->open(); + } else if (action == QStringLiteral("importLocalRadar")) { + // 导入本地雷达数据:选择文件夹 → 扫描测线 → 复制到项目目录 → 混入数据集列表。 + auto* dlg = new geopro::app::ImportLocalRadarDialog(id, &window); + dlg->setAttribute(Qt::WA_DeleteOnClose); + QObject::connect(dlg, &geopro::app::ImportLocalRadarDialog::imported, &window, + [&nav, toast, &localRadarStore](const QString& tmId, + const std::vector& rows) { + localRadarStore.addDatasets(tmId, rows); + toast(QStringLiteral("雷达数据导入成功")); + nav.switchProject(nav.currentProjectId()); + }); + dlg->open(); } else if (action == QStringLiteral("showHide") || action == QStringLiteral("locate")) { toast(QStringLiteral("「%1」需要二维/三维视图,开发中").arg(name)); } else { @@ -2120,10 +2140,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re const QString dsName = item->data(0, geopro::app::kDsNameRole).toString(); // tmObjectId(白化 structParentId)从行读出透传,使白化模板列表非空。 const QString tmObjectId = item->data(0, geopro::app::kDsTmObjectIdRole).toString(); + const QString fileUrl = item->data(0, geopro::app::kDsFileUrlRole).toString(); QMenu menu(datasetList); menu.addAction(QStringLiteral("数据集详情"), datasetList, - [&detailCtrl, dsId, ddCode, dsName, tmObjectId]() { - detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId); + [&detailCtrl, dsId, ddCode, dsName, tmObjectId, fileUrl]() { + detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId, fileUrl); }); menu.addAction(QStringLiteral("属性"), datasetList, [&nav, dsId]() { nav.selectDataset(dsId); // 只读元字段 @@ -2288,16 +2309,22 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re datasetTabs->setTabText(1, QStringLiteral("文件")); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, - [removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs]( + [removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs, + &localRadarStore]( const QString& tmObjectId, const std::vector& rows, int total, bool append) { removeTreeLoadMore(datasetList); // tmObjectId(本批所属 TM 对象 id)存入每项 → 白化对话框透传用(structParentId)。 geopro::app::populateDatasetList(datasetList, rows, append, tmObjectId); - const int loaded = addTreeLoadMore(datasetList, total); + // 混入本地雷达数据集 + auto localRows = localRadarStore.datasetsFor(tmObjectId); + if (!localRows.empty()) { + geopro::app::populateDatasetList(datasetList, localRows, true, tmObjectId); + } + const int loaded = addTreeLoadMore(datasetList, total + static_cast(localRows.size())); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集")); datasetTabs->setTabText( - 0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total) + 0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total + static_cast(localRows.size())) : QStringLiteral("数据")); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::filesLoaded, fileList, diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index 76c7461..d2e4a13 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -201,6 +201,7 @@ QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d, const QString& tm item->setData(0, kDsTypeNameRole, QString::fromStdString(d.typeName)); item->setData(0, kDsCreateTimeRole, QString::fromStdString(d.createTime)); item->setData(0, kDsTmObjectIdRole, tmObjectId); // 所属 TM 对象 id(白化 structParentId) + item->setData(0, kDsFileUrlRole, QString::fromStdString(d.fileUrl)); // 文件 url(雷达等本地加载用) // 单击 tip:显示数据集主要属性(名称 / 类型 / 创建时间),对齐菜单文档「tip显示ds的主要属性」。 QString tip = QStringLiteral("名称:%1").arg(QString::fromStdString(d.dsName)); if (!d.typeName.empty()) tip += QStringLiteral("\n类型:%1").arg(QString::fromStdString(d.typeName)); diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index f260c03..7b8219a 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -272,6 +272,7 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { if (isTm) { // TM 节点:不提供任何「新建」(测线下不能新增对象)——仅「导入数据集」。 add(QStringLiteral("导入数据集…"), QStringLiteral("importDs")); + add(QStringLiteral("导入本地雷达数据…"), QStringLiteral("importLocalRadar")); } menu.addSeparator(); add(QStringLiteral("删除"), QStringLiteral("delete")); diff --git a/src/app/panels/chart/BScanProfileView.cpp b/src/app/panels/chart/BScanProfileView.cpp index af8f834..3d0264c 100644 --- a/src/app/panels/chart/BScanProfileView.cpp +++ b/src/app/panels/chart/BScanProfileView.cpp @@ -1,7 +1,10 @@ #include "panels/chart/BScanProfileView.hpp" +#include +#include +#include +#include #include -#include #include #include "model/detail/DetailPayloads.hpp" @@ -15,16 +18,119 @@ BScanProfileView::~BScanProfileView() = default; void BScanProfileView::setupUi() { auto* lay = new QVBoxLayout(this); - lay->addWidget(new QLabel(QStringLiteral("B-Scan Profile (placeholder)"), this)); - lay->addStretch(); + lay->setContentsMargins(8, 8, 8, 8); + + // 通道切换工具条 + auto* toolLay = new QHBoxLayout(); + toolLay->addStretch(); + channelCombo_ = new QComboBox(this); + channelCombo_->setVisible(false); + toolLay->addWidget(channelCombo_); + lay->addLayout(toolLay); + + infoLabel_ = new QLabel(this); + infoLabel_->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + lay->addWidget(infoLabel_); + + imageLabel_ = new QLabel(this); + imageLabel_->setAlignment(Qt::AlignCenter); + imageLabel_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + imageLabel_->setMinimumSize(200, 150); + lay->addWidget(imageLabel_, 1); + + QObject::connect(channelCombo_, QOverload::of(&QComboBox::currentIndexChanged), + this, [this](int idx) { + if (idx < 0 || idx >= static_cast(payload_.channels.size())) return; + payload_.currentChannel = idx; + updateImage(); + }); } void BScanProfileView::setPayload(const QVariant& payload) { if (!payload.canConvert()) return; - const auto p = payload.value(); + payload_ = payload.value(); hasData_ = true; - // Phase 2 壳子:仅记录数据,Phase 3/4 补渲染。 - Q_UNUSED(p) + + // 填充通道下拉框 + channelCombo_->clear(); + if (payload_.channels.size() > 1) { + for (const auto& ch : payload_.channels) { + channelCombo_->addItem(ch.channelName); + } + channelCombo_->setCurrentIndex( + (payload_.currentChannel >= 0 && + payload_.currentChannel < static_cast(payload_.channels.size())) + ? payload_.currentChannel + : 0); + channelCombo_->setVisible(true); + } else { + channelCombo_->setVisible(false); + } + + updateImage(); +} + +void BScanProfileView::updateImage() { + const auto& chs = payload_.channels; + if (chs.empty() || payload_.meta.ntraces <= 0 || payload_.meta.samples <= 0) { + infoLabel_->setText(QStringLiteral("暂无数据")); + imageLabel_->clear(); + return; + } + + const int ch = payload_.currentChannel; + const int useCh = (ch >= 0 && ch < static_cast(chs.size())) ? ch : 0; + const auto& cd = chs[static_cast(useCh)]; + const int ntraces = payload_.meta.ntraces; + const int samples = payload_.meta.samples; + const std::vector& data = cd.data; + if (static_cast(data.size()) < ntraces * samples) { + infoLabel_->setText(QStringLiteral("数据大小不匹配")); + imageLabel_->clear(); + return; + } + + // 元信息 + QString info = QStringLiteral("通道:%1 | 道数:%2 | 采样点数:%3 | 天线:%4 %5MHz") + .arg(cd.channelName) + .arg(ntraces) + .arg(samples) + .arg(payload_.antennaModel) + .arg(payload_.antennaFreqMHz); + if (!payload_.date.isEmpty()) info += QStringLiteral(" | 日期:%1").arg(payload_.date); + infoLabel_->setText(info); + + // 计算值域 + float vmin = data[0], vmax = data[0]; + for (float v : data) { + if (v < vmin) vmin = v; + if (v > vmax) vmax = v; + } + const float range = vmax - vmin; + + // 生成 B-scan 图像:宽度=ntraces,高度=samples,(t,s)=data[t*samples+s] + QImage img(ntraces, samples, QImage::Format_ARGB32); + for (int t = 0; t < ntraces; ++t) { + const int base = t * samples; + for (int s = 0; s < samples; ++s) { + float v = data[base + s]; + int g = 128; + if (range > 0.0f) { + g = static_cast(((v - vmin) / range) * 255.0f + 0.5f); + if (g < 0) g = 0; + if (g > 255) g = 255; + } + img.setPixelColor(t, s, QColor(g, g, g)); + } + } + + QPixmap pix = QPixmap::fromImage(img); + // 若图像较小,适度放大以提升可视性;最大不超过标签尺寸(保持纵横比)。 + const QSize labelSize = imageLabel_->size(); + if (pix.width() < labelSize.width() && pix.height() < labelSize.height()) { + pix = pix.scaled(labelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + imageLabel_->setPixmap(pix); } } // namespace geopro::app diff --git a/src/app/panels/chart/BScanProfileView.hpp b/src/app/panels/chart/BScanProfileView.hpp index 800f00e..9ea7133 100644 --- a/src/app/panels/chart/BScanProfileView.hpp +++ b/src/app/panels/chart/BScanProfileView.hpp @@ -1,12 +1,15 @@ #pragma once +#include #include +#include "model/detail/DetailPayloads.hpp" #include "panels/chart/IDetailView.hpp" +class QComboBox; + namespace geopro::app { // 雷达 B-Scan 剖面详情视图(IDetailView 实现)。 -// Phase 2 先放壳子:实现接口、解包 GprProfilePayload、显示通道数和基本信息。 -// Phase 3/4 补自绘 Canvas + A-Scan + 交互。 +// Phase 3:本地文件路径打通后,按真实 GprProfilePayload 渲染灰度 B-scan 图像。 class BScanProfileView : public QWidget, public IDetailView { Q_OBJECT public: @@ -18,8 +21,13 @@ public: private: void setupUi(); + void updateImage(); bool hasData_ = false; + geopro::core::GprProfilePayload payload_; + QLabel* infoLabel_ = nullptr; + QLabel* imageLabel_ = nullptr; + QComboBox* channelCombo_ = nullptr; }; } // namespace geopro::app diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index 347da44..bf0dc8a 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -19,7 +19,8 @@ DatasetDetailController::~DatasetDetailController() { } void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode, - const QString& dsName, const QString& tmObjectId) { + const QString& dsName, const QString& tmObjectId, + const QString& fileUrl) { qInfo("[detail] openDataset id=%s ddCode=%s name=%s tm=%s", qUtf8Printable(dsId), qUtf8Printable(ddCode), qUtf8Printable(dsName), qUtf8Printable(tmObjectId)); auto* s = registry_.find(ddCode.toStdString()); @@ -28,6 +29,7 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } + if (!fileUrl.isEmpty()) fileUrlByDsId_[dsId] = fileUrl; const std::vector tabs = s->tabs(); emit datasetOpened(dsId, ddCode, dsName, tmObjectId, tabs); for (int i = 0; i < static_cast(tabs.size()); ++i) @@ -53,9 +55,11 @@ void DatasetDetailController::loadTabImpl(const QString& dsId, const QString& dd // loadAsync 对未知 loaderKey 抛 std::runtime_error;若逃逸槽函数会被 GuardedApplication // 吞掉、遮罩永久悬挂(文档化的崩溃/挂起类)。就地兜底为 loadFailed,且不留半注册的在飞句柄。 + const QString fileUrl = fileUrlByDsId_.value(dsId); data::DetailLoad* load = nullptr; try { - load = repo_.loadAsync(spec.loaderKey.toStdString(), dsId.toStdString(), pageNo, pageSize); + load = repo_.loadAsync(spec.loaderKey.toStdString(), dsId.toStdString(), pageNo, pageSize, + fileUrl); } catch (const std::exception& e) { qWarning("[detail] loadAsync 失败 id=%s tab=%d key=%s: %s", qUtf8Printable(dsId), tabIndex, qUtf8Printable(spec.loaderKey), e.what()); diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index ea7b06b..e7eca17 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -23,7 +23,7 @@ public slots: // 打开数据集:查策略 → datasetOpened(页签集) → 对每个非 lazy 页签发起 loadTab。 // tmObjectId:数据集所属 TM 对象 id(=白化 structParentId),透传给详情页给白化对话框用;可空。 void openDataset(const QString& dsId, const QString& ddCode, const QString& dsName = QString(), - const QString& tmObjectId = QString()); + const QString& tmObjectId = QString(), const QString& fileUrl = QString()); // 加载某页签(lazy 页签首次激活时由壳触发;非 lazy 由 openDataset 自动触发)。 // 分页型页签(如 dd_grid 列表)首载用默认页(pageNo=1/pageSize=0 → 仓储解析默认每页条数)。 void loadTab(const QString& dsId, const QString& ddCode, int tabIndex); @@ -46,5 +46,6 @@ private: data::IAsyncDatasetRepository& repo_; ChartStrategyRegistry& registry_; QMap> inflight_; // 按页签槽位的在飞句柄(§5.0 身份比对) + QMap fileUrlByDsId_; // dsId → fileUrl(雷达等本地加载用) }; } // namespace geopro::controller diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 58d4560..51a24fe 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -19,7 +19,9 @@ add_library(geopro_data STATIC api/Api3dRepository.cpp api/DatasetLoadHandles.cpp api/NavRequest.cpp - GprVolumeRepository.cpp) + GprVolumeRepository.cpp + LocalRadarDatasetStore.cpp + LocalRadarScanner.cpp) target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) # geopro_gpr3dv_bridge:逐线 GPR 体(BuiltI16)来源,GprVolumeRepository 反量化为 VolumeGrid。 target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core geopro_gpr3dv_bridge PRIVATE nlohmann_json::nlohmann_json) diff --git a/src/data/LocalRadarDatasetStore.cpp b/src/data/LocalRadarDatasetStore.cpp new file mode 100644 index 0000000..9499659 --- /dev/null +++ b/src/data/LocalRadarDatasetStore.cpp @@ -0,0 +1,25 @@ +#include "LocalRadarDatasetStore.hpp" + +namespace geopro::data { + +void LocalRadarDatasetStore::addDatasets(const QString& tmObjectId, + const std::vector& rows) { + auto& vec = byTm_[tmObjectId]; + vec.insert(vec.end(), rows.begin(), rows.end()); +} + +std::vector LocalRadarDatasetStore::datasetsFor(const QString& tmObjectId) const { + auto it = byTm_.find(tmObjectId); + if (it == byTm_.end()) return {}; + return it.value(); +} + +void LocalRadarDatasetStore::clear() { + byTm_.clear(); +} + +void LocalRadarDatasetStore::clearTm(const QString& tmObjectId) { + byTm_.remove(tmObjectId); +} + +} // namespace geopro::data diff --git a/src/data/LocalRadarDatasetStore.hpp b/src/data/LocalRadarDatasetStore.hpp new file mode 100644 index 0000000..d91073c --- /dev/null +++ b/src/data/LocalRadarDatasetStore.hpp @@ -0,0 +1,22 @@ +#pragma once +#include +#include +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::data { + +// 本地雷达数据集存储:按所属 TM 分组管理已导入的本地雷达测线。 +// 生命周期随应用运行,关闭后不持久化(M1 测试定位)。 +class LocalRadarDatasetStore { +public: + void addDatasets(const QString& tmObjectId, const std::vector& rows); + std::vector datasetsFor(const QString& tmObjectId) const; + void clear(); + void clearTm(const QString& tmObjectId); + +private: + QMap> byTm_; +}; + +} // namespace geopro::data diff --git a/src/data/LocalRadarScanner.cpp b/src/data/LocalRadarScanner.cpp new file mode 100644 index 0000000..092e572 --- /dev/null +++ b/src/data/LocalRadarScanner.cpp @@ -0,0 +1,130 @@ +#include "LocalRadarScanner.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include "io/gpr/IprHeader.hpp" + +namespace geopro::data { + +QString LocalRadarScanner::surveyKeyFromFileName(const QString& baseName) { + const int lastUnderscore = baseName.lastIndexOf(QLatin1Char('_')); + if (lastUnderscore > 0) { + return baseName.left(lastUnderscore); + } + return baseName; +} + +std::vector LocalRadarScanner::scan(const QString& dirPath) { + std::vector out; + QDir dir(dirPath); + if (!dir.exists()) return out; + + // 收集所有 .data 文件 + QStringList dataFiles = dir.entryList(QStringList() << QStringLiteral("*.data"), + QDir::Files | QDir::NoDotAndDotDot, + QDir::Name); + + // 先按 NUMBER_OF_CH 分类:多通道单文件 vs 单通道多文件 + // key = surveyKey, value = list of data file base names + QMap modeBSurveys; // 模式 B:单通道,需要分组 + std::vector modeASurveys; // 模式 A:单文件多通道 + + for (const QString& dataFile : dataFiles) { + QFileInfo fi(dir.filePath(dataFile)); + const QString baseName = fi.completeBaseName(); + const QString headPath = dir.filePath(baseName + QStringLiteral(".head")); + if (!QFile::exists(headPath)) continue; + + try { + QFile headFile(headPath); + if (!headFile.open(QIODevice::ReadOnly | QIODevice::Text)) continue; + const std::string headText = headFile.readAll().toStdString(); + const io::gpr::IprHeader h = io::gpr::parseIprHeader(headText); + + if (h.numberOfCh > 1) { + // 模式 A:单文件多通道 + RadarSurveyInfo info; + info.surveyName = baseName; + info.ddCode = QStringLiteral("dd_radar_3d"); + info.channelCount = h.numberOfCh; + info.folderPath = dirPath; + info.dataFiles << fi.absoluteFilePath(); + info.meta = h; + modeASurveys.push_back(std::move(info)); + } else { + // 模式 B:单通道,先收集待分组 + const QString key = surveyKeyFromFileName(baseName); + modeBSurveys[key] << baseName; + } + } catch (...) { + // 解析失败,跳过 + continue; + } + } + + // 处理模式 A + out.insert(out.end(), std::make_move_iterator(modeASurveys.begin()), + std::make_move_iterator(modeASurveys.end())); + + // 处理模式 B:按 key 分组,同 key 的多个 .data 属于同一条测线 + for (auto it = modeBSurveys.begin(); it != modeBSurveys.end(); ++it) { + const QString& key = it.key(); + const QStringList& baseNames = it.value(); + if (baseNames.isEmpty()) continue; + + RadarSurveyInfo info; + info.surveyName = key; + info.folderPath = dirPath; + info.channelCount = baseNames.size(); + info.ddCode = (baseNames.size() == 1) + ? QStringLiteral("dd_radar_2d") + : QStringLiteral("dd_radar_3d"); + + // 读取第一个 .head 作为元信息 + const QString firstHeadPath = dir.filePath(baseNames.first() + QStringLiteral(".head")); + try { + QFile headFile(firstHeadPath); + if (headFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + info.meta = io::gpr::parseIprHeader(headFile.readAll().toStdString()); + } + } catch (...) { + } + + // 收集所有 .data 路径(按文件名排序) + for (const QString& bn : baseNames) { + info.dataFiles << dir.filePath(bn + QStringLiteral(".data")); + } + std::sort(info.dataFiles.begin(), info.dataFiles.end()); + + out.push_back(std::move(info)); + } + + return out; +} + +std::vector LocalRadarScanner::toDsRows(const std::vector& surveys) { + std::vector out; + out.reserve(surveys.size()); + for (const auto& s : surveys) { + DsRow row; + row.id = QStringLiteral("local-radar-%1") + .arg(QString::number(qHash(s.surveyName), 16).toStdString()) + .toStdString(); + row.dsName = s.surveyName.toStdString(); + row.ddCode = s.ddCode.toStdString(); + row.fileUrl = s.folderPath.toStdString(); + row.typeName = (s.channelCount == 1) ? "雷达2D" : "雷达3D"; + row.createTime = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")).toStdString(); + row.parentId.clear(); + // structParentId 由调用方注入 + out.push_back(std::move(row)); + } + return out; +} + +} // namespace geopro::data diff --git a/src/data/LocalRadarScanner.hpp b/src/data/LocalRadarScanner.hpp new file mode 100644 index 0000000..a3359e4 --- /dev/null +++ b/src/data/LocalRadarScanner.hpp @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include +#include "repo/RepoTypes.hpp" +#include "io/gpr/IprHeader.hpp" + +namespace geopro::data { + +// 扫描发现的雷达测线信息 +struct RadarSurveyInfo { + QString surveyName; // 测线名称(测线键) + QString ddCode; // "dd_radar_2d" 或 "dd_radar_3d" + int channelCount = 1; // 通道数 + QString folderPath; // 测线所在文件夹绝对路径 + QStringList dataFiles; // 该测线包含的 .data 文件路径列表 + io::gpr::IprHeader meta; // 从头文件解析的元信息 +}; + +// 本地雷达文件夹扫描器 +class LocalRadarScanner { +public: + // 扫描 dirPath 下所有雷达测线。 + // 规则: + // 1. 扫描所有 .data 文件,为每个 .data 找同名 .head。 + // 2. 读取 .head 获取 NUMBER_OF_CH。 + // 3. 若 NUMBER_OF_CH > 1:该 .data 自身就是一条多通道测线(模式 A)。 + // 4. 若 NUMBER_OF_CH <= 1:按去掉最后一段 _xxx 后缀分组(模式 B)。 + static std::vector scan(const QString& dirPath); + + // 将扫描结果转换为 DsRow(导入时由调用方注入 structParentId)。 + static std::vector toDsRows(const std::vector& surveys); + +private: + static QString surveyKeyFromFileName(const QString& baseName); +}; + +} // namespace geopro::data diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index 39b2022..8e1bcc4 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -1,6 +1,8 @@ #include "api/ApiDatasetRepository.hpp" #include +#include #include +#include #include #include #include @@ -161,7 +163,7 @@ net::ApiBatch* gridRowsBatch(net::ApiClient& api, const std::string& dsId, int p ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const std::string& dsId, - int pageNo, int pageSize) { + int pageNo, int pageSize, const QString& fileUrl) { if (loaderKey == "inversion.scatter") return makeInversionScatter(dsId); if (loaderKey == "inversion.grid") return makeInversionGrid(dsId); if (loaderKey == "ert_measurement.scatter") return makeMeasurementScatter(dsId); @@ -172,9 +174,9 @@ DetailLoad* ApiDatasetRepository::loadAsync(const std::string& loaderKey, const if (loaderKey == "traj.elev") return makeTrajectoryElevation(dsId); if (loaderKey == "traj.map") return makeTrajectoryMap(dsId); if (loaderKey == "grid.rows") return makeGridRows(dsId, pageNo, pageSize); - if (loaderKey == "radar.profile") return makeRadarProfile(dsId); - if (loaderKey == "radar.info") return makeRadarInfo(dsId); - if (loaderKey == "radar.anomalies") return makeRadarAnomalies(dsId); + if (loaderKey == "radar.profile") return makeRadarProfile(dsId, fileUrl); + if (loaderKey == "radar.info") return makeRadarInfo(dsId, fileUrl); + if (loaderKey == "radar.anomalies") return makeRadarAnomalies(dsId, fileUrl); throw std::runtime_error("unknown loaderKey: " + loaderKey); } @@ -253,11 +255,12 @@ DetailLoad* ApiDatasetRepository::makeGridRows(const std::string& dsId, int page } // ── 雷达数据集:本地文件加载(零网络请求)。 ── -// M1 阶段:路径未注册时返回空 payload,视图层显示占位提示。 -// 后续 app 层注入 radarPaths_ 映射后自动读取。 namespace { +using geopro::io::gpr::IprHeader; +using geopro::io::gpr::BScan; + // 读取文本文件全部内容(UTF-8)。 std::string readTextFile(const QString& path) { QFile f(path); @@ -267,26 +270,25 @@ std::string readTextFile(const QString& path) { return f.readAll().toStdString(); } -// 从 .iprh + .iprb 组装 GprProfilePayload(单通道 2d)。 -geopro::core::GprProfilePayload buildRadar2dPayload(const QString& iprhPath, - const QString& iprbPath) { +// 从单个 .head + .data 组装单通道 GprProfilePayload(兼容旧 .iprh/.iprb)。 +geopro::core::GprProfilePayload buildRadar2dPayload(const QString& headPath, + const QString& dataPath) { using namespace geopro::io::gpr; - const std::string headerText = readTextFile(iprhPath); + const std::string headerText = readTextFile(headPath); const IprHeader h = parseIprHeader(headerText); - const BScan bscan = readIprb(iprbPath.toStdString(), h); + const BScan bscan = readIprb(dataPath.toStdString(), h); geopro::core::GprProfilePayload payload; payload.meta.ntraces = static_cast(bscan.traces); payload.meta.samples = bscan.samples; payload.meta.dx = h.distanceInterval; payload.meta.dz = h.timeWindowNs / (h.samples > 1 ? h.samples - 1 : 1); - payload.meta.velocityMPerNs = h.soilVelocity / 1e9; // m/s → m/ns + payload.meta.velocityMPerNs = h.soilVelocity / 1e9; payload.date = QString::fromStdString(h.date); payload.time = QString::fromStdString(h.startTime); payload.antennaModel = QString::fromStdString(h.antennas); payload.antennaFreqMHz = h.frequency; - // int16 → float,行主序。 geopro::core::GprChannelData ch; ch.channelName = QStringLiteral("CH01"); ch.data.reserve(bscan.data.size()); @@ -296,27 +298,212 @@ geopro::core::GprProfilePayload buildRadar2dPayload(const QString& iprhPath, return payload; } +// 扫描文件夹,返回所有 .data 文件路径(同时兼容 .iprb)。 +QStringList findDataFilesInFolder(const QString& folderPath) { + QDir dir(folderPath); + QStringList filters; + filters << QStringLiteral("*.data") << QStringLiteral("*.iprb"); + return dir.entryList(filters, QDir::Files | QDir::NoDotAndDotDot, QDir::Name); +} + +// 为 .data 找同名 .head(或 .iprh)。 +QString findHeadPath(const QString& dataPath) { + QFileInfo fi(dataPath); + const QString base = fi.path() + QStringLiteral("/") + fi.completeBaseName(); + QString headPath = base + QStringLiteral(".head"); + if (QFile::exists(headPath)) return headPath; + headPath = base + QStringLiteral(".iprh"); + if (QFile::exists(headPath)) return headPath; + return QString(); +} + +// 从测线文件夹组装 GprProfilePayload(支持模式 A 单文件多通道 + 模式 B 多文件单通道)。 +geopro::core::GprProfilePayload buildRadarPayloadFromFolder(const QString& folderPath) { + using namespace geopro::io::gpr; + geopro::core::GprProfilePayload payload; + + const QStringList dataFiles = findDataFilesInFolder(folderPath); + if (dataFiles.isEmpty()) { + throw std::runtime_error("测线文件夹内无 .data 或 .iprb 文件: " + folderPath.toStdString()); + } + + // 收集所有 .data 及其 .head + struct ChannelFile { + QString dataPath; + QString headPath; + IprHeader header; + }; + std::vector files; + for (const QString& name : dataFiles) { + const QString dataPath = folderPath + QStringLiteral("/") + name; + const QString headPath = findHeadPath(dataPath); + if (headPath.isEmpty()) continue; + try { + IprHeader h = parseIprHeader(readTextFile(headPath)); + files.push_back({dataPath, headPath, h}); + } catch (...) { + continue; + } + } + if (files.empty()) { + throw std::runtime_error("测线文件夹内无有效头文件: " + folderPath.toStdString()); + } + + // 取第一个头作为公共元信息 + const IprHeader& firstH = files.front().header; + payload.meta.samples = firstH.samples; + payload.meta.dx = firstH.distanceInterval; + payload.meta.dz = firstH.timeWindowNs / (firstH.samples > 1 ? firstH.samples - 1 : 1); + payload.meta.velocityMPerNs = firstH.soilVelocity / 1e9; + payload.date = QString::fromStdString(firstH.date); + payload.time = QString::fromStdString(firstH.startTime); + payload.antennaModel = QString::fromStdString(firstH.antennas); + payload.antennaFreqMHz = firstH.frequency; + + // 模式 A:第一个 .data 的 NUMBER_OF_CH > 1,单文件多通道交错 + if (firstH.numberOfCh > 1) { + std::vector bscans = readMultiChannelData(files.front().dataPath.toStdString(), firstH); + payload.meta.ntraces = static_cast(bscans.front().traces); + for (std::size_t c = 0; c < bscans.size(); ++c) { + geopro::core::GprChannelData ch; + ch.channelName = QStringLiteral("CH%1").arg(c + 1, 2, 10, QLatin1Char('0')); + ch.data.reserve(bscans[c].data.size()); + for (int16_t v : bscans[c].data) ch.data.push_back(static_cast(v)); + payload.channels.push_back(std::move(ch)); + } + return payload; + } + + // 模式 B:多文件单通道 + payload.meta.ntraces = static_cast(files.front().header.lastTrace); + for (std::size_t c = 0; c < files.size(); ++c) { + const BScan bscan = readIprb(files[c].dataPath.toStdString(), files[c].header); + geopro::core::GprChannelData ch; + ch.channelName = QStringLiteral("CH%1").arg(c + 1, 2, 10, QLatin1Char('0')); + ch.data.reserve(bscan.data.size()); + for (int16_t v : bscan.data) ch.data.push_back(static_cast(v)); + payload.channels.push_back(std::move(ch)); + } + return payload; +} + +// 从 IprHeader 构建采集参数 TablePayload +geopro::core::TablePayload buildRadarInfoPayload(const IprHeader& h) { + geopro::core::TablePayload payload; + payload.columns = { + {QStringLiteral("field"), QStringLiteral("参数名"), 120, 0, geopro::core::TableColumnKind::Text}, + {QStringLiteral("value"), QStringLiteral("值"), 200, 0, geopro::core::TableColumnKind::Text}, + }; + + auto addRow = [&](const QString& name, const QString& value) { + payload.rows.push_back({name, value}); + }; + + addRow(QStringLiteral("采样点数"), QString::number(h.samples)); + addRow(QStringLiteral("道数"), QString::number(h.lastTrace)); + addRow(QStringLiteral("通道数"), QString::number(h.numberOfCh)); + addRow(QStringLiteral("时窗(ns)"), QString::number(h.timeWindowNs)); + addRow(QStringLiteral("波速(m/s)"), QString::number(h.soilVelocity)); + addRow(QStringLiteral("道间距(m)"), QString::number(h.distanceInterval)); + addRow(QStringLiteral("日期"), QString::fromStdString(h.date)); + addRow(QStringLiteral("开始时间"), QString::fromStdString(h.startTime)); + addRow(QStringLiteral("结束时间"), QString::fromStdString(h.stopTime)); + addRow(QStringLiteral("频率(MHz)"), QString::number(h.frequency)); + addRow(QStringLiteral("叠加次数"), QString::number(h.stacks)); + addRow(QStringLiteral("天线型号"), QString::fromStdString(h.antennas)); + addRow(QStringLiteral("采集模式"), QString::fromStdString(h.mode)); + addRow(QStringLiteral("深度(m)"), QString::number(h.depth)); + addRow(QStringLiteral("介电常数"), QString::number(h.dielectric)); + addRow(QStringLiteral("土壤类型"), QString::fromStdString(h.soilType)); + addRow(QStringLiteral("单位"), QString::fromStdString(h.units)); + addRow(QStringLiteral("Bits"), QString::number(h.bits)); + + return payload; +} + } // namespace -DetailLoad* ApiDatasetRepository::makeRadarProfile(const std::string& dsId) { - // M1:路径未配置时返回空 payload(视图显示占位)。 - // TODO: 从 app 层注入的 radarPaths_ 映射读取真实路径。 +DetailLoad* ApiDatasetRepository::makeRadarProfile(const std::string& dsId, + const QString& fileUrl) { Q_UNUSED(dsId) - geopro::core::GprProfilePayload payload; - return new LocalDetailLoad(QVariant::fromValue(payload)); + if (fileUrl.isEmpty()) { + geopro::core::GprProfilePayload payload; + return new LocalDetailLoad(QVariant::fromValue(payload)); + } + QString path = fileUrl; + if (path.startsWith(QStringLiteral("file://"))) path = path.mid(7); + if (path.startsWith(QStringLiteral("http://")) || + path.startsWith(QStringLiteral("https://"))) { + qWarning("[radar] 远程文件URL暂不支持直接加载: %s", qUtf8Printable(fileUrl)); + geopro::core::GprProfilePayload payload; + return new LocalDetailLoad(QVariant::fromValue(payload)); + } + + QFileInfo fi(path); + try { + geopro::core::GprProfilePayload payload; + if (fi.isDir()) { + // 测线文件夹模式 + payload = buildRadarPayloadFromFolder(path); + } else if (fi.suffix().compare(QStringLiteral("iprb"), Qt::CaseInsensitive) == 0 || + fi.suffix().compare(QStringLiteral("data"), Qt::CaseInsensitive) == 0) { + // 单文件模式:尝试找同名头文件 + QString headPath = findHeadPath(path); + if (!headPath.isEmpty()) { + payload = buildRadar2dPayload(headPath, path); + } else { + qWarning("[radar] 找不到头文件: %s", qUtf8Printable(path)); + } + } else { + // 默认尝试按文件夹处理(fileUrl 可能是文件夹路径但 QFileInfo 判断不准) + payload = buildRadarPayloadFromFolder(path); + } + return new LocalDetailLoad(QVariant::fromValue(payload)); + } catch (const std::exception& e) { + qWarning("[radar] 加载失败: %s", e.what()); + geopro::core::GprProfilePayload payload; + return new LocalDetailLoad(QVariant::fromValue(payload)); + } } -DetailLoad* ApiDatasetRepository::makeRadarInfo(const std::string& dsId) { +DetailLoad* ApiDatasetRepository::makeRadarInfo(const std::string& dsId, + const QString& fileUrl) { Q_UNUSED(dsId) - // 采集参数页签:TablePayload,从 .iprh 解析的字段。 - // M1 先返回空表格占位。 geopro::core::TablePayload payload; + if (fileUrl.isEmpty()) { + return new LocalDetailLoad(QVariant::fromValue(payload)); + } + QString path = fileUrl; + if (path.startsWith(QStringLiteral("file://"))) path = path.mid(7); + + try { + QFileInfo fi(path); + QString headPath; + if (fi.isDir()) { + // 找文件夹内第一个 .head 或 .iprh + QDir dir(path); + QStringList heads = dir.entryList(QStringList() << QStringLiteral("*.head") + << QStringLiteral("*.iprh"), + QDir::Files); + if (!heads.isEmpty()) headPath = dir.filePath(heads.first()); + } else { + headPath = findHeadPath(path); + } + if (!headPath.isEmpty()) { + IprHeader h = geopro::io::gpr::parseIprHeader(readTextFile(headPath)); + payload = buildRadarInfoPayload(h); + } + } catch (const std::exception& e) { + qWarning("[radar] 采集参数加载失败: %s", e.what()); + } return new LocalDetailLoad(QVariant::fromValue(payload)); } -DetailLoad* ApiDatasetRepository::makeRadarAnomalies(const std::string& dsId) { +DetailLoad* ApiDatasetRepository::makeRadarAnomalies(const std::string& dsId, + const QString& fileUrl) { Q_UNUSED(dsId) - // 异常列表页签:空列表占位。 + Q_UNUSED(fileUrl) + // 异常列表页签:TODO 解析 .mrk 文件 geopro::core::TablePayload payload; return new LocalDetailLoad(QVariant::fromValue(payload)); } diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp index e805c24..49cb3f5 100644 --- a/src/data/api/ApiDatasetRepository.hpp +++ b/src/data/api/ApiDatasetRepository.hpp @@ -1,5 +1,6 @@ #pragma once #include "repo/IAsyncDatasetRepository.hpp" +#include namespace geopro::net { class ApiClient; } namespace geopro::data { @@ -8,7 +9,8 @@ class ApiDatasetRepository : public IAsyncDatasetRepository { public: explicit ApiDatasetRepository(net::ApiClient& api); DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId, - int pageNo = 1, int pageSize = 0) override; + int pageNo = 1, int pageSize = 0, + const QString& fileUrl = QString()) override; private: DetailLoad* makeInversionScatter(const std::string& dsId); DetailLoad* makeInversionGrid(const std::string& dsId); @@ -22,9 +24,9 @@ private: DetailLoad* makeGridRows(const std::string& dsId, int pageNo, int pageSize); // 雷达数据集:本地文件加载(零网络)。 - DetailLoad* makeRadarProfile(const std::string& dsId); - DetailLoad* makeRadarInfo(const std::string& dsId); - DetailLoad* makeRadarAnomalies(const std::string& dsId); + DetailLoad* makeRadarProfile(const std::string& dsId, const QString& fileUrl); + DetailLoad* makeRadarInfo(const std::string& dsId, const QString& fileUrl); + DetailLoad* makeRadarAnomalies(const std::string& dsId, const QString& fileUrl); net::ApiClient& api_; }; diff --git a/src/data/repo/IAsyncDatasetRepository.hpp b/src/data/repo/IAsyncDatasetRepository.hpp index 0f8c6bd..b2ff743 100644 --- a/src/data/repo/IAsyncDatasetRepository.hpp +++ b/src/data/repo/IAsyncDatasetRepository.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include namespace geopro::data { @@ -13,7 +14,8 @@ public: // pageNo/pageSize 仅分页型 loaderKey(如 grid.rows)使用;其余 loaderKey 忽略。 // 默认 pageNo=1/pageSize=0 → 非分页加载(分页 loaderKey 自行解析默认每页条数)。 virtual DetailLoad* loadAsync(const std::string& loaderKey, const std::string& dsId, - int pageNo = 1, int pageSize = 0) = 0; + int pageNo = 1, int pageSize = 0, + const QString& fileUrl = QString()) = 0; }; } // namespace geopro::data diff --git a/src/io/gpr/IprbReader.cpp b/src/io/gpr/IprbReader.cpp index 2f6c76d..6137e74 100644 --- a/src/io/gpr/IprbReader.cpp +++ b/src/io/gpr/IprbReader.cpp @@ -89,4 +89,61 @@ BScan readIprbRange(const std::string& path, const IprHeader& h, return b; } +std::vector readMultiChannelData(const std::string& path, const IprHeader& h) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) { + throw std::runtime_error("readMultiChannelData: 无法打开文件: " + path); + } + + const std::int64_t fileBytes = static_cast(f.tellg()); + const std::int64_t samples = static_cast(h.samples); + const int channels = h.numberOfCh > 1 ? h.numberOfCh : 1; + if (samples <= 0) { + throw std::runtime_error("readMultiChannelData: samples 非法(<=0): " + path); + } + const std::int64_t bytesPerBlock = samples * 2; // int16 + if (fileBytes % bytesPerBlock != 0) { + throw std::runtime_error( + "readMultiChannelData: 文件大小不是 samples*2 的整数倍: " + path); + } + const std::int64_t totalBlocks = fileBytes / bytesPerBlock; + if (totalBlocks % channels != 0) { + throw std::runtime_error( + "readMultiChannelData: 总块数不能被通道数整除: " + path); + } + const std::int64_t tracesPerChannel = totalBlocks / channels; + + // 预分配所有通道 + std::vector out(channels); + for (int c = 0; c < channels; ++c) { + out[c].samples = h.samples; + out[c].traces = tracesPerChannel; + out[c].data.resize(static_cast(samples * tracesPerChannel)); + } + + // 读取全部数据 + std::vector buffer(static_cast(totalBlocks * samples)); + f.seekg(0, std::ios::beg); + f.read(reinterpret_cast(buffer.data()), + static_cast(fileBytes)); + if (!f) { + throw std::runtime_error("readMultiChannelData: 读取数据失败: " + path); + } + + // 按交错布局分发到各通道 + // globalBlockIdx = t * channels + c 对应 通道 c 的 trace t + for (std::int64_t t = 0; t < tracesPerChannel; ++t) { + for (int c = 0; c < channels; ++c) { + const std::int64_t globalBlockIdx = t * channels + c; + const std::size_t srcOffset = static_cast(globalBlockIdx * samples); + const std::size_t dstOffset = static_cast(t * samples); + std::copy(buffer.begin() + srcOffset, + buffer.begin() + srcOffset + static_cast(samples), + out[c].data.begin() + dstOffset); + } + } + + return out; +} + } // namespace geopro::io::gpr diff --git a/src/io/gpr/IprbReader.hpp b/src/io/gpr/IprbReader.hpp index 84989ae..3540d6a 100644 --- a/src/io/gpr/IprbReader.hpp +++ b/src/io/gpr/IprbReader.hpp @@ -29,6 +29,12 @@ BScan readIprb(const std::string& path, const IprHeader& h); BScan readIprbRange(const std::string& path, const IprHeader& h, std::int64_t t0, std::int64_t t1); +// 读取多通道交错 .data 文件。 +// 文件布局:[ch0_t0采样][ch1_t0采样]...[chM_t0采样][ch0_t1采样]... +// totalBlocks = fileBytes / (samples*2); tracesPerChannel = totalBlocks / channels; +// 返回 std::vector,每个元素对应一个通道。 +std::vector readMultiChannelData(const std::string& path, const IprHeader& h); + } // namespace geopro::io::gpr #endif // GEOPRO_IO_GPR_IPRBREADER_HPP diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index cd68f5d..685cacb 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -48,7 +48,7 @@ struct StubAsyncRepo : data::IAsyncDatasetRepository { StubDetailLoad* last = nullptr; int lastPageNo = 0, lastPageSize = 0; // 记录最近一次分页参数(验证 loadTabPaged 透传) data::DetailLoad* loadAsync(const std::string&, const std::string&, int pageNo, - int pageSize) override { + int pageSize, const QString&) override { lastPageNo = pageNo; lastPageSize = pageSize; last = new StubDetailLoad; diff --git a/tests/data/test_3d_repo.cpp b/tests/data/test_3d_repo.cpp index 2785fa8..3367e49 100644 --- a/tests/data/test_3d_repo.cpp +++ b/tests/data/test_3d_repo.cpp @@ -110,7 +110,8 @@ TEST(LocalSample3dRepo, LoadTerrainPathsCallsBack) { namespace { // 极简桩:volumeInfo/createVolume 不触碰 dsRepo_,loadAsync 直接回空。 struct StubAsyncRepo : IAsyncDatasetRepository { - DetailLoad* loadAsync(const std::string&, const std::string&, int, int) override { + DetailLoad* loadAsync(const std::string&, const std::string&, int, int, + const QString&) override { return nullptr; } }; diff --git a/tests/io/gpr/test_ipr_header_extended.cpp b/tests/io/gpr/test_ipr_header_extended.cpp index c0997e4..a03c727 100644 --- a/tests/io/gpr/test_ipr_header_extended.cpp +++ b/tests/io/gpr/test_ipr_header_extended.cpp @@ -85,7 +85,7 @@ TEST(IprHeaderExtended, missingOptionalFieldsAllowed) { const IprHeader h = parseIprHeader(text); EXPECT_EQ(h.samples, 100); - EXPECT_TRUE(h.date.isEmpty()); + EXPECT_TRUE(h.date.empty()); EXPECT_DOUBLE_EQ(h.frequency, 0.0); }