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)