initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
sample.png
|
||||
sample2.png
|
||||
sample3.png
|
||||
sample4.png
|
||||
sample5.png
|
||||
sample6.png
|
||||
old code
|
||||
48
README.md
Normal file
48
README.md
Normal 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
517
kisscut_gui.py
Normal 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())
|
||||
Reference in New Issue
Block a user