Lemontropia-Suite/modules/tga_converter.py

559 lines
21 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, upscale_method: str = 'nearest') -> 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:
# upscale=True unless method is 'none'
do_upscale = upscale and upscale_method != 'none'
image = self._apply_canvas(image, canvas_size, do_upscale, upscale_method)
# 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:
# upscale=True unless method is 'none'
do_upscale = upscale and upscale_method != 'none'
image = self._apply_canvas(image, canvas_size, do_upscale, upscale_method)
# 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, upscale_method='nearest'):
"""
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
upscale_method: 'nearest' (sharp pixels), 'hq4x' (smoothed), or 'lanczos' (smooth)
Returns:
New image with canvas size, source image centered
"""
from PIL import Image, ImageFilter
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., 85%)
max_size = int(min(canvas_w, canvas_h) * 0.85)
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)
if upscale_method == 'nearest':
# NEAREST: Sharp pixels, best for clean pixel art
image = image.resize((new_w, new_h), Image.Resampling.NEAREST)
elif upscale_method == 'hq4x':
# HQ4x-style: Integer scale first, then smooth to target
int_scale = max(2, int(scale))
temp_w = img_w * int_scale
temp_h = img_h * int_scale
# Scale up with NEAREST (integer multiple)
image = image.resize((temp_w, temp_h), Image.Resampling.NEAREST)
# Then downscale to target with LANCZOS for smoothing
image = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Enhance edges
image = image.filter(ImageFilter.EDGE_ENHANCE_MORE)
else: # lanczos
# LANCZOS: Smooth, best for photorealistic textures
image = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Apply subtle sharpening for crispness
image = image.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3))
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()