128 lines
3.6 KiB
Python
128 lines
3.6 KiB
Python
"""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
|