P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷) - P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지 - 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치 - MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
600
docs/avatar-art/p1-linocut/motif/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
docs/avatar-art/p1-linocut/motif/preview/bud-crossfade.jpg
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
docs/avatar-art/p1-linocut/motif/preview/bud-sizes.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/avatar-art/p1-linocut/motif/preview/sheets-raw-small.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 112 KiB |
1
docs/avatar-art/p1-linocut/motif/prompts/bud-sheet.txt
Normal 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.
|
||||
|
|
@ -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.
|
||||
BIN
docs/avatar-art/p1-linocut/motif/raw/bud-sheet.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/avatar-art/p1-linocut/motif/raw/weather-sheet.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
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()
|
||||
BIN
docs/avatar-art/p1-linocut/motif/sprites/bud-closed.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/bud-droop.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/bud-half.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/bud-open.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-cognitive.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-defensive.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-energy.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-negative.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/avatar-art/p1-linocut/motif/sprites/weather-positive.png
Normal file
|
After Width: | Height: | Size: 65 KiB |