Raster2SVG-Studio/src/r2s/ui/main_window.py

775 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, {}))
# Dont 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)