添加模型分割
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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_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:
|
private:
|
||||||
QNetworkAccessManager* m_nam = nullptr;
|
QNetworkAccessManager* m_nam = nullptr;
|
||||||
QUrl m_baseUrl;
|
QUrl m_baseUrl;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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/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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 "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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成稳定且不重复的 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) {
|
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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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():
|
||||||
|
"""懒加载 SamPredictor(vit_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,) int,1=前景点,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 图 base64(PNG/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
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user