文件夹中同名图片对比 & 替换工具
需求
有一批图片需要同名替换,项目里的图片散落在不同的文件夹,替换起来很麻烦
Unity贴图替换检索工具
核心目标
- 美术提供一批新版图片,批量检索 Unity 工程,区分可直接同名替换的旧贴图 和 工程不存在、需要新增导入的贴图,配套清单导出、快速定位文件夹、名称复制功能,方便资源替换工作。
部署
- 项目内新建 Assets/Editor 文件夹
- 放入脚本 TextureReplaceChecker.cs,编译完成
- 顶部菜单:工具 → 贴图替换检索工具
操作步骤
- 导入图片名单(任选方式)
✅ 拖拽外部图片 / 工程贴图到窗口区域
✅ 【从文件夹读取图片名称】批量加载
✅ 【导入名单 JSON】读取历史批次
⚠️ 常规同名替换:不用勾选【保留文件后缀名】 - 点击【开始扫描工程匹配目录】
- 结果处理
【待替换资源目录】
打开文件夹:进入目录覆盖贴图
导出资源清单:生成纯文件名 txt 用于核对
【需要新增导入的图片清单】
支持批量复制全部名称 / 单行单独复制名称 - 更换批次:【清空名称列表】重新载入扫描
重要提示
- 默认按不带后缀文件名匹配,勾选后缀开启严格全名匹配
- 仅检索 Assets 目录图片(png/tga/jpg/psd)
- 工具仅查询资源,不会修改工程任何文件,安全可用
点击查看代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
[Serializable]
public class TextureNameListData
{
public List<string> textureNames = new List<string>();
}
public class FolderMatchInfo
{
public string folderPath;
public int matchCount;
public List<string> matchedAssetPaths = new List<string>();
}
public class TextureReplaceChecker : EditorWindow
{
private List<string> targetTextureNames = new List<string>();
private List<FolderMatchInfo> searchResult = new List<FolderMatchInfo>();
private List<string> newAddedTextureNames = new List<string>();
private Vector2 scrollViewPos;
private bool keepExtension = false;
[MenuItem("工具/贴图替换检索工具")]
static void OpenWindow()
{
TextureReplaceChecker window = GetWindow<TextureReplaceChecker>("贴图替换检索工具");
// 初始化窗口大小,防止初次打开尺寸太小文字裁切
window.minSize = new Vector2(900, 650);
window.position = new Rect(window.position.x, window.position.y, 1100, 720);
}
void OnGUI()
{
GUILayout.Space(8);
GUILayout.Label("待匹配图片名称列表(拖拽贴图到下方区域添加)", EditorStyles.boldLabel);
keepExtension = EditorGUILayout.ToggleLeft("名称列表【保留文件后缀名】", keepExtension);
Rect dropArea = EditorGUILayout.GetControlRect(GUILayout.Height(60));
GUI.Box(dropArea, "拖拽Texture到此添加名称【支持工程外本地图片文件】");
HandleDragDrop(dropArea);
GUILayout.Space(6);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("从文件夹读取图片名称"))
{
LoadImageNamesFromFolder();
}
if (GUILayout.Button("清空名称列表"))
{
targetTextureNames.Clear();
searchResult.Clear();
newAddedTextureNames.Clear();
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("导出名单JSON"))
{
ExportJsonList();
}
if (GUILayout.Button("导入名单JSON"))
{
ImportJsonList();
}
EditorGUILayout.EndHorizontal();
GUILayout.Space(8);
int existCount = targetTextureNames.Count - newAddedTextureNames.Count;
EditorGUILayout.LabelField($"总载入图片数量:{targetTextureNames.Count}", EditorStyles.boldLabel);
GUI.color = Color.green;
EditorGUILayout.LabelField($"✅ 工程内可替换贴图:{existCount}", EditorStyles.miniLabel);
GUI.color = Color.white;
GUI.color = new Color(1f, 0.6f, 0f);
EditorGUILayout.LabelField($"➕ 需要新增导入贴图:{newAddedTextureNames.Count}", EditorStyles.miniLabel);
GUI.color = Color.white;
if (GUILayout.Button("开始扫描工程匹配目录", GUILayout.Height(30)))
{
RunSearch();
}
GUILayout.Space(10);
scrollViewPos = EditorGUILayout.BeginScrollView(scrollViewPos);
GUILayout.Label("【待替换资源目录】目录|匹配数量", EditorStyles.boldLabel);
foreach (var info in searchResult)
{
EditorGUILayout.BeginHorizontal("Box");
// 设置最小宽度,保证长路径不会被强行挤压截断
EditorGUILayout.LabelField($"【{info.matchCount}】 {info.folderPath}", EditorStyles.miniLabel, GUILayout.MinWidth(600));
GUILayout.FlexibleSpace();
if (GUILayout.Button("打开文件夹", GUILayout.Width(90)))
{
string fullPath = Path.GetFullPath(info.folderPath);
EditorUtility.RevealInFinder(fullPath);
}
if (GUILayout.Button("导出资源清单", GUILayout.Width(110)))
{
ExportFolderAssetList(info);
}
EditorGUILayout.EndHorizontal();
}
// ========== 新增贴图清单区域 ==========
if (newAddedTextureNames.Count > 0)
{
GUILayout.Space(12);
GUI.color = new Color(1f, 0.6f, 0f);
GUILayout.Label("【需要新增导入的图片清单】", EditorStyles.boldLabel);
GUI.color = Color.white;
if (GUILayout.Button("复制全部新增名称到剪贴板"))
{
string allNames = string.Join("\n", newAddedTextureNames);
EditorGUIUtility.systemCopyBuffer = allNames;
EditorUtility.DisplayDialog("完成", "所有新增名称已复制!每行一个名称", "确定");
}
foreach (var name in newAddedTextureNames)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(name, EditorStyles.miniLabel, GUILayout.MinWidth(600));
GUILayout.FlexibleSpace();
if (GUILayout.Button("复制名称", GUILayout.Width(80)))
{
EditorGUIUtility.systemCopyBuffer = name;
EditorUtility.DisplayDialog("复制成功", $"已复制:{name}", "确定");
}
EditorGUILayout.EndHorizontal();
}
}
else if (targetTextureNames.Count > 0)
{
GUILayout.Space(12);
GUILayout.Label("【需要新增导入的图片清单】无", EditorStyles.miniLabel);
}
EditorGUILayout.EndScrollView();
}
void HandleDragDrop(Rect areaRect)
{
Event evt = Event.current;
if (!areaRect.Contains(evt.mousePosition)) return;
switch (evt.type)
{
case EventType.DragUpdated:
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
evt.Use();
break;
case EventType.DragPerform:
DragAndDrop.AcceptDrag();
foreach (UnityEngine.Object obj in DragAndDrop.objectReferences)
{
if (obj is Texture tex)
{
string assetPath = AssetDatabase.GetAssetPath(tex);
string fileName;
if (keepExtension)
fileName = Path.GetFileName(assetPath);
else
fileName = Path.GetFileNameWithoutExtension(assetPath);
if (!targetTextureNames.Contains(fileName))
targetTextureNames.Add(fileName);
}
}
foreach (string filePath in DragAndDrop.paths)
{
if (!File.Exists(filePath)) continue;
string ext = Path.GetExtension(filePath).ToLower();
HashSet<string> extFilter = new HashSet<string> { ".png", ".tga", ".jpg", ".jpeg", ".psd" };
if (!extFilter.Contains(ext)) continue;
string fileName;
if (keepExtension)
fileName = Path.GetFileName(filePath);
else
fileName = Path.GetFileNameWithoutExtension(filePath);
if (!targetTextureNames.Contains(fileName))
targetTextureNames.Add(fileName);
}
evt.Use();
Repaint();
break;
}
}
void LoadImageNamesFromFolder()
{
string selectFolder = EditorUtility.OpenFolderPanel("选择存放图片的文件夹", "", "");
if (string.IsNullOrEmpty(selectFolder)) return;
var files = Directory.GetFiles(selectFolder);
HashSet<string> extFilter = new HashSet<string> { ".png", ".tga", ".jpg", ".jpeg", ".psd" };
foreach (var file in files)
{
string ext = Path.GetExtension(file).ToLower();
if (!extFilter.Contains(ext)) continue;
string fileName;
if (keepExtension)
fileName = Path.GetFileName(file);
else
fileName = Path.GetFileNameWithoutExtension(file);
if (!targetTextureNames.Contains(fileName))
targetTextureNames.Add(fileName);
}
EditorUtility.DisplayDialog("完成", $"成功读取图片名称", "OK");
}
void ExportFolderAssetList(FolderMatchInfo info)
{
string savePath = EditorUtility.SaveFilePanel("保存资源清单", "", Path.GetFileName(info.folderPath) + "_资源清单.txt", "txt");
if (string.IsNullOrEmpty(savePath))
return;
List<string> nameList = new List<string>();
foreach (var path in info.matchedAssetPaths)
{
string fileName = Path.GetFileNameWithoutExtension(path);
nameList.Add(fileName);
}
string content = $"目录路径:{info.folderPath}\r\n匹配资源总数:{info.matchCount}\r\n\r\n资源名称列表:\r\n";
content += string.Join("\r\n", nameList);
File.WriteAllText(savePath, content);
EditorUtility.DisplayDialog("导出成功", $"清单已保存至:\n{savePath}", "确定");
}
void RunSearch()
{
if (targetTextureNames.Count == 0)
{
EditorUtility.DisplayDialog("提示", "请先添加待检索图片", "OK");
return;
}
searchResult.Clear();
newAddedTextureNames.Clear();
Dictionary<string, FolderMatchInfo> folderDict = new Dictionary<string, FolderMatchInfo>();
HashSet<string> targetSet = new HashSet<string>(targetTextureNames);
HashSet<string> foundNames = new HashSet<string>();
string[] allAssets = AssetDatabase.GetAllAssetPaths();
foreach (var assetPath in allAssets)
{
if (!assetPath.StartsWith("Assets")) continue;
string ext = Path.GetExtension(assetPath).ToLower();
if (ext == ".meta") continue;
if (!new HashSet<string> { ".png", ".tga", ".jpg", ".jpeg", ".psd" }.Contains(ext)) continue;
string fileNameNoExt = Path.GetFileNameWithoutExtension(assetPath);
string searchKey = keepExtension ? Path.GetFileName(assetPath) : fileNameNoExt;
if (!targetSet.Contains(searchKey)) continue;
foundNames.Add(searchKey);
string folder = Path.GetDirectoryName(assetPath);
if (!folderDict.ContainsKey(folder))
{
folderDict[folder] = new FolderMatchInfo()
{
folderPath = folder
};
}
folderDict[folder].matchCount++;
folderDict[folder].matchedAssetPaths.Add(assetPath);
}
searchResult = folderDict.Values.ToList();
foreach (var name in targetTextureNames)
{
if (!foundNames.Contains(name))
{
newAddedTextureNames.Add(name);
}
}
EditorUtility.DisplayDialog("扫描结束",
$"可替换文件夹数量:{searchResult.Count}\n需要新增贴图数量:{newAddedTextureNames.Count}",
"OK");
}
void ExportJsonList()
{
string savePath = EditorUtility.SaveFilePanel("导出名称清单", "", "TextureList.json", "json");
if (string.IsNullOrEmpty(savePath)) return;
TextureNameListData data = new TextureNameListData();
data.textureNames = new List<string>(targetTextureNames);
string json = JsonUtility.ToJson(data, prettyPrint: true);
File.WriteAllText(savePath, json);
}
void ImportJsonList()
{
string loadPath = EditorUtility.OpenFilePanel("导入名称清单", "", "json");
if (string.IsNullOrEmpty(loadPath)) return;
string jsonText = File.ReadAllText(loadPath);
TextureNameListData data = JsonUtility.FromJson<TextureNameListData>(jsonText);
targetTextureNames = new List<string>(data.textureNames);
searchResult.Clear();
newAddedTextureNames.Clear();
}
}
一键替换同名资源
文件夹同名图片对比 & 替换工具_0.01
核心目标
- 选择两个文件夹:资源文件夹(源图片)、待替换文件夹(被覆盖目录)
- 自动递归扫描全部子文件夹图片,按不带后缀文件名匹配(大小写忽略)
- 扫描结果顶部显示统计:匹配成功数量、无同名资源数量
- 列表区分两类条目:
- ✅匹配成功:展示双向文件名称
- ❌无同名资源:条目右侧附带【复制名称】按钮,一键复制不带后缀文件名
- 勾选确认选项后,执行文件覆盖替换,输出替换成功 / 失败统计
- 提供【清空扫描结果】按钮,仅清除列表,保留已选文件夹路径
操作步骤
- 选择路径:
- 资源文件夹:存放正确新版图片(用来覆盖旧图)
- 待替换文件夹:里面旧图片会被同名图片覆盖
- 先点【先扫描匹配数量】预览有多少张会被替换(建议每次都先扫描)
- 确认无误后点击【执行替换】,弹窗确认再覆盖~~~~

点击查看代码
#修复:无同名数据优先展示,修改行高40
import sys
import os
import shutil
from PyQt5.QtWidgets import (
QApplication, QDialog, QWidget, QHBoxLayout, QVBoxLayout,
QPushButton, QLineEdit, QLabel, QFileDialog, QListWidget,
QListWidgetItem, QCheckBox, QMessageBox
)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QClipboard
IMG_EXT = {".png", ".jpg", ".jpeg", ".bmp", ".tga"}
ROW_HEIGHT = 40 # 统一行高,防止按钮挤压
class FolderCompareDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("文件夹同名图片对比 & 替换工具")
self.resize(900, 600)
self.src_folder = "" # 源文件夹(用来复制)
self.dst_folder = "" # 待替换文件夹(被覆盖)
self.match_list = [] # 储存匹配成功 (源,目标)
self.clipboard = QApplication.clipboard()
main_layout = QVBoxLayout()
# ========== 路径选择行 ==========
path_layout = QHBoxLayout()
self.src_edit = QLineEdit()
self.src_edit.setPlaceholderText("【资源文件夹】存放原版图片")
btn_src = QPushButton("选择资源目录")
btn_src.clicked.connect(self.select_src)
self.dst_edit = QLineEdit()
self.dst_edit.setPlaceholderText("【待替换文件夹】将被覆盖")
btn_dst = QPushButton("选择待替换目录")
btn_dst.clicked.connect(self.select_dst)
path_layout.addWidget(QLabel("源:"))
path_layout.addWidget(self.src_edit)
path_layout.addWidget(btn_src)
path_layout.addSpacing(10)
path_layout.addWidget(QLabel("目标:"))
path_layout.addWidget(self.dst_edit)
path_layout.addWidget(btn_dst)
# ========== 操作按钮行 ==========
btn_layout = QHBoxLayout()
self.btn_scan = QPushButton("开始扫描同名图片")
self.btn_scan.clicked.connect(self.scan_files)
self.btn_clear_result = QPushButton("清空扫描结果")
self.btn_clear_result.clicked.connect(self.clear_scan_result)
self.check_replace = QCheckBox("确认允许覆盖目标文件夹内同名图片")
self.btn_execute = QPushButton("执行替换")
self.btn_execute.setEnabled(False)
btn_layout.addWidget(self.btn_scan)
btn_layout.addWidget(self.btn_clear_result)
btn_layout.addStretch()
btn_layout.addWidget(self.check_replace)
btn_layout.addWidget(self.btn_execute)
# ========== 结果列表 ==========
self.result_list = QListWidget()
# 修复变量名错误
self.result_list.setStyleSheet("QListWidget::item {height:%dpx;}" % ROW_HEIGHT)
main_layout.addLayout(path_layout)
main_layout.addLayout(btn_layout)
main_layout.addWidget(self.result_list)
self.setLayout(main_layout)
def select_src(self):
folder = QFileDialog.getExistingDirectory(self, "选择【资源文件夹】")
if folder:
self.src_folder = folder
self.src_edit.setText(folder)
def select_dst(self):
folder = QFileDialog.getExistingDirectory(self, "选择【待替换文件夹】")
if folder:
self.dst_folder = folder
self.dst_edit.setText(folder)
# 清空扫描数据,保留路径
def clear_scan_result(self):
self.result_list.clear()
self.match_list.clear()
self.btn_execute.setEnabled(False)
def copy_file_name(self, base_name):
self.clipboard.setText(base_name)
def scan_files(self):
self.result_list.clear()
self.match_list.clear()
src_dir = self.src_folder
dst_dir = self.dst_folder
if not (os.path.isdir(src_dir) and os.path.isdir(dst_dir)):
QMessageBox.warning(self, "提示", "请先选择两个有效文件夹!")
return
# 递归读取图片
def scan_all_image(root_folder):
file_dict = {}
for root, _, files in os.walk(root_folder):
for name in files:
base, ext = os.path.splitext(name)
ext = ext.lower()
if ext in IMG_EXT:
key = base.lower()
file_dict[key] = (os.path.join(root, name), base)
return file_dict
dst_map = scan_all_image(dst_dir)
src_map = scan_all_image(src_dir)
match_count = 0
no_match_count = 0
match_items = [] # 匹配成功条目(后显示)
nomatch_items = [] # 未匹配条目(置顶显示)
for key, (src_path, base_name) in src_map.items():
src_filename = os.path.basename(src_path)
if key in dst_map:
dst_path = dst_map[key][0]
dst_filename = os.path.basename(dst_path)
self.match_list.append((src_path, dst_path))
match_items.append((base_name, src_filename, dst_filename))
match_count += 1
else:
nomatch_items.append((base_name, src_filename))
no_match_count += 1
# 1. 顶部统计行
stat_item = QListWidgetItem()
stat_item.setSizeHint(QSize(-1, ROW_HEIGHT))
stat_item.setText(f"扫描完成:匹配可替换 {match_count} 张,无同名资源 {no_match_count} 张")
self.result_list.addItem(stat_item)
# 2. 优先添加【未找到同名】条目(置顶)
for base_name, src_filename in nomatch_items:
item = QListWidgetItem()
item.setSizeHint(QSize(-1, ROW_HEIGHT))
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(15)
label = QLabel(f"❌ 未找到同名 | {src_filename}")
btn_copy = QPushButton("复制名称")
btn_copy.setFixedWidth(100)
btn_copy.clicked.connect(lambda checked, name=base_name: self.copy_file_name(name))
layout.addWidget(label)
layout.addStretch()
layout.addWidget(btn_copy)
self.result_list.addItem(item)
self.result_list.setItemWidget(item, container)
# 3. 再添加【匹配成功】条目
for base_name, src_filename, dst_filename in match_items:
item = QListWidgetItem()
item.setSizeHint(QSize(-1, ROW_HEIGHT))
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(12, 10, 12, 10)
label = QLabel(f"✅ 匹配成功 | {src_filename} --> {dst_filename}")
layout.addWidget(label)
layout.addStretch()
self.result_list.addItem(item)
self.result_list.setItemWidget(item, container)
if match_count > 0:
self.btn_execute.setEnabled(True)
else:
self.btn_execute.setEnabled(False)
def do_replace(self):
if not self.check_replace.isChecked():
QMessageBox.warning(self, "禁止操作", "请勾选【确认允许覆盖目标文件夹内同名图片】才能执行!")
return
success = 0
fail = 0
for src_path, dst_path in self.match_list:
try:
shutil.copy2(src_path, dst_path)
success += 1
except Exception as e:
fail += 1
QMessageBox.information(self, "执行完成",
f"替换成功:{success} 张\n替换失败:{fail} 张")
# 绑定替换按钮点击事件
if __name__ == "__main__":
app = QApplication(sys.argv)
dialog = FolderCompareDialog()
dialog.btn_execute.clicked.connect(dialog.do_replace)
dialog.show()
sys.exit(app.exec_())

浙公网安备 33010602011771号