"""
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