Files
font2pic/pic2svg.py
2026-02-06 11:56:47 +08:00

380 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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-255None 表示使用 Otsu 自动阈值
turdsize: 抑制噪点的面积阈值potrace -t
opttolerance: 曲线优化容差potrace -O
unit: 坐标量化单位potrace -u1 表示不量化)
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 PBMP1"""
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' <path d="{" ".join(component_paths)}" '
f'fill="#000000" fill-rule="evenodd"/>'
)
if not paths:
return False
height, width = mask.shape
svg_content = (
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}">\n'
f"{chr(10).join(paths)}\n"
"</svg>\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())