Files
kisscut/kisscut_gui.py

699 lines
27 KiB
Python
Raw Permalink Normal View History

2025-05-21 06:05:38 +02:00
#!/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<h and 0<=nx<w and hole[ny, nx] and not visited[ny, nx]:
visited[ny, nx] = True
dq.append((ny, nx))
arr[np.logical_and(hole, ~visited)] = 0
return Image.fromarray(arr)
def needs_padding(mask):
arr = np.array(mask)
if np.any(arr[0,:] < 255) or np.any(arr[-1,:] < 255) or np.any(arr[:,0] < 255) or np.any(arr[:,-1] < 255):
return True
return False
def pad_image(im, margin, color):
w, h = im.size
new = Image.new(im.mode, (w + 2*margin, h + 2*margin), color)
new.paste(im, (margin, margin))
return new
def process_image(path, dpi, inflation, alpha_threshold):
img = Image.open(path).convert("RGBA")
mask = create_mask(img, alpha_threshold)
px = max(1, int(round(dpi * inflation)))
inflated = inflate_mask(mask, px)
fixed = fill_holes(inflated)
margin = 0
if needs_padding(fixed):
margin = int(max(12, px * 2))
img = pad_image(img, margin, (255,255,255,0))
mask = pad_image(mask, margin, 255)
inflated = pad_image(inflated, margin, 255)
fixed = pad_image(fixed, margin, 255)
w, h = img.size
bg = Image.new('RGBA', (w, h), (255,255,255,255))
mask3 = Image.merge('RGBA', [fixed, fixed, fixed, Image.new('L', (w,h), 255)])
comp = Image.alpha_composite(bg, mask3)
comp = Image.alpha_composite(comp, img)
return img, fixed, comp, margin
def pil_to_qimage(pil_img: Image.Image) -> 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'''<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">
<path d="{d}" fill="none" stroke="black" stroke-width="1"/>
</svg>'''
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'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">\\n'
# svg += f'<path d="{d}" fill="none" stroke="red" stroke-width="1"/>\\n</svg>'
# 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"
# )
2025-05-21 06:05:38 +02:00
# --- 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())