#!/usr/bin/env python3 import gradio as gr import trimesh import numpy as np import tempfile import zipfile import requests import os from pathlib import Path from typing import List, Tuple, Optional, Dict, Any from src import convert_meshes def load_mesh( file_path: str, ) -> Optional[Tuple[List[Tuple[str, trimesh.Trimesh]], Optional[Dict]]]: try: loaded = trimesh.load(str(file_path)) if isinstance(loaded, trimesh.Scene): mesh_list = [] scene_data = {"graph": loaded.graph, "transforms": {}} for geom_name, geom in loaded.geometry.items(): if hasattr(geom, "faces") and len(geom.faces) > 0: mesh_list.append((geom_name, geom)) # Store transform for this geometry nodes = loaded.graph.geometry_nodes.get(geom_name, []) if nodes: scene_data["transforms"][geom_name] = loaded.graph.get( nodes[0] )[0] else: scene_data["transforms"][geom_name] = np.eye(4) return (mesh_list, scene_data) if mesh_list else None elif hasattr(loaded, "faces"): # Single mesh case return ([("mesh", loaded)], None) else: return None except Exception as e: print(f"Error loading {file_path}: {e}") return None def export_processed_meshes(result, output_path, progress, total_files): """Export processed meshes, reconstructing scenes when appropriate.""" processed_models = [] # Group meshes by their original file file_groups = {} for name, mesh in result.meshes: # Extract file name from combined name (e.g., "Lantern_LanternPole_Body" -> "Lantern") if "_" in name and result.scene_metadata: parts = name.split("_", 1) file_name = parts[0] mesh_name = parts[1] if len(parts) > 1 else "mesh" else: file_name = name mesh_name = "mesh" if file_name not in file_groups: file_groups[file_name] = [] file_groups[file_name].append((mesh_name, mesh)) # Export each file group for i, (file_name, meshes) in enumerate(file_groups.items()): progress_desc = f"Saving {file_name}..." progress( (total_files + 1 + i / len(file_groups)) / (total_files + 2), desc=progress_desc, ) # Check if this file had scene metadata has_scene = result.scene_metadata and file_name in result.scene_metadata if has_scene and len(meshes) > 1: # Reconstruct and export as Scene scene = trimesh.Scene() scene_data = result.scene_metadata[file_name] for mesh_name, mesh in meshes: # Get transform for this mesh transform = scene_data["transforms"].get(mesh_name, np.eye(4)) # Add to scene with proper naming scene.add_geometry( mesh, node_name=mesh_name, geom_name=mesh_name, transform=transform ) # Export the scene model_path = output_path / f"{file_name}_palettized.glb" scene.export(str(model_path)) processed_models.append(str(model_path)) else: # Export individual meshes for mesh_name, mesh in meshes: if len(meshes) > 1: model_path = output_path / f"{file_name}_{mesh_name}_palettized.glb" else: model_path = output_path / f"{file_name}_palettized.glb" mesh.export(str(model_path), include_normals=True) processed_models.append(str(model_path)) return processed_models def download_from_urls( urls_text: str, progress=gr.Progress() ) -> Tuple[List[str], List[str]]: if not urls_text or not urls_text.strip(): return [], [] urls = [url.strip() for url in urls_text.strip().split("\n") if url.strip()] downloaded_files = [] failed_urls = [] temp_dir = tempfile.mkdtemp(prefix="glb_downloads_") for i, url in enumerate(urls): progress((i + 1) / len(urls), desc=f"Downloading {i + 1}/{len(urls)}...") try: filename = os.path.basename(url.split("?")[0]) if not filename or not filename.endswith((".glb", ".gltf")): filename = f"model_{i + 1}.glb" file_path = os.path.join(temp_dir, filename) response = requests.get(url, timeout=30) response.raise_for_status() with open(file_path, "wb") as f: f.write(response.content) downloaded_files.append(file_path) except Exception as e: print(f"Failed to download {url}: {e}") failed_urls.append(url) return downloaded_files, failed_urls def process_batch( files: List[Any], atlas_size: int, sample_rate: float, simplify_details: bool, detail_filter_diameter: int, detail_color_sigma: int, detail_space_sigma: int, progress=gr.Progress(), ) -> Tuple[Optional[str], List[str], Optional[str], str, Dict]: if not files: return None, [], None, "No files to process.", {} progress(0, desc="Starting batch processing...") output_dir = tempfile.mkdtemp(prefix="glb_atlas_") output_path = Path(output_dir) mesh_list = [] failed_files = [] scene_metadata = {} for i, file in enumerate(files): if hasattr(file, "name"): file_path = file.name display_name = Path(file.name).name else: file_path = file display_name = Path(file).name progress((i + 1) / (len(files) + 2), desc=f"Loading {display_name}...") file_name = Path(file_path).stem loaded_data = load_mesh(file_path) if loaded_data is not None: meshes, scene_data = loaded_data # Store scene data if present if scene_data: scene_metadata[file_name] = scene_data # Add all meshes from this file to the list for mesh_name, mesh in meshes: # Create unique name combining file and mesh names if len(meshes) > 1: combined_name = f"{file_name}_{mesh_name}" else: combined_name = file_name mesh_list.append((combined_name, mesh)) else: failed_files.append(display_name) if not mesh_list: return ( None, [], None, "No valid meshes could be loaded from the uploaded files.", {}, ) try: progress(len(files) / (len(files) + 2), desc="Generating texture atlas...") detail_sensitivity = ( (detail_filter_diameter, detail_color_sigma, detail_space_sigma) if simplify_details else None ) result = convert_meshes( mesh_list, atlas_size=atlas_size, face_sampling_ratio=sample_rate, simplify_details=simplify_details, detail_sensitivity=detail_sensitivity, scene_metadata=scene_metadata, ) atlas_path = output_path / "shared_palette.png" result.atlas.save(atlas_path) # Export processed meshes, reconstructing scenes when appropriate processed_models = export_processed_meshes( result, output_path, progress, len(files) ) status = f"āœ“ Processed {len(result.meshes)} model(s)\nšŸ“Š Atlas: {atlas_size}Ɨ{atlas_size} pixels" if failed_files: status += f"\n⚠ Failed: {len(failed_files)} file(s)" # Extract display names for the processed models display_names = [] for model_path in processed_models: model_name = Path(model_path).stem if model_name.endswith("_palettized"): model_name = model_name[:-11] # Remove "_palettized" suffix display_names.append(model_name) metadata = { "models": processed_models, "names": display_names, "atlas_path": str(atlas_path), "output_dir": output_dir, "total": len(processed_models), } progress(1.0, desc="Processing complete!") first_model = processed_models[0] if processed_models else None return str(atlas_path), processed_models, first_model, status, metadata except Exception as e: return None, [], None, f"Error during processing: {str(e)}", {} def update_model_viewer( direction: str, current_index: int, metadata: Dict ) -> Tuple[Optional[str], int, str]: if not metadata or "models" not in metadata: return None, 0, "No models to display" models = metadata["models"] names = metadata["names"] total = metadata["total"] if not models: return None, 0, "No models available" if direction == "next": new_index = (current_index + 1) % total elif direction == "prev": new_index = (current_index - 1) % total else: new_index = 0 model_path = models[new_index] model_name = names[new_index] label = f"Model {new_index + 1} of {total}: {model_name}" return model_path, new_index, label def create_download_zip(metadata: Dict) -> Optional[str]: if not metadata or "output_dir" not in metadata: return None output_dir = Path(metadata["output_dir"]) zip_path = output_dir / "glb_atlas_output.zip" try: with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: if "atlas_path" in metadata: atlas_path = Path(metadata["atlas_path"]) if atlas_path.exists(): zipf.write(atlas_path, atlas_path.name) if "models" in metadata: for model_path in metadata["models"]: model_file = Path(model_path) if model_file.exists(): zipf.write(model_file, model_file.name) return str(zip_path) except Exception as e: print(f"Error creating ZIP: {e}") return None with gr.Blocks( title="Mesh Palettizer", theme=gr.themes.Soft(), css=""" #atlas-display img { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; } """, ) as demo: model_index = gr.State(value=0) processing_metadata = gr.State(value={}) gr.Markdown( """ # šŸŽØ Mesh Palettizer Simplify 3D model textures using optimized color palettes. Upload GLB/GLTF models to create clean, palettized textures for stylized rendering. """ ) with gr.Row(): with gr.Column(scale=1): with gr.Tabs() as input_tabs: with gr.Tab("šŸ“ Upload Files"): file_input = gr.File( label="Select GLB/GLTF Files", file_count="multiple", file_types=[".glb", ".gltf"], type="filepath", ) gr.Examples( examples=[[["examples/Duck.glb", "examples/Lantern.glb"]]], inputs=file_input, label="Example Models", ) with gr.Tab("šŸ”— Load from URLs"): url_input = gr.Textbox( label="Enter URLs (one per line)", placeholder="https://example.com/model1.glb\nhttps://example.com/model2.glb", lines=5, interactive=True, ) atlas_size = gr.Dropdown( choices=[8, 16, 32, 64, 128, 256, 512, 1024], value=32, label="Atlas Size", info="NƗN pixels", ) with gr.Accordion("Advanced", open=False): sample_rate = gr.Slider( minimum=0.01, maximum=1.0, value=0.1, step=0.01, label="Sampling Rate", info="% of faces to sample", ) simplify_details = gr.Checkbox( value=True, label="Remove Texture Details", info="Apply bilateral filter to remove fine details (scales, fur, etc.)", ) with gr.Row(visible=True) as detail_controls: detail_filter_diameter = gr.Slider( minimum=5, maximum=15, value=9, step=2, label="Filter Diameter", info="Pixel neighborhood diameter (higher = stronger smoothing)", ) detail_color_sigma = gr.Slider( minimum=25, maximum=150, value=75, step=5, label="Color Sensitivity", info="Color difference threshold (higher = more colors mixed)", ) detail_space_sigma = gr.Slider( minimum=25, maximum=150, value=75, step=5, label="Spatial Sensitivity", info="Spatial extent (higher = pixels farther apart influence each other)", ) process_btn = gr.Button("šŸš€ Process", variant="primary", size="lg") status_text = gr.Textbox( label="Status", lines=2, interactive=False, show_label=False ) with gr.Column(scale=2): with gr.Tabs(): with gr.Tab("šŸ“Š Palette"): atlas_image = gr.Image( label="Color Palette", type="filepath", show_download_button=True, height=400, container=True, elem_id="atlas-display", ) with gr.Tab("šŸŽ® 3D Preview"): model_label = gr.Markdown("") model_viewer = gr.Model3D( label="Model", height=400, clear_color=[0.95, 0.95, 0.95, 1.0] ) with gr.Row(): prev_btn = gr.Button("ā—€", size="sm") model_counter = gr.Markdown( "Model 1 of 1", elem_id="model-counter" ) next_btn = gr.Button("ā–¶", size="sm") with gr.Row(): download_btn = gr.Button( "šŸ“¦ Download All", variant="secondary", size="lg" ) download_file = gr.File(label="Package", visible=False) def toggle_detail_controls(enabled): return gr.update(visible=enabled) simplify_details.change( fn=toggle_detail_controls, inputs=[simplify_details], outputs=[detail_controls] ) def process_from_files( files, atlas_size, sample_rate, simplify_details, detail_filter_diameter, detail_color_sigma, detail_space_sigma, ): if not files: return ( None, None, "Please upload files first.", {}, 0, "", "", gr.update(visible=False), ) atlas_path, models, first_model, status, metadata = process_batch( files, atlas_size, sample_rate, simplify_details, detail_filter_diameter, detail_color_sigma, detail_space_sigma, ) if models: viewer_label = metadata["names"][0] counter_text = f"Model 1 of {len(models)}" else: viewer_label = "" counter_text = "" return ( atlas_path, first_model, status, metadata, 0, viewer_label, counter_text, gr.update(visible=False), ) def process_from_urls( urls_text, atlas_size, sample_rate, simplify_details, detail_filter_diameter, detail_color_sigma, detail_space_sigma, ): if not urls_text or not urls_text.strip(): return ( None, None, "Please enter URLs first.", {}, 0, "", "", gr.update(visible=False), ) downloaded_files, failed_urls = download_from_urls(urls_text) if not downloaded_files: error_msg = "Failed to download any files." if failed_urls: error_msg += f" URLs that failed: {len(failed_urls)}" return None, None, error_msg, {}, 0, "", "", gr.update(visible=False) atlas_path, models, first_model, status, metadata = process_batch( downloaded_files, atlas_size, sample_rate, simplify_details, detail_filter_diameter, detail_color_sigma, detail_space_sigma, ) if failed_urls: status += f"\n⚠ Failed to download {len(failed_urls)} URL(s)" if models: viewer_label = metadata["names"][0] counter_text = f"Model 1 of {len(models)}" else: viewer_label = "" counter_text = "" return ( atlas_path, first_model, status, metadata, 0, viewer_label, counter_text, gr.update(visible=False), ) def process_wrapper( files, urls_text, atlas_size, sample_rate, simplify_details, detail_filter_diameter, detail_color_sigma, detail_space_sigma, ): if files and len(files) > 0: return process_from_files( files, atlas_size, sample_rate, simplify_details, detail_filter_diameter, detail_color_sigma, detail_space_sigma, ) elif urls_text and urls_text.strip(): return process_from_urls( urls_text, atlas_size, sample_rate, simplify_details, detail_filter_diameter, detail_color_sigma, detail_space_sigma, ) else: return ( None, None, "Please provide files or URLs.", {}, 0, "", "", gr.update(visible=False), ) process_btn.click( fn=process_wrapper, inputs=[ file_input, url_input, atlas_size, sample_rate, simplify_details, detail_filter_diameter, detail_color_sigma, detail_space_sigma, ], outputs=[ atlas_image, model_viewer, status_text, processing_metadata, model_index, model_label, model_counter, download_file, ], ) def navigate_prev(current_index, metadata): model_path, new_index, _ = update_model_viewer("prev", current_index, metadata) counter_text = ( f"Model {new_index + 1} of {metadata['total']}" if metadata and "total" in metadata else "" ) name_text = ( metadata["names"][new_index] if metadata and "names" in metadata else "" ) return model_path, new_index, name_text, counter_text def navigate_next(current_index, metadata): model_path, new_index, _ = update_model_viewer("next", current_index, metadata) counter_text = ( f"Model {new_index + 1} of {metadata['total']}" if metadata and "total" in metadata else "" ) name_text = ( metadata["names"][new_index] if metadata and "names" in metadata else "" ) return model_path, new_index, name_text, counter_text prev_btn.click( fn=navigate_prev, inputs=[model_index, processing_metadata], outputs=[model_viewer, model_index, model_label, model_counter], ) next_btn.click( fn=navigate_next, inputs=[model_index, processing_metadata], outputs=[model_viewer, model_index, model_label, model_counter], ) def prepare_download(metadata): zip_path = create_download_zip(metadata) if zip_path: return gr.update(value=zip_path, visible=True) return gr.update(visible=False) download_btn.click( fn=prepare_download, inputs=[processing_metadata], outputs=[download_file] ) if __name__ == "__main__": demo.launch(share=False, server_name="0.0.0.0", server_port=7860, show_error=True)