P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷) - P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지 - 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치 - MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
This commit is contained in:
parent
85bd079d18
commit
00169533d8
83 changed files with 7843 additions and 0 deletions
583
docs/avatar-art/p1-linocut/motif/scripts/build_motif_sprites.py
Normal file
583
docs/avatar-art/p1-linocut/motif/scripts/build_motif_sprites.py
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
"""P1 리노컷 모티프 스프라이트 후처리.
|
||||
|
||||
원본 시트(raw/bud-sheet.png, raw/weather-sheet.png)를 읽어 칸을 분리하고,
|
||||
종이 배경을 알파로 바꾸고(unpremultiply), 정렬한 스프라이트 PNG를 만든다.
|
||||
검사 수치를 motif/manifest.json에 기록하고 미리보기를 motif/preview/에 만든다.
|
||||
|
||||
작업 패킷: 2단계-B-1c. 결정적으로 동작해야 하며 난수를 쓰지 않는다.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
MOTIF_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RAW_DIR = os.path.join(MOTIF_DIR, "raw")
|
||||
SPRITES_DIR = os.path.join(MOTIF_DIR, "sprites")
|
||||
PREVIEW_DIR = os.path.join(MOTIF_DIR, "preview")
|
||||
MANIFEST_PATH = os.path.join(MOTIF_DIR, "manifest.json")
|
||||
|
||||
BUD_CANVAS = (280, 420) # (W, H) — 2B-1c 반려 재작업: 240x360에서 확대(봉오리 머리 잘림)
|
||||
BUD_MARGIN_FRAC = 0.05 # 4장 합집합 bbox 사방 여백 최소치
|
||||
BUD_FIT_FRAC = 0.90 # 합집합 bbox가 캔버스에서 차지할 최대 비율
|
||||
WEATHER_CANVAS = (320, 200) # (W, H)
|
||||
WEATHER_MAX_FRAC = 0.88
|
||||
|
||||
CLIP_BAND_PX = 3 # 잘림 검사용 가장자리 띠 두께
|
||||
CLIP_ALPHA_CUT = 0.05 # 잘림 검사용 알파 임계값
|
||||
|
||||
BUD_PANEL_NAMES = ["bud-closed", "bud-half", "bud-open", "bud-droop"]
|
||||
WEATHER_PANEL_NAMES = [
|
||||
"weather-positive", # 해
|
||||
"weather-negative", # 비구름
|
||||
"weather-defensive", # 안개
|
||||
"weather-energy", # 초승달
|
||||
"weather-cognitive", # 옅은 안개결
|
||||
]
|
||||
|
||||
ALPHA_LO = 18.0
|
||||
ALPHA_HI = 60.0
|
||||
SPLIT_THRESHOLD = 12.0 # 칸 분리용 전경 판정 임계값(종이색 거리)
|
||||
CROP_PAD = 12
|
||||
|
||||
|
||||
def smoothstep(d, lo, hi):
|
||||
t = np.clip((d - lo) / (hi - lo), 0.0, 1.0)
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def color_dist(arr, color):
|
||||
diff = arr.astype(np.float64) - np.asarray(color, dtype=np.float64)
|
||||
return np.sqrt((diff ** 2).sum(axis=-1))
|
||||
|
||||
|
||||
def estimate_paper_color(arr, border=20):
|
||||
h, w = arr.shape[:2]
|
||||
top = arr[:border, :, :].reshape(-1, 3)
|
||||
bottom = arr[-border:, :, :].reshape(-1, 3)
|
||||
left = arr[:, :border, :].reshape(-1, 3)
|
||||
right = arr[:, -border:, :].reshape(-1, 3)
|
||||
allb = np.concatenate([top, bottom, left, right], axis=0)
|
||||
return np.median(allb.astype(np.float64), axis=0)
|
||||
|
||||
|
||||
def find_panel_col_ranges(mask, n_expected):
|
||||
"""mask: (H,W) bool 전경. 열 투영 간격으로 n_expected개 칸의 (c0,c1)을 반환."""
|
||||
col_has = mask.any(axis=0)
|
||||
n = len(col_has)
|
||||
gaps = []
|
||||
i = 0
|
||||
while i < n:
|
||||
if not col_has[i]:
|
||||
j = i
|
||||
while j < n and not col_has[j]:
|
||||
j += 1
|
||||
gaps.append((i, j))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
internal = [g for g in gaps if g[0] > 0 and g[1] < n]
|
||||
internal_sorted = sorted(internal, key=lambda g: g[1] - g[0], reverse=True)
|
||||
chosen = sorted(internal_sorted[: n_expected - 1], key=lambda g: g[0])
|
||||
bounds = [0] + [(g[0] + g[1]) // 2 for g in chosen] + [n]
|
||||
ranges = [(bounds[k], bounds[k + 1]) for k in range(len(bounds) - 1)]
|
||||
gap_widths = [g[1] - g[0] for g in chosen]
|
||||
return ranges, gap_widths
|
||||
|
||||
|
||||
def crop_panel(arr, mask, col_range, pad=CROP_PAD):
|
||||
h, w = mask.shape
|
||||
c0, c1 = col_range
|
||||
sub_mask = mask[:, c0:c1]
|
||||
rows = np.where(sub_mask.any(axis=1))[0]
|
||||
r0, r1 = int(rows.min()), int(rows.max()) + 1
|
||||
r0p = max(0, r0 - pad)
|
||||
r1p = min(h, r1 + pad)
|
||||
c0p = max(0, c0 - pad)
|
||||
c1p = min(w, c1 + pad)
|
||||
return arr[r0p:r1p, c0p:c1p, :].copy(), (r0p, r1p, c0p, c1p)
|
||||
|
||||
|
||||
def unpremultiply_crop(sub_rgb_u8, paper_color):
|
||||
sub = sub_rgb_u8.astype(np.float64)
|
||||
d = color_dist(sub, paper_color)
|
||||
alpha = smoothstep(d, ALPHA_LO, ALPHA_HI)
|
||||
paper_b = np.asarray(paper_color, dtype=np.float64).reshape(1, 1, 3)
|
||||
a3 = alpha[..., None]
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
unprem = (sub - (1.0 - a3) * paper_b) / np.clip(a3, 1e-6, None)
|
||||
rgb = np.where(a3 > 0.02, unprem, sub)
|
||||
rgb = np.clip(rgb, 0, 255)
|
||||
return rgb, alpha
|
||||
|
||||
|
||||
def ink_bottom_anchor(rgb, alpha, luminance_cut=90.0, alpha_cut=0.5, band=3):
|
||||
"""알파>alpha_cut 이고 어두운(잉크) 픽셀 중 가장 아래쪽 무리의 중심을 반환한다."""
|
||||
lum = rgb.mean(axis=2)
|
||||
ink = (alpha > alpha_cut) & (lum < luminance_cut)
|
||||
rows = np.where(ink.any(axis=1))[0]
|
||||
if rows.size == 0:
|
||||
# ink 판정 실패 시 전체 알파 콘텐츠 최하단으로 대체
|
||||
rows_all = np.where((alpha > alpha_cut).any(axis=1))[0]
|
||||
bottom_row = int(rows_all.max())
|
||||
band_mask = (alpha > alpha_cut)[max(0, bottom_row - band + 1): bottom_row + 1, :]
|
||||
ys, xs = np.where(band_mask)
|
||||
ys = ys + max(0, bottom_row - band + 1)
|
||||
return float(xs.mean()), float(ys.mean())
|
||||
bottom_row = int(rows.max())
|
||||
r0 = max(0, bottom_row - band + 1)
|
||||
band_mask = ink[r0: bottom_row + 1, :]
|
||||
ys, xs = np.where(band_mask)
|
||||
ys = ys + r0
|
||||
return float(xs.mean()), float(ys.mean())
|
||||
|
||||
|
||||
def content_bbox(alpha, alpha_cut=0.5):
|
||||
rows = np.where((alpha > alpha_cut).any(axis=1))[0]
|
||||
cols = np.where((alpha > alpha_cut).any(axis=0))[0]
|
||||
if rows.size == 0 or cols.size == 0:
|
||||
return None
|
||||
return int(rows.min()), int(rows.max()) + 1, int(cols.min()), int(cols.max()) + 1
|
||||
|
||||
|
||||
def resize_raw_and_unpremultiply(raw_u8, paper_color, factor):
|
||||
"""원본(unpremultiply 전) crop을 리사이즈한 뒤 alpha/unpremultiply를 다시 계산한다.
|
||||
|
||||
이미 unpremultiply한 (rgb,alpha)를 premultiply-리샘플-재분할하면 저알파 경계에서
|
||||
링잉으로 종이색에 가까운 색이 남는 halo가 생긴다(실측 확인). 대신 원본 색상만
|
||||
리샘플하고 동일한 색-거리 기준 alpha 계산을 다시 적용하면 네이티브 해상도와
|
||||
같은 방식으로 일관된 결과를 얻는다.
|
||||
"""
|
||||
if abs(factor - 1.0) < 1e-6:
|
||||
return unpremultiply_crop(raw_u8, paper_color)
|
||||
h, w = raw_u8.shape[:2]
|
||||
new_w = max(1, int(round(w * factor)))
|
||||
new_h = max(1, int(round(h * factor)))
|
||||
resized = np.asarray(Image.fromarray(raw_u8).resize((new_w, new_h), Image.LANCZOS))
|
||||
return unpremultiply_crop(resized, paper_color)
|
||||
|
||||
|
||||
def paste_into_canvas(rgb, alpha, canvas_w, canvas_h, ox, oy):
|
||||
"""sprite(rgb,alpha)의 좌상단이 canvas 좌표 (ox,oy)에 오도록 붙인다. ox,oy는 float(반올림)."""
|
||||
ox_i, oy_i = int(round(ox)), int(round(oy))
|
||||
h, w = alpha.shape
|
||||
canvas_rgb = np.zeros((canvas_h, canvas_w, 3), dtype=np.float64)
|
||||
canvas_a = np.zeros((canvas_h, canvas_w), dtype=np.float64)
|
||||
|
||||
src_x0, src_y0 = 0, 0
|
||||
src_x1, src_y1 = w, h
|
||||
dst_x0, dst_y0 = ox_i, oy_i
|
||||
dst_x1, dst_y1 = ox_i + w, oy_i + h
|
||||
|
||||
if dst_x0 < 0:
|
||||
src_x0 -= dst_x0
|
||||
dst_x0 = 0
|
||||
if dst_y0 < 0:
|
||||
src_y0 -= dst_y0
|
||||
dst_y0 = 0
|
||||
if dst_x1 > canvas_w:
|
||||
src_x1 -= (dst_x1 - canvas_w)
|
||||
dst_x1 = canvas_w
|
||||
if dst_y1 > canvas_h:
|
||||
src_y1 -= (dst_y1 - canvas_h)
|
||||
dst_y1 = canvas_h
|
||||
|
||||
if dst_x1 > dst_x0 and dst_y1 > dst_y0:
|
||||
canvas_rgb[dst_y0:dst_y1, dst_x0:dst_x1, :] = rgb[src_y0:src_y1, src_x0:src_x1, :]
|
||||
canvas_a[dst_y0:dst_y1, dst_x0:dst_x1] = alpha[src_y0:src_y1, src_x0:src_x1]
|
||||
return canvas_rgb, canvas_a
|
||||
|
||||
|
||||
def save_rgba(path, rgb, alpha):
|
||||
out = np.zeros((*alpha.shape, 4), dtype=np.uint8)
|
||||
out[..., :3] = np.clip(rgb, 0, 255).astype(np.uint8)
|
||||
out[..., 3] = np.clip(alpha * 255.0, 0, 255).astype(np.uint8)
|
||||
Image.fromarray(out, mode="RGBA").save(path)
|
||||
|
||||
|
||||
def kmeans_np(pixels, k=4, n_init=6, iters=50, seed=0):
|
||||
"""sklearn 부재 시 수동 k-means. pixels: (N,3) float64."""
|
||||
rng = np.random.RandomState(seed)
|
||||
best_centers, best_inertia, best_labels = None, None, None
|
||||
n = pixels.shape[0]
|
||||
for init_i in range(n_init):
|
||||
idx = rng.choice(n, size=k, replace=False)
|
||||
centers = pixels[idx].copy()
|
||||
labels = np.zeros(n, dtype=np.int64)
|
||||
for _ in range(iters):
|
||||
d = np.linalg.norm(pixels[:, None, :] - centers[None, :, :], axis=2)
|
||||
new_labels = d.argmin(axis=1)
|
||||
if np.array_equal(new_labels, labels) and _ > 0:
|
||||
labels = new_labels
|
||||
break
|
||||
labels = new_labels
|
||||
for c in range(k):
|
||||
sel = pixels[labels == c]
|
||||
if sel.shape[0] > 0:
|
||||
centers[c] = sel.mean(axis=0)
|
||||
d = np.linalg.norm(pixels[:, None, :] - centers[None, :, :], axis=2)
|
||||
inertia = (d[np.arange(n), labels] ** 2).sum()
|
||||
if best_inertia is None or inertia < best_inertia:
|
||||
best_inertia, best_centers, best_labels = inertia, centers.copy(), labels.copy()
|
||||
return best_centers, best_labels, best_inertia
|
||||
|
||||
|
||||
def process_sheet(sheet_name, n_panels, kind, manifest):
|
||||
path = os.path.join(RAW_DIR, sheet_name)
|
||||
im = Image.open(path).convert("RGB")
|
||||
arr = np.asarray(im)
|
||||
h, w = arr.shape[:2]
|
||||
paper_color = estimate_paper_color(arr)
|
||||
fg_mask = color_dist(arr, paper_color) > SPLIT_THRESHOLD
|
||||
col_ranges, gap_widths = find_panel_col_ranges(fg_mask, n_panels)
|
||||
|
||||
panels = []
|
||||
for i, cr in enumerate(col_ranges):
|
||||
crop_rgb_u8, bbox = crop_panel(arr, fg_mask, cr)
|
||||
rgb, alpha = unpremultiply_crop(crop_rgb_u8, paper_color)
|
||||
panels.append({"raw_u8": crop_rgb_u8, "rgb": rgb, "alpha": alpha, "bbox": bbox, "col_range": cr})
|
||||
|
||||
manifest[kind]["raw_sheet"] = {
|
||||
"file": f"raw/{sheet_name}",
|
||||
"size": [w, h],
|
||||
"paper_color_rgb": [round(float(c), 1) for c in paper_color],
|
||||
"panel_gap_widths_px": gap_widths,
|
||||
"panel_col_ranges": [list(cr) for cr in col_ranges],
|
||||
}
|
||||
return panels, paper_color
|
||||
|
||||
|
||||
def build_bud_sprites(panels, paper_color, manifest):
|
||||
"""4장의 줄기 밑동을 기준으로 정렬하고, 합집합 bbox(alpha>0.05)가 캔버스의
|
||||
BUD_FIT_FRAC 안(사방 여백 BUD_MARGIN_FRAC 이상)에 들어가도록 4장 공통 배율
|
||||
하나를 정해 배치한다(1차 반려 사유: 밑동만 맞추고 축소하지 않아 봉오리 머리가
|
||||
캔버스 위에서 잘림).
|
||||
"""
|
||||
W, H = BUD_CANVAS
|
||||
|
||||
# 1) 네이티브 해상도에서 밑동 앵커와 alpha>0.05 콘텐츠 bbox를 구해
|
||||
# 앵커 기준 상하좌우 여유폭(extent)을 계산한다.
|
||||
extents = []
|
||||
for p in panels:
|
||||
rgb, alpha = p["rgb"], p["alpha"]
|
||||
ax, ay = ink_bottom_anchor(rgb, alpha)
|
||||
bbox = content_bbox(alpha, alpha_cut=CLIP_ALPHA_CUT)
|
||||
r0, r1, c0, c1 = bbox
|
||||
extents.append({
|
||||
"anchor": (ax, ay),
|
||||
"up": ay - r0,
|
||||
"down": max(0.0, r1 - ay),
|
||||
"left": ax - c0,
|
||||
"right": c1 - ax,
|
||||
})
|
||||
|
||||
up_max = max(e["up"] for e in extents)
|
||||
down_max = max(e["down"] for e in extents)
|
||||
left_max = max(e["left"] for e in extents)
|
||||
right_max = max(e["right"] for e in extents)
|
||||
|
||||
width_needed = left_max + right_max
|
||||
height_needed = up_max + down_max
|
||||
|
||||
factor = min(BUD_FIT_FRAC * W / width_needed, BUD_FIT_FRAC * H / height_needed)
|
||||
|
||||
base_y = H * (1.0 - BUD_MARGIN_FRAC)
|
||||
base_x = W / 2.0 + factor * (left_max - right_max) / 2.0
|
||||
|
||||
finals = []
|
||||
anchors_out = []
|
||||
for p, name in zip(panels, BUD_PANEL_NAMES):
|
||||
rgb, alpha = resize_raw_and_unpremultiply(p["raw_u8"], paper_color, factor)
|
||||
ax, ay = ink_bottom_anchor(rgb, alpha)
|
||||
ox = base_x - ax
|
||||
oy = base_y - ay
|
||||
canvas_rgb, canvas_a = paste_into_canvas(rgb, alpha, W, H, ox, oy)
|
||||
|
||||
out_path = os.path.join(SPRITES_DIR, f"{name}.png")
|
||||
save_rgba(out_path, canvas_rgb, canvas_a)
|
||||
|
||||
final_ax, final_ay = ink_bottom_anchor(canvas_rgb, canvas_a)
|
||||
anchors_out.append((final_ax, final_ay))
|
||||
finals.append({"name": name, "path": f"sprites/{name}.png", "canvas": list(BUD_CANVAS),
|
||||
"stem_bottom_anchor_px": [round(final_ax, 2), round(final_ay, 2)]})
|
||||
|
||||
anchors_arr = np.array(anchors_out)
|
||||
max_dev = 0.0
|
||||
for i in range(len(anchors_arr)):
|
||||
for j in range(i + 1, len(anchors_arr)):
|
||||
dev = float(np.linalg.norm(anchors_arr[i] - anchors_arr[j]))
|
||||
max_dev = max(max_dev, dev)
|
||||
|
||||
manifest["bud"]["sprites"] = finals
|
||||
manifest["bud"]["scale_factor"] = round(factor, 4)
|
||||
manifest["bud"]["canvas"] = list(BUD_CANVAS)
|
||||
manifest["bud"]["base_anchor_px"] = [round(base_x, 2), round(base_y, 2)]
|
||||
manifest["bud"]["union_bbox_native_px"] = {
|
||||
"up": round(up_max, 2), "down": round(down_max, 2),
|
||||
"left": round(left_max, 2), "right": round(right_max, 2),
|
||||
}
|
||||
manifest["bud"]["stem_bottom_alignment_max_dev_px"] = round(max_dev, 3)
|
||||
return finals
|
||||
|
||||
|
||||
def build_weather_sprites(panels, paper_color, manifest):
|
||||
finals = []
|
||||
for p, name in zip(panels, WEATHER_PANEL_NAMES):
|
||||
rgb, alpha = p["rgb"], p["alpha"]
|
||||
bbox = content_bbox(alpha)
|
||||
r0, r1, c0, c1 = bbox
|
||||
cw, ch = c1 - c0, r1 - r0
|
||||
max_w = WEATHER_MAX_FRAC * WEATHER_CANVAS[0]
|
||||
max_h = WEATHER_MAX_FRAC * WEATHER_CANVAS[1]
|
||||
factor = min(1.0, max_w / cw, max_h / ch)
|
||||
if factor < 1.0:
|
||||
rgb, alpha = resize_raw_and_unpremultiply(p["raw_u8"], paper_color, factor)
|
||||
bbox2 = content_bbox(alpha)
|
||||
r0, r1, c0, c1 = bbox2
|
||||
|
||||
content_cx = (c0 + c1) / 2.0
|
||||
content_cy = (r0 + r1) / 2.0
|
||||
target_cx = WEATHER_CANVAS[0] / 2.0
|
||||
target_cy = WEATHER_CANVAS[1] / 2.0
|
||||
ox = target_cx - content_cx
|
||||
oy = target_cy - content_cy
|
||||
|
||||
canvas_rgb, canvas_a = paste_into_canvas(rgb, alpha, WEATHER_CANVAS[0], WEATHER_CANVAS[1], ox, oy)
|
||||
out_path = os.path.join(SPRITES_DIR, f"{name}.png")
|
||||
save_rgba(out_path, canvas_rgb, canvas_a)
|
||||
|
||||
finals.append({"name": name, "path": f"sprites/{name}.png", "canvas": list(WEATHER_CANVAS),
|
||||
"scale_factor": round(factor, 4)})
|
||||
manifest["weather"]["sprites"] = finals
|
||||
return finals
|
||||
|
||||
|
||||
def qc_metrics(name, path, paper_color, manifest_section):
|
||||
im = Image.open(path)
|
||||
arr = np.asarray(im).astype(np.float64)
|
||||
rgb = arr[..., :3]
|
||||
alpha = arr[..., 3] / 255.0
|
||||
|
||||
bbox = content_bbox(alpha)
|
||||
if bbox is None:
|
||||
leak = float(alpha.mean())
|
||||
halo_ratio = 0.0
|
||||
else:
|
||||
r0, r1, c0, c1 = bbox
|
||||
outside = np.ones_like(alpha, dtype=bool)
|
||||
outside[r0:r1, c0:c1] = False
|
||||
leak = float(alpha[outside].mean()) if outside.any() else 0.0
|
||||
|
||||
edge_mask = (alpha > 0.05) & (alpha < 0.95)
|
||||
n_edge = int(edge_mask.sum())
|
||||
if n_edge > 0:
|
||||
d = color_dist(rgb, paper_color)
|
||||
close = (d < 30) & edge_mask
|
||||
halo_ratio = float(close.sum()) / n_edge
|
||||
else:
|
||||
halo_ratio = 0.0
|
||||
|
||||
pix = rgb[alpha > 0.5]
|
||||
color_clusters = None
|
||||
if pix.shape[0] >= 4:
|
||||
centers, labels, inertia = kmeans_np(pix.astype(np.float64), k=4)
|
||||
counts = np.bincount(labels, minlength=4)
|
||||
order = np.argsort(-counts)
|
||||
color_clusters = [
|
||||
{"center_rgb": [round(float(x), 1) for x in centers[o]], "pixel_count": int(counts[o])}
|
||||
for o in order
|
||||
]
|
||||
|
||||
h, w = alpha.shape
|
||||
band = np.zeros((h, w), dtype=bool)
|
||||
band[:CLIP_BAND_PX, :] = True
|
||||
band[-CLIP_BAND_PX:, :] = True
|
||||
band[:, :CLIP_BAND_PX] = True
|
||||
band[:, -CLIP_BAND_PX:] = True
|
||||
clip_count = int(((alpha > CLIP_ALPHA_CUT) & band).sum())
|
||||
|
||||
manifest_section.setdefault("qc", {})[name] = {
|
||||
"background_leak_mean_alpha_outside_bbox": round(leak, 5),
|
||||
"background_leak_pass": bool(leak < 0.01),
|
||||
"halo_ratio_edge_pixels": round(halo_ratio, 5),
|
||||
"halo_pass": bool(halo_ratio <= 0.02),
|
||||
"clip_edge_band_px": CLIP_BAND_PX,
|
||||
"clip_alpha_gt_0_05_count": clip_count,
|
||||
"clip_pass": bool(clip_count == 0),
|
||||
"color_clusters_k4": color_clusters,
|
||||
}
|
||||
|
||||
|
||||
def build_preview_backdrops(bud_names, weather_names):
|
||||
backdrops = ["#ECE3D1", "#F1DEC2", "#DCE0E2", "#E6DAD3", "#E2E0D0"]
|
||||
|
||||
def hex_to_rgb(h):
|
||||
h = h.lstrip("#")
|
||||
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
sprite_names = bud_names + weather_names
|
||||
cell_w, cell_h = 360, 460 # 280x420 bud 캔버스가 여백 포함해 들어가도록
|
||||
cols = len(backdrops)
|
||||
rows = len(sprite_names)
|
||||
sheet = Image.new("RGB", (cell_w * cols, cell_h * rows), (255, 255, 255))
|
||||
for r, sname in enumerate(sprite_names):
|
||||
sprite = Image.open(os.path.join(SPRITES_DIR, f"{sname}.png")).convert("RGBA")
|
||||
sw, sh = sprite.size
|
||||
scale = min((cell_w - 24) / sw, (cell_h - 24) / sh, 1.0)
|
||||
disp = sprite.resize((max(1, int(sw * scale)), max(1, int(sh * scale))), Image.LANCZOS)
|
||||
for c, bg_hex in enumerate(backdrops):
|
||||
cell = Image.new("RGB", (cell_w, cell_h), hex_to_rgb(bg_hex))
|
||||
px = (cell_w - disp.width) // 2
|
||||
py = (cell_h - disp.height) // 2
|
||||
cell.paste(disp, (px, py), disp)
|
||||
sheet.paste(cell, (c * cell_w, r * cell_h))
|
||||
|
||||
long_side = max(sheet.size)
|
||||
if long_side > 1400:
|
||||
s = 1400.0 / long_side
|
||||
sheet = sheet.resize((int(sheet.width * s), int(sheet.height * s)), Image.LANCZOS)
|
||||
sheet.convert("RGB").save(os.path.join(PREVIEW_DIR, "sprites-on-backdrops.jpg"), quality=90)
|
||||
|
||||
|
||||
def alpha_composite_over(base_rgb, sprite, opacity=1.0):
|
||||
"""base_rgb: (H,W,3) float bg, sprite: PIL RGBA image same size. opacity 0..1 곱."""
|
||||
sarr = np.asarray(sprite).astype(np.float64)
|
||||
a = (sarr[..., 3] / 255.0) * opacity
|
||||
rgb = sarr[..., :3]
|
||||
out = base_rgb * (1 - a[..., None]) + rgb * a[..., None]
|
||||
return out
|
||||
|
||||
|
||||
def build_bud_crossfade():
|
||||
bg_hex = "#ECE3D1"
|
||||
bg = tuple(int(bg_hex.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
|
||||
closed = Image.open(os.path.join(SPRITES_DIR, "bud-closed.png")).convert("RGBA")
|
||||
half = Image.open(os.path.join(SPRITES_DIR, "bud-half.png")).convert("RGBA")
|
||||
open_ = Image.open(os.path.join(SPRITES_DIR, "bud-open.png")).convert("RGBA")
|
||||
droop = Image.open(os.path.join(SPRITES_DIR, "bud-droop.png")).convert("RGBA")
|
||||
|
||||
w, h = closed.size
|
||||
base = np.tile(np.array(bg, dtype=np.float64), (h, w, 1))
|
||||
|
||||
def blend(a_img, b_img, t):
|
||||
base_layer = alpha_composite_over(base.copy(), a_img, opacity=1.0)
|
||||
out = alpha_composite_over(base_layer, b_img, opacity=t)
|
||||
return out
|
||||
|
||||
cells = []
|
||||
labels = []
|
||||
for t in (0.25, 0.5, 0.75):
|
||||
cells.append(blend(closed, half, t))
|
||||
labels.append(f"closed->half {int(t*100)}%")
|
||||
for t in (0.25, 0.5, 0.75):
|
||||
cells.append(blend(half, open_, t))
|
||||
labels.append(f"half->open {int(t*100)}%")
|
||||
cells.append(blend(closed, droop, 0.5))
|
||||
labels.append("closed->droop 50%")
|
||||
|
||||
pad = 10
|
||||
cell_w, cell_h = w + pad * 2, h + pad * 2
|
||||
cols = 4
|
||||
rows = int(np.ceil(len(cells) / cols))
|
||||
sheet = Image.new("RGB", (cell_w * cols, cell_h * rows), (255, 255, 255))
|
||||
for i, cell_arr in enumerate(cells):
|
||||
cell_img = Image.fromarray(np.clip(cell_arr, 0, 255).astype(np.uint8), mode="RGB")
|
||||
r, c = divmod(i, cols)
|
||||
sheet.paste(cell_img, (c * cell_w + pad, r * cell_h + pad))
|
||||
sheet.save(os.path.join(PREVIEW_DIR, "bud-crossfade.jpg"), quality=90)
|
||||
return labels
|
||||
|
||||
|
||||
def build_bud_sizes():
|
||||
"""4장을 표시 높이 96px, 48px로 줄여 실사용 크기에서 잘림·뭉개짐을 확인한다."""
|
||||
bg_hex = "#ECE3D1"
|
||||
bg = tuple(int(bg_hex.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
|
||||
heights = [96, 48]
|
||||
pad = 12
|
||||
|
||||
row_imgs = []
|
||||
for target_h in heights:
|
||||
cells = []
|
||||
for name in BUD_PANEL_NAMES:
|
||||
sprite = Image.open(os.path.join(SPRITES_DIR, f"{name}.png")).convert("RGBA")
|
||||
sw, sh = sprite.size
|
||||
new_w = max(1, round(sw * target_h / sh))
|
||||
disp = sprite.resize((new_w, target_h), Image.LANCZOS)
|
||||
cell = Image.new("RGB", (new_w + pad * 2, target_h + pad * 2), bg)
|
||||
cell.paste(disp, (pad, pad), disp)
|
||||
cells.append(cell)
|
||||
row_w = sum(c.width for c in cells)
|
||||
row_h = max(c.height for c in cells)
|
||||
row = Image.new("RGB", (row_w, row_h), bg)
|
||||
x = 0
|
||||
for c in cells:
|
||||
row.paste(c, (x, 0))
|
||||
x += c.width
|
||||
row_imgs.append(row)
|
||||
|
||||
total_w = max(r.width for r in row_imgs)
|
||||
total_h = sum(r.height for r in row_imgs) + pad
|
||||
sheet = Image.new("RGB", (total_w, total_h), (255, 255, 255))
|
||||
y = 0
|
||||
for r in row_imgs:
|
||||
sheet.paste(r, (0, y))
|
||||
y += r.height + pad
|
||||
sheet.save(os.path.join(PREVIEW_DIR, "bud-sizes.jpg"), quality=90)
|
||||
|
||||
|
||||
def build_sheets_raw_small():
|
||||
imgs = []
|
||||
for name in ["bud-sheet.png", "weather-sheet.png"]:
|
||||
im = Image.open(os.path.join(RAW_DIR, name)).convert("RGB")
|
||||
w, h = im.size
|
||||
scale = 700.0 / w
|
||||
im = im.resize((700, int(h * scale)), Image.LANCZOS)
|
||||
imgs.append(im)
|
||||
total_h = sum(i.height for i in imgs) + 10
|
||||
max_w = max(i.width for i in imgs)
|
||||
sheet = Image.new("RGB", (max_w, total_h), (255, 255, 255))
|
||||
y = 0
|
||||
for im in imgs:
|
||||
sheet.paste(im, (0, y))
|
||||
y += im.height + 10
|
||||
sheet.save(os.path.join(PREVIEW_DIR, "sheets-raw-small.jpg"), quality=90)
|
||||
|
||||
|
||||
def main():
|
||||
manifest = {"bud": {}, "weather": {}}
|
||||
|
||||
bud_panels, bud_paper = process_sheet("bud-sheet.png", 4, "bud", manifest)
|
||||
weather_panels, weather_paper = process_sheet("weather-sheet.png", 5, "weather", manifest)
|
||||
|
||||
build_bud_sprites(bud_panels, bud_paper, manifest)
|
||||
build_weather_sprites(weather_panels, weather_paper, manifest)
|
||||
|
||||
for entry in manifest["bud"]["sprites"]:
|
||||
qc_metrics(entry["name"], os.path.join(MOTIF_DIR, entry["path"]), bud_paper, manifest["bud"])
|
||||
for entry in manifest["weather"]["sprites"]:
|
||||
qc_metrics(entry["name"], os.path.join(MOTIF_DIR, entry["path"]), weather_paper, manifest["weather"])
|
||||
|
||||
bud_names = BUD_PANEL_NAMES
|
||||
weather_names = WEATHER_PANEL_NAMES
|
||||
build_preview_backdrops(bud_names, weather_names)
|
||||
crossfade_labels = build_bud_crossfade()
|
||||
build_bud_sizes()
|
||||
build_sheets_raw_small()
|
||||
|
||||
manifest["preview"] = {
|
||||
"sprites_on_backdrops": "preview/sprites-on-backdrops.jpg",
|
||||
"bud_crossfade": "preview/bud-crossfade.jpg",
|
||||
"bud_crossfade_cells": crossfade_labels,
|
||||
"bud_sizes": "preview/bud-sizes.jpg",
|
||||
"sheets_raw_small": "preview/sheets-raw-small.jpg",
|
||||
}
|
||||
|
||||
with open(MANIFEST_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print("done")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue