SahilCodevally commited on
Commit
ac4d54d
·
2 Parent(s): 73f29aaf275ae8

Merge branch 'master'

Browse files
.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 CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.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 CHANGED
@@ -1,14 +1,53 @@
1
  ---
2
- title: Codevally Ai
3
- emoji: 🦀
4
- colorFrom: green
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.9.0
8
  app_file: app.py
9
  pinned: false
10
- license: mit
11
- short_description: Codevally Portfolios
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: VLA Demo - Codevally AI
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: indigo
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

  • SHA256: 9de33e78d3cedcaa0ba91644f159b9cfbc5d1907d6610c009f5ccbddf6c0e5bb
  • Pointer size: 131 Bytes
  • Size of remote file: 693 kB
assets/demo_images/construction_site.jpg ADDED

Git LFS Details

  • SHA256: 054e9ebc241287d5f6b6d15f3c6f877adab8b8872836a66c6cec6b8d3853145b
  • Pointer size: 131 Bytes
  • Size of remote file: 745 kB
assets/demo_images/desk_scene.jpg ADDED

Git LFS Details

  • SHA256: 05e62512d76a7ff32eddc04d2ef87059b462fb3a655d577e7051eb4ff561275d
  • Pointer size: 131 Bytes
  • Size of remote file: 631 kB
assets/demo_images/garage_tools.jpg ADDED

Git LFS Details

  • SHA256: 9abd688859a74287abf21c028e014bb296acae09929d326375913ed40787fd6d
  • Pointer size: 131 Bytes
  • Size of remote file: 795 kB
assets/demo_images/grocery_shelf.jpg ADDED

Git LFS Details

  • SHA256: 36c715dba4c4da128fb225e7e3149dab15d92c821fcae85b9f1c0801618a29e9
  • Pointer size: 131 Bytes
  • Size of remote file: 811 kB
assets/demo_images/kitchen_counter.jpg ADDED

Git LFS Details

  • SHA256: 9e6c336e254a88a13ce9ba1ab31b7665d97216e7f37bf9302760f8d318dd01ca
  • Pointer size: 131 Bytes
  • Size of remote file: 721 kB
assets/demo_images/lab_equipment.jpg ADDED

Git LFS Details

  • SHA256: 9d1fde4765bf672083f5d61d24ef982d419f855967da4b1b1d85192092304d01
  • Pointer size: 131 Bytes
  • Size of remote file: 613 kB
assets/demo_images/medical_tray.jpg ADDED

Git LFS Details

  • SHA256: 2ef33650e95a5a090cb5abde067544e2797ee016ecea5b7ce91bf755bba4a7e7
  • Pointer size: 131 Bytes
  • Size of remote file: 605 kB
assets/demo_images/office_supplies.jpg ADDED

Git LFS Details

  • SHA256: 7275f104e58f7f96930a333e37e6fc35d961580298e675015c583a07423e5a1c
  • Pointer size: 131 Bytes
  • Size of remote file: 750 kB
assets/demo_images/warehouse_boxes.jpg ADDED

Git LFS Details

  • SHA256: 2d739a63abfc9c486fd954ad06ac55bf0f070bba2e1e39249b388881cdf3d7d2
  • Pointer size: 131 Bytes
  • Size of remote file: 739 kB
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
+ &nbsp;·&nbsp; 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
+ &nbsp;·&nbsp; Vision Language Action AI Platform
97
+ &nbsp;·&nbsp; © 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"&nbsp;|&nbsp; {n} object{'s' if n != 1 else ''} detected"
82
+ f"&nbsp;|&nbsp; {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)