Newer
Older
RobotCar / src / pc / gui / panels / image_param_panel.py
"""
image_param_panel
二値化パラメータ調整 UI パネル
"""

from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
    QComboBox,
    QDoubleSpinBox,
    QFormLayout,
    QHBoxLayout,
    QInputDialog,
    QLabel,
    QMessageBox,
    QPushButton,
    QSpinBox,
    QVBoxLayout,
    QWidget,
)

from pc.gui.panels.collapsible_group_box import (
    CollapsibleGroupBox,
)

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(CollapsibleGroupBox):
    """二値化パラメータ調整 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