{ "cells": [ { "cell_type": "markdown", "id": "cffd59b9", "metadata": {}, "source": [ "# People Counter Sample Application\n", "\n", "This notebook shows how to create an object detection app for Panorama using a pretrained MXNet model.\n", "\n", "By completing this notebook, you will learn:\n", "* How to write a Python script for your app that takes in camera streams, performs inference, and outputs results\n", "* How to test your code using the Test Utility inside this Jupyter notebook, which saves you deployment time\n", "* How to use an MXNet object detection model with your app\n", "* How to programmatically package and deploy applications using the Panorama CLI\n", "* How to use an abstract camera node and over ride the camera programatically" ] }, { "cell_type": "markdown", "id": "47498764", "metadata": {}, "source": [ "--- \n", "\n", "1. [Prerequisites](#Prerequisites)\n", "1. [Set up](#Set-up)\n", "1. [Import model](#Import-model)\n", "1. [Write and test app code](#Write-and-test-app-code-in-notebook)\n", "1. [Package app](#Package-app)\n", "1. [Deploy app to device](#Deploy-app-to-device)" ] }, { "cell_type": "markdown", "id": "8a2a3ae2", "metadata": {}, "source": [ "# Prerequisites" ] }, { "cell_type": "markdown", "id": "9aaddd01", "metadata": {}, "source": [ "1. In a terminal session on this Jupyter notebook server, run `aws configure`. This allows this notebook server to access Panorama resources and deploy applications on your behalf." ] }, { "cell_type": "markdown", "id": "b1c2f6f8", "metadata": {}, "source": [ "# Set up" ] }, { "cell_type": "markdown", "id": "352b4749", "metadata": {}, "source": [ "Import libraries for use with this notebook environment, you do not need these libraries when you write your application code." ] }, { "cell_type": "code", "execution_count": null, "id": "1183276e", "metadata": {}, "outputs": [], "source": [ "import sys\n", "import os\n", "import time\n", "import json\n", "\n", "import boto3\n", "import sagemaker\n", "\n", "import matplotlib.pyplot as plt\n", "from IPython.core.magic import register_cell_magic\n", "\n", "sys.path.insert( 0, os.path.abspath( \"../common/test_utility\" ) )\n", "import panorama_test_utility\n", "\n", "# instantiate boto3 clients\n", "s3_client = boto3.client('s3')\n", "panorama_client = boto3.client('panorama')\n", "\n", "# configure matplotlib\n", "%matplotlib inline\n", "plt.rcParams[\"figure.figsize\"] = (20,20)\n", "\n", "# register custom magic command\n", "@register_cell_magic\n", "def save_cell(line, cell):\n", " 'Save python code block to a file'\n", " with open(line, 'wt') as fd:\n", " fd.write(cell)" ] }, { "cell_type": "markdown", "id": "00f12da5", "metadata": { "tags": [] }, "source": [ "## Notebook parameters\n", "Global constants that help the notebook create Panorama resources on your behalf." ] }, { "cell_type": "code", "execution_count": null, "id": "1a73ca51", "metadata": {}, "outputs": [], "source": [ "# Device ID, should look like: device-oc66nax4cgzwhyuaeyifrqowue\n", "DEVICE_ID = input( 'DEVICE_ID (format: device-*)' ).strip()\n", "\n", "# Enter your S3 bucket info here\n", "S3_BUCKET = input( 'S3_BUCKET' ).strip()\n", "\n", "# Enter your desired AWS region\n", "AWS_REGION = input( 'AWS_REGION (e.g. us-east-1)' ).strip()\n", "\n", "ML_MODEL_FNAME = 'ssd_512_resnet50_v1_voc'" ] }, { "cell_type": "code", "execution_count": null, "id": "82721929", "metadata": { "tags": [] }, "outputs": [], "source": [ "# application name\n", "app_name = 'people_counter_app'\n", "\n", "## package names and node names\n", "code_package_name = 'PEOPLE_COUNTER_CODE'\n", "model_package_name = 'SSD_MODEL'\n", "camera_node_name = 'abstract_rtsp_media_source'\n", "\n", "# model node name, raw model path (without platform dependent suffics), and input data shape\n", "model_node_name = \"model_node\"\n", "model_file_basename = \"./models/\" + ML_MODEL_FNAME\n", "model_data_shape = '{\"data\":[1,3,512,512]}'\n", "\n", "# video file path to simulate camera stream\n", "videoname = '../common/test_utility/videos/TownCentreXVID.avi'\n", "\n", "# AWS account ID\n", "account_id = boto3.client(\"sts\").get_caller_identity()[\"Account\"]\n" ] }, { "cell_type": "markdown", "id": "d1034c1e", "metadata": {}, "source": [ "## Set up application\n", "\n", "Every application uses the creator's AWS Account ID as the prefix to uniquely identifies the application resources. Running `panorama-cli import-application` replaces the generic account Id with your account Id." ] }, { "cell_type": "code", "execution_count": null, "id": "96ef4328", "metadata": {}, "outputs": [], "source": [ "!cd ./people_counter_app && panorama-cli import-application" ] }, { "cell_type": "markdown", "id": "000e62b8", "metadata": { "tags": [] }, "source": [ "# Import model" ] }, { "cell_type": "markdown", "id": "b80a83d3", "metadata": {}, "source": [ "We need to compile and import the model twice. Once for testing with this notebook server and once for deploying to the Panorama device.\n", "\n", "While working with the Panorama sample code, we provide pretrained models for you to use. Locally, models are stored in `./models`. This step downloads the model artifacts from our Amazon S3 bucket to the local folder. If you want to use your own models, put your tar.gz file into the `./models` folder." ] }, { "cell_type": "markdown", "id": "8d57d4ea", "metadata": { "tags": [] }, "source": [ "### Prepare model for testing with notebook server" ] }, { "cell_type": "code", "execution_count": null, "id": "ee525919", "metadata": { "tags": [] }, "outputs": [], "source": [ "# Downloads pretrained model for this sample.\n", "# This step takes some time, depending on your network environment.\n", "panorama_test_utility.download_sample_model( ML_MODEL_FNAME, \"./models\" )" ] }, { "cell_type": "code", "execution_count": null, "id": "4d35111d-32d1-4aa7-91b9-7dec8cd697c7", "metadata": {}, "outputs": [], "source": [ "# Compile the model to run with test-utility.\n", "# This step takes 7 mins ~ 10 mins.\n", "%run ../common/test_utility/panorama_test_utility_compile.py \\\n", "\\\n", "--s3-model-location s3://{S3_BUCKET}/{app_name}/ \\\n", "\\\n", "--model-node-name model_node \\\n", "--model-file-basename ./models/{ML_MODEL_FNAME} \\\n", "--model-data-shape '{model_data_shape}' \\\n", "--model-framework MXNET" ] }, { "cell_type": "markdown", "id": "803e360e", "metadata": { "tags": [] }, "source": [ "### Prepare model for deploying to Panorama device" ] }, { "cell_type": "code", "execution_count": null, "id": "3c2eb9dc", "metadata": {}, "outputs": [], "source": [ "model_asset_name = 'model_asset'\n", "model_package_path = f'packages/{account_id}-{model_package_name}-1.0'\n", "model_descriptor_path = f'packages/{account_id}-{model_package_name}-1.0/descriptor.json'" ] }, { "cell_type": "code", "execution_count": null, "id": "44e21ded", "metadata": {}, "outputs": [], "source": [ "!cd ./people_counter_app && panorama-cli add-raw-model \\\n", " --model-asset-name {model_asset_name} \\\n", " --model-local-path ../models/{ML_MODEL_FNAME}.tar.gz \\\n", " --descriptor-path {model_descriptor_path} \\\n", " --packages-path {model_package_path}" ] }, { "cell_type": "markdown", "id": "1c2101a7", "metadata": {}, "source": [ "# Write and test app code in notebook" ] }, { "cell_type": "markdown", "id": "8c8d548c", "metadata": {}, "source": [ "Every app has an entry point script, written in Python that pulls the frames from camera streams, performs inference, and send the results to the desired location. This file can be found in `your_app/packages/code_node/src/app.py`. Below, you will iterate on the code from within the notebook environment. The entry point file will be updated everytime you run the next notebook cell thanks to the `%%save_cell`. This is a magic command to update the contents of the entry point script. \n", "\n", "After updating the entry point script, use the Test Utility Run command (panorama_test_utility_run.py) command to simulate the application.\n", "\n", "### Iterating on Code Changes\n", "\n", "To iterate on the code:\n", "1. Interrupt the kernel if application is still running.\n", "2. Make changes in the next cell, and run the cell to update the entry point script. \n", "3. Run the panorama_test_utility_run.py again.\n", "\n", "**CHANGE VIDEO** : For you to change video, please set the file path to the --video-file argument of the panorama_test_utility_run.py command." ] }, { "cell_type": "code", "execution_count": null, "id": "3cc2cf2b", "metadata": {}, "outputs": [], "source": [ "%%save_cell ./{app_name}/packages/{account_id}-{code_package_name}-1.0/src/app.py\n", "\n", "import json\n", "import logging\n", "import time\n", "from logging.handlers import RotatingFileHandler\n", "\n", "import boto3\n", "import cv2\n", "import numpy as np\n", "import panoramasdk\n", "\n", "class Application(panoramasdk.node):\n", " def __init__(self):\n", " \"\"\"Initializes the application's attributes with parameters from the interface, and default values.\"\"\"\n", " \n", " self.MODEL_NODE = \"model_node\"\n", " self.MODEL_DIM = 512\n", " self.frame_num = 0\n", " self.threshold = 50.\n", " # Desired class\n", " self.classids = [14.]\n", " \n", " try:\n", " # Parameters\n", " logger.info('Getting parameters')\n", " self.threshold = self.inputs.threshold.get()\n", " except:\n", " logger.exception('Error during initialization.')\n", " finally:\n", " logger.info('Initialiation complete.')\n", " logger.info('Threshold: {}'.format(self.threshold))\n", " \n", "\n", " def process_streams(self):\n", " \"\"\"Processes one frame of video from one or more video streams.\"\"\"\n", " self.frame_num += 1\n", " logger.debug(self.frame_num)\n", "\n", " # Loop through attached video streams\n", " streams = self.inputs.video_in.get()\n", " for stream in streams:\n", " self.process_media(stream)\n", "\n", " self.outputs.video_out.put(streams)\n", "\n", " def process_media(self, stream):\n", " \"\"\"Runs inference on a frame of video.\"\"\"\n", " image_data = preprocess(stream.image, self.MODEL_DIM)\n", " logger.debug(image_data.shape)\n", "\n", " # Run inference\n", " inference_results = self.call({\"data\":image_data}, self.MODEL_NODE)\n", "\n", " # Process results (object deteciton)\n", " self.process_results(inference_results, stream)\n", "\n", " def process_results(self, inference_results, stream):\n", " \"\"\"Processes output tensors from a computer vision model and annotates a video frame.\"\"\"\n", " if inference_results is None:\n", " logger.warning(\"Inference results are None.\")\n", " return\n", "\n", " num_people = 0\n", "\n", " class_data = None # Class Data\n", " bbox_data = None # Bounding Box Data\n", " conf_data = None # Confidence Data\n", " \n", " # Pulls data from the class holding the results\n", " # inference_results is a class, which can be iterated through\n", " # but inference_results has no index accessors (cannot do inference_results[0])\n", "\n", " k = 0\n", " for det_data in inference_results:\n", " if k == 0:\n", " class_data = det_data[0]\n", " if k == 1:\n", " conf_data = det_data[0]\n", " if k == 2:\n", " bbox_data = det_data[0]\n", " for a in range(len(conf_data)):\n", " if conf_data[a][0] * 100 > self.threshold and class_data[a][0] in self.classids:\n", " (left, top, right, bottom) = np.clip(det_data[0][a]/self.MODEL_DIM,0,1)\n", " stream.add_rect(left, top, right, bottom)\n", " num_people += 1\n", " else:\n", " continue\n", " k += 1\n", " \n", " logger.info('# people {}'.format(str(num_people)))\n", " stream.add_label('# people {}'.format(str(num_people)), 0.1, 0.1)\n", "\n", "\n", "def preprocess(img, size):\n", " \"\"\"Resizes and normalizes a frame of video.\"\"\"\n", " resized = cv2.resize(img, (size, size))\n", " mean = [0.485, 0.456, 0.406] # RGB\n", " std = [0.229, 0.224, 0.225] # RGB\n", " img = resized.astype(np.float32) / 255. # converting array of ints to floats\n", " r, g, b = cv2.split(img) \n", " # normalizing per channel data:\n", " r = (r - mean[0]) / std[0]\n", " g = (g - mean[1]) / std[1]\n", " b = (b - mean[2]) / std[2]\n", " # putting the 3 channels back together:\n", " x1 = [[[], [], []]]\n", " x1[0][0] = r\n", " x1[0][1] = g\n", " x1[0][2] = b\n", " return np.asarray(x1)\n", "\n", "def get_logger(name=__name__,level=logging.INFO):\n", " logger = logging.getLogger(name)\n", " logger.setLevel(level)\n", " handler = RotatingFileHandler(\"/opt/aws/panorama/logs/app.log\", maxBytes=100000000, backupCount=2)\n", " formatter = logging.Formatter(fmt='%(asctime)s %(levelname)-8s %(message)s',\n", " datefmt='%Y-%m-%d %H:%M:%S')\n", " handler.setFormatter(formatter)\n", " logger.addHandler(handler)\n", " return logger\n", "\n", "def main():\n", " try:\n", " logger.info(\"INITIALIZING APPLICATION\")\n", " app = Application()\n", " logger.info(\"PROCESSING STREAMS\")\n", " while True:\n", " app.process_streams()\n", " except:\n", " logger.exception('Exception during processing loop.')\n", "\n", "logger = get_logger(level=logging.INFO)\n", "main()" ] }, { "cell_type": "code", "execution_count": null, "id": "ec7144f6-f2ae-4d59-98b3-7b6cbe31c7f8", "metadata": { "tags": [] }, "outputs": [], "source": [ "# Run the application with test-utility.\n", "#\n", "# As '--output-pyplot' option is specified, this command simulates HDMI output with pyplot rendering in the output cell.\n", "# In order to see console output (stdout/stderr) from the application, please remove the --output-pyplot option.\n", "#\n", "%run ../common/test_utility/panorama_test_utility_run.py \\\n", "\\\n", "--app-name {app_name} \\\n", "--code-package-name {code_package_name} \\\n", "--model-package-name {model_package_name} \\\n", "--camera-node-name {camera_node_name} \\\n", "--model-node-name {model_node_name} \\\n", "--model-file-basename {model_file_basename} \\\n", "--video-file {videoname} \\\n", "--py-file ./{app_name}/packages/{account_id}-{code_package_name}-1.0/src/app.py \\\n", "--output-pyplot" ] }, { "cell_type": "markdown", "id": "a714082b", "metadata": {}, "source": [ "# Package app" ] }, { "cell_type": "markdown", "id": "36c7a060", "metadata": {}, "source": [ "Updates the app to be deployed with the recent code" ] }, { "cell_type": "code", "execution_count": null, "id": "7dd93954", "metadata": {}, "outputs": [], "source": [ "py_file_name = 'app.py'\n", "panorama_test_utility.update_package_descriptor( app_name, account_id, code_package_name, py_file_name )" ] }, { "cell_type": "markdown", "id": "4df1a26a-e84b-48f3-a2c6-b289a5a0b4d1", "metadata": { "tags": [] }, "source": [ "## Update camera streams\n", "\n", "In the AWS Panorama console, you can select the camera streams, but programmatically, you need to define the camera stream info for the cameras you are using with the app.\n", "\n", "We used an ```abstract data source``` here, usually this lets you select the pre-created camera source from the console. But programatically, we have to do the following steps\n", "\n", "\n", "- Create Camera\n", "- Create Override json file\n", "- Include the Override json file while are deploying the application" ] }, { "cell_type": "markdown", "id": "c217bb6c-a15f-4ec8-b00b-b91fca369180", "metadata": {}, "source": [ "### Create New Camera\n", "\n", "Because we are using an ```abstract_rtsp_media_source```, we have to create a camera before we can use the ```abstract_rtsp_media_source```\n", "\n", "**NOTE** : Update your RTSP Info in the next cell, Username, Password and RTSP Stream URL" ] }, { "cell_type": "code", "execution_count": null, "id": "e808c80f-6778-4e7c-a906-e02f2a3a4909", "metadata": {}, "outputs": [], "source": [ "CAMERA_NAME = \"test_rtsp_camera\"\n", "CAMERA_CREDS = '{\"Username\":\"root\",\"Password\":\"Aws2017!\",\"StreamUrl\": \"rtsp://192.168.0.201/onvif-media/media.amp?profile=profile_1_h264&sessiontimeout=60&streamtype=unicast\"}'" ] }, { "cell_type": "code", "execution_count": null, "id": "53af9488-5e7c-4bb9-ba65-feaf06be8a4e", "metadata": {}, "outputs": [], "source": [ "res = !aws panorama create-node-from-template-job --template-type RTSP_CAMERA_STREAM \\\n", " --output-package-name {CAMERA_NAME} \\\n", " --output-package-version '2.0' \\\n", " --node-name {CAMERA_NAME} \\\n", " --template-parameters '{CAMERA_CREDS}'\n", "\n", "# FIXME : camera node creation fails if it already exists.\n", "# Should either ignore the already-exist error, or delete the node at the end of this notebook\n", "\n", "res = ''.join(res)\n", "print(res)\n", "res_json = json.loads(res)" ] }, { "cell_type": "code", "execution_count": null, "id": "b94dc937-f62a-49a1-ae86-315b9a0c3266", "metadata": {}, "outputs": [], "source": [ "!aws panorama describe-node-from-template-job --job-id {res_json['JobId']}" ] }, { "cell_type": "markdown", "id": "62921b7b-0669-45cb-b0b3-18087fc9be82", "metadata": {}, "source": [ "## Overriding camera node" ] }, { "cell_type": "markdown", "id": "672873ea", "metadata": {}, "source": [ "If you want to override the camera configuration at deployment (for ex. deploy to another site) you can provide a deployment time override. Go to `people_counter_app/deployment_overrides/override_camera.json` file and replace YOUR_AWS_ACCOUNT_ID with your ACCOUNT_ID and YOUR_CAMERA_NAME with your camera name." ] }, { "cell_type": "code", "execution_count": null, "id": "36e9c66f-3f32-4984-b5d8-be69251f022b", "metadata": {}, "outputs": [], "source": [ "# Update Account ID\n", "with open( f\"./{app_name}/deployment_overrides/override_camera.json\", \"r\" ) as fd:\n", " override_json = json.load(fd)\n", "\n", "override_json['nodeGraphOverrides']['packages'][0]['name'] = '{}::{}'.format(account_id, CAMERA_NAME)\n", "override_json['nodeGraphOverrides']['nodes'][0]['name'] = CAMERA_NAME\n", "override_json['nodeGraphOverrides']['nodes'][0]['interface'] = '{}::{}.{}'.format(account_id, CAMERA_NAME, CAMERA_NAME) \n", "override_json['nodeGraphOverrides']['nodeOverrides'][0]['with'][0]['name'] = CAMERA_NAME \n", "\n", "with open( f\"./{app_name}/deployment_overrides/override_camera.json\", \"w\") as fd:\n", " json.dump(override_json, fd)" ] }, { "cell_type": "markdown", "id": "337238ff", "metadata": {}, "source": [ "### Build app with container" ] }, { "cell_type": "code", "execution_count": null, "id": "afc63216", "metadata": {}, "outputs": [], "source": [ "container_asset_name = 'code_asset'" ] }, { "cell_type": "code", "execution_count": null, "id": "25f5b4b1", "metadata": {}, "outputs": [], "source": [ "%%capture captured_output\n", "\n", "# Building container image.This process takes time (5min ~ 10min)\n", "# FIXME : without %%capture, browser tab crashes because of too much output from the command.\n", "\n", "!cd ./people_counter_app && panorama-cli build \\\n", " --container-asset-name {container_asset_name} \\\n", " --package-path packages/{account_id}-{code_package_name}-1.0" ] }, { "cell_type": "code", "execution_count": null, "id": "81c66f7d-70b6-411c-b86f-5256a47d8516", "metadata": {}, "outputs": [], "source": [ "stdout_lines = captured_output.stdout.splitlines()\n", "stderr_lines = captured_output.stderr.splitlines()\n", "print(\" :\")\n", "print(\" :\")\n", "for line in stdout_lines[-30:] + stderr_lines[-30:]:\n", " print(line)" ] }, { "cell_type": "markdown", "id": "f017081e", "metadata": {}, "source": [ "### Upload application to Panorama for deploying to devices" ] }, { "cell_type": "code", "execution_count": null, "id": "e8bc1f3b", "metadata": {}, "outputs": [], "source": [ "# This step takes some time, depending on your network environment.\n", "!cd ./people_counter_app && panorama-cli package-application" ] }, { "cell_type": "markdown", "id": "4a6794d7", "metadata": {}, "source": [ "### Ready for deploying to a device\n", "\n", "Congrats! Your app is now ready to deploy to a device. Next, you can continue in this notebook to deploy the app programmatically or you can go to the Panorama console and deploying using the AWS Console. The console makes it easier to select camera streams and select the devices you want to deploy to. Programmatic deployment is faster to complete and easier to automate." ] }, { "cell_type": "markdown", "id": "e8e572a8", "metadata": {}, "source": [ "# Deploy app to device" ] }, { "cell_type": "markdown", "id": "b59f04c5", "metadata": {}, "source": [ "Let's make sure the device we are deploying to is available." ] }, { "cell_type": "code", "execution_count": null, "id": "c4cd42dc", "metadata": {}, "outputs": [], "source": [ "response = panorama_client.describe_device(\n", " DeviceId= DEVICE_ID\n", ")\n", "\n", "print('You are deploying to Device: {}'.format(response['Name']))" ] }, { "cell_type": "markdown", "id": "a09b151f", "metadata": {}, "source": [ "## Deploy app\n", "\n", "You are ready to deploy your app. Below, you can see an example of how to use the AWS CLI to deploy the app. Alternatively, you can use the boto3 SDK as you did above for getting the device information." ] }, { "cell_type": "code", "execution_count": null, "id": "63cd44d3-cc80-4145-896f-eaefaed3370d", "metadata": {}, "outputs": [], "source": [ "with open(f\"./{app_name}/graphs/{app_name}/graph.json\") as fd:\n", " manifest_payload = \"'%s'\" % json.dumps({\"PayloadData\":json.dumps(json.load(fd))})\n", " \n", "with open(f\"./{app_name}/deployment_overrides/override_camera.json\") as fd:\n", " override_payload = \"'%s'\" % json.dumps({\"PayloadData\":json.dumps(json.load(fd))})" ] }, { "cell_type": "code", "execution_count": null, "id": "1ecf0ebc", "metadata": {}, "outputs": [], "source": [ "res = !aws panorama create-application-instance \\\n", " --name {app_name} \\\n", " --default-runtime-context-device {DEVICE_ID} \\\n", " --manifest-payload {manifest_payload} \\\n", " --manifest-overrides-payload {override_payload}\n", "\n", "res = ''.join(res)\n", "print(res)\n", "res_json = json.loads(res)" ] }, { "cell_type": "markdown", "id": "08db290f", "metadata": {}, "source": [ "### Check Application Status" ] }, { "cell_type": "code", "execution_count": null, "id": "0eadc9f9", "metadata": {}, "outputs": [], "source": [ "# Instantiate panorama client\n", "# FIXME : not using AWS_REGION here, because panorama-cli uses only default region currently.\n", "panorama_client = boto3.client(\"panorama\")" ] }, { "cell_type": "code", "execution_count": null, "id": "03691551-7dbb-451c-b3a3-a0a9b1fedfd1", "metadata": {}, "outputs": [], "source": [ "app_id = res_json['ApplicationInstanceId']\n", "print( \"Application Instance Id :\", app_id )\n", "\n", "progress_dots = panorama_test_utility.ProgressDots()\n", "while True:\n", " response = panorama_client.describe_application_instance( ApplicationInstanceId = app_id )\n", " status = response['Status']\n", " progress_dots.update_status( f'{status} ({response[\"StatusDescription\"]})' )\n", " if status in ('DEPLOYMENT_SUCCEEDED','DEPLOYMENT_FAILED'):\n", " break\n", " time.sleep(60)\n", " " ] }, { "cell_type": "markdown", "id": "ad70b33a", "metadata": {}, "source": [ "# Clean up" ] }, { "cell_type": "code", "execution_count": null, "id": "43b45a9b", "metadata": {}, "outputs": [], "source": [ "panorama_test_utility.remove_application( DEVICE_ID, app_id )" ] }, { "cell_type": "code", "execution_count": null, "id": "457f94b8", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "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.5" } }, "nbformat": 4, "nbformat_minor": 5 }