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.
This commit is contained in:
LemonNexus 2026-02-11 17:56:33 +00:00
parent 8f7c7cb5a1
commit 7fa889a9bc
2 changed files with 734 additions and 0 deletions

34
memory/2026-02-11.md Normal file
View File

@ -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).

View File

@ -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()