from __future__ import annotations import json from pathlib import Path from typing import Any, Dict, Optional import numpy as np import cv2 from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QImage, QPixmap from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, QLabel, QMainWindow, QPushButton, QComboBox, QFormLayout, QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QWidget, QVBoxLayout, QMessageBox ) from PySide6.QtWidgets import ( QPlainTextEdit, QSplitter, QGroupBox, QScrollArea, QMenuBar ) from PySide6.QtGui import QAction from PySide6.QtWidgets import QColorDialog from PySide6.QtGui import QColor from PySide6.QtWidgets import QDialog from ..pipeline import run_style, available_styles from ..preview import svg_to_bgr from .auto_explore import AutoExploreDialog, Candidate from ..styles import ALL_STYLES # ensure this exists def bgr_to_qpixmap(bgr: np.ndarray) -> QPixmap: rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) h, w, _ = rgb.shape qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format.Format_RGB888) return QPixmap.fromImage(qimg) class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle("Raster2SVG Studio") self._styles = available_styles() self._bgr: Optional[np.ndarray] = None self._last_svg: Optional[str] = None self._last_preview_bgr: Optional[np.ndarray] = None self._input_path: Optional[str] = None self._param_widgets: Dict[str, QWidget] = {} self._dirty_params: bool = False # Debounced update timer self._timer = QTimer(self) self._timer.setSingleShot(True) self._timer.timeout.connect(self._recompute) # --- UI widgets menubar = QMenuBar(self) help_menu = menubar.addMenu("Help") act_theory = QAction("Theory of Operation", self) act_theory.triggered.connect(self._show_theory) help_menu.addAction(act_theory) self.setMenuBar(menubar) self.btn_open = QPushButton("Open Image…") self.btn_open.clicked.connect(self._open_image) self.input_path_edit = QLineEdit() self.input_path_edit.setReadOnly(True) self.bg_color = QColor(255, 255, 255) # default white self.btn_bg_color = QPushButton("Background…") self.btn_bg_color.clicked.connect(self._pick_bg_color) self.btn_bg_color.setEnabled(False) self.lbl_bg_swatch = QLabel() self.lbl_bg_swatch.setFixedSize(24, 24) self._update_bg_swatch() self.output_name_edit = QLineEdit() self.output_name_edit.setReadOnly(False) self.auto_update_chk = QCheckBox("Auto-update") self.auto_update_chk.setChecked(True) self.auto_update_chk.stateChanged.connect(self._on_auto_update_changed) self.btn_apply = QPushButton("Apply") self.btn_apply.clicked.connect(self._recompute) self.btn_apply.setEnabled(False) # enabled when auto-update off and dirty self.btn_export = QPushButton("Export SVG…") self.btn_export.clicked.connect(self._export_svg) self.btn_export.setEnabled(False) self.btn_auto_explore = QPushButton("Auto-explore…") self.btn_auto_explore.clicked.connect(self._auto_explore) self.btn_auto_explore.setEnabled(False) self.style_combo = QComboBox() for k in self._styles.keys(): self.style_combo.addItem(k) self.style_combo.currentTextChanged.connect(self._on_style_changed) # Param widgets (we use a simple JSON param editor + a few common controls) ''' self.params_json = QLineEdit() self.params_json.setPlaceholderText('{"n_colors":6,"simplify":1.2,"min_area":80}') self.params_json.editingFinished.connect(self._schedule_update) ''' self.params_json_view = QPlainTextEdit() self.params_json_view.setReadOnly(True) self.params_json_view.setMinimumHeight(140) self.param_form_box = QGroupBox("Parameters") self.param_form_layout = QFormLayout() self.param_form_box.setLayout(self.param_form_layout) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setWidget(self.param_form_box) self.scale_box = QDoubleSpinBox() self.scale_box.setRange(0.05, 10.0) self.scale_box.setSingleStep(0.05) self.scale_box.setValue(1.0) self.scale_box.valueChanged.connect(self._schedule_update) # Previews self.lbl_orig = QLabel("Original") self.lbl_proc = QLabel("Processed") self.lbl_svg = QLabel("SVG Preview") for lbl in (self.lbl_orig, self.lbl_proc, self.lbl_svg): lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) lbl.setMinimumSize(320, 240) lbl.setStyleSheet("border: 1px solid #999;") # Layout top = QHBoxLayout() #top.addWidget(self.btn_open) #self.btn_open.setLayout(top) top.addWidget(self.input_path_edit) top.addWidget(self.output_name_edit) top.addWidget(self.auto_update_chk) top.addWidget(QLabel("Style:")) top.addWidget(self.style_combo) top.addWidget(QLabel("Scale:")) top.addWidget(self.scale_box) top.addWidget(self.btn_export) top.addStretch(1) form = QFormLayout() form.addRow("Params (JSON overrides):", self.params_json_view) previews = QHBoxLayout() previews.addWidget(self.lbl_orig, 1) previews.addWidget(self.lbl_proc, 1) previews.addWidget(self.lbl_svg, 1) root = QVBoxLayout() root.addLayout(top) root.addLayout(form) root.addLayout(previews) left = QWidget() left_layout = QVBoxLayout() # top file group file_form = QFormLayout() file_form.addRow("File:", self.btn_open) file_form.addRow("Input:", self.input_path_edit) file_form.addRow("Suggested output:", self.output_name_edit) file_form.addRow("Export SVG:", self.btn_export) file_form.addRow("Auto-explore:", self.btn_auto_explore) bg_row = QHBoxLayout() bg_row.addWidget(QLabel("Transparent background:")) bg_row.addWidget(self.lbl_bg_swatch) bg_row.addWidget(self.btn_bg_color) bg_row.addStretch(1) left_layout.addLayout(bg_row) style_row = QHBoxLayout() style_row.addWidget(QLabel("Style:")) style_row.addWidget(self.style_combo) style_row.addStretch(1) style_row.addWidget(self.auto_update_chk) style_row.addWidget(self.btn_apply) #left_layout.addLayout(top) left_layout.addLayout(file_form) left_layout.addLayout(style_row) left_layout.addWidget(scroll, 1) left_layout.addWidget(QLabel("Parameters (JSON)")) left_layout.addWidget(self.params_json_view, 0) left.setLayout(left_layout) right = QWidget() right_layout = QVBoxLayout() row = QHBoxLayout() row.addWidget(self.lbl_proc, 1) row.addWidget(self.lbl_svg, 1) right_layout.addLayout(row, 1) # smaller original at bottom right_layout.addWidget(self.lbl_orig, 0) right.setLayout(right_layout) split = QSplitter() split.addWidget(left) split.addWidget(right) split.setStretchFactor(0, 0) split.setStretchFactor(1, 1) #container = QWidget() #container.setLayout(root) #self.setCentralWidget(container) self.setCentralWidget(split) # Initialize param JSON to defaults for initial style self._apply_style_defaults_to_editor() def _pick_bg_color(self) -> None: col = QColorDialog.getColor(self.bg_color, self, "Select background color") if not col.isValid(): return self.bg_color = col self._update_bg_swatch() self._apply_background_and_refresh() def _update_bg_swatch(self) -> None: self.lbl_bg_swatch.setStyleSheet( f"background-color: {self.bg_color.name()}; border: 1px solid #666;" ) def _apply_background(self, img: np.ndarray) -> np.ndarray: """ Convert an RGBA or RGB image to opaque BGR by compositing over the selected background color. """ if img.ndim != 3 or img.shape[2] != 4: # Already opaque BGR if img.shape[2] == 3: return img raise ValueError("Unexpected image format") # Split channels b, g, r, a = cv2.split(img) alpha = a.astype(np.float32) / 255.0 alpha = alpha[..., None] # shape (H, W, 1) bg = np.array( [self.bg_color.blue(), self.bg_color.green(), self.bg_color.red()], dtype=np.float32 ) fg = np.dstack([b, g, r]).astype(np.float32) out = fg * alpha + bg * (1.0 - alpha) return out.astype(np.uint8) def _apply_background_and_refresh(self) -> None: if self._orig_img is None: return try: self._bgr = self._apply_background(self._orig_img) except Exception as e: QMessageBox.critical(self, "Image error", str(e)) return # Update original preview self.lbl_orig.setPixmap( bgr_to_qpixmap(self._bgr).scaled( self.lbl_orig.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) ) # Recompute derivatives if self.auto_update_chk.isChecked(): self._schedule_update() else: self._dirty_params = True self.btn_apply.setEnabled(True) def _show_theory(self) -> None: txt = ( "Theory of Operation\n\n" "This tool converts a raster image (pixels) into an SVG (vector paths).\n\n" "Views:\n" "1) Original: the unmodified input raster.\n" "2) Processed: the style-specific intermediate representation (e.g., quantized colors, threshold mask, tone bands).\n" "3) SVG Preview: the vector output rendered back to a raster for display.\n" " If CairoSVG is installed, this is a true SVG render; otherwise it falls back to the Processed view.\n\n" "Workflow:\n" "Open an image → choose a style → adjust parameters → Apply/Auto-update → Export SVG.\n" ) QMessageBox.information(self, "Theory of Operation", txt) def _apply_style_defaults_to_editor(self) -> None: style = self.style_combo.currentText() defaults = dict(self._styles.get(style, {})) # Don’t duplicate scale; we manage it separately defaults.pop("scale", None) #self.params_json_view.setText(json.dumps(defaults)) self._update_json_view() def _on_style_changed_1(self) -> None: self._apply_style_defaults_to_editor() self._schedule_update() def _on_style_changed(self) -> None: style = self.style_combo.currentText() self._build_param_form(style) self._update_output_suggestion() if self._bgr is not None and self.auto_update_chk.isChecked(): self._schedule_update() def _schedule_update(self) -> None: # debounce to avoid thrashing while sliders/editing self._timer.start(200) def _open_image(self) -> None: path, _ = QFileDialog.getOpenFileName(self, "Open Image", "", "Images (*.png *.jpg *.jpeg *.bmp *.tif *.tiff)") if not path: return img = cv2.imread(path, cv2.IMREAD_UNCHANGED) if img is None: has_alpha = False pass else: has_alpha = (img.ndim == 3 and img.shape[2] == 4) self._has_alpha = has_alpha self._orig_img = img # keep original, possibly RGBA self.btn_bg_color.setEnabled(self._has_alpha) self._apply_background_and_refresh() bgr = cv2.imread(path, cv2.IMREAD_COLOR) if bgr is None: QMessageBox.critical(self, "Error", f"Could not read image:\n{path}") return self._bgr = bgr self.lbl_orig.setPixmap(bgr_to_qpixmap(bgr).scaled( self.lbl_orig.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation )) self.btn_export.setEnabled(True) self.btn_auto_explore.setEnabled(True) self._input_path = path self.input_path_edit.setText(path) self._update_output_suggestion() self._schedule_update() def resizeEvent(self, event) -> None: super().resizeEvent(event) # re-render pixmaps to fit if self._bgr is not None: self.lbl_orig.setPixmap(bgr_to_qpixmap(self._bgr).scaled( self.lbl_orig.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation )) if self._last_preview_bgr is not None: self.lbl_proc.setPixmap(bgr_to_qpixmap(self._last_preview_bgr).scaled( self.lbl_proc.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation )) if self._last_svg is not None and self._bgr is not None: svg_bgr = svg_to_bgr(self._last_svg, self._bgr.shape[1], self._bgr.shape[0]) if svg_bgr is not None: self.lbl_svg.setPixmap(bgr_to_qpixmap(svg_bgr).scaled( self.lbl_svg.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation )) def _parse_params(self) -> Dict[str, Any]: txt = self.params_json.text().strip() if not txt: return {} try: return json.loads(txt) except Exception as e: QMessageBox.warning(self, "Params JSON error", f"Could not parse params JSON:\n{e}") return {} def _make_candidates8(self, style: str, base: Dict[str, Any]) -> list[tuple[str, Dict[str, Any]]]: b = dict(base) def cand(label: str, updates: Dict[str, Any]) -> tuple[str, Dict[str, Any]]: p = dict(b) p.update(updates) return (label, p) if style == "posterized": n = int(b.get("n_colors", 6)) simp = float(b.get("simplify", 1.2)) area = int(b.get("min_area", 80)) blur = int(b.get("blur", 1)) sw = float(b.get("stroke_width", 0.8)) stroke = bool(b.get("add_stroke", False)) return [ cand("Fewer colors", {"n_colors": max(2, n - 2)}), cand("More colors", {"n_colors": min(20, n + 3)}), cand("Simplify more", {"simplify": min(20.0, simp * 1.6)}), cand("Simplify less", {"simplify": max(0.0, simp * 0.7)}), cand("Drop specks more", {"min_area": min(1_000_000, int(area * 2))}), cand("Keep more detail", {"min_area": max(0, int(area * 0.5))}), cand("More blur", {"blur": min(10, blur + 2)}), cand("Outline regions", {"add_stroke": True, "stroke_width": max(0.1, sw)} if not stroke else {"add_stroke": False}), ] if style == "lineart": mode = str(b.get("mode", "adaptive")) thr = int(b.get("threshold", 128)) bs = int(b.get("block_size", 31)) c = int(b.get("c", 7)) simp = float(b.get("simplify", 1.0)) sw = float(b.get("stroke_width", 1.2)) inv = bool(b.get("invert", True)) # Ensure odd block_size def odd(x: int) -> int: x = max(3, min(201, x)) return x if (x % 2 == 1) else x + 1 return [ cand("Adaptive (smoother)", {"mode": "adaptive", "block_size": odd(bs + 20), "c": c}), cand("Adaptive (sharper)", {"mode": "adaptive", "block_size": odd(bs - 14), "c": c}), cand("Adaptive (higher C)", {"mode": "adaptive", "block_size": odd(bs), "c": min(50, c + 6)}), cand("Adaptive (lower C)", {"mode": "adaptive", "block_size": odd(bs), "c": max(-50, c - 6)}), cand("Fixed (darker)", {"mode": "fixed", "threshold": max(0, thr - 25)}), cand("Fixed (lighter)", {"mode": "fixed", "threshold": min(255, thr + 25)}), cand("Thicker stroke", {"stroke_width": min(50.0, sw * 1.6)}), cand("Invert toggled", {"invert": (not inv)}), ] if style == "woodcut": bands = int(b.get("tone_bands", 5)) base_sp = float(b.get("hatch_base_spacing", 18.0)) fac = float(b.get("hatch_spacing_factor", 0.70)) ang = float(b.get("hatch_angle_deg", -25.0)) e_low = int(b.get("edge_low", 40)) e_high = int(b.get("edge_high", 120)) e_sw = float(b.get("edge_stroke_width", 1.4)) h_sw = float(b.get("hatch_stroke_width", 1.0)) return [ cand("More tone bands", {"tone_bands": min(12, bands + 2)}), cand("Fewer tone bands", {"tone_bands": max(2, bands - 2)}), cand("Tighter hatching", {"hatch_base_spacing": max(2.0, base_sp * 0.75)}), cand("Looser hatching", {"hatch_base_spacing": min(200.0, base_sp * 1.35)}), cand("Rotate hatch +30°", {"hatch_angle_deg": max(-89.0, min(89.0, ang + 30.0))}), cand("Rotate hatch -30°", {"hatch_angle_deg": max(-89.0, min(89.0, ang - 30.0))}), cand("Stronger edges", {"edge_low": min(255, e_low + 15), "edge_high": min(255, e_high + 25), "edge_stroke_width": min(20.0, e_sw * 1.2)}), cand("More hatch ink", {"hatch_stroke_width": min(20.0, h_sw * 1.3)}), ] if style == "pontillist": n = int(b.get("n_colors", 6)) step = float(b.get("grid_step", 8.0)) gamma = float(b.get("tone_gamma", 1.6)) acc = float(b.get("accept_scale", 1.0)) rmax = float(b.get("dot_radius_max", 2.2)) boundaries = bool(b.get("draw_boundaries", True)) return [ cand("Fewer colors", {"n_colors": max(2, n - 2)}), cand("More colors", {"n_colors": min(20, n + 3)}), cand("Denser (smaller step)", {"grid_step": max(2.0, step * 0.75)}), cand("Sparser (larger step)", {"grid_step": min(50.0, step * 1.35)}), cand("More dark emphasis", {"tone_gamma": min(4.0, gamma * 1.25)}), cand("Less dark emphasis", {"tone_gamma": max(0.3, gamma * 0.8)}), cand("More dots overall", {"accept_scale": min(3.0, acc * 1.25)}), cand("Toggle boundaries", {"draw_boundaries": (not boundaries)}), ] # Fallback: no candidates return [] def _recompute(self) -> None: if self._bgr is None: return style = self.style_combo.currentText() ''' params = self._parse_params() params["scale"] = float(self.scale_box.value()) ''' params = self._read_params_from_form() params["scale"] = float(self.scale_box.value()) if hasattr(self, "scale_box") else 1.0 try: res = run_style(self._bgr, style, params) except Exception as e: QMessageBox.critical(self, "Conversion error", str(e)) return self._last_svg = res.svg self._last_preview_bgr = res.preview_bgr # Processed pane self.lbl_proc.setPixmap(bgr_to_qpixmap(res.preview_bgr).scaled( self.lbl_proc.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation )) # SVG pane (render via cairosvg if available) svg_bgr = svg_to_bgr(res.svg, self._bgr.shape[1], self._bgr.shape[0]) if svg_bgr is None: # fallback: show processed raster stage self.lbl_svg.setPixmap(bgr_to_qpixmap(res.preview_bgr).scaled( self.lbl_svg.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation )) self.lbl_svg.setToolTip("Install optional dependency 'cairosvg' for true SVG preview: pip install 'r2s[preview]'") else: self.lbl_svg.setPixmap(bgr_to_qpixmap(svg_bgr).scaled( self.lbl_svg.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation )) self.lbl_svg.setToolTip("") self._dirty_params = False self.btn_apply.setEnabled(False) def _export_svg(self) -> None: if not self._last_svg: QMessageBox.information(self, "Nothing to export", "Run a conversion first.") return # out, _ = QFileDialog.getSaveFileName(self, "Export SVG", "output.svg", "SVG (*.svg)") suggest = self.output_name_edit.text().strip() or "output.svg" out, _ = QFileDialog.getSaveFileName(self, "Export SVG", suggest, "SVG (*.svg)") if not out: return Path(out).write_text(self._last_svg, encoding="utf-8") def _auto_explore(self) -> None: if self._bgr is None: QMessageBox.information(self, "Auto-explore", "Open an image first.") return style = self.style_combo.currentText() # Current params from form (not from JSON box) base_params = self._read_params_from_form() # If you keep scale global, include it for rendering consistency if hasattr(self, "scale_box"): base_params["scale"] = float(self.scale_box.value()) # Reduced scale for candidate rendering speed explore_scale = 0.5 # Preserve user scale but override for exploration rendering base_for_render = dict(base_params) base_for_render["scale"] = explore_scale # Build 8 candidates cand_defs = self._make_candidates8(style, base_params) if len(cand_defs) != 8: QMessageBox.warning(self, "Auto-explore", f"No candidate generator for style '{style}'.") return # Render current (center) try: cur_pix = self._render_candidate_pixmap(style, base_params, explore_scale) except Exception as e: QMessageBox.critical(self, "Auto-explore error", str(e)) return current = Candidate(label="Current settings", params=dict(base_params), preview_pix=cur_pix) # Render 8 candidates candidates8: list[Candidate] = [] try: for label, p in cand_defs: pix = self._render_candidate_pixmap(style, p, explore_scale) candidates8.append(Candidate(label=label, params=p, preview_pix=pix)) except Exception as e: QMessageBox.critical(self, "Auto-explore error", str(e)) return dlg = AutoExploreDialog(self, candidates8=candidates8, current=current) #if dlg.exec() == dlg.Accepted: if dlg.exec() == QDialog.DialogCode.Accepted: chosen = dlg.selected_params() # Apply chosen params to the form widgets self._set_form_from_params(chosen) self._dirty_params = True self._update_json_view() if self.auto_update_chk.isChecked(): self._schedule_update() else: self.btn_apply.setEnabled(True) def _set_form_from_params(self, params: Dict[str, Any]) -> None: for k, v in params.items(): if k not in self._param_widgets: continue w = self._param_widgets[k] try: if isinstance(w, QCheckBox): w.setChecked(bool(v)) elif isinstance(w, QSpinBox): w.setValue(int(v)) elif isinstance(w, QDoubleSpinBox): w.setValue(float(v)) elif isinstance(w, QComboBox): w.setCurrentText(str(v)) elif isinstance(w, QLineEdit): w.setText(str(v)) except Exception: # Ignore ill-typed values rather than crashing pass # If you keep scale global, update it too if "scale" in params and hasattr(self, "scale_box"): try: self.scale_box.setValue(float(params["scale"])) except Exception: pass def _build_param_form(self, style_name: str) -> None: # clear old widgets while self.param_form_layout.rowCount(): self.param_form_layout.removeRow(0) self._param_widgets.clear() style = ALL_STYLES[style_name] specs = style.param_specs() defaults = style.default_params() defaults.pop("scale", None) # if scale handled elsewhere for spec in specs: w = None if spec.ptype == "bool": cb = QCheckBox() cb.setChecked(bool(spec.default)) cb.stateChanged.connect(self._on_param_changed) w = cb elif spec.ptype == "int": sb = QSpinBox() if spec.min is not None: sb.setMinimum(int(spec.min)) if spec.max is not None: sb.setMaximum(int(spec.max)) sb.setSingleStep(int(spec.step or 1)) sb.setValue(int(spec.default)) sb.valueChanged.connect(self._on_param_changed) w = sb elif spec.ptype == "float": dsb = QDoubleSpinBox() if spec.min is not None: dsb.setMinimum(float(spec.min)) if spec.max is not None: dsb.setMaximum(float(spec.max)) dsb.setSingleStep(float(spec.step or 0.1)) dsb.setDecimals(3) dsb.setValue(float(spec.default)) dsb.valueChanged.connect(self._on_param_changed) w = dsb elif spec.ptype == "choice": combo = QComboBox() combo.addItems(spec.choices or []) combo.setCurrentText(str(spec.default)) combo.currentTextChanged.connect(self._on_param_changed) w = combo else: # "str" le = QLineEdit() le.setText(str(spec.default)) le.editingFinished.connect(self._on_param_changed) w = le if spec.help: w.setToolTip(spec.help) self._param_widgets[spec.key] = w self.param_form_layout.addRow(spec.label + ":", w) self._dirty_params = True self._update_json_view() def _on_param_changed(self) -> None: self._dirty_params = True self._update_json_view() if self.auto_update_chk.isChecked(): self._schedule_update() else: self.btn_apply.setEnabled(True) def _read_params_from_form(self) -> Dict[str, Any]: params: Dict[str, Any] = {} for k, w in self._param_widgets.items(): if isinstance(w, QCheckBox): params[k] = bool(w.isChecked()) elif isinstance(w, QSpinBox): params[k] = int(w.value()) elif isinstance(w, QDoubleSpinBox): params[k] = float(w.value()) elif isinstance(w, QComboBox): params[k] = str(w.currentText()) elif isinstance(w, QLineEdit): params[k] = str(w.text()) else: pass return params def _update_json_view(self) -> None: params = self._read_params_from_form() # If you still manage scale separately, include it here so JSON reflects reality: params["scale"] = float(self.scale_box.value()) if hasattr(self, "scale_box") else 1.0 self.params_json_view.setPlainText(json.dumps(params, indent=2, sort_keys=True)) def _on_auto_update_changed(self) -> None: if self.auto_update_chk.isChecked(): self.btn_apply.setEnabled(False) if self._dirty_params: self._schedule_update() else: self.btn_apply.setEnabled(self._dirty_params) def _update_output_suggestion(self) -> None: if not self._input_path: return p = Path(self._input_path) style = self.style_combo.currentText() self.output_name_edit.setText(f"{p.stem}_{style}.svg") def _render_candidate_pixmap(self, style: str, params: Dict[str, Any], explore_scale: float) -> QPixmap: if self._bgr is None: raise RuntimeError("No image loaded") # Force scaled rendering for speed p = dict(params) p["scale"] = explore_scale res = run_style(self._bgr, style, p) svg_bgr = svg_to_bgr(res.svg, self._bgr.shape[1], self._bgr.shape[0]) use_bgr = svg_bgr if svg_bgr is not None else res.preview_bgr return bgr_to_qpixmap(use_bgr)