添加模型分割

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,6 +1,7 @@
#include "net/ModelServerClient.h"
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
@@ -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,

View File

@@ -1,6 +1,7 @@
#pragma once
#include <QByteArray>
#include <QJsonArray>
#include <QObject>
#include <QString>
#include <QUrl>
@@ -27,6 +28,15 @@ public:
// 返回 nullptr 表示参数/URL 非法导致无法发起。
QNetworkReply* computeDepthPng8Async(const QByteArray& imageBytes, QString* outImmediateError = nullptr);
// POST /segment/sam_promptJSON 响应由调用方解析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;

View File

@@ -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

View File

@@ -0,0 +1,56 @@
#include "dialogs/EntityFinalizeDialog.h"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QDoubleSpinBox>
#include <QVBoxLayout>
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;
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <QDialog>
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;
};

View File

@@ -1,10 +1,12 @@
#include "editor/EditorCanvas.h"
#include "editor/EntityCutoutUtils.h"
#include "core/animation/AnimationSampling.h"
#include "core/depth/DepthService.h"
#include <algorithm>
#include <cmath>
#include <QBuffer>
#include <QDir>
#include <QFileInfo>
#include <QCursor>
@@ -14,9 +16,167 @@
#include <QPainterPath>
#include <QWheelEvent>
#include <QKeyEvent>
#include <QJsonArray>
#include <QIODevice>
#include <QPen>
#include <QPolygonF>
namespace {
constexpr int kSamCropMargin = 32;
constexpr int kMinStrokePointsSam = 4;
constexpr int kMinStrokePointsManual = 8;
constexpr int kMaxSamPointPrompts = 32;
QVector<QPointF> snapStrokeToEdges(const QVector<QPointF>& 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<int>(static_cast<const uchar*>(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<QPointF> out;
out.reserve(strokeWorld.size());
const int r = std::max(1, searchRadiusPx);
for (const QPointF& p : strokeWorld) {
const int cx = static_cast<int>(std::round(p.x()));
const int cy = static_cast<int>(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<QPointF>& 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<int>(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<double>(cw - 1));
double y1 = clampD(tight.top() - origin.y(), 0.0, static_cast<double>(ch - 1));
double x2 = clampD(tight.right() - origin.x(), 0.0, static_cast<double>(cw - 1));
double y2 = clampD(tight.bottom() - origin.y(), 0.0, static_cast<double>(ch - 1));
if (x2 <= x1) {
x2 = std::min(static_cast<double>(cw - 1), x1 + 1.0);
}
if (y2 <= y1) {
y2 = std::min(static_cast<double>(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<QPointF>& poly) {
QPainterPath path;
if (poly.size() < 3) {
return path;
}
path.addPolygon(QPolygonF(poly));
path.closeSubpath();
return path;
}
QPointF polygonCentroid(const QVector<QPointF>& 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<double>(p0.x()) * static_cast<double>(p1.y()) -
static_cast<double>(p1.x()) * static_cast<double>(p0.y());
a2 += cross;
cx6a += (static_cast<double>(p0.x()) + static_cast<double>(p1.x())) * cross;
cy6a += (static_cast<double>(p0.y()) + static_cast<double>(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<QPointF>& 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<quint64>(row[x]);
++cnt;
}
}
if (cnt == 0) {
return 0;
}
return static_cast<int>(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<QPointF>& 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<QPointF>& 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<core::Project::Entity>& 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<core::Project::Entity>& 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<core::Project::Entity>& 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<QPointF>& 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<qreal>(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<int>(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<qreal>(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<qreal>(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<QPointF> 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);
}

View File

@@ -2,6 +2,8 @@
#include "core/domain/Project.h"
#include <QByteArray>
#include <QJsonArray>
#include <QPixmap>
#include <QPointF>
#include <QImage>
@@ -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<QPointF>& 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<QPointF>& 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<QPointF> 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;

View File

@@ -0,0 +1,110 @@
#include "editor/EntityCutoutUtils.h"
#include <algorithm>
#include <cmath>
#include <QPainter>
#include <QPolygonF>
#include <QTransform>
namespace entity_cutout {
QPainterPath pathFromWorldPolygon(const QVector<QPointF>& poly) {
QPainterPath path;
if (poly.size() < 3) {
return path;
}
path.addPolygon(QPolygonF(poly));
path.closeSubpath();
return path;
}
QPointF polygonCentroid(const QVector<QPointF>& 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<double>(p0.x()) * static_cast<double>(p1.y()) -
static_cast<double>(p1.x()) * static_cast<double>(p0.y());
a2 += cross;
cx6a += (static_cast<double>(p0.x()) + static_cast<double>(p1.x())) * cross;
cy6a += (static_cast<double>(p0.y()) + static_cast<double>(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<QPointF>& 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<QPointF>& 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

View File

@@ -0,0 +1,18 @@
#pragma once
#include <QImage>
#include <QPointF>
#include <QPainterPath>
#include <QRect>
#include <QSize>
#include <QVector>
namespace entity_cutout {
QPainterPath pathFromWorldPolygon(const QVector<QPointF>& poly);
QPointF polygonCentroid(const QVector<QPointF>& poly);
QRect clampRectToImage(const QRect& r, const QSize& size);
QImage extractEntityImage(const QImage& bg, const QVector<QPointF>& polyWorld, QPointF& outTopLeftWorld);
void applyBlackFillToBackground(QImage& bgCutout, const QVector<QPointF>& polyWorld);
} // namespace entity_cutout

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();

View File

@@ -0,0 +1,52 @@
#include "widgets/LongPressSwitchToolButton.h"
#include <QApplication>
#include <QMouseEvent>
#include <QTimer>
#include <algorithm>
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();
}
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include <QToolButton>
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;
};

View File

@@ -0,0 +1,55 @@
#include "widgets/ToolOptionPopup.h"
#include <QBoxLayout>
#include <QPushButton>
#include <QToolButton>
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<Option>& opts) {
m_options = opts;
rebuildUi();
}
void ToolOptionPopup::rebuildUi() {
QLayout* old = layout();
if (old) {
delete old;
}
auto* root = new QVBoxLayout(this);
root->setContentsMargins(8, 8, 8, 8);
root->setSpacing(6);
for (const auto& opt : m_options) {
auto* b = new QPushButton(opt.text, this);
b->setFlat(true);
connect(b, &QPushButton::clicked, this, [this, id = opt.id]() {
emit optionChosen(id);
close();
});
root->addWidget(b);
}
}
void ToolOptionPopup::popupNearToolButton(QToolButton* btn) {
if (!btn) {
return;
}
if (!layout()) {
rebuildUi();
}
adjustSize();
const QPoint g = btn->mapToGlobal(QPoint(btn->width(), 0));
move(g.x() + 6, g.y());
show();
raise();
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include <QFrame>
#include <QString>
#include <QVector>
class QToolButton;
/// 可复用的“按钮弹出选项面板”Qt::Popup用于同一工具按钮选择子模式。
class ToolOptionPopup final : public QFrame {
Q_OBJECT
public:
struct Option {
int id = 0;
QString text;
};
explicit ToolOptionPopup(QWidget* parent = nullptr);
void setOptions(const QVector<Option>& opts);
void popupNearToolButton(QToolButton* btn);
signals:
void optionChosen(int id);
private:
void rebuildUi();
private:
QVector<Option> m_options;
};