multimodalart HF Staff commited on
Commit
af4a1b8
·
verified ·
1 Parent(s): 457de60

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +95 -426
app.py CHANGED
@@ -38,15 +38,15 @@ pipe = QwenImageEditPlusPipeline.from_pretrained(
38
  torch_dtype=dtype
39
  ).to(device)
40
 
 
41
  pipe.load_lora_weights(
42
- "dx8152/Qwen-Edit-2509-Multiple-angles",
43
- weight_name="镜头转换.safetensors",
44
- adapter_name="angles"
45
  )
46
 
47
-
48
- pipe.set_adapters(["angles"], adapter_weights=[1.])
49
- pipe.fuse_lora(adapter_names=["angles"], lora_scale=1.25)
50
  pipe.unload_lora_weights()
51
 
52
  pipe.transformer.__class__ = QwenImageTransformer2DModel
@@ -60,152 +60,34 @@ optimize_pipeline_(
60
 
61
  MAX_SEED = np.iinfo(np.int32).max
62
 
63
-
64
- def _generate_video_segment(
65
- input_image_path: str,
66
- output_image_path: str,
67
- prompt: str,
68
- request: gr.Request
69
- ) -> str:
70
- """
71
- Generate a single video segment between two frames by calling an external
72
- Wan 2.2 image-to-video service hosted on Hugging Face Spaces.
73
-
74
- This helper function is used internally when the user asks to create
75
- a video between the input and output images.
76
-
77
- Args:
78
- input_image_path (str):
79
- Path to the starting frame image on disk.
80
- output_image_path (str):
81
- Path to the ending frame image on disk.
82
- prompt (str):
83
- Text prompt describing the camera movement / transition.
84
- request (gr.Request):
85
- Gradio request object, used here to forward the `x-ip-token`
86
- header to the downstream Space for authentication/rate limiting.
87
-
88
- Returns:
89
- str:
90
- A string returned by the external service, usually a URL or path
91
- to the generated video.
92
- """
93
- x_ip_token = request.headers['x-ip-token']
94
- video_client = Client(
95
- "multimodalart/wan-2-2-first-last-frame",
96
- headers={"x-ip-token": x_ip_token}
97
- )
98
- result = video_client.predict(
99
- start_image_pil=handle_file(input_image_path),
100
- end_image_pil=handle_file(output_image_path),
101
- prompt=prompt,
102
- api_name="/generate_video",
103
- )
104
- return result[0]["video"]
105
-
106
-
107
- def build_camera_prompt(
108
- rotate_deg: float = 0.0,
109
- move_forward: float = 0.0,
110
- vertical_tilt: float = 0.0,
111
- wideangle: bool = False
112
- ) -> str:
113
- """
114
- Build a camera movement prompt based on the chosen controls.
115
-
116
- This converts the provided control values into a prompt instruction with the corresponding trigger words for the multiple-angles LoRA.
117
-
118
- Args:
119
- rotate_deg (float, optional):
120
- Horizontal rotation in degrees. Positive values rotate left,
121
- negative values rotate right. Defaults to 0.0.
122
- move_forward (float, optional):
123
- Forward movement / zoom factor. Larger values imply moving the
124
- camera closer or into a close-up. Defaults to 0.0.
125
- vertical_tilt (float, optional):
126
- Vertical angle of the camera:
127
- - Negative ≈ bird's-eye view
128
- - Positive ≈ worm's-eye view
129
- Defaults to 0.0.
130
- wideangle (bool, optional):
131
- Whether to switch to a wide-angle lens style. Defaults to False.
132
-
133
- Returns:
134
- str:
135
- A text prompt describing the camera motion. If no controls are
136
- active, returns `"no camera movement"`.
137
- """
138
- prompt_parts = []
139
-
140
- # Rotation
141
- if rotate_deg != 0:
142
- direction = "left" if rotate_deg > 0 else "right"
143
- if direction == "left":
144
- prompt_parts.append(
145
- f"将镜头向左旋转{abs(rotate_deg)}度 Rotate the camera {abs(rotate_deg)} degrees to the left."
146
- )
147
- else:
148
- prompt_parts.append(
149
- f"将镜头向右旋转{abs(rotate_deg)}度 Rotate the camera {abs(rotate_deg)} degrees to the right."
150
- )
151
-
152
- # Move forward / close-up
153
- if move_forward > 5:
154
- prompt_parts.append("将镜头转为特写镜头 Turn the camera to a close-up.")
155
- elif move_forward >= 1:
156
- prompt_parts.append("将镜头向前移动 Move the camera forward.")
157
-
158
- # Vertical tilt
159
- if vertical_tilt <= -1:
160
- prompt_parts.append("将相机转向鸟瞰视角 Turn the camera to a bird's-eye view.")
161
- elif vertical_tilt >= 1:
162
- prompt_parts.append("将相机切换到仰视视角 Turn the camera to a worm's-eye view.")
163
-
164
- # Lens option
165
- if wideangle:
166
- prompt_parts.append(" 将镜头转为广角镜头 Turn the camera to a wide-angle lens.")
167
-
168
- final_prompt = " ".join(prompt_parts).strip()
169
- return final_prompt if final_prompt else "no camera movement"
170
 
171
 
172
  @spaces.GPU
173
- def infer_camera_edit(
174
  image: Optional[Image.Image] = None,
175
- rotate_deg: float = 0.0,
176
- move_forward: float = 0.0,
177
- vertical_tilt: float = 0.0,
178
- wideangle: bool = False,
179
  seed: int = 0,
180
  randomize_seed: bool = True,
181
  true_guidance_scale: float = 1.0,
182
  num_inference_steps: int = 4,
183
  height: Optional[int] = None,
184
  width: Optional[int] = None,
185
- prev_output: Optional[Image.Image] = None,
186
- ) -> Tuple[Image.Image, int, str]:
187
  """
188
- Edit the camera angles/view of an image with Qwen Image Edit 2509 and dx8152's Qwen-Edit-2509-Multiple-angles LoRA.
189
-
190
- Applies a camera-style transformation (rotation, zoom, tilt, lens)
191
- to an input image.
192
 
193
  Args:
194
  image (PIL.Image.Image | None, optional):
195
- Input image to edit. If `None`, the function will instead try to
196
- use `prev_output`. At least one of `image` or `prev_output` must
197
- be available. Defaults to None.
198
- rotate_deg (float, optional):
199
- Horizontal rotation in degrees (-90, -45, 0, 45, 90). Positive values rotate
200
- to the left, negative to the right. Defaults to 0.0.
201
- move_forward (float, optional):
202
- Forward movement / zoom factor (0, 5, 10). Higher values move the
203
- camera closer; values >5 switch to a close-up style. Defaults to 0.0.
204
- vertical_tilt (float, optional):
205
- Vertical tilt (-1 to 1). -1 ≈ bird's-eye view, +1 ≈ worm's-eye view.
206
- Defaults to 0.0.
207
- wideangle (bool, optional):
208
- Whether to use a wide-angle lens style. Defaults to False.
209
  seed (int, optional):
210
  Random seed for the generation. Ignored if `randomize_seed=True`.
211
  Defaults to 0.
@@ -214,154 +96,58 @@ def infer_camera_edit(
214
  Defaults to True.
215
  true_guidance_scale (float, optional):
216
  CFG / guidance scale controlling prompt adherence.
217
- Defaults to 1.0 since the demo is using a distilled transformer for faster inference.
218
  num_inference_steps (int, optional):
219
  Number of inference steps. Defaults to 4.
220
  height (int, optional):
221
  Output image height. Must typically be a multiple of 8.
222
- If set to 0, the model will infer a size. Defaults to 1024 if none is provided.
223
  width (int, optional):
224
  Output image width. Must typically be a multiple of 8.
225
- If set to 0, the model will infer a size. Defaults to 1024 if none is provided.
226
- prev_output (PIL.Image.Image | None, optional):
227
- Previous output image to use as input when no new image is uploaded.
228
- Defaults to None.
229
 
230
  Returns:
231
- Tuple[PIL.Image.Image, int, str]:
232
- - The edited output image.
233
  - The actual seed used for generation.
234
- - The constructed camera prompt string.
235
  """
236
  progress = gr.Progress(track_tqdm=True)
 
 
 
237
 
238
- prompt = build_camera_prompt(rotate_deg, move_forward, vertical_tilt, wideangle)
239
- print(f"Generated Prompt: {prompt}")
240
 
241
  if randomize_seed:
242
  seed = random.randint(0, MAX_SEED)
243
  generator = torch.Generator(device=device).manual_seed(seed)
244
 
245
- # Choose input image (prefer uploaded, else last output)
246
  pil_images = []
247
- if image is not None:
248
- if isinstance(image, Image.Image):
249
- pil_images.append(image.convert("RGB"))
250
- elif hasattr(image, "name"):
251
- pil_images.append(Image.open(image.name).convert("RGB"))
252
- elif prev_output:
253
- pil_images.append(prev_output.convert("RGB"))
254
-
255
- if len(pil_images) == 0:
256
- raise gr.Error("Please upload an image first.")
257
-
258
- if prompt == "no camera movement":
259
- return image, seed, prompt
260
 
261
  result = pipe(
262
  image=pil_images,
263
  prompt=prompt,
264
- height=height if height != 0 else None,
265
- width=width if width != 0 else None,
266
  num_inference_steps=num_inference_steps,
267
  generator=generator,
268
  true_cfg_scale=true_guidance_scale,
269
  num_images_per_prompt=1,
270
  ).images[0]
271
 
272
- return result, seed, prompt
273
-
274
-
275
- def create_video_between_images(
276
- input_image: Optional[Image.Image],
277
- output_image: Optional[np.ndarray],
278
- prompt: str,
279
- request: gr.Request
280
- ) -> str:
281
- """
282
- Create a short transition video between the input and output images via the
283
- Wan 2.2 first-last-frame Space.
284
-
285
- Args:
286
- input_image (PIL.Image.Image | None):
287
- Starting frame image (the original / previous view).
288
- output_image (numpy.ndarray | None):
289
- Ending frame image - the output image with the the edited camera angles.
290
- prompt (str):
291
- The camera movement prompt used to describe the transition.
292
- request (gr.Request):
293
- Gradio request object, used to forward the `x-ip-token` header
294
- to the video generation app.
295
-
296
- Returns:
297
- str:
298
- a path pointing to the generated video.
299
-
300
- Raises:
301
- gr.Error:
302
- If either image is missing or if the video generation fails.
303
- """
304
- if input_image is None or output_image is None:
305
- raise gr.Error("Both input and output images are required to create a video.")
306
-
307
- try:
308
- with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
309
- input_image.save(tmp.name)
310
- input_image_path = tmp.name
311
-
312
- output_pil = Image.fromarray(output_image.astype('uint8'))
313
- with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
314
- output_pil.save(tmp.name)
315
- output_image_path = tmp.name
316
-
317
- video_path = _generate_video_segment(
318
- input_image_path,
319
- output_image_path,
320
- prompt if prompt else "Camera movement transformation",
321
- request
322
- )
323
- return video_path
324
- except Exception as e:
325
- raise gr.Error(f"Video generation failed: {e}")
326
-
327
-
328
- # --- UI ---
329
- css = '''#col-container { max-width: 800px; margin: 0 auto; }
330
- .dark .progress-text{color: white !important}
331
- #examples{max-width: 800px; margin: 0 auto; }'''
332
-
333
-
334
- def reset_all() -> list:
335
- """
336
- Reset all camera control knobs and flags to their default values.
337
-
338
- This is used by the "Reset" button to set:
339
- - rotate_deg = 0
340
- - move_forward = 0
341
- - vertical_tilt = 0
342
- - wideangle = False
343
- - is_reset = True
344
-
345
- Returns:
346
- list:
347
- A list of values matching the order of the reset outputs:
348
- [rotate_deg, move_forward, vertical_tilt, wideangle, is_reset, True]
349
- """
350
- return [0, 0, 0, 0, False, True]
351
-
352
-
353
- def end_reset() -> bool:
354
- """
355
- Mark the end of a reset cycle.
356
-
357
- This helper is chained after `reset_all` to set the internal
358
- `is_reset` flag back to False, so that live inference can resume.
359
-
360
- Returns:
361
- bool:
362
- Always returns False.
363
- """
364
- return False
365
 
366
 
367
  def update_dimensions_on_upload(
@@ -369,7 +155,7 @@ def update_dimensions_on_upload(
369
  ) -> Tuple[int, int]:
370
  """
371
  Compute recommended (width, height) for the output resolution when an
372
- image is uploaded while preserveing the aspect ratio.
373
 
374
  Args:
375
  image (PIL.Image.Image | None):
@@ -400,47 +186,49 @@ def update_dimensions_on_upload(
400
  return new_width, new_height
401
 
402
 
403
- with gr.Blocks() as demo:
 
 
 
 
 
 
 
 
404
  with gr.Column(elem_id="col-container"):
405
- gr.Markdown("## 🎬 Qwen Image Edit — Camera Angle Control")
406
  gr.Markdown("""
407
- Qwen Image Edit 2509 for Camera Control
408
- Using [dx8152's Qwen-Edit-2509-Multiple-angles LoRA](https://huggingface.co/dx8152/Qwen-Edit-2509-Multiple-angles) and [Phr00t/Qwen-Image-Edit-Rapid-AIO](https://huggingface.co/Phr00t/Qwen-Image-Edit-Rapid-AIO/tree/main) for 4-step inference 💨
409
- """
410
- )
 
 
 
 
411
 
412
  with gr.Row():
413
  with gr.Column():
414
- image = gr.Image(label="Input Image", type="pil")
415
- prev_output = gr.Image(value=None, visible=False)
416
- is_reset = gr.Checkbox(value=False, visible=False)
417
-
418
- with gr.Tab("Camera Controls"):
419
- rotate_deg = gr.Slider(
420
- label="Rotate Right-Left (degrees °)",
421
- minimum=-90,
422
- maximum=90,
423
- step=45,
424
- value=0
425
- )
426
- move_forward = gr.Slider(
427
- label="Move Forward → Close-Up",
428
- minimum=0,
429
- maximum=10,
430
- step=5,
431
- value=0
432
  )
433
- vertical_tilt = gr.Slider(
434
- label="Vertical Angle (Bird ↔ Worm)",
435
- minimum=-1,
436
- maximum=1,
437
- step=1,
438
- value=0
439
  )
440
- wideangle = gr.Checkbox(label="Wide-Angle Lens", value=False)
441
- with gr.Row():
442
- reset_btn = gr.Button("Reset")
443
- run_btn = gr.Button("Generate", variant="primary")
 
 
 
 
 
444
 
445
  with gr.Accordion("Advanced Settings", open=False):
446
  seed = gr.Slider(
@@ -485,149 +273,30 @@ with gr.Blocks() as demo:
485
 
486
  with gr.Column():
487
  result = gr.Image(label="Output Image", interactive=False)
488
- prompt_preview = gr.Textbox(label="Processed Prompt", interactive=False)
489
- create_video_button = gr.Button(
490
- "🎥 Create Video Between Images",
491
- variant="secondary",
492
- visible=False
493
- )
494
- with gr.Group(visible=False) as video_group:
495
- video_output = gr.Video(
496
- label="Generated Video",
497
- buttons=["download"],
498
- autoplay=True
499
- )
500
 
501
  inputs = [
502
- image, rotate_deg, move_forward,
503
- vertical_tilt, wideangle,
504
- seed, randomize_seed, true_guidance_scale, num_inference_steps, height, width, prev_output
505
  ]
506
- outputs = [result, seed, prompt_preview]
507
-
508
- # Reset behavior
509
- reset_btn.click(
510
- fn=reset_all,
511
- inputs=None,
512
- outputs=[rotate_deg, move_forward, vertical_tilt, wideangle, is_reset],
513
- queue=False
514
- ).then(fn=end_reset, inputs=None, outputs=[is_reset], queue=False)
515
-
516
- # Manual generation with video button visibility control
517
- def infer_and_show_video_button(*args: Any):
518
- """
519
- Wrapper around `infer_camera_edit` that also controls the visibility
520
- of the 'Create Video Between Images' button.
521
-
522
- The first argument in `args` is expected to be the input image; if both
523
- input and output images are present, the video button is shown.
524
-
525
- Args:
526
- *args:
527
- Positional arguments forwarded directly to `infer_camera_edit`.
528
-
529
- Returns:
530
- tuple:
531
- (output_image, seed, prompt, video_button_visibility_update)
532
- """
533
- result_img, result_seed, result_prompt = infer_camera_edit(*args)
534
- # Show video button if we have both input and output images
535
- show_button = args[0] is not None and result_img is not None
536
- return result_img, result_seed, result_prompt, gr.update(visible=show_button)
537
-
538
- run_event = run_btn.click(
539
- fn=infer_and_show_video_button,
540
- inputs=inputs,
541
- outputs=outputs + [create_video_button]
542
- )
543
 
544
- # Video creation
545
- create_video_button.click(
546
- fn=lambda: gr.update(visible=True),
547
- outputs=[video_group],
548
- api_visibility="private"
549
- ).then(
550
- fn=create_video_between_images,
551
- inputs=[image, result, prompt_preview],
552
- outputs=[video_output],
553
- api_visibility="private"
554
- )
555
-
556
- # Examples
557
- gr.Examples(
558
- examples=[
559
- ["tool_of_the_sea.png", 90, 0, 0, False, 0, True, 1.0, 4, 568, 1024],
560
- ["monkey.jpg", -90, 0, 0, False, 0, True, 1.0, 4, 704, 1024],
561
- ["metropolis.jpg", 0, 0, -1, False, 0, True, 1.0, 4, 816, 1024],
562
- ["disaster_girl.jpg", -45, 0, 1, False, 0, True, 1.0, 4, 768, 1024],
563
- ["grumpy.png", 90, 0, 1, False, 0, True, 1.0, 4, 576, 1024]
564
- ],
565
- inputs=[
566
- image, rotate_deg, move_forward,
567
- vertical_tilt, wideangle,
568
- seed, randomize_seed, true_guidance_scale, num_inference_steps, height, width
569
- ],
570
- outputs=outputs,
571
- fn=infer_camera_edit,
572
- cache_examples=True,
573
- cache_mode="lazy",
574
- elem_id="examples"
575
  )
576
 
577
- # Image upload triggers dimension update and control reset
578
  image.upload(
579
  fn=update_dimensions_on_upload,
580
  inputs=[image],
581
  outputs=[width, height]
582
- ).then(
583
- fn=reset_all,
584
- inputs=None,
585
- outputs=[rotate_deg, move_forward, vertical_tilt, wideangle, is_reset],
586
- queue=False
587
- ).then(
588
- fn=end_reset,
589
- inputs=None,
590
- outputs=[is_reset],
591
- queue=False
592
  )
593
 
594
- # Live updates
595
- def maybe_infer(
596
- is_reset: bool,
597
- progress: gr.Progress = gr.Progress(track_tqdm=True),
598
- *args: Any
599
- ):
600
- if is_reset:
601
- return gr.update(), gr.update(), gr.update(), gr.update()
602
- else:
603
- result_img, result_seed, result_prompt = infer_camera_edit(*args)
604
- # Show video button if we have both input and output
605
- show_button = args[0] is not None and result_img is not None
606
- return result_img, result_seed, result_prompt, gr.update(visible=show_button)
607
-
608
- control_inputs = [
609
- image, rotate_deg, move_forward,
610
- vertical_tilt, wideangle,
611
- seed, randomize_seed, true_guidance_scale, num_inference_steps, height, width, prev_output
612
- ]
613
- control_inputs_with_flag = [is_reset] + control_inputs
614
-
615
- for control in [rotate_deg, move_forward, vertical_tilt]:
616
- control.release(
617
- fn=maybe_infer,
618
- inputs=control_inputs_with_flag,
619
- outputs=outputs + [create_video_button]
620
- )
621
-
622
- wideangle.input(
623
- fn=maybe_infer,
624
- inputs=control_inputs_with_flag,
625
- outputs=outputs + [create_video_button]
626
- )
627
-
628
- run_event.then(lambda img, *_: img, inputs=[result], outputs=[prev_output])
629
-
630
- gr.api(infer_camera_edit, api_name="infer_edit_camera_angles")
631
- gr.api(create_video_between_images, api_name="create_video_between_images")
632
 
633
- demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=css, footer_links=["api", "gradio", "settings"])
 
38
  torch_dtype=dtype
39
  ).to(device)
40
 
41
+ # Load Light Migration LoRA
42
  pipe.load_lora_weights(
43
+ "dx8152/Qwen-Edit-2509-Light-Migration",
44
+ weight_name="参考色调.safetensors",
45
+ adapter_name="light_migration"
46
  )
47
 
48
+ pipe.set_adapters(["light_migration"], adapter_weights=[1.])
49
+ pipe.fuse_lora(adapter_names=["light_migration"], lora_scale=1.25)
 
50
  pipe.unload_lora_weights()
51
 
52
  pipe.transformer.__class__ = QwenImageTransformer2DModel
 
60
 
61
  MAX_SEED = np.iinfo(np.int32).max
62
 
63
+ # Default prompt for light migration
64
+ DEFAULT_PROMPT = "参考色调,移除图1原有的光照并参考图2的光照和色调对图1重新照明"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
 
67
  @spaces.GPU
68
+ def infer_light_migration(
69
  image: Optional[Image.Image] = None,
70
+ light_source: Optional[Image.Image] = None,
71
+ prompt: str = DEFAULT_PROMPT,
 
 
72
  seed: int = 0,
73
  randomize_seed: bool = True,
74
  true_guidance_scale: float = 1.0,
75
  num_inference_steps: int = 4,
76
  height: Optional[int] = None,
77
  width: Optional[int] = None,
78
+ ) -> Tuple[Image.Image, int]:
 
79
  """
80
+ Transfer lighting and color tones from a reference image to a source image
81
+ using Qwen Image Edit 2509 with the Light Migration LoRA.
 
 
82
 
83
  Args:
84
  image (PIL.Image.Image | None, optional):
85
+ The source image to relight. Defaults to None.
86
+ light_source (PIL.Image.Image | None, optional):
87
+ The reference image providing the lighting and color tones. Defaults to None.
88
+ prompt (str, optional):
89
+ The prompt describing the lighting transfer operation.
90
+ Defaults to the Chinese prompt for light migration.
 
 
 
 
 
 
 
 
91
  seed (int, optional):
92
  Random seed for the generation. Ignored if `randomize_seed=True`.
93
  Defaults to 0.
 
96
  Defaults to True.
97
  true_guidance_scale (float, optional):
98
  CFG / guidance scale controlling prompt adherence.
99
+ Defaults to 1.0 for the distilled transformer.
100
  num_inference_steps (int, optional):
101
  Number of inference steps. Defaults to 4.
102
  height (int, optional):
103
  Output image height. Must typically be a multiple of 8.
104
+ If set to 0 or None, the model will infer a size. Defaults to None.
105
  width (int, optional):
106
  Output image width. Must typically be a multiple of 8.
107
+ If set to 0 or None, the model will infer a size. Defaults to None.
 
 
 
108
 
109
  Returns:
110
+ Tuple[PIL.Image.Image, int]:
111
+ - The relit output image.
112
  - The actual seed used for generation.
 
113
  """
114
  progress = gr.Progress(track_tqdm=True)
115
+
116
+ if image is None:
117
+ raise gr.Error("Please upload a source image (Image 1).")
118
 
119
+ if light_source is None:
120
+ raise gr.Error("Please upload a light source reference image (Image 2).")
121
 
122
  if randomize_seed:
123
  seed = random.randint(0, MAX_SEED)
124
  generator = torch.Generator(device=device).manual_seed(seed)
125
 
126
+ # Prepare images - Image 1 is source, Image 2 is light reference
127
  pil_images = []
128
+
129
+ if isinstance(image, Image.Image):
130
+ pil_images.append(image.convert("RGB"))
131
+ elif hasattr(image, "name"):
132
+ pil_images.append(Image.open(image.name).convert("RGB"))
133
+
134
+ if isinstance(light_source, Image.Image):
135
+ pil_images.append(light_source.convert("RGB"))
136
+ elif hasattr(light_source, "name"):
137
+ pil_images.append(Image.open(light_source.name).convert("RGB"))
 
 
 
138
 
139
  result = pipe(
140
  image=pil_images,
141
  prompt=prompt,
142
+ height=height if height and height != 0 else None,
143
+ width=width if width and width != 0 else None,
144
  num_inference_steps=num_inference_steps,
145
  generator=generator,
146
  true_cfg_scale=true_guidance_scale,
147
  num_images_per_prompt=1,
148
  ).images[0]
149
 
150
+ return result, seed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
 
153
  def update_dimensions_on_upload(
 
155
  ) -> Tuple[int, int]:
156
  """
157
  Compute recommended (width, height) for the output resolution when an
158
+ image is uploaded while preserving the aspect ratio.
159
 
160
  Args:
161
  image (PIL.Image.Image | None):
 
186
  return new_width, new_height
187
 
188
 
189
+ # --- UI ---
190
+ css = '''
191
+ #col-container { max-width: 1000px; margin: 0 auto; }
192
+ .dark .progress-text { color: white !important }
193
+ #examples { max-width: 1000px; margin: 0 auto; }
194
+ .image-container { min-height: 300px; }
195
+ '''
196
+
197
+ with gr.Blocks(theme=gr.themes.Citrus(), css=css) as demo:
198
  with gr.Column(elem_id="col-container"):
199
+ gr.Markdown("## 💡 Qwen Image Edit — Light Migration")
200
  gr.Markdown("""
201
+ Transfer lighting and color tones from a reference image to your source image
202
+
203
+ Using [dx8152's Qwen-Edit-2509-Light-Migration LoRA](https://huggingface.co/dx8152/Qwen-Edit-2509-Light-Migration)
204
+ and [Phr00t/Qwen-Image-Edit-Rapid-AIO](https://huggingface.co/Phr00t/Qwen-Image-Edit-Rapid-AIO/tree/main) for 4-step inference 💨
205
+
206
+ **How it works:** Upload your source image (Image 1) and a reference image with the desired lighting/color tone (Image 2).
207
+ The model will relight Image 1 using the lighting characteristics from Image 2.
208
+ """)
209
 
210
  with gr.Row():
211
  with gr.Column():
212
+ with gr.Row():
213
+ image = gr.Image(
214
+ label="Image 1 (Source - to be relit)",
215
+ type="pil",
216
+ elem_classes="image-container"
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  )
218
+ light_source = gr.Image(
219
+ label="Image 2 (Light Reference)",
220
+ type="pil",
221
+ elem_classes="image-container"
 
 
222
  )
223
+
224
+ prompt = gr.Textbox(
225
+ label="Prompt",
226
+ value=DEFAULT_PROMPT,
227
+ placeholder="Enter prompt for light migration...",
228
+ lines=2
229
+ )
230
+
231
+ run_btn = gr.Button("✨ Transfer Lighting", variant="primary", size="lg")
232
 
233
  with gr.Accordion("Advanced Settings", open=False):
234
  seed = gr.Slider(
 
273
 
274
  with gr.Column():
275
  result = gr.Image(label="Output Image", interactive=False)
276
+ output_seed = gr.Number(label="Seed Used", interactive=False)
 
 
 
 
 
 
 
 
 
 
 
277
 
278
  inputs = [
279
+ image, light_source, prompt,
280
+ seed, randomize_seed, true_guidance_scale,
281
+ num_inference_steps, height, width
282
  ]
283
+ outputs = [result, output_seed]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ # Run button click
286
+ run_btn.click(
287
+ fn=infer_light_migration,
288
+ inputs=inputs,
289
+ outputs=outputs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  )
291
 
292
+ # Image upload triggers dimension update
293
  image.upload(
294
  fn=update_dimensions_on_upload,
295
  inputs=[image],
296
  outputs=[width, height]
 
 
 
 
 
 
 
 
 
 
297
  )
298
 
299
+ # API endpoint
300
+ gr.api(infer_light_migration, api_name="infer_light_migration")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
+ demo.launch(mcp_server=True)