Merge branch 'dev'

This commit is contained in:
XcantloadX 2025-03-21 21:26:39 +08:00
commit 8f85a28924
38 changed files with 355 additions and 145 deletions

View File

@ -67,7 +67,7 @@ generate-metadata: env
@{{venv}} python -m build -s kotonebot-resource
# Package KAA
@package: package-resource generate-metadata
@package: env package-resource generate-metadata
{{venv}} python tools/make_resources.py -p # Make R.py in production mode
Write-Host "Removing old build files..."

View File

@ -5,6 +5,12 @@ export interface PropertyRenderBase {
required?: boolean,
}
type SelectOption<T extends string> = {
value: T,
options: Array<{ value: T, label: string }>,
onChange: (value: T) => void
}
interface PropertyRenderInputOptions {
text: {
value: string,
@ -17,7 +23,8 @@ interface PropertyRenderInputOptions {
'long-text': {
value: string,
onChange: (value: string) => void
}
},
select: SelectOption<string>
}
type RenderType = keyof PropertyRenderInputOptions;
@ -162,6 +169,21 @@ const PropertyGrid: React.FC<PropertyGridProps> = ({ properties, titleColumnWidt
} else if (type === 'long-text') {
const propertyLongText = property.render as PropertyRenderInputOptions['long-text'];
field = <textarea value={propertyLongText.value} onChange={(e) => propertyLongText.onChange(e.target.value)} />;
} else if (type === 'select') {
const propertySelect = property.render as PropertyRenderInputOptions['select'];
field = (
<select
value={propertySelect.value}
onChange={(e) => propertySelect.onChange(e.target.value as any)}
style={{ width: '100%', padding: '4px' }}
>
{propertySelect.options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
} else {
console.error('Invalid property render type:', property.render);

View File

@ -1,10 +1,9 @@
import { useState } from "react";
import { Annotation } from "../components/ImageEditor/types";
import { useImmer } from "use-immer";
export type DefinitionType = 'template' | 'ocr' | 'color' | 'hint-box' | 'hint-point';
export interface Definition {
export interface BaseDefinition {
/** 最终出现在 R.py 中的名称 */
name: string;
/** 显示在调试器与调试输出中的名称 */
@ -17,7 +16,7 @@ export interface Definition {
}
export interface TemplateDefinition extends Definition {
export interface TemplateDefinition extends BaseDefinition {
type: 'template';
/**
*
@ -29,6 +28,15 @@ export interface TemplateDefinition extends Definition {
useHintRect: boolean
}
export interface HintBoxDefinition extends BaseDefinition {
type: 'hint-box';
}
export interface HintPointDefinition extends BaseDefinition {
type: 'hint-point';
}
export type Definition = TemplateDefinition | HintBoxDefinition | HintPointDefinition;
export type Definitions = Record<string, Definition>;

View File

@ -5,7 +5,7 @@ import PropertyGrid, { Property, PropertyCategory } from '../../components/Prope
import ImageEditor, { AnnotationChangedEvent } from '../../components/ImageEditor/ImageEditor';
import { Annotation, Tool as EditorTool } from '../../components/ImageEditor/types';
import { BsCursor, BsFolder2Open, BsFloppy, BsCardImage, BsQuestionSquare, BsPinMap } from 'react-icons/bs';
import useImageMetaData, { DefinitionType, ImageMetaData, TemplateDefinition, Definitions } from '../../hooks/useImageMetaData';
import useImageMetaData, { DefinitionType, ImageMetaData, TemplateDefinition, Definitions, Definition } from '../../hooks/useImageMetaData';
import { useImageViewerModal } from '../../components/ImageViewerModal';
import { useMessageBox } from '../../hooks/useMessageBox';
import { useToast } from '../../components/ToastMessage';
@ -138,7 +138,7 @@ const usePropertyGridData = (
definitions: Definitions,
image: HTMLImageElement | null,
onImageClick: (imageUrl: string) => void,
onDefinitionChange?: (id: string, changes: Partial<TemplateDefinition>) => void,
onDefinitionChange?: (id: string, changes: Partial<Definition>) => void,
imageFileName?: string,
annotations?: Annotation[],
currentFileResult?: FileResult | null
@ -246,7 +246,25 @@ const usePropertyGridData = (
},
{
title: '类型',
render: () => definition.type,
render: {
type: 'select',
required: true,
value: definition.type,
options: selectedAnnotation.type === 'rect'
? [
{ value: 'template', label: '模板' },
{ value: 'hint-box', label: 'HintBox' }
]
: [
{ value: 'hint-point', label: 'HintPoint' }
],
onChange: (value) => {
if (value === definition.type) return;
onDefinitionChange?.(selectedAnnotation.id, {
type: value as "template" | "hint-box" | "hint-point",
});
}
}
}
],
foldable: true
@ -540,7 +558,7 @@ const ImageAnnotation: React.FC = () => {
setSelectedAnnotation(annotation);
};
const handleDefinitionChange = (id: string, changes: Partial<TemplateDefinition>) => {
const handleDefinitionChange = (id: string, changes: Partial<Definition>) => {
Definitions.update({
...changes,
annotationId: id

View File

@ -9,7 +9,7 @@ import { Splitable } from '../../components/Splitable';
import { Tool, Annotation } from '../../components/ImageEditor/types';
import { create } from 'zustand';
import { css } from '@emotion/react';
import useImageMetaData, { Definition, DefinitionType, ImageMetaData, TemplateDefinition } from '../../hooks/useImageMetaData';
import useImageMetaData, { BaseDefinition, DefinitionType, ImageMetaData, TemplateDefinition } from '../../hooks/useImageMetaData';
import { useDarkMode } from '../../hooks/useDarkMode';
import { useDebugClient } from '../../store/debugStore';
import useLatestCallback from '../../hooks/useLatestCallback';
@ -157,25 +157,25 @@ const useScriptRecorderStore = create<ScriptRecorderState>((set) => ({
}));
interface ToolConfigItem {
code?: (d: Definition, a: Annotation) => string;
code?: (d: BaseDefinition, a: Annotation) => string;
}
const ToolConfig: Record<ScriptRecorderTool, ToolConfigItem> = {
'drag': {
},
'template': {
code: (d: Definition) => `image.find(R.${d.name})`,
code: (d: BaseDefinition) => `image.find(R.${d.name})`,
},
'template-click': {
code: (d: Definition) =>
code: (d: BaseDefinition) =>
`if image.find(R.${d.name}):\n\tdevice.click()`,
},
'ocr': {
code: (d: Definition) => `ocr.ocr(R.${d.name})`,
code: (d: BaseDefinition) => `ocr.ocr(R.${d.name})`,
},
'ocr-click': {
code: (d: Definition) =>
code: (d: BaseDefinition) =>
`if ocr.ocr(R.${d.name}):\n\tdevice.click()`,
},
'hint-box': {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 B

View File

Before

Width:  |  Height:  |  Size: 474 B

After

Width:  |  Height:  |  Size: 474 B

View File

Before

Width:  |  Height:  |  Size: 711 KiB

After

Width:  |  Height:  |  Size: 711 KiB

View File

@ -0,0 +1 @@
{"definitions":{"b4081250-d962-46ad-9257-03b68ea99a1e":{"name":"Daily.PointDissmissContestReward","displayName":"跳过竞赛赛季奖励动画","type":"hint-point","annotationId":"b4081250-d962-46ad-9257-03b68ea99a1e","useHintRect":false}},"annotations":[{"id":"b4081250-d962-46ad-9257-03b68ea99a1e","type":"point","data":{"x":604,"y":178}}]}

View File

Before

Width:  |  Height:  |  Size: 924 KiB

After

Width:  |  Height:  |  Size: 924 KiB

View File

@ -0,0 +1 @@
{"definitions":{"f1f21925-3e22-4dd1-b53b-bb52bcf26c2b":{"name":"Common.ButtonCommuSkip","displayName":"跳过交流按钮","type":"template","annotationId":"f1f21925-3e22-4dd1-b53b-bb52bcf26c2b","useHintRect":false}},"annotations":[{"id":"f1f21925-3e22-4dd1-b53b-bb52bcf26c2b","type":"rect","data":{"x1":180,"y1":1187,"x2":204,"y2":1215}}]}

View File

@ -1 +1 @@
{"definitions":{"05890a1b-8764-4e9f-9d21-65d292c22e13":{"name":"InPurodyuusu.BoxGoalClearNext","displayName":"培育目标达成 NEXT 文字区域","type":"hint-box","annotationId":"05890a1b-8764-4e9f-9d21-65d292c22e13","useHintRect":false}},"annotations":[{"id":"05890a1b-8764-4e9f-9d21-65d292c22e13","type":"rect","data":{"x1":79,"y1":503,"x2":157,"y2":532}}]}
{"definitions":{"05890a1b-8764-4e9f-9d21-65d292c22e13":{"name":"InPurodyuusu.TextGoalClearNext","displayName":"培育目标达成 NEXT 文字","type":"template","annotationId":"05890a1b-8764-4e9f-9d21-65d292c22e13","useHintRect":false}},"annotations":[{"id":"05890a1b-8764-4e9f-9d21-65d292c22e13","type":"rect","data":{"x1":79,"y1":503,"x2":157,"y2":532}}]}

View File

@ -1 +1 @@
{"definitions":{"582d36c0-0916-4706-9833-4fbc026701f5":{"name":"InPurodyuusu.BoxPDrinkMaxConfirmTitle","displayName":"P饮料溢出 不领取弹窗标题","type":"hint-box","annotationId":"582d36c0-0916-4706-9833-4fbc026701f5","useHintRect":false}},"annotations":[{"id":"582d36c0-0916-4706-9833-4fbc026701f5","type":"rect","data":{"x1":46,"y1":829,"x2":270,"y2":876}}]}
{"definitions":{"582d36c0-0916-4706-9833-4fbc026701f5":{"name":"InPurodyuusu.TextPDrinkMaxConfirmTitle","displayName":"P饮料溢出 不领取弹窗标题","type":"template","annotationId":"582d36c0-0916-4706-9833-4fbc026701f5","useHintRect":false}},"annotations":[{"id":"582d36c0-0916-4706-9833-4fbc026701f5","type":"rect","data":{"x1":46,"y1":829,"x2":270,"y2":876}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

View File

@ -0,0 +1 @@
{"definitions":{"bab6c393-692c-4681-ac0d-76c0d9dabea6":{"name":"InPurodyuusu.IconTitleSkillCardRemoval","displayName":"技能卡自选删除 标题图标","type":"template","annotationId":"bab6c393-692c-4681-ac0d-76c0d9dabea6","useHintRect":false},"00551158-fee9-483f-b034-549139a96f58":{"name":"InPurodyuusu.ButtonRemove","displayName":"削除","type":"template","annotationId":"00551158-fee9-483f-b034-549139a96f58","useHintRect":false,"description":"技能卡自选删除对话框上的确认按钮"}},"annotations":[{"id":"bab6c393-692c-4681-ac0d-76c0d9dabea6","type":"rect","data":{"x1":19,"y1":36,"x2":58,"y2":79}},{"id":"00551158-fee9-483f-b034-549139a96f58","type":"rect","data":{"x1":247,"y1":1137,"x2":417,"y2":1185}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

View File

@ -0,0 +1 @@
{"definitions":{"8c179a21-be6f-4db8-a9b0-9afeb5c36b1c":{"name":"InPurodyuusu.TextPDrink","displayName":"文本「Pドリンク」","type":"template","annotationId":"8c179a21-be6f-4db8-a9b0-9afeb5c36b1c","useHintRect":false,"description":"用于 P 饮料选择对话框"}},"annotations":[{"id":"8c179a21-be6f-4db8-a9b0-9afeb5c36b1c","type":"rect","data":{"x1":244,"y1":615,"x2":357,"y2":641}}]}

View File

@ -1 +1 @@
{"definitions":{"6b58d90d-2e5e-4b7f-bc01-941f2633de89":{"name":"InPurodyuusu.BoxSelectPStuff","displayName":"选择奖励对话框提示语","type":"hint-box","annotationId":"6b58d90d-2e5e-4b7f-bc01-941f2633de89","useHintRect":false},"b3c9e526-de47-44c2-9bda-e9a8a79fdb5a":{"name":"InPurodyuusu.BoxSelectPStuffComfirm","displayName":"选择奖励对话框 领取按钮","type":"hint-box","annotationId":"b3c9e526-de47-44c2-9bda-e9a8a79fdb5a","useHintRect":false}},"annotations":[{"id":"6b58d90d-2e5e-4b7f-bc01-941f2633de89","type":"rect","data":{"x1":62,"y1":558,"x2":655,"y2":716}},{"id":"b3c9e526-de47-44c2-9bda-e9a8a79fdb5a","type":"rect","data":{"x1":256,"y1":1064,"x2":478,"y2":1128}}]}
{"definitions":{"b3c9e526-de47-44c2-9bda-e9a8a79fdb5a":{"name":"InPurodyuusu.BoxSelectPStuffComfirm","displayName":"选择奖励对话框 领取按钮","type":"hint-box","annotationId":"b3c9e526-de47-44c2-9bda-e9a8a79fdb5a","useHintRect":false},"c948f136-416f-447e-8152-54a1cd1d1329":{"name":"InPurodyuusu.TextClaim","displayName":"文字「受け取る」","type":"template","annotationId":"c948f136-416f-447e-8152-54a1cd1d1329","useHintRect":false,"description":"用于 P 物品、P 饮料、技能卡选择对话框"},"0c0627be-4a09-4450-a078-1858d3ace532":{"name":"InPurodyuusu.TextPItem","displayName":"文字「Pアイテム」","type":"template","annotationId":"0c0627be-4a09-4450-a078-1858d3ace532","useHintRect":false,"description":"用于 P 物品选择对话框"}},"annotations":[{"id":"b3c9e526-de47-44c2-9bda-e9a8a79fdb5a","type":"rect","data":{"x1":256,"y1":1064,"x2":478,"y2":1128}},{"id":"c948f136-416f-447e-8152-54a1cd1d1329","type":"rect","data":{"x1":144,"y1":608,"x2":245,"y2":647}},{"id":"0c0627be-4a09-4450-a078-1858d3ace532","type":"rect","data":{"x1":247,"y1":613,"x2":354,"y2":645}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

View File

@ -0,0 +1 @@
{"definitions":{"d271a24f-efe8-424d-8fd5-f6b3756ba4ca":{"name":"InPurodyuusu.TextSkillCard","displayName":"文字「スキルカード」","type":"template","annotationId":"d271a24f-efe8-424d-8fd5-f6b3756ba4ca","useHintRect":false,"description":"用于技能卡选择对话框"}},"annotations":[{"id":"d271a24f-efe8-424d-8fd5-f6b3756ba4ca","type":"rect","data":{"x1":229,"y1":614,"x2":372,"y2":645}}]}

View File

@ -1 +1 @@
{"definitions":{"6b58d90d-2e5e-4b7f-bc01-941f2633de89":{"name":"InPurodyuusu.BoxSkillCardEnhaced","displayName":"技能卡强化文本提示","type":"hint-box","annotationId":"6b58d90d-2e5e-4b7f-bc01-941f2633de89","useHintRect":false}},"annotations":[{"id":"6b58d90d-2e5e-4b7f-bc01-941f2633de89","type":"rect","data":{"x1":49,"y1":948,"x2":676,"y2":1106}}]}
{"definitions":{"6b58d90d-2e5e-4b7f-bc01-941f2633de89":{"name":"InPurodyuusu.IconSkillCardEventBubble","displayName":"技能卡事件气泡框图标","type":"template","annotationId":"6b58d90d-2e5e-4b7f-bc01-941f2633de89","useHintRect":false,"description":"背景上左右两边的星星"}},"annotations":[{"id":"6b58d90d-2e5e-4b7f-bc01-941f2633de89","type":"rect","data":{"x1":46,"y1":1007,"x2":79,"y2":1047}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

View File

@ -0,0 +1 @@
{"definitions":{"e5e84f9e-28da-4cf4-bcba-c9145fe39b07":{"name":"Produce.ButtonSkipLive","displayName":"培育结束跳过演出按钮","type":"template","annotationId":"e5e84f9e-28da-4cf4-bcba-c9145fe39b07","useHintRect":false}},"annotations":[{"id":"e5e84f9e-28da-4cf4-bcba-c9145fe39b07","type":"rect","data":{"x1":1179,"y1":56,"x2":1221,"y2":106}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

View File

@ -0,0 +1 @@
{"definitions":{"b6b94f21-ef4b-4425-9c7e-ca2b574b0add":{"name":"Produce.TextSkipLiveDialogTitle","displayName":"跳过演出确认对话框标题","type":"template","annotationId":"b6b94f21-ef4b-4425-9c7e-ca2b574b0add","useHintRect":false},"fad5eec2-5fd5-412f-9abb-987a3087dc54":{"name":"Common.IconButtonCheck","displayName":"按钮✓图标","type":"template","annotationId":"fad5eec2-5fd5-412f-9abb-987a3087dc54","useHintRect":false,"description":"通常会显示在对话框按钮上"},"bc7155ac-18c9-4335-9ec2-c8762d37a057":{"name":"Common.IconButtonCross","displayName":"按钮×图标","type":"template","annotationId":"bc7155ac-18c9-4335-9ec2-c8762d37a057","useHintRect":false,"description":"通常会显示在对话框按钮上"}},"annotations":[{"id":"b6b94f21-ef4b-4425-9c7e-ca2b574b0add","type":"rect","data":{"x1":336,"y1":358,"x2":572,"y2":399}},{"id":"fad5eec2-5fd5-412f-9abb-987a3087dc54","type":"rect","data":{"x1":666,"y1":576,"x2":721,"y2":624}},{"id":"bc7155ac-18c9-4335-9ec2-c8762d37a057","type":"rect","data":{"x1":390,"y1":577,"x2":434,"y2":624}}]}

View File

@ -42,13 +42,16 @@ import kotonebot.backend.color as raw_color
from kotonebot.backend.color import (
find as color_find, find_all as color_find_all
)
from kotonebot.backend.ocr import Ocr, OcrResult, OcrResultList, jp, en, StringMatchFunction
from kotonebot.backend.ocr import (
Ocr, OcrResult, OcrResultList, jp, en, StringMatchFunction
)
from kotonebot.client.factory import create_device
from kotonebot.config.manager import load_config, save_config
from kotonebot.config.base_config import UserConfig
from kotonebot.backend.core import Image, HintBox
from kotonebot.errors import KotonebotWarning
from kotonebot.client.factory import DeviceImpl
from kotonebot.backend.preprocessor import PreprocessorProtocol
OcrLanguage = Literal['jp', 'en']
ScreenshotMode = Literal['auto', 'manual', 'manual-inherit']
@ -396,6 +399,7 @@ class ContextImage:
*,
transparent: bool = False,
interval: float = DEFAULT_INTERVAL,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> TemplateMatchResult | None:
"""
等待指定图像出现
@ -406,7 +410,14 @@ class ContextImage:
while True:
if is_manual:
device.screenshot()
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
ret = self.find(
template,
mask,
transparent=transparent,
threshold=threshold,
colored=colored,
preprocessors=preprocessors,
)
if ret is not None:
self.context.device.last_find = ret
return ret
@ -423,7 +434,8 @@ class ContextImage:
colored: bool = False,
*,
transparent: bool = False,
interval: float = DEFAULT_INTERVAL
interval: float = DEFAULT_INTERVAL,
preprocessors: list[PreprocessorProtocol] | None = None,
):
"""
等待指定图像中的任意一个出现
@ -439,7 +451,14 @@ class ContextImage:
if is_manual:
device.screenshot()
for template, mask in zip(templates, _masks):
if self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored):
if self.find(
template,
mask,
transparent=transparent,
threshold=threshold,
colored=colored,
preprocessors=preprocessors,
):
return True
if time.time() - start_time > timeout:
return False
@ -454,7 +473,8 @@ class ContextImage:
colored: bool = False,
*,
transparent: bool = False,
interval: float = DEFAULT_INTERVAL
interval: float = DEFAULT_INTERVAL,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> TemplateMatchResult:
"""
等待指定图像出现
@ -465,7 +485,14 @@ class ContextImage:
while True:
if is_manual:
device.screenshot()
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
ret = self.find(
template,
mask,
transparent=transparent,
threshold=threshold,
colored=colored,
preprocessors=preprocessors,
)
if ret is not None:
self.context.device.last_find = ret
return ret
@ -482,7 +509,8 @@ class ContextImage:
colored: bool = False,
*,
transparent: bool = False,
interval: float = DEFAULT_INTERVAL
interval: float = DEFAULT_INTERVAL,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> TemplateMatchResult:
"""
等待指定图像中的任意一个出现
@ -498,7 +526,14 @@ class ContextImage:
if is_manual:
device.screenshot()
for template, mask in zip(templates, _masks):
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
ret = self.find(
template,
mask,
transparent=transparent,
threshold=threshold,
colored=colored,
preprocessors=preprocessors,
)
if ret is not None:
self.context.device.last_find = ret
return ret

View File

@ -10,6 +10,7 @@ from skimage.metrics import structural_similarity
from .core import Image, unify_image
from ..util import Rect, Point
from .debug import result as debug_result, debug, img
from .preprocessor import PreprocessorProtocol
logger = getLogger(__name__)
@ -124,6 +125,8 @@ def _results2str(results: Sequence[TemplateMatchResult | MultipleTemplateMatchRe
return 'None'
return ', '.join([_result2str(result) for result in results])
# TODO: 应该把 template_match 和 find、wait、expect 等函数的公共参数提取出来
# TODO: 需要在调试结果中输出 preprocessors 处理后的图像
def template_match(
template: MatLike | str | Image,
image: MatLike | str | Image,
@ -134,6 +137,7 @@ def template_match(
max_results: int = 5,
remove_duplicate: bool = True,
colored: bool = False,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> list[TemplateMatchResult]:
"""
寻找模板在图像中的位置
@ -150,6 +154,7 @@ def template_match(
:param max_results: 最大结果数默认为 1
:param remove_duplicate: 是否移除重复结果默认为 True
:param colored: 是否匹配颜色默认为 False
:param preprocessors: 预处理列表默认为 None
"""
# 统一参数
template = unify_image(template, transparent)
@ -164,6 +169,13 @@ def template_match(
# 从透明图像中提取 alpha 通道作为 mask
mask = cv2.threshold(template[:, :, 3], 0, 255, cv2.THRESH_BINARY)[1]
template = template[:, :, :3]
# 预处理
if preprocessors is not None:
for preprocessor in preprocessors:
image = preprocessor.process(image)
template = preprocessor.process(template)
if mask is not None:
mask = preprocessor.process(mask)
# 匹配模板
if mask is not None:
# https://stackoverflow.com/questions/35642497/python-opencv-cv2-matchtemplate-with-transparency
@ -302,6 +314,7 @@ def find_all_crop(
*,
colored: bool = False,
remove_duplicate: bool = True,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> list[CropResult]:
"""
指定一个模板在输入图像中寻找其出现的所有位置并裁剪出结果
@ -313,6 +326,7 @@ def find_all_crop(
:param threshold: 阈值默认为 0.8
:param colored: 是否匹配颜色默认为 False
:param remove_duplicate: 是否移除重复结果默认为 True
:param preprocessors: 预处理列表默认为 None
"""
matches = template_match(
template,
@ -323,6 +337,7 @@ def find_all_crop(
max_results=-1,
remove_duplicate=remove_duplicate,
colored=colored,
preprocessors=preprocessors,
)
# logger.debug(
# f'find_all_crop(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
@ -345,6 +360,7 @@ def find(
debug_output: bool = True,
colored: bool = False,
remove_duplicate: bool = True,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> TemplateMatchResult | None:
"""
指定一个模板在输入图像中寻找其出现的第一个位置
@ -357,6 +373,7 @@ def find(
:param debug_output: 是否输出调试信息默认为 True
:param colored: 是否匹配颜色默认为 False
:param remove_duplicate: 是否移除重复结果默认为 True
:param preprocessors: 预处理列表默认为 None
"""
matches = template_match(
template,
@ -367,6 +384,7 @@ def find(
max_results=1,
remove_duplicate=remove_duplicate,
colored=colored,
preprocessors=preprocessors,
)
# logger.debug(
# f'find(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
@ -396,6 +414,7 @@ def find_all(
remove_duplicate: bool = True,
colored: bool = False,
debug_output: bool = True,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> list[TemplateMatchResult]:
"""
指定一个模板在输入图像中寻找其出现的所有位置
@ -407,6 +426,7 @@ def find_all(
:param threshold: 阈值默认为 0.8
:param remove_duplicate: 是否移除重复结果默认为 True
:param colored: 是否匹配颜色默认为 False
:param preprocessors: 预处理列表默认为 None
"""
results = template_match(
template,
@ -417,6 +437,7 @@ def find_all(
max_results=-1,
remove_duplicate=remove_duplicate,
colored=colored,
preprocessors=preprocessors,
)
# logger.debug(
# f'find_all(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
@ -441,6 +462,7 @@ def find_multi(
threshold: float = 0.8,
colored: bool = False,
remove_duplicate: bool = True,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> MultipleTemplateMatchResult | None:
"""
指定多个模板在输入图像中逐个寻找模板返回第一个匹配到的结果
@ -452,6 +474,7 @@ def find_multi(
:param threshold: 阈值默认为 0.8
:param colored: 是否匹配颜色默认为 False
:param remove_duplicate: 是否移除重复结果默认为 True
:param preprocessors: 预处理列表默认为 None
"""
ret = None
if masks is None:
@ -468,6 +491,7 @@ def find_multi(
colored=colored,
debug_output=False,
remove_duplicate=remove_duplicate,
preprocessors=preprocessors,
)
# 调试输出
if find_result is not None:
@ -508,6 +532,7 @@ def find_all_multi(
threshold: float = 0.8,
colored: bool = False,
remove_duplicate: bool = True,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> list[MultipleTemplateMatchResult]:
"""
指定多个模板在输入图像中逐个寻找模板返回所有匹配到的结果
@ -526,6 +551,7 @@ def find_all_multi(
:param threshold: 阈值默认为 0.8
:param colored: 是否匹配颜色默认为 False
:param remove_duplicate: 是否移除重复结果默认为 True
:param preprocessors: 预处理列表默认为 None
:return: 匹配到的一维结果列表
"""
ret: list[MultipleTemplateMatchResult] = []
@ -544,6 +570,7 @@ def find_all_multi(
colored=colored,
remove_duplicate=remove_duplicate,
debug_output=False,
preprocessors=preprocessors,
)
ret.extend([
MultipleTemplateMatchResult.from_template_match_result(r, index)
@ -591,6 +618,7 @@ def count(
threshold: float = 0.8,
remove_duplicate: bool = True,
colored: bool = False,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> int:
"""
指定一个模板统计其出现的次数
@ -602,6 +630,7 @@ def count(
:param threshold: 阈值默认为 0.8
:param remove_duplicate: 是否移除重复结果默认为 True
:param colored: 是否匹配颜色默认为 False
:param preprocessors: 预处理列表默认为 None
"""
results = template_match(
template,
@ -612,6 +641,7 @@ def count(
max_results=-1,
remove_duplicate=remove_duplicate,
colored=colored,
preprocessors=preprocessors,
)
# logger.debug(
# f'count(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
@ -641,6 +671,7 @@ def expect(
threshold: float = 0.8,
colored: bool = False,
remove_duplicate: bool = True,
preprocessors: list[PreprocessorProtocol] | None = None,
) -> TemplateMatchResult:
"""
指定一个模板寻找其出现的第一个位置若未找到则抛出异常
@ -652,6 +683,7 @@ def expect(
:param threshold: 阈值默认为 0.8
:param colored: 是否匹配颜色默认为 False
:param remove_duplicate: 是否移除重复结果默认为 True
:param preprocessors: 预处理列表默认为 None
"""
ret = find(
image,
@ -662,6 +694,7 @@ def expect(
colored=colored,
remove_duplicate=remove_duplicate,
debug_output=False,
preprocessors=preprocessors,
)
# logger.debug(
# f'expect(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '

View File

@ -0,0 +1,38 @@
from typing import Protocol
import cv2
import numpy as np
from cv2.typing import MatLike
class PreprocessorProtocol(Protocol):
"""预处理协议。用于 Image 与 Ocr 中的 `preprocessor` 参数。"""
def process(self, image: MatLike) -> MatLike:
"""
预处理图像
:param image: 输入图像格式为 BGR
:return: 预处理后的图像格式不限
"""
...
class HsvColorFilter(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) -> MatLike:
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
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})'

View File

@ -65,7 +65,8 @@ class Device:
"""上次 image 对象或 ocr 对象的寻找结果"""
self.orientation: Literal['portrait', 'landscape'] = 'portrait'
"""
设备当前方向默认为竖屏
设备当前方向默认为竖屏注意此属性并非用于检测设备方向
如果需要检测设备方向请使用 `self.detect_orientation()` 方法
横屏时为 'landscape'竖屏时为 'portrait'
"""

View File

@ -72,7 +72,7 @@ class UserConfig(ConfigBaseModel, Generic[T]):
class RootConfig(ConfigBaseModel, Generic[T]):
version: int = 1
version: int = 2
"""配置版本。"""
user_configs: list[UserConfig[T]] = []
"""用户配置。"""

View File

@ -1,6 +1,7 @@
from typing import Literal
from logging import getLogger
from kotonebot.backend.preprocessor import HsvColorFilter
from kotonebot.tasks.actions.loading import loading
from .. import R
@ -14,7 +15,7 @@ from kotonebot import (
sleep,
Interval,
)
from ..game_ui import CommuEventButtonUI
from ..game_ui import CommuEventButtonUI, WhiteFilter
from .pdorinku import acquire_pdorinku
from kotonebot.backend.dispatch import SimpleDispatcher
from kotonebot.tasks.actions.commu import handle_unread_commu
@ -55,7 +56,7 @@ def acquire_skill_card():
def select_p_item():
"""
前置条件P物品选择对话框受け取るPアイテムを選んでください;\n
结束条件P物品获取动画
结束状态P物品获取动画
"""
# 前置条件 [screenshots/produce/in_produce/select_p_item.png]
# 前置条件 [screenshots/produce/in_produce/claim_p_item.png]
@ -69,16 +70,16 @@ def select_p_item():
sleep(0.5)
device.click(ocr.expect_wait('受け取る'))
@action('技能卡强化', screenshot_mode='manual-inherit')
@action('技能卡自选强化', screenshot_mode='manual-inherit')
def hanlde_skill_card_enhance():
"""
前置条件技能卡强化对话框\n
结束条件技能卡强化动画结束后瞬间
结束状态技能卡强化动画结束后瞬间
:return: 是否成功处理对话框
"""
# 前置条件 [kotonebot-resource\sprites\jp\in_purodyuusu\screenshot_skill_card_enhane.png]
# 结束条件 [screenshots/produce/in_produce/skill_card_enhance.png]
# 结束状态 [screenshots/produce/in_produce/skill_card_enhance.png]
cards = image.find_multi([
R.InPurodyuusu.A,
R.InPurodyuusu.M
@ -93,22 +94,52 @@ def hanlde_skill_card_enhance():
device.screenshot()
if image.find(R.InPurodyuusu.ButtonEnhance, colored=True):
device.click()
elif '強化' in ocr.ocr(rect=R.InPurodyuusu.BoxSkillCardEnhaced).squash().text:
logger.debug("Enhance button found")
elif image.find(R.InPurodyuusu.IconSkillCardEventBubble):
device.click_center()
logger.debug("Skill card event bubble found")
break
it.wait()
logger.debug("Handle skill card enhance finished.")
@action('技能卡自选删除', screenshot_mode='manual-inherit')
def handle_skill_card_removal():
"""
前置条件技能卡删除对话框\n
结束状态技能卡删除动画结束后瞬间
"""
# 前置条件 [kotonebot-resource\sprites\jp\in_purodyuusu\screenshot_remove_skill_card.png]
card = image.find_multi([
R.InPurodyuusu.A,
R.InPurodyuusu.M
])
if card is None:
logger.info("No skill cards found")
return False
device.click(card)
it = Interval()
while True:
device.screenshot()
if image.find(R.InPurodyuusu.ButtonRemove):
device.click()
logger.debug("Remove button clicked.")
elif image.find(R.InPurodyuusu.IconSkillCardEventBubble):
device.click_center()
logger.debug("Skill card event bubble found")
break
it.wait()
logger.debug("Handle skill card removal finished.")
AcquisitionType = Literal[
"PDrinkAcquire", # P饮料被动领取
"PDrinkSelect", # P饮料主动领取
"PDrinkMax", # P饮料到达上限
"PSkillCardAcquire", # 技能卡领取
"PSkillCardChange", # 技能卡更换
"PSkillCardSelect", # 技能卡选择
"PSkillCardEnhanced", # 技能卡强化
"PSkillCardEnhanceSelect", # 技能卡强化选择
"PSkillCardRemove", # 技能卡移除
"PSkillCardEnhanceSelect", # 技能卡自选强化
"PSkillCardRemoveSelect", # 技能卡自选删除
"PSkillCardEvent", # 技能卡事件(随机强化、删除、更换)
"PItemClaim", # P物品领取
"PItemSelect", # P物品选择
"Clear", # 目标达成
@ -118,7 +149,7 @@ AcquisitionType = Literal[
"Loading", # 加载画面
]
@measure_time(file_path='logs/acquisition.time.log')
@measure_time()
@action('处理培育事件', screenshot_mode='manual')
def acquisitions() -> AcquisitionType | None:
"""处理行动开始前和结束后可能需要处理的事件,直到到行动页面为止"""
@ -158,10 +189,10 @@ def acquisitions() -> AcquisitionType | None:
logger.info("Leave button found")
device.click(leave)
return "PDrinkMax"
if ocr.find(contains('報酬'), rect=R.InPurodyuusu.BoxPDrinkMaxConfirmTitle):
if image.find(R.InPurodyuusu.TextPDrinkMaxConfirmTitle):
logger.debug("PDrink max confirm found")
device.screenshot()
if ocr.find(contains('報酬'), rect=R.InPurodyuusu.BoxPDrinkMaxConfirmTitle):
if image.find(R.InPurodyuusu.TextPDrinkMaxConfirmTitle):
if confirm := image.find(R.Common.ButtonConfirm):
logger.info("Confirm button found")
device.click(confirm)
@ -176,11 +207,16 @@ def acquisitions() -> AcquisitionType | None:
device.click_center()
return "PSkillCardAcquire"
# 技能卡强化选择
# 技能卡自选强化
if image.find(R.InPurodyuusu.IconTitleSkillCardEnhance):
if hanlde_skill_card_enhance():
return "PSkillCardEnhanceSelect"
# 技能卡自选删除
if image.find(R.InPurodyuusu.IconTitleSkillCardRemoval):
if handle_skill_card_removal():
return "PSkillCardRemoveSelect"
# 目标达成
logger.debug("Check gloal clear (達成)...")
if image.find(R.InPurodyuusu.IconClearBlue):
@ -190,7 +226,7 @@ def acquisitions() -> AcquisitionType | None:
sleep(1)
return "Clear"
# 目标达成 NEXT
if ocr.find(regex('NEXT|next'), rect=R.InPurodyuusu.BoxGoalClearNext):
if image.find(R.InPurodyuusu.TextGoalClearNext, preprocessors=[WhiteFilter()]):
logger.info("Goal clear (達成) next found")
device.click_center()
sleep(1)
@ -218,53 +254,44 @@ def acquisitions() -> AcquisitionType | None:
# 物品选择对话框
logger.debug("Check award select dialog...")
if result := ocr.find(contains("受け取る"), rect=R.InPurodyuusu.BoxSelectPStuff):
if image.find(R.InPurodyuusu.TextClaim):
logger.info("Award select dialog found.")
logger.debug(f"Dialog text: {result.text}")
# P饮料选择
logger.debug("Check PDrink select...")
if "Pドリンク" in result.text:
if image.find(R.InPurodyuusu.TextPDrink):
logger.info("PDrink select found")
acquire_pdorinku(index=0)
return "PDrinkSelect"
# 技能卡选择
logger.debug("Check skill card select...")
if "スキルカード" in result.text:
if image.find(R.InPurodyuusu.TextSkillCard):
logger.info("Acquire skill card found")
acquire_skill_card()
return "PSkillCardSelect"
# P物品选择
logger.debug("Check PItem select...")
if "Pアイテム" in result.text:
if image.find(R.InPurodyuusu.TextPItem):
logger.info("Acquire PItem found")
select_p_item()
return "PItemSelect"
# 技能卡变更事件
# 包括下面这些:
# 1. 技能卡更换
# [screenshots/produce/in_produce/support_card_change.png]
# 2. 技能卡强化
# [screenshots/produce/in_produce/skill_card_enhance.png]
# 3. 技能卡移除
# [screenshots/produce/in_produce/skill_card_removal.png]
logger.debug("Check skill card events...")
if result := ocr.ocr(rect=R.InPurodyuusu.BoxSkillCardEnhaced).squash():
# 技能卡更换(支援卡效果)
# [screenshots/produce/in_produce/support_card_change.png]
if "チェンジ" in result.text:
logger.info("Change skill card found")
device.click(*bottom_pos)
return "PSkillCardChange"
# 技能卡强化
# [screenshots/produce/in_produce/skill_card_enhance.png]
if "強化" in result.text:
logger.info("Enhance skill card found")
device.click(*bottom_pos)
return "PSkillCardEnhanced"
# 技能卡移除
# [screenshots\produce\in_produce\skill_card_removal.png]
if "削除" in result.text:
logger.info("Remove skill card found")
device.click(*bottom_pos)
return "PSkillCardRemove"
if image.find(R.InPurodyuusu.IconSkillCardEventBubble):
device.click() # 不能 click_center因为中间是技能卡
return "PSkillCardEvent"
# 技能卡获取
# [res/sprites/jp/in_purodyuusu/screenshot_skill_card_acquired.png]
# [kotonebot-resource\sprites\jp\in_purodyuusu\screenshot_skill_card_acquired.png]
# 因为这个文本有缩放动画,因此暂时没法用模板匹配代替
if ocr.find("スキルカード獲得", rect=R.InPurodyuusu.BoxSkillCardAcquired):
logger.info("Acquire skill card from loot box")
device.click_center()

View File

@ -5,6 +5,7 @@ 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
@ -13,7 +14,7 @@ logger = logging.getLogger(__name__)
@action('检查是否处于交流')
def is_at_commu():
return image.find(R.Common.ButtonCommuFastforward) is not None
return image.find(R.Common.ButtonCommuSkip, preprocessors=[WhiteFilter()]) is not None
@action('跳过交流')
def skip_commu():
@ -30,56 +31,32 @@ 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)
skip_btn = image.find(R.Common.ButtonCommuFastforward, preprocessors=[WhiteFilter()])
if skip_btn is None:
logger.info('No fast forward button found. Not at a commu.')
return ret
ret = True
logger.debug('Fast forward button found. Check commu')
button_bg_rect = rect_expand(skip_btn.rect, 10, 10, 50, 10)
def is_fastforwarding():
nonlocal img
assert img is not None
colors = color.raw().dominant_color(img, 2, rect=button_bg_rect)
RANGE = ((20, 65, 95), (180, 100, 100))
return any(color.raw().in_range(c, RANGE) for c in colors)
# 防止截图速度过快时,截图到了未加载完全的画面
cd = Interval(seconds=0.6)
hit = 0
HIT_THRESHOLD = 2
it = Interval()
while True:
if image.find(R.Common.ButtonCommuFastforward) and not is_fastforwarding():
logger.debug("Unread commu hit %d/%d", hit, HIT_THRESHOLD)
hit += 1
else:
hit = 0
if not is_at_commu():
break
if hit >= HIT_THRESHOLD:
break
cd.wait()
img = device.screenshot()
should_skip = hit >= HIT_THRESHOLD
if not should_skip:
logger.info('Fast forwarding. No action needed.')
return False
if should_skip:
user.info('发现未读交流', images=[img])
logger.debug('Not fast forwarding. Click fast forward button')
device.click(skip_btn)
sleep(0.7)
if image.wait_for(R.Common.ButtonConfirm, timeout=5):
logger.debug('Click confirm button')
if image.find(R.Common.ButtonCommuSkip, preprocessors=[WhiteFilter()]):
device.click()
else:
logger.info('Fast forwarding. No action needed.')
logger.debug('Wait until not at commu')
# TODO: 这里改用 while 循环,避免点击未生效的情况
until(lambda: not is_at_commu(), interval=0.3)
logger.info('Fast forward done')
logger.debug('Clicked skip button.')
if image.find(R.Common.ButtonConfirm):
logger.info('Unread commu found.')
device.click()
logger.debug('Clicked confirm button.')
logger.debug('Pushing notification...')
user.info('发现未读交流', images=[img])
logger.debug('Fast forwarding...')
it.wait()
logger.info('Fast forward done.')
return ret
@ -87,12 +64,12 @@ 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)
from kotonebot.backend.context import manual_context, inject_context
from kotonebot.backend.debug.mock import MockDevice
manual_context().begin()
device = MockDevice()
device.load_image(r"D:\current_screenshot.png")
inject_context(device=device)
print(is_at_commu())
# rect = image.expect(R.Common.ButtonCommuFastforward).rect
# print(rect)
# rect = rect_expand(rect, 10, 10, 50, 10)
# print(rect)
# img = device.screenshot()
# print(color.raw().dominant_color(img, 2, rect=rect))
# skip_commu()
# check_and_skip_commu()
# while True:
# print(handle_unread_commu())

View File

@ -13,12 +13,13 @@ from .. import R
from . import loading
from .scenes import at_home
from ..util.trace import trace
from ..game_ui import WhiteFilter
from .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, crop, cropped
from kotonebot.util import AdaptiveWait, 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 (
@ -677,6 +678,7 @@ def exam(type: Literal['mid', 'final']):
while ocr.wait_for(contains("メモリー"), timeout=7):
device.click_center()
# TODO: 将这个函数改为手动截图模式
@action('考试结束流程')
def produce_end():
"""执行考试结束流程"""
@ -690,12 +692,22 @@ def produce_end():
# 等待选择封面画面 [screenshots/produce_end/select_cover.jpg]
# 次へ
logger.info("Waiting for select cover screen...")
aw = AdaptiveWait(timeout=60 * 5, max_interval=20)
it = Interval()
while not image.find(R.InPurodyuusu.ButtonNextNoIcon):
# device.screenshot()
# 未读交流
if handle_unread_commu():
logger.info("Skipping unread commu")
continue
aw()
# 跳过演出
# [kotonebot-resource\sprites\jp\produce\screenshot_produce_end.png]
elif image.find(R.Produce.ButtonSkipLive, preprocessors=[WhiteFilter()]):
logger.info("Skipping live.")
device.click()
# [kotonebot-resource\sprites\jp\produce\screenshot_produce_end_skip.png]
elif image.find(R.Produce.TextSkipLiveDialogTitle):
logger.info("Confirming skip live.")
device.click(image.expect_wait(R.Common.IconButtonCheck))
it.wait()
device.click(0, 0)
# 选择封面
logger.info("Use default cover.")

View File

@ -1,10 +1,11 @@
"""领取社团奖励,并尽可能地给其他人送礼物"""
import logging
from kotonebot import task, device, image, sleep, ocr
from . import R
from .common import conf
from .actions.scenes import at_home, goto_home
from kotonebot.tasks.game_ui import toolbar_menu
from kotonebot import task, device, image, sleep, ocr
logger = logging.getLogger(__name__)
@ -23,7 +24,7 @@ def club_reward():
# 进入社团UI
logger.info('Entering club UI')
device.click(image.expect_wait(R.Daily.IconMenu, timeout=5))
device.click(toolbar_menu(True))
device.click(image.expect_wait(R.Daily.IconMenuClub, timeout=5))
sleep(3)

View File

@ -1,4 +1,3 @@
from gc import enable
import os
import json
import shutil

View File

@ -2,10 +2,9 @@
import logging
from gettext import gettext as _
from kotonebot.backend.dispatch import SimpleDispatcher
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
@ -28,9 +27,9 @@ def goto_contest() -> bool:
device.click(btn_contest)
if not has_ongoing_contest:
while not image.find(R.Daily.ButtonContestRanking):
# [screenshots/contest/acquire1.png]
# [kotonebot-resource\sprites\jp\daily\screenshot_contest_season_reward.png]
# [screenshots/contest/acquire2.png]
device.click(0, 0)
device.click(R.Daily.PointDissmissContestReward)
sleep(1)
# [screenshots/contest/main.png]
else:
@ -93,8 +92,7 @@ def pick_and_contest(has_ongoing_contest: bool = False) -> bool:
# 点击 SKIP
sleep(3)
logger.debug('Clicking on SKIP.')
# TODO: 改为二值化图片
device.click(image.expect(R.Daily.ButtonIconSkip, colored=True, transparent=True, threshold=0.999))
device.click(image.expect(R.Daily.ButtonIconSkip, preprocessors=[WhiteFilter()]))
while not image.wait_for(R.Common.ButtonNextNoIcon, timeout=2):
device.click_center()
logger.debug('Waiting for the result.')

View File

@ -1,16 +1,19 @@
from dataclasses import dataclass
from typing import Literal, NamedTuple
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
import cv2
from cv2.typing import MatLike
@action('按钮是否禁用', screenshot_mode='manual-inherit')
def button_state(*, target: Image | None = None, rect: Rect | None = None) -> bool | None:
@ -189,20 +192,51 @@ class CommuEventButtonUI:
ocr_result = ocr.raw().ocr(img, rect=rects[0])
return ocr_result.squash().text
def filter_white(img: MatLike):
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
lower_white = np.array([0, 0, 180])
upper_white = np.array([180, 50, 255])
return cv2.inRange(hsv, lower_white, upper_white)
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:
"""寻找工具栏上的首页按钮。若未找到,则抛出异常。"""
...
# TODO: image 对象加入自定义 hook处理 post-process 和 pre-process
@action('工具栏按钮.寻找首页', screenshot_mode='manual-inherit')
def toolbar_home():
img = device.screenshot()
img = filter_white(img)
result = image.raw().find(img, R.Common.ButtonToolbarHomeBinary.binary())
return result
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

View File

@ -189,7 +189,7 @@ def do_produce(
device.screenshot()
# 有进行中培育的情况
if ocr.find(contains('プロデュース'), rect=R.Produce.BoxProduceOngoing):
if ocr.find(contains(''), rect=R.Produce.BoxProduceOngoing):
logger.info('Ongoing produce found. Try to resume produce.')
resume_produce()
return True