#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
图片转SVG工具 - 基于 potrace 的高保真描边
目标:准确还原图片中的黑色部分
"""
import argparse
import os
import shutil
import subprocess
import tempfile
import cv2
import numpy as np
class ImageToSVG:
"""图片转SVG转换器 - 使用 potrace 进行高精度矢量化"""
def __init__(
self,
threshold=None,
turdsize=0,
opttolerance=0,
unit=1,
optimize_curves=False,
circle_fit=0.0,
):
"""
初始化转换器
Args:
threshold: 固定阈值(0-255),None 表示使用 Otsu 自动阈值
turdsize: 抑制噪点的面积阈值(potrace -t)
opttolerance: 曲线优化容差(potrace -O)
unit: 坐标量化单位(potrace -u,1 表示不量化)
optimize_curves: 是否开启曲线优化(文件更小但可能失真)
circle_fit: 圆拟合误差阈值(相对半径),>0启用圆替代
"""
self.threshold = threshold
self.turdsize = turdsize
self.opttolerance = opttolerance
self.unit = unit
self.optimize_curves = optimize_curves
self.circle_fit = circle_fit
@staticmethod
def _iter_input_files(input_dir):
exts = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
for name in sorted(os.listdir(input_dir)):
path = os.path.join(input_dir, name)
if not os.path.isfile(path):
continue
if os.path.splitext(name)[1].lower() in exts:
yield path
@staticmethod
def _ensure_potrace():
"""确保 potrace 可用"""
if shutil.which("potrace") is None:
raise RuntimeError("未找到 potrace,请先安装:brew install potrace")
@staticmethod
def _load_gray(image_path):
"""加载图片并转换为灰度图(透明通道会按白底合成)"""
img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
if img is None:
raise ValueError(f"无法读取图片: {image_path}")
if img.ndim == 3 and img.shape[2] == 4:
bgr = img[:, :, :3].astype(np.float32)
alpha = img[:, :, 3:4].astype(np.float32) / 255.0
bgr = bgr * alpha + 255.0 * (1.0 - alpha)
gray = cv2.cvtColor(bgr.astype(np.uint8), cv2.COLOR_BGR2GRAY)
elif img.ndim == 3:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
else:
gray = img
return gray
def _binarize(self, gray):
"""二值化:黑色为前景"""
if self.threshold is None:
_, binary = cv2.threshold(
gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
else:
_, binary = cv2.threshold(gray, self.threshold, 255, cv2.THRESH_BINARY_INV)
return (binary > 0).astype(np.uint8)
@staticmethod
def _write_pbm(mask, path):
"""写入 ASCII PBM(P1)"""
height, width = mask.shape
with open(path, "w", encoding="ascii") as f:
f.write("P1\n")
f.write(f"{width} {height}\n")
for y in range(height):
row = mask[y]
f.write(" ".join("1" if v else "0" for v in row))
f.write("\n")
def _run_potrace(self, pbm_path, output_path):
"""调用 potrace 生成 SVG"""
cmd = [
"potrace",
str(pbm_path),
"-s",
"-o",
str(output_path),
"-u",
str(self.unit),
"-t",
str(self.turdsize),
"-O",
str(self.opttolerance),
]
if not self.optimize_curves:
cmd.append("-n")
subprocess.run(cmd, check=True)
@staticmethod
def _format_number(value):
text = f"{value:.2f}".rstrip("0").rstrip(".")
return text if text else "0"
def _circle_path(self, cx, cy, radius):
"""将圆转换为SVG路径(由两段圆弧组成)"""
cy_s = self._format_number(cy)
r_s = self._format_number(radius)
start_x = self._format_number(cx + radius)
end_x = self._format_number(cx - radius)
return (
f"M {start_x} {cy_s} "
f"A {r_s} {r_s} 0 1 0 {end_x} {cy_s} "
f"A {r_s} {r_s} 0 1 0 {start_x} {cy_s} Z"
)
@staticmethod
def _fit_circle(contour):
"""拟合圆并返回最大相对误差"""
(cx, cy), radius = cv2.minEnclosingCircle(contour)
if radius <= 0:
return None
points = contour.reshape(-1, 2).astype(np.float32)
dx = points[:, 0] - cx
dy = points[:, 1] - cy
dist = np.sqrt(dx * dx + dy * dy)
rel_error = np.abs(dist - radius) / radius
max_rel_error = float(rel_error.max())
return cx, cy, radius, max_rel_error
@staticmethod
def _collect_component_indices(hierarchy, start_idx):
indices = []
stack = [start_idx]
while stack:
idx = stack.pop()
indices.append(idx)
child = hierarchy[idx][2]
while child != -1:
stack.append(child)
child = hierarchy[child][0]
return indices
def _try_circle_svg(self, mask, output_path):
if self.circle_fit <= 0:
return False
contours, hierarchy = cv2.findContours(
mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE
)
if not contours or hierarchy is None:
return False
hierarchy = hierarchy[0]
paths = []
for idx, info in enumerate(hierarchy):
if info[3] != -1:
continue
parent_area = cv2.contourArea(contours[idx])
if parent_area <= self.turdsize:
continue
component_indices = self._collect_component_indices(hierarchy, idx)
component_paths = []
for contour_idx in component_indices:
contour = contours[contour_idx]
result = self._fit_circle(contour)
if result is None:
return False
cx, cy, radius, max_rel_error = result
if max_rel_error > self.circle_fit:
return False
component_paths.append(self._circle_path(cx, cy, radius))
if component_paths:
paths.append(
f' '
)
if not paths:
return False
height, width = mask.shape
svg_content = (
'\n'
'\n"
)
with open(output_path, "w", encoding="utf-8") as f:
f.write(svg_content)
return True
def convert_to_svg(self, image_path, output_path=None):
"""
将图片转换为SVG(高保真)
Args:
image_path: 输入图片路径
output_path: 输出SVG路径
"""
gray = self._load_gray(image_path)
mask = self._binarize(gray)
if mask.sum() == 0:
print("未检测到黑色区域,跳过。")
return None
if output_path is None:
base_name = os.path.splitext(image_path)[0]
output_path = base_name + ".svg"
else:
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
if self._try_circle_svg(mask, output_path):
file_size = os.path.getsize(output_path)
print(f"SVG文件已保存: {output_path}")
print(f"文件大小: {file_size} 字节 ({file_size/1024:.1f} KB)")
return output_path
self._ensure_potrace()
with tempfile.TemporaryDirectory() as tmp_dir:
pbm_path = os.path.join(tmp_dir, "input.pbm")
self._write_pbm(mask, pbm_path)
self._run_potrace(pbm_path, output_path)
file_size = os.path.getsize(output_path)
print(f"SVG文件已保存: {output_path}")
print(f"文件大小: {file_size} 字节 ({file_size/1024:.1f} KB)")
return output_path
def main():
"""主函数"""
parser = argparse.ArgumentParser(
description="将图片转换为高保真SVG(使用potrace描边)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python pic2svg.py input.png
python pic2svg.py --indir images
python pic2svg.py --indir images --outdir output
python pic2svg.py input.png --outdir output
python pic2svg.py input.png --turdsize 0 --opttolerance 0 --unit 1
""",
)
parser.add_argument("input", nargs="?", help="输入图片路径(可选,与 --indir 互斥)")
parser.add_argument("--indir", help="输入目录(批量转换,与 input 互斥)")
parser.add_argument(
"--outdir",
help="输出目录(自动创建,使用输入文件名.svg)",
)
parser.add_argument(
"--threshold",
type=int,
default=None,
help="固定阈值(0-255),默认使用Otsu自动阈值",
)
parser.add_argument(
"--turdsize",
type=int,
default=0,
help="抑制噪点面积阈值(potrace -t,默认 0)",
)
parser.add_argument(
"--opttolerance",
type=float,
default=0.0,
help="曲线优化容差(potrace -O,默认 0)",
)
parser.add_argument(
"--unit",
type=int,
default=1,
help="坐标量化单位(potrace -u,默认 1)",
)
parser.add_argument(
"--optimize-curves",
action="store_true",
help="启用曲线优化(文件更小但可能略失真)",
)
parser.add_argument(
"--circle-fit",
type=float,
default=0.0,
help="圆拟合误差阈值(相对半径),>0启用圆替代,如 0.02",
)
args = parser.parse_args()
try:
if args.input is None and args.indir is None:
parser.error("必须提供输入文件或 --indir")
if args.input and args.indir:
parser.error("input 与 --indir 互斥,请只指定一个")
if args.indir and not os.path.isdir(args.indir):
parser.error(f"输入目录不存在: {args.indir}")
converter = ImageToSVG(
threshold=args.threshold,
turdsize=args.turdsize,
opttolerance=args.opttolerance,
unit=args.unit,
optimize_curves=args.optimize_curves,
circle_fit=args.circle_fit,
)
if args.indir:
outdir = args.outdir
if outdir:
os.makedirs(outdir, exist_ok=True)
files = list(converter._iter_input_files(args.indir))
if not files:
print("输入目录中未找到可处理的图片文件。")
return 1
success = 0
failed = 0
for path in files:
output_path = None
if outdir:
base_name = os.path.splitext(os.path.basename(path))[0]
output_path = os.path.join(outdir, base_name + ".svg")
try:
converter.convert_to_svg(path, output_path)
success += 1
except Exception as e:
failed += 1
print(f"转换失败: {path} -> {e}")
print(f"批量转换完成: 成功 {success} 个,失败 {failed} 个")
else:
output_path = None
if args.outdir:
os.makedirs(args.outdir, exist_ok=True)
base_name = os.path.splitext(os.path.basename(args.input))[0]
output_path = os.path.join(args.outdir, base_name + ".svg")
converter.convert_to_svg(args.input, output_path)
except Exception as e:
print(f"转换失败: {e}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())