Restructured the project and optimized several nodes.
This commit is contained in:
Binary file not shown.
Binary file not shown.
+172
-292
@@ -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**
|
||||
for rid in region_ids:
|
||||
collector_mutex.lock()
|
||||
bbox = regions[rid]['bbox'][:]
|
||||
collector_mutex.unlock()
|
||||
|
||||
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')
|
||||
|
||||
# **Debug: Save screenshots to verify capture**
|
||||
screenshot.save("debug_screenshot.png")
|
||||
collector_mutex.lock()
|
||||
if rid in regions:
|
||||
regions[rid]['raw_text'] = raw_text
|
||||
collector_mutex.unlock()
|
||||
|
||||
# Preprocess image
|
||||
processed = preprocess_image(screenshot)
|
||||
processed.save("debug_processed.png") # Debug: Save processed image
|
||||
time.sleep(0.7)
|
||||
|
||||
# Run OCR
|
||||
text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
|
||||
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))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# 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}%")
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# =============================================================================
|
||||
# OverlayCanvas (UI)
|
||||
# =============================================================================
|
||||
|
||||
class OverlayCanvas(QWidget):
|
||||
def _ensure_overlay():
|
||||
"""
|
||||
UI overlay that allows dragging/resizing of the OCR region.
|
||||
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).
|
||||
"""
|
||||
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.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
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Draw the blue OCR region."""
|
||||
painter = QPainter(self)
|
||||
pen = QPen(QColor(0, 0, 255))
|
||||
pen.setWidth(5) # Thicker lines
|
||||
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")
|
||||
|
||||
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()
|
||||
|
||||
for i, handle in enumerate(self.resize_handles()):
|
||||
if handle.contains(event.pos()):
|
||||
self.selected_handle = i
|
||||
global overlay_window
|
||||
if overlay_window is not None:
|
||||
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 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()
|
||||
|
||||
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()
|
||||
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
"""End drag or resize event."""
|
||||
self.selected_handle = None
|
||||
self.drag_offset = None
|
||||
|
||||
def resize_handles(self):
|
||||
"""Get the resizing handles of the region."""
|
||||
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)
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# 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([])
|
||||
# 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()
|
||||
|
||||
# Run event loop
|
||||
app_gui.exec_()
|
||||
QTimer.singleShot(0, delayed_create)
|
||||
|
||||
class OverlayCanvas(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
screen_geo = QApplication.primaryScreen().geometry()
|
||||
self.setGeometry(screen_geo)
|
||||
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||
|
||||
self.drag_offset = None
|
||||
self.selected_handle = None
|
||||
self.selected_region_id = None
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
pen = QPen(QColor(0, 0, 255))
|
||||
pen.setWidth(5)
|
||||
painter.setPen(pen)
|
||||
|
||||
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):
|
||||
if event.button() == Qt.LeftButton:
|
||||
collector_mutex.lock()
|
||||
all_items = list(regions.items())
|
||||
collector_mutex.unlock()
|
||||
|
||||
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
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
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:
|
||||
# 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:
|
||||
# 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):
|
||||
self.selected_handle = None
|
||||
self.drag_offset = None
|
||||
self.selected_region_id = None
|
||||
|
||||
def _resize_handles(self, x, y, w, h):
|
||||
return [
|
||||
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
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Flyff Character Status Node (New Version):
|
||||
- Has no inputs/outputs.
|
||||
- Creates an OCR region in data_collector.
|
||||
- Periodically grabs raw text from that region, parses it here in the node,
|
||||
and sets data_manager's HP, MP, FP, EXP accordingly.
|
||||
- Also updates its own text fields with the parsed values.
|
||||
"""
|
||||
|
||||
import re
|
||||
from OdenGraphQt import BaseNode
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
from Modules import data_manager, data_collector
|
||||
|
||||
class FlyffCharacterStatusNode(BaseNode):
|
||||
__identifier__ = 'bunny-lab.io.flyff_character_status_node'
|
||||
NODE_NAME = 'Flyff - Character Status'
|
||||
|
||||
def __init__(self):
|
||||
super(FlyffCharacterStatusNode, self).__init__()
|
||||
# Prevent duplicates
|
||||
if data_manager.character_status_collector_exists:
|
||||
QMessageBox.critical(None, "Error", "Only one Flyff Character Status Collector node is allowed.")
|
||||
raise Exception("Duplicate Character Status Node.")
|
||||
data_manager.character_status_collector_exists = True
|
||||
|
||||
# Add text fields for display
|
||||
self.add_text_input('hp', 'HP', text="HP: 0/0")
|
||||
self.add_text_input('mp', 'MP', text="MP: 0/0")
|
||||
self.add_text_input('fp', 'FP', text="FP: 0/0")
|
||||
self.add_text_input('exp', 'EXP', text="EXP: 0%")
|
||||
|
||||
# Create a unique region id for this node (or just "character_status")
|
||||
self.region_id = "character_status"
|
||||
data_collector.create_ocr_region(self.region_id, x=250, y=50, w=180, h=130)
|
||||
|
||||
# Start the data_collector background thread (if not already started)
|
||||
data_collector.start_collector()
|
||||
|
||||
# Set the node name
|
||||
self.set_name("Flyff - Character Status")
|
||||
|
||||
def parse_character_stats(self, raw_text):
|
||||
"""
|
||||
Extract HP, MP, FP, EXP from the raw OCR text lines.
|
||||
"""
|
||||
lines = [l.strip() for l in raw_text.splitlines() if l.strip()]
|
||||
hp_current, hp_total = 0, 0
|
||||
mp_current, mp_total = 0, 0
|
||||
fp_current, fp_total = 0, 0
|
||||
exp_value = 0.0
|
||||
|
||||
if len(lines) >= 4:
|
||||
# line 1: HP
|
||||
hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0])
|
||||
if hp_match:
|
||||
hp_current = int(hp_match.group(1))
|
||||
hp_total = int(hp_match.group(2))
|
||||
|
||||
# line 2: MP
|
||||
mp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[1])
|
||||
if mp_match:
|
||||
mp_current = int(mp_match.group(1))
|
||||
mp_total = int(mp_match.group(2))
|
||||
|
||||
# line 3: FP
|
||||
fp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[2])
|
||||
if fp_match:
|
||||
fp_current = int(fp_match.group(1))
|
||||
fp_total = int(fp_match.group(2))
|
||||
|
||||
# line 4: EXP
|
||||
exp_match = re.search(r"(\d+(?:\.\d+)?)", lines[3])
|
||||
if exp_match:
|
||||
val = float(exp_match.group(1))
|
||||
if val < 0: val = 0
|
||||
if val > 100: val = 100
|
||||
exp_value = val
|
||||
|
||||
return hp_current, hp_total, mp_current, mp_total, fp_current, fp_total, exp_value
|
||||
|
||||
def process_input(self):
|
||||
"""
|
||||
Called periodically by the global timer in your main application (borealis.py).
|
||||
"""
|
||||
# Grab raw text from data_collector
|
||||
raw_text = data_collector.get_raw_text(self.region_id)
|
||||
|
||||
# Parse it
|
||||
hp_c, hp_t, mp_c, mp_t, fp_c, fp_t, exp_v = self.parse_character_stats(raw_text)
|
||||
|
||||
# Update data_manager
|
||||
data_manager.set_data_bulk({
|
||||
"hp_current": hp_c,
|
||||
"hp_total": hp_t,
|
||||
"mp_current": mp_c,
|
||||
"mp_total": mp_t,
|
||||
"fp_current": fp_c,
|
||||
"fp_total": fp_t,
|
||||
"exp": exp_v
|
||||
})
|
||||
|
||||
# Update the node's text fields
|
||||
self.set_property('hp', f"HP: {hp_c}/{hp_t}")
|
||||
self.set_property('mp', f"MP: {mp_c}/{mp_t}")
|
||||
self.set_property('fp', f"FP: {fp_c}/{fp_t}")
|
||||
self.set_property('exp', f"EXP: {exp_v}%")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,126 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Standardized Flyff Character Status Node:
|
||||
- Polls an API for character stats and updates values dynamically.
|
||||
- Uses a global update timer for processing.
|
||||
- Immediately transmits updated values to connected nodes.
|
||||
- If the API is unreachable, it will wait for 5 seconds before retrying
|
||||
and log the error only once per retry period.
|
||||
- Calls self.view.draw_node() after updating the node name, ensuring
|
||||
the node's bounding box recalculates even when the API is disconnected.
|
||||
- Port colors adjusted to match earlier styling.
|
||||
"""
|
||||
|
||||
from OdenGraphQt import BaseNode
|
||||
from Qt import QtCore
|
||||
import requests
|
||||
import traceback
|
||||
import time
|
||||
|
||||
class FlyffCharacterStatusNode(BaseNode):
|
||||
__identifier__ = 'bunny-lab.io.flyff_character_status_node'
|
||||
NODE_NAME = 'Flyff - Character Status'
|
||||
|
||||
def __init__(self):
|
||||
super(FlyffCharacterStatusNode, self).__init__()
|
||||
self.values = {
|
||||
"HP: Current": "N/A",
|
||||
"HP: Total": "N/A",
|
||||
"MP: Current": "N/A",
|
||||
"MP: Total": "N/A",
|
||||
"FP: Current": "N/A",
|
||||
"FP: Total": "N/A",
|
||||
"EXP": "N/A"
|
||||
}
|
||||
|
||||
# Set each output with a custom color:
|
||||
# (Choose values close to the screenshot for each port.)
|
||||
self.add_output('HP: Current', color=(126, 36, 57))
|
||||
self.add_output('HP: Total', color=(126, 36, 57))
|
||||
self.add_output('MP: Current', color=(35, 89, 144))
|
||||
self.add_output('MP: Total', color=(35, 89, 144))
|
||||
self.add_output('FP: Current', color=(36, 116, 32))
|
||||
self.add_output('FP: Total', color=(36, 116, 32))
|
||||
self.add_output('EXP', color=(48, 116, 143))
|
||||
|
||||
self.set_name("Flyff - Character Status (API Disconnected)")
|
||||
self.view.draw_node() # ensure bounding box updates initially
|
||||
|
||||
# Variables to handle API downtime gracefully:
|
||||
self._api_down = False
|
||||
self._last_api_attempt = 0
|
||||
self._retry_interval = 5 # seconds to wait before retrying after a failure
|
||||
self._last_error_printed = 0
|
||||
|
||||
def process_input(self):
|
||||
current_time = time.time()
|
||||
# If the API is down, only retry after _retry_interval seconds
|
||||
if self._api_down and (current_time - self._last_api_attempt < self._retry_interval):
|
||||
return
|
||||
|
||||
self._last_api_attempt = current_time
|
||||
try:
|
||||
response = requests.get("http://127.0.0.1:5000/data", timeout=1)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if isinstance(data, dict):
|
||||
mapping = {
|
||||
"hp_current": "HP: Current",
|
||||
"hp_total": "HP: Total",
|
||||
"mp_current": "MP: Current",
|
||||
"mp_total": "MP: Total",
|
||||
"fp_current": "FP: Current",
|
||||
"fp_total": "FP: Total",
|
||||
"exp": "EXP"
|
||||
}
|
||||
updated = False
|
||||
for key, value in data.items():
|
||||
if key in mapping:
|
||||
formatted_key = mapping[key]
|
||||
if str(value) != self.values.get(formatted_key, None):
|
||||
self.values[formatted_key] = str(value)
|
||||
updated = True
|
||||
self._api_down = False
|
||||
if updated:
|
||||
self.set_name("Flyff - Character Status (API Connected)")
|
||||
self.view.draw_node() # recalc bounding box on connect
|
||||
self.transmit_data()
|
||||
else:
|
||||
if current_time - self._last_error_printed >= self._retry_interval:
|
||||
print("[ERROR] Unexpected API response format (not a dict):", data)
|
||||
self._last_error_printed = current_time
|
||||
self.set_name("Flyff - Character Status (API Disconnected)")
|
||||
self.view.draw_node() # recalc bounding box on disconnect
|
||||
self._api_down = True
|
||||
else:
|
||||
if current_time - self._last_error_printed >= self._retry_interval:
|
||||
print(f"[ERROR] API request failed with status code {response.status_code}")
|
||||
self._last_error_printed = current_time
|
||||
self.set_name("Flyff - Character Status (API Disconnected)")
|
||||
self.view.draw_node()
|
||||
self._api_down = True
|
||||
except Exception as e:
|
||||
if current_time - self._last_error_printed >= self._retry_interval:
|
||||
print("[ERROR] Error polling API in CharacterStatusNode:", str(e))
|
||||
self._last_error_printed = current_time
|
||||
self.set_name("Flyff - Character Status (API Disconnected)")
|
||||
self.view.draw_node()
|
||||
self._api_down = True
|
||||
|
||||
def transmit_data(self):
|
||||
for stat, value in self.values.items():
|
||||
port = self.get_output(stat)
|
||||
if port and port.connected_ports():
|
||||
for connected_port in port.connected_ports():
|
||||
connected_node = connected_port.node()
|
||||
if hasattr(connected_node, 'receive_data'):
|
||||
try:
|
||||
connected_node.receive_data(value, stat)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Error transmitting data to {connected_node}: {e}")
|
||||
print("[ERROR] Stack Trace:\n", traceback.format_exc())
|
||||
|
||||
def receive_data(self, data, source_port_name=None):
|
||||
# This node only transmits data; it does not receive external data.
|
||||
pass
|
||||
@@ -1,21 +0,0 @@
|
||||
from OdenGraphQt import GroupNode
|
||||
|
||||
|
||||
class MyGroupNode(GroupNode):
|
||||
"""
|
||||
example test group node with a in port and out port.
|
||||
"""
|
||||
|
||||
# set a unique node identifier.
|
||||
__identifier__ = 'nodes.group'
|
||||
|
||||
# set the initial default node name.
|
||||
NODE_NAME = 'group node'
|
||||
|
||||
def __init__(self):
|
||||
super(MyGroupNode, self).__init__()
|
||||
self.set_color(50, 8, 25)
|
||||
|
||||
# create input and output port.
|
||||
self.add_input('in')
|
||||
self.add_output('out')
|
||||
@@ -1,155 +0,0 @@
|
||||
from OdenGraphQt import BaseNode
|
||||
from OdenGraphQt.constants import NodePropWidgetEnum
|
||||
from OdenGraphQt.widgets.node_widgets import NodeLineEditValidatorCheckBox
|
||||
|
||||
|
||||
class DropdownMenuNode(BaseNode):
|
||||
"""
|
||||
An example node with a embedded added QCombobox menu.
|
||||
"""
|
||||
|
||||
# unique node identifier.
|
||||
__identifier__ = 'nodes.widget'
|
||||
|
||||
# initial default node name.
|
||||
NODE_NAME = 'menu'
|
||||
|
||||
def __init__(self):
|
||||
super(DropdownMenuNode, self).__init__()
|
||||
|
||||
# create input & output ports
|
||||
self.add_input('in 1')
|
||||
self.add_output('out 1')
|
||||
self.add_output('out 2')
|
||||
|
||||
# create the QComboBox menu.
|
||||
items = ["item 1", "item 2", "item 3"]
|
||||
self.add_combo_menu(
|
||||
"my_menu",
|
||||
"Menu Test",
|
||||
items=items,
|
||||
tooltip="example custom tooltip",
|
||||
)
|
||||
|
||||
|
||||
class TextInputNode(BaseNode):
|
||||
"""
|
||||
An example of a node with a embedded QLineEdit.
|
||||
"""
|
||||
|
||||
# unique node identifier.
|
||||
__identifier__ = 'nodes.widget'
|
||||
|
||||
# initial default node name.
|
||||
NODE_NAME = 'text'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
pattern = r"^[A-Za-z0-9]*$"
|
||||
placeholder = ""
|
||||
tooltip = "Valid characters: A-Z a-z 0-9"
|
||||
is_case_sensitive = True
|
||||
checkbox_label = "Use Parser?"
|
||||
|
||||
# create input & output ports
|
||||
self.add_input('in')
|
||||
self.add_output('out')
|
||||
|
||||
# create QLineEdit text input widget.
|
||||
self.add_text_input('my_input', 'Text Input', tab='widgets')
|
||||
|
||||
tool_btn_kwargs = {
|
||||
"func": self._callback,
|
||||
"tooltip": "Awesome"
|
||||
}
|
||||
kwargs = {
|
||||
"validator": {
|
||||
"pattern": pattern,
|
||||
"placeholder": placeholder,
|
||||
"tooltip": tooltip,
|
||||
"is_case_insensitive": is_case_sensitive,
|
||||
"checkbox_visible": True,
|
||||
"tool_btn_visible": True,
|
||||
},
|
||||
"checkbox_label": checkbox_label,
|
||||
"tool_btn": tool_btn_kwargs,
|
||||
}
|
||||
node_widget = NodeLineEditValidatorCheckBox(
|
||||
"src_path",
|
||||
pattern,
|
||||
placeholder,
|
||||
tooltip,
|
||||
is_case_sensitive,
|
||||
checkbox_label,
|
||||
checkbox_visible=True,
|
||||
tool_btn_visible=True,
|
||||
widget_label="src_path",
|
||||
parent=self.view,
|
||||
)
|
||||
node_widget.get_custom_widget().set_tool_btn(**tool_btn_kwargs)
|
||||
self.add_custom_widget(
|
||||
node_widget,
|
||||
NodePropWidgetEnum.LINEEDIT_VALIDATOR_CHECKBOX.value,
|
||||
"widgets",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
kwargs2 = {
|
||||
"validator": {
|
||||
"pattern": pattern,
|
||||
"placeholder": placeholder,
|
||||
"tooltip": tooltip,
|
||||
"is_case_insensitive": is_case_sensitive,
|
||||
"checkbox_visible": False,
|
||||
"tool_btn_visible": False,
|
||||
},
|
||||
"checkbox_label": "Check In Luggage?",
|
||||
"tool_btn": tool_btn_kwargs,
|
||||
}
|
||||
node_widget2 = NodeLineEditValidatorCheckBox(
|
||||
"dst_path",
|
||||
pattern,
|
||||
placeholder,
|
||||
tooltip,
|
||||
is_case_sensitive,
|
||||
"Check In Luggage?",
|
||||
checkbox_visible=False,
|
||||
tool_btn_visible=False,
|
||||
widget_label="dst_path",
|
||||
parent=self.view,
|
||||
)
|
||||
node_widget2.get_custom_widget().set_tool_btn(**tool_btn_kwargs)
|
||||
node_widget2.set_checkbox_visible(False)
|
||||
node_widget2.set_tool_btn_visible(False)
|
||||
self.add_custom_widget(
|
||||
node_widget2,
|
||||
NodePropWidgetEnum.LINEEDIT_VALIDATOR_CHECKBOX.value,
|
||||
"widgets",
|
||||
**kwargs2,
|
||||
)
|
||||
|
||||
def _callback(self):
|
||||
print(f"YOU HAVE CLICKED ON '{self.NODE_NAME}'")
|
||||
|
||||
|
||||
class CheckboxNode(BaseNode):
|
||||
"""
|
||||
An example of a node with 2 embedded QCheckBox widgets.
|
||||
"""
|
||||
|
||||
# set a unique node identifier.
|
||||
__identifier__ = 'nodes.widget'
|
||||
|
||||
# set the initial default node name.
|
||||
NODE_NAME = 'checkbox'
|
||||
|
||||
def __init__(self):
|
||||
super(CheckboxNode, self).__init__()
|
||||
|
||||
# create the checkboxes.
|
||||
self.add_checkbox('cb_1', '', 'Checkbox 1', True)
|
||||
self.add_checkbox('cb_2', '', 'Checkbox 2', False)
|
||||
|
||||
# create input and output port.
|
||||
self.add_input('in', color=(200, 100, 0))
|
||||
self.add_output('out', color=(0, 100, 200))
|
||||
+68
-6
@@ -1,11 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from Qt import QtWidgets, QtCore, QtGui
|
||||
import sys
|
||||
import pkgutil
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
|
||||
from Qt import QtWidgets, QtCore, QtGui
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MONKEY-PATCH to fix "module 'qtpy.QtGui' has no attribute 'QUndoStack'"
|
||||
# OdenGraphQt tries to do QtGui.QUndoStack(self).
|
||||
# We'll import QUndoStack from QtWidgets and attach it to QtGui.
|
||||
try:
|
||||
from qtpy.QtWidgets import QUndoStack
|
||||
import qtpy
|
||||
# Force QtGui.QUndoStack to reference QtWidgets.QUndoStack
|
||||
qtpy.QtGui.QUndoStack = QUndoStack
|
||||
except ImportError:
|
||||
print("WARNING: Could not monkey-patch QUndoStack. You may see an error if OdenGraphQt needs it.")
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Import your data_manager so we can start the Flask server
|
||||
from Modules import data_manager
|
||||
|
||||
# --- BEGIN MONKEY PATCH FOR PIPE COLOR (Optional) ---
|
||||
# If you want custom pipe colors, uncomment this patch:
|
||||
@@ -59,34 +77,78 @@ from OdenGraphQt import NodeGraph, BaseNode
|
||||
|
||||
def import_nodes_from_folder(package_name):
|
||||
"""
|
||||
Dynamically import all modules from the given package
|
||||
Recursively import all modules from the given package
|
||||
and return a list of classes that subclass BaseNode.
|
||||
"""
|
||||
imported_nodes = []
|
||||
package = importlib.import_module(package_name)
|
||||
for loader, module_name, is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
|
||||
|
||||
# Get the root directory of the package
|
||||
package_path = package.__path__[0]
|
||||
|
||||
for root, _, files in os.walk(package_path):
|
||||
rel_path = os.path.relpath(root, package_path).replace(os.sep, '.')
|
||||
module_prefix = f"{package_name}.{rel_path}" if rel_path != '.' else package_name
|
||||
|
||||
for file in files:
|
||||
if file.endswith(".py") and file != "__init__.py":
|
||||
module_name = f"{module_prefix}.{file[:-3]}"
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||
if issubclass(obj, BaseNode) and obj.__module__ == module.__name__:
|
||||
imported_nodes.append(obj)
|
||||
except Exception as e:
|
||||
print(f"Failed to import {module_name}: {e}")
|
||||
|
||||
return imported_nodes
|
||||
|
||||
def make_node_command(graph, node_type_str):
|
||||
"""
|
||||
Return a function that creates a node of the given type at the current cursor position.
|
||||
For the Flyff Character Status Collector node, check if one already exists.
|
||||
If so, schedule an error message to be shown.
|
||||
|
||||
Also ensure that node creation is delayed until after QApplication is up, to avoid
|
||||
'QWidget: Must construct a QApplication before a QWidget' errors.
|
||||
"""
|
||||
def command():
|
||||
def real_create():
|
||||
# Check if we are about to create a duplicate Character Status Collector node.
|
||||
if node_type_str.startswith("bunny-lab.io.flyff_character_status_node"):
|
||||
for node in graph.all_nodes():
|
||||
if node.__class__.__name__ == "FlyffCharacterStatusNode":
|
||||
# Show error message about duplicates
|
||||
QtWidgets.QMessageBox.critical(
|
||||
None,
|
||||
"Error",
|
||||
"Only one Flyff Character Status Collector node is allowed. If you added more, things would break (really) badly."
|
||||
)
|
||||
return
|
||||
try:
|
||||
pos = graph.cursor_pos()
|
||||
graph.create_node(node_type_str, pos=pos)
|
||||
except Exception as e:
|
||||
print(f"Error creating node of type {node_type_str}: {e}")
|
||||
QtWidgets.QMessageBox.critical(None, "Error", str(e))
|
||||
|
||||
def command():
|
||||
# If there's already a QApplication running, just create the node now.
|
||||
if QtWidgets.QApplication.instance():
|
||||
real_create()
|
||||
else:
|
||||
# Otherwise, schedule the node creation for the next event cycle.
|
||||
QtCore.QTimer.singleShot(0, real_create)
|
||||
|
||||
return command
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create the QApplication first
|
||||
app = QtWidgets.QApplication([])
|
||||
|
||||
# Create the NodeGraph controller.
|
||||
# Start the Flask server from data_manager so /data is always available
|
||||
data_manager.start_api_server()
|
||||
|
||||
# Create the NodeGraph controller
|
||||
# (the monkey-patch ensures NodeGraph won't crash if it tries QtGui.QUndoStack(self))
|
||||
graph = NodeGraph()
|
||||
graph.widget.setWindowTitle("Project Borealis - Flyff Information Overlay")
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
Reference in New Issue
Block a user