- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일) - 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조 - P1 원화 생성 프롬프트 보존
182 lines
9 KiB
Python
182 lines
9 KiB
Python
"""공통 리노컷 리그 — 종이결 타일 생성.
|
|
|
|
base-front.png의 배경 영역(분할 category 0, 인물 경계에서 20px 이상 떨어진 곳)에서
|
|
256x256을 뽑아 무채색에 가까운(평균 명도 245~250) 곱하기용 타일로 만든다.
|
|
np.roll로 원본의 이음매를 캔버스 중앙 십자선으로 옮긴 뒤 그 십자선만 국소적으로
|
|
블러 블렌드해 감춘다 — 그 결과 타일의 실제 바깥 가장자리(좌우/상하)는 원본에서
|
|
서로 인접했던 픽셀이라 이어 붙여도 이음매가 거의 보이지 않는다.
|
|
|
|
base/base-front.png만 있으면 되고, 다른 단계와 순서 의존성이 없다(원하는
|
|
때에 다시 돌려도 된다).
|
|
|
|
실행: <venv>/python.exe paper_grain.py <persona-dir>
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
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
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
from segmentation import run_segmentation # noqa: E402
|
|
from persona_config import load_persona_config # noqa: E402
|
|
|
|
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(persona_dir: Path) -> int:
|
|
cfg = load_persona_config(persona_dir)
|
|
base_dir = cfg.base_dir
|
|
layers_v2_dir = cfg.layers_v2_dir
|
|
preview_v2_dir = cfg.preview_v2_dir
|
|
manifest_path = cfg.manifest_path
|
|
|
|
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) 자체가 종이결 텍스처 노이즈 때문에 분류기가 곳곳에 좁쌀만한
|
|
# 오분류 반점을 흩뿌려 놓는다(연결요소가 여러 개로 쪼개짐). 그 반점 하나하나에서
|
|
# 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.setdefault("layersV2", {})
|
|
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(Path(sys.argv[1])))
|