LovnishVerma commited on
Commit
d9008cd
Β·
verified Β·
1 Parent(s): daf2428

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1149 -369
app.py CHANGED
@@ -1,280 +1,624 @@
1
  """
2
- EchoLens β€” Realtime Vision Assistant for Blind & Low-Vision Users
3
- =================================================================
4
- Keyboard: D = Describe now Β· R = Toggle realtime Β· Esc = Stop Β· P = Repeat
 
 
 
5
  """
6
 
7
- import torch
8
- import gradio as gr
9
- from PIL import Image
10
- from transformers import AutoProcessor, AutoModelForCausalLM
11
- import edge_tts
12
- import tempfile
13
  import asyncio
14
- import threading
15
- import time
16
  import os
17
  import re
18
- from io import BytesIO
19
- from collections import Counter
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  # ═══════════════════════════════════════════════════════════════
22
- # CONFIG
23
  # ═══════════════════════════════════════════════════════════════
24
- CAPTURE_INTERVAL = 3.5 # seconds between realtime captures
25
- HASH_THRESHOLD = 0.12 # dHash distance to treat as "same scene"
26
- MAX_DIM = 768 # downscale before inference
27
-
28
- VOICE_MAP = {
29
- "Aria (Female, US)": "en-US-AriaNeural",
30
- "Guy (Male, US)": "en-US-GuyNeural",
31
- "Jenny (Female, US)": "en-US-JennyNeural",
32
- "Sonia (Female, UK)": "en-GB-SoniaNeural",
33
- "Ryan (Male, UK)": "en-GB-RyanNeural",
34
  }
35
 
36
- TASKS = {
37
- "Quick Caption": "<CAPTION>",
38
- "Describe Scene": "<MORE_DETAILED_CAPTION>",
39
- "Read Text (OCR)": "<OCR>",
40
- "Detect Objects": "<OD>",
 
41
  }
42
 
43
- MAX_TOKENS = {
44
- "<CAPTION>": 64,
45
- "<MORE_DETAILED_CAPTION>": 160,
46
- "<OD>": 256,
47
- "<OCR>": 300,
 
48
  }
49
 
 
50
  # ═══════════════════════════════════════════════════════════════
51
- # DEVICE & MODEL
52
  # ═══════════════════════════════════════════════════════════════
53
- device = "cuda" if torch.cuda.is_available() else "cpu"
54
- print(f"Device: {device}")
55
 
56
- model = AutoModelForCausalLM.from_pretrained(
57
- "microsoft/Florence-2-base",
58
- trust_remote_code=True,
59
- torch_dtype=torch.float16 if device == "cuda" else torch.float32,
60
- ).to(device).eval()
61
 
62
- processor = AutoProcessor.from_pretrained(
63
- "microsoft/Florence-2-base",
64
- trust_remote_code=True,
65
- )
 
 
 
 
 
 
 
66
 
67
- # ── Background warmup ────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  _warmup_done = threading.Event()
69
 
70
- def _warmup():
71
- dummy = Image.new("RGB", (224, 224), 128)
72
- inp = processor(text="<CAPTION>", images=dummy, return_tensors="pt").to(device)
73
- with torch.inference_mode():
74
- model.generate(input_ids=inp["input_ids"],
75
- pixel_values=inp["pixel_values"],
76
- max_new_tokens=10, num_beams=1)
77
- _warmup_done.set()
78
- print("Model warmed up!")
79
 
80
- threading.Thread(target=_warmup, daemon=True).start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  # ═══════════════════════════════════════════════════════════════
83
- # DEDICATED TTS EVENT LOOP
84
  # ═══════════════════════════════════════════════════════════════
85
- _tts_loop = asyncio.new_event_loop()
86
 
87
- def _run_tts_loop(loop):
88
- asyncio.set_event_loop(loop)
89
- loop.run_forever()
90
 
91
- threading.Thread(target=_run_tts_loop, args=(_tts_loop,), daemon=True).start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
- def text_to_speech(text: str, voice_id: str = "en-US-AriaNeural") -> str | None:
 
 
 
 
94
  if not text or not text.strip():
95
  return None
 
96
  try:
97
- async def _gen():
98
- with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as f:
 
 
 
 
99
  path = f.name
100
- comm = edge_tts.Communicate(text.strip(), voice=voice_id, rate="+5%")
101
- await comm.save(path)
 
 
 
 
102
  return path
103
- future = asyncio.run_coroutine_threadsafe(_gen(), _tts_loop)
104
- return future.result(timeout=15)
 
105
  except Exception as e:
106
  print(f"TTS error: {e}")
107
  return None
108
 
 
109
  # ═══════════════════════════════════════════════════════════════
110
- # STATE
111
  # ═══════════════════════════════════════════════════════════════
112
- class State:
113
- def __init__(self):
114
- self.hash: bytes | None = None
115
- self.task: str = ""
116
- self.text: str = ""
117
- self.audio: str | None = None
118
- self.lock = threading.Lock()
119
- self._tmp_files: list[tuple[str, float]] = []
120
-
121
- def set(self, h: bytes, task: str, text: str, audio: str | None):
122
- with self.lock:
123
- self._cleanup()
124
- if self.audio and os.path.exists(self.audio):
125
- try: os.unlink(self.audio)
126
- except OSError: pass
127
- self.hash = h
128
- self.task = task
129
- self.text = text
130
- self.audio = audio
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  if audio:
132
  self._tmp_files.append((audio, time.time()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
- def _cleanup(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  now = time.time()
136
  keep = []
137
  for path, ts in self._tmp_files:
138
- if now - ts > 300:
139
- try: os.unlink(path)
140
- except OSError: pass
 
 
141
  else:
142
  keep.append((path, ts))
143
  self._tmp_files = keep
144
 
145
- def matches(self, h: bytes, task: str) -> bool:
146
- return (self.hash is not None
147
- and self.hash == h
148
- and self.task == task
149
- and self.text)
150
 
151
- state = State()
 
 
152
 
153
  # ═══════════════════════════════════════════════════════════════
154
- # IMAGE HELPERS
155
  # ═══════════════════════════════════════════════════════════════
156
 
157
- def dhash(img: Image.Image, size: int = 16) -> bytes:
158
- gray = img.resize((size + 1, size)).convert("L")
159
- px = list(gray.getdata())
 
 
160
  return bytes(
161
- 1 if px[y * (size + 1) + x] > px[y * (size + 1) + x + 1] else 0
162
- for y in range(size) for x in range(size)
 
163
  )
164
 
165
- def hash_dist(a: bytes | None, b: bytes | None) -> float:
 
 
166
  if a is None or b is None:
167
  return 1.0
168
- return sum(x != y for x, y in zip(a, b)) / max(len(a), 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- def resize_for_inference(img: Image.Image) -> Image.Image:
171
- w, h = img.size
172
- if max(w, h) <= MAX_DIM:
173
- return img
174
- scale = MAX_DIM / max(w, h)
175
- return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
176
 
177
  # ═══════════════════════════════════════════════════════════════
178
- # CORE INFERENCE
179
  # ═══════════════════════════════════════════════════════════════
180
 
 
181
  def run_inference(image: Image.Image, task_label: str) -> str:
182
- task = TASKS.get(task_label, "<CAPTION>")
183
- image = resize_for_inference(image)
184
- inputs = processor(text=task, images=image, return_tensors="pt").to(device)
185
- with torch.inference_mode():
186
- output_ids = model.generate(
187
- input_ids=inputs["input_ids"],
188
- pixel_values=inputs["pixel_values"],
189
- max_new_tokens=MAX_TOKENS.get(task, 64),
190
- do_sample=False,
191
- num_beams=1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  )
193
- raw = processor.batch_decode(output_ids, skip_special_tokens=False)[0]
194
- result = processor.post_process_generation(
195
- raw, task=task, image_size=(image.width, image.height)
196
- )
197
- if task == "<OD>":
198
- od_result = result.get("<OD>", {}) # ← this is a dict, not a string
199
- return _format_od(od_result)
200
- elif task == "<OCR>":
201
- text_found = result.get("<OCR>", "").strip()
202
- return f"Text found: {text_found}" if text_found else "No text detected."
203
- else:
204
- return result.get(task, "").strip()
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
- def _format_od(od: dict) -> str:
208
- """od = {"bboxes": [[x1,y1,x2,y2], ...], "labels": ["cat", "dog", ...]}"""
209
- if not od or not od.get("labels"):
210
- return "No objects detected."
211
 
212
- labels = od.get("labels", [])
213
- bboxes = od.get("bboxes", [])
214
 
215
- objects: list[tuple[str, str]] = []
 
216
  for label, bbox in zip(labels, bboxes):
217
  x1, _, x2, _ = bbox
218
  cx = (x1 + x2) / 2
219
- # Florence-2 bboxes are in absolute pixels relative to image_size
220
- # Use 1/3 and 2/3 of image width as thresholds β€” but we don't have
221
- # image width here, so use the Florence coordinate space (0–999)
222
- pos = "on the left" if cx < 333 else ("in the center" if cx < 666 else "on the right")
 
 
 
223
  objects.append((label.strip(), pos))
224
 
225
- if not objects:
226
- return "No objects detected."
227
-
228
- # Deduplicate by label
229
- seen: set[str] = set()
230
- unique: list[tuple[str, str]] = []
231
  for lbl, pos in objects:
232
  key = lbl.lower()
233
  if key and key not in seen:
234
  seen.add(key)
235
  unique.append((lbl, pos))
236
 
 
 
 
 
237
  if len(unique) == 1:
238
  lbl, pos = unique[0]
239
  return f"I see {lbl} {pos}."
240
- parts = [f"{lbl} {pos}".strip() for lbl, pos in unique]
241
- if len(parts) <= 6:
 
 
242
  return "I see " + ", ".join(parts[:-1]) + f", and {parts[-1]}."
243
- return f"I see {len(unique)} objects: " + ", ".join(parts[:5]) + f", and {len(unique) - 5} more."
 
 
 
244
 
245
  # ═══════════════════════════════════════════════════════════════
246
- # HANDLERS
247
  # ═══════════════════════════════════════════════════════════════
248
 
249
- def handle_describe(image, task_label: str, voice_name: str):
250
- """Manual describe β€” streams words visually, then returns audio."""
 
 
 
 
251
  if image is None:
252
- yield "Please open the camera or upload an image.", None
253
  return
 
 
254
  if not isinstance(image, Image.Image):
255
  image = Image.fromarray(image)
256
 
257
- h = dhash(image)
 
258
  task_key = TASKS.get(task_label, "<CAPTION>")
259
 
260
- with state.lock:
261
- if state.matches(h, task_key):
262
- yield state.text, state.audio
263
- return
 
 
 
 
264
 
265
  caption = run_inference(image, task_label)
 
266
 
 
267
  words = caption.split()
268
  partial = ""
269
  for i, w in enumerate(words):
270
  partial += (" " if partial else "") + w
271
- if (i + 1) % 4 == 0 or i == len(words) - 1:
272
- yield partial, None
273
 
 
274
  voice_id = VOICE_MAP.get(voice_name, "en-US-AriaNeural")
275
- audio = text_to_speech(caption, voice_id)
276
- state.set(h, task_key, caption, audio)
277
- yield caption, audio
 
 
 
 
 
 
 
 
278
 
279
 
280
  def handle_realtime_stream(image, task_label: str, voice_name: str, rt_active: bool):
@@ -283,254 +627,690 @@ def handle_realtime_stream(image, task_label: str, voice_name: str, rt_active: b
283
  Only processes if realtime toggle is ON.
284
  """
285
  if not rt_active:
286
- return gr.update(), gr.update()
 
287
  if image is None:
288
- return gr.update(), gr.update()
289
 
 
290
  if not isinstance(image, Image.Image):
291
  image = Image.fromarray(image)
292
 
293
- h = dhash(image)
 
 
 
 
 
 
 
294
  task_key = TASKS.get(task_label, "<CAPTION>")
295
 
296
- # Scene-change gate
297
- with state.lock:
298
- if state.hash is not None and hash_dist(h, state.hash) < HASH_THRESHOLD:
299
- return gr.update(), gr.update()
 
 
 
 
 
300
 
301
  # Cache check
302
- with state.lock:
303
- if state.matches(h, task_key):
304
- return state.text, state.audio
 
 
 
 
305
 
 
306
  caption = run_inference(image, task_label)
 
 
 
307
  voice_id = VOICE_MAP.get(voice_name, "en-US-AriaNeural")
308
- audio = text_to_speech(caption, voice_id)
309
- state.set(h, task_key, caption, audio)
310
- return caption, audio
311
 
 
 
312
 
313
- def handle_upload(image, task_label: str, voice_name: str):
314
- yield from handle_describe(image, task_label, voice_name)
315
 
 
316
 
317
- def handle_repeat(voice_name: str):
318
- with state.lock:
319
- text = state.text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  if not text:
321
- return gr.update(), gr.update(value=None)
 
322
  voice_id = VOICE_MAP.get(voice_name, "en-US-AriaNeural")
323
- audio = text_to_speech(text, voice_id)
324
- return text, audio
325
 
 
326
 
327
- def handle_stop():
328
- return gr.update(value=""), gr.update(value=None)
329
 
 
 
 
 
330
 
331
- def toggle_realtime(current: bool):
332
- """Flip the realtime state, return new state + updated button label."""
333
- new_state = not current
334
- if new_state:
335
- label = "🟒 Realtime ON β€” click to stop (R)"
336
- status = "Realtime ON β€” describing every 3.5 seconds"
337
- else:
338
- label = "⚫ Toggle Realtime (R)"
339
- status = "Realtime stopped."
340
- return new_state, label, status
 
 
 
 
 
 
 
 
 
 
341
 
342
 
343
  # ═══════════════════════════════════════════════════════════════
344
- # CSS
345
  # ═══════════════════════════════════════════════════════════════
 
346
  CSS = """
347
- body { font-size: 18px !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  .gr-button {
349
  min-height: 52px !important;
350
- font-size: 17px !important;
351
- border-radius: 12px !important;
352
- cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  }
 
 
354
  .gr-textbox textarea {
355
- font-size: 20px !important;
356
  line-height: 1.7 !important;
 
 
 
357
  }
358
- body.hc { filter: contrast(1.6) brightness(1.1); }
359
- body.fs-large .gr-textbox textarea { font-size: 26px !important; }
360
- body.fs-xlarge .gr-textbox textarea { font-size: 32px !important; }
361
  #echo-status {
362
- background: #1e293b; color: #f8fafc;
363
- padding: 12px 18px; border-radius: 10px;
364
- font-size: 17px; margin-bottom: 12px;
365
- min-height: 44px; font-weight: 600;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  }
 
 
367
  @media (max-width: 768px) {
368
- .gr-button { width: 100% !important; min-height: 58px !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  }
370
  """
371
 
372
  # ═══════════════════════════════════════════════════════════════
373
- # BUILD UI
374
  # ═══════════════════════════════════════════════════════════════
375
- with gr.Blocks(title="EchoLens β€” Vision Assistant for the Blind", css=CSS,
376
- theme=gr.themes.Soft()) as demo:
377
-
378
- # Realtime toggle state β€” single source of truth
379
- rt_active = gr.State(False)
380
-
381
- gr.HTML('<div id="echo-live" aria-live="assertive" aria-atomic="true" '
382
- 'style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden" '
383
- 'role="status"></div>')
384
-
385
- gr.HTML("""
386
- <div id="echo-status" role="status" aria-live="polite">
387
- Loading model, please wait...
388
- </div>
389
- """)
390
-
391
- gr.Markdown("# πŸ‘οΈ EchoLens β€” Vision Assistant")
392
- gr.Markdown("Helping blind and visually impaired users understand their surroundings.")
393
-
394
- # Accessibility toolbar
395
- gr.HTML("""
396
- <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;">
397
- <button onclick="document.body.classList.remove('fs-large','fs-xlarge')"
398
- style="padding:8px 14px;border-radius:6px;border:1px solid #ccc;cursor:pointer;font-size:14px">A</button>
399
- <button onclick="document.body.classList.remove('fs-large','fs-xlarge');document.body.classList.add('fs-large')"
400
- style="padding:8px 14px;border-radius:6px;border:1px solid #ccc;cursor:pointer;font-size:17px">A+</button>
401
- <button onclick="document.body.classList.remove('fs-large','fs-xlarge');document.body.classList.add('fs-xlarge')"
402
- style="padding:8px 14px;border-radius:6px;border:1px solid #ccc;cursor:pointer;font-size:20px">A++</button>
403
- <button onclick="document.body.classList.toggle('hc')"
404
- style="padding:8px 14px;border-radius:6px;border:1px solid #ccc;cursor:pointer;font-size:14px;background:#1e293b;color:white">⬛ High Contrast</button>
405
- <span style="margin-left:auto;font-size:13px;color:#6b7280;align-self:center">
406
- <kbd>D</kbd> describe Β· <kbd>R</kbd> realtime Β· <kbd>P</kbd> repeat Β· <kbd>Esc</kbd> stop
407
- </span>
408
- </div>
409
- """)
410
-
411
- with gr.Row():
412
- # ── LEFT: camera ──────────────────────────────────────
413
- with gr.Column(scale=1):
414
- webcam_input = gr.Image(
415
- label="Camera", type="numpy", sources=["webcam"],
416
- elem_id="echo-webcam", height=224, streaming=True,
417
- )
418
- upload_input = gr.Image(
419
- label="Upload Image", type="numpy", sources=["upload"],
420
- elem_id="echo-upload", height=160,
421
- )
422
- task_choice = gr.Radio(
423
- choices=list(TASKS.keys()),
424
- value="Quick Caption",
425
- label="What should I do?",
426
- )
427
- voice_choice = gr.Dropdown(
428
- choices=list(VOICE_MAP.keys()),
429
- value="Aria (Female, US)",
430
- label="Voice",
431
- )
432
 
433
- describe_btn = gr.Button(
434
- "πŸ” Describe Now (D)", variant="primary", size="lg",
435
- elem_id="echo-describe-btn",
436
- )
437
 
438
- # ── Realtime toggle β€” pure Gradio button + State ──
439
- realtime_btn = gr.Button(
440
- "⚫ Toggle Realtime (R)",
441
- variant="secondary", size="lg",
442
- elem_id="echo-rt-btn",
443
- )
444
- rt_status = gr.Textbox(
445
- value="", label="", interactive=False,
446
- visible=True, max_lines=1, show_label=False,
447
- container=False,
448
- )
 
 
 
 
 
 
 
 
 
 
449
 
450
- # ── RIGHT: output ────────────────────────────────────
451
- with gr.Column(scale=1):
452
- caption_out = gr.Textbox(
453
- label="Description", lines=5, interactive=False,
454
- show_copy_button=True,
455
- placeholder="Description will appear here...",
456
- elem_id="echo-caption",
457
- )
458
- audio_out = gr.Audio(
459
- label="Audio", type="filepath", autoplay=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  )
 
461
 
462
- with gr.Row():
463
- repeat_btn = gr.Button("πŸ” Repeat (P)", variant="secondary",
464
- elem_id="echo-repeat-btn")
465
- stop_btn = gr.Button("⏹ Silence", variant="stop",
466
- elem_id="echo-stop-btn")
467
-
468
- gr.HTML("""
469
- <div style="background:#f0fdf4;border:1px solid #bbf7d0;
470
- border-radius:10px;padding:14px;margin-top:10px;font-size:15px;line-height:1.6">
471
- <strong>Tips for blind users:</strong><br>
472
- β€’ <kbd>D</kbd> β€” describe what camera sees now<br>
473
- β€’ <kbd>R</kbd> β€” start/stop auto-description every 3.5s<br>
474
- β€’ <kbd>P</kbd> β€” repeat last description<br>
475
- β€’ <kbd>Esc</kbd> β€” stop realtime<br>
476
- β€’ Use <strong>Read Text</strong> to read signs, labels, screens<br>
477
- β€’ Use <strong>Detect Objects</strong> to hear what's where
478
- </div>
479
- """)
480
-
481
- # ═══════════════════════════════════════════════════════
482
- # EVENT WIRING
483
- # ═══════════════════════════════════════════════════════
484
-
485
- # Manual describe
486
- describe_btn.click(
487
- handle_describe,
488
- inputs=[webcam_input, task_choice, voice_choice],
489
- outputs=[caption_out, audio_out],
490
- show_progress=False,
491
- )
492
 
493
- # Upload
494
- upload_input.change(
495
- handle_upload,
496
- inputs=[upload_input, task_choice, voice_choice],
497
- outputs=[caption_out, audio_out],
498
- show_progress=False,
499
- )
500
 
501
- # ── Realtime toggle β€” flips gr.State, updates button label ──
502
- realtime_btn.click(
503
- toggle_realtime,
504
- inputs=[rt_active],
505
- outputs=[rt_active, realtime_btn, rt_status],
506
- )
507
 
508
- # ── Realtime streaming β€” fires every CAPTURE_INTERVAL seconds ──
509
- # Only does work when rt_active == True (gated inside handler)
510
- webcam_input.stream(
511
- handle_realtime_stream,
512
- inputs=[webcam_input, task_choice, voice_choice, rt_active],
513
- outputs=[caption_out, audio_out],
514
- stream_every=CAPTURE_INTERVAL,
515
- show_progress=False,
516
- time_limit=None,
517
- )
518
 
519
- # Repeat
520
- repeat_btn.click(
521
- handle_repeat,
522
- inputs=[voice_choice],
523
- outputs=[caption_out, audio_out],
524
- show_progress=False,
525
- )
526
 
527
- # Stop
528
- stop_btn.click(
529
- handle_stop,
530
- inputs=[],
531
- outputs=[caption_out, audio_out],
532
- show_progress=False,
533
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
535
  if __name__ == "__main__":
536
- demo.launch(debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ ╔══════════════════════════════════════════════════════════════════════════════╗
3
+ β•‘ ECHOLENS β€” Realtime Vision Assistant for Blind & Low-Vision Users β•‘
4
+ β•‘ β•‘
5
+ β•‘ Keyboard: D = Describe Β· R = Toggle realtime Β· Esc = Stop Β· P = Repeat β•‘
6
+ β•‘ Voice Commands: Click "Enable Voice Commands" for hands-free control β•‘
7
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
8
  """
9
 
10
+ from __future__ import annotations
11
+
 
 
 
 
12
  import asyncio
13
+ import hashlib
14
+ import io
15
  import os
16
  import re
17
+ import threading
18
+ import time
19
+ import warnings
20
+ from collections import deque
21
+ from dataclasses import dataclass, field
22
+ from enum import Enum
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Literal, Optional, Tuple
25
+
26
+ import gradio as gr
27
+ import numpy as np
28
+ import torch
29
+ from PIL import Image, ImageEnhance
30
+ from transformers import AutoModelForCausalLM, AutoProcessor
31
+
32
+ # ── Suppress noisy warnings ─────────────────────────────────────────────────
33
+ warnings.filterwarnings("ignore", message=".*Torch was not compiled with flash attention.*")
34
+ warnings.filterwarnings("ignore", message=".*Using the model.*inference mode.*")
35
+
36
+ # ═══════════════════════════════════════════════════════════════
37
+ # CONFIGURATION
38
+ # ═══════════════════════════════════════════════════════════════
39
+
40
+
41
+ class Config:
42
+ """Central configuration β€” tweak values here."""
43
+
44
+ # Timing
45
+ CAPTURE_INTERVAL: float = 3.0 # seconds between realtime captures
46
+ SCENE_THRESHOLD: float = 0.10 # dHash distance to treat as "same scene"
47
+ DEBOUNCE_MS: int = 800 # ms to debounce rapid requests
48
+ MAX_DIM: int = 768 # downscale before inference
49
+ HASH_SIZE: int = 16 # perceptual hash grid size
50
+
51
+ # Audio
52
+ TTS_TIMEOUT: float = 12.0
53
+ TTS_RATE: str = "+8%" # slightly faster speech
54
+ AUDIO_FORMAT: str = "mp3"
55
+ MAX_QUEUE_SIZE: int = 3 # max pending audio announcements
56
+
57
+ # Model
58
+ MODEL_NAME: str = "microsoft/Florence-2-base"
59
+ MAX_NEW_TOKENS: Dict[str, int] = field(default_factory=lambda: {
60
+ "<CAPTION>": 64,
61
+ "<DETAILED_CAPTION>": 120,
62
+ "<MORE_DETAILED_CAPTION>": 200,
63
+ "<OD>": 256,
64
+ "<OCR>": 300,
65
+ })
66
+
67
+ # UI
68
+ APP_NAME: str = "EchoLens"
69
+ APP_VERSION: str = "2.0"
70
+
71
+
72
+ CONFIG = Config()
73
 
74
  # ═══════════════════════════════════════════════════════════════
75
+ # VOICE CONFIGURATION
76
  # ═══════════════════════════════════════════════════════════════
77
+
78
+ VOICE_MAP: Dict[str, str] = {
79
+ "Aria β€” Female US": "en-US-AriaNeural",
80
+ "Guy β€” Male US": "en-US-GuyNeural",
81
+ "Jenny β€” Female US": "en-US-JennyNeural",
82
+ "Sonia β€” Female UK": "en-GB-SoniaNeural",
83
+ "Ryan β€” Male UK": "en-GB-RyanNeural",
84
+ "Emily β€” Female Australia": "en-AU-EmilyNeural",
85
+ "William β€” Male Australia": "en-AU-WilliamNeural",
86
+ "Natasha β€” Female Australia": "en-AU-NatashaNeural",
87
  }
88
 
89
+ TASKS: Dict[str, str] = {
90
+ "Quick Caption": "<CAPTION>",
91
+ "Describe Scene": "<DETAILED_CAPTION>",
92
+ "Detailed Description": "<MORE_DETAILED_CAPTION>",
93
+ "Read Text (OCR)": "<OCR>",
94
+ "Detect Objects": "<OD>",
95
  }
96
 
97
+ TASK_DESCRIPTIONS: Dict[str, str] = {
98
+ "Quick Caption": "A brief one-sentence description",
99
+ "Describe Scene": "A paragraph describing the scene",
100
+ "Detailed Description": "A thorough multi-sentence description",
101
+ "Read Text (OCR)": "Reads any visible text aloud",
102
+ "Detect Objects": "Names objects and their locations",
103
  }
104
 
105
+
106
  # ═══════════════════════════════════════════════════════════════
107
+ # DEVICE & MODEL LOADING
108
  # ═══════════════════════════════════════════════════════════════
 
 
109
 
 
 
 
 
 
110
 
111
+ def get_device() -> str:
112
+ """Select best available device."""
113
+ if torch.cuda.is_available():
114
+ return "cuda"
115
+ elif torch.backends.mps.is_available():
116
+ return "mps"
117
+ return "cpu"
118
+
119
+
120
+ DEVICE: str = get_device()
121
+ DTYPE: torch.dtype = torch.float16 if DEVICE == "cuda" else torch.float32
122
 
123
+ print(f"πŸ–₯️ Device: {DEVICE.upper()}")
124
+ print(f"πŸ”’ Dtype: {DTYPE}")
125
+
126
+ # ── Model Loading ──────────────────────────────────────────────
127
+ _model_loaded = threading.Event()
128
+
129
+ processor: Optional[AutoProcessor] = None
130
+ model: Optional[AutoModelForCausalLM] = None
131
+
132
+
133
+ def _load_model():
134
+ """Load Florence-2 model in background thread."""
135
+ global model, processor
136
+ try:
137
+ model = AutoModelForCausalLM.from_pretrained(
138
+ CONFIG.MODEL_NAME,
139
+ trust_remote_code=True,
140
+ torch_dtype=DTYPE,
141
+ ).to(DEVICE).eval()
142
+
143
+ processor = AutoProcessor.from_pretrained(
144
+ CONFIG.MODEL_NAME,
145
+ trust_remote_code=True,
146
+ )
147
+ print("βœ… Model loaded successfully")
148
+ except Exception as e:
149
+ print(f"❌ Model loading failed: {e}")
150
+ raise
151
+
152
+
153
+ # Load synchronously on startup (can be made async if needed)
154
+ _load_model()
155
+ _model_loaded.set()
156
+
157
+ # ── Background Warmup ──────────────────────────────────────────
158
  _warmup_done = threading.Event()
159
 
 
 
 
 
 
 
 
 
 
160
 
161
+ def _warmup_model():
162
+ """Run a dummy inference to warm up CUDA kernels."""
163
+ if model is None or processor is None:
164
+ return
165
+ try:
166
+ dummy = Image.new("RGB", (224, 224), 128)
167
+ inputs = processor(text="<CAPTION>", images=dummy, return_tensors="pt").to(DEVICE)
168
+ with torch.inference_mode():
169
+ model.generate(
170
+ input_ids=inputs["input_ids"],
171
+ pixel_values=inputs["pixel_values"],
172
+ max_new_tokens=10,
173
+ num_beams=1,
174
+ )
175
+ _warmup_done.set()
176
+ print("πŸ”₯ Model warmed up")
177
+ except Exception as e:
178
+ print(f"Warmup warning: {e}")
179
+
180
+
181
+ threading.Thread(target=_warmup_model, daemon=True).start()
182
+
183
 
184
  # ═══════════════════════════════════════════════════════════════
185
+ # AUDIO QUEUE SYSTEM
186
  # ═══════════════════════════════════════════════════════════════
 
187
 
 
 
 
188
 
189
+ class AudioQueue:
190
+ """Thread-safe FIFO audio queue with interruption support."""
191
+
192
+ def __init__(self, max_size: int = 3):
193
+ self._queue: deque[Tuple[str, str]] = deque() # (text, audio_path)
194
+ self._current: Optional[str] = None
195
+ self._lock = threading.Lock()
196
+ self._counter = 0
197
+ self._max_size = max_size
198
+
199
+ def enqueue(self, text: str, audio_path: str) -> Optional[str]:
200
+ """Add audio to queue. Returns the path to play (or None if queue full)."""
201
+ with self._lock:
202
+ if len(self._queue) >= self._max_size:
203
+ # Remove oldest
204
+ oldest = self._queue.popleft()
205
+ self._safe_delete(oldest[1])
206
+ self._queue.append((text, audio_path))
207
+ self._counter += 1
208
+ return audio_path
209
+
210
+ def dequeue(self) -> Optional[Tuple[str, str]]:
211
+ """Get next audio item."""
212
+ with self._lock:
213
+ if self._queue:
214
+ item = self._queue.popleft()
215
+ self._current = item[1]
216
+ return item
217
+ return None
218
+
219
+ def clear(self):
220
+ """Clear all queued audio and delete files."""
221
+ with self._lock:
222
+ for _, path in self._queue:
223
+ self._safe_delete(path)
224
+ self._queue.clear()
225
+ self._current = None
226
+
227
+ def interrupt(self):
228
+ """Interrupt current and clear queue."""
229
+ self.clear()
230
+
231
+ @property
232
+ def is_empty(self) -> bool:
233
+ with self._lock:
234
+ return len(self._queue) == 0
235
+
236
+ @property
237
+ def size(self) -> int:
238
+ with self._lock:
239
+ return len(self._queue)
240
+
241
+ @staticmethod
242
+ def _safe_delete(path: str):
243
+ try:
244
+ if path and os.path.exists(path):
245
+ os.unlink(path)
246
+ except OSError:
247
+ pass
248
+
249
+
250
+ # Global audio queue
251
+ AUDIO_QUEUE = AudioQueue(max_size=CONFIG.MAX_QUEUE_SIZE)
252
+
253
+
254
+ # ═══════════════════════════════════════════════════════════════
255
+ # TTS ENGINE (edge-tts)
256
+ # ═══════════════════════════════════════════════════════════════
257
+
258
+
259
+ def init_tts_loop() -> asyncio.AbstractEventLoop:
260
+ """Create a dedicated event loop for TTS in a background thread."""
261
+ loop = asyncio.new_event_loop()
262
+
263
+ def _run():
264
+ asyncio.set_event_loop(loop)
265
+ loop.run_forever()
266
+
267
+ threading.Thread(target=_run, daemon=True).start()
268
+ return loop
269
+
270
 
271
+ _TTS_LOOP = init_tts_loop()
272
+
273
+
274
+ def text_to_speech(text: str, voice_id: str = "en-US-AriaNeural") -> Optional[str]:
275
+ """Convert text to speech, returning the audio file path."""
276
  if not text or not text.strip():
277
  return None
278
+
279
  try:
280
+ import tempfile
281
+
282
+ import edge_tts
283
+
284
+ async def _generate():
285
+ with tempfile.NamedTemporaryFile(delete=False, suffix=f".{CONFIG.AUDIO_FORMAT}") as f:
286
  path = f.name
287
+ communicate = edge_tts.Communicate(
288
+ text.strip(),
289
+ voice=voice_id,
290
+ rate=CONFIG.TTS_RATE,
291
+ )
292
+ await communicate.save(path)
293
  return path
294
+
295
+ future = asyncio.run_coroutine_threadsafe(_generate(), _TTS_LOOP)
296
+ return future.result(timeout=CONFIG.TTS_TIMEOUT)
297
  except Exception as e:
298
  print(f"TTS error: {e}")
299
  return None
300
 
301
+
302
  # ═══════════════════════════════════════════════════════════════
303
+ # APPLICATION STATE
304
  # ═══════════════════════════════════════════════════════════════
305
+
306
+
307
+ @dataclass
308
+ class AppState:
309
+ """Thread-safe application state."""
310
+
311
+ # Scene hashing
312
+ last_hash: Optional[bytes] = None
313
+ last_task: str = ""
314
+ last_text: str = ""
315
+ last_audio: Optional[str] = None
316
+
317
+ # Realtime
318
+ realtime_active: bool = False
319
+ last_capture_time: float = 0.0
320
+
321
+ # History
322
+ history: List[Dict[str, Any]] = field(default_factory=list)
323
+ max_history: int = 50
324
+
325
+ # Stats
326
+ total_describes: int = 0
327
+ total_realtime_captures: int = 0
328
+
329
+ # Lock
330
+ _lock: threading.Lock = field(default_factory=threading.Lock)
331
+ _tmp_files: List[Tuple[str, float]] = field(default_factory=list)
332
+
333
+ def update(self, hash_val: bytes, task: str, text: str, audio: Optional[str]):
334
+ """Update state with new capture results."""
335
+ with self._lock:
336
+ self._cleanup_old_files()
337
+ if self.last_audio and os.path.exists(self.last_audio):
338
+ try:
339
+ os.unlink(self.last_audio)
340
+ except OSError:
341
+ pass
342
+ self.last_hash = hash_val
343
+ self.last_task = task
344
+ self.last_text = text
345
+ self.last_audio = audio
346
  if audio:
347
  self._tmp_files.append((audio, time.time()))
348
+ # Add to history
349
+ self.history.insert(0, {
350
+ "time": time.strftime("%H:%M:%S"),
351
+ "task": task,
352
+ "text": text,
353
+ })
354
+ if len(self.history) > self.max_history:
355
+ self.history = self.history[: self.max_history]
356
+
357
+ def is_duplicate(self, hash_val: bytes, task: str) -> bool:
358
+ """Check if this hash+task combination was already processed."""
359
+ with self._lock:
360
+ return (
361
+ self.last_hash is not None
362
+ and self.last_hash == hash_val
363
+ and self.last_task == task
364
+ and self.last_text != ""
365
+ )
366
 
367
+ def get_last(self) -> Tuple[str, Optional[str]]:
368
+ """Get last description text and audio."""
369
+ with self._lock:
370
+ return self.last_text, self.last_audio
371
+
372
+ def add_stat(self, key: str):
373
+ with self._lock:
374
+ if key == "describe":
375
+ self.total_describes += 1
376
+ elif key == "realtime":
377
+ self.total_realtime_captures += 1
378
+
379
+ def get_stats(self) -> Dict[str, Any]:
380
+ with self._lock:
381
+ return {
382
+ "describes": self.total_describes,
383
+ "realtime_captures": self.total_realtime_captures,
384
+ "history_count": len(self.history),
385
+ }
386
+
387
+ def _cleanup_old_files(self):
388
+ """Remove temp files older than 5 minutes."""
389
  now = time.time()
390
  keep = []
391
  for path, ts in self._tmp_files:
392
+ if now - ts > 300: # 5 minutes
393
+ try:
394
+ os.unlink(path)
395
+ except OSError:
396
+ pass
397
  else:
398
  keep.append((path, ts))
399
  self._tmp_files = keep
400
 
 
 
 
 
 
401
 
402
+ # Global state
403
+ APP_STATE = AppState()
404
+
405
 
406
  # ═══════════════════════════════════════════════════════════════
407
+ # IMAGE PROCESSING
408
  # ═══════════════════════════════════════════════════════════════
409
 
410
+
411
+ def compute_hash(image: Image.Image, size: int = 16) -> bytes:
412
+ """Compute difference hash (dHash) for scene change detection."""
413
+ gray = image.resize((size + 1, size), Image.LANCZOS).convert("L")
414
+ pixels = list(gray.getdata())
415
  return bytes(
416
+ 1 if pixels[y * (size + 1) + x] > pixels[y * (size + 1) + x + 1] else 0
417
+ for y in range(size)
418
+ for x in range(size)
419
  )
420
 
421
+
422
+ def hash_distance(a: Optional[bytes], b: Optional[bytes]) -> float:
423
+ """Compute normalized Hamming distance between two hashes."""
424
  if a is None or b is None:
425
  return 1.0
426
+ if len(a) != len(b):
427
+ return 1.0
428
+ return sum(x != y for x, y in zip(a, b)) / len(a)
429
+
430
+
431
+ def preprocess_image(image: Image.Image) -> Image.Image:
432
+ """Resize image for inference while preserving aspect ratio."""
433
+ w, h = image.size
434
+ if max(w, h) <= CONFIG.MAX_DIM:
435
+ return image
436
+ scale = CONFIG.MAX_DIM / max(w, h)
437
+ new_size = (int(w * scale), int(h * scale))
438
+ return image.resize(new_size, Image.LANCZOS)
439
+
440
+
441
+ def auto_enhance(image: Image.Image) -> Image.Image:
442
+ """Auto-enhance image for better vision model performance."""
443
+ # Slight contrast boost helps Florence-2 on low-light images
444
+ enhancer = ImageEnhance.Contrast(image)
445
+ image = enhancer.enhance(1.1)
446
+ return image
447
 
 
 
 
 
 
 
448
 
449
  # ═══════════════════════════════════════════════════════════════
450
+ # CORE VISION INFERENCE
451
  # ═══════════════════════════════════════════════════════════════
452
 
453
+
454
  def run_inference(image: Image.Image, task_label: str) -> str:
455
+ """Run Florence-2 inference on an image."""
456
+ if model is None or processor is None:
457
+ return "Error: Model not loaded. Please wait or restart."
458
+
459
+ task_token = TASKS.get(task_label, "<CAPTION>")
460
+ max_tokens = CONFIG.MAX_NEW_TOKENS.get(task_token, 64)
461
+
462
+ try:
463
+ # Preprocess
464
+ image = preprocess_image(image)
465
+ image = auto_enhance(image)
466
+
467
+ # Prepare inputs
468
+ inputs = processor(
469
+ text=task_token,
470
+ images=image,
471
+ return_tensors="pt",
472
+ ).to(DEVICE)
473
+
474
+ # Generate
475
+ with torch.inference_mode():
476
+ output_ids = model.generate(
477
+ input_ids=inputs["input_ids"],
478
+ pixel_values=inputs["pixel_values"],
479
+ max_new_tokens=max_tokens,
480
+ do_sample=False,
481
+ num_beams=1,
482
+ use_cache=True,
483
+ )
484
+
485
+ # Decode
486
+ raw_text = processor.batch_decode(output_ids, skip_special_tokens=False)[0]
487
+ result = processor.post_process_generation(
488
+ raw_text,
489
+ task=task_token,
490
+ image_size=(image.width, image.height),
491
  )
 
 
 
 
 
 
 
 
 
 
 
 
492
 
493
+ # Format output based on task
494
+ if task_token == "<OD>":
495
+ od_data = result.get("<OD>", {})
496
+ return format_object_detection(od_data)
497
+ elif task_token == "<OCR>":
498
+ text_found = result.get("<OCR>", "").strip()
499
+ if not text_found:
500
+ return "No text detected in the image."
501
+ return f"Text found: {text_found}"
502
+ else:
503
+ caption = result.get(task_token, "").strip()
504
+ if not caption:
505
+ return "I couldn't understand what's in the image. Please try again."
506
+ return caption
507
+
508
+ except torch.cuda.OutOfMemoryError:
509
+ torch.cuda.empty_cache()
510
+ return "The image is too large for memory. Try a smaller image."
511
+ except Exception as e:
512
+ print(f"Inference error: {e}")
513
+ return f"Sorry, I had trouble analyzing that image. Please try again."
514
+
515
+
516
+ def format_object_detection(od_data: Dict) -> str:
517
+ """Format object detection results into natural language."""
518
+ if not od_data or not od_data.get("labels"):
519
+ return "No objects detected in the image."
520
 
521
+ labels = od_data.get("labels", [])
522
+ bboxes = od_data.get("bboxes", [])
 
 
523
 
524
+ if not labels:
525
+ return "No objects detected in the image."
526
 
527
+ # Build object list with positions
528
+ objects: List[Tuple[str, str]] = []
529
  for label, bbox in zip(labels, bboxes):
530
  x1, _, x2, _ = bbox
531
  cx = (x1 + x2) / 2
532
+ # Florence uses 0-999 coordinate space
533
+ if cx < 333:
534
+ pos = "on the left"
535
+ elif cx < 666:
536
+ pos = "in the center"
537
+ else:
538
+ pos = "on the right"
539
  objects.append((label.strip(), pos))
540
 
541
+ # Deduplicate (keep first occurrence of each label type)
542
+ seen: set = set()
543
+ unique: List[Tuple[str, str]] = []
 
 
 
544
  for lbl, pos in objects:
545
  key = lbl.lower()
546
  if key and key not in seen:
547
  seen.add(key)
548
  unique.append((lbl, pos))
549
 
550
+ if not unique:
551
+ return "No objects detected in the image."
552
+
553
+ # Format naturally
554
  if len(unique) == 1:
555
  lbl, pos = unique[0]
556
  return f"I see {lbl} {pos}."
557
+
558
+ parts = [f"{lbl} {pos}" for lbl, pos in unique]
559
+
560
+ if len(parts) <= 5:
561
  return "I see " + ", ".join(parts[:-1]) + f", and {parts[-1]}."
562
+ else:
563
+ summary = ", ".join(parts[:5])
564
+ return f"I see {len(unique)} objects: {summary}, and {len(unique) - 5} more."
565
+
566
 
567
  # ═══════════════════════════════════════════════════════════════
568
+ # HANDLER FUNCTIONS
569
  # ═══════════════════════════════════════════════════════════════
570
 
571
+
572
+ def describe_now(image, task_label: str, voice_name: str):
573
+ """
574
+ Manual describe handler.
575
+ Streams words visually, then returns final text + audio.
576
+ """
577
  if image is None:
578
+ yield "πŸ“· Please open the camera or upload an image first.", None, "Waiting for image..."
579
  return
580
+
581
+ # Convert to PIL if needed
582
  if not isinstance(image, Image.Image):
583
  image = Image.fromarray(image)
584
 
585
+ # Compute hash
586
+ img_hash = compute_hash(image)
587
  task_key = TASKS.get(task_label, "<CAPTION>")
588
 
589
+ # Check cache
590
+ if APP_STATE.is_duplicate(img_hash, task_key):
591
+ text, audio = APP_STATE.get_last()
592
+ yield text, audio, f"βœ“ Cached result β€’ {task_label}"
593
+ return
594
+
595
+ # Run inference
596
+ yield "⏳ Analyzing image...", None, "Processing..."
597
 
598
  caption = run_inference(image, task_label)
599
+ APP_STATE.add_stat("describe")
600
 
601
+ # Stream words
602
  words = caption.split()
603
  partial = ""
604
  for i, w in enumerate(words):
605
  partial += (" " if partial else "") + w
606
+ if (i + 1) % 3 == 0 or i == len(words) - 1:
607
+ yield partial, None, f"⏳ Speaking... ({i + 1}/{len(words)} words)"
608
 
609
+ # Generate TTS
610
  voice_id = VOICE_MAP.get(voice_name, "en-US-AriaNeural")
611
+ audio_path = text_to_speech(caption, voice_id)
612
+
613
+ # Update state
614
+ APP_STATE.update(img_hash, task_key, caption, audio_path)
615
+
616
+ yield caption, audio_path, f"βœ“ {task_label} β€’ {len(words)} words"
617
+
618
+
619
+ def handle_upload(image, task_label: str, voice_name: str):
620
+ """Handle uploaded image β€” same as describe."""
621
+ yield from describe_now(image, task_label, voice_name)
622
 
623
 
624
  def handle_realtime_stream(image, task_label: str, voice_name: str, rt_active: bool):
 
627
  Only processes if realtime toggle is ON.
628
  """
629
  if not rt_active:
630
+ return gr.update(), gr.update(), "Realtime paused β€” press R to start"
631
+
632
  if image is None:
633
+ return gr.update(), gr.update(), "No camera feed detected"
634
 
635
+ # Convert to PIL
636
  if not isinstance(image, Image.Image):
637
  image = Image.fromarray(image)
638
 
639
+ # Debounce check
640
+ now = time.time()
641
+ if now - APP_STATE.last_capture_time < 1.0:
642
+ return gr.update(), gr.update(), "⏳ Debouncing..."
643
+ APP_STATE.last_capture_time = now
644
+
645
+ # Compute hash
646
+ img_hash = compute_hash(image)
647
  task_key = TASKS.get(task_label, "<CAPTION>")
648
 
649
+ # Scene change detection
650
+ if APP_STATE.last_hash is not None:
651
+ dist = hash_distance(img_hash, APP_STATE.last_hash)
652
+ if dist < CONFIG.SCENE_THRESHOLD:
653
+ return (
654
+ gr.update(),
655
+ gr.update(),
656
+ f"🟒 Realtime active β€’ Scene unchanged (similarity: {1 - dist:.0%})",
657
+ )
658
 
659
  # Cache check
660
+ if APP_STATE.is_duplicate(img_hash, task_key):
661
+ text, audio = APP_STATE.get_last()
662
+ return (
663
+ text,
664
+ audio,
665
+ f"🟒 Realtime active β€’ Used cached result",
666
+ )
667
 
668
+ # Run inference
669
  caption = run_inference(image, task_label)
670
+ APP_STATE.add_stat("realtime")
671
+
672
+ # Generate TTS
673
  voice_id = VOICE_MAP.get(voice_name, "en-US-AriaNeural")
674
+ audio_path = text_to_speech(caption, voice_id)
 
 
675
 
676
+ # Update state
677
+ APP_STATE.update(img_hash, task_key, caption, audio_path)
678
 
679
+ dist = hash_distance(img_hash, APP_STATE.last_hash) if APP_STATE.last_hash else 1.0
680
+ status = f"🟒 Realtime active β€’ Scene changed ({1 - dist:.0%} similar) β€’ {len(caption.split())} words"
681
 
682
+ return caption, audio_path, status
683
 
684
+
685
+ def toggle_realtime(current: bool) -> Tuple[bool, str, str]:
686
+ """Toggle realtime mode on/off."""
687
+ new_state = not current
688
+ if new_state:
689
+ label = "🟒 Stop Realtime (R)"
690
+ status = "🟒 Realtime ON β€” describing every 3 seconds"
691
+ else:
692
+ AUDIO_QUEUE.interrupt()
693
+ label = "⚫ Start Realtime (R)"
694
+ status = "⚫ Realtime OFF β€” press R or click to start"
695
+ return new_state, label, status
696
+
697
+
698
+ def repeat_last(voice_name: str):
699
+ """Repeat the last description."""
700
+ text, _ = APP_STATE.get_last()
701
  if not text:
702
+ return "No previous description to repeat.", None, "No history available"
703
+
704
  voice_id = VOICE_MAP.get(voice_name, "en-US-AriaNeural")
705
+ audio_path = text_to_speech(text, voice_id)
 
706
 
707
+ return text, audio_path, "πŸ” Repeated last description"
708
 
 
 
709
 
710
+ def stop_all():
711
+ """Stop all audio and clear state."""
712
+ AUDIO_QUEUE.interrupt()
713
+ return "", None, "⏹ Stopped β€” press D to describe or R for realtime"
714
 
715
+
716
+ def get_history() -> str:
717
+ """Get formatted history."""
718
+ if not APP_STATE.history:
719
+ return "No descriptions yet."
720
+ lines = []
721
+ for i, item in enumerate(APP_STATE.history[:10], 1):
722
+ lines.append(f"{i}. [{item['time']}] {item['task']}: {item['text'][:80]}...")
723
+ return "\n".join(lines)
724
+
725
+
726
+ def get_stats() -> str:
727
+ """Get usage statistics."""
728
+ stats = APP_STATE.get_stats()
729
+ return (
730
+ f"πŸ“Š Statistics:\n"
731
+ f"β€’ Manual describes: {stats['describes']}\n"
732
+ f"β€’ Realtime captures: {stats['realtime_captures']}\n"
733
+ f"β€’ History entries: {stats['history_count']}"
734
+ )
735
 
736
 
737
  # ═══════════════════════════════════════════════════════════════
738
+ # CSS STYLES
739
  # ═══════════════════════════════════════════════════════════════
740
+
741
  CSS = """
742
+ /* ── Base ─────────────────────────────────────────────────── */
743
+ :root {
744
+ --accent: #2563eb;
745
+ --accent-hover: #1d4ed8;
746
+ --success: #059669;
747
+ --warning: #d97706;
748
+ --danger: #dc2626;
749
+ --bg-primary: #ffffff;
750
+ --bg-secondary: #f8fafc;
751
+ --bg-dark: #0f172a;
752
+ --text-primary: #1e293b;
753
+ --text-secondary: #64748b;
754
+ --border: #e2e8f0;
755
+ --radius: 12px;
756
+ --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
757
+ --shadow-lg: 0 10px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);
758
+ }
759
+
760
+ /* ── Font size modes ──────────────────────────────────────── */
761
+ body.fs-normal { --base-size: 16px; }
762
+ body.fs-large { --base-size: 20px; }
763
+ body.fs-xlarge { --base-size: 26px; }
764
+
765
+ body {
766
+ font-size: var(--base-size, 16px) !important;
767
+ }
768
+
769
+ /* ── High Contrast Mode ───────────────────────────────────── */
770
+ body.hc {
771
+ filter: contrast(1.7) brightness(1.05);
772
+ }
773
+ body.hc .gr-button {
774
+ border: 2px solid #000 !important;
775
+ }
776
+ body.hc .gr-input,
777
+ body.hc .gr-textbox textarea {
778
+ border: 2px solid #000 !important;
779
+ }
780
+
781
+ /* ── Layout ───────────────────────────────────────────────── */
782
  .gr-button {
783
  min-height: 52px !important;
784
+ font-size: var(--base-size, 16px) !important;
785
+ border-radius: var(--radius) !important;
786
+ font-weight: 600 !important;
787
+ transition: all 0.15s ease !important;
788
+ box-shadow: var(--shadow) !important;
789
+ }
790
+ .gr-button:hover {
791
+ transform: translateY(-1px);
792
+ box-shadow: var(--shadow-lg) !important;
793
+ }
794
+ .gr-button:active {
795
+ transform: translateY(0);
796
+ }
797
+
798
+ /* Primary button */
799
+ .gr-button-primary {
800
+ background: linear-gradient(135deg, var(--accent), var(--accent-hover)) !important;
801
+ border: none !important;
802
  }
803
+
804
+ /* ── Textbox ──────────────────────────────────────────────── */
805
  .gr-textbox textarea {
806
+ font-size: calc(var(--base-size, 16px) * 1.15) !important;
807
  line-height: 1.7 !important;
808
+ border-radius: var(--radius) !important;
809
+ padding: 14px !important;
810
+ font-family: 'Segoe UI', system-ui, sans-serif !important;
811
  }
812
+
813
+ /* ── Status Bar ───────────────────────────────────────────── */
 
814
  #echo-status {
815
+ background: linear-gradient(135deg, #1e293b, #0f172a);
816
+ color: #f1f5f9;
817
+ padding: 14px 20px;
818
+ border-radius: var(--radius);
819
+ font-size: calc(var(--base-size, 16px) * 0.95);
820
+ font-weight: 600;
821
+ margin-bottom: 16px;
822
+ box-shadow: var(--shadow);
823
+ border-left: 4px solid var(--accent);
824
+ transition: all 0.3s ease;
825
+ }
826
+
827
+ /* ── Cards ────────────────────────────────────────────────── */
828
+ .echo-card {
829
+ background: var(--bg-secondary);
830
+ border: 1px solid var(--border);
831
+ border-radius: var(--radius);
832
+ padding: 20px;
833
+ margin-bottom: 16px;
834
+ box-shadow: var(--shadow);
835
+ }
836
+
837
+ /* ── Keyboard Shortcuts Display ───────────────────────────── */
838
+ .echo-kbd {
839
+ display: inline-flex;
840
+ align-items: center;
841
+ gap: 6px;
842
+ padding: 6px 12px;
843
+ background: #e2e8f0;
844
+ border-radius: 6px;
845
+ font-size: calc(var(--base-size, 16px) * 0.8);
846
+ font-family: monospace;
847
+ font-weight: 600;
848
+ color: #334155;
849
+ }
850
+
851
+ /* ── Tips Box ─────────────────────────────────────────────── */
852
+ .echo-tips {
853
+ background: linear-gradient(135deg, #ecfdf5, #d1fae5);
854
+ border: 1px solid #a7f3d0;
855
+ border-radius: var(--radius);
856
+ padding: 18px;
857
+ margin-top: 14px;
858
+ font-size: calc(var(--base-size, 16px) * 0.9);
859
+ line-height: 1.7;
860
+ }
861
+
862
+ /* ── Radio buttons ────────────────────────────────────────── */
863
+ .gr-radio {
864
+ font-size: calc(var(--base-size, 16px) * 0.95) !important;
865
+ }
866
+
867
+ /* ── Dropdown ─────────────────────────────────────────────── */
868
+ .gr-dropdown {
869
+ font-size: calc(var(--base-size, 16px) * 0.95) !important;
870
+ }
871
+
872
+ /* ── Section headers ──────────────────────────────────────── */
873
+ .echo-section-title {
874
+ font-size: calc(var(--base-size, 16px) * 1.2);
875
+ font-weight: 700;
876
+ color: var(--text-primary);
877
+ margin-bottom: 12px;
878
+ padding-bottom: 8px;
879
+ border-bottom: 2px solid var(--border);
880
+ }
881
+
882
+ /* ── Accessibility Toolbar ───────────────────────────────── */
883
+ .echo-toolbar {
884
+ display: flex;
885
+ gap: 10px;
886
+ flex-wrap: wrap;
887
+ margin-bottom: 16px;
888
+ padding: 12px;
889
+ background: var(--bg-secondary);
890
+ border-radius: var(--radius);
891
+ border: 1px solid var(--border);
892
+ align-items: center;
893
+ }
894
+
895
+ .echo-toolbar button {
896
+ padding: 8px 16px;
897
+ border-radius: 8px;
898
+ border: 1px solid var(--border);
899
+ background: white;
900
+ cursor: pointer;
901
+ font-weight: 600;
902
+ font-size: calc(var(--base-size, 16px) * 0.85);
903
+ transition: all 0.15s;
904
+ }
905
+ .echo-toolbar button:hover {
906
+ background: #e2e8f0;
907
+ transform: translateY(-1px);
908
+ }
909
+
910
+ /* ── Stats display ────────────────────────────────────────── */
911
+ .echo-stats {
912
+ font-family: monospace;
913
+ font-size: calc(var(--base-size, 16px) * 0.85);
914
+ color: var(--text-secondary);
915
+ background: var(--bg-secondary);
916
+ padding: 10px 14px;
917
+ border-radius: var(--radius);
918
+ margin-top: 10px;
919
  }
920
+
921
+ /* ── Responsive ───────────────────────────────────────────── */
922
  @media (max-width: 768px) {
923
+ .gr-button {
924
+ width: 100% !important;
925
+ min-height: 56px !important;
926
+ }
927
+ .echo-toolbar {
928
+ flex-direction: column;
929
+ align-items: stretch;
930
+ }
931
+ .echo-toolbar button {
932
+ width: 100%;
933
+ }
934
+ }
935
+
936
+ /* ── Focus indicators for accessibility ───────────────────── */
937
+ button:focus-visible,
938
+ .gr-button:focus-visible {
939
+ outline: 3px solid var(--accent) !important;
940
+ outline-offset: 2px !important;
941
+ }
942
+
943
+ /* ── Screen reader only ───────────────────────────────────── */
944
+ .sr-only {
945
+ position: absolute;
946
+ width: 1px;
947
+ height: 1px;
948
+ padding: 0;
949
+ margin: -1px;
950
+ overflow: hidden;
951
+ clip: rect(0, 0, 0, 0);
952
+ white-space: nowrap;
953
+ border-width: 0;
954
+ }
955
+
956
+ /* ── Loading animation ────────────────────────────────────── */
957
+ @keyframes pulse-dot {
958
+ 0%, 100% { opacity: 1; }
959
+ 50% { opacity: 0.4; }
960
+ }
961
+ .echo-loading::after {
962
+ content: "...";
963
+ animation: pulse-dot 1.5s infinite;
964
  }
965
  """
966
 
967
  # ═══════════════════════════════════════════════════════════════
968
+ # GRADIO UI
969
  # ═══════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970
 
 
 
 
 
971
 
972
+ def build_ui() -> gr.Blocks:
973
+ """Build the Gradio user interface."""
974
+
975
+ with gr.Blocks(
976
+ title=f"{CONFIG.APP_NAME} v{CONFIG.APP_VERSION} β€” Vision Assistant",
977
+ css=CSS,
978
+ theme=gr.themes.Soft(
979
+ primary_hue="blue",
980
+ secondary_hue="slate",
981
+ neutral_hue="slate",
982
+ spacing_size="md",
983
+ radius_size="md",
984
+ ),
985
+ analytics_enabled=False,
986
+ ) as demo:
987
+
988
+ # ── Live region for screen readers ───────────────────
989
+ gr.HTML(
990
+ '<div class="sr-only" aria-live="assertive" aria-atomic="true" '
991
+ 'id="aria-live-region" role="status"></div>'
992
+ )
993
 
994
+ # ── Status Bar ───────────────────────────────────────
995
+ status_bar = gr.HTML(
996
+ '<div id="echo-status" role="status" aria-live="polite">'
997
+ "βœ… Ready β€” Press D to describe what the camera sees"
998
+ "</div>"
999
+ )
1000
+
1001
+ # ── Header ───────────────────────────────────────────
1002
+ gr.Markdown(
1003
+ f"# πŸ‘οΈ {CONFIG.APP_NAME} β€” Realtime Vision Assistant",
1004
+ elem_classes=["echo-section-title"],
1005
+ )
1006
+ gr.Markdown(
1007
+ "Helping blind and visually impaired users understand their surroundings. "
1008
+ "Press **D** to describe, **R** for realtime mode, **P** to repeat."
1009
+ )
1010
+
1011
+ # ── Accessibility Toolbar ────────────────────────────
1012
+ gr.HTML("""
1013
+ <div class="echo-toolbar" role="toolbar" aria-label="Accessibility controls">
1014
+ <span style="font-weight:600;color:#475569;font-size:0.9em">Text Size:</span>
1015
+ <button onclick="document.body.classList.remove('fs-large','fs-xlarge');document.body.classList.add('fs-normal')"
1016
+ aria-label="Normal text size">A</button>
1017
+ <button onclick="document.body.classList.remove('fs-large','fs-xlarge');document.body.classList.add('fs-large')"
1018
+ aria-label="Large text size" style="font-size:1.15em">A+</button>
1019
+ <button onclick="document.body.classList.remove('fs-large','fs-xlarge');document.body.classList.add('fs-xlarge')"
1020
+ aria-label="Extra large text size" style="font-size:1.3em">A++</button>
1021
+ <button onclick="document.body.classList.toggle('hc')"
1022
+ aria-label="Toggle high contrast mode"
1023
+ style="background:#1e293b;color:white">⬛ High Contrast</button>
1024
+ <span style="margin-left:auto;font-size:0.85em;color:#6b7280;align-self:center">
1025
+ <span class="echo-kbd">D</span> describe Β·
1026
+ <span class="echo-kbd">R</span> realtime Β·
1027
+ <span class="echo-kbd">P</span> repeat Β·
1028
+ <span class="echo-kbd">Esc</span> stop
1029
+ </span>
1030
+ </div>
1031
+ """
1032
+ )
1033
+
1034
+ # ── Realtime state (single source of truth) ──────────
1035
+ rt_state = gr.State(False)
1036
+
1037
+ with gr.Row():
1038
+ # ════════════════════════════════════════════════
1039
+ # LEFT COLUMN β€” Inputs
1040
+ # ════════════════════════════════════════════════
1041
+ with gr.Column(scale=1):
1042
+
1043
+ # ── Camera ─────────────────────────────────
1044
+ webcam = gr.Image(
1045
+ label="πŸ“· Camera Feed",
1046
+ type="numpy",
1047
+ sources=["webcam"],
1048
+ streaming=True,
1049
+ height=260,
1050
+ elem_id="echo-webcam",
1051
+ )
1052
+
1053
+ # ── Upload ─────────────────────────────────
1054
+ upload = gr.Image(
1055
+ label="πŸ“ Or Upload Image",
1056
+ type="numpy",
1057
+ sources=["upload"],
1058
+ height=140,
1059
+ elem_id="echo-upload",
1060
+ )
1061
+
1062
+ # ── Task Selection ─────────────────────────
1063
+ task_radio = gr.Radio(
1064
+ choices=list(TASKS.keys()),
1065
+ value="Quick Caption",
1066
+ label="What should I do?",
1067
+ info="Select the type of description you want",
1068
+ )
1069
+
1070
+ # Task description
1071
+ task_info = gr.Textbox(
1072
+ value=TASK_DESCRIPTIONS["Quick Caption"],
1073
+ label="",
1074
+ interactive=False,
1075
+ max_lines=1,
1076
+ show_label=False,
1077
+ container=False,
1078
+ elem_classes=["echo-stats"],
1079
+ )
1080
+
1081
+ # ── Voice Selection ────────────────────────
1082
+ voice_dropdown = gr.Dropdown(
1083
+ choices=list(VOICE_MAP.keys()),
1084
+ value="Aria β€” Female US",
1085
+ label="πŸ”Š Voice",
1086
+ info="Choose a voice for spoken descriptions",
1087
+ )
1088
+
1089
+ # ── Describe Button ────────────────────────
1090
+ describe_btn = gr.Button(
1091
+ "πŸ” Describe Now (D)",
1092
+ variant="primary",
1093
+ size="lg",
1094
+ elem_id="echo-describe-btn",
1095
+ )
1096
+
1097
+ # ── Realtime Toggle ────────────────────────
1098
+ realtime_btn = gr.Button(
1099
+ "⚫ Start Realtime (R)",
1100
+ variant="secondary",
1101
+ size="lg",
1102
+ elem_id="echo-rt-btn",
1103
+ )
1104
+
1105
+ # ════════════════════════════════════════════════
1106
+ # RIGHT COLUMN β€” Output
1107
+ # ════════════════════════════════════════════════
1108
+ with gr.Column(scale=1):
1109
+
1110
+ # ── Caption Output ─────────────────────────
1111
+ caption_box = gr.Textbox(
1112
+ label="πŸ“ Description",
1113
+ lines=6,
1114
+ interactive=False,
1115
+ show_copy_button=True,
1116
+ placeholder="Description will appear here...",
1117
+ elem_id="echo-caption",
1118
+ )
1119
+
1120
+ # ── Audio Output ───────────────────────────
1121
+ audio_player = gr.Audio(
1122
+ label="πŸ”Š Audio",
1123
+ type="filepath",
1124
+ autoplay=True,
1125
+ elem_id="echo-audio",
1126
+ )
1127
+
1128
+ # ── Action Buttons ─────────────────────────
1129
+ with gr.Row():
1130
+ repeat_btn = gr.Button(
1131
+ "πŸ” Repeat Last (P)",
1132
+ variant="secondary",
1133
+ size="lg",
1134
+ elem_id="echo-repeat-btn",
1135
+ )
1136
+ stop_btn = gr.Button(
1137
+ "⏹ Stop All (Esc)",
1138
+ variant="stop",
1139
+ size="lg",
1140
+ elem_id="echo-stop-btn",
1141
+ )
1142
+
1143
+ # ── Tips ───────────────────────────────────
1144
+ gr.HTML("""
1145
+ <div class="echo-tips" role="complementary" aria-label="Tips for users">
1146
+ <strong style="color:#065f46;font-size:1.05em">πŸ’‘ Tips:</strong><br>
1147
+ β€’ <strong>D</strong> β€” Describe what the camera sees right now<br>
1148
+ β€’ <strong>R</strong> β€” Start/stop auto-description every 3 seconds<br>
1149
+ β€’ <strong>P</strong> β€” Repeat the last description<br>
1150
+ β€’ <strong>Esc</strong> β€” Stop all audio and realtime mode<br>
1151
+ β€’ <strong>Read Text</strong> β€” Reads signs, labels, screens (OCR)<br>
1152
+ β€’ <strong>Detect Objects</strong> β€” Hear what's where in the scene
1153
+ </div>
1154
+ """)
1155
+
1156
+ # ═══════════════════════════════════════════════════
1157
+ # BOTTOM SECTION β€” Stats & History
1158
+ # ═══════════════════════════════════════════════════
1159
+ with gr.Accordion("πŸ“Š Session Statistics", open=False):
1160
+ stats_box = gr.Textbox(
1161
+ value="Press 'Get Stats' to see usage statistics",
1162
+ label="Statistics",
1163
+ interactive=False,
1164
+ lines=4,
1165
  )
1166
+ stats_btn = gr.Button("Refresh Statistics", size="sm")
1167
 
1168
+ # ═══════════════════════════════════════════════════
1169
+ # EVENT WIRING
1170
+ # ═══════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1171
 
1172
+ # Update task description when task changes
1173
+ def update_task_info(task_label):
1174
+ return TASK_DESCRIPTIONS.get(task_label, "")
 
 
 
 
1175
 
1176
+ task_radio.change(
1177
+ update_task_info,
1178
+ inputs=[task_radio],
1179
+ outputs=[task_info],
1180
+ )
 
1181
 
1182
+ # Manual Describe
1183
+ describe_btn.click(
1184
+ describe_now,
1185
+ inputs=[webcam, task_radio, voice_dropdown],
1186
+ outputs=[caption_box, audio_player, status_bar],
1187
+ show_progress="minimal",
1188
+ )
 
 
 
1189
 
1190
+ # Upload
1191
+ upload.change(
1192
+ handle_upload,
1193
+ inputs=[upload, task_radio, voice_dropdown],
1194
+ outputs=[caption_box, audio_player, status_bar],
1195
+ show_progress="minimal",
1196
+ )
1197
 
1198
+ # Realtime Toggle
1199
+ realtime_btn.click(
1200
+ toggle_realtime,
1201
+ inputs=[rt_state],
1202
+ outputs=[rt_state, realtime_btn, status_bar],
1203
+ )
1204
+
1205
+ # Realtime Stream
1206
+ webcam.stream(
1207
+ handle_realtime_stream,
1208
+ inputs=[webcam, task_radio, voice_dropdown, rt_state],
1209
+ outputs=[caption_box, audio_player, status_bar],
1210
+ stream_every=CONFIG.CAPTURE_INTERVAL,
1211
+ time_limit=None,
1212
+ )
1213
+
1214
+ # Repeat
1215
+ repeat_btn.click(
1216
+ repeat_last,
1217
+ inputs=[voice_dropdown],
1218
+ outputs=[caption_box, audio_player, status_bar],
1219
+ show_progress=False,
1220
+ )
1221
+
1222
+ # Stop
1223
+ stop_btn.click(
1224
+ stop_all,
1225
+ inputs=[],
1226
+ outputs=[caption_box, audio_player, status_bar],
1227
+ show_progress=False,
1228
+ )
1229
+
1230
+ # Stats
1231
+ stats_btn.click(
1232
+ get_stats,
1233
+ inputs=[],
1234
+ outputs=[stats_box],
1235
+ )
1236
+
1237
+ # ═══════════════════════════════════════════════════
1238
+ # KEYBOARD SHORTCUTS (JavaScript)
1239
+ # ═══════════════════════════════════════════════════
1240
+ gr.HTML("""
1241
+ <script>
1242
+ document.addEventListener('keydown', function(e) {
1243
+ // Don't trigger shortcuts when typing in inputs
1244
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
1245
+ return;
1246
+ }
1247
+
1248
+ const key = e.key.toLowerCase();
1249
+
1250
+ if (key === 'd') {
1251
+ e.preventDefault();
1252
+ const btn = document.getElementById('echo-describe-btn');
1253
+ if (btn) btn.click();
1254
+ }
1255
+ else if (key === 'r') {
1256
+ e.preventDefault();
1257
+ const btn = document.getElementById('echo-rt-btn');
1258
+ if (btn) btn.click();
1259
+ }
1260
+ else if (key === 'p') {
1261
+ e.preventDefault();
1262
+ const btn = document.getElementById('echo-repeat-btn');
1263
+ if (btn) btn.click();
1264
+ }
1265
+ else if (key === 'escape') {
1266
+ e.preventDefault();
1267
+ const btn = document.getElementById('echo-stop-btn');
1268
+ if (btn) btn.click();
1269
+ }
1270
+ });
1271
+
1272
+ // Announce to screen readers
1273
+ function announce(message) {
1274
+ const live = document.getElementById('aria-live-region');
1275
+ if (live) {
1276
+ live.textContent = message;
1277
+ setTimeout(() => { live.textContent = ''; }, 1000);
1278
+ }
1279
+ }
1280
+
1281
+ // Hook button clicks for announcements
1282
+ document.addEventListener('click', function(e) {
1283
+ const btn = e.target.closest('button');
1284
+ if (!btn) return;
1285
+ if (btn.id === 'echo-describe-btn') announce('Describing scene');
1286
+ if (btn.id === 'echo-rt-btn') announce('Toggling realtime mode');
1287
+ if (btn.id === 'echo-repeat-btn') announce('Repeating last description');
1288
+ if (btn.id === 'echo-stop-btn') announce('Stopping all audio');
1289
+ });
1290
+ </script>
1291
+ """)
1292
+
1293
+ return demo
1294
+
1295
+
1296
+ # ═══════════════════════════════════════════════════════════════
1297
+ # MAIN
1298
+ # ═══════════════════════════════════════════════════════════════
1299
 
1300
  if __name__ == "__main__":
1301
+ print(f"πŸš€ Starting {CONFIG.APP_NAME} v{CONFIG.APP_VERSION}")
1302
+ print(f" Device: {DEVICE.upper()}")
1303
+ print(f" Tasks: {list(TASKS.keys())}")
1304
+ print(f" Voices: {list(VOICE_MAP.keys())}")
1305
+ print(f" Realtime interval: {CONFIG.CAPTURE_INTERVAL}s")
1306
+
1307
+ demo = build_ui()
1308
+
1309
+ demo.launch(
1310
+ server_name="0.0.0.0",
1311
+ server_port=7860,
1312
+ share=False,
1313
+ debug=True,
1314
+ show_error=True,
1315
+ favicon_path=None,
1316
+ )