#!/usr/bin/env python3 import sys import os import time import re import pytesseract import numpy as np 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, ImageEnhance, 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" # Hard-coded since we removed EasyOCR POLLING_RATE_MS = 1000 MAX_DATA_POINTS = 7 GREEN_HEADER_STYLE = "bold green" HANDLE_SIZE = 10 LABEL_HEIGHT = 20 DEFAULT_WIDTH = 150 DEFAULT_HEIGHT = 50 def sanitize_experience_string(raw_text): """Extract a float from raw OCR text, removing extraneous symbols.""" 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)) return round(val, 4) def format_experience_value(value): """Format float to 'XX.XXXX' with leading zeros if needed.""" 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}" def format_duration(seconds): """ Convert total seconds into hours / min / sec: - If hours > 0 => "Xh Ym Zs" - Otherwise => "Xm Ys" """ 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" class Region: 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), ] class OverlayCanvas(QWidget): 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) painter.setRenderHint(QPainter.Antialiasing) 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): # Check handles 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 rect 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 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 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 class BorealisOverlay(QWidget): """ Project Borealis with Tesseract + Rich table + Rich progress bar, properly displayed with "with Progress(...) as progress". """ def __init__(self): super().__init__() # Fullscreen overlay screen_geometry = QApplication.primaryScreen().geometry() self.setGeometry(screen_geometry) self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.setAttribute(Qt.WA_TranslucentBackground, True) self.regions = [ Region(250, 50, label="Experience"), ] self.canvas = OverlayCanvas(self.regions, self) self.canvas.setGeometry(self.rect()) # Tesseract self.engine = pytesseract self.points = [] self.timer = QTimer(self) self.timer.timeout.connect(self.collect_ocr_data) self.timer.start(POLLING_RATE_MS) def collect_ocr_data(self): if not self.engine: return 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_image = self.preprocess_image(screenshot) text = pytesseract.image_to_string(processed_image, config='--psm 6 --oem 1') region.data = text.strip() if region.label.lower() == "experience": val = sanitize_experience_string(region.data) if val is not None: self.update_points(val) region.data = format_experience_value(val) + "%" else: region.data = "N/A" self.display_ocr_data_in_terminal() def preprocess_image(self, image): gray = image.convert("L") scaled = gray.resize( (gray.width * 3, gray.height * 3), resample=Image.Resampling.LANCZOS ) thresh = scaled.point(lambda p: p > 200 and 255) return thresh.filter(ImageFilter.MedianFilter(3)) 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): """Return integer seconds until 100% or None if not feasible.""" 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): console = Console() os.system('cls' if os.name == 'nt' else 'clear') console.print("[bold white]Project Borealis[/bold white]") console.print("[dim]Flyff Information Overlay[/dim]\n") console.print(f"[bold]OCR Engine[/bold]: {OCR_ENGINE}") console.print(f"[bold]Data Polling Rate[/bold]: {POLLING_RATE_MS / 1000}s\n") # Build the historical 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 == 1: t0, 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] exp_str = f"[green]{format_experience_value(v_cur)}%[/green]" 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() # Prepare the progress bar 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) if secs_left is not None else "???" # We'll display a single progress bar line in a context manager # Setting transient=False ensures it remains in the console with Progress( TextColumn("[bold white]Predicted Time to Level:[/bold white] "), BarColumn(bar_width=30), TextColumn(" [green]{task.percentage:>5.2f}%[/green] "), TextColumn(f"[orange]{time_str}[/orange] until 100%", justify="right"), console=console, transient=False, ) as progress: task_id = progress.add_task("", total=100, completed=current_exp) # Force a manual refresh so the bar appears immediately progress.refresh() def main(): app = QApplication(sys.argv) window = BorealisOverlay() window.setWindowTitle("Project Borealis Overlay (Fixed Progress Bar)") window.show() sys.exit(app.exec_()) if __name__ == "__main__": main()