#!/usr/bin/env python3 """ Project Borealis (Single Region: HP/MP/FP/EXP) ============================================== • One region labeled "Player Stats" capturing 4 lines: 1) HP: current / max 2) MP: current / max 3) FP: current / max 4) EXP (percentage) • HP, MP, FP each have Rich progress bars that stay their assigned color at 100%. • The 4th line (EXP) feeds into historical EXP logic (table + predicted time). • Region is resizable & draggable (edit_mode = True). • Tesseract uses --psm 4 for multi-line segmentation. Adjust region.x, region.y, region.w, region.h to match your UI. """ import sys import os 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 from rich.table import Table from rich.progress import Progress, BarColumn, TextColumn # ---- [ Global Config ] ---- pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" OCR_ENGINE = "Tesseract" POLLING_RATE_MS = 1000 MAX_DATA_POINTS = 7 DEFAULT_WIDTH = 150 DEFAULT_HEIGHT = 120 # taller to ensure line 4 is captured HANDLE_SIZE = 10 LABEL_HEIGHT = 20 GREEN_HEADER_STYLE = "bold green" def format_duration(seconds): """ Convert total seconds into hours/min/seconds (e.g., "Xh Ym Zs"). Returns '???' if None. """ 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): """ 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): """ 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}" 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 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 ] 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 # 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 (reverse if multiple) 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 class BorealisOverlay(QWidget): """ Single Region Overlay for Player Stats (HP/MP/FP/EXP) """ def __init__(self): 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="Player Stats") 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 = [] # Timer for periodic OCR scanning self.timer = QTimer(self) self.timer.timeout.connect(self.collect_ocr_data) self.timer.start(POLLING_RATE_MS) # --------------------------------------------------------------------- # OCR # --------------------------------------------------------------------- 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) # Use psm=4 for multi-line text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1') region.data = text.strip() self.display_ocr_data_in_terminal() def preprocess_image(self, image): """ 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): """ 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): """ 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 # --------------------------------------------------------------------- def compute_time_to_100(self): """ 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 display_ocr_data_in_terminal(self): """ Clears terminal, prints HP/MP/FP bars, EXP table, predicted time. """ console = Console() os.system('cls' if os.name == 'nt' else 'clear') # Title console.print("[bold white]Project Borealis[/bold white]") console.print("[dim]Flyff Information Overlay[/dim]\n") # Parse stats 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"] # Show HP / MP / FP bars, forcing each color to remain even at 100% with Progress( "{task.description}", BarColumn(bar_width=30), TextColumn(" {task.completed}/{task.total} ({task.percentage:>5.2f}%)"), console=console, transient=False, auto_refresh=True, ) as progress: progress.add_task( "[bold red]HP[/bold red]", total=hp_max, completed=hp_cur, style="red", complete_style="red" # remain red at 100% ) progress.add_task( "[bold blue]MP[/bold blue]", total=mp_max, completed=mp_cur, style="blue", complete_style="blue" # remain blue at 100% ) progress.add_task( "[bold green]FP[/bold green]", total=fp_max, completed=fp_cur, style="green", complete_style="green" # remain green at 100% ) progress.refresh() progress.stop() console.print() # blank line after bars # If we have an EXP value, update historical data if exp_val is not None: self.update_points(exp_val) # Build the 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) console.print(table) console.print() # blank line # Predicted time to level current_exp = self.points[-1][1] if self.points else 0.0 secs_left = self.compute_time_to_100() time_str = format_duration(secs_left) with 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"), console=console, transient=False, ) as progress: progress.add_task("", total=100, completed=current_exp) progress.refresh() def main(): """ Launches the PyQt5 overlay, starts the event loop. """ app = QApplication(sys.argv) window = BorealisOverlay() window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)") window.show() sys.exit(app.exec_()) if __name__ == "__main__": main()