P1 서연 리노컷 아트 원본과 자산 파이프라인

- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷)
- P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지
- 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치
- MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
This commit is contained in:
Yun Chan 2026-10-01 09:58:15 +09:00
parent 85bd079d18
commit 00169533d8
83 changed files with 7843 additions and 0 deletions

View file

@ -0,0 +1,177 @@
"""P1 서연 종이결 타일 생성 — 2단계-B-1a (4).
base-front.png의 배경 영역(분할 category 0, 인물 경계에서 20px 이상 떨어진 곳)에서
256x256을 뽑아 무채색에 가까운(평균 명도 245~250) 곱하기용 타일로 만든다.
np.roll로 원본의 이음매를 캔버스 중앙 십자선으로 옮긴 뒤 그 십자선만 국소적으로
블러 블렌드해 감춘다 — 그 결과 타일의 실제 바깥 가장자리(좌우/상하)는 원본에서
서로 인접했던 픽셀이라 이어 붙여도 이음매가 거의 보이지 않는다.
실행: <venv>/python.exe build_paper_grain.py
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
from PIL import Image
from scipy.ndimage import distance_transform_edt, gaussian_filter, label
SCRIPTS_DIR = Path(__file__).resolve().parent
import sys # noqa: E402
sys.path.insert(0, str(SCRIPTS_DIR))
from build_layers_segmented import run_segmentation # noqa: E402
ROOT = SCRIPTS_DIR.parent
BASE_DIR = ROOT / "base"
LAYERS_V2_DIR = ROOT / "layers" / "v2"
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
MANIFEST_PATH = ROOT / "manifest.json"
TILE_SIZE = 256
PERSON_MARGIN_PX = 20
CANVAS_EDGE_MARGIN_PX = 10 # 캔버스 실제 가장자리(테두리 비네팅 위험) 회피
MIN_CLEAN_FRAC = 0.90 # 완전히 깨끗한 256x256 창이 캔버스 어디에도 없어 최선의 창을 쓰고 나머지는 메운다
TARGET_LUM_MIN = 245.0
TARGET_LUM_MAX = 250.0
TARGET_LUM_MID = (TARGET_LUM_MIN + TARGET_LUM_MAX) / 2.0
CHROMA_KEEP = 0.30 # 원래 색조를 30%만 남기고(거의 무채색), 70%는 명도값으로 대체
SEAM_FALLOFF_PX = 24.0
SEAM_BLUR_SIGMA = 6.0
def find_tile_origin(eligible: np.ndarray, size: int) -> tuple[int, int, float]:
"""eligible 비율이 가장 높은 size x size 창의 좌상단 좌표와 그 비율을 고른다.
캔버스 인물이 배경 대부분 면적에 걸쳐 있어(세로로 긴 흉상 구도) 잔머리 등
가장자리 침범 때문에 완전히 100% 깨끗한 창은 어디에도 없다 — 최선의 창을 고르고
남은 오염 픽셀은 호출부에서 인접 픽셀로 메운다."""
h, w = eligible.shape
mask_f = eligible.astype(np.float64)
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
y_lo, y_hi = CANVAS_EDGE_MARGIN_PX, h - size - CANVAS_EDGE_MARGIN_PX
x_lo, x_hi = CANVAS_EDGE_MARGIN_PX, w - size - CANVAS_EDGE_MARGIN_PX
if y_hi < y_lo or x_hi < x_lo:
y_lo, y_hi, x_lo, x_hi = 0, h - size, 0, w - size
sub = sums[y_lo:y_hi + 1, x_lo:x_hi + 1]
idx = np.unravel_index(np.argmax(sub), sub.shape)
y0, x0 = int(idx[0] + y_lo), int(idx[1] + x_lo)
frac = float(sub[idx]) / float(size * size)
if frac < MIN_CLEAN_FRAC:
raise SystemExit(f"[중단] 가장 깨끗한 256x256 창도 {frac*100:.1f}%로 기준({MIN_CLEAN_FRAC*100:.0f}%) 미달이다.")
return y0, x0, frac
def main() -> int:
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
front_path = BASE_DIR / "base-front.png"
front = np.array(Image.open(front_path).convert("RGB")).astype(np.float64)
h, w, _ = front.shape
category_mask = run_segmentation(front_path)
bg_mask = category_mask == 0
# 배경(category0) 자체가 종이결 텍스처 노이즈 때문에 분류기가 곳곳에 좁쌀만한
# 오분류 반점을 흩뿌려 놓는다(연결요소가 15개로 쪼개짐). 그 반점 하나하나에서
# 20px씩 침식하면 실제 인물과 무관한 곳까지 배경 후보가 사라진다. 대신
# "인물(가장 큰 전경 연결요소) 경계에서 20px 이상"만 걸러 낸다 — 반점은 실제
# 얼굴 이 아니라 채도 낮은 종이 위의 분류 잡음이므로 그 자리 원본 픽셀도 그냥
# 종이 질감이다.
fg_mask = category_mask != 0
labeled_fg, _ = label(fg_mask)
sizes = np.bincount(labeled_fg.ravel())
sizes[0] = 0
person_mask = labeled_fg == int(np.argmax(sizes))
dist_to_person = distance_transform_edt(~person_mask)
eligible = bg_mask & (dist_to_person >= PERSON_MARGIN_PX)
print(f"배경(category0) 픽셀: {int(bg_mask.sum())}, 인물 경계 {PERSON_MARGIN_PX}px 이상 떨어진 후보: {int(eligible.sum())}")
y0, x0, clean_frac = find_tile_origin(eligible, TILE_SIZE)
patch = front[y0:y0 + TILE_SIZE, x0:x0 + TILE_SIZE, :].copy()
print(f"타일 원본 위치: (x={x0}, y={y0}), size={TILE_SIZE}, 깨끗한 비율={clean_frac*100:.2f}%")
# 인물이 거의 전체 캔버스 높이를 차지하는 흉상 구도라 완전히 깨끗한 창이 없다
# (가장 좋은 창도 잔머리가 모서리에 살짝 걸침). 그 창 안의 오염 픽셀만 같은
# 패치의 가장 가까운 깨끗한 픽셀 색으로 메운다 — 머리카락 색이 종이결에
# 섞이는 것을 막는다.
contaminated_frac = 1.0 - clean_frac
win_eligible = eligible[y0:y0 + TILE_SIZE, x0:x0 + TILE_SIZE]
bad = ~win_eligible
n_bad = int(bad.sum())
if n_bad > 0:
_, (iy, ix) = distance_transform_edt(bad, return_indices=True)
patch[bad] = patch[iy[bad], ix[bad]]
print(f"오염 픽셀 메움: {n_bad}px ({contaminated_frac*100:.2f}%)")
# --- 무채색화 + 명도 목표대로 이동(결 대비=표준편차는 유지) ---
lum = patch.mean(axis=2)
shift = TARGET_LUM_MID - lum.mean()
lum_adj = np.clip(lum + shift, 0, 255)
# 각 픽셀의 원래 색조(자기 명도 대비 편차)를 유지한 채 명도만 이동
tinted = patch - lum[..., None] + lum_adj[..., None]
achromatic = np.clip(tinted * CHROMA_KEEP + lum_adj[..., None] * (1.0 - CHROMA_KEEP), 0, 255)
print(f"명도 이동: 원본평균={lum.mean():.2f} -> 목표={TARGET_LUM_MID:.2f} (shift={shift:.2f})")
print(f"조정 후 평균 명도={achromatic.mean(axis=2).mean():.2f}, 표준편차={lum.std():.2f}(원본)/{achromatic.mean(axis=2).std():.2f}(조정 후)")
# --- 이음매를 캔버스 중앙으로 옮기고 그 십자선만 블러로 감춘다 ---
rolled = np.roll(achromatic, shift=(TILE_SIZE // 2, TILE_SIZE // 2), axis=(0, 1))
blurred = np.stack([gaussian_filter(rolled[..., c], sigma=SEAM_BLUR_SIGMA) for c in range(3)], axis=2)
yy, xx = np.mgrid[0:TILE_SIZE, 0:TILE_SIZE]
dist_seam = np.minimum(np.abs(yy - TILE_SIZE // 2), np.abs(xx - TILE_SIZE // 2)).astype(np.float64)
seam_w = np.clip(1.0 - dist_seam / SEAM_FALLOFF_PX, 0.0, 1.0) ** 2
tile = rolled * (1 - seam_w[..., None]) + blurred * seam_w[..., None]
tile = np.clip(tile, 0, 255)
out_path = LAYERS_V2_DIR / "paper-grain.png"
Image.fromarray(tile.round().astype(np.uint8), "RGB").save(out_path)
print(f"저장: {out_path}")
# --- 이음매 연속성 검사: 좌우/상하 가장자리 1px 평균 차 ---
left_edge = tile[:, 0, :].mean(axis=0)
right_edge = tile[:, -1, :].mean(axis=0)
top_edge = tile[0, :, :].mean(axis=0)
bottom_edge = tile[-1, :, :].mean(axis=0)
lr_diff = float(np.abs(left_edge - right_edge).mean())
tb_diff = float(np.abs(top_edge - bottom_edge).mean())
print(f"이음매 연속성: 좌우 가장자리 평균차={lr_diff:.3f}, 상하 가장자리 평균차={tb_diff:.3f} (참고: 작을수록 매끄러움)")
final_lum = tile.mean(axis=2)
print(f"최종 타일 평균 명도={final_lum.mean():.2f} (목표 {TARGET_LUM_MIN}~{TARGET_LUM_MAX}), 표준편차={final_lum.std():.2f}")
# --- 미리보기: 3x3 타일링 ---
tiled = Image.new("RGB", (TILE_SIZE * 3, TILE_SIZE * 3))
tile_im = Image.fromarray(tile.round().astype(np.uint8), "RGB")
for j in range(3):
for i in range(3):
tiled.paste(tile_im, (i * TILE_SIZE, j * TILE_SIZE))
tiled.save(PREVIEW_V2_DIR / "paper-grain-tiled.png")
print(f"저장: {PREVIEW_V2_DIR / 'paper-grain-tiled.png'}")
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
manifest["layersV2"]["paperGrain"] = {
"sourceImage": "base/base-front.png",
"sourceOrigin": [x0, y0],
"tileSize": TILE_SIZE,
"personMarginPx": PERSON_MARGIN_PX,
"cleanFraction": clean_frac,
"contaminatedPixelsFilled": n_bad,
"chromaKeep": CHROMA_KEEP,
"targetLumRange": [TARGET_LUM_MIN, TARGET_LUM_MAX],
"lumShift": shift,
"finalMeanLum": float(final_lum.mean()),
"finalLumStd": float(final_lum.std()),
"seamContinuity": {"leftRightDiff": lr_diff, "topBottomDiff": tb_diff},
}
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"manifest.json 갱신: {MANIFEST_PATH}")
return 0
if __name__ == "__main__":
raise SystemExit(main())