#!/usr/bin/env python3 import sys import time import re import pytesseract 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" OCR_ENGINE = "Tesseract" POLLING_RATE_MS = 500 MAX_DATA_POINTS = 8 DEFAULT_WIDTH = 175 DEFAULT_HEIGHT = 145 HANDLE_SIZE = 7 LABEL_HEIGHT = 20 GREEN_HEADER_STYLE = "bold green" # ----------------------------------------------------------------------------- # Helper Functions # ----------------------------------------------------------------------------- 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) 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): """ 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) 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): """ INFORMATION DISPLAY (formatting): --------------------------------- Format a float 0-100 to XX.XXXX for display in table output. """ 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: """ DATA STRUCTURE: --------------- 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 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 ] # ----------------------------------------------------------------------------- # 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.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: # 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) 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 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: # 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() 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 # Enforce min size sr.w = max(sr.w, 10) sr.h = max(sr.h, 10) self.update() # repaint 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): """ MAIN APPLICATION LOGIC: ----------------------- Single Region Overlay for Player Stats (HP/MP/FP/EXP) with OCR scanning. """ 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) # Single region, with an increased height (120) region = Region(250, 50, label="Character Status") region.h = 120 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 = [] # We will store a reference to Rich.Live here 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): """ 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 stats_dict = { "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 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): """ 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"] mp_cur, mp_max = stats["mp"] fp_cur, fp_max = stats["fp"] exp_val = stats["exp"] # Update historical EXP points if valid 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_text = Text("Project Borealis\n", style="bold white") subtitle_text = Text("Flyff Information Overlay\n\n", style="dim") # --------------------- # 2) 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" ) bar_progress.refresh() # --------------------- # 3) 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) # --------------------- # 4) 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 ) t_task = 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 table, time_bar # predicted-time progress ) # ----------------------------------------------------------------------------- # 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. """ app = QApplication(sys.argv) window = BorealisOverlay() # We'll inject Live momentarily 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) if __name__ == "__main__": main()