vlalab 0.1.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.
vlalab/core.py ADDED
@@ -0,0 +1,672 @@
1
+ """
2
+ VLA-Lab Core API
3
+
4
+ Simple, intuitive API for VLA deployment logging and debugging.
5
+
6
+ Usage:
7
+ import vlalab
8
+
9
+ # Initialize a run
10
+ run = vlalab.init(
11
+ project="pick_and_place",
12
+ config={
13
+ "model": "diffusion_policy",
14
+ "action_horizon": 8,
15
+ },
16
+ )
17
+
18
+ # Log a step
19
+ vlalab.log({
20
+ "state": [0.5, 0.2, 0.3, 0, 0, 0, 1, 1.0],
21
+ "action": [0.51, 0.21, 0.31, 0, 0, 0, 1, 1.0],
22
+ "images": {"front": image_array},
23
+ "inference_latency_ms": 32.1,
24
+ })
25
+
26
+ # Finish
27
+ vlalab.finish()
28
+ """
29
+
30
+ import os
31
+ import atexit
32
+ import inspect
33
+ from pathlib import Path
34
+ from datetime import datetime
35
+ from typing import Optional, Dict, Any, Union, List
36
+ import numpy as np
37
+
38
+
39
+ def _find_project_root(start_path: Optional[Path] = None, max_depth: int = 10) -> Optional[Path]:
40
+ """
41
+ Auto-detect project root directory.
42
+
43
+ Searches upward from the vlalab.init() call site for project markers:
44
+ - .git/ (Git 仓库)
45
+ - setup.py (Python 包)
46
+ - pyproject.toml (现代 Python 项目)
47
+ - README.md (项目文档)
48
+
49
+ Args:
50
+ start_path: 起始路径(如果为 None,则从调用者文件位置开始)
51
+ max_depth: 最大向上查找深度
52
+
53
+ Returns:
54
+ 项目根目录路径,如果未找到则返回 None
55
+ """
56
+ if start_path is None:
57
+ # 获取调用者的文件路径
58
+ frame = inspect.currentframe()
59
+ try:
60
+ # 向上查找调用栈,找到第一个不在 vlalab 包内的调用者
61
+ while frame:
62
+ frame = frame.f_back
63
+ if frame is None:
64
+ break
65
+ filename = frame.f_code.co_filename
66
+ if 'vlalab' not in filename and filename != '<stdin>':
67
+ start_path = Path(filename).parent.resolve()
68
+ break
69
+ finally:
70
+ del frame
71
+
72
+ if start_path is None:
73
+ # 如果无法获取调用者路径,使用当前工作目录
74
+ start_path = Path.cwd()
75
+
76
+ # 确保是绝对路径
77
+ current = Path(start_path).resolve()
78
+
79
+ # 策略:优先找 .git(最可靠),找不到再用 pyproject.toml / setup.py
80
+ # 注意:README.md 不单独作为判断条件,因为子目录也可能有
81
+
82
+ # 第一遍:只找 .git(Git 仓库根目录,最可靠)
83
+ search_path = current
84
+ depth = 0
85
+ while depth < max_depth:
86
+ if (search_path / '.git').exists():
87
+ return search_path
88
+ parent = search_path.parent
89
+ if parent == search_path:
90
+ break
91
+ search_path = parent
92
+ depth += 1
93
+
94
+ # 第二遍:找 pyproject.toml 或 setup.py(Python 包根目录)
95
+ search_path = current
96
+ depth = 0
97
+ while depth < max_depth:
98
+ if (search_path / 'pyproject.toml').exists() or (search_path / 'setup.py').exists():
99
+ return search_path
100
+ parent = search_path.parent
101
+ if parent == search_path:
102
+ break
103
+ search_path = parent
104
+ depth += 1
105
+
106
+ # 都没找到,返回起始目录
107
+ return start_path
108
+
109
+
110
+ def _ensure_gitignore(runs_dir: Path) -> None:
111
+ """
112
+ 确保 vlalab_runs 目录内有 .gitignore 文件,自动排除日志文件。
113
+
114
+ 这样用户不需要手动修改项目的 .gitignore。
115
+ """
116
+ runs_dir = Path(runs_dir)
117
+ if not runs_dir.exists():
118
+ runs_dir.mkdir(parents=True, exist_ok=True)
119
+
120
+ gitignore_path = runs_dir / ".gitignore"
121
+ if not gitignore_path.exists():
122
+ gitignore_content = """\
123
+ # Auto-generated by VLA-Lab
124
+ # Ignore all run data in this directory
125
+ *
126
+ !.gitignore
127
+ """
128
+ try:
129
+ gitignore_path.write_text(gitignore_content)
130
+ except Exception:
131
+ pass # 静默失败,不影响主流程
132
+
133
+
134
+ class Config:
135
+ """
136
+ Configuration object that allows attribute-style access.
137
+
138
+ Supports both dict-style (config["key"]) and attribute-style (config.key) access.
139
+ """
140
+
141
+ def __init__(self, config_dict: Optional[Dict[str, Any]] = None):
142
+ self._data = config_dict or {}
143
+ # Set attributes for direct access
144
+ for key, value in self._data.items():
145
+ setattr(self, key, value)
146
+
147
+ def __getitem__(self, key: str) -> Any:
148
+ return self._data[key]
149
+
150
+ def __setitem__(self, key: str, value: Any):
151
+ self._data[key] = value
152
+ setattr(self, key, value)
153
+
154
+ def __contains__(self, key: str) -> bool:
155
+ return key in self._data
156
+
157
+ def get(self, key: str, default: Any = None) -> Any:
158
+ return self._data.get(key, default)
159
+
160
+ def update(self, d: Dict[str, Any]):
161
+ self._data.update(d)
162
+ for key, value in d.items():
163
+ setattr(self, key, value)
164
+
165
+ def to_dict(self) -> Dict[str, Any]:
166
+ return dict(self._data)
167
+
168
+ def __repr__(self) -> str:
169
+ return f"Config({self._data})"
170
+
171
+
172
+ class Run:
173
+ """
174
+ A single run/experiment session.
175
+
176
+ This is returned by vlalab.init() and provides:
177
+ - config: Access to configuration via run.config.key
178
+ - log(): Log a step
179
+ - log_image(): Log an image
180
+ - finish(): Finish the run
181
+ """
182
+
183
+ def __init__(
184
+ self,
185
+ project: str = "default",
186
+ name: Optional[str] = None,
187
+ config: Optional[Dict[str, Any]] = None,
188
+ dir: Optional[str] = None,
189
+ tags: Optional[List[str]] = None,
190
+ notes: Optional[str] = None,
191
+ mode: str = "online", # "online", "offline", "disabled"
192
+ ):
193
+ """
194
+ Initialize a run.
195
+
196
+ Args:
197
+ project: Project name (creates subdirectory)
198
+ name: Run name (auto-generated if None)
199
+ config: Configuration dict (accessible via run.config.key)
200
+ dir: Base directory for runs (default: ./vlalab_runs)
201
+ tags: Optional tags for the run
202
+ notes: Optional notes about the run
203
+ mode: "online" (future cloud sync), "offline" (local only), "disabled" (no logging)
204
+ """
205
+ from vlalab.logging import RunLogger
206
+
207
+ self.project = project
208
+ self.mode = mode
209
+ self.tags = tags or []
210
+ self.notes = notes or ""
211
+
212
+ # Generate run name if not provided
213
+ if name is None:
214
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
215
+ name = f"run_{timestamp}"
216
+ self.name = name
217
+
218
+ # Setup config object for attribute access
219
+ self.config = Config(config)
220
+
221
+ # Store step counter
222
+ self._step_idx = 0
223
+ self._disabled = (mode == "disabled")
224
+
225
+ if self._disabled:
226
+ self._logger = None
227
+ print(f"[vlalab] Run disabled, no logging will occur")
228
+ return
229
+
230
+ # Setup run directory
231
+ # 优先级:1. 显式指定的 dir 参数 2. VLALAB_DIR 环境变量 3. 自动检测项目根目录
232
+ if dir is not None:
233
+ base_dir = dir
234
+ elif "VLALAB_DIR" in os.environ:
235
+ base_dir = os.environ.get("VLALAB_DIR")
236
+ else:
237
+ # Auto-detect project root directory
238
+ project_root = _find_project_root()
239
+ if project_root:
240
+ # 在项目根目录下创建 vlalab_runs/ 目录
241
+ base_dir = project_root / "vlalab_runs"
242
+ else:
243
+ # 如果无法检测,使用当前工作目录
244
+ base_dir = Path.cwd() / "vlalab_runs"
245
+
246
+ self.run_dir = Path(base_dir) / project / name
247
+
248
+ # 确保 vlalab_runs 目录内有 .gitignore(自动排除,无需用户手动配置)
249
+ _ensure_gitignore(Path(base_dir))
250
+
251
+ # Extract model/task info from config if available
252
+ model_name = self.config.get("model", self.config.get("model_name", "unknown"))
253
+ task_name = self.config.get("task", self.config.get("task_name", project))
254
+ robot_name = self.config.get("robot", self.config.get("robot_name", "unknown"))
255
+
256
+ # Initialize the underlying RunLogger
257
+ self._logger = RunLogger(
258
+ run_dir=self.run_dir,
259
+ model_name=model_name,
260
+ model_type=self.config.get("model_type"),
261
+ model_path=self.config.get("model_path"),
262
+ task_name=task_name,
263
+ task_prompt=self.config.get("task_prompt"),
264
+ robot_name=robot_name,
265
+ robot_type=self.config.get("robot_type"),
266
+ inference_freq=self.config.get("inference_freq"),
267
+ action_dim=self.config.get("action_dim"),
268
+ action_horizon=self.config.get("action_horizon"),
269
+ server_config=self.config.get("server_config", {}),
270
+ client_config=self.config.get("client_config", {}),
271
+ )
272
+
273
+ # Store full config in metadata
274
+ self._logger.meta.extra = {"config": self.config.to_dict()}
275
+ self._logger._save_meta()
276
+
277
+ print(f"[vlalab] 🚀 Run initialized: {self.run_dir}")
278
+ print(f"[vlalab] 📊 Project: {project} | Name: {name}")
279
+
280
+ def log(
281
+ self,
282
+ data: Dict[str, Any],
283
+ step: Optional[int] = None,
284
+ commit: bool = True,
285
+ ):
286
+ """
287
+ Log a step with flexible data format.
288
+
289
+ Args:
290
+ data: Dict containing any of:
291
+ - state: Robot state vector
292
+ - pose: Robot pose [x, y, z, qx, qy, qz, qw]
293
+ - gripper: Gripper state (0-1)
294
+ - action: Action values (single or chunk)
295
+ - images: Dict of camera_name -> image_array/base64
296
+ - *_latency_ms: Any timing field ending in _latency_ms
297
+ - Any other timing fields (client_send, server_recv, etc.)
298
+ step: Step index (auto-incremented if None)
299
+ commit: Whether to write immediately (True) or batch
300
+
301
+ Examples:
302
+ # Simple logging
303
+ run.log({"state": [0.5, 0.2], "action": [0.1, 0.2]})
304
+
305
+ # With images
306
+ run.log({
307
+ "state": [0.5, 0.2],
308
+ "action": [0.1, 0.2],
309
+ "images": {"front": img_array},
310
+ })
311
+
312
+ # With timing
313
+ run.log({
314
+ "state": [0.5, 0.2],
315
+ "inference_latency_ms": 32.1,
316
+ "transport_latency_ms": 5.2,
317
+ })
318
+ """
319
+ if self._disabled or self._logger is None:
320
+ return
321
+
322
+ # Determine step index
323
+ if step is not None:
324
+ current_step = step
325
+ else:
326
+ current_step = self._step_idx
327
+ self._step_idx += 1
328
+
329
+ # Extract known fields
330
+ state = data.get("state")
331
+ pose = data.get("pose")
332
+ gripper = data.get("gripper")
333
+ action = data.get("action")
334
+ images = data.get("images")
335
+ prompt = data.get("prompt")
336
+
337
+ # Extract timing fields
338
+ timing = {}
339
+ timing_keys = [
340
+ "client_send", "server_recv", "infer_start", "infer_end", "send_timestamp",
341
+ "transport_latency_ms", "inference_latency_ms", "total_latency_ms",
342
+ "message_interval_ms", "preprocess_ms", "postprocess_ms",
343
+ ]
344
+ for key in timing_keys:
345
+ if key in data:
346
+ timing[key] = data[key]
347
+
348
+ # Also capture any custom *_latency_ms or *_ms fields
349
+ for key, value in data.items():
350
+ if (key.endswith("_latency_ms") or key.endswith("_ms")) and key not in timing:
351
+ timing[key] = value
352
+
353
+ # Extract any extra tags
354
+ tags = {}
355
+ known_keys = {"state", "pose", "gripper", "action", "images", "prompt"} | set(timing_keys)
356
+ for key, value in data.items():
357
+ if key not in known_keys and not key.endswith("_ms"):
358
+ # Store as tag if it's a simple value
359
+ if isinstance(value, (int, float, str, bool)):
360
+ tags[key] = value
361
+
362
+ # Log the step
363
+ self._logger.log_step(
364
+ step_idx=current_step,
365
+ state=state,
366
+ pose=pose,
367
+ gripper=gripper,
368
+ action=action,
369
+ images=images,
370
+ timing=timing if timing else None,
371
+ prompt=prompt,
372
+ tags=tags if tags else None,
373
+ )
374
+
375
+ def log_image(
376
+ self,
377
+ camera_name: str,
378
+ image: Union[np.ndarray, str],
379
+ step: Optional[int] = None,
380
+ ):
381
+ """
382
+ Log a single image.
383
+
384
+ Args:
385
+ camera_name: Name of the camera
386
+ image: Image array (H, W, C) or base64 string
387
+ step: Step index (uses current step if None)
388
+ """
389
+ if self._disabled or self._logger is None:
390
+ return
391
+
392
+ current_step = step if step is not None else self._step_idx
393
+ self.log({"images": {camera_name: image}}, step=current_step, commit=True)
394
+
395
+ def finish(self):
396
+ """Finish the run and save all data."""
397
+ if self._logger is not None:
398
+ self._logger.close()
399
+ print(f"[vlalab] ✅ Run finished: {self._logger.step_count} steps logged")
400
+ print(f"[vlalab] 📁 Data saved to: {self.run_dir}")
401
+
402
+ def __enter__(self):
403
+ return self
404
+
405
+ def __exit__(self, exc_type, exc_val, exc_tb):
406
+ self.finish()
407
+ return False
408
+
409
+
410
+ # Global state
411
+ _current_run: Optional[Run] = None
412
+
413
+
414
+ def init(
415
+ project: str = "default",
416
+ name: Optional[str] = None,
417
+ config: Optional[Dict[str, Any]] = None,
418
+ dir: Optional[str] = None,
419
+ tags: Optional[List[str]] = None,
420
+ notes: Optional[str] = None,
421
+ mode: str = "offline",
422
+ ) -> Run:
423
+ """
424
+ Initialize a new VLA-Lab run.
425
+
426
+ This is the main entry point for VLA-Lab.
427
+
428
+ Args:
429
+ project: Project name (creates subdirectory)
430
+ name: Run name (auto-generated with timestamp if None)
431
+ config: Configuration dict, accessible via run.config.key
432
+ dir: Base directory for runs (default: ./vlalab_runs or $VLALAB_DIR)
433
+ tags: Optional tags for the run
434
+ notes: Optional notes about the run
435
+ mode: "offline" (local only), "disabled" (no logging)
436
+
437
+ Returns:
438
+ Run object with config attribute and log() method
439
+
440
+ Example:
441
+ import vlalab
442
+
443
+ run = vlalab.init(
444
+ project="pick_and_place",
445
+ config={
446
+ "model": "diffusion_policy",
447
+ "action_horizon": 8,
448
+ "inference_freq": 10,
449
+ },
450
+ )
451
+
452
+ print(f"Action horizon: {run.config.action_horizon}")
453
+
454
+ # Log steps
455
+ vlalab.log({"state": [...], "action": [...]})
456
+
457
+ # Finish
458
+ vlalab.finish()
459
+ """
460
+ global _current_run
461
+
462
+ # Close previous run if exists
463
+ if _current_run is not None:
464
+ _current_run.finish()
465
+
466
+ # Create new run
467
+ _current_run = Run(
468
+ project=project,
469
+ name=name,
470
+ config=config,
471
+ dir=dir,
472
+ tags=tags,
473
+ notes=notes,
474
+ mode=mode,
475
+ )
476
+
477
+ # Register atexit handler to auto-finish
478
+ atexit.register(_auto_finish)
479
+
480
+ return _current_run
481
+
482
+
483
+ def log(
484
+ data: Dict[str, Any],
485
+ step: Optional[int] = None,
486
+ commit: bool = True,
487
+ ):
488
+ """
489
+ Log a step to the current run.
490
+
491
+ Must call vlalab.init() first.
492
+
493
+ Args:
494
+ data: Dict containing state, action, images, timing, etc.
495
+ step: Step index (auto-incremented if None)
496
+ commit: Whether to write immediately
497
+
498
+ Example:
499
+ vlalab.log({
500
+ "state": [0.5, 0.2, 0.3, 0, 0, 0, 1, 1.0],
501
+ "action": [0.51, 0.21, 0.31, 0, 0, 0, 1, 1.0],
502
+ "images": {"front": image_array},
503
+ "inference_latency_ms": 32.1,
504
+ })
505
+ """
506
+ global _current_run
507
+
508
+ if _current_run is None:
509
+ raise RuntimeError("vlalab.init() must be called before vlalab.log()")
510
+
511
+ _current_run.log(data, step=step, commit=commit)
512
+
513
+
514
+ def log_image(
515
+ camera_name: str,
516
+ image: Union[np.ndarray, str],
517
+ step: Optional[int] = None,
518
+ ):
519
+ """
520
+ Log a single image to the current run.
521
+
522
+ Args:
523
+ camera_name: Name of the camera
524
+ image: Image array (H, W, C) or base64 string
525
+ step: Step index (uses current step if None)
526
+ """
527
+ global _current_run
528
+
529
+ if _current_run is None:
530
+ raise RuntimeError("vlalab.init() must be called before vlalab.log_image()")
531
+
532
+ _current_run.log_image(camera_name, image, step=step)
533
+
534
+
535
+ def finish():
536
+ """
537
+ Finish the current run.
538
+
539
+ This is called automatically on exit, but can be called manually.
540
+ """
541
+ global _current_run
542
+
543
+ if _current_run is not None:
544
+ _current_run.finish()
545
+ _current_run = None
546
+
547
+
548
+ def _auto_finish():
549
+ """Auto-finish handler for atexit."""
550
+ global _current_run
551
+ if _current_run is not None:
552
+ try:
553
+ _current_run.finish()
554
+ except Exception:
555
+ pass
556
+ _current_run = None
557
+
558
+
559
+ def get_run() -> Optional[Run]:
560
+ """Get the current active run, or None if no run is active."""
561
+ return _current_run
562
+
563
+
564
+ # ============================================================================
565
+ # Run Discovery - 和 init() 共享同一套目录配置
566
+ # ============================================================================
567
+
568
+ def get_runs_dir(dir: Optional[str] = None) -> Path:
569
+ """
570
+ Get the runs directory.
571
+
572
+ For visualization tools, this detects the project root from the current
573
+ working directory (where the user runs the command).
574
+
575
+ Args:
576
+ dir: Override directory (default: auto-detect project root or $VLALAB_DIR or ./vlalab_runs)
577
+
578
+ Returns:
579
+ Path to the runs directory
580
+ """
581
+ if dir is not None:
582
+ base_dir = dir
583
+ elif "VLALAB_DIR" in os.environ:
584
+ base_dir = os.environ.get("VLALAB_DIR")
585
+ else:
586
+ # 对于可视化/查询工具,从当前工作目录检测项目根目录
587
+ # (用户运行 streamlit 或其他可视化命令的目录)
588
+ project_root = _find_project_root(start_path=Path.cwd())
589
+ if project_root:
590
+ base_dir = project_root / "vlalab_runs"
591
+ else:
592
+ base_dir = Path.cwd() / "vlalab_runs"
593
+
594
+ return Path(base_dir)
595
+
596
+
597
+ def list_projects(dir: Optional[str] = None) -> List[str]:
598
+ """
599
+ List all projects in the runs directory.
600
+
601
+ Args:
602
+ dir: Override directory (default: $VLALAB_DIR or ./vlalab_runs)
603
+
604
+ Returns:
605
+ List of project names
606
+ """
607
+ runs_dir = get_runs_dir(dir)
608
+ if not runs_dir.exists():
609
+ return []
610
+
611
+ projects = []
612
+ for item in runs_dir.iterdir():
613
+ if item.is_dir() and not item.name.startswith("."):
614
+ projects.append(item.name)
615
+
616
+ return sorted(projects)
617
+
618
+
619
+ def list_runs(
620
+ project: Optional[str] = None,
621
+ dir: Optional[str] = None,
622
+ ) -> List[Path]:
623
+ """
624
+ List all runs, optionally filtered by project.
625
+
626
+ Uses the same directory as init() to ensure consistency.
627
+
628
+ Args:
629
+ project: Filter by project name (None for all projects)
630
+ dir: Override directory (default: $VLALAB_DIR or ./vlalab_runs)
631
+
632
+ Returns:
633
+ List of run paths, sorted by modification time (newest first)
634
+
635
+ Example:
636
+ import vlalab
637
+
638
+ # List all runs
639
+ runs = vlalab.list_runs()
640
+
641
+ # List runs in a specific project
642
+ runs = vlalab.list_runs(project="pick_and_place")
643
+
644
+ for run_path in runs:
645
+ print(run_path.name)
646
+ """
647
+ runs_dir = get_runs_dir(dir)
648
+ if not runs_dir.exists():
649
+ return []
650
+
651
+ runs = []
652
+
653
+ def is_run_dir(path: Path) -> bool:
654
+ return (path / "meta.json").exists() or (path / "steps.jsonl").exists()
655
+
656
+ if project:
657
+ # List runs in a specific project
658
+ project_dir = runs_dir / project
659
+ if project_dir.exists() and project_dir.is_dir():
660
+ for item in project_dir.iterdir():
661
+ if item.is_dir() and is_run_dir(item):
662
+ runs.append(item)
663
+ else:
664
+ # List all runs across all projects
665
+ for project_item in runs_dir.iterdir():
666
+ if project_item.is_dir() and not project_item.name.startswith("."):
667
+ for run_item in project_item.iterdir():
668
+ if run_item.is_dir() and is_run_dir(run_item):
669
+ runs.append(run_item)
670
+
671
+ # Sort by modification time (newest first)
672
+ return sorted(runs, key=lambda p: p.stat().st_mtime, reverse=True)
@@ -0,0 +1,10 @@
1
+ """
2
+ VLA-Lab Logging Module
3
+
4
+ Provides unified logging interface for VLA real-world deployment.
5
+ """
6
+
7
+ from vlalab.logging.run_logger import RunLogger
8
+ from vlalab.logging.jsonl_writer import JsonlWriter
9
+
10
+ __all__ = ["RunLogger", "JsonlWriter"]