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つの数字を高精度で読み取ることは、比較的簡単(このような使い方が現実的なのかも)
結構、精度の高いナンバープレート検出モデルが手に入ったので、今度は、これを使って何か価値あるものを作ってみたいと妄想しています。