From 4a1e20a45f636e2e6976dc3ad4da3fe38a64cea6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Mon, 9 Oct 2023 22:48:08 +0800 Subject: [PATCH] feat: TabOcr_BuildPHashDatabase --- .../tabs/tabOcr/tabOcr_BuildPHashDatabase.ui | 255 ++++++++++++++++++ .../tabOcr/tabOcr_BuildPHashDatabase_ui.py | 193 +++++++++++++ ui/designer/tabs/tabOcrEntry.ui | 11 + ui/designer/tabs/tabOcrEntry_ui.py | 7 +- ui/extends/{ocr.py => ocr/__init__.py} | 2 + ui/extends/ocr/build_phash.py | 61 +++++ .../tabs/tabOcr/tabOcr_BuildPHashDatabase.py | 138 ++++++++++ ui/resources/lang/en_US.ts | 42 ++- ui/resources/lang/zh_CN.ts | 42 ++- 9 files changed, 746 insertions(+), 5 deletions(-) create mode 100644 ui/designer/tabs/tabOcr/tabOcr_BuildPHashDatabase.ui create mode 100644 ui/designer/tabs/tabOcr/tabOcr_BuildPHashDatabase_ui.py rename ui/extends/{ocr.py => ocr/__init__.py} (94%) create mode 100644 ui/extends/ocr/build_phash.py create mode 100644 ui/implements/tabs/tabOcr/tabOcr_BuildPHashDatabase.py diff --git a/ui/designer/tabs/tabOcr/tabOcr_BuildPHashDatabase.ui b/ui/designer/tabs/tabOcr/tabOcr_BuildPHashDatabase.ui new file mode 100644 index 0000000..ac24876 --- /dev/null +++ b/ui/designer/tabs/tabOcr/tabOcr_BuildPHashDatabase.ui @@ -0,0 +1,255 @@ + + + tabOcr_BuildPHashDatabase + + + + 0 + 0 + 632 + 551 + + + + tabOcr_BuildPHashDatabase + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + folders.title + + + + + + + + folders.songDir + + + + + + + + 0 + 0 + + + + + + + + + + + + folders.charIconDir + + + + + + + + 0 + 0 + + + + + + + + + + + + + options.title + + + + + + + + + + hash_size + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + 2 + + + 64 + + + 16 + + + + + + + + + + + highfreq_factor + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + 32 + + + 4 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + resetButton + + + + + + + + + + 0 + + + 0 + + + Qt::AlignCenter + + + %v/%m - %p% + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + buildButton + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + FileSelector + QWidget +
ui.implements.components.fileSelector
+ 1 +
+
+ + +
diff --git a/ui/designer/tabs/tabOcr/tabOcr_BuildPHashDatabase_ui.py b/ui/designer/tabs/tabOcr/tabOcr_BuildPHashDatabase_ui.py new file mode 100644 index 0000000..d7ff2bd --- /dev/null +++ b/ui/designer/tabs/tabOcr/tabOcr_BuildPHashDatabase_ui.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'tabOcr_BuildPHashDatabase.ui' +## +## Created by: Qt User Interface Compiler version 6.5.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QGroupBox, QHBoxLayout, QLabel, + QProgressBar, QPushButton, QSizePolicy, QSpacerItem, + QSpinBox, QVBoxLayout, QWidget) + +from ui.implements.components.fileSelector import FileSelector + +class Ui_tabOcr_BuildPHashDatabase(object): + def setupUi(self, tabOcr_BuildPHashDatabase): + if not tabOcr_BuildPHashDatabase.objectName(): + tabOcr_BuildPHashDatabase.setObjectName(u"tabOcr_BuildPHashDatabase") + tabOcr_BuildPHashDatabase.resize(632, 551) + tabOcr_BuildPHashDatabase.setWindowTitle(u"tabOcr_BuildPHashDatabase") + self.verticalLayout_3 = QVBoxLayout(tabOcr_BuildPHashDatabase) + self.verticalLayout_3.setObjectName(u"verticalLayout_3") + self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) + + self.verticalLayout_3.addItem(self.verticalSpacer) + + self.groupBox = QGroupBox(tabOcr_BuildPHashDatabase) + self.groupBox.setObjectName(u"groupBox") + self.verticalLayout = QVBoxLayout(self.groupBox) + self.verticalLayout.setObjectName(u"verticalLayout") + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.label = QLabel(self.groupBox) + self.label.setObjectName(u"label") + + self.horizontalLayout.addWidget(self.label) + + self.songDirSelector = FileSelector(self.groupBox) + self.songDirSelector.setObjectName(u"songDirSelector") + sizePolicy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.songDirSelector.sizePolicy().hasHeightForWidth()) + self.songDirSelector.setSizePolicy(sizePolicy) + + self.horizontalLayout.addWidget(self.songDirSelector) + + + self.verticalLayout.addLayout(self.horizontalLayout) + + self.horizontalLayout_2 = QHBoxLayout() + self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") + self.label_2 = QLabel(self.groupBox) + self.label_2.setObjectName(u"label_2") + + self.horizontalLayout_2.addWidget(self.label_2) + + self.charIconDirSelector = FileSelector(self.groupBox) + self.charIconDirSelector.setObjectName(u"charIconDirSelector") + sizePolicy.setHeightForWidth(self.charIconDirSelector.sizePolicy().hasHeightForWidth()) + self.charIconDirSelector.setSizePolicy(sizePolicy) + + self.horizontalLayout_2.addWidget(self.charIconDirSelector) + + + self.verticalLayout.addLayout(self.horizontalLayout_2) + + + self.verticalLayout_3.addWidget(self.groupBox) + + self.groupBox_2 = QGroupBox(tabOcr_BuildPHashDatabase) + self.groupBox_2.setObjectName(u"groupBox_2") + self.horizontalLayout_3 = QHBoxLayout(self.groupBox_2) + self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") + self.verticalLayout_2 = QVBoxLayout() + self.verticalLayout_2.setObjectName(u"verticalLayout_2") + self.horizontalLayout_6 = QHBoxLayout() + self.horizontalLayout_6.setObjectName(u"horizontalLayout_6") + self.label_3 = QLabel(self.groupBox_2) + self.label_3.setObjectName(u"label_3") + self.label_3.setText(u"hash_size") + self.label_3.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) + + self.horizontalLayout_6.addWidget(self.label_3) + + self.hashSizeSpinBox = QSpinBox(self.groupBox_2) + self.hashSizeSpinBox.setObjectName(u"hashSizeSpinBox") + sizePolicy1 = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.hashSizeSpinBox.sizePolicy().hasHeightForWidth()) + self.hashSizeSpinBox.setSizePolicy(sizePolicy1) + self.hashSizeSpinBox.setMinimum(2) + self.hashSizeSpinBox.setMaximum(64) + self.hashSizeSpinBox.setValue(16) + + self.horizontalLayout_6.addWidget(self.hashSizeSpinBox) + + + self.verticalLayout_2.addLayout(self.horizontalLayout_6) + + self.horizontalLayout_7 = QHBoxLayout() + self.horizontalLayout_7.setObjectName(u"horizontalLayout_7") + self.label_4 = QLabel(self.groupBox_2) + self.label_4.setObjectName(u"label_4") + self.label_4.setText(u"highfreq_factor") + self.label_4.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) + + self.horizontalLayout_7.addWidget(self.label_4) + + self.highfreqFactorSpinBox = QSpinBox(self.groupBox_2) + self.highfreqFactorSpinBox.setObjectName(u"highfreqFactorSpinBox") + sizePolicy1.setHeightForWidth(self.highfreqFactorSpinBox.sizePolicy().hasHeightForWidth()) + self.highfreqFactorSpinBox.setSizePolicy(sizePolicy1) + self.highfreqFactorSpinBox.setMaximum(32) + self.highfreqFactorSpinBox.setValue(4) + + self.horizontalLayout_7.addWidget(self.highfreqFactorSpinBox) + + + self.verticalLayout_2.addLayout(self.horizontalLayout_7) + + + self.horizontalLayout_3.addLayout(self.verticalLayout_2) + + self.horizontalSpacer_3 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.horizontalLayout_3.addItem(self.horizontalSpacer_3) + + self.optionsResetButton = QPushButton(self.groupBox_2) + self.optionsResetButton.setObjectName(u"optionsResetButton") + + self.horizontalLayout_3.addWidget(self.optionsResetButton) + + + self.verticalLayout_3.addWidget(self.groupBox_2) + + self.progressBar = QProgressBar(tabOcr_BuildPHashDatabase) + self.progressBar.setObjectName(u"progressBar") + self.progressBar.setMaximum(0) + self.progressBar.setValue(0) + self.progressBar.setAlignment(Qt.AlignCenter) + self.progressBar.setFormat(u"%v/%m - %p%") + + self.verticalLayout_3.addWidget(self.progressBar) + + self.horizontalLayout_5 = QHBoxLayout() + self.horizontalLayout_5.setObjectName(u"horizontalLayout_5") + self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.horizontalLayout_5.addItem(self.horizontalSpacer) + + self.buildButton = QPushButton(tabOcr_BuildPHashDatabase) + self.buildButton.setObjectName(u"buildButton") + + self.horizontalLayout_5.addWidget(self.buildButton) + + self.horizontalSpacer_2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.horizontalLayout_5.addItem(self.horizontalSpacer_2) + + + self.verticalLayout_3.addLayout(self.horizontalLayout_5) + + self.verticalSpacer_2 = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) + + self.verticalLayout_3.addItem(self.verticalSpacer_2) + + + self.retranslateUi(tabOcr_BuildPHashDatabase) + + QMetaObject.connectSlotsByName(tabOcr_BuildPHashDatabase) + # setupUi + + def retranslateUi(self, tabOcr_BuildPHashDatabase): + self.groupBox.setTitle(QCoreApplication.translate("tabOcr_BuildPHashDatabase", u"folders.title", None)) + self.label.setText(QCoreApplication.translate("tabOcr_BuildPHashDatabase", u"folders.songDir", None)) + self.label_2.setText(QCoreApplication.translate("tabOcr_BuildPHashDatabase", u"folders.charIconDir", None)) + self.groupBox_2.setTitle(QCoreApplication.translate("tabOcr_BuildPHashDatabase", u"options.title", None)) + self.optionsResetButton.setText(QCoreApplication.translate("tabOcr_BuildPHashDatabase", u"resetButton", None)) + self.buildButton.setText(QCoreApplication.translate("tabOcr_BuildPHashDatabase", u"buildButton", None)) + pass + # retranslateUi + diff --git a/ui/designer/tabs/tabOcrEntry.ui b/ui/designer/tabs/tabOcrEntry.ui index 7314699..cba2b68 100644 --- a/ui/designer/tabs/tabOcrEntry.ui +++ b/ui/designer/tabs/tabOcrEntry.ui @@ -29,6 +29,11 @@ tab.b30 + + + tab.buildPHashDatabase + + @@ -46,6 +51,12 @@
ui.implements.tabs.tabOcr.tabOcr_B30
1 + + TabOcr_BuildPHashDatabase + QWidget +
ui.implements.tabs.tabOcr.tabOcr_BuildPHashDatabase
+ 1 +
diff --git a/ui/designer/tabs/tabOcrEntry_ui.py b/ui/designer/tabs/tabOcrEntry_ui.py index 6144e6a..003a04a 100644 --- a/ui/designer/tabs/tabOcrEntry_ui.py +++ b/ui/designer/tabs/tabOcrEntry_ui.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'tabOcrEntry.ui' ## -## Created by: Qt User Interface Compiler version 6.5.1 +## Created by: Qt User Interface Compiler version 6.5.2 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -19,6 +19,7 @@ from PySide6.QtWidgets import (QApplication, QSizePolicy, QTabWidget, QVBoxLayou QWidget) from ui.implements.tabs.tabOcr.tabOcr_B30 import TabOcr_B30 +from ui.implements.tabs.tabOcr.tabOcr_BuildPHashDatabase import TabOcr_BuildPHashDatabase from ui.implements.tabs.tabOcr.tabOcr_Device import TabOcr_Device class Ui_TabOcrEntry(object): @@ -37,6 +38,9 @@ class Ui_TabOcrEntry(object): self.tab_2 = TabOcr_B30() self.tab_2.setObjectName(u"tab_2") self.tabWidget.addTab(self.tab_2, "") + self.tab_3 = TabOcr_BuildPHashDatabase() + self.tab_3.setObjectName(u"tab_3") + self.tabWidget.addTab(self.tab_3, "") self.verticalLayout.addWidget(self.tabWidget) @@ -52,6 +56,7 @@ class Ui_TabOcrEntry(object): def retranslateUi(self, TabOcrEntry): self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab), QCoreApplication.translate("TabOcrEntry", u"tab.device", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_2), QCoreApplication.translate("TabOcrEntry", u"tab.b30", None)) + self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_3), QCoreApplication.translate("TabOcrEntry", u"tab.buildPHashDatabase", None)) pass # retranslateUi diff --git a/ui/extends/ocr.py b/ui/extends/ocr/__init__.py similarity index 94% rename from ui/extends/ocr.py rename to ui/extends/ocr/__init__.py index 212ab91..bda9d16 100644 --- a/ui/extends/ocr.py +++ b/ui/extends/ocr/__init__.py @@ -1,3 +1,5 @@ +from .build_phash import build_image_phash_database + try: import json diff --git a/ui/extends/ocr/build_phash.py b/ui/extends/ocr/build_phash.py new file mode 100644 index 0000000..8907738 --- /dev/null +++ b/ui/extends/ocr/build_phash.py @@ -0,0 +1,61 @@ +import sqlite3 +import time +from pathlib import Path +from typing import Any, Callable, Optional + +import cv2 +from arcaea_offline_ocr.phash_db import phash_opencv + + +def build_image_phash_database( + images: list[Path], + labels: list[str], + *, + hash_size: int = 16, + highfreq_factor: int = 4, + progress_func: Optional[Callable[[int, int], Any]] = None, +): + assert len(images) == len(labels) + + conn = sqlite3.connect(":memory:", check_same_thread=False) + + with conn: + cursor = conn.cursor() + + cursor.execute("CREATE TABLE properties (key TEXT, value TEXT)") + cursor.executemany( + "INSERT INTO properties VALUES (?, ?)", + [ + ("hash_size", hash_size), + ("highfreq_factor", highfreq_factor), + ], + ) + + image_num = len(images) + id_hashes = [] + for i, label, image_path in zip(range(image_num), labels, images): + image_hash = phash_opencv( + cv2.imread(str(image_path.resolve()), cv2.IMREAD_GRAYSCALE), + hash_size=hash_size, + highfreq_factor=highfreq_factor, + ) + image_hash_bytes = image_hash.flatten().tobytes() + + id_hashes.append([label, image_hash_bytes]) + if progress_func: + progress_func(i + 1, image_num) + + hash_length = len(id_hashes[0][1]) + cursor.execute(f"CREATE TABLE hashes (id TEXT, hash BLOB({hash_length}))") + + cursor.executemany( + "INSERT INTO hashes VALUES (?, ?)", + id_hashes, + ) + cursor.executemany( + "INSERT INTO properties VALUES (?, ?)", + [("built_timestamp", int(time.time()))], + ) + conn.commit() + + return conn diff --git a/ui/implements/tabs/tabOcr/tabOcr_BuildPHashDatabase.py b/ui/implements/tabs/tabOcr/tabOcr_BuildPHashDatabase.py new file mode 100644 index 0000000..e334f17 --- /dev/null +++ b/ui/implements/tabs/tabOcr/tabOcr_BuildPHashDatabase.py @@ -0,0 +1,138 @@ +import logging +import re +import sqlite3 +import time +from pathlib import Path + +from PySide6.QtCore import QThread, Signal, Slot +from PySide6.QtWidgets import QFileDialog, QMessageBox, QWidget + +from ui.designer.tabs.tabOcr.tabOcr_BuildPHashDatabase_ui import ( + Ui_tabOcr_BuildPHashDatabase, +) +from ui.extends.ocr import build_image_phash_database + +logger = logging.getLogger(__name__) + + +class BuildDatabaseThread(QThread): + conn: sqlite3.Connection + + progress = Signal(int, int) + success = Signal() + error = Signal(str) + finished = Signal() + + def __init__( + self, + images: list[Path], + labels: list[str], + *, + hashSize: int | None = None, + highfreqFactor: int | None = None, + ): + super().__init__() + self.images = images + self.labels = labels + self.hashSize = hashSize + self.highfreqFactor = highfreqFactor + + def run(self): + try: + progressFunc = lambda i, total: self.progress.emit(i, total) + + kwargsDict = {} + if self.hashSize is not None: + kwargsDict["hash_size"] = self.hashSize + if self.highfreqFactor is not None: + kwargsDict["highfreq_factor"] = self.highfreqFactor + self.conn = build_image_phash_database( + self.images, self.labels, progress_func=progressFunc, **kwargsDict + ) + self.success.emit() + except Exception as e: + logger.exception("Error during pHash database build") + self.error.emit(str(e)) + finally: + self.finished.emit() + + +class TabOcr_BuildPHashDatabase(Ui_tabOcr_BuildPHashDatabase, QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + + self.songDirSelector.setMode(self.songDirSelector.getExistingDirectory) + self.charIconDirSelector.setMode(self.charIconDirSelector.getExistingDirectory) + + self.buildButton.clicked.connect(self.databaseBuildStart) + + @Slot() + def on_optionsResetButton_clicked(self): + self.hashSizeSpinBox.setValue(16) + self.highfreqFactorSpinBox.setValue(4) + + def databaseFileName(self): + return f"image-phash-{int(time.time() * 1000)}.db" + + def databaseBuildStart(self): + if not self.songDirSelector.selectedFiles(): + QMessageBox.critical(self, None, "Song directory not selected.") + return + if not self.charIconDirSelector.selectedFiles(): + QMessageBox.critical(self, None, "Char icon directory not selected.") + return + + songDir = self.songDirSelector.selectedFiles()[0] + charIconDir = self.charIconDirSelector.selectedFiles()[0] + + acceptExts = [".jpg", ".png"] + songFilePaths = [ + p for p in Path(songDir).glob("**/*") if p.suffix in acceptExts + ] + charIconFilePaths = [ + p for p in Path(charIconDir).glob("**/*") if p.suffix in acceptExts + ] + songLabels = [re.sub(r"_.*$", "", p.stem) for p in songFilePaths] + charLabels = [f"character||{p.stem}" for p in charIconFilePaths] + + self.databaseBuildThread = BuildDatabaseThread( + songFilePaths + charIconFilePaths, songLabels + charLabels + ) + self.databaseBuildThread.progress.connect(self.databaseBuildProgress) + self.databaseBuildThread.success.connect(self.databaseBuildSuccess) + self.databaseBuildThread.error.connect(self.databaseBuildError) + self.buildButton.setEnabled(False) + self.databaseBuildThread.start() + + @Slot(int, int) + def databaseBuildProgress(self, i: int, total: int): + if i < 5: + self.progressBar.setMaximum(total) + self.progressBar.setValue(i) + + @Slot(str) + def databaseBuildError(self, msg: str): + QMessageBox.critical(self, "Error", msg) + self.databaseBuildCleanUp() + + @Slot() + def databaseBuildSuccess(self): + dbMemory = self.databaseBuildThread.conn + + dbFileName, _ = QFileDialog.getSaveFileName(self, None, self.databaseFileName()) + if not dbFileName: + self.databaseBuildCleanUp() + QMessageBox.information(self, None, "User canceled operation.") + return + + dbDisk = sqlite3.connect(dbFileName) + dbMemory.backup(dbDisk) + self.databaseBuildCleanUp() + + def databaseBuildCleanUp(self): + self.databaseBuildThread.deleteLater() + self.databaseBuildThread = None + self.progressBar.setMaximum(0) + self.progressBar.setValue(0) + self.buildButton.setEnabled(True) diff --git a/ui/resources/lang/en_US.ts b/ui/resources/lang/en_US.ts index 972e3b6..c572a5d 100644 --- a/ui/resources/lang/en_US.ts +++ b/ui/resources/lang/en_US.ts @@ -89,12 +89,12 @@ Continue - + dialog.tryInitExistingDatabase The existing database doesn't seem to be initialized properly, try initialize again? - + dialog.confirmNewDatabase Database file does not exist. Create now? @@ -696,6 +696,11 @@ validation tab.b30 B30 + + + tab.buildPHashDatabase + Build pHash Database + TabOcr_B30 @@ -1167,4 +1172,37 @@ validation Result (play rating) + + tabOcr_BuildPHashDatabase + + + folders.title + Data Folders + + + + folders.songDir + Song jackets + + + + folders.charIconDir + Character icons + + + + options.title + Options + + + + resetButton + Reset + + + + buildButton + Build + + diff --git a/ui/resources/lang/zh_CN.ts b/ui/resources/lang/zh_CN.ts index c95af67..120d025 100644 --- a/ui/resources/lang/zh_CN.ts +++ b/ui/resources/lang/zh_CN.ts @@ -89,12 +89,12 @@ 继续 - + dialog.tryInitExistingDatabase 现有的数据库似乎没有正确初始化,是否尝试再次初始化? - + dialog.confirmNewDatabase 数据库文件不存在,是否创建? @@ -695,6 +695,11 @@ tab.b30 B30 + + + tab.buildPHashDatabase + 构建 pHash 数据库 + TabOcr_B30 @@ -1166,4 +1171,37 @@ 结果(单曲 PTT) + + tabOcr_BuildPHashDatabase + + + folders.title + 数据文件夹 + + + + folders.songDir + 曲封 + + + + folders.charIconDir + 搭档头像 + + + + options.title + 选项 + + + + resetButton + 重置 + + + + buildButton + 构建 + +