docs: 生成英文 README,新增摄像头查看工具

README 拆分为英文版和中文版并添加语言切换链接,
新增 uf_camera_view 多摄像头拼接工具,
udev 规则移至项目根目录。
This commit is contained in:
Vinman 2026-06-16 17:05:34 +08:00
parent e01fec8b91
commit de538a24c3
9 changed files with 695 additions and 86 deletions

192
README.md
View File

@ -1,202 +1,222 @@
# UFACTORY LeRobot # UFACTORY LeRobot
UFACTORY 机械臂与 LeRobot 框架集成项目,支持多种遥操作方式的数据采集、策略训练和部署推理。 > [中文版本](README_ZH.md)
## 功能特性 UFACTORY robot arm integration with the LeRobot framework for robot learning, data collection, and policy deployment.
- 🤖 UFACTORY 机械臂控制xArm 系列) Reference project: [ufactory_teleop](https://github.com/xArm-Developer/ufactory_teleop)
- 🎮 多种遥操作方式GELLO / Pika / UMI / SpaceMouse
- 📷 多摄像头数据采集RealSense / UMI 相机)
- 📊 数据集录制与管理(兼容 LeRobot 格式)
- 🧠 模仿学习训练ACT / Diffusion Policy 等)
- 🚀 策略评估与实时推理
- 🔧 Mock 机器人模拟(只用遥操作设备采集数据)
## 环境要求 ## Features
- xArm robot control (xArm series)
- Multiple teleop modes: GELLO / Pika / UMI / SpaceMouse
- Multi-camera data collection (RealSense / UMI camera)
- Dataset recording & management (LeRobot-compatible)
- Imitation learning training (ACT / Diffusion Policy / etc.)
- Policy evaluation & real-time inference
- Mock mode (no physical robot needed)
## Requirements
- Ubuntu 22.04 / 24.04 - Ubuntu 22.04 / 24.04
- Python >= 3.10 - Python >= 3.10
- CUDA >= 12.0GPU 训练推荐) - CUDA >= 12.0 (recommended for GPU training)
- UFACTORY 机械臂xArm 系列,可选) - UFACTORY xArm (optional)
## 安装 ## Installation
### 基础项目安装 ### Base Install
```bash ```bash
git clone https://github.com/xArm-Developer/ufactory_lerobot.git git clone https://github.com/xArm-Developer/ufactory_lerobot.git
cd ufactory_lerobot cd ufactory_lerobot
# 创建 conda 环境 # Create conda environment
conda create -n uf_lerobot python=3.10 -y conda create -n uf_lerobot python=3.10 -y
conda activate uf_lerobot conda activate uf_lerobot
# 安装项目 # Install project
pip install -e . pip install -e .
``` ```
包含:`lerobot==0.4.3`、`xarm-python-sdk`、`numpy`、`pyyaml`lerobot 已自动携带 torch、opencv、wandb 等训练相关依赖)。 Includes: `lerobot==0.4.3`, `xarm-python-sdk`, `numpy`, `pyyaml`. LeRobot already pulls in torch, opencv, wandb, etc.
### 外设模块安装 ### Peripheral Modules
外设依赖以可选模块形式提供,通过 `[模块名]` 安装。 Peripheral dependencies are available as optional extras via `[module]` install.
#### GELLO 遥操作 #### GELLO Teleop
适用于 GELLO 示教臂Dynamixel 舵机方案),控制空间为关节空间。 Dynamixel-based leader arm, joint-space control.
```bash ```bash
# 1. 安装 GELLO 模块 # 1. Install GELLO module
pip install -e ".[gello]" pip install -e ".[gello]"
# 2. 添加串口权限(重新登录后生效) # 2. Add serial port permissions (re-login required)
sudo usermod -aG dialout $USER sudo usermod -aG dialout $USER
``` ```
#### Pika 遥操作 #### Pika Teleop
适用于 Pika Sense 手持示教器 + Vive Tracker控制空间为笛卡尔空间。 Pika Sense handheld + Vive Tracker, task-space control.
```bash ```bash
# 1. 安装外设依赖(不需要它们的间接依赖) # 1. Install peripheral deps (skip transitive deps)
pip install pysurvive agx-pypika --no-deps pip install pysurvive agx-pypika --no-deps
# 2. 安装 udev 规则(重新插拔设备后生效) # 2. Install udev rules (re-plug devices afterwards)
sudo cp src/rules/*.rules /etc/udev/rules.d/ sudo cp src/rules/*.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger sudo udevadm control --reload-rules && sudo udevadm trigger
``` ```
> Vive Tracker 首次使用前需校准:`uf-vive-calibrate` > Calibrate Vive Tracker before first use: `uf-vive-calibrate`
#### UMI 遥操作 #### UMI Teleop
适用于 UMIUniversal Manipulation Interface方案含 Vive Tracker 追踪,支持双机械臂。 Universal Manipulation Interface + Vive Tracker, supports dual-arm.
```bash ```bash
# 1. 安装 XVSDK系统级依赖仅支持 Ubuntu Focal # 1. Install XVSDK (system-level, Ubuntu Focal only)
sudo dpkg -i src/xvsdk/XVSDK_focal_amd64.deb sudo dpkg -i src/xvsdk/XVSDK_focal_amd64.deb
sudo apt install -y --fix-broken sudo apt install -y --fix-broken
# 2. 安装外设依赖 # 2. Install peripheral deps
pip install pysurvive --no-deps pip install pysurvive --no-deps
# 3. 安装 udev 规则(重新插拔设备后生效) # 3. Install udev rules (re-plug devices afterwards)
sudo cp src/rules/*.rules /etc/udev/rules.d/ sudo cp src/rules/*.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger sudo udevadm control --reload-rules && sudo udevadm trigger
``` ```
> Vive Tracker 首次使用前需校准:`uf-vive-calibrate` > Calibrate Vive Tracker before first use: `uf-vive-calibrate`
**多 UMI 设备配置**(使用两台及以上时): **Multi-UMI device configuration** (two or more devices):
```bash ```bash
# 增加 USB 缓冲区大小 # Increase USB buffer size
sudo sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/s/quiet splash/quiet splash usbcore.usbfs_memory_mb=128/' /etc/default/grub sudo sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/s/quiet splash/quiet splash usbcore.usbfs_memory_mb=128/' /etc/default/grub
sync sync
sudo update-grub sudo update-grub
sudo reboot sudo reboot
``` ```
#### SpaceMouse 遥操作 #### SpaceMouse Teleop
适用于 3Dconnexion SpaceMouse / SpaceNavigator。 3Dconnexion SpaceMouse / SpaceNavigator.
```bash ```bash
# 1. 安装 SpaceMouse 模块 # 1. Install SpaceMouse module
pip install -e ".[spacemouse]" pip install -e ".[spacemouse]"
# 2. 安装 udev 规则(重新插拔设备后生效) # 2. Install udev rules (re-plug device afterwards)
sudo cp src/rules/*.rules /etc/udev/rules.d/ sudo cp src/rules/*.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger sudo udevadm control --reload-rules && sudo udevadm trigger
``` ```
## 使用 ## Usage
### 1. 遥操作测试 ### 1. Teleop Testing
测试遥操作设备与机械臂的联动,不录制数据。 Test teleop-to-robot control loop without recording.
```bash ```bash
uf-robot-teleop -c path/to/config.yaml uf-robot-teleop -c path/to/config.yaml
uf-robot-teleop -c path/to/config.yaml -f 60 # 指定频率 uf-robot-teleop -c path/to/config.yaml -f 60 # specify frequency
``` ```
### 2. 数据采集 ### 2. Data Collection
通过遥操作录制数据集。 Record datasets via teleop.
```bash ```bash
uf-lerobot-record -c path/to/record_config.yaml uf-lerobot-record -c path/to/record_config.yaml
uf-lerobot-record -c path/to/config.yaml --resume # 续录 uf-lerobot-record -c path/to/config.yaml --resume # resume recording
``` ```
### 3. 策略训练 ### 3. Policy Training
采集数据后,使用 LeRobot 训练管道进行模仿学习训练。 Train imitation learning policies on collected data.
```bash ```bash
lerobot-train --policy act --dataset your_dataset_name lerobot-train --policy act --dataset your_dataset_name
``` ```
### 4. 策略评估 ### 4. Policy Evaluation
Evaluate trained policies.
```bash ```bash
uf-lerobot-eval -c path/to/eval_config.yaml uf-lerobot-eval -c path/to/eval_config.yaml
``` ```
### Mock 模式(无实体机械臂) ### 5. Camera Viewer
```yaml View and stitch multiple camera feeds.
type: "uf::mock_robot" # 单臂模拟
type: "uf::multiple_mock_robot" # 双臂模拟 ```bash
uf-camera-view -l # list all cameras
uf-camera-view -l -T xvisio # list XVisio cameras only
uf-camera-view -T xvisio # view XVisio cameras (default 1280x1280 YU12)
uf-camera-view -T xvisio -W 640 -H 1920 -F NV12 # specify format
uf-camera-view -T other # view other camera types
``` ```
## 遥操作方式对比 ### Mock Mode (no physical robot)
| 特性 | GELLO | Pika | UMI | SpaceMouse | ```yaml
|------|-------|------|-----|------------| type: "uf::mock_robot" # single arm simulation
| 控制空间 | 关节空间 | 任务空间 | 任务空间 | 任务空间 | type: "uf::multiple_mock_robot" # dual arm simulation
| 跟踪方式 | Dynamixel 舵机 | Vive Tracker | UMI SLAM / Vive | 3D 鼠标 | ```
| 双臂支持 | ❌ | ❌ | ✅ | ❌ |
| 系统依赖 | dialout 组 | — | XVSDK deb | — |
## 项目结构 ## Teleop Comparison
| Feature | GELLO | Pika | UMI | SpaceMouse |
|---------|-------|------|-----|------------|
| Control space | Joint space | Task space | Task space | Task space |
| Tracking | Dynamixel servos | Vive Tracker | UMI SLAM / Vive | 3D mouse |
| Dual-arm | ❌ | ❌ | ✅ | ❌ |
| System dep | dialout group | — | XVSDK deb | — |
## Project Structure
``` ```
ufactory_lerobot/ ufactory_lerobot/
├── src/ ├── src/
│ ├── ufactory_lerobot/ │ ├── ufactory_lerobot/
│ │ ├── robots/ # 机器人控制 │ │ ├── robots/ # Robot control
│ │ │ ├── uf_robot/ # xArm 实体机器人 │ │ │ ├── uf_robot/ # xArm physical robot
│ │ │ ├── uf_mock_robot/ # 仿真 Mock 机器人 │ │ │ ├── uf_mock_robot/ # Mock robot simulator
│ │ ├── teleoperators/ # 遥操作器 │ │ │ └── utils.py # make_robot_from_config patch
│ │ │ ├── gello_teleop/ # GELLO (Dynamixel 示教臂) │ │ ├── teleoperators/ # Teleop drivers
│ │ │ ├── pika_teleop/ # Pika Sense (手持示教器 + Vive) │ │ │ ├── gello_teleop/ # GELLO (Dynamixel leader)
│ │ │ ├── umi_teleop/ # UMI (含双机械臂) │ │ │ ├── pika_teleop/ # Pika Sense (handheld + Vive)
│ │ │ ├── space_mouse/ # SpaceMouse (3D 鼠标) │ │ │ ├── umi_teleop/ # UMI (dual-arm support)
│ │ ├── cameras/ # 摄像头模块 │ │ │ ├── space_mouse/ # SpaceMouse (3D mouse)
│ │ │ └── umi_camera/ # UMI 相机 │ │ ├── cameras/ # Camera modules
│ │ ├── devices/ # 外部设备驱动 │ │ │ └── umi_camera/ # UMI camera
│ │ │ ├── pika/ # Pika 串口驱动 │ │ ├── devices/ # External device drivers
│ │ │ ├── pika/ # Pika serial driver
│ │ │ └── umi/ # XVLib / Vive Tracker │ │ │ └── umi/ # XVLib / Vive Tracker
│ │ ├── scripts/ # 执行脚本 │ │ ├── scripts/ # Entry-point scripts
│ │ │ ├── uf_robot_teleop.py # 遥操作测试 │ │ │ ├── uf_robot_teleop.py # Teleop testing
│ │ │ ├── uf_lerobot_record.py # 数据采集 │ │ │ ├── uf_lerobot_record.py # Data recording
│ │ │ ├── uf_lerobot_eval.py # 策略评估 │ │ │ ├── uf_lerobot_eval.py # Policy evaluation
│ │ │ └── vive_calibrate.py # Vive Tracker 校准 │ │ │ ├── uf_camera_view.py # camera viewer tool
│ │ └── utils/ # 工具函数 │ │ │ └── vive_calibrate.py # Vive Tracker calibration
│ ├── rules/ # udev 设备规则 │ │ └── utils/ # Utilities
│ └── xvsdk/ # XVSDK 系统依赖 │ ├── rules/ # udev device rules
├── config/ # YAML 配置文件 │ └── xvsdk/ # XVSDK system dependency
├── config/ # YAML config files
│ ├── gello/ │ ├── gello/
│ ├── pika/ │ ├── pika/
│ ├── umi/ │ ├── umi/
│ └── spacemouse/ │ └── spacemouse/
├── pyproject.toml ├── pyproject.toml
└── README.md └── LICENSE
``` ```
## 许可证 ## License
本项目基于 Apache License 2.0 发布,详见 [LICENSE](LICENSE) 文件。 This project is released under the Apache License 2.0. See [LICENSE](LICENSE).

205
README_ZH.md Normal file
View File

@ -0,0 +1,205 @@
# UFACTORY LeRobot
> [English Version](README.md)
UFACTORY 机械臂与 LeRobot 框架集成项目,支持多种遥操作方式的数据采集、策略训练和部署推理。
## 功能特性
- 🤖 UFACTORY 机械臂控制xArm 系列)
- 🎮 多种遥操作方式GELLO / Pika / UMI / SpaceMouse
- 📷 多摄像头数据采集RealSense / UMI 相机)
- 📊 数据集录制与管理(兼容 LeRobot 格式)
- 🧠 模仿学习训练ACT / Diffusion Policy 等)
- 🚀 策略评估与实时推理
- 🔧 Mock 机器人模拟(只用遥操作设备采集数据)
## 环境要求
- Ubuntu 22.04 / 24.04
- Python >= 3.10
- CUDA >= 12.0GPU 训练推荐)
- UFACTORY 机械臂xArm 系列,可选)
## 安装
### 基础项目安装
```bash
git clone https://github.com/xArm-Developer/ufactory_lerobot.git
cd ufactory_lerobot
# 创建 conda 环境
conda create -n uf_lerobot python=3.10 -y
conda activate uf_lerobot
# 安装项目
pip install -e .
```
包含:`lerobot==0.4.3`、`xarm-python-sdk`、`numpy`、`pyyaml`lerobot 已自动携带 torch、opencv、wandb 等训练相关依赖)。
### 外设模块安装
外设依赖以可选模块形式提供,通过 `[模块名]` 安装。
#### GELLO 遥操作
适用于 GELLO 示教臂Dynamixel 舵机方案),控制空间为关节空间。
```bash
# 1. 安装 GELLO 模块
pip install -e ".[gello]"
# 2. 添加串口权限(重新登录后生效)
sudo usermod -aG dialout $USER
```
#### Pika 遥操作
适用于 Pika Sense 手持示教器 + Vive Tracker控制空间为笛卡尔空间。
```bash
# 1. 安装外设依赖(不需要它们的间接依赖)
pip install pysurvive agx-pypika --no-deps
# 2. 安装 udev 规则(重新插拔设备后生效)
sudo cp rules/*.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
```
> Vive Tracker 首次使用前需校准:`uf-vive-calibrate`
#### UMI 遥操作
适用于 UMIUniversal Manipulation Interface方案含 Vive Tracker 追踪,支持双机械臂。
```bash
# 1. 安装 XVSDK系统级依赖仅支持 Ubuntu Focal
sudo dpkg -i src/xvsdk/XVSDK_focal_amd64.deb
sudo apt install -y --fix-broken
# 2. 安装外设依赖
pip install pysurvive --no-deps
# 3. 安装 udev 规则(重新插拔设备后生效)
sudo cp rules/*.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
```
> Vive Tracker 首次使用前需校准:`uf-vive-calibrate`
**多 UMI 设备配置**(使用两台及以上时):
```bash
# 增加 USB 缓冲区大小
sudo sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/s/quiet splash/quiet splash usbcore.usbfs_memory_mb=128/' /etc/default/grub
sync
sudo update-grub
sudo reboot
```
#### SpaceMouse 遥操作
适用于 3Dconnexion SpaceMouse / SpaceNavigator。
```bash
# 1. 安装 SpaceMouse 模块
pip install -e ".[spacemouse]"
# 2. 安装 udev 规则(重新插拔设备后生效)
sudo cp rules/*.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
```
## 使用
### 1. 遥操作测试
测试遥操作设备与机械臂的联动,不录制数据。
```bash
uf-robot-teleop -c path/to/config.yaml
uf-robot-teleop -c path/to/config.yaml -f 60 # 指定频率
```
### 2. 数据采集
通过遥操作录制数据集。
```bash
uf-lerobot-record -c path/to/record_config.yaml
uf-lerobot-record -c path/to/config.yaml --resume # 续录
```
### 3. 策略训练
采集数据后,使用 LeRobot 训练管道进行模仿学习训练。
```bash
lerobot-train --policy act --dataset your_dataset_name
```
### 4. 策略评估
```bash
uf-lerobot-eval -c path/to/eval_config.yaml
```
### Mock 模式(无实体机械臂)
```yaml
type: "uf::mock_robot" # 单臂模拟
type: "uf::multiple_mock_robot" # 双臂模拟
```
## 遥操作方式对比
| 特性 | GELLO | Pika | UMI | SpaceMouse |
|------|-------|------|-----|------------|
| 控制空间 | 关节空间 | 任务空间 | 任务空间 | 任务空间 |
| 跟踪方式 | Dynamixel 舵机 | Vive Tracker | UMI SLAM / Vive | 3D 鼠标 |
| 双臂支持 | ❌ | ❌ | ✅ | ❌ |
| 系统依赖 | dialout 组 | — | XVSDK deb | — |
## 项目结构
```
ufactory_lerobot/
├── src/
│ ├── ufactory_lerobot/
│ │ ├── robots/ # 机器人控制
│ │ │ ├── uf_robot/ # xArm 实体机器人
│ │ │ ├── uf_mock_robot/ # 仿真 Mock 机器人
│ │ ├── teleoperators/ # 遥操作器
│ │ │ ├── gello_teleop/ # GELLO (Dynamixel 示教臂)
│ │ │ ├── pika_teleop/ # Pika Sense (手持示教器 + Vive)
│ │ │ ├── umi_teleop/ # UMI (含双机械臂)
│ │ │ ├── space_mouse/ # SpaceMouse (3D 鼠标)
│ │ ├── cameras/ # 摄像头模块
│ │ │ └── umi_camera/ # UMI 相机
│ │ ├── devices/ # 外部设备驱动
│ │ │ ├── pika/ # Pika 串口驱动
│ │ │ └── umi/ # XVLib / Vive Tracker
│ │ ├── scripts/ # 执行脚本
│ │ │ ├── uf_robot_teleop.py # 遥操作测试
│ │ │ ├── uf_lerobot_record.py # 数据采集
│ │ │ ├── uf_lerobot_eval.py # 策略评估
│ │ │ ├── uf_camera_view.py # 摄像头查看工具
│ │ │ └── vive_calibrate.py # Vive Tracker 校准
│ │ └── utils/ # 工具函数
├── config/ # YAML 配置文件
│ ├── gello/
│ ├── pika/
│ ├── umi/
│ └── spacemouse/
├── rules/ # udev 设备规则
├── xvsdk/ # XVSDK 系统依赖
├── pyproject.toml
└── README.md
```
## 许可证
本项目基于 Apache License 2.0 发布,详见 [LICENSE](LICENSE) 文件。

View File

@ -36,6 +36,7 @@ uf-robot-teleop = "ufactory_lerobot.scripts.uf_robot_teleop:main"
uf-lerobot-record = "ufactory_lerobot.scripts.uf_lerobot_record:main" uf-lerobot-record = "ufactory_lerobot.scripts.uf_lerobot_record:main"
uf-lerobot-eval = "ufactory_lerobot.scripts.uf_lerobot_eval:main" uf-lerobot-eval = "ufactory_lerobot.scripts.uf_lerobot_eval:main"
uf-vive-calibrate = "ufactory_lerobot.scripts.vive_calibrate:main" uf-vive-calibrate = "ufactory_lerobot.scripts.vive_calibrate:main"
uf-camera-view = "ufactory_lerobot.scripts.uf_camera_view:main"
[project.optional-dependencies] [project.optional-dependencies]
# GELLO 遥操作 # GELLO 遥操作

View File

@ -0,0 +1,383 @@
#!/usr/bin/env python
"""Multi-camera viewer: XVisio / RealSense / other cameras."""
import os
import sys
import cv2
import time
import argparse
import threading
from pathlib import Path
# ---------- 屏蔽 OpenCV Qt 字体警告 ----------
os.environ["QT_LOGGING_RULES"] = "*=false"
_fake_stderr = open(os.devnull, "w")
_real_stderr = sys.stderr
sys.stderr = _fake_stderr
import cv2 as _cv2
sys.stderr = _real_stderr
_fake_stderr.close()
# ---------------------------------------------
BY_ID_DIR = Path("/dev/v4l/by-id")
BY_PATH_DIR = Path("/dev/v4l/by-path")
# camera type -> (keyword, default format)
CAMERA_TYPES = {
"xvisio": (1280, 1280, "YU12"),
"realsense": (640, 480, ""),
}
class Camera:
"""Generic camera."""
def __init__(self, device: str, serial: str = "", by_path: str = "",
by_id: str = "", width: int = 640, height: int = 480,
fourcc: str = ""):
self.device = device
self.by_path = by_path
self.by_id = by_id
self.serial = serial
self.width = width
self.height = height
self.fourcc = fourcc
self._cap: cv2.VideoCapture | None = None
def open(self) -> bool:
for b in [cv2.CAP_V4L2, cv2.CAP_ANY]:
cap = cv2.VideoCapture(self.device, b)
if cap.isOpened():
if self.fourcc:
cap.set(cv2.CAP_PROP_FOURCC,
cv2.VideoWriter_fourcc(*self.fourcc))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
rw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
rh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
if rw < 1 or rh < 1:
cap.release()
continue
self.width, self.height = rw, rh
self._cap = cap
return True
return False
def read(self):
if self._cap is None:
return None
ret, frame = self._cap.read()
return frame if ret else None
def close(self):
if self._cap:
self._cap.release()
self._cap = None
@property
def fps(self) -> float:
return self._cap.get(cv2.CAP_PROP_FPS) if self._cap else 0.0
@property
def current_fourcc(self) -> str:
if not self._cap:
return ""
code = int(self._cap.get(cv2.CAP_PROP_FOURCC))
try:
return "".join(chr((code >> (i * 8)) & 0xFF) for i in range(4))
except (ValueError, OverflowError):
return str(code)
@property
def is_open(self) -> bool:
return self._cap is not None and self._cap.isOpened()
class CameraManager:
"""Camera discovery."""
@staticmethod
def find(cam_type: str = "other") -> list[dict]:
"""Scan cameras by type.
cam_type: xvisio / realsense / other (default, excludes known types)
Uses /dev/v4l/by-path/ (unique per port), gets serial from by-id.
video-index0 = Video Capture, index1+ = depth/IR/metadata.
"""
if not BY_PATH_DIR.exists():
return []
info = CAMERA_TYPES.get(cam_type)
if info:
keyword = cam_type
w, h, fc = info
else:
keyword = None
w, h, fc = (640, 480, "")
# 建立 by-id 映射: resolved device -> by-id path
id_map = {} # {'/dev/video0': '/dev/v4l/by-id/xxxx'}
if BY_ID_DIR.exists():
for p in BY_ID_DIR.iterdir():
id_map[str(p.resolve())] = str(p)
result = []
for p in sorted(BY_PATH_DIR.iterdir()):
name = p.name
if not name.endswith("-video-index0"):
continue
device = str(p.resolve())
by_id = id_map.get(device, "")
# use by-id name for type check (by-path does not contain vendor/model)
type_name = (by_id or name).lower()
if keyword and keyword not in type_name:
continue
if cam_type == 'other' and not keyword and any(k in type_name for k in CAMERA_TYPES.keys()):
continue
if by_id:
# serial = by_id.rsplit("-video-index0", 1)[0]
# parts = serial.split("_", 2)
# serial = parts[-1] if len(parts) > 2 else serial
serial = by_id.rsplit('/', 1)[-1].split('-', 1)[-1].rsplit("-video-index0", 1)[0]
tmp = serial.split("_")
serial = '_'.join(list({val: val for val in tmp if val}.values()))
else:
serial = name.rsplit("-video-index0", 1)[0]
result.append({
"device": device,
"serial": serial,
"by_path": str(p),
"by_id": by_id,
"default_w": w,
"default_h": h,
"default_fourcc": fc,
})
return result
@staticmethod
def find_by_serial(serial: str, cam_type: str = "other") -> list[dict]:
all_devs = CameraManager.find(cam_type)
return [d for d in all_devs if serial in d["by_id"] or serial in d["by_path"] or serial in d["serial"]]
# return [d for d in all_devs if serial in d["serial"]]
class MultiCameraViewer:
"""Multi-camera stitched viewer."""
FONT = cv2.FONT_HERSHEY_SIMPLEX
FONT_SCALE = 1.2
FONT_THICK = 2
LABEL_H = 66
def __init__(self, cameras: list[Camera], seconds: int = 0):
self.cameras = cameras
self.seconds = seconds
self._max_w = int(os.environ.get("DISPLAY_W", 3840))
self._max_h = int(os.environ.get("DISPLAY_H", 2160))
def _stitch_frames(self, frames: list, start_monotonic: float = 0):
valid = [(cam, f) for cam, f in zip(self.cameras, frames) if f is not None]
if not valid:
return None
resized = []
seconds = int(time.monotonic() - start_monotonic)
for cam, frame in valid:
h, w = frame.shape[:2]
max_per_cam = self._max_h * 0.8
if h > max_per_cam:
scale = max_per_cam / h
frame = cv2.resize(frame, (int(w * scale), int(h * scale)))
label1 = cam.serial
label2 = f"{w}x{h} | {cam.fps:.0f}fps | {cam.current_fourcc} | #{seconds}"
padded = cv2.copyMakeBorder(frame, self.LABEL_H, 0, 0, 0,
cv2.BORDER_CONSTANT, value=(40, 40, 40))
cv2.putText(padded, label1, (10, 30),
self.FONT, self.FONT_SCALE, (0, 255, 255), self.FONT_THICK, cv2.LINE_AA)
cv2.putText(padded, label2, (10, 80),
self.FONT, self.FONT_SCALE, (0, 200, 255), self.FONT_THICK, cv2.LINE_AA)
resized.append(padded)
max_h = max(f.shape[0] for f in resized)
aligned = []
for i, f in enumerate(resized):
if f.shape[0] != max_h:
f = cv2.resize(f, (int(f.shape[1] * max_h / f.shape[0]), max_h))
# 加右边分隔线100, 100, 100 灰色4px 宽)
f = cv2.copyMakeBorder(f, 0, 0, 0, 4,
cv2.BORDER_CONSTANT, value=(100, 100, 100))
aligned.append(f)
result = cv2.hconcat(aligned)
rh, rw = result.shape[:2]
scale = min(self._max_w / rw, self._max_h / rh, 1.0)
if scale < 1.0:
result = cv2.resize(result, (int(rw * scale), int(rh * scale)))
return result
def run(self):
has_gui = bool(os.environ.get("DISPLAY", ""))
if not has_gui:
print("No GUI, press Ctrl+C to exit.")
if has_gui:
cv2.namedWindow("Camera Viewer", cv2.WINDOW_NORMAL)
# 用 pynput 全局监听键盘(不依赖 OpenCV 窗口焦点)
stop_event = threading.Event()
if has_gui:
from pynput import keyboard
def _on_press(key):
try:
if key == keyboard.Key.esc or (hasattr(key, 'char') and key.char == 'q'):
stop_event.set()
except Exception:
pass
kb_listener = keyboard.Listener(on_press=_on_press)
kb_listener.daemon = True
kb_listener.start()
print(f"\nConnected {len(self.cameras)} cameras:\n")
for i, cam in enumerate(self.cameras):
print(f"[{i+1}] {cam.serial} ({cam.width}x{cam.height} {cam.fourcc or 'default'})")
print(f" video_path: {cam.device}")
print(f" v4l_path: {cam.by_path}")
if cam.by_id:
print(f" v4l_id: {cam.by_id}")
print()
print("\nPress q or Esc to exit.\n")
expired = 0 if self.seconds <= 0 else time.monotonic() + self.seconds
frame_count = 0
start_monotonic = time.monotonic()
try:
while not stop_event.is_set() and (expired == 0 or time.monotonic() < expired):
frames = [cam.read() for cam in self.cameras]
frame_count += 1
if has_gui:
stitched = self._stitch_frames(frames, start_monotonic)
if stitched is not None:
if frame_count == 1:
cv2.resizeWindow("Camera Viewer",
stitched.shape[1], stitched.shape[0])
cv2.imshow("Camera Viewer", stitched)
cv2.waitKey(1)
# exit if window was closed
try:
if cv2.getWindowProperty("Camera Viewer", cv2.WND_PROP_VISIBLE) < 1:
break
except cv2.error:
break
else:
if frame_count % 30 == 0:
parts = []
for cam, f in zip(self.cameras, frames):
status = "OK" if f is not None else "FAIL"
parts.append(f"{cam.device}:{status}")
print(f"[{time.strftime('%H:%M:%S')}] #{frame_count} | "
+ " | ".join(parts), end="\r")
except KeyboardInterrupt:
pass
finally:
stop_event.set()
if has_gui:
cv2.destroyAllWindows()
for cam in self.cameras:
cam.close()
print("\nExited.")
def main():
parser = argparse.ArgumentParser(
description="Multi-camera stitched viewer",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
uf-camera-view -l # list all cameras
uf-camera-view -T xvisio -l # list all XVisio vSLAM
uf-camera-view -T realsense -l # list all RealSense
uf-camera-view -T xvisio # show all XVisio (default 1280x1280 YU12)
uf-camera-view -T xvisio -s 250801DR48 # filter by serial number
uf-camera-view -T xvisio -W 1280 -H 1280 -F YU12 # specify format (main camera)
uf-camera-view -T xvisio -W 640 -H 1920 -F NV12 # specify format (aux cameras)
uf-camera-view -T other # show other cameras
uf-camera-view -T other -t 30 # preview 30 seconds
DISPLAY_W=2560 DISPLAY_H=1440 uf-camera-view -T xvisio # set window size limit""")
parser.add_argument("-T", "--type", type=str, default=None,
choices=["xvisio", "realsense", "other", "all"],
help="camera type")
parser.add_argument("-s", "--serial", type=str, default=None,
help="filter by serial number")
parser.add_argument("-l", "--list", action="store_true",
help="list devices of the given type")
parser.add_argument("-t", "--time", type=int, default=0,
help="preview seconds, 0 = infinite (default: 0)")
parser.add_argument("-W", "--width", type=int, default=None,
help="capture width")
parser.add_argument("-H", "--height", type=int, default=None,
help="capture height")
parser.add_argument("-F", "--fourcc", type=str, default=None,
help="pixel format (e.g. YU12, NV12, MJPG)")
if len(sys.argv) == 1:
parser.print_help()
return
args = parser.parse_args()
# -l without -T defaults to all; no -l and no -T is an error
if args.list and not args.type:
args.type = "all"
elif not args.list and not args.type:
parser.error("-T/--type is required (xvisio, realsense, other, all)")
devices = CameraManager.find(args.type)
if args.list:
if devices:
type_label = {"all": "", "other": "Other "}.get(args.type, f'{args.type} ')
print(f"Found {len(devices)} {type_label}camera(s):\n")
for i, d in enumerate(devices):
print(f"[{i+1}] {d['serial']}")
print(f" video_path: {d['device']}")
print(f" v4l_path: {d['by_path']}")
if d.get("by_id"):
print(f" v4l_id: {d['by_id']}")
print()
else:
print(f"No {args.type} cameras found")
return
if args.serial:
devices = CameraManager.find_by_serial(args.serial, args.type)
if not devices:
print(f"No {args.type} cameras found. Use -l to list available devices.")
return
cameras = []
for d in devices:
w = args.width if args.width else d["default_w"]
h = args.height if args.height else d["default_h"]
fc = args.fourcc if args.fourcc else d["default_fourcc"]
cam = Camera(d["device"], serial=d["serial"],
by_path=d["by_path"], by_id=d.get("by_id", ""),
width=w, height=h, fourcc=fc)
if cam.open():
cameras.append(cam)
else:
print(f"WARNING: cannot open {d['device']}")
if not cameras:
print("No available cameras.")
return
viewer = MultiCameraViewer(cameras, seconds=args.time)
viewer.run()
if __name__ == "__main__":
main()