From 0710090b4d1b9455f8b51571c74caa147167f3aa Mon Sep 17 00:00:00 2001 From: DingVero Date: Thu, 9 Apr 2026 23:38:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=A8=A1=E5=9E=8B=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E7=A9=BA=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/core/net/ModelServerClient.cpp | 41 +++ client/core/net/ModelServerClient.h | 10 + client/core/workspace/ProjectWorkspace.cpp | 63 ++++ client/core/workspace/ProjectWorkspace.h | 3 + client/gui/CMakeLists.txt | 2 + client/gui/dialogs/BlackholeResolveDialog.cpp | 45 ++- client/gui/dialogs/BlackholeResolveDialog.h | 5 + client/gui/dialogs/InpaintPreviewDialog.cpp | 68 ++++ client/gui/dialogs/InpaintPreviewDialog.h | 22 ++ client/gui/main_window/MainWindow.cpp | 324 +++++++++++++++++- python_server/server.py | 7 +- 11 files changed, 582 insertions(+), 8 deletions(-) create mode 100644 client/gui/dialogs/InpaintPreviewDialog.cpp create mode 100644 client/gui/dialogs/InpaintPreviewDialog.h diff --git a/client/core/net/ModelServerClient.cpp b/client/core/net/ModelServerClient.cpp index e765f76..ffe2017 100644 --- a/client/core/net/ModelServerClient.cpp +++ b/client/core/net/ModelServerClient.cpp @@ -87,6 +87,47 @@ QNetworkReply* ModelServerClient::segmentSamPromptAsync( return m_nam->post(req, body); } +QNetworkReply* ModelServerClient::inpaintAsync( + const QByteArray& cropRgbPngBytes, + const QByteArray& maskPngBytes, + const QString& prompt, + const QString& negativePrompt, + double strength, + int maxSide, + 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; + } + if (maskPngBytes.isEmpty()) { + if (outImmediateError) *outImmediateError = QStringLiteral("Mask 为空。"); + return nullptr; + } + + const QUrl url = m_baseUrl.resolved(QUrl(QStringLiteral("/inpaint"))); + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + + QJsonObject payload; + payload.insert(QStringLiteral("image_b64"), QString::fromLatin1(cropRgbPngBytes.toBase64())); + payload.insert(QStringLiteral("mask_b64"), QString::fromLatin1(maskPngBytes.toBase64())); + payload.insert(QStringLiteral("prompt"), prompt); + payload.insert(QStringLiteral("negative_prompt"), negativePrompt); + payload.insert(QStringLiteral("strength"), strength); + payload.insert(QStringLiteral("max_side"), maxSide); + + const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); + return m_nam->post(req, body); +} + bool ModelServerClient::computeDepthPng8( const QByteArray& imageBytes, QByteArray& outPngBytes, diff --git a/client/core/net/ModelServerClient.h b/client/core/net/ModelServerClient.h index 5705ee0..eb4b08e 100644 --- a/client/core/net/ModelServerClient.h +++ b/client/core/net/ModelServerClient.h @@ -37,6 +37,16 @@ public: const QJsonArray& boxXyxy, QString* outImmediateError = nullptr); + // POST /inpaint,JSON 响应由调用方解析(success / output_image_b64 / error / output_path)。 + QNetworkReply* inpaintAsync( + const QByteArray& cropRgbPngBytes, + const QByteArray& maskPngBytes, + const QString& prompt, + const QString& negativePrompt, + double strength, + int maxSide, + QString* outImmediateError = nullptr); + private: QNetworkAccessManager* m_nam = nullptr; QUrl m_baseUrl; diff --git a/client/core/workspace/ProjectWorkspace.cpp b/client/core/workspace/ProjectWorkspace.cpp index 3a6611d..c1aa648 100644 --- a/client/core/workspace/ProjectWorkspace.cpp +++ b/client/core/workspace/ProjectWorkspace.cpp @@ -1869,6 +1869,69 @@ bool ProjectWorkspace::resolveBlackholeByCopyBackground(const QString& id, const return true; } +bool ProjectWorkspace::resolveBlackholeByModelInpaint(const QString& id, const QImage& patchedBackground, + bool hideBlackholeAfterFill) { + if (m_projectDir.isEmpty() || id.isEmpty()) { + return false; + } + const QString bgAbs = backgroundAbsolutePath(); + if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { + return false; + } + if (patchedBackground.isNull()) { + return false; + } + + // 写回背景文件 + { + QImage bg = patchedBackground; + if (bg.format() != QImage::Format_ARGB32_Premultiplied) { + bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + QImageWriter writer(bgAbs); + writer.setFormat("png"); + writer.setCompression(1); + if (!writer.write(bg)) { + return false; + } + } + + // 更新实体黑洞状态 + 记录历史 + const auto before = m_project.entities(); + auto ents = before; + int hit = -1; + for (int i = 0; i < ents.size(); ++i) { + if (ents[i].id == id) { + hit = i; + break; + } + } + if (hit < 0) { + return false; + } + + ents[hit].blackholeVisible = hideBlackholeAfterFill ? false : ents[hit].blackholeVisible; + if (ents[hit].blackholeId.isEmpty()) { + ents[hit].blackholeId = QStringLiteral("blackhole-%1").arg(ents[hit].id); + } + ents[hit].blackholeResolvedBy = QStringLiteral("model_inpaint"); + + m_project.setEntities(ents); + if (!saveSingleEntityPayload(ents[hit]) || !writeIndexJsonWithoutPayloadSync()) { + m_project.setEntities(before); + return false; + } + + Operation op; + op.type = Operation::Type::SetEntities; + op.label = QStringLiteral("黑洞模型补全"); + op.beforeEntities = before; + op.afterEntities = ents; + pushOperation(op); + m_redoStack.clear(); + return true; +} + bool ProjectWorkspace::setEntityVisibilityKey(const QString& id, int frame, bool visible) { if (m_projectDir.isEmpty() || id.isEmpty() || frame < 0) { return false; diff --git a/client/core/workspace/ProjectWorkspace.h b/client/core/workspace/ProjectWorkspace.h index 80a8cf3..03b70f8 100644 --- a/client/core/workspace/ProjectWorkspace.h +++ b/client/core/workspace/ProjectWorkspace.h @@ -101,6 +101,9 @@ public: // 复制背景其他区域填充黑洞(sourceOffsetPx 以黑洞包围盒左上角为基准偏移) bool resolveBlackholeByCopyBackground(const QString& id, const QPoint& sourceOffsetPx, bool hideBlackholeAfterFill); + // 使用模型补全后的结果写回背景(patchedBackground 已包含补全贴合后的完整背景图像) + bool resolveBlackholeByModelInpaint(const QString& id, const QImage& patchedBackground, + bool hideBlackholeAfterFill); bool setEntityVisibilityKey(const QString& id, int frame, bool visible); bool removeEntityVisibilityKey(const QString& id, int frame); bool setEntityDisplayName(const QString& id, const QString& displayName); diff --git a/client/gui/CMakeLists.txt b/client/gui/CMakeLists.txt index 693ddee..a13cf7e 100644 --- a/client/gui/CMakeLists.txt +++ b/client/gui/CMakeLists.txt @@ -14,6 +14,7 @@ set(GUI_SOURCES ${GUI_ROOT}/dialogs/CancelableTaskDialog.cpp ${GUI_ROOT}/dialogs/EntityFinalizeDialog.cpp ${GUI_ROOT}/dialogs/BlackholeResolveDialog.cpp + ${GUI_ROOT}/dialogs/InpaintPreviewDialog.cpp ${GUI_ROOT}/dialogs/EntityIntroPopup.cpp ${GUI_ROOT}/editor/EditorCanvas.cpp ${GUI_ROOT}/editor/EntityCutoutUtils.cpp @@ -37,6 +38,7 @@ set(GUI_HEADERS ${GUI_ROOT}/dialogs/CancelableTaskDialog.h ${GUI_ROOT}/dialogs/EntityFinalizeDialog.h ${GUI_ROOT}/dialogs/BlackholeResolveDialog.h + ${GUI_ROOT}/dialogs/InpaintPreviewDialog.h ${GUI_ROOT}/dialogs/EntityIntroPopup.h ${GUI_ROOT}/editor/EditorCanvas.h ${GUI_ROOT}/editor/EntityCutoutUtils.h diff --git a/client/gui/dialogs/BlackholeResolveDialog.cpp b/client/gui/dialogs/BlackholeResolveDialog.cpp index bf8d261..b57e1c1 100644 --- a/client/gui/dialogs/BlackholeResolveDialog.cpp +++ b/client/gui/dialogs/BlackholeResolveDialog.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,10 @@ BlackholeResolveDialog::BlackholeResolveDialog(const QString& blackholeName, QWi m_pages->setCurrentWidget(m_pageSelect); } +QString BlackholeResolveDialog::promptText() const { + return m_promptEdit ? m_promptEdit->toPlainText().trimmed() : QString(); +} + void BlackholeResolveDialog::buildSelectPage() { m_pageSelect = new QWidget(this); auto* layout = new QVBoxLayout(m_pageSelect); @@ -61,14 +66,14 @@ void BlackholeResolveDialog::buildSelectPage() { QStringLiteral("使用原始背景"), QStringLiteral("撤销黑洞显示,恢复抠图前背景区域。"), m_pageSelect); + auto* btnModel = makeAlgoButton( + QStringLiteral("模型补全(SDXL Inpaint)"), + QStringLiteral("输入提示词,自动补全缺失区域;可预览后再决定是否接受。"), + m_pageSelect); layout->addWidget(btnCopy); layout->addWidget(btnOriginal); + layout->addWidget(btnModel); - auto* modelNote = new QLabel( - QStringLiteral("模型补全:已预留接口,本版本暂不实现。"), - m_pageSelect); - modelNote->setStyleSheet("color: palette(mid);"); - layout->addWidget(modelNote); layout->addStretch(1); auto* btns = new QDialogButtonBox(QDialogButtonBox::Cancel, m_pageSelect); @@ -81,6 +86,9 @@ void BlackholeResolveDialog::buildSelectPage() { connect(btnOriginal, &QPushButton::clicked, this, [this]() { enterAlgorithmPage(Algorithm::UseOriginalBackground); }); + connect(btnModel, &QPushButton::clicked, this, [this]() { + enterAlgorithmPage(Algorithm::ModelInpaint); + }); m_pages->addWidget(m_pageSelect); } @@ -141,8 +149,29 @@ void BlackholeResolveDialog::buildDetailPage() { oLay->addStretch(1); } + // 详情页 C:模型补全(提示词) + m_modelDetail = new QWidget(m_algoDetails); + { + auto* mLay = new QVBoxLayout(m_modelDetail); + mLay->setSpacing(8); + + auto* panel = new QFrame(m_modelDetail); + panel->setFrameShape(QFrame::StyledPanel); + auto* pLay = new QVBoxLayout(panel); + pLay->setSpacing(8); + + m_promptEdit = new QPlainTextEdit(panel); + m_promptEdit->setPlainText(QStringLiteral("This is part of a Chinese painting; please complete the background for me, following the style of the other parts.")); + m_promptEdit->setMinimumHeight(90); + pLay->addWidget(m_promptEdit); + + mLay->addWidget(panel); + mLay->addStretch(1); + } + m_algoDetails->addWidget(m_copyDetail); m_algoDetails->addWidget(m_originalDetail); + m_algoDetails->addWidget(m_modelDetail); layout->addWidget(m_algoDetails, 1); auto* btns = new QDialogButtonBox(m_pageDetail); @@ -165,10 +194,14 @@ void BlackholeResolveDialog::enterAlgorithmPage(Algorithm algo) { m_detailTitle->setText(QStringLiteral("第 2 步:复制背景其他区域")); m_detailHint->setText(QStringLiteral("准备进入画布拖动取样框模式。")); m_algoDetails->setCurrentWidget(m_copyDetail); - } else { + } else if (algo == Algorithm::UseOriginalBackground) { m_detailTitle->setText(QStringLiteral("第 2 步:使用原始背景")); m_detailHint->setText(QStringLiteral("确认后将切换为原始背景显示。")); m_algoDetails->setCurrentWidget(m_originalDetail); + } else { + m_detailTitle->setText(QStringLiteral("第 2 步:模型补全(SDXL Inpaint)")); + m_detailHint->setText(QStringLiteral("输入提示词(可选),点击应用后将生成预览。")); + m_algoDetails->setCurrentWidget(m_modelDetail); } m_pages->setCurrentWidget(m_pageDetail); } diff --git a/client/gui/dialogs/BlackholeResolveDialog.h b/client/gui/dialogs/BlackholeResolveDialog.h index d477f8b..5d19696 100644 --- a/client/gui/dialogs/BlackholeResolveDialog.h +++ b/client/gui/dialogs/BlackholeResolveDialog.h @@ -11,11 +11,13 @@ public: enum class Algorithm { CopyBackgroundRegion, UseOriginalBackground, + ModelInpaint, }; explicit BlackholeResolveDialog(const QString& blackholeName, QWidget* parent = nullptr); Algorithm selectedAlgorithm() const { return m_selectedAlgorithm; } + QString promptText() const; private: void buildSelectPage(); @@ -38,5 +40,8 @@ private: QWidget* m_copyDetail = nullptr; QWidget* m_originalDetail = nullptr; + + QWidget* m_modelDetail = nullptr; + class QPlainTextEdit* m_promptEdit = nullptr; }; diff --git a/client/gui/dialogs/InpaintPreviewDialog.cpp b/client/gui/dialogs/InpaintPreviewDialog.cpp new file mode 100644 index 0000000..87ee99a --- /dev/null +++ b/client/gui/dialogs/InpaintPreviewDialog.cpp @@ -0,0 +1,68 @@ +#include "dialogs/InpaintPreviewDialog.h" + +#include +#include +#include +#include +#include +#include +#include + +static QLabel* makeImageLabel(QWidget* parent) { + auto* lab = new QLabel(parent); + lab->setBackgroundRole(QPalette::Base); + lab->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); + lab->setScaledContents(false); + lab->setAlignment(Qt::AlignCenter); + return lab; +} + +static QScrollArea* wrapScroll(QWidget* child, QWidget* parent) { + auto* sc = new QScrollArea(parent); + sc->setWidget(child); + sc->setWidgetResizable(true); + sc->setBackgroundRole(QPalette::Dark); + return sc; +} + +InpaintPreviewDialog::InpaintPreviewDialog(const QString& title, QWidget* parent) + : QDialog(parent) { + setModal(true); + setMinimumSize(860, 520); + setWindowTitle(title); + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(8, 8, 8, 8); + root->setSpacing(8); + + m_beforeLabel = makeImageLabel(this); + m_afterLabel = makeImageLabel(this); + m_beforeScroll = wrapScroll(m_beforeLabel, this); + m_afterScroll = wrapScroll(m_afterLabel, this); + + auto* splitter = new QSplitter(Qt::Horizontal, this); + splitter->addWidget(m_beforeScroll); + splitter->addWidget(m_afterScroll); + splitter->setStretchFactor(0, 1); + splitter->setStretchFactor(1, 1); + root->addWidget(splitter, 1); + + auto* btns = new QDialogButtonBox(this); + btns->addButton(QStringLiteral("取消"), QDialogButtonBox::RejectRole); + btns->addButton(QStringLiteral("接受并写回"), QDialogButtonBox::AcceptRole); + connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject); + root->addWidget(btns); +} + +void InpaintPreviewDialog::setImages(const QImage& before, const QImage& after) { + if (m_beforeLabel) { + m_beforeLabel->setPixmap(QPixmap::fromImage(before)); + m_beforeLabel->adjustSize(); + } + if (m_afterLabel) { + m_afterLabel->setPixmap(QPixmap::fromImage(after)); + m_afterLabel->adjustSize(); + } +} + diff --git a/client/gui/dialogs/InpaintPreviewDialog.h b/client/gui/dialogs/InpaintPreviewDialog.h new file mode 100644 index 0000000..9379252 --- /dev/null +++ b/client/gui/dialogs/InpaintPreviewDialog.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +class QLabel; +class QScrollArea; + +class InpaintPreviewDialog final : public QDialog { + Q_OBJECT +public: + explicit InpaintPreviewDialog(const QString& title, QWidget* parent = nullptr); + + void setImages(const QImage& before, const QImage& after); + +private: + QLabel* m_beforeLabel = nullptr; + QLabel* m_afterLabel = nullptr; + QScrollArea* m_beforeScroll = nullptr; + QScrollArea* m_afterScroll = nullptr; +}; + diff --git a/client/gui/main_window/MainWindow.cpp b/client/gui/main_window/MainWindow.cpp index b28c29b..9355bf6 100644 --- a/client/gui/main_window/MainWindow.cpp +++ b/client/gui/main_window/MainWindow.cpp @@ -3,6 +3,7 @@ #include "dialogs/CancelableTaskDialog.h" #include "dialogs/EntityFinalizeDialog.h" #include "dialogs/BlackholeResolveDialog.h" +#include "dialogs/InpaintPreviewDialog.h" #include "editor/EditorCanvas.h" #include "editor/EntityCutoutUtils.h" #include "dialogs/ImageCropDialog.h" @@ -38,6 +39,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -3566,11 +3570,329 @@ void MainWindow::showBlackholeContextMenu(const QPoint& globalPos, const QString } statusBar()->showMessage(QStringLiteral("拖动画布中的青色取样框,松开鼠标即应用;Esc 取消")); return; - } else { + } else if (dlg.selectedAlgorithm() == BlackholeResolveDialog::Algorithm::UseOriginalBackground) { ok = m_workspace.resolveBlackholeByUseOriginalBackground(entityId); if (!ok) { QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("应用“使用原始背景”失败。")); } + } else { + // 模型补全(SDXL Inpaint):裁剪黑洞区域 -> 生成 mask -> 请求后端补全 -> 预览 -> 接受后写回背景 + const QString bgAbs = m_workspace.backgroundAbsolutePath(); + if (bgAbs.isEmpty() || !QFileInfo::exists(bgAbs)) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("背景文件无效。")); + return; + } + QImage bg(bgAbs); + if (bg.isNull()) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("读取背景失败。")); + return; + } + if (bg.format() != QImage::Format_ARGB32_Premultiplied) { + bg = bg.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + + QVector holePolyWorld; + for (const auto& e : m_workspace.entities()) { + if (e.id == entityId) { + holePolyWorld = e.cutoutPolygonWorld; + break; + } + } + if (holePolyWorld.size() < 3) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("黑洞数据无效(多边形点数不足)。")); + return; + } + + QPainterPath holePath; + holePath.addPolygon(QPolygonF(holePolyWorld)); + holePath.closeSubpath(); + + // 给模型更多上下文,避免“只看见一小块洞”导致输出色块/噪声 + constexpr int kMargin = 160; + const QRect holeRect = holePath.boundingRect().toAlignedRect(); + QRect cropRect = holeRect.adjusted(-kMargin, -kMargin, kMargin, kMargin); + cropRect = cropRect.intersected(QRect(QPoint(0, 0), bg.size())); + if (!cropRect.isValid() || cropRect.width() <= 1 || cropRect.height() <= 1) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("裁剪区域无效。")); + return; + } + + const QImage cropRgb = bg.copy(cropRect).convertToFormat(QImage::Format_RGB888); + QImage mask(cropRect.size(), QImage::Format_Grayscale8); + mask.fill(0); + { + QPainter pm(&mask); + pm.setRenderHint(QPainter::Antialiasing, true); + pm.translate(-cropRect.topLeft()); + pm.fillPath(holePath, QColor(255, 255, 255)); + pm.end(); + } + + // 关键:黑洞区域在原始背景里通常是“被抠走的前景(人)像素”,但我们目标是补背景。 + // 为避免模型受前景像素干扰,这里先把 mask 内像素用周围背景的平均色“抹掉”,再发往后端 inpaint。 + QImage cropForInpaint = cropRgb.convertToFormat(QImage::Format_RGB888); + { + const QImage m8 = mask.convertToFormat(QImage::Format_Grayscale8); + // 取一圈“mask 外的邻域”估计背景平均色 + long long sr = 0, sg = 0, sb = 0; + int sc = 0; + for (int y = 0; y < cropForInpaint.height(); ++y) { + const uchar* mr = m8.constScanLine(y); + const uchar* pr = cropForInpaint.constScanLine(y); + for (int x = 0; x < cropForInpaint.width(); ++x) { + if (mr[x] != 0) continue; + bool nearHole = false; + for (int dy = -2; dy <= 2 && !nearHole; ++dy) { + const int yy = y + dy; + if (yy < 0 || yy >= m8.height()) continue; + const uchar* r2 = m8.constScanLine(yy); + for (int dx = -2; dx <= 2; ++dx) { + const int xx = x + dx; + if (xx < 0 || xx >= m8.width()) continue; + if (r2[xx] != 0) { + nearHole = true; + break; + } + } + } + if (!nearHole) continue; + const int idx = x * 3; + sr += pr[idx + 0]; + sg += pr[idx + 1]; + sb += pr[idx + 2]; + sc += 1; + } + } + const int fillR = (sc > 0) ? int(sr / sc) : 128; + const int fillG = (sc > 0) ? int(sg / sc) : 128; + const int fillB = (sc > 0) ? int(sb / sc) : 128; + + for (int y = 0; y < cropForInpaint.height(); ++y) { + const uchar* mr = m8.constScanLine(y); + uchar* pr = cropForInpaint.scanLine(y); + for (int x = 0; x < cropForInpaint.width(); ++x) { + if (mr[x] == 0) continue; + const int idx = x * 3; + pr[idx + 0] = uchar(std::clamp(fillR, 0, 255)); + pr[idx + 1] = uchar(std::clamp(fillG, 0, 255)); + pr[idx + 2] = uchar(std::clamp(fillB, 0, 255)); + } + } + } + + QByteArray cropPng; + { + QBuffer buf(&cropPng); + if (!buf.open(QIODevice::WriteOnly) || !cropForInpaint.save(&buf, "PNG")) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("编码裁剪图失败。")); + return; + } + } + QByteArray maskPng; + { + QBuffer buf(&maskPng); + if (!buf.open(QIODevice::WriteOnly) || !mask.save(&buf, "PNG")) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("编码 mask 失败。")); + 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; + const QString prompt = dlg.promptText(); + QNetworkReply* reply = client->inpaintAsync( + cropPng, + maskPng, + prompt, + QString(), + // 经验值:强度过高容易把整个裁剪块“重绘成色块”,先降低更稳 + 0.55, + 1024, + &immediateErr); + if (!reply) { + QMessageBox::warning(this, + QStringLiteral("黑洞修复"), + immediateErr.isEmpty() ? QStringLiteral("无法发起后端请求。") : immediateErr); + client->deleteLater(); + return; + } + + auto* task = new CancelableTaskDialog(QStringLiteral("黑洞修复"), + QStringLiteral("正在请求后端模型补全,请稍候……"), + this); + task->setAttribute(Qt::WA_DeleteOnClose, true); + connect(task, &CancelableTaskDialog::canceled, this, [reply, task]() { + if (reply) reply->abort(); + if (task) task->reject(); + }); + + connect(reply, &QNetworkReply::finished, this, + [this, reply, task, client, entityId, bg, cropRect, cropRgb, mask]() mutable { + 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 (task) task->close(); + + if (netErr != QNetworkReply::NoError) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), + 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("黑洞修复"), + 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("黑洞修复"), 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("黑洞修复"), + err.isEmpty() ? QStringLiteral("补全失败。") : err); + return; + } + + const QString outB64 = obj.value(QStringLiteral("output_image_b64")).toString(); + if (outB64.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("后端未返回图像数据。")); + return; + } + QImage inpainted; + { + const QByteArray bytes = QByteArray::fromBase64(outB64.toLatin1()); + inpainted.loadFromData(bytes, "PNG"); + } + if (inpainted.isNull()) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("解析补全图失败。")); + return; + } + if (inpainted.size() != cropRgb.size()) { + inpainted = inpainted.scaled(cropRgb.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } + if (inpainted.format() != QImage::Format_RGB888) { + inpainted = inpainted.convertToFormat(QImage::Format_RGB888); + } + + // 合成:mask 羽化后 alpha blend,避免硬边与块状感 + QImage after = cropRgb.convertToFormat(QImage::Format_RGB888); + QImage alpha = mask.convertToFormat(QImage::Format_Grayscale8); + { + // 轻微膨胀 + 模糊,让边缘更自然(开销小,且无需额外依赖) + QImage dil(alpha.size(), QImage::Format_Grayscale8); + dil.fill(0); + for (int y = 0; y < alpha.height(); ++y) { + for (int x = 0; x < alpha.width(); ++x) { + int best = 0; + for (int dy = -1; dy <= 1; ++dy) { + const int yy = y + dy; + if (yy < 0 || yy >= alpha.height()) continue; + const uchar* row = alpha.constScanLine(yy); + for (int dx = -1; dx <= 1; ++dx) { + const int xx = x + dx; + if (xx < 0 || xx >= alpha.width()) continue; + best = std::max(best, int(row[xx])); + } + } + dil.scanLine(y)[x] = uchar(best); + } + } + alpha = dil; + // 简单 box blur 3x3 + QImage blur(alpha.size(), QImage::Format_Grayscale8); + blur.fill(0); + for (int y = 0; y < alpha.height(); ++y) { + for (int x = 0; x < alpha.width(); ++x) { + int sum = 0; + int cnt = 0; + for (int dy = -1; dy <= 1; ++dy) { + const int yy = y + dy; + if (yy < 0 || yy >= alpha.height()) continue; + const uchar* row = alpha.constScanLine(yy); + for (int dx = -1; dx <= 1; ++dx) { + const int xx = x + dx; + if (xx < 0 || xx >= alpha.width()) continue; + sum += int(row[xx]); + cnt += 1; + } + } + blur.scanLine(y)[x] = uchar(std::clamp(sum / std::max(1, cnt), 0, 255)); + } + } + alpha = blur; + } + + for (int y = 0; y < after.height(); ++y) { + const uchar* arow = alpha.constScanLine(y); + uchar* orow = after.scanLine(y); + const uchar* irow = inpainted.constScanLine(y); + for (int x = 0; x < after.width(); ++x) { + const int a = int(arow[x]); // 0..255 + if (a <= 0) continue; + const int idx = x * 3; + for (int c = 0; c < 3; ++c) { + const int o = int(orow[idx + c]); + const int n = int(irow[idx + c]); + const int v = (o * (255 - a) + n * a + 127) / 255; + orow[idx + c] = uchar(std::clamp(v, 0, 255)); + } + } + } + + InpaintPreviewDialog preview(QStringLiteral("模型补全预览"), this); + preview.setImages(cropRgb, after); + if (preview.exec() != QDialog::Accepted) { + statusBar()->showMessage(QStringLiteral("已取消模型补全")); + return; + } + + QImage patchedBg = bg; + { + QPainter p(&patchedBg); + p.drawImage(cropRect.topLeft(), after); + p.end(); + } + const bool ok2 = m_workspace.resolveBlackholeByModelInpaint(entityId, patchedBg, true); + if (!ok2) { + QMessageBox::warning(this, QStringLiteral("黑洞修复"), QStringLiteral("写回背景失败。")); + return; + } + + statusBar()->showMessage(QStringLiteral("黑洞已通过模型补全修复")); + refreshProjectTree(); + updateUiEnabledState(); + if (m_editorCanvas) { + m_editorCanvas->notifyBackgroundContentChanged(); + } + refreshEditorPage(); + if (m_previewRequested) { + refreshPreviewPage(); + } + }); + + task->show(); + return; } if (ok) { diff --git a/python_server/server.py b/python_server/server.py index 78c5cff..22a9ba2 100644 --- a/python_server/server.py +++ b/python_server/server.py @@ -420,7 +420,12 @@ def inpaint(req: InpaintRequest) -> Dict[str, Any]: out_path = out_dir / f"{model_name}_inpaint.png" out.save(out_path) - return {"success": True, "output_path": str(out_path)} + # 兼容 Qt 前端:直接返回结果图,避免前端再去读取服务器磁盘路径 + return { + "success": True, + "output_path": str(out_path), + "output_image_b64": _pil_image_to_png_b64(out), + } except Exception as e: return {"success": False, "error": str(e)}