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