1
0
mirror of https://github.com/TheFunny/ArisuAutoSweeper synced 2026-06-09 20:04:52 +00:00

Upload code

This commit is contained in:
2023-11-01 15:33:35 +08:00
commit 6860f2eb72
415 changed files with 50990 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
import module.config.server as server_
from module.base.button import Button, ButtonWrapper, ClickButton, match_template
from module.base.timer import Timer
from module.base.utils import *
from module.config.config import AzurLaneConfig
from module.device.device import Device
from module.logger import logger
class ModuleBase:
config: AzurLaneConfig
device: Device
def __init__(self, config, device=None, task=None):
"""
Args:
config (AzurLaneConfig, str):
Name of the user config under ./config
device (Device, str):
To reuse a device.
If None, create a new Device object.
If str, create a new Device object and use the given device as serial.
task (str):
Bind a task only for dev purpose. Usually to be None for auto task scheduling.
If None, use default configs.
"""
if isinstance(config, AzurLaneConfig):
self.config = config
elif isinstance(config, str):
self.config = AzurLaneConfig(config, task=task)
else:
logger.warning('Alas ModuleBase received an unknown config, assume it is AzurLaneConfig')
self.config = config
if isinstance(device, Device):
self.device = device
elif device is None:
self.device = Device(config=self.config)
elif isinstance(device, str):
self.config.override(Emulator_Serial=device)
self.device = Device(config=self.config)
else:
logger.warning('Alas ModuleBase received an unknown device, assume it is Device')
self.device = device
self.interval_timer = {}
def match_template(self, button, interval=0, similarity=0.85):
"""
Args:
button (ButtonWrapper):
interval (int, float): interval between two active events.
similarity (int, float): 0 to 1.
Returns:
bool:
Examples:
Image detection:
```
self.device.screenshot()
self.appear(Button(area=(...), color=(...), button=(...))
self.appear(Template(file='...')
```
"""
self.device.stuck_record_add(button)
if interval and not self.interval_is_reached(button, interval=interval):
return False
appear = button.match_template(self.device.image, similarity=similarity)
if appear and interval:
self.interval_reset(button, interval=interval)
return appear
def match_color(self, button, interval=0, threshold=10):
"""
Args:
button (ButtonWrapper):
interval (int, float): interval between two active events.
threshold (int): 0 to 255, smaller means more similar
Returns:
bool:
"""
self.device.stuck_record_add(button)
if interval and not self.interval_is_reached(button, interval=interval):
return False
appear = button.match_color(self.device.image, threshold=threshold)
if appear and interval:
self.interval_reset(button, interval=interval)
return appear
def match_template_color(self, button, interval=0, similarity=0.85, threshold=30):
"""
Args:
button (ButtonWrapper):
interval (int, float): interval between two active events.
similarity (int, float): 0 to 1.
threshold (int): 0 to 255, smaller means more similar
Returns:
bool:
"""
self.device.stuck_record_add(button)
if interval and not self.interval_is_reached(button, interval=interval):
return False
appear = button.match_template_color(self.device.image, similarity=similarity, threshold=threshold)
if appear and interval:
self.interval_reset(button, interval=interval)
return appear
appear = match_template
def appear_then_click(self, button, interval=5, similarity=0.85):
appear = self.appear(button, interval=interval, similarity=similarity)
if appear:
self.device.click(button)
return appear
def wait_until_stable(self, button, timer=Timer(0.3, count=1), timeout=Timer(5, count=10)):
"""
A terrible method, don't rely too much on it.
"""
logger.info(f'Wait until stable: {button}')
prev_image = self.image_crop(button)
timer.reset()
timeout.reset()
while 1:
self.device.screenshot()
if timeout.reached():
logger.warning(f'wait_until_stable({button}) timeout')
break
image = self.image_crop(button)
if match_template(image, prev_image):
if timer.reached():
logger.info(f'{button} stabled')
break
else:
prev_image = image
timer.reset()
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, copy=copy)
elif isinstance(button, ButtonWrapper):
return crop(self.device.image, button.area, copy=copy)
elif hasattr(button, 'area'):
return crop(self.device.image, button.area, copy=copy)
else:
return crop(self.device.image, button, copy=copy)
def image_color_count(self, button, color, threshold=221, count=50):
"""
Args:
button (Button, tuple): Button instance or area.
color (tuple): RGB.
threshold: 255 means colors are the same, the lower the worse.
count (int): Pixels count.
Returns:
bool:
"""
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'):
"""
Find an area with pure color on image, convert into a Button.
Args:
area (tuple[int]): Area to search from
color (tuple[int]): Target color
color_threshold (int): 0-255, 255 means exact match
encourage (int): Radius of button
name (str): Name of the button
Returns:
Button: Or None if nothing matched.
"""
image = color_similarity_2d(self.image_crop(area), color=color)
points = np.array(np.where(image > color_threshold)).T[:, ::-1]
if points.shape[0] < encourage ** 2:
# Not having enough pixels to match
return None
point = fit_points(points, mod=image_size(image), encourage=encourage)
point = ensure_int(point + area[:2])
button_area = area_offset((-encourage, -encourage, encourage, encourage), offset=point)
return ClickButton(button=button_area, name=name)
def interval_reset(self, button, interval=5):
if isinstance(button, (list, tuple)):
for b in button:
self.interval_reset(b, interval)
return
if button is not None:
if button.name in self.interval_timer:
self.interval_timer[button.name].reset()
else:
self.interval_timer[button.name] = Timer(interval).reset()
def interval_clear(self, button, interval=5):
if isinstance(button, (list, tuple)):
for b in button:
self.interval_clear(b, interval)
return
if button is not None:
if button.name in self.interval_timer:
self.interval_timer[button.name].clear()
else:
self.interval_timer[button.name] = Timer(interval).clear()
def interval_is_reached(self, button, interval=5):
if button.name in self.interval_timer:
if self.interval_timer[button.name].limit != interval:
self.interval_timer[button.name] = Timer(interval)
else:
self.interval_timer[button.name] = Timer(interval)
return self.interval_timer[button.name].reached()
_image_file = ''
@property
def image_file(self):
return self._image_file
@image_file.setter
def image_file(self, value):
"""
For development.
Load image from local file system and set it to self.device.image
Test an image without taking a screenshot from emulator.
"""
if isinstance(value, Image.Image):
value = np.array(value)
elif isinstance(value, str):
value = load_image(value)
self.device.image = value
def set_lang(self, lang):
"""
For development.
Change lang and affect globally,
including assets and server specific methods.
"""
server_.set_lang(lang)
logger.attr('Lang', self.config.LANG)
+268
View File
@@ -0,0 +1,268 @@
import module.config.server as server
from module.base.decorator import cached_property, del_cached_property
from module.base.resource import Resource
from module.base.utils import *
from module.exception import ScriptError
class Button(Resource):
def __init__(self, file, area, search, color, button):
"""
Args:
file: Filepath to an assets
area: Area to crop template
search: Area to search from, 20px larger than `area` by default
color: Average color of assets
button: Area to click if assets appears on the image
"""
self.file: str = file
self.area: t.Tuple[int, int, int, int] = area
self.search: t.Tuple[int, int, int, int] = search
self.color: t.Tuple[int, int, int] = color
self._button: t.Tuple[int, int, int, int] = button
self.resource_add(self.file)
self._button_offset: t.Tuple[int, int] = (0, 0)
@property
def button(self):
return area_offset(self._button, self._button_offset)
def load_offset(self, button):
self._button_offset = button._button_offset
def clear_offset(self):
self._button_offset = (0, 0)
@cached_property
def image(self):
return load_image(self.file, self.area)
def resource_release(self):
del_cached_property(self, 'image')
self.clear_offset()
def __str__(self):
return self.file
__repr__ = __str__
def __eq__(self, other):
return str(self) == str(other)
def __hash__(self):
return hash(self.file)
def __bool__(self):
return True
def match_color(self, image, threshold=10) -> bool:
"""
Check if the button appears on the image, using average color
Args:
image (np.ndarray): Screenshot.
threshold (int): Default to 10.
Returns:
bool: True if button appears on screenshot.
"""
color = get_color(image, self.area)
return color_similar(
color1=color,
color2=self.color,
threshold=threshold
)
def match_template(self, image, similarity=0.85) -> bool:
"""
Detects assets by template matching.
To Some buttons, its location may not be static, `_button_offset` will be set.
Args:
image: Screenshot.
similarity (float): 0-1.
Returns:
bool.
"""
image = crop(image, self.search, copy=False)
res = cv2.matchTemplate(self.image, image, cv2.TM_CCOEFF_NORMED)
_, sim, _, point = cv2.minMaxLoc(res)
self._button_offset = np.array(point) + self.search[:2] - self.area[:2]
return sim > similarity
def match_template_color(self, image, similarity=0.85, threshold=30) -> bool:
"""
Template match first, color match then
Args:
image: Screenshot.
similarity (float): 0-1.
threshold (int): Default to 10.
Returns:
"""
matched = self.match_template(image, similarity=similarity)
if not matched:
return False
area = area_offset(self.area, offset=self._button_offset)
color = get_color(image, area)
return color_similar(
color1=color,
color2=self.color,
threshold=threshold
)
class ButtonWrapper(Resource):
def __init__(self, name='MULTI_ASSETS', **kwargs):
self.name = name
self.data_buttons = kwargs
self._matched_button: t.Optional[Button] = None
self.resource_add(self.name)
def resource_release(self):
del_cached_property(self, 'assets')
self._matched_button = None
def __str__(self):
return self.name
__repr__ = __str__
def __eq__(self, other):
return str(self) == str(other)
def __hash__(self):
return hash(self.name)
def __bool__(self):
return True
@cached_property
def buttons(self) -> t.List[Button]:
# for trial in [server.lang, 'share', 'cn']:
for trial in [server.lang, 'share', 'jp']:
assets = self.data_buttons.get(trial, None)
if assets is not None:
if isinstance(assets, Button):
return [assets]
elif isinstance(assets, list):
return assets
raise ScriptError(f'ButtonWrapper({self}) on server {server.lang} has no fallback button')
def match_color(self, image, threshold=10) -> bool:
for assets in self.buttons:
if assets.match_color(image, threshold=threshold):
self._matched_button = assets
return True
return False
def match_template(self, image, similarity=0.85) -> bool:
for assets in self.buttons:
if assets.match_template(image, similarity=similarity):
self._matched_button = assets
return True
return False
def match_template_color(self, image, similarity=0.85, threshold=30) -> bool:
for assets in self.buttons:
if assets.match_template_color(image, similarity=similarity, threshold=threshold):
self._matched_button = assets
return True
return False
@property
def matched_button(self) -> Button:
if self._matched_button is None:
return self.buttons[0]
else:
return self._matched_button
@property
def area(self) -> tuple[int, int, int, int]:
return self.matched_button.area
@property
def search(self) -> tuple[int, int, int, int]:
return self.matched_button.search
@property
def color(self) -> tuple[int, int, int]:
return self.matched_button.color
@property
def button(self) -> tuple[int, int, int, int]:
return self.matched_button.button
@property
def button_offset(self) -> tuple[int, int]:
return self.matched_button._button_offset
@property
def width(self) -> int:
return area_size(self.area)[0]
@property
def height(self) -> int:
return area_size(self.area)[1]
def load_offset(self, button):
"""
Load offset from another button.
Args:
button (Button, ButtonWrapper):
"""
if isinstance(button, ButtonWrapper):
button = button.matched_button
for b in self.buttons:
b.load_offset(button)
def clear_offset(self):
for b in self.buttons:
b.clear_offset()
class ClickButton:
def __init__(self, button, name='CLICK_BUTTON'):
self.area = button
self.button = button
self.name = name
def __str__(self):
return self.name
__repr__ = __str__
def __eq__(self, other):
return str(self) == str(other)
def __hash__(self):
return hash(self.name)
def __bool__(self):
return True
def match_template(image, template, similarity=0.85):
"""
Args:
image (np.ndarray): Screenshot
template (np.ndarray):
area (tuple): Crop area of image.
offset (int, tuple): Detection area offset.
similarity (float): 0-1. Similarity. Lower than this value will return float(0).
Returns:
bool:
"""
res = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED)
_, sim, _, point = cv2.minMaxLoc(res)
return sim > similarity
+181
View File
@@ -0,0 +1,181 @@
import typing as t
class TabWrapper:
def __init__(self, generator, prefix='', suffix='', newline=True):
"""
Args:
generator (CodeGenerator):
"""
self.generator = generator
self.prefix = prefix
self.suffix = suffix
self.newline = newline
self.nested = False
def __enter__(self):
if not self.nested and self.prefix:
self.generator.add(self.prefix, newline=self.newline)
self.generator.tab_count += 1
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.generator.tab_count -= 1
if self.suffix:
self.generator.add(self.suffix)
def __repr__(self):
return self.prefix
def set_nested(self, suffix=''):
self.nested = True
self.suffix += suffix
class CodeGenerator:
def __init__(self):
self.tab_count = 0
self.lines = []
def generate(self) -> t.Iterable[str]:
yield ''
def add(self, line, comment=False, newline=True):
self.lines.append(self._line_with_tabs(line, comment=comment, newline=newline))
def print(self):
lines = ''.join(self.lines)
print(lines)
def write(self, file: str = None):
lines = ''.join(self.lines)
with open(file, 'w', encoding='utf-8', newline='') as f:
f.write(lines)
def _line_with_tabs(self, line, comment=False, newline=True):
if comment:
line = '# ' + line
out = ' ' * self.tab_count + line
if newline:
out += '\n'
return out
def _repr(self, obj):
if isinstance(obj, str):
if '\n' in obj:
out = '"""\n'
with self.tab():
for line in obj.strip().split('\n'):
line = line.strip()
out += self._line_with_tabs(line)
out += self._line_with_tabs('"""', newline=False)
return out
return repr(obj)
def tab(self):
return TabWrapper(self)
def Empty(self):
self.add('')
def Import(self, text, empty=2):
for line in text.strip().split('\n'):
line = line.strip()
self.add(line)
for _ in range(empty):
self.Empty()
def Value(self, key=None, value=None, type_=None, **kwargs):
if key is not None:
if type_ is not None:
self.add(f'{key}: {type_} = {self._repr(value)}')
else:
self.add(f'{key} = {self._repr(value)}')
for key, value in kwargs.items():
self.Value(key, value)
def Comment(self, text):
for line in text.strip().split('\n'):
line = line.strip()
self.add(line, comment=True)
def CommentAutoGenerage(self, file):
"""
Args:
file: dev_tools.button_extract
"""
# Only leave one blank line at above
if len(self.lines) >= 2:
if self.lines[-2:] == ['\n', '\n']:
self.lines.pop(-1)
self.Comment('This file was auto-generated, do not modify it manually. To generate:')
self.Comment(f'``` python -m {file} ```')
self.Empty()
def List(self, key=None):
if key is not None:
return TabWrapper(self, prefix=str(key) + ' = [', suffix=']')
else:
return TabWrapper(self, prefix='[', suffix=']', newline=False)
def ListItem(self, value):
if isinstance(value, TabWrapper):
value.set_nested(suffix=',')
self.add(f'{self._repr(value)}')
return value
else:
self.add(f'{self._repr(value)},')
def Dict(self, key=None):
if key is not None:
return TabWrapper(self, prefix=str(key) + ' = {', suffix='}')
else:
return TabWrapper(self, prefix='{', suffix='}', newline=False)
def DictItem(self, key=None, value=None):
if isinstance(value, TabWrapper):
value.set_nested(suffix=',')
if key is not None:
self.add(f'{self._repr(key)}: {self._repr(value)}')
return value
else:
if key is not None:
self.add(f'{self._repr(key)}: {self._repr(value)},')
def Object(self, object_class, key=None):
if key is not None:
return TabWrapper(self, prefix=f'{key} = {object_class}(', suffix=')')
else:
return TabWrapper(self, prefix=f'{object_class}(', suffix=')', newline=False)
def ObjectAttr(self, key=None, value=None):
if isinstance(value, TabWrapper):
value.set_nested(suffix=',')
if key is None:
self.add(f'{self._repr(value)}')
else:
self.add(f'{key}={self._repr(value)}')
return value
else:
if key is None:
self.add(f'{self._repr(value)},')
else:
self.add(f'{key}={self._repr(value)},')
def Class(self, name, inherit=None):
if inherit is not None:
return TabWrapper(self, prefix=f'class {name}({inherit}):')
else:
return TabWrapper(self, prefix=f'class {name}:')
def Def(self, name, args=''):
return TabWrapper(self, prefix=f'def {name}({args}):')
generator = CodeGenerator()
Import = generator.Import
Value = generator.Value
Comment = generator.Comment
Dict = generator.Dict
DictItem = generator.DictItem
+196
View File
@@ -0,0 +1,196 @@
import random
import re
from functools import wraps
from typing import Callable, Generic, TypeVar
T = TypeVar("T")
class Config:
"""
Decorator that calls different function with a same name according to config.
func_list likes:
func_list = {
'func1': [
{'options': {'ENABLE': True}, 'func': 1},
{'options': {'ENABLE': False}, 'func': 1}
]
}
"""
func_list = {}
@classmethod
def when(cls, **kwargs):
"""
Args:
**kwargs: Any option in AzurLaneConfig.
Examples:
@Config.when(USE_ONE_CLICK_RETIREMENT=True)
def retire_ships(self, amount=None, rarity=None):
pass
@Config.when(USE_ONE_CLICK_RETIREMENT=False)
def retire_ships(self, amount=None, rarity=None):
pass
"""
from module.logger import logger
options = kwargs
def decorate(func):
name = func.__name__
data = {'options': options, 'func': func}
if name not in cls.func_list:
cls.func_list[name] = [data]
else:
override = False
for record in cls.func_list[name]:
if record['options'] == data['options']:
record['func'] = data['func']
override = True
if not override:
cls.func_list[name].append(data)
@wraps(func)
def wrapper(self, *args, **kwargs):
"""
Args:
self: ModuleBase instance.
*args:
**kwargs:
"""
for record in cls.func_list[name]:
flag = [value is None or self.config.__getattribute__(key) == value
for key, value in record['options'].items()]
if not all(flag):
continue
return record['func'](self, *args, **kwargs)
logger.warning(f'No option fits for {name}, using the last define func.')
return func(self, *args, **kwargs)
return wrapper
return decorate
class cached_property(Generic[T]):
"""
cached-property from https://github.com/pydanny/cached-property
Add typing support
A property that is only computed once per instance and then replaces itself
with an ordinary attribute. Deleting the attribute resets the property.
Source: https://github.com/bottlepy/bottle/commit/fa7733e075da0d790d809aa3d2f53071897e6f76
"""
def __init__(self, func: Callable[..., T]):
self.func = func
def __get__(self, obj, cls) -> T:
if obj is None:
return self
value = obj.__dict__[self.func.__name__] = self.func(obj)
return value
def del_cached_property(obj, name):
"""
Delete a cached property safely.
Args:
obj:
name (str):
"""
try:
del obj.__dict__[name]
except KeyError:
pass
def has_cached_property(obj, name):
"""
Check if a property is cached.
Args:
obj:
name (str):
"""
return name in obj.__dict__
def function_drop(rate=0.5, default=None):
"""
Drop function calls to simulate random emulator stuck, for testing purpose.
Args:
rate (float): 0 to 1. Drop rate.
default: Default value to return if dropped.
Examples:
@function_drop(0.3)
def click(self, button, record_check=True):
pass
30% possibility:
INFO | Dropped: module.device.device.Device.click(REWARD_GOTO_MAIN, record_check=True)
70% possibility:
INFO | Click (1091, 628) @ REWARD_GOTO_MAIN
"""
from module.logger import logger
def decorate(func):
@wraps(func)
def wrapper(*args, **kwargs):
if random.uniform(0, 1) > rate:
return func(*args, **kwargs)
else:
cls = ''
arguments = [str(arg) for arg in args]
if len(arguments):
matched = re.search('<(.*?) object at', arguments[0])
if matched:
cls = matched.group(1) + '.'
arguments.pop(0)
arguments += [f'{k}={v}' for k, v in kwargs.items()]
arguments = ', '.join(arguments)
logger.info(f'Dropped: {cls}{func.__name__}({arguments})')
return default
return wrapper
return decorate
def run_once(f):
"""
Run a function only once, no matter how many times it has been called.
Examples:
@run_once
def my_function(foo, bar):
return foo + bar
while 1:
my_function()
Examples:
def my_function(foo, bar):
return foo + bar
action = run_once(my_function)
while 1:
action()
"""
def wrapper(*args, **kwargs):
if not wrapper.has_run:
wrapper.has_run = True
return f(*args, **kwargs)
wrapper.has_run = False
return wrapper
+149
View File
@@ -0,0 +1,149 @@
import re
from module.logger import logger
class Filter:
def __init__(self, regex, attr, preset=()):
"""
Args:
regex: Regular expression.
attr: Attribute name.
preset: Build-in string preset.
"""
if isinstance(regex, str):
regex = re.compile(regex)
self.regex = regex
self.attr = attr
self.preset = tuple(list(p.lower() for p in preset))
self.filter_raw = []
self.filter = []
def load(self, string):
"""
Load a filter string, filters are connected with ">"
There are also tons of unicode characters similar to ">"
> \u003E correct
\uFF1E
\uFE65
\u203a
˃ \u02c3
\u1433
\u276F
"""
string = str(string)
string = re.sub(r'[ \t\r\n]', '', string)
string = re.sub(r'[>﹥›˃ᐳ❯]', '>', string)
self.filter_raw = string.split('>')
self.filter = [self.parse_filter(f) for f in self.filter_raw]
def is_preset(self, filter):
return len(filter) and filter.lower() in self.preset
def apply(self, objs, func=None):
"""
Args:
objs (list): List of objects and strings
func (callable): A function to filter object.
Function should receive an object as arguments, and return a bool.
True means add it to output.
Returns:
list: A list of objects and preset strings, such as [object, object, object, 'reset']
"""
out = []
for raw, filter in zip(self.filter_raw, self.filter):
if self.is_preset(raw):
raw = raw.lower()
if raw not in out:
out.append(raw)
else:
for index, obj in enumerate(objs):
if self.apply_filter_to_obj(obj=obj, filter=filter) and obj not in out:
out.append(obj)
if func is not None:
objs, out = out, []
for obj in objs:
if isinstance(obj, str):
out.append(obj)
elif func(obj):
out.append(obj)
else:
# Drop this object
pass
return out
def apply_filter_to_obj(self, obj, filter):
"""
Args:
obj (object):
filter (list[str]):
Returns:
bool: If an object satisfy a filter.
"""
for attr, value in zip(self.attr, filter):
if not value:
continue
if str(obj.__getattribute__(attr)).lower() != str(value):
return False
return True
def parse_filter(self, string):
"""
Args:
string (str):
Returns:
list[strNone]:
"""
string = string.replace(' ', '').lower()
result = re.search(self.regex, string)
if self.is_preset(string):
return [string]
if result and len(string) and result.span()[1]:
return [result.group(index + 1) for index, attr in enumerate(self.attr)]
else:
logger.warning(f'Invalid filter: "{string}". This selector does not match the regex, nor a preset.')
# Invalid filter will be ignored.
# Return strange things and make it impossible to match
return ['1nVa1d'] + [None] * (len(self.attr) - 1)
class MultiLangFilter(Filter):
"""
To support multi-language, there might be different correct matches of same object.
"""
def apply_filter_to_obj(self, obj, filter):
"""
Args:
obj (object): In this case, attributes of object are array (instead of plain string).
Any match of element in it will return True
filter (list[str]):
Returns:
bool: If an object satisfy a filter.
"""
for attr, value in zip(self.attr, filter):
if not value:
continue
if not hasattr(obj, attr):
continue
obj_value = obj.__getattribute__(attr)
if isinstance(obj_value, (str, int)):
if str(obj_value).lower() != str(value):
return False
if isinstance(obj_value, list):
if value not in obj_value:
return False
return True
+77
View File
@@ -0,0 +1,77 @@
import re
import module.config.server as server
from module.base.decorator import cached_property, del_cached_property
def get_assets_from_file(file, regex):
assets = set()
with open(file, 'r', encoding='utf-8') as f:
for row in f.readlines():
result = regex.search(row)
if result:
assets.add(result.group(1))
return assets
class PreservedAssets:
@cached_property
def ui(self):
assets = set()
assets |= get_assets_from_file(
file='./tasks/base/assets/assets_base_page.py',
regex=re.compile(r'^([A-Za-z][A-Za-z0-9_]+) = ')
)
assets |= get_assets_from_file(
file='./tasks/base/assets/assets_base_popup.py',
regex=re.compile(r'^([A-Za-z][A-Za-z0-9_]+) = ')
)
return assets
_preserved_assets = PreservedAssets()
class Resource:
# Class property, record all button and templates
instances = {}
def resource_add(self, key):
Resource.instances[key] = self
def resource_release(self):
pass
@classmethod
def is_loaded(cls, obj):
if hasattr(obj, '_image') and obj._image is None:
return False
elif hasattr(obj, 'image') and obj.image is None:
return False
return True
@classmethod
def resource_show(cls):
from module.logger import logger
logger.hr('Show resource')
for key, obj in cls.instances.items():
if cls.is_loaded(obj):
continue
logger.info(f'{obj}: {key}')
def release_resources(next_task=''):
# Release assets cache
# module.ui has about 80 assets and takes about 3MB
# Alas has about 800 assets, but they are not all loaded.
# Template images take more, about 6MB each
for key, obj in Resource.instances.items():
# Preserve assets for ui switching
if next_task and str(obj) in _preserved_assets.ui:
continue
# if Resource.is_loaded(obj):
# logger.info(f'Release {obj}')
obj.resource_release()
# Useless in most cases, but just call it
# gc.collect()
+123
View File
@@ -0,0 +1,123 @@
import functools
import random
import time
from functools import partial
from module.logger import logger as logging_logger
"""
Copied from `retry`, but modified something.
"""
try:
from decorator import decorator
except ImportError:
def decorator(caller):
""" Turns caller into a decorator.
Unlike decorator module, function signature is not preserved.
:param caller: caller(f, *args, **kwargs)
"""
def decor(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return caller(f, *args, **kwargs)
return wrapper
return decor
def __retry_internal(f, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0,
logger=logging_logger):
"""
Executes a function and retries it if it failed.
:param f: the function to execute.
:param exceptions: an exception or a tuple of exceptions to catch. default: Exception.
:param tries: the maximum number of attempts. default: -1 (infinite).
:param delay: initial delay between attempts. default: 0.
:param max_delay: the maximum value of delay. default: None (no limit).
:param backoff: multiplier applied to delay between attempts. default: 1 (no backoff).
:param jitter: extra seconds added to delay between attempts. default: 0.
fixed if a number, random if a range tuple (min, max)
:param logger: logger.warning(fmt, error, delay) will be called on failed attempts.
default: retry.logging_logger. if None, logging is disabled.
:returns: the result of the f function.
"""
_tries, _delay = tries, delay
while _tries:
try:
return f()
except exceptions as e:
_tries -= 1
if not _tries:
# Difference, raise same exception
raise e
if logger is not None:
# Difference, show exception
logger.exception(e)
logger.warning(f'{type(e).__name__}({e}), retrying in {_delay} seconds...')
time.sleep(_delay)
_delay *= backoff
if isinstance(jitter, tuple):
_delay += random.uniform(*jitter)
else:
_delay += jitter
if max_delay is not None:
_delay = min(_delay, max_delay)
def retry(exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0, logger=logging_logger):
"""Returns a retry decorator.
:param exceptions: an exception or a tuple of exceptions to catch. default: Exception.
:param tries: the maximum number of attempts. default: -1 (infinite).
:param delay: initial delay between attempts. default: 0.
:param max_delay: the maximum value of delay. default: None (no limit).
:param backoff: multiplier applied to delay between attempts. default: 1 (no backoff).
:param jitter: extra seconds added to delay between attempts. default: 0.
fixed if a number, random if a range tuple (min, max)
:param logger: logger.warning(fmt, error, delay) will be called on failed attempts.
default: retry.logging_logger. if None, logging is disabled.
:returns: a retry decorator.
"""
@decorator
def retry_decorator(f, *fargs, **fkwargs):
args = fargs if fargs else list()
kwargs = fkwargs if fkwargs else dict()
return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter,
logger)
return retry_decorator
def retry_call(f, fargs=None, fkwargs=None, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1,
jitter=0,
logger=logging_logger):
"""
Calls a function and re-executes it if it failed.
:param f: the function to execute.
:param fargs: the positional arguments of the function to execute.
:param fkwargs: the named arguments of the function to execute.
:param exceptions: an exception or a tuple of exceptions to catch. default: Exception.
:param tries: the maximum number of attempts. default: -1 (infinite).
:param delay: initial delay between attempts. default: 0.
:param max_delay: the maximum value of delay. default: None (no limit).
:param backoff: multiplier applied to delay between attempts. default: 1 (no backoff).
:param jitter: extra seconds added to delay between attempts. default: 0.
fixed if a number, random if a range tuple (min, max)
:param logger: logger.warning(fmt, error, delay) will be called on failed attempts.
default: retry.logging_logger. if None, logging is disabled.
:returns: the result of the f function.
"""
args = fargs if fargs else list()
kwargs = fkwargs if fkwargs else dict()
return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter, logger)
+163
View File
@@ -0,0 +1,163 @@
import time
from datetime import datetime, timedelta
from functools import wraps
def timer(function):
@wraps(function)
def function_timer(*args, **kwargs):
t0 = time.time()
result = function(*args, **kwargs)
t1 = time.time()
print('%s: %s s' % (function.__name__, str(round(t1 - t0, 10))))
return result
return function_timer
def future_time(string):
"""
Args:
string (str): Such as 14:59.
Returns:
datetime.datetime: Time with given hour, minute in the future.
"""
hour, minute = [int(x) for x in string.split(':')]
future = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
future = future + timedelta(days=1) if future < datetime.now() else future
return future
def past_time(string):
"""
Args:
string (str): Such as 14:59.
Returns:
datetime.datetime: Time with given hour, minute in the past.
"""
hour, minute = [int(x) for x in string.split(':')]
past = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
past = past - timedelta(days=1) if past > datetime.now() else past
return past
def future_time_range(string):
"""
Args:
string (str): Such as 23:30-06:30.
Returns:
tuple(datetime.datetime): (time start, time end).
"""
start, end = [future_time(s) for s in string.split('-')]
if start > end:
start = start - timedelta(days=1)
return start, end
def time_range_active(time_range):
"""
Args:
time_range(tuple(datetime.datetime)): (time start, time end).
Returns:
bool:
"""
return time_range[0] < datetime.now() < time_range[1]
class Timer:
def __init__(self, limit, count=0):
"""
Args:
limit (int, float): Timer limit
count (int): Timer reach confirm count. Default to 0.
When using a structure like this, must set a count.
Otherwise it goes wrong, if screenshot time cost greater than limit.
if self.appear(MAIN_CHECK):
if confirm_timer.reached():
pass
else:
confirm_timer.reset()
Also, It's a good idea to set `count`, to make alas run more stable on slow computers.
Expected speed is 0.35 second / screenshot.
"""
self.limit = limit
self.count = count
self._current = 0
self._reach_count = count
def start(self):
if not self.started():
self._current = time.time()
self._reach_count = 0
return self
def started(self):
return bool(self._current)
def current(self):
"""
Returns:
float
"""
if self.started():
return time.time() - self._current
else:
return 0.
def set_current(self, current, count=0):
self._current = time.time() - current
self._reach_count = count
def reached(self):
"""
Returns:
bool
"""
self._reach_count += 1
return time.time() - self._current > self.limit and self._reach_count > self.count
def reset(self):
self._current = time.time()
self._reach_count = 0
return self
def clear(self):
self._current = 0
self._reach_count = self.count
return self
def reached_and_reset(self):
"""
Returns:
bool:
"""
if self.reached():
self.reset()
return True
else:
return False
def wait(self):
"""
Wait until timer reached.
"""
diff = self._current + self.limit - time.time()
if diff > 0:
time.sleep(diff)
def show(self):
from module.logger import logger
logger.info(str(self))
def __str__(self):
return f'Timer(limit={round(self.current(), 3)}/{self.limit}, count={self._reach_count}/{self.count})'
__repr__ = __str__
+3
View File
@@ -0,0 +1,3 @@
from .utils import *
from .grids import *
from .points import *
+377
View File
@@ -0,0 +1,377 @@
import operator
import typing as t
class SelectedGrids:
def __init__(self, grids):
self.grids = grids
self.indexes: t.Dict[tuple, SelectedGrids] = {}
def __iter__(self):
return iter(self.grids)
def __getitem__(self, item):
if isinstance(item, int):
return self.grids[item]
else:
return SelectedGrids(self.grids[item])
def __contains__(self, item):
return item in self.grids
def __str__(self):
# return str([str(grid) for grid in self])
return '[' + ', '.join([str(grid) for grid in self]) + ']'
def __len__(self):
return len(self.grids)
def __bool__(self):
return self.count > 0
# def __getattr__(self, item):
# return [grid.__getattribute__(item) for grid in self.grids]
@property
def location(self):
"""
Returns:
list[tuple]:
"""
return [grid.location for grid in self.grids]
@property
def cost(self):
"""
Returns:
list[int]:
"""
return [grid.cost for grid in self.grids]
@property
def weight(self):
"""
Returns:
list[int]:
"""
return [grid.weight for grid in self.grids]
@property
def count(self):
"""
Returns:
int:
"""
return len(self.grids)
def select(self, **kwargs):
"""
Args:
**kwargs: Attributes of Grid.
Returns:
SelectedGrids:
"""
def matched(obj):
flag = True
for k, v in kwargs.items():
obj_v = obj.__getattribute__(k)
if type(obj_v) != type(v) or obj_v != v:
flag = False
return flag
return SelectedGrids([grid for grid in self.grids if matched(grid)])
def create_index(self, *attrs):
indexes = {}
# index_keys = [(grid.__getattribute__(attr) for attr in attrs) for grid in self.grids]
for grid in self.grids:
k = tuple(grid.__getattribute__(attr) for attr in attrs)
try:
indexes[k].append(grid)
except KeyError:
indexes[k] = [grid]
indexes = {k: SelectedGrids(v) for k, v in indexes.items()}
self.indexes = indexes
return indexes
def indexed_select(self, *values):
return self.indexes.get(values, SelectedGrids([]))
def left_join(self, right, on_attr, set_attr, default=None):
"""
Args:
right (SelectedGrids): Right table to join
on_attr:
set_attr:
default:
Returns:
SelectedGrids:
"""
right.create_index(*on_attr)
for grid in self:
attr_value = tuple([grid.__getattribute__(attr) for attr in on_attr])
right_grid = right.indexed_select(*attr_value).first_or_none()
if right_grid is not None:
for attr in set_attr:
grid.__setattr__(attr, right_grid.__getattribute__(attr))
else:
for attr in set_attr:
grid.__setattr__(attr, default)
return self
def filter(self, func):
"""
Filter grids by a function.
Args:
func (callable): Function should receive an grid as argument, and return a bool.
Returns:
SelectedGrids:
"""
return SelectedGrids([grid for grid in self if func(grid)])
def set(self, **kwargs):
"""
Set attribute to each grid.
Args:
**kwargs:
"""
for grid in self:
for key, value in kwargs.items():
grid.__setattr__(key, value)
def get(self, attr):
"""
Get an attribute from each grid.
Args:
attr: Attribute name.
Returns:
list:
"""
return [grid.__getattribute__(attr) for grid in self.grids]
def call(self, func, **kwargs):
"""
Call a function in reach grid, and get results.
Args:
func (str): Function name to call.
**kwargs:
Returns:
list:
"""
return [grid.__getattribute__(func)(**kwargs) for grid in self]
def first_or_none(self):
"""
Returns:
"""
try:
return self.grids[0]
except IndexError:
return None
def add(self, grids):
"""
Args:
grids(SelectedGrids):
Returns:
SelectedGrids:
"""
return SelectedGrids(list(set(self.grids + grids.grids)))
def add_by_eq(self, grids):
"""
Another `add()` method, but de-duplicates with `__eq__` instead of `__hash__`.
Args:
grids(SelectedGrids):
Returns:
SelectedGrids:
"""
new = []
for grid in self.grids + grids.grids:
if grid not in new:
new.append(grid)
return SelectedGrids(new)
def intersect(self, grids):
"""
Args:
grids(SelectedGrids):
Returns:
SelectedGrids:
"""
return SelectedGrids(list(set(self.grids).intersection(set(grids.grids))))
def intersect_by_eq(self, grids):
"""
Another `intersect()` method, but de-duplicates with `__eq__` instead of `__hash__`.
Args:
grids(SelectedGrids):
Returns:
SelectedGrids:
"""
new = []
for grid in self.grids:
if grid in grids.grids:
new.append(grid)
return SelectedGrids(new)
def delete(self, grids):
"""
Args:
grids(SelectedGrids):
Returns:
SelectedGrids:
"""
g = [grid for grid in self.grids if grid not in grids]
return SelectedGrids(g)
def sort(self, *args):
"""
Args:
args (str): Attribute name to sort.
Returns:
SelectedGrids:
"""
if not self:
return self
if len(args):
grids = sorted(self.grids, key=operator.attrgetter(*args))
return SelectedGrids(grids)
else:
return self
def sort_by_camera_distance(self, camera):
"""
Args:
camera (tuple):
Returns:
SelectedGrids:
"""
import numpy as np
if not self:
return self
location = np.array(self.location)
diff = np.sum(np.abs(location - camera), axis=1)
# grids = [x for _, x in sorted(zip(diff, self.grids))]
grids = tuple(np.array(self.grids)[np.argsort(diff)])
return SelectedGrids(grids)
def sort_by_clock_degree(self, center=(0, 0), start=(0, 1), clockwise=True):
"""
Args:
center (tuple): Origin point.
start (tuple): Start coordinate, this point will be considered as theta=0.
clockwise (bool): True for clockwise, false for counterclockwise.
Returns:
SelectedGrids:
"""
import numpy as np
if not self:
return self
vector = np.subtract(self.location, center)
theta = np.arctan2(vector[:, 1], vector[:, 0]) / np.pi * 180
vector = np.subtract(start, center)
theta = theta - np.arctan2(vector[1], vector[0]) / np.pi * 180
if not clockwise:
theta = -theta
theta[theta < 0] += 360
grids = tuple(np.array(self.grids)[np.argsort(theta)])
return SelectedGrids(grids)
class RoadGrids:
def __init__(self, grids):
"""
Args:
grids (list):
"""
self.grids = []
for grid in grids:
if isinstance(grid, list):
self.grids.append(SelectedGrids(grids=grid))
else:
self.grids.append(SelectedGrids(grids=[grid]))
def __str__(self):
return str(' - '.join([str(grid) for grid in self.grids]))
def roadblocks(self):
"""
Returns:
SelectedGrids:
"""
grids = []
for block in self.grids:
if block.count == block.select(is_enemy=True).count:
grids += block.grids
return SelectedGrids(grids)
def potential_roadblocks(self):
"""
Returns:
SelectedGrids:
"""
grids = []
for block in self.grids:
if any([grid.is_fleet for grid in block]):
continue
if any([grid.is_cleared for grid in block]):
continue
if block.count - block.select(is_enemy=True).count == 1:
grids += block.select(is_enemy=True).grids
return SelectedGrids(grids)
def first_roadblocks(self):
"""
Returns:
SelectedGrids:
"""
grids = []
for block in self.grids:
if any([grid.is_fleet for grid in block]):
continue
if any([grid.is_cleared for grid in block]):
continue
if block.select(is_enemy=True).count >= 1:
grids += block.select(is_enemy=True).grids
return SelectedGrids(grids)
def combine(self, road):
"""
Args:
road (RoadGrids):
Returns:
RoadGrids:
"""
out = RoadGrids([])
for select_1 in self.grids:
for select_2 in road.grids:
select = select_1.add(select_2)
out.grids.append(select)
return out
+395
View File
@@ -0,0 +1,395 @@
import numpy as np
from scipy import optimize
from .utils import area_pad
class Points:
def __init__(self, points):
if points is None or len(points) == 0:
self._bool = False
self.points = None
else:
self._bool = True
self.points = np.array(points)
if len(self.points.shape) == 1:
self.points = np.array([self.points])
self.x, self.y = self.points.T
def __str__(self):
return str(self.points)
__repr__ = __str__
def __iter__(self):
return iter(self.points)
def __getitem__(self, item):
return self.points[item]
def __len__(self):
if self:
return len(self.points)
else:
return 0
def __bool__(self):
return self._bool
def link(self, point, is_horizontal=False):
if is_horizontal:
lines = [[y, np.pi / 2] for y in self.y]
return Lines(lines, is_horizontal=True)
else:
x, y = point
theta = -np.arctan((self.x - x) / (self.y - y))
rho = self.x * np.cos(theta) + self.y * np.sin(theta)
lines = np.array([rho, theta]).T
return Lines(lines, is_horizontal=False)
def mean(self):
if not self:
return None
return np.round(np.mean(self.points, axis=0)).astype(int)
def group(self, threshold=3):
if not self:
return np.array([])
groups = []
points = self.points
if len(points) == 1:
return np.array([points[0]])
while len(points):
p0, p1 = points[0], points[1:]
distance = np.sum(np.abs(p1 - p0), axis=1)
new = Points(np.append(p1[distance <= threshold], [p0], axis=0)).mean().tolist()
groups.append(new)
points = p1[distance > threshold]
return np.array(groups)
class Lines:
MID_Y = 360
def __init__(self, lines, is_horizontal):
if lines is None or len(lines) == 0:
self._bool = False
self.lines = None
else:
self._bool = True
self.lines = np.array(lines)
if len(self.lines.shape) == 1:
self.lines = np.array([self.lines])
self.rho, self.theta = self.lines.T
self.is_horizontal = is_horizontal
def __str__(self):
return str(self.lines)
__repr__ = __str__
def __iter__(self):
return iter(self.lines)
def __getitem__(self, item):
return Lines(self.lines[item], is_horizontal=self.is_horizontal)
def __len__(self):
if self:
return len(self.lines)
else:
return 0
def __bool__(self):
return self._bool
@property
def sin(self):
return np.sin(self.theta)
@property
def cos(self):
return np.cos(self.theta)
@property
def mean(self):
if not self:
return None
if self.is_horizontal:
return np.mean(self.lines, axis=0)
else:
x = np.mean(self.mid)
theta = np.mean(self.theta)
rho = x * np.cos(theta) + self.MID_Y * np.sin(theta)
return np.array((rho, theta))
@property
def mid(self):
if not self:
return np.array([])
if self.is_horizontal:
return self.rho
else:
return (self.rho - self.MID_Y * self.sin) / self.cos
def get_x(self, y):
return (self.rho - y * self.sin) / self.cos
def get_y(self, x):
return (self.rho - x * self.cos) / self.sin
def add(self, other):
if not other:
return self
if not self:
return other
lines = np.append(self.lines, other.lines, axis=0)
return Lines(lines, is_horizontal=self.is_horizontal)
def move(self, x, y):
if not self:
return self
if self.is_horizontal:
self.lines[:, 0] += y
else:
self.lines[:, 0] += x * self.cos + y * self.sin
return Lines(self.lines, is_horizontal=self.is_horizontal)
def sort(self):
if not self:
return self
lines = self.lines[np.argsort(self.mid)]
return Lines(lines, is_horizontal=self.is_horizontal)
def group(self, threshold=3):
if not self:
return self
lines = self.sort()
prev = 0
regrouped = []
group = []
for mid, line in zip(lines.mid, lines.lines):
line = line.tolist()
if mid - prev > threshold:
if len(regrouped) == 0:
if len(group) != 0:
regrouped = [group]
else:
regrouped += [group]
group = [line]
else:
group.append(line)
prev = mid
regrouped += [group]
regrouped = np.vstack([Lines(r, is_horizontal=self.is_horizontal).mean for r in regrouped])
return Lines(regrouped, is_horizontal=self.is_horizontal)
def distance_to_point(self, point):
x, y = point
return self.rho - x * self.cos - y * self.sin
@staticmethod
def cross_two_lines(lines1, lines2):
for rho1, sin1, cos1 in zip(lines1.rho, lines1.sin, lines1.cos):
for rho2, sin2, cos2 in zip(lines2.rho, lines2.sin, lines2.cos):
a = np.array([[cos1, sin1], [cos2, sin2]])
b = np.array([rho1, rho2])
yield np.linalg.solve(a, b)
def cross(self, other):
points = np.vstack(self.cross_two_lines(self, other))
points = Points(points)
return points
def delete(self, other, threshold=3):
if not self:
return self
other_mid = other.mid
lines = []
for mid, line in zip(self.mid, self.lines):
if np.any(np.abs(other_mid - mid) < threshold):
continue
lines.append(line)
return Lines(lines, is_horizontal=self.is_horizontal)
def area2corner(area):
"""
Args:
area: (x1, y1, x2, y2)
Returns:
np.ndarray: [upper-left, upper-right, bottom-left, bottom-right]
"""
return np.array([[area[0], area[1]], [area[2], area[1]], [area[0], area[3]], [area[2], area[3]]])
def corner2area(corner):
"""
Args:
corner: [upper-left, upper-right, bottom-left, bottom-right]
Returns:
np.ndarray: (x1, y1, x2, y2)
"""
x, y = np.array(corner).T
return np.rint([np.min(x), np.min(y), np.max(x), np.max(y)]).astype(int)
def corner2inner(corner):
"""
The largest rectangle inscribed in trapezoid.
Args:
corner: ((x0, y0), (x1, y1), (x2, y2), (x3, y3))
Returns:
tuple[int]: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
"""
x0, y0, x1, y1, x2, y2, x3, y3 = np.array(corner).flatten()
area = tuple(np.rint((max(x0, x2), max(y0, y1), min(x1, x3), min(y2, y3))).astype(int))
return area
def corner2outer(corner):
"""
The smallest rectangle circumscribed by the trapezoid.
Args:
corner: ((x0, y0), (x1, y1), (x2, y2), (x3, y3))
Returns:
tuple[int]: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
"""
x0, y0, x1, y1, x2, y2, x3, y3 = np.array(corner).flatten()
area = tuple(np.rint((min(x0, x2), min(y0, y1), max(x1, x3), max(y2, y3))).astype(int))
return area
def trapezoid2area(corner, pad=0):
"""
Convert corners of a trapezoid to area.
Args:
corner: ((x0, y0), (x1, y1), (x2, y2), (x3, y3))
pad (int):
Positive value for inscribed area.
Negative value and 0 for circumscribed area.
Returns:
tuple[int]: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
"""
if pad > 0:
return area_pad(corner2inner(corner), pad=pad)
elif pad < 0:
return area_pad(corner2outer(corner), pad=pad)
else:
return area_pad(corner2area(corner), pad=pad)
def points_to_area_generator(points, shape):
"""
Args:
points (np.ndarray): N x 2 array.
shape (tuple): (x, y).
Yields:
tuple, np.ndarray: (x, y), [upper-left, upper-right, bottom-left, bottom-right]
"""
points = points.reshape(*shape[::-1], 2)
for y in range(shape[1] - 1):
for x in range(shape[0] - 1):
area = np.array([points[y, x], points[y, x + 1], points[y + 1, x], points[y + 1, x + 1]])
yield ((x, y), area)
def get_map_inner(points):
"""
Args:
points (np.ndarray): N x 2 array.
Yields:
np.ndarray: (x, y).
"""
points = np.array(points)
if len(points.shape) == 1:
points = np.array([points])
return np.mean(points, axis=0)
def separate_edges(edges, inner):
"""
Args:
edges: A iterate object which contains float ot integer.
inner (float, int): A inner point to separate edges.
Returns:
float, float: Lower edge and upper edge. if not found, return None
"""
if len(edges) == 0:
return None, None
elif len(edges) == 1:
edge = edges[0]
return (None, edge) if edge > inner else (edge, None)
else:
lower = [edge for edge in edges if edge < inner]
upper = [edge for edge in edges if edge > inner]
lower = lower[0] if len(lower) else None
upper = upper[-1] if len(upper) else None
return lower, upper
def perspective_transform(points, data):
"""
Args:
points: A 2D array with shape (n, 2)
data: Perspective data, a 2D array with shape (3, 3),
see https://web.archive.org/web/20150222120106/xenia.media.mit.edu/~cwren/interpolator/
Returns:
np.ndarray: 2D array with shape (n, 2)
"""
points = np.pad(np.array(points), ((0, 0), (0, 1)), mode='constant', constant_values=1)
matrix = data.dot(points.T)
x, y = matrix[0] / matrix[2], matrix[1] / matrix[2]
points = np.array([x, y]).T
return points
def fit_points(points, mod, encourage=1):
"""
Get a closet point in a group of points with common difference.
Will ignore points in the distance.
Args:
points: Points on image, a 2D array with shape (n, 2)
mod: Common difference of points, (x, y).
encourage (int, float): Describe how close to fit a group of points, in pixel.
Smaller means closer to local minimum, larger means closer to global minimum.
Returns:
np.ndarray: (x, y)
"""
encourage = np.square(encourage)
mod = np.array(mod)
points = np.array(points) % mod
points = np.append(points - mod, points, axis=0)
def cal_distance(point):
distance = np.linalg.norm(points - point, axis=1)
return np.sum(1 / (1 + np.exp(encourage / distance) / distance))
# Fast local minimizer
# result = optimize.minimize(cal_distance, np.mean(points, axis=0), method='SLSQP')
# return result['x'] % mod
# Brute-force global minimizer
area = np.append(-mod - 10, mod + 10)
result = optimize.brute(cal_distance, ((area[0], area[2]), (area[1], area[3])))
return result % mod
+919
View File
@@ -0,0 +1,919 @@
import re
import cv2
import numpy as np
from PIL import Image
REGEX_NODE = re.compile(r'(-?[A-Za-z]+)(-?\d+)')
def random_normal_distribution_int(a, b, n=3):
"""Generate a normal distribution int within the interval. Use the average value of several random numbers to
simulate normal distribution.
Args:
a (int): The minimum of the interval.
b (int): The maximum of the interval.
n (int): The amount of numbers in simulation. Default to 3.
Returns:
int
"""
if a < b:
output = np.mean(np.random.randint(a, b, size=n))
return int(output.round())
else:
return b
def random_rectangle_point(area, n=3):
"""Choose a random point in an area.
Args:
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
n (int): The amount of numbers in simulation. Default to 3.
Returns:
tuple(int): (x, y)
"""
x = random_normal_distribution_int(area[0], area[2], n=n)
y = random_normal_distribution_int(area[1], area[3], n=n)
return x, y
def random_rectangle_vector(vector, box, random_range=(0, 0, 0, 0), padding=15):
"""Place a vector in a box randomly.
Args:
vector: (x, y)
box: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
random_range (tuple): Add a random_range to vector. (x_min, y_min, x_max, y_max).
padding (int):
Returns:
tuple(int), tuple(int): start_point, end_point.
"""
vector = np.array(vector) + random_rectangle_point(random_range)
vector = np.round(vector).astype(int)
half_vector = np.round(vector / 2).astype(int)
box = np.array(box) + np.append(np.abs(half_vector) + padding, -np.abs(half_vector) - padding)
center = random_rectangle_point(box)
start_point = center - half_vector
end_point = start_point + vector
return tuple(start_point), tuple(end_point)
def random_rectangle_vector_opted(
vector, box, random_range=(0, 0, 0, 0), padding=15, whitelist_area=None, blacklist_area=None):
"""
Place a vector in a box randomly.
When emulator/game stuck, it treats a swipe as a click, clicking at the end of swipe path.
To prevent this, random results need to be filtered.
Args:
vector: (x, y)
box: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
random_range (tuple): Add a random_range to vector. (x_min, y_min, x_max, y_max).
padding (int):
whitelist_area: (list[tuple[int]]):
A list of area that safe to click. Swipe path will end there.
blacklist_area: (list[tuple[int]]):
If none of the whitelist_area satisfies current vector, blacklist_area will be used.
Delete random path that ends in any blacklist_area.
Returns:
tuple(int), tuple(int): start_point, end_point.
"""
vector = np.array(vector) + random_rectangle_point(random_range)
vector = np.round(vector).astype(int)
half_vector = np.round(vector / 2).astype(int)
box_pad = np.array(box) + np.append(np.abs(half_vector) + padding, -np.abs(half_vector) - padding)
box_pad = area_offset(box_pad, half_vector)
segment = int(np.linalg.norm(vector) // 70) + 1
def in_blacklist(end):
if not blacklist_area:
return False
for x in range(segment + 1):
point = - vector * x / segment + end
for area in blacklist_area:
if point_in_area(point, area, threshold=0):
return True
return False
if whitelist_area:
for area in whitelist_area:
area = area_limit(area, box_pad)
if all([x > 0 for x in area_size(area)]):
end_point = random_rectangle_point(area)
for _ in range(10):
if in_blacklist(end_point):
continue
return point_limit(end_point - vector, box), point_limit(end_point, box)
for _ in range(100):
end_point = random_rectangle_point(box_pad)
if in_blacklist(end_point):
continue
return point_limit(end_point - vector, box), point_limit(end_point, box)
end_point = random_rectangle_point(box_pad)
return point_limit(end_point - vector, box), point_limit(end_point, box)
def random_line_segments(p1, p2, n, random_range=(0, 0, 0, 0)):
"""Cut a line into multiple segments.
Args:
p1: (x, y).
p2: (x, y).
n: Number of slice.
random_range: Add a random_range to points.
Returns:
list[tuple]: [(x0, y0), (x1, y1), (x2, y2)]
"""
return [tuple((((n - index) * p1 + index * p2) / n).astype(int) + random_rectangle_point(random_range))
for index in range(0, n + 1)]
def ensure_time(second, n=3, precision=3):
"""Ensure to be time.
Args:
second (int, float, tuple): time, such as 10, (10, 30), '10, 30'
n (int): The amount of numbers in simulation. Default to 5.
precision (int): Decimals.
Returns:
float:
"""
if isinstance(second, tuple):
multiply = 10 ** precision
result = random_normal_distribution_int(second[0] * multiply, second[1] * multiply, n) / multiply
return round(result, precision)
elif isinstance(second, str):
if ',' in second:
lower, upper = second.replace(' ', '').split(',')
lower, upper = int(lower), int(upper)
return ensure_time((lower, upper), n=n, precision=precision)
if '-' in second:
lower, upper = second.replace(' ', '').split('-')
lower, upper = int(lower), int(upper)
return ensure_time((lower, upper), n=n, precision=precision)
else:
return int(second)
else:
return second
def ensure_int(*args):
"""
Convert all elements to int.
Return the same structure as nested objects.
Args:
*args:
Returns:
list:
"""
def to_int(item):
try:
return int(item)
except TypeError:
result = [to_int(i) for i in item]
if len(result) == 1:
result = result[0]
return result
return to_int(args)
def area_offset(area, offset):
"""
Move an area.
Args:
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
offset: (x, y).
Returns:
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
"""
upper_left_x, upper_left_y, bottom_right_x, bottom_right_y = area
x, y = offset
return upper_left_x + x, upper_left_y + y, bottom_right_x + x, bottom_right_y + y
def area_pad(area, pad=10):
"""
Inner offset an area.
Args:
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
pad (int):
Returns:
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
"""
upper_left_x, upper_left_y, bottom_right_x, bottom_right_y = area
return upper_left_x + pad, upper_left_y + pad, bottom_right_x - pad, bottom_right_y - pad
def limit_in(x, lower, upper):
"""
Limit x within range (lower, upper)
Args:
x:
lower:
upper:
Returns:
int, float:
"""
return max(min(x, upper), lower)
def area_limit(area1, area2):
"""
Limit an area in another area.
Args:
area1: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
area2: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
Returns:
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
"""
x_lower, y_lower, x_upper, y_upper = area2
return (
limit_in(area1[0], x_lower, x_upper),
limit_in(area1[1], y_lower, y_upper),
limit_in(area1[2], x_lower, x_upper),
limit_in(area1[3], y_lower, y_upper),
)
def area_size(area):
"""
Area size or shape.
Args:
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
Returns:
tuple: (x, y).
"""
return (
max(area[2] - area[0], 0),
max(area[3] - area[1], 0)
)
def point_limit(point, area):
"""
Limit point in an area.
Args:
point: (x, y).
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
Returns:
tuple: (x, y).
"""
return (
limit_in(point[0], area[0], area[2]),
limit_in(point[1], area[1], area[3])
)
def point_in_area(point, area, threshold=5):
"""
Args:
point: (x, y).
area: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
threshold: int
Returns:
bool:
"""
return area[0] - threshold < point[0] < area[2] + threshold and area[1] - threshold < point[1] < area[3] + threshold
def area_in_area(area1, area2, threshold=5):
"""
Args:
area1: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
area2: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
threshold: int
Returns:
bool:
"""
return area2[0] - threshold <= area1[0] \
and area2[1] - threshold <= area1[1] \
and area1[2] <= area2[2] + threshold \
and area1[3] <= area2[3] + threshold
def area_cross_area(area1, area2, threshold=5):
"""
Args:
area1: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
area2: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y).
threshold: int
Returns:
bool:
"""
# https://www.yiiven.cn/rect-is-intersection.html
xa1, ya1, xa2, ya2 = area1
xb1, yb1, xb2, yb2 = area2
return abs(xb2 + xb1 - xa2 - xa1) <= xa2 - xa1 + xb2 - xb1 + threshold * 2 \
and abs(yb2 + yb1 - ya2 - ya1) <= ya2 - ya1 + yb2 - yb1 + threshold * 2
def float2str(n, decimal=3):
"""
Args:
n (float):
decimal (int):
Returns:
str:
"""
return str(round(n, decimal)).ljust(decimal + 2, "0")
def point2str(x, y, length=4):
"""
Args:
x (int, float):
y (int, float):
length (int): Align length.
Returns:
str: String with numbers right aligned, such as '( 100, 80)'.
"""
return '(%s, %s)' % (str(int(x)).rjust(length), str(int(y)).rjust(length))
def col2name(col):
"""
Convert a zero indexed column cell reference to a string.
Args:
col: The cell column. Int.
Returns:
Column style string.
Examples:
0 -> A, 3 -> D, 35 -> AJ, -1 -> -A
"""
col_neg = col < 0
if col_neg:
col_num = -col
else:
col_num = col + 1 # Change to 1-index.
col_str = ''
while col_num:
# Set remainder from 1 .. 26
remainder = col_num % 26
if remainder == 0:
remainder = 26
# Convert the remainder to a character.
col_letter = chr(remainder + 64)
# Accumulate the column letters, right to left.
col_str = col_letter + col_str
# Get the next order of magnitude.
col_num = int((col_num - 1) / 26)
if col_neg:
return '-' + col_str
else:
return col_str
def name2col(col_str):
"""
Convert a cell reference in A1 notation to a zero indexed row and column.
Args:
col_str: A1 style string.
Returns:
row, col: Zero indexed cell row and column indices.
"""
# Convert base26 column string to number.
expn = 0
col = 0
col_neg = col_str.startswith('-')
col_str = col_str.strip('-').upper()
for char in reversed(col_str):
col += (ord(char) - 64) * (26 ** expn)
expn += 1
if col_neg:
return -col
else:
return col - 1 # Convert 1-index to zero-index
def node2location(node):
"""
See location2node()
Args:
node (str): Example: 'E3'
Returns:
tuple[int]: Example: (4, 2)
"""
res = REGEX_NODE.search(node)
if res:
x, y = res.group(1), res.group(2)
y = int(y)
if y > 0:
y -= 1
return name2col(x), y
else:
# Whatever
return ord(node[0]) % 32 - 1, int(node[1:]) - 1
def location2node(location):
"""
Convert location tuple to an Excel-like cell.
Accept negative values also.
-2 -1 0 1 2 3
-2 -B-2 -A-2 A-2 B-2 C-2 D-2
-1 -B-1 -A-1 A-1 B-1 C-1 D-1
0 -B1 -A1 A1 B1 C1 D1
1 -B2 -A2 A2 B2 C2 D2
2 -B3 -A3 A3 B3 C3 D3
3 -B4 -A4 A4 B4 C4 D4
# To generate the table above
index = range(-2, 4)
row = ' ' + ' '.join([str(i).rjust(4) for i in index])
print(row)
for y in index:
row = str(y).rjust(2) + ' ' + ' '.join([location2node((x, y)).rjust(4) for x in index])
print(row)
def check(node):
return point2str(*node2location(location2node(node)), length=2)
row = ' ' + ' '.join([str(i).rjust(8) for i in index])
print(row)
for y in index:
row = str(y).rjust(2) + ' ' + ' '.join([check((x, y)).rjust(4) for x in index])
print(row)
Args:
location (tuple[int]):
Returns:
str:
"""
x, y = location
if y >= 0:
y += 1
return col2name(x) + str(y)
def load_image(file, area=None):
"""
Load an image like pillow and drop alpha channel.
Args:
file (str):
area (tuple):
Returns:
np.ndarray:
"""
image = Image.open(file)
if area is not None:
image = image.crop(area)
image = np.array(image)
channel = image.shape[2] if len(image.shape) > 2 else 1
if channel > 3:
image = image[:, :, :3].copy()
return image
def save_image(image, file):
"""
Save an image like pillow.
Args:
image (np.ndarray):
file (str):
"""
# image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
# cv2.imwrite(file, image)
Image.fromarray(image).save(file)
def crop(image, area, copy=True):
"""
Crop image like pillow, when using opencv / numpy.
Provides a black background if cropping outside of image.
Args:
image (np.ndarray):
area:
copy (bool):
Returns:
np.ndarray:
"""
x1, y1, x2, y2 = map(int, map(round, area))
h, w = image.shape[:2]
border = np.maximum((0 - y1, y2 - h, 0 - x1, x2 - w), 0)
x1, y1, x2, y2 = np.maximum((x1, y1, x2, y2), 0)
image = image[y1:y2, x1:x2]
if sum(border) > 0:
image = cv2.copyMakeBorder(image, *border, borderType=cv2.BORDER_CONSTANT, value=(0, 0, 0))
if copy:
image = image.copy()
return image
def resize(image, size):
"""
Resize image like pillow image.resize(), but implement in opencv.
Pillow uses PIL.Image.NEAREST by default.
Args:
image (np.ndarray):
size: (x, y)
Returns:
np.ndarray:
"""
return cv2.resize(image, size, interpolation=cv2.INTER_NEAREST)
def image_channel(image):
"""
Args:
image (np.ndarray):
Returns:
int: 0 for grayscale, 3 for RGB.
"""
return image.shape[2] if len(image.shape) == 3 else 0
def image_size(image):
"""
Args:
image (np.ndarray):
Returns:
int, int: width, height
"""
shape = image.shape
return shape[1], shape[0]
def image_paste(image, background, origin):
"""
Paste an image on background.
This method does not return a value, but instead updates the array "background".
Args:
image:
background:
origin: Upper-left corner, (x, y)
"""
x, y = origin
w, h = image_size(image)
background[y:y + h, x:x + w] = image
def rgb2gray(image):
"""
Args:
image (np.ndarray): Shape (height, width, channel)
Returns:
np.ndarray: Shape (height, width)
"""
r, g, b = cv2.split(image)
return cv2.add(
cv2.multiply(cv2.max(cv2.max(r, g), b), 0.5),
cv2.multiply(cv2.min(cv2.min(r, g), b), 0.5)
)
def rgb2hsv(image):
"""
Convert RGB color space to HSV color space.
HSV is Hue Saturation Value.
Args:
image (np.ndarray): Shape (height, width, channel)
Returns:
np.ndarray: Hue (0~360), Saturation (0~100), Value (0~100).
"""
image = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype(float)
image *= (360 / 180, 100 / 255, 100 / 255)
return image
def rgb2yuv(image):
"""
Convert RGB to YUV color space.
Args:
image (np.ndarray): Shape (height, width, channel)
Returns:
np.ndarray: Shape (height, width)
"""
image = cv2.cvtColor(image, cv2.COLOR_RGB2YUV)
return image
def rgb2luma(image):
"""
Convert RGB to the Y channel (Luminance) in YUV color space.
Args:
image (np.ndarray): Shape (height, width, channel)
Returns:
np.ndarray: Shape (height, width)
"""
image = cv2.cvtColor(image, cv2.COLOR_RGB2YUV)
luma, _, _ = cv2.split(image)
return luma
def get_color(image, area):
"""Calculate the average color of a particular area of the image.
Args:
image (np.ndarray): Screenshot.
area (tuple): (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
Returns:
tuple: (r, g, b)
"""
temp = crop(image, area, copy=False)
color = cv2.mean(temp)
return color[:3]
def get_bbox(image, threshold=0):
"""
A numpy implementation of the getbbox() in pillow.
Args:
image (np.ndarray): Screenshot.
threshold (int): Color <= threshold will be considered black
Returns:
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
"""
if image_channel(image) == 3:
image = np.max(image, axis=2)
x = np.where(np.max(image, axis=0) > threshold)[0]
y = np.where(np.max(image, axis=1) > threshold)[0]
return x[0], y[0], x[-1] + 1, y[-1] + 1
def get_bbox_reversed(image, threshold=0):
"""
Similar to `get_bbox` but for black contents on white background.
Args:
image (np.ndarray): Screenshot.
threshold (int): Color >= threshold will be considered white
Returns:
tuple: (upper_left_x, upper_left_y, bottom_right_x, bottom_right_y)
"""
if image_channel(image) == 3:
image = np.min(image, axis=2)
x = np.where(np.min(image, axis=0) < threshold)[0]
y = np.where(np.min(image, axis=1) < threshold)[0]
return x[0], y[0], x[-1] + 1, y[-1] + 1
def color_similarity(color1, color2):
"""
Args:
color1 (tuple): (r, g, b)
color2 (tuple): (r, g, b)
Returns:
int:
"""
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
return diff
def color_similar(color1, color2, threshold=10):
"""Consider two colors are similar, if tolerance lesser or equal threshold.
Tolerance = Max(Positive(difference_rgb)) + Max(- Negative(difference_rgb))
The same as the tolerance in Photoshop.
Args:
color1 (tuple): (r, g, b)
color2 (tuple): (r, g, b)
threshold (int): Default to 10.
Returns:
bool: True if two colors are similar.
"""
# print(color1, color2)
diff = np.array(color1).astype(int) - np.array(color2).astype(int)
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
return diff <= threshold
def color_similar_1d(image, color, threshold=10):
"""
Args:
image (np.ndarray): 1D array.
color: (r, g, b)
threshold(int): Default to 10.
Returns:
np.ndarray: bool
"""
diff = image.astype(int) - color
diff = np.max(np.maximum(diff, 0), axis=1) - np.min(np.minimum(diff, 0), axis=1)
return diff <= threshold
def color_similarity_2d(image, color):
"""
Args:
image: 2D array.
color: (r, g, b)
Returns:
np.ndarray: uint8
"""
r, g, b = cv2.split(cv2.subtract(image, (*color, 0)))
positive = cv2.max(cv2.max(r, g), b)
r, g, b = cv2.split(cv2.subtract((*color, 0), image))
negative = cv2.max(cv2.max(r, g), b)
return cv2.subtract(255, cv2.add(positive, negative))
def extract_letters(image, letter=(255, 255, 255), threshold=128):
"""Set letter color to black, set background color to white.
Args:
image: Shape (height, width, channel)
letter (tuple): Letter RGB.
threshold (int):
Returns:
np.ndarray: Shape (height, width)
"""
r, g, b = cv2.split(cv2.subtract(image, (*letter, 0)))
positive = cv2.max(cv2.max(r, g), b)
r, g, b = cv2.split(cv2.subtract((*letter, 0), image))
negative = cv2.max(cv2.max(r, g), b)
return cv2.multiply(cv2.add(positive, negative), 255.0 / threshold)
def extract_white_letters(image, threshold=128):
"""Set letter color to black, set background color to white.
This function will discourage color pixels (Non-gray pixels)
Args:
image: Shape (height, width, channel)
threshold (int):
Returns:
np.ndarray: Shape (height, width)
"""
r, g, b = cv2.split(cv2.subtract((255, 255, 255, 0), image))
minimum = cv2.min(cv2.min(r, g), b)
maximum = cv2.max(cv2.max(r, g), b)
return cv2.multiply(cv2.add(maximum, cv2.subtract(maximum, minimum)), 255.0 / threshold)
def color_mapping(image, max_multiply=2):
"""
Mapping color to 0-255.
Minimum color to 0, maximum color to 255, multiply colors by 2 at max.
Args:
image (np.ndarray):
max_multiply (int, float):
Returns:
np.ndarray:
"""
image = image.astype(float)
low, high = np.min(image), np.max(image)
multiply = min(255 / (high - low), max_multiply)
add = (255 - multiply * (low + high)) / 2
image = cv2.add(cv2.multiply(image, multiply), add)
image[image > 255] = 255
image[image < 0] = 0
return image.astype(np.uint8)
def image_left_strip(image, threshold, length):
"""
In `DAILY:200/200` strip `DAILY:` and leave `200/200`
Args:
image (np.ndarray): (height, width)
threshold (int):
0-255
The first column with brightness lower than this
will be considered as left edge.
length (int):
Strip this length of image after the left edge
Returns:
np.ndarray:
"""
brightness = np.mean(image, axis=0)
match = np.where(brightness < threshold)[0]
if len(match):
left = match[0] + length
total = image.shape[1]
if left < total:
image = image[:, left:]
return image
def red_overlay_transparency(color1, color2, red=247):
"""Calculate the transparency of red overlay.
Args:
color1: origin color.
color2: changed color.
red(int): red color 0-255. Default to 247.
Returns:
float: 0-1
"""
return (color2[0] - color1[0]) / (red - color1[0])
def color_bar_percentage(image, area, prev_color, reverse=False, starter=0, threshold=30):
"""
Args:
image:
area:
prev_color:
reverse: True if bar goes from right to left.
starter:
threshold:
Returns:
float: 0 to 1.
"""
image = crop(image, area)
image = image[:, ::-1, :] if reverse else image
length = image.shape[1]
prev_index = starter
for _ in range(1280):
bar = color_similarity_2d(image, color=prev_color)
index = np.where(np.any(bar > 255 - threshold, axis=0))[0]
if not index.size:
return prev_index / length
else:
index = index[-1]
if index <= prev_index:
return index / length
prev_index = index
prev_row = bar[:, prev_index] > 255 - threshold
if not prev_row.size:
return prev_index / length
prev_color = np.mean(image[:, prev_index], axis=0)
return 0.