775 lines
29 KiB
Python
775 lines
29 KiB
Python
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)
|