import cv2 import numpy as np from scipy import signal from module.base.base import ModuleBase from module.base.button import ButtonWrapper from module.base.decorator import cached_property, del_cached_property from module.base.timer import Timer from module.base.utils import Lines, area_center, area_offset, color_similarity_2d from module.exception import ScriptError from module.logger import logger def xywh2xyxy(area): """ Convert (x, y, width, height) to (x1, y1, x2, y2) """ x, y, w, h = area return x, y, x + w, y + h def xyxy2xywh(area): """ Convert (x1, y1, x2, y2) to (x, y, width, height) """ x1, y1, x2, y2 = area return min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1) class InventoryItem: def __init__(self, main: ModuleBase, loca: tuple[int, int], point: tuple[int, int]): self.main = main self.loca = loca self.point = point def __str__(self): return f'Item({self.loca})' __repr__ = __str__ def crop(self, area, copy=False): area = area_offset(area, offset=self.point) return self.main.image_crop(area, copy=copy) @cached_property def button(self): area = area_offset((-40, -20, 40, 20), offset=self.point) return area @cached_property def is_selected(self): image = self.crop((-60, -100, 60, 40)) image = color_similarity_2d(image, (255, 255, 255)) param = { 'height': 160, } hori = cv2.reduce(image, 1, cv2.REDUCE_AVG).flatten() peaks, _ = signal.find_peaks(hori, **param) if len(peaks) != 2: return False vert = cv2.reduce(image, 0, cv2.REDUCE_AVG).flatten() peaks, _ = signal.find_peaks(vert, **param) if len(peaks) != 2: return False return True class InventoryManager: GRID_DELTA = (104, 124) ERROR_LINES_TOLERANCE = (-10, 10) COINCIDENT_POINT_ENCOURAGE_DISTANCE = 1. def __init__(self, main: ModuleBase, inventory: ButtonWrapper): """ max_count: expected max count of this inventory page """ self.main = main self.inventory = inventory self.items: dict[tuple[int, int], InventoryItem] = {} self.selected: InventoryItem | None = None def mid_cleanse(self, mids, mid_diff_range, edge_range): """ Args: mids: mid_diff_range: edge_range: Returns: """ count = len(mids) if count == 1: return mids elif count == 2: # Only one row, [173.5 175. ] mid_diff_mean = np.mean(mid_diff_range) diff = max(mids) - min(mids) if diff < mid_diff_mean * 0.3: return np.mean(mids).reshape((1,)) # Double rows return mids # print(mids) encourage = self.COINCIDENT_POINT_ENCOURAGE_DISTANCE ** 2 # Drawing lines def iter_lines(): for index, mid in enumerate(mids): for n in range(self.ERROR_LINES_TOLERANCE[0], self.ERROR_LINES_TOLERANCE[1] + 1): theta = np.arctan(index + n) rho = mid * np.cos(theta) yield [rho, theta] def coincident_point_value(point): """Value that measures how close a point to the coincident point. The smaller the better. Coincident point may be many. Use an activation function to encourage a group of coincident lines and ignore wrong lines. """ x, y = point # Do not use: # distance = coincident.distance_to_point(point) distance = np.abs(x - coincident.get_x(y)) # print((distance * 1).astype(int).reshape(len(mids), np.diff(self.config.ERROR_LINES_TOLERANCE)[0]+1)) # Activation function # distance = 1 / (1 + np.exp(16 / distance - distance)) distance = 1 / (1 + np.exp(encourage / distance) / distance) distance = np.sum(distance) return distance # Fitting mid coincident = Lines(np.vstack(list(iter_lines())), is_horizontal=False) coincident_point_range = ( ( -abs(self.ERROR_LINES_TOLERANCE[0]) * mid_diff_range[1] + edge_range[0], abs(self.ERROR_LINES_TOLERANCE[1]) * mid_diff_range[1] + edge_range[1] ), mid_diff_range ) from scipy import optimize coincident_point = optimize.brute(coincident_point_value, coincident_point_range) # print(coincident_point) # Filling mid left, right = edge_range mids = np.arange(-25, 25) * coincident_point[1] + coincident_point[0] mids = mids[(mids > left) & (mids < right)] # print(mids) return mids def update(self): image = self.main.image_crop(self.inventory, copy=False) image = color_similarity_2d(image, color=(252, 200, 109)) # Search rarity stars kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) cv2.morphologyEx(image, cv2.MORPH_OPEN, kernel, dst=image) # image_star = cv2.inRange(image, 221, 255) # Close rarity stars as item kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 3)) cv2.morphologyEx(image, cv2.MORPH_CLOSE, kernel, dst=image) image_item = cv2.inRange(image, 221, 255) # from PIL import Image # Image.fromarray(image_star).show() def iter_area(im): # Iter matched area from given image contours, _ = cv2.findContours(im, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) for cont in contours: rect = cv2.boundingRect(cv2.convexHull(cont).astype(np.float32)) # width < 5stars and height < 1star if not (65 > rect[2] >= 5 and 10 > rect[3]): continue rect = xywh2xyxy(rect) rect = area_center(rect) yield rect area_item = list(iter_area(image_item)) # Re-generate a correct xy array points = np.array(area_item) points += self.inventory.area[:2] area = self.inventory.area x_list = np.unique(np.sort(points[:, 0])) y_list = np.unique(np.sort(points[:, 1])) # print(x_list) # print(y_list) x_list = self.mid_cleanse( x_list, mid_diff_range=(self.GRID_DELTA[0] - 3, self.GRID_DELTA[0] + 3), edge_range=(area[0], area[2]) ) y_list = self.mid_cleanse( y_list, mid_diff_range=(self.GRID_DELTA[1] - 3, self.GRID_DELTA[1] + 3), edge_range=(area[1], area[3]) ) # print(x_list) # print(y_list) def is_near_existing(p): diff = np.linalg.norm(points - p, axis=1) return np.any(diff < 3) def iter_items(): y_max = -1 for y in y_list: for x in x_list: if is_near_existing((x, y)): y_max = y break for yi, y in enumerate(y_list): if y < y_max: # Fill items for xi, x in enumerate(x_list): yield InventoryItem(main=self.main, loca=(xi, yi), point=(int(x), int(y))) elif y == y_max: # Fill until the last item for xi, x in enumerate(x_list): if is_near_existing((x, y)): yield InventoryItem(main=self.main, loca=(xi, yi), point=(int(x), int(y))) else: break # Re-generate items self.items = {} selected = [] for item in iter_items(): self.items[item.loca] = item if item.is_selected: selected.append(item) # Check selected self.selected = None count = len(selected) if count == 0: # logger.warning('Inventory has no item selected') pass elif count > 1: logger.warning(f'Inventory has multiple items selected: {selected}') self.selected = selected[0] else: self.selected = selected[0] logger.info(f'Inventory: {len(self.items)} items, selected {self.selected}') def get_row_first(self, row=1, first=0) -> InventoryItem | None: """ Get the first item of the next row Args: row: 1 for next row, -1 for prev row first: 0 for the first_item """ if self.selected == None: return None loca = self.selected.loca loca = (first, loca[1] + row) try: return self.items[loca] except KeyError: return None def get_right(self) -> InventoryItem | None: """ Get the right item of the selected """ if self.selected == None: return None loca = self.selected.loca loca = (loca[0] + 1, loca[1]) try: return self.items[loca] except KeyError: return None def get_first(self) -> InventoryItem | None: """ Get the first item of inventory """ try: return self.items[(0, 0)] except KeyError: return None def select(self, item, skip_first_screenshot=True): logger.info(f'Inventory select {item}') if isinstance(item, InventoryItem): pass else: try: item = self.items[item] except KeyError: raise ScriptError(f'Inventory select {item} but is not in current items') interval = Timer(2, count=6) while 1: if skip_first_screenshot: skip_first_screenshot = False else: self.main.device.screenshot() # End del_cached_property(item, 'is_selected') if item.is_selected: logger.info('Inventory item selected') break # Click if interval.reached(): self.main.device.click(item) interval.reset() self.update() def wait_selected(self, skip_first_screenshot=True): timeout = Timer(2, count=6).start() while 1: if skip_first_screenshot: skip_first_screenshot = False else: self.main.device.screenshot() self.update() if self.selected is not None: break if timeout.reached(): logger.warning('Wait inventory selected timeout') break