prithivMLmods commited on
Commit
eab7e74
·
verified ·
1 Parent(s): a5fedaf

update app

Browse files
Files changed (1) hide show
  1. app.py +1039 -732
app.py CHANGED
@@ -4,7 +4,7 @@ import random
4
  import torch
5
  import spaces
6
  from typing import Iterable
7
- from PIL import Image, ImageDraw
8
  from diffusers import FlowMatchEulerDiscreteScheduler
9
  from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline
10
  from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel
@@ -28,7 +28,6 @@ colors.purple = colors.Color(
28
  c950="#3B0764",
29
  )
30
 
31
-
32
  class PurpleTheme(Soft):
33
  def __init__(
34
  self,
@@ -80,7 +79,6 @@ class PurpleTheme(Soft):
80
  block_label_background_fill="*primary_200",
81
  )
82
 
83
-
84
  purple_theme = PurpleTheme()
85
 
86
  MAX_SEED = np.iinfo(np.int32).max
@@ -92,9 +90,9 @@ pipe = QwenImageEditPlusPipeline.from_pretrained(
92
  transformer=QwenImageTransformer2DModel.from_pretrained(
93
  "prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V4",
94
  torch_dtype=dtype,
95
- device_map="cuda",
96
  ),
97
- torch_dtype=dtype,
98
  ).to(device)
99
  try:
100
  pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
@@ -103,52 +101,52 @@ except Exception as e:
103
  print(f"Warning: Could not set FA3 processor: {e}")
104
 
105
  ADAPTER_SPECS = {
106
- "Object-Remover": {
107
- "repo": "prithivMLmods/QIE-2509-Object-Remover-Bbox",
108
- "weights": "QIE-2509-Object-Remover-Bbox-5000.safetensors",
109
- "adapter_name": "object-remover",
110
  },
111
  }
112
  loaded = False
113
 
114
- DEFAULT_PROMPT = "Remove the red highlighted object from the scene"
115
-
116
-
117
- def burn_boxes_onto_image(pil_image: Image.Image, boxes_json_str: str) -> Image.Image:
118
- """Burn red outline-only rectangles onto the image (no fill)."""
119
- import json
120
- if not pil_image:
121
- return pil_image
122
- try:
123
- boxes = json.loads(boxes_json_str) if boxes_json_str and boxes_json_str.strip() else []
124
- except Exception:
125
- boxes = []
126
- if not boxes:
127
- return pil_image
128
-
129
- img = pil_image.copy().convert("RGB")
130
- w, h = img.size
131
- draw = ImageDraw.Draw(img)
132
- bw = max(3, w // 250)
133
-
134
- for b in boxes:
135
- x1 = int(b["x1"] * w)
136
- y1 = int(b["y1"] * h)
137
- x2 = int(b["x2"] * w)
138
- y2 = int(b["y2"] * h)
139
- lx, rx = min(x1, x2), max(x1, x2)
140
- ty, by_ = min(y1, y2), max(y1, y2)
141
- # Red outline only — no fill
142
- draw.rectangle([lx, ty, rx, by_], outline=(255, 0, 0), width=bw)
143
 
144
- return img
 
 
 
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  @spaces.GPU
148
- def infer_object_removal(
149
- source_image: Image.Image,
150
- boxes_json: str,
151
- prompt: str,
152
  seed: int = 0,
153
  randomize_seed: bool = True,
154
  guidance_scale: float = 1.0,
@@ -158,47 +156,29 @@ def infer_object_removal(
158
  ):
159
  global loaded
160
  progress = gr.Progress(track_tqdm=True)
161
-
162
  if not loaded:
163
  pipe.load_lora_weights(
164
- ADAPTER_SPECS["Object-Remover"]["repo"],
165
- weight_name=ADAPTER_SPECS["Object-Remover"]["weights"],
166
- adapter_name=ADAPTER_SPECS["Object-Remover"]["adapter_name"],
167
- )
168
- pipe.set_adapters(
169
- [ADAPTER_SPECS["Object-Remover"]["adapter_name"]], adapter_weights=[1.0]
170
  )
 
171
  loaded = True
 
 
 
172
 
173
- if not prompt or prompt.strip() == "":
174
- prompt = DEFAULT_PROMPT
175
- print(f"Prompt: {prompt}")
176
- print(f"Boxes JSON received: '{boxes_json}'")
177
-
178
- if source_image is None:
179
- raise gr.Error("Please upload an image first.")
180
-
181
- import json
182
- try:
183
- boxes = json.loads(boxes_json) if boxes_json and boxes_json.strip() else []
184
- except Exception as e:
185
- print(f"JSON parse error: {e}")
186
- boxes = []
187
-
188
- if not boxes:
189
- raise gr.Error("Please draw at least one bounding box on the image.")
190
-
191
- progress(0.3, desc="Burning red boxes onto image...")
192
- marked = burn_boxes_onto_image(source_image, boxes_json)
193
-
194
- progress(0.5, desc="Running object removal inference...")
195
 
196
  if randomize_seed:
197
  seed = random.randint(0, MAX_SEED)
198
  generator = torch.Generator(device=device).manual_seed(seed)
199
-
 
 
200
  result = pipe(
201
- image=[marked],
202
  prompt=prompt,
203
  height=height if height != 0 else None,
204
  width=width if width != 0 else None,
@@ -207,9 +187,7 @@ def infer_object_removal(
207
  guidance_scale=guidance_scale,
208
  num_images_per_prompt=1,
209
  ).images[0]
210
-
211
- return result, seed, marked
212
-
213
 
214
  def update_dimensions_on_upload(image):
215
  if image is None:
@@ -227,703 +205,1032 @@ def update_dimensions_on_upload(image):
227
  new_height = (new_height // 8) * 8
228
  return new_width, new_height
229
 
230
-
231
- css = r"""
232
- @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
233
- body,.gradio-container{background-color:#FAF5FF!important;background-image:linear-gradient(#E9D5FF 1px,transparent 1px),linear-gradient(90deg,#E9D5FF 1px,transparent 1px)!important;background-size:40px 40px!important;font-family:'Outfit',sans-serif!important}
234
- .dark body,.dark .gradio-container{background-color:#1a1a1a!important;background-image:linear-gradient(rgba(168,85,247,.1) 1px,transparent 1px),linear-gradient(90deg,rgba(168,85,247,.1) 1px,transparent 1px)!important;background-size:40px 40px!important}
235
- #col-container{margin:0 auto;max-width:1200px}
236
- #main-title{text-align:center!important;padding:1rem 0 .5rem 0}
237
- #main-title h1{font-size:2.4em!important;font-weight:700!important;background:linear-gradient(135deg,#A855F7 0%,#C084FC 50%,#9333EA 100%);background-size:200% 200%;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;animation:gradient-shift 4s ease infinite;letter-spacing:-.02em}
238
- @keyframes gradient-shift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
239
- #subtitle{text-align:center!important;margin-bottom:1.5rem}
240
- #subtitle p{margin:0 auto;color:#666;font-size:1rem;text-align:center!important}
241
- #subtitle a{color:#A855F7!important;text-decoration:none;font-weight:500}
242
- #subtitle a:hover{text-decoration:underline}
243
- .gradio-group{background:rgba(255,255,255,.9)!important;border:2px solid #E9D5FF!important;border-radius:12px!important;box-shadow:0 4px 24px rgba(168,85,247,.08)!important;backdrop-filter:blur(10px);transition:all .3s ease}
244
- .gradio-group:hover{box-shadow:0 8px 32px rgba(168,85,247,.12)!important;border-color:#C084FC!important}
245
- .dark .gradio-group{background:rgba(30,30,30,.9)!important;border-color:rgba(168,85,247,.3)!important}
246
- .primary{border-radius:8px!important;font-weight:600!important;letter-spacing:.02em!important;transition:all .3s ease!important}
247
- .primary:hover{transform:translateY(-2px)!important}
248
- .gradio-textbox textarea{font-family:'IBM Plex Mono',monospace!important;font-size:.95rem!important;line-height:1.7!important;background:rgba(255,255,255,.95)!important;border:1px solid #E9D5FF!important;border-radius:8px!important}
249
- .gradio-accordion{border-radius:10px!important;border:1px solid #E9D5FF!important}
250
- .gradio-accordion>.label-wrap{background:rgba(168,85,247,.03)!important;border-radius:10px!important}
251
- footer{display:none!important}
252
- @keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
253
- .gradio-row{animation:fadeIn .4s ease-out}
254
- label{font-weight:600!important;color:#333!important}
255
- .dark label{color:#eee!important}
256
- .gradio-slider input[type="range"]{accent-color:#A855F7!important}
257
- ::-webkit-scrollbar{width:8px;height:8px}
258
- ::-webkit-scrollbar-track{background:rgba(168,85,247,.05);border-radius:4px}
259
- ::-webkit-scrollbar-thumb{background:linear-gradient(135deg,#A855F7,#C084FC);border-radius:4px}
260
- ::-webkit-scrollbar-thumb:hover{background:linear-gradient(135deg,#9333EA,#A855F7)}
261
-
262
- #bbox-draw-wrap{position:relative;border:2px solid #C084FC;border-radius:12px;overflow:hidden;background:#1a1a1a;min-height:420px}
263
- #bbox-draw-wrap:hover{border-color:#A855F7}
264
- #bbox-draw-canvas{cursor:crosshair;display:block;margin:0 auto}
265
- .bbox-hint{background:rgba(168,85,247,.08);border:1px solid #E9D5FF;border-radius:8px;padding:10px 16px;margin:8px 0;font-size:.9rem;color:#6B21A8}
266
- .dark .bbox-hint{background:rgba(168,85,247,.15);border-color:rgba(168,85,247,.3);color:#C084FC}
267
-
268
- .bbox-toolbar-section{
269
- display:flex;
270
- gap:8px;
271
- flex-wrap:wrap;
272
- justify-content:center;
273
- align-items:center;
274
- padding:12px 16px;
275
- margin-top:10px;
276
- background:rgba(255,255,255,.92);
277
- border:2px solid #E9D5FF;
278
- border-radius:10px;
279
- box-shadow:0 2px 12px rgba(168,85,247,.08);
280
- }
281
- .dark .bbox-toolbar-section{
282
- background:rgba(30,30,30,.9);
283
- border-color:rgba(168,85,247,.3);
284
- }
285
- .bbox-toolbar-section .toolbar-label{
286
- font-family:'Outfit',sans-serif;
287
- font-weight:600;
288
- font-size:13px;
289
- color:#6B21A8;
290
- margin-right:6px;
291
- user-select:none;
292
- }
293
- .dark .bbox-toolbar-section .toolbar-label{color:#C084FC}
294
- .bbox-toolbar-section .toolbar-divider{
295
- width:1px;
296
- height:28px;
297
- background:#E9D5FF;
298
- margin:0 4px;
299
- }
300
- .dark .bbox-toolbar-section .toolbar-divider{background:rgba(168,85,247,.3)}
301
- .bbox-toolbar-section button{
302
- color:#fff;
303
- border:none;
304
- padding:7px 15px;
305
- border-radius:7px;
306
- cursor:pointer;
307
- font-family:'Outfit',sans-serif;
308
- font-weight:600;
309
- font-size:13px;
310
- box-shadow:0 2px 5px rgba(0,0,0,.15);
311
- transition:background .2s,transform .15s,box-shadow .2s;
312
- }
313
- .bbox-toolbar-section button:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(0,0,0,.2)}
314
- .bbox-toolbar-section button:active{transform:translateY(0)}
315
- .bbox-tb-draw{background:#9333EA}
316
- .bbox-tb-draw:hover{background:#A855F7}
317
- .bbox-tb-draw.active{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.5)}
318
- .bbox-tb-select{background:#6366f1}
319
- .bbox-tb-select:hover{background:#818cf8}
320
- .bbox-tb-select.active{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.5)}
321
- .bbox-tb-del{background:#dc2626}
322
- .bbox-tb-del:hover{background:#ef4444}
323
- .bbox-tb-undo{background:#7E22CE}
324
- .bbox-tb-undo:hover{background:#9333EA}
325
- .bbox-tb-clear{background:#be123c}
326
- .bbox-tb-clear:hover{background:#e11d48}
327
-
328
- #bbox-status{position:absolute;top:10px;left:10px;background:rgba(0,0,0,.75);color:#00ff88;padding:5px 10px;border-radius:6px;font-family:'IBM Plex Mono',monospace;font-size:11px;z-index:10;display:none;pointer-events:none}
329
- #bbox-count{position:absolute;top:10px;right:10px;background:rgba(147,51,234,.85);color:#fff;padding:4px 10px;border-radius:6px;font-family:'IBM Plex Mono',monospace;font-size:11px;z-index:10;display:none}
330
-
331
- #bbox-debug-count{
332
- text-align:center;
333
- padding:6px 12px;
334
- margin-top:6px;
335
- font-family:'IBM Plex Mono',monospace;
336
- font-size:12px;
337
- color:#6B21A8;
338
- background:rgba(168,85,247,.06);
339
- border:1px dashed #C084FC;
340
- border-radius:6px;
341
- }
342
- .dark #bbox-debug-count{color:#C084FC;background:rgba(168,85,247,.12)}
343
-
344
- #boxes-json-input{
345
- max-height:0!important;
346
- overflow:hidden!important;
347
- margin:0!important;
348
- padding:0!important;
349
- opacity:0!important;
350
- pointer-events:none!important;
351
- position:absolute!important;
352
- z-index:-1!important;
353
- }
354
- """
355
-
356
- bbox_drawer_js = r"""
357
- () => {
358
- function initCanvasBbox() {
359
- if (window.__bboxInitDone) return;
360
-
361
- const canvas = document.getElementById('bbox-draw-canvas');
362
- const wrap = document.getElementById('bbox-draw-wrap');
363
- const status = document.getElementById('bbox-status');
364
- const badge = document.getElementById('bbox-count');
365
- const debugCount = document.getElementById('bbox-debug-count');
366
-
367
- const btnDraw = document.getElementById('tb-draw');
368
- const btnSelect = document.getElementById('tb-select');
369
- const btnDel = document.getElementById('tb-del');
370
- const btnUndo = document.getElementById('tb-undo');
371
- const btnClear = document.getElementById('tb-clear');
372
-
373
- if (!canvas || !wrap || !debugCount || !btnDraw) {
374
- console.log('[BBox] waiting for DOM...');
375
- setTimeout(initCanvasBbox, 250);
376
- return;
377
- }
378
-
379
- window.__bboxInitDone = true;
380
- console.log('[BBox] canvas init OK');
381
- const ctx = canvas.getContext('2d');
382
-
383
- let boxes = [];
384
- window.__bboxBoxes = boxes;
385
-
386
- let baseImg = null;
387
- let dispW = 512, dispH = 400;
388
- let selectedIdx = -1;
389
- let mode = 'draw';
390
-
391
- let dragging = false;
392
- let dragType = null;
393
- let dragStart = {x:0, y:0};
394
- let dragOrig = null;
395
- const HANDLE = 7;
396
- const RED_STROKE = 'rgba(255,0,0,0.95)';
397
- const RED_STROKE_WIDTH = 3;
398
- const SEL_STROKE = 'rgba(0,120,255,0.95)';
399
-
400
- function n2px(b) {
401
- return {x1:b.x1*dispW, y1:b.y1*dispH, x2:b.x2*dispW, y2:b.y2*dispH};
402
- }
403
- function px2n(x1,y1,x2,y2) {
404
- return {
405
- x1: Math.min(x1,x2)/dispW, y1: Math.min(y1,y2)/dispH,
406
- x2: Math.max(x1,x2)/dispW, y2: Math.max(y1,y2)/dispH
407
- };
408
- }
409
- function clamp01(v){return Math.max(0,Math.min(1,v));}
410
- function fitSize(nw, nh) {
411
- const mw = wrap.clientWidth || 512, mh = 500;
412
- const r = Math.min(mw/nw, mh/nh, 1);
413
- dispW = Math.round(nw*r); dispH = Math.round(nh*r);
414
- canvas.width = dispW; canvas.height = dispH;
415
- canvas.style.width = dispW+'px';
416
- canvas.style.height = dispH+'px';
417
- }
418
- function canvasXY(e) {
419
- const r = canvas.getBoundingClientRect();
420
- const cx = e.touches ? e.touches[0].clientX : e.clientX;
421
- const cy = e.touches ? e.touches[0].clientY : e.clientY;
422
- return {x: Math.max(0,Math.min(dispW, cx-r.left)),
423
- y: Math.max(0,Math.min(dispH, cy-r.top))};
424
- }
425
-
426
- function syncToGradio() {
427
- window.__bboxBoxes = boxes;
428
- const jsonStr = JSON.stringify(boxes);
429
-
430
- if (debugCount) {
431
- debugCount.textContent = boxes.length > 0
432
- ? '\u2705 ' + boxes.length + ' box' + (boxes.length > 1 ? 'es' : '') +
433
- ' ready | JSON: ' + jsonStr.substring(0,80) +
434
- (jsonStr.length > 80 ? '\u2026' : '')
435
- : '\u2B1C No boxes drawn yet';
436
- }
437
-
438
- const container = document.getElementById('boxes-json-input');
439
- if (!container) {
440
- console.warn('[BBox] #boxes-json-input not in DOM');
441
- return;
442
- }
443
- const targets = [
444
- ...container.querySelectorAll('textarea'),
445
- ...container.querySelectorAll('input[type="text"]'),
446
- ...container.querySelectorAll('input:not([type])')
447
- ];
448
- targets.forEach(el => {
449
- const proto = el.tagName === 'TEXTAREA'
450
- ? HTMLTextAreaElement.prototype
451
- : HTMLInputElement.prototype;
452
- const ns = Object.getOwnPropertyDescriptor(proto, 'value');
453
- if (ns && ns.set) {
454
- ns.set.call(el, jsonStr);
455
- el.dispatchEvent(new Event('input', {bubbles:true, composed:true}));
456
- el.dispatchEvent(new Event('change', {bubbles:true, composed:true}));
457
- el.dispatchEvent(new Event('blur', {bubbles:true, composed:true}));
458
  }
459
- });
460
- }
461
-
462
- function placeholder() {
463
- ctx.fillStyle='#2a2a2a'; ctx.fillRect(0,0,dispW,dispH);
464
- ctx.strokeStyle='#444'; ctx.lineWidth=2; ctx.setLineDash([8,4]);
465
- ctx.strokeRect(20,20,dispW-40,dispH-40); ctx.setLineDash([]);
466
- ctx.fillStyle='#888'; ctx.font='16px Outfit,sans-serif';
467
- ctx.textAlign='center'; ctx.textBaseline='middle';
468
- ctx.fillText('Upload an image above first',dispW/2,dispH/2-10);
469
- ctx.font='13px Outfit'; ctx.fillStyle='#666';
470
- ctx.fillText('Then draw red boxes on objects to remove',dispW/2,dispH/2+14);
471
- }
472
-
473
- function redraw(tempRect) {
474
- ctx.clearRect(0,0,dispW,dispH);
475
- if (!baseImg) { placeholder(); updateBadge(); return; }
476
- ctx.drawImage(baseImg, 0, 0, dispW, dispH);
477
-
478
- boxes.forEach((b,i) => {
479
- const p = n2px(b);
480
- const lx=p.x1, ty=p.y1, w=p.x2-p.x1, h=p.y2-p.y1;
481
-
482
- /* RED OUTLINE ONLY — no fill */
483
- if (i === selectedIdx) {
484
- ctx.strokeStyle = SEL_STROKE;
485
- ctx.lineWidth = RED_STROKE_WIDTH + 1;
486
- ctx.setLineDash([6,3]);
487
- } else {
488
- ctx.strokeStyle = RED_STROKE;
489
- ctx.lineWidth = RED_STROKE_WIDTH;
490
- ctx.setLineDash([]);
491
  }
492
- ctx.strokeRect(lx, ty, w, h);
493
- ctx.setLineDash([]);
494
-
495
- /* label tag */
496
- ctx.fillStyle = i===selectedIdx ? 'rgba(0,120,255,0.85)' : 'rgba(255,0,0,0.85)';
497
- ctx.font = 'bold 11px IBM Plex Mono,monospace';
498
- ctx.textAlign = 'left'; ctx.textBaseline = 'top';
499
- const label = '#'+(i+1);
500
- const tw = ctx.measureText(label).width;
501
- ctx.fillRect(lx, ty-16, tw+6, 16);
502
- ctx.fillStyle = '#fff';
503
- ctx.fillText(label, lx+3, ty-14);
504
-
505
- if (i === selectedIdx) drawHandles(p);
506
- });
507
-
508
- /* temp drawing rect — outline only */
509
- if (tempRect) {
510
- const rx = Math.min(tempRect.x1,tempRect.x2);
511
- const ry = Math.min(tempRect.y1,tempRect.y2);
512
- const rw = Math.abs(tempRect.x2-tempRect.x1);
513
- const rh = Math.abs(tempRect.y2-tempRect.y1);
514
- ctx.strokeStyle = RED_STROKE;
515
- ctx.lineWidth = RED_STROKE_WIDTH;
516
- ctx.setLineDash([6,3]);
517
- ctx.strokeRect(rx, ry, rw, rh);
518
- ctx.setLineDash([]);
519
- }
520
- updateBadge();
521
- }
522
-
523
- function drawHandles(p) {
524
- const pts = handlePoints(p);
525
- ctx.fillStyle = 'rgba(0,120,255,0.9)';
526
- ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5;
527
- for (const k in pts) {
528
- const h = pts[k];
529
- ctx.fillRect(h.x-HANDLE, h.y-HANDLE, HANDLE*2, HANDLE*2);
530
- ctx.strokeRect(h.x-HANDLE, h.y-HANDLE, HANDLE*2, HANDLE*2);
531
- }
532
- }
533
-
534
- function handlePoints(p) {
535
- const mx = (p.x1+p.x2)/2, my = (p.y1+p.y2)/2;
536
- return {
537
- tl:{x:p.x1,y:p.y1}, tc:{x:mx,y:p.y1}, tr:{x:p.x2,y:p.y1},
538
- ml:{x:p.x1,y:my}, mr:{x:p.x2,y:my},
539
- bl:{x:p.x1,y:p.y2}, bc:{x:mx,y:p.y2}, br:{x:p.x2,y:p.y2}
540
- };
541
- }
542
-
543
- function hitHandle(px, py, boxIdx) {
544
- if (boxIdx < 0) return null;
545
- const p = n2px(boxes[boxIdx]);
546
- const pts = handlePoints(p);
547
- for (const k in pts) {
548
- if (Math.abs(px-pts[k].x) <= HANDLE+2 && Math.abs(py-pts[k].y) <= HANDLE+2) return k;
549
- }
550
- return null;
551
- }
552
-
553
- function hitBox(px, py) {
554
- for (let i = boxes.length-1; i >= 0; i--) {
555
- const p = n2px(boxes[i]);
556
- if (px >= p.x1 && px <= p.x2 && py >= p.y1 && py <= p.y2) return i;
557
- }
558
- return -1;
559
- }
560
-
561
- function updateBadge() {
562
- if (boxes.length > 0) {
563
- badge.style.display = 'block';
564
- badge.textContent = boxes.length + ' box' + (boxes.length>1?'es':'');
565
- } else {
566
- badge.style.display = 'none';
567
- }
568
- }
569
-
570
- function setMode(m) {
571
- mode = m;
572
- btnDraw.classList.toggle('active', m==='draw');
573
- btnSelect.classList.toggle('active', m==='select');
574
- canvas.style.cursor = m==='draw' ? 'crosshair' : 'default';
575
- if (m==='draw') selectedIdx = -1;
576
- redraw();
577
- }
578
-
579
- function showStatus(txt) {
580
- status.textContent = txt; status.style.display = 'block';
581
- }
582
- function hideStatus() { status.style.display = 'none'; }
583
-
584
- function onDown(e) {
585
- if (!baseImg) return;
586
- e.preventDefault();
587
- const {x, y} = canvasXY(e);
588
-
589
- if (mode === 'draw') {
590
- dragging = true; dragType = 'new';
591
- dragStart = {x, y};
592
- selectedIdx = -1;
593
- } else {
594
- if (selectedIdx >= 0) {
595
- const h = hitHandle(x, y, selectedIdx);
596
- if (h) {
597
- dragging = true; dragType = h;
598
- dragStart = {x, y};
599
- dragOrig = {...boxes[selectedIdx]};
600
- showStatus('Resizing box #'+(selectedIdx+1));
601
  return;
602
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  }
604
- const hi = hitBox(x, y);
605
- if (hi >= 0) {
606
- selectedIdx = hi;
607
- const h2 = hitHandle(x, y, selectedIdx);
608
- if (h2) {
609
- dragging = true; dragType = h2;
610
- dragStart = {x, y};
611
- dragOrig = {...boxes[selectedIdx]};
612
- showStatus('Resizing box #'+(selectedIdx+1));
613
- redraw(); return;
614
- }
615
- dragging = true; dragType = 'move';
616
- dragStart = {x, y};
617
- dragOrig = {...boxes[selectedIdx]};
618
- showStatus('Moving box #'+(selectedIdx+1));
619
- } else {
620
- selectedIdx = -1;
621
- hideStatus();
622
  }
623
- redraw();
624
- }
625
- }
626
-
627
- function onMove(e) {
628
- if (!baseImg) return;
629
- e.preventDefault();
630
- const {x, y} = canvasXY(e);
631
-
632
- if (!dragging) {
633
- if (mode === 'select') {
634
- if (selectedIdx >= 0 && hitHandle(x,y,selectedIdx)) {
635
- const h = hitHandle(x,y,selectedIdx);
636
- const curs = {tl:'nwse-resize',tr:'nesw-resize',bl:'nesw-resize',br:'nwse-resize',
637
- tc:'ns-resize',bc:'ns-resize',ml:'ew-resize',mr:'ew-resize'};
638
- canvas.style.cursor = curs[h] || 'move';
639
- } else if (hitBox(x,y) >= 0) {
640
- canvas.style.cursor = 'move';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
641
  } else {
642
- canvas.style.cursor = 'default';
643
  }
 
644
  }
645
- return;
646
- }
647
-
648
- if (dragType === 'new') {
649
- redraw({x1:dragStart.x, y1:dragStart.y, x2:x, y2:y});
650
- showStatus(Math.abs(x-dragStart.x).toFixed(0)+'\u00d7'+Math.abs(y-dragStart.y).toFixed(0)+' px');
651
- return;
652
- }
653
-
654
- const dx = (x - dragStart.x) / dispW;
655
- const dy = (y - dragStart.y) / dispH;
656
- const b = boxes[selectedIdx];
657
- const o = dragOrig;
658
-
659
- if (dragType === 'move') {
660
- const bw = o.x2-o.x1, bh = o.y2-o.y1;
661
- let nx1 = o.x1+dx, ny1 = o.y1+dy;
662
- nx1 = clamp01(nx1); ny1 = clamp01(ny1);
663
- if (nx1+bw > 1) nx1 = 1-bw;
664
- if (ny1+bh > 1) ny1 = 1-bh;
665
- b.x1=nx1; b.y1=ny1; b.x2=nx1+bw; b.y2=ny1+bh;
666
- } else {
667
- const t = dragType;
668
- if (t.includes('l')) b.x1 = clamp01(o.x1 + dx);
669
- if (t.includes('r')) b.x2 = clamp01(o.x2 + dx);
670
- if (t.includes('t')) b.y1 = clamp01(o.y1 + dy);
671
- if (t.includes('b')) b.y2 = clamp01(o.y2 + dy);
672
- if (Math.abs(b.x2-b.x1) < 0.01) { b.x1=o.x1; b.x2=o.x2; }
673
- if (Math.abs(b.y2-b.y1) < 0.01) { b.y1=o.y1; b.y2=o.y2; }
674
- if (b.x1 > b.x2) { const t2=b.x1; b.x1=b.x2; b.x2=t2; }
675
- if (b.y1 > b.y2) { const t2=b.y1; b.y1=b.y2; b.y2=t2; }
676
- }
677
- redraw();
678
- }
679
-
680
- function onUp(e) {
681
- if (!dragging) return;
682
- if (e) e.preventDefault();
683
- dragging = false;
684
-
685
- if (dragType === 'new') {
686
- const pt = e ? canvasXY(e) : {x:dragStart.x, y:dragStart.y};
687
- if (Math.abs(pt.x-dragStart.x) > 4 && Math.abs(pt.y-dragStart.y) > 4) {
688
- const nb = px2n(dragStart.x, dragStart.y, pt.x, pt.y);
689
- boxes.push(nb);
690
- window.__bboxBoxes = boxes;
691
- selectedIdx = boxes.length - 1;
692
- console.log('[BBox] created box #'+boxes.length, nb);
693
- showStatus('Box #'+boxes.length+' created');
694
- } else { hideStatus(); }
695
- } else {
696
- showStatus('Box #'+(selectedIdx+1)+' updated');
697
- }
698
- dragType = null; dragOrig = null;
699
- syncToGradio();
700
- redraw();
701
- }
702
-
703
- canvas.addEventListener('mousedown', onDown);
704
- canvas.addEventListener('mousemove', onMove);
705
- canvas.addEventListener('mouseup', onUp);
706
- canvas.addEventListener('mouseleave', (e)=>{if(dragging)onUp(e);});
707
- canvas.addEventListener('touchstart', onDown, {passive:false});
708
- canvas.addEventListener('touchmove', onMove, {passive:false});
709
- canvas.addEventListener('touchend', onUp, {passive:false});
710
- canvas.addEventListener('touchcancel',(e)=>{e.preventDefault();dragging=false;redraw();},{passive:false});
711
-
712
- btnDraw.addEventListener('click', ()=>setMode('draw'));
713
- btnSelect.addEventListener('click', ()=>setMode('select'));
714
-
715
- btnDel.addEventListener('click', () => {
716
- if (selectedIdx >= 0 && selectedIdx < boxes.length) {
717
- const removed = selectedIdx + 1;
718
- boxes.splice(selectedIdx, 1);
719
- window.__bboxBoxes = boxes;
720
- selectedIdx = -1;
721
- syncToGradio(); redraw();
722
- showStatus('Box #'+removed+' deleted');
723
- } else {
724
- showStatus('No box selected');
725
- }
726
- });
727
-
728
- btnUndo.addEventListener('click', () => {
729
- if (boxes.length > 0) {
730
- boxes.pop();
731
- window.__bboxBoxes = boxes;
732
- selectedIdx = -1;
733
- syncToGradio(); redraw();
734
- showStatus('Last box removed');
735
- }
736
- });
737
-
738
- btnClear.addEventListener('click', () => {
739
- boxes.length = 0;
740
- window.__bboxBoxes = boxes;
741
- selectedIdx = -1;
742
- syncToGradio(); redraw(); hideStatus();
743
- });
744
-
745
- let lastSrc = null;
746
- setInterval(() => {
747
- const imgs = document.querySelectorAll('#source-image-component img');
748
- let el = null;
749
- for (const img of imgs) {
750
- if (img.src && img.src.length > 30 &&
751
- (img.src.startsWith('data:') ||
752
- img.src.startsWith('blob:') ||
753
- img.src.includes('/file=') ||
754
- img.src.includes('/upload') ||
755
- img.src.includes('localhost') ||
756
- img.src.includes('127.0.0.1') ||
757
- img.src.startsWith('http'))) {
758
- el = img;
759
- break;
760
  }
761
- }
762
-
763
- if (el && el.src && el.src !== lastSrc) {
764
- lastSrc = el.src;
765
- const img = new window.Image();
766
- img.crossOrigin = 'anonymous';
767
- img.onload = () => {
768
- baseImg = img;
769
- boxes.length = 0;
770
- window.__bboxBoxes = boxes;
771
- selectedIdx = -1;
772
- fitSize(img.naturalWidth, img.naturalHeight);
773
- syncToGradio(); redraw(); hideStatus();
774
- console.log('[BBox] loaded image', img.naturalWidth, 'x', img.naturalHeight);
775
- };
776
- img.onerror = () => {
777
- console.warn('[BBox] image load failed for', el.src.substring(0,60));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  };
779
- img.src = el.src;
780
- } else if (!el || !el.src) {
781
- if (baseImg) {
782
- baseImg = null;
783
- boxes.length = 0;
784
- window.__bboxBoxes = boxes;
785
- selectedIdx = -1;
786
- fitSize(512,400); syncToGradio(); redraw(); hideStatus();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  }
788
- lastSrc = null;
789
- }
790
- }, 300);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
 
792
- setInterval(() => { syncToGradio(); }, 500);
 
793
 
794
- new ResizeObserver(() => {
795
- if (baseImg) { fitSize(baseImg.naturalWidth, baseImg.naturalHeight); redraw(); }
796
- }).observe(wrap);
 
 
 
 
 
 
797
 
798
- setMode('draw');
799
- fitSize(512,400); redraw();
800
- syncToGradio();
801
- }
 
 
 
 
802
 
803
- initCanvasBbox();
 
 
804
  }
805
- """
806
 
 
 
 
 
 
807
 
808
- with gr.Blocks(css=css, theme=purple_theme) as demo:
809
- gr.Markdown("# **QIE-Object-Remover-Bbox**", elem_id="main-title")
810
- gr.Markdown(
811
- "Perform diverse image edits using a specialized [LoRA](https://huggingface.co/prithivMLmods/QIE-2511-Object-Remover-v2). "
812
- "Upload an image, draw red bounding boxes over the objects you want to remove, and click Remove Object. "
813
- "Multiple boxes supported. Select, move, resize or delete individual boxes.",
814
- elem_id="subtitle",
815
- )
 
 
 
816
 
817
- with gr.Row():
818
- with gr.Column(scale=1):
819
- source_image = gr.Image(
820
- label="Upload Image",
821
- type="pil",
822
- height=350,
823
- elem_id="source-image-component",
824
- )
825
 
826
- gr.Markdown("# **Bbox Edit Controller**")
827
- gr.HTML(
828
- '<div class="bbox-hint">'
829
- "<b>Draw mode:</b> Click & drag to create red rectangles. "
830
- "<b>Select mode:</b> Click a box to select it \u2192 drag to <b>move</b>, "
831
- "drag handles to <b>resize</b>. Use <b>Delete Selected</b> to remove one box."
832
- "</div>"
833
- )
834
 
835
- gr.HTML(
836
- """
837
- <div id="bbox-draw-wrap">
838
- <canvas id="bbox-draw-canvas" width="512" height="400"></canvas>
839
- <div id="bbox-status"></div>
840
- <div id="bbox-count"></div>
841
- </div>
842
- """
843
- )
844
 
845
- gr.HTML(
846
- """
847
- <div class="bbox-toolbar-section">
848
- <span class="toolbar-label">🛠 Tools:</span>
849
- <button id="tb-draw" class="bbox-tb-draw active" title="Draw new boxes">✏️ Draw</button>
850
- <button id="tb-select" class="bbox-tb-select" title="Select / move / resize">🔲 Select</button>
851
- <div class="toolbar-divider"></div>
852
- <span class="toolbar-label">Actions:</span>
853
- <button id="tb-del" class="bbox-tb-del" title="Delete selected box">✕ Delete Selected</button>
854
- <button id="tb-undo" class="bbox-tb-undo" title="Remove last box">↩ Undo</button>
855
- <button id="tb-clear" class="bbox-tb-clear" title="Remove all boxes">🗑 Clear All</button>
856
- </div>
857
- """
858
- )
859
 
860
- gr.HTML('<div id="bbox-debug-count">\u2B1C No boxes drawn yet</div>')
 
 
861
 
862
- boxes_json = gr.Textbox(
863
- value="[]",
864
- visible=True,
865
- interactive=True,
866
- elem_id="boxes-json-input",
867
- label="boxes-json",
868
- )
869
 
870
- prompt = gr.Textbox(
871
- label="Prompt",
872
- value=DEFAULT_PROMPT,
873
- lines=2,
874
- info="Edit the prompt if needed",
875
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876
 
877
- run_btn = gr.Button("\U0001F5D1\uFE0F Remove Object", variant="primary", size="lg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879
  with gr.Column(scale=1):
880
- result = gr.Image(label="Output Image", height=449)
881
- preview = gr.Image(label="Input Sent to Model (with red boxes)", height=393)
 
882
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883
  with gr.Accordion("Advanced Settings", open=True):
884
  seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
885
  randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
 
886
  with gr.Row():
887
  guidance_scale = gr.Slider(label="Guidance Scale", minimum=1.0, maximum=10.0, step=0.1, value=1.0)
888
  num_inference_steps = gr.Slider(label="Inference Steps", minimum=1, maximum=20, step=1, value=4)
 
889
  with gr.Row():
890
- height_slider = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
891
- width_slider = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
892
 
893
- with gr.Accordion("About", open=False):
 
 
 
 
 
 
 
 
894
  gr.Markdown(
895
- "*QIE-Object-Remover-Bbox* by "
896
- "[prithivMLmods](https://huggingface.co/prithivMLmods). "
897
- "Adapter: [QIE-2511-Object-Remover-v2]"
898
- "(https://huggingface.co/prithivMLmods/QIE-2511-Object-Remover-v2). "
899
- "More adapters \u2192 [Qwen-Image-Edit-LoRAs]"
900
- "(https://huggingface.co/models?other=base_model:adapter:Qwen/Qwen-Image-Edit-2509)."
901
  )
902
-
903
- demo.load(fn=None, js=bbox_drawer_js)
904
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
905
  run_btn.click(
906
- fn=infer_object_removal,
907
- inputs=[source_image, boxes_json, prompt, seed, randomize_seed,
908
- guidance_scale, num_inference_steps, height_slider, width_slider],
909
- outputs=[result, seed, preview],
910
- js="""(src, bj, p, s, rs, gs, nis, h, w) => {
911
- const boxes = window.__bboxBoxes || [];
912
- const json = JSON.stringify(boxes);
913
- console.log('[BBox] submitting', boxes.length, 'boxes:', json);
914
- return [src, json, p, s, rs, gs, nis, h, w];
915
- }""",
916
  )
917
-
918
- source_image.upload(
919
  fn=update_dimensions_on_upload,
920
- inputs=[source_image],
921
- outputs=[width_slider, height_slider],
 
 
 
 
922
  )
923
-
924
-
 
 
 
 
925
  if __name__ == "__main__":
926
- demo.launch(
927
- css=css, theme=purple_theme,
928
- mcp_server=True, ssr_mode=False, show_error=True
929
- )
 
4
  import torch
5
  import spaces
6
  from typing import Iterable
7
+ from PIL import Image
8
  from diffusers import FlowMatchEulerDiscreteScheduler
9
  from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline
10
  from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel
 
28
  c950="#3B0764",
29
  )
30
 
 
31
  class PurpleTheme(Soft):
32
  def __init__(
33
  self,
 
79
  block_label_background_fill="*primary_200",
80
  )
81
 
 
82
  purple_theme = PurpleTheme()
83
 
84
  MAX_SEED = np.iinfo(np.int32).max
 
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())
 
101
  print(f"Warning: Could not set FA3 processor: {e}")
102
 
103
  ADAPTER_SPECS = {
104
+ "Multi-Angle-Lighting": {
105
+ "repo": "dx8152/Qwen-Edit-2509-Multi-Angle-Lighting",
106
+ "weights": "多角度灯光-251116.safetensors",
107
+ "adapter_name": "multi-angle-lighting"
108
  },
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
+ azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys()))
138
+ elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys()))
139
+
140
+ if elevation_snapped == 0:
141
+ return f"Light source from the {AZIMUTH_MAP[azimuth_snapped]}"
142
+ else:
143
+ return f"Light source from {ELEVATION_MAP[elevation_snapped]}"
144
 
145
  @spaces.GPU
146
+ def infer_lighting_edit(
147
+ image: Image.Image,
148
+ azimuth: float = 0.0,
149
+ elevation: float = 0.0,
150
  seed: int = 0,
151
  randomize_seed: bool = True,
152
  guidance_scale: float = 1.0,
 
156
  ):
157
  global loaded
158
  progress = gr.Progress(track_tqdm=True)
159
+
160
  if not loaded:
161
  pipe.load_lora_weights(
162
+ ADAPTER_SPECS["Multi-Angle-Lighting"]["repo"],
163
+ weight_name=ADAPTER_SPECS["Multi-Angle-Lighting"]["weights"],
164
+ adapter_name=ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"]
 
 
 
165
  )
166
+ pipe.set_adapters([ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"]], adapter_weights=[1.0])
167
  loaded = True
168
+
169
+ prompt = build_lighting_prompt(azimuth, elevation)
170
+ print(f"Generated Prompt: {prompt}")
171
 
172
+ progress(0.7, desc="Fast lighting enabled....")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
  if randomize_seed:
175
  seed = random.randint(0, MAX_SEED)
176
  generator = torch.Generator(device=device).manual_seed(seed)
177
+ if image is None:
178
+ raise gr.Error("Please upload an image first.")
179
+ pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB")
180
  result = pipe(
181
+ image=[pil_image],
182
  prompt=prompt,
183
  height=height if height != 0 else None,
184
  width=width if width != 0 else None,
 
187
  guidance_scale=guidance_scale,
188
  num_images_per_prompt=1,
189
  ).images[0]
190
+ return result, seed, prompt
 
 
191
 
192
  def update_dimensions_on_upload(image):
193
  if image is None:
 
205
  new_height = (new_height // 8) * 8
206
  return new_width, new_height
207
 
208
+ class LightingControl3D(gr.HTML):
209
+ """
210
+ A 3D lighting control component using Three.js.
211
+ """
212
+ def __init__(self, value=None, imageUrl=None, **kwargs):
213
+ if value is None:
214
+ value = {"azimuth": 0, "elevation": 0}
215
+
216
+ html_template = """
217
+ <div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #1a1a1a; border-radius: 12px; overflow: hidden;">
218
+ <div id="prompt-overlay" style="position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); padding: 8px 16px; border-radius: 8px; font-family: monospace; font-size: 12px; color: #00ff88; white-space: nowrap; z-index: 10;"></div>
219
+ </div>
220
+ """
221
+
222
+ js_on_load = """
223
+ (() => {
224
+ const wrapper = element.querySelector('#lighting-control-wrapper');
225
+ const promptOverlay = element.querySelector('#prompt-overlay');
226
+
227
+ const initScene = () => {
228
+ if (typeof THREE === 'undefined') {
229
+ setTimeout(initScene, 100);
230
+ return;
231
+ }
232
+
233
+ const scene = new THREE.Scene();
234
+ scene.background = new THREE.Color(0x1a1a1a);
235
+
236
+ const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000);
237
+ camera.position.set(4.5, 3, 4.5);
238
+ camera.lookAt(0, 0.75, 0);
239
+
240
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
241
+ renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
242
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
243
+ renderer.shadowMap.enabled = true;
244
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
245
+ wrapper.insertBefore(renderer.domElement, promptOverlay);
246
+
247
+ scene.add(new THREE.AmbientLight(0xffffff, 0.1));
248
+
249
+ const ground = new THREE.Mesh(
250
+ new THREE.PlaneGeometry(10, 10),
251
+ new THREE.ShadowMaterial({ opacity: 0.3 })
252
+ );
253
+ ground.rotation.x = -Math.PI / 2;
254
+ ground.position.y = 0;
255
+ ground.receiveShadow = true;
256
+ scene.add(ground);
257
+
258
+ scene.add(new THREE.GridHelper(8, 16, 0x333333, 0x222222));
259
+
260
+ const CENTER = new THREE.Vector3(0, 0.75, 0);
261
+ const BASE_DISTANCE = 2.5;
262
+ const AZIMUTH_RADIUS = 2.4;
263
+ const ELEVATION_RADIUS = 1.8;
264
+
265
+ let azimuthAngle = props.value?.azimuth || 0;
266
+ let elevationAngle = props.value?.elevation || 0;
267
+
268
+ const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315];
269
+ const elevationSteps = [-90, 0, 90];
270
+ const azimuthNames = {
271
+ 0: 'Front', 45: 'Right Front', 90: 'Right',
272
+ 135: 'Right Rear', 180: 'Rear', 225: 'Left Rear',
273
+ 270: 'Left', 315: 'Left Front'
274
+ };
275
+ const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' };
276
+
277
+ function snapToNearest(value, steps) {
278
+ return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  }
280
+
281
+ function createPlaceholderTexture() {
282
+ const canvas = document.createElement('canvas');
283
+ canvas.width = 256;
284
+ canvas.height = 256;
285
+ const ctx = canvas.getContext('2d');
286
+ ctx.fillStyle = '#3a3a4a';
287
+ ctx.fillRect(0, 0, 256, 256);
288
+ ctx.fillStyle = '#ffcc99';
289
+ ctx.beginPath();
290
+ ctx.arc(128, 128, 80, 0, Math.PI * 2);
291
+ ctx.fill();
292
+ ctx.fillStyle = '#333';
293
+ ctx.beginPath();
294
+ ctx.arc(100, 110, 10, 0, Math.PI * 2);
295
+ ctx.arc(156, 110, 10, 0, Math.PI * 2);
296
+ ctx.fill();
297
+ ctx.strokeStyle = '#333';
298
+ ctx.lineWidth = 3;
299
+ ctx.beginPath();
300
+ ctx.arc(128, 130, 35, 0.2, Math.PI - 0.2);
301
+ ctx.stroke();
302
+ return new THREE.CanvasTexture(canvas);
 
 
 
 
 
 
 
 
 
303
  }
304
+
305
+ let currentTexture = createPlaceholderTexture();
306
+ const planeMaterial = new THREE.MeshStandardMaterial({ map: currentTexture, side: THREE.DoubleSide, roughness: 0.5, metalness: 0 });
307
+ let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
308
+ targetPlane.position.copy(CENTER);
309
+ targetPlane.receiveShadow = true;
310
+ scene.add(targetPlane);
311
+
312
+ function updateTextureFromUrl(url) {
313
+ if (!url) {
314
+ planeMaterial.map = createPlaceholderTexture();
315
+ planeMaterial.needsUpdate = true;
316
+ scene.remove(targetPlane);
317
+ targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial);
318
+ targetPlane.position.copy(CENTER);
319
+ targetPlane.receiveShadow = true;
320
+ scene.add(targetPlane);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  return;
322
  }
323
+
324
+ const loader = new THREE.TextureLoader();
325
+ loader.crossOrigin = 'anonymous';
326
+ loader.load(url, (texture) => {
327
+ texture.minFilter = THREE.LinearFilter;
328
+ texture.magFilter = THREE.LinearFilter;
329
+ planeMaterial.map = texture;
330
+ planeMaterial.needsUpdate = true;
331
+
332
+ const img = texture.image;
333
+ if (img && img.width && img.height) {
334
+ const aspect = img.width / img.height;
335
+ const maxSize = 1.5;
336
+ let planeWidth, planeHeight;
337
+ if (aspect > 1) {
338
+ planeWidth = maxSize;
339
+ planeHeight = maxSize / aspect;
340
+ } else {
341
+ planeHeight = maxSize;
342
+ planeWidth = maxSize * aspect;
343
+ }
344
+ scene.remove(targetPlane);
345
+ targetPlane = new THREE.Mesh(
346
+ new THREE.PlaneGeometry(planeWidth, planeHeight),
347
+ planeMaterial
348
+ );
349
+ targetPlane.position.copy(CENTER);
350
+ targetPlane.receiveShadow = true;
351
+ scene.add(targetPlane);
352
+ }
353
+ }, undefined, (err) => {
354
+ console.error('Failed to load texture:', err);
355
+ });
356
  }
357
+
358
+ if (props.imageUrl) {
359
+ updateTextureFromUrl(props.imageUrl);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  }
361
+
362
+ // --- NEW LIGHT MODEL: SQUARE STUDIO LIGHT WITH RAYS ---
363
+ const lightGroup = new THREE.Group();
364
+
365
+ // 1. Studio Panel Housing (Black, thin, square)
366
+ const panelGeo = new THREE.BoxGeometry(0.8, 0.8, 0.1);
367
+ const panelMat = new THREE.MeshStandardMaterial({
368
+ color: 0x111111, // Black body
369
+ roughness: 0.3,
370
+ metalness: 0.8
371
+ });
372
+ const panel = new THREE.Mesh(panelGeo, panelMat);
373
+ // Shift box slightly back so the front face is at z=0 relative to the group
374
+ panel.position.z = -0.05;
375
+ lightGroup.add(panel);
376
+
377
+ // 2. Emissive Light Face (Bright White)
378
+ const faceGeo = new THREE.PlaneGeometry(0.75, 0.75);
379
+ const faceMat = new THREE.MeshBasicMaterial({
380
+ color: 0xffffff, // Pure white
381
+ side: THREE.DoubleSide
382
+ });
383
+ const face = new THREE.Mesh(faceGeo, faceMat);
384
+ face.position.z = 0.01; // Slightly in front of the black housing
385
+ lightGroup.add(face);
386
+
387
+ // 3. Volumetric Light Rays (Transparent Cone)
388
+ // CylinderGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded)
389
+ const beamHeight = 4.0;
390
+ const beamGeo = new THREE.CylinderGeometry(0.38, 1.2, beamHeight, 32, 1, true);
391
+
392
+ // Rotate cylinder to point along +Z axis
393
+ beamGeo.rotateX(-Math.PI / 2);
394
+ // Translate so the top (start) of the beam sits on the light face
395
+ beamGeo.translate(0, 0, beamHeight / 2);
396
+
397
+ const beamMat = new THREE.MeshBasicMaterial({
398
+ color: 0xffffff,
399
+ transparent: true,
400
+ opacity: 0.12, // Low opacity for subtleness
401
+ side: THREE.DoubleSide,
402
+ depthWrite: false, // Important for transparent sorting
403
+ blending: THREE.AdditiveBlending // Glow effect
404
+ });
405
+
406
+ const beam = new THREE.Mesh(beamGeo, beamMat);
407
+ lightGroup.add(beam);
408
+
409
+ // Actual SpotLight Calculation Source
410
+ const spotLight = new THREE.SpotLight(0xffffff, 10, 10, Math.PI / 3, 1, 1);
411
+ spotLight.position.set(0, 0, 0); // Position at the center of the custom mesh
412
+ spotLight.castShadow = true;
413
+ spotLight.shadow.mapSize.width = 1024;
414
+ spotLight.shadow.mapSize.height = 1024;
415
+ spotLight.shadow.camera.near = 0.5;
416
+ spotLight.shadow.camera.far = 500;
417
+ spotLight.shadow.bias = -0.005;
418
+ lightGroup.add(spotLight);
419
+
420
+ const lightTarget = new THREE.Object3D();
421
+ lightTarget.position.copy(CENTER);
422
+ scene.add(lightTarget);
423
+ spotLight.target = lightTarget;
424
+
425
+ scene.add(lightGroup);
426
+
427
+ // --- CONTROLS ---
428
+
429
+ const azimuthRing = new THREE.Mesh(
430
+ new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 64),
431
+ new THREE.MeshStandardMaterial({ color: 0xffff00, emissive: 0xffff00, emissiveIntensity: 0.3 })
432
+ );
433
+ azimuthRing.rotation.x = Math.PI / 2;
434
+ azimuthRing.position.y = 0.05;
435
+ scene.add(azimuthRing);
436
+
437
+ const azimuthHandle = new THREE.Mesh(
438
+ new THREE.SphereGeometry(0.18, 16, 16),
439
+ new THREE.MeshStandardMaterial({ color: 0xffff00, emissive: 0xffff00, emissiveIntensity: 0.5 })
440
+ );
441
+ azimuthHandle.userData.type = 'azimuth';
442
+ scene.add(azimuthHandle);
443
+
444
+ const arcPoints = [];
445
+ for (let i = 0; i <= 32; i++) {
446
+ const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 32));
447
+ arcPoints.push(new THREE.Vector3(-0.8, ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, ELEVATION_RADIUS * Math.cos(angle)));
448
+ }
449
+ const arcCurve = new THREE.CatmullRomCurve3(arcPoints);
450
+ const elevationArc = new THREE.Mesh(
451
+ new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false),
452
+ new THREE.MeshStandardMaterial({ color: 0x0000ff, emissive: 0x0000ff, emissiveIntensity: 0.3 })
453
+ );
454
+ scene.add(elevationArc);
455
+
456
+ const elevationHandle = new THREE.Mesh(
457
+ new THREE.SphereGeometry(0.18, 16, 16),
458
+ new THREE.MeshStandardMaterial({ color: 0x0000ff, emissive: 0x0000ff, emissiveIntensity: 0.5 })
459
+ );
460
+ elevationHandle.userData.type = 'elevation';
461
+ scene.add(elevationHandle);
462
+
463
+ const refreshBtn = document.createElement('button');
464
+ refreshBtn.innerHTML = 'Reset View';
465
+ refreshBtn.style.position = 'absolute';
466
+ refreshBtn.style.top = '15px';
467
+ refreshBtn.style.right = '15px';
468
+ refreshBtn.style.background = '#9333EA';
469
+ refreshBtn.style.color = '#fff';
470
+ refreshBtn.style.border = 'none';
471
+ refreshBtn.style.padding = '8px 16px';
472
+ refreshBtn.style.borderRadius = '6px';
473
+ refreshBtn.style.cursor = 'pointer';
474
+ refreshBtn.style.zIndex = '10';
475
+ refreshBtn.style.fontSize = '14px';
476
+ refreshBtn.style.fontWeight = '600';
477
+ refreshBtn.style.fontFamily = 'system-ui, sans-serif';
478
+ refreshBtn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.3)';
479
+ refreshBtn.style.transition = 'background 0.2s';
480
+
481
+ refreshBtn.onmouseover = () => refreshBtn.style.background = '#A855F7';
482
+ refreshBtn.onmouseout = () => refreshBtn.style.background = '#9333EA';
483
+
484
+ wrapper.appendChild(refreshBtn);
485
+
486
+ refreshBtn.addEventListener('click', () => {
487
+ azimuthAngle = 0;
488
+ elevationAngle = 0;
489
+ updatePositions();
490
+ updatePropsAndTrigger();
491
+ });
492
+
493
+ function updatePositions() {
494
+ const distance = BASE_DISTANCE;
495
+ const azRad = THREE.MathUtils.degToRad(azimuthAngle);
496
+ const elRad = THREE.MathUtils.degToRad(elevationAngle);
497
+
498
+ const lightX = distance * Math.sin(azRad) * Math.cos(elRad);
499
+ const lightY = distance * Math.sin(elRad) + CENTER.y;
500
+ const lightZ = distance * Math.cos(azRad) * Math.cos(elRad);
501
+
502
+ lightGroup.position.set(lightX, lightY, lightZ);
503
+ lightGroup.lookAt(CENTER);
504
+
505
+ azimuthHandle.position.set(AZIMUTH_RADIUS * Math.sin(azRad), 0.05, AZIMUTH_RADIUS * Math.cos(azRad));
506
+ elevationHandle.position.set(-0.8, ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y, ELEVATION_RADIUS * Math.cos(elRad));
507
+
508
+ const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
509
+ const elSnap = snapToNearest(elevationAngle, elevationSteps);
510
+ let prompt = 'Light source from';
511
+ if (elSnap !== 0) {
512
+ prompt += ' ' + elevationNames[String(elSnap)];
513
  } else {
514
+ prompt += ' the ' + azimuthNames[azSnap];
515
  }
516
+ promptOverlay.textContent = prompt;
517
  }
518
+
519
+ function updatePropsAndTrigger() {
520
+ const azSnap = snapToNearest(azimuthAngle, azimuthSteps);
521
+ const elSnap = snapToNearest(elevationAngle, elevationSteps);
522
+
523
+ props.value = { azimuth: azSnap, elevation: elSnap };
524
+ trigger('change', props.value);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  }
526
+
527
+ const raycaster = new THREE.Raycaster();
528
+ const mouse = new THREE.Vector2();
529
+ let isDragging = false;
530
+ let dragTarget = null;
531
+ let dragStartMouse = new THREE.Vector2();
532
+ const intersection = new THREE.Vector3();
533
+
534
+ const canvas = renderer.domElement;
535
+
536
+ canvas.addEventListener('mousedown', (e) => {
537
+ const rect = canvas.getBoundingClientRect();
538
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
539
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
540
+
541
+ raycaster.setFromCamera(mouse, camera);
542
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
543
+
544
+ if (intersects.length > 0) {
545
+ isDragging = true;
546
+ dragTarget = intersects[0].object;
547
+ dragTarget.material.emissiveIntensity = 1.0;
548
+ dragTarget.scale.setScalar(1.3);
549
+ dragStartMouse.copy(mouse);
550
+ canvas.style.cursor = 'grabbing';
551
+ }
552
+ });
553
+
554
+ canvas.addEventListener('mousemove', (e) => {
555
+ const rect = canvas.getBoundingClientRect();
556
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
557
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
558
+
559
+ if (isDragging && dragTarget) {
560
+ raycaster.setFromCamera(mouse, camera);
561
+
562
+ if (dragTarget.userData.type === 'azimuth') {
563
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
564
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
565
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
566
+ if (azimuthAngle < 0) azimuthAngle += 360;
567
+ }
568
+ } else if (dragTarget.userData.type === 'elevation') {
569
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
570
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
571
+ const relY = intersection.y - CENTER.y;
572
+ const relZ = intersection.z;
573
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
574
+ }
575
+ }
576
+ updatePositions();
577
+ } else {
578
+ raycaster.setFromCamera(mouse, camera);
579
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
580
+ [azimuthHandle, elevationHandle].forEach(h => {
581
+ h.material.emissiveIntensity = 0.5;
582
+ h.scale.setScalar(1);
583
+ });
584
+ if (intersects.length > 0) {
585
+ intersects[0].object.material.emissiveIntensity = 0.8;
586
+ intersects[0].object.scale.setScalar(1.1);
587
+ canvas.style.cursor = 'grab';
588
+ } else {
589
+ canvas.style.cursor = 'default';
590
+ }
591
+ }
592
+ });
593
+
594
+ const onMouseUp = () => {
595
+ if (dragTarget) {
596
+ dragTarget.material.emissiveIntensity = 0.5;
597
+ dragTarget.scale.setScalar(1);
598
+
599
+ const targetAz = snapToNearest(azimuthAngle, azimuthSteps);
600
+ const targetEl = snapToNearest(elevationAngle, elevationSteps);
601
+
602
+ const startAz = azimuthAngle, startEl = elevationAngle;
603
+ const startTime = Date.now();
604
+
605
+ function animateSnap() {
606
+ const t = Math.min((Date.now() - startTime) / 200, 1);
607
+ const ease = 1 - Math.pow(1 - t, 3);
608
+
609
+ let azDiff = targetAz - startAz;
610
+ if (azDiff > 180) azDiff -= 360;
611
+ if (azDiff < -180) azDiff += 360;
612
+ azimuthAngle = startAz + azDiff * ease;
613
+ if (azimuthAngle < 0) azimuthAngle += 360;
614
+ if (azimuthAngle >= 360) azimuthAngle -= 360;
615
+
616
+ elevationAngle = startEl + (targetEl - startEl) * ease;
617
+
618
+ updatePositions();
619
+ if (t < 1) requestAnimationFrame(animateSnap);
620
+ else updatePropsAndTrigger();
621
+ }
622
+ animateSnap();
623
+ }
624
+ isDragging = false;
625
+ dragTarget = null;
626
+ canvas.style.cursor = 'default';
627
  };
628
+
629
+ canvas.addEventListener('mouseup', onMouseUp);
630
+ canvas.addEventListener('mouseleave', onMouseUp);
631
+ canvas.addEventListener('touchstart', (e) => {
632
+ e.preventDefault();
633
+ const touch = e.touches[0];
634
+ const rect = canvas.getBoundingClientRect();
635
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
636
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
637
+
638
+ raycaster.setFromCamera(mouse, camera);
639
+ const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]);
640
+
641
+ if (intersects.length > 0) {
642
+ isDragging = true;
643
+ dragTarget = intersects[0].object;
644
+ dragTarget.material.emissiveIntensity = 1.0;
645
+ dragTarget.scale.setScalar(1.3);
646
+ dragStartMouse.copy(mouse);
647
+ }
648
+ }, { passive: false });
649
+
650
+ canvas.addEventListener('touchmove', (e) => {
651
+ e.preventDefault();
652
+ const touch = e.touches[0];
653
+ const rect = canvas.getBoundingClientRect();
654
+ mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
655
+ mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
656
+
657
+ if (isDragging && dragTarget) {
658
+ raycaster.setFromCamera(mouse, camera);
659
+
660
+ if (dragTarget.userData.type === 'azimuth') {
661
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05);
662
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
663
+ azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z));
664
+ if (azimuthAngle < 0) azimuthAngle += 360;
665
+ }
666
+ } else if (dragTarget.userData.type === 'elevation') {
667
+ const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8);
668
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
669
+ const relY = intersection.y - CENTER.y;
670
+ const relZ = intersection.z;
671
+ elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90);
672
+ }
673
+ }
674
+ updatePositions();
675
+ }
676
+ }, { passive: false });
677
+
678
+ canvas.addEventListener('touchend', (e) => {
679
+ e.preventDefault();
680
+ onMouseUp();
681
+ }, { passive: false });
682
+
683
+ canvas.addEventListener('touchcancel', (e) => {
684
+ e.preventDefault();
685
+ onMouseUp();
686
+ }, { passive: false });
687
+
688
+ updatePositions();
689
+
690
+ function render() {
691
+ requestAnimationFrame(render);
692
+ renderer.render(scene, camera);
693
  }
694
+ render();
695
+
696
+ new ResizeObserver(() => {
697
+ camera.aspect = wrapper.clientWidth / wrapper.clientHeight;
698
+ camera.updateProjectionMatrix();
699
+ renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
700
+ }).observe(wrapper);
701
+
702
+ wrapper._updateFromProps = (newVal) => {
703
+ if (newVal && typeof newVal === 'object') {
704
+ azimuthAngle = newVal.azimuth ?? azimuthAngle;
705
+ elevationAngle = newVal.elevation ?? elevationAngle;
706
+ updatePositions();
707
+ }
708
+ };
709
+
710
+ wrapper._updateTexture = updateTextureFromUrl;
711
+
712
+ let lastImageUrl = props.imageUrl;
713
+ let lastValue = JSON.stringify(props.value);
714
+ setInterval(() => {
715
+ if (props.imageUrl !== lastImageUrl) {
716
+ lastImageUrl = props.imageUrl;
717
+ updateTextureFromUrl(props.imageUrl);
718
+ }
719
+ const currentValue = JSON.stringify(props.value);
720
+ if (currentValue !== lastValue) {
721
+ lastValue = currentValue;
722
+ if (props.value && typeof props.value === 'object') {
723
+ azimuthAngle = props.value.azimuth ?? azimuthAngle;
724
+ elevationAngle = props.value.elevation ?? elevationAngle;
725
+ updatePositions();
726
+ }
727
+ }
728
+ }, 100);
729
+ };
730
+
731
+ initScene();
732
+ })();
733
+ """
734
+
735
+ super().__init__(
736
+ value=value,
737
+ html_template=html_template,
738
+ js_on_load=js_on_load,
739
+ imageUrl=imageUrl,
740
+ **kwargs
741
+ )
742
 
743
+ css = """
744
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
745
 
746
+ /* Background grid pattern - Purple theme */
747
+ body, .gradio-container {
748
+ background-color: #FAF5FF !important;
749
+ background-image:
750
+ linear-gradient(#E9D5FF 1px, transparent 1px),
751
+ linear-gradient(90deg, #E9D5FF 1px, transparent 1px) !important;
752
+ background-size: 40px 40px !important;
753
+ font-family: 'Outfit', sans-serif !important;
754
+ }
755
 
756
+ /* Dark mode grid */
757
+ .dark body, .dark .gradio-container {
758
+ background-color: #1a1a1a !important;
759
+ background-image:
760
+ linear-gradient(rgba(168, 85, 247, 0.1) 1px, transparent 1px),
761
+ linear-gradient(90deg, rgba(168, 85, 247, 0.1) 1px, transparent 1px) !important;
762
+ background-size: 40px 40px !important;
763
+ }
764
 
765
+ #col-container {
766
+ margin: 0 auto;
767
+ max-width: 1200px;
768
  }
 
769
 
770
+ /* Main title styling */
771
+ #main-title {
772
+ text-align: center !important;
773
+ padding: 1rem 0 0.5rem 0;
774
+ }
775
 
776
+ #main-title h1 {
777
+ font-size: 2.4em !important;
778
+ font-weight: 700 !important;
779
+ background: linear-gradient(135deg, #A855F7 0%, #C084FC 50%, #9333EA 100%);
780
+ background-size: 200% 200%;
781
+ -webkit-background-clip: text;
782
+ -webkit-text-fill-color: transparent;
783
+ background-clip: text;
784
+ animation: gradient-shift 4s ease infinite;
785
+ letter-spacing: -0.02em;
786
+ }
787
 
788
+ @keyframes gradient-shift {
789
+ 0%, 100% { background-position: 0% 50%; }
790
+ 50% { background-position: 100% 50%; }
791
+ }
 
 
 
 
792
 
793
+ /* Subtitle styling */
794
+ #subtitle {
795
+ text-align: center !important;
796
+ margin-bottom: 1.5rem;
797
+ }
 
 
 
798
 
799
+ #subtitle p {
800
+ margin: 0 auto;
801
+ color: #666666;
802
+ font-size: 1rem;
803
+ text-align: center !important;
804
+ }
 
 
 
805
 
806
+ #subtitle a {
807
+ color: #A855F7 !important;
808
+ text-decoration: none;
809
+ font-weight: 500;
810
+ }
 
 
 
 
 
 
 
 
 
811
 
812
+ #subtitle a:hover {
813
+ text-decoration: underline;
814
+ }
815
 
816
+ /* Center all markdown text */
817
+ .gradio-container > .main > .wrap > .contain > .gap > div:nth-child(2) {
818
+ text-align: center !important;
819
+ }
 
 
 
820
 
821
+ /* Card styling */
822
+ .gradio-group {
823
+ background: rgba(255, 255, 255, 0.9) !important;
824
+ border: 2px solid #E9D5FF !important;
825
+ border-radius: 12px !important;
826
+ box-shadow: 0 4px 24px rgba(168, 85, 247, 0.08) !important;
827
+ backdrop-filter: blur(10px);
828
+ transition: all 0.3s ease;
829
+ }
830
+
831
+ .gradio-group:hover {
832
+ box-shadow: 0 8px 32px rgba(168, 85, 247, 0.12) !important;
833
+ border-color: #C084FC !important;
834
+ }
835
+
836
+ .dark .gradio-group {
837
+ background: rgba(30, 30, 30, 0.9) !important;
838
+ border-color: rgba(168, 85, 247, 0.3) !important;
839
+ }
840
+
841
+ /* Image upload area */
842
+ .gradio-image {
843
+ border-radius: 10px !important;
844
+ overflow: hidden;
845
+ border: 2px dashed #C084FC !important;
846
+ transition: all 0.3s ease;
847
+ }
848
+
849
+ .gradio-image:hover {
850
+ border-color: #A855F7 !important;
851
+ background: rgba(168, 85, 247, 0.02) !important;
852
+ }
853
+
854
+ /* Radio buttons */
855
+ .gradio-radio {
856
+ border-radius: 8px !important;
857
+ }
858
+
859
+ .gradio-radio label {
860
+ border-radius: 6px !important;
861
+ transition: all 0.2s ease !important;
862
+ border: 1px solid transparent !important;
863
+ }
864
+
865
+ .gradio-radio label:hover {
866
+ background: rgba(168, 85, 247, 0.05) !important;
867
+ }
868
+
869
+ .gradio-radio label.selected {
870
+ background: rgba(168, 85, 247, 0.1) !important;
871
+ border-color: #A855F7 !important;
872
+ }
873
+
874
+ /* Primary button */
875
+ .primary {
876
+ border-radius: 8px !important;
877
+ font-weight: 600 !important;
878
+ letter-spacing: 0.02em !important;
879
+ transition: all 0.3s ease !important;
880
+ }
881
+
882
+ .primary:hover {
883
+ transform: translateY(-2px) !important;
884
+ }
885
+
886
+ /* Tabs styling */
887
+ .tab-nav {
888
+ border-bottom: 2px solid #E9D5FF !important;
889
+ }
890
 
891
+ .tab-nav button {
892
+ font-weight: 500 !important;
893
+ padding: 10px 18px !important;
894
+ border-radius: 8px 8px 0 0 !important;
895
+ transition: all 0.2s ease !important;
896
+ }
897
+
898
+ .tab-nav button.selected {
899
+ background: rgba(168, 85, 247, 0.1) !important;
900
+ border-bottom: 2px solid #A855F7 !important;
901
+ }
902
+
903
+ /* Output textbox */
904
+ .gradio-textbox textarea {
905
+ font-family: 'IBM Plex Mono', monospace !important;
906
+ font-size: 0.95rem !important;
907
+ line-height: 1.7 !important;
908
+ background: rgba(255, 255, 255, 0.95) !important;
909
+ border: 1px solid #E9D5FF !important;
910
+ border-radius: 8px !important;
911
+ }
912
+
913
+ .dark .gradio-textbox textarea {
914
+ background: rgba(30, 30, 30, 0.95) !important;
915
+ border-color: rgba(168, 85, 247, 0.2) !important;
916
+ }
917
+
918
+ /* Markdown output */
919
+ .gradio-markdown {
920
+ font-family: 'Outfit', sans-serif !important;
921
+ line-height: 1.7 !important;
922
+ }
923
+
924
+ .gradio-markdown code {
925
+ font-family: 'IBM Plex Mono', monospace !important;
926
+ background: rgba(168, 85, 247, 0.08) !important;
927
+ padding: 2px 6px !important;
928
+ border-radius: 4px !important;
929
+ color: #7E22CE !important;
930
+ }
931
+
932
+ .gradio-markdown pre {
933
+ background: rgba(168, 85, 247, 0.05) !important;
934
+ border: 1px solid #E9D5FF !important;
935
+ border-radius: 8px !important;
936
+ padding: 1rem !important;
937
+ }
938
+
939
+ /* Examples section */
940
+ .gradio-examples {
941
+ border-radius: 10px !important;
942
+ }
943
+
944
+ .gradio-examples .gallery-item {
945
+ border: 2px solid #E9D5FF !important;
946
+ border-radius: 8px !important;
947
+ transition: all 0.2s ease !important;
948
+ }
949
+
950
+ .gradio-examples .gallery-item:hover {
951
+ border-color: #A855F7 !important;
952
+ transform: translateY(-2px) !important;
953
+ box-shadow: 0 4px 12px rgba(168, 85, 247, 0.15) !important;
954
+ }
955
+
956
+ /* Scrollbar styling */
957
+ ::-webkit-scrollbar {
958
+ width: 8px;
959
+ height: 8px;
960
+ }
961
+
962
+ ::-webkit-scrollbar-track {
963
+ background: rgba(168, 85, 247, 0.05);
964
+ border-radius: 4px;
965
+ }
966
 
967
+ ::-webkit-scrollbar-thumb {
968
+ background: linear-gradient(135deg, #A855F7, #C084FC);
969
+ border-radius: 4px;
970
+ }
971
+
972
+ ::-webkit-scrollbar-thumb:hover {
973
+ background: linear-gradient(135deg, #9333EA, #A855F7);
974
+ }
975
+
976
+ /* Accordion styling */
977
+ .gradio-accordion {
978
+ border-radius: 10px !important;
979
+ border: 1px solid #E9D5FF !important;
980
+ }
981
+
982
+ .gradio-accordion > .label-wrap {
983
+ background: rgba(168, 85, 247, 0.03) !important;
984
+ border-radius: 10px !important;
985
+ }
986
+
987
+ /* Hide footer */
988
+ footer {
989
+ display: none !important;
990
+ }
991
+
992
+ /* Animations */
993
+ @keyframes fadeIn {
994
+ from { opacity: 0; transform: translateY(10px); }
995
+ to { opacity: 1; transform: translateY(0); }
996
+ }
997
+
998
+ .gradio-row {
999
+ animation: fadeIn 0.4s ease-out;
1000
+ }
1001
+
1002
+ /* Label styling */
1003
+ label {
1004
+ font-weight: 600 !important;
1005
+ color: #333 !important;
1006
+ }
1007
+
1008
+ .dark label {
1009
+ color: #eee !important;
1010
+ }
1011
+
1012
+ /* Dropdown styling */
1013
+ .gradio-dropdown {
1014
+ border-radius: 8px !important;
1015
+ }
1016
+
1017
+ .gradio-dropdown select, .gradio-dropdown input {
1018
+ border: 1px solid #E9D5FF !important;
1019
+ border-radius: 8px !important;
1020
+ transition: all 0.2s ease !important;
1021
+ }
1022
+
1023
+ .gradio-dropdown select:focus, .gradio-dropdown input:focus {
1024
+ border-color: #A855F7 !important;
1025
+ box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.1) !important;
1026
+ }
1027
+
1028
+ /* Slider styling */
1029
+ .gradio-slider input[type="range"] {
1030
+ accent-color: #A855F7 !important;
1031
+ }
1032
+
1033
+ .gradio-slider .range-slider {
1034
+ background: #E9D5FF !important;
1035
+ }
1036
+
1037
+ .gradio-slider .range-slider .handle {
1038
+ background: #A855F7 !important;
1039
+ border-color: #A855F7 !important;
1040
+ }
1041
+
1042
+ /* 3D Control styling */
1043
+ #lighting-3d-control {
1044
+ min-height: 450px;
1045
+ border: 2px solid #E9D5FF !important;
1046
+ border-radius: 12px !important;
1047
+ overflow: hidden;
1048
+ }
1049
+
1050
+ /* Progress bar */
1051
+ .dark .progress-text {
1052
+ color: white !important;
1053
+ }
1054
+
1055
+ .progress-bar {
1056
+ background: linear-gradient(90deg, #A855F7, #C084FC) !important;
1057
+ }
1058
+
1059
+ /* Checkbox styling */
1060
+ .gradio-checkbox input[type="checkbox"]:checked {
1061
+ background-color: #A855F7 !important;
1062
+ border-color: #A855F7 !important;
1063
+ }
1064
+
1065
+ /* Number input styling */
1066
+ .gradio-number input {
1067
+ border: 1px solid #E9D5FF !important;
1068
+ border-radius: 8px !important;
1069
+ }
1070
+
1071
+ .gradio-number input:focus {
1072
+ border-color: #A855F7 !important;
1073
+ box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.1) !important;
1074
+ }
1075
+
1076
+ /* Gallery styling */
1077
+ .gradio-gallery {
1078
+ border-radius: 10px !important;
1079
+ }
1080
+
1081
+ .gradio-gallery .gallery-item {
1082
+ border: 2px solid #E9D5FF !important;
1083
+ border-radius: 8px !important;
1084
+ transition: all 0.2s ease !important;
1085
+ }
1086
+
1087
+ .gradio-gallery .gallery-item:hover {
1088
+ border-color: #A855F7 !important;
1089
+ box-shadow: 0 4px 12px rgba(168, 85, 247, 0.15) !important;
1090
+ }
1091
+
1092
+ /* Fillable container */
1093
+ .fillable {
1094
+ max-width: 1200px !important;
1095
+ }
1096
+ """
1097
+
1098
+ with gr.Blocks() as demo:
1099
+ gr.Markdown("# **Qwen-Image-Edit-3D-Lighting-Control**", elem_id="main-title")
1100
+ gr.Markdown("Control lighting directions using the 3D viewport or sliders. Using the [Multi-Angle-Lighting](https://huggingface.co/dx8152/Qwen-Edit-2509-Multi-Angle-Lighting) LoRA for precise lighting control.", elem_id="subtitle")
1101
+
1102
+ with gr.Row():
1103
  with gr.Column(scale=1):
1104
+ image = gr.Image(label="Input Image", type="pil", height=320)
1105
+
1106
+ gr.Markdown("### 3D Lighting Control")
1107
 
1108
+ lighting_3d = LightingControl3D(
1109
+ value={"azimuth": 0, "elevation": 0},
1110
+ elem_id="lighting-3d-control"
1111
+ )
1112
+ run_btn = gr.Button("Generate Image", variant="primary", size="lg")
1113
+
1114
+ gr.Markdown("### Slider Controls")
1115
+
1116
+ azimuth_slider = gr.Slider(
1117
+ label="Azimuth (Horizontal Rotation)",
1118
+ minimum=0,
1119
+ maximum=315,
1120
+ step=45,
1121
+ value=0,
1122
+ info="0°=front, 90°=right, 180°=rear, 270°=left"
1123
+ )
1124
+
1125
+ elevation_slider = gr.Slider(
1126
+ label="Elevation (Vertical Angle)",
1127
+ minimum=-90,
1128
+ maximum=90,
1129
+ step=90,
1130
+ value=0,
1131
+ info="-90°=from below, 0°=horizontal, 90°=from above"
1132
+ )
1133
+
1134
+ with gr.Column(scale=1):
1135
+ result = gr.Image(label="Output Image", height=555)
1136
+
1137
  with gr.Accordion("Advanced Settings", open=True):
1138
  seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
1139
  randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
1140
+
1141
  with gr.Row():
1142
  guidance_scale = gr.Slider(label="Guidance Scale", minimum=1.0, maximum=10.0, step=0.1, value=1.0)
1143
  num_inference_steps = gr.Slider(label="Inference Steps", minimum=1, maximum=20, step=1, value=4)
1144
+
1145
  with gr.Row():
1146
+ height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024)
1147
+ width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024)
1148
 
1149
+
1150
+ with gr.Row():
1151
+ prompt_preview = gr.Textbox(
1152
+ label="Generated Prompt",
1153
+ value="Light source from the Front",
1154
+ interactive=True,
1155
+ lines=1,
1156
+ )
1157
+ with gr.Accordion("About the Space", open=False):
1158
  gr.Markdown(
1159
+ "This app, *Qwen-Image-Edit-3D-Lighting-Control*, is designed by [prithivMLmods](https://huggingface.co/prithivMLmods) to accelerate fast inference with 4-step image edits and is inspired by [qwen-image-multiple-angles-3d-camera](https://huggingface.co/spaces/multimodalart/qwen-image-multiple-angles-3d-camera). For more adapters, visit: [Qwen-Image-Edit-LoRAs](https://huggingface.co/models?other=base_model:adapter:Qwen/Qwen-Image-Edit-2509)."
 
 
 
 
 
1160
  )
1161
+
1162
+ def update_prompt_from_sliders(azimuth, elevation):
1163
+ """Update prompt preview when sliders change."""
1164
+ prompt = build_lighting_prompt(azimuth, elevation)
1165
+ return prompt
1166
+
1167
+ def sync_3d_to_sliders(lighting_value):
1168
+ """Sync 3D control changes to sliders."""
1169
+ if lighting_value and isinstance(lighting_value, dict):
1170
+ az = lighting_value.get('azimuth', 0)
1171
+ el = lighting_value.get('elevation', 0)
1172
+ prompt = build_lighting_prompt(az, el)
1173
+ return az, el, prompt
1174
+ return gr.update(), gr.update(), gr.update()
1175
+
1176
+ def sync_sliders_to_3d(azimuth, elevation):
1177
+ """Sync slider changes to 3D control."""
1178
+ return {"azimuth": azimuth, "elevation": elevation}
1179
+
1180
+ def update_3d_image(image):
1181
+ """Update the 3D component with the uploaded image."""
1182
+ if image is None:
1183
+ return gr.update(imageUrl=None)
1184
+
1185
+ import base64
1186
+ from io import BytesIO
1187
+ buffered = BytesIO()
1188
+ image.save(buffered, format="PNG")
1189
+ img_str = base64.b64encode(buffered.getvalue()).decode()
1190
+ data_url = f"data:image/png;base64,{img_str}"
1191
+ return gr.update(imageUrl=data_url)
1192
+
1193
+ for slider in [azimuth_slider, elevation_slider]:
1194
+ slider.change(
1195
+ fn=update_prompt_from_sliders,
1196
+ inputs=[azimuth_slider, elevation_slider],
1197
+ outputs=[prompt_preview]
1198
+ )
1199
+
1200
+ lighting_3d.change(
1201
+ fn=sync_3d_to_sliders,
1202
+ inputs=[lighting_3d],
1203
+ outputs=[azimuth_slider, elevation_slider, prompt_preview]
1204
+ )
1205
+
1206
+ for slider in [azimuth_slider, elevation_slider]:
1207
+ slider.release(
1208
+ fn=sync_sliders_to_3d,
1209
+ inputs=[azimuth_slider, elevation_slider],
1210
+ outputs=[lighting_3d]
1211
+ )
1212
+
1213
  run_btn.click(
1214
+ fn=infer_lighting_edit,
1215
+ inputs=[image, azimuth_slider, elevation_slider, seed, randomize_seed, guidance_scale, num_inference_steps, height, width],
1216
+ outputs=[result, seed, prompt_preview]
 
 
 
 
 
 
 
1217
  )
1218
+
1219
+ image.upload(
1220
  fn=update_dimensions_on_upload,
1221
+ inputs=[image],
1222
+ outputs=[width, height]
1223
+ ).then(
1224
+ fn=update_3d_image,
1225
+ inputs=[image],
1226
+ outputs=[lighting_3d]
1227
  )
1228
+
1229
+ image.clear(
1230
+ fn=lambda: gr.update(imageUrl=None),
1231
+ outputs=[lighting_3d]
1232
+ )
1233
+
1234
  if __name__ == "__main__":
1235
+ head = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
1236
+ demo.launch(head=head, css=css, theme=purple_theme, mcp_server=True, ssr_mode=False, show_error=True)