#!/usr/bin/env python3 """Generate, crop, resize, and quality-check game image assets.""" import argparse import json import sys from pathlib import Path from PIL import Image, ImageChops, ImageOps, ImageStat import generate_textures ROOT = Path(__file__).resolve().parent.parent IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"} DEFAULT_ROLE_IMAGE_MODEL = "openai/gpt-5-image-mini" API_ASPECTS = { "texture": "1:1", "tile": "1:1", "icon": "1:1", "app_icon": "1:1", "overlay": "1:1", "card": "3:2", "banner": "3:2", "backdrop": "3:2", "background": "3:2", } PROFILES = { "texture": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024), "aspect": 1.0, "alpha": None, "view": "flat/orthographic"}, "tile": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024), "aspect": 1.0, "alpha": None, "view": "flat/orthographic", "power_of_two": True, "seamless": True}, "icon": {"target": (64, 64), "min": (32, 32), "max": (512, 512), "aspect": 1.0, "alpha": True, "view": "flat, centered glyph"}, "app_icon": {"target": (1024, 1024), "min": (512, 512), "max": (1024, 1024), "aspect": 1.0, "alpha": None, "view": "square, centered composition"}, "overlay": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024), "aspect": 1.0, "alpha": None, "view": "screen-space overlay", "min_contrast": 5.0}, "card": {"target": (256, 128), "min": (128, 64), "max": (1024, 512), "aspect": 2.0, "alpha": None, "view": "flat, front-facing card"}, "banner": {"target": (1024, 256), "min": (512, 128), "max": (2048, 512), "aspect": 4.0, "alpha": None, "view": "flat, front-facing banner"}, "backdrop": {"target": (1024, 256), "min": (1024, 256), "max": (8192, 2048), "aspect": 4.0, "alpha": True, "view": "straight-on wide backdrop on transparency"}, "background": {"target": (1920, 1080), "min": (1280, 720), "max": (8192, 4320), "aspect": 16 / 9, "alpha": None, "view": "intended game camera and horizon"}, "screenshot": {"target": (1280, 720), "min": (1280, 720), "max": (3840, 2160), "aspect": 16 / 9, "alpha": None, "view": "gameplay frame, venue, and UI state", "min_contrast": 5.0}, } ROLE_GUIDANCE = { "texture": "flat-lit game surface texture, straight-on orthographic view, no perspective", "tile": "seamless tileable surface texture, flat even lighting, orthographic, no perspective or shadows", "icon": "single centered flat UI glyph, clean silhouette, transparent background, no mockup or perspective", "app_icon": "square game application icon, centered composition, no perspective", "overlay": "screen-space overlay texture with transparent edges, no perspective", "card": "single wide landscape game UI card filling the frame, front-facing and flat, no perspective, no surrounding mockup or whitespace", "banner": "wide front-facing game banner texture, keep important details in a narrow horizontal band through the image center for final cropping, no perspective, no surrounding scene", "backdrop": "wide isolated skyline silhouette across a low horizon in a narrow horizontal band through the image center, transparent background, no foreground objects", "background": "wide 16:9 game background with a coherent horizon and straight camera view, keep important details in the central horizontal band for final cropping", } def resolve_path(value): path = Path(value).expanduser() return path if path.is_absolute() else ROOT / path def has_alpha(image): return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info) def has_transparency(image): if not has_alpha(image): return False return image.convert("RGBA").getchannel("A").getextrema()[0] < 255 def parse_color(value): value = value.strip().lstrip("#") if len(value) not in (6, 8): raise argparse.ArgumentTypeError("color must be #RRGGBB or #RRGGBBAA") try: channels = tuple(int(value[index:index + 2], 16) for index in range(0, len(value), 2)) except ValueError as exc: raise argparse.ArgumentTypeError( "color must use hexadecimal digits") from exc return channels + ((255,) if len(channels) == 3 else ()) def save_image(image, path): path.parent.mkdir(parents=True, exist_ok=True) suffix = path.suffix.lower() if suffix in (".jpg", ".jpeg") and image.mode in ("RGBA", "LA"): background = Image.new("RGB", image.size, (0, 0, 0)) background.paste(image.convert("RGBA"), mask=image.convert("RGBA").getchannel("A")) image = background image.save(path) print( f"wrote {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path} {image.size}") def crop_image(source, output, width, height, x=None, y=None): with Image.open(source) as image: image.load() source_width, source_height = image.size if width <= 0 or height <= 0 or width > source_width or height > source_height: raise ValueError( f"crop {width}x{height} must fit inside source {source_width}x{source_height}") if (x is None) != (y is None): raise ValueError("--x and --y must be supplied together") left = (source_width - width) // 2 if x is None else x top = (source_height - height) // 2 if y is None else y if left < 0 or top < 0 or left + width > source_width or top + height > source_height: raise ValueError( "crop origin and dimensions extend beyond the source image") cropped = image.crop((left, top, left + width, top + height)) save_image(cropped, output) def resize_image(source, output, width, height, fit="contain", background=(0, 0, 0, 0)): if width <= 0 or height <= 0: raise ValueError("resize dimensions must be positive") with Image.open(source) as image: image.load() if fit == "stretch": result = image.resize((width, height), Image.Resampling.LANCZOS) elif fit == "cover": result = ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS) else: contained = ImageOps.contain( image, (width, height), method=Image.Resampling.LANCZOS) use_alpha = has_alpha(image) or background[3] < 255 mode = "RGBA" if use_alpha else "RGB" fill = background if use_alpha else background[:3] result = Image.new(mode, (width, height), fill) contained = contained.convert(mode) offset = ((width - contained.width) // 2, (height - contained.height) // 2) if use_alpha: result.alpha_composite(contained, offset) else: result.paste(contained, offset) save_image(result, output) def infer_role(path): parts = [part.lower() for part in path.parts] stem = path.stem.lower() if "screenshots" in parts or "screenshot" in stem: return "screenshot" if "icons" in parts: return "icon" if "cards" in parts: return "card" if stem == "icon": return "app_icon" if "vignette" in stem or "overlay" in stem: return "overlay" if "skyline" in stem or "backdrop" in parts: return "backdrop" if "awning" in stem or "banner" in parts: return "banner" if stem in {"brick_facade", "concrete", "asphalt"} or "tiles" in parts: return "tile" if "backgrounds" in parts or "background" in parts: return "background" return "texture" def _mean_edge_difference(image): rgb = image.convert("RGB") width, height = rgb.size horizontal = ImageStat.Stat(ImageChops.difference( rgb.crop((0, 0, 1, height)), rgb.crop((width - 1, 0, width, height)))) vertical = ImageStat.Stat(ImageChops.difference( rgb.crop((0, 0, width, 1)), rgb.crop((0, height - 1, width, height)))) return max(sum(horizontal.mean) / len(horizontal.mean), sum(vertical.mean) / len(vertical.mean)) def audit_image(path, role="auto", seam_threshold=18.0): path = Path(path) chosen_role = infer_role(path) if role == "auto" else role profile = PROFILES[chosen_role] errors = [] warnings = [] try: with Image.open(path) as image: image.load() width, height = image.size alpha = has_transparency(image) minimum = profile["min"] maximum = profile["max"] if width < minimum[0] or height < minimum[1]: errors.append( f"too small: {width}x{height}; minimum is {minimum[0]}x{minimum[1]}") if width > maximum[0] or height > maximum[1]: errors.append( f"too large: {width}x{height}; maximum is {maximum[0]}x{maximum[1]}") aspect = profile.get("aspect") actual_aspect = width / height if aspect and abs(actual_aspect - aspect) / aspect > 0.12: errors.append( f"wrong aspect ratio: {actual_aspect:.3f}; expected about {aspect:.3f}") if profile.get("alpha") is True and not alpha: errors.append("transparency required but image is opaque") if alpha and image.convert("RGBA").getchannel("A").getbbox() is None: errors.append("image is fully transparent") elif profile.get("alpha") is False and alpha: warnings.append( "image has an alpha channel although this role is normally opaque") if profile.get("power_of_two") and (width & (width - 1) or height & (height - 1)): warnings.append("dimensions are not powers of two") edge_difference = None if profile.get("seamless"): edge_difference = _mean_edge_difference(image) if edge_difference > seam_threshold: errors.append( f"tile edges differ by {edge_difference:.1f} (limit {seam_threshold:.1f})") contrast = None if profile.get("min_contrast") is not None: contrast = ImageStat.Stat(image.convert("L")).stddev[0] if contrast < profile["min_contrast"]: errors.append( f"near-blank frame: contrast {contrast:.1f} is below {profile['min_contrast']:.1f}") manual_review = [ f"Confirm {profile.get('view', 'intended')} perspective/projection visually; " "automated checks cannot infer it reliably." ] return { "path": str(path), "role": chosen_role, "size": [width, height], "alpha": alpha, "edge_difference": edge_difference, "contrast": contrast, "quality": "bad" if errors else "good", "errors": errors, "warnings": warnings, "manual_review": manual_review, } except (OSError, ValueError) as exc: return { "path": str(path), "role": chosen_role, "size": None, "alpha": None, "edge_difference": None, "contrast": None, "quality": "bad", "errors": [f"cannot read image: {exc}"], "warnings": [], "manual_review": [], } def image_paths(values): found = [] for value in values: path = resolve_path(value) if path.is_dir(): found.extend(item for item in path.rglob( "*") if item.is_file() and item.suffix.lower() in IMAGE_EXTENSIONS) elif path.suffix.lower() in IMAGE_EXTENSIONS: found.append(path) else: raise ValueError(f"not an image file or directory: {path}") return sorted(set(found)) def _variant_output(output: Path, index: int, count: int) -> Path: """Suffix the output path for alternative images (N > 1 only).""" if count <= 1: return output return output.with_name(f"{output.stem}-{index}{output.suffix}") def _generate(args): profile = PROFILES[args.role] if (args.width is None) != (args.height is None): raise ValueError("--width and --height must be supplied together") width, height = ( args.width, args.height) if args.width else profile["target"] transparent = args.transparent if args.transparent is not None else profile.get( "alpha") is True prompt = f"{args.prompt.strip()}. {ROLE_GUIDANCE[args.role]}." count = max(1, int(getattr(args, "variants", 1) or 1)) output = resolve_path(args.output) for index in range(1, count + 1): image = generate_textures.generate_image( prompt, args.model, args.request_size, transparent, aspect_ratio=API_ASPECTS[args.role]) foreground = getattr(args, "foreground", None) if foreground: image = colorize_alpha(image, foreground) if args.role == "icon" and has_alpha(image): image = resize_image_from_image( image, width, height, "contain", (0, 0, 0, 0)) else: image = ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS) if args.role == "tile": image = make_seamless(image) save_image(image, _variant_output(output, index, count)) def resize_image_from_image(image, width, height, fit, background): if fit == "cover": return ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS) contained = ImageOps.contain( image, (width, height), method=Image.Resampling.LANCZOS) mode = "RGBA" if has_alpha(image) or background[3] < 255 else "RGB" result = Image.new(mode, (width, height), background if mode == "RGBA" else background[:3]) contained = contained.convert(mode) offset = ((width - contained.width) // 2, (height - contained.height) // 2) if mode == "RGBA": result.alpha_composite(contained, offset) else: result.paste(contained, offset) return result def colorize_alpha(image, color): if not has_alpha(image): raise ValueError( "foreground color requires an image with transparency") result = Image.new("RGBA", image.size, color[:3]) result.putalpha(image.convert("RGBA").getchannel("A")) return result def make_seamless(image, band=32): original_mode = image.mode result = image.convert("RGBA") width, height = result.size pixels = result.load() band = min(band, width // 4, height // 4) def blend_edges(first_index, last_index, length, across_length, horizontal): for offset in range(band): weight = 1.0 - offset / band first = first_index(offset) last = last_index(offset) for across in range(across_length): first_pos = (first, across) if horizontal else (across, first) last_pos = (last, across) if horizontal else (across, last) first_color = pixels[first_pos] last_color = pixels[last_pos] average = tuple((a + b) / 2 for a, b in zip(first_color, last_color)) pixels[first_pos] = tuple(round(a * (1 - weight) + b * weight) for a, b in zip(first_color, average)) pixels[last_pos] = tuple(round(a * (1 - weight) + b * weight) for a, b in zip(last_color, average)) blend_edges(lambda offset: offset, lambda offset: width - 1 - offset, width, height, True) blend_edges(lambda offset: offset, lambda offset: height - 1 - offset, height, width, False) return result if has_alpha(image) else result.convert(original_mode) def seamless_image(source, output): with Image.open(source) as image: image.load() result = make_seamless(image) save_image(result, output) def build_parser(): parser = argparse.ArgumentParser( description="Create and validate game image assets.") commands = parser.add_subparsers(dest="command", required=True) generate = commands.add_parser( "generate", help="generate an image using the existing OpenRouter backend") generate.add_argument("--prompt", required=True) generate.add_argument("--output", required=True, help="output path, relative to the project root or absolute") generate.add_argument("--role", choices=API_ASPECTS, required=True) generate.add_argument( "--model", default=DEFAULT_ROLE_IMAGE_MODEL) generate.add_argument("--request-size", type=int, default=generate_textures.GEN_SIZE) generate.add_argument("--width", type=int) generate.add_argument("--height", type=int) generate.add_argument("--variants", type=int, default=1, help="generate N alternative images; saved as " "-1.png ... -N.png when N > 1") generate.add_argument( "--foreground", type=parse_color, help="replace visible RGB with this color while preserving alpha") alpha = generate.add_mutually_exclusive_group() alpha.add_argument("--transparent", dest="transparent", action="store_true") alpha.add_argument("--opaque", dest="transparent", action="store_false") generate.set_defaults(transparent=None) generate.set_defaults(handler=_generate) crop = commands.add_parser( "crop", help="crop an exact-size region; defaults to the image center") crop.add_argument("input") crop.add_argument("output") crop.add_argument("--width", type=int, required=True) crop.add_argument("--height", type=int, required=True) crop.add_argument("--x", type=int) crop.add_argument("--y", type=int) crop.set_defaults(handler=lambda args: crop_image(resolve_path(args.input), resolve_path(args.output), args.width, args.height, args.x, args.y)) resize = commands.add_parser( "resize", help="resize to exact dimensions while preserving aspect by default") resize.add_argument("input") resize.add_argument("output") resize.add_argument("--width", type=int, required=True) resize.add_argument("--height", type=int, required=True) resize.add_argument("--fit", choices=("contain", "cover", "stretch"), default="contain") resize.add_argument("--background", type=parse_color, default=(0, 0, 0, 0)) resize.set_defaults(handler=lambda args: resize_image(resolve_path(args.input), resolve_path(args.output), args.width, args.height, args.fit, args.background)) seamless = commands.add_parser( "seamless", help="blend opposite edges of a texture for tiling") seamless.add_argument("input") seamless.add_argument("output") seamless.set_defaults(handler=lambda args: seamless_image( resolve_path(args.input), resolve_path(args.output))) audit = commands.add_parser( "audit", help="classify images as good/bad using their game-asset role") audit.add_argument("paths", nargs="+", help="image files or directories") audit.add_argument("--role", choices=("auto", *PROFILES), default="auto") audit.add_argument("--seam-threshold", type=float, default=18.0) audit.add_argument("--json", action="store_true", help="emit machine-readable JSON") audit.set_defaults(handler=_audit) return parser def _audit(args): results = [audit_image(path, args.role, args.seam_threshold) for path in image_paths(args.paths)] if args.json: print(json.dumps(results, indent=2)) else: good_count = sum(result["quality"] == "good" for result in results) for result in results: size = "unreadable" if result["size"] is None else "x".join( map(str, result["size"])) print( f"{result['quality'].upper():4} {result['role']:10} {size:12} {result['path']}") for message in result["errors"] + result["warnings"]: print(f" - {message}") for message in result["manual_review"]: print(f" - review: {message}") print(f"Summary: {good_count} good, {len(results) - good_count} bad") return 1 if any(result["quality"] == "bad" for result in results) else 0 def main(argv=None): parser = build_parser() args = parser.parse_args(argv) try: result = args.handler(args) return result if isinstance(result, int) else 0 except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())