Merge branch 'dev'
This commit is contained in:
commit
88e4282c28
|
@ -15,6 +15,12 @@
|
|||
3. 打开 VSCode 设置,搜索 `imageComments.pathMode` 并设置为 `relativeToWorkspace`。
|
||||
4. 编译资源:在 VSCode 中选择“Terminal” -> “Run Task” -> “Make R.py”并执行。
|
||||
|
||||
## 运行单个任务
|
||||
如果使用 VSCode,只需要在运行配置里选择 `Python: Current Module` 即可。
|
||||
|
||||
如果使用 PyCharm,按照下图新建一个 Run Configuration:
|
||||
|
||||

|
||||
|
||||
## 打包 & 安装
|
||||
```bash
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
|
@ -252,8 +252,7 @@ def calc(image: MatLike, card_count: int):
|
|||
|
||||
|
||||
from kotonebot.backend.debug.mock import MockDevice
|
||||
from kotonebot.backend.context import device, init_context, manual_context, inject_context
|
||||
from kotonebot.tasks.actions.in_purodyuusu import handle_recommended_card, skill_card_count
|
||||
from kotonebot.backend.context import device, init_context, manual_context
|
||||
from kotonebot.backend.util import Profiler
|
||||
init_context()
|
||||
mock = MockDevice()
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 221 B |
|
@ -591,7 +591,7 @@ class ContextColor:
|
|||
def find_all(self, *args, **kwargs):
|
||||
return color_find_all(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
|
||||
|
||||
|
||||
@deprecated('使用 kotonebot.backend.debug 模块替代')
|
||||
class ContextDebug:
|
||||
def __init__(self, context: 'Context'):
|
||||
self.__context = context
|
||||
|
@ -817,6 +817,7 @@ def use_screenshot(*args: MatLike | None) -> MatLike:
|
|||
return device.screenshot()
|
||||
|
||||
WaitBeforeType = Literal['screenshot']
|
||||
@deprecated('使用普通 sleep 代替')
|
||||
def wait(at_least: float = 0.3, *, before: WaitBeforeType) -> None:
|
||||
global next_wait, next_wait_time
|
||||
if before == 'screenshot':
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
from typing import Callable, ParamSpec, TypeVar, overload, Concatenate, Literal
|
||||
from dataclasses import dataclass
|
||||
from typing_extensions import deprecated
|
||||
|
||||
import cv2
|
||||
from cv2.typing import MatLike
|
||||
|
@ -108,6 +109,7 @@ def action(
|
|||
...
|
||||
|
||||
@overload
|
||||
@deprecated('使用普通 while 循环代替')
|
||||
def action(
|
||||
name: str,
|
||||
*,
|
||||
|
|
|
@ -6,7 +6,7 @@ from logging import Logger
|
|||
from types import CodeType
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, Callable, Concatenate, Sequence, TypeVar, ParamSpec, Literal, Protocol, cast
|
||||
from typing_extensions import Self
|
||||
from typing_extensions import deprecated
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
@ -74,6 +74,7 @@ class DispatcherContext:
|
|||
"""是否即将结束运行"""
|
||||
return self.finished
|
||||
|
||||
@deprecated('使用 SimpleDispatcher 类或 while 循环替代')
|
||||
def dispatcher(
|
||||
func: Callable[Concatenate[DispatcherContext, P], R],
|
||||
*,
|
||||
|
|
|
@ -4,7 +4,8 @@ import logging
|
|||
import unicodedata
|
||||
from functools import lru_cache
|
||||
from dataclasses import dataclass
|
||||
from typing_extensions import Self
|
||||
import warnings
|
||||
from typing_extensions import Self, deprecated
|
||||
from typing import Callable, NamedTuple
|
||||
|
||||
import cv2
|
||||
|
@ -129,6 +130,7 @@ class TextComparator:
|
|||
def __repr__(self) -> str:
|
||||
return f'{self.name}("{self.text}")'
|
||||
|
||||
@deprecated("即将移除")
|
||||
@lru_cache(maxsize=1000)
|
||||
def fuzz(text: str) -> TextComparator:
|
||||
"""返回 fuzzy 算法的字符串匹配函数。"""
|
||||
|
@ -404,6 +406,7 @@ class Ocr:
|
|||
:return: 找到的文本,如果未找到则返回 None
|
||||
"""
|
||||
if hint is not None:
|
||||
warnings.warn("使用 `rect` 参数代替")
|
||||
if ret := self.find(img, text, rect=hint):
|
||||
logger.debug(f"find: {text} SUCCESS [hint={hint}]")
|
||||
return ret
|
||||
|
@ -441,6 +444,7 @@ class Ocr:
|
|||
"""
|
||||
# HintBox 处理
|
||||
if hint is not None:
|
||||
warnings.warn("使用 `rect` 参数代替")
|
||||
result = self.find_all(img, texts, rect=hint, pad=pad)
|
||||
if all(result):
|
||||
return result
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
|
||||
from typing import Protocol
|
||||
from typing import Protocol, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from cv2.typing import MatLike
|
||||
|
||||
ImageFormat = Literal['bgr', 'hsv']
|
||||
|
||||
class PreprocessorProtocol(Protocol):
|
||||
"""预处理协议。用于 Image 与 Ocr 中的 `preprocessor` 参数。"""
|
||||
def process(self, image: MatLike) -> MatLike:
|
||||
def process(self, image: MatLike, *, format: ImageFormat = 'bgr') -> MatLike:
|
||||
"""
|
||||
预处理图像。
|
||||
|
||||
:param image: 输入图像,格式为 BGR。
|
||||
:param image: 输入图像。
|
||||
:param format: 输入图像的格式,可选值为 'bgr' 或 'hsv'。
|
||||
:return: 预处理后的图像,格式不限。
|
||||
"""
|
||||
...
|
||||
|
@ -29,10 +32,73 @@ class HsvColorFilter(PreprocessorProtocol):
|
|||
self.upper = np.array(upper)
|
||||
self.name = name
|
||||
|
||||
def process(self, image: MatLike) -> MatLike:
|
||||
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||
def process(self, image: MatLike, *, format: ImageFormat = 'bgr') -> MatLike:
|
||||
if format == 'bgr':
|
||||
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||
elif format == 'hsv':
|
||||
hsv = image
|
||||
else:
|
||||
raise ValueError(f'Invalid format: {format}')
|
||||
mask = cv2.inRange(hsv, self.lower, self.upper)
|
||||
return mask
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'HsvColorFilter(for color "{self.name}" with range {self.lower} - {self.upper})'
|
||||
|
||||
class HsvColorRemover(PreprocessorProtocol):
|
||||
"""去除指定范围内的 HSV 颜色。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lower: tuple[int, int, int],
|
||||
upper: tuple[int, int, int],
|
||||
*,
|
||||
name: str | None = None,
|
||||
):
|
||||
self.lower = np.array(lower)
|
||||
self.upper = np.array(upper)
|
||||
self.name = name
|
||||
|
||||
def process(self, image: MatLike, *, format: ImageFormat = 'bgr') -> MatLike:
|
||||
if format == 'bgr':
|
||||
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||
elif format == 'hsv':
|
||||
hsv = image
|
||||
else:
|
||||
raise ValueError(f'Invalid format: {format}')
|
||||
mask = cv2.inRange(hsv, self.lower, self.upper)
|
||||
mask = cv2.bitwise_not(mask)
|
||||
result = cv2.bitwise_and(image, image, mask=mask)
|
||||
return result
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'HsvColorRemover(for color "{self.name}" with range {self.lower} - {self.upper})'
|
||||
|
||||
class HsvColorsRemover(PreprocessorProtocol):
|
||||
"""去除多个指定范围内的 HSV 颜色。"""
|
||||
def __init__(
|
||||
self,
|
||||
colors: list[tuple[tuple[int, int, int], tuple[int, int, int]]],
|
||||
*,
|
||||
name: str | None = None,
|
||||
):
|
||||
self.colors = colors
|
||||
self.name = name
|
||||
self.__preprocessors = [
|
||||
HsvColorRemover(color[0], color[1], name=name) for color in colors
|
||||
]
|
||||
|
||||
def process(self, image: MatLike, *, format: ImageFormat = 'bgr') -> MatLike:
|
||||
if format == 'bgr':
|
||||
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||
elif format == 'hsv':
|
||||
hsv = image
|
||||
else:
|
||||
raise ValueError(f'Invalid format: {format}')
|
||||
|
||||
for p in self.__preprocessors:
|
||||
hsv = p.process(hsv, format='hsv')
|
||||
return hsv
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'HsvColorsRemover(for colors {self.colors})'
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import runpy
|
||||
import logging
|
||||
import argparse
|
||||
|
||||
from kotonebot.tasks.common import BaseConfig
|
||||
|
||||
|
||||
def run_script(script_path: str) -> None:
|
||||
"""
|
||||
使用 runpy 运行指定的 Python 脚本
|
||||
|
||||
Args:
|
||||
script_path: Python 脚本的路径
|
||||
"""
|
||||
# 获取模块名
|
||||
module_name = script_path.strip('.py').replace('\\', '/').strip('/').replace('/', '.')
|
||||
|
||||
print(f"正在运行脚本: {script_path}")
|
||||
# 运行脚本
|
||||
from kotonebot.backend.context import init_context
|
||||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
||||
logging.getLogger('kotonebot').setLevel(logging.DEBUG)
|
||||
init_context(config_type=BaseConfig)
|
||||
runpy.run_module(module_name, run_name="__main__")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='运行指定的 Python 脚本')
|
||||
parser.add_argument('script_path', help='要运行的 Python 脚本路径')
|
||||
|
||||
args = parser.parse_args()
|
||||
run_script(args.script_path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -5,9 +5,9 @@ from cv2.typing import MatLike
|
|||
|
||||
|
||||
from .. import R
|
||||
from ..game_ui import WhiteFilter
|
||||
from kotonebot.util import Countdown, Interval
|
||||
from kotonebot import device, image, color, user, rect_expand, until, action, sleep, use_screenshot
|
||||
from kotonebot.util import Interval
|
||||
from kotonebot.tasks.game_ui import WhiteFilter
|
||||
from kotonebot import device, image, user, action, use_screenshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -31,13 +31,13 @@ def handle_unread_commu(img: MatLike | None = None) -> bool:
|
|||
ret = False
|
||||
logger.info('Check and skip commu')
|
||||
img = use_screenshot(img)
|
||||
skip_btn = image.find(R.Common.ButtonCommuFastforward, preprocessors=[WhiteFilter()])
|
||||
skip_btn = image.find(R.Common.ButtonCommuSkip, preprocessors=[WhiteFilter()])
|
||||
if skip_btn is None:
|
||||
logger.info('No fast forward button found. Not at a commu.')
|
||||
logger.info('No skip button found. Not at a commu.')
|
||||
return ret
|
||||
|
||||
ret = True
|
||||
logger.debug('Fast forward button found. Check commu')
|
||||
logger.debug('Skip button found. Check commu')
|
||||
|
||||
it = Interval()
|
||||
while True:
|
||||
|
|
|
@ -1,14 +1,9 @@
|
|||
import logging
|
||||
from typing import Callable
|
||||
|
||||
|
||||
|
||||
from .. import R
|
||||
from .loading import loading
|
||||
from kotonebot.util import Interval
|
||||
from ..game_ui import toolbar_home
|
||||
from kotonebot import device, image, action, cropped, until, sleep
|
||||
from kotonebot.errors import UnrecoverableError
|
||||
from kotonebot.tasks.game_ui import toolbar_home
|
||||
from kotonebot import device, image, action, until, sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -78,6 +73,5 @@ def goto_shop():
|
|||
until(at_daily_shop, critical=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
goto_home()
|
||||
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from kotonebot import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@task('清理日志')
|
||||
def clear_logs():
|
||||
"""清理 logs 目录下超过 7 天的日志文件"""
|
||||
log_dir = Path('logs')
|
||||
if not log_dir.exists():
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
cutoff_date = now - timedelta(days=7)
|
||||
|
||||
logger.info('Clearing logs...')
|
||||
for file in log_dir.glob('*.log'):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(file.stat().st_mtime)
|
||||
if mtime < cutoff_date:
|
||||
file.unlink()
|
||||
logger.info(f'Removed file {file}.')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to remove {file}: {e}.')
|
||||
logger.info('Clearing logs done.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
from kotonebot.backend.context import init_context, manual_context
|
||||
init_context()
|
||||
manual_context().begin()
|
||||
clear_logs()
|
|
@ -1,9 +1,9 @@
|
|||
"""收取活动费"""
|
||||
import logging
|
||||
|
||||
from . import R
|
||||
from .common import conf
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from .. import R
|
||||
from ..common import conf
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, device, image, color
|
||||
|
||||
logger = logging.getLogger(__name__)
|
|
@ -1,9 +1,9 @@
|
|||
"""领取礼物(邮箱)"""
|
||||
import logging
|
||||
|
||||
from . import R
|
||||
from .common import conf
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from .. import R
|
||||
from ..common import conf
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import device, image, task, color, rect_expand, sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
|
@ -3,10 +3,10 @@ import logging
|
|||
from typing import Literal
|
||||
from datetime import timedelta
|
||||
|
||||
from . import R
|
||||
from .common import conf
|
||||
from .actions.loading import wait_loading_end
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from .. import R
|
||||
from ..common import conf
|
||||
from ..actions.loading import wait_loading_end
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, device, image, action, ocr, contains, cropped, rect_expand, color, sleep, regex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
|
@ -1,11 +1,12 @@
|
|||
"""扭蛋机,支持任意次数的任意扭蛋类型"""
|
||||
import logging
|
||||
|
||||
from kotonebot import task, action, device, image, sleep, Interval
|
||||
from .. import R
|
||||
from ..common import conf
|
||||
from ..game_ui.scrollable import Scrollable
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot.backend.image import TemplateMatchResult
|
||||
from . import R
|
||||
from .common import conf
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, action, device, image, sleep, Interval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -107,15 +108,8 @@ def capsule_toys():
|
|||
draw_capsule_toys(buttons[1], conf().capsule_toys.sense_capsule_toys_count)
|
||||
|
||||
# 划到第二页
|
||||
device.swipe(
|
||||
R.Daily.CapsuleToys.NextPageStartPoint.x,
|
||||
R.Daily.CapsuleToys.NextPageStartPoint.y,
|
||||
R.Daily.CapsuleToys.NextPageEndPoint.x,
|
||||
R.Daily.CapsuleToys.NextPageEndPoint.y,
|
||||
duration=2.0 # 划慢点,确保精确定位
|
||||
# FIXME: adb不支持swipe duration失效
|
||||
)
|
||||
sleep(1) # 等待滑动静止(由于swipe duration失效,所以这里需要手动等待)
|
||||
sc = Scrollable()
|
||||
sc.next(page=1)
|
||||
|
||||
# 处理逻辑扭蛋扭蛋和非凡扭蛋
|
||||
buttons = get_capsule_toys_draw_buttons()
|
||||
|
@ -129,8 +123,4 @@ def capsule_toys():
|
|||
draw_capsule_toys(buttons[1], conf().capsule_toys.anomaly_capsule_toys_count)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
capsule_toys()
|
||||
|
||||
capsule_toys()
|
|
@ -1,10 +1,10 @@
|
|||
"""领取社团奖励,并尽可能地给其他人送礼物"""
|
||||
import logging
|
||||
|
||||
from . import R
|
||||
from .common import conf
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from kotonebot.tasks.game_ui import toolbar_menu
|
||||
from .. import R
|
||||
from ..common import conf
|
||||
from ..game_ui import toolbar_menu
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, device, image, sleep, ocr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
|
@ -2,11 +2,11 @@
|
|||
import logging
|
||||
from gettext import gettext as _
|
||||
|
||||
from . import R
|
||||
from .common import conf
|
||||
from .game_ui import WhiteFilter
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from .actions.loading import wait_loading_end
|
||||
from .. import R
|
||||
from ..common import conf
|
||||
from ..game_ui import WhiteFilter
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from ..actions.loading import wait_loading_end
|
||||
from kotonebot import device, image, ocr, color, action, task, user, rect_expand, sleep, contains
|
||||
|
||||
logger = logging.getLogger(__name__)
|
|
@ -1,10 +1,10 @@
|
|||
"""领取任务奖励"""
|
||||
import logging
|
||||
|
||||
from . import R
|
||||
from .common import conf, Priority
|
||||
from .actions.loading import wait_loading_end
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from .. import R
|
||||
from ..common import conf, Priority
|
||||
from ..actions.loading import wait_loading_end
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import device, image, color, task, action, rect_expand, sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
|
@ -3,12 +3,12 @@ import logging
|
|||
from typing import Optional
|
||||
from typing_extensions import deprecated
|
||||
|
||||
from . import R
|
||||
from .common import conf, DailyMoneyShopItems
|
||||
from .. import R
|
||||
from ..common import conf, DailyMoneyShopItems
|
||||
from kotonebot.util import cropped
|
||||
from kotonebot import task, device, image, ocr, action, sleep
|
||||
from kotonebot.backend.dispatch import DispatcherContext, SimpleDispatcher, dispatcher
|
||||
from .actions.scenes import goto_home, goto_shop, at_daily_shop
|
||||
from ..actions.scenes import goto_home, goto_shop, at_daily_shop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
"""升级一张支援卡,优先升级低等级支援卡"""
|
||||
import logging
|
||||
|
||||
from .. import R
|
||||
from ..common import conf
|
||||
from ..game_ui.scrollable import Scrollable
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, device, image, sleep
|
||||
from . import R
|
||||
from .common import conf
|
||||
from .actions.scenes import at_home, goto_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -31,19 +32,8 @@ def upgrade_support_card():
|
|||
device.click(image.expect_wait(R.Common.ButtonIdolSupportCard, timeout=5))
|
||||
sleep(2)
|
||||
|
||||
# TODO: 将下面硬编码的值塞到合适的地方
|
||||
|
||||
# 往下滑,划到最底部
|
||||
for _ in range(5):
|
||||
device.swipe(
|
||||
R.Daily.SupportCard.DragDownStartPoint.x,
|
||||
R.Daily.SupportCard.DragDownStartPoint.y,
|
||||
R.Daily.SupportCard.DragDownEndPoint.x,
|
||||
R.Daily.SupportCard.DragDownEndPoint.y,
|
||||
duration=1.0
|
||||
)
|
||||
sleep(0.1)
|
||||
sleep(1.5)
|
||||
Scrollable().to(1)
|
||||
|
||||
# 点击左上角第一张支援卡
|
||||
# 点击位置百分比: (0.18, 0.34)
|
|
@ -1,246 +0,0 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Literal, NamedTuple, overload
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from kotonebot.backend.image import TemplateMatchResult
|
||||
|
||||
from . import R
|
||||
from kotonebot import action, device, color, image, ocr, sleep
|
||||
from kotonebot.backend.color import HsvColor
|
||||
from kotonebot.util import Rect
|
||||
from kotonebot.backend.core import HintBox, Image
|
||||
from kotonebot.backend.preprocessor import HsvColorFilter
|
||||
|
||||
|
||||
@action('按钮是否禁用', screenshot_mode='manual-inherit')
|
||||
def button_state(*, target: Image | None = None, rect: Rect | None = None) -> bool | None:
|
||||
"""
|
||||
判断按钮是否处于禁用状态。
|
||||
|
||||
:param rect: 按钮的矩形区域。必须包括文字或图标部分。
|
||||
:param target: 按钮目标模板。
|
||||
"""
|
||||
img = device.screenshot()
|
||||
if rect is not None:
|
||||
_rect = rect
|
||||
elif target is not None:
|
||||
result = image.find(target)
|
||||
if result is None:
|
||||
return None
|
||||
_rect = result.rect
|
||||
else:
|
||||
raise ValueError('Either rect or target must be provided.')
|
||||
if color.find('#babcbd', rect=_rect):
|
||||
return False
|
||||
elif color.find('#ffffff', rect=_rect):
|
||||
return True
|
||||
else:
|
||||
raise ValueError(f'Unknown button state: {img}')
|
||||
|
||||
def web2cv(hsv: HsvColor):
|
||||
return (int(hsv[0]/360*180), int(hsv[1]/100*255), int(hsv[2]/100*255))
|
||||
|
||||
WHITE_LOW = (0, 0, 200)
|
||||
WHITE_HIGH = (180, 30, 255)
|
||||
|
||||
PINK_TARGET = (335, 78, 95)
|
||||
PINK_LOW = (300, 70, 90)
|
||||
PINK_HIGH = (350, 80, 100)
|
||||
|
||||
BLUE_TARGET = (210, 88, 93)
|
||||
BLUE_LOW = (200, 80, 90)
|
||||
BLUE_HIGH = (220, 90, 100)
|
||||
|
||||
YELLOW_TARGET = (39, 81, 97)
|
||||
YELLOW_LOW = (30, 70, 90)
|
||||
YELLOW_HIGH = (45, 90, 100)
|
||||
|
||||
DEFAULT_COLORS = [
|
||||
(web2cv(PINK_LOW), web2cv(PINK_HIGH)),
|
||||
(web2cv(YELLOW_LOW), web2cv(YELLOW_HIGH)),
|
||||
(web2cv(BLUE_LOW), web2cv(BLUE_HIGH)),
|
||||
]
|
||||
|
||||
def filter_rectangles(
|
||||
img: MatLike,
|
||||
color_ranges: tuple[HsvColor, HsvColor],
|
||||
aspect_ratio_threshold: float,
|
||||
area_threshold: int,
|
||||
rect: Rect | None = None
|
||||
) -> list[Rect]:
|
||||
"""
|
||||
过滤出指定颜色,并执行轮廓查找,返回符合要求的轮廓的 bound box。
|
||||
返回结果按照 y 坐标排序。
|
||||
"""
|
||||
img_hsv =cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||
|
||||
white_mask = cv2.inRange(img_hsv, np.array(color_ranges[0]), np.array(color_ranges[1]))
|
||||
contours, _ = cv2.findContours(white_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
result_rects = []
|
||||
for contour in contours:
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
# 如果不在指定范围内,跳过
|
||||
if rect is not None:
|
||||
rect_x1, rect_y1, rect_w, rect_h = rect
|
||||
rect_x2 = rect_x1 + rect_w
|
||||
rect_y2 = rect_y1 + rect_h
|
||||
if not (
|
||||
x >= rect_x1 and
|
||||
y >= rect_y1 and
|
||||
x + w <= rect_x2 and
|
||||
y + h <= rect_y2
|
||||
):
|
||||
continue
|
||||
aspect_ratio = w / h
|
||||
area = cv2.contourArea(contour)
|
||||
if aspect_ratio >= aspect_ratio_threshold and area >= area_threshold:
|
||||
result_rects.append((x, y, w, h))
|
||||
result_rects.sort(key=lambda x: x[1])
|
||||
return result_rects
|
||||
|
||||
@dataclass
|
||||
class EventButton:
|
||||
rect: Rect
|
||||
selected: bool
|
||||
description: str
|
||||
title: str
|
||||
|
||||
# 参考图片:
|
||||
# [screenshots/produce/action_study3.png]
|
||||
# TODO: CommuEventButtonUI 需要能够识别不可用的按钮
|
||||
class CommuEventButtonUI:
|
||||
"""
|
||||
此类用于识别培育中交流中出现的事件/效果里的按钮。
|
||||
|
||||
例如外出(おでかけ)、冲刺周课程选择这两个页面的选择按钮。
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
selected_colors: list[tuple[HsvColor, HsvColor]] = DEFAULT_COLORS,
|
||||
rect: HintBox = R.InPurodyuusu.BoxCommuEventButtonsArea
|
||||
):
|
||||
"""
|
||||
:param selected_colors: 按钮选中后的主题色。
|
||||
:param rect: 识别范围
|
||||
"""
|
||||
self.color_ranges = selected_colors
|
||||
self.rect = rect
|
||||
|
||||
@action('交流事件按钮.识别选中', screenshot_mode='manual-inherit')
|
||||
def selected(self, description: bool = True, title: bool = False) -> EventButton | None:
|
||||
img = device.screenshot()
|
||||
for i, color_range in enumerate(self.color_ranges):
|
||||
rects = filter_rectangles(img, color_range, 7, 500, rect=self.rect)
|
||||
if len(rects) > 0:
|
||||
desc_text = self.description() if description else ''
|
||||
title_text = ocr.ocr(rect=rects[0]).squash().text if title else ''
|
||||
return EventButton(rects[0], True, desc_text, title_text)
|
||||
return None
|
||||
|
||||
@action('交流事件按钮.识别按钮', screenshot_mode='manual-inherit')
|
||||
def all(self, description: bool = True, title: bool = False) -> list[EventButton]:
|
||||
"""
|
||||
识别所有按钮的位置以及选中后的描述文本
|
||||
|
||||
前置条件:当前显示了交流事件按钮\n
|
||||
结束状态:-
|
||||
|
||||
:param description: 是否识别描述文本。
|
||||
:param title: 是否识别标题。
|
||||
"""
|
||||
img = device.screenshot()
|
||||
rects = filter_rectangles(img, (WHITE_LOW, WHITE_HIGH), 7, 500, rect=self.rect)
|
||||
if not rects:
|
||||
return []
|
||||
selected = self.selected()
|
||||
result: list[EventButton] = []
|
||||
for rect in rects:
|
||||
desc_text = ''
|
||||
title_text = ''
|
||||
if title:
|
||||
title_text = ocr.ocr(rect=rect).squash().text
|
||||
if description:
|
||||
device.click(rect)
|
||||
sleep(0.15)
|
||||
device.screenshot()
|
||||
desc_text = self.description()
|
||||
result.append(EventButton(rect, False, desc_text, title_text))
|
||||
# 修改最后一次点击的按钮为 selected 状态
|
||||
if len(result) > 0:
|
||||
result[-1].selected = True
|
||||
if selected is not None:
|
||||
result.append(selected)
|
||||
selected.selected = False
|
||||
result.sort(key=lambda x: x.rect[1])
|
||||
return result
|
||||
|
||||
@action('交流事件按钮.识别描述', screenshot_mode='manual-inherit')
|
||||
def description(self) -> str:
|
||||
"""
|
||||
识别当前选中按钮的描述文本
|
||||
|
||||
前置条件:有选中按钮\n
|
||||
结束状态:-
|
||||
"""
|
||||
img = device.screenshot()
|
||||
rects = filter_rectangles(img, (WHITE_LOW, WHITE_HIGH), 3, 1000, rect=self.rect)
|
||||
rects.sort(key=lambda x: x[1])
|
||||
# TODO: 这里 rects 可能为空,需要加入判断重试
|
||||
ocr_result = ocr.raw().ocr(img, rect=rects[0])
|
||||
return ocr_result.squash().text
|
||||
|
||||
class WhiteFilter(HsvColorFilter):
|
||||
"""
|
||||
匹配时,只匹配图像和模板中的白色部分。
|
||||
|
||||
此类用于识别空心/透明背景的白色图标或文字。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(WHITE_LOW, WHITE_HIGH)
|
||||
|
||||
@overload
|
||||
def toolbar_home(critical: Literal[False] = False) -> TemplateMatchResult | None:
|
||||
"""寻找工具栏上的首页按钮。"""
|
||||
...
|
||||
|
||||
@overload
|
||||
def toolbar_home(critical: Literal[True]) -> TemplateMatchResult:
|
||||
"""寻找工具栏上的首页按钮。若未找到,则抛出异常。"""
|
||||
...
|
||||
|
||||
@action('工具栏按钮.寻找首页', screenshot_mode='manual-inherit')
|
||||
def toolbar_home(critical: bool = False):
|
||||
device.screenshot()
|
||||
if critical:
|
||||
return image.expect_wait(R.Common.ButtonToolbarHome, preprocessors=[WhiteFilter()])
|
||||
else:
|
||||
return image.find(R.Common.ButtonToolbarHome, preprocessors=[WhiteFilter()])
|
||||
|
||||
@overload
|
||||
def toolbar_menu(critical: Literal[False] = False) -> TemplateMatchResult | None:
|
||||
"""寻找工具栏上的菜单按钮。"""
|
||||
...
|
||||
|
||||
@overload
|
||||
def toolbar_menu(critical: Literal[True]) -> TemplateMatchResult:
|
||||
"""寻找工具栏上的菜单按钮。若未找到,则抛出异常。"""
|
||||
...
|
||||
|
||||
@action('工具栏按钮.寻找菜单', screenshot_mode='manual-inherit')
|
||||
def toolbar_menu(critical: bool = False):
|
||||
device.screenshot()
|
||||
if critical:
|
||||
return image.expect_wait(R.Common.ButtonToolbarMenu, preprocessors=[WhiteFilter()])
|
||||
else:
|
||||
return image.find(R.Common.ButtonToolbarMenu, preprocessors=[WhiteFilter()])
|
||||
|
||||
if __name__ == '__main__':
|
||||
from pprint import pprint as print
|
||||
from kotonebot.backend.context import init_context, manual_context, device
|
||||
init_context()
|
||||
manual_context().begin()
|
||||
print(toolbar_home())
|
|
@ -0,0 +1,3 @@
|
|||
from .toolbar import toolbar_home, toolbar_menu
|
||||
from .commu_event_buttons import CommuEventButtonUI
|
||||
from .common import WhiteFilter
|
|
@ -0,0 +1,101 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Literal, overload
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from kotonebot.backend.image import TemplateMatchResult
|
||||
|
||||
from kotonebot.tasks import R
|
||||
from kotonebot import action, color, image
|
||||
from kotonebot.backend.color import HsvColor
|
||||
from kotonebot.util import Rect
|
||||
from kotonebot.backend.core import Image
|
||||
from kotonebot.backend.preprocessor import HsvColorFilter
|
||||
|
||||
|
||||
def filter_rectangles(
|
||||
img: MatLike,
|
||||
color_ranges: tuple[HsvColor, HsvColor],
|
||||
aspect_ratio_threshold: float,
|
||||
area_threshold: int,
|
||||
rect: Rect | None = None
|
||||
) -> list[Rect]:
|
||||
"""
|
||||
过滤出指定颜色,并执行轮廓查找,返回符合要求的轮廓的 bound box。
|
||||
返回结果按照 y 坐标排序。
|
||||
"""
|
||||
img_hsv =cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||
|
||||
white_mask = cv2.inRange(img_hsv, np.array(color_ranges[0]), np.array(color_ranges[1]))
|
||||
contours, _ = cv2.findContours(white_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
result_rects = []
|
||||
for contour in contours:
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
# 如果不在指定范围内,跳过
|
||||
if rect is not None:
|
||||
rect_x1, rect_y1, rect_w, rect_h = rect
|
||||
rect_x2 = rect_x1 + rect_w
|
||||
rect_y2 = rect_y1 + rect_h
|
||||
if not (
|
||||
x >= rect_x1 and
|
||||
y >= rect_y1 and
|
||||
x + w <= rect_x2 and
|
||||
y + h <= rect_y2
|
||||
):
|
||||
continue
|
||||
aspect_ratio = w / h
|
||||
area = cv2.contourArea(contour)
|
||||
if aspect_ratio >= aspect_ratio_threshold and area >= area_threshold:
|
||||
result_rects.append((x, y, w, h))
|
||||
result_rects.sort(key=lambda x: x[1])
|
||||
return result_rects
|
||||
|
||||
@action('按钮是否禁用', screenshot_mode='manual-inherit')
|
||||
def button_state(*, target: Image | None = None, rect: Rect | None = None) -> bool | None:
|
||||
"""
|
||||
判断按钮是否处于禁用状态。
|
||||
|
||||
:param rect: 按钮的矩形区域。必须包括文字或图标部分。
|
||||
:param target: 按钮目标模板。
|
||||
"""
|
||||
img = device.screenshot()
|
||||
if rect is not None:
|
||||
_rect = rect
|
||||
elif target is not None:
|
||||
result = image.find(target)
|
||||
if result is None:
|
||||
return None
|
||||
_rect = result.rect
|
||||
else:
|
||||
raise ValueError('Either rect or target must be provided.')
|
||||
if color.find('#babcbd', rect=_rect):
|
||||
return False
|
||||
elif color.find('#ffffff', rect=_rect):
|
||||
return True
|
||||
else:
|
||||
raise ValueError(f'Unknown button state: {img}')
|
||||
|
||||
|
||||
WHITE_LOW = (0, 0, 200)
|
||||
WHITE_HIGH = (180, 30, 255)
|
||||
|
||||
|
||||
class WhiteFilter(HsvColorFilter):
|
||||
"""
|
||||
匹配时,只匹配图像和模板中的白色部分。
|
||||
|
||||
此类用于识别空心/透明背景的白色图标或文字。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(WHITE_LOW, WHITE_HIGH)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from pprint import pprint as print
|
||||
from kotonebot.backend.context import init_context, manual_context, device
|
||||
|
||||
init_context()
|
||||
manual_context().begin()
|
|
@ -0,0 +1,121 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from kotonebot.tasks import R
|
||||
from kotonebot.backend.core import HintBox
|
||||
from kotonebot.backend.color import HsvColor
|
||||
from kotonebot import action, device, ocr, sleep, Rect
|
||||
from .common import filter_rectangles, WHITE_LOW, WHITE_HIGH
|
||||
|
||||
@dataclass
|
||||
class EventButton:
|
||||
rect: Rect
|
||||
selected: bool
|
||||
description: str
|
||||
title: str
|
||||
|
||||
def web2cv(hsv: HsvColor):
|
||||
return (int(hsv[0]/360*180), int(hsv[1]/100*255), int(hsv[2]/100*255))
|
||||
|
||||
|
||||
|
||||
PINK_TARGET = (335, 78, 95)
|
||||
PINK_LOW = (300, 70, 90)
|
||||
PINK_HIGH = (350, 80, 100)
|
||||
|
||||
BLUE_TARGET = (210, 88, 93)
|
||||
BLUE_LOW = (200, 80, 90)
|
||||
BLUE_HIGH = (220, 90, 100)
|
||||
|
||||
YELLOW_TARGET = (39, 81, 97)
|
||||
YELLOW_LOW = (30, 70, 90)
|
||||
YELLOW_HIGH = (45, 90, 100)
|
||||
|
||||
DEFAULT_COLORS = [
|
||||
(web2cv(PINK_LOW), web2cv(PINK_HIGH)),
|
||||
(web2cv(YELLOW_LOW), web2cv(YELLOW_HIGH)),
|
||||
(web2cv(BLUE_LOW), web2cv(BLUE_HIGH)),
|
||||
]
|
||||
|
||||
# 参考图片:
|
||||
# [screenshots/produce/action_study3.png]
|
||||
# TODO: CommuEventButtonUI 需要能够识别不可用的按钮
|
||||
class CommuEventButtonUI:
|
||||
"""
|
||||
此类用于识别培育中交流中出现的事件/效果里的按钮。
|
||||
|
||||
例如外出(おでかけ)、冲刺周课程选择这两个页面的选择按钮。
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
selected_colors: list[tuple[HsvColor, HsvColor]] = DEFAULT_COLORS,
|
||||
rect: HintBox = R.InPurodyuusu.BoxCommuEventButtonsArea
|
||||
):
|
||||
"""
|
||||
:param selected_colors: 按钮选中后的主题色。
|
||||
:param rect: 识别范围
|
||||
"""
|
||||
self.color_ranges = selected_colors
|
||||
self.rect = rect
|
||||
|
||||
@action('交流事件按钮.识别选中', screenshot_mode='manual-inherit')
|
||||
def selected(self, description: bool = True, title: bool = False) -> EventButton | None:
|
||||
img = device.screenshot()
|
||||
for i, color_range in enumerate(self.color_ranges):
|
||||
rects = filter_rectangles(img, color_range, 7, 500, rect=self.rect)
|
||||
if len(rects) > 0:
|
||||
desc_text = self.description() if description else ''
|
||||
title_text = ocr.ocr(rect=rects[0]).squash().text if title else ''
|
||||
return EventButton(rects[0], True, desc_text, title_text)
|
||||
return None
|
||||
|
||||
@action('交流事件按钮.识别按钮', screenshot_mode='manual-inherit')
|
||||
def all(self, description: bool = True, title: bool = False) -> list[EventButton]:
|
||||
"""
|
||||
识别所有按钮的位置以及选中后的描述文本
|
||||
|
||||
前置条件:当前显示了交流事件按钮\n
|
||||
结束状态:-
|
||||
|
||||
:param description: 是否识别描述文本。
|
||||
:param title: 是否识别标题。
|
||||
"""
|
||||
img = device.screenshot()
|
||||
rects = filter_rectangles(img, (WHITE_LOW, WHITE_HIGH), 7, 500, rect=self.rect)
|
||||
if not rects:
|
||||
return []
|
||||
selected = self.selected()
|
||||
result: list[EventButton] = []
|
||||
for rect in rects:
|
||||
desc_text = ''
|
||||
title_text = ''
|
||||
if title:
|
||||
title_text = ocr.ocr(rect=rect).squash().text
|
||||
if description:
|
||||
device.click(rect)
|
||||
sleep(0.15)
|
||||
device.screenshot()
|
||||
desc_text = self.description()
|
||||
result.append(EventButton(rect, False, desc_text, title_text))
|
||||
# 修改最后一次点击的按钮为 selected 状态
|
||||
if len(result) > 0:
|
||||
result[-1].selected = True
|
||||
if selected is not None:
|
||||
result.append(selected)
|
||||
selected.selected = False
|
||||
result.sort(key=lambda x: x.rect[1])
|
||||
return result
|
||||
|
||||
@action('交流事件按钮.识别描述', screenshot_mode='manual-inherit')
|
||||
def description(self) -> str:
|
||||
"""
|
||||
识别当前选中按钮的描述文本
|
||||
|
||||
前置条件:有选中按钮\n
|
||||
结束状态:-
|
||||
"""
|
||||
img = device.screenshot()
|
||||
rects = filter_rectangles(img, (WHITE_LOW, WHITE_HIGH), 3, 1000, rect=self.rect)
|
||||
rects.sort(key=lambda x: x[1])
|
||||
# TODO: 这里 rects 可能为空,需要加入判断重试
|
||||
ocr_result = ocr.raw().ocr(img, rect=rects[0])
|
||||
return ocr_result.squash().text
|
|
@ -0,0 +1,323 @@
|
|||
import logging
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from cv2.typing import MatLike, Rect
|
||||
|
||||
from kotonebot import device, color, action
|
||||
from kotonebot.backend.core import HintBox
|
||||
from kotonebot.backend.preprocessor import HsvColorFilter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 暗色系滚动条阈值。bitwise_not = True
|
||||
# 例:金币商店、金币扭蛋页面
|
||||
THRESHOLD_DARK_FULL = 240 # 滚动条+滚动条背景
|
||||
THRESHOLD_DARK_FOREGROUND = 190 # 仅滚动条
|
||||
|
||||
# 亮色系滚动条阈值。bitwise_not = False
|
||||
# 例:每日任务、音乐播放器选歌页面
|
||||
THRESHOLD_LIGHT_FULL = 140 # 滚动条+滚动条背景(效果不佳)
|
||||
THRESHOLD_LIGHT_FOREGROUND = 220 # 仅滚动条
|
||||
|
||||
def find_scroll_bar(img: MatLike, threshold: int, bitwise_not: bool = False) -> Rect | None:
|
||||
"""
|
||||
寻找给定图像中的滚动条。
|
||||
基于二值化+轮廓查找实现。
|
||||
|
||||
:param img: 输入图像。图像必须中存在滚动条,否则无法保证结果是什么。
|
||||
:param threshold: 二值化阈值。
|
||||
:param bitwise_not: 是否对二值化结果取反。
|
||||
:return: 滚动条的矩形区域 `(x, y, w, h)`,如果未找到则返回 None。
|
||||
"""
|
||||
# 灰度、二值化、查找轮廓
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
_, binary = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)
|
||||
if bitwise_not:
|
||||
binary = cv2.bitwise_not(binary)
|
||||
# cv2.imshow('binary', binary)
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
# 找出所有可能是滚动条的轮廓:
|
||||
# 宽高比 < 0.5,且形似矩形
|
||||
filtered_contours = []
|
||||
for contour in contours:
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
contour_area = cv2.contourArea(contour)
|
||||
rect_area = w * h
|
||||
if w/h < 0.5 and contour_area / rect_area > 0.6:
|
||||
filtered_contours.append((contour, (x, y, w, h)))
|
||||
|
||||
# 找出最长的轮廓
|
||||
if filtered_contours:
|
||||
longest_contour = max(filtered_contours, key=lambda c: c[1][3])
|
||||
return longest_contour[1]
|
||||
return None
|
||||
|
||||
def find_scroll_bar2(img: MatLike) -> Rect | None:
|
||||
"""
|
||||
寻找给定图像中的滚动条。
|
||||
基于边缘检测+轮廓查找实现。
|
||||
|
||||
:param img: 输入图像。图像必须中存在滚动条,否则无法保证结果是什么。
|
||||
:return: 滚动条的矩形区域 `(x, y, w, h)`,如果未找到则返回 None。
|
||||
"""
|
||||
# 高斯模糊、边缘检测
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
gray = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
edges = cv2.Canny(gray, 50, 70)
|
||||
# cv2.imshow('edges', cv2.resize(edges, (0, 0), fx=0.5, fy=0.5))
|
||||
# 膨胀
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
||||
dilated = cv2.dilate(edges, kernel, iterations=1)
|
||||
# cv2.imshow('dilated', cv2.resize(dilated, (0, 0), fx=0.5, fy=0.5))
|
||||
# 轮廓检测
|
||||
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
# 找出最可能是滚动条的轮廓:
|
||||
# 宽高比 < 0.5,且形似矩形,且最长
|
||||
rects = []
|
||||
for contour in contours:
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
contour_area = cv2.contourArea(contour)
|
||||
rect_area = w * h
|
||||
if w/h < 0.5 and contour_area / rect_area > 0.6:
|
||||
rects.append((x, y, w, h))
|
||||
if rects:
|
||||
longest_rect = max(rects, key=lambda r: r[2] * r[3])
|
||||
return longest_rect
|
||||
return None
|
||||
|
||||
class ScrollableIterator:
|
||||
def __init__(self, scrollable: 'Scrollable', delta_pixels: int):
|
||||
self.scrollable = scrollable
|
||||
self.delta_pixels = delta_pixels
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if self.scrollable.position >= 1:
|
||||
raise StopIteration
|
||||
self.scrollable.by(pixels=self.delta_pixels)
|
||||
return self.scrollable.position
|
||||
|
||||
class Scrollable:
|
||||
"""
|
||||
此类用于处理游戏内的可滚动容器。
|
||||
|
||||
例:
|
||||
```python
|
||||
sc = Scrollable()
|
||||
sc.to(0) # 滚动到最开始
|
||||
sc.to(0.5) # 滚动到中间
|
||||
sc.to(1) # 滚动到最后
|
||||
|
||||
sc.by(0.1) # 滚动10%
|
||||
sc.by(pixels=100) # 滚动100px
|
||||
|
||||
sc.page_count # 滚动页数
|
||||
sc.position # 当前滚动位置
|
||||
|
||||
# 以步长 10% 开始滚动,直到滚动到最后
|
||||
for _ in sc(0.1):
|
||||
print(sc.position)
|
||||
```
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
scrollbar_rect: HintBox | None = None,
|
||||
color_schema: Literal['light', 'dark'] = 'light',
|
||||
*,
|
||||
at_start_threshold: float = 0.01,
|
||||
at_end_threshold: float = 0.99,
|
||||
auto_update: bool = True
|
||||
):
|
||||
"""
|
||||
:param auto_update: 在每次滑动后是否自动更新滚动数据。
|
||||
"""
|
||||
self.color_schema = color_schema
|
||||
self.scrollbar_rect = scrollbar_rect
|
||||
self.position: float = 0
|
||||
"""当前滚动位置。范围 [0, 1]"""
|
||||
self.thumb_height: int | None = None
|
||||
"""滚动条把手高度"""
|
||||
self.thumb_position: tuple[int, int] | None = None
|
||||
"""滚动条把手位置"""
|
||||
self.track_position: tuple[int, int] | None = None
|
||||
"""滚动轨道位置"""
|
||||
self.track_height: int | None = None
|
||||
"""滚动轨道高度"""
|
||||
self.page_count: int | None = None
|
||||
"""滚动页数"""
|
||||
self.auto_update = auto_update
|
||||
"""是否自动更新滚动数据"""
|
||||
self.at_start_threshold = at_start_threshold
|
||||
self.at_end_threshold = at_end_threshold
|
||||
|
||||
if color_schema == 'dark':
|
||||
raise NotImplementedError('Dark color schema is not implemented yet.')
|
||||
|
||||
@action('滚动.更新数据', screenshot_mode='manual-inherit')
|
||||
def update(self) -> bool:
|
||||
"""
|
||||
立即更新滚动数据。
|
||||
|
||||
:return: 是否更新成功。
|
||||
"""
|
||||
img = device.screenshot()
|
||||
if self.scrollbar_rect is None:
|
||||
logger.debug('Finding scrollbar rect...')
|
||||
self.scrollbar_rect = find_scroll_bar2(img)
|
||||
if self.scrollbar_rect is None:
|
||||
logger.warning('Unable to find scrollbar. (1)')
|
||||
return False
|
||||
logger.debug('Scrollbar rect found.')
|
||||
|
||||
x, y, w, h = self.scrollbar_rect
|
||||
scroll_img = img[y:y+h, x:x+w]
|
||||
# 灰度、二值化
|
||||
gray = cv2.cvtColor(scroll_img, cv2.COLOR_BGR2GRAY)
|
||||
_, binary = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY)
|
||||
# 0 = 滚动条,255 = 背景
|
||||
|
||||
# 计算滚动位置
|
||||
positions = np.where(binary == 0)[0]
|
||||
if len(positions) > 0:
|
||||
self.track_position = (int(x), int(y))
|
||||
self.track_height = int(h)
|
||||
self.thumb_height = int(positions[-1] - positions[0])
|
||||
self.thumb_position = (int(x), int(y + positions[0]))
|
||||
self.position = float(positions[-1] / h)
|
||||
self.page_count = int(h / self.thumb_height)
|
||||
logger.debug(f'Scrollbar height: {self.thumb_height}, position: {self.position}')
|
||||
if self.position < self.at_start_threshold:
|
||||
self.position = 0
|
||||
elif self.position > self.at_end_threshold:
|
||||
self.position = 1
|
||||
return True
|
||||
else:
|
||||
logger.warning('Unable to find scrollbar. (2)')
|
||||
return False
|
||||
|
||||
@action('滚动.下一页', screenshot_mode='manual-inherit')
|
||||
def next(self, *, page: float) -> bool:
|
||||
"""
|
||||
滚动到下一页。
|
||||
|
||||
:param page: 滚动页数。
|
||||
:return: 是否滚动成功。
|
||||
"""
|
||||
logger.debug('Scrolling to next page.')
|
||||
if not self.thumb_height:
|
||||
self.update()
|
||||
if not self.thumb_height or not self.thumb_position:
|
||||
logger.warning('Unable to update scrollbar data.')
|
||||
return False
|
||||
if self.position >= 1:
|
||||
logger.debug('Already at the end of the scrollbar.')
|
||||
return False
|
||||
|
||||
delta = int(self.thumb_height * page)
|
||||
self.by(pixels=delta)
|
||||
return True
|
||||
|
||||
@action('滚动.滚动', screenshot_mode='manual-inherit')
|
||||
def by(self, percentage: float | None = None, *, pixels: int | None = None) -> bool:
|
||||
"""
|
||||
滚动指定距离。
|
||||
|
||||
:param percentage: 滚动距离,范围 [-1, 1]。
|
||||
:param pixels: 滚动距离,单位为像素。此参数优先级高于 percentage。
|
||||
:return: 是否滚动成功。
|
||||
"""
|
||||
if percentage is not None and (percentage > 1 or percentage < -1):
|
||||
raise ValueError('percentage must be in range [-1, 1].')
|
||||
if pixels is not None and pixels < 0:
|
||||
raise ValueError('pixels must be positive.')
|
||||
if not self.thumb_height or not self.thumb_position or not self.track_height:
|
||||
self.update()
|
||||
if not self.thumb_height or not self.thumb_position or not self.track_height:
|
||||
logger.warning('Unable to update scrollbar data.')
|
||||
return False
|
||||
|
||||
x, src_y = self.thumb_position
|
||||
src_y += self.thumb_height // 2
|
||||
if pixels is not None:
|
||||
dst_y = src_y + pixels
|
||||
logger.debug(f'Scrolling by {pixels} px...')
|
||||
elif percentage is not None:
|
||||
logger.debug(f'Scrolling by {percentage}...')
|
||||
dst_y = src_y + int(self.track_height * percentage)
|
||||
else:
|
||||
raise ValueError('Either percentage or pixels must be provided.')
|
||||
device.swipe(x, src_y, x, dst_y, 0.3)
|
||||
time.sleep(0.2)
|
||||
if self.auto_update:
|
||||
self.update()
|
||||
return True
|
||||
|
||||
@action('滚动.滚动到', screenshot_mode='manual-inherit')
|
||||
def to(self, position: float) -> bool:
|
||||
"""
|
||||
滚动到指定位置。
|
||||
|
||||
:param position: 目标位置,范围 [0, 1]。
|
||||
:return: 是否滚动成功。
|
||||
"""
|
||||
if position > 1 or position < 0:
|
||||
raise ValueError('position must be in range [0, 1].')
|
||||
logger.debug(f'Scrolling to {position}...')
|
||||
if not self.thumb_height or not self.thumb_position or not self.track_height or not self.track_position:
|
||||
self.update()
|
||||
if not self.thumb_height or not self.thumb_position or not self.track_height or not self.track_position:
|
||||
logger.warning('Unable to update scrollbar data.')
|
||||
return False
|
||||
|
||||
x, y = self.track_position
|
||||
tx, ty = self.thumb_position
|
||||
ty += self.thumb_height // 2
|
||||
target_y = y + int(self.track_height * position)
|
||||
device.swipe(tx, ty, x, target_y, 0.3)
|
||||
time.sleep(0.2)
|
||||
if self.auto_update:
|
||||
self.update()
|
||||
return True
|
||||
|
||||
def __call__(self, step_percentage: float) -> ScrollableIterator:
|
||||
"""
|
||||
以指定步长滚动。
|
||||
|
||||
:param step_percentage: 步长,范围 [-1, 1]。
|
||||
:return: 一个迭代器,迭代时滚动指定步长。
|
||||
"""
|
||||
if not self.track_height:
|
||||
self.update()
|
||||
if not self.track_height:
|
||||
raise ValueError('Unable to update scrollbar data.')
|
||||
return ScrollableIterator(self, int(self.track_height * step_percentage))
|
||||
|
||||
if __name__ == '__main__':
|
||||
from kotonebot.backend.context import init_context, manual_context, device
|
||||
init_context()
|
||||
manual_context().begin()
|
||||
from kotonebot import device
|
||||
import cv2
|
||||
from kotonebot.util import cv2_imread
|
||||
import time
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
device.screenshot()
|
||||
sc = Scrollable(color_schema='light')
|
||||
sc.update()
|
||||
sc.to(0)
|
||||
print(sc.page_count)
|
||||
pg = sc.page_count
|
||||
assert pg is not None
|
||||
for _ in sc(4 / (pg * 12) * 0.8):
|
||||
print(sc.position)
|
||||
|
||||
cv2.waitKey(0)
|
||||
cv2.destroyAllWindows()
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
from typing import Literal, overload
|
||||
|
||||
from kotonebot.backend.image import TemplateMatchResult
|
||||
|
||||
from kotonebot.tasks import R
|
||||
from .common import WhiteFilter
|
||||
from kotonebot import action, device, image
|
||||
|
||||
@overload
|
||||
def toolbar_home(critical: Literal[False] = False) -> TemplateMatchResult | None:
|
||||
"""寻找工具栏上的首页按钮。"""
|
||||
...
|
||||
|
||||
@overload
|
||||
def toolbar_home(critical: Literal[True]) -> TemplateMatchResult:
|
||||
"""寻找工具栏上的首页按钮。若未找到,则抛出异常。"""
|
||||
...
|
||||
|
||||
@action('工具栏按钮.寻找首页', screenshot_mode='manual-inherit')
|
||||
def toolbar_home(critical: bool = False):
|
||||
device.screenshot()
|
||||
if critical:
|
||||
return image.expect_wait(R.Common.ButtonToolbarHome, preprocessors=[WhiteFilter()])
|
||||
else:
|
||||
return image.find(R.Common.ButtonToolbarHome, preprocessors=[WhiteFilter()])
|
||||
|
||||
@overload
|
||||
def toolbar_menu(critical: Literal[False] = False) -> TemplateMatchResult | None:
|
||||
"""寻找工具栏上的菜单按钮。"""
|
||||
...
|
||||
|
||||
@overload
|
||||
def toolbar_menu(critical: Literal[True]) -> TemplateMatchResult:
|
||||
"""寻找工具栏上的菜单按钮。若未找到,则抛出异常。"""
|
||||
...
|
||||
|
||||
@action('工具栏按钮.寻找菜单', screenshot_mode='manual-inherit')
|
||||
def toolbar_menu(critical: bool = False):
|
||||
device.screenshot()
|
||||
if critical:
|
||||
return image.expect_wait(R.Common.ButtonToolbarMenu, preprocessors=[WhiteFilter()])
|
||||
else:
|
||||
return image.find(R.Common.ButtonToolbarMenu, preprocessors=[WhiteFilter()])
|
|
@ -1,23 +1,19 @@
|
|||
from typing import Literal
|
||||
from logging import getLogger
|
||||
|
||||
from kotonebot.backend.preprocessor import HsvColorFilter
|
||||
from kotonebot.tasks.actions.loading import loading
|
||||
|
||||
from .. import R
|
||||
from kotonebot import (
|
||||
ocr,
|
||||
device,
|
||||
contains,
|
||||
image,
|
||||
regex,
|
||||
action,
|
||||
sleep,
|
||||
Interval,
|
||||
)
|
||||
from ..game_ui import CommuEventButtonUI, WhiteFilter
|
||||
from kotonebot.tasks.game_ui import WhiteFilter, CommuEventButtonUI
|
||||
from .pdorinku import acquire_pdorinku
|
||||
from kotonebot.backend.dispatch import SimpleDispatcher
|
||||
from kotonebot.tasks.actions.commu import handle_unread_commu
|
||||
from kotonebot.util import measure_time
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import math
|
||||
import time
|
||||
import logging
|
||||
from typing_extensions import deprecated, assert_never
|
||||
|
@ -10,19 +9,19 @@ import numpy as np
|
|||
from cv2.typing import MatLike
|
||||
|
||||
from .. import R
|
||||
from . import loading
|
||||
from .scenes import at_home
|
||||
from ..actions import loading
|
||||
from ..actions.scenes import at_home
|
||||
from ..util.trace import trace
|
||||
from ..game_ui import WhiteFilter
|
||||
from .commu import handle_unread_commu
|
||||
from ..actions.commu import handle_unread_commu
|
||||
from ..common import ProduceAction, RecommendCardDetectionMode, conf
|
||||
from kotonebot.errors import UnrecoverableError
|
||||
from kotonebot.backend.context.context import use_screenshot
|
||||
from .common import until_acquisition_clear, acquisitions, commut_event
|
||||
from kotonebot.util import AdaptiveWait, Countdown, Interval, crop, cropped
|
||||
from ..produce.common import until_acquisition_clear, acquisitions, commut_event
|
||||
from kotonebot.util import Countdown, Interval, crop, cropped
|
||||
from kotonebot.backend.dispatch import DispatcherContext, SimpleDispatcher
|
||||
from kotonebot import ocr, device, contains, image, regex, action, sleep, color, Rect, wait
|
||||
from .non_lesson_actions import (
|
||||
from ..produce.non_lesson_actions import (
|
||||
enter_allowance, allowance_available,
|
||||
study_available, enter_study,
|
||||
is_rest_available, rest,
|
||||
|
@ -418,10 +417,16 @@ def until_action_scene(week_first: bool = False):
|
|||
R.InPurodyuusu.ButtonFinalPracticeDance # 离考试剩余一周
|
||||
]):
|
||||
logger.info("Action scene not detected. Retry...")
|
||||
if acquisitions():
|
||||
continue
|
||||
# commu_event 和 acquisitions 顺序不能颠倒。
|
||||
# 在 PRO 培育初始饮料、技能卡二选一事件时,右下方的
|
||||
# 快进按钮会被视为交流。如果先执行 acquisitions(),
|
||||
# 会因为命中交流而 continue,commut_event() 永远
|
||||
# 不会执行。
|
||||
# [screenshots/produce/in_produce/initial_commu_event.png]
|
||||
if week_first and commut_event():
|
||||
continue
|
||||
if acquisitions():
|
||||
continue
|
||||
sleep(0.2)
|
||||
else:
|
||||
logger.info("Now at action scene.")
|
||||
|
@ -1168,7 +1173,6 @@ if __name__ == '__main__':
|
|||
file_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s'))
|
||||
logging.getLogger().addHandler(file_handler)
|
||||
|
||||
from kotonebot.util import Profiler
|
||||
from kotonebot.backend.context import init_context, manual_context
|
||||
from ..common import BaseConfig
|
||||
from kotonebot.backend.debug import debug
|
|
@ -5,11 +5,11 @@
|
|||
"""
|
||||
from logging import getLogger
|
||||
|
||||
from kotonebot.tasks.common import conf
|
||||
|
||||
from .. import R
|
||||
from .common import acquisitions
|
||||
from ..game_ui import CommuEventButtonUI
|
||||
from ..common import conf
|
||||
from ..produce.common import acquisitions
|
||||
from ..game_ui.commu_event_buttons import CommuEventButtonUI
|
||||
from kotonebot.util import Interval
|
||||
from kotonebot.errors import UnrecoverableError
|
||||
from kotonebot import device, image, action, sleep
|
||||
|
@ -67,7 +67,7 @@ def enter_study():
|
|||
elif target == 'vocal':
|
||||
logger.debug("Clicking on lesson vocal.")
|
||||
device.double_click(image.expect(R.InPurodyuusu.TextSelfStudyVocal))
|
||||
from .in_purodyuusu import until_practice_scene, practice
|
||||
from ..produce.in_purodyuusu import until_practice_scene, practice
|
||||
logger.info("Entering practice scene.")
|
||||
until_practice_scene()
|
||||
logger.info("Executing practice.")
|
|
@ -3,15 +3,16 @@ from itertools import cycle
|
|||
from typing import Optional, Literal
|
||||
|
||||
from kotonebot.backend.context.context import wait
|
||||
from kotonebot.tasks.game_ui.scrollable import Scrollable
|
||||
from kotonebot.ui import user
|
||||
from kotonebot.util import Countdown, Interval
|
||||
from kotonebot.backend.dispatch import SimpleDispatcher
|
||||
|
||||
from . import R
|
||||
from .common import conf, PIdol
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from .actions.in_purodyuusu import hajime_pro, hajime_regular, resume_regular_produce
|
||||
from kotonebot import device, image, ocr, task, action, sleep, equals, contains
|
||||
from .. import R
|
||||
from ..common import conf, PIdol
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from ..produce.in_purodyuusu import hajime_pro, hajime_regular, resume_regular_produce
|
||||
from kotonebot import device, image, ocr, task, action, sleep, contains
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -70,7 +71,7 @@ def select_idol(target_titles: list[str] | PIdol):
|
|||
found = False
|
||||
max_tries = 5
|
||||
tries = 0
|
||||
# TODO: 加入 ScrollBar 类,判断滚动条进度
|
||||
sc = Scrollable()
|
||||
# 找到目标偶像
|
||||
while not found:
|
||||
# 首先检查当前选中的是不是已经是目标
|
||||
|
@ -90,8 +91,9 @@ def select_idol(target_titles: list[str] | PIdol):
|
|||
if tries > max_tries:
|
||||
break
|
||||
# 翻页
|
||||
device.swipe(x1=100, x2=100, y1=max_y, y2=min_y)
|
||||
sleep(2)
|
||||
# device.swipe(x1=100, x2=100, y1=max_y, y2=min_y)
|
||||
sc.next(page=0.8)
|
||||
sleep(0.4)
|
||||
device.screenshot()
|
||||
results = image.find_all(R.Produce.IconPIdolLevel)
|
||||
results.sort(key=lambda r: tuple(r.position))
|
|
@ -7,6 +7,7 @@ import cProfile
|
|||
from importlib import resources
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Callable, TYPE_CHECKING, TypeGuard
|
||||
from typing_extensions import deprecated
|
||||
|
||||
import cv2
|
||||
from cv2.typing import MatLike
|
||||
|
@ -30,6 +31,7 @@ def is_rect(rect: typing.Any) -> TypeGuard[Rect]:
|
|||
def is_point(point: typing.Any) -> TypeGuard[Point]:
|
||||
return isinstance(point, typing.Sequence) and len(point) == 2 and all(isinstance(i, int) for i in point)
|
||||
|
||||
@deprecated('使用 HintBox 类与 Devtool 工具替代')
|
||||
def crop(img: MatLike, /, x1: float = 0, y1: float = 0, x2: float = 1, y2: float = 1) -> MatLike:
|
||||
"""
|
||||
按比例裁剪图像。
|
||||
|
@ -47,6 +49,7 @@ def crop(img: MatLike, /, x1: float = 0, y1: float = 0, x2: float = 1, y2: float
|
|||
y2_px = int(h * y2)
|
||||
return img[y1_px:y2_px, x1_px:x2_px]
|
||||
|
||||
@deprecated('使用 numpy 的切片替代')
|
||||
def crop_rect(img: MatLike, rect: Rect) -> MatLike:
|
||||
"""
|
||||
按范围裁剪图像。
|
||||
|
@ -89,6 +92,7 @@ class DeviceHookContextManager:
|
|||
if self.click_hook_before is not None:
|
||||
self.device.click_hooks_before.remove(self.click_hook_before)
|
||||
|
||||
@deprecated('使用 HintBox 类与 Devtool 工具替代')
|
||||
def cropped(
|
||||
device: 'Device',
|
||||
x1: float = 0,
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 758 KiB |
Loading…
Reference in New Issue