Restructured the project and optimized several nodes.

This commit is contained in:
2025-02-16 18:53:21 -07:00
parent 670ae774ef
commit e30ba4ec4f
32 changed files with 390 additions and 576 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,326 +1,206 @@
#!/usr/bin/env python3
# Modules/data_collector.py
import threading
import time
import re
import threading
import numpy as np
import cv2
import pytesseract
from flask import Flask, jsonify
from PIL import Image, ImageGrab, ImageFilter
from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtCore import QTimer, QRect, QPoint, Qt, QMutex
from PyQt5.QtWidgets import QWidget, QApplication
from PyQt5.QtCore import QRect, QPoint, Qt, QMutex, QTimer
from PyQt5.QtGui import QPainter, QPen, QColor, QFont
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
# =============================================================================
# Global Config
# =============================================================================
POLLING_RATE_MS = 500
MAX_DATA_POINTS = 8
DEFAULT_WIDTH = 180
DEFAULT_HEIGHT = 130
HANDLE_SIZE = 8
LABEL_HEIGHT = 20
# Template Matching Threshold (Define it here)
MATCH_THRESHOLD = 0.4 # Set to 0.4 as a typical value for correlation threshold
collector_mutex = QMutex()
regions = {}
overlay_window = None
# Flask API setup
app = Flask(__name__)
# **Shared Region Data (Thread-Safe)**
region_lock = QMutex() # Mutex to synchronize access between UI and OCR thread
shared_region = {
"x": 250,
"y": 50,
"w": DEFAULT_WIDTH,
"h": DEFAULT_HEIGHT
}
# Global variable for OCR data
latest_data = {
"hp_current": 0,
"hp_total": 0,
"mp_current": 0,
"mp_total": 0,
"fp_current": 0,
"fp_total": 0,
"exp": 0.0000
}
# =============================================================================
# OCR Data Collection
# =============================================================================
def preprocess_image(image):
"""
Preprocess the image for OCR: convert to grayscale, resize, and apply thresholding.
"""
gray = image.convert("L") # Convert to grayscale
scaled = gray.resize((gray.width * 3, gray.height * 3)) # Upscale the image for better accuracy
thresh = scaled.point(lambda p: p > 200 and 255) # Apply a threshold to clean up the image
return thresh.filter(ImageFilter.MedianFilter(3)) # Apply a median filter to remove noise
def sanitize_experience_string(raw_text):
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 locate_bars_opencv(template_path, threshold=MATCH_THRESHOLD):
"""
Attempt to locate the bars via OpenCV template matching.
"""
screenshot_pil = ImageGrab.grab()
screenshot_np = np.array(screenshot_pil)
screenshot_bgr = cv2.cvtColor(screenshot_np, cv2.COLOR_RGB2BGR)
template_bgr = cv2.imread(template_path, cv2.IMREAD_COLOR)
if template_bgr is None:
return None
result = cv2.matchTemplate(screenshot_bgr, template_bgr, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
th, tw, _ = template_bgr.shape
if max_val >= threshold:
found_x, found_y = max_loc
return (found_x, found_y, tw, th)
else:
return None
def parse_all_stats(raw_text):
raw_lines = raw_text.splitlines()
lines = [l.strip() for l in raw_lines if l.strip()]
stats_dict = {
"hp": (0,1),
"mp": (0,1),
"fp": (0,1),
"exp": None
def create_ocr_region(region_id, x=250, y=50, w=DEFAULT_WIDTH, h=DEFAULT_HEIGHT):
collector_mutex.lock()
if region_id in regions:
collector_mutex.unlock()
return
regions[region_id] = {
'bbox': [x, y, w, h],
'raw_text': ""
}
if len(lines) < 4:
return stats_dict
collector_mutex.unlock()
_ensure_overlay()
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)))
def get_raw_text(region_id):
collector_mutex.lock()
if region_id not in regions:
collector_mutex.unlock()
return ""
text = regions[region_id]['raw_text']
collector_mutex.unlock()
return text
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)))
def start_collector():
t = threading.Thread(target=_update_ocr_loop, daemon=True)
t.start()
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_val = sanitize_experience_string(lines[3])
stats_dict["exp"] = exp_val
return stats_dict
# =============================================================================
# Region & UI
# =============================================================================
class Region:
"""
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 [
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),
]
# =============================================================================
# Flask API Server
# =============================================================================
@app.route('/data')
def get_data():
"""Returns the latest OCR data as JSON."""
return jsonify(latest_data)
def collect_ocr_data():
"""
Collects OCR data every 0.5 seconds and updates global latest_data.
"""
global latest_data
def _update_ocr_loop():
while True:
# **Fetch updated region values from UI (thread-safe)**
region_lock.lock() # Lock for thread safety
x, y, w, h = shared_region["x"], shared_region["y"], shared_region["w"], shared_region["h"]
region_lock.unlock()
collector_mutex.lock()
region_ids = list(regions.keys())
collector_mutex.unlock()
# **Grab the image of the updated region**
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
for rid in region_ids:
collector_mutex.lock()
bbox = regions[rid]['bbox'][:]
collector_mutex.unlock()
# **Debug: Save screenshots to verify capture**
screenshot.save("debug_screenshot.png")
x, y, w, h = bbox
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
processed = _preprocess_image(screenshot)
raw_text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
# Preprocess image
processed = preprocess_image(screenshot)
processed.save("debug_processed.png") # Debug: Save processed image
collector_mutex.lock()
if rid in regions:
regions[rid]['raw_text'] = raw_text
collector_mutex.unlock()
# Run OCR
text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
time.sleep(0.7)
stats = parse_all_stats(text.strip())
hp_cur, hp_max = stats["hp"]
mp_cur, mp_max = stats["mp"]
fp_cur, fp_max = stats["fp"]
exp_val = stats["exp"]
# Update latest data
latest_data = {
"hp_current": hp_cur,
"hp_total": hp_max,
"mp_current": mp_cur,
"mp_total": mp_max,
"fp_current": fp_cur,
"fp_total": fp_max,
"exp": exp_val
}
def _preprocess_image(image):
gray = image.convert("L")
scaled = gray.resize((gray.width * 3, gray.height * 3))
thresh = scaled.point(lambda p: 255 if p > 200 else 0)
return thresh.filter(ImageFilter.MedianFilter(3))
# DEBUG OUTPUT
print(f"Flyff - Character Status: HP: {hp_cur}/{hp_max}, MP: {mp_cur}/{mp_max}, FP: {fp_cur}/{fp_max}, EXP: {exp_val}%")
def _ensure_overlay():
"""
Creates the overlay window if none exists.
If no QApplication instance is running yet, schedule the creation after
the main application event loop starts (to avoid "Must construct a QApplication first" errors).
"""
global overlay_window
if overlay_window is not None:
return
time.sleep(0.5)
# If there's already a running QApplication, create overlay immediately.
if QApplication.instance() is not None:
overlay_window = OverlayCanvas()
overlay_window.show()
else:
# Schedule creation for when the app event loop is up.
def delayed_create():
global overlay_window
if overlay_window is None:
overlay_window = OverlayCanvas()
overlay_window.show()
# =============================================================================
# OverlayCanvas (UI)
# =============================================================================
QTimer.singleShot(0, delayed_create)
class OverlayCanvas(QWidget):
"""
UI overlay that allows dragging/resizing of the OCR region.
"""
def __init__(self, parent=None):
super().__init__(parent)
# **Full-screen overlay**
screen_geo = QApplication.primaryScreen().geometry()
self.setGeometry(screen_geo) # Set to full screen
self.setGeometry(screen_geo)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_TranslucentBackground, True)
# **Load shared region**
self.region = shared_region
self.drag_offset = None
self.selected_handle = None
self.selected_region_id = None
def paintEvent(self, event):
"""Draw the blue OCR region."""
painter = QPainter(self)
pen = QPen(QColor(0, 0, 255))
pen.setWidth(5) # Thicker lines
pen.setWidth(5)
painter.setPen(pen)
painter.drawRect(self.region["x"], self.region["y"], self.region["w"], self.region["h"])
painter.setFont(QFont("Arial", 12, QFont.Bold))
painter.setPen(QColor(0, 0, 255))
painter.drawText(self.region["x"], self.region["y"] - 5, "Character Status")
collector_mutex.lock()
region_copy = {rid: data['bbox'][:] for rid, data in regions.items()}
collector_mutex.unlock()
for rid, bbox in region_copy.items():
x, y, w, h = bbox
painter.drawRect(x, y, w, h)
painter.setFont(QFont("Arial", 12, QFont.Bold))
painter.setPen(QColor(0, 0, 255))
painter.drawText(x, y - 5, f"OCR Region: {rid}")
def mousePressEvent(self, event):
"""Handle drag and resize interactions."""
if event.button() == Qt.LeftButton:
region_lock.lock() # Lock for thread safety
x, y, w, h = self.region["x"], self.region["y"], self.region["w"], self.region["h"]
region_lock.unlock()
collector_mutex.lock()
all_items = list(regions.items())
collector_mutex.unlock()
for i, handle in enumerate(self.resize_handles()):
if handle.contains(event.pos()):
self.selected_handle = i
for rid, data in all_items:
x, y, w, h = data['bbox']
handles = self._resize_handles(x, y, w, h)
for i, handle_rect in enumerate(handles):
if handle_rect.contains(event.pos()):
self.selected_handle = i
self.selected_region_id = rid
return
if QRect(x, y, w, h).contains(event.pos()):
self.drag_offset = event.pos() - QPoint(x, y)
self.selected_region_id = rid
return
if QRect(x, y, w, h).contains(event.pos()):
self.drag_offset = event.pos() - QPoint(x, y)
def mouseMoveEvent(self, event):
"""Allow dragging and resizing."""
if not self.selected_region_id:
return
collector_mutex.lock()
if self.selected_region_id not in regions:
collector_mutex.unlock()
return
bbox = regions[self.selected_region_id]['bbox']
collector_mutex.unlock()
x, y, w, h = bbox
if self.selected_handle is not None:
region_lock.lock()
sr = self.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: # 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)
region_lock.unlock()
# resizing
if self.selected_handle == 0: # top-left
new_w = w + (x - event.x())
new_h = h + (y - event.y())
new_x = event.x()
new_y = event.y()
if new_w < 10: new_w = 10
if new_h < 10: new_h = 10
collector_mutex.lock()
if self.selected_region_id in regions:
regions[self.selected_region_id]['bbox'] = [new_x, new_y, new_w, new_h]
collector_mutex.unlock()
elif self.selected_handle == 1: # bottom-right
new_w = event.x() - x
new_h = event.y() - y
if new_w < 10: new_w = 10
if new_h < 10: new_h = 10
collector_mutex.lock()
if self.selected_region_id in regions:
regions[self.selected_region_id]['bbox'] = [x, y, new_w, new_h]
collector_mutex.unlock()
self.update()
elif self.drag_offset:
region_lock.lock()
self.region["x"] = event.x() - self.drag_offset.x()
self.region["y"] = event.y() - self.drag_offset.y()
region_lock.unlock()
# dragging
new_x = event.x() - self.drag_offset.x()
new_y = event.y() - self.drag_offset.y()
collector_mutex.lock()
if self.selected_region_id in regions:
regions[self.selected_region_id]['bbox'][0] = new_x
regions[self.selected_region_id]['bbox'][1] = new_y
collector_mutex.unlock()
self.update()
def mouseReleaseEvent(self, event):
"""End drag or resize event."""
self.selected_handle = None
self.drag_offset = None
self.selected_region_id = None
def resize_handles(self):
"""Get the resizing handles of the region."""
def _resize_handles(self, x, y, w, h):
return [
QRect(self.region["x"] - HANDLE_SIZE // 2, self.region["y"] - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
QRect(self.region["x"] + self.region["w"] - HANDLE_SIZE // 2, self.region["y"] + self.region["h"] - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE)
QRect(x - HANDLE_SIZE//2, y - HANDLE_SIZE//2, HANDLE_SIZE, HANDLE_SIZE), # top-left
QRect(x + w - HANDLE_SIZE//2, y + h - HANDLE_SIZE//2, HANDLE_SIZE, HANDLE_SIZE) # bottom-right
]
# =============================================================================
# Start Application
# =============================================================================
def run_flask_app():
"""Runs the Flask API server in a separate thread."""
app.run(host="127.0.0.1", port=5000)
if __name__ == '__main__':
# Start the OCR thread
collector_thread = threading.Thread(target=collect_ocr_data, daemon=True)
collector_thread.start()
# Start the Flask API thread
flask_thread = threading.Thread(target=run_flask_app, daemon=True)
flask_thread.start()
# Start PyQt5 GUI
app_gui = QApplication([])
overlay_window = OverlayCanvas()
overlay_window.show()
# Run event loop
app_gui.exec_()

66
Modules/data_manager.py Normal file
View File

@ -0,0 +1,66 @@
# Modules/data_manager.py
import threading
import time
from flask import Flask, jsonify
from PyQt5.QtCore import QMutex
# Global datastore for character metrics
data_store = {
"hp_current": 0,
"hp_total": 0,
"mp_current": 0,
"mp_total": 0,
"fp_current": 0,
"fp_total": 0,
"exp": 0.0
}
# Mutex for thread safety
data_mutex = QMutex()
# Flag to ensure only one character status collector node exists
character_status_collector_exists = False
# Flask Application
app = Flask(__name__)
@app.route('/data')
def data_api():
"""
Returns the current character metrics as JSON.
"""
return jsonify(get_data())
def start_api_server():
"""
Starts the Flask API server in a separate daemon thread.
"""
def run():
app.run(host="127.0.0.1", port=5000)
t = threading.Thread(target=run, daemon=True)
t.start()
def get_data():
"""
Return a copy of the global data_store.
"""
data_mutex.lock()
data_copy = data_store.copy()
data_mutex.unlock()
return data_copy
def set_data(key, value):
"""
Set a single metric in the global data_store.
"""
data_mutex.lock()
data_store[key] = value
data_mutex.unlock()
def set_data_bulk(metrics_dict):
"""
Update multiple metrics in the global data_store at once.
"""
data_mutex.lock()
data_store.update(metrics_dict)
data_mutex.unlock()