LovnishVerma commited on
Commit
a4cedc5
·
verified ·
1 Parent(s): 7ef5a1b

Upload 41 files

Browse files
Files changed (41) hide show
  1. .env.example +11 -0
  2. .gitattributes +35 -35
  3. Dockerfile +20 -0
  4. LICENSE +21 -0
  5. README.md +51 -13
  6. app/__pycache__/config.cpython-310.pyc +0 -0
  7. app/__pycache__/main.cpython-310.pyc +0 -0
  8. app/accessibility/accessibility.py +7 -0
  9. app/config.py +42 -0
  10. app/conversation/__pycache__/assistant.cpython-310.pyc +0 -0
  11. app/conversation/__pycache__/context.cpython-310.pyc +0 -0
  12. app/conversation/assistant.py +78 -0
  13. app/conversation/context.py +59 -0
  14. app/conversation/intent.py +15 -0
  15. app/main.py +28 -0
  16. app/speech/__pycache__/audio_manager.cpython-310.pyc +0 -0
  17. app/speech/__pycache__/tts.cpython-310.pyc +0 -0
  18. app/speech/audio_manager.py +63 -0
  19. app/speech/stt.py +17 -0
  20. app/speech/tts.py +53 -0
  21. app/ui/__pycache__/components.cpython-310.pyc +0 -0
  22. app/ui/__pycache__/events.cpython-310.pyc +0 -0
  23. app/ui/__pycache__/styles.cpython-310.pyc +0 -0
  24. app/ui/components.py +83 -0
  25. app/ui/events.py +66 -0
  26. app/ui/styles.py +62 -0
  27. app/vision/__pycache__/captioning.cpython-310.pyc +0 -0
  28. app/vision/__pycache__/detection.cpython-310.pyc +0 -0
  29. app/vision/__pycache__/florence.cpython-310.pyc +0 -0
  30. app/vision/__pycache__/ocr.cpython-310.pyc +0 -0
  31. app/vision/__pycache__/scene_change.cpython-310.pyc +0 -0
  32. app/vision/__pycache__/utils.cpython-310.pyc +0 -0
  33. app/vision/__pycache__/vision_engine.cpython-310.pyc +0 -0
  34. app/vision/captioning.py +12 -0
  35. app/vision/detection.py +52 -0
  36. app/vision/florence.py +150 -0
  37. app/vision/ocr.py +8 -0
  38. app/vision/scene_change.py +20 -0
  39. app/vision/utils.py +16 -0
  40. app/vision/vision_engine.py +26 -0
  41. requirements.txt +10 -0
.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SIGHTLINE Configuration
2
+ SIGHTLINE_MODEL_NAME=microsoft/Florence-2-base
3
+ SIGHTLINE_CAPTURE_INTERVAL=3.0
4
+ SIGHTLINE_SCENE_THRESHOLD=0.10
5
+ SIGHTLINE_DEBOUNCE_MS=800
6
+ SIGHTLINE_MAX_DIM=768
7
+ SIGHTLINE_HASH_SIZE=16
8
+ SIGHTLINE_TTS_TIMEOUT=12.0
9
+ SIGHTLINE_TTS_RATE=+8%
10
+ SIGHTLINE_AUDIO_FORMAT=mp3
11
+ SIGHTLINE_MAX_QUEUE_SIZE=3
.gitattributes CHANGED
@@ -1,35 +1,35 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ build-essential \
7
+ libgl1-mesa-glx \
8
+ libglib2.0-0 \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ COPY . .
15
+
16
+ ENV PYTHONUNBUFFERED=1
17
+
18
+ EXPOSE 7860
19
+
20
+ CMD ["python", "-m", "app.main"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lovnish Verma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,13 +1,51 @@
1
- ---
2
- title: SightLine
3
- emoji:
4
- colorFrom: gray
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.25.0
8
- python_version: '3.12'
9
- app_file: app.py
10
- pinned: false
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SightLine: See the World Through AI
2
+
3
+ SightLine is a voice-first multimodal AI assistant designed primarily for blind and low-vision users.
4
+
5
+ ## Features
6
+ - **Real-Time Scene Understanding:** Continuously analyze the camera feed to detect people, vehicles, obstacles, etc.
7
+ - **Voice-First Interaction:** Hands-free interaction via voice commands (e.g. "What do you see?", "Read the sign").
8
+ - **Smart Scene Change Detection:** Triggers descriptions only when meaningful changes occur in the scene.
9
+ - **Conversational Context:** Remembers recent objects and OCR text to provide natural context-aware responses.
10
+ - **Accessibility First:** High contrast, ARIA labels, screen reader support, large controls.
11
+
12
+ ## Project Structure
13
+ ```text
14
+ sightline/
15
+ ├── app/
16
+ │ ├── main.py
17
+ │ ├── config.py
18
+ │ ├── vision/
19
+ │ ├── speech/
20
+ │ ├── conversation/
21
+ │ ├── camera/
22
+ │ ├── accessibility/
23
+ │ └── ui/
24
+ ├── Dockerfile
25
+ ├── requirements.txt
26
+ └── README.md
27
+ ```
28
+
29
+ ## Running Locally
30
+
31
+ 1. Create a virtual environment and install dependencies:
32
+ ```bash
33
+ python -m venv venv
34
+ source venv/bin/activate
35
+ pip install -r requirements.txt
36
+ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 --upgrade --force-reinstall
37
+ ```
38
+ 2. Start the application:
39
+ ```bash
40
+ python -m app.main
41
+ ```
42
+ 3. Open the UI at `http://localhost:7860`.
43
+
44
+ ## Docker Deployment
45
+ ```bash
46
+ docker build -t sightline .
47
+ docker run -p 7860:7860 sightline
48
+ ```
49
+
50
+ ## Privacy & Safety
51
+ SightLine does not permanently store camera frames or microphone recordings. It processes imagery purely to provide instant auditory feedback to the user. **Note:** SightLine is not a replacement for a cane, guide dog, caregiver, or professional mobility aid.
app/__pycache__/config.cpython-310.pyc ADDED
Binary file (1.64 kB). View file
 
app/__pycache__/main.cpython-310.pyc ADDED
Binary file (819 Bytes). View file
 
app/accessibility/accessibility.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ def create_aria_live_region() -> str:
2
+ """Returns HTML string for screen reader announcements."""
3
+ return '<div class="sr-only" aria-live="assertive" id="aria-live-region" role="status"></div>'
4
+
5
+ def announce_status(message: str) -> str:
6
+ """Formats a message to be read by screen readers immediately."""
7
+ return f'<div id="sightline-status" role="status" aria-live="assertive">{message}</div>'
app/config.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dataclasses import dataclass
3
+ from typing import Dict
4
+
5
+ @dataclass
6
+ class Config:
7
+ """Central configuration for SightLine."""
8
+
9
+ # Timing
10
+ CAPTURE_INTERVAL: float = float(os.getenv("SIGHTLINE_CAPTURE_INTERVAL", "3.0"))
11
+ SCENE_THRESHOLD: float = float(os.getenv("SIGHTLINE_SCENE_THRESHOLD", "0.10"))
12
+ DEBOUNCE_MS: int = int(os.getenv("SIGHTLINE_DEBOUNCE_MS", "800"))
13
+ MAX_DIM: int = int(os.getenv("SIGHTLINE_MAX_DIM", "768"))
14
+ HASH_SIZE: int = int(os.getenv("SIGHTLINE_HASH_SIZE", "16"))
15
+
16
+ # Audio
17
+ TTS_TIMEOUT: float = float(os.getenv("SIGHTLINE_TTS_TIMEOUT", "12.0"))
18
+ TTS_RATE: str = os.getenv("SIGHTLINE_TTS_RATE", "+8%")
19
+ AUDIO_FORMAT: str = os.getenv("SIGHTLINE_AUDIO_FORMAT", "mp3")
20
+ MAX_QUEUE_SIZE: int = int(os.getenv("SIGHTLINE_MAX_QUEUE_SIZE", "3"))
21
+
22
+ # Model
23
+ MODEL_NAME: str = os.getenv("SIGHTLINE_MODEL_NAME", "microsoft/Florence-2-base")
24
+
25
+ # Text generation limits for tasks
26
+ MAX_NEW_TOKENS: Dict[str, int] = None
27
+
28
+ # UI
29
+ APP_NAME: str = "SightLine"
30
+ APP_VERSION: str = "1.0"
31
+
32
+ def __post_init__(self):
33
+ if self.MAX_NEW_TOKENS is None:
34
+ self.MAX_NEW_TOKENS = {
35
+ "<CAPTION>": 64,
36
+ "<DETAILED_CAPTION>": 120,
37
+ "<MORE_DETAILED_CAPTION>": 200,
38
+ "<OD>": 256,
39
+ "<OCR>": 300,
40
+ }
41
+
42
+ CONFIG = Config()
app/conversation/__pycache__/assistant.cpython-310.pyc ADDED
Binary file (2.54 kB). View file
 
app/conversation/__pycache__/context.cpython-310.pyc ADDED
Binary file (2.14 kB). View file
 
app/conversation/assistant.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.vision.florence import FlorenceVisionEngine
2
+ from app.conversation.context import CONTEXT
3
+ from app.speech.tts import text_to_speech
4
+ from app.speech.audio_manager import AudioQueue
5
+ from app.vision.scene_change import compute_hash, hash_distance
6
+ from app.config import CONFIG
7
+ from PIL import Image
8
+
9
+ AUDIO_QUEUE = AudioQueue(max_size=CONFIG.MAX_QUEUE_SIZE)
10
+
11
+ class SightLineAssistant:
12
+ """Orchestrates vision, context, and speech."""
13
+
14
+ def __init__(self):
15
+ self.vision = FlorenceVisionEngine()
16
+ self.audio_finish_time = 0.0
17
+
18
+ def initialize(self):
19
+ self.vision.load()
20
+
21
+ def process_image(self, image, task: str, voice_name: str, force: bool = False):
22
+ """Process an image and generate a response."""
23
+ if self.vision.model is None:
24
+ return "Model is initializing, please wait...", None, "⏳ Model Loading..."
25
+
26
+ import time
27
+ if not force and time.time() < getattr(self, "audio_finish_time", 0.0):
28
+ return None, None, "🔊 Speaking..."
29
+
30
+ import numpy as np
31
+ if isinstance(image, np.ndarray):
32
+ image = Image.fromarray(image)
33
+
34
+ img_hash = compute_hash(image)
35
+
36
+ # Map human-readable tasks to internal tokens
37
+ task_map = {
38
+ "Quick Glance": "<CAPTION>",
39
+ "Detailed Scene": "<DETAILED_CAPTION>",
40
+ "Immersive Description": "<MORE_DETAILED_CAPTION>",
41
+ "Read Text": "<OCR>"
42
+ }
43
+ internal_task = task_map.get(task, "<DETAILED_CAPTION>")
44
+
45
+ # Debounce/Duplicate check if not forced
46
+ if not force and CONTEXT.is_duplicate(img_hash, internal_task):
47
+ text, audio = CONTEXT.get_last()
48
+ return text, audio, "Used cached result"
49
+
50
+ # Inference based on task
51
+ if internal_task == "<OCR>":
52
+ response = self.vision.read_text(image)
53
+ elif internal_task == "<MORE_DETAILED_CAPTION>":
54
+ response = self.vision.describe_scene(image, detailed=True)
55
+ else:
56
+ response = self.vision.describe_scene(image, detailed=False)
57
+
58
+ # TTS
59
+ audio_path = text_to_speech(response, voice_name)
60
+
61
+ if audio_path:
62
+ import time
63
+ try:
64
+ from pydub import AudioSegment
65
+ duration = AudioSegment.from_file(audio_path).duration_seconds
66
+ except Exception as e:
67
+ print(f"Duration error: {e}")
68
+ duration = len(response) / 15.0 # Fallback rough estimate
69
+ self.audio_finish_time = time.time() + duration
70
+
71
+ # Update context
72
+ CONTEXT.update(img_hash, task, response, audio_path)
73
+
74
+ # Enqueue audio
75
+ if audio_path:
76
+ AUDIO_QUEUE.enqueue(response, audio_path)
77
+
78
+ return response, audio_path, f"Processed task: {task}"
app/conversation/context.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ import threading
3
+ import time
4
+ from typing import List, Dict, Any, Optional, Tuple
5
+
6
+ @dataclass
7
+ class SessionContext:
8
+ """Thread-safe application state and conversational context."""
9
+
10
+ # Scene hashing
11
+ last_hash: Optional[bytes] = None
12
+ last_task: str = ""
13
+ last_text: str = ""
14
+ last_audio: Optional[str] = None
15
+
16
+ # Realtime
17
+ realtime_active: bool = False
18
+ last_capture_time: float = 0.0
19
+
20
+ # History
21
+ history: List[Dict[str, Any]] = field(default_factory=list)
22
+ max_history: int = 50
23
+
24
+ # Lock
25
+ _lock: threading.Lock = field(default_factory=threading.Lock)
26
+
27
+ def update(self, hash_val: bytes, task: str, text: str, audio: Optional[str]):
28
+ """Update state with new capture results."""
29
+ with self._lock:
30
+ self.last_hash = hash_val
31
+ self.last_task = task
32
+ self.last_text = text
33
+ self.last_audio = audio
34
+
35
+ # Add to history
36
+ self.history.insert(0, {
37
+ "time": time.strftime("%H:%M:%S"),
38
+ "task": task,
39
+ "text": text,
40
+ })
41
+ if len(self.history) > self.max_history:
42
+ self.history = self.history[: self.max_history]
43
+
44
+ def is_duplicate(self, hash_val: bytes, task: str) -> bool:
45
+ """Check if this hash+task combination was already processed."""
46
+ with self._lock:
47
+ return (
48
+ self.last_hash is not None
49
+ and self.last_hash == hash_val
50
+ and self.last_task == task
51
+ and self.last_text != ""
52
+ )
53
+
54
+ def get_last(self) -> Tuple[str, Optional[str]]:
55
+ """Get last description text and audio."""
56
+ with self._lock:
57
+ return self.last_text, self.last_audio
58
+
59
+ CONTEXT = SessionContext()
app/conversation/intent.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ def detect_intent(text: str) -> str:
4
+ """Map natural language to a vision task token."""
5
+ text = text.lower().strip()
6
+
7
+ if re.search(r"read|sign|text|document|label|menu", text):
8
+ return "<OCR>"
9
+ elif re.search(r"where|object|nearby|around me|front of me", text):
10
+ return "<OD>"
11
+ elif re.search(r"detail|describe.*room", text):
12
+ return "<MORE_DETAILED_CAPTION>"
13
+ else:
14
+ # Default to standard caption
15
+ return "<DETAILED_CAPTION>"
app/main.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from app.ui.components import build_ui
3
+ from app.conversation.assistant import SightLineAssistant
4
+ from app.config import CONFIG
5
+ from app.ui.styles import CSS
6
+
7
+ import threading
8
+
9
+ def main():
10
+ print(f"Starting {CONFIG.APP_NAME} v{CONFIG.APP_VERSION}")
11
+
12
+ assistant = SightLineAssistant()
13
+ # Load model in background thread for instant UI rendering
14
+ threading.Thread(target=assistant.initialize, daemon=True).start()
15
+
16
+ demo = build_ui(assistant)
17
+
18
+ demo.launch(
19
+ server_name="0.0.0.0",
20
+ server_port=7860,
21
+ share=False,
22
+ debug=True,
23
+ css=CSS
24
+ )
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
app/speech/__pycache__/audio_manager.cpython-310.pyc ADDED
Binary file (2.62 kB). View file
 
app/speech/__pycache__/tts.cpython-310.pyc ADDED
Binary file (1.92 kB). View file
 
app/speech/audio_manager.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import os
3
+ from collections import deque
4
+ from typing import Tuple, Optional
5
+
6
+ class AudioQueue:
7
+ """Thread-safe FIFO audio queue with interruption support."""
8
+
9
+ def __init__(self, max_size: int = 3):
10
+ self._queue: deque[Tuple[str, str]] = deque() # (text, audio_path)
11
+ self._current: Optional[str] = None
12
+ self._lock = threading.Lock()
13
+ self._counter = 0
14
+ self._max_size = max_size
15
+
16
+ def enqueue(self, text: str, audio_path: str) -> Optional[str]:
17
+ """Add audio to queue. Returns the path to play (or None if queue full)."""
18
+ with self._lock:
19
+ if len(self._queue) >= self._max_size:
20
+ oldest = self._queue.popleft()
21
+ self._safe_delete(oldest[1])
22
+ self._queue.append((text, audio_path))
23
+ self._counter += 1
24
+ return audio_path
25
+
26
+ def dequeue(self) -> Optional[Tuple[str, str]]:
27
+ """Get next audio item."""
28
+ with self._lock:
29
+ if self._queue:
30
+ item = self._queue.popleft()
31
+ self._current = item[1]
32
+ return item
33
+ return None
34
+
35
+ def clear(self):
36
+ """Clear all queued audio and delete files."""
37
+ with self._lock:
38
+ for _, path in self._queue:
39
+ self._safe_delete(path)
40
+ self._queue.clear()
41
+ self._current = None
42
+
43
+ def interrupt(self):
44
+ """Interrupt current and clear queue."""
45
+ self.clear()
46
+
47
+ @property
48
+ def is_empty(self) -> bool:
49
+ with self._lock:
50
+ return len(self._queue) == 0
51
+
52
+ @property
53
+ def size(self) -> int:
54
+ with self._lock:
55
+ return len(self._queue)
56
+
57
+ @staticmethod
58
+ def _safe_delete(path: str):
59
+ try:
60
+ if path and os.path.exists(path):
61
+ os.unlink(path)
62
+ except OSError:
63
+ pass
app/speech/stt.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class STTEngine:
2
+ """Abstract interface for Speech-to-Text."""
3
+
4
+ def load(self):
5
+ """Initialize the STT model/provider."""
6
+ pass
7
+
8
+ def transcribe(self, audio_file: str) -> str:
9
+ """Transcribe an audio file into text."""
10
+ return ""
11
+
12
+ class MockSTT(STTEngine):
13
+ """Fallback mock STT since we are focusing on UI/Vision first."""
14
+ def transcribe(self, audio_file: str) -> str:
15
+ return "describe the scene"
16
+
17
+ # Future integration with faster-whisper goes here.
app/speech/tts.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import threading
3
+ import tempfile
4
+ from typing import Optional
5
+ import edge_tts
6
+ from app.config import CONFIG
7
+
8
+ # Voice mappings for supported locales
9
+ VOICE_MAP = {
10
+ "English (US) - Aria": "en-US-AriaNeural",
11
+ "English (US) - Guy": "en-US-GuyNeural",
12
+ "English (UK) - Sonia": "en-GB-SoniaNeural",
13
+ "Hindi - Swara": "hi-IN-SwaraNeural",
14
+ "Hindi - Madhur": "hi-IN-MadhurNeural",
15
+ }
16
+
17
+ def init_tts_loop() -> asyncio.AbstractEventLoop:
18
+ """Create a dedicated event loop for TTS in a background thread."""
19
+ loop = asyncio.new_event_loop()
20
+
21
+ def _run():
22
+ asyncio.set_event_loop(loop)
23
+ loop.run_forever()
24
+
25
+ threading.Thread(target=_run, daemon=True).start()
26
+ return loop
27
+
28
+ _TTS_LOOP = init_tts_loop()
29
+
30
+ def text_to_speech(text: str, voice_name: str = "English (US) - Aria") -> Optional[str]:
31
+ """Convert text to speech, returning the audio file path."""
32
+ if not text or not text.strip():
33
+ return None
34
+
35
+ try:
36
+ voice_id = VOICE_MAP.get(voice_name, "en-US-AriaNeural")
37
+
38
+ async def _generate():
39
+ with tempfile.NamedTemporaryFile(delete=False, suffix=f".{CONFIG.AUDIO_FORMAT}") as f:
40
+ path = f.name
41
+ communicate = edge_tts.Communicate(
42
+ text.strip(),
43
+ voice=voice_id,
44
+ rate=CONFIG.TTS_RATE,
45
+ )
46
+ await communicate.save(path)
47
+ return path
48
+
49
+ future = asyncio.run_coroutine_threadsafe(_generate(), _TTS_LOOP)
50
+ return future.result(timeout=CONFIG.TTS_TIMEOUT)
51
+ except Exception as e:
52
+ print(f"TTS error: {e}")
53
+ return None
app/ui/__pycache__/components.cpython-310.pyc ADDED
Binary file (3.31 kB). View file
 
app/ui/__pycache__/events.cpython-310.pyc ADDED
Binary file (2.21 kB). View file
 
app/ui/__pycache__/styles.cpython-310.pyc ADDED
Binary file (1.53 kB). View file
 
app/ui/components.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from app.ui.styles import CSS
3
+ from app.config import CONFIG
4
+
5
+ def _status_html(msg: str) -> str:
6
+ """Wrap a status message in the styled status-bar div."""
7
+ return f'<div id="sightline-status" role="status" aria-live="polite">{msg}</div>'
8
+
9
+ def build_ui(assistant) -> gr.Blocks:
10
+ auto_start_js = """
11
+ function() {
12
+ setInterval(function() {
13
+ let clickTarget = function(btn) {
14
+ let text = (btn.textContent || btn.innerText || '').toLowerCase().trim();
15
+ if (text === 'click to access webcam' || text === 'record') {
16
+ btn.click();
17
+ }
18
+ };
19
+
20
+ // Check Shadow DOMs
21
+ document.querySelectorAll('*').forEach(function(el) {
22
+ if (el.shadowRoot) {
23
+ el.shadowRoot.querySelectorAll('button').forEach(clickTarget);
24
+ }
25
+ });
26
+
27
+ // Check regular DOM
28
+ document.querySelectorAll('button').forEach(clickTarget);
29
+ }, 1000); // Check every second indefinitely
30
+ }
31
+ """
32
+ with gr.Blocks(title=f"{CONFIG.APP_NAME} v{CONFIG.APP_VERSION}", js=auto_start_js) as demo:
33
+ gr.HTML('<div class="sr-only" aria-live="assertive" id="aria-live-region" role="status"></div>')
34
+
35
+ status_bar = gr.HTML(_status_html("✅ Ready — Press D to describe"))
36
+
37
+ gr.Markdown(f"# 👁️ {CONFIG.APP_NAME}")
38
+
39
+ rt_state = gr.State(True)
40
+
41
+ with gr.Row():
42
+ with gr.Column(scale=1):
43
+ # Controls at the top
44
+ with gr.Row():
45
+ describe_btn = gr.Button("🔍 Describe (D)", variant="primary", elem_id="btn-describe")
46
+ realtime_btn = gr.Button("⚫ Stop Realtime (R)", variant="secondary", elem_id="btn-realtime")
47
+
48
+ with gr.Row():
49
+ task_radio = gr.Radio(
50
+ choices=["Quick Glance", "Detailed Scene", "Immersive Description", "Read Text"],
51
+ value="Detailed Scene",
52
+ label="Mode"
53
+ )
54
+ voice_dropdown = gr.Dropdown(
55
+ choices=["English (US) - Aria", "Hindi - Swara"],
56
+ value="English (US) - Aria",
57
+ label="Voice"
58
+ )
59
+
60
+ webcam = gr.Image(label="📷 Camera", type="numpy", sources=["webcam"], streaming=True)
61
+ with gr.Accordion("Upload Image", open=False):
62
+ upload = gr.Image(label="📁 Upload", type="numpy", sources=["upload"])
63
+
64
+ with gr.Column(scale=1):
65
+ caption_box = gr.Textbox(label="📝 Description", lines=6, interactive=False)
66
+ audio_player = gr.Audio(label="🔊 Audio", type="filepath", autoplay=True)
67
+
68
+ with gr.Row():
69
+ repeat_btn = gr.Button("🔁 Repeat (P)", elem_id="btn-repeat")
70
+ stop_btn = gr.Button("⏹ Stop (Esc)", elem_id="btn-stop")
71
+
72
+ components = {
73
+ "webcam": webcam, "upload": upload, "task_radio": task_radio,
74
+ "voice_dropdown": voice_dropdown, "describe_btn": describe_btn,
75
+ "realtime_btn": realtime_btn, "caption_box": caption_box,
76
+ "audio_player": audio_player, "status_bar": status_bar,
77
+ "rt_state": rt_state, "repeat_btn": repeat_btn, "stop_btn": stop_btn
78
+ }
79
+
80
+ from app.ui.events import wire_events
81
+ wire_events(demo, components, assistant)
82
+
83
+ return demo
app/ui/events.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+
3
+ def wire_events(demo, components, assistant):
4
+ c = components
5
+
6
+ def handle_describe(webcam_img, upload_img, task, voice):
7
+ is_webcam = webcam_img is not None
8
+ img = webcam_img if is_webcam else upload_img
9
+ if img is None:
10
+ return gr.update(), gr.update(), f'<div id="sightline-status">Please provide an image.</div>'
11
+
12
+ import numpy as np
13
+ import cv2
14
+ # If the image came from the webcam, it is likely horizontally mirrored by Gradio.
15
+ # We must un-mirror it so text and spatial relationships are correct!
16
+ if is_webcam and isinstance(img, np.ndarray):
17
+ img = cv2.flip(img, 1)
18
+
19
+ text, audio, status = assistant.process_image(img, task, voice, force=True)
20
+ return text or gr.update(), audio or gr.update(), f'<div id="sightline-status">{status}</div>'
21
+
22
+ c["describe_btn"].click(
23
+ handle_describe,
24
+ inputs=[c["webcam"], c["upload"], c["task_radio"], c["voice_dropdown"]],
25
+ outputs=[c["caption_box"], c["audio_player"], c["status_bar"]]
26
+ )
27
+
28
+ def toggle_rt(is_active):
29
+ new_state = not is_active
30
+ btn_text = "⚫ Stop Realtime (R)" if new_state else "⚫ Start Realtime (R)"
31
+ status_msg = "Realtime Started" if new_state else "Realtime Paused"
32
+ return new_state, gr.update(value=btn_text), f'<div id="sightline-status">{status_msg}</div>'
33
+
34
+ c["realtime_btn"].click(
35
+ toggle_rt,
36
+ inputs=[c["rt_state"]],
37
+ outputs=[c["rt_state"], c["realtime_btn"], c["status_bar"]]
38
+ )
39
+
40
+ def handle_rt_stream(image, task, voice, is_active):
41
+ print(f"DEBUG stream: is_active={is_active}, task={task}, voice={voice}")
42
+ if is_active is False:
43
+ return gr.update(), gr.update(), f'<div id="sightline-status">Realtime Paused</div>'
44
+ if not is_active:
45
+ # Fallback if None
46
+ is_active = True
47
+
48
+ if image is None:
49
+ return gr.update(), gr.update(), f'<div id="sightline-status">No Camera</div>'
50
+
51
+ import numpy as np
52
+ import cv2
53
+ # Un-mirror the webcam feed for backend processing
54
+ if isinstance(image, np.ndarray):
55
+ image = cv2.flip(image, 1)
56
+
57
+ text, audio, status = assistant.process_image(image, task, voice, force=False)
58
+ return text or gr.update(), audio or gr.update(), f'<div id="sightline-status">{status}</div>'
59
+
60
+ c["webcam"].stream(
61
+ handle_rt_stream,
62
+ inputs=[c["webcam"], c["task_radio"], c["voice_dropdown"], c["rt_state"]],
63
+ outputs=[c["caption_box"], c["audio_player"], c["status_bar"]],
64
+ stream_every=3.0
65
+ )
66
+
app/ui/styles.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CSS = """
2
+ /* Base Variables */
3
+ :root {
4
+ --accent: #2563eb;
5
+ --accent-hover: #1d4ed8;
6
+ --bg-primary: #ffffff;
7
+ --bg-secondary: #f8fafc;
8
+ --text-primary: #1e293b;
9
+ --border: #e2e8f0;
10
+ --radius: 12px;
11
+ }
12
+
13
+ /* High Contrast Support */
14
+ body.hc {
15
+ filter: contrast(1.7) brightness(1.05);
16
+ }
17
+
18
+ .gr-button {
19
+ min-height: 80px !important; /* Larger touch targets */
20
+ font-size: 1.3em !important;
21
+ border-radius: var(--radius) !important;
22
+ font-weight: bold !important;
23
+ border: 4px solid transparent !important;
24
+ transition: all 0.2s ease-in-out;
25
+ }
26
+
27
+ .gr-button:focus, .gr-button:hover {
28
+ border: 4px solid #FFD700 !important; /* High contrast yellow focus ring */
29
+ outline: none !important;
30
+ transform: scale(1.02);
31
+ }
32
+
33
+ .gr-button-primary {
34
+ background: #000000 !important;
35
+ color: #FFFFFF !important;
36
+ border: 4px solid #FFFFFF !important;
37
+ }
38
+
39
+ #sightline-status {
40
+ background: #000000;
41
+ color: #00FF00; /* High contrast terminal green */
42
+ padding: 20px;
43
+ border-radius: var(--radius);
44
+ font-size: 1.4em;
45
+ font-weight: 800;
46
+ text-align: center;
47
+ border: 3px solid #00FF00;
48
+ box-shadow: 0 4px 6px rgba(0,255,0,0.2);
49
+ }
50
+
51
+ /* Screen reader only */
52
+ .sr-only {
53
+ position: absolute;
54
+ width: 1px;
55
+ height: 1px;
56
+ padding: 0;
57
+ margin: -1px;
58
+ overflow: hidden;
59
+ clip: rect(0, 0, 0, 0);
60
+ border: 0;
61
+ }
62
+ """
app/vision/__pycache__/captioning.cpython-310.pyc ADDED
Binary file (500 Bytes). View file
 
app/vision/__pycache__/detection.cpython-310.pyc ADDED
Binary file (1.4 kB). View file
 
app/vision/__pycache__/florence.cpython-310.pyc ADDED
Binary file (5.08 kB). View file
 
app/vision/__pycache__/ocr.cpython-310.pyc ADDED
Binary file (395 Bytes). View file
 
app/vision/__pycache__/scene_change.cpython-310.pyc ADDED
Binary file (1.3 kB). View file
 
app/vision/__pycache__/utils.cpython-310.pyc ADDED
Binary file (843 Bytes). View file
 
app/vision/__pycache__/vision_engine.cpython-310.pyc ADDED
Binary file (1.3 kB). View file
 
app/vision/captioning.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def format_caption(raw_caption: str) -> str:
2
+ """Format raw scene descriptions for speech clarity."""
3
+ caption = raw_caption.strip()
4
+ if not caption:
5
+ return "I couldn't understand what's in the image."
6
+
7
+ # Capitalize first letter, ensure period at the end
8
+ if not caption.endswith('.'):
9
+ caption += '.'
10
+ caption = caption[0].upper() + caption[1:]
11
+
12
+ return caption
app/vision/detection.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Tuple
2
+
3
+ def format_object_detection(od_data: Dict) -> str:
4
+ """Format object detection results into natural spatial language."""
5
+ if not od_data or not od_data.get("labels"):
6
+ return "No objects detected."
7
+
8
+ labels = od_data.get("labels", [])
9
+ bboxes = od_data.get("bboxes", [])
10
+
11
+ if not labels:
12
+ return "No objects detected."
13
+
14
+ objects: List[Tuple[str, str]] = []
15
+ for label, bbox in zip(labels, bboxes):
16
+ x1, y1, x2, y2 = bbox
17
+ cx = (x1 + x2) / 2
18
+ cy = (y1 + y2) / 2
19
+
20
+ # Florence uses 0-1000 coordinate space typically, or 0-999
21
+ if cx < 333:
22
+ pos = "on the left"
23
+ elif cx < 666:
24
+ pos = "in the center"
25
+ else:
26
+ pos = "on the right"
27
+
28
+ objects.append((label.strip(), pos))
29
+
30
+ # Deduplicate (keep first occurrence of each label type)
31
+ seen: set = set()
32
+ unique: List[Tuple[str, str]] = []
33
+ for lbl, pos in objects:
34
+ key = lbl.lower()
35
+ if key and key not in seen:
36
+ seen.add(key)
37
+ unique.append((lbl, pos))
38
+
39
+ if not unique:
40
+ return "No objects detected."
41
+
42
+ if len(unique) == 1:
43
+ lbl, pos = unique[0]
44
+ return f"I see a {lbl} {pos}."
45
+
46
+ parts = [f"a {lbl} {pos}" for lbl, pos in unique]
47
+
48
+ if len(parts) <= 5:
49
+ return "I see " + ", ".join(parts[:-1]) + f", and {parts[-1]}."
50
+ else:
51
+ summary = ", ".join(parts[:5])
52
+ return f"I see {len(unique)} objects including: {summary}, and {len(unique) - 5} more."
app/vision/florence.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from transformers import AutoModelForCausalLM, AutoProcessor
3
+ from PIL import Image
4
+ from typing import Optional, Dict, Any
5
+
6
+ from app.vision.vision_engine import VisionEngine
7
+ from app.config import CONFIG
8
+ from app.vision.utils import preprocess_image, auto_enhance
9
+ from app.vision.captioning import format_caption
10
+ from app.vision.ocr import format_ocr
11
+ from app.vision.detection import format_object_detection
12
+
13
+ def get_device() -> str:
14
+ """Select best available device."""
15
+ if torch.cuda.is_available():
16
+ return "cuda"
17
+ elif torch.backends.mps.is_available():
18
+ return "mps"
19
+ return "cpu"
20
+
21
+ DEVICE: str = get_device()
22
+ DTYPE: torch.dtype = torch.float16 if DEVICE == "cuda" else torch.float32
23
+
24
+ class FlorenceVisionEngine(VisionEngine):
25
+ def __init__(self):
26
+ self.model: Optional[AutoModelForCausalLM] = None
27
+ self.processor: Optional[AutoProcessor] = None
28
+ self.paddle_ocr = None
29
+
30
+ def load(self):
31
+ """Load the Florence-2 model."""
32
+ if self.model is not None:
33
+ return
34
+
35
+ try:
36
+ print(f"Loading Florence-2 on {DEVICE.upper()}...")
37
+ self.model = AutoModelForCausalLM.from_pretrained(
38
+ CONFIG.MODEL_NAME,
39
+ trust_remote_code=True,
40
+ torch_dtype=DTYPE,
41
+ ).to(DEVICE).eval()
42
+
43
+ self.processor = AutoProcessor.from_pretrained(
44
+ CONFIG.MODEL_NAME,
45
+ trust_remote_code=True,
46
+ )
47
+ print("Florence-2 loaded successfully")
48
+
49
+ try:
50
+ from paddleocr import PaddleOCR
51
+ print("Loading PaddleOCR...")
52
+ self.paddle_ocr = PaddleOCR(use_angle_cls=True, lang='en', show_log=False)
53
+ print("PaddleOCR loaded successfully")
54
+ except Exception as e:
55
+ print(f"PaddleOCR load failed: {e}")
56
+
57
+ self._warmup()
58
+ except Exception as e:
59
+ print(f"Model loading failed: {e}")
60
+ raise
61
+
62
+ def _warmup(self):
63
+ """Run a dummy inference to warm up kernels."""
64
+ try:
65
+ dummy = Image.new("RGB", (224, 224), 128)
66
+ self._run_inference(dummy, "<CAPTION>")
67
+ print("Model warmed up")
68
+ except Exception as e:
69
+ print(f"Warmup warning: {e}")
70
+
71
+ def _run_inference(self, image: Image.Image, task_token: str) -> Dict[str, Any]:
72
+ """Core inference logic."""
73
+ if self.model is None or self.processor is None:
74
+ raise RuntimeError("Model not loaded. Call load() first.")
75
+
76
+ image = preprocess_image(image)
77
+ image = auto_enhance(image)
78
+ max_tokens = CONFIG.MAX_NEW_TOKENS.get(task_token, 64)
79
+
80
+ inputs = self.processor(
81
+ text=task_token,
82
+ images=image,
83
+ return_tensors="pt",
84
+ ).to(DEVICE)
85
+
86
+ if "pixel_values" in inputs:
87
+ inputs["pixel_values"] = inputs["pixel_values"].to(DTYPE)
88
+
89
+ with torch.inference_mode():
90
+ output_ids = self.model.generate(
91
+ input_ids=inputs["input_ids"],
92
+ pixel_values=inputs["pixel_values"],
93
+ max_new_tokens=max_tokens,
94
+ do_sample=False,
95
+ num_beams=1,
96
+ use_cache=True,
97
+ )
98
+
99
+ raw_text = self.processor.batch_decode(output_ids, skip_special_tokens=False)[0]
100
+ result = self.processor.post_process_generation(
101
+ raw_text,
102
+ task=task_token,
103
+ image_size=(image.width, image.height),
104
+ )
105
+ return result
106
+
107
+ def describe_scene(self, image: Image.Image, detailed: bool = False) -> str:
108
+ task = "<MORE_DETAILED_CAPTION>" if detailed else "<DETAILED_CAPTION>"
109
+ try:
110
+ result = self._run_inference(image, task)
111
+ return format_caption(result.get(task, ""))
112
+ except Exception as e:
113
+ print(f"describe_scene error: {e}")
114
+ return "I couldn't analyze the scene right now."
115
+
116
+ def read_text(self, image: Image.Image) -> str:
117
+ try:
118
+ if hasattr(self, 'paddle_ocr') and self.paddle_ocr:
119
+ import numpy as np
120
+ # Convert PIL Image to RGB Numpy array for PaddleOCR
121
+ img_array = np.array(image.convert("RGB"))
122
+ result = self.paddle_ocr.ocr(img_array, cls=True)
123
+
124
+ if not result or result[0] is None:
125
+ return "I couldn't find any clear text in the image."
126
+
127
+ lines = []
128
+ for line in result[0]:
129
+ text = line[1][0]
130
+ lines.append(text)
131
+
132
+ final_text = " ".join(lines).strip()
133
+ if not final_text:
134
+ return "I couldn't find any clear text."
135
+ return f"The text says: {final_text}"
136
+ else:
137
+ # Fallback to Florence-2 OCR
138
+ result = self._run_inference(image, "<OCR>")
139
+ return format_ocr(result.get("<OCR>", ""))
140
+ except Exception as e:
141
+ print(f"read_text error: {e}")
142
+ return "I couldn't read the text right now."
143
+
144
+ def analyze(self, image: Image.Image, task: str) -> str:
145
+ # Generic handler
146
+ try:
147
+ result = self._run_inference(image, task)
148
+ return str(result)
149
+ except Exception as e:
150
+ return f"Error: {e}"
app/vision/ocr.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ def format_ocr(raw_text: str) -> str:
2
+ """Format OCR text for speech clarity."""
3
+ text_found = raw_text.strip()
4
+ if not text_found:
5
+ return "No text detected."
6
+
7
+ # Add conversational framing
8
+ return f"The text says: {text_found}"
app/vision/scene_change.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image
2
+ from typing import Optional
3
+
4
+ def compute_hash(image: Image.Image, size: int = 16) -> bytes:
5
+ """Compute difference hash (dHash) for scene change detection."""
6
+ gray = image.resize((size + 1, size), Image.LANCZOS).convert("L")
7
+ pixels = list(gray.getdata())
8
+ return bytes(
9
+ 1 if pixels[y * (size + 1) + x] > pixels[y * (size + 1) + x + 1] else 0
10
+ for y in range(size)
11
+ for x in range(size)
12
+ )
13
+
14
+ def hash_distance(a: Optional[bytes], b: Optional[bytes]) -> float:
15
+ """Compute normalized Hamming distance between two hashes."""
16
+ if a is None or b is None:
17
+ return 1.0
18
+ if len(a) != len(b):
19
+ return 1.0
20
+ return sum(x != y for x, y in zip(a, b)) / len(a)
app/vision/utils.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageEnhance
2
+ from app.config import CONFIG
3
+
4
+ def preprocess_image(image: Image.Image) -> Image.Image:
5
+ """Resize image for inference while preserving aspect ratio."""
6
+ w, h = image.size
7
+ if max(w, h) <= CONFIG.MAX_DIM:
8
+ return image
9
+ scale = CONFIG.MAX_DIM / max(w, h)
10
+ new_size = (int(w * scale), int(h * scale))
11
+ return image.resize(new_size, Image.LANCZOS)
12
+
13
+ def auto_enhance(image: Image.Image) -> Image.Image:
14
+ """Auto-enhance image for better vision model performance."""
15
+ enhancer = ImageEnhance.Contrast(image)
16
+ return enhancer.enhance(1.1)
app/vision/vision_engine.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict, Any
3
+ from PIL import Image
4
+
5
+ class VisionEngine(ABC):
6
+ """Abstract interface for vision models."""
7
+
8
+ @abstractmethod
9
+ def load(self):
10
+ """Load the vision model into memory."""
11
+ pass
12
+
13
+ @abstractmethod
14
+ def describe_scene(self, image: Image.Image, detailed: bool = False) -> str:
15
+ """Generate a description of the scene."""
16
+ pass
17
+
18
+ @abstractmethod
19
+ def read_text(self, image: Image.Image) -> str:
20
+ """Perform OCR and return formatted text."""
21
+ pass
22
+
23
+ @abstractmethod
24
+ def analyze(self, image: Image.Image, task: str) -> str:
25
+ """Run a raw generic analysis task on the image."""
26
+ pass
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ transformers==4.48.0
2
+ timm
3
+ torch>=2.1.0
4
+ torchvision
5
+ Pillow>=10.0.0
6
+ einops
7
+ edge-tts
8
+ gradio>=5.0.0
9
+ numpy
10
+ accelerate