"""P1 서연 종이결 타일 생성 — 2단계-B-1a (4). base-front.png의 배경 영역(분할 category 0, 인물 경계에서 20px 이상 떨어진 곳)에서 256x256을 뽑아 무채색에 가까운(평균 명도 245~250) 곱하기용 타일로 만든다. np.roll로 원본의 이음매를 캔버스 중앙 십자선으로 옮긴 뒤 그 십자선만 국소적으로 블러 블렌드해 감춘다 — 그 결과 타일의 실제 바깥 가장자리(좌우/상하)는 원본에서 서로 인접했던 픽셀이라 이어 붙여도 이음매가 거의 보이지 않는다. 실행: /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())