feat: add TGA Icon Converter for game cache files

- New module: modules/tga_converter.py
  - Reads Truevision TGA format from game cache
  - Converts to PNG with proper color handling (BGR->RGB)
  - Auto-detects cache folder location
  - Batch conversion support
  - Handles alpha channels

- New dialog: ui/tga_converter_dialog.py
  - Visual interface for TGA conversion
  - Shows list of cached .tga files
  - Progress tracking during conversion
  - One-click convert all

- Added to Tools → Computer Vision → TGA Icon Converter (Ctrl+T)

This is much more efficient than extracting icons from screenshots!
The game already has the icons in cache as .tga files.
This commit is contained in:
LemonNexus 2026-02-11 15:37:14 +00:00
parent adc76e42cc
commit acca0d3491
3 changed files with 782 additions and 0 deletions

392
modules/tga_converter.py Normal file
View File

@ -0,0 +1,392 @@
"""
Lemontropia Suite - TGA Icon Converter
Convert Entropia Universe cached .tga icons to PNG format.
The game stores item icons as .tga files in the cache folder.
This module converts them to PNG for easier use.
"""
import struct
import logging
from pathlib import Path
from typing import Optional, List, Tuple, Dict
from dataclasses import dataclass
import numpy as np
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
Image = None
logger = logging.getLogger(__name__)
@dataclass
class TGAFile:
"""Represents a TGA file with metadata."""
filepath: Path
width: int
height: int
pixel_depth: int
has_alpha: bool
def __str__(self) -> str:
return f"TGA({self.width}x{self.height}, {self.pixel_depth}bpp, alpha={self.has_alpha})"
class TGAConverter:
"""
Converter for Entropia Universe TGA icon files.
Usage:
converter = TGAConverter()
# Convert single file
png_path = converter.convert_tga_to_png("item_icon.tga")
# Batch convert all cached icons
results = converter.convert_cache_folder()
"""
# Common Entropia Universe cache locations
DEFAULT_CACHE_PATHS = [
Path.home() / "Documents" / "Entropia Universe" / "cache" / "icons",
Path.home() / "Documents" / "Entropia Universe" / "cache",
Path("C:") / "ProgramData" / "Entropia Universe" / "cache" / "icons",
Path("C:") / "Program Files (x86)" / "Entropia Universe" / "cache" / "icons",
]
def __init__(self, output_dir: Optional[Path] = None):
"""
Initialize TGA converter.
Args:
output_dir: Directory for converted PNG files.
Default: Documents/Entropia Universe/Icons/
"""
if output_dir is None:
output_dir = Path.home() / "Documents" / "Entropia Universe" / "Icons"
self.output_dir = output_dir
self.output_dir.mkdir(parents=True, exist_ok=True)
self._cache_path: Optional[Path] = None
if not PIL_AVAILABLE:
logger.warning("PIL/Pillow not available. Install with: pip install Pillow")
def find_cache_folder(self) -> Optional[Path]:
"""
Find the Entropia Universe icon cache folder.
Returns:
Path to cache folder or None if not found
"""
# Check default locations
for path in self.DEFAULT_CACHE_PATHS:
if path.exists():
logger.info(f"Found cache folder: {path}")
self._cache_path = path
return path
# Try to find by looking for .tga files
logger.info("Searching for .tga files...")
docs_path = Path.home() / "Documents" / "Entropia Universe"
if docs_path.exists():
for tga_file in docs_path.rglob("*.tga"):
cache_path = tga_file.parent
logger.info(f"Found cache folder via search: {cache_path}")
self._cache_path = cache_path
return cache_path
logger.warning("Could not find Entropia Universe cache folder")
return None
def read_tga_header(self, filepath: Path) -> Optional[TGAFile]:
"""
Read TGA file header.
Args:
filepath: Path to .tga file
Returns:
TGAFile with metadata or None if invalid
"""
try:
with open(filepath, 'rb') as f:
# Read TGA header (18 bytes)
header = f.read(18)
if len(header) < 18:
logger.warning(f"Invalid TGA file (too small): {filepath}")
return None
# Unpack header
# Format: id_length, color_map_type, image_type, color_map_spec(5),
# x_origin(2), y_origin(2), width(2), height(2), pixel_depth, descriptor
id_length = header[0]
color_map_type = header[1]
image_type = header[2]
# Skip color map specification (5 bytes)
x_origin = struct.unpack('<H', header[8:10])[0]
y_origin = struct.unpack('<H', header[10:12])[0]
width = struct.unpack('<H', header[12:14])[0]
height = struct.unpack('<H', header[14:16])[0]
pixel_depth = header[16]
image_descriptor = header[17]
# Check if valid TGA
if width == 0 or height == 0:
logger.warning(f"Invalid TGA dimensions: {filepath}")
return None
has_alpha = (pixel_depth == 32) or (image_descriptor & 0x0F > 0)
return TGAFile(
filepath=filepath,
width=width,
height=height,
pixel_depth=pixel_depth,
has_alpha=has_alpha
)
except Exception as e:
logger.error(f"Error reading TGA header: {e}")
return None
def read_tga_pixels(self, tga_file: TGAFile) -> Optional[np.ndarray]:
"""
Read pixel data from TGA file.
Args:
tga_file: TGAFile with metadata
Returns:
Numpy array of pixels (H, W, C) or None
"""
try:
with open(tga_file.filepath, 'rb') as f:
# Skip header
f.seek(18)
# Skip ID field if present
header = f.read(18)
id_length = header[0] if len(header) >= 18 else 0
if id_length > 0:
f.seek(18 + id_length)
# Calculate pixel data size
bytes_per_pixel = tga_file.pixel_depth // 8
pixel_data_size = tga_file.width * tga_file.height * bytes_per_pixel
# Read pixel data
pixel_data = f.read(pixel_data_size)
if len(pixel_data) < pixel_data_size:
logger.warning(f"Incomplete TGA pixel data: {tga_file.filepath}")
return None
# Convert to numpy array
pixels = np.frombuffer(pixel_data, dtype=np.uint8)
# Reshape based on format
if bytes_per_pixel == 4:
# BGRA format
pixels = pixels.reshape((tga_file.height, tga_file.width, 4))
# Convert BGRA to RGBA
pixels = pixels[:, :, [2, 1, 0, 3]]
elif bytes_per_pixel == 3:
# BGR format
pixels = pixels.reshape((tga_file.height, tga_file.width, 3))
# Convert BGR to RGB
pixels = pixels[:, :, [2, 1, 0]]
elif bytes_per_pixel == 2:
# 16-bit format - convert to RGB
pixels = pixels.view(np.uint16).reshape((tga_file.height, tga_file.width))
# Simple conversion (not accurate but works)
pixels = np.stack([
((pixels >> 10) & 0x1F) << 3,
((pixels >> 5) & 0x1F) << 3,
(pixels & 0x1F) << 3
], axis=-1).astype(np.uint8)
else:
logger.warning(f"Unsupported pixel depth: {tga_file.pixel_depth}")
return None
# Flip vertically (TGA stores bottom-to-top)
pixels = np.flipud(pixels)
return pixels
except Exception as e:
logger.error(f"Error reading TGA pixels: {e}")
return None
def convert_tga_to_png(self, tga_path: Path, output_name: Optional[str] = None) -> Optional[Path]:
"""
Convert a TGA file to PNG.
Args:
tga_path: Path to .tga file
output_name: Optional output filename (without extension)
Returns:
Path to converted PNG file or None if failed
"""
if not PIL_AVAILABLE:
logger.error("PIL/Pillow required for TGA to PNG conversion")
return None
try:
# Read TGA
tga_file = self.read_tga_header(tga_path)
if not tga_file:
return None
logger.info(f"Converting: {tga_path.name} ({tga_file})")
pixels = self.read_tga_pixels(tga_file)
if pixels is None:
return None
# Create PIL Image
if pixels.shape[2] == 4:
# RGBA
image = Image.fromarray(pixels, 'RGBA')
else:
# RGB
image = Image.fromarray(pixels, 'RGB')
# Determine output path
if output_name is None:
output_name = tga_path.stem
png_path = self.output_dir / f"{output_name}.png"
# Save as PNG
image.save(png_path, 'PNG')
logger.info(f"Saved: {png_path}")
return png_path
except Exception as e:
logger.error(f"Error converting {tga_path}: {e}")
return None
def convert_cache_folder(self, cache_path: Optional[Path] = None) -> Dict[str, Path]:
"""
Convert all TGA files in cache folder to PNG.
Args:
cache_path: Path to cache folder (auto-detect if None)
Returns:
Dictionary mapping TGA filenames to PNG paths
"""
results = {}
if cache_path is None:
cache_path = self.find_cache_folder()
if not cache_path or not cache_path.exists():
logger.error("Cache folder not found")
return results
# Find all TGA files
tga_files = list(cache_path.glob("*.tga"))
if not tga_files:
logger.warning(f"No .tga files found in: {cache_path}")
return results
logger.info(f"Found {len(tga_files)} TGA files to convert")
# Convert each file
for i, tga_path in enumerate(tga_files, 1):
logger.info(f"[{i}/{len(tga_files)}] Converting: {tga_path.name}")
png_path = self.convert_tga_to_png(tga_path)
if png_path:
results[tga_path.name] = png_path
logger.info(f"Converted {len(results)}/{len(tga_files)} files successfully")
logger.info(f"Output directory: {self.output_dir}")
return results
def get_conversion_summary(self) -> str:
"""Get a summary of available TGA files and conversion status."""
cache_path = self.find_cache_folder()
if not cache_path:
return "❌ Cache folder not found"
tga_files = list(cache_path.glob("*.tga"))
png_files = list(self.output_dir.glob("*.png"))
lines = [
"📁 TGA Icon Cache Summary",
"",
f"Cache location: {cache_path}",
f"TGA files found: {len(tga_files)}",
f"PNG files converted: {len(png_files)}",
f"Output directory: {self.output_dir}",
"",
]
if tga_files:
lines.append("Sample TGA files:")
for tga in tga_files[:5]:
tga_info = self.read_tga_header(tga)
if tga_info:
lines.append(f"{tga.name} ({tga_info.width}x{tga_info.height})")
else:
lines.append(f"{tga.name} (invalid)")
if len(tga_files) > 5:
lines.append(f" ... and {len(tga_files) - 5} more")
return "\n".join(lines)
# Convenience functions
def convert_tga_to_png(tga_path: str, output_dir: Optional[str] = None) -> Optional[str]:
"""Quick convert single TGA file."""
converter = TGAConverter(Path(output_dir) if output_dir else None)
result = converter.convert_tga_to_png(Path(tga_path))
return str(result) if result else None
def convert_all_cache_icons(output_dir: Optional[str] = None) -> List[str]:
"""Convert all cached icons."""
converter = TGAConverter(Path(output_dir) if output_dir else None)
results = converter.convert_cache_folder()
return [str(path) for path in results.values()]
def main():
"""CLI interface for TGA conversion."""
import sys
print("🔧 Lemontropia Suite - TGA Icon Converter")
print("=" * 50)
converter = TGAConverter()
# Show summary
print(converter.get_conversion_summary())
print()
# Ask to convert
response = input("\nConvert all TGA files to PNG? (y/n): ")
if response.lower() == 'y':
results = converter.convert_cache_folder()
print(f"\n✅ Converted {len(results)} files to:")
print(converter.output_dir)
else:
print("Cancelled.")
if __name__ == "__main__":
main()

View File

@ -121,6 +121,7 @@ from ui.session_history import SessionHistoryDialog
from ui.gallery_dialog import GalleryDialog, ScreenshotCapture
from ui.settings_dialog import SettingsDialog
from ui.inventory_scanner_dialog import InventoryScannerDialog
from ui.tga_converter_dialog import TGAConverterDialog
# ============================================================================
# Screenshot Hotkey Integration
@ -761,6 +762,12 @@ class MainWindow(QMainWindow):
inventory_scan_action.triggered.connect(self.on_inventory_scan)
vision_menu.addAction(inventory_scan_action)
# TGA Icon Converter
tga_convert_action = QAction("🔧 &TGA Icon Converter", self)
tga_convert_action.setShortcut("Ctrl+T")
tga_convert_action.triggered.connect(self.on_tga_convert)
vision_menu.addAction(tga_convert_action)
# View menu
view_menu = menubar.addMenu("&View")
@ -1913,6 +1920,15 @@ class MainWindow(QMainWindow):
self.log_error("Vision", f"Failed to open inventory scanner: {e}")
QMessageBox.warning(self, "Error", f"Could not open Inventory Scanner: {e}")
def on_tga_convert(self):
"""Open TGA Icon Converter dialog."""
try:
dialog = TGAConverterDialog(self)
dialog.exec()
except Exception as e:
self.log_error("Vision", f"Failed to open TGA converter: {e}")
QMessageBox.warning(self, "Error", f"Could not open TGA Converter: {e}")
# ========================================================================
# Settings Management
# ========================================================================

374
ui/tga_converter_dialog.py Normal file
View File

@ -0,0 +1,374 @@
"""
Lemontropia Suite - TGA Icon Converter Dialog
UI for converting Entropia Universe cached .tga icons to PNG.
"""
import logging
from pathlib import Path
from typing import Optional
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QPushButton, QListWidget, QListWidgetItem,
QGroupBox, QProgressBar, QTextEdit, QFileDialog,
QMessageBox, QSpinBox, QCheckBox
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QPixmap
from modules.tga_converter import TGAConverter
logger = logging.getLogger(__name__)
class TGAConvertWorker(QThread):
"""Background worker for TGA conversion."""
progress_update = pyqtSignal(str)
file_converted = pyqtSignal(str, str) # tga_name, png_path
conversion_complete = pyqtSignal(int, int) # success_count, total_count
conversion_error = pyqtSignal(str)
def __init__(self, converter: TGAConverter):
super().__init__()
self.converter = converter
self._is_running = True
def run(self):
"""Run the conversion."""
try:
# Find cache folder
self.progress_update.emit("Finding cache folder...")
cache_path = self.converter.find_cache_folder()
if not cache_path:
self.conversion_error.emit("Cache folder not found")
return
# Get list of TGA files
tga_files = list(cache_path.glob("*.tga"))
total = len(tga_files)
success = 0
self.progress_update.emit(f"Found {total} TGA files")
for i, tga_path in enumerate(tga_files):
if not self._is_running:
break
self.progress_update.emit(f"[{i+1}/{total}] Converting: {tga_path.name}")
png_path = self.converter.convert_tga_to_png(tga_path)
if png_path:
success += 1
self.file_converted.emit(tga_path.name, str(png_path))
self.conversion_complete.emit(success, total)
except Exception as e:
self.conversion_error.emit(str(e))
def stop(self):
"""Stop the conversion."""
self._is_running = False
class TGAConverterDialog(QDialog):
"""
Dialog for converting Entropia Universe TGA icons to PNG.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("🔧 TGA Icon Converter")
self.setMinimumSize(700, 600)
self.resize(800, 700)
self.converter = TGAConverter()
self.convert_worker: Optional[TGAConvertWorker] = None
self.converted_files = []
self._setup_ui()
self._apply_dark_theme()
# Auto-scan on open
self._scan_cache()
def _setup_ui(self):
"""Setup the dialog UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(15, 15, 15, 15)
layout.setSpacing(10)
# Header
header = QLabel("🔧 TGA Icon Converter")
header.setStyleSheet("font-size: 18px; font-weight: bold; color: #4caf50;")
layout.addWidget(header)
desc = QLabel(
"Convert Entropia Universe cached .tga icon files to PNG format.\n"
"The game stores item icons in the cache folder as .tga files."
)
desc.setStyleSheet("color: #888; padding: 5px;")
desc.setWordWrap(True)
layout.addWidget(desc)
# Cache folder info
cache_group = QGroupBox("📁 Cache Folder")
cache_layout = QVBoxLayout(cache_group)
self.cache_path_label = QLabel("Searching...")
self.cache_path_label.setStyleSheet("font-family: Consolas; color: #888;")
cache_layout.addWidget(self.cache_path_label)
cache_buttons = QHBoxLayout()
self.scan_btn = QPushButton("🔍 Scan Cache")
self.scan_btn.clicked.connect(self._scan_cache)
cache_buttons.addWidget(self.scan_btn)
browse_cache_btn = QPushButton("Browse...")
browse_cache_btn.clicked.connect(self._browse_cache_folder)
cache_buttons.addWidget(browse_cache_btn)
cache_buttons.addStretch()
cache_layout.addLayout(cache_buttons)
layout.addWidget(cache_group)
# TGA Files list
files_group = QGroupBox("📄 TGA Files Found")
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.setMaximumHeight(150)
files_layout.addWidget(self.files_list)
layout.addWidget(files_group)
# Output settings
output_group = QGroupBox("💾 Output Settings")
output_layout = QHBoxLayout(output_group)
output_layout.addWidget(QLabel("Output folder:"))
self.output_path_label = QLabel(str(self.converter.output_dir))
self.output_path_label.setStyleSheet("font-family: Consolas; color: #888;")
output_layout.addWidget(self.output_path_label, 1)
output_browse = QPushButton("Browse...")
output_browse.clicked.connect(self._browse_output_folder)
output_layout.addWidget(output_browse)
layout.addWidget(output_group)
# Convert button
self.convert_btn = QPushButton("🚀 Convert All to PNG")
self.convert_btn.setMinimumHeight(50)
self.convert_btn.setStyleSheet("""
QPushButton {
background-color: #0d47a1;
font-weight: bold;
font-size: 14px;
}
QPushButton:hover { background-color: #1565c0; }
QPushButton:disabled { background-color: #333; }
""")
self.convert_btn.clicked.connect(self._start_conversion)
layout.addWidget(self.convert_btn)
# Progress
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 0) # Indeterminate
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
self.status_label = QLabel("Ready")
self.status_label.setStyleSheet("color: #888; padding: 5px;")
layout.addWidget(self.status_label)
# Results
results_group = QGroupBox("✅ Converted Files")
results_layout = QVBoxLayout(results_group)
self.results_list = QListWidget()
results_layout.addWidget(self.results_list)
results_buttons = QHBoxLayout()
open_folder_btn = QPushButton("📂 Open Output Folder")
open_folder_btn.clicked.connect(self._open_output_folder)
results_buttons.addWidget(open_folder_btn)
results_buttons.addStretch()
results_layout.addLayout(results_buttons)
layout.addWidget(results_group)
# Close button
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn)
def _scan_cache(self):
"""Scan for TGA files in cache."""
self.files_list.clear()
self.files_count_label.setText("Scanning...")
cache_path = self.converter.find_cache_folder()
if cache_path:
self.cache_path_label.setText(str(cache_path))
tga_files = list(cache_path.glob("*.tga"))
self.files_count_label.setText(f"Found {len(tga_files)} TGA files")
for tga_file in tga_files:
item = QListWidgetItem(tga_file.name)
# Try to get TGA info
tga_info = self.converter.read_tga_header(tga_file)
if tga_info:
item.setToolTip(f"{tga_info.width}x{tga_info.height}, {tga_info.pixel_depth}bpp")
self.files_list.addItem(item)
self.convert_btn.setEnabled(len(tga_files) > 0)
else:
self.cache_path_label.setText("❌ Not found")
self.files_count_label.setText("Cache folder not found")
self.convert_btn.setEnabled(False)
def _browse_cache_folder(self):
"""Browse for cache folder."""
dir_path = QFileDialog.getExistingDirectory(
self,
"Select Entropia Universe Cache Folder",
str(Path.home() / "Documents" / "Entropia Universe"),
QFileDialog.Option.ShowDirsOnly
)
if dir_path:
self.converter._cache_path = Path(dir_path)
self._scan_cache()
def _browse_output_folder(self):
"""Browse for output folder."""
dir_path = QFileDialog.getExistingDirectory(
self,
"Select Output Folder",
str(self.converter.output_dir),
QFileDialog.Option.ShowDirsOnly
)
if dir_path:
self.converter.output_dir = Path(dir_path)
self.output_path_label.setText(dir_path)
def _start_conversion(self):
"""Start TGA to PNG conversion."""
if self.convert_worker and self.convert_worker.isRunning():
QMessageBox.warning(self, "Busy", "Conversion already in progress")
return
self.convert_btn.setEnabled(False)
self.progress_bar.setVisible(True)
self.results_list.clear()
self.converted_files = []
# Start worker
self.convert_worker = TGAConvertWorker(self.converter)
self.convert_worker.progress_update.connect(self._on_progress)
self.convert_worker.file_converted.connect(self._on_file_converted)
self.convert_worker.conversion_complete.connect(self._on_conversion_complete)
self.convert_worker.conversion_error.connect(self._on_conversion_error)
self.convert_worker.start()
def _on_progress(self, message: str):
"""Handle progress update."""
self.status_label.setText(message)
def _on_file_converted(self, tga_name: str, png_path: str):
"""Handle file converted."""
self.converted_files.append(png_path)
item = QListWidgetItem(f"{tga_name}{Path(png_path).name}")
self.results_list.addItem(item)
def _on_conversion_complete(self, success: int, total: int):
"""Handle conversion complete."""
self.progress_bar.setVisible(False)
self.convert_btn.setEnabled(True)
self.status_label.setText(f"Complete: {success}/{total} files converted")
QMessageBox.information(
self,
"Conversion Complete",
f"Successfully converted {success} of {total} files\n\n"
f"Output: {self.converter.output_dir}"
)
def _on_conversion_error(self, error: str):
"""Handle conversion error."""
self.progress_bar.setVisible(False)
self.convert_btn.setEnabled(True)
self.status_label.setText(f"Error: {error}")
QMessageBox.critical(self, "Error", error)
def _open_output_folder(self):
"""Open output folder in file explorer."""
import os
import platform
output_path = str(self.converter.output_dir)
if platform.system() == "Windows":
os.startfile(output_path)
elif platform.system() == "Darwin": # macOS
import subprocess
subprocess.run(["open", output_path])
else: # Linux
import subprocess
subprocess.run(["xdg-open", output_path])
def closeEvent(self, event):
"""Handle dialog close."""
if self.convert_worker and self.convert_worker.isRunning():
self.convert_worker.stop()
self.convert_worker.wait(1000)
event.accept()
def _apply_dark_theme(self):
"""Apply dark theme."""
self.setStyleSheet("""
QDialog {
background-color: #1e1e1e;
color: #e0e0e0;
}
QGroupBox {
font-weight: bold;
border: 1px solid #444;
border-radius: 6px;
margin-top: 10px;
padding-top: 10px;
}
QListWidget {
background-color: #252525;
border: 1px solid #444;
color: #e0e0e0;
}
QPushButton {
background-color: #0d47a1;
border: 1px solid #1565c0;
border-radius: 4px;
padding: 6px 12px;
color: white;
}
QPushButton:hover {
background-color: #1565c0;
}
QLabel {
color: #e0e0e0;
}
""")