Commit Β·
1e19a89
1
Parent(s): 07fb364
feat: Enhance poster generation with FLUX img2img support and improved styling
Browse files- app.py +6 -0
- app/services/image_gen.py +133 -23
app.py
CHANGED
|
@@ -220,6 +220,7 @@ def use_hint(session_id: str, task_id: str, team_id: str = "team-a"):
|
|
| 220 |
return f"π‘ Hint used for {task_id} (β5 pts)"
|
| 221 |
|
| 222 |
|
|
|
|
| 223 |
def record_journal(
|
| 224 |
session_id: str,
|
| 225 |
transcript: str = "",
|
|
@@ -231,6 +232,11 @@ def record_journal(
|
|
| 231 |
):
|
| 232 |
"""Record a journal entry, summarize it, and return the result.
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
Two input paths are supported:
|
| 235 |
|
| 236 |
* **Voice path** β pass ``audio_path`` (e.g. a path returned by
|
|
|
|
| 220 |
return f"π‘ Hint used for {task_id} (β5 pts)"
|
| 221 |
|
| 222 |
|
| 223 |
+
@spaces.GPU(duration=120)
|
| 224 |
def record_journal(
|
| 225 |
session_id: str,
|
| 226 |
transcript: str = "",
|
|
|
|
| 232 |
):
|
| 233 |
"""Record a journal entry, summarize it, and return the result.
|
| 234 |
|
| 235 |
+
Decorated with ``@spaces.GPU`` so the Cohere ASR model can run on the
|
| 236 |
+
GPU. On HF ZeroGPU, ``torch.cuda.is_available()`` is ``False`` outside
|
| 237 |
+
a ``@spaces.GPU`` function, so without this the ASR model would load
|
| 238 |
+
on CPU and the voice-journal button would appear to hang.
|
| 239 |
+
|
| 240 |
Two input paths are supported:
|
| 241 |
|
| 242 |
* **Voice path** β pass ``audio_path`` (e.g. a path returned by
|
app/services/image_gen.py
CHANGED
|
@@ -24,9 +24,20 @@ POSTER_DIR = Path("app/assets/posters")
|
|
| 24 |
# ββ Lazy model state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
_model = None
|
| 26 |
_model_loaded = False
|
|
|
|
| 27 |
_skip_env_var = "CITYQUEST_SKIP_MODEL"
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def _load_model() -> Optional[object]:
|
| 31 |
"""Lazy-load the FLUX.1-schnell pipeline via diffusers on first call.
|
| 32 |
|
|
@@ -137,7 +148,7 @@ def generate_poster(poster_prompt: str) -> Optional[str]:
|
|
| 137 |
|
| 138 |
print(f"[image_gen] Generating poster for prompt ({len(poster_prompt)} chars) ...")
|
| 139 |
image = pipe(
|
| 140 |
-
prompt=poster_prompt,
|
| 141 |
guidance_scale=0.0, # schnell uses 0.0
|
| 142 |
num_inference_steps=4, # schnell: 4 steps is enough
|
| 143 |
width=1024,
|
|
@@ -145,19 +156,101 @@ def generate_poster(poster_prompt: str) -> Optional[str]:
|
|
| 145 |
generator=torch.Generator(device="cuda" if torch.cuda.is_available() else "cpu").manual_seed(42),
|
| 146 |
).images[0]
|
| 147 |
|
| 148 |
-
|
| 149 |
-
POSTER_DIR.mkdir(parents=True, exist_ok=True)
|
| 150 |
-
filename = f"poster_{uuid.uuid4().hex[:8]}.png"
|
| 151 |
-
filepath = POSTER_DIR / filename
|
| 152 |
-
image.save(str(filepath))
|
| 153 |
-
print(f"[image_gen] Poster saved -> {filepath}")
|
| 154 |
-
return str(filepath)
|
| 155 |
|
| 156 |
except Exception as e:
|
| 157 |
print(f"[image_gen] Poster generation failed: {type(e).__name__}: {e}")
|
| 158 |
return None
|
| 159 |
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
# ββ Photo collage poster (uses uploaded gameplay photos) βββββββββββββββββ
|
| 162 |
|
| 163 |
def generate_collage_poster(
|
|
@@ -308,9 +401,13 @@ def generate_poster_sync(
|
|
| 308 |
) -> tuple[Optional[str], str]:
|
| 309 |
"""Generate a poster and return (image_path, status_message).
|
| 310 |
|
| 311 |
-
Strategy:
|
| 312 |
-
1. If
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
Args:
|
| 316 |
session_id: Session identifier (for logging).
|
|
@@ -320,26 +417,39 @@ def generate_poster_sync(
|
|
| 320 |
Returns:
|
| 321 |
Tuple of (image_path_or_None, status_message).
|
| 322 |
"""
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
| 331 |
)
|
| 332 |
-
print("[image_gen]
|
| 333 |
|
| 334 |
-
# ββ Strategy 2: FLUX text-to-image
|
| 335 |
image_path = generate_poster(poster_prompt)
|
| 336 |
-
|
| 337 |
if image_path:
|
| 338 |
return image_path, (
|
| 339 |
f"**Poster generated!** Saved as `{image_path}` "
|
| 340 |
-
f"
|
| 341 |
)
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
return None, (
|
| 344 |
"β οΈ **Poster generation unavailable** β FLUX.1-schnell model could not be loaded.\n\n"
|
| 345 |
"Possible causes:\n"
|
|
|
|
| 24 |
# ββ Lazy model state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
_model = None
|
| 26 |
_model_loaded = False
|
| 27 |
+
_img2img_model = None
|
| 28 |
_skip_env_var = "CITYQUEST_SKIP_MODEL"
|
| 29 |
|
| 30 |
|
| 31 |
+
# ββ Poster styling βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
# Appended to every FLUX prompt so the output reads as a polished poster
|
| 33 |
+
# rather than a literal snapshot.
|
| 34 |
+
POSTER_STYLE = (
|
| 35 |
+
" Illustrated travel-poster art style, bold dynamic composition, "
|
| 36 |
+
"rich saturated colors, dramatic lighting, clean graphic shapes, "
|
| 37 |
+
"high detail, trending on ArtStation. No text, no watermark."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
def _load_model() -> Optional[object]:
|
| 42 |
"""Lazy-load the FLUX.1-schnell pipeline via diffusers on first call.
|
| 43 |
|
|
|
|
| 148 |
|
| 149 |
print(f"[image_gen] Generating poster for prompt ({len(poster_prompt)} chars) ...")
|
| 150 |
image = pipe(
|
| 151 |
+
prompt=poster_prompt + POSTER_STYLE,
|
| 152 |
guidance_scale=0.0, # schnell uses 0.0
|
| 153 |
num_inference_steps=4, # schnell: 4 steps is enough
|
| 154 |
width=1024,
|
|
|
|
| 156 |
generator=torch.Generator(device="cuda" if torch.cuda.is_available() else "cpu").manual_seed(42),
|
| 157 |
).images[0]
|
| 158 |
|
| 159 |
+
return _save_poster(image)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
except Exception as e:
|
| 162 |
print(f"[image_gen] Poster generation failed: {type(e).__name__}: {e}")
|
| 163 |
return None
|
| 164 |
|
| 165 |
|
| 166 |
+
def _load_img2img_model() -> Optional[object]:
|
| 167 |
+
"""Lazy-load a FLUX image-to-image pipeline, reusing the text-to-image
|
| 168 |
+
model's weights so we don't load a second ~24 GB copy.
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
A ``FluxImg2ImgPipeline``, or ``None`` on failure / skip.
|
| 172 |
+
"""
|
| 173 |
+
global _img2img_model
|
| 174 |
+
if _img2img_model is not None:
|
| 175 |
+
return _img2img_model
|
| 176 |
+
|
| 177 |
+
pipe = _load_model() # the base text-to-image FLUX pipeline
|
| 178 |
+
if pipe is None:
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
try:
|
| 182 |
+
from diffusers import FluxImg2ImgPipeline
|
| 183 |
+
|
| 184 |
+
# from_pipe reuses the already-loaded components (and any CPU-offload
|
| 185 |
+
# hooks) instead of downloading / instantiating a second model.
|
| 186 |
+
_img2img_model = FluxImg2ImgPipeline.from_pipe(pipe)
|
| 187 |
+
print("[image_gen] FLUX img2img pipeline ready")
|
| 188 |
+
return _img2img_model
|
| 189 |
+
except Exception as e:
|
| 190 |
+
print(f"[image_gen] FLUX img2img init failed: {type(e).__name__}: {e}")
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def generate_poster_img2img(
|
| 195 |
+
poster_prompt: str,
|
| 196 |
+
photo_path: str,
|
| 197 |
+
strength: float = 0.8,
|
| 198 |
+
) -> Optional[str]:
|
| 199 |
+
"""Generate a poster by letting FLUX re-imagine an uploaded gameplay photo.
|
| 200 |
+
|
| 201 |
+
The photo seeds the composition/colors; the prompt drives the poster
|
| 202 |
+
look. ``strength`` controls how far FLUX moves from the photo
|
| 203 |
+
(0 = identical, 1 = ignore photo). ~0.8 keeps the scene recognizable
|
| 204 |
+
while turning it into a stylized poster.
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
poster_prompt: Text prompt describing the desired poster.
|
| 208 |
+
photo_path: Path to an uploaded gameplay photo to use as the seed.
|
| 209 |
+
strength: Denoising strength in ``[0, 1]``.
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
Filesystem path to the saved poster, or ``None`` on failure.
|
| 213 |
+
"""
|
| 214 |
+
if not Path(photo_path).exists():
|
| 215 |
+
print(f"[image_gen] img2img photo not found: {photo_path}")
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
pipe = _load_img2img_model()
|
| 219 |
+
if pipe is None:
|
| 220 |
+
return None
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
import torch
|
| 224 |
+
from PIL import Image
|
| 225 |
+
|
| 226 |
+
init_image = Image.open(photo_path).convert("RGB").resize((1024, 768))
|
| 227 |
+
print(f"[image_gen] Re-imagining {photo_path} via FLUX img2img (strength={strength}) ...")
|
| 228 |
+
image = pipe(
|
| 229 |
+
prompt=poster_prompt + POSTER_STYLE,
|
| 230 |
+
image=init_image,
|
| 231 |
+
strength=strength,
|
| 232 |
+
guidance_scale=0.0, # schnell uses 0.0
|
| 233 |
+
num_inference_steps=8, # effective steps β steps * strength
|
| 234 |
+
generator=torch.Generator(device="cuda" if torch.cuda.is_available() else "cpu").manual_seed(42),
|
| 235 |
+
).images[0]
|
| 236 |
+
|
| 237 |
+
return _save_poster(image)
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
print(f"[image_gen] img2img poster generation failed: {type(e).__name__}: {e}")
|
| 241 |
+
return None
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _save_poster(image) -> str:
|
| 245 |
+
"""Save a PIL image to the poster directory and return its path."""
|
| 246 |
+
POSTER_DIR.mkdir(parents=True, exist_ok=True)
|
| 247 |
+
filename = f"poster_{uuid.uuid4().hex[:8]}.png"
|
| 248 |
+
filepath = POSTER_DIR / filename
|
| 249 |
+
image.save(str(filepath))
|
| 250 |
+
print(f"[image_gen] Poster saved -> {filepath}")
|
| 251 |
+
return str(filepath)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
# ββ Photo collage poster (uses uploaded gameplay photos) βββββββββββββββββ
|
| 255 |
|
| 256 |
def generate_collage_poster(
|
|
|
|
| 401 |
) -> tuple[Optional[str], str]:
|
| 402 |
"""Generate a poster and return (image_path, status_message).
|
| 403 |
|
| 404 |
+
Strategy (in priority order):
|
| 405 |
+
1. If a gameplay photo exists β **FLUX image-to-image**: the model
|
| 406 |
+
re-imagines the photo into a stylized poster (GPU). This is the
|
| 407 |
+
desired behavior β a *generated* poster seeded by a real moment.
|
| 408 |
+
2. No photo β **FLUX text-to-image** from the prompt (GPU).
|
| 409 |
+
3. FLUX unavailable (offline / no GPU) β Pillow photo collage as a
|
| 410 |
+
graceful fallback so the demo still produces something.
|
| 411 |
|
| 412 |
Args:
|
| 413 |
session_id: Session identifier (for logging).
|
|
|
|
| 417 |
Returns:
|
| 418 |
Tuple of (image_path_or_None, status_message).
|
| 419 |
"""
|
| 420 |
+
valid_photos = [p for p in (photo_paths or []) if p and Path(p).exists()]
|
| 421 |
+
|
| 422 |
+
# ββ Strategy 1: FLUX img2img from an uploaded gameplay photo βββββββββ
|
| 423 |
+
if valid_photos:
|
| 424 |
+
print(f"[image_gen] Attempting FLUX img2img from {len(valid_photos)} photo(s)")
|
| 425 |
+
image_path = generate_poster_img2img(poster_prompt, valid_photos[0])
|
| 426 |
+
if image_path:
|
| 427 |
+
return image_path, (
|
| 428 |
+
f"**AI poster generated from your photo!** π¨\n"
|
| 429 |
+
f"FLUX re-imagined your gameplay shot into a poster β "
|
| 430 |
+
f"saved as `{image_path}`."
|
| 431 |
)
|
| 432 |
+
print("[image_gen] img2img failed, falling back to FLUX text-to-image")
|
| 433 |
|
| 434 |
+
# ββ Strategy 2: FLUX text-to-image from the prompt βββββββββββββββββ
|
| 435 |
image_path = generate_poster(poster_prompt)
|
|
|
|
| 436 |
if image_path:
|
| 437 |
return image_path, (
|
| 438 |
f"**Poster generated!** Saved as `{image_path}` "
|
| 439 |
+
f"β refresh the Recap tab to view."
|
| 440 |
)
|
| 441 |
|
| 442 |
+
# ββ Strategy 3: Pillow collage fallback (offline / no GPU) βββββββββ
|
| 443 |
+
if valid_photos:
|
| 444 |
+
print("[image_gen] FLUX unavailable, falling back to photo collage")
|
| 445 |
+
collage = generate_collage_poster(valid_photos)
|
| 446 |
+
if collage:
|
| 447 |
+
return collage, (
|
| 448 |
+
f"**Poster created from your gameplay photos.** πΈ\n"
|
| 449 |
+
f"_(FLUX was unavailable, so this is a collage of your photos "
|
| 450 |
+
f"rather than an AI-generated poster.)_ Saved as `{collage}`."
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
return None, (
|
| 454 |
"β οΈ **Poster generation unavailable** β FLUX.1-schnell model could not be loaded.\n\n"
|
| 455 |
"Possible causes:\n"
|