lang2sim 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lang2sim/__init__.py +47 -0
- lang2sim/agent/__init__.py +5 -0
- lang2sim/agent/nemotron_client.py +109 -0
- lang2sim/agent/react_loop.py +848 -0
- lang2sim/agent/tools.py +998 -0
- lang2sim/cli/app.py +183 -0
- lang2sim/cli/screens/__init__.py +8 -0
- lang2sim/cli/screens/command_palette_modal.py +146 -0
- lang2sim/cli/screens/landing_screen.py +96 -0
- lang2sim/cli/screens/sessions_modal.py +236 -0
- lang2sim/cli/screens/workspace_screen.py +709 -0
- lang2sim/cli/widgets/__init__.py +16 -0
- lang2sim/cli/widgets/action_pill.py +67 -0
- lang2sim/cli/widgets/command_card.py +265 -0
- lang2sim/cli/widgets/plan_card.py +106 -0
- lang2sim/cli/widgets/prompt_card.py +96 -0
- lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
- lang2sim/cli/widgets/thought_box.py +64 -0
- lang2sim/compiler/__init__.py +15 -0
- lang2sim/compiler/sdf_compiler.py +65 -0
- lang2sim/compiler/sir_generator.py +46 -0
- lang2sim/compiler/sir_validator.py +87 -0
- lang2sim/core/__init__.py +50 -0
- lang2sim/core/config.py +116 -0
- lang2sim/core/models.py +279 -0
- lang2sim/core/session_manager.py +189 -0
- lang2sim/perturbation/__init__.py +50 -0
- lang2sim/perturbation/_compat.py +19 -0
- lang2sim/perturbation/gazebo_bridge.py +520 -0
- lang2sim/perturbation/mcp_server.py +129 -0
- lang2sim/perturbation/routers/__init__.py +19 -0
- lang2sim/perturbation/routers/world.py +110 -0
- lang2sim/perturbation/sdf_snippets.py +112 -0
- lang2sim/runtime/__init__.py +7 -0
- lang2sim/runtime/control_api/__init__.py +7 -0
- lang2sim/runtime/control_api/client.py +187 -0
- lang2sim/runtime/control_api/server.py +504 -0
- lang2sim/runtime/display.py +141 -0
- lang2sim/runtime/docker_runner.py +286 -0
- lang2sim/runtime/doctor.py +220 -0
- lang2sim/runtime/hardware_probe.py +144 -0
- lang2sim/runtime/health_monitor.py +66 -0
- lang2sim/runtime/launch_builder.py +137 -0
- lang2sim/runtime/nav2_dispatcher.py +215 -0
- lang2sim/runtime/platform.py +137 -0
- lang2sim/runtime/ros_bridge.py +183 -0
- lang2sim/runtime/run_folder.py +86 -0
- lang2sim/runtime/self_healing.py +387 -0
- lang2sim/runtime/session.py +136 -0
- lang2sim/runtime/world_check.py +197 -0
- lang2sim/telemetry/__init__.py +85 -0
- lang2sim/telemetry/nqi_scorer.py +645 -0
- lang2sim/telemetry/ros_subscribers.py +489 -0
- lang2sim/telemetry/trajectory.py +258 -0
- lang2sim/telemetry/vision.py +477 -0
- lang2sim/world_gen/__init__.py +16 -0
- lang2sim/world_gen/catalog_loader.py +83 -0
- lang2sim/world_gen/map_slicer.py +131 -0
- lang2sim/world_gen/shapely_solver.py +256 -0
- lang2sim-2.0.0.dist-info/METADATA +53 -0
- lang2sim-2.0.0.dist-info/RECORD +65 -0
- lang2sim-2.0.0.dist-info/WHEEL +5 -0
- lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
- lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
- lang2sim-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Dual-Stream Visual Observer.
|
|
2
|
+
|
|
3
|
+
Captures two visual streams:
|
|
4
|
+
1. Stream 1 (Robot Perspective): Reads on-board RGB camera (/camera/image_raw),
|
|
5
|
+
computes optical degradation metrics (motion blur, Gaussian noise, fog contrast attenuation).
|
|
6
|
+
2. Stream 2 (Desktop GUI Windows): Uses mss + xdotool/Win32 to capture native desktop
|
|
7
|
+
windows for Gazebo (Sim Time, RTF, 3D viewport) and RViz2 (Costmaps, LaserScan, Planned Path).
|
|
8
|
+
|
|
9
|
+
Encodes frames as base64 JPEG strings ready for multimodal reasoning models.
|
|
10
|
+
Pure NumPy decoding for raw ROS camera images (no cv_bridge required).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import base64
|
|
17
|
+
import io
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Mapping, Optional, Sequence, Tuple
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from PIL import Image
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"VisionResult",
|
|
33
|
+
"compute_optical_metrics",
|
|
34
|
+
"encode_jpeg_base64",
|
|
35
|
+
"capture_desktop_window",
|
|
36
|
+
"capture_gazebo_window",
|
|
37
|
+
"capture_rviz_window",
|
|
38
|
+
"read_robot_camera",
|
|
39
|
+
"generate_synthetic_frame",
|
|
40
|
+
"CameraSampler",
|
|
41
|
+
"get_camera_sampler",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class VisionResult:
|
|
47
|
+
"""Standard envelope for visual perception frames and metadata."""
|
|
48
|
+
ok: bool
|
|
49
|
+
stream: str # "desktop_window" or "robot_camera"
|
|
50
|
+
target: str # "gazebo", "rviz", "screen", or topic name
|
|
51
|
+
width: int = 0
|
|
52
|
+
height: int = 0
|
|
53
|
+
mime: str = "image/jpeg"
|
|
54
|
+
jpeg_base64: str = ""
|
|
55
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
56
|
+
latency_ms: float = 0.0
|
|
57
|
+
message: str = ""
|
|
58
|
+
save_path: Optional[str] = None
|
|
59
|
+
fallback: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"ok": self.ok,
|
|
64
|
+
"stream": self.stream,
|
|
65
|
+
"target": self.target,
|
|
66
|
+
"width": self.width,
|
|
67
|
+
"height": self.height,
|
|
68
|
+
"mime": self.mime,
|
|
69
|
+
"jpeg_base64": self.jpeg_base64,
|
|
70
|
+
"metrics": self.metrics,
|
|
71
|
+
"latency_ms": round(self.latency_ms, 2),
|
|
72
|
+
"message": self.message,
|
|
73
|
+
"save_path": self.save_path,
|
|
74
|
+
"fallback": self.fallback,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------------------- Optical Degradation Metrics
|
|
79
|
+
def _to_grayscale_f32(img_arr: np.ndarray) -> np.ndarray:
|
|
80
|
+
"""Convert RGB/BGR/L array to 2D grayscale float32 in [0, 255]."""
|
|
81
|
+
if img_arr.ndim == 2:
|
|
82
|
+
return img_arr.astype(np.float32)
|
|
83
|
+
# ITU-R 601-2 luma transform
|
|
84
|
+
return (0.299 * img_arr[:, :, 0] + 0.587 * img_arr[:, :, 1] + 0.114 * img_arr[:, :, 2]).astype(np.float32)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compute_optical_metrics(img_arr: np.ndarray) -> dict[str, float]:
|
|
88
|
+
"""Calculate optical degradation metrics: blur (variance of Laplacian), noise, contrast, and brightness."""
|
|
89
|
+
gray = _to_grayscale_f32(img_arr)
|
|
90
|
+
h, w = gray.shape
|
|
91
|
+
|
|
92
|
+
# 1. Brightness: mean luma in [0, 255]
|
|
93
|
+
brightness = float(np.mean(gray))
|
|
94
|
+
|
|
95
|
+
# 2. RMS Contrast: stddev / mean (or stddev) — attenuation by volumetric fog drops this
|
|
96
|
+
rms_contrast = float(np.std(gray))
|
|
97
|
+
|
|
98
|
+
# 3. Motion Blur: variance of 4-connected discrete Laplacian
|
|
99
|
+
# L(x, y) = gray[y+1, x] + gray[y-1, x] + gray[y, x+1] + gray[y, x-1] - 4*gray[y, x]
|
|
100
|
+
if h > 2 and w > 2:
|
|
101
|
+
laplacian = (
|
|
102
|
+
gray[2:, 1:-1]
|
|
103
|
+
+ gray[:-2, 1:-1]
|
|
104
|
+
+ gray[1:-1, 2:]
|
|
105
|
+
+ gray[1:-1, :-2]
|
|
106
|
+
- 4.0 * gray[1:-1, 1:-1]
|
|
107
|
+
)
|
|
108
|
+
blur_score = float(np.var(laplacian))
|
|
109
|
+
else:
|
|
110
|
+
blur_score = 0.0
|
|
111
|
+
|
|
112
|
+
# 4. Noise estimate: high-pass residual standard deviation
|
|
113
|
+
if h > 2 and w > 2:
|
|
114
|
+
# High pass kernel [[1, -2, 1], [-2, 4, -2], [1, -2, 1]] / 16
|
|
115
|
+
high_pass = (
|
|
116
|
+
(gray[2:, 2:] + gray[:-2, :-2] + gray[2:, :-2] + gray[:-2, 2:])
|
|
117
|
+
- 2.0 * (gray[2:, 1:-1] + gray[:-2, 1:-1] + gray[1:-1, 2:] + gray[1:-1, :-2])
|
|
118
|
+
+ 4.0 * gray[1:-1, 1:-1]
|
|
119
|
+
) / 16.0
|
|
120
|
+
noise_score = float(np.std(high_pass))
|
|
121
|
+
else:
|
|
122
|
+
noise_score = 0.0
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"brightness": round(brightness, 2),
|
|
126
|
+
"contrast_rms": round(rms_contrast, 2),
|
|
127
|
+
"blur_laplacian_var": round(blur_score, 2),
|
|
128
|
+
"noise_stddev": round(noise_score, 2),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def encode_jpeg_base64(img: Image.Image, quality: int = 80, max_dimension: int = 1280) -> str:
|
|
133
|
+
"""Downscale if larger than max_dimension, encode to JPEG, and return base64 string."""
|
|
134
|
+
w, h = img.size
|
|
135
|
+
if max(w, h) > max_dimension:
|
|
136
|
+
scale = max_dimension / max(w, h)
|
|
137
|
+
img = img.resize((int(w * scale), int(h * scale)), Image.Resampling.BILINEAR)
|
|
138
|
+
|
|
139
|
+
# Ensure RGB
|
|
140
|
+
if img.mode != "RGB":
|
|
141
|
+
img = img.convert("RGB")
|
|
142
|
+
|
|
143
|
+
buf = io.BytesIO()
|
|
144
|
+
img.save(buf, format="JPEG", quality=quality)
|
|
145
|
+
return base64.b64encode(buf.getvalue()).decode("ascii")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------- Desktop Window Capture
|
|
149
|
+
def _find_window_rect_x11(title_pattern: str) -> Optional[Tuple[int, int, int, int]]:
|
|
150
|
+
"""Query xdotool on Linux/X11 to locate window coordinates (x, y, w, h)."""
|
|
151
|
+
if not shutil.which("xdotool"):
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
cmd = f"xdotool search --onlyvisible --name '{title_pattern}'"
|
|
155
|
+
out = subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL).strip()
|
|
156
|
+
win_ids = [w.strip() for w in out.splitlines() if w.strip()]
|
|
157
|
+
if not win_ids:
|
|
158
|
+
return None
|
|
159
|
+
win_id = win_ids[0]
|
|
160
|
+
geom_out = subprocess.check_output(
|
|
161
|
+
f"xdotool getwindowgeometry --shell {win_id}",
|
|
162
|
+
shell=True,
|
|
163
|
+
text=True,
|
|
164
|
+
stderr=subprocess.DEVNULL,
|
|
165
|
+
)
|
|
166
|
+
geom = {}
|
|
167
|
+
for line in geom_out.splitlines():
|
|
168
|
+
if "=" in line:
|
|
169
|
+
k, v = line.split("=", 1)
|
|
170
|
+
geom[k.strip()] = int(v.strip())
|
|
171
|
+
return geom["X"], geom["Y"], geom["WIDTH"], geom["HEIGHT"]
|
|
172
|
+
except Exception:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _find_window_rect_windows(title_pattern: str) -> Optional[Tuple[int, int, int, int]]:
|
|
177
|
+
"""Locate window coordinates on Windows using Win32 API if available."""
|
|
178
|
+
try:
|
|
179
|
+
import win32gui
|
|
180
|
+
matched_hwnd = None
|
|
181
|
+
|
|
182
|
+
def enum_windows_callback(hwnd, extra):
|
|
183
|
+
nonlocal matched_hwnd
|
|
184
|
+
if win32gui.IsWindowVisible(hwnd):
|
|
185
|
+
title = win32gui.GetWindowText(hwnd)
|
|
186
|
+
if title_pattern.lower() in title.lower():
|
|
187
|
+
matched_hwnd = hwnd
|
|
188
|
+
|
|
189
|
+
win32gui.EnumWindows(enum_windows_callback, None)
|
|
190
|
+
if matched_hwnd:
|
|
191
|
+
rect = win32gui.GetWindowRect(matched_hwnd)
|
|
192
|
+
x, y, x2, y2 = rect
|
|
193
|
+
return x, y, max(1, x2 - x), max(1, y2 - y)
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _generate_synthetic_desktop(title: str, width: int = 1280, height: int = 720) -> Image.Image:
|
|
200
|
+
"""Generate a mock Gazebo/RViz interface screen when desktop GDI is unavailable."""
|
|
201
|
+
arr = np.full((height, width, 3), 30, dtype=np.uint8)
|
|
202
|
+
# Title bar
|
|
203
|
+
arr[:35, :] = [45, 45, 48]
|
|
204
|
+
# Sidebar
|
|
205
|
+
arr[35:, :240] = [38, 38, 42]
|
|
206
|
+
# Viewport center grid
|
|
207
|
+
arr[35:, 240:] = [24, 24, 26]
|
|
208
|
+
img = Image.fromarray(arr)
|
|
209
|
+
return img
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def capture_desktop_window(
|
|
213
|
+
title_pattern: str = "Gazebo",
|
|
214
|
+
save_path: Optional[str | Path] = None,
|
|
215
|
+
) -> VisionResult:
|
|
216
|
+
"""Capture a specific desktop application window, falling back to full desktop or synthetic frame."""
|
|
217
|
+
t0 = time.perf_counter()
|
|
218
|
+
import mss
|
|
219
|
+
|
|
220
|
+
rect: Optional[Tuple[int, int, int, int]] = None
|
|
221
|
+
fallback: Optional[str] = None
|
|
222
|
+
img: Optional[Image.Image] = None
|
|
223
|
+
|
|
224
|
+
if sys.platform.startswith("linux"):
|
|
225
|
+
rect = _find_window_rect_x11(title_pattern)
|
|
226
|
+
elif sys.platform.startswith("win"):
|
|
227
|
+
rect = _find_window_rect_windows(title_pattern)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
# Use mss.MSS or mss.mss
|
|
231
|
+
mss_cls = getattr(mss, "MSS", None) or getattr(mss, "mss")
|
|
232
|
+
with mss_cls() as sct:
|
|
233
|
+
if rect is not None:
|
|
234
|
+
rx, ry, rw, rh = rect
|
|
235
|
+
monitor = {"top": max(0, ry), "left": max(0, rx), "width": max(10, rw), "height": max(10, rh)}
|
|
236
|
+
sct_img = sct.grab(monitor)
|
|
237
|
+
msg = f"Captured '{title_pattern}' window ({rw}x{rh})"
|
|
238
|
+
else:
|
|
239
|
+
monitor = sct.monitors[1] if len(sct.monitors) > 1 else sct.monitors[0]
|
|
240
|
+
sct_img = sct.grab(monitor)
|
|
241
|
+
fallback = "full_screen"
|
|
242
|
+
msg = f"Window '{title_pattern}' not found; captured full desktop ({monitor['width']}x{monitor['height']})"
|
|
243
|
+
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
fallback = "synthetic_desktop"
|
|
246
|
+
msg = f"Direct screen capture unavailable ({exc}); generated mock '{title_pattern}' desktop frame"
|
|
247
|
+
img = _generate_synthetic_desktop(title_pattern)
|
|
248
|
+
|
|
249
|
+
arr = np.asarray(img)
|
|
250
|
+
metrics = compute_optical_metrics(arr)
|
|
251
|
+
b64_str = encode_jpeg_base64(img)
|
|
252
|
+
|
|
253
|
+
if save_path:
|
|
254
|
+
out_path = Path(save_path)
|
|
255
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
img.save(out_path, format="JPEG", quality=85)
|
|
257
|
+
saved = str(out_path)
|
|
258
|
+
else:
|
|
259
|
+
saved = None
|
|
260
|
+
|
|
261
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
262
|
+
return VisionResult(
|
|
263
|
+
ok=True,
|
|
264
|
+
stream="desktop_window",
|
|
265
|
+
target=title_pattern.lower(),
|
|
266
|
+
width=img.width,
|
|
267
|
+
height=img.height,
|
|
268
|
+
jpeg_base64=b64_str,
|
|
269
|
+
metrics=metrics,
|
|
270
|
+
latency_ms=latency,
|
|
271
|
+
message=msg,
|
|
272
|
+
save_path=saved,
|
|
273
|
+
fallback=fallback,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def capture_gazebo_window(save_path: Optional[str | Path] = None) -> dict[str, Any]:
|
|
278
|
+
"""Capture active Gazebo desktop simulation window."""
|
|
279
|
+
return capture_desktop_window("Gazebo", save_path).to_dict()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def capture_rviz_window(save_path: Optional[str | Path] = None) -> dict[str, Any]:
|
|
283
|
+
"""Capture active RViz2 desktop GUI window."""
|
|
284
|
+
return capture_desktop_window("RViz", save_path).to_dict()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --------------------------------------------------------------------------- Robot Camera Stream
|
|
288
|
+
def generate_synthetic_frame(
|
|
289
|
+
width: int = 640,
|
|
290
|
+
height: int = 480,
|
|
291
|
+
motion_blur: int = 0,
|
|
292
|
+
fog_density: float = 0.0,
|
|
293
|
+
noise_std: float = 0.0,
|
|
294
|
+
) -> Image.Image:
|
|
295
|
+
"""Generate a realistic test camera frame for offline testing."""
|
|
296
|
+
# Create checkerboard floor with a horizon and colored block obstacle
|
|
297
|
+
img_arr = np.zeros((height, width, 3), dtype=np.float32)
|
|
298
|
+
horizon = int(height * 0.45)
|
|
299
|
+
# Sky
|
|
300
|
+
sky_luma = max(20.0, 180.0 * (1.0 - fog_density))
|
|
301
|
+
img_arr[:horizon, :, :] = [sky_luma * 0.8, sky_luma * 0.9, sky_luma]
|
|
302
|
+
# Ground tiles
|
|
303
|
+
for y in range(horizon, height):
|
|
304
|
+
row_factor = (y - horizon) / (height - horizon)
|
|
305
|
+
for x in range(width):
|
|
306
|
+
check = ((x // 30) + (y // 30)) % 2
|
|
307
|
+
col = 140.0 if check else 80.0
|
|
308
|
+
img_arr[y, x, :] = [col * 0.8, col, col * 0.8]
|
|
309
|
+
|
|
310
|
+
# Red/yellow obstacle in front
|
|
311
|
+
ox1, ox2 = int(width * 0.4), int(width * 0.6)
|
|
312
|
+
oy1, oy2 = int(height * 0.5), int(height * 0.8)
|
|
313
|
+
img_arr[oy1:oy2, ox1:ox2, :] = [210.0, 140.0, 30.0]
|
|
314
|
+
|
|
315
|
+
# Apply fog contrast attenuation
|
|
316
|
+
if fog_density > 0:
|
|
317
|
+
fog_color = 200.0
|
|
318
|
+
alpha = min(0.9, fog_density * 3.0)
|
|
319
|
+
img_arr = (1.0 - alpha) * img_arr + alpha * fog_color
|
|
320
|
+
|
|
321
|
+
# Add Gaussian noise
|
|
322
|
+
if noise_std > 0:
|
|
323
|
+
noise = np.random.normal(0, noise_std * 255.0, img_arr.shape)
|
|
324
|
+
img_arr = np.clip(img_arr + noise, 0.0, 255.0)
|
|
325
|
+
|
|
326
|
+
# Convert to PIL
|
|
327
|
+
pil_img = Image.fromarray(np.uint8(np.clip(img_arr, 0.0, 255.0)))
|
|
328
|
+
|
|
329
|
+
# Apply motion blur if requested
|
|
330
|
+
if motion_blur > 1:
|
|
331
|
+
from PIL import ImageFilter
|
|
332
|
+
pil_img = pil_img.filter(ImageFilter.BoxBlur(motion_blur))
|
|
333
|
+
|
|
334
|
+
return pil_img
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class CameraSampler:
|
|
338
|
+
"""Subscribes to ROS 2 camera topic or serves synthetic test frames when ROS is absent."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, topic: str = "/camera/image_raw") -> None:
|
|
341
|
+
self.topic = topic
|
|
342
|
+
self.last_frame: Optional[Image.Image] = None
|
|
343
|
+
self.last_timestamp: float = 0.0
|
|
344
|
+
self.fps: float = 0.0
|
|
345
|
+
self._subscription = None
|
|
346
|
+
self._init_ros()
|
|
347
|
+
|
|
348
|
+
def _init_ros(self) -> None:
|
|
349
|
+
try:
|
|
350
|
+
import rclpy # type: ignore[import-not-found]
|
|
351
|
+
from sensor_msgs.msg import Image as RosImage # type: ignore[import-not-found]
|
|
352
|
+
|
|
353
|
+
if not rclpy.ok():
|
|
354
|
+
rclpy.init(args=None)
|
|
355
|
+
node = rclpy.create_node("lang2sim_vision_sampler")
|
|
356
|
+
|
|
357
|
+
def _callback(msg: RosImage):
|
|
358
|
+
t_now = time.time()
|
|
359
|
+
dt = t_now - self.last_timestamp
|
|
360
|
+
if dt > 0:
|
|
361
|
+
inst_fps = 1.0 / dt
|
|
362
|
+
self.fps = 0.8 * self.fps + 0.2 * inst_fps if self.fps > 0 else inst_fps
|
|
363
|
+
self.last_timestamp = t_now
|
|
364
|
+
|
|
365
|
+
# Pure numpy decoding
|
|
366
|
+
w, h = msg.width, msg.height
|
|
367
|
+
enc = msg.encoding.lower()
|
|
368
|
+
data = np.frombuffer(msg.data, dtype=np.uint8)
|
|
369
|
+
if enc in ("rgb8", "bgr8"):
|
|
370
|
+
arr = data.reshape((h, w, 3))
|
|
371
|
+
if enc == "bgr8":
|
|
372
|
+
arr = arr[:, :, ::-1]
|
|
373
|
+
self.last_frame = Image.fromarray(arr)
|
|
374
|
+
elif enc == "mono8":
|
|
375
|
+
arr = data.reshape((h, w))
|
|
376
|
+
self.last_frame = Image.fromarray(arr).convert("RGB")
|
|
377
|
+
|
|
378
|
+
self._subscription = node.create_subscription(RosImage, self.topic, _callback, 10)
|
|
379
|
+
except Exception:
|
|
380
|
+
# ROS 2 not installed or daemon not running — lazy fallback
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
def sample(self, save_path: Optional[str | Path] = None) -> VisionResult:
|
|
384
|
+
t0 = time.perf_counter()
|
|
385
|
+
if self.last_frame is not None:
|
|
386
|
+
img = self.last_frame
|
|
387
|
+
msg = f"Sampled camera topic '{self.topic}' ({self.fps:.1f} FPS)"
|
|
388
|
+
fallback = None
|
|
389
|
+
else:
|
|
390
|
+
# Fallback to realistic synthetic frame
|
|
391
|
+
img = generate_synthetic_frame()
|
|
392
|
+
msg = f"ROS topic '{self.topic}' not active; generated synthetic camera perception frame"
|
|
393
|
+
fallback = "synthetic_camera"
|
|
394
|
+
|
|
395
|
+
arr = np.asarray(img)
|
|
396
|
+
metrics = compute_optical_metrics(arr)
|
|
397
|
+
b64_str = encode_jpeg_base64(img)
|
|
398
|
+
|
|
399
|
+
if save_path:
|
|
400
|
+
out_path = Path(save_path)
|
|
401
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
402
|
+
img.save(out_path, format="JPEG", quality=85)
|
|
403
|
+
saved = str(out_path)
|
|
404
|
+
else:
|
|
405
|
+
saved = None
|
|
406
|
+
|
|
407
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
408
|
+
return VisionResult(
|
|
409
|
+
ok=True,
|
|
410
|
+
stream="robot_camera",
|
|
411
|
+
target=self.topic,
|
|
412
|
+
width=img.width,
|
|
413
|
+
height=img.height,
|
|
414
|
+
jpeg_base64=b64_str,
|
|
415
|
+
metrics=metrics,
|
|
416
|
+
latency_ms=latency,
|
|
417
|
+
message=msg,
|
|
418
|
+
save_path=saved,
|
|
419
|
+
fallback=fallback,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
_GLOBAL_SAMPLER: Optional[CameraSampler] = None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def get_camera_sampler(topic: str = "/camera/image_raw") -> CameraSampler:
|
|
427
|
+
global _GLOBAL_SAMPLER
|
|
428
|
+
if _GLOBAL_SAMPLER is None or _GLOBAL_SAMPLER.topic != topic:
|
|
429
|
+
_GLOBAL_SAMPLER = CameraSampler(topic)
|
|
430
|
+
return _GLOBAL_SAMPLER
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
async def read_robot_camera(
|
|
434
|
+
topic: str = "/camera/image_raw",
|
|
435
|
+
save_path: Optional[str | Path] = None,
|
|
436
|
+
) -> dict[str, Any]:
|
|
437
|
+
"""Read latest frame from on-board robot camera topic."""
|
|
438
|
+
return get_camera_sampler(topic).sample(save_path).to_dict()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# --------------------------------------------------------------------------- CLI
|
|
442
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
443
|
+
parser = argparse.ArgumentParser(description="Lang2Sim Dual-Stream Visual Observer")
|
|
444
|
+
parser.add_argument(
|
|
445
|
+
"--target",
|
|
446
|
+
choices=["screen", "gazebo", "rviz", "camera"],
|
|
447
|
+
default="screen",
|
|
448
|
+
help="Capture target (default: screen)",
|
|
449
|
+
)
|
|
450
|
+
parser.add_argument("--out", help="Save captured frame to JPEG path")
|
|
451
|
+
parser.add_argument("--print-b64-len", action="store_true", help="Print length of base64 JPEG payload")
|
|
452
|
+
parser.add_argument("--topic", default="/camera/image_raw", help="Robot camera ROS topic")
|
|
453
|
+
args = parser.parse_args(argv)
|
|
454
|
+
|
|
455
|
+
if args.target == "camera":
|
|
456
|
+
res = get_camera_sampler(args.topic).sample(args.out)
|
|
457
|
+
elif args.target in ("gazebo", "rviz"):
|
|
458
|
+
res = capture_desktop_window(args.target.capitalize(), args.out)
|
|
459
|
+
else:
|
|
460
|
+
res = capture_desktop_window("Screen", args.out)
|
|
461
|
+
|
|
462
|
+
print(f"[{res.stream.upper()}] Status: {'OK' if res.ok else 'FAIL'}")
|
|
463
|
+
print(f"Target: {res.target} ({res.width}x{res.height})")
|
|
464
|
+
print(f"Message: {res.message}")
|
|
465
|
+
print(f"Metrics: {res.metrics}")
|
|
466
|
+
print(f"Latency: {res.latency_ms:.2f}ms")
|
|
467
|
+
if res.save_path:
|
|
468
|
+
print(f"Saved: {res.save_path}")
|
|
469
|
+
if args.print_b64_len:
|
|
470
|
+
print(f"Base64 length: {len(res.jpeg_base64)} characters")
|
|
471
|
+
return 0
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
if __name__ == "__main__":
|
|
475
|
+
import warnings
|
|
476
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*found in sys.modules.*")
|
|
477
|
+
sys.exit(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
World generation and spatial geometry solver package for Sim2Lang.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from lang2sim.world_gen.shapely_solver import ShapelySpatialSolver, solve_spatial_layout
|
|
6
|
+
from lang2sim.world_gen.map_slicer import ZeroSlamMapSlicer, slice_map_from_layout
|
|
7
|
+
from lang2sim.world_gen.catalog_loader import SceneCatalog, catalog
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ShapelySpatialSolver",
|
|
11
|
+
"solve_spatial_layout",
|
|
12
|
+
"ZeroSlamMapSlicer",
|
|
13
|
+
"slice_map_from_layout",
|
|
14
|
+
"SceneCatalog",
|
|
15
|
+
"catalog",
|
|
16
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Catalog Loader and Semantic Anchor Resolver for Sim2Lang.
|
|
3
|
+
File: lang2sim/world_gen/catalog_loader.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Any, Optional, List, Tuple
|
|
9
|
+
|
|
10
|
+
from lang2sim.core.models import Pose2D, Vector3D, ObjectSpec
|
|
11
|
+
from lang2sim.core.config import get_catalog_dir
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SceneCatalog:
|
|
15
|
+
"""Interface to inspect pre-built scenes, semantic anchors, and catalog models."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, catalog_dir: Optional[Path] = None):
|
|
18
|
+
self.catalog_dir = catalog_dir or get_catalog_dir()
|
|
19
|
+
self.catalog_file = self.catalog_dir / "scene_catalog.json"
|
|
20
|
+
self._data: Dict[str, Any] = self._load()
|
|
21
|
+
|
|
22
|
+
def _load(self) -> Dict[str, Any]:
|
|
23
|
+
if not self.catalog_file.exists():
|
|
24
|
+
raise FileNotFoundError(f"Catalog file not found: {self.catalog_file}")
|
|
25
|
+
with open(self.catalog_file, "r", encoding="utf-8") as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
|
|
28
|
+
def list_scenes(self) -> List[str]:
|
|
29
|
+
"""List all pre-built scene IDs (e.g. 'aws_small_warehouse', 'robotics_lab')."""
|
|
30
|
+
return list(self._data.get("scenes", {}).keys())
|
|
31
|
+
|
|
32
|
+
def get_scene(self, scene_id: str) -> Dict[str, Any]:
|
|
33
|
+
"""Retrieve full metadata for a scene."""
|
|
34
|
+
scenes = self._data.get("scenes", {})
|
|
35
|
+
if scene_id not in scenes:
|
|
36
|
+
raise KeyError(f"Scene '{scene_id}' not in catalog. Available: {self.list_scenes()}")
|
|
37
|
+
return scenes[scene_id]
|
|
38
|
+
|
|
39
|
+
def get_scene_dimensions(self, scene_id: str) -> Vector3D:
|
|
40
|
+
"""Returns the 3D bounding dimensions of a scene."""
|
|
41
|
+
scene = self.get_scene(scene_id)
|
|
42
|
+
d = scene["dimensions"]
|
|
43
|
+
return Vector3D(x=float(d["x"]), y=float(d["y"]), z=float(d["z"]))
|
|
44
|
+
|
|
45
|
+
def resolve_anchor(self, scene_id: str, anchor_name: str) -> Pose2D:
|
|
46
|
+
"""
|
|
47
|
+
Resolves a semantic target anchor (e.g., 'rack_5', 'dock') into an exact Pose2D.
|
|
48
|
+
Eliminates Nav2 'Goal in collision' aborts.
|
|
49
|
+
"""
|
|
50
|
+
scene = self.get_scene(scene_id)
|
|
51
|
+
anchors = scene.get("anchors", {})
|
|
52
|
+
if anchor_name not in anchors:
|
|
53
|
+
raise KeyError(
|
|
54
|
+
f"Anchor '{anchor_name}' not found in scene '{scene_id}'. "
|
|
55
|
+
f"Available anchors: {list(anchors.keys())}"
|
|
56
|
+
)
|
|
57
|
+
raw_anchor = anchors[anchor_name]
|
|
58
|
+
return Pose2D.from_anchor(raw_anchor)
|
|
59
|
+
|
|
60
|
+
def list_models(self) -> List[str]:
|
|
61
|
+
"""List all physical object blueprints in the catalog."""
|
|
62
|
+
return list(self._data.get("models", {}).keys())
|
|
63
|
+
|
|
64
|
+
def get_model_spec(self, model_id: str, object_id: str, pose: Optional[Pose2D] = None) -> ObjectSpec:
|
|
65
|
+
"""
|
|
66
|
+
Builds a typed ObjectSpec from catalog blueprints, ensuring exact physical dimensions and mass.
|
|
67
|
+
"""
|
|
68
|
+
models = self._data.get("models", {})
|
|
69
|
+
if model_id not in models:
|
|
70
|
+
raise KeyError(f"Model '{model_id}' not found in catalog. Available: {self.list_models()}")
|
|
71
|
+
m = models[model_id]
|
|
72
|
+
dims = m["dimensions"]
|
|
73
|
+
return ObjectSpec(
|
|
74
|
+
id=object_id,
|
|
75
|
+
model_name=model_id,
|
|
76
|
+
dimensions=Vector3D(x=float(dims["x"]), y=float(dims["y"]), z=float(dims["z"])),
|
|
77
|
+
pose=pose or Pose2D(x=0.0, y=0.0, yaw=0.0),
|
|
78
|
+
is_static=bool(m.get("is_static", True)),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Global singleton instance
|
|
83
|
+
catalog = SceneCatalog()
|