誤認識した麻雀牌を教師データにする|推論結果から再学習する

EfficientNet

前回は、YOLOとEfficientNetを使ってカメラ映像から麻雀牌を推論し、推論に使った牌画像を保存しました。

今回は、その画像を人間が確認して、

correct(正解)
miss(不正解)

へ仕分けし、次の再学習に使えるDatasetを作成します。

一般的な機械学習の流れは、

Datasetを作る
↓
モデルを学習
↓
推論する

です。

今回からは、

モデルを学習
↓
実際の環境で推論
↓
推論画像を集める
↓
correct / missへ仕分け
↓
再学習
↓
また推論

というサイクルに変えます。

今回のポイントは、学習して終わりではなく、学習したモデル自身を使って次の学習データを集めることです。

今回使用するコードは、

devide_sample.py
from pathlib import Path
import shutil
import sys
from datetime import datetime

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QKeySequence, QShortcut
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget


Hai = {
        0: "m1", 1: "m2", 2: "m3", 3: "m4",4: "m5", 5: "m6", 6: "m7", 7: "m8", 8: "m9",9: "m5r",
        10: "p1", 11: "p2", 12: "p3",13: "p4", 14: "p5",15: "p6", 16: "p7",17: "p8", 18: "p9",19: "p5r",
        20: "s1",21: "s2", 22: "s3", 23: "s4", 24: "s5", 25: "s6", 26: "s7", 27: "s8", 28: "s9", 29: "s5r",
        30: "ton", 31: "nan", 32: "sha", 33: "pei", 34: "haku", 35: "hatsu",36: "chun",
    }

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

SAMPLE_DIR = Path("./uncheck")


MISS_DIR = Path(f"./01_data/{timestamp}/miss")
CORRECT_DIR = Path(f"./01_data/{timestamp}/correct")
CORRECT_DIR.mkdir(parents=True, exist_ok=True)
MISS_DIR.mkdir(parents=True, exist_ok=True)


class SampleChecker(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Image Checker")
        self.resize(1200, 900)

        self.files = sorted(SAMPLE_DIR.glob("*.jpg"))
        if not self.files:
            print("画像がありません")
            sys.exit(0)

        self.idx = 0
        self.edit = False

        self.img = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
        self.info = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)

        lay = QVBoxLayout(self)
        lay.addWidget(self.img, 1)
        lay.addWidget(self.info)

        QShortcut(QKeySequence(Qt.Key.Key_Left), self, activated=self.left)
        QShortcut(QKeySequence(Qt.Key.Key_Right), self, activated=self.right)
        QShortcut(QKeySequence(Qt.Key.Key_Return), self, activated=self.enter)
        QShortcut(QKeySequence(Qt.Key.Key_Enter), self, activated=self.enter)
        QShortcut(QKeySequence(Qt.Key.Key_Escape), self, activated=self.esc)

        self.load()

    def load(self):
        if self.idx >= len(self.files):
            QApplication.quit(); return
        self.path=self.files[self.idx]
        # b,ts=self.path.stem.split("_",1)
        before_name, ts = self.path.stem.split("_", 1)
        self.before = int(before_name)
        self.current=self.before; self.ts=ts
        self.pm=QPixmap(str(self.path))
        self.refresh()

    def refresh(self):
        self.img.setPixmap(self.pm.scaled(self.img.size() if self.img.size().width()>10 else self.size(),
                                         Qt.AspectRatioMode.KeepAspectRatio,
                                         Qt.TransformationMode.SmoothTransformation))
        t=f"{self.idx+1}/{len(self.files)}\nClass:{Hai[self.before]} ({self.before})\nEnter:OK  ←→ Change  Esc:Exit"
        t=f"{self.idx+1}/{len(self.files)}\nPredict:{Hai[self.before]} ({self.before})\nCorrect:{Hai[self.current]} ({self.current})\n←→:Change  Enter:Save  Esc:Exit"
        self.info.setText(t)

    def resizeEvent(self,e):
        self.refresh()
        super().resizeEvent(e)

    def left(self):
        self.current=36 if self.current==0 else self.current-1
        self.refresh()
    def right(self):
        self.current=0 if self.current==36 else self.current+1
        self.refresh()

    def enter(self):
        if self.current != self.before:
            dst_path = MISS_DIR/f"{self.current}"/f"{self.ts}{self.path.suffix.lower()}"
        else:
            dst_path = CORRECT_DIR/f"{self.current}"/f"{self.ts}{self.path.suffix.lower()}"
        dst_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(str(self.path), str(dst_path))
        self.idx+=1
        self.load()
    def esc(self):
        QApplication.quit()

if __name__=="__main__":
    app=QApplication(sys.argv)
    w=SampleChecker()
    w.show()
    sys.exit(app.exec())
check_sample.py
from pathlib import Path
import shutil
import sys

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QKeySequence, QShortcut
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget

Hai = {
        0: "m1", 1: "m2", 2: "m3", 3: "m4",4: "m5", 5: "m6", 6: "m7", 7: "m8", 8: "m9",9: "m5r",
        10: "p1", 11: "p2", 12: "p3",13: "p4", 14: "p5",15: "p6", 16: "p7",17: "p8", 18: "p9",19: "p5r",
        20: "s1",21: "s2", 22: "s3", 23: "s4", 24: "s5", 25: "s6", 26: "s7", 27: "s8", 28: "s9", 29: "s5r",
        30: "ton", 31: "nan", 32: "sha", 33: "pei", 34: "haku", 35: "hatsu",36: "chun",
    }

TARGET_DIR = Path("./20261010_162434/miss")


class SampleChecker(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Image Checker")
        self.resize(1200, 900)
        
        self.classes = sorted([
            int(p.name)
            for p in TARGET_DIR.iterdir()
            if p.is_dir()
        ])

        self.idx = 0
        
        self.flags = []
        self.img_idx = 0

        self.img = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
        self.info = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)

        lay = QVBoxLayout(self)
        lay.addWidget(self.img, 1)
        lay.addWidget(self.info)

        QShortcut(QKeySequence(Qt.Key.Key_Left), self, activated=self.left)
        QShortcut(QKeySequence(Qt.Key.Key_Right), self, activated=self.right)
        QShortcut(QKeySequence(Qt.Key.Key_F), self, activated=self.flag_chnage)
        QShortcut(QKeySequence(Qt.Key.Key_Return), self, activated=self.enter)
        QShortcut(QKeySequence(Qt.Key.Key_Enter), self, activated=self.enter)
        QShortcut(QKeySequence(Qt.Key.Key_Escape), self, activated=self.esc)

        self.class_load()
        
    def class_load(self,):
        if self.idx >= len(self.classes):
            QApplication.quit(); return
        class_dir = TARGET_DIR/str(self.classes[self.idx])
        self.files = []
        self.files = sorted(class_dir.glob("*.jpg"))
        self.flags = [False] * len(self.files)
        self.img_idx = 0
        if not self.files:
            print("画像がありません")
            sys.exit(0)
        self.load()

    def load(self):
        self.path=self.files[self.img_idx]
        self.pm=QPixmap(str(self.path))
        self.refresh()

    def refresh(self):
        self.img.setPixmap(self.pm.scaled(self.img.size() if self.img.size().width()>10 else self.size(),
                                         Qt.AspectRatioMode.KeepAspectRatio,
                                         Qt.TransformationMode.SmoothTransformation))
        t=f"[{Hai[self.classes[self.idx]]}] : {self.img_idx + 1}/ {len(self.files)} : 残す"
        if self.flags[self.img_idx]:
            t = f"[{Hai[self.classes[self.idx]]}] : {self.img_idx + 1}/ {len(self.files)} : 除外"
        self.info.setText(t)


    def resizeEvent(self,e):
        self.refresh()
        super().resizeEvent(e)


    def left(self):
        self.img_idx=0 if self.img_idx==0 else self.img_idx-1
        self.load()
    def right(self):
        self.img_idx=len(self.files)-1 if self.img_idx==len(self.files)-1 else self.img_idx+1
        self.load()
    
    
    def flag_chnage(self):
        self.flags[self.img_idx] = not self.flags[self.img_idx]
        self.refresh()


    def enter(self):
        for i in range(len(self.flags)):
            if self.flags[i]:
                path = self.files[i]
                dst_path = (path.parents[2] / "remove" / path.parent.name / path.name)
                dst_path.parent.mkdir(parents=True, exist_ok=True)
                shutil.move(str(path), str(dst_path))
            
        self.idx+=1
        self.class_load()

    def esc(self):
        QApplication.quit()

if __name__=="__main__":
    app=QApplication(sys.argv)
    w=SampleChecker()
    w.show()
    sys.exit(app.exec())

の2つです。


今回作る再学習の流れ

前回保存した画像には、モデルが予測した牌IDをファイル名に入れています。

ただし、本当に1筒なのかはまだ分かりません。

そこで、

uncheck
↓
devide_sample.py
↓
人間が予測結果を確認
↓
correct / miss
↓
check_sample.py
↓
不要なmiss画像を除外
↓
再学習

という流れで整理します。


devide_sample.pyで正誤を確認する

最初に使うのがdevide_sample.pyです。

確認する画像は、

SAMPLE_DIR = Path("./uncheck")

から読み込みます。

仕分けした画像は、実行日時ごとに、

01_data
└── timestamp
    ├── correct
    └── miss

へ保存します。


PredictとCorrectを見比べる

画面には、

Predict : モデルが予測した牌
Correct : 実際の牌

を表示します。

例えば、モデルが南と予測していて、実際の画像も南なら、

Predict : nan
Correct : nan

となります。

そのままEnterを押すと、

correct
└── 31

へ保存します。

一方、実際には發だった場合は、左右キーでCorrectを変更します。

Predict : nan
Correct : hatsu

としてEnterを押すと、

miss
└── 35

へ保存します。

左右キーでは牌IDを0〜36の範囲で変更できます。

保存時に予測と正解が違えばmiss、同じならcorrectへ振り分けます。

ここで重要なのは、miss画像を予測した牌ではなく、実際の正しい牌IDへ保存することです。

そのまま次の教師データとして使用できます。

実際に南と間違えて推論した發の画像

モデル自身に苦手な画像を集めてもらう

今回の再学習で一番重要なのがここです。

画像分類では、

Datasetを作る
↓
学習する
↓
Accuracyを確認する
↓
完成

で終わることもあります。

しかし実際の麻雀卓で使うと、

照明
影
角度
カメラとの距離
牌の位置

など、最初のDatasetでは十分に用意できなかった条件が出てきます。

そこで、

まずモデルを作る
↓
実際の環境で使う
↓
間違えた画像を見つける
↓
Datasetへ追加する

という方法にします。

つまり、

必要そうな画像を想像して増やすのではなく、モデルが実際に苦手だった画像を集める

という考え方です。


missだけではなくcorrectも学習に使う

ただし、再学習で使うのはmissだけではありません。

missには、

現在のモデルが実際に間違えた画像

が入るため、弱点を改善するうえで重要です。

しかし、

miss
miss
miss
miss

と、間違えた画像だけを追加するとDatasetに偏りができます。

そのため再学習では、

miss
+
correct

の両方を使います。

correctは、

現在のモデルでも正しく認識できた画像

です。

これも学習に残すことで、

今まで認識できていた画像
+
新しく見つかった苦手な画像

の両方を学習できます。

考え方としては、

miss
→ 弱点を改善するため重点的に使う

correct
→ Datasetの偏りを防ぐために使う

です。

missを優先するが、missだけにはしないことがポイントです。


miss画像をもう一度確認する

次に使うのが、

check_sample.py

です。

missに入った画像でも、すべてを無条件で再学習へ使うわけではありません。

例えば、

牌が大きく見切れている
YOLOの検出範囲がおかしい
ほとんど牌が写っていない

といった画像は、学習から除外したい場合があります。

そこで、miss画像をクラスごとにもう一度確認します。

対象フォルダは、

TARGET_DIR = Path("./20260729_215809/miss")

のように指定します。


不要なmiss画像を除外する

操作はシンプルです。

← →
画像を移動

F
残す / 除外を切り替え

Enter
現在のクラスを確定

Esc
終了

Fキーで、現在表示している画像を残す / 除外に切り替えます。

Enterを押すと、除外対象にした画像だけ、

remove
└── 牌ID

へ移動します。

最終的には、

data
└── timestamp
    ├── correct
    ├── miss
    └── remove

という形になります。


Datasetをモデルと一緒に育てる

今回の仕組みを繰り返すと、

Dataset v1
↓
Model v1
↓
実際の環境で推論
↓
correct / missを収集
↓
Dataset v2
↓
Model v2
↓
また推論
↓
Dataset v3
...

となります。

最初から完璧なDatasetを作るのではなく、

実際に使って見つかった弱点を少しずつDatasetへ追加していく

という方法です。

再学習時には、

これまでの学習データ
+
新しいcorrect
+
新しいmiss

を使います。


今回の仕組み全体

ここまでの流れをつなげると、

カメラ
↓
YOLO
牌を検出
↓
EfficientNet
牌を分類
↓
Crop画像を保存
↓
uncheck
↓
devide_sample.py
↓
correct / miss
↓
check_sample.py
↓
不要画像をremove
↓
correct + miss
↓
データ拡張
↓
再学習
↓
新しいモデル
↓
もう一度カメラで推論

となります。

ここで、学習と実際の推論が1つのループとしてつながりました。


まとめ

今回は、推論した画像を次の教師データへ戻す仕組みを作りました。

まず、

devide_sample.py

で、

推論結果
↓
人間が確認
↓
correct / miss

へ分けます。

次に、

check_sample.py

で、

miss
↓
再確認
↓
不要画像をremove

と整理します。

そして再学習では、

miss
+
correct

の両方を使用します。

missはモデルの弱点を改善するために重要ですが、missだけではDatasetが偏るため、correct画像も一緒に使います。

今回の一番重要なポイントは、

学習して終わり

ではなく、

学習
↓
実際に使う
↓
成功・失敗を集める
↓
Datasetへ戻す
↓
再学習

というサイクルを作ることです。

モデル自身に実際の環境を経験させ、その結果からDatasetとモデルを育てていきます。


関連記事

コメント

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