kiwi/scripts/logo/composite.py
pyr0ball c18bfec8f5
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
fix(lint): add ruff config, fix all lint errors for GitHub CI
- pyproject.toml: add [tool.ruff] config suppressing E402/W293 globally,
  F841/E741/E702 in tests, E741 in scripts
- inventory.py: split semicolon import (E702)
- recipes.py: fix logger -> log (F821 undefined name)
- shopping.py: rename l -> lnk in list comprehension (E741)
- format_conversion.py: noqa F841 on CUDA flag (used as future hook)
- backfill_keywords.py: rename done -> _done (F841)
- ingest_purplecarrot.py: drop == True comparison (E712)
- Auto-fix: I001 import sorting, F401 unused imports across all files
2026-07-06 02:49:02 -07:00

129 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""
Kiwi logo compositor.
Takes:
- A kiwifruit disc SVG (generated by generate_disc.py)
- A kiwi bird PNG (AI-generated via ComfyUI img2img then PIL-corrected)
Composites bird over disc, removes white background, outputs icon sizes.
Usage:
conda run -n cf python3 scripts/logo/composite.py \
--disc scripts/logo/kiwi_disc.svg \
--bird scripts/logo/kiwi_bird_final.png \
--out frontend/public/icons/
Requirements:
cairosvg, Pillow, numpy
"""
import argparse
import io
from pathlib import Path
import cairosvg
import numpy as np
from PIL import Image, ImageDraw
SIZES = [512, 256, 192, 64]
def remove_background(bird_img: Image.Image) -> Image.Image:
"""Remove white/gray background from AI-generated bird PNG."""
arr = np.array(bird_img, dtype=np.float32)
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
brightness = (r + g + b) / 3.0
saturation = np.max([r, g, b], axis=0) - np.min([r, g, b], axis=0)
near_white = (brightness > 228) & (saturation < 28)
# Remove gray ground shadow in bottom 30% of image only
h = arr.shape[0]
gray_shadow = np.zeros(arr.shape[:2], dtype=bool)
gray_shadow[int(h * 0.70):, :] = (
(brightness[int(h * 0.70):, :] > 135) &
(saturation[int(h * 0.70):, :] < 65)
)
gray_shadow2 = np.zeros(arr.shape[:2], dtype=bool)
gray_shadow2[int(h * 0.78):, :] = (
(brightness[int(h * 0.78):, :] > 110) &
(saturation[int(h * 0.78):, :] < 75)
)
transparent = near_white | gray_shadow | gray_shadow2
# Soft edge
soft = (brightness > 205) & (saturation < 55) & ~transparent
soft_alpha = np.where(soft, ((255.0 - brightness) / 50.0) * 255.0, 0.0)
new_alpha = np.where(transparent, 0.0, np.where(soft, soft_alpha, 255.0))
arr[:, :, 3] = np.clip(new_alpha, 0, 255)
return Image.fromarray(arr.astype(np.uint8))
def composite(disc_svg: Path, bird_png: Path, out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
# Render disc at max size
max_size = max(SIZES)
disc_bytes = cairosvg.svg2png(url=str(disc_svg), output_width=max_size, output_height=max_size)
disc = Image.open(io.BytesIO(disc_bytes)).convert("RGBA")
# Load and de-background bird
bird_raw = Image.open(bird_png).convert("RGBA")
bird_nobg = remove_background(bird_raw)
bbox = bird_nobg.getbbox()
bird_crop = bird_nobg.crop(bbox)
# Scale bird to 80% of disc diameter
target_w = int(max_size * 0.80)
target_h = int(bird_crop.height * target_w / bird_crop.width)
bird_scaled = bird_crop.resize((target_w, target_h), Image.LANCZOS)
# Composite centered, slightly raised and shifted right (beak faces left)
cx, cy = max_size // 2, max_size // 2
x_off = cx - bird_scaled.width // 2 + int(max_size * 0.06)
y_off = cy - bird_scaled.height // 2 - int(max_size * 0.04)
result = disc.copy()
result.paste(bird_scaled, (x_off, y_off), bird_scaled)
# Draw eye post-composite (eye was at 305,335 in 768px bird space)
# Eye was at pixel (305, 335) in original 768px bird image
# Adjust for crop bbox offset, then scale to composite space
sx = target_w / bird_crop.width
sy = target_h / bird_crop.height
# Known eye coords from PIL correction pass (EX=305, EY=335 in 768px space)
eye_x = int(x_off + (305 - bbox[0]) * sx)
eye_y = int(y_off + (335 - bbox[1]) * sy)
draw = ImageDraw.Draw(result)
draw.ellipse([eye_x - 7, eye_y - 7, eye_x + 7, eye_y + 7], fill=(240, 235, 220, 255))
draw.ellipse([eye_x - 3, eye_y - 3, eye_x + 3, eye_y + 3], fill=(12, 5, 2, 255))
# Export all sizes
for size in SIZES:
out_path = out_dir / f"icon-{size}.png"
resized = result.resize((size, size), Image.LANCZOS)
resized.save(out_path)
print(f" Saved {out_path} ({size}px)")
# Maskable copies (same image — kiwi disc has safe-zone padding built in)
for size in [192, 512]:
src = out_dir / f"icon-{size}.png"
dst = out_dir / f"maskable-{size}.png"
dst.write_bytes(src.read_bytes())
print(f" Copied {dst}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Kiwi logo compositor")
parser.add_argument("--disc", type=Path, default=Path("scripts/logo/kiwi_disc.svg"))
parser.add_argument("--bird", type=Path, default=Path("scripts/logo/kiwi_bird_final.png"))
parser.add_argument("--out", type=Path, default=Path("frontend/public/icons"))
args = parser.parse_args()
print(f"Compositing {args.bird.name} onto {args.disc.name}{args.out}/")
composite(args.disc, args.bird, args.out)
print("Done.")