Added HP, MP, and FP.

This commit is contained in:
Nicole Rappe 2025-02-11 02:47:40 -07:00
parent 57e0c8de00
commit 01da38d05d

View File

@ -1,16 +1,30 @@
#!/usr/bin/env python3 #!/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 sys
import os import os
import time import time
import re import re
import pytesseract import pytesseract
import numpy as np
from PyQt5.QtWidgets import QApplication, QWidget from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtCore import Qt, QRect, QPoint, QTimer from PyQt5.QtCore import Qt, QRect, QPoint, QTimer
from PyQt5.QtGui import QPainter, QPen, QColor, QFont 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.console import Console
from rich.table import Table from rich.table import Table
@ -19,18 +33,39 @@ from rich.progress import Progress, BarColumn, TextColumn
# ---- [ Global Config ] ---- # ---- [ Global Config ] ----
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
OCR_ENGINE = "Tesseract" OCR_ENGINE = "Tesseract"
POLLING_RATE_MS = 1000 POLLING_RATE_MS = 1000
MAX_DATA_POINTS = 7 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 HANDLE_SIZE = 10
LABEL_HEIGHT = 20 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): 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_percent = raw_text.replace('%', '')
text_no_spaces = text_no_percent.replace(' ', '') text_no_spaces = text_no_percent.replace(' ', '')
cleaned = re.sub(r'[^0-9\.]', '', text_no_spaces) cleaned = re.sub(r'[^0-9\.]', '', text_no_spaces)
@ -38,15 +73,20 @@ def sanitize_experience_string(raw_text):
if not match: if not match:
return None return None
val = float(match.group(0)) val = float(match.group(0))
if val < 0:
val = 0
elif val > 100:
val = 100
return round(val, 4) return round(val, 4)
def format_experience_value(value): 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: if value < 0:
value = 0 value = 0
elif value > 100: elif value > 100:
value = 100 value = 100
float_4 = round(value, 4) float_4 = round(value, 4)
raw_str = f"{float_4:.4f}" raw_str = f"{float_4:.4f}"
int_part, dec_part = raw_str.split('.') int_part, dec_part = raw_str.split('.')
@ -58,23 +98,11 @@ def format_experience_value(value):
int_part = "00" int_part = "00"
return f"{int_part}.{dec_part}" 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: 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.x = x
self.y = y self.y = y
self.w = DEFAULT_WIDTH self.w = DEFAULT_WIDTH
@ -91,36 +119,44 @@ class Region:
return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT) return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT)
def resize_handles(self): def resize_handles(self):
"""
Return four small rectangles (handles) for resizing each corner.
"""
return [ return [
QRect(self.x - HANDLE_SIZE // 2, self.y - 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), 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), 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), QRect(self.x + self.w - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # bottom-right
] ]
class OverlayCanvas(QWidget): class OverlayCanvas(QWidget):
"""
Renders the overlay & handles region dragging/resizing.
"""
def __init__(self, regions, parent=None): def __init__(self, regions, parent=None):
super().__init__(parent) super().__init__(parent)
self.regions = regions self.regions = regions
self.edit_mode = True self.edit_mode = True # allow editing by default
self.selected_region = None self.selected_region = None
self.selected_handle = None self.selected_handle = None
self.drag_offset = QPoint() self.drag_offset = QPoint()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
for region in self.regions: for region in self.regions:
if region.visible: if region.visible:
# Draw the bounding rectangle
pen = QPen(region.color) pen = QPen(region.color)
pen.setWidth(3) pen.setWidth(3)
painter.setPen(pen) painter.setPen(pen)
painter.drawRect(region.x, region.y, region.w, region.h) painter.drawRect(region.x, region.y, region.w, region.h)
# Draw the region label
painter.setFont(QFont("Arial", 12, QFont.Bold)) painter.setFont(QFont("Arial", 12, QFont.Bold))
painter.setPen(region.color) painter.setPen(region.color)
painter.drawText(region.x, region.y - 5, region.label) painter.drawText(region.x, region.y - 5, region.label)
# If in edit mode, show corner handles
if self.edit_mode: if self.edit_mode:
for handle in region.resize_handles(): for handle in region.resize_handles():
painter.fillRect(handle, region.color) painter.fillRect(handle, region.color)
@ -130,14 +166,15 @@ class OverlayCanvas(QWidget):
return return
if event.button() == Qt.LeftButton: if event.button() == Qt.LeftButton:
# Check topmost region first (reverse if multiple)
for region in reversed(self.regions): for region in reversed(self.regions):
# Check each handle # Check each resize handle
for i, handle in enumerate(region.resize_handles()): for i, handle in enumerate(region.resize_handles()):
if handle.contains(event.pos()): if handle.contains(event.pos()):
self.selected_region = region self.selected_region = region
self.selected_handle = i self.selected_handle = i
return return
# Check label or main rect # Check label or main rect for dragging
if region.label_rect().contains(event.pos()): if region.label_rect().contains(event.pos()):
self.selected_region = region self.selected_region = region
self.selected_handle = None self.selected_handle = None
@ -160,26 +197,28 @@ class OverlayCanvas(QWidget):
else: else:
# Resize # Resize
sr = self.selected_region 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.w += sr.x - event.x()
sr.h += sr.y - event.y() sr.h += sr.y - event.y()
sr.x = event.x() sr.x = event.x()
sr.y = event.y() 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.w = event.x() - sr.x
sr.h += sr.y - event.y() sr.h += sr.y - event.y()
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.w += sr.x - event.x()
sr.h = event.y() - sr.y sr.h = event.y() - sr.y
sr.x = event.x() 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.w = event.x() - sr.x
sr.h = event.y() - sr.y sr.h = event.y() - sr.y
# Enforce min size
sr.w = max(sr.w, 10) sr.w = max(sr.w, 10)
sr.h = max(sr.h, 10) sr.h = max(sr.h, 10)
self.update()
self.update() # repaint
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
if not self.edit_mode: if not self.edit_mode:
@ -189,33 +228,39 @@ class OverlayCanvas(QWidget):
self.selected_handle = None self.selected_handle = None
class BorealisOverlay(QWidget): class BorealisOverlay(QWidget):
"""
Single Region Overlay for Player Stats (HP/MP/FP/EXP)
"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
screen_geo = QApplication.primaryScreen().geometry()
screen_geometry = QApplication.primaryScreen().geometry() self.setGeometry(screen_geo)
self.setGeometry(screen_geometry)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_TranslucentBackground, True) 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 = OverlayCanvas(self.regions, self)
self.canvas.setGeometry(self.rect()) self.canvas.setGeometry(self.rect())
# Tesseract only # Tesseract
self.engine = pytesseract self.engine = pytesseract
# Keep history of EXP data
self.points = [] self.points = []
# Timer for periodic OCR scanning
self.timer = QTimer(self) self.timer = QTimer(self)
self.timer.timeout.connect(self.collect_ocr_data) self.timer.timeout.connect(self.collect_ocr_data)
self.timer.start(POLLING_RATE_MS) self.timer.start(POLLING_RATE_MS)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# OCR & Data # OCR
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def collect_ocr_data(self): def collect_ocr_data(self):
if not self.engine:
return
for region in self.regions: for region in self.regions:
if region.visible: if region.visible:
screenshot = ImageGrab.grab( screenshot = ImageGrab.grab(
@ -223,36 +268,75 @@ class BorealisOverlay(QWidget):
) )
processed = self.preprocess_image(screenshot) 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() 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() self.display_ocr_data_in_terminal()
def preprocess_image(self, image): def preprocess_image(self, image):
"""
Convert to grayscale, scale up, threshold, median filter
for improved Tesseract accuracy.
"""
gray = image.convert("L") gray = image.convert("L")
scaled = gray.resize( scaled = gray.resize((gray.width * 3, gray.height * 3))
(gray.width * 3, gray.height * 3),
resample=Image.Resampling.LANCZOS
)
thresh = scaled.point(lambda p: p > 200 and 255) thresh = scaled.point(lambda p: p > 200 and 255)
return thresh.filter(ImageFilter.MedianFilter(3)) 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): def update_points(self, new_val):
"""
Track historical EXP changes for table & predicted time to level.
"""
now = time.time() now = time.time()
if self.points: if self.points:
_, last_v = self.points[-1] _, last_v = self.points[-1]
# skip duplicates # skip duplicates
if abs(new_val - last_v) < 1e-6: if abs(new_val - last_v) < 1e-6:
return return
# rollover # if new_val < last_v, assume rollover
if new_val < last_v: if new_val < last_v:
self.points.clear() self.points.clear()
self.points.append((now, new_val)) self.points.append((now, new_val))
@ -260,9 +344,12 @@ class BorealisOverlay(QWidget):
self.points.pop(0) self.points.pop(0)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Table & Calculation # Display
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
def compute_time_to_100(self): def compute_time_to_100(self):
"""
Estimate time to reach 100% from current EXP data.
"""
n = len(self.points) n = len(self.points)
if n < 2: if n < 2:
return None return None
@ -290,18 +377,66 @@ class BorealisOverlay(QWidget):
return int(remain / rate_per_s) return int(remain / rate_per_s)
def display_ocr_data_in_terminal(self): 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() console = Console()
os.system('cls' if os.name == 'nt' else 'clear') os.system('cls' if os.name == 'nt' else 'clear')
# Title
console.print("[bold white]Project Borealis[/bold white]") console.print("[bold white]Project Borealis[/bold white]")
console.print("[dim]Flyff Information Overlay[/dim]\n") console.print("[dim]Flyff Information Overlay[/dim]\n")
console.print(f"[bold]OCR Engine[/bold]: {OCR_ENGINE}") # Parse stats
console.print(f"[bold]Data Polling Rate[/bold]: {POLLING_RATE_MS/1000}s\n") 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 = Table(show_header=True, header_style=GREEN_HEADER_STYLE, style=None)
table.add_column("Historical EXP", justify="center", style="green") table.add_column("Historical EXP", justify="center", style="green")
table.add_column("Time Since Last Kill", 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") table.add_column("Average Time Between Kills", justify="center", style="green")
n = len(self.points) n = len(self.points)
# If we only have 1 data point => show single row if n == 0:
if n == 1: table.add_row("N/A", "N/A", "N/A", "N/A")
t0, v0 = self.points[0] elif n == 1:
_, v0 = self.points[0]
exp_str = f"[green]{format_experience_value(v0)}%[/green]" exp_str = f"[green]{format_experience_value(v0)}%[/green]"
table.add_row(exp_str, "N/A", "N/A", "N/A") table.add_row(exp_str, "N/A", "N/A", "N/A")
else: else:
@ -319,61 +455,50 @@ class BorealisOverlay(QWidget):
t_cur, v_cur = self.points[i] t_cur, v_cur = self.points[i]
t_prev, v_prev = self.points[i - 1] t_prev, v_prev = self.points[i - 1]
# Calculate a difference
delta_v = v_cur - v_prev delta_v = v_cur - v_prev
delta_str = f"{delta_v:+.4f}%" 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_main = format_experience_value(v_cur)
exp_str = ( exp_str = f"[green]{exp_main}%[/green] [dim]({delta_str})[/dim]"
f"[green]{exp_main}%[/green] "
f"[dim]({delta_str})[/dim]"
)
# Time since last kill
delta_t = t_cur - t_prev delta_t = t_cur - t_prev
t_since_str = f"{delta_t:.1f}s" t_since_str = f"{delta_t:.1f}s"
# average exp from first data point
diff_v = v_cur - self.points[0][1] diff_v = v_cur - self.points[0][1]
steps = i steps = i
avg_exp_str = f"{diff_v/steps:.4f}%" avg_exp_str = f"{diff_v/steps:.4f}%"
# average time from first data point
total_time = t_cur - self.points[0][0] total_time = t_cur - self.points[0][0]
avg_kill_time = total_time / steps avg_kill_time = total_time / steps
avg_time_str = f"{avg_kill_time:.1f}s" avg_time_str = f"{avg_kill_time:.1f}s"
table.add_row( table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str)
exp_str,
t_since_str,
avg_exp_str,
avg_time_str
)
console.print(table) 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 current_exp = self.points[-1][1] if self.points else 0.0
secs_left = self.compute_time_to_100() 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( with Progress(
TextColumn("[bold white]Predicted Time to Level:[/bold white] "), 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(" [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, console=console,
transient=False, transient=False,
) as progress: ) as progress:
task_id = progress.add_task("", total=100, completed=current_exp) progress.add_task("", total=100, completed=current_exp)
progress.refresh() progress.refresh()
def main(): def main():
"""
Launches the PyQt5 overlay, starts the event loop.
"""
app = QApplication(sys.argv) app = QApplication(sys.argv)
window = BorealisOverlay() window = BorealisOverlay()
window.setWindowTitle("Project Borealis Overlay (Delta in Historical EXP)") window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)")
window.show() window.show()
sys.exit(app.exec_()) sys.exit(app.exec_())