agents/openai.yaml
interface: display_name: "Custom Icons" short_description: "Create custom SVG and transparent raster icons" default_prompt: "Use $custom-icons to create a cohesive custom icon set for my project."
jkc66/custom-icons-skill · GitHub
Create or refine custom icon assets as native or traced SVGs and transparent PNG/WebP files. Use when the user asks for a bespoke icon or cohesive icon set, wants an image traced into a clean vector, or needs a detailed or 3D icon with transparency.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add jkc66/custom-icons-skill --skill custom-icons설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
agents/openai.yamlinterface: display_name: "Custom Icons" short_description: "Create custom SVG and transparent raster icons" default_prompt: "Use $custom-icons to create a cohesive custom icon set for my project."
references/native-vector-workflow.md# Native vector workflow Use this branch for an existing SVG, deterministic geometric/UI artwork, or an SVG illustration whose fills, gradients, masks, and effects can express the requested appearance faithfully. ## Preserve the contract Inspect the target project's existing icon conventions before editing or creating artwork. Match its `viewBox`, rendered width and height, fill/stroke model, stroke width, caps, joins, corner radius, optical padding, naming, and color behavior. Preserve an existing SVG's IDs and geometry unless the request requires changing them. For a new icon without project conventions, use a square `24×24` coordinate system for simple UI artwork or `1024×1024` for detailed illustration. Use the exact contracted color; use `currentColor` only when the user or target system expects inherited color. ## Build self-contained SVG - Express geometry with SVG paths and primitives; keep repeated geometry intentional and readable. - Keep gradients, masks, clip paths, and filters local to the file with unique IDs and local `#id` references. - Include a `viewBox` and explicit matching width and height. Keep artwork inside the viewBox with the contracted optical padding. - Preserve transparency where the appearance requires it. - Keep the delivered SVG self-contained: embed no raster image, script, remote resource, external stylesheet, font dependency, or reference to another file. For a set, reuse one shared artboard, geometry grammar, stroke/detail weight, palette behavior, and optical bounds. ## Validate and preview ```bash python3 "<skill-dir>/scripts/validate_icon.py" \ "<workspace>/<asset-name>.svg" ``` Render the SVG at the smallest intended size and at a larger inspection size. Confirm the subject, geometry, effects, color, transparency, clipping, and padding against the asset contract. For a set, validate every SVG together with `--require-matching-canvas` and compare the rendered row as one family.
references/raster-workflow.md# Transparent raster workflow Use this branch for 3D, multicolor, shaded, textured, or highly detailed icons whose appearance depends on continuous tone. Deliver PNG or lossless WebP with an alpha channel. ## Choose a key color Select a saturated flat background color that is absent from the subject and its edge colors. Green `#00ff00` is a useful default; use magenta `#ff00ff` for green subjects, or another distant hue when both appear in the palette. Record the selected key color with the source. ## Create or edit the source Use a supplied image as the edit target when one exists. Otherwise, generate a high-resolution raster using this prompt contract: ```text Asset: one transparent-ready raster icon Subject: [exact subject and distinguishing features] Style: [style anchor, material, lighting, perspective] Composition: one centered isolated subject, square canvas, even optical padding, clear silhouette at [smallest target size] Palette: [contracted subject colors]; exclude the background key color from the subject Backdrop: perfectly uniform solid [key color] extending to every edge Preserve from references: [identity, silhouette, proportions, composition, or other invariants] Exclude: floor plane, backdrop shadows, reflections on the backdrop, gradients or texture in the backdrop, scenery, unintended text, signatures, unrequested marks, and watermarks ``` Generate one icon per image. For a set, repeat the pilot's camera, lighting, material, palette, optical size, and padding language and use the pilot as a style reference when supported. Accept the source only when the background is uniform at all four corners, the subject does not use the key color, and no intended shadow or reflection merges into the backdrop. ## Remove the key Resolve `<skill-dir>` from the active `SKILL.md` and `<workspace>` from the main workflow. ```bash python3 "<skill-dir>/scripts/remove_chroma_key.py" \ --input "<source-image>" \ --out "<workspace>/<asset-name>.png" \ --auto-key border \ --soft-matte \ --spill-cleanup \ --edge-feather 0.5 \ --force python3 "<skill-dir>/scripts/validate_icon.py" \ "<workspace>/<asset-name>.png" \ --require-transparent-corners ``` Use a `.webp` output path when lossless WebP is contracted; the utility preserves alpha and writes WebP losslessly. If the source already has clean transparency, preserve it and skip chroma-key removal. For an exact requested size or a set whose source dimensions differ, normalize each transparent result to one shared canvas: ```bash python3 "<skill-dir>/scripts/normalize_raster_icon.py" \ --input "<transparent-source>" \ --out "<workspace>/<asset-name>.png" \ --canvas-size "<shared-pixel-size>" \ --padding 12 \ --force ``` ## Tune only when evidence requires it - Add `--edge-contract 1` when a key-colored fringe remains. - Reduce `--edge-feather` toward `0.25` when edges look soft; raise it cautiously when a hard cutout is visible. - Set `--key-color "#rrggbb" --auto-key none` when border sampling selects the wrong color. - Add `--trim --padding 12` only when the source has excessive transparent margins. If one set member is trimmed, normalize every member to one shared square canvas before delivery. - Adjust `--transparent-threshold` and `--opaque-threshold` only after inspecting partial-alpha edges. - Regenerate the source when lighting, texture, or shadows vary across the backdrop. Matte tuning cannot reliably repair a non-uniform background. Keep the smallest change that fixes the observed defect and record the successful parameters in the workspace. ## Validate visually Preview the output over light, dark, and high-contrast backgrounds at the smallest intended size. Confirm transparent corners, a nonempty opaque subject, clean hairline/detail edges, no key-color fringe, no clipped antialiasing, and consistent optical padding across the set. For a set, retain the same square source dimensions rather than trimming each subject to a different canvas, then run: ```bash python3 "<skill-dir>/scripts/validate_icon.py" \ "<asset-1>.png" "<asset-2>.png" "<asset-n>.png" \ --require-transparent-corners \ --require-matching-canvas ```
references/vector-workflow.md# Traced vector workflow Use this branch for monochrome silhouettes, line art, simple decorative artwork, or a raster image that must become SVG. Prefer a direct vector edit for an existing SVG and for deterministic geometry explicitly requested by the user. ## Create the source Use a supplied image as the source when one exists. Otherwise, generate a high-resolution raster using this prompt contract: ```text Asset: one scalable icon source Subject: [exact subject and distinguishing features] Style: [style anchor], monochrome vector-like [silhouette or line art] Geometry: [stroke/detail weight, corner character, symmetry or asymmetry] Composition: one centered isolated subject, square canvas, even optical padding, clear at [smallest target size] Color: solid black artwork on a pure white background Preserve from references: [identity, silhouette, proportions, composition, or other invariants] Exclude: gradients, gray shading, texture, scenery, borders, unintended text, signatures, unrequested marks, and watermarks ``` Generate one icon per image. For a set, repeat the pilot's exact geometry, composition, and padding language and use the pilot as a style reference when the tool supports references. Accept the source only when black/white separation is unambiguous, all intended negative spaces remain open, and the subject is recognizable at the target size. ## Process the source Resolve `<skill-dir>` from the active `SKILL.md` and `<workspace>` from the main workflow. Keep trace drafts separate from the final SVG. ```bash python3 "<skill-dir>/scripts/crop_and_trace.py" \ "<source-image>" "<workspace>" "<asset-name>" \ --threshold 180 --padding 12 potrace "<workspace>/<asset-name>.pbm" \ --svg --flat --tight --turdsize 2 \ --color "#000000" \ --output "<workspace>/<asset-name>.trace.svg" python3 "<skill-dir>/scripts/normalize_svg_icon.py" \ --input "<workspace>/<asset-name>.trace.svg" \ --out "<workspace>/<asset-name>.normalized.svg" \ --canvas-size "<contracted-square-size>" \ --padding "<contracted-padding>" \ --force bunx svgo "<workspace>/<asset-name>.normalized.svg" \ --output "<workspace>/<asset-name>.svg" \ --multipass python3 "<skill-dir>/scripts/validate_icon.py" \ "<workspace>/<asset-name>.svg" ``` Replace `#000000` with the contracted color at the Potrace step. This produces filled path geometry even when the source resembles a stroked line drawing. Use the same square size and padding for every member of a set. Use size `24` with padding `2` for unspecified UI icons; scale both values proportionally for a different coordinate system. If `bunx` cannot create temporary files in the environment, set `TMPDIR` to a writable temporary directory for that command. If SVGO is unavailable but the unoptimized trace validates, retain the trace as an explicitly reported unoptimized fallback rather than blocking delivery. ## Tune only when evidence requires it - Raise `--threshold` when pale antialiasing or fine marks disappear; lower it when gray noise thickens the shape. Keep the value within `0..255`. - Raise Potrace `--turdsize` to remove isolated specks; lower it when intentional tiny details disappear. - Regenerate the source when tracing cannot recover a clean silhouette or open negative space. Tracing is not a substitute for a suitable source. Keep the smallest change that fixes the observed defect and record the successful parameters in the workspace. ## Validate visually Render or preview the final SVG rather than judging XML alone. Compare it with the processed PNG at the smallest intended size. Confirm that the `viewBox` is present, the artwork is not clipped, intended holes remain open, curve simplification has not changed the subject, and the final color matches the contract. For a set, run: ```bash python3 "<skill-dir>/scripts/validate_icon.py" \ "<asset-1>.svg" "<asset-2>.svg" "<asset-n>.svg" \ --require-matching-canvas ```
scripts/crop_and_trace.py#!/usr/bin/env python3
"""Prepare an icon source for Potrace or make a simple chroma-key cutout."""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
from typing import Tuple
Color = Tuple[int, int, int]
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _load_pillow():
try:
from PIL import Image, ImageOps, UnidentifiedImageError
except ImportError:
_die("Pillow is required. Install the Python package 'pillow' in the active environment.")
return Image, ImageOps, UnidentifiedImageError
def _parse_key_color(raw: str) -> Color:
match = re.fullmatch(r"#?([0-9a-fA-F]{6})", raw.strip())
if not match:
_die("--key-color must be a hex RGB value such as #00ff00.")
value = match.group(1)
return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4)) # type: ignore[return-value]
def _validate_args(args: argparse.Namespace) -> None:
source = Path(args.source)
if not source.is_file():
_die(f"Input image not found: {source}")
if not 0 <= args.threshold <= 255:
_die("--threshold must be between 0 and 255.")
if not 0 <= args.tolerance <= 255:
_die("--tolerance must be between 0 and 255.")
if args.padding < 0:
_die("--padding must be zero or greater.")
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", args.name):
_die("name must be a filename-safe basename without path separators.")
if args.key_color and not args.chroma:
_die("--key-color is only valid together with --chroma.")
def _remove_key(image, key: Color, tolerance: int):
pixels = image.load()
width, height = image.size
matched = 0
for y in range(height):
for x in range(width):
red, green, blue, alpha = pixels[x, y]
distance = max(abs(red - key[0]), abs(green - key[1]), abs(blue - key[2]))
if distance <= tolerance:
pixels[x, y] = (0, 0, 0, 0)
matched += 1
else:
pixels[x, y] = (red, green, blue, alpha)
if matched == 0:
_die(
f"No pixels matched key #{key[0]:02x}{key[1]:02x}{key[2]:02x} "
f"at tolerance {tolerance}."
)
return image
def _composite_on_white(image):
Image, _, _ = _load_pillow()
background = Image.new("RGBA", image.size, (255, 255, 255, 255))
return Image.alpha_composite(background, image).convert("RGB")
def _crop_to_bbox(image, bbox, padding: int):
if bbox is None:
_die("No visible icon subject was found in the source image.")
left, top, right, bottom = bbox
return image.crop(
(
max(0, left - padding),
max(0, top - padding),
min(image.width, right + padding),
min(image.height, bottom + padding),
)
)
def process_icon(
source_path: Path,
output_dir: Path,
name: str,
*,
threshold: int,
chroma_key: Color | None,
tolerance: int,
padding: int,
) -> None:
Image, ImageOps, UnidentifiedImageError = _load_pillow()
try:
with Image.open(source_path) as source:
image = source.convert("RGBA")
except (UnidentifiedImageError, OSError) as error:
_die(f"Could not read input image {source_path}: {error}")
if chroma_key is not None:
image = _remove_key(image, chroma_key, tolerance)
bbox = image.getchannel("A").getbbox()
else:
flattened = _composite_on_white(image)
gray = ImageOps.grayscale(flattened)
bbox = gray.point(lambda pixel: 255 if pixel < 245 else 0).getbbox()
image = _crop_to_bbox(image, bbox, padding)
output_dir.mkdir(parents=True, exist_ok=True)
png_path = output_dir / f"{name}.png"
image.save(png_path, format="PNG", optimize=True)
print(f"Wrote {png_path}")
if chroma_key is not None:
print(f"Key color: #{chroma_key[0]:02x}{chroma_key[1]:02x}{chroma_key[2]:02x}")
return
flattened = _composite_on_white(image)
gray = ImageOps.grayscale(flattened)
bitmap = gray.point(lambda pixel: 0 if pixel < threshold else 255, mode="1")
if bitmap.getextrema() == (255, 255):
_die(
"Binarization produced no black foreground pixels. "
"Raise --threshold or use a higher-contrast source."
)
pbm_path = output_dir / f"{name}.pbm"
bitmap.save(pbm_path)
print(f"Wrote {pbm_path}")
print(f"Threshold: {threshold}")
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Crop an icon source and prepare a PBM trace or simple chroma-key PNG."
)
parser.add_argument("source", help="Input image path.")
parser.add_argument("output_dir", help="Directory for generated files.")
parser.add_argument("name", help="Filename-safe basename for generated files.")
parser.add_argument(
"--threshold",
type=int,
default=180,
help="Black/white threshold for vector tracing, 0-255 (default: 180).",
)
parser.add_argument(
"--chroma",
action="store_true",
help="Write a simple transparent cutout instead of a PBM trace source.",
)
parser.add_argument(
"--key-color",
help="Chroma key as hex RGB (default with --chroma: #00ff00).",
)
parser.add_argument(
"--tolerance",
type=int,
default=60,
help="Maximum per-channel chroma distance, 0-255 (default: 60).",
)
parser.add_argument(
"--padding",
type=int,
default=12,
help="Source pixels retained around the detected subject (default: 12).",
)
return parser
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
_validate_args(args)
key = _parse_key_color(args.key_color or "#00ff00") if args.chroma else None
process_icon(
Path(args.source),
Path(args.output_dir),
args.name,
threshold=args.threshold,
chroma_key=key,
tolerance=args.tolerance,
padding=args.padding,
)
if __name__ == "__main__":
main()
scripts/normalize_raster_icon.py#!/usr/bin/env python3
"""Center a transparent raster icon on an exact square canvas."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _load_pillow():
try:
from PIL import Image, UnidentifiedImageError
except ImportError:
_die("Pillow is required. Install the Python package 'pillow' in the active environment.")
return Image, UnidentifiedImageError
def _validate_args(args: argparse.Namespace) -> None:
source = Path(args.input)
if not source.is_file():
_die(f"Input image not found: {source}")
output = Path(args.out)
if output.suffix.lower() not in {".png", ".webp"}:
_die("--out must end in .png or .webp.")
if output.exists() and not args.force:
_die(f"Output already exists: {output} (use --force to overwrite)")
if args.canvas_size <= 0:
_die("--canvas-size must be greater than zero.")
if args.padding < 0:
_die("--padding must be zero or greater.")
if args.padding * 2 >= args.canvas_size:
_die("--padding must leave a positive inner canvas.")
def _normalize(args: argparse.Namespace) -> None:
Image, UnidentifiedImageError = _load_pillow()
source = Path(args.input)
output = Path(args.out)
try:
with Image.open(source) as opened:
image = opened.convert("RGBA")
except (UnidentifiedImageError, OSError) as error:
_die(f"Could not read input image {source}: {error}")
alpha = image.getchannel("A")
alpha_min, alpha_max = alpha.getextrema()
if alpha_max == 0:
_die("Input is fully transparent; no icon subject remains.")
if alpha_min == 255:
_die("Input has no transparent pixels to define an isolated icon subject.")
bbox = alpha.getbbox()
if bbox is None:
_die("Input has no visible alpha bounds.")
subject = image.crop(bbox)
inner_size = args.canvas_size - (args.padding * 2)
scale = min(inner_size / subject.width, inner_size / subject.height)
width = max(1, int(round(subject.width * scale)))
height = max(1, int(round(subject.height * scale)))
subject = subject.resize((width, height), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (args.canvas_size, args.canvas_size), (0, 0, 0, 0))
left = (args.canvas_size - width) // 2
top = (args.canvas_size - height) // 2
canvas.alpha_composite(subject, (left, top))
output.parent.mkdir(parents=True, exist_ok=True)
if output.suffix.lower() == ".webp":
canvas.save(output, format="WEBP", lossless=True, quality=100, method=6)
else:
canvas.save(output, format="PNG", optimize=True)
print(f"Wrote {output}")
print(f"Canvas: {args.canvas_size}x{args.canvas_size}; visible size: {width}x{height}")
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Center a transparent PNG/WebP icon on an exact square canvas."
)
parser.add_argument("--input", required=True, help="Transparent input image.")
parser.add_argument("--out", required=True, help="Output .png or .webp path.")
parser.add_argument(
"--canvas-size",
required=True,
type=int,
help="Square output width and height in pixels.",
)
parser.add_argument(
"--padding",
type=int,
default=12,
help="Minimum transparent padding in output pixels (default: 12).",
)
parser.add_argument("--force", action="store_true", help="Overwrite an existing output file.")
return parser
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
_validate_args(args)
_normalize(args)
if __name__ == "__main__":
main()
scripts/normalize_svg_icon.py#!/usr/bin/env python3
"""Normalize SVG artwork to an exact square viewBox with optical padding."""
from __future__ import annotations
import argparse
import math
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
ROOT_ONLY_ELEMENTS = {"defs", "style", "title", "desc", "metadata"}
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _local_name(tag: object) -> str:
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
def _namespace(tag: str) -> str | None:
if tag.startswith("{") and "}" in tag:
return tag[1:].split("}", 1)[0]
return None
def _parse_viewbox(raw: str | None) -> tuple[float, float, float, float]:
if not raw:
_die("Input SVG is missing a viewBox.")
values = raw.replace(",", " ").split()
if len(values) != 4:
_die("Input SVG viewBox must contain four numbers.")
try:
parsed = tuple(float(value) for value in values)
except ValueError:
_die("Input SVG viewBox contains a non-numeric value.")
if not all(math.isfinite(value) for value in parsed):
_die("Input SVG viewBox values must be finite.")
if parsed[2] <= 0 or parsed[3] <= 0:
_die("Input SVG viewBox width and height must be positive.")
return parsed # type: ignore[return-value]
def _fmt(value: float) -> str:
rounded = round(value, 6)
return f"{rounded:g}"
def _validate_args(args: argparse.Namespace) -> None:
source = Path(args.input)
if not source.is_file():
_die(f"Input SVG not found: {source}")
output = Path(args.out)
if output.suffix.lower() != ".svg":
_die("--out must end in .svg.")
if output.exists() and not args.force:
_die(f"Output already exists: {output} (use --force to overwrite)")
if not math.isfinite(args.canvas_size) or args.canvas_size <= 0:
_die("--canvas-size must be a positive finite number.")
if not math.isfinite(args.padding) or args.padding < 0:
_die("--padding must be a non-negative finite number.")
if args.padding * 2 >= args.canvas_size:
_die("--padding must leave a positive inner canvas.")
def _normalize(args: argparse.Namespace) -> None:
source = Path(args.input)
output = Path(args.out)
try:
tree = ET.parse(source)
except (ET.ParseError, OSError) as error:
_die(f"Could not parse input SVG: {error}")
root = tree.getroot()
if _local_name(root.tag) != "svg":
_die("Input XML root is not <svg>.")
min_x, min_y, width, height = _parse_viewbox(root.get("viewBox"))
inner = args.canvas_size - (args.padding * 2)
scale = min(inner / width, inner / height)
translated_width = width * scale
translated_height = height * scale
translate_x = ((args.canvas_size - translated_width) / 2) - (min_x * scale)
translate_y = ((args.canvas_size - translated_height) / 2) - (min_y * scale)
namespace = _namespace(root.tag)
if namespace:
ET.register_namespace("", namespace)
group_tag = f"{{{namespace}}}g" if namespace else "g"
group = ET.Element(
group_tag,
{
"transform": (
f"translate({_fmt(translate_x)} {_fmt(translate_y)}) "
f"scale({_fmt(scale)})"
)
},
)
movable = [
child for child in list(root) if _local_name(child.tag) not in ROOT_ONLY_ELEMENTS
]
if not movable:
_die("Input SVG contains no movable artwork elements.")
for child in movable:
root.remove(child)
group.append(child)
root.append(group)
size = _fmt(args.canvas_size)
root.set("viewBox", f"0 0 {size} {size}")
root.set("width", size)
root.set("height", size)
root.set("preserveAspectRatio", "xMidYMid meet")
output.parent.mkdir(parents=True, exist_ok=True)
tree.write(output, encoding="utf-8", xml_declaration=True)
print(f"Wrote {output}")
print(
f"Canvas: {size}x{size}; padding: {_fmt(args.padding)}; "
f"scale: {_fmt(scale)}"
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Normalize SVG artwork to an exact square coordinate system."
)
parser.add_argument("--input", required=True, help="Input SVG path.")
parser.add_argument("--out", required=True, help="Output SVG path.")
parser.add_argument(
"--canvas-size",
required=True,
type=float,
help="Square output viewBox, width, and height.",
)
parser.add_argument(
"--padding",
type=float,
default=2.0,
help="Optical padding in output viewBox units (default: 2).",
)
parser.add_argument("--force", action="store_true", help="Overwrite an existing output file.")
return parser
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
_validate_args(args)
_normalize(args)
if __name__ == "__main__":
main()
scripts/remove_chroma_key.py#!/usr/bin/env python3
"""Remove a solid chroma-key background from an image.
This helper supports the imagegen skill's built-in-first transparent workflow:
generate an image on a flat key color, then convert that key color to alpha.
"""
from __future__ import annotations
import argparse
from io import BytesIO
from pathlib import Path
import re
from statistics import median
import sys
from typing import Tuple
Color = Tuple[int, int, int]
KEY_DOMINANCE_THRESHOLD = 16.0
ALPHA_NOISE_FLOOR = 8
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _dependency_hint(package: str) -> str:
return f"Install the Python package '{package}' in the active environment."
def _load_pillow():
try:
from PIL import Image, ImageFilter
except ImportError:
_die(f"Pillow is required for chroma-key removal. {_dependency_hint('pillow')}")
return Image, ImageFilter
def _parse_key_color(raw: str) -> Color:
value = raw.strip()
match = re.fullmatch(r"#?([0-9a-fA-F]{6})", value)
if not match:
_die("key color must be a hex RGB value like #00ff00.")
hex_value = match.group(1)
return (
int(hex_value[0:2], 16),
int(hex_value[2:4], 16),
int(hex_value[4:6], 16),
)
def _validate_args(args: argparse.Namespace) -> None:
if args.tolerance < 0 or args.tolerance > 255:
_die("--tolerance must be between 0 and 255.")
if args.transparent_threshold < 0 or args.transparent_threshold > 255:
_die("--transparent-threshold must be between 0 and 255.")
if args.opaque_threshold < 0 or args.opaque_threshold > 255:
_die("--opaque-threshold must be between 0 and 255.")
if args.soft_matte and args.transparent_threshold >= args.opaque_threshold:
_die("--transparent-threshold must be lower than --opaque-threshold.")
if args.edge_feather < 0 or args.edge_feather > 64:
_die("--edge-feather must be between 0 and 64.")
if args.edge_contract < 0 or args.edge_contract > 16:
_die("--edge-contract must be between 0 and 16.")
if args.padding < 0:
_die("--padding must be zero or greater.")
src = Path(args.input)
if not src.is_file():
_die(f"Input image not found: {src}")
out = Path(args.out)
if out.exists() and not args.force:
_die(f"Output already exists: {out} (use --force to overwrite)")
if out.suffix.lower() not in {".png", ".webp"}:
_die("--out must end in .png or .webp so the alpha channel is preserved.")
def _channel_distance(a: Color, b: Color) -> int:
return max(abs(a[0] - b[0]), abs(a[1] - b[1]), abs(a[2] - b[2]))
def _clamp_channel(value: float) -> int:
return max(0, min(255, int(round(value))))
def _smoothstep(value: float) -> float:
value = max(0.0, min(1.0, value))
return value * value * (3.0 - 2.0 * value)
def _soft_alpha(distance: int, transparent_threshold: float, opaque_threshold: float) -> int:
if distance <= transparent_threshold:
return 0
if distance >= opaque_threshold:
return 255
ratio = (float(distance) - transparent_threshold) / (
opaque_threshold - transparent_threshold
)
return _clamp_channel(255.0 * _smoothstep(ratio))
def _dominance_alpha(rgb: Color, key: Color) -> int:
spill_channels = _spill_channels(key)
if not spill_channels:
return 255
channels = [float(value) for value in rgb]
non_spill = [idx for idx in range(3) if idx not in spill_channels]
key_strength = (
min(channels[idx] for idx in spill_channels)
if len(spill_channels) > 1
else channels[spill_channels[0]]
)
non_key_strength = max((channels[idx] for idx in non_spill), default=0.0)
dominance = key_strength - non_key_strength
if dominance <= 0:
return 255
denominator = max(1.0, float(max(key)) - non_key_strength)
alpha = 1.0 - min(1.0, dominance / denominator)
return _clamp_channel(alpha * 255.0)
def _spill_channels(key: Color) -> list[int]:
key_max = max(key)
if key_max < 128:
return []
return [idx for idx, value in enumerate(key) if value >= key_max - 16 and value >= 128]
def _key_channel_dominance(rgb: Color, key: Color) -> float:
spill_channels = _spill_channels(key)
if not spill_channels:
return 0.0
channels = [float(value) for value in rgb]
non_spill = [idx for idx in range(3) if idx not in spill_channels]
key_strength = (
min(channels[idx] for idx in spill_channels)
if len(spill_channels) > 1
else channels[spill_channels[0]]
)
non_key_strength = max((channels[idx] for idx in non_spill), default=0.0)
return key_strength - non_key_strength
def _looks_key_colored(rgb: Color, key: Color, distance: int) -> bool:
if distance <= 32:
return True
spill_channels = _spill_channels(key)
if not spill_channels:
return True
return _key_channel_dominance(rgb, key) >= KEY_DOMINANCE_THRESHOLD
def _cleanup_spill(rgb: Color, key: Color, alpha: int = 255) -> Color:
if alpha >= 252:
return rgb
spill_channels = _spill_channels(key)
if not spill_channels:
return rgb
channels = [float(value) for value in rgb]
non_spill = [idx for idx in range(3) if idx not in spill_channels]
if non_spill:
anchor = max(channels[idx] for idx in non_spill)
cap = max(0.0, anchor - 1.0)
for idx in spill_channels:
if channels[idx] > cap:
channels[idx] = cap
return (
_clamp_channel(channels[0]),
_clamp_channel(channels[1]),
_clamp_channel(channels[2]),
)
def _apply_alpha_to_image(
image,
*,
key: Color,
tolerance: int,
spill_cleanup: bool,
soft_matte: bool,
transparent_threshold: float,
opaque_threshold: float,
) -> int:
pixels = image.load()
width, height = image.size
transparent = 0
for y in range(height):
for x in range(width):
red, green, blue, alpha = pixels[x, y]
rgb = (red, green, blue)
distance = _channel_distance(rgb, key)
key_like = _looks_key_colored(rgb, key, distance)
output_alpha = (
min(
_soft_alpha(distance, transparent_threshold, opaque_threshold),
_dominance_alpha(rgb, key),
)
if soft_matte and key_like
else (0 if distance <= tolerance else 255)
)
output_alpha = int(round(output_alpha * (alpha / 255.0)))
if 0 < output_alpha <= ALPHA_NOISE_FLOOR:
output_alpha = 0
if output_alpha == 0:
pixels[x, y] = (0, 0, 0, 0)
transparent += 1
continue
if spill_cleanup and key_like:
red, green, blue = _cleanup_spill(rgb, key, output_alpha)
pixels[x, y] = (red, green, blue, output_alpha)
return transparent
def _contract_alpha(image, pixels: int):
if pixels == 0:
return image
_, ImageFilter = _load_pillow()
alpha = image.getchannel("A")
for _ in range(pixels):
alpha = alpha.filter(ImageFilter.MinFilter(3))
image.putalpha(alpha)
return image
def _apply_edge_feather(image, radius: float):
if radius == 0:
return image
Image, ImageFilter = _load_pillow()
from PIL import ImageChops
alpha = image.getchannel("A")
blur = ImageFilter.GaussianBlur(radius=radius)
feathered_alpha = alpha.filter(blur)
premultiplied = [
ImageChops.multiply(channel, alpha).filter(blur)
for channel in image.convert("RGB").split()
]
premultiplied_image = Image.merge(
"RGBA", (*premultiplied, feathered_alpha)
)
pixels = premultiplied_image.load()
width, height = premultiplied_image.size
for y in range(height):
for x in range(width):
red, green, blue, output_alpha = pixels[x, y]
if output_alpha == 0:
pixels[x, y] = (0, 0, 0, 0)
continue
pixels[x, y] = (
_clamp_channel((red * 255.0) / output_alpha),
_clamp_channel((green * 255.0) / output_alpha),
_clamp_channel((blue * 255.0) / output_alpha),
output_alpha,
)
return premultiplied_image
def _trim_transparent(image, padding: int):
bbox = image.getchannel("A").getbbox()
if bbox is None:
_die("The processed image is fully transparent; no icon subject remains.")
left, top, right, bottom = bbox
return image.crop(
(
max(0, left - padding),
max(0, top - padding),
min(image.width, right + padding),
min(image.height, bottom + padding),
)
)
def _encode_image(image, output_format: str) -> bytes:
out = BytesIO()
if output_format.upper() == "WEBP":
image.save(out, format="WEBP", lossless=True, quality=100, method=6)
else:
image.save(out, format="PNG", optimize=True)
return out.getvalue()
def _alpha_counts(image) -> tuple[int, int, int]:
pixels = image.load()
width, height = image.size
total = 0
transparent = 0
partial = 0
for y in range(height):
for x in range(width):
alpha = pixels[x, y][3]
total += 1
if alpha == 0:
transparent += 1
elif alpha < 255:
partial += 1
return total, transparent, partial
def _sample_border_key(image, mode: str) -> Color:
width, height = image.size
pixels = image.load()
samples: list[Color] = []
if mode == "corners":
patch = max(1, min(width, height, 12))
boxes = [
(0, 0, patch, patch),
(width - patch, 0, width, patch),
(0, height - patch, patch, height),
(width - patch, height - patch, width, height),
]
for left, top, right, bottom in boxes:
for y in range(top, bottom):
for x in range(left, right):
red, green, blue = pixels[x, y][:3]
samples.append((red, green, blue))
else:
band = max(1, min(width, height, 6))
step = max(1, min(width, height) // 256)
for x in range(0, width, step):
for y in range(band):
red, green, blue = pixels[x, y][:3]
samples.append((red, green, blue))
red, green, blue = pixels[x, height - 1 - y][:3]
samples.append((red, green, blue))
for y in range(0, height, step):
for x in range(band):
red, green, blue = pixels[x, y][:3]
samples.append((red, green, blue))
red, green, blue = pixels[width - 1 - x, y][:3]
samples.append((red, green, blue))
if not samples:
_die("Could not sample background key color from image border.")
return (
int(round(median(sample[0] for sample in samples))),
int(round(median(sample[1] for sample in samples))),
int(round(median(sample[2] for sample in samples))),
)
def _remove_chroma_key(args: argparse.Namespace) -> None:
Image, _ = _load_pillow()
src = Path(args.input)
out = Path(args.out)
try:
with Image.open(src) as image:
rgba = image.convert("RGBA")
except OSError as error:
_die(f"Could not read input image {src}: {error}")
key = (
_sample_border_key(rgba, args.auto_key)
if args.auto_key != "none"
else _parse_key_color(args.key_color)
)
transparent = _apply_alpha_to_image(
rgba,
key=key,
tolerance=args.tolerance,
spill_cleanup=args.spill_cleanup,
soft_matte=args.soft_matte,
transparent_threshold=args.transparent_threshold,
opaque_threshold=args.opaque_threshold,
)
rgba = _contract_alpha(rgba, args.edge_contract)
rgba = _apply_edge_feather(rgba, args.edge_feather)
if rgba.getchannel("A").getbbox() is None:
_die("The processed image is fully transparent; no icon subject remains.")
if args.trim:
rgba = _trim_transparent(rgba, args.padding)
total, transparent_after, partial_after = _alpha_counts(rgba)
out.parent.mkdir(parents=True, exist_ok=True)
output_format = "PNG" if out.suffix.lower() == ".png" else "WEBP"
out.write_bytes(_encode_image(rgba, output_format))
print(f"Wrote {out}")
print(f"Key color: #{key[0]:02x}{key[1]:02x}{key[2]:02x}")
print(f"Transparent pixels: {transparent_after}/{total}")
print(f"Partially transparent pixels: {partial_after}/{total}")
if transparent == 0:
print("Warning: no pixels matched the key color before feathering.", file=sys.stderr)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Remove a solid chroma-key background and write an image with alpha."
)
parser.add_argument("--input", required=True, help="Input image path.")
parser.add_argument("--out", required=True, help="Output .png or .webp path.")
parser.add_argument(
"--key-color",
default="#00ff00",
help="Hex RGB key color to remove, for example #00ff00.",
)
parser.add_argument(
"--tolerance",
type=int,
default=12,
help="Hard-key per-channel tolerance for matching the key color, 0-255.",
)
parser.add_argument(
"--auto-key",
choices=["none", "corners", "border"],
default="none",
help="Sample the key color from image corners or border instead of --key-color.",
)
parser.add_argument(
"--soft-matte",
action="store_true",
help="Use a smooth alpha ramp between transparent and opaque thresholds.",
)
parser.add_argument(
"--transparent-threshold",
type=float,
default=12.0,
help="Soft-matte distance at or below which pixels become fully transparent.",
)
parser.add_argument(
"--opaque-threshold",
type=float,
default=96.0,
help="Soft-matte distance at or above which pixels become fully opaque.",
)
parser.add_argument(
"--edge-feather",
type=float,
default=0.0,
help="Optional alpha blur radius for softened edges, 0-64.",
)
parser.add_argument(
"--edge-contract",
type=int,
default=0,
help="Shrink the visible alpha matte by this many pixels before feathering.",
)
parser.add_argument(
"--trim",
action="store_true",
help="Crop transparent outer pixels after matte processing.",
)
parser.add_argument(
"--padding",
type=int,
default=12,
help="Transparent source pixels retained around the trimmed subject (default: 12).",
)
parser.add_argument(
"--spill-cleanup",
dest="spill_cleanup",
action="store_true",
help="Reduce obvious key-color spill on opaque pixels.",
)
parser.add_argument(
"--despill",
dest="spill_cleanup",
action="store_true",
help="Alias for --spill-cleanup; decontaminate key-color edge spill.",
)
parser.add_argument("--force", action="store_true", help="Overwrite an existing output file.")
return parser
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
_validate_args(args)
_remove_chroma_key(args)
if __name__ == "__main__":
main()
scripts/validate_icon.py#!/usr/bin/env python3
"""Validate structural requirements for generated SVG and transparent raster icons."""
from __future__ import annotations
import argparse
import math
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
SVG_GRAPHICS = {"path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "use"}
SVG_FORBIDDEN = {"script", "foreignObject", "image"}
RASTER_SUFFIXES = {".png", ".webp"}
def _die(message: str, code: int = 1) -> None:
print(f"FAIL: {message}", file=sys.stderr)
raise SystemExit(code)
def _local_name(tag: object) -> str:
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
def _parse_viewbox(raw: str) -> tuple[float, float, float, float]:
values = raw.replace(",", " ").split()
if len(values) != 4:
_die("SVG viewBox must contain four numbers.")
try:
parsed = tuple(float(value) for value in values)
except ValueError:
_die("SVG viewBox contains a non-numeric value.")
if not all(math.isfinite(value) for value in parsed):
_die("SVG viewBox values must be finite.")
if parsed[2] <= 0 or parsed[3] <= 0:
_die("SVG viewBox width and height must be positive.")
return parsed # type: ignore[return-value]
def _validate_svg(path: Path) -> dict[str, object]:
try:
tree = ET.parse(path)
except (ET.ParseError, OSError) as error:
_die(f"Could not parse SVG: {error}")
root = tree.getroot()
if _local_name(root.tag) != "svg":
_die("The XML root element is not <svg>.")
viewbox_raw = root.get("viewBox")
if not viewbox_raw:
_die("SVG is missing a viewBox.")
viewbox = _parse_viewbox(viewbox_raw)
graphics = []
forbidden = []
local_ids = {
element.get("id")
for element in root.iter()
if isinstance(element.tag, str) and element.get("id")
}
for element in root.iter():
name = _local_name(element.tag)
if name in SVG_GRAPHICS:
graphics.append(element)
if name in SVG_FORBIDDEN:
forbidden.append(name)
for attribute, value in element.attrib.items():
if _local_name(attribute) != "href":
continue
reference = value.strip()
if not reference.startswith("#"):
_die("SVG href references must stay inside the delivered file.")
if len(reference) == 1 or reference[1:] not in local_ids:
_die(f"SVG href points to a missing local ID: {reference}")
if forbidden:
_die(f"SVG contains disallowed element(s): {', '.join(sorted(set(forbidden)))}.")
if not graphics:
_die("SVG contains no vector graphics elements.")
for element in graphics:
if _local_name(element.tag) == "path" and not element.get("d", "").strip():
_die("SVG contains an empty path.")
path_count = sum(_local_name(element.tag) == "path" for element in graphics)
print(
"PASS: SVG "
f"viewBox={viewbox[0]:g} {viewbox[1]:g} {viewbox[2]:g} {viewbox[3]:g}; "
f"graphics={len(graphics)}; paths={path_count}"
)
return {
"kind": "svg",
"path": path,
"canvas": (viewbox, root.get("width"), root.get("height")),
}
def _load_pillow():
try:
from PIL import Image, UnidentifiedImageError
except ImportError:
_die("Pillow is required. Install the Python package 'pillow' in the active environment.")
return Image, UnidentifiedImageError
def _validate_raster(path: Path, require_transparent_corners: bool) -> dict[str, object]:
Image, UnidentifiedImageError = _load_pillow()
try:
with Image.open(path) as source:
source.load()
image = source.convert("RGBA")
except (UnidentifiedImageError, OSError) as error:
_die(f"Could not read raster image: {error}")
width, height = image.size
if width <= 0 or height <= 0:
_die("Raster dimensions must be positive.")
alpha = image.getchannel("A")
alpha_min, alpha_max = alpha.getextrema()
if alpha_max == 0:
_die("Raster is fully transparent; no icon subject remains.")
if alpha_min == 255:
_die("Raster has no transparent pixels.")
corners = [
alpha.getpixel((0, 0)),
alpha.getpixel((width - 1, 0)),
alpha.getpixel((0, height - 1)),
alpha.getpixel((width - 1, height - 1)),
]
if require_transparent_corners and any(value != 0 for value in corners):
_die(f"Raster corners are not fully transparent: {corners}.")
bbox = alpha.getbbox()
if bbox is None:
_die("Raster has no visible alpha bounds.")
histogram = alpha.histogram()
transparent = histogram[0]
partial = sum(histogram[1:255])
opaque = histogram[255]
print(
"PASS: raster "
f"size={width}x{height}; alpha_bbox={bbox}; "
f"transparent={transparent}; partial={partial}; opaque={opaque}"
)
return {"kind": "raster", "path": path, "canvas": (width, height)}
def _validate_matching_canvases(results: list[dict[str, object]]) -> None:
for kind in ("svg", "raster"):
branch = [result for result in results if result["kind"] == kind]
if len(branch) < 2:
continue
expected = branch[0]["canvas"]
mismatches = [result for result in branch[1:] if result["canvas"] != expected]
if mismatches:
details = ", ".join(
f"{result['path']}={result['canvas']}" for result in branch
)
_die(f"{kind} canvas mismatch; expected one shared canvas: {details}")
print(f"PASS: matching {kind} canvas across {len(branch)} assets: {expected}")
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Validate a generated SVG, transparent PNG, or transparent WebP icon."
)
parser.add_argument("asset", nargs="+", help="Path(s) to icon assets.")
parser.add_argument(
"--require-transparent-corners",
action="store_true",
help="Require all four raster corner pixels to have alpha 0.",
)
parser.add_argument(
"--require-matching-canvas",
action="store_true",
help="Require one shared canvas within each SVG or raster group.",
)
return parser
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
results: list[dict[str, object]] = []
for raw_path in args.asset:
path = Path(raw_path)
if not path.is_file():
_die(f"Asset not found: {path}")
suffix = path.suffix.lower()
if suffix == ".svg":
if args.require_transparent_corners:
_die("--require-transparent-corners applies only to raster assets.")
results.append(_validate_svg(path))
elif suffix in RASTER_SUFFIXES:
results.append(_validate_raster(path, args.require_transparent_corners))
else:
_die("Supported asset extensions are .svg, .png, and .webp.")
if args.require_matching_canvas:
_validate_matching_canvases(results)
if __name__ == "__main__":
main()
SKILL.md--- name: custom-icons description: Create or refine custom icon assets as native or traced SVGs and transparent PNG/WebP files. Use when the user asks for a bespoke icon or cohesive icon set, wants an image traced into a clean vector, or needs a detailed or 3D icon with transparency. --- # Custom Icons Build each icon through an inspectable source-to-asset pipeline. Treat the user's stated subject, style, palette, format, dimensions, and destination as the asset contract. ## Workflow ### 1. Establish the asset contract Inspect the request, supplied references, and target project before asking questions. Infer routine details with these defaults: - Match a supplied reference or existing icon set before introducing a new style. - Choose SVG for monochrome silhouettes, line art, and simple UI artwork. - Choose transparent PNG or lossless WebP for 3D, multicolor, shaded, or textured artwork. - Use a square `24×24` coordinate system for unspecified UI SVGs and `1024×1024` for unspecified detailed illustrative SVGs. - Use black for an unspecified vector color and preserve the generated/source dimensions for an unspecified raster size. - Save final assets in the request workspace when no destination is given. Ask one concise question only when the subject is missing, requirements conflict, or the format choice would materially change the requested appearance. A target framework is optional unless implementation or framework-specific packaging is requested. For each asset, determine the subject, branch, style anchor, palette, format, and final location. This step is complete when every field is stated or safely inferred and no material conflict remains. ### 2. Choose one branch per asset - **Traced vector:** Use when a raster source must become SVG or when a new icon needs an organic, hand-drawn, or generated silhouette. Read [references/vector-workflow.md](references/vector-workflow.md) before generating or processing the asset. - **Transparent raster:** Use for detailed, 3D, multicolor, shaded, or textured artwork. Read [references/raster-workflow.md](references/raster-workflow.md) before generating or processing the asset. - **Native vector:** Use for an existing vector source, deterministic geometric/UI artwork, or a detailed SVG that can be expressed faithfully with vector fills, gradients, and effects. Read [references/native-vector-workflow.md](references/native-vector-workflow.md) before creating or editing the asset. Classify mixed icon sets asset by asset; a set may legitimately contain both SVG and raster outputs. Keep the user's requested format when feasible. If the requested format would discard essential visual detail, explain the tradeoff and resolve it before generation. This step is complete when every asset has exactly one pipeline and the pipeline can produce its contracted format. ### 3. Prepare sources and workspace Resolve `<skill-dir>` to the directory containing this `SKILL.md`; invoke bundled scripts with `<skill-dir>/scripts/...`, not a path assumed to exist in the user's project. Create `tmp/custom-icons/<request-slug>/` in the target project. Use filesystem-safe, lowercase names with hyphens for request and asset slugs. Keep prompts, generated sources, processed previews, trace drafts, and validation artifacts there. Preserve these intermediates until the user asks for cleanup, and avoid overwriting an existing final asset unless the request authorizes replacement. When references are supplied, assign each one a role such as `edit target`, `style reference`, or `palette reference`. Preserve the edit target's identity, silhouette, proportions, and composition except where the request explicitly changes them. For a set of three or more icons, create one representative pilot first. Confirm its silhouette, style, palette, padding, and small-size legibility before producing the rest, then reuse that visual contract for every asset. This step is complete when the workspace exists, inputs have explicit roles, and any multi-icon set has a validated pilot. ### 4. Generate and process Use the selected branch reference as the single source of truth for its prompt and commands. Generate one isolated asset per source image. When an image-generation or editing tool is unavailable, continue from a user-supplied source; if no source exists, report that generation is the blocking step. Keep all branch attempts in the workspace. Tune the source or processing parameters when the first result misses the contract; do not conceal a failed attempt by switching formats. This step is complete when every contracted final file exists in the workspace and was produced by its selected pipeline. ### 5. Validate every asset Run the bundled validator on each output: ```bash python3 "<skill-dir>/scripts/validate_icon.py" "<final-asset>" ``` For an icon set, also validate all outputs from each branch together: ```bash python3 "<skill-dir>/scripts/validate_icon.py" \ "<asset-1>" "<asset-2>" "<asset-n>" \ --require-matching-canvas ``` Then inspect each icon visually at its intended display size and compare it with the request and any references. Check: - recognizable subject and clean silhouette; - consistent canvas, optical size, padding, palette, perspective, and stroke/detail weight across a set; - crisp vector geometry without stray paths or lost details; - clean raster transparency without key-color spill, clipped edges, or unintended shadows; - legibility at the smallest intended size. Unless the user requests variable artboards, require identical pixel dimensions for raster members and an identical `viewBox`, width, and height coordinate system for SVG members. Iterate on any failed asset and rerun both checks. This step is complete only when the validator passes and every asset passes the visual checklist; a set is incomplete if any member fails. ### 6. Deliver and report Copy only validated final assets to the contracted destination. Report the branch used, final paths, workspace path, validation result, and any deliberate deviation from the request. Include integration code only when requested or when the target environment requires non-obvious usage. Delivery is complete when every promised asset is present at its reported path and every validation result is accounted for.