From b72d1e4c92718ac6f89bd12398b93257648a8656 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Tue, 11 Feb 2025 05:15:20 -0700 Subject: [PATCH] Added automatic character stat page locator logic at startup. --- borealis_overlay.py | 326 +++++++++++++++++++------------------------- 1 file changed, 144 insertions(+), 182 deletions(-) diff --git a/borealis_overlay.py b/borealis_overlay.py index 3a3a3b1..8a39d46 100644 --- a/borealis_overlay.py +++ b/borealis_overlay.py @@ -3,7 +3,16 @@ import sys import time import re +import numpy as np +import cv2 import pytesseract + +try: + import winsound + HAS_WINSOUND = True +except ImportError: + HAS_WINSOUND = False + from PyQt5.QtWidgets import QApplication, QWidget from PyQt5.QtCore import Qt, QRect, QPoint, QTimer from PyQt5.QtGui import QPainter, QPen, QColor, QFont @@ -15,31 +24,92 @@ from rich.progress import Progress, BarColumn, TextColumn from rich.text import Text from rich.live import Live -# ---- [ Global Config ] ---- +# ============================================================================= +# Global Config +# ============================================================================= + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" -OCR_ENGINE = "Tesseract" POLLING_RATE_MS = 500 MAX_DATA_POINTS = 8 -DEFAULT_WIDTH = 175 -DEFAULT_HEIGHT = 145 -HANDLE_SIZE = 7 +# We still use these defaults for Region size. +DEFAULT_WIDTH = 180 +DEFAULT_HEIGHT = 130 +HANDLE_SIZE = 8 LABEL_HEIGHT = 20 GREEN_HEADER_STYLE = "bold green" -# ----------------------------------------------------------------------------- +BEEP_INTERVAL_SECONDS = 1.0 # Only beep once every 1 second + +# STATUS BAR AUTO-LOCATOR LOGIC (WILL BE BUILT-OUT TO BE MORE ROBUST LATER) +# Set your theme to "Masquerade" and Interface Scale to 140%, and browser zoom level to 110% +TEMPLATE_PATH = "G:\\Nextcloud\\Projects\\Scripting\\bars_template.png" # Path to your bars template file +MATCH_THRESHOLD = 0.5 # The correlation threshold to consider a "good" match + +# ============================================================================= # Helper Functions -# ----------------------------------------------------------------------------- +# ============================================================================= + +def beep_hp_warning(): + """ + Only beep if enough time has elapsed since the last beep (BEEP_INTERVAL_SECONDS). + """ + current_time = time.time() + if (beep_hp_warning.last_beep_time is None or + (current_time - beep_hp_warning.last_beep_time >= BEEP_INTERVAL_SECONDS)): + + beep_hp_warning.last_beep_time = current_time + if HAS_WINSOUND: + # frequency=376 Hz, duration=100 ms + winsound.Beep(376, 100) + else: + # Attempt terminal bell + print('\a', end='') + +beep_hp_warning.last_beep_time = None + + +def locate_bars_opencv(template_path, threshold=MATCH_THRESHOLD): + """ + Attempt to locate the bars via OpenCV template matching: + 1) Grab the full screen using PIL.ImageGrab. + 2) Convert to NumPy array in BGR format for cv2. + 3) Load template from `template_path`. + 4) Use cv2.matchTemplate to find the best match location. + 5) If max correlation > threshold, return (x, y, w, h). + 6) Else return None. + """ + # 1) Capture full screen + screenshot_pil = ImageGrab.grab() + screenshot_np = np.array(screenshot_pil) # shape (H, W, 4) possibly + # Convert RGBA or RGB to BGR + screenshot_bgr = cv2.cvtColor(screenshot_np, cv2.COLOR_RGB2BGR) + + # 2) Load template from file + template_bgr = cv2.imread(template_path, cv2.IMREAD_COLOR) + if template_bgr is None: + print(f"[WARN] Could not load template file: {template_path}") + return None + + # 3) Template matching + result = cv2.matchTemplate(screenshot_bgr, template_bgr, cv2.TM_CCOEFF_NORMED) + + # 4) Find best match + min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) + # template width/height + th, tw, _ = template_bgr.shape + + if max_val >= threshold: + # max_loc is top-left corner of the best match + found_x, found_y = max_loc + return (found_x, found_y, tw, th) + else: + return None + def format_duration(seconds): - """ - INFORMATION PROCESSING: - ----------------------- - Convert total seconds into hours/min/seconds (e.g., "Xh Ym Zs"). - Returns '???' if the input is None or invalid. - """ if seconds is None: return "???" seconds = int(seconds) @@ -52,13 +122,8 @@ def format_duration(seconds): else: return f"{mins}m {secs}s" + def sanitize_experience_string(raw_text): - """ - INFORMATION PROCESSING: - ----------------------- - Extracts a float from raw OCR text for EXP (0-100%). - Handles e.g. "25.5682%", "77.8649" etc. - """ text_no_percent = raw_text.replace('%', '') text_no_spaces = text_no_percent.replace(' ', '') cleaned = re.sub(r'[^0-9\.]', '', text_no_spaces) @@ -72,12 +137,8 @@ def sanitize_experience_string(raw_text): val = 100 return round(val, 4) + def format_experience_value(value): - """ - INFORMATION DISPLAY (formatting): - --------------------------------- - Format a float 0-100 to XX.XXXX for display in table output. - """ if value < 0: value = 0 elif value > 100: @@ -96,11 +157,8 @@ def format_experience_value(value): # ----------------------------------------------------------------------------- # Region Class # ----------------------------------------------------------------------------- - class Region: """ - DATA STRUCTURE: - --------------- Defines a draggable/resizable screen region for OCR capture. """ def __init__(self, x, y, label="Region", color=QColor(0,0,255)): @@ -120,30 +178,24 @@ class Region: return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT) def resize_handles(self): - """ - Return four small rectangles (handles) for resizing each corner. - """ return [ - QRect(self.x - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # top-left - QRect(self.x + self.w - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # top-right - QRect(self.x - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # bottom-left - QRect(self.x + self.w - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # bottom-right + 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), ] # ----------------------------------------------------------------------------- -# OverlayCanvas Class +# OverlayCanvas Class # ----------------------------------------------------------------------------- - class OverlayCanvas(QWidget): """ - UI RENDERING LOGIC: - ------------------- Renders the overlay & handles region dragging/resizing. """ def __init__(self, regions, parent=None): super().__init__(parent) self.regions = regions - self.edit_mode = True # allow editing by default + self.edit_mode = True self.selected_region = None self.selected_handle = None self.drag_offset = QPoint() @@ -152,18 +204,15 @@ class OverlayCanvas(QWidget): painter = QPainter(self) for region in self.regions: if region.visible: - # Draw the bounding rectangle pen = QPen(region.color) pen.setWidth(3) painter.setPen(pen) painter.drawRect(region.x, region.y, region.w, region.h) - # Draw the region label painter.setFont(QFont("Arial", 12, QFont.Bold)) painter.setPen(region.color) painter.drawText(region.x, region.y - 5, region.label) - # If in edit mode, show corner handles if self.edit_mode: for handle in region.resize_handles(): painter.fillRect(handle, region.color) @@ -171,17 +220,13 @@ class OverlayCanvas(QWidget): def mousePressEvent(self, event): if not self.edit_mode: return - if event.button() == Qt.LeftButton: - # Check topmost region first for region in reversed(self.regions): - # Check each resize handle for i, handle in enumerate(region.resize_handles()): if handle.contains(event.pos()): self.selected_region = region self.selected_handle = i return - # Check label or main rect for dragging if region.label_rect().contains(event.pos()): self.selected_region = region self.selected_handle = None @@ -198,11 +243,9 @@ class OverlayCanvas(QWidget): return if self.selected_handle is None: - # Drag entire rectangle self.selected_region.x = event.x() - self.drag_offset.x() self.selected_region.y = event.y() - self.drag_offset.y() else: - # Resize sr = self.selected_region if self.selected_handle == 0: # top-left sr.w += sr.x - event.x() @@ -221,11 +264,10 @@ class OverlayCanvas(QWidget): sr.w = event.x() - sr.x sr.h = event.y() - sr.y - # Enforce min size sr.w = max(sr.w, 10) sr.h = max(sr.h, 10) - self.update() # repaint + self.update() def mouseReleaseEvent(self, event): if not self.edit_mode: @@ -237,12 +279,13 @@ class OverlayCanvas(QWidget): # ----------------------------------------------------------------------------- # BorealisOverlay Class # ----------------------------------------------------------------------------- - class BorealisOverlay(QWidget): """ - MAIN APPLICATION LOGIC: - ----------------------- - Single Region Overlay for Player Stats (HP/MP/FP/EXP) with OCR scanning. + Single Region Overlay for Player Stats (HP/MP/FP/EXP) with: + - Automatic location via OpenCV template matching at startup + - OCR scanning + - Low-HP beep + - Rich Live updates in terminal """ def __init__(self, live=None): super().__init__() @@ -251,9 +294,24 @@ class BorealisOverlay(QWidget): self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.setAttribute(Qt.WA_TranslucentBackground, True) - # Single region, with an increased height (120) - region = Region(250, 50, label="Character Status") - region.h = 120 + # Try to find the bars automatically + # If found => use that location, else default + initial_x, initial_y = 250, 50 + region_w, region_h = DEFAULT_WIDTH, DEFAULT_HEIGHT + + match_result = locate_bars_opencv(TEMPLATE_PATH, MATCH_THRESHOLD) + if match_result is not None: + found_x, found_y, w, h = match_result + print(f"Template matched at {found_x}, {found_y} with confidence >= {MATCH_THRESHOLD}.") + initial_x, initial_y = found_x, found_y + # Optionally override region size with template size + region_w, region_h = w, h + else: + print("No high-confidence match found. Using default region.") + + region = Region(initial_x, initial_y, label="Character Status") + region.w = region_w + region.h = region_h self.regions = [region] self.canvas = OverlayCanvas(self.regions, self) @@ -265,7 +323,6 @@ class BorealisOverlay(QWidget): # Keep history of EXP data self.points = [] - # We will store a reference to Rich.Live here self.live = live # Timer for periodic OCR scanning @@ -274,121 +331,69 @@ class BorealisOverlay(QWidget): self.timer.start(POLLING_RATE_MS) def set_live(self, live): - """ - Called by main() so we can update the Live object from inside this class. - """ self.live = live - # ------------------------------------------------------------------------- - # OCR - # ------------------------------------------------------------------------- def collect_ocr_data(self): - """ - INFORMATION GATHERING: - ---------------------- - Periodically invoked by QTimer. Captures region screenshot, OCR's it, - and triggers the terminal display update. - """ for region in self.regions: if region.visible: screenshot = ImageGrab.grab( bbox=(region.x, region.y, region.x + region.w, region.y + region.h) ) processed = self.preprocess_image(screenshot) - - # Use psm=4 for multi-line text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1') region.data = text.strip() - # Instead of printing directly, we now build a Rich renderable and update Live. if self.live is not None: renderable = self.build_renderable() self.live.update(renderable) def preprocess_image(self, image): - """ - INFORMATION PROCESSING: - ----------------------- - Convert to grayscale, scale up, threshold, median filter - for improved Tesseract accuracy. - """ gray = image.convert("L") scaled = gray.resize((gray.width * 3, gray.height * 3)) thresh = scaled.point(lambda p: p > 200 and 255) return thresh.filter(ImageFilter.MedianFilter(3)) - # ------------------------------------------------------------------------- - # Parsing - # ------------------------------------------------------------------------- def parse_all_stats(self, raw_text): - """ - INFORMATION ANALYSIS: - --------------------- - Expect up to 4 lines: HP, MP, FP, EXP. - Returns dict with keys "hp", "mp", "fp", "exp". - """ raw_lines = raw_text.splitlines() - lines = [l.strip() for l in raw_lines if l.strip()] # remove empty lines - + lines = [l.strip() for l in raw_lines if l.strip()] stats_dict = { - "hp": (0, 1), - "mp": (0, 1), - "fp": (0, 1), + "hp": (0,1), + "mp": (0,1), + "fp": (0,1), "exp": None } - if len(lines) < 4: return stats_dict - # HP 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 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 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 exp_val = sanitize_experience_string(lines[3]) stats_dict["exp"] = exp_val - return stats_dict def update_points(self, new_val): - """ - INFORMATION TRACKING: - --------------------- - Track historical EXP changes for table & predicted time to level. - """ now = time.time() if self.points: _, last_v = self.points[-1] - # skip duplicates if abs(new_val - last_v) < 1e-6: return - # if new_val < last_v, assume rollover if new_val < last_v: self.points.clear() self.points.append((now, new_val)) if len(self.points) > MAX_DATA_POINTS: self.points.pop(0) - # ------------------------------------------------------------------------- - # Display Logic - # ------------------------------------------------------------------------- def compute_time_to_100(self): - """ - INFORMATION PREDICTION: - ----------------------- - Estimate time to reach 100% from current EXP data. - """ n = len(self.points) if n < 2: return None @@ -416,16 +421,6 @@ class BorealisOverlay(QWidget): return int(remain / rate_per_s) def build_renderable(self): - """ - INFORMATION DISPLAY (Rich): - --------------------------- - Construct a single Rich renderable (Group) that includes: - - Title - - HP/MP/FP progress bars - - Historical EXP table - - Predicted time progress bar - """ - # Gather stats from first region raw_text = self.regions[0].data stats = self.parse_all_stats(raw_text) hp_cur, hp_max = stats["hp"] @@ -433,57 +428,37 @@ class BorealisOverlay(QWidget): fp_cur, fp_max = stats["fp"] exp_val = stats["exp"] - # Update historical EXP points if valid + # HP beep logic + if hp_max > 0: + hp_ratio = hp_cur / hp_max + if 0 < hp_ratio <= 0.40: + beep_hp_warning() + if exp_val is not None: self.update_points(exp_val) current_exp = self.points[-1][1] if self.points else 0.0 - # --------------------- - # 1) Title Section - # --------------------- + # Title title_text = Text("Project Borealis\n", style="bold white") subtitle_text = Text("Flyff Information Overlay\n\n", style="dim") - # --------------------- - # 2) HP / MP / FP Bars - # --------------------- + # HP / MP / FP bars bar_progress = Progress( "{task.description}", BarColumn(bar_width=30), TextColumn(" {task.completed}/{task.total} ({task.percentage:>5.2f}%)"), transient=False, - auto_refresh=False # We'll refresh after all tasks are added - ) - - # HP - hp_task = bar_progress.add_task( - "[bold red]HP[/bold red]", - total=hp_max, - completed=hp_cur, - style="red", - complete_style="red" - ) - # MP - mp_task = bar_progress.add_task( - "[bold blue]MP[/bold blue]", - total=mp_max, - completed=mp_cur, - style="blue", - complete_style="blue" - ) - # FP - fp_task = bar_progress.add_task( - "[bold green]FP[/bold green]", - total=fp_max, - completed=fp_cur, - style="green", - complete_style="green" + auto_refresh=False ) + bar_progress.add_task("[bold red]HP[/bold red]", total=hp_max, completed=hp_cur, + style="red", complete_style="red") + bar_progress.add_task("[bold blue]MP[/bold blue]", total=mp_max, completed=mp_cur, + style="blue", complete_style="blue") + bar_progress.add_task("[bold green]FP[/bold green]", total=fp_max, completed=fp_cur, + style="green", complete_style="green") bar_progress.refresh() - # --------------------- - # 3) Historical EXP Table - # --------------------- + # Historical EXP table table = Table(show_header=True, header_style=GREEN_HEADER_STYLE, style=None) table.add_column("Historical EXP", justify="center", style="green") table.add_column("Time Since Last Kill", justify="center", style="green") @@ -503,7 +478,6 @@ class BorealisOverlay(QWidget): t_prev, v_prev = self.points[i - 1] delta_v = v_cur - v_prev delta_str = f"{delta_v:+.4f}%" - exp_main = format_experience_value(v_cur) exp_str = f"[green]{exp_main}%[/green] [dim]({delta_str})[/dim]" @@ -520,9 +494,7 @@ class BorealisOverlay(QWidget): table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str) - # --------------------- - # 4) Predicted Time to Level - # --------------------- + # Predicted Time to Level secs_left = self.compute_time_to_100() time_str = format_duration(secs_left) @@ -534,45 +506,35 @@ class BorealisOverlay(QWidget): transient=False, auto_refresh=False ) - t_task = time_bar.add_task("", total=100, completed=current_exp) + time_bar.add_task("", total=100, completed=current_exp) time_bar.refresh() - # Combine everything into a Rich Group - # Title + Subtitle + HP/MP/FP Progress + Table + Time Bar return Group( title_text, subtitle_text, - bar_progress, # HP/MP/FP + bar_progress, table, - time_bar # predicted-time progress + time_bar ) # ----------------------------------------------------------------------------- -# main() +# main # ----------------------------------------------------------------------------- - def main(): """ - LAUNCH SEQUENCE: - --------------- - 1) Create QApplication. - 2) Create BorealisOverlay Window. - 3) Use Rich Live to continuously update terminal output with no flicker. - 4) Start PyQt event loop. + 1) Attempt to locate HP/MP/FP/Exp bars using OpenCV template matching. + 2) Position overlay region accordingly if found, else default. + 3) Start PyQt, periodically OCR the region, update Rich Live in terminal. """ app = QApplication(sys.argv) - window = BorealisOverlay() # We'll inject Live momentarily - + window = BorealisOverlay() window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)") window.show() console = Console() - # Use a Live context manager so we can do partial updates with Live(console=console, refresh_per_second=4) as live: - # Pass the live object to our BorealisOverlay so it can call live.update() window.set_live(live) - # Run the PyQt event loop (blocking) exit_code = app.exec_() sys.exit(exit_code)