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,600 @@
{
"bud": {
"raw_sheet": {
"file": "raw/bud-sheet.png",
"size": [
1881,
836
],
"paper_color_rgb": [
238.0,
229.0,
211.0
],
"panel_gap_widths_px": [
209,
217,
199
],
"panel_col_ranges": [
[
0,
483
],
[
483,
937
],
[
937,
1395
],
[
1395,
1881
]
]
},
"sprites": [
{
"name": "bud-closed",
"path": "sprites/bud-closed.png",
"canvas": [
280,
420
],
"stem_bottom_anchor_px": [
134.53,
398.67
]
},
{
"name": "bud-half",
"path": "sprites/bud-half.png",
"canvas": [
280,
420
],
"stem_bottom_anchor_px": [
134.42,
399.08
]
},
{
"name": "bud-open",
"path": "sprites/bud-open.png",
"canvas": [
280,
420
],
"stem_bottom_anchor_px": [
134.0,
398.69
]
},
{
"name": "bud-droop",
"path": "sprites/bud-droop.png",
"canvas": [
280,
420
],
"stem_bottom_anchor_px": [
134.0,
398.87
]
}
],
"scale_factor": 0.7677,
"canvas": [
280,
420
],
"base_anchor_px": [
134.23,
399.0
],
"union_bbox_native_px": {
"up": 488.94,
"down": 3.44,
"left": 127.52,
"right": 142.56
},
"stem_bottom_alignment_max_dev_px": 0.571,
"qc": {
"bud-closed": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
69.8,
89.1,
98.8
],
"pixel_count": 12879
},
{
"center_rgb": [
113.2,
123.9,
124.7
],
"pixel_count": 2595
},
{
"center_rgb": [
36.4,
47.8,
53.2
],
"pixel_count": 2251
},
{
"center_rgb": [
186.3,
183.5,
171.5
],
"pixel_count": 1690
}
]
},
"bud-half": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
74.3,
93.4,
102.6
],
"pixel_count": 13550
},
{
"center_rgb": [
202.9,
158.2,
93.2
],
"pixel_count": 3028
},
{
"center_rgb": [
37.6,
49.5,
55.3
],
"pixel_count": 2227
},
{
"center_rgb": [
180.0,
177.0,
163.3
],
"pixel_count": 2159
}
]
},
"bud-open": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
77.8,
96.4,
105.5
],
"pixel_count": 10746
},
{
"center_rgb": [
206.9,
162.3,
95.9
],
"pixel_count": 7984
},
{
"center_rgb": [
46.4,
61.9,
69.8
],
"pixel_count": 2847
},
{
"center_rgb": [
196.8,
184.8,
160.1
],
"pixel_count": 2724
}
]
},
"bud-droop": {
"background_leak_mean_alpha_outside_bbox": 1e-05,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
68.5,
87.9,
97.7
],
"pixel_count": 10902
},
{
"center_rgb": [
105.3,
117.9,
120.5
],
"pixel_count": 2961
},
{
"center_rgb": [
187.0,
174.1,
148.9
],
"pixel_count": 2295
},
{
"center_rgb": [
33.0,
42.5,
46.7
],
"pixel_count": 2111
}
]
}
}
},
"weather": {
"raw_sheet": {
"file": "raw/weather-sheet.png",
"size": [
1774,
887
],
"paper_color_rgb": [
236.0,
227.0,
207.0
],
"panel_gap_widths_px": [
47,
40,
56,
47
],
"panel_col_ranges": [
[
0,
351
],
[
351,
708
],
[
708,
1146
],
[
1146,
1413
],
[
1413,
1774
]
]
},
"sprites": [
{
"name": "weather-positive",
"path": "sprites/weather-positive.png",
"canvas": [
320,
200
],
"scale_factor": 0.6308
},
{
"name": "weather-negative",
"path": "sprites/weather-negative.png",
"canvas": [
320,
200
],
"scale_factor": 0.6048
},
{
"name": "weather-defensive",
"path": "sprites/weather-defensive.png",
"canvas": [
320,
200
],
"scale_factor": 0.7258
},
{
"name": "weather-energy",
"path": "sprites/weather-energy.png",
"canvas": [
320,
200
],
"scale_factor": 0.8421
},
{
"name": "weather-cognitive",
"path": "sprites/weather-cognitive.png",
"canvas": [
320,
200
],
"scale_factor": 0.9644
}
],
"qc": {
"weather-positive": {
"background_leak_mean_alpha_outside_bbox": 1e-05,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
205.8,
156.1,
85.0
],
"pixel_count": 3199
},
{
"center_rgb": [
198.8,
144.6,
68.4
],
"pixel_count": 3114
},
{
"center_rgb": [
213.4,
172.2,
111.1
],
"pixel_count": 1251
},
{
"center_rgb": [
223.2,
193.9,
146.5
],
"pixel_count": 1072
}
]
},
"weather-negative": {
"background_leak_mean_alpha_outside_bbox": 3e-05,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
91.2,
110.9,
120.0
],
"pixel_count": 5253
},
{
"center_rgb": [
67.8,
89.2,
101.3
],
"pixel_count": 4246
},
{
"center_rgb": [
133.2,
142.6,
142.6
],
"pixel_count": 2003
},
{
"center_rgb": [
182.2,
182.6,
173.5
],
"pixel_count": 1672
}
]
},
"weather-defensive": {
"background_leak_mean_alpha_outside_bbox": 7e-05,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
72.5,
91.5,
103.1
],
"pixel_count": 6508
},
{
"center_rgb": [
96.1,
112.7,
120.3
],
"pixel_count": 6443
},
{
"center_rgb": [
133.0,
142.1,
141.8
],
"pixel_count": 2762
},
{
"center_rgb": [
180.8,
181.7,
172.9
],
"pixel_count": 2254
}
]
},
"weather-energy": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
205.2,
156.9,
86.9
],
"pixel_count": 3074
},
{
"center_rgb": [
198.0,
145.1,
70.7
],
"pixel_count": 3008
},
{
"center_rgb": [
213.5,
173.5,
113.1
],
"pixel_count": 1275
},
{
"center_rgb": [
224.1,
195.8,
149.9
],
"pixel_count": 949
}
]
},
"weather-cognitive": {
"background_leak_mean_alpha_outside_bbox": 0.0,
"background_leak_pass": true,
"halo_ratio_edge_pixels": 0.0,
"halo_pass": true,
"clip_edge_band_px": 3,
"clip_alpha_gt_0_05_count": 0,
"clip_pass": true,
"color_clusters_k4": [
{
"center_rgb": [
135.7,
147.7,
152.0
],
"pixel_count": 2387
},
{
"center_rgb": [
119.6,
134.0,
142.1
],
"pixel_count": 2284
},
{
"center_rgb": [
158.2,
165.5,
163.8
],
"pixel_count": 1300
},
{
"center_rgb": [
185.9,
187.9,
179.6
],
"pixel_count": 1244
}
]
}
}
},
"preview": {
"sprites_on_backdrops": "preview/sprites-on-backdrops.jpg",
"bud_crossfade": "preview/bud-crossfade.jpg",
"bud_crossfade_cells": [
"closed->half 25%",
"closed->half 50%",
"closed->half 75%",
"half->open 25%",
"half->open 50%",
"half->open 75%",
"closed->droop 50%"
],
"bud_sizes": "preview/bud-sizes.jpg",
"sheets_raw_small": "preview/sheets-raw-small.jpg"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -0,0 +1 @@
A linocut relief print sprite sheet on plain flat cream paper (#EEE5D3). The background must be completely flat and empty: no paper texture, no border, no frame, no text, no signature. Four separate panels in one horizontal row with wide empty space between them, each showing the SAME small symbolic plant at the SAME scale. In every panel the bottom end of the stem sits at exactly the same height near the bottom edge and is horizontally centered in its panel. The plant is a single slender dark ink stem with two slate-blue leaves carved with a few white gouge lines, and one flower bud at the top. Panel 1: the bud tightly closed and upright, slate blue with a thin ochre tip. Panel 2: the same bud half open and upright, ochre petals just parting. Panel 3: the flower fully open and upright, five ochre petals spread with carved lines. Panel 4: the closed bud drooping, the upper stem bent so the bud hangs down beside the stem, leaves slightly lowered. Style exactly like the plant, cloud and sun motifs in the attached reference image: bold carved ink outlines, flat colors limited to slate blue (#53626C), ochre (#D0A362) and ink black, slight print misregistration and ink grain inside the shapes only. Nothing touches the panel edges and nothing overlaps between panels. No people.

View file

@ -0,0 +1 @@
A linocut relief print sprite sheet on plain flat cream paper (#EEE5D3). The background must be completely flat and empty: no paper texture, no border, no frame, no text. Five separate small weather symbols in one horizontal row with wide empty space between them, each roughly the same width, nothing overlapping: 1) a round ochre sun with short carved light rays; 2) a slate-blue rain cloud with slanted rain streaks falling below it; 3) a heavy low fog bank: a flat slate-grey cloud with horizontal carved fog lines beneath it; 4) a thin ochre crescent moon with two or three tiny four-pointed stars; 5) a very faint haze: three or four soft horizontal carved wisps in pale slate blue. Style exactly like the clouds, rain and sun in the attached reference image: bold carved edges, gouge marks and white carved lines inside the shapes, flat colors limited to slate blue (#53626C), ochre (#D0A362) and ink black, slight ink grain inside the shapes only. Symbols only: no plants, no people.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB