Borealis-Legacy/borealis_overlay.py

382 lines
13 KiB
Python

#!/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"
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/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)):
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 each 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
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
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):
def __init__(self):
super().__init__()
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 only
self.engine = pytesseract
self.points = []
self.timer = QTimer(self)
self.timer.timeout.connect(self.collect_ocr_data)
self.timer.start(POLLING_RATE_MS)
# ---------------------------------------------------------------------
# OCR & Data
# ---------------------------------------------------------------------
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 = self.preprocess_image(screenshot)
text = pytesseract.image_to_string(processed, 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]
# skip duplicates
if abs(new_val - last_v) < 1e-6:
return
# 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)
# ---------------------------------------------------------------------
# Table & Calculation
# ---------------------------------------------------------------------
def compute_time_to_100(self):
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):
from rich.progress import Progress, BarColumn, TextColumn
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 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 we only have 1 data point => show single row
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]
# 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]"
)
# 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
)
console.print(table)
console.print()
# 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 else "???"
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)
progress.refresh()
def main():
app = QApplication(sys.argv)
window = BorealisOverlay()
window.setWindowTitle("Project Borealis Overlay (Delta in Historical EXP)")
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()