#!/usr/bin/env python3 """ kisscut_gui_qt.py PySide6-based GUI for Kiss-Cut Preview with spline-smoothed Bezier SVG export. Now: auto-detects mask overflow, pads all images/masks, aligns overlays, and auto-detects sharp corners. """ import sys import os from PIL import Image, ImageDraw import numpy as np from collections import deque from skimage import measure from scipy.interpolate import splprep, splev from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QLabel, QLineEdit, QPushButton, QFileDialog, QHBoxLayout, QVBoxLayout, QFrame, QSlider, QSizePolicy, QSpacerItem, QMessageBox ) from PySide6.QtGui import QPixmap, QImage, QPainter, QColor, QPen from PySide6.QtCore import Qt, QPoint # --- Core Image Processing --- def create_mask(img: Image.Image, alpha_threshold: float) -> Image.Image: alpha = img.split()[3] thr = int(alpha_threshold * 255) return Image.eval(alpha, lambda a: 255 if a <= thr else 0) def inflate_mask(mask: Image.Image, px_radius: int) -> Image.Image: w, h = mask.size inflated = Image.new("L", (w, h), 255) draw = ImageDraw.Draw(inflated) pix = mask.load() for y in range(h): for x in range(w): if pix[x, y] < 255: bbox = [x - px_radius, y - px_radius, x + px_radius, y + px_radius] draw.ellipse(bbox, fill=0) return inflated def fill_holes(mask: Image.Image) -> Image.Image: arr = np.array(mask) h, w = arr.shape hole = arr == 255 visited = np.zeros_like(hole, dtype=bool) dq = deque() for x in range(w): if hole[0, x]: dq.append((0, x)); visited[0, x] = True if hole[h-1, x]: dq.append((h-1, x)); visited[h-1, x] = True for y in range(h): if hole[y, 0]: dq.append((y, 0)); visited[y, 0] = True if hole[y, w-1]: dq.append((y, w-1)); visited[y, w-1] = True while dq: y, x = dq.popleft() for dy, dx in [(1,0),(-1,0),(0,1),(0,-1)]: ny, nx = y+dy, x+dx if 0<=ny QImage: rgba = pil_img.convert('RGBA') data = rgba.tobytes('raw', 'RGBA') w, h = rgba.size qimg = QImage(data, w, h, QImage.Format_RGBA8888) return qimg def rdp(points, epsilon): points = np.asarray(points) if len(points) < 3: return points line = points[[0, -1]] vec = line[1] - line[0] diffs = points - line[0] distances = np.abs(vec[0]*diffs[:,1] - vec[1]*diffs[:,0]) / (np.linalg.norm(vec) + 1e-6) idx = np.argmax(distances) dmax = distances[idx] if dmax > epsilon: res1 = rdp(points[:idx+1], epsilon) res2 = rdp(points[idx:], epsilon) return np.vstack((res1[:-1], res2)) else: return np.vstack((points[0], points[-1])) def extract_largest_contour(mask_img): arr = np.array(mask_img) arr = 255 - arr arr = arr.astype(np.float32) / 255.0 contours = measure.find_contours(arr, level=0.5) if not contours: return None # filter: drop all contours with length < 5 (noise) contours = [c for c in contours if len(c) >= 5] if not contours: return None return max(contours, key=len) def moving_average(points, window=5): points = np.asarray(points) n = len(points) window = min(window, n) if window < 1: window = 1 pad = window // 2 padded = np.vstack([points[-pad:], points, points[:pad]]) return np.array([ padded[i:i+window].mean(axis=0) for i in range(n) ]) def auto_detect_corners(points, angle_thresh_deg=135, window=5): points = np.asarray(points) N = len(points) if N < window * 2 + 1: return [] marks = [] rad_thresh = np.deg2rad(angle_thresh_deg) for i in range(N): p0 = points[(i - window) % N] p1 = points[i] p2 = points[(i + window) % N] v1 = p0 - p1 v2 = p2 - p1 norm1 = np.linalg.norm(v1) norm2 = np.linalg.norm(v2) if norm1 == 0 or norm2 == 0: continue angle = np.arccos(np.clip(np.dot(v1, v2) / (norm1 * norm2), -1.0, 1.0)) if angle < rad_thresh: marks.append((p1[0], p1[1], "spikey")) return marks def fit_spline_polyline_with_marks(points, marks, num_out=120, smooth=8.0): points = np.asarray(points) N = len(points) if N < 3: return points[:num_out] local_smooth = np.full(N, smooth) for bx, by, mode in marks: dists = np.linalg.norm(points - np.array([bx, by]), axis=1) idx = np.argmin(dists) for delta in range(-2, 3): j = (idx + delta) % N if mode == "spikey": local_smooth[j] = min(local_smooth[j], 1.5) out_points = [] for i in range(N): window = max(3, int(local_smooth[i])) window = min(window, N) i0 = (i - window // 2) % N i1 = (i + window // 2 + 1) % N if i0 < i1: idxs = np.arange(i0, i1) else: idxs = np.concatenate([np.arange(i0, N), np.arange(0, i1)]) if len(idxs) == 0: mean = points[i] else: mean = points[idxs].mean(axis=0) out_points.append(mean) out_points = np.array(out_points) idxs = np.linspace(0, len(out_points), num_out, endpoint=False, dtype=int) return out_points[idxs] def fit_polyline_to_beziers(points, group=3): n = len(points) beziers = [] if n < 4: return [] for i in range(0, n-1, group): p0 = points[i % n] p1 = points[(i+1) % n] p2 = points[(i+2) % n] p3 = points[(i+3) % n] beziers.append([p0, p1, p2, p3]) return beziers def bezier_path_svg_cubicfit(beziers, width, height): d = "" for i, seg in enumerate(beziers): if i == 0: d += f"M {seg[0][0]:.2f},{seg[0][1]:.2f} " d += f"C {seg[1][0]:.2f},{seg[1][1]:.2f} {seg[2][0]:.2f},{seg[2][1]:.2f} {seg[3][0]:.2f},{seg[3][1]:.2f} " d += "Z" return f''' ''' def export_mask_as_bezier_svg(mask, path, rdp_epsilon_mm=0.2, spline_smooth=8.0, num_spline_points=120, dpi=300, angle_thresh_deg=135): contour = extract_largest_contour(mask) if contour is None or len(contour) < 8: raise ValueError("No contour found or contour too small!") contour = np.array(contour)[:, ::-1] if not np.allclose(contour[0], contour[-1]): contour = np.vstack([contour, contour[0]]) def mm_to_px(mm, dpi): return mm * dpi / 25.4 rdp_epsilon_px = mm_to_px(rdp_epsilon_mm, dpi) smoothed = moving_average(contour, window=int(max(3, rdp_epsilon_px))) reduced = rdp(smoothed, epsilon=rdp_epsilon_px) if reduced.shape[0] < 4: raise ValueError("Not enough points after simplification for curve fitting!") if not np.allclose(reduced[0], reduced[-1]): reduced = np.vstack([reduced, reduced[0]]) marks = auto_detect_corners(reduced, angle_thresh_deg=angle_thresh_deg, window=5) smooth_poly = fit_spline_polyline_with_marks(reduced, marks, num_out=num_spline_points, smooth=spline_smooth) beziers = fit_polyline_to_beziers(smooth_poly, group=3) h, w = mask.size[1], mask.size[0] svg = bezier_path_svg_cubicfit(beziers, w, h) with open(path, "w") as f: f.write(svg) return smooth_poly # --- Commented reference helpers from old code/ for future experiments --- # These stay commented so the current workflow remains unchanged, but the # snippets can be copied back in without keeping the old files around. # def fit_spline_polyline(points, num_out=200, smooth=5.0): # """SciPy spline fit used by an earlier GUI version (periodic curve).""" # x, y = points[:, 0], points[:, 1] # tck, _ = splprep([x, y], s=smooth, per=True) # unew = np.linspace(0, 1.0, num_out) # out = splev(unew, tck) # return np.vstack([out[0], out[1]]).T # def catmull_rom_to_beziers(points, closed=True, max_handle_frac=0.5): # """Catmull-Rom to cubic Bezier conversion with handle clamping.""" # points = np.asarray(points) # n = len(points) # beziers = [] # if closed: # pts = np.vstack([points[-1], points, points[:2]]) # rng = range(1, n + 1) # else: # pts = np.vstack([points[0], points, points[-1]]) # rng = range(1, n) # for i in rng: # p0, p1, p2, p3 = pts[i - 1], pts[i], pts[i + 1], pts[i + 2] # bp0 = p1 # v1 = (p2 - p0) / 6.0 # v2 = (p3 - p1) / 6.0 # seg1 = np.linalg.norm(p2 - p1) # seg0 = np.linalg.norm(p1 - p0) # seg2 = np.linalg.norm(p3 - p2) # max_len1 = max_handle_frac * min(seg1, seg0) # max_len2 = max_handle_frac * min(seg2, seg1) # v1_len = np.linalg.norm(v1) # v2_len = np.linalg.norm(v2) # if v1_len > max_len1 and v1_len > 0: # v1 = v1 * (max_len1 / v1_len) # if v2_len > max_len2 and v2_len > 0: # v2 = v2 * (max_len2 / v2_len) # bp1 = p1 + v1 # bp2 = p2 - v2 # bp3 = p2 # beziers.append([bp0, bp1, bp2, bp3]) # return beziers # def export_polyline_svg(points, path, width, height): # """Quick SVG export of a polyline outline for debugging.""" # d = "M " + " L ".join(f"{x:.2f},{y:.2f}" for x, y in points) + " Z" # svg = f'\\n' # svg += f'\\n' # with open(path, "w") as f: # f.write(svg) # def sample_polyline(points, N=1000): # """Evenly sample points along a polyline.""" # points = np.asarray(points) # dists = np.sqrt(np.sum(np.diff(points, axis=0)**2, axis=1)) # cumlen = np.concatenate([[0], np.cumsum(dists)]) # total_len = cumlen[-1] # interp_locs = np.linspace(0, total_len, N) # interp_points = [] # for loc in interp_locs: # idx = np.searchsorted(cumlen, loc) - 1 # idx = min(idx, len(points) - 2) # seglen = cumlen[idx+1] - cumlen[idx] # t = (loc - cumlen[idx]) / seglen if seglen > 0 else 0 # interp = (1 - t) * points[idx] + t * points[idx+1] # interp_points.append(interp) # return np.array(interp_points) # def _legacy_bezier_q(ctrl_poly, t): # """Cubic Bezier evaluation reused by sampling helpers.""" # mt = 1 - t # return ( # mt**3 * ctrl_poly[0][0] + 3 * mt**2 * t * ctrl_poly[1][0] + 3 * mt * t**2 * ctrl_poly[2][0] + t**3 * ctrl_poly[3][0], # mt**3 * ctrl_poly[0][1] + 3 * mt**2 * t * ctrl_poly[1][1] + 3 * mt * t**2 * ctrl_poly[2][1] + t**3 * ctrl_poly[3][1] # ) # def sample_bezier_path(beziers, N=1000): # """Sample a list of cubic Beziers into N evenly spaced points.""" # samples = [] # segs = len(beziers) # pts_per_seg = max(2, N // segs) # for bez in beziers: # for i in range(pts_per_seg): # t = i / (pts_per_seg - 1) # p = _legacy_bezier_q(bez, t) # samples.append(np.array(p)) # return np.array(samples[:N]) # def find_deviations(poly_points, bezier_points, threshold=5.0): # """Locate indexes where Bezier samples deviate from the polyline.""" # dists = np.linalg.norm(poly_points - bezier_points, axis=1) # deviations = np.where(dists > threshold)[0] # return deviations, dists # def blend_bezier_with_polyline(beziers, poly_points, bezier_points, deviations, alpha=0.7): # """Pull Bezier handles toward polyline points where error is large.""" # N = len(bezier_points) # segs = len(beziers) # pts_per_seg = max(2, N // segs) # for idx in deviations: # seg_idx = idx // pts_per_seg # if seg_idx >= len(beziers): # continue # p_poly = poly_points[idx] # for c_idx in (1, 2): # c = np.array(beziers[seg_idx][c_idx]) # beziers[seg_idx][c_idx] = tuple(alpha * c + (1 - alpha) * p_poly) # return beziers # Potrace + Skia offset pipeline (old kisscut_wrap.py). Requires potrace CLI, # lxml, svg.path, and skia-python; kept for reference if a true stroke offset # is preferred over the contour-based approach here. # import subprocess, tempfile # from lxml import etree # from svg.path import parse_path, CubicBezier, Line, QuadraticBezier, Arc # import skia # # def png_to_pnm(input_png, pnm_path): # img = Image.open(input_png).convert("L") # img.save(pnm_path, format="PPM") # # def pnm_to_base_svg(pnm_path, svg_path): # subprocess.run([ # "potrace", "-s", "-o", svg_path, "-b", "svg", # "--alphamax", "0.1", "--turdsize", "1", pnm_path # ], check=True) # # def offset_svg_paths(input_svg, output_svg, offset_pt=9.0): # parser = etree.XMLParser(remove_blank_text=True) # tree = etree.parse(input_svg, parser) # root = tree.getroot() # ns = {"svg": "http://www.w3.org/2000/svg"} # svg_ns = "http://www.w3.org/2000/svg" # new_root = etree.Element("{%s}svg" % svg_ns, # nsmap={None: svg_ns}, # width=root.get("width"), # height=root.get("height"), # viewBox=root.get("viewBox") or f"0 0 {root.get('width')} {root.get('height')}") # for path_el in root.xpath("//svg:path", namespaces=ns): # d = path_el.get("d") # path = parse_path(d) # sk_path = skia.Path() # start = path[0].start # sk_path.moveTo(start.real, -start.imag) # for seg in path: # if isinstance(seg, CubicBezier): # sk_path.cubicTo( # seg.control1.real, -seg.control1.imag, # seg.control2.real, -seg.control2.imag, # seg.end.real, -seg.end.imag # ) # elif isinstance(seg, Line): # sk_path.lineTo(seg.end.real, -seg.end.imag) # elif isinstance(seg, QuadraticBezier): # c1 = seg.start + 2/3*(seg.control - seg.start) # c2 = seg.end + 2/3*(seg.control - seg.end) # sk_path.cubicTo( # c1.real, -c1.imag, # c2.real, -c2.imag, # seg.end.real, -seg.end.imag # ) # elif isinstance(seg, Arc): # sk_path.lineTo(seg.end.real, -seg.end.imag) # stroke = skia.StrokeRec() # stroke.setStrokeStyle(offset_pt) # sk_offset = sk_path.strokeAndConvertToPath(stroke) # d2 = sk_offset.toSVGString() # etree.SubElement(new_root, "path", # d=d2, # fill="none", # stroke="red", # **{"stroke-width": "1"}) # etree.ElementTree(new_root).write( # output_svg, # pretty_print=True, # xml_declaration=True, # encoding="utf-8" # ) # --- Preview Widget (just display, auto-margin alignment) --- class PreviewWidget(QLabel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setFrameShape(QFrame.Box) self.setAlignment(Qt.AlignCenter) self.bezier_points = None self.display_img = None self.mask = None self.orig_img_shape = None self.scale = 1.0 self.offset = (0, 0) self.margin = 0 def set_preview(self, img, mask, bezier_points, margin): self.display_img = img self.mask = mask self.orig_img_shape = img.size self.bezier_points = bezier_points self.margin = margin self.repaint() def paintEvent(self, event): super().paintEvent(event) if self.display_img is None: return w0, h0 = self.orig_img_shape w, h = self.width(), self.height() scale = min(w / w0, h / h0) dx, dy = (w - w0 * scale) / 2, (h - h0 * scale) / 2 self.scale, self.offset = scale, (dx, dy) painter = QPainter(self) img_q = pil_to_qimage(self.display_img).scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) painter.drawImage(QPoint(int(dx), int(dy)), img_q) if self.mask is not None: mask_rgba = self.mask.convert('RGBA') mask_np = np.array(mask_rgba) mask_np[..., 3] = np.clip(mask_np[..., 0] / 255 * 110, 0, 110).astype(np.uint8) mask_img = Image.fromarray(mask_np) mask_q = pil_to_qimage(mask_img).scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) painter.drawImage(QPoint(int(dx), int(dy)), mask_q) if self.bezier_points is not None and len(self.bezier_points) > 1: pen = QPen(QColor(255, 0, 0, 200), 2) painter.setPen(pen) pts = self.bezier_points * scale + np.array([dx, dy]) for i in range(1, len(pts)): x1, y1 = pts[i - 1] x2, y2 = pts[i] painter.drawLine(int(x1), int(y1), int(x2), int(y2)) painter.end() # --- MainWindow Class --- class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle('Kiss-Cut Preview') self.setFixedSize(1100, 950) self.current_path = None # Left controls (inputs) input_layout = QVBoxLayout() self.input_dpi = QLineEdit('300') self.input_dpi.setFixedWidth(60) self.input_inf = QLineEdit('0.125') self.input_inf.setFixedWidth(60) self.input_thr = QLineEdit('0.1') self.input_thr.setFixedWidth(60) for lbl, w in [("DPI:", self.input_dpi), ("Inflation (inch):", self.input_inf), ("Alpha Threshold:", self.input_thr)]: row = QHBoxLayout() row.addWidget(QLabel(lbl)) row.addWidget(w) row.addSpacerItem(QSpacerItem(40, 1, QSizePolicy.Expanding, QSizePolicy.Minimum)) input_layout.addLayout(row) # Right controls (sliders) slider_layout = QVBoxLayout() self.slider_rdp = QSlider(Qt.Horizontal) self.slider_rdp.setMinimum(1) self.slider_rdp.setMaximum(100) self.slider_rdp.setValue(20) self.slider_rdp.setFixedWidth(75) self.slider_rdp.valueChanged.connect(self.update_rdp_label) self.lbl_rdp_val = QLabel("0.20") row_rdp = QHBoxLayout() row_rdp.addWidget(QLabel('RDP Epsilon (mm):')) row_rdp.addWidget(self.slider_rdp) row_rdp.addWidget(self.lbl_rdp_val) slider_layout.addLayout(row_rdp) self.slider_smooth = QSlider(Qt.Horizontal) self.slider_smooth.setMinimum(1) self.slider_smooth.setMaximum(100) self.slider_smooth.setValue(8) self.slider_smooth.setFixedWidth(75) self.slider_smooth.valueChanged.connect(self.update_smooth_label) self.lbl_smooth_val = QLabel("8.0") row_smooth = QHBoxLayout() row_smooth.addWidget(QLabel('Spline Smoothing:')) row_smooth.addWidget(self.slider_smooth) row_smooth.addWidget(self.lbl_smooth_val) slider_layout.addLayout(row_smooth) self.slider_numpts = QSlider(Qt.Horizontal) self.slider_numpts.setMinimum(20) self.slider_numpts.setMaximum(500) self.slider_numpts.setValue(120) self.slider_numpts.setFixedWidth(75) self.slider_numpts.valueChanged.connect(self.update_numpts_label) self.lbl_numpts_val = QLabel("120") row_numpts = QHBoxLayout() row_numpts.addWidget(QLabel('Spline Points:')) row_numpts.addWidget(self.slider_numpts) row_numpts.addWidget(self.lbl_numpts_val) slider_layout.addLayout(row_numpts) # Corner angle threshold slider self.slider_angle = QSlider(Qt.Horizontal) self.slider_angle.setMinimum(90) self.slider_angle.setMaximum(179) self.slider_angle.setValue(135) self.slider_angle.setFixedWidth(75) self.slider_angle.valueChanged.connect(self.update_angle_label) self.lbl_angle_val = QLabel("135°") row_angle = QHBoxLayout() row_angle.addWidget(QLabel('Corner Angle (deg):')) row_angle.addWidget(self.slider_angle) row_angle.addWidget(self.lbl_angle_val) slider_layout.addLayout(row_angle) # Reset and buttons self.btn_reset = QPushButton('Reset to Defaults') self.btn_reset.clicked.connect(self.reset_sliders) row_reset = QHBoxLayout() row_reset.addWidget(self.btn_reset) slider_layout.addLayout(row_reset) # Top controls side by side top_controls = QHBoxLayout() top_controls.addLayout(input_layout) top_controls.addSpacing(50) top_controls.addLayout(slider_layout) # Preview window (custom widget) self.preview = PreviewWidget() self.preview.setMinimumSize(800, 600) self.preview.setAcceptDrops(True) # Export and update self.btn_update = QPushButton('Update Preview') self.btn_export = QPushButton('Export Vector') self.btn_update.clicked.connect(self.update_preview) self.btn_export.clicked.connect(self.export_vector) row_btns = QHBoxLayout() row_btns.addWidget(self.btn_update) row_btns.addWidget(self.btn_export) # Layout everything main_layout = QVBoxLayout() main_layout.addLayout(top_controls) main_layout.addWidget(self.preview) main_layout.addLayout(row_btns) container = QWidget() container.setLayout(main_layout) self.setCentralWidget(container) def update_rdp_label(self): val = self.slider_rdp.value() / 100 self.lbl_rdp_val.setText(f"{val:.2f}") def update_smooth_label(self): val = self.slider_smooth.value() self.lbl_smooth_val.setText(f"{val:.1f}") def update_numpts_label(self): val = self.slider_numpts.value() self.lbl_numpts_val.setText(f"{val}") def update_angle_label(self): val = self.slider_angle.value() self.lbl_angle_val.setText(f"{val}°") def reset_sliders(self): self.slider_rdp.setValue(20) self.slider_smooth.setValue(8) self.slider_numpts.setValue(120) self.slider_angle.setValue(135) def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.accept() def dropEvent(self, event): urls = event.mimeData().urls() if urls: path = urls[0].toLocalFile() if os.path.isfile(path): self.current_path = path self.update_preview() def update_preview(self): if not self.current_path: return try: dpi = float(self.input_dpi.text()) inflation = float(self.input_inf.text()) thr = float(self.input_thr.text()) rdp_epsilon = self.slider_rdp.value() / 100 spline_smooth = float(self.slider_smooth.value()) num_spline_points = int(self.slider_numpts.value()) angle_thresh_deg = int(self.slider_angle.value()) img, mask, comp, margin = process_image(self.current_path, dpi, inflation, thr) contour = extract_largest_contour(mask) if contour is None or len(contour) < 8: raise ValueError("No contour found or contour too small!") contour = np.array(contour)[:, ::-1] if not np.allclose(contour[0], contour[-1]): contour = np.vstack([contour, contour[0]]) def mm_to_px(mm, dpi): return mm * dpi / 25.4 rdp_epsilon_px = mm_to_px(rdp_epsilon, dpi) smoothed = moving_average(contour, window=int(max(3, rdp_epsilon_px))) reduced = rdp(smoothed, epsilon=rdp_epsilon_px) if reduced.shape[0] < 4: raise ValueError("Not enough points after simplification for curve fitting!") if not np.allclose(reduced[0], reduced[-1]): reduced = np.vstack([reduced, reduced[0]]) marks = auto_detect_corners(reduced, angle_thresh_deg=angle_thresh_deg, window=5) smooth_poly = fit_spline_polyline_with_marks(reduced, marks, num_out=num_spline_points, smooth=spline_smooth) beziers = fit_polyline_to_beziers(smooth_poly, group=3) bezier_points = [] def _bezier_q(ctrl_poly, t): mt = 1 - t return ( mt**3 * ctrl_poly[0][0] + 3 * mt**2 * t * ctrl_poly[1][0] + 3 * mt * t**2 * ctrl_poly[2][0] + t**3 * ctrl_poly[3][0], mt**3 * ctrl_poly[0][1] + 3 * mt**2 * t * ctrl_poly[1][1] + 3 * mt * t**2 * ctrl_poly[2][1] + t**3 * ctrl_poly[3][1] ) for bez in beziers: for t in np.linspace(0, 1, 20): bezier_points.append(_bezier_q(bez, t)) bezier_points = np.array(bezier_points) self.preview.set_preview(img, mask, bezier_points, margin) except Exception as e: QMessageBox.critical(self, 'Error', str(e)) def export_vector(self): if not self.current_path: return fn, _ = QFileDialog.getSaveFileName(self, 'Save SVG', '', 'SVG Files (*.svg)') if fn: dpi = float(self.input_dpi.text()) inflation = float(self.input_inf.text()) thr = float(self.input_thr.text()) rdp_epsilon = self.slider_rdp.value() / 100 spline_smooth = float(self.slider_smooth.value()) num_spline_points = int(self.slider_numpts.value()) angle_thresh_deg = int(self.slider_angle.value()) img, mask, comp, margin = process_image(self.current_path, dpi, inflation, thr) try: export_mask_as_bezier_svg( mask, fn, rdp_epsilon, spline_smooth, num_spline_points, dpi, angle_thresh_deg=angle_thresh_deg) except Exception as e: QMessageBox.critical(self, 'Export Error', str(e)) # --- Entry Point --- if __name__ == '__main__': app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec())