添加模型分割

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 "net/ModelServerClient.h"
#include <QEventLoop> #include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
@@ -49,6 +50,43 @@ QNetworkReply* ModelServerClient::computeDepthPng8Async(const QByteArray& imageB
return m_nam->post(req, body); 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( bool ModelServerClient::computeDepthPng8(
const QByteArray& imageBytes, const QByteArray& imageBytes,
QByteArray& outPngBytes, QByteArray& outPngBytes,

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <QByteArray> #include <QByteArray>
#include <QJsonArray>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
@@ -27,6 +28,15 @@ public:
// 返回 nullptr 表示参数/URL 非法导致无法发起。 // 返回 nullptr 表示参数/URL 非法导致无法发起。
QNetworkReply* computeDepthPng8Async(const QByteArray& imageBytes, QString* outImmediateError = nullptr); 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: private:
QNetworkAccessManager* m_nam = nullptr; QNetworkAccessManager* m_nam = nullptr;
QUrl m_baseUrl; QUrl m_baseUrl;

View File

@@ -4,13 +4,17 @@ set(GUI_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
set(GUI_SOURCES set(GUI_SOURCES
${GUI_ROOT}/app/main.cpp ${GUI_ROOT}/app/main.cpp
${GUI_ROOT}/main_window/MainWindow.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}/main_window/RecentProjectHistory.cpp
${GUI_ROOT}/dialogs/AboutWindow.cpp ${GUI_ROOT}/dialogs/AboutWindow.cpp
${GUI_ROOT}/dialogs/ImageCropDialog.cpp ${GUI_ROOT}/dialogs/ImageCropDialog.cpp
${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp ${GUI_ROOT}/dialogs/FrameAnimationDialog.cpp
${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp ${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp
${GUI_ROOT}/dialogs/EntityFinalizeDialog.cpp
${GUI_ROOT}/dialogs/EntityIntroPopup.cpp ${GUI_ROOT}/dialogs/EntityIntroPopup.cpp
${GUI_ROOT}/editor/EditorCanvas.cpp ${GUI_ROOT}/editor/EditorCanvas.cpp
${GUI_ROOT}/editor/EntityCutoutUtils.cpp
${GUI_ROOT}/params/ParamControls.cpp ${GUI_ROOT}/params/ParamControls.cpp
${GUI_ROOT}/props/BackgroundPropertySection.cpp ${GUI_ROOT}/props/BackgroundPropertySection.cpp
${GUI_ROOT}/props/EntityPropertySection.cpp ${GUI_ROOT}/props/EntityPropertySection.cpp
@@ -19,13 +23,17 @@ set(GUI_SOURCES
set(GUI_HEADERS set(GUI_HEADERS
${GUI_ROOT}/main_window/MainWindow.h ${GUI_ROOT}/main_window/MainWindow.h
${GUI_ROOT}/widgets/LongPressSwitchToolButton.h
${GUI_ROOT}/widgets/ToolOptionPopup.h
${GUI_ROOT}/main_window/RecentProjectHistory.h ${GUI_ROOT}/main_window/RecentProjectHistory.h
${GUI_ROOT}/dialogs/AboutWindow.h ${GUI_ROOT}/dialogs/AboutWindow.h
${GUI_ROOT}/dialogs/ImageCropDialog.h ${GUI_ROOT}/dialogs/ImageCropDialog.h
${GUI_ROOT}/dialogs/FrameAnimationDialog.h ${GUI_ROOT}/dialogs/FrameAnimationDialog.h
${GUI_ROOT}/dialogs/CancelableTaskDialog.h ${GUI_ROOT}/dialogs/CancelableTaskDialog.h
${GUI_ROOT}/dialogs/EntityFinalizeDialog.h
${GUI_ROOT}/dialogs/EntityIntroPopup.h ${GUI_ROOT}/dialogs/EntityIntroPopup.h
${GUI_ROOT}/editor/EditorCanvas.h ${GUI_ROOT}/editor/EditorCanvas.h
${GUI_ROOT}/editor/EntityCutoutUtils.h
${GUI_ROOT}/params/ParamControls.h ${GUI_ROOT}/params/ParamControls.h
${GUI_ROOT}/props/BackgroundPropertySection.h ${GUI_ROOT}/props/BackgroundPropertySection.h
${GUI_ROOT}/props/EntityPropertySection.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/EditorCanvas.h"
#include "editor/EntityCutoutUtils.h"
#include "core/animation/AnimationSampling.h" #include "core/animation/AnimationSampling.h"
#include "core/depth/DepthService.h" #include "core/depth/DepthService.h"
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <QBuffer>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QCursor> #include <QCursor>
@@ -14,9 +16,167 @@
#include <QPainterPath> #include <QPainterPath>
#include <QWheelEvent> #include <QWheelEvent>
#include <QKeyEvent> #include <QKeyEvent>
#include <QJsonArray>
#include <QIODevice>
#include <QPen>
#include <QPolygonF>
namespace { 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) { void drawCheckerboard(QPainter& p, const QRect& r) {
// 轻量级棋盘格,让透明/纯色背景也有参照 // 轻量级棋盘格,让透明/纯色背景也有参照
const int cell = 16; 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) { QRectF transformedRectByScaleAndTranslate(const QRectF& r, const QPointF& center, double scaleRatio, const QPointF& delta) {
if (r.isNull()) { if (r.isNull()) {
return r.translated(delta); return r.translated(delta);
@@ -99,53 +224,6 @@ QRectF transformedRectByScaleAndTranslate(const QRectF& r, const QPointF& center
return QRectF(QPointF(minX, minY), QPointF(maxX, maxY)); 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) { int sampleDepthAtPoint(const QImage& depth8, const QPointF& worldPos) {
if (depth8.isNull() || depth8.format() != QImage::Format_Grayscale8) { if (depth8.isNull() || depth8.format() != QImage::Format_Grayscale8) {
return 0; return 0;
@@ -174,56 +252,6 @@ double distanceScaleFromDepth01(double depth01, double calibMult) {
return raw; 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 { struct GizmoHit {
EditorCanvas::DragMode mode = EditorCanvas::DragMode::None; 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) { for (const auto& lp : e.polygonLocal) {
polyTmp.push_back(originWorld + lp); 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; const int z = (!m_depthImage8.isNull()) ? sampleDepthAtPoint(m_depthImage8, cTmp) : e.depth;
v.depth = z; v.depth = z;
@@ -354,7 +382,7 @@ void EditorCanvas::setEntities(const QVector<core::Project::Entity>& entities, c
// 贴图按 origin 缩放 // 贴图按 origin 缩放
v.imageTopLeft = originWorld + (e.imageTopLeftWorld - e.originWorld) * scale; 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.rect = v.pathWorld.boundingRect();
v.color = QColor(255, 120, 0, 70); 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) { if (m_selectedEntity >= 0) {
const auto& ent = m_entities[m_selectedEntity]; const auto& ent = m_entities[m_selectedEntity];
const QPointF origin = 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); emit selectedEntityChanged(true, ent.id, ent.depth, origin);
} else if (!prevSelectedId.isEmpty()) { } else if (!prevSelectedId.isEmpty()) {
emit selectedEntityChanged(false, QString(), 0, QPointF()); emit selectedEntityChanged(false, QString(), 0, QPointF());
@@ -437,7 +465,7 @@ QPointF EditorCanvas::selectedEntityCentroidWorld() const {
} }
const auto& ent = m_entities[m_selectedEntity]; const auto& ent = m_entities[m_selectedEntity];
if (!ent.polygonWorld.isEmpty()) { if (!ent.polygonWorld.isEmpty()) {
return polygonCentroid(ent.polygonWorld); return entity_cutout::polygonCentroid(ent.polygonWorld);
} }
return ent.rect.center(); 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); const QSizeF sz(ent.image.width() * ent.visualScale, ent.image.height() * ent.visualScale);
bb = QRectF(ent.imageTopLeft, sz); bb = QRectF(ent.imageTopLeft, sz);
} else if (!ent.polygonWorld.isEmpty()) { } else if (!ent.polygonWorld.isEmpty()) {
bb = pathFromWorldPolygon(ent.polygonWorld).boundingRect(); bb = entity_cutout::pathFromWorldPolygon(ent.polygonWorld).boundingRect();
} else { } else {
bb = ent.rect; bb = ent.rect;
} }
@@ -575,7 +603,7 @@ void EditorCanvas::selectEntityById(const QString& id) {
m_selectedEntity = i; m_selectedEntity = i;
const auto& ent = m_entities[i]; const auto& ent = m_entities[i];
const QPointF origin = 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); emit selectedEntityChanged(true, ent.id, ent.depth, origin);
update(); update();
return; return;
@@ -635,6 +663,58 @@ void EditorCanvas::setTool(Tool tool) {
update(); 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() { void EditorCanvas::resetView() {
m_scale = 1.0; m_scale = 1.0;
m_pan = QPointF(0, 0); m_pan = QPointF(0, 0);
@@ -768,7 +848,7 @@ int EditorCanvas::hitTestEntity(const QPointF& worldPos) const {
continue; continue;
} }
if (!ent.polygonWorld.isEmpty()) { if (!ent.polygonWorld.isEmpty()) {
const QPainterPath path = pathFromWorldPolygon(ent.polygonWorld); const QPainterPath path = entity_cutout::pathFromWorldPolygon(ent.polygonWorld);
if (path.contains(worldPos)) { if (path.contains(worldPos)) {
return static_cast<int>(idx); return static_cast<int>(idx);
} }
@@ -829,7 +909,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) {
m_bgImageCutout = m_bgImage; m_bgImageCutout = m_bgImage;
for (const auto& ent : m_entities) { for (const auto& ent : m_entities) {
if (!ent.cutoutPolygonWorld.isEmpty() && !m_bgImageCutout.isNull()) { 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()); 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(); p.restore();
// 坐标轴/刻度:绘制在画布最外层,背景越界时贴边显示 // 坐标轴/刻度:绘制在画布最外层,背景越界时贴边显示
@@ -1090,7 +1185,7 @@ void EditorCanvas::paintEvent(QPaintEvent* e) {
if (isDragPreview) { if (isDragPreview) {
originWorld = m_dragCentroidBase + m_dragDelta; originWorld = m_dragCentroidBase + m_dragDelta;
} else if (!ent.polygonWorld.isEmpty()) { } else if (!ent.polygonWorld.isEmpty()) {
originWorld = polygonCentroid(ent.polygonWorld); originWorld = entity_cutout::polygonCentroid(ent.polygonWorld);
} }
const QPointF originView = worldToView(originWorld); const QPointF originView = worldToView(originWorld);
@@ -1155,7 +1250,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) {
if (hit >= 0) { if (hit >= 0) {
const auto& ent = m_entities[hit]; const auto& ent = m_entities[hit];
const QPointF cWorld = 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); const QPointF anchorView = worldToView(cWorld);
beginPresentationZoomTowardEntity(hit); beginPresentationZoomTowardEntity(hit);
emit presentationEntityIntroRequested(ent.id, anchorView); 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_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_dragging = true;
m_drawingEntity = true; m_drawingEntity = true;
m_draggingEntity = false; m_draggingEntity = false;
@@ -1207,7 +1329,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) {
if (isDragPreview) { if (isDragPreview) {
originWorld = m_dragCentroidBase + m_dragDelta; originWorld = m_dragCentroidBase + m_dragDelta;
} else if (!ent.polygonWorld.isEmpty()) { } else if (!ent.polygonWorld.isEmpty()) {
originWorld = polygonCentroid(ent.polygonWorld); originWorld = entity_cutout::polygonCentroid(ent.polygonWorld);
} }
const QPointF originView = worldToView(originWorld); const QPointF originView = worldToView(originWorld);
const GizmoHit gh = hitTestGizmo(e->position(), originView); const GizmoHit gh = hitTestGizmo(e->position(), originView);
@@ -1220,7 +1342,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) {
// 为了统一复用 move 逻辑:初始化 rect 与基准点 // 为了统一复用 move 逻辑:初始化 rect 与基准点
const QRectF r = m_entities[m_selectedEntity].rect.isNull() && !m_entities[m_selectedEntity].polygonWorld.isEmpty() 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;
m_entities[m_selectedEntity].rect = r; m_entities[m_selectedEntity].rect = r;
m_entityDragOffsetOriginWorld = viewToWorld(e->position()) - m_entities[m_selectedEntity].animatedOriginWorld; 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_dragPolyBase = m_entities[m_selectedEntity].polygonWorld;
m_dragPathBase = m_entities[m_selectedEntity].pathWorld; m_dragPathBase = m_entities[m_selectedEntity].pathWorld;
m_dragCentroidBase = m_dragCentroidBase =
m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : polygonCentroid(m_dragPolyBase); m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase);
update(); update();
return; return;
} }
@@ -1249,7 +1371,7 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) {
m_dragMode = DragMode::Free; m_dragMode = DragMode::Free;
emit entityDragActiveChanged(true); emit entityDragActiveChanged(true);
const QRectF r = m_entities[hit].rect.isNull() && !m_entities[hit].polygonWorld.isEmpty() 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;
m_entities[hit].rect = r; m_entities[hit].rect = r;
m_entityDragOffsetOriginWorld = worldPos - m_entities[hit].animatedOriginWorld; m_entityDragOffsetOriginWorld = worldPos - m_entities[hit].animatedOriginWorld;
@@ -1265,8 +1387,8 @@ void EditorCanvas::mousePressEvent(QMouseEvent* e) {
m_dragPolyBase = m_entities[hit].polygonWorld; m_dragPolyBase = m_entities[hit].polygonWorld;
m_dragPathBase = m_entities[hit].pathWorld; m_dragPathBase = m_entities[hit].pathWorld;
m_dragCentroidBase = m_dragCentroidBase =
m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : polygonCentroid(m_dragPolyBase); m_dragPolyBase.isEmpty() ? m_dragRectBase.center() : entity_cutout::polygonCentroid(m_dragPolyBase);
const QPointF origin = !m_entities[hit].polygonWorld.isEmpty() ? polygonCentroid(m_entities[hit].polygonWorld) const QPointF origin = !m_entities[hit].polygonWorld.isEmpty() ? entity_cutout::polygonCentroid(m_entities[hit].polygonWorld)
: m_entities[hit].rect.center(); : m_entities[hit].rect.center();
emit selectedEntityChanged(true, m_entities[hit].id, m_entities[hit].depth, origin); emit selectedEntityChanged(true, m_entities[hit].id, m_entities[hit].depth, origin);
update(); update();
@@ -1326,6 +1448,21 @@ void EditorCanvas::mouseMoveEvent(QMouseEvent* e) {
const QPointF deltaView = cur - m_lastMouseView; const QPointF deltaView = cur - m_lastMouseView;
m_lastMouseView = cur; 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) { if (m_tool == Tool::CreateEntity && m_drawingEntity) {
const QPointF w = viewToWorld(cur); const QPointF w = viewToWorld(cur);
if (m_strokeWorld.isEmpty()) { if (m_strokeWorld.isEmpty()) {
@@ -1442,40 +1579,27 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
m_drawingEntity = false; m_drawingEntity = false;
updateCursor(); updateCursor();
if (m_strokeWorld.size() >= 8) { if (m_entityCreateSegmentMode == EntityCreateSegmentMode::Manual) {
// 闭合:末点连回首点(绘制时 polygon 自动闭合) if (m_strokeWorld.size() >= kMinStrokePointsManual) {
core::Project::Entity ent; setPendingEntityPolygonWorld(m_strokeWorld);
// 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);
} }
} else if (m_entityCreateSegmentMode == EntityCreateSegmentMode::Snap) {
// 确保深度加载(用于平均深度) if (m_strokeWorld.size() >= kMinStrokePointsManual) {
if (m_depthDirty) { ensurePixmapLoaded();
m_depthDirty = false; const QVector<QPointF> snapped = snapStrokeToEdges(m_strokeWorld, m_bgImage, 6);
QImage img(m_depthAbsPath); setPendingEntityPolygonWorld(snapped);
m_depthImage8 = img.isNull() ? QImage() : img.convertToFormat(QImage::Format_Grayscale8);
} }
const QPointF c = polygonCentroid(ent.cutoutPolygonWorld); } else if (m_strokeWorld.size() >= kMinStrokePointsSam) {
ent.depth = m_depthImage8.isNull() ? 0 : sampleDepthAtPoint(m_depthImage8, c); ensurePixmapLoaded();
{ QByteArray cropPng;
const double ds01 = depthToScale01(ent.depth); QByteArray ovPng;
ent.distanceScaleCalibMult = 0.5 + ds01 * 1.0; 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(); m_strokeWorld.clear();
update(); update();
@@ -1497,12 +1621,17 @@ void EditorCanvas::mouseReleaseEvent(QMouseEvent* e) {
} }
if (!sentMove && !ent.id.isEmpty()) { if (!sentMove && !ent.id.isEmpty()) {
const QPointF origin = 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); emit selectedEntityChanged(true, ent.id, ent.depth, origin);
} }
} }
m_dragging = false; m_dragging = false;
if (m_pendingDragging && e->button() == Qt::LeftButton) {
m_pendingDragging = false;
m_pendingDragWhole = false;
m_pendingDragVertex = -1;
}
if (m_draggingEntity) { if (m_draggingEntity) {
emit entityDragActiveChanged(false); emit entityDragActiveChanged(false);
} }
@@ -1546,6 +1675,18 @@ void EditorCanvas::keyPressEvent(QKeyEvent* e) {
e->accept(); e->accept();
return; 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); QWidget::keyPressEvent(e);
} }

View File

@@ -2,6 +2,8 @@
#include "core/domain/Project.h" #include "core/domain/Project.h"
#include <QByteArray>
#include <QJsonArray>
#include <QPixmap> #include <QPixmap>
#include <QPointF> #include <QPointF>
#include <QImage> #include <QImage>
@@ -33,6 +35,17 @@ public:
void setTool(Tool tool); void setTool(Tool tool);
Tool tool() const { return m_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 resetView();
void zoomToFit(); void zoomToFit();
@@ -81,6 +94,16 @@ signals:
void hoveredWorldPosDepthChanged(const QPointF& worldPos, int depthZ); void hoveredWorldPosDepthChanged(const QPointF& worldPos, int depthZ);
void selectedEntityChanged(bool hasSelection, const QString& id, int depth, const QPointF& originWorld); void selectedEntityChanged(bool hasSelection, const QString& id, int depth, const QPointF& originWorld);
void requestAddEntity(const core::Project::Entity& entity, const QImage& image); 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 requestMoveEntity(const QString& id, const QPointF& delta);
void entityDragActiveChanged(bool on); void entityDragActiveChanged(bool on);
void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld); void selectedEntityPreviewChanged(const QString& id, int depth, const QPointF& originWorld);
@@ -106,6 +129,8 @@ private:
QPointF viewToWorld(const QPointF& v) const; QPointF viewToWorld(const QPointF& v) const;
QPointF worldToView(const QPointF& w) const; QPointF worldToView(const QPointF& w) const;
QRectF worldRectOfBackground() const; QRectF worldRectOfBackground() const;
bool isPointNearPendingVertex(const QPointF& worldPos, int* outIndex) const;
bool pendingPolygonContains(const QPointF& worldPos) const;
void tickPresentationZoomAnimation(); void tickPresentationZoomAnimation();
void tickPresentationHoverAnimation(); void tickPresentationHoverAnimation();
@@ -159,6 +184,7 @@ private:
bool m_presentationPreviewMode = false; bool m_presentationPreviewMode = false;
Tool m_tool = Tool::Move; Tool m_tool = Tool::Move;
EntityCreateSegmentMode m_entityCreateSegmentMode = EntityCreateSegmentMode::Manual;
qreal m_scale = 1.0; qreal m_scale = 1.0;
QPointF m_pan; // world 原点对应的 view 坐标偏移view = world*scale + pan QPointF m_pan; // world 原点对应的 view 坐标偏移view = world*scale + pan
@@ -193,6 +219,13 @@ private:
int m_currentFrame = 0; 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_presZoomTimer = nullptr;
QTimer* m_presHoverTimer = 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 "main_window/MainWindow.h"
#include "dialogs/AboutWindow.h" #include "dialogs/AboutWindow.h"
#include "dialogs/CancelableTaskDialog.h" #include "dialogs/CancelableTaskDialog.h"
#include "dialogs/EntityFinalizeDialog.h"
#include "editor/EditorCanvas.h" #include "editor/EditorCanvas.h"
#include "editor/EntityCutoutUtils.h"
#include "dialogs/ImageCropDialog.h" #include "dialogs/ImageCropDialog.h"
#include "core/domain/EntityIntro.h" #include "core/domain/EntityIntro.h"
#include "core/net/ModelServerClient.h" #include "core/net/ModelServerClient.h"
#include "widgets/ToolOptionPopup.h"
#include "params/ParamControls.h" #include "params/ParamControls.h"
#include "props/BackgroundPropertySection.h" #include "props/BackgroundPropertySection.h"
#include "props/EntityPropertySection.h" #include "props/EntityPropertySection.h"
@@ -55,8 +58,13 @@
#include <QStyle> #include <QStyle>
#include <QUrl> #include <QUrl>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest>
#include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonValue>
#include <QSet>
#include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
#include <QSet> #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() { void MainWindow::updateStatusBarText() {
// 坐标系:图片左上角为 (0,0)单位为像素world 坐标与背景像素一致) // 坐标系:图片左上角为 (0,0)单位为像素world 坐标与背景像素一致)
if (m_hasSelectedEntity) { if (m_hasSelectedEntity) {
@@ -1734,6 +1760,30 @@ void MainWindow::rebuildCentralPages() {
polishCompactToolButton(m_btnCreateEntity, 40); polishCompactToolButton(m_btnCreateEntity, 40);
toolLayout->addWidget(m_btnCreateEntity, 0, Qt::AlignHCenter); toolLayout->addWidget(m_btnCreateEntity, 0, Qt::AlignHCenter);
group->addButton(m_btnCreateEntity, static_cast<int>(EditorCanvas::Tool::CreateEntity)); 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 = new QToolButton(m_floatingToolDock);
m_btnToggleDepthOverlay->setCheckable(true); m_btnToggleDepthOverlay->setCheckable(true);
@@ -1845,6 +1895,288 @@ void MainWindow::rebuildCentralPages() {
refreshProjectTree(); refreshProjectTree();
updateUiEnabledState(); 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) { connect(m_editorCanvas, &EditorCanvas::requestMoveEntity, this, [this](const QString& id, const QPointF& delta) {
// 动画编辑:拖动即写入当前位置关键帧,避免被既有关键帧插值“拉回去” // 动画编辑:拖动即写入当前位置关键帧,避免被既有关键帧插值“拉回去”
const bool autoKey = true; const bool autoKey = true;
@@ -2128,7 +2460,7 @@ void MainWindow::refreshDopeSheet() {
void MainWindow::showBackgroundContextMenu(const QPoint& globalPos) { void MainWindow::showBackgroundContextMenu(const QPoint& globalPos) {
QMenu menu(this); QMenu menu(this);
QAction* actComputeDepth = menu.addAction(QStringLiteral("计算深度(后端)")); QAction* actComputeDepth = menu.addAction(QStringLiteral("计算深度"));
actComputeDepth->setEnabled(m_workspace.isOpen() && m_workspace.hasBackground()); actComputeDepth->setEnabled(m_workspace.isOpen() && m_workspace.hasBackground());
QAction* chosen = menu.exec(globalPos); QAction* chosen = menu.exec(globalPos);

View File

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

View File

@@ -10,13 +10,134 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Callable from typing import Callable, List
import sys import sys
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
# -----------------------------
# SAM 交互式SamPredictor与 AutomaticMaskGenerator 分流缓存
# -----------------------------
_sam_prompt_predictor = None
def get_sam_prompt_predictor():
"""懒加载 SamPredictorvit_h用于点/框提示分割。"""
global _sam_prompt_predictor
if _sam_prompt_predictor is not None:
return _sam_prompt_predictor
sam_root = _ensure_sam_on_path()
ckpt_path = _download_sam_checkpoint_if_needed(sam_root)
from segment_anything import sam_model_registry, SamPredictor # type: ignore[import]
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
sam = sam_model_registry["vit_h"](checkpoint=str(ckpt_path)).to(device)
_sam_prompt_predictor = SamPredictor(sam)
return _sam_prompt_predictor
def run_sam_prompt(
image_rgb: np.ndarray,
point_coords: np.ndarray,
point_labels: np.ndarray,
box_xyxy: np.ndarray | None = None,
) -> np.ndarray:
"""
使用点提示(必选)与可选矩形框在 RGB 图上分割。
参数均为原图像素坐标:
- point_coords: (N, 2) float
- point_labels: (N,) int1=前景点0=背景
- box_xyxy: (4,) [x1, y1, x2, y2] 或 None
返回bool 掩膜 (H, W)
"""
if image_rgb.dtype != np.uint8:
image_rgb = np.ascontiguousarray(image_rgb.astype(np.uint8))
else:
image_rgb = np.ascontiguousarray(image_rgb)
if image_rgb.ndim != 3 or image_rgb.shape[2] != 3:
raise ValueError(f"image_rgb 期望 HWC RGB uint8当前 shape={image_rgb.shape}")
predictor = get_sam_prompt_predictor()
predictor.set_image(image_rgb)
pc = np.asarray(point_coords, dtype=np.float32)
pl = np.asarray(point_labels, dtype=np.int64)
if pc.ndim != 2 or pc.shape[1] != 2:
raise ValueError("point_coords 应为 Nx2")
if pl.ndim != 1 or pl.shape[0] != pc.shape[0]:
raise ValueError("point_labels 长度须与 point_coords 行数一致")
box_arg = None
if box_xyxy is not None:
b = np.asarray(box_xyxy, dtype=np.float32).reshape(4)
box_arg = b
masks, scores, _low = predictor.predict(
point_coords=pc,
point_labels=pl,
box=box_arg,
multimask_output=True,
)
# masks: C x H x W
best = int(np.argmax(scores))
m = masks[best]
if m.dtype != np.bool_:
m = m > 0.5
return m
def mask_to_contour_xy(
mask_bool: np.ndarray,
epsilon_px: float = 2.0,
) -> List[List[float]]:
"""
从二值掩膜提取最大外轮廓,并用 Douglas-Peucker 简化。
返回 [[x, y], ...](裁剪图坐标系)。
"""
u8 = (np.asarray(mask_bool, dtype=np.uint8) * 255).astype(np.uint8)
try:
import cv2 # type: ignore[import]
contours, _h = cv2.findContours(u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
except Exception:
contours = None
if not contours:
# 无 OpenCV 时的极简回退:外接矩形
ys, xs = np.nonzero(mask_bool)
if ys.size == 0:
return []
x0, x1 = int(xs.min()), int(xs.max())
y0, y1 = int(ys.min()), int(ys.max())
return [
[float(x0), float(y0)],
[float(x1), float(y0)],
[float(x1), float(y1)],
[float(x0), float(y1)],
]
cnt = max(contours, key=cv2.contourArea)
if cv2.contourArea(cnt) < 1.0:
return []
peri = cv2.arcLength(cnt, True)
eps = max(epsilon_px, 0.001 * peri)
approx = cv2.approxPolyDP(cnt, eps, True)
out: List[List[float]] = []
for p in approx:
out.append([float(p[0][0]), float(p[0][1])])
if len(out) >= 3 and (out[0][0] != out[-1][0] or out[0][1] != out[-1][1]):
pass # 保持开放折线,前端可自行闭合
return out
_THIS_DIR = Path(__file__).resolve().parent _THIS_DIR = Path(__file__).resolve().parent

View File

@@ -18,7 +18,13 @@ from PIL import Image, ImageDraw
from config_loader import load_app_config, get_depth_backend_from_app from config_loader import load_app_config, get_depth_backend_from_app
from model.Depth.depth_loader import UnifiedDepthConfig, DepthBackend, build_depth_predictor from model.Depth.depth_loader import UnifiedDepthConfig, DepthBackend, build_depth_predictor
from model.Seg.seg_loader import UnifiedSegConfig, SegBackend, build_seg_predictor from model.Seg.seg_loader import (
UnifiedSegConfig,
SegBackend,
build_seg_predictor,
mask_to_contour_xy,
run_sam_prompt,
)
from model.Inpaint.inpaint_loader import UnifiedInpaintConfig, InpaintBackend, build_inpaint_predictor from model.Inpaint.inpaint_loader import UnifiedInpaintConfig, InpaintBackend, build_inpaint_predictor
from model.Animation.animation_loader import ( from model.Animation.animation_loader import (
UnifiedAnimationConfig, UnifiedAnimationConfig,
@@ -47,6 +53,28 @@ class SegmentRequest(ImageInput):
pass pass
class SamPromptSegmentRequest(BaseModel):
image_b64: str = Field(..., description="裁剪后的 RGB 图 base64PNG/JPG")
overlay_b64: Optional[str] = Field(
None,
description="与裁剪同尺寸的标记叠加 PNG base64可选当前用于校验尺寸一致",
)
point_coords: list[list[float]] = Field(
...,
description="裁剪坐标系下的提示点 [[x,y], ...]",
)
point_labels: list[int] = Field(
...,
description="与 point_coords 等长1=前景0=背景",
)
box_xyxy: list[float] = Field(
...,
description="裁剪内笔画紧包围盒 [x1,y1,x2,y2](像素)",
min_length=4,
max_length=4,
)
class InpaintRequest(ImageInput): class InpaintRequest(ImageInput):
prompt: Optional[str] = Field("", description="补全 prompt") prompt: Optional[str] = Field("", description="补全 prompt")
strength: float = Field(0.8, ge=0.0, le=1.0) strength: float = Field(0.8, ge=0.0, le=1.0)
@@ -287,6 +315,54 @@ def segment(req: SegmentRequest) -> Dict[str, Any]:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@app.post("/segment/sam_prompt")
def segment_sam_prompt(req: SamPromptSegmentRequest) -> Dict[str, Any]:
"""
交互式 SAM裁剪图 + 点/框提示,返回掩膜外轮廓点列(裁剪像素坐标)。
"""
try:
pil = _b64_to_pil_image(req.image_b64).convert("RGB")
rgb = np.array(pil, dtype=np.uint8)
h, w = rgb.shape[0], rgb.shape[1]
if req.overlay_b64:
ov = _b64_to_pil_image(req.overlay_b64)
if ov.size != (w, h):
return {
"success": False,
"error": f"overlay 尺寸 {ov.size} 与 image {w}x{h} 不一致",
"contour": [],
}
if len(req.point_coords) != len(req.point_labels):
return {
"success": False,
"error": "point_coords 与 point_labels 长度不一致",
"contour": [],
}
if len(req.point_coords) < 1:
return {"success": False, "error": "至少需要一个提示点", "contour": []}
pc = np.array(req.point_coords, dtype=np.float32)
if pc.ndim != 2 or pc.shape[1] != 2:
return {"success": False, "error": "point_coords 每项须为 [x,y]", "contour": []}
pl = np.array(req.point_labels, dtype=np.int64)
box = np.array(req.box_xyxy, dtype=np.float32)
mask = run_sam_prompt(rgb, pc, pl, box_xyxy=box)
if not np.any(mask):
return {"success": False, "error": "SAM 未产生有效掩膜", "contour": []}
contour = mask_to_contour_xy(mask, epsilon_px=2.0)
if len(contour) < 3:
return {"success": False, "error": "轮廓点数不足", "contour": []}
return {"success": True, "contour": contour, "error": None}
except Exception as e:
return {"success": False, "error": str(e), "contour": []}
# ----------------------------- # -----------------------------
# Inpaint # Inpaint
# ----------------------------- # -----------------------------