{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# 扫雷机器人\n", "\n", "项目原作者 [ArtrixTech](https://github.com/ArtrixTech), 项目地址 [BoomMine](https://github.com/ArtrixTech/BoomMine), 原文档发表在知乎 [@Artrix](https://zhuanlan.zhihu.com/p/35755039), 助教进行了文档的改进,和代码形式上的交互式重构。\n", "\n", "这个笔记本主要是为了让大家了解一个小型的 Python + OpenCV 项目是如何完成的,以便于以后进行更复杂的软件工程项目。\n", "\n", "通过对比 notebook 和 GitHub 上面的项目,大家也可以知道如何从交互式编程练习过渡到实际的 Python 脚本开发。\n", "\n", "## 热身环节\n", "\n", "一直对着命令行编程也累了,我们下载扫雷游戏 [Minesweeper Arbiter](http://saolei.net/Download/Arbiter_0.52.3.zip) 来玩一玩吧。\n", "\n", "如果你从前没有玩过扫雷,可以看看国内最大的扫雷爱好者论坛[扫雷网](http://saolei.net/Main/Index.asp)的基础入门教程:\n", "\n", "- [扫雷新手上路](http://saolei.net/BBS/Title.asp?Id=177) 作者: 门世运\n", "- [扫雷术语介绍](http://saolei.net/BBS/Title.asp?Id=227) 作者: 张砷镓\n", "- [扫雷定式及其变化](http://saolei.net/BBS/Title.asp?Id=362) 作者: 张砷镓\n", "- [关于数字1-7周围8格中雷的分布的各种形状](http://saolei.net/BBS/Title.asp?Id=8243) 作者: 杨萧杨\n", "- [扫雷游戏的起源](http://saolei.net/BBS/Title.asp?Id=1005) 作者: 张砷镓\n", "\n", "简单玩几局后,就可以考虑如何设计一个扫雷机器人,来帮助我们完成这个游戏啦~\n", "\n", "## 实现思路 \n", "\n", "在去做一件事情之前最重要的是什么?是将要做的这件事情在心中搭建一个步骤框架。只有这样,才能保证在去做这件事的过程中,尽可能的做到深思熟虑,使得最终有个好的结果。我们写程序也要尽可能做到在正式开始开发之前,在心中有个大致的思路。\n", "\n", "对于本项目而言,大致的开发过程是这样的:\n", "\n", "- 完成窗体内容截取部分\n", "- 完成雷块分割部分\n", "- 完成雷块类型识别部分\n", "- 完成扫雷算法\n", "\n", "好啦,既然我们有了个思路,那就撸起袖子大力干!\n", "\n", "### 窗体截取\n", "\n", "其实对于本项目而言,窗体截取是一个逻辑上简单,实现起来却相当麻烦的部分,而且还是必不可少的部分。笔者通过 [Spy++](https://docs.microsoft.com/en-us/visualstudio/debugger/how-to-start-spy-increment) 得到了以下两点信息:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "class_name = \"TMain\" # ms_arbiter.exe的主窗体类别\n", "title_name = \"Minesweeper Arbiter\" # ms_arbiter.exe的主窗体名称" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "注意到了么?主窗体的名称后面有个空格。正是这个空格让笔者困扰了一会儿,只有加上这个空格,win32gui 才能够正常的获取到窗体的句柄。(提示:在助教的机器上不加空格反而有效)\n", "\n", "运行 `ms_arbiter.exe`, 本项目采用了 win32gui 来获取窗体的位置信息,具体代码如下:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import win32gui\n", "\n", "class_name = \"TMain\" # ms_arbiter.exe的主窗体类别\n", "title_name = \"Minesweeper Arbiter\" # ms_arbiter.exe的主窗体名称\n", "\n", "hwnd = win32gui.FindWindow(class_name, title_name) # Handle to A Window\n", "if hwnd:\n", " left, top, right, bottom = win32gui.GetWindowRect(hwnd)\n", " print(\"Window find.\")\n", " print(\"left: \" + str(left))\n", " print(\"top: \" + str(top))\n", " print(\"right: \" + str(right))\n", " print(\"bottom: \" + str(bottom))\n", "else:\n", " print(\"Window not find.\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "你可以移动窗体的位置,重复运行上面的代码,看得到的位置数据是否有变化。\n", "\n", "通过以上代码,我们得到了窗体相对于整块屏幕的位置。\n", "\n", "之后我们需要通过 PIL 来进行扫雷界面的棋盘截取:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from PIL import ImageGrab\n", "\n", "# 微调参数,得到棋盘的相对位置,仅在我的电脑环境下测试通过\n", "# 这一步如果需要重复运行,需要把上面的代码也一起重复运行 (参数更新)\n", "left += 15\n", "top += 111\n", "right -= 15\n", "bottom -= 31\n", "\n", "# 棋盘截取\n", "rect = (left, top, right, bottom)\n", "img = ImageGrab.grab().crop(rect) # 扫雷界面不能被遮挡\n", "img.show() # 通过快照的位置调整上面的四个参数,实际调用中没有这一行\n", "\n", "# 得到棋盘的宽度和高度\n", "width = right - left\n", "height = bottom - top" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "其实有着更好的寻找扫雷棋盘相对位置的方法,但是这里希望大家进行人为参数寻找。\n", "\n", "这种无法由算法给出,需要人类给定的参数叫作“超参数”,这一概念后面还会见到。\n", "\n", "### 雷块分割\n", "\n", "在进行雷块分割之前,我们事先需要了解雷块的尺寸以及它的边框大小。经过笔者的测量,在 `ms_arbiter` 下,每一个雷块的尺寸为 16px*16px." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "block_width, block_height = 16, 16\n", "blocks_x = int((right - left) / block_width) # 宽度上块的个数,高级为 30\n", "blocks_y = int((bottom - top) / block_height) # 高度上块的个数,高级为 15\n", "print(str(blocks_x) + \",\" + str(blocks_y))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "之后,我们建立一个二维数组用于存储每一个雷块的图像,并且进行图像分割,保存在之前建立的数组中。" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def crop_block(hole_img, x, y):\n", " # 基于棋盘进行单个块的截取\n", " x1, y1 = x * block_width, y * block_height\n", " x2, y2 = x1 + block_width, y1 + block_height\n", " return hole_img.crop((x1, y1, x2, y2))\n", "\n", "blocks_img = [[0 for i in range(blocks_y)] for i in range(blocks_x)]\n", " \n", "for y in range(blocks_y):\n", " for x in range(blocks_x):\n", " blocks_img[x][y] = crop_block(img, x, y)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "将整个图像获取、分割的部分封装成一个库,随时调用就OK啦~在笔者的实现中,笔者将这一部分封装成了`imageProcess.py`, 其中函数 `get_frame()` 用于完成上述的图像获取、分割过程:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import win32gui\n", "import numpy\n", "from PIL import ImageGrab\n", "import cv2\n", "\n", "block_width, block_height = 16, 16\n", "\n", "def crop_block(hole_img, x, y):\n", " x1, y1 = x * block_width, y * block_height\n", " x2, y2 = x1 + block_width, y1 + block_height\n", " return hole_img.crop((x1, y1, x2, y2))\n", "\n", "def pil_to_cv(img):\n", " return cv2.cvtColor(numpy.asarray(img), cv2.COLOR_RGB2BGR)\n", "\n", "def get_frame():\n", " class_name = \"TMain\"\n", " title_name = \"Minesweeper Arbiter\"\n", "\n", " hwnd = win32gui.FindWindow(class_name, title_name)\n", " if hwnd:\n", " left, top, right, bottom = win32gui.GetWindowRect(hwnd)\n", "\n", " left += 15\n", " top += 111\n", " right -= 15\n", " bottom -= 31\n", "\n", " width = right - left\n", " height = bottom - top\n", "\n", " rect = (left, top, right, bottom)\n", " img = ImageGrab.grab().crop(rect)\n", "\n", " blocks_x = int((right - left) / block_width)\n", " blocks_y = int((bottom - top) / block_height)\n", "\n", " blocks_img = [[0 for i in range(blocks_y)] for i in range(blocks_x)]\n", "\n", " for y in range(blocks_y):\n", " for x in range(blocks_x):\n", " blocks_img[x][y] = crop_block(img, x, y)\n", "\n", " return img, blocks_img, (blocks_x, blocks_y), (width, height), (left, top, right, bottom)\n", "\n", " return -1" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "img = get_frame() # 单元测试该方法\n", "# img[0].show() # 显示整个棋盘\n", "img[1][0][0].show() # 显示指定的 [1][x][y] 块,可人为点击改变块的状态 " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "同样地,我们这里采用了笨笨的分割方法,如果学习过特征提取,我们能够使用更好的方法进行图像分割。在本节练习中,我们将利用图像颜色特征的知识对雷块的属性进行识别。\n", "\n", "我们现在来设计一个类(Class),下面是已经写好的源码,你能不能试着直接读懂它们:\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "class BoomMine:\n", " \n", " __inited = False\n", " blocks_x, blocks_y = -1, -1\n", " width, height = -1, -1\n", " img_cv, img = -1, -1\n", " blocks_img = [[-1 for i in range(blocks_y)] for i in range(blocks_x)]\n", " blocks_num = [[-3 for i in range(blocks_y)] for i in range(blocks_x)]\n", " blocks_is_mine = [[0 for i in range(blocks_y)] for i in range(blocks_x)]\n", "\n", " is_new_start = True\n", "\n", " next_steps = []\n", "\n", " @staticmethod # Python 内置函数 staticmethod() \n", " def rgb_to_bgr(rgb):\n", " return rgb[2], rgb[1], rgb[0]\n", "\n", " @staticmethod # 该方法不强制要求传递参数,无需实例化\n", " def equal(arr1, arr2):\n", " if arr1[0] == arr2[0] and arr1[1] == arr2[1] and arr1[2] == arr2[2]:\n", " return True\n", " return False\n", "\n", " def is_in_form(self, location):\n", " x, y = location[0], location[1]\n", " if x < self.left or x > self.right or y < self.top or y > self.bottom:\n", " return False\n", " return True\n", "\n", " def iterate_blocks_image(self, func):\n", " if self.__inited:\n", " for y in range(self.blocks_y):\n", " for x in range(self.blocks_x):\n", " # args are: self, [0]singleBlockImage, [1]location(as an array)\n", " func(self, self.blocks_img[x][y], (x, y))\n", "\n", " def iterate_blocks_number(self, func):\n", " if self.__inited:\n", " for y in range(self.blocks_y):\n", " for x in range(self.blocks_x):\n", " # args are: self, [0]singleBlockNumber, [1]location(as an array)\n", " func(self, self.blocks_num[x][y], (x, y))\n", "\n", " def analyze_block(self, block, location):\n", " x, y = location[0], location[1]\n", " now_num = self.blocks_num[x][y]\n", "\n", " # if 1:\n", " if not now_num == -2 and not 0 < now_num < 9:\n", "\n", " block = imageProcess.pil_to_cv(block)\n", " block_color = block[8, 8]\n", "\n", " # -1:Not opened\n", " # -2:Opened but blank\n", " # -3:Un initialized\n", "\n", " # Opened\n", " if self.equal(block_color, self.rgb_to_bgr((192, 192, 192))):\n", " if not self.equal(block[8, 1], self.rgb_to_bgr((255, 255, 255))):\n", " self.blocks_num[x][y] = -2\n", " self.is_started = True\n", " else:\n", " self.blocks_num[x][y] = -1\n", "\n", " elif self.equal(block_color, self.rgb_to_bgr((0, 0, 255))):\n", " self.blocks_num[x][y] = 1\n", "\n", " elif self.equal(block_color, self.rgb_to_bgr((0, 128, 0))):\n", " self.blocks_num[x][y] = 2\n", "\n", " elif self.equal(block_color, self.rgb_to_bgr((255, 0, 0))):\n", " self.blocks_num[x][y] = 3\n", "\n", " elif self.equal(block_color, self.rgb_to_bgr((0, 0, 128))):\n", " self.blocks_num[x][y] = 4\n", "\n", " elif self.equal(block_color, self.rgb_to_bgr((128, 0, 0))):\n", " self.blocks_num[x][y] = 5\n", "\n", " elif self.equal(block_color, self.rgb_to_bgr((0, 128, 128))):\n", " self.blocks_num[x][y] = 6\n", "\n", " elif self.equal(block_color, self.rgb_to_bgr((0, 0, 0))):\n", " if self.equal(block[6, 6], self.rgb_to_bgr((255, 255, 255))):\n", " # Is mine\n", " self.blocks_num[x][y] = 9\n", " elif self.equal(block[5, 8], self.rgb_to_bgr((255, 0, 0))):\n", " # Is flag\n", " self.blocks_num[x][y] = 0\n", " else:\n", " self.blocks_num[x][y] = 7\n", "\n", " elif self.equal(block_color, self.rgb_to_bgr((128, 128, 128))):\n", " self.blocks_num[x][y] = 8\n", " else:\n", " self.blocks_num[x][y] = -3\n", " self.is_mine_form = False\n", "\n", " if self.blocks_num[x][y] == -3 or not self.blocks_num[x][y] == -1:\n", " self.is_new_start = False\n", "\n", " def detect_mine(self, block, location):\n", "\n", " def generate_kernel(k, k_width, k_height, block_location):\n", " ls = []\n", " loc_x, loc_y = block_location[0], block_location[1]\n", " for now_y in range(k_height):\n", " for now_x in range(k_width):\n", "\n", " if k[now_y][now_x]:\n", " rel_x, rel_y = now_x - 1, now_y - 1\n", " ls.append((loc_y + rel_y, loc_x + rel_x))\n", " return ls\n", "\n", " def count_unopen_blocks(blocks):\n", " count = 0\n", " for single_block in blocks:\n", " if self.blocks_num[single_block[1]][single_block[0]] == -1:\n", " count += 1\n", " return count\n", "\n", " def mark_as_mine(blocks):\n", " for single_block in blocks:\n", " if self.blocks_num[single_block[1]][single_block[0]] == -1:\n", " self.blocks_is_mine[single_block[1]][single_block[0]] = 1\n", "\n", " x, y = location[0], location[1]\n", "\n", " if self.blocks_num[x][y] > 0:\n", "\n", " kernel_width, kernel_height = 3, 3\n", "\n", " # Kernel mode:[Row][Col]\n", " kernel = [[1, 1, 1], [1, 1, 1], [1, 1, 1]]\n", "\n", " # Left border\n", " if x == 0:\n", " for i in range(kernel_height):\n", " kernel[i][0] = 0\n", "\n", " # Right border\n", " if x == self.blocks_x - 1:\n", " for i in range(kernel_height):\n", " kernel[i][kernel_width - 1] = 0\n", "\n", " # Top border\n", " if y == 0:\n", " for i in range(kernel_width):\n", " kernel[0][i] = 0\n", "\n", " # Bottom border\n", " if y == self.blocks_y - 1:\n", " for i in range(kernel_width):\n", " kernel[kernel_height - 1][i] = 0\n", "\n", " # Generate the search map\n", " to_visit = generate_kernel(kernel, kernel_width, kernel_height, location)\n", "\n", " unopen_blocks = count_unopen_blocks(to_visit)\n", " if unopen_blocks == self.blocks_num[x][y]:\n", " mark_as_mine(to_visit)\n", "\n", " def detect_to_click_block(self, block, location):\n", "\n", " def generate_kernel(k, k_width, k_height, block_location):\n", " ls = []\n", " loc_x, loc_y = block_location[0], block_location[1]\n", " for now_y in range(k_height):\n", " for now_x in range(k_width):\n", "\n", " if k[now_y][now_x]:\n", " rel_x, rel_y = now_x - 1, now_y - 1\n", " ls.append((loc_y + rel_y, loc_x + rel_x))\n", " return ls\n", "\n", " def count_mines(blocks):\n", " count = 0\n", " for single_block in blocks:\n", " if self.blocks_is_mine[single_block[1]][single_block[0]] == 1:\n", " count += 1\n", " return count\n", "\n", " def mark_to_click_block(blocks):\n", " for single_block in blocks:\n", "\n", " # Not Mine\n", " if not self.blocks_is_mine[single_block[1]][single_block[0]] == 1:\n", "\n", " # Click-able\n", " if self.blocks_num[single_block[1]][single_block[0]] == -1:\n", "\n", " # Source Syntax: [y][x] - Converted\n", " if not (single_block[1], single_block[0]) in self.next_steps:\n", " self.next_steps.append((single_block[1], single_block[0]))\n", "\n", " x, y = location[0], location[1]\n", "\n", " if block > 0:\n", "\n", " kernel_width, kernel_height = 3, 3\n", "\n", " # Kernel mode:[Row][Col]\n", " kernel = [[1, 1, 1], [1, 1, 1], [1, 1, 1]]\n", "\n", " # Left border\n", " if x == 0:\n", " for i in range(kernel_height):\n", " kernel[i][0] = 0\n", "\n", " # Right border\n", " if x == self.blocks_x - 1:\n", " for i in range(kernel_height):\n", " kernel[i][kernel_width - 1] = 0\n", "\n", " # Top border\n", " if y == 0:\n", " for i in range(kernel_width):\n", " kernel[0][i] = 0\n", "\n", " # Bottom border\n", " if y == self.blocks_y - 1:\n", " for i in range(kernel_width):\n", " kernel[kernel_height - 1][i] = 0\n", "\n", " # Generate the search map\n", " to_visit = generate_kernel(kernel, kernel_width, kernel_height, location)\n", "\n", " mines_count = count_mines(to_visit)\n", "\n", " if mines_count == block:\n", " mark_to_click_block(to_visit)\n", "\n", " def rel_loc_to_real(self, block_rel_location):\n", " return self.left + 16 * block_rel_location[0] + 8, self.top + 16 * block_rel_location[1] + 8\n", "\n", " def __init__(self):\n", " self.next_steps = []\n", " self.left = 0\n", " self.top = 0\n", " self.right = 0\n", " self.bottom = 0\n", " self.continue_random_click = False\n", " self.is_mine_form = True\n", " self.is_started = False\n", " self.have_solve = False\n", " if self.process_once():\n", " self.__inited = True\n", "\n", " def show_map(self):\n", " if self.__inited:\n", " for y in range(self.blocks_y):\n", " line = \"\"\n", " for x in range(self.blocks_x):\n", " if self.blocks_num[x][y] == -1:\n", " line += \" \"\n", " else:\n", " line += str(self.blocks_num[x][y]) + \" \"\n", " print(line)\n", "\n", " def show_mine(self):\n", " if self.__inited:\n", " for y in range(self.blocks_y):\n", " line = \"\"\n", " for x in range(self.blocks_x):\n", " if self.blocks_is_mine[x][y] == 0:\n", " line += \" \"\n", " else:\n", " line += str(self.blocks_is_mine[x][y]) + \" \"\n", " print(line)\n", "\n", " def process_once(self):\n", "\n", " # Initialize\n", " result = imageProcess.get_frame()\n", " if result == -1:\n", " print(\"Minesweeper Arbiter Window Not Detected!\")\n", " return False\n", " self.img, self.blocks_img, form_size, img_size, form_location = result\n", "\n", " self.blocks_num = [[-1 for i in range(self.blocks_y)] for i in range(self.blocks_x)]\n", " self.blocks_is_mine = [[0 for i in range(self.blocks_y)] for i in range(self.blocks_x)]\n", " self.next_steps = []\n", " self.is_new_start = True\n", " self.is_mine_form = True\n", "\n", " self.blocks_x, self.blocks_y = form_size[0], form_size[1]\n", " self.width, self.height = img_size[0], img_size[1]\n", " self.img_cv = imageProcess.pil_to_cv(self.img)\n", " self.left, self.top, self.right, self.bottom = form_location\n", "\n", " # Analyze the number of blocks\n", " self.iterate_blocks_image(BoomMine.analyze_block)\n", "\n", " # Mark all mines\n", " self.iterate_blocks_number(BoomMine.detect_mine)\n", "\n", " # Calculate where to click\n", " self.iterate_blocks_number(BoomMine.detect_to_click_block)\n", "\n", " self.have_solve = False\n", " if len(self.next_steps) > 0:\n", " self.have_solve = True\n", "\n", " if self.is_in_form(mouseOperation.get_mouse_point()):\n", "\n", " for to_click in self.next_steps:\n", " on_screen_location = self.rel_loc_to_real(to_click)\n", " mouseOperation.mouse_move(on_screen_location[0], on_screen_location[1])\n", " mouseOperation.mouse_click()\n", "\n", " # If your computer's performance is not high, enable this:\n", " # time.sleep(0.001)\n", "\n", " if not self.have_solve and self.is_mine_form:\n", "\n", " rand_location = (random.randint(0, self.blocks_x - 1), random.randint(0, self.blocks_y - 1))\n", " rand_x, rand_y = rand_location[0], rand_location[1]\n", " iter_times = 0\n", "\n", " if len(self.blocks_is_mine) > 0:\n", "\n", " while self.blocks_is_mine[rand_x][rand_y] or not self.blocks_num[rand_x][rand_y] == -1 and iter_times < 50:\n", " rand_location = (random.randint(0, self.blocks_x - 1), random.randint(0, self.blocks_y - 1))\n", " rand_x, rand_y = rand_location[0], rand_location[1]\n", " iter_times += 1\n", "\n", " screen_location = self.rel_loc_to_real((rand_location[0], rand_location[1]))\n", " if self.is_in_form(mouseOperation.get_mouse_point()):\n", " mouseOperation.mouse_move(screen_location[0], screen_location[1])\n", " mouseOperation.mouse_click()\n", " else:\n", " self.is_mine_form = False\n", "\n", " cv2.imshow(\"Sweeper Screenshot\", self.img_cv)\n", "\n", " if cv2.waitKey(1) & 0xFF == ord('q'):\n", " return False\n", " return True" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "我们先不进行单元测试,先集成测试看一下整体效果:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import random\n", "import cv2\n", "import time\n", "import mouseOperation # 鼠标操作,无需知道细节\n", "import imageProcess # 这个文件我们之前实现了所有功能" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "miner = BoomMine()\n", "\n", "while 1:\n", " try:\n", " miner.process_once()\n", " except:\n", " pass\n", "# miner.show_map()\n", "# print(miner.next_steps)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "这个项目在一些机器上可能存在着 Bug, 试一下你能不能理解代码,找到出错的原因。\n", "\n", "### 扫雷算法实现\n", "\n", "简单起见,这里使用了一种比较暴力的扫雷逻辑:\n", "\n", "1. 遍历每一个已经有数字的雷块,判断在它周围的九宫格内未被打开的雷块数量是否和本身数字相同,如果相同则表明周围九宫格内全部都是地雷,进行标记;\n", "2. 再次遍历每一个有数字的雷块,取九宫格范围内所有未被打开的雷块,去除已经被上一次遍历标记为地雷的雷块,记录并且点开;\n", "3. 如果以上方式无法继续进行,那么说明遇到了死局,选择在当前所有未打开的雷块中随机点击,这意味着容易出现失败\n", "\n", "在实现它之后,你可以使用动态规划或者搜索的方式,改进这个扫雷算法,根据你的期望重构这个项目,发布到你的 GitHub 上面。" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.7.1" } }, "nbformat": 4, "nbformat_minor": 2 }