mirror of
https://github.com/LmeSzinc/StarRailCopilot.git
synced 2024-11-24 17:42:46 +00:00
304 lines
11 KiB
Python
304 lines
11 KiB
Python
import re
|
|
import typing as t
|
|
|
|
import cv2
|
|
import numpy as np
|
|
from scipy import signal
|
|
|
|
from module.base.timer import Timer
|
|
from module.base.utils import area_center, crop, rgb2luma
|
|
from module.logger import logger
|
|
from module.ocr.ocr import BoxedResult, OcrResultButton, OcrWhiteLetterOnComplexBackground
|
|
from tasks.base.ui import UI
|
|
from tasks.character.assets.assets_character_switch import *
|
|
from tasks.character.keywords import CharacterList, DICT_SORTED_RANGES, KEYWORDS_CHARACTER_LIST
|
|
|
|
|
|
class OcrCharacterName(OcrWhiteLetterOnComplexBackground):
|
|
merge_thres_x = 20
|
|
merge_thres_y = 10
|
|
|
|
def after_process(self, result):
|
|
result = result.replace('蛆', '妲')
|
|
# Dan Heng o.ImbibitorLunae
|
|
result = re.sub(r'[0Oo\-. ]{1,3}Imbi', 'Imbi', result)
|
|
result = result.replace('舟恒', '丹恒')
|
|
|
|
result = re.sub(r'院.?梅', '阮•梅', result)
|
|
|
|
return super().after_process(result)
|
|
|
|
|
|
class CharacterSwitch(UI):
|
|
characters: list[CharacterList] = []
|
|
character_current: CharacterList | None = None
|
|
character_buttons: list[OcrResultButton] = []
|
|
character_is_ranged: t.Optional[bool] = None
|
|
|
|
def character_update(self, skip_first_screenshot=True) -> list[CharacterList]:
|
|
"""
|
|
The following properties will be updated:
|
|
- self.characters
|
|
- self.character_current
|
|
- self.character_buttons
|
|
|
|
Pages:
|
|
in: page_main
|
|
"""
|
|
timeout = Timer(1, count=3).start()
|
|
while 1:
|
|
if skip_first_screenshot:
|
|
skip_first_screenshot = False
|
|
else:
|
|
self.device.screenshot()
|
|
|
|
# End
|
|
if timeout.reached():
|
|
logger.warning('Character update timeout')
|
|
break
|
|
|
|
# Ocr names
|
|
ocr = OcrCharacterName(OCR_MAP_CHARACTERS)
|
|
buttons = ocr.matched_ocr(self.device.image, keyword_classes=CharacterList)
|
|
# Add avatars
|
|
characters = [button.matched_keyword for button in buttons]
|
|
for trailblazer in self._iter_character_by_avatar():
|
|
if trailblazer.matched_keyword not in characters:
|
|
buttons.append(trailblazer)
|
|
|
|
# Set properties
|
|
buttons = sorted(buttons, key=lambda b: area_center(b.area)[1])
|
|
self.character_buttons = buttons
|
|
self.characters = [button.matched_keyword for button in self.character_buttons]
|
|
logger.attr('Characters', self.characters)
|
|
self.character_current = self._convert_selected_to_character(self._update_current_character())
|
|
|
|
# Must contain first character
|
|
if not buttons:
|
|
continue
|
|
expected_peaks = np.array([201, 279, 357, 435])
|
|
if buttons[0].area[3] < expected_peaks[0]:
|
|
break
|
|
else:
|
|
logger.info('No first character, retrying')
|
|
continue
|
|
|
|
return self.characters
|
|
|
|
def _iter_character_by_avatar(self) -> t.Iterable[OcrResultButton]:
|
|
"""
|
|
Detect characters that can't be found by OCR
|
|
"""
|
|
dict_template = {
|
|
KEYWORDS_CHARACTER_LIST.TrailblazerDestruction: [
|
|
TrailblazerDestructionMale,
|
|
TrailblazerDestructionFemale,
|
|
],
|
|
KEYWORDS_CHARACTER_LIST.TrailblazerPreservation: [
|
|
TrailblazerPreservationMale,
|
|
TrailblazerPreservationFemale,
|
|
|
|
],
|
|
KEYWORDS_CHARACTER_LIST.TrailblazerHarmony: [
|
|
TrailblazerHarmonyMale,
|
|
TrailblazerHarmonyFemale,
|
|
],
|
|
KEYWORDS_CHARACTER_LIST.Huohuo: [Huohuo],
|
|
KEYWORDS_CHARACTER_LIST.FuXuan: [FuXuan],
|
|
}
|
|
for character, templates in dict_template.items():
|
|
for template in templates:
|
|
template.load_search(TRAILBLAZER_SEARCH.area)
|
|
if template.match_template(self.device.image):
|
|
logger.info(f'Found avatar {template}')
|
|
# Create a fake OcrResultButton object
|
|
box = BoxedResult(box=template.button, text_img=None, ocr_text='', score=1.0)
|
|
button = OcrResultButton(boxed_result=box, matched_keyword=character)
|
|
yield button
|
|
|
|
def _update_current_character(self) -> list[int]:
|
|
"""
|
|
Returns:
|
|
list[int]: Selected index, 1 to 4.
|
|
"""
|
|
# 50px-width area starting from the right edge of HP bars
|
|
area = (1101, 151, 1151, 459)
|
|
# Y coordinates where the color peaks should be when character is selected
|
|
expected_peaks = np.array([201, 279, 357, 435])
|
|
expected_peaks_in_area = expected_peaks - area[1]
|
|
# Use Luminance to fit H264 video stream
|
|
image = rgb2luma(crop(self.device.image, area, copy=False))
|
|
# Remove character names
|
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
|
image = cv2.erode(image, kernel)
|
|
# To find peaks along Y
|
|
line = cv2.reduce(image, 1, cv2.REDUCE_AVG).flatten().astype(int)
|
|
|
|
# Find color peaks
|
|
parameters = {
|
|
'height': (60, 255),
|
|
'prominence': 30,
|
|
'distance': 5,
|
|
}
|
|
peaks, _ = signal.find_peaks(line, **parameters)
|
|
# Remove smooth peaks
|
|
parameters = {
|
|
'height': (5, 255),
|
|
'prominence': 5,
|
|
'distance': 5,
|
|
}
|
|
diff = -np.diff(line)
|
|
diff_peaks, _ = signal.find_peaks(diff, **parameters)
|
|
|
|
def is_steep_peak(y, threshold=5):
|
|
return np.abs(diff_peaks - y).min() <= threshold
|
|
|
|
def peak_to_selected(y, threshold=5):
|
|
distance = np.abs(expected_peaks_in_area - y)
|
|
return np.argmin(distance) + 1 if distance.min() < threshold else 0
|
|
|
|
selected = [peak_to_selected(peak) for peak in peaks if peak_to_selected(peak) and is_steep_peak(peak)]
|
|
logger.attr('CharacterSelected', selected)
|
|
return selected
|
|
|
|
def _convert_selected_to_character(self, selected: list[int]) -> CharacterList | None:
|
|
expected_peaks = [201, 279, 357, 435]
|
|
if not selected:
|
|
logger.warning(f'No current character')
|
|
logger.attr('CurrentCharacter', None)
|
|
return None
|
|
elif len(selected) == 1:
|
|
selected = selected[0]
|
|
else:
|
|
logger.warning(f'Too many current characters: {selected}, using first')
|
|
selected = selected[0]
|
|
|
|
expected_y = expected_peaks[selected - 1]
|
|
for button in self.character_buttons:
|
|
y = area_center(button.area)[1]
|
|
if expected_y - 78 < y < expected_y:
|
|
logger.attr('CurrentCharacter', button.matched_keyword)
|
|
return button.matched_keyword
|
|
|
|
logger.warning(f'Current character: {selected} does not belong to any detected character')
|
|
logger.attr('CurrentCharacter', None)
|
|
return None
|
|
|
|
def character_switch(self, character: CharacterList | str | int, skip_first_screenshot=True) -> bool:
|
|
"""
|
|
character_update() must be called before switching.
|
|
|
|
Args:
|
|
character: CharacterList object, or character name, or select index from 1 to 4.
|
|
skip_first_screenshot:
|
|
|
|
Returns:
|
|
bool: If chose success
|
|
"""
|
|
logger.info(f'Character choose: {character}')
|
|
if isinstance(character, int):
|
|
character = self._convert_selected_to_character([character])
|
|
if character is None:
|
|
return False
|
|
try:
|
|
index = self.characters.index(character) + 1
|
|
except IndexError:
|
|
logger.warning(f'Cannot choose character {character} as it was not detected')
|
|
return False
|
|
else:
|
|
if isinstance(character, str):
|
|
character = CharacterList.find(character)
|
|
try:
|
|
index = self.characters.index(character) + 1
|
|
except IndexError:
|
|
logger.warning(f'Cannot choose character {character} as it was not detected')
|
|
return False
|
|
|
|
button = self.character_buttons[index - 1]
|
|
interval = Timer(1, count=3)
|
|
count = 0
|
|
while 1:
|
|
if skip_first_screenshot:
|
|
skip_first_screenshot = False
|
|
else:
|
|
self.device.screenshot()
|
|
|
|
# End
|
|
selected = self._update_current_character()
|
|
if index in selected:
|
|
if len(selected) > 1:
|
|
logger.warning('Multiple selected characters found, cannot guarantee character selected')
|
|
return False
|
|
else:
|
|
logger.info('Character chose')
|
|
return True
|
|
if count > 3:
|
|
logger.warning('Failed to choose character, assume chose')
|
|
return False
|
|
|
|
if interval.reached() and self.is_in_main():
|
|
self.device.click(button)
|
|
interval.reset()
|
|
count += 1
|
|
|
|
def _get_ranged_character(self) -> CharacterList | bool:
|
|
# Check if it's using a ranged character already
|
|
for level, character_list in DICT_SORTED_RANGES.items():
|
|
if self.character_current in character_list:
|
|
logger.info(f'Already using a ranged character: {self.character_current}, range={level}')
|
|
return True
|
|
# Check if there is a ranged character in team
|
|
for level, character_list in DICT_SORTED_RANGES.items():
|
|
for ranged_character in character_list:
|
|
if ranged_character in self.characters:
|
|
logger.info(f'Use ranged character: {ranged_character}, range={level}')
|
|
return ranged_character
|
|
# No kids, as low camera height may miss enemy aim icon
|
|
if self.character_current.height == 'Kid':
|
|
# Switch to whoever tall
|
|
for height in ['Male', 'Lad', 'Lady', 'Miss', 'Maid', 'Boy', 'Girl']:
|
|
for tall_character in self.characters:
|
|
if tall_character.height == height:
|
|
logger.info(f'No kids, use tall character: {tall_character}')
|
|
return tall_character
|
|
# No ranged characters
|
|
logger.info('No ranged characters in team')
|
|
return False
|
|
|
|
def character_switch_to_ranged(self, update=True) -> bool | None:
|
|
"""
|
|
Args:
|
|
update: If update characters before switching
|
|
|
|
Returns:
|
|
bool: If using a ranged character now
|
|
or None if failed to switch
|
|
"""
|
|
if self.character_is_ranged is not None:
|
|
return self.character_is_ranged
|
|
|
|
logger.hr('Character switch to ranged')
|
|
if update:
|
|
self.character_update()
|
|
if self.character_current is None:
|
|
# Cannot switch, keep None to retry at next call
|
|
self.character_is_ranged = None
|
|
return None
|
|
|
|
character = self._get_ranged_character()
|
|
if character is True:
|
|
self.character_is_ranged = True
|
|
return True
|
|
elif character is False:
|
|
self.character_is_ranged = False
|
|
return False
|
|
|
|
success = self.character_switch(character)
|
|
if success:
|
|
self.character_is_ranged = True
|
|
return True
|
|
else:
|
|
# Cannot switch, keep None to retry at next call
|
|
self.character_is_ranged = None
|
|
return None
|