{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 47088 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 113660 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 123040 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 113960 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "import cv2\n",
    "import numpy as np\n",
    "from matplotlib import pyplot as plt\n",
    "from PIL import Image, ImageOps\n",
    "\n",
    "# 1. Функция для упорядочивания точек\n",
    "def order_points(pts):\n",
    "    \"\"\"\n",
    "    Упорядочивает точки в порядке:\n",
    "    верхний левый, верхний правый, нижний правый, нижний левый\n",
    "    \"\"\"\n",
    "    rect = np.zeros((4, 2), dtype=\"float32\")\n",
    "\n",
    "    s = pts.sum(axis=1)\n",
    "    rect[0] = pts[np.argmin(s)] \n",
    "    rect[2] = pts[np.argmax(s)]\n",
    "\n",
    "    diff = np.diff(pts, axis=1)\n",
    "    rect[1] = pts[np.argmin(diff)] \n",
    "    rect[3] = pts[np.argmax(diff)] \n",
    "\n",
    "    return rect\n",
    "\n",
    "# 2. Функция для перспективного преобразования\n",
    "def get_transform(image, pts, padding=0.1):\n",
    "    \"\"\"\n",
    "    Применяет перспективное преобразование к области, определенной точками pts в изображении.\n",
    "    Уменьшает область захвата на заданный процент (padding).\n",
    "    Возвращает обрезанное и преобразованное изображение номерного знака.\n",
    "    \n",
    "    :param image: Исходное изображение\n",
    "    :param pts: Точки контура номерного знака (4 точки)\n",
    "    :param padding: Процент уменьшения области захвата (например, 0.1 для 10%)\n",
    "    \"\"\"\n",
    "    rect = order_points(pts)\n",
    "    (tl, tr, br, bl) = rect\n",
    "\n",
    "    widthA = np.linalg.norm(br - bl)\n",
    "    widthB = np.linalg.norm(tr - tl)\n",
    "    maxWidth = max(int(widthA), int(widthB))\n",
    "\n",
    "    heightA = np.linalg.norm(tr - br)\n",
    "    heightB = np.linalg.norm(tl - bl)\n",
    "    maxHeight = max(int(heightA), int(heightB))\n",
    "\n",
    "    center = np.mean(rect, axis=0)\n",
    "\n",
    "    vectors = rect - center\n",
    "\n",
    "    rect_shrinked = rect - vectors * padding\n",
    "\n",
    "    rect_shrinked[:, 0] = np.clip(rect_shrinked[:, 0], 0, image.shape[1] - 1)\n",
    "    rect_shrinked[:, 1] = np.clip(rect_shrinked[:, 1], 0, image.shape[0] - 1)\n",
    "\n",
    "    dst = np.array([\n",
    "        [0, 0],\n",
    "        [maxWidth - 1, 0],\n",
    "        [maxWidth - 1, maxHeight - 1],\n",
    "        [0, maxHeight - 1]\n",
    "    ], dtype=\"float32\")\n",
    "\n",
    "    M = cv2.getPerspectiveTransform(rect_shrinked, dst)\n",
    "\n",
    "    warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))\n",
    "\n",
    "    return warped\n",
    "\n",
    "def get_transformed_plate(image, bbox, padding=0.1):\n",
    "    \"\"\"\n",
    "    Выполняет перспективное преобразование для извлечения номерного знака из изображения.\n",
    "    Уменьшает область захвата на заданный процент (padding).\n",
    "    \n",
    "    :param image: Исходное изображение\n",
    "    :param bbox: Координаты ограничивающего прямоугольника (4 точки)\n",
    "    :param padding: Процент уменьшения области захвата (например, 0.1 для 10%)\n",
    "    :return: Преобразованное изображение номерного знака\n",
    "    \"\"\"\n",
    "    plate = get_transform(image, bbox, padding)\n",
    "    return plate\n",
    "\n",
    "# 4. Функция для изменения размера с сохранением соотношения сторон и добавлением отступов\n",
    "def resize_with_padding(image, size=28, padding=4, bg_color=255):\n",
    "    \"\"\"\n",
    "    Изменяет размер изображения до заданного размера с сохранением соотношения сторон\n",
    "    и добавлением отступов.\n",
    "    \n",
    "    :param image: Градация серого изображения символа (numpy array).\n",
    "    :param size: Целевой размер (ширина и высота) итогового изображения.\n",
    "    :param padding: Количество пикселей для отступов.\n",
    "    :param bg_color: Цвет фона (0 для чёрного, 255 для белого).\n",
    "    :return: Изображение размером size x size пикселей.\n",
    "    \"\"\"\n",
    "    # Преобразуем numpy array в PIL Image\n",
    "    pil_img = Image.fromarray(image)\n",
    "    \n",
    "    # Определяем текущие размеры\n",
    "    w, h = pil_img.size\n",
    "    \n",
    "    # Рассчитываем масштабирование\n",
    "    scale = (size - 2 * padding) / max(w, h)\n",
    "    new_w = int(w * scale)\n",
    "    new_h = int(h * scale)\n",
    "    \n",
    "    # Изменяем размер с сохранением соотношения сторон\n",
    "    pil_resized = pil_img.resize((new_w, new_h), Image.LANCZOS)\n",
    "    \n",
    "    # Создаём новое изображение с фоном\n",
    "    new_img = Image.new(\"L\", (size, size), color=bg_color)\n",
    "    \n",
    "    # Вычисляем позицию для центрирования\n",
    "    paste_x = (size - new_w) // 2\n",
    "    paste_y = (size - new_h) // 2\n",
    "    \n",
    "    # Вставляем изменённое изображение на фон\n",
    "    new_img.paste(pil_resized, (paste_x, paste_y))\n",
    "    \n",
    "    # Преобразуем обратно в numpy array\n",
    "    return np.array(new_img)\n",
    "\n",
    "# 5. Обновлённые маски\n",
    "mask_8 = [\n",
    "    (0.05, 0.25, 0.1, 0.5),  # Символ 1: Буква\n",
    "    (0.17, 0.25, 0.1, 0.5),  # Символ 2: Цифра\n",
    "    (0.29, 0.25, 0.1, 0.5),  # Символ 3: Цифра\n",
    "    (0.41, 0.25, 0.1, 0.5),  # Символ 4: Цифра\n",
    "    (0.53, 0.25, 0.1, 0.5),  # Символ 5: Буква\n",
    "    (0.65, 0.25, 0.1, 0.5),  # Символ 6: Буква\n",
    "    (0.80, 0.05, 0.1, 0.5),  # Символ 7: Региональная цифра 1 (выше и меньше)\n",
    "    (0.90, 0.05, 0.1, 0.5)   # Символ 8: Региональная цифра 2 (выше и меньше)\n",
    "]\n",
    "\n",
    "mask_9 = [\n",
    "    (0.05, 0.25, 0.1, 0.5),  # Символ 1: Буква\n",
    "    (0.17, 0.25, 0.1, 0.5),  # Символ 2: Цифра\n",
    "    (0.29, 0.25, 0.1, 0.5),  # Символ 3: Цифра\n",
    "    (0.41, 0.25, 0.1, 0.5),  # Символ 4: Цифра\n",
    "    (0.53, 0.25, 0.1, 0.5),  # Символ 5: Буква\n",
    "    (0.65, 0.25, 0.1, 0.5),  # Символ 6: Буква\n",
    "    (0.77, 0.05, 0.1, 0.6),   # Символ 7: Региональная цифра 1\n",
    "    (0.89, 0.05, 0.1, 0.6),   # Символ 8: Региональная цифра 2\n",
    "    (1.01, 0.05, 0.1, 0.6)    # Символ 9: Региональная цифра 3\n",
    "]\n",
    "\n",
    "# 6. Функция для извлечения символов\n",
    "def extract_symbols(image, masks):\n",
    "    \"\"\"\n",
    "    Извлекает символы из изображения на основе предоставленных масок.\n",
    "    \n",
    "    :param image: Градация серого изображения номерного знака.\n",
    "    :param masks: Список кортежей с определениями маски как (x_frac, y_frac, w_frac, h_frac).\n",
    "    :return: Список извлечённых и изменённых по размеру изображений символов.\n",
    "    \"\"\"\n",
    "    img_h, img_w = image.shape\n",
    "    symbols = []\n",
    "    for bbox in masks:\n",
    "        x_frac, y_frac, w_frac, h_frac = bbox\n",
    "        x = int(x_frac * img_w)\n",
    "        y = int(y_frac * img_h)\n",
    "        w = int(w_frac * img_w)\n",
    "        h = int(h_frac * img_h)\n",
    "        \n",
    "        # Убедимся, что область находится внутри границ изображения\n",
    "        x_end = min(x + w, img_w)\n",
    "        y_end = min(y + h, img_h)\n",
    "        \n",
    "        symbol = image[y:y_end, x:x+w]\n",
    "        if symbol.size == 0:\n",
    "            # Если область вне границ изображения, пропускаем\n",
    "            continue\n",
    "        # Изменяем размер с сохранением соотношения сторон и добавлением отступов\n",
    "        symbol_resized = resize_with_padding(symbol, size=28, padding=2, bg_color=255)\n",
    "        symbols.append(symbol_resized)\n",
    "    return symbols\n",
    "\n",
    "# 7. Функция для проверки наличия символа\n",
    "def is_symbol_present(symbol, threshold=250):\n",
    "    \"\"\"\n",
    "    Определяет, присутствует ли символ на изображении на основе средней интенсивности пикселей.\n",
    "    \n",
    "    :param symbol: Градация серого изображения символа.\n",
    "    :param threshold: Порог интенсивности для определения присутствия символа.\n",
    "    :return: Логическое значение, указывающее на наличие символа.\n",
    "    \"\"\"\n",
    "    return np.mean(symbol) < threshold  # Тёмные символы имеют меньшую среднюю интенсивность\n",
    "\n",
    "# 8. Основная функция обработки одной пластинки\n",
    "def process_plate(plate):\n",
    "    \"\"\"\n",
    "    Обрабатывает одно изображение номерного знака для извлечения отдельных символов на основе масок.\n",
    "    \n",
    "    :param plate: BGR изображение вырезанного номерного знака.\n",
    "    :return: Кортеж, содержащий обработанное изображение и список извлечённых символов.\n",
    "    \"\"\"\n",
    "    if plate is None:\n",
    "        return None, []\n",
    "    \n",
    "    # Преобразование в градации серого\n",
    "    gray_plate = cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY)\n",
    "\n",
    "    # Увеличение размера для консистентной обработки\n",
    "    resized_plate = cv2.resize(gray_plate, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)\n",
    "    img_h, img_w = resized_plate.shape\n",
    "\n",
    "    # Применение гауссового размытия для снижения шума\n",
    "    blurred = cv2.GaussianBlur(resized_plate, (3, 3), 0)\n",
    "\n",
    "    # Извлечение символов с использованием обеих масок\n",
    "    symbols_8 = extract_symbols(blurred, mask_8)\n",
    "    symbols_9 = extract_symbols(blurred, mask_9)\n",
    "\n",
    "    # Определение, какую маску использовать, основываясь на наличии третьей региональной цифры\n",
    "    if len(symbols_9) == 9 and is_symbol_present(symbols_9[-1]):\n",
    "        characters = symbols_9\n",
    "    else:\n",
    "        characters = symbols_8\n",
    "\n",
    "    return blurred, characters\n",
    "\n",
    "# 9. Функция для визуализации маски (опционально, для калибровки)\n",
    "def visualize_mask(image, masks):\n",
    "    \"\"\"\n",
    "    Отображает области маски на изображении для визуальной проверки.\n",
    "    \n",
    "    :param image: Градация серого изображения номерного знака.\n",
    "    :param masks: Список кортежей с определениями маски.\n",
    "    \"\"\"\n",
    "    img_h, img_w = image.shape\n",
    "    img_color = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)\n",
    "    for bbox in masks:\n",
    "        x_frac, y_frac, w_frac, h_frac = bbox\n",
    "        x = int(x_frac * img_w)\n",
    "        y = int(y_frac * img_h)\n",
    "        w = int(w_frac * img_w)\n",
    "        h = int(h_frac * img_h)\n",
    "        cv2.rectangle(img_color, (x, y), (x + w, y + h), (0, 255, 0), 2)\n",
    "    plt.imshow(cv2.cvtColor(img_color, cv2.COLOR_BGR2RGB))\n",
    "    plt.axis('off')\n",
    "    plt.show()\n",
    "\n",
    "# 10. Код для сбора processed_plates\n",
    "def gather_processed_plates(image_paths, padding=0.1):\n",
    "    \"\"\"\n",
    "    Загружает изображения, обнаруживает номерные знаки и извлекает их.\n",
    "    \n",
    "    :param image_paths: Список путей к изображениям.\n",
    "    :param padding: Процент уменьшения области захвата (например, 0.1 для 10%)\n",
    "    :return: Список преобразованных изображений номерных знаков.\n",
    "    \"\"\"\n",
    "    processed_plates = []\n",
    "    \n",
    "    for img_path in image_paths:\n",
    "        image = cv2.imread(img_path)\n",
    "        \n",
    "        if image is None:\n",
    "            print(f\"Не удалось загрузить изображение по пути: {img_path}\")\n",
    "            processed_plates.append(None)\n",
    "            continue\n",
    "\n",
    "        img_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n",
    "        \n",
    "        ret, thresh = cv2.threshold(img_gray, 100, 200, cv2.THRESH_TOZERO_INV)\n",
    "        \n",
    "        contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)\n",
    "\n",
    "        plate_found = False \n",
    "\n",
    "        for cnt in contours:\n",
    "            x, y, w, h = cv2.boundingRect(cnt)\n",
    "            area = w * h\n",
    "            aspectRatio = float(w) / h\n",
    "\n",
    "            if aspectRatio >= 3 and area > 600:\n",
    "                approx = cv2.approxPolyDP(cnt, 0.05 * cv2.arcLength(cnt, True), True)\n",
    "                \n",
    "                if len(approx) <= 4 and x > 15:\n",
    "                    rect = cv2.minAreaRect(cnt)\n",
    "                    box = cv2.boxPoints(rect)\n",
    "                    box = np.intp(box)\n",
    "\n",
    "                    plate = get_transformed_plate(image, box, padding=padding) \n",
    "\n",
    "                    processed_plates.append(plate)\n",
    "                    plate_found = True\n",
    "                    break \n",
    "\n",
    "        if not plate_found:\n",
    "            print(f\"Номерной знак не найден на изображении: {img_path}\")\n",
    "            processed_plates.append(None)\n",
    "    \n",
    "    return processed_plates\n",
    "\n",
    "# 11. Пример использования\n",
    "if __name__ == \"__main__\":\n",
    "    # Пути к изображениям\n",
    "    images = ['img/1.jpg', 'img/2.jpg', 'img/3.jpg']\n",
    "\n",
    "    # Сборка processed_plates\n",
    "    processed_plates = gather_processed_plates(images, padding=0.1)\n",
    "\n",
    "    num_plates = len(processed_plates)\n",
    "\n",
    "    cols = min(num_plates, 3)\n",
    "    rows = (num_plates + cols - 1) // cols \n",
    "\n",
    "    fig, axs = plt.subplots(rows, cols, figsize=(5 * cols, 5 * rows))\n",
    "    if num_plates == 1:\n",
    "        axs = [axs] \n",
    "    else:\n",
    "        axs = axs.flatten()\n",
    "\n",
    "    for i, plate in enumerate(processed_plates):\n",
    "        ax = axs[i]\n",
    "        if plate is not None:\n",
    "            if len(plate.shape) == 3:\n",
    "                plate_rgb = cv2.cvtColor(plate, cv2.COLOR_BGR2RGB)\n",
    "                ax.imshow(plate_rgb)\n",
    "            else:\n",
    "                ax.imshow(plate, cmap='gray')\n",
    "        else:\n",
    "            ax.text(0.5, 0.5, 'Номер не найден', horizontalalignment='center', verticalalignment='center', fontsize=12, color='red')\n",
    "        \n",
    "        ax.set_title(f'Номер {i+1}')\n",
    "        ax.axis('off')\n",
    "    \n",
    "    # Удаление лишних субплотов, если они есть\n",
    "    for j in range(i + 1, len(axs)):\n",
    "        fig.delaxes(axs[j])\n",
    "\n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "    # Обработка и отображение извлечённых символов\n",
    "    for plate_index, plate in enumerate(processed_plates):\n",
    "        # Проверка корректности загрузки изображения\n",
    "        if plate is None:\n",
    "            print(f\"Номерной знак {plate_index + 1} не обработан.\")\n",
    "            continue\n",
    "\n",
    "        # Обработка номерного знака для извлечения символов\n",
    "        processed_plate, characters = process_plate(plate)\n",
    "\n",
    "        # Отображение результатов\n",
    "        plt.figure(figsize=(15, 5))\n",
    "\n",
    "        # Исходный номерной знак\n",
    "        plt.subplot(1, 3, 1)\n",
    "        plt.title(\"Исходный Номерной Знак\")\n",
    "        plt.imshow(cv2.cvtColor(plate, cv2.COLOR_BGR2RGB))\n",
    "        plt.axis('off')\n",
    "\n",
    "        # Обработанное изображение (градации серого и размытие)\n",
    "        plt.subplot(1, 3, 2)\n",
    "        plt.title(\"Обработанный Номерной Знак\")\n",
    "        plt.imshow(processed_plate, cmap='gray')\n",
    "        plt.axis('off')\n",
    "\n",
    "        # Извлечённые символы\n",
    "        plt.subplot(1, 3, 3)\n",
    "        plt.title(\"Извлечённые Символы\")\n",
    "        if characters:\n",
    "            # Располагаем символы в горизонтальном ряду\n",
    "            symbols_grid = np.hstack(characters)\n",
    "            plt.imshow(symbols_grid, cmap='gray')\n",
    "        else:\n",
    "            plt.text(0.5, 0.5, 'Символы не обнаружены', ha='center', va='center', fontsize=12)\n",
    "        plt.axis('off')\n",
    "\n",
    "        plt.suptitle(f\"Результат Обработки Номерного Знака {plate_index + 1}\")\n",
    "        plt.show()\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 47088 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 120424 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 129784 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 121768 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "import os\n",
    "import cv2\n",
    "import numpy as np\n",
    "from matplotlib import pyplot as plt\n",
    "from PIL import Image, ImageOps\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "from torchvision import transforms\n",
    "\n",
    "# 1. Функция для упорядочивания точек\n",
    "def order_points(pts):\n",
    "    \"\"\"\n",
    "    Упорядочивает точки в порядке:\n",
    "    верхний левый, верхний правый, нижний правый, нижний левый\n",
    "    \"\"\"\n",
    "    rect = np.zeros((4, 2), dtype=\"float32\")\n",
    "\n",
    "    s = pts.sum(axis=1)\n",
    "    rect[0] = pts[np.argmin(s)] \n",
    "    rect[2] = pts[np.argmax(s)]\n",
    "\n",
    "    diff = np.diff(pts, axis=1)\n",
    "    rect[1] = pts[np.argmin(diff)] \n",
    "    rect[3] = pts[np.argmax(diff)] \n",
    "\n",
    "    return rect\n",
    "\n",
    "# 2. Функция для перспективного преобразования\n",
    "def get_transform(image, pts, padding=0.1):\n",
    "    \"\"\"\n",
    "    Применяет перспективное преобразование к области, определенной точками pts в изображении.\n",
    "    Уменьшает область захвата на заданный процент (padding).\n",
    "    Возвращает обрезанное и преобразованное изображение номерного знака.\n",
    "    \n",
    "    :param image: Исходное изображение\n",
    "    :param pts: Точки контура номерного знака (4 точки)\n",
    "    :param padding: Процент уменьшения области захвата (например, 0.1 для 10%)\n",
    "    \"\"\"\n",
    "    rect = order_points(pts)\n",
    "    (tl, tr, br, bl) = rect\n",
    "\n",
    "    widthA = np.linalg.norm(br - bl)\n",
    "    widthB = np.linalg.norm(tr - tl)\n",
    "    maxWidth = max(int(widthA), int(widthB))\n",
    "\n",
    "    heightA = np.linalg.norm(tr - br)\n",
    "    heightB = np.linalg.norm(tl - bl)\n",
    "    maxHeight = max(int(heightA), int(heightB))\n",
    "\n",
    "    center = np.mean(rect, axis=0)\n",
    "\n",
    "    vectors = rect - center\n",
    "\n",
    "    rect_shrinked = rect - vectors * padding\n",
    "\n",
    "    rect_shrinked[:, 0] = np.clip(rect_shrinked[:, 0], 0, image.shape[1] - 1)\n",
    "    rect_shrinked[:, 1] = np.clip(rect_shrinked[:, 1], 0, image.shape[0] - 1)\n",
    "\n",
    "    dst = np.array([\n",
    "        [0, 0],\n",
    "        [maxWidth - 1, 0],\n",
    "        [maxWidth - 1, maxHeight - 1],\n",
    "        [0, maxHeight - 1]\n",
    "    ], dtype=\"float32\")\n",
    "\n",
    "    M = cv2.getPerspectiveTransform(rect_shrinked, dst)\n",
    "\n",
    "    warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))\n",
    "\n",
    "    return warped\n",
    "\n",
    "def get_transformed_plate(image, bbox, padding=0.1):\n",
    "    \"\"\"\n",
    "    Выполняет перспективное преобразование для извлечения номерного знака из изображения.\n",
    "    Уменьшает область захвата на заданный процент (padding).\n",
    "    \n",
    "    :param image: Исходное изображение\n",
    "    :param bbox: Координаты ограничивающего прямоугольника (4 точки)\n",
    "    :param padding: Процент уменьшения области захвата (например, 0.1 для 10%)\n",
    "    :return: Преобразованное изображение номерного знака\n",
    "    \"\"\"\n",
    "    plate = get_transform(image, bbox, padding)\n",
    "    return plate\n",
    "\n",
    "# 4. Функция для изменения размера с сохранением соотношения сторон и добавлением отступов\n",
    "def resize_with_padding(image, size=28, padding=4, bg_color=255):\n",
    "    \"\"\"\n",
    "    Изменяет размер изображения до заданного размера с сохранением соотношения сторон\n",
    "    и добавлением отступов.\n",
    "    \n",
    "    :param image: Градация серого изображения символа (numpy array).\n",
    "    :param size: Целевой размер (ширина и высота) итогового изображения.\n",
    "    :param padding: Количество пикселей для отступов.\n",
    "    :param bg_color: Цвет фона (0 для чёрного, 255 для белого).\n",
    "    :return: Изображение размером size x size пикселей.\n",
    "    \"\"\"\n",
    "    # Преобразуем numpy array в PIL Image\n",
    "    pil_img = Image.fromarray(image)\n",
    "    \n",
    "    # Определяем текущие размеры\n",
    "    w, h = pil_img.size\n",
    "    \n",
    "    # Рассчитываем масштабирование\n",
    "    scale = (size - 2 * padding) / max(w, h)\n",
    "    new_w = int(w * scale)\n",
    "    new_h = int(h * scale)\n",
    "    \n",
    "    # Изменяем размер с сохранением соотношения сторон\n",
    "    pil_resized = pil_img.resize((new_w, new_h), Image.LANCZOS)\n",
    "    \n",
    "    # Создаём новое изображение с фоном\n",
    "    new_img = Image.new(\"L\", (size, size), color=bg_color)\n",
    "    \n",
    "    # Вычисляем позицию для центрирования\n",
    "    paste_x = (size - new_w) // 2\n",
    "    paste_y = (size - new_h) // 2\n",
    "    \n",
    "    # Вставляем изменённое изображение на фон\n",
    "    new_img.paste(pil_resized, (paste_x, paste_y))\n",
    "    \n",
    "    # Преобразуем обратно в numpy array\n",
    "    return np.array(new_img)\n",
    "\n",
    "# 5. Обновлённые маски\n",
    "mask_8 = [\n",
    "    (0.05, 0.25, 0.1, 0.5),  # Символ 1: Буква\n",
    "    (0.17, 0.25, 0.1, 0.5),  # Символ 2: Цифра\n",
    "    (0.29, 0.25, 0.1, 0.5),  # Символ 3: Цифра\n",
    "    (0.41, 0.25, 0.1, 0.5),  # Символ 4: Цифра\n",
    "    (0.53, 0.25, 0.1, 0.5),  # Символ 5: Буква\n",
    "    (0.65, 0.25, 0.1, 0.5),  # Символ 6: Буква\n",
    "    (0.80, 0.05, 0.1, 0.5),  # Символ 7: Региональная цифра 1 (выше и меньше)\n",
    "    (0.90, 0.05, 0.1, 0.5)   # Символ 8: Региональная цифра 2 (выше и меньше)\n",
    "]\n",
    "\n",
    "mask_9 = [\n",
    "    (0.05, 0.25, 0.1, 0.5),  # Символ 1: Буква\n",
    "    (0.17, 0.25, 0.1, 0.5),  # Символ 2: Цифра\n",
    "    (0.29, 0.25, 0.1, 0.5),  # Символ 3: Цифра\n",
    "    (0.41, 0.25, 0.1, 0.5),  # Символ 4: Цифра\n",
    "    (0.53, 0.25, 0.1, 0.5),  # Символ 5: Буква\n",
    "    (0.65, 0.25, 0.1, 0.5),  # Символ 6: Буква\n",
    "    (0.77, 0.05, 0.1, 0.6),  # Символ 7: Региональная цифра 1\n",
    "    (0.89, 0.05, 0.1, 0.6),  # Символ 8: Региональная цифра 2\n",
    "    (1.01, 0.05, 0.1, 0.6)   # Символ 9: Региональная цифра 3\n",
    "]\n",
    "\n",
    "# 6. Функция для извлечения символов\n",
    "def extract_symbols(image, masks):\n",
    "    \"\"\"\n",
    "    Извлекает символы из изображения на основе предоставленных масок.\n",
    "    \n",
    "    :param image: Градация серого изображения номерного знака.\n",
    "    :param masks: Список кортежей с определениями маски как (x_frac, y_frac, w_frac, h_frac).\n",
    "    :return: Список извлечённых и изменённых по размеру изображений символов.\n",
    "    \"\"\"\n",
    "    img_h, img_w = image.shape\n",
    "    symbols = []\n",
    "    for bbox in masks:\n",
    "        x_frac, y_frac, w_frac, h_frac = bbox\n",
    "        x = int(x_frac * img_w)\n",
    "        y = int(y_frac * img_h)\n",
    "        w = int(w_frac * img_w)\n",
    "        h = int(h_frac * img_h)\n",
    "        \n",
    "        # Убедимся, что область находится внутри границ изображения\n",
    "        x_end = min(x + w, img_w)\n",
    "        y_end = min(y + h, img_h)\n",
    "        \n",
    "        symbol = image[y:y_end, x:x+w]\n",
    "        if symbol.size == 0:\n",
    "            # Если область вне границ изображения, пропускаем\n",
    "            continue\n",
    "        # Изменяем размер с сохранением соотношения сторон и добавлением отступов\n",
    "        symbol_resized = resize_with_padding(symbol, size=28, padding=2, bg_color=255)\n",
    "        symbols.append(symbol_resized)\n",
    "    return symbols\n",
    "\n",
    "# 7. Функция для проверки наличия символа\n",
    "def is_symbol_present(symbol, threshold=250):\n",
    "    \"\"\"\n",
    "    Определяет, присутствует ли символ на изображении на основе средней интенсивности пикселей.\n",
    "    \n",
    "    :param symbol: Градация серого изображения символа.\n",
    "    :param threshold: Порог интенсивности для определения присутствия символа.\n",
    "    :return: Логическое значение, указывающее на наличие символа.\n",
    "    \"\"\"\n",
    "    return np.mean(symbol) < threshold  # Тёмные символы имеют меньшую среднюю интенсивность\n",
    "\n",
    "# 8. Основная функция обработки одной пластинки\n",
    "def process_plate(plate):\n",
    "    \"\"\"\n",
    "    Обрабатывает одно изображение номерного знака для извлечения отдельных символов на основе масок.\n",
    "    \n",
    "    :param plate: BGR изображение вырезанного номерного знака.\n",
    "    :return: Кортеж, содержащий обработанное изображение, список извлечённых символов и ожидаемые типы символов.\n",
    "    \"\"\"\n",
    "    if plate is None:\n",
    "        return None, [], []\n",
    "    \n",
    "    # Преобразование в градации серого\n",
    "    gray_plate = cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY)\n",
    "\n",
    "    # Увеличение размера для консистентной обработки\n",
    "    resized_plate = cv2.resize(gray_plate, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)\n",
    "    img_h, img_w = resized_plate.shape\n",
    "\n",
    "    # Применение гауссового размытия для снижения шума\n",
    "    blurred = cv2.GaussianBlur(resized_plate, (3, 3), 0)\n",
    "\n",
    "    # Извлечение символов с использованием обеих масок\n",
    "    symbols_8 = extract_symbols(blurred, mask_8)\n",
    "    symbols_9 = extract_symbols(blurred, mask_9)\n",
    "\n",
    "    # Определение, какую маску использовать, основываясь на наличии третьей региональной цифры\n",
    "    if len(symbols_9) == 9 and is_symbol_present(symbols_9[-1]):\n",
    "        characters = symbols_9\n",
    "        expected_types = ['letter', 'digit', 'digit', 'digit', 'letter', 'letter', 'digit', 'digit', 'digit']\n",
    "    else:\n",
    "        characters = symbols_8\n",
    "        expected_types = ['letter', 'digit', 'digit', 'digit', 'letter', 'letter', 'digit', 'digit']\n",
    "\n",
    "    return blurred, characters, expected_types\n",
    "\n",
    "# 9. Функция для визуализации маски (опционально, для калибровки)\n",
    "def visualize_mask(image, masks):\n",
    "    \"\"\"\n",
    "    Отображает области маски на изображении для визуальной проверки.\n",
    "    \n",
    "    :param image: Градация серого изображения номерного знака.\n",
    "    :param masks: Список кортежей с определениями маски.\n",
    "    \"\"\"\n",
    "    img_h, img_w = image.shape\n",
    "    img_color = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)\n",
    "    for bbox in masks:\n",
    "        x_frac, y_frac, w_frac, h_frac = bbox\n",
    "        x = int(x_frac * img_w)\n",
    "        y = int(y_frac * img_h)\n",
    "        w = int(w_frac * img_w)\n",
    "        h = int(h_frac * img_h)\n",
    "        cv2.rectangle(img_color, (x, y), (x + w, y + h), (0, 255, 0), 2)\n",
    "    plt.imshow(cv2.cvtColor(img_color, cv2.COLOR_BGR2RGB))\n",
    "    plt.axis('off')\n",
    "    plt.show()\n",
    "\n",
    "# 10. Код для сбора processed_plates\n",
    "def gather_processed_plates(image_paths, padding=0.1):\n",
    "    \"\"\"\n",
    "    Загружает изображения, обнаруживает номерные знаки и извлекает их.\n",
    "    \n",
    "    :param image_paths: Список путей к изображениям.\n",
    "    :param padding: Процент уменьшения области захвата (например, 0.1 для 10%)\n",
    "    :return: Список преобразованных изображений номерных знаков.\n",
    "    \"\"\"\n",
    "    processed_plates = []\n",
    "    \n",
    "    for img_path in image_paths:\n",
    "        image = cv2.imread(img_path)\n",
    "        \n",
    "        if image is None:\n",
    "            print(f\"Не удалось загрузить изображение по пути: {img_path}\")\n",
    "            processed_plates.append(None)\n",
    "            continue\n",
    "\n",
    "        img_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n",
    "        \n",
    "        ret, thresh = cv2.threshold(img_gray, 100, 200, cv2.THRESH_TOZERO_INV)\n",
    "        \n",
    "        contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)\n",
    "\n",
    "        plate_found = False \n",
    "\n",
    "        for cnt in contours:\n",
    "            x, y, w, h = cv2.boundingRect(cnt)\n",
    "            area = w * h\n",
    "            aspectRatio = float(w) / h\n",
    "\n",
    "            if aspectRatio >= 3 and area > 600:\n",
    "                approx = cv2.approxPolyDP(cnt, 0.05 * cv2.arcLength(cnt, True), True)\n",
    "                \n",
    "                if len(approx) <= 4 and x > 15:\n",
    "                    rect = cv2.minAreaRect(cnt)\n",
    "                    box = cv2.boxPoints(rect)\n",
    "                    box = np.intp(box)\n",
    "\n",
    "                    plate = get_transformed_plate(image, box, padding=padding) \n",
    "\n",
    "                    processed_plates.append(plate)\n",
    "                    plate_found = True\n",
    "                    break \n",
    "\n",
    "        if not plate_found:\n",
    "            print(f\"Номерной знак не найден на изображении: {img_path}\")\n",
    "            processed_plates.append(None)\n",
    "    \n",
    "    return processed_plates\n",
    "\n",
    "# 11. Функция для распознавания символов с учётом ожидаемого типа\n",
    "def recognize_plate_number(characters, expected_types, model, transform, CLASSES, letter_classes, digit_classes, templates, device=\"cpu\"):\n",
    "    \"\"\"\n",
    "    Распознает номерной знак, учитывая ожидаемый тип каждого символа, используя модель и шаблоны.\n",
    "\n",
    "    :param characters: Список изображений символов.\n",
    "    :param expected_types: Список ожидаемых типов символов ('letter' или 'digit').\n",
    "    :param model: Обученная модель для распознавания символов.\n",
    "    :param transform: Преобразования для подготовки изображений перед подачей в модель.\n",
    "    :param CLASSES: Строка с классами.\n",
    "    :param letter_classes: Список букв.\n",
    "    :param digit_classes: Список цифр.\n",
    "    :param templates: Словарь с шаблонными изображениями.\n",
    "    :param device: Устройство для вычислений ('cpu' или 'cuda').\n",
    "    :return: Кортеж из двух строк: распознанный номер моделью и распознанный номер шаблонами.\n",
    "    \"\"\"\n",
    "    recognized_number_model = \"\"\n",
    "    recognized_number_template = \"\"\n",
    "    for idx, (symbol_img, expected_type) in enumerate(zip(characters, expected_types)):\n",
    "        # --- Модельное распознавание ---\n",
    "        # Преобразование изображения символа в формат, подходящий для модели\n",
    "        pil_img = Image.fromarray(symbol_img)\n",
    "        input_tensor = transform(pil_img).unsqueeze(0).to(device)  # Добавляем размерность батча\n",
    "\n",
    "        with torch.no_grad():\n",
    "            outputs = model(input_tensor)\n",
    "            probabilities = torch.softmax(outputs, dim=1).cpu().numpy()[0]\n",
    "\n",
    "        if expected_type == 'letter':\n",
    "            valid_indices = [i for i, c in enumerate(CLASSES) if c in letter_classes]\n",
    "        elif expected_type == 'digit':\n",
    "            valid_indices = [i for i, c in enumerate(CLASSES) if c in digit_classes]\n",
    "        else:\n",
    "            valid_indices = list(range(len(CLASSES)))  # Если тип не определен, рассматриваем все классы\n",
    "\n",
    "        if not valid_indices:\n",
    "            recognized_char_model = '?'\n",
    "        else:\n",
    "            # Выбираем индекс с максимальной вероятностью среди допустимых\n",
    "            max_prob_idx = valid_indices[np.argmax(probabilities[valid_indices])]\n",
    "            recognized_char_model = CLASSES[max_prob_idx]\n",
    "\n",
    "        recognized_number_model += recognized_char_model\n",
    "\n",
    "        # --- Шаблонное сопоставление ---\n",
    "        recognized_char_template = pattern_match_symbol(symbol_img, templates)\n",
    "        recognized_number_template += recognized_char_template\n",
    "\n",
    "    return recognized_number_model, recognized_number_template\n",
    "\n",
    "# 12. Загрузка и подготовка модели\n",
    "class SimpleCNN(nn.Module):\n",
    "    def __init__(self, num_classes):\n",
    "        super(SimpleCNN, self).__init__()\n",
    "        self.conv_layers = nn.Sequential(\n",
    "            nn.Conv2d(1, 32, kernel_size=3, padding=1),  # Увеличиваем количество фильтров\n",
    "            nn.LeakyReLU(negative_slope=0.1),  # Используем LeakyReLU вместо ReLU\n",
    "            nn.BatchNorm2d(32),  # Нормализация\n",
    "            nn.MaxPool2d(kernel_size=2, stride=2),\n",
    "            \n",
    "            nn.Conv2d(32, 64, kernel_size=3, padding=1),\n",
    "            nn.ELU(),  # Используем ELU вместо ReLU\n",
    "            nn.BatchNorm2d(64),\n",
    "            nn.MaxPool2d(kernel_size=2, stride=2),\n",
    "            \n",
    "            nn.Conv2d(64, 128, kernel_size=3, padding=1),\n",
    "            nn.SiLU(),  # Используем SiLU (Swish) вместо ReLU\n",
    "            nn.BatchNorm2d(128),\n",
    "            nn.MaxPool2d(kernel_size=2, stride=2)\n",
    "        )\n",
    "        self.fc_layers = nn.Sequential(\n",
    "            nn.Linear(128 * 3 * 3, 256),\n",
    "            nn.GELU(),  # Используем GELU для полносвязного слоя\n",
    "            nn.Dropout(0.5),\n",
    "            nn.Linear(256, num_classes)\n",
    "        )\n",
    "\n",
    "    def forward(self, x):\n",
    "        x = self.conv_layers(x)\n",
    "        x = x.view(x.size(0), -1)\n",
    "        x = self.fc_layers(x)\n",
    "        return x\n",
    "    \n",
    "# Определение классов и групп\n",
    "CLASSES = \"ABEKMHOPCTYX0123456789\"\n",
    "NUM_CLASSES = len(CLASSES)\n",
    "DEVICE = \"cpu\" \n",
    "letter_classes = list(\"ABEKMHOPCTYX\")\n",
    "digit_classes = list(\"0123456789\")\n",
    "\n",
    "# Загрузка модели\n",
    "model = SimpleCNN(NUM_CLASSES).to(DEVICE)\n",
    "model_path = \"best_model.pth\"\n",
    "if not os.path.exists(model_path):\n",
    "    raise FileNotFoundError(f\"Файл модели '{model_path}' не найден.\")\n",
    "model.load_state_dict(torch.load(model_path, map_location=DEVICE)) \n",
    "model.eval()\n",
    "\n",
    "# Определение преобразований\n",
    "transform = transforms.Compose([\n",
    "    transforms.Grayscale(),\n",
    "    transforms.ToTensor(),\n",
    "    transforms.Normalize((0.5,), (0.5,))\n",
    "])\n",
    "\n",
    "# 13. Функция для загрузки шаблонов\n",
    "def load_templates(template_dir, classes, transform):\n",
    "    \"\"\"\n",
    "    Загрузить и предварительно обработать шаблонные изображения из указанной директории.\n",
    "\n",
    "    :param template_dir: Путь к директории с шаблонными изображениями.\n",
    "    :param classes: Строка с классами.\n",
    "    :param transform: Преобразования для подготовки изображений.\n",
    "    :return: Словарь, сопоставляющий класс с изображением шаблона.\n",
    "    \"\"\"\n",
    "    templates = {}\n",
    "    for cls in classes:\n",
    "        template_path = os.path.join(template_dir, f\"{cls}.png\")\n",
    "        if not os.path.exists(template_path):\n",
    "            print(f\"Шаблон для класса '{cls}' не найден по пути: {template_path}\")\n",
    "            continue\n",
    "        template_img = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE)\n",
    "        if template_img is None:\n",
    "            print(f\"Не удалось загрузить шаблонное изображение для класса '{cls}' из {template_path}\")\n",
    "            continue\n",
    "        # Предварительная обработка: изменение размера и нормализация\n",
    "        template_resized = resize_with_padding(template_img, size=28, padding=2, bg_color=255)\n",
    "        templates[cls] = template_resized\n",
    "    return templates\n",
    "\n",
    "# 14. Функция для шаблонного сопоставления\n",
    "def pattern_match_symbol(symbol_img, templates):\n",
    "    \"\"\"\n",
    "    Выполняет шаблонное сопоставление для распознавания символа.\n",
    "\n",
    "    :param symbol_img: Градация серого изображения символа (numpy array).\n",
    "    :param templates: Словарь с шаблонными изображениями.\n",
    "    :return: Распознанный класс символа или '?'.\n",
    "    \"\"\"\n",
    "    best_match = '?'\n",
    "    best_score = -1  # Инициализируем с очень низкого значения\n",
    "    for cls, template in templates.items():\n",
    "        # Убедимся, что оба изображения в формате uint8\n",
    "        symbol = symbol_img.astype(np.uint8)\n",
    "        template = template.astype(np.uint8)\n",
    "        # Применяем пороговую обработку, если необходимо\n",
    "        # Для шаблонного сопоставления оба изображения должны быть однотонными\n",
    "        # Например, бинаризация\n",
    "        _, symbol_thresh = cv2.threshold(symbol, 128, 255, cv2.THRESH_BINARY_INV)\n",
    "        _, template_thresh = cv2.threshold(template, 128, 255, cv2.THRESH_BINARY_INV)\n",
    "        # Выполняем шаблонное сопоставление\n",
    "        res = cv2.matchTemplate(symbol_thresh, template_thresh, cv2.TM_CCOEFF_NORMED)\n",
    "        score = res[0][0]\n",
    "        if score > best_score:\n",
    "            best_score = score\n",
    "            best_match = cls\n",
    "    # Определяем порог для принятия совпадения\n",
    "    threshold = 0.3  # Этот параметр можно настроить\n",
    "    if best_score < threshold:\n",
    "        return '?'\n",
    "    return best_match\n",
    "\n",
    "# 15. Пример использования\n",
    "if __name__ == \"__main__\":\n",
    "    # Пути к изображениям\n",
    "    images = ['img/1.jpg', 'img/2.jpg', 'img/3.jpg'] # Замените на ваши пути\n",
    "\n",
    "    # Путь к директории с шаблонами\n",
    "    template_dir = 'templates'\n",
    "\n",
    "    # Загрузка шаблонов\n",
    "    templates = load_templates(template_dir, CLASSES, transform)\n",
    "\n",
    "    # Сборка processed_plates\n",
    "    processed_plates = gather_processed_plates(images, padding=0.1)\n",
    "\n",
    "    num_plates = len(processed_plates)\n",
    "\n",
    "    cols = min(num_plates, 3)\n",
    "    rows = (num_plates + cols - 1) // cols \n",
    "\n",
    "    fig, axs = plt.subplots(rows, cols, figsize=(5 * cols, 5 * rows))\n",
    "    if num_plates == 1:\n",
    "        axs = [axs] \n",
    "    else:\n",
    "        axs = axs.flatten()\n",
    "\n",
    "    for i, plate in enumerate(processed_plates):\n",
    "        ax = axs[i]\n",
    "        if plate is not None:\n",
    "            if len(plate.shape) == 3:\n",
    "                plate_rgb = cv2.cvtColor(plate, cv2.COLOR_BGR2RGB)\n",
    "                ax.imshow(plate_rgb)\n",
    "            else:\n",
    "                ax.imshow(plate, cmap='gray')\n",
    "        else:\n",
    "            ax.text(0.5, 0.5, 'Номер не найден', horizontalalignment='center', verticalalignment='center', fontsize=12, color='red')\n",
    "        \n",
    "        ax.set_title(f'Номер {i+1}')\n",
    "        ax.axis('off')\n",
    "    \n",
    "    # Удаление лишних субплотов, если они есть\n",
    "    for j in range(i + 1, len(axs)):\n",
    "        fig.delaxes(axs[j])\n",
    "\n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "    # Обработка и отображение извлечённых символов\n",
    "    for plate_index, plate in enumerate(processed_plates):\n",
    "        # Проверка корректности загрузки изображения\n",
    "        if plate is None:\n",
    "            print(f\"Номерной знак {plate_index + 1} не обработан.\")\n",
    "            continue\n",
    "\n",
    "        # Обработка номерного знака для извлечения символов\n",
    "        processed_plate, characters, expected_types = process_plate(plate)\n",
    "\n",
    "        # Распознавание номера\n",
    "        recognized_number_model, recognized_number_template = recognize_plate_number(\n",
    "            characters, \n",
    "            expected_types, \n",
    "            model, \n",
    "            transform, \n",
    "            CLASSES, \n",
    "            letter_classes, \n",
    "            digit_classes, \n",
    "            templates, \n",
    "            device=DEVICE\n",
    "        )\n",
    "\n",
    "        # Отображение результатов\n",
    "        plt.figure(figsize=(15, 5))\n",
    "\n",
    "        # Исходный номерной знак\n",
    "        plt.subplot(1, 3, 1)\n",
    "        plt.title(\"Исходный Номерной Знак\")\n",
    "        if len(plate.shape) == 3:\n",
    "            plt.imshow(cv2.cvtColor(plate, cv2.COLOR_BGR2RGB))\n",
    "        else:\n",
    "            plt.imshow(plate, cmap='gray')\n",
    "        plt.axis('off')\n",
    "\n",
    "        # Обработанное изображение (градации серого и размытие)\n",
    "        plt.subplot(1, 3, 2)\n",
    "        plt.title(\"Обработанный Номерной Знак\")\n",
    "        plt.imshow(processed_plate, cmap='gray')\n",
    "        plt.axis('off')\n",
    "\n",
    "        # Извлечённые символы\n",
    "        plt.subplot(1, 3, 3)\n",
    "        plt.title(\"Извлечённые Символы\")\n",
    "        if characters:\n",
    "            # Располагаем символы в горизонтальном ряду\n",
    "            symbols_grid = np.hstack(characters)\n",
    "            plt.imshow(symbols_grid, cmap='gray')\n",
    "        else:\n",
    "            plt.text(0.5, 0.5, 'Символы не обнаружены', ha='center', va='center', fontsize=12)\n",
    "        plt.axis('off')\n",
    "\n",
    "        # Добавление заголовка с распознанным номером обоими методами\n",
    "        plt.suptitle(f\"Результат Обработки Номерного Знака {plate_index + 1}:\\n\"\n",
    "                     f\"Модель: {recognized_number_model}\\n\"\n",
    "                     f\"Шаблоны: {recognized_number_template}\", fontsize=16, color='blue')\n",
    "\n",
    "        plt.show()\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv",
   "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.9.13"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
