写在前面
本项目已在github开源,链接https://github.com/QZero233/JavaAutoMinesweeper
本文的写作风格可能会有些奇怪,这是笔者的一次全新的尝试,后续会换回写blog的文风的
摘要
本文提出了一个全自动完成扫雷游戏的解决方案,这套方案使用了固定策略的图像分割以及求像素点RGB差值的方式来实现扫雷游戏的导入,使用一个简单的算法来实现游戏求解,使用Robot类来模拟点击,来落实最终的操作。我们使用了java语言进行实现和验证,并取得了较好的成果
引言
扫雷是一个很经典的游戏,这个游戏的规则很简单:玩家需要根据已经被扫过的区域,来判断地雷和安全区的位置,并最终排除出全部的地雷,具体游戏规则大家可以上网了解,这里不再赘述
理论上而言,扫雷游戏求解十分简单,只需要对所有的数字格子进行遍历,查看是否能确定某个地方一定是地雷,或者某个地方一定是安全的,进行标记和打开后,再重复此操作。这样的做法最后的结局有两种可能性:
- 排查出所有地雷,游戏结束
- 现有条件无法使我们再做出准确的判断,这也就是遇到了所谓的“死亡二选一”的情况。关于这种情况,本文会在后续工作部分讨论
在实践方面,这个问题的难点在于如何把扫雷游戏转化成一个矩阵,然后使用算法来求解。一般而言,程序能获取到的只有游戏的屏幕截图,挑战就在于如何识别这个截图。本文参考了一篇关于自动扫雷的文章【1】,借鉴了其中图像对比的方法,并提出了固定策略图像分割的方法,来实现游戏的导入
方法与代码实现
图像识别
总体思路是:
- 将整个扫雷游戏截图按照方格分割成一个一个的小图片
- 将每个小图片和每种方块的图片进行对比,得出这个位置的方块的类型
- 记录每个位置的方块类型,形成游戏矩阵
图片分割
这一步的目的就是把下面这张图变成
下面这样的一张张小图片
这一步的难点就在于,如何准确知道每个方块在图片上的坐标。这里我们是采取了一种比较偷懒的办法,那就是首先使用ps等软件测量最左边的一列的左边缘的x值,作为一个xOffset,同理,测量yOffset,然后再测量每个格子的大小blockSize,最终可以用公式
(
x
O
f
f
s
e
t
+
x
∗
b
l
o
c
k
S
i
z
e
,
y
O
f
f
s
e
t
+
y
∗
b
l
o
c
k
S
i
z
e
)
(xOffset + x*blockSize,yOffset+y*blockSize)
(xOffset+x∗blockSize,yOffset+y∗blockSize)计算出每个方块左上角的坐标
但由于每次缩放窗口后,这些参数都会发生变化,所以我们直接采取了把游戏窗口最大化的办法,这样可以尽可能减少变数。剩下的工作就是对这些参数进行微调,直到能获取一个让人满意的分割
在代码实现方面,我们是使用了Java的BufferedImage这个类,通过ImageIO.read来打开图片,使用ImageIO.write来保存图片,使用BufferedImage.getRGB和BufferedImage.setRGB来获取图片的每个像素点的RGB值。由于我们的目标是搭建一个原型系统,所以这种方式并不是最高效的实现方式,这也是后续优化工作的一个方向,分割部分的具体代码如下
public static void segmentImage(BufferedImage game,ImageProfile profile) throws Exception{
int blockSize=profile.getBlockSize();
for(int x=0;x<profile.getRowNum();x++){
for(int y=0;y<profile.getRowNum();y++){
BufferedImage newImage=new BufferedImage(blockSize,blockSize,BufferedImage.TYPE_INT_ARGB);
for(int i=0;i<blockSize;i++){
for(int j=0;j<blockSize;j++){
int color=game.getRGB(profile.getxOffset()+x*blockSize+i,profile.getyOffset()+y*blockSize+j);
newImage.setRGB(i,j,color);
}
}
ImageIO.write(newImage,"png",new File("segment/"+x+"-"+y+".png"));
}
}
}
(注:在实际图像识别的时候,我们并不会把图像分割之后,再保存成文件再识别,事实上我们会直接获取像素点并进行比较,这个函数是用来调整xOffset等参数时使用的)
识别图片
图片识别部分参考了【1】中的做法,在完成图片分割之后,将分割好的图片,和提前打好了标记的图片进行比较,来判断这个分割好的图片所属的类别。具体比较方法为:将两张图片的对应位置的RGB值做差,再取绝对值并求和,得到一个差值,这个差值越小,就代表分割好的图片和打好标记的图片越像。如果某个类别的图片,比如数字1的图片,和游戏图片(即一个个分割好的图片)的差值最小,那么就可以确定这个游戏图片就是数字1
在实际操作中,还是会遇到几个问题:
- 带标签的图片和游戏图片大小不一致怎么办?
- RGB值的表示实际上是用int数字的低24位来表示的,这就会使得在求差值的过程中,红色天然的就具有很大的权重,这对于类别判断的影响是很大的
- win7的扫雷界面自带渐变,这使得准确的识别变得更加困难
对于这些问题,我们的解决办法是:
- 对图片进行缩放,这是目前尝试下来效果比较好的办法。之前还尝试过类似于卷积的操作,就是故意把标记图片做小,然后把它作为卷积窗口,不停的滑动,求差值,然后求和,最后把所有的求和累计起来,但是这种办法的效果并不理想。推测可能原因是:这种方法下,会出现大量的mismatch,会导致大量的大差值覆盖掉了小差值,使得最终结果差异不大
- 这里的思路是灰度化之后再求差值,这样可以平衡一下各个颜色的权重,实验下来结果确实比之前要好
private static int getGrayAvg(int color){
int red=(color & 0xff0000) >> 16;
int green=(color & 0xff00) >> 8;
int blue=color & 0x0000ff;
return Math.round((red * 0.299f + green * 0.587f + blue * 0.114f));
}
- 这个确实没办法了,只能是不停的增加样本量,来减少误判率。或者就换个思路,使用神经网络来解决,但是可能是因为数据量不够,实际测试下来神经网络的效果并不理想
对比图片部分的代码如下
private static int getGrayAvg(int color){
int red=(color & 0xff0000) >> 16;
int green=(color & 0xff00) >> 8;
int blue=color & 0x0000ff;
return Math.round((red * 0.299f + green * 0.587f + blue * 0.114f));
}
public static double getLikelihood(BufferedImage game,BufferedImage target,int x,int y,int blockSize,int xOffset,int yOffset){
double currentSum=0;
//Only compare central parts 1/4 to 3/4
for(int k=target.getWidth()/4;k<target.getWidth()*3/4;k++){
for(int l=target.getHeight()/4;l<target.getHeight()*3/4;l++){
//Origin x: x*blockSize + k
//Origin y: y*blockSize + l
//Target x: k
//Target y: l
int srcColor=game.getRGB(x*blockSize + k + xOffset,y*blockSize + l + yOffset);
int targetColor=target.getRGB(k,l);
//Get gray average
srcColor=getGrayAvg(srcColor);
targetColor=getGrayAvg(targetColor);
int abs=Math.abs(srcColor-targetColor);
currentSum+=abs;
}
}
double averageSum=currentSum/(target.getWidth()*target.getHeight());
return averageSum;
}
后面就不断调用这个方法,对每个游戏图片,遍历所有的样本图片,来找返回值最小的那个,这样就可以最终形成游戏矩阵
游戏求解
游戏求解就略显简单了,我们事先约定:在游戏矩阵里,-1代表格子未打开,-2表示这个格子被插旗了(也就是一定有雷),0表示格子被打开了,但是没数字,其他数字就对应着游戏里格子上显示的数字
例如,下面这一个小区块
化为游戏矩阵就是
[
[-1,-1,-1],
[2,2,2],
[0,1,-2]
]
有了前面的工作后,求解就很简单了,大致思路是模仿了人类玩扫雷时的思维方式,就是根据已知信息去寻找一定可以确定是雷,或者一定可以确定不是雷的地方,然后进行标记或是打开。如果遍历了整个游戏,仍然无法确定,那就遇到了所谓的“死亡二选一”的情况,这个问题会留到后面的 后续工作 部分再来讨论
思路很简单,那直接贴代码了
public static List<Action> getSolution(int[][] game, int rowNum){
List<Action> result=new ArrayList<>();
int[][] directions={
{1,1},
{1,-1},
{-1,1},
{-1,-1},
{1,0},
{-1,0},
{0,1},
{0,-1}
};
for(int i=0;i<rowNum;i++){
for(int j=0;j<rowNum;j++){
int current=game[i][j];
if(current<=0)
continue;
int covered=0;
int flagged=0;
int uncovered=0;
for(int k=0;k<8;k++){
int nX=i+directions[k][0];
int nY=j+directions[k][1];
if(nX < 0 || nX >=rowNum || nY < 0 || nY >=rowNum)
continue;
if(game[nX][nY]==-1)
covered++;
else if(game[nX][nY]==-2)
flagged++;
else
uncovered++;
}
if(flagged==current && current!=0){
//Can open every neighbour
for(int k=0;k<8;k++){
int nX=i+directions[k][0];
int nY=j+directions[k][1];
if(nX < 0 || nX >=rowNum || nY < 0 || nY >=rowNum)
continue;
if(game[nX][nY]==-1 && !result.contains(new Action(nX,nY, Action.Type.OPEN))){
result.add(new Action(nX,nY, Action.Type.OPEN));
// System.out.println("Open "+nX+","+nY);
}
}
}else if(flagged==current-1 && flagged+covered==current){
//Can mark a bomb
for(int k=0;k<8;k++){
int nX=i+directions[k][0];
int nY=j+directions[k][1];
if(nX < 0 || nX >=rowNum || nY < 0 || nY >=rowNum)
continue;
if(game[nX][nY]==-1 && !result.contains(new Action(nX,nY, Action.Type.MARK))){
result.add(new Action(nX,nY, Action.Type.MARK));
// System.out.println("Mark "+nX+","+nY);
}
}
}
}
}
return result;
}
模拟操作
模拟操作主要是使用了Java中的Robot类,我们只需要模拟两个动作,即把鼠标指针移动到指定位置,然后左键或者右键即可
这个坐标就是目标格子在截屏中的坐标,使用之前的公式就可以轻松的算出来,在实际操作中,由于需要点击,而算出来的坐标是格子左上角的坐标,所以还需要往最终结果上加上一个偏移量,这里取得偏移量是blockSize/2,即最终坐标是
(
x
O
f
f
s
e
t
+
x
∗
b
l
o
c
k
S
i
z
e
+
b
l
o
c
k
S
i
z
e
/
2
,
y
O
f
f
s
e
t
+
y
∗
b
l
o
c
k
S
i
z
e
+
b
l
o
c
k
S
i
z
e
/
2
)
(xOffset + x*blockSize + blockSize/2,yOffset+y*blockSize+blockSize/2)
(xOffset+x∗blockSize+blockSize/2,yOffset+y∗blockSize+blockSize/2)
具体代码如下
for(Action action:actions){
Coordinate coordinate=ImageUtils.getCoordinate(profile,action.getX(),action.getY());
robot.mouseMove(coordinate.getX(),coordinate.getY());
if(action.getType()== Action.Type.OPEN){
robot.mousePress(InputEvent.BUTTON1_MASK);
robot.mouseRelease(InputEvent.BUTTON1_MASK);
}else{
robot.mousePress(InputEvent.BUTTON3_MASK);
robot.mouseRelease(InputEvent.BUTTON3_MASK);
}
Thread.sleep(100);
}
robot.mouseMove(0,0);
求解完成后,鼠标会移动到屏幕左上角来表示求解结束
其余技术细节
- 全局快捷键
为了便于控制,我们设置了一个全局快捷键,这个可以参考【2】,这里不再赘述了 - 自动截图
这个功能是参考了【3】,具体实现代码也很简单,代码如下
Dimension d = Toolkit.getDefaultToolkit().getScreenSize();
Robot robot=new Robot();
BufferedImage screenshot = robot.createScreenCapture(new
Rectangle(0, 0, (int) d.getWidth(), (int) d.getHeight()));
实验结果
由于识别准确率的问题,所以还是存在是不是点到地雷的情况,但是较少发生
测试中最大的问题还是“死亡二选一”的问题,这使得游戏无法自动求解,需要人为干预,后续会针对这个问题进行优化
目前测试下来,这个程序可以很好的完成中等难度的扫雷,在需要人为干预死亡二选一的情况下,程序能做到最快在70秒完成中等难度的扫雷游戏
下面是程序工作时的gif图以及最终的游戏结果
(下面的计时器显示,我确实没开倍速播放,如果优化一下,程序应该是能在更短时间内解决游戏的,最后gif停下来的地方就是遇到死亡二选一了)
后续工作
下面将针对遇到的问题进行一些讨论
- 识别精度问题,存在误判的情况
关于这个问题,目前有两个方向
一个是换战场,尝试win10的扫雷,文章【1】里面也提到了,win10的扫雷方块没有渐变色,识别起来应该会更简单
另一个就是疯狂扩大样本量,但是这样会增大图像对比时的时间成本,如果还要用差值法来进行图片对比,那可能需要再进行一些优化,例如尝试使用cuda来进行并行计算
当然,样本量大了之后,也可以尝试使用卷积神经网络来做分类,这也是一个可选的方向 - 随着blockSize变小,精准切割存在困难
当前的切割采取的是固定策略,其中的参数不一定是完美的,事实上,每个小块都可能会有1-2个像素的误差,当blockSize较大时,一般而言格子数量也较少,所以误差不那么明显;但如果blockSize较小,例如24x24的游戏中,blockSize测下来只有38,那么一方面,格子数量变多了,误差的累积效应增大,另一方面,各自本身较小,误差更明显,所以这种分割的结果就是很不理想
目前计划采取【1】中的办法,使用边界线等其他特征来判断格子的边界,从而进行切割 - 如何解决死亡二选一
这个暂时还没进行文献调研,还不能给出一个比较完美的方法,目前仅有一些符合直觉的思路:
其一是,瞎点,但是凭借个人经验,这样挺容易点到雷的…
其二是,找到可能的情况数量最少的地方,例如,只需要做二选一,肯定可以确认一个是雷,一个不是雷的地方,然后跑随机数。这就纯看运气了…但如果是做速通的话,这个思路或许是最高效的 - 算法仍然存在优化空间
虽然这个算法是 O ( n 2 ) O(n^2) O(n2)的,但是扫雷游戏的规模一般不大,所以求解的时间开销几乎可以忽略不计,开销的大头在图像识别和游戏矩阵的构建
在算法层面,其实是可以减少一下图像识别的次数的,例如,在标记完雷和打开完一定不是雷的格子之后,就根据程序已知的信息,再判断一下,还能不能找到可以确定是雷或者不是雷的地方,如果实在找不到了,再截图+图像识别,来获取最新的游戏状态
参考文献
【1】https://pangruitao.com/post/3058
【2】https://zhuanlan.zhihu.com/p/446086846
【3】https://cloud.tencent.com/developer/article/1669635