diff --git a/src/pc/gui/main_window.py b/src/pc/gui/main_window.py index a00514e..08de403 100644 --- a/src/pc/gui/main_window.py +++ b/src/pc/gui/main_window.py @@ -9,30 +9,26 @@ from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QImage, QKeyEvent, QPixmap from PySide6.QtWidgets import ( - QCheckBox, - QComboBox, - QDoubleSpinBox, - QFormLayout, - QGroupBox, QHBoxLayout, - QInputDialog, QLabel, QMainWindow, - QMessageBox, QPushButton, QScrollArea, - QSpinBox, QVBoxLayout, QWidget, ) from common import config from pc.comm.zmq_client import PcZmqClient +from pc.gui.panels import ( + ControlParamPanel, + ImageParamPanel, + OverlayPanel, +) from pc.steering.auto_params import ( load_control, load_detect_params, save_control, - save_detect_params, ) from pc.steering.base import SteeringBase from pc.steering.pd_control import PdControl, PdParams @@ -40,24 +36,12 @@ PursuitControl, PursuitParams, ) -from pc.steering.param_store import ( - ImagePreset, - PdPreset, - add_image_preset, - add_pd_preset, - delete_image_preset, - delete_pd_preset, - load_image_presets, - load_pd_presets, -) from pc.vision.line_detector import ( - DETECT_METHODS, ImageParams, LineDetectResult, detect_line, - reset_valley_tracker, ) -from pc.vision.overlay import OverlayFlags, draw_overlay +from pc.vision.overlay import draw_overlay # 映像更新間隔 (ms) FRAME_INTERVAL_MS: int = 33 @@ -89,9 +73,6 @@ self._throttle: float = 0.0 self._steer: float = 0.0 - # 自動保存の制御フラグ - self._auto_save_enabled = False - # 前回のパラメータを復元 pd_params, last_method = load_control() image_params = load_detect_params(last_method) @@ -109,19 +90,18 @@ # 最新フレームの保持(自動操縦で使用) self._latest_frame: np.ndarray | None = None - # オーバーレイ - self._overlay_flags = OverlayFlags() - self._last_detect_result: LineDetectResult | None = None + # 検出結果の保持 + self._last_detect_result: LineDetectResult | None = ( + None + ) self._setup_ui() self._setup_timers() - self._auto_save_enabled = True def _setup_ui(self) -> None: """UI を構築する""" self.setWindowTitle("RobotCar Controller") - # 中央ウィジェット central = QWidget() self.setCentralWidget(central) root_layout = QHBoxLayout(central) @@ -155,7 +135,6 @@ " padding: 4px;" ) left_layout.addWidget(self._detect_info_label) - root_layout.addLayout(left_layout, stretch=3) # 右側: スクロール可能なコントロールパネル @@ -179,9 +158,7 @@ # 自動操縦ボタン self._auto_btn = QPushButton("自動操縦 ON") self._auto_btn.setEnabled(False) - self._auto_btn.clicked.connect( - self._toggle_auto, - ) + self._auto_btn.clicked.connect(self._toggle_auto) control_layout.addWidget(self._auto_btn) # ステータス表示 @@ -198,17 +175,42 @@ self._control_label.setAlignment( Qt.AlignmentFlag.AlignCenter, ) - self._control_label.setStyleSheet("font-size: 14px;") + self._control_label.setStyleSheet( + "font-size: 14px;", + ) control_layout.addWidget(self._control_label) - # 画像処理パラメータ調整(Stage 1〜4) - self._setup_image_param_ui(control_layout) + # 画像処理パラメータパネル + self._image_panel = ImageParamPanel( + self._pd_control.image_params, + ) + self._image_panel.image_params_changed.connect( + self._on_image_params_changed, + ) + self._image_panel.method_changed.connect( + self._on_method_changed, + ) + control_layout.addWidget(self._image_panel) - # PD 制御パラメータ(操舵量計算) - self._setup_param_ui(control_layout) + # 制御パラメータパネル + self._control_panel = ControlParamPanel( + self._pd_control.params, + self._pursuit_control.params, + ) + self._control_panel.pd_params_changed.connect( + self._on_pd_params_changed, + ) + self._control_panel.pursuit_params_changed.connect( + self._on_pursuit_params_changed, + ) + self._control_panel.steering_method_changed.connect( + self._on_steering_method_changed, + ) + control_layout.addWidget(self._control_panel) - # デバッグ表示 - self._setup_overlay_ui(control_layout) + # デバッグ表示パネル + self._overlay_panel = OverlayPanel() + control_layout.addWidget(self._overlay_panel) # 操作ガイド guide = QLabel( @@ -222,7 +224,6 @@ guide.setStyleSheet("font-size: 12px; color: #666;") control_layout.addWidget(guide) - # 余白を下に詰める control_layout.addStretch() @property @@ -232,950 +233,48 @@ return self._pursuit_control return self._pd_control - def _setup_param_ui( - self, parent_layout: QVBoxLayout, - ) -> None: - """制御パラメータ調整 UI を構築する""" - group = QGroupBox("制御パラメータ") - layout = QVBoxLayout() - group.setLayout(layout) - - # 制御手法の選択 - self._steering_combo = QComboBox() - self._steering_combo.addItem( - "PD 制御", "pd", - ) - self._steering_combo.addItem( - "2点パシュート", "pursuit", - ) - layout.addWidget(self._steering_combo) - - # --- PD パラメータ --- - self._pd_param_form = QFormLayout() - layout.addLayout(self._pd_param_form) - - params = self._pd_control.params - - self._spin_kp = self._create_spin( - params.kp, 0.0, 0.05, - ) - self._pd_param_form.addRow( - "Kp (位置):", self._spin_kp, - ) - - self._spin_kh = self._create_spin( - params.kh, 0.0, 0.05, - ) - self._pd_param_form.addRow( - "Kh (傾き):", self._spin_kh, - ) - - self._spin_kd = self._create_spin( - params.kd, 0.0, 0.05, - ) - self._pd_param_form.addRow( - "Kd (微分):", self._spin_kd, - ) - - self._spin_max_steer_rate = self._create_spin( - params.max_steer_rate, 0.01, 0.01, - ) - self._pd_param_form.addRow( - "操舵制限:", self._spin_max_steer_rate, - ) - - self._spin_max_throttle = self._create_spin( - params.max_throttle, 0.0, 0.05, - ) - self._pd_param_form.addRow( - "最大速度:", self._spin_max_throttle, - ) - - self._spin_speed_k = self._create_spin( - params.speed_k, 0.0, 0.05, - ) - self._pd_param_form.addRow( - "減速係数:", self._spin_speed_k, - ) - - # PD 固有ウィジェットリスト(表示切替用) - self._pd_widgets: list[QWidget] = [ - self._spin_kp, self._spin_kh, - self._spin_kd, - ] - - # --- Pursuit パラメータ --- - self._pursuit_param_form = QFormLayout() - layout.addLayout(self._pursuit_param_form) - - pp = self._pursuit_control.params - - self._spin_near_ratio = self._create_spin( - pp.near_ratio, 0.0, 0.05, - ) - self._pursuit_param_form.addRow( - "近目標(比率):", self._spin_near_ratio, - ) - - self._spin_far_ratio = self._create_spin( - pp.far_ratio, 0.0, 0.05, - ) - self._pursuit_param_form.addRow( - "遠目標(比率):", self._spin_far_ratio, - ) - - self._spin_k_near = self._create_spin( - pp.k_near, 0.0, 0.05, - ) - self._pursuit_param_form.addRow( - "K_near:", self._spin_k_near, - ) - - self._spin_k_far = self._create_spin( - pp.k_far, 0.0, 0.05, - ) - self._pursuit_param_form.addRow( - "K_far:", self._spin_k_far, - ) - - self._spin_pursuit_steer_rate = self._create_spin( - pp.max_steer_rate, 0.01, 0.01, - ) - self._pursuit_param_form.addRow( - "操舵制限:", self._spin_pursuit_steer_rate, - ) - - self._spin_pursuit_throttle = self._create_spin( - pp.max_throttle, 0.0, 0.05, - ) - self._pursuit_param_form.addRow( - "最大速度:", self._spin_pursuit_throttle, - ) - - self._spin_pursuit_speed_k = self._create_spin( - pp.speed_k, 0.0, 0.1, - ) - self._pursuit_param_form.addRow( - "減速係数:", self._spin_pursuit_speed_k, - ) - - # Pursuit 固有ウィジェットリスト(表示切替用) - self._pursuit_widgets: list[QWidget] = [ - self._spin_near_ratio, - self._spin_far_ratio, - self._spin_k_near, - self._spin_k_far, - self._spin_pursuit_steer_rate, - self._spin_pursuit_throttle, - self._spin_pursuit_speed_k, - ] - - # --- プリセット管理 --- - self._pd_preset_combo, self._pd_preset_memo = ( - self._create_preset_ui( - layout, - self._on_load_pd_preset, - self._on_save_pd_preset, - self._on_delete_pd_preset, - self._on_pd_preset_selected, - ) - ) - - # コールバック接続 - self._steering_combo.currentIndexChanged.connect( - self._on_steering_method_changed, - ) - for spin in [ - self._spin_kp, self._spin_kh, - self._spin_kd, self._spin_max_steer_rate, - self._spin_max_throttle, self._spin_speed_k, - ]: - spin.valueChanged.connect( - self._on_param_changed, - ) - for spin in self._pursuit_widgets: - spin.valueChanged.connect( - self._on_pursuit_param_changed, - ) - self._pd_presets: list[PdPreset] = [] - self._refresh_pd_presets() - - # 初期表示の更新 - self._on_steering_method_changed() - - parent_layout.addWidget(group) - - def _setup_image_param_ui( - self, parent_layout: QVBoxLayout, - ) -> None: - """画像処理パラメータ調整 UI を構築する""" - group = QGroupBox("画像処理パラメータ") - layout = QVBoxLayout() - group.setLayout(layout) - - ip = self._pd_control.image_params - - # 検出手法の選択コンボボックス - self._method_combo = QComboBox() - for key, label in DETECT_METHODS.items(): - self._method_combo.addItem(label, key) - idx = self._method_combo.findData(ip.method) - if idx >= 0: - self._method_combo.setCurrentIndex(idx) - layout.addWidget(self._method_combo) - - # パラメータフォーム - self._image_form = QFormLayout() - layout.addLayout(self._image_form) - - # 各パラメータの可視性マッピング - # (widget, 表示する手法の集合) - self._image_param_vis: list[ - tuple[QWidget, set[str]] - ] = [] - - # --- 現行手法パラメータ --- - self._spin_clahe_clip = self._create_spin( - ip.clahe_clip, 0.5, 0.5, - ) - self._add_image_row( - "CLAHE強度:", self._spin_clahe_clip, - {"current"}, "clahe_clip", - ) - - self._spin_binary_thresh = QSpinBox() - self._spin_binary_thresh.setRange(0, 255) - self._spin_binary_thresh.setValue( - ip.binary_thresh, - ) - self._add_image_row( - "二値化閾値:", self._spin_binary_thresh, - {"current", "blackhat"}, "binary_thresh", - ) - - self._spin_open_size = QSpinBox() - self._spin_open_size.setRange(1, 999) - self._spin_open_size.setSingleStep(2) - self._spin_open_size.setValue(ip.open_size) - self._add_image_row( - "ノイズ除去:", self._spin_open_size, - {"current"}, "open_size", - ) - - self._spin_close_width = QSpinBox() - self._spin_close_width.setRange(1, 999) - self._spin_close_width.setSingleStep(2) - self._spin_close_width.setValue(ip.close_width) - self._add_image_row( - "途切れ補間:", self._spin_close_width, - {"current"}, "close_width", - ) - - # --- 案A/C: Black-hat --- - self._spin_blackhat_ksize = QSpinBox() - self._spin_blackhat_ksize.setRange(1, 999) - self._spin_blackhat_ksize.setSingleStep(2) - self._spin_blackhat_ksize.setValue( - ip.blackhat_ksize, - ) - self._add_image_row( - "BHカーネル:", self._spin_blackhat_ksize, - {"blackhat", "robust"}, "blackhat_ksize", - ) - - # --- 案B: 背景除算 --- - self._spin_bg_blur_ksize = QSpinBox() - self._spin_bg_blur_ksize.setRange(1, 999) - self._spin_bg_blur_ksize.setSingleStep(2) - self._spin_bg_blur_ksize.setValue( - ip.bg_blur_ksize, - ) - self._add_image_row( - "背景ブラー:", self._spin_bg_blur_ksize, - {"dual_norm"}, "bg_blur_ksize", - ) - - self._spin_global_thresh = QSpinBox() - self._spin_global_thresh.setRange(0, 255) - self._spin_global_thresh.setValue( - ip.global_thresh, - ) - self._spin_global_thresh.setSpecialValueText( - "無効", - ) - self._add_image_row( - "固定閾値:", self._spin_global_thresh, - {"dual_norm"}, "global_thresh", - ) - - # --- 案B/C: 適応的閾値 --- - self._spin_adaptive_block = QSpinBox() - self._spin_adaptive_block.setRange(3, 999) - self._spin_adaptive_block.setSingleStep(2) - self._spin_adaptive_block.setValue( - ip.adaptive_block, - ) - self._add_image_row( - "適応ブロック:", self._spin_adaptive_block, - {"dual_norm", "robust"}, "adaptive_block", - ) - - self._spin_adaptive_c = QSpinBox() - self._spin_adaptive_c.setRange(0, 255) - self._spin_adaptive_c.setValue(ip.adaptive_c) - self._add_image_row( - "適応定数C:", self._spin_adaptive_c, - {"dual_norm", "robust"}, "adaptive_c", - ) - - # --- 案A/B/C: 後処理 --- - self._spin_iso_close = QSpinBox() - self._spin_iso_close.setRange(1, 999) - self._spin_iso_close.setSingleStep(2) - self._spin_iso_close.setValue( - ip.iso_close_size, - ) - self._add_image_row( - "穴埋め:", self._spin_iso_close, - {"blackhat", "dual_norm", "robust"}, - "iso_close_size", - ) - - self._spin_dist_thresh = self._create_spin( - ip.dist_thresh, 0.0, 0.5, - ) - self._add_image_row( - "距離閾値:", self._spin_dist_thresh, - {"blackhat", "dual_norm", "robust"}, - "dist_thresh", - ) - - self._spin_min_line_width = QSpinBox() - self._spin_min_line_width.setRange(1, 999) - self._spin_min_line_width.setValue( - ip.min_line_width, - ) - self._add_image_row( - "最小線幅:", self._spin_min_line_width, - {"blackhat", "dual_norm", "robust"}, - "min_line_width", - ) - - # --- 案B: 段階クロージング --- - self._spin_stage_close_small = QSpinBox() - self._spin_stage_close_small.setRange(1, 999) - self._spin_stage_close_small.setSingleStep(2) - self._spin_stage_close_small.setValue( - ip.stage_close_small, - ) - self._add_image_row( - "段階穴埋め(小):", - self._spin_stage_close_small, - {"dual_norm"}, "stage_close_small", - ) - - self._spin_stage_min_area = QSpinBox() - self._spin_stage_min_area.setRange(0, 99999) - self._spin_stage_min_area.setValue( - ip.stage_min_area, - ) - self._spin_stage_min_area.setSpecialValueText( - "無効", - ) - self._add_image_row( - "孤立除去面積:", - self._spin_stage_min_area, - {"dual_norm"}, "stage_min_area", - ) - - self._spin_stage_close_large = QSpinBox() - self._spin_stage_close_large.setRange(0, 999) - self._spin_stage_close_large.setSingleStep(2) - self._spin_stage_close_large.setValue( - ip.stage_close_large, - ) - self._spin_stage_close_large.setSpecialValueText( - "無効", - ) - self._add_image_row( - "段階穴埋め(大):", - self._spin_stage_close_large, - {"dual_norm"}, "stage_close_large", - ) - - # --- ロバストフィッティング(全手法共通) --- - all_methods = { - "blackhat", "dual_norm", "robust", "valley", - } - - self._spin_median_ksize = QSpinBox() - self._spin_median_ksize.setRange(0, 999) - self._spin_median_ksize.setSingleStep(2) - self._spin_median_ksize.setValue(ip.median_ksize) - self._spin_median_ksize.setSpecialValueText( - "無効", - ) - self._add_image_row( - "メディアン:", self._spin_median_ksize, - all_methods, "median_ksize", - ) - - self._spin_neighbor_thresh = self._create_spin( - ip.neighbor_thresh, 0.0, 1.0, - ) - self._add_image_row( - "近傍除去:", self._spin_neighbor_thresh, - all_methods, "neighbor_thresh", - ) - - self._spin_residual_thresh = self._create_spin( - ip.residual_thresh, 0.0, 1.0, - ) - self._add_image_row( - "残差除去:", self._spin_residual_thresh, - all_methods, "residual_thresh", - ) - - # --- 案C/D: RANSAC --- - self._spin_ransac_thresh = self._create_spin( - ip.ransac_thresh, 1.0, 1.0, - ) - self._add_image_row( - "RANSAC閾値:", self._spin_ransac_thresh, - {"robust", "valley"}, "ransac_thresh", - ) - - # --- 幅フィルタ(透視補正) --- - self._spin_width_near = QSpinBox() - self._spin_width_near.setRange(0, 9999) - self._spin_width_near.setValue(ip.width_near) - self._spin_width_near.setSpecialValueText("無効") - self._add_image_row( - "線幅(近)px:", self._spin_width_near, - {"blackhat", "dual_norm", "robust", "valley"}, - "width_near", - ) - - self._spin_width_far = QSpinBox() - self._spin_width_far.setRange(0, 9999) - self._spin_width_far.setValue(ip.width_far) - self._spin_width_far.setSpecialValueText("無効") - self._add_image_row( - "線幅(遠)px:", self._spin_width_far, - {"blackhat", "dual_norm", "robust", "valley"}, - "width_far", - ) - - self._spin_width_tolerance = self._create_spin( - ip.width_tolerance, 1.0, 0.1, - ) - self._add_image_row( - "幅フィルタ倍率:", self._spin_width_tolerance, - {"blackhat", "dual_norm", "robust", "valley"}, - "width_tolerance", - ) - - # --- 案D: 谷検出+追跡 --- - self._spin_valley_gauss = QSpinBox() - self._spin_valley_gauss.setRange(3, 999) - self._spin_valley_gauss.setSingleStep(2) - self._spin_valley_gauss.setValue( - ip.valley_gauss_ksize, - ) - self._add_image_row( - "谷ガウス:", self._spin_valley_gauss, - {"valley"}, "valley_gauss_ksize", - ) - - self._spin_valley_min_depth = QSpinBox() - self._spin_valley_min_depth.setRange(1, 255) - self._spin_valley_min_depth.setValue( - ip.valley_min_depth, - ) - self._add_image_row( - "最小谷深度:", self._spin_valley_min_depth, - {"valley"}, "valley_min_depth", - ) - - self._spin_valley_max_dev = QSpinBox() - self._spin_valley_max_dev.setRange(1, 9999) - self._spin_valley_max_dev.setValue( - ip.valley_max_deviation, - ) - self._add_image_row( - "最大偏差:", self._spin_valley_max_dev, - {"valley"}, "valley_max_deviation", - ) - - self._spin_valley_coast = QSpinBox() - self._spin_valley_coast.setRange(0, 999) - self._spin_valley_coast.setValue( - ip.valley_coast_frames, - ) - self._add_image_row( - "予測継続:", self._spin_valley_coast, - {"valley"}, "valley_coast_frames", - ) - - self._spin_valley_ema = self._create_spin( - ip.valley_ema_alpha, 0.0, 0.05, - ) - self._add_image_row( - "EMA係数:", self._spin_valley_ema, - {"valley"}, "valley_ema_alpha", - ) - - # --- プリセット管理 --- - combo, memo = self._create_preset_ui( - layout, - self._on_load_image_preset, - self._on_save_image_preset, - self._on_delete_image_preset, - self._on_image_preset_selected, - ) - self._image_preset_combo = combo - self._image_preset_memo = memo - - # コールバック接続 - self._method_combo.currentIndexChanged.connect( - self._on_method_changed, - ) - for widget, _, _ in self._image_param_vis: - widget.valueChanged.connect( - self._on_image_param_changed, - ) - - self._image_presets: list[ImagePreset] = [] - self._image_filtered: list[int] = [] - - parent_layout.addWidget(group) - - # 初期表示の更新 - self._on_method_changed() - - def _add_image_row( - self, - label: str, - widget: QWidget, - methods: set[str], - field: str, - ) -> None: - """画像処理パラメータの行を追加する - - Args: - label: フォームラベル - widget: SpinBox ウィジェット - methods: 表示対象の検出手法集合 - field: ImageParams のフィールド名 - """ - self._image_form.addRow(label, widget) - self._image_param_vis.append( - (widget, methods, field), - ) - - def _on_method_changed(self) -> None: - """検出手法の変更を反映する""" - method = self._method_combo.currentData() - - # 谷検出の追跡状態をリセット - reset_valley_tracker() - - # 旧手法のパラメータを保存 - if self._auto_save_enabled: - ip = self._pd_control.image_params - save_detect_params(ip.method, ip) - - # 新手法のパラメータを読み込み - if self._auto_save_enabled: - new_ip = load_detect_params(method) - self._set_image_params(new_ip) - self._sync_image_spinboxes() - save_control( - self._pd_control.params, method, - ) - else: - self._pd_control.image_params.method = ( - method - ) - - # パラメータの表示/非表示を更新 - for widget, methods, _ in self._image_param_vis: - visible = method in methods - widget.setVisible(visible) - label = self._image_form.labelForField( - widget, - ) - if label: - label.setVisible(visible) - - # 保存済みプリセットを手法でフィルタ - if hasattr(self, "_image_preset_combo"): - self._refresh_image_presets() - - def _set_image_params( - self, ip: ImageParams, - ) -> None: - """両制御クラスの image_params を一括設定する""" - self._pd_control.image_params = ip - self._pursuit_control.image_params = ip - - def _sync_image_spinboxes(self) -> None: - """画像処理パラメータの SpinBox を現在値に同期する""" - self._auto_save_enabled = False - try: - ip = self._pd_control.image_params - for widget, _, field in self._image_param_vis: - widget.setValue(getattr(ip, field)) - finally: - self._auto_save_enabled = True - - def _on_image_param_changed(self) -> None: - """画像処理パラメータの変更を反映する""" - ip = self._pd_control.image_params - for widget, _, field in self._image_param_vis: - setattr(ip, field, widget.value()) - - if self._auto_save_enabled: - save_detect_params(ip.method, ip) - - # ── 画像処理プリセット ────────────────────────── - - def _refresh_image_presets(self) -> None: - """選択中の手法のプリセットだけ表示する""" - self._image_presets = load_image_presets() - method = self._method_combo.currentData() - self._image_preset_combo.clear() - self._image_filtered = [] - for i, p in enumerate(self._image_presets): - if p.image_params.method == method: - self._image_preset_combo.addItem( - p.title, - ) - self._image_filtered.append(i) - self._image_preset_memo.setText("") - - def _on_image_preset_selected(self) -> None: - """画像処理プリセット選択時にメモを表示""" - idx = self._get_image_preset_idx() - if idx >= 0: - self._image_preset_memo.setText( - self._image_presets[idx].memo, - ) - else: - self._image_preset_memo.setText("") - - def _get_image_preset_idx(self) -> int: - """コンボの選択を全体インデックスに変換""" - ci = self._image_preset_combo.currentIndex() - if ci < 0 or ci >= len(self._image_filtered): - return -1 - return self._image_filtered[ci] - - def _on_load_image_preset(self) -> None: - """画像処理プリセットを読み込む""" - idx = self._get_image_preset_idx() - if idx < 0: - return - self._auto_save_enabled = False - try: - ip = self._image_presets[idx].image_params - self._set_image_params(ip) - self._sync_image_spinboxes() - finally: - self._auto_save_enabled = True - save_detect_params(ip.method, ip) - - def _on_save_image_preset(self) -> None: - """画像処理プリセットを保存する""" - title, ok = QInputDialog.getText( - self, "画像処理プリセット保存", "タイトル:", - ) - if not ok or not title.strip(): - return - memo, ok = QInputDialog.getText( - self, "画像処理プリセット保存", "メモ:", - ) - if not ok: - return - ip = self._pd_control.image_params - add_image_preset(ImagePreset( - title=title.strip(), - memo=memo.strip(), - image_params=ImageParams(**{ - f.name: getattr(ip, f.name) - for f in ip.__dataclass_fields__.values() - }), - )) - self._refresh_image_presets() - self._image_preset_combo.setCurrentIndex( - self._image_preset_combo.count() - 1, - ) - - def _on_delete_image_preset(self) -> None: - """画像処理プリセットを削除する""" - idx = self._get_image_preset_idx() - if idx < 0: - return - title = self._image_presets[idx].title - reply = QMessageBox.question( - self, "削除確認", - f"「{title}」を削除しますか?", - QMessageBox.StandardButton.Yes - | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - delete_image_preset(idx) - self._refresh_image_presets() - - # ── PD 制御プリセット ───────────────────────── - - def _refresh_pd_presets(self) -> None: - """PD 制御プリセット一覧を更新する""" - self._pd_presets = load_pd_presets() - self._pd_preset_combo.clear() - for p in self._pd_presets: - self._pd_preset_combo.addItem(p.title) - self._pd_preset_memo.setText("") - - def _on_pd_preset_selected(self) -> None: - """PD 制御プリセット選択時にメモを表示""" - idx = self._pd_preset_combo.currentIndex() - if 0 <= idx < len(self._pd_presets): - self._pd_preset_memo.setText( - self._pd_presets[idx].memo, - ) - else: - self._pd_preset_memo.setText("") - - def _on_load_pd_preset(self) -> None: - """PD 制御プリセットを読み込む""" - idx = self._pd_preset_combo.currentIndex() - if idx < 0 or idx >= len(self._pd_presets): - return - self._auto_save_enabled = False - try: - p = self._pd_presets[idx].params - self._spin_kp.setValue(p.kp) - self._spin_kh.setValue(p.kh) - self._spin_kd.setValue(p.kd) - self._spin_max_steer_rate.setValue( - p.max_steer_rate, - ) - self._spin_max_throttle.setValue( - p.max_throttle, - ) - self._spin_speed_k.setValue(p.speed_k) - self._pd_control.params = p - finally: - self._auto_save_enabled = True - save_control( - p, - self._pd_control.image_params.method, - ) - - def _on_save_pd_preset(self) -> None: - """PD 制御プリセットを保存する""" - title, ok = QInputDialog.getText( - self, "PD プリセット保存", "タイトル:", - ) - if not ok or not title.strip(): - return - memo, ok = QInputDialog.getText( - self, "PD プリセット保存", "メモ:", - ) - if not ok: - return - p = self._pd_control.params - add_pd_preset(PdPreset( - title=title.strip(), - memo=memo.strip(), - params=PdParams( - kp=p.kp, kh=p.kh, kd=p.kd, - max_steer_rate=p.max_steer_rate, - max_throttle=p.max_throttle, - speed_k=p.speed_k, - ), - )) - self._refresh_pd_presets() - self._pd_preset_combo.setCurrentIndex( - self._pd_preset_combo.count() - 1, - ) - - def _on_delete_pd_preset(self) -> None: - """PD 制御プリセットを削除する""" - idx = self._pd_preset_combo.currentIndex() - if idx < 0 or idx >= len(self._pd_presets): - return - title = self._pd_presets[idx].title - reply = QMessageBox.question( - self, "削除確認", - f"「{title}」を削除しますか?", - QMessageBox.StandardButton.Yes - | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - delete_pd_preset(idx) - self._refresh_pd_presets() - - def _setup_overlay_ui( - self, parent_layout: QVBoxLayout, - ) -> None: - """デバッグ表示のチェックボックス UI を構築する""" - group = QGroupBox("デバッグ表示") - layout = QVBoxLayout() - group.setLayout(layout) - - items = [ - ("二値化画像", "binary"), - ("検出領域", "detect_region"), - ("フィッティング曲線", "poly_curve"), - ("行中心点", "row_centers"), - ("Theil-Sen直線", "theil_sen"), - ("中心線", "center_line"), - ] - for label, attr in items: - cb = QCheckBox(label) - cb.toggled.connect( - lambda checked, a=attr: - setattr(self._overlay_flags, a, checked), - ) - layout.addWidget(cb) - - parent_layout.addWidget(group) - - @staticmethod - def _create_preset_ui( - layout: QVBoxLayout, - on_load, - on_save, - on_delete, - on_selected, - ) -> tuple[QComboBox, QLabel]: - """プリセット管理 UI(ComboBox + メモ + ボタン)を作成する - - Returns: - (コンボボックス, メモラベル) のタプル - """ - combo = QComboBox() - combo.setPlaceholderText("プリセット") - layout.addWidget(combo) - - memo = QLabel("") - memo.setWordWrap(True) - memo.setStyleSheet( - "font-size: 11px; color: #888;", - ) - layout.addWidget(memo) - - btn_layout = QHBoxLayout() - for text, callback in [ - ("読込", on_load), - ("保存", on_save), - ("削除", on_delete), - ]: - btn = QPushButton(text) - btn.clicked.connect(callback) - btn_layout.addWidget(btn) - layout.addLayout(btn_layout) - - combo.currentIndexChanged.connect(on_selected) - return combo, memo - - @staticmethod - def _create_spin( - value: float, min_val: float, step: float, - ) -> QDoubleSpinBox: - """パラメータ用の QDoubleSpinBox を作成する - - 直接入力にも対応するため,上限は広めに設定する - """ - spin = QDoubleSpinBox() - spin.setRange(min_val, 99999.0) - spin.setSingleStep(step) - spin.setDecimals(3) - spin.setValue(value) - return spin - - def _on_param_changed(self) -> None: - """PD パラメータ SpinBox の値が変更されたときに反映する""" - p = self._pd_control.params - p.kp = self._spin_kp.value() - p.kh = self._spin_kh.value() - p.kd = self._spin_kd.value() - p.max_steer_rate = ( - self._spin_max_steer_rate.value() - ) - p.max_throttle = self._spin_max_throttle.value() - p.speed_k = self._spin_speed_k.value() - - if self._auto_save_enabled: - save_control( - p, - self._pd_control.image_params.method, - ) - - def _on_pursuit_param_changed(self) -> None: - """Pursuit パラメータの変更を反映する""" - p = self._pursuit_control.params - p.near_ratio = self._spin_near_ratio.value() - p.far_ratio = self._spin_far_ratio.value() - p.k_near = self._spin_k_near.value() - p.k_far = self._spin_k_far.value() - p.max_steer_rate = ( - self._spin_pursuit_steer_rate.value() - ) - p.max_throttle = ( - self._spin_pursuit_throttle.value() - ) - p.speed_k = self._spin_pursuit_speed_k.value() - - def _on_steering_method_changed(self) -> None: - """制御手法の変更を反映する""" - method = self._steering_combo.currentData() - self._steering_method = method - is_pd = method == "pd" - - # PD 固有ウィジェットの表示切替 - for w in self._pd_widgets: - w.setVisible(is_pd) - label = self._pd_param_form.labelForField(w) - if label: - label.setVisible(is_pd) - - # 共通ウィジェット(操舵制限/最大速度/減速係数) - for w in [ - self._spin_max_steer_rate, - self._spin_max_throttle, - self._spin_speed_k, - ]: - w.setVisible(is_pd) - label = self._pd_param_form.labelForField(w) - if label: - label.setVisible(is_pd) - - # Pursuit ウィジェットの表示切替 - for w in self._pursuit_widgets: - w.setVisible(not is_pd) - label = ( - self._pursuit_param_form.labelForField(w) - ) - if label: - label.setVisible(not is_pd) - def _setup_timers(self) -> None: """タイマーを設定する""" - # 映像更新用 self._frame_timer = QTimer(self) self._frame_timer.timeout.connect(self._update_frame) - # 操舵量送信用 self._control_timer = QTimer(self) self._control_timer.timeout.connect( self._send_control, ) + # ── パネルシグナルのスロット ─────────────────────────── + + def _on_image_params_changed( + self, ip: ImageParams, + ) -> None: + """画像処理パラメータの変更を両制御クラスに反映する""" + self._pd_control.image_params = ip + self._pursuit_control.image_params = ip + + def _on_method_changed(self, method: str) -> None: + """検出手法の変更に合わせて制御設定を保存する""" + save_control(self._pd_control.params, method) + + def _on_pd_params_changed(self, p: PdParams) -> None: + """PD パラメータの変更を制御クラスに反映して保存する""" + self._pd_control.params = p + save_control( + p, self._pd_control.image_params.method, + ) + + def _on_pursuit_params_changed( + self, p: PursuitParams, + ) -> None: + """Pursuit パラメータの変更を制御クラスに反映する""" + self._pursuit_control.params = p + + def _on_steering_method_changed( + self, method: str, + ) -> None: + """制御手法の切替を反映する""" + self._steering_method = method + # ── 接続 ────────────────────────────────────────────── def _toggle_connection(self) -> None: @@ -1295,7 +394,7 @@ # オーバーレイ描画 bgr = draw_overlay( bgr, self._last_detect_result, - self._overlay_flags, + self._overlay_panel.get_flags(), ) # 検出情報をラベルに表示 @@ -1308,13 +407,8 @@ rgb.data, w, h, ch * w, QImage.Format.Format_RGB888, ) - # 表示サイズは常に原寸基準で固定 - disp_w = int( - config.FRAME_WIDTH * DISPLAY_SCALE, - ) - disp_h = int( - config.FRAME_HEIGHT * DISPLAY_SCALE, - ) + disp_w = int(config.FRAME_WIDTH * DISPLAY_SCALE) + disp_h = int(config.FRAME_HEIGHT * DISPLAY_SCALE) pixmap = QPixmap.fromImage(image).scaled( disp_w, disp_h, @@ -1359,7 +453,6 @@ self._throttle = 0.0 self._steer = 0.0 self._pressed_keys.clear() - # 自動操縦中なら停止 if self._is_auto: self._disable_auto() self._update_control_label() @@ -1367,8 +460,7 @@ # throttle: W/↑ で前進,S/↓ で後退 forward = ( - Qt.Key.Key_W in keys - or Qt.Key.Key_Up in keys + Qt.Key.Key_W in keys or Qt.Key.Key_Up in keys ) backward = ( Qt.Key.Key_S in keys diff --git a/src/pc/gui/panels/__init__.py b/src/pc/gui/panels/__init__.py new file mode 100644 index 0000000..7988084 --- /dev/null +++ b/src/pc/gui/panels/__init__.py @@ -0,0 +1,14 @@ +""" +panels +GUI パネルウィジェット群 +""" + +from pc.gui.panels.control_param_panel import ControlParamPanel +from pc.gui.panels.image_param_panel import ImageParamPanel +from pc.gui.panels.overlay_panel import OverlayPanel + +__all__ = [ + "ControlParamPanel", + "ImageParamPanel", + "OverlayPanel", +] diff --git a/src/pc/gui/panels/control_param_panel.py b/src/pc/gui/panels/control_param_panel.py new file mode 100644 index 0000000..864288d --- /dev/null +++ b/src/pc/gui/panels/control_param_panel.py @@ -0,0 +1,382 @@ +""" +control_param_panel +PD / 2点パシュート制御パラメータ調整 UI パネル +""" + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QInputDialog, + QLabel, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from pc.gui.panels.image_param_panel import _create_preset_ui +from pc.steering.param_store import ( + PdPreset, + add_pd_preset, + delete_pd_preset, + load_pd_presets, +) +from pc.steering.pd_control import PdParams +from pc.steering.pursuit_control import PursuitParams + + +class ControlParamPanel(QGroupBox): + """PD / 2点パシュート制御パラメータ調整 UI""" + + # PD パラメータが変更されたときに emit する + pd_params_changed = Signal(object) + # Pursuit パラメータが変更されたときに emit する + pursuit_params_changed = Signal(object) + # 制御手法が変更されたときに emit する("pd" or "pursuit") + steering_method_changed = Signal(str) + + def __init__( + self, + pd_params: PdParams, + pursuit_params: PursuitParams, + ) -> None: + super().__init__("制御パラメータ") + self._pd_params = pd_params + self._pursuit_params = pursuit_params + self._auto_save_enabled = False + self._pd_presets: list[PdPreset] = [] + + self._setup_ui() + self._auto_save_enabled = True + + def get_pd_params(self) -> PdParams: + """現在の PD パラメータを返す""" + return self._pd_params + + def get_pursuit_params(self) -> PursuitParams: + """現在の Pursuit パラメータを返す""" + return self._pursuit_params + + def _setup_ui(self) -> None: + """UI を構築する""" + layout = QVBoxLayout() + self.setLayout(layout) + + # 制御手法の選択 + self._steering_combo = QComboBox() + self._steering_combo.addItem("PD 制御", "pd") + self._steering_combo.addItem( + "2点パシュート", "pursuit", + ) + layout.addWidget(self._steering_combo) + + # --- PD パラメータ --- + self._pd_param_form = QFormLayout() + layout.addLayout(self._pd_param_form) + + p = self._pd_params + + self._spin_kp = _create_spin(p.kp, 0.0, 0.05) + self._pd_param_form.addRow("Kp (位置):", self._spin_kp) + + self._spin_kh = _create_spin(p.kh, 0.0, 0.05) + self._pd_param_form.addRow("Kh (傾き):", self._spin_kh) + + self._spin_kd = _create_spin(p.kd, 0.0, 0.05) + self._pd_param_form.addRow("Kd (微分):", self._spin_kd) + + self._spin_max_steer_rate = _create_spin( + p.max_steer_rate, 0.01, 0.01, + ) + self._pd_param_form.addRow( + "操舵制限:", self._spin_max_steer_rate, + ) + + self._spin_max_throttle = _create_spin( + p.max_throttle, 0.0, 0.05, + ) + self._pd_param_form.addRow( + "最大速度:", self._spin_max_throttle, + ) + + self._spin_speed_k = _create_spin( + p.speed_k, 0.0, 0.05, + ) + self._pd_param_form.addRow( + "減速係数:", self._spin_speed_k, + ) + + # PD 固有ウィジェットリスト(表示切替用) + self._pd_widgets: list[QWidget] = [ + self._spin_kp, + self._spin_kh, + self._spin_kd, + ] + + # --- Pursuit パラメータ --- + self._pursuit_param_form = QFormLayout() + layout.addLayout(self._pursuit_param_form) + + pp = self._pursuit_params + + self._spin_near_ratio = _create_spin( + pp.near_ratio, 0.0, 0.05, + ) + self._pursuit_param_form.addRow( + "近目標(比率):", self._spin_near_ratio, + ) + + self._spin_far_ratio = _create_spin( + pp.far_ratio, 0.0, 0.05, + ) + self._pursuit_param_form.addRow( + "遠目標(比率):", self._spin_far_ratio, + ) + + self._spin_k_near = _create_spin( + pp.k_near, 0.0, 0.05, + ) + self._pursuit_param_form.addRow( + "K_near:", self._spin_k_near, + ) + + self._spin_k_far = _create_spin( + pp.k_far, 0.0, 0.05, + ) + self._pursuit_param_form.addRow( + "K_far:", self._spin_k_far, + ) + + self._spin_pursuit_steer_rate = _create_spin( + pp.max_steer_rate, 0.01, 0.01, + ) + self._pursuit_param_form.addRow( + "操舵制限:", self._spin_pursuit_steer_rate, + ) + + self._spin_pursuit_throttle = _create_spin( + pp.max_throttle, 0.0, 0.05, + ) + self._pursuit_param_form.addRow( + "最大速度:", self._spin_pursuit_throttle, + ) + + self._spin_pursuit_speed_k = _create_spin( + pp.speed_k, 0.0, 0.1, + ) + self._pursuit_param_form.addRow( + "減速係数:", self._spin_pursuit_speed_k, + ) + + # Pursuit 固有ウィジェットリスト(表示切替用) + self._pursuit_widgets: list[QWidget] = [ + self._spin_near_ratio, + self._spin_far_ratio, + self._spin_k_near, + self._spin_k_far, + self._spin_pursuit_steer_rate, + self._spin_pursuit_throttle, + self._spin_pursuit_speed_k, + ] + + # --- プリセット管理 --- + self._pd_preset_combo, self._pd_preset_memo = ( + _create_preset_ui( + layout, + self._on_load_pd_preset, + self._on_save_pd_preset, + self._on_delete_pd_preset, + self._on_pd_preset_selected, + ) + ) + + # コールバック接続 + self._steering_combo.currentIndexChanged.connect( + self._on_steering_method_changed, + ) + for spin in [ + self._spin_kp, self._spin_kh, + self._spin_kd, self._spin_max_steer_rate, + self._spin_max_throttle, self._spin_speed_k, + ]: + spin.valueChanged.connect(self._on_pd_changed) + for spin in self._pursuit_widgets: + spin.valueChanged.connect( + self._on_pursuit_changed, + ) + + self._refresh_pd_presets() + + # 初期表示の更新 + self._on_steering_method_changed() + + def _on_pd_changed(self) -> None: + """PD パラメータ SpinBox の値が変更されたときに反映する""" + p = self._pd_params + p.kp = self._spin_kp.value() + p.kh = self._spin_kh.value() + p.kd = self._spin_kd.value() + p.max_steer_rate = self._spin_max_steer_rate.value() + p.max_throttle = self._spin_max_throttle.value() + p.speed_k = self._spin_speed_k.value() + + if self._auto_save_enabled: + self.pd_params_changed.emit(p) + + def _on_pursuit_changed(self) -> None: + """Pursuit パラメータの変更を反映する""" + p = self._pursuit_params + p.near_ratio = self._spin_near_ratio.value() + p.far_ratio = self._spin_far_ratio.value() + p.k_near = self._spin_k_near.value() + p.k_far = self._spin_k_far.value() + p.max_steer_rate = ( + self._spin_pursuit_steer_rate.value() + ) + p.max_throttle = ( + self._spin_pursuit_throttle.value() + ) + p.speed_k = self._spin_pursuit_speed_k.value() + + if self._auto_save_enabled: + self.pursuit_params_changed.emit(p) + + def _on_steering_method_changed(self) -> None: + """制御手法の変更を反映する""" + method = self._steering_combo.currentData() + is_pd = method == "pd" + + # PD 固有ウィジェットの表示切替 + for w in self._pd_widgets: + w.setVisible(is_pd) + label = self._pd_param_form.labelForField(w) + if label: + label.setVisible(is_pd) + + # 共通ウィジェット(操舵制限/最大速度/減速係数) + for w in [ + self._spin_max_steer_rate, + self._spin_max_throttle, + self._spin_speed_k, + ]: + w.setVisible(is_pd) + label = self._pd_param_form.labelForField(w) + if label: + label.setVisible(is_pd) + + # Pursuit ウィジェットの表示切替 + for w in self._pursuit_widgets: + w.setVisible(not is_pd) + label = ( + self._pursuit_param_form.labelForField(w) + ) + if label: + label.setVisible(not is_pd) + + if self._auto_save_enabled: + self.steering_method_changed.emit(method) + + # ── PD プリセット管理 ────────────────────────────────── + + def _refresh_pd_presets(self) -> None: + """PD 制御プリセット一覧を更新する""" + self._pd_presets = load_pd_presets() + self._pd_preset_combo.clear() + for p in self._pd_presets: + self._pd_preset_combo.addItem(p.title) + self._pd_preset_memo.setText("") + + def _on_pd_preset_selected(self) -> None: + """PD 制御プリセット選択時にメモを表示する""" + idx = self._pd_preset_combo.currentIndex() + if 0 <= idx < len(self._pd_presets): + self._pd_preset_memo.setText( + self._pd_presets[idx].memo, + ) + else: + self._pd_preset_memo.setText("") + + def _on_load_pd_preset(self) -> None: + """PD 制御プリセットを読み込む""" + idx = self._pd_preset_combo.currentIndex() + if idx < 0 or idx >= len(self._pd_presets): + return + self._auto_save_enabled = False + try: + p = self._pd_presets[idx].params + self._spin_kp.setValue(p.kp) + self._spin_kh.setValue(p.kh) + self._spin_kd.setValue(p.kd) + self._spin_max_steer_rate.setValue( + p.max_steer_rate, + ) + self._spin_max_throttle.setValue(p.max_throttle) + self._spin_speed_k.setValue(p.speed_k) + self._pd_params = p + finally: + self._auto_save_enabled = True + self.pd_params_changed.emit(p) + + def _on_save_pd_preset(self) -> None: + """PD 制御プリセットを保存する""" + title, ok = QInputDialog.getText( + self, "PD プリセット保存", "タイトル:", + ) + if not ok or not title.strip(): + return + memo, ok = QInputDialog.getText( + self, "PD プリセット保存", "メモ:", + ) + if not ok: + return + p = self._pd_params + add_pd_preset(PdPreset( + title=title.strip(), + memo=memo.strip(), + params=PdParams( + kp=p.kp, kh=p.kh, kd=p.kd, + max_steer_rate=p.max_steer_rate, + max_throttle=p.max_throttle, + speed_k=p.speed_k, + ), + )) + self._refresh_pd_presets() + self._pd_preset_combo.setCurrentIndex( + self._pd_preset_combo.count() - 1, + ) + + def _on_delete_pd_preset(self) -> None: + """PD 制御プリセットを削除する""" + idx = self._pd_preset_combo.currentIndex() + if idx < 0 or idx >= len(self._pd_presets): + return + title = self._pd_presets[idx].title + reply = QMessageBox.question( + self, "削除確認", + f"「{title}」を削除しますか?", + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + delete_pd_preset(idx) + self._refresh_pd_presets() + + +def _create_spin( + value: float, min_val: float, step: float, +) -> QDoubleSpinBox: + """パラメータ用の QDoubleSpinBox を作成する + + 直接入力にも対応するため,上限は広めに設定する + """ + spin = QDoubleSpinBox() + spin.setRange(min_val, 99999.0) + spin.setSingleStep(step) + spin.setDecimals(3) + spin.setValue(value) + return spin diff --git a/src/pc/gui/panels/image_param_panel.py b/src/pc/gui/panels/image_param_panel.py new file mode 100644 index 0000000..1e7a1b7 --- /dev/null +++ b/src/pc/gui/panels/image_param_panel.py @@ -0,0 +1,584 @@ +""" +image_param_panel +画像処理パラメータ調整 UI パネル +""" + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QInputDialog, + QLabel, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from pc.steering.auto_params import ( + load_detect_params, + save_detect_params, +) +from pc.steering.param_store import ( + ImagePreset, + add_image_preset, + delete_image_preset, + load_image_presets, +) +from pc.vision.line_detector import ( + DETECT_METHODS, + ImageParams, + reset_valley_tracker, +) + + +class ImageParamPanel(QGroupBox): + """画像処理パラメータ調整 UI""" + + # 画像処理パラメータが変更されたときに emit する + image_params_changed = Signal(object) + # 検出手法が変更されたときに emit する(新手法キー) + method_changed = Signal(str) + + def __init__(self, image_params: ImageParams) -> None: + super().__init__("画像処理パラメータ") + self._image_params = image_params + self._auto_save_enabled = False + self._image_presets: list[ImagePreset] = [] + self._image_filtered: list[int] = [] + self._image_param_vis: list[ + tuple[QWidget, set[str], str] + ] = [] + + self._setup_ui() + self._auto_save_enabled = True + + def get_image_params(self) -> ImageParams: + """現在の画像処理パラメータを返す""" + return self._image_params + + def _setup_ui(self) -> None: + """UI を構築する""" + layout = QVBoxLayout() + self.setLayout(layout) + + ip = self._image_params + + # 検出手法の選択コンボボックス + self._method_combo = QComboBox() + for key, label in DETECT_METHODS.items(): + self._method_combo.addItem(label, key) + idx = self._method_combo.findData(ip.method) + if idx >= 0: + self._method_combo.setCurrentIndex(idx) + layout.addWidget(self._method_combo) + + # パラメータフォーム + self._image_form = QFormLayout() + layout.addLayout(self._image_form) + + # --- 現行手法パラメータ --- + self._spin_clahe_clip = self._create_spin( + ip.clahe_clip, 0.5, 0.5, + ) + self._add_row( + "CLAHE強度:", self._spin_clahe_clip, + {"current"}, "clahe_clip", + ) + + self._spin_binary_thresh = QSpinBox() + self._spin_binary_thresh.setRange(0, 255) + self._spin_binary_thresh.setValue(ip.binary_thresh) + self._add_row( + "二値化閾値:", self._spin_binary_thresh, + {"current", "blackhat"}, "binary_thresh", + ) + + self._spin_open_size = QSpinBox() + self._spin_open_size.setRange(1, 999) + self._spin_open_size.setSingleStep(2) + self._spin_open_size.setValue(ip.open_size) + self._add_row( + "ノイズ除去:", self._spin_open_size, + {"current"}, "open_size", + ) + + self._spin_close_width = QSpinBox() + self._spin_close_width.setRange(1, 999) + self._spin_close_width.setSingleStep(2) + self._spin_close_width.setValue(ip.close_width) + self._add_row( + "途切れ補間:", self._spin_close_width, + {"current"}, "close_width", + ) + + # --- 案A/C: Black-hat --- + self._spin_blackhat_ksize = QSpinBox() + self._spin_blackhat_ksize.setRange(1, 999) + self._spin_blackhat_ksize.setSingleStep(2) + self._spin_blackhat_ksize.setValue(ip.blackhat_ksize) + self._add_row( + "BHカーネル:", self._spin_blackhat_ksize, + {"blackhat", "robust"}, "blackhat_ksize", + ) + + # --- 案B: 背景除算 --- + self._spin_bg_blur_ksize = QSpinBox() + self._spin_bg_blur_ksize.setRange(1, 999) + self._spin_bg_blur_ksize.setSingleStep(2) + self._spin_bg_blur_ksize.setValue(ip.bg_blur_ksize) + self._add_row( + "背景ブラー:", self._spin_bg_blur_ksize, + {"dual_norm"}, "bg_blur_ksize", + ) + + self._spin_global_thresh = QSpinBox() + self._spin_global_thresh.setRange(0, 255) + self._spin_global_thresh.setValue(ip.global_thresh) + self._spin_global_thresh.setSpecialValueText("無効") + self._add_row( + "固定閾値:", self._spin_global_thresh, + {"dual_norm"}, "global_thresh", + ) + + # --- 案B/C: 適応的閾値 --- + self._spin_adaptive_block = QSpinBox() + self._spin_adaptive_block.setRange(3, 999) + self._spin_adaptive_block.setSingleStep(2) + self._spin_adaptive_block.setValue(ip.adaptive_block) + self._add_row( + "適応ブロック:", self._spin_adaptive_block, + {"dual_norm", "robust"}, "adaptive_block", + ) + + self._spin_adaptive_c = QSpinBox() + self._spin_adaptive_c.setRange(0, 255) + self._spin_adaptive_c.setValue(ip.adaptive_c) + self._add_row( + "適応定数C:", self._spin_adaptive_c, + {"dual_norm", "robust"}, "adaptive_c", + ) + + # --- 案A/B/C: 後処理 --- + self._spin_iso_close = QSpinBox() + self._spin_iso_close.setRange(1, 999) + self._spin_iso_close.setSingleStep(2) + self._spin_iso_close.setValue(ip.iso_close_size) + self._add_row( + "穴埋め:", self._spin_iso_close, + {"blackhat", "dual_norm", "robust"}, + "iso_close_size", + ) + + self._spin_dist_thresh = self._create_spin( + ip.dist_thresh, 0.0, 0.5, + ) + self._add_row( + "距離閾値:", self._spin_dist_thresh, + {"blackhat", "dual_norm", "robust"}, + "dist_thresh", + ) + + self._spin_min_line_width = QSpinBox() + self._spin_min_line_width.setRange(1, 999) + self._spin_min_line_width.setValue(ip.min_line_width) + self._add_row( + "最小線幅:", self._spin_min_line_width, + {"blackhat", "dual_norm", "robust"}, + "min_line_width", + ) + + # --- 案B: 段階クロージング --- + self._spin_stage_close_small = QSpinBox() + self._spin_stage_close_small.setRange(1, 999) + self._spin_stage_close_small.setSingleStep(2) + self._spin_stage_close_small.setValue( + ip.stage_close_small, + ) + self._add_row( + "段階穴埋め(小):", + self._spin_stage_close_small, + {"dual_norm"}, "stage_close_small", + ) + + self._spin_stage_min_area = QSpinBox() + self._spin_stage_min_area.setRange(0, 99999) + self._spin_stage_min_area.setValue(ip.stage_min_area) + self._spin_stage_min_area.setSpecialValueText("無効") + self._add_row( + "孤立除去面積:", self._spin_stage_min_area, + {"dual_norm"}, "stage_min_area", + ) + + self._spin_stage_close_large = QSpinBox() + self._spin_stage_close_large.setRange(0, 999) + self._spin_stage_close_large.setSingleStep(2) + self._spin_stage_close_large.setValue( + ip.stage_close_large, + ) + self._spin_stage_close_large.setSpecialValueText( + "無効", + ) + self._add_row( + "段階穴埋め(大):", + self._spin_stage_close_large, + {"dual_norm"}, "stage_close_large", + ) + + # --- ロバストフィッティング(全手法共通) --- + all_methods = { + "blackhat", "dual_norm", "robust", "valley", + } + + self._spin_median_ksize = QSpinBox() + self._spin_median_ksize.setRange(0, 999) + self._spin_median_ksize.setSingleStep(2) + self._spin_median_ksize.setValue(ip.median_ksize) + self._spin_median_ksize.setSpecialValueText("無効") + self._add_row( + "メディアン:", self._spin_median_ksize, + all_methods, "median_ksize", + ) + + self._spin_neighbor_thresh = self._create_spin( + ip.neighbor_thresh, 0.0, 1.0, + ) + self._add_row( + "近傍除去:", self._spin_neighbor_thresh, + all_methods, "neighbor_thresh", + ) + + self._spin_residual_thresh = self._create_spin( + ip.residual_thresh, 0.0, 1.0, + ) + self._add_row( + "残差除去:", self._spin_residual_thresh, + all_methods, "residual_thresh", + ) + + # --- 案C/D: RANSAC --- + self._spin_ransac_thresh = self._create_spin( + ip.ransac_thresh, 1.0, 1.0, + ) + self._add_row( + "RANSAC閾値:", self._spin_ransac_thresh, + {"robust", "valley"}, "ransac_thresh", + ) + + # --- 幅フィルタ(透視補正) --- + _width_methods = { + "blackhat", "dual_norm", "robust", "valley", + } + + self._spin_width_near = QSpinBox() + self._spin_width_near.setRange(0, 9999) + self._spin_width_near.setValue(ip.width_near) + self._spin_width_near.setSpecialValueText("無効") + self._add_row( + "線幅(近)px:", self._spin_width_near, + _width_methods, "width_near", + ) + + self._spin_width_far = QSpinBox() + self._spin_width_far.setRange(0, 9999) + self._spin_width_far.setValue(ip.width_far) + self._spin_width_far.setSpecialValueText("無効") + self._add_row( + "線幅(遠)px:", self._spin_width_far, + _width_methods, "width_far", + ) + + self._spin_width_tolerance = self._create_spin( + ip.width_tolerance, 1.0, 0.1, + ) + self._add_row( + "幅フィルタ倍率:", self._spin_width_tolerance, + _width_methods, "width_tolerance", + ) + + # --- 案D: 谷検出+追跡 --- + self._spin_valley_gauss = QSpinBox() + self._spin_valley_gauss.setRange(3, 999) + self._spin_valley_gauss.setSingleStep(2) + self._spin_valley_gauss.setValue( + ip.valley_gauss_ksize, + ) + self._add_row( + "谷ガウス:", self._spin_valley_gauss, + {"valley"}, "valley_gauss_ksize", + ) + + self._spin_valley_min_depth = QSpinBox() + self._spin_valley_min_depth.setRange(1, 255) + self._spin_valley_min_depth.setValue( + ip.valley_min_depth, + ) + self._add_row( + "最小谷深度:", self._spin_valley_min_depth, + {"valley"}, "valley_min_depth", + ) + + self._spin_valley_max_dev = QSpinBox() + self._spin_valley_max_dev.setRange(1, 9999) + self._spin_valley_max_dev.setValue( + ip.valley_max_deviation, + ) + self._add_row( + "最大偏差:", self._spin_valley_max_dev, + {"valley"}, "valley_max_deviation", + ) + + self._spin_valley_coast = QSpinBox() + self._spin_valley_coast.setRange(0, 999) + self._spin_valley_coast.setValue( + ip.valley_coast_frames, + ) + self._add_row( + "予測継続:", self._spin_valley_coast, + {"valley"}, "valley_coast_frames", + ) + + self._spin_valley_ema = self._create_spin( + ip.valley_ema_alpha, 0.0, 0.05, + ) + self._add_row( + "EMA係数:", self._spin_valley_ema, + {"valley"}, "valley_ema_alpha", + ) + + # --- プリセット管理 --- + combo, memo = _create_preset_ui( + layout, + self._on_load_preset, + self._on_save_preset, + self._on_delete_preset, + self._on_preset_selected, + ) + self._preset_combo = combo + self._preset_memo = memo + + # コールバック接続 + self._method_combo.currentIndexChanged.connect( + self._on_method_changed, + ) + for widget, _, _ in self._image_param_vis: + widget.valueChanged.connect( + self._on_image_param_changed, + ) + + # 初期表示 + self._on_method_changed() + + def _add_row( + self, + label: str, + widget: QWidget, + methods: set[str], + field: str, + ) -> None: + """画像処理パラメータの行を追加する + + Args: + label: フォームラベル + widget: SpinBox ウィジェット + methods: 表示対象の検出手法集合 + field: ImageParams のフィールド名 + """ + self._image_form.addRow(label, widget) + self._image_param_vis.append((widget, methods, field)) + + def _on_method_changed(self) -> None: + """検出手法の変更を反映する""" + method = self._method_combo.currentData() + + # 谷検出の追跡状態をリセット + reset_valley_tracker() + + if self._auto_save_enabled: + # 旧手法のパラメータを保存 + ip = self._image_params + save_detect_params(ip.method, ip) + # 新手法のパラメータを読み込み + new_ip = load_detect_params(method) + self._image_params = new_ip + self._sync_spinboxes() + self.image_params_changed.emit(new_ip) + self.method_changed.emit(method) + else: + self._image_params.method = method + + # パラメータの表示/非表示を更新 + for widget, methods, _ in self._image_param_vis: + visible = method in methods + widget.setVisible(visible) + label = self._image_form.labelForField(widget) + if label: + label.setVisible(visible) + + # 保存済みプリセットを手法でフィルタ + if hasattr(self, "_preset_combo"): + self._refresh_presets() + + def _sync_spinboxes(self) -> None: + """SpinBox を現在の image_params に同期する""" + self._auto_save_enabled = False + try: + ip = self._image_params + for widget, _, field in self._image_param_vis: + widget.setValue(getattr(ip, field)) + finally: + self._auto_save_enabled = True + + def _on_image_param_changed(self) -> None: + """SpinBox の値が変更されたときに反映する""" + ip = self._image_params + for widget, _, field in self._image_param_vis: + setattr(ip, field, widget.value()) + + if self._auto_save_enabled: + save_detect_params(ip.method, ip) + self.image_params_changed.emit(ip) + + # ── プリセット管理 ───────────────────────────────────── + + def _refresh_presets(self) -> None: + """選択中の手法のプリセットだけ表示する""" + self._image_presets = load_image_presets() + method = self._method_combo.currentData() + self._preset_combo.clear() + self._image_filtered = [] + for i, p in enumerate(self._image_presets): + if p.image_params.method == method: + self._preset_combo.addItem(p.title) + self._image_filtered.append(i) + self._preset_memo.setText("") + + def _on_preset_selected(self) -> None: + """プリセット選択時にメモを表示する""" + idx = self._get_preset_idx() + if idx >= 0: + self._preset_memo.setText( + self._image_presets[idx].memo, + ) + else: + self._preset_memo.setText("") + + def _get_preset_idx(self) -> int: + """コンボの選択を全体インデックスに変換する""" + ci = self._preset_combo.currentIndex() + if ci < 0 or ci >= len(self._image_filtered): + return -1 + return self._image_filtered[ci] + + def _on_load_preset(self) -> None: + """プリセットを読み込む""" + idx = self._get_preset_idx() + if idx < 0: + return + self._auto_save_enabled = False + try: + ip = self._image_presets[idx].image_params + self._image_params = ip + self._sync_spinboxes() + finally: + self._auto_save_enabled = True + save_detect_params(ip.method, ip) + self.image_params_changed.emit(ip) + + def _on_save_preset(self) -> None: + """プリセットを保存する""" + title, ok = QInputDialog.getText( + self, "画像処理プリセット保存", "タイトル:", + ) + if not ok or not title.strip(): + return + memo, ok = QInputDialog.getText( + self, "画像処理プリセット保存", "メモ:", + ) + if not ok: + return + ip = self._image_params + add_image_preset(ImagePreset( + title=title.strip(), + memo=memo.strip(), + image_params=ImageParams(**{ + f.name: getattr(ip, f.name) + for f in ip.__dataclass_fields__.values() + }), + )) + self._refresh_presets() + self._preset_combo.setCurrentIndex( + self._preset_combo.count() - 1, + ) + + def _on_delete_preset(self) -> None: + """プリセットを削除する""" + idx = self._get_preset_idx() + if idx < 0: + return + title = self._image_presets[idx].title + reply = QMessageBox.question( + self, "削除確認", + f"「{title}」を削除しますか?", + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + delete_image_preset(idx) + self._refresh_presets() + + @staticmethod + def _create_spin( + value: float, min_val: float, step: float, + ) -> QDoubleSpinBox: + """パラメータ用の QDoubleSpinBox を作成する + + 直接入力にも対応するため,上限は広めに設定する + """ + spin = QDoubleSpinBox() + spin.setRange(min_val, 99999.0) + spin.setSingleStep(step) + spin.setDecimals(3) + spin.setValue(value) + return spin + + +def _create_preset_ui( + layout: QVBoxLayout, + on_load, + on_save, + on_delete, + on_selected, +) -> tuple[QComboBox, QLabel]: + """プリセット管理 UI(ComboBox + メモ + ボタン)を作成する + + Returns: + (コンボボックス, メモラベル) のタプル + """ + combo = QComboBox() + combo.setPlaceholderText("プリセット") + layout.addWidget(combo) + + memo = QLabel("") + memo.setWordWrap(True) + memo.setStyleSheet("font-size: 11px; color: #888;") + layout.addWidget(memo) + + btn_layout = QHBoxLayout() + for text, callback in [ + ("読込", on_load), + ("保存", on_save), + ("削除", on_delete), + ]: + btn = QPushButton(text) + btn.clicked.connect(callback) + btn_layout.addWidget(btn) + layout.addLayout(btn_layout) + + combo.currentIndexChanged.connect(on_selected) + return combo, memo diff --git a/src/pc/gui/panels/overlay_panel.py b/src/pc/gui/panels/overlay_panel.py new file mode 100644 index 0000000..1a86ffa --- /dev/null +++ b/src/pc/gui/panels/overlay_panel.py @@ -0,0 +1,43 @@ +""" +overlay_panel +デバッグ表示の切替チェックボックスを提供するパネル +""" + +from PySide6.QtWidgets import QCheckBox, QGroupBox, QVBoxLayout + +from pc.vision.overlay import OverlayFlags + + +class OverlayPanel(QGroupBox): + """デバッグ表示の切替チェックボックス群""" + + def __init__(self) -> None: + super().__init__("デバッグ表示") + self._flags = OverlayFlags() + self._setup_ui() + + def _setup_ui(self) -> None: + """UI を構築する""" + layout = QVBoxLayout() + self.setLayout(layout) + + items = [ + ("二値化画像", "binary"), + ("検出領域", "detect_region"), + ("フィッティング曲線", "poly_curve"), + ("行中心点", "row_centers"), + ("Theil-Sen直線", "theil_sen"), + ("中心線", "center_line"), + ] + for label, attr in items: + cb = QCheckBox(label) + cb.toggled.connect( + lambda checked, a=attr: setattr( + self._flags, a, checked, + ), + ) + layout.addWidget(cb) + + def get_flags(self) -> OverlayFlags: + """現在のオーバーレイフラグを返す""" + return self._flags