From ff313f650c818ebc487850a2bbb45ebf0b96397a Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 12 Feb 2025 03:15:39 -0700 Subject: [PATCH] Fully Functional (Minus Region Resizing) --- .../borealis_overlay.py | 0 flow_UI.py => borealis.py | 13 +- data_collector_v2.py | 327 ++++++++++++++++++ debug_processed.png | Bin 0 -> 2824 bytes debug_screenshot.png | Bin 0 -> 13623 bytes 5 files changed, 328 insertions(+), 12 deletions(-) rename borealis_overlay.py => Legacy_Code/borealis_overlay.py (100%) rename flow_UI.py => borealis.py (87%) create mode 100644 data_collector_v2.py create mode 100644 debug_processed.png create mode 100644 debug_screenshot.png diff --git a/borealis_overlay.py b/Legacy_Code/borealis_overlay.py similarity index 100% rename from borealis_overlay.py rename to Legacy_Code/borealis_overlay.py diff --git a/flow_UI.py b/borealis.py similarity index 87% rename from flow_UI.py rename to borealis.py index ea6f2a9..d9c4e3b 100644 --- a/flow_UI.py +++ b/borealis.py @@ -1,15 +1,4 @@ #!/usr/bin/env python3 -""" -Main Application (flow_UI.py) - -This file dynamically imports custom node classes from the 'Nodes' package, -registers them with NodeGraphQt, and sets up an empty graph. -Nodes can be added dynamically via the graph’s right-click context menu, -and a "Remove Selected Node" option is provided. -A global update timer periodically calls process_input() on nodes. -Additionally, this file patches QGraphicsScene.setSelectionArea to handle -selection behavior properly (so that multiple nodes can be selected). -""" # --- Patch QGraphicsScene.setSelectionArea to handle selection arguments --- from Qt import QtWidgets, QtCore, QtGui @@ -73,7 +62,7 @@ if __name__ == '__main__': # Create the NodeGraph controller. graph = NodeGraph() - graph.widget.setWindowTitle("Modular Nodes Demo") + graph.widget.setWindowTitle("Project Borealis - Flyff Information Overlay") # Dynamically import custom node classes from the 'Nodes' package. custom_nodes = import_nodes_from_folder('Nodes') diff --git a/data_collector_v2.py b/data_collector_v2.py new file mode 100644 index 0000000..abeb253 --- /dev/null +++ b/data_collector_v2.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +import time +import re +import threading +import numpy as np +import cv2 +import pytesseract +from flask import Flask, jsonify +from PIL import Image, ImageGrab, ImageFilter +from PyQt5.QtWidgets import QApplication, QWidget +from PyQt5.QtCore import QTimer, QRect, QPoint, Qt, QMutex +from PyQt5.QtGui import QPainter, QPen, QColor, QFont + +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ============================================================================= +# Global Config +# ============================================================================= + +POLLING_RATE_MS = 500 +MAX_DATA_POINTS = 8 +DEFAULT_WIDTH = 180 +DEFAULT_HEIGHT = 130 +HANDLE_SIZE = 8 +LABEL_HEIGHT = 20 + +# Template Matching Threshold (Define it here) +MATCH_THRESHOLD = 0.4 # Set to 0.4 as a typical value for correlation threshold + +# Flask API setup +app = Flask(__name__) + +# **Shared Region Data (Thread-Safe)** +region_lock = QMutex() # Mutex to synchronize access between UI and OCR thread +shared_region = { + "x": 250, + "y": 50, + "w": DEFAULT_WIDTH, + "h": DEFAULT_HEIGHT +} + +# Global variable for OCR data +latest_data = { + "hp_current": 0, + "hp_total": 0, + "mp_current": 0, + "mp_total": 0, + "fp_current": 0, + "fp_total": 0, + "exp": 0.0000 +} + +# ============================================================================= +# OCR Data Collection +# ============================================================================= + +def preprocess_image(image): + """ + Preprocess the image for OCR: convert to grayscale, resize, and apply thresholding. + """ + gray = image.convert("L") # Convert to grayscale + scaled = gray.resize((gray.width * 3, gray.height * 3)) # Upscale the image for better accuracy + thresh = scaled.point(lambda p: p > 200 and 255) # Apply a threshold to clean up the image + return thresh.filter(ImageFilter.MedianFilter(3)) # Apply a median filter to remove noise + +def sanitize_experience_string(raw_text): + text_no_percent = raw_text.replace('%', '') + text_no_spaces = text_no_percent.replace(' ', '') + cleaned = re.sub(r'[^0-9\.]', '', text_no_spaces) + match = re.search(r'\d+(?:\.\d+)?', cleaned) + if not match: + return None + val = float(match.group(0)) + if val < 0: + val = 0 + elif val > 100: + val = 100 + return round(val, 4) + +def locate_bars_opencv(template_path, threshold=MATCH_THRESHOLD): + """ + Attempt to locate the bars via OpenCV template matching. + """ + screenshot_pil = ImageGrab.grab() + screenshot_np = np.array(screenshot_pil) + screenshot_bgr = cv2.cvtColor(screenshot_np, cv2.COLOR_RGB2BGR) + template_bgr = cv2.imread(template_path, cv2.IMREAD_COLOR) + if template_bgr is None: + return None + result = cv2.matchTemplate(screenshot_bgr, template_bgr, cv2.TM_CCOEFF_NORMED) + min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) + th, tw, _ = template_bgr.shape + if max_val >= threshold: + found_x, found_y = max_loc + return (found_x, found_y, tw, th) + else: + return None + +def parse_all_stats(raw_text): + raw_lines = raw_text.splitlines() + lines = [l.strip() for l in raw_lines if l.strip()] + stats_dict = { + "hp": (0,1), + "mp": (0,1), + "fp": (0,1), + "exp": None + } + if len(lines) < 4: + return stats_dict + + hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0]) + if hp_match: + stats_dict["hp"] = (int(hp_match.group(1)), int(hp_match.group(2))) + + mp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[1]) + if mp_match: + stats_dict["mp"] = (int(mp_match.group(1)), int(mp_match.group(2))) + + fp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[2]) + if fp_match: + stats_dict["fp"] = (int(fp_match.group(1)), int(fp_match.group(2))) + + exp_val = sanitize_experience_string(lines[3]) + stats_dict["exp"] = exp_val + return stats_dict + +# ============================================================================= +# Region & UI +# ============================================================================= + +class Region: + """ + Defines a draggable/resizable screen region for OCR capture. + """ + def __init__(self, x, y, label="Region", color=QColor(0, 0, 255)): + self.x = x + self.y = y + self.w = DEFAULT_WIDTH + self.h = DEFAULT_HEIGHT + self.label = label + self.color = color + self.visible = True + self.data = "" + + def rect(self): + return QRect(self.x, self.y, self.w, self.h) + + def label_rect(self): + return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT) + + def resize_handles(self): + return [ + QRect(self.x - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), + QRect(self.x + self.w - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), + QRect(self.x - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), + QRect(self.x + self.w - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), + ] + +# ============================================================================= +# Flask API Server +# ============================================================================= + +@app.route('/data') +def get_data(): + """Returns the latest OCR data as JSON.""" + return jsonify(latest_data) + +def collect_ocr_data(): + """ + Collects OCR data every 0.5 seconds and updates global latest_data. + """ + global latest_data + while True: + # **Fetch updated region values from UI (thread-safe)** + region_lock.lock() # Lock for thread safety + x, y, w, h = shared_region["x"], shared_region["y"], shared_region["w"], shared_region["h"] + region_lock.unlock() + + # **Grab the image of the updated region** + screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h)) + + # **Debug: Save screenshots to verify capture** + screenshot.save("debug_screenshot.png") + + # Preprocess image + processed = preprocess_image(screenshot) + processed.save("debug_processed.png") # Debug: Save processed image + + # Run OCR + text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1') + print("OCR Output:", text) # Debugging + + stats = parse_all_stats(text.strip()) + hp_cur, hp_max = stats["hp"] + mp_cur, mp_max = stats["mp"] + fp_cur, fp_max = stats["fp"] + exp_val = stats["exp"] + + # Update latest data + latest_data = { + "hp_current": hp_cur, + "hp_total": hp_max, + "mp_current": mp_cur, + "mp_total": mp_max, + "fp_current": fp_cur, + "fp_total": fp_max, + "exp": exp_val + } + + print(f"OCR Updated: HP: {hp_cur}/{hp_max}, MP: {mp_cur}/{mp_max}, FP: {fp_cur}/{fp_max}, EXP: {exp_val}") # Debug + + time.sleep(0.5) + +# ============================================================================= +# OverlayCanvas (UI) +# ============================================================================= + +class OverlayCanvas(QWidget): + """ + UI overlay that allows dragging/resizing of the OCR region. + """ + def __init__(self, parent=None): + super().__init__(parent) + + # **Full-screen overlay** + screen_geo = QApplication.primaryScreen().geometry() + self.setGeometry(screen_geo) # Set to full screen + + self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + self.setAttribute(Qt.WA_TranslucentBackground, True) + + # **Load shared region** + self.region = shared_region + self.drag_offset = None + self.selected_handle = None + + def paintEvent(self, event): + """Draw the blue OCR region.""" + painter = QPainter(self) + pen = QPen(QColor(0, 0, 255)) + pen.setWidth(5) # Thicker lines + painter.setPen(pen) + painter.drawRect(self.region["x"], self.region["y"], self.region["w"], self.region["h"]) + painter.setFont(QFont("Arial", 12, QFont.Bold)) + painter.setPen(QColor(0, 0, 255)) + painter.drawText(self.region["x"], self.region["y"] - 5, "Character Status") + + def mousePressEvent(self, event): + """Handle drag and resize interactions.""" + if event.button() == Qt.LeftButton: + region_lock.lock() # Lock for thread safety + x, y, w, h = self.region["x"], self.region["y"], self.region["w"], self.region["h"] + region_lock.unlock() + + for i, handle in enumerate(self.resize_handles()): + if handle.contains(event.pos()): + self.selected_handle = i + return + + if QRect(x, y, w, h).contains(event.pos()): + self.drag_offset = event.pos() - QPoint(x, y) + + def mouseMoveEvent(self, event): + """Allow dragging and resizing.""" + if self.selected_handle is not None: + region_lock.lock() + sr = self.region + if self.selected_handle == 0: # Top-left + sr["w"] += sr["x"] - event.x() + sr["h"] += sr["y"] - event.y() + sr["x"] = event.x() + sr["y"] = event.y() + elif self.selected_handle == 1: # Bottom-right + sr["w"] = event.x() - sr["x"] + sr["h"] = event.y() - sr["y"] + + sr["w"] = max(sr["w"], 10) + sr["h"] = max(sr["h"], 10) + region_lock.unlock() + + self.update() + + elif self.drag_offset: + region_lock.lock() + self.region["x"] = event.x() - self.drag_offset.x() + self.region["y"] = event.y() - self.drag_offset.y() + region_lock.unlock() + + print(f"Region Moved: x={self.region['x']}, y={self.region['y']}, w={self.region['w']}, h={self.region['h']}") # Debugging + self.update() + + def mouseReleaseEvent(self, event): + """End drag or resize event.""" + self.selected_handle = None + self.drag_offset = None + + def resize_handles(self): + """Get the resizing handles of the region.""" + return [ + QRect(self.region["x"] - HANDLE_SIZE // 2, self.region["y"] - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), + QRect(self.region["x"] + self.region["w"] - HANDLE_SIZE // 2, self.region["y"] + self.region["h"] - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE) + ] + +# ============================================================================= +# Start Application +# ============================================================================= + +def run_flask_app(): + """Runs the Flask API server in a separate thread.""" + app.run(host="127.0.0.1", port=5000) + +if __name__ == '__main__': + # Start the OCR thread + collector_thread = threading.Thread(target=collect_ocr_data, daemon=True) + collector_thread.start() + + # Start the Flask API thread + flask_thread = threading.Thread(target=run_flask_app, daemon=True) + flask_thread.start() + + # Start PyQt5 GUI + app_gui = QApplication([]) + overlay_window = OverlayCanvas() + overlay_window.show() + + # Run event loop + app_gui.exec_() diff --git a/debug_processed.png b/debug_processed.png new file mode 100644 index 0000000000000000000000000000000000000000..981699cad762ca7162b50642684564241725b1ee GIT binary patch literal 2824 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 literal 0 HcmV?d00001 diff --git a/debug_screenshot.png b/debug_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..a69d3d1c7f2ec0529c84f3975220e3eb27bbc059 GIT binary patch literal 13623 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