slice_bb_mascot.py 2.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  1. """Re-slice BB mascot poses from the character sheet.
  2. Produces the per-pose webp files consumed by MascotIcon (the onboarding tour).
  3. Source: screenshots/onboarding/bb_bambuddy.webp (RGB, no alpha, opaque bg)
  4. Output: frontend/public/img/bb_{hero,started,walk,almost,allset,help}.webp
  5. Two things matter and must not regress:
  6. - the crop excludes the caption text under each pose, so the tour modal
  7. never shows a sliver of "Almost there!" under the character;
  8. - the background is keyed out to alpha so the mascot composites cleanly
  9. on the dark-theme tour card.
  10. Pose coordinates are pixel offsets in the source sheet, derived once by
  11. column/row density analysis. If the character sheet is re-exported with
  12. different dimensions, re-derive them rather than nudging by eye.
  13. """
  14. from pathlib import Path
  15. import numpy as np
  16. from PIL import Image
  17. REPO_ROOT = Path(__file__).resolve().parent.parent
  18. SRC = REPO_ROOT / "screenshots/bb images/bb_bambuddy.webp"
  19. OUT_DIR = REPO_ROOT / "frontend/public/img"
  20. POSE_Y = (746, 924)
  21. POSES = {
  22. "started": (35, 206),
  23. "walk": (251, 423),
  24. "almost": (455, 631),
  25. "allset": (657, 851),
  26. "help": (861, 1079),
  27. }
  28. HERO_BOX = (50, 71, 620, 650)
  29. # Background ramp: pixels with min-channel >= BG_FULL go fully transparent,
  30. # pixels with min-channel <= INK stay fully opaque, in between alpha ramps
  31. # linearly so anti-aliased outlines keep their feathering.
  32. BG_FULL = 228
  33. INK = 200
  34. def keyout_to_alpha(crop: Image.Image) -> Image.Image:
  35. arr = np.array(crop.convert("RGB"))
  36. min_ch = arr.min(axis=2).astype(np.int32)
  37. alpha = np.clip((BG_FULL - min_ch) * 255 // (BG_FULL - INK), 0, 255).astype(np.uint8)
  38. return Image.fromarray(np.dstack([arr, alpha]), mode="RGBA")
  39. def save_webp_lossless(im: Image.Image, path: Path) -> None:
  40. im.save(path, "WEBP", lossless=True, quality=100, method=6)
  41. def main() -> None:
  42. src = Image.open(SRC)
  43. for name, (x0, x1) in POSES.items():
  44. crop = src.crop((x0, POSE_Y[0], x1, POSE_Y[1]))
  45. out_path = OUT_DIR / f"bb_{name}.webp"
  46. save_webp_lossless(keyout_to_alpha(crop), out_path)
  47. print(f" {out_path.name} {crop.size}")
  48. hero_crop = src.crop(HERO_BOX)
  49. save_webp_lossless(keyout_to_alpha(hero_crop), OUT_DIR / "bb_hero.webp")
  50. print(f" bb_hero.webp {hero_crop.size}")
  51. if __name__ == "__main__":
  52. main()