文件夹中同名图片对比 & 替换工具

需求

有一批图片需要同名替换,项目里的图片散落在不同的文件夹,替换起来很麻烦

Unity贴图替换检索工具

核心目标

  • 美术提供一批新版图片,批量检索 Unity 工程,区分可直接同名替换的旧贴图 和 工程不存在、需要新增导入的贴图,配套清单导出、快速定位文件夹、名称复制功能,方便资源替换工作。

部署

  1. 项目内新建 Assets/Editor 文件夹
  2. 放入脚本 TextureReplaceChecker.cs,编译完成
  3. 顶部菜单:工具 → 贴图替换检索工具

操作步骤

  1. 导入图片名单(任选方式)
    ✅ 拖拽外部图片 / 工程贴图到窗口区域
    ✅ 【从文件夹读取图片名称】批量加载
    ✅ 【导入名单 JSON】读取历史批次
    ⚠️ 常规同名替换:不用勾选【保留文件后缀名】
  2. 点击【开始扫描工程匹配目录】
  3. 结果处理
    【待替换资源目录】
    打开文件夹:进入目录覆盖贴图
    导出资源清单:生成纯文件名 txt 用于核对
    【需要新增导入的图片清单】
    支持批量复制全部名称 / 单行单独复制名称
  4. 更换批次:【清空名称列表】重新载入扫描

重要提示

  1. 默认按不带后缀文件名匹配,勾选后缀开启严格全名匹配
  2. 仅检索 Assets 目录图片(png/tga/jpg/psd)
  3. 工具仅查询资源,不会修改工程任何文件,安全可用
点击查看代码
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

核心目标

  1. 选择两个文件夹:资源文件夹(源图片)、待替换文件夹(被覆盖目录)
  2. 自动递归扫描全部子文件夹图片,按不带后缀文件名匹配(大小写忽略)
  3. 扫描结果顶部显示统计:匹配成功数量、无同名资源数量
  4. 列表区分两类条目:
  5. ✅匹配成功:展示双向文件名称
  6. ❌无同名资源:条目右侧附带【复制名称】按钮,一键复制不带后缀文件名
  7. 勾选确认选项后,执行文件覆盖替换,输出替换成功 / 失败统计
  8. 提供【清空扫描结果】按钮,仅清除列表,保留已选文件夹路径

操作步骤

  1. 选择路径:
  2. 资源文件夹:存放正确新版图片(用来覆盖旧图)
  3. 待替换文件夹:里面旧图片会被同名图片覆盖
  4. 先点【先扫描匹配数量】预览有多少张会被替换(建议每次都先扫描)
  5. 确认无误后点击【执行替换】,弹窗确认再覆盖~~~~
点击查看代码
#修复:无同名数据优先展示,修改行高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_())
posted @ 2026-07-27 18:44  mdyyyds_blog  阅读(4)  评论(0)    收藏  举报