bambuddy/scripts/slice_bb_mascot.py
maziggy a5d75cdbf3 fix(onboarding): step counter ignores skipped steps; transparent + tight BB poses
Three bugs in the tour shipped in the previous commit:

    - The step counter displayed raw index over total, so when add-printer
      auto-skipped (printer already configured), the first visible step
      rendered as "2 / 25". Counter now shows visible position over visible
      total — that user sees "1 / 24". Same trick handles every other
      skipIf-gated step (makerworld permission, sso/users when auth off).

    - The BB mascot crops were lossy webp with no alpha, so the off-white
      sheet background showed as a hard rectangle against the dark-theme
      tour card. Re-sliced from the source character sheet as lossless
      webp RGBA with a soft alpha ramp on the background, so the mascot
      composites cleanly.

    - The same crops included the caption text below each pose ("Almost
      there!" etc.), visible as a sliver under the character. Tightened
      the Y range to exclude the caption row.

    scripts/slice_bb_mascot.py captures the pose coordinates and the
    keyout ramp so a future character-sheet re-export is reproducible
    rather than nudged by eye.
2026-06-10 10:17:15 +02:00

70 lines
2.3 KiB
Python

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