YOLOv8でナンバープレートを検出してAI-OCRで読み取ってみました。 〜ファインチューニングに使用したデータは、撮影した写真ではなく、Pythonで生成した画像(30,000枚・192,000アノテーション)です〜

2023.10.02

1 はじめに

CX 事業本部 delivery部の平内(SIN)です。

最近は、精算が終わっていれば、出口のゲートが自動的に開いてスムーズに退場できる駐車場をよく見かけます。これは、出入口のカメラでナンバープレートを読み取って、駐車券と紐づけることで実現されているサービスだと思いますが、非常に体験の良いシステムだと感動しています。

ナンバープレートの読み取りを軽易に行うことができれば、この他にも色々と気持ちいいサービスが作れるのでは?と思っています。

という事で、今回は、ナンバープレートの読み取りを試してみました。最初に動作している様子を御覧ください。 写真からナンバープレートの位置を検出し、その部分をAI-OCRにかけて番号等を読みとっています。

今回作成した「ナンバープレート検出のモデル」は、多少斜めからの撮影や、小さく写ったものでも検出は可能なのですが、最終的な目的を「ナンバープレートも文字の読み取り」としたため、正面で、かつ、一定のサイズ(解像度)でナンバープレートが写った画像を対象として動作確認をしています。

2 ナンバープレートの生成

タイトルにありますように、YOLOv8をファインチューニングするためのデータセット画像は、プログラムで生成しています。

NumberPlateクラスは、カテゴリ(4種類)を指定することで、ランダムな内容でナンバープレート画像を生成します。

from number_plate import NumberPlate

categody=0 # 0:普通車(自家用) 1:普通車(事業用) 2:軽自動車(自家用) 3:軽自動車(事業用)

number_plate = NumberPlate()
img = number_plate.generate(category)
number_plate.py
"""
ナンバープレート画像を生成するクラス

number_plate = NumberPlate()

img = number_plate.generate(category)

category: 分類
0:自家用普通車
1:事業用普通車
2:自家用軽自動車
3:事業用軽自動車
"""
import random
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont


class NumberPlate:
    def __init__(self):
        self.__fontFace_0 = "./TrmFontJB.ttf"
        self.__fontFace_1 = "./hiiragi_marugo_ProN_W4.ttc"

        black = (0, 0, 0)
        green = (0, 60, 0)
        white = (240, 240, 240)
        yellow = (0, 240, 240)
        gray = (128, 128, 128)

        self.__height = 220
        self.__width = 440
        self.__margin = 4

        self.__back_color_list = [white, green, yellow, black]
        self.__text_color_list = [green, white, black, yellow]
        self.__gray_color = gray

    def __drawText(
        self, img, text, position, fontFace, fontScale, text_color, stroke_width
    ):
        (r, g, b) = text_color
        color = (b, g, r)
        # cv2 -> PIL
        imgPIL = Image.fromarray(cv2.cvtColor(img.copy(), cv2.COLOR_BGR2RGB))
        draw = ImageDraw.Draw(imgPIL)
        fontPIL = ImageFont.truetype(font=fontFace, size=fontScale)
        draw.text(
            xy=position, text=text, fill=color, font=fontPIL, stroke_width=stroke_width
        )
        # PIL -> cv2
        return cv2.cvtColor(np.array(imgPIL, dtype=np.uint8), cv2.COLOR_RGB2BGR)

    def __generate_place(self):
        list = [
            "札幌",
            "釧路",
            "山形",
            "水戸",
            "群馬",
            "長野",
            "大阪",
            "京都",
            "姫路",
            "多摩",
            "埼玉",
            "室蘭",
            "千葉",
            "釧路",
            "川崎",
        ]

        index = random.randint(0, len(list) - 1)

        return list[index]

    def __generate_classification(self):
        list = [
            "500",
            "300",
            "551",
            "512",
            "330",
            "331",
            "50",
            "30",
            "55",
            "51",
            "33",
            "31",
        ]
        index = random.randint(0, len(list) - 1)
        return list[index]

    def __generate_hiragana(self):
        list = ["あ", "き", "い", "を", "う", "く", "か"]
        index = random.randint(0, len(list) - 1)
        return list[index]

    def __generate_number(self):
        number = random.randint(1, 9999)
        isHead = True

        y = int(number / 1000)
        if isHead and y == 0:
            number_text = "・"
        else:
            isHead = False
            number_text = str(y)
        number -= y * 1000

        y = int(number / 100)
        if isHead and y == 0:
            number_text += "・"
        else:
            isHead = False
            number_text += str(y)
        number -= y * 100

        if isHead:
            number_text += " "
        else:
            if random.randint(0, 3) != 0:
                number_text += "-"
            else:
                number_text += " "

        y = int(number / 10)
        if isHead and y == 0:
            number_text += "・"
        else:
            number_text += str(y)

        number -= y * 10
        number_text += str(number)

        return number_text

    def generate(self, category):
        img = np.full((self.__height, self.__width, 3), 0, dtype=np.uint8)
        cv2.rectangle(
            img,
            (0, 0),
            (self.__width, self.__height),
            self.__back_color_list[category],
            thickness=-1,
        )
        cv2.rectangle(
            img,
            (0, 0),
            (self.__width - 2, self.__height - 2),
            self.__gray_color,
            thickness=1,
        )
        cv2.rectangle(
            img,
            (self.__margin, self.__margin),
            (self.__width - self.__margin * 2, self.__height - self.__margin * 2),
            self.__gray_color,
            thickness=1,
        )

        position = (100, 10)
        fontScale = 55
        stroke_width = 1
        img = self.__drawText(
            img,
            self.__generate_place(),
            position,
            self.__fontFace_1,
            fontScale,
            self.__text_color_list[category],
            stroke_width,
        )

        position = (230, 10)
        fontScale = 65
        stroke_width = 0
        img = self.__drawText(
            img,
            self.__generate_classification(),
            position,
            self.__fontFace_0,
            fontScale,
            self.__text_color_list[category],
            stroke_width,
        )
        position = (20, 80)
        fontScale = 150
        stroke_width = 0
        img = self.__drawText(
            img,
            self.__generate_hiragana(),
            position,
            self.__fontFace_0,
            fontScale,
            self.__text_color_list[category],
            stroke_width,
        )
        position = (80, 80)
        fontScale = 130
        stroke_width = 0
        img = self.__drawText(
            img,
            self.__generate_number(),
            position,
            self.__fontFace_0,
            fontScale,
            self.__text_color_list[category],
            stroke_width,
        )

        radius = 6
        position = (80, 30)
        img = cv2.circle(
            img, position, radius, self.__gray_color, thickness=-1, lineType=cv2.LINE_AA
        )
        position = (360, 30)
        img = cv2.circle(
            img, position, radius, self.__gray_color, thickness=-1, lineType=cv2.LINE_AA
        )
        return img


プログラムを実行して生成した画像の一例です。

0: 普通車(自家用)

1: 普通車(事業用)

2: 軽自動車(自家用)

3: 軽自動車(事業用)

なお、フォントは、「TRMフォント (模型製作用お助けフォント)」のものを使用させて頂きました。

3 データセット

先のプログラムで動的にナンバープレート画像を生成し、その「サイズ」や、「角度」を変えながら、「背景」と合成してデータセットを作成しています。

合成するパラメータとして、背景 (1280 * 960) に対するナンバープレート画像 (220 * 440)の比率は、最大1、最小0.2となっています。また、ナンバープレートの重なる確率は、0 (重なりは無し)です。

生成した画像は、30,000枚、アノテーションは、各クラス 48,000件ぐらいで、合計192,000件です。

データセット生成に使用したプログラムは、以下のとおりです。

create_dataset.py
"""
ナンバープレートを生成しアフィン変換して背景と合成することでGround Truth形式のデータセットを作成する
"""
import json
import glob
import random
import os
import shutil
import math
import numpy as np
import cv2
from PIL import Image
from number_plate import NumberPlate

MAX = 30000  # 生成する画像数

CLASS_NAME = [
    "PRIVATE_STANDARD",
    "BUS_STANDAR",
    "PRIVATE_LIGHT_VEHICLE",
    "BUS_LIGHT_VEHICLE",
]

COLORS = [(0, 0, 175), (175, 0, 0), (0, 175, 0), (175, 0, 175)]

BACKGROUND_IMAGE_PATH = "./dataset/background_images"
TARGET_IMAGE_PATH = "./dataset/output_png"
OUTPUT_PATH = "./dataset/output_ground_truth"

S3Bucket = "s3://ground_truth_dataset"
manifestFile = "output.manifest"


BASE_WIDTH = 440  # ナンバープレートの基本サイズは、背景画像とのバランスより、横幅を440を基準とする
BACK_WIDTH = 1280  # 背景画像ファイルのサイズを合わせる必要がある
BACK_HEIGHT = 960  # 背景画像ファイルのサイズを合わせる必要がある


# 背景画像取得クラス
class Background:
    def __init__(self, backPath):
        self.__backPath = backPath

    def get(self):
        imagePath = random.choice(glob.glob(self.__backPath + "/*.png"))
        return cv2.imread(imagePath, cv2.IMREAD_UNCHANGED)


# 検出対象取得クラス (base_widthで指定された横幅を基準にリサイズされる)
class Target:
    def __init__(self, target_path, base_width, class_name):
        self.__target_path = target_path
        self.__base_width = base_width
        self.__class_name = class_name
        self.__number_plate = NumberPlate()

    def get(self, class_id):
        # 対象画像
        class_name = self.__class_name[class_id]
        target_image = number_plate = self.__number_plate.generate(class_id)

        # 透過PNGへの変換
        # Point 1: 白色部分に対応するマスク画像を生成
        mask = np.all(target_image[:, :, :] == [255, 255, 255], axis=-1)
        # Point 2: 元画像をBGR形式からBGRA形式に変換
        target_image = cv2.cvtColor(target_image, cv2.COLOR_BGR2BGRA)
        # Point3: マスク画像をもとに、白色部分を透明化
        target_image[mask, 3] = 0

        # 基準(横)サイズに基づきリサイズ
        h, w, _ = target_image.shape
        aspect = h / w
        target_image = cv2.resize(
            target_image, (int(self.__base_width * aspect), self.__base_width)
        )

        return target_image


# 変換クラス
class Transformer:
    def __init__(self, width, height):
        self.__width = width
        self.__height = height
        self.__min_scale = 0.1
        self.__max_scale = 1

    # distonation UP:0,DOWN;1,LEFT:2,RIGHT:3
    # ratio: UP/DOWN 0 〜 0.1
    # ratio: LEFT/RIGHT 0 〜 0.3
    def __affine(self, target_image, ratio, distnatoin):
        # アフィン変換(上下、左右から見た傾き)
        h, w, _ = target_image.shape
        if distnatoin == 0:
            distortion_w = int(ratio * w)
            x1 = 0
            x2 = 0 - distortion_w
            x3 = w
            x4 = w - distortion_w
            y1 = h
            y2 = 0
            y3 = 0
            y4 = h
            # 変換後のイメージのサイズ
            ww = w + distortion_w
            hh = h
        elif distnatoin == 1:
            distortion_w = int(ratio * w)
            x1 = 0 - distortion_w
            x2 = 0
            x3 = w - distortion_w
            x4 = w
            y1 = h
            y2 = 0
            y3 = 0
            y4 = h
            # 変換後のイメージのサイズ
            ww = w + distortion_w
            hh = h
        elif distnatoin == 2:
            distortion_h = int(ratio * h)
            x1 = 0
            x2 = 0
            x3 = w
            x4 = w
            y1 = h
            y2 = 0 - int(distortion_h * 0.6)
            y3 = 0
            y4 = h - distortion_h
            # 変換後のイメージのサイズ
            ww = w
            hh = h + int(distortion_h * 1.3)
        elif distnatoin == 3:
            distortion_h = int(ratio * h)
            x1 = 0
            x2 = 0
            x3 = w
            x4 = w
            y1 = h - int(distortion_h * 0.6)
            y2 = 0
            y3 = 0 - distortion_h
            y4 = h
            # 変換後のイメージのサイズ
            ww = w
            hh = h + int(distortion_h * 1.3)

        pts2 = [(x2, y2), (x1, y1), (x4, y4), (x3, y3)]
        w2 = max(pts2, key=lambda x: x[0])[0]
        h2 = max(pts2, key=lambda x: x[1])[1]
        h, w, _ = target_image.shape
        pts1 = np.float32([(0, 0), (0, h), (w, h), (w, 0)])
        pts2 = np.float32(pts2)

        M = cv2.getPerspectiveTransform(pts2, pts1)
        target_image = cv2.warpPerspective(
            target_image, M, (w2 + 100, h2 + 100), borderValue=(255, 255, 255)
        )
        return (target_image, ww, hh)

    def warp(self, target_image):
        # サイズ変更
        target_image = self.__resize(target_image)

        # アフィン変換
        # distonation UP:0,DOWN;1,LEFT:2,RIGHT:3
        # ratio: UP/DOWN max:0.1
        # ratio: LEFT/RIGHT max:0.3
        ratio = random.uniform(0, 0.1)
        distonation = random.randint(0, 3)
        if distonation == 2 or distonation == 3:
            ratio = random.uniform(0, 0.3)
        (target_image, w, h) = self.__affine(target_image, ratio, distonation)

        # 配置位置決定
        left = random.randint(0, self.__width - w)
        top = random.randint(0, self.__height - h)
        rect = ((left, top), (left + w, top + h))

        # 背景面との合成
        new_image = self.__synthesize(target_image, left, top)
        return (new_image, rect)

    def __resize(self, img):
        scale = random.uniform(self.__min_scale, self.__max_scale)
        w, h, _ = img.shape
        return cv2.resize(img, (int(w * scale), int(h * scale)))

    def __rote(self, target_image, angle):
        h, w, _ = target_image.shape
        rate = h / w
        scale = 1
        if rate < 0.9 or 1.1 < rate:
            scale = 0.9
        elif rate < 0.8 or 1.2 < rate:
            scale = 0.6
        center = (int(w / 2), int(h / 2))
        trans = cv2.getRotationMatrix2D(center, angle, scale)
        return cv2.warpAffine(target_image, trans, (w, h))

    def __synthesize(self, target_image, left, top):
        background_image = np.zeros((self.__height, self.__width, 4), np.uint8)
        back_pil = Image.fromarray(background_image)
        front_pil = Image.fromarray(target_image)
        back_pil.paste(front_pil, (left, top), front_pil)
        return np.array(back_pil)


class Effecter:
    # Gauss
    def gauss(self, img, level):
        return cv2.blur(img, (level * 2 + 1, level * 2 + 1))

    # Noise
    def noise(self, img):
        img = img.astype("float64")
        img[:, :, 0] = self.__single_channel_noise(img[:, :, 0])
        img[:, :, 1] = self.__single_channel_noise(img[:, :, 1])
        img[:, :, 2] = self.__single_channel_noise(img[:, :, 2])
        return img.astype("uint8")

    def __single_channel_noise(self, single):
        diff = 255 - single.max()
        noise = np.random.normal(0, random.randint(1, 100), single.shape)
        noise = (noise - noise.min()) / (noise.max() - noise.min())
        noise = diff * noise
        noise = noise.astype(np.uint8)
        dst = single + noise
        return dst


# バウンディングボックス描画
def box(frame, rect, class_id):
    ((x1, y1), (x2, y2)) = rect
    label = "{}".format(CLASS_NAME[class_id])
    img = cv2.rectangle(frame, (x1, y1), (x2, y2), COLORS[class_id], 2)
    img = cv2.rectangle(img, (x1, y1), (x1 + 150, y1 - 20), COLORS[class_id], -1)
    cv2.putText(
        img,
        label,
        (x1 + 2, y1 - 2),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.5,
        (255, 255, 255),
        1,
        cv2.LINE_AA,
    )
    return img


# 背景と商品の合成
def marge_image(background_image, front_image):
    back_pil = Image.fromarray(background_image)
    front_pil = Image.fromarray(front_image)
    back_pil.paste(front_pil, (0, 0), front_pil)
    return np.array(back_pil)


# Manifest生成クラス
class Manifest:
    def __init__(self, class_name):
        self.__lines = ""
        self.__class_map = {}
        for i in range(len(class_name)):
            self.__class_map[str(i)] = class_name[i]

    def appned(self, fileName, data, height, width):
        date = "0000-00-00T00:00:00.000000"
        line = {
            "source-ref": "{}/{}".format(S3Bucket, fileName),
            "boxlabel": {
                "image_size": [{"width": width, "height": height, "depth": 3}],
                "annotations": [],
            },
            "boxlabel-metadata": {
                "job-name": "xxxxxxx",
                "class-map": self.__class_map,
                "human-annotated": "yes",
                "objects": {"confidence": 1},
                "creation-date": date,
                "type": "groundtruth/object-detection",
            },
        }
        for i in range(data.max()):
            (_, rect, class_id) = data.get(i)
            ((x1, y1), (x2, y2)) = rect
            line["boxlabel"]["annotations"].append(
                {
                    "class_id": class_id,
                    "width": x2 - x1,
                    "top": y1,
                    "height": y2 - y1,
                    "left": x1,
                }
            )
        self.__lines += json.dumps(line) + "\n"

    def get(self):
        return self.__lines


# 1画像分のデータを保持するクラス
class Data:
    def __init__(self, rate):
        self.__rects = []
        self.__images = []
        self.__class_ids = []
        self.__rate = rate

    def get_class_ids(self):
        return self.__class_ids

    def max(self):
        return len(self.__rects)

    def get(self, i):
        return (self.__images[i], self.__rects[i], self.__class_ids[i])

    # 追加(重複率が指定値以上の場合は失敗する)
    def append(self, target_image, rect, class_id):
        conflict = False
        for i in range(len(self.__rects)):
            iou = self.__multiplicity(self.__rects[i], rect)
            if iou > self.__rate:
                conflict = True
                break
        if conflict == False:
            self.__rects.append(rect)
            self.__images.append(target_image)
            self.__class_ids.append(class_id)
            return True
        return False

    # 重複率
    def __multiplicity(self, a, b):
        (ax_mn, ay_mn) = a[0]
        (ax_mx, ay_mx) = a[1]
        (bx_mn, by_mn) = b[0]
        (bx_mx, by_mx) = b[1]
        a_area = (ax_mx - ax_mn + 1) * (ay_mx - ay_mn + 1)
        b_area = (bx_mx - bx_mn + 1) * (by_mx - by_mn + 1)
        abx_mn = max(ax_mn, bx_mn)
        aby_mn = max(ay_mn, by_mn)
        abx_mx = min(ax_mx, bx_mx)
        aby_mx = min(ay_mx, by_mx)
        w = max(0, abx_mx - abx_mn + 1)
        h = max(0, aby_mx - aby_mn + 1)
        intersect = w * h
        return intersect / (a_area + b_area - intersect)


# 各クラスのデータ数が同一になるようにカウントする
class Counter:
    def __init__(self, max):
        self.__counter = np.zeros(max)

    def get(self):
        n = np.argmin(self.__counter)
        return int(n)

    def inc(self, index):
        self.__counter[index] += 1

    def print(self):
        print(self.__counter)


def main():
    # 出力先の初期化
    if os.path.exists(OUTPUT_PATH):
        shutil.rmtree(OUTPUT_PATH)
    os.mkdir(OUTPUT_PATH)

    target = Target(TARGET_IMAGE_PATH, BASE_WIDTH, CLASS_NAME)
    background = Background(BACKGROUND_IMAGE_PATH)

    transformer = Transformer(BACK_WIDTH, BACK_HEIGHT)
    manifest = Manifest(CLASS_NAME)
    counter = Counter(len(CLASS_NAME))
    effecter = Effecter()

    no = 0

    while True:
        # 背景画像の取得
        background_image = background.get()

        # 商品データ

        # 重なり率(これを超える場合は、配置されない)
        # rate = 0.1
        rate = 0  # ナンバープレートの重なりは、対象画となるため、重なりを排除する
        data = Data(rate)
        # for _ in range(20):
        for _ in range(10):
            # 現時点で作成数の少ないクラスIDを取得
            class_id = counter.get()
            # 商品画像の取得
            target_image = target.get(class_id)
            # 変換
            (transform_image, rect) = transformer.warp(target_image)
            frame = marge_image(background_image, transform_image)
            # 商品の追加(重複した場合は、失敗する)
            ret = data.append(transform_image, rect, class_id)
            if ret:
                counter.inc(class_id)

        print("max:{}".format(data.max()))
        frame = background_image
        for index in range(data.max()):
            (target_image, _, _) = data.get(index)
            # 合成
            frame = marge_image(frame, target_image)

        # アルファチャンネル削除
        frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)

        # エフェクト
        frame = effecter.gauss(frame, random.randint(0, 2))
        frame = effecter.noise(frame)

        # 画像名
        fileName = "{:05d}.png".format(no)
        no += 1

        # 画像保存
        cv2.imwrite("{}/{}".format(OUTPUT_PATH, fileName), frame)
        # manifest追加
        manifest.appned(fileName, data, frame.shape[0], frame.shape[1])

        for i in range(data.max()):
            (_, rect, class_id) = data.get(i)
            # バウンディングボックス描画(確認用)
            frame = box(frame, rect, class_id)

        counter.print()
        print("no:{}".format(no))
        if MAX <= no:
            break

        # 表示(確認用)
        cv2.imshow("frame", frame)
        cv2.waitKey(1)

    # manifest 保存
    with open("{}/{}".format(OUTPUT_PATH, manifestFile), "w") as f:
        f.write(manifest.get())


main()


上記のプログラムで、出力されるのは、実は、Amazon SageMaker Ground Truthで使用する形式になってます。

% tree
.
├── README.md
├── number_plate.py    // ナンバープレート画像生成クラス NumberPlate
├── create_dataset.py  // Ground Truth形式のデータセットを作成する
└── dataset 
    ├── background_images // 合成用の背景画像
    │   ├── 001.png
    │   ├── 002.png
    │   ├── 003.png
    │   ├── 004.png
    │   ├── 005.png
    │   ├── 006.png
    │   ├── 007.png
    │   └── 008.png
    └── output_ground_truth // 出力されたGround Truth形式のデータセット
        ├── 00000.png
        ├── 00001.png
        ├── 00002.png
・・・略・・・
        ├── 29997.png
        ├── 29998.png
        ├── 29999.png
        └── output.manifest

個人的な都合で恐縮なのですが、過去の作業経緯から、データセットは、全て一旦 Ground Truthの形式に寄せておいて、目的に合わせて変換して使用しているためです。

[Amazon SageMaker] オブジェクト検出におけるGround Truthを中心としたデータセット作成環境について

そして、YOLOv5形式へコンバートしたプログラムです、8:2で学習・検証用に分割されています。

convert_ground_truth_to_yolo.py
"""
Ground Truth形式のデータセットをYolo用に変換する
"""

import json
import glob
import os
import shutil

# 定義
inputPath = "./dataset/output_ground_truth"
# outputPath = "./dataset/yolo"
outputPath = "./yolo"
manifest = "output.manifest"
# 学習用と検証用の分割比率
ratio = 0.8  # 8対2に分割する


# 1件のJデータを表現するクラス
class Data:
    def __init__(self, src):
        # プロジェクト名の取得
        for key in src.keys():
            index = key.rfind("-metadata")
            if index != -1:
                projectName = key[0:index]

        # メタデータの取得
        metadata = src[projectName + "-metadata"]
        class_map = metadata["class-map"]

        # 画像名の取得
        self.imgFileName = os.path.basename(src["source-ref"])
        self.baseName = self.imgFileName.split(".")[0]
        # 画像サイズの取得
        project = src[projectName]
        image_size = project["image_size"]
        self.img_width = image_size[0]["width"]
        self.img_height = image_size[0]["height"]

        self.annotations = []
        # アノテーションの取得
        for annotation in project["annotations"]:
            class_id = annotation["class_id"]
            top = annotation["top"]
            left = annotation["left"]
            width = annotation["width"]
            height = annotation["height"]

            self.annotations.append(
                {
                    "label": class_map[str(class_id)],
                    "width": width,
                    "top": top,
                    "height": height,
                    "left": left,
                }
            )

    # 指定されたラベルを含むかどうか
    def exsists(self, label):
        for annotation in self.annotations:
            if annotation["label"] == label:
                return True
        return False

    def store(self, imagePath, labelPath, inputPath, labels):
        cls_list = []
        for label in labels:
            cls_list.append(label[0])

        text = ""
        for annotation in self.annotations:
            cls_id = cls_list.index(annotation["label"])
            top = annotation["top"]
            left = annotation["left"]
            width = annotation["width"]
            height = annotation["height"]

            yolo_x = (left + width / 2) / self.img_width
            yolo_y = (top + height / 2) / self.img_height
            yolo_w = width / self.img_width
            yolo_h = height / self.img_height

            text += "{} {:.6f} {:.6f} {:.6f} {:.6f}\n".format(
                cls_id, yolo_x, yolo_y, yolo_w, yolo_h
            )
        # txtの保存
        with open("{}/{}.txt".format(labelPath, self.baseName), mode="w") as f:
            f.write(text)
        # 画像のコピー
        shutil.copyfile(
            "{}/{}".format(inputPath, self.imgFileName),
            "{}/{}".format(imagePath, self.imgFileName),
        )


# dataListをラベルを含むものと、含まないものに分割する
def deviedDataList(dataList, label):
    targetList = []
    unTargetList = []
    for data in dataList:
        if data.exsists(label):
            targetList.append(data)
        else:
            unTargetList.append(data)
    return (targetList, unTargetList)


# ラベルの件数の少ない順に並べ替える(配列のインデックスが、クラスIDとなる)
def getLabel(dataList):
    labels = {}
    for data in dataList:
        for annotation in data.annotations:
            label = annotation["label"]
            if label in labels:
                labels[label] += 1
            else:
                labels[label] = 1
    # ラベルの件数の少ない順に並べ替える(配列のインデックスが、クラスIDとなる)
    labels = sorted(labels.items(), key=lambda x: x[1])
    return labels


# 全てのJSONデータを読み込む
def getDataList(inputPath, manifest):
    dataList = []
    with open("{}/{}".format(inputPath, manifest), "r") as f:
        srcList = f.read().split("\n")
        for src in srcList:
            if src != "":
                json_src = json.loads(src)
                dataList.append(Data(json.loads(src)))
    return dataList


def main():
    # 出力先フォルダ生成
    train_images = "{}/train/images".format(outputPath)
    validation_images = "{}/valid/images".format(outputPath)
    train_labels = "{}/train/labels".format(outputPath)
    validation_labels = "{}/valid/labels".format(outputPath)

    os.makedirs(outputPath, exist_ok=True)
    os.makedirs(train_images, exist_ok=True)
    os.makedirs(validation_images, exist_ok=True)
    os.makedirs(train_labels, exist_ok=True)
    os.makedirs(validation_labels, exist_ok=True)

    # 全てのJSONデータを読み込む
    dataList = getDataList(inputPath, manifest)
    log = "全データ: {}件 ".format(len(dataList))

    # ラベルの件数の少ない順に並べ替える(配列のインデックスが、クラスIDとなる)
    labels = getLabel(dataList)
    for i, label in enumerate(labels):
        log += "[{}]{}: {}件 ".format(i, label[0], label[1])
    print(log)

    # 保存済みリスト
    storedList = []
    log = ""
    # ラベルの数の少ないものから優先して分割する
    for i, label in enumerate(labels):
        log = ""
        log += "{} => ".format(label[0])
        # dataListをラベルが含まれるものと、含まないものに分割する
        (targetList, unTargetList) = deviedDataList(dataList, label[0])
        # 保存済みリストから、当該ラベルで既に保存済の件数をカウントする
        (include, notInclude) = deviedDataList(storedList, label[0])
        storedCounst = len(include)
        # train用に必要な件数
        # count = int(label[1] * ratio) - storedCounst
        count = int((len(dataList) * ratio))
        log += "{}:".format(count)
        # train側への保存
        for i in range(count):
            data = targetList.pop()
            data.store(train_images, train_labels, inputPath, labels)
            storedList.append(data)
        # validation側への保存
        log += "{} ".format(len(targetList))
        for data in targetList:
            data.store(validation_images, validation_labels, inputPath, labels)
            storedList.append(data)

        dataList = unTargetList
        log += "残り:{}件".format(len(dataList))
        print(log)


main()


% $ python3 convert_ground_truth_to_yolo.py
全データ: 30,000件 [0]P_S: 47738件 [1]B_S: 47738件 [2]P_L: 47738件 [3]B_L: 47738件
.
├── data.yaml
├── dataset
│   ├── background_images
│   ├── output_ground_truth
│   └── yolo
│       ├── train
│       │   ├── images
│       │   │   ├── 00600.png
│       │   │   ├── 00601.png
│       │   │   ├── 00602.png
・・・略・・・
│       │   │   ├── 02997.png
│       │   │   ├── 02998.png
│       │   │   └── 02999.png
│       │   └── labels
│       │       ├── 00600.txt
│       │       ├── 00601.txt
・・・略・・・
│       │       ├── 02998.txt
│       │       └── 02999.txt
│       └── valid
│           ├── images
│           │   ├── 00000.png
│           │   ├── 00001.png
・・・略・・・
│           │   ├── 00598.png
│           │   └── 00599.png
│           └── labels
│               ├── 00000.txt
│               ├── 00001.txt
・・・略・・・
│               ├── 00598.txt
│               └── 00599.txt
└─

4 ファインチューニング

データセットが準備できれば、YOLOv8のファインチューニングは簡単です。

from ultralytics import YOLO

model = YOLO("yolov8l.pt")
model.train(data="../dataset.yaml", epochs=5, batch=8, workers=4, degrees=90.0)
epoch mAP50-95(B)
1 0.926
2 0.946
3 0.970
4 0.949
5 0.972

5 AI-OCR

AI-OCRとしては、MicrosoftのComputer Visionで提供されている、Read APIを使用させて頂きました。

参考: Computer Vision 3.2 GA Read API を呼び出す

最初は、ナンバープレート画像を、そのままOCRにかけてみたのですが、下段左の平仮名1文字のところの認識が難しいようでした。これは、このように「ひらがな」1文字だけが配置されることに、モデルが対応しきれていないような気がしました。

対策として、画像を3つの部分に分割し、それぞれでOCRにかけるようにしてみました。また、認識精度が上がるように、業務用(緑バックの白文字、及び、黒バックの黄色文字)は、ネガポジ反転し、最終的にグレースケール変換することにしました。

AI-OCRで処理しているコードと、それを使っている、全体のコードです。

ocr.py
import time
import json
import os
import codecs
import requests
import cv2
import numpy as np
from PIL import ImageFont, ImageDraw, Image


def readApi(im_file_name):
    with open(im_file_name, "rb") as f:
        data = f.read()

    subscription_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    endpoint = "https://japaneast.api.cognitive.microsoft.com/"
    model_version = "2022-04-30"
    # model_version = "2022-01-30-preview"
    # model_version = "2021-04-12"
    language = "ja"

    text_recognition_url = endpoint + "vision/v3.2/read/analyze"
    headers = {
        "Ocp-Apim-Subscription-Key": subscription_key,
        "Content-Type": "application/octet-stream",
    }
    params = {"language ": language, "model-version": model_version}

    response = requests.post(
        text_recognition_url, headers=headers, params=params, json=None, data=data
    )
    response.raise_for_status()

    operation_url = response.headers["Operation-Location"]
    analysis = {}
    poll = True

    while poll:
        response_final = requests.get(
            response.headers["Operation-Location"], headers=headers
        )
        analysis = response_final.json()

        print(json.dumps(analysis, indent=4, ensure_ascii=False))

        time.sleep(1)
        if "analyzeResult" in analysis:
            poll = False
        if "status" in analysis and analysis["status"] == "failed":
            poll = False
    return analysis


def putText(img, text, point, size, color):
    fontFace = "gosic.ttc"
    stroke_width = 1

    # cv2 -> PIL
    imgPIL = Image.fromarray(cv2.cvtColor(img.copy(), cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(imgPIL)
    fontPIL = ImageFont.truetype(font=fontFace, size=size)
    draw.text(xy=point, text=text, fill=color, font=fontPIL, stroke_width=stroke_width)
    # PIL -> cv2
    return cv2.cvtColor(np.array(imgPIL, dtype=np.uint8), cv2.COLOR_RGB2BGR)


def convert_to_gray(image):
    image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)


def detect(name, image, cls_id, class_name, conf):
    work_dir = "./ocr"
    if not os.path.exists(work_dir):
        os.mkdir(work_dir)

    # 定形のサイズに変換する
    img_all = cv2.resize(image, (440, 220))
    # 業務車の場合は反転する
    if cls_id == 1 or cls_id == 3:
        img_all = cv2.bitwise_not(img_all)
    # グレースケール
    img_all = convert_to_gray(img_all)

    # 部分切り取り
    img_parts = []
    text_parts = ["", "", ""]
    # 上段
    img_parts.append(img_all[0:100, 0:440])
    # ひらがな
    img_parts.append(img_all[100:200, 20:100])
    # 4桁の数字
    img_parts.append(img_all[80:220, 80:440])

    text = ""

    for index in range(len(img_parts)):
        im_file_name = "{}/number_{}_{}.png".format(work_dir, name, index)
        json_file_name = "{}/analysis_{}_{}.json".format(work_dir, name, index)

        if not os.path.isfile(json_file_name):
            # 画像保存
            cv2.imwrite(im_file_name, img_parts[index])
            analysis = readApi(im_file_name)
            with codecs.open(json_file_name, "w+", "utf-8") as fp:
                json.dump(analysis, fp, ensure_ascii=False, indent=2)

        else:
            with open(json_file_name) as f:
                analysis = json.load(f)

        if "analyzeResult" in analysis:
            analyzeResult = analysis["analyzeResult"]

            if "readResults" in analyzeResult:
                readResults = analyzeResult["readResults"]
                readResult = readResults[0]
                if "lines" in readResult:
                    lines = readResult["lines"]
                    for line in lines:
                        x1, y1, _, _, x3, y3, _, _ = line["boundingBox"]
                        img_parts[index] = cv2.rectangle(
                            img_parts[index],
                            (x1, y1),
                            (x3, y3),
                            (0, 0, 255),
                            2,
                            cv2.LINE_4,
                        )
                        # ゴミの排除
                        text = (
                            line["text"]
                            .replace("*", "")
                            .replace("°", "")
                            .replace("-", "")
                        )
                        text_parts[index] = text.replace("*", "")

            # text = text.replace("*", "")

    # 白バックの画像生成
    verify_image = np.zeros((700, 500, 3), dtype=np.uint8) + 255
    h, w = image.shape[:2]

    # 元イメージ
    margin_x = 10
    margin_y = 50
    verify_image[margin_y : h + margin_y, margin_x : w + margin_x, :] = image

    margin_y = 230

    for img in img_parts:
        h2, w2 = img.shape[:2]
        verify_image[margin_y : h2 + margin_y, margin_x : w2 + margin_x, :] = img
        margin_y += h2 + 10

    margin_y = 600
    verify_image = putText(
        verify_image,
        "confidence: {:.2f}".format(conf),
        (margin_x, margin_y),
        15,
        (0, 0, 0),
    )

    margin_y += 25
    verify_image = putText(
        verify_image, class_name, (margin_x, margin_y), 15, (0, 0, 0)
    )
    margin_y += 30
    verify_image = putText(
        verify_image, text_parts[0], (margin_x, margin_y), 25, (0, 0, 0)
    )
    margin_x += 110
    verify_image = putText(
        verify_image, text_parts[1], (margin_x, margin_y), 25, (0, 0, 0)
    )
    margin_x += 50
    verify_image = putText(
        verify_image, text_parts[2], (margin_x, margin_y), 25, (0, 0, 0)
    )

    return verify_image


detect_number_plate.py
from ultralytics import YOLO
import cv2
import glob
import os
import ocr

confidence = 0.8
imgsz = 1280
class_names = ["普通自動車(自家用)", "普通自動車(業務用)", "軽自動車(自家用)", "普通自動車(業務用)"]
color = [(0, 0, 255), (255, 0, 255), (255, 0, 0), (255, 255, 0)]
face = ["🐹", "🐰", "🐲", "🐻"]


def main():
    model = YOLO("./best.pt")

    files = glob.glob("./images/*.png")
    for file in files:
        print(file)
        name = os.path.basename(file).split(".")[0]
        img = cv2.imread(file)

        results = model.predict(file, conf=confidence, imgsz=imgsz)

        for result in results:
            boxes = result.boxes.cpu().numpy()
            ids = result.boxes.cls.cpu().numpy().astype(int)
            confs = result.boxes.conf.cpu().numpy()

            if len(boxes) > 0:
                box = boxes[0]
                cls_id = ids[0]
                conf = confs[0]

                # ナンバープレート画像(OCRでの読み取りに使用する)
                r = box.xyxy[0].astype(int)
                number_plate_image = img[r[1] : r[3], r[0] : r[2]]
                verify_image = ocr.detect(
                    name, number_plate_image, cls_id, class_names[cls_id], conf
                )

                img = cv2.rectangle(img, (r[0], r[1]), (r[2], r[3]), color[cls_id], 7)
                print(
                    "\n{} {} conf:{:.2f}  ({},{}), ({},{})".format(
                        face[cls_id], class_names[cls_id], conf, r[0], r[1], r[2], r[3]
                    )
                )

        # 画像の表示
        img = cv2.resize(img, None, fx=0.5, fy=0.5)
        cv2.imshow("image", img)

        cv2.imshow("verify", verify_image)
        cv2.waitKey(1)

        # キー入力待機
        input("Please enter key. ")


if __name__ == "__main__":
    main()


6 ご当地ナンバー

地元らしい図柄を表示する「図柄入りナンバープレート」が存在します。

参考:「富山県版図柄入りナンバープレート」の交付が開始されました!

今回、身近に無くて試せてないのですが、テスト的に画像の生成だけやってみました。どれぐらい精度が確保できるかは分かってないのですが、このようなデータを追加することで、対応できるのでは?と思っています。

7 最後に

今回は、ナンバープレートの検出とその文字列の読み取りをやってみました。

個人的に学習できたことのサマリは、以下のような感じです。

  • ナンバープレート検出のモデルは、プログラムで生成した画像で作成可能(結構、精度高いものが作成できる)
  • ナンバープレートの「平仮名1文字」部分の読み取りは、結構難しい(誤検知の許容が必要、平仮名1文字の検出にファインチューニングしたAI-OCRも良いかも)
  • ナンバープレートの4つの数字を高精度で読み取ることは、比較的簡単(このような使い方が現実的なのかも)

結構、精度の高いナンバープレート検出モデルが手に入ったので、今度は、これを使って何か価値あるものを作ってみたいと妄想しています。