Folders and files
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Repository files navigation
{
"cells": [
{
"cell_type": "markdown",
"id": "da07b3bb",
"metadata": {},
"source": [
"# License Plate Detection & Recognition — YOLOv8 + OCR\n",
"\n",
"End-to-end ANPR (Automatic Number Plate Recognition) pipeline:\n",
"\n",
"**YOLOv8** locates the plate in a photo/frame -> the plate region is **cropped**\n",
"and **preprocessed** -> **EasyOCR** reads the characters -> optional\n",
"**post-processing** cleans up the text.\n",
"\n",
"### Before you run this\n",
"`Runtime > Change runtime type > T4 GPU`. Training on CPU will be very slow.\n",
"\n",
"### Table of contents\n",
"1. Setup\n",
"2. Dataset\n",
"3. Dataset exploration\n",
"4. Training (YOLOv8)\n",
"5. Validation\n",
"6. Inference (detection only)\n",
"7. Cropping detected plates\n",
"8. OCR preprocessing\n",
"9. OCR with EasyOCR\n",
"10. End-to-end pipeline\n",
"11. Batch processing + CSV export\n",
"12. Bonus: video pipeline\n",
"13. Improving accuracy further\n",
"14. Export & persist the model"
]
},
{
"cell_type": "markdown",
"id": "c9f99335",
"metadata": {},
"source": [
"## 1. Setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "308a4e7e",
"metadata": {},
"outputs": [],
"source": [
"!pip install -q ultralytics easyocr roboflow"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d14d6a61",
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import re\n",
"import cv2\n",
"import glob\n",
"import yaml\n",
"import torch\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from pathlib import Path\n",
"from ultralytics import YOLO\n",
"import easyocr\n",
"from IPython.display import Image as IPyImage, display"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "42024deb",
"metadata": {},
"outputs": [],
"source": [
"print(\"PyTorch:\", torch.__version__)\n",
"print(\"CUDA available:\", torch.cuda.is_available())\n",
"if torch.cuda.is_available():\n",
" print(\"GPU:\", torch.cuda.get_device_name(0))\n",
"else:\n",
" print(\"No GPU detected. Go to Runtime > Change runtime type > T4 GPU, \"\n",
" \"then Runtime > Restart session, and run this cell again.\")"
]
},
{
"cell_type": "markdown",
"id": "26b48531",
"metadata": {},
"source": [
"## 2. Dataset\n",
"\n",
"**Recommended: \"License Plate Recognition\" (Roboflow Universe Projects)**\n",
"`universe.roboflow.com/roboflow-universe-projects/license-plate-recognition-rxg4e`\n",
"\n",
"Why this one:\n",
"- 24,242 images total (21,174 train / 2,048 valid / 1,020 test) after 3x\n",
" augmentation — enough to fine-tune a solid detector without a multi-hour download.\n",
"- Already labeled and exported in native YOLOv8 format (`data.yaml` + `train/valid/test`\n",
" folders with `images/` and `labels/`), so there is no annotation conversion step.\n",
"- Single class (`License_Plate`), real-world traffic/parking photos with varied\n",
" angles, lighting, and plate styles — generalizes better than a narrow regional set.\n",
"- CC BY 4.0 licensed, 700+ stars, one of the most-downloaded datasets in this exact\n",
" category — this is the dataset most public YOLOv8+OCR plate tutorials use.\n",
"\n",
"**Get a free API key**: create an account at `app.roboflow.com`, then\n",
"`Settings > Roboflow API` to copy your private key. Paste it below.\n",
"\n",
"**No-signup alternative**: on the dataset page, click *Download Dataset* ->\n",
"choose the **YOLOv8** format -> download the zip directly, then upload it to\n",
"Colab (or Google Drive) and unzip instead of using the API.\n",
"\n",
"**Other datasets worth knowing about**, if you want to swap later:\n",
"- *Kaggle \"Car License Plate Detection\" (andrewmvd)* — small (433 images), Pascal\n",
" VOC XML annotations, good for a very fast smoke test but needs conversion to YOLO format.\n",
"- *Large License Plate Dataset / Open Images \"Vehicle registration plate\" class* —\n",
" tens of thousands of images if you want to scale up beyond this notebook."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "30e5afbe",
"metadata": {},
"outputs": [],
"source": [
"from roboflow import Roboflow\n",
"\n",
"# Get a free key at https://app.roboflow.com/settings/api\n",
"ROBOFLOW_API_KEY = \"YOUR_API_KEY_HERE\"\n",
"\n",
"rf = Roboflow(api_key=ROBOFLOW_API_KEY)\n",
"project = rf.workspace(\"roboflow-universe-projects\").project(\"license-plate-recognition-rxg4e\")\n",
"dataset = project.version(4).download(\"yolov8\")\n",
"\n",
"DATASET_DIR = dataset.location\n",
"print(\"Dataset downloaded to:\", DATASET_DIR)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "89a4e5a8",
"metadata": {},
"outputs": [],
"source": [
"with open(f\"{DATASET_DIR}/data.yaml\") as f:\n",
" data_cfg = yaml.safe_load(f)\n",
"\n",
"print(\"Classes:\", data_cfg[\"names\"])\n",
"for split in [\"train\", \"valid\", \"test\"]:\n",
" img_dir = f\"{DATASET_DIR}/{split}/images\"\n",
" if os.path.exists(img_dir):\n",
" print(f\"{split}: {len(os.listdir(img_dir))} images\")"
]
},
{
"cell_type": "markdown",
"id": "e2e77aee",
"metadata": {},
"source": [
"## 3. Dataset exploration\n",
"\n",
"Quick sanity check: draw the YOLO-format label boxes over a few training images\n",
"so you can confirm the annotations line up before spending compute on training."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "18988605",
"metadata": {},
"outputs": [],
"source": [
"def yolo_to_pixel(box, img_w, img_h):\n",
" cls, xc, yc, w, h = box\n",
" x1 = int((xc - w / 2) * img_w)\n",
" y1 = int((yc - h / 2) * img_h)\n",
" x2 = int((xc + w / 2) * img_w)\n",
" y2 = int((yc + h / 2) * img_h)\n",
" return int(cls), x1, y1, x2, y2\n",
"\n",
"\n",
"def show_samples(split=\"train\", n=6):\n",
" img_dir = f\"{DATASET_DIR}/{split}/images\"\n",
" lbl_dir = f\"{DATASET_DIR}/{split}/labels\"\n",
" img_files = sorted(os.listdir(img_dir))[:n]\n",
"\n",
" fig, axes = plt.subplots(2, 3, figsize=(16, 9))\n",
" for ax, fname in zip(axes.flatten(), img_files):\n",
" img = cv2.cvtColor(cv2.imread(f\"{img_dir}/{fname}\"), cv2.COLOR_BGR2RGB)\n",
" h, w = img.shape[:2]\n",
"\n",
" lbl_path = f\"{lbl_dir}/{Path(fname).stem}.txt\"\n",
" if os.path.exists(lbl_path):\n",
" with open(lbl_path) as f:\n",
" for line in f:\n",
" box = list(map(float, line.split()))\n",
" _, x1, y1, x2, y2 = yolo_to_pixel(box, w, h)\n",
" cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 3)\n",
"\n",
" ax.imshow(img)\n",
" ax.axis(\"off\")\n",
" plt.tight_layout()\n",
" plt.show()\n",
"\n",
"\n",
"show_samples(\"train\")"
]
},
{
"cell_type": "markdown",
"id": "37bd943f",
"metadata": {},
"source": [
"## 4. Training\n",
"\n",
"`yolov8s.pt` (small) is a good accuracy/speed balance for this task. Swap to\n",
"`yolov8n.pt` if you want faster iteration, or `yolov8m.pt` / `yolov8l.pt` if\n",
"you have GPU budget and want to push accuracy further.\n",
"\n",
"`patience=15` stops training early if validation mAP hasn't improved in 15\n",
"epochs, so raising `epochs` costs little if the model converges sooner."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "404a33ef",
"metadata": {},
"outputs": [],
"source": [
"model = YOLO(\"yolov8s.pt\")\n",
"\n",
"results = model.train(\n",
" data=f\"{DATASET_DIR}/data.yaml\",\n",
" epochs=60,\n",
" imgsz=640,\n",
" batch=16,\n",
" patience=15,\n",
" device=0 if torch.cuda.is_available() else \"cpu\",\n",
" project=\"license_plate_runs\",\n",
" name=\"yolov8s_lp\",\n",
" plots=True,\n",
")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5221d94c",
"metadata": {},
"outputs": [],
"source": [
"display(IPyImage(filename=\"license_plate_runs/yolov8s_lp/results.png\", width=900))\n",
"display(IPyImage(filename=\"license_plate_runs/yolov8s_lp/confusion_matrix.png\", width=600))"
]
},
{
"cell_type": "markdown",
"id": "df0dd38b",
"metadata": {},
"source": [
"## 5. Validation"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "fbb5f75b",
"metadata": {},
"outputs": [],
"source": [
"best_model = YOLO(\"license_plate_runs/yolov8s_lp/weights/best.pt\")\n",
"metrics = best_model.val(data=f\"{DATASET_DIR}/data.yaml\")\n",
"\n",
"print(f\"mAP50: {metrics.box.map50:.3f}\")\n",
"print(f\"mAP50-95: {metrics.box.map:.3f}\")\n",
"print(f\"Precision: {metrics.box.mp:.3f}\")\n",
"print(f\"Recall: {metrics.box.mr:.3f}\")"
]
},
{
"cell_type": "markdown",
"id": "cf0c539c",
"metadata": {},
"source": [
"## 6. Inference (detection only)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "ad65b262",
"metadata": {},
"outputs": [],
"source": [
"test_images = sorted(glob.glob(f\"{DATASET_DIR}/test/images/*.jpg\"))[:6]\n",
"if not test_images:\n",
" test_images = sorted(glob.glob(f\"{DATASET_DIR}/valid/images/*.jpg\"))[:6]\n",
"\n",
"det_results = best_model.predict(test_images, conf=0.25, verbose=False)\n",
"\n",
"fig, axes = plt.subplots(2, 3, figsize=(18, 10))\n",
"for ax, r in zip(axes.flatten(), det_results):\n",
" im = cv2.cvtColor(r.plot(), cv2.COLOR_BGR2RGB)\n",
" ax.imshow(im)\n",
" ax.axis(\"off\")\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "f4d0bdae",
"metadata": {},
"source": [
"## 7. Cropping detected plates\n",
"\n",
"YOLO only gives us *where* the plate is. To read the characters we crop that\n",
"region out of the full image and hand the crop to OCR."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c7cbf930",
"metadata": {},
"outputs": [],
"source": [
"def crop_plates(image_path, model, conf=0.25):\n",
" img = cv2.imread(image_path)\n",
" results = model.predict(image_path, conf=conf, verbose=False)\n",
" crops, boxes = [], []\n",
" for r in results:\n",
" for box in r.boxes.xyxy.cpu().numpy():\n",
" x1, y1, x2, y2 = map(int, box)\n",
" crops.append(img[y1:y2, x1:x2])\n",
" boxes.append((x1, y1, x2, y2))\n",
" return crops, boxes\n",
"\n",
"\n",
"crops, boxes = crop_plates(test_images[0], best_model)\n",
"print(f\"Found {len(crops)} plate(s) in {test_images[0]}\")\n",
"if crops:\n",
" plt.imshow(cv2.cvtColor(crops[0], cv2.COLOR_BGR2RGB))\n",
" plt.axis(\"off\")\n",
" plt.show()"
]
},
{
"cell_type": "markdown",
"id": "b92417d3",
"metadata": {},
"source": [
"## 8. OCR preprocessing\n",
"\n",
"Plate crops are small and often low-contrast, which is where most OCR errors\n",
"come from — not from EasyOCR itself. Upscaling, denoising, and thresholding\n",
"before OCR consistently improves character accuracy more than swapping OCR\n",
"engines does."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "0c07aba2",
"metadata": {},
"outputs": [],
"source": [
"def preprocess_plate(crop, upscale=2, use_clahe=True):\n",
" gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)\n",
" h, w = gray.shape\n",
" gray = cv2.resize(gray, (w * upscale, h * upscale), interpolation=cv2.INTER_CUBIC)\n",
"\n",
" if use_clahe:\n",
" clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n",
" gray = clahe.apply(gray)\n",
"\n",
" gray = cv2.bilateralFilter(gray, 11, 17, 17)\n",
" thresh = cv2.adaptiveThreshold(\n",
" gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 15\n",
" )\n",
" return thresh\n",
"\n",
"\n",
"if crops:\n",
" processed = preprocess_plate(crops[0])\n",
" fig, axes = plt.subplots(1, 2, figsize=(10, 4))\n",
" axes[0].imshow(cv2.cvtColor(crops[0], cv2.COLOR_BGR2RGB))\n",
" axes[0].set_title(\"Raw crop\")\n",
" axes[0].axis(\"off\")\n",
" axes[1].imshow(processed, cmap=\"gray\")\n",
" axes[1].set_title(\"Preprocessed\")\n",
" axes[1].axis(\"off\")\n",
" plt.show()"
]
},
{
"cell_type": "markdown",
"id": "2496a49e",
"metadata": {},
"source": [
"## 9. OCR with EasyOCR\n",
"\n",
"EasyOCR is the standard pairing with YOLOv8 for this task: deep-learning based\n",
"(handles noisy/blurred crops far better than Tesseract), minimal setup, and\n",
"GPU-accelerated in Colab."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "64c66782",
"metadata": {},
"outputs": [],
"source": [
"reader = easyocr.Reader([\"en\"], gpu=torch.cuda.is_available())"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4782a610",
"metadata": {},
"outputs": [],
"source": [
"def clean_plate_text(text):\n",
" return re.sub(r\"[^A-Z0-9]\", \"\", text.upper())\n",
"\n",
"\n",
"def ocr_plate(crop, reader, preprocess=True):\n",
" image_for_ocr = preprocess_plate(crop) if preprocess else crop\n",
" result = reader.readtext(image_for_ocr, detail=0, paragraph=False)\n",
" return clean_plate_text(\"\".join(result))\n",
"\n",
"\n",
"if crops:\n",
" print(\"Detected text:\", ocr_plate(crops[0], reader))"
]
},
{
"cell_type": "markdown",
"id": "22aae096",
"metadata": {},
"source": [
"## 10. End-to-end pipeline\n",
"\n",
"Detect -> crop -> preprocess -> OCR -> draw box + text, wrapped into one call."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9c02f90d",
"metadata": {},
"outputs": [],
"source": [
"def recognize_license_plate(image_path, detector, reader, conf=0.25, show=True):\n",
" img = cv2.imread(image_path)\n",
" results = detector.predict(image_path, conf=conf, verbose=False)\n",
"\n",
" plates = []\n",
" for r in results:\n",
" for box in r.boxes.xyxy.cpu().numpy():\n",
" x1, y1, x2, y2 = map(int, box)\n",
" crop = img[y1:y2, x1:x2]\n",
" text = ocr_plate(crop, reader)\n",
" plates.append({\"bbox\": (x1, y1, x2, y2), \"text\": text})\n",
" cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 3)\n",
" cv2.putText(img, text, (x1, max(0, y1 - 10)),\n",
" cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2)\n",
"\n",
" if show:\n",
" plt.figure(figsize=(10, 8))\n",
" plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))\n",
" plt.axis(\"off\")\n",
" plt.show()\n",
"\n",
" return plates\n",
"\n",
"\n",
"for img_path in test_images[:3]:\n",
" print(img_path)\n",
" recognize_license_plate(img_path, best_model, reader)"
]
},
{
"cell_type": "markdown",
"id": "d6ac1777",
"metadata": {},
"source": [
"## 11. Batch processing + CSV export"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6a93ae32",
"metadata": {},
"outputs": [],
"source": [
"records = []\n",
"for img_path in test_images:\n",
" for p in recognize_license_plate(img_path, best_model, reader, show=False):\n",
" records.append({\n",
" \"image\": os.path.basename(img_path),\n",
" \"plate_text\": p[\"text\"],\n",
" \"bbox\": p[\"bbox\"],\n",
" })\n",
"\n",
"df = pd.DataFrame(records)\n",
"df.to_csv(\"plate_recognition_results.csv\", index=False)\n",
"df.head(10)"
]
},
{
"cell_type": "markdown",
"id": "5b1a2444",
"metadata": {},
"source": [
"## 12. Bonus: video pipeline\n",
"\n",
"Runs detection every `skip_frames` frames (OCR is the slow part) and holds\n",
"the last reading in between, so the overlay doesn't flicker every frame."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "b3b2d9e4",
"metadata": {},
"outputs": [],
"source": [
"def process_video(video_path, output_path, detector, reader, conf=0.25, skip_frames=2):\n",
" cap = cv2.VideoCapture(video_path)\n",
" fps = cap.get(cv2.CAP_PROP_FPS)\n",
" w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))\n",
" h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))\n",
" out = cv2.VideoWriter(output_path, cv2.VideoWriter_fourcc(*\"mp4v\"), fps, (w, h))\n",
"\n",
" frame_idx = 0\n",
" last_plates = []\n",
" while True:\n",
" ret, frame = cap.read()\n",
" if not ret:\n",
" break\n",
"\n",
" if frame_idx % skip_frames == 0:\n",
" results = detector.predict(frame, conf=conf, verbose=False)\n",
" last_plates = []\n",
" for r in results:\n",
" for box in r.boxes.xyxy.cpu().numpy():\n",
" x1, y1, x2, y2 = map(int, box)\n",
" crop = frame[y1:y2, x1:x2]\n",
" text = ocr_plate(crop, reader)\n",
" last_plates.append((x1, y1, x2, y2, text))\n",
"\n",
" for (x1, y1, x2, y2, text) in last_plates:\n",
" cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 3)\n",
" cv2.putText(frame, text, (x1, max(0, y1 - 10)),\n",
" cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2)\n",
"\n",
" out.write(frame)\n",
" frame_idx += 1\n",
"\n",
" cap.release()\n",
" out.release()\n",
" print(\"Saved:\", output_path)\n",
"\n",
"\n",
"# Example (uncomment once you have a video file uploaded):\n",
"# process_video(\"input.mp4\", \"output.mp4\", best_model, reader)"
]
},
{
"cell_type": "markdown",
"id": "f6f88a50",
"metadata": {},
"source": [
"## 13. Improving accuracy further\n",
"\n",
"**Detection side**\n",
"- Train longer / on `yolov8m.pt` if mAP50 is below ~0.90 on validation.\n",
"- Raise `imgsz` to 800-960 if plates are small relative to the full frame\n",
" (small-object detection improves with resolution).\n",
"- Run `model.tune()` (Ultralytics' built-in hyperparameter search) if you\n",
" have compute budget to spend.\n",
"- Use `augment=True` in `predict()` for test-time augmentation — slower but\n",
" more robust at inference.\n",
"\n",
"**OCR side**\n",
"- The preprocessing in section 8 (upscale + CLAHE + adaptive threshold) is\n",
" usually worth more than switching OCR engines. Try disabling CLAHE\n",
" (`use_clahe=False`) if your plates are already high-contrast — it can\n",
" sometimes over-sharpen and hurt clean images.\n",
"- PaddleOCR is a strong alternative to EasyOCR, often slightly more accurate\n",
" on structured text, at the cost of a heavier install.\n",
"- If you know the target region's plate format, validate/correct OCR output\n",
" against it — the two commonly-confused character pairs are `O/0`, `I/1`,\n",
" `S/5`, `B/8`, `Z/2`.\n",
"- For video, take a majority vote of the OCR reading across several frames of\n",
" the same vehicle instead of trusting a single frame."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c6f12d68",
"metadata": {},
"outputs": [],
"source": [
"# Character-confusion correction against a known plate format.\n",
"CONFUSABLE_PAIRS = {\"O\": \"0\", \"0\": \"O\", \"I\": \"1\", \"1\": \"I\",\n",
" \"S\": \"5\", \"5\": \"S\", \"B\": \"8\", \"8\": \"B\", \"Z\": \"2\", \"2\": \"Z\"}\n",
"\n",
"\n",
"def try_format_correction(text, pattern):\n",
" # If text doesn't match pattern, try swapping one confusable character\n",
" # at a time and return the first swap that produces a match.\n",
" if re.match(pattern, text):\n",
" return text\n",
" for i, ch in enumerate(text):\n",
" if ch in CONFUSABLE_PAIRS:\n",
" candidate = text[:i] + CONFUSABLE_PAIRS[ch] + text[i + 1:]\n",
" if re.match(pattern, candidate):\n",
" return candidate\n",
" return text\n",
"\n",
"\n",
"PLATE_PATTERNS = {\n",
" \"generic\": r\"^[A-Z0-9]{5,10}$\",\n",
" \"india\": r\"^[A-Z]{2}[0-9]{1,2}[A-Z]{1,2}[0-9]{4}$\",\n",
"}\n",
"\n",
"# Example:\n",
"# try_format_correction(\"MH12AB1Z34\", PLATE_PATTERNS[\"india\"])"
]
},
{
"cell_type": "markdown",
"id": "9b2bce3d",
"metadata": {},
"source": [
"## 14. Export & persist the model\n",
"\n",
"Colab sessions are ephemeral — export weights and copy them to Drive so a\n",
"disconnect doesn't cost you the trained model."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "44dc76d6",
"metadata": {},
"outputs": [],
"source": [
"best_model.export(format=\"onnx\")\n",
"\n",
"# Optional: persist results to Google Drive\n",
"# from google.colab import drive\n",
"# drive.mount(\"/content/drive\")\n",
"# !cp -r license_plate_runs /content/drive/MyDrive/license_plate_runs"
]
},
{
"cell_type": "markdown",
"id": "da246fc2",
"metadata": {},
"source": [
"### Next steps\n",
"\n",
"- Wrapping `recognize_license_plate` in a FastAPI endpoint (image in, JSON\n",
" plate text + bbox out) is a natural way to turn this into a deployable\n",
" service rather than a notebook-only demo.\n",
"- If accuracy on your own images is lower than on the test set, the fastest\n",
" fix is usually adding ~100-200 manually-annotated images from your actual\n",
" use case and fine-tuning `best.pt` further, rather than tuning\n",
" hyperparameters on the public dataset."
]
}
],
"metadata": {
"accelerator": "GPU",
"colab": {
"gpuType": "T4",
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}