#!/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' f"{chr(10).join(paths)}\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())