Lemontropia-Suite/modules/inventory_scanner.py

509 lines
17 KiB
Python

"""
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() / "Documents" / "Entropia Universe" / "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()