Files
kisscut/kisscut_gui.py

517 lines
20 KiB
Python
Raw 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
# --- 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())