切图4倍数校验工具|批量检测_0.03

一、需求

游戏贴图规范校验工具,Unity常用:图片宽、高必须是 4 的整数倍,批量筛查不符合规范的贴图。
支持格式:png/jpg/jpeg/bmp/tga
功能清单:
多种导入方式:多选图片文件、选文件夹(可筛选文件格式)、拖拽文件 / 文件夹
后台线程检测,带进度弹窗,不阻塞 UI
自动区分【合格 / 不合格】分 Tab 展示
每条结果卡片:缩略预览、尺寸、状态;支持点击缩略图打开原图、打开目录定位文件、复制文件名
统计总数、合格 / 不合格数量,支持一键清空所有结果

二、使用步骤

运行脚本启动窗口
导入素材三选一
直接拖拽图片 / 文件夹到窗口左侧
点击【选择多张图片】挑选贴图
点击【选择整个文件夹】递归扫描目录内图片(可先选择格式)
弹出检测进度窗口,等待扫描完成
在右侧标签页查看结果
❌不合格图片:宽高不满足 4 倍数、文件读取失败
✅合格图片:宽高均为 4 倍数
卡片操作
点击缩略图:打开原图
打开目录:资源管理器自动选中目标文件
复制文件名:快速复制文件名称
【清空全部结果】重置列表,开始新一轮检测

点击查看代码
#修复:1、选择文件夹前加个可以选择文件格式的独立弹窗、2、有同名文件打开目录会提示警告

import subprocess
import sys
import os
import subprocess
from PIL import Image
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QLabel, QWidget, QHBoxLayout, QVBoxLayout,
    QSplitter, QSizePolicy, QPushButton, QFileDialog, QTabWidget,
    QScrollArea, QFrame, QDialog, QCheckBox, QDialogButtonBox, QMessageBox
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QFont, QPixmap

# 全局字体与圆角常量
GLOBAL_FONT_FAMILY = "Microsoft YaHei"
GLOBAL_FONT_SIZE = 10
DIALOG_FONT_SIZE = 14
APPLE_ROUND_RADIUS = 12
BUTTON_ROUND_RADIUS = 8
ALL_EXT = [".png", ".jpg", ".jpeg", ".bmp", ".tga"]
EXT_MAP = {
    ".png": "PNG",
    ".jpg": "JPG",
    ".jpeg": "JPEG",
    ".bmp": "BMP",
    ".tga": "TGA"
}

# 文件夹格式筛选弹窗
class FolderFilterDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("选择文件夹 & 筛选图片格式")
        self.setModal(True)
        self.resize(420, 260)
        self.selected_folder = ""
        self.choose_exts = []
        layout = QVBoxLayout()
        layout.setSpacing(12)
        layout.setContentsMargins(16,16,16,16)
        # 选择文件夹按钮
        self.folder_btn = QPushButton("点击选择目标文件夹")
        self.folder_path_label = QLabel("未选择文件夹")
        self.folder_path_label.setWordWrap(True)
        self.folder_btn.clicked.connect(self.select_dir)
        folder_layout = QVBoxLayout()
        folder_layout.addWidget(self.folder_btn)
        folder_layout.addWidget(self.folder_path_label)
        layout.addLayout(folder_layout)
        # 格式勾选提示
        tip = QLabel("勾选需要扫描的图片格式:")
        tip.setFont(QFont(GLOBAL_FONT_FAMILY, GLOBAL_FONT_SIZE, QFont.Bold))
        layout.addWidget(tip)
        self.check_box_dict = {}
        for ext in ALL_EXT:
            cb = QCheckBox(f"{EXT_MAP[ext]} ({ext})")
            cb.setChecked(True)
            self.check_box_dict[ext] = cb
            layout.addWidget(cb)
        # 底部确认取消
        btn_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        btn_box.accepted.connect(self.accept)
        btn_box.rejected.connect(self.reject)
        layout.addWidget(btn_box)
        self.setLayout(layout)
    def select_dir(self):
        folder = QFileDialog.getExistingDirectory(self, "选择文件夹")
        if folder:
            self.selected_folder = folder
    def get_filter_exts(self):
        self.choose_exts.clear()
        for ext, cb in self.check_box_dict.items():
            if cb.isChecked():
                self.choose_exts.append(ext)
        return self.choose_exts

# 后台检测线程
class CheckImageThread(QThread):
    one_finish = pyqtSignal(str, int, int, str, str)
    all_finish = pyqtSignal(dict)
    def __init__(self, path_list):
        super().__init__()
        self.path_list = path_list
    def run(self):
        finished_count = 0
        name_map = {}
        for file_path in self.path_list:
            _, ext = os.path.splitext(file_path)
            ext = ext.lower()
            if ext not in ALL_EXT:
                continue
            file_name = os.path.basename(file_path)
            # 收集同名文件路径
            if file_name not in name_map:
                name_map[file_name] = []
            name_map[file_name].append(file_path)
            try:
                img = Image.open(file_path)
                w, h = img.size
                img.close()
                w_pass = (w % 4 == 0)
                h_pass = (h % 4 == 0)
                state_text = "宽高均为4倍数" if (w_pass and h_pass) else "尺寸非4倍数"
                is_bad = not (w_pass and h_pass)
            except Exception as err:
                state_text = f"读取失败:{str(err)}"
                w, h = 0
                is_bad = True
            finished_count += 1
            self.one_finish.emit(file_name, w, h, state_text, file_path)
        self.all_finish.emit(name_map)

# 加载弹窗
class LoadingDialog(QDialog):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("图片检测中")
        self.setFixedSize(380, 160)
        self.setModal(True)
        layout = QVBoxLayout()
        layout.setSpacing(16)
        tip_label = QLabel("⏳ 正在批量检测图片,请稍候...")
        tip_label.setAlignment(Qt.AlignCenter)
        tip_label.setFont(QFont(GLOBAL_FONT_FAMILY, DIALOG_FONT_SIZE, QFont.Bold))
        self.progress_label = QLabel("已检测:0 / 总计:0")
        progress_font = QFont(GLOBAL_FONT_FAMILY, DIALOG_FONT_SIZE + 2, QFont.Bold)
        self.progress_label.setFont(progress_font)
        self.progress_label.setStyleSheet("color:#c02020;")
        self.progress_label.setAlignment(Qt.AlignCenter)
        layout.addWidget(tip_label)
        layout.addWidget(self.progress_label)
        self.setLayout(layout)
    def update_progress(self, now, total):
        self.progress_label.setText(f"已检测:{now} / 总计:{total}")

# 单条图片结果卡片
class ResultItem(QFrame):
    def __init__(self, parent_win, file_name, w, h, state, full_path):
        super().__init__()
        self.parent_win = parent_win
        self.current_full_path = full_path
        self.file_name = file_name
        self.folder_path = os.path.dirname(self.current_full_path)

        main_layout = QHBoxLayout()
        main_layout.setContentsMargins(10, 8, 10, 8)
        main_layout.setSpacing(10)

        # 左侧缩略预览图
        self.preview_label = QLabel()
        self.preview_label.setFixedSize(64, 64)
        self.preview_label.setAlignment(Qt.AlignCenter)
        self.preview_label.setStyleSheet(f"""
            QLabel {{border:2px dashed #a8d5ff;border-radius:{APPLE_ROUND_RADIUS-4}px;background:#fafcff;}}
            QLabel:hover {{border:2px dashed #4a90e2;background:#f0f7ff;}}
        """)
        self.preview_label.setToolTip("点击打开原图")
        self.preview_label.mousePressEvent = self.open_origin_image
        self.load_thumbnail()

        # 右侧文字按钮区域
        text_area = QWidget()
        text_area.setAttribute(Qt.WA_StyledBackground, True)
        text_area.setStyleSheet("background:white;border:none;")
        text_layout = QVBoxLayout(text_area)
        text_layout.setContentsMargins(0,0,0,0)
        text_layout.setSpacing(6)

        line1 = QHBoxLayout()
        name_label = QLabel(file_name)
        name_label.setFont(QFont(GLOBAL_FONT_FAMILY, GLOBAL_FONT_SIZE, QFont.Bold))
        line1.addWidget(name_label)
        line1.addStretch()

        line2 = QHBoxLayout()
        info_label = QLabel(f"尺寸:{w} × {h} | {state}")
        info_label.setFont(QFont(GLOBAL_FONT_FAMILY, GLOBAL_FONT_SIZE-1))
        info_label.setStyleSheet("border:none;")

        btn_style = f"""
            QPushButton {{border-radius:{BUTTON_ROUND_RADIUS}px;background:#f0f3f8;border:1px solid #d0d8e3;padding:4px 8px;}}
            QPushButton:hover {{background:#e0ebfa;}}
            QPushButton:pressed {{background:#c0d8f5;}}
        """
        copy_btn = QPushButton("复制文件名")
        copy_btn.setFixedWidth(90)
        copy_btn.setFont(QFont(GLOBAL_FONT_FAMILY, GLOBAL_FONT_SIZE-1))
        copy_btn.setStyleSheet(btn_style)
        copy_btn.clicked.connect(self.copy_filename)

        open_btn = QPushButton("打开目录")
        open_btn.setFixedWidth(80)
        open_btn.setFont(QFont(GLOBAL_FONT_FAMILY, GLOBAL_FONT_SIZE-1))
        open_btn.setStyleSheet(btn_style)
        open_btn.clicked.connect(self.open_folder_locate)

        line2.addWidget(info_label)
        line2.addStretch()
        line2.addWidget(copy_btn)
        line2.addWidget(open_btn)

        text_layout.addLayout(line1)
        text_layout.addLayout(line2)

        main_layout.addWidget(self.preview_label)
        main_layout.addWidget(text_area)
        self.setLayout(main_layout)
        self.setStyleSheet(f"""
            QFrame {{border:1px solid #cccccc;border-radius:{APPLE_ROUND_RADIUS-6}px;margin:3px;background:#fff;}}
        """)

    def load_thumbnail(self):
        try:
            pix = QPixmap(self.current_full_path)
            pix = pix.scaled(64,64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
            self.preview_label.setPixmap(pix)
        except:
            self.preview_label.setText("无图")
            self.preview_label.setFont(QFont(GLOBAL_FONT_FAMILY, 8))

    def open_origin_image(self, event):
        if sys.platform == "win32":
            os.startfile(self.current_full_path)

    def copy_filename(self):
        clipboard = QApplication.clipboard()
        clipboard.setText(self.file_name)

    def open_folder_locate(self):
        # 查询全局同名文件数量
        name_map = self.parent_win.name_record
        same_file_list = name_map.get(self.file_name, [])
        if len(same_file_list) > 1:
            QMessageBox.warning(self, "警告", f"当前存在 {len(same_file_list)} 个同名图片,无法直接定位文件夹!")
            return
        # 仅单个文件正常打开定位
        if sys.platform != "win32":
            return
        subprocess.Popen([
            "explorer",
            "/select,{}".format(os.path.normpath(self.current_full_path))
        ])

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("贴图4倍数校验工具|批量检测")
        self.resize(1020, 560)
        self.setAcceptDrops(True)
        self.total_count = 0
        self.bad_count = 0
        self.good_count = 0
        self.check_thread = None
        self.name_record = {} # 存储所有文件名-路径映射,用于同名判断
        app_font = QFont(GLOBAL_FONT_FAMILY, GLOBAL_FONT_SIZE)
        QApplication.setFont(app_font)
        self.global_btn_style = f"""
            QPushButton {{border-radius:{BUTTON_ROUND_RADIUS}px;background:#f0f3f8;border:1px solid #d0d8e3;padding:6px 4px;}}
            QPushButton:hover {{background:#e0ebfa;}}
            QPushButton:pressed {{background:#c0d8f5;}}
        """
        root_widget = QWidget()
        self.setCentralWidget(root_widget)
        root_layout = QHBoxLayout(root_widget)
        left_widget = QWidget()
        left_widget.setStyleSheet(f"QWidget{{border:none;border-radius:{APPLE_ROUND_RADIUS}px;background:#fcfdff;}}")
        left_layout = QVBoxLayout(left_widget)
        left_layout.setContentsMargins(12,12,12,12)
        left_layout.setSpacing(12)
        drop_tip = QLabel("🖼 将图片/文件夹拖入此处\n自动校验宽高是否为4的整数倍\n支持 PNG / JPG / BMP / TGA")
        drop_tip.setAlignment(Qt.AlignCenter)
        drop_tip.setFont(QFont(GLOBAL_FONT_FAMILY, 16))
        drop_tip.setStyleSheet("border:2px dashed #6699ff;border-radius:8px;padding:40px;background:#f8fbff;")
        drop_tip.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.drop_tip = drop_tip
        btn_layout = QHBoxLayout()
        self.btn_img = QPushButton("选择多张图片")
        self.btn_folder = QPushButton("选择整个文件夹")
        self.btn_clear = QPushButton("清空全部结果")
        for btn in [self.btn_img, self.btn_folder, self.btn_clear]:
            btn.setFont(app_font)
            btn.setStyleSheet(self.global_btn_style)
        self.btn_img.clicked.connect(self.select_images)
        self.btn_folder.clicked.connect(self.select_folder_with_filter)
        self.btn_clear.clicked.connect(self.clear_all)
        btn_layout.addWidget(self.btn_img)
        btn_layout.addWidget(self.btn_folder)
        btn_layout.addWidget(self.btn_clear)
        count_layout = QHBoxLayout()
        self.label_total = QLabel(f"已检测图片:{self.total_count} 张")
        self.label_total.setFont(app_font)
        count_layout.addWidget(self.label_total)
        count_layout.addStretch()
        left_layout.addWidget(drop_tip)
        left_layout.addLayout(btn_layout)
        left_layout.addLayout(count_layout)
        right_outer = QWidget()
        right_outer.setStyleSheet(f"""
            QWidget {{
                border: none;
                border-radius: {APPLE_ROUND_RADIUS}px;
                background-color: #ffffff;
            }}
        """)
        right_layout = QVBoxLayout(right_outer)
        right_layout.setContentsMargins(12,12,12,12)
        self.tab_widget = QTabWidget()
        self.tab_widget.setFont(app_font)
        self.tab_widget.setStyleSheet("""
            QTabBar::tab {
                padding:6px 16px;min-height:28px;margin:2px;
                border-radius:4px;border:none;background:#f5f7fa;
            }
            QTabBar::tab:selected {
                background:#d0e6ff;color:#004080;font-weight:bold;
                border:none;
            }
        """)
        self.scroll_bad = QScrollArea()
        self.scroll_bad.setWidgetResizable(True)
        self.scroll_bad.setStyleSheet("QScrollArea{border:none;background:#e8e8e8;}")
        self.widget_bad = QWidget()
        self.widget_bad.setStyleSheet("background:#e8e8e8;")
        self.layout_bad = QVBoxLayout(self.widget_bad)
        self.layout_bad.setAlignment(Qt.AlignTop)
        self.layout_bad.setContentsMargins(4,4,4,4)
        self.scroll_bad.setWidget(self.widget_bad)
        self.tab_idx_bad = self.tab_widget.addTab(self.scroll_bad, f"❌ 不合格图片({self.bad_count}张)")
        self.scroll_good = QScrollArea()
        self.scroll_good.setWidgetResizable(True)
        self.scroll_good.setStyleSheet("QScrollArea{border:none;background:#e8e8e8;}")
        self.widget_good = QWidget()
        self.widget_good.setStyleSheet("background:#e8e8e8;")
        self.layout_good = QVBoxLayout(self.widget_good)
        self.layout_good.setAlignment(Qt.AlignTop)
        self.layout_good.setContentsMargins(4,4,4,4)
        self.scroll_good.setWidget(self.widget_good)
        self.tab_idx_good = self.tab_widget.addTab(self.scroll_good, f"✅ 合格图片({self.good_count}张)")
        right_layout.addWidget(self.tab_widget)
        splitter = QSplitter(Qt.Horizontal)
        splitter.addWidget(left_widget)
        splitter.addWidget(right_outer)
        splitter.setStretchFactor(0, 1)
        splitter.setStretchFactor(1, 4)
        root_layout.addWidget(splitter)
        self.load_dialog = LoadingDialog()

    def select_folder_with_filter(self):
        dlg = FolderFilterDialog(self)
        if not dlg.exec_():
            return
        target_dir = dlg.selected_folder
        filter_exts = dlg.get_filter_exts()
        if not target_dir or len(filter_exts) == 0:
            return
        paths = []
        for root, _, files in os.walk(target_dir):
            for f in files:
                _, ext = os.path.splitext(f)
                ext = ext.lower()
                if ext in filter_exts:
                    paths.append(os.path.join(root, f))
        self.start_scan(paths)

    def refresh_count(self):
        self.label_total.setText(f"已检测图片:{self.total_count} 张")
        self.tab_widget.setTabText(self.tab_idx_bad, f"❌ 不合格图片({self.bad_count}张)")
        self.tab_widget.setTabText(self.tab_idx_good, f"✅ 合格图片({self.good_count}张)")

    def clear_all(self):
        while self.layout_bad.count() > 0:
            w = self.layout_bad.takeAt(0).widget()
            if w: w.deleteLater()
        while self.layout_good.count() > 0:
            w = self.layout_good.takeAt(0).widget()
            if w: w.deleteLater()
        self.total_count = self.bad_count = self.good_count = 0
        self.name_record.clear()
        self.refresh_count()

    def on_one_img(self, name, w, h, state, full_path):
        self.total_count += 1
        item = ResultItem(self, name, w, h, state, full_path)
        if "非4倍数" in state:
            self.bad_count += 1
            self.layout_bad.addWidget(item)
        else:
            self.good_count += 1
            self.layout_good.addWidget(item)
        self.refresh_count()

    def on_all_done(self, name_map):
        self.name_record = name_map
        self.load_dialog.close()
        self.check_thread = None

    def start_scan(self, path_list):
        if not path_list:
            return
        self.check_thread = CheckImageThread(path_list)
        self.check_thread.one_finish.connect(self.on_one_img)
        self.check_thread.all_finish.connect(self.on_all_done)
        self.load_dialog.show()
        self.check_thread.start()

    def select_images(self):
        filter_str = (
            "全部图片格式 (*.png *.jpg *.jpeg *.bmp *.tga);;"
            "PNG图片 (*.png);;"
            "JPG图片 (*.jpg *.jpeg);;"
            "BMP图片 (*.bmp);;"
            "TGA图片 (*.tga)"
        )
        paths, _ = QFileDialog.getOpenFileNames(self, "选择图片", "", filter_str)
        self.start_scan(paths)

    def dragEnterEvent(self, e):
        if e.mimeData().hasUrls():
            e.acceptProposedAction()
    def dropEvent(self, e):
        paths = []
        for url in e.mimeData().urls():
            p = url.toLocalFile()
            if os.path.isdir(p):
                for root, _, files in os.walk(p):
                    for f in files:
                        _, ext = os.path.splitext(f)
                        ext = ext.lower()
                        if ext in ALL_EXT:
                            paths.append(os.path.join(root, f))
            elif os.path.isfile(p):
                paths.append(p)
        self.start_scan(paths)

if __name__ == "__main__":
    app = QApplication(sys.argv)
    win = MainWindow()
    win.show()
    sys.exit(app.exec_())

posted @ 2026-07-27 20:19  mdyyyds_blog  阅读(3)  评论(0)    收藏  举报