oopsie-data-tools 0.2.0__tar.gz → 0.2.2__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.
- oopsie_data_tools-0.2.2/.github/workflows/publish.yml +67 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/.gitignore +2 -1
- oopsie_data_tools-0.2.2/PKG-INFO +57 -0
- oopsie_data_tools-0.2.2/README.md +35 -0
- oopsie_data_tools-0.2.2/examples/conversion_script_examples/_common.py +172 -0
- oopsie_data_tools-0.2.2/examples/conversion_script_examples/convert_ar_aloha.py +275 -0
- oopsie_data_tools-0.2.2/examples/conversion_script_examples/convert_rlds.py +279 -0
- oopsie_data_tools-0.2.2/examples/conversion_script_examples/convert_soar.py +250 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/annotation_tool/annotation_schema.py +8 -6
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/annotation_tool/annotator_server.py +6 -6
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/annotation_tool/ui/annotator.html +23 -14
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/cli.py +208 -37
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/init_wizard.py +23 -12
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/skill/SKILL.md +53 -2
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/skill/reference/conversion.md +29 -10
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/skill/reference/format.md +1 -1
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/skill/reference/robot-profile.md +4 -1
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/skill/reference/setup.md +19 -9
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/skill/reference/troubleshooting.md +7 -1
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/fixtures/make_invalid.py +1 -1
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/fixtures/make_valid.py +3 -3
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_annotation_completeness.py +4 -4
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_annotation_schema.py +6 -6
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_annotator_server.py +5 -5
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_cli_config.py +11 -11
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_cli_json.py +200 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_cli_tools.py +1 -1
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_conversion_utils_annotations.py +2 -2
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_conversion_utils_writers.py +76 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_credentials_location.py +47 -17
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_hf_upload.py +10 -12
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_install_skill.py +210 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_migrate_taxonomy_v2.py +5 -5
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_new_profile.py +7 -6
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_paths.py +32 -11
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_robot_setup.py +3 -3
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_skill_matches_cli.py +97 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_validate.py +3 -3
- oopsie_data_tools-0.2.2/oopsie_data_tools/utils/claude_skill.py +198 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/contributor_config.py +26 -16
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/conversion_utils.py +71 -3
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/h5_inspect.py +74 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/hf_upload.py +24 -7
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/migrate_taxonomy_v2.py +5 -5
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/paths.py +31 -30
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/validation/annotation_completeness.py +4 -4
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/validation/validation_utils.py +35 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/pyproject.toml +1 -1
- oopsie_data_tools-0.2.0/AI_CONTEXT.md +0 -269
- oopsie_data_tools-0.2.0/PKG-INFO +0 -131
- oopsie_data_tools-0.2.0/README.md +0 -109
- oopsie_data_tools-0.2.0/oopsie_data_tools/test/test_install_skill.py +0 -120
- oopsie_data_tools-0.2.0/oopsie_data_tools/utils/claude_skill.py +0 -96
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/.github/ISSUE_TEMPLATE/help-request--data-conversion.md +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/.github/ISSUE_TEMPLATE/help-request--robot-profile.md +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/.github/ISSUE_TEMPLATE/improvement-suggestion.md +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/.github/workflows/ci.yml +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/.python-version +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/LICENSE +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/configs/robot_profiles/act_plus_plus_robot_profile.yaml +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/configs/robot_profiles/openpi_example_robot_profile.yaml +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/configs/robot_profiles/template.yaml +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/examples/inference_examples/act_plus_plus/run_policy.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/examples/inference_examples/act_plus_plus/run_policy_browser.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/examples/inference_examples/act_plus_plus/run_policy_bulk.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/examples/inference_examples/act_plus_plus/run_policy_inloop.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/examples/inference_examples/openpi/run_policy_bulk.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/examples/inference_examples/openpi/run_policy_inloop.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/__init__.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/annotation_tool/__init__.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/annotation_tool/episode_recorder.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/annotation_tool/rollout_annotator.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/__init__.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/conftest.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/fixtures/__init__.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_annotator_server_guards.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_bulk_inference_end_to_end.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_contributor_config.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_diversity.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_episode_recorder.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_episode_video_paths.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_init_wizard.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_python38_compat.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_record_step_purity.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_rollout_annotator.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/test/test_rotation_utils.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/__init__.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/h5.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/hf_limits.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/log.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/restructure.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/robot_profile/__init__.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/robot_profile/robot_profile.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/robot_profile/rotation_utils.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/robot_profile/template.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/validation/__init__.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/validation/diversity.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/validation/episode_data.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/validation/episode_loader.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/validation/episode_validator.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/oopsie_data_tools/utils/validation/errors.py +0 -0
- {oopsie_data_tools-0.2.0 → oopsie_data_tools-0.2.2}/uv.lock +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Release flow: bump `version` in pyproject.toml, merge to main, then
|
|
4
|
+
# git tag v0.2.1 && git push origin v0.2.1
|
|
5
|
+
# The tag must sit on a commit that is on main, and its version must match
|
|
6
|
+
# pyproject.toml — both are enforced below before anything is uploaded.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
tags: ["v*"]
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
# The ancestry check below needs main, which a tag checkout does not fetch.
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
- uses: astral-sh/setup-uv@v5
|
|
20
|
+
with:
|
|
21
|
+
enable-cache: true
|
|
22
|
+
|
|
23
|
+
- name: Tag must be on main
|
|
24
|
+
run: |
|
|
25
|
+
git fetch --no-tags origin main
|
|
26
|
+
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
|
|
27
|
+
echo "::error::${GITHUB_REF_NAME} points at a commit that is not on main."
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# A tag whose version disagrees with pyproject.toml publishes a release under a
|
|
32
|
+
# name nobody expects, and PyPI uploads cannot be replaced. Fail before building.
|
|
33
|
+
- name: Tag must match the project version
|
|
34
|
+
run: |
|
|
35
|
+
project_version="$(uv version --short)"
|
|
36
|
+
tag_version="${GITHUB_REF_NAME#v}"
|
|
37
|
+
if [ "$project_version" != "$tag_version" ]; then
|
|
38
|
+
echo "::error::tag ${GITHUB_REF_NAME} != pyproject.toml version ${project_version}."
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
- run: uv build
|
|
43
|
+
- name: Check metadata renders on PyPI
|
|
44
|
+
run: uvx twine check --strict dist/*
|
|
45
|
+
|
|
46
|
+
- uses: actions/upload-artifact@v4
|
|
47
|
+
with:
|
|
48
|
+
name: dist
|
|
49
|
+
path: dist/
|
|
50
|
+
|
|
51
|
+
publish:
|
|
52
|
+
needs: build
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
# Trusted Publishing: PyPI verifies this workflow's OIDC token instead of an API
|
|
55
|
+
# token, so there is no long-lived credential in the repo. The environment name
|
|
56
|
+
# must match the one configured on the PyPI publisher.
|
|
57
|
+
environment:
|
|
58
|
+
name: pypi
|
|
59
|
+
url: https://pypi.org/p/oopsie-data-tools
|
|
60
|
+
permissions:
|
|
61
|
+
id-token: write
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/download-artifact@v4
|
|
64
|
+
with:
|
|
65
|
+
name: dist
|
|
66
|
+
path: dist/
|
|
67
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oopsie-data-tools
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Collect, annotate, validate and upload robotic manipulation rollout data.
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Requires-Dist: flask>=3.0.0
|
|
8
|
+
Requires-Dist: h5py>=3.7.0
|
|
9
|
+
Requires-Dist: huggingface-hub>=0.36.2
|
|
10
|
+
Requires-Dist: imageio-ffmpeg>=0.5.1
|
|
11
|
+
Requires-Dist: imageio>=2.35.1
|
|
12
|
+
Requires-Dist: numpy>=1.20.0
|
|
13
|
+
Requires-Dist: opencv-python>=4.11.0.86
|
|
14
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
15
|
+
Requires-Dist: scipy>=1.10.1
|
|
16
|
+
Provides-Extra: droid
|
|
17
|
+
Requires-Dist: pandas>=2.0.0; extra == 'droid'
|
|
18
|
+
Requires-Dist: pillow>=10.0.0; extra == 'droid'
|
|
19
|
+
Requires-Dist: tqdm>=4.66.0; extra == 'droid'
|
|
20
|
+
Requires-Dist: tyro>=0.9.0; extra == 'droid'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Oopsie Tools
|
|
24
|
+
|
|
25
|
+
Tools for collecting, annotating, inspecting, and converting robotic manipulation rollout data.
|
|
26
|
+
|
|
27
|
+
This repository currently provides tools for:
|
|
28
|
+
|
|
29
|
+
- HDF5 episode recording (`EpisodeRecorder`)
|
|
30
|
+
- Web annotation workflows
|
|
31
|
+
- In-the-loop annotation during policy rollout
|
|
32
|
+
|
|
33
|
+
as well as all the necessary utilities to validate, inspect, and upload Oopsie-Data to the official repositories.
|
|
34
|
+
|
|
35
|
+
[Sign up today](https://forms.gle/9arwZHAvRjvbozoT7) and start contributing!
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
For detailed explanations on how to use our tooling and contribute to the project, please visit [our website](https://oopsie-data.com/).
|
|
40
|
+
|
|
41
|
+
For an overview of the steps necessary to integrate the tooling into your workflow and to contribute data to the official Oopsie Data repositories, check out [our quickstart guide](https://oopsie-data.com/quickstart).
|
|
42
|
+
|
|
43
|
+
## Repository structure
|
|
44
|
+
|
|
45
|
+
The main tooling for data gathering and annotation is located in `oopsie-data-tools`.
|
|
46
|
+
|
|
47
|
+
We provide example scripts for automatically collecting and annotating evaluation data while running policy inference in examples. Currently we support the evaluation scripts supported by `openpi` and Trossen robotics `act_plus_plus` repository. If you want to run a different evaluation script, check out the detailed instructions on integrating our tools into standard robot evaluation pipelines.
|
|
48
|
+
|
|
49
|
+
## Issue/PR for support requests
|
|
50
|
+
|
|
51
|
+
We are very aware that changing eval code and recording data while doing experiments can be a big hassle and cause friction. We are therefore happy to help you integrate the recording tool into your setup. Please let us know what robot platform and policy you are evaluating and where to find a sample of your evaluation & inference code.
|
|
52
|
+
|
|
53
|
+
## Contributing
|
|
54
|
+
|
|
55
|
+
You can use our toolset any time you like to record and annotate robot rollouts. To contribute your data to the official Oopsie Dataset, please follow the [sign-up instructions](https://oopsie-data.com/contributing/)!
|
|
56
|
+
|
|
57
|
+
If you run into any issues, please do not hesitate to contact the team via mail or raise an issue here.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Oopsie Tools
|
|
2
|
+
|
|
3
|
+
Tools for collecting, annotating, inspecting, and converting robotic manipulation rollout data.
|
|
4
|
+
|
|
5
|
+
This repository currently provides tools for:
|
|
6
|
+
|
|
7
|
+
- HDF5 episode recording (`EpisodeRecorder`)
|
|
8
|
+
- Web annotation workflows
|
|
9
|
+
- In-the-loop annotation during policy rollout
|
|
10
|
+
|
|
11
|
+
as well as all the necessary utilities to validate, inspect, and upload Oopsie-Data to the official repositories.
|
|
12
|
+
|
|
13
|
+
[Sign up today](https://forms.gle/9arwZHAvRjvbozoT7) and start contributing!
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
For detailed explanations on how to use our tooling and contribute to the project, please visit [our website](https://oopsie-data.com/).
|
|
18
|
+
|
|
19
|
+
For an overview of the steps necessary to integrate the tooling into your workflow and to contribute data to the official Oopsie Data repositories, check out [our quickstart guide](https://oopsie-data.com/quickstart).
|
|
20
|
+
|
|
21
|
+
## Repository structure
|
|
22
|
+
|
|
23
|
+
The main tooling for data gathering and annotation is located in `oopsie-data-tools`.
|
|
24
|
+
|
|
25
|
+
We provide example scripts for automatically collecting and annotating evaluation data while running policy inference in examples. Currently we support the evaluation scripts supported by `openpi` and Trossen robotics `act_plus_plus` repository. If you want to run a different evaluation script, check out the detailed instructions on integrating our tools into standard robot evaluation pipelines.
|
|
26
|
+
|
|
27
|
+
## Issue/PR for support requests
|
|
28
|
+
|
|
29
|
+
We are very aware that changing eval code and recording data while doing experiments can be a big hassle and cause friction. We are therefore happy to help you integrate the recording tool into your setup. Please let us know what robot platform and policy you are evaluating and where to find a sample of your evaluation & inference code.
|
|
30
|
+
|
|
31
|
+
## Contributing
|
|
32
|
+
|
|
33
|
+
You can use our toolset any time you like to record and annotate robot rollouts. To contribute your data to the official Oopsie Dataset, please follow the [sign-up instructions](https://oopsie-data.com/contributing/)!
|
|
34
|
+
|
|
35
|
+
If you run into any issues, please do not hesitate to contact the team via mail or raise an issue here.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Scaffolding shared by the converter examples in this directory.
|
|
2
|
+
|
|
3
|
+
Everything schema-shaped — root attrs, robot states, actions, video paths, annotations —
|
|
4
|
+
lives in :mod:`oopsie_data_tools.utils.conversion_utils` and is used from there. What is
|
|
5
|
+
left over is the part that is the same in every converter but is not about the format:
|
|
6
|
+
the CLI shape, the output directory layout, and a batch loop that keeps going when one
|
|
7
|
+
episode fails.
|
|
8
|
+
|
|
9
|
+
A converter in this directory is then three things:
|
|
10
|
+
|
|
11
|
+
1. ``build_profile(...)`` — the :class:`RobotProfile` describing the source robot;
|
|
12
|
+
2. ``convert_one(item, episode_id, out)`` — read one source episode, write one HDF5;
|
|
13
|
+
3. a ``main`` that discovers items and hands them to :func:`run_batch`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import shutil
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable, Iterable, Sequence, TypeVar
|
|
23
|
+
|
|
24
|
+
from tqdm import tqdm
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Skip(Exception):
|
|
30
|
+
"""Raised by a converter to drop one source episode without counting it as a failure.
|
|
31
|
+
|
|
32
|
+
Use it for episodes that are legitimately out of scope — too short for the validator's
|
|
33
|
+
duration bounds, missing a camera — and let real errors propagate as themselves.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ConversionOutput:
|
|
39
|
+
"""The ``{output_dir}/{episode_id}.h5`` + ``{output_dir}/videos/`` layout.
|
|
40
|
+
|
|
41
|
+
Videos are *copied* rather than symlinked: a symlink survives local validation and then
|
|
42
|
+
uploads as a dangling path.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
output_dir: Path
|
|
46
|
+
videos_dir: Path
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def create(cls, output_dir: Path | str) -> "ConversionOutput":
|
|
50
|
+
output_dir = Path(output_dir).resolve()
|
|
51
|
+
videos_dir = output_dir / "videos"
|
|
52
|
+
videos_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
return cls(output_dir=output_dir, videos_dir=videos_dir)
|
|
54
|
+
|
|
55
|
+
def episode_h5(self, episode_id: str) -> Path:
|
|
56
|
+
return self.output_dir / f"{episode_id}.h5"
|
|
57
|
+
|
|
58
|
+
def video_path(self, episode_id: str, camera: str) -> Path:
|
|
59
|
+
"""Absolute destination for one camera's MP4.
|
|
60
|
+
|
|
61
|
+
Pass it straight to ``write_video_paths``, which relativizes against the .h5 file.
|
|
62
|
+
"""
|
|
63
|
+
return self.videos_dir / f"{episode_id}_{camera}.mp4"
|
|
64
|
+
|
|
65
|
+
def copy_video(self, source: Path, episode_id: str, camera: str) -> Path:
|
|
66
|
+
if not Path(source).is_file():
|
|
67
|
+
raise Skip(f"missing source video for camera {camera!r}")
|
|
68
|
+
destination = self.video_path(episode_id, camera)
|
|
69
|
+
shutil.copy2(source, destination)
|
|
70
|
+
return destination
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def add_common_args(parser: argparse.ArgumentParser, *, source_help: str) -> None:
|
|
74
|
+
"""Add the arguments every converter in this directory takes."""
|
|
75
|
+
parser.add_argument("--source", "-s", type=Path, required=True, help=source_help)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--output-dir",
|
|
78
|
+
"-o",
|
|
79
|
+
type=Path,
|
|
80
|
+
required=True,
|
|
81
|
+
help="Output directory for the HDF5 files and their videos/ subdirectory.",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--lab-id",
|
|
85
|
+
required=True,
|
|
86
|
+
help="Your registered lab id. Written as a root attr; 'your_lab_id' is rejected.",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--operator-name",
|
|
90
|
+
required=True,
|
|
91
|
+
help="Who ran the episodes. Written as a root attr.",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--annotator-name",
|
|
95
|
+
required=True,
|
|
96
|
+
help="Name of the episode_annotations/<annotator>/ subgroup the labels land in.",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--start-id",
|
|
100
|
+
type=int,
|
|
101
|
+
default=0,
|
|
102
|
+
help="Starting numeric counter for episode IDs (default: 0).",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--max-episodes",
|
|
106
|
+
type=int,
|
|
107
|
+
default=None,
|
|
108
|
+
help="Stop after writing this many episodes.",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--overwrite",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help="Rewrite output files that already exist (default: leave them alone).",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def default_episode_id(_item: object, counter: int) -> str:
|
|
118
|
+
return f"episode_{counter:06d}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_batch(
|
|
122
|
+
items: Sequence[T] | Iterable[T],
|
|
123
|
+
convert_one: Callable[[T, str, ConversionOutput], None],
|
|
124
|
+
*,
|
|
125
|
+
out: ConversionOutput,
|
|
126
|
+
episode_id_for: Callable[[T, int], str] = default_episode_id,
|
|
127
|
+
label: Callable[[T], str] = str,
|
|
128
|
+
start_id: int = 0,
|
|
129
|
+
desc: str = "Converting",
|
|
130
|
+
overwrite: bool = False,
|
|
131
|
+
max_episodes: int | None = None,
|
|
132
|
+
) -> dict[str, int]:
|
|
133
|
+
"""Convert every item, counting outcomes instead of stopping at the first failure.
|
|
134
|
+
|
|
135
|
+
``convert_one`` writes ``out.episode_h5(episode_id)``; raising :class:`Skip` drops the
|
|
136
|
+
item. The counter behind ``episode_id_for`` advances for every item, skipped ones
|
|
137
|
+
included, so an id stays tied to its source episode across reruns with different
|
|
138
|
+
filters.
|
|
139
|
+
"""
|
|
140
|
+
items = list(items)
|
|
141
|
+
counts = {"written": 0, "exists": 0, "skipped": 0, "failed": 0}
|
|
142
|
+
|
|
143
|
+
with tqdm(items, desc=desc, unit="ep") as progress:
|
|
144
|
+
for offset, item in enumerate(progress):
|
|
145
|
+
if max_episodes is not None and counts["written"] >= max_episodes:
|
|
146
|
+
break
|
|
147
|
+
episode_id = episode_id_for(item, start_id + offset)
|
|
148
|
+
progress.set_postfix_str(label(item)[:48])
|
|
149
|
+
|
|
150
|
+
if out.episode_h5(episode_id).exists() and not overwrite:
|
|
151
|
+
counts["exists"] += 1
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
convert_one(item, episode_id, out)
|
|
155
|
+
except Skip as skip:
|
|
156
|
+
tqdm.write(f" – {episode_id}: {skip}")
|
|
157
|
+
counts["skipped"] += 1
|
|
158
|
+
except Exception as exc: # noqa: BLE001 — one bad episode must not end the run
|
|
159
|
+
tqdm.write(f" ✗ {episode_id}: {exc}")
|
|
160
|
+
counts["failed"] += 1
|
|
161
|
+
else:
|
|
162
|
+
counts["written"] += 1
|
|
163
|
+
|
|
164
|
+
return counts
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def report(counts: dict[str, int]) -> None:
|
|
168
|
+
tqdm.write(
|
|
169
|
+
f"\nDone: {counts['written']} written, {counts['exists']} already existed, "
|
|
170
|
+
f"{counts['skipped']} skipped, {counts['failed']} failed.\n"
|
|
171
|
+
"Next: oopsie-data validate --path <output-dir>"
|
|
172
|
+
)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Convert ACT / ALOHA episode HDF5 files to ``oopsiedata_format_v1`` HDF5.
|
|
2
|
+
|
|
3
|
+
Source layout (one file per episode, as written by the ACT / ACT++ recording stack):
|
|
4
|
+
|
|
5
|
+
/action (T, 14) target joint positions, both arms
|
|
6
|
+
/observations/qpos (T, 14) measured joint positions, both arms
|
|
7
|
+
/observations/qvel, /observations/effort (T, 14) optional, unused here
|
|
8
|
+
/success (T,) per-timestep success flag
|
|
9
|
+
/compress_len (3, T) JPEG byte lengths, one row per camera
|
|
10
|
+
/observations/images/camera_high (T, ...) frames, JPEG-compressed or raw RGB
|
|
11
|
+
/observations/images/camera_wrist_left (T, ...)
|
|
12
|
+
/observations/images/camera_wrist_right (T, ...)
|
|
13
|
+
|
|
14
|
+
Both 14-vectors are ``[6 arm joints + gripper]`` per arm. They are written out whole as
|
|
15
|
+
``joint_position``, matching the 14 entries in the profile's joint-name lists, with the two
|
|
16
|
+
gripper columns additionally written as ``gripper_position`` — the layout of the bundled
|
|
17
|
+
``configs/robot_profiles/act_plus_plus_robot_profile.yaml``.
|
|
18
|
+
|
|
19
|
+
There are no cartesian data in the source and the profile does not claim any: a joint action
|
|
20
|
+
space requires ``joint_position`` state, not ``cartesian_position``. Earlier versions of this
|
|
21
|
+
script wrote zero-filled cartesian arrays, which the validator now rejects as undeclared.
|
|
22
|
+
|
|
23
|
+
The images are decoded and re-encoded to one MP4 per camera at ``control_freq`` fps, and
|
|
24
|
+
downscaled if a side exceeds the 1280 px the validator allows.
|
|
25
|
+
|
|
26
|
+
``language_instruction`` is not stored in the source, so it is passed on the command line and
|
|
27
|
+
applies to every episode in the run.
|
|
28
|
+
|
|
29
|
+
Output — one file per source episode:
|
|
30
|
+
|
|
31
|
+
{output_dir}/{episode_id}.h5
|
|
32
|
+
{output_dir}/videos/{episode_id}_{camera}.mp4
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
# A single episode
|
|
36
|
+
python convert_ar_aloha.py -s /data/aloha/episode_0.hdf5 -o ./converted \\
|
|
37
|
+
--lab-id my_lab --operator-name alice --annotator-name act_eval \\
|
|
38
|
+
--language "Pick up the ball"
|
|
39
|
+
|
|
40
|
+
# Every episode in a directory
|
|
41
|
+
python convert_ar_aloha.py -s /data/aloha/rollouts -o ./converted \\
|
|
42
|
+
--lab-id my_lab --operator-name alice --annotator-name act_eval \\
|
|
43
|
+
--language "Pick up the ball"
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import argparse
|
|
49
|
+
from functools import partial
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
import cv2
|
|
53
|
+
import h5py
|
|
54
|
+
import numpy as np
|
|
55
|
+
from _common import ConversionOutput, Skip, add_common_args, report, run_batch
|
|
56
|
+
|
|
57
|
+
from oopsie_data_tools.annotation_tool.episode_recorder import write_mp4
|
|
58
|
+
from oopsie_data_tools.utils.conversion_utils import (
|
|
59
|
+
resize_frames,
|
|
60
|
+
write_actions,
|
|
61
|
+
write_episode_annotations,
|
|
62
|
+
write_robot_states,
|
|
63
|
+
write_root_attrs,
|
|
64
|
+
write_video_paths,
|
|
65
|
+
)
|
|
66
|
+
from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
|
|
67
|
+
|
|
68
|
+
DEFAULT_CONTROL_FREQ = 50 # Hz — ALOHA's teleop rate, and the video fps
|
|
69
|
+
|
|
70
|
+
# Column indices within the 14D qpos / action vectors.
|
|
71
|
+
GRIPPER_IDX = [6, 13]
|
|
72
|
+
|
|
73
|
+
# Source dataset → output camera name. The order is also the row order of /compress_len.
|
|
74
|
+
CAMERA_MAP = {
|
|
75
|
+
"camera_high": "top",
|
|
76
|
+
"camera_wrist_left": "left_wrist",
|
|
77
|
+
"camera_wrist_right": "right_wrist",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
JOINT_NAMES = [
|
|
81
|
+
f"{joint}_{side}"
|
|
82
|
+
for side in ("left", "right")
|
|
83
|
+
for joint in (
|
|
84
|
+
"waist",
|
|
85
|
+
"shoulder",
|
|
86
|
+
"elbow",
|
|
87
|
+
"forearm_roll",
|
|
88
|
+
"wrist_angle",
|
|
89
|
+
"wrist_rotate",
|
|
90
|
+
"gripper",
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
ACTION_SPACE = ["joint_position", "gripper_position"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_profile(policy_name: str, control_freq: int) -> RobotProfile:
|
|
97
|
+
return RobotProfile(
|
|
98
|
+
policy_name=policy_name,
|
|
99
|
+
robot_name="viperx_300",
|
|
100
|
+
gripper_name="viperx_300_gripper",
|
|
101
|
+
is_biarm=True,
|
|
102
|
+
uses_mobile_base=False,
|
|
103
|
+
control_freq=control_freq,
|
|
104
|
+
camera_names=list(CAMERA_MAP.values()),
|
|
105
|
+
robot_state_keys=["joint_position", "gripper_position"],
|
|
106
|
+
robot_state_joint_names=list(JOINT_NAMES),
|
|
107
|
+
action_space=list(ACTION_SPACE),
|
|
108
|
+
action_joint_names=list(JOINT_NAMES),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decode_frames(raw: np.ndarray, lengths: np.ndarray | None) -> np.ndarray:
|
|
113
|
+
"""Return ``(T, H, W, 3)`` RGB frames from a camera dataset.
|
|
114
|
+
|
|
115
|
+
``lengths`` is the camera's ``/compress_len`` row; without it the dataset is assumed to
|
|
116
|
+
hold raw RGB frames already, which is how uncompressed ALOHA recordings are stored.
|
|
117
|
+
"""
|
|
118
|
+
if lengths is None:
|
|
119
|
+
if raw.ndim != 4 or raw.shape[-1] != 3:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Camera dataset has shape {raw.shape} and the file has no /compress_len, "
|
|
122
|
+
"so it is neither raw RGB nor decodable JPEG."
|
|
123
|
+
)
|
|
124
|
+
return np.asarray(raw, dtype=np.uint8)
|
|
125
|
+
|
|
126
|
+
frames = []
|
|
127
|
+
for t in range(raw.shape[0]):
|
|
128
|
+
buffer = np.frombuffer(raw[t, : int(lengths[t])], dtype=np.uint8)
|
|
129
|
+
bgr = cv2.imdecode(buffer, cv2.IMREAD_COLOR)
|
|
130
|
+
if bgr is None:
|
|
131
|
+
raise ValueError(f"Failed to decode JPEG frame {t}")
|
|
132
|
+
frames.append(bgr[:, :, ::-1]) # BGR → RGB
|
|
133
|
+
return np.stack(frames, axis=0)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def convert_one(
|
|
137
|
+
source_h5: Path,
|
|
138
|
+
episode_id: str,
|
|
139
|
+
out: ConversionOutput,
|
|
140
|
+
*,
|
|
141
|
+
lab_id: str,
|
|
142
|
+
operator_name: str,
|
|
143
|
+
annotator_name: str,
|
|
144
|
+
language_instruction: str,
|
|
145
|
+
policy_name: str,
|
|
146
|
+
control_freq: int,
|
|
147
|
+
success_aggregation: str,
|
|
148
|
+
) -> None:
|
|
149
|
+
profile = build_profile(policy_name, control_freq)
|
|
150
|
+
|
|
151
|
+
with h5py.File(source_h5, "r") as src:
|
|
152
|
+
action = src["action"][()]
|
|
153
|
+
qpos = src["observations/qpos"][()]
|
|
154
|
+
|
|
155
|
+
success_per_step = src["success"][()]
|
|
156
|
+
if success_per_step.size == 0:
|
|
157
|
+
raise Skip("no /success values to aggregate")
|
|
158
|
+
success = float(
|
|
159
|
+
success_per_step[-1] if success_aggregation == "last" else success_per_step.max()
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
compress_len = src["compress_len"][()] if "compress_len" in src else None
|
|
163
|
+
frames = {
|
|
164
|
+
camera: _decode_frames(
|
|
165
|
+
src[f"observations/images/{key}"][()],
|
|
166
|
+
None if compress_len is None else compress_len[row],
|
|
167
|
+
)
|
|
168
|
+
for row, (key, camera) in enumerate(CAMERA_MAP.items())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
video_paths = {}
|
|
172
|
+
for camera, camera_frames in frames.items():
|
|
173
|
+
destination = out.video_path(episode_id, camera)
|
|
174
|
+
write_mp4(destination, resize_frames(camera_frames), float(control_freq))
|
|
175
|
+
video_paths[camera] = str(destination)
|
|
176
|
+
|
|
177
|
+
h5_path = out.episode_h5(episode_id)
|
|
178
|
+
with h5py.File(h5_path, "w") as f:
|
|
179
|
+
write_root_attrs(
|
|
180
|
+
f,
|
|
181
|
+
episode_id=episode_id,
|
|
182
|
+
language_instruction=language_instruction,
|
|
183
|
+
lab_id=lab_id,
|
|
184
|
+
operator_name=operator_name,
|
|
185
|
+
robot_profile=profile,
|
|
186
|
+
)
|
|
187
|
+
write_video_paths(f, video_paths, h5_path)
|
|
188
|
+
write_robot_states(
|
|
189
|
+
f,
|
|
190
|
+
{
|
|
191
|
+
"joint_position": qpos,
|
|
192
|
+
"gripper_position": qpos[:, GRIPPER_IDX],
|
|
193
|
+
},
|
|
194
|
+
profile.robot_state_keys,
|
|
195
|
+
)
|
|
196
|
+
write_actions(
|
|
197
|
+
f,
|
|
198
|
+
{
|
|
199
|
+
"joint_position": action,
|
|
200
|
+
"gripper_position": action[:, GRIPPER_IDX],
|
|
201
|
+
},
|
|
202
|
+
profile.action_space,
|
|
203
|
+
)
|
|
204
|
+
write_episode_annotations(f, annotator_name=annotator_name, success=success)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def discover(source: Path) -> list[Path]:
|
|
208
|
+
if source.is_file():
|
|
209
|
+
return [source]
|
|
210
|
+
files = sorted(p for p in source.iterdir() if p.suffix in {".h5", ".hdf5"})
|
|
211
|
+
if not files:
|
|
212
|
+
raise SystemExit(f"No .h5 or .hdf5 files found in {source}")
|
|
213
|
+
return files
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main() -> None:
|
|
217
|
+
parser = argparse.ArgumentParser(
|
|
218
|
+
description="Convert ACT/ALOHA HDF5 episodes to oopsiedata_format_v1 HDF5",
|
|
219
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
220
|
+
epilog=__doc__,
|
|
221
|
+
)
|
|
222
|
+
add_common_args(
|
|
223
|
+
parser,
|
|
224
|
+
source_help="A single ACT/ALOHA .hdf5 episode, or a directory of them.",
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
"--language",
|
|
228
|
+
"-l",
|
|
229
|
+
required=True,
|
|
230
|
+
help="Language instruction for every episode (the source files do not store one).",
|
|
231
|
+
)
|
|
232
|
+
parser.add_argument(
|
|
233
|
+
"--policy-name",
|
|
234
|
+
default="act_plus_plus",
|
|
235
|
+
help="Value for robot_profile.policy_name (default: act_plus_plus).",
|
|
236
|
+
)
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
"--control-freq",
|
|
239
|
+
type=int,
|
|
240
|
+
default=DEFAULT_CONTROL_FREQ,
|
|
241
|
+
help=f"Control frequency in Hz, also the video fps (default: {DEFAULT_CONTROL_FREQ}).",
|
|
242
|
+
)
|
|
243
|
+
parser.add_argument(
|
|
244
|
+
"--success-aggregation",
|
|
245
|
+
choices=["max", "last"],
|
|
246
|
+
default="max",
|
|
247
|
+
help="How to collapse the per-timestep /success flag to a scalar (default: max).",
|
|
248
|
+
)
|
|
249
|
+
args = parser.parse_args()
|
|
250
|
+
|
|
251
|
+
out = ConversionOutput.create(args.output_dir)
|
|
252
|
+
counts = run_batch(
|
|
253
|
+
discover(args.source.resolve()),
|
|
254
|
+
partial(
|
|
255
|
+
convert_one,
|
|
256
|
+
lab_id=args.lab_id,
|
|
257
|
+
operator_name=args.operator_name,
|
|
258
|
+
annotator_name=args.annotator_name,
|
|
259
|
+
language_instruction=args.language,
|
|
260
|
+
policy_name=args.policy_name,
|
|
261
|
+
control_freq=args.control_freq,
|
|
262
|
+
success_aggregation=args.success_aggregation,
|
|
263
|
+
),
|
|
264
|
+
out=out,
|
|
265
|
+
label=lambda path: path.name,
|
|
266
|
+
start_id=args.start_id,
|
|
267
|
+
desc="ACT/ALOHA",
|
|
268
|
+
overwrite=args.overwrite,
|
|
269
|
+
max_episodes=args.max_episodes,
|
|
270
|
+
)
|
|
271
|
+
report(counts)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
main()
|