Test files and initial data gathering completed
@@ -0,0 +1,46 @@
|
||||
{"time": "21:22:12", "frame": 0, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}}
|
||||
{"time": "21:22:13", "frame": 1, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}}
|
||||
{"time": "21:22:14", "frame": 2, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}}
|
||||
{"time": "21:22:16", "frame": 3, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 203, "action_points": 140}}
|
||||
{"time": "21:22:17", "frame": 4, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 202, "action_points": 140}}
|
||||
{"time": "21:22:18", "frame": 5, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 202, "action_points": 140}}
|
||||
{"time": "21:22:20", "frame": 6, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 201, "action_points": 140}}
|
||||
{"time": "21:22:21", "frame": 7, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 201, "action_points": 140}}
|
||||
{"time": "21:22:22", "frame": 8, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}}
|
||||
{"time": "21:22:23", "frame": 9, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}}
|
||||
{"time": "21:22:24", "frame": 10, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}}
|
||||
{"time": "21:22:26", "frame": 11, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 199, "action_points": 140}}
|
||||
{"time": "21:22:27", "frame": 12, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 198, "action_points": 140}}
|
||||
{"time": "21:22:28", "frame": 13, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 198, "action_points": 140}}
|
||||
{"time": "21:22:29", "frame": 14, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1971, "action_points": 140}}
|
||||
{"time": "21:22:30", "frame": 15, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1971, "action_points": 140}}
|
||||
{"time": "21:22:32", "frame": 16, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}}
|
||||
{"time": "21:22:33", "frame": 17, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}}
|
||||
{"time": "21:22:34", "frame": 18, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}}
|
||||
{"time": "21:22:36", "frame": 19, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 195, "action_points": 140}}
|
||||
{"time": "21:22:37", "frame": 20, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 194, "action_points": 140}}
|
||||
{"time": "21:22:38", "frame": 21, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 194, "action_points": 140}}
|
||||
{"time": "21:22:40", "frame": 22, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 193, "action_points": 140}}
|
||||
{"time": "21:22:41", "frame": 23, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 193, "action_points": 140}}
|
||||
{"time": "21:22:43", "frame": 24, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1921, "action_points": 140}}
|
||||
{"time": "21:22:44", "frame": 25, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1921, "action_points": 140}}
|
||||
{"time": "21:22:46", "frame": 26, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1911, "action_points": 140}}
|
||||
{"time": "21:22:47", "frame": 27, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 190, "action_points": 140}}
|
||||
{"time": "21:22:49", "frame": 28, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 190, "action_points": 140}}
|
||||
{"time": "21:22:50", "frame": 29, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 189, "action_points": 140}}
|
||||
{"time": "21:22:52", "frame": 30, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 189, "action_points": 140}}
|
||||
{"time": "21:22:53", "frame": 31, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 188, "action_points": 140}}
|
||||
{"time": "21:22:55", "frame": 32, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 188, "action_points": 140}}
|
||||
{"time": "21:22:56", "frame": 33, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1871, "action_points": 140}}
|
||||
{"time": "21:22:58", "frame": 34, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 1871, "action_points": 140}}
|
||||
{"time": "21:22:59", "frame": 35, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 186, "action_points": 140}}
|
||||
{"time": "21:23:01", "frame": 36, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
|
||||
{"time": "21:23:02", "frame": 37, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
|
||||
{"time": "21:23:04", "frame": 38, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
|
||||
{"time": "21:23:05", "frame": 39, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
|
||||
{"time": "21:23:07", "frame": 40, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
|
||||
{"time": "21:23:08", "frame": 41, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
|
||||
{"time": "21:23:10", "frame": 42, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
|
||||
{"time": "21:23:12", "frame": 43, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 184, "action_points": 140}}
|
||||
{"time": "21:23:13", "frame": 44, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 184, "action_points": 140}}
|
||||
{"time": "21:23:15", "frame": 45, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 183, "action_points": 140}}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Lightweight image analysis for live capture feedback.
|
||||
|
||||
All stats are computed on a small downsampled copy so the demo loop stays
|
||||
fast at full 1080p capture.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ANALYSIS_SIZE = (320, 180)
|
||||
_TOP_COLORS = 5
|
||||
|
||||
# Harvesting is detected from the single pixel at this window coordinate.
|
||||
# Harvesting shows a light tan (~#f2c285, luma ~201); idle shows dark brown
|
||||
# (~#664c33, luma ~81).
|
||||
HARVEST_PIXEL = (1710, 1068)
|
||||
HARVEST_LUMA_THRESHOLD = 140
|
||||
|
||||
# The small numeric readout just before each bar at the bottom of the panel.
|
||||
# Regions are (x0, y0, x1, y1) in captured-window pixel coordinates. y0 starts
|
||||
# below the panel's top border line (y=1032) which would confuse OCR.
|
||||
NUMBER_REGIONS = {
|
||||
"mana": (0, 1033, 62, 1048),
|
||||
"food": (195, 1033, 226, 1048),
|
||||
"health": (355, 1033, 389, 1048),
|
||||
"inventory": (520, 1033, 552, 1048),
|
||||
"action_points": (610, 1033, 714, 1048),
|
||||
}
|
||||
|
||||
_OCR_SCALE = 3
|
||||
|
||||
try:
|
||||
import pytesseract
|
||||
except ImportError:
|
||||
pytesseract = None
|
||||
|
||||
|
||||
def _luma(pixel):
|
||||
r, g, b = pixel
|
||||
return round(0.299 * r + 0.587 * g + 0.114 * b)
|
||||
|
||||
|
||||
def load_image(path):
|
||||
"""Load a PNG as RGB; return None if the file is unreadable/corrupt."""
|
||||
try:
|
||||
return Image.open(path).convert("RGB")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def is_harvesting(image):
|
||||
"""Return whether the player is harvesting, based on the harvest pixel."""
|
||||
rgb = image.convert("RGB")
|
||||
color = rgb.getpixel(HARVEST_PIXEL)
|
||||
lum = _luma(color)
|
||||
return {
|
||||
"is_harvesting": lum >= HARVEST_LUMA_THRESHOLD,
|
||||
"color": "#%02x%02x%02x" % color,
|
||||
"luma": lum,
|
||||
}
|
||||
|
||||
|
||||
def _ocr_number(image, region):
|
||||
"""OCR a single number region; return int or None if it can't be read."""
|
||||
if pytesseract is None:
|
||||
return None
|
||||
crop = image.convert("L").crop(region)
|
||||
crop = crop.resize((crop.width * _OCR_SCALE, crop.height * _OCR_SCALE))
|
||||
crop = crop.point(lambda p: 0 if p < 140 else 255)
|
||||
try:
|
||||
text = pytesseract.image_to_string(
|
||||
crop,
|
||||
config="--psm 7 -c tessedit_char_whitelist=0123456789",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def read_bar_values(image):
|
||||
"""Return the numeric readout before each bar, or None if unreadable."""
|
||||
rgb = image.convert("RGB")
|
||||
return {
|
||||
name: _ocr_number(rgb, region)
|
||||
for name, region in NUMBER_REGIONS.items()
|
||||
}
|
||||
|
||||
|
||||
def _pixels(image):
|
||||
px = image.load()
|
||||
w, h = image.size
|
||||
return [px[x, y] for y in range(h) for x in range(w)]
|
||||
|
||||
|
||||
def summarize(image):
|
||||
"""Return size, mean RGB, and dominant colors of a captured frame."""
|
||||
small = image.convert("RGB").resize(ANALYSIS_SIZE)
|
||||
pixels = _pixels(small)
|
||||
n = len(pixels)
|
||||
total = [0, 0, 0]
|
||||
for r, g, b in pixels:
|
||||
total[0] += r
|
||||
total[1] += g
|
||||
total[2] += b
|
||||
mean = tuple(round(c / n) for c in total)
|
||||
top = Counter(pixels).most_common(_TOP_COLORS)
|
||||
dominant = [
|
||||
{"hex": "#%02x%02x%02x" % color, "fraction": round(count / n, 3)}
|
||||
for color, count in top
|
||||
]
|
||||
return {"size": (image.width, image.height), "mean_rgb": mean, "dominant": dominant}
|
||||
|
||||
|
||||
def difference(a, b):
|
||||
"""Sum of absolute channel differences between two same-size RGB images."""
|
||||
if a.size != b.size:
|
||||
return None
|
||||
pa, pb = a.load(), b.load()
|
||||
w, h = a.size
|
||||
diff = 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
ca, cb = pa[x, y], pb[x, y]
|
||||
diff += abs(ca[0] - cb[0]) + abs(ca[1] - cb[1]) + abs(ca[2] - cb[2])
|
||||
return diff
|
||||
@@ -0,0 +1,86 @@
|
||||
"""X11 window capture for Eternal Lands analysis.
|
||||
|
||||
Captures a specific window's pixels even when it is not focused. On X11,
|
||||
focus only affects input routing, never pixel retrieval: XGetImage reads the
|
||||
window's own pixmap. Cinnamon's compositor keeps the redirected content
|
||||
available, so this works regardless of focus or occlusion.
|
||||
|
||||
If window-level capture is unavailable (e.g. compositing is disabled), we
|
||||
fall back to grabbing the visible region from the root window at the
|
||||
window's screen coordinates.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from Xlib import X, display
|
||||
from Xlib.error import XError
|
||||
|
||||
|
||||
class WindowNotFound(Exception):
|
||||
"""Raised when no window matching the title pattern is found."""
|
||||
|
||||
|
||||
class WindowCapture:
|
||||
def __init__(self, title_pattern, display_name=None):
|
||||
self.title_pattern = re.compile(title_pattern, re.IGNORECASE)
|
||||
self.display = display.Display(display_name)
|
||||
self.root = self.display.screen().root
|
||||
self.window = None
|
||||
|
||||
def find_window(self):
|
||||
"""Return the cached or freshly-searched X window for the title."""
|
||||
if self.window is not None:
|
||||
try:
|
||||
self.window.get_geometry()
|
||||
return self.window
|
||||
except XError:
|
||||
self.window = None
|
||||
self.window = self._search(self.root)
|
||||
return self.window
|
||||
|
||||
def _search(self, window, depth=0):
|
||||
if depth > 24:
|
||||
return None
|
||||
try:
|
||||
name = window.get_wm_name()
|
||||
except XError:
|
||||
name = None
|
||||
if name and self.title_pattern.search(name):
|
||||
try:
|
||||
window.get_geometry()
|
||||
return window
|
||||
except XError:
|
||||
return None
|
||||
try:
|
||||
children = window.query_tree().children
|
||||
except XError:
|
||||
return None
|
||||
for child in children:
|
||||
found = self._search(child, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
def capture(self):
|
||||
"""Capture the target window and return a PIL.Image."""
|
||||
window = self.find_window()
|
||||
if window is None:
|
||||
raise WindowNotFound(
|
||||
f"no window matching {self.title_pattern.pattern!r} "
|
||||
f"on display {self.display.display_string}"
|
||||
)
|
||||
geo = window.get_geometry()
|
||||
try:
|
||||
ximage = window.get_image(0, 0, geo.width, geo.height, X.ZPixmap, 0xFFFFFFFF)
|
||||
except XError:
|
||||
coords = self.root.translate_coords(window, 0, 0)
|
||||
ximage = self.root.get_image(
|
||||
coords.x, coords.y, geo.width, geo.height, X.ZPixmap, 0xFFFFFFFF
|
||||
)
|
||||
return _to_image(ximage, geo.width, geo.height)
|
||||
|
||||
|
||||
def _to_image(ximage, width, height):
|
||||
return Image.frombytes("RGBA", (width, height), ximage.data, "raw", "BGRA")
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Demo: capture the Eternal Lands window, analyze it, and log results as JSONL.
|
||||
|
||||
Usage:
|
||||
python3 main.py [--title "Eternal Lands"] [--fps 5] [--save-every 0] [--log analysis.jsonl]
|
||||
python3 main.py --replay tests/images/harvesting [--log analysis.jsonl]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import analyze
|
||||
from capture import WindowCapture, WindowNotFound
|
||||
|
||||
|
||||
def analyze_and_print(image, frame, prev, logf):
|
||||
global _last_t
|
||||
info = analyze.summarize(image)
|
||||
harvest = analyze.is_harvesting(image)
|
||||
bars = analyze.read_bar_values(image)
|
||||
fps = 1.0 / max(1e-6, time.monotonic() - _last_t)
|
||||
_last_t = time.monotonic()
|
||||
|
||||
record = {
|
||||
"time": _now(),
|
||||
"frame": frame,
|
||||
"is_harvesting": harvest["is_harvesting"],
|
||||
"harvest_pixel": harvest["color"],
|
||||
"harvest_luma": harvest["luma"],
|
||||
"bars": bars,
|
||||
}
|
||||
if logf is not None:
|
||||
logf.write(json.dumps(record) + "\n")
|
||||
logf.flush()
|
||||
|
||||
small = image.convert("RGB").resize(analyze.ANALYSIS_SIZE)
|
||||
delta = None if prev is None else analyze.difference(prev, small)
|
||||
|
||||
top = ", ".join(f"{c['hex']} {c['fraction'] * 100:.0f}%" for c in info["dominant"][:3])
|
||||
status = "harvesting" if harvest["is_harvesting"] else "not harvesting"
|
||||
frame_str = f"{frame:05d}" if isinstance(frame, int) else frame
|
||||
print(
|
||||
f"[{frame_str}] {status} luma={harvest['luma']} "
|
||||
f"size={info['size'][0]}x{info['size'][1]} "
|
||||
f"mean={info['mean_rgb']} top=[{top}] bars={bars} "
|
||||
f"delta={delta} loop_fps={fps:.1f}",
|
||||
flush=True,
|
||||
)
|
||||
return small
|
||||
|
||||
|
||||
def run_replay(args):
|
||||
"""Process PNGs from a directory instead of a live window."""
|
||||
logf = open(args.log, "a") if args.log else None
|
||||
prev = None
|
||||
try:
|
||||
for name in sorted(os.listdir(args.replay)):
|
||||
if not name.lower().endswith(".png"):
|
||||
continue
|
||||
path = os.path.join(args.replay, name)
|
||||
image = analyze.load_image(path)
|
||||
if image is None:
|
||||
print(f"[skipped] unreadable image: {path}", flush=True)
|
||||
continue
|
||||
prev = analyze_and_print(image, name, prev, logf)
|
||||
finally:
|
||||
if logf:
|
||||
logf.close()
|
||||
|
||||
|
||||
def run_live(args):
|
||||
cap = WindowCapture(args.title)
|
||||
os.makedirs(args.snapshots, exist_ok=True)
|
||||
interval = 1.0 / args.fps
|
||||
|
||||
print(f"Capturing window matching {args.title!r} at {args.fps:.1f} fps (Ctrl-C to stop)", flush=True)
|
||||
logf = open(args.log, "a") if args.log else None
|
||||
prev = None
|
||||
frame = 0
|
||||
try:
|
||||
while True:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
image = cap.capture()
|
||||
except WindowNotFound as exc:
|
||||
print(f"[{_now()}] {exc}; retrying...", flush=True)
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
prev = analyze_and_print(image, frame, prev, logf)
|
||||
|
||||
if args.save_every and frame % args.save_every == 0:
|
||||
path = os.path.join(args.snapshots, f"frame_{frame:05d}.png")
|
||||
image.save(path)
|
||||
|
||||
frame += 1
|
||||
elapsed = time.monotonic() - started
|
||||
time.sleep(max(0.0, interval - elapsed))
|
||||
finally:
|
||||
if logf:
|
||||
logf.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Capture and analyze a specific window on X11")
|
||||
parser.add_argument(
|
||||
"--title", default="Eternal Lands",
|
||||
help="window title to match (substring, case-insensitive)",
|
||||
)
|
||||
parser.add_argument("--fps", type=float, default=5.0, help="target capture rate")
|
||||
parser.add_argument("--snapshots", default="snapshots", help="directory for PNG snapshots")
|
||||
parser.add_argument(
|
||||
"--save-every", type=int, default=0, metavar="N",
|
||||
help="save a snapshot every N frames (0 = off)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log", default="analysis.jsonl", metavar="PATH",
|
||||
help="append one JSON record per frame to PATH (empty string disables)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--replay", metavar="DIR",
|
||||
help="process existing PNGs from DIR instead of capturing a window",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.replay:
|
||||
run_replay(args)
|
||||
else:
|
||||
run_live(args)
|
||||
|
||||
|
||||
_last_t = time.monotonic()
|
||||
|
||||
|
||||
def _now():
|
||||
return time.strftime("%H:%M:%S")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped")
|
||||
@@ -0,0 +1,4 @@
|
||||
python-xlib>=0.33
|
||||
pillow>=10
|
||||
pytesseract>=0.3
|
||||
pytest>=8
|
||||
|
After Width: | Height: | Size: 768 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
@@ -0,0 +1,70 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import analyze
|
||||
|
||||
ALL_IMAGES = (
|
||||
sorted(Path("tests/images/harvesting").glob("*.png"))
|
||||
+ sorted(Path("tests/images/not_harvesting").glob("*.png"))
|
||||
)
|
||||
|
||||
assert len(ALL_IMAGES) > 0, "no images found under tests/images"
|
||||
|
||||
# Inventory capacity was observed to change as the player collects items.
|
||||
# Frames not listed here are expected to read 106.
|
||||
INVENTORY_EXPECTED = {
|
||||
"frame_00000.png": 110,
|
||||
"frame_00001.png": 110,
|
||||
"frame_00002.png": 108,
|
||||
"frame_00003.png": 108,
|
||||
}
|
||||
|
||||
OCR_AVAILABLE = analyze.pytesseract is not None and shutil.which("tesseract") is not None
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not OCR_AVAILABLE,
|
||||
reason="pytesseract or tesseract-ocr not installed",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def bar_values():
|
||||
values = {}
|
||||
for path in ALL_IMAGES:
|
||||
image = analyze.load_image(path)
|
||||
assert image is not None, f"cannot load {path}"
|
||||
values[path.name] = analyze.read_bar_values(image)
|
||||
return values
|
||||
|
||||
|
||||
def test_mana_is_32(bar_values):
|
||||
for name, values in bar_values.items():
|
||||
assert values["mana"] == 32, name
|
||||
|
||||
|
||||
def test_food_is_39(bar_values):
|
||||
for name, values in bar_values.items():
|
||||
assert values["food"] == 39, name
|
||||
|
||||
|
||||
def test_health_is_40(bar_values):
|
||||
for name, values in bar_values.items():
|
||||
assert values["health"] == 40, name
|
||||
|
||||
|
||||
def test_action_points_are_140(bar_values):
|
||||
for name, values in bar_values.items():
|
||||
assert values["action_points"] == 140, name
|
||||
|
||||
|
||||
def test_inventory_matches_expected(bar_values):
|
||||
for name, values in bar_values.items():
|
||||
expected = INVENTORY_EXPECTED.get(name, 106)
|
||||
assert values["inventory"] == expected, name
|
||||
|
||||
|
||||
def test_every_bar_has_a_value(bar_values):
|
||||
for name, values in bar_values.items():
|
||||
assert all(v is not None for v in values.values()), name
|
||||
@@ -0,0 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
import analyze
|
||||
|
||||
CORRUPT = sorted(Path("tests/images/corrupt").glob("*.png"))
|
||||
|
||||
|
||||
def test_corrupt_images_are_skipped_gracefully():
|
||||
for path in CORRUPT:
|
||||
assert analyze.load_image(path) is None, path
|
||||
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
import analyze
|
||||
|
||||
HARVESTING = sorted(Path("tests/images/harvesting").glob("*.png"))
|
||||
NOT_HARVESTING = sorted(Path("tests/images/not_harvesting").glob("*.png"))
|
||||
|
||||
assert len(HARVESTING) > 0, "no images found in tests/images/harvesting"
|
||||
assert len(NOT_HARVESTING) > 0, "no images found in tests/images/not_harvesting"
|
||||
|
||||
|
||||
def test_harvesting_frames_report_harvesting():
|
||||
for path in HARVESTING:
|
||||
image = analyze.load_image(path)
|
||||
assert image is not None, f"cannot load {path}"
|
||||
assert analyze.is_harvesting(image)["is_harvesting"] is True, path
|
||||
|
||||
|
||||
def test_not_harvesting_frames_report_not_harvesting():
|
||||
for path in NOT_HARVESTING:
|
||||
image = analyze.load_image(path)
|
||||
assert image is not None, f"cannot load {path}"
|
||||
assert analyze.is_harvesting(image)["is_harvesting"] is False, path
|
||||