添加模型分割

This commit is contained in:
2026-04-08 14:37:01 +08:00
parent 088dd91e27
commit a79c31a056
17 changed files with 1327 additions and 183 deletions

View File

@@ -1,10 +1,13 @@
#include "main_window/MainWindow.h"
#include "dialogs/AboutWindow.h"
#include "dialogs/CancelableTaskDialog.h"
#include "dialogs/EntityFinalizeDialog.h"
#include "editor/EditorCanvas.h"
#include "editor/EntityCutoutUtils.h"
#include "dialogs/ImageCropDialog.h"
#include "core/domain/EntityIntro.h"
#include "core/net/ModelServerClient.h"
#include "widgets/ToolOptionPopup.h"
#include "params/ParamControls.h"
#include "props/BackgroundPropertySection.h"
#include "props/EntityPropertySection.h"
@@ -55,8 +58,13 @@
#include <QStyle>
#include <QUrl>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QSet>
#include <QJsonObject>
#include <QJsonArray>
#include <QSet>
@@ -524,6 +532,24 @@ void MainWindow::createTimelineDock() {
});
}
void MainWindow::syncCreateEntityToolButtonTooltip() {
if (!m_btnCreateEntity || !m_editorCanvas) {
return;
}
using Mode = EditorCanvas::EntityCreateSegmentMode;
const Mode m = m_editorCanvas->entityCreateSegmentMode();
if (m == Mode::Manual) {
m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:手动分割\n"
"再次单击本按钮可选择:手动/吸附/模型"));
} else if (m == Mode::Snap) {
m_btnCreateEntity->setToolTip(QStringLiteral("创建实体:吸附分割(前端边缘吸附算法)\n"
"再次单击本按钮可选择:手动/吸附/模型"));
} else {
m_btnCreateEntity->setToolTip(QStringLiteral("创建实体模型分割SAM\n"
"再次单击本按钮可选择:手动/吸附/模型"));
}
}
void MainWindow::updateStatusBarText() {
// 坐标系:图片左上角为 (0,0)单位为像素world 坐标与背景像素一致)
if (m_hasSelectedEntity) {
@@ -1734,6 +1760,30 @@ void MainWindow::rebuildCentralPages() {
polishCompactToolButton(m_btnCreateEntity, 40);
toolLayout->addWidget(m_btnCreateEntity, 0, Qt::AlignHCenter);
group->addButton(m_btnCreateEntity, static_cast<int>(EditorCanvas::Tool::CreateEntity));
if (!m_createEntityPopup) {
m_createEntityPopup = new ToolOptionPopup(this);
m_createEntityPopup->setOptions({
{static_cast<int>(EditorCanvas::EntityCreateSegmentMode::Manual), QStringLiteral("手动分割")},
{static_cast<int>(EditorCanvas::EntityCreateSegmentMode::Snap), QStringLiteral("吸附分割")},
{static_cast<int>(EditorCanvas::EntityCreateSegmentMode::Sam), QStringLiteral("模型分割")},
});
connect(m_createEntityPopup, &ToolOptionPopup::optionChosen, this, [this](int id) {
if (!m_editorCanvas) return;
m_editorCanvas->setEntityCreateSegmentMode(static_cast<EditorCanvas::EntityCreateSegmentMode>(id));
syncCreateEntityToolButtonTooltip();
statusBar()->showMessage(QStringLiteral("已切换分割方式"));
});
}
connect(m_btnCreateEntity, &QToolButton::clicked, this, [this]() {
if (!m_editorCanvas || !m_btnCreateEntity) return;
if (m_btnCreateEntity->isChecked() && m_editorCanvas->tool() == EditorCanvas::Tool::CreateEntity) {
// 已选中时再次单击:弹出选择面板
if (m_createEntityPopup) {
m_createEntityPopup->popupNearToolButton(m_btnCreateEntity);
}
}
});
syncCreateEntityToolButtonTooltip();
m_btnToggleDepthOverlay = new QToolButton(m_floatingToolDock);
m_btnToggleDepthOverlay->setCheckable(true);
@@ -1845,6 +1895,288 @@ void MainWindow::rebuildCentralPages() {
refreshProjectTree();
updateUiEnabledState();
});
connect(m_editorCanvas, &EditorCanvas::requestSamSegment, this,
[this](const QByteArray& cropRgbPng,
const QByteArray& overlayPng,
const QPointF& cropTopLeftWorld,
const QJsonArray& pointCoords,
const QJsonArray& pointLabels,
const QJsonArray& boxXyxy) {
if (!m_workspace.isOpen() || !m_workspace.hasBackground()) {
QMessageBox::warning(this, QStringLiteral("实体"), QStringLiteral("请先打开项目并设置背景。"));
return;
}
QString base;
const QByteArray env = qgetenv("MODEL_SERVER_URL");
base = env.isEmpty() ? QStringLiteral("http://127.0.0.1:8000") : QString::fromUtf8(env);
auto* client = new core::ModelServerClient(this);
client->setBaseUrl(QUrl(base));
QString immediateErr;
QNetworkReply* reply = client->segmentSamPromptAsync(
cropRgbPng, overlayPng, pointCoords, pointLabels, boxXyxy, &immediateErr);
if (!reply) {
QMessageBox::warning(this,
QStringLiteral("SAM 分割"),
immediateErr.isEmpty() ? QStringLiteral("无法发起后端请求。") : immediateErr);
client->deleteLater();
return;
}
auto* dlg = new CancelableTaskDialog(QStringLiteral("SAM 分割"),
QStringLiteral("正在请求后端进行分割,请稍候……"),
this);
dlg->setAttribute(Qt::WA_DeleteOnClose, true);
connect(dlg, &CancelableTaskDialog::canceled, this, [reply, dlg]() {
if (reply) {
reply->abort();
}
if (dlg) {
dlg->reject();
}
});
connect(reply, &QNetworkReply::finished, this, [this, reply, dlg, client, cropTopLeftWorld]() {
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray raw = reply->readAll();
const auto netErr = reply->error();
const QString netErrStr = reply->errorString();
reply->deleteLater();
client->deleteLater();
if (dlg) {
dlg->close();
}
if (netErr != QNetworkReply::NoError) {
if (netErrStr.contains(QStringLiteral("canceled"), Qt::CaseInsensitive) ||
netErr == QNetworkReply::OperationCanceledError) {
statusBar()->showMessage(QStringLiteral("已取消分割"));
return;
}
QMessageBox::warning(this, QStringLiteral("SAM 分割"),
QStringLiteral("网络错误:%1").arg(netErrStr));
return;
}
if (httpStatus != 200) {
QString detail;
const QJsonDocument jerr = QJsonDocument::fromJson(raw);
if (jerr.isObject()) {
detail = jerr.object().value(QStringLiteral("detail")).toString();
}
QMessageBox::warning(
this,
QStringLiteral("SAM 分割"),
detail.isEmpty() ? QStringLiteral("后端返回 HTTP %1。").arg(httpStatus)
: QStringLiteral("后端错误HTTP %1%2").arg(httpStatus).arg(detail));
return;
}
const QJsonDocument jd = QJsonDocument::fromJson(raw);
if (!jd.isObject()) {
QMessageBox::warning(this, QStringLiteral("SAM 分割"), QStringLiteral("响应不是 JSON。"));
return;
}
const QJsonObject obj = jd.object();
if (!obj.value(QStringLiteral("success")).toBool()) {
const QString err = obj.value(QStringLiteral("error")).toString();
QMessageBox::warning(this, QStringLiteral("SAM 分割"),
err.isEmpty() ? QStringLiteral("分割失败。") : err);
return;
}
const QJsonArray contour = obj.value(QStringLiteral("contour")).toArray();
if (contour.size() < 3) {
QMessageBox::warning(this, QStringLiteral("SAM 分割"), QStringLiteral("轮廓点数不足。"));
return;
}
QVector<QPointF> polyWorld;
polyWorld.reserve(contour.size());
for (const QJsonValue& v : contour) {
if (!v.isArray()) {
continue;
}
const QJsonArray p = v.toArray();
if (p.size() < 2) {
continue;
}
const double x = p.at(0).toDouble();
const double y = p.at(1).toDouble();
polyWorld.append(cropTopLeftWorld + QPointF(x, y));
}
if (polyWorld.size() < 3) {
QMessageBox::warning(this, QStringLiteral("SAM 分割"), QStringLiteral("无效轮廓数据。"));
return;
}
core::Project::Entity ent;
ent.id.clear();
ent.cutoutPolygonWorld = polyWorld;
ent.originWorld = entity_cutout::polygonCentroid(ent.cutoutPolygonWorld);
ent.polygonLocal.clear();
ent.polygonLocal.reserve(ent.cutoutPolygonWorld.size());
for (const auto& pt : ent.cutoutPolygonWorld) {
ent.polygonLocal.push_back(pt - ent.originWorld);
}
QImage depth8;
if (m_workspace.hasDepth()) {
const QString dpath = m_workspace.depthAbsolutePath();
if (!dpath.isEmpty() && QFileInfo::exists(dpath)) {
const QImage dimg(dpath);
if (!dimg.isNull()) {
depth8 = dimg.convertToFormat(QImage::Format_Grayscale8);
}
}
}
const QPointF c = entity_cutout::polygonCentroid(ent.cutoutPolygonWorld);
int z = 0;
if (!depth8.isNull()) {
const int xi = static_cast<int>(std::floor(c.x()));
const int yi = static_cast<int>(std::floor(c.y()));
if (xi >= 0 && yi >= 0 && xi < depth8.width() && yi < depth8.height()) {
z = static_cast<int>(static_cast<const uchar*>(depth8.constScanLine(yi))[xi]);
}
}
ent.depth = z;
{
const double ds01 = static_cast<double>(std::clamp(z, 0, 255)) / 255.0;
ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0;
}
const QString bgAbs = m_workspace.backgroundAbsolutePath();
QImage bg(bgAbs);
if (!bg.isNull() && bg.format() != QImage::Format_ARGB32_Premultiplied) {
bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
QImage cutout;
if (!bg.isNull()) {
QPointF topLeft;
cutout = entity_cutout::extractEntityImage(bg, ent.cutoutPolygonWorld, topLeft);
ent.imageTopLeftWorld = topLeft;
}
QSet<QString> used;
for (const auto& ex : m_workspace.entities()) {
used.insert(ex.id);
}
int n = static_cast<int>(m_workspace.entities().size()) + 1;
for (int guard = 0; guard < 100000; ++guard, ++n) {
const QString cand = QStringLiteral("entity-%1").arg(n);
if (!used.contains(cand)) {
ent.id = cand;
break;
}
}
// 不直接落盘:进入待确认(可微调)
m_editorCanvas->setPendingEntityPolygonWorld(polyWorld);
statusBar()->showMessage(QStringLiteral("分割完成:可拖动顶点微调,回车/点击空白确认"));
});
dlg->show();
});
connect(m_editorCanvas, &EditorCanvas::requestFinalizePendingEntity, this, [this](const QVector<QPointF>& polyWorld) {
if (!m_workspace.isOpen() || !m_workspace.hasBackground()) {
return;
}
if (polyWorld.size() < 3) {
return;
}
// 自动深度
int z = 0;
if (m_workspace.hasDepth()) {
const QString dpath = m_workspace.depthAbsolutePath();
QImage depth8(dpath);
if (!depth8.isNull()) {
depth8 = depth8.convertToFormat(QImage::Format_Grayscale8);
const QPointF c = entity_cutout::polygonCentroid(polyWorld);
const int xi = static_cast<int>(std::floor(c.x()));
const int yi = static_cast<int>(std::floor(c.y()));
if (xi >= 0 && yi >= 0 && xi < depth8.width() && yi < depth8.height()) {
z = static_cast<int>(static_cast<const uchar*>(depth8.constScanLine(yi))[xi]);
}
}
}
// 生成稳定且不重复的 identity-<n>,同时作为“默认名称”
QString newId;
{
QSet<QString> used;
for (const auto& ex : m_workspace.entities()) {
used.insert(ex.id);
}
int n = static_cast<int>(m_workspace.entities().size()) + 1;
for (int guard = 0; guard < 100000; ++guard, ++n) {
const QString cand = QStringLiteral("entity-%1").arg(n);
if (!used.contains(cand)) {
newId = cand;
break;
}
}
}
EntityFinalizeDialog dlg(this);
dlg.setDefaultName(newId.isEmpty() ? QStringLiteral("entity-1") : newId);
dlg.setUserScale(1.0);
if (dlg.exec() != QDialog::Accepted) {
return;
}
core::Project::Entity ent;
ent.id = newId;
ent.displayName = dlg.name();
ent.cutoutPolygonWorld = polyWorld;
ent.originWorld = entity_cutout::polygonCentroid(ent.cutoutPolygonWorld);
ent.polygonLocal.clear();
ent.polygonLocal.reserve(ent.cutoutPolygonWorld.size());
for (const auto& pt : ent.cutoutPolygonWorld) {
ent.polygonLocal.push_back(pt - ent.originWorld);
}
ent.depth = std::clamp(z, 0, 255);
ent.userScale = std::max(1e-6, dlg.userScale());
{
const double ds01 = static_cast<double>(ent.depth) / 255.0;
ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0;
}
// 若用户把名称清空,则 displayName 置空UI 会回退显示 id保持原习惯
if (ent.displayName == ent.id) {
// 默认情况保留 displayName=id便于树上直接显示 entity-x
}
if (ent.displayName.isEmpty()) {
// 允许空:界面会用 id 展示
}
QImage bg(m_workspace.backgroundAbsolutePath());
if (!bg.isNull() && bg.format() != QImage::Format_ARGB32_Premultiplied) {
bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
QImage cutout;
if (!bg.isNull()) {
QPointF topLeft;
cutout = entity_cutout::extractEntityImage(bg, ent.cutoutPolygonWorld, topLeft);
ent.imageTopLeftWorld = topLeft;
}
if (!m_workspace.addEntity(ent, cutout)) {
QMessageBox::warning(this, QStringLiteral("实体"), QStringLiteral("保存实体失败。"));
return;
}
if (m_editorCanvas) {
m_editorCanvas->clearPendingEntityPolygon();
}
statusBar()->showMessage(QStringLiteral("实体已创建"));
refreshEditorPage();
refreshProjectTree();
updateUiEnabledState();
});
connect(m_editorCanvas, &EditorCanvas::requestMoveEntity, this, [this](const QString& id, const QPointF& delta) {
// 动画编辑:拖动即写入当前位置关键帧,避免被既有关键帧插值“拉回去”
const bool autoKey = true;
@@ -2128,7 +2460,7 @@ void MainWindow::refreshDopeSheet() {
void MainWindow::showBackgroundContextMenu(const QPoint& globalPos) {
QMenu menu(this);
QAction* actComputeDepth = menu.addAction(QStringLiteral("计算深度(后端)"));
QAction* actComputeDepth = menu.addAction(QStringLiteral("计算深度"));
actComputeDepth->setEnabled(m_workspace.isOpen() && m_workspace.hasBackground());
QAction* chosen = menu.exec(globalPos);

View File

@@ -9,6 +9,10 @@
#include <QIcon>
#include <QTimer>
#include <QToolButton>
class ToolOptionPopup;
class QAction;
class QCheckBox;
class QComboBox;
@@ -112,6 +116,7 @@ private:
gui::BackgroundPropertySection* m_bgPropertySection = nullptr;
gui::EntityPropertySection* m_entityPropertySection = nullptr;
QToolButton* m_btnCreateEntity = nullptr;
ToolOptionPopup* m_createEntityPopup = nullptr;
QToolButton* m_btnToggleDepthOverlay = nullptr;
EditorCanvas* m_editorCanvas = nullptr;
@@ -155,6 +160,7 @@ private:
QString m_bgAbsCache;
QString m_bgSizeTextCache;
void updateStatusBarText();
void syncCreateEntityToolButtonTooltip();
void refreshPropertyPanel();
void refreshEntityPropertyPanelFast();
void syncProjectTreeFromCanvasSelection();