prithivMLmods's picture
update app
361ae56 verified
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 = """
<div id="lighting-control-wrapper" style="width: 100%; height: 450px; position: relative; background: #1a1a1a; border-radius: 12px; overflow: hidden;">
<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>
</div>
"""
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 = '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>'
demo.launch(head=head, css=css, theme=purple_theme, mcp_server=True, ssr_mode=False, show_error=True)