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/__init__.py +82 -0
- vlalab/adapters/__init__.py +10 -0
- vlalab/adapters/converter.py +146 -0
- vlalab/adapters/dp_adapter.py +181 -0
- vlalab/adapters/groot_adapter.py +148 -0
- vlalab/apps/__init__.py +1 -0
- vlalab/apps/streamlit/__init__.py +1 -0
- vlalab/apps/streamlit/app.py +103 -0
- vlalab/apps/streamlit/pages/__init__.py +1 -0
- vlalab/apps/streamlit/pages/dataset_viewer.py +322 -0
- vlalab/apps/streamlit/pages/inference_viewer.py +360 -0
- vlalab/apps/streamlit/pages/latency_viewer.py +256 -0
- vlalab/cli.py +137 -0
- vlalab/core.py +672 -0
- vlalab/logging/__init__.py +10 -0
- vlalab/logging/jsonl_writer.py +114 -0
- vlalab/logging/run_loader.py +216 -0
- vlalab/logging/run_logger.py +343 -0
- vlalab/schema/__init__.py +17 -0
- vlalab/schema/run.py +162 -0
- vlalab/schema/step.py +177 -0
- vlalab/viz/__init__.py +9 -0
- vlalab/viz/mpl_fonts.py +161 -0
- vlalab-0.1.0.dist-info/METADATA +443 -0
- vlalab-0.1.0.dist-info/RECORD +29 -0
- vlalab-0.1.0.dist-info/WHEEL +5 -0
- vlalab-0.1.0.dist-info/entry_points.txt +2 -0
- vlalab-0.1.0.dist-info/licenses/LICENSE +21 -0
- vlalab-0.1.0.dist-info/top_level.txt +1 -0
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)
|