게시판바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기예제 소스

예제 소스

바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기 · 파일 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()