Spaces:
Running
Running
feat: 3D Three.js orbit viewer — camera marker moves live on sliders
Browse files
app.py
CHANGED
|
@@ -1,11 +1,6 @@
|
|
| 1 |
import random
|
| 2 |
import base64
|
| 3 |
import io
|
| 4 |
-
import numpy as np
|
| 5 |
-
import matplotlib
|
| 6 |
-
matplotlib.use("Agg")
|
| 7 |
-
import matplotlib.pyplot as plt
|
| 8 |
-
import matplotlib.patches as mpatches
|
| 9 |
import fal_client
|
| 10 |
import gradio as gr
|
| 11 |
from PIL import Image
|
|
@@ -26,11 +21,6 @@ ELEVATION_NAMES = {
|
|
| 26 |
}
|
| 27 |
DISTANCE_NAMES = {0.6: "close-up", 1.0: "medium shot", 1.8: "wide shot"}
|
| 28 |
|
| 29 |
-
# Couleur par élévation
|
| 30 |
-
ELEVATION_COLORS = {-30: "#4fc3f7", 0: "#69f0ae", 30: "#ffb74d", 60: "#ef5350"}
|
| 31 |
-
ELEVATION_LABELS = {-30: "⬇ low", 0: "➡ eye", 30: "↗ high", 60: "⬆ top"}
|
| 32 |
-
|
| 33 |
-
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 34 |
|
| 35 |
def snap_to_nearest(value, options):
|
| 36 |
return min(options, key=lambda x: abs(x - value))
|
|
@@ -43,116 +33,21 @@ def build_camera_prompt(azimuth: float, elevation: float, distance: float) -> st
|
|
| 43 |
return f"<sks> {AZIMUTH_NAMES[az]}, {ELEVATION_NAMES[el]}, {DISTANCE_NAMES[di]}"
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def pil_to_data_uri(img: Image.Image) -> str:
|
| 47 |
buf = io.BytesIO()
|
| 48 |
img.save(buf, format="PNG")
|
| 49 |
b64 = base64.b64encode(buf.getvalue()).decode()
|
| 50 |
return f"data:image/png;base64,{b64}"
|
| 51 |
|
| 52 |
-
# ── Diagramme caméra ──────────────────────────────────────────────────────────
|
| 53 |
-
|
| 54 |
-
def draw_camera_diagram(azimuth: float, elevation: float, distance: float):
|
| 55 |
-
az = snap_to_nearest(azimuth, AZIMUTHS)
|
| 56 |
-
el = snap_to_nearest(elevation, ELEVATIONS)
|
| 57 |
-
di = snap_to_nearest(distance, DISTANCES)
|
| 58 |
-
|
| 59 |
-
# Azimut en coordonnées polaires : 0° = haut (front), sens horaire
|
| 60 |
-
# matplotlib polar : 0 = droite, sens anti-horaire → convertir
|
| 61 |
-
az_rad = np.radians(90 - az)
|
| 62 |
-
|
| 63 |
-
# Rayon normalisé selon distance (3 orbites)
|
| 64 |
-
r_map = {0.6: 0.35, 1.0: 0.60, 1.8: 0.88}
|
| 65 |
-
r = r_map[di]
|
| 66 |
-
|
| 67 |
-
cam_color = ELEVATION_COLORS[el]
|
| 68 |
-
prompt = build_camera_prompt(azimuth, elevation, distance)
|
| 69 |
-
|
| 70 |
-
BG = "#111118"
|
| 71 |
-
GRID = "#1e1e30"
|
| 72 |
-
ORBIT = "#2a2a45"
|
| 73 |
-
LABEL = "#6666aa"
|
| 74 |
-
SUBJECT = "#ffffff"
|
| 75 |
-
BEAM = cam_color
|
| 76 |
-
|
| 77 |
-
fig = plt.figure(figsize=(5.2, 5.8), facecolor=BG)
|
| 78 |
-
ax = fig.add_axes([0.08, 0.12, 0.84, 0.84], projection="polar", facecolor=BG)
|
| 79 |
-
|
| 80 |
-
theta = np.linspace(0, 2 * np.pi, 360)
|
| 81 |
-
|
| 82 |
-
# Orbites des 3 distances
|
| 83 |
-
orbit_styles = {0.35: ("--", 0.4, "close-up"), 0.60: ("-", 0.6, "medium"), 0.88: ("--", 0.4, "wide")}
|
| 84 |
-
for rv, (ls, alpha, dlabel) in orbit_styles.items():
|
| 85 |
-
ax.plot(theta, [rv] * 360, ls, color=ORBIT, linewidth=1, alpha=alpha, zorder=1)
|
| 86 |
-
ax.text(np.radians(15), rv + 0.03, dlabel, color=LABEL, fontsize=6.5,
|
| 87 |
-
ha="left", va="bottom", fontfamily="monospace")
|
| 88 |
-
|
| 89 |
-
# Etiquettes azimut (8 directions)
|
| 90 |
-
az_short = {0: "FRONT", 45: "FR", 90: "RIGHT", 135: "BR",
|
| 91 |
-
180: "BACK", 225: "BL", 270: "LEFT", 315: "FL"}
|
| 92 |
-
for a, short in az_short.items():
|
| 93 |
-
a_rad = np.radians(90 - a)
|
| 94 |
-
ax.text(a_rad, 1.04, short, color=LABEL, fontsize=6, ha="center", va="center",
|
| 95 |
-
fontfamily="monospace", fontweight="bold")
|
| 96 |
-
|
| 97 |
-
# Rayon guide vers caméra
|
| 98 |
-
ax.plot([az_rad, az_rad], [0, r], color=BEAM, linewidth=1, alpha=0.4, zorder=2)
|
| 99 |
-
|
| 100 |
-
# Sujet au centre
|
| 101 |
-
ax.plot(0, 0, "s", color=SUBJECT, markersize=11, zorder=5)
|
| 102 |
-
ax.text(np.radians(45), 0.08, "SUBJECT", color=SUBJECT, fontsize=6,
|
| 103 |
-
ha="center", va="center", fontfamily="monospace", alpha=0.7)
|
| 104 |
-
|
| 105 |
-
# Position caméra — cercle fond + cercle coloré (élévation)
|
| 106 |
-
ax.plot(az_rad, r, "o", color=BG, markersize=18, zorder=6)
|
| 107 |
-
ax.plot(az_rad, r, "o", color=BEAM, markersize=14, zorder=7, alpha=0.9)
|
| 108 |
-
ax.text(az_rad, r, "📷", fontsize=9, ha="center", va="center", zorder=8)
|
| 109 |
-
|
| 110 |
-
# Configuration axes
|
| 111 |
-
ax.set_ylim(0, 1.15)
|
| 112 |
-
ax.set_theta_zero_location("N")
|
| 113 |
-
ax.set_theta_direction(-1)
|
| 114 |
-
ax.set_xticks([])
|
| 115 |
-
ax.set_yticks([])
|
| 116 |
-
ax.spines["polar"].set_visible(False)
|
| 117 |
-
|
| 118 |
-
# Légende élévation
|
| 119 |
-
legend_handles = [
|
| 120 |
-
mpatches.Patch(color=ELEVATION_COLORS[e], label=f"{ELEVATION_LABELS[e]} ({e}°)")
|
| 121 |
-
for e in ELEVATIONS
|
| 122 |
-
]
|
| 123 |
-
ax.legend(handles=legend_handles, loc="lower center", bbox_to_anchor=(0.5, -0.18),
|
| 124 |
-
ncol=4, frameon=False, fontsize=7,
|
| 125 |
-
labelcolor="white", handlelength=1.0)
|
| 126 |
-
|
| 127 |
-
# Prompt en bas
|
| 128 |
-
fig.text(0.5, 0.02, prompt, ha="center", fontsize=8, color="#00e676",
|
| 129 |
-
fontfamily="monospace", style="italic",
|
| 130 |
-
bbox=dict(boxstyle="round,pad=0.3", facecolor="#0d1117", edgecolor="#00e676", alpha=0.8))
|
| 131 |
-
|
| 132 |
-
fig.patch.set_facecolor(BG)
|
| 133 |
-
return fig
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def update_diagram(azimuth, elevation, distance):
|
| 137 |
-
return draw_camera_diagram(azimuth, elevation, distance)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def update_prompt_preview(azimuth, elevation, distance):
|
| 141 |
-
return build_camera_prompt(azimuth, elevation, distance)
|
| 142 |
-
|
| 143 |
# ── Inférence fal.ai ──────────────────────────────────────────────────────────
|
| 144 |
|
| 145 |
-
def infer(
|
| 146 |
-
image: Image.Image,
|
| 147 |
-
azimuth: float,
|
| 148 |
-
elevation: float,
|
| 149 |
-
distance: float,
|
| 150 |
-
seed: int,
|
| 151 |
-
randomize_seed: bool,
|
| 152 |
-
):
|
| 153 |
if image is None:
|
| 154 |
raise gr.Error("Veuillez uploader une image source / Please upload a source image")
|
| 155 |
-
|
| 156 |
if randomize_seed:
|
| 157 |
seed = random.randint(0, 2**31 - 1)
|
| 158 |
|
|
@@ -168,22 +63,223 @@ def infer(
|
|
| 168 |
"image_size": {"width": 1024, "height": 1024},
|
| 169 |
"num_inference_steps": 4,
|
| 170 |
"guidance_scale": 1.0,
|
| 171 |
-
"loras": [
|
| 172 |
-
{
|
| 173 |
-
"path": "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA",
|
| 174 |
-
"scale": 1.0,
|
| 175 |
-
}
|
| 176 |
-
],
|
| 177 |
},
|
| 178 |
)
|
| 179 |
|
| 180 |
-
image_url_out = result["images"][0]["url"]
|
| 181 |
import urllib.request
|
| 182 |
-
with urllib.request.urlopen(
|
| 183 |
out_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
|
| 184 |
|
| 185 |
return out_img, seed, prompt
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
# ── UI Gradio ─────────────────────────────────────────────────────────────────
|
| 188 |
|
| 189 |
with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
|
|
@@ -191,33 +287,31 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
|
|
| 191 |
"""
|
| 192 |
# 🎥 Angle Studio
|
| 193 |
**Changez l'angle de vue de n'importe quelle image — 96 poses caméra**
|
| 194 |
-
|
| 195 |
*Change the camera angle of any image — 96 precise poses*
|
| 196 |
"""
|
| 197 |
)
|
| 198 |
|
| 199 |
with gr.Row():
|
| 200 |
-
# Colonne gauche — contrôles
|
| 201 |
with gr.Column(scale=1):
|
| 202 |
input_image = gr.Image(label="Image source / Source image", type="pil")
|
| 203 |
|
| 204 |
gr.Markdown("### 📷 Contrôle caméra / Camera Control")
|
| 205 |
|
| 206 |
azimuth_slider = gr.Slider(
|
| 207 |
-
minimum=0, maximum=315, step=45, value=0,
|
| 208 |
-
label="Azimut
|
| 209 |
)
|
| 210 |
elevation_slider = gr.Slider(
|
| 211 |
-
minimum=-30, maximum=60, step=30, value=0,
|
| 212 |
-
label="Élévation
|
| 213 |
)
|
| 214 |
distance_slider = gr.Slider(
|
| 215 |
-
minimum=0.6, maximum=1.8, step=0.6, value=1.0,
|
| 216 |
label="Distance (0.6=close-up · 1.0=medium · 1.8=wide)"
|
| 217 |
)
|
| 218 |
|
| 219 |
prompt_preview = gr.Textbox(
|
| 220 |
-
label="Prompt
|
| 221 |
value="<sks> front view, eye-level shot, medium shot",
|
| 222 |
interactive=False,
|
| 223 |
)
|
|
@@ -228,34 +322,18 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
|
|
| 228 |
|
| 229 |
generate_btn = gr.Button("▶ Générer / Generate", variant="primary", size="lg")
|
| 230 |
|
| 231 |
-
# Colonne droite — diagramme + résultat
|
| 232 |
with gr.Column(scale=1):
|
| 233 |
-
|
| 234 |
output_image = gr.Image(label="Résultat / Result", type="pil")
|
| 235 |
output_seed = gr.Number(label="Seed utilisé / Used seed", interactive=False)
|
| 236 |
|
| 237 |
gr.Markdown("### 🖼️ Galerie de session / Session Gallery")
|
| 238 |
gallery = gr.Gallery(label="Générations / Generations", columns=4, height=280)
|
| 239 |
-
|
| 240 |
session_images = gr.State([])
|
| 241 |
|
| 242 |
-
# Mise à jour diagramme + prompt en temps réel
|
| 243 |
slider_inputs = [azimuth_slider, elevation_slider, distance_slider]
|
| 244 |
-
|
| 245 |
for slider in slider_inputs:
|
| 246 |
-
slider.change(
|
| 247 |
-
fn=update_diagram,
|
| 248 |
-
inputs=slider_inputs,
|
| 249 |
-
outputs=camera_diagram,
|
| 250 |
-
)
|
| 251 |
-
slider.change(
|
| 252 |
-
fn=update_prompt_preview,
|
| 253 |
-
inputs=slider_inputs,
|
| 254 |
-
outputs=prompt_preview,
|
| 255 |
-
)
|
| 256 |
-
|
| 257 |
-
# Diagramme initial
|
| 258 |
-
demo.load(fn=update_diagram, inputs=slider_inputs, outputs=camera_diagram)
|
| 259 |
|
| 260 |
def run_and_append(image, az, el, di, seed, rand, history):
|
| 261 |
result, used_seed, prompt = infer(image, az, el, di, seed, rand)
|
|
@@ -264,8 +342,7 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
|
|
| 264 |
|
| 265 |
generate_btn.click(
|
| 266 |
fn=run_and_append,
|
| 267 |
-
inputs=[input_image,
|
| 268 |
-
seed_input, randomize, session_images],
|
| 269 |
outputs=[output_image, output_seed, session_images, gallery],
|
| 270 |
)
|
| 271 |
|
|
|
|
| 1 |
import random
|
| 2 |
import base64
|
| 3 |
import io
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import fal_client
|
| 5 |
import gradio as gr
|
| 6 |
from PIL import Image
|
|
|
|
| 21 |
}
|
| 22 |
DISTANCE_NAMES = {0.6: "close-up", 1.0: "medium shot", 1.8: "wide shot"}
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
def snap_to_nearest(value, options):
|
| 26 |
return min(options, key=lambda x: abs(x - value))
|
|
|
|
| 33 |
return f"<sks> {AZIMUTH_NAMES[az]}, {ELEVATION_NAMES[el]}, {DISTANCE_NAMES[di]}"
|
| 34 |
|
| 35 |
|
| 36 |
+
def update_prompt_preview(azimuth, elevation, distance):
|
| 37 |
+
return build_camera_prompt(azimuth, elevation, distance)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
def pil_to_data_uri(img: Image.Image) -> str:
|
| 41 |
buf = io.BytesIO()
|
| 42 |
img.save(buf, format="PNG")
|
| 43 |
b64 = base64.b64encode(buf.getvalue()).decode()
|
| 44 |
return f"data:image/png;base64,{b64}"
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# ── Inférence fal.ai ──────────────────────────────────────────────────────────
|
| 47 |
|
| 48 |
+
def infer(image, azimuth, elevation, distance, seed, randomize_seed):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
if image is None:
|
| 50 |
raise gr.Error("Veuillez uploader une image source / Please upload a source image")
|
|
|
|
| 51 |
if randomize_seed:
|
| 52 |
seed = random.randint(0, 2**31 - 1)
|
| 53 |
|
|
|
|
| 63 |
"image_size": {"width": 1024, "height": 1024},
|
| 64 |
"num_inference_steps": 4,
|
| 65 |
"guidance_scale": 1.0,
|
| 66 |
+
"loras": [{"path": "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA", "scale": 1.0}],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
},
|
| 68 |
)
|
| 69 |
|
|
|
|
| 70 |
import urllib.request
|
| 71 |
+
with urllib.request.urlopen(result["images"][0]["url"]) as resp:
|
| 72 |
out_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
|
| 73 |
|
| 74 |
return out_img, seed, prompt
|
| 75 |
|
| 76 |
+
# ── Viewer 3D Three.js ────────────────────────────────────────────────────────
|
| 77 |
+
|
| 78 |
+
VIEWER_HTML = """
|
| 79 |
+
<div style="position:relative;width:100%;border-radius:10px;overflow:hidden;background:#080810;">
|
| 80 |
+
<canvas id="as-canvas" style="width:100%;display:block;"></canvas>
|
| 81 |
+
<div id="as-prompt" style="
|
| 82 |
+
position:absolute;bottom:12px;left:50%;transform:translateX(-50%);
|
| 83 |
+
background:rgba(8,8,16,0.88);border:1px solid #00ff88;border-radius:5px;
|
| 84 |
+
padding:7px 14px;font-family:monospace;font-size:12px;color:#00ff88;
|
| 85 |
+
white-space:nowrap;pointer-events:none;letter-spacing:0.03em;">
|
| 86 |
+
<sks> front view, eye-level shot, medium shot
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
| 91 |
+
<script>
|
| 92 |
+
(function () {
|
| 93 |
+
const canvas = document.getElementById('as-canvas');
|
| 94 |
+
const promptEl = document.getElementById('as-prompt');
|
| 95 |
+
|
| 96 |
+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
| 97 |
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
| 98 |
+
renderer.setClearColor(0x080810, 1);
|
| 99 |
+
|
| 100 |
+
const scene = new THREE.Scene();
|
| 101 |
+
const viewCam = new THREE.PerspectiveCamera(42, 1, 0.01, 50);
|
| 102 |
+
viewCam.position.set(2.6, 1.9, 2.6);
|
| 103 |
+
viewCam.lookAt(0, 0.15, 0);
|
| 104 |
+
|
| 105 |
+
function resize() {
|
| 106 |
+
const w = canvas.clientWidth || 480;
|
| 107 |
+
const h = Math.round(w * 0.72);
|
| 108 |
+
renderer.setSize(w, h, false);
|
| 109 |
+
viewCam.aspect = w / h;
|
| 110 |
+
viewCam.updateProjectionMatrix();
|
| 111 |
+
}
|
| 112 |
+
resize();
|
| 113 |
+
new ResizeObserver(resize).observe(canvas.parentElement);
|
| 114 |
+
|
| 115 |
+
// ── Floor grid ──
|
| 116 |
+
const grid = new THREE.GridHelper(5, 20, 0x151528, 0x151528);
|
| 117 |
+
scene.add(grid);
|
| 118 |
+
|
| 119 |
+
// ── Azimuth ring ──
|
| 120 |
+
const ringGeo = new THREE.TorusGeometry(1.0, 0.014, 8, 96);
|
| 121 |
+
const ringMat = new THREE.MeshBasicMaterial({ color: 0x00ff88 });
|
| 122 |
+
const ring = new THREE.Mesh(ringGeo, ringMat);
|
| 123 |
+
ring.rotation.x = Math.PI / 2;
|
| 124 |
+
scene.add(ring);
|
| 125 |
+
|
| 126 |
+
// 8 orbit snap dots
|
| 127 |
+
const dotGeo = new THREE.SphereGeometry(0.028, 8, 8);
|
| 128 |
+
[0, 45, 90, 135, 180, 225, 270, 315].forEach(function(az) {
|
| 129 |
+
const dot = new THREE.Mesh(dotGeo, new THREE.MeshBasicMaterial({ color: 0x00ff88 }));
|
| 130 |
+
const r = az * Math.PI / 180;
|
| 131 |
+
dot.position.set(Math.sin(r), 0, Math.cos(r));
|
| 132 |
+
scene.add(dot);
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
// ── Elevation arc (left side) ──
|
| 136 |
+
var arcPts = [];
|
| 137 |
+
for (var e = -30; e <= 60; e += 3) {
|
| 138 |
+
var er = e * Math.PI / 180;
|
| 139 |
+
arcPts.push(new THREE.Vector3(-1.0 * Math.cos(er), Math.sin(er), 0));
|
| 140 |
+
}
|
| 141 |
+
var arcGeo = new THREE.BufferGeometry().setFromPoints(arcPts);
|
| 142 |
+
scene.add(new THREE.Line(arcGeo, new THREE.LineBasicMaterial({ color: 0xff6ec7 })));
|
| 143 |
+
|
| 144 |
+
// Elevation arc dot markers
|
| 145 |
+
[-30, 0, 30, 60].forEach(function(e) {
|
| 146 |
+
var er = e * Math.PI / 180;
|
| 147 |
+
var m = new THREE.Mesh(
|
| 148 |
+
new THREE.SphereGeometry(0.025, 8, 8),
|
| 149 |
+
new THREE.MeshBasicMaterial({ color: 0xff6ec7 })
|
| 150 |
+
);
|
| 151 |
+
m.position.set(-1.0 * Math.cos(er), Math.sin(er), 0);
|
| 152 |
+
scene.add(m);
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
// ── Subject at center ──
|
| 156 |
+
var subGeo = new THREE.BoxGeometry(0.17, 0.22, 0.025);
|
| 157 |
+
var subMesh = new THREE.Mesh(subGeo, new THREE.MeshBasicMaterial({ color: 0x223355 }));
|
| 158 |
+
subMesh.position.y = 0.11;
|
| 159 |
+
scene.add(subMesh);
|
| 160 |
+
var subEdges = new THREE.LineSegments(
|
| 161 |
+
new THREE.EdgesGeometry(subGeo),
|
| 162 |
+
new THREE.LineBasicMaterial({ color: 0x6688bb })
|
| 163 |
+
);
|
| 164 |
+
subEdges.position.copy(subMesh.position);
|
| 165 |
+
scene.add(subEdges);
|
| 166 |
+
|
| 167 |
+
// ── Camera marker (sphere + barrel) ──
|
| 168 |
+
var camSphGeo = new THREE.SphereGeometry(0.065, 16, 16);
|
| 169 |
+
var camSphMat = new THREE.MeshBasicMaterial({ color: 0xffcc00 });
|
| 170 |
+
var camSph = new THREE.Mesh(camSphGeo, camSphMat);
|
| 171 |
+
scene.add(camSph);
|
| 172 |
+
|
| 173 |
+
var barrelGeo = new THREE.CylinderGeometry(0.022, 0.032, 0.11, 8);
|
| 174 |
+
var barrelMat = new THREE.MeshBasicMaterial({ color: 0x2a3a4a });
|
| 175 |
+
var barrel = new THREE.Mesh(barrelGeo, barrelMat);
|
| 176 |
+
scene.add(barrel);
|
| 177 |
+
|
| 178 |
+
// ── Beam line ──
|
| 179 |
+
var beamPts = [new THREE.Vector3(0,0,0), new THREE.Vector3(0,0,0)];
|
| 180 |
+
var beamGeo = new THREE.BufferGeometry().setFromPoints(beamPts);
|
| 181 |
+
var beamMat = new THREE.LineBasicMaterial({ color: 0xffcc00, transparent: true, opacity: 0.4 });
|
| 182 |
+
var beam = new THREE.Line(beamGeo, beamMat);
|
| 183 |
+
scene.add(beam);
|
| 184 |
+
|
| 185 |
+
// ── State ──
|
| 186 |
+
var curAz = 0, curEl = 0, curDi = 1.0;
|
| 187 |
+
var tgtAz = 0, tgtEl = 0, tgtDi = 1.0;
|
| 188 |
+
|
| 189 |
+
var AZNAMES = {
|
| 190 |
+
0:'front view', 45:'front-right quarter view', 90:'right side view',
|
| 191 |
+
135:'back-right quarter view', 180:'back view', 225:'back-left quarter view',
|
| 192 |
+
270:'left side view', 315:'front-left quarter view'
|
| 193 |
+
};
|
| 194 |
+
var ELNAMES = { '-30':'low-angle shot', '0':'eye-level shot', '30':'elevated shot', '60':'high-angle shot' };
|
| 195 |
+
var DINAMES = { '0.6':'close-up', '1':'medium shot', '1.8':'wide shot' };
|
| 196 |
+
|
| 197 |
+
function snap(v, opts) {
|
| 198 |
+
return opts.reduce(function(a,b){ return Math.abs(b-v)<Math.abs(a-v)?b:a; });
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function buildPrompt(az, el, di) {
|
| 202 |
+
var saz = snap(az, [0,45,90,135,180,225,270,315]);
|
| 203 |
+
var sel = snap(el, [-30,0,30,60]);
|
| 204 |
+
var sdi = snap(di, [0.6,1.0,1.8]);
|
| 205 |
+
var diKey = sdi === 1.0 ? '1' : sdi.toString();
|
| 206 |
+
return '<sks> ' + AZNAMES[saz] + ', ' + ELNAMES[sel.toString()] + ', ' + DINAMES[diKey];
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
var EL_COLORS = { '-30':0x4fc3f7, '0':0x69f0ae, '30':0xffb74d, '60':0xef5350 };
|
| 210 |
+
|
| 211 |
+
function getSlider(id, def) {
|
| 212 |
+
var el = document.getElementById(id);
|
| 213 |
+
if (!el) return def;
|
| 214 |
+
var inp = el.querySelector('input[type="range"]');
|
| 215 |
+
return inp ? parseFloat(inp.value) : def;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function updateCam(az, el, di) {
|
| 219 |
+
var azR = az * Math.PI / 180;
|
| 220 |
+
var elR = el * Math.PI / 180;
|
| 221 |
+
var x = di * Math.sin(azR) * Math.cos(elR);
|
| 222 |
+
var y = di * Math.sin(elR);
|
| 223 |
+
var z = di * Math.cos(azR) * Math.cos(elR);
|
| 224 |
+
|
| 225 |
+
camSph.position.set(x, y, z);
|
| 226 |
+
|
| 227 |
+
// Barrel orientation (points toward subject)
|
| 228 |
+
barrel.position.set(x, y, z);
|
| 229 |
+
var dir = new THREE.Vector3(-x, -y, -z).normalize();
|
| 230 |
+
var up = new THREE.Vector3(0, 1, 0);
|
| 231 |
+
barrel.quaternion.setFromUnitVectors(up, dir);
|
| 232 |
+
barrel.translateY(0.045);
|
| 233 |
+
|
| 234 |
+
// Beam
|
| 235 |
+
var pos = beamGeo.attributes.position;
|
| 236 |
+
pos.setXYZ(0, x, y, z);
|
| 237 |
+
pos.setXYZ(1, 0, 0.11, 0);
|
| 238 |
+
pos.needsUpdate = true;
|
| 239 |
+
|
| 240 |
+
// Color by elevation
|
| 241 |
+
var sel = snap(el, [-30, 0, 30, 60]);
|
| 242 |
+
var col = EL_COLORS[sel.toString()] || 0x69f0ae;
|
| 243 |
+
camSphMat.color.setHex(col);
|
| 244 |
+
beamMat.color.setHex(col);
|
| 245 |
+
|
| 246 |
+
promptEl.textContent = buildPrompt(az, el, di);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// ── Viewer orbit (slow auto-rotate for dynamism) ──
|
| 250 |
+
var viewT = 0;
|
| 251 |
+
|
| 252 |
+
function animate() {
|
| 253 |
+
requestAnimationFrame(animate);
|
| 254 |
+
|
| 255 |
+
// Read Gradio sliders
|
| 256 |
+
tgtAz = getSlider('az_slider', 0);
|
| 257 |
+
tgtEl = getSlider('el_slider', 0);
|
| 258 |
+
tgtDi = getSlider('di_slider', 1.0);
|
| 259 |
+
|
| 260 |
+
// Smooth interpolation
|
| 261 |
+
var k = 0.10;
|
| 262 |
+
curAz += (tgtAz - curAz) * k;
|
| 263 |
+
curEl += (tgtEl - curEl) * k;
|
| 264 |
+
curDi += (tgtDi - curDi) * k;
|
| 265 |
+
|
| 266 |
+
updateCam(curAz, curEl, curDi);
|
| 267 |
+
|
| 268 |
+
// Slowly orbit the viewer camera around the scene
|
| 269 |
+
viewT += 0.004;
|
| 270 |
+
var vr = 3.4;
|
| 271 |
+
viewCam.position.x = Math.sin(viewT) * vr * 0.65 + 0.6;
|
| 272 |
+
viewCam.position.z = Math.cos(viewT) * vr * 0.65 + 0.6;
|
| 273 |
+
viewCam.position.y = 1.7 + Math.sin(viewT * 0.3) * 0.3;
|
| 274 |
+
viewCam.lookAt(0, 0.12, 0);
|
| 275 |
+
|
| 276 |
+
renderer.render(scene, viewCam);
|
| 277 |
+
}
|
| 278 |
+
animate();
|
| 279 |
+
})();
|
| 280 |
+
</script>
|
| 281 |
+
"""
|
| 282 |
+
|
| 283 |
# ── UI Gradio ─────────────────────────────────────────────────────────────────
|
| 284 |
|
| 285 |
with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
|
|
|
|
| 287 |
"""
|
| 288 |
# 🎥 Angle Studio
|
| 289 |
**Changez l'angle de vue de n'importe quelle image — 96 poses caméra**
|
|
|
|
| 290 |
*Change the camera angle of any image — 96 precise poses*
|
| 291 |
"""
|
| 292 |
)
|
| 293 |
|
| 294 |
with gr.Row():
|
|
|
|
| 295 |
with gr.Column(scale=1):
|
| 296 |
input_image = gr.Image(label="Image source / Source image", type="pil")
|
| 297 |
|
| 298 |
gr.Markdown("### 📷 Contrôle caméra / Camera Control")
|
| 299 |
|
| 300 |
azimuth_slider = gr.Slider(
|
| 301 |
+
minimum=0, maximum=315, step=45, value=0, elem_id="az_slider",
|
| 302 |
+
label="Azimut — rotation horizontale (0°=front · 90°=right · 180°=back)"
|
| 303 |
)
|
| 304 |
elevation_slider = gr.Slider(
|
| 305 |
+
minimum=-30, maximum=60, step=30, value=0, elem_id="el_slider",
|
| 306 |
+
label="Élévation — angle vertical (-30°=bas · 0°=eye-level · 60°=haut)"
|
| 307 |
)
|
| 308 |
distance_slider = gr.Slider(
|
| 309 |
+
minimum=0.6, maximum=1.8, step=0.6, value=1.0, elem_id="di_slider",
|
| 310 |
label="Distance (0.6=close-up · 1.0=medium · 1.8=wide)"
|
| 311 |
)
|
| 312 |
|
| 313 |
prompt_preview = gr.Textbox(
|
| 314 |
+
label="Prompt / Generated prompt",
|
| 315 |
value="<sks> front view, eye-level shot, medium shot",
|
| 316 |
interactive=False,
|
| 317 |
)
|
|
|
|
| 322 |
|
| 323 |
generate_btn = gr.Button("▶ Générer / Generate", variant="primary", size="lg")
|
| 324 |
|
|
|
|
| 325 |
with gr.Column(scale=1):
|
| 326 |
+
gr.HTML(VIEWER_HTML)
|
| 327 |
output_image = gr.Image(label="Résultat / Result", type="pil")
|
| 328 |
output_seed = gr.Number(label="Seed utilisé / Used seed", interactive=False)
|
| 329 |
|
| 330 |
gr.Markdown("### 🖼️ Galerie de session / Session Gallery")
|
| 331 |
gallery = gr.Gallery(label="Générations / Generations", columns=4, height=280)
|
|
|
|
| 332 |
session_images = gr.State([])
|
| 333 |
|
|
|
|
| 334 |
slider_inputs = [azimuth_slider, elevation_slider, distance_slider]
|
|
|
|
| 335 |
for slider in slider_inputs:
|
| 336 |
+
slider.change(fn=update_prompt_preview, inputs=slider_inputs, outputs=prompt_preview)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
def run_and_append(image, az, el, di, seed, rand, history):
|
| 339 |
result, used_seed, prompt = infer(image, az, el, di, seed, rand)
|
|
|
|
| 342 |
|
| 343 |
generate_btn.click(
|
| 344 |
fn=run_and_append,
|
| 345 |
+
inputs=[input_image, *slider_inputs, seed_input, randomize, session_images],
|
|
|
|
| 346 |
outputs=[output_image, output_seed, session_images, gallery],
|
| 347 |
)
|
| 348 |
|