- 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
129 lines
4.6 KiB
Python
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.")
|