528 lines
19 KiB
Python
528 lines
19 KiB
Python
"""
|
|
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" / "public_users_data" / "cache" / "icon",
|
|
Path("C:") / "ProgramData" / "Entropia Universe" / "cache" / "icons",
|
|
Path("C:") / "ProgramData" / "Entropia Universe" / "cache",
|
|
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 not path.exists():
|
|
continue
|
|
|
|
# Check if this folder directly contains .tga files
|
|
if list(path.glob("*.tga")):
|
|
logger.info(f"Found cache folder: {path}")
|
|
self._cache_path = path
|
|
return path
|
|
|
|
# Check for subfolders (version folders like "19.3.2.201024")
|
|
for subfolder in path.iterdir():
|
|
if not subfolder.is_dir():
|
|
continue
|
|
|
|
# Check if subfolder contains .tga files
|
|
if list(subfolder.glob("*.tga")):
|
|
logger.info(f"Found cache subfolder: {subfolder}")
|
|
self._cache_path = subfolder
|
|
return subfolder
|
|
|
|
# Check one level deeper (in case of nested structure)
|
|
for nested in subfolder.iterdir():
|
|
if nested.is_dir() and list(nested.glob("*.tga")):
|
|
logger.info(f"Found cache nested folder: {nested}")
|
|
self._cache_path = nested
|
|
return nested
|
|
|
|
# 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
|
|
|
|
# Also search in ProgramData
|
|
program_data = Path("C:") / "ProgramData" / "Entropia Universe"
|
|
if program_data.exists():
|
|
for tga_file in program_data.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:
|
|
# Read header to get ID length
|
|
header = f.read(18)
|
|
if len(header) < 18:
|
|
logger.warning(f"Invalid TGA header: {tga_file.filepath}")
|
|
return None
|
|
|
|
id_length = header[0]
|
|
color_map_type = header[1]
|
|
image_type = header[2]
|
|
|
|
# Skip ID field if present
|
|
if id_length > 0:
|
|
f.seek(18 + id_length)
|
|
|
|
# Skip color map if present
|
|
if color_map_type == 1:
|
|
# Read color map spec
|
|
color_map_offset = 3 # After id_length, color_map_type, image_type
|
|
color_map_length = struct.unpack('<H', header[color_map_offset+2:color_map_offset+4])[0]
|
|
color_map_entry_size = header[color_map_offset+4]
|
|
color_map_size = color_map_length * (color_map_entry_size // 8)
|
|
f.seek(f.tell() + color_map_size)
|
|
|
|
# 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} "
|
|
f"(got {len(pixel_data)}, expected {pixel_data_size})")
|
|
# Try to use what we have
|
|
if len(pixel_data) == 0:
|
|
return None
|
|
# Pad with zeros
|
|
pixel_data = pixel_data + bytes(pixel_data_size - len(pixel_data))
|
|
|
|
# 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,
|
|
canvas_size: Optional[Tuple[int, int]] = None,
|
|
upscale: bool = False) -> Optional[Path]:
|
|
"""
|
|
Convert a TGA file to PNG with optional canvas sizing.
|
|
|
|
Args:
|
|
tga_path: Path to .tga file
|
|
output_name: Optional output filename (without extension)
|
|
canvas_size: Optional (width, height) for output canvas
|
|
upscale: Whether to upscale small icons to fit canvas better
|
|
|
|
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:
|
|
# Try PIL first (handles more TGA formats)
|
|
try:
|
|
image = Image.open(tga_path)
|
|
|
|
# Convert to RGBA if necessary
|
|
if image.mode in ('RGB', 'RGBA'):
|
|
# Image is already in good format
|
|
pass
|
|
elif image.mode == 'P': # Palette
|
|
image = image.convert('RGBA')
|
|
else:
|
|
image = image.convert('RGBA')
|
|
|
|
# Apply canvas sizing if requested
|
|
if canvas_size:
|
|
image = self._apply_canvas(image, canvas_size, upscale)
|
|
|
|
# Determine output path and format
|
|
if output_name is None:
|
|
output_name = tga_path.stem
|
|
|
|
# Save as PNG
|
|
output_path = self.output_dir / f"{output_name}.png"
|
|
image.save(output_path, 'PNG')
|
|
|
|
logger.info(f"Converted (PIL): {tga_path.name} -> {output_path.name}")
|
|
return output_path
|
|
|
|
except Exception as pil_error:
|
|
logger.debug(f"PIL failed, trying manual: {pil_error}")
|
|
|
|
# Fallback to manual TGA reading
|
|
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:
|
|
image = Image.fromarray(pixels, 'RGBA')
|
|
else:
|
|
image = Image.fromarray(pixels, 'RGB')
|
|
|
|
# Apply canvas sizing if requested
|
|
if canvas_size:
|
|
image = self._apply_canvas(image, canvas_size, upscale)
|
|
|
|
# Determine output path and format
|
|
if output_name is None:
|
|
output_name = tga_path.stem
|
|
|
|
# Save as PNG
|
|
output_path = self.output_dir / f"{output_name}.png"
|
|
image.save(output_path, 'PNG')
|
|
|
|
logger.info(f"Converted (manual): {tga_path.name} -> {output_path.name}")
|
|
return output_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,
|
|
canvas_size: Optional[Tuple[int, int]] = None,
|
|
upscale: bool = False) -> Dict[str, Path]:
|
|
"""
|
|
Convert all TGA files in cache folder to PNG.
|
|
|
|
Args:
|
|
cache_path: Path to cache folder (auto-detect if None)
|
|
canvas_size: Optional (width, height) for output canvas
|
|
upscale: Whether to upscale small icons
|
|
|
|
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 (including in subfolders)
|
|
tga_files = list(cache_path.rglob("*.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, canvas_size=canvas_size, upscale=upscale)
|
|
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 _apply_canvas(self, image, canvas_size, upscale=False):
|
|
"""
|
|
Place image centered on a canvas of specified size.
|
|
|
|
Args:
|
|
image: Source PIL Image
|
|
canvas_size: (width, height) for output canvas
|
|
upscale: Whether to upscale small images to fit canvas better
|
|
|
|
Returns:
|
|
New image with canvas size, source image centered
|
|
"""
|
|
from PIL import Image
|
|
|
|
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:
|
|
# Scale up to fit within canvas with some padding (e.g., 90%)
|
|
max_size = int(min(canvas_w, canvas_h) * 0.9)
|
|
scale = min(max_size / img_w, max_size / img_h)
|
|
|
|
if scale > 1: # Only upscale, never downscale
|
|
new_w = int(img_w * scale)
|
|
new_h = int(img_h * scale)
|
|
image = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
|
img_w, img_h = new_w, new_h
|
|
|
|
# Calculate centered position
|
|
x = (canvas_w - img_w) // 2
|
|
y = (canvas_h - img_h) // 2
|
|
|
|
# Paste image onto canvas
|
|
if image.mode == 'RGBA':
|
|
canvas.paste(image, (x, y), image)
|
|
else:
|
|
canvas.paste(image, (x, y))
|
|
|
|
return canvas
|
|
|
|
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() |