class HomeScreenProcessor(Processor):

    REGIONS = ExtractionRegionsCollection(os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    PLAY_TEMPLATE = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "play.png"), 0)
    SEARCH_TEMPLATE = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "search.png"), 0)

    def process(self, frame: Frame) -> bool:
        if frame.valorant.home_screen:
            return True

        # self.REGIONS.draw(frame.debug_image)
        if not imageops.match_thresh_template(
            self.REGIONS["play"].extract_one(frame.image_yuv[:, :, 0]),
            self.PLAY_TEMPLATE,
            130,
            0.8,
        ):
            return False

        if not imageops.match_thresh_template(
            self.REGIONS["search"].extract_one(frame.image_yuv[:, :, 0]),
            self.SEARCH_TEMPLATE,
            100,
            0.8,
        ):
            return False

        play_text = imageops.ocr_region(
            frame,
            self.REGIONS,
            "play",
        )
        if levenshtein.distance(play_text.upper(), "PLAY") > 1:
            return False

        frame.valorant.home_screen = HomeScreen()
        draw_home_screen(frame.debug_image, frame.valorant.home_screen)
        return True

    def ocr_match(self, frame: Frame, region: str, target: str, requirement: float) -> bool:
        text = self.ocr_region(frame, region)
        match = levenshtein.ratio(text.upper(), target.upper())
        logger.debug(
            f"OCR match {text.upper()!r} ~ {target.upper()!r} => {match:.2f} > {requirement:.2f} => {match > requirement}"
        )
        return match > requirement

    def ocr_region(self, frame: Frame, target_region: str):
        region = self.REGIONS[target_region].extract_one(frame.image)
        gray = 255 - imageops.normalise(np.min(region, axis=2))
        text = imageops.tesser_ocr(
            gray,
            engine=imageops.tesseract_lstm,
        )
        return text
Exemple #2
0
    def load_map(cls, path: str):
        im = imageops.imread(path, 0)
        w_h = max(im.shape[0] + 480, im.shape[1] + 480)
        cls.offset_y = (w_h - im.shape[0]) // 2
        cls.offset_x = (w_h - im.shape[1]) // 2
        cls.MAP = cv2.copyMakeBorder(
            im,
            cls.offset_y,
            cls.offset_y,
            cls.offset_x,
            cls.offset_x,
            cv2.BORDER_CONSTANT,
        )
        # cls.MAP_TEMPLATE = cv2.convertScaleAbs(cls.MAP, alpha=1.6, beta=-30)

        LUT = np.linspace(-5, 10, 256)
        LUT = 1 / (1 + np.exp(-LUT * 1.5))
        cls.LUT = (LUT * 255).astype(np.uint8)
        cls.MAP_TEMPLATE = cv2.LUT(cls.MAP, cls.LUT)
        cls.MAP_TEMPLATE = cv2.GaussianBlur(cls.MAP_TEMPLATE, (0, 0),
                                            1.5)  # .astype(np.float)
        # cls.MAP_TEMPLATE *= 1.1
        # cls.MAP_TEMPLATE = np.clip(cls.MAP_TEMPLATE, 0, 255).astype(np.uint8)

        cls.MAP = cls.MAP_TEMPLATE
Exemple #3
0
class EndgameSRProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    COMPETITIVE_POINTS_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "competitive_points.png"), 0
    )
    COMPETITIVE_POINTS_THRESH = 0.8

    def process(self, frame: Frame) -> bool:
        y = frame.image_yuv[:, :, 0]
        im = self.REGIONS["competitive_points"].extract_one(y)
        _, thresh = cv2.threshold(im, 50, 255, cv2.THRESH_BINARY)
        match = np.max(cv2.matchTemplate(thresh, self.COMPETITIVE_POINTS_TEMPLATE, cv2.TM_CCORR_NORMED))

        frame.overwatch.endgame_sr_match = round(float(match), 5)

        if match > self.COMPETITIVE_POINTS_THRESH:
            sr_image = self.REGIONS["sr"].extract_one(y)
            sr = big_noodle.ocr_int(sr_image)
            if sr is None:
                logger.warning(f"Unable to parse SR")
            else:
                frame.overwatch.endgame_sr = EndgameSR(
                    sr, image=lazy_upload("end_sr", self.REGIONS.blank_out(frame.image), frame.timestamp)
                )
                return True

        return False
def load_agent_template(name) -> Tuple[np.ndarray, np.ndarray]:
    path = os.path.join(os.path.dirname(__file__), "data", "agents",
                        name.lower() + ".png")
    image1 = imageops.imread(path, -1)[3:-3, 3:-3]

    # path = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'agent_icons', name.lower() + '.png')
    # image = imageops.imread(path, -1)
    # image = cv2.resize(image, (33, 33), interpolation=cv2.INTER_NEAREST)[:, ::-1][3:-3, 3:-3]
    # image1 = image

    return image1[:, :, :3], cv2.cvtColor(image1[:, :, 3], cv2.COLOR_GRAY2BGR)
 def __init__(self) -> None:
     if self.WEAPON_TEMPLATES is None:
         self.WEAPON_TEMPLATES = [
             (str(os.path.basename(p)).split(".")[0], _load_template(imageops.imread(p, 0)))
             for p in glob.glob(os.path.join(os.path.dirname(__file__), "data", "weapon_templates", "*.png"))
         ]
         for hero in sorted(data.heroes):
             if hero not in [n[0] for n in self.WEAPON_TEMPLATES]:
                 logger.warning(f"Did not get weapon template for {hero}")
             else:
                 weapon_count = len([t for t in self.WEAPON_TEMPLATES if t[0].startswith(hero)])
                 logger.info(f"Got {weapon_count} weapon templates for {hero}")
     logger.info(
         f"Loaded {len(self.WEAPON_TEMPLATES)} weapon templates",
     )
Exemple #6
0
def _make_template(bgcol: Colour, hero: str, scale: float) -> np.ndarray:
    p = os.path.join(os.path.dirname(__file__), "data", "spectator_heroes",
                     hero + ".png")
    logger.debug("Loading %s template from %s", hero, p)
    hero_img = imageops.imread(p, -1)
    foreground = hero_img[:, :, :3].astype(np.float)
    alpha = cv2.cvtColor(hero_img[:, :, 3],
                         cv2.COLOR_GRAY2BGR).astype(float) / 255
    # noinspection PyTypeChecker
    background = np.full_like(foreground, bgcol)
    foreground = cv2.multiply(alpha, foreground)
    background = cv2.multiply(1.0 - alpha, background)
    template = cv2.add(foreground, background).astype(np.uint8)
    template = cv2.resize(template, (0, 0),
                          fx=scale,
                          fy=scale,
                          interpolation=cv2.INTER_AREA)
    return template
def load_hero_templates(hero: str) -> List[np.ndarray]:
    im = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "hero",
                     hero + ".png"), )

    h = np.min(im, axis=2)
    t = cv2.adaptiveThreshold(h, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                              cv2.THRESH_BINARY, 31, -10)
    t = t[10:-35, 20:-20]
    templates = [t]

    # find right edge of text
    b = cv2.GaussianBlur(t, (5, 5), 0)
    row = cv2.reduce(b[6:15], 0, cv2.REDUCE_SUM, dtype=cv2.CV_32S)[0] > 200
    right = t.shape[1] - np.argmax(row[::-1])
    right += 10
    if right < 360 and hero != ALL_HEROES:
        # if the text is not all the way to the edge, then the text will *sometimes* have
        # "< summary" if the player played more than 1 hero. Add a template without this text as well
        t2 = t.copy()
        t2[:, right:] = 0
        templates.append(t2)

    return templates
class PostgameProcessor(Processor):

    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    SCOREBOARD_REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions",
                     "scoreboard", "16_9.zip"))
    RESULTS = {
        "victory":
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", "victory.png"), 0),
        "defeat":
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", "defeat.png"), 0),
    }
    RESULT_TEMPLATE_REQUIRED_MATCH = 0.8

    # TAB_SELECTED_TEMPLATE = np.array([0] * 50 + [3] * 73 + [5] * 24 + [3] * 73 + [0] * 50)
    # TABS = [
    #     ('summary', 240),
    #     ('scoreboard', 335)
    # ]
    SCOREBOARD_SORT_MODES = [
        textops.strip_string("Individually Sorted").upper(),
        textops.strip_string("Grouped By Team").upper(),
    ]

    AGENT_TEMPLATES = {
        name: load_agent_template(
            os.path.join(os.path.dirname(__file__), "data", "agents",
                         name.lower() + ".png"))
        for name in agents
    }
    AGENT_TEMPLATE_REQUIRED_MATCH = 50

    def process(self, frame: Frame) -> bool:
        result_y = self.REGIONS["result"].extract_one(frame.image_yuv[:, :, 0])
        _, result_thresh = cv2.threshold(result_y, 220, 255, cv2.THRESH_BINARY)
        match, result = imageops.match_templates(
            result_thresh,
            self.RESULTS,
            cv2.TM_CCORR_NORMED,
            required_match=self.RESULT_TEMPLATE_REQUIRED_MATCH,
            previous_match_context=(self.__class__.__name__, "result"),
        )

        if match > self.RESULT_TEMPLATE_REQUIRED_MATCH:
            logger.debug(f"Round result is {result} with match={match}")

            score_ims = self.REGIONS["scores"].extract(frame.image)
            score_gray = [
                imageops.normalise(np.max(im, axis=2)) for im in score_ims
            ]
            scores = imageops.tesser_ocr_all(
                score_gray,
                expected_type=int,
                engine=din_next_regular_digits,
                invert=True,
            )
            logger.debug(f"Round score is {scores}")

            frame.valorant.postgame = Postgame(
                victory=result == "victory",
                score=(scores[0], scores[1]),
                map=imageops.ocr_region(frame, self.REGIONS, "map"),
                game_mode=imageops.ocr_region(frame, self.REGIONS,
                                              "game_mode"),
                image=lazy_upload("postgame",
                                  self.REGIONS.blank_out(frame.image),
                                  frame.timestamp),
            )
            draw_postgame(frame.debug_image, frame.valorant.postgame)

            sort_mode_gray = np.min(
                self.SCOREBOARD_REGIONS["scoreboard_sort_mode"].extract_one(
                    frame.image),
                axis=2)
            sort_mode_filt = 255 - imageops.normalise(sort_mode_gray,
                                                      bottom=75)
            # cv2.imshow('sort_mode_gray', sort_mode_gray)
            sort_mode = imageops.tesser_ocr(sort_mode_filt,
                                            engine=imageops.tesseract_lstm)

            sort_mode_match = max([
                levenshtein.ratio(
                    textops.strip_string(sort_mode).upper(), expected)
                for expected in self.SCOREBOARD_SORT_MODES
            ])
            logger.debug(
                f"Got scoreboard sort mode: {sort_mode!r} match={sort_mode_match:.2f}"
            )

            if sort_mode_match > 0.75:
                frame.valorant.scoreboard = self._parse_scoreboard(frame)
                draw_scoreboard(frame.debug_image, frame.valorant.scoreboard)

            return True

        return False

    def _parse_scoreboard(self, frame: Frame) -> Scoreboard:
        agent_images = self.SCOREBOARD_REGIONS["agents"].extract(frame.image)

        name_images = self.SCOREBOARD_REGIONS["names"].extract(frame.image)

        stat_images = self.SCOREBOARD_REGIONS["stats"].extract(frame.image)
        stat_images_filt = [
            self._filter_statrow_image(im) for im in stat_images
        ]
        stat_image_rows = [
            stat_images_filt[r * 8:(r + 1) * 8] for r in range(10)
        ]

        # cv2.imshow(
        #     'stats',
        #     np.vstack([
        #         np.hstack([self._filter_statrow_image(n)] + r)
        #         for n, r in zip(name_images, stat_image_rows)
        #     ])
        # )

        stats = []
        for i, (agent_im, name_im, stat_row) in enumerate(
                zip(agent_images, name_images, stat_image_rows)):
            agent_match, agent = imageops.match_templates(
                agent_im,
                self.AGENT_TEMPLATES,
                method=cv2.TM_SQDIFF,
                required_match=self.AGENT_TEMPLATE_REQUIRED_MATCH,
                use_masks=True,
                previous_match_context=(self.__class__.__name__, "scoreboard",
                                        "agent", i),
            )
            if agent_match > self.AGENT_TEMPLATE_REQUIRED_MATCH:
                agent = None

            row_bg = name_im[np.max(name_im, axis=2) < 200]
            row_color = np.median(row_bg, axis=0).astype(np.int)

            # cv2.imshow('name', self._filter_statrow_image(name_im))
            # cv2.waitKey(0)
            stat = PlayerStats(
                agent,
                imageops.tesser_ocr(
                    self._filter_statrow_image(name_im),
                    engine=imageops.tesseract_lstm,
                ),
                row_color[0] > row_color[2],
                *imageops.tesser_ocr_all(
                    stat_row,
                    expected_type=int,
                    engine=din_next_regular_digits,
                ),
            )
            stats.append(stat)
            logger.debug(
                f"Got player stats: {stat} - agent match={agent_match:.2f}, row colour={tuple(row_color)}"
            )

        return Scoreboard(
            stats,
            image=lazy_upload("scoreboard",
                              self.SCOREBOARD_REGIONS.blank_out(frame.image),
                              frame.timestamp),
        )

    def _filter_statrow_image(self, im):
        im_gray = np.min(im, axis=2).astype(np.float)
        bgcol = np.percentile(im_gray, 90)
        im_norm = im_gray - bgcol
        im_norm = im_norm / np.max(im_norm)
        im = 255 - np.clip(im_norm * 255, 0, 255).astype(np.uint8)
        return im
class TabProcessor(Processor):
    # ExtractionRegionsCollection(regions={
    #   'vs': ExtractionRegions(name="vs", 1 regions),
    #   'blue_names': ExtractionRegions(name="blue_names", 6 regions),
    #   'red_names': ExtractionRegions(name="red_names", 6 regions),
    #   'player_hero': ExtractionRegions(name="player_hero", 2 regions),
    #   'stats': ExtractionRegions(name="stats", 12 regions),
    #   'medals': ExtractionRegions(name="medals", 5 regions)
    # } regions)
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    TEMPLATES = {
        os.path.basename(p).split(".")[0]:
        _load_template(imageops.imread(p, -1))
        for p in glob.glob(
            os.path.join(os.path.dirname(__file__), "data", "hero_icons",
                         "*.png"))
    }
    HERO_TEMPLATE_THRESH = 100

    def __init__(self, save_name_images: bool = False):
        self.save_name_images = save_name_images

        self._last_matches: List[Optional[str]] = [None for _ in range(12)]

    def process(self, frame: Frame) -> bool:
        if not self.detect_tab(frame):
            return False

        player_name_image, player_hero_image = self.REGIONS[
            "player_hero"].extract(frame.image)
        images = NameImages(
            blue_team=self._mask_roles_out(self.REGIONS["blue_names"].extract(
                frame.image)),
            red_team=self.REGIONS["red_names"].extract(frame.image),
            ult_images=self.REGIONS["ults"].extract(frame.image),
            player_name_image=player_name_image,
            player_hero_image=player_hero_image,
            hero_icons_red=self.REGIONS["hero_icons_red"].extract(frame.image),
            hero_icons_blue=self.REGIONS["hero_icons_blue"].extract(
                frame.image),
        )

        player_hero_text = big_noodle.ocr(player_hero_image)
        hero = textops.best_match(
            player_hero_text.lower(),
            [h[1].key for h in data.heroes.items()],
            list(data.heroes.keys()),
            threshold=3,
        )

        heroes_played = self.parse_heroes(images)
        map_text, mode_text = self.parse_map_info(frame)

        stats: Optional[Stats]
        if hero:
            stat_values = []
            for i, im in enumerate(self.REGIONS["stats"].extract(frame.image)):
                masked = self._filter_digit_components(im)
                stat_values.append(digit.ocr(masked, 1.0))

            stat_names_row_1 = [s.name for s in data.generic_stats[:3]]
            stat_names_row_2 = [s.name for s in data.generic_stats[3:]]
            hero_stat_names_row_1 = [
                s.name for s in data.heroes[hero].stats[0]
            ]
            hero_stat_names_row_2 = [
                s.name for s in data.heroes[hero].stats[1]
            ]
            stat_names = stat_names_row_1 + hero_stat_names_row_1 + stat_names_row_2 + hero_stat_names_row_2

            stat_parsed: Dict[str, Optional[int]] = dict(
                zip(stat_names, stat_values))

            if stat_parsed["objective time"] is not None:
                stat_parsed["objective time"] = textops.mmss_to_seconds(
                    stat_parsed["objective time"])
                logger.debug(
                    f'Transformed MMSS objective time to {stat_parsed["objective time"]}'
                )

            stats = Stats(
                hero,
                eliminations=stat_parsed["eliminations"],
                objective_kills=stat_parsed["objective kills"],
                objective_time=stat_parsed["objective time"],
                hero_damage_done=stat_parsed["hero damage done"],
                healing_done=stat_parsed["healing done"],
                deaths=stat_parsed["deaths"],
                hero_specific_stats={
                    s.name: stat_parsed[s.name]
                    for s in itertools.chain.from_iterable(
                        data.heroes[hero].stats)
                },
            )
            logger.info(f"Parsed stats as {stats}")

        else:
            logger.warning(f"Could not recognise {player_hero_text} as a hero")
            stats = None

        frame.overwatch.tab_screen = TabScreen(
            map=map_text,
            mode=mode_text,
            blue_team=big_noodle.ocr_all(images.blue_team, channel="max"),
            blue_team_hero=heroes_played[6:12],
            blue_team_ults=[0 for _ in range(6)],
            red_team=big_noodle.ocr_all(images.red_team, channel="r"),
            red_team_hero=heroes_played[:6],
            player_name=big_noodle.ocr(player_name_image),
            player_hero=hero,
            stats=stats,
        )
        _draw_tab_screen(frame.debug_image, frame.overwatch.tab_screen)

        return True

    def _mask_roles_out(self, ims: List[np.ndarray]) -> List[np.ndarray]:
        """
        Mask out the role icons that appear to the left of the blue team names
        """
        r = []
        for im in ims:
            _, rank_mask = cv2.threshold(np.max(im, axis=2), 250, 255,
                                         cv2.THRESH_BINARY)
            rank_mask = cv2.erode(rank_mask, None)
            rank_mask = cv2.dilate(rank_mask, np.ones((11, 7)))
            masked = cv2.bitwise_and(
                im, 255 - cv2.cvtColor(rank_mask, cv2.COLOR_GRAY2BGR))
            r.append(masked)

        return r

    def _filter_digit_components(self, im: np.ndarray) -> np.ndarray:
        im = np.min(im, axis=2)
        t = imageops.otsu_thresh(im, 100, 255)
        _, mask = cv2.threshold(im, t, 255, cv2.THRESH_BINARY)

        dmask = cv2.dilate(mask, np.ones((3, 3)))
        gray = cv2.bitwise_and(im, dmask)

        # nmask = np.full_like(mask, 255)
        # labels, components = imageops.connected_components(mask)
        # for c1 in components:
        #     for c2 in components:
        #         if c1 is c2:
        #             continue
        #         if c1.x < c2.x < c1.x + c1.w or c1.x < c2.x + c2.w < c1.x + c1.w:
        #             # c1 is above/below c2
        #             nmask[labels == c1.label] = 0
        #         #     nmask[labels == c2.label] = 0
        #
        #
        # cv2.imshow('mask', np.vstack([im, mask, dmask, nmask, gray]))
        # cv2.waitKey(0)

        return gray

    def parse_heroes(self, images: NameImages) -> List[Optional[str]]:
        hero_played: List[Optional[str]] = [None for _ in range(12)]
        for i, icon in enumerate(images.hero_icons_red +
                                 images.hero_icons_blue):
            icon = cv2.resize(icon, (0, 0), fx=0.5, fy=0.5)
            last = self._last_matches[i]
            dontcheck = None
            if last:
                # check the hero this player was playing last
                dontcheck = last
                t, mask = self.TEMPLATES[last]
                match = np.min(
                    cv2.matchTemplate(icon, t, cv2.TM_SQDIFF, mask=mask))
                if match < self.HERO_TEMPLATE_THRESH:
                    hero_played[i] = last
                else:
                    # hero has changed
                    last = None
            if not last:
                for hero_name, (t, mask) in self.TEMPLATES.items():
                    if hero_name == dontcheck:
                        # already tested
                        continue
                    match = np.min(
                        cv2.matchTemplate(icon, t, cv2.TM_SQDIFF, mask=mask))
                    if match < self.HERO_TEMPLATE_THRESH:
                        self._last_matches[i] = hero_name
                        hero_played[i] = hero_name
                        break
        return hero_played

    def parse_map_info(self,
                       frame: Frame) -> Tuple[Optional[str], Optional[str]]:
        map_info_image = self.REGIONS["map_info"].extract_one(frame.image)
        yellow_text = cv2.inRange(
            cv2.cvtColor(map_info_image, cv2.COLOR_BGR2HSV_FULL),
            ((30 / 360) * 255, 0.5 * 255, 0.6 * 255),
            ((45 / 360) * 255, 1.0 * 255, 1.0 * 255),
        )
        yellow_text = cv2.filter2D(yellow_text, -1, np.ones((4, 2)) / (4 * 2))
        yellow_text_left = np.argmax(np.sum(yellow_text, axis=0) / 255 > 4)
        map_image, mode_image = (
            map_info_image[:, :yellow_text_left - 20],
            map_info_image[:, yellow_text_left - 5:],
        )
        map_text = imageops.tesser_ocr(np.min(map_image, axis=2),
                                       whitelist=string.ascii_uppercase + " ",
                                       scale=2,
                                       invert=True)
        mode_text = imageops.tesser_ocr(np.max(mode_image, axis=2),
                                        whitelist=string.ascii_uppercase + " ",
                                        scale=2,
                                        invert=True)
        if len(map_text) < 4 or len(mode_text) < 4:
            logger.warning(
                f"Unexpected map/mode text: {map_text} | {mode_text}")
            return None, None
        else:
            logger.debug(f"Got map={map_text}, mode={mode_text}")
            return map_text, mode_text

    VS_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "vs_template.png"), 0)

    VS_MATCH_THRESHOLD = 0.6

    def detect_tab(self, frame: Frame) -> bool:
        region = self.REGIONS["vs"].extract_one(frame.image)
        region = cv2.resize(region, (50, 50), cv2.INTER_NEAREST)
        region_gray = np.min(region, axis=2)

        # threshold of around 200, allow for flux/lower brightness settings bringing the range down
        _, thresh = cv2.threshold(region_gray,
                                  np.percentile(region_gray.ravel(), 93), 255,
                                  cv2.THRESH_BINARY)

        match = 1 - float(
            np.min(
                cv2.matchTemplate(thresh, self.VS_TEMPLATE,
                                  cv2.TM_SQDIFF_NORMED)))

        frame.overwatch.tab_match = round(match, 5)
        return match > self.VS_MATCH_THRESHOLD
class HeroSelectProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    ASSEMBLE_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data",
                     "assemble_your_team.png"), 0)
    ASSEMBLE_THRESH = 0.8

    ASSEMBLE_HSV_RANGE = [(0, 0, 200), (255, 15, 255)]

    def process(self, frame: Frame) -> bool:
        im = self.REGIONS["assemble_your_team"].extract_one(frame.image)
        thresh = cv2.inRange(cv2.cvtColor(im, cv2.COLOR_BGR2HSV_FULL),
                             self.ASSEMBLE_HSV_RANGE[0],
                             self.ASSEMBLE_HSV_RANGE[1])
        match = np.max(
            cv2.matchTemplate(thresh, self.ASSEMBLE_TEMPLATE,
                              cv2.TM_CCORR_NORMED))

        frame.overwatch.assemble_your_team_match = round(float(match), 5)
        if match > self.ASSEMBLE_THRESH:
            map_image = self.REGIONS["map"].extract_one(frame.image)
            map_thresh = imageops.otsu_thresh_lb_fraction(
                np.min(map_image, axis=2), 1.1)
            map_text = big_noodle.ocr(map_thresh, channel=None, threshold=None)

            mode_image = self.REGIONS["mode"].extract_one(frame.image_yuv[:, :,
                                                                          0])
            mode_thresh = cv2.adaptiveThreshold(mode_image, 255,
                                                cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                                cv2.THRESH_BINARY, 23, -10)
            mode_text = big_noodle.ocr(mode_thresh,
                                       channel=None,
                                       threshold=None)

            name_images = self.REGIONS["blue_names"].extract(frame.image)
            blue_thresh = [
                cv2.inRange(i, (200, 145, 0), (255, 255, 130))
                for i in name_images
            ]
            green_thresh = [
                cv2.inRange(i, (0, 220, 200), (100, 255, 255))
                for i in name_images
            ]
            name_thresh = [
                cv2.bitwise_or(i1, i2)
                for i1, i2 in zip(blue_thresh, green_thresh)
            ]
            names = [
                big_noodle.ocr(
                    i[:, :, 1],
                    channel=None,
                    threshold=t,
                    # debug=True
                ) for i, t in zip(name_images, name_thresh)
            ]

            frame.overwatch.assemble_your_team = AssembleYourTeam(
                map=map_text,
                mode=mode_text,
                blue_names=names,
                is_in_queue=self.detect_in_queue(frame),
            )
            return True

        return False

    TIME_ELAPSED_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "time_elapsed.png"), 0)
    TIME_ELAPSED_MATCH_THRESHOLD = 0.75

    def detect_in_queue(self, frame: Frame) -> bool:
        region = self.REGIONS["time_elapsed"].extract_one(frame.image_yuv[:, :,
                                                                          0])

        _, thresh = cv2.threshold(region, 130, 255, cv2.THRESH_BINARY)
        match = np.max(
            cv2.matchTemplate(thresh, self.TIME_ELAPSED_TEMPLATE,
                              cv2.TM_CCORR_NORMED))
        logger.debug(f"Time elapsed match={match:.1f}")

        return match > self.TIME_ELAPSED_MATCH_THRESHOLD
class TopHudProcessor(Processor):

    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    AGENT_TEMPLATES = {
        name: load_agent_template(
            os.path.join(os.path.dirname(__file__), "data", "agents",
                         name.lower() + ".png"))
        for name in agents
    }
    AGENT_TEMPLATES_FLIP = {
        name: (images[0][:, ::-1], images[1][:, ::-1])
        for name, images in AGENT_TEMPLATES.items()
    }
    AGENT_TEMPLATE_REQUIRED_MATCH = 0.1

    HAVE_ULT_SIGNAL = np.array([1] * 5 + [0] * 44 + [1] * 5, dtype=np.float)

    SPIKE_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "spike.png"), 0)

    def process(self, frame: Frame) -> bool:
        teams = self._parse_teams(frame)

        frame.valorant.top_hud = TopHud(
            score=self.parse_score(frame),
            teams=teams,
            has_ult_match=self._parse_ults(frame, teams),
            has_spike_match=self._parse_spike(frame, teams),
        )
        draw_top_hud(frame.debug_image, frame.valorant.top_hud)

        # self.REGIONS.draw(frame.debug_image)

        return frame.valorant.top_hud.score[
            0] is not None or frame.valorant.top_hud.score[1] is not None

    def parse_score(self, frame: Frame) -> Tuple[Optional[int], Optional[int]]:
        score_ims = self.REGIONS["scores"].extract(frame.image)
        score_gray = [np.min(im, axis=2) for im in score_ims]
        score_norm = [
            imageops.normalise(im, bottom=80, top=100) for im in score_gray
        ]

        # debugops.normalise(score_gray[0])
        # cv2.imshow('score_ims', np.hstack(score_ims))
        # cv2.imshow('score_gray', np.hstack(score_gray))
        # cv2.imshow('score_ys_norm', np.hstack(score_norm))

        score = imageops.tesser_ocr_all(score_norm,
                                        expected_type=int,
                                        invert=True,
                                        engine=din_next_regular_digits)
        logger.debug(f"Got score={score}")
        return score[0], score[1]

    def _parse_teams(self, frame: Frame) -> Tuple[TeamComp, TeamComp]:
        agents = []
        for i, agent_im in enumerate(self.REGIONS["teams"].extract(
                frame.image)):
            blurlevel = cv2.Laplacian(agent_im, cv2.CV_64F).var()
            if blurlevel < 100:
                agents.append(None)
                logger.debug(f"Got agent {i}=None (blurlevel={blurlevel:.2f})")
            else:
                templates = self.AGENT_TEMPLATES
                if i > 4:
                    templates = self.AGENT_TEMPLATES_FLIP
                # cv2.imshow('agent', self.AGENT_TEMPLATES_FLIP['Raze'][0])
                match, r_agent = imageops.match_templates(
                    agent_im,
                    templates,
                    method=cv2.TM_SQDIFF_NORMED,
                    required_match=self.AGENT_TEMPLATE_REQUIRED_MATCH,
                    use_masks=True,
                    previous_match_context=(self.__class__.__name__,
                                            "_parse_teams", i),
                    # verbose=True
                )
                agent = r_agent
                if match > self.AGENT_TEMPLATE_REQUIRED_MATCH:
                    agent = None

                logger.debug(
                    f"Got agent {i}={agent} (best={r_agent}, match={match:.3f}, blurlevel={blurlevel:.1f})"
                )
                agents.append(agent)
        return cast_teams((agents[:5], agents[5:]))

    def _parse_ults(
            self, frame: Frame,
            teams: Tuple[TeamComp, TeamComp]) -> Tuple[FiveOFloat, FiveOFloat]:
        ults = []
        for i, ult_im in enumerate(self.REGIONS["has_ult"].extract(
                frame.image)):
            if not teams[i // 5][i % 5]:
                ults.append(None)
                continue

            matches = [0.0]
            ult_hsv = cv2.cvtColor(ult_im, cv2.COLOR_BGR2HSV_FULL)

            ult_col = np.median(ult_hsv, axis=(0, ))
            ult_col = ult_col.astype(np.float)

            # The median pixel value for each channel should be the value of the "yellow"
            # Compute the abs offset from this value
            ult_col = np.abs(ult_col - np.median(ult_col, axis=(0, )))

            for c in range(3):
                # Compute the maximum (filtered with a width 5 bloxfilter) value and normalize by this
                # Check both sides of the image, as they may be different and use the lower of the two then clip the higher so it matches
                ult_col_hi = np.convolve(ult_col[:, c], [1 / 5] * 5)
                avg_diff_at_edge = min(
                    np.max(ult_col_hi[:len(ult_col_hi) // 2]),
                    np.max(ult_col_hi[len(ult_col_hi) // 2:]))
                # print(i, c, avg_diff_at_edge)
                if avg_diff_at_edge < 15:
                    # Not significant difference
                    continue

                have_ult_thresh_1d = np.clip(ult_col[:, c] / avg_diff_at_edge,
                                             0, 1)

                # This leaves have_ult_thresh_1d as a signal [0, 1] where 0 is match to the has ult colour,
                # and 1 is match to the outside

                # Correlate this (normalizing to [-1, 1] to make the correlation normalized) with the expected width for the has ult block
                have_ult_correlation = np.correlate(
                    have_ult_thresh_1d * 2 - 1,
                    self.HAVE_ULT_SIGNAL * 2 - 1) / len(self.HAVE_ULT_SIGNAL)
                have_ult_match = np.max(have_ult_correlation)
                #
                # if not frame.get('warmup') and i == 2:
                #     import matplotlib.pyplot as plt
                #     plt.figure()
                #     plt.imshow(ult_im)
                #     plt.figure()
                #     plt.plot(have_ult_thresh_1d)
                #     plt.plot(have_ult_correlation)
                #     plt.show()

                matches.append(have_ult_match)
            have_ult_match = np.max(matches)
            logger.debug(f"Got player {i} has ult match={have_ult_match:.3f}")
            ults.append(round(float(have_ult_match), 3))

        return cast_teams((ults[:5], ults[5:]))

    def _parse_spike(self, frame: Frame, teams: Tuple[TeamComp,
                                                      TeamComp]) -> FiveOFloat:
        spikes = []
        for i, ult_im in enumerate(self.REGIONS["has_spike"].extract(
                frame.image_yuv[:, :, 0])[:5]):
            if not teams[0][i % 5]:
                spikes.append(None)
                continue

            _, thresh = cv2.threshold(ult_im, 240, 255, cv2.THRESH_BINARY)
            match = np.max(
                cv2.matchTemplate(thresh, self.SPIKE_TEMPLATE,
                                  cv2.TM_CCORR_NORMED))
            #     cv2.imshow('match', thresh)
            #     cv2.waitKey(0)
            spikes.append(round(float(match), 3))

        return cast(FiveOFloat, spikes)
class CombatProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    REQUIRED_MATCH = 0.75
    TEMPLATES = {
        # these need to go first, so they can mask out the others
        "ASSIST, ELIMINATION":
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data",
                         "assist_elimination.png"), 0),
        "ASSIST, KNOCK DOWN":
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data",
                         "assist_knockdown.png"), 0),
        "ELIMINATED":
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", "eliminated.png"),
            0),
        "KNOCKED DOWN":
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data",
                         "knocked_down.png"), 0),
    }

    def eager_load(self):
        self.REGIONS.eager_load()

    def process(self, frame: Frame) -> bool:
        image_region = self.REGIONS["kill_text"]
        region = image_region.extract_one(frame.image_yuv[:, :, 0])
        region_color = image_region.extract_one(frame.image)
        thresh = imageops.unsharp_mask(region, 3.5, 6, 254)
        events: List[Event] = []
        for event_type, template in self.TEMPLATES.items():
            match = cv2.matchTemplate(thresh, template, cv2.TM_CCORR_NORMED)
            for _ in range(4):
                mnv, mxv, mnl, mxl = cv2.minMaxLoc(match)
                if mxv > self.REQUIRED_MATCH:

                    width = ((1920 // 2) -
                             (mxl[0] + image_region.regions[0][0])
                             ) * 2 - template.shape[1]
                    left = mxl[0] + template.shape[1]

                    logger.info(
                        f"Saw {event_type} ~ {mxv:1.2f}: w={width}, x={left}")

                    # name_image = region_color[
                    #     max(0, mxl[1] - 5):min(mxl[1] + template.shape[0] + 5, region.shape[0]),
                    #     left:left + width
                    # ]
                    # cv2.imshow('name', name_image)
                    # print(width)
                    # debugops.test_tesser_engines(name_image)
                    # cv2.waitKey(0)

                    events.append(
                        Event(
                            event_type,
                            width,
                            # None,
                            # None,
                            round(mxv, 4),
                        ))

                    match[max(0, mxl[1] - 20):min(mxl[1] + 20, match.shape[0]),
                          max(0, mxl[0] - 20):min(mxl[0] +
                                                  20, match.shape[0]), ] = 0
                    thresh[max(0, mxl[1] - 10):min(mxl[1] +
                                                   30, match.shape[0]), :] = 0
                else:
                    break

        if len(events):
            frame.apex.combat_log = CombatLog(events)
            _draw_log(frame.debug_image, frame.apex.combat_log)
            return True
        else:
            return False
Exemple #13
0
class SquadSummaryProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    TEMPLATES = {
        k: imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", k + ".png"), 0)
        for k in
        ["squad_eliminated", "champions_of_the_arena", "match_summary"]
    }

    REQUIRED_MATCH = 0.75

    def eager_load(self):
        self.REGIONS.eager_load()

    def process(self, frame: Frame) -> bool:
        y = frame.image_yuv[:, :, 0]
        champions_eliminated = self.REGIONS[
            "champions_eliminated"].extract_one(y)
        t, thresh = cv2.threshold(champions_eliminated, 0, 255,
                                  cv2.THRESH_BINARY | cv2.THRESH_OTSU)

        # cv2.imshow('thresh', thresh)

        match, key = imageops.match_templates(thresh, self.TEMPLATES,
                                              cv2.TM_CCORR_NORMED,
                                              self.REQUIRED_MATCH)
        frame.apex.squad_summary_match = round(match, 4)
        if match > self.REQUIRED_MATCH:
            champions = key in ["champions_of_the_arena"]

            duos_empty_area = self.REGIONS["duos_empty_area"].extract_one(
                frame.image_yuv[:, :, 0])
            duos_sum = np.sum(duos_empty_area > 100)
            duos = duos_sum < 100
            logger.debug(f"Got duos_sum={duos_sum} => duos={duos}")

            shunt = 0
            if duos:
                duos_shunt_area = self.REGIONS["duos_shunt_area"].extract_one(
                    frame.image_yuv[:, :, 0])
                duos_shunt_sum = np.sum(duos_shunt_area > 100)
                duos_shunt = duos_shunt_sum < 100
                logger.debug(
                    f"Got duos_shunt_sum={duos_shunt_sum} => duos_shunt={duos_shunt}"
                )
                if duos_shunt:
                    shunt = 270

            frame.apex.squad_summary = SquadSummary(
                champions=champions,
                placed=self._process_yellowtext(
                    self.REGIONS["placed"].extract_one(frame.image)),
                squad_kills=self._process_yellowtext(
                    self.REGIONS["squad_kills"].extract_one(frame.image)),
                player_stats=self._process_player_stats(y, duos, shunt),
                elite=False,
                mode="duos" if duos else None,
                image=lazy_upload(
                    "squad_summary",
                    self.REGIONS.blank_out(frame.image),
                    frame.timestamp,
                    selection="last",
                ),
            )
            self.REGIONS.draw(frame.debug_image)
            _draw_squad_summary(frame.debug_image, frame.apex.squad_summary)
            return True

        return False

    def _process_yellowtext(self, image: np.ndarray) -> Optional[int]:
        # mask out only yellow text (digits)
        yellow = cv2.inRange(image, (0, 40, 150), (90, 230, 255))
        yellow = cv2.dilate(yellow, None)
        yellowtext_image = cv2.bitwise_and(
            image, cv2.cvtColor(yellow, cv2.COLOR_GRAY2BGR))
        yellowtext_image_g = np.max(yellowtext_image, axis=2)
        yellowtext_image_g = cv2.erode(yellowtext_image_g, np.ones((2, 2)))

        text = imageops.tesser_ocr(
            yellowtext_image_g,
            engine=imageops.tesseract_lstm,
            scale=4,
            blur=4,
            invert=True,
        )
        otext = text
        text = text.upper()
        for s1, s2 in "|1", "I1", "L1", "O0", "S5", "B6":
            text = text.replace(s1, s2)
        for hashchar in "#H":
            text = text.replace(hashchar, "")
        logger.info(f"Got text={otext} -> {text}")

        try:
            return int(text)
        except ValueError:
            logger.warning(f"Could not parse {text!r} as int")
            return None

    def _process_player_stats(self,
                              y: np.ndarray,
                              duos: bool = False,
                              shunt: int = 0) -> Tuple[PlayerStats, ...]:
        name_images = self.REGIONS["names"].shunt(x=shunt).extract(y)
        names = []
        for im in name_images:
            # self._mask_components_touching_edges(im)
            im = 255 - cv2.bitwise_and(
                im,
                cv2.dilate(
                    cv2.threshold(im, 0, 255,
                                  cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1],
                    None,
                ),
            )
            im = cv2.resize(im, (0, 0), fx=2, fy=2)
            im = cv2.GaussianBlur(im, (0, 0), 1)

            name = imageops.tesser_ocr(
                im,
                engine=imageops.tesseract_lstm,
            ).replace(" ", "")
            match = np.mean(imageops.tesseract_lstm.AllWordConfidences())
            logger.info(f"Got name {name!r} ~ {match:1.2f}")
            if match < 0.75:
                name = imageops.tesser_ocr(
                    im,
                    engine=imageops.tesseract_only,
                )
                logger.info(f"Using {name!r} instead")
            names.append(name)

        stat_images = self.REGIONS["stats"].shunt(x=shunt).extract(y)

        # for im in stat_images:
        #     self._mask_components_touching_edges(im)

        stats = imageops.tesser_ocr_all(
            stat_images,
            engine=ocr.tesseract_ttlakes_digits_specials,
        )

        for i in range(len(stats)):
            value = stats[i]
            logger.debug(f"Got stat {i}: {value!r}")
            if value:
                value = value.lower().replace(" ", "")
                for c1, c2 in "l1", "i1", "o0", (":", ""):
                    value = value.replace(c1, c2)
                value = textops.strip_string(value, string.digits + "/")
            if i < 3:
                try:
                    stats[i] = tuple([int(v) for v in value.split("/")])
                except ValueError as e:
                    logger.warning(f'Could not parse {value!r} as 3 ints" {e}')
                    stats[i] = None
            elif 6 <= i <= 8:
                # survival time
                if stats[i] is not None:
                    try:
                        seconds = int(value)
                    except ValueError as e:
                        logger.warning(
                            f'Could not parse "{stats[i]}" as int: {e}')
                        seconds = None
                    else:
                        seconds = mmss_to_seconds(seconds)
                        logger.info(f"MM:SS {stats[i]} -> {seconds}")
                    stats[i] = seconds
            else:
                try:
                    stats[i] = int(value)
                except ValueError as e:
                    logger.warning(f'Could not parse {value!r} as int" {e}')
                    stats[i] = None

        # typing: ignore
        # noinspection PyTypeChecker
        count = 3 if not duos else 2
        r = tuple([PlayerStats(names[i], *stats[i::3]) for i in range(count)])

        for s in r:
            if not s.kills:
                pass
            elif len(s.kills) == 3:
                s.assists = s.kills[1]
                s.knocks = s.kills[2]
                s.kills = s.kills[0]
            else:
                s.kills = s.kills[0]

        logger.info(f"Got {pprint.pformat(r)}")
        return r

    def _mask_components_touching_edges(self,
                                        im: np.ndarray,
                                        threshold=100) -> bool:
        masked = False
        _, t = cv2.threshold(im, threshold, 255, cv2.THRESH_BINARY)
        mask, components = imageops.connected_components(t)
        for c in components[1:]:
            if c.y <= 1 or c.y + c.h >= im.shape[0] - 1:
                mask = (mask != c.label).astype(np.uint8) * 255
                mask = cv2.erode(mask, None)
                im[:] = cv2.bitwise_and(im, mask)
                masked = c.area > 50
        return masked
class MatchStatusProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "..", "..", "data", "regions",
                     "16_9.zip"))
    HEAD_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "head.png"), 0)
    SKULL_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "skull.png"), 0)
    RANK_TEMPLATES = [(
        rank,
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", "ranks",
                         rank + ".png")),
        cv2.cvtColor(
            imageops.imread(
                os.path.join(os.path.dirname(__file__), "data", "ranks",
                             rank + ".png"),
                -1,
            )[:, :, 3],
            cv2.COLOR_GRAY2BGR,
        ),
    ) for rank in data.ranks]
    MODE_TEMPLATES = [(
        mode,
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", "modes",
                         mode + ".png")),
        cv2.cvtColor(
            imageops.imread(
                os.path.join(os.path.dirname(__file__), "data", "modes",
                             mode + ".png"),
                -1,
            )[:, :, 3],
            cv2.COLOR_GRAY2BGR,
        ),
    ) for mode in ["duos"]]
    SUBS = [
        "?2",
        "O0",
        "L1",
        "I1",
        "B6",
    ]

    def __init__(self):
        super().__init__()
        self.last_rank_template = 0

    def eager_load(self):
        self.REGIONS.eager_load()

    def process(self, frame: Frame):
        y = cv2.cvtColor(frame.image, cv2.COLOR_BGR2YUV)[:, :, 0]

        # The text moves depending on normal or elite queue
        # Look for the "head" template showing players alive
        head_region = np.max(self.REGIONS["head_region"].extract_one(
            frame.image),
                             axis=2)
        _, head_thresh = cv2.threshold(head_region, 200, 255,
                                       cv2.THRESH_BINARY)
        head_match = cv2.matchTemplate(head_thresh, self.HEAD_TEMPLATE,
                                       cv2.TM_CCORR_NORMED)
        mnv, mxv, mnl, mxl = cv2.minMaxLoc(head_match)
        frame.apex.match_status_match = round(float(mxv), 2)
        if mxv < 0.9:
            return False

        badge_image = self.REGIONS["rank_badge"].extract_one(frame.image)
        # cv2.imshow('rank_badge_image', badge_image)
        # print(rank_badge_matches)

        # 90 for unranked, 15 for ranked
        has_badge = mxl[0] < 30

        mode = None
        if has_badge:
            mode_badge_matches = self._parse_badge(badge_image,
                                                   self.MODE_TEMPLATES)
            if mode_badge_matches[0] < 750:
                mode = "duos"

        squads_left_text = self._parse_squads_left_text(y, has_badge)
        squads_left = self._get_squads_left(squads_left_text, mode)
        if not squads_left:
            mode = "solos"
            solos_players_left = self._get_squads_left(squads_left_text, mode)
        else:
            solos_players_left = None

        if not mode and has_badge:
            mode = "ranked"

        if mode == "ranked":
            rank_badge_matches = self._parse_badge(badge_image,
                                                   self.RANK_TEMPLATES)
            rank_text_image = self.REGIONS["rank_text"].extract_one(
                frame.image_yuv[:, :, 0])
            rank_text = imageops.tesser_ocr(
                rank_text_image,
                whitelist="IV",
                scale=3,
                invert=True,
                engine=imageops.tesseract_only,
            )
            rp_text_image = self.REGIONS["ranked_rp"].extract_one(
                frame.image_yuv[:, :, 0])
            rp_text = imageops.tesser_ocr(
                rp_text_image,
                whitelist=string.digits + "+-RP",
                scale=3,
                invert=True,
                engine=imageops.tesseract_only,
            )
        else:
            rank_badge_matches = None
            rank_text = None
            rp_text = None

        frame.apex.match_status = MatchStatus(
            squads_left=squads_left,
            players_alive=self._get_players_alive(y, has_badge)
            if squads_left and squads_left > 4 else None,
            kills=self._get_kills(y, mode),
            ranked=mode == "ranked",
            rank_badge_matches=rank_badge_matches,
            rank_text=rank_text,
            rp_text=rp_text,
            solos_players_left=solos_players_left,
            mode=mode,
        )
        self.REGIONS.draw(frame.debug_image)
        _draw_status(frame.debug_image, frame.apex.match_status)
        return True

    def _parse_squads_left_text(self, luma: np.ndarray,
                                has_badge: bool) -> str:
        prefix = "ranked_" if has_badge else ""
        region = self.REGIONS[prefix + "squads_left"].extract_one(luma)
        squads_left_text = imageops.tesser_ocr(region,
                                               engine=imageops.tesseract_lstm,
                                               scale=2,
                                               invert=True).upper()
        squads_left_text = ("".join(c for c in squads_left_text
                                    if c in string.ascii_uppercase +
                                    string.digits + " ").strip().replace(
                                        "B", "6"))
        return squads_left_text

    def _get_squads_left(self,
                         squads_left_text: str,
                         mode: Optional[str] = None) -> Optional[int]:
        expected_text = "SQUADSLEFT"
        expected_max_squads = 30
        if mode == "solos":
            expected_text = "PLAYERSLEFT"
            expected_max_squads = 60
        elif mode == "duos":
            expected_max_squads = 30

        text_match = levenshtein.ratio(squads_left_text[2:].replace(" ", ""),
                                       expected_text)
        if text_match > 0.8:
            number_text = squads_left_text[:3].split(" ", 1)[0]
            for s1, s2 in self.SUBS:
                number_text = number_text.replace(s1, s2)
            try:
                squads_left = int(number_text)
            except ValueError:
                logger.warning(
                    f"Failed to parse {number_text!r} as int - extracted from {squads_left_text!r}"
                )
                return None
            else:
                if 2 <= squads_left <= expected_max_squads:
                    return squads_left
                else:
                    logger.warning(
                        f"Got squads_left={squads_left} - rejecting. Extracted from {squads_left_text!r}"
                    )
                    return None
        elif text_match > 0.6:
            logger.warning(
                f'Refusing to parse "{squads_left_text} as squads left - match={text_match}'
            )
            return None
        else:
            return None

    def _get_players_alive(self, luma: np.ndarray,
                           has_badge: bool) -> Optional[int]:
        prefix = "ranked_" if has_badge else ""
        region = self.REGIONS[prefix + "alive"].extract_one(luma)
        players_alive = imageops.tesser_ocr(
            region,
            engine=ocr.tesseract_ttlakes_digits,
            scale=4,
            expected_type=int)
        # shows a '?' if below 10
        if players_alive and 10 <= players_alive <= 60:
            return players_alive
        else:
            logger.warning(f"Rejecting players_alive={players_alive}")
            return None

    def _get_kills(self, luma: np.ndarray, mode: str) -> Optional[int]:
        prefix = (mode + "_") if mode else ""
        key = prefix + "kills"
        if key not in self.REGIONS.regions:
            key = "kills"
        region = self.REGIONS[key].extract_one(luma)
        _, kills_thresh = cv2.threshold(region, 0, 255,
                                        cv2.THRESH_BINARY | cv2.THRESH_OTSU)
        kills_thresh = cv2.copyMakeBorder(kills_thresh,
                                          5,
                                          5,
                                          0,
                                          5,
                                          cv2.BORDER_CONSTANT,
                                          value=0)
        match = cv2.matchTemplate(kills_thresh, self.SKULL_TEMPLATE,
                                  cv2.TM_CCORR_NORMED)
        mn, mx, mnloc, mxloc = cv2.minMaxLoc(match)
        if mx > 0.9:
            kills_image = region[:, mxloc[0] + self.SKULL_TEMPLATE.shape[1]:]
            # cv2.imshow('kills', cv2.resize(kills_image, (100, 100)))

            kills_text = (imageops.tesser_ocr(kills_image,
                                              engine=imageops.tesseract_lstm,
                                              scale=2,
                                              invert=True).upper().strip())
            for s1, s2 in self.SUBS:
                kills_text = kills_text.replace(s1, s2)
            try:
                kills = int(kills_text)
                if 0 < kills <= 50:
                    return kills
                else:
                    logger.warning(f"Rejecting kills={kills}")
                    return None
            except ValueError:
                logger.warning(f"Cannot parse kills={kills_text!r} as int")
                return None
        else:
            return None

    def _parse_badge(
            self, badge_image: np.ndarray,
            badges: List[Tuple[str, np.ndarray,
                               np.ndarray]]) -> Tuple[float, ...]:
        matches = []
        for rank, template, mask in badges:
            match = np.min(
                matchTemplate(badge_image, template, cv2.TM_SQDIFF, mask=mask))
            matches.append(round(match, 1))
        return tuple(matches)
class MenuProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    READY = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "ready.png"), 0)
    CANCEL = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "cancel.png"), 0)
    REQUIRED_MATCH = 0.9

    CROWN = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "crown.png"), 0)

    def eager_load(self):
        self.REGIONS.eager_load()

    def process(self, frame: Frame):
        if frame.apex.apex_play_menu_match:
            return frame.apex.apex_play_menu_match >= self.REQUIRED_MATCH

        y = frame.image_yuv[:, :, 0]

        ready_button = self.REGIONS["ready_button"].extract_one(y)
        t, thresh = cv2.threshold(ready_button, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)

        ready_match = np.max(cv2.matchTemplate(thresh, self.READY, cv2.TM_CCORR_NORMED))
        if ready_match >= self.REQUIRED_MATCH:
            cancel_match = 0.0
        else:
            cancel_match = np.max(cv2.matchTemplate(thresh, self.CANCEL, cv2.TM_CCORR_NORMED))
        frame.apex.apex_play_menu_match = round(float(max(ready_match, cancel_match)), 5)
        _draw_buttons_match(frame.debug_image, ready_match, cancel_match, self.REQUIRED_MATCH)

        if ready_match >= self.REQUIRED_MATCH or cancel_match >= self.REQUIRED_MATCH:
            player_name_image = self.REGIONS["player_name"].extract_one(y)
            mate1, mate2 = self.REGIONS["squadmates"].extract(y)

            rank_text_region = self.REGIONS["rank_text"].extract_one(y)
            rank_text = imageops.tesser_ocr(rank_text_region, invert=True, engine=imageops.tesseract_lstm)

            rp_text_region = self.REGIONS["rp_text"].extract_one(y)
            rp_text = imageops.tesser_ocr(rp_text_region, invert=True, engine=imageops.tesseract_lstm)

            frame.apex.apex_play_menu = PlayMenu(
                player_name=self._ocr_playername(player_name_image),
                squadmates=(self._ocr_playername(mate1), self._ocr_playername(mate2)),
                ready=cancel_match >= self.REQUIRED_MATCH,
                rank_text=rank_text,
                rp_text=rp_text,
            )
            self.REGIONS.draw(frame.debug_image)
            _draw_play_menu(frame.debug_image, frame.apex.apex_play_menu)

            return True

        else:
            return False

    def _ocr_playername(self, player_name_image: np.ndarray) -> str:
        # crop out crown
        _, thresh = cv2.threshold(player_name_image, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
        mnv, mxv, mnl, mxl = cv2.minMaxLoc(cv2.matchTemplate(thresh, self.CROWN, cv2.TM_CCORR_NORMED))
        if mxv > 0.99:
            player_name_image = player_name_image[:, mxl[0] + self.CROWN.shape[1] :]

        player_name = imageops.tesser_ocr(player_name_image, scale=4)
        return player_name
class EndgameProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    RESULTS = ["VICTORY", "DEFEAT", "DRAW"]

    VALID_STATS_NAMES = list(data.heroes.keys()) + [ALL_HEROES]

    def process(self, frame: Frame) -> bool:
        if self.detect_endgame(frame):

            result, map_text = self.parse_result_and_map(frame)
            if not result or not map_text:
                return True

            stats = self.parse_stats(frame)

            frame.overwatch.endgame = Endgame(result=result,
                                              map=map_text,
                                              stats=stats)

            return True

        return False

    def parse_stats(self, frame: Frame) -> Optional[Stats]:
        hero = self.parse_stats_hero(frame)
        if not hero:
            # ALL HEREOS now shows as no text - just parse empty as ALL HEROES and detect failed parses
            hero = ALL_HEROES
        if hero in self.VALID_STATS_NAMES:
            stats = dict(
                zip(
                    [s.name for s in data.generic_stats],
                    big_noodle.ocr_all_int(self.REGIONS["stats"].extract(
                        frame.image),
                                           channel="max",
                                           height=56),
                ))
            logger.debug(f"Parsed stats: {stats}")

            if hero == ALL_HEROES and sum(v is not None
                                          for v in stats.values()) <= 2:
                # because we parse unknowns as ALL HEREOS, if the stats failed to parse this is probably not a stats screen
                logger.info(
                    f"Did not get valid stats for potential ALL HEROES stats - ignoring"
                )
                return None

            if stats["objective time"] is not None:
                stats["objective time"] = textops.mmss_to_seconds(
                    stats["objective time"])
                logger.debug(
                    f'Transformed MMSS objective time to {stats["objective time"]}'
                )

            if hero == ALL_HEROES:
                hero_specific_stats = None
            else:
                stat_names_row_1 = [s.name for s in data.heroes[hero].stats[0]]
                stat_names_row_2 = [s.name for s in data.heroes[hero].stats[1]]
                stat_names = stat_names_row_1 + stat_names_row_2
                logger.debug(
                    f"Hero: {hero} has {len(stat_names)} hero specific stats: {stat_names}"
                )

                images = self.REGIONS[f"hero_stats_{len(stat_names)}"].extract(
                    frame.image)
                normed = [
                    ((image.astype(np.float) / np.percentile(image, 98)) *
                     255).clip(0, 255).astype(np.uint8) for image in images
                ]
                # cv2.imshow('ims', np.vstack((np.hstack(images[:len(stat_names)]), np.hstack(normed[:len(stat_names)]))))
                # cv2.waitKey(0)
                stat_values = digit.ocr_images(normed[:len(stat_names)],
                                               scale=0.73)
                hero_specific_stats = dict(zip(stat_names, stat_values))
                logger.info(f"Parsed {hero} stats: {hero_specific_stats}")

            return Stats(
                hero,
                eliminations=stats["eliminations"],
                objective_kills=stats["objective kills"],
                objective_time=stats["objective time"],
                hero_damage_done=stats["hero damage done"],
                healing_done=stats["healing done"],
                deaths=stats["deaths"],
                hero_specific_stats=hero_specific_stats,
            )
        elif hero:
            logging.error(
                f"Parsed hero name as {hero!r} but was not in list of valid names"
            )
            return None
        else:
            return None

    HERO_STAT_TEMPLATES = []
    for h in VALID_STATS_NAMES:
        for t in load_hero_templates(h):
            HERO_STAT_TEMPLATES.append((h, t))
    HERO_NAME_TEMPLATE_MATCH_THRESHOLD = 0.3

    def parse_stats_hero(self, frame: Frame) -> Optional[str]:
        hero_image = np.min(self.REGIONS["hero_stat_name"].extract_one(
            frame.image),
                            axis=2)
        hero_image_thresh = cv2.adaptiveThreshold(
            hero_image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY,
            31, -10)
        matches = sorted([(np.min(
            cv2.matchTemplate(hero_image_thresh, t, cv2.TM_SQDIFF_NORMED)), n)
                          for (n, t) in self.HERO_STAT_TEMPLATES])
        logger.debug("Found hero stat matches: " +
                     ", ".join(f"({n}: {m:1.2f})"
                               for (m, n) in matches[:5]) + "...")
        if matches[0][0] < self.HERO_NAME_TEMPLATE_MATCH_THRESHOLD:
            hero = matches[0][1]
            logger.info(f"Classifying stats hero as {hero}")
            return hero
        else:
            logger.debug("Could not identify hero")
            return None

    def parse_result_and_map(
            self, frame: Frame) -> Tuple[Optional[str], Optional[str]]:
        result_im = self.REGIONS["result"].extract_one(frame.image)
        gray = np.max(result_im, axis=2)
        # mask out white/gray text (this is map and match time info)
        white_text = ((gray > 100) &
                      (np.ptp(result_im, axis=2) < 20)).astype(np.uint8) * 255
        white_text = cv2.erode(white_text, None)
        white_text = np.sum(white_text, axis=0) / 255
        right = np.argmax(white_text > 2)
        if right > 150:
            right -= 10
            logger.info(
                f"Trimming width of result image {gray.shape[1]} -> {right} to cut white text"
            )
            gray = gray[:, :right]
        else:
            right = gray.shape[1]
        result_text = imageops.tesser_ocr(gray,
                                          whitelist="".join(
                                              set("".join(self.RESULTS))),
                                          invert=True)
        result = textops.matches(result_text, self.RESULTS)
        if np.min(result) > 2:
            logger.warning(
                f"Could not identify result from {result_text!r} (match={np.min(result)})"
            )
            return None, None

        result = self.RESULTS[arrayops.argmin(result)]
        logger.debug(f"Got result {result} from {result_text!r}")
        # TODO: test this with "draw" result
        map_image = self.REGIONS["map_name"].extract_one(frame.image)[:,
                                                                      right:]
        gray = np.min(map_image, axis=2)
        map_text = textops.strip_string(
            imageops.tesser_ocr(gray,
                                whitelist=string.ascii_uppercase + " :'",
                                invert=True,
                                scale=2),
            string.ascii_uppercase + " ",
        )
        logger.debug(f"Parsed map as {map_text}")

        return result, map_text

    LEAVE_GAME_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data",
                     "leave_game_template.png"), 0)
    LEAVE_GAME_TEMPLATE_THRESH = 0.6

    def detect_endgame(self, frame: Frame) -> bool:
        leave_game_button = self.REGIONS["leave_game_button"].extract_one(
            frame.image)
        # leave_game_button = cv2.resize(leave_game_button, (0, 0), fx=0.5, fy=0.5)

        gray = np.min(leave_game_button, axis=2)
        _, thresh = cv2.threshold(gray, 0, 255,
                                  cv2.THRESH_BINARY | cv2.THRESH_OTSU)

        frame.overwatch.endgame_match = round(
            1 - float(
                np.min(
                    cv2.matchTemplate(thresh, self.LEAVE_GAME_TEMPLATE,
                                      cv2.TM_SQDIFF_NORMED))), 5)
        return frame.overwatch.endgame_match > self.LEAVE_GAME_TEMPLATE_THRESH
Exemple #17
0
class MatchSummaryProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    MATCH_SUMMARY_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "match_summary.png"),
        0)
    XP_BREAKDOWN_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "xp_breakdown.png"), 0)
    SCORE_REPORT_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "score_report.png"), 0)
    REQUIRED_MATCH = 0.75

    PLACED_COLOUR = (32, 61, 238)

    XP_STATS = [
        "Won Match",
        "Top 3 Finish",
        "Time Survived",
        "Kills",
        "Damage Done",
        "Revive Ally",
        "Respawn Ally",
    ]
    XP_STATS_NORMED = [s.replace(" ", "").upper() for s in XP_STATS]
    SUBS = ["[(", "{(", "])", "})"]

    def eager_load(self):
        self.REGIONS.eager_load()

    def process(self, frame: Frame) -> bool:
        y = frame.image_yuv[:, :, 0]
        your_squad_image = self.REGIONS["match_summary"].extract_one(y)
        t, thresh = cv2.threshold(your_squad_image, 0, 255,
                                  cv2.THRESH_BINARY | cv2.THRESH_OTSU)
        match = np.max(
            cv2.matchTemplate(thresh, self.MATCH_SUMMARY_TEMPLATE,
                              cv2.TM_CCORR_NORMED))
        frame.apex.match_summary_match = round(float(match), 5)
        if match >= self.REQUIRED_MATCH:
            self.REGIONS.draw(frame.debug_image)
            placed = self._get_placed(frame)

            image_title = "match_summary"
            xp_stats, score_report = None, None

            xp_breakdown_title_image = self.REGIONS[
                "xp_breakdown"].extract_one(y)
            _, xp_breakdown_title_thresh = cv2.threshold(
                xp_breakdown_title_image, 150, 255, cv2.THRESH_BINARY)
            xp_breakdown_title_match = np.max(
                cv2.matchTemplate(
                    xp_breakdown_title_thresh,
                    self.XP_BREAKDOWN_TEMPLATE,
                    cv2.TM_CCORR_NORMED,
                ))
            if xp_breakdown_title_match > self.REQUIRED_MATCH:
                xp_stats = self._parse_xp_breakdown(y)
                image_title += "_xp_breakdown"
            else:
                score_report_title_image = self.REGIONS[
                    "score_report"].extract_one(y)
                _, score_report_title_thresh = cv2.threshold(
                    score_report_title_image, 150, 255, cv2.THRESH_BINARY)
                score_report_title_match = np.max(
                    cv2.matchTemplate(
                        score_report_title_thresh,
                        self.SCORE_REPORT_TEMPLATE,
                        cv2.TM_CCORR_NORMED,
                    ))
                if score_report_title_match > self.REQUIRED_MATCH:
                    score_report = self._parse_score_report(y)
                    image_title += "_score_report"

            if placed is not None:
                frame.apex.match_summary = MatchSummary(
                    placed=placed,
                    xp_stats=xp_stats,
                    score_report=score_report,
                    image=lazy_upload(
                        image_title,
                        self.REGIONS.blank_out(frame.image),
                        frame.timestamp,
                        selection="last",
                    ),
                )
                _draw_match_summary(frame.debug_image,
                                    frame.apex.match_summary)
                return True

        return False

    def _parse_xp_breakdown(self, y: np.ndarray) -> XPStats:
        xp_breakdown_image = self.REGIONS["xp_fields"].extract_one(y)
        xp_breakdown_image = cv2.adaptiveThreshold(
            xp_breakdown_image,
            255,
            cv2.ADAPTIVE_THRESH_MEAN_C,
            cv2.THRESH_BINARY_INV,
            63,
            -30,
        )
        lines = imageops.tesser_ocr(
            xp_breakdown_image,
            whitelist=string.ascii_letters + string.digits + "() \n",
            engine=imageops.tesseract_lstm_multiline,
        )
        for s1, s2 in self.SUBS:
            lines = lines.replace(s1, s2)

        xp_stats = XPStats()
        for line in lines.splitlines():
            stat_name, stat_value = self._parse_stat(line)
            if stat_name == "Won Match":
                xp_stats.won = True
            elif stat_name == "Top 3 Finish":
                xp_stats.top3_finish = True
            elif stat_name and stat_value is not None:
                # require stat value parsed correctly
                if stat_name == "Time Survived":
                    xp_stats.time_survived = mmss_to_seconds(stat_value)
                elif stat_name == "Kills":
                    xp_stats.kills = stat_value
                elif stat_name == "Damage Done":
                    xp_stats.damage_done = stat_value
                elif stat_name == "Revive Ally":
                    xp_stats.revive_ally = stat_value
                elif stat_name == "Respawn Ally":
                    xp_stats.respawn_ally = stat_value
        return xp_stats

    def _parse_score_report(self, y: np.ndarray) -> ScoreReport:
        rp_report_image = self.REGIONS["rp_fields"].extract_one(y)

        lines = []
        for line in range(3):
            line_im = rp_report_image[line * 40 + 5:(line + 1) * 40 - 7, 5:]
            lines.append(
                imageops.tesser_ocr(line_im,
                                    engine=imageops.tesseract_lstm,
                                    invert=True,
                                    scale=2))

        score_report = ScoreReport()
        for line in lines:
            valid = False
            if ":" in line:
                stat_name, stat_value = line.lower().replace(" ",
                                                             "").split(":", 1)
                if stat_name == "entrycost":
                    score_report.entry_rank = stat_value.lower()
                    valid = True
                elif stat_name == "kills":
                    try:
                        score_report.kills = int(stat_value.replace("o", "0"))
                    except ValueError:
                        logger.warning(
                            f'Could not parse Score Report > kills: {stat_value!r}" as int'
                        )
                    else:
                        valid = True
                elif stat_name == "matchplacement":
                    stat_value = stat_value.replace("#", "")
                    try:
                        score_report.placement = int(
                            stat_value.replace("o", "0").split("/", 1)[0])
                    except ValueError:
                        logger.warning(
                            f'Could not parse Score Report > placement: {stat_value!r}" as placement'
                        )
                    else:
                        valid = True
            if not valid:
                logger.warning(f"Unknown line in score report: {line!r}")

        score_adjustment_image = self.REGIONS["score_adjustment"].extract_one(
            y)
        score_adjustment_text = imageops.tesser_ocr(
            score_adjustment_image,
            engine=imageops.tesseract_lstm,
            invert=True,
            scale=1)
        score_adjustment_text_strip = (textops.strip_string(
            score_adjustment_text, alphabet=string.digits + "RP+-").replace(
                "RP", "").replace("+", "").replace("-", ""))
        try:
            score_report.rp_adjustment = int(score_adjustment_text_strip)
        except ValueError:
            logger.warning(
                f'Could not parse Score Report > score adjustment: {score_adjustment_text!r}" as valid adjustment'
            )

        current_rp_image = self.REGIONS["current_rp"].extract_one(y)
        current_rp_text = imageops.tesser_ocr(current_rp_image,
                                              engine=imageops.tesseract_lstm,
                                              invert=True,
                                              scale=1)
        current_rp_text_strip = textops.strip_string(current_rp_text,
                                                     alphabet=string.digits +
                                                     "RP").replace("RP", "")
        try:
            score_report.current_rp = int(current_rp_text_strip)
        except ValueError:
            logger.warning(
                f'Could not parse Score Report > current RP: {current_rp_text!r}" as valid RP'
            )

        return score_report

    def _parse_stat(self, line: str) -> Tuple[Optional[str], Optional[int]]:
        if len(line) > 5:
            parts = line.split("(", 1)
            if len(parts) > 1:
                stat_name_s, stat_value_s = parts[:2]
            else:
                stat_name_s, stat_value_s = line, None
            match, stat_name_normed = textops.matches_ratio(
                stat_name_s.replace(" ", "").upper(), self.XP_STATS_NORMED)
            if match > 0.8:
                stat_name = self.XP_STATS[self.XP_STATS_NORMED.index(
                    stat_name_normed)]
                if stat_value_s:
                    stat_value = self._parse_stat_number(stat_value_s)
                    if stat_value is not None:
                        logger.info(
                            f"Parsed {stat_name}={stat_value} ({line!r} ~ {match:1.2f})"
                        )
                        return stat_name, stat_value
                    else:
                        logger.info(
                            f"Unable to parse value for {stat_name} ({line!r} ~ {match:1.2f})"
                        )
                        return stat_name, None
                else:
                    return stat_name, None
            else:
                logger.warning(f"Don't know how to parse stat {line!r}")
                return None, None
        elif line:
            logger.warning(f"Ignoring stat {line!r} - too short")
            return None, None
        else:
            return None, None

    def _parse_stat_number(self, stat_value_s: str) -> Optional[int]:
        stat_value_s = stat_value_s.upper()

        # common errors in parsing digits
        for s1, s2 in "D0", "I1", "L1":
            stat_value_s = stat_value_s.replace(s1, s2)

        # remove brackets, spaces, X (e.g. in "Kills (x3)"), time separators, commas
        stat_value_s = "".join(c for c in stat_value_s if c not in "() X:.,;|")

        try:
            return int(stat_value_s)
        except ValueError:
            return None

    def _get_placed(self, frame: Frame) -> Optional[int]:
        placed_image = self.REGIONS["squad_placed"].extract_one(
            frame.image).copy()
        cv2.normalize(placed_image, placed_image, 0, 255, cv2.NORM_MINMAX)
        orange = cv2.inRange(
            placed_image,
            np.array(self.PLACED_COLOUR) - 40,
            np.array(self.PLACED_COLOUR) + 40,
        )
        text = imageops.tesser_ocr(orange, whitelist=string.digits + "#")
        if text and text[0] == "#":
            try:
                placed = int(text[1:])
            except ValueError:
                logger.warning(f"Could not parse {text!r} as number")
                return None
            else:
                logger.debug(f"Parsed {text!r} as {placed}")
                if 1 <= placed <= 30:
                    return placed
                else:
                    logger.warning(f"Rejected placed={placed}")
        else:
            logger.warning(f'Rejected placed text {text!r} - did not get "#"')
            return None
class RoleSelectProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    TANK_TEMPLATE = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "tank.png"), 0)
    TANK_LARGE_TEMPLATE = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "tank_large.png"), 0)
    LOCK_TEMPLATE = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "lock.png"), 0)

    REQUIRED_MATCH = 0.95

    def process(self, frame: Frame) -> bool:
        y = frame.image_yuv[:, :, 0]
        tank_region = np.max(self.REGIONS["tank_region"].extract_one(frame.image), axis=2)

        _, thresh = cv2.threshold(tank_region, 100, 255, cv2.THRESH_BINARY)
        # cv2.imshow('thresh1', thresh)

        tank_match_sm = cv2.matchTemplate(thresh, self.TANK_TEMPLATE, cv2.TM_CCORR_NORMED)
        _, match_sm, _, mxloc_sm = cv2.minMaxLoc(tank_match_sm)

        tank_match_lg = cv2.matchTemplate(thresh, self.TANK_LARGE_TEMPLATE, cv2.TM_CCORR_NORMED)
        _, match_lg, _, mxloc_lg = cv2.minMaxLoc(tank_match_lg)

        lock_match = cv2.matchTemplate(thresh, self.LOCK_TEMPLATE, cv2.TM_CCORR_NORMED)
        _, match_lock, _, mxloc_lock = cv2.minMaxLoc(lock_match)

        matched_i = arrayops.argmax([match_sm, match_lg, match_lock])
        # print([match_sm, match_lg, match_lock])
        match = [match_sm, match_lg, match_lock][matched_i]
        matched = ["tank", "tank_lg", "lock"][matched_i]
        best_match_pos = [mxloc_sm, mxloc_lg, mxloc_lock][matched_i]
        match_x = best_match_pos[0]
        # print(matched, match_x)

        frame.overwatch.role_select_match = round(match, 2)

        if match > self.REQUIRED_MATCH:
            grouped = match_x < 150

            logger.debug(
                f"Found match for {matched!r} with match={match:0.3f} ({match_sm:.2f}, {match_lg:.2f}, {match_lock:.2f}), x={match_x} => grouped={grouped}"
            )

            suffix = "_group" if grouped else "_solo"
            frame.overwatch.role_select = RoleSelect(
                placement_text=imageops.tesser_ocr_all(
                    self.REGIONS["placements" + suffix].extract(y), whitelist=string.digits + "/-"
                ),
                sr_text=big_noodle.ocr_all(self.REGIONS["srs" + suffix].extract(y), height=23, invert=True),
                account_name=imageops.tesser_ocr(
                    self.REGIONS["account_name"].extract_one(y), engine=imageops.tesseract_lstm
                ),
                grouped=grouped,
                image=lazy_upload(
                    "role_select", self.REGIONS.blank_out(frame.image), frame.timestamp, selection="last"
                ),
            )
            if frame.debug_image is not None:
                self.REGIONS.draw(frame.debug_image)
            _draw_role_select(frame.debug_image, frame.overwatch.role_select)
            return True

        return False
def load_rank_template(p: str) -> Tuple[np.ndarray, np.ndarray]:
    image = imageops.imread(p, -1)
    image = cv2.resize(image, (40, 40), cv2.INTER_CUBIC)
    mask = image[:, :, 3]
    _, mask = cv2.threshold(mask, 200, 255, cv2.THRESH_BINARY)
    return image[:, :, :3], cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
Exemple #20
0
class MinimapProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "..", "..", "data", "regions",
                     "16_9.zip"))
    SPECTATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "spectate.png"), 0)
    THRESHOLD = 0.1

    offset_x = 0
    offset_y = 0

    @classmethod
    def load_map(cls, path: str):
        im = imageops.imread(path, 0)
        w_h = max(im.shape[0] + 480, im.shape[1] + 480)
        cls.offset_y = (w_h - im.shape[0]) // 2
        cls.offset_x = (w_h - im.shape[1]) // 2
        cls.MAP = cv2.copyMakeBorder(
            im,
            cls.offset_y,
            cls.offset_y,
            cls.offset_x,
            cls.offset_x,
            cv2.BORDER_CONSTANT,
        )
        # cls.MAP_TEMPLATE = cv2.convertScaleAbs(cls.MAP, alpha=1.6, beta=-30)

        LUT = np.linspace(-5, 10, 256)
        LUT = 1 / (1 + np.exp(-LUT * 1.5))
        cls.LUT = (LUT * 255).astype(np.uint8)
        cls.MAP_TEMPLATE = cv2.LUT(cls.MAP, cls.LUT)
        cls.MAP_TEMPLATE = cv2.GaussianBlur(cls.MAP_TEMPLATE, (0, 0),
                                            1.5)  # .astype(np.float)
        # cls.MAP_TEMPLATE *= 1.1
        # cls.MAP_TEMPLATE = np.clip(cls.MAP_TEMPLATE, 0, 255).astype(np.uint8)

        cls.MAP = cls.MAP_TEMPLATE
        # cls.MAP_TEMPLATE = cv2.GaussianBlur(cls.MAP, (0, 0), 1.1).astype(np.float)
        # cls.MAP_TEMPLATE *= 2
        # cls.MAP_TEMPLATE = np.clip(cls.MAP_TEMPLATE, 0, 255).astype(np.uint8)

    MAP_VERSION = 0

    def __init__(self, use_tflite: bool = True):
        self.map_rotated = deque(maxlen=10)
        self.map_rotate_in_config = None
        if use_tflite:
            from overtrack_cv.core.tflite import TFLiteModel

            self.model = TFLiteModel(
                os.path.join(os.path.dirname(__file__), "data",
                             "minimap_filter.tflite"))
        else:
            from overtrack_cv.core.tf import load_model

            self.model = load_model(
                os.path.join(os.path.dirname(__file__), "data",
                             "minimap_filter")
                # "C:/Users/simon/overtrack_2/training/apex_minimap/v3/v15/checkpoint"
            )
            # from tensorflow.python.keras.saving import export_saved_model
            # export_saved_model(self.model, os.path.join(os.path.dirname(__file__), 'data', 'minimap_filter'), serving_only=True)

        self.current_game: Optional[CurrentGame] = None
        self.current_composite: Optional[RingsComposite] = None

    def eager_load(self):
        self.REGIONS.eager_load()
        self._check_for_rotate_setting()
        self._check_for_map_update()

    def _check_for_rotate_setting(self):
        try:
            # noinspection PyUnresolvedReferences
            from client.util import knownpaths

            games_path = knownpaths.get_path(knownpaths.FOLDERID.SavedGames,
                                             knownpaths.UserHandle.current)
            config_path = os.path.join(games_path, "Respawn", "Apex",
                                       "profile", "profile.cfg")
            value = None
            with open(config_path) as f:
                for line in f.readlines():
                    if line.startswith("hud_setting_minimapRotate"):
                        value = line.split()[1].strip().replace('"', "")
            if value:
                pvalue = value.lower() in ["1", "true"]
                logger.info(
                    f"Extracted hud_setting_minimapRotate: {value!r} from {config_path} - setting rotate to {pvalue}"
                )
                self.map_rotate_in_config = pvalue
            else:
                logger.info(
                    f"Could not find hud_setting_minimapRotate in {config_path} - setting rotate to autodetect"
                )
                self.map_rotate_in_config = None

        except:
            logger.exception(
                f"Failed to read hud_setting_minimapRotate from profile - setting rotate to autodetect"
            )
            self.map_rotate_in_config = None

    def _check_for_map_update(self):
        logger.info("Checking for map updates")
        try:
            r = requests.get(
                "https://overtrack-client-2.s3-us-west-2.amazonaws.com/dynamic/apex-map/current.json"
            )
            logger.info(f"Checking for map update: {r} {r.content!r}")
            if r.status_code == 404:
                logger.info("Map updates not enabled")
                return

            data = r.json()
            if data["version"] <= self.MAP_VERSION:
                logger.info(
                    f'Current version {self.MAP_VERSION} is up to date - update version is {data["version"]}'
                )
                return
            else:
                maps_path = os.path.join(
                    os.path.join(os.path.dirname(__file__), "data", "maps"))
                os.makedirs(maps_path, exist_ok=True)

                map_path = os.path.join(maps_path, f'{data["version"]}.png')
                if os.path.exists(map_path):
                    try:
                        self.__class__.load_map(map_path)
                    except:
                        logger.info("Map corrupted")
                        os.remove(map_path)
                    else:
                        logger.info(
                            f'Loaded map {data["version"]} from {map_path}')
                        return

                logger.info(
                    f'Downloading map {data["version"]} from {data["url"]} to {map_path}'
                )
                with requests.get(data["url"], stream=True) as r:
                    r.raise_for_status()
                    with open(map_path, "wb") as f:
                        for chunk in r.iter_content(chunk_size=8192):
                            if chunk:
                                f.write(chunk)
                self.__class__.load_map(map_path)

        except:
            logger.exception("Failed to check for map update")

    def update(self):
        self._check_for_rotate_setting()
        self._check_for_map_update()

    def process(self, frame: Frame):
        spectate_image = frame.image_yuv[40:40 + 30, 670:670 + 130, 0]
        _, spectate_image_t = cv2.threshold(spectate_image, 220, 255,
                                            cv2.THRESH_BINARY)
        is_spectate = np.max(
            cv2.matchTemplate(spectate_image_t, self.SPECTATE,
                              cv2.TM_CCORR_NORMED)) > 0.9

        if not is_spectate:
            map_image = frame.image[50:50 + 240, 50:50 + 240]
        else:
            map_image = frame.image[114:114 + 240, 50:50 + 240]

        map_image_y = cv2.cvtColor(map_image, cv2.COLOR_BGR2YUV)[:, :, 0]
        map_image_y = cv2.LUT(map_image_y, self.LUT)
        map_image_y = cv2.GaussianBlur(map_image_y, (0, 0),
                                       1.5).astype(np.float)
        map_image_y *= 1.5
        map_image_y = np.clip(map_image_y, 0, 255).astype(np.uint8)

        t0 = time.perf_counter()
        filtered_minimap, filtered_rings = [
            np.clip(p[0], 0, 255).astype(np.uint8) for p in self.model.predict(
                np.expand_dims(map_image, axis=0).astype(np.float32))
        ]
        logger.debug(f"predict {(time.perf_counter() - t0) * 1000:.2f}")

        filtered = np.concatenate(
            (
                np.expand_dims(map_image_y[8:-8, 8:-8], axis=-1),
                cv2.resize(
                    filtered_rings,
                    (filtered_minimap.shape[1], filtered_minimap.shape[0]),
                    interpolation=cv2.INTER_NEAREST,
                ),
            ),
            axis=-1,
        )

        # location, min_loc, min_val = self._get_location(filtered[:, :, 0])
        location = None
        zoom = self._get_zoom(frame)

        t0 = time.perf_counter()
        if self.map_rotate_in_config or (
                len(self.map_rotated) and
            (sum(self.map_rotated) / len(self.map_rotated)) > 0.75):
            # 75% of last 10 frames said map was rotated - check rotated first
            logger.debug(f"Checking rotated first")
            bearing = self._get_bearing(frame, frame.debug_image)
            if bearing is not None:
                location = self._get_location(map_image_y, bearing, zoom=zoom)
                logger.debug(f"Got rotated location={location}")
            if (location is None or location.match > self.THRESHOLD
                ) and self.map_rotate_in_config is None:
                # try unrotated
                alt_location = self._get_location(map_image_y, zoom=zoom)
                logger.debug(f"Got unrotated location={alt_location}")
                if location is None or alt_location.match < location.match:
                    location = alt_location
                    bearing = None
        else:
            logger.debug(f"Checking unrotated first")
            location = self._get_location(map_image_y, zoom=zoom)
            logger.debug(f"Got unrotated location={location}")
            if location.match > self.THRESHOLD and self.map_rotate_in_config is None:
                bearing = self._get_bearing(frame, frame.debug_image)
                if bearing is not None:
                    alt_location = self._get_location(map_image_y,
                                                      bearing,
                                                      zoom=zoom)
                    logger.debug(f"Got rotated location={alt_location}")
                    if alt_location.match < location.match:
                        location = alt_location
                    else:
                        bearing = None
        logger.debug(f"match {(time.perf_counter() - t0) * 1000:.2f}")

        logger.debug(f"Got location: {location}")
        if location:
            self.map_rotated.append(location.bearing is not None)

            t0 = time.perf_counter()
            self._update_composite(frame, location, filtered_rings)
            logger.debug(
                f"update composite {(time.perf_counter() - t0) * 1000:.2f}")

            t0 = time.perf_counter()
            blur = cv2.GaussianBlur(filtered, (0, 0), 4)

            blur[:, :, 0] = 0
            edges = self.filter_edge(blur, 50, 20, 20, 10)
            edges[:5, :] = 0
            edges[-5:, :] = 0
            edges[:, :5] = 0
            edges[:, -5:] = 0
            logger.debug(
                f"filter edges {(time.perf_counter() - t0) * 1000:.2f}")

            t0 = time.perf_counter()
            frame.apex.minimap = Minimap(
                location,
                None,
                None,
                spectate=is_spectate,
                rings_composite=self.current_composite,
                version=3,
            )
            logger.debug(
                f"get circles {(time.perf_counter() - t0) * 1000:.2f}")

            try:
                _draw_map_location(
                    frame.debug_image,
                    frame.minimap,
                    self.MAP,
                    self.offset_x,
                    self.offset_y,
                    self.MAP_TEMPLATE,
                    filtered,
                    edges,
                    self.current_composite,
                )
            except:
                logger.exception("Failed to draw debug map location")

            return True
        elif location:
            try:
                _draw_map_location(
                    frame.debug_image,
                    Minimap(
                        location,
                        None,
                        None,
                    ),
                    self.MAP,
                    self.MAP_TEMPLATE,
                    filtered,
                    None,
                    self.current_composite,
                )
            except Exception as e:
                pass
                # traceback.print_exc(e)
        return False

    def _get_zoom(self, frame):
        zoom = 1
        if "game_time" in frame:
            if frame.game_time > 1100:
                # round 5 closing / ring 6
                zoom = 0.375
            elif frame.game_time > 980:
                # round 4 closing / ring 5
                zoom = 0.75
        return zoom

    def _get_bearing(self, frame: Frame,
                     debug_image: Optional[np.ndarray]) -> Optional[int]:
        bearing_image = self.REGIONS["bearing"].extract_one(
            frame.image_yuv[:, :, 0])
        _, bearing_thresh = cv2.threshold(bearing_image, 190, 255,
                                          cv2.THRESH_BINARY)

        if debug_image is not None:
            debug_image[90:90 + bearing_image.shape[0],
                        1020:1020 + bearing_image.shape[1], ] = cv2.cvtColor(
                            bearing_image, cv2.COLOR_GRAY2BGR)
            debug_image[90:90 + bearing_image.shape[0],
                        1100:1100 + bearing_image.shape[1], ] = cv2.cvtColor(
                            bearing_thresh, cv2.COLOR_GRAY2BGR)

        bearing = imageops.tesser_ocr(
            bearing_thresh,
            expected_type=int,
            engine=ocr.tesseract_ttlakes_digits,
            warn_on_fail=False,
        )
        if bearing is None or not 0 <= bearing <= 360:
            logger.debug(f"Got invalid bearing: {bearing}")
            return None
        if bearing is not None:
            logger.debug(f"Got bearing={bearing}")
            return bearing
        else:
            return None

    def _get_location(
        self,
        region: np.ndarray,
        bearing: Optional[int] = None,
        zoom: Optional[float] = None,
        base_template=None,
    ) -> Location:
        if base_template is None:
            base_template = self.MAP_TEMPLATE

        rot = None
        if bearing is None:
            map_template = base_template
        else:
            height, width = base_template.shape[:2]
            rot = cv2.getRotationMatrix2D(
                (base_template.shape[1] // 2, base_template.shape[0] // 2),
                bearing - 360,
                1,
            )
            map_template = cv2.warpAffine(base_template, rot, (width, height))

        if zoom and zoom != 1:
            region = cv2.resize(region, (0, 0), fx=zoom, fy=zoom)

        # cv2.imshow('map_template', map_template)
        match = cv2.matchTemplate(map_template, region, cv2.TM_SQDIFF_NORMED)
        min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(match)

        coords = (
            min_loc[0] + int(240 * zoom) // 2 - 8,
            min_loc[1] + int(240 * zoom) // 2 - 8,
        )
        if rot is not None:
            inv = cv2.invertAffineTransform(rot)
            coords = cv2.transform(np.array([[coords]]), inv)[0][0]

        return Location(
            tuple(np.array(coords) - (self.offset_x, self.offset_y)),
            min_val,
            bearing=bearing,
            zoom=zoom,
        )  # , match

    def _update_composite(self, frame: Frame, location: Location,
                          filtered: np.ndarray) -> None:
        current_game: Optional[CurrentGame] = getattr(frame, "current_game",
                                                      None)
        game_time: Optional[float] = getattr(frame, "game_time", None)

        if current_game:
            if current_game is not self.current_game:
                logger.info(
                    f"Creating new RingsComposite for {frame.current_game}")
                self.current_game = frame.current_game
                self.current_composite = RingsComposite()
        else:
            self.current_game = None
            self.current_composite = None

        if self.current_game and game_time and self.current_composite:
            ring_state = apex_data.get_round_state(game_time)

            to_add = []
            if ring_state.ring_index and not ring_state.ring_closing:
                to_add.append(
                    (ring_state.ring_index, filtered[:, :, 1], "outer ring"))
            else:
                logger.debug(
                    f"Not adding outer ring to composite for game time {s2ts(game_time)}, closing={ring_state.ring_closing}"
                )

            if ring_state.next_ring_index:
                to_add.append(
                    (ring_state.next_ring_index, filtered[:, :,
                                                          0], "inner ring"))
            else:
                logger.debug(
                    f"Not adding inner ring to composite for game time {s2ts(game_time)}"
                )

            for index, image, name in to_add:
                logger.debug(
                    f"Adding {name} to ring composite {index} (approx {np.sum(image > 128)} observed ring pixels) for game time {s2ts(game_time)}"
                )

                # TODO: handle zoom, handle non-rotating
                if location.bearing is None:
                    image_t = image
                else:
                    image = cv2.copyMakeBorder(
                        image,
                        image.shape[0] // 5,
                        image.shape[0] // 5,
                        image.shape[1] // 5,
                        image.shape[1] // 5,
                        cv2.BORDER_CONSTANT,
                    )
                    image_t = cv2.warpAffine(
                        image,
                        cv2.getRotationMatrix2D(
                            (image.shape[1] // 2, image.shape[0] // 2),
                            360 - location.bearing,
                            1,
                        ),
                        (image.shape[0], image.shape[1]),
                    )
                image_t = cv2.resize(image_t, (0, 0),
                                     fx=location.zoom,
                                     fy=location.zoom)

                if index not in self.current_composite.images:
                    self.current_composite.images[
                        index] = SerializableRingsComposite(
                            np.zeros((
                                self.MAP_TEMPLATE.shape[0] // 2,
                                self.MAP_TEMPLATE.shape[1] // 2,
                            )))
                target = self.current_composite.images[index].array

                # TODO: handle borders
                try:
                    y = location.y // 2 - image_t.shape[0] // 2
                    x = location.x // 2 - image_t.shape[1] // 2
                    target[y:y + image_t.shape[0],
                           x:x + image_t.shape[1]] += image_t.astype(
                               np.float) / 255.0
                except:
                    logger.exception("Failed to add ring to composite")

    def filter_edge(
        self,
        im: np.ndarray,
        thresh: int,
        edge_type_box_size: int,
        edge_type_widening: int,
        edge_extraction_size: int,
    ) -> np.ndarray:
        thresh = im > thresh

        x_edge_prominent = cv2.boxFilter(im,
                                         0, (2, edge_type_box_size),
                                         normalize=True)
        y_edge_prominent = cv2.boxFilter(im,
                                         0, (edge_type_box_size, 2),
                                         normalize=True)
        greater = (x_edge_prominent > y_edge_prominent).astype(np.uint8)
        greater = cv2.dilate(greater, np.ones((1, edge_type_widening)))
        greater = cv2.erode(greater, np.ones((edge_type_widening, 1)))

        w_edge = thresh & (cv2.dilate(im, np.ones(
            (1, edge_extraction_size))) == im)
        h_edge = thresh & (cv2.dilate(im, np.ones(
            (edge_extraction_size, 1))) == im)

        # cv2.imshow('x_edge_prominent', x_edge_prominent)
        # cv2.imshow('y_edge_prominent', y_edge_prominent)
        # cv2.imshow('greater', greater * 255)
        # cv2.imshow('w_edge', w_edge.astype(np.uint8) * 255)
        # cv2.imshow('h_edge', h_edge.astype(np.uint8) * 255)

        edge = (w_edge * greater) + (h_edge * (1 - greater))
        return edge
class AgentSelectProcessor(Processor):

    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    AGENT_NAME_TEMPLATES: Dict[AgentName, np.ndarray] = {
        agent_name: cv2.copyMakeBorder(
            imageops.imread(
                os.path.join(os.path.dirname(__file__), "data", "agent_names",
                             agent_name.lower() + ".png"), 0),
            10,
            10,
            10,
            10,
            cv2.BORDER_CONSTANT,
        )
        #     cv2.resize(
        #     cv2.imread(os.path.join(os.path.dirname(__file__), 'data', 'agent_names', agent_name + '.png'), 0),
        #     (0, 0),
        #     fx=0.5,
        #     fy=0.5,
        # )
        for agent_name in agents
        # if os.path.exists(os.path.join(os.path.dirname(__file__), 'data', 'agent_names', agent_name.lower() + '.png'))
    }
    AGENT_TEMPLATE_REQUIRED_MATCH = 0.95

    RANK_TEMPLATES: Dict[str, Tuple[np.ndarray, np.ndarray]] = {
        str(os.path.basename(p)).rsplit(".", 1)[0]: load_rank_template(p)
        for p in glob.glob(
            os.path.join(os.path.dirname(__file__), "data", "ranks", "*.png"))
    }

    LOCK_IN_BUTTON_COLOR = (180, 210, 140)

    def __init__(self):
        pass

    def process(self, frame: Frame) -> bool:
        agent_name_yuv = self.REGIONS["agent_name"].extract_one(
            frame.image_yuv)
        agent_name_thresh = cv2.inRange(agent_name_yuv, (200, 85, 120),
                                        (255, 115, 150))
        # if hasattr(frame, 'source_image'):
        # 	cv2.imshow('agent_name_yuv', agent_name_yuv)
        # 	cv2.imshow('agent_name_thresh', agent_name_thresh)
        # 	cv2.imwrite(
        # 		os.path.join(os.path.dirname(__file__), 'data', 'agent_names', os.path.basename(frame.source_image)),
        # 		agent_name_thresh
        # 	)

        match, best_match = imageops.match_templates(
            agent_name_thresh,
            self.AGENT_NAME_TEMPLATES,
            method=cv2.TM_CCORR_NORMED,
            required_match=0.95,
            # verbose=True,
        )
        # self.REGIONS.draw(frame.debug_image)

        if match > self.AGENT_TEMPLATE_REQUIRED_MATCH:
            selected_agent_ims = self.REGIONS["selected_agents"].extract(
                frame.image)
            selected_agent_ims_gray = [
                255 - imageops.normalise(np.max(im, axis=2), bottom=50)
                for im in selected_agent_ims
            ]
            selected_agent_texts = imageops.tesser_ocr_all(
                selected_agent_ims_gray,
                engine=imageops.tesseract_lstm,
            )
            logger.info(f"Got selected_agent_texts={selected_agent_texts}")

            picking = True
            for i, text in enumerate(selected_agent_texts):
                for word in textops.strip_string(text, string.ascii_letters +
                                                 " .").split(" "):
                    match = levenshtein.ratio(word, best_match)
                    logger.debug(
                        f"Player {i}: Got match {match:.2f} for {word!r} = {best_match!r}"
                    )
                    if match > 0.7:
                        logger.info(
                            f"Found matching locked in agent {text!r} for selecting agent {best_match!r} - selection locked"
                        )
                        picking = False

            game_mode = imageops.ocr_region(frame, self.REGIONS, "game_mode")

            ranks = []
            for i, im in enumerate(self.REGIONS["player_ranks"].extract(
                    frame.image)):
                match, matched_rank = imageops.match_templates(
                    im,
                    self.RANK_TEMPLATES,
                    method=cv2.TM_SQDIFF,
                    use_masks=True,
                    required_match=15,
                    previous_match_context=("player_ranks", i),
                )
                ranks.append((matched_rank, round(match, 3)))

            player_name_ims = self.REGIONS["player_names"].extract(frame.image)
            player_name_gray = [
                255 - imageops.normalise(np.max(im, axis=2), bottom=50)
                for im in player_name_ims
            ]
            player_names = imageops.tesser_ocr_all(
                player_name_gray, engine=imageops.tesseract_lstm)

            frame.valorant.agent_select = AgentSelect(
                best_match,
                locked_in=not picking,
                map=imageops.ocr_region(frame, self.REGIONS, "map"),
                game_mode=game_mode,
                player_names=player_names,
                agents=selected_agent_texts,
                ranks=ranks,
                image=lazy_upload("agent_select",
                                  self.REGIONS.blank_out(frame.image),
                                  frame.timestamp,
                                  selection="last"),
            )
            draw_agent_select(frame.debug_image, frame.valorant.agent_select)
            return True

        return False
def load_agent_template(path):
    image = imageops.imread(path, -1)[5:-5, 5:-5]
    return image[:, :, :3], cv2.cvtColor(image[:, :, 3], cv2.COLOR_GRAY2BGR)
class WeaponProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "..", "..", "data", "regions",
                     "16_9.zip"))
    CLIP_DIGITS = [
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", "clip_digits",
                         f"{d}.png"),
            0,
        ) for d in string.digits
    ]
    AMMO_DIGITS = [
        imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", "ammo_digits",
                         f"{d}.png"),
            0,
        ) for d in string.digits
    ]

    def __init__(self):
        self.CLIP_WEIGHTS = self.AMMO_WEIGHTS = []
        for typ in "CLIP", "AMMO":
            weights = []
            for im in getattr(self, typ + "_DIGITS"):
                weight = np.zeros(im.shape, dtype=np.float)
                weight[im > 0] = 1 / np.sum(im > 0)
                weight[im == 0] = -3 / np.sum(im == 0)
                weights.append(weight)
            setattr(self, typ + "_WEIGHTS", weights)

    def eager_load(self):
        self.REGIONS.eager_load()

    def process(self, frame: Frame):
        y = cv2.cvtColor(frame.image, cv2.COLOR_BGR2YUV)[:, :, 0]

        weapon_images = self.REGIONS["weapon_names"].extract(y)
        weapon_images = [255 - imageops.normalise(i) for i in weapon_images]

        weapon_names = imageops.tesser_ocr_all(
            weapon_images,
            whitelist=string.ascii_uppercase,
            engine=imageops.tesseract_lstm,
            scale=2,
        )

        selected_weapons_regions = self.REGIONS[
            "selected_weapon_tell"].extract(frame.image)
        selected_weapons_colours = [
            np.median(r, axis=(0, 1)) for r in selected_weapons_regions
        ]

        def thresh_clip(im):
            im = np.max(im, axis=2)
            threshim = np.tile(im[:, 0], (im.shape[1], 1)).T
            im = cv2.subtract(im, threshim)
            tim = im > 20
            return tim

        frame.apex.weapons = Weapons(
            weapon_names,
            selected_weapons=(
                (
                    int(selected_weapons_colours[0][0]),
                    int(selected_weapons_colours[0][1]),
                    int(selected_weapons_colours[0][2]),
                ),
                (
                    int(selected_weapons_colours[1][0]),
                    int(selected_weapons_colours[1][1]),
                    int(selected_weapons_colours[1][2]),
                ),
            ),
            clip=self._ocr_digits(
                [im > 200 for im in self.REGIONS["clip"].extract(y)],
                self.CLIP_WEIGHTS),
            ammo=self._ocr_digits(
                [
                    thresh_clip(im)
                    for im in self.REGIONS["ammo"].extract(frame.image)
                ],
                self.AMMO_WEIGHTS,
            ),
        )

        # self.REGIONS.draw(frame.debug_image)
        _draw_weapons(frame.debug_image, frame.apex.weapons)

        return frame.apex.weapons.selected_weapons is not None

    def _ocr_digits(self, ims: List[np.ndarray],
                    weights: List[np.ndarray]) -> Optional[int]:
        digits = []
        # cv2.imshow('digits', np.hstack(ims).astype(np.uint8) * 255)
        for i, im in enumerate(ims):
            if np.sum(im) < 50:
                continue
            best = None
            for d, w in enumerate(weights):
                score = np.sum(np.multiply(im, w))
                if score > 0.95:
                    digits.append(str(d))
                    break
                if not best or score > best[0]:
                    best = score, d
            else:
                if best[0] > 0.1:
                    digits.append(str(best[1]))
                else:
                    logger.warning(
                        f"Unable to OCR clip digit {i} - best match: {best[1]} @ {best[0]:.2f}"
                    )
        if not digits:
            return None
        try:
            return int("".join(digits))
        except Exception as e:
            logger.warning(f"Unable to parse OCR of clip: {digits!r}: {e}")
            return None
Exemple #24
0
class EliminationsProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    WERE_TEMPLATE = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "were.png"), 0)
    WERE_MASK = cv2.dilate(WERE_TEMPLATE, np.ones((4, 4)))

    ELIMINATED_TEMPLATE = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "eliminated.png"), 0)
    ELIMINATED_MASK = cv2.dilate(ELIMINATED_TEMPLATE, np.ones((4, 4)))

    def process(self, frame: Frame) -> bool:
        region = self.REGIONS["eliminations"].extract_one(frame.image)
        region_b = np.min(region, axis=2)
        _, thresh = cv2.threshold(region_b, 200, 255, cv2.THRESH_BINARY)

        eliminated_locations = self._get_locations(
            imageops.matchTemplate(
                thresh,
                self.ELIMINATED_TEMPLATE,
                cv2.TM_SQDIFF_NORMED,
            ),
            0.6,
            region_name="eliminated",
        )
        if not eliminated_locations:
            return False

        self._draw_locations(
            frame.debug_image,
            self.REGIONS["eliminations"].regions[0][:2],
            eliminated_locations,
            self.ELIMINATED_TEMPLATE.shape,
            "ELIMINATED",
        )

        were_locations = self._get_locations(
            imageops.matchTemplate(
                thresh,
                self.WERE_TEMPLATE,
                cv2.TM_SQDIFF_NORMED,
            ),
            0.6,
            max_matches=2,
            region_name="were",
        )
        self._draw_locations(
            frame.debug_image,
            self.REGIONS["eliminations"].regions[0][:2],
            were_locations,
            self.WERE_TEMPLATE.shape,
            "WERE",
        )

        eliminated_by_image = None
        elimination_images = []
        for ((x, y), m) in eliminated_locations:
            if were_locations:
                is_were_eliminated = min([abs(y - y2) for ((_, y2), _) in were_locations]) < 10
            else:
                is_were_eliminated = False

            if not is_were_eliminated:
                line_x = self.REGIONS["eliminations"].regions[0][0] + x
                line_w = (frame.image.shape[1] // 2 - line_x) * 2
                name_x = line_x + 135
                name_w = line_w - 132
            else:
                line_x = self.REGIONS["eliminations"].regions[0][0] + x - 125
                line_w = (frame.image.shape[1] // 2 - line_x) * 2
                name_x = line_x + 295
                name_w = line_w - 285

            line_y = self.REGIONS["eliminations"].regions[0][1] + y - 4
            line_h = 40

            line = frame.image[line_y : line_y + line_h, name_x : name_x + name_w]
            if not is_were_eliminated:
                elimination_images.append(line)
            else:
                # cv2.imshow('line', frame.image[
                #                    line_y: line_y + line_h,
                #                    name_x: name_x + name_w
                #                    ])
                # cv2.waitKey(0)
                eliminated_by_image = line

            if frame.debug_image is not None:
                cv2.rectangle(frame.debug_image, (name_x, line_y), (name_x + name_w, line_y + line_h), (0, 255, 0))

        if eliminated_by_image is not None:
            elimination_images.append(eliminated_by_image)

        eliminations = big_noodle.ocr_all(elimination_images, channel="max", height=30)
        if eliminated_by_image is not None:
            eliminated_by = eliminations[-1]
            eliminations = eliminations[:-1]
        else:
            eliminated_by = None

        frame.overwatch.eliminations = Eliminations(eliminations, eliminated_by)
        return True

    def _get_locations(
        self, match: np.ndarray, thresh: float, max_matches: int = 6, region_name: str = ""
    ) -> List[Tuple[Tuple[int, int], float]]:
        match[match == np.inf] = 0

        r = []
        for i in range(max_matches):
            min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(match)
            if min_val > thresh:
                logger.debug(
                    f'Rejected match {min_val:.2f} at {min_loc} in region{" " + region_name if region_name else ""}'
                )
                break
            logger.debug(
                f'Found match {min_val:.2f} at {min_loc} in region{" " + region_name if region_name else ""}'
            )

            match[
                clamp(0, min_loc[1] - 20, match.shape[0]) : clamp(0, min_loc[1] + 20, match.shape[0]),
                clamp(0, min_loc[0] - 20, match.shape[1]) : clamp(0, min_loc[0] + 20, match.shape[1]),
            ] = 1
            r.append((min_loc, min_val))

        return r

    def _draw_locations(
        self,
        debug_image: Optional[np.ndarray],
        image_offset: Tuple[int, int],
        locations: List[Tuple[Tuple[int, int], float]],
        template_shape: Tuple[int, int],
        name: str,
    ):

        if debug_image is None:
            return

        # debug_image = debug_image[
        #     image_offset[1]:,
        #     image_offset[0]:
        # ]

        for i, ((x, y), match) in enumerate(locations):
            x += image_offset[0]
            y += image_offset[1]

            cv2.rectangle(debug_image, (x, y), (x + template_shape[1], y + template_shape[0]), (0, 0, 255), 1)
            cv2.putText(
                debug_image,
                f"{(x, y)}: {name}, match={match:.3f}, index={i}",
                (x, y - 10),
                cv2.FONT_HERSHEY_SIMPLEX,
                0.5,
                (0, 0, 255),
            )
class ScoreProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    RESULTS = ["VICTORY", "DEFEAT", "DRAW"]

    ROUND_N_COMPLETE = re.compile(r"R[DO]UN[DO]([0-9O]{1,2})COMPLETE")

    def process(self, frame: Frame) -> bool:
        if self.detect_score_screen(frame):
            logger.debug(
                f"Matched score screen with match={frame.overwatch.score_screen_match}"
            )

            score = self.parse_score_screen(frame)
            if score:
                frame.overwatch.score_screen = score

            return True
        elif self.detect_final_score(frame):
            logger.debug(
                f"Matched final score with match={frame.overwatch.final_score_match}"
            )

            final = self.parse_final_score(frame)
            if final:
                frame.overwatch.final_score = final

            return True

        return False

    def parse_score_screen(self, frame: Frame) -> Optional[ScoreScreen]:
        score_ims = self.REGIONS["score"].extract(frame.image)
        try:
            blue_score, red_score = big_noodle.ocr_all_int(score_ims,
                                                           height=212)
        except ValueError as ve:
            logger.warning(f"Failed to parse scores: {ve}")
            return None

        logger.debug(f"Got score {blue_score} / {red_score}")

        # manual thresholding
        im = self.REGIONS["round_text"].extract_one(frame.image)
        # debugops.manual_thresh_otsu(im)
        # im = np.min(im, axis=2)
        # _, thresh = cv2.threshold(im, imageops.otsu_thresh(im, 200, 255), 255, cv2.THRESH_BINARY)
        # round_text = big_noodle.ocr(thresh, threshold=None, height=70, debug=True)
        round_text = big_noodle.ocr(im,
                                    channel="min",
                                    threshold="otsu_above_mean",
                                    height=72,
                                    debug=False)

        round_number = None
        match = self.ROUND_N_COMPLETE.match(round_text)
        if match:
            round_number = int(match.group(1).replace("O", "0"))
            logger.debug(
                f"Got round {round_number} from round string {round_text!r}")
        else:
            logger.warning(
                f"Could not parse round from round string {round_text!r}")

        return ScoreScreen(blue_score, red_score, round_number)

    def parse_final_score(self, frame: Frame) -> Optional[FinalScore]:
        score_ims = self.REGIONS["final_score"].extract(frame.image)
        score_ims = [
            imageops.otsu_thresh_lb_fraction(im, 1.4) for im in score_ims
        ]
        try:
            blue_score, red_score = big_noodle.ocr_all_int(score_ims,
                                                           channel=None,
                                                           threshold=None,
                                                           height=127)
        except ValueError as ve:
            logger.warning(f"Failed to parse final score: {ve}")
            return None

        logger.debug(f"Got final score {blue_score} / {red_score}")

        im = cv2.cvtColor(
            self.REGIONS["final_result_text"].extract_one(frame.image),
            cv2.COLOR_BGR2HSV_FULL)
        thresh = cv2.inRange(im, (0, 0, 240), (255, 255, 255))
        result_text = big_noodle.ocr(thresh,
                                     channel=None,
                                     threshold=None,
                                     height=120,
                                     debug=False)
        matches = textops.matches(result_text, self.RESULTS)
        result: Optional[str]
        if np.min(matches) > 2:
            if blue_score is not None and red_score is not None:
                if blue_score > red_score:
                    result = "VICTORY"
                elif red_score > blue_score:
                    result = "DEFEAT"
                else:
                    result = "DRAW"
                logger.warning(
                    f"Could not identify result from {result_text!r} (match={np.min(matches)}) - "
                    f"using score {blue_score}, {red_score} to infer result={result}"
                )
            else:
                logger.warning(
                    f"Could not identify result from {result_text!r} (match={np.min(matches)}) and did not parse scores"
                )
                result = None
        else:
            result = self.RESULTS[arrayops.argmin(matches)]
            logger.debug(f"Got result {result} from {result_text!r}")

        return FinalScore(blue_score, red_score, result)

    COMPLETE_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data",
                     "complete_template.png"), 0)
    COMPLETE_TEMPLATE_THRESH = 0.6

    def detect_score_screen(self, frame: Frame) -> bool:
        text_region = self.REGIONS["complete_text"].extract_one(frame.image)
        text_region = cv2.resize(text_region, (0, 0), fx=0.5, fy=0.5)
        _, thresh = cv2.threshold(np.min(text_region, 2), 200, 255,
                                  cv2.THRESH_BINARY)
        frame.overwatch.score_screen_match = round(
            1 - float(
                np.min(
                    cv2.matchTemplate(thresh, self.COMPLETE_TEMPLATE,
                                      cv2.TM_SQDIFF_NORMED))), 5)
        return frame.overwatch.score_screen_match > self.COMPLETE_TEMPLATE_THRESH

    FINAL_SCORE_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data",
                     "final_score_template.png"), 0)
    FINAL_SCORE_TEMPLATE_THRESH = 0.6

    def detect_final_score(self, frame: Frame) -> bool:
        text_region = self.REGIONS["final_score_text"].extract_one(frame.image)
        text_region = cv2.resize(text_region, (0, 0), fx=0.75, fy=0.75)
        thresh = imageops.otsu_thresh_lb_fraction(text_region, 0.8)
        frame.overwatch.final_score_match = round(
            1 - float(
                np.min(
                    cv2.matchTemplate(thresh, self.FINAL_SCORE_TEMPLATE,
                                      cv2.TM_SQDIFF_NORMED))), 5)
        return frame.overwatch.final_score_match > self.FINAL_SCORE_TEMPLATE_THRESH
Exemple #26
0
class TimerProcessor(Processor):

    REGIONS = ExtractionRegionsCollection(os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    SPIKE_PLANTED_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "spike_planted.png"), 0
    )
    SPIKE_PLANTED_REQUIRED_MATCH = 0.5
    BUY_PHASE_TEMPLATE = imageops.imread(os.path.join(os.path.dirname(__file__), "data", "buy_phase.png"), 0)

    def process(self, frame: Frame) -> bool:
        # timer_y = self.REGIONS['timer'].extract_one(frame.image_yuv[:, :, 0])
        # _, timer_y_thresh = cv2.threshold(timer_y, 230, 255, cv2.THRESH_BINARY)

        spike_planted_im = self.REGIONS["spike_planted"].extract_one(frame.image)
        spike_planted_thresh = cv2.inRange(
            spike_planted_im,
            (0, 0, 130),
            (10, 10, 250),
        )
        # cv2.imshow('spike_planted_im', spike_planted_im)
        # cv2.imshow('spike_planted_thresh', spike_planted_thresh)
        # cv2.imshow('SPIKE_PLANTED_TEMPLATE', self.SPIKE_PLANTED_TEMPLATE)
        spike_planted_match = np.max(
            cv2.matchTemplate(
                spike_planted_thresh,
                self.SPIKE_PLANTED_TEMPLATE,
                cv2.TM_CCORR_NORMED,
            )
        )
        logger.debug(f"Spike planted match: {spike_planted_match:.2f}")
        spike_planted = bool(spike_planted_match > self.SPIKE_PLANTED_REQUIRED_MATCH)

        if spike_planted:
            buy_phase = False
        else:
            buy_phase_gray = np.min(self.REGIONS["buy_phase"].extract_one(frame.image), axis=2)
            buy_phase_norm = imageops.normalise(buy_phase_gray, bottom=80)
            # cv2.imshow('buy_phase_norm', buy_phase_norm)
            buy_phase_match = np.max(
                cv2.matchTemplate(buy_phase_norm, self.BUY_PHASE_TEMPLATE, cv2.TM_CCORR_NORMED)
            )
            logger.debug(f"Buy phase match: {buy_phase_match}")
            buy_phase = buy_phase_match > 0.9

        countdown_text = None
        if not spike_planted:
            countdown_gray = np.min(self.REGIONS["timer"].extract_one(frame.image), axis=2)
            countdown_norm = 255 - imageops.normalise(countdown_gray, bottom=80)
            # debugops.test_tesser_engines(
            #     countdown_norm
            # )
            countdown_text = imageops.tesser_ocr(
                countdown_norm,
                # whitelist=string.digits + ':.',
                engine=imageops.tesseract_only,
            )

            if len(countdown_text) > 6:
                countdown_text = None

        frame.valorant.timer = Timer(
            spike_planted=spike_planted,
            buy_phase=buy_phase,
            countdown=countdown_text,
        )
        draw_timer(frame.debug_image, frame.valorant.timer)
        return frame.valorant.timer.valid
class YourSquadProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))
    TEMPLATES = {
        k: imageops.imread(os.path.join(os.path.dirname(__file__), "data", k + ".png"), 0)
        for k in ["your_squad", "your_selection", "champion_squad"]
    }

    REQUIRED_MATCH = 0.95

    def __init__(self):
        self.duos = False
        self.duos_last_seen = 0

    def eager_load(self):
        self.REGIONS.eager_load()

    def process(self, frame: Frame):
        y = frame.image_yuv[:, :, 0]

        your_squad_image = self.REGIONS["your_squad"].extract_one(y)
        t, thresh = cv2.threshold(your_squad_image, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)

        match, key = imageops.match_templates(thresh, self.TEMPLATES, cv2.TM_CCORR_NORMED, self.REQUIRED_MATCH)
        frame.apex.your_squad_match = round(match, 4)
        if match < self.REQUIRED_MATCH:
            return False

        name1_trios = np.min(self.REGIONS["names"].extract_one(frame.image), axis=2)
        name1_duos = np.min(self.REGIONS["names_duos"].extract_one(frame.image), axis=2)
        # name1_thresh_value = max(np.max(name1_duos), np.max(name1_trios)) * 0.95
        name1_thresh_value = 240
        # logger.debug(f"Name thresh: {name1_thresh_value}")

        name1_trios_score = int(np.sum(name1_trios > name1_thresh_value))
        name1_duos_score = int(np.sum(name1_duos > name1_thresh_value))
        logger.debug(f"Trios name score: {name1_trios_score} vs duos name score: {name1_duos_score}")

        # self.duos = name1_duos_score and name1_duos_score > name1_trios_score
        self.duos = name1_trios_score < 100
        logger.info(f"Using duos={self.duos}")

        if key == "your_squad":
            names_region_name = "names_duos" if self.duos else "names"
            names = imageops.tesser_ocr_all(
                self.REGIONS[names_region_name].extract(y),
                engine=imageops.tesseract_lstm,
                invert=True,
            )
            frame.apex.your_squad = YourSquad(
                tuple(self._to_name(n) for n in names),
                mode="duos" if self.duos else None,
                images=lazy_upload(
                    "your_squad",
                    np.hstack(self.REGIONS[names_region_name].extract(frame.image)),
                    frame.timestamp,
                ),
            )
            self.REGIONS.draw(frame.debug_image)
            _draw_squad(frame.debug_image, frame.apex.your_squad)
        elif key == "your_selection":
            frame.apex.your_selection = YourSelection(
                name=self._to_name(
                    imageops.tesser_ocr(
                        self.REGIONS["names"].extract(y)[1],
                        engine=imageops.tesseract_lstm,
                        invert=True,
                    )
                ),
                image=lazy_upload(
                    "your_selection",
                    self.REGIONS["names"].extract(frame.image)[1],
                    frame.timestamp,
                ),
            )
            self.REGIONS.draw(frame.debug_image)
            _draw_squad(frame.debug_image, frame.apex.your_selection)
        elif key == "champion_squad":
            names_region_name = "names_duos" if self.duos else "names"
            names = imageops.tesser_ocr_all(
                self.REGIONS[names_region_name].extract(y),
                engine=imageops.tesseract_lstm,
                invert=True,
            )
            frame.apex.champion_squad = ChampionSquad(
                tuple(self._to_name(n) for n in names),
                mode="duos" if self.duos else None,
                images=lazy_upload(
                    "champion_squad",
                    np.hstack(self.REGIONS[names_region_name].extract(frame.image)),
                    frame.timestamp,
                ),
            )
            self.REGIONS.draw(frame.debug_image)
            _draw_squad(frame.debug_image, frame.apex.champion_squad)

        return True

    def _to_name(self, name_text: str) -> Optional[str]:
        for s1, s2 in "[(", "{(", "])", "})":
            name_text = name_text.replace(s1, s2)
        if len(name_text) > 3 and name_text[0] == "(" and name_text[-1] == ")":
            return name_text[1:-1].replace(" ", "").replace("(", "").replace(")", "")
        else:
            logger.warning(f"Got name {name_text!r} for player: not correctly bracketed")
            return name_text.replace(" ", "").replace("(", "").replace(")", "")
class KillfeedProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(
        os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    FRIENDLY_KILL_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "friendly_kill.png"),
        0)
    ENEMY_KILL_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "enemy_kill.png"), 0)
    KILL_THRESHOLD = 0.95

    AGENT_DEATH_TEMPLATES: Dict[AgentName, Tuple[np.ndarray, np.ndarray]] = {
        name: load_agent_template(name)
        for name in agents
    }
    AGENT_KILLER_TEMPLATES: Dict[AgentName, Tuple[np.ndarray, np.ndarray]] = {
        n: (a[0][:, ::-1], a[1][:, ::-1])
        for n, a in AGENT_DEATH_TEMPLATES.items()
    }
    AGENT_THRESHOLD = 0.1

    WEAPON_NAMES = [
        "classic",
        "shorty",
        "frenzy",
        "ghost",
        "sheriff",
        "stinger",
        "spectre",
        "bucky",
        "judge",
        "bulldog",
        "guardian",
        "phantom",
        "vandal",
        "marshal",
        "operator",
        "ares",
        "odin",
        "knife",
        "brimstone.incendiary",
        "brimstone.orbital_strike",
        "jett.blade_storm",
        "phoenix.blaze",
        "phoenix.hot_hands",
        "raze.blast_pack",
        "raze.boom_bot",
        "raze.paint_shells",
        "raze.showstopper",
        "sova.hunters_fury",
        "sova.shock_dart",
        "breach.aftershock",
        "viper.snake_bite",
    ]
    WEAPON_IMAGES = {
        n: imageops.imread(
            os.path.join(os.path.dirname(__file__), "data", "weapons",
                         n + ".png"), 0)
        for n in WEAPON_NAMES
    }
    for n, im in WEAPON_IMAGES.items():
        assert im.shape[
            1] <= 145, f"{n} image dimensions too high: {im.shape[1]}"
    WEAPON_TEMPLATES = {
        w: cv2.GaussianBlur(
            cv2.dilate(
                cv2.copyMakeBorder(image, 5, 35 - image.shape[0], 5,
                                   145 - image.shape[1], cv2.BORDER_CONSTANT),
                None,
            ),
            (0, 0),
            0.5,
        )
        for w, image in WEAPON_IMAGES.items()
    }
    WEAPON_THRESHOLD = 0.85

    WALLBANG_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "kill_modifiers",
                     "wallbang.png"), 0)
    HEADSHOT_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "kill_modifiers",
                     "headshot.png"), 0)
    KILL_MODIFIER_THRESHOLD = 0.75

    def process(self, frame: Frame) -> bool:
        x, y, w, h = self.REGIONS["killfeed"].regions[0]
        region = self.REGIONS["killfeed"].extract_one(frame.image)

        h, s, v = cv2.split(cv2.cvtColor(region, cv2.COLOR_BGR2HSV_FULL))
        h -= 50
        # cv2.imshow('h', h)
        # cv2.imshow('s', s)
        # cv2.imshow('v', v)

        friendly_kill_match = cv2.matchTemplate(h, self.FRIENDLY_KILL_TEMPLATE,
                                                cv2.TM_CCORR_NORMED)
        enemy_kill_match = cv2.matchTemplate(h, self.ENEMY_KILL_TEMPLATE,
                                             cv2.TM_CCORR_NORMED)
        kill_match = np.max(
            np.stack((friendly_kill_match, enemy_kill_match), axis=-1),
            axis=2,
        )

        kill_rows = []

        for i in range(9):
            mnv, mxv, mnl, mxl = cv2.minMaxLoc(kill_match)
            if mxv < self.KILL_THRESHOLD:
                break

            kill_match[
                max(0, mxl[1] - self.FRIENDLY_KILL_TEMPLATE.shape[0] //
                    2):min(mxl[1] + self.FRIENDLY_KILL_TEMPLATE.shape[0] //
                           2, kill_match.shape[0]),
                max(0, mxl[0] - self.FRIENDLY_KILL_TEMPLATE.shape[1] //
                    2):min(mxl[0] + self.FRIENDLY_KILL_TEMPLATE.shape[1] //
                           2, kill_match.shape[1]), ] = 0

            center = (
                int(mxl[0] + x + 20),
                int(mxl[1] + y + self.FRIENDLY_KILL_TEMPLATE.shape[0] // 2),
            )
            friendly_kill_v = friendly_kill_match[mxl[1], mxl[0]]
            enemy_kill_v = enemy_kill_match[mxl[1], mxl[0]]
            logger.debug(
                f"Found kill match at {center}: friendly_kill_v={friendly_kill_v:.4f}, enemy_kill_v={enemy_kill_v:.4f}"
            )

            kill_rows.append(
                KillRowPosition(
                    index=i,
                    match=round(float(mxv), 4),
                    center=center,
                    friendly=bool(friendly_kill_v > enemy_kill_v),
                ))

        kill_rows.sort(key=lambda r: r.center[1])
        if len(kill_rows):
            kills = []

            for row in kill_rows:
                killed_agent, killed_agent_match, killed_agent_x = self._parse_agent(
                    frame, row, True)
                if killed_agent_match > self.AGENT_THRESHOLD * 2:
                    continue

                killer_agent, killer_agent_match, killer_agent_x = self._parse_agent(
                    frame, row, False)
                if killer_agent_match > self.AGENT_THRESHOLD * 2:
                    continue

                if killed_agent_match > self.AGENT_THRESHOLD and killer_agent_match > self.AGENT_THRESHOLD:
                    # both invalid - dont bother logging
                    continue
                elif killed_agent_match > self.AGENT_THRESHOLD:
                    logger.warning(
                        f"Ignoring kill {row} - killed_agent_match={killed_agent_match:.1f} ({killed_agent})"
                    )
                    continue
                elif killer_agent_match > self.AGENT_THRESHOLD:
                    logger.warning(
                        f"Ignoring kill {row} - killer_agent_match={killer_agent_match:.1f} ({killer_agent})"
                    )
                    continue

                killed_name = self._parse_killed_name(frame, row,
                                                      killed_agent_x)
                if killed_name is None:
                    logger.warning(
                        f"Ignoring kill {row} - killed name failed to parse")
                    continue

                weapon, weapon_match, wallbang_match, headshot_match, weapon_x = self._parse_weapon(
                    frame, row, killer_agent_x, killer_agent)

                killer_name = self._parse_killer_name(frame, row,
                                                      killer_agent_x, weapon_x)
                if killer_name is None:
                    logger.warning(
                        f"Ignoring kill {row} - killer name failed to parse")
                    continue

                kill = Kill(
                    y=int(row.center[1]),
                    row_match=round(float(row.match), 4),
                    killer_friendly=row.friendly,
                    killer=KillfeedPlayer(
                        agent=killer_agent,
                        agent_match=round(killer_agent_match, 4),
                        name=killer_name,
                    ),
                    killed=KillfeedPlayer(
                        agent=killed_agent,
                        agent_match=round(killed_agent_match, 4),
                        name=killed_name,
                    ),
                    weapon=weapon,
                    weapon_match=round(weapon_match, 2),
                    wallbang=wallbang_match > self.KILL_MODIFIER_THRESHOLD,
                    wallbang_match=round(wallbang_match, 4),
                    headshot=headshot_match > self.KILL_MODIFIER_THRESHOLD,
                    headshot_match=round(headshot_match, 4),
                )
                kills.append(kill)
                logger.debug(f"Got kill: {kill}")

                if frame.debug_image is not None:
                    s = (
                        f"{row.match:.2f} | "
                        f"{killer_agent} ({killer_agent_match:.4f}) {killer_name!r} >"
                        f' {weapon} {"* " if kill.headshot else ""}{"- " if kill.wallbang else ""}> '
                        f"{killed_agent} ({killed_agent_match:.4f}) {killed_name!r}"
                    )
                    (w, _), _ = cv2.getTextSize(s, cv2.FONT_HERSHEY_SIMPLEX,
                                                0.6, 1)
                    for c, t in ((0, 0, 0), 3), ((0, 255, 128), 1):
                        cv2.putText(
                            frame.debug_image,
                            s,
                            (killer_agent_x - (w + 35), row.center[1] + 5),
                            cv2.FONT_HERSHEY_SIMPLEX,
                            0.6,
                            c,
                            t,
                        )

            if len(kills):
                frame.valorant.killfeed = Killfeed(kills=kills, )

                draw_weapon_templates(frame.debug_image, self.WEAPON_TEMPLATES)

                return True

        return False

    _last_image_id = None
    _last_image_names = set()

    def _get_region(self,
                    image,
                    y1,
                    y2,
                    x1,
                    x2,
                    c=None,
                    debug_name=None,
                    debug_image=None):
        if y1 < 0:
            y1 = image.shape[0] + y1
        if y2 < 0:
            y2 = image.shape[0] + y2
        if x1 < 0:
            x1 = image.shape[1] + x1
        if x2 < 0:
            x2 = image.shape[1] + x2
        if debug_image is not None:
            co = str2col(debug_name)
            cv2.rectangle(
                debug_image,
                (x1, y1),
                (x2, y2),
                co,
            )
            if id(debug_image) != self._last_image_id:
                self._last_image_names.clear()
                self._last_image_id = id(debug_image)
            if debug_name and debug_name not in self._last_image_names:
                self._last_image_names.add(debug_name)
                for col, th in ((0, 0, 0), 3), (co, 1):
                    cv2.putText(
                        debug_image,
                        debug_name,
                        (x1, y1 - 5),
                        cv2.FONT_HERSHEY_SIMPLEX,
                        0.5,
                        col,
                        th,
                    )
        region = image[y1:y2, x1:x2, ]
        if c is not None:
            region = region[:, :, c]
        return region

    def _parse_agent(self, frame: Frame, row: KillRowPosition,
                     agent_death: bool) -> Tuple[AgentName, float, int]:
        if agent_death:
            region_x = frame.image.shape[1] - 120
            agent_im = self._get_region(
                frame.image,
                row.center[1] - 20,
                row.center[1] + 20,
                -120,
                -35,
                debug_name="killed_agent",
                debug_image=frame.debug_image,
            )
        else:
            region_x = frame.image.shape[1] - 600
            agent_im = self._get_region(
                frame.image,
                row.center[1] - 20,
                row.center[1] + 20,
                -600,
                row.center[0] - 60,
                debug_name="killer_agent",
                debug_image=frame.debug_image,
            )

        import matplotlib.pyplot as plt

        # cv2.imwrite(f'C:/tmp/agents2/{region_x}.png', agent_im)
        agent_matches = {}
        agent_match_m = []
        t = None
        for a, t in [self.AGENT_KILLER_TEMPLATES,
                     self.AGENT_DEATH_TEMPLATES][agent_death].items():
            match = cv2.matchTemplate(agent_im,
                                      t[0],
                                      cv2.TM_SQDIFF_NORMED,
                                      mask=t[1])
            agent_matches[a] = match
            agent_match_m.append(match)
            # print(a, f'{np.min(match):,}')
            # cv2.imshow(a, t[0])

        import matplotlib.pyplot as plt

        plt.figure()
        plt.imshow(np.vstack(agent_match_m))
        for i, a in enumerate(agents):
            plt.text(-40, int((i + 0.5) * match.shape[0]), a)
            plt.text(
                match.shape[1],
                int((i + 0.5) * match.shape[0]),
                f"{np.min(agent_matches[a]):,}".rjust(12),
                fontdict={"family": "monospace"},
            )
        plt.show()

        agent_match_m = np.min(np.stack(agent_match_m, axis=-1), axis=2)
        mnv, mxv, mnl, mxl = cv2.minMaxLoc(agent_match_m)
        # print(mnv, mnl)

        # print(agent_matches)
        # print(list(zip(self.AGENT_DEATH_TEMPLATES.keys(), agent_match_m)))
        # import matplotlib.pyplot as plt

        # plt.figure()
        # t = self.AGENT_DEATH_TEMPLATES['Breach']
        # plt.imshow(cv2.matchTemplate(agent_im, t[0], cv2.TM_SQDIFF, mask=t[1]))
        # plt.show()

        # plt.figure()
        # plt.imshow(cv2.cvtColor(agent_im, cv2.COLOR_BGR2RGB))
        # plt.figure()
        # plt.imshow(cv2.cvtColor(np.hstack([v[0] for v in self.AGENT_DEATH_TEMPLATES.values()]), cv2.COLOR_BGR2RGB))
        # plt.show()

        agent, agent_match = None, float("inf")
        for a, m in agent_matches.items():
            v = m[mnl[1], mnl[0]]
            if v < agent_match:
                agent_match = v
                agent = a

        return agent, float(agent_match), int(region_x + mnl[0])

    def _parse_killed_name(self, frame, row, killed_agent_x) -> Optional[str]:
        killed_name_gray = self._get_region(
            frame.image_yuv,
            row.center[1] - 10,
            row.center[1] + 10,
            row.center[0] + 10,
            killed_agent_x - 10,
            0,
            debug_name="killed_name",
            debug_image=frame.debug_image,
        )
        if killed_name_gray.shape[1] == 0:
            return None
        killed_name_norm = 255 - imageops.normalise(killed_name_gray, min=170)
        return textops.strip_string(
            imageops.tesser_ocr(killed_name_norm,
                                engine=imageops.tesseract_lstm).upper(),
            alphabet=string.ascii_uppercase + string.digits + "# ",
        )

    def _parse_weapon(
            self, frame, row, killer_agent_x,
            killer_agent) -> Tuple[Optional[str], float, float, float, int]:
        weapon_region_left = killer_agent_x + 60
        weapon_region_right = row.center[0] - 20
        weapon_gray = self._get_region(
            frame.image_yuv,
            row.center[1] - 15,
            row.center[1] + 17,
            weapon_region_left,
            weapon_region_right,
            0,
            debug_name="weapon",
            debug_image=frame.debug_image,
        )
        if weapon_gray.shape[1] == 0:
            return None, 0, 0, 0, weapon_region_right
        weapon_adapt_thresh = np.clip(
            np.convolve(np.percentile(weapon_gray, 10, axis=0),
                        [0.2, 0.6, 0.2],
                        mode="same"),
            160,
            200,
        )
        weapon_thresh = ((weapon_gray - weapon_adapt_thresh > 30) *
                         255).astype(np.uint8)

        kill_modifiers_thresh = weapon_thresh[:, -75:]
        _, wallbang_match, _, wallbang_loc = cv2.minMaxLoc(
            cv2.matchTemplate(kill_modifiers_thresh, self.WALLBANG_TEMPLATE,
                              cv2.TM_CCORR_NORMED))
        _, headshot_match, _, headshot_loc = cv2.minMaxLoc(
            cv2.matchTemplate(kill_modifiers_thresh, self.HEADSHOT_TEMPLATE,
                              cv2.TM_CCORR_NORMED))
        wallbang_match, headshot_match = float(wallbang_match), float(
            headshot_match)
        logger.debug(
            f"wallbang_match={wallbang_match:.2f}, headshot_match={headshot_match:.2f}"
        )

        right = weapon_thresh.shape[1] - 1
        if wallbang_match > self.KILL_MODIFIER_THRESHOLD:
            right = min(right, (weapon_thresh.shape[1] - 75) + wallbang_loc[0])
        if headshot_match > self.KILL_MODIFIER_THRESHOLD:
            right = min(right, (weapon_thresh.shape[1] - 75) + headshot_loc[0])
        if right != weapon_thresh.shape[1] - 1:
            logger.debug(f"Using right={right} (clipping kill modifier)")
            weapon_thresh = weapon_thresh[:, :right]

        # cv2.imwrite(f'C:/tmp/agents2/weap.png', weapon_thresh)

        # import matplotlib.pyplot as plt
        # f, figs = plt.subplots(4)
        # figs[0].imshow(weapon_gray)
        # figs[1].plot(weapon_adapt_thresh)
        # figs[2].imshow(weapon_gray - weapon_adapt_thresh)
        # figs[3].imshow(weapon_thresh)
        # plt.show()
        # cv2.imshow('weapon_thresh', weapon_thresh)

        weapon_image = cv2.dilate(
            cv2.copyMakeBorder(
                weapon_thresh,
                5,
                5,
                5,
                5,
                cv2.BORDER_CONSTANT,
            ),
            np.ones((2, 2)),
        )
        contours, hierarchy = imageops.findContours(weapon_image,
                                                    cv2.RETR_EXTERNAL,
                                                    cv2.CHAIN_APPROX_SIMPLE)
        contours_xywh = [(cnt, cv2.boundingRect(cnt)) for cnt in contours]

        best_weap_match, best_weap = 0, None

        for cnt, (x1, y1, w, h) in sorted(contours_xywh,
                                          key=lambda cnt_xywh: cnt_xywh[1][0],
                                          reverse=True):
            x2, y2 = x1 + w, y1 + h
            a = cv2.contourArea(cnt)

            fromright = weapon_image.shape[1] - x2

            ignore = False
            if w > 145:
                logger.warning(f"Ignoring weapon contour with w={w}")
                ignore = True
            if fromright < 30:
                # contour is far right - could be small agent ability, so be less strict
                if a < 100 or h < 10:
                    logger.debug(
                        f"Ignoring right weapon contour {cv2.boundingRect(cnt)}, fromright={fromright}, a={a}"
                    )
                    ignore = True
                else:
                    logger.debug(
                        f"Allowing potential ability contour {cv2.boundingRect(cnt)}, fromright={fromright}, a={a}"
                    )
            elif a < 200 or h < 16:
                # print('ignore', cv2.boundingRect(cnt), x2, a)
                logger.debug(
                    f"Ignoring weapon contour {cv2.boundingRect(cnt)}, fromright={fromright}, a={a}"
                )
                ignore = True

            if ignore:
                if frame.debug_image is not None and a > 1:
                    cv2.drawContours(
                        frame.debug_image,
                        [cnt],
                        -1,
                        (0, 128, 255),
                        1,
                        offset=(
                            weapon_region_left - 5,
                            row.center[1] - 20,
                        ),
                    )
                continue

            # Draw contour to image, padding l=5, r=10, t=2, b=2
            # The extra width padding prevents abilities matching small parts of large guns
            weapon_im = np.zeros((h + 4, w + 15), dtype=np.uint8)
            cv2.drawContours(
                weapon_im,
                [cnt],
                -1,
                255,
                -1,
                offset=(
                    -x1 + 5,
                    -y1 + 2,
                ),
            )
            if weapon_im.shape[1] > 150:
                weapon_im = weapon_im[:, :150]
            weapon_match, weapon = imageops.match_templates(
                weapon_im,
                {
                    w: t
                    for w, t in self.WEAPON_TEMPLATES.items() if "." not in w
                    or w.lower().startswith(killer_agent.lower() + ".")
                },
                cv2.TM_CCORR_NORMED,
                template_in_image=False,
                required_match=0.96,
                verbose=False,
            )
            if best_weap_match < weapon_match:
                best_weap_match, best_weap = weapon_match, weapon

            valid = weapon_match > self.WEAPON_THRESHOLD

            if frame.debug_image is not None and a > 1:
                cv2.drawContours(
                    frame.debug_image,
                    [cnt],
                    -1,
                    (128, 255, 0) if valid else (0, 0, 255),
                    1,
                    offset=(
                        weapon_region_left - 5,
                        row.center[1] - 20,
                    ),
                )

            if valid:
                if frame.debug_image is not None:
                    x, y = 600, row.center[1] - 15
                    frame.debug_image[y:y + weapon_thresh.shape[0], x:x +
                                      weapon_thresh.shape[1]] = cv2.cvtColor(
                                          weapon_thresh, cv2.COLOR_GRAY2BGR)
                    x -= weapon_im.shape[1] + 10
                    frame.debug_image[y:y + weapon_im.shape[0],
                                      x:x + weapon_im.shape[1]] = cv2.cvtColor(
                                          weapon_im, cv2.COLOR_GRAY2BGR)

                    cv2.line(
                        frame.debug_image,
                        (x, y + weapon_im.shape[0] // 2),
                        (450, self.WEAPON_NAMES.index(weapon) * 40 + 120),
                        (0, 255, 0),
                        2,
                        cv2.LINE_AA,
                    )

                return (
                    weapon,
                    float(weapon_match),
                    float(wallbang_match),
                    float(headshot_match),
                    int(weapon_region_left + x1),
                )

        logger.warning(
            f"Unable to find weapon - best match was {best_weap!r} match={best_weap_match:.2f}"
        )
        return None, 0, 0, 0, weapon_region_right

    def _parse_killer_name(self, frame, row, killer_agent_x,
                           weapon_x) -> Optional[str]:
        killer_name_gray = self._get_region(
            frame.image_yuv,
            row.center[1] - 10,
            row.center[1] + 10,
            killer_agent_x + 35,
            weapon_x - 10,
            0,
            debug_name="killer_name",
            debug_image=frame.debug_image,
        )
        if killer_name_gray.shape[1] == 0:
            return None
        killer_name_norm = 255 - imageops.normalise(killer_name_gray, min=170)
        killer_name = textops.strip_string(
            imageops.tesser_ocr(killer_name_norm,
                                engine=imageops.tesseract_lstm).upper(),
            alphabet=string.ascii_uppercase + string.digits + "#",
        )
        return killer_name
Exemple #29
0
class MenuProcessor(Processor):
    REGIONS = ExtractionRegionsCollection(os.path.join(os.path.dirname(__file__), "data", "regions", "16_9.zip"))

    PLACEMENT_MATCHES_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "placement_matches.png"), 0
    )
    PLACEMENT_MATCHES_TEMPLATE_THRESHOLD = 0.6

    def process(self, frame: Frame) -> bool:
        if frame.overwatch.main_menu or frame.overwatch.play_menu:
            return True

        self.REGIONS.draw(frame.debug_image)
        if self.detect_main_menu(frame):
            version_region = self.REGIONS["version"].extract_one(frame.image)
            thresh = imageops.otsu_thresh_lb_fraction(version_region, 0.75)
            version = imageops.tesser_ocr(thresh, whitelist=string.digits + ".-", invert=True, scale=4, blur=2)

            frame.overwatch.main_menu = MainMenu(version=version)

            _draw_main_menu(frame.debug_image, frame.overwatch.main_menu)

            return True

        elif self.detect_play_menu(frame):
            # placement_region = self.REGIONS['placement_matches'].extract_one(frame.image)
            # placement_region = cv2.cvtColor(placement_region, cv2.COLOR_BGR2GRAY)
            # _, thresh = cv2.threshold(placement_region, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
            # match = 1 - float(np.min(cv2.matchTemplate(thresh, self.PLACEMENT_MATCHES_TEMPLATE, cv2.TM_SQDIFF_NORMED)))
            # is_placements = match > self.PLACEMENT_MATCHES_TEMPLATE_THRESHOLD
            #
            # if not is_placements:
            #     group_sr_region = self.REGIONS['group_sr'].extract_one(frame.image)
            #     color_variance = np.mean(np.var(group_sr_region, axis=(0, 1)))
            #     if color_variance < 100:
            #         # only one color - maybe placement
            #         logger.warning(f'Got low color variance ({color_variance:.2f}) - ignoring parsed SR')
            #         sr = None
            #     else:
            #         sr = self.read_sr(frame)
            # else:
            #     sr = None
            #
            # frame.overwatch.play_menu = PlayMenu(
            #     placements=is_placements,
            #     sr=sr,
            #     image=lazy_upload(
            #         'sr_full',
            #         self.REGIONS['sr_full'].extract_one(frame.image),
            #         frame.timestamp
            #     )
            # )
            #
            # _draw_play_menu(frame.debug_image, frame.overwatch.play_menu)

            return True

        return False

    # def read_sr(self, frame):
    #     group_sr_region = self.REGIONS['group_sr'].extract_one(frame.image)
    #     personal_sr_region = self.REGIONS['personal_sr'].extract_one(frame.image)
    #
    #     # try read personal SR using hue - this can read behind text on raw images
    #     personal_sr_hue = 255 - cv2.cvtColor(personal_sr_region, cv2.COLOR_BGR2HSV_FULL)[:, :, 0]
    #     sr = self._parse_sr(personal_sr_hue, 'personal_hue')
    #     if sr:
    #         return sr
    #
    #     # try read group using grayscale - requires group leader
    #     sr = self._parse_sr(np.min(personal_sr_region, axis=2), 'personal')
    #     if sr:
    #         return sr
    #
    #     # try read "group" SR region - this will reject SR if it is actual group SR
    #     sr = self._parse_sr(np.min(group_sr_region, axis=2), 'group')
    #     if sr:
    #         return sr
    #
    #     return sr
    #
    # def _parse_sr(self, im: np.ndarray, typ: str) -> Optional[int]:
    #     _, thresh = cv2.threshold(im, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
    #     thresh = cv2.dilate(thresh, np.ones((5, 5)))
    #
    #     labels, components = imageops.connected_components(thresh)
    #     components = sorted(components, key=lambda c: c.x)
    #     components = [
    #         c for c in components
    #         if c.y and c.x + c.w != im.shape[1] and c.y + c.h != im.shape[0] and
    #         (abs(c.h - 17) > 3 or c.w < 60) and
    #         c.x < 200
    #     ]
    #     if not len(components):
    #         logger.debug(f'{typ}: Got 0 components for sr')
    #         return None
    #
    #     leftmost = components[0].x + components[0].w
    #     if leftmost > 150:
    #         logger.warning(f'{typ}: Rank icon at {leftmost} - rejecting group SR')
    #         return None
    #
    #     logger.info(f'{typ}: Found rank icon at {leftmost}')
    #
    #     im = im[:, leftmost:leftmost + 150]
    #     result = imageops.tesser_ocr(im, int, engine=imageops.tesseract_only)
    #     logger.debug(f'{typ}: Parsed SR as {result}')
    #     if not result:
    #         result = imageops.tesser_ocr(im, int, engine=imageops.tesseract_lstm)
    #         logger.debug(f'{typ}: Parsed SR as {result}')
    #
    #     if result and 500 <= result <= 5000:
    #         return result
    #     else:
    #         logger.warning(f'{typ}: Got invalid SR: {result}')
    #         return None

    OVERWATCH_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "overwatch_template.png"), 0
    )
    OVERWATCH_TEMPLATE_THRESH = 0.6

    def detect_main_menu(self, frame: Frame) -> bool:
        text_region = self.REGIONS["overwatch_text"].extract_one(frame.image)
        text_region = cv2.resize(text_region, (0, 0), fx=0.5, fy=0.5)

        thresh = imageops.otsu_thresh_lb_fraction(text_region, 1.1)
        frame.overwatch.main_menu_match = round(
            1 - float(np.min(cv2.matchTemplate(thresh, self.OVERWATCH_TEMPLATE, cv2.TM_SQDIFF_NORMED))), 5
        )
        return frame.overwatch.main_menu_match > self.OVERWATCH_TEMPLATE_THRESH

    COMPETITIVE_TEMPLATE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "competitive_play.png"), 0
    )
    COMPETITIVE_TEMPLATE_LARGE = imageops.imread(
        os.path.join(os.path.dirname(__file__), "data", "competitive_play_large.png"), 0
    )
    COMPETITIVE_TEMPLATE_THRESH = 0.6

    def detect_play_menu(self, frame: Frame) -> bool:
        competitive_region = self.REGIONS["competitive_play"].extract_one(frame.image)
        competitive_region = cv2.resize(competitive_region, (0, 0), fx=0.5, fy=0.5)

        gray = np.min(competitive_region, axis=2)
        _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)
        match = 0.0
        for t in self.COMPETITIVE_TEMPLATE, self.COMPETITIVE_TEMPLATE_LARGE:
            match = max(match, round(1 - float(np.min(cv2.matchTemplate(thresh, t, cv2.TM_SQDIFF_NORMED))), 5))
        frame.overwatch.play_menu_match = match
        return frame.overwatch.play_menu_match > self.COMPETITIVE_TEMPLATE_THRESH