#!/usr/bin/env python3 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 from PIL import Image, ImageGrab, ImageFilter from rich.console import Console, Group from rich.table import Table from rich.progress import Progress, BarColumn, TextColumn from rich.text import Text from rich.live import Live # ============================================================================= # Global Config # ============================================================================= pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" POLLING_RATE_MS = 500 MAX_DATA_POINTS = 8 # 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) TEMPLATE_PATH = "G:\\Nextcloud\\Projects\\Scripting\\bars_template.png" # Path to your bars template file MATCH_THRESHOLD = 0.4 # 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): if seconds is None: return "???" seconds = int(seconds) hours = seconds // 3600 leftover = seconds % 3600 mins = leftover // 60 secs = leftover % 60 if hours > 0: return f"{hours}h {mins}m {secs}s" else: return f"{mins}m {secs}s" 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 format_experience_value(value): if value < 0: value = 0 elif value > 100: value = 100 float_4 = round(value, 4) raw_str = f"{float_4:.4f}" int_part, dec_part = raw_str.split('.') if int_part == "100": pass elif len(int_part) == 1 and int_part != "0": int_part = "0" + int_part elif int_part == "0": int_part = "00" return f"{int_part}.{dec_part}" # ----------------------------------------------------------------------------- # Region Class # ----------------------------------------------------------------------------- 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), ] # ----------------------------------------------------------------------------- # OverlayCanvas Class # ----------------------------------------------------------------------------- class OverlayCanvas(QWidget): """ Renders the overlay & handles region dragging/resizing. """ def __init__(self, regions, parent=None): super().__init__(parent) self.regions = regions self.edit_mode = True self.selected_region = None self.selected_handle = None self.drag_offset = QPoint() def paintEvent(self, event): painter = QPainter(self) for region in self.regions: if region.visible: pen = QPen(region.color) pen.setWidth(3) painter.setPen(pen) painter.drawRect(region.x, region.y, region.w, region.h) painter.setFont(QFont("Arial", 12, QFont.Bold)) painter.setPen(region.color) painter.drawText(region.x, region.y - 5, region.label) if self.edit_mode: for handle in region.resize_handles(): painter.fillRect(handle, region.color) def mousePressEvent(self, event): if not self.edit_mode: return if event.button() == Qt.LeftButton: for region in reversed(self.regions): for i, handle in enumerate(region.resize_handles()): if handle.contains(event.pos()): self.selected_region = region self.selected_handle = i return if region.label_rect().contains(event.pos()): self.selected_region = region self.selected_handle = None self.drag_offset = event.pos() - QPoint(region.x, region.y) return if region.rect().contains(event.pos()): self.selected_region = region self.selected_handle = None self.drag_offset = event.pos() - QPoint(region.x, region.y) return def mouseMoveEvent(self, event): if not self.edit_mode or self.selected_region is None: return if self.selected_handle is None: self.selected_region.x = event.x() - self.drag_offset.x() self.selected_region.y = event.y() - self.drag_offset.y() else: sr = self.selected_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: # top-right sr.w = event.x() - sr.x sr.h += sr.y - event.y() sr.y = event.y() elif self.selected_handle == 2: # bottom-left sr.w += sr.x - event.x() sr.h = event.y() - sr.y sr.x = event.x() elif self.selected_handle == 3: # 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) self.update() def mouseReleaseEvent(self, event): if not self.edit_mode: return if event.button() == Qt.LeftButton: self.selected_region = None self.selected_handle = None # ----------------------------------------------------------------------------- # BorealisOverlay Class # ----------------------------------------------------------------------------- class BorealisOverlay(QWidget): """ 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__() screen_geo = QApplication.primaryScreen().geometry() self.setGeometry(screen_geo) self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.setAttribute(Qt.WA_TranslucentBackground, True) # 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"Character Status Located 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("Could not auto-locate the character status page. Set your theme to Masquerade and Interface Scale to 140%, and browser zoom level to 110%. 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) self.canvas.setGeometry(self.rect()) # Tesseract self.engine = pytesseract # Keep history of EXP data self.points = [] self.live = live # Timer for periodic OCR scanning self.timer = QTimer(self) self.timer.timeout.connect(self.collect_ocr_data) self.timer.start(POLLING_RATE_MS) def set_live(self, live): self.live = live def collect_ocr_data(self): 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) text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1') region.data = text.strip() if self.live is not None: renderable = self.build_renderable() self.live.update(renderable) def preprocess_image(self, image): 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)) def parse_all_stats(self, 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 def update_points(self, new_val): now = time.time() if self.points: _, last_v = self.points[-1] if abs(new_val - last_v) < 1e-6: return 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) def compute_time_to_100(self): n = len(self.points) if n < 2: return None first_t, first_v = self.points[0] last_t, last_v = self.points[-1] diff_v = last_v - first_v if diff_v <= 0: return None steps = n - 1 total_time = last_t - first_t if total_time <= 0: return None avg_change = diff_v / steps remain = 100.0 - last_v if remain <= 0: return None avg_time = total_time / steps rate_per_s = avg_change / avg_time if avg_time > 0 else 0 if rate_per_s <= 0: return None return int(remain / rate_per_s) def build_renderable(self): raw_text = self.regions[0].data stats = self.parse_all_stats(raw_text) hp_cur, hp_max = stats["hp"] mp_cur, mp_max = stats["mp"] fp_cur, fp_max = stats["fp"] exp_val = stats["exp"] # 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 # Title title_text = Text("Project Borealis\n", style="bold white") subtitle_text = Text("Flyff Information Overlay\n\n", style="dim") # 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 ) 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() # 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") table.add_column("Average EXP Per Kill", justify="center", style="green") table.add_column("Average Time Between Kills", justify="center", style="green") n = len(self.points) if n == 0: table.add_row("N/A", "N/A", "N/A", "N/A") elif n == 1: _, v0 = self.points[0] exp_str = f"[green]{format_experience_value(v0)}%[/green]" table.add_row(exp_str, "N/A", "N/A", "N/A") else: for i in range(1, n): t_cur, v_cur = self.points[i] 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]" delta_t = t_cur - t_prev t_since_str = f"{delta_t:.1f}s" diff_v = v_cur - self.points[0][1] steps = i avg_exp_str = f"{diff_v/steps:.4f}%" total_time = t_cur - self.points[0][0] avg_kill_time = total_time / steps avg_time_str = f"{avg_kill_time:.1f}s" table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str) # Predicted Time to Level secs_left = self.compute_time_to_100() time_str = format_duration(secs_left) time_bar = Progress( TextColumn("[bold white]Predicted Time to Level:[/bold white] "), BarColumn(bar_width=30, complete_style="magenta"), TextColumn(" [green]{task.percentage:>5.2f}%[/green] "), TextColumn(f"[magenta]{time_str}[/magenta] until 100%", justify="right"), transient=False, auto_refresh=False ) time_bar.add_task("", total=100, completed=current_exp) time_bar.refresh() return Group( title_text, subtitle_text, bar_progress, table, time_bar ) # ----------------------------------------------------------------------------- # main # ----------------------------------------------------------------------------- def main(): """ 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() window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)") window.show() console = Console() with Live(console=console, refresh_per_second=4) as live: window.set_live(live) exit_code = app.exec_() sys.exit(exit_code) if __name__ == "__main__": main()