Initial project structure: reusable isometric bot engine with D2R implementation

This commit is contained in:
Hoid 2026-02-14 08:50:36 +00:00
commit e0282a7111
44 changed files with 3433 additions and 0 deletions

View file

87
engine/vision/color.py Normal file
View file

@ -0,0 +1,87 @@
"""Color and pixel analysis utilities.
Provides tools for reading health/mana bars, detecting UI states
via color sampling, and pixel-level game state detection.
"""
from typing import Tuple, Optional, List
import logging
import numpy as np
import cv2
logger = logging.getLogger(__name__)
class ColorAnalyzer:
"""Analyze pixel colors and UI bar states."""
@staticmethod
def get_pixel_color(screen: np.ndarray, x: int, y: int) -> Tuple[int, int, int]:
"""Get BGR color at pixel position."""
return tuple(screen[y, x].tolist())
@staticmethod
def get_pixel_hsv(screen: np.ndarray, x: int, y: int) -> Tuple[int, int, int]:
"""Get HSV color at pixel position."""
hsv = cv2.cvtColor(screen[y:y+1, x:x+1], cv2.COLOR_BGR2HSV)
return tuple(hsv[0, 0].tolist())
@staticmethod
def color_matches(
color: Tuple[int, int, int],
target: Tuple[int, int, int],
tolerance: int = 20,
) -> bool:
"""Check if a color matches target within tolerance."""
return all(abs(c - t) <= tolerance for c, t in zip(color, target))
@staticmethod
def read_bar_percentage(
screen: np.ndarray,
bar_region: Tuple[int, int, int, int],
filled_color_hsv: Tuple[Tuple[int, int, int], Tuple[int, int, int]],
) -> float:
"""Read a horizontal bar's fill percentage (health, mana, xp, etc.).
Args:
screen: Screenshot in BGR
bar_region: (x, y, width, height) of the bar
filled_color_hsv: (lower_hsv, upper_hsv) range of the filled portion
Returns:
Fill percentage 0.0 to 1.0
"""
x, y, w, h = bar_region
bar = screen[y:y+h, x:x+w]
hsv = cv2.cvtColor(bar, cv2.COLOR_BGR2HSV)
lower, upper = filled_color_hsv
mask = cv2.inRange(hsv, np.array(lower), np.array(upper))
# Scan columns left to right to find the fill boundary
col_fill = np.mean(mask, axis=0) / 255.0
# Find the rightmost column that's mostly filled
threshold = 0.3
filled_cols = np.where(col_fill > threshold)[0]
if len(filled_cols) == 0:
return 0.0
return (filled_cols[-1] + 1) / w
@staticmethod
def sample_region_dominant_color(
screen: np.ndarray,
region: Tuple[int, int, int, int],
) -> Tuple[int, int, int]:
"""Get the dominant BGR color in a region."""
x, y, w, h = region
roi = screen[y:y+h, x:x+w]
pixels = roi.reshape(-1, 3).astype(np.float32)
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
_, labels, centers = cv2.kmeans(pixels, 1, None, criteria, 3, cv2.KMEANS_RANDOM_CENTERS)
return tuple(centers[0].astype(int).tolist())

140
engine/vision/detector.py Normal file
View file

@ -0,0 +1,140 @@
"""Object and UI element detection using computer vision.
Provides high-level detection for game elements using template matching,
color filtering, and contour analysis.
"""
from typing import List, Optional, Tuple
from dataclasses import dataclass
import logging
import numpy as np
import cv2
logger = logging.getLogger(__name__)
@dataclass
class Detection:
"""Represents a detected object/element on screen."""
x: int
y: int
width: int
height: int
confidence: float
label: str = ""
@property
def center(self) -> Tuple[int, int]:
return (self.x + self.width // 2, self.y + self.height // 2)
@property
def bounds(self) -> Tuple[int, int, int, int]:
return (self.x, self.y, self.x + self.width, self.y + self.height)
class ElementDetector:
"""Detects game UI elements and objects via computer vision."""
def __init__(self, confidence_threshold: float = 0.8):
self.confidence_threshold = confidence_threshold
self._templates: dict[str, np.ndarray] = {}
def load_template(self, name: str, image_path: str) -> None:
"""Load a template image for matching."""
template = cv2.imread(image_path, cv2.IMREAD_COLOR)
if template is None:
raise FileNotFoundError(f"Template not found: {image_path}")
self._templates[name] = template
logger.debug(f"Loaded template '{name}': {template.shape}")
def find_template(
self, screen: np.ndarray, template_name: str,
method: int = cv2.TM_CCOEFF_NORMED,
) -> Optional[Detection]:
"""Find best match of a template in the screen image."""
if template_name not in self._templates:
logger.error(f"Unknown template: {template_name}")
return None
template = self._templates[template_name]
result = cv2.matchTemplate(screen, template, method)
_, max_val, _, max_loc = cv2.minMaxLoc(result)
if max_val >= self.confidence_threshold:
h, w = template.shape[:2]
return Detection(
x=max_loc[0], y=max_loc[1],
width=w, height=h,
confidence=max_val, label=template_name,
)
return None
def find_all_templates(
self, screen: np.ndarray, template_name: str,
method: int = cv2.TM_CCOEFF_NORMED,
) -> List[Detection]:
"""Find all matches of a template above confidence threshold."""
if template_name not in self._templates:
return []
template = self._templates[template_name]
h, w = template.shape[:2]
result = cv2.matchTemplate(screen, template, method)
locations = np.where(result >= self.confidence_threshold)
detections = []
for pt in zip(*locations[::-1]):
detections.append(Detection(
x=pt[0], y=pt[1], width=w, height=h,
confidence=result[pt[1], pt[0]], label=template_name,
))
# Non-maximum suppression (simple distance-based)
return self._nms(detections, distance_threshold=min(w, h) // 2)
def find_by_color(
self, screen: np.ndarray, lower_hsv: Tuple[int, int, int],
upper_hsv: Tuple[int, int, int], min_area: int = 100,
label: str = "",
) -> List[Detection]:
"""Find objects by HSV color range."""
hsv = cv2.cvtColor(screen, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, np.array(lower_hsv), np.array(upper_hsv))
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
detections = []
for contour in contours:
area = cv2.contourArea(contour)
if area >= min_area:
x, y, w, h = cv2.boundingRect(contour)
detections.append(Detection(
x=x, y=y, width=w, height=h,
confidence=area / (w * h), label=label,
))
return detections
def _nms(self, detections: List[Detection], distance_threshold: int) -> List[Detection]:
"""Simple non-maximum suppression by distance."""
if not detections:
return []
detections.sort(key=lambda d: d.confidence, reverse=True)
kept = []
for det in detections:
too_close = False
for k in kept:
dx = abs(det.center[0] - k.center[0])
dy = abs(det.center[1] - k.center[1])
if dx < distance_threshold and dy < distance_threshold:
too_close = True
break
if not too_close:
kept.append(det)
return kept