#!/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"} 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"}, "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": None, "view": "straight-on wide backdrop"}, "background": {"target": (1920, 1080), "min": (1280, 720), "max": (8192, 4320), "aspect": 16 / 9, "alpha": None, "view": "intended game camera and horizon"}, } 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 front-facing flat game UI card, no perspective, no surrounding mockup", "banner": "wide front-facing game banner texture, no perspective, no surrounding scene", "backdrop": "wide seamless-looking game environment backdrop, straight-on view, no foreground objects", "background": "wide 16:9 game background environment with a coherent horizon and straight camera view", } 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 "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") 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})") 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, "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, "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 _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]}." image = generate_textures.generate_image( prompt, args.model, args.request_size, transparent) 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) save_image(image, resolve_path(args.output)) 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 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=PROFILES, required=True) generate.add_argument( "--model", default=generate_textures.DEFAULT_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) 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)) 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())