#include "panels/chart/AutoAnnotationDialog.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "FormKit.hpp" #include "Theme.hpp" // scaledPx #include "dto/DatasetChartDto.hpp" // parseDatasetAnomalies(JSON→Anomaly) #include "panels/chart/ColorMapService.hpp" #include "panels/chart/ContourPlotItem.hpp" #include "repo/IDatasetCommandRepository.hpp" namespace geopro::app { namespace { constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4 // 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon)。 const QString kPolygonType = QStringLiteral("3"); // 从网格标量算 max/min/mean/median(过滤 NaN)。空 → 全 '-'。 struct Stats { bool valid = false; double mx = 0, mn = 0, mean = 0, median = 0; }; Stats computeStats(const std::vector& raw) { std::vector v; v.reserve(raw.size()); for (double d : raw) if (!std::isnan(d)) v.push_back(d); if (v.empty()) return {}; std::sort(v.begin(), v.end()); Stats s; s.valid = true; s.mn = v.front(); s.mx = v.back(); double sum = 0; for (double d : v) sum += d; s.mean = sum / static_cast(v.size()); const size_t m = v.size() / 2; s.median = (v.size() % 2 == 0) ? (v[m - 1] + v[m]) / 2.0 : v[m]; return s; } QString fmt2(bool valid, double x) { return valid ? QString::number(x, 'f', 2) : QStringLiteral("-"); } } // namespace AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, QString projectId, const geopro::core::Grid& grid, const geopro::core::ColorScale& scale, QWidget* parent) : QDialog(parent), repo_(repo), dsObjectId_(std::move(dsObjectId)), projectId_(std::move(projectId)), grid_(grid), scale_(scale) { setWindowTitle(QStringLiteral("自动标注")); setModal(true); resize(geopro::app::scaledPx(1400), geopro::app::scaledPx(600)); auto* root = formkit::dialogRoot(this); auto* split = new QHBoxLayout(); // ── 左:规则卡片列表(35%,对照原版左栏)──────────────────────────────── auto* leftCol = new QVBoxLayout(); leftCol->addWidget(new QLabel(QStringLiteral("异常判定规则"), this)); auto* ruleContainer = new QWidget(this); ruleHost_ = new QVBoxLayout(ruleContainer); ruleHost_->setContentsMargins(0, 0, 0, 0); leftCol->addWidget(ruleContainer); auto* addBtn = new QPushButton(QStringLiteral("添加规则"), this); connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); }); leftCol->addWidget(addBtn); leftCol->addStretch(); auto* leftWrap = new QWidget(this); leftWrap->setLayout(leftCol); split->addWidget(leftWrap, 35); // ── 右:上(统计条 + 预览图) + 下预览表 ────────────────────────────────── // 对照原版右上 :等值面预览图为主、数据统计在上。 auto* rightCol = new QVBoxLayout(); buildStatsBar(rightCol); buildPreviewPlot(rightCol); detectedLabel_ = new QLabel(QStringLiteral("自动标注结果"), this); rightCol->addWidget(detectedLabel_); previewTable_ = new QTableWidget(0, 6, this); previewTable_->setHorizontalHeaderLabels({QStringLiteral("序号"), QStringLiteral("异常名称"), QStringLiteral("异常类型"), QStringLiteral("阈值范围"), QStringLiteral("阈值模式"), QStringLiteral("操作")}); previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); rightCol->addWidget(previewTable_, 1); auto* rightWrap = new QWidget(this); rightWrap->setLayout(rightCol); split->addWidget(rightWrap, 65); root->addLayout(split, 1); // ── 底部按钮:取消 / 执行自动标注 / 确认保存 ───────────────────────────── auto* btnLay = new QHBoxLayout(); btnLay->addStretch(); 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); btnLay->addWidget(saveBtn_); root->addLayout(btnLay); connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); connect(execBtn, &QPushButton::clicked, this, &AutoAnnotationDialog::onExecute); connect(saveBtn_, &QPushButton::clicked, this, &AutoAnnotationDialog::onSave); addRule(); // 默认一条规则 loadExceptionTypes(); // 拉面异常类型 } AutoAnnotationDialog::~AutoAnnotationDialog() { // previewItem_ 由 QwtPlot autoDelete=true 处理,但析构顺序不确定:先 detach 再交还。 if (previewItem_) { previewItem_->detach(); delete previewItem_; previewItem_ = nullptr; } // colorSvc_ 为 unique_ptr,自动释放。 } void AutoAnnotationDialog::buildStatsBar(QVBoxLayout* into) { // 数据统计(max/min/mean/median,从网格标量算)。 const Stats s = computeStats(grid_.values()); auto* bar = new QFrame(this); bar->setFrameShape(QFrame::StyledPanel); auto* lay = new QHBoxLayout(bar); auto addStat = [&](const QString& name, double v) { lay->addWidget(new QLabel(QStringLiteral("%1:%2").arg(name, fmt2(s.valid, v)), bar)); }; addStat(QStringLiteral("最大值"), s.mx); addStat(QStringLiteral("最小值"), s.mn); addStat(QStringLiteral("均值"), s.mean); addStat(QStringLiteral("中位数"), s.median); lay->addStretch(); into->addWidget(bar); } void AutoAnnotationDialog::buildPreviewPlot(QVBoxLayout* into) { // 复刻原版 :用 GridDataChartView 同款 ContourPlotItem 渲染当前网格等值面, // 预览图为主区域;执行/删除时把预演异常实时叠加(refreshPreviewAnomalies)。 previewPlot_ = new QwtPlot(this); previewPlot_->setObjectName(QStringLiteral("autoAnnotPreview")); previewPlot_->enableAxis(QwtPlot::xBottom, true); previewPlot_->enableAxis(QwtPlot::xTop, false); previewPlot_->enableAxis(QwtPlot::yLeft, true); previewPlot_->setMinimumHeight(geopro::app::scaledPx(220)); // 网格 < 2×2 视为无可渲染数据:仅占位,不建等值面项。 if (grid_.nx() < 2 || grid_.ny() < 2) { into->addWidget(previewPlot_, 1); return; } colorSvc_ = std::make_unique(scale_); previewItem_ = new ContourPlotItem(); // 轻量预览:渲染等值面 + 等值线,关标注(小图标注过密);初始无异常。 previewItem_->setData(grid_, colorSvc_.get(), {}, /*showLines=*/true, /*showLabels=*/false); previewItem_->setShowAnomalies(true); previewItem_->attach(previewPlot_); // 轴范围 = 数据范围(y 深度向下:上沿 ymax、下沿 ymin,与 GridDataChartView 一致)。 const QRectF bbox = previewItem_->boundingRect(); if (!bbox.isNull()) { previewPlot_->setAxisScale(QwtPlot::xBottom, bbox.left(), bbox.right()); previewPlot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom()); } previewPlot_->replot(); into->addWidget(previewPlot_, 1); } void AutoAnnotationDialog::refreshPreviewAnomalies() { // 把当前 previewExceptions_(execute 返回 / 删除后剩余)映射成 Anomaly 叠加到预览图。 // 复用 dto::parseDatasetAnomalies(与正式异常同一 JSON 形态:location.coordinate + legend)。 if (!previewItem_ || !colorSvc_) return; const auto anoms = geopro::data::dto::parseDatasetAnomalies(previewExceptions_); previewItem_->setData(grid_, colorSvc_.get(), anoms, /*showLines=*/true, /*showLabels=*/false); previewItem_->setShowAnomalies(true); if (previewPlot_) previewPlot_->replot(); } void AutoAnnotationDialog::loadExceptionTypes() { if (!repo_) return; QPointer self(this); repo_->listExceptionTypes(projectId_, kPolygonType, [self](bool ok, QJsonArray list, QString) { if (!self || !ok) return; self->exceptionTypeOptions_ = {}; for (const QJsonValue& v : list) { const QJsonObject o = v.toObject(); QString name = o.value(QStringLiteral("exceptionTypeName")).toString(); if (name.isEmpty()) name = o.value(QStringLiteral("label")).toString(); QString id = o.value(QStringLiteral("id")).toString(); if (id.isEmpty()) id = o.value(QStringLiteral("value")).toString(); if (!id.isEmpty()) self->exceptionTypeOptions_.append( QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); } // 回填已存在规则卡片下拉。 for (auto& r : self->rules_) { r.type->clear(); for (const QJsonValue& ov : self->exceptionTypeOptions_) { const QJsonObject o = ov.toObject(); r.type->addItem(o.value(QStringLiteral("name")).toString(), o.value(QStringLiteral("id")).toString()); } } }); } void AutoAnnotationDialog::addRule() { auto* card = new QFrame(this); card->setFrameShape(QFrame::StyledPanel); auto* cardLay = new QVBoxLayout(card); cardLay->setContentsMargins(6, 6, 6, 6); RuleCard rc; rc.frame = card; // 卡片头:折叠 + 「规则N」 + 删除。 auto* header = new QHBoxLayout(); auto* collapseBtn = new QToolButton(card); collapseBtn->setText(QStringLiteral("▼")); collapseBtn->setCheckable(true); rc.title = new QLabel(card); auto* delBtn = new QToolButton(card); delBtn->setText(QStringLiteral("删除")); header->addWidget(collapseBtn); header->addWidget(rc.title, 1); header->addWidget(delBtn); cardLay->addLayout(header); // 卡片主体(可折叠)。 rc.body = new QWidget(card); auto* form = formkit::makeEditForm(); // 阈值模式:radio-button 组(数值/百分位)。 auto* modeRow = new QWidget(rc.body); auto* modeLay = new QHBoxLayout(modeRow); modeLay->setContentsMargins(0, 0, 0, 0); auto* rbNum = new QRadioButton(QStringLiteral("数值"), modeRow); auto* rbPct = new QRadioButton(QStringLiteral("百分位"), modeRow); rbNum->setChecked(true); rc.modeGroup = new QButtonGroup(rc.body); rc.modeGroup->addButton(rbNum, 1); rc.modeGroup->addButton(rbPct, 2); modeLay->addWidget(rbNum); modeLay->addWidget(rbPct); modeLay->addStretch(); form->addRow(formkit::editLabel(QStringLiteral("阈值模式")), modeRow); // 阈值范围:min - max。 auto* rangeRow = new QWidget(rc.body); auto* rangeLay = new QHBoxLayout(rangeRow); rangeLay->setContentsMargins(0, 0, 0, 0); rc.min = new QLineEdit(rangeRow); rc.min->setPlaceholderText(QStringLiteral("最小")); rc.max = new QLineEdit(rangeRow); rc.max->setPlaceholderText(QStringLiteral("最大")); rangeLay->addWidget(rc.min); rangeLay->addWidget(new QLabel(QStringLiteral("-"), rangeRow)); rangeLay->addWidget(rc.max); form->addRow(formkit::editLabel(QStringLiteral("阈值范围")), rangeRow); // 切模式清空 min/max(对照原版 handleThresholdModeChange)。 auto clearRange = [min = rc.min, max = rc.max]() { min->clear(); max->clear(); }; connect(rbNum, &QRadioButton::toggled, rc.body, [clearRange](bool on) { if (on) clearRange(); }); connect(rbPct, &QRadioButton::toggled, rc.body, [clearRange](bool on) { if (on) clearRange(); }); rc.minPoints = new QSpinBox(rc.body); rc.minPoints->setRange(1, 100000); rc.minPoints->setValue(kDefaultMinPoints); form->addRow(formkit::editLabel(QStringLiteral("最小点数")), rc.minPoints); // 空态感知下拉:异常类型异步加载(loadExceptionTypes),未选显占位、无数据弹「暂无数据」。 rc.type = formkit::comboBox(QStringLiteral("请选择异常类型"), rc.body); for (const QJsonValue& ov : exceptionTypeOptions_) { const QJsonObject o = ov.toObject(); rc.type->addItem(o.value(QStringLiteral("name")).toString(), o.value(QStringLiteral("id")).toString()); } form->addRow(formkit::editLabel(QStringLiteral("异常类型")), rc.type); rc.body->setLayout(form); cardLay->addWidget(rc.body); // 折叠/展开。 connect(collapseBtn, &QToolButton::toggled, rc.body, [rc, collapseBtn](bool collapsed) { rc.body->setVisible(!collapsed); collapseBtn->setText(collapsed ? QStringLiteral("▶") : QStringLiteral("▼")); }); // 删除(至少保留一条)。 connect(delBtn, &QToolButton::clicked, this, [this, card]() { removeRule(card); }); rules_.push_back(rc); ruleHost_->addWidget(card); renumberRules(); } void AutoAnnotationDialog::removeRule(QWidget* frame) { if (rules_.size() <= 1) { // 对照原版 atLeastOneRule。 QMessageBox::warning(this, windowTitle(), QStringLiteral("至少保留一条规则")); return; } auto it = std::find_if(rules_.begin(), rules_.end(), [frame](const RuleCard& c) { return c.frame == frame; }); if (it == rules_.end()) return; ruleHost_->removeWidget(frame); frame->deleteLater(); rules_.erase(it); renumberRules(); } void AutoAnnotationDialog::renumberRules() { for (int i = 0; i < static_cast(rules_.size()); ++i) rules_[i].title->setText(QStringLiteral("规则 %1").arg(i + 1)); } int AutoAnnotationDialog::currentMode(const RuleCard& c) const { const int id = c.modeGroup->checkedId(); return id > 0 ? id : 1; // 默认数值 } QJsonArray AutoAnnotationDialog::buildRuleList() const { QJsonArray arr; for (const auto& r : rules_) { QJsonObject rule{ {QStringLiteral("exceptionTypeId"), r.type->currentData().toString()}, {QStringLiteral("thresholdMode"), currentMode(r)}, {QStringLiteral("minPointCount"), r.minPoints->value()}, }; // min/max:空 → null(对照原版 Number(...) 或 null)。 const QString mn = r.min->text().trimmed(); const QString mx = r.max->text().trimmed(); rule.insert(QStringLiteral("thresholdMin"), mn.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(mn.toDouble())); rule.insert(QStringLiteral("thresholdMax"), mx.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(mx.toDouble())); arr.append(rule); } return arr; } void AutoAnnotationDialog::onExecute() { if (!repo_) return; // 校验:每条规则 min/max 至少填一个、异常类型必选(对照原版 handleExecute)。 for (const auto& r : rules_) { if (r.min->text().trimmed().isEmpty() && r.max->text().trimmed().isEmpty()) { QMessageBox::warning(this, windowTitle(), QStringLiteral("阈值范围至少填写一项")); return; } if (r.type->currentData().toString().isEmpty()) { QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型")); return; } } const QJsonArray rules = buildRuleList(); QJsonObject body{ {QStringLiteral("dsObjectId"), dsObjectId_}, {QStringLiteral("projectId"), projectId_}, {QStringLiteral("exceptionMarkRuleList"), rules}, }; QPointer self(this); repo_->executeExceptionMark(body, [self](bool ok, QJsonObject data, QString msg) { if (!self) return; if (!ok) { QMessageBox::warning(self, self->windowTitle(), msg.isEmpty() ? QStringLiteral("自动标注执行失败") : msg); return; } // 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。 QJsonArray list = data.value(QStringLiteral("value")).toArray(); if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray(); self->previewExceptions_ = list; self->detectedLabel_->setText( QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(list.size())); self->previewTable_->setRowCount(list.size()); for (int i = 0; i < list.size(); ++i) { const QJsonObject o = list[i].toObject(); self->previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1))); self->previewTable_->setItem( i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); self->previewTable_->setItem( i, 2, new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString())); self->previewTable_->setItem( i, 3, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); self->previewTable_->setItem( i, 4, new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString())); // 操作列:逐条删除(对照原版预览表 删除)。 auto* delBtn = new QPushButton(QStringLiteral("删除"), self->previewTable_); QPointer weak(self); QObject::connect(delBtn, &QPushButton::clicked, self, [weak, i]() { if (weak) weak->deletePreviewRow(i); }); self->previewTable_->setCellWidget(i, 5, delBtn); } self->saveBtn_->setEnabled(!list.isEmpty()); self->refreshPreviewAnomalies(); // 实时叠加预演异常到预览图 if (list.isEmpty()) QMessageBox::information(self, self->windowTitle(), QStringLiteral("暂未识别到异常")); }); } void AutoAnnotationDialog::deletePreviewRow(int row) { if (row < 0 || row >= previewExceptions_.size()) return; const QString name = previewExceptions_[row].toObject().value(QStringLiteral("exceptionName")).toString(); if (QMessageBox::question(this, QStringLiteral("确认删除"), QStringLiteral("%1,确认删除?").arg(name)) != QMessageBox::Yes) return; previewExceptions_.removeAt(row); // 重建预览表(重排序号 + 重绑删除)。复用 onExecute 的填表分支会重发请求,故就地重建。 previewTable_->setRowCount(previewExceptions_.size()); for (int i = 0; i < previewExceptions_.size(); ++i) { const QJsonObject o = previewExceptions_[i].toObject(); previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1))); previewTable_->setItem( i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); previewTable_->setItem( i, 2, new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString())); previewTable_->setItem(i, 3, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); previewTable_->setItem( i, 4, new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString())); auto* delBtn = new QPushButton(QStringLiteral("删除"), previewTable_); QPointer weak(this); connect(delBtn, &QPushButton::clicked, this, [weak, i]() { if (weak) weak->deletePreviewRow(i); }); previewTable_->setCellWidget(i, 5, delBtn); } detectedLabel_->setText( QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(previewExceptions_.size())); saveBtn_->setEnabled(!previewExceptions_.isEmpty()); refreshPreviewAnomalies(); // 同步从预览图移除该异常 } void AutoAnnotationDialog::onSave() { if (!repo_ || previewExceptions_.isEmpty()) { QMessageBox::warning(this, windowTitle(), QStringLiteral("暂无可保存的异常,请先执行自动标注")); return; } // 组装 exceptionList:保留 execute 返回项的关键字段(对照原版 batchCreateException)。 QJsonArray exceptionList; for (const QJsonValue& v : previewExceptions_) { const QJsonObject o = v.toObject(); exceptionList.append(QJsonObject{ {QStringLiteral("remarkSourceType"), o.value(QStringLiteral("exceptionMarkType"))}, {QStringLiteral("exceptionName"), o.value(QStringLiteral("exceptionName"))}, {QStringLiteral("exceptionTypeId"), o.value(QStringLiteral("exceptionTypeId"))}, {QStringLiteral("location"), o.value(QStringLiteral("location"))}, {QStringLiteral("remark"), o.value(QStringLiteral("remark"))}, }); } QJsonObject body{ {QStringLiteral("projectId"), projectId_}, {QStringLiteral("remarkSourceId"), dsObjectId_}, {QStringLiteral("exceptionList"), exceptionList}, }; saveBtn_->setEnabled(false); QPointer self(this); repo_->batchCreateException(body, [self](bool ok, QString msg) { if (!self) return; self->saveBtn_->setEnabled(true); if (ok) { self->accept(); } else { QMessageBox::warning(self, self->windowTitle(), msg.isEmpty() ? QStringLiteral("保存失败") : msg); } }); } } // namespace geopro::app