bl_info = { "name": "Material Image Remapper", "author": "Chipp Walters, Anthony Aragues", "version": (1, 0, 1), "blender": (2, 82, 0), "location": "View3D > Tool Shelf > ♥U", "description": "Finds duplicate images in all scene materials and remaps them to one", "warning": "", "wiki_url": "https://www.chippwalters.com", "category": "Edit", } import bpy import os import hashlib import tempfile # Ensure the temp directory exists temp_dir = tempfile.gettempdir() os.makedirs(temp_dir, exist_ok=True) def save_and_hash_image(image, temp_dir, scene): """Save the image to a temporary file and calculate its hash.""" temp_filename = os.path.join(temp_dir, image.name) temp_filepath = bpy.path.abspath(temp_filename) # Attempt to save the image to the temp directory try: # Ensure the scene parameter is correctly passed as a keyword argument image.save_render(filepath=temp_filepath, scene=scene) print(f"*** Image saved to: {temp_filepath}") except Exception as e: print(f"*** Error saving image {image.name}: {e}") return None # Calculate the hash of the saved image try: with open(temp_filepath, 'rb') as f: img_data = f.read() img_hash = hashlib.md5(img_data).hexdigest() print(f"*** Hash for {image.name}: {img_hash}") except Exception as e: print(f"*** Error hashing image {image.name}: {e}") return None # Optionally, clean up the temporary file os.remove(temp_filepath) print(f"*** Temporary file removed: {temp_filepath}") return img_hash def image_hash(image, temp_dir): """Generate a hash for an image, handling both packed and unpacked images.""" if image.packed_file: # Pass the current scene from the context to save_and_hash_image return save_and_hash_image(image, temp_dir, bpy.context.scene) else: # For unpacked images, directly hash the file data try: abs_path = bpy.path.abspath(image.filepath) with open(abs_path, 'rb') as f: img_data = f.read() img_hash = hashlib.md5(img_data).hexdigest() return img_hash except Exception as e: print(f"*** Error processing image {image.name}: {e}") return None def check_image_nodes(node, images_info, seen_hashes, temp_dir): """Recursive function to check nodes for image textures, including in node groups, avoiding duplicates based on hash.""" if node.type == 'TEX_IMAGE': image = node.image if image is not None: img_hash = image_hash(image, temp_dir) if img_hash and img_hash not in seen_hashes: seen_hashes.add(img_hash) # Mark this hash as seen image_info = { 'name': image.name, 'filepath': image.filepath if not image.packed_file else "Packed Image", 'exists': os.path.exists(image.filepath) if not image.packed_file else "N/A (packed image)", 'packed': image.packed_file is not None, 'hash': img_hash } images_info.append(image_info) elif node.type == 'GROUP': # Dive into node groups for group_node in node.node_tree.nodes: check_image_nodes(group_node, images_info, seen_hashes, temp_dir) def process_materials(temp_dir): """Process all materials in the scene.""" all_images = [] hash_to_image_info = {} # Maps hash values to image info for detecting duplicates duplicates = {} # Dictionary to hold duplicate image mappings (old image -> unique image) for mat in bpy.data.materials: if mat.use_nodes: images_info = [] seen_hashes = set() # Keep track of image hashes for node in mat.node_tree.nodes: check_image_nodes(node, images_info, seen_hashes, temp_dir) for image_info in images_info: img_hash = image_info['hash'] if img_hash in hash_to_image_info: # If the hash is already present, this is a duplicate old_image_info = image_info unique_image_info = hash_to_image_info[img_hash] duplicates[old_image_info['name']] = unique_image_info['name'] else: # New unique image, track it hash_to_image_info[img_hash] = image_info all_images.append(image_info) # Track all processed images # Print out all unique images found in the scene print("\n*** All images processed in the scene: " + str(len(duplicates))) for image in all_images: print(f"- {image['name']} (Filepath: {image['filepath']}, Packed: {image['packed']}, Hash: {image['hash']})") # Print duplicates num_replaced = 0 if duplicates: for old_image, unique_image in duplicates.items(): if old_image != unique_image: print(old_image,unique_image) print("\n*** Duplicate images found:") print(f"- {old_image} -> {unique_image}") # Replace all occurrences of old_image with unique_image in materials replace_duplicate_image(old_image, unique_image) num_replaced += 1 else: print("*** No duplicate images found.") #num_replaced = 1 #num_replaced -= 1 return num_replaced def replace_duplicate_image(old_image_name, unique_image_name): """Replace all occurrences of old_image_name with unique_image_name in materials.""" print(f"*** Replacing {old_image_name} with {unique_image_name} in materials...") for mat in bpy.data.materials: if mat.use_nodes: for node in mat.node_tree.nodes: replace_image_in_node(node, old_image_name, unique_image_name) def replace_image_in_node(node, old_image_name, unique_image_name): """Replace old_image_name with unique_image_name in a single node.""" if node.type == 'TEX_IMAGE' and node.image.name == old_image_name: print(f"*** Found {old_image_name} in node {node.name}") node.image = bpy.data.images[unique_image_name] print(f"*** Replaced {old_image_name} with {unique_image_name} in node {node.name}") elif node.type == 'GROUP': # Recursively replace images within node groups for group_node in node.node_tree.nodes: replace_image_in_node(group_node, old_image_name, unique_image_name) class SimpleConfirmOperator(bpy.types.Operator): """Simple Operator to confirm remapping.""" bl_idname = "object.simple_confirm_operator" bl_label = "Remap Material Images" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): num_replaced = process_materials(temp_dir) self.report({'INFO'}, f"Number of images replaced: " + str(num_replaced)) print("Images replaced " + str(num_replaced)) return {'FINISHED'} def invoke(self, context, event): wm = context.window_manager return wm.invoke_confirm(self, event) class CleanUpUnusedMatImagesOperator(bpy.types.Operator): bl_idname = "wm.clean_up_unused_mat_images" bl_label = "Clean Up Unused Mat Images" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): # Remove unused images removed_images = self.remove_unused_images() if removed_images: print("Removed unused material images:") for image_name in removed_images: print(f"- {image_name}") else: print("No unused material images found.") self.report({'INFO'}, f"Number of images replaced: " + str(len(removed_images))) return {'FINISHED'} def invoke(self, context, event): return context.window_manager.invoke_confirm(self, event) def draw(self, context): self.layout.label(text="Are you sure you want to clean up unused material images?") # Additional UI components can be added here if necessary. def remove_unused_images(self): removed_image_names = [] images_to_remove = [image for image in bpy.data.images if not image.users] for image in images_to_remove: removed_image_names.append(image.name) bpy.data.images.remove(image) return removed_image_names class SimplePanel(bpy.types.Panel): """Panel to house the Remap Material Images button.""" bl_label = "Remap Material Images Panel" bl_idname = "OBJECT_PT_simple_panel" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = 'Edit' def draw(self, context): layout = self.layout layout.operator("object.simple_confirm_operator", text="Remap Material Images") layout.operator("wm.clean_up_unused_mat_images", text="Delete Unused Images") def register(): bpy.utils.register_class(SimpleConfirmOperator) bpy.utils.register_class(SimplePanel) bpy.utils.register_class(CleanUpUnusedMatImagesOperator) def unregister(): bpy.utils.unregister_class(SimpleConfirmOperator) bpy.utils.unregister_class(SimplePanel) bpy.utils.unregister_class(CleanUpUnusedMatImagesOperator) if __name__ == "__main__": register()