-
Notifications
You must be signed in to change notification settings - Fork 158
MinimapTracking
在开始星穹铁道小地图识别原理之前,希望你已经阅读过 碧蓝航线海图识别原理 了,因为这一系列的复杂识别是一代一代发展而来的,了解每一代的特性才能理解为何下一代作出这些改进:
小地图识别对星穹铁道的脚本也很重要了,等同海图识别是一个碧蓝航线脚本的核心,没有这个的话只会发展成大号超大号按键精灵。
星穹铁道的小地图识别和原神的类似,它包含三部分的识别 ①识别角色位置(position) ②识别角色朝向(direction) ③识别视野朝向(rotation),需要注意的是,角色朝向与视野朝向是不同的,角色朝向是小地图上蓝色箭头的方向,而视野朝向是屏幕相机的朝向,在小地图上是浅蓝色直角三角。
小地图识别的源码由以下文件组成:
-
tasks/map/resource/const.py
,存放各种参数 -
tasks/map/resource/generate.py
,生成游戏地图和地图特征,开发环境下地图特征实时计算,生产环境下则使用预先计算好的地图特征 -
tasks/map/resource/resource.py
,载入预先计算好的地图特征 -
tasks/map/minimap/utils.py
,存放工具函数 -
tasks/map/minimap/minimap.py
,小地图识别
这是碧蓝航线大世界地图,目标是识别出屏幕中心点所对应的地图坐标,地图坐标将用于拖动地图。如果你还记得 海图识别原理就可以看出,大世界地图也是一个一点透视的网格。
调用海图识别,得到 OS_GLOBE_HOMO_STORAGE,这个参数是硬编码在 ALAS 里的,这样就不用每次都调用海图识别了。它的意思是:在 (445, 180), (879, 180), (376, 497), (963, 497)
四个点围成的多边形范围内有一个 4 x 3 的网格。
OS_GLOBE_HOMO_STORAGE = ((4, 3), ((445, 180), (879, 180), (376, 497), (963, 497)))
假定每个格子大小都是 140 x 140,可以得到透视变换前的 4 个点坐标和透视变换后的 4 个点坐标。(140 的数值是历史遗留,取别的数值也是可以的)
src_pts = ((0, 0), (560, 0), (0, 420), (560, 420))
dst_pts = ((445, 180), (879, 180), (376, 497), (963, 497))
对调 src_pts 和 dst_pts,计算出透视逆变换的参数 homo_data,并使用这个参数将屏幕截图逆变换成无透视的二维平面,这个效果与 Photoshop 里的透视裁剪工具是一样的。(实际代码中,为避免内容在逆变换后超出图片范围,动态计算了 homo_size,见 Homography.find_homography()
)
homo_data = cv2.getPerspectiveTransform(dst_pts, src_pts)
image = cv2.warpPerspective(image, homo_data, homo_size)
大世界地图的背景是动态的,并且上面会浮有许多小图标。每个海域的边缘线参差不齐,这意味着他们在不同区域都是独特的,将他们提取出来作为图片的特征。
使用 scipy.signal.find_peaks
识别海域边缘,海图识别的精髓之一就是这个找峰值函数,大世界地图识别也沿用了,它能在嘈杂的背景下找到不明显的边缘。与常规的边缘识别相比,例如 Canny 边缘识别,它有更多的可调参数,这意味着可以对特定的识别场景调参以达到更好的效果,当然了开销也更高。
现在我们有两张图片,一张是当前位置的特征图像(由游戏截图转换而来),另一张是完整地图的特征图像(预先制作好的),需要做的就是从大图中找小图,经典的模板匹配应用场景。之所以能使用模板匹配找地图位置,是因为有几条重要的游戏特性保障着,这些特性说起来好像废话一般,以至于常常被开发者忽略:
- 当游戏切换到地图界面时,游戏就会显示地图。这意味着模板匹配必定会有结果,不存在不匹配的情况。
- 游戏的内容不会重复,地图的每个区域都是独特的。这意味着模板匹配最多只有一个结果,不存在多个匹配结果。
- 基于上述两个特性,模板匹配结果中相似度最大的点,就是当前地图位置,不管它的相似度有多低。
可以看到相似度矩阵里有一个亮点,那就是当前地图位置。
了解了原理之后,识别的源码就只有几行。(这里缩小图片再进行模板匹配可以加快匹配速度)
local = self.find_peaks(self.perspective_transform(image), para=self.config.OS_LOCAL_FIND_PEAKS_PARAMETERS)
local = local.astype(np.uint8)
local = cv2.resize(local, None, fx=self.config.OS_GLOBE_IMAGE_RESIZE, fy=self.config.OS_GLOBE_IMAGE_RESIZE)
result = cv2.matchTemplate(self.globe, local, cv2.TM_CCOEFF_NORMED)
_, similarity, _, loca = cv2.minMaxLoc(result)
loca = np.array(loca) / self.config.OS_GLOBE_IMAGE_RESIZE
loca = tuple(self.homo_center + loca - self.config.OS_GLOBE_IMAGE_PAD)
原神小地图是有现成的项目 cvAutoTrack 的,但是我们仍然希望重复造轮子来解决 ①"风起地"区域识别错误 ②识精度不够导致脚本操作角色原地转圈圈 的问题。下面这张是风起地的小地图。
cvAutoTrack 是为 空荧酒馆 服务的,空荧酒馆的功能是追踪玩家位置 显示附近宝箱特产等,而 GIA 的功能是自动操作游戏。在追踪辅助中,操作是玩家产生的,是未知的不连续的,在游戏脚本中,操作都是脚本程序产生的,是已知的连续的。脚本的识别场景更固定且可控,就意味着可以进行简化来提升效率。
- 每次识别角色位置时,仅在上一次位置的附近搜索,新的角色位置成为记录为上一次的位置。
- 地图传送后,将传送点坐标作为上一次的位置。
如果你有 opencv 基础,在阅读上面的内容后内心可能会有疑问:为什么地图识别用模板匹配不用特征匹配?
原神 "风起地" 区域的小地图非常空旷,大片区域内只有一个传送点和一条河,cvAutoTrack 在当时使用的是 SURF 特征匹配,这个区域的特征点数量少质量也不高,因此在这个区域错误多。
模板匹配的优势在碧蓝大世界识别中有过描述,游戏机制保证了区域内有且只有一个最佳匹配,模板匹配的劣势是它不具有尺度不变性,也就是模板和目标图片大小不一致就无法识别。游戏脚本通常都要求在一个固定的分辨率下运行缩放是固定的,因此规避了模板匹配的劣势。
与碧蓝大世界地图识别不同,原神的角色位置识别不做边缘识别,直接把小地图的用作模板去匹配。降分辨率和转灰度这样的模板匹配常见优化肯定是全安排上了,位置识别的开销是 1.41 ms。
但是原神的地图复杂,在缺少特征提取的情况下,匹配结果并不是一片黑色背景里只有一个亮点那般理想。
比如这张图里,确实有一个亮点,同时右下角也有一片高相似度的区域,相似度比亮点本身还高,直接去最大值作为匹配点肯定是不行的。因此将相似度矩阵减去高斯模糊后的,来获得局部最大值。
local_maximum = cv2.subtract(result, cv2.GaussianBlur(result, (5, 5), 0))
_, local_sim, _, loca = cv2.minMaxLoc(local_maximum)
小地图位置识别需要提升到亚像素精度,这样才能反映微小的位移,但是模板匹配的精度就只能精确到 1 像素。
把相似度矩阵想像成一个曲面,那么最佳匹配点不是一个点,而是一个二维的峰。峰的最高点才是真正的最佳匹配点,但是最高点不一定会落在整数坐标上。从代数的角度上来说,我们需要根据目前最佳匹配点(整数精度)相邻的若干点,拟合出峰的曲面,再通过求导找出极值点,但显然这样的开销是非常高的。
opencv 的缩放里带有插值算法,可以简化这步运算,假设我们希望精度达到 0.05,那么就把相似度矩阵放大 20 倍,再找最大值。放大的时候必须使用立方差值,不能是临近或者线性,这样亚像素最大值才有可能高出现有的最大值。
def cubic_find_maximum(image, precision=0.05):
"""
Using CUBIC resize algorithm to fit a curved surface, find the maximum value and location.
Args:
image (np.ndarray):
precision (int, float):
Returns:
float: Maximum value on curved surface
np.ndarray[float, float]: Location of maximum value
"""
image = cv2.resize(image, None, fx=1 / precision, fy=1 / precision, interpolation=cv2.INTER_CUBIC)
_, sim, _, loca = cv2.minMaxLoc(image)
loca = np.array(loca, dtype=np.float64) * precision
return sim, loca
星穹铁道的地图相比原神的要更加简洁,是黑白灰+强调色,这让特征匹配更加难办了,各种的特征匹配算法说白了还是角点检测算法,在这张图里它甚至没几个角点,所以还是使用原神同款的模板匹配来识别。
也因为更简洁,我们又把边缘识别重新搬出来了,先 Canny 边缘识别再去模板匹配。所有的地图也经过边缘识别预处理再储存起来。
游戏还有另一个机制,角色运动的时候小地图会动态缩小,这个缩小不是即时的而是有惯性的。最简单的办法还是梯度缩放取最佳,耗时 6.57 ms。这个实现并不算优雅,还有能优化的空间,但是一时间想不到了。
# Walking is in scale 1.20
# Running is in scale 1.25
scale_list = [1.00, 1.05, 1.10, 1.15, 1.20, 1.25]
for scale in scale_list:
state = self._predict_position(image, scale)
if state.sim > best_sim:
best_sim = state.sim
best_scale = scale
best_state = state
best_state = self._predict_precise_position(best_state)
星穹铁道角色朝向识别和原神的几乎是一样的。
首先根据颜色提取出箭头来,我们一直在使用 color_similarity_2d
这个函数,它的算法和 PhotoShop 魔棒功能的容差是一样的,颜色差值 = abs(max(RGB差值)) + abs(min(RGB差值))
。
image = self.get_minimap(image, self.DIRECTION_RADIUS)
image = color_similarity_2d(image, color=self.DIRECTION_ARROW_COLOR)
面对这个图像,第一想法可能是找出箭头的边缘,计算角平分线,但是有海图识别的经历,只有两条线肯定是最棒的,但是背景总是复杂的,需要爱与魔法来剔除错误的线。
回归到模板匹配,用 0~360 度的箭头模板去匹配小地图的箭头,最佳匹配就是当前角度。
当你需要 N 个模板匹配 1 个图像的时候,就可以反转模板和图像的关系,把模板拼合成一张大图,用目标图像作为模板去匹配,这也是一个常见的优化方法了。把每 5 度旋转的箭头模板拼合成一张图,裁切出小地图的箭头去匹配,得到一个大致的角度,再去匹配每 1 度旋转的拼合图,开销 0.64 ms。
原神同款识别,参考幻大师的视频,就不复读一遍了:https://www.bilibili.com/video/BV1A84y1A7ku/
区别就是加了一步预处理,转换至 YUV 色彩空间提取蓝通道
image = self.get_minimap(image, radius=self.MINIMAP_RADIUS)
_, _, v = cv2.split(cv2.cvtColor(image, cv2.COLOR_RGB2YUV))
image = cv2.subtract(128, v)