diff --git a/.gitignore b/.gitignore index ea2b0bb..b4966c7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ # 環境変数 .env -# PD パラメータ保存ファイル +# パラメータ保存 pd_params.json +params/ # 旧コード(参照用,Git 管理外) src_old/ diff --git a/src/pc/gui/main_window.py b/src/pc/gui/main_window.py index 13eba12..7e206a0 100644 --- a/src/pc/gui/main_window.py +++ b/src/pc/gui/main_window.py @@ -27,12 +27,22 @@ from common import config from pc.comm.zmq_client import PcZmqClient +from pc.steering.auto_params import ( + load_control, + load_detect_params, + save_control, + save_detect_params, +) from pc.steering.pd_control import PdControl, PdParams from pc.steering.param_store import ( - ParamEntry, - add_entry, - delete_entry, - load_entries, + 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, @@ -72,8 +82,16 @@ self._throttle: float = 0.0 self._steer: float = 0.0 - # 自動操縦 - self._pd_control = PdControl() + # 自動保存の制御フラグ + self._auto_save_enabled = False + + # 前回のパラメータを復元 + pd_params, last_method = load_control() + image_params = load_detect_params(last_method) + self._pd_control = PdControl( + params=pd_params, + image_params=image_params, + ) # 最新フレームの保持(自動操縦で使用) self._latest_frame: np.ndarray | None = None @@ -84,6 +102,7 @@ self._setup_ui() self._setup_timers() + self._auto_save_enabled = True def _setup_ui(self) -> None: """UI を構築する""" @@ -152,14 +171,11 @@ self._control_label.setStyleSheet("font-size: 14px;") control_layout.addWidget(self._control_label) - # PD パラメータ調整 - self._setup_param_ui(control_layout) - - # 画像処理パラメータ調整 + # 画像処理パラメータ調整(Stage 1〜4) self._setup_image_param_ui(control_layout) - # パラメータ保存・読み込み - self._setup_param_store_ui(control_layout) + # PD 制御パラメータ(操舵量計算) + self._setup_param_ui(control_layout) # デバッグ表示 self._setup_overlay_ui(control_layout) @@ -183,9 +199,12 @@ self, parent_layout: QVBoxLayout, ) -> None: """PD パラメータ調整 UI を構築する""" - group = QGroupBox("PD パラメータ") + group = QGroupBox("PD 制御パラメータ") + layout = QVBoxLayout() + group.setLayout(layout) + form = QFormLayout() - group.setLayout(form) + layout.addLayout(form) params = self._pd_control.params @@ -219,7 +238,39 @@ ) form.addRow("減速係数:", self._spin_speed_k) - # パラメータ変更時のコールバック + # --- プリセット管理 --- + self._pd_preset_combo = QComboBox() + self._pd_preset_combo.setPlaceholderText( + "プリセット", + ) + layout.addWidget(self._pd_preset_combo) + + self._pd_preset_memo = QLabel("") + self._pd_preset_memo.setWordWrap(True) + self._pd_preset_memo.setStyleSheet( + "font-size: 11px; color: #888;", + ) + layout.addWidget(self._pd_preset_memo) + + btn_layout = QHBoxLayout() + load_btn = QPushButton("読込") + load_btn.clicked.connect( + self._on_load_pd_preset, + ) + btn_layout.addWidget(load_btn) + save_btn = QPushButton("保存") + save_btn.clicked.connect( + self._on_save_pd_preset, + ) + btn_layout.addWidget(save_btn) + del_btn = QPushButton("削除") + del_btn.clicked.connect( + self._on_delete_pd_preset, + ) + btn_layout.addWidget(del_btn) + layout.addLayout(btn_layout) + + # コールバック接続 for spin in [ self._spin_kp, self._spin_kh, self._spin_kd, self._spin_max_steer_rate, @@ -228,6 +279,11 @@ spin.valueChanged.connect( self._on_param_changed, ) + self._pd_preset_combo.currentIndexChanged \ + .connect(self._on_pd_preset_selected) + + self._pd_presets: list[PdPreset] = [] + self._refresh_pd_presets() parent_layout.addWidget(group) @@ -380,6 +436,38 @@ {"robust"}, ) + # --- プリセット管理 --- + self._image_preset_combo = QComboBox() + self._image_preset_combo.setPlaceholderText( + "プリセット", + ) + layout.addWidget(self._image_preset_combo) + + self._image_preset_memo = QLabel("") + self._image_preset_memo.setWordWrap(True) + self._image_preset_memo.setStyleSheet( + "font-size: 11px; color: #888;", + ) + layout.addWidget(self._image_preset_memo) + + btn_layout = QHBoxLayout() + load_btn = QPushButton("読込") + load_btn.clicked.connect( + self._on_load_image_preset, + ) + btn_layout.addWidget(load_btn) + save_btn = QPushButton("保存") + save_btn.clicked.connect( + self._on_save_image_preset, + ) + btn_layout.addWidget(save_btn) + del_btn = QPushButton("削除") + del_btn.clicked.connect( + self._on_delete_image_preset, + ) + btn_layout.addWidget(del_btn) + layout.addLayout(btn_layout) + # コールバック接続 self._method_combo.currentIndexChanged.connect( self._on_method_changed, @@ -388,6 +476,11 @@ widget.valueChanged.connect( self._on_image_param_changed, ) + self._image_preset_combo.currentIndexChanged \ + .connect(self._on_image_preset_selected) + + self._image_presets: list[ImagePreset] = [] + self._image_filtered: list[int] = [] parent_layout.addWidget(group) @@ -409,8 +502,26 @@ def _on_method_changed(self) -> None: """検出手法の変更を反映する""" method = self._method_combo.currentData() - self._pd_control.image_params.method = method + # 旧手法のパラメータを保存 + 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._pd_control.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) @@ -420,6 +531,52 @@ if label: label.setVisible(visible) + # 保存済みプリセットを手法でフィルタ + if hasattr(self, "_image_preset_combo"): + self._refresh_image_presets() + + def _sync_image_spinboxes(self) -> None: + """画像処理パラメータの SpinBox を現在値に同期する""" + self._auto_save_enabled = False + try: + ip = self._pd_control.image_params + self._spin_clahe_clip.setValue( + ip.clahe_clip, + ) + self._spin_binary_thresh.setValue( + ip.binary_thresh, + ) + self._spin_open_size.setValue(ip.open_size) + self._spin_close_width.setValue( + ip.close_width, + ) + self._spin_blackhat_ksize.setValue( + ip.blackhat_ksize, + ) + self._spin_bg_blur_ksize.setValue( + ip.bg_blur_ksize, + ) + self._spin_adaptive_block.setValue( + ip.adaptive_block, + ) + self._spin_adaptive_c.setValue( + ip.adaptive_c, + ) + self._spin_iso_close.setValue( + ip.iso_close_size, + ) + self._spin_dist_thresh.setValue( + ip.dist_thresh, + ) + self._spin_min_line_width.setValue( + ip.min_line_width, + ) + self._spin_ransac_thresh.setValue( + ip.ransac_thresh, + ) + finally: + self._auto_save_enabled = True + def _on_image_param_changed(self) -> None: """画像処理パラメータの変更を反映する""" ip = self._pd_control.image_params @@ -458,221 +615,88 @@ self._spin_ransac_thresh.value() ) - def _setup_param_store_ui( - self, parent_layout: QVBoxLayout, - ) -> None: - """パラメータ保存・読み込み UI を構築する""" - group = QGroupBox("パラメータ管理") - layout = QVBoxLayout() - group.setLayout(layout) + if self._auto_save_enabled: + save_detect_params(ip.method, ip) - # コンボボックス(パラメータ選択) - self._param_combo = QComboBox() - self._param_combo.setPlaceholderText( - "保存済みパラメータ" - ) - layout.addWidget(self._param_combo) + # ── 画像処理プリセット ────────────────────────── - # メモ表示ラベル - self._param_memo_label = QLabel("") - self._param_memo_label.setWordWrap(True) - self._param_memo_label.setStyleSheet( - "font-size: 11px; color: #888;" - ) - layout.addWidget(self._param_memo_label) + 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("") - # ボタン行 - btn_layout = QHBoxLayout() - - load_btn = QPushButton("読込") - load_btn.clicked.connect(self._on_load_param) - btn_layout.addWidget(load_btn) - - save_btn = QPushButton("保存") - save_btn.clicked.connect(self._on_save_param) - btn_layout.addWidget(save_btn) - - delete_btn = QPushButton("削除") - delete_btn.clicked.connect(self._on_delete_param) - btn_layout.addWidget(delete_btn) - - layout.addLayout(btn_layout) - parent_layout.addWidget(group) - - # コンボボックスの選択変更でメモを表示 - self._param_combo.currentIndexChanged.connect( - self._on_param_combo_changed, - ) - - # 保存済みエントリの読み込み - self._param_entries: list[ParamEntry] = [] - self._refresh_param_combo() - - def _refresh_param_combo(self) -> None: - """コンボボックスの内容を更新する""" - self._param_entries = load_entries() - self._param_combo.clear() - for entry in self._param_entries: - self._param_combo.addItem(entry.title) - self._param_memo_label.setText("") - - def _on_param_combo_changed( - self, index: int, - ) -> None: - """コンボボックスの選択変更時にメモを表示する""" - if 0 <= index < len(self._param_entries): - self._param_memo_label.setText( - self._param_entries[index].memo, + 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._param_memo_label.setText("") + self._image_preset_memo.setText("") - def _on_load_param(self) -> None: - """選択したパラメータを SpinBox に反映する""" - index = self._param_combo.currentIndex() - if index < 0 or index >= len(self._param_entries): + 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 - entry = self._param_entries[index] - p = entry.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._auto_save_enabled = False + try: + ip = self._image_presets[idx].image_params + self._pd_control.image_params = ip + self._sync_image_spinboxes() + finally: + self._auto_save_enabled = True + save_detect_params(ip.method, ip) - ip = entry.image_params - - # 検出手法 - idx = self._method_combo.findData(ip.method) - if idx >= 0: - self._method_combo.setCurrentIndex(idx) - - # 現行手法 - self._spin_clahe_clip.setValue(ip.clahe_clip) - self._spin_binary_thresh.setValue( - ip.binary_thresh, - ) - self._spin_open_size.setValue(ip.open_size) - self._spin_close_width.setValue(ip.close_width) - - # 案A/C: Black-hat - self._spin_blackhat_ksize.setValue( - ip.blackhat_ksize, - ) - - # 案B: 背景除算 - self._spin_bg_blur_ksize.setValue( - ip.bg_blur_ksize, - ) - - # 案B/C: 適応的閾値 - self._spin_adaptive_block.setValue( - ip.adaptive_block, - ) - self._spin_adaptive_c.setValue(ip.adaptive_c) - - # 案A/B/C: 後処理 - self._spin_iso_close.setValue( - ip.iso_close_size, - ) - self._spin_dist_thresh.setValue(ip.dist_thresh) - self._spin_min_line_width.setValue( - ip.min_line_width, - ) - - # 案C: RANSAC - self._spin_ransac_thresh.setValue( - ip.ransac_thresh, - ) - - def _on_save_param(self) -> None: - """現在のパラメータを保存する""" + def _on_save_image_preset(self) -> None: + """画像処理プリセットを保存する""" title, ok = QInputDialog.getText( - self, "パラメータ保存", "タイトル:", + self, "画像処理プリセット保存", "タイトル:", ) if not ok or not title.strip(): return - memo, ok = QInputDialog.getText( - self, "パラメータ保存", "メモ:", + self, "画像処理プリセット保存", "メモ:", ) if not ok: return - - entry = ParamEntry( + ip = self._pd_control.image_params + add_image_preset(ImagePreset( title=title.strip(), memo=memo.strip(), - params=PdParams( - kp=self._spin_kp.value(), - kh=self._spin_kh.value(), - kd=self._spin_kd.value(), - max_steer_rate=( - self._spin_max_steer_rate.value() - ), - max_throttle=( - self._spin_max_throttle.value() - ), - speed_k=self._spin_speed_k.value(), - ), - image_params=ImageParams( - method=( - self._method_combo.currentData() - ), - clahe_clip=( - self._spin_clahe_clip.value() - ), - binary_thresh=( - self._spin_binary_thresh.value() - ), - open_size=( - self._spin_open_size.value() - ), - close_width=( - self._spin_close_width.value() - ), - blackhat_ksize=( - self._spin_blackhat_ksize.value() - ), - bg_blur_ksize=( - self._spin_bg_blur_ksize.value() - ), - adaptive_block=( - self._spin_adaptive_block.value() - ), - adaptive_c=( - self._spin_adaptive_c.value() - ), - iso_close_size=( - self._spin_iso_close.value() - ), - dist_thresh=( - self._spin_dist_thresh.value() - ), - min_line_width=( - self._spin_min_line_width.value() - ), - ransac_thresh=( - self._spin_ransac_thresh.value() - ), - ), - ) - add_entry(entry) - self._refresh_param_combo() - # 追加したエントリを選択 - self._param_combo.setCurrentIndex( - self._param_combo.count() - 1, + 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_param(self) -> None: - """選択したパラメータを削除する""" - index = self._param_combo.currentIndex() - if index < 0 or index >= len(self._param_entries): + def _on_delete_image_preset(self) -> None: + """画像処理プリセットを削除する""" + idx = self._get_image_preset_idx() + if idx < 0: return - - title = self._param_entries[index].title + title = self._image_presets[idx].title reply = QMessageBox.question( self, "削除確認", f"「{title}」を削除しますか?", @@ -681,8 +705,99 @@ QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: - delete_entry(index) - self._refresh_param_combo() + 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, @@ -743,6 +858,12 @@ 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 _setup_timers(self) -> None: """タイマーを設定する""" # 映像更新用 diff --git a/src/pc/steering/auto_params.py b/src/pc/steering/auto_params.py new file mode 100644 index 0000000..fb3167d --- /dev/null +++ b/src/pc/steering/auto_params.py @@ -0,0 +1,130 @@ +""" +auto_params +パラメータの自動保存・読み込みを管理するモジュール +アプリ起動時に前回のパラメータを復元し,変更時に自動保存する + +ファイル構成: + params/ + ├── control.json PD 制御 + 最後に使用した手法 + ├── detect_current.json 現行手法の画像処理パラメータ + ├── detect_blackhat.json 案A の画像処理パラメータ + ├── detect_dual_norm.json 案B の画像処理パラメータ + └── detect_robust.json 案C の画像処理パラメータ +""" + +import json +from dataclasses import asdict +from pathlib import Path + +from pc.steering.pd_control import PdParams +from pc.vision.line_detector import ImageParams + +# パラメータ保存ディレクトリ +_PARAMS_DIR: Path = ( + Path(__file__).resolve().parent.parent.parent.parent + / "params" +) + +# PD 制御パラメータファイル +_CONTROL_FILE: Path = _PARAMS_DIR / "control.json" + +# 検出手法ごとのファイル名 +_DETECT_FILES: dict[str, str] = { + "current": "detect_current.json", + "blackhat": "detect_blackhat.json", + "dual_norm": "detect_dual_norm.json", + "robust": "detect_robust.json", +} + + +def save_control( + params: PdParams, method: str, +) -> None: + """PD 制御パラメータと最後に使用した手法を保存する + + Args: + params: PD 制御パラメータ + method: 最後に使用した検出手法の識別子 + """ + _PARAMS_DIR.mkdir(exist_ok=True) + data = asdict(params) + data["last_method"] = method + _write_json(_CONTROL_FILE, data) + + +def load_control() -> tuple[PdParams, str]: + """PD 制御パラメータと最後に使用した手法を読み込む + + Returns: + (PD 制御パラメータ, 最後に使用した手法の識別子) + """ + if not _CONTROL_FILE.exists(): + return PdParams(), "current" + + data = _read_json(_CONTROL_FILE) + method = data.pop("last_method", "current") + known = PdParams.__dataclass_fields__ + filtered = { + k: v for k, v in data.items() + if k in known + } + return PdParams(**filtered), method + + +def save_detect_params( + method: str, params: ImageParams, +) -> None: + """検出手法のパラメータを保存する + + Args: + method: 検出手法の識別子 + params: 画像処理パラメータ + """ + filename = _DETECT_FILES.get(method) + if filename is None: + return + _PARAMS_DIR.mkdir(exist_ok=True) + data = asdict(params) + data["method"] = method + _write_json(_PARAMS_DIR / filename, data) + + +def load_detect_params(method: str) -> ImageParams: + """検出手法のパラメータを読み込む + + Args: + method: 検出手法の識別子 + + Returns: + 画像処理パラメータ(ファイルがない場合はデフォルト) + """ + filename = _DETECT_FILES.get(method) + if filename is None: + return ImageParams(method=method) + + path = _PARAMS_DIR / filename + if not path.exists(): + return ImageParams(method=method) + + data = _read_json(path) + known = ImageParams.__dataclass_fields__ + filtered = { + k: v for k, v in data.items() + if k in known + } + filtered["method"] = method + return ImageParams(**filtered) + + +def _write_json(path: Path, data: dict) -> None: + """JSON ファイルに書き込む""" + with open(path, "w", encoding="utf-8") as f: + json.dump( + data, f, ensure_ascii=False, indent=2, + ) + + +def _read_json(path: Path) -> dict: + """JSON ファイルを読み込む""" + with open(path, "r", encoding="utf-8") as f: + return json.load(f) diff --git a/src/pc/steering/param_store.py b/src/pc/steering/param_store.py index 5efd5f3..c51ee3f 100644 --- a/src/pc/steering/param_store.py +++ b/src/pc/steering/param_store.py @@ -1,7 +1,7 @@ """ param_store -パラメータの保存・読み込みを管理するモジュール -1つの JSON ファイルに複数のパラメータセットを格納する +パラメータプリセットの保存・読み込みを管理するモジュール +画像処理パラメータと PD 制御パラメータを独立して管理する """ import json @@ -11,101 +11,149 @@ from pc.steering.pd_control import PdParams from pc.vision.line_detector import ImageParams -# パラメータ保存先のファイルパス -_STORE_PATH: Path = ( +# プリセット保存ディレクトリ +_PARAMS_DIR: Path = ( Path(__file__).resolve().parent.parent.parent.parent - / "pd_params.json" + / "params" ) +_PD_FILE: Path = _PARAMS_DIR / "presets_pd.json" +_IMAGE_FILE: Path = _PARAMS_DIR / "presets_image.json" + + +# ── PD 制御プリセット ───────────────────────── + @dataclass -class ParamEntry: - """パラメータセットの1エントリ +class PdPreset: + """PD 制御パラメータのプリセット Attributes: - title: パラメータセットのタイトル - memo: メモ(用途や特徴の説明) + title: プリセットのタイトル + memo: メモ params: PD 制御パラメータ - image_params: 画像処理パラメータ """ + title: str memo: str params: PdParams + + +def load_pd_presets() -> list[PdPreset]: + """PD 制御プリセット一覧を読み込む""" + return _load_presets( + _PD_FILE, PdPreset, "params", PdParams, + ) + + +def add_pd_preset(preset: PdPreset) -> None: + """PD 制御プリセットを追加する""" + presets = load_pd_presets() + presets.append(preset) + _save_presets( + _PD_FILE, presets, "params", + ) + + +def delete_pd_preset(index: int) -> None: + """PD 制御プリセットを削除する""" + presets = load_pd_presets() + if 0 <= index < len(presets): + presets.pop(index) + _save_presets( + _PD_FILE, presets, "params", + ) + + +# ── 画像処理プリセット ──────────────────────── + + +@dataclass +class ImagePreset: + """画像処理パラメータのプリセット + + Attributes: + title: プリセットのタイトル + memo: メモ + image_params: 画像処理パラメータ + """ + + title: str + memo: str image_params: ImageParams -def load_entries() -> list[ParamEntry]: - """保存済みのパラメータ一覧を読み込む +def load_image_presets() -> list[ImagePreset]: + """画像処理プリセット一覧を読み込む""" + return _load_presets( + _IMAGE_FILE, ImagePreset, + "image_params", ImageParams, + ) - Returns: - パラメータエントリのリスト - """ - if not _STORE_PATH.exists(): + +def add_image_preset(preset: ImagePreset) -> None: + """画像処理プリセットを追加する""" + presets = load_image_presets() + presets.append(preset) + _save_presets( + _IMAGE_FILE, presets, "image_params", + ) + + +def delete_image_preset(index: int) -> None: + """画像処理プリセットを削除する""" + presets = load_image_presets() + if 0 <= index < len(presets): + presets.pop(index) + _save_presets( + _IMAGE_FILE, presets, "image_params", + ) + + +# ── 共通処理 ────────────────────────────────── + + +def _load_presets(path, preset_cls, params_key, params_cls): + """プリセットファイルを読み込む""" + if not path.exists(): return [] - with open(_STORE_PATH, "r", encoding="utf-8") as f: + with open(path, "r", encoding="utf-8") as f: data = json.load(f) - entries: list[ParamEntry] = [] + known = params_cls.__dataclass_fields__ + presets = [] for item in data: - if "image_params" in item: - # 未知のフィールドを無視(後方互換性) - known = ImageParams.__dataclass_fields__ - ip_data = { + if params_key in item: + filtered = { k: v - for k, v in item["image_params"].items() + for k, v in item[params_key].items() if k in known } - image_params = ImageParams(**ip_data) + params = params_cls(**filtered) else: - image_params = ImageParams() + params = params_cls() - entries.append(ParamEntry( + presets.append(preset_cls( title=item["title"], memo=item["memo"], - params=PdParams(**item["params"]), - image_params=image_params, + **{params_key: params}, )) - return entries + return presets -def save_entries(entries: list[ParamEntry]) -> None: - """パラメータ一覧をファイルに保存する - - Args: - entries: パラメータエントリのリスト - """ +def _save_presets(path, presets, params_key): + """プリセットファイルに保存する""" + path.parent.mkdir(exist_ok=True) data = [] - for entry in entries: + for preset in presets: data.append({ - "title": entry.title, - "memo": entry.memo, - "params": asdict(entry.params), - "image_params": asdict(entry.image_params), + "title": preset.title, + "memo": preset.memo, + params_key: asdict( + getattr(preset, params_key), + ), }) - with open(_STORE_PATH, "w", encoding="utf-8") as f: + with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) - - -def add_entry(entry: ParamEntry) -> None: - """パラメータセットを追加する - - Args: - entry: 追加するエントリ - """ - entries = load_entries() - entries.append(entry) - save_entries(entries) - - -def delete_entry(index: int) -> None: - """パラメータセットを削除する - - Args: - index: 削除するエントリのインデックス - """ - entries = load_entries() - if 0 <= index < len(entries): - entries.pop(index) - save_entries(entries)