LeafCat79 commited on
Commit
567019b
·
verified ·
1 Parent(s): 2897473

Improve style-aware asset generation

Browse files
Files changed (1) hide show
  1. app.py +270 -15
app.py CHANGED
@@ -3,6 +3,7 @@ import hashlib
3
  import io
4
  import json
5
  import math
 
6
  import random
7
  import re
8
  import tempfile
@@ -10,9 +11,10 @@ import time
10
  from dataclasses import dataclass
11
  from html import escape
12
  from urllib.parse import quote
 
13
 
14
  import gradio as gr
15
- from PIL import Image, ImageDraw, ImageFont
16
 
17
 
18
  STARTER_HTML = """<!DOCTYPE html>
@@ -83,11 +85,112 @@ class AssetSpec:
83
  height: int
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def slugify(value: str) -> str:
87
  value = re.sub(r"[^a-zA-Z0-9]+", "_", value.strip().lower()).strip("_")
88
  return value or "asset"
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def parse_assets(raw_roles: str, style_hint: str) -> list[AssetSpec]:
92
  specs: list[AssetSpec] = []
93
  for line in raw_roles.splitlines():
@@ -108,10 +211,7 @@ def parse_assets(raw_roles: str, style_hint: str) -> list[AssetSpec]:
108
  is_background = any(word in slug for word in ("background", "backdrop", "scene", "map", "level"))
109
  width, height = (800, 450) if is_background else (128, 128)
110
  filename = f"sprite_{slug}.png"
111
- full_prompt = (
112
- f"{prompt}, {style_hint}, game asset, clean readable shape, "
113
- "no text, no watermark"
114
- ).strip(", ")
115
  specs.append(AssetSpec(role=role, prompt=full_prompt, filename=filename, width=width, height=height))
116
  return specs
117
 
@@ -148,6 +248,16 @@ def write_gallery_image(content: bytes, role: str) -> str:
148
 
149
  def palette_for(text: str) -> list[tuple[int, int, int]]:
150
  lowered = text.lower()
 
 
 
 
 
 
 
 
 
 
151
  themed = [
152
  (("lava", "fire", "volcano", "hell"), [(139, 28, 18), (239, 84, 34), (42, 18, 16), (255, 198, 71)]),
153
  (("ice", "snow", "frost", "arctic"), [(75, 151, 191), (187, 235, 255), (24, 63, 96), (240, 252, 255)]),
@@ -189,6 +299,98 @@ def palette_for(text: str) -> list[tuple[int, int, int]]:
189
  ]
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  def draw_background(spec: AssetSpec, rng: random.Random) -> bytes:
193
  colors = palette_for(spec.prompt)
194
  image = Image.new("RGBA", (spec.width, spec.height), colors[2] + (255,))
@@ -530,8 +732,10 @@ def local_asset_png(spec: AssetSpec, index: int, run_id: int) -> bytes:
530
  seed_text = f"{spec.role}|{spec.prompt}|{index}|{run_id}"
531
  rng = random.Random(seed_text)
532
  if is_background_spec(spec):
533
- return draw_background(spec, rng)
534
- return draw_sprite(spec, rng)
 
 
535
 
536
 
537
  def placeholder_png_bytes(role: str, width: int, height: int) -> bytes:
@@ -549,12 +753,52 @@ def placeholder_png_bytes(role: str, width: int, height: int) -> bytes:
549
  return out.getvalue()
550
 
551
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  def generate_asset(spec: AssetSpec, index: int, run_id: int) -> tuple[str, str, str | None]:
553
- png_content = local_asset_png(spec, index, run_id)
 
 
 
 
554
  return (
555
  png_bytes_to_data_uri(png_content),
556
  write_gallery_image(png_content, spec.role),
557
- None,
558
  )
559
 
560
 
@@ -696,13 +940,17 @@ def build_preview(html_code: str) -> str:
696
  )
697
 
698
 
 
 
 
 
699
  def generate_images_and_game(html_code: str, roles: str, style_hint: str):
700
  if not html_code.strip():
701
- return "", "Paste HTML game code first.", [], ""
702
 
703
  specs = parse_assets(roles, style_hint or "pixel art style")
704
  if not specs:
705
- return html_code, "Add at least one asset role, like `player: brave knight`.", [], build_preview(html_code)
706
 
707
  assets: dict[str, str] = {}
708
  gallery = []
@@ -717,10 +965,12 @@ def generate_images_and_game(html_code: str, roles: str, style_hint: str):
717
  errors.append(f"{spec.role}: fallback used ({error})")
718
 
719
  rewritten = embed_assets(html_code, assets, specs)
720
- status = f"Generated and embedded {len(specs)} fresh local asset(s). Run {str(run_id)[-6:]}."
 
 
721
  if errors:
722
- status += "\n\n" + "\n".join(errors)
723
- return rewritten, status, gallery, build_preview(rewritten)
724
 
725
 
726
  with gr.Blocks(title="Image Generator for HTML Games") as demo:
@@ -759,6 +1009,11 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
759
  language="html",
760
  lines=18,
761
  )
 
 
 
 
 
762
 
763
  gr.Markdown("## Game preview")
764
  preview = gr.HTML(build_preview(STARTER_HTML))
@@ -766,7 +1021,7 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
766
  generate_btn.click(
767
  fn=generate_images_and_game,
768
  inputs=[html_input, roles, style],
769
- outputs=[output_code, status, gallery, preview],
770
  )
771
 
772
 
 
3
  import io
4
  import json
5
  import math
6
+ import os
7
  import random
8
  import re
9
  import tempfile
 
11
  from dataclasses import dataclass
12
  from html import escape
13
  from urllib.parse import quote
14
+ from urllib.request import Request, urlopen
15
 
16
  import gradio as gr
17
+ from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
18
 
19
 
20
  STARTER_HTML = """<!DOCTYPE html>
 
85
  height: int
86
 
87
 
88
+ @dataclass
89
+ class StylePlan:
90
+ medium: str
91
+ palette: str
92
+ texture: str
93
+ lighting: str
94
+ linework: str
95
+ camera: str
96
+ tags: tuple[str, ...]
97
+
98
+
99
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
100
+ HF_IMAGE_MODEL = os.environ.get("HF_IMAGE_MODEL", "black-forest-labs/FLUX.1-schnell")
101
+ HF_IMAGE_ENDPOINT = f"https://api-inference.huggingface.co/models/{HF_IMAGE_MODEL}"
102
+
103
+
104
  def slugify(value: str) -> str:
105
  value = re.sub(r"[^a-zA-Z0-9]+", "_", value.strip().lower()).strip("_")
106
  return value or "asset"
107
 
108
 
109
+ def interpret_style_hint(style_hint: str) -> StylePlan:
110
+ text = (style_hint or "").lower()
111
+ tags: list[str] = []
112
+
113
+ def has(*words: str) -> bool:
114
+ return any(word in text for word in words)
115
+
116
+ if has("watercolor", "ink wash", "gouache", "paper"):
117
+ tags.append("watercolor")
118
+ medium = "watercolor game illustration on textured paper"
119
+ palette = "soft layered washes, muted pigments, gentle color bleed"
120
+ texture = "visible paper grain, translucent edges, organic brush pooling"
121
+ lighting = "soft ambient light with low contrast"
122
+ linework = "loose ink accents, imperfect hand-painted contour lines"
123
+ elif has("vector", "flat", "icon", "logo"):
124
+ tags.append("vector")
125
+ medium = "clean vector game art"
126
+ palette = "flat separated color fields with bold accent colors"
127
+ texture = "smooth fills, no painterly grain"
128
+ lighting = "graphic cel shading with crisp highlights"
129
+ linework = "thick precise outlines and hard-edged silhouettes"
130
+ elif has("clay", "claymation", "stop motion", "plasticine"):
131
+ tags.append("clay")
132
+ medium = "claymation stop-motion game asset"
133
+ palette = "chunky colored clay with warm handmade tones"
134
+ texture = "fingerprint-like clay bumps, rounded sculpted forms"
135
+ lighting = "soft studio light with small specular highlights"
136
+ linework = "no ink outlines, shape defined by soft shadows"
137
+ elif has("oil", "painterly", "impasto", "canvas"):
138
+ tags.append("oil")
139
+ medium = "oil-painted fantasy game art"
140
+ palette = "rich blended colors with deep shadows"
141
+ texture = "visible bristle strokes and canvas-like texture"
142
+ lighting = "dramatic directional light"
143
+ linework = "painted edges instead of hard outlines"
144
+ elif has("pixel", "8-bit", "16-bit", "retro"):
145
+ tags.append("pixel")
146
+ medium = "retro pixel-art game asset"
147
+ palette = "limited palette with readable color ramps"
148
+ texture = "sharp square pixels and no anti-aliasing"
149
+ lighting = "simple two-tone pixel shading"
150
+ linework = "one-pixel dark outline and blocky silhouette"
151
+ elif has("anime", "manga"):
152
+ tags.append("anime")
153
+ medium = "anime game illustration"
154
+ palette = "clean saturated colors with expressive accents"
155
+ texture = "smooth cel-shaded surfaces"
156
+ lighting = "bright rim light and glossy highlights"
157
+ linework = "confident manga-style outlines"
158
+ elif has("cyber", "neon", "synthwave"):
159
+ tags.append("vector")
160
+ tags.append("neon")
161
+ medium = "neon cyberpunk arcade game art"
162
+ palette = "electric cyan, magenta, violet, and black"
163
+ texture = "glowing edges, glossy panels, light bloom"
164
+ lighting = "high contrast neon rim lighting"
165
+ linework = "hard sci-fi outlines with luminous accents"
166
+ else:
167
+ tags.append("illustration")
168
+ medium = "cohesive 2D game illustration"
169
+ palette = "distinct theme-driven colors with clear value contrast"
170
+ texture = "clean readable game asset finish"
171
+ lighting = "balanced game lighting"
172
+ linework = "clear readable silhouette and controlled edges"
173
+
174
+ if has("top-down", "top down", "shooter", "rpg", "arena"):
175
+ camera = "top-down readable game camera"
176
+ elif has("platformer", "side-scroller", "side scroller"):
177
+ camera = "side-view platformer camera"
178
+ else:
179
+ camera = "game-ready camera angle matching the role"
180
+
181
+ return StylePlan(medium, palette, texture, lighting, linework, camera, tuple(tags))
182
+
183
+
184
+ def build_asset_prompt(role: str, prompt: str, style_hint: str) -> str:
185
+ plan = interpret_style_hint(style_hint)
186
+ return (
187
+ f"{role} asset: {prompt}. Style interpretation: {plan.medium}; {plan.palette}; "
188
+ f"{plan.texture}; {plan.lighting}; {plan.linework}; {plan.camera}. "
189
+ "Transparent background for characters and items unless the role is a background. "
190
+ "Game asset, readable at small size, no text, no watermark."
191
+ )
192
+
193
+
194
  def parse_assets(raw_roles: str, style_hint: str) -> list[AssetSpec]:
195
  specs: list[AssetSpec] = []
196
  for line in raw_roles.splitlines():
 
211
  is_background = any(word in slug for word in ("background", "backdrop", "scene", "map", "level"))
212
  width, height = (800, 450) if is_background else (128, 128)
213
  filename = f"sprite_{slug}.png"
214
+ full_prompt = build_asset_prompt(role, prompt, style_hint)
 
 
 
215
  specs.append(AssetSpec(role=role, prompt=full_prompt, filename=filename, width=width, height=height))
216
  return specs
217
 
 
248
 
249
  def palette_for(text: str) -> list[tuple[int, int, int]]:
250
  lowered = text.lower()
251
+ if any(word in lowered for word in ("watercolor", "ink wash", "gouache", "paper grain")):
252
+ return [(113, 151, 168), (207, 180, 158), (241, 232, 214), (154, 118, 148)]
253
+ if any(word in lowered for word in ("clay", "claymation", "stop-motion", "plasticine")):
254
+ return [(204, 105, 83), (238, 175, 104), (107, 72, 61), (242, 213, 151)]
255
+ if any(word in lowered for word in ("oil", "painterly", "impasto", "canvas")):
256
+ return [(98, 45, 122), (213, 154, 70), (38, 28, 42), (230, 212, 156)]
257
+ if any(word in lowered for word in ("vector", "flat", "logo")):
258
+ return [(42, 125, 246), (255, 81, 128), (18, 24, 38), (255, 221, 64)]
259
+ if any(word in lowered for word in ("anime", "manga")):
260
+ return [(78, 151, 236), (255, 139, 173), (42, 45, 76), (255, 238, 166)]
261
  themed = [
262
  (("lava", "fire", "volcano", "hell"), [(139, 28, 18), (239, 84, 34), (42, 18, 16), (255, 198, 71)]),
263
  (("ice", "snow", "frost", "arctic"), [(75, 151, 191), (187, 235, 255), (24, 63, 96), (240, 252, 255)]),
 
299
  ]
300
 
301
 
302
+ def style_tags_for(prompt: str) -> set[str]:
303
+ lowered = prompt.lower()
304
+ tags = set()
305
+ groups = {
306
+ "watercolor": ("watercolor", "ink wash", "gouache", "paper grain"),
307
+ "vector": ("vector", "flat", "logo", "hard-edged", "graphic"),
308
+ "clay": ("clay", "claymation", "stop-motion", "plasticine", "sculpted"),
309
+ "oil": ("oil", "painterly", "impasto", "canvas", "bristle"),
310
+ "pixel": ("pixel", "8-bit", "16-bit", "retro"),
311
+ "anime": ("anime", "manga", "cel-shaded"),
312
+ "neon": ("neon", "cyberpunk", "synthwave", "glowing"),
313
+ }
314
+ for tag, words in groups.items():
315
+ if any(word in lowered for word in words):
316
+ tags.add(tag)
317
+ return tags
318
+
319
+
320
+ def apply_style_finish(content: bytes, spec: AssetSpec, rng: random.Random) -> bytes:
321
+ tags = style_tags_for(spec.prompt)
322
+ if not tags:
323
+ return content
324
+
325
+ image = Image.open(io.BytesIO(content)).convert("RGBA")
326
+ draw = ImageDraw.Draw(image, "RGBA")
327
+ is_bg = is_background_spec(spec)
328
+
329
+ if "watercolor" in tags:
330
+ wash = Image.new("RGBA", image.size, (245, 238, 220, 34 if is_bg else 12))
331
+ image = Image.alpha_composite(image, wash)
332
+ draw = ImageDraw.Draw(image, "RGBA")
333
+ for _ in range(60 if is_bg else 18):
334
+ x = rng.randint(-20, image.width)
335
+ y = rng.randint(-20, image.height)
336
+ r = rng.randint(10, 70 if is_bg else 24)
337
+ color = rng.choice(palette_for(spec.prompt)) + (rng.randint(18, 55),)
338
+ draw.ellipse((x - r, y - r, x + r, y + r), fill=color)
339
+ image = image.filter(ImageFilter.GaussianBlur(0.45))
340
+
341
+ if "oil" in tags:
342
+ for _ in range(120 if is_bg else 34):
343
+ x = rng.randint(0, image.width)
344
+ y = rng.randint(0, image.height)
345
+ length = rng.randint(8, 38 if is_bg else 16)
346
+ color = rng.choice(palette_for(spec.prompt)) + (rng.randint(75, 145),)
347
+ draw.line((x, y, x + rng.randint(-length, length), y + rng.randint(-length, length)), fill=color, width=rng.randint(2, 5))
348
+ image = ImageEnhance.Contrast(image).enhance(1.12)
349
+
350
+ if "clay" in tags:
351
+ image = ImageEnhance.Color(image).enhance(0.82)
352
+ image = ImageEnhance.Contrast(image).enhance(1.18)
353
+ draw = ImageDraw.Draw(image, "RGBA")
354
+ for _ in range(45 if is_bg else 14):
355
+ x = rng.randint(0, image.width)
356
+ y = rng.randint(0, image.height)
357
+ r = rng.randint(3, 18 if is_bg else 8)
358
+ draw.ellipse((x - r, y - r, x + r, y + r), outline=(255, 238, 205, rng.randint(25, 70)), width=2)
359
+ image = image.filter(ImageFilter.SMOOTH_MORE)
360
+
361
+ if "vector" in tags:
362
+ image = ImageEnhance.Contrast(image).enhance(1.35)
363
+ image = ImageEnhance.Color(image).enhance(1.25)
364
+ if not is_bg:
365
+ alpha = image.getchannel("A")
366
+ outline = alpha.filter(ImageFilter.MaxFilter(7)).filter(ImageFilter.GaussianBlur(0.2))
367
+ outlined = Image.new("RGBA", image.size, (12, 16, 28, 255))
368
+ outlined.putalpha(outline)
369
+ image = Image.alpha_composite(outlined, image)
370
+
371
+ if "anime" in tags:
372
+ image = ImageEnhance.Color(image).enhance(1.35)
373
+ image = ImageEnhance.Contrast(image).enhance(1.18)
374
+ draw = ImageDraw.Draw(image, "RGBA")
375
+ for _ in range(8 if is_bg else 4):
376
+ x = rng.randint(0, image.width)
377
+ y = rng.randint(0, image.height)
378
+ draw.line((x, y, x + rng.randint(20, 80), y - rng.randint(10, 50)), fill=(255, 255, 255, 80), width=2)
379
+
380
+ if "neon" in tags:
381
+ glow = image.filter(ImageFilter.GaussianBlur(4 if is_bg else 3))
382
+ image = Image.blend(glow, image, 0.72)
383
+ image = ImageEnhance.Color(image).enhance(1.45)
384
+
385
+ if "pixel" in tags:
386
+ small = image.resize((max(1, image.width // 4), max(1, image.height // 4)), Image.Resampling.NEAREST)
387
+ image = small.resize(image.size, Image.Resampling.NEAREST)
388
+
389
+ out = io.BytesIO()
390
+ image.save(out, format="PNG")
391
+ return out.getvalue()
392
+
393
+
394
  def draw_background(spec: AssetSpec, rng: random.Random) -> bytes:
395
  colors = palette_for(spec.prompt)
396
  image = Image.new("RGBA", (spec.width, spec.height), colors[2] + (255,))
 
732
  seed_text = f"{spec.role}|{spec.prompt}|{index}|{run_id}"
733
  rng = random.Random(seed_text)
734
  if is_background_spec(spec):
735
+ content = draw_background(spec, rng)
736
+ else:
737
+ content = draw_sprite(spec, rng)
738
+ return apply_style_finish(content, spec, rng)
739
 
740
 
741
  def placeholder_png_bytes(role: str, width: int, height: int) -> bytes:
 
753
  return out.getvalue()
754
 
755
 
756
+ def hf_image_png(spec: AssetSpec, index: int, run_id: int) -> bytes | None:
757
+ if not HF_TOKEN:
758
+ return None
759
+
760
+ payload = {
761
+ "inputs": spec.prompt,
762
+ "parameters": {
763
+ "width": spec.width,
764
+ "height": spec.height,
765
+ "num_inference_steps": 4,
766
+ "guidance_scale": 0.0,
767
+ "seed": abs(hash(f"{spec.role}|{spec.prompt}|{index}|{run_id}")) % 2147483647,
768
+ },
769
+ "options": {"wait_for_model": True},
770
+ }
771
+ request = Request(
772
+ HF_IMAGE_ENDPOINT,
773
+ data=json.dumps(payload).encode("utf-8"),
774
+ headers={
775
+ "Authorization": f"Bearer {HF_TOKEN}",
776
+ "Content-Type": "application/json",
777
+ "Accept": "image/png",
778
+ },
779
+ method="POST",
780
+ )
781
+ try:
782
+ with urlopen(request, timeout=90) as response:
783
+ content_type = response.headers.get("content-type", "")
784
+ content = response.read()
785
+ if "image" not in content_type:
786
+ return None
787
+ return image_to_png_bytes(content, spec.width, spec.height)
788
+ except Exception:
789
+ return None
790
+
791
+
792
  def generate_asset(spec: AssetSpec, index: int, run_id: int) -> tuple[str, str, str | None]:
793
+ png_content = hf_image_png(spec, index, run_id)
794
+ source = "hf"
795
+ if png_content is None:
796
+ png_content = local_asset_png(spec, index, run_id)
797
+ source = "local style fallback"
798
  return (
799
  png_bytes_to_data_uri(png_content),
800
  write_gallery_image(png_content, spec.role),
801
+ None if source == "hf" else source,
802
  )
803
 
804
 
 
940
  )
941
 
942
 
943
+ def build_prompt_preview(specs: list[AssetSpec]) -> str:
944
+ return "\n\n".join(f"{spec.role}:\n{spec.prompt}" for spec in specs)
945
+
946
+
947
  def generate_images_and_game(html_code: str, roles: str, style_hint: str):
948
  if not html_code.strip():
949
+ return "", "Paste HTML game code first.", [], "", ""
950
 
951
  specs = parse_assets(roles, style_hint or "pixel art style")
952
  if not specs:
953
+ return html_code, "Add at least one asset role, like `player: brave knight`.", [], "", build_preview(html_code)
954
 
955
  assets: dict[str, str] = {}
956
  gallery = []
 
965
  errors.append(f"{spec.role}: fallback used ({error})")
966
 
967
  rewritten = embed_assets(html_code, assets, specs)
968
+ using_hf = bool(HF_TOKEN)
969
+ source = f"HF image model `{HF_IMAGE_MODEL}`" if using_hf else "local style fallback"
970
+ status = f"Generated and embedded {len(specs)} fresh asset(s) using {source}. Run {str(run_id)[-6:]}."
971
  if errors:
972
+ status += "\n\n" + "\n".join(f"{item}: no HF image returned, used local style fallback" for item in [e.split(':', 1)[0] for e in errors])
973
+ return rewritten, status, gallery, build_prompt_preview(specs), build_preview(rewritten)
974
 
975
 
976
  with gr.Blocks(title="Image Generator for HTML Games") as demo:
 
1009
  language="html",
1010
  lines=18,
1011
  )
1012
+ prompt_preview = gr.Textbox(
1013
+ label="Interpreted image prompts",
1014
+ lines=8,
1015
+ interactive=False,
1016
+ )
1017
 
1018
  gr.Markdown("## Game preview")
1019
  preview = gr.HTML(build_preview(STARTER_HTML))
 
1021
  generate_btn.click(
1022
  fn=generate_images_and_game,
1023
  inputs=[html_input, roles, style],
1024
+ outputs=[output_code, status, gallery, prompt_preview, preview],
1025
  )
1026
 
1027