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