From 7fa889a9bc5fac228944e5acd000ea4136d331a7 Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Wed, 11 Feb 2026 17:56:33 +0000 Subject: [PATCH] feat: add standalone icon extractor tool New standalone tool: standalone_icon_extractor.py A focused, single-purpose GUI tool for extracting Entropia Universe icons: - Auto-detects cache folder including version subfolders - Converts TGA to PNG with transparent backgrounds - Canvas sizing options (64x64 to 512x512) - Upscale options: None, NEAREST, HQ4x, LANCZOS - Selective conversion (select specific files or all) - Saves settings between sessions - Dark theme UI Usage: python standalone_icon_extractor.py Does not require Lemontropia Suite to be installed/running. --- memory/2026-02-11.md | 34 ++ standalone_icon_extractor.py | 700 +++++++++++++++++++++++++++++++++++ 2 files changed, 734 insertions(+) create mode 100644 memory/2026-02-11.md create mode 100644 standalone_icon_extractor.py diff --git a/memory/2026-02-11.md b/memory/2026-02-11.md new file mode 100644 index 0000000..5492f48 --- /dev/null +++ b/memory/2026-02-11.md @@ -0,0 +1,34 @@ +# 2026-02-11 + +## TGA Icon Converter Improvements + +### AI Upscaling with Real-ESRGAN +- Added `modules/ai_upscaler.py` - Real-ESRGAN integration for AI-powered upscaling +- Designed specifically for low-res game textures/icons (not pixel art) +- 4x upscale factor with neural network enhancement +- Falls back gracefully if model not available +- Only shown in UI if Real-ESRGAN is installed + +### No Upscaling Option +- Added "❌ No Upscaling" as the default option +- Keeps original icon size, just centers on canvas +- Useful when you want the icon at original resolution on a larger canvas + +### Auto-Detection Fixes +- Changed from `glob` to `rglob` to search ALL subfolders recursively +- Now properly finds TGA files in version subfolders like `19.3.2.201024` +- Shows relative paths in the file list + +### Icon Cache Parser +- Created `modules/icon_cache_parser.py` to parse `iconcache.dat` +- Attempts to extract item names from the binary cache file +- Uses heuristics: pattern matching for icon IDs, string extraction for names +- Challenge: proprietary binary format with no public documentation + +### Commits +- `5374eba` - feat: add Real-ESRGAN AI upscaling support +- `5ec7541` - feat: add "No Upscaling" option to TGA converter +- `8f7c7cb` - fix: TGA converter improvements (auto-detection, defaults) + +## Context +User clarified that game icons are "not pixel icons really, just quite low res" - Real-ESRGAN is perfect for this use case (designed for rendered graphics, not pixel art). diff --git a/standalone_icon_extractor.py b/standalone_icon_extractor.py new file mode 100644 index 0000000..4048b4f --- /dev/null +++ b/standalone_icon_extractor.py @@ -0,0 +1,700 @@ +""" +Entropia Universe Icon Extractor +A standalone tool for extracting and converting game icons from cache. + +Description: Standalone GUI tool to extract TGA icons from Entropia Universe cache, +convert them to PNG, and optionally upscale them for better quality. + +Usage: + python standalone_icon_extractor.py + +Author: LemonNexus +""" + +import sys +import logging +from pathlib import Path +from typing import Optional, List, Tuple +from dataclasses import dataclass +from enum import Enum + +try: + from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QComboBox, QListWidget, QListWidgetItem, + QFileDialog, QProgressBar, QGroupBox, QMessageBox, QCheckBox, + QSpinBox, QSplitter + ) + from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSettings + from PyQt6.QtGui import QIcon, QPixmap + PYQT_AVAILABLE = True +except ImportError: + PYQT_AVAILABLE = False + print("PyQt6 not available. Install with: pip install PyQt6") + +try: + from PIL import Image, ImageFilter + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + print("PIL not available. Install with: pip install Pillow") + +import numpy as np + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class TGAHeader: + """TGA file header structure.""" + def __init__(self, data: bytes): + self.id_length = data[0] + self.color_map_type = data[1] + self.image_type = data[2] + self.color_map_origin = int.from_bytes(data[3:5], 'little') + self.color_map_length = int.from_bytes(data[5:7], 'little') + self.color_map_depth = data[7] + self.x_origin = int.from_bytes(data[8:10], 'little') + self.y_origin = int.from_bytes(data[10:12], 'little') + self.width = int.from_bytes(data[12:14], 'little') + self.height = int.from_bytes(data[14:16], 'little') + self.pixel_depth = data[16] + self.image_descriptor = data[17] + + def __str__(self): + return f"{self.width}x{self.height}, {self.pixel_depth}bpp" + + +class TGAConverter: + """Converter for TGA files to PNG with canvas and upscaling options.""" + + def __init__(self, output_dir: Optional[Path] = None): + self.output_dir = output_dir or Path.home() / "Documents" / "Entropia Universe" / "Icons" + self.output_dir.mkdir(parents=True, exist_ok=True) + self._cache_path: Optional[Path] = None + + def find_cache_folder(self) -> Optional[Path]: + """Find the Entropia Universe icon cache folder.""" + possible_paths = [ + Path("C:") / "ProgramData" / "Entropia Universe" / "public_users_data" / "cache" / "icon", + Path.home() / "Documents" / "Entropia Universe" / "cache" / "icons", + ] + + for path in possible_paths: + if path.exists(): + # Check if this folder or any subfolder has .tga files + if list(path.rglob("*.tga")): + self._cache_path = path + return path + + return None + + def read_tga_header(self, filepath: Path) -> Optional[TGAHeader]: + """Read TGA header from file.""" + try: + with open(filepath, 'rb') as f: + header_data = f.read(18) + if len(header_data) < 18: + return None + return TGAHeader(header_data) + except Exception as e: + logger.error(f"Error reading TGA header: {e}") + return None + + def convert_tga_to_png( + self, + tga_path: Path, + output_name: Optional[str] = None, + canvas_size: Optional[Tuple[int, int]] = None, + upscale: bool = False, + upscale_method: str = 'nearest' + ) -> Optional[Path]: + """Convert a TGA file to PNG.""" + try: + # Try PIL first + image = Image.open(tga_path) + if image.mode != 'RGBA': + image = image.convert('RGBA') + + # Apply canvas sizing if requested + if canvas_size: + do_upscale = upscale and upscale_method != 'none' + image = self._apply_canvas(image, canvas_size, do_upscale, upscale_method) + + # Save + if output_name is None: + output_name = tga_path.stem + + output_path = self.output_dir / f"{output_name}.png" + image.save(output_path, 'PNG') + + return output_path + + except Exception as e: + logger.error(f"Conversion failed: {e}") + return None + + def _apply_canvas( + self, + image: Image.Image, + canvas_size: Tuple[int, int], + upscale: bool = False, + upscale_method: str = 'nearest' + ) -> Image.Image: + """Place image centered on a canvas.""" + canvas_w, canvas_h = canvas_size + img_w, img_h = image.size + + # Create transparent canvas + canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) + + # Calculate scaling + if upscale: + max_size = int(min(canvas_w, canvas_h) * 0.85) + scale = min(max_size / img_w, max_size / img_h) + + if scale > 1: + new_w = int(img_w * scale) + new_h = int(img_h * scale) + + if upscale_method == 'nearest': + image = image.resize((new_w, new_h), Image.Resampling.NEAREST) + elif upscale_method == 'hq4x': + int_scale = max(2, int(scale)) + temp_w = img_w * int_scale + temp_h = img_h * int_scale + image = image.resize((temp_w, temp_h), Image.Resampling.NEAREST) + image = image.resize((new_w, new_h), Image.Resampling.LANCZOS) + image = image.filter(ImageFilter.EDGE_ENHANCE_MORE) + else: # lanczos + image = image.resize((new_w, new_h), Image.Resampling.LANCZOS) + + image = image.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3)) + img_w, img_h = new_w, new_h + + # Center on canvas + x = (canvas_w - img_w) // 2 + y = (canvas_h - img_h) // 2 + + canvas.paste(image, (x, y), image if image.mode == 'RGBA' else None) + return canvas + + +class ConversionWorker(QThread): + """Background worker for batch conversion.""" + progress = pyqtSignal(str) + file_done = pyqtSignal(str, str) # filename, output_path + finished = pyqtSignal(int, int) # success, total + error = pyqtSignal(str) + + def __init__( + self, + files: List[Path], + converter: TGAConverter, + canvas_size: Optional[Tuple[int, int]], + upscale_method: str + ): + super().__init__() + self.files = files + self.converter = converter + self.canvas_size = canvas_size + self.upscale_method = upscale_method + self._running = True + + def run(self): + """Run conversion.""" + try: + success = 0 + total = len(self.files) + + for i, filepath in enumerate(self.files): + if not self._running: + break + + self.progress.emit(f"[{i+1}/{total}] {filepath.name}") + + output = self.converter.convert_tga_to_png( + filepath, + canvas_size=self.canvas_size, + upscale=True, + upscale_method=self.upscale_method + ) + + if output: + success += 1 + self.file_done.emit(filepath.name, str(output)) + + self.finished.emit(success, total) + + except Exception as e: + self.error.emit(str(e)) + + def stop(self): + self._running = False + + +class IconExtractorWindow(QMainWindow): + """Main window for the standalone icon extractor.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("🎮 Entropia Universe Icon Extractor") + self.setMinimumSize(900, 700) + + self.converter = TGAConverter() + self.worker: Optional[ConversionWorker] = None + self.found_files: List[Path] = [] + + self.settings = QSettings("Lemontropia", "IconExtractor") + + self._setup_ui() + self._load_settings() + self._auto_scan() + + def _setup_ui(self): + """Setup the UI.""" + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(15, 15, 15, 15) + layout.setSpacing(12) + + # Header + header = QLabel("🎮 Entropia Universe Icon Extractor") + header.setStyleSheet("font-size: 20px; font-weight: bold; color: #4caf50;") + layout.addWidget(header) + + desc = QLabel( + "Extract and convert item icons from Entropia Universe game cache.\n" + "Icons are saved as PNG files with transparent backgrounds." + ) + desc.setStyleSheet("color: #888; padding: 5px;") + desc.setWordWrap(True) + layout.addWidget(desc) + + # Main splitter + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left panel - Settings + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + left_layout.setContentsMargins(0, 0, 0, 0) + + # Cache folder + cache_group = QGroupBox("📁 Cache Folder") + cache_layout = QVBoxLayout(cache_group) + + self.cache_label = QLabel("Not found") + self.cache_label.setStyleSheet("font-family: Consolas; color: #888; padding: 5px; background: #1a1a1a; border-radius: 3px;") + cache_layout.addWidget(self.cache_label) + + cache_btn_layout = QHBoxLayout() + + scan_btn = QPushButton("🔍 Auto-Detect") + scan_btn.clicked.connect(self._auto_scan) + cache_btn_layout.addWidget(scan_btn) + + browse_btn = QPushButton("Browse...") + browse_btn.clicked.connect(self._browse_cache) + cache_btn_layout.addWidget(browse_btn) + + cache_btn_layout.addStretch() + cache_layout.addLayout(cache_btn_layout) + left_layout.addWidget(cache_group) + + # Output folder + output_group = QGroupBox("💾 Output Folder") + output_layout = QVBoxLayout(output_group) + + self.output_label = QLabel(str(self.converter.output_dir)) + self.output_label.setStyleSheet("font-family: Consolas; color: #888; padding: 5px; background: #1a1a1a; border-radius: 3px;") + output_layout.addWidget(self.output_label) + + output_btn = QPushButton("Change Output Folder...") + output_btn.clicked.connect(self._browse_output) + output_layout.addWidget(output_btn) + + left_layout.addWidget(output_group) + + # Settings + settings_group = QGroupBox("⚙️ Conversion Settings") + settings_layout = QVBoxLayout(settings_group) + + # Canvas size + size_layout = QHBoxLayout() + size_layout.addWidget(QLabel("Canvas Size:")) + self.size_combo = QComboBox() + self.size_combo.addItem("Original (no canvas)", None) + self.size_combo.addItem("64x64", (64, 64)) + self.size_combo.addItem("128x128", (128, 128)) + self.size_combo.addItem("256x256", (256, 256)) + self.size_combo.addItem("280x280 (Forum)", (280, 280)) + self.size_combo.addItem("320x320", (320, 320)) + self.size_combo.addItem("512x512", (512, 512)) + self.size_combo.setCurrentIndex(5) # Default 320x320 + size_layout.addWidget(self.size_combo) + size_layout.addStretch() + settings_layout.addLayout(size_layout) + + # Upscale method + method_layout = QHBoxLayout() + method_layout.addWidget(QLabel("Upscale:")) + self.method_combo = QComboBox() + self.method_combo.addItem("❌ No Upscaling", "none") + self.method_combo.addItem("Sharp Pixels (NEAREST)", "nearest") + self.method_combo.addItem("Smooth (HQ4x-style)", "hq4x") + self.method_combo.addItem("Photorealistic (LANCZOS)", "lanczos") + self.method_combo.setToolTip( + "None: Keep original size\n" + "NEAREST: Sharp edges (pixel art)\n" + "HQ4x: Smooth (game icons)\n" + "LANCZOS: Very smooth (photos)" + ) + method_layout.addWidget(self.method_combo) + method_layout.addStretch() + settings_layout.addLayout(method_layout) + + left_layout.addWidget(settings_group) + + # Convert button + self.convert_btn = QPushButton("🚀 Convert All Icons") + self.convert_btn.setMinimumHeight(60) + self.convert_btn.setStyleSheet(""" + QPushButton { + background-color: #0d47a1; + font-weight: bold; + font-size: 16px; + border-radius: 5px; + } + QPushButton:hover { background-color: #1565c0; } + QPushButton:disabled { background-color: #333; color: #666; } + """) + self.convert_btn.clicked.connect(self._start_conversion) + left_layout.addWidget(self.convert_btn) + + # Progress + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + left_layout.addWidget(self.progress_bar) + + self.status_label = QLabel("Ready") + self.status_label.setStyleSheet("color: #888; padding: 5px;") + left_layout.addWidget(self.status_label) + + left_layout.addStretch() + + splitter.addWidget(left_panel) + + # Right panel - File list + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + right_layout.setContentsMargins(0, 0, 0, 0) + + files_group = QGroupBox("📄 Found Icons") + files_layout = QVBoxLayout(files_group) + + self.files_count_label = QLabel("No files found") + files_layout.addWidget(self.files_count_label) + + self.files_list = QListWidget() + self.files_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + files_layout.addWidget(self.files_list) + + # Selection buttons + sel_layout = QHBoxLayout() + + select_all_btn = QPushButton("Select All") + select_all_btn.clicked.connect(self.files_list.selectAll) + sel_layout.addWidget(select_all_btn) + + select_none_btn = QPushButton("Select None") + select_none_btn.clicked.connect(self.files_list.clearSelection) + sel_layout.addWidget(select_none_btn) + + sel_layout.addStretch() + + open_folder_btn = QPushButton("📂 Open Output Folder") + open_folder_btn.clicked.connect(self._open_output_folder) + sel_layout.addWidget(open_folder_btn) + + files_layout.addLayout(sel_layout) + right_layout.addWidget(files_group) + + splitter.addWidget(right_panel) + splitter.setSizes([350, 550]) + + layout.addWidget(splitter, 1) + + # Footer + footer = QLabel("Entropia Universe Icon Extractor | Standalone Tool") + footer.setStyleSheet("color: #555; font-size: 11px; padding: 5px;") + footer.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(footer) + + def _load_settings(self): + """Load saved settings.""" + # Output folder + saved_output = self.settings.value("output_dir", str(self.converter.output_dir)) + self.converter.output_dir = Path(saved_output) + self.output_label.setText(saved_output) + + # Canvas size + size_index = int(self.settings.value("canvas_size_index", 5)) + self.size_combo.setCurrentIndex(size_index) + + # Upscale method + method_index = int(self.settings.value("upscale_method_index", 0)) + self.method_combo.setCurrentIndex(method_index) + + def _save_settings(self): + """Save current settings.""" + self.settings.setValue("output_dir", str(self.converter.output_dir)) + self.settings.setValue("canvas_size_index", self.size_combo.currentIndex()) + self.settings.setValue("upscale_method_index", self.method_combo.currentIndex()) + + def _auto_scan(self): + """Auto-detect cache folder.""" + self.status_label.setText("Scanning for cache folder...") + + cache_path = self.converter.find_cache_folder() + + if cache_path: + self.cache_label.setText(str(cache_path)) + self._refresh_file_list(cache_path) + else: + self.cache_label.setText("❌ Not found - click Browse to select manually") + self.status_label.setText("Cache folder not found") + + def _browse_cache(self): + """Browse for cache folder.""" + folder = QFileDialog.getExistingDirectory( + self, + "Select Entropia Universe Cache Folder", + str(Path("C:") / "ProgramData") + ) + + if folder: + path = Path(folder) + self.converter._cache_path = path + self.cache_label.setText(str(path)) + self._refresh_file_list(path) + + def _refresh_file_list(self, cache_path: Path): + """Refresh the list of found files.""" + self.files_list.clear() + self.found_files = [] + + # Search all subfolders for TGA files + tga_files = list(cache_path.rglob("*.tga")) + + self.files_count_label.setText(f"Found {len(tga_files)} icon files") + self.status_label.setText(f"Found {len(tga_files)} files in {cache_path}") + + for tga_file in sorted(tga_files): + try: + rel_path = str(tga_file.relative_to(cache_path)) + except: + rel_path = tga_file.name + + item = QListWidgetItem(rel_path) + item.setData(Qt.ItemDataRole.UserRole, str(tga_file)) + + # Get info + header = self.converter.read_tga_header(tga_file) + if header: + item.setToolTip(f"{header.width}x{header.height}, {header.pixel_depth}bpp") + + self.files_list.addItem(item) + self.found_files.append(tga_file) + + self.convert_btn.setEnabled(len(tga_files) > 0) + + def _browse_output(self): + """Browse for output folder.""" + folder = QFileDialog.getExistingDirectory( + self, + "Select Output Folder", + str(self.converter.output_dir) + ) + + if folder: + self.converter.output_dir = Path(folder) + self.output_label.setText(folder) + self._save_settings() + + def _start_conversion(self): + """Start batch conversion.""" + # Get selected files or all files + selected_items = self.files_list.selectedItems() + if selected_items: + files_to_convert = [ + Path(item.data(Qt.ItemDataRole.UserRole)) + for item in selected_items + ] + else: + files_to_convert = self.found_files + + if not files_to_convert: + QMessageBox.warning(self, "No Files", "No files selected for conversion.") + return + + # Get settings + canvas_size = self.size_combo.currentData() + upscale_method = self.method_combo.currentData() + + # Save settings + self._save_settings() + + # Setup UI + self.convert_btn.setEnabled(False) + self.convert_btn.setText("⏳ Converting...") + self.progress_bar.setRange(0, len(files_to_convert)) + self.progress_bar.setValue(0) + self.progress_bar.setVisible(True) + + # Start worker + self.worker = ConversionWorker( + files_to_convert, + self.converter, + canvas_size, + upscale_method + ) + self.worker.progress.connect(self._on_progress) + self.worker.file_done.connect(self._on_file_done) + self.worker.finished.connect(self._on_finished) + self.worker.error.connect(self._on_error) + self.worker.start() + + def _on_progress(self, msg: str): + self.status_label.setText(msg) + self.progress_bar.setValue(self.progress_bar.value() + 1) + + def _on_file_done(self, filename: str, output_path: str): + logger.info(f"Converted: {filename} -> {output_path}") + + def _on_finished(self, success: int, total: int): + self.convert_btn.setEnabled(True) + self.convert_btn.setText("🚀 Convert All Icons") + self.progress_bar.setVisible(False) + self.status_label.setText(f"✅ Converted {success}/{total} files") + + QMessageBox.information( + self, + "Conversion Complete", + f"Successfully converted {success} of {total} icons.\n\n" + f"Output folder:\n{self.converter.output_dir}" + ) + + def _on_error(self, error_msg: str): + self.convert_btn.setEnabled(True) + self.convert_btn.setText("🚀 Convert All Icons") + self.progress_bar.setVisible(False) + self.status_label.setText(f"❌ Error: {error_msg}") + + QMessageBox.critical(self, "Error", f"Conversion failed:\n{error_msg}") + + def _open_output_folder(self): + """Open output folder in file manager.""" + import os + import subprocess + + path = str(self.converter.output_dir) + + if os.name == 'nt': + os.startfile(path) + elif sys.platform == 'darwin': + subprocess.run(['open', path]) + else: + subprocess.run(['xdg-open', path]) + + def closeEvent(self, event): + """Save settings on close.""" + self._save_settings() + if self.worker and self.worker.isRunning(): + self.worker.stop() + self.worker.wait(1000) + event.accept() + + +def main(): + """Main entry point.""" + if not PYQT_AVAILABLE: + print("ERROR: PyQt6 is required. Install with: pip install PyQt6") + sys.exit(1) + + if not PIL_AVAILABLE: + print("ERROR: Pillow is required. Install with: pip install Pillow") + sys.exit(1) + + app = QApplication(sys.argv) + app.setStyle('Fusion') + + # Dark theme + app.setStyleSheet(""" + QMainWindow, QDialog { + background-color: #1a1a1a; + } + QWidget { + background-color: #1a1a1a; + color: #e0e0e0; + } + QGroupBox { + font-weight: bold; + border: 1px solid #333; + border-radius: 5px; + margin-top: 10px; + padding-top: 10px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + } + QPushButton { + background-color: #333; + border: 1px solid #555; + padding: 8px 15px; + border-radius: 3px; + } + QPushButton:hover { + background-color: #444; + } + QComboBox { + background-color: #2a2a2a; + border: 1px solid #555; + padding: 5px; + min-width: 150px; + } + QListWidget { + background-color: #222; + border: 1px solid #444; + border-radius: 3px; + } + QListWidget::item { + padding: 5px; + } + QListWidget::item:selected { + background-color: #0d47a1; + } + QProgressBar { + border: 1px solid #444; + border-radius: 3px; + text-align: center; + } + QProgressBar::chunk { + background-color: #4caf50; + } + """) + + window = IconExtractorWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() \ No newline at end of file