添加模型分割
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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_prompt,JSON 响应由调用方解析(success / contour / error)。
|
||||
QNetworkReply* segmentSamPromptAsync(
|
||||
const QByteArray& cropRgbPngBytes,
|
||||
const QByteArray& overlayPngBytes,
|
||||
const QJsonArray& pointCoords,
|
||||
const QJsonArray& pointLabels,
|
||||
const QJsonArray& boxXyxy,
|
||||
QString* outImmediateError = nullptr);
|
||||
|
||||
private:
|
||||
QNetworkAccessManager* m_nam = nullptr;
|
||||
QUrl m_baseUrl;
|
||||
|
||||
@@ -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
|
||||
|
||||
56
client/gui/dialogs/EntityFinalizeDialog.cpp
Normal file
56
client/gui/dialogs/EntityFinalizeDialog.cpp
Normal 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;
|
||||
}
|
||||
|
||||
24
client/gui/dialogs/EntityFinalizeDialog.h
Normal file
24
client/gui/dialogs/EntityFinalizeDialog.h
Normal 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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
110
client/gui/editor/EntityCutoutUtils.cpp
Normal file
110
client/gui/editor/EntityCutoutUtils.cpp
Normal 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
|
||||
18
client/gui/editor/EntityCutoutUtils.h
Normal file
18
client/gui/editor/EntityCutoutUtils.h
Normal 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
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成稳定且不重复的 id:entity-<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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
52
client/gui/widgets/LongPressSwitchToolButton.cpp
Normal file
52
client/gui/widgets/LongPressSwitchToolButton.cpp
Normal 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();
|
||||
}
|
||||
}
|
||||
32
client/gui/widgets/LongPressSwitchToolButton.h
Normal file
32
client/gui/widgets/LongPressSwitchToolButton.h
Normal 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;
|
||||
};
|
||||
55
client/gui/widgets/ToolOptionPopup.cpp
Normal file
55
client/gui/widgets/ToolOptionPopup.cpp
Normal 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();
|
||||
}
|
||||
|
||||
32
client/gui/widgets/ToolOptionPopup.h
Normal file
32
client/gui/widgets/ToolOptionPopup.h
Normal 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user