diff --git "a/docs/04_ENV/ENV_04_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.txt" "b/docs/04_ENV/ENV_04_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.txt" index aef2aba..38babc7 100644 --- "a/docs/04_ENV/ENV_04_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.txt" +++ "b/docs/04_ENV/ENV_04_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.txt" @@ -50,12 +50,14 @@ pc/ ├── main.py エントリーポイント + ├── review.py データ仕分け GUI エントリーポイント ├── gui/ GUI 関連 │ └── main_window.py メインウィンドウ ├── comm/ 通信関連 │ └── zmq_client.py ZMQ 送受信 - ├── data/ 学習データ収集 - │ └── collector.py 二値画像のラベル付き保存 + ├── data/ 学習データ収集・仕分け + │ ├── collector.py 二値画像のラベル付き保存 + │ └── reviewer.py 仕分けレビュー GUI ├── steering/ 操舵量計算(独立モジュール) │ ├── base.py 共通インターフェース │ ├── pd_control.py PD 制御の実装 diff --git a/src/pc/data/__init__.py b/src/pc/data/__init__.py index bdec126..e7d79a9 100644 --- a/src/pc/data/__init__.py +++ b/src/pc/data/__init__.py @@ -4,5 +4,6 @@ """ from pc.data.collector import DataCollector +from pc.data.reviewer import ReviewWindow -__all__ = ["DataCollector"] +__all__ = ["DataCollector", "ReviewWindow"] diff --git a/src/pc/data/collector.py b/src/pc/data/collector.py index eb48bbc..ca2fd80 100644 --- a/src/pc/data/collector.py +++ b/src/pc/data/collector.py @@ -12,11 +12,15 @@ import cv2 import numpy as np -# デフォルトの保存先(プロジェクトルート / data) +# プロジェクトルート _PROJECT_ROOT: Path = ( Path(__file__).resolve().parent.parent.parent.parent ) -DEFAULT_DATA_DIR: Path = _PROJECT_ROOT / "data" + +# データディレクトリ +DATA_DIR: Path = _PROJECT_ROOT / "data" +RAW_DIR: Path = DATA_DIR / "raw" +CONFIRMED_DIR: Path = DATA_DIR / "confirmed" # ラベル名 LABEL_INTERSECTION: str = "intersection" @@ -27,16 +31,16 @@ """二値画像をラベル付きで保存するコレクタ Attributes: - data_dir: 保存先ルートディレクトリ + raw_dir: 未確定データの保存先ルートディレクトリ session_dir: 現在の録画セッションのディレクトリ is_recording: 録画中かどうか """ def __init__( self, - data_dir: Path = DEFAULT_DATA_DIR, + raw_dir: Path = RAW_DIR, ) -> None: - self._data_dir = data_dir + self._raw_dir = raw_dir self._session_dir: Path | None = None self._is_recording: bool = False self._count_intersection: int = 0 @@ -67,7 +71,7 @@ 作成したセッションディレクトリのパス """ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - self._session_dir = self._data_dir / timestamp + self._session_dir = self._raw_dir / timestamp (self._session_dir / LABEL_INTERSECTION).mkdir( parents=True, exist_ok=True, ) diff --git a/src/pc/data/reviewer.py b/src/pc/data/reviewer.py new file mode 100644 index 0000000..670bdd8 --- /dev/null +++ b/src/pc/data/reviewer.py @@ -0,0 +1,413 @@ +""" +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'' + f"{label_text}" + 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) diff --git a/src/pc/review.py b/src/pc/review.py new file mode 100644 index 0000000..b62364a --- /dev/null +++ b/src/pc/review.py @@ -0,0 +1,22 @@ +""" +review +データ仕分け GUI のエントリーポイント +""" + +import sys + +from PySide6.QtWidgets import QApplication + +from pc.data.reviewer import ReviewWindow + + +def main() -> None: + """データ仕分け GUI を起動する""" + app = QApplication(sys.argv) + window = ReviewWindow() + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main()