前回は、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とモデルを育てていきます。
関連記事
- 麻雀CPU、あったらいいので作ります。④ 実物の牌を認識できるのか試してみた
- YOLOモデルの作り方をゼロから解説|通常の学習手順と自動生成までの全体像
- Ultralytics YOLOを学習する|YAML設定から推論まで
- 麻雀牌を画像分類する方法|YOLO検出後にEfficientNetで牌種類を判定する
- 画像分類・EfficientNetの学習データを作る|画像分割とデータ拡張
- 麻雀牌を二段階で画像分類する|4分類→牌種類分類のDatasetを作る
- EfficientNet-B0で麻雀牌を学習する|PyTorch・timmで転移学習
- 麻雀牌の画像分類モデルを学習して精度を確認する|Stage1・Stage2をまとめて学習
- YOLOとEfficientNetで麻雀牌を推論する|カメラ映像から二段階分類する



コメント