From 0155eb0be0a13f239d529ec1a1fca43fa827568c Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Sun, 15 Feb 2026 00:55:37 +0000 Subject: [PATCH] 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'. --- core/ocr_service.py | 13 ++ plugins/skill_scanner/plugin.py | 221 +++++++++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 7 deletions(-) diff --git a/core/ocr_service.py b/core/ocr_service.py index 4a4f960..6053d2a 100644 --- a/core/ocr_service.py +++ b/core/ocr_service.py @@ -201,6 +201,19 @@ class OCRService: '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]: """OCR using EasyOCR.""" import numpy as np diff --git a/plugins/skill_scanner/plugin.py b/plugins/skill_scanner/plugin.py index 05c1ed4..e483a20 100644 --- a/plugins/skill_scanner/plugin.py +++ b/plugins/skill_scanner/plugin.py @@ -19,6 +19,153 @@ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer 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): """OCR scan using core service.""" scan_complete = pyqtSignal(dict) @@ -30,21 +177,37 @@ class SkillOCRThread(QThread): self.ocr_service = ocr_service def run(self): - """Perform OCR using core service.""" + """Perform OCR using core service - only on Entropia window.""" try: - self.progress_update.emit("Capturing screen...") + self.progress_update.emit("Finding Entropia window...") - # Use core OCR service - result = self.ocr_service.recognize() + # Capture only the Entropia game window + 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']: self.scan_error.emit(result['error']) 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) except Exception as e: @@ -114,6 +277,50 @@ class SkillOCRThread(QThread): 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): """Alternative parser for when text is heavily merged.""" skills = {}