kotonebot 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,748 +1,778 @@
1
- import os
2
- from logging import getLogger
3
- from typing import NamedTuple, Protocol, Sequence, runtime_checkable
4
-
5
- import cv2
6
- import numpy as np
7
- from cv2.typing import MatLike, Rect as CvRect
8
- from skimage.metrics import structural_similarity
9
-
10
- from .core import Image, unify_image
11
- from .preprocessor import PreprocessorProtocol
12
- from kotonebot.primitives import Point as KbPoint, Rect as KbRect, Size as KbSize
13
- from .debug import result as debug_result, debug, img
14
-
15
- logger = getLogger(__name__)
16
-
17
- class TemplateNoMatchError(Exception):
18
- """模板未找到异常。"""
19
- def __init__(self, image: MatLike | Image, template: MatLike | str | Image):
20
- self.image = image
21
- self.template = template
22
- super().__init__(f"Template not found: {template}")
23
-
24
- @runtime_checkable
25
- class ResultProtocol(Protocol):
26
- @property
27
- def rect(self) -> KbRect:
28
- """结果区域。左上角坐标和宽高。"""
29
- ...
30
-
31
-
32
- class TemplateMatchResult(NamedTuple):
33
- score: float
34
- position: KbPoint
35
- """结果位置。左上角坐标。"""
36
- size: KbSize
37
- """输入模板的大小。宽高。"""
38
-
39
- @property
40
- def rect(self) -> KbRect:
41
- """结果区域。"""
42
- return KbRect(self.position[0], self.position[1], self.size[0], self.size[1])
43
-
44
- @property
45
- def right_bottom(self) -> KbPoint:
46
- """结果右下角坐标。"""
47
- return KbPoint(self.position[0] + self.size[0], self.position[1] + self.size[1])
48
-
49
- class MultipleTemplateMatchResult(NamedTuple):
50
- score: float
51
- position: KbPoint
52
- """结果位置。左上角坐标。"""
53
- size: KbSize
54
- """命中模板的大小。宽高。"""
55
- index: int
56
- """命中模板在列表中的索引。"""
57
-
58
- @property
59
- def rect(self) -> KbRect:
60
- """结果区域。左上角坐标和宽高。"""
61
- return KbRect(self.position[0], self.position[1], self.size[0], self.size[1])
62
-
63
- @property
64
- def right_bottom(self) -> KbPoint:
65
- """结果右下角坐标。"""
66
- return KbPoint(self.position[0] + self.size[0], self.position[1] + self.size[1])
67
-
68
- @classmethod
69
- def from_template_match_result(cls, result: TemplateMatchResult, index: int):
70
- return cls(
71
- score=result.score,
72
- position=result.position,
73
- size=result.size,
74
- index=index
75
- )
76
-
77
- class CropResult(NamedTuple):
78
- score: float
79
- position: KbPoint
80
- size: KbSize
81
- image: MatLike
82
-
83
- @property
84
- def rect(self) -> KbRect:
85
- return KbRect(self.position[0], self.position[1], self.size[0], self.size[1])
86
-
87
- def _draw_result(image: MatLike, matches: Sequence[ResultProtocol] | ResultProtocol | None) -> MatLike:
88
- """在图像上绘制匹配结果的矩形框。"""
89
- if matches is None:
90
- return image
91
- if isinstance(matches, ResultProtocol):
92
- matches = [matches]
93
- result_image = image.copy()
94
- for match in matches:
95
- cv2.rectangle(result_image, match.rect.xywh, (0, 0, 255), 2)
96
- return result_image
97
-
98
- def _img2str(image: MatLike | str | Image | None) -> str:
99
- if image is None:
100
- return 'None'
101
- if isinstance(image, str):
102
- try:
103
- return os.path.relpath(image)
104
- except ValueError:
105
- # ValueError: path is on mount 'C:', start on mount 'E:'
106
- # 程序路径与资源路径不在同一个地方的情况
107
- return image
108
- elif isinstance(image, Image):
109
- return f'<Image: {image.name} at {image.path}>'
110
- else:
111
- return '<opencv Mat>'
112
-
113
- def _imgs2str(images: Sequence[MatLike | str | Image | None] | None) -> str:
114
- if images is None:
115
- return 'None'
116
- return ', '.join([_img2str(image) for image in images])
117
-
118
- def _result2str(result: TemplateMatchResult | MultipleTemplateMatchResult | None) -> str:
119
- if result is None:
120
- return 'None'
121
- return f'{result.rect} {result.score}'
122
-
123
- def _results2str(results: Sequence[TemplateMatchResult | MultipleTemplateMatchResult] | None) -> str:
124
- if results is None:
125
- return 'None'
126
- return ', '.join([_result2str(result) for result in results])
127
-
128
- # TODO: 应该把 template_match 和 find、wait、expect 等函数的公共参数提取出来
129
- # TODO: 需要在调试结果中输出 preprocessors 处理后的图像
130
- def template_match(
131
- template: MatLike | str | Image,
132
- image: MatLike | str | Image,
133
- mask: MatLike | str | Image | None = None,
134
- *,
135
- transparent: bool = False,
136
- threshold: float = 0.8,
137
- max_results: int = 5,
138
- remove_duplicate: bool = True,
139
- colored: bool = False,
140
- preprocessors: list[PreprocessorProtocol] | None = None,
141
- ) -> list[TemplateMatchResult]:
142
- """
143
- 寻找模板在图像中的位置。
144
-
145
- .. note::
146
- `mask` 和 `transparent` 参数不能同时使用。
147
- 如果使用透明图像,所有透明像素必须为 100% 透明,不能包含半透明像素。
148
-
149
- :param template: 模板图像,可以是图像路径或 cv2.Mat。
150
- :param image: 图像,可以是图像路径或 cv2.Mat。
151
- :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
152
- :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
153
- :param threshold: 阈值,默认为 0.8。
154
- :param max_results: 最大结果数,默认为 1。
155
- :param remove_duplicate: 是否移除重复结果,默认为 True
156
- :param colored: 是否匹配颜色,默认为 False
157
- :param preprocessors: 预处理列表,默认为 None
158
- """
159
- # 统一参数
160
- template = unify_image(template, transparent)
161
- image = unify_image(image)
162
- if transparent is True and mask is not None:
163
- raise ValueError('mask and transparent cannot be used together')
164
- if mask is not None:
165
- mask = unify_image(mask)
166
- mask = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)[1]
167
- if transparent is True:
168
- # https://stackoverflow.com/questions/57899997/how-to-create-mask-from-alpha-channel-in-opencv
169
- # 从透明图像中提取 alpha 通道作为 mask
170
- mask = cv2.threshold(template[:, :, 3], 0, 255, cv2.THRESH_BINARY)[1]
171
- template = template[:, :, :3]
172
- # 预处理
173
- if preprocessors is not None:
174
- for preprocessor in preprocessors:
175
- image = preprocessor.process(image)
176
- template = preprocessor.process(template)
177
- if mask is not None:
178
- mask = preprocessor.process(mask)
179
- # 匹配模板
180
- if mask is not None:
181
- # https://stackoverflow.com/questions/35642497/python-opencv-cv2-matchtemplate-with-transparency
182
- # 使用 Mask 时,必须使用 TM_CCORR_NORMED 方法
183
- result = cv2.matchTemplate(image, template, cv2.TM_CCORR_NORMED, mask=mask)
184
- else:
185
- result = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED)
186
-
187
- # ========== 整理结果 ==========
188
- # 去重、排序、转换为 TemplateMatchResult
189
-
190
- # 获取所有大于阈值的匹配结果并按分数排序
191
- h, w = template.shape[:2]
192
- matches = []
193
- if remove_duplicate:
194
- # 创建一个掩码来标记已匹配区域
195
- used_mask = np.zeros((image.shape[0], image.shape[1]), np.uint8)
196
-
197
- # 获取所有匹配点并按分数从高到低排序
198
- match_points = np.where(result >= threshold)
199
- scores = result[match_points]
200
- sorted_indices = np.argsort(-scores) # 降序排序
201
-
202
- for idx in sorted_indices:
203
- y, x = match_points[0][idx], match_points[1][idx]
204
- score = float(scores[idx])
205
-
206
- # 去重
207
- if remove_duplicate:
208
- # 获取匹配区域的中心点
209
- center_x = x + w // 2
210
- center_y = y + h // 2
211
-
212
- # 如果中心点已被标记,跳过此匹配
213
- if used_mask[center_y, center_x] == 255:
214
- continue
215
-
216
- # 标记整个匹配区域
217
- used_mask[y:y+h, x:x+w] = 255
218
-
219
- # 颜色匹配
220
- if colored:
221
- img1, img2 = image[y:y+h, x:x+w], template
222
- if mask is not None:
223
- # 如果用了 Mask,需要裁剪出 Mask 区域,其余部分置黑
224
- img1 = cv2.bitwise_and(img1, img1, mask=mask)
225
- img2 = cv2.bitwise_and(img2, img2, mask=mask)
226
-
227
- if not hist_match(img1, img2, (0, 0, w, h)):
228
- continue
229
-
230
- matches.append(TemplateMatchResult(
231
- score=score,
232
- position=KbPoint(int(x), int(y)),
233
- size=KbSize(int(w), int(h))
234
- ))
235
-
236
- # 如果达到最大结果数,提前结束
237
- if max_results > 0 and len(matches) >= max_results:
238
- break
239
-
240
- return matches
241
-
242
- def hist_match(
243
- image: MatLike | str,
244
- template: MatLike | str,
245
- rect: CvRect | None = None,
246
- threshold: float = 0.9,
247
- ) -> bool:
248
- """
249
- 对输入图像的矩形部分与模板进行颜色直方图匹配。
250
- 将图像分为上中下三个区域,分别计算直方图并比较相似度。
251
-
252
- https://answers.opencv.org/question/59027/template-matching-using-color/
253
-
254
- :param image: 输入图像
255
- :param template: 模板图像
256
- :param rect: 输入图像中待匹配的矩形区域
257
- :param threshold: 相似度阈值,默认为 0.8
258
- :return: 是否匹配成功
259
- """
260
- # 统一参数
261
- image = unify_image(image)
262
- template = unify_image(template)
263
-
264
- # 从图像中裁剪出矩形区域
265
- if rect is None:
266
- roi = image
267
- else:
268
- x, y, w, h = rect
269
- roi = image[y:y+h, x:x+w]
270
-
271
- # 确保尺寸一致
272
- if roi.shape != template.shape:
273
- # roi = cv2.resize(roi, (template.shape[1], template.shape[0]))
274
- raise ValueError('Expected two images with the same size.')
275
-
276
- # 将图像分为上中下三个区域
277
- h = roi.shape[0]
278
- h_band = h // 3
279
- bands_roi = [
280
- roi[0:h_band],
281
- roi[h_band:2*h_band],
282
- roi[2*h_band:h]
283
- ]
284
- bands_template = [
285
- template[0:h_band],
286
- template[h_band:2*h_band],
287
- template[2*h_band:h]
288
- ]
289
-
290
- # 计算每个区域的直方图
291
- total_score = 0
292
- for roi_band, template_band in zip(bands_roi, bands_template):
293
- hist_roi = cv2.calcHist([roi_band], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
294
- hist_template = cv2.calcHist([template_band], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
295
-
296
- # 归一化直方图
297
- cv2.normalize(hist_roi, hist_roi)
298
- cv2.normalize(hist_template, hist_template)
299
-
300
- # 计算直方图相似度
301
- score = cv2.compareHist(hist_roi, hist_template, cv2.HISTCMP_CORREL)
302
- total_score += score
303
-
304
- # 计算平均相似度
305
- avg_score = total_score / 3
306
- return avg_score >= threshold
307
-
308
- def find_all_crop(
309
- image: MatLike | str | Image,
310
- template: MatLike | str | Image,
311
- mask: MatLike | str | Image | None = None,
312
- transparent: bool = False,
313
- threshold: float = 0.8,
314
- *,
315
- colored: bool = False,
316
- remove_duplicate: bool = True,
317
- preprocessors: list[PreprocessorProtocol] | None = None,
318
- ) -> list[CropResult]:
319
- """
320
- 指定一个模板,在输入图像中寻找其出现的所有位置,并裁剪出结果。
321
-
322
- :param image: 图像,可以是图像路径或 cv2.Mat。
323
- :param template: 模板图像,可以是图像路径或 cv2.Mat。
324
- :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
325
- :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
326
- :param threshold: 阈值,默认为 0.8。
327
- :param colored: 是否匹配颜色,默认为 False。
328
- :param remove_duplicate: 是否移除重复结果,默认为 True。
329
- :param preprocessors: 预处理列表,默认为 None。
330
- """
331
- matches = template_match(
332
- template,
333
- image,
334
- mask,
335
- transparent=transparent,
336
- threshold=threshold,
337
- max_results=-1,
338
- remove_duplicate=remove_duplicate,
339
- colored=colored,
340
- preprocessors=preprocessors,
341
- )
342
- # logger.debug(
343
- # f'find_all_crop(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
344
- # f'matches: {_results2str(matches)}'
345
- # )
346
- return [CropResult(
347
- match.score,
348
- match.position,
349
- match.size,
350
- image[match.rect[1]:match.rect[1]+match.rect[3], match.rect[0]:match.rect[0]+match.rect[2]] # type: ignore
351
- ) for match in matches]
352
-
353
- def find(
354
- image: MatLike,
355
- template: MatLike | str | Image,
356
- mask: MatLike | str | Image | None = None,
357
- *,
358
- transparent: bool = False,
359
- threshold: float = 0.8,
360
- debug_output: bool = True,
361
- colored: bool = False,
362
- remove_duplicate: bool = True,
363
- preprocessors: list[PreprocessorProtocol] | None = None,
364
- ) -> TemplateMatchResult | None:
365
- """
366
- 指定一个模板,在输入图像中寻找其出现的第一个位置。
367
-
368
- :param image: 图像,可以是图像路径或 cv2.Mat。
369
- :param template: 模板图像,可以是图像路径或 cv2.Mat。
370
- :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
371
- :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
372
- :param threshold: 阈值,默认为 0.8
373
- :param debug_output: 是否输出调试信息,默认为 True
374
- :param colored: 是否匹配颜色,默认为 False
375
- :param remove_duplicate: 是否移除重复结果,默认为 True
376
- :param preprocessors: 预处理列表,默认为 None
377
- """
378
- matches = template_match(
379
- template,
380
- image,
381
- mask,
382
- transparent=transparent,
383
- threshold=threshold,
384
- max_results=1,
385
- remove_duplicate=remove_duplicate,
386
- colored=colored,
387
- preprocessors=preprocessors,
388
- )
389
- # logger.debug(
390
- # f'find(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
391
- # f'matches: {_results2str(matches)}'
392
- # )
393
- # 调试输出
394
- if debug.enabled and debug_output:
395
- result_image = _draw_result(image, matches)
396
- result_text = f"template: {img(template)} \n"
397
- result_text += f"matches: {len(matches)} \n"
398
- for match in matches:
399
- result_text += f"score: {match.score} position: {match.position} size: {match.size} \n"
400
- debug_result(
401
- 'image.find',
402
- [result_image, image],
403
- result_text
404
- )
405
- return matches[0] if len(matches) > 0 else None
406
-
407
- def find_all(
408
- image: MatLike,
409
- template: MatLike | str | Image,
410
- mask: MatLike | str | Image | None = None,
411
- *,
412
- transparent: bool = False,
413
- threshold: float = 0.8,
414
- remove_duplicate: bool = True,
415
- colored: bool = False,
416
- debug_output: bool = True,
417
- preprocessors: list[PreprocessorProtocol] | None = None,
418
- ) -> list[TemplateMatchResult]:
419
- """
420
- 指定一个模板,在输入图像中寻找其出现的所有位置。
421
-
422
- :param image: 图像,可以是图像路径或 cv2.Mat。
423
- :param template: 模板图像,可以是图像路径或 cv2.Mat。
424
- :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
425
- :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
426
- :param threshold: 阈值,默认为 0.8。
427
- :param remove_duplicate: 是否移除重复结果,默认为 True。
428
- :param colored: 是否匹配颜色,默认为 False
429
- :param preprocessors: 预处理列表,默认为 None。
430
- """
431
- results = template_match(
432
- template,
433
- image,
434
- mask,
435
- transparent=transparent,
436
- threshold=threshold,
437
- max_results=-1,
438
- remove_duplicate=remove_duplicate,
439
- colored=colored,
440
- preprocessors=preprocessors,
441
- )
442
- # logger.debug(
443
- # f'find_all(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
444
- # f'matches: {_results2str(results)}'
445
- # )
446
- if debug.enabled and debug_output:
447
- result_image = _draw_result(image, results)
448
- debug_result(
449
- 'image.find_all',
450
- [result_image, image],
451
- f"template: {img(template)} \n"
452
- f"matches: {len(results)} \n"
453
- )
454
- return results
455
-
456
- def find_multi(
457
- image: MatLike,
458
- templates: Sequence[MatLike | str | Image],
459
- masks: Sequence[MatLike | str | Image | None] | None = None,
460
- *,
461
- transparent: bool = False,
462
- threshold: float = 0.8,
463
- colored: bool = False,
464
- remove_duplicate: bool = True,
465
- preprocessors: list[PreprocessorProtocol] | None = None,
466
- ) -> MultipleTemplateMatchResult | None:
467
- """
468
- 指定多个模板,在输入图像中逐个寻找模板,返回第一个匹配到的结果。
469
-
470
- :param image: 图像,可以是图像路径或 cv2.Mat。
471
- :param templates: 模板图像列表,可以是图像路径或 cv2.Mat。
472
- :param masks: 掩码图像列表,可以是图像路径或 cv2.Mat。
473
- :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
474
- :param threshold: 阈值,默认为 0.8。
475
- :param colored: 是否匹配颜色,默认为 False。
476
- :param remove_duplicate: 是否移除重复结果,默认为 True。
477
- :param preprocessors: 预处理列表,默认为 None
478
- """
479
- ret = None
480
- if masks is None:
481
- _masks = [None] * len(templates)
482
- else:
483
- _masks = masks
484
- for index, (template, mask) in enumerate(zip(templates, _masks)):
485
- find_result = find(
486
- image,
487
- template,
488
- mask,
489
- transparent=transparent,
490
- threshold=threshold,
491
- colored=colored,
492
- debug_output=False,
493
- remove_duplicate=remove_duplicate,
494
- preprocessors=preprocessors,
495
- )
496
- # 调试输出
497
- if find_result is not None:
498
- ret = MultipleTemplateMatchResult(
499
- score=find_result.score,
500
- position=find_result.position,
501
- size=find_result.size,
502
- index=index
503
- )
504
- break
505
- # logger.debug(
506
- # f'find_multi(): templates: {_imgs2str(templates)} images: {_img2str(image)} masks: {_imgs2str(masks)} '
507
- # f'result: {_result2str(ret)}'
508
- # )
509
- if debug.enabled:
510
- msg = (
511
- "<table class='result-table'>" +
512
- "<tr><th>Template</th><th>Mask</th><th>Result</th></tr>" +
513
- "\n".join([
514
- f"<tr><td>{img(t)}</td><td>{img(m)}</td><td>{'✓' if ret and t == templates[ret.index] else '✗'}</td></tr>"
515
- for i, (t, m) in enumerate(zip(templates, _masks))
516
- ]) +
517
- "</table>\n"
518
- )
519
- debug_result(
520
- 'image.find_multi',
521
- [_draw_result(image, ret), image],
522
- msg
523
- )
524
- return ret
525
-
526
- def find_all_multi(
527
- image: MatLike,
528
- templates: list[MatLike | str | Image],
529
- masks: list[MatLike | str | Image | None] | None = None,
530
- *,
531
- transparent: bool = False,
532
- threshold: float = 0.8,
533
- colored: bool = False,
534
- remove_duplicate: bool = True,
535
- preprocessors: list[PreprocessorProtocol] | None = None,
536
- ) -> list[MultipleTemplateMatchResult]:
537
- """
538
- 指定多个模板,在输入图像中逐个寻找模板,返回所有匹配到的结果。
539
-
540
- 此函数等价于
541
- ```python
542
- result = []
543
- for template in templates:
544
- result.append(find_all(template, ...))
545
- ```
546
-
547
- :param image: 图像,可以是图像路径或 cv2.Mat。
548
- :param templates: 模板图像列表,可以是图像路径或 cv2.Mat。
549
- :param masks: 掩码图像列表,可以是图像路径或 cv2.Mat。
550
- :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
551
- :param threshold: 阈值,默认为 0.8。
552
- :param colored: 是否匹配颜色,默认为 False。
553
- :param remove_duplicate: 是否移除重复结果,默认为 True。
554
- :param preprocessors: 预处理列表,默认为 None。
555
- :return: 匹配到的一维结果列表。
556
- """
557
- ret: list[MultipleTemplateMatchResult] = []
558
- if masks is None:
559
- _masks = [None] * len(templates)
560
- else:
561
- _masks = masks
562
-
563
- for index, (template, mask) in enumerate(zip(templates, _masks)):
564
- results = find_all(
565
- image,
566
- template,
567
- mask,
568
- transparent=transparent,
569
- threshold=threshold,
570
- colored=colored,
571
- remove_duplicate=remove_duplicate,
572
- debug_output=False,
573
- preprocessors=preprocessors,
574
- )
575
- ret.extend([
576
- MultipleTemplateMatchResult.from_template_match_result(r, index)
577
- for r in results
578
- ])
579
- # logger.debug(
580
- # f'find_all_multi(): templates: {_imgs2str(templates)} images: {_img2str(image)} masks: {_imgs2str(masks)} '
581
- # f'result: {_results2str(ret)}'
582
- # )
583
- if debug.enabled:
584
- # 参数表格
585
- msg = (
586
- "<center>Templates</center>"
587
- "<table class='result-table'>"
588
- "<tr><th>Template</th><th>Mask</th></tr>"
589
- )
590
- for t, m in zip(templates, _masks):
591
- msg += f"<tr><td>{img(t)}</td><td>{img(m)}</td></tr>"
592
- msg += "</table>"
593
- msg += "<br>"
594
- # 结果表格
595
- msg += (
596
- "<center>Results</center>"
597
- "<table class='result-table'>"
598
- "<tr><th>Template</th><th>Mask</th><th>Result</th></tr>"
599
- )
600
- for result in ret:
601
- template = templates[result.index]
602
- mask = _masks[result.index]
603
- msg += f"<tr><td>{img(template)}</td><td>{img(mask)}</td><td>{result.position}</td></tr>"
604
- msg += "</table>"
605
- debug_result(
606
- 'image.find_all_multi',
607
- [_draw_result(image, ret), image],
608
- msg
609
- )
610
- return ret
611
-
612
- def count(
613
- image: MatLike,
614
- template: MatLike | str | Image,
615
- mask: MatLike | str | Image | None = None,
616
- *,
617
- transparent: bool = False,
618
- threshold: float = 0.8,
619
- remove_duplicate: bool = True,
620
- colored: bool = False,
621
- preprocessors: list[PreprocessorProtocol] | None = None,
622
- ) -> int:
623
- """
624
- 指定一个模板,统计其出现的次数。
625
-
626
- :param image: 图像,可以是图像路径或 cv2.Mat。
627
- :param template: 模板图像,可以是图像路径或 cv2.Mat。
628
- :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
629
- :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
630
- :param threshold: 阈值,默认为 0.8。
631
- :param remove_duplicate: 是否移除重复结果,默认为 True。
632
- :param colored: 是否匹配颜色,默认为 False。
633
- :param preprocessors: 预处理列表,默认为 None。
634
- """
635
- results = template_match(
636
- template,
637
- image,
638
- mask,
639
- transparent=transparent,
640
- threshold=threshold,
641
- max_results=-1,
642
- remove_duplicate=remove_duplicate,
643
- colored=colored,
644
- preprocessors=preprocessors,
645
- )
646
- # logger.debug(
647
- # f'count(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
648
- # f'result: {_results2str(results)}'
649
- # )
650
- if debug.enabled:
651
- result_image = _draw_result(image, results)
652
- debug_result(
653
- 'image.count',
654
- [result_image, image],
655
- (
656
- f"template: {img(template)} \n"
657
- f"mask: {img(mask)} \n"
658
- f"transparent: {transparent} \n"
659
- f"threshold: {threshold} \n"
660
- f"count: {len(results)} \n"
661
- )
662
- )
663
- return len(results)
664
-
665
- def expect(
666
- image: MatLike,
667
- template: MatLike | str | Image,
668
- mask: MatLike | str | Image | None = None,
669
- *,
670
- transparent: bool = False,
671
- threshold: float = 0.8,
672
- colored: bool = False,
673
- remove_duplicate: bool = True,
674
- preprocessors: list[PreprocessorProtocol] | None = None,
675
- ) -> TemplateMatchResult:
676
- """
677
- 指定一个模板,寻找其出现的第一个位置。若未找到,则抛出异常。
678
-
679
- :param image: 图像,可以是图像路径或 cv2.Mat。
680
- :param template: 模板图像,可以是图像路径或 cv2.Mat。
681
- :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
682
- :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
683
- :param threshold: 阈值,默认为 0.8。
684
- :param colored: 是否匹配颜色,默认为 False。
685
- :param remove_duplicate: 是否移除重复结果,默认为 True。
686
- :param preprocessors: 预处理列表,默认为 None。
687
- """
688
- ret = find(
689
- image,
690
- template,
691
- mask,
692
- transparent=transparent,
693
- threshold=threshold,
694
- colored=colored,
695
- remove_duplicate=remove_duplicate,
696
- debug_output=False,
697
- preprocessors=preprocessors,
698
- )
699
- # logger.debug(
700
- # f'expect(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
701
- # f'result: {_result2str(ret)}'
702
- # )
703
- if debug.enabled:
704
- debug_result(
705
- 'image.expect',
706
- [_draw_result(image, ret), image],
707
- (
708
- f"template: {img(template)} \n"
709
- f"mask: {img(mask)} \n"
710
- f"args: transparent={transparent} threshold={threshold} \n"
711
- f"result: {ret} "
712
- '<span class="text-success">SUCCESS</span>' if ret is not None
713
- else '<span class="text-danger">FAILED</span>'
714
- )
715
- )
716
- if ret is None:
717
- raise TemplateNoMatchError(image, template)
718
- else:
719
- return ret
720
-
721
- def similar(
722
- image1: MatLike,
723
- image2: MatLike,
724
- threshold: float = 0.9
725
- ) -> bool:
726
- """
727
- 判断两张图像是否相似(灰度)。输入的两张图片必须为相同尺寸。
728
- """
729
- if image1.shape != image2.shape:
730
- raise ValueError('Expected two images with the same size.')
731
- image1 = cv2.cvtColor(image1, cv2.COLOR_BGR2GRAY)
732
- image2 = cv2.cvtColor(image2, cv2.COLOR_BGR2GRAY)
733
- result = structural_similarity(image1, image2, multichannel=True)
734
- # logger.debug(
735
- # f'similar(): image1: {_img2str(image1)} image2: {_img2str(image2)} '
736
- # f'result: {result}'
737
- # )
738
- # 调试输出
739
- if debug.enabled:
740
- result_image = np.hstack([image1, image2])
741
- debug_result(
742
- 'image.similar',
743
- [result_image, image1, image2],
744
- f"result: {result} >= {threshold} == {result >= threshold} \n"
745
- )
746
- return result >= threshold
747
-
748
-
1
+ import os
2
+ from logging import getLogger
3
+ from typing import NamedTuple, Protocol, Sequence, runtime_checkable
4
+
5
+ import cv2
6
+ import numpy as np
7
+ from cv2.typing import MatLike, Rect as CvRect
8
+ from skimage.metrics import structural_similarity
9
+
10
+ from .core import Image, unify_image
11
+ from .preprocessor import PreprocessorProtocol
12
+ from kotonebot.primitives import Point as KbPoint, Rect as KbRect, Size as KbSize
13
+ from .debug import result as debug_result, debug, img
14
+
15
+ logger = getLogger(__name__)
16
+
17
+ class TemplateNoMatchError(Exception):
18
+ """模板未找到异常。"""
19
+ def __init__(self, image: MatLike | Image, template: MatLike | str | Image):
20
+ self.image = image
21
+ self.template = template
22
+ super().__init__(f"Template not found: {template}")
23
+
24
+ @runtime_checkable
25
+ class ResultProtocol(Protocol):
26
+ @property
27
+ def rect(self) -> KbRect:
28
+ """结果区域。左上角坐标和宽高。"""
29
+ ...
30
+
31
+
32
+ class TemplateMatchResult(NamedTuple):
33
+ score: float
34
+ position: KbPoint
35
+ """结果位置。左上角坐标。"""
36
+ size: KbSize
37
+ """输入模板的大小。宽高。"""
38
+
39
+ @property
40
+ def rect(self) -> KbRect:
41
+ """结果区域。"""
42
+ return KbRect(self.position[0], self.position[1], self.size[0], self.size[1])
43
+
44
+ @property
45
+ def right_bottom(self) -> KbPoint:
46
+ """结果右下角坐标。"""
47
+ return KbPoint(self.position[0] + self.size[0], self.position[1] + self.size[1])
48
+
49
+ class MultipleTemplateMatchResult(NamedTuple):
50
+ score: float
51
+ position: KbPoint
52
+ """结果位置。左上角坐标。"""
53
+ size: KbSize
54
+ """命中模板的大小。宽高。"""
55
+ index: int
56
+ """命中模板在列表中的索引。"""
57
+
58
+ @property
59
+ def rect(self) -> KbRect:
60
+ """结果区域。左上角坐标和宽高。"""
61
+ return KbRect(self.position[0], self.position[1], self.size[0], self.size[1])
62
+
63
+ @property
64
+ def right_bottom(self) -> KbPoint:
65
+ """结果右下角坐标。"""
66
+ return KbPoint(self.position[0] + self.size[0], self.position[1] + self.size[1])
67
+
68
+ @classmethod
69
+ def from_template_match_result(cls, result: TemplateMatchResult, index: int):
70
+ return cls(
71
+ score=result.score,
72
+ position=result.position,
73
+ size=result.size,
74
+ index=index
75
+ )
76
+
77
+ class CropResult(NamedTuple):
78
+ score: float
79
+ position: KbPoint
80
+ size: KbSize
81
+ image: MatLike
82
+
83
+ @property
84
+ def rect(self) -> KbRect:
85
+ return KbRect(self.position[0], self.position[1], self.size[0], self.size[1])
86
+
87
+ def _draw_result(image: MatLike, matches: Sequence[ResultProtocol] | ResultProtocol | None) -> MatLike:
88
+ """在图像上绘制匹配结果的矩形框。"""
89
+ if matches is None:
90
+ return image
91
+ if isinstance(matches, ResultProtocol):
92
+ matches = [matches]
93
+ result_image = image.copy()
94
+ for match in matches:
95
+ cv2.rectangle(result_image, match.rect.xywh, (0, 0, 255), 2)
96
+ return result_image
97
+
98
+ def _img2str(image: MatLike | str | Image | None) -> str:
99
+ if image is None:
100
+ return 'None'
101
+ if isinstance(image, str):
102
+ try:
103
+ return os.path.relpath(image)
104
+ except ValueError:
105
+ # ValueError: path is on mount 'C:', start on mount 'E:'
106
+ # 程序路径与资源路径不在同一个地方的情况
107
+ return image
108
+ elif isinstance(image, Image):
109
+ return f'<Image: {image.name} at {image.path}>'
110
+ else:
111
+ return '<opencv Mat>'
112
+
113
+ def _imgs2str(images: Sequence[MatLike | str | Image | None] | None) -> str:
114
+ if images is None:
115
+ return 'None'
116
+ return ', '.join([_img2str(image) for image in images])
117
+
118
+ def _result2str(result: TemplateMatchResult | MultipleTemplateMatchResult | None) -> str:
119
+ if result is None:
120
+ return 'None'
121
+ return f'{result.rect} {result.score}'
122
+
123
+ def _results2str(results: Sequence[TemplateMatchResult | MultipleTemplateMatchResult] | None) -> str:
124
+ if results is None:
125
+ return 'None'
126
+ return ', '.join([_result2str(result) for result in results])
127
+
128
+ # TODO: 应该把 template_match 和 find、wait、expect 等函数的公共参数提取出来
129
+ # TODO: 需要在调试结果中输出 preprocessors 处理后的图像
130
+ def template_match(
131
+ template: MatLike | str | Image,
132
+ image: MatLike | str | Image,
133
+ mask: MatLike | str | Image | None = None,
134
+ *,
135
+ rect: KbRect | None = None,
136
+ transparent: bool = False,
137
+ threshold: float = 0.8,
138
+ max_results: int = 5,
139
+ remove_duplicate: bool = True,
140
+ colored: bool = False,
141
+ preprocessors: list[PreprocessorProtocol] | None = None,
142
+ ) -> list[TemplateMatchResult]:
143
+ """
144
+ 寻找模板在图像中的位置。
145
+
146
+ .. note::
147
+ `mask` `transparent` 参数不能同时使用。
148
+ 如果使用透明图像,所有透明像素必须为 100% 透明,不能包含半透明像素。
149
+
150
+ :param template: 模板图像,可以是图像路径或 cv2.Mat。
151
+ :param image: 图像,可以是图像路径或 cv2.Mat。
152
+ :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
153
+ :param rect: 如果指定,则只在指定矩形区域内进行匹配。
154
+ :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
155
+ :param threshold: 阈值,默认为 0.8
156
+ :param max_results: 最大结果数,默认为 1
157
+ :param remove_duplicate: 是否移除重复结果,默认为 True
158
+ :param colored: 是否匹配颜色,默认为 False。
159
+ :param preprocessors: 预处理列表,默认为 None。
160
+ """
161
+ # 统一参数
162
+ template = unify_image(template, transparent)
163
+ image = unify_image(image)
164
+
165
+ # 处理矩形区域
166
+ original_image = image
167
+ if rect is not None:
168
+ x, y, w, h = rect.xywh
169
+ image = image[y:y+h, x:x+w]
170
+
171
+ if transparent is True and mask is not None:
172
+ raise ValueError('mask and transparent cannot be used together')
173
+ if mask is not None:
174
+ mask = unify_image(mask)
175
+ mask = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)[1]
176
+ if transparent is True:
177
+ # https://stackoverflow.com/questions/57899997/how-to-create-mask-from-alpha-channel-in-opencv
178
+ # 从透明图像中提取 alpha 通道作为 mask
179
+ mask = cv2.threshold(template[:, :, 3], 0, 255, cv2.THRESH_BINARY)[1]
180
+ template = template[:, :, :3]
181
+ # 预处理
182
+ if preprocessors is not None:
183
+ for preprocessor in preprocessors:
184
+ image = preprocessor.process(image)
185
+ template = preprocessor.process(template)
186
+ if mask is not None:
187
+ mask = preprocessor.process(mask)
188
+ # 匹配模板
189
+ if mask is not None:
190
+ # https://stackoverflow.com/questions/35642497/python-opencv-cv2-matchtemplate-with-transparency
191
+ # 使用 Mask 时,必须使用 TM_CCORR_NORMED 方法
192
+ result = cv2.matchTemplate(image, template, cv2.TM_CCORR_NORMED, mask=mask)
193
+ else:
194
+ result = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED)
195
+
196
+ # ========== 整理结果 ==========
197
+ # 去重、排序、转换为 TemplateMatchResult
198
+
199
+ # 获取所有大于阈值的匹配结果并按分数排序
200
+ h, w = template.shape[:2]
201
+ matches = []
202
+ if remove_duplicate:
203
+ # 创建一个掩码来标记已匹配区域
204
+ used_mask = np.zeros((image.shape[0], image.shape[1]), np.uint8)
205
+
206
+ # 获取所有匹配点并按分数从高到低排序
207
+ match_points = np.where(result >= threshold)
208
+ scores = result[match_points]
209
+ sorted_indices = np.argsort(-scores) # 降序排序
210
+
211
+ for idx in sorted_indices:
212
+ y, x = match_points[0][idx], match_points[1][idx]
213
+ score = float(scores[idx])
214
+
215
+ # 去重
216
+ if remove_duplicate:
217
+ # 获取匹配区域的中心点
218
+ center_x = x + w // 2
219
+ center_y = y + h // 2
220
+
221
+ # 如果中心点已被标记,跳过此匹配
222
+ if used_mask[center_y, center_x] == 255:
223
+ continue
224
+
225
+ # 标记整个匹配区域
226
+ used_mask[y:y+h, x:x+w] = 255
227
+
228
+ # 颜色匹配
229
+ if colored:
230
+ img1, img2 = image[y:y+h, x:x+w], template
231
+ if mask is not None:
232
+ # 如果用了 Mask,需要裁剪出 Mask 区域,其余部分置黑
233
+ img1 = cv2.bitwise_and(img1, img1, mask=mask)
234
+ img2 = cv2.bitwise_and(img2, img2, mask=mask)
235
+
236
+ if not hist_match(img1, img2, (0, 0, w, h)):
237
+ continue
238
+
239
+ matches.append(TemplateMatchResult(
240
+ score=score,
241
+ position=KbPoint(int(x) + (rect.x1 if rect else 0), int(y) + (rect.y1 if rect else 0)),
242
+ size=KbSize(int(w), int(h))
243
+ ))
244
+
245
+ # 如果达到最大结果数,提前结束
246
+ if max_results > 0 and len(matches) >= max_results:
247
+ break
248
+
249
+ return matches
250
+
251
+ def hist_match(
252
+ image: MatLike | str,
253
+ template: MatLike | str,
254
+ rect: CvRect | None = None,
255
+ threshold: float = 0.9,
256
+ ) -> bool:
257
+ """
258
+ 对输入图像的矩形部分与模板进行颜色直方图匹配。
259
+ 将图像分为上中下三个区域,分别计算直方图并比较相似度。
260
+
261
+ https://answers.opencv.org/question/59027/template-matching-using-color/
262
+
263
+ :param image: 输入图像
264
+ :param template: 模板图像
265
+ :param rect: 输入图像中待匹配的矩形区域
266
+ :param threshold: 相似度阈值,默认为 0.8
267
+ :return: 是否匹配成功
268
+ """
269
+ # 统一参数
270
+ image = unify_image(image)
271
+ template = unify_image(template)
272
+
273
+ # 从图像中裁剪出矩形区域
274
+ if rect is None:
275
+ roi = image
276
+ else:
277
+ x, y, w, h = rect
278
+ roi = image[y:y+h, x:x+w]
279
+
280
+ # 确保尺寸一致
281
+ if roi.shape != template.shape:
282
+ # roi = cv2.resize(roi, (template.shape[1], template.shape[0]))
283
+ raise ValueError('Expected two images with the same size.')
284
+
285
+ # 将图像分为上中下三个区域
286
+ h = roi.shape[0]
287
+ h_band = h // 3
288
+ bands_roi = [
289
+ roi[0:h_band],
290
+ roi[h_band:2*h_band],
291
+ roi[2*h_band:h]
292
+ ]
293
+ bands_template = [
294
+ template[0:h_band],
295
+ template[h_band:2*h_band],
296
+ template[2*h_band:h]
297
+ ]
298
+
299
+ # 计算每个区域的直方图
300
+ total_score = 0
301
+ for roi_band, template_band in zip(bands_roi, bands_template):
302
+ hist_roi = cv2.calcHist([roi_band], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
303
+ hist_template = cv2.calcHist([template_band], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
304
+
305
+ # 归一化直方图
306
+ cv2.normalize(hist_roi, hist_roi)
307
+ cv2.normalize(hist_template, hist_template)
308
+
309
+ # 计算直方图相似度
310
+ score = cv2.compareHist(hist_roi, hist_template, cv2.HISTCMP_CORREL)
311
+ total_score += score
312
+
313
+ # 计算平均相似度
314
+ avg_score = total_score / 3
315
+ return avg_score >= threshold
316
+
317
+ def find_all_crop(
318
+ image: MatLike | str | Image,
319
+ template: MatLike | str | Image,
320
+ mask: MatLike | str | Image | None = None,
321
+ transparent: bool = False,
322
+ threshold: float = 0.8,
323
+ *,
324
+ rect: KbRect | None = None,
325
+ colored: bool = False,
326
+ remove_duplicate: bool = True,
327
+ preprocessors: list[PreprocessorProtocol] | None = None,
328
+ ) -> list[CropResult]:
329
+ """
330
+ 指定一个模板,在输入图像中寻找其出现的所有位置,并裁剪出结果。
331
+
332
+ :param image: 图像,可以是图像路径或 cv2.Mat。
333
+ :param template: 模板图像,可以是图像路径或 cv2.Mat。
334
+ :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
335
+ :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
336
+ :param threshold: 阈值,默认为 0.8。
337
+ :param rect: 如果指定,则只在指定矩形区域内进行匹配。
338
+ :param colored: 是否匹配颜色,默认为 False。
339
+ :param remove_duplicate: 是否移除重复结果,默认为 True。
340
+ :param preprocessors: 预处理列表,默认为 None。
341
+ """
342
+ matches = template_match(
343
+ template,
344
+ image,
345
+ mask,
346
+ rect=rect,
347
+ transparent=transparent,
348
+ threshold=threshold,
349
+ max_results=-1,
350
+ remove_duplicate=remove_duplicate,
351
+ colored=colored,
352
+ preprocessors=preprocessors,
353
+ )
354
+ # logger.debug(
355
+ # f'find_all_crop(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
356
+ # f'matches: {_results2str(matches)}'
357
+ # )
358
+ return [CropResult(
359
+ match.score,
360
+ match.position,
361
+ match.size,
362
+ image[match.rect[1]:match.rect[1]+match.rect[3], match.rect[0]:match.rect[0]+match.rect[2]] # type: ignore
363
+ ) for match in matches]
364
+
365
+ def find(
366
+ image: MatLike,
367
+ template: MatLike | str | Image,
368
+ mask: MatLike | str | Image | None = None,
369
+ *,
370
+ rect: KbRect | None = None,
371
+ transparent: bool = False,
372
+ threshold: float = 0.8,
373
+ debug_output: bool = True,
374
+ colored: bool = False,
375
+ remove_duplicate: bool = True,
376
+ preprocessors: list[PreprocessorProtocol] | None = None,
377
+ ) -> TemplateMatchResult | None:
378
+ """
379
+ 指定一个模板,在输入图像中寻找其出现的第一个位置。
380
+
381
+ :param image: 图像,可以是图像路径或 cv2.Mat。
382
+ :param template: 模板图像,可以是图像路径或 cv2.Mat。
383
+ :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
384
+ :param rect: 如果指定,则只在指定矩形区域内进行匹配。
385
+ :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
386
+ :param threshold: 阈值,默认为 0.8。
387
+ :param debug_output: 是否输出调试信息,默认为 True。
388
+ :param colored: 是否匹配颜色,默认为 False。
389
+ :param remove_duplicate: 是否移除重复结果,默认为 True。
390
+ :param preprocessors: 预处理列表,默认为 None。
391
+ """
392
+ matches = template_match(
393
+ template,
394
+ image,
395
+ mask,
396
+ rect=rect,
397
+ transparent=transparent,
398
+ threshold=threshold,
399
+ max_results=1,
400
+ remove_duplicate=remove_duplicate,
401
+ colored=colored,
402
+ preprocessors=preprocessors,
403
+ )
404
+ # logger.debug(
405
+ # f'find(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
406
+ # f'matches: {_results2str(matches)}'
407
+ # )
408
+ # 调试输出
409
+ if debug.enabled and debug_output:
410
+ result_image = _draw_result(image, matches)
411
+ result_text = f"template: {img(template)} \n"
412
+ result_text += f"matches: {len(matches)} \n"
413
+ for match in matches:
414
+ result_text += f"score: {match.score} position: {match.position} size: {match.size} \n"
415
+ debug_result(
416
+ 'image.find',
417
+ [result_image, image],
418
+ result_text
419
+ )
420
+ return matches[0] if len(matches) > 0 else None
421
+
422
+ def find_all(
423
+ image: MatLike,
424
+ template: MatLike | str | Image,
425
+ mask: MatLike | str | Image | None = None,
426
+ *,
427
+ rect: KbRect | None = None,
428
+ transparent: bool = False,
429
+ threshold: float = 0.8,
430
+ remove_duplicate: bool = True,
431
+ colored: bool = False,
432
+ debug_output: bool = True,
433
+ preprocessors: list[PreprocessorProtocol] | None = None,
434
+ ) -> list[TemplateMatchResult]:
435
+ """
436
+ 指定一个模板,在输入图像中寻找其出现的所有位置。
437
+
438
+ :param image: 图像,可以是图像路径或 cv2.Mat。
439
+ :param template: 模板图像,可以是图像路径或 cv2.Mat。
440
+ :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
441
+ :param rect: 如果指定,则只在指定矩形区域内进行匹配。
442
+ :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
443
+ :param threshold: 阈值,默认为 0.8。
444
+ :param remove_duplicate: 是否移除重复结果,默认为 True。
445
+ :param colored: 是否匹配颜色,默认为 False。
446
+ :param preprocessors: 预处理列表,默认为 None。
447
+ """
448
+ results = template_match(
449
+ template,
450
+ image,
451
+ mask,
452
+ rect=rect,
453
+ transparent=transparent,
454
+ threshold=threshold,
455
+ max_results=-1,
456
+ remove_duplicate=remove_duplicate,
457
+ colored=colored,
458
+ preprocessors=preprocessors,
459
+ )
460
+ # logger.debug(
461
+ # f'find_all(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
462
+ # f'matches: {_results2str(results)}'
463
+ # )
464
+ if debug.enabled and debug_output:
465
+ result_image = _draw_result(image, results)
466
+ debug_result(
467
+ 'image.find_all',
468
+ [result_image, image],
469
+ f"template: {img(template)} \n"
470
+ f"matches: {len(results)} \n"
471
+ )
472
+ return results
473
+
474
+ def find_multi(
475
+ image: MatLike,
476
+ templates: Sequence[MatLike | str | Image],
477
+ masks: Sequence[MatLike | str | Image | None] | None = None,
478
+ *,
479
+ rect: KbRect | None = None,
480
+ transparent: bool = False,
481
+ threshold: float = 0.8,
482
+ colored: bool = False,
483
+ remove_duplicate: bool = True,
484
+ preprocessors: list[PreprocessorProtocol] | None = None,
485
+ ) -> MultipleTemplateMatchResult | None:
486
+ """
487
+ 指定多个模板,在输入图像中逐个寻找模板,返回第一个匹配到的结果。
488
+
489
+ :param image: 图像,可以是图像路径或 cv2.Mat。
490
+ :param templates: 模板图像列表,可以是图像路径或 cv2.Mat。
491
+ :param masks: 掩码图像列表,可以是图像路径或 cv2.Mat。
492
+ :param rect: 如果指定,则只在指定矩形区域内进行匹配。
493
+ :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
494
+ :param threshold: 阈值,默认为 0.8。
495
+ :param colored: 是否匹配颜色,默认为 False。
496
+ :param remove_duplicate: 是否移除重复结果,默认为 True。
497
+ :param preprocessors: 预处理列表,默认为 None
498
+ """
499
+ ret = None
500
+ if masks is None:
501
+ _masks = [None] * len(templates)
502
+ else:
503
+ _masks = masks
504
+ for index, (template, mask) in enumerate(zip(templates, _masks)):
505
+ find_result = find(
506
+ image,
507
+ template,
508
+ mask,
509
+ rect=rect,
510
+ transparent=transparent,
511
+ threshold=threshold,
512
+ colored=colored,
513
+ debug_output=False,
514
+ remove_duplicate=remove_duplicate,
515
+ preprocessors=preprocessors,
516
+ )
517
+ # 调试输出
518
+ if find_result is not None:
519
+ ret = MultipleTemplateMatchResult(
520
+ score=find_result.score,
521
+ position=find_result.position,
522
+ size=find_result.size,
523
+ index=index
524
+ )
525
+ break
526
+ # logger.debug(
527
+ # f'find_multi(): templates: {_imgs2str(templates)} images: {_img2str(image)} masks: {_imgs2str(masks)} '
528
+ # f'result: {_result2str(ret)}'
529
+ # )
530
+ if debug.enabled:
531
+ msg = (
532
+ "<table class='result-table'>" +
533
+ "<tr><th>Template</th><th>Mask</th><th>Result</th></tr>" +
534
+ "\n".join([
535
+ f"<tr><td>{img(t)}</td><td>{img(m)}</td><td>{'✓' if ret and t == templates[ret.index] else '✗'}</td></tr>"
536
+ for i, (t, m) in enumerate(zip(templates, _masks))
537
+ ]) +
538
+ "</table>\n"
539
+ )
540
+ debug_result(
541
+ 'image.find_multi',
542
+ [_draw_result(image, ret), image],
543
+ msg
544
+ )
545
+ return ret
546
+
547
+ def find_all_multi(
548
+ image: MatLike,
549
+ templates: list[MatLike | str | Image],
550
+ masks: list[MatLike | str | Image | None] | None = None,
551
+ *,
552
+ rect: KbRect | None = None,
553
+ transparent: bool = False,
554
+ threshold: float = 0.8,
555
+ colored: bool = False,
556
+ remove_duplicate: bool = True,
557
+ preprocessors: list[PreprocessorProtocol] | None = None,
558
+ ) -> list[MultipleTemplateMatchResult]:
559
+ """
560
+ 指定多个模板,在输入图像中逐个寻找模板,返回所有匹配到的结果。
561
+
562
+ 此函数等价于
563
+ ```python
564
+ result = []
565
+ for template in templates:
566
+ result.append(find_all(template, ...))
567
+ ```
568
+
569
+ :param image: 图像,可以是图像路径或 cv2.Mat。
570
+ :param templates: 模板图像列表,可以是图像路径或 cv2.Mat。
571
+ :param masks: 掩码图像列表,可以是图像路径或 cv2.Mat。
572
+ :param rect: 如果指定,则只在指定矩形区域内进行匹配。
573
+ :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
574
+ :param threshold: 阈值,默认为 0.8。
575
+ :param colored: 是否匹配颜色,默认为 False。
576
+ :param remove_duplicate: 是否移除重复结果,默认为 True。
577
+ :param preprocessors: 预处理列表,默认为 None。
578
+ :return: 匹配到的一维结果列表。
579
+ """
580
+ ret: list[MultipleTemplateMatchResult] = []
581
+ if masks is None:
582
+ _masks = [None] * len(templates)
583
+ else:
584
+ _masks = masks
585
+
586
+ for index, (template, mask) in enumerate(zip(templates, _masks)):
587
+ results = find_all(
588
+ image,
589
+ template,
590
+ mask,
591
+ rect=rect,
592
+ transparent=transparent,
593
+ threshold=threshold,
594
+ colored=colored,
595
+ remove_duplicate=remove_duplicate,
596
+ debug_output=False,
597
+ preprocessors=preprocessors,
598
+ )
599
+ ret.extend([
600
+ MultipleTemplateMatchResult.from_template_match_result(r, index)
601
+ for r in results
602
+ ])
603
+ # logger.debug(
604
+ # f'find_all_multi(): templates: {_imgs2str(templates)} images: {_img2str(image)} masks: {_imgs2str(masks)} '
605
+ # f'result: {_results2str(ret)}'
606
+ # )
607
+ if debug.enabled:
608
+ # 参数表格
609
+ msg = (
610
+ "<center>Templates</center>"
611
+ "<table class='result-table'>"
612
+ "<tr><th>Template</th><th>Mask</th></tr>"
613
+ )
614
+ for t, m in zip(templates, _masks):
615
+ msg += f"<tr><td>{img(t)}</td><td>{img(m)}</td></tr>"
616
+ msg += "</table>"
617
+ msg += "<br>"
618
+ # 结果表格
619
+ msg += (
620
+ "<center>Results</center>"
621
+ "<table class='result-table'>"
622
+ "<tr><th>Template</th><th>Mask</th><th>Result</th></tr>"
623
+ )
624
+ for result in ret:
625
+ template = templates[result.index]
626
+ mask = _masks[result.index]
627
+ msg += f"<tr><td>{img(template)}</td><td>{img(mask)}</td><td>{result.position}</td></tr>"
628
+ msg += "</table>"
629
+ debug_result(
630
+ 'image.find_all_multi',
631
+ [_draw_result(image, ret), image],
632
+ msg
633
+ )
634
+ return ret
635
+
636
+ def count(
637
+ image: MatLike,
638
+ template: MatLike | str | Image,
639
+ mask: MatLike | str | Image | None = None,
640
+ *,
641
+ rect: KbRect | None = None,
642
+ transparent: bool = False,
643
+ threshold: float = 0.8,
644
+ remove_duplicate: bool = True,
645
+ colored: bool = False,
646
+ preprocessors: list[PreprocessorProtocol] | None = None,
647
+ ) -> int:
648
+ """
649
+ 指定一个模板,统计其出现的次数。
650
+
651
+ :param image: 图像,可以是图像路径或 cv2.Mat。
652
+ :param template: 模板图像,可以是图像路径或 cv2.Mat。
653
+ :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
654
+ :param rect: 如果指定,则只在指定矩形区域内进行匹配。
655
+ :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
656
+ :param threshold: 阈值,默认为 0.8。
657
+ :param remove_duplicate: 是否移除重复结果,默认为 True。
658
+ :param colored: 是否匹配颜色,默认为 False。
659
+ :param preprocessors: 预处理列表,默认为 None。
660
+ """
661
+ results = template_match(
662
+ template,
663
+ image,
664
+ mask,
665
+ rect=rect,
666
+ transparent=transparent,
667
+ threshold=threshold,
668
+ max_results=-1,
669
+ remove_duplicate=remove_duplicate,
670
+ colored=colored,
671
+ preprocessors=preprocessors,
672
+ )
673
+ # logger.debug(
674
+ # f'count(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
675
+ # f'result: {_results2str(results)}'
676
+ # )
677
+ if debug.enabled:
678
+ result_image = _draw_result(image, results)
679
+ debug_result(
680
+ 'image.count',
681
+ [result_image, image],
682
+ (
683
+ f"template: {img(template)} \n"
684
+ f"mask: {img(mask)} \n"
685
+ f"transparent: {transparent} \n"
686
+ f"threshold: {threshold} \n"
687
+ f"count: {len(results)} \n"
688
+ )
689
+ )
690
+ return len(results)
691
+
692
+ def expect(
693
+ image: MatLike,
694
+ template: MatLike | str | Image,
695
+ mask: MatLike | str | Image | None = None,
696
+ *,
697
+ rect: KbRect | None = None,
698
+ transparent: bool = False,
699
+ threshold: float = 0.8,
700
+ colored: bool = False,
701
+ remove_duplicate: bool = True,
702
+ preprocessors: list[PreprocessorProtocol] | None = None,
703
+ ) -> TemplateMatchResult:
704
+ """
705
+ 指定一个模板,寻找其出现的第一个位置。若未找到,则抛出异常。
706
+
707
+ :param image: 图像,可以是图像路径或 cv2.Mat。
708
+ :param template: 模板图像,可以是图像路径或 cv2.Mat。
709
+ :param mask: 掩码图像,可以是图像路径或 cv2.Mat。
710
+ :param rect: 如果指定,则只在指定矩形区域内进行匹配。
711
+ :param transparent: 若为 True,则认为输入模板是透明的,并自动将透明模板转换为 Mask 图像。
712
+ :param threshold: 阈值,默认为 0.8。
713
+ :param colored: 是否匹配颜色,默认为 False。
714
+ :param remove_duplicate: 是否移除重复结果,默认为 True。
715
+ :param preprocessors: 预处理列表,默认为 None。
716
+ """
717
+ ret = find(
718
+ image,
719
+ template,
720
+ mask,
721
+ rect=rect,
722
+ transparent=transparent,
723
+ threshold=threshold,
724
+ colored=colored,
725
+ remove_duplicate=remove_duplicate,
726
+ debug_output=False,
727
+ preprocessors=preprocessors,
728
+ )
729
+ # logger.debug(
730
+ # f'expect(): template: {_img2str(template)} image: {_img2str(image)} mask: {_img2str(mask)} '
731
+ # f'result: {_result2str(ret)}'
732
+ # )
733
+ if debug.enabled:
734
+ debug_result(
735
+ 'image.expect',
736
+ [_draw_result(image, ret), image],
737
+ (
738
+ f"template: {img(template)} \n"
739
+ f"mask: {img(mask)} \n"
740
+ f"args: transparent={transparent} threshold={threshold} \n"
741
+ f"result: {ret} "
742
+ '<span class="text-success">SUCCESS</span>' if ret is not None
743
+ else '<span class="text-danger">FAILED</span>'
744
+ )
745
+ )
746
+ if ret is None:
747
+ raise TemplateNoMatchError(image, template)
748
+ else:
749
+ return ret
750
+
751
+ def similar(
752
+ image1: MatLike,
753
+ image2: MatLike,
754
+ threshold: float = 0.9
755
+ ) -> bool:
756
+ """
757
+ 判断两张图像是否相似(灰度)。输入的两张图片必须为相同尺寸。
758
+ """
759
+ if image1.shape != image2.shape:
760
+ raise ValueError('Expected two images with the same size.')
761
+ image1 = cv2.cvtColor(image1, cv2.COLOR_BGR2GRAY)
762
+ image2 = cv2.cvtColor(image2, cv2.COLOR_BGR2GRAY)
763
+ result = structural_similarity(image1, image2, multichannel=True)
764
+ # logger.debug(
765
+ # f'similar(): image1: {_img2str(image1)} image2: {_img2str(image2)} '
766
+ # f'result: {result}'
767
+ # )
768
+ # 调试输出
769
+ if debug.enabled:
770
+ result_image = np.hstack([image1, image2])
771
+ debug_result(
772
+ 'image.similar',
773
+ [result_image, image1, image2],
774
+ f"result: {result} >= {threshold} == {result >= threshold} \n"
775
+ )
776
+ return result >= threshold
777
+
778
+