phys-ai-dataconverter 0.1.0__tar.gz
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.
- phys_ai_dataconverter-0.1.0/LICENSE +21 -0
- phys_ai_dataconverter-0.1.0/PKG-INFO +138 -0
- phys_ai_dataconverter-0.1.0/README.md +102 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/__init__.py +8 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/__main__.py +5 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/align.py +45 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/cli.py +117 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/convert.py +227 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/episode.py +264 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/images.py +40 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/model.py +67 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/readers/__init__.py +33 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/readers/mcap_ros2.py +90 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/verify.py +65 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/writers/__init__.py +8 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter/writers/lerobot_v3.py +264 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter.egg-info/PKG-INFO +138 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter.egg-info/SOURCES.txt +23 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter.egg-info/dependency_links.txt +1 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter.egg-info/entry_points.txt +2 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter.egg-info/requires.txt +9 -0
- phys_ai_dataconverter-0.1.0/phys_ai_dataconverter.egg-info/top_level.txt +1 -0
- phys_ai_dataconverter-0.1.0/pyproject.toml +53 -0
- phys_ai_dataconverter-0.1.0/setup.cfg +4 -0
- phys_ai_dataconverter-0.1.0/tests/test_convert.py +390 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anand Krishniyer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phys-ai-dataconverter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert robot recordings (ROS 2 MCAP) into training datasets (LeRobot v3): cameras, joint state and actions aligned by capture time
|
|
5
|
+
Author: Anand Krishniyer
|
|
6
|
+
Author-email: akrishniyer@gmail.com
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/akrishni/phys-ai-dataconverter
|
|
9
|
+
Project-URL: Documentation, https://github.com/akrishni/phys-ai-dataconverter#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/akrishni/phys-ai-dataconverter/issues
|
|
11
|
+
Keywords: robotics,dataset,mcap,ros2,rosbag,lerobot,imitation-learning,conversion
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: phys-ai-dataprofiler>=0.4.0
|
|
28
|
+
Requires-Dist: mcap>=1.0
|
|
29
|
+
Requires-Dist: mcap-ros2-support>=0.5
|
|
30
|
+
Requires-Dist: av>=12
|
|
31
|
+
Requires-Dist: numpy
|
|
32
|
+
Requires-Dist: pyarrow>=14
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# phys-ai-dataconverter
|
|
38
|
+
|
|
39
|
+
Convert robot recordings into datasets you can train on. Version 0.1 turns ROS 2 teleoperation recordings
|
|
40
|
+
(MCAP) into [LeRobot](https://github.com/huggingface/lerobot) v3 datasets. It pairs camera frames, joint
|
|
41
|
+
state and the operator's commands by capture time, and refuses to fill gaps with stale values.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install phys-ai-dataconverter
|
|
45
|
+
phys-ai-dataconverter recordings/ -o ./pick_bottles --task "pick up the bottles" --check preset:teleop-imitation
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each `.mcap` file becomes one episode. `--check` then profiles the result's data table and videos with
|
|
49
|
+
[phys-ai-dataprofiler](https://github.com/akrishni/phys-ai-dataprofiler) and checks it against a
|
|
50
|
+
requirements file or profiler preset, saving the report in `meta/check.md`. The output works unchanged with LeRobot's `LeRobotDataset` and `lerobot-train`.
|
|
51
|
+
|
|
52
|
+
## What goes in
|
|
53
|
+
|
|
54
|
+
The camera's frames are the timeline, one row per frame:
|
|
55
|
+
|
|
56
|
+
| Column | From | Default |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `observation.images.<camera>` | `sensor_msgs/CompressedImage` (JPEG, PNG) or raw `sensor_msgs/Image`, encoded to AV1 | the highest-resolution camera; `--camera` |
|
|
59
|
+
| `observation.state` | `sensor_msgs/JointState` positions | the joints the action commands; `--state-joints` |
|
|
60
|
+
| `action` | `trajectory_msgs/JointTrajectory` commands (the teleop leader), concatenated | every such topic; `--action-topic`, `--no-action` |
|
|
61
|
+
|
|
62
|
+
The camera name comes from the topic: `/zed/zed_node/left/image_rect_color/compressed` becomes
|
|
63
|
+
`observation.images.zed_left`, or set it with `--camera-key`. The topics are chosen from the first file and
|
|
64
|
+
printed, and every later file must have the same ones.
|
|
65
|
+
|
|
66
|
+
## Alignment
|
|
67
|
+
|
|
68
|
+
Each frame takes the joint reading and the command nearest to it in time, within half a frame interval
|
|
69
|
+
(`--tolerance-ms`). Leading or trailing frames with no match yet are dropped. A frame in the middle with no
|
|
70
|
+
match stops the conversion with the time and size of the gap. That's a gap in the recording, so the frame
|
|
71
|
+
isn't filled with a stale value. Raise `--tolerance-ms` to accept it.
|
|
72
|
+
|
|
73
|
+
**Clocks.** A ROS message has two times: when the sensor captured it (`header.stamp`) and when the
|
|
74
|
+
recorder wrote it (MCAP `log_time`). On real robots a camera frame reaches the recorder about 25 ms after
|
|
75
|
+
capture and a joint reading about 1 ms after, so pairing on write time matches each frame with the arm's
|
|
76
|
+
position a frame later. The converter pairs on capture time. Teleop commands usually carry no capture
|
|
77
|
+
time, so they're placed on the capture timeline using the joint topic's recorder delay. `--clock log`
|
|
78
|
+
pairs on write time; `--clock header` requires a capture time on every topic.
|
|
79
|
+
|
|
80
|
+
## What comes out
|
|
81
|
+
|
|
82
|
+
LeRobot v3.0, as written by lerobot 0.4 and later:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
data/chunk-000/file-000.parquet observation.state, action, timestamp, frame_index, episode_index, index, task_index
|
|
86
|
+
videos/<camera>/chunk-000/file-000.mp4 all episodes back to back, AV1, a keyframe every 2 frames
|
|
87
|
+
meta/info.json stats.json tasks.parquet episodes/chunk-000/file-000.parquet
|
|
88
|
+
meta/conversion.json sources, topics, clock, worst match per episode, notes
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`meta/conversion.json` also records what the converter noticed: dropped camera frames, a camera rate
|
|
92
|
+
that differs from the dataset fps, or a robot clock that isn't synchronised with the recorder (two of
|
|
93
|
+
the public test recordings below are 73 days off). The output is built in `<output>.partial` and renamed
|
|
94
|
+
when complete, so a failed run leaves nothing behind. `--overwrite` only ever replaces a folder that is
|
|
95
|
+
already a LeRobot dataset.
|
|
96
|
+
|
|
97
|
+
## Tested on
|
|
98
|
+
|
|
99
|
+
`ola31/readme_test_rosbag_1` and `Dongkkka/physical_ai_tools_test_rosbag` on the Hugging Face Hub, a
|
|
100
|
+
two-arm teleop robot: 3 recordings, 1,777 frames at 30 fps, 19 joints.
|
|
101
|
+
- **Values:** every state and action value equals the source message.
|
|
102
|
+
- **Timing:** each frame's joint reading is within 5 ms of the frame's capture time.
|
|
103
|
+
- **Video:** video frames are in source order.
|
|
104
|
+
- **LeRobot:** `LeRobotDataset` loads the result, and `lerobot-train` trains ACT on it.
|
|
105
|
+
|
|
106
|
+
## Limits
|
|
107
|
+
|
|
108
|
+
Version 0.1 handles one camera per dataset, ROS 2 MCAP input, and joint state and commands from
|
|
109
|
+
`JointState` and `JointTrajectory`. Planned next: several cameras, ROS 1 `.bag` and ROS 2 `.db3`.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git clone https://github.com/akrishni/phys-ai-dataconverter
|
|
115
|
+
cd phys-ai-dataconverter
|
|
116
|
+
pip install -e ".[dev]"
|
|
117
|
+
python tests/run_all.py # offline: MCAP files with known answers are written by the tests
|
|
118
|
+
python tests/run_all.py --hub # also convert a public recording from the Hub
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
phys_ai_dataconverter/
|
|
123
|
+
model.py Stream, image and topic types shared by everything below
|
|
124
|
+
readers/ one module per input format (mcap_ros2)
|
|
125
|
+
episode.py one recording to one aligned episode: topic choice, clocks, alignment
|
|
126
|
+
align.py nearest-timestamp matching with a tolerance
|
|
127
|
+
writers/ one module per output format (lerobot_v3)
|
|
128
|
+
convert.py the pipeline: read, align, write to <output>.partial, rename
|
|
129
|
+
verify.py --check: profile the output with phys-ai-dataprofiler and evaluate requirements
|
|
130
|
+
cli.py the phys-ai-dataconverter command
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A new input format is a new module in `readers/` that produces `Stream`s. A new output format is a new
|
|
134
|
+
module in `writers/`. Nothing else changes.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT License, copyright (c) 2026 Anand Krishniyer. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# phys-ai-dataconverter
|
|
2
|
+
|
|
3
|
+
Convert robot recordings into datasets you can train on. Version 0.1 turns ROS 2 teleoperation recordings
|
|
4
|
+
(MCAP) into [LeRobot](https://github.com/huggingface/lerobot) v3 datasets. It pairs camera frames, joint
|
|
5
|
+
state and the operator's commands by capture time, and refuses to fill gaps with stale values.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install phys-ai-dataconverter
|
|
9
|
+
phys-ai-dataconverter recordings/ -o ./pick_bottles --task "pick up the bottles" --check preset:teleop-imitation
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Each `.mcap` file becomes one episode. `--check` then profiles the result's data table and videos with
|
|
13
|
+
[phys-ai-dataprofiler](https://github.com/akrishni/phys-ai-dataprofiler) and checks it against a
|
|
14
|
+
requirements file or profiler preset, saving the report in `meta/check.md`. The output works unchanged with LeRobot's `LeRobotDataset` and `lerobot-train`.
|
|
15
|
+
|
|
16
|
+
## What goes in
|
|
17
|
+
|
|
18
|
+
The camera's frames are the timeline, one row per frame:
|
|
19
|
+
|
|
20
|
+
| Column | From | Default |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `observation.images.<camera>` | `sensor_msgs/CompressedImage` (JPEG, PNG) or raw `sensor_msgs/Image`, encoded to AV1 | the highest-resolution camera; `--camera` |
|
|
23
|
+
| `observation.state` | `sensor_msgs/JointState` positions | the joints the action commands; `--state-joints` |
|
|
24
|
+
| `action` | `trajectory_msgs/JointTrajectory` commands (the teleop leader), concatenated | every such topic; `--action-topic`, `--no-action` |
|
|
25
|
+
|
|
26
|
+
The camera name comes from the topic: `/zed/zed_node/left/image_rect_color/compressed` becomes
|
|
27
|
+
`observation.images.zed_left`, or set it with `--camera-key`. The topics are chosen from the first file and
|
|
28
|
+
printed, and every later file must have the same ones.
|
|
29
|
+
|
|
30
|
+
## Alignment
|
|
31
|
+
|
|
32
|
+
Each frame takes the joint reading and the command nearest to it in time, within half a frame interval
|
|
33
|
+
(`--tolerance-ms`). Leading or trailing frames with no match yet are dropped. A frame in the middle with no
|
|
34
|
+
match stops the conversion with the time and size of the gap. That's a gap in the recording, so the frame
|
|
35
|
+
isn't filled with a stale value. Raise `--tolerance-ms` to accept it.
|
|
36
|
+
|
|
37
|
+
**Clocks.** A ROS message has two times: when the sensor captured it (`header.stamp`) and when the
|
|
38
|
+
recorder wrote it (MCAP `log_time`). On real robots a camera frame reaches the recorder about 25 ms after
|
|
39
|
+
capture and a joint reading about 1 ms after, so pairing on write time matches each frame with the arm's
|
|
40
|
+
position a frame later. The converter pairs on capture time. Teleop commands usually carry no capture
|
|
41
|
+
time, so they're placed on the capture timeline using the joint topic's recorder delay. `--clock log`
|
|
42
|
+
pairs on write time; `--clock header` requires a capture time on every topic.
|
|
43
|
+
|
|
44
|
+
## What comes out
|
|
45
|
+
|
|
46
|
+
LeRobot v3.0, as written by lerobot 0.4 and later:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
data/chunk-000/file-000.parquet observation.state, action, timestamp, frame_index, episode_index, index, task_index
|
|
50
|
+
videos/<camera>/chunk-000/file-000.mp4 all episodes back to back, AV1, a keyframe every 2 frames
|
|
51
|
+
meta/info.json stats.json tasks.parquet episodes/chunk-000/file-000.parquet
|
|
52
|
+
meta/conversion.json sources, topics, clock, worst match per episode, notes
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`meta/conversion.json` also records what the converter noticed: dropped camera frames, a camera rate
|
|
56
|
+
that differs from the dataset fps, or a robot clock that isn't synchronised with the recorder (two of
|
|
57
|
+
the public test recordings below are 73 days off). The output is built in `<output>.partial` and renamed
|
|
58
|
+
when complete, so a failed run leaves nothing behind. `--overwrite` only ever replaces a folder that is
|
|
59
|
+
already a LeRobot dataset.
|
|
60
|
+
|
|
61
|
+
## Tested on
|
|
62
|
+
|
|
63
|
+
`ola31/readme_test_rosbag_1` and `Dongkkka/physical_ai_tools_test_rosbag` on the Hugging Face Hub, a
|
|
64
|
+
two-arm teleop robot: 3 recordings, 1,777 frames at 30 fps, 19 joints.
|
|
65
|
+
- **Values:** every state and action value equals the source message.
|
|
66
|
+
- **Timing:** each frame's joint reading is within 5 ms of the frame's capture time.
|
|
67
|
+
- **Video:** video frames are in source order.
|
|
68
|
+
- **LeRobot:** `LeRobotDataset` loads the result, and `lerobot-train` trains ACT on it.
|
|
69
|
+
|
|
70
|
+
## Limits
|
|
71
|
+
|
|
72
|
+
Version 0.1 handles one camera per dataset, ROS 2 MCAP input, and joint state and commands from
|
|
73
|
+
`JointState` and `JointTrajectory`. Planned next: several cameras, ROS 1 `.bag` and ROS 2 `.db3`.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
git clone https://github.com/akrishni/phys-ai-dataconverter
|
|
79
|
+
cd phys-ai-dataconverter
|
|
80
|
+
pip install -e ".[dev]"
|
|
81
|
+
python tests/run_all.py # offline: MCAP files with known answers are written by the tests
|
|
82
|
+
python tests/run_all.py --hub # also convert a public recording from the Hub
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
phys_ai_dataconverter/
|
|
87
|
+
model.py Stream, image and topic types shared by everything below
|
|
88
|
+
readers/ one module per input format (mcap_ros2)
|
|
89
|
+
episode.py one recording to one aligned episode: topic choice, clocks, alignment
|
|
90
|
+
align.py nearest-timestamp matching with a tolerance
|
|
91
|
+
writers/ one module per output format (lerobot_v3)
|
|
92
|
+
convert.py the pipeline: read, align, write to <output>.partial, rename
|
|
93
|
+
verify.py --check: profile the output with phys-ai-dataprofiler and evaluate requirements
|
|
94
|
+
cli.py the phys-ai-dataconverter command
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A new input format is a new module in `readers/` that produces `Stream`s. A new output format is a new
|
|
98
|
+
module in `writers/`. Nothing else changes.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT License, copyright (c) 2026 Anand Krishniyer. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""phys_ai_dataconverter: convert robot recordings (ROS 2 MCAP) into training datasets (LeRobot v3).
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anand Krishniyer. MIT License."""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from phys_ai_dataconverter.model import ConversionError # noqa: E402,F401
|
|
8
|
+
from phys_ai_dataconverter.convert import ConversionReport, ConvertOptions, convert # noqa: E402,F401
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nearest-timestamp alignment of one stream onto the ticks of another.
|
|
3
|
+
|
|
4
|
+
Each reference tick (a camera frame) takes the source sample (a joint reading) closest to it in time.
|
|
5
|
+
The distance is kept, and a tick whose nearest sample is further than the tolerance gets no value
|
|
6
|
+
rather than a stale one: the caller decides whether to drop the frame or fail.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Alignment:
|
|
18
|
+
index: np.ndarray # per tick: index of the nearest sample, -1 when outside the tolerance
|
|
19
|
+
age_ns: np.ndarray # per tick: |tick - sample time| in ns (of the nearest sample, even when rejected)
|
|
20
|
+
matched: np.ndarray # per tick: True when within the tolerance
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def n_unmatched(self) -> int:
|
|
24
|
+
return int((~self.matched).sum())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def nearest(ticks_ns: np.ndarray, sample_ns: np.ndarray, tolerance_ns: int) -> Alignment:
|
|
28
|
+
"""Match every tick to the nearest sample. Both arrays must be sorted ascending; ties go to the
|
|
29
|
+
earlier sample, so a value is never taken from after it could have been known when both are
|
|
30
|
+
equally close."""
|
|
31
|
+
ticks_ns = np.asarray(ticks_ns, dtype=np.int64)
|
|
32
|
+
sample_ns = np.asarray(sample_ns, dtype=np.int64)
|
|
33
|
+
if len(sample_ns) == 0:
|
|
34
|
+
n = len(ticks_ns)
|
|
35
|
+
return Alignment(np.full(n, -1), np.full(n, np.iinfo(np.int64).max), np.zeros(n, dtype=bool))
|
|
36
|
+
if np.any(np.diff(sample_ns) < 0) or np.any(np.diff(ticks_ns) < 0):
|
|
37
|
+
raise ValueError("timestamps must be sorted")
|
|
38
|
+
right = np.searchsorted(sample_ns, ticks_ns, side="left").clip(0, len(sample_ns) - 1)
|
|
39
|
+
left = (right - 1).clip(0, len(sample_ns) - 1)
|
|
40
|
+
d_right = np.abs(sample_ns[right] - ticks_ns)
|
|
41
|
+
d_left = np.abs(ticks_ns - sample_ns[left])
|
|
42
|
+
idx = np.where(d_left <= d_right, left, right)
|
|
43
|
+
age = np.minimum(d_left, d_right)
|
|
44
|
+
ok = age <= tolerance_ns
|
|
45
|
+
return Alignment(np.where(ok, idx, -1), age, ok)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
phys-ai-dataconverter: convert robot recordings into training datasets.
|
|
3
|
+
|
|
4
|
+
phys-ai-dataconverter recordings/ -o ./pick_bottles --task "pick up the bottles"
|
|
5
|
+
phys-ai-dataconverter rec1.mcap rec2.mcap -o ./ds --check preset:teleop-imitation
|
|
6
|
+
|
|
7
|
+
Exit codes: 0 converted (and, with --check, the check passed), 1 the --check failed,
|
|
8
|
+
2 the conversion was refused (the message says why), 4 the --check was unknown.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from phys_ai_dataconverter import __version__
|
|
18
|
+
|
|
19
|
+
PROG = "phys-ai-dataconverter"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
p = argparse.ArgumentParser(
|
|
24
|
+
prog=PROG,
|
|
25
|
+
description="Convert robot recordings (one episode per file) into a LeRobot v3 dataset: one camera, "
|
|
26
|
+
"observation.state from a joint state topic and action from joint command topics, aligned "
|
|
27
|
+
"to the camera frames by nearest capture time. Input: ROS 2 MCAP.",
|
|
28
|
+
epilog="Example: phys-ai-dataconverter recordings/ -o ./pick_bottles --task \"pick up the bottles\" "
|
|
29
|
+
"--camera /zed/zed_node/left/image_rect_color/compressed --check preset:teleop-imitation",
|
|
30
|
+
)
|
|
31
|
+
p.add_argument("--version", action="version", version=f"{PROG} {__version__}")
|
|
32
|
+
p.add_argument("inputs", nargs="+", help="Recording files, or folders searched for them (one episode per file)")
|
|
33
|
+
p.add_argument("--output", "-o", required=True, help="Folder for the new dataset")
|
|
34
|
+
p.add_argument("--task", default="teleoperation", help="Task description stored with every episode")
|
|
35
|
+
s = p.add_argument_group("streams (defaults are picked from the first file and printed)")
|
|
36
|
+
s.add_argument("--camera", help="Image topic (default: the highest-resolution one)")
|
|
37
|
+
s.add_argument("--camera-key", help="Feature name (default: observation.images.<from the topic>)")
|
|
38
|
+
s.add_argument("--state-topic", help="Joint state topic (default: the busiest one)")
|
|
39
|
+
s.add_argument("--state-joints", help="Comma-separated joint names for observation.state "
|
|
40
|
+
"(default: the joints the action topics command)")
|
|
41
|
+
s.add_argument("--action-topic", action="append", dest="action_topics",
|
|
42
|
+
help="Joint command topic; repeat for several, concatenated in order (default: all, sorted)")
|
|
43
|
+
s.add_argument("--no-action", action="store_true", help="Write no action column")
|
|
44
|
+
t = p.add_argument_group("timing")
|
|
45
|
+
t.add_argument("--fps", type=int, help="Dataset frame rate (default: the camera's measured rate, rounded)")
|
|
46
|
+
t.add_argument("--tolerance-ms", type=float,
|
|
47
|
+
help="Largest allowed distance from a frame to its matched state/command (default: half a frame)")
|
|
48
|
+
t.add_argument("--clock", choices=["auto", "header", "log"], default="auto",
|
|
49
|
+
help="auto (default): pair camera and state on capture time (header.stamp), placing unstamped "
|
|
50
|
+
"commands via the state topic's recorder delay; header: capture time for every topic; "
|
|
51
|
+
"log: recorder write time for every topic")
|
|
52
|
+
v = p.add_argument_group("output")
|
|
53
|
+
v.add_argument("--format", dest="output_format", default="lerobot-v3", choices=["lerobot-v3"])
|
|
54
|
+
v.add_argument("--vcodec", default="libsvtav1", help="libsvtav1 (LeRobot's default) or h264 (faster)")
|
|
55
|
+
v.add_argument("--crf", type=int, default=30, help="Video quality; lower is better and larger (default 30)")
|
|
56
|
+
v.add_argument("--robot-type", help="Stored in info.json")
|
|
57
|
+
v.add_argument("--overwrite", action="store_true", help="Replace an existing dataset at --output")
|
|
58
|
+
p.add_argument("--check", metavar="FILE|preset:NAME",
|
|
59
|
+
help="After converting, profile the output with phys-ai-dataprofiler and check it against "
|
|
60
|
+
"these requirements (a YAML file or a profiler preset); the report goes in meta/")
|
|
61
|
+
p.add_argument("--quiet", action="store_true")
|
|
62
|
+
return p
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _inputs(args: list[str]) -> list[Path]:
|
|
66
|
+
from phys_ai_dataconverter.readers import input_extensions
|
|
67
|
+
exts = input_extensions()
|
|
68
|
+
files: list[Path] = []
|
|
69
|
+
for i in args:
|
|
70
|
+
q = Path(i)
|
|
71
|
+
if q.is_dir():
|
|
72
|
+
found = sorted(f for f in q.rglob("*") if f.is_file() and f.suffix.lower() in exts)
|
|
73
|
+
if not found:
|
|
74
|
+
raise SystemExit(f"Error: no {' '.join(exts)} files under {q}")
|
|
75
|
+
files += found
|
|
76
|
+
else:
|
|
77
|
+
files.append(q)
|
|
78
|
+
return files
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main(argv: list[str] | None = None) -> int:
|
|
82
|
+
a = build_parser().parse_args(argv)
|
|
83
|
+
log = (lambda m: None) if a.quiet else (lambda m: print(m, file=sys.stderr))
|
|
84
|
+
from phys_ai_dataconverter.convert import ConvertOptions, convert, print_report
|
|
85
|
+
from phys_ai_dataconverter.model import ConversionError
|
|
86
|
+
try:
|
|
87
|
+
files = _inputs(a.inputs)
|
|
88
|
+
report = convert(files, a.output, ConvertOptions(
|
|
89
|
+
camera=a.camera, camera_key=a.camera_key, state_topic=a.state_topic,
|
|
90
|
+
state_joints=[j.strip() for j in a.state_joints.split(",")] if a.state_joints else None,
|
|
91
|
+
action_topics=a.action_topics, no_action=a.no_action, task=a.task, fps=a.fps,
|
|
92
|
+
tolerance_ms=a.tolerance_ms, clock=a.clock, output_format=a.output_format, vcodec=a.vcodec,
|
|
93
|
+
crf=a.crf, robot_type=a.robot_type, overwrite=a.overwrite), log=log)
|
|
94
|
+
except ConversionError as exc:
|
|
95
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
except SystemExit as exc:
|
|
98
|
+
if isinstance(exc.code, str):
|
|
99
|
+
print(exc.code, file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
raise
|
|
102
|
+
print_report(report, log)
|
|
103
|
+
if not a.check:
|
|
104
|
+
log(f"Check it: {PROG} ... --check preset:teleop-imitation (or a requirements file)")
|
|
105
|
+
return 0
|
|
106
|
+
from phys_ai_dataconverter.verify import check
|
|
107
|
+
log("")
|
|
108
|
+
log(f"Checking {a.output} against {a.check} with phys-ai-dataprofiler")
|
|
109
|
+
try:
|
|
110
|
+
return check(a.output, a.check, log if not a.quiet else (lambda m: None))
|
|
111
|
+
except ConversionError as exc:
|
|
112
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
113
|
+
return 2
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
sys.exit(main())
|