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.
Files changed (65) hide show
  1. lang2sim/__init__.py +47 -0
  2. lang2sim/agent/__init__.py +5 -0
  3. lang2sim/agent/nemotron_client.py +109 -0
  4. lang2sim/agent/react_loop.py +848 -0
  5. lang2sim/agent/tools.py +998 -0
  6. lang2sim/cli/app.py +183 -0
  7. lang2sim/cli/screens/__init__.py +8 -0
  8. lang2sim/cli/screens/command_palette_modal.py +146 -0
  9. lang2sim/cli/screens/landing_screen.py +96 -0
  10. lang2sim/cli/screens/sessions_modal.py +236 -0
  11. lang2sim/cli/screens/workspace_screen.py +709 -0
  12. lang2sim/cli/widgets/__init__.py +16 -0
  13. lang2sim/cli/widgets/action_pill.py +67 -0
  14. lang2sim/cli/widgets/command_card.py +265 -0
  15. lang2sim/cli/widgets/plan_card.py +106 -0
  16. lang2sim/cli/widgets/prompt_card.py +96 -0
  17. lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
  18. lang2sim/cli/widgets/thought_box.py +64 -0
  19. lang2sim/compiler/__init__.py +15 -0
  20. lang2sim/compiler/sdf_compiler.py +65 -0
  21. lang2sim/compiler/sir_generator.py +46 -0
  22. lang2sim/compiler/sir_validator.py +87 -0
  23. lang2sim/core/__init__.py +50 -0
  24. lang2sim/core/config.py +116 -0
  25. lang2sim/core/models.py +279 -0
  26. lang2sim/core/session_manager.py +189 -0
  27. lang2sim/perturbation/__init__.py +50 -0
  28. lang2sim/perturbation/_compat.py +19 -0
  29. lang2sim/perturbation/gazebo_bridge.py +520 -0
  30. lang2sim/perturbation/mcp_server.py +129 -0
  31. lang2sim/perturbation/routers/__init__.py +19 -0
  32. lang2sim/perturbation/routers/world.py +110 -0
  33. lang2sim/perturbation/sdf_snippets.py +112 -0
  34. lang2sim/runtime/__init__.py +7 -0
  35. lang2sim/runtime/control_api/__init__.py +7 -0
  36. lang2sim/runtime/control_api/client.py +187 -0
  37. lang2sim/runtime/control_api/server.py +504 -0
  38. lang2sim/runtime/display.py +141 -0
  39. lang2sim/runtime/docker_runner.py +286 -0
  40. lang2sim/runtime/doctor.py +220 -0
  41. lang2sim/runtime/hardware_probe.py +144 -0
  42. lang2sim/runtime/health_monitor.py +66 -0
  43. lang2sim/runtime/launch_builder.py +137 -0
  44. lang2sim/runtime/nav2_dispatcher.py +215 -0
  45. lang2sim/runtime/platform.py +137 -0
  46. lang2sim/runtime/ros_bridge.py +183 -0
  47. lang2sim/runtime/run_folder.py +86 -0
  48. lang2sim/runtime/self_healing.py +387 -0
  49. lang2sim/runtime/session.py +136 -0
  50. lang2sim/runtime/world_check.py +197 -0
  51. lang2sim/telemetry/__init__.py +85 -0
  52. lang2sim/telemetry/nqi_scorer.py +645 -0
  53. lang2sim/telemetry/ros_subscribers.py +489 -0
  54. lang2sim/telemetry/trajectory.py +258 -0
  55. lang2sim/telemetry/vision.py +477 -0
  56. lang2sim/world_gen/__init__.py +16 -0
  57. lang2sim/world_gen/catalog_loader.py +83 -0
  58. lang2sim/world_gen/map_slicer.py +131 -0
  59. lang2sim/world_gen/shapely_solver.py +256 -0
  60. lang2sim-2.0.0.dist-info/METADATA +53 -0
  61. lang2sim-2.0.0.dist-info/RECORD +65 -0
  62. lang2sim-2.0.0.dist-info/WHEEL +5 -0
  63. lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
  64. lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
  65. 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()