YOLO用矩形アノテーションツールをPythonで自作する|Tkinterでズーム・PANに対応

YOLO

前回は、OpenCVを使ってYOLOの学習に使用する麻雀牌画像を撮影しました。

撮影した画像をYOLOの学習に使うには、画像内のどこに麻雀牌が写っているのかを矩形で指定するアノテーション作業が必要です。

今回は、PythonのTkinterを使ってYOLO用の矩形アノテーションツールを作成します。

今回のツールでは、次の操作に対応しています。

  • 左ドラッグによる矩形作成
  • クラスの選択
  • 既存ラベルの読み込み
  • 矩形の選択・削除
  • Undo
  • Ctrl+スクロールによるズーム
  • 右ドラッグによるPAN移動
  • YOLO形式での保存

できるだけシンプルな画面にしつつ、実際のアノテーション作業で必要になった機能を追加しています。

記事本文では、コードのすべてを1行ずつ解説するのではなく、アノテーションツールを作るうえで重要な部分に絞って紹介します。

また、記事の後半に今回使用している00_annotation.pyのコード全文も掲載しています。

全文は長いため折りたたんでいますが、そのまま確認できるようにしています。


なぜアノテーションツールを自作したのか

最初からアノテーションツールを自作していたわけではありません。

はじめはLabelImgを使って麻雀牌をアノテーションし、そのデータを使ってYOLOを学習するところまで進めていました。

LabelImgでも問題なく作業できましたが、アノテーションを続けるうちに、

  • もっと効率よく作業したい
  • 自分の環境に合わせて機能を追加したい
  • 必要な操作だけのシンプルな画面にしたい

と思うようになりました。

今後、撮影条件を変えたり学習データを増やしたりすると、アノテーションは何度も行うことになります。

また、将来的には、すでに学習したYOLOモデルを使って画像内の牌を自動検出し、その結果を人が確認・修正する半自動アノテーション機能も追加したいと考えています。

最初からすべてを手作業で囲むのではなく、

学習済みモデルで牌を検出

↓

検出結果を矩形として表示

↓

間違っている部分だけ修正

↓

YOLOラベルとして保存

という流れにできれば、アノテーション作業をさらに効率化できます。

既存ツールへ機能を追加するよりも、自分で作ったツールの方が、学習モデルとの連携や独自機能の追加を行いやすいと考えました。

そこで、まずは基本的な操作だけを備えたシンプルなツールを作り、実際に使いながらズームやPAN移動などの機能を追加しています。

今後も、作業の中で必要になった機能を少しずつ追加していく予定です。


今回作成したツール

画面は、左側の画像表示エリアと、右側の操作エリアに分かれています。

左側には画像と矩形を表示し、右側には次の項目を配置しています。

  • 画像フォルダの選択
  • 現在の画像番号とファイル名
  • クラス一覧
  • 保存して次へ
  • 選択矩形の削除
  • Undo
  • 表示のリセット
  • 操作方法

基本的な作業の流れは次のとおりです。

画像フォルダを選択する

↓

クラスを選択する

↓

画像上を左ドラッグして牌を囲む

↓

必要に応じて矩形を削除・修正する

↓

保存して次の画像へ進む

クラス情報を別ファイルで管理する

牌のクラス情報は、classes_yolo.txtから読み込みます。

CLASS_FILE = Path("./01_annotation/classes_yolo.txt")

クラスファイルは、次の形式で記述します。

0 manzu
1 pinzu
2 souzu
3 jihai

1列目がYOLOで使用するクラス番号、2列目がクラス名です。

クラス情報をプログラムから分離しておくことで、クラスを変更するときにPythonコードを修正する必要がありません。

読み込んだクラスは、画面右側のリストへ表示します。


矩形情報をBoxクラスで管理する

作成した矩形は、Boxというデータクラスで管理しています。

@dataclass
class Box:
    x1: float
    y1: float
    x2: float
    y2: float
    class_id: int

x1y1x2y2が矩形の座標で、class_idが選択した牌のクラス番号です。

矩形を専用のクラスで管理することで、

  • 座標の並びを整える
  • 画像外へはみ出さないようにする
  • クリックした位置が矩形内か判定する
  • 矩形の幅と高さを取得する

といった処理をまとめています。

マウスをどの方向へドラッグしても正しく矩形になるように、座標の大小も自動で調整します。


左ドラッグで矩形を作成する

クラスを選択した状態で画像上を左ドラッグすると、新しい矩形を作成します。

マウスを押した位置を開始点として保存します。

self.drawing = True
self.draw_start = point
self.draw_end = point

ドラッグ中は、現在のマウス位置まで点線の矩形を表示します。

マウスを離すと、開始位置と終了位置からBoxを作成します。

box = Box(
    *self.draw_start,
    *end,
    self.current_class,
).clip(width, height)

クリックしただけで不要な矩形が作られないように、幅または高さが5ピクセル未満の矩形は追加しません。

if box.width < MIN_BOX_SIZE or box.height < MIN_BOX_SIZE:
    self.set_status("小さすぎる矩形は追加しません")
    return

作成した矩形には、クラスごとに異なる色とクラス名を表示します。


矩形の選択・削除・Undo

作成済みの矩形をクリックすると、その矩形を選択できます。

選択中の矩形は枠線を太く表示し、どの矩形が選ばれているか分かるようにしています。

選択した矩形は、次の操作で削除できます。

  • 「選択矩形を削除」ボタン
  • Deleteキー
  • BackSpaceキー

また、矩形を追加または削除するたびに状態を履歴へ保存しています。

self.history.append(copy.deepcopy(self.boxes))

Ctrl+ZまたはCommand+Zを押すと、一つ前の状態へ戻せます。

アノテーション中は矩形を間違えて追加したり削除したりすることがあるため、Undoは特に便利な機能でした。


ズームとPAN移動に対応する

画像全体を表示した状態では、牌の端が見づらい場合があります。

そこで、Ctrlキーを押しながらスクロールすると、画像を拡大・縮小できるようにしました。

MIN_ZOOM = 0.2
MAX_ZOOM = 10.0
ZOOM_STEP = 1.15

ズームは0.2倍から10倍までに制限しています。

また、拡大した画像は右ドラッグで上下左右へ移動できます。

macOSでは右クリックがButton-2またはButton-3として扱われる場合があるため、両方へ同じ処理を設定しています。

for button in (2, 3):
    self.canvas.bind(
        f"<ButtonPress-{button}>",
        self.pan_press,
    )
    self.canvas.bind(
        f"<B{button}-Motion>",
        self.pan_drag,
    )

ズームとPAN移動を組み合わせることで、牌の境界を確認しながら細かく矩形を指定できます。


ズームしても座標がずれない仕組み

画面上の画像は、ウィンドウサイズやズーム倍率によって表示サイズが変わります。

そのため、矩形座標をCanvas上の座標で保存すると、ズームしたときに位置がずれてしまいます。

今回のツールでは、矩形を元画像上の座標で管理しています。

Canvas上へ表示するときだけ、表示倍率と画像位置を使って座標を変換します。

def to_canvas(self, x, y):
    scale, offset_x, offset_y = self.transform()

    return (
        x * scale + offset_x,
        y * scale + offset_y,
    )

マウス操作で取得したCanvas座標は、反対に元画像の座標へ戻します。

この座標変換によって、画像を拡大・縮小・移動しても、矩形の位置と保存されるラベルがずれないようになっています。


既存のYOLOラベルを読み込む

画像と同じ名前のラベルファイルが存在する場合は、保存済みの矩形を自動で読み込みます。

sample001.jpg
sample001.txt

YOLO形式のラベルは、次の順番で保存されています。

クラス番号 中心X 中心Y 幅 高さ

これらは画像サイズに対して0から1の範囲へ正規化された値です。

読み込むときは、正規化された値を元画像上の座標へ戻します。

Box(
    (xc - bw / 2) * width,
    (yc - bh / 2) * height,
    (xc + bw / 2) * width,
    (yc + bh / 2) * height,
    class_id,
)

既存ラベルを読み込めるようにしたことで、保存済みのアノテーションを後から確認・修正できます。


YOLO形式で保存する

保存時は、矩形の座標をYOLO形式へ変換します。

xc = ((box.x1 + box.x2) / 2) / width
yc = ((box.y1 + box.y2) / 2) / height
bw = box.width / width
bh = box.height / height

保存される内容は次のようになります。

0 0.302145 0.415320 0.102340 0.241250
15 0.421670 0.413800 0.099420 0.239710
34 0.743150 0.417610 0.101280 0.242100

画像と同じフォルダへ、同じ名前のテキストファイルを保存します。

「保存して次へ」を押すと、ラベルを保存して自動的に次の画像を表示します。

最後の画像まで保存すると、完了メッセージを表示してツールを終了します。


操作方法

今回作成したツールの操作方法は次のとおりです。

操作内容
左ドラッグ新しい矩形を作成
矩形を左クリック矩形を選択
右ドラッグ画像をPAN移動
Ctrl+スクロール画像を拡大・縮小
Delete・BackSpace選択した矩形を削除
Ctrl+Z・Command+Z一つ前の状態へ戻す
Ctrl+S・Command+S保存して次の画像へ進む
表示をリセットズームとPAN位置を初期化

完成したコード

今回作成した00_annotation.pyの全文です。

コードが長いため、デフォルトでは折りたたんでいます。必要に応じて「00_annotation.py の全文を見る」をクリックしてください。

00_annotation.py の全文を見る
import copy
import re
import tkinter as tk
from dataclasses import dataclass
from pathlib import Path
from tkinter import filedialog, messagebox, simpledialog

from PIL import Image, ImageTk


CLASS_FILE = Path("./01_annotation/classes_yolo.txt")
DEFAULT_DIR = Path("./01_annotation/sample")

IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
COLORS = (
    "#e6194b", "#3cb44b", "#ffe119", "#4363d8",
    "#f58231", "#911eb4", "#46f0f0", "#f032e6",
    "#bcf60c", "#fabebe", "#008080", "#e6beff",
    "#333333",
)

MIN_BOX_SIZE = 5
MIN_ZOOM = 0.2
MAX_ZOOM = 10.0
ZOOM_STEP = 1.15


@dataclass
class Box:
    x1: float
    y1: float
    x2: float
    y2: float
    class_id: int

    def normalize(self) -> Box:
        return Box(
            min(self.x1, self.x2),
            min(self.y1, self.y2),
            max(self.x1, self.x2),
            max(self.y1, self.y2),
            self.class_id,
        )

    def clip(self, width: int, height: int) -> Box:
        box = self.normalize()
        return Box(
            max(0, min(box.x1, width)),
            max(0, min(box.y1, height)),
            max(0, min(box.x2, width)),
            max(0, min(box.y2, height)),
            box.class_id,
        )

    def contains(self, x: float, y: float) -> bool:
        box = self.normalize()
        return box.x1 <= x <= box.x2 and box.y1 <= y <= box.y2

    @property
    def width(self) -> float:
        return abs(self.x2 - self.x1)

    @property
    def height(self) -> float:
        return abs(self.y2 - self.y1)


class Annotator:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("YOLO Annotator")
        self.root.geometry("1100x750")

        self.class_map = {}
        self.class_ids = []
        self.current_class = None

        self.image_paths = []
        self.index = 0
        self.image = None
        self.tk_image = None

        self.boxes = []
        self.history = []
        self.selected = None

        self.drawing = False
        self.draw_start = None
        self.draw_end = None

        self.zoom = 1.0
        self.pan_x = 0.0
        self.pan_y = 0.0
        self.pan_start = None
        self.pan_origin = None

        self.build_ui()
        self.load_classes()
        self.bind_events()

    def build_ui(self):
        self.canvas = tk.Canvas(self.root, bg="black", highlightthickness=0)
        self.canvas.pack(side="left", fill="both", expand=True)

        side = tk.Frame(self.root, width=220, padx=8, pady=8)
        side.pack(side="right", fill="y")
        side.pack_propagate(False)

        tk.Button(
            side,
            text="画像フォルダを開く",
            command=self.choose_folder,
        ).pack(fill="x")

        self.progress = tk.Label(side, text="画像未選択", justify="left")
        self.progress.pack(fill="x", pady=8)

        tk.Label(side, text="Classes", font=("", 12, "bold")).pack(anchor="w")

        self.class_list = tk.Listbox(side, exportselection=False)
        self.class_list.pack(fill="both", expand=True, pady=(4, 8))

        tk.Button(
            side,
            text="保存して次へ",
            command=self.save_and_next,
        ).pack(fill="x", pady=2)

        tk.Button(
            side,
            text="選択矩形を削除",
            command=self.delete_selected,
        ).pack(fill="x", pady=2)

        tk.Button(
            side,
            text="元に戻す",
            command=self.undo,
        ).pack(fill="x", pady=2)

        tk.Button(
            side,
            text="表示をリセット",
            command=self.reset_view,
        ).pack(fill="x", pady=2)

        self.status = tk.Label(
            side,
            text=(
                "左ドラッグ: 矩形作成\n"
                "右ドラッグ: PAN移動\n"
                "Ctrl + スクロール: ズーム"
            ),
            justify="left",
            wraplength=200,
        )
        self.status.pack(fill="x", pady=(10, 0))

    def bind_events(self):
        self.class_list.bind("<<ListboxSelect>>", self.select_class)

        self.canvas.bind("<ButtonPress-1>", self.left_press)
        self.canvas.bind("<B1-Motion>", self.left_drag)
        self.canvas.bind("<ButtonRelease-1>", self.left_release)

        # macOSでは右クリックがButton-2になる場合があるため両方に対応
        for button in (2, 3):
            self.canvas.bind(f"<ButtonPress-{button}>", self.pan_press)
            self.canvas.bind(f"<B{button}-Motion>", self.pan_drag)
            self.canvas.bind(f"<ButtonRelease-{button}>", self.pan_release)

        self.canvas.bind("<Control-MouseWheel>", self.zoom_image)
        self.canvas.bind("<Control-Button-4>", self.zoom_image)
        self.canvas.bind("<Control-Button-5>", self.zoom_image)
        self.canvas.bind("<Configure>", lambda _event: self.redraw())

        for key in ("<Control-s>", "<Command-s>"):
            self.root.bind(key, self.save_and_next)

        for key in ("<Control-z>", "<Command-z>"):
            self.root.bind(key, self.undo)

        self.root.bind("<Delete>", self.delete_selected)
        self.root.bind("<BackSpace>", self.delete_selected)

    def load_classes(self):
        if not CLASS_FILE.exists():
            messagebox.showerror(
                "エラー",
                f"クラスファイルが見つかりません。\n{CLASS_FILE.resolve()}",
            )
            return

        try:
            for line in CLASS_FILE.read_text(encoding="utf-8").splitlines():
                line = line.strip()
                if not line or line.startswith("#"):
                    continue

                class_id, name = line.split(maxsplit=1)
                class_id = int(class_id)

                self.class_map[class_id] = name
                self.class_ids.append(class_id)
                self.class_list.insert("end", f"{class_id}: {name}")
        except (OSError, ValueError) as error:
            messagebox.showerror("クラス読込エラー", str(error))
            return

        if self.class_ids:
            self.class_list.selection_set(0)
            self.current_class = self.class_ids[0]

    def select_class(self, _event=None):
        selection = self.class_list.curselection()
        if selection:
            self.current_class = self.class_ids[selection[0]]

    def choose_folder(self):
        initial_dir = DEFAULT_DIR if DEFAULT_DIR.exists() else Path.cwd()

        try:
            selected = filedialog.askdirectory(
                parent=self.root,
                title="画像フォルダを選択",
                initialdir=str(initial_dir),
                mustexist=True,
            )
        except tk.TclError:
            selected = simpledialog.askstring(
                "画像フォルダ",
                "画像フォルダの絶対パスを入力してください。",
                parent=self.root,
                initialvalue=str(initial_dir),
            )

        if not selected:
            return

        folder = Path(selected).expanduser()
        if not folder.is_dir():
            messagebox.showerror("エラー", f"フォルダが見つかりません。\n{folder}")
            return

        self.image_paths = sorted(
            [
                path
                for path in folder.iterdir()
                if path.suffix.lower() in IMAGE_EXTENSIONS
            ],
            key=lambda path: [
                int(part) if part.isdigit() else part.lower()
                for part in re.split(r"(\d+)", path.name)
            ],
        )

        if not self.image_paths:
            messagebox.showwarning("画像なし", "対応画像が見つかりません。")
            return

        self.index = 0
        self.load_image()

    def load_image(self):
        path = self.image_paths[self.index]

        try:
            with Image.open(path) as source:
                self.image = source.copy()
        except OSError as error:
            messagebox.showerror("画像読込エラー", str(error))
            return

        self.boxes = self.load_labels(path)
        self.history = [copy.deepcopy(self.boxes)]
        self.selected = None
        self.reset_view()

        self.progress.config(
            text=f"{self.index + 1} / {len(self.image_paths)}\n{path.name}"
        )

    def load_labels(self, image_path: Path):
        label_path = image_path.with_suffix(".txt")
        if not label_path.exists():
            return []

        width, height = self.image.size
        boxes = []

        try:
            for line in label_path.read_text(encoding="utf-8").splitlines():
                if not line.strip():
                    continue

                class_id, xc, yc, bw, bh = line.split()
                class_id = int(class_id)
                xc, yc, bw, bh = map(float, (xc, yc, bw, bh))

                boxes.append(
                    Box(
                        (xc - bw / 2) * width,
                        (yc - bh / 2) * height,
                        (xc + bw / 2) * width,
                        (yc + bh / 2) * height,
                        class_id,
                    ).clip(width, height)
                )
        except (OSError, ValueError) as error:
            messagebox.showwarning("ラベル読込エラー", str(error))

        return boxes

    def transform(self):
        if self.image is None:
            return 1.0, 0.0, 0.0

        canvas_w = max(self.canvas.winfo_width(), 1)
        canvas_h = max(self.canvas.winfo_height(), 1)
        image_w, image_h = self.image.size

        fit_scale = min(canvas_w / image_w, canvas_h / image_h)
        scale = fit_scale * self.zoom

        offset_x = (canvas_w - image_w * scale) / 2 + self.pan_x
        offset_y = (canvas_h - image_h * scale) / 2 + self.pan_y

        return scale, offset_x, offset_y

    def to_canvas(self, x, y):
        scale, offset_x, offset_y = self.transform()
        return x * scale + offset_x, y * scale + offset_y

    def to_image(self, x, y, clamp=False):
        if self.image is None:
            return None

        scale, offset_x, offset_y = self.transform()
        image_x = (x - offset_x) / scale
        image_y = (y - offset_y) / scale
        width, height = self.image.size

        if clamp:
            return (
                max(0, min(image_x, width)),
                max(0, min(image_y, height)),
            )

        if 0 <= image_x <= width and 0 <= image_y <= height:
            return image_x, image_y

        return None

    def redraw(self):
        self.canvas.delete("all")
        if self.image is None:
            return

        scale, offset_x, offset_y = self.transform()
        image_w, image_h = self.image.size
        display_w = max(1, round(image_w * scale))
        display_h = max(1, round(image_h * scale))

        resized = self.image.resize(
            (display_w, display_h),
            Image.Resampling.LANCZOS,
        )
        self.tk_image = ImageTk.PhotoImage(resized)

        self.canvas.create_image(
            offset_x,
            offset_y,
            anchor="nw",
            image=self.tk_image,
        )

        for index, box in enumerate(self.boxes):
            x1, y1 = self.to_canvas(box.x1, box.y1)
            x2, y2 = self.to_canvas(box.x2, box.y2)
            color = COLORS[box.class_id % len(COLORS)]

            self.canvas.create_rectangle(
                x1,
                y1,
                x2,
                y2,
                outline=color,
                width=4 if index == self.selected else 2,
            )
            self.canvas.create_text(
                x1,
                y1 - 4,
                text=f"{box.class_id}: {self.class_map.get(box.class_id, 'unknown')}",
                fill=color,
                anchor="sw",
            )

        if self.drawing and self.draw_start and self.draw_end:
            x1, y1 = self.to_canvas(*self.draw_start)
            x2, y2 = self.to_canvas(*self.draw_end)
            color = (
                COLORS[self.current_class % len(COLORS)]
                if self.current_class is not None
                else "white"
            )
            self.canvas.create_rectangle(
                x1,
                y1,
                x2,
                y2,
                outline=color,
                width=2,
                dash=(4, 2),
            )

    def left_press(self, event):
        point = self.to_image(event.x, event.y)
        if point is None:
            self.selected = None
            self.redraw()
            return

        self.selected = self.find_box(*point)
        if self.selected is not None:
            self.drawing = False
            self.redraw()
            return

        if self.current_class is None:
            self.set_status("クラスを選択してください")
            return

        self.drawing = True
        self.draw_start = point
        self.draw_end = point
        self.redraw()

    def left_drag(self, event):
        if not self.drawing:
            return

        self.draw_end = self.to_image(event.x, event.y, clamp=True)
        self.redraw()

    def left_release(self, event):
        if not self.drawing or self.draw_start is None:
            return

        self.drawing = False
        end = self.to_image(event.x, event.y, clamp=True)

        if end is None or self.current_class is None:
            self.draw_start = self.draw_end = None
            self.redraw()
            return

        width, height = self.image.size
        box = Box(
            *self.draw_start,
            *end,
            self.current_class,
        ).clip(width, height)

        self.draw_start = self.draw_end = None

        if box.width < MIN_BOX_SIZE or box.height < MIN_BOX_SIZE:
            self.set_status("小さすぎる矩形は追加しません")
            self.redraw()
            return

        self.boxes.append(box)
        self.selected = len(self.boxes) - 1
        self.history.append(copy.deepcopy(self.boxes))
        self.redraw()

    def find_box(self, x, y):
        for index in range(len(self.boxes) - 1, -1, -1):
            if self.boxes[index].contains(x, y):
                return index
        return None

    def pan_press(self, event):
        if self.image is None:
            return

        self.pan_start = (event.x, event.y)
        self.pan_origin = (self.pan_x, self.pan_y)

    def pan_drag(self, event):
        if self.pan_start is None or self.pan_origin is None:
            return

        self.pan_x = self.pan_origin[0] + event.x - self.pan_start[0]
        self.pan_y = self.pan_origin[1] + event.y - self.pan_start[1]
        self.redraw()

    def pan_release(self, _event):
        self.pan_start = None
        self.pan_origin = None

    def zoom_image(self, event):
        if self.image is None:
            return "break"

        image_point = self.to_image(event.x, event.y)
        if image_point is None:
            return "break"

        zoom_in = getattr(event, "num", None) == 4 or event.delta > 0
        factor = ZOOM_STEP if zoom_in else 1 / ZOOM_STEP
        new_zoom = max(MIN_ZOOM, min(self.zoom * factor, MAX_ZOOM))

        if new_zoom == self.zoom:
            return "break"

        self.zoom = new_zoom
        scale, _, _ = self.transform()

        canvas_w = max(self.canvas.winfo_width(), 1)
        canvas_h = max(self.canvas.winfo_height(), 1)
        image_w, image_h = self.image.size

        centered_x = (canvas_w - image_w * scale) / 2
        centered_y = (canvas_h - image_h * scale) / 2

        self.pan_x = event.x - image_point[0] * scale - centered_x
        self.pan_y = event.y - image_point[1] * scale - centered_y

        self.redraw()
        return "break"

    def reset_view(self):
        self.zoom = 1.0
        self.pan_x = 0.0
        self.pan_y = 0.0
        self.redraw()

    def delete_selected(self, _event=None):
        if self.selected is None:
            self.set_status("削除する矩形を選択してください")
            return "break"

        self.boxes.pop(self.selected)
        self.selected = None
        self.history.append(copy.deepcopy(self.boxes))
        self.redraw()
        return "break"

    def undo(self, _event=None):
        if len(self.history) <= 1:
            self.set_status("これ以上元に戻せません")
            return "break"

        self.history.pop()
        self.boxes = copy.deepcopy(self.history[-1])
        self.selected = None
        self.redraw()
        return "break"

    def save_and_next(self, _event=None):
        if self.image is None:
            self.set_status("画像フォルダを開いてください")
            return "break"

        width, height = self.image.size
        lines = []

        for box in self.boxes:
            box = box.clip(width, height)
            xc = ((box.x1 + box.x2) / 2) / width
            yc = ((box.y1 + box.y2) / 2) / height
            bw = box.width / width
            bh = box.height / height

            lines.append(
                f"{box.class_id} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}"
            )

        label_path = self.image_paths[self.index].with_suffix(".txt")

        try:
            label_path.write_text("\n".join(lines), encoding="utf-8")
        except OSError as error:
            messagebox.showerror("保存エラー", str(error))
            return "break"

        print(f"saved: {label_path}")

        if self.index + 1 >= len(self.image_paths):
            messagebox.showinfo("完了", "すべての画像を保存しました。")
            self.root.quit()
            return "break"

        self.index += 1
        self.load_image()
        return "break"

    def set_status(self, message):
        self.status.config(text=message)


if __name__ == "__main__":
    root = tk.Tk()
    Annotator(root)
    root.mainloop()

実行方法

最後に、今回作成したアノテーションツールの実行方法を紹介します。

必要なライブラリ

GUIにはTkinter、画像の読み込みにはPillowを使用しています。

Tkinterは通常Pythonに含まれていますが、Pillowが入っていない場合はインストールします。

pip install pillow

ファイルを配置する

今回のコードでは、クラス一覧を次のファイルから読み込んでいます。

01_annotation/
├── classes_yolo.txt
├── sample/
└── 00_annotation.py

classes_yolo.txtには、YOLOで使用するクラス番号とクラス名を記述します。

例えば次のような形式です。

0 manzu
1 pinzu
2 souzu
3 jihai

また、sampleフォルダは画像フォルダを選択するときの初期位置として使用しています。

画像自体はツール起動後に別のフォルダを選択することもできます。

プログラムを実行する

ターミナルから00_annotation.pyを実行します。

python 00_annotation.py

環境によっては次のように実行します。

python3 00_annotation.py

起動すると、YOLO Annotatorの画面が表示されます。

「画像フォルダを開く」から、アノテーションしたい画像が入っているフォルダを選択します。


まとめ

今回は、PythonとTkinterを使ってYOLO用の矩形アノテーションツールを作成しました。

最初はLabelImgを使っていましたが、作業を効率化し、自分の環境に合わせて機能を追加するために自作しています。

最初から多機能なツールを作ったわけではなく、まずは矩形作成とYOLO形式での保存だけを実装し、その後、実際に使いながら次の機能を追加しました。

  • 既存ラベルの読み込み
  • 矩形の選択と削除
  • Undo
  • ズーム
  • PAN移動
  • 進捗表示

自作することで、必要な機能だけを備えたシンプルなツールにできただけでなく、YOLOラベルの仕組みについても理解を深められました。

次回は、今回作成したYOLOラベルをもとに、元画像から麻雀牌部分を自動で切り出します。


関連記事

コメント

タイトルとURLをコピーしました