First commit
This commit is contained in:
295
.gitignore
vendored
Normal file
295
.gitignore
vendored
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/osx,windows,linux,c++,python,rust
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=osx,windows,linux,c++,python,rust
|
||||||
|
|
||||||
|
out/
|
||||||
|
### C++ ###
|
||||||
|
# Prerequisites
|
||||||
|
*.d
|
||||||
|
|
||||||
|
# Compiled Object files
|
||||||
|
*.slo
|
||||||
|
*.lo
|
||||||
|
*.o
|
||||||
|
*.obj
|
||||||
|
|
||||||
|
# Precompiled Headers
|
||||||
|
*.gch
|
||||||
|
*.pch
|
||||||
|
|
||||||
|
# Compiled Dynamic libraries
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.dll
|
||||||
|
|
||||||
|
# Fortran module files
|
||||||
|
*.mod
|
||||||
|
*.smod
|
||||||
|
|
||||||
|
# Compiled Static libraries
|
||||||
|
*.lai
|
||||||
|
*.la
|
||||||
|
*.a
|
||||||
|
*.lib
|
||||||
|
|
||||||
|
# Executables
|
||||||
|
*.exe
|
||||||
|
*.out
|
||||||
|
*.app
|
||||||
|
|
||||||
|
### Linux ###
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# KDE directory preferences
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
### OSX ###
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
### Python ###
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
### Python Patch ###
|
||||||
|
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||||
|
poetry.toml
|
||||||
|
|
||||||
|
# ruff
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# LSP config files
|
||||||
|
pyrightconfig.json
|
||||||
|
|
||||||
|
### Rust ###
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug/
|
||||||
|
|
||||||
|
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||||
|
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
### Windows ###
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/osx,windows,linux,c++,python,rust
|
||||||
31
README.md
31
README.md
@@ -1,2 +1,29 @@
|
|||||||
# Littlehand
|
## Getting started
|
||||||
Littlehand
|
|
||||||
|
- Install it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv -p 3.12 --seed
|
||||||
|
dora build [dataflow.yml] --uv
|
||||||
|
```
|
||||||
|
|
||||||
|
- Run it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dora run [dataflow.yml] --uv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dataflows
|
||||||
|
|
||||||
|
| Dataflow | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `dataflow_ulite6.yml` | Ufactory ULite6 robot control with web UI|
|
||||||
|
| `dataflow_zed_cpp.yml` | ZED camera capture with image viewer |
|
||||||
|
|
||||||
|
## Nodes
|
||||||
|
|
||||||
|
| Node | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `ulite6` | UFactory Lite6 robot controller with REST API and web UI |
|
||||||
|
| `zed_camera_cpp` | ZED stereo camera capture (C++) |
|
||||||
|
| `image_viewer` | Display images from Dora stream |
|
||||||
|
|||||||
17
dataflow_ulite6.yml
Normal file
17
dataflow_ulite6.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
nodes:
|
||||||
|
- id: ulite6
|
||||||
|
build: uv pip install -e dora_ulite6
|
||||||
|
path: dora_ulite6/dora_ulite6/main.py
|
||||||
|
inputs:
|
||||||
|
tick: dora/timer/millis/10
|
||||||
|
outputs:
|
||||||
|
- status
|
||||||
|
- joint_pose
|
||||||
|
- tcp_pose
|
||||||
|
env:
|
||||||
|
ROBOT_IP: "192.168.1.192"
|
||||||
|
DEFAULT_SPEED: "30"
|
||||||
|
DEFAULT_UNITS: "mm"
|
||||||
|
API_HOST: "0.0.0.0"
|
||||||
|
API_PORT: "8080"
|
||||||
|
VACUUM_ENABLED: "true"
|
||||||
30
dataflow_zed_cpp.yml
Normal file
30
dataflow_zed_cpp.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
nodes:
|
||||||
|
- id: zed_camera_cpp
|
||||||
|
build: bash -lc "cmake -S dora_zed_cpp -B dora_zed_cpp/build && cmake --build dora_zed_cpp/build"
|
||||||
|
path: dora_zed_cpp/build/dora_zed_cpp
|
||||||
|
env:
|
||||||
|
ZED_RESOLUTION: "720"
|
||||||
|
ZED_FPS: "15"
|
||||||
|
ZED_DEPTH_MODE: "NEURAL"
|
||||||
|
ZED_DEPTH_MIN_MM: "10"
|
||||||
|
ZED_DEPTH_MAX_MM: "500"
|
||||||
|
ZED_DEPTH_FILL: "false"
|
||||||
|
ZED_FLIP: "ON"
|
||||||
|
ZED_WARMUP_FRAMES: "30"
|
||||||
|
inputs:
|
||||||
|
tick: dora/timer/millis/100
|
||||||
|
outputs:
|
||||||
|
- image_bgr
|
||||||
|
- point_cloud
|
||||||
|
- camera_info
|
||||||
|
- id: image_viewer
|
||||||
|
build: |
|
||||||
|
uv venv -p 3.12 --seed --allow-existing
|
||||||
|
uv pip install -e dora_image_viewer
|
||||||
|
path: dora_image_viewer/dora_image_viewer/main.py
|
||||||
|
env:
|
||||||
|
VIRTUAL_ENV: ./.venv
|
||||||
|
PLOT_WIDTH: 1280
|
||||||
|
PLOT_HEIGHT: 720
|
||||||
|
inputs:
|
||||||
|
image: zed_camera_cpp/image_bgr
|
||||||
95
dora_image_viewer/README.md
Normal file
95
dora_image_viewer/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Dora Node for plotting data with OpenCV
|
||||||
|
|
||||||
|
This node is used to plot a text and a list of bbox on a base image (ideal for object detection).
|
||||||
|
|
||||||
|
# YAML
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: dora-image-viewer
|
||||||
|
build: pip install ../../dora_image_viewer
|
||||||
|
path: dora_image_viewer/dora_image_viewer/main.py
|
||||||
|
inputs:
|
||||||
|
# image: Arrow array of size 1 containing the base image
|
||||||
|
# bbox: Arrow array of bbox
|
||||||
|
# text: Arrow array of size 1 containing the text to be plotted
|
||||||
|
|
||||||
|
env:
|
||||||
|
PLOT_WIDTH: 640 # optional, default is image input width
|
||||||
|
PLOT_HEIGHT: 480 # optional, default is image input height
|
||||||
|
```
|
||||||
|
|
||||||
|
# Inputs
|
||||||
|
|
||||||
|
- `image`: Arrow array containing the base image
|
||||||
|
|
||||||
|
```python
|
||||||
|
## Image data
|
||||||
|
image_data: UInt8Array # Example: pa.array(img.ravel())
|
||||||
|
metadata = {
|
||||||
|
"width": 640,
|
||||||
|
"height": 480,
|
||||||
|
"shape": [480, 640, 3], # optional alternative to width/height
|
||||||
|
"encoding": str, # bgr8, rgb8
|
||||||
|
}
|
||||||
|
|
||||||
|
## Example
|
||||||
|
node.send_output(
|
||||||
|
image_data, {"width": 640, "height": 480, "encoding": "bgr8"}
|
||||||
|
)
|
||||||
|
|
||||||
|
## Decoding
|
||||||
|
storage = event["value"]
|
||||||
|
|
||||||
|
metadata = event["metadata"]
|
||||||
|
encoding = metadata["encoding"]
|
||||||
|
width = metadata.get("width")
|
||||||
|
height = metadata.get("height")
|
||||||
|
shape = metadata.get("shape")
|
||||||
|
|
||||||
|
if encoding == "bgr8":
|
||||||
|
channels = 3
|
||||||
|
storage_type = np.uint8
|
||||||
|
|
||||||
|
frame = (
|
||||||
|
storage.to_numpy()
|
||||||
|
.astype(storage_type)
|
||||||
|
.reshape((height, width, channels))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `bbox`: an arrow array containing the bounding boxes, confidence scores, and class names of the detected objects
|
||||||
|
|
||||||
|
```Python
|
||||||
|
|
||||||
|
bbox: {
|
||||||
|
"bbox": np.array, # flattened array of bounding boxes
|
||||||
|
"conf": np.array, # flat array of confidence scores
|
||||||
|
"labels": np.array, # flat array of class names
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded_bbox = pa.array([bbox], {"format": "xyxy"})
|
||||||
|
|
||||||
|
decoded_bbox = {
|
||||||
|
"bbox": encoded_bbox[0]["bbox"].values.to_numpy().reshape(-1, 4),
|
||||||
|
"conf": encoded_bbox[0]["conf"].values.to_numpy(),
|
||||||
|
"labels": encoded_bbox[0]["labels"].values.to_numpy(zero_copy_only=False),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `text`: Arrow array containing the text to be plotted
|
||||||
|
|
||||||
|
```python
|
||||||
|
text: str
|
||||||
|
|
||||||
|
encoded_text = pa.array([text])
|
||||||
|
|
||||||
|
decoded_text = encoded_text[0].as_py()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Check example at [examples/python-dataflow](examples/python-dataflow)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under Apache-2.0. Check out [NOTICE.md](../../NOTICE.md) for more information.
|
||||||
13
dora_image_viewer/dora_image_viewer/__init__.py
Normal file
13
dora_image_viewer/dora_image_viewer/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""TODO: Add docstring."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Define the path to the README file relative to the package directory
|
||||||
|
readme_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "README.md")
|
||||||
|
|
||||||
|
# Read the content of the README file
|
||||||
|
try:
|
||||||
|
with open(readme_path, encoding="utf-8") as f:
|
||||||
|
__doc__ = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
__doc__ = "README file not found."
|
||||||
250
dora_image_viewer/dora_image_viewer/main.py
Normal file
250
dora_image_viewer/dora_image_viewer/main.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""TODO: Add docstring."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pyarrow as pa
|
||||||
|
from dora import Node
|
||||||
|
from PIL import (
|
||||||
|
Image,
|
||||||
|
)
|
||||||
|
|
||||||
|
if True:
|
||||||
|
import pillow_avif # noqa # noqa
|
||||||
|
|
||||||
|
RUNNER_CI = True if os.getenv("CI") == "true" else False
|
||||||
|
|
||||||
|
|
||||||
|
class Plot:
|
||||||
|
"""TODO: Add docstring."""
|
||||||
|
|
||||||
|
frame: np.array = np.array([])
|
||||||
|
|
||||||
|
bboxes: dict = {
|
||||||
|
"bbox": np.array([]),
|
||||||
|
"conf": np.array([]),
|
||||||
|
"labels": np.array([]),
|
||||||
|
}
|
||||||
|
|
||||||
|
text: str = ""
|
||||||
|
|
||||||
|
width: np.uint32 = None
|
||||||
|
height: np.uint32 = None
|
||||||
|
|
||||||
|
|
||||||
|
def plot_frame(plot):
|
||||||
|
"""TODO: Add docstring."""
|
||||||
|
for bbox in zip(plot.bboxes["bbox"], plot.bboxes["conf"], plot.bboxes["labels"]):
|
||||||
|
[
|
||||||
|
[min_x, min_y, max_x, max_y],
|
||||||
|
confidence,
|
||||||
|
label,
|
||||||
|
] = bbox
|
||||||
|
cv2.rectangle(
|
||||||
|
plot.frame,
|
||||||
|
(int(min_x), int(min_y)),
|
||||||
|
(int(max_x), int(max_y)),
|
||||||
|
(0, 255, 0),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
plot.frame,
|
||||||
|
f"{label}, {confidence:0.2f}",
|
||||||
|
(int(max_x) - 120, int(max_y) - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
(0, 255, 0),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
plot.frame,
|
||||||
|
plot.text,
|
||||||
|
(20, 20),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.5,
|
||||||
|
(255, 255, 255),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if plot.width is not None and plot.height is not None:
|
||||||
|
plot.frame = cv2.resize(plot.frame, (plot.width, plot.height))
|
||||||
|
|
||||||
|
if not RUNNER_CI:
|
||||||
|
if len(plot.frame.shape) >= 3:
|
||||||
|
cv2.imshow("Dora Node: dora-image-viewer", plot.frame)
|
||||||
|
|
||||||
|
|
||||||
|
def yuv420p_to_bgr_opencv(yuv_array, width, height):
|
||||||
|
"""TODO: Add docstring."""
|
||||||
|
yuv_array = yuv_array[: width * height * 3 // 2]
|
||||||
|
yuv = yuv_array.reshape((height * 3 // 2, width))
|
||||||
|
return cv2.cvtColor(yuv, cv2.COLOR_YUV420p2RGB)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Handle dynamic nodes, ask for the name of the node in the dataflow, and the same values as the ENV variables.
|
||||||
|
"""TODO: Add docstring."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="OpenCV Plotter: This node is used to plot text and bounding boxes on an image.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="The name of the node in the dataflow.",
|
||||||
|
default="dora-image-viewer",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--plot-width",
|
||||||
|
type=int,
|
||||||
|
required=False,
|
||||||
|
help="The width of the plot.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--plot-height",
|
||||||
|
type=int,
|
||||||
|
required=False,
|
||||||
|
help="The height of the plot.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
plot_width = os.getenv("PLOT_WIDTH", args.plot_width)
|
||||||
|
plot_height = os.getenv("PLOT_HEIGHT", args.plot_height)
|
||||||
|
|
||||||
|
if plot_width is not None:
|
||||||
|
if isinstance(plot_width, str) and plot_width.isnumeric():
|
||||||
|
plot_width = int(plot_width)
|
||||||
|
|
||||||
|
if plot_height is not None:
|
||||||
|
if isinstance(plot_height, str) and plot_height.isnumeric():
|
||||||
|
plot_height = int(plot_height)
|
||||||
|
|
||||||
|
node = Node(
|
||||||
|
args.name,
|
||||||
|
) # provide the name to connect to the dataflow if dynamic node
|
||||||
|
plot = Plot()
|
||||||
|
|
||||||
|
plot.width = plot_width
|
||||||
|
plot.height = plot_height
|
||||||
|
|
||||||
|
pa.array([]) # initialize pyarrow array
|
||||||
|
|
||||||
|
for event in node:
|
||||||
|
event_type = event["type"]
|
||||||
|
|
||||||
|
if event_type == "INPUT":
|
||||||
|
event_id = event["id"]
|
||||||
|
|
||||||
|
if event_id == "image":
|
||||||
|
storage = event["value"]
|
||||||
|
|
||||||
|
metadata = event["metadata"]
|
||||||
|
encoding = metadata["encoding"].lower()
|
||||||
|
width = metadata.get("width")
|
||||||
|
height = metadata.get("height")
|
||||||
|
if (width is None or height is None) and "shape" in metadata:
|
||||||
|
shape = metadata["shape"]
|
||||||
|
if isinstance(shape, (list, tuple)) and len(shape) >= 2:
|
||||||
|
height = height if height is not None else int(shape[0])
|
||||||
|
width = width if width is not None else int(shape[1])
|
||||||
|
if width is None or height is None:
|
||||||
|
raise KeyError("width/height (or shape) missing from metadata")
|
||||||
|
if encoding == "bgr8":
|
||||||
|
channels = 3
|
||||||
|
storage_type = np.uint8
|
||||||
|
plot.frame = (
|
||||||
|
storage.to_numpy()
|
||||||
|
.astype(storage_type)
|
||||||
|
.reshape((height, width, channels))
|
||||||
|
.copy() # Copy So that we can add annotation on the image
|
||||||
|
)
|
||||||
|
elif encoding == "rgb8":
|
||||||
|
channels = 3
|
||||||
|
storage_type = np.uint8
|
||||||
|
frame = (
|
||||||
|
storage.to_numpy()
|
||||||
|
.astype(storage_type)
|
||||||
|
.reshape((height, width, channels))
|
||||||
|
)
|
||||||
|
|
||||||
|
plot.frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
elif encoding in ["jpeg", "jpg", "jpe", "bmp", "webp", "png"]:
|
||||||
|
channels = 3
|
||||||
|
storage_type = np.uint8
|
||||||
|
storage = storage.to_numpy()
|
||||||
|
plot.frame = cv2.imdecode(storage, cv2.IMREAD_COLOR)
|
||||||
|
|
||||||
|
elif encoding == "yuv420":
|
||||||
|
storage = storage.to_numpy()
|
||||||
|
|
||||||
|
# Convert back to BGR results in more saturated image.
|
||||||
|
channels = 3
|
||||||
|
storage_type = np.uint8
|
||||||
|
img_bgr_restored = yuv420p_to_bgr_opencv(storage, width, height)
|
||||||
|
|
||||||
|
plot.frame = img_bgr_restored
|
||||||
|
elif encoding == "avif":
|
||||||
|
# Convert AVIF to RGB
|
||||||
|
array = storage.to_numpy()
|
||||||
|
bytes = array.tobytes()
|
||||||
|
img = Image.open(io.BytesIO(bytes))
|
||||||
|
img = img.convert("RGB")
|
||||||
|
plot.frame = np.array(img)
|
||||||
|
plot.frame = cv2.cvtColor(plot.frame, cv2.COLOR_RGB2BGR)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unsupported image encoding: {encoding}")
|
||||||
|
|
||||||
|
plot_frame(plot)
|
||||||
|
if not RUNNER_CI:
|
||||||
|
if cv2.waitKey(1) & 0xFF == ord("q"):
|
||||||
|
break
|
||||||
|
|
||||||
|
elif event_id == "bbox":
|
||||||
|
arrow_bbox = event["value"][0]
|
||||||
|
bbox_format = event["metadata"]["format"].lower()
|
||||||
|
|
||||||
|
if bbox_format == "xyxy":
|
||||||
|
bbox = arrow_bbox["bbox"].values.to_numpy().reshape(-1, 4)
|
||||||
|
elif bbox_format == "xywh":
|
||||||
|
original_bbox = arrow_bbox["bbox"].values.to_numpy().reshape(-1, 4)
|
||||||
|
bbox = np.array(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
x - w / 2,
|
||||||
|
y - h / 2,
|
||||||
|
x + w / 2,
|
||||||
|
y + h / 2,
|
||||||
|
)
|
||||||
|
for [x, y, w, h] in original_bbox
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unsupported bbox format: {bbox_format}")
|
||||||
|
|
||||||
|
plot.bboxes = {
|
||||||
|
"bbox": bbox,
|
||||||
|
"conf": arrow_bbox["conf"].values.to_numpy(),
|
||||||
|
"labels": arrow_bbox["labels"].values.to_numpy(
|
||||||
|
zero_copy_only=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
elif event_id == "text":
|
||||||
|
plot.text = event["value"][0].as_py()
|
||||||
|
elif event_type == "ERROR":
|
||||||
|
raise RuntimeError(event["error"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
37
dora_image_viewer/pyproject.toml
Normal file
37
dora_image_viewer/pyproject.toml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[project]
|
||||||
|
name = "dora-image-viewer"
|
||||||
|
version = "0.4.1"
|
||||||
|
license = { file = "MIT" }
|
||||||
|
authors = [
|
||||||
|
{ name = "Haixuan Xavier Tao", email = "tao.xavier@outlook.com" },
|
||||||
|
{ name = "Enzo Le Van", email = "dev@enzo-le-van.fr" },
|
||||||
|
]
|
||||||
|
description = "Dora Node for plotting text and bbox on image with OpenCV"
|
||||||
|
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"dora-rs >= 0.3.9",
|
||||||
|
"numpy < 2.0.0",
|
||||||
|
"opencv-python >= 4.1.1",
|
||||||
|
"pillow-avif-plugin>=1.5.1",
|
||||||
|
"pillow>=10.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest >=8.1.1", "ruff >=0.9.1"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
dora-image-viewer = "dora_image_viewer.main:main"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
extend-select = [
|
||||||
|
"D", # pydocstyle
|
||||||
|
"UP", # Ruff's UP rule
|
||||||
|
"PERF", # Ruff's PERF rule
|
||||||
|
"RET", # Ruff's RET rule
|
||||||
|
"RSE", # Ruff's RSE rule
|
||||||
|
"NPY", # Ruff's NPY rule
|
||||||
|
"N", # Ruff's N rule
|
||||||
|
"I", # Ruff's I rule
|
||||||
|
]
|
||||||
12
dora_image_viewer/tests/test_dora_image_viewer.py
Normal file
12
dora_image_viewer/tests/test_dora_image_viewer.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""TODO: Add docstring."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_main():
|
||||||
|
"""TODO: Add docstring."""
|
||||||
|
from dora_image_viewer.main import main
|
||||||
|
|
||||||
|
# Check that everything is working, and catch dora Runtime Exception as we're not running in a dora dataflow.
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
main()
|
||||||
92
dora_ulite6/README.md
Normal file
92
dora_ulite6/README.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Dora ULite6 Node
|
||||||
|
|
||||||
|
Dora node for controlling a UFactory Lite6 robot via REST API, web UI, and publishing joint/TCP state.
|
||||||
|
|
||||||
|
## Dataflow
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: ulite6
|
||||||
|
build: uv pip install -e dora_ulite6
|
||||||
|
path: dora_ulite6/dora_ulite6/main.py
|
||||||
|
inputs:
|
||||||
|
tick: dora/timer/millis/10
|
||||||
|
outputs: [status, joint_pose, tcp_pose]
|
||||||
|
env:
|
||||||
|
ROBOT_IP: "192.168.1.192"
|
||||||
|
DEFAULT_SPEED: "30"
|
||||||
|
DEFAULT_UNITS: "mm"
|
||||||
|
API_HOST: "0.0.0.0"
|
||||||
|
API_PORT: "8080"
|
||||||
|
VACUUM_ENABLED: "false"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env Variable | Default | Description |
|
||||||
|
|--------------|---------|-------------|
|
||||||
|
| `ROBOT_IP` | `192.168.1.192` | Robot IP address |
|
||||||
|
| `DEFAULT_SPEED` | `30` | Movement speed (mm/s) |
|
||||||
|
| `DEFAULT_UNITS` | `mm` | Position units (mm or m) |
|
||||||
|
| `API_HOST` | `0.0.0.0` | API server host |
|
||||||
|
| `API_PORT` | `8080` | API server port |
|
||||||
|
| `VACUUM_ENABLED` | `false` | Enable vacuum gripper controls |
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
Access at `http://localhost:8080/`
|
||||||
|
|
||||||
|
- Live status, TCP pose, and joint angles
|
||||||
|
- Home and Reset buttons
|
||||||
|
- Move to position form
|
||||||
|
- Vacuum gripper controls (when enabled)
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
Interactive docs at `http://localhost:8080/docs`
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/` | Web control interface |
|
||||||
|
| GET | `/api/status` | Robot status (connected, errors) |
|
||||||
|
| GET | `/api/pose` | Current TCP pose |
|
||||||
|
| GET | `/api/joints` | Current joint angles |
|
||||||
|
| GET | `/api/config` | API configuration |
|
||||||
|
| POST | `/api/home` | Go to home position |
|
||||||
|
| POST | `/api/reset` | Clear errors and reset state |
|
||||||
|
| POST | `/api/move_to` | Move to position |
|
||||||
|
| POST | `/api/move_to_pose` | Move to full pose |
|
||||||
|
| POST | `/api/disconnect` | Disconnect from robot |
|
||||||
|
|
||||||
|
### Vacuum Gripper (when `VACUUM_ENABLED=true`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/vacuum` | Get vacuum status |
|
||||||
|
| POST | `/api/vacuum/on` | Turn vacuum on |
|
||||||
|
| POST | `/api/vacuum/off` | Turn vacuum off |
|
||||||
|
|
||||||
|
## CLI Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get pose
|
||||||
|
curl http://localhost:8080/api/pose
|
||||||
|
|
||||||
|
# Go home
|
||||||
|
curl -X POST http://localhost:8080/api/home
|
||||||
|
|
||||||
|
# Move to position
|
||||||
|
curl -X POST http://localhost:8080/api/move_to \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"x": 200, "y": 0, "z": 300}'
|
||||||
|
|
||||||
|
# Move with orientation
|
||||||
|
curl -X POST http://localhost:8080/api/move_to_pose \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"x": 200, "y": 0, "z": 300, "roll": 180, "pitch": 0, "yaw": 0}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dora Outputs
|
||||||
|
|
||||||
|
- `status` - JSON: `{ok, action, message, timestamp_ns}`
|
||||||
|
- `joint_pose` - 6 joint angles in degrees
|
||||||
|
- `tcp_pose` - `[x, y, z, roll, pitch, yaw]` in mm/deg
|
||||||
1
dora_ulite6/dora_ulite6/__init__.py
Normal file
1
dora_ulite6/dora_ulite6/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Dora ULite6 node package."""
|
||||||
780
dora_ulite6/dora_ulite6/main.py
Normal file
780
dora_ulite6/dora_ulite6/main.py
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
"""Dora node for controlling a UFactory Lite6 robot and publishing state."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyarrow as pa
|
||||||
|
import uvicorn
|
||||||
|
from dora import Node
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
from xarm.wrapper import XArmAPI
|
||||||
|
|
||||||
|
|
||||||
|
class ULite6Helper:
|
||||||
|
"""Minimal ULite6 helper based on ufactory-control."""
|
||||||
|
|
||||||
|
def __init__(self, robot_ip: str):
|
||||||
|
self.robot_ip = robot_ip
|
||||||
|
self.arm = XArmAPI(self.robot_ip)
|
||||||
|
self.arm.connect()
|
||||||
|
self.arm.motion_enable(enable=True)
|
||||||
|
|
||||||
|
def go_home(self) -> int:
|
||||||
|
self.arm.set_mode(0)
|
||||||
|
self.arm.set_state(state=0)
|
||||||
|
code = self.arm.move_gohome(wait=True)
|
||||||
|
# move_gohome may return tuple (code, result) in some versions
|
||||||
|
if isinstance(code, tuple):
|
||||||
|
code = code[0]
|
||||||
|
return code
|
||||||
|
|
||||||
|
def move_to_pose(
|
||||||
|
self,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
roll: float,
|
||||||
|
pitch: float,
|
||||||
|
yaw: float,
|
||||||
|
speed: float = 30.0,
|
||||||
|
units: str = "mm",
|
||||||
|
) -> int:
|
||||||
|
if units == "m":
|
||||||
|
x, y, z = x * 1000.0, y * 1000.0, z * 1000.0
|
||||||
|
self.arm.set_mode(0)
|
||||||
|
self.arm.set_state(0)
|
||||||
|
return self.arm.set_position(
|
||||||
|
x, y, z, roll=roll, pitch=pitch, yaw=yaw, speed=speed, wait=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_current_position(self) -> Dict[str, float]:
|
||||||
|
code, pos = self.arm.get_position()
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f"get_position failed with code {code}")
|
||||||
|
return {
|
||||||
|
"x": pos[0],
|
||||||
|
"y": pos[1],
|
||||||
|
"z": pos[2],
|
||||||
|
"roll": pos[3],
|
||||||
|
"pitch": pos[4],
|
||||||
|
"yaw": pos[5],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_pose_matrix(self, tcp_offset_mm: float = 0.0) -> np.ndarray:
|
||||||
|
pos = self.get_current_position()
|
||||||
|
R = Rotation.from_euler(
|
||||||
|
"xyz", [pos["roll"], pos["pitch"], pos["yaw"]], degrees=True
|
||||||
|
).as_matrix()
|
||||||
|
|
||||||
|
position_m = np.array([pos["x"], pos["y"], pos["z"]]) / 1000.0
|
||||||
|
if tcp_offset_mm != 0.0:
|
||||||
|
tcp_offset_local = np.array([0, 0, tcp_offset_mm]) / 1000.0
|
||||||
|
tcp_offset_global = R @ tcp_offset_local
|
||||||
|
position_m = position_m + tcp_offset_global
|
||||||
|
|
||||||
|
T = np.eye(4)
|
||||||
|
T[:3, :3] = R
|
||||||
|
T[:3, 3] = position_m
|
||||||
|
return T
|
||||||
|
|
||||||
|
def get_joint_angles_deg(self) -> np.ndarray:
|
||||||
|
code, angles = self.arm.get_servo_angle(is_radian=False)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f"get_servo_angle failed with code {code}")
|
||||||
|
return np.array(angles, dtype=float)
|
||||||
|
|
||||||
|
def get_status(self) -> Dict[str, Any]:
|
||||||
|
"""Get robot status information."""
|
||||||
|
return {
|
||||||
|
"connected": self.arm.connected,
|
||||||
|
"state": self.arm.state,
|
||||||
|
"mode": self.arm.mode,
|
||||||
|
"error_code": self.arm.error_code,
|
||||||
|
"warn_code": self.arm.warn_code,
|
||||||
|
"has_error": self.arm.has_error,
|
||||||
|
"has_warn": self.arm.has_warn,
|
||||||
|
"motor_enable_states": self.arm.motor_enable_states,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset_state(self) -> int:
|
||||||
|
"""Clear errors/warnings and reset robot state."""
|
||||||
|
self.arm.clean_error()
|
||||||
|
self.arm.clean_warn()
|
||||||
|
self.arm.motion_enable(enable=True)
|
||||||
|
self.arm.set_mode(0)
|
||||||
|
return self.arm.set_state(0)
|
||||||
|
|
||||||
|
def set_vacuum_gripper(self, on: bool) -> int:
|
||||||
|
"""Turn vacuum gripper on or off."""
|
||||||
|
code = self.arm.set_vacuum_gripper(on)
|
||||||
|
if isinstance(code, tuple):
|
||||||
|
code = code[0]
|
||||||
|
return code
|
||||||
|
|
||||||
|
def get_vacuum_gripper(self) -> Dict[str, Any]:
|
||||||
|
"""Get vacuum gripper status."""
|
||||||
|
code, status = self.arm.get_vacuum_gripper()
|
||||||
|
return {"code": code, "on": status == 1}
|
||||||
|
|
||||||
|
def disconnect(self) -> int:
|
||||||
|
return self.arm.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pydantic models for API requests ---
|
||||||
|
|
||||||
|
|
||||||
|
class MoveToRequest(BaseModel):
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
z: float
|
||||||
|
speed: Optional[float] = None
|
||||||
|
roll: Optional[float] = 180.0
|
||||||
|
pitch: Optional[float] = 0.0
|
||||||
|
yaw: Optional[float] = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class MoveToPoseRequest(BaseModel):
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
z: float
|
||||||
|
roll: float
|
||||||
|
pitch: float
|
||||||
|
yaw: float
|
||||||
|
speed: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- FastAPI app factory ---
|
||||||
|
|
||||||
|
|
||||||
|
def create_api(
|
||||||
|
helper: ULite6Helper, default_speed: float, default_units: str, vacuum_enabled: bool
|
||||||
|
) -> FastAPI:
|
||||||
|
"""Create FastAPI application with robot control endpoints."""
|
||||||
|
app = FastAPI(
|
||||||
|
title="ULite6 Robot Control API",
|
||||||
|
description="REST API for controlling UFactory Lite6 robot",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def web_ui():
|
||||||
|
"""Serve web control interface."""
|
||||||
|
return """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ULite6 Control</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #00d4ff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #16213e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
color: #00d4ff;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.status-item {
|
||||||
|
background: #0f3460;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.status-item .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.status-item .value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-item .value.ok { color: #4ade80; }
|
||||||
|
.status-item .value.error { color: #f87171; }
|
||||||
|
.pose-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pose-item {
|
||||||
|
background: #0f3460;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pose-item .label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.pose-item .value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.joints-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #00d4ff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
button:hover { background: #00d4ff; color: #1a1a2e; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
button.danger { border-color: #f87171; }
|
||||||
|
button.danger:hover { background: #f87171; }
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
background: #0f3460;
|
||||||
|
border: 1px solid #16213e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}
|
||||||
|
#log {
|
||||||
|
background: #0a0a15;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid #16213e;
|
||||||
|
}
|
||||||
|
.log-entry:last-child { border-bottom: none; }
|
||||||
|
.log-entry.success { color: #4ade80; }
|
||||||
|
.log-entry.error { color: #f87171; }
|
||||||
|
.log-entry .time { color: #666; margin-right: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ULite6 Robot Control</h1>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Status</h2>
|
||||||
|
<div id="status" class="status-grid">
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="label">Connected</div>
|
||||||
|
<div class="value" id="st-connected">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="label">State</div>
|
||||||
|
<div class="value" id="st-state">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="label">Mode</div>
|
||||||
|
<div class="value" id="st-mode">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="label">Error</div>
|
||||||
|
<div class="value" id="st-error">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="label">Warning</div>
|
||||||
|
<div class="value" id="st-warn">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>TCP Pose (mm, deg)</h2>
|
||||||
|
<div class="pose-grid">
|
||||||
|
<div class="pose-item"><div class="label">X</div><div class="value" id="pose-x">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">Y</div><div class="value" id="pose-y">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">Z</div><div class="value" id="pose-z">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">Roll</div><div class="value" id="pose-roll">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">Pitch</div><div class="value" id="pose-pitch">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">Yaw</div><div class="value" id="pose-yaw">--</div></div>
|
||||||
|
</div>
|
||||||
|
<h2 style="margin-top: 16px;">Joint Angles (deg)</h2>
|
||||||
|
<div class="joints-grid">
|
||||||
|
<div class="pose-item"><div class="label">J1</div><div class="value" id="j0">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">J2</div><div class="value" id="j1">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">J3</div><div class="value" id="j2">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">J4</div><div class="value" id="j3">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">J5</div><div class="value" id="j4">--</div></div>
|
||||||
|
<div class="pose-item"><div class="label">J6</div><div class="value" id="j5">--</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Controls</h2>
|
||||||
|
<div class="btn-group" style="margin-bottom: 16px;">
|
||||||
|
<button onclick="goHome()" id="btn-home">Home</button>
|
||||||
|
<button onclick="resetState()" id="btn-reset">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div id="vacuum-controls" style="display: none; margin-bottom: 16px;">
|
||||||
|
<h2>Vacuum Gripper</h2>
|
||||||
|
<div class="btn-group" style="margin-top: 8px;">
|
||||||
|
<button onclick="vacuumOn()" id="btn-vacuum-on">Vacuum ON</button>
|
||||||
|
<button onclick="vacuumOff()" id="btn-vacuum-off">Vacuum OFF</button>
|
||||||
|
<span id="vacuum-status" style="margin-left: 12px; padding: 8px;">Status: --</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Move To Position</h2>
|
||||||
|
<form id="moveForm" onsubmit="moveTo(event)">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="move-x">X (mm)</label>
|
||||||
|
<input type="number" id="move-x" step="0.1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="move-y">Y (mm)</label>
|
||||||
|
<input type="number" id="move-y" step="0.1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="move-z">Z (mm)</label>
|
||||||
|
<input type="number" id="move-z" step="0.1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="move-speed">Speed (mm/s)</label>
|
||||||
|
<input type="number" id="move-speed" step="1" value="30" min="1" max="200">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="btn-move">Move</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Response Log</h2>
|
||||||
|
<div id="log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
function log(msg, isError = false) {
|
||||||
|
const logEl = $('log');
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'log-entry ' + (isError ? 'error' : 'success');
|
||||||
|
entry.innerHTML = '<span class="time">' + time + '</span>' + msg;
|
||||||
|
logEl.insertBefore(entry, logEl.firstChild);
|
||||||
|
if (logEl.children.length > 50) logEl.removeChild(logEl.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, opts = {}) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus() {
|
||||||
|
const data = await fetchJson('/api/status');
|
||||||
|
if (data.error) {
|
||||||
|
$('st-connected').textContent = 'Error';
|
||||||
|
$('st-connected').className = 'value error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('st-connected').textContent = data.connected ? 'Yes' : 'No';
|
||||||
|
$('st-connected').className = 'value ' + (data.connected ? 'ok' : 'error');
|
||||||
|
$('st-state').textContent = data.state;
|
||||||
|
$('st-mode').textContent = data.mode;
|
||||||
|
$('st-error').textContent = data.error_code;
|
||||||
|
$('st-error').className = 'value ' + (data.error_code === 0 ? 'ok' : 'error');
|
||||||
|
$('st-warn').textContent = data.warn_code;
|
||||||
|
$('st-warn').className = 'value ' + (data.warn_code === 0 ? 'ok' : 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePose() {
|
||||||
|
const pose = await fetchJson('/api/pose');
|
||||||
|
if (!pose.error) {
|
||||||
|
$('pose-x').textContent = pose.x.toFixed(1);
|
||||||
|
$('pose-y').textContent = pose.y.toFixed(1);
|
||||||
|
$('pose-z').textContent = pose.z.toFixed(1);
|
||||||
|
$('pose-roll').textContent = pose.roll.toFixed(1);
|
||||||
|
$('pose-pitch').textContent = pose.pitch.toFixed(1);
|
||||||
|
$('pose-yaw').textContent = pose.yaw.toFixed(1);
|
||||||
|
}
|
||||||
|
const joints = await fetchJson('/api/joints');
|
||||||
|
if (!joints.error && joints.joints) {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
$('j' + i).textContent = joints.joints[i].toFixed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goHome() {
|
||||||
|
$('btn-home').disabled = true;
|
||||||
|
log('Sending Home command...');
|
||||||
|
const res = await fetchJson('/api/home', { method: 'POST' });
|
||||||
|
$('btn-home').disabled = false;
|
||||||
|
if (res.ok) {
|
||||||
|
log('Home completed (code: ' + res.code + ')');
|
||||||
|
} else {
|
||||||
|
log('Home failed: ' + (res.detail || res.code), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetState() {
|
||||||
|
$('btn-reset').disabled = true;
|
||||||
|
log('Resetting robot state...');
|
||||||
|
const res = await fetchJson('/api/reset', { method: 'POST' });
|
||||||
|
$('btn-reset').disabled = false;
|
||||||
|
if (res.ok) {
|
||||||
|
log('Reset completed (code: ' + res.code + ')');
|
||||||
|
} else {
|
||||||
|
log('Reset failed: ' + (res.detail || res.code), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveTo(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const x = parseFloat($('move-x').value);
|
||||||
|
const y = parseFloat($('move-y').value);
|
||||||
|
const z = parseFloat($('move-z').value);
|
||||||
|
const speed = parseFloat($('move-speed').value) || 30;
|
||||||
|
|
||||||
|
$('btn-move').disabled = true;
|
||||||
|
log('Moving to (' + x + ', ' + y + ', ' + z + ') at ' + speed + ' mm/s...');
|
||||||
|
|
||||||
|
const res = await fetchJson('/api/move_to', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ x, y, z, speed })
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btn-move').disabled = false;
|
||||||
|
if (res.ok) {
|
||||||
|
log('Move completed (code: ' + res.code + ')');
|
||||||
|
} else {
|
||||||
|
log('Move failed: ' + (res.detail || res.code), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vacuum gripper functions
|
||||||
|
let vacuumEnabled = false;
|
||||||
|
|
||||||
|
async function initConfig() {
|
||||||
|
const config = await fetchJson('/api/config');
|
||||||
|
if (config.vacuum_enabled) {
|
||||||
|
vacuumEnabled = true;
|
||||||
|
$('vacuum-controls').style.display = 'block';
|
||||||
|
updateVacuumStatus();
|
||||||
|
setInterval(updateVacuumStatus, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateVacuumStatus() {
|
||||||
|
if (!vacuumEnabled) return;
|
||||||
|
const data = await fetchJson('/api/vacuum');
|
||||||
|
if (!data.error) {
|
||||||
|
const statusEl = $('vacuum-status');
|
||||||
|
statusEl.textContent = 'Status: ' + (data.on ? 'ON' : 'OFF');
|
||||||
|
statusEl.style.color = data.on ? '#4ade80' : '#888';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vacuumOn() {
|
||||||
|
$('btn-vacuum-on').disabled = true;
|
||||||
|
log('Turning vacuum ON...');
|
||||||
|
const res = await fetchJson('/api/vacuum/on', { method: 'POST' });
|
||||||
|
$('btn-vacuum-on').disabled = false;
|
||||||
|
if (res.ok) {
|
||||||
|
log('Vacuum ON (code: ' + res.code + ')');
|
||||||
|
updateVacuumStatus();
|
||||||
|
} else {
|
||||||
|
log('Vacuum ON failed: ' + (res.detail || res.code), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vacuumOff() {
|
||||||
|
$('btn-vacuum-off').disabled = true;
|
||||||
|
log('Turning vacuum OFF...');
|
||||||
|
const res = await fetchJson('/api/vacuum/off', { method: 'POST' });
|
||||||
|
$('btn-vacuum-off').disabled = false;
|
||||||
|
if (res.ok) {
|
||||||
|
log('Vacuum OFF (code: ' + res.code + ')');
|
||||||
|
updateVacuumStatus();
|
||||||
|
} else {
|
||||||
|
log('Vacuum OFF failed: ' + (res.detail || res.code), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
setInterval(updateStatus, 1000);
|
||||||
|
setInterval(updatePose, 500);
|
||||||
|
updateStatus();
|
||||||
|
updatePose();
|
||||||
|
initConfig();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
def get_status():
|
||||||
|
"""Get robot status (connected, enabled, errors)."""
|
||||||
|
try:
|
||||||
|
return helper.get_status()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/pose")
|
||||||
|
def get_pose():
|
||||||
|
"""Get current TCP pose [x, y, z, roll, pitch, yaw]."""
|
||||||
|
try:
|
||||||
|
return helper.get_current_position()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/joints")
|
||||||
|
def get_joints():
|
||||||
|
"""Get current joint angles in degrees."""
|
||||||
|
try:
|
||||||
|
angles = helper.get_joint_angles_deg()
|
||||||
|
return {"joints": angles.tolist(), "units": "deg"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/home")
|
||||||
|
def go_home():
|
||||||
|
"""Send robot to home position."""
|
||||||
|
try:
|
||||||
|
code = helper.go_home()
|
||||||
|
return {"ok": code == 0, "code": code}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/move_to")
|
||||||
|
def move_to(request: MoveToRequest):
|
||||||
|
"""Move to position with optional orientation."""
|
||||||
|
try:
|
||||||
|
speed = request.speed if request.speed is not None else default_speed
|
||||||
|
code = helper.move_to_pose(
|
||||||
|
request.x,
|
||||||
|
request.y,
|
||||||
|
request.z,
|
||||||
|
request.roll,
|
||||||
|
request.pitch,
|
||||||
|
request.yaw,
|
||||||
|
speed,
|
||||||
|
default_units,
|
||||||
|
)
|
||||||
|
return {"ok": code == 0, "code": code}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/move_to_pose")
|
||||||
|
def move_to_pose(request: MoveToPoseRequest):
|
||||||
|
"""Move to full pose with position and orientation."""
|
||||||
|
try:
|
||||||
|
speed = request.speed if request.speed is not None else default_speed
|
||||||
|
code = helper.move_to_pose(
|
||||||
|
request.x,
|
||||||
|
request.y,
|
||||||
|
request.z,
|
||||||
|
request.roll,
|
||||||
|
request.pitch,
|
||||||
|
request.yaw,
|
||||||
|
speed,
|
||||||
|
default_units,
|
||||||
|
)
|
||||||
|
return {"ok": code == 0, "code": code}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/reset")
|
||||||
|
def reset_state():
|
||||||
|
"""Clear errors/warnings and reset robot state."""
|
||||||
|
try:
|
||||||
|
code = helper.reset_state()
|
||||||
|
return {"ok": code == 0, "code": code}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/disconnect")
|
||||||
|
def disconnect():
|
||||||
|
"""Disconnect from robot."""
|
||||||
|
try:
|
||||||
|
code = helper.disconnect()
|
||||||
|
return {"ok": code == 0, "code": code}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
# Vacuum gripper endpoints (only if enabled)
|
||||||
|
if vacuum_enabled:
|
||||||
|
|
||||||
|
@app.get("/api/vacuum")
|
||||||
|
def get_vacuum():
|
||||||
|
"""Get vacuum gripper status."""
|
||||||
|
try:
|
||||||
|
return helper.get_vacuum_gripper()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/vacuum/on")
|
||||||
|
def vacuum_on():
|
||||||
|
"""Turn vacuum gripper on."""
|
||||||
|
try:
|
||||||
|
code = helper.set_vacuum_gripper(True)
|
||||||
|
return {"ok": code == 0, "code": code}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/vacuum/off")
|
||||||
|
def vacuum_off():
|
||||||
|
"""Turn vacuum gripper off."""
|
||||||
|
try:
|
||||||
|
code = helper.set_vacuum_gripper(False)
|
||||||
|
return {"ok": code == 0, "code": code}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/config")
|
||||||
|
def get_config():
|
||||||
|
"""Get API configuration."""
|
||||||
|
return {"vacuum_enabled": vacuum_enabled}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def run_uvicorn(app: FastAPI, host: str, port: int) -> None:
|
||||||
|
"""Run uvicorn server (for use in background thread)."""
|
||||||
|
config = uvicorn.Config(app, host=host, port=port, log_level="warning")
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_joint_pose(node: Node, joints_deg: np.ndarray) -> None:
|
||||||
|
metadata = {"units": "deg", "timestamp_ns": time.time_ns()}
|
||||||
|
node.send_output("joint_pose", pa.array(joints_deg.tolist()), metadata=metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_tcp_pose(node: Node, tcp_pose: Dict[str, float]) -> None:
|
||||||
|
metadata = {
|
||||||
|
"units_position": "mm",
|
||||||
|
"units_rotation": "deg",
|
||||||
|
"timestamp_ns": time.time_ns(),
|
||||||
|
}
|
||||||
|
vec = [
|
||||||
|
tcp_pose["x"],
|
||||||
|
tcp_pose["y"],
|
||||||
|
tcp_pose["z"],
|
||||||
|
tcp_pose["roll"],
|
||||||
|
tcp_pose["pitch"],
|
||||||
|
tcp_pose["yaw"],
|
||||||
|
]
|
||||||
|
node.send_output("tcp_pose", pa.array(vec), metadata=metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_error_status(node: Node, action: str, message: str) -> None:
|
||||||
|
payload = {
|
||||||
|
"ok": False,
|
||||||
|
"action": action,
|
||||||
|
"message": message,
|
||||||
|
"timestamp_ns": time.time_ns(),
|
||||||
|
}
|
||||||
|
node.send_output("status", pa.array([json.dumps(payload)]))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
node = Node()
|
||||||
|
|
||||||
|
robot_ip = os.getenv("ROBOT_IP", "192.168.1.192")
|
||||||
|
default_speed = float(os.getenv("DEFAULT_SPEED", "30"))
|
||||||
|
default_units = os.getenv("DEFAULT_UNITS", "mm")
|
||||||
|
api_host = os.getenv("API_HOST", "0.0.0.0")
|
||||||
|
api_port = int(os.getenv("API_PORT", "8080"))
|
||||||
|
vacuum_enabled = os.getenv("VACUUM_ENABLED", "false").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
helper = ULite6Helper(robot_ip)
|
||||||
|
|
||||||
|
# Create and start FastAPI server in background thread
|
||||||
|
app = create_api(helper, default_speed, default_units, vacuum_enabled)
|
||||||
|
api_thread = threading.Thread(
|
||||||
|
target=run_uvicorn, args=(app, api_host, api_port), daemon=True
|
||||||
|
)
|
||||||
|
api_thread.start()
|
||||||
|
|
||||||
|
# Dora event loop - only handles tick for state publishing
|
||||||
|
for event in node:
|
||||||
|
if event["type"] != "INPUT":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event["id"] == "tick":
|
||||||
|
try:
|
||||||
|
joints = helper.get_joint_angles_deg()
|
||||||
|
tcp_pose = helper.get_current_position()
|
||||||
|
_send_joint_pose(node, joints)
|
||||||
|
_send_tcp_pose(node, tcp_pose)
|
||||||
|
except Exception as exc:
|
||||||
|
_send_error_status(node, "publish_state", str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
28
dora_ulite6/pyproject.toml
Normal file
28
dora_ulite6/pyproject.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[project]
|
||||||
|
name = "dora-ulite6"
|
||||||
|
version = "0.1.0"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [
|
||||||
|
{ name = "Dora" }
|
||||||
|
]
|
||||||
|
description = "Dora node for controlling the UFactory Lite6 robot"
|
||||||
|
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"dora-rs >= 0.3.9",
|
||||||
|
"numpy < 2.0.0",
|
||||||
|
"scipy >= 1.14.0",
|
||||||
|
"xarm-python-sdk >= 1.17.3",
|
||||||
|
"fastapi >= 0.109.0",
|
||||||
|
"uvicorn >= 0.27.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest >=8.1.1", "ruff >=0.9.1"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
dora-ulite6 = "dora_ulite6.main:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["dora_ulite6"]
|
||||||
34
dora_zed_cpp/CMakeLists.txt
Normal file
34
dora_zed_cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
project(dora_zed_cpp LANGUAGES C CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_FLAGS "-fPIC")
|
||||||
|
|
||||||
|
include(DoraTargets.cmake)
|
||||||
|
|
||||||
|
set(ZED_DIR "/usr/local/zed" CACHE PATH "Path to the ZED SDK")
|
||||||
|
set(ZED_PATH ${ZED_DIR})
|
||||||
|
find_package(CUDAToolkit REQUIRED)
|
||||||
|
find_package(zed REQUIRED)
|
||||||
|
|
||||||
|
find_package(OpenCV REQUIRED)
|
||||||
|
|
||||||
|
link_directories(${dora_link_dirs})
|
||||||
|
link_directories(${ZED_LIBRARY_DIR})
|
||||||
|
|
||||||
|
add_executable(dora_zed_cpp main.cc ${node_bridge})
|
||||||
|
add_dependencies(dora_zed_cpp Dora_cxx)
|
||||||
|
|
||||||
|
target_include_directories(
|
||||||
|
dora_zed_cpp
|
||||||
|
PRIVATE
|
||||||
|
${dora_cxx_include_dir}
|
||||||
|
${dora_c_include_dir}
|
||||||
|
${ZED_INCLUDE_DIRS}
|
||||||
|
${OpenCV_INCLUDE_DIRS}
|
||||||
|
${CUDAToolkit_INCLUDE_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(dora_zed_cpp dora_node_api_cxx ${ZED_LIBRARIES} ${OpenCV_LIBS})
|
||||||
|
|
||||||
|
install(TARGETS dora_zed_cpp DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/bin)
|
||||||
79
dora_zed_cpp/DoraTargets.cmake
Normal file
79
dora_zed_cpp/DoraTargets.cmake
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
set(DORA_ROOT_DIR "/home/cristhian/workspace/garbage/dora" CACHE FILEPATH "Path to the root of dora")
|
||||||
|
|
||||||
|
set(dora_c_include_dir "${CMAKE_CURRENT_BINARY_DIR}/include/c")
|
||||||
|
|
||||||
|
set(dora_cxx_include_dir "${CMAKE_CURRENT_BINARY_DIR}/include/cxx")
|
||||||
|
set(node_bridge "${CMAKE_CURRENT_BINARY_DIR}/node_bridge.cc")
|
||||||
|
|
||||||
|
if(DORA_ROOT_DIR)
|
||||||
|
include(ExternalProject)
|
||||||
|
ExternalProject_Add(Dora
|
||||||
|
SOURCE_DIR ${DORA_ROOT_DIR}
|
||||||
|
BUILD_IN_SOURCE True
|
||||||
|
CONFIGURE_COMMAND ""
|
||||||
|
BUILD_COMMAND
|
||||||
|
cargo build
|
||||||
|
--package dora-node-api-c
|
||||||
|
&&
|
||||||
|
cargo build
|
||||||
|
--package dora-node-api-cxx
|
||||||
|
INSTALL_COMMAND ""
|
||||||
|
)
|
||||||
|
|
||||||
|
add_custom_command(OUTPUT ${node_bridge} ${dora_cxx_include_dir} ${dora_c_include_dir}
|
||||||
|
WORKING_DIRECTORY ${DORA_ROOT_DIR}
|
||||||
|
DEPENDS Dora
|
||||||
|
COMMAND
|
||||||
|
mkdir ${dora_cxx_include_dir} -p
|
||||||
|
&&
|
||||||
|
mkdir ${CMAKE_CURRENT_BINARY_DIR}/include/c -p
|
||||||
|
&&
|
||||||
|
cp target/cxxbridge/dora-node-api-cxx/src/lib.rs.cc ${node_bridge}
|
||||||
|
&&
|
||||||
|
cp target/cxxbridge/dora-node-api-cxx/src/lib.rs.h ${dora_cxx_include_dir}/dora-node-api.h
|
||||||
|
&&
|
||||||
|
cp apis/c/node ${CMAKE_CURRENT_BINARY_DIR}/include/c -r
|
||||||
|
)
|
||||||
|
|
||||||
|
add_custom_target(Dora_c DEPENDS ${dora_c_include_dir})
|
||||||
|
add_custom_target(Dora_cxx DEPENDS ${node_bridge} ${dora_cxx_include_dir})
|
||||||
|
set(dora_link_dirs ${DORA_ROOT_DIR}/target/debug)
|
||||||
|
else()
|
||||||
|
include(ExternalProject)
|
||||||
|
ExternalProject_Add(Dora
|
||||||
|
PREFIX ${CMAKE_CURRENT_BINARY_DIR}/dora
|
||||||
|
GIT_REPOSITORY https://github.com/dora-rs/dora.git
|
||||||
|
GIT_TAG main
|
||||||
|
BUILD_IN_SOURCE True
|
||||||
|
CONFIGURE_COMMAND ""
|
||||||
|
BUILD_COMMAND
|
||||||
|
cargo build
|
||||||
|
--package dora-node-api-c
|
||||||
|
--target-dir ${CMAKE_CURRENT_BINARY_DIR}/dora/src/Dora/target
|
||||||
|
&&
|
||||||
|
cargo build
|
||||||
|
--package dora-node-api-cxx
|
||||||
|
--target-dir ${CMAKE_CURRENT_BINARY_DIR}/dora/src/Dora/target
|
||||||
|
INSTALL_COMMAND ""
|
||||||
|
)
|
||||||
|
|
||||||
|
add_custom_command(OUTPUT ${node_bridge} ${dora_cxx_include_dir} ${dora_c_include_dir}
|
||||||
|
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/dora/src/Dora/target
|
||||||
|
DEPENDS Dora
|
||||||
|
COMMAND
|
||||||
|
mkdir ${CMAKE_CURRENT_BINARY_DIR}/include/c -p
|
||||||
|
&&
|
||||||
|
mkdir ${dora_cxx_include_dir} -p
|
||||||
|
&&
|
||||||
|
cp cxxbridge/dora-node-api-cxx/src/lib.rs.cc ${node_bridge}
|
||||||
|
&&
|
||||||
|
cp cxxbridge/dora-node-api-cxx/src/lib.rs.h ${dora_cxx_include_dir}/dora-node-api.h
|
||||||
|
&&
|
||||||
|
cp ../apis/c/node ${CMAKE_CURRENT_BINARY_DIR}/include/c -r
|
||||||
|
)
|
||||||
|
|
||||||
|
set(dora_link_dirs ${CMAKE_CURRENT_BINARY_DIR}/dora/src/Dora/target/debug)
|
||||||
|
|
||||||
|
add_custom_target(Dora_c DEPENDS ${dora_c_include_dir})
|
||||||
|
add_custom_target(Dora_cxx DEPENDS ${node_bridge} ${dora_cxx_include_dir})
|
||||||
|
endif()
|
||||||
31
dora_zed_cpp/README.md
Normal file
31
dora_zed_cpp/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# dora_zed_cpp
|
||||||
|
|
||||||
|
C++ Dora node for ZED camera streaming.
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S dora_zed_cpp -B dora_zed_cpp/build
|
||||||
|
cmake --build dora_zed_cpp/build
|
||||||
|
|
||||||
|
dora run dataflow_zed_cpp.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration (env)
|
||||||
|
|
||||||
|
- `ZED_RESOLUTION` (720, 1080, 2000)
|
||||||
|
- `ZED_FPS` (default 15)
|
||||||
|
- `ZED_DEPTH_MODE` (NEURAL, PERFORMANCE, QUALITY, NONE)
|
||||||
|
- `ZED_DEPTH_MIN_MM` (default 10)
|
||||||
|
- `ZED_DEPTH_MAX_MM` (default 500)
|
||||||
|
- `ZED_DEPTH_FILL` (true/false, default false)
|
||||||
|
- `ZED_FLIP` (ON/OFF, default ON)
|
||||||
|
- `ZED_WARMUP_FRAMES` (default 30)
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
- `image_bgr` raw bytes, metadata: `shape`, `dtype`, `encoding`, `layout`, and intrinsics (`fx`, `fy`, `cx`, `cy`, `distortion`)
|
||||||
|
- `point_cloud` raw bytes, metadata: `shape`, `dtype`, `channels`, `units`, `layout`, and intrinsics
|
||||||
|
- `camera_info` raw bytes (4 floats: fx, fy, cx, cy), metadata includes `distortion`
|
||||||
290
dora_zed_cpp/main.cc
Normal file
290
dora_zed_cpp/main.cc
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
#include <dora-node-api.h>
|
||||||
|
#include <sl/Camera.hpp>
|
||||||
|
#include <opencv2/opencv.hpp>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cctype>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <iostream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <tuple>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int get_env_int(const char* name, int default_value) {
|
||||||
|
const char* value = std::getenv(name);
|
||||||
|
if (!value || std::string(value).empty()) {
|
||||||
|
return default_value;
|
||||||
|
}
|
||||||
|
return std::stoi(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get_env_bool(const char* name, bool default_value) {
|
||||||
|
const char* value = std::getenv(name);
|
||||||
|
if (!value || std::string(value).empty()) {
|
||||||
|
return default_value;
|
||||||
|
}
|
||||||
|
std::string v(value);
|
||||||
|
for (auto& c : v) {
|
||||||
|
c = static_cast<char>(std::tolower(c));
|
||||||
|
}
|
||||||
|
return v == "1" || v == "true" || v == "yes" || v == "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string get_env_str(const char* name, const std::string& default_value) {
|
||||||
|
const char* value = std::getenv(name);
|
||||||
|
if (!value || std::string(value).empty()) {
|
||||||
|
return default_value;
|
||||||
|
}
|
||||||
|
return std::string(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
sl::RESOLUTION parse_resolution(int resolution) {
|
||||||
|
switch (resolution) {
|
||||||
|
case 1080:
|
||||||
|
return sl::RESOLUTION::HD1080;
|
||||||
|
case 2000:
|
||||||
|
return sl::RESOLUTION::HD2K;
|
||||||
|
case 720:
|
||||||
|
default:
|
||||||
|
return sl::RESOLUTION::HD720;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sl::DEPTH_MODE parse_depth_mode(const std::string& mode) {
|
||||||
|
if (mode == "PERFORMANCE") {
|
||||||
|
return sl::DEPTH_MODE::PERFORMANCE;
|
||||||
|
}
|
||||||
|
if (mode == "QUALITY") {
|
||||||
|
return sl::DEPTH_MODE::QUALITY;
|
||||||
|
}
|
||||||
|
if (mode == "NONE") {
|
||||||
|
return sl::DEPTH_MODE::NONE;
|
||||||
|
}
|
||||||
|
return sl::DEPTH_MODE::NEURAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
sl::FLIP_MODE parse_flip(const std::string& mode) {
|
||||||
|
if (mode == "OFF") {
|
||||||
|
return sl::FLIP_MODE::OFF;
|
||||||
|
}
|
||||||
|
return sl::FLIP_MODE::ON;
|
||||||
|
}
|
||||||
|
|
||||||
|
rust::Vec<int64_t> make_shape(int height, int width, int channels) {
|
||||||
|
rust::Vec<int64_t> shape;
|
||||||
|
shape.push_back(height);
|
||||||
|
shape.push_back(width);
|
||||||
|
shape.push_back(channels);
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
rust::Vec<double> make_distortion(const std::vector<double>& distortion) {
|
||||||
|
rust::Vec<double> values;
|
||||||
|
for (double v : distortion) {
|
||||||
|
values.push_back(v);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
rust::Vec<int64_t> make_vector_int(std::initializer_list<int64_t> values) {
|
||||||
|
rust::Vec<int64_t> out;
|
||||||
|
for (int64_t v : values) {
|
||||||
|
out.push_back(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void list_available_cameras() {
|
||||||
|
auto devices = sl::Camera::getDeviceList();
|
||||||
|
std::cout << "Detected ZED cameras: " << devices.size() << std::endl;
|
||||||
|
for (std::size_t i = 0; i < devices.size(); i++) {
|
||||||
|
const auto& dev = devices[i];
|
||||||
|
std::cout << " - [" << i << "] model=" << sl::toString(dev.camera_model)
|
||||||
|
<< ", serial=" << dev.serial_number << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool open_camera(sl::Camera& zed, const sl::InitParameters& init_params) {
|
||||||
|
sl::ERROR_CODE err = zed.open(init_params);
|
||||||
|
if (err != sl::ERROR_CODE::SUCCESS) {
|
||||||
|
std::cerr << "Failed to open ZED camera: " << sl::toString(err) << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ensure_camera_ready(
|
||||||
|
sl::Camera& zed,
|
||||||
|
const sl::InitParameters& init_params,
|
||||||
|
int sleep_ms
|
||||||
|
) {
|
||||||
|
while (true) {
|
||||||
|
if (open_camera(zed, init_params)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(sleep_ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
auto dora_node = init_dora_node();
|
||||||
|
|
||||||
|
const int resolution = get_env_int("ZED_RESOLUTION", 720);
|
||||||
|
const int fps = get_env_int("ZED_FPS", 15);
|
||||||
|
const std::string depth_mode = get_env_str("ZED_DEPTH_MODE", "NEURAL");
|
||||||
|
const int depth_min_mm = get_env_int("ZED_DEPTH_MIN_MM", 10);
|
||||||
|
const int depth_max_mm = get_env_int("ZED_DEPTH_MAX_MM", 500);
|
||||||
|
const bool depth_fill = get_env_bool("ZED_DEPTH_FILL", false);
|
||||||
|
const std::string flip = get_env_str("ZED_FLIP", "ON");
|
||||||
|
const int warmup_frames = get_env_int("ZED_WARMUP_FRAMES", 30);
|
||||||
|
const int retry_interval_seconds = get_env_int("ZED_RETRY_INTERVAL_SECONDS", 5);
|
||||||
|
const int retry_interval_ms = retry_interval_seconds * 1000;
|
||||||
|
|
||||||
|
sl::InitParameters init_params;
|
||||||
|
init_params.camera_resolution = parse_resolution(resolution);
|
||||||
|
init_params.camera_fps = fps;
|
||||||
|
init_params.depth_mode = parse_depth_mode(depth_mode);
|
||||||
|
init_params.coordinate_units = sl::UNIT::MILLIMETER;
|
||||||
|
init_params.camera_image_flip = parse_flip(flip);
|
||||||
|
init_params.depth_minimum_distance = depth_min_mm;
|
||||||
|
init_params.depth_maximum_distance = depth_max_mm;
|
||||||
|
|
||||||
|
list_available_cameras();
|
||||||
|
|
||||||
|
sl::Camera zed;
|
||||||
|
ensure_camera_ready(zed, init_params, retry_interval_ms);
|
||||||
|
|
||||||
|
sl::RuntimeParameters runtime_params;
|
||||||
|
runtime_params.enable_depth = true;
|
||||||
|
runtime_params.enable_fill_mode = depth_fill;
|
||||||
|
|
||||||
|
sl::Mat image_mat;
|
||||||
|
sl::Mat point_cloud_mat;
|
||||||
|
|
||||||
|
for (int i = 0; i < warmup_frames; i++) {
|
||||||
|
zed.grab(runtime_params);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto reload_calibration = [&]() {
|
||||||
|
sl::CameraInformation info = zed.getCameraInformation();
|
||||||
|
auto calib = info.camera_configuration.calibration_parameters;
|
||||||
|
auto left = calib.left_cam;
|
||||||
|
|
||||||
|
std::vector<double> dist;
|
||||||
|
dist.reserve(std::size(left.disto));
|
||||||
|
for (double v : left.disto) {
|
||||||
|
dist.push_back(v);
|
||||||
|
}
|
||||||
|
return std::tuple<float, float, float, float, std::vector<double>>(
|
||||||
|
left.fx, left.fy, left.cx, left.cy, std::move(dist));
|
||||||
|
};
|
||||||
|
|
||||||
|
auto [fx, fy, cx, cy, distortion] = reload_calibration();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
auto event = dora_node.events->next();
|
||||||
|
auto type = event_type(event);
|
||||||
|
|
||||||
|
if (type == DoraEventType::Stop || type == DoraEventType::AllInputsClosed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type != DoraEventType::Input) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto input = event_as_input(std::move(event));
|
||||||
|
(void)input;
|
||||||
|
|
||||||
|
if (zed.grab(runtime_params) != sl::ERROR_CODE::SUCCESS) {
|
||||||
|
std::cerr << "Grab failed; attempting to reconnect..." << std::endl;
|
||||||
|
zed.close();
|
||||||
|
list_available_cameras();
|
||||||
|
ensure_camera_ready(zed, init_params, retry_interval_ms);
|
||||||
|
std::tie(fx, fy, cx, cy, distortion) = reload_calibration();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
zed.retrieveImage(image_mat, sl::VIEW::LEFT);
|
||||||
|
zed.retrieveMeasure(point_cloud_mat, sl::MEASURE::XYZRGBA);
|
||||||
|
|
||||||
|
int width = image_mat.getWidth();
|
||||||
|
int height = image_mat.getHeight();
|
||||||
|
|
||||||
|
cv::Mat image_rgba(height, width, CV_8UC4, image_mat.getPtr<sl::uchar1>());
|
||||||
|
cv::Mat image_bgr;
|
||||||
|
cv::cvtColor(image_rgba, image_bgr, cv::COLOR_BGRA2BGR);
|
||||||
|
|
||||||
|
const auto image_size = static_cast<size_t>(image_bgr.total() * image_bgr.elemSize());
|
||||||
|
rust::Slice<const uint8_t> image_slice{
|
||||||
|
reinterpret_cast<const uint8_t*>(image_bgr.data), image_size};
|
||||||
|
|
||||||
|
auto image_metadata = new_metadata();
|
||||||
|
image_metadata->set_string("dtype", "uint8");
|
||||||
|
image_metadata->set_string("encoding", "bgr8");
|
||||||
|
image_metadata->set_string("layout", "HWC");
|
||||||
|
image_metadata->set_list_int("shape", make_shape(height, width, 3));
|
||||||
|
image_metadata->set_float("fx", fx);
|
||||||
|
image_metadata->set_float("fy", fy);
|
||||||
|
image_metadata->set_float("cx", cx);
|
||||||
|
image_metadata->set_float("cy", cy);
|
||||||
|
image_metadata->set_list_float("distortion", make_distortion(distortion));
|
||||||
|
|
||||||
|
auto image_result = send_output_with_metadata(
|
||||||
|
dora_node.send_output, "image_bgr", image_slice, std::move(image_metadata));
|
||||||
|
if (!std::string(image_result.error).empty()) {
|
||||||
|
std::cerr << "Error sending image_bgr: " << std::string(image_result.error) << std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pc_width = point_cloud_mat.getWidth();
|
||||||
|
int pc_height = point_cloud_mat.getHeight();
|
||||||
|
const size_t pc_size = static_cast<size_t>(pc_width * pc_height * sizeof(sl::float4));
|
||||||
|
const auto* pc_ptr = reinterpret_cast<const uint8_t*>(point_cloud_mat.getPtr<sl::float4>());
|
||||||
|
rust::Slice<const uint8_t> pc_slice{pc_ptr, pc_size};
|
||||||
|
|
||||||
|
auto pc_metadata = new_metadata();
|
||||||
|
pc_metadata->set_string("dtype", "float32");
|
||||||
|
pc_metadata->set_string("layout", "HWC");
|
||||||
|
pc_metadata->set_string("channels", "XYZRGBA");
|
||||||
|
pc_metadata->set_string("units", "mm");
|
||||||
|
pc_metadata->set_list_int("shape", make_shape(pc_height, pc_width, 4));
|
||||||
|
pc_metadata->set_float("fx", fx);
|
||||||
|
pc_metadata->set_float("fy", fy);
|
||||||
|
pc_metadata->set_float("cx", cx);
|
||||||
|
pc_metadata->set_float("cy", cy);
|
||||||
|
pc_metadata->set_list_float("distortion", make_distortion(distortion));
|
||||||
|
|
||||||
|
auto pc_result = send_output_with_metadata(
|
||||||
|
dora_node.send_output, "point_cloud", pc_slice, std::move(pc_metadata));
|
||||||
|
if (!std::string(pc_result.error).empty()) {
|
||||||
|
std::cerr << "Error sending point_cloud: " << std::string(pc_result.error) << std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
float camera_data[4] = {fx, fy, cx, cy};
|
||||||
|
rust::Slice<const uint8_t> cam_slice{
|
||||||
|
reinterpret_cast<const uint8_t*>(camera_data), sizeof(camera_data)};
|
||||||
|
|
||||||
|
auto cam_metadata = new_metadata();
|
||||||
|
cam_metadata->set_string("dtype", "float32");
|
||||||
|
cam_metadata->set_string("layout", "C");
|
||||||
|
cam_metadata->set_list_int("shape", make_vector_int({4}));
|
||||||
|
cam_metadata->set_list_float("distortion", make_distortion(distortion));
|
||||||
|
|
||||||
|
auto cam_result = send_output_with_metadata(
|
||||||
|
dora_node.send_output, "camera_info", cam_slice, std::move(cam_metadata));
|
||||||
|
if (!std::string(cam_result.error).empty()) {
|
||||||
|
std::cerr << "Error sending camera_info: " << std::string(cam_result.error) << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zed.close();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user