Spaces:
Running
Running
Download vectorizer_core.py from SkHugger/icon-vectorizer: direct link, hf CLI and curl.
- Browser
- Download file 67.3 kB
-
https://huggingface.co/spaces/SkHugger/icon-vectorizer/resolve/main/vectorizer_core.py
- Command line
-
hf download hf://spaces/SkHugger/icon-vectorizer/vectorizer_core.py
-
curl -L -o vectorizer_core.py https://huggingface.co/spaces/SkHugger/icon-vectorizer/resolve/main/vectorizer_core.py
67.3 kB
"""
ICON VECTORIZER - ACCURATE CENTERLINE
====================================
vectorizer_core.py — slow-but-accurate 4-stage centerline vectorization pipeline.
Converts black-outline icons (JPG/PNG) into clean centerline SVG with semantic
data-name labels (e.g. eyepiece-rim, optical-tube, curved-arm).
Pipeline
--------
Stage 0 - Preprocessing : 3x/4x INTER_CUBIC upscale, median+gaussian denoise,
user threshold, morphological opening.
Stage 1 - Segmentation : connected ink components (+ contour-hierarchy split),
optional MobileSAM refinement.
Stage 2 - Primitives : circularity test -> perfect <circle> (minEnclosingCircle
+ RANSAC refine); convex quad -> inset <rect>.
Stage 3 - Centerline : skimage skeletonize (Lee, NOT Zhang-Suen) + EDT distance,
skeleton graph (endpoints/junctions/loops), spur prune,
endpoint snap, junction branch-direction measurement.
Stage 4 - Fitting : RANSAC circle/ellipse on closed loops, Kasa arc fit,
Schneider cubic Bezier fit (Graphics Gems, tol=0.6px),
optional CLIP semantic labeling, clean SVG output.
Everything geometric is deterministic and offline. Only the OPTIONAL semantic
labeling (CLIP) and segmentation refinement (MobileSAM) need torch + model
downloads; the tracer works fully without them.
Author: built for Twins Design Studio / allfa — accuracy >> speed.
"""
from __future__ import annotations
import math
import os
import time
import urllib.request
from dataclasses import dataclass, field, replace
from typing import Callable, Dict, List, Optional, Tuple
import numpy as np
import cv2
from PIL import Image
try:
from skimage.morphology import skeletonize
HAS_SKIMAGE = True
except ImportError: # pragma: no cover
HAS_SKIMAGE = False
try:
from scipy.ndimage import gaussian_filter1d, distance_transform_edt
HAS_SCIPY = True
except ImportError: # pragma: no cover
HAS_SCIPY = False
# ----------------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------------
SMOOTH_PRESETS = {"Low": 1, "Medium": 4, "High": 10}
@dataclass
class FitParams:
"""Fitting parameters (accurate mode).
tol is in ORIGINAL image pixels (user-facing unit); the pipeline works
at `scale`x, so the working tolerance is tol * scale.
"""
tol: float = 0.6 # max deviation in px (original image size)
smooth_iters: int = 1 # light pre-smoothing passes (Low)
reduce: bool = False # merge near-collinear cubics (OFF for accuracy)
# Prefer a single cubic over a fragmented fit when the chain is "almost"
# cubic: accept a single cubic if its max deviation is within this factor
# of tol. Keeps smooth curves as ONE editable cubic instead of dozens of
# fragments; genuine S-curves still split (their single-cubic error is far
# larger). The actual deviation is always reported per path.
SINGLE_CUBIC_TOL_FACTOR = 4.0
# Pre-fit smoothing sigma (upscaled px). Removes rasterization staircase
# noise (high frequency) without distorting real curve shape (which bends
# over tens of px). Applied `smooth_iters` times per SMOOTH_PRESETS.
SMOOTH_SIGMA = 1.5
@dataclass
class VectorizerConfig:
threshold: int = 110
upscale: int = 3 # Quality menu: 1x/2x/3x/4x
smoothing: str = "Low" # Low / Medium / High
tolerance: float = 0.6 # px, upscaled space
detect_corners: bool = True
detect_junctions: bool = True
detect_circles: bool = True # circles AND arcs
reduce_anchors: bool = False
use_sam: bool = False # MobileSAM refinement (slow, optional)
use_clip: bool = True # CLIP semantic labels (optional)
clip_prompts: List[str] = field(default_factory=lambda: [
"eyepiece", "optical tube", "lens", "microscope arm", "pivot",
"stage", "base", "handle", "knob", "button", "icon outline",
])
min_part_area: int = 60 # px^2 in upscaled space
def fit_params(self) -> FitParams:
return FitParams(
tol=float(self.tolerance),
smooth_iters=SMOOTH_PRESETS.get(self.smoothing, 1),
reduce=bool(self.reduce_anchors),
)
@dataclass
class Part:
id: int
mask: np.ndarray # bool array, upscaled working space, ink=True
bbox: Tuple[int, int, int, int] # x, y, w, h (upscaled)
label: str = "" # CLIP base label, e.g. "eyepiece"
# progress callback type: fn(fraction: float, stage: str)
ProgressCb = Optional[Callable[[float, str], None]]
def _progress(cb: ProgressCb, frac: float, stage: str):
if cb:
try:
cb(max(0.0, min(1.0, frac)), stage)
except Exception:
pass
# ============================================================================
# STAGE 0 - Preprocessing
# ============================================================================
def preprocess(gray: np.ndarray, cfg: VectorizerConfig) -> Tuple[np.ndarray, int]:
"""Upscale, denoise, threshold, clean. Returns (ink_mask_bool, scale)."""
scale = int(cfg.upscale)
h, w = gray.shape[:2]
big = cv2.resize(gray, (w * scale, h * scale), interpolation=cv2.INTER_CUBIC)
big = cv2.medianBlur(big, 3) # kill screen moire
big = cv2.GaussianBlur(big, (0, 0), 0.6 * scale) # gentle AA
_, bw = cv2.threshold(big, int(cfg.threshold), 255, cv2.THRESH_BINARY_INV)
bw = cv2.morphologyEx(bw, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
return (bw > 0), scale
def load_grayscale(path: str) -> np.ndarray:
img = Image.open(path).convert("L")
return np.array(img)
# ============================================================================
# STAGE 1 - Semantic segmentation
# ============================================================================
def _split_component_hierarchy(comp: np.ndarray) -> List[np.ndarray]:
"""Split one ink component via contour hierarchy (outer contours = parts).
Keeps it conservative: only splits when an outer contour (other than the
largest) is reasonably big; tiny fragments are merged back into the
largest region so thin touching strokes don't shatter.
"""
cnts, hier = cv2.findContours(comp.astype(np.uint8), cv2.RETR_CCOMP,
cv2.CHAIN_APPROX_SIMPLE)
if not cnts or hier is None:
return [comp]
hier = hier[0]
outers = [i for i in range(len(cnts)) if hier[i][3] == -1]
if len(outers) <= 1:
return [comp]
areas = [(cv2.contourArea(cnts[i]), i) for i in outers]
areas.sort(reverse=True)
biggest_area = areas[0][0]
keep = [i for a, i in areas if a >= 0.15 * biggest_area]
if len(keep) <= 1:
return [comp]
parts = []
for i in keep:
m = np.zeros_like(comp)
cv2.drawContours(m, cnts, i, 1, thickness=cv2.FILLED)
parts.append(m.astype(bool))
return parts
def segment_parts(ink: np.ndarray, cfg: VectorizerConfig,
cb: ProgressCb = None) -> List[Part]:
"""Stage 1: split the ink mask into logical parts.
Default (reliable, offline): connected ink components + conservative
contour-hierarchy split. Optional: MobileSAM refinement (slow, CPU-heavy).
"""
_progress(cb, 0.10, "Stage 1/4 - segmenting parts")
n, labels, stats, _ = cv2.connectedComponentsWithStats(
ink.astype(np.uint8), connectivity=8)
parts: List[Part] = []
pid = 0
for i in range(1, n):
area = stats[i, cv2.CC_STAT_AREA]
if area < cfg.min_part_area:
continue
comp = (labels == i)
for sub in _split_component_hierarchy(comp):
ys, xs = np.nonzero(sub)
if len(xs) == 0:
continue
x0, x1 = xs.min(), xs.max()
y0, y1 = ys.min(), ys.max()
parts.append(Part(id=pid, mask=sub,
bbox=(int(x0), int(y0), int(x1 - x0 + 1), int(y1 - y0 + 1))))
pid += 1
if cfg.use_sam:
sam_parts = _try_sam_parts(ink, cb)
if sam_parts:
parts = sam_parts # SAM wins when it actually runs
_progress(cb, 0.18, f"Stage 1/4 - {len(parts)} parts")
return parts
_MOBILESAM_URL = ("https://github.com/ChaoningZhang/MobileSAM/raw/master/"
"weights/mobile_sam.pt")
def _try_sam_parts(ink: np.ndarray, cb: ProgressCb = None) -> Optional[List[Part]]:
"""Optional MobileSAM automatic mask generation. Returns None on any failure.
NOTE: honest caveat — SAM segments *regions*, not line-art strokes. On
black-outline icons it often returns background blobs, which is why this
is OFF by default and connected components are the reliable path.
"""
try:
from mobile_sam import sam_model_registry, SamAutomaticMaskGenerator
except ImportError:
return None
try:
ckpt = os.path.join(os.path.expanduser("~"), ".cache",
"icon_vectorizer", "mobile_sam.pt")
if not os.path.exists(ckpt):
_progress(cb, 0.12, "Stage 1/4 - downloading MobileSAM weights (one-time)")
os.makedirs(os.path.dirname(ckpt), exist_ok=True)
urllib.request.urlretrieve(_MOBILESAM_URL, ckpt)
_progress(cb, 0.14, "Stage 1/4 - MobileSAM segmenting (slow on CPU)")
sam = sam_model_registry["vit_t"](checkpoint=ckpt)
sam.to("cpu")
gen = SamAutomaticMaskGenerator(sam, points_per_side=16)
rgb = np.stack([(ink * 255).astype(np.uint8)] * 3, axis=-1)
masks = gen.generate(255 - rgb) # SAM expects dark-on-light flipped? keep simple
parts = []
for i, m in enumerate(masks):
seg = m["segmentation"]
if seg.sum() < 200:
continue
ys, xs = np.nonzero(seg)
parts.append(Part(id=i, mask=seg,
bbox=(int(xs.min()), int(ys.min()),
int(xs.max() - xs.min() + 1),
int(ys.max() - ys.min() + 1))))
return parts or None
except Exception:
return None
# ============================================================================
# STAGE 2 - Primitive detection (per part)
# ============================================================================
def _kasa_circle(pts: np.ndarray) -> Tuple[float, float, float]:
"""Algebraic (Kasa) circle fit. pts: Nx2."""
x = pts[:, 0]
y = pts[:, 1]
A = np.c_[2 * x, 2 * y, np.ones(len(x))]
b = x ** 2 + y ** 2
sol, *_ = np.linalg.lstsq(A, b, rcond=None)
cx, cy = float(sol[0]), float(sol[1])
r = math.sqrt(max(0.0, float(sol[2]) + cx ** 2 + cy ** 2))
return cx, cy, r
def ransac_circle(pts: np.ndarray, tol: float = 1.2, iters: int = 80,
min_inlier_ratio: float = 0.70) -> Tuple[bool, Tuple[float, float, float], float]:
"""RANSAC circle refinement. Returns (ok, (cx,cy,r), inlier_ratio)."""
pts = np.asarray(pts, dtype=np.float64)
if len(pts) < 6:
return False, (0.0, 0.0, 0.0), 0.0
best_inliers = np.zeros(len(pts), dtype=bool)
rng = np.random.default_rng(7)
for _ in range(iters):
idx = rng.choice(len(pts), 3, replace=False)
tri = pts[idx]
# skip near-collinear triples
if abs(np.cross(tri[1] - tri[0], tri[2] - tri[0])) < 1e-6:
continue
try:
cx, cy, r = _kasa_circle(tri)
except Exception:
continue
if r < 1.0:
continue
d = np.abs(np.linalg.norm(pts - [cx, cy], axis=1) - r)
inl = d < tol
if inl.sum() > best_inliers.sum():
best_inliers = inl
ratio = float(best_inliers.sum()) / len(pts)
if ratio < min_inlier_ratio or best_inliers.sum() < 6:
return False, (0.0, 0.0, 0.0), ratio
cx, cy, r = _kasa_circle(pts[best_inliers]) # refit on inliers
return True, (cx, cy, r), ratio
def _quad_angles_ok(quad: np.ndarray) -> bool:
"""Check convex quad has ~90 degree corners."""
def ang(a, b, c):
v1 = a - b
v2 = c - b
n = np.linalg.norm(v1) * np.linalg.norm(v2)
if n < 1e-9:
return 0.0
return math.degrees(math.acos(np.clip(np.dot(v1, v2) / n, -1, 1)))
for i in range(4):
a = ang(quad[i], quad[(i + 1) % 4], quad[(i + 2) % 4])
if not (65.0 < a < 115.0):
return False
return True
def _inset_quad(quad: np.ndarray, d: float) -> Optional[np.ndarray]:
"""Offset a convex quad inward by d (parallel edge offset)."""
c = quad.mean(axis=0)
lines = []
for i in range(4):
p1, p2 = quad[i], quad[(i + 1) % 4]
e = p2 - p1
L = np.linalg.norm(e)
if L < 1e-9:
return None
n = np.array([-e[1], e[0]]) / L
if np.dot(n, c - p1) < 0: # flip to inward
n = -n
lines.append((p1 + n * d, e))
out = []
for i in range(4):
p1, e1 = lines[i]
p2, e2 = lines[(i + 1) % 4]
# intersect p1 + t*e1 with p2 + s*e2
M = np.column_stack([e1, -e2])
det = np.linalg.det(M)
if abs(det) < 1e-9:
return None
t = np.linalg.det(np.column_stack([p2 - p1, -e2])) / det
out.append(p1 + t * e1)
return np.array(out)
def detect_primitive(part_mask: np.ndarray, cfg: VectorizerConfig) -> Optional[dict]:
"""Stage 2: circle / rect detection for one part mask (upscaled coords).
Returns dict(kind=..., ...) or None when the part needs centerline tracing.
"""
cnts, _ = cv2.findContours(part_mask.astype(np.uint8), cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_NONE)
if not cnts:
return None
cnt = max(cnts, key=cv2.contourArea)
pts = cnt[:, 0, :].astype(np.float64) # x, y
if len(pts) < 8:
return None
area = cv2.contourArea(cnt)
peri = cv2.arcLength(cnt, True)
if peri <= 0:
return None
# --- circle: circularity + RANSAC refinement ---------------------------
circularity = 4 * math.pi * area / (peri * peri)
if cfg.detect_circles and circularity > 0.85:
(cx0, cy0), r0 = cv2.minEnclosingCircle(pts.astype(np.float32))
ok, (cx, cy, r), ratio = ransac_circle(pts, tol=1.2, iters=80,
min_inlier_ratio=0.60)
if not ok: # fall back to min enclosing circle
cx, cy, r, ratio = float(cx0), float(cy0), float(r0), 1.0
# sanity: radius must be plausible vs the part bbox
ys, xs = np.nonzero(part_mask)
bw = max(1.0, float(xs.max() - xs.min()), float(ys.max() - ys.min()))
if 0.35 * bw < r < 0.75 * bw:
return {"kind": "circle", "cx": cx, "cy": cy, "r": r,
"inliers": ratio, "circularity": circularity}
# --- rectangle: convex quad with ~90 deg corners -----------------------
approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)[:, 0, :].astype(np.float64)
if len(approx) == 4 and cv2.isContourConvex(approx.astype(np.float32)):
if _quad_angles_ok(approx):
# filled vs outline: ink/bbox area ratio
x, y, w, h = cv2.boundingRect(cnt)
fill_ratio = float(part_mask.sum()) / max(1.0, float(w * h))
if fill_ratio > 0.70:
# filled rect: output as-is (no centerline inset)
return {"kind": "rect", "pts": approx, "stroke_w": 0.0}
dt = cv2.distanceTransform(part_mask.astype(np.uint8),
cv2.DIST_L2, 3)
stroke_w = 2.0 * float(dt.max()) # upscaled px
inset = _inset_quad(approx, stroke_w / 2.0)
if inset is not None:
return {"kind": "rect", "pts": inset, "stroke_w": stroke_w}
return None
# ============================================================================
# STAGE 3 - Accurate centerline: medial axis + skeleton graph
# ============================================================================
@dataclass
class SkelNode:
id: int
x: float
y: float
kind: str # 'end' | 'junction'
@dataclass
class SkelEdge:
id: int
n1: int # node id (-1 for loop edges)
n2: int
pts: np.ndarray # Nx2 float, x/y in upscaled coords
loop: bool = False
@dataclass
class SkelGraph:
nodes: Dict[int, SkelNode]
edges: Dict[int, SkelEdge]
stroke_w: float # median stroke width, upscaled px
branch_dirs: Dict[Tuple[int, int], Tuple[float, float]] # (node, edge) -> unit dir
def _neighbors8(r: int, c: int, h: int, w: int):
for dr in (-1, 0, 1):
for dc in (-1, 0, 1):
if dr == 0 and dc == 0:
continue
rr, cc = r + dr, c + dc
if 0 <= rr < h and 0 <= cc < w:
yield rr, cc
def _fill_tiny_holes(ink: np.ndarray, max_area: float = 500.0) -> np.ndarray:
"""Fill tiny background holes (raster specks) inside the ink mask.
A 1-2px white speck creates a bogus little skeleton loop; real ring
interiors are orders of magnitude bigger and are left alone.
max_area is in upscaled px^2 (500 ~= 8px diameter at 3x).
"""
try:
from scipy.ndimage import label as _label
except ImportError:
return ink
inv = ~ink
lh, nh = _label(inv, structure=np.ones((3, 3), dtype=int))
if nh == 0:
return ink
border = set(np.unique(np.concatenate([lh[0, :], lh[-1, :],
lh[:, 0], lh[:, -1]])))
out = ink.copy()
for i in range(1, nh + 1):
if i in border:
continue
if int((lh == i).sum()) < max_area:
out[lh == i] = True
return out
def skeleton_graph(ink: np.ndarray) -> Optional[SkelGraph]:
"""Stage 3: skeleton -> pruned node/edge graph.
Uses skimage.morphology.skeletonize (Lee, 1994) — NOT Zhang-Suen — plus
a separate deterministic Euclidean distance transform.
NOTE: the spec asked for medial_axis(return_distance=True), but
skimage 0.26.0's medial_axis is NONDETERMINISTIC (verified: same input
gives 673/696/698/700/701 skeleton pixels across runs, even
single-threaded). A tracer that outputs a different SVG every run is
unacceptable, so we use the deterministic Lee skeletonize + EDT.
Distance-based pruning (dist < 1.2) works exactly the same.
ink: bool array (True = ink), upscaled working space.
"""
if not HAS_SKIMAGE or not HAS_SCIPY:
raise RuntimeError(
"scikit-image and scipy are required (pip install scikit-image scipy)")
ink = _fill_tiny_holes(np.ascontiguousarray(ink))
skel = skeletonize(ink)
dist = distance_transform_edt(ink)
# prune: drop skeleton pixels closer than 1.2px to the boundary
keep = skel & (dist >= 1.2)
if keep.sum() < 4:
keep = skel # very thin stroke: keep everything
if keep.sum() < 4:
return None
h, w = keep.shape
k8 = keep.astype(np.uint8)
nb = cv2.filter2D(k8, -1, np.ones((3, 3), np.uint8)).astype(np.int32) - k8.astype(np.int32)
nb[~keep] = 0
# drop isolated pixels
keep[nb == 0] = False
nb[~keep] = 0
if keep.sum() < 4:
return None
stroke_w = float(2.0 * np.median(dist[keep]))
stroke_w = max(stroke_w, 2.0)
# --- nodes: endpoints (nb==1); junction clusters (nb>2, 8-connected) ---
node_id = np.full((h, w), -1, dtype=np.int32)
nodes: Dict[int, SkelNode] = {}
nid = 0
# junction clusters first
jmask = (nb > 2) & keep
nj, jlabels = cv2.connectedComponents(jmask.astype(np.uint8), connectivity=8)
for j in range(1, nj):
ys, xs = np.nonzero(jlabels == j)
cx = float(xs.mean())
cy = float(ys.mean())
# snap representative pixel to cluster pixel nearest centroid
d2 = (xs - cx) ** 2 + (ys - cy) ** 2
rep = int(np.argmin(d2))
nodes[nid] = SkelNode(id=nid, x=cx, y=cy, kind="junction")
node_id[ys, xs] = nid
nid += 1
# endpoints
ys, xs = np.nonzero((nb == 1) & keep & (node_id < 0))
for y, x in zip(ys, xs):
nodes[nid] = SkelNode(id=nid, x=float(x), y=float(y), kind="end")
node_id[y, x] = nid
nid += 1
node_mask = node_id >= 0
unvis = keep & (~node_mask)
edges: Dict[int, SkelEdge] = {}
eid = 0
def node_xy(i: int) -> Tuple[float, float]:
n = nodes[i]
return (n.x, n.y)
# --- walk edges starting from every node -------------------------------
for i, nd in list(nodes.items()):
# representative pixel(s) of this node
rep_pixels = list(zip(*np.nonzero(node_id == i)))
for (ry, rx) in rep_pixels:
for (qy, qx) in _neighbors8(ry, rx, h, w):
if not unvis[qy, qx]:
continue
# walk
pts = [node_xy(i), (float(qx), float(qy))]
unvis[qy, qx] = False
prev = (qy, qx)
cur = (qy, qx)
end_node = None
while True:
nxt = None
for (ny, nx) in _neighbors8(cur[0], cur[1], h, w):
if (ny, nx) == prev:
continue
if not keep[ny, nx]:
continue
hit = int(node_id[ny, nx])
if hit >= 0 and hit != i:
end_node = hit
break
if hit == i:
continue
if unvis[ny, nx]:
nxt = (ny, nx)
break
if end_node is not None:
pts.append(node_xy(end_node))
break
if nxt is None:
break # dead end: treat as open chain tip
prev, cur = cur, nxt
unvis[cur[0], cur[1]] = False
pts.append((float(cur[1]), float(cur[0])))
edges[eid] = SkelEdge(id=eid, n1=i,
n2=end_node if end_node is not None else -2,
pts=np.array(pts, dtype=np.float64))
eid += 1
# --- leftover pixels: closed loops with no nodes ------------------------
while unvis.any():
ys, xs = np.nonzero(unvis)
sy, sx = int(ys[0]), int(xs[0])
pts = [(float(sx), float(sy))]
unvis[sy, sx] = False
prev = (sy, sx)
cur = (sy, sx)
closed = False
while True:
nxt = None
for (ny, nx) in _neighbors8(cur[0], cur[1], h, w):
if (ny, nx) == prev:
continue
if unvis[ny, nx]:
nxt = (ny, nx)
break
if (ny, nx) == (sy, sx) and len(pts) > 4:
closed = True
break
if closed or nxt is None:
break
prev, cur = cur, nxt
unvis[cur[0], cur[1]] = False
pts.append((float(cur[1]), float(cur[0])))
if len(pts) >= 6:
pts.append(pts[0])
edges[eid] = SkelEdge(id=eid, n1=-1, n2=-1,
pts=np.array(pts, dtype=np.float64), loop=True)
eid += 1
if not edges:
return None
g = SkelGraph(nodes=nodes, edges=edges, stroke_w=stroke_w, branch_dirs={})
# Dissolve pixelation-artifact junctions FIRST (so real tips are never
# mistaken for spurs), then prune true noise branches and collapse
# instability tangles; alternate until stable. Degree-2 junctions are
# always artifacts, never real branches.
for _ in range(4):
_dissolve_degree2(g)
_prune_spurs(g)
_collapse_junction_tangles(g)
_remove_redundant_bridges(g)
_demote_tip_junctions(g)
_snap_endpoints(g)
_dissolve_degree2(g)
_prune_spurs(g)
_collapse_junction_tangles(g)
_remove_redundant_bridges(g)
_fix_degenerate_loops(g)
_measure_branch_dirs(g)
return g
def _edge_length(e: SkelEdge) -> float:
d = np.diff(e.pts, axis=0)
return float(np.linalg.norm(d, axis=1).sum())
def _incident(g: SkelGraph, nid: int) -> List[int]:
return [eid for eid, e in g.edges.items() if e.n1 == nid or e.n2 == nid]
def _prune_spurs(g: SkelGraph):
"""Remove spur branches shorter than 1.2 * global stroke width.
A spur = edge with exactly one endpoint node at a real branch point
(degree >= 3 junction). Uses the GLOBAL stroke width: a branch off a
thick stroke is noise even when the branch itself is thin.
"""
w = g.stroke_w
doomed = []
for eid, e in g.edges.items():
if e.loop or e.n1 < 0 or e.n2 < 0:
continue
k1, k2 = g.nodes[e.n1].kind, g.nodes[e.n2].kind
if (k1 == "end") == (k2 == "end"):
continue # spur = exactly one endpoint
if _edge_length(e) < 1.2 * w:
doomed.append(eid)
for eid in doomed:
e = g.edges.pop(eid)
for nid in (e.n1, e.n2):
if nid >= 0 and g.nodes[nid].kind == "end" and not _incident(g, nid):
g.nodes.pop(nid, None)
def _snap_endpoints(g: SkelGraph):
"""Merge endpoint pairs closer than 0.5 * stroke width (closes tiny gaps)."""
ends = [n for n in g.nodes.values() if n.kind == "end"]
merged = set()
for a in ends:
if a.id in merged:
continue
for b in ends:
if b.id <= a.id or b.id in merged:
continue
d = math.hypot(a.x - b.x, a.y - b.y)
if d < 0.5 * g.stroke_w and d > 1e-9:
# rewire b -> a
mx, my = (a.x + b.x) / 2, (a.y + b.y) / 2
a.x, a.y = mx, my
for e in g.edges.values():
if e.n1 == b.id:
e.n1 = a.id
if e.n2 == b.id:
e.n2 = a.id
g.nodes.pop(b.id, None)
merged.add(b.id)
break
def _dissolve_degree2(g: SkelGraph):
"""Merge the two edges at EVERY degree-2 junction, any angle.
A degree-2 "junction" is never a real branch point — it is a staircase /
pixelation artifact on a continuous curve. Dissolving it keeps a curve as
ONE chain (one cubic) instead of fragments. Sharp corners are NOT lost:
the corner point stays in the merged point list and _split_corners
re-splits there during fitting with corner-preserving tangents.
"""
while True:
dissolved = False
for nid, nd in list(g.nodes.items()):
if nd.kind != "junction":
continue
inc = _incident(g, nid)
if len(inc) != 2:
continue
e1, e2 = g.edges[inc[0]], g.edges[inc[1]]
if e1.loop or e2.loop:
continue
o1 = e1.pts if e1.n1 == nid else e1.pts[::-1] # starts at nid
o2 = e2.pts if e2.n1 == nid else e2.pts[::-1] # starts at nid
merged_pts = np.vstack([o1[::-1], o2[1:]])
new_n1 = e1.n2 if e1.n1 == nid else e1.n1
new_n2 = e2.n2 if e2.n1 == nid else e2.n1
is_loop = (new_n1 == new_n2 and new_n1 >= 0)
g.edges[e1.id] = SkelEdge(
id=e1.id,
n1=-1 if is_loop else new_n1,
n2=-1 if is_loop else new_n2,
pts=merged_pts, loop=is_loop)
g.edges.pop(e2.id)
g.nodes.pop(nid, None)
dissolved = True
break # restart scan: ids changed
if not dissolved:
break
def _fix_degenerate_loops(g: SkelGraph):
"""Edges that start and end at (almost) the same point are loops.
Catches the rare walk artifact: a loop with a single false junction on
it becomes one edge whose tip nearly touches its own tail.
"""
w = g.stroke_w
for e in g.edges.values():
if e.loop or len(e.pts) < 8:
continue
d = float(np.linalg.norm(e.pts[0] - e.pts[-1]))
if d < max(2.0, 0.3 * w) and _edge_length(e) > 2.0 * w:
e.loop = True
e.n1 = e.n2 = -1
# close the point list for loop handling
if d > 1e-9:
e.pts = np.vstack([e.pts, e.pts[0]])
def _collapse_junction_tangles(g: SkelGraph):
"""Merge junctions linked by short junction-junction edges (< 0.8w).
Collapses medial-axis instability tangles — dense little webs of false
junctions where a curve bends — into single junction nodes. Internal tiny
edges are deleted; external edges are rewired to the centroid.
"""
w = g.stroke_w
jids = [nid for nid, n in g.nodes.items() if n.kind == "junction"]
if len(jids) < 2:
return
parent = {j: j for j in jids}
def find(a):
while parent[a] != a:
parent[a] = parent[parent[a]]
a = parent[a]
return a
for e in g.edges.values():
if e.loop or e.n1 < 0 or e.n2 < 0 or e.n1 == e.n2:
continue
if (g.nodes[e.n1].kind == "junction"
and g.nodes[e.n2].kind == "junction"
and _edge_length(e) < 0.8 * w):
ra, rb = find(e.n1), find(e.n2)
if ra != rb:
parent[ra] = rb
groups: Dict[int, List[int]] = {}
for j in jids:
groups.setdefault(find(j), []).append(j)
for members in groups.values():
if len(members) < 2:
continue
new_id = min(members)
nx = sum(g.nodes[m].x for m in members) / len(members)
ny = sum(g.nodes[m].y for m in members) / len(members)
g.nodes[new_id].x, g.nodes[new_id].y = nx, ny
member_set = set(members)
for eid, e in list(g.edges.items()):
if e.loop:
continue
in1 = e.n1 in member_set
in2 = e.n2 in member_set
if in1 and in2:
g.edges.pop(eid) # internal tangle edge
elif in1:
e.n1 = new_id
e.pts[0] = (nx, ny)
elif in2:
e.n2 = new_id
e.pts[-1] = (nx, ny)
for m in members:
if m != new_id:
g.nodes.pop(m, None)
# self-loops created by the merge
for eid, e in list(g.edges.items()):
if not e.loop and e.n1 == new_id and e.n2 == new_id:
if _edge_length(e) < 2.0 * w:
g.edges.pop(eid)
else:
e.loop, e.n1, e.n2 = True, -1, -1
# duplicate edges -> keep the longest
seen: Dict[Tuple[int, int], int] = {}
for eid, e in list(g.edges.items()):
if e.loop or e.n1 < 0 or e.n2 < 0:
continue
key = tuple(sorted((e.n1, e.n2)))
if key in seen:
prev = g.edges[seen[key]]
if _edge_length(e) > _edge_length(prev):
g.edges.pop(seen[key])
seen[key] = eid
else:
g.edges.pop(eid)
else:
seen[key] = eid
def _has_alt_path(g: SkelGraph, src: int, dst: int, exclude: int) -> bool:
"""BFS: is there a path from src to dst not using edge `exclude`?"""
seen = {src}
stack = [src]
while stack:
cur = stack.pop()
if cur == dst:
return True
for eid in _incident(g, cur):
if eid == exclude:
continue
e = g.edges[eid]
nxt = e.n2 if e.n1 == cur else e.n1
if nxt >= 0 and nxt not in seen:
seen.add(nxt)
stack.append(nxt)
return False
def _remove_redundant_bridges(g: SkelGraph):
"""Remove short inter-junction edges that are redundant (a cycle exists).
In dense junction tangles (strokes merging into blobs), the skeleton
grows short cross-edges between nearby junctions. These are artifacts:
removing them doesn't disconnect the graph (an alternative path exists),
but keeping them renders as ugly blobs. A legitimate short stroke (like
the bar of an 'H') has NO alternative path, so it's preserved.
"""
w = g.stroke_w
if w <= 0:
return
for _ in range(10):
removed = False
for eid, e in list(g.edges.items()):
if e.loop or e.n1 < 0 or e.n2 < 0 or e.n1 == e.n2:
continue
n1 = g.nodes.get(e.n1)
n2 = g.nodes.get(e.n2)
if n1 is None or n2 is None:
continue
if n1.kind != "junction" or n2.kind != "junction":
continue
if _edge_length(e) >= 1.0 * w:
continue
if _has_alt_path(g, e.n1, e.n2, exclude=eid):
g.edges.pop(eid, None)
removed = True
break
if not removed:
break
_dissolve_degree2(g)
def _demote_tip_junctions(g: SkelGraph):
"""Junction nodes left with <=1 incident edge are really just tips."""
for nid, nd in list(g.nodes.items()):
if nd.kind == "junction" and len(_incident(g, nid)) <= 1:
nd.kind = "end"
def _measure_branch_dirs(g: SkelGraph):
"""For each junction, measure each branch direction away from the junction
using points 0.8w..2.4w out — used for bump-free tangent constraints."""
w = g.stroke_w
for nid, nd in g.nodes.items():
if nd.kind != "junction":
continue
j = np.array([nd.x, nd.y])
for eid in _incident(g, nid):
e = g.edges[eid]
if e.loop:
continue
p = e.pts if e.n1 == nid else e.pts[::-1]
d = np.linalg.norm(p - j, axis=1)
sel = p[(d >= 0.8 * w) & (d <= 2.4 * w)]
if len(sel) < 2:
sel = p[1:min(len(p), 8)]
if len(sel) < 2:
continue
v = sel.mean(axis=0) - j
n = np.linalg.norm(v)
if n > 1e-9:
g.branch_dirs[(nid, eid)] = (float(v[0] / n), float(v[1] / n))
# ============================================================================
# STAGE 4 - Fitting & semantic labeling
# ============================================================================
def _norm(v: np.ndarray) -> np.ndarray:
n = float(np.linalg.norm(v))
if n < 1e-12:
return v
return v / n
def _dedupe(pts: np.ndarray, eps: float = 1e-6) -> np.ndarray:
if len(pts) < 2:
return pts
keep = [0]
for i in range(1, len(pts)):
if np.linalg.norm(pts[i] - pts[keep[-1]]) > eps:
keep.append(i)
return pts[np.array(keep)]
# ---- Schneider cubic Bezier fit (Graphics Gems, "An Algorithm for
# Automatically Fitting Digitized Curves") ------------------------------
def _chord_param(pts: np.ndarray) -> np.ndarray:
d = np.linalg.norm(np.diff(pts, axis=0), axis=1)
u = np.zeros(len(pts))
u[1:] = np.cumsum(d)
if u[-1] < 1e-12:
return u
return u / u[-1]
def _bez_pt(bez: np.ndarray, t: float) -> np.ndarray:
mt = 1.0 - t
return ((mt ** 3) * bez[0] + 3 * (mt ** 2) * t * bez[1]
+ 3 * mt * (t ** 2) * bez[2] + (t ** 3) * bez[3])
def _gen_bezier(pts: np.ndarray, u: np.ndarray,
tan1: np.ndarray, tan2: np.ndarray) -> np.ndarray:
"""Least-squares solve for alpha1/alpha2. tan1: along travel at start;
tan2: INTO the curve at end (Graphics Gems convention)."""
C = np.zeros((2, 2))
X = np.zeros(2)
p0, p3 = pts[0], pts[-1]
for i in range(len(pts)):
t = u[i]
mt = 1.0 - t
b1 = 3 * t * mt * mt
b2 = 3 * t * t * mt
a1 = tan1 * b1
a2 = tan2 * b2
C[0, 0] += float(a1 @ a1)
C[0, 1] += float(a1 @ a2)
C[1, 1] += float(a2 @ a2)
tmp = pts[i] - (p0 * (mt ** 3) + p0 * b1 + p3 * b2 + p3 * (t ** 3))
X[0] += float(a1 @ tmp)
X[1] += float(a2 @ tmp)
C[1, 0] = C[0, 1]
det = C[0, 0] * C[1, 1] - C[0, 1] * C[1, 0]
seg_len = float(np.linalg.norm(p3 - p0))
if abs(det) < 1e-12:
al1 = al2 = seg_len / 3.0
else:
al1 = (X[0] * C[1, 1] - X[1] * C[0, 1]) / det
al2 = (C[0, 0] * X[1] - C[1, 0] * X[0]) / det
eps = 1e-6 * max(seg_len, 1.0)
if al1 < eps or al2 < eps: # Wu/Barsky heuristic fallback
al1 = al2 = seg_len / 3.0
return np.array([p0, p0 + tan1 * al1, p3 + tan2 * al2, p3])
def _max_err(pts: np.ndarray, bez: np.ndarray,
u: np.ndarray) -> Tuple[float, int]:
maxd = 0.0
idx = len(pts) // 2
for i in range(len(pts)):
d = float(np.linalg.norm(_bez_pt(bez, u[i]) - pts[i]))
if d > maxd:
maxd, idx = d, i
return maxd, idx
def _newton(bez: np.ndarray, p: np.ndarray, t: float) -> float:
q = _bez_pt(bez, t)
mt = 1.0 - t
d1 = (3 * mt * mt * (bez[1] - bez[0]) + 6 * mt * t * (bez[2] - bez[1])
+ 3 * t * t * (bez[3] - bez[2]))
d2 = 6 * mt * (bez[2] - 2 * bez[1] + bez[0]) + 6 * t * (bez[3] - 2 * bez[2] + bez[1])
num = float((q - p) @ d1)
den = float(d1 @ d1 + (q - p) @ d2)
if abs(den) < 1e-12:
return t
return t - num / den
def _reparam(bez: np.ndarray, pts: np.ndarray, u: np.ndarray) -> np.ndarray:
out = np.empty_like(u)
for i in range(len(pts)):
out[i] = _newton(bez, pts[i], u[i])
return np.clip(out, 0.0, 1.0)
def _fit_cubic(pts: np.ndarray, tan1: np.ndarray, tan2: np.ndarray,
tol: float, depth: int = 0) -> List[np.ndarray]:
u = _chord_param(pts)
bez = _gen_bezier(pts, u, tan1, tan2)
err, idx = _max_err(pts, bez, u)
if err >= tol:
# Newton-Raphson reparameterization BEFORE splitting: chord-length
# is only an initial guess; refining it often avoids a split.
# (Graphics Gems tries this when err < tol^2; we try it always —
# it can only improve the fit and costs 4 iterations.)
for _ in range(4):
u = _reparam(bez, pts, u)
bez = _gen_bezier(pts, u, tan1, tan2)
err, idx = _max_err(pts, bez, u)
if err < tol:
break
if err < tol:
return [bez]
if depth >= 12 or len(pts) <= 4:
return [bez]
idx = max(2, min(len(pts) - 3, idx))
tc = _norm(pts[idx - 1] - pts[idx + 1]) # into-curve tangent at split
return (_fit_cubic(pts[:idx + 1], tan1, tc, tol, depth + 1)
+ _fit_cubic(pts[idx:], -tc, tan2, tol, depth + 1))
def schneider_fit(pts: np.ndarray, tan1: np.ndarray, tan2: np.ndarray,
tol: float) -> List[np.ndarray]:
"""Public wrapper: fit digitized points with cubic Beziers within tol."""
pts = _dedupe(np.asarray(pts, dtype=np.float64))
if len(pts) < 2:
return []
if len(pts) == 2:
p0, p3 = pts
d = p3 - p0
return [np.array([p0, p0 + d / 3, p0 + 2 * d / 3, p3])]
return _fit_cubic(pts, _norm(tan1), _norm(tan2), tol)
def try_single_cubic(pts: np.ndarray, tan1: np.ndarray, tan2: np.ndarray,
tol: float) -> Optional[List[np.ndarray]]:
"""Try to fit ALL points with ONE cubic within tol.
Uses Newton-Raphson reparameterization to get the best single cubic.
Returns [bezier] on success, None if a single cubic can't hold tol.
"""
pts = _dedupe(np.asarray(pts, dtype=np.float64))
if len(pts) < 3:
return None
tan1, tan2 = _norm(tan1), _norm(tan2)
u = _chord_param(pts)
bez = _gen_bezier(pts, u, tan1, tan2)
for _ in range(6):
u = _reparam(bez, pts, u)
bez = _gen_bezier(pts, u, tan1, tan2)
err, _ = _max_err(pts, bez, u)
return [bez] if err < tol else None
# ---- deviation measurement ---------------------------------------------------
def _polyline_dev(pts: np.ndarray, poly: np.ndarray) -> Tuple[float, float]:
"""max/mean distance from pts to a polyline (Nx2). Vectorized."""
if len(poly) < 2 or len(pts) == 0:
return 0.0, 0.0
a = poly[:-1][None, :, :] # 1 x M x 2
b = poly[1:][None, :, :]
p = pts[:, None, :] # N x 1 x 2
ab = b - a
denom = np.sum(ab ** 2, axis=2, keepdims=True) + 1e-12
t = np.clip(np.sum((p - a) * ab, axis=2, keepdims=True) / denom, 0, 1)
proj = a + t * ab
d = np.linalg.norm(p - proj, axis=2)
dmin = d.min(axis=1)
return float(dmin.max()), float(dmin.mean())
def _sample_beziers(beziers: List[np.ndarray], per: int = 24) -> np.ndarray:
out = []
for bez in beziers:
ts = np.linspace(0, 1, per)
for t in ts:
out.append(_bez_pt(bez, t))
return np.array(out)
# ---- chain subdivision: corners / straight / arc ------------------------------
def _split_corners(pts: np.ndarray, tan1: np.ndarray, tan2: np.ndarray,
stroke_w: float) -> List[Tuple[int, int, np.ndarray, np.ndarray]]:
"""Split a chain at sharp corners (>55 deg turn). Returns
[(a, b, tan1, tan2), ...] index ranges with corner-preserving tangents,
so callers can slice matching raw (unsmoothed) points for dev stats."""
k = max(3, int(round(stroke_w / 2)))
corners = []
for i in range(k, len(pts) - k):
v1 = pts[i] - pts[i - k]
v2 = pts[i + k] - pts[i]
n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
if n1 < 1e-9 or n2 < 1e-9:
continue
turn = math.degrees(math.acos(np.clip(float(v1 @ v2) / (n1 * n2), -1, 1)))
if turn > 55:
corners.append(i)
if not corners:
return [(0, len(pts) - 1, tan1, tan2)]
# keep corners separated by at least k
kept = [corners[0]]
for c in corners[1:]:
if c - kept[-1] >= k:
kept.append(c)
out = []
bounds = [0] + kept + [len(pts) - 1]
for s in range(len(bounds) - 1):
a, b = bounds[s], bounds[s + 1]
t1 = tan1 if s == 0 else _norm(pts[a + 1] - pts[a - 1])
t2 = tan2 if s == len(bounds) - 2 else _norm(pts[b - 1] - pts[b + 1])
out.append((a, b, t1, t2))
return out
def _is_straight(pts: np.ndarray, tol: float) -> bool:
if len(pts) < 3:
return True
chord = pts[-1] - pts[0]
L = float(np.linalg.norm(chord))
if L < 1e-9:
return False
n = np.array([-chord[1], chord[0]]) / L
return bool(np.abs((pts - pts[0]) @ n).max() < tol * 0.75)
def _try_arc(pts: np.ndarray, tol: float) -> Optional[dict]:
"""Kasa circle fit on an open chain -> SVG arc params."""
if len(pts) < 6:
return None
try:
cx, cy, r = _kasa_circle(pts)
except Exception:
return None
if r < 2.0 or not np.isfinite(r):
return None
dev = np.abs(np.linalg.norm(pts - [cx, cy], axis=1) - r)
if dev.max() > tol:
return None
angs = np.arctan2(pts[:, 1] - cy, pts[:, 0] - cx) # y-down: + = clockwise
diffs = np.diff(angs)
diffs = (diffs + math.pi) % (2 * math.pi) - math.pi
span = float(np.degrees(diffs.sum()))
if abs(span) < 5:
return None
return {"cx": cx, "cy": cy, "r": r,
"large_arc": 1 if abs(span) > 180 else 0,
"sweep": 1 if span > 0 else 0,
"x1": float(pts[0, 0]), "y1": float(pts[0, 1]),
"x2": float(pts[-1, 0]), "y2": float(pts[-1, 1])}
def _break_loop(pts: np.ndarray) -> np.ndarray:
"""Open a closed loop at its sharpest-curvature point."""
n = len(pts)
k = max(2, n // 36)
best, bi = -1.0, 0
for i in range(n):
v1 = pts[i] - pts[(i - k) % n]
v2 = pts[(i + k) % n] - pts[i]
n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
if n1 < 1e-9 or n2 < 1e-9:
continue
turn = math.degrees(math.acos(np.clip(float(v1 @ v2) / (n1 * n2), -1, 1)))
if turn > best:
best, bi = turn, i
return np.roll(pts, -bi, axis=0)
def _reduce_anchors(beziers: List[np.ndarray], pts: np.ndarray,
tol: float) -> List[np.ndarray]:
"""Greedy merge of consecutive cubics that still fit within tol*1.5."""
if len(beziers) < 2:
return beziers
out = [beziers[0]]
# sample points per bezier for refit attempts
for bez in beziers[1:]:
cand = _sample_beziers([out[-1], bez], per=16)
t1 = _norm(cand[1] - cand[0])
t2 = _norm(cand[-2] - cand[-1])
merged = _fit_cubic(cand, t1, t2, tol * 1.5, depth=0)
if len(merged) == 1:
err, _ = _max_err(cand, merged[0], _chord_param(cand))
if err < tol * 1.5:
out[-1] = merged[0]
continue
out.append(bez)
return out
# ---- per-edge fitting ----------------------------------------------------------
def _estimate_tangent(pts: np.ndarray, at_start: bool) -> np.ndarray:
k = min(4, len(pts) - 1)
if at_start:
return _norm(pts[k] - pts[0])
return _norm(pts[-k - 1] - pts[-1]) # into-curve at end
def _fit_edge(e: SkelEdge, g: SkelGraph, cfg: VectorizerConfig,
scale: int, base: str, part_idx: int) -> List[dict]:
fp = cfg.fit_params()
tol_w = fp.tol * scale # working (upscaled) pixels
pts = _dedupe(e.pts.astype(np.float64))
if len(pts) < 2:
return []
closed = False
# --- closed loops: circle -> ellipse -> break open -----------------------
if e.loop:
ok, (cx, cy, r), ratio = ransac_circle(pts, tol=1.2, iters=80,
min_inlier_ratio=0.90)
if ok:
return [_circle_el(cx / scale, cy / scale, r / scale, base,
part_idx, g.stroke_w / scale)]
if len(pts) >= 12:
try:
(ecx, ecy), (rw, rh), ang = cv2.fitEllipse(pts.astype(np.float32))
rx, ry = rw / 2, rh / 2
if min(rx, ry) > 2:
th = np.linspace(0, 2 * math.pi, 72)
ex = ecx + rx * np.cos(th) * math.cos(math.radians(ang)) \
- ry * np.sin(th) * math.sin(math.radians(ang))
ey = ecy + rx * np.cos(th) * math.sin(math.radians(ang)) \
+ ry * np.sin(th) * math.cos(math.radians(ang))
samp = np.c_[ex, ey]
md, _ = _polyline_dev(pts, samp)
if md < fp.tol * 2.5:
return [_ellipse_el(ecx / scale, ecy / scale,
rx / scale, ry / scale, ang,
base, part_idx, g.stroke_w / scale)]
except Exception:
pass
pts = _break_loop(pts)
closed = True
raw = pts.copy()
# --- light pre-smoothing (Low = 1 pass) ----------------------------------
if HAS_SCIPY and fp.smooth_iters > 0 and len(pts) > 4:
for _ in range(fp.smooth_iters):
pts[:, 0] = gaussian_filter1d(pts[:, 0], SMOOTH_SIGMA,
mode="nearest")
pts[:, 1] = gaussian_filter1d(pts[:, 1], SMOOTH_SIGMA,
mode="nearest")
# --- endpoint tangents: junction branch dirs = no bumps -------------------
if not e.loop and e.n1 >= 0 and g.nodes[e.n1].kind == "junction" \
and cfg.detect_junctions and (e.n1, e.id) in g.branch_dirs:
tan1 = np.array(g.branch_dirs[(e.n1, e.id)])
else:
tan1 = _estimate_tangent(pts, True)
if not e.loop and e.n2 >= 0 and g.nodes[e.n2].kind == "junction" \
and cfg.detect_junctions and (e.n2, e.id) in g.branch_dirs:
tan2 = -np.array(g.branch_dirs[(e.n2, e.id)]) # into-curve
else:
tan2 = _estimate_tangent(pts, False)
chains = (_split_corners(pts, tan1, tan2, g.stroke_w)
if cfg.detect_corners else [(0, len(pts) - 1, tan1, tan2)])
els: List[dict] = []
sw = g.stroke_w / scale
for a, b, t1, t2 in chains:
c = _dedupe(pts[a:b + 1])
rc = _dedupe(raw[a:b + 1]) # matching unsmoothed points for dev stats
if len(c) < 2:
continue
if _is_straight(c, tol_w):
els.append(_line_el(c, rc, scale, base, part_idx, sw, closed and len(chains) == 1))
continue
arc = _try_arc(c, tol_w) if cfg.detect_circles else None
if arc is not None:
els.append(_arc_el(arc, rc, scale, base, part_idx, sw))
continue
# Prefer ONE cubic for smooth chains (not 20 fragments): try a
# single cubic first with a relaxed tolerance; fall back to the
# accurate recursive fit when the shape genuinely needs it.
single = try_single_cubic(c, t1, t2, tol_w * SINGLE_CUBIC_TOL_FACTOR)
if single is not None:
beziers = single
else:
beziers = schneider_fit(c, t1, t2, tol_w)
if fp.reduce:
beziers = _reduce_anchors(beziers, c, tol_w)
els.append(_path_el(beziers, rc, scale, base, part_idx, sw,
closed and len(chains) == 1))
return els
# ---- element constructors (original-image coords) ------------------------------
def _dev_stats(raw_up: np.ndarray, sampled_up: np.ndarray, scale: int):
md, mn = _polyline_dev(raw_up, sampled_up)
return md / scale, mn / scale
def _circle_el(cx, cy, r, base, part_idx, sw):
return {"kind": "circle", "cx": cx, "cy": cy, "r": r, "base": base,
"part": part_idx, "anchors": 4, "max_dev": 0.0, "mean_dev": 0.0,
"stroke_w": sw, "name": ""}
def _ellipse_el(cx, cy, rx, ry, ang, base, part_idx, sw):
return {"kind": "ellipse", "cx": cx, "cy": cy, "rx": rx, "ry": ry,
"angle": ang, "base": base, "part": part_idx, "anchors": 4,
"max_dev": 0.0, "mean_dev": 0.0, "stroke_w": sw, "name": ""}
def _line_el(c_up, raw_up, scale, base, part_idx, sw, closed):
p0, p1 = c_up[0] / scale, c_up[-1] / scale
md, mn = _dev_stats(raw_up, c_up, scale)
return {"kind": "line", "p0": p0, "p1": p1, "base": base, "part": part_idx,
"anchors": 2, "max_dev": md, "mean_dev": mn, "stroke_w": sw,
"name": "", "closed": closed}
def _arc_el(arc, raw_up, scale, base, part_idx, sw):
cx, cy, r = arc["cx"] / scale, arc["cy"] / scale, arc["r"] / scale
th = np.linspace(0, 1, 33)
# sample along the arc for dev stats
a0 = math.atan2(arc["y1"] - arc["cy"], arc["x1"] - arc["cx"])
span = math.radians(360)
# reconstruct signed span from flags (approx for stats only)
a1 = math.atan2(arc["y2"] - arc["cy"], arc["x2"] - arc["cx"])
d = (a1 - a0)
while d <= -math.pi:
d += 2 * math.pi
while d > math.pi:
d -= 2 * math.pi
if arc["sweep"] == 0 and d > 0:
d -= 2 * math.pi
if arc["sweep"] == 1 and d < 0:
d += 2 * math.pi
angs = a0 + d * th
samp = np.c_[arc["cx"] + arc["r"] * np.cos(angs),
arc["cy"] + arc["r"] * np.sin(angs)]
md, mn = _dev_stats(raw_up, samp, scale)
return {"kind": "arc", "cx": cx, "cy": cy, "r": r,
"large_arc": arc["large_arc"], "sweep": arc["sweep"],
"x1": arc["x1"] / scale, "y1": arc["y1"] / scale,
"x2": arc["x2"] / scale, "y2": arc["y2"] / scale,
"base": base, "part": part_idx, "anchors": 3,
"max_dev": md, "mean_dev": mn, "stroke_w": sw, "name": ""}
def _path_el(beziers_up, raw_up, scale, base, part_idx, sw, closed):
beziers = [b / scale for b in beziers_up]
samp = _sample_beziers(beziers_up)
md, mn = _dev_stats(raw_up, samp, scale)
return {"kind": "path", "beziers": beziers, "base": base, "part": part_idx,
"anchors": 3 * len(beziers) + 1, "max_dev": md, "mean_dev": mn,
"stroke_w": sw, "name": "", "closed": closed}
def _element_from_primitive(prim: dict, scale: int, base: str, part_idx: int) -> dict:
sw = 0.0
if prim["kind"] == "circle":
sw = 2.0 * prim["r"] / scale * 0.12 # placeholder, refined below
el = _circle_el(prim["cx"] / scale, prim["cy"] / scale,
prim["r"] / scale, base, part_idx, sw)
el["prim_inliers"] = prim.get("inliers", 1.0)
return el
# rect: inset quad in original coords
q = prim["pts"] / scale
el = {"kind": "rect", "quad": q, "base": base, "part": part_idx,
"anchors": 4, "max_dev": 0.0, "mean_dev": 0.0,
"stroke_w": prim.get("stroke_w", 0.0) / scale, "name": ""}
return el
# ============================================================================
# CLIP semantic labeling (optional, offline after one-time weight download)
# ============================================================================
_clip_cache: dict = {}
def clip_label_parts(part_crops: List[Image.Image],
prompts: List[str]) -> Optional[List[str]]:
"""Label each part crop with CLIP ViT-B/32. Returns None on any failure
(missing torch/models -> caller falls back to generic names)."""
if not part_crops or not prompts:
return None
try:
import torch
try:
import open_clip
if "open_clip" not in _clip_cache:
model, _, preprocess = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="openai")
tok = open_clip.get_tokenizer("ViT-B-32")
_clip_cache["open_clip"] = (model, preprocess, tok, "oc")
model, preprocess, tok, _ = _clip_cache["open_clip"]
device = "cpu"
model.to(device).eval()
text = tok([f"an icon of {p}" for p in prompts]).to(device)
with torch.no_grad():
tf = model.encode_text(text)
tf = tf / tf.norm(dim=-1, keepdim=True)
labels = []
for crop in part_crops:
img = preprocess(crop).unsqueeze(0).to(device)
imf = model.encode_image(img)
imf = imf / imf.norm(dim=-1, keepdim=True)
sim = (100.0 * imf @ tf.T).softmax(dim=-1)
labels.append(prompts[int(sim.argmax())])
return labels
except ImportError:
pass
import clip as oclip # OpenAI's clip package
if "oa_clip" not in _clip_cache:
model, preprocess = oclip.load("ViT-B/32", device="cpu")
_clip_cache["oa_clip"] = (model, preprocess)
model, preprocess = _clip_cache["oa_clip"]
model.eval()
text = oclip.tokenize([f"an icon of {p}" for p in prompts])
with torch.no_grad():
tf = model.encode_text(text)
tf = tf / tf.norm(dim=-1, keepdim=True)
labels = []
for crop in part_crops:
img = preprocess(crop).unsqueeze(0)
imf = model.encode_image(img)
imf = imf / imf.norm(dim=-1, keepdim=True)
sim = (100.0 * imf @ tf.T).softmax(dim=-1)
labels.append(prompts[int(sim.argmax())])
return labels
except Exception:
return None
def _label_parts(gray: np.ndarray, parts: List[Part], scale: int,
cfg: VectorizerConfig) -> List[str]:
"""Crop each part from the original and label with CLIP (fallback: '')."""
if not cfg.use_clip or not parts:
return [""] * len(parts)
H, W = gray.shape[:2]
rgb = Image.fromarray(np.stack([gray] * 3, axis=-1))
crops = []
for p in parts:
x, y, w, h = p.bbox
x0, y0 = max(0, x // scale - 4), max(0, y // scale - 4)
x1, y1 = min(W, (x + w) // scale + 4), min(H, (y + h) // scale + 4)
crop = rgb.crop((x0, y0, max(x1, x0 + 8), max(y1, y0 + 8)))
side = max(crop.size)
sq = Image.new("RGB", (side, side), (255, 255, 255))
sq.paste(crop, ((side - crop.size[0]) // 2, (side - crop.size[1]) // 2))
crops.append(sq.resize((224, 224), Image.BICUBIC))
labels = clip_label_parts(crops, cfg.clip_prompts)
if not labels:
return [""] * len(parts)
return [l.strip().lower().replace(" ", "-") for l in labels]
def _assign_names(elements: List[dict]):
"""data-name assignment: circle -> {base}-rim, else {base}; dedupe."""
used: Dict[str, int] = {}
for el in elements:
base = el.get("base", "")
if base:
key = f"{base}-rim" if el["kind"] == "circle" else base
else:
key = {"circle": "circle", "ellipse": "ellipse",
"rect": "rect"}.get(el["kind"], "path")
n = used.get(key, 0) + 1
used[key] = n
el["name"] = key if n == 1 else f"{key}-{n}"
# ============================================================================
# SVG writer
# ============================================================================
def _f(v: float) -> str:
s = f"{v:.1f}"
return s
def _path_d(beziers: List[np.ndarray], closed: bool) -> str:
p0 = beziers[0][0]
d = f"M{_f(p0[0])} {_f(p0[1])}"
for b in beziers:
d += (f"C{_f(b[1][0])} {_f(b[1][1])} {_f(b[2][0])} {_f(b[2][1])} "
f"{_f(b[3][0])} {_f(b[3][1])}")
if closed:
d += "Z"
return d
def _element_svg(el: dict, indent: str) -> str:
n = el["name"]
k = el["kind"]
if k == "circle":
return (f'{indent}<circle data-name="{n}" cx="{_f(el["cx"])}" '
f'cy="{_f(el["cy"])}" r="{_f(el["r"])}"/>')
if k == "ellipse":
t = (f' transform="rotate({_f(el["angle"])} {_f(el["cx"])} {_f(el["cy"])})"'
if abs(el["angle"]) > 0.5 else "")
return (f'{indent}<ellipse data-name="{n}" cx="{_f(el["cx"])}" '
f'cy="{_f(el["cy"])}" rx="{_f(el["rx"])}" ry="{_f(el["ry"])}"{t}/>')
if k == "rect":
q = el["quad"]
xs, ys = q[:, 0], q[:, 1]
# axis-aligned (order-agnostic): every corner's x is ~x0/x1 and y ~y0/y1
x0, x1 = float(xs.min()), float(xs.max())
y0, y1 = float(ys.min()), float(ys.max())
aa = True
for x, y in q:
if min(abs(x - x0), abs(x - x1)) > 1.0:
aa = False
break
if min(abs(y - y0), abs(y - y1)) > 1.0:
aa = False
break
if aa:
return (f'{indent}<rect data-name="{n}" x="{_f(x0)}" y="{_f(y0)}" '
f'width="{_f(x1 - x0)}" height="{_f(y1 - y0)}"/>')
d = f"M{_f(xs[0])} {_f(ys[0])}"
for i in range(1, 4):
d += f"L{_f(xs[i])} {_f(ys[i])}"
return f'{indent}<path data-name="{n}" d="{d}Z"/>'
if k == "line":
p0, p1 = el["p0"], el["p1"]
d = f"M{_f(p0[0])} {_f(p0[1])}L{_f(p1[0])} {_f(p1[1])}"
if el.get("closed"):
d += "Z"
return f'{indent}<path data-name="{n}" d="{d}"/>'
if k == "arc":
d = (f'M{_f(el["x1"])} {_f(el["y1"])}A{_f(el["r"])} {_f(el["r"])} 0 '
f'{el["large_arc"]},{el["sweep"]} {_f(el["x2"])} {_f(el["y2"])}')
return f'{indent}<path data-name="{n}" d="{d}"/>'
# path
d = _path_d(el["beziers"], el.get("closed", False))
return f'{indent}<path data-name="{n}" d="{d}"/>'
def build_svg(elements: List[dict], W: int, H: int, stroke_w: float,
title: str = "icon") -> str:
lines = ['<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" '
f'width="{W}" height="{H}">',
f' <g fill="none" stroke="black" stroke-width="{_f(stroke_w)}">']
# group elements sharing a CLIP base label (like the eyepiece example)
groups: Dict[str, List[dict]] = {}
order: List[str] = []
for el in elements:
base = el.get("base", "")
key = base if base else f"\x00{el['name']}"
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(el)
for key in order:
els = groups[key]
if key.startswith("\x00") or len(els) == 1:
for el in els:
lines.append(_element_svg(el, " "))
else:
lines.append(f' <g data-name="{key}">')
for el in els:
lines.append(_element_svg(el, " "))
lines.append(" </g>")
lines += [" </g>", "</svg>"]
return "\n".join(lines) + "\n"
# ============================================================================
# Main entry points
# ============================================================================
def convert_image_to_svg(image_path: str, cfg: VectorizerConfig,
cb: ProgressCb = None) -> dict:
"""Full 4-stage pipeline. Returns dict(svg, elements, stats, W, H, ...)."""
t0 = time.time()
gray = load_grayscale(image_path)
H, W = gray.shape[:2]
_progress(cb, 0.02, "Stage 0/4 - preprocessing")
ink, scale = preprocess(gray, cfg)
# Dual threshold: segment parts at a LOWER threshold so antialiased gray
# halos don't merge separate strokes into one blob (topology), while
# stroke width is measured from the main threshold mask (geometry).
seg_th = max(50, int(cfg.threshold) - 30)
if seg_th < int(cfg.threshold):
ink_seg, _ = preprocess(gray, replace(cfg, threshold=seg_th))
else:
ink_seg = ink
parts = segment_parts(ink_seg, cfg, cb)
# Distance transform of the main-threshold mask: used to measure the
# true stroke width under each (thin) segmentation part.
try:
from scipy.ndimage import distance_transform_edt as _edt
_dt_hi = _edt(ink)
except Exception:
_dt_hi = None
_progress(cb, 0.19, "Stage 4/4 - CLIP labeling")
labels = _label_parts(gray, parts, scale, cfg)
elements: List[dict] = []
sw_list: List[float] = []
n_parts = max(1, len(parts))
for idx, part in enumerate(parts):
_progress(cb, 0.20 + 0.70 * idx / n_parts,
f"Stages 2-3/4 - part {idx + 1}/{len(parts)}")
base = labels[idx] if idx < len(labels) else ""
prim = detect_primitive(part.mask, cfg)
if prim is not None:
el = _element_from_primitive(prim, scale, base, idx)
sw_list.append(el["stroke_w"] if el["stroke_w"] > 0
else 3.0 / scale)
# refine circle stroke width from local distance transform
elements.append(el)
continue
g = skeleton_graph(part.mask)
if g is None:
continue
sw_list.append(g.stroke_w / scale)
for e in g.edges.values():
try:
elements.extend(_fit_edge(e, g, cfg, scale, base, idx))
except Exception:
continue
_progress(cb, 0.94, "Writing SVG")
_assign_names(elements)
# Robust global stroke width: for each (thin) segmentation part, measure
# the full stroke width from the main-threshold distance transform, then
# take the 75th percentile (tiny detail parts don't drag it down).
# Robust to merged blobs and thin-mask underestimation.
_widths = []
if _dt_hi is not None:
for part in parts:
try:
d = _dt_hi[part.mask]
if d.size > 100:
_widths.append(2.0 * float(np.percentile(d, 90)) / scale)
except Exception:
pass
if _widths:
stroke_w = float(np.percentile(_widths, 75))
else:
stroke_w = float(np.median(sw_list)) if sw_list else 2.0
# fix primitive-circle stroke widths with the global median
for el in elements:
if el["kind"] == "circle" and el.get("prim_inliers") is not None:
el["stroke_w"] = stroke_w
svg = build_svg(elements, W, H, stroke_w)
max_dev = max([e["max_dev"] for e in elements], default=0.0)
mean_dev = (sum(e["mean_dev"] for e in elements) / len(elements)
if elements else 0.0)
stats = {
"paths": len(elements),
"anchors": sum(e["anchors"] for e in elements),
"stroke_w": round(stroke_w, 1),
"max_dev": round(max_dev, 2),
"mean_dev": round(mean_dev, 2),
"parts": len(parts),
"elapsed": round(time.time() - t0, 1),
}
_progress(cb, 1.0, f"Done in {stats['elapsed']}s")
return {"svg": svg, "elements": elements, "stats": stats,
"W": W, "H": H, "scale": scale}
def convert_file(input_path: str, output_path: Optional[str] = None,
cfg: Optional[VectorizerConfig] = None,
cb: ProgressCb = None) -> Tuple[str, dict]:
"""Convert one image; default output: <input_dir>/output_svgs/<name>.svg."""
cfg = cfg or VectorizerConfig()
if output_path is None:
d = os.path.join(os.path.dirname(os.path.abspath(input_path)),
"output_svgs")
os.makedirs(d, exist_ok=True)
stem = os.path.splitext(os.path.basename(input_path))[0]
output_path = os.path.join(d, stem + ".svg")
res = convert_image_to_svg(input_path, cfg, cb)
with open(output_path, "w", encoding="utf-8") as f:
f.write(res["svg"])
return output_path, res
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="ICON VECTORIZER - ACCURATE CENTERLINE")
ap.add_argument("input", help="input JPG/PNG icon")
ap.add_argument("-o", "--output", default=None)
ap.add_argument("--threshold", type=int, default=110)
ap.add_argument("--upscale", type=int, default=3, choices=[1, 2, 3, 4])
ap.add_argument("--smoothing", default="Low", choices=["Low", "Medium", "High"])
ap.add_argument("--tolerance", type=float, default=0.6)
ap.add_argument("--no-corners", action="store_true")
ap.add_argument("--no-junctions", action="store_true")
ap.add_argument("--no-circles", action="store_true")
ap.add_argument("--reduce-anchors", action="store_true")
ap.add_argument("--no-clip", action="store_true")
ap.add_argument("--sam", action="store_true")
a = ap.parse_args()
cfg = VectorizerConfig(
threshold=a.threshold, upscale=a.upscale, smoothing=a.smoothing,
tolerance=a.tolerance, detect_corners=not a.no_corners,
detect_junctions=not a.no_junctions, detect_circles=not a.no_circles,
reduce_anchors=a.reduce_anchors, use_clip=not a.no_clip,
use_sam=a.sam)
out, res = convert_file(a.input, a.output, cfg,
cb=lambda f, s: print(f"\r{f*100:5.1f}% {s}", end=""))
print(f"\n-> {out}")
print(res["stats"])
for el in res["elements"]:
print(f' {el["kind"]:7s} {el["name"]:24s} anchors={el["anchors"]} '
f'maxdev={el["max_dev"]:.2f}')
"""
ICON VECTORIZER - ACCURATE CENTERLINE
====================================
vectorizer_core.py — slow-but-accurate 4-stage centerline vectorization pipeline.
Converts black-outline icons (JPG/PNG) into clean centerline SVG with semantic
data-name labels (e.g. eyepiece-rim, optical-tube, curved-arm).
Pipeline
--------
Stage 0 - Preprocessing : 3x/4x INTER_CUBIC upscale, median+gaussian denoise,
user threshold, morphological opening.
Stage 1 - Segmentation : connected ink components (+ contour-hierarchy split),
optional MobileSAM refinement.
Stage 2 - Primitives : circularity test -> perfect <circle> (minEnclosingCircle
+ RANSAC refine); convex quad -> inset <rect>.
Stage 3 - Centerline : skimage skeletonize (Lee, NOT Zhang-Suen) + EDT distance,
skeleton graph (endpoints/junctions/loops), spur prune,
endpoint snap, junction branch-direction measurement.
Stage 4 - Fitting : RANSAC circle/ellipse on closed loops, Kasa arc fit,
Schneider cubic Bezier fit (Graphics Gems, tol=0.6px),
optional CLIP semantic labeling, clean SVG output.
Everything geometric is deterministic and offline. Only the OPTIONAL semantic
labeling (CLIP) and segmentation refinement (MobileSAM) need torch + model
downloads; the tracer works fully without them.
Author: built for Twins Design Studio / allfa — accuracy >> speed.
"""
from __future__ import annotations
import math
import os
import time
import urllib.request
from dataclasses import dataclass, field, replace
from typing import Callable, Dict, List, Optional, Tuple
import numpy as np
import cv2
from PIL import Image
try:
from skimage.morphology import skeletonize
HAS_SKIMAGE = True
except ImportError: # pragma: no cover
HAS_SKIMAGE = False
try:
from scipy.ndimage import gaussian_filter1d, distance_transform_edt
HAS_SCIPY = True
except ImportError: # pragma: no cover
HAS_SCIPY = False
# ----------------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------------
SMOOTH_PRESETS = {"Low": 1, "Medium": 4, "High": 10}
class FitParams:
"""Fitting parameters (accurate mode).
tol is in ORIGINAL image pixels (user-facing unit); the pipeline works
at `scale`x, so the working tolerance is tol * scale.
"""
tol: float = 0.6 # max deviation in px (original image size)
smooth_iters: int = 1 # light pre-smoothing passes (Low)
reduce: bool = False # merge near-collinear cubics (OFF for accuracy)
# Prefer a single cubic over a fragmented fit when the chain is "almost"
# cubic: accept a single cubic if its max deviation is within this factor
# of tol. Keeps smooth curves as ONE editable cubic instead of dozens of
# fragments; genuine S-curves still split (their single-cubic error is far
# larger). The actual deviation is always reported per path.
SINGLE_CUBIC_TOL_FACTOR = 4.0
# Pre-fit smoothing sigma (upscaled px). Removes rasterization staircase
# noise (high frequency) without distorting real curve shape (which bends
# over tens of px). Applied `smooth_iters` times per SMOOTH_PRESETS.
SMOOTH_SIGMA = 1.5
class VectorizerConfig:
threshold: int = 110
upscale: int = 3 # Quality menu: 1x/2x/3x/4x
smoothing: str = "Low" # Low / Medium / High
tolerance: float = 0.6 # px, upscaled space
detect_corners: bool = True
detect_junctions: bool = True
detect_circles: bool = True # circles AND arcs
reduce_anchors: bool = False
use_sam: bool = False # MobileSAM refinement (slow, optional)
use_clip: bool = True # CLIP semantic labels (optional)