切图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_())

浙公网安备 33010602011771号