prithivMLmods commited on
Commit
08fd665
·
verified ·
1 Parent(s): 2810238

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +238 -608
app.py CHANGED
@@ -13,6 +13,7 @@ from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
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,6 +82,7 @@ class OrangeRedTheme(Soft):
81
 
82
  orange_red_theme = OrangeRedTheme()
83
 
 
84
  MAX_SEED = np.iinfo(np.int32).max
85
 
86
  dtype = torch.bfloat16
@@ -94,6 +96,7 @@ pipe = QwenImageEditPlusPipeline.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,39 +112,17 @@ ADAPTER_SPECS = {
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
 
@@ -162,12 +143,7 @@ def infer_lighting_edit(
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
-
171
  if not loaded:
172
  pipe.load_lora_weights(
173
  ADAPTER_SPECS["Multi-Angle-Lighting"]["repo"],
@@ -179,12 +155,16 @@ def infer_lighting_edit(
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,12 +175,11 @@ def infer_lighting_edit(
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
205
  if original_width > original_height:
206
  new_width = 1024
@@ -214,29 +193,18 @@ def update_dimensions_on_upload(image):
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: linear-gradient(180deg, #0d1117 0%, #161b22 50%, #21262d 100%); border-radius: 12px; overflow: hidden; border: 1px solid #30363d;">
229
- <div id="prompt-overlay" style="position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.85); padding: 10px 20px; border-radius: 8px; font-family: 'Courier New', monospace; font-size: 13px; color: #58a6ff; white-space: nowrap; z-index: 10; border: 1px solid #30363d; box-shadow: 0 4px 12px rgba(0,0,0,0.4);"></div>
230
- <div id="controls-legend" style="position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.75); padding: 8px 12px; border-radius: 6px; font-family: sans-serif; font-size: 11px; color: #c9d1d9; z-index: 10; border: 1px solid #30363d;">
231
- <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
232
- <span style="width: 12px; height: 12px; background: #ffd700; border-radius: 50%; display: inline-block;"></span>
233
- <span>Azimuth (Direction)</span>
234
- </div>
235
- <div style="display: flex; align-items: center; gap: 6px;">
236
- <span style="width: 12px; height: 12px; background: #4dabf7; border-radius: 50%; display: inline-block;"></span>
237
- <span>Elevation (Height)</span>
238
- </div>
239
- </div>
240
  </div>
241
  """
242
 
@@ -245,7 +213,6 @@ class LightingControl3D(gr.HTML):
245
  const wrapper = element.querySelector('#lighting-control-wrapper');
246
  const promptOverlay = element.querySelector('#prompt-overlay');
247
 
248
- // Wait for THREE to load
249
  const initScene = () => {
250
  if (typeof THREE === 'undefined') {
251
  setTimeout(initScene, 100);
@@ -254,55 +221,39 @@ class LightingControl3D(gr.HTML):
254
 
255
  // Scene setup
256
  const scene = new THREE.Scene();
257
- scene.background = new THREE.Color(0x0d1117);
258
-
259
- // Add fog for depth
260
- scene.fog = new THREE.Fog(0x0d1117, 8, 20);
261
 
262
  const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
263
  camera.position.set(4.5, 3, 4.5);
264
  camera.lookAt(0, 0.75, 0);
265
 
266
- const renderer = new THREE.WebGLRenderer({ antialias: true });
267
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
268
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
269
  renderer.shadowMap.enabled = true;
270
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
 
271
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
272
- renderer.toneMappingExposure = 1.2;
273
- wrapper.insertBefore(renderer.domElement, wrapper.firstChild);
274
 
275
- // Ambient lighting
276
- const ambientLight = new THREE.AmbientLight(0x404050, 0.3);
277
- scene.add(ambientLight);
278
 
279
- // Ground plane with better material
280
- const groundGeometry = new THREE.PlaneGeometry(12, 12);
281
- const groundMaterial = new THREE.MeshStandardMaterial({
282
- color: 0x1a1a2e,
283
- roughness: 0.8,
284
  metalness: 0.2
285
  });
286
- const ground = new THREE.Mesh(groundGeometry, groundMaterial);
287
  ground.rotation.x = -Math.PI / 2;
288
- ground.position.y = 0;
289
  ground.receiveShadow = true;
290
  scene.add(ground);
291
 
292
- // Shadow-only plane
293
- const shadowPlane = new THREE.Mesh(
294
- new THREE.PlaneGeometry(12, 12),
295
- new THREE.ShadowMaterial({ opacity: 0.4 })
296
- );
297
- shadowPlane.rotation.x = -Math.PI / 2;
298
- shadowPlane.position.y = 0.001;
299
- shadowPlane.receiveShadow = true;
300
- scene.add(shadowPlane);
301
-
302
- // Grid with better styling
303
- const gridHelper = new THREE.GridHelper(10, 20, 0x30363d, 0x21262d);
304
- gridHelper.position.y = 0.002;
305
- scene.add(gridHelper);
306
 
307
  // Constants
308
  const CENTER = new THREE.Vector3(0, 0.75, 0);
@@ -314,72 +265,29 @@ class LightingControl3D(gr.HTML):
314
  let azimuthAngle = props.value?.azimuth || 0;
315
  let elevationAngle = props.value?.elevation || 0;
316
 
317
- // Mappings
318
- const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
319
- const elevationSteps = [-90, 0, 90];
320
- const azimuthNames = {
321
- 0: 'Front', 45: 'Right Front', 90: 'Right',
322
- 135: 'Right Rear', 180: 'Rear', 225: 'Left Rear',
323
- 270: 'Left', 315: 'Left Front'
324
- };
325
- const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' };
326
-
327
- function snapToNearest(value, steps) {
328
- return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
329
- }
330
-
331
- // Create placeholder texture (camera icon style)
332
  function createPlaceholderTexture() {
333
  const canvas = document.createElement('canvas');
334
- canvas.width = 256;
335
- canvas.height = 256;
336
  const ctx = canvas.getContext('2d');
337
-
338
- // Background gradient
339
- const gradient = ctx.createLinearGradient(0, 0, 256, 256);
340
- gradient.addColorStop(0, '#2d333b');
341
- gradient.addColorStop(1, '#22272e');
342
- ctx.fillStyle = gradient;
343
- ctx.fillRect(0, 0, 256, 256);
344
-
345
- // Border
346
- ctx.strokeStyle = '#444c56';
347
- ctx.lineWidth = 4;
348
- ctx.strokeRect(10, 10, 236, 236);
349
-
350
- // Camera icon
351
- ctx.fillStyle = '#58a6ff';
352
- ctx.beginPath();
353
- ctx.roundRect(78, 98, 100, 70, 8);
354
- ctx.fill();
355
-
356
- // Lens
357
- ctx.fillStyle = '#2d333b';
358
- ctx.beginPath();
359
- ctx.arc(128, 133, 25, 0, Math.PI * 2);
360
- ctx.fill();
361
-
362
- ctx.fillStyle = '#58a6ff';
363
- ctx.beginPath();
364
- ctx.arc(128, 133, 18, 0, Math.PI * 2);
365
- ctx.fill();
366
-
367
- // Flash
368
- ctx.fillStyle = '#58a6ff';
369
- ctx.beginPath();
370
- ctx.roundRect(138, 88, 30, 15, 3);
371
- ctx.fill();
372
-
373
- // Text
374
- ctx.fillStyle = '#8b949e';
375
- ctx.font = '16px sans-serif';
376
  ctx.textAlign = 'center';
377
- ctx.fillText('Upload Image', 128, 200);
378
-
379
  return new THREE.CanvasTexture(canvas);
380
  }
381
 
382
- // Target image plane
383
  let currentTexture = createPlaceholderTexture();
384
  const planeMaterial = new THREE.MeshStandardMaterial({
385
  map: currentTexture,
@@ -389,444 +297,245 @@ class LightingControl3D(gr.HTML):
389
  });
390
  let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
391
  targetPlane.position.copy(CENTER);
392
- targetPlane.receiveShadow = true;
393
  targetPlane.castShadow = true;
 
394
  scene.add(targetPlane);
395
 
396
- // Frame around the image
397
- const frameGeometry = new THREE.BoxGeometry(1.3, 1.3, 0.05);
398
- const frameMaterial = new THREE.MeshStandardMaterial({
399
- color: 0x30363d,
400
- roughness: 0.5,
401
- metalness: 0.3
402
- });
403
- const frame = new THREE.Mesh(frameGeometry, frameMaterial);
404
- frame.position.set(CENTER.x, CENTER.y, -0.03);
405
- scene.add(frame);
406
-
407
- // Function to update texture from image URL
408
  function updateTextureFromUrl(url) {
409
  if (!url) {
410
  planeMaterial.map = createPlaceholderTexture();
411
  planeMaterial.needsUpdate = true;
412
- scene.remove(targetPlane);
413
- scene.remove(frame);
414
- targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
415
- targetPlane.position.copy(CENTER);
416
- targetPlane.receiveShadow = true;
417
- targetPlane.castShadow = true;
418
- scene.add(targetPlane);
419
-
420
- frame.geometry = new THREE.BoxGeometry(1.3, 1.3, 0.05);
421
- frame.position.set(CENTER.x, CENTER.y, -0.03);
422
- scene.add(frame);
423
  return;
424
  }
425
-
426
  const loader = new THREE.TextureLoader();
427
  loader.crossOrigin = 'anonymous';
428
  loader.load(url, (texture) => {
429
- texture.minFilter = THREE.LinearFilter;
430
- texture.magFilter = THREE.LinearFilter;
431
  planeMaterial.map = texture;
432
  planeMaterial.needsUpdate = true;
433
 
434
  const img = texture.image;
435
- if (img && img.width && img.height) {
436
  const aspect = img.width / img.height;
437
  const maxSize = 1.5;
438
- let planeWidth, planeHeight;
439
- if (aspect > 1) {
440
- planeWidth = maxSize;
441
- planeHeight = maxSize / aspect;
442
- } else {
443
- planeHeight = maxSize;
444
- planeWidth = maxSize * aspect;
445
- }
446
- scene.remove(targetPlane);
447
- scene.remove(frame);
448
-
449
- targetPlane = new THREE.Mesh(
450
- new THREE.PlaneGeometry(planeWidth, planeHeight),
451
- planeMaterial
452
- );
453
- targetPlane.position.copy(CENTER);
454
- targetPlane.receiveShadow = true;
455
- targetPlane.castShadow = true;
456
- scene.add(targetPlane);
457
-
458
- frame.geometry = new THREE.BoxGeometry(planeWidth + 0.1, planeHeight + 0.1, 0.05);
459
- frame.position.set(CENTER.x, CENTER.y, -0.03);
460
- scene.add(frame);
461
  }
462
- }, undefined, (err) => {
463
- console.error('Failed to load texture:', err);
464
  });
465
  }
 
466
 
467
- if (props.imageUrl) {
468
- updateTextureFromUrl(props.imageUrl);
469
- }
470
-
471
- // =============================================
472
- // SOFTBOX LIGHT - Red box with white diffuser
473
- // =============================================
474
  const lightGroup = new THREE.Group();
475
 
476
- // Main softbox frame (red)
477
- const softboxDepth = 0.4;
478
- const softboxWidth = 0.7;
479
- const softboxHeight = 0.7;
480
-
481
- // Back panel (red)
482
- const backPanel = new THREE.Mesh(
483
- new THREE.BoxGeometry(softboxWidth, softboxHeight, 0.05),
484
- new THREE.MeshStandardMaterial({
485
- color: 0xcc2222,
486
- roughness: 0.7,
487
- metalness: 0.3
488
- })
489
- );
490
- backPanel.position.z = softboxDepth / 2;
491
- lightGroup.add(backPanel);
492
-
493
- // Side panels (red, tapered)
494
- const sideGeometry = new THREE.BoxGeometry(0.04, softboxHeight, softboxDepth);
495
- const sideMaterial = new THREE.MeshStandardMaterial({
496
- color: 0xaa1111,
497
- roughness: 0.6,
498
- metalness: 0.2
499
  });
500
-
501
- const leftSide = new THREE.Mesh(sideGeometry, sideMaterial);
502
- leftSide.position.set(-softboxWidth/2 + 0.02, 0, 0);
503
- lightGroup.add(leftSide);
504
-
505
- const rightSide = new THREE.Mesh(sideGeometry, sideMaterial);
506
- rightSide.position.set(softboxWidth/2 - 0.02, 0, 0);
507
- lightGroup.add(rightSide);
508
-
509
- // Top and bottom panels (red)
510
- const tbGeometry = new THREE.BoxGeometry(softboxWidth, 0.04, softboxDepth);
511
-
512
- const topPanel = new THREE.Mesh(tbGeometry, sideMaterial);
513
- topPanel.position.set(0, softboxHeight/2 - 0.02, 0);
514
- lightGroup.add(topPanel);
515
-
516
- const bottomPanel = new THREE.Mesh(tbGeometry, sideMaterial);
517
- bottomPanel.position.set(0, -softboxHeight/2 + 0.02, 0);
518
- lightGroup.add(bottomPanel);
519
-
520
- // White diffuser front (emissive white)
521
- const diffuserMaterial = new THREE.MeshStandardMaterial({
522
  color: 0xffffff,
523
  emissive: 0xffffff,
524
- emissiveIntensity: 2.0,
525
- roughness: 0.2,
526
- metalness: 0,
527
- transparent: true,
528
- opacity: 0.95
529
  });
530
 
531
- const diffuser = new THREE.Mesh(
532
- new THREE.PlaneGeometry(softboxWidth - 0.08, softboxHeight - 0.08),
533
- diffuserMaterial
534
- );
535
- diffuser.position.z = -softboxDepth / 2 + 0.02;
536
- lightGroup.add(diffuser);
537
-
538
- // Inner glow effect
539
- const innerGlow = new THREE.Mesh(
540
- new THREE.PlaneGeometry(softboxWidth - 0.15, softboxHeight - 0.15),
541
- new THREE.MeshBasicMaterial({
542
- color: 0xffffff,
543
- transparent: true,
544
- opacity: 0.8
545
- })
546
- );
547
- innerGlow.position.z = -softboxDepth / 2 + 0.01;
548
- lightGroup.add(innerGlow);
549
-
550
- // Mounting bracket
551
- const bracket = new THREE.Mesh(
552
- new THREE.CylinderGeometry(0.03, 0.03, 0.15, 8),
553
- new THREE.MeshStandardMaterial({
554
- color: 0x333333,
555
- roughness: 0.5,
556
- metalness: 0.8
557
- })
558
- );
559
- bracket.rotation.x = Math.PI / 2;
560
- bracket.position.z = softboxDepth / 2 + 0.075;
561
- lightGroup.add(bracket);
562
-
563
- // Ball joint
564
- const ballJoint = new THREE.Mesh(
565
- new THREE.SphereGeometry(0.05, 16, 16),
566
- new THREE.MeshStandardMaterial({
567
- color: 0x222222,
568
- roughness: 0.4,
569
- metalness: 0.9
570
- })
571
- );
572
- ballJoint.position.z = softboxDepth / 2 + 0.15;
573
- lightGroup.add(ballJoint);
574
-
575
- // SpotLight for actual lighting
576
- const spotLight = new THREE.SpotLight(0xffffff, 15, 12, Math.PI / 4, 0.5, 1);
577
- spotLight.position.set(0, 0, -softboxDepth / 2);
578
  spotLight.castShadow = true;
579
  spotLight.shadow.mapSize.width = 1024;
580
  spotLight.shadow.mapSize.height = 1024;
581
- spotLight.shadow.camera.near = 0.5;
582
- spotLight.shadow.camera.far = 15;
583
- spotLight.shadow.bias = -0.002;
584
  lightGroup.add(spotLight);
585
 
586
- const lightTarget = new THREE.Object3D();
587
- lightTarget.position.copy(CENTER);
588
- scene.add(lightTarget);
589
- spotLight.target = lightTarget;
590
-
591
- // Point light for softer fill
592
- const pointLight = new THREE.PointLight(0xffffff, 3, 8);
593
- pointLight.position.set(0, 0, -softboxDepth / 2 - 0.1);
594
- lightGroup.add(pointLight);
595
 
596
  scene.add(lightGroup);
597
 
598
- // =============================================
599
- // YELLOW: Azimuth ring and handle
600
- // =============================================
601
  const azimuthRing = new THREE.Mesh(
602
- new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.035, 16, 64),
603
  new THREE.MeshStandardMaterial({
604
- color: 0xffd700,
605
- emissive: 0xffd700,
606
- emissiveIntensity: 0.4,
607
- roughness: 0.3,
608
- metalness: 0.6
609
  })
610
  );
611
  azimuthRing.rotation.x = Math.PI / 2;
612
  azimuthRing.position.y = 0.05;
613
  scene.add(azimuthRing);
614
 
615
- // Azimuth direction markers
616
- azimuthSteps.forEach(angle => {
617
- const rad = THREE.MathUtils.degToRad(angle);
618
- const marker = new THREE.Mesh(
619
- new THREE.SphereGeometry(0.06, 8, 8),
620
- new THREE.MeshStandardMaterial({
621
- color: 0xffd700,
622
- emissive: 0xffd700,
623
- emissiveIntensity: 0.2
624
- })
625
- );
626
- marker.position.set(
627
- AZIMUTH_RADIUS * Math.sin(rad),
628
- 0.05,
629
- AZIMUTH_RADIUS * Math.cos(rad)
630
- );
631
- scene.add(marker);
632
- });
633
-
634
  const azimuthHandle = new THREE.Mesh(
635
- new THREE.SphereGeometry(0.16, 24, 24),
636
  new THREE.MeshStandardMaterial({
637
- color: 0xffd700,
638
- emissive: 0xffd700,
639
- emissiveIntensity: 0.6,
640
- roughness: 0.2,
641
- metalness: 0.7
642
  })
643
  );
644
  azimuthHandle.userData.type = 'azimuth';
645
  scene.add(azimuthHandle);
646
 
647
- // =============================================
648
- // BLUE: Elevation arc and handle
649
- // =============================================
650
  const arcPoints = [];
651
- for (let i = 0; i <= 48; i++) {
652
- const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 48));
653
  arcPoints.push(new THREE.Vector3(
654
  -0.8,
655
  ELEVATION_RADIUS * Math.sin(angle) + CENTER.y,
656
  ELEVATION_RADIUS * Math.cos(angle)
657
  ));
658
  }
659
- const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
660
  const elevationArc = new THREE.Mesh(
661
- new THREE.TubeGeometry(arcCurve, 48, 0.035, 12, false),
662
  new THREE.MeshStandardMaterial({
663
- color: 0x4dabf7,
664
- emissive: 0x4dabf7,
665
- emissiveIntensity: 0.4,
666
- roughness: 0.3,
667
- metalness: 0.6
668
  })
669
  );
670
  scene.add(elevationArc);
671
 
672
- // Elevation markers
673
- elevationSteps.forEach(angle => {
674
- const rad = THREE.MathUtils.degToRad(angle);
675
- const marker = new THREE.Mesh(
676
- new THREE.SphereGeometry(0.06, 8, 8),
677
- new THREE.MeshStandardMaterial({
678
- color: 0x4dabf7,
679
- emissive: 0x4dabf7,
680
- emissiveIntensity: 0.2
681
- })
682
- );
683
- marker.position.set(
684
- -0.8,
685
- ELEVATION_RADIUS * Math.sin(rad) + CENTER.y,
686
- ELEVATION_RADIUS * Math.cos(rad)
687
- );
688
- scene.add(marker);
689
- });
690
-
691
  const elevationHandle = new THREE.Mesh(
692
- new THREE.SphereGeometry(0.16, 24, 24),
693
  new THREE.MeshStandardMaterial({
694
- color: 0x4dabf7,
695
- emissive: 0x4dabf7,
696
- emissiveIntensity: 0.6,
697
- roughness: 0.2,
698
- metalness: 0.7
699
  })
700
  );
701
  elevationHandle.userData.type = 'elevation';
702
  scene.add(elevationHandle);
703
 
704
- // =============================================
705
- // REFRESH BUTTON (styled button, not symbol)
706
- // =============================================
707
  const refreshBtn = document.createElement('button');
708
- refreshBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg><span style="margin-left: 6px;">Reset</span>';
709
- refreshBtn.style.cssText = `
710
- position: absolute;
711
- top: 10px;
712
- right: 10px;
713
- background: linear-gradient(135deg, #238636 0%, #2ea043 100%);
714
- color: white;
715
- border: none;
716
- padding: 8px 14px;
717
- border-radius: 6px;
718
- cursor: pointer;
719
- z-index: 10;
720
- font-size: 13px;
721
- font-weight: 500;
722
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
723
- display: flex;
724
- align-items: center;
725
- box-shadow: 0 2px 8px rgba(0,0,0,0.3);
726
- transition: all 0.2s ease;
727
- `;
728
- refreshBtn.onmouseenter = () => {
729
- refreshBtn.style.background = 'linear-gradient(135deg, #2ea043 0%, #3fb950 100%)';
730
- refreshBtn.style.transform = 'translateY(-1px)';
731
- refreshBtn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
732
- };
733
- refreshBtn.onmouseleave = () => {
734
- refreshBtn.style.background = 'linear-gradient(135deg, #238636 0%, #2ea043 100%)';
735
- refreshBtn.style.transform = 'translateY(0)';
736
- refreshBtn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
737
- };
738
  wrapper.appendChild(refreshBtn);
739
 
740
- refreshBtn.addEventListener('click', () => {
741
- // Animate reset
742
- const startAz = azimuthAngle;
743
- const startEl = elevationAngle;
744
- const startTime = Date.now();
745
-
746
- function animateReset() {
747
- const t = Math.min((Date.now() - startTime) / 400, 1);
748
- const ease = 1 - Math.pow(1 - t, 4);
749
-
750
- let azDiff = 0 - startAz;
751
- if (azDiff > 180) azDiff -= 360;
752
- if (azDiff < -180) azDiff += 360;
753
- azimuthAngle = startAz + azDiff * ease;
754
- if (azimuthAngle < 0) azimuthAngle += 360;
755
- if (azimuthAngle >= 360) azimuthAngle -= 360;
756
-
757
- elevationAngle = startEl + (0 - startEl) * ease;
758
-
759
- updatePositions();
760
-
761
- if (t < 1) {
762
- requestAnimationFrame(animateReset);
763
- } else {
764
- azimuthAngle = 0;
765
- elevationAngle = 0;
766
- updatePositions();
767
- updatePropsAndTrigger();
768
- }
769
- }
770
- animateReset();
771
  });
772
 
 
 
 
 
 
 
 
 
 
 
773
  function updatePositions() {
774
- const distance = BASE_DISTANCE;
775
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
776
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
777
 
778
- const lightX = distance * Math.sin(azRad) * Math.cos(elRad);
779
- const lightY = distance * Math.sin(elRad) + CENTER.y;
780
- const lightZ = distance * Math.cos(azRad) * Math.cos(elRad);
 
781
 
782
  lightGroup.position.set(lightX, lightY, lightZ);
783
  lightGroup.lookAt(CENTER);
784
 
785
- azimuthHandle.position.set(
786
- AZIMUTH_RADIUS * Math.sin(azRad),
787
- 0.05,
788
- AZIMUTH_RADIUS * Math.cos(azRad)
789
- );
790
- elevationHandle.position.set(
791
- -0.8,
792
- ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y,
793
- ELEVATION_RADIUS * Math.cos(elRad)
794
- );
795
 
796
- // Update prompt
797
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
798
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
799
- let prompt = '💡 Light source from';
800
- if (elSnap !== 0) {
801
- prompt += ' ' + elevationNames[String(elSnap)];
802
- } else {
803
- prompt += ' the ' + azimuthNames[azSnap];
804
- }
805
- promptOverlay.textContent = prompt;
806
  }
807
 
808
  function updatePropsAndTrigger() {
809
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
810
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
811
-
812
  props.value = { azimuth: azSnap, elevation: elSnap };
813
  trigger('change', props.value);
814
  }
815
 
816
- // Raycasting
817
  const raycaster = new THREE.Raycaster();
818
  const mouse = new THREE.Vector2();
819
  let isDragging = false;
820
  let dragTarget = null;
821
- let dragStartMouse = new THREE.Vector2();
822
  const intersection = new THREE.Vector3();
823
 
824
  const canvas = renderer.domElement;
825
 
826
- canvas.addEventListener('mousedown', (e) => {
827
  const rect = canvas.getBoundingClientRect();
828
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
829
- mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
830
 
831
  raycaster.setFromCamera(mouse, camera);
832
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
@@ -834,17 +543,17 @@ class LightingControl3D(gr.HTML):
834
  if (intersects.length > 0) {
835
  isDragging = true;
836
  dragTarget = intersects[0].object;
837
- dragTarget.material.emissiveIntensity = 1.2;
838
- dragTarget.scale.setScalar(1.3);
839
- dragStartMouse.copy(mouse);
840
  canvas.style.cursor = 'grabbing';
 
841
  }
842
- });
 
843
 
844
- canvas.addEventListener('mousemove', (e) => {
845
  const rect = canvas.getBoundingClientRect();
846
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
847
- mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
848
 
849
  if (isDragging && dragTarget) {
850
  raycaster.setFromCamera(mouse, camera);
@@ -855,185 +564,117 @@ class LightingControl3D(gr.HTML):
855
  azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
856
  if (azimuthAngle < 0) azimuthAngle += 360;
857
  }
858
- } else if (dragTarget.userData.type === 'elevation') {
859
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0.8);
860
  if (raycaster.ray.intersectPlane(plane, intersection)) {
861
  const relY = intersection.y - CENTER.y;
862
  const relZ = intersection.z;
863
- elevationAngle = THREE.MathUtils.clamp(
864
- THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)),
865
- -90,
866
- 90
867
- );
868
  }
869
  }
870
  updatePositions();
871
  } else {
 
872
  raycaster.setFromCamera(mouse, camera);
873
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
874
- [azimuthHandle, elevationHandle].forEach(h => {
875
- h.material.emissiveIntensity = 0.6;
876
- h.scale.setScalar(1);
877
- });
878
- if (intersects.length > 0) {
879
- intersects[0].object.material.emissiveIntensity = 0.9;
880
- intersects[0].object.scale.setScalar(1.15);
881
- canvas.style.cursor = 'grab';
882
- } else {
883
- canvas.style.cursor = 'default';
884
- }
885
  }
886
- });
887
 
888
- const onMouseUp = () => {
889
  if (dragTarget) {
890
- dragTarget.material.emissiveIntensity = 0.6;
891
  dragTarget.scale.setScalar(1);
892
-
893
- // Snap and animate
894
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
895
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
896
-
897
  const startAz = azimuthAngle, startEl = elevationAngle;
898
  const startTime = Date.now();
899
 
900
- function animateSnap() {
901
  const t = Math.min((Date.now() - startTime) / 200, 1);
902
  const ease = 1 - Math.pow(1 - t, 3);
903
 
904
  let azDiff = targetAz - startAz;
905
  if (azDiff > 180) azDiff -= 360;
906
  if (azDiff < -180) azDiff += 360;
 
907
  azimuthAngle = startAz + azDiff * ease;
908
  if (azimuthAngle < 0) azimuthAngle += 360;
909
  if (azimuthAngle >= 360) azimuthAngle -= 360;
910
 
911
  elevationAngle = startEl + (targetEl - startEl) * ease;
912
-
913
  updatePositions();
914
- if (t < 1) requestAnimationFrame(animateSnap);
 
915
  else updatePropsAndTrigger();
916
  }
917
- animateSnap();
918
  }
919
  isDragging = false;
920
  dragTarget = null;
921
  canvas.style.cursor = 'default';
922
- };
923
-
924
- canvas.addEventListener('mouseup', onMouseUp);
925
- canvas.addEventListener('mouseleave', onMouseUp);
926
 
927
- // Touch support for mobile
928
- canvas.addEventListener('touchstart', (e) => {
929
- e.preventDefault();
930
- const touch = e.touches[0];
931
- const rect = canvas.getBoundingClientRect();
932
- mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
933
- mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
934
-
935
- raycaster.setFromCamera(mouse, camera);
936
- const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
937
-
938
- if (intersects.length > 0) {
939
- isDragging = true;
940
- dragTarget = intersects[0].object;
941
- dragTarget.material.emissiveIntensity = 1.2;
942
- dragTarget.scale.setScalar(1.3);
943
- dragStartMouse.copy(mouse);
944
- }
945
- }, { passive: false });
946
 
947
- canvas.addEventListener('touchmove', (e) => {
948
  e.preventDefault();
949
- const touch = e.touches[0];
950
- const rect = canvas.getBoundingClientRect();
951
- mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
952
- mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
953
-
954
- if (isDragging && dragTarget) {
955
- raycaster.setFromCamera(mouse, camera);
956
-
957
- if (dragTarget.userData.type === 'azimuth') {
958
- const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
959
- if (raycaster.ray.intersectPlane(plane, intersection)) {
960
- azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
961
- if (azimuthAngle < 0) azimuthAngle += 360;
962
- }
963
- } else if (dragTarget.userData.type === 'elevation') {
964
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0.8);
965
- if (raycaster.ray.intersectPlane(plane, intersection)) {
966
- const relY = intersection.y - CENTER.y;
967
- const relZ = intersection.z;
968
- elevationAngle = THREE.MathUtils.clamp(
969
- THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)),
970
- -90,
971
- 90
972
- );
973
- }
974
- }
975
- updatePositions();
976
- }
977
- }, { passive: false });
978
-
979
- canvas.addEventListener('touchend', (e) => {
980
  e.preventDefault();
981
- onMouseUp();
982
- }, { passive: false });
983
-
984
- canvas.addEventListener('touchcancel', (e) => {
985
  e.preventDefault();
986
- onMouseUp();
987
- }, { passive: false });
988
 
989
- // Initial update
990
  updatePositions();
991
 
992
- // Render loop
993
  function render() {
994
  requestAnimationFrame(render);
995
  renderer.render(scene, camera);
996
  }
997
  render();
998
 
999
- // Handle resize
1000
  new ResizeObserver(() => {
1001
  camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
1002
  camera.updateProjectionMatrix();
1003
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
1004
  }).observe(wrapper);
1005
 
1006
- // Store update functions for external calls
1007
- wrapper._updateFromProps = (newVal) => {
1008
- if (newVal && typeof newVal === 'object') {
1009
- azimuthAngle = newVal.azimuth ?? azimuthAngle;
1010
- elevationAngle = newVal.elevation ?? elevationAngle;
1011
  updatePositions();
1012
  }
1013
  };
1014
-
1015
  wrapper._updateTexture = updateTextureFromUrl;
1016
 
1017
- // Watch for prop changes
1018
- let lastImageUrl = props.imageUrl;
1019
- let lastValue = JSON.stringify(props.value);
1020
  setInterval(() => {
1021
- if (props.imageUrl !== lastImageUrl) {
1022
- lastImageUrl = props.imageUrl;
1023
  updateTextureFromUrl(props.imageUrl);
1024
  }
1025
- const currentValue = JSON.stringify(props.value);
1026
- if (currentValue !== lastValue) {
1027
- lastValue = currentValue;
1028
- if (props.value && typeof props.value === 'object') {
1029
- azimuthAngle = props.value.azimuth ?? azimuthAngle;
1030
- elevationAngle = props.value.elevation ?? elevationAngle;
1031
- updatePositions();
1032
- }
1033
  }
1034
  }, 100);
1035
  };
1036
-
1037
  initScene();
1038
  })();
1039
  """
@@ -1046,6 +687,7 @@ class LightingControl3D(gr.HTML):
1046
  **kwargs
1047
  )
1048
 
 
1049
  css = '''
1050
  #col-container { max-width: 1200px; margin: 0 auto; }
1051
  .dark .progress-text { color: white !important; }
@@ -1053,7 +695,6 @@ css = '''
1053
  .slider-row { display: flex; gap: 10px; align-items: center; }
1054
  #main-title h1 {font-size: 2.4em !important;}
1055
  '''
1056
-
1057
  with gr.Blocks(css=css) as demo:
1058
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
1059
  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).")
@@ -1075,19 +716,13 @@ with gr.Blocks(css=css) as demo:
1075
 
1076
  azimuth_slider = gr.Slider(
1077
  label="Azimuth (Horizontal Rotation)",
1078
- minimum=0,
1079
- maximum=315,
1080
- step=45,
1081
- value=0,
1082
  info="0°=front, 90°=right, 180°=rear, 270°=left"
1083
  )
1084
 
1085
  elevation_slider = gr.Slider(
1086
  label="Elevation (Vertical Angle)",
1087
- minimum=-90,
1088
- maximum=90,
1089
- step=90,
1090
- value=0,
1091
  info="-90°=from below, 0°=horizontal, 90°=from above"
1092
  )
1093
 
@@ -1110,13 +745,12 @@ with gr.Blocks(css=css) as demo:
1110
  height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
1111
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
1112
 
 
1113
  def update_prompt_from_sliders(azimuth, elevation):
1114
- """Update prompt preview when sliders change."""
1115
  prompt = build_lighting_prompt(azimuth, elevation)
1116
  return prompt
1117
 
1118
  def sync_3d_to_sliders(lighting_value):
1119
- """Sync 3D control changes to sliders."""
1120
  if lighting_value and isinstance(lighting_value, dict):
1121
  az = lighting_value.get('azimuth', 0)
1122
  el = lighting_value.get('elevation', 0)
@@ -1125,14 +759,10 @@ with gr.Blocks(css=css) as demo:
1125
  return gr.update(), gr.update(), gr.update()
1126
 
1127
  def sync_sliders_to_3d(azimuth, elevation):
1128
- """Sync slider changes to 3D control."""
1129
  return {"azimuth": azimuth, "elevation": elevation}
1130
 
1131
  def update_3d_image(image):
1132
- """Update the 3D component with the uploaded image."""
1133
- if image is None:
1134
- return gr.update(imageUrl=None)
1135
-
1136
  import base64
1137
  from io import BytesIO
1138
  buffered = BytesIO()
@@ -1181,7 +811,7 @@ with gr.Blocks(css=css) as demo:
1181
  fn=lambda: gr.update(imageUrl=None),
1182
  outputs=[lighting_3d]
1183
  )
1184
-
1185
  if __name__ == "__main__":
1186
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
1187
  css = '.fillable{max-width: 1200px !important}'
 
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
 
83
  orange_red_theme = OrangeRedTheme()
84
 
85
+ # --- MODEL SETUP ---
86
  MAX_SEED = np.iinfo(np.int32).max
87
 
88
  dtype = torch.bfloat16
 
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
  }
113
  loaded = False
114
 
115
+ # --- CONSTANTS ---
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
+ ELEVATION_MAP = {-90: "Below", 0: "", 90: "Above"}
121
 
122
  def snap_to_nearest(value, options):
 
123
  return min(options, key=lambda x: abs(x - value))
124
 
125
  def build_lighting_prompt(azimuth: float, elevation: float) -> str:
 
 
 
 
 
 
 
 
 
 
 
126
  azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
127
  elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
128
 
 
143
  height: int = 1024,
144
  width: int = 1024,
145
  ):
 
 
 
146
  global loaded
 
 
147
  if not loaded:
148
  pipe.load_lora_weights(
149
  ADAPTER_SPECS["Multi-Angle-Lighting"]["repo"],
 
155
 
156
  prompt = build_lighting_prompt(azimuth, elevation)
157
  print(f"Generated Prompt: {prompt}")
158
+
159
  if randomize_seed:
160
  seed = random.randint(0, MAX_SEED)
161
  generator = torch.Generator(device=device).manual_seed(seed)
162
+
163
  if image is None:
164
  raise gr.Error("Please upload an image first.")
165
+
166
  pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
167
+
168
  result = pipe(
169
  image=[pil_image],
170
  prompt=prompt,
 
175
  guidance_scale=guidance_scale,
176
  num_images_per_prompt=1,
177
  ).images[0]
178
+
179
  return result, seed, prompt
180
 
181
  def update_dimensions_on_upload(image):
182
+ if image is None: return 1024, 1024
 
 
183
  original_width, original_height = image.size
184
  if original_width > original_height:
185
  new_width = 1024
 
193
  new_height = (new_height // 8) * 8
194
  return new_width, new_height
195
 
196
+ # --- 3D COMPONENT CLASS ---
197
  class LightingControl3D(gr.HTML):
198
  """
199
+ A 3D lighting control component using Three.js with updated Softbox design and controls.
 
 
200
  """
201
  def __init__(self, value=None, imageUrl=None, **kwargs):
202
  if value is None:
203
  value = {"azimuth": 0, "elevation": 0}
204
 
205
  html_template = """
206
+ <div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #1a1a1a; border-radius: 12px; overflow: hidden; box-shadow: inset 0 0 20px rgba(0,0,0,0.5);">
207
+ <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: 6px; font-family: 'IBM Plex Mono', monospace; font-size: 14px; color: #fff; border: 1px solid #333; z-index: 10;">Waiting for interaction...</div>
 
 
 
 
 
 
 
 
 
 
208
  </div>
209
  """
210
 
 
213
  const wrapper = element.querySelector('#lighting-control-wrapper');
214
  const promptOverlay = element.querySelector('#prompt-overlay');
215
 
 
216
  const initScene = () => {
217
  if (typeof THREE === 'undefined') {
218
  setTimeout(initScene, 100);
 
221
 
222
  // Scene setup
223
  const scene = new THREE.Scene();
224
+ scene.background = new THREE.Color(0x1a1a1a);
225
+ scene.fog = new THREE.FogExp2(0x1a1a1a, 0.05);
 
 
226
 
227
  const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
228
  camera.position.set(4.5, 3, 4.5);
229
  camera.lookAt(0, 0.75, 0);
230
 
231
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
232
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
233
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
234
  renderer.shadowMap.enabled = true;
235
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
236
+ renderer.outputEncoding = THREE.sRGBEncoding;
237
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
238
+ wrapper.insertBefore(renderer.domElement, promptOverlay);
 
239
 
240
+ // Base Lighting
241
+ const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.1);
242
+ scene.add(hemiLight);
243
 
244
+ // Ground plane
245
+ const groundMat = new THREE.MeshStandardMaterial({
246
+ color: 0x111111,
247
+ roughness: 0.8,
 
248
  metalness: 0.2
249
  });
250
+ const ground = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), groundMat);
251
  ground.rotation.x = -Math.PI / 2;
 
252
  ground.receiveShadow = true;
253
  scene.add(ground);
254
 
255
+ // Grid
256
+ scene.add(new THREE.GridHelper(10, 20, 0x333333, 0x222222));
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
  // Constants
259
  const CENTER = new THREE.Vector3(0, 0.75, 0);
 
265
  let azimuthAngle = props.value?.azimuth || 0;
266
  let elevationAngle = props.value?.elevation || 0;
267
 
268
+ // --- TARGET PLANE SETUP ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  function createPlaceholderTexture() {
270
  const canvas = document.createElement('canvas');
271
+ canvas.width = 512; canvas.height = 512;
 
272
  const ctx = canvas.getContext('2d');
273
+ // Dark background
274
+ ctx.fillStyle = '#222';
275
+ ctx.fillRect(0, 0, 512, 512);
276
+ // Stylized grid/face
277
+ ctx.strokeStyle = '#444';
278
+ ctx.lineWidth = 2;
279
+ for(let i=0; i<=512; i+=64) {
280
+ ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,512); ctx.stroke();
281
+ ctx.beginPath(); ctx.moveTo(0,i); ctx.lineTo(512,i); ctx.stroke();
282
+ }
283
+ ctx.fillStyle = '#FF4500';
284
+ ctx.font = 'bold 100px Arial';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  ctx.textAlign = 'center';
286
+ ctx.textBaseline = 'middle';
287
+ ctx.fillText('IMG', 256, 256);
288
  return new THREE.CanvasTexture(canvas);
289
  }
290
 
 
291
  let currentTexture = createPlaceholderTexture();
292
  const planeMaterial = new THREE.MeshStandardMaterial({
293
  map: currentTexture,
 
297
  });
298
  let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
299
  targetPlane.position.copy(CENTER);
 
300
  targetPlane.castShadow = true;
301
+ targetPlane.receiveShadow = true;
302
  scene.add(targetPlane);
303
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  function updateTextureFromUrl(url) {
305
  if (!url) {
306
  planeMaterial.map = createPlaceholderTexture();
307
  planeMaterial.needsUpdate = true;
308
+ // Reset geometry to square
309
+ targetPlane.geometry.dispose();
310
+ targetPlane.geometry = new THREE.PlaneGeometry(1.2, 1.2);
 
 
 
 
 
 
 
 
311
  return;
312
  }
 
313
  const loader = new THREE.TextureLoader();
314
  loader.crossOrigin = 'anonymous';
315
  loader.load(url, (texture) => {
316
+ texture.encoding = THREE.sRGBEncoding;
 
317
  planeMaterial.map = texture;
318
  planeMaterial.needsUpdate = true;
319
 
320
  const img = texture.image;
321
+ if (img.width && img.height) {
322
  const aspect = img.width / img.height;
323
  const maxSize = 1.5;
324
+ let w, h;
325
+ if (aspect > 1) { w = maxSize; h = maxSize / aspect; }
326
+ else { h = maxSize; w = maxSize * aspect; }
327
+ targetPlane.geometry.dispose();
328
+ targetPlane.geometry = new THREE.PlaneGeometry(w, h);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  }
 
 
330
  });
331
  }
332
+ if (props.imageUrl) updateTextureFromUrl(props.imageUrl);
333
 
334
+ // --- NEW LIGHT MODEL (SOFTBOX) ---
 
 
 
 
 
 
335
  const lightGroup = new THREE.Group();
336
 
337
+ // Materials
338
+ // Red Body
339
+ const bodyMat = new THREE.MeshStandardMaterial({
340
+ color: 0xcc0000, // Red
341
+ roughness: 0.5,
342
+ metalness: 0.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  });
344
+ // White Light Face
345
+ const lightFaceMat = new THREE.MeshStandardMaterial({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  color: 0xffffff,
347
  emissive: 0xffffff,
348
+ emissiveIntensity: 2.0
 
 
 
 
349
  });
350
 
351
+ // Box Geometry with multi-material
352
+ // Faces: +x, -x, +y, -y, +z, -z
353
+ // We want the face pointing towards the target (+Z in LookAt local space) to be white.
354
+ // Three.js BoxGeometry indices: 0:+x, 1:-x, 2:+y, 3:-y, 4:+z, 5:-z
355
+ const boxMaterials = [
356
+ bodyMat, bodyMat, bodyMat, bodyMat,
357
+ lightFaceMat, // +Z (Front)
358
+ bodyMat
359
+ ];
360
+
361
+ const softbox = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.6, 0.4), boxMaterials);
362
+ // When lightGroup looks at CENTER, its +Z axis points to CENTER.
363
+ // We want the lightFace (+Z) to be at the front.
364
+ // However, by default, lookAt points +Z to target.
365
+ // So the +Z face of the box is the one facing the target.
366
+ softbox.position.set(0, 0, 0);
367
+ softbox.castShadow = true;
368
+ lightGroup.add(softbox);
369
+
370
+ // Physical Spotlight (Invisible source, but casts the shadow)
371
+ const spotLight = new THREE.SpotLight(0xffffff, 8, 10, Math.PI / 4, 0.5, 1);
372
+ spotLight.position.set(0, 0, 0.1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  spotLight.castShadow = true;
374
  spotLight.shadow.mapSize.width = 1024;
375
  spotLight.shadow.mapSize.height = 1024;
376
+ spotLight.shadow.bias = -0.001;
 
 
377
  lightGroup.add(spotLight);
378
 
379
+ // Target for spotlight
380
+ const lightTargetObj = new THREE.Object3D();
381
+ lightTargetObj.position.copy(CENTER);
382
+ scene.add(lightTargetObj);
383
+ spotLight.target = lightTargetObj;
 
 
 
 
384
 
385
  scene.add(lightGroup);
386
 
387
+ // --- CONTROLS (AZIMUTH & ELEVATION) ---
388
+
389
+ // YELLOW: Azimuth Ring
390
  const azimuthRing = new THREE.Mesh(
391
+ new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.03, 16, 64),
392
  new THREE.MeshStandardMaterial({
393
+ color: 0xffcc00,
394
+ emissive: 0xffcc00,
395
+ emissiveIntensity: 0.2,
396
+ transparent: true,
397
+ opacity: 0.8
398
  })
399
  );
400
  azimuthRing.rotation.x = Math.PI / 2;
401
  azimuthRing.position.y = 0.05;
402
  scene.add(azimuthRing);
403
 
404
+ // YELLOW: Azimuth Handle
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  const azimuthHandle = new THREE.Mesh(
406
+ new THREE.SphereGeometry(0.18, 32, 32),
407
  new THREE.MeshStandardMaterial({
408
+ color: 0xffdd00, // Bright Yellow
409
+ emissive: 0xffaa00,
410
+ emissiveIntensity: 0.4,
411
+ metalness: 0.5,
412
+ roughness: 0.2
413
  })
414
  );
415
  azimuthHandle.userData.type = 'azimuth';
416
  scene.add(azimuthHandle);
417
 
418
+ // BLUE: Elevation Arc
 
 
419
  const arcPoints = [];
420
+ for (let i = 0; i <= 32; i++) {
421
+ const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 32));
422
  arcPoints.push(new THREE.Vector3(
423
  -0.8,
424
  ELEVATION_RADIUS * Math.sin(angle) + CENTER.y,
425
  ELEVATION_RADIUS * Math.cos(angle)
426
  ));
427
  }
 
428
  const elevationArc = new THREE.Mesh(
429
+ new THREE.TubeGeometry(new THREE.CatmullRomCurve3(arcPoints), 32, 0.03, 8, false),
430
  new THREE.MeshStandardMaterial({
431
+ color: 0x007bff, // Blue
432
+ emissive: 0x0055ff,
433
+ emissiveIntensity: 0.3
 
 
434
  })
435
  );
436
  scene.add(elevationArc);
437
 
438
+ // BLUE: Elevation Handle
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  const elevationHandle = new THREE.Mesh(
440
+ new THREE.SphereGeometry(0.18, 32, 32),
441
  new THREE.MeshStandardMaterial({
442
+ color: 0x007bff, // Blue
443
+ emissive: 0x0044cc,
444
+ emissiveIntensity: 0.4,
445
+ metalness: 0.5,
446
+ roughness: 0.2
447
  })
448
  );
449
  elevationHandle.userData.type = 'elevation';
450
  scene.add(elevationHandle);
451
 
452
+ // --- REFRESH BUTTON ---
 
 
453
  const refreshBtn = document.createElement('button');
454
+ refreshBtn.innerHTML = 'Reset View';
455
+ Object.assign(refreshBtn.style, {
456
+ position: 'absolute',
457
+ top: '15px',
458
+ right: '15px',
459
+ background: '#FF4500',
460
+ color: 'white',
461
+ border: 'none',
462
+ padding: '8px 16px',
463
+ borderRadius: '6px',
464
+ cursor: 'pointer',
465
+ fontSize: '13px',
466
+ fontWeight: '600',
467
+ boxShadow: '0 2px 5px rgba(0,0,0,0.3)',
468
+ zIndex: '20',
469
+ transition: 'background 0.2s'
470
+ });
471
+ refreshBtn.onmouseover = () => refreshBtn.style.background = '#ff6622';
472
+ refreshBtn.onmouseout = () => refreshBtn.style.background = '#FF4500';
473
+
 
 
 
 
 
 
 
 
 
 
474
  wrapper.appendChild(refreshBtn);
475
 
476
+ refreshBtn.addEventListener('click', (e) => {
477
+ e.stopPropagation();
478
+ azimuthAngle = 0;
479
+ elevationAngle = 0;
480
+ updatePositions();
481
+ updatePropsAndTrigger();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  });
483
 
484
+ // --- LOGIC ---
485
+ const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
486
+ const elevationSteps = [-90, 0, 90];
487
+ const azimuthNames = { 0:'Front',45:'Right Front',90:'Right',135:'Right Rear',180:'Rear',225:'Left Rear',270:'Left',315:'Left Front'};
488
+ const elevationNames = { '-90':'Below', '0':'', '90':'Above'};
489
+
490
+ function snapToNearest(value, steps) {
491
+ return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
492
+ }
493
+
494
  function updatePositions() {
 
495
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
496
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
497
 
498
+ // Position Light
499
+ const lightX = BASE_DISTANCE * Math.sin(azRad) * Math.cos(elRad);
500
+ const lightY = BASE_DISTANCE * Math.sin(elRad) + CENTER.y;
501
+ const lightZ = BASE_DISTANCE * Math.cos(azRad) * Math.cos(elRad);
502
 
503
  lightGroup.position.set(lightX, lightY, lightZ);
504
  lightGroup.lookAt(CENTER);
505
 
506
+ // Position Handles
507
+ azimuthHandle.position.set(AZIMUTH_RADIUS * Math.sin(azRad), 0.05, AZIMUTH_RADIUS * Math.cos(azRad));
508
+ elevationHandle.position.set(-0.8, ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y, ELEVATION_RADIUS * Math.cos(elRad));
 
 
 
 
 
 
 
509
 
510
+ // Update Text
511
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
512
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
513
+ let txt = 'Light source from';
514
+ if (elSnap !== 0) txt += ' ' + elevationNames[String(elSnap)];
515
+ else txt += ' the ' + azimuthNames[azSnap];
516
+ promptOverlay.textContent = txt;
 
 
 
517
  }
518
 
519
  function updatePropsAndTrigger() {
520
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
521
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
 
522
  props.value = { azimuth: azSnap, elevation: elSnap };
523
  trigger('change', props.value);
524
  }
525
 
526
+ // --- INTERACTION ---
527
  const raycaster = new THREE.Raycaster();
528
  const mouse = new THREE.Vector2();
529
  let isDragging = false;
530
  let dragTarget = null;
 
531
  const intersection = new THREE.Vector3();
532
 
533
  const canvas = renderer.domElement;
534
 
535
+ function handleStart(x, y) {
536
  const rect = canvas.getBoundingClientRect();
537
+ mouse.x = ((x - rect.left) / rect.width) * 2 - 1;
538
+ mouse.y = -((y - rect.top) / rect.height) * 2 + 1;
539
 
540
  raycaster.setFromCamera(mouse, camera);
541
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
 
543
  if (intersects.length > 0) {
544
  isDragging = true;
545
  dragTarget = intersects[0].object;
546
+ dragTarget.scale.setScalar(1.2);
 
 
547
  canvas.style.cursor = 'grabbing';
548
+ return true;
549
  }
550
+ return false;
551
+ }
552
 
553
+ function handleMove(x, y) {
554
  const rect = canvas.getBoundingClientRect();
555
+ mouse.x = ((x - rect.left) / rect.width) * 2 - 1;
556
+ mouse.y = -((y - rect.top) / rect.height) * 2 + 1;
557
 
558
  if (isDragging && dragTarget) {
559
  raycaster.setFromCamera(mouse, camera);
 
564
  azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
565
  if (azimuthAngle < 0) azimuthAngle += 360;
566
  }
567
+ } else {
568
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
569
  if (raycaster.ray.intersectPlane(plane, intersection)) {
570
  const relY = intersection.y - CENTER.y;
571
  const relZ = intersection.z;
572
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
 
 
 
 
573
  }
574
  }
575
  updatePositions();
576
  } else {
577
+ // Hover effect
578
  raycaster.setFromCamera(mouse, camera);
579
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
580
+ if (intersects.length > 0) canvas.style.cursor = 'grab';
581
+ else canvas.style.cursor = 'default';
 
 
 
 
 
 
 
 
 
582
  }
583
+ }
584
 
585
+ function handleEnd() {
586
  if (dragTarget) {
 
587
  dragTarget.scale.setScalar(1);
588
+ // Snap animation
 
589
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
590
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
 
591
  const startAz = azimuthAngle, startEl = elevationAngle;
592
  const startTime = Date.now();
593
 
594
+ function animate() {
595
  const t = Math.min((Date.now() - startTime) / 200, 1);
596
  const ease = 1 - Math.pow(1 - t, 3);
597
 
598
  let azDiff = targetAz - startAz;
599
  if (azDiff > 180) azDiff -= 360;
600
  if (azDiff < -180) azDiff += 360;
601
+
602
  azimuthAngle = startAz + azDiff * ease;
603
  if (azimuthAngle < 0) azimuthAngle += 360;
604
  if (azimuthAngle >= 360) azimuthAngle -= 360;
605
 
606
  elevationAngle = startEl + (targetEl - startEl) * ease;
 
607
  updatePositions();
608
+
609
+ if (t < 1) requestAnimationFrame(animate);
610
  else updatePropsAndTrigger();
611
  }
612
+ animate();
613
  }
614
  isDragging = false;
615
  dragTarget = null;
616
  canvas.style.cursor = 'default';
617
+ }
 
 
 
618
 
619
+ canvas.addEventListener('mousedown', e => handleStart(e.clientX, e.clientY));
620
+ canvas.addEventListener('mousemove', e => handleMove(e.clientX, e.clientY));
621
+ canvas.addEventListener('mouseup', handleEnd);
622
+ canvas.addEventListener('mouseleave', handleEnd);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
623
 
624
+ canvas.addEventListener('touchstart', e => {
625
  e.preventDefault();
626
+ handleStart(e.touches[0].clientX, e.touches[0].clientY);
627
+ }, {passive: false});
628
+ canvas.addEventListener('touchmove', e => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  e.preventDefault();
630
+ handleMove(e.touches[0].clientX, e.touches[0].clientY);
631
+ }, {passive: false});
632
+ canvas.addEventListener('touchend', e => {
 
633
  e.preventDefault();
634
+ handleEnd();
635
+ }, {passive: false});
636
 
637
+ // Initial render
638
  updatePositions();
639
 
 
640
  function render() {
641
  requestAnimationFrame(render);
642
  renderer.render(scene, camera);
643
  }
644
  render();
645
 
646
+ // Resize
647
  new ResizeObserver(() => {
648
  camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
649
  camera.updateProjectionMatrix();
650
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
651
  }).observe(wrapper);
652
 
653
+ // Prop watchers
654
+ wrapper._updateFromProps = (v) => {
655
+ if (v && typeof v === 'object') {
656
+ azimuthAngle = v.azimuth ?? azimuthAngle;
657
+ elevationAngle = v.elevation ?? elevationAngle;
658
  updatePositions();
659
  }
660
  };
 
661
  wrapper._updateTexture = updateTextureFromUrl;
662
 
663
+ // Polling for prop changes
664
+ let lastImg = props.imageUrl;
665
+ let lastVal = JSON.stringify(props.value);
666
  setInterval(() => {
667
+ if (props.imageUrl !== lastImg) {
668
+ lastImg = props.imageUrl;
669
  updateTextureFromUrl(props.imageUrl);
670
  }
671
+ const currVal = JSON.stringify(props.value);
672
+ if (currVal !== lastVal) {
673
+ lastVal = currVal;
674
+ wrapper._updateFromProps(props.value);
 
 
 
 
675
  }
676
  }, 100);
677
  };
 
678
  initScene();
679
  })();
680
  """
 
687
  **kwargs
688
  )
689
 
690
+ # --- GRADIO UI ---
691
  css = '''
692
  #col-container { max-width: 1200px; margin: 0 auto; }
693
  .dark .progress-text { color: white !important; }
 
695
  .slider-row { display: flex; gap: 10px; align-items: center; }
696
  #main-title h1 {font-size: 2.4em !important;}
697
  '''
 
698
  with gr.Blocks(css=css) as demo:
699
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
700
  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).")
 
716
 
717
  azimuth_slider = gr.Slider(
718
  label="Azimuth (Horizontal Rotation)",
719
+ minimum=0, maximum=315, step=45, value=0,
 
 
 
720
  info="0°=front, 90°=right, 180°=rear, 270°=left"
721
  )
722
 
723
  elevation_slider = gr.Slider(
724
  label="Elevation (Vertical Angle)",
725
+ minimum=-90, maximum=90, step=90, value=0,
 
 
 
726
  info="-90°=from below, 0°=horizontal, 90°=from above"
727
  )
728
 
 
745
  height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
746
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
747
 
748
+ # Event Handlers
749
  def update_prompt_from_sliders(azimuth, elevation):
 
750
  prompt = build_lighting_prompt(azimuth, elevation)
751
  return prompt
752
 
753
  def sync_3d_to_sliders(lighting_value):
 
754
  if lighting_value and isinstance(lighting_value, dict):
755
  az = lighting_value.get('azimuth', 0)
756
  el = lighting_value.get('elevation', 0)
 
759
  return gr.update(), gr.update(), gr.update()
760
 
761
  def sync_sliders_to_3d(azimuth, elevation):
 
762
  return {"azimuth": azimuth, "elevation": elevation}
763
 
764
  def update_3d_image(image):
765
+ if image is None: return gr.update(imageUrl=None)
 
 
 
766
  import base64
767
  from io import BytesIO
768
  buffered = BytesIO()
 
811
  fn=lambda: gr.update(imageUrl=None),
812
  outputs=[lighting_3d]
813
  )
814
+
815
  if __name__ == "__main__":
816
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
817
  css = '.fillable{max-width: 1200px !important}'