from __future__ import annotations import argparse from pathlib import Path from PIL import Image, ImageChops ICON_SIZES = (16, 20, 24, 32, 40, 48, 64, 128, 256) def remove_green_background(image: Image.Image) -> Image.Image: rgba = image.convert("RGBA") pixels = rgba.load() width, height = rgba.size for y in range(height): for x in range(width): r, g, b, a = pixels[x, y] green_score = g - max(r, b) is_key = g > 90 and green_score > 35 is_fringe = g > 32 and g > r * 1.35 and g > b * 1.20 if is_key or is_fringe: edge = max(0, min(255, (green_score - 18) * 5)) alpha = max(0, 255 - edge) if alpha < 56: pixels[x, y] = (0, 0, 0, 0) else: despilled_g = min(g, max(r, b)) pixels[x, y] = (r, despilled_g, b, min(a, alpha)) return rgba def crop_to_alpha(image: Image.Image, padding_ratio: float) -> Image.Image: alpha = image.getchannel("A") bbox = alpha.getbbox() if not bbox: raise ValueError("source image has no visible pixels after background removal") cropped = image.crop(bbox) side = max(cropped.size) padding = round(side * padding_ratio) canvas_side = side + padding * 2 canvas = Image.new("RGBA", (canvas_side, canvas_side), (0, 0, 0, 0)) canvas.alpha_composite(cropped, ((canvas_side - cropped.width) // 2, (canvas_side - cropped.height) // 2)) return canvas def trim_transparent_padding(image: Image.Image) -> Image.Image: empty = Image.new("RGBA", image.size, (0, 0, 0, 0)) bbox = ImageChops.difference(image, empty).getbbox() if bbox is None: return image return image.crop(bbox) def build_icons(source: Path, out_dir: Path, padding_ratio: float) -> None: out_dir.mkdir(parents=True, exist_ok=True) source_image = Image.open(source) transparent = remove_green_background(source_image) master = crop_to_alpha(transparent, padding_ratio) master = trim_transparent_padding(master) master_path = out_dir / "tray-icon.png" master.save(master_path) resized_images: list[Image.Image] = [] for size in ICON_SIZES: resized = master.resize((size, size), Image.Resampling.LANCZOS) resized.save(out_dir / f"tray-icon-{size}.png") resized_images.append(resized) ico_path = out_dir / "tray.ico" resized_images[-1].save( ico_path, format="ICO", sizes=[(size, size) for size in ICON_SIZES], append_images=resized_images[:-1], ) print(f"wrote {master_path}") print(f"wrote {ico_path}") def main() -> None: parser = argparse.ArgumentParser(description="Build u-desktop tray icon assets from a generated source image.") parser.add_argument("--source", type=Path, required=True, help="Generated source PNG on a green chroma-key background.") parser.add_argument("--out-dir", type=Path, default=Path("assets/icons"), help="Directory for PNG sizes and tray.ico.") parser.add_argument("--padding", type=float, default=0.04, help="Transparent padding ratio around the cropped icon.") args = parser.parse_args() build_icons(args.source, args.out_dir, args.padding) if __name__ == "__main__": main()