VLA管线Demo
VLA管线Demo
简介
机器人在物理环境中实现自主操作,不仅依赖环境感知能力,还需要形成涵盖任务目标理解、环境状态预测与动作执行的完整闭环。相较于数字空间,真实环境具有更强的不确定性。遮挡、光照变化、物体形变以及相机运动等因素,均可能导致相同操作呈现不同的观测结果与状态变化。视觉—语言—动作模型(Vision-Language-Action,VLA)旨在解决此类问题,其训练通常需要以动作片段为单位组织的视频数据,并结合三维手部状态、相机运动轨迹及自然语言指令等信号。
训练数据获取是制约 VLA 模型规模化发展的关键瓶颈。真实机器人数据采集能够提供较高精度的状态与动作信息,但其规模受到硬件成本、实验场地、安全要求及人工标注开销的显著限制。互联网视频具有较大的数据规模和丰富的场景覆盖范围,但通常缺乏精确的三维状态、相机运动信息及结构化动作表征。第一视角人类视频(Egocentric Human Videos)为解决上述矛盾提供了一种可行的数据来源。此类视频可通过可穿戴设备进行规模化采集,其观察视角与机器人的第一视角感知具有较高一致性,并能够自然记录操作者的双手运动及其与环境物体的交互过程。通过从视频中恢复相机位姿、场景深度、双手三维姿态及动作语义信息,可进一步构建完整的观测—动作轨迹,为 VLA 模型的预训练与下游任务适配提供结构化数据基础。
VITRA 是一种机器人操作视觉—语言—动作(VLA)模型预训练方法,使用大量未经脚本设计的真实生活人类手部活动视频。VITRA 将人手视为机器人末端执行器,并证明:无需任何人工标注,即可将“自然场景中”的第一视角人类视频转换为与现有机器人 VLA 训练数据在任务粒度和标签形式上完全对齐的数据格式。本文参考 VITRA 论文的技术路线,将其中的核心环节封装为RayData map_batches,在阿里云 ACK 的 Ray 环境中,将第一视角人类视频转化为 LeRobotDataset v2.0 格式的训练数据。整条链路使用 CPU 完成视频裁切和动作分割,在 GPU 上通过 Ray GPU Actor 加载 HaWoR、MoGe-2 和 MegaSAM 模型完成三维重建,最终输出符合 LeRobot v2.0 格式的标准化 VLA 训练数据集。
整体流程
接下来将逐步分解如何通过 Ray on ACK 方案跑具身智能场景 VLA 数据管线:
-
准备 ACK 集群(已有集群可跳过创建)+ 托管 ack-kuberay 组件安装
-
模型和数据集准备
-
ACK 集群 PV/PVC 准备
-
Ray VLA 业务镜像构建
-
基于 Ray Data + data juicer 的VLA 业务代码
-
构建并提交 RayJob CR
-
RayJob /RayCluster dashboard 访问/可观测
-
构建产物查看
-
业务代码调试 算子优化,重新走 5 流程

操作步骤
ACK 集群准备 + 托管 ack-kuberay 组件安装
如果已有可用的 ACK 集群,可跳过“创建 ACK 集群(可选)”,直接从“安装集群组件 Kuberay-Operator”开始。已有集群需满足以下条件:
- 至少有一个可调度的 GPU 节点(RayJob 的
gpu-worker申请nvidia.com/gpu: 1),并为 Ray Head 和 Worker 预留足够的 CPU 与内存,具体规格见rayjob.yaml.tpl。 - 已安装
csi-plugin组件,用于挂载 OSS 存储卷。 - 建议与 OSS Bucket、ACR 实例位于同一地域,以便使用内网 Endpoint。
创建 ACK 集群(可选)
仅在尚无 ACK 集群时执行本节。
方式一:通过控制台创建
按照首次使用 ACK 快速入门创建 ACK 托管版集群。生产环境建议选择 Pro 版,并提前规划地域、VPC、vSwitch、Service CIDR 和 Terway Pod vSwitch。
方式二:通过阿里云 CLI 创建
使用前参考附录完成阿里云 CLI 配置。
集群创建会产生 ACK、ECS、SLB、NAT 网关等资源费用。执行前请确认资源规格、网络规划、配额和账户余额。下面的模板创建 ACK 托管版 Pro 集群和一个 GPU 节点池;请先替换所有 <...> 占位符,并根据目标地域可用规格调整节点配置。
创建 create-ack.json:
源文件:create-ack.json
展开查看 create-ack.json
{
"name": "vla-ray-cluster",
"cluster_type": "ManagedKubernetes",
"profile": "Default",
"cluster_spec": "ack.pro.small",
"region_id": "<region-id>",
"snat_entry": true,
"endpoint_public_access": false,
"deletion_protection": true,
"proxy_mode": "ipvs",
"timezone": "Asia/Shanghai",
"addons": [
{
"name": "terway-controlplane",
"config": "{\"ENITrunking\":\"true\"}"
},
{
"name": "terway-eniip",
"config": "{\"IPVlan\":\"false\",\"NetworkPolicy\":\"false\",\"ENITrunking\":\"true\"}"
},
{ "name": "csi-plugin" },
{ "name": "managed-csiprovisioner" }
],
"os_type": "Linux",
"platform": "AliyunLinux",
"image_type": "AliyunLinux3",
"pod_vswitch_ids": ["<pod-vswitch-id>"],
"charge_type": "PostPaid",
"vpcid": "<vpc-id>",
"service_cidr": "172.21.0.0/20",
"vswitch_ids": ["<vswitch-id>"],
"ip_stack": "ipv4",
"nodepools": [
{
"nodepool_info": {
"name": "gpu-worker"
},
"scaling_group": {
"system_disk_category": "cloud_essd",
"system_disk_size": 120,
"system_disk_performance_level": "PL0",
"vswitch_ids": ["<vswitch-id>"],
"instance_types": ["<gpu-instance-type>"],
"instance_charge_type": "PostPaid",
"platform": "AliyunLinux",
"image_type": "AliyunLinux3",
"desired_size": 1
},
"kubernetes_config": {
"cpu_policy": "none",
"cms_enabled": true,
"unschedulable": false,
"runtime": "containerd",
"runtime_version": "1.6.36"
}
}
]
}
提交创建请求并记录集群 ID:
export REGION_ID="<region-id>"
CREATE_RESULT="$(
aliyun cs POST /clusters \
--region "${REGION_ID}" \
--header "Content-Type=application/json" \
--body "$(cat create-ack.json)"
)"
printf '%s\n' "${CREATE_RESULT}" | jq .
export CLUSTER_ID="$(printf '%s' "${CREATE_RESULT}" | jq -r '.cluster_id')"
# 创建操作为异步执行;重复查询,直至 state 进入 running
aliyun cs GET "/clusters/${CLUSTER_ID}" --region "${REGION_ID}" | jq .
模板默认关闭 API Server 公网访问,应在 VPC 内或通过 CloudShell 管理集群。如果确实需要从公网连接,可将 endpoint_public_access 改为 true,并按最小范围限制访问来源。完整字段和有效取值以 CreateCluster API 为准。
安装集群组件 Kuberay-Operator
按照 Ray on ACK 最佳实践在 ACK 控制台的组件管理页面安装托管版 Kuberay-Operator。该托管组件由 ACK 负责版本维护,并集成调度、弹性配额、Prometheus、SLS 和 OSS 等能力,因此本文不使用 Helm 安装开源版本替代它。如果集群已安装该组件,可跳过本节。
获取集群 KubeConfig 并通过 kubectl 连接集群
方式一:通过控制台获取
按照获取集群 KubeConfig 并使用 kubectl 连接集群下载公网或内网 KubeConfig。
方式二:通过阿里云 CLI 获取
新建集群时复用上一步导出的 REGION_ID 和 CLUSTER_ID;使用已有集群时,先将其替换为集群所在地域和集群 ID(可在 ACK 控制台集群列表中查看,或执行 aliyun cs GET /api/v1/clusters --region <region-id> 查询)。KubeConfig 包含集群访问凭据,不得提交到 Git 仓库或通过不安全渠道传递。
# 新建集群时已在上一步导出;使用已有集群时替换占位符
export REGION_ID="${REGION_ID:-<region-id>}"
export CLUSTER_ID="${CLUSTER_ID:-<cluster-id>}"
export KUBECONFIG="${HOME}/.kube/vla-ray-cluster.config"
mkdir -p "$(dirname "${KUBECONFIG}")"
aliyun cs GET "/k8s/${CLUSTER_ID}/user_config" \
--region "${REGION_ID}" \
| jq -r '.config' > "${KUBECONFIG}"
chmod 600 "${KUBECONFIG}"
kubectl cluster-info
kubectl get nodes
请选择与执行环境网络连通的 KubeConfig:VPC 内使用内网连接,本地工作站需要集群已启用并安全配置 API Server 公网访问。
模型和数据集准备
数据:
EgoDex Test Set (16 GB)
https://github.com/apple-aiml-research/ml-egodex
下载并解压后,将数据上传至OSS 对应Bucket 根目录下 /egodex/data/test/
模型:
| mano/MANO_LEFT.pkl | 4 MB | MANO 官网(需注册,下载 mano_v*_*.zip后解压) |
|---|---|---|
| mano/MANO_RIGHT.pkl | 4 MB | MANO 官网(需注册,下载 mano_v*_*.zip后解压) |
| hawor/hawor.ckpt | 3.1 GB | HuggingFace ThunderVVV/HaWoR |
| hawor/model_config.yaml | 3 KB | HuggingFace ThunderVVV/HaWoR |
| hawor/detector.pt | 51 MB | HuggingFace rolpotamias/WiLoR |
| moge/model.pt | 1.3 GB | HuggingFace Ruicheng/moge-2-vitl-normal |
模型文件上传至OSS 对应Bucket 根目录下/models/
数据上传 OSS 方式
方式一:通过控制台上传
先在本地解压 ZIP,然后:
- 进入 OSS 控制台 → Bucket 列表 → 目标 Bucket → 文件列表。
- 单击“上传文件”。
- 将解压后的整个文件夹拖入上传区域。
- 确认目标目录后上传。控制台会保留文件夹结构,单个文件不能超过 5 GB。
更多信息,请参见 OSS 简单上传。
方式二:通过阿里云 CLI 上传
阿里云 CLI v3.0.304 及以上版本集成了 ossutil 2.0,可通过 aliyun ossutil 管理 Bucket 和 Object。下面使用公网 Endpoint 从本地上传;后续 ACK PV 挂载仍建议使用同地域内网 Endpoint。
Bucket 名称在全球范围内唯一,请替换为你自己的名称。输入 Bucket 存放数据、模型和代码,输出 Bucket 存放作业结果;如 Bucket 已存在,可跳过对应的 mb 命令。
export REGION_ID="<region-id>"
export OSS_PUBLIC_ENDPOINT="https://oss-${REGION_ID}.aliyuncs.com"
export OSS_ROOT_BUCKET="<input-and-model-bucket>"
export OSS_OUTPUT_BUCKET="<output-bucket>"
# 确认集成的 ossutil 可用
aliyun ossutil version
# 创建私有 Bucket
aliyun ossutil mb "oss://${OSS_ROOT_BUCKET}" \
--region "${REGION_ID}" \
--endpoint "${OSS_PUBLIC_ENDPOINT}"
aliyun ossutil mb "oss://${OSS_OUTPUT_BUCKET}" \
--region "${REGION_ID}" \
--endpoint "${OSS_PUBLIC_ENDPOINT}"
# 上传 EgoDex 数据,目标目录为 /egodex/data/test/
aliyun ossutil cp ./egodex/data/test/ \
"oss://${OSS_ROOT_BUCKET}/egodex/data/test/" \
--recursive --update \
--region "${REGION_ID}" \
--endpoint "${OSS_PUBLIC_ENDPOINT}"
# 上传模型,目标目录为 /models/
aliyun ossutil cp ./models/ \
"oss://${OSS_ROOT_BUCKET}/models/" \
--recursive --update \
--region "${REGION_ID}" \
--endpoint "${OSS_PUBLIC_ENDPOINT}"
# 验证上传结果
aliyun ossutil ls "oss://${OSS_ROOT_BUCKET}/egodex/data/test/" \
--recursive --region "${REGION_ID}" --endpoint "${OSS_PUBLIC_ENDPOINT}"
aliyun ossutil ls "oss://${OSS_ROOT_BUCKET}/models/" \
--recursive --region "${REGION_ID}" --endpoint "${OSS_PUBLIC_ENDPOINT}"
--update 使重复执行时只上传源文件中较新的内容。不要在命令行中显式传入 --access-key-id 或 --access-key-secret,应复用已配置的安全身份 Profile。完整命令说明参见使用阿里云 CLI 调用 ossutil 管理 OSS 资源。
Ray VLA 镜像构建
源文件:Dockerfile
展开查看 Dockerfile
FROM pytorch/pytorch:2.7.1-cuda12.8-cudnn9-devel
LABEL description="VLA Pipeline pre-built image with Data-Juicer, MoGe-2, HaWoR, MegaSaM, PyTorch3D support"
ENV DEBIAN_FRONTEND=noninteractive
ENV TORCH_CUDA_ARCH_LIST="7.0;7.5;8.0;8.6;8.9;9.0;12.0"
ENV FORCE_CUDA=1
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
wget \
vim \
curl \
ffmpeg \
libgl1-mesa-glx \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
build-essential \
ninja-build \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir "py-data-juicer==1.6.0"
RUN pip install --no-cache-dir "ray[data,default]==2.56.0"
RUN pip install --no-cache-dir --force-reinstall "setuptools==69.5.1"
# 构建阶段提前确认 PyTorch 二进制已包含 Blackwell(sm_120)支持。
RUN python -c 'import torch; archs = torch._C._cuda_getArchFlags().split(); assert torch.version.cuda == "12.8", torch.version.cuda; assert "sm_120" in archs, archs'
RUN git clone --depth 1 https://github.com/ThunderVVV/HaWoR.git /root/.cache/data_juicer/assets/HaWoR
# modify requirements.txt:
# - remove chumpy (need --no-build-isolation, install separately)
# - remove aitviewer / moderngl-window / pyrender (OpenGL rendering, no head environment needed)
# - relax torch-scatter version locking (install the PyTorch 2.7 / CUDA 12.8 wheel separately)
# - relax numpy version locking (to avoid dependency resolution conflicts)
RUN cd /root/.cache/data_juicer/assets/HaWoR \
&& sed -i '/^chumpy/d' requirements.txt \
&& sed -i '/^aitviewer/d' requirements.txt \
&& sed -i '/^moderngl-window/d' requirements.txt \
&& sed -i '/^pyrender/d' requirements.txt \
&& sed -i 's/^torch-scatter==.*/# &/' requirements.txt \
&& sed -i 's/^numpy==.*/numpy/' requirements.txt
RUN pip install --no-cache-dir "torch-scatter==2.1.2" -f https://data.pyg.org/whl/torch-2.7.0+cu128.html
# MoGe-2 camera calibration
RUN pip install --no-cache-dir "moge @ git+https://github.com/microsoft/MoGe.git@925b8ed835a7a9cdb7578ba15c658a0afc969030"
# HaWoR dependencies (install from requirements.txt)
RUN pip install --no-cache-dir -r /root/.cache/data_juicer/assets/HaWoR/requirements.txt
# chumpy needs --no-build-isolation, install separately
RUN pip install --no-cache-dir --no-build-isolation "chumpy @ git+https://github.com/mattloper/chumpy"
RUN pip install --no-cache-dir scipy pyarrow av Pillow openai
RUN pip install --no-cache-dir \
"numpy==1.26.4" \
"pyarrow>=19,<26" \
"plyfile==1.1.3" \
"opencv-python==4.10.0.84" \
"opencv-contrib-python==4.10.0.84" \
"pydantic==2.10.6" \
"click>=7.0"
# 克隆 MegaSAM 并修改源码:
# - 将 .type() 替换为 .scalar_type(),以兼容新版 PyTorch
# - 增加 L20(8.9)、H20(9.0)和 Blackwell(12.0)CUDA 架构支持
RUN git clone --recursive https://github.com/mega-sam/mega-sam.git /root/.cache/data_juicer/assets/mega-sam \
&& cd /root/.cache/data_juicer/assets/mega-sam \
&& sed -i 's/\.type()/\.scalar_type()/g' \
base/src/altcorr_kernel.cu \
base/src/correlation_kernels.cu \
base/src/droid_kernels.cu \
base/thirdparty/lietorch/lietorch/src/lietorch_gpu.cu \
base/thirdparty/lietorch/lietorch/src/lietorch_cpu.cpp \
&& sed -i "/compute_86,code=sm_86/a\ '-gencode=arch=compute_89,code=sm_89',\n '-gencode=arch=compute_90,code=sm_90',\n '-gencode=arch=compute_120,code=sm_120'," base/setup.py
# 在共享 Python 环境中编译 droid_backends 和 lietorch。
RUN cd /root/.cache/data_juicer/assets/mega-sam/base && python setup.py install
# 依赖不兼容或运行时模块无法导入时,直接终止镜像构建。
RUN pip check \
&& python -c "import torch; import cv2, data_juicer, droid_backends, lietorch, numpy, plyfile, pyarrow, pydantic, ray, ray.data; \
assert data_juicer.__version__ == '1.6.0'; assert ray.__version__ == '2.56.0'; \
assert numpy.__version__ == '1.26.4'; assert int(pyarrow.__version__.split('.')[0]) < 26, pyarrow.__version__; \
assert cv2.__version__ == '4.10.0'; assert pydantic.__version__ == '2.10.6'; \
assert torch.version.cuda == '12.8', torch.version.cuda"
RUN pip cache purge 2>/dev/null || true
WORKDIR /workspace
ENTRYPOINT ["python"]
镜像构建完成后,推送镜像到镜像仓库,建议推送至同地域 ACR 并记录内网镜像访问地址在创建 RayJob 时使用。
通过阿里云 CLI 准备 ACR 并推送镜像
以下示例使用已有的 ACR 企业版实例。实例创建暂不通过 CLI 完成;如果尚无实例,请先在 ACR 控制台创建。命名空间和仓库已存在时,可跳过相应创建命令。
export ACR_INSTANCE_ID="<acr-instance-id>"
export ACR_NAMESPACE="vla"
export ACR_REPOSITORY="ray-vla-pipeline"
export ACR_REGISTRY="<registry-domain>"
export IMAGE_TAG="v1"
# 查询实例,并创建私有命名空间和仓库
aliyun cr ListInstance
aliyun cr CreateNamespace \
--InstanceId "${ACR_INSTANCE_ID}" \
--NamespaceName "${ACR_NAMESPACE}" \
--AutoCreateRepo false
aliyun cr CreateRepository \
--InstanceId "${ACR_INSTANCE_ID}" \
--RepoNamespaceName "${ACR_NAMESPACE}" \
--RepoName "${ACR_REPOSITORY}" \
--RepoType PRIVATE \
--Summary "Ray VLA pipeline image"
# 获取临时登录凭证,并通过标准输入交给 Docker,避免密码进入命令历史
TOKEN_JSON="$(aliyun cr GetAuthorizationToken --InstanceId "${ACR_INSTANCE_ID}")"
printf '%s' "$(printf '%s' "${TOKEN_JSON}" | jq -r '.data.authorizationToken')" \
| docker login "${ACR_REGISTRY}" \
--username "$(printf '%s' "${TOKEN_JSON}" | jq -r '.data.tempUserName')" \
--password-stdin
export RAY_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/${ACR_REPOSITORY}:${IMAGE_TAG}"
docker build -t "${RAY_IMAGE}" .
docker push "${RAY_IMAGE}"
GetAuthorizationToken 返回的是短期凭证,只用于本次镜像推送。后续创建长期有效的 Kubernetes imagePullSecret 时,请使用 ACR 实例访问凭证或具备最小只读权限的专用 RAM 身份,不要把临时 Token 或个人凭证提交到仓库。ACK 集群拉取镜像时,RAY_IMAGE 应优先改为同地域的 ACR VPC 地址。
ACK 集群PV/PVC/Secret 准备
按实际情况调整init.sh中的环境变量,创建init.sh,fixed-resources.yaml.tpl
源文件:init.sh
展开查看 init.sh
# Kubernetes 命名空间
export NAMESPACE="default"
# RayJob 资源名称
export RAY_JOB_NAME="vla-ray-job"
# Ray 版本,应与容器镜像中的 Ray 版本一致
export RAY_VERSION="2.56.0"
# Ray Head 和 Worker 使用的容器镜像
export RAY_IMAGE="<registry>/<repository>:<tag>"
# Kubernetes 镜像拉取凭据 Secret 名称
export IMAGE_PULL_SECRET_NAME="ray-image-pull-secret"
# 镜像仓库地址,例如 registry-vpc.cn-hangzhou.aliyuncs.com
export REGISTRY_SERVER="<registry-server>"
# 镜像仓库登录用户名
export REGISTRY_USERNAME="<registry-username>"
# 镜像仓库登录密码
export REGISTRY_PASSWORD="<registry-password>"
# 保存 OpenAI 配置的 Kubernetes Secret 名称
export OPENAI_SECRET_NAME="ray-openai-secret"
# OpenAI API 密钥,填写原始值,脚本会自动进行 Base64 编码
export OPENAI_API_KEY="<openai-api-key>"
# OpenAI 兼容接口地址,填写原始值,脚本会自动进行 Base64 编码
export OPENAI_BASE_URL="<openai-base-url>"
# 保存 OSS AccessKey 的 Kubernetes Secret 名称
export OSS_SECRET_NAME="ray-oss-secret"
# OSS AccessKey ID
export OSS_ACCESS_KEY_ID="<oss-access-key-id>"
# OSS AccessKey Secret
export OSS_ACCESS_KEY_SECRET="<oss-access-key-secret>"
# OSS Endpoint,建议使用与集群同地域的内网地址
export OSS_ENDPOINT="<oss-endpoint>"
# OSS 请求签名版本;使用 V4 时需确保客户端支持
export OSS_SIG_VERSION="v4"
# 输入数据、模型和代码存储对应的 PV 名称
export OSS_ROOT_PV_NAME="ray-vla-root-pv"
# 输入数据、模型和代码存储对应的 PVC 名称
export OSS_ROOT_PVC_NAME="ray-vla-root-pvc"
# 存放输入数据、模型和代码的 OSS Bucket
export OSS_ROOT_BUCKET="<input-and-model-bucket>"
# Bucket 内作为挂载根目录的前缀
export OSS_ROOT_PATH="/<input-and-model-prefix>"
# 输出数据存储对应的 PV 名称
export OSS_OUTPUT_PV_NAME="ray-vla-output-pv"
# 输出数据存储对应的 PVC 名称
export OSS_OUTPUT_PVC_NAME="ray-vla-output-pvc"
# 存放作业输出结果的 OSS Bucket
export OSS_OUTPUT_BUCKET="<output-bucket>"
# Bucket 内用于保存作业输出的前缀
export OSS_OUTPUT_PATH="/<output-prefix>"
required_vars=(
NAMESPACE RAY_JOB_NAME RAY_VERSION RAY_IMAGE
IMAGE_PULL_SECRET_NAME REGISTRY_SERVER REGISTRY_USERNAME REGISTRY_PASSWORD
OPENAI_SECRET_NAME OPENAI_API_KEY OPENAI_BASE_URL
OSS_SECRET_NAME OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET OSS_ENDPOINT OSS_SIG_VERSION
OSS_ROOT_PV_NAME OSS_ROOT_PVC_NAME OSS_ROOT_BUCKET OSS_ROOT_PATH
OSS_OUTPUT_PV_NAME OSS_OUTPUT_PVC_NAME OSS_OUTPUT_BUCKET OSS_OUTPUT_PATH
)
for var_name in "${required_vars[@]}"; do
var_value="${!var_name:-}"
if [[ -z "${var_value}" || "${var_value}" == *"<"* || "${var_value}" == *">"* ]]; then
echo "变量 ${var_name} 未设置或仍包含示例占位符" >&2
exit 1
fi
done
export OSS_ACCESS_KEY_ID_BASE64="$(printf '%s' "${OSS_ACCESS_KEY_ID}" | base64 | tr -d '\n')"
export OSS_ACCESS_KEY_SECRET_BASE64="$(printf '%s' "${OSS_ACCESS_KEY_SECRET}" | base64 | tr -d '\n')"
export OPENAI_API_KEY_BASE64="$(printf '%s' "${OPENAI_API_KEY}" | base64 | tr -d '\n')"
export OPENAI_BASE_URL_BASE64="$(printf '%s' "${OPENAI_BASE_URL}" | base64 | tr -d '\n')"
REGISTRY_AUTH_BASE64="$(
printf '%s' "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" \
| base64 \
| tr -d '\r\n'
)"
export DOCKER_CONFIG_JSON_BASE64="$(
printf '{"auths":{"%s":{"auth":"%s"}}}' \
"${REGISTRY_SERVER}" \
"${REGISTRY_AUTH_BASE64}" \
| base64 \
| tr -d '\r\n'
)"
展开查看 fixed-resources.yaml.tpl
apiVersion: v1
kind: Secret
metadata:
name: "${IMAGE_PULL_SECRET_NAME}"
namespace: "${NAMESPACE}"
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: "${DOCKER_CONFIG_JSON_BASE64}"
---
apiVersion: v1
kind: Secret
metadata:
name: "${OSS_SECRET_NAME}"
namespace: "${NAMESPACE}"
type: Opaque
data:
akId: "${OSS_ACCESS_KEY_ID_BASE64}"
akSecret: "${OSS_ACCESS_KEY_SECRET_BASE64}"
---
apiVersion: v1
kind: Secret
metadata:
name: "${OPENAI_SECRET_NAME}"
namespace: "${NAMESPACE}"
type: Opaque
data:
OPENAI_API_KEY: "${OPENAI_API_KEY_BASE64}"
OPENAI_BASE_URL: "${OPENAI_BASE_URL_BASE64}"
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: "${OSS_ROOT_PV_NAME}"
spec:
capacity:
storage: 100Gi
accessModes:
- ReadOnlyMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
csi:
driver: ossplugin.csi.alibabacloud.com
volumeHandle: "${OSS_ROOT_PV_NAME}"
nodePublishSecretRef:
name: "${OSS_SECRET_NAME}"
namespace: "${NAMESPACE}"
volumeAttributes:
bucket: "${OSS_ROOT_BUCKET}"
path: "${OSS_ROOT_PATH}"
url: "${OSS_ENDPOINT}"
sigVersion: "${OSS_SIG_VERSION}"
otherOpts: "${OSS_ROOT_OTHER_OPTS}"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: "${OSS_ROOT_PVC_NAME}"
namespace: "${NAMESPACE}"
spec:
accessModes:
- ReadOnlyMany
resources:
requests:
storage: 100Gi
storageClassName: ""
volumeMode: Filesystem
volumeName: "${OSS_ROOT_PV_NAME}"
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: "${OSS_OUTPUT_PV_NAME}"
spec:
capacity:
storage: 500Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
csi:
driver: ossplugin.csi.alibabacloud.com
volumeHandle: "${OSS_OUTPUT_PV_NAME}"
nodePublishSecretRef:
name: "${OSS_SECRET_NAME}"
namespace: "${NAMESPACE}"
volumeAttributes:
bucket: "${OSS_OUTPUT_BUCKET}"
path: "${OSS_OUTPUT_PATH}"
url: "${OSS_ENDPOINT}"
sigVersion: "${OSS_SIG_VERSION}"
otherOpts: "${OSS_OUTPUT_OTHER_OPTS}"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: "${OSS_OUTPUT_PVC_NAME}"
namespace: "${NAMESPACE}"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 500Gi
storageClassName: ""
volumeMode: Filesystem
volumeName: "${OSS_OUTPUT_PV_NAME}"
在可访问ACK集群的唤醒下执行以下代码
source init.sh
envsubst < fixed-resources.yaml.tpl > fixed-resources.yaml
kubectl apply -f fixed-resources.yaml
基于 Ray Data + data juicer 的VLA 业务代码
将 vla_pipeline.py video_action_captioning_mapper.py 上传到OSS code 目录下,上传方式参考3.2.3
源文件:vla_pipeline.py
展开查看 vla_pipeline.py
#!/usr/bin/env python3
"""
数据源、输出目录、模型路径或资源参数,可按需调整
"""
from __future__ import annotations
import json
import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# 1. 输入数据:所有 Ray Pod 都能访问的 MP4 根目录,代码会递归查找 *.mp4。
INPUT_DIR = "/data/oss/egodex/data/test"
# 2. 输出根目录:所有 Ray Pod 都能写入的 OSS RWX 路径。
OUTPUT_ROOT = (
"/data/output/vla-results/"
"vla-demo"
)
# 3. 模型根目录,目录结构见 validate_models()。
MODEL_ROOT = "/data/oss/models"
# 4. 数据与导出参数。 Demo 固定处理排序后的前 100 个 MP4。
LIMIT = 100
TASK_TEXT = "manipulate object"
FRAME_NUM = 20
LEROBOT_FPS = 10
# 5. Ray Data 资源参数。MoGe 固定 8 个 Actor;Combined 可从 8 扩到 14;
# 每个 Actor 申请 0.25 GPU,GPU_BATCH_SIZE 只作用于这两个阶段。
CPU_PER_TASK = 1.0
GPU_BATCH_SIZE = 16
MOGE_GPUS = 0.25
COMBINED_GPUS = 0.25
MOGE_ACTOR_MIN = 8
MOGE_ACTOR_MAX = 8
COMBINED_ACTOR_MIN = 8
COMBINED_ACTOR_MAX = 14
# 6. 字幕模型。OPENAI_API_KEY 应预先注入 Ray Head,或通过 Ray Job runtime_env
# 传入;不要把密钥直接写进本文件。缺少凭据时当前 Mapper 会告警并回退文本。
CAPTION_MODEL = os.environ.get("VLA_CAPTION_MODEL", "qwen-vl-max")
CAPTION_API_ENDPOINT = os.environ.get("VLA_CAPTION_API_ENDPOINT", "")
VIDEO_KEY = "videos"
EXPECTED_DATA_JUICER_VERSION = "1.6.0"
import ray # noqa: E402
from ray.data import ActorPoolStrategy # noqa: E402
from data_juicer.ops.base_op import OPERATORS, Mapper # noqa: E402
from data_juicer.ops.mapper import ( # noqa: E402
ExportToLeRobotMapper,
VideoCameraCalibrationMogeMapper,
VideoCameraPoseMegaSaMMapper,
VideoExtractFramesMapper,
VideoHandActionComputeMapper,
VideoHandReconstructionHaworMapper,
)
from data_juicer.utils.constant import Fields, MetaKeys # noqa: E402
sys.path.insert(0, os.path.dirname(__file__))
from video_action_captioning_mapper import VideoActionCaptioningMapper # noqa: E402
@OPERATORS.register_module("video_hawor_megasam_combined_mapper")
class VideoHaWorMegaSaMCombinedMapper(Mapper):
"""在同一个 GPU Actor 内依次执行 HaWoR 和 MegaSaM。"""
_accelerator = "cuda"
def __init__(
self,
hawor_model_path: str,
hawor_config_path: str,
hawor_detector_path: str,
mano_right_path: str,
mano_left_path: str,
camera_calibration_field: str = MetaKeys.camera_calibration_moge_tags,
hawor_tag_field: str = MetaKeys.hand_reconstruction_hawor_tags,
frame_field: str = MetaKeys.video_frames,
megasam_tag_field: str = MetaKeys.video_camera_pose_tags,
megasam_save_dir: str | None = None,
*args: Any,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
common = {
"batch_mode": True,
"skip_op_error": kwargs.get("skip_op_error", False),
}
self._hawor_kwargs = {
"hawor_model_path": hawor_model_path,
"hawor_config_path": hawor_config_path,
"hawor_detector_path": hawor_detector_path,
"mano_right_path": mano_right_path,
"mano_left_path": mano_left_path,
"camera_calibration_field": camera_calibration_field,
"tag_field_name": hawor_tag_field,
"frame_field": frame_field,
"thresh": 0.2,
**common,
}
self._megasam_kwargs = {
"tag_field_name": megasam_tag_field,
"camera_calibration_field": camera_calibration_field,
"frame_field": frame_field,
"max_frames": 1000,
"droid_buffer": 1024,
"save_dir": megasam_save_dir,
**common,
}
self._hawor_op: VideoHandReconstructionHaworMapper | None = None
self._megasam_op: VideoCameraPoseMegaSaMMapper | None = None
def process_single(
self,
sample: dict[str, Any] | None = None,
rank: int | None = None,
) -> dict[str, Any]:
from loguru import logger
if sample is None:
raise ValueError("sample must not be None")
if self._hawor_op is None:
self._hawor_op = VideoHandReconstructionHaworMapper(
**self._hawor_kwargs
)
if self._megasam_op is None:
self._megasam_op = VideoCameraPoseMegaSaMMapper(
**self._megasam_kwargs
)
sample = self._hawor_op.process_single(sample, rank=rank)
try:
return self._megasam_op.process_single(sample, rank=rank)
except Exception as error:
# 与 V11 一致:MegaSaM 失败时保留 HaWoR 结果,让后续阶段继续。
logger.exception(f"MegaSaM failed; HaWoR result is preserved: {error}")
sample.setdefault(Fields.meta, {})
frame_field = self._hawor_kwargs["frame_field"]
tag_field = self._megasam_kwargs["tag_field_name"]
sample[Fields.meta][tag_field] = [
{} for _ in range(max(1, len(sample.get(frame_field, []))))
]
return sample
@dataclass(frozen=True)
class Paths:
root: Path
frames: Path
moge_arrays: Path
megasam_arrays: Path
lerobot: Path
processed_json: Path
processed_parquet: Path
@dataclass(frozen=True)
class Models:
moge: Path
hawor: Path
hawor_config: Path
hawor_detector: Path
mano_right: Path
mano_left: Path
def validate_models(root: Path) -> Models:
"""要求 MODEL_ROOT 下存在 moge/、hawor/ 和 mano/ 三个子目录。"""
models = Models(
moge=root / "moge/model.pt",
hawor=root / "hawor/hawor.ckpt",
hawor_config=root / "hawor/model_config.yaml",
hawor_detector=root / "hawor/detector.pt",
mano_right=root / "mano/MANO_RIGHT.pkl",
mano_left=root / "mano/MANO_LEFT.pkl",
)
missing = [str(path) for path in models.__dict__.values() if not path.is_file()]
if missing:
raise FileNotFoundError(f"model files are missing: {missing}")
return models
def prepare_paths() -> Paths:
input_dir = Path(INPUT_DIR)
output_root = Path(OUTPUT_ROOT)
model_root = Path(MODEL_ROOT)
for name, path in (
("INPUT_DIR", input_dir),
("OUTPUT_ROOT", output_root),
("MODEL_ROOT", model_root),
):
if not path.is_absolute():
raise ValueError(f"{name} must be an absolute shared-storage path")
if not input_dir.is_dir():
raise FileNotFoundError(f"INPUT_DIR does not exist: {input_dir}")
if not model_root.is_dir():
raise FileNotFoundError(f"MODEL_ROOT does not exist: {model_root}")
run_id = time.strftime("v12-%Y%m%dT%H%M%SZ", time.gmtime())
root = output_root / run_id
root.mkdir(parents=True, exist_ok=False)
return Paths(
root=root,
frames=root / "frames",
moge_arrays=root / "moge_arrays",
megasam_arrays=root / "megasam_arrays",
lerobot=root / "lerobot_dataset",
processed_json=root / "processed.jsonl",
processed_parquet=root / "processed.parquet",
)
def load_samples() -> list[dict[str, Any]]:
videos = sorted(path for path in Path(INPUT_DIR).rglob("*.mp4") if path.is_file())
if LIMIT > 0:
videos = videos[:LIMIT]
if not videos:
raise RuntimeError(f"no MP4 files found under {INPUT_DIR}")
return [
{VIDEO_KEY: [str(path)], "text": TASK_TEXT, Fields.meta: {}}
for path in videos
]
def run() -> None:
import data_juicer
if getattr(data_juicer, "__version__", "unknown") != EXPECTED_DATA_JUICER_VERSION:
raise RuntimeError(
f"Data-Juicer must be {EXPECTED_DATA_JUICER_VERSION}, got "
f"{getattr(data_juicer, '__version__', 'unknown')}"
)
if LIMIT < 0 or FRAME_NUM < 1 or LEROBOT_FPS < 1:
raise ValueError("LIMIT must be >= 0; FRAME_NUM and LEROBOT_FPS must be > 0")
# 先验证模型,再创建输出目录,配置错误不会留下半成品目录。
models = validate_models(Path(MODEL_ROOT))
samples = load_samples()
paths = prepare_paths()
worker_env = {
"PYTHONUNBUFFERED": "1",
"PYTHONDONTWRITEBYTECODE": "1",
"HF_HUB_OFFLINE": os.environ.get("HF_HUB_OFFLINE", "1"),
"TRANSFORMERS_OFFLINE": os.environ.get("TRANSFORMERS_OFFLINE", "1"),
"VLA_FRAME_DIR_UNIQUE": "1",
}
for name in ("OPENAI_API_KEY", "OPENAI_ADMIN_KEY"):
if os.environ.get(name):
worker_env[name] = os.environ[name]
ray.init(address="auto", runtime_env={"env_vars": worker_env})
# Data-Juicer 的嵌套 meta 字段包含普通 Arrow 类型无法直接表达的对象。
from ray.data import DataContext
DataContext.get_current().enable_fallback_to_arrow_object_ext_type = True
moge_pool = ActorPoolStrategy(
min_size=MOGE_ACTOR_MIN,
max_size=MOGE_ACTOR_MAX,
)
combined_pool = ActorPoolStrategy(
min_size=COMBINED_ACTOR_MIN,
max_size=COMBINED_ACTOR_MAX,
)
ds = ray.data.from_items(samples)
ds = ds.map_batches(
VideoExtractFramesMapper,
fn_constructor_kwargs={
"frame_sampling_method": "uniform",
"frame_num": FRAME_NUM,
"video_backend": "ffmpeg",
"output_format": "path",
"frame_dir": str(paths.frames),
"frame_field": MetaKeys.video_frames,
"legacy_split_by_text_token": False,
"batch_mode": True,
"skip_op_error": False,
"video_key": VIDEO_KEY,
},
batch_size=1,
num_cpus=CPU_PER_TASK,
batch_format="pyarrow",
)
ds = ds.map_batches(
VideoCameraCalibrationMogeMapper,
fn_constructor_kwargs={
"model_path": str(models.moge),
"tag_field_name": MetaKeys.camera_calibration_moge_tags,
"frame_field": MetaKeys.video_frames,
"output_depth": True,
"output_points": False,
"output_mask": False,
"save_dir": str(paths.moge_arrays),
"batch_mode": True,
"skip_op_error": False,
},
batch_size=GPU_BATCH_SIZE,
num_gpus=MOGE_GPUS,
batch_format="pyarrow",
compute=moge_pool,
)
ds = ds.map_batches(
VideoHaWorMegaSaMCombinedMapper,
fn_constructor_kwargs={
"hawor_model_path": str(models.hawor),
"hawor_config_path": str(models.hawor_config),
"hawor_detector_path": str(models.hawor_detector),
"mano_right_path": str(models.mano_right),
"mano_left_path": str(models.mano_left),
"camera_calibration_field": MetaKeys.camera_calibration_moge_tags,
"hawor_tag_field": MetaKeys.hand_reconstruction_hawor_tags,
"megasam_tag_field": MetaKeys.video_camera_pose_tags,
"frame_field": MetaKeys.video_frames,
"megasam_save_dir": str(paths.megasam_arrays),
"batch_mode": True,
"skip_op_error": False,
},
batch_size=GPU_BATCH_SIZE,
num_gpus=COMBINED_GPUS,
batch_format="pyarrow",
compute=combined_pool,
)
ds = ds.map_batches(
VideoHandActionComputeMapper,
fn_constructor_kwargs={
"hand_reconstruction_field": MetaKeys.hand_reconstruction_hawor_tags,
"camera_pose_field": MetaKeys.video_camera_pose_tags,
"tag_field_name": MetaKeys.hand_action_tags,
"hand_type": "both",
"batch_mode": True,
"skip_op_error": False,
},
batch_size=1,
num_cpus=CPU_PER_TASK,
batch_format="pyarrow",
)
caption_kwargs: dict[str, Any] = {
"api_or_hf_model": CAPTION_MODEL,
"is_api_model": True,
"hand_type": "both",
"frame_field": MetaKeys.video_frames,
"tag_field_name": "hand_action_caption",
"batch_mode": True,
"skip_op_error": False,
}
if CAPTION_API_ENDPOINT:
caption_kwargs["api_endpoint"] = CAPTION_API_ENDPOINT
ds = ds.map_batches(
VideoActionCaptioningMapper,
fn_constructor_kwargs=caption_kwargs,
batch_size=1,
num_cpus=CPU_PER_TASK,
batch_format="pyarrow",
)
ds = ds.map_batches(
ExportToLeRobotMapper,
fn_constructor_kwargs={
"output_dir": str(paths.lerobot),
"hand_action_field": MetaKeys.hand_action_tags,
"frame_field": MetaKeys.video_frames,
"video_key": VIDEO_KEY,
"task_description_key": "text",
"fps": LEROBOT_FPS,
"robot_type": "egodex_hand",
"batch_mode": True,
"skip_op_error": False,
},
batch_size=1,
num_cpus=CPU_PER_TASK,
batch_format="pyarrow",
)
result = ds.materialize()
output_rows = int(result.count())
result.write_json(str(paths.processed_json), force_ascii=False)
result.write_parquet(str(paths.processed_parquet))
ExportToLeRobotMapper.finalize_dataset(
output_dir=str(paths.lerobot),
fps=LEROBOT_FPS,
robot_type="egodex_hand",
)
print(
json.dumps(
{
"status": "succeeded",
"input_rows": len(samples),
"output_rows": output_rows,
"output_dir": str(paths.root),
"lerobot_dataset": str(paths.lerobot),
},
ensure_ascii=False,
indent=2,
),
flush=True,
)
ray.shutdown()
if __name__ == "__main__":
run()
源文件:video_action_captioning_mapper.py
展开查看 video_action_captioning_mapper.py
# yapf: disable
import json
import re
from typing import Dict, List, Optional
from loguru import logger
from pydantic import PositiveInt
from data_juicer.utils.constant import Fields, MetaKeys
from data_juicer.utils.lazy_loader import LazyLoader
from data_juicer.utils.mm_utils import image_path_to_base64, image_byte_to_base64
from data_juicer.utils.model_utils import (
get_model,
prepare_model,
update_sampling_params,
)
from data_juicer.ops.base_op import OPERATORS, TAGGING_OPS, Mapper
vllm = LazyLoader("vllm")
OP_NAME = 'video_action_captioning_mapper'
DEFAULT_SYSTEM_PROMPT = (
'You are a multimodal expert specializing in video captioning '
'for egocentric human-object interaction (HOI) clips.'
)
DEFAULT_USER_PROMPT_TEMPLATE = """Below are video frames sampled from an egocentric video containing a single atomic hand-object interaction. Describe the specific {hand_type}-hand action shown in these frames.
The {hand_type}-hand palm position is marked with a blue dot. Do not confuse it with the {opposite_hand_type} hand. Respect the temporal order of frames (Frame 1 is earliest, last frame is latest). Consider the hand status in each frame, whether there is an interacted object, and the temporal progression.
Rules for describing the {hand_type}-hand action:
- Only describe {hand_type}-hand actions. Ignore the {opposite_hand_type} hand completely.
- Write in imperative form (e.g., "Insert the key," not "The hand is inserting..."). Do not use personal pronouns.
- Use specific, descriptive verbs. Prefer verbs like "pick" and "place" when applicable. Avoid vague terms like "clean", "spray", or "fix".
- Describe the interacted object only if: (1) the {hand_type} hand clearly interacts with it, or (2) the hand is purposefully moving toward it with clear intent. Otherwise, return "N/A" as the action.
- Do not hallucinate: if no clear hand action or object is present, return "N/A" as the action.
- Do not guess the action based on context.
Return your answer in JSON format:
{{"think": "<brief 3-4 sentence reasoning>", "action": "<one-sentence action description or N/A>"}}
Here are the frames:
""" # noqa: E501
@TAGGING_OPS.register_module(OP_NAME)
@OPERATORS.register_module(OP_NAME)
class VideoActionCaptioningMapper(Mapper):
"""Generates hand action captions from pre-extracted video frames
using a VLM model (via API or vLLM).
This operator reads frames from a specified field (e.g., video_frames),
sends them along with a configurable prompt to a VLM, and stores the
structured JSON response (think + action) in a meta field.
The action description is also written to the text field.
Supports annotating 'left', 'right', or 'both' hands. When hand_type
is 'both', the operator runs two separate VLM calls (one per hand)
and joins the action descriptions with '; ' in the text field.
"""
_accelerator = 'cuda'
def __init__(
self,
api_or_hf_model: str = 'Qwen/Qwen2.5-VL-7B-Instruct',
is_api_model: bool = False,
*,
hand_type: str = 'right',
frame_field: str = MetaKeys.video_frames,
tag_field_name: str = 'hand_action_caption',
api_endpoint: Optional[str] = None,
response_path: Optional[str] = None,
system_prompt: Optional[str] = None,
user_prompt_template: Optional[str] = None,
model_params: Dict = {},
sampling_params: Dict = {},
try_num: PositiveInt = 3,
**kwargs,
):
"""
Initialization method.
:param api_or_hf_model: API model name or HuggingFace model name.
:param is_api_model: Whether the model is an API model.
If true, use OpenAI-compatible API; otherwise use vLLM.
:param hand_type: Which hand to describe: 'left', 'right', or
'both'. When 'both', two separate calls are made and actions
are joined with '; ' in the text field.
:param frame_field: The field name where pre-extracted frames
are stored. Each element is a list of frame paths (one list
per video).
:param tag_field_name: The meta field name to store the generated
caption result (JSON with 'think' and 'action').
:param api_endpoint: URL endpoint for the API.
:param response_path: Path to extract content from the API response.
Defaults to 'choices.0.message.content'.
:param system_prompt: System prompt for the VLM. If None, uses the
default egocentric HOI system prompt.
:param user_prompt_template: User prompt template string. Supports
{hand_type} and {opposite_hand_type} placeholders.
If None, uses the default template.
:param model_params: Parameters for initializing the model.
:param sampling_params: Extra parameters passed to the model.
e.g {'temperature': 0.9, 'top_p': 0.95}
:param try_num: The number of retry attempts when there is an API
call error or output parsing error.
:param kwargs: Extra keyword arguments.
"""
super().__init__(**kwargs)
self.is_api_model = is_api_model
if hand_type not in ('left', 'right', 'both'):
raise ValueError(
f"hand_type must be 'left', 'right', or 'both', "
f"got '{hand_type}'")
self.hand_type = hand_type
self.frame_field = frame_field
self.tag_field_name = tag_field_name
self.try_num = try_num
self.system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
self.user_prompt_template = (
user_prompt_template or DEFAULT_USER_PROMPT_TEMPLATE
)
sampling_params = update_sampling_params(
sampling_params, api_or_hf_model, not self.is_api_model)
if self.is_api_model:
self.sampling_params = sampling_params
self.model_key = prepare_model(
model_type='api',
model=api_or_hf_model,
endpoint=api_endpoint,
response_path=response_path,
**model_params,
)
else:
self.num_proc = 1
self.model_key = prepare_model(
model_type='vllm',
pretrained_model_name_or_path=api_or_hf_model,
**model_params,
)
self.sampling_params = vllm.SamplingParams(**sampling_params)
def _build_messages(self, frames, hand_type, opposite_hand_type):
"""Build the chat messages with frames embedded as images."""
user_text = self.user_prompt_template.format(
hand_type=hand_type,
opposite_hand_type=opposite_hand_type,
)
# Build multimodal content: prompt text + Frame N: [image] ...
user_content = [{'type': 'text', 'text': user_text}]
for i, frame in enumerate(frames):
image_data = image_byte_to_base64(frame) if isinstance(frame, bytes) else image_path_to_base64(frame)
user_content.append({
'type': 'text',
'text': f'Frame {i + 1}:',
})
user_content.append({
'type': 'image_url',
'image_url': {
'url': f'data:image/jpeg;base64,'
f'{image_data}',
},
})
user_content.append({
'type': 'text',
'text': '\nAnalyze the frames above and return the JSON result.',
})
messages = []
if self.system_prompt:
messages.append({
'role': 'system',
'content': self.system_prompt,
})
messages.append({
'role': 'user',
'content': user_content,
})
return messages
def _call_model(self, messages, rank=None):
"""Call the model and return raw text output."""
if self.is_api_model:
output = ''
for attempt in range(self.try_num):
try:
client = get_model(self.model_key, rank=rank)
output = client(messages, **self.sampling_params)
break
except Exception as e:
logger.warning(
f'API call failed (attempt {attempt + 1}'
f'/{self.try_num}): {e}')
else:
model, _ = get_model(self.model_key, rank, self.use_cuda())
response = model.chat(messages, self.sampling_params)
output = response[0].outputs[0].text
return output
@staticmethod
def _parse_output(raw_output):
"""Parse the JSON output from the model.
Handles cases where the model wraps JSON in ```json...``` fences
and/or appends extra commentary after the JSON block.
"""
text = raw_output.strip()
# Try to extract JSON from markdown code fences first
fence_match = re.search(
r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL)
if fence_match:
text = fence_match.group(1)
else:
# Try to extract the first {...} block
brace_match = re.search(r'\{.*\}', text, re.DOTALL)
if brace_match:
text = brace_match.group(0)
try:
result = json.loads(text, strict=False)
except json.JSONDecodeError:
try:
result = json.loads(text.replace("'", '"'), strict=False)
except Exception:
logger.warning(
f'Failed to parse model output as JSON: {raw_output}')
return {'think': '', 'action': ''}
if not isinstance(result, dict):
return {'think': '', 'action': str(result)}
return {
'think': result.get('think', ''),
'action': result.get('action', ''),
}
def _caption_single_hand(self, frames, hand_type, rank=None):
"""Run captioning for a single hand and return parsed result."""
opposite = 'left' if hand_type == 'right' else 'right'
messages = self._build_messages(frames, hand_type, opposite)
output = self._call_model(messages, rank=rank)
return self._parse_output(output)
def process_single(self, sample, rank=None, context=False):
# check if it's generated already
if self.tag_field_name in sample.get(Fields.meta, {}):
return sample
if Fields.meta not in sample:
sample[Fields.meta] = {}
# get frames from the frame_field
frame_data = sample.get(self.frame_field, [])
if not frame_data:
sample[Fields.meta][self.tag_field_name] = {
'think': '', 'action': 'N/A'}
return sample
# frame_data is a list of lists (one per video), flatten if needed
if isinstance(frame_data[0], list):
frames = frame_data[0]
else:
frames = frame_data
if not frames:
sample[Fields.meta][self.tag_field_name] = {
'think': '', 'action': 'N/A'}
return sample
if self.hand_type == 'both':
right_result = self._caption_single_hand(
frames, 'right', rank=rank)
left_result = self._caption_single_hand(
frames, 'left', rank=rank)
sample[Fields.meta][self.tag_field_name] = {
'right': right_result,
'left': left_result,
}
# join non-N/A actions into text
actions = []
for side, result in [('right', right_result),
('left', left_result)]:
action = result.get('action', '')
if action and action != 'N/A':
actions.append(f'{side} hand: {action}')
if actions:
sample[self.text_key] = '; '.join(actions)
else:
result = self._caption_single_hand(
frames, self.hand_type, rank=rank)
sample[Fields.meta][self.tag_field_name] = result
action = result.get('action', '')
if action and action != 'N/A':
sample[self.text_key] = action
return sample
构建并提交RayJob CR
使用3.4中已修改好的init.sh
执行
source init.sh
envsubst < rayjob.yaml.tpl > rayjob.yaml
kubectl apply -f rayjob.yaml
创建 rayjob.yaml.tpl,内容如下:
源文件:rayjob.yaml.tpl
展开查看 rayjob.yaml.tpl
apiVersion: ray.io/v1
kind: RayJob
metadata:
name: "${RAY_JOB_NAME}"
namespace: "${NAMESPACE}"
labels:
app.kubernetes.io/name: vla-ray-job
app.kubernetes.io/component: rayjob
spec:
submissionMode: K8sJobMode
entrypoint: "python /data/oss/code/vla_pipeline.py"
shutdownAfterJobFinishes: true
ttlSecondsAfterFinished: 600
submitterPodTemplate:
metadata:
labels:
app.kubernetes.io/name: vla-ray-job
app.kubernetes.io/component: submitter
app.kubernetes.io/instance: "${RAY_JOB_NAME}"
spec:
restartPolicy: Never
imagePullSecrets:
- name: "${IMAGE_PULL_SECRET_NAME}"
containers:
- name: ray-job-submitter
image: "${RAY_IMAGE}"
imagePullPolicy: IfNotPresent
rayClusterSpec:
rayVersion: "${RAY_VERSION}"
enableInTreeAutoscaling: true
autoscalerOptions:
upscalingMode: Aggressive
idleTimeoutSeconds: 120
imagePullPolicy: IfNotPresent
headGroupSpec:
rayStartParams:
dashboard-host: "0.0.0.0"
num-cpus: "0"
object-store-memory: "4294967296"
template:
metadata:
labels:
app.kubernetes.io/name: vla-ray-job
app.kubernetes.io/component: head
app.kubernetes.io/instance: "${RAY_JOB_NAME}"
spec:
imagePullSecrets:
- name: "${IMAGE_PULL_SECRET_NAME}"
containers:
- name: ray-head
image: "${RAY_IMAGE}"
imagePullPolicy: IfNotPresent
workingDir: /data/oss/code
env:
- name: PYTHONPATH
value: /data/oss/code
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: "${OPENAI_SECRET_NAME}"
key: OPENAI_API_KEY
- name: OPENAI_BASE_URL
valueFrom:
secretKeyRef:
name: "${OPENAI_SECRET_NAME}"
key: OPENAI_BASE_URL
ports:
- name: gcs-server
containerPort: 6379
protocol: TCP
- name: dashboard
containerPort: 8265
protocol: TCP
- name: client
containerPort: 10001
protocol: TCP
resources:
requests:
cpu: "4"
memory: 16Gi
limits:
cpu: "8"
memory: 24Gi
volumeMounts:
- name: oss-root
mountPath: /data/oss
readOnly: true
- name: oss-output
mountPath: /data/output
- name: dshm
mountPath: /dev/shm
- name: raytmp
mountPath: /tmp/ray
- name: workdir
mountPath: /data/work
volumes:
- name: oss-root
persistentVolumeClaim:
claimName: "${OSS_ROOT_PVC_NAME}"
readOnly: true
- name: oss-output
persistentVolumeClaim:
claimName: "${OSS_OUTPUT_PVC_NAME}"
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 8Gi
- name: raytmp
emptyDir:
sizeLimit: 30Gi
- name: workdir
persistentVolumeClaim:
claimName: "${OSS_OUTPUT_PVC_NAME}"
workerGroupSpecs:
- groupName: gpu-worker
replicas: 1
minReplicas: 1
maxReplicas: 1
rayStartParams:
num-cpus: "16"
memory: "103079215104"
object-store-memory: "25769803776"
template:
metadata:
labels:
app.kubernetes.io/name: vla-ray-job
app.kubernetes.io/component: gpu-worker
app.kubernetes.io/instance: "${RAY_JOB_NAME}"
spec:
imagePullSecrets:
- name: "${IMAGE_PULL_SECRET_NAME}"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/component: gpu-worker
app.kubernetes.io/instance: "${RAY_JOB_NAME}"
containers:
- name: ray-worker
image: "${RAY_IMAGE}"
imagePullPolicy: IfNotPresent
workingDir: /data/oss/code
env:
- name: PYTHONPATH
value: /data/oss/code
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: "${OPENAI_SECRET_NAME}"
key: OPENAI_API_KEY
- name: OPENAI_BASE_URL
valueFrom:
secretKeyRef:
name: "${OPENAI_SECRET_NAME}"
key: OPENAI_BASE_URL
resources:
requests:
cpu: "16"
memory: 96Gi
nvidia.com/gpu: "1"
limits:
cpu: "28"
memory: 160Gi
nvidia.com/gpu: "1"
volumeMounts:
- name: oss-root
mountPath: /data/oss
readOnly: true
- name: oss-output
mountPath: /data/output
- name: dshm
mountPath: /dev/shm
- name: raytmp
mountPath: /tmp/ray
- name: workdir
mountPath: /data/work
volumes:
- name: oss-root
persistentVolumeClaim:
claimName: "${OSS_ROOT_PVC_NAME}"
readOnly: true
- name: oss-output
persistentVolumeClaim:
claimName: "${OSS_OUTPUT_PVC_NAME}"
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 32Gi
- name: raytmp
emptyDir:
sizeLimit: 100Gi
- name: workdir
persistentVolumeClaim:
claimName: "${OSS_OUTPUT_PVC_NAME}"
- groupName: cpu-worker
replicas: 0
minReplicas: 0
maxReplicas: 16
rayStartParams:
num-cpus: "16"
memory: "68719476736"
object-store-memory: "17179869184"
template:
metadata:
labels:
app.kubernetes.io/name: vla-ray-job
app.kubernetes.io/component: cpu-worker
app.kubernetes.io/instance: "${RAY_JOB_NAME}"
spec:
imagePullSecrets:
- name: "${IMAGE_PULL_SECRET_NAME}"
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/component: cpu-worker
app.kubernetes.io/instance: "${RAY_JOB_NAME}"
containers:
- name: ray-worker
image: "${RAY_IMAGE}"
imagePullPolicy: IfNotPresent
workingDir: /data/oss/code
env:
- name: PYTHONPATH
value: /data/oss/code
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: "${OPENAI_SECRET_NAME}"
key: OPENAI_API_KEY
- name: OPENAI_BASE_URL
valueFrom:
secretKeyRef:
name: "${OPENAI_SECRET_NAME}"
key: OPENAI_BASE_URL
resources:
requests:
cpu: "16"
memory: 72Gi
limits:
cpu: "24"
memory: 96Gi
volumeMounts:
- name: oss-root
mountPath: /data/oss
readOnly: true
- name: oss-output
mountPath: /data/output
- name: dshm
mountPath: /dev/shm
- name: raytmp
mountPath: /tmp/ray
- name: workdir
mountPath: /data/work
volumes:
- name: oss-root
persistentVolumeClaim:
claimName: "${OSS_ROOT_PVC_NAME}"
readOnly: true
- name: oss-output
persistentVolumeClaim:
claimName: "${OSS_OUTPUT_PVC_NAME}"
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 24Gi
- name: raytmp
emptyDir:
sizeLimit: 60Gi
- name: workdir
persistentVolumeClaim:
claimName: "${OSS_OUTPUT_PVC_NAME}"
RayJob /RayCluster dashboard 访问/可观测
安装并查看Ray集群监控
安装ACK Ray监控组件
-
登陆阿里云容器服务控制台,在集群列表页面,单击目标集群名称,然后在左侧导航栏,选择运维管理 > Prometheus 监控。
-
单击右上角 采集配置,进入云监控控制台 - 接入中心 页面。
-
在接入管理 - 接入配置子页面中,单击新增接入
-
选择 ACK Ray 可观测 监控组件,根据实际需求,选择要安装的接入配置:
-
Ray集群:采集所有RayCluster暴露的监控指标,并安装Ray Cluster监控大盘。
-
ACK KubeRay:采集KubeRay Operator组件暴露的监控指标,并安装KubeRay Operator监控大盘。
-
-
点击开始接入,并等待接入完成。
查看作业运行状态
ACK Ray为用户暴露Ray Dashboard,用户可以通过Ray Dashboard查看作业的实时和历史运行状态。

查看ACK Ray监控大盘
安装ACK Ray监控组件后,可在阿里云容器服务控制台集群名称页的 运维管理 > Prometheus 监控 > AI / 大数据 下查看ACK Ray可观测组件安装的监控大盘。

构建产物查看
数据输出格式为 LeRobot
https://github.com/huggingface/lerobot
可以访问oss,检查构建产物或通过以下方式检查
source init.sh
envsubst < inspect-deployment.tpl > inspect-deployment.yaml
kubectl apply -f inspect-deployment.yaml
展开查看 inspect-deployment.tpl
apiVersion: apps/v1
kind: Deployment
metadata:
name: vla-artifact-inspector
namespace: "${NAMESPACE}"
labels:
app.kubernetes.io/name: vla-artifact-inspector
app.kubernetes.io/component: debug
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: vla-artifact-inspector
template:
metadata:
labels:
app.kubernetes.io/name: vla-artifact-inspector
app.kubernetes.io/component: debug
spec:
imagePullSecrets:
- name: "${IMAGE_PULL_SECRET_NAME}"
containers:
- name: inspector
image: "${RAY_IMAGE}"
imagePullPolicy: IfNotPresent
workingDir: /data/oss/code
command:
- python
- -c
- "import time; time.sleep(10**9)"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 2Gi
volumeMounts:
- name: oss-root
mountPath: /data/oss
readOnly: true
- name: oss-output
mountPath: /data/output
volumes:
- name: oss-root
persistentVolumeClaim:
claimName: "${OSS_ROOT_PVC_NAME}"
readOnly: true
- name: oss-output
persistentVolumeClaim:
claimName: "${OSS_OUTPUT_PVC_NAME}"
上传vis_hand_action_demo.py到OSS,进入inspect-deployment容器内,执行命令
python vis_hand_action_demo.py --model_root /data/oss/models --data_path /xxx/xxx.parquet --save_dir /xxx/xxx
展开查看 vis_hand_action_demo.py
#!/usr/bin/env python
"""Verify hand-action annotations without depending on ``hawor_utils``.
The script still uses the HaWoR repository and MANO model files bundled or
mounted in the runtime environment, but all local ``hawor_utils`` glue code is
included here.
Usage:
python vis_hand_action_demo_standalone.py \
--data_path processed.parquet \
--save_dir ./vis_action_verify \
--model_root /data/oss/models
"""
import argparse
import fractions
import json
import os
import pickle
import sys
from contextlib import redirect_stdout
import cv2
import numpy as np
import pyarrow.parquet as pq
import torch
from data_juicer.utils.cache_utils import DATA_JUICER_ASSETS_CACHE
from data_juicer.utils.constant import Fields, MetaKeys
# ---------------------------------------------------------------------------
# Inlined hawor_utils
# ---------------------------------------------------------------------------
HAWOR_HOME = os.path.expanduser(
os.environ.get(
"HAWOR_HOME",
os.path.join(DATA_JUICER_ASSETS_CACHE, "HaWoR"),
)
)
if not os.path.isdir(HAWOR_HOME):
raise FileNotFoundError(
f"HaWoR repository not found: {HAWOR_HOME}. "
"Set HAWOR_HOME to the HaWoR repository path."
)
if HAWOR_HOME not in sys.path:
sys.path.insert(0, HAWOR_HOME)
# HaWoR imports must happen after HAWOR_HOME is added to sys.path.
from hawor.utils.geometry import aa_to_rotmat # noqa: E402
from lib.models.mano_wrapper import MANO # noqa: E402
_MANO_MODEL_PATHS = {"right": None, "left": None}
def _existing_file(paths):
for path in paths:
if path:
expanded = os.path.abspath(os.path.expanduser(path))
if os.path.isfile(expanded):
return expanded
return None
def configure_mano_paths(
model_root=None,
mano_right_path=None,
mano_left_path=None,
):
"""Resolve MANO files from CLI arguments, environment, or known mounts."""
model_root = model_root or os.environ.get("MODEL_ROOT")
model_roots = [model_root] if model_root else []
model_roots.extend(["/data/oss/models", "/data/models"])
right_candidates = [
mano_right_path,
os.environ.get("MANO_RIGHT_PATH"),
*(os.path.join(root, "mano/MANO_RIGHT.pkl") for root in model_roots),
os.path.join(HAWOR_HOME, "_DATA/data/mano/MANO_RIGHT.pkl"),
]
left_candidates = [
mano_left_path,
os.environ.get("MANO_LEFT_PATH"),
*(os.path.join(root, "mano/MANO_LEFT.pkl") for root in model_roots),
os.path.join(HAWOR_HOME, "_DATA/data_left/mano_left/MANO_LEFT.pkl"),
]
_MANO_MODEL_PATHS["right"] = _existing_file(right_candidates)
_MANO_MODEL_PATHS["left"] = _existing_file(left_candidates)
if _MANO_MODEL_PATHS["right"] is None:
searched = "\n - ".join(
os.path.abspath(os.path.expanduser(path))
for path in right_candidates
if path
)
raise FileNotFoundError(
"MANO_RIGHT.pkl was not found. Provide --model_root, "
"--mano_right_path, MODEL_ROOT, or MANO_RIGHT_PATH. "
f"Searched:\n - {searched}"
)
print(f"MANO right model: {_MANO_MODEL_PATHS['right']}")
if _MANO_MODEL_PATHS["left"]:
print(f"MANO left model: {_MANO_MODEL_PATHS['left']}")
def _mano_config(is_left=False):
hand = "left" if is_left else "right"
model_path = _MANO_MODEL_PATHS[hand]
if model_path is None:
option = "--mano_left_path" if is_left else "--mano_right_path"
filename = "MANO_LEFT.pkl" if is_left else "MANO_RIGHT.pkl"
raise FileNotFoundError(
f"{filename} is required for {hand}-hand visualization; "
f"provide {option} or --model_root."
)
data_dir = os.path.join(
HAWOR_HOME,
"_DATA/data_left" if is_left else "_DATA/data",
)
config = {
"data_dir": data_dir,
"model_path": model_path,
"gender": "neutral",
"num_hand_joints": 15,
"create_body_pose": False,
}
if is_left:
config["is_rhand"] = False
return config
def _create_mano(is_left=False, use_cuda=None, fix_shapedirs=True):
use_cuda = torch.cuda.is_available() if use_cuda is None else use_cuda
if use_cuda and not torch.cuda.is_available():
raise RuntimeError("CUDA was requested for MANO, but CUDA is unavailable")
with open(os.devnull, "w") as sink, redirect_stdout(sink):
mano = MANO(**_mano_config(is_left=is_left))
if is_left and fix_shapedirs:
# Work around the left-hand MANO shapedirs issue documented by smplx.
mano.shapedirs[:, 0, :] *= -1
device = torch.device("cuda" if use_cuda else "cpu")
return mano.to(device), device
def get_mano_faces():
mano, _ = _create_mano(is_left=False, use_cuda=False)
return mano.faces
def _run_mano(
trans,
root_orient,
hand_pose,
betas,
*,
is_left=False,
use_cuda=None,
fix_shapedirs=True,
):
if betas is None:
raise ValueError("betas must not be None")
mano, device = _create_mano(
is_left=is_left,
use_cuda=use_cuda,
fix_shapedirs=fix_shapedirs,
)
batch_size, frame_count, _ = root_orient.shape
num_joints = 15
global_orient = aa_to_rotmat(
root_orient.reshape(batch_size * frame_count, -1)
).view(batch_size * frame_count, 1, 3, 3)
hand_pose_rotmat = aa_to_rotmat(
hand_pose.reshape(batch_size * frame_count * num_joints, 3)
).view(batch_size * frame_count, num_joints, 3, 3)
mano_output = mano(
global_orient=global_orient.float().to(device),
hand_pose=hand_pose_rotmat.float().to(device),
betas=betas.reshape(batch_size * frame_count, -1).float().to(device),
transl=trans.reshape(batch_size * frame_count, 3).float().to(device),
pose2rot=False,
)
return {
"joints": mano_output.joints.reshape(
batch_size, frame_count, -1, 3
),
"vertices": mano_output.vertices.reshape(
batch_size, frame_count, -1, 3
),
}
def run_mano(
trans,
root_orient,
hand_pose,
is_right=None,
betas=None,
use_cuda=None,
):
del is_right
return _run_mano(
trans,
root_orient,
hand_pose,
betas,
is_left=False,
use_cuda=use_cuda,
)
def run_mano_left(
trans,
root_orient,
hand_pose,
is_right=None,
betas=None,
use_cuda=None,
fix_shapedirs=True,
):
del is_right
return _run_mano(
trans,
root_orient,
hand_pose,
betas,
is_left=True,
use_cuda=use_cuda,
fix_shapedirs=fix_shapedirs,
)
# ---------------------------------------------------------------------------
# Visualization
# ---------------------------------------------------------------------------
def load_image(image_input):
if isinstance(image_input, (str, bytes)):
if isinstance(image_input, str):
if not os.path.exists(image_input):
raise ValueError(f"Error: File not found at {image_input}")
img = cv2.imread(image_input)
else:
nparr = np.frombuffer(image_input, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("Failed to decode image.")
return img
raise TypeError("Input must be a file path (str) or image bytes (bytes).")
def compute_hand_mesh(
hand_transl,
hand_orient,
hand_pose,
hand_betas,
is_left=False,
):
"""Compute MANO hand mesh vertices, joints and faces."""
transl = torch.tensor(hand_transl, dtype=torch.float32).unsqueeze(0)
rot = torch.tensor(hand_orient, dtype=torch.float32).unsqueeze(0)
pose = torch.tensor(hand_pose, dtype=torch.float32).unsqueeze(0)
betas = torch.tensor(hand_betas, dtype=torch.float32).unsqueeze(0)
mano_fn = run_mano_left if is_left else run_mano
mano_out = mano_fn(transl, rot, pose, betas=betas)
vertices = mano_out["vertices"][0].detach().cpu().numpy()
joints = mano_out["joints"][0].detach().cpu().numpy()
faces_base = get_mano_faces()
faces_new = np.array(
[
[92, 38, 234],
[234, 38, 239],
[38, 122, 239],
[239, 122, 279],
[122, 118, 279],
[279, 118, 215],
[118, 117, 215],
[215, 117, 214],
[117, 119, 214],
[214, 119, 121],
[119, 120, 121],
[121, 120, 78],
[120, 108, 78],
[78, 108, 79],
]
)
faces = np.concatenate([faces_base, faces_new], axis=0)
if is_left:
faces = faces[:, [0, 2, 1]]
return vertices, joints, faces
def project_points_to_2d(points_3d, fov_x, width, height):
"""Project batch of 3D points to 2D pixel coords."""
fx = width / (2.0 * np.tan(fov_x / 2.0))
cx, cy = width / 2.0, height / 2.0
z_safe = np.where(
np.abs(points_3d[..., 2]) < 1e-6,
1e-6,
points_3d[..., 2],
)
u = fx * points_3d[..., 0] / z_safe + cx
v = fx * points_3d[..., 1] / z_safe + cy
return np.stack([u, v], axis=-1)
def draw_mesh_filled(frame, verts_2d, faces, color, alpha=0.3):
"""Draw filled semi-transparent mesh on frame."""
overlay = frame.copy()
height, width = frame.shape[:2]
for face in faces:
points = verts_2d[face].astype(np.int32)
if np.any(points[:, 0] < -width) or np.any(points[:, 0] > 2 * width):
continue
if np.any(points[:, 1] < -height) or np.any(points[:, 1] > 2 * height):
continue
cv2.fillPoly(
overlay,
[points],
color=color,
lineType=cv2.LINE_AA,
)
cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
def draw_mesh_wireframe(
frame,
verts_2d,
faces,
color,
alpha=0.4,
thickness=1,
):
"""Draw mesh wireframe on frame."""
overlay = frame.copy()
height, width = frame.shape[:2]
for face in faces:
points = verts_2d[face].astype(np.int32)
if np.any(points[:, 0] < -width) or np.any(points[:, 0] > 2 * width):
continue
if np.any(points[:, 1] < -height) or np.any(points[:, 1] > 2 * height):
continue
cv2.polylines(
overlay,
[points],
isClosed=True,
color=color,
thickness=thickness,
lineType=cv2.LINE_AA,
)
cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
MANO_SKELETON_BONES = [
(0, 1),
(1, 2),
(2, 3),
(3, 4),
(0, 5),
(5, 6),
(6, 7),
(7, 8),
(0, 9),
(9, 10),
(10, 11),
(11, 12),
(0, 13),
(13, 14),
(14, 15),
(15, 16),
(0, 17),
(17, 18),
(18, 19),
(19, 20),
]
FINGER_COLORS = {
"thumb": (0, 255, 255),
"index": (0, 0, 255),
"middle": (0, 255, 0),
"ring": (255, 165, 0),
"pinky": (255, 0, 255),
}
BONE_FINGER_MAP = {
0: "index",
1: "index",
2: "index",
3: "index",
4: "middle",
5: "middle",
6: "middle",
7: "middle",
8: "ring",
9: "ring",
10: "ring",
11: "ring",
12: "pinky",
13: "pinky",
14: "pinky",
15: "pinky",
16: "thumb",
17: "thumb",
18: "thumb",
19: "thumb",
}
def draw_joints(
frame,
joints_2d,
bone_color_override=None,
joint_radius=4,
bone_thickness=2,
alpha=0.8,
):
"""Draw MANO hand joints and skeleton bones on frame."""
overlay = frame.copy()
height, width = frame.shape[:2]
for bone_idx, (joint1, joint2) in enumerate(MANO_SKELETON_BONES):
point1 = joints_2d[joint1].astype(np.int32)
point2 = joints_2d[joint2].astype(np.int32)
if (
point1[0] < -width
or point1[0] > 2 * width
or point1[1] < -height
or point1[1] > 2 * height
or point2[0] < -width
or point2[0] > 2 * width
or point2[1] < -height
or point2[1] > 2 * height
):
continue
color = (
bone_color_override
if bone_color_override is not None
else FINGER_COLORS[BONE_FINGER_MAP[bone_idx]]
)
cv2.line(
overlay,
tuple(point1),
tuple(point2),
color,
bone_thickness,
cv2.LINE_AA,
)
for joint_idx in range(joints_2d.shape[0]):
point = joints_2d[joint_idx].astype(np.int32)
if (
point[0] < -width
or point[0] > 2 * width
or point[1] < -height
or point[1] > 2 * height
):
continue
radius = joint_radius + 2 if joint_idx == 0 else joint_radius
cv2.circle(
overlay,
tuple(point),
radius,
(255, 255, 255),
-1,
cv2.LINE_AA,
)
cv2.circle(
overlay,
tuple(point),
radius,
(0, 0, 0),
1,
cv2.LINE_AA,
)
cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
def world_to_camera(pos_world, cam_c2w):
"""Convert world position to camera space using cam_c2w."""
cam_c2w = np.asarray(cam_c2w, dtype=np.float64)
rotation = cam_c2w[:3, :3]
translation = cam_c2w[:3, 3]
return rotation.T @ (pos_world - translation)
def project_to_2d(pos_cam, fov_x, width, height):
"""Project camera-space position to 2D pixel coords."""
fx = width / (2.0 * np.tan(fov_x / 2.0))
cx, cy = width / 2.0, height / 2.0
z_value = pos_cam[2] if abs(pos_cam[2]) > 1e-6 else 1e-6
u_value = fx * pos_cam[0] / z_value + cx
v_value = fx * pos_cam[1] / z_value + cy
return np.array([u_value, v_value])
def draw_trajectory(frame, points_2d, color, thickness=2, dot_radius=4):
"""Draw trajectory line with dots."""
for point_idx in range(1, len(points_2d)):
point1 = tuple(points_2d[point_idx - 1].astype(int))
point2 = tuple(points_2d[point_idx].astype(int))
cv2.line(frame, point1, point2, color, thickness, cv2.LINE_AA)
for point in points_2d:
cv2.circle(
frame,
tuple(point.astype(int)),
dot_radius,
color,
-1,
cv2.LINE_AA,
)
def draw_current_marker(frame, point, color, label=""):
"""Draw a highlighted marker for current position."""
point_int = tuple(point.astype(int))
cv2.circle(frame, point_int, 10, color, -1, cv2.LINE_AA)
cv2.circle(frame, point_int, 12, (255, 255, 255), 2, cv2.LINE_AA)
if label:
cv2.putText(
frame,
label,
(point_int[0] + 15, point_int[1] - 5),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
color,
2,
cv2.LINE_AA,
)
def draw_action_info(frame, hand_infos, frame_idx):
"""Draw per-hand state and action values on frame."""
font = cv2.FONT_HERSHEY_SIMPLEX
height, _ = frame.shape[:2]
x_value = 10
y_value = height - 22 * (len(hand_infos) * 3 + 1) - 10
lines = [(f"Frame {frame_idx}", (255, 255, 255))]
for info in hand_infos:
hand_label = "R" if info["hand"] == "right" else "L"
state = info["state"]
action = info["action"]
grip_text = "OPEN" if state[7] > 0 else "CLOSED"
grip_color = (0, 255, 0) if state[7] > 0 else (0, 0, 255)
lines.append(
(
f"[{hand_label}] Pos: [{state[0]:+.3f},{state[1]:+.3f},{state[2]:+.3f}]"
f" Rot: [{state[3]:+.2f},{state[4]:+.2f},{state[5]:+.2f}]",
(255, 255, 255),
)
)
lines.append(
(
f"[{hand_label}] dPos: [{action[0]:+.4f},{action[1]:+.4f},{action[2]:+.4f}]"
f" dRot: [{action[3]:+.3f},{action[4]:+.3f},{action[5]:+.3f}]",
(200, 200, 200),
)
)
lines.append(
(
f"[{hand_label}] Grip: {grip_text} ({state[7]:+.2f})",
grip_color,
)
)
for text, color in lines:
(text_width, text_height), _ = cv2.getTextSize(
text,
font,
0.45,
1,
)
cv2.rectangle(
frame,
(x_value - 2, y_value - text_height - 2),
(x_value + text_width + 4, y_value + 4),
(0, 0, 0),
-1,
)
cv2.putText(
frame,
text,
(x_value, y_value),
font,
0.45,
color,
1,
cv2.LINE_AA,
)
y_value += 22
def load_samples(data_path):
if data_path.endswith((".json", ".jsonl")):
with open(data_path, "r", encoding="utf-8") as data_file:
return [json.loads(line) for line in data_file if line.strip()]
if data_path.endswith(".pkl"):
with open(data_path, "rb") as data_file:
return pickle.load(data_file)
if data_path.endswith(".parquet"):
return pq.read_table(data_path).to_pylist()
raise ValueError("data_path must end with .json, .jsonl, .pkl, or .parquet")
def main():
parser = argparse.ArgumentParser(
description=(
"Verify action annotations with 3D hand mesh and trajectory "
"for both hands"
),
)
parser.add_argument(
"--data_path",
type=str,
required=True,
help="Path to data containing hand_action_tags",
)
parser.add_argument(
"--save_dir",
type=str,
default="./vis_action_verify",
)
parser.add_argument(
"--model_root",
default=None,
help=(
"Model root containing mano/MANO_RIGHT.pkl and MANO_LEFT.pkl; "
"defaults to MODEL_ROOT or common mount paths"
),
)
parser.add_argument(
"--mano_right_path",
default=None,
help="Explicit path to MANO_RIGHT.pkl",
)
parser.add_argument(
"--mano_left_path",
default=None,
help="Explicit path to MANO_LEFT.pkl",
)
parser.add_argument("--sample_idx", type=int, default=0)
parser.add_argument("--video_idx", type=int, default=0)
parser.add_argument("--fps", type=float, default=30)
args = parser.parse_args()
configure_mano_paths(
model_root=args.model_root,
mano_right_path=args.mano_right_path,
mano_left_path=args.mano_left_path,
)
os.makedirs(args.save_dir, exist_ok=True)
print(f"Loading: {args.data_path}")
samples = load_samples(args.data_path)
target_sample = samples[args.sample_idx]
meta = target_sample[Fields.meta]
if isinstance(meta, bytes):
meta = pickle.loads(meta)
frames = target_sample[MetaKeys.video_frames][args.video_idx]
assert MetaKeys.hand_action_tags in meta, "Need hand_action_tags"
assert MetaKeys.hand_reconstruction_hawor_tags in meta, "Need hawor tags"
assert MetaKeys.video_camera_pose_tags in meta, "Need camera pose tags"
action_tags = meta[MetaKeys.hand_action_tags][args.video_idx]
hawor = meta[MetaKeys.hand_reconstruction_hawor_tags][args.video_idx]
cam_pose = meta[MetaKeys.video_camera_pose_tags][args.video_idx]
from data_juicer.utils.file_utils import load_numpy
cam_c2w_all = load_numpy(cam_pose["cam_c2w"])
fov_x = hawor["fov_x"]
if "states" in action_tags:
hand_type = action_tags.get("hand_type", "right")
action_tags = {hand_type: action_tags}
image = load_image(frames[0])
image_height, image_width = image.shape[:2]
hand_colors = {
"right": {
"gt": (0, 255, 0),
"recon": (0, 165, 255),
"mesh": (180, 120, 200),
},
"left": {
"gt": (255, 255, 0),
"recon": (255, 0, 255),
"mesh": (200, 150, 50),
},
}
hand_results = {}
for hand_type in ["right", "left"]:
hand_action = action_tags.get(hand_type, {})
states_raw = hand_action.get("states", [])
actions_raw = hand_action.get("actions", [])
valid_ids = hand_action.get("valid_frame_ids", [])
if len(states_raw) < 2:
print(
f"\n {hand_type} hand: no action data from pipeline, "
"skipping"
)
continue
states = np.array(states_raw, dtype=np.float64)
actions = np.array(actions_raw, dtype=np.float64)
print(f"\n === {hand_type.upper()} hand ===")
is_left = hand_type == "left"
if hand_type in hawor and isinstance(hawor[hand_type], dict):
hand = hawor[hand_type]
frame_ids = hand.get("frame_ids", [])
hand_transl = hand.get("transl", [])
hand_orient = hand.get("global_orient", [])
hand_pose = hand.get("hand_pose", [])
hand_betas = hand.get("betas", [])
else:
prefix = f"{hand_type}_"
frame_ids = hawor.get(f"{prefix}frame_id_list", [])
hand_transl = hawor.get(f"{prefix}transl_list", [])
hand_orient = hawor.get(f"{prefix}global_orient_list", [])
hand_pose = hawor.get(f"{prefix}hand_pose_list", [])
hand_betas = hawor.get(f"{prefix}beta_list", [])
joints_cam_raw = hand_action.get("joints_cam")
if joints_cam_raw is not None and len(joints_cam_raw) > 0:
joints_cam = np.array(joints_cam_raw, dtype=np.float64)
joints_cam_id_map = {
frame_id: idx for idx, frame_id in enumerate(valid_ids)
}
print(f" joints_cam (from action_tags): {joints_cam.shape}")
else:
joints_cam = None
joints_cam_id_map = {}
if len(frame_ids) >= 2:
mesh_verts, mesh_joints, mesh_faces = compute_hand_mesh(
hand_transl,
hand_orient,
hand_pose,
hand_betas,
is_left=is_left,
)
print(f" Mesh: vertices {mesh_verts.shape}")
else:
mesh_verts, mesh_joints, mesh_faces, frame_ids = (
None,
None,
None,
[],
)
frame_count = len(states)
print(
f" States: {states.shape}, Actions: {actions.shape}, "
f"Valid: {len(valid_ids)}"
)
mesh_id_map = {
frame_id: idx for idx, frame_id in enumerate(frame_ids)
}
wrist_2d = []
for frame_idx in range(frame_count):
frame_id = valid_ids[frame_idx]
pos_cam = world_to_camera(
states[frame_idx, :3],
cam_c2w_all[frame_id],
)
if joints_cam is not None and frame_id in joints_cam_id_map:
joint_idx = joints_cam_id_map[frame_id]
wrist_cam = joints_cam[joint_idx, 0, :]
if frame_id in mesh_id_map:
mesh_idx = mesh_id_map[frame_id]
pos_cam = pos_cam + (
wrist_cam - np.asarray(hand_transl[mesh_idx])
)
elif mesh_joints is not None and frame_id in mesh_id_map:
mesh_idx = mesh_id_map[frame_id]
wrist_cam = mesh_joints[mesh_idx, 0, :]
pos_cam = pos_cam + (
wrist_cam - np.asarray(hand_transl[mesh_idx])
)
wrist_2d.append(
project_to_2d(
pos_cam,
fov_x,
image_width,
image_height,
)
)
hand_results[hand_type] = {
"states": states,
"actions": actions,
"valid_ids": valid_ids,
"wrist_2d": np.array(wrist_2d),
"mesh_verts": mesh_verts,
"mesh_joints": mesh_joints,
"mesh_faces": mesh_faces,
"mesh_id_map": mesh_id_map,
"frame_ids": frame_ids,
"joints_cam": joints_cam,
"joints_cam_id_map": joints_cam_id_map,
}
if not hand_results:
print("No valid hand data found!")
return
all_valid_frame_ids = sorted(
set().union(
*(
set(result["valid_ids"])
for result in hand_results.values()
)
)
)
print(f"\nRendering {len(all_valid_frame_ids)} frames...")
frames_dir = os.path.join(args.save_dir, "frames")
os.makedirs(frames_dir, exist_ok=True)
output_frames = []
for frame_seq, frame_id in enumerate(all_valid_frame_ids):
frame = load_image(frames[frame_id])
canvas = frame.copy()
hand_infos = []
for hand_type, result in hand_results.items():
colors = hand_colors[hand_type]
if (
result["mesh_verts"] is not None
and frame_id in result["mesh_id_map"]
):
mesh_idx = result["mesh_id_map"][frame_id]
verts_2d = project_points_to_2d(
result["mesh_verts"][mesh_idx],
fov_x,
image_width,
image_height,
)
draw_mesh_filled(
canvas,
verts_2d,
result["mesh_faces"],
colors["mesh"],
alpha=0.25,
)
draw_mesh_wireframe(
canvas,
verts_2d,
result["mesh_faces"],
colors["mesh"],
alpha=0.5,
thickness=1,
)
if (
result["joints_cam"] is not None
and frame_id in result["joints_cam_id_map"]
):
joint_idx = result["joints_cam_id_map"][frame_id]
joints_2d = project_points_to_2d(
result["joints_cam"][joint_idx],
fov_x,
image_width,
image_height,
)
draw_joints(
canvas,
joints_2d,
joint_radius=4,
bone_thickness=2,
alpha=0.85,
)
elif (
result["mesh_joints"] is not None
and frame_id in result["mesh_id_map"]
):
mesh_idx = result["mesh_id_map"][frame_id]
joints_2d = project_points_to_2d(
result["mesh_joints"][mesh_idx],
fov_x,
image_width,
image_height,
)
draw_joints(
canvas,
joints_2d,
joint_radius=4,
bone_thickness=2,
alpha=0.85,
)
if frame_id not in result["valid_ids"]:
continue
action_idx = result["valid_ids"].index(frame_id)
trail = result["wrist_2d"][: action_idx + 1]
if len(trail) >= 2:
draw_trajectory(
canvas,
trail,
colors["gt"],
thickness=3,
dot_radius=4,
)
label_prefix = "R" if hand_type == "right" else "L"
draw_current_marker(
canvas,
result["wrist_2d"][action_idx],
colors["gt"],
label_prefix,
)
hand_infos.append(
{
"hand": hand_type,
"state": result["states"][action_idx],
"action": result["actions"][action_idx],
}
)
if hand_infos:
draw_action_info(canvas, hand_infos, frame_id)
output_path = os.path.join(
frames_dir,
f"verify_{frame_seq:04d}.jpg",
)
cv2.imwrite(output_path, canvas)
output_frames.append(canvas)
if output_frames:
import av
video_path = os.path.join(args.save_dir, "action_verify.mp4")
output_height, output_width = output_frames[0].shape[:2]
fps_fraction = fractions.Fraction(args.fps).limit_denominator(10000)
container = av.open(video_path, mode="w")
stream = container.add_stream("libx264", rate=fps_fraction)
stream.width = output_width
stream.height = output_height
stream.pix_fmt = "yuv420p"
stream.options = {"crf": "18", "preset": "medium"}
for frame_bgr in output_frames:
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
av_frame = av.VideoFrame.from_ndarray(frame_rgb, format="rgb24")
for packet in stream.encode(av_frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
container.close()
size_mb = os.path.getsize(video_path) / (1024 * 1024)
print(f"\nVideo: {video_path} ({size_mb:.1f} MB)")
print("Done!")
if __name__ == "__main__":
main()
业务代码调试 算子优化,重新走 3.5 流程
附录:供 AI 使用的阿里云 CLI 配置
使用 阿里云 CLI 执行 ACK、OSS、ACR 等命令前,请先完成以下配置。相关命令还会使用 jq、kubectl 和 Docker。
# macOS;其他系统请参考 aliyun-cli 项目的安装说明
brew install aliyun-cli
aliyun version
# 交互式环境推荐使用 OAuth,避免在磁盘或 Shell 历史中保存长期 AK/SK
aliyun configure --mode OAuth --profile vla-demo
aliyun configure switch --profile vla-demo
# 查看当前配置
aliyun configure list
如需在 CI 或无人值守环境中执行,请优先使用 STS、RAM Role、OIDC 等临时身份方式。不要把 AccessKey、密码或 Token 写入本文、脚本或 Git 仓库。调用 API 前还需确保目标云产品已开通,并为当前身份授予所需的最小权限。