scripts/remove_chroma_key.py
scripts/remove_chroma_key.pyBrowse 12 files
3,457 tokens
13,905 bytes
Token encoding: o200k_base
Snapshot 18d7ace
← Back to SKILL.md
1#!/usr/bin/env python32"""Remove a solid chroma-key background from an image.3 4This helper supports the imagegen skill's built-in-first transparent workflow:5generate an image on a flat key color, then convert that key color to alpha.6"""7 8from __future__ import annotations9 10import argparse11from io import BytesIO12from pathlib import Path13import re14from statistics import median15import sys16from typing import Tuple17 18 19Color = Tuple[int, int, int]20KEY_DOMINANCE_THRESHOLD = 16.021ALPHA_NOISE_FLOOR = 822 23 24def _die(message: str, code: int = 1) -> None:25 print(f"Error: {message}", file=sys.stderr)26 raise SystemExit(code)27 28 29def _dependency_hint(package: str) -> str:30 return (31 "Activate the repo-selected environment first, then install it with "32 f"`uv pip install {package}`. If this repo uses a local virtualenv, start with "33 "`source .venv/bin/activate`; otherwise use this repo's configured shared fallback "34 "environment."35 )36 37 38def _load_pillow():39 try:40 from PIL import Image, ImageFilter41 except ImportError:42 _die(f"Pillow is required for chroma-key removal. {_dependency_hint('pillow')}")43 return Image, ImageFilter44 45 46def _parse_key_color(raw: str) -> Color:47 value = raw.strip()48 match = re.fullmatch(r"#?([0-9a-fA-F]{6})", value)49 if not match:50 _die("key color must be a hex RGB value like #00ff00.")51 hex_value = match.group(1)52 return (53 int(hex_value[0:2], 16),54 int(hex_value[2:4], 16),55 int(hex_value[4:6], 16),56 )57 58 59def _validate_args(args: argparse.Namespace) -> None:60 if args.tolerance < 0 or args.tolerance > 255:61 _die("--tolerance must be between 0 and 255.")62 if args.transparent_threshold < 0 or args.transparent_threshold > 255:63 _die("--transparent-threshold must be between 0 and 255.")64 if args.opaque_threshold < 0 or args.opaque_threshold > 255:65 _die("--opaque-threshold must be between 0 and 255.")66 if args.soft_matte and args.transparent_threshold >= args.opaque_threshold:67 _die("--transparent-threshold must be lower than --opaque-threshold.")68 if args.edge_feather < 0 or args.edge_feather > 64:69 _die("--edge-feather must be between 0 and 64.")70 if args.edge_contract < 0 or args.edge_contract > 16:71 _die("--edge-contract must be between 0 and 16.")72 73 src = Path(args.input)74 if not src.exists():75 _die(f"Input image not found: {src}")76 77 out = Path(args.out)78 if out.exists() and not args.force:79 _die(f"Output already exists: {out} (use --force to overwrite)")80 81 if out.suffix.lower() not in {".png", ".webp"}:82 _die("--out must end in .png or .webp so the alpha channel is preserved.")83 84 85def _channel_distance(a: Color, b: Color) -> int:86 return max(abs(a[0] - b[0]), abs(a[1] - b[1]), abs(a[2] - b[2]))87 88 89def _clamp_channel(value: float) -> int:90 return max(0, min(255, int(round(value))))91 92 93def _smoothstep(value: float) -> float:94 value = max(0.0, min(1.0, value))95 return value * value * (3.0 - 2.0 * value)96 97 98def _soft_alpha(99 distance: int, transparent_threshold: float, opaque_threshold: float100) -> int:101 if distance <= transparent_threshold:102 return 0103 if distance >= opaque_threshold:104 return 255105 ratio = (float(distance) - transparent_threshold) / (106 opaque_threshold - transparent_threshold107 )108 return _clamp_channel(255.0 * _smoothstep(ratio))109 110 111def _dominance_alpha(rgb: Color, key: Color) -> int:112 spill_channels = _spill_channels(key)113 if not spill_channels:114 return 255115 116 channels = [float(value) for value in rgb]117 non_spill = [idx for idx in range(3) if idx not in spill_channels]118 key_strength = (119 min(channels[idx] for idx in spill_channels)120 if len(spill_channels) > 1121 else channels[spill_channels[0]]122 )123 non_key_strength = max((channels[idx] for idx in non_spill), default=0.0)124 dominance = key_strength - non_key_strength125 if dominance <= 0:126 return 255127 128 denominator = max(1.0, float(max(key)) - non_key_strength)129 alpha = 1.0 - min(1.0, dominance / denominator)130 return _clamp_channel(alpha * 255.0)131 132 133def _spill_channels(key: Color) -> list[int]:134 key_max = max(key)135 if key_max < 128:136 return []137 return [138 idx for idx, value in enumerate(key) if value >= key_max - 16 and value >= 128139 ]140 141 142def _key_channel_dominance(rgb: Color, key: Color) -> float:143 spill_channels = _spill_channels(key)144 if not spill_channels:145 return 0.0146 147 channels = [float(value) for value in rgb]148 non_spill = [idx for idx in range(3) if idx not in spill_channels]149 key_strength = (150 min(channels[idx] for idx in spill_channels)151 if len(spill_channels) > 1152 else channels[spill_channels[0]]153 )154 non_key_strength = max((channels[idx] for idx in non_spill), default=0.0)155 return key_strength - non_key_strength156 157 158def _looks_key_colored(rgb: Color, key: Color, distance: int) -> bool:159 if distance <= 32:160 return True161 162 spill_channels = _spill_channels(key)163 if not spill_channels:164 return True165 166 return _key_channel_dominance(rgb, key) >= KEY_DOMINANCE_THRESHOLD167 168 169def _cleanup_spill(rgb: Color, key: Color, alpha: int = 255) -> Color:170 if alpha >= 252:171 return rgb172 173 spill_channels = _spill_channels(key)174 if not spill_channels:175 return rgb176 177 channels = [float(value) for value in rgb]178 non_spill = [idx for idx in range(3) if idx not in spill_channels]179 if non_spill:180 anchor = max(channels[idx] for idx in non_spill)181 cap = max(0.0, anchor - 1.0)182 for idx in spill_channels:183 if channels[idx] > cap:184 channels[idx] = cap185 186 return (187 _clamp_channel(channels[0]),188 _clamp_channel(channels[1]),189 _clamp_channel(channels[2]),190 )191 192 193def _apply_alpha_to_image(194 image,195 *,196 key: Color,197 tolerance: int,198 spill_cleanup: bool,199 soft_matte: bool,200 transparent_threshold: float,201 opaque_threshold: float,202) -> int:203 pixels = image.load()204 width, height = image.size205 transparent = 0206 207 for y in range(height):208 for x in range(width):209 red, green, blue, alpha = pixels[x, y]210 rgb = (red, green, blue)211 distance = _channel_distance(rgb, key)212 key_like = _looks_key_colored(rgb, key, distance)213 output_alpha = (214 min(215 _soft_alpha(distance, transparent_threshold, opaque_threshold),216 _dominance_alpha(rgb, key),217 )218 if soft_matte and key_like219 else (0 if distance <= tolerance else 255)220 )221 output_alpha = int(round(output_alpha * (alpha / 255.0)))222 if 0 < output_alpha <= ALPHA_NOISE_FLOOR:223 output_alpha = 0224 225 if output_alpha == 0:226 pixels[x, y] = (0, 0, 0, 0)227 transparent += 1228 continue229 230 if spill_cleanup and key_like:231 red, green, blue = _cleanup_spill(rgb, key, output_alpha)232 pixels[x, y] = (red, green, blue, output_alpha)233 234 return transparent235 236 237def _contract_alpha(image, pixels: int):238 if pixels == 0:239 return image240 241 _, ImageFilter = _load_pillow()242 alpha = image.getchannel("A")243 for _ in range(pixels):244 alpha = alpha.filter(ImageFilter.MinFilter(3))245 image.putalpha(alpha)246 return image247 248 249def _apply_edge_feather(image, radius: float):250 if radius == 0:251 return image252 253 _, ImageFilter = _load_pillow()254 alpha = image.getchannel("A")255 alpha = alpha.filter(ImageFilter.GaussianBlur(radius=radius))256 image.putalpha(alpha)257 return image258 259 260def _encode_image(image, output_format: str) -> bytes:261 out = BytesIO()262 image.save(out, format=output_format.upper())263 return out.getvalue()264 265 266def _alpha_counts(image) -> tuple[int, int, int]:267 pixels = image.load()268 width, height = image.size269 total = 0270 transparent = 0271 partial = 0272 273 for y in range(height):274 for x in range(width):275 alpha = pixels[x, y][3]276 total += 1277 if alpha == 0:278 transparent += 1279 elif alpha < 255:280 partial += 1281 282 return total, transparent, partial283 284 285def _sample_border_key(image, mode: str) -> Color:286 width, height = image.size287 pixels = image.load()288 samples: list[Color] = []289 290 if mode == "corners":291 patch = max(1, min(width, height, 12))292 boxes = [293 (0, 0, patch, patch),294 (width - patch, 0, width, patch),295 (0, height - patch, patch, height),296 (width - patch, height - patch, width, height),297 ]298 for left, top, right, bottom in boxes:299 for y in range(top, bottom):300 for x in range(left, right):301 red, green, blue = pixels[x, y][:3]302 samples.append((red, green, blue))303 else:304 band = max(1, min(width, height, 6))305 step = max(1, min(width, height) // 256)306 for x in range(0, width, step):307 for y in range(band):308 red, green, blue = pixels[x, y][:3]309 samples.append((red, green, blue))310 red, green, blue = pixels[x, height - 1 - y][:3]311 samples.append((red, green, blue))312 for y in range(0, height, step):313 for x in range(band):314 red, green, blue = pixels[x, y][:3]315 samples.append((red, green, blue))316 red, green, blue = pixels[width - 1 - x, y][:3]317 samples.append((red, green, blue))318 319 if not samples:320 _die("Could not sample background key color from image border.")321 322 return (323 int(round(median(sample[0] for sample in samples))),324 int(round(median(sample[1] for sample in samples))),325 int(round(median(sample[2] for sample in samples))),326 )327 328 329def _remove_chroma_key(args: argparse.Namespace) -> None:330 Image, _ = _load_pillow()331 src = Path(args.input)332 out = Path(args.out)333 334 with Image.open(src) as image:335 rgba = image.convert("RGBA")336 key = (337 _sample_border_key(rgba, args.auto_key)338 if args.auto_key != "none"339 else _parse_key_color(args.key_color)340 )341 342 transparent = _apply_alpha_to_image(343 rgba,344 key=key,345 tolerance=args.tolerance,346 spill_cleanup=args.spill_cleanup,347 soft_matte=args.soft_matte,348 transparent_threshold=args.transparent_threshold,349 opaque_threshold=args.opaque_threshold,350 )351 rgba = _contract_alpha(rgba, args.edge_contract)352 rgba = _apply_edge_feather(rgba, args.edge_feather)353 354 total, transparent_after, partial_after = _alpha_counts(rgba)355 356 out.parent.mkdir(parents=True, exist_ok=True)357 output_format = "PNG" if out.suffix.lower() == ".png" else "WEBP"358 out.write_bytes(_encode_image(rgba, output_format))359 360 print(f"Wrote {out}")361 print(f"Key color: #{key[0]:02x}{key[1]:02x}{key[2]:02x}")362 print(f"Transparent pixels: {transparent_after}/{total}")363 print(f"Partially transparent pixels: {partial_after}/{total}")364 if transparent == 0:365 print(366 "Warning: no pixels matched the key color before feathering.",367 file=sys.stderr,368 )369 370 371def _build_parser() -> argparse.ArgumentParser:372 parser = argparse.ArgumentParser(373 description="Remove a solid chroma-key background and write an image with alpha."374 )375 parser.add_argument("--input", required=True, help="Input image path.")376 parser.add_argument("--out", required=True, help="Output .png or .webp path.")377 parser.add_argument(378 "--key-color",379 default="#00ff00",380 help="Hex RGB key color to remove, for example #00ff00.",381 )382 parser.add_argument(383 "--tolerance",384 type=int,385 default=12,386 help="Hard-key per-channel tolerance for matching the key color, 0-255.",387 )388 parser.add_argument(389 "--auto-key",390 choices=["none", "corners", "border"],391 default="none",392 help="Sample the key color from image corners or border instead of --key-color.",393 )394 parser.add_argument(395 "--soft-matte",396 action="store_true",397 help="Use a smooth alpha ramp between transparent and opaque thresholds.",398 )399 parser.add_argument(400 "--transparent-threshold",401 type=float,402 default=12.0,403 help="Soft-matte distance at or below which pixels become fully transparent.",404 )405 parser.add_argument(406 "--opaque-threshold",407 type=float,408 default=96.0,409 help="Soft-matte distance at or above which pixels become fully opaque.",410 )411 parser.add_argument(412 "--edge-feather",413 type=float,414 default=0.0,415 help="Optional alpha blur radius for softened edges, 0-64.",416 )417 parser.add_argument(418 "--edge-contract",419 type=int,420 default=0,421 help="Shrink the visible alpha matte by this many pixels before feathering.",422 )423 parser.add_argument(424 "--spill-cleanup",425 dest="spill_cleanup",426 action="store_true",427 help="Reduce obvious key-color spill on opaque pixels.",428 )429 parser.add_argument(430 "--despill",431 dest="spill_cleanup",432 action="store_true",433 help="Alias for --spill-cleanup; decontaminate key-color edge spill.",434 )435 parser.add_argument(436 "--force", action="store_true", help="Overwrite an existing output file."437 )438 return parser439 440 441def main() -> None:442 parser = _build_parser()443 args = parser.parse_args()444 _validate_args(args)445 _remove_chroma_key(args)446 447 448if __name__ == "__main__":449 main()450 