feat: Constrain skill scanner to only Entropia game window
BUG: OCR was reading text from Discord, EU-Utility UI, and other windows. FIX: 1. Added find_entropia_window() - Uses win32gui + psutil on Windows to find the game window by process name 'Entropia.exe' and window title containing 'Entropia Universe'. Returns (left, top, width, height). 2. Added capture_entropia_region() - Captures only the game window region, falls back to full screen if window not found. 3. Added is_valid_skill_text() - Filters out non-game text patterns: - Discord, Event Bus, Game Reader, Test, Page Scanner, HOTKEY MODE - UI elements like 'Skill Tracker', 'Calculator', 'Nexus Search' - Debug text like '[SkillScanner]', 'Parsed:', 'Cleared' - Process names like 'Entropia.exe', 'Client (64 bit)', 'Arkadia' - Lines with >10 words (skills aren't that long) 4. Added recognize_image() method to OCRService for convenience. 5. Modified SkillOCRThread.run() to: - Capture only Entropia window - Filter text before parsing - Use _parse_skills_filtered() which validates each line 6. Added _parse_skills_filtered() method that: - Splits text by lines - Only keeps lines containing a valid rank - Validates each line with is_valid_skill_text() - Logs filtered lines for debugging RESULT: - Scanner now ONLY reads from the game window - Invalid text (Discord, UI, debug) is filtered out - Much cleaner skill parsing results Note: Window title varies by location (e.g., '[Arkadia]', '[Calypso]') but process name is always 'Entropia.exe'.
This commit is contained in:
parent
05f8c06312
commit
0155eb0be0
|
|
@ -201,6 +201,19 @@ class OCRService:
|
||||||
'results': []
|
'results': []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def recognize_image(self, image) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Perform OCR on a PIL Image.
|
||||||
|
Convenience alias for recognize(image=image).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: PIL Image to OCR
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'text', 'confidence', 'results', 'image_size'
|
||||||
|
"""
|
||||||
|
return self.recognize(image=image)
|
||||||
|
|
||||||
def _ocr_easyocr(self, image) -> Dict[str, Any]:
|
def _ocr_easyocr(self, image) -> Dict[str, Any]:
|
||||||
"""OCR using EasyOCR."""
|
"""OCR using EasyOCR."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,153 @@ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
|
||||||
from plugins.base_plugin import BasePlugin
|
from plugins.base_plugin import BasePlugin
|
||||||
|
|
||||||
|
|
||||||
|
def find_entropia_window():
|
||||||
|
"""
|
||||||
|
Find the Entropia Universe game window.
|
||||||
|
Returns (left, top, width, height) or None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import platform
|
||||||
|
|
||||||
|
if platform.system() == 'Windows':
|
||||||
|
import win32gui
|
||||||
|
import win32process
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
def callback(hwnd, windows):
|
||||||
|
if not win32gui.IsWindowVisible(hwnd):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Get window title
|
||||||
|
title = win32gui.GetWindowText(hwnd)
|
||||||
|
|
||||||
|
# Check if it's an Entropia window (title contains "Entropia Universe")
|
||||||
|
if 'Entropia Universe' in title:
|
||||||
|
try:
|
||||||
|
# Get process ID
|
||||||
|
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||||
|
process = psutil.Process(pid)
|
||||||
|
|
||||||
|
# Verify process name contains "Entropia"
|
||||||
|
if 'entropia' in process.name().lower():
|
||||||
|
rect = win32gui.GetWindowRect(hwnd)
|
||||||
|
left, top, right, bottom = rect
|
||||||
|
windows.append((left, top, right - left, bottom - top, title))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
win32gui.EnumWindows(callback, windows)
|
||||||
|
|
||||||
|
if windows:
|
||||||
|
# Return the largest window (most likely the main game)
|
||||||
|
windows.sort(key=lambda w: w[2] * w[3], reverse=True)
|
||||||
|
left, top, width, height, title = windows[0]
|
||||||
|
print(f"[SkillScanner] Found Entropia window: '{title}' at ({left}, {top}, {width}, {height})")
|
||||||
|
return (left, top, width, height)
|
||||||
|
|
||||||
|
elif platform.system() == 'Linux':
|
||||||
|
# Try using xdotool or wmctrl
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
['xdotool', 'search', '--name', 'Entropia Universe'],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
window_id = result.stdout.strip().split('\n')[0]
|
||||||
|
# Get window geometry
|
||||||
|
geo_result = subprocess.run(
|
||||||
|
['xdotool', 'getwindowgeometry', window_id],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if geo_result.returncode == 0:
|
||||||
|
# Parse: "Position: 100,200 (screen: 0)" and "Geometry: 1920x1080"
|
||||||
|
pos_match = re.search(r'Position: (\d+),(\d+)', geo_result.stdout)
|
||||||
|
geo_match = re.search(r'Geometry: (\d+)x(\d+)', geo_result.stdout)
|
||||||
|
if pos_match and geo_match:
|
||||||
|
left = int(pos_match.group(1))
|
||||||
|
top = int(pos_match.group(2))
|
||||||
|
width = int(geo_match.group(1))
|
||||||
|
height = int(geo_match.group(2))
|
||||||
|
print(f"[SkillScanner] Found Entropia window at ({left}, {top}, {width}, {height})")
|
||||||
|
return (left, top, width, height)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SkillScanner] Linux window detection failed: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SkillScanner] Window detection error: {e}")
|
||||||
|
|
||||||
|
print("[SkillScanner] Could not find Entropia window - will use full screen")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def capture_entropia_region(region=None):
|
||||||
|
"""
|
||||||
|
Capture screen region of Entropia window.
|
||||||
|
If region is None, tries to find the window automatically.
|
||||||
|
Returns PIL Image or None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import ImageGrab
|
||||||
|
|
||||||
|
if region is None:
|
||||||
|
region = find_entropia_window()
|
||||||
|
|
||||||
|
if region:
|
||||||
|
left, top, width, height = region
|
||||||
|
# Add some padding to ensure we capture everything
|
||||||
|
# Don't go below 0
|
||||||
|
left = max(0, left)
|
||||||
|
top = max(0, top)
|
||||||
|
screenshot = ImageGrab.grab(bbox=(left, top, left + width, top + height))
|
||||||
|
print(f"[SkillScanner] Captured Entropia window region: {width}x{height}")
|
||||||
|
return screenshot
|
||||||
|
else:
|
||||||
|
# Fallback to full screen
|
||||||
|
screenshot = ImageGrab.grab()
|
||||||
|
print("[SkillScanner] Captured full screen (window not found)")
|
||||||
|
return screenshot
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SkillScanner] Capture error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_skill_text(text):
|
||||||
|
"""
|
||||||
|
Filter out non-game text from OCR results.
|
||||||
|
Returns True if text looks like it could be from the game.
|
||||||
|
"""
|
||||||
|
# List of patterns that indicate NON-game text (UI, Discord, etc.)
|
||||||
|
invalid_patterns = [
|
||||||
|
'Discord', 'Presence', 'Event Bus', 'Example', 'Game Reader',
|
||||||
|
'Test', 'Page Scanner', 'HOTKEY MODE', 'Skill Tracker',
|
||||||
|
'Navigate', 'window', 'UI', 'Plugin', 'Settings',
|
||||||
|
'Click', 'Button', 'Menu', 'Panel', 'Tab', 'Loading...',
|
||||||
|
'Calculator', 'Nexus Search', 'Dashboard', 'Analytics',
|
||||||
|
'Multi-Page', 'Scanner', 'Auto-detect', 'F12',
|
||||||
|
'Cleared', 'Parsed:', '[SkillScanner]', 'INFO', 'DEBUG',
|
||||||
|
'Loading', 'Initializing', 'Connecting', 'Error:', 'Warning:',
|
||||||
|
'Entropia.exe', 'Client (64 bit)', 'Arkadia', 'Calypso',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check for invalid patterns
|
||||||
|
text_upper = text.upper()
|
||||||
|
for pattern in invalid_patterns:
|
||||||
|
if pattern.upper() in text_upper:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for reasonable skill name length (not too long, not too short)
|
||||||
|
words = text.split()
|
||||||
|
if len(words) > 10: # Skills don't have 10+ words
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class SkillOCRThread(QThread):
|
class SkillOCRThread(QThread):
|
||||||
"""OCR scan using core service."""
|
"""OCR scan using core service."""
|
||||||
scan_complete = pyqtSignal(dict)
|
scan_complete = pyqtSignal(dict)
|
||||||
|
|
@ -30,21 +177,37 @@ class SkillOCRThread(QThread):
|
||||||
self.ocr_service = ocr_service
|
self.ocr_service = ocr_service
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Perform OCR using core service."""
|
"""Perform OCR using core service - only on Entropia window."""
|
||||||
try:
|
try:
|
||||||
self.progress_update.emit("Capturing screen...")
|
self.progress_update.emit("Finding Entropia window...")
|
||||||
|
|
||||||
# Use core OCR service
|
# Capture only the Entropia game window
|
||||||
result = self.ocr_service.recognize()
|
screenshot = capture_entropia_region()
|
||||||
|
|
||||||
|
if screenshot is None:
|
||||||
|
self.scan_error.emit("Could not capture game window")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.progress_update.emit("Running OCR on game window...")
|
||||||
|
|
||||||
|
# Use core OCR service with the captured image
|
||||||
|
result = self.ocr_service.recognize_image(screenshot)
|
||||||
|
|
||||||
if 'error' in result and result['error']:
|
if 'error' in result and result['error']:
|
||||||
self.scan_error.emit(result['error'])
|
self.scan_error.emit(result['error'])
|
||||||
return
|
return
|
||||||
|
|
||||||
self.progress_update.emit("Parsing skills...")
|
self.progress_update.emit("Filtering and parsing skills...")
|
||||||
|
|
||||||
|
# Filter out non-game text and parse skills
|
||||||
|
raw_text = result.get('text', '')
|
||||||
|
skills_data = self._parse_skills_filtered(raw_text)
|
||||||
|
|
||||||
|
if not skills_data:
|
||||||
|
self.progress_update.emit("No valid skills found. Make sure Skills window is open.")
|
||||||
|
else:
|
||||||
|
self.progress_update.emit(f"Found {len(skills_data)} skills")
|
||||||
|
|
||||||
# Parse skills from text
|
|
||||||
skills_data = self._parse_skills(result.get('text', ''))
|
|
||||||
self.scan_complete.emit(skills_data)
|
self.scan_complete.emit(skills_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -114,6 +277,50 @@ class SkillOCRThread(QThread):
|
||||||
|
|
||||||
return skills
|
return skills
|
||||||
|
|
||||||
|
def _parse_skills_filtered(self, text):
|
||||||
|
"""
|
||||||
|
Parse skills with filtering to remove non-game text.
|
||||||
|
Only returns skills that pass validity checks.
|
||||||
|
"""
|
||||||
|
# First, split text into lines and filter each line
|
||||||
|
lines = text.split('\n')
|
||||||
|
valid_lines = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if line contains a rank (required for skill lines)
|
||||||
|
has_rank = any(rank in line for rank in [
|
||||||
|
'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average',
|
||||||
|
'Skilled', 'Expert', 'Professional', 'Master',
|
||||||
|
'Arch Master', 'Grand Master', # Multi-word first
|
||||||
|
'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable',
|
||||||
|
'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome'
|
||||||
|
])
|
||||||
|
|
||||||
|
if not has_rank:
|
||||||
|
continue # Skip lines without ranks
|
||||||
|
|
||||||
|
# Check for invalid patterns
|
||||||
|
if not is_valid_skill_text(line):
|
||||||
|
print(f"[SkillScanner] Filtered out: '{line[:50]}...'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
valid_lines.append(line)
|
||||||
|
|
||||||
|
# Join valid lines and parse
|
||||||
|
filtered_text = '\n'.join(valid_lines)
|
||||||
|
|
||||||
|
if not filtered_text.strip():
|
||||||
|
print("[SkillScanner] No valid game text found after filtering")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
print(f"[SkillScanner] Filtered {len(lines)} lines to {len(valid_lines)} valid lines")
|
||||||
|
|
||||||
|
return self._parse_skills(filtered_text)
|
||||||
|
|
||||||
def _parse_skills_alternative(self, text, ranks):
|
def _parse_skills_alternative(self, text, ranks):
|
||||||
"""Alternative parser for when text is heavily merged."""
|
"""Alternative parser for when text is heavily merged."""
|
||||||
skills = {}
|
skills = {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue