Spaces:
Running
Running
feat: 3D viewer — image source affichée sur le panneau sujet + lerp caméra
Browse files
app.py
CHANGED
|
@@ -7,57 +7,59 @@ from PIL import Image
|
|
| 7 |
|
| 8 |
# ── Constantes poses ──────────────────────────────────────────────────────────
|
| 9 |
|
| 10 |
-
AZIMUTHS
|
| 11 |
ELEVATIONS = [-30, 0, 30, 60]
|
| 12 |
-
DISTANCES
|
| 13 |
|
| 14 |
AZIMUTH_NAMES = {
|
| 15 |
-
0:
|
| 16 |
-
135:
|
| 17 |
-
270:
|
| 18 |
}
|
| 19 |
ELEVATION_NAMES = {
|
| 20 |
-
-30:
|
| 21 |
}
|
| 22 |
-
DISTANCE_NAMES = {0.6:
|
| 23 |
|
| 24 |
|
| 25 |
-
def
|
| 26 |
return min(options, key=lambda x: abs(x - value))
|
| 27 |
|
| 28 |
|
| 29 |
-
def
|
| 30 |
-
az =
|
| 31 |
-
el =
|
| 32 |
-
di =
|
| 33 |
return f"<sks> {AZIMUTH_NAMES[az]}, {ELEVATION_NAMES[el]}, {DISTANCE_NAMES[di]}"
|
| 34 |
|
| 35 |
|
| 36 |
-
def
|
| 37 |
-
|
| 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("
|
| 51 |
if randomize_seed:
|
| 52 |
seed = random.randint(0, 2**31 - 1)
|
| 53 |
|
| 54 |
-
prompt =
|
| 55 |
-
image_url = pil_to_data_uri(image)
|
| 56 |
|
|
|
|
| 57 |
result = fal_client.run(
|
| 58 |
"fal-ai/qwen-image-edit",
|
| 59 |
arguments={
|
| 60 |
-
"image_url":
|
| 61 |
"prompt": prompt,
|
| 62 |
"seed": seed,
|
| 63 |
"image_size": {"width": 1024, "height": 1024},
|
|
@@ -66,23 +68,22 @@ def infer(image, azimuth, elevation, distance, seed, randomize_seed):
|
|
| 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
|
| 77 |
|
| 78 |
-
VIEWER_HTML = """
|
| 79 |
-
<div style="position:relative;width:100%;border-radius:10px;overflow:hidden;background:#
|
| 80 |
<canvas id="as-canvas" style="width:100%;display:block;"></canvas>
|
|
|
|
|
|
|
| 81 |
<div id="as-prompt" style="
|
| 82 |
-
position:absolute;bottom:
|
| 83 |
-
background:rgba(
|
| 84 |
-
padding:
|
| 85 |
-
white-space:nowrap;pointer-events:none;
|
| 86 |
<sks> front view, eye-level shot, medium shot
|
| 87 |
</div>
|
| 88 |
</div>
|
|
@@ -90,123 +91,128 @@ VIEWER_HTML = """
|
|
| 90 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
| 91 |
<script>
|
| 92 |
(function () {
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
| 98 |
-
renderer.setClearColor(
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
|
| 105 |
function resize() {
|
| 106 |
-
|
| 107 |
-
|
| 108 |
renderer.setSize(w, h, false);
|
| 109 |
-
|
| 110 |
-
|
| 111 |
}
|
| 112 |
resize();
|
| 113 |
new ResizeObserver(resize).observe(canvas.parentElement);
|
| 114 |
|
| 115 |
-
/
|
| 116 |
-
|
| 117 |
scene.add(grid);
|
| 118 |
|
| 119 |
-
/
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
});
|
| 134 |
|
| 135 |
-
/
|
| 136 |
var arcPts = [];
|
| 137 |
-
for (var e = -
|
| 138 |
var er = e * Math.PI / 180;
|
| 139 |
-
arcPts.push(new THREE.Vector3(-
|
| 140 |
}
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
| 146 |
var er = e * Math.PI / 180;
|
| 147 |
-
var
|
| 148 |
-
new THREE.SphereGeometry(0.
|
| 149 |
new THREE.MeshBasicMaterial({ color: 0xff6ec7 })
|
| 150 |
);
|
| 151 |
-
|
| 152 |
-
scene.add(
|
| 153 |
});
|
| 154 |
|
| 155 |
-
/
|
| 156 |
-
var
|
| 157 |
-
var
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
scene.add(subMesh);
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
);
|
| 164 |
-
|
| 165 |
-
|
| 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(
|
| 171 |
scene.add(camSph);
|
| 172 |
|
| 173 |
-
var
|
| 174 |
-
var
|
| 175 |
-
var barrel = new THREE.Mesh(barrelGeo, barrelMat);
|
| 176 |
scene.add(barrel);
|
| 177 |
|
| 178 |
-
/
|
| 179 |
-
var
|
| 180 |
-
var
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
var
|
| 188 |
-
|
| 189 |
-
var
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
var
|
| 195 |
-
var
|
| 196 |
-
|
| 197 |
-
function
|
| 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 =
|
| 203 |
-
var sel =
|
| 204 |
-
var sdi =
|
| 205 |
-
var
|
| 206 |
-
return '<sks> ' +
|
| 207 |
}
|
| 208 |
|
| 209 |
-
var
|
| 210 |
|
| 211 |
function getSlider(id, def) {
|
| 212 |
var el = document.getElementById(id);
|
|
@@ -215,7 +221,16 @@ VIEWER_HTML = """
|
|
| 215 |
return inp ? parseFloat(inp.value) : def;
|
| 216 |
}
|
| 217 |
|
| 218 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
var azR = az * Math.PI / 180;
|
| 220 |
var elR = el * Math.PI / 180;
|
| 221 |
var x = di * Math.sin(azR) * Math.cos(elR);
|
|
@@ -224,56 +239,52 @@ VIEWER_HTML = """
|
|
| 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 |
-
|
| 231 |
-
barrel.
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 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 |
-
/
|
| 250 |
-
var viewT = 0;
|
| 251 |
-
|
| 252 |
function animate() {
|
| 253 |
requestAnimationFrame(animate);
|
| 254 |
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
| 277 |
}
|
| 278 |
animate();
|
| 279 |
})();
|
|
@@ -283,57 +294,63 @@ VIEWER_HTML = """
|
|
| 283 |
# ── UI Gradio ─────────────────────────────────────────────────────────────────
|
| 284 |
|
| 285 |
with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
|
| 286 |
-
gr.Markdown(
|
| 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 |
-
|
|
|
|
| 299 |
|
|
|
|
| 300 |
azimuth_slider = gr.Slider(
|
| 301 |
minimum=0, maximum=315, step=45, value=0, elem_id="az_slider",
|
| 302 |
-
label="Azimut
|
| 303 |
)
|
| 304 |
elevation_slider = gr.Slider(
|
| 305 |
minimum=-30, maximum=60, step=30, value=0, elem_id="el_slider",
|
| 306 |
-
label="Élévation
|
| 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
|
| 311 |
)
|
| 312 |
|
| 313 |
prompt_preview = gr.Textbox(
|
| 314 |
-
label="Prompt
|
| 315 |
-
value="<sks> front view, eye-level shot, medium shot"
|
| 316 |
-
interactive=False,
|
| 317 |
)
|
| 318 |
|
| 319 |
with gr.Row():
|
| 320 |
seed_input = gr.Number(label="Seed", value=0, precision=0)
|
| 321 |
-
randomize
|
| 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
|
| 329 |
|
| 330 |
gr.Markdown("### 🖼️ Galerie de session / Session Gallery")
|
| 331 |
-
gallery
|
| 332 |
session_images = gr.State([])
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,7 +359,7 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
|
|
| 342 |
|
| 343 |
generate_btn.click(
|
| 344 |
fn=run_and_append,
|
| 345 |
-
inputs=[input_image, *
|
| 346 |
outputs=[output_image, output_seed, session_images, gallery],
|
| 347 |
)
|
| 348 |
|
|
|
|
| 7 |
|
| 8 |
# ── Constantes poses ──────────────────────────────────────────────────────────
|
| 9 |
|
| 10 |
+
AZIMUTHS = [0, 45, 90, 135, 180, 225, 270, 315]
|
| 11 |
ELEVATIONS = [-30, 0, 30, 60]
|
| 12 |
+
DISTANCES = [0.6, 1.0, 1.8]
|
| 13 |
|
| 14 |
AZIMUTH_NAMES = {
|
| 15 |
+
0:"front view", 45:"front-right quarter view", 90:"right side view",
|
| 16 |
+
135:"back-right quarter view", 180:"back view", 225:"back-left quarter view",
|
| 17 |
+
270:"left side view", 315:"front-left quarter view"
|
| 18 |
}
|
| 19 |
ELEVATION_NAMES = {
|
| 20 |
+
-30:"low-angle shot", 0:"eye-level shot", 30:"elevated shot", 60:"high-angle shot"
|
| 21 |
}
|
| 22 |
+
DISTANCE_NAMES = {0.6:"close-up", 1.0:"medium shot", 1.8:"wide shot"}
|
| 23 |
|
| 24 |
|
| 25 |
+
def snap(value, options):
|
| 26 |
return min(options, key=lambda x: abs(x - value))
|
| 27 |
|
| 28 |
|
| 29 |
+
def build_prompt(azimuth, elevation, distance):
|
| 30 |
+
az = snap(azimuth, AZIMUTHS)
|
| 31 |
+
el = snap(elevation, ELEVATIONS)
|
| 32 |
+
di = snap(distance, DISTANCES)
|
| 33 |
return f"<sks> {AZIMUTH_NAMES[az]}, {ELEVATION_NAMES[el]}, {DISTANCE_NAMES[di]}"
|
| 34 |
|
| 35 |
|
| 36 |
+
def image_to_uri(img):
|
| 37 |
+
if img is None:
|
| 38 |
+
return ""
|
|
|
|
|
|
|
| 39 |
buf = io.BytesIO()
|
| 40 |
img.save(buf, format="PNG")
|
| 41 |
b64 = base64.b64encode(buf.getvalue()).decode()
|
| 42 |
return f"data:image/png;base64,{b64}"
|
| 43 |
|
| 44 |
+
|
| 45 |
+
def update_prompt(az, el, di):
|
| 46 |
+
return build_prompt(az, el, di)
|
| 47 |
+
|
| 48 |
# ── Inférence fal.ai ──────────────────────────────────────────────────────────
|
| 49 |
|
| 50 |
def infer(image, azimuth, elevation, distance, seed, randomize_seed):
|
| 51 |
if image is None:
|
| 52 |
+
raise gr.Error("Upload a source image first")
|
| 53 |
if randomize_seed:
|
| 54 |
seed = random.randint(0, 2**31 - 1)
|
| 55 |
|
| 56 |
+
prompt = build_prompt(azimuth, elevation, distance)
|
|
|
|
| 57 |
|
| 58 |
+
import urllib.request
|
| 59 |
result = fal_client.run(
|
| 60 |
"fal-ai/qwen-image-edit",
|
| 61 |
arguments={
|
| 62 |
+
"image_url": image_to_uri(image),
|
| 63 |
"prompt": prompt,
|
| 64 |
"seed": seed,
|
| 65 |
"image_size": {"width": 1024, "height": 1024},
|
|
|
|
| 68 |
"loras": [{"path": "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA", "scale": 1.0}],
|
| 69 |
},
|
| 70 |
)
|
|
|
|
|
|
|
| 71 |
with urllib.request.urlopen(result["images"][0]["url"]) as resp:
|
| 72 |
out_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
|
|
|
|
| 73 |
return out_img, seed, prompt
|
| 74 |
|
| 75 |
+
# ── Viewer 3D ─────────────────────────────────────────────────────────────────
|
| 76 |
|
| 77 |
+
VIEWER_HTML = r"""
|
| 78 |
+
<div style="position:relative;width:100%;border-radius:10px;overflow:hidden;background:#07070f;user-select:none;">
|
| 79 |
<canvas id="as-canvas" style="width:100%;display:block;"></canvas>
|
| 80 |
+
|
| 81 |
+
<!-- prompt overlay -->
|
| 82 |
<div id="as-prompt" style="
|
| 83 |
+
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
|
| 84 |
+
background:rgba(7,7,15,0.88);border:1px solid #00ff88;border-radius:5px;
|
| 85 |
+
padding:6px 14px;font-family:'Courier New',monospace;font-size:11px;
|
| 86 |
+
color:#00ff88;white-space:nowrap;pointer-events:none;">
|
| 87 |
<sks> front view, eye-level shot, medium shot
|
| 88 |
</div>
|
| 89 |
</div>
|
|
|
|
| 91 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
| 92 |
<script>
|
| 93 |
(function () {
|
| 94 |
+
/* ── renderer ── */
|
| 95 |
+
var canvas = document.getElementById('as-canvas');
|
| 96 |
+
var promptEl = document.getElementById('as-prompt');
|
| 97 |
+
var renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
|
| 98 |
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
| 99 |
+
renderer.setClearColor(0x07070f, 1);
|
| 100 |
+
|
| 101 |
+
var scene = new THREE.Scene();
|
| 102 |
|
| 103 |
+
/* ── viewer camera (fixed 3/4 angle) ── */
|
| 104 |
+
var vcam = new THREE.PerspectiveCamera(38, 1, 0.01, 50);
|
| 105 |
+
vcam.position.set(2.8, 2.0, 2.8);
|
| 106 |
+
vcam.lookAt(0, 0.1, 0);
|
| 107 |
|
| 108 |
function resize() {
|
| 109 |
+
var w = canvas.parentElement.clientWidth || 500;
|
| 110 |
+
var h = Math.round(w * 0.72);
|
| 111 |
renderer.setSize(w, h, false);
|
| 112 |
+
vcam.aspect = w / h;
|
| 113 |
+
vcam.updateProjectionMatrix();
|
| 114 |
}
|
| 115 |
resize();
|
| 116 |
new ResizeObserver(resize).observe(canvas.parentElement);
|
| 117 |
|
| 118 |
+
/* ── grid floor ── */
|
| 119 |
+
var grid = new THREE.GridHelper(6, 24, 0x111128, 0x111128);
|
| 120 |
scene.add(grid);
|
| 121 |
|
| 122 |
+
/* ── azimuth ring (green torus on XZ plane) ── */
|
| 123 |
+
var torusMat = new THREE.MeshBasicMaterial({ color: 0x00ff88 });
|
| 124 |
+
[0.60, 1.00, 1.80].forEach(function(di) {
|
| 125 |
+
var t = new THREE.Mesh(
|
| 126 |
+
new THREE.TorusGeometry(di, 0.013, 8, 80),
|
| 127 |
+
new THREE.MeshBasicMaterial({ color: di === 1.00 ? 0x00ff88 : 0x00884a, transparent: true, opacity: di === 1.00 ? 1 : 0.4 })
|
| 128 |
+
);
|
| 129 |
+
t.rotation.x = Math.PI / 2;
|
| 130 |
+
scene.add(t);
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
/* snap dots on main ring */
|
| 134 |
+
var dotGeo = new THREE.SphereGeometry(0.025, 8, 8);
|
| 135 |
+
var dotMat = new THREE.MeshBasicMaterial({ color: 0x00ff88 });
|
| 136 |
+
[0,45,90,135,180,225,270,315].forEach(function(a) {
|
| 137 |
+
var ar = a * Math.PI / 180;
|
| 138 |
+
var d = new THREE.Mesh(dotGeo, dotMat);
|
| 139 |
+
d.position.set(Math.sin(ar), 0, Math.cos(ar));
|
| 140 |
+
scene.add(d);
|
| 141 |
});
|
| 142 |
|
| 143 |
+
/* ── elevation arc (pink, left side XY plane) ── */
|
| 144 |
var arcPts = [];
|
| 145 |
+
for (var e = -35; e <= 65; e += 3) {
|
| 146 |
var er = e * Math.PI / 180;
|
| 147 |
+
arcPts.push(new THREE.Vector3(-Math.cos(er), Math.sin(er), 0));
|
| 148 |
}
|
| 149 |
+
scene.add(new THREE.Line(
|
| 150 |
+
new THREE.BufferGeometry().setFromPoints(arcPts),
|
| 151 |
+
new THREE.LineBasicMaterial({ color: 0xff6ec7, linewidth: 2 })
|
| 152 |
+
));
|
| 153 |
+
/* snap dots on arc */
|
| 154 |
+
[-30,0,30,60].forEach(function(e) {
|
| 155 |
var er = e * Math.PI / 180;
|
| 156 |
+
var d = new THREE.Mesh(
|
| 157 |
+
new THREE.SphereGeometry(0.022, 8, 8),
|
| 158 |
new THREE.MeshBasicMaterial({ color: 0xff6ec7 })
|
| 159 |
);
|
| 160 |
+
d.position.set(-Math.cos(er), Math.sin(er), 0);
|
| 161 |
+
scene.add(d);
|
| 162 |
});
|
| 163 |
|
| 164 |
+
/* ── subject panel (shows uploaded image) ── */
|
| 165 |
+
var subW = 0.28, subH = 0.36;
|
| 166 |
+
var subGeo = new THREE.PlaneGeometry(subW, subH);
|
| 167 |
+
var subMat = new THREE.MeshBasicMaterial({ color: 0x223355, side: THREE.DoubleSide });
|
| 168 |
+
var subMesh = new THREE.Mesh(subGeo, subMat);
|
| 169 |
+
subMesh.position.y = subH / 2 + 0.01;
|
| 170 |
scene.add(subMesh);
|
| 171 |
+
/* border */
|
| 172 |
+
var borderGeo = new THREE.EdgesGeometry(new THREE.BoxGeometry(subW, subH, 0.01));
|
| 173 |
+
var borderLine = new THREE.LineSegments(borderGeo, new THREE.LineBasicMaterial({ color: 0x4466aa }));
|
| 174 |
+
borderLine.position.copy(subMesh.position);
|
| 175 |
+
scene.add(borderLine);
|
| 176 |
+
|
| 177 |
+
/* ── camera marker: sphere + barrel ── */
|
|
|
|
|
|
|
| 178 |
var camSphMat = new THREE.MeshBasicMaterial({ color: 0xffcc00 });
|
| 179 |
+
var camSph = new THREE.Mesh(new THREE.SphereGeometry(0.060, 16, 16), camSphMat);
|
| 180 |
scene.add(camSph);
|
| 181 |
|
| 182 |
+
var barrelMat = new THREE.MeshBasicMaterial({ color: 0x1e2d3a });
|
| 183 |
+
var barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.020, 0.030, 0.10, 8), barrelMat);
|
|
|
|
| 184 |
scene.add(barrel);
|
| 185 |
|
| 186 |
+
/* ── beam ── */
|
| 187 |
+
var beamGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
|
| 188 |
+
var beamMat = new THREE.LineBasicMaterial({ color: 0xffcc00, transparent: true, opacity: 0.45 });
|
| 189 |
+
scene.add(new THREE.Line(beamGeo, beamMat));
|
| 190 |
+
|
| 191 |
+
/* ── lights ── */
|
| 192 |
+
scene.add(new THREE.AmbientLight(0xffffff, 1));
|
| 193 |
+
|
| 194 |
+
/* ── state ── */
|
| 195 |
+
var cAz = 0, cEl = 0, cDi = 1.0;
|
| 196 |
+
var tAz = 0, tEl = 0, tDi = 1.0;
|
| 197 |
+
var lastUri = '';
|
| 198 |
+
|
| 199 |
+
var AZ = {0:'front view',45:'front-right quarter view',90:'right side view',
|
| 200 |
+
135:'back-right quarter view',180:'back view',225:'back-left quarter view',
|
| 201 |
+
270:'left side view',315:'front-left quarter view'};
|
| 202 |
+
var EL = {'-30':'low-angle shot','0':'eye-level shot','30':'elevated shot','60':'high-angle shot'};
|
| 203 |
+
var DI = {'0.6':'close-up','1':'medium shot','1.8':'wide shot'};
|
| 204 |
+
|
| 205 |
+
function snapV(v, opts) { return opts.reduce(function(a,b){ return Math.abs(b-v)<Math.abs(a-v)?b:a; }); }
|
|
|
|
|
|
|
| 206 |
|
| 207 |
function buildPrompt(az, el, di) {
|
| 208 |
+
var saz = snapV(az,[0,45,90,135,180,225,270,315]);
|
| 209 |
+
var sel = snapV(el,[-30,0,30,60]);
|
| 210 |
+
var sdi = snapV(di,[0.6,1.0,1.8]);
|
| 211 |
+
var dk = sdi===1.0?'1':sdi.toFixed(1);
|
| 212 |
+
return '<sks> ' + AZ[saz] + ', ' + EL[sel+''] + ', ' + DI[dk];
|
| 213 |
}
|
| 214 |
|
| 215 |
+
var ELC = {'-30':0x4fc3f7,'0':0x69f0ae,'30':0xffb74d,'60':0xef5350};
|
| 216 |
|
| 217 |
function getSlider(id, def) {
|
| 218 |
var el = document.getElementById(id);
|
|
|
|
| 221 |
return inp ? parseFloat(inp.value) : def;
|
| 222 |
}
|
| 223 |
|
| 224 |
+
function getImageUri() {
|
| 225 |
+
var el = document.getElementById('img_uri_store');
|
| 226 |
+
if (!el) return '';
|
| 227 |
+
var ta = el.querySelector('textarea');
|
| 228 |
+
return ta ? ta.value : '';
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
var loader = new THREE.TextureLoader();
|
| 232 |
+
|
| 233 |
+
function updateScene(az, el, di) {
|
| 234 |
var azR = az * Math.PI / 180;
|
| 235 |
var elR = el * Math.PI / 180;
|
| 236 |
var x = di * Math.sin(azR) * Math.cos(elR);
|
|
|
|
| 239 |
|
| 240 |
camSph.position.set(x, y, z);
|
| 241 |
|
|
|
|
| 242 |
barrel.position.set(x, y, z);
|
| 243 |
var dir = new THREE.Vector3(-x, -y, -z).normalize();
|
| 244 |
+
barrel.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dir);
|
| 245 |
+
barrel.translateY(0.04);
|
| 246 |
+
|
| 247 |
+
var bpos = beamGeo.attributes.position;
|
| 248 |
+
bpos.setXYZ(0, x, y, z);
|
| 249 |
+
bpos.setXYZ(1, 0, subMesh.position.y, 0);
|
| 250 |
+
bpos.needsUpdate = true;
|
| 251 |
+
|
| 252 |
+
var sel = snapV(el,[-30,0,30,60]);
|
| 253 |
+
var col = ELC[sel+''] || 0x69f0ae;
|
|
|
|
|
|
|
|
|
|
| 254 |
camSphMat.color.setHex(col);
|
| 255 |
beamMat.color.setHex(col);
|
| 256 |
|
| 257 |
promptEl.textContent = buildPrompt(az, el, di);
|
| 258 |
}
|
| 259 |
|
| 260 |
+
/* ── animate ── */
|
|
|
|
|
|
|
| 261 |
function animate() {
|
| 262 |
requestAnimationFrame(animate);
|
| 263 |
|
| 264 |
+
tAz = getSlider('az_slider', 0);
|
| 265 |
+
tEl = getSlider('el_slider', 0);
|
| 266 |
+
tDi = getSlider('di_slider', 1.0);
|
| 267 |
+
|
| 268 |
+
var k = 0.09;
|
| 269 |
+
cAz += (tAz - cAz) * k;
|
| 270 |
+
cEl += (tEl - cEl) * k;
|
| 271 |
+
cDi += (tDi - cDi) * k;
|
| 272 |
+
|
| 273 |
+
updateScene(cAz, cEl, cDi);
|
| 274 |
+
|
| 275 |
+
/* update subject texture when image changes */
|
| 276 |
+
var uri = getImageUri();
|
| 277 |
+
if (uri && uri !== lastUri) {
|
| 278 |
+
lastUri = uri;
|
| 279 |
+
loader.load(uri, function(tex) {
|
| 280 |
+
tex.encoding = THREE.sRGBEncoding;
|
| 281 |
+
subMat.map = tex;
|
| 282 |
+
subMat.color.setHex(0xffffff);
|
| 283 |
+
subMat.needsUpdate = true;
|
| 284 |
+
});
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
renderer.render(scene, vcam);
|
| 288 |
}
|
| 289 |
animate();
|
| 290 |
})();
|
|
|
|
| 294 |
# ── UI Gradio ─────────────────────────────────────────────────────────────────
|
| 295 |
|
| 296 |
with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
|
| 297 |
+
gr.Markdown("""
|
|
|
|
| 298 |
# 🎥 Angle Studio
|
| 299 |
**Changez l'angle de vue de n'importe quelle image — 96 poses caméra**
|
| 300 |
*Change the camera angle of any image — 96 precise poses*
|
| 301 |
+
""")
|
|
|
|
| 302 |
|
| 303 |
with gr.Row():
|
| 304 |
+
# ── Colonne gauche : contrôles ──
|
| 305 |
with gr.Column(scale=1):
|
| 306 |
input_image = gr.Image(label="Image source / Source image", type="pil")
|
| 307 |
|
| 308 |
+
# Textbox caché → passe l'image au viewer Three.js
|
| 309 |
+
img_uri_store = gr.Textbox(visible=False, elem_id="img_uri_store")
|
| 310 |
|
| 311 |
+
gr.Markdown("### 📷 Contrôle caméra / Camera Control")
|
| 312 |
azimuth_slider = gr.Slider(
|
| 313 |
minimum=0, maximum=315, step=45, value=0, elem_id="az_slider",
|
| 314 |
+
label="Azimut (0°=front · 90°=right · 180°=back · 270°=left)"
|
| 315 |
)
|
| 316 |
elevation_slider = gr.Slider(
|
| 317 |
minimum=-30, maximum=60, step=30, value=0, elem_id="el_slider",
|
| 318 |
+
label="Élévation (-30°=bas · 0°=eye-level · 60°=haut)"
|
| 319 |
)
|
| 320 |
distance_slider = gr.Slider(
|
| 321 |
minimum=0.6, maximum=1.8, step=0.6, value=1.0, elem_id="di_slider",
|
| 322 |
+
label="Distance (0.6=close-up · 1.0=medium · 1.8=wide)"
|
| 323 |
)
|
| 324 |
|
| 325 |
prompt_preview = gr.Textbox(
|
| 326 |
+
label="Prompt", interactive=False,
|
| 327 |
+
value="<sks> front view, eye-level shot, medium shot"
|
|
|
|
| 328 |
)
|
| 329 |
|
| 330 |
with gr.Row():
|
| 331 |
seed_input = gr.Number(label="Seed", value=0, precision=0)
|
| 332 |
+
randomize = gr.Checkbox(label="Random seed", value=True)
|
| 333 |
|
| 334 |
generate_btn = gr.Button("▶ Générer / Generate", variant="primary", size="lg")
|
| 335 |
|
| 336 |
+
# ── Colonne droite : viewer + résultat ──
|
| 337 |
with gr.Column(scale=1):
|
| 338 |
gr.HTML(VIEWER_HTML)
|
| 339 |
output_image = gr.Image(label="Résultat / Result", type="pil")
|
| 340 |
+
output_seed = gr.Number(label="Seed utilisé", interactive=False)
|
| 341 |
|
| 342 |
gr.Markdown("### 🖼️ Galerie de session / Session Gallery")
|
| 343 |
+
gallery = gr.Gallery(columns=4, height=260)
|
| 344 |
session_images = gr.State([])
|
| 345 |
|
| 346 |
+
sliders = [azimuth_slider, elevation_slider, distance_slider]
|
| 347 |
+
|
| 348 |
+
# Image uploadée → data URI dans le store caché
|
| 349 |
+
input_image.change(fn=image_to_uri, inputs=input_image, outputs=img_uri_store)
|
| 350 |
+
|
| 351 |
+
# Sliders → prompt preview
|
| 352 |
+
for s in sliders:
|
| 353 |
+
s.change(fn=update_prompt, inputs=sliders, outputs=prompt_preview)
|
| 354 |
|
| 355 |
def run_and_append(image, az, el, di, seed, rand, history):
|
| 356 |
result, used_seed, prompt = infer(image, az, el, di, seed, rand)
|
|
|
|
| 359 |
|
| 360 |
generate_btn.click(
|
| 361 |
fn=run_and_append,
|
| 362 |
+
inputs=[input_image, *sliders, seed_input, randomize, session_images],
|
| 363 |
outputs=[output_image, output_seed, session_images, gallery],
|
| 364 |
)
|
| 365 |
|