From a79c31a05637d82ba1bff638c4586c96eea92a0e Mon Sep 17 00:00:00 2001 From: DingVero Date: Wed, 8 Apr 2026 14:37:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=A8=A1=E5=9E=8B=E5=88=86?= =?UTF-8?q?=E5=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/core/net/ModelServerClient.cpp | 38 ++ client/core/net/ModelServerClient.h | 10 + client/gui/CMakeLists.txt | 8 + client/gui/dialogs/EntityFinalizeDialog.cpp | 56 ++ client/gui/dialogs/EntityFinalizeDialog.h | 24 + client/gui/editor/EditorCanvas.cpp | 501 +++++++++++------- client/gui/editor/EditorCanvas.h | 33 ++ client/gui/editor/EntityCutoutUtils.cpp | 110 ++++ client/gui/editor/EntityCutoutUtils.h | 18 + client/gui/main_window/MainWindow.cpp | 334 +++++++++++- client/gui/main_window/MainWindow.h | 6 + .../gui/widgets/LongPressSwitchToolButton.cpp | 52 ++ .../gui/widgets/LongPressSwitchToolButton.h | 32 ++ client/gui/widgets/ToolOptionPopup.cpp | 55 ++ client/gui/widgets/ToolOptionPopup.h | 32 ++ python_server/model/Seg/seg_loader.py | 123 ++++- python_server/server.py | 78 ++- 17 files changed, 1327 insertions(+), 183 deletions(-) create mode 100644 client/gui/dialogs/EntityFinalizeDialog.cpp create mode 100644 client/gui/dialogs/EntityFinalizeDialog.h create mode 100644 client/gui/editor/EntityCutoutUtils.cpp create mode 100644 client/gui/editor/EntityCutoutUtils.h create mode 100644 client/gui/widgets/LongPressSwitchToolButton.cpp create mode 100644 client/gui/widgets/LongPressSwitchToolButton.h create mode 100644 client/gui/widgets/ToolOptionPopup.cpp create mode 100644 client/gui/widgets/ToolOptionPopup.h diff --git a/client/core/net/ModelServerClient.cpp b/client/core/net/ModelServerClient.cpp index 0955196..e765f76 100644 --- a/client/core/net/ModelServerClient.cpp +++ b/client/core/net/ModelServerClient.cpp @@ -1,6 +1,7 @@ #include "net/ModelServerClient.h" #include +#include #include #include #include @@ -49,6 +50,43 @@ QNetworkReply* ModelServerClient::computeDepthPng8Async(const QByteArray& imageB return m_nam->post(req, body); } +QNetworkReply* ModelServerClient::segmentSamPromptAsync( + const QByteArray& cropRgbPngBytes, + const QByteArray& overlayPngBytes, + const QJsonArray& pointCoords, + const QJsonArray& pointLabels, + const QJsonArray& boxXyxy, + QString* outImmediateError +) { + if (outImmediateError) { + outImmediateError->clear(); + } + if (!m_baseUrl.isValid() || m_baseUrl.isEmpty()) { + if (outImmediateError) *outImmediateError = QStringLiteral("后端地址无效。"); + return nullptr; + } + if (cropRgbPngBytes.isEmpty()) { + if (outImmediateError) *outImmediateError = QStringLiteral("裁剪图像为空。"); + return nullptr; + } + + const QUrl url = m_baseUrl.resolved(QUrl(QStringLiteral("/segment/sam_prompt"))); + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + + QJsonObject payload; + payload.insert(QStringLiteral("image_b64"), QString::fromLatin1(cropRgbPngBytes.toBase64())); + if (!overlayPngBytes.isEmpty()) { + payload.insert(QStringLiteral("overlay_b64"), QString::fromLatin1(overlayPngBytes.toBase64())); + } + payload.insert(QStringLiteral("point_coords"), pointCoords); + payload.insert(QStringLiteral("point_labels"), pointLabels); + payload.insert(QStringLiteral("box_xyxy"), boxXyxy); + + const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); + return m_nam->post(req, body); +} + bool ModelServerClient::computeDepthPng8( const QByteArray& imageBytes, QByteArray& outPngBytes, diff --git a/client/core/net/ModelServerClient.h b/client/core/net/ModelServerClient.h index a52b14c..5705ee0 100644 --- a/client/core/net/ModelServerClient.h +++ b/client/core/net/ModelServerClient.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -27,6 +28,15 @@ public: // 返回 nullptr 表示参数/URL 非法导致无法发起。 QNetworkReply* computeDepthPng8Async(const QByteArray& imageBytes, QString* outImmediateError = nullptr); + // POST /segment/sam_prompt,JSON 响应由调用方解析(success / contour / error)。 + QNetworkReply* segmentSamPromptAsync( + const QByteArray& cropRgbPngBytes, + const QByteArray& overlayPngBytes, + const QJsonArray& pointCoords, + const QJsonArray& pointLabels, + const QJsonArray& boxXyxy, + QString* outImmediateError = nullptr); + private: QNetworkAccessManager* m_nam = nullptr; QUrl m_baseUrl; diff --git a/client/gui/CMakeLists.txt b/client/gui/CMakeLists.txt index f7bc514..a9d4c44 100644 --- a/client/gui/CMakeLists.txt +++ b/client/gui/CMakeLists.txt @@ -4,13 +4,17 @@ set(GUI_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) set(GUI_SOURCES ${GUI_ROOT}/app/main.cpp ${GUI_ROOT}/main_window/MainWindow.cpp + ${GUI_ROOT}/widgets/LongPressSwitchToolButton.cpp + ${GUI_ROOT}/widgets/ToolOptionPopup.cpp ${GUI_ROOT}/main_window/RecentProjectHistory.cpp ${GUI_ROOT}/dialogs/AboutWindow.cpp ${GUI_ROOT}/dialogs/ImageCropDialog.cpp ${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp ${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp + ${GUI_ROOT}/dialogs/EntityFinalizeDialog.cpp ${GUI_ROOT}/dialogs/EntityIntroPopup.cpp ${GUI_ROOT}/editor/EditorCanvas.cpp + ${GUI_ROOT}/editor/EntityCutoutUtils.cpp ${GUI_ROOT}/params/ParamControls.cpp ${GUI_ROOT}/props/BackgroundPropertySection.cpp ${GUI_ROOT}/props/EntityPropertySection.cpp @@ -19,13 +23,17 @@ set(GUI_SOURCES set(GUI_HEADERS ${GUI_ROOT}/main_window/MainWindow.h + ${GUI_ROOT}/widgets/LongPressSwitchToolButton.h + ${GUI_ROOT}/widgets/ToolOptionPopup.h ${GUI_ROOT}/main_window/RecentProjectHistory.h ${GUI_ROOT}/dialogs/AboutWindow.h ${GUI_ROOT}/dialogs/ImageCropDialog.h ${GUI_ROOT}/dialogs/FrameAnimationDialog.h ${GUI_ROOT}/dialogs/CancelableTaskDialog.h + ${GUI_ROOT}/dialogs/EntityFinalizeDialog.h ${GUI_ROOT}/dialogs/EntityIntroPopup.h ${GUI_ROOT}/editor/EditorCanvas.h + ${GUI_ROOT}/editor/EntityCutoutUtils.h ${GUI_ROOT}/params/ParamControls.h ${GUI_ROOT}/props/BackgroundPropertySection.h ${GUI_ROOT}/props/EntityPropertySection.h diff --git a/client/gui/dialogs/EntityFinalizeDialog.cpp b/client/gui/dialogs/EntityFinalizeDialog.cpp new file mode 100644 index 0000000..baef2a5 --- /dev/null +++ b/client/gui/dialogs/EntityFinalizeDialog.cpp @@ -0,0 +1,56 @@ +#include "dialogs/EntityFinalizeDialog.h" + +#include +#include +#include +#include +#include + +EntityFinalizeDialog::EntityFinalizeDialog(QWidget* parent) + : QDialog(parent) { + setWindowTitle(QStringLiteral("确认实体")); + setModal(true); + + auto* root = new QVBoxLayout(this); + auto* form = new QFormLayout(); + + m_name = new QLineEdit(this); + m_name->setPlaceholderText(QStringLiteral("例如:entity-1、人物、树、建筑…")); + form->addRow(QStringLiteral("名称"), m_name); + + m_userScale = new QDoubleSpinBox(this); + m_userScale->setDecimals(3); + m_userScale->setRange(0.01, 50.0); + m_userScale->setSingleStep(0.05); + form->addRow(QStringLiteral("整体缩放"), m_userScale); + + root->addLayout(form); + + auto* btns = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject); + root->addWidget(btns); +} + +void EntityFinalizeDialog::setDefaultName(const QString& name) { + if (m_name) { + m_name->setText(name); + m_name->selectAll(); + m_name->setFocus(); + } +} + +QString EntityFinalizeDialog::name() const { + return m_name ? m_name->text().trimmed() : QString(); +} + +void EntityFinalizeDialog::setUserScale(double s) { + if (m_userScale) { + m_userScale->setValue(s); + } +} + +double EntityFinalizeDialog::userScale() const { + return m_userScale ? m_userScale->value() : 1.0; +} + diff --git a/client/gui/dialogs/EntityFinalizeDialog.h b/client/gui/dialogs/EntityFinalizeDialog.h new file mode 100644 index 0000000..b738115 --- /dev/null +++ b/client/gui/dialogs/EntityFinalizeDialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +class QLineEdit; +class QDoubleSpinBox; + +/// 实体落盘前的确认对话框:命名 + 常用属性调整。 +class EntityFinalizeDialog final : public QDialog { + Q_OBJECT +public: + explicit EntityFinalizeDialog(QWidget* parent = nullptr); + + void setDefaultName(const QString& name); + QString name() const; + + void setUserScale(double s); + double userScale() const; + +private: + QLineEdit* m_name = nullptr; + QDoubleSpinBox* m_userScale = nullptr; +}; + diff --git a/client/gui/editor/EditorCanvas.cpp b/client/gui/editor/EditorCanvas.cpp index cc115e6..5e9ddad 100644 --- a/client/gui/editor/EditorCanvas.cpp +++ b/client/gui/editor/EditorCanvas.cpp @@ -1,10 +1,12 @@ #include "editor/EditorCanvas.h" +#include "editor/EntityCutoutUtils.h" #include "core/animation/AnimationSampling.h" #include "core/depth/DepthService.h" #include #include +#include #include #include #include @@ -14,9 +16,167 @@ #include #include #include +#include +#include +#include +#include namespace { +constexpr int kSamCropMargin = 32; +constexpr int kMinStrokePointsSam = 4; +constexpr int kMinStrokePointsManual = 8; +constexpr int kMaxSamPointPrompts = 32; + +QVector snapStrokeToEdges(const QVector& strokeWorld, const QImage& bgImage, int searchRadiusPx) { + if (strokeWorld.size() < 3 || bgImage.isNull()) { + return strokeWorld; + } + QImage gray = bgImage.convertToFormat(QImage::Format_Grayscale8); + const int w = gray.width(); + const int h = gray.height(); + auto at = [&](int x, int y) -> int { + x = std::clamp(x, 0, w - 1); + y = std::clamp(y, 0, h - 1); + return static_cast(static_cast(gray.constScanLine(y))[x]); + }; + auto gradMag = [&](int x, int y) -> int { + // 简易 Sobel 近似(整数) + const int gx = + -at(x - 1, y - 1) + at(x + 1, y - 1) + + -2 * at(x - 1, y) + 2 * at(x + 1, y) + + -at(x - 1, y + 1) + at(x + 1, y + 1); + const int gy = + -at(x - 1, y - 1) - 2 * at(x, y - 1) - at(x + 1, y - 1) + + at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1); + return std::abs(gx) + std::abs(gy); + }; + QVector out; + out.reserve(strokeWorld.size()); + const int r = std::max(1, searchRadiusPx); + for (const QPointF& p : strokeWorld) { + const int cx = static_cast(std::round(p.x())); + const int cy = static_cast(std::round(p.y())); + int bestX = cx; + int bestY = cy; + int bestG = -1; + for (int dy = -r; dy <= r; ++dy) { + for (int dx = -r; dx <= r; ++dx) { + const int x = cx + dx; + const int y = cy + dy; + if (x < 0 || y < 0 || x >= w || y >= h) continue; + const int g = gradMag(x, y); + if (g > bestG) { + bestG = g; + bestX = x; + bestY = y; + } + } + } + out.push_back(QPointF(bestX, bestY)); + } + return out; +} + +bool buildSamSegmentPayloadFromStroke( + const QVector& strokeWorld, + const QImage& bgImage, + QByteArray& outCropPng, + QByteArray& outOverlayPng, + QPointF& outCropTopLeftWorld, + QJsonArray& outPointCoords, + QJsonArray& outPointLabels, + QJsonArray& outBoxXyxy +) { + if (strokeWorld.size() < kMinStrokePointsSam || bgImage.isNull()) { + return false; + } + const QRectF polyBr = QPolygonF(strokeWorld).boundingRect(); + if (polyBr.isEmpty()) { + return false; + } + const QRect cropRect = entity_cutout::clampRectToImage( + polyBr.adjusted(-kSamCropMargin, -kSamCropMargin, kSamCropMargin, kSamCropMargin).toAlignedRect(), + bgImage.size()); + if (cropRect.isEmpty()) { + return false; + } + outCropTopLeftWorld = cropRect.topLeft(); + + const QImage cropRgb = bgImage.copy(cropRect).convertToFormat(QImage::Format_RGB888); + QBuffer bufCrop(&outCropPng); + if (!bufCrop.open(QIODevice::WriteOnly) || !cropRgb.save(&bufCrop, "PNG")) { + outCropPng.clear(); + return false; + } + bufCrop.close(); + + const QPointF origin = cropRect.topLeft(); + const int cw = cropRect.width(); + const int ch = cropRect.height(); + + QImage overlay(cw, ch, QImage::Format_ARGB32_Premultiplied); + overlay.fill(Qt::transparent); + { + QPainter pop(&overlay); + pop.setRenderHint(QPainter::Antialiasing, true); + QPen pen(QColor(255, 60, 60, 240)); + pen.setWidthF(4.0); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + pop.setPen(pen); + QPolygonF local; + local.reserve(strokeWorld.size()); + for (const QPointF& w : strokeWorld) { + local.append(w - origin); + } + pop.drawPolyline(local); + } + QBuffer bufOv(&outOverlayPng); + if (!bufOv.open(QIODevice::WriteOnly) || !overlay.save(&bufOv, "PNG")) { + outOverlayPng.clear(); + return false; + } + bufOv.close(); + + const int n = static_cast(strokeWorld.size()); + const int step = std::max(1, (n + kMaxSamPointPrompts - 1) / kMaxSamPointPrompts); + for (int i = 0; i < n; i += step) { + const QPointF L = strokeWorld[i] - origin; + outPointCoords.append(QJsonArray{L.x(), L.y()}); + outPointLabels.append(1); + } + if (n > 1) { + const QPointF Llast = strokeWorld.last() - origin; + if (!outPointCoords.isEmpty()) { + const QJsonArray prev = outPointCoords.last().toArray(); + if (prev.size() >= 2) { + const double dx = Llast.x() - prev.at(0).toDouble(); + const double dy = Llast.y() - prev.at(1).toDouble(); + if (dx * dx + dy * dy > 4.0) { + outPointCoords.append(QJsonArray{Llast.x(), Llast.y()}); + outPointLabels.append(1); + } + } + } + } + + const QRectF tight = QPolygonF(strokeWorld).boundingRect(); + auto clampD = [](double v, double lo, double hi) { return std::clamp(v, lo, hi); }; + double x1 = clampD(tight.left() - origin.x(), 0.0, static_cast(cw - 1)); + double y1 = clampD(tight.top() - origin.y(), 0.0, static_cast(ch - 1)); + double x2 = clampD(tight.right() - origin.x(), 0.0, static_cast(cw - 1)); + double y2 = clampD(tight.bottom() - origin.y(), 0.0, static_cast(ch - 1)); + if (x2 <= x1) { + x2 = std::min(static_cast(cw - 1), x1 + 1.0); + } + if (y2 <= y1) { + y2 = std::min(static_cast(ch - 1), y1 + 1.0); + } + outBoxXyxy = QJsonArray{x1, y1, x2, y2}; + return true; +} + void drawCheckerboard(QPainter& p, const QRect& r) { // 轻量级棋盘格,让透明/纯色背景也有参照 const int cell = 16; @@ -45,41 +205,6 @@ void drawGrid(QPainter& p, const QRect& r) { } } -QPainterPath pathFromWorldPolygon(const QVector& poly) { - QPainterPath path; - if (poly.size() < 3) { - return path; - } - path.addPolygon(QPolygonF(poly)); - path.closeSubpath(); - return path; -} - -QPointF polygonCentroid(const QVector& poly) { - // 简单多边形质心(面积加权)。若面积接近 0,则退化为包围盒中心。 - if (poly.size() < 3) { - return {}; - } - double a2 = 0.0; // 2*area - double cx6a = 0.0; - double cy6a = 0.0; - for (int i = 0; i < poly.size(); ++i) { - const QPointF p0 = poly[i]; - const QPointF p1 = poly[(i + 1) % poly.size()]; - const double cross = static_cast(p0.x()) * static_cast(p1.y()) - - static_cast(p1.x()) * static_cast(p0.y()); - a2 += cross; - cx6a += (static_cast(p0.x()) + static_cast(p1.x())) * cross; - cy6a += (static_cast(p0.y()) + static_cast(p1.y())) * cross; - } - if (std::abs(a2) < 1e-6) { - const QRectF bb = pathFromWorldPolygon(poly).boundingRect(); - return bb.center(); - } - const double inv6a = 1.0 / (3.0 * a2); // 1/(6A) with a2=2A -> 1/(3*a2) - return QPointF(cx6a * inv6a, cy6a * inv6a); -} - QRectF transformedRectByScaleAndTranslate(const QRectF& r, const QPointF& center, double scaleRatio, const QPointF& delta) { if (r.isNull()) { return r.translated(delta); @@ -99,53 +224,6 @@ QRectF transformedRectByScaleAndTranslate(const QRectF& r, const QPointF& center return QRectF(QPointF(minX, minY), QPointF(maxX, maxY)); } -QRect clampRectToImage(const QRect& r, const QSize& size) { - QRect out = r.normalized(); - if (out.isNull()) { - return {}; - } - out.setLeft(std::max(0, out.left())); - out.setTop(std::max(0, out.top())); - out.setRight(std::min(size.width() - 1, out.right())); - out.setBottom(std::min(size.height() - 1, out.bottom())); - if (out.width() <= 0 || out.height() <= 0) { - return {}; - } - return out; -} - -int computeAverageDepthInPolygon(const QImage& depth8, const QVector& polyWorld) { - if (depth8.isNull() || depth8.format() != QImage::Format_Grayscale8 || polyWorld.size() < 3) { - return 0; - } - const QPainterPath path = pathFromWorldPolygon(polyWorld); - if (path.isEmpty()) { - return 0; - } - const QRect bbox = clampRectToImage(path.boundingRect().toAlignedRect(), depth8.size()); - if (bbox.isNull()) { - return 0; - } - - quint64 sum = 0; - quint64 cnt = 0; - for (int y = bbox.top(); y <= bbox.bottom(); ++y) { - const uchar* row = depth8.constScanLine(y); - for (int x = bbox.left(); x <= bbox.right(); ++x) { - // 取像素中心点做包含测试 - if (!path.contains(QPointF(x + 0.5, y + 0.5))) { - continue; - } - sum += static_cast(row[x]); - ++cnt; - } - } - if (cnt == 0) { - return 0; - } - return static_cast(sum / cnt); -} - int sampleDepthAtPoint(const QImage& depth8, const QPointF& worldPos) { if (depth8.isNull() || depth8.format() != QImage::Format_Grayscale8) { return 0; @@ -174,56 +252,6 @@ double distanceScaleFromDepth01(double depth01, double calibMult) { return raw; } -QImage extractEntityImage(const QImage& bg, const QVector& polyWorld, QPointF& outTopLeftWorld) { - if (bg.isNull() || polyWorld.size() < 3) { - outTopLeftWorld = {}; - return {}; - } - const QPainterPath path = pathFromWorldPolygon(polyWorld); - if (path.isEmpty()) { - outTopLeftWorld = {}; - return {}; - } - const QRect bbox = clampRectToImage(path.boundingRect().toAlignedRect(), bg.size()); - if (bbox.isNull()) { - outTopLeftWorld = {}; - return {}; - } - - outTopLeftWorld = bbox.topLeft(); - - QImage out(bbox.size(), QImage::Format_ARGB32_Premultiplied); - out.fill(Qt::transparent); - QPainter p(&out); - p.setRenderHint(QPainter::Antialiasing, true); - - // 把 path 平移到裁剪图坐标系 - QTransform tr; - tr.translate(-bbox.left(), -bbox.top()); - const QPainterPath localPath = tr.map(path); - - p.setClipPath(localPath); - p.drawImage(QPoint(0, 0), bg, bbox); - p.end(); - return out; -} - -void applyBlackFillToBackground(QImage& bgCutout, const QVector& polyWorld) { - if (bgCutout.isNull() || polyWorld.size() < 3) { - return; - } - QPainterPath path = pathFromWorldPolygon(polyWorld); - if (path.isEmpty()) { - return; - } - QPainter p(&bgCutout); - p.setRenderHint(QPainter::Antialiasing, true); - p.setPen(Qt::NoPen); - p.setBrush(QColor(0, 0, 0, 255)); - p.drawPath(path); - p.end(); -} - struct GizmoHit { EditorCanvas::DragMode mode = EditorCanvas::DragMode::None; }; @@ -333,7 +361,7 @@ void EditorCanvas::setEntities(const QVector& entities, c for (const auto& lp : e.polygonLocal) { polyTmp.push_back(originWorld + lp); } - const QPointF cTmp = polyTmp.isEmpty() ? originWorld : polygonCentroid(polyTmp); + const QPointF cTmp = polyTmp.isEmpty() ? originWorld : entity_cutout::polygonCentroid(polyTmp); const int z = (!m_depthImage8.isNull()) ? sampleDepthAtPoint(m_depthImage8, cTmp) : e.depth; v.depth = z; @@ -354,7 +382,7 @@ void EditorCanvas::setEntities(const QVector& entities, c // 贴图按 origin 缩放 v.imageTopLeft = originWorld + (e.imageTopLeftWorld - e.originWorld) * scale; - v.pathWorld = pathFromWorldPolygon(v.polygonWorld); + v.pathWorld = entity_cutout::pathFromWorldPolygon(v.polygonWorld); v.rect = v.pathWorld.boundingRect(); v.color = QColor(255, 120, 0, 70); @@ -394,7 +422,7 @@ void EditorCanvas::setEntities(const QVector& entities, c if (m_selectedEntity >= 0) { const auto& ent = m_entities[m_selectedEntity]; const QPointF origin = - ent.polygonWorld.isEmpty() ? ent.rect.center() : polygonCentroid(ent.polygonWorld); + ent.polygonWorld.isEmpty() ? ent.rect.center() : entity_cutout::polygonCentroid(ent.polygonWorld); emit selectedEntityChanged(true, ent.id, ent.depth, origin); } else if (!prevSelectedId.isEmpty()) { emit selectedEntityChanged(false, QString(), 0, QPointF()); @@ -437,7 +465,7 @@ QPointF EditorCanvas::selectedEntityCentroidWorld() const { } const auto& ent = m_entities[m_selectedEntity]; if (!ent.polygonWorld.isEmpty()) { - return polygonCentroid(ent.polygonWorld); + return entity_cutout::polygonCentroid(ent.polygonWorld); } return ent.rect.center(); } @@ -500,7 +528,7 @@ void EditorCanvas::presentationComputeZoomTarget(int entityIndex, QPointF* outPa const QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale); bb = QRectF(ent.imageTopLeft, sz); } else if (!ent.polygonWorld.isEmpty()) { - bb = pathFromWorldPolygon(ent.polygonWorld).boundingRect(); + bb = entity_cutout::pathFromWorldPolygon(ent.polygonWorld).boundingRect(); } else { bb = ent.rect; } @@ -575,7 +603,7 @@ void EditorCanvas::selectEntityById(const QString& id) { m_selectedEntity = i; const auto& ent = m_entities[i]; const QPointF origin = - ent.polygonWorld.isEmpty() ? ent.rect.center() : polygonCentroid(ent.polygonWorld); + ent.polygonWorld.isEmpty() ? ent.rect.center() : entity_cutout::polygonCentroid(ent.polygonWorld); emit selectedEntityChanged(true, ent.id, ent.depth, origin); update(); return; @@ -635,6 +663,58 @@ void EditorCanvas::setTool(Tool tool) { update(); } +void EditorCanvas::setEntityCreateSegmentMode(EntityCreateSegmentMode m) { + if (m_entityCreateSegmentMode == m) { + return; + } + m_entityCreateSegmentMode = m; + update(); +} + +void EditorCanvas::setPendingEntityPolygonWorld(const QVector& polyWorld) { + m_pendingPolyWorld = polyWorld; + m_pendingDragging = false; + m_pendingDragWhole = false; + m_pendingDragVertex = -1; + update(); +} + +void EditorCanvas::clearPendingEntityPolygon() { + m_pendingPolyWorld.clear(); + m_pendingDragging = false; + m_pendingDragWhole = false; + m_pendingDragVertex = -1; + update(); +} + +bool EditorCanvas::isPointNearPendingVertex(const QPointF& worldPos, int* outIndex) const { + if (outIndex) *outIndex = -1; + if (m_pendingPolyWorld.size() < 3) return false; + const qreal rView = 10.0; + const qreal rWorld = rView / std::max(m_scale, 0.001); + const qreal r2 = rWorld * rWorld; + int best = -1; + qreal bestD2 = r2; + for (int i = 0; i < m_pendingPolyWorld.size(); ++i) { + const QPointF d = m_pendingPolyWorld[i] - worldPos; + const qreal d2 = d.x() * d.x() + d.y() * d.y(); + if (d2 <= bestD2) { + bestD2 = d2; + best = i; + } + } + if (best >= 0) { + if (outIndex) *outIndex = best; + return true; + } + return false; +} + +bool EditorCanvas::pendingPolygonContains(const QPointF& worldPos) const { + if (m_pendingPolyWorld.size() < 3) return false; + return entity_cutout::pathFromWorldPolygon(m_pendingPolyWorld).contains(worldPos); +} + void EditorCanvas::resetView() { m_scale = 1.0; m_pan = QPointF(0, 0); @@ -768,7 +848,7 @@ int EditorCanvas::hitTestEntity(const QPointF& worldPos) const { continue; } if (!ent.polygonWorld.isEmpty()) { - const QPainterPath path = pathFromWorldPolygon(ent.polygonWorld); + const QPainterPath path = entity_cutout::pathFromWorldPolygon(ent.polygonWorld); if (path.contains(worldPos)) { return static_cast(idx); } @@ -829,7 +909,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { m_bgImageCutout = m_bgImage; for (const auto& ent : m_entities) { if (!ent.cutoutPolygonWorld.isEmpty() && !m_bgImageCutout.isNull()) { - applyBlackFillToBackground(m_bgImageCutout, ent.cutoutPolygonWorld); + entity_cutout::applyBlackFillToBackground(m_bgImageCutout, ent.cutoutPolygonWorld); } } } @@ -1030,6 +1110,21 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { p.drawLine(m_strokeWorld.first(), m_strokeWorld.last()); } + // 待确认实体:多边形预览 + 顶点 + if (!m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) { + const qreal lw = 2.5 / std::max(m_scale, 0.001); + p.setPen(QPen(QColor(60, 180, 255, 230), lw)); + p.setBrush(QColor(60, 180, 255, 45)); + const QPainterPath path = entity_cutout::pathFromWorldPolygon(m_pendingPolyWorld); + p.drawPath(path); + const qreal vr = 5.0 / std::max(m_scale, 0.001); + p.setPen(QPen(QColor(0, 0, 0, 120), lw)); + p.setBrush(QColor(255, 255, 255, 220)); + for (const QPointF& v : m_pendingPolyWorld) { + p.drawEllipse(v, vr, vr); + } + } + p.restore(); // 坐标轴/刻度:绘制在画布最外层,背景越界时贴边显示 @@ -1090,7 +1185,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) { if (isDragPreview) { originWorld = m_dragCentroidBase + m_dragDelta; } else if (!ent.polygonWorld.isEmpty()) { - originWorld = polygonCentroid(ent.polygonWorld); + originWorld = entity_cutout::polygonCentroid(ent.polygonWorld); } const QPointF originView = worldToView(originWorld); @@ -1155,7 +1250,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { if (hit >= 0) { const auto& ent = m_entities[hit]; const QPointF cWorld = - ent.polygonWorld.isEmpty() ? ent.rect.center() : polygonCentroid(ent.polygonWorld); + ent.polygonWorld.isEmpty() ? ent.rect.center() : entity_cutout::polygonCentroid(ent.polygonWorld); const QPointF anchorView = worldToView(cWorld); beginPresentationZoomTowardEntity(hit); emit presentationEntityIntroRequested(ent.id, anchorView); @@ -1178,6 +1273,33 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { } if (m_tool == Tool::CreateEntity && e->button() == Qt::LeftButton) { + // 若已有待确认多边形:进入微调(顶点/整体拖拽),或点击空白直接确认 + if (!m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) { + m_dragging = true; + m_lastMouseView = e->position(); + const QPointF w = viewToWorld(e->position()); + m_pendingLastMouseWorld = w; + int vi = -1; + if (isPointNearPendingVertex(w, &vi)) { + m_pendingDragging = true; + m_pendingDragVertex = vi; + m_pendingDragWhole = false; + e->accept(); + return; + } + if (pendingPolygonContains(w)) { + m_pendingDragging = true; + m_pendingDragWhole = true; + m_pendingDragVertex = -1; + e->accept(); + return; + } + // 点击空白:确认 + emit requestFinalizePendingEntity(m_pendingPolyWorld); + e->accept(); + return; + } + m_dragging = true; m_drawingEntity = true; m_draggingEntity = false; @@ -1207,7 +1329,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { if (isDragPreview) { originWorld = m_dragCentroidBase + m_dragDelta; } else if (!ent.polygonWorld.isEmpty()) { - originWorld = polygonCentroid(ent.polygonWorld); + originWorld = entity_cutout::polygonCentroid(ent.polygonWorld); } const QPointF originView = worldToView(originWorld); const GizmoHit gh = hitTestGizmo(e->position(), originView); @@ -1220,7 +1342,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { // 为了统一复用 move 逻辑:初始化 rect 与基准点 const QRectF r = m_entities[m_selectedEntity].rect.isNull() && !m_entities[m_selectedEntity].polygonWorld.isEmpty() - ? pathFromWorldPolygon(m_entities[m_selectedEntity].polygonWorld).boundingRect() + ? entity_cutout::pathFromWorldPolygon(m_entities[m_selectedEntity].polygonWorld).boundingRect() : m_entities[m_selectedEntity].rect; m_entities[m_selectedEntity].rect = r; m_entityDragOffsetOriginWorld = viewToWorld(e->position()) - m_entities[m_selectedEntity].animatedOriginWorld; @@ -1236,7 +1358,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { m_dragPolyBase = m_entities[m_selectedEntity].polygonWorld; m_dragPathBase = m_entities[m_selectedEntity].pathWorld; m_dragCentroidBase = - m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : polygonCentroid(m_dragPolyBase); + m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase); update(); return; } @@ -1249,7 +1371,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { m_dragMode = DragMode::Free; emit entityDragActiveChanged(true); const QRectF r = m_entities[hit].rect.isNull() && !m_entities[hit].polygonWorld.isEmpty() - ? pathFromWorldPolygon(m_entities[hit].polygonWorld).boundingRect() + ? entity_cutout::pathFromWorldPolygon(m_entities[hit].polygonWorld).boundingRect() : m_entities[hit].rect; m_entities[hit].rect = r; m_entityDragOffsetOriginWorld = worldPos - m_entities[hit].animatedOriginWorld; @@ -1265,8 +1387,8 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) { m_dragPolyBase = m_entities[hit].polygonWorld; m_dragPathBase = m_entities[hit].pathWorld; m_dragCentroidBase = - m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : polygonCentroid(m_dragPolyBase); - const QPointF origin = !m_entities[hit].polygonWorld.isEmpty() ? polygonCentroid(m_entities[hit].polygonWorld) + m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase); + const QPointF origin = !m_entities[hit].polygonWorld.isEmpty() ? entity_cutout::polygonCentroid(m_entities[hit].polygonWorld) : m_entities[hit].rect.center(); emit selectedEntityChanged(true, m_entities[hit].id, m_entities[hit].depth, origin); update(); @@ -1326,6 +1448,21 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) { const QPointF deltaView = cur - m_lastMouseView; m_lastMouseView = cur; + if (m_pendingDragging && !m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) { + const QPointF curWorld = viewToWorld(e->position()); + const QPointF delta = curWorld - m_pendingLastMouseWorld; + m_pendingLastMouseWorld = curWorld; + if (m_pendingDragWhole) { + for (auto& pt : m_pendingPolyWorld) { + pt += delta; + } + } else if (m_pendingDragVertex >= 0 && m_pendingDragVertex < m_pendingPolyWorld.size()) { + m_pendingPolyWorld[m_pendingDragVertex] += delta; + } + update(); + return; + } + if (m_tool == Tool::CreateEntity && m_drawingEntity) { const QPointF w = viewToWorld(cur); if (m_strokeWorld.isEmpty()) { @@ -1442,40 +1579,27 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { m_drawingEntity = false; updateCursor(); - if (m_strokeWorld.size() >= 8) { - // 闭合:末点连回首点(绘制时 polygon 自动闭合) - core::Project::Entity ent; - // id 由上层(workspace/UI)统一分配,避免因过滤/隐藏导致重复 - ent.id.clear(); - ent.cutoutPolygonWorld = m_strokeWorld; - ent.originWorld = 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); + if (m_entityCreateSegmentMode == EntityCreateSegmentMode::Manual) { + if (m_strokeWorld.size() >= kMinStrokePointsManual) { + setPendingEntityPolygonWorld(m_strokeWorld); } - - // 确保深度加载(用于平均深度) - if (m_depthDirty) { - m_depthDirty = false; - QImage img(m_depthAbsPath); - m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8); + } else if (m_entityCreateSegmentMode == EntityCreateSegmentMode::Snap) { + if (m_strokeWorld.size() >= kMinStrokePointsManual) { + ensurePixmapLoaded(); + const QVector snapped = snapStrokeToEdges(m_strokeWorld, m_bgImage, 6); + setPendingEntityPolygonWorld(snapped); } - const QPointF c = polygonCentroid(ent.cutoutPolygonWorld); - ent.depth = m_depthImage8.isNull() ? 0 : sampleDepthAtPoint(m_depthImage8, c); - { - const double ds01 = depthToScale01(ent.depth); - ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0; + } else if (m_strokeWorld.size() >= kMinStrokePointsSam) { + ensurePixmapLoaded(); + QByteArray cropPng; + QByteArray ovPng; + QPointF cropOrigin; + QJsonArray pts; + QJsonArray labs; + QJsonArray box; + if (buildSamSegmentPayloadFromStroke(m_strokeWorld, m_bgImage, cropPng, ovPng, cropOrigin, pts, labs, box)) { + emit requestSamSegment(cropPng, ovPng, cropOrigin, pts, labs, box); } - - // 抠实体:从原背景抠出,背景对应区域填黑 - QImage cutout; - if (!m_bgImage.isNull()) { - QPointF topLeft; - cutout = extractEntityImage(m_bgImage, ent.cutoutPolygonWorld, topLeft); - ent.imageTopLeftWorld = topLeft; - } - emit requestAddEntity(ent, cutout); } m_strokeWorld.clear(); update(); @@ -1497,12 +1621,17 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) { } if (!sentMove && !ent.id.isEmpty()) { const QPointF origin = - ent.polygonWorld.isEmpty() ? ent.rect.center() : polygonCentroid(ent.polygonWorld); + ent.polygonWorld.isEmpty() ? ent.rect.center() : entity_cutout::polygonCentroid(ent.polygonWorld); emit selectedEntityChanged(true, ent.id, ent.depth, origin); } } m_dragging = false; + if (m_pendingDragging && e->button() == Qt::LeftButton) { + m_pendingDragging = false; + m_pendingDragWhole = false; + m_pendingDragVertex = -1; + } if (m_draggingEntity) { emit entityDragActiveChanged(false); } @@ -1546,6 +1675,18 @@ void EditorCanvas::keyPressEvent(QKeyEvent* e) { e->accept(); return; } + if (!m_presentationPreviewMode && m_pendingPolyWorld.size() >= 3) { + if (e->key() == Qt::Key_Return || e->key() == Qt::Key_Enter) { + emit requestFinalizePendingEntity(m_pendingPolyWorld); + e->accept(); + return; + } + if (e->key() == Qt::Key_Escape) { + clearPendingEntityPolygon(); + e->accept(); + return; + } + } QWidget::keyPressEvent(e); } diff --git a/client/gui/editor/EditorCanvas.h b/client/gui/editor/EditorCanvas.h index 3a2c985..f5bb4df 100644 --- a/client/gui/editor/EditorCanvas.h +++ b/client/gui/editor/EditorCanvas.h @@ -2,6 +2,8 @@ #include "core/domain/Project.h" +#include +#include #include #include #include @@ -33,6 +35,17 @@ public: void setTool(Tool tool); Tool tool() const { return m_tool; } + /// 创建实体的分割方式(在「创」工具被选中后再次单击弹出面板选择)。 + enum class EntityCreateSegmentMode { Manual, Snap, Sam }; + Q_ENUM(EntityCreateSegmentMode) + void setEntityCreateSegmentMode(EntityCreateSegmentMode m); + EntityCreateSegmentMode entityCreateSegmentMode() const { return m_entityCreateSegmentMode; } + + /// 进入/更新「待确认」实体多边形(允许用户在画布上微调)。 + void setPendingEntityPolygonWorld(const QVector& polyWorld); + void clearPendingEntityPolygon(); + bool hasPendingEntityPolygon() const { return m_pendingPolyWorld.size() >= 3; } + void resetView(); void zoomToFit(); @@ -81,6 +94,16 @@ signals: void hoveredWorldPosDepthChanged(const QPointF& worldPos, int depthZ); void selectedEntityChanged(bool hasSelection, const QString& id, int depth, const QPointF& originWorld); void requestAddEntity(const core::Project::Entity& entity, const QImage& image); + /// 创建实体:将裁剪 RGB、标记叠加层与 SAM 提示发往模型服务,由主窗口收 JSON 轮廓后再 addEntity。 + void requestSamSegment( + const QByteArray& cropRgbPng, + const QByteArray& overlayPng, + const QPointF& cropTopLeftWorld, + const QJsonArray& pointCoords, + const QJsonArray& pointLabels, + const QJsonArray& boxXyxy); + /// 待确认实体:用户按回车/点击空白处确认后触发(由主窗口弹窗命名并落盘)。 + void requestFinalizePendingEntity(const QVector& polyWorld); void requestMoveEntity(const QString& id, const QPointF& delta); void entityDragActiveChanged(bool on); void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld); @@ -106,6 +129,8 @@ private: QPointF viewToWorld(const QPointF& v) const; QPointF worldToView(const QPointF& w) const; QRectF worldRectOfBackground() const; + bool isPointNearPendingVertex(const QPointF& worldPos, int* outIndex) const; + bool pendingPolygonContains(const QPointF& worldPos) const; void tickPresentationZoomAnimation(); void tickPresentationHoverAnimation(); @@ -159,6 +184,7 @@ private: bool m_presentationPreviewMode = false; Tool m_tool = Tool::Move; + EntityCreateSegmentMode m_entityCreateSegmentMode = EntityCreateSegmentMode::Manual; qreal m_scale = 1.0; QPointF m_pan; // world 原点对应的 view 坐标偏移(view = world*scale + pan) @@ -193,6 +219,13 @@ private: int m_currentFrame = 0; + // —— 创建实体:待确认多边形(可微调)—— + QVector m_pendingPolyWorld; + bool m_pendingDragging = false; + bool m_pendingDragWhole = false; + int m_pendingDragVertex = -1; + QPointF m_pendingLastMouseWorld; + // —— 预览展示:实体悬停动效、点击聚焦缩放 —— QTimer* m_presZoomTimer = nullptr; QTimer* m_presHoverTimer = nullptr; diff --git a/client/gui/editor/EntityCutoutUtils.cpp b/client/gui/editor/EntityCutoutUtils.cpp new file mode 100644 index 0000000..f0440df --- /dev/null +++ b/client/gui/editor/EntityCutoutUtils.cpp @@ -0,0 +1,110 @@ +#include "editor/EntityCutoutUtils.h" + +#include +#include + +#include +#include +#include + +namespace entity_cutout { + +QPainterPath pathFromWorldPolygon(const QVector& poly) { + QPainterPath path; + if (poly.size() < 3) { + return path; + } + path.addPolygon(QPolygonF(poly)); + path.closeSubpath(); + return path; +} + +QPointF polygonCentroid(const QVector& poly) { + if (poly.size() < 3) { + return {}; + } + double a2 = 0.0; + double cx6a = 0.0; + double cy6a = 0.0; + for (int i = 0; i < poly.size(); ++i) { + const QPointF p0 = poly[i]; + const QPointF p1 = poly[(i + 1) % poly.size()]; + const double cross = static_cast(p0.x()) * static_cast(p1.y()) - + static_cast(p1.x()) * static_cast(p0.y()); + a2 += cross; + cx6a += (static_cast(p0.x()) + static_cast(p1.x())) * cross; + cy6a += (static_cast(p0.y()) + static_cast(p1.y())) * cross; + } + if (std::abs(a2) < 1e-6) { + const QRectF bb = pathFromWorldPolygon(poly).boundingRect(); + return bb.center(); + } + const double inv6a = 1.0 / (3.0 * a2); + return QPointF(cx6a * inv6a, cy6a * inv6a); +} + +QRect clampRectToImage(const QRect& r, const QSize& size) { + QRect out = r.normalized(); + if (out.isNull()) { + return {}; + } + out.setLeft(std::max(0, out.left())); + out.setTop(std::max(0, out.top())); + out.setRight(std::min(size.width() - 1, out.right())); + out.setBottom(std::min(size.height() - 1, out.bottom())); + if (out.width() <= 0 || out.height() <= 0) { + return {}; + } + return out; +} + +QImage extractEntityImage(const QImage& bg, const QVector& polyWorld, QPointF& outTopLeftWorld) { + if (bg.isNull() || polyWorld.size() < 3) { + outTopLeftWorld = {}; + return {}; + } + const QPainterPath path = pathFromWorldPolygon(polyWorld); + if (path.isEmpty()) { + outTopLeftWorld = {}; + return {}; + } + const QRect bbox = clampRectToImage(path.boundingRect().toAlignedRect(), bg.size()); + if (bbox.isNull()) { + outTopLeftWorld = {}; + return {}; + } + + outTopLeftWorld = bbox.topLeft(); + + QImage out(bbox.size(), QImage::Format_ARGB32_Premultiplied); + out.fill(Qt::transparent); + QPainter p(&out); + p.setRenderHint(QPainter::Antialiasing, true); + + QTransform tr; + tr.translate(-bbox.left(), -bbox.top()); + const QPainterPath localPath = tr.map(path); + + p.setClipPath(localPath); + p.drawImage(QPoint(0, 0), bg, bbox); + p.end(); + return out; +} + +void applyBlackFillToBackground(QImage& bgCutout, const QVector& polyWorld) { + if (bgCutout.isNull() || polyWorld.size() < 3) { + return; + } + QPainterPath path = pathFromWorldPolygon(polyWorld); + if (path.isEmpty()) { + return; + } + QPainter p(&bgCutout); + p.setRenderHint(QPainter::Antialiasing, true); + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 0, 0, 255)); + p.drawPath(path); + p.end(); +} + +} // namespace entity_cutout diff --git a/client/gui/editor/EntityCutoutUtils.h b/client/gui/editor/EntityCutoutUtils.h new file mode 100644 index 0000000..866c90c --- /dev/null +++ b/client/gui/editor/EntityCutoutUtils.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace entity_cutout { + +QPainterPath pathFromWorldPolygon(const QVector& poly); +QPointF polygonCentroid(const QVector& poly); +QRect clampRectToImage(const QRect& r, const QSize& size); +QImage extractEntityImage(const QImage& bg, const QVector& polyWorld, QPointF& outTopLeftWorld); +void applyBlackFillToBackground(QImage& bgCutout, const QVector& polyWorld); + +} // namespace entity_cutout diff --git a/client/gui/main_window/MainWindow.cpp b/client/gui/main_window/MainWindow.cpp index d2dc2e4..fd87318 100644 --- a/client/gui/main_window/MainWindow.cpp +++ b/client/gui/main_window/MainWindow.cpp @@ -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 #include #include +#include +#include #include #include +#include +#include +#include #include #include @@ -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(EditorCanvas::Tool::CreateEntity)); + if (!m_createEntityPopup) { + m_createEntityPopup = new ToolOptionPopup(this); + m_createEntityPopup->setOptions({ + {static_cast(EditorCanvas::EntityCreateSegmentMode::Manual), QStringLiteral("手动分割")}, + {static_cast(EditorCanvas::EntityCreateSegmentMode::Snap), QStringLiteral("吸附分割")}, + {static_cast(EditorCanvas::EntityCreateSegmentMode::Sam), QStringLiteral("模型分割")}, + }); + connect(m_createEntityPopup, &ToolOptionPopup::optionChosen, this, [this](int id) { + if (!m_editorCanvas) return; + m_editorCanvas->setEntityCreateSegmentMode(static_cast(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 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(std::floor(c.x())); + const int yi = static_cast(std::floor(c.y())); + if (xi >= 0 && yi >= 0 && xi < depth8.width() && yi < depth8.height()) { + z = static_cast(static_cast(depth8.constScanLine(yi))[xi]); + } + } + ent.depth = z; + { + const double ds01 = static_cast(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 used; + for (const auto& ex : m_workspace.entities()) { + used.insert(ex.id); + } + int n = static_cast(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& 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(std::floor(c.x())); + const int yi = static_cast(std::floor(c.y())); + if (xi >= 0 && yi >= 0 && xi < depth8.width() && yi < depth8.height()) { + z = static_cast(static_cast(depth8.constScanLine(yi))[xi]); + } + } + } + + // 生成稳定且不重复的 id:entity-,同时作为“默认名称” + QString newId; + { + QSet used; + for (const auto& ex : m_workspace.entities()) { + used.insert(ex.id); + } + int n = static_cast(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(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); diff --git a/client/gui/main_window/MainWindow.h b/client/gui/main_window/MainWindow.h index 387a0f6..58856a9 100644 --- a/client/gui/main_window/MainWindow.h +++ b/client/gui/main_window/MainWindow.h @@ -9,6 +9,10 @@ #include #include +#include + +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(); diff --git a/client/gui/widgets/LongPressSwitchToolButton.cpp b/client/gui/widgets/LongPressSwitchToolButton.cpp new file mode 100644 index 0000000..9be4cd5 --- /dev/null +++ b/client/gui/widgets/LongPressSwitchToolButton.cpp @@ -0,0 +1,52 @@ +#include "widgets/LongPressSwitchToolButton.h" + +#include +#include +#include + +#include + +LongPressSwitchToolButton::LongPressSwitchToolButton(QWidget* parent) + : QToolButton(parent) { + m_timer = new QTimer(this); + m_timer->setSingleShot(true); + m_timer->setInterval(m_intervalMs); + connect(m_timer, &QTimer::timeout, this, [this]() { + if (!(QApplication::mouseButtons() & Qt::LeftButton)) { + return; + } + m_longPressEmittedForThisPress = true; + emit longPressTriggered(); + }); +} + +void LongPressSwitchToolButton::setLongPressInterval(int ms) { + m_intervalMs = std::max(50, ms); + if (m_timer) { + m_timer->setInterval(m_intervalMs); + } +} + +void LongPressSwitchToolButton::mousePressEvent(QMouseEvent* event) { + if (event->button() == Qt::LeftButton) { + m_longPressEmittedForThisPress = false; + m_timer->start(); + } + QToolButton::mousePressEvent(event); +} + +void LongPressSwitchToolButton::mouseReleaseEvent(QMouseEvent* event) { + const bool left = (event->button() == Qt::LeftButton); + if (left && m_timer->isActive()) { + m_timer->stop(); + } + const bool hadLongPress = left && m_longPressEmittedForThisPress; + QToolButton::mouseReleaseEvent(event); + if (hadLongPress) { + m_longPressEmittedForThisPress = false; + if (isDown()) { + setDown(false); + } + update(); + } +} diff --git a/client/gui/widgets/LongPressSwitchToolButton.h b/client/gui/widgets/LongPressSwitchToolButton.h new file mode 100644 index 0000000..87f08df --- /dev/null +++ b/client/gui/widgets/LongPressSwitchToolButton.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +class QMouseEvent; +class QTimer; + +/** + * 可复用的工具栏按钮:短按行为与普通 QToolButton 一致; + * 左键按住超过设定时间(默认 450ms)且仍未松开时发出 longPressTriggered(), + * 用于在「同一工具钮」上切换子模式(类似 Photoshop 长按切换同类工具)。 + */ +class LongPressSwitchToolButton final : public QToolButton { + Q_OBJECT +public: + explicit LongPressSwitchToolButton(QWidget* parent = nullptr); + + void setLongPressInterval(int ms); + int longPressInterval() const { return m_intervalMs; } + +signals: + void longPressTriggered(); + +protected: + void mousePressEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + +private: + QTimer* m_timer = nullptr; + int m_intervalMs = 450; + bool m_longPressEmittedForThisPress = false; +}; diff --git a/client/gui/widgets/ToolOptionPopup.cpp b/client/gui/widgets/ToolOptionPopup.cpp new file mode 100644 index 0000000..cafa634 --- /dev/null +++ b/client/gui/widgets/ToolOptionPopup.cpp @@ -0,0 +1,55 @@ +#include "widgets/ToolOptionPopup.h" + +#include +#include +#include + +ToolOptionPopup::ToolOptionPopup(QWidget* parent) + : QFrame(parent) { + setWindowFlags(Qt::Popup | Qt::FramelessWindowHint); + setFrameShape(QFrame::StyledPanel); + setObjectName(QStringLiteral("ToolOptionPopup")); + setStyleSheet(QStringLiteral( + "#ToolOptionPopup { background: palette(base); border: 1px solid palette(mid); border-radius: 10px; }" + "#ToolOptionPopup QPushButton { border: 1px solid transparent; padding: 8px 10px; text-align: left; }" + "#ToolOptionPopup QPushButton:hover { background: palette(midlight); }")); +} + +void ToolOptionPopup::setOptions(const QVector