{"cells":[{"metadata":{},"cell_type":"markdown","source":"**This notebook is a fork from a wonderful [kernel.](https://www.kaggle.com/humananalog/inference-demo/data)\n**\n![](http://)I have tried to visually explain the outputs (face extractions) in case of two person in a video, or dim lighting. Visual representation of isotropical resizing and zero-padding for the model also provided. Due to easy interpretation, the code becomes handy for modification."},{"metadata":{"trusted":true},"cell_type":"code","source":"# Importing the required libraries\n\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport os\nimport glob\nimport matplotlib.pyplot as plt\nimport sys\nimport time\nimport torch\n\nsys.path.insert(0, \"/kaggle/input/blazeface-pytorch\")\nfrom blazeface import BlazeFace","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Version\n\nprint('OpenCV version:', cv2.__version__)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Initializing the paths\n\ninput_path = '/kaggle/input/deepfake-detection-challenge/'\ntrain_dir = glob.glob(input_path + 'train_sample_videos/*.mp4')","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Reading the labels of training data\n\ndf_train = pd.read_json(input_path + 'train_sample_videos/metadata.json').transpose()\ndf_train.head()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Plotting the count of labels\n\n# Fake class is in majority\ndf_train.label.value_counts().plot.bar()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"df_train.label.value_counts()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"df_train.head()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Oversampling\ndic = {}\nfor ind in df_train.index: \n    if(df_train['label'][ind] == 'REAL'):\n        train_dir.append(input_path + 'train_sample_videos/' + ind)\n        train_dir.append(input_path + 'train_sample_videos/' + ind)\n        dic[ind] = 0\n    else:\n        dic[ind] = 1","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# dic to store labels of each training video\ndic","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"print(len(dic))\nprint(len(train_dir))","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Loading the BlazeFace model weights\n\ngpu = torch.device(\"cuda:0\" if torch.cuda.is_available() else \"cpu\")\nfacedet = BlazeFace().to(gpu)\nfacedet.load_weights(\"/kaggle/input/blazeface-pytorch/blazeface.pth\")\nfacedet.load_anchors(\"/kaggle/input/blazeface-pytorch/anchors.npy\")\n_ = facedet.train(False)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"class VideoReader:\n    \"\"\"Helper class for reading one or more frames from a video file.\"\"\"\n\n    def __init__(self, verbose=True, insets=(0, 0)):\n        \"\"\"Creates a new VideoReader.\n\n        Arguments:\n            verbose: whether to print warnings and error messages\n            insets: amount to inset the image by, as a percentage of \n                (width, height). This lets you \"zoom in\" to an image \n                to remove unimportant content around the borders. \n                Useful for face detection, which may not work if the \n                faces are too small.\n        \"\"\"\n        self.verbose = verbose\n        self.insets = insets\n\n    def read_frames(self, path, num_frames, jitter=0, seed=None):\n        \"\"\"Reads frames from 90th frame continuously.\n\n        Arguments:\n            path: the video file\n            num_frames: how many frames to read, -1 means the entire video\n                (warning: this will take up a lot of memory!)\n            jitter: if not 0, adds small random offsets to the frame indices;\n                this is useful so we don't always land on even or odd frames\n            seed: random seed for jittering; if you set this to a fixed value,\n                you probably want to set it only on the first video \n        \"\"\"\n        assert num_frames > 0\n\n        capture = cv2.VideoCapture(path)\n        # frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))\n        # if frame_count <= 0: return None\n        frame_idxs = np.linspace(90, 90 + num_frames - 1, num_frames, endpoint=True, dtype=np.int)\n        # frame_idxs = np.linspace(0, frame_count - 1, num_frames, endpoint=True, dtype=np.int)\n        if jitter > 0:\n            np.random.seed(seed)\n            jitter_offsets = np.random.randint(-jitter, jitter, len(frame_idxs))\n            frame_idxs = np.clip(frame_idxs + jitter_offsets, 0, frame_count - 1)\n\n        result = self._read_frames_at_indices(path, capture, frame_idxs)\n        capture.release()\n        return result\n\n    def read_frames_at_indices(self, path, frame_idxs):\n        \"\"\"Reads frames from a video and puts them into a NumPy array.\n\n        Arguments:\n            path: the video file\n            frame_idxs: a list of frame indices. Important: should be\n                sorted from low-to-high! If an index appears multiple\n                times, the frame is still read only once.\n\n        Returns:\n            - a NumPy array of shape (num_frames, height, width, 3)\n            - a list of the frame indices that were read\n\n        Reading stops if loading a frame fails, in which case the first\n        dimension returned may actually be less than num_frames.\n\n        Returns None if an exception is thrown for any reason, or if no\n        frames were read.\n        \"\"\"\n        assert len(frame_idxs) > 0\n        capture = cv2.VideoCapture(path)\n        result = self._read_frames_at_indices(path, capture, frame_idxs)\n        capture.release()\n        return result\n\n    def _read_frames_at_indices(self, path, capture, frame_idxs):\n        try:\n            frames = []\n            idxs_read = []\n            for frame_idx in range(frame_idxs[0], frame_idxs[-1] + 1):\n                # Get the next frame, but don't decode if we're not using it.\n                ret = capture.grab()\n                if not ret:\n                    if self.verbose:\n                        print(\"Error grabbing frame %d from movie %s\" % (frame_idx, path))\n                    break\n\n                # Need to look at this frame?\n                current = len(idxs_read)\n                if frame_idx == frame_idxs[current]:\n                    ret, frame = capture.retrieve()\n                    if not ret or frame is None:\n                        if self.verbose:\n                            print(\"Error retrieving frame %d from movie %s\" % (frame_idx, path))\n                        break\n\n                    frame = self._postprocess_frame(frame)\n                    frames.append(frame)\n                    idxs_read.append(frame_idx)\n\n            if len(frames) > 0:\n                return np.stack(frames), idxs_read\n            if self.verbose:\n                print(\"No frames read from movie %s\" % path)\n            return None\n        except:\n            if self.verbose:\n                print(\"Exception while reading movie %s\" % path)\n            return None    \n\n    def _postprocess_frame(self, frame):\n        frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)\n\n        if self.insets[0] > 0:\n            W = frame.shape[1]\n            p = int(W * self.insets[0])\n            frame = frame[:, p:-p, :]\n\n        if self.insets[1] > 0:\n            H = frame.shape[1]\n            q = int(H * self.insets[1])\n            frame = frame[q:-q, :, :]\n\n        return frame","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"class FaceExtractor:\n    \"\"\"Wrapper for face extraction workflow.\"\"\"\n    \n    def __init__(self, video_read_fn, facedetn):\n        \"\"\"Creates a new FaceExtractor.\n\n        Arguments:\n            video_read_fn: a function that takes in a path to a video file\n                and returns a tuple consisting of a NumPy array with shape\n                (num_frames, H, W, 3) and a list of frame indices, or None\n                in case of an error\n            facedet: the face detector object\n        \"\"\"\n        self.video_read_fn = video_read_fn\n        self.facedet = facedet\n    \n    def process_videos(self, input_dir, filenames, video_idxs):\n        \"\"\"For the specified selection of videos, grabs one or more frames \n        from each video, runs the face detector, and tries to find the faces \n        in each frame.\n\n        The frames are split into tiles, and the tiles from the different videos \n        are concatenated into a single batch. This means the face detector gets\n        a batch of size len(video_idxs) * num_frames * num_tiles (usually 3).\n\n        Arguments:\n            input_dir: base folder where the video files are stored\n            filenames: list of all video files in the input_dir\n            video_idxs: one or more indices from the filenames list; these\n                are the videos we'll actually process\n\n        Returns a list of dictionaries, one for each frame read from each video.\n\n        This dictionary contains:\n            - video_idx: the video this frame was taken from\n            - frame_idx: the index of the frame in the video\n            - frame_w, frame_h: original dimensions of the frame\n            - faces: a list containing zero or more NumPy arrays with a face crop\n            - scores: a list array with the confidence score for each face crop\n\n        If reading a video failed for some reason, it will not appear in the \n        output array. Note that there's no guarantee a given video will actually\n        have num_frames results (as soon as a reading problem is encountered for \n        a video, we continue with the next video).\n        \"\"\"\n        target_size = self.facedet.input_size\n\n        videos_read = []\n        frames_read = []\n        frames = []\n        tiles = []\n        resize_info = []\n        for video_idx in video_idxs:\n            # Read the full-size frames from this video.\n            filename = filenames[video_idx]\n            video_path = os.path.join(input_dir, filename)\n            result = self.video_read_fn(video_path)\n\n            # Error? Then skip this video.\n            if result is None: continue\n\n            videos_read.append(video_idx)\n\n            # Keep track of the original frames (need them later).\n            my_frames, my_idxs = result\n            frames.append(my_frames)\n            frames_read.append(my_idxs)\n\n            # Split the frames into several tiles. Resize the tiles to 128x128.\n            my_tiles, my_resize_info = self._tile_frames(my_frames, target_size)\n            tiles.append(my_tiles)\n            resize_info.append(my_resize_info)\n\n        # Put all the tiles for all the frames from all the videos into\n        # a single batch.\n        batch = np.concatenate(tiles)\n\n        # Run the face detector. The result is a list of PyTorch tensors, \n        # one for each image in the batch.\n        all_detections = self.facedet.predict_on_batch(batch, apply_nms=False)\n\n        result = []\n        offs = 0\n        for v in range(len(tiles)):\n            # Not all videos may have the same number of tiles, so find which \n            # detections go with which video.\n            num_tiles = tiles[v].shape[0]\n            detections = all_detections[offs:offs + num_tiles]\n            offs += num_tiles\n\n            # Convert the detections from 128x128 back to the original frame size.\n            detections = self._resize_detections(detections, target_size, resize_info[v])\n\n            # Because we have several tiles for each frame, combine the predictions\n            # from these tiles. The result is a list of PyTorch tensors, but now one\n            # for each frame (rather than each tile).\n            num_frames = frames[v].shape[0]\n            frame_size = (frames[v].shape[2], frames[v].shape[1])\n            detections = self._untile_detections(num_frames, frame_size, detections)\n\n            # The same face may have been detected in multiple tiles, so filter out\n            # overlapping detections. This is done separately for each frame.\n            detections = self.facedet.nms(detections)\n\n            for i in range(len(detections)):\n                # Crop the faces out of the original frame.\n                faces = self._add_margin_to_detections(detections[i], frame_size, 0.2)\n                faces = self._crop_faces(frames[v][i], faces)\n\n                # Add additional information about the frame and detections.\n                scores = list(detections[i][:, 16].cpu().numpy())\n                frame_dict = { \"video_idx\": videos_read[v],\n                               \"frame_idx\": frames_read[v][i],\n                               \"frame_w\": frame_size[0],\n                               \"frame_h\": frame_size[1],\n                               \"faces\": faces, \n                               \"scores\": scores }\n                result.append(frame_dict)\n\n                # TODO: could also add:\n                # - face rectangle in original frame coordinates\n                # - the keypoints (in crop coordinates)\n                \n        return result\n\n    def process_video(self, video_path):\n        \"\"\"Convenience method for doing face extraction on a single video.\"\"\"\n        input_dir = os.path.dirname(video_path)\n        filenames = [ os.path.basename(video_path) ]\n        return self.process_videos(input_dir, filenames, [0])\n\n    def _tile_frames(self, frames, target_size):\n        \"\"\"Splits each frame into several smaller, partially overlapping tiles\n        and resizes each tile to target_size.\n\n        After a bunch of experimentation, I found that for a 1920x1080 video,\n        BlazeFace works better on three 1080x1080 windows. These overlap by 420\n        pixels. (Two windows also work but it's best to have a clean center crop\n        in there as well.)\n\n        I also tried 6 windows of size 720x720 (horizontally: 720|360, 360|720;\n        vertically: 720|1200, 480|720|480, 1200|720) but that gives many false\n        positives when a window has no face in it.\n\n        For a video in portrait orientation (1080x1920), we only take a single\n        crop of the top-most 1080 pixels. If we split up the video vertically,\n        then we might get false positives again.\n\n        (NOTE: Not all videos are necessarily 1080p but the code can handle this.)\n\n        Arguments:\n            frames: NumPy array of shape (num_frames, height, width, 3)\n            target_size: (width, height)\n\n        Returns:\n            - a new (num_frames * N, target_size[1], target_size[0], 3) array\n              where N is the number of tiles used.\n            - a list [scale_w, scale_h, offset_x, offset_y] that describes how\n              to map the resized and cropped tiles back to the original image \n              coordinates. This is needed for scaling up the face detections \n              from the smaller image to the original image, so we can take the \n              face crops in the original coordinate space.    \n        \"\"\"\n        num_frames, H, W, _ = frames.shape\n\n        # Settings for 6 overlapping windows:\n        # split_size = 720\n        # x_step = 480\n        # y_step = 360\n        # num_v = 2\n        # num_h = 3\n\n        # Settings for 2 overlapping windows:\n        # split_size = min(H, W)\n        # x_step = W - split_size\n        # y_step = H - split_size\n        # num_v = 1\n        # num_h = 2 if W > H else 1\n\n        split_size = min(H, W)\n        x_step = (W - split_size) // 2\n        y_step = (H - split_size) // 2\n        num_v = 1\n        num_h = 3 if W > H else 1\n\n        splits = np.zeros((num_frames * num_v * num_h, target_size[1], target_size[0], 3), dtype=np.uint8)\n\n        i = 0\n        for f in range(num_frames):\n            y = 0\n            for v in range(num_v):\n                x = 0\n                for h in range(num_h):\n                    crop = frames[f, y:y+split_size, x:x+split_size, :]\n                    splits[i] = cv2.resize(crop, target_size, interpolation=cv2.INTER_AREA)\n                    x += x_step\n                    i += 1\n                y += y_step\n\n        resize_info = [split_size / target_size[0], split_size / target_size[1], 0, 0]\n        return splits, resize_info\n\n    def _resize_detections(self, detections, target_size, resize_info):\n        \"\"\"Converts a list of face detections back to the original \n        coordinate system.\n\n        Arguments:\n            detections: a list containing PyTorch tensors of shape (num_faces, 17) \n            target_size: (width, height)\n            resize_info: [scale_w, scale_h, offset_x, offset_y]\n        \"\"\"\n        projected = []\n        target_w, target_h = target_size\n        scale_w, scale_h, offset_x, offset_y = resize_info\n\n        for i in range(len(detections)):\n            detection = detections[i].clone()\n\n            # ymin, xmin, ymax, xmax\n            for k in range(2):\n                detection[:, k*2    ] = (detection[:, k*2    ] * target_h - offset_y) * scale_h\n                detection[:, k*2 + 1] = (detection[:, k*2 + 1] * target_w - offset_x) * scale_w\n\n            # keypoints are x,y\n            for k in range(2, 8):\n                detection[:, k*2    ] = (detection[:, k*2    ] * target_w - offset_x) * scale_w\n                detection[:, k*2 + 1] = (detection[:, k*2 + 1] * target_h - offset_y) * scale_h\n\n            projected.append(detection)\n\n        return projected    \n    \n    def _untile_detections(self, num_frames, frame_size, detections):\n        \"\"\"With N tiles per frame, there also are N times as many detections.\n        This function groups together the detections for a given frame; it is\n        the complement to tile_frames().\n        \"\"\"\n        combined_detections = []\n\n        W, H = frame_size\n        split_size = min(H, W)\n        x_step = (W - split_size) // 2\n        y_step = (H - split_size) // 2\n        num_v = 1\n        num_h = 3 if W > H else 1\n\n        i = 0\n        for f in range(num_frames):\n            detections_for_frame = []\n            y = 0\n            for v in range(num_v):\n                x = 0\n                for h in range(num_h):\n                    # Adjust the coordinates based on the split positions.\n                    detection = detections[i].clone()\n                    if detection.shape[0] > 0:\n                        for k in range(2):\n                            detection[:, k*2    ] += y\n                            detection[:, k*2 + 1] += x\n                        for k in range(2, 8):\n                            detection[:, k*2    ] += x\n                            detection[:, k*2 + 1] += y\n\n                    detections_for_frame.append(detection)\n                    x += x_step\n                    i += 1\n                y += y_step\n\n            combined_detections.append(torch.cat(detections_for_frame))\n\n        return combined_detections\n    \n    def _add_margin_to_detections(self, detections, frame_size, margin=0.2):\n        \"\"\"Expands the face bounding box.\n\n        NOTE: The face detections often do not include the forehead, which\n        is why we use twice the margin for ymin.\n\n        Arguments:\n            detections: a PyTorch tensor of shape (num_detections, 17)\n            frame_size: maximum (width, height)\n            margin: a percentage of the bounding box's height\n\n        Returns a PyTorch tensor of shape (num_detections, 17).\n        \"\"\"\n        offset = torch.round(margin * (detections[:, 2] - detections[:, 0]))\n        detections = detections.clone()\n        detections[:, 0] = torch.clamp(detections[:, 0] - offset*2, min=0)            # ymin\n        detections[:, 1] = torch.clamp(detections[:, 1] - offset, min=0)              # xmin\n        detections[:, 2] = torch.clamp(detections[:, 2] + offset, max=frame_size[1])  # ymax\n        detections[:, 3] = torch.clamp(detections[:, 3] + offset, max=frame_size[0])  # xmax\n        return detections\n    \n    def _crop_faces(self, frame, detections):\n        \"\"\"Copies the face region(s) from the given frame into a set\n        of new NumPy arrays.\n\n        Arguments:\n            frame: a NumPy array of shape (H, W, 3)\n            detections: a PyTorch tensor of shape (num_detections, 17)\n\n        Returns a list of NumPy arrays, one for each face crop. If there\n        are no faces detected for this frame, returns an empty list.\n        \"\"\"\n        faces = []\n        for i in range(len(detections)):\n            ymin, xmin, ymax, xmax = detections[i, :4].cpu().numpy().astype(np.int)\n            face = frame[ymin:ymax, xmin:xmax, :]\n            faces.append(face)\n        return faces\n\n    def keep_only_best_face(self, crops):\n        \"\"\"For each frame, only keeps the face with the highest confidence. \n        \n        This gets rid of false positives, but obviously is problematic for \n        videos with two people!\n\n        This is an optional postprocessing step. Modifies the original\n        data structure.\n        \"\"\"\n        for i in range(len(crops)):\n            frame_data = crops[i]\n            if len(frame_data[\"faces\"]) > 0:\n                frame_data[\"faces\"] = frame_data[\"faces\"][:1]\n                frame_data[\"scores\"] = frame_data[\"scores\"][:1]    ","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"def isotropically_resize_image(img, size, resample=cv2.INTER_AREA):\n    h, w = img.shape[:2]\n    if w > h:\n        h = h * size // w\n        w = size\n    else:\n        w = w * size // h\n        h = size\n\n    resized = cv2.resize(img, (w, h), interpolation=resample)\n    return resized\n\n\ndef make_square_image(img):\n    h, w = img.shape[:2]\n    size = max(h, w)\n    t = 0\n    b = size - h\n    l = 0\n    r = size - w\n    return cv2.copyMakeBorder(img, t, b, l, r, cv2.BORDER_CONSTANT, value=0)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# 64 frames starting from 90th frame till 153rd frame will be captured\nframes_per_video = 64\n\nvideo_reader = VideoReader()\nvideo_read_fn = lambda x: video_reader.read_frames(x, num_frames = frames_per_video)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Extracting faces from video with 2 persons\nface_extractor = FaceExtractor(video_read_fn, facedet)\nfaces = face_extractor.process_video(input_path + 'train_sample_videos/aapnvogymq.mp4')","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Face 1\nplt.imshow(np.squeeze(np.array(faces[0]['faces'][0])))","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Face 2\nplt.imshow(np.squeeze(np.array(faces[0]['faces'][1])))","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# In case only one person in the video, the other detections are false positives\n# The face with highest confidence is taken\n\nface_extractor.keep_only_best_face(faces)\nplt.imshow(np.squeeze(np.array(faces[0]['faces'])))\n\n# TODO: def sort_by_histogram(self, crops) for videos with 2 people.","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"print(np.array(faces[0]['faces']).shape)\nprint(np.squeeze(np.array(faces[0]['faces'])).shape)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Resize to the model's required input size\ninput_size = 224\n\n# We keep the aspect ratio intact\nresized_face = isotropically_resize_image(np.squeeze(np.array(faces[0]['faces'])), input_size)\nplt.imshow(resized_face)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Adding zero padding if required\nresized_face = make_square_image(resized_face)\nplt.imshow(resized_face)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"def face_extract_on_video(video_path):\n    # Find the faces for N frames in the video\n    faces = face_extractor.process_video(video_path)\n\n    # Only look at one face per frame.\n    face_extractor.keep_only_best_face(faces)\n    \n    if len(faces) > 0:\n        features = []\n        for frame_data in faces:\n            for face in frame_data[\"faces\"]:\n                resized_face = isotropically_resize_image(face, input_size)\n                resized_face = make_square_image(resized_face)\n                features.append(resized_face)\n    return features","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"def face_extract_on_video_set(video_paths):\n    # Find the faces (ROI) for frames_per_video frames in the the given videos\n    roi = []\n    for x in video_paths:\n        roi.append(face_extract_on_video(x))\n    return roi","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# Example\n\nvid_path = [train_dir[0], train_dir[1], train_dir[2], train_dir[3], '/kaggle/input/deepfake-detection-challenge/train_sample_videos/andaxzscny.mp4']\n\nt1 = time.time()\nroi = face_extract_on_video_set(vid_path)\nprint(\"Time taken: \", time.time() - t1)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"np.array(roi).shape","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"np.array(roi[0]).shape","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"# In video 'andaxzscny.mp4' only 10 face extractions possible out of 64 capturd frames\nnp.array(roi[4]).shape","execution_count":null,"outputs":[]}],"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.6"}},"nbformat":4,"nbformat_minor":4}