commit 173214258a3bc48295e7ecc92ba834732ba9854a Author: Victor Giers Date: Wed May 21 06:05:38 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb586d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +sample.png +sample2.png +sample3.png +sample4.png +sample5.png +sample6.png +old code diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ab5ba84 --- /dev/null +++ b/LICENSE @@ -0,0 +1,3 @@ +CC0 1.0 Universal + +The person... \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7eddfbc --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/kisscut_gui.py b/kisscut_gui.py new file mode 100644 index 0000000..e1acc43 --- /dev/null +++ b/kisscut_gui.py @@ -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 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 + +# --- 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()) \ No newline at end of file