"""
reviewer
収集した二値画像を閲覧・仕分けするレビュー GUI
raw/ のセッションを開き,画像を1枚ずつ確認して
confirmed/ に確定 or 削除する
キー操作:
←: 前の画像に戻る
→/Enter: 現在のラベルで確定して次へ
M: ラベルを反転(intersection ↔ normal)
Delete/Backspace: 画像を削除して次へ
Escape: 終了
"""
from pathlib import Path
import cv2
import numpy as np
from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QKeyEvent, QPixmap
from PySide6.QtWidgets import (
QFileDialog,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QVBoxLayout,
QWidget,
)
from pc.data.collector import (
CONFIRMED_DIR,
LABEL_INTERSECTION,
LABEL_NORMAL,
RAW_DIR,
)
# 画像表示の拡大倍率
REVIEW_SCALE: int = 16
def _load_image(path: Path) -> np.ndarray:
"""画像ファイルを読み込む
Args:
path: 画像ファイルのパス
Returns:
グレースケール画像
"""
img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
if img is None:
return np.zeros((30, 40), dtype=np.uint8)
return img
def _count_confirmed() -> tuple[int, int]:
"""confirmed/ 内の画像数を集計する
Returns:
(intersection の枚数, normal の枚数)
"""
n_int = len(
list(
(CONFIRMED_DIR / LABEL_INTERSECTION).glob(
"*.png",
),
)
) if (CONFIRMED_DIR / LABEL_INTERSECTION).is_dir() else 0
n_norm = len(
list(
(CONFIRMED_DIR / LABEL_NORMAL).glob("*.png"),
)
) if (CONFIRMED_DIR / LABEL_NORMAL).is_dir() else 0
return n_int, n_norm
def _next_confirmed_index(label: str) -> int:
"""confirmed/ の次の連番を返す
Args:
label: ラベル名
Returns:
次の連番(1始まり)
"""
label_dir = CONFIRMED_DIR / label
if not label_dir.is_dir():
return 1
existing = list(label_dir.glob("*.png"))
if not existing:
return 1
max_num = 0
for p in existing:
try:
num = int(p.stem.split("_")[0])
max_num = max(max_num, num)
except ValueError:
continue
return max_num + 1
class _ImageEntry:
"""画像1枚の情報を保持する
Attributes:
path: ファイルパス
label: 現在のラベル(intersection / normal)
"""
def __init__(self, path: Path, label: str) -> None:
self.path = path
self.label = label
class ReviewWindow(QMainWindow):
"""データ仕分けウィンドウ"""
def __init__(
self,
session_dir: Path | None = None,
) -> None:
super().__init__()
self._entries: list[_ImageEntry] = []
self._index: int = 0
self._setup_ui()
if session_dir is not None:
self._load_session(session_dir)
def _setup_ui(self) -> None:
"""UI を構築する"""
self.setWindowTitle("データ仕分け")
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
# セッション選択
top_bar = QHBoxLayout()
self._open_btn = QPushButton("セッションを開く")
self._open_btn.clicked.connect(self._on_open)
top_bar.addWidget(self._open_btn)
self._session_label = QLabel("未選択")
self._session_label.setStyleSheet(
"font-size: 13px; color: #888;"
)
top_bar.addWidget(self._session_label, stretch=1)
root.addLayout(top_bar)
# 画像表示
self._image_label = QLabel(
"セッションを選択してください",
)
self._image_label.setAlignment(
Qt.AlignmentFlag.AlignCenter,
)
self._image_label.setMinimumSize(
40 * REVIEW_SCALE, 30 * REVIEW_SCALE,
)
self._image_label.setStyleSheet(
"background-color: #222;"
" color: #aaa; font-size: 16px;"
)
root.addWidget(self._image_label)
# ラベル・進捗表示
self._info_label = QLabel("")
self._info_label.setAlignment(
Qt.AlignmentFlag.AlignCenter,
)
self._info_label.setStyleSheet(
"font-size: 16px; font-family: monospace;"
" padding: 6px;"
)
root.addWidget(self._info_label)
# 操作ボタン
btn_bar = QHBoxLayout()
self._prev_btn = QPushButton("← 戻る (←)")
self._prev_btn.clicked.connect(self._go_prev)
btn_bar.addWidget(self._prev_btn)
self._flip_btn = QPushButton("ラベル反転 (M)")
self._flip_btn.clicked.connect(self._flip_label)
btn_bar.addWidget(self._flip_btn)
self._delete_btn = QPushButton("削除 (Del)")
self._delete_btn.clicked.connect(
self._delete_current,
)
btn_bar.addWidget(self._delete_btn)
self._confirm_btn = QPushButton("確定 → (→)")
self._confirm_btn.clicked.connect(
self._confirm_current,
)
btn_bar.addWidget(self._confirm_btn)
root.addLayout(btn_bar)
# 集計表示(raw 残り + confirmed 累計)
self._summary_label = QLabel("")
self._summary_label.setAlignment(
Qt.AlignmentFlag.AlignCenter,
)
self._summary_label.setStyleSheet(
"font-size: 13px; font-family: monospace;"
" color: #888; padding: 4px;"
)
root.addWidget(self._summary_label)
# 操作ガイド
guide = QLabel(
"←: 戻る →/Enter: 確定 "
"M: ラベル反転 Del/BS: 削除 Esc: 終了"
)
guide.setAlignment(Qt.AlignmentFlag.AlignCenter)
guide.setStyleSheet(
"font-size: 12px; color: #666;"
)
root.addWidget(guide)
# ── セッション読み込み ────────────────────────────────
def _on_open(self) -> None:
"""セッション選択ダイアログを開く"""
raw_dir = str(RAW_DIR)
dir_path = QFileDialog.getExistingDirectory(
self, "セッションディレクトリを選択",
raw_dir,
)
if dir_path:
self._load_session(Path(dir_path))
def _load_session(self, session_dir: Path) -> None:
"""セッションの画像一覧を読み込む
Args:
session_dir: セッションディレクトリのパス
"""
self._entries.clear()
self._index = 0
for label in (LABEL_INTERSECTION, LABEL_NORMAL):
label_dir = session_dir / label
if not label_dir.is_dir():
continue
for img_path in sorted(label_dir.glob("*.png")):
self._entries.append(
_ImageEntry(img_path, label),
)
self._session_label.setText(
f"セッション: {session_dir.name}"
)
self._update_display()
# ── ナビゲーション ────────────────────────────────────
def _go_prev(self) -> None:
"""前の画像に戻る"""
if len(self._entries) == 0:
return
self._index = max(0, self._index - 1)
self._update_display()
# ── ラベル操作 ────────────────────────────────────────
def _flip_label(self) -> None:
"""現在の画像のラベルを反転する(raw 内で移動)"""
if len(self._entries) == 0:
return
entry = self._entries[self._index]
new_label = (
LABEL_NORMAL
if entry.label == LABEL_INTERSECTION
else LABEL_INTERSECTION
)
entry.label = new_label
self._update_display()
def _confirm_current(self) -> None:
"""現在の画像を confirmed/ に移動して次へ"""
if len(self._entries) == 0:
return
entry = self._entries[self._index]
label = entry.label
# confirmed/ 内の保存先を確保
dest_dir = CONFIRMED_DIR / label
dest_dir.mkdir(parents=True, exist_ok=True)
idx = _next_confirmed_index(label)
dest_path = dest_dir / f"{idx:06d}.png"
# raw → confirmed へ移動
entry.path.rename(dest_path)
self._entries.pop(self._index)
if len(self._entries) == 0:
self._index = 0
elif self._index >= len(self._entries):
self._index = len(self._entries) - 1
self._update_display()
def _delete_current(self) -> None:
"""現在の画像を削除して次へ"""
if len(self._entries) == 0:
return
entry = self._entries[self._index]
entry.path.unlink(missing_ok=True)
self._entries.pop(self._index)
if len(self._entries) == 0:
self._index = 0
elif self._index >= len(self._entries):
self._index = len(self._entries) - 1
self._update_display()
# ── 表示更新 ──────────────────────────────────────────
def _update_display(self) -> None:
"""画像・ラベル・進捗の表示を更新する"""
n = len(self._entries)
if n == 0:
self._image_label.setText("画像がありません")
self._info_label.setText("")
self._update_summary()
return
entry = self._entries[self._index]
# 画像表示
img = _load_image(entry.path)
h, w = img.shape[:2]
qimg = QImage(
img.data, w, h, w,
QImage.Format.Format_Grayscale8,
)
pixmap = QPixmap.fromImage(qimg).scaled(
w * REVIEW_SCALE,
h * REVIEW_SCALE,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.FastTransformation,
)
self._image_label.setPixmap(pixmap)
# ラベル色分け
if entry.label == LABEL_INTERSECTION:
color = "#f44"
label_text = "intersection"
else:
color = "#4a4"
label_text = "normal"
self._info_label.setText(
f'<span style="color:{color};"'
f' font-weight:bold;">'
f"{label_text}</span>"
f" {self._index + 1} / {n}"
f" [{entry.path.name}]"
)
self._update_summary()
def _update_summary(self) -> None:
"""集計表示を更新する"""
# raw 残り
n_raw_int = sum(
1 for e in self._entries
if e.label == LABEL_INTERSECTION
)
n_raw_norm = len(self._entries) - n_raw_int
# confirmed 累計
c_int, c_norm = _count_confirmed()
self._summary_label.setText(
f"[raw] int: {n_raw_int} norm: {n_raw_norm}"
f" [confirmed] int: {c_int}"
f" norm: {c_norm}"
)
# ── キー操作 ──────────────────────────────────────────
def keyPressEvent(self, event: QKeyEvent) -> None:
"""キー押下時の処理"""
key = event.key()
if key == Qt.Key.Key_Left:
self._go_prev()
elif key in (
Qt.Key.Key_Right, Qt.Key.Key_Return,
):
self._confirm_current()
elif key == Qt.Key.Key_M:
self._flip_label()
elif key in (
Qt.Key.Key_Delete, Qt.Key.Key_Backspace,
):
self._delete_current()
elif key == Qt.Key.Key_Escape:
self.close()
else:
super().keyPressEvent(event)