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:
parent
8f7c7cb5a1
commit
7fa889a9bc
|
|
@ -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).
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue