Added HP, MP, and FP.
This commit is contained in:
parent
57e0c8de00
commit
01da38d05d
@ -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_())
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user