import gradio as gr import numpy as np import random import torch import spaces from typing import Iterable from PIL import Image from diffusers import FlowMatchEulerDiscreteScheduler from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3 from gradio.themes import Soft from gradio.themes.utils import colors, fonts, sizes colors.purple = colors.Color( name="purple", c50="#FAF5FF", c100="#F3E8FF", c200="#E9D5FF", c300="#DAB2FF", c400="#C084FC", c500="#A855F7", c600="#9333EA", c700="#7E22CE", c800="#6B21A8", c900="#581C87", c950="#3B0764", ) class PurpleTheme(Soft): def __init__( self, *, primary_hue: colors.Color | str = colors.gray, secondary_hue: colors.Color | str = colors.purple, neutral_hue: colors.Color | str = colors.slate, text_size: sizes.Size | str = sizes.text_lg, font: fonts.Font | str | Iterable[fonts.Font | str] = ( fonts.GoogleFont("Outfit"), "Arial", "sans-serif", ), font_mono: fonts.Font | str | Iterable[fonts.Font | str] = ( fonts.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace", ), ): super().__init__( primary_hue=primary_hue, secondary_hue=secondary_hue, neutral_hue=neutral_hue, text_size=text_size, font=font, font_mono=font_mono, ) super().set( background_fill_primary="*primary_50", background_fill_primary_dark="*primary_900", body_background_fill="linear-gradient(135deg, *primary_200, *primary_100)", body_background_fill_dark="linear-gradient(135deg, *primary_900, *primary_800)", button_primary_text_color="white", button_primary_text_color_hover="white", button_primary_background_fill="linear-gradient(90deg, *secondary_500, *secondary_600)", button_primary_background_fill_hover="linear-gradient(90deg, *secondary_600, *secondary_700)", button_primary_background_fill_dark="linear-gradient(90deg, *secondary_600, *secondary_700)", button_primary_background_fill_hover_dark="linear-gradient(90deg, *secondary_500, *secondary_600)", button_secondary_text_color="black", button_secondary_text_color_hover="white", button_secondary_background_fill="linear-gradient(90deg, *primary_300, *primary_300)", button_secondary_background_fill_hover="linear-gradient(90deg, *primary_400, *primary_400)", button_secondary_background_fill_dark="linear-gradient(90deg, *primary_500, *primary_600)", button_secondary_background_fill_hover_dark="linear-gradient(90deg, *primary_500, *primary_500)", slider_color="*secondary_500", slider_color_dark="*secondary_600", block_title_text_weight="600", block_border_width="3px", block_shadow="*shadow_drop_lg", button_primary_shadow="*shadow_drop_lg", button_large_padding="11px", color_accent_soft="*primary_100", block_label_background_fill="*primary_200", ) purple_theme = PurpleTheme() MAX_SEED = np.iinfo(np.int32).max dtype = torch.bfloat16 device = "cuda" if torch.cuda.is_available() else "cpu" pipe = QwenImageEditPlusPipeline.from_pretrained( "Qwen/Qwen-Image-Edit-2509", transformer=QwenImageTransformer2DModel.from_pretrained( "prithivMLmods/Qwen-Image-Edit-Rapid-AIO-V4", torch_dtype=dtype, device_map='cuda' ), torch_dtype=dtype ).to(device) try: pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3()) print("Flash Attention 3 Processor set successfully.") except Exception as e: print(f"Warning: Could not set FA3 processor: {e}") ADAPTER_SPECS = { "Multi-Angle-Lighting": { "repo": "dx8152/Qwen-Edit-2509-Multi-Angle-Lighting", "weights": "多角度灯光-251116.safetensors", "adapter_name": "multi-angle-lighting" }, } loaded = False AZIMUTH_MAP = { 0: "Front", 45: "Right Front", 90: "Right", 135: "Right Rear", 180: "Rear", 225: "Left Rear", 270: "Left", 315: "Left Front" } ELEVATION_MAP = { -90: "Below", 0: "", 90: "Above" } def snap_to_nearest(value, options): """Snap a value to the nearest option in a list.""" return min(options, key=lambda x: abs(x - value)) def build_lighting_prompt(azimuth: float, elevation: float) -> str: """ Build a lighting prompt from azimuth and elevation values. """ azimuth_snapped = snap_to_nearest(azimuth, list(AZIMUTH_MAP.keys())) elevation_snapped = snap_to_nearest(elevation, list(ELEVATION_MAP.keys())) if elevation_snapped == 0: return f"Light source from the {AZIMUTH_MAP[azimuth_snapped]}" else: return f"Light source from {ELEVATION_MAP[elevation_snapped]}" @spaces.GPU def infer_lighting_edit( image: Image.Image, azimuth: float = 0.0, elevation: float = 0.0, seed: int = 0, randomize_seed: bool = True, guidance_scale: float = 1.0, num_inference_steps: int = 4, height: int = 1024, width: int = 1024, ): global loaded progress = gr.Progress(track_tqdm=True) if not loaded: pipe.load_lora_weights( ADAPTER_SPECS["Multi-Angle-Lighting"]["repo"], weight_name=ADAPTER_SPECS["Multi-Angle-Lighting"]["weights"], adapter_name=ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"] ) pipe.set_adapters([ADAPTER_SPECS["Multi-Angle-Lighting"]["adapter_name"]], adapter_weights=[1.0]) loaded = True prompt = build_lighting_prompt(azimuth, elevation) print(f"Generated Prompt: {prompt}") progress(0.7, desc="Fast lighting enabled....") if randomize_seed: seed = random.randint(0, MAX_SEED) generator = torch.Generator(device=device).manual_seed(seed) if image is None: raise gr.Error("Please upload an image first.") pil_image = image.convert("RGB") if isinstance(image, Image.Image) else Image.open(image).convert("RGB") result = pipe( image=[pil_image], prompt=prompt, height=height if height != 0 else None, width=width if width != 0 else None, num_inference_steps=num_inference_steps, generator=generator, guidance_scale=guidance_scale, num_images_per_prompt=1, ).images[0] return result, seed, prompt def update_dimensions_on_upload(image): if image is None: return 1024, 1024 original_width, original_height = image.size if original_width > original_height: new_width = 1024 aspect_ratio = original_height / original_width new_height = int(new_width * aspect_ratio) else: new_height = 1024 aspect_ratio = original_width / original_height new_width = int(new_height * aspect_ratio) new_width = (new_width // 8) * 8 new_height = (new_height // 8) * 8 return new_width, new_height class LightingControl3D(gr.HTML): """ A 3D lighting control component using Three.js. """ def __init__(self, value=None, imageUrl=None, **kwargs): if value is None: value = {"azimuth": 0, "elevation": 0} html_template = """
""" js_on_load = """ (() => { const wrapper = element.querySelector('#lighting-control-wrapper'); const promptOverlay = element.querySelector('#prompt-overlay'); const initScene = () => { if (typeof THREE === 'undefined') { setTimeout(initScene, 100); return; } const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); const camera = new THREE.PerspectiveCamera(50, wrapper.clientWidth / wrapper.clientHeight, 0.1, 1000); camera.position.set(4.5, 3, 4.5); camera.lookAt(0, 0.75, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(wrapper.clientWidth, wrapper.clientHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; wrapper.insertBefore(renderer.domElement, promptOverlay); scene.add(new THREE.AmbientLight(0xffffff, 0.1)); const ground = new THREE.Mesh( new THREE.PlaneGeometry(10, 10), new THREE.ShadowMaterial({ opacity: 0.3 }) ); ground.rotation.x = -Math.PI / 2; ground.position.y = 0; ground.receiveShadow = true; scene.add(ground); scene.add(new THREE.GridHelper(8, 16, 0x333333, 0x222222)); const CENTER = new THREE.Vector3(0, 0.75, 0); const BASE_DISTANCE = 2.5; const AZIMUTH_RADIUS = 2.4; const ELEVATION_RADIUS = 1.8; let azimuthAngle = props.value?.azimuth || 0; let elevationAngle = props.value?.elevation || 0; const azimuthSteps = [0, 45, 90, 135, 180, 225, 270, 315]; const elevationSteps = [-90, 0, 90]; const azimuthNames = { 0: 'Front', 45: 'Right Front', 90: 'Right', 135: 'Right Rear', 180: 'Rear', 225: 'Left Rear', 270: 'Left', 315: 'Left Front' }; const elevationNames = { '-90': 'Below', '0': '', '90': 'Above' }; function snapToNearest(value, steps) { return steps.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev); } function createPlaceholderTexture() { const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 256; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#3a3a4a'; ctx.fillRect(0, 0, 256, 256); ctx.fillStyle = '#ffcc99'; ctx.beginPath(); ctx.arc(128, 128, 80, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#333'; ctx.beginPath(); ctx.arc(100, 110, 10, 0, Math.PI * 2); ctx.arc(156, 110, 10, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#333'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(128, 130, 35, 0.2, Math.PI - 0.2); ctx.stroke(); return new THREE.CanvasTexture(canvas); } let currentTexture = createPlaceholderTexture(); const planeMaterial = new THREE.MeshStandardMaterial({ map: currentTexture, side: THREE.DoubleSide, roughness: 0.5, metalness: 0 }); let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial); targetPlane.position.copy(CENTER); targetPlane.receiveShadow = true; scene.add(targetPlane); function updateTextureFromUrl(url) { if (!url) { planeMaterial.map = createPlaceholderTexture(); planeMaterial.needsUpdate = true; scene.remove(targetPlane); targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMaterial); targetPlane.position.copy(CENTER); targetPlane.receiveShadow = true; scene.add(targetPlane); return; } const loader = new THREE.TextureLoader(); loader.crossOrigin = 'anonymous'; loader.load(url, (texture) => { texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; planeMaterial.map = texture; planeMaterial.needsUpdate = true; const img = texture.image; if (img && img.width && img.height) { const aspect = img.width / img.height; const maxSize = 1.5; let planeWidth, planeHeight; if (aspect > 1) { planeWidth = maxSize; planeHeight = maxSize / aspect; } else { planeHeight = maxSize; planeWidth = maxSize * aspect; } scene.remove(targetPlane); targetPlane = new THREE.Mesh( new THREE.PlaneGeometry(planeWidth, planeHeight), planeMaterial ); targetPlane.position.copy(CENTER); targetPlane.receiveShadow = true; scene.add(targetPlane); } }, undefined, (err) => { console.error('Failed to load texture:', err); }); } if (props.imageUrl) { updateTextureFromUrl(props.imageUrl); } // --- NEW LIGHT MODEL: SQUARE STUDIO LIGHT WITH RAYS --- const lightGroup = new THREE.Group(); // 1. Studio Panel Housing (Black, thin, square) const panelGeo = new THREE.BoxGeometry(0.8, 0.8, 0.1); const panelMat = new THREE.MeshStandardMaterial({ color: 0x111111, // Black body roughness: 0.3, metalness: 0.8 }); const panel = new THREE.Mesh(panelGeo, panelMat); // Shift box slightly back so the front face is at z=0 relative to the group panel.position.z = -0.05; lightGroup.add(panel); // 2. Emissive Light Face (Bright White) const faceGeo = new THREE.PlaneGeometry(0.75, 0.75); const faceMat = new THREE.MeshBasicMaterial({ color: 0xffffff, // Pure white side: THREE.DoubleSide }); const face = new THREE.Mesh(faceGeo, faceMat); face.position.z = 0.01; // Slightly in front of the black housing lightGroup.add(face); // 3. Volumetric Light Rays (Transparent Cone) // CylinderGeometry(radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded) const beamHeight = 4.0; const beamGeo = new THREE.CylinderGeometry(0.38, 1.2, beamHeight, 32, 1, true); // Rotate cylinder to point along +Z axis beamGeo.rotateX(-Math.PI / 2); // Translate so the top (start) of the beam sits on the light face beamGeo.translate(0, 0, beamHeight / 2); const beamMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.12, // Low opacity for subtleness side: THREE.DoubleSide, depthWrite: false, // Important for transparent sorting blending: THREE.AdditiveBlending // Glow effect }); const beam = new THREE.Mesh(beamGeo, beamMat); lightGroup.add(beam); // Actual SpotLight Calculation Source const spotLight = new THREE.SpotLight(0xffffff, 10, 10, Math.PI / 3, 1, 1); spotLight.position.set(0, 0, 0); // Position at the center of the custom mesh spotLight.castShadow = true; spotLight.shadow.mapSize.width = 1024; spotLight.shadow.mapSize.height = 1024; spotLight.shadow.camera.near = 0.5; spotLight.shadow.camera.far = 500; spotLight.shadow.bias = -0.005; lightGroup.add(spotLight); const lightTarget = new THREE.Object3D(); lightTarget.position.copy(CENTER); scene.add(lightTarget); spotLight.target = lightTarget; scene.add(lightGroup); // --- CONTROLS --- const azimuthRing = new THREE.Mesh( new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 64), new THREE.MeshStandardMaterial({ color: 0xffff00, emissive: 0xffff00, emissiveIntensity: 0.3 }) ); azimuthRing.rotation.x = Math.PI / 2; azimuthRing.position.y = 0.05; scene.add(azimuthRing); const azimuthHandle = new THREE.Mesh( new THREE.SphereGeometry(0.18, 16, 16), new THREE.MeshStandardMaterial({ color: 0xffff00, emissive: 0xffff00, emissiveIntensity: 0.5 }) ); azimuthHandle.userData.type = 'azimuth'; scene.add(azimuthHandle); const arcPoints = []; for (let i = 0; i <= 32; i++) { const angle = THREE.MathUtils.degToRad(-90 + (180 * i / 32)); arcPoints.push(new THREE.Vector3(-0.8, ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, ELEVATION_RADIUS * Math.cos(angle))); } const arcCurve = new THREE.CatmullRomCurve3(arcPoints); const elevationArc = new THREE.Mesh( new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false), new THREE.MeshStandardMaterial({ color: 0x0000ff, emissive: 0x0000ff, emissiveIntensity: 0.3 }) ); scene.add(elevationArc); const elevationHandle = new THREE.Mesh( new THREE.SphereGeometry(0.18, 16, 16), new THREE.MeshStandardMaterial({ color: 0x0000ff, emissive: 0x0000ff, emissiveIntensity: 0.5 }) ); elevationHandle.userData.type = 'elevation'; scene.add(elevationHandle); const refreshBtn = document.createElement('button'); refreshBtn.innerHTML = 'Reset View'; refreshBtn.style.position = 'absolute'; refreshBtn.style.top = '15px'; refreshBtn.style.right = '15px'; refreshBtn.style.background = '#9333EA'; refreshBtn.style.color = '#fff'; refreshBtn.style.border = 'none'; refreshBtn.style.padding = '8px 16px'; refreshBtn.style.borderRadius = '6px'; refreshBtn.style.cursor = 'pointer'; refreshBtn.style.zIndex = '10'; refreshBtn.style.fontSize = '14px'; refreshBtn.style.fontWeight = '600'; refreshBtn.style.fontFamily = 'system-ui, sans-serif'; refreshBtn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.3)'; refreshBtn.style.transition = 'background 0.2s'; refreshBtn.onmouseover = () => refreshBtn.style.background = '#A855F7'; refreshBtn.onmouseout = () => refreshBtn.style.background = '#9333EA'; wrapper.appendChild(refreshBtn); refreshBtn.addEventListener('click', () => { azimuthAngle = 0; elevationAngle = 0; updatePositions(); updatePropsAndTrigger(); }); function updatePositions() { const distance = BASE_DISTANCE; const azRad = THREE.MathUtils.degToRad(azimuthAngle); const elRad = THREE.MathUtils.degToRad(elevationAngle); const lightX = distance * Math.sin(azRad) * Math.cos(elRad); const lightY = distance * Math.sin(elRad) + CENTER.y; const lightZ = distance * Math.cos(azRad) * Math.cos(elRad); lightGroup.position.set(lightX, lightY, lightZ); lightGroup.lookAt(CENTER); azimuthHandle.position.set(AZIMUTH_RADIUS * Math.sin(azRad), 0.05, AZIMUTH_RADIUS * Math.cos(azRad)); elevationHandle.position.set(-0.8, ELEVATION_RADIUS * Math.sin(elRad) + CENTER.y, ELEVATION_RADIUS * Math.cos(elRad)); const azSnap = snapToNearest(azimuthAngle, azimuthSteps); const elSnap = snapToNearest(elevationAngle, elevationSteps); let prompt = 'Light source from'; if (elSnap !== 0) { prompt += ' ' + elevationNames[String(elSnap)]; } else { prompt += ' the ' + azimuthNames[azSnap]; } promptOverlay.textContent = prompt; } function updatePropsAndTrigger() { const azSnap = snapToNearest(azimuthAngle, azimuthSteps); const elSnap = snapToNearest(elevationAngle, elevationSteps); props.value = { azimuth: azSnap, elevation: elSnap }; trigger('change', props.value); } const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); let isDragging = false; let dragTarget = null; let dragStartMouse = new THREE.Vector2(); const intersection = new THREE.Vector3(); const canvas = renderer.domElement; canvas.addEventListener('mousedown', (e) => { const rect = canvas.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]); if (intersects.length > 0) { isDragging = true; dragTarget = intersects[0].object; dragTarget.material.emissiveIntensity = 1.0; dragTarget.scale.setScalar(1.3); dragStartMouse.copy(mouse); canvas.style.cursor = 'grabbing'; } }); canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; if (isDragging && dragTarget) { raycaster.setFromCamera(mouse, camera); if (dragTarget.userData.type === 'azimuth') { const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05); if (raycaster.ray.intersectPlane(plane, intersection)) { azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z)); if (azimuthAngle < 0) azimuthAngle += 360; } } else if (dragTarget.userData.type === 'elevation') { const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8); if (raycaster.ray.intersectPlane(plane, intersection)) { const relY = intersection.y - CENTER.y; const relZ = intersection.z; elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90); } } updatePositions(); } else { raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]); [azimuthHandle, elevationHandle].forEach(h => { h.material.emissiveIntensity = 0.5; h.scale.setScalar(1); }); if (intersects.length > 0) { intersects[0].object.material.emissiveIntensity = 0.8; intersects[0].object.scale.setScalar(1.1); canvas.style.cursor = 'grab'; } else { canvas.style.cursor = 'default'; } } }); const onMouseUp = () => { if (dragTarget) { dragTarget.material.emissiveIntensity = 0.5; dragTarget.scale.setScalar(1); const targetAz = snapToNearest(azimuthAngle, azimuthSteps); const targetEl = snapToNearest(elevationAngle, elevationSteps); const startAz = azimuthAngle, startEl = elevationAngle; const startTime = Date.now(); function animateSnap() { const t = Math.min((Date.now() - startTime) / 200, 1); const ease = 1 - Math.pow(1 - t, 3); let azDiff = targetAz - startAz; if (azDiff > 180) azDiff -= 360; if (azDiff < -180) azDiff += 360; azimuthAngle = startAz + azDiff * ease; if (azimuthAngle < 0) azimuthAngle += 360; if (azimuthAngle >= 360) azimuthAngle -= 360; elevationAngle = startEl + (targetEl - startEl) * ease; updatePositions(); if (t < 1) requestAnimationFrame(animateSnap); else updatePropsAndTrigger(); } animateSnap(); } isDragging = false; dragTarget = null; canvas.style.cursor = 'default'; }; canvas.addEventListener('mouseup', onMouseUp); canvas.addEventListener('mouseleave', onMouseUp); canvas.addEventListener('touchstart', (e) => { e.preventDefault(); const touch = e.touches[0]; const rect = canvas.getBoundingClientRect(); mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects([azimuthHandle, elevationHandle]); if (intersects.length > 0) { isDragging = true; dragTarget = intersects[0].object; dragTarget.material.emissiveIntensity = 1.0; dragTarget.scale.setScalar(1.3); dragStartMouse.copy(mouse); } }, { passive: false }); canvas.addEventListener('touchmove', (e) => { e.preventDefault(); const touch = e.touches[0]; const rect = canvas.getBoundingClientRect(); mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1; if (isDragging && dragTarget) { raycaster.setFromCamera(mouse, camera); if (dragTarget.userData.type === 'azimuth') { const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.05); if (raycaster.ray.intersectPlane(plane, intersection)) { azimuthAngle = THREE.MathUtils.radToDeg(Math.atan2(intersection.x, intersection.z)); if (azimuthAngle < 0) azimuthAngle += 360; } } else if (dragTarget.userData.type === 'elevation') { const plane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -0.8); if (raycaster.ray.intersectPlane(plane, intersection)) { const relY = intersection.y - CENTER.y; const relZ = intersection.z; elevationAngle = THREE.MathUtils.clamp(THREE.MathUtils.radToDeg(Math.atan2(relY, relZ)), -90, 90); } } updatePositions(); } }, { passive: false }); canvas.addEventListener('touchend', (e) => { e.preventDefault(); onMouseUp(); }, { passive: false }); canvas.addEventListener('touchcancel', (e) => { e.preventDefault(); onMouseUp(); }, { passive: false }); updatePositions(); function render() { requestAnimationFrame(render); renderer.render(scene, camera); } render(); new ResizeObserver(() => { camera.aspect = wrapper.clientWidth / wrapper.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(wrapper.clientWidth, wrapper.clientHeight); }).observe(wrapper); wrapper._updateFromProps = (newVal) => { if (newVal && typeof newVal === 'object') { azimuthAngle = newVal.azimuth ?? azimuthAngle; elevationAngle = newVal.elevation ?? elevationAngle; updatePositions(); } }; wrapper._updateTexture = updateTextureFromUrl; let lastImageUrl = props.imageUrl; let lastValue = JSON.stringify(props.value); setInterval(() => { if (props.imageUrl !== lastImageUrl) { lastImageUrl = props.imageUrl; updateTextureFromUrl(props.imageUrl); } const currentValue = JSON.stringify(props.value); if (currentValue !== lastValue) { lastValue = currentValue; if (props.value && typeof props.value === 'object') { azimuthAngle = props.value.azimuth ?? azimuthAngle; elevationAngle = props.value.elevation ?? elevationAngle; updatePositions(); } } }, 100); }; initScene(); })(); """ super().__init__( value=value, html_template=html_template, js_on_load=js_on_load, imageUrl=imageUrl, **kwargs ) css = """ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap'); /* Background grid pattern - Purple theme */ 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; } /* Dark mode grid */ .dark body, .dark .gradio-container { background-color: #1a1a1a !important; background-image: linear-gradient(rgba(168, 85, 247, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(168, 85, 247, 0.1) 1px, transparent 1px) !important; background-size: 40px 40px !important; } #col-container { margin: 0 auto; max-width: 1200px; } /* Main title styling */ #main-title { text-align: center !important; padding: 1rem 0 0.5rem 0; } #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: -0.02em; } @keyframes gradient-shift { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } } /* Subtitle styling */ #subtitle { text-align: center !important; margin-bottom: 1.5rem; } #subtitle p { margin: 0 auto; color: #666666; font-size: 1rem; text-align: center !important; } #subtitle a { color: #A855F7 !important; text-decoration: none; font-weight: 500; } #subtitle a:hover { text-decoration: underline; } /* Center all markdown text */ .gradio-container > .main > .wrap > .contain > .gap > div:nth-child(2) { text-align: center !important; } /* Card styling */ .gradio-group { background: rgba(255, 255, 255, 0.9) !important; border: 2px solid #E9D5FF !important; border-radius: 12px !important; box-shadow: 0 4px 24px rgba(168, 85, 247, 0.08) !important; backdrop-filter: blur(10px); transition: all 0.3s ease; } .gradio-group:hover { box-shadow: 0 8px 32px rgba(168, 85, 247, 0.12) !important; border-color: #C084FC !important; } .dark .gradio-group { background: rgba(30, 30, 30, 0.9) !important; border-color: rgba(168, 85, 247, 0.3) !important; } /* Image upload area */ .gradio-image { border-radius: 10px !important; overflow: hidden; border: 2px dashed #C084FC !important; transition: all 0.3s ease; } .gradio-image:hover { border-color: #A855F7 !important; background: rgba(168, 85, 247, 0.02) !important; } /* Radio buttons */ .gradio-radio { border-radius: 8px !important; } .gradio-radio label { border-radius: 6px !important; transition: all 0.2s ease !important; border: 1px solid transparent !important; } .gradio-radio label:hover { background: rgba(168, 85, 247, 0.05) !important; } .gradio-radio label.selected { background: rgba(168, 85, 247, 0.1) !important; border-color: #A855F7 !important; } /* Primary button */ .primary { border-radius: 8px !important; font-weight: 600 !important; letter-spacing: 0.02em !important; transition: all 0.3s ease !important; } .primary:hover { transform: translateY(-2px) !important; } /* Tabs styling */ .tab-nav { border-bottom: 2px solid #E9D5FF !important; } .tab-nav button { font-weight: 500 !important; padding: 10px 18px !important; border-radius: 8px 8px 0 0 !important; transition: all 0.2s ease !important; } .tab-nav button.selected { background: rgba(168, 85, 247, 0.1) !important; border-bottom: 2px solid #A855F7 !important; } /* Output textbox */ .gradio-textbox textarea { font-family: 'IBM Plex Mono', monospace !important; font-size: 0.95rem !important; line-height: 1.7 !important; background: rgba(255, 255, 255, 0.95) !important; border: 1px solid #E9D5FF !important; border-radius: 8px !important; } .dark .gradio-textbox textarea { background: rgba(30, 30, 30, 0.95) !important; border-color: rgba(168, 85, 247, 0.2) !important; } /* Markdown output */ .gradio-markdown { font-family: 'Outfit', sans-serif !important; line-height: 1.7 !important; } .gradio-markdown code { font-family: 'IBM Plex Mono', monospace !important; background: rgba(168, 85, 247, 0.08) !important; padding: 2px 6px !important; border-radius: 4px !important; color: #7E22CE !important; } .gradio-markdown pre { background: rgba(168, 85, 247, 0.05) !important; border: 1px solid #E9D5FF !important; border-radius: 8px !important; padding: 1rem !important; } /* Examples section */ .gradio-examples { border-radius: 10px !important; } .gradio-examples .gallery-item { border: 2px solid #E9D5FF !important; border-radius: 8px !important; transition: all 0.2s ease !important; } .gradio-examples .gallery-item:hover { border-color: #A855F7 !important; transform: translateY(-2px) !important; box-shadow: 0 4px 12px rgba(168, 85, 247, 0.15) !important; } /* Scrollbar styling */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: rgba(168, 85, 247, 0.05); border-radius: 4px; } ::-webkit-scrollbar-thumb { background: linear-gradient(135deg, #A855F7, #C084FC); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: linear-gradient(135deg, #9333EA, #A855F7); } /* Accordion styling */ .gradio-accordion { border-radius: 10px !important; border: 1px solid #E9D5FF !important; } .gradio-accordion > .label-wrap { background: rgba(168, 85, 247, 0.03) !important; border-radius: 10px !important; } /* Hide footer */ footer { display: none !important; } /* Animations */ @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .gradio-row { animation: fadeIn 0.4s ease-out; } /* Label styling */ label { font-weight: 600 !important; color: #333 !important; } .dark label { color: #eee !important; } /* Dropdown styling */ .gradio-dropdown { border-radius: 8px !important; } .gradio-dropdown select, .gradio-dropdown input { border: 1px solid #E9D5FF !important; border-radius: 8px !important; transition: all 0.2s ease !important; } .gradio-dropdown select:focus, .gradio-dropdown input:focus { border-color: #A855F7 !important; box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.1) !important; } /* Slider styling */ .gradio-slider input[type="range"] { accent-color: #A855F7 !important; } .gradio-slider .range-slider { background: #E9D5FF !important; } .gradio-slider .range-slider .handle { background: #A855F7 !important; border-color: #A855F7 !important; } /* 3D Control styling */ #lighting-3d-control { min-height: 450px; border: 2px solid #E9D5FF !important; border-radius: 12px !important; overflow: hidden; } /* Progress bar */ .dark .progress-text { color: white !important; } .progress-bar { background: linear-gradient(90deg, #A855F7, #C084FC) !important; } /* Checkbox styling */ .gradio-checkbox input[type="checkbox"]:checked { background-color: #A855F7 !important; border-color: #A855F7 !important; } /* Number input styling */ .gradio-number input { border: 1px solid #E9D5FF !important; border-radius: 8px !important; } .gradio-number input:focus { border-color: #A855F7 !important; box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.1) !important; } /* Gallery styling */ .gradio-gallery { border-radius: 10px !important; } .gradio-gallery .gallery-item { border: 2px solid #E9D5FF !important; border-radius: 8px !important; transition: all 0.2s ease !important; } .gradio-gallery .gallery-item:hover { border-color: #A855F7 !important; box-shadow: 0 4px 12px rgba(168, 85, 247, 0.15) !important; } /* Fillable container */ .fillable { max-width: 1200px !important; } """ with gr.Blocks() as demo: gr.Markdown("# **Qwen-Image-Edit-3D-Lighting-Control**", elem_id="main-title") 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") with gr.Row(): with gr.Column(scale=1): image = gr.Image(label="Input Image", type="pil", height=320) gr.Markdown("### 3D Lighting Control") lighting_3d = LightingControl3D( value={"azimuth": 0, "elevation": 0}, elem_id="lighting-3d-control" ) run_btn = gr.Button("Generate Image", variant="primary", size="lg") gr.Markdown("### Slider Controls") azimuth_slider = gr.Slider( label="Azimuth (Horizontal Rotation)", minimum=0, maximum=315, step=45, value=0, info="0°=front, 90°=right, 180°=rear, 270°=left" ) elevation_slider = gr.Slider( label="Elevation (Vertical Angle)", minimum=-90, maximum=90, step=90, value=0, info="-90°=from below, 0°=horizontal, 90°=from above" ) with gr.Column(scale=1): result = gr.Image(label="Output Image", height=555) with gr.Accordion("Advanced Settings", open=True): seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0) randomize_seed = gr.Checkbox(label="Randomize Seed", value=True) with gr.Row(): guidance_scale = gr.Slider(label="Guidance Scale", minimum=1.0, maximum=10.0, step=0.1, value=1.0) num_inference_steps = gr.Slider(label="Inference Steps", minimum=1, maximum=20, step=1, value=4) with gr.Row(): height = gr.Slider(label="Height", minimum=256, maximum=2048, step=8, value=1024) width = gr.Slider(label="Width", minimum=256, maximum=2048, step=8, value=1024) with gr.Row(): prompt_preview = gr.Textbox( label="Generated Prompt", value="Light source from the Front", interactive=True, lines=1, ) with gr.Accordion("About the Space", open=False): gr.Markdown( "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)." ) def update_prompt_from_sliders(azimuth, elevation): """Update prompt preview when sliders change.""" prompt = build_lighting_prompt(azimuth, elevation) return prompt def sync_3d_to_sliders(lighting_value): """Sync 3D control changes to sliders.""" if lighting_value and isinstance(lighting_value, dict): az = lighting_value.get('azimuth', 0) el = lighting_value.get('elevation', 0) prompt = build_lighting_prompt(az, el) return az, el, prompt return gr.update(), gr.update(), gr.update() def sync_sliders_to_3d(azimuth, elevation): """Sync slider changes to 3D control.""" return {"azimuth": azimuth, "elevation": elevation} def update_3d_image(image): """Update the 3D component with the uploaded image.""" if image is None: return gr.update(imageUrl=None) import base64 from io import BytesIO buffered = BytesIO() image.save(buffered, format="PNG") img_str = base64.b64encode(buffered.getvalue()).decode() data_url = f"data:image/png;base64,{img_str}" return gr.update(imageUrl=data_url) for slider in [azimuth_slider, elevation_slider]: slider.change( fn=update_prompt_from_sliders, inputs=[azimuth_slider, elevation_slider], outputs=[prompt_preview] ) lighting_3d.change( fn=sync_3d_to_sliders, inputs=[lighting_3d], outputs=[azimuth_slider, elevation_slider, prompt_preview] ) for slider in [azimuth_slider, elevation_slider]: slider.release( fn=sync_sliders_to_3d, inputs=[azimuth_slider, elevation_slider], outputs=[lighting_3d] ) run_btn.click( fn=infer_lighting_edit, inputs=[image, azimuth_slider, elevation_slider, seed, randomize_seed, guidance_scale, num_inference_steps, height, width], outputs=[result, seed, prompt_preview] ) image.upload( fn=update_dimensions_on_upload, inputs=[image], outputs=[width, height] ).then( fn=update_3d_image, inputs=[image], outputs=[lighting_3d] ) image.clear( fn=lambda: gr.update(imageUrl=None), outputs=[lighting_3d] ) if __name__ == "__main__": head = '' demo.launch(head=head, css=css, theme=purple_theme, mcp_server=True, ssr_mode=False, show_error=True)