NANI-Nithin commited on
Commit
1e19a89
Β·
1 Parent(s): 07fb364

feat: Enhance poster generation with FLUX img2img support and improved styling

Browse files
Files changed (2) hide show
  1. app.py +6 -0
  2. 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
- # Save to disk
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 uploaded photos exist β†’ build a photo collage (instant, no GPU).
313
- 2. Otherwise β†’ fall back to FLUX text-to-image (needs GPU).
 
 
 
 
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
- # ── Strategy 1: Photo collage from uploaded gameplay photos ─────────
324
- if photo_paths:
325
- print(f"[image_gen] Attempting collage from {len(photo_paths)} photo(s)")
326
- collage = generate_collage_poster(photo_paths)
327
- if collage:
328
- return collage, (
329
- f"**Poster created from your gameplay photos!** πŸ“Έ\n"
330
- f"Saved as `{collage}` β€” refresh the Recap tab to view."
 
 
 
331
  )
332
- print("[image_gen] Collage failed, falling back to FLUX text-to-image")
333
 
334
- # ── Strategy 2: FLUX text-to-image (GPU required) ─────────────────
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"\\u2014 refresh the Recap tab to view."
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"