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