From 01da38d05d29aea1a9d894c4572f10737a36b9d1 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Tue, 11 Feb 2025 02:47:40 -0700 Subject: [PATCH] Added HP, MP, and FP. --- borealis_overlay.py | 315 +++++++++++++++++++++++++++++++------------- 1 file changed, 220 insertions(+), 95 deletions(-) diff --git a/borealis_overlay.py b/borealis_overlay.py index 9f8e755..4a496c5 100644 --- a/borealis_overlay.py +++ b/borealis_overlay.py @@ -1,16 +1,30 @@ #!/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 -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 PIL import Image, ImageGrab, ImageFilter from rich.console import Console from rich.table import Table @@ -19,18 +33,39 @@ 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 +OCR_ENGINE = "Tesseract" +POLLING_RATE_MS = 1000 MAX_DATA_POINTS = 7 -GREEN_HEADER_STYLE = "bold green" +DEFAULT_WIDTH = 150 +DEFAULT_HEIGHT = 120 # taller to ensure line 4 is captured HANDLE_SIZE = 10 LABEL_HEIGHT = 20 -DEFAULT_WIDTH = 150 -DEFAULT_HEIGHT = 50 + +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): - """Extract a float from raw OCR text, removing extraneous symbols.""" + """ + 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) @@ -38,15 +73,20 @@ def sanitize_experience_string(raw_text): 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 float to 'XX.XXXX' with leading zeros if needed.""" + """ + 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('.') @@ -58,23 +98,11 @@ def format_experience_value(value): int_part = "00" return f"{int_part}.{dec_part}" -def format_duration(seconds): - """ - Convert total seconds into hours/min/seconds (e.g. "Xh Ym Zs" or "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)): + """ + 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 @@ -91,36 +119,44 @@ 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), - 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), + 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 + 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) - painter.setRenderHint(QPainter.Antialiasing) 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) @@ -130,14 +166,15 @@ class OverlayCanvas(QWidget): return if event.button() == Qt.LeftButton: + # Check topmost region first (reverse if multiple) for region in reversed(self.regions): - # Check each handle + # 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 + # Check label or main rect for dragging if region.label_rect().contains(event.pos()): self.selected_region = region self.selected_handle = None @@ -160,26 +197,28 @@ class OverlayCanvas(QWidget): else: # Resize sr = self.selected_region - if self.selected_handle == 0: # Top-left + 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 + 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 + 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 + 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() + + self.update() # repaint def mouseReleaseEvent(self, event): if not self.edit_mode: @@ -189,33 +228,39 @@ class OverlayCanvas(QWidget): self.selected_handle = None class BorealisOverlay(QWidget): + """ + Single Region Overlay for Player Stats (HP/MP/FP/EXP) + """ def __init__(self): super().__init__() - - screen_geometry = QApplication.primaryScreen().geometry() - self.setGeometry(screen_geometry) + screen_geo = QApplication.primaryScreen().geometry() + self.setGeometry(screen_geo) self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.setAttribute(Qt.WA_TranslucentBackground, True) - self.regions = [ Region(250, 50, label="Experience") ] + # 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 only + # 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 & Data + # OCR # --------------------------------------------------------------------- def collect_ocr_data(self): - if not self.engine: - return - for region in self.regions: if region.visible: screenshot = ImageGrab.grab( @@ -223,36 +268,75 @@ class BorealisOverlay(QWidget): ) processed = self.preprocess_image(screenshot) - text = pytesseract.image_to_string(processed, config='--psm 6 --oem 1') + # Use psm=4 for multi-line + text = pytesseract.image_to_string(processed, config='--psm 4 --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): + """ + 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), - resample=Image.Resampling.LANCZOS - ) + 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 - # rollover + # if new_val < last_v, assume rollover if new_val < last_v: self.points.clear() self.points.append((now, new_val)) @@ -260,9 +344,12 @@ class BorealisOverlay(QWidget): self.points.pop(0) # --------------------------------------------------------------------- - # Table & Calculation + # 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 @@ -290,18 +377,66 @@ class BorealisOverlay(QWidget): return int(remain / rate_per_s) def display_ocr_data_in_terminal(self): - from rich.progress import Progress, BarColumn, TextColumn - + """ + 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") - console.print(f"[bold]OCR Engine[/bold]: {OCR_ENGINE}") - console.print(f"[bold]Data Polling Rate[/bold]: {POLLING_RATE_MS/1000}s\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"] - # Build table + # 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") @@ -309,9 +444,10 @@ class BorealisOverlay(QWidget): table.add_column("Average Time Between Kills", justify="center", style="green") n = len(self.points) - # If we only have 1 data point => show single row - if n == 1: - t0, v0 = self.points[0] + 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: @@ -319,61 +455,50 @@ class BorealisOverlay(QWidget): t_cur, v_cur = self.points[i] t_prev, v_prev = self.points[i - 1] - # Calculate a difference delta_v = v_cur - v_prev delta_str = f"{delta_v:+.4f}%" - # e.g. "v_cur + (delta in dark gray)" - # "72.8260% [dim](+0.0334%) [/dim]" exp_main = format_experience_value(v_cur) - exp_str = ( - f"[green]{exp_main}%[/green] " - f"[dim]({delta_str})[/dim]" - ) + exp_str = f"[green]{exp_main}%[/green] [dim]({delta_str})[/dim]" - # Time since last kill delta_t = t_cur - t_prev t_since_str = f"{delta_t:.1f}s" - # average exp from first data point diff_v = v_cur - self.points[0][1] steps = i avg_exp_str = f"{diff_v/steps:.4f}%" - # average time from first data point 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 - ) + table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str) console.print(table) - console.print() + console.print() # blank line - # Progress bar + # 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) if secs_left else "???" + time_str = format_duration(secs_left) with Progress( TextColumn("[bold white]Predicted Time to Level:[/bold white] "), - BarColumn(bar_width=30), + BarColumn(bar_width=30, complete_style="magenta"), TextColumn(" [green]{task.percentage:>5.2f}%[/green] "), - TextColumn(f"[orange]{time_str}[/orange] until 100%", justify="right"), + TextColumn(f"[magenta]{time_str}[/magenta] until 100%", justify="right"), console=console, transient=False, ) as progress: - task_id = progress.add_task("", total=100, completed=current_exp) + 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 (Delta in Historical EXP)") + window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)") window.show() sys.exit(app.exec_())