prithivMLmods commited on
Commit
653a3e9
·
verified ·
1 Parent(s): ce4ea57

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +346 -274
app.py CHANGED
@@ -13,7 +13,6 @@ from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
13
  from gradio.themes import Soft
14
  from gradio.themes.utils import colors, fonts, sizes
15
 
16
- # --- Theme Configuration ---
17
  colors.orange_red = colors.Color(
18
  name="orange_red",
19
  c50="#FFF0E5",
@@ -82,11 +81,10 @@ class OrangeRedTheme(Soft):
82
 
83
  orange_red_theme = OrangeRedTheme()
84
 
85
- # --- Model & Hardware Setup ---
86
  MAX_SEED = np.iinfo(np.int32).max
 
87
  dtype = torch.bfloat16
88
  device = "cuda" if torch.cuda.is_available() else "cpu"
89
-
90
  pipe = QwenImageEditPlusPipeline.from_pretrained(
91
  "Qwen/Qwen-Image-Edit-2511",
92
  transformer=QwenImageTransformer2DModel.from_pretrained(
@@ -96,7 +94,6 @@ pipe = QwenImageEditPlusPipeline.from_pretrained(
96
  ),
97
  torch_dtype=dtype
98
  ).to(device)
99
-
100
  try:
101
  pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
102
  print("Flash Attention 3 Processor set successfully.")
@@ -112,18 +109,39 @@ ADAPTER_SPECS = {
112
  }
113
  loaded = False
114
 
115
- # --- Mappings ---
116
  AZIMUTH_MAP = {
117
- 0: "Front", 45: "Right Front", 90: "Right", 135: "Right Rear",
118
- 180: "Rear", 225: "Left Rear", 270: "Left", 315: "Left Front"
 
 
 
 
 
 
119
  }
120
 
121
- ELEVATION_MAP = { -90: "Below", 0: "", 90: "Above" }
 
 
 
 
122
 
123
  def snap_to_nearest(value, options):
 
124
  return min(options, key=lambda x: abs(x - value))
125
 
126
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
 
 
 
 
 
 
 
 
 
 
 
127
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
128
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
129
 
@@ -132,7 +150,6 @@ def build_lighting_prompt(azimuth: float, elevation: float) -> str:
132
  else:
133
  return f"Light source from {ELEVATION_MAP[elevation_snapped]}"
134
 
135
- # --- Inference Function ---
136
  @spaces.GPU
137
  def infer_lighting_edit(
138
  image: Image.Image,
@@ -145,6 +162,9 @@ def infer_lighting_edit(
145
  height: int = 1024,
146
  width: int = 1024,
147
  ):
 
 
 
148
  global loaded
149
  progress = gr.Progress(track_tqdm=True)
150
 
@@ -159,17 +179,12 @@ def infer_lighting_edit(
159
 
160
  prompt = build_lighting_prompt(azimuth, elevation)
161
  print(f"Generated Prompt: {prompt}")
162
-
163
  if randomize_seed:
164
  seed = random.randint(0, MAX_SEED)
165
-
166
  generator = torch.Generator(device=device).manual_seed(seed)
167
-
168
  if image is None:
169
  raise gr.Error("Please upload an image first.")
170
-
171
  pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
172
-
173
  result = pipe(
174
  image=[pil_image],
175
  prompt=prompt,
@@ -180,10 +195,10 @@ def infer_lighting_edit(
180
  guidance_scale=guidance_scale,
181
  num_images_per_prompt=1,
182
  ).images[0]
183
-
184
  return result, seed, prompt
185
 
186
  def update_dimensions_on_upload(image):
 
187
  if image is None:
188
  return 1024, 1024
189
  original_width, original_height = image.size
@@ -199,18 +214,19 @@ def update_dimensions_on_upload(image):
199
  new_height = (new_height // 8) * 8
200
  return new_width, new_height
201
 
202
- # --- 3D Control Component ---
203
  class LightingControl3D(gr.HTML):
204
  """
205
  A 3D lighting control component using Three.js.
 
 
206
  """
207
  def __init__(self, value=None, imageUrl=None, **kwargs):
208
  if value is None:
209
  value = {"azimuth": 0, "elevation": 0}
210
 
211
  html_template = """
212
- <div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #1a1a1a; border-radius: 12px; overflow: hidden; border: 1px solid #333;">
213
- <div id="prompt-overlay" style="position: absolute; bottom: 15px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.85); padding: 8px 16px; border-radius: 8px; font-family: 'IBM Plex Mono', monospace; font-size: 13px; color: #00ff88; white-space: nowrap; z-index: 10; border: 1px solid #333;"></div>
214
  </div>
215
  """
216
 
@@ -219,100 +235,110 @@ class LightingControl3D(gr.HTML):
219
  const wrapper = element.querySelector('#lighting-control-wrapper');
220
  const promptOverlay = element.querySelector('#prompt-overlay');
221
 
 
222
  const initScene = () => {
223
  if (typeof THREE === 'undefined') {
224
  setTimeout(initScene, 100);
225
  return;
226
  }
227
 
228
- // --- Scene Setup ---
229
  const scene = new THREE.Scene();
230
  scene.background = new THREE.Color(0x1a1a1a);
231
 
232
  const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
233
- camera.position.set(4.5, 3.5, 4.5);
234
  camera.lookAt(0, 0.75, 0);
235
 
236
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
237
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
238
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
239
  renderer.shadowMap.enabled = true;
240
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
241
  wrapper.insertBefore(renderer.domElement, promptOverlay);
242
 
243
- // --- Environment ---
244
- scene.add(new THREE.AmbientLight(0xffffff, 0.15));
245
 
246
- // Ground Plane
247
  const ground = new THREE.Mesh(
248
- new THREE.PlaneGeometry(12, 12),
249
- new THREE.ShadowMaterial({ opacity: 0.25 })
250
  );
251
  ground.rotation.x = -Math.PI / 2;
252
  ground.position.y = 0;
253
  ground.receiveShadow = true;
254
  scene.add(ground);
255
 
256
- scene.add(new THREE.GridHelper(8, 16, 0x444444, 0x222222));
 
257
 
258
  // Constants
259
  const CENTER = new THREE.Vector3(0, 0.75, 0);
260
- const BASE_DISTANCE = 2.6;
261
  const AZIMUTH_RADIUS = 2.4;
262
- const ELEVATION_RADIUS = 1.9;
263
-
264
- // --- Handle Colors (User Request) ---
265
- const COLOR_AZIMUTH = 0xffd700; // Yellow/Gold
266
- const COLOR_ELEVATION = 0x007bff; // Blue
267
 
 
268
  let azimuthAngle = props.value?.azimuth || 0;
269
  let elevationAngle = props.value?.elevation || 0;
270
 
271
- // --- Target Image Plane ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  function createPlaceholderTexture() {
273
  const canvas = document.createElement('canvas');
274
- canvas.width = 512; canvas.height = 512;
 
275
  const ctx = canvas.getContext('2d');
276
- // Gradient background
277
- const grd = ctx.createLinearGradient(0, 0, 0, 512);
278
- grd.addColorStop(0, '#2a2a3a');
279
- grd.addColorStop(1, '#1a1a2a');
280
- ctx.fillStyle = grd;
281
- ctx.fillRect(0, 0, 512, 512);
282
-
283
- // Simple silhouette
284
- ctx.fillStyle = '#ff4500';
285
  ctx.beginPath();
286
- ctx.arc(256, 256, 150, 0, Math.PI * 2);
287
  ctx.fill();
288
-
289
- ctx.fillStyle = '#ffffff';
290
- ctx.font = 'bold 40px sans-serif';
291
- ctx.textAlign = 'center';
292
- ctx.fillText("Preview", 256, 270);
293
-
 
 
 
 
294
  return new THREE.CanvasTexture(canvas);
295
  }
296
 
 
297
  let currentTexture = createPlaceholderTexture();
298
- const planeMaterial = new THREE.MeshStandardMaterial({
299
- map: currentTexture,
300
- side: THREE.DoubleSide,
301
- roughness: 0.4,
302
- metalness: 0.1
303
- });
304
-
305
- let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 1.5), planeMaterial);
306
  targetPlane.position.copy(CENTER);
307
  targetPlane.receiveShadow = true;
308
  scene.add(targetPlane);
309
 
 
310
  function updateTextureFromUrl(url) {
311
  if (!url) {
 
312
  planeMaterial.map = createPlaceholderTexture();
313
  planeMaterial.needsUpdate = true;
 
314
  scene.remove(targetPlane);
315
- targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 1.5), planeMaterial);
316
  targetPlane.position.copy(CENTER);
317
  targetPlane.receiveShadow = true;
318
  scene.add(targetPlane);
@@ -324,71 +350,82 @@ class LightingControl3D(gr.HTML):
324
  loader.load(url, (texture) => {
325
  texture.minFilter = THREE.LinearFilter;
326
  texture.magFilter = THREE.LinearFilter;
327
- texture.colorSpace = THREE.SRGBColorSpace;
328
  planeMaterial.map = texture;
329
  planeMaterial.needsUpdate = true;
330
 
 
331
  const img = texture.image;
332
  if (img && img.width && img.height) {
333
  const aspect = img.width / img.height;
334
- const maxSize = 1.8;
335
- let w, h;
336
- if (aspect > 1) { w = maxSize; h = maxSize / aspect; }
337
- else { h = maxSize; w = maxSize * aspect; }
338
-
 
 
 
 
339
  scene.remove(targetPlane);
340
- targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(w, h), planeMaterial);
 
 
 
341
  targetPlane.position.copy(CENTER);
342
  targetPlane.receiveShadow = true;
343
  scene.add(targetPlane);
344
  }
 
 
345
  });
346
  }
347
 
348
- if (props.imageUrl) updateTextureFromUrl(props.imageUrl);
 
 
 
349
 
350
- // --- NEW LIGHT MODEL (Softbox Design) ---
351
- const lightGroup = new THREE.Group();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
- // 1. Softbox Housing (Black matte box)
354
- const housingGeo = new THREE.BoxGeometry(0.5, 0.5, 0.6); // Deep box
355
- const housingMat = new THREE.MeshStandardMaterial({
356
- color: 0x222222,
357
- roughness: 0.8,
358
- metalness: 0.2
359
- });
360
- const housing = new THREE.Mesh(housingGeo, housingMat);
361
- // Shift housing back so origin is near front
362
- housing.position.z = -0.15;
363
- lightGroup.add(housing);
364
-
365
- // 2. Connector / Bracket (Cylinder at back)
366
- const bracketGeo = new THREE.CylinderGeometry(0.08, 0.08, 0.3, 16);
367
- const bracketMat = new THREE.MeshStandardMaterial({ color: 0x555555, roughness: 0.5, metalness: 0.8 });
368
- const bracket = new THREE.Mesh(bracketGeo, bracketMat);
369
- bracket.rotation.x = Math.PI / 2;
370
- bracket.position.z = -0.55;
371
- lightGroup.add(bracket);
372
-
373
- // 3. Diffuser Face (Bright White Emissive Plane)
374
- const diffuserGeo = new THREE.PlaneGeometry(0.45, 0.45);
375
- const diffuserMat = new THREE.MeshStandardMaterial({
376
- color: 0xffffff,
377
- emissive: 0xffffff,
378
- emissiveIntensity: 1.5,
379
- side: THREE.FrontSide
380
  });
381
- const diffuser = new THREE.Mesh(diffuserGeo, diffuserMat);
382
- diffuser.position.z = 0.151; // Just in front of housing front edge
383
- lightGroup.add(diffuser);
384
 
385
- // 4. Actual SpotLight Source
386
- const spotLight = new THREE.SpotLight(0xffffff, 15, 12, Math.PI / 4, 0.5, 1);
387
- spotLight.position.set(0, 0, 0.2); // Positioned at the face
388
  spotLight.castShadow = true;
389
  spotLight.shadow.mapSize.width = 1024;
390
  spotLight.shadow.mapSize.height = 1024;
391
- spotLight.shadow.bias = -0.001;
 
 
392
  lightGroup.add(spotLight);
393
 
394
  const lightTarget = new THREE.Object3D();
@@ -398,91 +435,62 @@ class LightingControl3D(gr.HTML):
398
 
399
  scene.add(lightGroup);
400
 
401
- // --- CONTROLS: Azimuth (Yellow) ---
402
  const azimuthRing = new THREE.Mesh(
403
- new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.03, 16, 100),
404
- new THREE.MeshStandardMaterial({
405
- color: COLOR_AZIMUTH,
406
- emissive: COLOR_AZIMUTH,
407
- emissiveIntensity: 0.2,
408
- transparent: true,
409
- opacity: 0.6
410
- })
411
  );
412
  azimuthRing.rotation.x = Math.PI / 2;
413
  azimuthRing.position.y = 0.05;
414
  scene.add(azimuthRing);
415
 
416
  const azimuthHandle = new THREE.Mesh(
417
- new THREE.SphereGeometry(0.15, 32, 32),
418
- new THREE.MeshStandardMaterial({
419
- color: COLOR_AZIMUTH,
420
- emissive: COLOR_AZIMUTH,
421
- emissiveIntensity: 0.6,
422
- metalness: 0.5,
423
- roughness: 0.2
424
- })
425
  );
426
  azimuthHandle.userData.type = 'azimuth';
427
  scene.add(azimuthHandle);
428
 
429
- // --- CONTROLS: Elevation (Blue) ---
430
  const arcPoints = [];
431
- for (let i = 0; i <= 40; i++) {
432
- const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 40));
433
- arcPoints.push(new THREE.Vector3(-0.9, ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, ELEVATION_RADIUS * Math.cos(angle)));
434
  }
435
  const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
436
  const elevationArc = new THREE.Mesh(
437
- new THREE.TubeGeometry(arcCurve, 40, 0.03, 8, false),
438
- new THREE.MeshStandardMaterial({
439
- color: COLOR_ELEVATION,
440
- emissive: COLOR_ELEVATION,
441
- emissiveIntensity: 0.2,
442
- transparent: true,
443
- opacity: 0.6
444
- })
445
  );
446
  scene.add(elevationArc);
447
 
448
  const elevationHandle = new THREE.Mesh(
449
- new THREE.SphereGeometry(0.15, 32, 32),
450
- new THREE.MeshStandardMaterial({
451
- color: COLOR_ELEVATION,
452
- emissive: COLOR_ELEVATION,
453
- emissiveIntensity: 0.6,
454
- metalness: 0.5,
455
- roughness: 0.2
456
- })
457
  );
458
  elevationHandle.userData.type = 'elevation';
459
  scene.add(elevationHandle);
460
 
461
- // --- NEW REFRESH BUTTON ---
462
  const refreshBtn = document.createElement('button');
463
- refreshBtn.innerHTML = 'Reset View';
464
- // Styling
465
- Object.assign(refreshBtn.style, {
466
- position: 'absolute',
467
- top: '15px',
468
- right: '15px',
469
- background: 'linear-gradient(90deg, #ff5722, #d84315)',
470
- color: 'white',
471
- border: 'none',
472
- padding: '8px 16px',
473
- borderRadius: '6px',
474
- cursor: 'pointer',
475
- zIndex: '20',
476
- fontFamily: 'sans-serif',
477
- fontSize: '13px',
478
- fontWeight: '600',
479
- boxShadow: '0 4px 6px rgba(0,0,0,0.3)',
480
- transition: 'transform 0.1s ease, box-shadow 0.1s ease'
481
- });
482
-
483
- refreshBtn.onmouseover = () => { refreshBtn.style.transform = 'translateY(-1px)'; refreshBtn.style.boxShadow = '0 6px 8px rgba(0,0,0,0.4)'; };
484
- refreshBtn.onmouseout = () => { refreshBtn.style.transform = 'translateY(0)'; refreshBtn.style.boxShadow = '0 4px 6px rgba(0,0,0,0.3)'; };
485
- refreshBtn.onmousedown = () => { refreshBtn.style.transform = 'translateY(1px)'; refreshBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)'; };
486
 
487
  wrapper.appendChild(refreshBtn);
488
 
@@ -493,20 +501,11 @@ class LightingControl3D(gr.HTML):
493
  updatePropsAndTrigger();
494
  });
495
 
496
- // --- Logic & Events ---
497
- const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
498
- const elevationSteps = [-90, 0, 90];
499
-
500
- function snapToNearest(value, steps) {
501
- return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
502
- }
503
-
504
  function updatePositions() {
505
  const distance = BASE_DISTANCE;
506
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
507
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
508
 
509
- // Position light group
510
  const lightX = distance * Math.sin(azRad) * Math.cos(elRad);
511
  const lightY = distance * Math.sin(elRad) + CENTER.y;
512
  const lightZ = distance * Math.cos(azRad) * Math.cos(elRad);
@@ -514,181 +513,243 @@ class LightingControl3D(gr.HTML):
514
  lightGroup.position.set(lightX, lightY, lightZ);
515
  lightGroup.lookAt(CENTER);
516
 
517
- // Position handles
518
- azimuthHandle.position.set(
519
- AZIMUTH_RADIUS * Math.sin(azRad),
520
- 0.05,
521
- AZIMUTH_RADIUS * Math.cos(azRad)
522
- );
523
-
524
- elevationHandle.position.set(
525
- -0.9,
526
- ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y,
527
- ELEVATION_RADIUS * Math.cos(elRad)
528
- );
529
 
530
- // Update Text Prompt
531
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
532
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
533
- const azName = {0:'Front',45:'Right Front',90:'Right',135:'Right Rear',180:'Rear',225:'Left Rear',270:'Left',315:'Left Front'};
534
- const elName = {'-90':'Below','0':'','90':'Above'};
535
-
536
- let txt = 'Light source from';
537
- if (elSnap !== 0) txt += ' ' + elName[String(elSnap)];
538
- else txt += ' the ' + azName[azSnap];
539
- promptOverlay.textContent = txt;
540
  }
541
 
542
  function updatePropsAndTrigger() {
543
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
544
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
 
545
  props.value = { azimuth: azSnap, elevation: elSnap };
546
  trigger('change', props.value);
547
  }
548
 
549
- // Raycasting / Dragging
550
  const raycaster = new THREE.Raycaster();
551
  const mouse = new THREE.Vector2();
552
  let isDragging = false;
553
  let dragTarget = null;
 
554
  const intersection = new THREE.Vector3();
 
555
  const canvas = renderer.domElement;
556
 
557
- const updateMouse = (e) => {
558
  const rect = canvas.getBoundingClientRect();
559
- const clientX = e.touches ? e.touches[0].clientX : e.clientX;
560
- const clientY = e.touches ? e.touches[0].clientY : e.clientY;
561
- mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
562
- mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
563
- };
564
-
565
- const onStart = (e) => {
566
- e.preventDefault();
567
- updateMouse(e);
568
  raycaster.setFromCamera(mouse, camera);
569
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
 
570
  if (intersects.length > 0) {
571
  isDragging = true;
572
  dragTarget = intersects[0].object;
573
- dragTarget.scale.setScalar(1.3);
574
  dragTarget.material.emissiveIntensity = 1.0;
 
 
575
  canvas.style.cursor = 'grabbing';
576
  }
577
- };
578
-
579
- const onMove = (e) => {
580
- updateMouse(e);
 
 
581
 
582
  if (isDragging && dragTarget) {
583
  raycaster.setFromCamera(mouse, camera);
 
584
  if (dragTarget.userData.type === 'azimuth') {
585
  const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
586
  if (raycaster.ray.intersectPlane(plane, intersection)) {
587
  azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
588
  if (azimuthAngle < 0) azimuthAngle += 360;
589
  }
590
- } else {
591
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.9);
592
  if (raycaster.ray.intersectPlane(plane, intersection)) {
593
  const relY = intersection.y - CENTER.y;
594
- elevationAngle = THREE.MathUtils.clamp(
595
- THREE.MathUtils.radToDeg(Math.atan2(relY, intersection.z)),
596
- -90, 90
597
- );
598
  }
599
  }
600
  updatePositions();
601
  } else {
602
- // Hover effect
603
  raycaster.setFromCamera(mouse, camera);
604
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
605
- canvas.style.cursor = intersects.length > 0 ? 'grab' : 'default';
606
  [azimuthHandle, elevationHandle].forEach(h => {
607
- if (h !== dragTarget) {
608
- h.scale.setScalar(1);
609
- h.material.emissiveIntensity = 0.6;
610
- }
611
  });
612
  if (intersects.length > 0) {
613
- intersects[0].object.scale.setScalar(1.2);
614
- intersects[0].object.material.emissiveIntensity = 1.0;
 
 
 
615
  }
616
  }
617
- };
618
 
619
- const onEnd = () => {
620
  if (dragTarget) {
 
621
  dragTarget.scale.setScalar(1);
622
- // Snap animation
 
623
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
624
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
625
- const startAz = azimuthAngle;
626
- const startEl = elevationAngle;
627
  const startTime = Date.now();
628
 
629
- function anim() {
630
- const t = Math.min((Date.now() - startTime) / 250, 1);
631
  const ease = 1 - Math.pow(1 - t, 3);
632
 
633
- // Azimuth wrapping logic
634
  let azDiff = targetAz - startAz;
635
  if (azDiff > 180) azDiff -= 360;
636
  if (azDiff < -180) azDiff += 360;
637
-
638
  azimuthAngle = startAz + azDiff * ease;
639
  if (azimuthAngle < 0) azimuthAngle += 360;
640
  if (azimuthAngle >= 360) azimuthAngle -= 360;
641
 
642
  elevationAngle = startEl + (targetEl - startEl) * ease;
643
- updatePositions();
644
 
645
- if (t < 1) requestAnimationFrame(anim);
 
646
  else updatePropsAndTrigger();
647
  }
648
- anim();
649
  }
650
  isDragging = false;
651
  dragTarget = null;
652
  canvas.style.cursor = 'default';
653
  };
654
 
655
- canvas.addEventListener('mousedown', onStart);
656
- window.addEventListener('mousemove', onMove);
657
- window.addEventListener('mouseup', onEnd);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
 
659
- canvas.addEventListener('touchstart', onStart, {passive: false});
660
- window.addEventListener('touchmove', onMove, {passive: false});
661
- window.addEventListener('touchend', onEnd);
 
662
 
663
- // Initialization
664
  updatePositions();
665
 
666
- const animate = () => {
667
- requestAnimationFrame(animate);
 
668
  renderer.render(scene, camera);
669
- };
670
- animate();
671
 
672
- // Props Watcher
673
- setInterval(() => {
674
- if (props.imageUrl !== currentTexture.sourceFile) {
675
- // simple check, better logic exists but keeping simple
676
- }
677
- if (JSON.stringify(props.value) !== JSON.stringify({azimuth:snapToNearest(azimuthAngle, azimuthSteps), elevation:snapToNearest(elevationAngle, elevationSteps)})) {
678
- // External update logic here if needed
679
- }
680
- }, 200);
681
 
682
- // External Interface
683
  wrapper._updateFromProps = (newVal) => {
684
- if (newVal) {
685
  azimuthAngle = newVal.azimuth ?? azimuthAngle;
686
  elevationAngle = newVal.elevation ?? elevationAngle;
687
  updatePositions();
688
  }
689
  };
 
690
  wrapper._updateTexture = updateTextureFromUrl;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691
  };
 
692
  initScene();
693
  })();
694
  """
@@ -701,7 +762,6 @@ class LightingControl3D(gr.HTML):
701
  **kwargs
702
  )
703
 
704
- # --- Gradio UI Layout ---
705
  css = '''
706
  #col-container { max-width: 1200px; margin: 0 auto; }
707
  .dark .progress-text { color: white !important; }
@@ -709,17 +769,16 @@ css = '''
709
  .slider-row { display: flex; gap: 10px; align-items: center; }
710
  #main-title h1 {font-size: 2.4em !important;}
711
  '''
712
-
713
  with gr.Blocks(css=css) as demo:
714
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
715
- 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.")
716
 
717
  with gr.Row():
718
  with gr.Column(scale=1):
719
  image = gr.Image(label="Input Image", type="pil", height=300)
720
 
721
  gr.Markdown("### 3D Lighting Control")
722
- gr.Markdown("*Drag the colored handles: 🟡 **Yellow** Azimuth (Direction), 🔵 **Blue** Elevation (Height)*")
723
 
724
  lighting_3d = LightingControl3D(
725
  value={"azimuth": 0, "elevation": 0},
@@ -731,22 +790,29 @@ with gr.Blocks(css=css) as demo:
731
 
732
  azimuth_slider = gr.Slider(
733
  label="Azimuth (Horizontal Rotation)",
734
- minimum=0, maximum=315, step=45, value=0,
 
 
 
735
  info="0°=front, 90°=right, 180°=rear, 270°=left"
736
  )
737
 
738
  elevation_slider = gr.Slider(
739
  label="Elevation (Vertical Angle)",
740
- minimum=-90, maximum=90, step=90, value=0,
 
 
 
741
  info="-90°=from below, 0°=horizontal, 90°=from above"
742
  )
743
 
744
- prompt_preview = gr.Textbox(
745
- label="Generated Prompt",
746
- value="Light source from the Front",
747
- interactive=True,
748
- lines=1,
749
- )
 
750
 
751
  with gr.Column(scale=1):
752
  result = gr.Image(label="Output Image", height=500)
@@ -759,11 +825,13 @@ with gr.Blocks(css=css) as demo:
759
  height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
760
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
761
 
762
- # --- Event Wiring ---
763
  def update_prompt_from_sliders(azimuth, elevation):
764
- return build_lighting_prompt(azimuth, elevation)
 
 
765
 
766
  def sync_3d_to_sliders(lighting_value):
 
767
  if lighting_value and isinstance(lighting_value, dict):
768
  az = lighting_value.get('azimuth', 0)
769
  el = lighting_value.get('elevation', 0)
@@ -772,11 +840,14 @@ with gr.Blocks(css=css) as demo:
772
  return gr.update(), gr.update(), gr.update()
773
 
774
  def sync_sliders_to_3d(azimuth, elevation):
 
775
  return {"azimuth": azimuth, "elevation": elevation}
776
 
777
  def update_3d_image(image):
 
778
  if image is None:
779
  return gr.update(imageUrl=None)
 
780
  import base64
781
  from io import BytesIO
782
  buffered = BytesIO()
@@ -825,7 +896,8 @@ with gr.Blocks(css=css) as demo:
825
  fn=lambda: gr.update(imageUrl=None),
826
  outputs=[lighting_3d]
827
  )
828
-
829
  if __name__ == "__main__":
830
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
831
- demo.launch(head=head, theme=orange_red_theme, mcp_server=True, ssr_mode=False, show_error=True)
 
 
13
  from gradio.themes import Soft
14
  from gradio.themes.utils import colors, fonts, sizes
15
 
 
16
  colors.orange_red = colors.Color(
17
  name="orange_red",
18
  c50="#FFF0E5",
 
81
 
82
  orange_red_theme = OrangeRedTheme()
83
 
 
84
  MAX_SEED = np.iinfo(np.int32).max
85
+
86
  dtype = torch.bfloat16
87
  device = "cuda" if torch.cuda.is_available() else "cpu"
 
88
  pipe = QwenImageEditPlusPipeline.from_pretrained(
89
  "Qwen/Qwen-Image-Edit-2511",
90
  transformer=QwenImageTransformer2DModel.from_pretrained(
 
94
  ),
95
  torch_dtype=dtype
96
  ).to(device)
 
97
  try:
98
  pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
99
  print("Flash Attention 3 Processor set successfully.")
 
109
  }
110
  loaded = False
111
 
 
112
  AZIMUTH_MAP = {
113
+ 0: "Front",
114
+ 45: "Right Front",
115
+ 90: "Right",
116
+ 135: "Right Rear",
117
+ 180: "Rear",
118
+ 225: "Left Rear",
119
+ 270: "Left",
120
+ 315: "Left Front"
121
  }
122
 
123
+ ELEVATION_MAP = {
124
+ -90: "Below",
125
+ 0: "",
126
+ 90: "Above"
127
+ }
128
 
129
  def snap_to_nearest(value, options):
130
+ """Snap a value to the nearest option in a list."""
131
  return min(options, key=lambda x: abs(x - value))
132
 
133
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
134
+ """
135
+ Build a lighting prompt from azimuth and elevation values.
136
+
137
+ Args:
138
+ azimuth: Horizontal rotation in degrees (0-360)
139
+ elevation: Vertical angle in degrees (-90 to 90)
140
+
141
+ Returns:
142
+ Formatted prompt string for the LoRA
143
+ """
144
+ # Snap to nearest valid values
145
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
146
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
147
 
 
150
  else:
151
  return f"Light source from {ELEVATION_MAP[elevation_snapped]}"
152
 
 
153
  @spaces.GPU
154
  def infer_lighting_edit(
155
  image: Image.Image,
 
162
  height: int = 1024,
163
  width: int = 1024,
164
  ):
165
+ """
166
+ Edit the lighting of an image using Qwen Image Edit 2511 with multi-angle lighting LoRA.
167
+ """
168
  global loaded
169
  progress = gr.Progress(track_tqdm=True)
170
 
 
179
 
180
  prompt = build_lighting_prompt(azimuth, elevation)
181
  print(f"Generated Prompt: {prompt}")
 
182
  if randomize_seed:
183
  seed = random.randint(0, MAX_SEED)
 
184
  generator = torch.Generator(device=device).manual_seed(seed)
 
185
  if image is None:
186
  raise gr.Error("Please upload an image first.")
 
187
  pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
 
188
  result = pipe(
189
  image=[pil_image],
190
  prompt=prompt,
 
195
  guidance_scale=guidance_scale,
196
  num_images_per_prompt=1,
197
  ).images[0]
 
198
  return result, seed, prompt
199
 
200
  def update_dimensions_on_upload(image):
201
+ """Compute recommended dimensions preserving aspect ratio."""
202
  if image is None:
203
  return 1024, 1024
204
  original_width, original_height = image.size
 
214
  new_height = (new_height // 8) * 8
215
  return new_width, new_height
216
 
 
217
  class LightingControl3D(gr.HTML):
218
  """
219
  A 3D lighting control component using Three.js.
220
+ Outputs: { azimuth: number, elevation: number }
221
+ Accepts imageUrl prop to display user's uploaded image on the plane.
222
  """
223
  def __init__(self, value=None, imageUrl=None, **kwargs):
224
  if value is None:
225
  value = {"azimuth": 0, "elevation": 0}
226
 
227
  html_template = """
228
+ <div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #1a1a1a; border-radius: 12px; overflow: hidden;">
229
+ <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>
230
  </div>
231
  """
232
 
 
235
  const wrapper = element.querySelector('#lighting-control-wrapper');
236
  const promptOverlay = element.querySelector('#prompt-overlay');
237
 
238
+ // Wait for THREE to load
239
  const initScene = () => {
240
  if (typeof THREE === 'undefined') {
241
  setTimeout(initScene, 100);
242
  return;
243
  }
244
 
245
+ // Scene setup
246
  const scene = new THREE.Scene();
247
  scene.background = new THREE.Color(0x1a1a1a);
248
 
249
  const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
250
+ camera.position.set(4.5, 3, 4.5);
251
  camera.lookAt(0, 0.75, 0);
252
 
253
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
254
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
255
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
256
  renderer.shadowMap.enabled = true;
257
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
258
  wrapper.insertBefore(renderer.domElement, promptOverlay);
259
 
260
+ // Lighting
261
+ scene.add(new THREE.AmbientLight(0xffffff, 0.1));
262
 
263
+ // Ground plane for shadows
264
  const ground = new THREE.Mesh(
265
+ new THREE.PlaneGeometry(10, 10),
266
+ new THREE.ShadowMaterial({ opacity: 0.3 })
267
  );
268
  ground.rotation.x = -Math.PI / 2;
269
  ground.position.y = 0;
270
  ground.receiveShadow = true;
271
  scene.add(ground);
272
 
273
+ // Grid
274
+ scene.add(new THREE.GridHelper(8, 16, 0x333333, 0x222222));
275
 
276
  // Constants
277
  const CENTER = new THREE.Vector3(0, 0.75, 0);
278
+ const BASE_DISTANCE = 2.5;
279
  const AZIMUTH_RADIUS = 2.4;
280
+ const ELEVATION_RADIUS = 1.8;
 
 
 
 
281
 
282
+ // State
283
  let azimuthAngle = props.value?.azimuth || 0;
284
  let elevationAngle = props.value?.elevation || 0;
285
 
286
+ // Mappings
287
+ const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
288
+ const elevationSteps = [-90, 0, 90];
289
+ const azimuthNames = {
290
+ 0: 'Front', 45: 'Right Front', 90: 'Right',
291
+ 135: 'Right Rear', 180: 'Rear', 225: 'Left Rear',
292
+ 270: 'Left', 315: 'Left Front'
293
+ };
294
+ const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' };
295
+
296
+ function snapToNearest(value, steps) {
297
+ return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
298
+ }
299
+
300
+ // Create placeholder texture (smiley face)
301
  function createPlaceholderTexture() {
302
  const canvas = document.createElement('canvas');
303
+ canvas.width = 256;
304
+ canvas.height = 256;
305
  const ctx = canvas.getContext('2d');
306
+ ctx.fillStyle = '#3a3a4a';
307
+ ctx.fillRect(0, 0, 256, 256);
308
+ ctx.fillStyle = '#ffcc99';
 
 
 
 
 
 
309
  ctx.beginPath();
310
+ ctx.arc(128, 128, 80, 0, Math.PI * 2);
311
  ctx.fill();
312
+ ctx.fillStyle = '#333';
313
+ ctx.beginPath();
314
+ ctx.arc(100, 110, 10, 0, Math.PI * 2);
315
+ ctx.arc(156, 110, 10, 0, Math.PI * 2);
316
+ ctx.fill();
317
+ ctx.strokeStyle = '#333';
318
+ ctx.lineWidth = 3;
319
+ ctx.beginPath();
320
+ ctx.arc(128, 130, 35, 0.2, Math.PI - 0.2);
321
+ ctx.stroke();
322
  return new THREE.CanvasTexture(canvas);
323
  }
324
 
325
+ // Target image plane
326
  let currentTexture = createPlaceholderTexture();
327
+ const planeMaterial = new THREE.MeshStandardMaterial({ map: currentTexture, side: THREE.DoubleSide, roughness: 0.5, metalness: 0 });
328
+ let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
 
 
 
 
 
 
329
  targetPlane.position.copy(CENTER);
330
  targetPlane.receiveShadow = true;
331
  scene.add(targetPlane);
332
 
333
+ // Function to update texture from image URL
334
  function updateTextureFromUrl(url) {
335
  if (!url) {
336
+ // Reset to placeholder
337
  planeMaterial.map = createPlaceholderTexture();
338
  planeMaterial.needsUpdate = true;
339
+ // Reset plane to square
340
  scene.remove(targetPlane);
341
+ targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
342
  targetPlane.position.copy(CENTER);
343
  targetPlane.receiveShadow = true;
344
  scene.add(targetPlane);
 
350
  loader.load(url, (texture) => {
351
  texture.minFilter = THREE.LinearFilter;
352
  texture.magFilter = THREE.LinearFilter;
 
353
  planeMaterial.map = texture;
354
  planeMaterial.needsUpdate = true;
355
 
356
+ // Adjust plane aspect ratio to match image
357
  const img = texture.image;
358
  if (img && img.width && img.height) {
359
  const aspect = img.width / img.height;
360
+ const maxSize = 1.5;
361
+ let planeWidth, planeHeight;
362
+ if (aspect > 1) {
363
+ planeWidth = maxSize;
364
+ planeHeight = maxSize / aspect;
365
+ } else {
366
+ planeHeight = maxSize;
367
+ planeWidth = maxSize * aspect;
368
+ }
369
  scene.remove(targetPlane);
370
+ targetPlane = new THREE.Mesh(
371
+ new THREE.PlaneGeometry(planeWidth, planeHeight),
372
+ planeMaterial
373
+ );
374
  targetPlane.position.copy(CENTER);
375
  targetPlane.receiveShadow = true;
376
  scene.add(targetPlane);
377
  }
378
+ }, undefined, (err) => {
379
+ console.error('Failed to load texture:', err);
380
  });
381
  }
382
 
383
+ // Check for initial imageUrl
384
+ if (props.imageUrl) {
385
+ updateTextureFromUrl(props.imageUrl);
386
+ }
387
 
388
+ // Create LED texture
389
+ function createLEDTexture() {
390
+ const canvas = document.createElement('canvas');
391
+ canvas.width = 64;
392
+ canvas.height = 64;
393
+ const ctx = canvas.getContext('2d');
394
+ ctx.fillStyle = '#000000';
395
+ ctx.fillRect(0, 0, 64, 64);
396
+ for (let i = 0; i < 8; i++) {
397
+ for (let j = 0; j < 8; j++) {
398
+ ctx.fillStyle = '#ffffff';
399
+ ctx.beginPath();
400
+ ctx.arc(4 + 8 * i, 4 + 8 * j, 3, 0, Math.PI * 2);
401
+ ctx.fill();
402
+ }
403
+ }
404
+ return new THREE.CanvasTexture(canvas);
405
+ }
406
 
407
+ // Studio LED light model
408
+ const lightGroup = new THREE.Group();
409
+ const ledTexture = createLEDTexture();
410
+ const ledMat = new THREE.MeshStandardMaterial({
411
+ color: 0x111111,
412
+ emissive: 0xffffff,
413
+ emissiveIntensity: 2,
414
+ emissiveMap: ledTexture,
415
+ roughness: 0.5,
416
+ metalness: 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  });
418
+ const ledPanel = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.6, 0.1), ledMat);
419
+ lightGroup.add(ledPanel);
 
420
 
421
+ const spotLight = new THREE.SpotLight(0xffffff, 10, 10, Math.PI / 3, 1, 1);
422
+ spotLight.position.set(0, 0, -0.05);
 
423
  spotLight.castShadow = true;
424
  spotLight.shadow.mapSize.width = 1024;
425
  spotLight.shadow.mapSize.height = 1024;
426
+ spotLight.shadow.camera.near = 0.5;
427
+ spotLight.shadow.camera.far = 500;
428
+ spotLight.shadow.bias = -0.005;
429
  lightGroup.add(spotLight);
430
 
431
  const lightTarget = new THREE.Object3D();
 
435
 
436
  scene.add(lightGroup);
437
 
438
+ // YELLOW: Azimuth ring
439
  const azimuthRing = new THREE.Mesh(
440
+ new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 64),
441
+ new THREE.MeshStandardMaterial({ color: 0xffcc00, emissive: 0xffcc00, emissiveIntensity: 0.3 })
 
 
 
 
 
 
442
  );
443
  azimuthRing.rotation.x = Math.PI / 2;
444
  azimuthRing.position.y = 0.05;
445
  scene.add(azimuthRing);
446
 
447
  const azimuthHandle = new THREE.Mesh(
448
+ new THREE.SphereGeometry(0.18, 16, 16),
449
+ new THREE.MeshStandardMaterial({ color: 0xffcc00, emissive: 0xffcc00, emissiveIntensity: 0.5 })
 
 
 
 
 
 
450
  );
451
  azimuthHandle.userData.type = 'azimuth';
452
  scene.add(azimuthHandle);
453
 
454
+ // BLUE: Elevation arc
455
  const arcPoints = [];
456
+ for (let i = 0; i <= 32; i++) {
457
+ const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 32));
458
+ arcPoints.push(new THREE.Vector3(-0.8, ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, ELEVATION_RADIUS * Math.cos(angle)));
459
  }
460
  const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
461
  const elevationArc = new THREE.Mesh(
462
+ new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false),
463
+ new THREE.MeshStandardMaterial({ color: 0x007bff, emissive: 0x007bff, emissiveIntensity: 0.3 })
 
 
 
 
 
 
464
  );
465
  scene.add(elevationArc);
466
 
467
  const elevationHandle = new THREE.Mesh(
468
+ new THREE.SphereGeometry(0.18, 16, 16),
469
+ new THREE.MeshStandardMaterial({ color: 0x007bff, emissive: 0x007bff, emissiveIntensity: 0.5 })
 
 
 
 
 
 
470
  );
471
  elevationHandle.userData.type = 'elevation';
472
  scene.add(elevationHandle);
473
 
474
+ // REFRESH BUTTON
475
  const refreshBtn = document.createElement('button');
476
+ refreshBtn.textContent = 'Reset View';
477
+ refreshBtn.style.position = 'absolute';
478
+ refreshBtn.style.top = '15px';
479
+ refreshBtn.style.right = '15px';
480
+ refreshBtn.style.backgroundColor = '#333';
481
+ refreshBtn.style.color = '#fff';
482
+ refreshBtn.style.border = '1px solid #555';
483
+ refreshBtn.style.borderRadius = '5px';
484
+ refreshBtn.style.padding = '5px 10px';
485
+ refreshBtn.style.cursor = 'pointer';
486
+ refreshBtn.style.zIndex = '20';
487
+ refreshBtn.style.fontSize = '12px';
488
+ refreshBtn.style.fontWeight = '500';
489
+ refreshBtn.style.transition = 'background-color 0.2s';
490
+
491
+ // Hover effects
492
+ refreshBtn.onmouseover = function() { this.style.backgroundColor = '#444'; };
493
+ refreshBtn.onmouseout = function() { this.style.backgroundColor = '#333'; };
 
 
 
 
 
494
 
495
  wrapper.appendChild(refreshBtn);
496
 
 
501
  updatePropsAndTrigger();
502
  });
503
 
 
 
 
 
 
 
 
 
504
  function updatePositions() {
505
  const distance = BASE_DISTANCE;
506
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
507
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
508
 
 
509
  const lightX = distance * Math.sin(azRad) * Math.cos(elRad);
510
  const lightY = distance * Math.sin(elRad) + CENTER.y;
511
  const lightZ = distance * Math.cos(azRad) * Math.cos(elRad);
 
513
  lightGroup.position.set(lightX, lightY, lightZ);
514
  lightGroup.lookAt(CENTER);
515
 
516
+ azimuthHandle.position.set(AZIMUTH_RADIUS * Math.sin(azRad), 0.05, AZIMUTH_RADIUS * Math.cos(azRad));
517
+ elevationHandle.position.set(-0.8, ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y, ELEVATION_RADIUS * Math.cos(elRad));
 
 
 
 
 
 
 
 
 
 
518
 
519
+ // Update prompt
520
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
521
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
522
+ let prompt = 'Light source from';
523
+ if (elSnap !== 0) {
524
+ prompt += ' ' + elevationNames[String(elSnap)];
525
+ } else {
526
+ prompt += ' the ' + azimuthNames[azSnap];
527
+ }
528
+ promptOverlay.textContent = prompt;
529
  }
530
 
531
  function updatePropsAndTrigger() {
532
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
533
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
534
+
535
  props.value = { azimuth: azSnap, elevation: elSnap };
536
  trigger('change', props.value);
537
  }
538
 
539
+ // Raycasting
540
  const raycaster = new THREE.Raycaster();
541
  const mouse = new THREE.Vector2();
542
  let isDragging = false;
543
  let dragTarget = null;
544
+ let dragStartMouse = new THREE.Vector2();
545
  const intersection = new THREE.Vector3();
546
+
547
  const canvas = renderer.domElement;
548
 
549
+ canvas.addEventListener('mousedown', (e) => {
550
  const rect = canvas.getBoundingClientRect();
551
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
552
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
553
+
 
 
 
 
 
 
554
  raycaster.setFromCamera(mouse, camera);
555
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
556
+
557
  if (intersects.length > 0) {
558
  isDragging = true;
559
  dragTarget = intersects[0].object;
 
560
  dragTarget.material.emissiveIntensity = 1.0;
561
+ dragTarget.scale.setScalar(1.3);
562
+ dragStartMouse.copy(mouse);
563
  canvas.style.cursor = 'grabbing';
564
  }
565
+ });
566
+
567
+ canvas.addEventListener('mousemove', (e) => {
568
+ const rect = canvas.getBoundingClientRect();
569
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
570
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
571
 
572
  if (isDragging && dragTarget) {
573
  raycaster.setFromCamera(mouse, camera);
574
+
575
  if (dragTarget.userData.type === 'azimuth') {
576
  const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
577
  if (raycaster.ray.intersectPlane(plane, intersection)) {
578
  azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
579
  if (azimuthAngle < 0) azimuthAngle += 360;
580
  }
581
+ } else if (dragTarget.userData.type === 'elevation') {
582
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
583
  if (raycaster.ray.intersectPlane(plane, intersection)) {
584
  const relY = intersection.y - CENTER.y;
585
+ const relZ = intersection.z;
586
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
 
 
587
  }
588
  }
589
  updatePositions();
590
  } else {
 
591
  raycaster.setFromCamera(mouse, camera);
592
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
 
593
  [azimuthHandle, elevationHandle].forEach(h => {
594
+ h.material.emissiveIntensity = 0.5;
595
+ h.scale.setScalar(1);
 
 
596
  });
597
  if (intersects.length > 0) {
598
+ intersects[0].object.material.emissiveIntensity = 0.8;
599
+ intersects[0].object.scale.setScalar(1.1);
600
+ canvas.style.cursor = 'grab';
601
+ } else {
602
+ canvas.style.cursor = 'default';
603
  }
604
  }
605
+ });
606
 
607
+ const onMouseUp = () => {
608
  if (dragTarget) {
609
+ dragTarget.material.emissiveIntensity = 0.5;
610
  dragTarget.scale.setScalar(1);
611
+
612
+ // Snap and animate
613
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
614
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
615
+
616
+ const startAz = azimuthAngle, startEl = elevationAngle;
617
  const startTime = Date.now();
618
 
619
+ function animateSnap() {
620
+ const t = Math.min((Date.now() - startTime) / 200, 1);
621
  const ease = 1 - Math.pow(1 - t, 3);
622
 
 
623
  let azDiff = targetAz - startAz;
624
  if (azDiff > 180) azDiff -= 360;
625
  if (azDiff < -180) azDiff += 360;
 
626
  azimuthAngle = startAz + azDiff * ease;
627
  if (azimuthAngle < 0) azimuthAngle += 360;
628
  if (azimuthAngle >= 360) azimuthAngle -= 360;
629
 
630
  elevationAngle = startEl + (targetEl - startEl) * ease;
 
631
 
632
+ updatePositions();
633
+ if (t < 1) requestAnimationFrame(animateSnap);
634
  else updatePropsAndTrigger();
635
  }
636
+ animateSnap();
637
  }
638
  isDragging = false;
639
  dragTarget = null;
640
  canvas.style.cursor = 'default';
641
  };
642
 
643
+ canvas.addEventListener('mouseup', onMouseUp);
644
+ canvas.addEventListener('mouseleave', onMouseUp);
645
+ # Touch support for mobile
646
+ canvas.addEventListener('touchstart', (e) => {
647
+ e.preventDefault();
648
+ const touch = e.touches[0];
649
+ const rect = canvas.getBoundingClientRect();
650
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
651
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
652
+
653
+ raycaster.setFromCamera(mouse, camera);
654
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
655
+
656
+ if (intersects.length > 0) {
657
+ isDragging = true;
658
+ dragTarget = intersects[0].object;
659
+ dragTarget.material.emissiveIntensity = 1.0;
660
+ dragTarget.scale.setScalar(1.3);
661
+ dragStartMouse.copy(mouse);
662
+ }
663
+ }, { passive: false });
664
+
665
+ canvas.addEventListener('touchmove', (e) => {
666
+ e.preventDefault();
667
+ const touch = e.touches[0];
668
+ const rect = canvas.getBoundingClientRect();
669
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
670
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
671
+
672
+ if (isDragging && dragTarget) {
673
+ raycaster.setFromCamera(mouse, camera);
674
+
675
+ if (dragTarget.userData.type === 'azimuth') {
676
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
677
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
678
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
679
+ if (azimuthAngle < 0) azimuthAngle += 360;
680
+ }
681
+ } else if (dragTarget.userData.type === 'elevation') {
682
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
683
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
684
+ const relY = intersection.y - CENTER.y;
685
+ const relZ = intersection.z;
686
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
687
+ }
688
+ }
689
+ updatePositions();
690
+ }
691
+ }, { passive: false });
692
+
693
+ canvas.addEventListener('touchend', (e) => {
694
+ e.preventDefault();
695
+ onMouseUp();
696
+ }, { passive: false });
697
 
698
+ canvas.addEventListener('touchcancel', (e) => {
699
+ e.preventDefault();
700
+ onMouseUp();
701
+ }, { passive: false });
702
 
703
+ // Initial update
704
  updatePositions();
705
 
706
+ // Render loop
707
+ function render() {
708
+ requestAnimationFrame(render);
709
  renderer.render(scene, camera);
710
+ }
711
+ render();
712
 
713
+ // Handle resize
714
+ new ResizeObserver(() => {
715
+ camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
716
+ camera.updateProjectionMatrix();
717
+ renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
718
+ }).observe(wrapper);
 
 
 
719
 
720
+ // Store update functions for external calls
721
  wrapper._updateFromProps = (newVal) => {
722
+ if (newVal && typeof newVal === 'object') {
723
  azimuthAngle = newVal.azimuth ?? azimuthAngle;
724
  elevationAngle = newVal.elevation ?? elevationAngle;
725
  updatePositions();
726
  }
727
  };
728
+
729
  wrapper._updateTexture = updateTextureFromUrl;
730
+
731
+ // Watch for prop changes (imageUrl and value)
732
+ let lastImageUrl = props.imageUrl;
733
+ let lastValue = JSON.stringify(props.value);
734
+ setInterval(() => {
735
+ // Check imageUrl changes
736
+ if (props.imageUrl !== lastImageUrl) {
737
+ lastImageUrl = props.imageUrl;
738
+ updateTextureFromUrl(props.imageUrl);
739
+ }
740
+ // Check value changes (from sliders)
741
+ const currentValue = JSON.stringify(props.value);
742
+ if (currentValue !== lastValue) {
743
+ lastValue = currentValue;
744
+ if (props.value && typeof props.value === 'object') {
745
+ azimuthAngle = props.value.azimuth ?? azimuthAngle;
746
+ elevationAngle = props.value.elevation ?? elevationAngle;
747
+ updatePositions();
748
+ }
749
+ }
750
+ }, 100);
751
  };
752
+
753
  initScene();
754
  })();
755
  """
 
762
  **kwargs
763
  )
764
 
 
765
  css = '''
766
  #col-container { max-width: 1200px; margin: 0 auto; }
767
  .dark .progress-text { color: white !important; }
 
769
  .slider-row { display: flex; gap: 10px; align-items: center; }
770
  #main-title h1 {font-size: 2.4em !important;}
771
  '''
 
772
  with gr.Blocks(css=css) as demo:
773
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
774
+ 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).")
775
 
776
  with gr.Row():
777
  with gr.Column(scale=1):
778
  image = gr.Image(label="Input Image", type="pil", height=300)
779
 
780
  gr.Markdown("### 3D Lighting Control")
781
+ gr.Markdown("*Drag the colored handles: 🟡 Azimuth (Direction), 🔵 Elevation (Height)*")
782
 
783
  lighting_3d = LightingControl3D(
784
  value={"azimuth": 0, "elevation": 0},
 
790
 
791
  azimuth_slider = gr.Slider(
792
  label="Azimuth (Horizontal Rotation)",
793
+ minimum=0,
794
+ maximum=315,
795
+ step=45,
796
+ value=0,
797
  info="0°=front, 90°=right, 180°=rear, 270°=left"
798
  )
799
 
800
  elevation_slider = gr.Slider(
801
  label="Elevation (Vertical Angle)",
802
+ minimum=-90,
803
+ maximum=90,
804
+ step=90,
805
+ value=0,
806
  info="-90°=from below, 0°=horizontal, 90°=from above"
807
  )
808
 
809
+ with gr.Row():
810
+ prompt_preview = gr.Textbox(
811
+ label="Generated Prompt",
812
+ value="Light source from the Front",
813
+ interactive=True,
814
+ lines=1,
815
+ )
816
 
817
  with gr.Column(scale=1):
818
  result = gr.Image(label="Output Image", height=500)
 
825
  height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
826
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
827
 
 
828
  def update_prompt_from_sliders(azimuth, elevation):
829
+ """Update prompt preview when sliders change."""
830
+ prompt = build_lighting_prompt(azimuth, elevation)
831
+ return prompt
832
 
833
  def sync_3d_to_sliders(lighting_value):
834
+ """Sync 3D control changes to sliders."""
835
  if lighting_value and isinstance(lighting_value, dict):
836
  az = lighting_value.get('azimuth', 0)
837
  el = lighting_value.get('elevation', 0)
 
840
  return gr.update(), gr.update(), gr.update()
841
 
842
  def sync_sliders_to_3d(azimuth, elevation):
843
+ """Sync slider changes to 3D control."""
844
  return {"azimuth": azimuth, "elevation": elevation}
845
 
846
  def update_3d_image(image):
847
+ """Update the 3D component with the uploaded image."""
848
  if image is None:
849
  return gr.update(imageUrl=None)
850
+
851
  import base64
852
  from io import BytesIO
853
  buffered = BytesIO()
 
896
  fn=lambda: gr.update(imageUrl=None),
897
  outputs=[lighting_3d]
898
  )
899
+
900
  if __name__ == "__main__":
901
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
902
+ css = '.fillable{max-width: 1200px !important}'
903
+ demo.launch(head=head, css=css, theme=orange_red_theme, mcp_server=True, ssr_mode=False, show_error=True)