feat: add Inventory Scanner for extracting item icons and stats
- New module: modules/inventory_scanner.py - Detects inventory and item details windows - Extracts item icons from inventory grid - Reads item stats from details panel - Parses weapon, armor, and common stats - Handles scrolling (planned for future) - New dialog: ui/inventory_scanner_dialog.py - Visual interface for scanning - Shows extracted icons in grid view - Displays item stats in table - Saves results to JSON - Background worker for non-blocking scans - Updated main_window.py: - Added Tools → Computer Vision → Inventory Scanner (Ctrl+I) - Integrated with existing GameVisionAI This allows users to extract item data from Entropia Universe for gear management and loadout configuration.
This commit is contained in:
parent
27b3bd0fe1
commit
1a0c0d4231
|
|
@ -0,0 +1,290 @@
|
||||||
|
# Lemontropia Suite - OCR System Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implemented a **robust multi-backend OCR system** that handles PyTorch DLL errors on Windows Store Python and provides graceful fallbacks to working backends.
|
||||||
|
|
||||||
|
## Problem Solved
|
||||||
|
- **PyTorch fails to load c10.dll on Windows Store Python 3.13**
|
||||||
|
- PaddleOCR requires PyTorch which causes DLL errors
|
||||||
|
- Need working OCR for game text detection without breaking dependencies
|
||||||
|
|
||||||
|
## Solution Architecture
|
||||||
|
|
||||||
|
### 1. OCR Backends (Priority Order)
|
||||||
|
|
||||||
|
| Backend | File | Speed | Accuracy | Dependencies | Windows Store Python |
|
||||||
|
|---------|------|-------|----------|--------------|---------------------|
|
||||||
|
| **OpenCV EAST** | `opencv_east_backend.py` | ⚡ Fastest | Detection only | None | ✅ Works |
|
||||||
|
| **EasyOCR** | `easyocr_backend.py` | 🚀 Fast | ⭐⭐⭐ Good | PyTorch | ❌ May fail |
|
||||||
|
| **Tesseract** | `tesseract_backend.py` | 🐢 Slow | ⭐⭐ Medium | Tesseract binary | ✅ Works |
|
||||||
|
| **PaddleOCR** | `paddleocr_backend.py` | 🚀 Fast | ⭐⭐⭐⭐⭐ Best | PaddlePaddle | ❌ May fail |
|
||||||
|
|
||||||
|
### 2. Hardware Detection
|
||||||
|
|
||||||
|
**File**: `modules/hardware_detection.py`
|
||||||
|
|
||||||
|
- Detects GPU availability (CUDA, MPS, DirectML)
|
||||||
|
- Detects PyTorch with **safe error handling for DLL errors**
|
||||||
|
- Detects Windows Store Python
|
||||||
|
- Recommends best OCR backend based on hardware
|
||||||
|
|
||||||
|
### 3. Unified OCR Interface
|
||||||
|
|
||||||
|
**File**: `modules/game_vision_ai.py` (updated)
|
||||||
|
|
||||||
|
- `UnifiedOCRProcessor` - Main OCR interface
|
||||||
|
- Auto-selects best available backend
|
||||||
|
- Graceful fallback chain
|
||||||
|
- Backend switching at runtime
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/
|
||||||
|
├── __init__.py # Module exports
|
||||||
|
├── hardware_detection.py # GPU/ML framework detection
|
||||||
|
└── ocr_backends/
|
||||||
|
├── __init__.py # Backend factory and base classes
|
||||||
|
├── opencv_east_backend.py # OpenCV EAST text detector
|
||||||
|
├── easyocr_backend.py # EasyOCR backend
|
||||||
|
├── tesseract_backend.py # Tesseract OCR backend
|
||||||
|
└── paddleocr_backend.py # PaddleOCR backend with DLL handling
|
||||||
|
|
||||||
|
test_ocr_system.py # Comprehensive test suite
|
||||||
|
demo_ocr.py # Interactive demo
|
||||||
|
requirements-ocr.txt # OCR dependencies
|
||||||
|
OCR_SETUP.md # Setup guide
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/
|
||||||
|
└── game_vision_ai.py # Updated to use unified OCR interface
|
||||||
|
|
||||||
|
vision_example.py # Updated examples
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. PyTorch DLL Error Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
# The system detects and handles PyTorch DLL errors gracefully
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
# If this fails with DLL error on Windows Store Python...
|
||||||
|
except OSError as e:
|
||||||
|
if 'dll' in str(e).lower() or 'c10' in str(e).lower():
|
||||||
|
# Automatically use fallback backends
|
||||||
|
logger.warning("PyTorch DLL error - using fallback OCR")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Auto-Selection Logic
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Priority order (skips PyTorch-based if DLL error detected)
|
||||||
|
DEFAULT_PRIORITY = [
|
||||||
|
'paddleocr', # Best accuracy (if PyTorch works)
|
||||||
|
'easyocr', # Good balance (if PyTorch works)
|
||||||
|
'tesseract', # Stable fallback
|
||||||
|
'opencv_east', # Always works
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Simple Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from modules.game_vision_ai import GameVisionAI
|
||||||
|
|
||||||
|
# Initialize (auto-selects best backend)
|
||||||
|
vision = GameVisionAI()
|
||||||
|
|
||||||
|
# Process screenshot
|
||||||
|
result = vision.process_screenshot("game_screenshot.png")
|
||||||
|
|
||||||
|
print(f"Backend: {result.ocr_backend}")
|
||||||
|
print(f"Text regions: {len(result.text_regions)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Backend Diagnostics
|
||||||
|
|
||||||
|
```python
|
||||||
|
from modules.game_vision_ai import GameVisionAI
|
||||||
|
|
||||||
|
# Run diagnostics
|
||||||
|
diag = GameVisionAI.diagnose()
|
||||||
|
|
||||||
|
# Check available backends
|
||||||
|
for backend in diag['ocr_backends']:
|
||||||
|
print(f"{backend['name']}: {'Available' if backend['available'] else 'Not available'}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Run Test Suite
|
||||||
|
```bash
|
||||||
|
python test_ocr_system.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Demo
|
||||||
|
```bash
|
||||||
|
python demo_ocr.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Examples
|
||||||
|
```bash
|
||||||
|
# Hardware detection
|
||||||
|
python vision_example.py --hardware
|
||||||
|
|
||||||
|
# List OCR backends
|
||||||
|
python vision_example.py --backends
|
||||||
|
|
||||||
|
# Full diagnostics
|
||||||
|
python vision_example.py --diagnostics
|
||||||
|
|
||||||
|
# Test with image
|
||||||
|
python vision_example.py --full path/to/screenshot.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Minimal (OpenCV EAST Only)
|
||||||
|
```bash
|
||||||
|
pip install opencv-python numpy pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: With EasyOCR
|
||||||
|
```bash
|
||||||
|
pip install torch torchvision # May fail on Windows Store Python
|
||||||
|
pip install easyocr
|
||||||
|
pip install opencv-python numpy pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: With Tesseract
|
||||||
|
```bash
|
||||||
|
# Install Tesseract binary first
|
||||||
|
choco install tesseract # Windows
|
||||||
|
# or download from https://github.com/UB-Mannheim/tesseract/wiki
|
||||||
|
|
||||||
|
pip install pytesseract opencv-python numpy pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows Store Python Compatibility
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
```
|
||||||
|
OSError: [WinError 126] The specified module could not be found
|
||||||
|
File "torch\__init__.py", line xxx, in <module>
|
||||||
|
from torch._C import * # DLL load failed
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Solution
|
||||||
|
The system automatically:
|
||||||
|
1. Detects Windows Store Python
|
||||||
|
2. Detects PyTorch DLL errors on import
|
||||||
|
3. Excludes PyTorch-based backends from selection
|
||||||
|
4. Falls back to OpenCV EAST or Tesseract
|
||||||
|
|
||||||
|
### Workarounds for Full PyTorch Support
|
||||||
|
1. **Use Python from python.org** instead of Windows Store
|
||||||
|
2. **Use Anaconda/Miniconda** for better compatibility
|
||||||
|
3. **Use WSL2** (Windows Subsystem for Linux)
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Hardware Detection
|
||||||
|
|
||||||
|
```python
|
||||||
|
from modules.hardware_detection import (
|
||||||
|
HardwareDetector,
|
||||||
|
print_hardware_summary,
|
||||||
|
recommend_ocr_backend
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get hardware info
|
||||||
|
info = HardwareDetector.detect_all()
|
||||||
|
print(f"PyTorch available: {info.pytorch_available}")
|
||||||
|
print(f"PyTorch DLL error: {info.pytorch_dll_error}")
|
||||||
|
|
||||||
|
# Get recommendation
|
||||||
|
recommended = recommend_ocr_backend() # Returns: 'opencv_east', 'easyocr', etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### OCR Backends
|
||||||
|
|
||||||
|
```python
|
||||||
|
from modules.ocr_backends import OCRBackendFactory
|
||||||
|
|
||||||
|
# Check all backends
|
||||||
|
backends = OCRBackendFactory.check_all_backends()
|
||||||
|
|
||||||
|
# Create specific backend
|
||||||
|
backend = OCRBackendFactory.create_backend('opencv_east')
|
||||||
|
|
||||||
|
# Get best available
|
||||||
|
backend = OCRBackendFactory.get_best_backend()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unified OCR
|
||||||
|
|
||||||
|
```python
|
||||||
|
from modules.game_vision_ai import UnifiedOCRProcessor
|
||||||
|
|
||||||
|
# Auto-select best backend
|
||||||
|
ocr = UnifiedOCRProcessor()
|
||||||
|
|
||||||
|
# Force specific backend
|
||||||
|
ocr = UnifiedOCRProcessor(backend_priority=['tesseract', 'opencv_east'])
|
||||||
|
|
||||||
|
# Extract text
|
||||||
|
regions = ocr.extract_text("image.png")
|
||||||
|
|
||||||
|
# Switch backend
|
||||||
|
ocr.set_backend('tesseract')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Game Vision AI
|
||||||
|
|
||||||
|
```python
|
||||||
|
from modules.game_vision_ai import GameVisionAI
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
vision = GameVisionAI()
|
||||||
|
|
||||||
|
# Or with specific backend
|
||||||
|
vision = GameVisionAI(ocr_backend='tesseract')
|
||||||
|
|
||||||
|
# Process screenshot
|
||||||
|
result = vision.process_screenshot("screenshot.png")
|
||||||
|
|
||||||
|
# Switch backend at runtime
|
||||||
|
vision.switch_ocr_backend('opencv_east')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- **OpenCV EAST**: ~97 FPS on GPU, ~23 FPS on CPU
|
||||||
|
- **EasyOCR**: ~10 FPS on CPU, faster on GPU
|
||||||
|
- **Tesseract**: Slower but very stable
|
||||||
|
- **PaddleOCR**: Fastest with GPU, best accuracy
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| "No OCR backend available" | Install opencv-python |
|
||||||
|
| "PyTorch DLL error" | Use OpenCV EAST or Tesseract |
|
||||||
|
| "Tesseract not found" | Install Tesseract binary |
|
||||||
|
| Low accuracy | Use EasyOCR or PaddleOCR |
|
||||||
|
| Slow performance | Enable GPU or use OpenCV EAST |
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] ONNX Runtime backend (lighter than PyTorch)
|
||||||
|
- [ ] TensorFlow Lite backend
|
||||||
|
- [ ] Custom trained models for game UI
|
||||||
|
- [ ] YOLO-based UI element detection
|
||||||
|
- [ ] Online learning for icon recognition
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
"""
|
||||||
|
Lemontropia Suite - OCR Demo
|
||||||
|
Demonstrates the multi-backend OCR system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
|
||||||
|
def create_demo_image():
|
||||||
|
"""Create a demo image with text-like regions."""
|
||||||
|
# Create white image
|
||||||
|
img = np.ones((600, 800, 3), dtype=np.uint8) * 255
|
||||||
|
|
||||||
|
# Draw some "UI elements" that look like game text
|
||||||
|
# Header bar
|
||||||
|
cv2.rectangle(img, (50, 30), (750, 70), (50, 50, 50), -1)
|
||||||
|
|
||||||
|
# Loot window
|
||||||
|
cv2.rectangle(img, (450, 100), (780, 400), (200, 200, 200), -1)
|
||||||
|
cv2.rectangle(img, (450, 100), (780, 130), (100, 100, 100), -1)
|
||||||
|
|
||||||
|
# Item slots (grid)
|
||||||
|
slot_size = 50
|
||||||
|
gap = 10
|
||||||
|
start_x, start_y = 470, 150
|
||||||
|
for row in range(4):
|
||||||
|
for col in range(5):
|
||||||
|
x = start_x + col * (slot_size + gap)
|
||||||
|
y = start_y + row * (slot_size + gap)
|
||||||
|
cv2.rectangle(img, (x, y), (x + slot_size, y + slot_size), (150, 150, 150), -1)
|
||||||
|
# Add small colored squares (items)
|
||||||
|
if (row + col) % 3 == 0:
|
||||||
|
color = (0, 100, 200) if row % 2 == 0 else (200, 100, 0)
|
||||||
|
cv2.rectangle(img, (x+5, y+5), (x+slot_size-5, y+slot_size-5), color, -1)
|
||||||
|
|
||||||
|
# Text-like regions (rectangles that could contain text)
|
||||||
|
# Chat/log area
|
||||||
|
cv2.rectangle(img, (20, 450), (400, 580), (240, 240, 240), -1)
|
||||||
|
cv2.rectangle(img, (20, 450), (400, 475), (180, 180, 180), -1)
|
||||||
|
|
||||||
|
# Status bars
|
||||||
|
cv2.rectangle(img, (20, 150), (200, 170), (200, 200, 200), -1)
|
||||||
|
cv2.rectangle(img, (20, 150), (150, 170), (0, 150, 0), -1) # Health bar
|
||||||
|
|
||||||
|
cv2.rectangle(img, (20, 180), (200, 200), (200, 200, 200), -1)
|
||||||
|
cv2.rectangle(img, (20, 180), (120, 200), (0, 100, 200), -1) # Energy bar
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def demo_hardware_detection():
|
||||||
|
"""Demo hardware detection."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("HARDWARE DETECTION DEMO")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
from modules.hardware_detection import (
|
||||||
|
print_hardware_summary,
|
||||||
|
recommend_ocr_backend,
|
||||||
|
HardwareDetector
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print_hardware_summary()
|
||||||
|
|
||||||
|
# Get recommendation
|
||||||
|
recommended = recommend_ocr_backend()
|
||||||
|
print(f"\n📋 Recommended OCR backend: {recommended}")
|
||||||
|
|
||||||
|
# Check for Windows Store Python issues
|
||||||
|
info = HardwareDetector.detect_all()
|
||||||
|
if info.is_windows_store_python:
|
||||||
|
print("\n⚠️ Note: Windows Store Python detected")
|
||||||
|
print(" If you see DLL errors, use 'opencv_east' or 'tesseract' backend")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_ocr_backends():
|
||||||
|
"""Demo OCR backends."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("OCR BACKEND DEMO")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
from modules.ocr_backends import OCRBackendFactory
|
||||||
|
|
||||||
|
# Create demo image
|
||||||
|
demo_img = create_demo_image()
|
||||||
|
|
||||||
|
# Save for reference
|
||||||
|
demo_path = Path.home() / ".lemontropia" / "demo_screenshot.png"
|
||||||
|
demo_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cv2.imwrite(str(demo_path), demo_img)
|
||||||
|
print(f"\nDemo image saved to: {demo_path}")
|
||||||
|
|
||||||
|
# Check available backends
|
||||||
|
print("\nChecking OCR backends...")
|
||||||
|
backends = OCRBackendFactory.check_all_backends(use_gpu=True)
|
||||||
|
|
||||||
|
for backend_info in backends:
|
||||||
|
status = "✅" if backend_info.available else "❌"
|
||||||
|
gpu = "🚀 GPU" if backend_info.gpu_accelerated else "💻 CPU"
|
||||||
|
print(f"\n{status} {backend_info.name.upper()}")
|
||||||
|
print(f" Available: {backend_info.available}")
|
||||||
|
print(f" GPU: {gpu}")
|
||||||
|
|
||||||
|
if backend_info.error_message:
|
||||||
|
print(f" Error: {backend_info.error_message}")
|
||||||
|
|
||||||
|
# Test if available
|
||||||
|
if backend_info.available:
|
||||||
|
print(f" Testing...", end=" ")
|
||||||
|
try:
|
||||||
|
backend = OCRBackendFactory.create_backend(
|
||||||
|
backend_info.name, use_gpu=True
|
||||||
|
)
|
||||||
|
if backend:
|
||||||
|
regions = backend.extract_text(demo_img)
|
||||||
|
print(f"Detected {len(regions)} text regions ✓")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_game_vision():
|
||||||
|
"""Demo GameVisionAI."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("GAME VISION AI DEMO")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
from modules.game_vision_ai import GameVisionAI
|
||||||
|
|
||||||
|
# Create demo image
|
||||||
|
demo_img = create_demo_image()
|
||||||
|
demo_path = Path.home() / ".lemontropia" / "demo_screenshot.png"
|
||||||
|
cv2.imwrite(str(demo_path), demo_img)
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
print("\nInitializing GameVisionAI...")
|
||||||
|
vision = GameVisionAI(use_gpu=True)
|
||||||
|
|
||||||
|
print(f"✅ Initialized!")
|
||||||
|
print(f" OCR Backend: {vision.ocr.get_current_backend()}")
|
||||||
|
print(f" GPU: {vision.backend.value}")
|
||||||
|
|
||||||
|
# Process screenshot
|
||||||
|
print(f"\nProcessing demo screenshot...")
|
||||||
|
result = vision.process_screenshot(demo_path)
|
||||||
|
|
||||||
|
print(f"✅ Processing complete!")
|
||||||
|
print(f" Processing time: {result.processing_time_ms:.1f}ms")
|
||||||
|
print(f" Text regions: {len(result.text_regions)}")
|
||||||
|
print(f" Icons detected: {len(result.icon_regions)}")
|
||||||
|
|
||||||
|
# Show detected regions
|
||||||
|
if result.text_regions:
|
||||||
|
print("\n Detected text regions:")
|
||||||
|
for i, region in enumerate(result.text_regions[:5]):
|
||||||
|
x, y, w, h = region.bbox
|
||||||
|
print(f" {i+1}. bbox=({x},{y},{w},{h}), conf={region.confidence:.2f}")
|
||||||
|
|
||||||
|
if result.icon_regions:
|
||||||
|
print("\n Detected icons:")
|
||||||
|
for i, icon in enumerate(result.icon_regions[:5]):
|
||||||
|
x, y, w, h = icon.bbox
|
||||||
|
print(f" {i+1}. bbox=({x},{y},{w},{h}), hash={icon.icon_hash[:16]}...")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_backend_switching():
|
||||||
|
"""Demo switching between backends."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("BACKEND SWITCHING DEMO")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
from modules.game_vision_ai import UnifiedOCRProcessor
|
||||||
|
|
||||||
|
# Create processor
|
||||||
|
processor = UnifiedOCRProcessor()
|
||||||
|
print(f"\nDefault backend: {processor.get_current_backend()}")
|
||||||
|
|
||||||
|
# List all available backends
|
||||||
|
print("\nAvailable backends:")
|
||||||
|
for info in processor.get_available_backends():
|
||||||
|
status = "✅" if info.available else "❌"
|
||||||
|
print(f" {status} {info.name}")
|
||||||
|
|
||||||
|
# Try switching backends
|
||||||
|
demo_img = create_demo_image()
|
||||||
|
|
||||||
|
for backend_name in ['opencv_east', 'tesseract', 'easyocr', 'paddleocr']:
|
||||||
|
print(f"\nTrying to switch to '{backend_name}'...", end=" ")
|
||||||
|
success = processor.set_backend(backend_name)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"✅ Success!")
|
||||||
|
regions = processor.extract_text(demo_img)
|
||||||
|
print(f" Detected {len(regions)} regions")
|
||||||
|
else:
|
||||||
|
print(f"❌ Not available")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run demos."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("LEMONTROPIA SUITE - OCR SYSTEM DEMO")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\nThis demo shows the multi-backend OCR system.")
|
||||||
|
print("The system automatically handles PyTorch DLL errors")
|
||||||
|
print("and falls back to working backends.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
demo_hardware_detection()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Hardware detection failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
demo_ocr_backends()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ OCR backend demo failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
demo_game_vision()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Game Vision demo failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
demo_backend_switching()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Backend switching demo failed: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("DEMO COMPLETE")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\nFor more information, see OCR_SETUP.md")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,510 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
from modules.ocr_backends import create_ocr_backend, AVAILABLE_BACKENDS
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
@ -0,0 +1,453 @@
|
||||||
|
"""
|
||||||
|
Lemontropia Suite - Inventory Scanner Dialog
|
||||||
|
UI for scanning and extracting item data from Entropia Universe inventory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||||
|
QLabel, QPushButton, QListWidget, QListWidgetItem,
|
||||||
|
QGroupBox, QSplitter, QTableWidget, QTableWidgetItem,
|
||||||
|
QHeaderView, QTextEdit, QMessageBox, QFileDialog,
|
||||||
|
QProgressBar, QCheckBox, QSpinBox, QTabWidget,
|
||||||
|
QWidget, QScrollArea, QFrame
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
|
||||||
|
from PyQt6.QtGui import QPixmap, QImage
|
||||||
|
|
||||||
|
from modules.inventory_scanner import InventoryScanner, InventoryScanResult, ItemStats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryScanWorker(QThread):
|
||||||
|
"""Background worker for inventory scanning."""
|
||||||
|
|
||||||
|
scan_complete = pyqtSignal(object) # InventoryScanResult
|
||||||
|
scan_error = pyqtSignal(str)
|
||||||
|
progress_update = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, scanner: InventoryScanner, extract_icons: bool = True,
|
||||||
|
read_details: bool = True):
|
||||||
|
super().__init__()
|
||||||
|
self.scanner = scanner
|
||||||
|
self.extract_icons = extract_icons
|
||||||
|
self.read_details = read_details
|
||||||
|
self._is_running = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the scan."""
|
||||||
|
try:
|
||||||
|
self.progress_update.emit("Capturing screen...")
|
||||||
|
|
||||||
|
result = self.scanner.scan_inventory(
|
||||||
|
extract_icons=self.extract_icons,
|
||||||
|
read_details=self.read_details
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._is_running:
|
||||||
|
self.scan_complete.emit(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self._is_running:
|
||||||
|
self.scan_error.emit(str(e))
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the scan."""
|
||||||
|
self._is_running = False
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryScannerDialog(QDialog):
|
||||||
|
"""
|
||||||
|
Dialog for scanning Entropia Universe inventory.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Auto-detect inventory window
|
||||||
|
- Extract item icons
|
||||||
|
- Read item stats from details panel
|
||||||
|
- Save results
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("🔍 Inventory Scanner")
|
||||||
|
self.setMinimumSize(900, 700)
|
||||||
|
self.resize(1100, 800)
|
||||||
|
|
||||||
|
self.scanner = InventoryScanner()
|
||||||
|
self.scan_worker: Optional[InventoryScanWorker] = None
|
||||||
|
self.last_result: Optional[InventoryScanResult] = None
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
self._apply_dark_theme()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Setup the dialog UI."""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(15, 15, 15, 15)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QLabel("Inventory Scanner - Extract items and stats from Entropia Universe")
|
||||||
|
header.setStyleSheet("font-size: 16px; font-weight: bold; color: #4caf50;")
|
||||||
|
layout.addWidget(header)
|
||||||
|
|
||||||
|
# Controls
|
||||||
|
controls_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# Scan buttons
|
||||||
|
self.scan_btn = QPushButton("🔍 Scan Inventory")
|
||||||
|
self.scan_btn.setMinimumHeight(40)
|
||||||
|
self.scan_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #0d47a1;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #1565c0; }
|
||||||
|
""")
|
||||||
|
self.scan_btn.clicked.connect(self.on_scan)
|
||||||
|
controls_layout.addWidget(self.scan_btn)
|
||||||
|
|
||||||
|
self.scan_details_btn = QPushButton("📋 Scan Item Details Only")
|
||||||
|
self.scan_details_btn.setMinimumHeight(40)
|
||||||
|
self.scan_details_btn.clicked.connect(self.on_scan_details_only)
|
||||||
|
controls_layout.addWidget(self.scan_details_btn)
|
||||||
|
|
||||||
|
# Options
|
||||||
|
options_group = QGroupBox("Options")
|
||||||
|
options_layout = QHBoxLayout(options_group)
|
||||||
|
|
||||||
|
self.extract_icons_check = QCheckBox("Extract Icons")
|
||||||
|
self.extract_icons_check.setChecked(True)
|
||||||
|
options_layout.addWidget(self.extract_icons_check)
|
||||||
|
|
||||||
|
self.read_details_check = QCheckBox("Read Item Details")
|
||||||
|
self.read_details_check.setChecked(True)
|
||||||
|
options_layout.addWidget(self.read_details_check)
|
||||||
|
|
||||||
|
controls_layout.addWidget(options_group)
|
||||||
|
controls_layout.addStretch()
|
||||||
|
|
||||||
|
# Save button
|
||||||
|
self.save_btn = QPushButton("💾 Save Results")
|
||||||
|
self.save_btn.clicked.connect(self.on_save_results)
|
||||||
|
self.save_btn.setEnabled(False)
|
||||||
|
controls_layout.addWidget(self.save_btn)
|
||||||
|
|
||||||
|
layout.addLayout(controls_layout)
|
||||||
|
|
||||||
|
# Progress bar
|
||||||
|
self.progress = QProgressBar()
|
||||||
|
self.progress.setRange(0, 0) # Indeterminate
|
||||||
|
self.progress.setVisible(False)
|
||||||
|
layout.addWidget(self.progress)
|
||||||
|
|
||||||
|
# Status label
|
||||||
|
self.status_label = QLabel("Ready to scan")
|
||||||
|
self.status_label.setStyleSheet("color: #888; padding: 5px;")
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
# Results tabs
|
||||||
|
self.results_tabs = QTabWidget()
|
||||||
|
layout.addWidget(self.results_tabs)
|
||||||
|
|
||||||
|
# Tab 1: Item Icons
|
||||||
|
self.icons_tab = self._create_icons_tab()
|
||||||
|
self.results_tabs.addTab(self.icons_tab, "🖼️ Item Icons")
|
||||||
|
|
||||||
|
# Tab 2: Item Details
|
||||||
|
self.details_tab = self._create_details_tab()
|
||||||
|
self.results_tabs.addTab(self.details_tab, "📋 Item Details")
|
||||||
|
|
||||||
|
# Tab 3: Raw Data
|
||||||
|
self.raw_tab = self._create_raw_tab()
|
||||||
|
self.results_tabs.addTab(self.raw_tab, "📝 Raw Data")
|
||||||
|
|
||||||
|
# Help text
|
||||||
|
help = QLabel(
|
||||||
|
"Instructions:\n"
|
||||||
|
"1. Open Entropia Universe\n"
|
||||||
|
"2. Open your inventory window\n"
|
||||||
|
"3. Click an item to show its details panel\n"
|
||||||
|
"4. Click 'Scan Inventory' above"
|
||||||
|
)
|
||||||
|
help.setStyleSheet("color: #888; padding: 10px; background-color: #252525; border-radius: 4px;")
|
||||||
|
help.setWordWrap(True)
|
||||||
|
layout.addWidget(help)
|
||||||
|
|
||||||
|
# Close button
|
||||||
|
close_btn = QPushButton("Close")
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
def _create_icons_tab(self) -> QWidget:
|
||||||
|
"""Create the item icons tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# Icons count
|
||||||
|
self.icons_count_label = QLabel("No icons extracted yet")
|
||||||
|
layout.addWidget(self.icons_count_label)
|
||||||
|
|
||||||
|
# Icons grid
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setStyleSheet("background-color: #1a1a1a;")
|
||||||
|
|
||||||
|
self.icons_container = QWidget()
|
||||||
|
self.icons_grid = QGridLayout(self.icons_container)
|
||||||
|
self.icons_grid.setSpacing(10)
|
||||||
|
scroll.setWidget(self.icons_container)
|
||||||
|
|
||||||
|
layout.addWidget(scroll)
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _create_details_tab(self) -> QWidget:
|
||||||
|
"""Create the item details tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# Details table
|
||||||
|
self.details_table = QTableWidget()
|
||||||
|
self.details_table.setColumnCount(2)
|
||||||
|
self.details_table.setHorizontalHeaderLabels(["Property", "Value"])
|
||||||
|
self.details_table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
self.details_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||||
|
layout.addWidget(self.details_table)
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _create_raw_tab(self) -> QWidget:
|
||||||
|
"""Create the raw data tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
self.raw_text = QTextEdit()
|
||||||
|
self.raw_text.setReadOnly(True)
|
||||||
|
self.raw_text.setFontFamily("Consolas")
|
||||||
|
layout.addWidget(self.raw_text)
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def on_scan(self):
|
||||||
|
"""Start full inventory scan."""
|
||||||
|
self._start_scan(
|
||||||
|
extract_icons=self.extract_icons_check.isChecked(),
|
||||||
|
read_details=self.read_details_check.isChecked()
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_scan_details_only(self):
|
||||||
|
"""Scan only item details."""
|
||||||
|
self._start_scan(extract_icons=False, read_details=True)
|
||||||
|
|
||||||
|
def _start_scan(self, extract_icons: bool, read_details: bool):
|
||||||
|
"""Start scan worker."""
|
||||||
|
if self.scan_worker and self.scan_worker.isRunning():
|
||||||
|
QMessageBox.warning(self, "Scan in Progress", "A scan is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update UI
|
||||||
|
self.scan_btn.setEnabled(False)
|
||||||
|
self.scan_details_btn.setEnabled(False)
|
||||||
|
self.progress.setVisible(True)
|
||||||
|
self.status_label.setText("Scanning...")
|
||||||
|
self.save_btn.setEnabled(False)
|
||||||
|
|
||||||
|
# Clear previous results
|
||||||
|
self._clear_icons_grid()
|
||||||
|
self.details_table.setRowCount(0)
|
||||||
|
self.raw_text.clear()
|
||||||
|
|
||||||
|
# Start worker
|
||||||
|
self.scan_worker = InventoryScanWorker(
|
||||||
|
self.scanner,
|
||||||
|
extract_icons=extract_icons,
|
||||||
|
read_details=read_details
|
||||||
|
)
|
||||||
|
self.scan_worker.scan_complete.connect(self._on_scan_complete)
|
||||||
|
self.scan_worker.scan_error.connect(self._on_scan_error)
|
||||||
|
self.scan_worker.progress_update.connect(self._on_progress_update)
|
||||||
|
self.scan_worker.start()
|
||||||
|
|
||||||
|
def _on_scan_complete(self, result: InventoryScanResult):
|
||||||
|
"""Handle scan completion."""
|
||||||
|
self.last_result = result
|
||||||
|
|
||||||
|
# Update UI
|
||||||
|
self.progress.setVisible(False)
|
||||||
|
self.scan_btn.setEnabled(True)
|
||||||
|
self.scan_details_btn.setEnabled(True)
|
||||||
|
self.save_btn.setEnabled(True)
|
||||||
|
|
||||||
|
self.status_label.setText(
|
||||||
|
f"Scan complete: {len(result.items)} icons, "
|
||||||
|
f"details: {'Yes' if result.details_item else 'No'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update tabs
|
||||||
|
self._update_icons_tab(result)
|
||||||
|
self._update_details_tab(result)
|
||||||
|
self._update_raw_tab(result)
|
||||||
|
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Scan Complete",
|
||||||
|
f"Found {len(result.items)} items\n"
|
||||||
|
f"Item details: {'Yes' if result.details_item else 'No'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_scan_error(self, error: str):
|
||||||
|
"""Handle scan error."""
|
||||||
|
self.progress.setVisible(False)
|
||||||
|
self.scan_btn.setEnabled(True)
|
||||||
|
self.scan_details_btn.setEnabled(True)
|
||||||
|
self.status_label.setText(f"Error: {error}")
|
||||||
|
|
||||||
|
QMessageBox.critical(self, "Scan Error", error)
|
||||||
|
|
||||||
|
def _on_progress_update(self, message: str):
|
||||||
|
"""Handle progress update."""
|
||||||
|
self.status_label.setText(message)
|
||||||
|
|
||||||
|
def _clear_icons_grid(self):
|
||||||
|
"""Clear icons grid."""
|
||||||
|
while self.icons_grid.count():
|
||||||
|
item = self.icons_grid.takeAt(0)
|
||||||
|
if item.widget():
|
||||||
|
item.widget().deleteLater()
|
||||||
|
|
||||||
|
def _update_icons_tab(self, result: InventoryScanResult):
|
||||||
|
"""Update icons tab with results."""
|
||||||
|
self.icons_count_label.setText(f"Extracted {len(result.items)} item icons")
|
||||||
|
|
||||||
|
self._clear_icons_grid()
|
||||||
|
|
||||||
|
for i, item in enumerate(result.items):
|
||||||
|
if item.icon_path and Path(item.icon_path).exists():
|
||||||
|
# Create icon widget
|
||||||
|
icon_widget = QFrame()
|
||||||
|
icon_widget.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
icon_layout = QVBoxLayout(icon_widget)
|
||||||
|
|
||||||
|
# Image
|
||||||
|
pixmap = QPixmap(item.icon_path)
|
||||||
|
if not pixmap.isNull():
|
||||||
|
scaled = pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)
|
||||||
|
img_label = QLabel()
|
||||||
|
img_label.setPixmap(scaled)
|
||||||
|
img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
icon_layout.addWidget(img_label)
|
||||||
|
|
||||||
|
# Info
|
||||||
|
info = QLabel(f"Slot: {item.slot_position}\nHash: {item.icon_hash[:8]}...")
|
||||||
|
info.setStyleSheet("font-size: 10px; color: #888;")
|
||||||
|
icon_layout.addWidget(info)
|
||||||
|
|
||||||
|
# Add to grid (5 columns)
|
||||||
|
row = i // 5
|
||||||
|
col = i % 5
|
||||||
|
self.icons_grid.addWidget(icon_widget, row, col)
|
||||||
|
|
||||||
|
def _update_details_tab(self, result: InventoryScanResult):
|
||||||
|
"""Update details tab with results."""
|
||||||
|
if not result.details_item:
|
||||||
|
self.details_table.setRowCount(1)
|
||||||
|
self.details_table.setItem(0, 0, QTableWidgetItem("Status"))
|
||||||
|
self.details_table.setItem(0, 1, QTableWidgetItem("No item details detected"))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = result.details_item
|
||||||
|
properties = [
|
||||||
|
("Item Name", stats.item_name),
|
||||||
|
("Class", stats.item_class),
|
||||||
|
("Damage", f"{stats.damage:.2f}" if stats.damage else "N/A"),
|
||||||
|
("Range", f"{stats.range:.2f}" if stats.range else "N/A"),
|
||||||
|
("Attacks/Min", str(stats.attacks_per_min) if stats.attacks_per_min else "N/A"),
|
||||||
|
("Decay (PEC)", f"{stats.decay:.4f}" if stats.decay else "N/A"),
|
||||||
|
("Ammo Burn (PEC)", f"{stats.ammo_burn:.4f}" if stats.ammo_burn else "N/A"),
|
||||||
|
("DPP", f"{stats.damage_per_pec:.4f}" if stats.damage_per_pec else "N/A"),
|
||||||
|
("Weight", f"{stats.weight:.2f}" if stats.weight else "N/A"),
|
||||||
|
("Level", str(stats.level) if stats.level else "N/A"),
|
||||||
|
("Durability", f"{stats.durability:.1f}%" if stats.durability else "N/A"),
|
||||||
|
("Protection Impact", f"{stats.protection_impact:.1f}" if stats.protection_impact else "N/A"),
|
||||||
|
("Protection Stab", f"{stats.protection_stab:.1f}" if stats.protection_stab else "N/A"),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.details_table.setRowCount(len(properties))
|
||||||
|
for row, (prop, value) in enumerate(properties):
|
||||||
|
self.details_table.setItem(row, 0, QTableWidgetItem(prop))
|
||||||
|
self.details_table.setItem(row, 1, QTableWidgetItem(value))
|
||||||
|
|
||||||
|
def _update_raw_tab(self, result: InventoryScanResult):
|
||||||
|
"""Update raw data tab."""
|
||||||
|
import json
|
||||||
|
text = json.dumps(result.to_dict(), indent=2)
|
||||||
|
self.raw_text.setText(text)
|
||||||
|
|
||||||
|
def on_save_results(self):
|
||||||
|
"""Save scan results to file."""
|
||||||
|
if not self.last_result:
|
||||||
|
return
|
||||||
|
|
||||||
|
filepath, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save Inventory Scan",
|
||||||
|
str(Path.home() / f"inventory_scan_{datetime.now():%Y%m%d_%H%M%S}.json"),
|
||||||
|
"JSON Files (*.json)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if filepath:
|
||||||
|
try:
|
||||||
|
self.last_result.save(filepath)
|
||||||
|
QMessageBox.information(self, "Saved", f"Results saved to:\n{filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to save:\n{e}")
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
"""Handle dialog close."""
|
||||||
|
if self.scan_worker and self.scan_worker.isRunning():
|
||||||
|
self.scan_worker.stop()
|
||||||
|
self.scan_worker.wait(1000)
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
def _apply_dark_theme(self):
|
||||||
|
"""Apply dark theme."""
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QDialog {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
QGroupBox {
|
||||||
|
font-weight: bold;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
QTableWidget {
|
||||||
|
background-color: #252525;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
QHeaderView::section {
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
padding: 6px;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid #444;
|
||||||
|
}
|
||||||
|
QTextEdit {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #d0d0d0;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
@ -120,6 +120,7 @@ from ui.hud_overlay_clean import HUDOverlay
|
||||||
from ui.session_history import SessionHistoryDialog
|
from ui.session_history import SessionHistoryDialog
|
||||||
from ui.gallery_dialog import GalleryDialog, ScreenshotCapture
|
from ui.gallery_dialog import GalleryDialog, ScreenshotCapture
|
||||||
from ui.settings_dialog import SettingsDialog
|
from ui.settings_dialog import SettingsDialog
|
||||||
|
from ui.inventory_scanner_dialog import InventoryScannerDialog
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Screenshot Hotkey Integration
|
# Screenshot Hotkey Integration
|
||||||
|
|
@ -752,6 +753,14 @@ class MainWindow(QMainWindow):
|
||||||
vision_test_action.triggered.connect(self.on_vision_test)
|
vision_test_action.triggered.connect(self.on_vision_test)
|
||||||
vision_menu.addAction(vision_test_action)
|
vision_menu.addAction(vision_test_action)
|
||||||
|
|
||||||
|
vision_menu.addSeparator()
|
||||||
|
|
||||||
|
# Inventory Scanner
|
||||||
|
inventory_scan_action = QAction("🔍 &Inventory Scanner", self)
|
||||||
|
inventory_scan_action.setShortcut("Ctrl+I")
|
||||||
|
inventory_scan_action.triggered.connect(self.on_inventory_scan)
|
||||||
|
vision_menu.addAction(inventory_scan_action)
|
||||||
|
|
||||||
# View menu
|
# View menu
|
||||||
view_menu = menubar.addMenu("&View")
|
view_menu = menubar.addMenu("&View")
|
||||||
|
|
||||||
|
|
@ -1895,6 +1904,15 @@ class MainWindow(QMainWindow):
|
||||||
self.log_error("Vision", f"Failed to open test dialog: {e}")
|
self.log_error("Vision", f"Failed to open test dialog: {e}")
|
||||||
QMessageBox.warning(self, "Error", f"Could not open Vision Test: {e}")
|
QMessageBox.warning(self, "Error", f"Could not open Vision Test: {e}")
|
||||||
|
|
||||||
|
def on_inventory_scan(self):
|
||||||
|
"""Open Inventory Scanner dialog."""
|
||||||
|
try:
|
||||||
|
dialog = InventoryScannerDialog(self)
|
||||||
|
dialog.exec()
|
||||||
|
except Exception as e:
|
||||||
|
self.log_error("Vision", f"Failed to open inventory scanner: {e}")
|
||||||
|
QMessageBox.warning(self, "Error", f"Could not open Inventory Scanner: {e}")
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Settings Management
|
# Settings Management
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Lemontropia Suite - Game Vision AI Example
|
Lemontropia Suite - Game Vision AI Example
|
||||||
Demonstrates usage of the Game Vision AI module.
|
Demonstrates usage of the Game Vision AI module.
|
||||||
|
|
||||||
|
Updated for multi-backend OCR system with Windows Store Python support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -12,32 +14,57 @@ logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def demo_gpu_detection():
|
def demo_hardware_detection():
|
||||||
"""Demonstrate GPU detection."""
|
"""Demonstrate hardware detection including PyTorch DLL error handling."""
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
print("GPU DETECTION DEMO")
|
print("HARDWARE DETECTION DEMO")
|
||||||
print("="*60)
|
print("="*60)
|
||||||
|
|
||||||
from modules.game_vision_ai import GPUDetector, GPUBackend
|
from modules.hardware_detection import (
|
||||||
|
HardwareDetector,
|
||||||
|
print_hardware_summary,
|
||||||
|
recommend_ocr_backend
|
||||||
|
)
|
||||||
|
|
||||||
# Detect GPU
|
# Print full summary
|
||||||
backend = GPUDetector.detect_backend()
|
print_hardware_summary()
|
||||||
print(f"\nDetected GPU Backend: {backend.value}")
|
|
||||||
|
|
||||||
# Get detailed info
|
# Get recommendation
|
||||||
info = GPUDetector.get_gpu_info()
|
recommended = recommend_ocr_backend()
|
||||||
print(f"\nGPU Details:")
|
print(f"\n📋 Recommended OCR backend: {recommended}")
|
||||||
print(f" Backend: {info['backend']}")
|
|
||||||
print(f" CUDA Available: {info['cuda_available']}")
|
|
||||||
print(f" MPS Available: {info['mps_available']}")
|
|
||||||
|
|
||||||
if info.get('devices'):
|
# Check for Windows Store Python
|
||||||
print(f"\n Devices:")
|
info = HardwareDetector.detect_all()
|
||||||
for dev in info['devices']:
|
if info.is_windows_store_python:
|
||||||
mem_gb = dev.get('memory_total', 0) / (1024**3)
|
print("\n⚠️ Windows Store Python detected!")
|
||||||
print(f" [{dev['id']}] {dev['name']} ({mem_gb:.1f} GB)")
|
if info.pytorch_dll_error:
|
||||||
|
print(" PyTorch DLL errors expected - using fallback OCR backends")
|
||||||
|
|
||||||
print(f"\n PyTorch Device String: {GPUDetector.get_device_string(backend)}")
|
|
||||||
|
def demo_ocr_backends():
|
||||||
|
"""Demonstrate OCR backend selection."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("OCR BACKEND DEMO")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
from modules.ocr_backends import OCRBackendFactory
|
||||||
|
|
||||||
|
print("\nChecking all OCR backends...")
|
||||||
|
backends = OCRBackendFactory.check_all_backends(use_gpu=True)
|
||||||
|
|
||||||
|
for info in backends:
|
||||||
|
status = "✅ Available" if info.available else "❌ Not Available"
|
||||||
|
gpu_status = "🚀 GPU" if info.gpu_accelerated else "💻 CPU"
|
||||||
|
|
||||||
|
print(f"\n{info.name.upper()}:")
|
||||||
|
print(f" Status: {status}")
|
||||||
|
print(f" GPU: {gpu_status}")
|
||||||
|
|
||||||
|
if info.error_message:
|
||||||
|
print(f" Note: {info.error_message}")
|
||||||
|
|
||||||
|
available = [b.name for b in backends if b.available]
|
||||||
|
print(f"\n📊 Available backends: {', '.join(available) if available else 'None'}")
|
||||||
|
|
||||||
|
|
||||||
def demo_ocr(image_path: str = None):
|
def demo_ocr(image_path: str = None):
|
||||||
|
|
@ -46,11 +73,15 @@ def demo_ocr(image_path: str = None):
|
||||||
print("OCR TEXT EXTRACTION DEMO")
|
print("OCR TEXT EXTRACTION DEMO")
|
||||||
print("="*60)
|
print("="*60)
|
||||||
|
|
||||||
from modules.game_vision_ai import OCRProcessor
|
from modules.game_vision_ai import UnifiedOCRProcessor
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
# Initialize OCR
|
# Initialize OCR with auto-selection
|
||||||
print("\nInitializing OCR (this may take a moment on first run)...")
|
print("\nInitializing OCR (auto-selecting best backend)...")
|
||||||
ocr = OCRProcessor(use_gpu=True, lang='en')
|
ocr = UnifiedOCRProcessor(use_gpu=True, lang='en', auto_select=True)
|
||||||
|
|
||||||
|
print(f"Selected backend: {ocr.get_current_backend()}")
|
||||||
|
|
||||||
if image_path and Path(image_path).exists():
|
if image_path and Path(image_path).exists():
|
||||||
print(f"\nProcessing: {image_path}")
|
print(f"\nProcessing: {image_path}")
|
||||||
|
|
@ -58,11 +89,21 @@ def demo_ocr(image_path: str = None):
|
||||||
|
|
||||||
print(f"\nDetected {len(regions)} text regions:")
|
print(f"\nDetected {len(regions)} text regions:")
|
||||||
for i, region in enumerate(regions, 1):
|
for i, region in enumerate(regions, 1):
|
||||||
print(f" {i}. '{region.text}' (confidence: {region.confidence:.2%})")
|
text_preview = region.text[:50] + "..." if len(region.text) > 50 else region.text
|
||||||
|
print(f" {i}. '{text_preview}' (confidence: {region.confidence:.2%})")
|
||||||
|
print(f" Backend: {region.backend}")
|
||||||
print(f" Position: ({region.bbox[0]}, {region.bbox[1]}) {region.bbox[2]}x{region.bbox[3]}")
|
print(f" Position: ({region.bbox[0]}, {region.bbox[1]}) {region.bbox[2]}x{region.bbox[3]}")
|
||||||
else:
|
else:
|
||||||
print(f"\nNo image provided or file not found: {image_path}")
|
# Create demo image
|
||||||
print("Usage: python vision_example.py --ocr path/to/screenshot.png")
|
print("\nNo image provided, creating demo image...")
|
||||||
|
demo_img = np.ones((400, 600, 3), dtype=np.uint8) * 255
|
||||||
|
cv2.rectangle(demo_img, (50, 50), (200, 80), (0, 0, 0), -1)
|
||||||
|
cv2.rectangle(demo_img, (50, 100), (250, 130), (0, 0, 0), -1)
|
||||||
|
|
||||||
|
regions = ocr.extract_text(demo_img)
|
||||||
|
print(f"\nDetected {len(regions)} text regions in demo image")
|
||||||
|
|
||||||
|
print(f"\nUsage: python vision_example.py --ocr path/to/screenshot.png")
|
||||||
|
|
||||||
|
|
||||||
def demo_icon_detection(image_path: str = None):
|
def demo_icon_detection(image_path: str = None):
|
||||||
|
|
@ -73,6 +114,7 @@ def demo_icon_detection(image_path: str = None):
|
||||||
|
|
||||||
from modules.game_vision_ai import IconDetector
|
from modules.game_vision_ai import IconDetector
|
||||||
import cv2
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
detector = IconDetector()
|
detector = IconDetector()
|
||||||
|
|
||||||
|
|
@ -98,7 +140,23 @@ def demo_icon_detection(image_path: str = None):
|
||||||
icons = detector.extract_icons_from_region(image, (0, 0, w, h))
|
icons = detector.extract_icons_from_region(image, (0, 0, w, h))
|
||||||
print(f"Found {len(icons)} potential icons in full image")
|
print(f"Found {len(icons)} potential icons in full image")
|
||||||
else:
|
else:
|
||||||
print(f"\nNo image provided or file not found: {image_path}")
|
print("\nNo image provided, creating demo image...")
|
||||||
|
# Create demo image with icon-like regions
|
||||||
|
demo_img = np.ones((600, 800, 3), dtype=np.uint8) * 200
|
||||||
|
|
||||||
|
# Draw some icon-sized squares
|
||||||
|
for i in range(3):
|
||||||
|
for j in range(4):
|
||||||
|
x = 100 + j * 60
|
||||||
|
y = 100 + i * 60
|
||||||
|
color = (100 + i*50, 150, 200 - j*30)
|
||||||
|
cv2.rectangle(demo_img, (x, y), (x+48, y+48), color, -1)
|
||||||
|
|
||||||
|
h, w = demo_img.shape[:2]
|
||||||
|
icons = detector.extract_icons_from_region(demo_img, (0, 0, w, h))
|
||||||
|
print(f"Found {len(icons)} potential icons in demo image")
|
||||||
|
|
||||||
|
print(f"\nUsage: python vision_example.py --icons path/to/screenshot.png")
|
||||||
|
|
||||||
|
|
||||||
def demo_full_vision(image_path: str = None):
|
def demo_full_vision(image_path: str = None):
|
||||||
|
|
@ -108,13 +166,17 @@ def demo_full_vision(image_path: str = None):
|
||||||
print("="*60)
|
print("="*60)
|
||||||
|
|
||||||
from modules.game_vision_ai import GameVisionAI
|
from modules.game_vision_ai import GameVisionAI
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
# Initialize vision AI
|
# Initialize vision AI
|
||||||
print("\nInitializing Game Vision AI...")
|
print("\nInitializing Game Vision AI...")
|
||||||
vision = GameVisionAI(use_gpu=True, ocr_lang='en')
|
vision = GameVisionAI(use_gpu=True, ocr_lang='en')
|
||||||
|
|
||||||
print(f"GPU Available: {vision.is_gpu_available()}")
|
print(f"✅ Initialized!")
|
||||||
print(f"Backend: {vision.backend.value}")
|
print(f" OCR Backend: {vision.ocr.get_current_backend()}")
|
||||||
|
print(f" GPU: {vision.backend.value}")
|
||||||
|
print(f" GPU Available: {vision.is_gpu_available()}")
|
||||||
|
|
||||||
if image_path and Path(image_path).exists():
|
if image_path and Path(image_path).exists():
|
||||||
print(f"\nProcessing: {image_path}")
|
print(f"\nProcessing: {image_path}")
|
||||||
|
|
@ -124,11 +186,13 @@ def demo_full_vision(image_path: str = None):
|
||||||
|
|
||||||
print(f"\n--- Results ---")
|
print(f"\n--- Results ---")
|
||||||
print(f"Processing Time: {result.processing_time_ms:.1f}ms")
|
print(f"Processing Time: {result.processing_time_ms:.1f}ms")
|
||||||
|
print(f"OCR Backend: {result.ocr_backend}")
|
||||||
print(f"GPU Backend: {result.gpu_backend}")
|
print(f"GPU Backend: {result.gpu_backend}")
|
||||||
|
|
||||||
print(f"\nText Regions ({len(result.text_regions)}):")
|
print(f"\nText Regions ({len(result.text_regions)}):")
|
||||||
for region in result.text_regions:
|
for region in result.text_regions:
|
||||||
print(f" • '{region.text}' ({region.confidence:.2%})")
|
text_preview = region.text[:40] + "..." if len(region.text) > 40 else region.text
|
||||||
|
print(f" • '{text_preview}' ({region.confidence:.2%}) [{region.backend}]")
|
||||||
|
|
||||||
print(f"\nIcon Regions ({len(result.icon_regions)}):")
|
print(f"\nIcon Regions ({len(result.icon_regions)}):")
|
||||||
for region in result.icon_regions:
|
for region in result.icon_regions:
|
||||||
|
|
@ -136,8 +200,71 @@ def demo_full_vision(image_path: str = None):
|
||||||
|
|
||||||
print(f"\nExtracted icons saved to: {vision.extracted_icons_dir}")
|
print(f"\nExtracted icons saved to: {vision.extracted_icons_dir}")
|
||||||
else:
|
else:
|
||||||
print(f"\nNo image provided or file not found: {image_path}")
|
print("\nNo image provided, creating demo image...")
|
||||||
print("Usage: python vision_example.py --full path/to/screenshot.png")
|
# Create demo image
|
||||||
|
demo_img = np.ones((600, 800, 3), dtype=np.uint8) * 240
|
||||||
|
|
||||||
|
# Draw loot window
|
||||||
|
cv2.rectangle(demo_img, (400, 50), (750, 400), (220, 220, 220), -1)
|
||||||
|
|
||||||
|
# Draw some icon slots
|
||||||
|
for row in range(4):
|
||||||
|
for col in range(5):
|
||||||
|
x = 420 + col * 55
|
||||||
|
y = 80 + row * 55
|
||||||
|
cv2.rectangle(demo_img, (x, y), (x+48, y+48), (180, 180, 180), -1)
|
||||||
|
if (row + col) % 2 == 0:
|
||||||
|
color = (100, 150, 200) if row % 2 == 0 else (200, 150, 100)
|
||||||
|
cv2.rectangle(demo_img, (x+5, y+5), (x+43, y+43), color, -1)
|
||||||
|
|
||||||
|
# Save and process
|
||||||
|
demo_path = Path.home() / ".lemontropia" / "demo_screenshot.png"
|
||||||
|
demo_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cv2.imwrite(str(demo_path), demo_img)
|
||||||
|
|
||||||
|
result = vision.process_screenshot(demo_path)
|
||||||
|
|
||||||
|
print(f"\n--- Demo Results ---")
|
||||||
|
print(f"Processing Time: {result.processing_time_ms:.1f}ms")
|
||||||
|
print(f"Text Regions: {len(result.text_regions)}")
|
||||||
|
print(f"Icon Regions: {len(result.icon_regions)}")
|
||||||
|
|
||||||
|
print(f"\nUsage: python vision_example.py --full path/to/screenshot.png")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_backend_switching():
|
||||||
|
"""Demonstrate switching between OCR backends."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("OCR BACKEND SWITCHING DEMO")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
from modules.game_vision_ai import UnifiedOCRProcessor
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Create demo image
|
||||||
|
demo_img = np.ones((400, 600, 3), dtype=np.uint8) * 255
|
||||||
|
cv2.rectangle(demo_img, (50, 50), (200, 80), (0, 0, 0), -1)
|
||||||
|
cv2.rectangle(demo_img, (50, 100), (250, 130), (0, 0, 0), -1)
|
||||||
|
|
||||||
|
# Initialize with auto-selection
|
||||||
|
print("\nInitializing with auto-selected backend...")
|
||||||
|
ocr = UnifiedOCRProcessor()
|
||||||
|
print(f"Default backend: {ocr.get_current_backend()}")
|
||||||
|
|
||||||
|
# Try switching to each backend
|
||||||
|
backends = ['opencv_east', 'tesseract', 'easyocr', 'paddleocr']
|
||||||
|
|
||||||
|
for backend_name in backends:
|
||||||
|
print(f"\nTrying '{backend_name}'...", end=" ")
|
||||||
|
success = ocr.set_backend(backend_name)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"✅ Success!")
|
||||||
|
regions = ocr.extract_text(demo_img)
|
||||||
|
print(f" Detected {len(regions)} text regions")
|
||||||
|
else:
|
||||||
|
print(f"❌ Not available (see error above)")
|
||||||
|
|
||||||
|
|
||||||
def demo_icon_matching():
|
def demo_icon_matching():
|
||||||
|
|
@ -180,23 +307,41 @@ def demo_icon_matching():
|
||||||
print(f" Similarity between two images: {similarity:.2%}")
|
print(f" Similarity between two images: {similarity:.2%}")
|
||||||
|
|
||||||
|
|
||||||
def demo_calibration():
|
def demo_diagnostics():
|
||||||
"""Demonstrate calibration."""
|
"""Demonstrate system diagnostics."""
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
print("CALIBRATION DEMO")
|
print("SYSTEM DIAGNOSTICS DEMO")
|
||||||
print("="*60)
|
print("="*60)
|
||||||
|
|
||||||
from modules.game_vision_ai import GameVisionAI
|
from modules.game_vision_ai import GameVisionAI
|
||||||
|
|
||||||
vision = GameVisionAI(use_gpu=True)
|
print("\nRunning full system diagnostics...")
|
||||||
|
diag = GameVisionAI.diagnose()
|
||||||
|
|
||||||
print("\nTo calibrate, provide sample screenshots:")
|
print("\n--- Hardware ---")
|
||||||
print(" vision.calibrate_for_game([path1, path2, ...])")
|
hw = diag['hardware']
|
||||||
print("\nThis will:")
|
print(f" Platform: {hw['system']['platform']}")
|
||||||
print(" 1. Process each screenshot")
|
print(f" Windows Store Python: {hw['system']['windows_store']}")
|
||||||
print(" 2. Measure detection accuracy")
|
print(f" GPU Backend: {hw['gpu']['backend']}")
|
||||||
print(" 3. Calculate average processing time")
|
print(f" CUDA Available: {hw['gpu']['cuda_available']}")
|
||||||
print(" 4. Provide recommendations")
|
print(f" OpenCV CUDA: {hw['gpu']['opencv_cuda']}")
|
||||||
|
|
||||||
|
print("\n--- ML Frameworks ---")
|
||||||
|
ml = hw['ml_frameworks']
|
||||||
|
print(f" PyTorch: {'Available' if ml['pytorch']['available'] else 'Not Available'}")
|
||||||
|
if ml['pytorch']['available']:
|
||||||
|
print(f" Version: {ml['pytorch']['version']}")
|
||||||
|
if ml['pytorch']['dll_error']:
|
||||||
|
print(f" ⚠️ DLL Error detected!")
|
||||||
|
|
||||||
|
print("\n--- OCR Backends ---")
|
||||||
|
for backend in diag['ocr_backends']:
|
||||||
|
status = "✅" if backend['available'] else "❌"
|
||||||
|
print(f" {status} {backend['name']}")
|
||||||
|
|
||||||
|
print("\n--- Recommendations ---")
|
||||||
|
print(f" OCR Backend: {diag['recommendations']['ocr_backend']}")
|
||||||
|
print(f" GPU: {diag['recommendations']['gpu']}")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -208,53 +353,68 @@ def main():
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
Examples:
|
||||||
python vision_example.py --gpu # GPU detection demo
|
python vision_example.py --hardware # Hardware detection
|
||||||
|
python vision_example.py --backends # List OCR backends
|
||||||
python vision_example.py --ocr image.png # OCR demo
|
python vision_example.py --ocr image.png # OCR demo
|
||||||
python vision_example.py --icons image.png # Icon detection demo
|
python vision_example.py --icons image.png # Icon detection demo
|
||||||
python vision_example.py --full image.png # Full vision demo
|
python vision_example.py --full image.png # Full vision demo
|
||||||
|
python vision_example.py --switch # Backend switching demo
|
||||||
python vision_example.py --matching # Icon matching demo
|
python vision_example.py --matching # Icon matching demo
|
||||||
|
python vision_example.py --diagnostics # System diagnostics
|
||||||
python vision_example.py --all # Run all demos
|
python vision_example.py --all # Run all demos
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument('--gpu', action='store_true', help='GPU detection demo')
|
parser.add_argument('--hardware', action='store_true', help='Hardware detection demo')
|
||||||
parser.add_argument('--ocr', type=str, metavar='IMAGE', help='OCR demo with image')
|
parser.add_argument('--backends', action='store_true', help='List OCR backends')
|
||||||
parser.add_argument('--icons', type=str, metavar='IMAGE', help='Icon detection demo')
|
parser.add_argument('--ocr', type=str, metavar='IMAGE', nargs='?', const='', help='OCR demo')
|
||||||
parser.add_argument('--full', type=str, metavar='IMAGE', help='Full vision demo')
|
parser.add_argument('--icons', type=str, metavar='IMAGE', nargs='?', const='', help='Icon detection demo')
|
||||||
|
parser.add_argument('--full', type=str, metavar='IMAGE', nargs='?', const='', help='Full vision demo')
|
||||||
|
parser.add_argument('--switch', action='store_true', help='Backend switching demo')
|
||||||
parser.add_argument('--matching', action='store_true', help='Icon matching demo')
|
parser.add_argument('--matching', action='store_true', help='Icon matching demo')
|
||||||
parser.add_argument('--calibration', action='store_true', help='Calibration demo')
|
parser.add_argument('--diagnostics', action='store_true', help='System diagnostics')
|
||||||
parser.add_argument('--all', action='store_true', help='Run all demos')
|
parser.add_argument('--all', action='store_true', help='Run all demos')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# If no args, show help
|
# If no args, show help
|
||||||
if not any([args.gpu, args.ocr, args.icons, args.full, args.matching, args.calibration, args.all]):
|
if not any([args.hardware, args.backends, args.ocr is not None,
|
||||||
|
args.icons is not None, args.full is not None,
|
||||||
|
args.switch, args.matching, args.diagnostics, args.all]):
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.all or args.gpu:
|
if args.all or args.hardware:
|
||||||
demo_gpu_detection()
|
demo_hardware_detection()
|
||||||
|
|
||||||
if args.all or args.ocr:
|
if args.all or args.backends:
|
||||||
demo_ocr(args.ocr)
|
demo_ocr_backends()
|
||||||
|
|
||||||
if args.all or args.icons:
|
if args.all or args.ocr is not None:
|
||||||
demo_icon_detection(args.icons)
|
demo_ocr(args.ocr if args.ocr else None)
|
||||||
|
|
||||||
if args.all or args.full:
|
if args.all or args.icons is not None:
|
||||||
demo_full_vision(args.full)
|
demo_icon_detection(args.icons if args.icons else None)
|
||||||
|
|
||||||
|
if args.all or args.full is not None:
|
||||||
|
demo_full_vision(args.full if args.full else None)
|
||||||
|
|
||||||
|
if args.all or args.switch:
|
||||||
|
demo_backend_switching()
|
||||||
|
|
||||||
if args.all or args.matching:
|
if args.all or args.matching:
|
||||||
demo_icon_matching()
|
demo_icon_matching()
|
||||||
|
|
||||||
if args.all or args.calibration:
|
if args.all or args.diagnostics:
|
||||||
demo_calibration()
|
demo_diagnostics()
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"\n❌ Import Error: {e}")
|
print(f"\n❌ Import Error: {e}")
|
||||||
print("\nMake sure all dependencies are installed:")
|
print("\nMake sure all dependencies are installed:")
|
||||||
print(" pip install -r requirements.txt")
|
print(" pip install opencv-python numpy pillow")
|
||||||
|
print("\nFor full OCR support:")
|
||||||
|
print(" pip install easyocr pytesseract paddleocr")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ Error: {e}")
|
print(f"\n❌ Error: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue