prithivMLmods commited on
Commit
f0d6a6a
·
verified ·
1 Parent(s): 7ca0271

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +258 -331
app.py CHANGED
@@ -11,6 +11,7 @@ from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel
11
  from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
12
  from gradio.themes import Soft
13
  from gradio.themes.utils import colors, fonts, sizes
 
14
  colors.orange_red = colors.Color(
15
  name="orange_red",
16
  c50="#FFF0E5",
@@ -25,6 +26,7 @@ colors.orange_red = colors.Color(
25
  c900="#992900",
26
  c950="#802200",
27
  )
 
28
  class OrangeRedTheme(Soft):
29
  def __init__(
30
  self,
@@ -75,10 +77,13 @@ class OrangeRedTheme(Soft):
75
  color_accent_soft="*primary_100",
76
  block_label_background_fill="*primary_200",
77
  )
 
78
  orange_red_theme = OrangeRedTheme()
 
79
  MAX_SEED = np.iinfo(np.int32).max
80
  dtype = torch.bfloat16
81
  device = "cuda" if torch.cuda.is_available() else "cpu"
 
82
  pipe = QwenImageEditPlusPipeline.from_pretrained(
83
  "Qwen/Qwen-Image-Edit-2511",
84
  transformer=QwenImageTransformer2DModel.from_pretrained(
@@ -88,11 +93,13 @@ pipe = QwenImageEditPlusPipeline.from_pretrained(
88
  ),
89
  torch_dtype=dtype
90
  ).to(device)
 
91
  try:
92
  pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
93
  print("Flash Attention 3 Processor set successfully.")
94
  except Exception as e:
95
  print(f"Warning: Could not set FA3 processor: {e}")
 
96
  ADAPTER_SPECS = {
97
  "Multi-Angle-Lighting": {
98
  "repo": "dx8152/Qwen-Edit-2509-Multi-Angle-Lighting",
@@ -100,7 +107,9 @@ ADAPTER_SPECS = {
100
  "adapter_name": "multi-angle-lighting"
101
  },
102
  }
 
103
  loaded = False
 
104
  AZIMUTH_MAP = {
105
  0: "Front",
106
  45: "Right Front",
@@ -111,21 +120,24 @@ AZIMUTH_MAP = {
111
  270: "Left",
112
  315: "Left Front"
113
  }
 
114
  ELEVATION_MAP = {
115
  -90: "Below",
116
  0: "",
117
  90: "Above"
118
  }
 
119
  def snap_to_nearest(value, options):
120
  return min(options, key=lambda x: abs(x - value))
 
121
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
122
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
123
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
124
-
125
  if elevation_snapped == 0:
126
  return f"Light source from the {AZIMUTH_MAP[azimuth_snapped]}"
127
  else:
128
  return f"Light source from {ELEVATION_MAP[elevation_snapped]}"
 
129
  @spaces.GPU
130
  def infer_lighting_edit(
131
  image: Image.Image,
@@ -139,7 +151,6 @@ def infer_lighting_edit(
139
  width: int = 1024,
140
  ):
141
  global loaded
142
-
143
  if not loaded:
144
  pipe.load_lora_weights(
145
  ADAPTER_SPECS["Multi-Angle-Lighting"]["repo"],
@@ -148,15 +159,20 @@ def infer_lighting_edit(
148
  )
149
  pipe.set_adapters([ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"]], adapter_weights=[1.0])
150
  loaded = True
151
-
152
  prompt = build_lighting_prompt(azimuth, elevation)
153
  print(f"Generated Prompt: {prompt}")
 
154
  if randomize_seed:
155
  seed = random.randint(0, MAX_SEED)
 
156
  generator = torch.Generator(device=device).manual_seed(seed)
 
157
  if image is None:
158
  raise gr.Error("Please upload an image first.")
 
159
  pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
 
160
  result = pipe(
161
  image=[pil_image],
162
  prompt=prompt,
@@ -167,7 +183,9 @@ def infer_lighting_edit(
167
  guidance_scale=guidance_scale,
168
  num_images_per_prompt=1,
169
  ).images[0]
 
170
  return result, seed, prompt
 
171
  def update_dimensions_on_upload(image):
172
  if image is None:
173
  return 1024, 1024
@@ -183,63 +201,62 @@ def update_dimensions_on_upload(image):
183
  new_width = (new_width // 8) * 8
184
  new_height = (new_height // 8) * 8
185
  return new_width, new_height
 
186
  class LightingControl3D(gr.HTML):
187
  def __init__(self, value=None, imageUrl=None, **kwargs):
188
  if value is None:
189
  value = {"azimuth": 0, "elevation": 0}
190
-
191
  html_template = """
192
- <div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #1a1a1a; border-radius: 12px; overflow: hidden;">
193
- <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: 12px; color: #00ff88; white-space: nowrap; z-index: 10;"></div>
194
  </div>
195
  """
196
-
197
  js_on_load = """
198
  (() => {
199
  const wrapper = element.querySelector('#lighting-control-wrapper');
200
  const promptOverlay = element.querySelector('#prompt-overlay');
201
-
202
  const initScene = () => {
203
  if (typeof THREE === 'undefined') {
204
  setTimeout(initScene, 100);
205
  return;
206
  }
207
-
208
  const scene = new THREE.Scene();
209
- scene.background = new THREE.Color(0x1a1a1a);
210
-
211
  const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
212
- camera.position.set(4.5, 3, 4.5);
213
- camera.lookAt(0, 0.75, 0);
214
-
215
  const renderer = new THREE.WebGLRenderer({ antialias: true });
216
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
217
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
218
  renderer.shadowMap.enabled = true;
219
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
220
  wrapper.insertBefore(renderer.domElement, promptOverlay);
221
-
222
- scene.add(new THREE.AmbientLight(0xffffff, 0.1));
223
-
224
  const ground = new THREE.Mesh(
225
- new THREE.PlaneGeometry(10, 10),
226
- new THREE.ShadowMaterial({ opacity: 0.3 })
227
  );
228
  ground.rotation.x = -Math.PI / 2;
229
- ground.position.y = 0;
230
  ground.receiveShadow = true;
231
  scene.add(ground);
232
-
233
- scene.add(new THREE.GridHelper(8, 16, 0x333333, 0x222222));
234
-
235
- const CENTER = new THREE.Vector3(0, 0.75, 0);
236
- const BASE_DISTANCE = 2.5;
237
- const AZIMUTH_RADIUS = 2.4;
238
- const ELEVATION_RADIUS = 1.8;
239
-
240
  let azimuthAngle = props.value?.azimuth || 0;
241
  let elevationAngle = props.value?.elevation || 0;
242
-
243
  const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
244
  const elevationSteps = [-90, 0, 90];
245
  const azimuthNames = {
@@ -248,54 +265,40 @@ class LightingControl3D(gr.HTML):
248
  270: 'Left', 315: 'Left Front'
249
  };
250
  const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' };
251
-
252
  function snapToNearest(value, steps) {
253
  return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
254
  }
255
-
256
  function createPlaceholderTexture() {
257
  const canvas = document.createElement('canvas');
258
- canvas.width = 256;
259
- canvas.height = 256;
260
  const ctx = canvas.getContext('2d');
261
- ctx.fillStyle = '#3a3a4a';
262
  ctx.fillRect(0, 0, 256, 256);
263
- ctx.fillStyle = '#ffcc99';
264
- ctx.beginPath();
265
- ctx.arc(128, 128, 80, 0, Math.PI * 2);
266
- ctx.fill();
267
- ctx.fillStyle = '#333';
268
- ctx.beginPath();
269
- ctx.arc(100, 110, 10, 0, Math.PI * 2);
270
- ctx.arc(156, 110, 10, 0, Math.PI * 2);
271
- ctx.fill();
272
- ctx.strokeStyle = '#333';
273
- ctx.lineWidth = 3;
274
- ctx.beginPath();
275
- ctx.arc(128, 130, 35, 0.2, Math.PI - 0.2);
276
- ctx.stroke();
277
  return new THREE.CanvasTexture(canvas);
278
  }
279
-
280
  let currentTexture = createPlaceholderTexture();
281
- const planeMaterial = new THREE.MeshStandardMaterial({ map: currentTexture, side: THREE.DoubleSide, roughness: 0.5, metalness: 0 });
282
- let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
 
 
 
 
 
283
  targetPlane.position.copy(CENTER);
284
  targetPlane.receiveShadow = true;
285
  scene.add(targetPlane);
286
-
287
  function updateTextureFromUrl(url) {
288
  if (!url) {
289
  planeMaterial.map = createPlaceholderTexture();
290
  planeMaterial.needsUpdate = true;
291
- scene.remove(targetPlane);
292
- targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
293
- targetPlane.position.copy(CENTER);
294
- targetPlane.receiveShadow = true;
295
- scene.add(targetPlane);
296
  return;
297
  }
298
-
299
  const loader = new THREE.TextureLoader();
300
  loader.crossOrigin = 'anonymous';
301
  loader.load(url, (texture) => {
@@ -303,294 +306,220 @@ class LightingControl3D(gr.HTML):
303
  texture.magFilter = THREE.LinearFilter;
304
  planeMaterial.map = texture;
305
  planeMaterial.needsUpdate = true;
306
-
307
  const img = texture.image;
308
  if (img && img.width && img.height) {
309
  const aspect = img.width / img.height;
310
- const maxSize = 1.5;
311
- let planeWidth, planeHeight;
312
- if (aspect > 1) {
313
- planeWidth = maxSize;
314
- planeHeight = maxSize / aspect;
315
- } else {
316
- planeHeight = maxSize;
317
- planeWidth = maxSize * aspect;
318
- }
319
  scene.remove(targetPlane);
320
- targetPlane = new THREE.Mesh(
321
- new THREE.PlaneGeometry(planeWidth, planeHeight),
322
- planeMaterial
323
- );
324
  targetPlane.position.copy(CENTER);
325
  targetPlane.receiveShadow = true;
326
  scene.add(targetPlane);
327
  }
328
- }, undefined, (err) => {
329
- console.error('Failed to load texture:', err);
330
  });
331
  }
332
-
333
- if (props.imageUrl) {
334
- updateTextureFromUrl(props.imageUrl);
335
- }
336
-
337
- function createLEDTexture() {
338
- const canvas = document.createElement('canvas');
339
- canvas.width = 256;
340
- canvas.height = 128;
341
- const ctx = canvas.getContext('2d');
342
-
343
- ctx.fillStyle = '#000000';
344
- ctx.fillRect(0, 0, 256, 128);
345
-
346
- const rows = 6;
347
- const cols = 12;
348
- const paddingX = 10;
349
- const paddingY = 10;
350
- const cellW = (256 - paddingX * 2) / cols;
351
- const cellH = (128 - paddingY * 2) / rows;
352
-
353
- for(let i=0; i<cols; i++) {
354
- for(let j=0; j<rows; j++) {
355
- const cx = paddingX + i * cellW + cellW/2;
356
- const cy = paddingY + j * cellH + cellH/2;
357
-
358
- const grad = ctx.createRadialGradient(cx, cy, 1, cx, cy, cellH * 0.45);
359
- grad.addColorStop(0, 'rgba(255, 255, 255, 1.0)');
360
- grad.addColorStop(0.4, 'rgba(255, 255, 255, 0.95)');
361
- grad.addColorStop(1, 'rgba(255, 255, 255, 0.0)');
362
-
363
- ctx.fillStyle = grad;
364
- ctx.beginPath();
365
- ctx.arc(cx, cy, cellH * 0.45, 0, Math.PI * 2);
366
- ctx.fill();
367
- }
368
- }
369
- return new THREE.CanvasTexture(canvas);
370
- }
371
-
372
  const lightGroup = new THREE.Group();
373
- const ledTexture = createLEDTexture();
374
-
375
- const housingMat = new THREE.MeshStandardMaterial({
376
- color: 0x808080,
377
- roughness: 0.4,
378
- metalness: 0.6
379
- });
380
-
381
- const barnDoorMat = new THREE.MeshStandardMaterial({
382
- color: 0x111111,
383
- roughness: 0.8,
384
- metalness: 0.1
385
- });
386
-
387
- const housingGeo = new THREE.BoxGeometry(0.8, 0.5, 0.15);
388
- const housing = new THREE.Mesh(housingGeo, housingMat);
389
- lightGroup.add(housing);
390
-
391
- const panelGeo = new THREE.PlaneGeometry(0.72, 0.42);
392
- const panelMat = new THREE.MeshStandardMaterial({
393
- map: ledTexture,
394
- emissiveMap: ledTexture,
395
- emissive: 0xffffff,
396
- emissiveIntensity: 5.0,
397
- color: 0x000000,
398
- roughness: 0.3,
399
- metalness: 0.1
400
  });
401
- const panel = new THREE.Mesh(panelGeo, panelMat);
402
- panel.position.z = 0.076;
403
- lightGroup.add(panel);
404
-
405
- function createBarnDoor(w, h) {
406
- const geo = new THREE.BoxGeometry(w, h, 0.02);
407
- const mesh = new THREE.Mesh(geo, barnDoorMat);
408
- const pivot = new THREE.Group();
409
- mesh.position.y = h/2;
410
- pivot.add(mesh);
411
- return pivot;
412
- }
413
-
414
- const topFlap = createBarnDoor(0.8, 0.25);
415
- topFlap.position.set(0, 0.25, 0.075);
416
- topFlap.rotation.x = THREE.MathUtils.degToRad(120);
417
- lightGroup.add(topFlap);
418
- const bottomFlap = createBarnDoor(0.8, 0.25);
419
- bottomFlap.position.set(0, -0.25, 0.075);
420
- bottomFlap.rotation.x = THREE.MathUtils.degToRad(-120);
421
- bottomFlap.rotation.z = Math.PI;
422
- lightGroup.add(bottomFlap);
423
-
424
- const yokeMat = new THREE.MeshStandardMaterial({
425
- color: 0x808080,
426
- roughness: 0.4,
427
- metalness: 0.6
428
  });
429
- const yokeGeo = new THREE.TorusGeometry(0.5, 0.02, 8, 32, Math.PI);
430
- const yoke = new THREE.Mesh(yokeGeo, yokeMat);
431
- yoke.rotation.z = Math.PI / 2;
432
- yoke.position.x = -0.55;
433
-
434
- const fullFixture = new THREE.Group();
435
- fullFixture.add(lightGroup);
436
-
437
- const standGeo = new THREE.CylinderGeometry(0.03, 0.03, 0.4);
438
- const stand = new THREE.Mesh(standGeo, yokeMat);
439
- stand.rotation.z = Math.PI / 2;
440
- stand.position.x = -0.6;
441
- fullFixture.add(stand);
442
- const spotLight = new THREE.SpotLight(0xffffff, 8, 10, Math.PI / 4, 0.5, 1);
443
- spotLight.position.set(0, 0, 0.1);
444
  spotLight.castShadow = true;
445
  spotLight.shadow.mapSize.width = 1024;
446
  spotLight.shadow.mapSize.height = 1024;
447
- spotLight.shadow.camera.near = 0.5;
448
- spotLight.shadow.camera.far = 500;
449
- spotLight.shadow.bias = -0.005;
450
  lightGroup.add(spotLight);
451
-
452
  const lightTarget = new THREE.Object3D();
453
  lightTarget.position.copy(CENTER);
454
  scene.add(lightTarget);
455
  spotLight.target = lightTarget;
456
-
457
- scene.add(fullFixture);
458
-
 
459
  const azimuthRing = new THREE.Mesh(
460
- new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 64),
461
- new THREE.MeshStandardMaterial({ color: 0x007bff, emissive: 0x007bff, emissiveIntensity: 0.3 })
462
  );
463
  azimuthRing.rotation.x = Math.PI / 2;
464
  azimuthRing.position.y = 0.05;
465
  scene.add(azimuthRing);
466
-
467
  const azimuthHandle = new THREE.Mesh(
468
- new THREE.SphereGeometry(0.18, 16, 16),
469
- new THREE.MeshStandardMaterial({ color: 0x007bff, emissive: 0x007bff, emissiveIntensity: 0.5 })
470
  );
471
  azimuthHandle.userData.type = 'azimuth';
472
  scene.add(azimuthHandle);
473
-
474
- const arcPoints = [];
475
- for (let i = 0; i <= 32; i++) {
476
- const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 32));
477
- arcPoints.push(new THREE.Vector3(-0.8, ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, ELEVATION_RADIUS * Math.cos(angle)));
478
- }
479
- const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
480
  const elevationArc = new THREE.Mesh(
481
- new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false),
482
- new THREE.MeshStandardMaterial({ color: 0xdc3545, emissive: 0xdc3545, emissiveIntensity: 0.3 })
483
  );
 
 
484
  scene.add(elevationArc);
485
-
486
  const elevationHandle = new THREE.Mesh(
487
- new THREE.SphereGeometry(0.18, 16, 16),
488
- new THREE.MeshStandardMaterial({ color: 0xdc3545, emissive: 0xdc3545, emissiveIntensity: 0.5 })
489
  );
490
  elevationHandle.userData.type = 'elevation';
491
  scene.add(elevationHandle);
492
-
 
493
  const refreshBtn = document.createElement('div');
494
  refreshBtn.innerHTML = '⟳';
495
  refreshBtn.style.position = 'absolute';
496
- refreshBtn.style.top = '10px';
497
- refreshBtn.style.right = '10px';
498
- refreshBtn.style.background = 'rgba(255,255,255,0.5)';
499
- refreshBtn.style.padding = '5px';
 
500
  refreshBtn.style.borderRadius = '50%';
 
 
 
 
 
501
  refreshBtn.style.cursor = 'pointer';
502
- refreshBtn.style.zIndex = '10';
503
- refreshBtn.style.fontSize = '20px';
 
 
504
  wrapper.appendChild(refreshBtn);
505
-
 
 
 
 
 
 
 
 
506
  refreshBtn.addEventListener('click', () => {
507
  azimuthAngle = 0;
508
  elevationAngle = 0;
509
  updatePositions();
510
  updatePropsAndTrigger();
511
  });
512
-
513
  function updatePositions() {
514
- const distance = BASE_DISTANCE;
515
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
516
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
517
-
518
- const lightX = distance * Math.sin(azRad) * Math.cos(elRad);
519
- const lightY = distance * Math.sin(elRad) + CENTER.y;
520
- const lightZ = distance * Math.cos(azRad) * Math.cos(elRad);
521
-
522
- fullFixture.position.set(lightX, lightY, lightZ);
523
- fullFixture.lookAt(CENTER);
524
-
525
- azimuthHandle.position.set(AZIMUTH_RADIUS * Math.sin(azRad), 0.05, AZIMUTH_RADIUS * Math.cos(azRad));
526
- elevationHandle.position.set(-0.8, ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y, ELEVATION_RADIUS * Math.cos(elRad));
527
-
528
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
529
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
530
  let prompt = 'Light source from';
531
- if (elSnap !== 0) {
532
- prompt += ' ' + elevationNames[String(elSnap)];
533
- } else {
534
- prompt += ' the ' + azimuthNames[azSnap];
535
- }
536
  promptOverlay.textContent = prompt;
537
  }
538
-
539
  function updatePropsAndTrigger() {
540
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
541
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
542
-
543
  props.value = { azimuth: azSnap, elevation: elSnap };
544
  trigger('change', props.value);
545
  }
546
-
547
  const raycaster = new THREE.Raycaster();
548
  const mouse = new THREE.Vector2();
549
  let isDragging = false;
550
  let dragTarget = null;
551
- let dragStartMouse = new THREE.Vector2();
552
- const intersection = new THREE.Vector3();
553
-
554
  const canvas = renderer.domElement;
555
-
556
  canvas.addEventListener('mousedown', (e) => {
557
  const rect = canvas.getBoundingClientRect();
558
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
559
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
560
-
561
  raycaster.setFromCamera(mouse, camera);
562
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
563
-
564
  if (intersects.length > 0) {
565
  isDragging = true;
566
  dragTarget = intersects[0].object;
567
- dragTarget.material.emissiveIntensity = 1.0;
568
- dragTarget.scale.setScalar(1.3);
569
- dragStartMouse.copy(mouse);
570
  canvas.style.cursor = 'grabbing';
571
  }
572
  });
573
-
574
  canvas.addEventListener('mousemove', (e) => {
575
  const rect = canvas.getBoundingClientRect();
576
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
577
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
578
-
579
  if (isDragging && dragTarget) {
580
  raycaster.setFromCamera(mouse, camera);
581
-
582
  if (dragTarget.userData.type === 'azimuth') {
583
  const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
584
- if (raycaster.ray.intersectPlane(plane, intersection)) {
585
- azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
 
586
  if (azimuthAngle < 0) azimuthAngle += 360;
587
  }
588
  } else if (dragTarget.userData.type === 'elevation') {
589
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
590
- if (raycaster.ray.intersectPlane(plane, intersection)) {
591
- const relY = intersection.y - CENTER.y;
592
- const relZ = intersection.z;
593
- elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
 
 
 
594
  }
595
  }
596
  updatePositions();
@@ -598,43 +527,43 @@ class LightingControl3D(gr.HTML):
598
  raycaster.setFromCamera(mouse, camera);
599
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
600
  [azimuthHandle, elevationHandle].forEach(h => {
601
- h.material.emissiveIntensity = 0.5;
602
  h.scale.setScalar(1);
603
  });
604
  if (intersects.length > 0) {
605
- intersects[0].object.material.emissiveIntensity = 0.8;
606
- intersects[0].object.scale.setScalar(1.1);
607
  canvas.style.cursor = 'grab';
608
  } else {
609
  canvas.style.cursor = 'default';
610
  }
611
  }
612
  });
613
-
614
  const onMouseUp = () => {
615
  if (dragTarget) {
616
- dragTarget.material.emissiveIntensity = 0.5;
617
  dragTarget.scale.setScalar(1);
618
-
619
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
620
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
621
-
622
  const startAz = azimuthAngle, startEl = elevationAngle;
623
  const startTime = Date.now();
624
-
625
  function animateSnap() {
626
- const t = Math.min((Date.now() - startTime) / 200, 1);
627
  const ease = 1 - Math.pow(1 - t, 3);
628
-
629
  let azDiff = targetAz - startAz;
630
  if (azDiff > 180) azDiff -= 360;
631
  if (azDiff < -180) azDiff += 360;
632
  azimuthAngle = startAz + azDiff * ease;
633
  if (azimuthAngle < 0) azimuthAngle += 360;
634
  if (azimuthAngle >= 360) azimuthAngle -= 360;
635
-
636
  elevationAngle = startEl + (targetEl - startEl) * ease;
637
-
638
  updatePositions();
639
  if (t < 1) requestAnimationFrame(animateSnap);
640
  else updatePropsAndTrigger();
@@ -645,80 +574,76 @@ class LightingControl3D(gr.HTML):
645
  dragTarget = null;
646
  canvas.style.cursor = 'default';
647
  };
648
-
649
  canvas.addEventListener('mouseup', onMouseUp);
650
  canvas.addEventListener('mouseleave', onMouseUp);
 
 
651
  canvas.addEventListener('touchstart', (e) => {
652
  e.preventDefault();
653
  const touch = e.touches[0];
654
  const rect = canvas.getBoundingClientRect();
655
  mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
656
  mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
657
-
658
  raycaster.setFromCamera(mouse, camera);
659
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
660
-
661
  if (intersects.length > 0) {
662
  isDragging = true;
663
  dragTarget = intersects[0].object;
664
- dragTarget.material.emissiveIntensity = 1.0;
665
- dragTarget.scale.setScalar(1.3);
666
- dragStartMouse.copy(mouse);
667
  }
668
  }, { passive: false });
669
-
670
  canvas.addEventListener('touchmove', (e) => {
671
  e.preventDefault();
 
672
  const touch = e.touches[0];
673
  const rect = canvas.getBoundingClientRect();
674
  mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
675
  mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
676
-
677
- if (isDragging && dragTarget) {
678
- raycaster.setFromCamera(mouse, camera);
679
-
680
- if (dragTarget.userData.type === 'azimuth') {
681
- const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
682
- if (raycaster.ray.intersectPlane(plane, intersection)) {
683
- azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
684
- if (azimuthAngle < 0) azimuthAngle += 360;
685
- }
686
- } else if (dragTarget.userData.type === 'elevation') {
687
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
688
- if (raycaster.ray.intersectPlane(plane, intersection)) {
689
- const relY = intersection.y - CENTER.y;
690
- const relZ = intersection.z;
691
- elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
692
- }
 
 
693
  }
694
- updatePositions();
695
  }
 
696
  }, { passive: false });
697
-
698
- canvas.addEventListener('touchend', (e) => {
699
- e.preventDefault();
700
- onMouseUp();
701
- }, { passive: false });
702
-
703
- canvas.addEventListener('touchcancel', (e) => {
704
- e.preventDefault();
705
- onMouseUp();
706
- }, { passive: false });
707
-
708
  updatePositions();
709
-
710
  function render() {
711
  requestAnimationFrame(render);
712
  renderer.render(scene, camera);
713
  }
714
  render();
715
-
716
  new ResizeObserver(() => {
717
  camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
718
  camera.updateProjectionMatrix();
719
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
720
  }).observe(wrapper);
721
-
722
  wrapper._updateFromProps = (newVal) => {
723
  if (newVal && typeof newVal === 'object') {
724
  azimuthAngle = newVal.azimuth ?? azimuthAngle;
@@ -726,9 +651,9 @@ class LightingControl3D(gr.HTML):
726
  updatePositions();
727
  }
728
  };
729
-
730
  wrapper._updateTexture = updateTextureFromUrl;
731
-
732
  let lastImageUrl = props.imageUrl;
733
  let lastValue = JSON.stringify(props.value);
734
  setInterval(() => {
@@ -745,13 +670,13 @@ class LightingControl3D(gr.HTML):
745
  updatePositions();
746
  }
747
  }
748
- }, 100);
749
  };
750
-
751
  initScene();
752
  })();
753
  """
754
-
755
  super().__init__(
756
  value=value,
757
  html_template=html_template,
@@ -759,6 +684,7 @@ class LightingControl3D(gr.HTML):
759
  imageUrl=imageUrl,
760
  **kwargs
761
  )
 
762
  css = '''
763
  #col-container { max-width: 1200px; margin: 0 auto; }
764
  .dark .progress-text { color: white !important; }
@@ -766,25 +692,26 @@ css = '''
766
  .slider-row { display: flex; gap: 10px; align-items: center; }
767
  #main-title h1 {font-size: 2.4em !important;}
768
  '''
 
769
  with gr.Blocks(css=css) as demo:
770
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
771
  gr.Markdown("Control lighting directions using the **3D viewport** or **sliders**. Use [Multi-Angle-Lighting](https://huggingface.co/dx8152/Qwen-Edit-2509-Multi-Angle-Lighting) LoRA for precise lighting control, paired with [Rapid-AIO-V19](https://huggingface.co/prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19).")
772
-
773
  with gr.Row():
774
  with gr.Column(scale=1):
775
  image = gr.Image(label="Input Image", type="pil", height=300)
776
-
777
  gr.Markdown("### 3D Lighting Control")
778
- gr.Markdown("*Drag the colored handles: 🔵 Azimuth (Direction), 🔴 Elevation (Height)*")
779
-
780
  lighting_3d = LightingControl3D(
781
  value={"azimuth": 0, "elevation": 0},
782
  elem_id="lighting-3d-control"
783
  )
784
  run_btn = gr.Button("Generate Image", variant="primary", size="lg")
785
-
786
  gr.Markdown("### Slider Controls")
787
-
788
  azimuth_slider = gr.Slider(
789
  label="Azimuth (Horizontal Rotation)",
790
  minimum=0,
@@ -793,7 +720,7 @@ with gr.Blocks(css=css) as demo:
793
  value=0,
794
  info="0°=front, 90°=right, 180°=rear, 270°=left"
795
  )
796
-
797
  elevation_slider = gr.Slider(
798
  label="Elevation (Vertical Angle)",
799
  minimum=-90,
@@ -809,10 +736,10 @@ with gr.Blocks(css=css) as demo:
809
  interactive=True,
810
  lines=1,
811
  )
812
-
813
  with gr.Column(scale=1):
814
  result = gr.Image(label="Output Image", height=500)
815
-
816
  with gr.Accordion("Advanced Settings", open=False):
817
  seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
818
  randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
@@ -820,11 +747,11 @@ with gr.Blocks(css=css) as demo:
820
  num_inference_steps = gr.Slider(label="Inference Steps", minimum=1, maximum=20, step=1, value=4)
821
  height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
822
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
823
-
824
  def update_prompt_from_sliders(azimuth, elevation):
825
  prompt = build_lighting_prompt(azimuth, elevation)
826
  return prompt
827
-
828
  def sync_3d_to_sliders(lighting_value):
829
  if lighting_value and isinstance(lighting_value, dict):
830
  az = lighting_value.get('azimuth', 0)
@@ -832,10 +759,10 @@ with gr.Blocks(css=css) as demo:
832
  prompt = build_lighting_prompt(az, el)
833
  return az, el, prompt
834
  return gr.update(), gr.update(), gr.update()
835
-
836
  def sync_sliders_to_3d(azimuth, elevation):
837
  return {"azimuth": azimuth, "elevation": elevation}
838
-
839
  def update_3d_image(image):
840
  if image is None:
841
  return gr.update(imageUrl=None)
@@ -846,33 +773,33 @@ with gr.Blocks(css=css) as demo:
846
  img_str = base64.b64encode(buffered.getvalue()).decode()
847
  data_url = f"data:image/png;base64,{img_str}"
848
  return gr.update(imageUrl=data_url)
849
-
850
  for slider in [azimuth_slider, elevation_slider]:
851
  slider.change(
852
  fn=update_prompt_from_sliders,
853
  inputs=[azimuth_slider, elevation_slider],
854
  outputs=[prompt_preview]
855
  )
856
-
857
  lighting_3d.change(
858
  fn=sync_3d_to_sliders,
859
  inputs=[lighting_3d],
860
  outputs=[azimuth_slider, elevation_slider, prompt_preview]
861
  )
862
-
863
  for slider in [azimuth_slider, elevation_slider]:
864
  slider.release(
865
  fn=sync_sliders_to_3d,
866
  inputs=[azimuth_slider, elevation_slider],
867
  outputs=[lighting_3d]
868
  )
869
-
870
  run_btn.click(
871
  fn=infer_lighting_edit,
872
  inputs=[image, azimuth_slider, elevation_slider, seed, randomize_seed, guidance_scale, num_inference_steps, height, width],
873
  outputs=[result, seed, prompt_preview]
874
  )
875
-
876
  image.upload(
877
  fn=update_dimensions_on_upload,
878
  inputs=[image],
@@ -882,12 +809,12 @@ with gr.Blocks(css=css) as demo:
882
  inputs=[image],
883
  outputs=[lighting_3d]
884
  )
885
-
886
  image.clear(
887
  fn=lambda: gr.update(imageUrl=None),
888
  outputs=[lighting_3d]
889
  )
890
-
891
  if __name__ == "__main__":
892
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
893
  css = '.fillable{max-width: 1200px !important}'
 
11
  from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
12
  from gradio.themes import Soft
13
  from gradio.themes.utils import colors, fonts, sizes
14
+
15
  colors.orange_red = colors.Color(
16
  name="orange_red",
17
  c50="#FFF0E5",
 
26
  c900="#992900",
27
  c950="#802200",
28
  )
29
+
30
  class OrangeRedTheme(Soft):
31
  def __init__(
32
  self,
 
77
  color_accent_soft="*primary_100",
78
  block_label_background_fill="*primary_200",
79
  )
80
+
81
  orange_red_theme = OrangeRedTheme()
82
+
83
  MAX_SEED = np.iinfo(np.int32).max
84
  dtype = torch.bfloat16
85
  device = "cuda" if torch.cuda.is_available() else "cpu"
86
+
87
  pipe = QwenImageEditPlusPipeline.from_pretrained(
88
  "Qwen/Qwen-Image-Edit-2511",
89
  transformer=QwenImageTransformer2DModel.from_pretrained(
 
93
  ),
94
  torch_dtype=dtype
95
  ).to(device)
96
+
97
  try:
98
  pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
99
  print("Flash Attention 3 Processor set successfully.")
100
  except Exception as e:
101
  print(f"Warning: Could not set FA3 processor: {e}")
102
+
103
  ADAPTER_SPECS = {
104
  "Multi-Angle-Lighting": {
105
  "repo": "dx8152/Qwen-Edit-2509-Multi-Angle-Lighting",
 
107
  "adapter_name": "multi-angle-lighting"
108
  },
109
  }
110
+
111
  loaded = False
112
+
113
  AZIMUTH_MAP = {
114
  0: "Front",
115
  45: "Right Front",
 
120
  270: "Left",
121
  315: "Left Front"
122
  }
123
+
124
  ELEVATION_MAP = {
125
  -90: "Below",
126
  0: "",
127
  90: "Above"
128
  }
129
+
130
  def snap_to_nearest(value, options):
131
  return min(options, key=lambda x: abs(x - value))
132
+
133
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
134
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
135
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
 
136
  if elevation_snapped == 0:
137
  return f"Light source from the {AZIMUTH_MAP[azimuth_snapped]}"
138
  else:
139
  return f"Light source from {ELEVATION_MAP[elevation_snapped]}"
140
+
141
  @spaces.GPU
142
  def infer_lighting_edit(
143
  image: Image.Image,
 
151
  width: int = 1024,
152
  ):
153
  global loaded
 
154
  if not loaded:
155
  pipe.load_lora_weights(
156
  ADAPTER_SPECS["Multi-Angle-Lighting"]["repo"],
 
159
  )
160
  pipe.set_adapters([ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"]], adapter_weights=[1.0])
161
  loaded = True
162
+
163
  prompt = build_lighting_prompt(azimuth, elevation)
164
  print(f"Generated Prompt: {prompt}")
165
+
166
  if randomize_seed:
167
  seed = random.randint(0, MAX_SEED)
168
+
169
  generator = torch.Generator(device=device).manual_seed(seed)
170
+
171
  if image is None:
172
  raise gr.Error("Please upload an image first.")
173
+
174
  pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
175
+
176
  result = pipe(
177
  image=[pil_image],
178
  prompt=prompt,
 
183
  guidance_scale=guidance_scale,
184
  num_images_per_prompt=1,
185
  ).images[0]
186
+
187
  return result, seed, prompt
188
+
189
  def update_dimensions_on_upload(image):
190
  if image is None:
191
  return 1024, 1024
 
201
  new_width = (new_width // 8) * 8
202
  new_height = (new_height // 8) * 8
203
  return new_width, new_height
204
+
205
  class LightingControl3D(gr.HTML):
206
  def __init__(self, value=None, imageUrl=None, **kwargs):
207
  if value is None:
208
  value = {"azimuth": 0, "elevation": 0}
209
+
210
  html_template = """
211
+ <div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #111111; border-radius: 12px; overflow: hidden;">
212
+ <div id="prompt-overlay" style="position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.75); padding: 8px 18px; border-radius: 12px; font-family: monospace; font-size: 13px; color: #a5d6ff; white-space: nowrap; z-index: 10; border: 1px solid #333;"></div>
213
  </div>
214
  """
215
+
216
  js_on_load = """
217
  (() => {
218
  const wrapper = element.querySelector('#lighting-control-wrapper');
219
  const promptOverlay = element.querySelector('#prompt-overlay');
220
+
221
  const initScene = () => {
222
  if (typeof THREE === 'undefined') {
223
  setTimeout(initScene, 100);
224
  return;
225
  }
226
+
227
  const scene = new THREE.Scene();
228
+ scene.background = new THREE.Color(0x111111);
229
+
230
  const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
231
+ camera.position.set(5, 3.5, 5);
232
+ camera.lookAt(0, 0.8, 0);
233
+
234
  const renderer = new THREE.WebGLRenderer({ antialias: true });
235
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
236
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
237
  renderer.shadowMap.enabled = true;
238
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
239
  wrapper.insertBefore(renderer.domElement, promptOverlay);
240
+
241
+ scene.add(new THREE.AmbientLight(0xffffff, 0.15));
242
+
243
  const ground = new THREE.Mesh(
244
+ new THREE.PlaneGeometry(12, 12),
245
+ new THREE.ShadowMaterial({ opacity: 0.35 })
246
  );
247
  ground.rotation.x = -Math.PI / 2;
248
+ ground.position.y = -0.01;
249
  ground.receiveShadow = true;
250
  scene.add(ground);
251
+
252
+ scene.add(new THREE.GridHelper(10, 20, 0x2a2a2a, 0x1a1a1a));
253
+
254
+ const CENTER = new THREE.Vector3(0, 0.8, 0);
255
+ const BASE_DISTANCE = 3.2;
256
+
 
 
257
  let azimuthAngle = props.value?.azimuth || 0;
258
  let elevationAngle = props.value?.elevation || 0;
259
+
260
  const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
261
  const elevationSteps = [-90, 0, 90];
262
  const azimuthNames = {
 
265
  270: 'Left', 315: 'Left Front'
266
  };
267
  const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' };
268
+
269
  function snapToNearest(value, steps) {
270
  return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
271
  }
272
+
273
  function createPlaceholderTexture() {
274
  const canvas = document.createElement('canvas');
275
+ canvas.width = 256; canvas.height = 256;
 
276
  const ctx = canvas.getContext('2d');
277
+ ctx.fillStyle = '#222';
278
  ctx.fillRect(0, 0, 256, 256);
279
+ ctx.fillStyle = '#444';
280
+ ctx.fillRect(20, 20, 216, 216);
 
 
 
 
 
 
 
 
 
 
 
 
281
  return new THREE.CanvasTexture(canvas);
282
  }
283
+
284
  let currentTexture = createPlaceholderTexture();
285
+ const planeMaterial = new THREE.MeshStandardMaterial({
286
+ map: currentTexture,
287
+ side: THREE.DoubleSide,
288
+ roughness: 0.7,
289
+ metalness: 0.1
290
+ });
291
+ let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 1.4), planeMaterial);
292
  targetPlane.position.copy(CENTER);
293
  targetPlane.receiveShadow = true;
294
  scene.add(targetPlane);
295
+
296
  function updateTextureFromUrl(url) {
297
  if (!url) {
298
  planeMaterial.map = createPlaceholderTexture();
299
  planeMaterial.needsUpdate = true;
 
 
 
 
 
300
  return;
301
  }
 
302
  const loader = new THREE.TextureLoader();
303
  loader.crossOrigin = 'anonymous';
304
  loader.load(url, (texture) => {
 
306
  texture.magFilter = THREE.LinearFilter;
307
  planeMaterial.map = texture;
308
  planeMaterial.needsUpdate = true;
309
+
310
  const img = texture.image;
311
  if (img && img.width && img.height) {
312
  const aspect = img.width / img.height;
313
+ const maxSize = 1.8;
314
+ let w = maxSize, h = maxSize;
315
+ if (aspect > 1) h = maxSize / aspect;
316
+ else w = maxSize * aspect;
 
 
 
 
 
317
  scene.remove(targetPlane);
318
+ targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(w, h), planeMaterial);
 
 
 
319
  targetPlane.position.copy(CENTER);
320
  targetPlane.receiveShadow = true;
321
  scene.add(targetPlane);
322
  }
 
 
323
  });
324
  }
325
+
326
+ if (props.imageUrl) updateTextureFromUrl(props.imageUrl);
327
+
328
+ // ── Modern Softbox Light Fixture ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  const lightGroup = new THREE.Group();
330
+
331
+ // Softbox housing (slightly beveled rectangle)
332
+ const boxGeo = new THREE.BoxGeometry(1.2, 0.9, 0.18);
333
+ const boxMat = new THREE.MeshStandardMaterial({
334
+ color: 0xe0e0ff,
335
+ roughness: 0.5,
336
+ metalness: 0.3,
337
+ emissive: 0x404060,
338
+ emissiveIntensity: 0.15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  });
340
+ const box = new THREE.Mesh(boxGeo, boxMat);
341
+ lightGroup.add(box);
342
+
343
+ // Diffusion panel (front glowing surface)
344
+ const diffusionGeo = new THREE.PlaneGeometry(1.08, 0.78);
345
+ const diffusionMat = new THREE.MeshStandardMaterial({
346
+ color: 0xffffff,
347
+ emissive: 0x88aaff,
348
+ emissiveIntensity: 1.8,
349
+ roughness: 0.9,
350
+ transparent: true,
351
+ opacity: 0.85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  });
353
+ const diffusion = new THREE.Mesh(diffusionGeo, diffusionMat);
354
+ diffusion.position.z = 0.095;
355
+ lightGroup.add(diffusion);
356
+
357
+ // Subtle rim light
358
+ const rimLight = new THREE.PointLight(0xaaccff, 2, 3);
359
+ rimLight.position.set(0, 0, 0.12);
360
+ lightGroup.add(rimLight);
361
+
362
+ // Main spot light
363
+ const spotLight = new THREE.SpotLight(0xffffff, 12, 12, Math.PI / 3.5, 0.4, 1.2);
364
+ spotLight.position.set(0, 0, 0.15);
 
 
 
365
  spotLight.castShadow = true;
366
  spotLight.shadow.mapSize.width = 1024;
367
  spotLight.shadow.mapSize.height = 1024;
368
+ spotLight.shadow.bias = -0.004;
 
 
369
  lightGroup.add(spotLight);
370
+
371
  const lightTarget = new THREE.Object3D();
372
  lightTarget.position.copy(CENTER);
373
  scene.add(lightTarget);
374
  spotLight.target = lightTarget;
375
+
376
+ scene.add(lightGroup);
377
+
378
+ // ── Controls ──
379
  const azimuthRing = new THREE.Mesh(
380
+ new THREE.TorusGeometry(2.6, 0.05, 16, 64),
381
+ new THREE.MeshStandardMaterial({ color: 0x00d4ff, emissive: 0x00d4ff, emissiveIntensity: 0.6 })
382
  );
383
  azimuthRing.rotation.x = Math.PI / 2;
384
  azimuthRing.position.y = 0.05;
385
  scene.add(azimuthRing);
386
+
387
  const azimuthHandle = new THREE.Mesh(
388
+ new THREE.SphereGeometry(0.20, 24, 24),
389
+ new THREE.MeshStandardMaterial({ color: 0x00d4ff, emissive: 0x00d4ff, emissiveIntensity: 0.8 })
390
  );
391
  azimuthHandle.userData.type = 'azimuth';
392
  scene.add(azimuthHandle);
393
+
 
 
 
 
 
 
394
  const elevationArc = new THREE.Mesh(
395
+ new THREE.TorusGeometry(2.0, 0.05, 16, 64, Math.PI),
396
+ new THREE.MeshStandardMaterial({ color: 0xff2e63, emissive: 0xff2e63, emissiveIntensity: 0.6 })
397
  );
398
+ elevationArc.rotation.z = Math.PI / 2;
399
+ elevationArc.position.set(-0.9, CENTER.y, 0);
400
  scene.add(elevationArc);
401
+
402
  const elevationHandle = new THREE.Mesh(
403
+ new THREE.SphereGeometry(0.20, 24, 24),
404
+ new THREE.MeshStandardMaterial({ color: 0xff2e63, emissive: 0xff2e63, emissiveIntensity: 0.8 })
405
  );
406
  elevationHandle.userData.type = 'elevation';
407
  scene.add(elevationHandle);
408
+
409
+ // ── Refresh Button ──
410
  const refreshBtn = document.createElement('div');
411
  refreshBtn.innerHTML = '⟳';
412
  refreshBtn.style.position = 'absolute';
413
+ refreshBtn.style.top = '12px';
414
+ refreshBtn.style.right = '12px';
415
+ refreshBtn.style.width = '36px';
416
+ refreshBtn.style.height = '36px';
417
+ refreshBtn.style.background = 'rgba(40,40,60,0.7)';
418
  refreshBtn.style.borderRadius = '50%';
419
+ refreshBtn.style.color = '#a0d0ff';
420
+ refreshBtn.style.fontSize = '24px';
421
+ refreshBtn.style.display = 'flex';
422
+ refreshBtn.style.alignItems = 'center';
423
+ refreshBtn.style.justifyContent = 'center';
424
  refreshBtn.style.cursor = 'pointer';
425
+ refreshBtn.style.zIndex = '20';
426
+ refreshBtn.style.border = '1px solid #444';
427
+ refreshBtn.style.transition = 'all 0.2s';
428
+ refreshBtn.title = 'Reset lighting';
429
  wrapper.appendChild(refreshBtn);
430
+
431
+ refreshBtn.addEventListener('mouseover', () => {
432
+ refreshBtn.style.background = 'rgba(60,80,120,0.9)';
433
+ refreshBtn.style.transform = 'rotate(90deg)';
434
+ });
435
+ refreshBtn.addEventListener('mouseout', () => {
436
+ refreshBtn.style.background = 'rgba(40,40,60,0.7)';
437
+ refreshBtn.style.transform = 'rotate(0deg)';
438
+ });
439
  refreshBtn.addEventListener('click', () => {
440
  azimuthAngle = 0;
441
  elevationAngle = 0;
442
  updatePositions();
443
  updatePropsAndTrigger();
444
  });
445
+
446
  function updatePositions() {
 
447
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
448
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
449
+
450
+ const x = BASE_DISTANCE * Math.sin(azRad) * Math.cos(elRad);
451
+ const y = BASE_DISTANCE * Math.sin(elRad) + CENTER.y;
452
+ const z = BASE_DISTANCE * Math.cos(azRad) * Math.cos(elRad);
453
+
454
+ lightGroup.position.set(x, y, z);
455
+ lightGroup.lookAt(CENTER);
456
+
457
+ azimuthHandle.position.set(2.6 * Math.sin(azRad), 0.05, 2.6 * Math.cos(azRad));
458
+ elevationHandle.position.set(-0.9, 2.0 * Math.sin(elRad) + CENTER.y, 2.0 * Math.cos(elRad));
459
+
460
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
461
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
462
  let prompt = 'Light source from';
463
+ if (elSnap !== 0) prompt += ' ' + elevationNames[String(elSnap)];
464
+ else prompt += ' the ' + azimuthNames[azSnap];
 
 
 
465
  promptOverlay.textContent = prompt;
466
  }
467
+
468
  function updatePropsAndTrigger() {
469
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
470
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
 
471
  props.value = { azimuth: azSnap, elevation: elSnap };
472
  trigger('change', props.value);
473
  }
474
+
475
  const raycaster = new THREE.Raycaster();
476
  const mouse = new THREE.Vector2();
477
  let isDragging = false;
478
  let dragTarget = null;
479
+
 
 
480
  const canvas = renderer.domElement;
481
+
482
  canvas.addEventListener('mousedown', (e) => {
483
  const rect = canvas.getBoundingClientRect();
484
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
485
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
486
+
487
  raycaster.setFromCamera(mouse, camera);
488
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
489
+
490
  if (intersects.length > 0) {
491
  isDragging = true;
492
  dragTarget = intersects[0].object;
493
+ dragTarget.material.emissiveIntensity = 1.2;
494
+ dragTarget.scale.setScalar(1.4);
 
495
  canvas.style.cursor = 'grabbing';
496
  }
497
  });
498
+
499
  canvas.addEventListener('mousemove', (e) => {
500
  const rect = canvas.getBoundingClientRect();
501
  mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
502
  mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
503
+
504
  if (isDragging && dragTarget) {
505
  raycaster.setFromCamera(mouse, camera);
506
+
507
  if (dragTarget.userData.type === 'azimuth') {
508
  const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
509
+ const intersect = new THREE.Vector3();
510
+ if (raycaster.ray.intersectPlane(plane, intersect)) {
511
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersect.x, intersect.z));
512
  if (azimuthAngle < 0) azimuthAngle += 360;
513
  }
514
  } else if (dragTarget.userData.type === 'elevation') {
515
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.9);
516
+ const intersect = new THREE.Vector3();
517
+ if (raycaster.ray.intersectPlane(plane, intersect)) {
518
+ const relY = intersect.y - CENTER.y;
519
+ const relZ = intersect.z;
520
+ elevationAngle = THREE.MathUtils.clamp(
521
+ THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90
522
+ );
523
  }
524
  }
525
  updatePositions();
 
527
  raycaster.setFromCamera(mouse, camera);
528
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
529
  [azimuthHandle, elevationHandle].forEach(h => {
530
+ h.material.emissiveIntensity = 0.8;
531
  h.scale.setScalar(1);
532
  });
533
  if (intersects.length > 0) {
534
+ intersects[0].object.material.emissiveIntensity = 1.1;
535
+ intersects[0].object.scale.setScalar(1.2);
536
  canvas.style.cursor = 'grab';
537
  } else {
538
  canvas.style.cursor = 'default';
539
  }
540
  }
541
  });
542
+
543
  const onMouseUp = () => {
544
  if (dragTarget) {
545
+ dragTarget.material.emissiveIntensity = 0.8;
546
  dragTarget.scale.setScalar(1);
547
+
548
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
549
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
550
+
551
  const startAz = azimuthAngle, startEl = elevationAngle;
552
  const startTime = Date.now();
553
+
554
  function animateSnap() {
555
+ const t = Math.min((Date.now() - startTime) / 240, 1);
556
  const ease = 1 - Math.pow(1 - t, 3);
557
+
558
  let azDiff = targetAz - startAz;
559
  if (azDiff > 180) azDiff -= 360;
560
  if (azDiff < -180) azDiff += 360;
561
  azimuthAngle = startAz + azDiff * ease;
562
  if (azimuthAngle < 0) azimuthAngle += 360;
563
  if (azimuthAngle >= 360) azimuthAngle -= 360;
564
+
565
  elevationAngle = startEl + (targetEl - startEl) * ease;
566
+
567
  updatePositions();
568
  if (t < 1) requestAnimationFrame(animateSnap);
569
  else updatePropsAndTrigger();
 
574
  dragTarget = null;
575
  canvas.style.cursor = 'default';
576
  };
577
+
578
  canvas.addEventListener('mouseup', onMouseUp);
579
  canvas.addEventListener('mouseleave', onMouseUp);
580
+
581
+ // Touch support (simplified)
582
  canvas.addEventListener('touchstart', (e) => {
583
  e.preventDefault();
584
  const touch = e.touches[0];
585
  const rect = canvas.getBoundingClientRect();
586
  mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
587
  mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
588
+
589
  raycaster.setFromCamera(mouse, camera);
590
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
591
+
592
  if (intersects.length > 0) {
593
  isDragging = true;
594
  dragTarget = intersects[0].object;
595
+ dragTarget.material.emissiveIntensity = 1.2;
596
+ dragTarget.scale.setScalar(1.4);
 
597
  }
598
  }, { passive: false });
599
+
600
  canvas.addEventListener('touchmove', (e) => {
601
  e.preventDefault();
602
+ if (!isDragging) return;
603
  const touch = e.touches[0];
604
  const rect = canvas.getBoundingClientRect();
605
  mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
606
  mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
607
+
608
+ raycaster.setFromCamera(mouse, camera);
609
+
610
+ if (dragTarget.userData.type === 'azimuth') {
611
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
612
+ const intersect = new THREE.Vector3();
613
+ if (raycaster.ray.intersectPlane(plane, intersect)) {
614
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersect.x, intersect.z));
615
+ if (azimuthAngle < 0) azimuthAngle += 360;
616
+ }
617
+ } else if (dragTarget.userData.type === 'elevation') {
618
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.9);
619
+ const intersect = new THREE.Vector3();
620
+ if (raycaster.ray.intersectPlane(plane, intersect)) {
621
+ const relY = intersect.y - CENTER.y;
622
+ const relZ = intersect.z;
623
+ elevationAngle = THREE.MathUtils.clamp(
624
+ THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90
625
+ );
626
  }
 
627
  }
628
+ updatePositions();
629
  }, { passive: false });
630
+
631
+ canvas.addEventListener('touchend', onMouseUp, { passive: false });
632
+
 
 
 
 
 
 
 
 
633
  updatePositions();
634
+
635
  function render() {
636
  requestAnimationFrame(render);
637
  renderer.render(scene, camera);
638
  }
639
  render();
640
+
641
  new ResizeObserver(() => {
642
  camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
643
  camera.updateProjectionMatrix();
644
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
645
  }).observe(wrapper);
646
+
647
  wrapper._updateFromProps = (newVal) => {
648
  if (newVal && typeof newVal === 'object') {
649
  azimuthAngle = newVal.azimuth ?? azimuthAngle;
 
651
  updatePositions();
652
  }
653
  };
654
+
655
  wrapper._updateTexture = updateTextureFromUrl;
656
+
657
  let lastImageUrl = props.imageUrl;
658
  let lastValue = JSON.stringify(props.value);
659
  setInterval(() => {
 
670
  updatePositions();
671
  }
672
  }
673
+ }, 80);
674
  };
675
+
676
  initScene();
677
  })();
678
  """
679
+
680
  super().__init__(
681
  value=value,
682
  html_template=html_template,
 
684
  imageUrl=imageUrl,
685
  **kwargs
686
  )
687
+
688
  css = '''
689
  #col-container { max-width: 1200px; margin: 0 auto; }
690
  .dark .progress-text { color: white !important; }
 
692
  .slider-row { display: flex; gap: 10px; align-items: center; }
693
  #main-title h1 {font-size: 2.4em !important;}
694
  '''
695
+
696
  with gr.Blocks(css=css) as demo:
697
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
698
  gr.Markdown("Control lighting directions using the **3D viewport** or **sliders**. Use [Multi-Angle-Lighting](https://huggingface.co/dx8152/Qwen-Edit-2509-Multi-Angle-Lighting) LoRA for precise lighting control, paired with [Rapid-AIO-V19](https://huggingface.co/prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19).")
699
+
700
  with gr.Row():
701
  with gr.Column(scale=1):
702
  image = gr.Image(label="Input Image", type="pil", height=300)
703
+
704
  gr.Markdown("### 3D Lighting Control")
705
+ gr.Markdown("*Drag the colored handles: 🔵 Azimuth (cyan), 🔴 Elevation (magenta)*")
706
+
707
  lighting_3d = LightingControl3D(
708
  value={"azimuth": 0, "elevation": 0},
709
  elem_id="lighting-3d-control"
710
  )
711
  run_btn = gr.Button("Generate Image", variant="primary", size="lg")
712
+
713
  gr.Markdown("### Slider Controls")
714
+
715
  azimuth_slider = gr.Slider(
716
  label="Azimuth (Horizontal Rotation)",
717
  minimum=0,
 
720
  value=0,
721
  info="0°=front, 90°=right, 180°=rear, 270°=left"
722
  )
723
+
724
  elevation_slider = gr.Slider(
725
  label="Elevation (Vertical Angle)",
726
  minimum=-90,
 
736
  interactive=True,
737
  lines=1,
738
  )
739
+
740
  with gr.Column(scale=1):
741
  result = gr.Image(label="Output Image", height=500)
742
+
743
  with gr.Accordion("Advanced Settings", open=False):
744
  seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
745
  randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
 
747
  num_inference_steps = gr.Slider(label="Inference Steps", minimum=1, maximum=20, step=1, value=4)
748
  height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
749
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
750
+
751
  def update_prompt_from_sliders(azimuth, elevation):
752
  prompt = build_lighting_prompt(azimuth, elevation)
753
  return prompt
754
+
755
  def sync_3d_to_sliders(lighting_value):
756
  if lighting_value and isinstance(lighting_value, dict):
757
  az = lighting_value.get('azimuth', 0)
 
759
  prompt = build_lighting_prompt(az, el)
760
  return az, el, prompt
761
  return gr.update(), gr.update(), gr.update()
762
+
763
  def sync_sliders_to_3d(azimuth, elevation):
764
  return {"azimuth": azimuth, "elevation": elevation}
765
+
766
  def update_3d_image(image):
767
  if image is None:
768
  return gr.update(imageUrl=None)
 
773
  img_str = base64.b64encode(buffered.getvalue()).decode()
774
  data_url = f"data:image/png;base64,{img_str}"
775
  return gr.update(imageUrl=data_url)
776
+
777
  for slider in [azimuth_slider, elevation_slider]:
778
  slider.change(
779
  fn=update_prompt_from_sliders,
780
  inputs=[azimuth_slider, elevation_slider],
781
  outputs=[prompt_preview]
782
  )
783
+
784
  lighting_3d.change(
785
  fn=sync_3d_to_sliders,
786
  inputs=[lighting_3d],
787
  outputs=[azimuth_slider, elevation_slider, prompt_preview]
788
  )
789
+
790
  for slider in [azimuth_slider, elevation_slider]:
791
  slider.release(
792
  fn=sync_sliders_to_3d,
793
  inputs=[azimuth_slider, elevation_slider],
794
  outputs=[lighting_3d]
795
  )
796
+
797
  run_btn.click(
798
  fn=infer_lighting_edit,
799
  inputs=[image, azimuth_slider, elevation_slider, seed, randomize_seed, guidance_scale, num_inference_steps, height, width],
800
  outputs=[result, seed, prompt_preview]
801
  )
802
+
803
  image.upload(
804
  fn=update_dimensions_on_upload,
805
  inputs=[image],
 
809
  inputs=[image],
810
  outputs=[lighting_3d]
811
  )
812
+
813
  image.clear(
814
  fn=lambda: gr.update(imageUrl=None),
815
  outputs=[lighting_3d]
816
  )
817
+
818
  if __name__ == "__main__":
819
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
820
  css = '.fillable{max-width: 1200px !important}'