artel3D commited on
Commit
878eaca
·
1 Parent(s): 41d6349

feat: viewer 3D Plotly natif — fini Three.js dans Gradio, plus d'écran noir

Browse files
Files changed (2) hide show
  1. app.py +225 -263
  2. requirements.txt +2 -0
app.py CHANGED
@@ -1,25 +1,28 @@
1
  import random
2
  import base64
3
  import io
 
 
 
4
  import fal_client
5
  import gradio as gr
 
 
6
  from PIL import Image
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):
@@ -33,6 +36,10 @@ def build_prompt(azimuth, elevation, distance):
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 ""
@@ -42,8 +49,197 @@ def image_to_uri(img):
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
 
@@ -53,7 +249,6 @@ def infer(image, azimuth, elevation, distance, seed, randomize_seed):
53
  if randomize_seed:
54
  seed = random.randint(0, 2**31 - 1)
55
  prompt = build_prompt(azimuth, elevation, distance)
56
- import urllib.request
57
  result = fal_client.run(
58
  "fal-ai/qwen-image-edit",
59
  arguments={
@@ -70,250 +265,10 @@ def infer(image, azimuth, elevation, distance, seed, randomize_seed):
70
  out_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
71
  return out_img, seed, prompt
72
 
73
- # ── JS global injecté via gr.Blocks(js=...) — s'exécute dans le vrai contexte page ──
74
-
75
- BLOCKS_JS = r"""
76
- () => {
77
- const scr = document.createElement('script');
78
- scr.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
79
- scr.onload = waitForWrapper;
80
- document.head.appendChild(scr);
81
-
82
- function waitForWrapper() {
83
- const wrapper = document.getElementById('cv3d-wrapper');
84
- if (!wrapper) { setTimeout(waitForWrapper, 150); return; }
85
- initScene(wrapper);
86
- }
87
-
88
- function initScene(wrapper) {
89
- const promptEl = document.getElementById('cv3d-prompt');
90
-
91
- const renderer = new THREE.WebGLRenderer({ antialias: true });
92
- renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
93
- renderer.setSize(wrapper.clientWidth, wrapper.clientHeight || 450);
94
- wrapper.insertBefore(renderer.domElement, promptEl);
95
-
96
- const scene = new THREE.Scene();
97
- scene.background = new THREE.Color(0x1a1a1a);
98
- scene.add(new THREE.AmbientLight(0xffffff, 0.6));
99
- const dl = new THREE.DirectionalLight(0xffffff, 0.6);
100
- dl.position.set(5, 10, 5);
101
- scene.add(dl);
102
-
103
- /* Vue du viewer = caméra fixe inclinée 3/4 façon multimodalart */
104
- const vcam = new THREE.PerspectiveCamera(50, wrapper.clientWidth / (wrapper.clientHeight || 450), 0.1, 1000);
105
- vcam.position.set(4.5, 3, 4.5);
106
- vcam.lookAt(0, 0.75, 0);
107
-
108
- new ResizeObserver(() => {
109
- renderer.setSize(wrapper.clientWidth, wrapper.clientHeight);
110
- vcam.aspect = wrapper.clientWidth / wrapper.clientHeight;
111
- vcam.updateProjectionMatrix();
112
- }).observe(wrapper);
113
-
114
- /* grille au sol */
115
- scene.add(new THREE.GridHelper(8, 16, 0x333333, 0x222222));
116
-
117
- /* Constantes spatiales — alignées sur la ref */
118
- const CENTER = new THREE.Vector3(0, 0.75, 0);
119
- const BASE_DISTANCE = 1.6;
120
- const AZIMUTH_RADIUS = 2.4;
121
- const ELEVATION_RADIUS = 1.8;
122
-
123
- /* Plan sujet (image utilisateur) */
124
- function makePlaceholderTex() {
125
- const c = document.createElement('canvas'); c.width = c.height = 256;
126
- const ctx = c.getContext('2d');
127
- ctx.fillStyle = '#3a3a4a'; ctx.fillRect(0,0,256,256);
128
- ctx.fillStyle = '#ffcc99'; ctx.beginPath(); ctx.arc(128,128,80,0,Math.PI*2); ctx.fill();
129
- ctx.fillStyle = '#333';
130
- ctx.beginPath(); ctx.arc(100,110,10,0,Math.PI*2); ctx.arc(156,110,10,0,Math.PI*2); ctx.fill();
131
- ctx.strokeStyle = '#333'; ctx.lineWidth = 3;
132
- ctx.beginPath(); ctx.arc(128,130,35,0.2,Math.PI-0.2); ctx.stroke();
133
- return new THREE.CanvasTexture(c);
134
- }
135
- const planeMat = new THREE.MeshBasicMaterial({ map: makePlaceholderTex(), side: THREE.DoubleSide });
136
- let targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), planeMat);
137
- targetPlane.position.copy(CENTER);
138
- scene.add(targetPlane);
139
-
140
- function setPlaneFromUri(uri) {
141
- const loader = new THREE.TextureLoader();
142
- loader.crossOrigin = 'anonymous';
143
- loader.load(uri, (tex) => {
144
- tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter;
145
- planeMat.map = tex; planeMat.needsUpdate = true;
146
- const img = tex.image;
147
- if (img && img.width && img.height) {
148
- const a = img.width / img.height, M = 1.5;
149
- const w = a > 1 ? M : M * a, h = a > 1 ? M / a : M;
150
- scene.remove(targetPlane);
151
- targetPlane = new THREE.Mesh(new THREE.PlaneGeometry(w, h), planeMat);
152
- targetPlane.position.copy(CENTER);
153
- scene.add(targetPlane);
154
- }
155
- });
156
- }
157
-
158
- /* Caméra physique : body bleu + lens cylindrique pointant vers l'avant (+Z local) */
159
- const cameraGroup = new THREE.Group();
160
- const camMat = new THREE.MeshStandardMaterial({ color: 0x6699cc, metalness: 0.5, roughness: 0.3 });
161
- const body = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.22, 0.38), camMat);
162
- cameraGroup.add(body);
163
- const lens = new THREE.Mesh(
164
- new THREE.CylinderGeometry(0.09, 0.11, 0.18, 16),
165
- new THREE.MeshStandardMaterial({ color: 0x6699cc, metalness: 0.5, roughness: 0.3 })
166
- );
167
- lens.rotation.x = Math.PI / 2;
168
- lens.position.z = 0.26;
169
- cameraGroup.add(lens);
170
- /* lentille jaune lumineuse (pour faire le "œil" de la caméra) */
171
- const lensFront = new THREE.Mesh(
172
- new THREE.SphereGeometry(0.08, 16, 16),
173
- new THREE.MeshStandardMaterial({ color: 0xffcc44, emissive: 0xffaa00, emissiveIntensity: 0.6 })
174
- );
175
- lensFront.position.z = 0.36;
176
- cameraGroup.add(lensFront);
177
- scene.add(cameraGroup);
178
-
179
- /* Anneau VERT : azimut */
180
- const azimuthRing = new THREE.Mesh(
181
- new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 64),
182
- new THREE.MeshStandardMaterial({ color: 0x00ff88, emissive: 0x00ff88, emissiveIntensity: 0.3 })
183
- );
184
- azimuthRing.rotation.x = Math.PI / 2;
185
- azimuthRing.position.y = 0.05;
186
- scene.add(azimuthRing);
187
- const azHandle = new THREE.Mesh(
188
- new THREE.SphereGeometry(0.16, 16, 16),
189
- new THREE.MeshStandardMaterial({ color: 0x00ff88, emissive: 0x00ff88, emissiveIntensity: 0.6 })
190
- );
191
- scene.add(azHandle);
192
-
193
- /* Arc ROSE : élévation (-30° → 60°) */
194
- const arcPts = [];
195
- for (let i = 0; i <= 32; i++) {
196
- const a = THREE.MathUtils.degToRad(-30 + (90 * i / 32));
197
- arcPts.push(new THREE.Vector3(-0.8, ELEVATION_RADIUS * Math.sin(a) + CENTER.y, ELEVATION_RADIUS * Math.cos(a)));
198
- }
199
- const elevationArc = new THREE.Mesh(
200
- new THREE.TubeGeometry(new THREE.CatmullRomCurve3(arcPts), 32, 0.04, 8, false),
201
- new THREE.MeshStandardMaterial({ color: 0xff69b4, emissive: 0xff69b4, emissiveIntensity: 0.3 })
202
- );
203
- scene.add(elevationArc);
204
- const elHandle = new THREE.Mesh(
205
- new THREE.SphereGeometry(0.16, 16, 16),
206
- new THREE.MeshStandardMaterial({ color: 0xff69b4, emissive: 0xff69b4, emissiveIntensity: 0.6 })
207
- );
208
- scene.add(elHandle);
209
-
210
- /* Ligne ORANGE : distance caméra → sujet + handle */
211
- const distLineGeo = new THREE.BufferGeometry();
212
- const distLine = new THREE.Line(distLineGeo, new THREE.LineBasicMaterial({ color: 0xffa500 }));
213
- scene.add(distLine);
214
- const distHandle = new THREE.Mesh(
215
- new THREE.SphereGeometry(0.14, 16, 16),
216
- new THREE.MeshStandardMaterial({ color: 0xffa500, emissive: 0xffa500, emissiveIntensity: 0.6 })
217
- );
218
- scene.add(distHandle);
219
-
220
- /* Helpers prompt */
221
- function snapV(v, opts) { return opts.reduce((a,b) => Math.abs(b-v) < Math.abs(a-v) ? b : a); }
222
- const AZN = {0:'front view',45:'front-right quarter view',90:'right side view',
223
- 135:'back-right quarter view',180:'back view',225:'back-left quarter view',
224
- 270:'left side view',315:'front-left quarter view'};
225
- const ELN = {'-30':'low-angle shot','0':'eye-level shot','30':'elevated shot','60':'high-angle shot'};
226
- const DIN = {'0.6':'close-up','1.0':'medium shot','1.8':'wide shot'};
227
-
228
- function buildPrompt(az, el, di) {
229
- return '<sks> ' + AZN[snapV(az,[0,45,90,135,180,225,270,315])]
230
- + ', ' + ELN[String(snapV(el,[-30,0,30,60]))]
231
- + ', ' + DIN[snapV(di,[0.6,1.0,1.8]).toFixed(1)];
232
- }
233
-
234
- function getSlider(id, def) {
235
- const el = document.getElementById(id);
236
- if (!el) return def;
237
- const inp = el.querySelector('input[type="range"]');
238
- return inp ? parseFloat(inp.value) : def;
239
- }
240
- function getUri() {
241
- const el = document.getElementById('img_uri_store');
242
- if (!el) return '';
243
- const ta = el.querySelector('textarea');
244
- return (ta && ta.value) ? ta.value : '';
245
- }
246
-
247
- /* État animé (lerp depuis sliders) */
248
- let cAz = 0, cEl = 0, cDi = 1.0, lastUri = '';
249
-
250
- function update(az, el, di) {
251
- const distance = BASE_DISTANCE * di;
252
- const azR = THREE.MathUtils.degToRad(az);
253
- const elR = THREE.MathUtils.degToRad(el);
254
-
255
- const cx = distance * Math.sin(azR) * Math.cos(elR);
256
- const cy = distance * Math.sin(elR) + CENTER.y;
257
- const cz = distance * Math.cos(azR) * Math.cos(elR);
258
-
259
- cameraGroup.position.set(cx, cy, cz);
260
- cameraGroup.lookAt(CENTER);
261
-
262
- azHandle.position.set(AZIMUTH_RADIUS * Math.sin(azR), 0.05, AZIMUTH_RADIUS * Math.cos(azR));
263
- elHandle.position.set(-0.8, ELEVATION_RADIUS * Math.sin(elR) + CENTER.y, ELEVATION_RADIUS * Math.cos(elR));
264
-
265
- const orangeR = Math.max(distance - 0.5, 0.1);
266
- distHandle.position.set(
267
- orangeR * Math.sin(azR) * Math.cos(elR),
268
- orangeR * Math.sin(elR) + CENTER.y,
269
- orangeR * Math.cos(azR) * Math.cos(elR)
270
- );
271
- distLineGeo.setFromPoints([cameraGroup.position.clone(), CENTER.clone()]);
272
-
273
- if (promptEl) promptEl.textContent = buildPrompt(az, el, di);
274
- }
275
-
276
- (function animate() {
277
- requestAnimationFrame(animate);
278
- const k = 0.12;
279
- cAz += (getSlider('az_slider', 0) - cAz) * k;
280
- cEl += (getSlider('el_slider', 0) - cEl) * k;
281
- cDi += (getSlider('di_slider', 1.0) - cDi) * k;
282
- update(cAz, cEl, cDi);
283
-
284
- const uri = getUri();
285
- if (uri && uri !== lastUri) { lastUri = uri; setPlaneFromUri(uri); }
286
-
287
- renderer.render(scene, vcam);
288
- })();
289
- }
290
- }
291
- """
292
-
293
- # ── HTML du viewer (juste le conteneur — le JS vient de gr.Blocks(js=...) ) ──
294
-
295
- VIEWER_HTML = """
296
- <div style="margin-bottom:6px; font-size:12px; color:#888;">
297
- <span style="color:#00ff88;">●</span> Azimut &nbsp;
298
- <span style="color:#ff69b4;">●</span> Élévation &nbsp;
299
- <span style="color:#ffa500;">●</span> Distance
300
- </div>
301
- <div id="cv3d-wrapper" style="
302
- width:100%; height:450px; position:relative;
303
- background:#1a1a1a; border-radius:12px; overflow:hidden;">
304
- <div id="cv3d-prompt" style="
305
- position:absolute; bottom:10px; left:50%; transform:translateX(-50%);
306
- background:rgba(0,0,0,0.85); padding:8px 16px; border-radius:8px;
307
- font-family:monospace; font-size:12px; color:#00ff88;
308
- white-space:nowrap; pointer-events:none; z-index:10;">
309
- &lt;sks&gt; front view, eye-level shot, medium shot
310
- </div>
311
- </div>
312
- """
313
 
314
  # ── UI Gradio ─────────────────────────────────────────────────────────────────
315
 
316
- with gr.Blocks(title="Angle Studio", theme=gr.themes.Base(), js=BLOCKS_JS) as demo:
317
  gr.Markdown("""
318
  # 🎥 Angle Studio
319
  **Changez l'angle de vue de n'importe quelle image — 96 poses caméra**
@@ -323,24 +278,23 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base(), js=BLOCKS_JS) as de
323
  with gr.Row():
324
  with gr.Column(scale=1):
325
  input_image = gr.Image(label="Image source / Source image", type="pil")
326
- img_uri_store = gr.Textbox(visible=False, elem_id="img_uri_store")
327
 
328
  gr.Markdown("### 📷 Contrôle caméra / Camera Control")
329
  azimuth_slider = gr.Slider(
330
- minimum=0, maximum=315, step=45, value=0, elem_id="az_slider",
331
- label="Azimut (0°=front · 90°=right · 180°=back · 270°=left)"
332
  )
333
  elevation_slider = gr.Slider(
334
- minimum=-30, maximum=60, step=30, value=0, elem_id="el_slider",
335
- label="Élévation (-30°=bas · 0°=eye-level · 60°=haut)"
336
  )
337
  distance_slider = gr.Slider(
338
- minimum=0.6, maximum=1.8, step=0.6, value=1.0, elem_id="di_slider",
339
- label="Distance (0.6=close-up · 1.0=medium · 1.8=wide)"
340
  )
341
  prompt_preview = gr.Textbox(
342
  label="Prompt", interactive=False,
343
- value="<sks> front view, eye-level shot, medium shot"
344
  )
345
  with gr.Row():
346
  seed_input = gr.Number(label="Seed", value=0, precision=0)
@@ -348,7 +302,15 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base(), js=BLOCKS_JS) as de
348
  generate_btn = gr.Button("▶ Générer / Generate", variant="primary", size="lg")
349
 
350
  with gr.Column(scale=1):
351
- gr.HTML(VIEWER_HTML)
 
 
 
 
 
 
 
 
352
  output_image = gr.Image(label="Résultat / Result", type="pil")
353
  output_seed = gr.Number(label="Seed utilisé", interactive=False)
354
 
@@ -357,9 +319,9 @@ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base(), js=BLOCKS_JS) as de
357
  session_images = gr.State([])
358
 
359
  sliders = [azimuth_slider, elevation_slider, distance_slider]
360
- input_image.change(fn=image_to_uri, inputs=input_image, outputs=img_uri_store)
361
  for s in sliders:
362
- s.change(fn=update_prompt, inputs=sliders, outputs=prompt_preview)
 
363
 
364
  def run_and_append(image, az, el, di, seed, rand, history):
365
  result, used_seed, prompt = infer(image, az, el, di, seed, rand)
 
1
  import random
2
  import base64
3
  import io
4
+ import math
5
+ import urllib.request
6
+
7
  import fal_client
8
  import gradio as gr
9
+ import numpy as np
10
+ import plotly.graph_objects as go
11
  from PIL import Image
12
 
13
+ # ── Constantes poses (LoRA fal Multiple-Angles) ───────────────────────────────
14
 
15
  AZIMUTHS = [0, 45, 90, 135, 180, 225, 270, 315]
16
  ELEVATIONS = [-30, 0, 30, 60]
17
  DISTANCES = [0.6, 1.0, 1.8]
18
 
19
  AZIMUTH_NAMES = {
20
+ 0: "front view", 45: "front-right quarter view", 90: "right side view",
21
+ 135: "back-right quarter view", 180: "back view", 225: "back-left quarter view",
22
+ 270: "left side view", 315: "front-left quarter view",
 
 
 
23
  }
24
+ ELEVATION_NAMES = {-30: "low-angle shot", 0: "eye-level shot", 30: "elevated shot", 60: "high-angle shot"}
25
+ DISTANCE_NAMES = {0.6: "close-up", 1.0: "medium shot", 1.8: "wide shot"}
26
 
27
 
28
  def snap(value, options):
 
36
  return f"<sks> {AZIMUTH_NAMES[az]}, {ELEVATION_NAMES[el]}, {DISTANCE_NAMES[di]}"
37
 
38
 
39
+ def update_prompt(az, el, di):
40
+ return build_prompt(az, el, di)
41
+
42
+
43
  def image_to_uri(img):
44
  if img is None:
45
  return ""
 
49
  return f"data:image/png;base64,{b64}"
50
 
51
 
52
+ # ── Viewer 3D Plotly ──────────────────────────────────────────────────────────
53
+
54
+ CENTER = np.array([0.0, 0.75, 0.0]) # centre du sujet
55
+ BASE_DISTANCE = 1.6 # rayon de base de la caméra
56
+ AZIMUTH_RADIUS = 2.4 # rayon anneau vert
57
+ ELEVATION_RADIUS = 1.8 # rayon arc rose
58
+
59
+ PLANE_W, PLANE_H = 1.5, 1.5 # taille du panneau-sujet
60
+
61
+
62
+ def _camera_position(azimuth, elevation, distance):
63
+ """Coords caméra en convention LoRA fal :
64
+ azimut 0°=front (+Z), 90°=right (+X), 180°=back (-Z), 270°=left (-X)."""
65
+ d = BASE_DISTANCE * distance
66
+ az = math.radians(azimuth)
67
+ el = math.radians(elevation)
68
+ x = d * math.sin(az) * math.cos(el)
69
+ y = d * math.sin(el) + CENTER[1]
70
+ z = d * math.cos(az) * math.cos(el)
71
+ return np.array([x, y, z])
72
+
73
+
74
+ def build_viewer_figure(azimuth, elevation, distance):
75
+ cam = _camera_position(azimuth, elevation, distance)
76
+
77
+ fig = go.Figure()
78
+
79
+ # ── Plan sujet (rectangle face à +Z) ─────────────────────────────────────
80
+ px = [-PLANE_W / 2, PLANE_W / 2, PLANE_W / 2, -PLANE_W / 2]
81
+ py = [CENTER[1] - PLANE_H / 2, CENTER[1] - PLANE_H / 2,
82
+ CENTER[1] + PLANE_H / 2, CENTER[1] + PLANE_H / 2]
83
+ pz = [0, 0, 0, 0]
84
+ fig.add_trace(go.Mesh3d(
85
+ x=px, y=py, z=pz,
86
+ i=[0, 0], j=[1, 2], k=[2, 3],
87
+ color="#3a4a8a", opacity=0.85,
88
+ flatshading=True, hoverinfo="skip", name="Sujet",
89
+ ))
90
+ # bordure du plan
91
+ bx = px + [px[0]]
92
+ by = py + [py[0]]
93
+ bz = pz + [pz[0]]
94
+ fig.add_trace(go.Scatter3d(
95
+ x=bx, y=by, z=bz, mode="lines",
96
+ line=dict(color="#8aa8ff", width=4),
97
+ hoverinfo="skip", showlegend=False,
98
+ ))
99
+
100
+ # ── Anneau azimut (vert) au sol ──────────────────────────────────────────
101
+ th = np.linspace(0, 2 * np.pi, 80)
102
+ fig.add_trace(go.Scatter3d(
103
+ x=AZIMUTH_RADIUS * np.sin(th),
104
+ y=np.zeros_like(th),
105
+ z=AZIMUTH_RADIUS * np.cos(th),
106
+ mode="lines",
107
+ line=dict(color="#00ff88", width=8),
108
+ name="Azimut", hoverinfo="skip",
109
+ ))
110
+ # marqueurs cardinaux (Front / Right / Back / Left)
111
+ fig.add_trace(go.Scatter3d(
112
+ x=[0, AZIMUTH_RADIUS, 0, -AZIMUTH_RADIUS],
113
+ y=[0, 0, 0, 0],
114
+ z=[AZIMUTH_RADIUS, 0, -AZIMUTH_RADIUS, 0],
115
+ mode="text",
116
+ text=["FRONT 0°", "RIGHT 90°", "BACK 180°", "LEFT 270°"],
117
+ textfont=dict(color="#00ff88", size=11),
118
+ hoverinfo="skip", showlegend=False,
119
+ ))
120
+ # handle azimut courant
121
+ az_r = math.radians(azimuth)
122
+ fig.add_trace(go.Scatter3d(
123
+ x=[AZIMUTH_RADIUS * math.sin(az_r)],
124
+ y=[0],
125
+ z=[AZIMUTH_RADIUS * math.cos(az_r)],
126
+ mode="markers",
127
+ marker=dict(size=12, color="#00ff88", symbol="circle",
128
+ line=dict(color="white", width=2)),
129
+ name="Azimut courant", hoverinfo="skip", showlegend=False,
130
+ ))
131
+
132
+ # ── Arc élévation (rose) côté gauche ─────────────────────────────────────
133
+ el_range = np.linspace(math.radians(-30), math.radians(60), 40)
134
+ fig.add_trace(go.Scatter3d(
135
+ x=np.full_like(el_range, -0.8),
136
+ y=ELEVATION_RADIUS * np.sin(el_range) + CENTER[1],
137
+ z=ELEVATION_RADIUS * np.cos(el_range),
138
+ mode="lines",
139
+ line=dict(color="#ff69b4", width=8),
140
+ name="Élévation", hoverinfo="skip",
141
+ ))
142
+ el_r = math.radians(elevation)
143
+ fig.add_trace(go.Scatter3d(
144
+ x=[-0.8],
145
+ y=[ELEVATION_RADIUS * math.sin(el_r) + CENTER[1]],
146
+ z=[ELEVATION_RADIUS * math.cos(el_r)],
147
+ mode="markers",
148
+ marker=dict(size=12, color="#ff69b4", symbol="circle",
149
+ line=dict(color="white", width=2)),
150
+ name="Élévation courante", hoverinfo="skip", showlegend=False,
151
+ ))
152
+
153
+ # ── Ligne caméra → sujet (orange = distance) ─────────────────────────────
154
+ fig.add_trace(go.Scatter3d(
155
+ x=[cam[0], CENTER[0]],
156
+ y=[cam[1], CENTER[1]],
157
+ z=[cam[2], CENTER[2]],
158
+ mode="lines",
159
+ line=dict(color="#ffa500", width=5, dash="dot"),
160
+ name="Distance", hoverinfo="skip",
161
+ ))
162
+
163
+ # ── Caméra (gros marqueur bleu) ──────────────────────────────────────────
164
+ fig.add_trace(go.Scatter3d(
165
+ x=[cam[0]], y=[cam[1]], z=[cam[2]],
166
+ mode="markers+text",
167
+ marker=dict(size=18, color="#6699ff", symbol="diamond",
168
+ line=dict(color="#ffcc44", width=3)),
169
+ text=["📷"],
170
+ textposition="top center",
171
+ textfont=dict(size=18),
172
+ name="Caméra", hoverinfo="skip", showlegend=False,
173
+ ))
174
+
175
+ # ── Cône de vue (frustum simplifié) ──────────────────────────────────────
176
+ # 4 rayons depuis la caméra vers les coins du panneau
177
+ corners = np.array([
178
+ [-PLANE_W / 2, CENTER[1] - PLANE_H / 2, 0],
179
+ [ PLANE_W / 2, CENTER[1] - PLANE_H / 2, 0],
180
+ [ PLANE_W / 2, CENTER[1] + PLANE_H / 2, 0],
181
+ [-PLANE_W / 2, CENTER[1] + PLANE_H / 2, 0],
182
+ ])
183
+ fx, fy, fz = [], [], []
184
+ for c in corners:
185
+ fx += [cam[0], c[0], None]
186
+ fy += [cam[1], c[1], None]
187
+ fz += [cam[2], c[2], None]
188
+ fig.add_trace(go.Scatter3d(
189
+ x=fx, y=fy, z=fz, mode="lines",
190
+ line=dict(color="#ffcc44", width=2),
191
+ opacity=0.45, hoverinfo="skip", showlegend=False, name="FOV",
192
+ ))
193
+
194
+ # ── Sol (grid plan) ──────────────────────────────────────────────────────
195
+ grid_n = 9
196
+ g = np.linspace(-3, 3, grid_n)
197
+ gx, gz = [], []
198
+ for v in g:
199
+ gx += [-3, 3, None, v, v, None]
200
+ gz += [v, v, None, -3, 3, None]
201
+ fig.add_trace(go.Scatter3d(
202
+ x=gx, y=[0] * len(gx), z=gz, mode="lines",
203
+ line=dict(color="#333333", width=1),
204
+ hoverinfo="skip", showlegend=False,
205
+ ))
206
+
207
+ # ── Layout : vue isométrique fixe, fond sombre ────────────────────────────
208
+ prompt = build_prompt(azimuth, elevation, distance)
209
+ fig.update_layout(
210
+ paper_bgcolor="#1a1a1a",
211
+ plot_bgcolor="#1a1a1a",
212
+ margin=dict(l=0, r=0, t=10, b=40),
213
+ showlegend=False,
214
+ scene=dict(
215
+ xaxis=dict(visible=False, range=[-3.2, 3.2]),
216
+ yaxis=dict(visible=False, range=[-0.2, 3]),
217
+ zaxis=dict(visible=False, range=[-3.2, 3.2]),
218
+ bgcolor="#1a1a1a",
219
+ aspectmode="manual",
220
+ aspectratio=dict(x=1, y=0.7, z=1),
221
+ camera=dict(
222
+ eye=dict(x=1.7, y=1.2, z=1.7),
223
+ center=dict(x=0, y=0.2, z=0),
224
+ up=dict(x=0, y=1, z=0),
225
+ ),
226
+ ),
227
+ annotations=[dict(
228
+ text=f"<b>{prompt}</b>",
229
+ showarrow=False,
230
+ xref="paper", yref="paper",
231
+ x=0.5, y=0.0,
232
+ font=dict(family="monospace", size=13, color="#00ff88"),
233
+ bgcolor="rgba(0,0,0,0.85)",
234
+ borderpad=6,
235
+ )],
236
+ )
237
+ return fig
238
+
239
+
240
+ def update_viewer(az, el, di):
241
+ return build_viewer_figure(az, el, di)
242
+
243
 
244
  # ── Inférence fal.ai ──────────────────────────────────────────────────────────
245
 
 
249
  if randomize_seed:
250
  seed = random.randint(0, 2**31 - 1)
251
  prompt = build_prompt(azimuth, elevation, distance)
 
252
  result = fal_client.run(
253
  "fal-ai/qwen-image-edit",
254
  arguments={
 
265
  out_img = Image.open(io.BytesIO(resp.read())).convert("RGB")
266
  return out_img, seed, prompt
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  # ── UI Gradio ─────────────────────────────────────────────────────────────────
270
 
271
+ with gr.Blocks(title="Angle Studio", theme=gr.themes.Base()) as demo:
272
  gr.Markdown("""
273
  # 🎥 Angle Studio
274
  **Changez l'angle de vue de n'importe quelle image — 96 poses caméra**
 
278
  with gr.Row():
279
  with gr.Column(scale=1):
280
  input_image = gr.Image(label="Image source / Source image", type="pil")
 
281
 
282
  gr.Markdown("### 📷 Contrôle caméra / Camera Control")
283
  azimuth_slider = gr.Slider(
284
+ minimum=0, maximum=315, step=45, value=0,
285
+ label="Azimut (0°=front · 90°=right · 180°=back · 270°=left)",
286
  )
287
  elevation_slider = gr.Slider(
288
+ minimum=-30, maximum=60, step=30, value=0,
289
+ label="Élévation (-30°=bas · 0°=eye-level · 60°=haut)",
290
  )
291
  distance_slider = gr.Slider(
292
+ minimum=0.6, maximum=1.8, step=0.6, value=1.0,
293
+ label="Distance (0.6=close-up · 1.0=medium · 1.8=wide)",
294
  )
295
  prompt_preview = gr.Textbox(
296
  label="Prompt", interactive=False,
297
+ value="<sks> front view, eye-level shot, medium shot",
298
  )
299
  with gr.Row():
300
  seed_input = gr.Number(label="Seed", value=0, precision=0)
 
302
  generate_btn = gr.Button("▶ Générer / Generate", variant="primary", size="lg")
303
 
304
  with gr.Column(scale=1):
305
+ gr.Markdown(
306
+ "<div style='font-size:12px;color:#888;margin-bottom:4px;'>"
307
+ "<span style='color:#00ff88;'>●</span> Azimut &nbsp;"
308
+ "<span style='color:#ff69b4;'>●</span> Élévation &nbsp;"
309
+ "<span style='color:#ffa500;'>●</span> Distance &nbsp;"
310
+ "<span style='color:#6699ff;'>◆</span> Caméra"
311
+ "</div>"
312
+ )
313
+ viewer_3d = gr.Plot(value=build_viewer_figure(0, 0, 1.0), label=None)
314
  output_image = gr.Image(label="Résultat / Result", type="pil")
315
  output_seed = gr.Number(label="Seed utilisé", interactive=False)
316
 
 
319
  session_images = gr.State([])
320
 
321
  sliders = [azimuth_slider, elevation_slider, distance_slider]
 
322
  for s in sliders:
323
+ s.change(fn=update_prompt, inputs=sliders, outputs=prompt_preview)
324
+ s.change(fn=update_viewer, inputs=sliders, outputs=viewer_3d)
325
 
326
  def run_and_append(image, az, el, di, seed, rand, history):
327
  result, used_seed, prompt = infer(image, az, el, di, seed, rand)
requirements.txt CHANGED
@@ -1,2 +1,4 @@
1
  fal-client
2
  gradio
 
 
 
1
  fal-client
2
  gradio
3
+ plotly
4
+ numpy