EU-Utility/core/icon_extractor.py

286 lines
8.9 KiB
Python

"""
EU-Utility - Icon Extractor Integration
Integrates TGA icon extraction from Lemontropia Suite's Icon Extractor.
Provides icon cache management and TGA to PNG conversion.
"""
import struct
from pathlib import Path
from io import BytesIO
from typing import Optional, Tuple, Any
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
print("[IconExtractor] PIL not available. Install: pip install Pillow")
class TGAReader:
"""Read TGA files from Entropia Universe cache."""
TGA_TYPES = {
0: 'NO_IMAGE',
1: 'COLOR_MAPPED',
2: 'RGB',
3: 'GRAYSCALE',
9: 'RLE_COLOR_MAPPED',
10: 'RLE_RGB',
11: 'RLE_GRAYSCALE',
}
def __init__(self, file_path: Path):
self.file_path = file_path
self.header = None
self.width = 0
self.height = 0
self.pixels = None
def read(self) -> Optional[Any]: # Returns Optional[Image.Image]
"""Read TGA file and return PIL Image."""
if not PIL_AVAILABLE:
return None
# Import Image here to avoid NameError
from PIL import Image
try:
with open(self.file_path, 'rb') as f:
data = f.read()
return self._parse_tga(data)
except Exception as e:
print(f"[TGAReader] Error reading {self.file_path}: {e}")
return None
def _parse_tga(self, data: bytes) -> Optional[Image.Image]:
"""Parse TGA data."""
if len(data) < 18:
return None
# Read header
id_length = data[0]
color_map_type = data[1]
image_type = data[2]
# Color map spec (5 bytes)
color_map_origin = struct.unpack('<H', data[3:5])[0]
color_map_length = struct.unpack('<H', data[5:7])[0]
color_map_entry_size = data[7]
# Image spec (10 bytes)
x_origin = struct.unpack('<H', data[8:10])[0]
y_origin = struct.unpack('<H', data[10:12])[0]
self.width = struct.unpack('<H', data[12:14])[0]
self.height = struct.unpack('<H', data[14:16])[0]
pixel_depth = data[16]
descriptor = data[17]
# Skip ID and color map
offset = 18 + id_length
if color_map_type == 1:
offset += color_map_length * (color_map_entry_size // 8)
# Read pixel data
pixel_data = data[offset:]
# Create image based on type
if image_type in (2, 10): # RGB or RLE RGB
return self._read_rgb(pixel_data, pixel_depth, image_type == 10)
elif image_type in (3, 11): # Grayscale
return self._read_grayscale(pixel_data, image_type == 11)
return None
def _read_rgb(self, data: bytes, depth: int, rle: bool) -> Optional[Any]: # Optional[Image.Image]
"""Read RGB pixel data."""
if not PIL_AVAILABLE:
return None
from PIL import Image
if depth not in (24, 32):
return None
bytes_per_pixel = depth // 8
expected_size = self.width * self.height * bytes_per_pixel
if rle:
data = self._decode_rle(data, bytes_per_pixel)
if len(data) < expected_size:
return None
# Convert BGR to RGB
if bytes_per_pixel == 3:
mode = 'RGB'
pixels = bytearray()
for i in range(0, len(data), 3):
if i + 2 < len(data):
pixels.extend([data[i+2], data[i+1], data[i]]) # BGR to RGB
else:
mode = 'RGBA'
pixels = bytearray()
for i in range(0, len(data), 4):
if i + 3 < len(data):
pixels.extend([data[i+2], data[i+1], data[i], data[i+3]]) # BGRA to RGBA
try:
img = Image.frombytes(mode, (self.width, self.height), bytes(pixels))
# Flip vertically (TGA stores bottom-to-top)
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
return img
except Exception as e:
print(f"[TGAReader] Error creating image: {e}")
return None
def _read_grayscale(self, data: bytes, rle: bool) -> Optional[Any]: # Optional[Image.Image]
"""Read grayscale pixel data."""
if not PIL_AVAILABLE:
return None
from PIL import Image
expected_size = self.width * self.height
if rle:
data = self._decode_rle(data, 1)
if len(data) < expected_size:
return None
try:
img = Image.frombytes('L', (self.width, self.height), data[:expected_size])
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
return img
except Exception as e:
print(f"[TGAReader] Error creating grayscale image: {e}")
return None
def _decode_rle(self, data: bytes, bytes_per_pixel: int) -> bytes:
"""Decode RLE compressed data."""
result = bytearray()
i = 0
while i < len(data):
header = data[i]
i += 1
if header >= 128: # Run-length packet
count = (header - 128) + 1
pixel = data[i:i + bytes_per_pixel]
i += bytes_per_pixel
for _ in range(count):
result.extend(pixel)
else: # Raw packet
count = header + 1
for _ in range(count):
result.extend(data[i:i + bytes_per_pixel])
i += bytes_per_pixel
return bytes(result)
class IconCacheManager:
"""Manages EU icon cache extraction and storage."""
def __init__(self, cache_dir: str = "assets/eu_icons"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.icon_index = {}
self._load_index()
def _load_index(self):
"""Load icon index."""
index_file = self.cache_dir / "index.json"
if index_file.exists():
try:
import json
with open(index_file, 'r') as f:
self.icon_index = json.load(f)
except:
self.icon_index = {}
def _save_index(self):
"""Save icon index."""
import json
index_file = self.cache_dir / "index.json"
with open(index_file, 'w') as f:
json.dump(self.icon_index, f, indent=2)
def extract_from_tga(self, tga_path: Path, icon_name: str) -> Optional[Path]:
"""Extract icon from TGA file and save as PNG."""
if not PIL_AVAILABLE:
return None
reader = TGAReader(tga_path)
img = reader.read()
if img:
# Save as PNG
output_path = self.cache_dir / f"{icon_name}.png"
img.save(output_path, 'PNG')
# Update index
self.icon_index[icon_name] = {
'source': str(tga_path),
'size': (reader.width, reader.height),
'cached': str(output_path)
}
self._save_index()
return output_path
return None
def get_icon(self, icon_name: str, size: Tuple[int, int] = (32, 32)) -> Optional[Path]:
"""Get icon path, extracting from TGA if needed."""
# Check cache
cached = self.cache_dir / f"{icon_name}.png"
if cached.exists():
return cached
# Check if we have source TGA
if icon_name in self.icon_index:
source = Path(self.icon_index[icon_name]['source'])
if source.exists():
return self.extract_from_tga(source, icon_name)
return None
def scan_cache_directory(self, eu_cache_path: Path):
"""Scan EU cache directory for TGA icons."""
if not eu_cache_path.exists():
return
print(f"[IconCache] Scanning {eu_cache_path}...")
found = 0
for tga_file in eu_cache_path.rglob("*.tga"):
# Try to extract
icon_name = tga_file.stem
if self.extract_from_tga(tga_file, icon_name):
found += 1
print(f"[IconCache] Extracted {found} icons")
def list_icons(self) -> list:
"""List all available cached icons."""
icons = []
for png_file in self.cache_dir.glob("*.png"):
icons.append(png_file.stem)
return sorted(icons)
# Singleton instance
_icon_cache = None
def get_icon_cache() -> IconCacheManager:
"""Get global icon cache instance."""
global _icon_cache
if _icon_cache is None:
_icon_cache = IconCacheManager()
return _icon_cache