Fixed On-Screen OCR region Overlay

This commit is contained in:
Nicole Rappe 2025-02-16 21:07:26 -07:00
parent e30ba4ec4f
commit 6888b55612
5 changed files with 108 additions and 131 deletions

View File

@ -3,12 +3,13 @@
import threading import threading
import time import time
import re import re
import sys
import numpy as np import numpy as np
import cv2 import cv2
import pytesseract import pytesseract
from PIL import Image, ImageGrab, ImageFilter from PIL import Image, ImageGrab, ImageFilter
from PyQt5.QtWidgets import QWidget, QApplication from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtCore import QRect, QPoint, Qt, QMutex, QTimer from PyQt5.QtCore import QRect, QPoint, Qt, QMutex, QTimer
from PyQt5.QtGui import QPainter, QPen, QColor, QFont from PyQt5.QtGui import QPainter, QPen, QColor, QFont
@ -21,19 +22,37 @@ LABEL_HEIGHT = 20
collector_mutex = QMutex() collector_mutex = QMutex()
regions = {} regions = {}
overlay_window = None
app_instance = None
def _ensure_qapplication():
"""
Ensures that QApplication is initialized before creating widgets.
"""
global app_instance
if QApplication.instance() is None:
print("Starting QApplication in a separate thread.")
app_instance = QApplication(sys.argv)
threading.Thread(target=app_instance.exec_, daemon=True).start()
def create_ocr_region(region_id, x=250, y=50, w=DEFAULT_WIDTH, h=DEFAULT_HEIGHT): def create_ocr_region(region_id, x=250, y=50, w=DEFAULT_WIDTH, h=DEFAULT_HEIGHT):
"""
Creates an OCR region with a visible, resizable box on the screen.
"""
print(f"Creating OCR Region: {region_id} at ({x}, {y}, {w}, {h})")
_ensure_qapplication() # Ensure QApplication is running first
collector_mutex.lock() collector_mutex.lock()
if region_id in regions: if region_id in regions:
collector_mutex.unlock() collector_mutex.unlock()
return return
regions[region_id] = { regions[region_id] = {
'bbox': [x, y, w, h], 'bbox': [x, y, w, h],
'raw_text': "" 'raw_text': "",
'widget': OCRRegionWidget(x, y, w, h, region_id)
} }
collector_mutex.unlock() collector_mutex.unlock()
_ensure_overlay()
def get_raw_text(region_id): def get_raw_text(region_id):
collector_mutex.lock() collector_mutex.lock()
@ -69,6 +88,8 @@ def _update_ocr_loop():
regions[rid]['raw_text'] = raw_text regions[rid]['raw_text'] = raw_text
collector_mutex.unlock() collector_mutex.unlock()
print(f"OCR Text for {rid}: {raw_text}")
time.sleep(0.7) time.sleep(0.7)
def _preprocess_image(image): def _preprocess_image(image):
@ -77,130 +98,87 @@ def _preprocess_image(image):
thresh = scaled.point(lambda p: 255 if p > 200 else 0) thresh = scaled.point(lambda p: 255 if p > 200 else 0)
return thresh.filter(ImageFilter.MedianFilter(3)) return thresh.filter(ImageFilter.MedianFilter(3))
def _ensure_overlay(): class OCRRegionWidget(QWidget):
""" def __init__(self, x, y, w, h, region_id):
Creates the overlay window if none exists. super().__init__()
If no QApplication instance is running yet, schedule the creation after
the main application event loop starts (to avoid "Must construct a QApplication first" errors).
"""
global overlay_window
if overlay_window is not None:
return
# If there's already a running QApplication, create overlay immediately. self.setGeometry(x, y, w, h)
if QApplication.instance() is not None: self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
overlay_window = OverlayCanvas()
overlay_window.show()
else:
# Schedule creation for when the app event loop is up.
def delayed_create():
global overlay_window
if overlay_window is None:
overlay_window = OverlayCanvas()
overlay_window.show()
QTimer.singleShot(0, delayed_create)
class OverlayCanvas(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
screen_geo = QApplication.primaryScreen().geometry()
self.setGeometry(screen_geo)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_TranslucentBackground, True) self.setAttribute(Qt.WA_TranslucentBackground, True)
self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
self.drag_offset = None self.drag_offset = None
self.selected_handle = None self.selected_handle = None
self.selected_region_id = None self.region_id = region_id
print(f"OCR Region Widget Created at {x}, {y}, {w}, {h}")
self.show()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
pen = QPen(QColor(0, 0, 255)) pen = QPen(QColor(0, 0, 255))
pen.setWidth(5) pen.setWidth(3)
painter.setPen(pen) painter.setPen(pen)
collector_mutex.lock() # Draw main rectangle
region_copy = {rid: data['bbox'][:] for rid, data in regions.items()} painter.drawRect(0, 0, self.width(), self.height())
collector_mutex.unlock()
for rid, bbox in region_copy.items(): # Draw resize handles
x, y, w, h = bbox painter.setBrush(QColor(0, 0, 255))
painter.drawRect(x, y, w, h) for handle in self._resize_handles():
painter.setFont(QFont("Arial", 12, QFont.Bold)) painter.drawRect(handle)
painter.setPen(QColor(0, 0, 255))
painter.drawText(x, y - 5, f"OCR Region: {rid}") def _resize_handles(self):
w, h = self.width(), self.height()
return [
QRect(0, 0, HANDLE_SIZE, HANDLE_SIZE), # Top-left
QRect(w - HANDLE_SIZE, h - HANDLE_SIZE, HANDLE_SIZE, HANDLE_SIZE) # Bottom-right
]
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.LeftButton: if event.button() == Qt.LeftButton:
collector_mutex.lock() for i, handle in enumerate(self._resize_handles()):
all_items = list(regions.items()) if handle.contains(event.pos()):
collector_mutex.unlock() self.selected_handle = i
for rid, data in all_items:
x, y, w, h = data['bbox']
handles = self._resize_handles(x, y, w, h)
for i, handle_rect in enumerate(handles):
if handle_rect.contains(event.pos()):
self.selected_handle = i
self.selected_region_id = rid
return
if QRect(x, y, w, h).contains(event.pos()):
self.drag_offset = event.pos() - QPoint(x, y)
self.selected_region_id = rid
return return
def mouseMoveEvent(self, event): self.drag_offset = event.pos()
if not self.selected_region_id:
return
collector_mutex.lock()
if self.selected_region_id not in regions:
collector_mutex.unlock()
return
bbox = regions[self.selected_region_id]['bbox']
collector_mutex.unlock()
x, y, w, h = bbox def mouseMoveEvent(self, event):
if self.selected_handle is not None: if self.selected_handle is not None:
# resizing w, h = self.width(), self.height()
if self.selected_handle == 0: # top-left if self.selected_handle == 0: # Top-left
new_w = w + (x - event.x()) new_w = w + (self.x() - event.globalX())
new_h = h + (y - event.y()) new_h = h + (self.y() - event.globalY())
new_x = event.x() new_x = event.globalX()
new_y = event.y() new_y = event.globalY()
if new_w < 10: new_w = 10 if new_w < 20: new_w = 20
if new_h < 10: new_h = 10 if new_h < 20: new_h = 20
collector_mutex.lock() self.setGeometry(new_x, new_y, new_w, new_h)
if self.selected_region_id in regions: elif self.selected_handle == 1: # Bottom-right
regions[self.selected_region_id]['bbox'] = [new_x, new_y, new_w, new_h] new_w = event.globalX() - self.x()
collector_mutex.unlock() new_h = event.globalY() - self.y()
elif self.selected_handle == 1: # bottom-right if new_w < 20: new_w = 20
new_w = event.x() - x if new_h < 20: new_h = 20
new_h = event.y() - y self.setGeometry(self.x(), self.y(), new_w, new_h)
if new_w < 10: new_w = 10
if new_h < 10: new_h = 10 collector_mutex.lock()
collector_mutex.lock() if self.region_id in regions:
if self.selected_region_id in regions: regions[self.region_id]['bbox'] = [self.x(), self.y(), self.width(), self.height()]
regions[self.selected_region_id]['bbox'] = [x, y, new_w, new_h] collector_mutex.unlock()
collector_mutex.unlock()
self.update() self.update()
elif self.drag_offset: elif self.drag_offset:
# dragging new_x = event.globalX() - self.drag_offset.x()
new_x = event.x() - self.drag_offset.x() new_y = event.globalY() - self.drag_offset.y()
new_y = event.y() - self.drag_offset.y() self.move(new_x, new_y)
collector_mutex.lock() collector_mutex.lock()
if self.selected_region_id in regions: if self.region_id in regions:
regions[self.selected_region_id]['bbox'][0] = new_x regions[self.region_id]['bbox'] = [new_x, new_y, self.width(), self.height()]
regions[self.selected_region_id]['bbox'][1] = new_y
collector_mutex.unlock() collector_mutex.unlock()
self.update()
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
self.selected_handle = None self.selected_handle = None
self.drag_offset = None self.drag_offset = None
self.selected_region_id = None
def _resize_handles(self, x, y, w, h):
return [
QRect(x - HANDLE_SIZE//2, y - HANDLE_SIZE//2, HANDLE_SIZE, HANDLE_SIZE), # top-left
QRect(x + w - HANDLE_SIZE//2, y + h - HANDLE_SIZE//2, HANDLE_SIZE, HANDLE_SIZE) # bottom-right
]

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -1,46 +1,44 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Flyff Character Status Node (New Version): Flyff Character Status Node:
- Has no inputs/outputs. - Creates an OCR region in data_collector.
- Creates an OCR region in data_collector. - Periodically grabs raw text from that region and updates status.
- Periodically grabs raw text from that region, parses it here in the node,
and sets data_manager's HP, MP, FP, EXP accordingly.
- Also updates its own text fields with the parsed values.
""" """
import re import re
from OdenGraphQt import BaseNode from OdenGraphQt import BaseNode
from PyQt5.QtWidgets import QMessageBox from PyQt5.QtWidgets import QMessageBox
from PyQt5.QtCore import QTimer # Corrected import
from Modules import data_manager, data_collector from Modules import data_manager, data_collector
class FlyffCharacterStatusNode(BaseNode): class FlyffCharacterStatusNode(BaseNode):
__identifier__ = 'bunny-lab.io.flyff_character_status_node' __identifier__ = "bunny-lab.io.flyff_character_status_node"
NODE_NAME = 'Flyff - Character Status' NODE_NAME = "Flyff - Character Status"
def __init__(self): def __init__(self):
super(FlyffCharacterStatusNode, self).__init__() super(FlyffCharacterStatusNode, self).__init__()
# Prevent duplicates
if data_manager.character_status_collector_exists: if data_manager.character_status_collector_exists:
QMessageBox.critical(None, "Error", "Only one Flyff Character Status Collector node is allowed.") QMessageBox.critical(None, "Error", "Only one Flyff Character Status Collector node is allowed.")
raise Exception("Duplicate Character Status Node.") raise Exception("Duplicate Character Status Node.")
data_manager.character_status_collector_exists = True data_manager.character_status_collector_exists = True
# Add text fields for display self.add_text_input("hp", "HP", text="HP: 0/0")
self.add_text_input('hp', 'HP', text="HP: 0/0") self.add_text_input("mp", "MP", text="MP: 0/0")
self.add_text_input('mp', 'MP', text="MP: 0/0") self.add_text_input("fp", "FP", text="FP: 0/0")
self.add_text_input('fp', 'FP', text="FP: 0/0") self.add_text_input("exp", "EXP", text="EXP: 0%")
self.add_text_input('exp', 'EXP', text="EXP: 0%")
# Create a unique region id for this node (or just "character_status")
self.region_id = "character_status" self.region_id = "character_status"
data_collector.create_ocr_region(self.region_id, x=250, y=50, w=180, h=130) data_collector.create_ocr_region(self.region_id, x=250, y=50, w=180, h=130)
# Start the data_collector background thread (if not already started)
data_collector.start_collector() data_collector.start_collector()
# Set the node name
self.set_name("Flyff - Character Status") self.set_name("Flyff - Character Status")
# Set up a timer to periodically update character stats
self.timer = QTimer()
self.timer.timeout.connect(self.process_input)
self.timer.start(1000) # Update every second
def parse_character_stats(self, raw_text): def parse_character_stats(self, raw_text):
""" """
Extract HP, MP, FP, EXP from the raw OCR text lines. Extract HP, MP, FP, EXP from the raw OCR text lines.
@ -52,6 +50,8 @@ class FlyffCharacterStatusNode(BaseNode):
exp_value = 0.0 exp_value = 0.0
if len(lines) >= 4: if len(lines) >= 4:
print("Processing OCR Lines:", lines) # Debugging output
# line 1: HP # line 1: HP
hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0]) hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0])
if hp_match: if hp_match:
@ -82,15 +82,14 @@ class FlyffCharacterStatusNode(BaseNode):
def process_input(self): def process_input(self):
""" """
Called periodically by the global timer in your main application (borealis.py). Called periodically to update character status from OCR.
""" """
# Grab raw text from data_collector
raw_text = data_collector.get_raw_text(self.region_id) raw_text = data_collector.get_raw_text(self.region_id)
print("Raw OCR Text:", raw_text) # Debugging OCR text reading
# Parse it
hp_c, hp_t, mp_c, mp_t, fp_c, fp_t, exp_v = self.parse_character_stats(raw_text) hp_c, hp_t, mp_c, mp_t, fp_c, fp_t, exp_v = self.parse_character_stats(raw_text)
# Update data_manager # Update the data manager with the parsed values
data_manager.set_data_bulk({ data_manager.set_data_bulk({
"hp_current": hp_c, "hp_current": hp_c,
"hp_total": hp_t, "hp_total": hp_t,
@ -101,8 +100,8 @@ class FlyffCharacterStatusNode(BaseNode):
"exp": exp_v "exp": exp_v
}) })
# Update the node's text fields # Update the node's UI text fields
self.set_property('hp', f"HP: {hp_c}/{hp_t}") self.set_property("hp", f"HP: {hp_c}/{hp_t}")
self.set_property('mp', f"MP: {mp_c}/{mp_t}") self.set_property("mp", f"MP: {mp_c}/{mp_t}")
self.set_property('fp', f"FP: {fp_c}/{fp_t}") self.set_property("fp", f"FP: {fp_c}/{fp_t}")
self.set_property('exp', f"EXP: {exp_v}%") self.set_property("exp", f"EXP: {exp_v}%")