geopro/src/app/panels/chart/AutoAnnotationDialog.cpp

504 lines
22 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "panels/chart/AutoAnnotationDialog.hpp"
#include <algorithm>
#include <cmath>
#include <utility>
#include <QButtonGroup>
#include <QComboBox>
#include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QTableWidget>
#include <QToolButton>
#include <QVBoxLayout>
#include <qwt_plot.h>
#include "FormKit.hpp"
#include "Theme.hpp" // scaledPx
#include "dto/DatasetChartDto.hpp" // parseDatasetAnomaliesJSON→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<double>& raw) {
std::vector<double> 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<double>(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);
// ── 右:上(统计条 + 预览图) + 下预览表 ──────────────────────────────────
// 对照原版右上 <ContourPreview>:等值面预览图为主、数据统计在上。
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) {
// 复刻原版 <ContourPreview>:用 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<ColorMapService>(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<AutoAnnotationDialog> 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<int>(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<AutoAnnotationDialog> 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<AutoAnnotationDialog> 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<AutoAnnotationDialog> 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<AutoAnnotationDialog> 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