|
| 1 | +import sys, itertools, requests, json, os.path, hashlib, time, cv2, numpy as np |
| 2 | + |
| 3 | +api_cache_folder = 'cache' |
| 4 | +api_url = 'http://schoolido.lu/api/cards/?page_size=100' |
| 5 | +round_card_images_folder = 'round_card_images' |
| 6 | +all_patterns_file = 'cache/all_patterns.png' |
| 7 | + |
| 8 | +circle_size = 128 |
| 9 | + |
| 10 | +rarity_top = 2 |
| 11 | +rarity_height = 22 |
| 12 | +rarity_left = 2 |
| 13 | +rarity_width = 22 |
| 14 | + |
| 15 | +pattern_top = 16 |
| 16 | +pattern_height = 80 |
| 17 | +pattern_left = 16 |
| 18 | +pattern_width = 80 |
| 19 | +pattern_ratio = 0.5 |
| 20 | + |
| 21 | +match_top = 12 |
| 22 | +match_height = 88 |
| 23 | +match_left = 12 |
| 24 | +match_width = 88 |
| 25 | + |
| 26 | + |
| 27 | +def api_get(url): |
| 28 | + """ |
| 29 | + Make a request against the schoolido.lu api. The results are cached |
| 30 | + :param url: url |
| 31 | + :return: result data |
| 32 | + """ |
| 33 | + cache_file = "%s/%s.json" % (api_cache_folder, hashlib.md5(url).hexdigest()) |
| 34 | + if not os.path.isfile(cache_file): |
| 35 | + if not os.path.isdir(api_cache_folder): |
| 36 | + os.mkdir(api_cache_folder) |
| 37 | + api_result = [] |
| 38 | + while url is not None: |
| 39 | + sys.stderr.write('Fetching %s\n' % url) |
| 40 | + r = requests.get(url) |
| 41 | + if r.status_code != 200: |
| 42 | + raise Exception(r.status_code) |
| 43 | + json_data = r.json() |
| 44 | + url = json_data['next'] |
| 45 | + api_result += json_data['results'] |
| 46 | + with open(cache_file, 'w') as f: |
| 47 | + json.dump(api_result, f, indent=4, sort_keys=True) |
| 48 | + |
| 49 | + if os.path.isfile(all_patterns_file): |
| 50 | + os.unlink(all_patterns_file) |
| 51 | + |
| 52 | + with open(cache_file) as f: |
| 53 | + result = json.load(f) |
| 54 | + return result |
| 55 | + |
| 56 | + |
| 57 | +def get_card_images(card): |
| 58 | + """ |
| 59 | + Returns a list of a card's round images including the remote and local (if cached) paths, and whether it |
| 60 | + belongs to the idolized version of the card |
| 61 | + :param card: card data from schoolido.lu api |
| 62 | + :return: list of (url, local_path, idolized) tuples |
| 63 | + """ |
| 64 | + urls = [(card['round_card_image'], False), (card['round_card_idolized_image'], True)] |
| 65 | + result = [] |
| 66 | + for url, idolized in urls: |
| 67 | + if url is None: |
| 68 | + continue |
| 69 | + if url[0:2] == '//': |
| 70 | + url = 'http:%s' % url |
| 71 | + local_path = "%s/%s" % (round_card_images_folder, os.path.basename(url).split('?')[0]) |
| 72 | + result.append((url, local_path, idolized)) |
| 73 | + return result |
| 74 | + |
| 75 | + |
| 76 | +def fetch_round_card(card): |
| 77 | + """ |
| 78 | + Downloads a card's round images (if they don't exist). |
| 79 | + There is a 100ms sleep time after each download to give the host some rest. |
| 80 | + :param card: card data from schoolido.lu api |
| 81 | + """ |
| 82 | + if not os.path.isdir(round_card_images_folder): |
| 83 | + os.mkdir(round_card_images_folder) |
| 84 | + |
| 85 | + for url, dest, idolized in get_card_images(card): |
| 86 | + if not os.path.isfile(dest): |
| 87 | + sys.stderr.write('Fetching %s\n' % url) |
| 88 | + r = requests.get(url) |
| 89 | + if r.status_code != 200: |
| 90 | + raise Exception(r.status_code) |
| 91 | + with open(dest, 'wb') as f: |
| 92 | + f.write(r.content) |
| 93 | + time.sleep(0.1) |
| 94 | + |
| 95 | + |
| 96 | +def get_card_group(card, idolized): |
| 97 | + """ |
| 98 | + Returns an identifier for a card's group, to help separate them by rarities |
| 99 | + :param card: card data from schoolido.lu api |
| 100 | + :param idolized: True if idolized version |
| 101 | + :return: card group name |
| 102 | + """ |
| 103 | + return "%s-%s%d" % (card['rarity'], card['attribute'], 1 if idolized else 0) |
| 104 | + |
| 105 | + |
| 106 | +def make_rarity_patterns(cards): |
| 107 | + """ |
| 108 | + Make and return patterns for each card rarity |
| 109 | + :param cards: list of card data from schoolido.lu ap |
| 110 | + :return: list of (group identifier, pattern, idolized) tuples |
| 111 | + """ |
| 112 | + did = {} |
| 113 | + patterns = [] |
| 114 | + |
| 115 | + for card in cards: |
| 116 | + images = get_card_images(card) |
| 117 | + for index, (url, local_path, idolized) in enumerate(images): |
| 118 | + group = get_card_group(card, idolized) |
| 119 | + if group in did: |
| 120 | + continue |
| 121 | + did[group] = 1 |
| 122 | + im = cv2.imread(local_path) |
| 123 | + im_cropped = im[rarity_top:rarity_top + rarity_height, rarity_left:rarity_left + rarity_width] |
| 124 | + patterns.append((group, im_cropped, idolized)) |
| 125 | + return patterns |
| 126 | + |
| 127 | + |
| 128 | +def get_pattern_coordinates(index, idolized): |
| 129 | + """ |
| 130 | + Returns coordinates for extracting the card's pattern from the all_patterns image |
| 131 | + :param index: card index inside the cards obtained from the api |
| 132 | + :param idolized: True if idolized version |
| 133 | + :return: coordinates to be used as im_patterns[coordinates] |
| 134 | + """ |
| 135 | + real_index = (index * 2 + 1) if idolized else (index * 2) |
| 136 | + y1 = int(pattern_height * pattern_ratio * real_index) |
| 137 | + y2 = int(pattern_height * pattern_ratio * (real_index + 1)) |
| 138 | + x1 = 0 |
| 139 | + x2 = int(pattern_width * pattern_ratio) |
| 140 | + return np.index_exp[y1:y2, x1:x2] |
| 141 | + |
| 142 | + |
| 143 | +def generate_all_patterns(cards): |
| 144 | + """ |
| 145 | + Generates an all_patterns image |
| 146 | + :param cards: list of card data from schoolido.lu ap |
| 147 | + """ |
| 148 | + if not os.path.isdir(os.path.dirname(all_patterns_file)): |
| 149 | + os.mkdir(os.path.dirname(all_patterns_file)) |
| 150 | + coordinates = get_pattern_coordinates(len(cards), True) |
| 151 | + im_patterns_height, im_patterns_width = [c.stop for c in coordinates] |
| 152 | + im_patterns = np.ones((im_patterns_height, im_patterns_width, 3), np.uint8) |
| 153 | + |
| 154 | + for index, card in enumerate(cards): |
| 155 | + for url, local_path, idolized in get_card_images(card): |
| 156 | + if not os.path.exists(local_path): |
| 157 | + fetch_round_card(card) |
| 158 | + im = cv2.imread(local_path) |
| 159 | + im_cropped = im[pattern_top:pattern_top + pattern_height, pattern_left:pattern_left + pattern_width] |
| 160 | + im_cropped = cv2.resize(im_cropped, (0, 0), fx=pattern_ratio, fy=pattern_ratio) |
| 161 | + coordinates = get_pattern_coordinates(index, idolized) |
| 162 | + im_patterns[coordinates] = im_cropped |
| 163 | + cv2.imwrite(all_patterns_file, im_patterns) |
| 164 | + |
| 165 | + |
| 166 | +def make_card_patterns(cards): |
| 167 | + """ |
| 168 | + Return patterns for each round card image |
| 169 | + :param cards: list of card data from schoolido.lu ap |
| 170 | + :return: a map, with group identifiers as keys and a list of (pattern, card data, idolized) as values |
| 171 | + """ |
| 172 | + if not os.path.isfile(all_patterns_file): |
| 173 | + generate_all_patterns(cards) |
| 174 | + |
| 175 | + im_patterns = cv2.imread(all_patterns_file) |
| 176 | + result = {} |
| 177 | + for index, card in enumerate(cards): |
| 178 | + for url, local_path, idolized in get_card_images(card): |
| 179 | + coordinates = get_pattern_coordinates(index, idolized) |
| 180 | + im_cropped = im_patterns[coordinates] |
| 181 | + |
| 182 | + groups = [get_card_group(card, idolized)] |
| 183 | + # Include non-idolized patterns in both groups, since an idolized card can show the unidolized version |
| 184 | + if not idolized: |
| 185 | + groups.append(get_card_group(card, True)) |
| 186 | + for group in groups: |
| 187 | + if group not in result: |
| 188 | + result[group] = [] |
| 189 | + result[group].append((im_cropped, card, idolized)) |
| 190 | + return result |
| 191 | + |
| 192 | + |
| 193 | +def vertical_split(im): |
| 194 | + """ |
| 195 | + Split an image vertically. This works for member list as well as (well, most times) scouting screenshots |
| 196 | + :param im: screenshot data |
| 197 | + :return: the ratio for resizing the screenshot, a list of (start, stop) chunks to use when looping |
| 198 | + """ |
| 199 | + im_gray = cv2.cvtColor(im, cv2.COLOR_RGB2GRAY) |
| 200 | + thresh = 223 |
| 201 | + |
| 202 | + im_bw = cv2.threshold(im_gray, thresh, 255, cv2.THRESH_BINARY)[1] |
| 203 | + rows = [y for y in xrange(len(im_bw)) if |
| 204 | + max([len(list(g)) for e, g in itertools.groupby(im_bw[y]) if e == 255] or [0]) > len(im[0]) / 2] |
| 205 | + |
| 206 | + row_groups = [(min(arr), max(arr)) for arr in np.split(rows, np.where(np.diff(rows) != 1)[0] + 1)] |
| 207 | + row_sizes = [(b[0] - a[1] - 1) for a, b in zip(row_groups, row_groups[1:]) if (b[0] - a[1] - 1) > 100] |
| 208 | + row_positions = [(a[1], b[0]) for a, b in zip(row_groups, row_groups[1:]) if (b[0] - a[1] - 1) > 100] |
| 209 | + |
| 210 | + ratio = 1.0 * circle_size / np.median(row_sizes) |
| 211 | + return ratio, row_positions |
| 212 | + |
| 213 | + |
| 214 | +def horizontal_split(im): |
| 215 | + """ |
| 216 | + Horizontally split a row into smaller chunks which could contain rounded card images |
| 217 | + :param im: a screenshot's row obtained after using vertical_split's result |
| 218 | + :return: a list of (start, stop) chunks to use when looping |
| 219 | + """ |
| 220 | + im_gray = cv2.cvtColor(im, cv2.COLOR_RGB2GRAY) |
| 221 | + thresh = 223 |
| 222 | + |
| 223 | + im_bw = cv2.threshold(im_gray, thresh, 255, cv2.THRESH_BINARY)[1] |
| 224 | + im_width = len(im[0]) |
| 225 | + |
| 226 | + cols = [y for y in xrange(len(im_bw[0])) if 0 not in np.transpose(im_bw)[y]] |
| 227 | + |
| 228 | + col_groups = [(min(arr), max(arr)) for arr in np.split(cols, np.where(np.diff(cols) != 1)[0] + 1)] |
| 229 | + col_positions = [(b, b + circle_size) for a, b in col_groups] + [(a - circle_size, a) for a, b in col_groups] |
| 230 | + unique_col_positions = [] |
| 231 | + for col_position in col_positions: |
| 232 | + if col_position[0] < 0 or col_position[1] >= im_width: |
| 233 | + continue |
| 234 | + if any([abs(col_position[0] - col_position2[0]) <= 2 and abs(col_position[1] - col_position2[1]) <= 2 for |
| 235 | + col_position2 in unique_col_positions]): |
| 236 | + break |
| 237 | + unique_col_positions.append(col_position) |
| 238 | + |
| 239 | + return unique_col_positions |
| 240 | + |
| 241 | + |
| 242 | +def get_matching_cards(im_match, possible_cards): |
| 243 | + """ |
| 244 | + Tries to match a region against a list of possible card patterns |
| 245 | + :param im_match: region data |
| 246 | + :param possible_cards: list of (card_pattern, card data, idolized) tuples |
| 247 | + :return: the matching cards, as (card data, idolized) tuples |
| 248 | + """ |
| 249 | + im_match = cv2.resize(im_match, (0, 0), fx=pattern_ratio, fy=pattern_ratio) |
| 250 | + matches = [] |
| 251 | + threshold = 0.8 |
| 252 | + for card_pattern, card, idolized in possible_cards: |
| 253 | + if len(im_match) < len(card_pattern) or len(im_match[0]) < len(card_pattern[0]): |
| 254 | + break |
| 255 | + res = cv2.matchTemplate(im_match, card_pattern, cv2.TM_CCOEFF_NORMED) |
| 256 | + loc = np.where(res >= threshold) |
| 257 | + card_pattern_matches = zip(*loc[::-1]) |
| 258 | + if card_pattern_matches: |
| 259 | + matches.append((card, idolized)) |
| 260 | + return matches |
| 261 | + |
| 262 | + |
| 263 | +def search_row(im, rarity_patterns, card_patterns): |
| 264 | + """ |
| 265 | + Searches a row for round card pattern matches |
| 266 | + :param im: the image's row data |
| 267 | + :param rarity_patterns: result from make_rarity_patterns |
| 268 | + :param card_patterns: result from make_make_card_patterns |
| 269 | + :return: list of match information |
| 270 | + """ |
| 271 | + found_cards = [] |
| 272 | + |
| 273 | + im_width = len(im[0]) |
| 274 | + im_height = len(im) |
| 275 | + |
| 276 | + col_positions = horizontal_split(im) |
| 277 | + |
| 278 | + for group_min_x, group_max_x in col_positions: |
| 279 | + im4 = im[:, group_min_x:group_max_x] |
| 280 | + |
| 281 | + for pattern_group, pattern, pattern_idolized in rarity_patterns: |
| 282 | + im3 = im4[0:rarity_top + rarity_height + 2, 0:rarity_left + rarity_width + 2] |
| 283 | + res = cv2.matchTemplate(im3, pattern, cv2.TM_CCOEFF_NORMED) |
| 284 | + |
| 285 | + threshold = 0.7 |
| 286 | + loc = np.where(res >= threshold) |
| 287 | + rarity_pattern_matches = zip(*loc[::-1]) |
| 288 | + |
| 289 | + for index, pt in enumerate(rarity_pattern_matches): |
| 290 | + distances = [abs(pt[0] - pt2[0]) + abs(pt[1] - pt2[1]) for pt2 in rarity_pattern_matches[:index]] |
| 291 | + if distances and min(distances) < 100: |
| 292 | + continue |
| 293 | + |
| 294 | + match_x = pt[0] - rarity_left |
| 295 | + match_y = pt[1] - rarity_top |
| 296 | + relative_x = 1.0 * (match_x + group_min_x) / im_width |
| 297 | + relative_y = 1.0 * match_y / im_height |
| 298 | + |
| 299 | + im_match = im4[pt[1] + match_top:pt[1] + match_top + match_height, |
| 300 | + pt[0] + match_left:pt[0] + match_left + match_width] |
| 301 | + for matching_card, matching_idolized in get_matching_cards(im_match, card_patterns[pattern_group]): |
| 302 | + found_cards.append({ |
| 303 | + 'card': matching_card, |
| 304 | + 'idolized': matching_idolized, |
| 305 | + 'relative_x': relative_x, |
| 306 | + 'relative_y': relative_y, |
| 307 | + }) |
| 308 | + return found_cards |
| 309 | + |
| 310 | + |
| 311 | +def main(): |
| 312 | + """ |
| 313 | + Reads a screenshot and tries to find round card image matches, using data and images from schoolido.lu |
| 314 | + The screenshot's path is read from argv[1] |
| 315 | + The result is written as json to stdout |
| 316 | + Extra info is written to stderr |
| 317 | + Note: Cache is stored inside cache/ and round_card_images/ |
| 318 | + Delete the cache/ folder to force downloading of card data |
| 319 | + """ |
| 320 | + os.chdir(os.path.dirname(os.path.realpath(__file__))) |
| 321 | + start = time.time() |
| 322 | + cards = api_get(api_url) |
| 323 | + sys.stderr.write("%f elapsed after api_get\n" % (time.time() - start)) |
| 324 | + |
| 325 | + card_patterns = make_card_patterns(cards) |
| 326 | + sys.stderr.write("%f elapsed after card_patterns\n" % (time.time() - start)) |
| 327 | + |
| 328 | + rarity_patterns = make_rarity_patterns(cards) |
| 329 | + sys.stderr.write("%f elapsed after rarity_patterns\n" % (time.time() - start)) |
| 330 | + |
| 331 | + im_original = cv2.imread(sys.argv[1]) |
| 332 | + sys.stderr.write("%f elapsed after imread\n" % (time.time() - start)) |
| 333 | + |
| 334 | + ratio, row_positions = vertical_split(im_original) |
| 335 | + sys.stderr.write("%f elapsed after vertical_split\n" % (time.time() - start)) |
| 336 | + |
| 337 | + original_width = len(im_original[0]) |
| 338 | + original_height = len(im_original) |
| 339 | + resized_width = original_width * ratio |
| 340 | + resized_height = original_height * ratio |
| 341 | + |
| 342 | + found_cards = [] |
| 343 | + for group_min_y, group_max_y in row_positions: |
| 344 | + im = cv2.resize(im_original[group_min_y:group_max_y, :], (0, 0), fx=ratio, fy=ratio) |
| 345 | + group_min_relative_y = 1.0 * group_min_y / original_height |
| 346 | + group_relative_h = 1.0 * (group_max_y - group_min_y) / original_height |
| 347 | + for found_card in search_row(im, rarity_patterns, card_patterns): |
| 348 | + found_card['relative_y'] = group_min_relative_y + group_relative_h * found_card['relative_y'] |
| 349 | + found_cards.append(found_card) |
| 350 | + |
| 351 | + for found_card in found_cards: |
| 352 | + found_card['relative_w'] = 1.0 * circle_size / resized_width |
| 353 | + found_card['relative_h'] = 1.0 * circle_size / resized_height |
| 354 | + |
| 355 | + sys.stderr.write("%f elapsed before dumping\n" % (time.time() - start)) |
| 356 | + sys.stdout.write(json.dumps(found_cards, indent=4, sort_keys=True)) |
| 357 | + |
| 358 | + |
| 359 | +if __name__ == '__main__': |
| 360 | + main() |
0 commit comments