예제 소스
SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 169개
guanyu_svg_clean.py
"""PNG 프레임을 담은 관우 애니메이션 SVG에서 스프라이트 시트 잔재를 지운다.
- 아래 가운데 번호 배지(검은 원판 + 흰 숫자)
- 위 가장자리에 걸친 윗줄 칸 조각(바위 띠, 윗줄 번호 배지 아랫부분)
- 왼쪽 가장자리에 걸친 옆 칸 조각
결과: 앱용 1회 재생 SVG(public/effects/guanyu_danshu.svg), 선택적으로 정리된 원본 형식 SVG·WebP
python scripts/guanyu_svg_clean.py ../guanyu_original_art_animated.with_numbers.svg \
--svg-out ../guanyu_original_art_animated.svg
"""
from __future__ import annotations
import argparse
import base64
import io
import re
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
IMG_RE = re.compile(r'(<image id="(f\d+)"[^>]*href="data:image/png;base64,)([^"]+)(")')
KEY_RE = re.compile(r"@keyframes (f\d+)\{0%,([\d.]+)%\{opacity:0\} [\d.]+%,([\d.]+)%")
DUR_RE = re.compile(r"animation:f\d+ ([\d.]+)s")
def find_badge(rgba: np.ndarray) -> tuple[int, int, int] | None:
"""아래 가운데의 검은 원판(번호 배지) → (cx, cy, r). 아래가 잘린 원판도 찾는다."""
h, w = rgba.shape[:2]
y0, x0, x1 = int(h * 0.78), int(w * 0.33), int(w * 0.67)
sub = rgba[y0:, x0:x1].astype(np.float32)
a = sub[..., 3:4] / 255
gray = cv2.cvtColor((sub[..., :3] * a + 255 * (1 - a)).astype(np.uint8), cv2.COLOR_RGB2GRAY)
pad = cv2.copyMakeBorder(gray, 0, 40, 0, 0, cv2.BORDER_CONSTANT, value=255)
circles = cv2.HoughCircles(cv2.medianBlur(pad, 3), cv2.HOUGH_GRADIENT, dp=1, minDist=40,
param1=120, param2=14, minRadius=22, maxRadius=34)
if circles is None:
return None
dark = (sub[..., 3] > 160) & (sub[..., :3].max(axis=2) < 50)
yy, xx = np.mgrid[:dark.shape[0], :dark.shape[1]]
best = None
for cx, cy, r in circles[0]:
inside = (xx - cx) ** 2 + (yy - cy) ** 2 <= (r * 0.85) ** 2
if inside.sum() < 300:
continue
frac = dark[inside].mean()
if frac > 0.45 and (best is None or frac > best[0]):
best = (frac, int(cx) + x0, int(cy) + y0, int(round(r)))
return best[1:] if best else None
def clean(rgba: np.ndarray) -> tuple[np.ndarray, dict]:
h, w = rgba.shape[:2]
alpha = rgba[..., 3].astype(np.float32)
info: dict = {}
badge = find_badge(rgba)
if badge:
cx, cy, r = badge
m = np.zeros((h, w), np.float32)
cv2.circle(m, (cx, cy), r + 10, 1.0, -1)
alpha *= 1 - cv2.GaussianBlur(m, (0, 0), 1.5)
info["badge"] = badge
# 가장자리에 걸친 옆 칸 조각. 옅은 빛 번짐이 주인공과 이어 붙이므로 불투명한 부분으로만 묶는다
ys, xs = np.nonzero(alpha > 16)
top, left = ys.min(), xs.min()
solid = alpha > 90
n, lab, st, _ = cv2.connectedComponentsWithStats(solid.astype(np.uint8), connectivity=8)
kill = np.zeros(n, bool)
for k in range(1, n):
ky, kx = st[k, cv2.CC_STAT_TOP], st[k, cv2.CC_STAT_LEFT]
kb, kr = ky + st[k, cv2.CC_STAT_HEIGHT], kx + st[k, cv2.CC_STAT_WIDTH]
if ky <= top + 3 and kb < top + 120:
kill[k] = True
elif kx <= left + 3 and kr < left + 90:
kill[k] = True
elif kb < top + 85 and st[k, cv2.CC_STAT_AREA] < 2500:
kill[k] = True # 윗줄 띠에서 떨어져 나온 작은 바위
killm = kill[lab]
keep = solid & ~killm
near_kill = cv2.dilate(killm.astype(np.uint8), np.ones((9, 9), np.uint8)) > 0
near_keep = cv2.dilate(keep.astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
alpha[killm | (near_kill & ~near_keep & ~solid)] = 0
info["removed"] = int(kill.sum())
# 남은 옅은 부스러기
n, lab, st, _ = cv2.connectedComponentsWithStats((alpha > 16).astype(np.uint8), connectivity=8)
specks = np.where(st[:, cv2.CC_STAT_AREA] < 60)[0]
alpha[np.isin(lab, specks[specks != 0])] = 0
out = rgba.copy()
out[..., 3] = np.clip(alpha, 0, 255).astype(np.uint8)
out[out[..., 3] == 0, :3] = 0
return out, info
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("svg", type=Path)
ap.add_argument("--svg-out", type=Path)
ap.add_argument("--anim-svg-out", type=Path,
default=Path(__file__).resolve().parents[1] / "public/effects/guanyu_danshu.svg",
help="앱 단수 연출용 1회 재생 SVG")
ap.add_argument("--webp-out", type=Path)
ap.add_argument("--quality", type=int, default=92)
args = ap.parse_args()
svg = args.svg.read_text()
total = float(DUR_RE.search(svg).group(1)) * 1000
ends = {fid: float(e) for fid, _s, e in KEY_RE.findall(svg)}
starts = {fid: float(s) for fid, s, _e in KEY_RE.findall(svg)}
frames, durs = [], []
def repl(m: re.Match) -> str:
fid = m.group(2)
im = np.asarray(Image.open(io.BytesIO(base64.b64decode(m.group(3)))).convert("RGBA"))
out, info = clean(im)
print(fid, info)
frames.append(Image.fromarray(out, "RGBA"))
durs.append(round((ends[fid] - starts[fid]) / 100 * total))
buf = io.BytesIO()
frames[-1].save(buf, "PNG", optimize=True)
return m.group(1) + base64.b64encode(buf.getvalue()).decode() + m.group(4)
new_svg = IMG_RE.sub(repl, svg)
if args.svg_out:
args.svg_out.write_text(new_svg)
print("svg", args.svg_out, f"{args.svg_out.stat().st_size // 1024}KB")
if args.anim_svg_out:
write_anim_svg(frames, durs, args.anim_svg_out, args.quality)
print("anim svg", args.anim_svg_out, f"{args.anim_svg_out.stat().st_size // 1024}KB",
"durs", durs, sum(durs))
if args.webp_out:
frames[0].save(args.webp_out, save_all=True, append_images=frames[1:], duration=durs, loop=1,
quality=args.quality, alpha_quality=100, method=6)
print("webp", args.webp_out, f"{args.webp_out.stat().st_size // 1024}KB")
def write_anim_svg(frames: list[Image.Image], durs: list[int], path: Path, quality: int) -> None:
"""프레임을 WebP로 담아 한 번만 재생하고 마지막 프레임에서 멈추는 SVG."""
w, h = frames[0].size
total = sum(durs)
css, imgs = [], []
t = 0
for i, (f, d) in enumerate(zip(frames, durs)):
s, e = 100 * t / total, 100 * (t + d) / total
t += d
if i == 0:
kf = f"0%{{opacity:1}} {e:.3f}%,100%{{opacity:0}}"
elif i == len(frames) - 1:
kf = f"0%{{opacity:0}} {s:.3f}%,100%{{opacity:1}}"
else:
kf = f"0%{{opacity:0}} {s:.3f}%{{opacity:1}} {e:.3f}%,100%{{opacity:0}}"
css.append(f"#f{i}{{animation:f{i} {total}ms step-end 1 forwards}}@keyframes f{i}{{{kf}}}")
buf = io.BytesIO()
f.save(buf, "WEBP", quality=quality, alpha_quality=100, method=6)
imgs.append(f'<image id="f{i}" class="frame" width="{w}" height="{h}" '
f'href="data:image/webp;base64,{base64.b64encode(buf.getvalue()).decode()}"/>')
last = len(frames) - 1
path.write_text(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">\n'
f"<style>.frame{{opacity:0}}\n" + "\n".join(css) +
f"\n@media (prefers-reduced-motion:reduce){{.frame{{animation:none!important;opacity:0}}#f{last}{{opacity:1}}}}"
"</style>\n" + "\n".join(imgs) + "\n</svg>\n"
)
if __name__ == "__main__":
main()