diff --git a/borealis_overlay.py b/borealis_overlay.py index ae8f7e5..715cc0e 100644 --- a/borealis_overlay.py +++ b/borealis_overlay.py @@ -1,21 +1,92 @@ +#!/usr/bin/env python3 +""" +Project Borealis (Fixed Progress Bar) +===================================== +• Uses Tesseract OCR only, storing up to 7 data points. +• Rollover logic (reset if new < old). +• Skips duplicates. +• Shows a Rich table of historical data (2+ points). +• Then displays a Rich progress bar for the current EXP, plus predicted time to level + in hours/min/seconds if over 60 minutes. + +Key fix: we use "with Progress(...) as progress:" *without* transient, +so the bar remains in the console after the update. +""" + import sys +import os +import time +import re import pytesseract -import easyocr import numpy as np -from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QCheckBox, QTextEdit, QComboBox, QVBoxLayout + +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 # For screen capture and image processing +from PIL import Image, ImageGrab, ImageEnhance, ImageFilter -HANDLE_SIZE = 10 # Size of the resize handle squares -LABEL_HEIGHT = 20 # Height of the label area above the rectangle -DEFAULT_WIDTH = 150 # Default width for the regions -DEFAULT_HEIGHT = 50 # Default height for the regions -DEFAULT_SPACING = 20 # Default horizontal spacing between regions +from rich.console import Console +from rich.table import Table +from rich.progress import Progress, BarColumn, TextColumn -# Set the path for tesseract manually +# ---------------- [ 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)): @@ -25,81 +96,68 @@ class Region: self.h = DEFAULT_HEIGHT self.label = label self.color = color - self.visible = True # Track the visibility of the region - self.data = "" # Store OCR data for this region + self.visible = True + self.data = "" def rect(self): return QRect(self.x, self.y, self.w, self.h) def label_rect(self): - """The rectangle representing the label area.""" return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT) def resize_handles(self): - """Calculate the positions of the resize handles.""" 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 + 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): - """ - A canvas to draw, resize, and interact with rectangular regions. - """ def __init__(self, regions, parent=None): super().__init__(parent) self.regions = regions - self.edit_mode = False + self.edit_mode = True self.selected_region = None - self.selected_handle = None # Track which handle is being dragged + 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: # Only draw visible regions - # Draw the rectangle + if region.visible: pen = QPen(region.color) pen.setWidth(3) painter.setPen(pen) painter.drawRect(region.x, region.y, region.w, region.h) - # Draw the label above the rectangle, aligned with the left edge of the region painter.setFont(QFont("Arial", 12, QFont.Bold)) painter.setPen(region.color) - painter.drawText(region.x, region.y - 5, region.label) # Aligned to the left of the region + painter.drawText(region.x, region.y - 5, region.label) - # Draw resize handles if in edit mode 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 # Ignore clicks if not in edit mode + return if event.button() == Qt.LeftButton: - for region in reversed(self.regions): # Check regions from topmost to bottommost - # Check if a resize handle is clicked + 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 if the label area is clicked (for dragging) + # Check label or rect if region.label_rect().contains(event.pos()): self.selected_region = region - self.selected_handle = None # No resize handle, just dragging the rectangle + self.selected_handle = None self.drag_offset = event.pos() - QPoint(region.x, region.y) return - - # Check if the main rectangle is clicked (fallback for dragging) if region.rect().contains(event.pos()): self.selected_region = region self.selected_handle = None @@ -111,274 +169,209 @@ class OverlayCanvas(QWidget): return if self.selected_handle is None: - # Dragging the entire rectangle + # Drag rectangle self.selected_region.x = event.x() - self.drag_offset.x() self.selected_region.y = event.y() - self.drag_offset.y() else: - # Resizing the rectangle + # Resize + sr = self.selected_region if self.selected_handle == 0: # Top-left - self.selected_region.w += self.selected_region.x - event.x() - self.selected_region.h += self.selected_region.y - event.y() - self.selected_region.x = event.x() - self.selected_region.y = event.y() + 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 - self.selected_region.w = event.x() - self.selected_region.x - self.selected_region.h += self.selected_region.y - event.y() - self.selected_region.y = event.y() + sr.w = event.x() - sr.x + sr.h += sr.y - event.y() + sr.y = event.y() elif self.selected_handle == 2: # Bottom-left - self.selected_region.w += self.selected_region.x - event.x() - self.selected_region.h = event.y() - self.selected_region.y - self.selected_region.x = event.x() + sr.w += sr.x - event.x() + sr.h = event.y() - sr.y + sr.x = event.x() elif self.selected_handle == 3: # Bottom-right - self.selected_region.w = event.x() - self.selected_region.x - self.selected_region.h = event.y() - self.selected_region.y + sr.w = event.x() - sr.x + sr.h = event.y() - sr.y - # Prevent negative width/height - self.selected_region.w = max(self.selected_region.w, 10) - self.selected_region.h = max(self.selected_region.h, 10) - - self.update() # Trigger a repaint + 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 # Deselect handle - + 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__() - # Set window properties to cover the full screen + # Fullscreen overlay screen_geometry = QApplication.primaryScreen().geometry() self.setGeometry(screen_geometry) self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) - self.setAttribute(Qt.WA_TranslucentBackground, True) # Transparent background + self.setAttribute(Qt.WA_TranslucentBackground, True) - # Create regions to draw and interact with, all regions have the same size and are moved higher up self.regions = [ - Region(250, 50, label="Experience"), # Moved slightly to the right - Region(450, 50, label="Region 02"), # Moved slightly to the right - Region(650, 50, label="Region 03") # Moved slightly to the right + Region(250, 50, label="Experience"), ] - - # Create canvas and attach to window self.canvas = OverlayCanvas(self.regions, self) - self.canvas.setGeometry(self.rect()) # Match the canvas size to the full window + self.canvas.setGeometry(self.rect()) - # Add title, Edit Mode UI, and buttons (Overlay Visibility checkbox) - self.init_ui() + # Tesseract + self.engine = pytesseract - # Timer for polling OCR data + self.points = [] self.timer = QTimer(self) self.timer.timeout.connect(self.collect_ocr_data) - self.timer.start(1000) # Poll every second (default) - - self.reader = None # Default OCR reader (None until an engine is selected) - - def init_ui(self): - """Initialize UI components.""" - # Title label - self.title_label = QLabel("Borealis Overlay", self) - self.title_label.setStyleSheet("QLabel { color: white; font-size: 20px; font-weight: bold; }") # Adjusted title size - self.title_label.move(10, 5) - - # OCR Engine label and selection dropdown - self.engine_label = QLabel("OCR Engine:", self) - self.engine_label.setStyleSheet("QLabel { color: white; font-size: 14px; }") - self.engine_label.move(10, 60) # Moved OCR Engine label slightly higher - - self.engine_dropdown = QComboBox(self) - self.engine_dropdown.setStyleSheet(""" - QComboBox { - color: white; - background-color: #2b2b2b; - border: 1px solid #3c3f41; - font-size: 14px; - } - QComboBox QAbstractItemView { - color: white; - background-color: #2b2b2b; - } - """) - self.engine_dropdown.addItem("Select OCR Engine") # Placeholder option - self.engine_dropdown.addItem("Tesseract") # Only Tesseract for now - self.engine_dropdown.addItem("EasyOCR") # Adding EasyOCR option - self.engine_dropdown.move(100, 90) # Dropdown moved slightly up and aligned with label - self.engine_dropdown.currentIndexChanged.connect(self.on_engine_selected) - - # Polling rate dropdown - self.polling_rate_label = QLabel("Polling Rate:", self) - self.polling_rate_label.setStyleSheet("QLabel { color: white; font-size: 14px; }") - self.polling_rate_label.move(10, 120) - - self.polling_rate_dropdown = QComboBox(self) - self.polling_rate_dropdown.setStyleSheet(""" - QComboBox { - color: white; - background-color: #2b2b2b; - border: 1px solid #3c3f41; - font-size: 14px; - } - QComboBox QAbstractItemView { - color: white; - background-color: #2b2b2b; - } - """) - self.polling_rate_dropdown.addItem("0.1 Seconds") - self.polling_rate_dropdown.addItem("0.5 Seconds") - self.polling_rate_dropdown.addItem("1 Second") - self.polling_rate_dropdown.addItem("2 Seconds") - self.polling_rate_dropdown.addItem("5 Seconds") - self.polling_rate_dropdown.move(100, 150) # Dropdown moved slightly up - self.polling_rate_dropdown.currentIndexChanged.connect(self.on_polling_rate_selected) - - # Options label - self.options_label = QLabel("Options", self) - self.options_label.setStyleSheet("QLabel { color: white; font-size: 16px; font-weight: bold; }") - self.options_label.move(10, 180) # Positioned above checkboxes - - # Edit mode checkbox - self.mode_toggle = QCheckBox("Edit Mode", self) - self.mode_toggle.setStyleSheet("QCheckBox { color: white; }") - self.mode_toggle.move(10, 210) - self.mode_toggle.stateChanged.connect(self.toggle_edit_mode) - - # Overlay Visibility checkbox - self.visibility_checkbox = QCheckBox("Overlay Visibility", self) - self.visibility_checkbox.setStyleSheet("QCheckBox { color: white; }") - self.visibility_checkbox.move(10, 240) # Positioned below Edit Mode - self.visibility_checkbox.setChecked(True) # Default to visible - self.visibility_checkbox.stateChanged.connect(self.toggle_overlay_visibility) - - # Collect Data checkbox for OCR functionality (disabled initially) - self.collect_data_checkbox = QCheckBox("Collect Data", self) - self.collect_data_checkbox.setStyleSheet("QCheckBox { color: white; }") - self.collect_data_checkbox.move(10, 270) # Positioned below Overlay Visibility - self.collect_data_checkbox.setEnabled(False) # Initially disabled - self.collect_data_checkbox.stateChanged.connect(self.toggle_ocr) - - # Data Collection Output label - self.output_label = QLabel("Data Collection Output:", self) - self.output_label.setStyleSheet("QLabel { color: white; font-size: 14px; font-weight: bold; }") - self.output_label.move(10, 330) # Moved down by 50px - - # Text area for OCR data display (with transparent background) - self.ocr_display = QTextEdit(self) - self.ocr_display.setStyleSheet("QTextEdit { color: white; background-color: transparent; font-size: 14px; }") - self.ocr_display.setReadOnly(True) - self.ocr_display.setGeometry(10, 360, 300, 400) - - def on_engine_selected(self): - """Enable the Collect Data checkbox when an OCR engine is selected.""" - selected_engine = self.engine_dropdown.currentText() - if selected_engine == "Tesseract": - self.reader = pytesseract - elif selected_engine == "EasyOCR": - self.reader = easyocr.Reader(['en']) # Initialize EasyOCR reader for English language - elif selected_engine == "Select OCR Engine": - self.reader = None - - if self.reader: - self.collect_data_checkbox.setEnabled(True) - else: - self.collect_data_checkbox.setEnabled(False) - - def on_polling_rate_selected(self): - """Update the polling rate based on dropdown selection.""" - polling_rate = self.polling_rate_dropdown.currentText() - if polling_rate == "0.1 Seconds": - self.timer.setInterval(100) - elif polling_rate == "0.5 Seconds": - self.timer.setInterval(500) - elif polling_rate == "1 Second": - self.timer.setInterval(1000) - elif polling_rate == "2 Seconds": - self.timer.setInterval(2000) - elif polling_rate == "5 Seconds": - self.timer.setInterval(5000) - - def toggle_edit_mode(self, state): - """Enable or disable edit mode for dragging and resizing rectangles.""" - editing = (state == 2) - self.canvas.edit_mode = editing # Pass the state to the canvas - - def toggle_overlay_visibility(self): - """Toggle the visibility of the regions.""" - visible = self.visibility_checkbox.isChecked() - for region in self.regions: - region.visible = visible # Toggle visibility based on checkbox - self.update() # Trigger a repaint - - def toggle_ocr(self, state): - """Enable or disable OCR collection.""" - if state == Qt.Checked: - self.collect_ocr_data() - else: - self.clear_ocr_data() + self.timer.start(POLLING_RATE_MS) def collect_ocr_data(self): - """Collect OCR data from each visible region.""" - if self.collect_data_checkbox.isChecked() and self.reader: # Only collect data if checkbox is checked - for region in self.regions: - if region.visible: - # Capture the image of the region - screenshot = ImageGrab.grab(bbox=(region.x, region.y, region.x + region.w, region.y + region.h)) + if not self.engine: + return - # Preprocess the image (Convert to grayscale and apply threshold) - processed_image = self.preprocess_image(screenshot) + 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) - # Convert the processed image to a numpy array for EasyOCR - numpy_image = np.array(processed_image) + text = pytesseract.image_to_string(processed_image, config='--psm 6 --oem 1') + region.data = text.strip() - # Perform OCR on the preprocessed numpy image - if self.reader == pytesseract: - text = pytesseract.image_to_string(processed_image, config='--psm 6 --oem 1') + 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: - # Get text from EasyOCR - results = self.reader.readtext(numpy_image) + region.data = "N/A" - if results: - text = results[0][1] # If OCR detects text, use it - else: - text = "No text detected" # Fallback if no text is detected - - region.data = text.strip() - - self.display_ocr_data() + self.display_ocr_data_in_terminal() def preprocess_image(self, image): - """Preprocess the image to enhance text recognition.""" - # Convert image to grayscale - gray_image = image.convert("L") + 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)) - # Apply threshold to make the image binary (black and white) - threshold_image = gray_image.point(lambda p: p > 200 and 255) + 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) - # Apply noise reduction (filter) - processed_image = threshold_image.filter(ImageFilter.MedianFilter(3)) - return processed_image + 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 - def clear_ocr_data(self): - """Clear the displayed OCR data.""" - self.ocr_display.clear() + steps = n - 1 + total_time = last_t - first_t + if total_time <= 0: + return None - def display_ocr_data(self): - """Display OCR data in the text area on the left-hand side.""" - ocr_text = "" - for region in self.regions: - ocr_text += f"{region.label} Output:\n{region.data}\n\n" # Updated headers with "Output" - self.ocr_display.setText(ocr_text) + 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("Borealis Overlay") # Set application window title - window.show() # Explicitly show the window + window.setWindowTitle("Project Borealis Overlay (Fixed Progress Bar)") + window.show() sys.exit(app.exec_())