initial commit

This commit is contained in:
2025-05-21 06:05:38 +02:00
commit 173214258a
4 changed files with 575 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
sample.png
sample2.png
sample3.png
sample4.png
sample5.png
sample6.png
old code

3
LICENSE Normal file
View File

@@ -0,0 +1,3 @@
CC0 1.0 Universal
The person...

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# kisscut
**Author:** Victor Giers
> ⚠️ **This README.md has been automatically generated using AI and might contain hallucinations or inaccuracies. Please proceed with caution!**
# KissCut Vectorization Tool
## Description
KissCut is a simple tool to convert bitmap images into vectorized outlines suitable for laser cutting and other purposes. It processes images to extract contours, smooth them, and export as SVG files.
## Features
- Convert image masks to vector outlines.
- Adjustable parameters for contour detection and smoothing.
- Export vector graphics in SVG format.
- Drag-and-drop functionality for easy file loading.
- User-friendly GUI with sliders for fine-tuning settings.
## Requirements
- Python 3.6 or higher
- Required libraries: `numpy`, `Pillow`, `scikit-image`, `PyQt5`
Install the required libraries using pip:
```bash
pip install numpy pillow scikit-image PyQt5
```
## Usage
1. **Load an Image**: Drag and drop an image file into the preview window or use the "Update Preview" button to load.
2. **Adjust Parameters**:
- **DPI (Dots Per Inch)**: Resolution of the input image.
- **Inflation (inch)**: Additional margin around the detected contour.
- **Alpha Threshold**: Threshold for binarizing the image.
- **RDP Epsilon (mm)**: Precision of the RDP algorithm for simplifying contours.
- **Spline Smoothing**: Amount of smoothing applied to the contour.
- **Spline Points**: Number of points used in the spline approximation.
- **Corner Angle (deg)**: Threshold angle for detecting corners.
3. **Update Preview**: Click "Update Preview" to apply changes and see the result.
4. **Export Vector**: Click "Export Vector" to save the vectorized outline as an SVG file.
## License
This project is licensed under the [CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0/) Public Domain Dedication, which means you can copy, modify, distribute and use the work, even commercially, without needing to give any attribution.

517
kisscut_gui.py Normal file
View File

@@ -0,0 +1,517 @@
#!/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())