무료 로고 사이트에서 저해상도 래스터 이미지로 만든 나의 GenoGlobe.com 로고를 벡터 형식으로 전환해 보고 싶은 마음에 파워포인트에서 이런 미련한 일을 한 적이 있었다(2023년형 GenoGlobe 로고를 새로 만들다).
| 초승달 모양 사이의 간격이 일정하지 않은 것이 오히려 미학적 완성도를 높인다. |
이것은 로고를 구성하는 원의 직경을 대략 측정하기 위함이었다. 여기에서 얻은 중간 정보를 이용하여 LibreCAD에서 열심히 도형을 그렸는데, 요령이 부족하여 최종적으로 벡터화는 하지 못하였다. CAD 프로그램의 결과 파일(DXF)은 벡터임이 분명한데, 이를 SVG로 만드는 방법을 그당시에는 찾지 못하였다. 특히 속이 채워진 도형은 다루기가 더 까다로웠던 것 같다. 원리적으로 말하자면 이미지를 역엔지니어링하기 위한 '기하학적인 접근'인 셈이다.
그로부터 벌써 2년 반의 시간이 지났고, ChatGPT를 이용하면 이제는 완벽한 벡터 이미지를 얻을 수 있지 않을까 생각하게 되었다. 색상은 배경을 제외하면 단 두 가지에 불과하고, 테두리를 구성하는 도형은 전부 원호이다. 또한 원의 중심은 전체 도형의 틀을 이루는 정사각형의 대각선 위에 있다. 따라서 챗GPT에게 부탁하면 쉬운 방법을 알려줄 것이라고 생각했다.
Inkscape의 '경로(=Path) -> 비트맵 따라 그리기'에서 PNG 이미지를 직접 다루는 것도 좋은데, 내가 최종적으로 택한 방법은 파이썬 스크립트(png_go_svg_autograce.py)를 써서 대략적으로 벡터 이미지를 얻은 다음에 Inkscape에서 처리하는 것이었다.
이 스크립트는 경계선이 폴리곤 형태인 벡터 이미지를 생성한다.
Inkscape에서 이 중간본 SVG 파일을 읽은 뒤 각 'path'가 분리되어 있는 것을 확인한 다음 전체를 선택하여 '경로-> 단순화 (Control + L)'을 단 한번만 실시하여 노드를 줄이면 아무리 확대해도 매끈한 최종 결과물이 나온다. 처음부터 PNG를 입력하여 '비트맵 따라 그리기'를 해도 되지만 약간의 후처리가 필요하다. 실제로 해 보면 확대했을 경우 파란색 초승달 모양 테두리에 노란색 찌꺼기가 남는 게 보인다. 이를 없애려면 차집한 조작을 하거나, 지우개로 아주 섬세하게 지워야 한다. 지울 때에는 남색 개체를 보이지 않게 해 두는 것이 안전하다.
Inkscape에서 경로는 대단히 중요한 개념이다. 이는 점(node)과 그 사이를 잇는 곡선(세그먼트)로 정의된 수학적 도형의 설계도에 해당한다. 오늘 사용한 파이썬 스크립트에서는 색상을 바탕으로 픽셀 경계를 추적하여 경계점을 그대로 연결하고 곡선 피팅을 하지 않았다. 즉, 의도적으로 베지어(Bézier)를 쓰지 않고 폴리곤(아주 많은 짧은 직선)으로 근사한 벡터화였다.
보다 정밀한 방법은 1) potrace를 호출에 베지어 SVG를 생성하거나 2) 원호 기반으로 베지어/원호로 출력하면 된다고 한다. Inkscape의 비트맵 추적은 내부적으로 potrace 계열 알고리즘을 쓴다.
다음은 챗GPT가 만들어 준 png_go_svg_autotrace.py 전체이다.
#!/usr/bin/env python """ PNG 로고(3색: 흰/노랑/남색)를 자동으로 벡터(SVG)로 변환하는 스크립트. 사용법: python png_to_svg_autotrace.py input_logo.png 결과: input_logo_autotrace_transparent.svg input_logo_autotrace_whitebg.svg """ import sys import os from typing import Tuple, List import numpy as np from PIL import Image from skimage import measure import svgwrite # ---- 1. 색상 기준 정의 ------------------------------------------------------ # 로고에서 사용한 대략적인 색상 (필요하면 조정 가능) YELLOW_REF = np.array([242, 224, 89], dtype=np.float32) # #f2e059 근처 NAVY_REF = np.array([14, 27, 66], dtype=np.float32) # #0e1b42 근처 WHITE_REF = np.array([255, 255, 255], dtype=np.float32) # 흰 배경 REF_COLORS = np.stack([YELLOW_REF, NAVY_REF, WHITE_REF], axis=0) REF_NAMES = ["yellow", "navy", "white"] REF_HEX = { "yellow": "#f2e059", "navy": "#0e1b42", "white": "#ffffff", } # ---- 2. 유틸 함수들 --------------------------------------------------------- def classify_colors(arr: np.ndarray) -> np.ndarray: """ RGB 이미지 배열(arr: H x W x 3)을 3개 참조 색(노랑/남색/흰색)에 가장 가까운 색으로 분류하여 label map(H x W)을 반환. label: 0=yellow, 1=navy, 2=white """ h, w, _ = arr.shape img_flat = arr.reshape(-1, 3).astype(np.float32) # 각 픽셀과 참조 색상 사이의 제곱 거리 계산 # result: (N_pixels, 3) diff = img_flat[:, None, :] - REF_COLORS[None, :, :] dist2 = np.sum(diff ** 2, axis=-1) labels_flat = np.argmin(dist2, axis=1) labels = labels_flat.reshape(h, w) return labels def find_color_contours(mask: np.ndarray, min_length: int = 50) -> List[np.ndarray]: """ 색상 마스크(불리언 배열 또는 0/1 배열)에서 외곽선을 찾고 일정 길이(min_length) 이상인 contour들만 반환. """ # skimage.measure.find_contours는 값이 0~1 범위인 float 배열에 대해 동작 arr = mask.astype(float) contours = measure.find_contours(arr, 0.5) # 너무 짧은 contour는 제거 contours = [c for c in contours if c.shape[0] >= min_length] return contours def contours_to_svg_paths( dwg: svgwrite.Drawing, contours: List[np.ndarray], fill_color: str ): """ contour 리스트를 SVG path로 추가한다. contours: 각 요소는 (N_points, 2) 배열, (row, col) = (y, x) """ for c in contours: if len(c) < 2: continue # skimage 컨투어는 (row, col) = (y, x) # SVG는 (x, y) 순서, 좌상단 (0,0), 아래로 y+ commands = [] y0, x0 = c[0] commands.append(f"M {x0:.3f} {y0:.3f}") for y, x in c[1:]: commands.append(f"L {x:.3f} {y:.3f}") commands.append("Z") d_attr = " ".join(commands) dwg.add(dwg.path(d=d_attr, fill=fill_color)) # ---- 3. 메인 처리 ----------------------------------------------------------- def png_to_svg(input_path: str): # 1) 이미지 로드 img = Image.open(input_path).convert("RGB") arr = np.array(img) h, w, _ = arr.shape # 2) 색상 분류 (노랑/남색/흰색) labels = classify_colors(arr) # 3) 각 색상별 마스크 생성 masks = { "yellow": (labels == 0), "navy": (labels == 1), "white": (labels == 2), } # 4) 각 색상별 외곽선 추출 contours_by_color = {} for name, mask in masks.items(): if name == "white": # 배경은 SVG 도형으로 따로 그리지 않고, 필요 시 rect로 처리 continue contours = find_color_contours(mask, min_length=80) contours_by_color[name] = contours # 5) 출력 파일 이름 base, _ = os.path.splitext(input_path) out_transparent = base + "_autotrace_transparent.svg" out_whitebg = base + "_autotrace_whitebg.svg" # 6) SVG (투명 배경) dwg_t = svgwrite.Drawing(out_transparent, size=(w, h), viewBox=f"0 0 {w} {h}") # 노랑 먼저, 그 위에 남색 (어차피 픽셀 분류상 겹치지 않지만, 시각적으로도 자연스러운 순서) if "yellow" in contours_by_color: contours_to_svg_paths(dwg_t, contours_by_color["yellow"], REF_HEX["yellow"]) if "navy" in contours_by_color: contours_to_svg_paths(dwg_t, contours_by_color["navy"], REF_HEX["navy"]) dwg_t.save() print(f"[OK] Transparent SVG saved to: {out_transparent}") # 7) SVG (흰 배경) dwg_w = svgwrite.Drawing(out_whitebg, size=(w, h), viewBox=f"0 0 {w} {h}") # 흰 사각형 배경 dwg_w.add(dwg_w.rect(insert=(0, 0), size=(w, h), fill=REF_HEX["white"])) # 도형 덮어그리기 if "yellow" in contours_by_color: contours_to_svg_paths(dwg_w, contours_by_color["yellow"], REF_HEX["yellow"]) if "navy" in contours_by_color: contours_to_svg_paths(dwg_w, contours_by_color["navy"], REF_HEX["navy"]) dwg_w.save() print(f"[OK] White-background SVG saved to: {out_whitebg}") # ---- 4. 실행부 ---------------------------------------------------------------- def main(): if len(sys.argv) < 2: print("Usage: python png_to_svg_autotrace.py input_logo.png") sys.exit(1) input_path = sys.argv[1] if not os.path.exists(input_path): print(f"[ERROR] File not found: {input_path}") sys.exit(1) png_to_svg(input_path) if __name__ == "__main__": main()
원본 이미지가 완전히 기하학적인 도형으로만 이루어져 있어고 색상도 매우 단순했기 때문에 이런 간단한 스크립트로 일차적인 벡터화가 성공적으로 되었다. 베지어 트레이싱은 저품질 알리아싱(aliasing)을 다룰 때에는 아주 취약하다.
베지어 트레이싱은
알리아싱된 저품질 래스터 이미지의 테두리를
복원하는 데 본질적으로 취약하다.
이는 곡률 정보가 이미 샘플링 단계에서 손실되기 때문이다.
오늘 얻은 최종 결과물을 여기에 소개한다.
| 출처: DokuWiki 설정(GenoGlobe.com 위키) |
글을 마무리하면서 챗GPT에게 물어보니 LibreCAD 파일을 SVG로 전환하는 방법이 나온다. 허! 이걸 정리해서 블로그에 쓸 이유가 있을까? 누구나 질문을 던지면 다 알게 되는 세상인데...






















