Commit ·
e1c010e
0
Parent(s):
Init commit
Browse files- .env.example +31 -0
- .gitattributes +1 -0
- .gitignore +40 -0
- README.md +53 -0
- app.py +44 -0
- assets/demo_images/assembly_line.jpg +3 -0
- assets/demo_images/construction_site.jpg +3 -0
- assets/demo_images/desk_scene.jpg +3 -0
- assets/demo_images/garage_tools.jpg +3 -0
- assets/demo_images/grocery_shelf.jpg +3 -0
- assets/demo_images/kitchen_counter.jpg +3 -0
- assets/demo_images/lab_equipment.jpg +3 -0
- assets/demo_images/medical_tray.jpg +3 -0
- assets/demo_images/office_supplies.jpg +3 -0
- assets/demo_images/warehouse_boxes.jpg +3 -0
- requirements.txt +16 -0
- src/__init__.py +1 -0
- src/language/__init__.py +1 -0
- src/language/reasoner.py +329 -0
- src/pipeline/__init__.py +1 -0
- src/pipeline/vla_pipeline.py +220 -0
- src/planner/__init__.py +1 -0
- src/planner/action_planner.py +197 -0
- src/ui/__init__.py +12 -0
- src/ui/components.py +99 -0
- src/ui/examples.py +45 -0
- src/ui/handlers.py +146 -0
- src/ui/layout.py +151 -0
- src/ui/styles.py +192 -0
- src/utils/__init__.py +1 -0
- src/utils/config.py +100 -0
- src/utils/object_mapper.py +366 -0
- src/vision/__init__.py +1 -0
- src/vision/detector.py +134 -0
- src/vision/grounding_dino_detector.py +350 -0
- src/visualization/__init__.py +1 -0
- src/visualization/visualizer.py +336 -0
- tests/__init__.py +1 -0
- tests/test_action_planner.py +132 -0
- tests/test_detector.py +60 -0
- tests/test_object_mapper.py +71 -0
- tests/test_reasoner.py +112 -0
- tests/test_visualizer.py +106 -0
.env.example
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment Configuration Template
|
| 2 |
+
# Copy this file to .env and fill in your values.
|
| 3 |
+
|
| 4 |
+
# ── LLM Provider ──────────────────────────────────────────────────────────────
|
| 5 |
+
# Choose: "openai" or "groq"
|
| 6 |
+
# Groq is free-tier and ~5-10x faster — recommended for HF Spaces
|
| 7 |
+
LLM_PROVIDER="groq"
|
| 8 |
+
|
| 9 |
+
# OpenAI (used when LLM_PROVIDER=openai)
|
| 10 |
+
OPENAI_API_KEY=""
|
| 11 |
+
OPENAI_MODEL="gpt-4o-mini"
|
| 12 |
+
|
| 13 |
+
# Groq (used when LLM_PROVIDER=groq) — get key at console.groq.com
|
| 14 |
+
GROQ_API_KEY=""
|
| 15 |
+
GROQ_MODEL="llama-3.3-70b-versatile"
|
| 16 |
+
|
| 17 |
+
# ── Vision / Detection ────────────────────────────────────────────────────────
|
| 18 |
+
# Choose: "grounding_dino" (open-vocabulary, recommended)
|
| 19 |
+
# or "yolo" (COCO-80 fixed classes, faster)
|
| 20 |
+
DETECTION_MODEL="grounding_dino"
|
| 21 |
+
|
| 22 |
+
# Grounding DINO settings
|
| 23 |
+
GROUNDING_DINO_MODEL="IDEA-Research/grounding-dino-tiny"
|
| 24 |
+
GROUNDING_DINO_CONFIDENCE="0.3"
|
| 25 |
+
|
| 26 |
+
# YOLO settings (only used when DETECTION_MODEL=yolo)
|
| 27 |
+
YOLO_MODEL="yolov8n.pt"
|
| 28 |
+
YOLO_CONFIDENCE="0.25"
|
| 29 |
+
|
| 30 |
+
# ── Logging ───────────────────────────────────────────────────────────────────
|
| 31 |
+
LOG_LEVEL="INFO"
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*$py.class
|
| 8 |
+
*.so
|
| 9 |
+
.Python
|
| 10 |
+
*.egg-info/
|
| 11 |
+
dist/
|
| 12 |
+
build/
|
| 13 |
+
*.egg
|
| 14 |
+
|
| 15 |
+
# YOLO model weights
|
| 16 |
+
*.pt
|
| 17 |
+
models/
|
| 18 |
+
|
| 19 |
+
# Virtual environment
|
| 20 |
+
.venv/
|
| 21 |
+
venv/
|
| 22 |
+
env/
|
| 23 |
+
|
| 24 |
+
# IDE
|
| 25 |
+
.vscode/
|
| 26 |
+
.idea/
|
| 27 |
+
*.swp
|
| 28 |
+
*.swo
|
| 29 |
+
|
| 30 |
+
# OS
|
| 31 |
+
.DS_Store
|
| 32 |
+
Thumbs.db
|
| 33 |
+
|
| 34 |
+
# Logs
|
| 35 |
+
*.log
|
| 36 |
+
|
| 37 |
+
# Gradio
|
| 38 |
+
flagged/
|
| 39 |
+
|
| 40 |
+
README_APP.md
|
README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: VLA Demo - Codevally AI
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: cyan
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "4.44.0"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: other
|
| 11 |
+
short_description: Vision Language Action pipeline — open-vocabulary AI demo
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# 🤖 Vision Language Action (VLA) Demo
|
| 15 |
+
|
| 16 |
+
> Built by **[Codevally](https://www.codevally.com)** — AI-Powered Software Development & AI Solutions
|
| 17 |
+
|
| 18 |
+
Upload an image and enter a natural-language command. The system detects objects using **Grounding DINO** (open-vocabulary), interprets your instruction with an **LLM**, generates a structured action plan, and visualizes the result.
|
| 19 |
+
|
| 20 |
+
**Try it:** _"Pick up the defective board and place it in the red bin"_
|
| 21 |
+
|
| 22 |
+
## How it works
|
| 23 |
+
|
| 24 |
+
1. 📸 **Vision** — Grounding DINO detects objects based on your command text
|
| 25 |
+
2. 🧠 **Language** — LLM (OpenAI / Groq) interprets the command
|
| 26 |
+
3. 📋 **Planning** — Structured action steps are generated
|
| 27 |
+
4. 🎬 **Action** — Annotated visualization is produced
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## 🏢 About Codevally
|
| 32 |
+
|
| 33 |
+
[Codevally](https://www.codevally.com) is a forward-thinking technology company at the intersection of **AI and software development**. We specialize in building intelligent systems, AI-powered applications, and custom AI solutions that transform businesses — from traditional software to cutting-edge machine learning implementations.
|
| 34 |
+
|
| 35 |
+
### 🔬 Our AI Specializations
|
| 36 |
+
|
| 37 |
+
| Domain | Capabilities |
|
| 38 |
+
| ----------------------------- | ---------------------------------------------------------------------- |
|
| 39 |
+
| 🖼️ **Computer Vision** | Object detection, image classification, video analytics, visual search |
|
| 40 |
+
| 🤖 **Language Models (LLMs)** | OpenAI, Claude, Gemini, Llama fine-tuning, RAG systems, AI agents |
|
| 41 |
+
| 💬 **NLP** | Sentiment analysis, NER, text summarization, question answering |
|
| 42 |
+
| 📈 **Predictive AI** | Forecasting, anomaly detection, churn & risk scoring |
|
| 43 |
+
|
| 44 |
+
### ✅ Why Codevally
|
| 45 |
+
|
| 46 |
+
- **AI-First Approach** — Deep expertise in AI/ML and modern LLM technologies
|
| 47 |
+
- **End-to-End Solutions** — From concept to deployment and monitoring (MLOps)
|
| 48 |
+
- **Practical AI** — Focused on real business value, not just technology
|
| 49 |
+
- **Responsible AI** — Ethics, bias detection, and governance built-in
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
_This demo is an open-vocabulary Vision Language Action pipeline built by the Codevally AI team. [Learn more →](https://www.codevally.com)_
|
app.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py — VLA Demo entry point.
|
| 3 |
+
|
| 4 |
+
Compatible with:
|
| 5 |
+
- Local: python app.py
|
| 6 |
+
- HF Spaces: imported directly (demo object must exist at module level)
|
| 7 |
+
|
| 8 |
+
All UI logic lives in the src/ui/ package.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
from src.utils.config import (
|
| 14 |
+
setup_logging, validate_config,
|
| 15 |
+
LLM_PROVIDER, OPENAI_MODEL, GROQ_MODEL,
|
| 16 |
+
DETECTION_MODEL,
|
| 17 |
+
)
|
| 18 |
+
from src.ui import create_ui
|
| 19 |
+
|
| 20 |
+
setup_logging()
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
for warning in validate_config():
|
| 24 |
+
logger.warning(warning)
|
| 25 |
+
|
| 26 |
+
# ── Startup config summary ─────────────────────────────────────────────────
|
| 27 |
+
_active_model = GROQ_MODEL if LLM_PROVIDER == "groq" else OPENAI_MODEL
|
| 28 |
+
logger.info("=" * 60)
|
| 29 |
+
logger.info(" Codevally VLA Demo — startup configuration")
|
| 30 |
+
logger.info(" LLM Provider : %s", LLM_PROVIDER.upper())
|
| 31 |
+
logger.info(" LLM Model : %s", _active_model)
|
| 32 |
+
logger.info(" Vision Model : %s", DETECTION_MODEL)
|
| 33 |
+
logger.info("=" * 60)
|
| 34 |
+
|
| 35 |
+
# Build the Gradio app at module level so HF Spaces can find it
|
| 36 |
+
demo = create_ui()
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
logger.info("Launching Codevally VLA Demo...")
|
| 40 |
+
demo.launch(
|
| 41 |
+
server_name="0.0.0.0",
|
| 42 |
+
server_port=7860,
|
| 43 |
+
share=False,
|
| 44 |
+
)
|
assets/demo_images/assembly_line.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/construction_site.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/desk_scene.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/garage_tools.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/grocery_shelf.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/kitchen_counter.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/lab_equipment.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/medical_tray.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/office_supplies.jpg
ADDED
|
Git LFS Details
|
assets/demo_images/warehouse_boxes.jpg
ADDED
|
Git LFS Details
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Install CPU-only PyTorch first
|
| 2 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 3 |
+
|
| 4 |
+
torch
|
| 5 |
+
torchvision
|
| 6 |
+
torchaudio
|
| 7 |
+
|
| 8 |
+
ultralytics>=8.0.0
|
| 9 |
+
opencv-python>=4.8.0
|
| 10 |
+
gradio>=4.0.0
|
| 11 |
+
openai>=1.0.0
|
| 12 |
+
numpy>=1.24.0,<2.0.0
|
| 13 |
+
Pillow>=9.0.0
|
| 14 |
+
python-dotenv>=1.0.0
|
| 15 |
+
transformers>=4.40.0
|
| 16 |
+
pytest>=7.0.0
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# VLA Demo System - Source Package
|
src/language/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Language Module Package
|
src/language/reasoner.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Language Reasoning Module — Natural-language command interpretation via LLM.
|
| 3 |
+
|
| 4 |
+
Takes a user command and the list of detected objects, then asks an LLM
|
| 5 |
+
to produce a structured action interpretation:
|
| 6 |
+
|
| 7 |
+
{"action": "move", "source": "bottle", "destination": "box"}
|
| 8 |
+
|
| 9 |
+
Supported providers (set via LLM_PROVIDER env var):
|
| 10 |
+
- "openai" → OpenAI API (GPT-4o-mini by default)
|
| 11 |
+
- "groq" → Groq API (Llama 3.3 70B by default, ~5-10x faster)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# System prompt sent to the LLM
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
_SYSTEM_PROMPT = """\
|
| 24 |
+
You are an AI assistant for a Vision Language Action robotics system.
|
| 25 |
+
|
| 26 |
+
Given:
|
| 27 |
+
1. A list of objects detected in a scene (with labels and bounding boxes).
|
| 28 |
+
2. A natural-language command from a human operator.
|
| 29 |
+
|
| 30 |
+
Your task:
|
| 31 |
+
Interpret the command and return a JSON object with exactly these keys:
|
| 32 |
+
- "action" : The verb describing what to do (e.g. "move", "pick_up",
|
| 33 |
+
"place", "inspect", "rotate", "remove").
|
| 34 |
+
- "source" : The object to act on.
|
| 35 |
+
- "destination" : The target location or object (if applicable, else null).
|
| 36 |
+
|
| 37 |
+
CRITICAL RULES:
|
| 38 |
+
• For "source" and "destination", you MUST use the EXACT label string from
|
| 39 |
+
the detected objects list. The user may refer to objects using different
|
| 40 |
+
words (e.g. "wrench" might actually be detected as "knife", "toolbox"
|
| 41 |
+
might be "suitcase", "mug" might be "cup"). Your job is to figure out
|
| 42 |
+
which detected object the user is referring to and use that detected label.
|
| 43 |
+
• If the user says "wrench" but only "knife" is detected, return "knife".
|
| 44 |
+
• If the user says "toolbox" but only "suitcase" is detected, return "suitcase".
|
| 45 |
+
• If the user says "bin" but only "bowl" is detected, return "bowl".
|
| 46 |
+
• NEVER return a label that is not in the detected objects list.
|
| 47 |
+
• If truly no detected object can reasonably match, use the closest detected
|
| 48 |
+
object label and note it.
|
| 49 |
+
• Return ONLY the JSON object, no extra text.
|
| 50 |
+
• If the command is ambiguous, make your best interpretation.
|
| 51 |
+
• If no destination is implied, set "destination" to null.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Provider → base URL mapping (Groq uses the OpenAI-compatible SDK)
|
| 56 |
+
_PROVIDER_BASE_URLS: dict[str, str | None] = {
|
| 57 |
+
"openai": None, # uses default OpenAI endpoint
|
| 58 |
+
"groq": "https://api.groq.com/openai/v1",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class CommandReasoner:
|
| 63 |
+
"""
|
| 64 |
+
Interprets natural-language commands using an OpenAI-compatible LLM.
|
| 65 |
+
|
| 66 |
+
Supports OpenAI and Groq as backends — both expose the same
|
| 67 |
+
Chat Completions API so the same prompt and parsing logic work for both.
|
| 68 |
+
|
| 69 |
+
Attributes:
|
| 70 |
+
provider: Active provider name ("openai" or "groq").
|
| 71 |
+
model: Model identifier (e.g. "gpt-4o-mini" or "llama-3.3-70b-versatile").
|
| 72 |
+
client: OpenAI SDK client instance (or None in fallback mode).
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
def __init__(
|
| 76 |
+
self,
|
| 77 |
+
provider: str,
|
| 78 |
+
api_key: str,
|
| 79 |
+
model: str,
|
| 80 |
+
):
|
| 81 |
+
"""
|
| 82 |
+
Initialise the reasoner for a specific provider.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
provider: "openai" or "groq".
|
| 86 |
+
api_key: API key for the chosen provider.
|
| 87 |
+
model: Model identifier string.
|
| 88 |
+
"""
|
| 89 |
+
self.provider = provider
|
| 90 |
+
self.model = model
|
| 91 |
+
self.client = None
|
| 92 |
+
|
| 93 |
+
if api_key:
|
| 94 |
+
self._init_client(provider, api_key)
|
| 95 |
+
else:
|
| 96 |
+
logger.warning(
|
| 97 |
+
"No API key provided for provider='%s' — "
|
| 98 |
+
"CommandReasoner will use fallback mode.",
|
| 99 |
+
provider,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def _init_client(self, provider: str, api_key: str) -> None:
|
| 103 |
+
"""Create an OpenAI-SDK client pointed at the chosen provider endpoint."""
|
| 104 |
+
try:
|
| 105 |
+
from openai import OpenAI
|
| 106 |
+
|
| 107 |
+
base_url = _PROVIDER_BASE_URLS.get(provider) # None → OpenAI default
|
| 108 |
+
kwargs: dict = {"api_key": api_key}
|
| 109 |
+
if base_url:
|
| 110 |
+
kwargs["base_url"] = base_url
|
| 111 |
+
|
| 112 |
+
self.client = OpenAI(**kwargs)
|
| 113 |
+
logger.info(
|
| 114 |
+
"LLM client initialised (provider=%s, model=%s).",
|
| 115 |
+
provider,
|
| 116 |
+
self.model,
|
| 117 |
+
)
|
| 118 |
+
except Exception as exc:
|
| 119 |
+
logger.error("Failed to initialise LLM client (%s): %s", provider, exc)
|
| 120 |
+
self.client = None
|
| 121 |
+
|
| 122 |
+
# ------------------------------------------------------------------
|
| 123 |
+
# Public API
|
| 124 |
+
# ------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
def interpret(
|
| 127 |
+
self,
|
| 128 |
+
command: str,
|
| 129 |
+
detected_objects: list[dict[str, Any]],
|
| 130 |
+
) -> dict[str, Any]:
|
| 131 |
+
"""
|
| 132 |
+
Interpret a user command in the context of detected objects.
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
command: Free-form user instruction.
|
| 136 |
+
detected_objects: List of detection dicts from the vision module.
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
Dict with keys ``action``, ``source``, ``destination``.
|
| 140 |
+
"""
|
| 141 |
+
if not command or not command.strip():
|
| 142 |
+
logger.warning("Empty command received.")
|
| 143 |
+
return self._error_result("No command provided.")
|
| 144 |
+
|
| 145 |
+
if self.client is None:
|
| 146 |
+
logger.info("Using fallback reasoning (no LLM client).")
|
| 147 |
+
return self._fallback_interpret(command, detected_objects)
|
| 148 |
+
|
| 149 |
+
return self._llm_interpret(command, detected_objects)
|
| 150 |
+
|
| 151 |
+
# ------------------------------------------------------------------
|
| 152 |
+
# LLM-based interpretation
|
| 153 |
+
# ------------------------------------------------------------------
|
| 154 |
+
|
| 155 |
+
def _llm_interpret(
|
| 156 |
+
self,
|
| 157 |
+
command: str,
|
| 158 |
+
detected_objects: list[dict[str, Any]],
|
| 159 |
+
) -> dict[str, Any]:
|
| 160 |
+
"""Call the LLM to interpret the command."""
|
| 161 |
+
objects_description = self._format_objects(detected_objects)
|
| 162 |
+
|
| 163 |
+
user_message = (
|
| 164 |
+
f"Detected objects in the scene:\n{objects_description}\n\n"
|
| 165 |
+
f"User command: \"{command}\"\n\n"
|
| 166 |
+
f"Return the structured JSON interpretation."
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
response = self.client.chat.completions.create(
|
| 171 |
+
model=self.model,
|
| 172 |
+
messages=[
|
| 173 |
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
| 174 |
+
{"role": "user", "content": user_message},
|
| 175 |
+
],
|
| 176 |
+
temperature=0.1,
|
| 177 |
+
max_tokens=256,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
raw = response.choices[0].message.content.strip()
|
| 181 |
+
logger.info("LLM raw response: %s", raw)
|
| 182 |
+
|
| 183 |
+
return self._parse_response(raw)
|
| 184 |
+
|
| 185 |
+
except Exception as exc:
|
| 186 |
+
logger.error("LLM call failed: %s", exc)
|
| 187 |
+
return self._fallback_interpret(command, detected_objects)
|
| 188 |
+
|
| 189 |
+
# ------------------------------------------------------------------
|
| 190 |
+
# Fallback (no LLM)
|
| 191 |
+
# ------------------------------------------------------------------
|
| 192 |
+
|
| 193 |
+
def _fallback_interpret(
|
| 194 |
+
self,
|
| 195 |
+
command: str,
|
| 196 |
+
detected_objects: list[dict[str, Any]],
|
| 197 |
+
) -> dict[str, Any]:
|
| 198 |
+
"""
|
| 199 |
+
Smart keyword-based fallback when no LLM is available.
|
| 200 |
+
|
| 201 |
+
Uses the ObjectMapper for synonym-aware matching between user
|
| 202 |
+
terms and detected YOLO labels.
|
| 203 |
+
"""
|
| 204 |
+
from src.utils.object_mapper import find_best_match
|
| 205 |
+
|
| 206 |
+
logger.info("Fallback interpretation for: '%s'", command)
|
| 207 |
+
|
| 208 |
+
# Try to determine action verb
|
| 209 |
+
action = "move"
|
| 210 |
+
for verb in ("pick", "place", "move", "inspect", "rotate", "remove", "find"):
|
| 211 |
+
if verb in command.lower():
|
| 212 |
+
action = verb
|
| 213 |
+
break
|
| 214 |
+
|
| 215 |
+
labels = [obj["label"] for obj in detected_objects]
|
| 216 |
+
cmd_lower = command.lower()
|
| 217 |
+
|
| 218 |
+
# Extract noun phrases from command by removing common verbs/prepositions
|
| 219 |
+
stop_words = {
|
| 220 |
+
"the", "a", "an", "and", "or", "to", "from", "in", "on", "at",
|
| 221 |
+
"into", "onto", "of", "with", "for", "it", "up", "back", "next",
|
| 222 |
+
"pick", "place", "move", "inspect", "rotate", "remove", "find",
|
| 223 |
+
"put", "take", "grab", "get", "bring", "carry", "push", "pull",
|
| 224 |
+
"near", "close", "closest", "nearest", "under", "over", "above",
|
| 225 |
+
"below", "beside", "behind",
|
| 226 |
+
}
|
| 227 |
+
words = cmd_lower.replace(",", " ").replace(".", " ").split()
|
| 228 |
+
|
| 229 |
+
# Build candidate noun phrases (single words + bigrams)
|
| 230 |
+
candidates: list[str] = []
|
| 231 |
+
for w in words:
|
| 232 |
+
if w not in stop_words and len(w) > 1:
|
| 233 |
+
candidates.append(w)
|
| 234 |
+
for i in range(len(words) - 1):
|
| 235 |
+
bigram = f"{words[i]} {words[i+1]}"
|
| 236 |
+
if words[i] not in stop_words or words[i+1] not in stop_words:
|
| 237 |
+
candidates.append(bigram)
|
| 238 |
+
|
| 239 |
+
# Match candidates against detected labels using ObjectMapper
|
| 240 |
+
source = None
|
| 241 |
+
destination = None
|
| 242 |
+
used_labels: set[str] = set()
|
| 243 |
+
|
| 244 |
+
for candidate in candidates:
|
| 245 |
+
match = find_best_match(candidate, labels)
|
| 246 |
+
if match and match not in used_labels:
|
| 247 |
+
if source is None:
|
| 248 |
+
source = match
|
| 249 |
+
used_labels.add(match)
|
| 250 |
+
elif destination is None:
|
| 251 |
+
destination = match
|
| 252 |
+
used_labels.add(match)
|
| 253 |
+
break
|
| 254 |
+
|
| 255 |
+
# Fallback: if no matches, use first two detected objects
|
| 256 |
+
if source is None and labels:
|
| 257 |
+
source = labels[0]
|
| 258 |
+
if destination is None and len(labels) > 1:
|
| 259 |
+
remaining = [l for l in labels if l != source]
|
| 260 |
+
if remaining:
|
| 261 |
+
destination = remaining[0]
|
| 262 |
+
|
| 263 |
+
return {
|
| 264 |
+
"action": action,
|
| 265 |
+
"source": source or "unknown",
|
| 266 |
+
"destination": destination,
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
# ------------------------------------------------------------------
|
| 270 |
+
# Helpers
|
| 271 |
+
# ------------------------------------------------------------------
|
| 272 |
+
|
| 273 |
+
@staticmethod
|
| 274 |
+
def _format_objects(detected_objects: list[dict[str, Any]]) -> str:
|
| 275 |
+
"""Format detected objects as a readable string for the LLM prompt."""
|
| 276 |
+
if not detected_objects:
|
| 277 |
+
return "No objects detected."
|
| 278 |
+
|
| 279 |
+
lines: list[str] = []
|
| 280 |
+
for i, obj in enumerate(detected_objects, 1):
|
| 281 |
+
bbox = obj.get("bbox", [])
|
| 282 |
+
conf = obj.get("confidence", 0)
|
| 283 |
+
lines.append(
|
| 284 |
+
f" {i}. {obj['label']} (confidence: {conf:.2f}, bbox: {bbox})"
|
| 285 |
+
)
|
| 286 |
+
return "\n".join(lines)
|
| 287 |
+
|
| 288 |
+
@staticmethod
|
| 289 |
+
def _parse_response(raw: str) -> dict[str, Any]:
|
| 290 |
+
"""
|
| 291 |
+
Parse the LLM response string into a dict.
|
| 292 |
+
|
| 293 |
+
Handles markdown code fences and extra whitespace.
|
| 294 |
+
"""
|
| 295 |
+
cleaned = raw.strip()
|
| 296 |
+
# Strip optional markdown code fences
|
| 297 |
+
if cleaned.startswith("```"):
|
| 298 |
+
lines = cleaned.split("\n")
|
| 299 |
+
lines = [ln for ln in lines if not ln.strip().startswith("```")]
|
| 300 |
+
cleaned = "\n".join(lines).strip()
|
| 301 |
+
|
| 302 |
+
try:
|
| 303 |
+
result = json.loads(cleaned)
|
| 304 |
+
except json.JSONDecodeError:
|
| 305 |
+
logger.warning("Failed to parse LLM JSON: %s", cleaned)
|
| 306 |
+
return {
|
| 307 |
+
"action": "unknown",
|
| 308 |
+
"source": "unknown",
|
| 309 |
+
"destination": None,
|
| 310 |
+
"error": "Failed to parse LLM response.",
|
| 311 |
+
"raw_response": raw,
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
# Ensure required keys
|
| 315 |
+
return {
|
| 316 |
+
"action": result.get("action", "unknown"),
|
| 317 |
+
"source": result.get("source", "unknown"),
|
| 318 |
+
"destination": result.get("destination"),
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
@staticmethod
|
| 322 |
+
def _error_result(message: str) -> dict[str, Any]:
|
| 323 |
+
"""Return a structured error result."""
|
| 324 |
+
return {
|
| 325 |
+
"action": "error",
|
| 326 |
+
"source": "unknown",
|
| 327 |
+
"destination": None,
|
| 328 |
+
"error": message,
|
| 329 |
+
}
|
src/pipeline/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Pipeline Module Package
|
src/pipeline/vla_pipeline.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pipeline Orchestrator — Wires all VLA modules together into a single
|
| 3 |
+
``run()`` call.
|
| 4 |
+
|
| 5 |
+
Supports two detection backends (set via ``DETECTION_MODEL`` env var):
|
| 6 |
+
- ``"yolo"`` — YOLOv8, fixed 80 COCO classes
|
| 7 |
+
- ``"grounding_dino"`` — Open-vocabulary, detects any text-prompted object
|
| 8 |
+
|
| 9 |
+
The ``VLAPipeline`` class initializes the selected detector, reasoner,
|
| 10 |
+
planner, and visualizer, then exposes one public method that drives the
|
| 11 |
+
full Vision → Language → Action flow.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
from src.utils.config import (
|
| 21 |
+
DETECTION_MODEL,
|
| 22 |
+
LLM_PROVIDER,
|
| 23 |
+
OPENAI_API_KEY,
|
| 24 |
+
OPENAI_MODEL,
|
| 25 |
+
GROQ_API_KEY,
|
| 26 |
+
GROQ_MODEL,
|
| 27 |
+
YOLO_CONFIDENCE,
|
| 28 |
+
YOLO_MODEL,
|
| 29 |
+
GROUNDING_DINO_MODEL,
|
| 30 |
+
GROUNDING_DINO_CONFIDENCE,
|
| 31 |
+
)
|
| 32 |
+
from src.language.reasoner import CommandReasoner
|
| 33 |
+
from src.planner.action_planner import ActionPlanner
|
| 34 |
+
from src.visualization.visualizer import ActionVisualizer
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class PipelineResult:
|
| 41 |
+
"""Container for all intermediate and final pipeline outputs."""
|
| 42 |
+
|
| 43 |
+
detections: list[dict[str, Any]] = field(default_factory=list)
|
| 44 |
+
interpretation: dict[str, Any] = field(default_factory=dict)
|
| 45 |
+
action_plan: dict[str, Any] = field(default_factory=dict)
|
| 46 |
+
detection_image: np.ndarray | None = None
|
| 47 |
+
action_image: np.ndarray | None = None
|
| 48 |
+
error: str | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class VLAPipeline:
|
| 52 |
+
"""
|
| 53 |
+
Orchestrates the full Vision Language Action pipeline.
|
| 54 |
+
|
| 55 |
+
The detector backend is chosen via the ``DETECTION_MODEL`` environment
|
| 56 |
+
variable:
|
| 57 |
+
|
| 58 |
+
- ``"yolo"`` → :class:`ObjectDetector` (COCO-80)
|
| 59 |
+
- ``"grounding_dino"`` → :class:`GroundingDINODetector` (open vocabulary)
|
| 60 |
+
|
| 61 |
+
Usage::
|
| 62 |
+
|
| 63 |
+
pipeline = VLAPipeline()
|
| 64 |
+
result = pipeline.run(image, "Move the bottle to the box")
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
def __init__(self) -> None:
|
| 68 |
+
"""Initialize all sub-modules."""
|
| 69 |
+
logger.info("Initializing VLA Pipeline...")
|
| 70 |
+
|
| 71 |
+
# Select detection backend
|
| 72 |
+
self.detection_mode = DETECTION_MODEL.lower()
|
| 73 |
+
self.detector: Any = None
|
| 74 |
+
self._grounding_dino = None # Only set when using Grounding DINO
|
| 75 |
+
|
| 76 |
+
if self.detection_mode == "grounding_dino":
|
| 77 |
+
self._init_grounding_dino()
|
| 78 |
+
else:
|
| 79 |
+
self._init_yolo()
|
| 80 |
+
|
| 81 |
+
# Select LLM provider
|
| 82 |
+
api_key = GROQ_API_KEY if LLM_PROVIDER == "groq" else OPENAI_API_KEY
|
| 83 |
+
model = GROQ_MODEL if LLM_PROVIDER == "groq" else OPENAI_MODEL
|
| 84 |
+
self.reasoner = CommandReasoner(
|
| 85 |
+
provider=LLM_PROVIDER,
|
| 86 |
+
api_key=api_key,
|
| 87 |
+
model=model,
|
| 88 |
+
)
|
| 89 |
+
self.planner = ActionPlanner()
|
| 90 |
+
self.visualizer = ActionVisualizer()
|
| 91 |
+
|
| 92 |
+
logger.info(
|
| 93 |
+
"VLA Pipeline initialized (detector=%s, llm_provider=%s, model=%s).",
|
| 94 |
+
self.detection_mode,
|
| 95 |
+
LLM_PROVIDER,
|
| 96 |
+
model,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
def _init_yolo(self) -> None:
|
| 100 |
+
"""Initialize the YOLO detector."""
|
| 101 |
+
from src.vision.detector import ObjectDetector
|
| 102 |
+
|
| 103 |
+
self.detector = ObjectDetector(
|
| 104 |
+
model_name=YOLO_MODEL,
|
| 105 |
+
confidence=YOLO_CONFIDENCE,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
def _init_grounding_dino(self) -> None:
|
| 109 |
+
"""Initialize the Grounding DINO detector."""
|
| 110 |
+
from src.vision.grounding_dino_detector import GroundingDINODetector
|
| 111 |
+
|
| 112 |
+
gd = GroundingDINODetector(
|
| 113 |
+
model_name=GROUNDING_DINO_MODEL,
|
| 114 |
+
confidence=GROUNDING_DINO_CONFIDENCE,
|
| 115 |
+
)
|
| 116 |
+
self.detector = gd
|
| 117 |
+
self._grounding_dino = gd
|
| 118 |
+
|
| 119 |
+
def run(self, image: np.ndarray, command: str) -> PipelineResult:
|
| 120 |
+
"""
|
| 121 |
+
Execute the full VLA pipeline.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
image: Input image as a NumPy array (H, W, C).
|
| 125 |
+
command: Natural-language user command.
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
``PipelineResult`` containing all intermediate outputs and
|
| 129 |
+
the final annotated images.
|
| 130 |
+
"""
|
| 131 |
+
result = PipelineResult()
|
| 132 |
+
|
| 133 |
+
# Validate inputs
|
| 134 |
+
if image is None or not isinstance(image, np.ndarray):
|
| 135 |
+
result.error = "Invalid image provided."
|
| 136 |
+
logger.error(result.error)
|
| 137 |
+
return result
|
| 138 |
+
|
| 139 |
+
if not command or not command.strip():
|
| 140 |
+
result.error = "No command provided."
|
| 141 |
+
logger.error(result.error)
|
| 142 |
+
return result
|
| 143 |
+
|
| 144 |
+
logger.info("=== VLA Pipeline Start ===")
|
| 145 |
+
logger.info("Command: '%s'", command)
|
| 146 |
+
logger.info("Detector: %s", self.detection_mode)
|
| 147 |
+
|
| 148 |
+
# ------------------------------------------------------------------
|
| 149 |
+
# Step 1: Vision — Detect objects
|
| 150 |
+
# ------------------------------------------------------------------
|
| 151 |
+
logger.info("[1/4] Running object detection...")
|
| 152 |
+
try:
|
| 153 |
+
if self._grounding_dino is not None:
|
| 154 |
+
# Build a text prompt from the user's command
|
| 155 |
+
from src.vision.grounding_dino_detector import GroundingDINODetector
|
| 156 |
+
|
| 157 |
+
text_prompt = GroundingDINODetector.build_prompt_from_command(command)
|
| 158 |
+
result.detections = self._grounding_dino.detect(image, text_prompt)
|
| 159 |
+
else:
|
| 160 |
+
result.detections = self.detector.detect(image)
|
| 161 |
+
|
| 162 |
+
logger.info("Detected %d objects.", len(result.detections))
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
result.error = f"Detection failed: {exc}"
|
| 165 |
+
logger.error(result.error)
|
| 166 |
+
return result
|
| 167 |
+
|
| 168 |
+
# Generate detection overlay image
|
| 169 |
+
result.detection_image = self.visualizer.draw_detections(
|
| 170 |
+
image, result.detections
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
if not result.detections:
|
| 174 |
+
result.error = "No objects detected in the image."
|
| 175 |
+
logger.warning(result.error)
|
| 176 |
+
return result
|
| 177 |
+
|
| 178 |
+
# ------------------------------------------------------------------
|
| 179 |
+
# Step 2: Language — Interpret command
|
| 180 |
+
# ------------------------------------------------------------------
|
| 181 |
+
logger.info("[2/4] Interpreting command...")
|
| 182 |
+
try:
|
| 183 |
+
result.interpretation = self.reasoner.interpret(
|
| 184 |
+
command, result.detections
|
| 185 |
+
)
|
| 186 |
+
logger.info("Interpretation: %s", result.interpretation)
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
result.error = f"Command interpretation failed: {exc}"
|
| 189 |
+
logger.error(result.error)
|
| 190 |
+
return result
|
| 191 |
+
|
| 192 |
+
# ------------------------------------------------------------------
|
| 193 |
+
# Step 3: Planning — Generate action plan
|
| 194 |
+
# ------------------------------------------------------------------
|
| 195 |
+
logger.info("[3/4] Generating action plan...")
|
| 196 |
+
try:
|
| 197 |
+
result.action_plan = self.planner.plan(
|
| 198 |
+
result.detections, result.interpretation
|
| 199 |
+
)
|
| 200 |
+
logger.info("Plan status: %s", result.action_plan.get("status"))
|
| 201 |
+
except Exception as exc:
|
| 202 |
+
result.error = f"Action planning failed: {exc}"
|
| 203 |
+
logger.error(result.error)
|
| 204 |
+
return result
|
| 205 |
+
|
| 206 |
+
# ------------------------------------------------------------------
|
| 207 |
+
# Step 4: Visualization — Render action overlay
|
| 208 |
+
# ------------------------------------------------------------------
|
| 209 |
+
logger.info("[4/4] Generating action visualization...")
|
| 210 |
+
try:
|
| 211 |
+
result.action_image = self.visualizer.visualize(
|
| 212 |
+
image, result.action_plan
|
| 213 |
+
)
|
| 214 |
+
except Exception as exc:
|
| 215 |
+
result.error = f"Visualization failed: {exc}"
|
| 216 |
+
logger.error(result.error)
|
| 217 |
+
return result
|
| 218 |
+
|
| 219 |
+
logger.info("=== VLA Pipeline Complete ===")
|
| 220 |
+
return result
|
src/planner/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Planner Module Package
|
src/planner/action_planner.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Action Planning Module — Generates structured action steps from
|
| 3 |
+
detected objects and the interpreted command.
|
| 4 |
+
|
| 5 |
+
Uses the ObjectMapper for fuzzy label matching between the command
|
| 6 |
+
interpretation and the actual YOLO detections.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from src.utils.object_mapper import find_best_match
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ActionPlanner:
|
| 18 |
+
"""
|
| 19 |
+
Generates a step-by-step action plan by matching the interpreted
|
| 20 |
+
command to detected objects in the scene.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def plan(
|
| 24 |
+
self,
|
| 25 |
+
detected_objects: list[dict[str, Any]],
|
| 26 |
+
command_interpretation: dict[str, Any],
|
| 27 |
+
) -> dict[str, Any]:
|
| 28 |
+
"""
|
| 29 |
+
Create an action plan from detections and the interpreted command.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
detected_objects: Objects detected by the vision module.
|
| 33 |
+
command_interpretation: Output from the language reasoner
|
| 34 |
+
(keys: ``action``, ``source``, ``destination``).
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
Dict containing:
|
| 38 |
+
- ``steps`` (list[str]): Human-readable action steps.
|
| 39 |
+
- ``source_object`` (dict | None): Matched source detection.
|
| 40 |
+
- ``dest_object`` (dict | None): Matched destination detection.
|
| 41 |
+
- ``source_bbox`` (list | None): Source bounding box.
|
| 42 |
+
- ``dest_bbox`` (list | None): Destination bounding box.
|
| 43 |
+
- ``action`` (str): The action verb.
|
| 44 |
+
- ``status`` (str): ``"success"`` or ``"partial"`` or ``"failed"``.
|
| 45 |
+
"""
|
| 46 |
+
if not detected_objects:
|
| 47 |
+
logger.warning("No detected objects provided to planner.")
|
| 48 |
+
return self._empty_plan("No objects detected in the scene.")
|
| 49 |
+
|
| 50 |
+
action = command_interpretation.get("action", "unknown")
|
| 51 |
+
source_query = command_interpretation.get("source", "")
|
| 52 |
+
dest_query = command_interpretation.get("destination")
|
| 53 |
+
|
| 54 |
+
detected_labels = [obj["label"] for obj in detected_objects]
|
| 55 |
+
|
| 56 |
+
# Match source object
|
| 57 |
+
source_obj = self._match_object(source_query, detected_labels, detected_objects)
|
| 58 |
+
# Match destination object (optional)
|
| 59 |
+
dest_obj = (
|
| 60 |
+
self._match_object(dest_query, detected_labels, detected_objects)
|
| 61 |
+
if dest_query
|
| 62 |
+
else None
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Build the step list
|
| 66 |
+
steps = self._build_steps(action, source_query, dest_query, source_obj, dest_obj)
|
| 67 |
+
|
| 68 |
+
# Determine status
|
| 69 |
+
status = self._determine_status(source_obj, dest_obj, dest_query)
|
| 70 |
+
|
| 71 |
+
plan = {
|
| 72 |
+
"action": action,
|
| 73 |
+
"steps": steps,
|
| 74 |
+
"source_object": source_obj,
|
| 75 |
+
"dest_object": dest_obj,
|
| 76 |
+
"source_bbox": source_obj["bbox"] if source_obj else None,
|
| 77 |
+
"dest_bbox": dest_obj["bbox"] if dest_obj else None,
|
| 78 |
+
"status": status,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
logger.info(
|
| 82 |
+
"Action plan generated: %d steps, status=%s", len(steps), status
|
| 83 |
+
)
|
| 84 |
+
return plan
|
| 85 |
+
|
| 86 |
+
# ------------------------------------------------------------------
|
| 87 |
+
# Internal helpers
|
| 88 |
+
# ------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
def _match_object(
|
| 92 |
+
query: str | None,
|
| 93 |
+
detected_labels: list[str],
|
| 94 |
+
detected_objects: list[dict[str, Any]],
|
| 95 |
+
) -> dict[str, Any] | None:
|
| 96 |
+
"""
|
| 97 |
+
Match a query string to a detected object using fuzzy matching.
|
| 98 |
+
|
| 99 |
+
Returns the full detection dict or None.
|
| 100 |
+
"""
|
| 101 |
+
if not query:
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
matched_label = find_best_match(query, detected_labels)
|
| 105 |
+
if matched_label is None:
|
| 106 |
+
logger.warning("Could not match '%s' to any detected object.", query)
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
# Return the first detection with this label
|
| 110 |
+
for obj in detected_objects:
|
| 111 |
+
if obj["label"] == matched_label:
|
| 112 |
+
return obj
|
| 113 |
+
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
def _build_steps(
|
| 118 |
+
action: str,
|
| 119 |
+
source_query: str,
|
| 120 |
+
dest_query: str | None,
|
| 121 |
+
source_obj: dict | None,
|
| 122 |
+
dest_obj: dict | None,
|
| 123 |
+
) -> list[str]:
|
| 124 |
+
"""Build human-readable action steps."""
|
| 125 |
+
steps: list[str] = []
|
| 126 |
+
|
| 127 |
+
source_label = source_obj["label"] if source_obj else source_query
|
| 128 |
+
dest_label = dest_obj["label"] if dest_obj else dest_query
|
| 129 |
+
|
| 130 |
+
# Step 1: Locate source
|
| 131 |
+
if source_obj:
|
| 132 |
+
bbox = source_obj["bbox"]
|
| 133 |
+
steps.append(
|
| 134 |
+
f"Step 1: Locate '{source_label}' at position "
|
| 135 |
+
f"[{bbox[0]}, {bbox[1]}, {bbox[2]}, {bbox[3]}]"
|
| 136 |
+
)
|
| 137 |
+
else:
|
| 138 |
+
steps.append(
|
| 139 |
+
f"Step 1: Locate '{source_label}' — ⚠ Not detected in scene"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# Step 2: Locate destination (if applicable)
|
| 143 |
+
if dest_query:
|
| 144 |
+
if dest_obj:
|
| 145 |
+
bbox = dest_obj["bbox"]
|
| 146 |
+
steps.append(
|
| 147 |
+
f"Step 2: Locate destination '{dest_label}' at position "
|
| 148 |
+
f"[{bbox[0]}, {bbox[1]}, {bbox[2]}, {bbox[3]}]"
|
| 149 |
+
)
|
| 150 |
+
else:
|
| 151 |
+
steps.append(
|
| 152 |
+
f"Step 2: Locate destination '{dest_label}' "
|
| 153 |
+
f"— ⚠ Not detected in scene"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Step 3: Execute action
|
| 157 |
+
action_desc = action.replace("_", " ").capitalize()
|
| 158 |
+
if dest_label:
|
| 159 |
+
steps.append(
|
| 160 |
+
f"Step {len(steps) + 1}: {action_desc} '{source_label}' "
|
| 161 |
+
f"→ '{dest_label}'"
|
| 162 |
+
)
|
| 163 |
+
else:
|
| 164 |
+
steps.append(
|
| 165 |
+
f"Step {len(steps) + 1}: {action_desc} '{source_label}'"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Step 4: Confirmation
|
| 169 |
+
steps.append(f"Step {len(steps) + 1}: Verify action completion")
|
| 170 |
+
|
| 171 |
+
return steps
|
| 172 |
+
|
| 173 |
+
@staticmethod
|
| 174 |
+
def _determine_status(
|
| 175 |
+
source_obj: dict | None,
|
| 176 |
+
dest_obj: dict | None,
|
| 177 |
+
dest_query: str | None,
|
| 178 |
+
) -> str:
|
| 179 |
+
"""Determine planning status based on matching results."""
|
| 180 |
+
if source_obj is None:
|
| 181 |
+
return "failed"
|
| 182 |
+
if dest_query and dest_obj is None:
|
| 183 |
+
return "partial"
|
| 184 |
+
return "success"
|
| 185 |
+
|
| 186 |
+
@staticmethod
|
| 187 |
+
def _empty_plan(reason: str) -> dict[str, Any]:
|
| 188 |
+
"""Return an empty plan with a failure reason."""
|
| 189 |
+
return {
|
| 190 |
+
"action": "none",
|
| 191 |
+
"steps": [f"Planning failed: {reason}"],
|
| 192 |
+
"source_object": None,
|
| 193 |
+
"dest_object": None,
|
| 194 |
+
"source_bbox": None,
|
| 195 |
+
"dest_bbox": None,
|
| 196 |
+
"status": "failed",
|
| 197 |
+
}
|
src/ui/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/ui — Gradio UI package for the VLA Demo System.
|
| 3 |
+
|
| 4 |
+
Exports
|
| 5 |
+
-------
|
| 6 |
+
create_ui : callable
|
| 7 |
+
Factory function that builds and returns the Gradio Blocks interface.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from src.ui.layout import create_ui
|
| 11 |
+
|
| 12 |
+
__all__ = ["create_ui"]
|
src/ui/components.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/ui/components.py — Reusable HTML components for the Codevally VLA UI.
|
| 3 |
+
|
| 4 |
+
Each constant is a self-contained HTML string ready to drop into a
|
| 5 |
+
``gr.HTML()`` call. Keeping HTML here avoids polluting layout.py with
|
| 6 |
+
long inline strings.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
HEADER_HTML: str = """
|
| 10 |
+
<div class="cv-header">
|
| 11 |
+
<div style="display:flex; align-items:center; gap:16px; margin-bottom:14px;
|
| 12 |
+
position:relative; z-index:1;">
|
| 13 |
+
<div style="width:44px; height:44px;
|
| 14 |
+
background:linear-gradient(135deg,#1A4BDB,#00D4FF);
|
| 15 |
+
border-radius:12px; display:flex; align-items:center;
|
| 16 |
+
justify-content:center; font-size:22px; flex-shrink:0;
|
| 17 |
+
box-shadow:0 4px 16px rgba(0,212,255,0.3);">🤖</div>
|
| 18 |
+
<div>
|
| 19 |
+
<div style="font-size:22px; font-weight:700; color:#fff;
|
| 20 |
+
letter-spacing:-0.01em; line-height:1.2;">
|
| 21 |
+
Vision Language Action
|
| 22 |
+
</div>
|
| 23 |
+
<div style="font-size:13px; color:rgba(255,255,255,0.5); margin-top:2px;">
|
| 24 |
+
Powered by
|
| 25 |
+
<a href="https://www.codevally.com" target="_blank"
|
| 26 |
+
style="color:#00D4FF; font-weight:600; text-decoration:none;">
|
| 27 |
+
Codevally
|
| 28 |
+
</a>
|
| 29 |
+
· Open Vocabulary Detection + LLM Reasoning
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
<div style="margin-left:auto; display:flex; flex-direction:column;
|
| 33 |
+
align-items:flex-end; gap:6px; position:relative; z-index:1;">
|
| 34 |
+
<a href="https://www.codevally.com" target="_blank"
|
| 35 |
+
style="background:rgba(0,212,255,0.12); border:1px solid rgba(0,212,255,0.35);
|
| 36 |
+
border-radius:8px; padding:7px 18px; color:#00D4FF; font-size:13px;
|
| 37 |
+
font-weight:600; text-decoration:none; letter-spacing:0.02em;
|
| 38 |
+
transition:background 0.2s;"
|
| 39 |
+
onmouseover="this.style.background='rgba(0,212,255,0.2)'"
|
| 40 |
+
onmouseout="this.style.background='rgba(0,212,255,0.12)'">
|
| 41 |
+
🌐 codevally.com
|
| 42 |
+
</a>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<p style="font-size:14px; color:rgba(255,255,255,0.6); max-width:760px;
|
| 47 |
+
line-height:1.7; margin:0 0 14px; position:relative; z-index:1;">
|
| 48 |
+
An enterprise-grade
|
| 49 |
+
<strong style="color:rgba(255,255,255,0.85);">
|
| 50 |
+
Vision Language Action (VLA) pipeline
|
| 51 |
+
</strong>
|
| 52 |
+
that understands any natural-language instruction, detects objects using
|
| 53 |
+
open vocabulary AI, generates structured action plans, and visualizes
|
| 54 |
+
robotic task execution — all in real time.
|
| 55 |
+
</p>
|
| 56 |
+
|
| 57 |
+
<div style="display:flex; flex-wrap:wrap; gap:6px;
|
| 58 |
+
position:relative; z-index:1; margin-bottom:0;">
|
| 59 |
+
<span class="cv-badge">🔍 Grounding DINO</span>
|
| 60 |
+
<span class="cv-badge">🧠 GPT-4o</span>
|
| 61 |
+
<span class="cv-badge">🎯 Open Vocabulary</span>
|
| 62 |
+
<span class="cv-badge">⚡ Real-time Inference</span>
|
| 63 |
+
<span class="cv-badge">🤖 Action Planning</span>
|
| 64 |
+
<span class="cv-badge">🏭 Industrial AI</span>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div class="how-it-works">
|
| 68 |
+
<div class="hiw-step">
|
| 69 |
+
<span class="hiw-icon">📸</span>
|
| 70 |
+
<span class="hiw-label">Vision</span>
|
| 71 |
+
Open vocabulary object detection
|
| 72 |
+
</div>
|
| 73 |
+
<div class="hiw-step">
|
| 74 |
+
<span class="hiw-icon">🧠</span>
|
| 75 |
+
<span class="hiw-label">Language</span>
|
| 76 |
+
LLM command interpretation
|
| 77 |
+
</div>
|
| 78 |
+
<div class="hiw-step">
|
| 79 |
+
<span class="hiw-icon">📋</span>
|
| 80 |
+
<span class="hiw-label">Planning</span>
|
| 81 |
+
Structured action steps
|
| 82 |
+
</div>
|
| 83 |
+
<div class="hiw-step">
|
| 84 |
+
<span class="hiw-icon">🎬</span>
|
| 85 |
+
<span class="hiw-label">Action</span>
|
| 86 |
+
Annotated visualization
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
FOOTER_HTML: str = """
|
| 93 |
+
<div class="cv-footer">
|
| 94 |
+
Built by
|
| 95 |
+
<a href="https://www.codevally.com" target="_blank">Codevally</a>
|
| 96 |
+
· Vision Language Action AI Platform
|
| 97 |
+
· © 2026 Codevally Technologies
|
| 98 |
+
</div>
|
| 99 |
+
"""
|
src/ui/examples.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/ui/examples.py — Demo image loader for the Gradio Examples component.
|
| 3 |
+
|
| 4 |
+
Reads demo images from the configured assets directory and pairs each
|
| 5 |
+
image with a contextually appropriate natural-language command.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from src.utils.config import DEMO_IMAGES_DIR
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Map each demo image filename to a curated command
|
| 14 |
+
_IMAGE_COMMANDS: dict[str, str] = {
|
| 15 |
+
"assembly_line.jpg": "Pick up the defective board and place it in the red bin",
|
| 16 |
+
"construction_site.jpg": "Move the hard hat next to the gloves",
|
| 17 |
+
"desk_scene.jpg": "Pick up the bottle and place it in the box",
|
| 18 |
+
"garage_tools.jpg": "Place the wrench back into the toolbox",
|
| 19 |
+
"grocery_shelf.jpg": "Move the water bottle into the shopping basket",
|
| 20 |
+
"kitchen_counter.jpg": "Pick up the apple and place it in the bowl",
|
| 21 |
+
"lab_equipment.jpg": "Move the bottle next to the microscope",
|
| 22 |
+
"medical_tray.jpg": "Pick up the scissors and place them on the tray",
|
| 23 |
+
"office_supplies.jpg": "Move the phone next to the laptop",
|
| 24 |
+
"warehouse_boxes.jpg": "Move the closest box onto the hand truck",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_demo_examples() -> list[list]:
|
| 29 |
+
"""
|
| 30 |
+
Return a list of ``[image_path, command]`` pairs for Gradio Examples.
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Sorted list of ``[str, str]`` pairs, one per demo image found on disk.
|
| 34 |
+
Returns an empty list if the demo images directory does not exist.
|
| 35 |
+
"""
|
| 36 |
+
demo_dir = Path(DEMO_IMAGES_DIR)
|
| 37 |
+
if not demo_dir.exists():
|
| 38 |
+
return []
|
| 39 |
+
|
| 40 |
+
examples: list[list] = []
|
| 41 |
+
for img_path in sorted(demo_dir.glob("*.jpg")):
|
| 42 |
+
cmd = _IMAGE_COMMANDS.get(img_path.name, "Inspect the closest object")
|
| 43 |
+
examples.append([str(img_path), cmd])
|
| 44 |
+
|
| 45 |
+
return examples
|
src/ui/handlers.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/ui/handlers.py — Gradio event-handler functions for the VLA pipeline.
|
| 3 |
+
|
| 4 |
+
These functions are wired to Gradio UI events (button clicks, etc.) and
|
| 5 |
+
are intentionally kept free of Gradio component construction so they can
|
| 6 |
+
be unit-tested independently.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from src.pipeline.vla_pipeline import VLAPipeline
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Shared pipeline instance (initialised once at import time)
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
_pipeline: VLAPipeline | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_pipeline() -> VLAPipeline:
|
| 26 |
+
"""Return the shared VLAPipeline, initialising it on first call."""
|
| 27 |
+
global _pipeline
|
| 28 |
+
if _pipeline is None:
|
| 29 |
+
logger.info("Initializing VLA Pipeline...")
|
| 30 |
+
_pipeline = VLAPipeline()
|
| 31 |
+
logger.info("VLA Pipeline ready.")
|
| 32 |
+
return _pipeline
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Main Gradio handler
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
def process_pipeline(
|
| 39 |
+
image: np.ndarray | None,
|
| 40 |
+
command: str,
|
| 41 |
+
) -> tuple:
|
| 42 |
+
"""
|
| 43 |
+
Run the VLA pipeline and return outputs for Gradio components.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
image: Input image as a NumPy array (from gr.Image).
|
| 47 |
+
command: Natural-language instruction from the user.
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
5-tuple:
|
| 51 |
+
- detection_image (np.ndarray)
|
| 52 |
+
- detections_json (str)
|
| 53 |
+
- action_plan_md (str)
|
| 54 |
+
- action_image (np.ndarray)
|
| 55 |
+
- status_message (str)
|
| 56 |
+
"""
|
| 57 |
+
empty = _blank_image("Upload an image and\nenter a command to begin")
|
| 58 |
+
|
| 59 |
+
if image is None:
|
| 60 |
+
return empty, "[]", "*Please upload an image.*", empty, "⚠️ No image provided."
|
| 61 |
+
|
| 62 |
+
if not command or not command.strip():
|
| 63 |
+
return empty, "[]", "*Please enter a command.*", empty, "⚠️ No command entered."
|
| 64 |
+
|
| 65 |
+
logger.info("Processing: '%s'", command)
|
| 66 |
+
result = get_pipeline().run(image, command)
|
| 67 |
+
|
| 68 |
+
detections_json = json.dumps(result.detections, indent=2) if result.detections else "[]"
|
| 69 |
+
action_plan_md = _format_action_plan(result.action_plan, result.interpretation)
|
| 70 |
+
|
| 71 |
+
# Status bar
|
| 72 |
+
if result.error:
|
| 73 |
+
status = f"⚠️ {result.error}"
|
| 74 |
+
else:
|
| 75 |
+
plan_status = result.action_plan.get("status", "unknown")
|
| 76 |
+
emoji = {"success": "✅", "partial": "⚠️", "failed": "❌"}.get(plan_status, "❓")
|
| 77 |
+
n = len(result.detections)
|
| 78 |
+
steps = len(result.action_plan.get("steps", []))
|
| 79 |
+
status = (
|
| 80 |
+
f"{emoji} Pipeline complete — Plan status: **{plan_status}**"
|
| 81 |
+
f" | {n} object{'s' if n != 1 else ''} detected"
|
| 82 |
+
f" | {steps} action step{'s' if steps != 1 else ''} generated"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
det_img = _ensure_rgb(result.detection_image, empty)
|
| 86 |
+
act_img = _ensure_rgb(result.action_image, empty)
|
| 87 |
+
return det_img, detections_json, action_plan_md, act_img, status
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# Helpers
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
def _format_action_plan(action_plan: dict, interpretation: dict) -> str:
|
| 94 |
+
"""Format the action plan dict as a readable Markdown string."""
|
| 95 |
+
if not action_plan:
|
| 96 |
+
return "*No action plan generated.*"
|
| 97 |
+
|
| 98 |
+
lines: list[str] = []
|
| 99 |
+
|
| 100 |
+
if interpretation:
|
| 101 |
+
lines += [
|
| 102 |
+
"### 🧠 Command Interpretation",
|
| 103 |
+
"| Field | Value |",
|
| 104 |
+
"|-------|-------|",
|
| 105 |
+
f"| **Action** | `{interpretation.get('action', 'N/A')}` |",
|
| 106 |
+
f"| **Source** | `{interpretation.get('source', 'N/A')}` |",
|
| 107 |
+
f"| **Destination** | `{interpretation.get('destination') or 'N/A'}` |",
|
| 108 |
+
"",
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
steps = action_plan.get("steps", [])
|
| 112 |
+
if steps:
|
| 113 |
+
lines.append("### 📋 Execution Steps")
|
| 114 |
+
for i, step in enumerate(steps, 1):
|
| 115 |
+
lines.append(f"**{i}.** {step}")
|
| 116 |
+
lines.append("")
|
| 117 |
+
|
| 118 |
+
status = action_plan.get("status", "unknown")
|
| 119 |
+
emoji = {"success": "✅", "partial": "⚠️", "failed": "❌"}.get(status, "❓")
|
| 120 |
+
lines += ["---", f"### {emoji} Status: `{status.upper()}`"]
|
| 121 |
+
|
| 122 |
+
return "\n".join(lines)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _ensure_rgb(image: np.ndarray | None, fallback: np.ndarray) -> np.ndarray:
|
| 126 |
+
"""Convert image to RGB format expected by Gradio."""
|
| 127 |
+
if image is None:
|
| 128 |
+
return fallback
|
| 129 |
+
if len(image.shape) == 2:
|
| 130 |
+
return cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
|
| 131 |
+
if image.shape[2] == 4:
|
| 132 |
+
return cv2.cvtColor(image, cv2.COLOR_BGRA2RGB)
|
| 133 |
+
return image
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _blank_image(text: str = "") -> np.ndarray:
|
| 137 |
+
"""Return a dark placeholder image with optional centred text."""
|
| 138 |
+
img = np.full((400, 600, 3), 18, dtype=np.uint8) # near-black
|
| 139 |
+
if text:
|
| 140 |
+
for i, line in enumerate(text.split("\n")):
|
| 141 |
+
y = 185 + i * 34
|
| 142 |
+
(w, _), _ = cv2.getTextSize(line, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 1)
|
| 143 |
+
x = max(0, (600 - w) // 2)
|
| 144 |
+
cv2.putText(img, line, (x, y), cv2.FONT_HERSHEY_SIMPLEX,
|
| 145 |
+
0.7, (100, 100, 100), 1, cv2.LINE_AA)
|
| 146 |
+
return img
|
src/ui/layout.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/ui/layout.py — Gradio Blocks layout definition for the VLA Demo.
|
| 3 |
+
|
| 4 |
+
This module owns *only* the component hierarchy and wiring.
|
| 5 |
+
Styling → src/ui/styles.py
|
| 6 |
+
HTML bits → src/ui/components.py
|
| 7 |
+
Handlers → src/ui/handlers.py
|
| 8 |
+
Examples → src/ui/examples.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import gradio as gr
|
| 12 |
+
|
| 13 |
+
from src.ui.styles import CUSTOM_CSS
|
| 14 |
+
from src.ui.components import HEADER_HTML, FOOTER_HTML
|
| 15 |
+
from src.ui.handlers import process_pipeline
|
| 16 |
+
from src.ui.examples import get_demo_examples
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def create_ui() -> gr.Blocks:
|
| 20 |
+
"""
|
| 21 |
+
Build and return the Gradio Blocks interface.
|
| 22 |
+
|
| 23 |
+
All sub-modules are imported locally so that this function can be
|
| 24 |
+
called in isolation for testing without triggering heavy model loads.
|
| 25 |
+
"""
|
| 26 |
+
demo_examples = get_demo_examples()
|
| 27 |
+
|
| 28 |
+
with gr.Blocks(
|
| 29 |
+
title="VLA — AI Vision Language Action Pipeline | Codevally",
|
| 30 |
+
theme=gr.themes.Base(
|
| 31 |
+
primary_hue="blue",
|
| 32 |
+
secondary_hue="cyan",
|
| 33 |
+
neutral_hue="slate",
|
| 34 |
+
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
|
| 35 |
+
),
|
| 36 |
+
css=CUSTOM_CSS,
|
| 37 |
+
) as demo:
|
| 38 |
+
|
| 39 |
+
# ── Header ────────────────────────────────────────────────────────
|
| 40 |
+
gr.HTML(HEADER_HTML)
|
| 41 |
+
|
| 42 |
+
# ── Main layout ───────────────────────────────────────────────────
|
| 43 |
+
with gr.Row(equal_height=False):
|
| 44 |
+
|
| 45 |
+
# ── Left: Inputs ───────────────────────────────────────────
|
| 46 |
+
with gr.Column(scale=1, min_width=300):
|
| 47 |
+
gr.Markdown("<div class='cv-section-label'>📷 Input</div>")
|
| 48 |
+
image_input = gr.Image(
|
| 49 |
+
label="Upload Scene Image",
|
| 50 |
+
type="numpy",
|
| 51 |
+
height=280,
|
| 52 |
+
elem_id="image_upload",
|
| 53 |
+
)
|
| 54 |
+
gr.Markdown(
|
| 55 |
+
"<div class='cv-section-label' style='margin-top:12px;'>"
|
| 56 |
+
"💬 Natural Language Command</div>"
|
| 57 |
+
)
|
| 58 |
+
command_input = gr.Textbox(
|
| 59 |
+
label="Command",
|
| 60 |
+
placeholder=(
|
| 61 |
+
'e.g. "Pick up the defective board and place it in the red bin"'
|
| 62 |
+
),
|
| 63 |
+
lines=3,
|
| 64 |
+
show_label=False,
|
| 65 |
+
)
|
| 66 |
+
run_btn = gr.Button(
|
| 67 |
+
"🚀 Run AI Pipeline",
|
| 68 |
+
variant="primary",
|
| 69 |
+
size="lg",
|
| 70 |
+
)
|
| 71 |
+
gr.Markdown(
|
| 72 |
+
"<div class='cv-section-label' style='margin-top:14px;'>"
|
| 73 |
+
"📊 Pipeline Status</div>"
|
| 74 |
+
)
|
| 75 |
+
status_output = gr.Markdown(
|
| 76 |
+
value="*Ready — upload a scene image and enter an instruction.*",
|
| 77 |
+
elem_classes=["status-box"],
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# ── Right: Outputs ─────────────────────────────────────────
|
| 81 |
+
with gr.Column(scale=2):
|
| 82 |
+
with gr.Tabs(elem_id="result_tabs"):
|
| 83 |
+
|
| 84 |
+
with gr.TabItem("🔍 Detection Results"):
|
| 85 |
+
gr.Markdown(
|
| 86 |
+
"_Grounding DINO detects scene objects based on your "
|
| 87 |
+
"command text — no fixed class limitations._"
|
| 88 |
+
)
|
| 89 |
+
with gr.Row():
|
| 90 |
+
detection_image = gr.Image(
|
| 91 |
+
label="Detected Objects",
|
| 92 |
+
type="numpy",
|
| 93 |
+
height=320,
|
| 94 |
+
)
|
| 95 |
+
detections_json = gr.Code(
|
| 96 |
+
label="Detection Data (JSON)",
|
| 97 |
+
language="json",
|
| 98 |
+
lines=14,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
with gr.TabItem("🎯 Action Plan & Visualization"):
|
| 102 |
+
gr.Markdown(
|
| 103 |
+
"_The LLM interprets your command and maps it to detected "
|
| 104 |
+
"objects, generating step-by-step robotic action instructions._"
|
| 105 |
+
)
|
| 106 |
+
with gr.Row():
|
| 107 |
+
action_image = gr.Image(
|
| 108 |
+
label="Action Visualization",
|
| 109 |
+
type="numpy",
|
| 110 |
+
height=320,
|
| 111 |
+
)
|
| 112 |
+
action_plan_md = gr.Markdown(
|
| 113 |
+
value=(
|
| 114 |
+
"*Run the pipeline to see the "
|
| 115 |
+
"AI-generated action plan.*"
|
| 116 |
+
),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# ── Demo examples ──────────────────────────────────────────────────
|
| 120 |
+
if demo_examples:
|
| 121 |
+
gr.Markdown("---")
|
| 122 |
+
gr.Markdown(
|
| 123 |
+
"### 📂 Try a Demo Scene\n"
|
| 124 |
+
"_Click any example below to load a scene image with a pre-filled "
|
| 125 |
+
"command. These showcase Codevally's VLA pipeline across diverse "
|
| 126 |
+
"industrial and everyday environments._"
|
| 127 |
+
)
|
| 128 |
+
gr.Examples(
|
| 129 |
+
examples=demo_examples,
|
| 130 |
+
inputs=[image_input, command_input],
|
| 131 |
+
label="Demo Scenes",
|
| 132 |
+
examples_per_page=5,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# ── Footer ────────────────────────────────────────────────────────
|
| 136 |
+
gr.HTML(FOOTER_HTML)
|
| 137 |
+
|
| 138 |
+
# ── Event wiring ──────────────────────────────────────────────────
|
| 139 |
+
run_btn.click(
|
| 140 |
+
fn=process_pipeline,
|
| 141 |
+
inputs=[image_input, command_input],
|
| 142 |
+
outputs=[
|
| 143 |
+
detection_image,
|
| 144 |
+
detections_json,
|
| 145 |
+
action_plan_md,
|
| 146 |
+
action_image,
|
| 147 |
+
status_output,
|
| 148 |
+
],
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
return demo
|
src/ui/styles.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/ui/styles.py — Custom CSS for the Codevally VLA Demo UI.
|
| 3 |
+
|
| 4 |
+
All visual styling is centralised here so that layout.py stays
|
| 5 |
+
focused purely on component structure.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
CUSTOM_CSS: str = """
|
| 9 |
+
/* ── Google Font ── */
|
| 10 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 11 |
+
|
| 12 |
+
/* ── Root & Body ── */
|
| 13 |
+
:root {
|
| 14 |
+
--cv-blue: #1A4BDB;
|
| 15 |
+
--cv-dark: #0D1B3E;
|
| 16 |
+
--cv-accent: #00D4FF;
|
| 17 |
+
--cv-glass: rgba(255,255,255,0.06);
|
| 18 |
+
--cv-border: rgba(255,255,255,0.1);
|
| 19 |
+
--cv-success: #22C55E;
|
| 20 |
+
--cv-warn: #F59E0B;
|
| 21 |
+
--cv-error: #EF4444;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
body, .gradio-container {
|
| 25 |
+
font-family: 'Inter', system-ui, sans-serif !important;
|
| 26 |
+
background: linear-gradient(135deg, #0a0f1e 0%, #0d1b3e 50%, #0a0f1e 100%) !important;
|
| 27 |
+
min-height: 100vh;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* ── Header Banner ── */
|
| 31 |
+
.cv-header {
|
| 32 |
+
background: linear-gradient(135deg, #0D1B3E 0%, #1A2E5E 60%, #0D1B3E 100%);
|
| 33 |
+
border: 1px solid var(--cv-border);
|
| 34 |
+
border-radius: 16px;
|
| 35 |
+
padding: 28px 36px;
|
| 36 |
+
margin-bottom: 20px;
|
| 37 |
+
position: relative;
|
| 38 |
+
overflow: hidden;
|
| 39 |
+
}
|
| 40 |
+
.cv-header::before {
|
| 41 |
+
content: '';
|
| 42 |
+
position: absolute;
|
| 43 |
+
top: -60px; right: -60px;
|
| 44 |
+
width: 220px; height: 220px;
|
| 45 |
+
background: radial-gradient(circle, rgba(0,212,255,0.12) 0%, transparent 70%);
|
| 46 |
+
border-radius: 50%;
|
| 47 |
+
}
|
| 48 |
+
.cv-header::after {
|
| 49 |
+
content: '';
|
| 50 |
+
position: absolute;
|
| 51 |
+
bottom: -40px; left: 20%;
|
| 52 |
+
width: 140px; height: 140px;
|
| 53 |
+
background: radial-gradient(circle, rgba(26,75,219,0.15) 0%, transparent 70%);
|
| 54 |
+
border-radius: 50%;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* ── Badges ── */
|
| 58 |
+
.cv-badge {
|
| 59 |
+
display: inline-block;
|
| 60 |
+
background: rgba(0,212,255,0.12);
|
| 61 |
+
color: var(--cv-accent);
|
| 62 |
+
border: 1px solid rgba(0,212,255,0.3);
|
| 63 |
+
border-radius: 20px;
|
| 64 |
+
padding: 3px 12px;
|
| 65 |
+
font-size: 12px;
|
| 66 |
+
font-weight: 500;
|
| 67 |
+
margin: 2px 4px 2px 0;
|
| 68 |
+
letter-spacing: 0.03em;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* ── Cards ── */
|
| 72 |
+
.cv-card {
|
| 73 |
+
background: var(--cv-glass);
|
| 74 |
+
backdrop-filter: blur(12px);
|
| 75 |
+
border: 1px solid var(--cv-border);
|
| 76 |
+
border-radius: 16px;
|
| 77 |
+
padding: 20px;
|
| 78 |
+
transition: border-color 0.2s;
|
| 79 |
+
}
|
| 80 |
+
.cv-card:hover { border-color: rgba(0,212,255,0.25); }
|
| 81 |
+
|
| 82 |
+
/* ── Section labels ── */
|
| 83 |
+
.cv-section-label {
|
| 84 |
+
font-size: 11px;
|
| 85 |
+
font-weight: 600;
|
| 86 |
+
letter-spacing: 0.08em;
|
| 87 |
+
text-transform: uppercase;
|
| 88 |
+
color: var(--cv-accent);
|
| 89 |
+
margin-bottom: 8px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* ── Run button ── */
|
| 93 |
+
button.primary {
|
| 94 |
+
background: linear-gradient(135deg, var(--cv-blue) 0%, #2563EB 100%) !important;
|
| 95 |
+
border: none !important;
|
| 96 |
+
border-radius: 10px !important;
|
| 97 |
+
font-weight: 600 !important;
|
| 98 |
+
font-size: 15px !important;
|
| 99 |
+
letter-spacing: 0.02em !important;
|
| 100 |
+
box-shadow: 0 4px 20px rgba(26,75,219,0.4) !important;
|
| 101 |
+
transition: transform 0.15s, box-shadow 0.15s !important;
|
| 102 |
+
}
|
| 103 |
+
button.primary:hover {
|
| 104 |
+
transform: translateY(-1px) !important;
|
| 105 |
+
box-shadow: 0 6px 28px rgba(26,75,219,0.55) !important;
|
| 106 |
+
}
|
| 107 |
+
button.primary:active { transform: translateY(0) !important; }
|
| 108 |
+
|
| 109 |
+
/* ── Tabs ── */
|
| 110 |
+
.tabs .tab-nav { border-bottom: 1px solid var(--cv-border) !important; }
|
| 111 |
+
.tabs .tab-nav button {
|
| 112 |
+
font-weight: 500 !important;
|
| 113 |
+
color: rgba(255,255,255,0.55) !important;
|
| 114 |
+
border-radius: 8px 8px 0 0 !important;
|
| 115 |
+
}
|
| 116 |
+
.tabs .tab-nav button.selected {
|
| 117 |
+
color: var(--cv-accent) !important;
|
| 118 |
+
border-bottom: 2px solid var(--cv-accent) !important;
|
| 119 |
+
background: rgba(0,212,255,0.07) !important;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* ── Inputs ── */
|
| 123 |
+
input, textarea, .block { border-radius: 10px !important; }
|
| 124 |
+
|
| 125 |
+
/* ── Footer ── */
|
| 126 |
+
.cv-footer {
|
| 127 |
+
text-align: center;
|
| 128 |
+
padding: 16px 0 6px;
|
| 129 |
+
font-size: 13px;
|
| 130 |
+
color: rgba(255,255,255,0.35);
|
| 131 |
+
}
|
| 132 |
+
.cv-footer a {
|
| 133 |
+
color: var(--cv-accent);
|
| 134 |
+
text-decoration: none;
|
| 135 |
+
font-weight: 500;
|
| 136 |
+
}
|
| 137 |
+
.cv-footer a:hover { text-decoration: underline; }
|
| 138 |
+
|
| 139 |
+
/* ── Status box ── */
|
| 140 |
+
.status-box {
|
| 141 |
+
background: rgba(255,255,255,0.04);
|
| 142 |
+
border-radius: 10px;
|
| 143 |
+
border: 1px solid var(--cv-border);
|
| 144 |
+
padding: 10px 14px;
|
| 145 |
+
font-size: 14px;
|
| 146 |
+
min-height: 40px;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/* ── How it works strip ── */
|
| 150 |
+
.how-it-works {
|
| 151 |
+
display: flex;
|
| 152 |
+
gap: 0;
|
| 153 |
+
margin: 18px 0 8px;
|
| 154 |
+
border-radius: 12px;
|
| 155 |
+
overflow: hidden;
|
| 156 |
+
border: 1px solid var(--cv-border);
|
| 157 |
+
}
|
| 158 |
+
.hiw-step {
|
| 159 |
+
flex: 1;
|
| 160 |
+
padding: 14px 12px;
|
| 161 |
+
text-align: center;
|
| 162 |
+
background: rgba(255,255,255,0.03);
|
| 163 |
+
border-right: 1px solid var(--cv-border);
|
| 164 |
+
font-size: 12px;
|
| 165 |
+
color: rgba(255,255,255,0.7);
|
| 166 |
+
}
|
| 167 |
+
.hiw-step:last-child { border-right: none; }
|
| 168 |
+
.hiw-step .hiw-icon { font-size: 20px; display: block; margin-bottom: 4px; }
|
| 169 |
+
.hiw-step .hiw-label {
|
| 170 |
+
font-weight: 600; font-size: 11px; letter-spacing: 0.05em;
|
| 171 |
+
color: var(--cv-accent); display: block; margin-bottom: 2px;
|
| 172 |
+
text-transform: uppercase;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* ── Hide default Gradio footer bar ── */
|
| 176 |
+
footer { display: none !important; }
|
| 177 |
+
.gradio-container ~ footer { display: none !important; }
|
| 178 |
+
|
| 179 |
+
/* ─�� Scrollable Detection JSON box ── */
|
| 180 |
+
#result_tabs .code-wrap,
|
| 181 |
+
#result_tabs .cm-scroller,
|
| 182 |
+
#result_tabs .codemirror-wrapper {
|
| 183 |
+
max-height: 380px !important;
|
| 184 |
+
overflow-y: auto !important;
|
| 185 |
+
scrollbar-width: thin;
|
| 186 |
+
scrollbar-color: rgba(0,212,255,0.4) rgba(255,255,255,0.05);
|
| 187 |
+
}
|
| 188 |
+
#result_tabs .code-wrap::-webkit-scrollbar { width: 6px; }
|
| 189 |
+
#result_tabs .code-wrap::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); border-radius: 3px; }
|
| 190 |
+
#result_tabs .code-wrap::-webkit-scrollbar-thumb { background: rgba(0,212,255,0.4); border-radius: 3px; }
|
| 191 |
+
#result_tabs .code-wrap::-webkit-scrollbar-thumb:hover { background: rgba(0,212,255,0.65); }
|
| 192 |
+
"""
|
src/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utilities Package
|
src/utils/config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration module for the VLA Demo System.
|
| 3 |
+
|
| 4 |
+
Loads settings from environment variables (with .env file support).
|
| 5 |
+
All configuration is centralized here to avoid hardcoded values.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
# Load .env from project root
|
| 15 |
+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 16 |
+
load_dotenv(_PROJECT_ROOT / ".env")
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# LLM Provider Configuration
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# LLM_PROVIDER selects the language model backend: "openai" or "groq"
|
| 24 |
+
LLM_PROVIDER: str = os.getenv("LLM_PROVIDER", "openai") # "openai" or "groq"
|
| 25 |
+
|
| 26 |
+
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
|
| 27 |
+
OPENAI_MODEL: str = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 28 |
+
|
| 29 |
+
GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "")
|
| 30 |
+
GROQ_MODEL: str = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Vision / Detection Configuration
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
DETECTION_MODEL: str = os.getenv("DETECTION_MODEL", "grounding_dino") # "yolo" or "grounding_dino"
|
| 36 |
+
YOLO_MODEL: str = os.getenv("YOLO_MODEL", "yolov8n.pt")
|
| 37 |
+
YOLO_CONFIDENCE: float = float(os.getenv("YOLO_CONFIDENCE", "0.25"))
|
| 38 |
+
GROUNDING_DINO_MODEL: str = os.getenv(
|
| 39 |
+
"GROUNDING_DINO_MODEL", "IDEA-Research/grounding-dino-tiny"
|
| 40 |
+
)
|
| 41 |
+
GROUNDING_DINO_CONFIDENCE: float = float(os.getenv("GROUNDING_DINO_CONFIDENCE", "0.3"))
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Paths
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
ASSETS_DIR: Path = _PROJECT_ROOT / "assets"
|
| 47 |
+
DEMO_IMAGES_DIR: Path = ASSETS_DIR / "demo_images"
|
| 48 |
+
MODELS_DIR: Path = _PROJECT_ROOT / "models"
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Logging Setup
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def setup_logging() -> None:
|
| 57 |
+
"""Configure root logger with a consistent format."""
|
| 58 |
+
logging.basicConfig(
|
| 59 |
+
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
| 60 |
+
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
| 61 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def validate_config() -> list[str]:
|
| 66 |
+
"""
|
| 67 |
+
Validate critical configuration values.
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
List of warning messages for missing/invalid config values.
|
| 71 |
+
"""
|
| 72 |
+
warnings: list[str] = []
|
| 73 |
+
|
| 74 |
+
valid_providers = ("openai", "groq")
|
| 75 |
+
if LLM_PROVIDER not in valid_providers:
|
| 76 |
+
warnings.append(
|
| 77 |
+
f"Unknown LLM_PROVIDER='{LLM_PROVIDER}'. "
|
| 78 |
+
f"Valid values: {valid_providers}. Defaulting to 'openai'."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
if LLM_PROVIDER == "openai" and not OPENAI_API_KEY:
|
| 82 |
+
warnings.append(
|
| 83 |
+
"LLM_PROVIDER='openai' but OPENAI_API_KEY is not set. "
|
| 84 |
+
"Language reasoning will use fallback mode."
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
if LLM_PROVIDER == "groq" and not GROQ_API_KEY:
|
| 88 |
+
warnings.append(
|
| 89 |
+
"LLM_PROVIDER='groq' but GROQ_API_KEY is not set. "
|
| 90 |
+
"Language reasoning will use fallback mode."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
valid_detectors = ("yolo", "grounding_dino")
|
| 94 |
+
if DETECTION_MODEL not in valid_detectors:
|
| 95 |
+
warnings.append(
|
| 96 |
+
f"Unknown DETECTION_MODEL='{DETECTION_MODEL}'. "
|
| 97 |
+
f"Valid values: {valid_detectors}. Defaulting to 'grounding_dino'."
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return warnings
|
src/utils/object_mapper.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Object Mapper — Synonym normalization for bridging YOLO labels
|
| 3 |
+
and natural-language user commands.
|
| 4 |
+
|
| 5 |
+
YOLO may detect "cup" while the user says "mug". This module provides
|
| 6 |
+
a mapping layer and fuzzy-match helper to reconcile the two vocabularies.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from difflib import SequenceMatcher
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Synonym Map: user-language → canonical YOLO label
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
SYNONYM_MAP: dict[str, str] = {
|
| 18 |
+
# ---------------------------------------------------------------
|
| 19 |
+
# COCO has 80 classes. This map bridges common user vocabulary
|
| 20 |
+
# to the actual YOLO/COCO label strings.
|
| 21 |
+
# ---------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
# person
|
| 24 |
+
"human": "person",
|
| 25 |
+
"worker": "person",
|
| 26 |
+
"operator": "person",
|
| 27 |
+
"man": "person",
|
| 28 |
+
"woman": "person",
|
| 29 |
+
"people": "person",
|
| 30 |
+
"employee": "person",
|
| 31 |
+
"technician": "person",
|
| 32 |
+
|
| 33 |
+
# bicycle
|
| 34 |
+
"bike": "bicycle",
|
| 35 |
+
"cycle": "bicycle",
|
| 36 |
+
|
| 37 |
+
# car
|
| 38 |
+
"automobile": "car",
|
| 39 |
+
"vehicle": "car",
|
| 40 |
+
"sedan": "car",
|
| 41 |
+
|
| 42 |
+
# motorcycle
|
| 43 |
+
"motorbike": "motorcycle",
|
| 44 |
+
"scooter": "motorcycle",
|
| 45 |
+
|
| 46 |
+
# truck
|
| 47 |
+
"van": "truck",
|
| 48 |
+
"lorry": "truck",
|
| 49 |
+
"pickup": "truck",
|
| 50 |
+
|
| 51 |
+
# boat
|
| 52 |
+
"ship": "boat",
|
| 53 |
+
"vessel": "boat",
|
| 54 |
+
"canoe": "boat",
|
| 55 |
+
|
| 56 |
+
# traffic light
|
| 57 |
+
"signal": "traffic light",
|
| 58 |
+
"stoplight": "traffic light",
|
| 59 |
+
|
| 60 |
+
# backpack
|
| 61 |
+
"bag": "backpack",
|
| 62 |
+
"rucksack": "backpack",
|
| 63 |
+
"knapsack": "backpack",
|
| 64 |
+
|
| 65 |
+
# umbrella
|
| 66 |
+
"parasol": "umbrella",
|
| 67 |
+
|
| 68 |
+
# handbag
|
| 69 |
+
"purse": "handbag",
|
| 70 |
+
"clutch": "handbag",
|
| 71 |
+
|
| 72 |
+
# suitcase (toolboxes, cases look like suitcases to YOLO)
|
| 73 |
+
"briefcase": "suitcase",
|
| 74 |
+
"toolbox": "suitcase",
|
| 75 |
+
"case": "suitcase",
|
| 76 |
+
"luggage": "suitcase",
|
| 77 |
+
"tool box": "suitcase",
|
| 78 |
+
|
| 79 |
+
# bottle
|
| 80 |
+
"water bottle": "bottle",
|
| 81 |
+
"flask": "bottle",
|
| 82 |
+
"jug": "bottle",
|
| 83 |
+
"canteen": "bottle",
|
| 84 |
+
"container": "bottle",
|
| 85 |
+
|
| 86 |
+
# wine glass
|
| 87 |
+
"goblet": "wine glass",
|
| 88 |
+
"chalice": "wine glass",
|
| 89 |
+
"glass": "wine glass",
|
| 90 |
+
|
| 91 |
+
# cup
|
| 92 |
+
"mug": "cup",
|
| 93 |
+
"teacup": "cup",
|
| 94 |
+
"coffee cup": "cup",
|
| 95 |
+
"tumbler": "cup",
|
| 96 |
+
|
| 97 |
+
# fork
|
| 98 |
+
"prong": "fork",
|
| 99 |
+
|
| 100 |
+
# knife (wrenches, tools often detected as knife-like)
|
| 101 |
+
"blade": "knife",
|
| 102 |
+
"wrench": "knife",
|
| 103 |
+
"spanner": "knife",
|
| 104 |
+
"screwdriver": "knife",
|
| 105 |
+
"tool": "knife",
|
| 106 |
+
|
| 107 |
+
# spoon
|
| 108 |
+
"ladle": "spoon",
|
| 109 |
+
"scoop": "spoon",
|
| 110 |
+
|
| 111 |
+
# bowl
|
| 112 |
+
"tray": "bowl",
|
| 113 |
+
"dish": "bowl",
|
| 114 |
+
"plate": "bowl",
|
| 115 |
+
"bin": "bowl",
|
| 116 |
+
"basket": "bowl",
|
| 117 |
+
|
| 118 |
+
# banana / apple / orange (fruits)
|
| 119 |
+
"fruit": "apple",
|
| 120 |
+
|
| 121 |
+
# sandwich
|
| 122 |
+
"sub": "sandwich",
|
| 123 |
+
"burger": "sandwich",
|
| 124 |
+
"wrap": "sandwich",
|
| 125 |
+
|
| 126 |
+
# pizza
|
| 127 |
+
"pie": "pizza",
|
| 128 |
+
|
| 129 |
+
# cake
|
| 130 |
+
"pastry": "cake",
|
| 131 |
+
"dessert": "cake",
|
| 132 |
+
|
| 133 |
+
# chair
|
| 134 |
+
"seat": "chair",
|
| 135 |
+
"stool": "chair",
|
| 136 |
+
"office chair": "chair",
|
| 137 |
+
|
| 138 |
+
# couch
|
| 139 |
+
"sofa": "couch",
|
| 140 |
+
"loveseat": "couch",
|
| 141 |
+
"settee": "couch",
|
| 142 |
+
|
| 143 |
+
# bed
|
| 144 |
+
"mattress": "bed",
|
| 145 |
+
"cot": "bed",
|
| 146 |
+
|
| 147 |
+
# dining table
|
| 148 |
+
"desk": "dining table",
|
| 149 |
+
"table": "dining table",
|
| 150 |
+
"workbench": "dining table",
|
| 151 |
+
"bench": "dining table",
|
| 152 |
+
"counter": "dining table",
|
| 153 |
+
|
| 154 |
+
# tv
|
| 155 |
+
"monitor": "tv",
|
| 156 |
+
"screen": "tv",
|
| 157 |
+
"display": "tv",
|
| 158 |
+
"television": "tv",
|
| 159 |
+
|
| 160 |
+
# laptop
|
| 161 |
+
"notebook": "laptop",
|
| 162 |
+
"computer": "laptop",
|
| 163 |
+
"macbook": "laptop",
|
| 164 |
+
|
| 165 |
+
# mouse
|
| 166 |
+
"trackpad": "mouse",
|
| 167 |
+
|
| 168 |
+
# remote
|
| 169 |
+
"controller": "remote",
|
| 170 |
+
"remote control": "remote",
|
| 171 |
+
|
| 172 |
+
# keyboard
|
| 173 |
+
"keypad": "keyboard",
|
| 174 |
+
|
| 175 |
+
# cell phone
|
| 176 |
+
"mobile": "cell phone",
|
| 177 |
+
"phone": "cell phone",
|
| 178 |
+
"smartphone": "cell phone",
|
| 179 |
+
"iphone": "cell phone",
|
| 180 |
+
"handset": "cell phone",
|
| 181 |
+
"scanner": "cell phone",
|
| 182 |
+
|
| 183 |
+
# microwave
|
| 184 |
+
"microwave oven": "microwave",
|
| 185 |
+
|
| 186 |
+
# oven
|
| 187 |
+
"stove": "oven",
|
| 188 |
+
"furnace": "oven",
|
| 189 |
+
|
| 190 |
+
# refrigerator
|
| 191 |
+
"fridge": "refrigerator",
|
| 192 |
+
"cooler": "refrigerator",
|
| 193 |
+
"freezer": "refrigerator",
|
| 194 |
+
|
| 195 |
+
# book
|
| 196 |
+
"notebook paper": "book",
|
| 197 |
+
"manual": "book",
|
| 198 |
+
"textbook": "book",
|
| 199 |
+
"binder": "book",
|
| 200 |
+
|
| 201 |
+
# clock
|
| 202 |
+
"watch": "clock",
|
| 203 |
+
"timer": "clock",
|
| 204 |
+
|
| 205 |
+
# vase
|
| 206 |
+
"pot": "vase",
|
| 207 |
+
"planter": "vase",
|
| 208 |
+
"jar": "vase",
|
| 209 |
+
|
| 210 |
+
# scissors
|
| 211 |
+
"cutter": "scissors",
|
| 212 |
+
"shears": "scissors",
|
| 213 |
+
"clipper": "scissors",
|
| 214 |
+
"pliers": "scissors",
|
| 215 |
+
"wire cutter": "scissors",
|
| 216 |
+
|
| 217 |
+
# teddy bear
|
| 218 |
+
"stuffed animal": "teddy bear",
|
| 219 |
+
"plush": "teddy bear",
|
| 220 |
+
"toy": "teddy bear",
|
| 221 |
+
|
| 222 |
+
# potted plant
|
| 223 |
+
"plant": "potted plant",
|
| 224 |
+
"houseplant": "potted plant",
|
| 225 |
+
"flower": "potted plant",
|
| 226 |
+
|
| 227 |
+
# -----------------------------------------------------------
|
| 228 |
+
# Domain-specific aliases for industrial / demo contexts
|
| 229 |
+
# -----------------------------------------------------------
|
| 230 |
+
"damaged part": "knife",
|
| 231 |
+
"defective part": "knife",
|
| 232 |
+
"component": "knife",
|
| 233 |
+
"metal part": "knife",
|
| 234 |
+
"part": "knife",
|
| 235 |
+
"piece": "knife",
|
| 236 |
+
|
| 237 |
+
"red bin": "bowl",
|
| 238 |
+
"blue bin": "bowl",
|
| 239 |
+
"sorting bin": "bowl",
|
| 240 |
+
"reject bin": "bowl",
|
| 241 |
+
"accept bin": "bowl",
|
| 242 |
+
|
| 243 |
+
"circuit board": "keyboard",
|
| 244 |
+
"pcb": "keyboard",
|
| 245 |
+
"board": "keyboard",
|
| 246 |
+
|
| 247 |
+
"hard hat": "sports ball",
|
| 248 |
+
"helmet": "sports ball",
|
| 249 |
+
"safety hat": "sports ball",
|
| 250 |
+
|
| 251 |
+
"gloves": "backpack",
|
| 252 |
+
"safety gloves": "backpack",
|
| 253 |
+
|
| 254 |
+
"pallet": "suitcase",
|
| 255 |
+
"crate": "suitcase",
|
| 256 |
+
"carton": "suitcase",
|
| 257 |
+
"box": "suitcase",
|
| 258 |
+
|
| 259 |
+
"hand truck": "bicycle",
|
| 260 |
+
"dolly": "bicycle",
|
| 261 |
+
"cart": "bicycle",
|
| 262 |
+
|
| 263 |
+
"forklift": "truck",
|
| 264 |
+
|
| 265 |
+
"clipboard": "book",
|
| 266 |
+
"blueprint": "book",
|
| 267 |
+
|
| 268 |
+
"stethoscope": "tie",
|
| 269 |
+
"syringe": "toothbrush",
|
| 270 |
+
"safety goggles": "wine glass",
|
| 271 |
+
"goggles": "wine glass",
|
| 272 |
+
|
| 273 |
+
"oil can": "bottle",
|
| 274 |
+
"spray can": "bottle",
|
| 275 |
+
"battery": "suitcase",
|
| 276 |
+
"car battery": "suitcase",
|
| 277 |
+
|
| 278 |
+
"multimeter": "cell phone",
|
| 279 |
+
"meter": "cell phone",
|
| 280 |
+
"soldering iron": "knife",
|
| 281 |
+
"magnifying lamp": "clock",
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def normalize_label(label: str) -> str:
|
| 287 |
+
"""
|
| 288 |
+
Normalize a label using the synonym map.
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
label: Raw label string (from user command or YOLO).
|
| 292 |
+
|
| 293 |
+
Returns:
|
| 294 |
+
The canonical label if a synonym exists, otherwise the
|
| 295 |
+
original label lowercased and stripped.
|
| 296 |
+
"""
|
| 297 |
+
cleaned = label.strip().lower()
|
| 298 |
+
return SYNONYM_MAP.get(cleaned, cleaned)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _similarity(a: str, b: str) -> float:
|
| 302 |
+
"""Return a 0-1 similarity score between two strings."""
|
| 303 |
+
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def find_best_match(
|
| 307 |
+
query: str,
|
| 308 |
+
detected_labels: list[str],
|
| 309 |
+
threshold: float = 0.4,
|
| 310 |
+
) -> str | None:
|
| 311 |
+
"""
|
| 312 |
+
Find the best matching detected label for a user query term.
|
| 313 |
+
|
| 314 |
+
Strategy:
|
| 315 |
+
1. Exact match after normalization.
|
| 316 |
+
2. Substring containment check.
|
| 317 |
+
3. Fuzzy similarity above *threshold*.
|
| 318 |
+
|
| 319 |
+
Args:
|
| 320 |
+
query: The term from the user command (e.g. "mug").
|
| 321 |
+
detected_labels: Labels returned by the vision detector.
|
| 322 |
+
threshold: Minimum similarity score for fuzzy matching.
|
| 323 |
+
|
| 324 |
+
Returns:
|
| 325 |
+
The best matching label, or ``None`` if no match is found.
|
| 326 |
+
"""
|
| 327 |
+
normalized_query = normalize_label(query)
|
| 328 |
+
normalized_detected = {normalize_label(lbl): lbl for lbl in detected_labels}
|
| 329 |
+
|
| 330 |
+
# 1. Exact match on normalized labels
|
| 331 |
+
if normalized_query in normalized_detected:
|
| 332 |
+
logger.info(
|
| 333 |
+
"Exact match: '%s' → '%s'",
|
| 334 |
+
query,
|
| 335 |
+
normalized_detected[normalized_query],
|
| 336 |
+
)
|
| 337 |
+
return normalized_detected[normalized_query]
|
| 338 |
+
|
| 339 |
+
# 2. Substring containment
|
| 340 |
+
for norm_lbl, orig_lbl in normalized_detected.items():
|
| 341 |
+
if normalized_query in norm_lbl or norm_lbl in normalized_query:
|
| 342 |
+
logger.info(
|
| 343 |
+
"Substring match: '%s' → '%s'", query, orig_lbl
|
| 344 |
+
)
|
| 345 |
+
return orig_lbl
|
| 346 |
+
|
| 347 |
+
# 3. Fuzzy matching
|
| 348 |
+
best_score = 0.0
|
| 349 |
+
best_label: str | None = None
|
| 350 |
+
for norm_lbl, orig_lbl in normalized_detected.items():
|
| 351 |
+
score = _similarity(normalized_query, norm_lbl)
|
| 352 |
+
if score > best_score:
|
| 353 |
+
best_score = score
|
| 354 |
+
best_label = orig_lbl
|
| 355 |
+
|
| 356 |
+
if best_label and best_score >= threshold:
|
| 357 |
+
logger.info(
|
| 358 |
+
"Fuzzy match: '%s' → '%s' (score=%.2f)",
|
| 359 |
+
query,
|
| 360 |
+
best_label,
|
| 361 |
+
best_score,
|
| 362 |
+
)
|
| 363 |
+
return best_label
|
| 364 |
+
|
| 365 |
+
logger.warning("No match found for query '%s'", query)
|
| 366 |
+
return None
|
src/vision/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Vision Module Package
|
src/vision/detector.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vision Module — Object detection using YOLOv8.
|
| 3 |
+
|
| 4 |
+
Wraps the Ultralytics YOLO model to detect objects in images and return
|
| 5 |
+
structured results. The model is loaded **once** in the constructor and
|
| 6 |
+
reused for all subsequent detections.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ObjectDetector:
|
| 18 |
+
"""
|
| 19 |
+
Detects objects in images using a pretrained YOLOv8 model.
|
| 20 |
+
|
| 21 |
+
Attributes:
|
| 22 |
+
model: The loaded YOLO model instance (cached after first load).
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, model_name: str = "yolov8n.pt", confidence: float = 0.25):
|
| 26 |
+
"""
|
| 27 |
+
Initialize the detector and load the YOLO model.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
model_name: Name or path of the YOLO model weights.
|
| 31 |
+
confidence: Minimum confidence threshold for detections.
|
| 32 |
+
"""
|
| 33 |
+
self.model_name = model_name
|
| 34 |
+
self.confidence = confidence
|
| 35 |
+
self.model = None
|
| 36 |
+
|
| 37 |
+
self._load_model()
|
| 38 |
+
|
| 39 |
+
def _load_model(self) -> None:
|
| 40 |
+
"""Load the YOLO model. Called once during initialization."""
|
| 41 |
+
try:
|
| 42 |
+
from ultralytics import YOLO
|
| 43 |
+
|
| 44 |
+
logger.info("Loading YOLO model: %s", self.model_name)
|
| 45 |
+
self.model = YOLO(self.model_name)
|
| 46 |
+
logger.info("YOLO model loaded successfully.")
|
| 47 |
+
except Exception as exc:
|
| 48 |
+
logger.error("Failed to load YOLO model '%s': %s", self.model_name, exc)
|
| 49 |
+
raise RuntimeError(
|
| 50 |
+
f"Could not load YOLO model '{self.model_name}'. "
|
| 51 |
+
f"Ensure ultralytics is installed and the model file is accessible."
|
| 52 |
+
) from exc
|
| 53 |
+
|
| 54 |
+
def detect(self, image: np.ndarray) -> list[dict[str, Any]]:
|
| 55 |
+
"""
|
| 56 |
+
Run object detection on an image.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
image: Input image as a NumPy array (H, W, C) in BGR or RGB format.
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
List of detected objects, each containing:
|
| 63 |
+
- ``label`` (str): Class name.
|
| 64 |
+
- ``confidence`` (float): Detection confidence (0-1).
|
| 65 |
+
- ``bbox`` (list[int]): Bounding box as [x1, y1, x2, y2].
|
| 66 |
+
|
| 67 |
+
Raises:
|
| 68 |
+
ValueError: If the input image is invalid.
|
| 69 |
+
RuntimeError: If the model is not loaded.
|
| 70 |
+
"""
|
| 71 |
+
if self.model is None:
|
| 72 |
+
raise RuntimeError("YOLO model is not loaded.")
|
| 73 |
+
|
| 74 |
+
if image is None or not isinstance(image, np.ndarray):
|
| 75 |
+
raise ValueError("Invalid image: expected a NumPy ndarray.")
|
| 76 |
+
|
| 77 |
+
if image.ndim < 2:
|
| 78 |
+
raise ValueError(
|
| 79 |
+
f"Invalid image dimensions: expected 2D or 3D array, "
|
| 80 |
+
f"got {image.ndim}D."
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
logger.info(
|
| 84 |
+
"Running detection on image of shape %s with confidence=%.2f",
|
| 85 |
+
image.shape,
|
| 86 |
+
self.confidence,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
results = self.model(image, conf=self.confidence, verbose=False)
|
| 91 |
+
except Exception as exc:
|
| 92 |
+
logger.error("YOLO inference failed: %s", exc)
|
| 93 |
+
return []
|
| 94 |
+
|
| 95 |
+
detections = self._parse_results(results)
|
| 96 |
+
logger.info("Detected %d objects.", len(detections))
|
| 97 |
+
return detections
|
| 98 |
+
|
| 99 |
+
def _parse_results(self, results: Any) -> list[dict[str, Any]]:
|
| 100 |
+
"""
|
| 101 |
+
Parse YOLO results into a structured list of detections.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
results: Raw YOLO results object.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Structured list of detection dictionaries.
|
| 108 |
+
"""
|
| 109 |
+
detections: list[dict[str, Any]] = []
|
| 110 |
+
|
| 111 |
+
for result in results:
|
| 112 |
+
boxes = result.boxes
|
| 113 |
+
if boxes is None:
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
for i in range(len(boxes)):
|
| 117 |
+
try:
|
| 118 |
+
bbox = boxes.xyxy[i].cpu().numpy().tolist()
|
| 119 |
+
confidence = float(boxes.conf[i].cpu().numpy())
|
| 120 |
+
class_id = int(boxes.cls[i].cpu().numpy())
|
| 121 |
+
label = result.names.get(class_id, f"class_{class_id}")
|
| 122 |
+
|
| 123 |
+
detections.append(
|
| 124 |
+
{
|
| 125 |
+
"label": label,
|
| 126 |
+
"confidence": round(confidence, 4),
|
| 127 |
+
"bbox": [int(coord) for coord in bbox],
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
except (IndexError, KeyError, AttributeError) as exc:
|
| 131 |
+
logger.warning("Failed to parse detection %d: %s", i, exc)
|
| 132 |
+
continue
|
| 133 |
+
|
| 134 |
+
return detections
|
src/vision/grounding_dino_detector.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Grounding DINO Detector — Open-vocabulary object detection using
|
| 3 |
+
text prompts via HuggingFace Transformers.
|
| 4 |
+
|
| 5 |
+
Unlike YOLO (fixed 80 COCO classes), Grounding DINO can detect *any*
|
| 6 |
+
object described in natural language. This makes it ideal for demos
|
| 7 |
+
where users reference domain-specific objects like "defective board",
|
| 8 |
+
"red bin", or "toolbox".
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class GroundingDINODetector:
|
| 20 |
+
"""
|
| 21 |
+
Detects objects in images using text-prompted Grounding DINO.
|
| 22 |
+
|
| 23 |
+
The model is loaded once in the constructor and reused. Detection
|
| 24 |
+
requires a text prompt describing what objects to look for.
|
| 25 |
+
|
| 26 |
+
Attributes:
|
| 27 |
+
model: The loaded Grounding DINO model.
|
| 28 |
+
processor: The image/text processor.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
model_name: str = "IDEA-Research/grounding-dino-tiny",
|
| 34 |
+
confidence: float = 0.25,
|
| 35 |
+
):
|
| 36 |
+
"""
|
| 37 |
+
Initialize the Grounding DINO detector.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
model_name: HuggingFace model identifier.
|
| 41 |
+
confidence: Minimum confidence (box_threshold) for detections.
|
| 42 |
+
"""
|
| 43 |
+
self.model_name = model_name
|
| 44 |
+
self.confidence = confidence
|
| 45 |
+
self.model = None
|
| 46 |
+
self.processor = None
|
| 47 |
+
|
| 48 |
+
self._load_model()
|
| 49 |
+
|
| 50 |
+
def _load_model(self) -> None:
|
| 51 |
+
"""Load the Grounding DINO model and processor."""
|
| 52 |
+
try:
|
| 53 |
+
import torch
|
| 54 |
+
from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection
|
| 55 |
+
|
| 56 |
+
logger.info("Loading Grounding DINO model: %s", self.model_name)
|
| 57 |
+
|
| 58 |
+
self.processor = AutoProcessor.from_pretrained(self.model_name)
|
| 59 |
+
self.model = AutoModelForZeroShotObjectDetection.from_pretrained(
|
| 60 |
+
self.model_name,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Use GPU if available, otherwise CPU
|
| 64 |
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 65 |
+
self.model = self.model.to(self.device)
|
| 66 |
+
self.model.eval()
|
| 67 |
+
|
| 68 |
+
logger.info(
|
| 69 |
+
"Grounding DINO loaded successfully (device=%s).", self.device
|
| 70 |
+
)
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
logger.error(
|
| 73 |
+
"Failed to load Grounding DINO '%s': %s", self.model_name, exc
|
| 74 |
+
)
|
| 75 |
+
raise RuntimeError(
|
| 76 |
+
f"Could not load Grounding DINO model '{self.model_name}'. "
|
| 77 |
+
f"Ensure transformers and torch are installed."
|
| 78 |
+
) from exc
|
| 79 |
+
|
| 80 |
+
def detect(
|
| 81 |
+
self,
|
| 82 |
+
image: np.ndarray,
|
| 83 |
+
text_prompt: str | None = None,
|
| 84 |
+
) -> list[dict[str, Any]]:
|
| 85 |
+
"""
|
| 86 |
+
Run open-vocabulary detection on an image.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
image: Input image as a NumPy array (H, W, C) in RGB format.
|
| 90 |
+
text_prompt: Dot-separated list of object descriptions to detect.
|
| 91 |
+
Example: ``"red bin. defective board. toolbox."``
|
| 92 |
+
If None, uses a broad default prompt.
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
List of detected objects with label, confidence, and bbox.
|
| 96 |
+
|
| 97 |
+
Raises:
|
| 98 |
+
ValueError: If the input image is invalid.
|
| 99 |
+
RuntimeError: If the model is not loaded.
|
| 100 |
+
"""
|
| 101 |
+
if self.model is None or self.processor is None:
|
| 102 |
+
raise RuntimeError("Grounding DINO model is not loaded.")
|
| 103 |
+
|
| 104 |
+
if image is None or not isinstance(image, np.ndarray):
|
| 105 |
+
raise ValueError("Invalid image: expected a NumPy ndarray.")
|
| 106 |
+
|
| 107 |
+
if image.ndim < 2:
|
| 108 |
+
raise ValueError(
|
| 109 |
+
f"Invalid image dimensions: expected 2D or 3D array, "
|
| 110 |
+
f"got {image.ndim}D."
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
if not text_prompt:
|
| 114 |
+
text_prompt = "object."
|
| 115 |
+
|
| 116 |
+
# Ensure prompt ends with a period (required by Grounding DINO)
|
| 117 |
+
if not text_prompt.strip().endswith("."):
|
| 118 |
+
text_prompt = text_prompt.strip() + "."
|
| 119 |
+
|
| 120 |
+
logger.info(
|
| 121 |
+
"Running Grounding DINO detection: prompt='%s', confidence=%.2f",
|
| 122 |
+
text_prompt,
|
| 123 |
+
self.confidence,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
import torch
|
| 128 |
+
from PIL import Image as PILImage
|
| 129 |
+
|
| 130 |
+
# Convert numpy to PIL Image
|
| 131 |
+
pil_image = PILImage.fromarray(image)
|
| 132 |
+
|
| 133 |
+
# Process inputs
|
| 134 |
+
inputs = self.processor(
|
| 135 |
+
images=pil_image,
|
| 136 |
+
text=text_prompt,
|
| 137 |
+
return_tensors="pt",
|
| 138 |
+
).to(self.device)
|
| 139 |
+
|
| 140 |
+
# Run inference
|
| 141 |
+
with torch.no_grad():
|
| 142 |
+
outputs = self.model(**inputs)
|
| 143 |
+
|
| 144 |
+
# Post-process results (transformers >=5.0 API)
|
| 145 |
+
results = self.processor.post_process_grounded_object_detection(
|
| 146 |
+
outputs,
|
| 147 |
+
threshold=self.confidence,
|
| 148 |
+
text_threshold=self.confidence,
|
| 149 |
+
target_sizes=[pil_image.size[::-1]], # (height, width)
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
detections = self._parse_results(results)
|
| 153 |
+
detections = self._apply_nms(detections)
|
| 154 |
+
logger.info("Grounding DINO detected %d objects (after NMS).", len(detections))
|
| 155 |
+
return detections
|
| 156 |
+
|
| 157 |
+
except Exception as exc:
|
| 158 |
+
logger.error("Grounding DINO inference failed: %s", exc)
|
| 159 |
+
return []
|
| 160 |
+
|
| 161 |
+
def _parse_results(
|
| 162 |
+
self, results: list[dict[str, Any]]
|
| 163 |
+
) -> list[dict[str, Any]]:
|
| 164 |
+
"""Parse Grounding DINO results into structured detections."""
|
| 165 |
+
detections: list[dict[str, Any]] = []
|
| 166 |
+
|
| 167 |
+
if not results:
|
| 168 |
+
return detections
|
| 169 |
+
|
| 170 |
+
result = results[0] # Single image
|
| 171 |
+
boxes = result.get("boxes", [])
|
| 172 |
+
scores = result.get("scores", [])
|
| 173 |
+
# transformers >=5.0 returns text_labels (list[str]) directly
|
| 174 |
+
labels = result.get("text_labels") or result.get("labels", [])
|
| 175 |
+
|
| 176 |
+
for i in range(len(boxes)):
|
| 177 |
+
try:
|
| 178 |
+
bbox = boxes[i].cpu().numpy().tolist()
|
| 179 |
+
confidence = float(scores[i].cpu().numpy())
|
| 180 |
+
raw_label = labels[i] if i < len(labels) else "object"
|
| 181 |
+
label = str(raw_label).strip()
|
| 182 |
+
|
| 183 |
+
detections.append(
|
| 184 |
+
{
|
| 185 |
+
"label": label,
|
| 186 |
+
"confidence": float(round(confidence, 4)),
|
| 187 |
+
"bbox": [int(coord) for coord in bbox],
|
| 188 |
+
}
|
| 189 |
+
)
|
| 190 |
+
except (IndexError, AttributeError) as exc:
|
| 191 |
+
logger.warning("Failed to parse detection %d: %s", i, exc)
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
return detections
|
| 195 |
+
|
| 196 |
+
@staticmethod
|
| 197 |
+
def _iou(box_a: list[int], box_b: list[int]) -> float:
|
| 198 |
+
"""
|
| 199 |
+
Compute Intersection-over-Union (IoU) between two bounding boxes.
|
| 200 |
+
|
| 201 |
+
Boxes are in [x1, y1, x2, y2] format.
|
| 202 |
+
"""
|
| 203 |
+
xa1, ya1, xa2, ya2 = box_a
|
| 204 |
+
xb1, yb1, xb2, yb2 = box_b
|
| 205 |
+
|
| 206 |
+
inter_x1 = max(xa1, xb1)
|
| 207 |
+
inter_y1 = max(ya1, yb1)
|
| 208 |
+
inter_x2 = min(xa2, xb2)
|
| 209 |
+
inter_y2 = min(ya2, yb2)
|
| 210 |
+
|
| 211 |
+
inter_w = max(0, inter_x2 - inter_x1)
|
| 212 |
+
inter_h = max(0, inter_y2 - inter_y1)
|
| 213 |
+
inter_area = inter_w * inter_h
|
| 214 |
+
|
| 215 |
+
area_a = max(0, xa2 - xa1) * max(0, ya2 - ya1)
|
| 216 |
+
area_b = max(0, xb2 - xb1) * max(0, yb2 - yb1)
|
| 217 |
+
union_area = area_a + area_b - inter_area
|
| 218 |
+
|
| 219 |
+
return inter_area / union_area if union_area > 0 else 0.0
|
| 220 |
+
|
| 221 |
+
def _apply_nms(
|
| 222 |
+
self,
|
| 223 |
+
detections: list[dict[str, Any]],
|
| 224 |
+
iou_threshold: float = 0.5,
|
| 225 |
+
) -> list[dict[str, Any]]:
|
| 226 |
+
"""
|
| 227 |
+
Apply Non-Max Suppression to remove redundant detections.
|
| 228 |
+
|
| 229 |
+
When two boxes overlap significantly (IoU >= iou_threshold), keep the
|
| 230 |
+
one with the longer/more-specific label (e.g. "red bin" beats "red")
|
| 231 |
+
or the higher confidence if labels have the same length.
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
detections: Raw list of detection dicts.
|
| 235 |
+
iou_threshold: IoU overlap threshold above which one box is suppressed.
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
Filtered list with duplicates removed.
|
| 239 |
+
"""
|
| 240 |
+
if len(detections) <= 1:
|
| 241 |
+
return detections
|
| 242 |
+
|
| 243 |
+
# Sort by confidence descending
|
| 244 |
+
detections = sorted(detections, key=lambda d: d["confidence"], reverse=True)
|
| 245 |
+
kept: list[dict[str, Any]] = []
|
| 246 |
+
|
| 247 |
+
for det in detections:
|
| 248 |
+
suppress = False
|
| 249 |
+
for kept_det in kept:
|
| 250 |
+
iou = self._iou(det["bbox"], kept_det["bbox"])
|
| 251 |
+
if iou >= iou_threshold:
|
| 252 |
+
# Prefer more specific (longer) label; mark current as suppressed
|
| 253 |
+
if len(det["label"]) > len(kept_det["label"]):
|
| 254 |
+
# Current is more specific — replace the kept one
|
| 255 |
+
kept.remove(kept_det)
|
| 256 |
+
break # will be added below
|
| 257 |
+
else:
|
| 258 |
+
suppress = True
|
| 259 |
+
break
|
| 260 |
+
|
| 261 |
+
if not suppress:
|
| 262 |
+
kept.append(det)
|
| 263 |
+
|
| 264 |
+
if len(kept) < len(detections):
|
| 265 |
+
logger.info(
|
| 266 |
+
"NMS removed %d redundant detections.", len(detections) - len(kept)
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
return kept
|
| 270 |
+
|
| 271 |
+
@staticmethod
|
| 272 |
+
def build_prompt_from_command(command: str) -> str:
|
| 273 |
+
"""
|
| 274 |
+
Extract object-like noun phrases from a user command and build
|
| 275 |
+
a Grounding DINO text prompt.
|
| 276 |
+
|
| 277 |
+
Condition adjectives (defective, damaged, broken, etc.) are stripped
|
| 278 |
+
because they describe *state*, not visual appearance, and confuse
|
| 279 |
+
Grounding DINO. The underlying object noun is kept.
|
| 280 |
+
|
| 281 |
+
Args:
|
| 282 |
+
command: User's natural-language command.
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
A dot-separated prompt string for Grounding DINO.
|
| 286 |
+
|
| 287 |
+
Examples:
|
| 288 |
+
>>> build_prompt_from_command("Move the red bin to the table")
|
| 289 |
+
"red bin. table."
|
| 290 |
+
>>> build_prompt_from_command("Pick up the defective board")
|
| 291 |
+
"board."
|
| 292 |
+
"""
|
| 293 |
+
import re
|
| 294 |
+
cmd_lower = command.lower().strip()
|
| 295 |
+
|
| 296 |
+
# ------------------------------------------------------------------
|
| 297 |
+
# Step 1: Strip condition/state adjectives — they describe condition
|
| 298 |
+
# not visual appearance so Grounding DINO can't detect them.
|
| 299 |
+
# ------------------------------------------------------------------
|
| 300 |
+
CONDITION_ADJECTIVES = {
|
| 301 |
+
"defective", "damaged", "broken", "faulty", "worn", "old",
|
| 302 |
+
"cracked", "scratched", "dented", "bent", "burnt", "corroded",
|
| 303 |
+
"rusted", "leaking", "contaminated", "expired", "rejected",
|
| 304 |
+
"malfunctioning", "misaligned", "missing", "incorrect",
|
| 305 |
+
"closest", "nearest", "specific", "certain", "particular",
|
| 306 |
+
}
|
| 307 |
+
words = cmd_lower.split()
|
| 308 |
+
cleaned_words = [w for w in words if w not in CONDITION_ADJECTIVES]
|
| 309 |
+
cmd_lower = " ".join(cleaned_words)
|
| 310 |
+
|
| 311 |
+
# ------------------------------------------------------------------
|
| 312 |
+
# Step 2: Remove action verbs, articles, and prepositions
|
| 313 |
+
# ------------------------------------------------------------------
|
| 314 |
+
stop_words = {
|
| 315 |
+
"the", "a", "an", "and", "or", "to", "from", "in", "on", "at",
|
| 316 |
+
"into", "onto", "of", "with", "for", "it", "up", "back", "next",
|
| 317 |
+
"pick", "place", "move", "inspect", "rotate", "remove", "find",
|
| 318 |
+
"put", "take", "grab", "get", "bring", "carry", "push", "pull",
|
| 319 |
+
"near", "close", "under", "over", "above", "below", "beside",
|
| 320 |
+
"behind", "please", "can", "you", "will", "should", "must",
|
| 321 |
+
"then", "also", "there", "here", "this", "that",
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
# ------------------------------------------------------------------
|
| 325 |
+
# Step 3: Split on locational prepositions to isolate noun chunks
|
| 326 |
+
# ------------------------------------------------------------------
|
| 327 |
+
chunks = re.split(
|
| 328 |
+
r'\b(?:to|from|into|onto|next to|near|beside|under|over|on top of'
|
| 329 |
+
r'|and\s+(?:place|put|move|bring|carry|push|pull|drop|set|lay))\b',
|
| 330 |
+
cmd_lower,
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
object_terms: list[str] = []
|
| 334 |
+
seen: set[str] = set()
|
| 335 |
+
for chunk in chunks:
|
| 336 |
+
chunk_words = chunk.strip().split()
|
| 337 |
+
cleaned = [w for w in chunk_words if w not in stop_words]
|
| 338 |
+
if cleaned:
|
| 339 |
+
term = " ".join(cleaned).strip()
|
| 340 |
+
# Deduplicate and skip trivially short terms
|
| 341 |
+
if term and len(term) > 1 and term not in seen:
|
| 342 |
+
object_terms.append(term)
|
| 343 |
+
seen.add(term)
|
| 344 |
+
|
| 345 |
+
if not object_terms:
|
| 346 |
+
return cmd_lower + "."
|
| 347 |
+
|
| 348 |
+
prompt = ". ".join(object_terms) + "."
|
| 349 |
+
logger.info("Built Grounding DINO prompt: '%s'", prompt)
|
| 350 |
+
return prompt
|
src/visualization/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Visualization Module Package
|
src/visualization/visualizer.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Visualization Module — Annotates images with detection results
|
| 3 |
+
and action plan overlays using OpenCV.
|
| 4 |
+
|
| 5 |
+
Provides two visualization modes:
|
| 6 |
+
1. **Detection overlay** — All detected objects with labels + confidence.
|
| 7 |
+
2. **Action overlay** — Source/destination bounding boxes, arrow, and steps.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import cv2
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# Color palette (BGR for OpenCV)
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
COLOR_GREEN = (0, 200, 0)
|
| 22 |
+
COLOR_RED = (0, 0, 220)
|
| 23 |
+
COLOR_YELLOW = (0, 220, 220)
|
| 24 |
+
COLOR_CYAN = (220, 200, 0)
|
| 25 |
+
COLOR_WHITE = (255, 255, 255)
|
| 26 |
+
COLOR_BLACK = (0, 0, 0)
|
| 27 |
+
COLOR_ORANGE = (0, 140, 255)
|
| 28 |
+
COLOR_BLUE = (255, 100, 0)
|
| 29 |
+
|
| 30 |
+
# Distinct palette for detection overlay
|
| 31 |
+
_DETECTION_COLORS = [
|
| 32 |
+
COLOR_GREEN,
|
| 33 |
+
COLOR_CYAN,
|
| 34 |
+
COLOR_YELLOW,
|
| 35 |
+
COLOR_ORANGE,
|
| 36 |
+
COLOR_BLUE,
|
| 37 |
+
COLOR_RED,
|
| 38 |
+
(200, 0, 200), # magenta
|
| 39 |
+
(100, 200, 100), # light green
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ActionVisualizer:
|
| 44 |
+
"""
|
| 45 |
+
Draws visual annotations on images for detection results and action plans.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
# ------------------------------------------------------------------
|
| 49 |
+
# Detection Overlay
|
| 50 |
+
# ------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
def draw_detections(
|
| 53 |
+
self,
|
| 54 |
+
image: np.ndarray,
|
| 55 |
+
detections: list[dict[str, Any]],
|
| 56 |
+
) -> np.ndarray:
|
| 57 |
+
"""
|
| 58 |
+
Draw all detected objects on the image with labels and confidence.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
image: Input image (H, W, C) as NumPy array.
|
| 62 |
+
detections: List of detection dicts from the vision module.
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
Annotated copy of the image.
|
| 66 |
+
"""
|
| 67 |
+
if image is None or not isinstance(image, np.ndarray):
|
| 68 |
+
logger.error("Invalid image for detection visualization.")
|
| 69 |
+
return image if image is not None else np.zeros((400, 600, 3), dtype=np.uint8)
|
| 70 |
+
|
| 71 |
+
annotated = image.copy()
|
| 72 |
+
|
| 73 |
+
if not detections:
|
| 74 |
+
logger.info("No detections to visualize.")
|
| 75 |
+
self._put_centered_text(annotated, "No objects detected")
|
| 76 |
+
return annotated
|
| 77 |
+
|
| 78 |
+
for idx, det in enumerate(detections):
|
| 79 |
+
color = _DETECTION_COLORS[idx % len(_DETECTION_COLORS)]
|
| 80 |
+
bbox = det.get("bbox", [])
|
| 81 |
+
label = det.get("label", "unknown")
|
| 82 |
+
confidence = det.get("confidence", 0.0)
|
| 83 |
+
|
| 84 |
+
if len(bbox) != 4:
|
| 85 |
+
continue
|
| 86 |
+
|
| 87 |
+
x1, y1, x2, y2 = [int(c) for c in bbox]
|
| 88 |
+
|
| 89 |
+
# Draw bounding box
|
| 90 |
+
cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 2)
|
| 91 |
+
|
| 92 |
+
# Label with confidence
|
| 93 |
+
text = f"{label} ({confidence:.2f})"
|
| 94 |
+
text_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0]
|
| 95 |
+
|
| 96 |
+
# Background rectangle for text
|
| 97 |
+
cv2.rectangle(
|
| 98 |
+
annotated,
|
| 99 |
+
(x1, y1 - text_size[1] - 10),
|
| 100 |
+
(x1 + text_size[0] + 6, y1),
|
| 101 |
+
color,
|
| 102 |
+
-1,
|
| 103 |
+
)
|
| 104 |
+
cv2.putText(
|
| 105 |
+
annotated,
|
| 106 |
+
text,
|
| 107 |
+
(x1 + 3, y1 - 5),
|
| 108 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 109 |
+
0.6,
|
| 110 |
+
COLOR_WHITE,
|
| 111 |
+
2,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
logger.info("Detection overlay drawn with %d objects.", len(detections))
|
| 115 |
+
return annotated
|
| 116 |
+
|
| 117 |
+
# ------------------------------------------------------------------
|
| 118 |
+
# Action Overlay
|
| 119 |
+
# ------------------------------------------------------------------
|
| 120 |
+
|
| 121 |
+
def visualize(
|
| 122 |
+
self,
|
| 123 |
+
image: np.ndarray,
|
| 124 |
+
action_plan: dict[str, Any],
|
| 125 |
+
) -> np.ndarray:
|
| 126 |
+
"""
|
| 127 |
+
Draw the action plan overlay: source/destination boxes, arrow,
|
| 128 |
+
and step labels.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
image: Input image.
|
| 132 |
+
action_plan: Dict from the ActionPlanner containing
|
| 133 |
+
``source_bbox``, ``dest_bbox``, ``steps``, etc.
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Annotated copy of the image.
|
| 137 |
+
"""
|
| 138 |
+
if image is None or not isinstance(image, np.ndarray):
|
| 139 |
+
logger.error("Invalid image for action visualization.")
|
| 140 |
+
return image if image is not None else np.zeros((400, 600, 3), dtype=np.uint8)
|
| 141 |
+
|
| 142 |
+
annotated = image.copy()
|
| 143 |
+
|
| 144 |
+
source_bbox = action_plan.get("source_bbox")
|
| 145 |
+
dest_bbox = action_plan.get("dest_bbox")
|
| 146 |
+
steps = action_plan.get("steps", [])
|
| 147 |
+
status = action_plan.get("status", "unknown")
|
| 148 |
+
|
| 149 |
+
# Draw source bounding box (green)
|
| 150 |
+
if source_bbox and len(source_bbox) == 4:
|
| 151 |
+
self._draw_highlighted_box(
|
| 152 |
+
annotated, source_bbox, COLOR_GREEN, "SOURCE"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# Draw destination bounding box (red)
|
| 156 |
+
if dest_bbox and len(dest_bbox) == 4:
|
| 157 |
+
self._draw_highlighted_box(
|
| 158 |
+
annotated, dest_bbox, COLOR_RED, "DESTINATION"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Draw arrow from source to destination
|
| 162 |
+
if source_bbox and dest_bbox:
|
| 163 |
+
self._draw_action_arrow(annotated, source_bbox, dest_bbox)
|
| 164 |
+
|
| 165 |
+
# Draw step annotations
|
| 166 |
+
self._draw_steps_overlay(annotated, steps, status)
|
| 167 |
+
|
| 168 |
+
logger.info("Action visualization drawn (status=%s).", status)
|
| 169 |
+
return annotated
|
| 170 |
+
|
| 171 |
+
# ------------------------------------------------------------------
|
| 172 |
+
# Drawing helpers
|
| 173 |
+
# ------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
@staticmethod
|
| 176 |
+
def _draw_highlighted_box(
|
| 177 |
+
image: np.ndarray,
|
| 178 |
+
bbox: list[int],
|
| 179 |
+
color: tuple,
|
| 180 |
+
label: str,
|
| 181 |
+
) -> None:
|
| 182 |
+
"""Draw a highlighted bounding box with a corner-style frame."""
|
| 183 |
+
x1, y1, x2, y2 = [int(c) for c in bbox]
|
| 184 |
+
thickness = 3
|
| 185 |
+
|
| 186 |
+
# Semi-transparent overlay
|
| 187 |
+
overlay = image.copy()
|
| 188 |
+
cv2.rectangle(overlay, (x1, y1), (x2, y2), color, -1)
|
| 189 |
+
cv2.addWeighted(overlay, 0.15, image, 0.85, 0, image)
|
| 190 |
+
|
| 191 |
+
# Solid border
|
| 192 |
+
cv2.rectangle(image, (x1, y1), (x2, y2), color, thickness)
|
| 193 |
+
|
| 194 |
+
# Corner accents (L-shaped)
|
| 195 |
+
corner_len = min(30, (x2 - x1) // 4, (y2 - y1) // 4)
|
| 196 |
+
ct = thickness + 1
|
| 197 |
+
# Top-left
|
| 198 |
+
cv2.line(image, (x1, y1), (x1 + corner_len, y1), color, ct)
|
| 199 |
+
cv2.line(image, (x1, y1), (x1, y1 + corner_len), color, ct)
|
| 200 |
+
# Top-right
|
| 201 |
+
cv2.line(image, (x2, y1), (x2 - corner_len, y1), color, ct)
|
| 202 |
+
cv2.line(image, (x2, y1), (x2, y1 + corner_len), color, ct)
|
| 203 |
+
# Bottom-left
|
| 204 |
+
cv2.line(image, (x1, y2), (x1 + corner_len, y2), color, ct)
|
| 205 |
+
cv2.line(image, (x1, y2), (x1, y2 - corner_len), color, ct)
|
| 206 |
+
# Bottom-right
|
| 207 |
+
cv2.line(image, (x2, y2), (x2 - corner_len, y2), color, ct)
|
| 208 |
+
cv2.line(image, (x2, y2), (x2, y2 - corner_len), color, ct)
|
| 209 |
+
|
| 210 |
+
# Label above box
|
| 211 |
+
text_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)[0]
|
| 212 |
+
cv2.rectangle(
|
| 213 |
+
image,
|
| 214 |
+
(x1, y1 - text_size[1] - 14),
|
| 215 |
+
(x1 + text_size[0] + 10, y1 - 2),
|
| 216 |
+
color,
|
| 217 |
+
-1,
|
| 218 |
+
)
|
| 219 |
+
cv2.putText(
|
| 220 |
+
image,
|
| 221 |
+
label,
|
| 222 |
+
(x1 + 5, y1 - 7),
|
| 223 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 224 |
+
0.7,
|
| 225 |
+
COLOR_WHITE,
|
| 226 |
+
2,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
@staticmethod
|
| 230 |
+
def _draw_action_arrow(
|
| 231 |
+
image: np.ndarray,
|
| 232 |
+
source_bbox: list[int],
|
| 233 |
+
dest_bbox: list[int],
|
| 234 |
+
) -> None:
|
| 235 |
+
"""Draw a curved arrow from the source centre to the destination centre."""
|
| 236 |
+
sx = (source_bbox[0] + source_bbox[2]) // 2
|
| 237 |
+
sy = (source_bbox[1] + source_bbox[3]) // 2
|
| 238 |
+
dx = (dest_bbox[0] + dest_bbox[2]) // 2
|
| 239 |
+
dy = (dest_bbox[1] + dest_bbox[3]) // 2
|
| 240 |
+
|
| 241 |
+
# Draw a dashed path then a solid arrowhead
|
| 242 |
+
mid_x = (sx + dx) // 2
|
| 243 |
+
mid_y = min(sy, dy) - 40 # curve above
|
| 244 |
+
|
| 245 |
+
# Bezier-approximated curve via polyline
|
| 246 |
+
pts: list[tuple[int, int]] = []
|
| 247 |
+
for t_i in range(21):
|
| 248 |
+
t = t_i / 20.0
|
| 249 |
+
px = int((1 - t) ** 2 * sx + 2 * (1 - t) * t * mid_x + t ** 2 * dx)
|
| 250 |
+
py = int((1 - t) ** 2 * sy + 2 * (1 - t) * t * mid_y + t ** 2 * dy)
|
| 251 |
+
pts.append((px, py))
|
| 252 |
+
|
| 253 |
+
for i in range(len(pts) - 1):
|
| 254 |
+
# Dashed effect: draw every other segment
|
| 255 |
+
if i % 2 == 0:
|
| 256 |
+
cv2.line(image, pts[i], pts[i + 1], COLOR_ORANGE, 3)
|
| 257 |
+
|
| 258 |
+
# Arrowhead at destination
|
| 259 |
+
cv2.arrowedLine(
|
| 260 |
+
image, pts[-3], (dx, dy), COLOR_ORANGE, 3, tipLength=0.3
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# "ACTION" label at midpoint
|
| 264 |
+
cv2.putText(
|
| 265 |
+
image,
|
| 266 |
+
"ACTION",
|
| 267 |
+
(mid_x - 30, mid_y - 10),
|
| 268 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 269 |
+
0.6,
|
| 270 |
+
COLOR_ORANGE,
|
| 271 |
+
2,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
@staticmethod
|
| 275 |
+
def _draw_steps_overlay(
|
| 276 |
+
image: np.ndarray,
|
| 277 |
+
steps: list[str],
|
| 278 |
+
status: str,
|
| 279 |
+
) -> None:
|
| 280 |
+
"""Draw the steps list as a semi-transparent panel at the bottom."""
|
| 281 |
+
if not steps:
|
| 282 |
+
return
|
| 283 |
+
|
| 284 |
+
h, w = image.shape[:2]
|
| 285 |
+
line_height = 28
|
| 286 |
+
panel_height = len(steps) * line_height + 20
|
| 287 |
+
panel_top = h - panel_height
|
| 288 |
+
|
| 289 |
+
# Semi-transparent dark panel
|
| 290 |
+
overlay = image.copy()
|
| 291 |
+
cv2.rectangle(overlay, (0, panel_top), (w, h), COLOR_BLACK, -1)
|
| 292 |
+
cv2.addWeighted(overlay, 0.7, image, 0.3, 0, image)
|
| 293 |
+
|
| 294 |
+
# Status badge
|
| 295 |
+
status_color = (
|
| 296 |
+
COLOR_GREEN
|
| 297 |
+
if status == "success"
|
| 298 |
+
else COLOR_YELLOW if status == "partial" else COLOR_RED
|
| 299 |
+
)
|
| 300 |
+
cv2.putText(
|
| 301 |
+
image,
|
| 302 |
+
f"Status: {status.upper()}",
|
| 303 |
+
(10, panel_top + 20),
|
| 304 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 305 |
+
0.5,
|
| 306 |
+
status_color,
|
| 307 |
+
2,
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
# Steps text
|
| 311 |
+
for i, step in enumerate(steps):
|
| 312 |
+
y = panel_top + 20 + (i + 1) * line_height
|
| 313 |
+
# Truncate long steps
|
| 314 |
+
display_step = step if len(step) < 80 else step[:77] + "..."
|
| 315 |
+
cv2.putText(
|
| 316 |
+
image,
|
| 317 |
+
display_step,
|
| 318 |
+
(10, y),
|
| 319 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 320 |
+
0.5,
|
| 321 |
+
COLOR_WHITE,
|
| 322 |
+
1,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
@staticmethod
|
| 326 |
+
def _put_centered_text(
|
| 327 |
+
image: np.ndarray,
|
| 328 |
+
text: str,
|
| 329 |
+
color: tuple = COLOR_WHITE,
|
| 330 |
+
) -> None:
|
| 331 |
+
"""Put centred text on the image."""
|
| 332 |
+
h, w = image.shape[:2]
|
| 333 |
+
text_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 1.0, 2)[0]
|
| 334 |
+
x = (w - text_size[0]) // 2
|
| 335 |
+
y = (h + text_size[1]) // 2
|
| 336 |
+
cv2.putText(image, text, (x, y), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2)
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Tests Package
|
tests/test_action_planner.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the Action Planning Module — ActionPlanner.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from src.planner.action_planner import ActionPlanner
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestActionPlanner:
|
| 11 |
+
"""Tests for ActionPlanner.plan() method."""
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def planner(self) -> ActionPlanner:
|
| 15 |
+
return ActionPlanner()
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def sample_detections(self) -> list[dict]:
|
| 19 |
+
return [
|
| 20 |
+
{"label": "bottle", "confidence": 0.92, "bbox": [100, 150, 200, 350]},
|
| 21 |
+
{"label": "box", "confidence": 0.85, "bbox": [400, 300, 550, 500]},
|
| 22 |
+
{"label": "cup", "confidence": 0.78, "bbox": [250, 200, 320, 300]},
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
def test_plan_returns_required_keys(self, planner, sample_detections):
|
| 26 |
+
"""Plan output must have all required keys."""
|
| 27 |
+
interpretation = {
|
| 28 |
+
"action": "move",
|
| 29 |
+
"source": "bottle",
|
| 30 |
+
"destination": "box",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
result = planner.plan(sample_detections, interpretation)
|
| 34 |
+
|
| 35 |
+
assert "steps" in result
|
| 36 |
+
assert "source_bbox" in result
|
| 37 |
+
assert "dest_bbox" in result
|
| 38 |
+
assert "status" in result
|
| 39 |
+
assert "action" in result
|
| 40 |
+
|
| 41 |
+
def test_plan_success_status(self, planner, sample_detections):
|
| 42 |
+
"""Plan should succeed when both source and destination match."""
|
| 43 |
+
interpretation = {
|
| 44 |
+
"action": "move",
|
| 45 |
+
"source": "bottle",
|
| 46 |
+
"destination": "box",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
result = planner.plan(sample_detections, interpretation)
|
| 50 |
+
|
| 51 |
+
assert result["status"] == "success"
|
| 52 |
+
assert result["source_bbox"] == [100, 150, 200, 350]
|
| 53 |
+
assert result["dest_bbox"] == [400, 300, 550, 500]
|
| 54 |
+
|
| 55 |
+
def test_plan_partial_status(self, planner, sample_detections):
|
| 56 |
+
"""Plan should be partial when destination doesn't match."""
|
| 57 |
+
interpretation = {
|
| 58 |
+
"action": "move",
|
| 59 |
+
"source": "bottle",
|
| 60 |
+
"destination": "nonexistent_object",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
result = planner.plan(sample_detections, interpretation)
|
| 64 |
+
|
| 65 |
+
assert result["status"] == "partial"
|
| 66 |
+
assert result["source_bbox"] is not None
|
| 67 |
+
assert result["dest_bbox"] is None
|
| 68 |
+
|
| 69 |
+
def test_plan_failed_status(self, planner, sample_detections):
|
| 70 |
+
"""Plan should fail when source doesn't match."""
|
| 71 |
+
interpretation = {
|
| 72 |
+
"action": "move",
|
| 73 |
+
"source": "nonexistent_object",
|
| 74 |
+
"destination": "box",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
result = planner.plan(sample_detections, interpretation)
|
| 78 |
+
|
| 79 |
+
assert result["status"] == "failed"
|
| 80 |
+
|
| 81 |
+
def test_plan_generates_steps(self, planner, sample_detections):
|
| 82 |
+
"""Plan should generate meaningful steps."""
|
| 83 |
+
interpretation = {
|
| 84 |
+
"action": "move",
|
| 85 |
+
"source": "bottle",
|
| 86 |
+
"destination": "box",
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
result = planner.plan(sample_detections, interpretation)
|
| 90 |
+
|
| 91 |
+
assert len(result["steps"]) >= 3
|
| 92 |
+
assert any("bottle" in step.lower() for step in result["steps"])
|
| 93 |
+
assert any("box" in step.lower() for step in result["steps"])
|
| 94 |
+
|
| 95 |
+
def test_plan_no_destination(self, planner, sample_detections):
|
| 96 |
+
"""Plan should work when destination is None (e.g. 'inspect')."""
|
| 97 |
+
interpretation = {
|
| 98 |
+
"action": "inspect",
|
| 99 |
+
"source": "cup",
|
| 100 |
+
"destination": None,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
result = planner.plan(sample_detections, interpretation)
|
| 104 |
+
|
| 105 |
+
assert result["status"] == "success"
|
| 106 |
+
assert result["dest_bbox"] is None
|
| 107 |
+
|
| 108 |
+
def test_plan_empty_detections(self, planner):
|
| 109 |
+
"""Plan should fail gracefully with no detections."""
|
| 110 |
+
interpretation = {
|
| 111 |
+
"action": "move",
|
| 112 |
+
"source": "bottle",
|
| 113 |
+
"destination": "box",
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
result = planner.plan([], interpretation)
|
| 117 |
+
|
| 118 |
+
assert result["status"] == "failed"
|
| 119 |
+
|
| 120 |
+
def test_plan_with_synonym(self, planner, sample_detections):
|
| 121 |
+
"""Plan should resolve synonyms via ObjectMapper (mug → cup)."""
|
| 122 |
+
interpretation = {
|
| 123 |
+
"action": "pick_up",
|
| 124 |
+
"source": "mug",
|
| 125 |
+
"destination": None,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
result = planner.plan(sample_detections, interpretation)
|
| 129 |
+
|
| 130 |
+
# "mug" should map to "cup" via the synonym mapper
|
| 131 |
+
assert result["status"] == "success"
|
| 132 |
+
assert result["source_bbox"] == [250, 200, 320, 300]
|
tests/test_detector.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the Vision Module — ObjectDetector.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestObjectDetector:
|
| 10 |
+
"""Tests for ObjectDetector.detect() method."""
|
| 11 |
+
|
| 12 |
+
def test_detect_returns_list(self):
|
| 13 |
+
"""Detection should return a list."""
|
| 14 |
+
from src.vision.detector import ObjectDetector
|
| 15 |
+
|
| 16 |
+
detector = ObjectDetector(model_name="yolov8n.pt", confidence=0.25)
|
| 17 |
+
|
| 18 |
+
# Create a synthetic test image (solid blue, 480x640)
|
| 19 |
+
image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 20 |
+
image[:, :] = (200, 100, 50)
|
| 21 |
+
|
| 22 |
+
result = detector.detect(image)
|
| 23 |
+
assert isinstance(result, list), "detect() must return a list"
|
| 24 |
+
|
| 25 |
+
def test_detect_result_schema(self):
|
| 26 |
+
"""Each detection should have label, confidence, and bbox keys."""
|
| 27 |
+
from src.vision.detector import ObjectDetector
|
| 28 |
+
|
| 29 |
+
detector = ObjectDetector(model_name="yolov8n.pt", confidence=0.25)
|
| 30 |
+
|
| 31 |
+
# Use a real-ish image to maybe get detections
|
| 32 |
+
image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
|
| 33 |
+
result = detector.detect(image)
|
| 34 |
+
|
| 35 |
+
for det in result:
|
| 36 |
+
assert "label" in det, "Detection must have 'label'"
|
| 37 |
+
assert "confidence" in det, "Detection must have 'confidence'"
|
| 38 |
+
assert "bbox" in det, "Detection must have 'bbox'"
|
| 39 |
+
assert isinstance(det["label"], str)
|
| 40 |
+
assert isinstance(det["confidence"], float)
|
| 41 |
+
assert isinstance(det["bbox"], list)
|
| 42 |
+
assert len(det["bbox"]) == 4
|
| 43 |
+
|
| 44 |
+
def test_detect_invalid_image_raises(self):
|
| 45 |
+
"""Passing None should raise ValueError."""
|
| 46 |
+
from src.vision.detector import ObjectDetector
|
| 47 |
+
|
| 48 |
+
detector = ObjectDetector(model_name="yolov8n.pt", confidence=0.25)
|
| 49 |
+
|
| 50 |
+
with pytest.raises(ValueError):
|
| 51 |
+
detector.detect(None)
|
| 52 |
+
|
| 53 |
+
def test_detect_1d_image_raises(self):
|
| 54 |
+
"""Passing a 1D array should raise ValueError."""
|
| 55 |
+
from src.vision.detector import ObjectDetector
|
| 56 |
+
|
| 57 |
+
detector = ObjectDetector(model_name="yolov8n.pt", confidence=0.25)
|
| 58 |
+
|
| 59 |
+
with pytest.raises(ValueError):
|
| 60 |
+
detector.detect(np.array([1, 2, 3]))
|
tests/test_object_mapper.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the Object Mapper — synonym normalization and fuzzy matching.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from src.utils.object_mapper import (
|
| 8 |
+
normalize_label,
|
| 9 |
+
find_best_match,
|
| 10 |
+
SYNONYM_MAP,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestNormalizeLabel:
|
| 15 |
+
"""Tests for normalize_label()."""
|
| 16 |
+
|
| 17 |
+
def test_known_synonym(self):
|
| 18 |
+
assert normalize_label("mug") == "cup"
|
| 19 |
+
|
| 20 |
+
def test_known_synonym_case_insensitive(self):
|
| 21 |
+
assert normalize_label("MUG") == "cup"
|
| 22 |
+
|
| 23 |
+
def test_unknown_label_passthrough(self):
|
| 24 |
+
assert normalize_label("bottle") == "bottle"
|
| 25 |
+
|
| 26 |
+
def test_whitespace_stripped(self):
|
| 27 |
+
assert normalize_label(" mug ") == "cup"
|
| 28 |
+
|
| 29 |
+
def test_container_to_bottle(self):
|
| 30 |
+
assert normalize_label("container") == "bottle"
|
| 31 |
+
|
| 32 |
+
def test_smartphone_to_cell_phone(self):
|
| 33 |
+
assert normalize_label("smartphone") == "cell phone"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TestFindBestMatch:
|
| 37 |
+
"""Tests for find_best_match()."""
|
| 38 |
+
|
| 39 |
+
@pytest.fixture
|
| 40 |
+
def detected_labels(self) -> list[str]:
|
| 41 |
+
return ["bottle", "cup", "box", "person", "laptop"]
|
| 42 |
+
|
| 43 |
+
def test_exact_match(self, detected_labels):
|
| 44 |
+
result = find_best_match("bottle", detected_labels)
|
| 45 |
+
assert result == "bottle"
|
| 46 |
+
|
| 47 |
+
def test_synonym_match(self, detected_labels):
|
| 48 |
+
"""'mug' should resolve to 'cup' via synonym normalization."""
|
| 49 |
+
result = find_best_match("mug", detected_labels)
|
| 50 |
+
assert result == "cup"
|
| 51 |
+
|
| 52 |
+
def test_container_matches_bottle(self, detected_labels):
|
| 53 |
+
result = find_best_match("container", detected_labels)
|
| 54 |
+
assert result == "bottle"
|
| 55 |
+
|
| 56 |
+
def test_substring_match(self, detected_labels):
|
| 57 |
+
result = find_best_match("lap", detected_labels)
|
| 58 |
+
assert result == "laptop"
|
| 59 |
+
|
| 60 |
+
def test_no_match_returns_none(self, detected_labels):
|
| 61 |
+
result = find_best_match("xyznonexistent", detected_labels)
|
| 62 |
+
assert result is None
|
| 63 |
+
|
| 64 |
+
def test_empty_query(self, detected_labels):
|
| 65 |
+
result = find_best_match("", detected_labels)
|
| 66 |
+
# Empty normalizes to empty, no match expected
|
| 67 |
+
assert result is None or isinstance(result, str)
|
| 68 |
+
|
| 69 |
+
def test_empty_detected_labels(self):
|
| 70 |
+
result = find_best_match("bottle", [])
|
| 71 |
+
assert result is None
|
tests/test_reasoner.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the Language Reasoning Module — CommandReasoner.
|
| 3 |
+
|
| 4 |
+
Uses mocked OpenAI client to test interpretation logic without API calls.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from unittest.mock import MagicMock, patch
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from src.language.reasoner import CommandReasoner
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestCommandReasoner:
|
| 16 |
+
"""Tests for CommandReasoner.interpret() method."""
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def sample_detections(self) -> list[dict]:
|
| 20 |
+
"""Sample detection results for testing."""
|
| 21 |
+
return [
|
| 22 |
+
{"label": "bottle", "confidence": 0.92, "bbox": [100, 150, 200, 350]},
|
| 23 |
+
{"label": "box", "confidence": 0.85, "bbox": [400, 300, 550, 500]},
|
| 24 |
+
{"label": "cup", "confidence": 0.78, "bbox": [250, 200, 320, 300]},
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
def test_fallback_mode_without_api_key(self, sample_detections):
|
| 28 |
+
"""Reasoner without API key should use fallback mode."""
|
| 29 |
+
reasoner = CommandReasoner(provider="openai", api_key="", model="gpt-4o")
|
| 30 |
+
|
| 31 |
+
result = reasoner.interpret(
|
| 32 |
+
"Move the bottle to the box", sample_detections
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
assert isinstance(result, dict)
|
| 36 |
+
assert "action" in result
|
| 37 |
+
assert "source" in result
|
| 38 |
+
assert "destination" in result
|
| 39 |
+
|
| 40 |
+
def test_fallback_extracts_action_verb(self, sample_detections):
|
| 41 |
+
"""Fallback mode should detect the action verb from the command."""
|
| 42 |
+
reasoner = CommandReasoner(provider="openai", api_key="", model="gpt-4o")
|
| 43 |
+
|
| 44 |
+
result = reasoner.interpret(
|
| 45 |
+
"inspect the cup carefully", sample_detections
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
assert result["action"] == "inspect"
|
| 49 |
+
|
| 50 |
+
def test_fallback_matches_labels(self, sample_detections):
|
| 51 |
+
"""Fallback mode should match detected labels from the command."""
|
| 52 |
+
reasoner = CommandReasoner(provider="openai", api_key="", model="gpt-4o")
|
| 53 |
+
|
| 54 |
+
result = reasoner.interpret(
|
| 55 |
+
"Move the bottle to the box", sample_detections
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
assert result["source"] == "bottle"
|
| 59 |
+
assert result["destination"] == "box"
|
| 60 |
+
|
| 61 |
+
def test_empty_command_returns_error(self, sample_detections):
|
| 62 |
+
"""Empty command should return an error result."""
|
| 63 |
+
reasoner = CommandReasoner(provider="openai", api_key="", model="gpt-4o")
|
| 64 |
+
|
| 65 |
+
result = reasoner.interpret("", sample_detections)
|
| 66 |
+
|
| 67 |
+
assert result["action"] == "error"
|
| 68 |
+
assert "error" in result
|
| 69 |
+
|
| 70 |
+
@patch("src.language.reasoner.CommandReasoner._init_client")
|
| 71 |
+
def test_llm_interpretation(self, mock_init, sample_detections):
|
| 72 |
+
"""Test successful LLM-based interpretation with mocked client."""
|
| 73 |
+
reasoner = CommandReasoner(provider="openai", api_key="test-key", model="gpt-4o")
|
| 74 |
+
|
| 75 |
+
# Mock the OpenAI client response
|
| 76 |
+
mock_response = MagicMock()
|
| 77 |
+
mock_response.choices = [MagicMock()]
|
| 78 |
+
mock_response.choices[0].message.content = json.dumps(
|
| 79 |
+
{"action": "move", "source": "bottle", "destination": "box"}
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
mock_client = MagicMock()
|
| 83 |
+
mock_client.chat.completions.create.return_value = mock_response
|
| 84 |
+
reasoner.client = mock_client
|
| 85 |
+
|
| 86 |
+
result = reasoner.interpret(
|
| 87 |
+
"Move the bottle to the box", sample_detections
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
assert result["action"] == "move"
|
| 91 |
+
assert result["source"] == "bottle"
|
| 92 |
+
assert result["destination"] == "box"
|
| 93 |
+
|
| 94 |
+
def test_parse_response_with_markdown_fences(self):
|
| 95 |
+
"""Parser should handle markdown code fences from LLM."""
|
| 96 |
+
reasoner = CommandReasoner(provider="openai", api_key="", model="gpt-4o")
|
| 97 |
+
|
| 98 |
+
raw = '```json\n{"action": "pick_up", "source": "cup", "destination": null}\n```'
|
| 99 |
+
result = reasoner._parse_response(raw)
|
| 100 |
+
|
| 101 |
+
assert result["action"] == "pick_up"
|
| 102 |
+
assert result["source"] == "cup"
|
| 103 |
+
assert result["destination"] is None
|
| 104 |
+
|
| 105 |
+
def test_parse_response_invalid_json(self):
|
| 106 |
+
"""Parser should return error dict for invalid JSON."""
|
| 107 |
+
reasoner = CommandReasoner(provider="openai", api_key="", model="gpt-4o")
|
| 108 |
+
|
| 109 |
+
result = reasoner._parse_response("this is not json")
|
| 110 |
+
|
| 111 |
+
assert result["action"] == "unknown"
|
| 112 |
+
assert "error" in result
|
tests/test_visualizer.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the Visualization Module — ActionVisualizer.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from src.visualization.visualizer import ActionVisualizer
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestActionVisualizer:
|
| 12 |
+
"""Tests for ActionVisualizer methods."""
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def visualizer(self) -> ActionVisualizer:
|
| 16 |
+
return ActionVisualizer()
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def test_image(self) -> np.ndarray:
|
| 20 |
+
"""A 480x640 synthetic test image."""
|
| 21 |
+
return np.zeros((480, 640, 3), dtype=np.uint8)
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
def sample_detections(self) -> list[dict]:
|
| 25 |
+
return [
|
| 26 |
+
{"label": "bottle", "confidence": 0.92, "bbox": [100, 150, 200, 350]},
|
| 27 |
+
{"label": "box", "confidence": 0.85, "bbox": [400, 300, 550, 500]},
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
@pytest.fixture
|
| 31 |
+
def sample_action_plan(self) -> dict:
|
| 32 |
+
return {
|
| 33 |
+
"action": "move",
|
| 34 |
+
"steps": [
|
| 35 |
+
"Step 1: Locate 'bottle'",
|
| 36 |
+
"Step 2: Locate 'box'",
|
| 37 |
+
"Step 3: Move 'bottle' → 'box'",
|
| 38 |
+
"Step 4: Verify action completion",
|
| 39 |
+
],
|
| 40 |
+
"source_bbox": [100, 150, 200, 350],
|
| 41 |
+
"dest_bbox": [400, 300, 550, 500],
|
| 42 |
+
"status": "success",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def test_draw_detections_returns_image(
|
| 46 |
+
self, visualizer, test_image, sample_detections
|
| 47 |
+
):
|
| 48 |
+
"""draw_detections should return an image of the same shape."""
|
| 49 |
+
result = visualizer.draw_detections(test_image, sample_detections)
|
| 50 |
+
|
| 51 |
+
assert isinstance(result, np.ndarray)
|
| 52 |
+
assert result.shape == test_image.shape
|
| 53 |
+
|
| 54 |
+
def test_draw_detections_modifies_image(
|
| 55 |
+
self, visualizer, test_image, sample_detections
|
| 56 |
+
):
|
| 57 |
+
"""draw_detections should modify the image (not return a blank copy)."""
|
| 58 |
+
result = visualizer.draw_detections(test_image, sample_detections)
|
| 59 |
+
|
| 60 |
+
# The annotated image should differ from the original
|
| 61 |
+
assert not np.array_equal(result, test_image)
|
| 62 |
+
|
| 63 |
+
def test_draw_detections_empty_list(self, visualizer, test_image):
|
| 64 |
+
"""draw_detections with empty list should still return an image."""
|
| 65 |
+
result = visualizer.draw_detections(test_image, [])
|
| 66 |
+
|
| 67 |
+
assert isinstance(result, np.ndarray)
|
| 68 |
+
assert result.shape == test_image.shape
|
| 69 |
+
|
| 70 |
+
def test_visualize_returns_image(
|
| 71 |
+
self, visualizer, test_image, sample_action_plan
|
| 72 |
+
):
|
| 73 |
+
"""visualize should return an image of the same shape."""
|
| 74 |
+
result = visualizer.visualize(test_image, sample_action_plan)
|
| 75 |
+
|
| 76 |
+
assert isinstance(result, np.ndarray)
|
| 77 |
+
assert result.shape == test_image.shape
|
| 78 |
+
|
| 79 |
+
def test_visualize_modifies_image(
|
| 80 |
+
self, visualizer, test_image, sample_action_plan
|
| 81 |
+
):
|
| 82 |
+
"""visualize should modify the image."""
|
| 83 |
+
result = visualizer.visualize(test_image, sample_action_plan)
|
| 84 |
+
|
| 85 |
+
assert not np.array_equal(result, test_image)
|
| 86 |
+
|
| 87 |
+
def test_visualize_with_no_bboxes(self, visualizer, test_image):
|
| 88 |
+
"""visualize should handle missing bounding boxes gracefully."""
|
| 89 |
+
plan = {
|
| 90 |
+
"action": "inspect",
|
| 91 |
+
"steps": ["Step 1: Inspect object"],
|
| 92 |
+
"source_bbox": None,
|
| 93 |
+
"dest_bbox": None,
|
| 94 |
+
"status": "failed",
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
result = visualizer.visualize(test_image, plan)
|
| 98 |
+
|
| 99 |
+
assert isinstance(result, np.ndarray)
|
| 100 |
+
assert result.shape == test_image.shape
|
| 101 |
+
|
| 102 |
+
def test_draw_detections_invalid_image(self, visualizer):
|
| 103 |
+
"""draw_detections with None should return a fallback image."""
|
| 104 |
+
result = visualizer.draw_detections(None, [])
|
| 105 |
+
|
| 106 |
+
assert isinstance(result, np.ndarray)
|