from __future__ import annotations from pathlib import Path from PIL import Image ROOT = Path(__file__).resolve().parents[1] SHEET_PATH = ROOT / "assets" / "generated" / "star_action_sheet.png" OUT_DIR = ROOT / "assets" / "characters" / "star" CANVAS_SIZE = (900, 1200) POSES = [ "idle", "listening", "thinking", "worried", "smile", "happy", "focus", "talk", ] def _remove_green_screen(image: Image.Image) -> Image.Image: image = image.convert("RGBA") pixels = image.load() width, height = image.size for y in range(height): for x in range(width): red, green, blue, alpha = pixels[x, y] is_green_screen = green > 110 and green > red * 1.28 and green > blue * 1.12 is_green_edge = green > 80 and green > red * 1.45 and green > blue * 1.25 if is_green_screen or is_green_edge: pixels[x, y] = (red, green, blue, 0) elif alpha: spill = max(0, green - max(red, blue) - 24) if spill: pixels[x, y] = (red, max(0, green - spill), blue, alpha) return _remove_right_edge_artifacts(image) def _remove_right_edge_artifacts(image: Image.Image) -> Image.Image: alpha = image.getchannel("A") width, height = image.size seen: set[tuple[int, int]] = set() pixels_to_clear: list[tuple[int, int]] = [] for y in range(height): for x in range(width): if (x, y) in seen or alpha.getpixel((x, y)) < 12: continue stack = [(x, y)] seen.add((x, y)) component: list[tuple[int, int]] = [] min_x = max_x = x while stack: px, py = stack.pop() component.append((px, py)) min_x = min(min_x, px) max_x = max(max_x, px) for nx, ny in ((px - 1, py), (px + 1, py), (px, py - 1), (px, py + 1)): if nx < 0 or ny < 0 or nx >= width or ny >= height or (nx, ny) in seen: continue if alpha.getpixel((nx, ny)) >= 12: seen.add((nx, ny)) stack.append((nx, ny)) touches_right_edge = max_x >= width - 2 starts_on_right_side = min_x > width * 0.55 if len(component) < 16 or (touches_right_edge and starts_on_right_side): pixels_to_clear.extend(component) if not pixels_to_clear: return image pixels = image.load() for x, y in pixels_to_clear: red, green, blue, _alpha = pixels[x, y] pixels[x, y] = (red, green, blue, 0) return image def _fit_to_stage_canvas(image: Image.Image) -> Image.Image: alpha = image.getchannel("A") bbox = alpha.getbbox() if bbox is None: return Image.new("RGBA", CANVAS_SIZE, (0, 0, 0, 0)) crop = image.crop(bbox) crop_width, crop_height = crop.size max_width = 760 target_height = 1080 scale = min(target_height / crop_height, max_width / crop_width) new_size = (round(crop_width * scale), round(crop_height * scale)) crop = crop.resize(new_size, Image.Resampling.LANCZOS) canvas = Image.new("RGBA", CANVAS_SIZE, (0, 0, 0, 0)) x = (CANVAS_SIZE[0] - new_size[0]) // 2 y = CANVAS_SIZE[1] - new_size[1] - 24 canvas.alpha_composite(crop, (x, y)) return canvas def main() -> None: sheet = Image.open(SHEET_PATH) cell_width = sheet.width // 4 cell_height = sheet.height // 2 OUT_DIR.mkdir(parents=True, exist_ok=True) for index, pose in enumerate(POSES): row, col = divmod(index, 4) cell = sheet.crop( ( col * cell_width, row * cell_height, (col + 1) * cell_width, (row + 1) * cell_height, ) ) stage_asset = _fit_to_stage_canvas(_remove_green_screen(cell)) if pose == "focus": _clear_box(stage_asset, x_min=460, y_min=360) out_path = OUT_DIR / f"{pose}.png" stage_asset.save(out_path) print(out_path.relative_to(ROOT)) def _clear_box(image: Image.Image, x_min: int, y_min: int) -> None: pixels = image.load() for y in range(y_min, image.height): for x in range(x_min, image.width): red, green, blue, _alpha = pixels[x, y] pixels[x, y] = (red, green, blue, 0) if __name__ == "__main__": main()