""" Lemontropia Suite - Inventory Scanner Specialized computer vision for extracting item data from Entropia Universe inventory. Features: - Extract item icons from inventory grid - Read item stats from details panel - Handle scrolling for long stat lists - Auto-detect inventory window position """ import cv2 import numpy as np import logging from pathlib import Path from typing import List, Dict, Optional, Tuple, Any from dataclasses import dataclass, field from datetime import datetime import json from modules.game_vision_ai import GameVisionAI, TextRegion, IconRegion logger = logging.getLogger(__name__) @dataclass class InventoryItem: """Represents an item in inventory.""" name: str = "" icon_path: Optional[str] = None icon_hash: str = "" slot_position: Tuple[int, int] = (0, 0) # Grid position (row, col) quantity: int = 1 def to_dict(self) -> Dict[str, Any]: return { 'name': self.name, 'icon_hash': self.icon_hash, 'slot_position': self.slot_position, 'quantity': self.quantity } @dataclass class ItemStats: """Stats extracted from item details panel.""" item_name: str = "" item_class: str = "" # Weapon, Armor, etc. # Weapon stats damage: Optional[float] = None range: Optional[float] = None attacks_per_min: Optional[int] = None decay: Optional[float] = None # PEC ammo_burn: Optional[float] = None # PEC damage_per_pec: Optional[float] = None # Armor stats protection_stab: Optional[float] = None protection_impact: Optional[float] = None protection_cut: Optional[float] = None protection_penetration: Optional[float] = None protection_shrapnel: Optional[float] = None protection_burn: Optional[float] = None protection_cold: Optional[float] = None protection_acid: Optional[float] = None protection_electric: Optional[float] = None # Common stats weight: Optional[float] = None level: Optional[int] = None durability: Optional[float] = None # % markup: Optional[float] = None # Raw text for manual parsing raw_text: str = "" def to_dict(self) -> Dict[str, Any]: return { 'item_name': self.item_name, 'item_class': self.item_class, 'damage': self.damage, 'range': self.range, 'attacks_per_min': self.attacks_per_min, 'decay': self.decay, 'ammo_burn': self.ammo_burn, 'damage_per_pec': self.damage_per_pec, 'protection_stab': self.protection_stab, 'protection_impact': self.protection_impact, 'weight': self.weight, 'level': self.level, 'durability': self.durability, 'raw_text': self.raw_text } @dataclass class InventoryScanResult: """Result of scanning inventory.""" timestamp: datetime = field(default_factory=datetime.now) items: List[InventoryItem] = field(default_factory=list) details_item: Optional[ItemStats] = None inventory_region: Optional[Tuple[int, int, int, int]] = None # x, y, w, h details_region: Optional[Tuple[int, int, int, int]] = None def to_dict(self) -> Dict[str, Any]: return { 'timestamp': self.timestamp.isoformat(), 'items': [item.to_dict() for item in self.items], 'details': self.details_item.to_dict() if self.details_item else None, 'inventory_region': self.inventory_region, 'details_region': self.details_region } def save(self, filepath: str): """Save scan result to JSON.""" with open(filepath, 'w') as f: json.dump(self.to_dict(), f, indent=2) class InventoryScanner: """ Scanner for Entropia Universe inventory. Usage: scanner = InventoryScanner() # Scan inventory for item icons result = scanner.scan_inventory() # Read item details stats = scanner.read_item_details() """ def __init__(self, vision_ai: Optional[GameVisionAI] = None): self.vision = vision_ai or GameVisionAI() # Inventory window detection self.inventory_title_patterns = ["INVENTORY", "Inventory"] self.item_slot_size = (40, 40) # Typical inventory slot size self.item_slot_gap = 4 # Gap between slots # Icon extraction settings self.icon_output_dir = Path.home() / ".lemontropia" / "extracted_icons" self.icon_output_dir.mkdir(parents=True, exist_ok=True) # Detection results cache self._last_screenshot: Optional[np.ndarray] = None self._last_inventory_region: Optional[Tuple[int, int, int, int]] = None self._last_details_region: Optional[Tuple[int, int, int, int]] = None def capture_screen(self) -> np.ndarray: """Capture current screen.""" try: import mss with mss.mss() as sct: monitor = sct.monitors[1] # Primary monitor screenshot = sct.grab(monitor) img = np.array(screenshot) img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) self._last_screenshot = img return img except Exception as e: logger.error(f"Failed to capture screen: {e}") return None def detect_inventory_window(self, image: Optional[np.ndarray] = None) -> Optional[Tuple[int, int, int, int]]: """ Detect inventory window position. Returns: (x, y, w, h) of inventory window or None """ if image is None: image = self.capture_screen() if image is None: return None # Look for "INVENTORY" text in the image texts = self.vision.ocr.extract_text(image) for text_region in texts: text_upper = text_region.text.upper() if "INVENTORY" in text_upper: # Found inventory title, estimate window bounds # Inventory window typically extends down and to the right from title x, y, w, h = text_region.bbox # Estimate full window (typical size ~300x400) window_x = x - 20 # Slight offset for border window_y = y window_w = 350 window_h = 450 # Ensure within image bounds img_h, img_w = image.shape[:2] window_x = max(0, window_x) window_y = max(0, window_y) window_w = min(window_w, img_w - window_x) window_h = min(window_h, img_h - window_y) region = (window_x, window_y, window_w, window_h) self._last_inventory_region = region logger.info(f"Detected inventory window: {region}") return region logger.warning("Could not detect inventory window") return None def detect_item_details_window(self, image: Optional[np.ndarray] = None) -> Optional[Tuple[int, int, int, int]]: """ Detect item details window position. Returns: (x, y, w, h) of details window or None """ if image is None: image = self.capture_screen() if image is None: return None # Look for details panel indicators texts = self.vision.ocr.extract_text(image) for text_region in texts: text = text_region.text.upper() # Look for common details panel headers if any(keyword in text for keyword in ["OVERVIEW", "DETAILS", "DESCRIPTION", "STATS"]): x, y, w, h = text_region.bbox # Estimate full details window (typically ~250x350) window_x = x - 10 window_y = y - 10 window_w = 280 window_h = 400 # Ensure within bounds img_h, img_w = image.shape[:2] window_x = max(0, window_x) window_y = max(0, window_y) window_w = min(window_w, img_w - window_x) window_h = min(window_h, img_h - window_y) region = (window_x, window_y, window_w, window_h) self._last_details_region = region logger.info(f"Detected details window: {region}") return region logger.warning("Could not detect item details window") return None def extract_inventory_icons(self, inventory_region: Optional[Tuple[int, int, int, int]] = None, image: Optional[np.ndarray] = None) -> List[InventoryItem]: """ Extract item icons from inventory grid. Args: inventory_region: Region of inventory window (auto-detect if None) image: Screenshot (capture new if None) Returns: List of InventoryItem with icon paths """ if image is None: image = self.capture_screen() if image is None: return [] if inventory_region is None: inventory_region = self.detect_inventory_window(image) if inventory_region is None: logger.error("Cannot extract icons: inventory window not detected") return [] items = [] try: x, y, w, h = inventory_region inventory_img = image[y:y+h, x:x+w] # Use GameVisionAI to detect icons icon_regions = self.vision.detect_icons(inventory_img) for i, icon_region in enumerate(icon_regions): # Calculate grid position (approximate) slot_x = icon_region.bbox[0] // (self.item_slot_size[0] + self.item_slot_gap) slot_y = icon_region.bbox[1] // (self.item_slot_size[1] + self.item_slot_gap) # Save icon timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") icon_filename = f"inv_icon_{timestamp}_{i}_{icon_region.icon_hash[:8]}.png" icon_path = self.icon_output_dir / icon_filename cv2.imwrite(str(icon_path), icon_region.image) item = InventoryItem( icon_path=str(icon_path), icon_hash=icon_region.icon_hash, slot_position=(int(slot_y), int(slot_x)) ) items.append(item) logger.debug(f"Extracted icon {i}: {icon_path}") except Exception as e: logger.error(f"Failed to extract inventory icons: {e}") return items def read_item_details(self, details_region: Optional[Tuple[int, int, int, int]] = None, image: Optional[np.ndarray] = None) -> Optional[ItemStats]: """ Read item stats from details panel. Args: details_region: Region of details window (auto-detect if None) image: Screenshot (capture new if None) Returns: ItemStats with extracted data """ if image is None: image = self.capture_screen() if image is None: return None if details_region is None: details_region = self.detect_item_details_window(image) if details_region is None: logger.error("Cannot read details: details window not detected") return None try: x, y, w, h = details_region details_img = image[y:y+h, x:x+w] # Extract all text from details panel texts = self.vision.ocr.extract_text(details_img) # Combine all text full_text = "\n".join([t.text for t in texts]) # Parse stats stats = self._parse_item_stats(full_text) stats.raw_text = full_text logger.info(f"Read item details: {stats.item_name}") return stats except Exception as e: logger.error(f"Failed to read item details: {e}") return None def _parse_item_stats(self, text: str) -> ItemStats: """ Parse item stats from extracted text. This handles various item types (weapons, armor, etc.) """ stats = ItemStats() lines = text.split('\n') # Try to find item name (usually first non-empty line) for line in lines: line = line.strip() if line and len(line) > 2: # Skip common headers if line.upper() not in ["OVERVIEW", "DETAILS", "DESCRIPTION", "BASIC", "STATS"]: stats.item_name = line break # Parse numeric values for line in lines: line = line.strip() # Weapon stats if "Damage" in line or "damage" in line: val = self._extract_number(line) if val: stats.damage = val if "Range" in line or "range" in line: val = self._extract_number(line) if val: stats.range = val if "Attacks" in line or "attacks" in line or "Att. Per" in line: val = self._extract_int(line) if val: stats.attacks_per_min = val if "Decay" in line or "decay" in line: val = self._extract_number(line) if val: stats.decay = val if "Ammo" in line or "ammo" in line or "Ammo Burn" in line: val = self._extract_number(line) if val: stats.ammo_burn = val # Armor stats if "Stab" in line or "stab" in line: val = self._extract_number(line) if val: stats.protection_stab = val if "Impact" in line or "impact" in line: val = self._extract_number(line) if val: stats.protection_impact = val if "Cut" in line or "cut" in line: val = self._extract_number(line) if val: stats.protection_cut = val # Common stats if "Weight" in line or "weight" in line: val = self._extract_number(line) if val: stats.weight = val if "Level" in line or "level" in line or "Req. Level" in line: val = self._extract_int(line) if val: stats.level = val # Detect item class from text content if stats.damage: stats.item_class = "Weapon" elif stats.protection_impact: stats.item_class = "Armor" return stats def _extract_number(self, text: str) -> Optional[float]: """Extract first float number from text.""" import re matches = re.findall(r'[\d.]+', text) if matches: try: return float(matches[0]) except: return None return None def _extract_int(self, text: str) -> Optional[int]: """Extract first integer from text.""" val = self._extract_number(text) if val: return int(val) return None def scan_inventory(self, extract_icons: bool = True, read_details: bool = True) -> InventoryScanResult: """ Full inventory scan - icons + details. Returns: InventoryScanResult with all extracted data """ result = InventoryScanResult() # Capture screen image = self.capture_screen() if image is None: logger.error("Failed to capture screen for inventory scan") return result # Detect windows inventory_region = self.detect_inventory_window(image) details_region = self.detect_item_details_window(image) result.inventory_region = inventory_region result.details_region = details_region # Extract icons if extract_icons and inventory_region: result.items = self.extract_inventory_icons(inventory_region, image) # Read details if read_details and details_region: result.details_item = self.read_item_details(details_region, image) return result def save_item_to_database(self, item: InventoryItem, stats: ItemStats): """Save extracted item to database for future reference.""" # TODO: Implement database storage logger.info(f"Would save item to DB: {item.icon_hash}") # Convenience function def scan_inventory() -> InventoryScanResult: """Quick inventory scan.""" scanner = InventoryScanner() return scanner.scan_inventory() def scan_item_details() -> Optional[ItemStats]: """Quick item details scan.""" scanner = InventoryScanner() return scanner.read_item_details()