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,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()