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:
LemonNexus 2026-02-15 00:55:37 +00:00
parent 05f8c06312
commit 0155eb0be0
2 changed files with 227 additions and 7 deletions

View File

@ -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

View File

@ -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 = {}