prithivMLmods commited on
Commit
661a97f
·
verified ·
1 Parent(s): 0f30d6c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +254 -452
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,19 +82,21 @@ class OrangeRedTheme(Soft):
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(
91
- "prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V4",
92
  torch_dtype=dtype,
93
  device_map='cuda'
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,18 @@ 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
 
@@ -150,6 +132,7 @@ def build_lighting_prompt(azimuth: float, elevation: float) -> str:
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,9 +145,6 @@ 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
 
@@ -179,12 +159,17 @@ 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,10 +180,10 @@ 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
@@ -214,29 +199,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: 'Segoe UI', sans-serif; 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: 8px; font-family: 'Segoe UI', 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: #0088ff; border-radius: 50%; display: inline-block;"></span>
237
- <span>Elevation (Height)</span>
238
- </div>
239
- </div>
240
  </div>
241
  """
242
 
@@ -245,141 +219,100 @@ 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);
252
  return;
253
  }
254
 
255
- // Scene setup
256
  const scene = new THREE.Scene();
257
- scene.background = new THREE.Color(0x0d1117);
258
 
259
  const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
260
- camera.position.set(4.5, 3, 4.5);
261
  camera.lookAt(0, 0.75, 0);
262
 
263
- const renderer = new THREE.WebGLRenderer({ antialias: true });
264
  renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
265
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
266
  renderer.shadowMap.enabled = true;
267
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
268
- wrapper.insertBefore(renderer.domElement, wrapper.firstChild);
269
 
270
- // Lighting
271
  scene.add(new THREE.AmbientLight(0xffffff, 0.15));
272
 
273
- // Ground plane for shadows
274
  const ground = new THREE.Mesh(
275
  new THREE.PlaneGeometry(12, 12),
276
- new THREE.ShadowMaterial({ opacity: 0.4 })
277
  );
278
  ground.rotation.x = -Math.PI / 2;
279
  ground.position.y = 0;
280
  ground.receiveShadow = true;
281
  scene.add(ground);
282
 
283
- // Grid with better styling
284
- const gridHelper = new THREE.GridHelper(8, 16, 0x30363d, 0x21262d);
285
- gridHelper.position.y = 0.01;
286
- scene.add(gridHelper);
287
 
288
  // Constants
289
  const CENTER = new THREE.Vector3(0, 0.75, 0);
290
- const BASE_DISTANCE = 2.5;
291
  const AZIMUTH_RADIUS = 2.4;
292
- const ELEVATION_RADIUS = 1.8;
 
 
 
 
293
 
294
- // State
295
  let azimuthAngle = props.value?.azimuth || 0;
296
  let elevationAngle = props.value?.elevation || 0;
297
 
298
- // Mappings
299
- const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
300
- const elevationSteps = [-90, 0, 90];
301
- const azimuthNames = {
302
- 0: 'Front', 45: 'Right Front', 90: 'Right',
303
- 135: 'Right Rear', 180: 'Rear', 225: 'Left Rear',
304
- 270: 'Left', 315: 'Left Front'
305
- };
306
- const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' };
307
-
308
- function snapToNearest(value, steps) {
309
- return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
310
- }
311
-
312
- // Create placeholder texture (smiley face)
313
  function createPlaceholderTexture() {
314
  const canvas = document.createElement('canvas');
315
- canvas.width = 256;
316
- canvas.height = 256;
317
  const ctx = canvas.getContext('2d');
318
-
319
  // Gradient background
320
- const gradient = ctx.createLinearGradient(0, 0, 256, 256);
321
- gradient.addColorStop(0, '#2d333b');
322
- gradient.addColorStop(1, '#22272e');
323
- ctx.fillStyle = gradient;
324
- ctx.fillRect(0, 0, 256, 256);
325
-
326
- // Border
327
- ctx.strokeStyle = '#444c56';
328
- ctx.lineWidth = 4;
329
- ctx.strokeRect(2, 2, 252, 252);
330
 
331
- // Face
332
- ctx.fillStyle = '#ffc68a';
333
  ctx.beginPath();
334
- ctx.arc(128, 128, 70, 0, Math.PI * 2);
335
  ctx.fill();
336
 
337
- // Eyes
338
- ctx.fillStyle = '#1a1a2e';
339
- ctx.beginPath();
340
- ctx.arc(100, 115, 8, 0, Math.PI * 2);
341
- ctx.arc(156, 115, 8, 0, Math.PI * 2);
342
- ctx.fill();
343
-
344
- // Smile
345
- ctx.strokeStyle = '#1a1a2e';
346
- ctx.lineWidth = 4;
347
- ctx.lineCap = 'round';
348
- ctx.beginPath();
349
- ctx.arc(128, 125, 30, 0.3, Math.PI - 0.3);
350
- ctx.stroke();
351
-
352
- // Text
353
- ctx.fillStyle = '#8b949e';
354
- ctx.font = '14px Arial';
355
  ctx.textAlign = 'center';
356
- ctx.fillText('Upload Image', 128, 230);
357
 
358
  return new THREE.CanvasTexture(canvas);
359
  }
360
 
361
- // Target image plane
362
  let currentTexture = createPlaceholderTexture();
363
  const planeMaterial = new THREE.MeshStandardMaterial({
364
  map: currentTexture,
365
  side: THREE.DoubleSide,
366
- roughness: 0.6,
367
  metalness: 0.1
368
  });
369
- let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
 
370
  targetPlane.position.copy(CENTER);
371
  targetPlane.receiveShadow = true;
372
  scene.add(targetPlane);
373
 
374
- // Function to update texture from image URL
375
  function updateTextureFromUrl(url) {
376
  if (!url) {
377
- // Reset to placeholder
378
  planeMaterial.map = createPlaceholderTexture();
379
  planeMaterial.needsUpdate = true;
380
- // Reset plane to square
381
  scene.remove(targetPlane);
382
- targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
383
  targetPlane.position.copy(CENTER);
384
  targetPlane.receiveShadow = true;
385
  scene.add(targetPlane);
@@ -391,122 +324,71 @@ class LightingControl3D(gr.HTML):
391
  loader.load(url, (texture) => {
392
  texture.minFilter = THREE.LinearFilter;
393
  texture.magFilter = THREE.LinearFilter;
 
394
  planeMaterial.map = texture;
395
  planeMaterial.needsUpdate = true;
396
 
397
- // Adjust plane aspect ratio to match image
398
  const img = texture.image;
399
  if (img && img.width && img.height) {
400
  const aspect = img.width / img.height;
401
- const maxSize = 1.5;
402
- let planeWidth, planeHeight;
403
- if (aspect > 1) {
404
- planeWidth = maxSize;
405
- planeHeight = maxSize / aspect;
406
- } else {
407
- planeHeight = maxSize;
408
- planeWidth = maxSize * aspect;
409
- }
410
  scene.remove(targetPlane);
411
- targetPlane = new THREE.Mesh(
412
- new THREE.PlaneGeometry(planeWidth, planeHeight),
413
- planeMaterial
414
- );
415
  targetPlane.position.copy(CENTER);
416
  targetPlane.receiveShadow = true;
417
  scene.add(targetPlane);
418
  }
419
- }, undefined, (err) => {
420
- console.error('Failed to load texture:', err);
421
  });
422
  }
423
 
424
- // Check for initial imageUrl
425
- if (props.imageUrl) {
426
- updateTextureFromUrl(props.imageUrl);
427
- }
428
 
429
- // Create professional softbox light
430
  const lightGroup = new THREE.Group();
431
 
432
- // Softbox frame (RED)
433
- const softboxFrameMat = new THREE.MeshStandardMaterial({
434
- color: 0xcc2222,
435
- roughness: 0.3,
436
- metalness: 0.7
 
437
  });
438
-
439
- // Main softbox body
440
- const softboxBody = new THREE.Mesh(
441
- new THREE.BoxGeometry(0.7, 0.7, 0.15),
442
- softboxFrameMat
443
- );
444
- softboxBody.position.z = 0.05;
445
- lightGroup.add(softboxBody);
446
-
447
- // Softbox rim/bezel
448
- const rimGeometry = new THREE.BoxGeometry(0.75, 0.75, 0.03);
449
- const rimMesh = new THREE.Mesh(rimGeometry, new THREE.MeshStandardMaterial({
450
- color: 0x881111,
451
- roughness: 0.2,
452
- metalness: 0.8
453
- }));
454
- rimMesh.position.z = -0.03;
455
- lightGroup.add(rimMesh);
456
-
457
- // Light diffusion panel (WHITE glow)
458
- const diffuserMat = new THREE.MeshStandardMaterial({
459
- color: 0xffffff,
460
- emissive: 0xffffff,
461
  emissiveIntensity: 1.5,
462
- roughness: 0.9,
463
- metalness: 0,
464
- transparent: true,
465
- opacity: 0.95
466
  });
467
- const diffuser = new THREE.Mesh(
468
- new THREE.PlaneGeometry(0.6, 0.6),
469
- diffuserMat
470
- );
471
- diffuser.position.z = -0.04;
472
  lightGroup.add(diffuser);
473
 
474
- // Inner glow ring
475
- const glowRingMat = new THREE.MeshBasicMaterial({
476
- color: 0xffffee,
477
- transparent: true,
478
- opacity: 0.3
479
- });
480
- const glowRing = new THREE.Mesh(
481
- new THREE.RingGeometry(0.28, 0.35, 32),
482
- glowRingMat
483
- );
484
- glowRing.position.z = -0.05;
485
- lightGroup.add(glowRing);
486
-
487
- // Handle/mount
488
- const handleMat = new THREE.MeshStandardMaterial({
489
- color: 0x333333,
490
- roughness: 0.5,
491
- metalness: 0.6
492
- });
493
- const handle = new THREE.Mesh(
494
- new THREE.CylinderGeometry(0.04, 0.04, 0.3, 12),
495
- handleMat
496
- );
497
- handle.rotation.x = Math.PI / 2;
498
- handle.position.z = 0.2;
499
- lightGroup.add(handle);
500
-
501
- // Spotlight (WHITE light)
502
- const spotLight = new THREE.SpotLight(0xffffff, 12, 12, Math.PI / 3, 0.8, 1);
503
- spotLight.position.set(0, 0, -0.05);
504
  spotLight.castShadow = true;
505
  spotLight.shadow.mapSize.width = 1024;
506
  spotLight.shadow.mapSize.height = 1024;
507
- spotLight.shadow.camera.near = 0.5;
508
- spotLight.shadow.camera.far = 500;
509
- spotLight.shadow.bias = -0.005;
510
  lightGroup.add(spotLight);
511
 
512
  const lightTarget = new THREE.Object3D();
@@ -516,107 +398,92 @@ class LightingControl3D(gr.HTML):
516
 
517
  scene.add(lightGroup);
518
 
519
- // YELLOW: Azimuth ring
520
  const azimuthRing = new THREE.Mesh(
521
- new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.05, 16, 64),
522
  new THREE.MeshStandardMaterial({
523
- color: 0xffd700,
524
- emissive: 0xffd700,
525
- emissiveIntensity: 0.4,
526
- roughness: 0.3,
527
- metalness: 0.6
528
  })
529
  );
530
  azimuthRing.rotation.x = Math.PI / 2;
531
  azimuthRing.position.y = 0.05;
532
  scene.add(azimuthRing);
533
 
534
- // Azimuth handle (YELLOW)
535
  const azimuthHandle = new THREE.Mesh(
536
- new THREE.SphereGeometry(0.2, 24, 24),
537
  new THREE.MeshStandardMaterial({
538
- color: 0xffd700,
539
- emissive: 0xffd700,
540
- emissiveIntensity: 0.5,
541
- roughness: 0.2,
542
- metalness: 0.7
543
  })
544
  );
545
  azimuthHandle.userData.type = 'azimuth';
546
  scene.add(azimuthHandle);
547
 
548
- // BLUE: Elevation arc
549
  const arcPoints = [];
550
- for (let i = 0; i <= 32; i++) {
551
- const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 32));
552
- arcPoints.push(new THREE.Vector3(-0.8, ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, ELEVATION_RADIUS * Math.cos(angle)));
553
  }
554
  const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
555
  const elevationArc = new THREE.Mesh(
556
- new THREE.TubeGeometry(arcCurve, 32, 0.05, 8, false),
557
  new THREE.MeshStandardMaterial({
558
- color: 0x0088ff,
559
- emissive: 0x0088ff,
560
- emissiveIntensity: 0.4,
561
- roughness: 0.3,
562
- metalness: 0.6
563
  })
564
  );
565
  scene.add(elevationArc);
566
 
567
- // Elevation handle (BLUE)
568
  const elevationHandle = new THREE.Mesh(
569
- new THREE.SphereGeometry(0.2, 24, 24),
570
  new THREE.MeshStandardMaterial({
571
- color: 0x0088ff,
572
- emissive: 0x0088ff,
573
- emissiveIntensity: 0.5,
574
- roughness: 0.2,
575
- metalness: 0.7
576
  })
577
  );
578
  elevationHandle.userData.type = 'elevation';
579
  scene.add(elevationHandle);
580
 
581
- // Refresh button (proper button style)
582
  const refreshBtn = document.createElement('button');
583
- 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="M23 4v6h-6"></path><path d="M1 20v-6h6"></path><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg><span style="margin-left: 6px;">Reset</span>';
584
- refreshBtn.style.cssText = `
585
- position: absolute;
586
- top: 10px;
587
- right: 10px;
588
- background: linear-gradient(135deg, #238636 0%, #2ea043 100%);
589
- color: white;
590
- border: none;
591
- padding: 8px 14px;
592
- border-radius: 8px;
593
- cursor: pointer;
594
- z-index: 10;
595
- font-size: 12px;
596
- font-family: 'Segoe UI', sans-serif;
597
- font-weight: 600;
598
- display: flex;
599
- align-items: center;
600
- justify-content: center;
601
- box-shadow: 0 4px 12px rgba(35, 134, 54, 0.4);
602
- transition: all 0.2s ease;
603
- `;
604
- refreshBtn.onmouseenter = () => {
605
- refreshBtn.style.background = 'linear-gradient(135deg, #2ea043 0%, #3fb950 100%)';
606
- refreshBtn.style.transform = 'translateY(-1px)';
607
- refreshBtn.style.boxShadow = '0 6px 16px rgba(35, 134, 54, 0.5)';
608
- };
609
- refreshBtn.onmouseleave = () => {
610
- refreshBtn.style.background = 'linear-gradient(135deg, #238636 0%, #2ea043 100%)';
611
- refreshBtn.style.transform = 'translateY(0)';
612
- refreshBtn.style.boxShadow = '0 4px 12px rgba(35, 134, 54, 0.4)';
613
- };
614
- refreshBtn.onmousedown = () => {
615
- refreshBtn.style.transform = 'translateY(1px)';
616
- };
617
- refreshBtn.onmouseup = () => {
618
- refreshBtn.style.transform = 'translateY(-1px)';
619
- };
620
  wrapper.appendChild(refreshBtn);
621
 
622
  refreshBtn.addEventListener('click', () => {
@@ -626,11 +493,20 @@ class LightingControl3D(gr.HTML):
626
  updatePropsAndTrigger();
627
  });
628
 
 
 
 
 
 
 
 
 
629
  function updatePositions() {
630
  const distance = BASE_DISTANCE;
631
  const azRad = THREE.MathUtils.degToRad(azimuthAngle);
632
  const elRad = THREE.MathUtils.degToRad(elevationAngle);
633
 
 
634
  const lightX = distance * Math.sin(azRad) * Math.cos(elRad);
635
  const lightY = distance * Math.sin(elRad) + CENTER.y;
636
  const lightZ = distance * Math.cos(azRad) * Math.cos(elRad);
@@ -638,244 +514,181 @@ class LightingControl3D(gr.HTML):
638
  lightGroup.position.set(lightX, lightY, lightZ);
639
  lightGroup.lookAt(CENTER);
640
 
641
- azimuthHandle.position.set(AZIMUTH_RADIUS * Math.sin(azRad), 0.05, AZIMUTH_RADIUS * Math.cos(azRad));
642
- elevationHandle.position.set(-0.8, ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y, ELEVATION_RADIUS * Math.cos(elRad));
 
 
 
 
 
 
 
 
 
 
643
 
644
- // Update prompt
645
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
646
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
647
- let prompt = '💡 Light source from';
648
- if (elSnap !== 0) {
649
- prompt += ' ' + elevationNames[String(elSnap)];
650
- } else {
651
- prompt += ' the ' + azimuthNames[azSnap];
652
- }
653
- promptOverlay.textContent = prompt;
654
  }
655
 
656
  function updatePropsAndTrigger() {
657
  const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
658
  const elSnap = snapToNearest(elevationAngle, elevationSteps);
659
-
660
  props.value = { azimuth: azSnap, elevation: elSnap };
661
  trigger('change', props.value);
662
  }
663
 
664
- // Raycasting
665
  const raycaster = new THREE.Raycaster();
666
  const mouse = new THREE.Vector2();
667
  let isDragging = false;
668
  let dragTarget = null;
669
- let dragStartMouse = new THREE.Vector2();
670
  const intersection = new THREE.Vector3();
671
-
672
  const canvas = renderer.domElement;
673
 
674
- canvas.addEventListener('mousedown', (e) => {
675
  const rect = canvas.getBoundingClientRect();
676
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
677
- mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
678
-
 
 
 
 
 
 
679
  raycaster.setFromCamera(mouse, camera);
680
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
681
-
682
  if (intersects.length > 0) {
683
  isDragging = true;
684
  dragTarget = intersects[0].object;
685
- dragTarget.material.emissiveIntensity = 1.2;
686
  dragTarget.scale.setScalar(1.3);
687
- dragStartMouse.copy(mouse);
688
  canvas.style.cursor = 'grabbing';
689
  }
690
- });
691
-
692
- canvas.addEventListener('mousemove', (e) => {
693
- const rect = canvas.getBoundingClientRect();
694
- mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
695
- mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
696
 
697
  if (isDragging && dragTarget) {
698
  raycaster.setFromCamera(mouse, camera);
699
-
700
  if (dragTarget.userData.type === 'azimuth') {
701
  const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
702
  if (raycaster.ray.intersectPlane(plane, intersection)) {
703
  azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
704
  if (azimuthAngle < 0) azimuthAngle += 360;
705
  }
706
- } else if (dragTarget.userData.type === 'elevation') {
707
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
708
  if (raycaster.ray.intersectPlane(plane, intersection)) {
709
  const relY = intersection.y - CENTER.y;
710
- const relZ = intersection.z;
711
- elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
 
 
712
  }
713
  }
714
  updatePositions();
715
  } else {
 
716
  raycaster.setFromCamera(mouse, camera);
717
  const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
 
718
  [azimuthHandle, elevationHandle].forEach(h => {
719
- h.material.emissiveIntensity = 0.5;
720
- h.scale.setScalar(1);
 
 
721
  });
722
  if (intersects.length > 0) {
723
- intersects[0].object.material.emissiveIntensity = 0.8;
724
- intersects[0].object.scale.setScalar(1.15);
725
- canvas.style.cursor = 'grab';
726
- } else {
727
- canvas.style.cursor = 'default';
728
  }
729
  }
730
- });
731
 
732
- const onMouseUp = () => {
733
  if (dragTarget) {
734
- dragTarget.material.emissiveIntensity = 0.5;
735
  dragTarget.scale.setScalar(1);
736
-
737
- // Snap and animate
738
  const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
739
  const targetEl = snapToNearest(elevationAngle, elevationSteps);
740
-
741
- const startAz = azimuthAngle, startEl = elevationAngle;
742
  const startTime = Date.now();
743
 
744
- function animateSnap() {
745
- const t = Math.min((Date.now() - startTime) / 200, 1);
746
  const ease = 1 - Math.pow(1 - t, 3);
747
 
 
748
  let azDiff = targetAz - startAz;
749
  if (azDiff > 180) azDiff -= 360;
750
  if (azDiff < -180) azDiff += 360;
 
751
  azimuthAngle = startAz + azDiff * ease;
752
  if (azimuthAngle < 0) azimuthAngle += 360;
753
  if (azimuthAngle >= 360) azimuthAngle -= 360;
754
 
755
  elevationAngle = startEl + (targetEl - startEl) * ease;
756
-
757
  updatePositions();
758
- if (t < 1) requestAnimationFrame(animateSnap);
 
759
  else updatePropsAndTrigger();
760
  }
761
- animateSnap();
762
  }
763
  isDragging = false;
764
  dragTarget = null;
765
  canvas.style.cursor = 'default';
766
  };
767
 
768
- canvas.addEventListener('mouseup', onMouseUp);
769
- canvas.addEventListener('mouseleave', onMouseUp);
770
-
771
- // Touch support for mobile
772
- canvas.addEventListener('touchstart', (e) => {
773
- e.preventDefault();
774
- const touch = e.touches[0];
775
- const rect = canvas.getBoundingClientRect();
776
- mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
777
- mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
778
-
779
- raycaster.setFromCamera(mouse, camera);
780
- const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
781
-
782
- if (intersects.length > 0) {
783
- isDragging = true;
784
- dragTarget = intersects[0].object;
785
- dragTarget.material.emissiveIntensity = 1.2;
786
- dragTarget.scale.setScalar(1.3);
787
- dragStartMouse.copy(mouse);
788
- }
789
- }, { passive: false });
790
-
791
- canvas.addEventListener('touchmove', (e) => {
792
- e.preventDefault();
793
- const touch = e.touches[0];
794
- const rect = canvas.getBoundingClientRect();
795
- mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
796
- mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
797
-
798
- if (isDragging && dragTarget) {
799
- raycaster.setFromCamera(mouse, camera);
800
-
801
- if (dragTarget.userData.type === 'azimuth') {
802
- const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
803
- if (raycaster.ray.intersectPlane(plane, intersection)) {
804
- azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
805
- if (azimuthAngle < 0) azimuthAngle += 360;
806
- }
807
- } else if (dragTarget.userData.type === 'elevation') {
808
- const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
809
- if (raycaster.ray.intersectPlane(plane, intersection)) {
810
- const relY = intersection.y - CENTER.y;
811
- const relZ = intersection.z;
812
- elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
813
- }
814
- }
815
- updatePositions();
816
- }
817
- }, { passive: false });
818
-
819
- canvas.addEventListener('touchend', (e) => {
820
- e.preventDefault();
821
- onMouseUp();
822
- }, { passive: false });
823
 
824
- canvas.addEventListener('touchcancel', (e) => {
825
- e.preventDefault();
826
- onMouseUp();
827
- }, { passive: false });
828
 
829
- // Initial update
830
  updatePositions();
831
 
832
- // Render loop
833
- function render() {
834
- requestAnimationFrame(render);
835
  renderer.render(scene, camera);
836
- }
837
- render();
838
 
839
- // Handle resize
840
- new ResizeObserver(() => {
841
- camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
842
- camera.updateProjectionMatrix();
843
- renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
844
- }).observe(wrapper);
 
 
 
845
 
846
- // Store update functions for external calls
847
  wrapper._updateFromProps = (newVal) => {
848
- if (newVal && typeof newVal === 'object') {
849
  azimuthAngle = newVal.azimuth ?? azimuthAngle;
850
  elevationAngle = newVal.elevation ?? elevationAngle;
851
  updatePositions();
852
  }
853
  };
854
-
855
  wrapper._updateTexture = updateTextureFromUrl;
856
-
857
- // Watch for prop changes (imageUrl and value)
858
- let lastImageUrl = props.imageUrl;
859
- let lastValue = JSON.stringify(props.value);
860
- setInterval(() => {
861
- // Check imageUrl changes
862
- if (props.imageUrl !== lastImageUrl) {
863
- lastImageUrl = props.imageUrl;
864
- updateTextureFromUrl(props.imageUrl);
865
- }
866
- // Check value changes (from sliders)
867
- const currentValue = JSON.stringify(props.value);
868
- if (currentValue !== lastValue) {
869
- lastValue = currentValue;
870
- if (props.value && typeof props.value === 'object') {
871
- azimuthAngle = props.value.azimuth ?? azimuthAngle;
872
- elevationAngle = props.value.elevation ?? elevationAngle;
873
- updatePositions();
874
- }
875
- }
876
- }, 100);
877
  };
878
-
879
  initScene();
880
  })();
881
  """
@@ -888,6 +701,7 @@ class LightingControl3D(gr.HTML):
888
  **kwargs
889
  )
890
 
 
891
  css = '''
892
  #col-container { max-width: 1200px; margin: 0 auto; }
893
  .dark .progress-text { color: white !important; }
@@ -895,16 +709,17 @@ css = '''
895
  .slider-row { display: flex; gap: 10px; align-items: center; }
896
  #main-title h1 {font-size: 2.4em !important;}
897
  '''
 
898
  with gr.Blocks(css=css) as demo:
899
  gr.Markdown("# **Qwen-Image-Edit-2511-3D-Lighting-Control**", elem_id="main-title")
900
- 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).")
901
 
902
  with gr.Row():
903
  with gr.Column(scale=1):
904
  image = gr.Image(label="Input Image", type="pil", height=300)
905
 
906
  gr.Markdown("### 3D Lighting Control")
907
- gr.Markdown("*Drag the colored handles: 🟡 Azimuth (Direction), 🔵 Elevation (Height)*")
908
 
909
  lighting_3d = LightingControl3D(
910
  value={"azimuth": 0, "elevation": 0},
@@ -916,29 +731,22 @@ with gr.Blocks(css=css) as demo:
916
 
917
  azimuth_slider = gr.Slider(
918
  label="Azimuth (Horizontal Rotation)",
919
- minimum=0,
920
- maximum=315,
921
- step=45,
922
- value=0,
923
  info="0°=front, 90°=right, 180°=rear, 270°=left"
924
  )
925
 
926
  elevation_slider = gr.Slider(
927
  label="Elevation (Vertical Angle)",
928
- minimum=-90,
929
- maximum=90,
930
- step=90,
931
- value=0,
932
  info="-90°=from below, 0°=horizontal, 90°=from above"
933
  )
934
 
935
- with gr.Row():
936
- prompt_preview = gr.Textbox(
937
- label="Generated Prompt",
938
- value="Light source from the Front",
939
- interactive=True,
940
- lines=1,
941
- )
942
 
943
  with gr.Column(scale=1):
944
  result = gr.Image(label="Output Image", height=500)
@@ -951,13 +759,11 @@ with gr.Blocks(css=css) as demo:
951
  height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
952
  width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
953
 
 
954
  def update_prompt_from_sliders(azimuth, elevation):
955
- """Update prompt preview when sliders change."""
956
- prompt = build_lighting_prompt(azimuth, elevation)
957
- return prompt
958
 
959
  def sync_3d_to_sliders(lighting_value):
960
- """Sync 3D control changes to sliders."""
961
  if lighting_value and isinstance(lighting_value, dict):
962
  az = lighting_value.get('azimuth', 0)
963
  el = lighting_value.get('elevation', 0)
@@ -966,14 +772,11 @@ with gr.Blocks(css=css) as demo:
966
  return gr.update(), gr.update(), gr.update()
967
 
968
  def sync_sliders_to_3d(azimuth, elevation):
969
- """Sync slider changes to 3D control."""
970
  return {"azimuth": azimuth, "elevation": elevation}
971
 
972
  def update_3d_image(image):
973
- """Update the 3D component with the uploaded image."""
974
  if image is None:
975
  return gr.update(imageUrl=None)
976
-
977
  import base64
978
  from io import BytesIO
979
  buffered = BytesIO()
@@ -1022,8 +825,7 @@ with gr.Blocks(css=css) as demo:
1022
  fn=lambda: gr.update(imageUrl=None),
1023
  outputs=[lighting_3d]
1024
  )
1025
-
1026
  if __name__ == "__main__":
1027
  head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
1028
- css = '.fillable{max-width: 1200px !important}'
1029
- demo.launch(head=head, css=css, 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
+ # --- Theme Configuration ---
17
  colors.orange_red = colors.Color(
18
  name="orange_red",
19
  c50="#FFF0E5",
 
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(
93
+ "prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V19",
94
  torch_dtype=dtype,
95
  device_map='cuda'
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
+ # --- 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
  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
  height: int = 1024,
146
  width: int = 1024,
147
  ):
 
 
 
148
  global loaded
149
  progress = gr.Progress(track_tqdm=True)
150
 
 
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
  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
  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
  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
  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
 
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
 
489
  refreshBtn.addEventListener('click', () => {
 
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
  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
  **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
  .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
 
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
  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
  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
  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)