Files
security-game/tools/asset_pipeline.py
T

452 lines
20 KiB
Python

#!/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 _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,
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, 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 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(
"--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())