prithivMLmods commited on
Commit
a81b7e6
·
verified ·
1 Parent(s): 20f53e4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +337 -348
app.py CHANGED
@@ -9,14 +9,16 @@ from io import BytesIO
9
  from PIL import Image
10
 
11
  # NOTE: Ensure QwenImageEditPlusPipeline is available in your environment.
 
12
  try:
13
  from diffusers import FlowMatchEulerDiscreteScheduler, QwenImageEditPlusPipeline
14
  except ImportError:
15
- # Fallback for UI testing if diffusers/custom pipeline not installed
16
  print("Warning: QwenImageEditPlusPipeline not found. UI will load, but generation will fail.")
17
  class QwenImageEditPlusPipeline:
18
  @classmethod
19
- def from_pretrained(cls, *args, **kwargs): return cls()
 
20
  def to(self, device): return self
21
  def load_lora_weights(self, *args, **kwargs): pass
22
  def set_adapters(self, *args, **kwargs): pass
@@ -43,10 +45,10 @@ try:
43
  adapter_name="lightning"
44
  )
45
 
46
- # Load the NEW Multi-Angle-Lighting LoRA
47
- # We allow the library to find the default safetensors file in the repo
48
  pipe.load_lora_weights(
49
  "dx8152/Qwen-Edit-2509-Multi-Angle-Lighting",
 
50
  adapter_name="lighting"
51
  )
52
 
@@ -55,28 +57,10 @@ except Exception as e:
55
  print(f"Model loading failed (ignorable if just testing UI): {e}")
56
  pipe = None
57
 
58
- # --- Prompt Mappings ---
59
 
60
- # Camera Azimuth
61
  AZIMUTH_MAP = {
62
- 0: "front view", 45: "front-right quarter view", 90: "right side view",
63
- 135: "back-right quarter view", 180: "back view", 225: "back-left quarter view",
64
- 270: "left side view", 315: "front-left quarter view"
65
- }
66
-
67
- # Camera Elevation
68
- ELEVATION_MAP = {
69
- -30: "low-angle shot", 0: "eye-level shot",
70
- 30: "elevated shot", 60: "high-angle shot"
71
- }
72
-
73
- # Camera Distance
74
- DISTANCE_MAP = {
75
- 0.6: "close-up", 1.0: "medium shot", 1.4: "wide shot", 1.8: "wide shot"
76
- }
77
-
78
- # Lighting Direction (Mapped by angle 0-360, plus special flags for Above/Below)
79
- LIGHTING_ANGLE_MAP = {
80
  0: "Light source from the Front",
81
  45: "Light source from the Right Front",
82
  90: "Light source from the Right",
@@ -90,38 +74,41 @@ LIGHTING_ANGLE_MAP = {
90
  def snap_to_nearest(value, options):
91
  return min(options, key=lambda x: abs(x - value))
92
 
93
- def build_full_prompt(cam_az, cam_el, cam_dist, light_az, light_mode):
94
- # Camera
95
- c_az = snap_to_nearest(cam_az, list(AZIMUTH_MAP.keys()))
96
- c_el = snap_to_nearest(cam_el, list(ELEVATION_MAP.keys()))
97
- c_dist = snap_to_nearest(cam_dist, list(DISTANCE_MAP.keys()))
98
- cam_str = f"{AZIMUTH_MAP[c_az]} {ELEVATION_MAP[c_el]} {DISTANCE_MAP[c_dist]}"
 
 
 
 
 
 
 
 
 
 
99
 
100
- # Lighting
101
- if light_mode == "Above":
102
- light_str = "Light source from Above"
103
- elif light_mode == "Below":
104
- light_str = "Light source from Below"
105
- else:
106
- # Ring mode
107
- l_az = snap_to_nearest(light_az, list(LIGHTING_ANGLE_MAP.keys()))
108
- light_str = LIGHTING_ANGLE_MAP[l_az]
109
-
110
- return f"<sks> {cam_str} {light_str}"
111
 
112
  @spaces.GPU
113
- def infer_edit(
114
  image: Image.Image,
115
- cam_az: float, cam_el: float, cam_dist: float,
116
- light_az: float, light_mode: str, # "Ring", "Above", "Below"
117
- seed: int, randomize_seed: bool,
118
- guidance_scale: float, num_inference_steps: int,
119
- height: int, width: int,
 
 
 
120
  ):
121
  if pipe is None:
122
  raise gr.Error("Model not initialized.")
123
 
124
- prompt = build_full_prompt(cam_az, cam_el, cam_dist, light_az, light_mode)
125
  print(f"Generated Prompt: {prompt}")
126
 
127
  if randomize_seed:
@@ -149,6 +136,7 @@ def infer_edit(
149
  def update_dimensions_on_upload(image):
150
  if image is None: return 1024, 1024
151
  w, h = image.size
 
152
  if w > h:
153
  new_w, new_h = 1024, int(1024 * (h / w))
154
  else:
@@ -162,402 +150,403 @@ def get_image_base64(image):
162
  img_str = base64.b64encode(buffered.getvalue()).decode()
163
  return f"data:image/png;base64,{img_str}"
164
 
165
- # --- 3D HTML Logic with Lighting Control ---
166
  THREE_JS_LOGIC = """
167
- <div id="camera-control-wrapper" style="width: 100%; height: 500px; position: relative; background: #151515; border-radius: 12px; overflow: hidden; box-shadow: inset 0 0 20px #000;">
168
- <div id="status-overlay" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.6); padding: 8px; border-radius: 4px; font-family: monospace; font-size: 11px; color: #aaa; pointer-events: none;">
169
- <div>📸 Camera: <span id="cam-status" style="color:white">Front</span></div>
170
- <div>💡 Light: <span id="light-status" style="color:#ffcc00">Front</span></div>
171
- </div>
172
- <div style="position: absolute; bottom: 10px; right: 10px; color: #555; font-size: 10px; font-family: sans-serif;">
173
- Drag Yellow Sphere for Light • Drag Blue/Pink for Camera
174
- </div>
175
  </div>
176
  <script>
177
  (function() {
178
- const wrapper = document.getElementById('camera-control-wrapper');
179
- const camStatus = document.getElementById('cam-status');
180
- const lightStatus = document.getElementById('light-status');
181
 
182
- window.sceneState = {
183
- camAz: 0, camEl: 0, camDist: 1.0,
184
- lightAz: 0, lightMode: 'Ring' // 'Ring', 'Above', 'Below'
 
185
  };
186
 
187
- // Helper: Snap
188
- function snap(val, steps) {
189
- return steps.reduce((prev, curr) => Math.abs(curr - val) < Math.abs(prev - val) ? curr : prev);
190
- }
191
-
192
- // Mappings for Display
193
- const camAzMap = {0:'Front',45:'Front-Right',90:'Right',135:'Back-Right',180:'Back',225:'Back-Left',270:'Left',315:'Front-Left'};
194
- const lightAzMap = {0:'Front',45:'Right Front',90:'Right',135:'Right Rear',180:'Rear',225:'Left Rear',270:'Left',315:'Left Front'};
195
-
196
  const initScene = () => {
197
- if (typeof THREE === 'undefined') { setTimeout(initScene, 100); return; }
 
 
 
198
 
 
199
  const scene = new THREE.Scene();
200
- scene.background = new THREE.Color(0x151515);
201
- scene.fog = new THREE.Fog(0x151515, 5, 15);
202
 
203
- const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
204
- camera.position.set(5, 4, 5);
205
- camera.lookAt(0, 0.5, 0);
 
206
 
207
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
208
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
209
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
 
 
210
  wrapper.appendChild(renderer.domElement);
211
 
212
- // Lights for the 3D scene itself
213
- scene.add(new THREE.AmbientLight(0xffffff, 0.3));
214
- const dl = new THREE.DirectionalLight(0xffffff, 0.8);
215
- dl.position.set(2, 5, 2);
216
- scene.add(dl);
217
  scene.add(new THREE.GridHelper(8, 16, 0x333333, 0x222222));
218
 
219
- const CENTER = new THREE.Vector3(0, 0.75, 0);
 
 
 
 
 
 
220
 
221
- // --- 1. Target Plane (The Image) ---
222
  function createPlaceholderTexture() {
223
  const canvas = document.createElement('canvas');
224
  canvas.width = 256; canvas.height = 256;
225
  const ctx = canvas.getContext('2d');
226
- ctx.fillStyle = '#222'; ctx.fillRect(0,0,256,256);
227
- ctx.strokeStyle = '#444'; ctx.lineWidth = 5; ctx.strokeRect(0,0,256,256);
228
- ctx.fillStyle = '#666'; ctx.font = "20px monospace"; ctx.fillText("IMAGE", 90, 130);
 
229
  return new THREE.CanvasTexture(canvas);
230
  }
231
- let planeMat = new THREE.MeshBasicMaterial({ map: createPlaceholderTexture(), side: THREE.DoubleSide });
232
- let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMat);
233
- targetPlane.position.copy(CENTER);
234
- scene.add(targetPlane);
235
-
236
- // --- 2. Camera Rig ---
237
- const camGroup = new THREE.Group();
238
- // Camera Body
239
- const camBody = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.2, 0.4), new THREE.MeshStandardMaterial({ color: 0x4488ff, roughness: 0.4 }));
240
- camBody.add(new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.12, 0.2, 16).rotateX(Math.PI/2).translate(0,0,0.3), new THREE.MeshStandardMaterial({color:0x222})));
241
- camGroup.add(camBody);
242
- scene.add(camGroup);
243
-
244
- // Camera Handles
245
- const createHandle = (color, type) => {
246
- const g = new THREE.SphereGeometry(0.15, 16, 16);
247
- const m = new THREE.MeshStandardMaterial({ color: color, emissive: color, emissiveIntensity: 0.4 });
248
- const mesh = new THREE.Mesh(g, m);
249
- mesh.userData.type = type;
250
- return mesh;
251
- };
252
- const hCamAz = createHandle(0x00ff88, 'camAz');
253
- const hCamEl = createHandle(0xff0088, 'camEl');
254
- const hCamDist = createHandle(0xffaa00, 'camDist');
255
- scene.add(hCamAz); scene.add(hCamEl); scene.add(hCamDist);
256
 
257
- // Camera Guidelines
258
- const azRing = new THREE.Mesh(new THREE.TorusGeometry(2.4, 0.02, 16, 64), new THREE.MeshBasicMaterial({ color: 0x00ff88, opacity: 0.2, transparent: true }));
259
- azRing.rotation.x = Math.PI/2; azRing.position.y = 0.05;
260
- scene.add(azRing);
261
- const distLine = new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xffaa00, opacity: 0.5, transparent: true }));
262
- scene.add(distLine);
263
-
264
- // --- 3. Light Rig ---
265
- const LIGHT_RADIUS = 3.2; // Further out than camera azimuth
 
 
 
 
 
 
266
  const lightGroup = new THREE.Group();
267
-
268
- // The "Sun" Sphere
269
- const sunGeo = new THREE.SphereGeometry(0.25, 16, 16);
270
- const sunMat = new THREE.MeshStandardMaterial({ color: 0xffcc00, emissive: 0xffaa00, emissiveIntensity: 1 });
271
- const sunMesh = new THREE.Mesh(sunGeo, sunMat);
272
- sunMesh.userData.type = 'light';
273
-
274
- // Light Rays (Visual decoration)
275
- const rays = new THREE.Group();
276
- for(let i=0; i<8; i++) {
277
- const ray = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.8), new THREE.MeshBasicMaterial({color: 0xffcc00}));
278
- ray.rotation.z = (i/8) * Math.PI * 2;
279
- rays.add(ray);
280
- }
281
- sunMesh.add(rays);
282
- scene.add(sunMesh);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
- // Light Guide Ring (Yellow)
285
- const lightRing = new THREE.Mesh(new THREE.TorusGeometry(LIGHT_RADIUS, 0.03, 16, 64), new THREE.MeshBasicMaterial({ color: 0xffcc00, opacity: 0.3, transparent: true }));
286
- lightRing.rotation.x = Math.PI/2; lightRing.position.y = CENTER.y;
287
- scene.add(lightRing);
288
-
289
- // Vertical Guides for Above/Below
290
- const vertLine = new THREE.Mesh(new THREE.CylinderGeometry(0.01, 0.01, 6), new THREE.MeshBasicMaterial({ color: 0xffcc00, opacity: 0.1, transparent: true }));
291
- vertLine.position.copy(CENTER);
292
- scene.add(vertLine);
293
 
294
- // --- Update Logic ---
295
  function updatePositions() {
296
- const s = window.sceneState;
297
-
298
- // 1. Camera Math
299
- const cRadAz = THREE.MathUtils.degToRad(s.camAz);
300
- const cRadEl = THREE.MathUtils.degToRad(s.camEl);
301
- const dist = 1.6 * s.camDist;
302
-
303
- const cx = dist * Math.sin(cRadAz) * Math.cos(cRadEl);
304
- const cy = dist * Math.sin(cRadEl) + CENTER.y;
305
- const cz = dist * Math.cos(cRadAz) * Math.cos(cRadEl);
306
 
307
- camGroup.position.set(cx, cy, cz);
308
- camGroup.lookAt(CENTER);
 
 
 
 
 
309
 
310
- // Camera Handle Pos
311
- hCamAz.position.set(2.4 * Math.sin(cRadAz), 0.05, 2.4 * Math.cos(cRadAz));
312
- hCamEl.position.set(-0.8, 1.8 * Math.sin(cRadEl) + CENTER.y, 1.8 * Math.cos(cRadEl));
313
- const dH = dist - 0.4;
314
- hCamDist.position.set(dH * Math.sin(cRadAz) * Math.cos(cRadEl), dH * Math.sin(cRadEl) + CENTER.y, dH * Math.cos(cRadAz) * Math.cos(cRadEl));
315
- distLine.geometry.setFromPoints([camGroup.position, CENTER]);
316
-
317
- // 2. Light Math
318
- if (s.lightMode === 'Above') {
319
- sunMesh.position.set(0, CENTER.y + 3, 0);
320
- lightRing.visible = false;
321
- } else if (s.lightMode === 'Below') {
322
- sunMesh.position.set(0, CENTER.y - 2.5, 0);
323
- lightRing.visible = false;
324
- } else {
325
- lightRing.visible = true;
326
- const lRad = THREE.MathUtils.degToRad(s.lightAz);
327
- sunMesh.position.set(
328
- LIGHT_RADIUS * Math.sin(lRad),
329
- CENTER.y,
330
- LIGHT_RADIUS * Math.cos(lRad)
331
- );
332
  }
333
- sunMesh.lookAt(CENTER);
334
-
335
- // 3. Status Text
336
- const cAzSnap = snap(s.camAz, [0,45,90,135,180,225,270,315]);
337
- const lAzSnap = snap(s.lightAz, [0,45,90,135,180,225,270,315]);
338
- camStatus.innerText = `${camAzMap[cAzSnap]} | El: ${snap(s.camEl, [-30,0,30,60])}°`;
339
- lightStatus.innerText = s.lightMode !== 'Ring' ? s.lightMode : lightAzMap[lAzSnap];
340
  }
341
 
342
  // --- Interaction ---
343
  const raycaster = new THREE.Raycaster();
344
  const mouse = new THREE.Vector2();
345
  let isDragging = false;
346
- let dragItem = null;
347
- let startY = 0;
 
 
 
 
 
 
 
 
348
 
349
- wrapper.addEventListener('mousedown', (e) => {
350
- const rect = wrapper.getBoundingClientRect();
351
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
352
- mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
353
-
354
  raycaster.setFromCamera(mouse, camera);
355
- const intersects = raycaster.intersectObjects([hCamAz, hCamEl, hCamDist, sunMesh]);
356
 
357
- if (intersects.length > 0) {
358
- isDragging = true;
359
- dragItem = intersects[0].object;
360
- startY = mouse.y;
361
- dragItem.material.emissiveIntensity = 1.0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  }
 
363
  });
364
 
365
  window.addEventListener('mousemove', (e) => {
366
- const rect = wrapper.getBoundingClientRect();
367
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
368
- mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
 
369
 
370
- if (isDragging && dragItem) {
371
- raycaster.setFromCamera(mouse, camera);
372
- const pt = new THREE.Vector3();
 
 
 
373
 
374
- if (dragItem.userData.type === 'camAz') {
375
- const plane = new THREE.Plane(new THREE.Vector3(0,1,0), -0.05);
376
- if (raycaster.ray.intersectPlane(plane, pt)) {
377
- let ang = THREE.MathUtils.radToDeg(Math.atan2(pt.x, pt.z));
378
- if(ang < 0) ang += 360;
379
- window.sceneState.camAz = ang;
380
- }
381
- }
382
- else if (dragItem.userData.type === 'camEl') {
383
- const plane = new THREE.Plane(new THREE.Vector3(1,0,0), -0.8);
384
- if (raycaster.ray.intersectPlane(plane, pt)) {
385
- let ang = THREE.MathUtils.radToDeg(Math.atan2(pt.y - CENTER.y, pt.z));
386
- window.sceneState.camEl = THREE.MathUtils.clamp(ang, -30, 60);
387
- }
388
- }
389
- else if (dragItem.userData.type === 'camDist') {
390
- const d = 1.0 - (mouse.y - startY) * 2;
391
- window.sceneState.camDist = THREE.MathUtils.clamp(d, 0.6, 1.4);
392
- }
393
- else if (dragItem.userData.type === 'light') {
394
- // Complex logic for Light: Plane intersection + Vertical Check
395
- const plane = new THREE.Plane(new THREE.Vector3(0,1,0), -CENTER.y);
396
- if (raycaster.ray.intersectPlane(plane, pt)) {
397
- // Check explicit vertical raycast for visual consistency if needed,
398
- // but here we map mouse Y to height triggers roughly
399
- const distToCenter = pt.distanceTo(CENTER);
400
-
401
- // Simplified: Use mouse Y from screen to determine Above/Below
402
- if (mouse.y > 0.6) {
403
- window.sceneState.lightMode = 'Above';
404
- } else if (mouse.y < -0.6) {
405
- window.sceneState.lightMode = 'Below';
406
- } else {
407
- window.sceneState.lightMode = 'Ring';
408
- let ang = THREE.MathUtils.radToDeg(Math.atan2(pt.x, pt.z));
409
- if(ang < 0) ang += 360;
410
- window.sceneState.lightAz = ang;
411
- }
412
- }
413
- }
414
  updatePositions();
415
  } else {
416
- // Hover cursor
 
 
417
  raycaster.setFromCamera(mouse, camera);
418
- const hits = raycaster.intersectObjects([hCamAz, hCamEl, hCamDist, sunMesh]);
419
- wrapper.style.cursor = hits.length > 0 ? 'pointer' : 'default';
420
  }
421
  });
422
 
423
  window.addEventListener('mouseup', () => {
424
- if(isDragging) {
425
  isDragging = false;
426
- if(dragItem) dragItem.material.emissiveIntensity = dragItem.userData.type==='light'?1:0.4;
427
- dragItem = null;
428
 
429
- // Snap & Export
430
- const s = window.sceneState;
431
- s.camAz = snap(s.camAz, [0,45,90,135,180,225,270,315]);
432
- s.camEl = snap(s.camEl, [-30,0,30,60]);
433
- s.camDist = snap(s.camDist, [0.6, 1.0, 1.4]);
434
 
435
- if (s.lightMode === 'Ring') {
436
- s.lightAz = snap(s.lightAz, [0,45,90,135,180,225,270,315]);
437
- }
 
438
 
439
- updatePositions();
440
 
441
- // Communicate to Gradio
442
  const bridge = document.querySelector("#bridge-output textarea");
443
  if (bridge) {
444
- bridge.value = JSON.stringify(s);
445
  bridge.dispatchEvent(new Event("input", { bubbles: true }));
446
  }
447
  }
448
  });
449
 
450
- // External Hook
451
- window.camera3D = {
452
- updateTexture: (url) => {
453
- if(!url) { planeMat.map = createPlaceholderTexture(); return; }
454
- new THREE.TextureLoader().load(url, (t)=>{
455
- t.minFilter=THREE.LinearFilter; planeMat.map=t;
456
- const aspect = t.image.width/t.image.height;
457
- const s = 1.2;
458
- if(aspect>1) targetPlane.scale.set(s, s/aspect, 1);
459
- else targetPlane.scale.set(s*aspect, s, 1);
460
- planeMat.needsUpdate=true;
461
- });
462
- },
463
- updateState: (json) => {
464
- if(typeof json === 'string') json = JSON.parse(json);
465
- if(!json) return;
466
- Object.assign(window.sceneState, json);
467
- updatePositions();
468
- }
469
- };
470
-
471
- // Loop
472
- function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); }
473
  animate();
474
  updatePositions();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  };
 
476
  initScene();
477
  })();
478
  </script>
479
  """
480
 
481
- # --- UI Construction ---
482
  css = """
483
  #col-container { max-width: 1200px; margin: 0 auto; }
 
484
  .gradio-container { overflow: visible !important; }
485
  """
486
 
487
  with gr.Blocks() as demo:
488
  gr.HTML('<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>')
489
 
490
- gr.Markdown("# 🎬 Qwen Image Edit — 3D Lighting & Camera Control")
491
- gr.Markdown("Control the camera (Blue/Green/Pink handles) and the **Lighting Source** (Yellow Sun Sphere).")
492
 
493
  with gr.Row(elem_id="col-container"):
 
494
  with gr.Column(scale=1):
495
  image = gr.Image(label="Input Image", type="pil", height=250)
496
 
497
- # The 3D Controller
498
  gr.HTML(THREE_JS_LOGIC)
499
 
500
- # Bridges
501
- bridge_output = gr.Textbox(elem_id="bridge-output", visible=False) # JS -> Python
502
- bridge_input = gr.JSON(value={}, visible=False) # Python -> JS
503
-
504
- # Manual Controls (Hidden or Accordion if user prefers 3D only)
505
- with gr.Accordion("Manual Sliders", open=False):
506
- with gr.Row():
507
- cam_az = gr.Slider(0, 315, 0, step=45, label="Camera Azimuth")
508
- cam_el = gr.Slider(-30, 60, 0, step=30, label="Camera Elevation")
509
- cam_dist = gr.Slider(0.6, 1.4, 1.0, step=0.4, label="Camera Distance")
510
- with gr.Row():
511
- light_az = gr.Slider(0, 315, 0, step=45, label="Light Direction")
512
- light_mode = gr.Radio(["Ring", "Above", "Below"], value="Ring", label="Light Vertical")
513
-
514
- run_btn = gr.Button("🚀 Generate", variant="primary", size="lg")
515
- prompt_view = gr.Textbox(label="Generated Prompt", interactive=False)
516
 
 
 
 
 
 
 
 
 
 
 
517
  with gr.Column(scale=1):
518
- result = gr.Image(label="Output")
519
- with gr.Accordion("Advanced Settings", open=False):
520
- seed = gr.Slider(0, MAX_SEED, 0, label="Seed")
521
- rand_seed = gr.Checkbox(True, label="Randomize Seed")
522
- cfg = gr.Slider(1, 10, 1.0, 0.1, label="Guidance")
523
- steps = gr.Slider(1, 50, 4, 1, label="Steps")
524
- w = gr.Slider(256, 2048, 1024, 8, label="Width")
525
- h = gr.Slider(256, 2048, 1024, 8, label="Height")
526
-
527
- # --- Events ---
528
-
529
- # 1. Update Image in 3D
530
- image.upload(update_dimensions_on_upload, image, [w, h]) \
531
- .then(get_image_base64, image, bridge_output) \
532
- .then(None, bridge_output, None, js="(v) => window.camera3D.updateTexture(v)")
533
-
534
- # 2. Sync Sliders -> Bridge -> 3D
535
- def sliders_to_bridge(ca, ce, cd, la, lm):
536
- return {"camAz": ca, "camEl": ce, "camDist": cd, "lightAz": la, "lightMode": lm}
 
 
 
 
 
537
 
538
- input_triggers = [cam_az, cam_el, cam_dist, light_az, light_mode]
539
- for trig in input_triggers:
540
- trig.change(sliders_to_bridge, input_triggers, bridge_input) \
541
- .then(None, bridge_input, None, js="(v) => window.camera3D.updateState(v)") \
542
- .then(build_full_prompt, input_triggers, prompt_view)
543
-
544
- # 3. Sync 3D -> Bridge -> Sliders
545
- def bridge_to_sliders(json_str):
 
 
 
 
 
546
  try:
547
- d = json.loads(json_str)
548
- return (
549
- d.get('camAz', 0), d.get('camEl', 0), d.get('camDist', 1.0),
550
- d.get('lightAz', 0), d.get('lightMode', 'Ring')
551
- )
552
- except: return 0,0,1,0,'Ring'
553
-
554
- bridge_output.change(bridge_to_sliders, bridge_output, input_triggers)
555
 
556
- # 4. Generate
557
  run_btn.click(
558
- infer_edit,
559
- inputs=[image, cam_az, cam_el, cam_dist, light_az, light_mode, seed, rand_seed, cfg, steps, h, w],
560
- outputs=[result, seed, prompt_view]
561
  )
562
 
563
  if __name__ == "__main__":
 
9
  from PIL import Image
10
 
11
  # NOTE: Ensure QwenImageEditPlusPipeline is available in your environment.
12
+ # If using a local file, uncomment the local import. If using a custom Diffusers build, keep as is.
13
  try:
14
  from diffusers import FlowMatchEulerDiscreteScheduler, QwenImageEditPlusPipeline
15
  except ImportError:
16
+ # Fallback/Placeholder if specific pipeline isn't installed, purely to allow UI testing
17
  print("Warning: QwenImageEditPlusPipeline not found. UI will load, but generation will fail.")
18
  class QwenImageEditPlusPipeline:
19
  @classmethod
20
+ def from_pretrained(cls, *args, **kwargs):
21
+ return cls()
22
  def to(self, device): return self
23
  def load_lora_weights(self, *args, **kwargs): pass
24
  def set_adapters(self, *args, **kwargs): pass
 
45
  adapter_name="lightning"
46
  )
47
 
48
+ # Load the Lighting LoRA
 
49
  pipe.load_lora_weights(
50
  "dx8152/Qwen-Edit-2509-Multi-Angle-Lighting",
51
+ weight_name="qwen-edit-2509-multi-angle-lighting.safetensors",
52
  adapter_name="lighting"
53
  )
54
 
 
57
  print(f"Model loading failed (ignorable if just testing UI): {e}")
58
  pipe = None
59
 
60
+ # --- Prompt Building ---
61
 
62
+ # Horizontal mappings (Azimuth)
63
  AZIMUTH_MAP = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  0: "Light source from the Front",
65
  45: "Light source from the Right Front",
66
  90: "Light source from the Right",
 
74
  def snap_to_nearest(value, options):
75
  return min(options, key=lambda x: abs(x - value))
76
 
77
+ def build_lighting_prompt(azimuth: float, elevation: float) -> str:
78
+ """
79
+ Constructs the prompt based on Azimuth (horizontal) and Elevation (vertical).
80
+ Priority: If elevation is extreme (Above/Below), that takes precedence.
81
+ Otherwise, use horizontal direction.
82
+ """
83
+ # Vertical Thresholds
84
+ if elevation >= 45:
85
+ return "<sks> Light source from Above"
86
+ if elevation <= -45:
87
+ return "<sks> Light source from Below"
88
+
89
+ # Horizontal Logic
90
+ # Normalize azimuth to 0-360
91
+ azimuth = azimuth % 360
92
+ az_snap = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
93
 
94
+ return f"<sks> {AZIMUTH_MAP[az_snap]}"
 
 
 
 
 
 
 
 
 
 
95
 
96
  @spaces.GPU
97
+ def infer_lighting_edit(
98
  image: Image.Image,
99
+ azimuth: float = 0.0,
100
+ elevation: float = 0.0,
101
+ seed: int = 0,
102
+ randomize_seed: bool = True,
103
+ guidance_scale: float = 1.0,
104
+ num_inference_steps: int = 4,
105
+ height: int = 1024,
106
+ width: int = 1024,
107
  ):
108
  if pipe is None:
109
  raise gr.Error("Model not initialized.")
110
 
111
+ prompt = build_lighting_prompt(azimuth, elevation)
112
  print(f"Generated Prompt: {prompt}")
113
 
114
  if randomize_seed:
 
136
  def update_dimensions_on_upload(image):
137
  if image is None: return 1024, 1024
138
  w, h = image.size
139
+ # Resize logic to keep aspect ratio but snap to multiples of 8 within reasonable bounds
140
  if w > h:
141
  new_w, new_h = 1024, int(1024 * (h / w))
142
  else:
 
150
  img_str = base64.b64encode(buffered.getvalue()).decode()
151
  return f"data:image/png;base64,{img_str}"
152
 
153
+ # --- 3D Lighting Control HTML Logic ---
154
  THREE_JS_LOGIC = """
155
+ <div id="light-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #111; border-radius: 12px; overflow: hidden;">
156
+ <div id="prompt-overlay" style="position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); padding: 8px 16px; border-radius: 8px; font-family: monospace; font-size: 14px; color: #ffcc00; white-space: nowrap; z-index: 10; border: 1px solid #ffcc00;">Initializing...</div>
157
+ <div style="position: absolute; top: 10px; left: 10px; color: #666; font-family: sans-serif; font-size: 11px;">Drag the Yellow Orb to move light</div>
 
 
 
 
 
158
  </div>
159
  <script>
160
  (function() {
161
+ const wrapper = document.getElementById('light-control-wrapper');
162
+ const promptOverlay = document.getElementById('prompt-overlay');
 
163
 
164
+ // Global Access for Python Bridge
165
+ window.light3D = {
166
+ updateState: null,
167
+ updateTexture: null
168
  };
169
 
 
 
 
 
 
 
 
 
 
170
  const initScene = () => {
171
+ if (typeof THREE === 'undefined') {
172
+ setTimeout(initScene, 100);
173
+ return;
174
+ }
175
 
176
+ // --- Setup ---
177
  const scene = new THREE.Scene();
178
+ scene.background = new THREE.Color(0x111111);
 
179
 
180
+ // Static Camera looking at the scene
181
+ const camera = new THREE.PerspectiveCamera(45, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
182
+ camera.position.set(0, 1.5, 5); // Slightly elevated front view
183
+ camera.lookAt(0, 0, 0);
184
 
185
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
186
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
187
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
188
+ renderer.shadowMap.enabled = true; // Enable shadows for visual feedback
189
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
190
  wrapper.appendChild(renderer.domElement);
191
 
192
+ // --- Helpers ---
 
 
 
 
193
  scene.add(new THREE.GridHelper(8, 16, 0x333333, 0x222222));
194
 
195
+ // --- Objects ---
196
+ const CENTER = new THREE.Vector3(0, 0, 0);
197
+ const ORBIT_RADIUS = 2.5;
198
+
199
+ // 1. The Subject (Central Image Plane + Sphere for shading ref)
200
+ const group = new THREE.Group();
201
+ scene.add(group);
202
 
203
+ // Placeholder Texture
204
  function createPlaceholderTexture() {
205
  const canvas = document.createElement('canvas');
206
  canvas.width = 256; canvas.height = 256;
207
  const ctx = canvas.getContext('2d');
208
+ ctx.fillStyle = '#222'; ctx.fillRect(0, 0, 256, 256);
209
+ ctx.fillStyle = '#444';
210
+ ctx.font = '30px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
211
+ ctx.fillText("Upload Image", 128, 128);
212
  return new THREE.CanvasTexture(canvas);
213
  }
214
+
215
+ let planeMaterial = new THREE.MeshStandardMaterial({
216
+ map: createPlaceholderTexture(),
217
+ side: THREE.DoubleSide,
218
+ roughness: 0.8,
219
+ metalness: 0.1
220
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
+ let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 1.5), planeMaterial);
223
+ targetPlane.castShadow = true;
224
+ targetPlane.receiveShadow = true;
225
+ group.add(targetPlane);
226
+
227
+ // Reference Sphere (Hidden behind plane usually, or useful for seeing pure shading)
228
+ const refSphere = new THREE.Mesh(
229
+ new THREE.SphereGeometry(0.5, 32, 32),
230
+ new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1.0 })
231
+ );
232
+ refSphere.position.z = -0.5;
233
+ refSphere.castShadow = true;
234
+ group.add(refSphere);
235
+
236
+ // 2. The Light Source (The "Sun")
237
  const lightGroup = new THREE.Group();
238
+ scene.add(lightGroup);
239
+
240
+ // Actual Light
241
+ const dirLight = new THREE.DirectionalLight(0xffffff, 2.0);
242
+ dirLight.castShadow = true;
243
+ dirLight.shadow.mapSize.width = 1024;
244
+ dirLight.shadow.mapSize.height = 1024;
245
+ lightGroup.add(dirLight);
246
+
247
+ // Visual Representation (Yellow Orb)
248
+ const lightMesh = new THREE.Mesh(
249
+ new THREE.SphereGeometry(0.2, 16, 16),
250
+ new THREE.MeshBasicMaterial({ color: 0xffcc00 })
251
+ );
252
+ // Add glow
253
+ const glow = new THREE.Mesh(
254
+ new THREE.SphereGeometry(0.3, 16, 16),
255
+ new THREE.MeshBasicMaterial({ color: 0xffcc00, transparent: true, opacity: 0.3 })
256
+ );
257
+ lightMesh.add(glow);
258
+ lightMesh.userData.type = 'lightSource';
259
+ lightGroup.add(lightMesh);
260
+
261
+ // Ambient light to fill shadows slightly
262
+ scene.add(new THREE.AmbientLight(0xffffff, 0.2));
263
+
264
+ // --- State ---
265
+ let azimuthAngle = 0; // 0 = Front, 90 = Right, 180 = Back
266
+ let elevationAngle = 0; // 90 = Top, -90 = Bottom
267
+
268
+ // --- Prompt Mapping Logic (JS Side for preview) ---
269
+ const azMap = {
270
+ 0: "Front", 45: "Right Front", 90: "Right", 135: "Right Rear",
271
+ 180: "Rear", 225: "Left Rear", 270: "Left", 315: "Left Front"
272
+ };
273
+ const azSteps = [0, 45, 90, 135, 180, 225, 270, 315];
274
 
275
+ function snapToNearest(value, steps) {
276
+ let norm = value % 360;
277
+ if (norm < 0) norm += 360;
278
+ return steps.reduce((prev, curr) => Math.abs(curr - norm) < Math.abs(prev - norm) ? curr : prev);
279
+ }
 
 
 
 
280
 
 
281
  function updatePositions() {
282
+ // Convert Azimuth/Elevation to spherical coordinates
283
+ // In ThreeJS: Y is Up. 0 Azimuth should be +Z (Front)
284
+ // But usually Front is +Z. Let's calculate standard spherical.
 
 
 
 
 
 
 
285
 
286
+ const rAz = THREE.MathUtils.degToRad(azimuthAngle);
287
+ const rEl = THREE.MathUtils.degToRad(elevationAngle);
288
+
289
+ // Calculate position on sphere
290
+ // x = r * sin(az) * cos(el)
291
+ // y = r * sin(el)
292
+ // z = r * cos(az) * cos(el)
293
 
294
+ const x = ORBIT_RADIUS * Math.sin(rAz) * Math.cos(rEl);
295
+ const y = ORBIT_RADIUS * Math.sin(rEl);
296
+ const z = ORBIT_RADIUS * Math.cos(rAz) * Math.cos(rEl);
297
+
298
+ lightGroup.position.set(x, y, z);
299
+ lightGroup.lookAt(CENTER); // Light points to center
300
+
301
+ // Update UI Text
302
+ let text = "";
303
+ if (elevationAngle >= 45) text = "Light source from Above";
304
+ else if (elevationAngle <= -45) text = "Light source from Below";
305
+ else {
306
+ const snap = snapToNearest(azimuthAngle, azSteps);
307
+ text = "Light source from the " + azMap[snap];
 
 
 
 
 
 
 
 
308
  }
309
+ promptOverlay.innerText = text;
 
 
 
 
 
 
310
  }
311
 
312
  // --- Interaction ---
313
  const raycaster = new THREE.Raycaster();
314
  const mouse = new THREE.Vector2();
315
  let isDragging = false;
316
+
317
+ const canvas = renderer.domElement;
318
+
319
+ function getMouse(e) {
320
+ const rect = canvas.getBoundingClientRect();
321
+ return {
322
+ x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
323
+ y: -((e.clientY - rect.top) / rect.height) * 2 + 1
324
+ };
325
+ }
326
 
327
+ canvas.addEventListener('mousedown', (e) => {
328
+ const m = getMouse(e);
329
+ mouse.set(m.x, m.y);
 
 
330
  raycaster.setFromCamera(mouse, camera);
 
331
 
332
+ // Allow clicking anywhere to move light, or specifically the orb
333
+ // To make it easy, let's just project mouse to a virtual sphere
334
+ isDragging = true;
335
+ handleDrag(e);
336
+ });
337
+
338
+ function handleDrag(e) {
339
+ if (!isDragging) return;
340
+
341
+ const m = getMouse(e);
342
+ mouse.set(m.x, m.y);
343
+
344
+ // Logic: Raycast to a virtual sphere at center
345
+ // Or simpler: Map mouse X to Azimuth, Mouse Y to Elevation
346
+ // Let's use Mouse movement to delta
347
+
348
+ // Robust approach: Project mouse onto a virtual sphere
349
+ // But simpler UI: Mouse X = Rotation, Mouse Y = Elevation
350
+ // This feels like "OrbitControls" but for the light
351
+ }
352
+
353
+ // Let's use a simpler drag logic: standard delta movement
354
+ let previousMouse = { x: 0, y: 0 };
355
+
356
+ canvas.addEventListener('mousedown', (e) => {
357
+ isDragging = true;
358
+ previousMouse = { x: e.clientX, y: e.clientY };
359
+
360
+ // Check if clicked on orb (visual feedback)
361
+ const m = getMouse(e);
362
+ mouse.set(m.x, m.y);
363
+ raycaster.setFromCamera(mouse, camera);
364
+ const intersects = raycaster.intersectObject(lightMesh);
365
+ if(intersects.length > 0) {
366
+ lightMesh.scale.setScalar(1.2);
367
  }
368
+ canvas.style.cursor = 'grabbing';
369
  });
370
 
371
  window.addEventListener('mousemove', (e) => {
372
+ if (isDragging) {
373
+ const deltaX = e.clientX - previousMouse.x;
374
+ const deltaY = e.clientY - previousMouse.y;
375
+ previousMouse = { x: e.clientX, y: e.clientY };
376
 
377
+ // Adjust sensitivity
378
+ azimuthAngle -= deltaX * 0.5;
379
+ elevationAngle += deltaY * 0.5;
380
+
381
+ // Clamp Elevation
382
+ elevationAngle = Math.max(-89, Math.min(89, elevationAngle));
383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  updatePositions();
385
  } else {
386
+ // Hover effect
387
+ const m = getMouse(e);
388
+ mouse.set(m.x, m.y);
389
  raycaster.setFromCamera(mouse, camera);
390
+ const intersects = raycaster.intersectObject(lightMesh);
391
+ canvas.style.cursor = intersects.length > 0 ? 'grab' : 'default';
392
  }
393
  });
394
 
395
  window.addEventListener('mouseup', () => {
396
+ if (isDragging) {
397
  isDragging = false;
398
+ lightMesh.scale.setScalar(1.0);
399
+ canvas.style.cursor = 'default';
400
 
401
+ // Snap for the bridge output, but keep visual smooth?
402
+ // No, let's output exact values, python snaps them.
 
 
 
403
 
404
+ // Send to Python
405
+ // Normalize Azimuth for output
406
+ let outAz = azimuthAngle % 360;
407
+ if(outAz < 0) outAz += 360;
408
 
409
+ const data = { azimuth: outAz, elevation: elevationAngle };
410
 
 
411
  const bridge = document.querySelector("#bridge-output textarea");
412
  if (bridge) {
413
+ bridge.value = JSON.stringify(data);
414
  bridge.dispatchEvent(new Event("input", { bubbles: true }));
415
  }
416
  }
417
  });
418
 
419
+ // --- Render Loop ---
420
+ function animate() {
421
+ requestAnimationFrame(animate);
422
+ renderer.render(scene, camera);
423
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  animate();
425
  updatePositions();
426
+
427
+ // --- Exposed Methods for Python ---
428
+ window.light3D.updateState = (data) => {
429
+ if (!data) return;
430
+ if (typeof data === 'string') data = JSON.parse(data);
431
+ azimuthAngle = data.azimuth !== undefined ? data.azimuth : azimuthAngle;
432
+ elevationAngle = data.elevation !== undefined ? data.elevation : elevationAngle;
433
+ updatePositions();
434
+ };
435
+
436
+ window.light3D.updateTexture = (url) => {
437
+ if (!url) {
438
+ planeMaterial.map = createPlaceholderTexture();
439
+ planeMaterial.needsUpdate = true;
440
+ return;
441
+ }
442
+ new THREE.TextureLoader().load(url, (tex) => {
443
+ tex.colorSpace = THREE.SRGBColorSpace;
444
+ tex.minFilter = THREE.LinearFilter;
445
+ planeMaterial.map = tex;
446
+
447
+ const img = tex.image;
448
+ const aspect = img.width / img.height;
449
+ const scale = 1.5;
450
+ if (aspect > 1) targetPlane.scale.set(scale, scale / aspect, 1);
451
+ else targetPlane.scale.set(scale * aspect, scale, 1);
452
+
453
+ planeMaterial.needsUpdate = true;
454
+ });
455
+ };
456
  };
457
+
458
  initScene();
459
  })();
460
  </script>
461
  """
462
 
463
+ # --- UI Setup ---
464
  css = """
465
  #col-container { max-width: 1200px; margin: 0 auto; }
466
+ #light-control-wrapper { box-shadow: 0 4px 12px rgba(255, 204, 0, 0.2); border: 1px solid #333; }
467
  .gradio-container { overflow: visible !important; }
468
  """
469
 
470
  with gr.Blocks() as demo:
471
  gr.HTML('<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>')
472
 
473
+ gr.Markdown("# 💡 Qwen Edit 2509 Multi-Angle Lighting Control")
474
+ gr.Markdown("Control the **direction of the light source** using the 3D visualizer or sliders.")
475
 
476
  with gr.Row(elem_id="col-container"):
477
+ # Left: Controls
478
  with gr.Column(scale=1):
479
  image = gr.Image(label="Input Image", type="pil", height=250)
480
 
481
+ # The 3D Viewport
482
  gr.HTML(THREE_JS_LOGIC)
483
 
484
+ # Hidden Bridges
485
+ bridge_output = gr.Textbox(elem_id="bridge-output", visible=False, label="Bridge Output")
486
+ bridge_input = gr.JSON(value={}, visible=False, label="Bridge Input")
 
 
 
 
 
 
 
 
 
 
 
 
 
487
 
488
+ with gr.Group():
489
+ gr.Markdown("### Light Position")
490
+ azimuth_slider = gr.Slider(0, 360, step=45, label="Horizontal Direction (Azimuth)", value=0, info="0=Front, 90=Right, 180=Rear, 270=Left")
491
+ elevation_slider = gr.Slider(-90, 90, step=15, label="Vertical Angle (Elevation)", value=0, info="+90=Above, -90=Below")
492
+
493
+ run_btn = gr.Button("🚀 Relight Image", variant="primary", size="lg")
494
+
495
+ prompt_preview = gr.Textbox(label="Generated Prompt", interactive=False, value="<sks> Light source from the Front")
496
+
497
+ # Right: Result
498
  with gr.Column(scale=1):
499
+ result = gr.Image(label="Output Image")
500
+
501
+ with gr.Accordion("Advanced", open=False):
502
+ seed = gr.Slider(0, MAX_SEED, value=0, label="Seed")
503
+ randomize_seed = gr.Checkbox(True, label="Randomize Seed")
504
+ guidance_scale = gr.Slider(1, 10, 1.0, step=0.1, label="Guidance")
505
+ steps = gr.Slider(1, 50, 4, step=1, label="Steps")
506
+ width = gr.Slider(256, 2048, 1024, step=8, label="Width")
507
+ height = gr.Slider(256, 2048, 1024, step=8, label="Height")
508
+
509
+ # --- Event Wiring ---
510
+
511
+ # 1. Helper to sync Textbox (Prompt)
512
+ def update_prompt(az, el):
513
+ return build_lighting_prompt(az, el)
514
+
515
+ # 2. Image Upload
516
+ def handle_image_upload(img):
517
+ w, h = update_dimensions_on_upload(img)
518
+ b64 = get_image_base64(img)
519
+ return w, h, b64
520
+
521
+ image.upload(handle_image_upload, inputs=[image], outputs=[width, height, bridge_input]) \
522
+ .then(None, [image], None, js="(img) => { if(img) window.light3D.updateTexture(img); }")
523
 
524
+ # 3. Sliders -> Update Bridge -> Update 3D
525
+ def sync_sliders_to_bridge(az, el):
526
+ return {"azimuth": az, "elevation": el}
527
+
528
+ for s in [azimuth_slider, elevation_slider]:
529
+ s.change(sync_sliders_to_bridge, [azimuth_slider, elevation_slider], bridge_input) \
530
+ .then(update_prompt, [azimuth_slider, elevation_slider], prompt_preview)
531
+
532
+ # Trigger JS update when bridge_input changes
533
+ bridge_input.change(None, [bridge_input], None, js="(val) => window.light3D.updateState(val)")
534
+
535
+ # 4. 3D Interaction (Bridge Output) -> Update Sliders
536
+ def sync_bridge_to_sliders(data_str):
537
  try:
538
+ data = json.loads(data_str)
539
+ return data.get('azimuth', 0), data.get('elevation', 0)
540
+ except:
541
+ return 0, 0
542
+
543
+ bridge_output.change(sync_bridge_to_sliders, bridge_output, [azimuth_slider, elevation_slider])
 
 
544
 
545
+ # 5. Generation
546
  run_btn.click(
547
+ infer_lighting_edit,
548
+ inputs=[image, azimuth_slider, elevation_slider, seed, randomize_seed, guidance_scale, steps, height, width],
549
+ outputs=[result, seed, prompt_preview]
550
  )
551
 
552
  if __name__ == "__main__":