Borealis-Legacy/borealis_overlay.py

582 lines
20 KiB
Python

#!/usr/bin/env python3
import sys
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, Group
from rich.table import Table
from rich.progress import Progress, BarColumn, TextColumn
from rich.text import Text
from rich.live import Live
# ---- [ Global Config ] ----
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
OCR_ENGINE = "Tesseract"
POLLING_RATE_MS = 500
MAX_DATA_POINTS = 8
DEFAULT_WIDTH = 175
DEFAULT_HEIGHT = 145
HANDLE_SIZE = 7
LABEL_HEIGHT = 20
GREEN_HEADER_STYLE = "bold green"
# -----------------------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------------------
def format_duration(seconds):
"""
INFORMATION PROCESSING:
-----------------------
Convert total seconds into hours/min/seconds (e.g., "Xh Ym Zs").
Returns '???' if the input is None or invalid.
"""
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):
"""
INFORMATION PROCESSING:
-----------------------
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):
"""
INFORMATION DISPLAY (formatting):
---------------------------------
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}"
# -----------------------------------------------------------------------------
# Region Class
# -----------------------------------------------------------------------------
class Region:
"""
DATA STRUCTURE:
---------------
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
]
# -----------------------------------------------------------------------------
# OverlayCanvas Class
# -----------------------------------------------------------------------------
class OverlayCanvas(QWidget):
"""
UI RENDERING LOGIC:
-------------------
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
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
# -----------------------------------------------------------------------------
# BorealisOverlay Class
# -----------------------------------------------------------------------------
class BorealisOverlay(QWidget):
"""
MAIN APPLICATION LOGIC:
-----------------------
Single Region Overlay for Player Stats (HP/MP/FP/EXP) with OCR scanning.
"""
def __init__(self, live=None):
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="Character Status")
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 = []
# We will store a reference to Rich.Live here
self.live = live
# Timer for periodic OCR scanning
self.timer = QTimer(self)
self.timer.timeout.connect(self.collect_ocr_data)
self.timer.start(POLLING_RATE_MS)
def set_live(self, live):
"""
Called by main() so we can update the Live object from inside this class.
"""
self.live = live
# -------------------------------------------------------------------------
# OCR
# -------------------------------------------------------------------------
def collect_ocr_data(self):
"""
INFORMATION GATHERING:
----------------------
Periodically invoked by QTimer. Captures region screenshot, OCR's it,
and triggers the terminal display update.
"""
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()
# Instead of printing directly, we now build a Rich renderable and update Live.
if self.live is not None:
renderable = self.build_renderable()
self.live.update(renderable)
def preprocess_image(self, image):
"""
INFORMATION PROCESSING:
-----------------------
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):
"""
INFORMATION ANALYSIS:
---------------------
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):
"""
INFORMATION TRACKING:
---------------------
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 Logic
# -------------------------------------------------------------------------
def compute_time_to_100(self):
"""
INFORMATION PREDICTION:
-----------------------
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 build_renderable(self):
"""
INFORMATION DISPLAY (Rich):
---------------------------
Construct a single Rich renderable (Group) that includes:
- Title
- HP/MP/FP progress bars
- Historical EXP table
- Predicted time progress bar
"""
# Gather stats from first region
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"]
# Update historical EXP points if valid
if exp_val is not None:
self.update_points(exp_val)
current_exp = self.points[-1][1] if self.points else 0.0
# ---------------------
# 1) Title Section
# ---------------------
title_text = Text("Project Borealis\n", style="bold white")
subtitle_text = Text("Flyff Information Overlay\n\n", style="dim")
# ---------------------
# 2) HP / MP / FP Bars
# ---------------------
bar_progress = Progress(
"{task.description}",
BarColumn(bar_width=30),
TextColumn(" {task.completed}/{task.total} ({task.percentage:>5.2f}%)"),
transient=False,
auto_refresh=False # We'll refresh after all tasks are added
)
# HP
hp_task = bar_progress.add_task(
"[bold red]HP[/bold red]",
total=hp_max,
completed=hp_cur,
style="red",
complete_style="red"
)
# MP
mp_task = bar_progress.add_task(
"[bold blue]MP[/bold blue]",
total=mp_max,
completed=mp_cur,
style="blue",
complete_style="blue"
)
# FP
fp_task = bar_progress.add_task(
"[bold green]FP[/bold green]",
total=fp_max,
completed=fp_cur,
style="green",
complete_style="green"
)
bar_progress.refresh()
# ---------------------
# 3) 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)
# ---------------------
# 4) Predicted Time to Level
# ---------------------
secs_left = self.compute_time_to_100()
time_str = format_duration(secs_left)
time_bar = 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"),
transient=False,
auto_refresh=False
)
t_task = time_bar.add_task("", total=100, completed=current_exp)
time_bar.refresh()
# Combine everything into a Rich Group
# Title + Subtitle + HP/MP/FP Progress + Table + Time Bar
return Group(
title_text,
subtitle_text,
bar_progress, # HP/MP/FP
table,
time_bar # predicted-time progress
)
# -----------------------------------------------------------------------------
# main()
# -----------------------------------------------------------------------------
def main():
"""
LAUNCH SEQUENCE:
---------------
1) Create QApplication.
2) Create BorealisOverlay Window.
3) Use Rich Live to continuously update terminal output with no flicker.
4) Start PyQt event loop.
"""
app = QApplication(sys.argv)
window = BorealisOverlay() # We'll inject Live momentarily
window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)")
window.show()
console = Console()
# Use a Live context manager so we can do partial updates
with Live(console=console, refresh_per_second=4) as live:
# Pass the live object to our BorealisOverlay so it can call live.update()
window.set_live(live)
# Run the PyQt event loop (blocking)
exit_code = app.exec_()
sys.exit(exit_code)
if __name__ == "__main__":
main()