509 lines
17 KiB
Python
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() |