Merge pull request #99 from LmeSzinc/dev

Dev
This commit is contained in:
LmeSzinc 2023-09-20 13:37:01 +08:00 committed by GitHub
commit 8f2bfa625d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 510 additions and 74 deletions

BIN
assets/character/FuXuan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/character/Lynx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 226 KiB

View File

@ -152,20 +152,21 @@ class ModuleBase:
prev_image = image
timer.reset()
def image_crop(self, button):
def image_crop(self, button, copy=True):
"""Extract the area from image.
Args:
button(Button, tuple): Button instance or area tuple.
copy:
"""
if isinstance(button, Button):
return crop(self.device.image, button.area)
return crop(self.device.image, button.area, copy=copy)
elif isinstance(button, ButtonWrapper):
return crop(self.device.image, button.area)
return crop(self.device.image, button.area, copy=copy)
elif hasattr(button, 'area'):
return crop(self.device.image, button.area)
return crop(self.device.image, button.area, copy=copy)
else:
return crop(self.device.image, button)
return crop(self.device.image, button, copy=copy)
def image_color_count(self, button, color, threshold=221, count=50):
"""
@ -178,9 +179,14 @@ class ModuleBase:
Returns:
bool:
"""
image = self.image_crop(button)
mask = color_similarity_2d(image, color=color) > threshold
return np.sum(mask) > count
if isinstance(button, np.ndarray):
image = button
else:
image = self.image_crop(button, copy=False)
mask = color_similarity_2d(image, color=color)
cv2.inRange(mask, threshold, 255, dst=mask)
sum_ = cv2.countNonZero(mask)
return sum_ > count
def image_color_button(self, area, color, color_threshold=250, encourage=5, name='COLOR_BUTTON'):
"""

View File

@ -350,6 +350,7 @@
"Clara",
"DanHeng",
"DanHengImbibitorLunae",
"FuXuan",
"Gepard",
"Herta",
"Himeko",
@ -358,6 +359,7 @@
"Kafka",
"Luka",
"Luocha",
"Lynx",
"March7th",
"Natasha",
"Pela",

View File

@ -53,7 +53,7 @@ class GeneratedConfig:
# Group `DungeonSupport`
DungeonSupport_Use = 'when_daily' # always_use, when_daily, do_not_use
DungeonSupport_Character = 'FirstCharacter' # FirstCharacter, Arlan, Asta, Bailu, Blade, Bronya, Clara, DanHeng, DanHengImbibitorLunae, Gepard, Herta, Himeko, Hook, JingYuan, Kafka, Luka, Luocha, March7th, Natasha, Pela, Qingque, Sampo, Seele, Serval, SilverWolf, Sushang, Tingyun, TrailblazerDestruction, TrailblazerPreservation, Welt, Yanqing, Yukong
DungeonSupport_Character = 'FirstCharacter' # FirstCharacter, Arlan, Asta, Bailu, Blade, Bronya, Clara, DanHeng, DanHengImbibitorLunae, FuXuan, Gepard, Herta, Himeko, Hook, JingYuan, Kafka, Luka, Luocha, Lynx, March7th, Natasha, Pela, Qingque, Sampo, Seele, Serval, SilverWolf, Sushang, Tingyun, TrailblazerDestruction, TrailblazerPreservation, Welt, Yanqing, Yukong
# Group `DungeonStorage`
DungeonStorage_TrailblazePower = {}

View File

@ -87,7 +87,7 @@ class ConfigGenerator:
options=[dungeon.name for dungeon in DungeonList.instances.values() if dungeon.is_Cavern_of_Corrosion])
# Insert characters
from tasks.character.keywords import CharacterList
unsupported_characters = ["FuXuan", "Lynx"]
unsupported_characters = []
characters = [character.name for character in CharacterList.instances.values()
if character.name not in unsupported_characters]
option_add(keys='DungeonSupport.Character.option', options=characters)

View File

@ -350,6 +350,7 @@
"Clara": "Clara",
"DanHeng": "Dan Heng",
"DanHengImbibitorLunae": "Dan Heng • Imbibitor Lunae",
"FuXuan": "Fu Xuan",
"Gepard": "Gepard",
"Herta": "Herta",
"Himeko": "Himeko",
@ -358,6 +359,7 @@
"Kafka": "Kafka",
"Luka": "Luka",
"Luocha": "Luocha",
"Lynx": "Lynx",
"March7th": "March 7th",
"Natasha": "Natasha",
"Pela": "Pela",

View File

@ -350,6 +350,7 @@
"Clara": "クラーラ",
"DanHeng": "丹恒",
"DanHengImbibitorLunae": "丹恒・飲月",
"FuXuan": "符玄",
"Gepard": "ジェパード",
"Herta": "ヘルタ",
"Himeko": "姫子",
@ -358,6 +359,7 @@
"Kafka": "カフカ",
"Luka": "ルカ",
"Luocha": "羅刹",
"Lynx": "リンクス",
"March7th": "三月なのか",
"Natasha": "ナターシャ",
"Pela": "ペラ",

View File

@ -350,6 +350,7 @@
"Clara": "克拉拉",
"DanHeng": "丹恒",
"DanHengImbibitorLunae": "丹恒•饮月",
"FuXuan": "符玄",
"Gepard": "杰帕德",
"Herta": "黑塔",
"Himeko": "姬子",
@ -358,6 +359,7 @@
"Kafka": "卡芙卡",
"Luka": "卢卡",
"Luocha": "罗刹",
"Lynx": "玲可",
"March7th": "三月七",
"Natasha": "娜塔莎",
"Pela": "佩拉",

View File

@ -350,6 +350,7 @@
"Clara": "克拉拉",
"DanHeng": "丹恆",
"DanHengImbibitorLunae": "丹恆•飲月",
"FuXuan": "符玄",
"Gepard": "傑帕德",
"Herta": "黑塔",
"Himeko": "姬子",
@ -358,6 +359,7 @@
"Kafka": "卡芙卡",
"Luka": "盧卡",
"Luocha": "羅剎",
"Lynx": "玲可",
"March7th": "三月七",
"Natasha": "娜塔莎",
"Pela": "佩拉",

View File

@ -389,6 +389,11 @@ class Duration(Ocr):
}[lang]
return re.compile(regex_str)
def after_process(self, result):
result = super().after_process(result)
result = result.strip('.,。,')
return result
def format_result(self, result: str) -> timedelta:
"""
Do OCR on a duration, such as `18d 2h 13m 30s`, `2h`, `13m 30s`, `9s`

View File

@ -106,7 +106,7 @@ class Combat(CombatInteract, CombatPrepare, CombatState, CombatTeam, CombatSuppo
continue
if self.appear(COMBAT_TEAM_PREPARE):
self.interval_reset(COMBAT_PREPARE)
self._map_A_timer.reset()
self.map_A_timer.reset()
if self.appear(COMBAT_PREPARE, interval=2):
if not self.handle_combat_prepare():
return False

View File

@ -210,13 +210,23 @@ DAILY_QUEST_RIGHT_END = ButtonWrapper(
button=(401, 259, 411, 632),
),
)
MASK_DAILY_QUEST = ButtonWrapper(
name='MASK_DAILY_QUEST',
share=Button(
file='./assets/share/daily/reward/MASK_DAILY_QUEST.png',
area=(117, 308, 1173, 630),
search=(97, 288, 1193, 650),
color=(208, 208, 208),
button=(117, 308, 1173, 630),
),
)
OCR_DAILY_QUEST = ButtonWrapper(
name='OCR_DAILY_QUEST',
share=Button(
file='./assets/share/daily/reward/OCR_DAILY_QUEST.png',
area=(117, 257, 1173, 630),
search=(97, 237, 1193, 650),
color=(208, 206, 202),
button=(117, 257, 1173, 630),
area=(117, 308, 1173, 630),
search=(97, 288, 1193, 650),
color=(204, 202, 199),
button=(117, 308, 1173, 630),
),
)

View File

@ -1,9 +1,12 @@
import cv2
import numpy as np
from module.base.timer import Timer
from module.logger import *
from module.base.utils import crop
from module.logger import logger
from module.ocr.ocr import Ocr, OcrResultButton
from module.ocr.utils import split_and_pair_buttons
from tasks.battle_pass.keywords import KEYWORD_BATTLE_PASS_QUEST
from tasks.daily.assets.assets_daily_reward import *
from tasks.daily.camera import CameraUI
from tasks.daily.keywords import (
@ -19,12 +22,18 @@ from tasks.dungeon.keywords import KEYWORDS_DUNGEON_TAB
from tasks.dungeon.ui import DungeonUI
from tasks.item.consumable_usage import ConsumableUsageUI
from tasks.item.relics import RelicsUI
from tasks.battle_pass.keywords import KEYWORD_BATTLE_PASS_QUEST
class DailyQuestOcr(Ocr):
def __init__(self, button: ButtonWrapper, lang=None, name=None):
super().__init__(button, lang, name)
merge_thres_y = 20
def pre_process(self, image):
image = super().pre_process(image)
image = crop(image, OCR_DAILY_QUEST.area)
mask = MASK_DAILY_QUEST.matched_button.image
# Remove "+200Activity"
cv2.bitwise_and(image, mask, dst=image)
return image
def after_process(self, result):
result = super().after_process(result)
@ -91,8 +100,7 @@ class DailyQuestUI(DungeonUI):
def _ocr_single_page(self) -> list[OcrResultButton]:
ocr = DailyQuestOcr(OCR_DAILY_QUEST)
ocr.merge_thres_y = 20
results = ocr.matched_ocr(self.device.image, [DailyQuestState, DailyQuest])
results = ocr.matched_ocr(self.device.image, [DailyQuestState, DailyQuest], direct_ocr=True)
if len(results) < 8:
logger.warning(f"Recognition failed at {8 - len(results)} quests on one page")

View File

@ -248,7 +248,7 @@ class Dungeon(DungeonUI, DungeonEvent, Combat):
area = area_offset((-50, -150, 0, 0), offset=self.config.ASSETS_RESOLUTION)
skip_first_screenshot = True
self._map_A_timer.reset()
self.map_A_timer.reset()
handled = False
while 1:
if skip_first_screenshot:
@ -265,7 +265,7 @@ class Dungeon(DungeonUI, DungeonEvent, Combat):
logger.info(f'No destructible object')
if not handled:
break
if self._map_A_timer.reached():
if self.map_A_timer.reached():
break
return handled

View File

@ -4,7 +4,7 @@ from module.base.timer import Timer
from module.logger import logger
from tasks.map.assets.assets_map_control import ROTATION_SWIPE_AREA
from tasks.map.control.joystick import JoystickContact, MapControlJoystick
from tasks.map.control.waypoint import Waypoint, WaypointRun, WaypointStraightRun, ensure_waypoint
from tasks.map.control.waypoint import Waypoint, ensure_waypoint
from tasks.map.minimap.minimap import Minimap
from tasks.map.resource.const import diff_to_180_180
@ -76,7 +76,7 @@ class MapControl(MapControlJoystick):
self,
contact: JoystickContact,
waypoint: Waypoint,
end_point_opt=True,
end_opt=True,
skip_first_screenshot=False
):
"""
@ -88,7 +88,7 @@ class MapControl(MapControlJoystick):
`with JoystickContact(self) as contact:`
waypoint:
Position to goto, (x, y)
end_point_opt:
end_opt:
True to enable endpoint optimizations,
character will smoothly approach target position
skip_first_screenshot:
@ -98,7 +98,7 @@ class MapControl(MapControlJoystick):
self.device.stuck_record_clear()
self.device.click_record_clear()
end_point_opt = end_point_opt and waypoint.end_point_opt
end_opt = end_opt and waypoint.end_opt
allow_2x_run = waypoint.speed in ['2x_run']
allow_straight_run = waypoint.speed in ['2x_run', 'straight_run']
allow_run = waypoint.speed in ['2x_run', 'straight_run', 'run']
@ -117,20 +117,20 @@ class MapControl(MapControlJoystick):
self.minimap.update(self.device.image)
# Arrive
if self.minimap.is_position_near(waypoint.position, threshold=waypoint.get_threshold(end_point_opt)):
if self.minimap.is_position_near(waypoint.position, threshold=waypoint.get_threshold(end_opt)):
logger.info(f'Arrive {waypoint}')
break
# Switch run case
diff = self.minimap.position_diff(waypoint.position)
if end_point_opt:
if end_opt:
if allow_2x_run and diff < 20:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow 2x_run')
allow_2x_run = False
if allow_straight_run and diff < 15:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow straight_run')
direction_interval = Timer(0.2)
self._map_2x_run_timer.reset()
self.map_2x_run_timer.reset()
allow_straight_run = False
if allow_run and diff < 7:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow run')
@ -160,7 +160,7 @@ class MapControl(MapControlJoystick):
direction_interval.reset()
self.handle_map_2x_run(run=True)
elif allow_straight_run:
# Run with 2x_run button
# Run straight forward
# - Set rotation once
# - Continuous fine-tuning direction
# - Disable 2x_run
@ -204,7 +204,7 @@ class MapControl(MapControlJoystick):
def goto(
self,
waypoints,
*waypoints,
skip_first_screenshot=True
):
"""
@ -218,8 +218,6 @@ class MapControl(MapControlJoystick):
skip_first_screenshot:
"""
logger.hr('Goto', level=1)
if not isinstance(waypoints, list):
waypoints = [waypoints]
waypoints = [ensure_waypoint(point) for point in waypoints]
logger.info(f'Go along {len(waypoints)} waypoints')
end_list = [False for _ in waypoints]
@ -231,35 +229,36 @@ class MapControl(MapControlJoystick):
self._goto(
contact=contact,
waypoint=point,
end_point_opt=end,
end_opt=end,
skip_first_screenshot=skip_first_screenshot
)
skip_first_screenshot = True
end_point = waypoints[-1]
if end_point.end_point_rotation is not None:
self.rotation_set(end_point.end_point_rotation, threshold=end_point.end_point_rotation_threshold)
if end_point.end_rotation is not None:
logger.hr('End rotation', level=1)
self.rotation_set(end_point.end_rotation, threshold=end_point.end_rotation_threshold)
if __name__ == '__main__':
# Control test in Himeko trail
# Must manually enter Himeko trail first and dismiss popup
self = MapControl('alas')
# Control test in Himeko trial
# Must manually enter Himeko trial first and dismiss popup
self = MapControl('src')
self.minimap.set_plane('Jarilo_BackwaterPass', floor='F1')
self.device.screenshot()
self.minimap.init_position((519, 359))
# Visit 3 items
self.goto([
WaypointRun((577.6, 363.4)),
])
self.goto([
WaypointStraightRun((577.5, 369.4), end_point_rotation=200),
])
self.goto([
WaypointRun((581.5, 387.3)),
WaypointRun((577.4, 411.5)),
])
self.goto(
Waypoint((577.6, 363.4)),
)
self.goto(
Waypoint((577.5, 369.4), end_rotation=200),
)
self.goto(
Waypoint((581.5, 387.3)),
Waypoint((577.4, 411.5)),
)
# Goto boss
self.goto([
WaypointStraightRun((607.6, 425.3)),
])
self.goto(
Waypoint((607.6, 425.3)),
)

View File

@ -1,6 +1,9 @@
import math
from functools import cached_property
import cv2
import numpy as np
from module.base.timer import Timer
from module.device.method.maatouch import MaatouchBuilder
from module.device.method.minitouch import CommandBuilder, insert_swipe, random_normal_distribution
@ -36,10 +39,8 @@ class JoystickContact:
Can not lift finger when:
- Process is force terminated
"""
builder = self.builder
if self.is_downed:
builder.up().commit()
builder.send()
self.up()
logger.info('JoystickContact ends')
else:
logger.info('JoystickContact ends but it was never downed')
@ -98,6 +99,13 @@ class JoystickContact:
point = (int(round(point[0])), int(round(point[1])))
return point
def up(self):
builder = self.builder
if self.is_downed:
builder.up().commit()
builder.send()
self.prev_point = None
def set(self, direction, run=True):
"""
Set joystick to given position
@ -110,6 +118,13 @@ class JoystickContact:
point = JoystickContact.direction2screen(direction, run=run)
builder = self.builder
if self.is_downed and not self.main.joystick_speed():
if self.main.joystick_lost_timer.reached():
logger.warning(f'Joystick contact lost: {self.main.joystick_lost_timer}, re-down')
self.up()
else:
self.main.joystick_lost_timer.reset()
if self.is_downed:
points = insert_swipe(p0=self.prev_point, p3=point, speed=20)
for point in points[1:]:
@ -121,20 +136,56 @@ class JoystickContact:
# Character starts moving, RUN button is still unavailable in a short time.
# Assume available in 0.3s
# We still have reties if 0.3s is incorrect.
self.main._map_2x_run_timer.set_current(0.7)
self.main.map_2x_run_timer.set_current(0.7)
self.main.joystick_lost_timer.reset()
self.prev_point = point
class MapControlJoystick(UI):
_map_A_timer = Timer(1)
_map_E_timer = Timer(1)
_map_2x_run_timer = Timer(1)
map_A_timer = Timer(1)
map_E_timer = Timer(1)
map_2x_run_timer = Timer(1)
joystick_lost_timer = Timer(1, count=2)
@cached_property
def joystick_center(self) -> tuple[float, float]:
def joystick_center(self) -> tuple[int, int]:
x1, y1, x2, y2 = JOYSTICK.area
return (x1 + x2) / 2, (y1 + y2) / 2
return int((x1 + x2) // 2), int((y1 + y2) // 2)
@cached_property
def DirectionRemapData(self):
d = JoystickContact.RADIUS_RUN[1] * 2
mx = np.zeros((d, d), dtype=np.float32)
my = np.zeros((d, d), dtype=np.float32)
for i in range(d):
for j in range(d):
mx[i, j] = d / 2 + i / 2 * np.cos(2 * np.pi * j / d)
my[i, j] = d / 2 + i / 2 * np.sin(2 * np.pi * j / d)
return mx, my
def joystick_speed(self) -> str:
"""
Returns:
str: 'run', 'walk', ''
"""
# About 1.5ms
x, y = self.joystick_center
radius = JoystickContact.RADIUS_RUN[1]
image = self.image_crop((x - radius, y - radius, x + radius, y + radius), copy=False)
image = cv2.remap(image, *self.DirectionRemapData, cv2.INTER_CUBIC)
# 190~205
run = image[185:210, :]
if self.image_color_count(run, color=(223, 199, 145), threshold=221, count=100):
return 'run'
# 90~100
walk = image[85:105, :]
if self.image_color_count(walk, color=(235, 235, 235), threshold=221, count=50):
return 'walk'
return ''
def map_get_technique_points(self):
"""
@ -162,9 +213,9 @@ class MapControlJoystick(UI):
Returns:
bool: If clicked.
"""
if self._map_A_timer.reached():
if self.map_A_timer.reached():
self.device.click(A_BUTTON)
self._map_A_timer.reset()
self.map_A_timer.reset()
return True
return False
@ -177,9 +228,9 @@ class MapControlJoystick(UI):
Returns:
bool: If clicked.
"""
if self._map_E_timer.reached():
if self.map_E_timer.reached():
self.device.click(E_BUTTON)
self._map_E_timer.reset()
self.map_E_timer.reset()
return True
return False
@ -194,13 +245,13 @@ class MapControlJoystick(UI):
"""
is_running = self.image_color_count(RUN_BUTTON, color=(208, 183, 138), threshold=221, count=100)
if run and not is_running and self._map_2x_run_timer.reached():
if run and not is_running and self.map_2x_run_timer.reached():
self.device.click(RUN_BUTTON)
self._map_2x_run_timer.reset()
self.map_2x_run_timer.reset()
return True
if not run and is_running and self._map_2x_run_timer.reached():
if not run and is_running and self.map_2x_run_timer.reached():
self.device.click(RUN_BUTTON)
self._map_2x_run_timer.reset()
self.map_2x_run_timer.reset()
return True
return False

View File

@ -13,17 +13,17 @@ class Waypoint:
endpoint_threshold: int = 3
# Max move speed, '2x_run', 'straight_run', 'run', 'walk'
# See MapControl._goto() for details of each speed level
speed: str = '2x_run'
speed: str = 'straight_run'
"""
The following attributes are only be used if this waypoint is the end point of goto()
"""
# True to enable endpoint optimizations, character will smoothly approach target position
# False to stop all controls at arrive
end_point_opt: bool = True
end_opt: bool = True
# Set rotation after arrive, 0~360
end_point_rotation: int = None
end_point_rotation_threshold: int = 15
end_rotation: int = None
end_rotation_threshold: int = 15
def __str__(self):
return f'Waypoint({self.position})'

347
tasks/map/interact/aim.py Normal file
View File

@ -0,0 +1,347 @@
import cv2
import numpy as np
from module.base.decorator import cached_property, del_cached_property
from module.base.utils import Points, image_size, load_image
from module.config.utils import dict_to_kv
from module.logger import logger
from tasks.base.ui import UI
def inrange(image, lower=0, upper=255):
"""
Get the coordinates of pixels in range.
Equivalent to `np.array(np.where(lower <= image <= upper))` but faster.
Note that this method will change `image`.
`cv2.findNonZero()` is faster than `np.where`
points = np.array(np.where(y > 24)).T[:, ::-1]
points = np.array(cv2.findNonZero((y > 24).astype(np.uint8)))[:, 0, :]
`cv2.inRange(y, 24)` is faster than `y > 24`
cv2.inRange(y, 24, 255, dst=y)
y = y > 24
Returns:
np.ndarray: Shape (N, 2)
E.g. [[x1, y1], [x2, y2], ...]
"""
cv2.inRange(image, lower, upper, dst=image)
try:
return np.array(cv2.findNonZero(image))[:, 0, :]
except IndexError:
# Empty result
# IndexError: too many indices for array: array is 0-dimensional, but 3 were indexed
return np.array([])
def subtract_blur(image, radius=3, negative=False):
"""
If you care performance more than quality:
- radius=3, use medianBlur
- radius=5,7,9,11, use GaussianBlur
- radius>11, use stackBlur (requires opencv >= 4.7.0)
Args:
image:
radius:
negative:
Returns:
np.ndarray:
"""
if radius <= 3:
blur = cv2.medianBlur(image, radius)
elif radius <= 11:
blur = cv2.GaussianBlur(image, (radius, radius), 0)
else:
blur = cv2.stackBlur(image, (radius, radius), 0)
if negative:
cv2.subtract(blur, image, dst=blur)
else:
cv2.subtract(image, blur, dst=blur)
return blur
def remove_border(image, radius):
"""
Paint edge pixels black.
No returns, changes are written to `image`
Args:
image:
radius:
"""
width, height = image_size(image)
image[:, :radius + 1] = 0
image[:, width - radius:] = 0
image[:radius + 1, :] = 0
image[height - radius:, :] = 0
def create_circle(min_radius, max_radius):
"""
Create a circle with min_radius <= R <= max_radius.
1 represents circle, 0 represents background
Args:
min_radius:
max_radius:
Returns:
np.ndarray:
"""
circle = np.ones((max_radius * 2 + 1, max_radius * 2 + 1), dtype=np.uint8)
center = np.array((max_radius, max_radius))
points = np.array(np.meshgrid(np.arange(circle.shape[0]), np.arange(circle.shape[1]))).T
distance = np.linalg.norm(points - center, axis=2)
circle[distance < min_radius] = 0
circle[distance > max_radius] = 0
return circle
def draw_circle(image, circle, points):
"""
Add a circle onto image.
No returns, changes are written to `image`
Args:
image:
circle: Created from create_circle()
points: (x, y), center of the circle to draw
"""
width, height = image_size(circle)
x1 = -int(width // 2)
y1 = -int(height // 2)
x2 = width + x1
y2 = height + y1
for point in points:
x, y = point
# Fancy index is faster
index = image[y + y1:y + y2, x + x1:x + x2]
# print(index.shape)
cv2.add(index, circle, dst=index)
class Aim:
radius_enemy = (24, 25)
radius_item = (8, 10)
def __init__(self):
self.debug = False
self.draw_item = None
self.draw_enemy = None
self.points_item = None
self.points_enemy = None
def clear_image_cache(self):
self.draw_item = None
self.draw_enemy = None
self.points_item = None
self.points_enemy = None
del_cached_property(self, 'aimed_enemy')
del_cached_property(self, 'aimed_item')
@cached_property
def mask_interact(self):
return load_image('./assets/mask/MASK_MAP_INTERACT.png')
@cached_property
def circle_enemy(self):
return create_circle(*self.radius_enemy)
@cached_property
def circle_item(self):
return create_circle(*self.radius_item)
# @timer
def predict_enemy(self, h, v):
min_radius, max_radius = self.radius_enemy
width, height = image_size(v)
# Get white circle `y`
y = subtract_blur(h, 3, negative=False)
cv2.inRange(h, 168, 255, h)
cv2.bitwise_and(y, h, dst=y)
# Get red glow `v`
cv2.inRange(v, 168, 255, dst=v)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
cv2.dilate(v, kernel, dst=v)
# Remove noise and leave red circle only
cv2.bitwise_and(y, v, dst=y)
# cv2.imshow('predict_enemy', y)
# Remove game UI
cv2.bitwise_and(y, self.mask_interact, dst=y)
# Remove points on the edge, or draw_circle() will overflow
remove_border(y, max_radius)
# Get all pixels
points = inrange(y, lower=18)
if points.shape[0] > 1000:
logger.warning(f'AimDetector.predict_enemy() too many points to draw: {points.shape}')
# Draw circles
draw = np.zeros((height, width), dtype=np.uint8)
draw_circle(draw, self.circle_enemy, points)
if self.debug:
self.draw_enemy = cv2.multiply(draw, 4)
subtract_blur(draw, 3)
# Find peaks
points = inrange(draw, lower=36)
points = Points(points).group(threshold=10)
if points.shape[0] > 3:
logger.warning(f'AimDetector.predict_enemy() too many peaks: {points.shape}')
self.points_enemy = points
# print(points)
return points
# @timer
def predict_item(self, v):
min_radius, max_radius = self.radius_item
width, height = image_size(v)
# Get white circle `y`
y = subtract_blur(v, 9)
white = cv2.inRange(v, 112, 144)
cv2.bitwise_and(y, white, dst=y)
# Get cyan glow `v`
cv2.inRange(v, 0, 84, dst=v)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
cv2.dilate(v, kernel, dst=v)
# Remove noise and leave cyan circle only
cv2.bitwise_and(y, v, dst=y)
# Remove game UI
cv2.bitwise_and(y, self.mask_interact, dst=y)
# Remove points on the edge, or draw_circle() will overflow
remove_border(y, max_radius)
# Get all pixels
points = inrange(y, lower=18)
# print(points.shape)
if points.shape[0] > 1000:
logger.warning(f'AimDetector.predict_item() too many points to draw: {points.shape}')
# Draw circles
draw = np.zeros((height, width), dtype=np.uint8)
draw_circle(draw, self.circle_item, points)
if self.debug:
self.draw_item = cv2.multiply(draw, 2)
subtract_blur(draw, 7)
# Find peaks
points = inrange(draw, lower=64)
points = Points(points).group(threshold=10)
if points.shape[0] > 3:
logger.warning(f'AimDetector.predict_item() too many peaks: {points.shape}')
self.points_item = points
# print(points)
return points
# @timer
def predict(self, image, enemy=True, item=True, show_log=True, debug=False):
"""
Predict `aim` on image, costs about 10.0~10.5ms.
Args:
image:
enemy: True to predict enemy
item: True to predict item
show_log:
debug: True to show AimDetector image
"""
self.debug = debug
self.clear_image_cache()
if isinstance(image, str):
image = load_image(image)
# 1.5~2.0ms
yuv = cv2.cvtColor(image, cv2.COLOR_RGB2YUV)
v = yuv[:, :, 2]
h = yuv[:, :, 0]
# 4.0~4.5ms
if enemy:
self.predict_enemy(h.copy(), v.copy())
# 3.0~3.5ms
if item:
self.predict_item(v.copy())
if show_log:
kv = {}
if self.aimed_enemy:
kv['enemy'] = self.aimed_enemy
if self.aimed_item:
kv['item'] = self.aimed_item
if kv:
logger.info(f'Aimed: {dict_to_kv(kv)}')
if debug:
self.show_aim()
def show_aim(self):
if self.draw_enemy is None:
if self.draw_item is None:
return
else:
r = g = b = self.draw_item
else:
if self.draw_item is None:
r = g = b = self.draw_enemy
else:
r = self.draw_enemy
g = b = self.draw_item
image = cv2.merge([b, g, r])
cv2.imshow('AimDetector', image)
cv2.waitKey(1)
@cached_property
def aimed_enemy(self) -> tuple[int, int] | None:
if self.points_enemy is None:
return None
try:
_ = self.points_enemy[1]
logger.warning(f'Multiple aimed enemy found, using first point of {self.points_enemy}')
except IndexError:
pass
try:
point = self.points_enemy[0]
return tuple(point)
except IndexError:
return None
@cached_property
def aimed_item(self) -> tuple[int, int] | None:
if self.points_item is None:
return None
try:
_ = self.points_item[1]
logger.warning(f'Multiple aimed item found, using first point of {self.points_item}')
except IndexError:
pass
try:
point = self.points_item[0]
return tuple(point)
except IndexError:
return None
class AimDetectorMixin(UI):
@cached_property
def aim(self):
return Aim()
if __name__ == '__main__':
"""
Test
"""
self = AimDetectorMixin('src')
self.device.disable_stuck_detection()
while 1:
self.device.screenshot()
self.aim.predict(self.device.image, debug=True)