LovnishVerma commited on
Commit
f99daf4
Β·
verified Β·
1 Parent(s): 453c4a3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +343 -185
app.py CHANGED
@@ -8,6 +8,7 @@ import asyncio
8
  import threading
9
  import time
10
  import base64
 
11
  from io import BytesIO
12
 
13
  device = "cuda" if torch.cuda.is_available() else "cpu"
@@ -24,273 +25,430 @@ processor = AutoProcessor.from_pretrained(
24
  trust_remote_code=True
25
  )
26
 
 
 
 
27
  def warmup():
28
  dummy = Image.new("RGB", (224, 224), color=128)
29
  inp = processor(text="<CAPTION>", images=dummy, return_tensors="pt").to(device)
30
  with torch.inference_mode():
31
- model.generate(
32
- input_ids=inp["input_ids"],
33
- pixel_values=inp["pixel_values"],
34
- max_new_tokens=20,
35
- num_beams=1,
36
- )
37
  print("Model warmed up!")
38
 
39
  threading.Thread(target=warmup, daemon=True).start()
40
 
41
- last_caption = {"text": "", "hash": None}
 
42
 
43
- def image_hash(image: Image.Image) -> int:
44
- thumb = image.resize((16, 16)).convert("L")
45
- return hash(thumb.tobytes())
46
 
47
- async def _tts_async(text: str, path: str):
48
- communicate = edge_tts.Communicate(text, voice="en-US-AriaNeural", rate="+10%")
49
- await communicate.save(path)
50
 
51
- def text_to_speech(text: str) -> str:
52
- with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp:
53
- path = tmp.name
54
- asyncio.run(_tts_async(text, path))
55
- return path
56
-
57
-
58
- def describe_frame(frame_b64: str, task_choice: str):
59
- if not frame_b64 or "," not in frame_b64:
60
- return gr.update(), gr.update()
61
  try:
62
- img_bytes = base64.b64decode(frame_b64.split(",")[1])
63
- image = Image.open(BytesIO(img_bytes)).convert("RGB")
 
 
 
 
 
 
64
  except Exception as e:
65
- print(f"Decode error: {e}")
66
- return gr.update(), gr.update()
67
 
68
- h = image_hash(image)
69
- if h == last_caption["hash"] and last_caption["text"]:
70
- print("Same frame, skipping.")
71
- return gr.update(), gr.update()
72
 
73
- task_map = {
74
- "Quick (faster)": "<CAPTION>",
75
- "Detailed (slower)": "<MORE_DETAILED_CAPTION>",
76
- }
77
- task = task_map.get(task_choice, "<CAPTION>")
 
 
 
 
 
 
 
 
 
78
 
79
- t0 = time.time()
80
  inputs = processor(text=task, images=image, return_tensors="pt").to(device)
81
  with torch.inference_mode():
82
  output_ids = model.generate(
83
  input_ids=inputs["input_ids"],
84
  pixel_values=inputs["pixel_values"],
85
- max_new_tokens=60 if task == "<CAPTION>" else 150,
86
  do_sample=False,
87
  num_beams=1,
88
  )
89
- generated_text = processor.batch_decode(output_ids, skip_special_tokens=False)[0]
90
- result = processor.post_process_generation(
91
- generated_text, task=task,
92
- image_size=(image.width, image.height),
93
- )
94
- caption = result[task]
95
- print(f"[{time.time()-t0:.2f}s] {caption}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
- last_caption["text"] = caption
98
- last_caption["hash"] = h
99
 
100
- audio_path = text_to_speech(caption)
101
- return caption, audio_path
 
 
 
 
 
 
 
 
 
 
102
 
 
 
 
 
103
 
104
- def describe_upload(image, task_choice):
 
 
 
105
  if image is None:
106
- yield "Please upload an image.", None
107
  return
108
  if not isinstance(image, Image.Image):
109
  image = Image.fromarray(image)
110
 
111
- task_map = {
112
- "Quick (faster)": "<CAPTION>",
113
- "Detailed (slower)": "<MORE_DETAILED_CAPTION>",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  }
115
- task = task_map.get(task_choice, "<CAPTION>")
116
 
117
- inputs = processor(text=task, images=image, return_tensors="pt").to(device)
118
- with torch.inference_mode():
119
- output_ids = model.generate(
120
- input_ids=inputs["input_ids"],
121
- pixel_values=inputs["pixel_values"],
122
- max_new_tokens=60 if task == "<CAPTION>" else 150,
123
- do_sample=False,
124
- num_beams=1,
125
- )
126
- generated_text = processor.batch_decode(output_ids, skip_special_tokens=False)[0]
127
- result = processor.post_process_generation(
128
- generated_text, task=task,
129
- image_size=(image.width, image.height),
130
- )
131
- caption = result[task]
132
- last_caption["text"] = caption
133
- last_caption["hash"] = image_hash(image)
134
-
135
- words = caption.split()
136
- partial = ""
137
- for word in words:
138
- partial += ("" if partial == "" else " ") + word
139
- yield partial, None
140
 
141
- audio_path = text_to_speech(caption)
142
- yield caption, audio_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
 
 
 
 
 
 
 
 
144
 
145
- with gr.Blocks(title="EchoLens RT", theme=gr.themes.Soft()) as demo:
 
146
 
147
- gr.Markdown("# πŸ‘οΈ EchoLens β€” Realtime Vision Assistant")
148
- gr.Markdown("**For blind and visually impaired users.** Open camera β†’ click **Start Realtime** for auto-description every 3 seconds.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  with gr.Row():
 
151
  with gr.Column(scale=1):
152
  webcam_input = gr.Image(
153
- label="Live Camera",
154
  type="numpy",
155
  sources=["webcam"],
 
156
  )
157
  upload_input = gr.Image(
158
- label="Or Upload an Image",
159
  type="numpy",
160
  sources=["upload"],
 
161
  )
162
  task_choice = gr.Radio(
163
- choices=["Quick (faster)", "Detailed (slower)"],
164
- value="Quick (faster)",
165
- label="Caption detail",
 
166
  )
167
- describe_btn = gr.Button("πŸ“Έ Describe Once", variant="primary")
168
- realtime_btn = gr.Button("β–Ά Start Realtime", variant="secondary", elem_id="realtime-btn")
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  with gr.Column(scale=1):
171
  caption_out = gr.Textbox(
172
- label="Caption",
173
- lines=5,
174
  interactive=False,
175
  show_copy_button=True,
176
- placeholder="Caption will appear here...",
 
177
  )
178
  audio_out = gr.Audio(
179
- label="Audio Description",
180
  type="filepath",
181
  autoplay=True,
 
182
  )
183
- status_out = gr.Textbox(
184
- label="Status",
185
- value="Ready.",
186
- interactive=False,
187
- lines=1,
188
- )
189
-
190
- # Hidden plumbing for JS β†’ Python frame passing
 
 
 
 
 
191
  with gr.Row(visible=False):
192
- frame_box = gr.Textbox(elem_id="frame-box", label="fb")
193
- frame_btn = gr.Button("go", elem_id="frame-btn")
194
-
195
- # ── Gradio events ──────────────────────────────────────────
196
 
 
197
  describe_btn.click(
198
- fn=describe_upload,
199
  inputs=[webcam_input, task_choice],
200
  outputs=[caption_out, audio_out],
201
  show_progress=False,
202
  )
203
 
204
  upload_input.change(
205
- fn=describe_upload,
206
  inputs=[upload_input, task_choice],
207
  outputs=[caption_out, audio_out],
208
  show_progress=False,
209
  )
210
 
211
  frame_btn.click(
212
- fn=describe_frame,
213
  inputs=[frame_box, task_choice],
214
  outputs=[caption_out, audio_out],
215
  show_progress=False,
216
  queue=True,
217
  )
218
 
219
- # Realtime toggle β€” Python just flips label/color,
220
- # JS (below) does the actual capture loop
221
- realtime_btn.click(
222
- fn=None,
223
- js="""
224
- () => {
225
- const btn = document.querySelector('#realtime-btn button');
226
- if (!btn) return;
227
-
228
- if (btn.dataset.running === 'true') {
229
- // --- STOP ---
230
- btn.dataset.running = 'false';
231
- clearInterval(window._echoTimer);
232
- window._echoTimer = null;
233
- btn.textContent = 'β–Ά Start Realtime';
234
- btn.style.background = '';
235
- btn.style.color = '';
236
- const s = document.querySelector('#status-box textarea');
237
- if (s) { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(s,'Realtime stopped.'); s.dispatchEvent(new Event('input',{bubbles:true})); }
238
- } else {
239
- // --- START ---
240
- btn.dataset.running = 'true';
241
- btn.textContent = '⏹ Stop Realtime';
242
- btn.style.background = '#ef4444';
243
- btn.style.color = 'white';
244
- const s = document.querySelector('#status-box textarea');
245
- if (s) { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(s,'Realtime running...'); s.dispatchEvent(new Event('input',{bubbles:true})); }
246
-
247
- function capture() {
248
- const video = [...document.querySelectorAll('video')].find(v => v.videoWidth > 0 && v.readyState >= 2);
249
- if (!video) { console.warn('[EchoLens] no video'); return; }
250
- const c = document.createElement('canvas');
251
- c.width = video.videoWidth; c.height = video.videoHeight;
252
- c.getContext('2d').drawImage(video, 0, 0);
253
- const b64 = c.toDataURL('image/jpeg', 0.75);
254
-
255
- const box = document.querySelector('#frame-box textarea');
256
- if (!box) { console.warn('[EchoLens] no frame-box'); return; }
257
- Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(box, b64);
258
- box.dispatchEvent(new Event('input',{bubbles:true}));
259
-
260
- setTimeout(() => {
261
- const fb = document.querySelector('#frame-btn button');
262
- if (fb) fb.click();
263
- else console.warn('[EchoLens] no frame-btn');
264
- }, 200);
265
- }
266
-
267
- capture(); // immediate
268
- window._echoTimer = setInterval(capture, 3500);
269
- }
270
- }
271
- """,
272
- )
273
-
274
- # Status box update from JS
275
- status_out.change(fn=None, inputs=[], outputs=[])
276
-
277
- # Inject status box elem_id via HTML trick
278
- gr.HTML("""
279
- <script>
280
- // Patch status textarea elem_id so JS can find it
281
- document.addEventListener('DOMContentLoaded', () => {
282
- setTimeout(() => {
283
- const labels = document.querySelectorAll('.label-wrap span');
284
- labels.forEach(l => {
285
- if (l.textContent === 'Status') {
286
- const ta = l.closest('.form')?.querySelector('textarea');
287
- if (ta) ta.closest('.block')?.setAttribute('id','status-box');
288
- }
289
- });
290
- }, 2000);
291
- });
292
- </script>
293
- """)
294
-
295
  if __name__ == "__main__":
296
  demo.launch(debug=True)
 
8
  import threading
9
  import time
10
  import base64
11
+ import os
12
  from io import BytesIO
13
 
14
  device = "cuda" if torch.cuda.is_available() else "cpu"
 
25
  trust_remote_code=True
26
  )
27
 
28
+ # ── Warmup ──────────────────────────────────────────────────
29
+ _warmup_done = threading.Event()
30
+
31
  def warmup():
32
  dummy = Image.new("RGB", (224, 224), color=128)
33
  inp = processor(text="<CAPTION>", images=dummy, return_tensors="pt").to(device)
34
  with torch.inference_mode():
35
+ model.generate(input_ids=inp["input_ids"],
36
+ pixel_values=inp["pixel_values"],
37
+ max_new_tokens=20, num_beams=1)
38
+ _warmup_done.set()
 
 
39
  print("Model warmed up!")
40
 
41
  threading.Thread(target=warmup, daemon=True).start()
42
 
43
+ # ── TTS ─────────────────────────────────────────────────────
44
+ _tts_loop = asyncio.new_event_loop()
45
 
46
+ def _run_tts_loop(loop):
47
+ asyncio.set_event_loop(loop)
48
+ loop.run_forever()
49
 
50
+ threading.Thread(target=_run_tts_loop, args=(_tts_loop,), daemon=True).start()
 
 
51
 
52
+ def text_to_speech(text: str) -> str | None:
 
 
 
 
 
 
 
 
 
53
  try:
54
+ async def _gen():
55
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as f:
56
+ path = f.name
57
+ communicate = edge_tts.Communicate(text, voice="en-US-AriaNeural", rate="+5%")
58
+ await communicate.save(path)
59
+ return path
60
+ future = asyncio.run_coroutine_threadsafe(_gen(), _tts_loop)
61
+ return future.result(timeout=15)
62
  except Exception as e:
63
+ print(f"TTS error: {e}")
64
+ return None
65
 
66
+ # ── Caption cache ────────────────────────────────────────────
67
+ last = {"hash": None, "text": "", "audio": None}
 
 
68
 
69
+ def image_hash(img: Image.Image) -> int:
70
+ return hash(img.resize((16, 16)).convert("L").tobytes())
71
+
72
+ # ── Core inference ───────────────────────────────────────────
73
+ TASKS = {
74
+ "Describe Scene": "<MORE_DETAILED_CAPTION>",
75
+ "Quick Caption": "<CAPTION>",
76
+ "Read Text (OCR)": "<OCR>",
77
+ "Detect Objects": "<OD>",
78
+ }
79
+
80
+ def run_inference(image: Image.Image, task_label: str) -> tuple[str, str | None]:
81
+ task = TASKS.get(task_label, "<CAPTION>")
82
+ max_tok = 200 if task == "<MORE_DETAILED_CAPTION>" else 100
83
 
 
84
  inputs = processor(text=task, images=image, return_tensors="pt").to(device)
85
  with torch.inference_mode():
86
  output_ids = model.generate(
87
  input_ids=inputs["input_ids"],
88
  pixel_values=inputs["pixel_values"],
89
+ max_new_tokens=max_tok,
90
  do_sample=False,
91
  num_beams=1,
92
  )
93
+ raw = processor.batch_decode(output_ids, skip_special_tokens=False)[0]
94
+ result = processor.post_process_generation(raw, task=task,
95
+ image_size=(image.width, image.height))
96
+
97
+ if task == "<OD>":
98
+ bboxes = result.get("<OD>", {})
99
+ labels = bboxes.get("labels", [])
100
+ if labels:
101
+ from collections import Counter
102
+ counts = Counter(labels)
103
+ caption = "I can see: " + ", ".join(
104
+ f"{v} {k}" for k, v in counts.most_common()
105
+ )
106
+ else:
107
+ caption = "No objects detected."
108
+ elif task == "<OCR>":
109
+ text_found = result.get("<OCR>", "").strip()
110
+ caption = f"Text found: {text_found}" if text_found else "No text detected."
111
+ else:
112
+ caption = result.get(task, "").strip()
113
+
114
+ return caption
115
 
 
 
116
 
117
+ def describe_image(image: Image.Image, task_label: str, force: bool = False):
118
+ h = image_hash(image)
119
+ if not force and h == last["hash"] and last["text"]:
120
+ return last["text"], last["audio"]
121
+
122
+ caption = run_inference(image, task_label)
123
+ audio = text_to_speech(caption)
124
+
125
+ # cleanup old temp file
126
+ if last["audio"] and os.path.exists(last["audio"]):
127
+ try: os.unlink(last["audio"])
128
+ except: pass
129
 
130
+ last["hash"] = h
131
+ last["text"] = caption
132
+ last["audio"] = audio
133
+ return caption, audio
134
 
135
+
136
+ # ── Gradio handlers ──────────────────────────────────────────
137
+ def handle_describe(image, task_label):
138
+ """Manual describe β€” streams words then returns audio."""
139
  if image is None:
140
+ yield "Please open the camera or upload an image.", None
141
  return
142
  if not isinstance(image, Image.Image):
143
  image = Image.fromarray(image)
144
 
145
+ caption = run_inference(image, task_label)
146
+ words, partial = caption.split(), ""
147
+ for w in words:
148
+ partial += ("" if not partial else " ") + w
149
+ yield partial, None
150
+
151
+ audio = text_to_speech(caption)
152
+ last.update(hash=image_hash(image), text=caption, audio=audio)
153
+ yield caption, audio
154
+
155
+
156
+ def handle_frame(b64: str, task_label: str):
157
+ """Called by JS timer β€” receives webcam frame as base64."""
158
+ if not b64 or "," not in b64:
159
+ return gr.update(), gr.update()
160
+ try:
161
+ data = base64.b64decode(b64.split(",")[1])
162
+ image = Image.open(BytesIO(data)).convert("RGB")
163
+ except Exception as e:
164
+ print(f"Frame decode error: {e}")
165
+ return gr.update(), gr.update()
166
+
167
+ caption, audio = describe_image(image, task_label)
168
+ if not caption:
169
+ return gr.update(), gr.update()
170
+ return caption, audio
171
+
172
+
173
+ def handle_upload(image, task_label):
174
+ yield from handle_describe(image, task_label)
175
+
176
+
177
+ # ── UI ───────────────────────────────────────────────────────
178
+ CSS = """
179
+ /* ── Accessibility base ── */
180
+ body { font-size: 18px !important; }
181
+ .gr-button { min-height: 52px !important; font-size: 17px !important; }
182
+ .gr-textbox textarea { font-size: 18px !important; line-height: 1.7 !important; }
183
+
184
+ /* ── High contrast toggle support ── */
185
+ body.hc { filter: contrast(1.6) brightness(1.1); }
186
+
187
+ /* ── Font-size classes ── */
188
+ body.fs-large * { font-size: 1.3em !important; }
189
+ body.fs-xlarge * { font-size: 1.6em !important; }
190
+
191
+ /* ── Status banner ── */
192
+ #echo-status {
193
+ background: #1e293b; color: #f8fafc;
194
+ padding: 10px 16px; border-radius: 8px;
195
+ font-size: 16px; margin-bottom: 8px;
196
+ min-height: 40px;
197
+ }
198
+
199
+ /* ── Realtime indicator ── */
200
+ #rt-indicator {
201
+ display:inline-block; width:12px; height:12px;
202
+ border-radius:50%; background:#6b7280;
203
+ margin-right:8px; vertical-align:middle;
204
+ transition: background 0.3s;
205
+ }
206
+ #rt-indicator.active { background:#22c55e; animation: pulse 1s infinite; }
207
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
208
+ """
209
+
210
+ JS_INIT = """
211
+ <script>
212
+ (function(){
213
+ let timer = null;
214
+ let running = false;
215
+
216
+ /* ── helpers ── */
217
+ function setTA(el, val){
218
+ const s = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;
219
+ s.call(el, val);
220
+ el.dispatchEvent(new Event('input',{bubbles:true}));
221
+ }
222
+ function status(msg){
223
+ const el = document.getElementById('echo-status');
224
+ if(el){ el.textContent = msg; el.setAttribute('aria-label', msg); }
225
  }
 
226
 
227
+ /* ── capture one frame β†’ hidden textarea β†’ hidden button ── */
228
+ function capture(){
229
+ const video = Array.from(document.querySelectorAll('video'))
230
+ .find(v => v.videoWidth > 0 && v.readyState >= 2);
231
+ if(!video){ status('⚠ Camera not active yet.'); return; }
232
+
233
+ const c = document.createElement('canvas');
234
+ c.width = video.videoWidth; c.height = video.videoHeight;
235
+ c.getContext('2d').drawImage(video,0,0);
236
+ const b64 = c.toDataURL('image/jpeg', 0.8);
237
+
238
+ const ta = document.querySelector('#echo-frame-box textarea');
239
+ if(!ta){ console.warn('[EchoLens] frame-box not found'); return; }
240
+ setTA(ta, b64);
241
+
242
+ setTimeout(()=>{
243
+ const btn = document.querySelector('#echo-frame-btn button');
244
+ if(btn) btn.click();
245
+ else console.warn('[EchoLens] frame-btn not found');
246
+ }, 100);
247
+ }
 
 
248
 
249
+ /* ── toggle realtime ── */
250
+ window.echoStart = function(){
251
+ if(running) return;
252
+ running = true;
253
+ document.getElementById('rt-indicator')?.classList.add('active');
254
+ status('🟒 Realtime ON β€” describing every 3 seconds');
255
+ capture();
256
+ timer = setInterval(capture, 3500);
257
+ };
258
+ window.echoStop = function(){
259
+ if(!running) return;
260
+ running = false;
261
+ clearInterval(timer); timer = null;
262
+ document.getElementById('rt-indicator')?.classList.remove('active');
263
+ status('⏹ Realtime stopped.');
264
+ };
265
+ window.echoToggle = function(){
266
+ running ? window.echoStop() : window.echoStart();
267
+ };
268
+
269
+ /* ── accessibility controls ── */
270
+ window.echoFontSize = function(size){
271
+ document.body.classList.remove('fs-large','fs-xlarge');
272
+ if(size !== 'normal') document.body.classList.add('fs-'+size);
273
+ };
274
+ window.echoContrast = function(){
275
+ document.body.classList.toggle('hc');
276
+ };
277
+
278
+ /* ── keyboard shortcuts ── */
279
+ document.addEventListener('keydown', e => {
280
+ if(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
281
+ if(e.key === 'r' || e.key === 'R') window.echoToggle();
282
+ if(e.key === 'd' || e.key === 'D'){
283
+ document.querySelector('#echo-describe-btn button')?.click();
284
+ }
285
+ if(e.key === 'Escape') window.echoStop();
286
+ });
287
+
288
+ /* ── announce captions to screen readers via aria-live ── */
289
+ const liveRegion = document.createElement('div');
290
+ liveRegion.setAttribute('aria-live','assertive');
291
+ liveRegion.setAttribute('aria-atomic','true');
292
+ liveRegion.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden';
293
+ liveRegion.id = 'echo-live';
294
+ document.body.appendChild(liveRegion);
295
+
296
+ /* Watch caption textbox and announce changes */
297
+ const captionObserver = new MutationObserver(()=>{
298
+ const ta = document.querySelector('#echo-caption textarea');
299
+ if(ta && ta.value){
300
+ document.getElementById('echo-live').textContent = ta.value;
301
+ }
302
+ });
303
+ window.addEventListener('load', ()=>{
304
+ setTimeout(()=>{
305
+ const ta = document.querySelector('#echo-caption textarea');
306
+ if(ta) captionObserver.observe(ta, {attributes:true,childList:true,subtree:true,characterData:true});
307
+ }, 2000);
308
+ });
309
+ })();
310
+ </script>
311
+ """
312
+
313
+
314
+ with gr.Blocks(
315
+ title="EchoLens β€” Vision Assistant for the Blind",
316
+ css=CSS,
317
+ theme=gr.themes.Soft(),
318
+ ) as demo:
319
 
320
+ gr.HTML(JS_INIT)
321
+
322
+ # ── Accessible status banner ──
323
+ gr.HTML("""
324
+ <div id="echo-status" role="status" aria-live="polite" aria-atomic="true">
325
+ EchoLens ready. Press D to describe, R to toggle realtime.
326
+ </div>
327
+ """)
328
 
329
+ gr.Markdown("# πŸ‘οΈ EchoLens β€” Vision Assistant")
330
+ gr.Markdown("Helping blind and visually impaired users understand their surroundings.")
331
 
332
+ # ── Accessibility toolbar ──
333
+ gr.HTML("""
334
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;" role="toolbar" aria-label="Accessibility controls">
335
+ <button onclick="echoFontSize('normal')"
336
+ style="padding:8px 14px;border-radius:6px;border:1px solid #ccc;cursor:pointer;font-size:14px"
337
+ aria-label="Normal font size">A</button>
338
+ <button onclick="echoFontSize('large')"
339
+ style="padding:8px 14px;border-radius:6px;border:1px solid #ccc;cursor:pointer;font-size:17px"
340
+ aria-label="Large font size">A+</button>
341
+ <button onclick="echoFontSize('xlarge')"
342
+ style="padding:8px 14px;border-radius:6px;border:1px solid #ccc;cursor:pointer;font-size:20px"
343
+ aria-label="Extra large font size">A++</button>
344
+ <button onclick="echoContrast()"
345
+ style="padding:8px 14px;border-radius:6px;border:1px solid #ccc;cursor:pointer;font-size:14px;background:#1e293b;color:white"
346
+ aria-label="Toggle high contrast">⬛ High Contrast</button>
347
+ <span style="margin-left:auto;font-size:13px;color:#6b7280;align-self:center">
348
+ Shortcuts: <kbd>D</kbd> describe &nbsp; <kbd>R</kbd> realtime &nbsp; <kbd>Esc</kbd> stop
349
+ </span>
350
+ </div>
351
+ """)
352
 
353
  with gr.Row():
354
+ # ── LEFT column ──────────────────────────────────────
355
  with gr.Column(scale=1):
356
  webcam_input = gr.Image(
357
+ label="Camera",
358
  type="numpy",
359
  sources=["webcam"],
360
+ elem_id="echo-webcam",
361
  )
362
  upload_input = gr.Image(
363
+ label="Upload Image",
364
  type="numpy",
365
  sources=["upload"],
366
+ elem_id="echo-upload",
367
  )
368
  task_choice = gr.Radio(
369
+ choices=list(TASKS.keys()),
370
+ value="Quick Caption",
371
+ label="What should I do?",
372
+ elem_id="echo-task",
373
  )
 
 
374
 
375
+ # Describe once
376
+ describe_btn = gr.Button(
377
+ "πŸ“Έ Describe (D)",
378
+ variant="primary",
379
+ size="lg",
380
+ elem_id="echo-describe-btn",
381
+ )
382
+
383
+ # Realtime toggle
384
+ gr.HTML("""
385
+ <button
386
+ onclick="echoToggle()"
387
+ aria-label="Toggle realtime description every 3 seconds"
388
+ style="width:100%;padding:14px;margin-top:8px;
389
+ background:#0f172a;color:white;border:none;
390
+ border-radius:8px;font-size:17px;cursor:pointer;">
391
+ <span id="rt-indicator"></span>
392
+ β–Ά / ⏹ Toggle Realtime (R)
393
+ </button>
394
+ """)
395
+
396
+ # ── RIGHT column ─────────────────────────────────────
397
  with gr.Column(scale=1):
398
  caption_out = gr.Textbox(
399
+ label="Description",
400
+ lines=6,
401
  interactive=False,
402
  show_copy_button=True,
403
+ placeholder="Description will appear here...",
404
+ elem_id="echo-caption",
405
  )
406
  audio_out = gr.Audio(
407
+ label="Audio",
408
  type="filepath",
409
  autoplay=True,
410
+ elem_id="echo-audio",
411
  )
412
+ gr.HTML("""
413
+ <div style="background:#f0fdf4;border:1px solid #bbf7d0;
414
+ border-radius:8px;padding:12px;margin-top:8px;font-size:14px">
415
+ <strong>Tips for blind users:</strong><br>
416
+ β€’ <kbd>D</kbd> β€” describe what camera sees<br>
417
+ β€’ <kbd>R</kbd> β€” start/stop auto-description every 3s<br>
418
+ β€’ <kbd>Esc</kbd> β€” stop realtime<br>
419
+ β€’ Use <strong>Read Text</strong> mode to read signs/documents<br>
420
+ β€’ Use <strong>Detect Objects</strong> to count items in scene
421
+ </div>
422
+ """)
423
+
424
+ # ── Hidden plumbing for JS frame passing ─────────────────
425
  with gr.Row(visible=False):
426
+ frame_box = gr.Textbox(elem_id="echo-frame-box", label="fb")
427
+ with gr.Column(elem_id="echo-frame-btn"):
428
+ frame_btn = gr.Button("go", elem_id="echo-frame-btn-inner")
 
429
 
430
+ # ── Events ───────────────────────────────────────────────
431
  describe_btn.click(
432
+ fn=handle_describe,
433
  inputs=[webcam_input, task_choice],
434
  outputs=[caption_out, audio_out],
435
  show_progress=False,
436
  )
437
 
438
  upload_input.change(
439
+ fn=handle_upload,
440
  inputs=[upload_input, task_choice],
441
  outputs=[caption_out, audio_out],
442
  show_progress=False,
443
  )
444
 
445
  frame_btn.click(
446
+ fn=handle_frame,
447
  inputs=[frame_box, task_choice],
448
  outputs=[caption_out, audio_out],
449
  show_progress=False,
450
  queue=True,
451
  )
452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  if __name__ == "__main__":
454
  demo.launch(debug=True)