Spaces:
Running on Zero
Running on Zero
Upload 41 files
Browse files- .env.example +11 -0
- .gitattributes +35 -35
- Dockerfile +20 -0
- LICENSE +21 -0
- README.md +51 -13
- app/__pycache__/config.cpython-310.pyc +0 -0
- app/__pycache__/main.cpython-310.pyc +0 -0
- app/accessibility/accessibility.py +7 -0
- app/config.py +42 -0
- app/conversation/__pycache__/assistant.cpython-310.pyc +0 -0
- app/conversation/__pycache__/context.cpython-310.pyc +0 -0
- app/conversation/assistant.py +78 -0
- app/conversation/context.py +59 -0
- app/conversation/intent.py +15 -0
- app/main.py +28 -0
- app/speech/__pycache__/audio_manager.cpython-310.pyc +0 -0
- app/speech/__pycache__/tts.cpython-310.pyc +0 -0
- app/speech/audio_manager.py +63 -0
- app/speech/stt.py +17 -0
- app/speech/tts.py +53 -0
- app/ui/__pycache__/components.cpython-310.pyc +0 -0
- app/ui/__pycache__/events.cpython-310.pyc +0 -0
- app/ui/__pycache__/styles.cpython-310.pyc +0 -0
- app/ui/components.py +83 -0
- app/ui/events.py +66 -0
- app/ui/styles.py +62 -0
- app/vision/__pycache__/captioning.cpython-310.pyc +0 -0
- app/vision/__pycache__/detection.cpython-310.pyc +0 -0
- app/vision/__pycache__/florence.cpython-310.pyc +0 -0
- app/vision/__pycache__/ocr.cpython-310.pyc +0 -0
- app/vision/__pycache__/scene_change.cpython-310.pyc +0 -0
- app/vision/__pycache__/utils.cpython-310.pyc +0 -0
- app/vision/__pycache__/vision_engine.cpython-310.pyc +0 -0
- app/vision/captioning.py +12 -0
- app/vision/detection.py +52 -0
- app/vision/florence.py +150 -0
- app/vision/ocr.py +8 -0
- app/vision/scene_change.py +20 -0
- app/vision/utils.py +16 -0
- app/vision/vision_engine.py +26 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|