This commit is contained in:
li-shihao-code
2026-05-15 21:55:42 +08:00
parent fd04f869b1
commit 93fa1b64b3
39 changed files with 3183 additions and 74 deletions
View File
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
+161 -6
View File
@@ -15,16 +15,21 @@ CONDA_ENV_NAME="${CONDA_ENV_NAME:-AutoCalib_Workshop}"
ROS_LOG_DIR="${ROS_LOG_DIR:-/tmp/roslog}" ROS_LOG_DIR="${ROS_LOG_DIR:-/tmp/roslog}"
RUN_LOG_ROOT="${RUN_LOG_ROOT:-${WORKSPACE_DIR}/log/isaac_real_sim_$(date +%Y%m%d_%H%M%S)}" RUN_LOG_ROOT="${RUN_LOG_ROOT:-${WORKSPACE_DIR}/log/isaac_real_sim_$(date +%Y%m%d_%H%M%S)}"
HEADLESS=0 HEADLESS=1
DO_BUILD=0 DO_BUILD=0
RUN_SMOKE=1 RUN_SMOKE=1
KEEP_RUNNING=0 KEEP_RUNNING=0
WITH_SENSOR_INGEST=1 WITH_SENSOR_INGEST=1
WAIT_FOR_ISAAC_TOPICS=1 WAIT_FOR_ISAAC_TOPICS=1
WORKSHOP_USE_GATEWAY=1
ISAAC_WAIT_SEC="${ISAAC_WAIT_SEC:-180}" ISAAC_WAIT_SEC="${ISAAC_WAIT_SEC:-180}"
WORKSHOP_WARMUP_SEC="${WORKSHOP_WARMUP_SEC:-5}" WORKSHOP_WARMUP_SEC="${WORKSHOP_WARMUP_SEC:-5}"
START_GRACE_SEC="${START_GRACE_SEC:-2}" START_GRACE_SEC="${START_GRACE_SEC:-2}"
STOP_GRACE_SEC="${STOP_GRACE_SEC:-8}"
TERM_GRACE_SEC="${TERM_GRACE_SEC:-3}"
SMOKE_TASKS="" SMOKE_TASKS=""
DATA_INPUT_PARAMS_FILE=""
DATASET_INDEX_FILE=""
PIDS=() PIDS=()
NAMES=() NAMES=()
@@ -54,6 +59,12 @@ usage() {
--keep-running 验收结束后保持仿真链路运行,按 Ctrl+C 停止。 --keep-running 验收结束后保持仿真链路运行,按 Ctrl+C 停止。
--no-sensor-ingest 不启动车间侧传感器 ingest 轮询进程。 --no-sensor-ingest 不启动车间侧传感器 ingest 轮询进程。
--skip-topic-check 不等待 Isaac 真值/传感器 topic,直接继续启动后续链路。 --skip-topic-check 不等待 Isaac 真值/传感器 topic,直接继续启动后续链路。
--local-calibration-services
底盘和运控阶段使用本地标定服务节点,便于验证四个算法服务的数据输入注入。
--data-input-params-file PATH
把真实采集/ingest 生成的 site_data_input.yaml 注入算法服务。
--dataset-index-file PATH
把真实采集/ingest 生成的 dataset_index.yaml 写入最终报告。
--isaac-wait-sec SEC 等待 Isaac topic 的最长时间,默认 180 秒。 --isaac-wait-sec SEC 等待 Isaac topic 的最长时间,默认 180 秒。
--warmup-sec SEC 总控 demo 启动后等待采集真值历史的时间,默认 5 秒。 --warmup-sec SEC 总控 demo 启动后等待采集真值历史的时间,默认 5 秒。
--tasks LIST 覆盖 smoke_test_workshop_orchestrator.py 的任务列表。 --tasks LIST 覆盖 smoke_test_workshop_orchestrator.py 的任务列表。
@@ -65,10 +76,13 @@ usage() {
CONDA_ENV_NAME 同 --conda-env。 CONDA_ENV_NAME 同 --conda-env。
ROS_LOG_DIR ROS 日志目录,默认 /tmp/roslog。 ROS_LOG_DIR ROS 日志目录,默认 /tmp/roslog。
RUN_LOG_ROOT 本脚本各后台进程日志目录。 RUN_LOG_ROOT 本脚本各后台进程日志目录。
STOP_GRACE_SEC SIGINT 后等待后台进程优雅退出的秒数,默认 8。
TERM_GRACE_SEC SIGTERM 后等待后台进程退出的秒数,默认 3。
示例: 示例:
./run_isaac_real_sim_test.sh --headless ./run_isaac_real_sim_test.sh --headless
./run_isaac_real_sim_test.sh --headless --keep-running ./run_isaac_real_sim_test.sh --headless --keep-running
./run_isaac_real_sim_test.sh --headless --local-calibration-services --data-input-params-file /data/agv_calib/session_001/site_data_input.yaml
./run_isaac_real_sim_test.sh --no-smoke ./run_isaac_real_sim_test.sh --no-smoke
EOF EOF
} }
@@ -86,6 +100,18 @@ die() {
exit 1 exit 1
} }
resolve_workspace_path() {
local path="$1"
case "${path}" in
/*)
echo "${path}"
;;
*)
echo "${WORKSPACE_DIR}/${path}"
;;
esac
}
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--headless) --headless)
@@ -113,6 +139,20 @@ while [[ $# -gt 0 ]]; do
WAIT_FOR_ISAAC_TOPICS=0 WAIT_FOR_ISAAC_TOPICS=0
shift shift
;; ;;
--local-calibration-services)
WORKSHOP_USE_GATEWAY=0
shift
;;
--data-input-params-file)
[[ $# -ge 2 ]] || die "--data-input-params-file 需要参数"
DATA_INPUT_PARAMS_FILE="$2"
shift 2
;;
--dataset-index-file)
[[ $# -ge 2 ]] || die "--dataset-index-file 需要参数"
DATASET_INDEX_FILE="$2"
shift 2
;;
--isaac-wait-sec) --isaac-wait-sec)
[[ $# -ge 2 ]] || die "--isaac-wait-sec 需要参数" [[ $# -ge 2 ]] || die "--isaac-wait-sec 需要参数"
ISAAC_WAIT_SEC="$2" ISAAC_WAIT_SEC="$2"
@@ -143,6 +183,26 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
if [[ -n "${DATA_INPUT_PARAMS_FILE}" ]]; then
DATA_INPUT_PARAMS_FILE="$(resolve_workspace_path "${DATA_INPUT_PARAMS_FILE}")"
[[ -f "${DATA_INPUT_PARAMS_FILE}" ]] || die "找不到数据输入参数文件: ${DATA_INPUT_PARAMS_FILE}"
[[ "${DATA_INPUT_PARAMS_FILE}" != *"'"* ]] || die "data_input_params_file 路径不能包含单引号"
if [[ -z "${DATASET_INDEX_FILE}" ]]; then
DATASET_INDEX_FILE="$(dirname "${DATA_INPUT_PARAMS_FILE}")/dataset_index.yaml"
fi
fi
if [[ -n "${DATASET_INDEX_FILE}" ]]; then
DATASET_INDEX_FILE="$(resolve_workspace_path "${DATASET_INDEX_FILE}")"
[[ "${DATASET_INDEX_FILE}" != *"'"* ]] || die "dataset_index_file 路径不能包含单引号"
fi
if [[ "${WORKSHOP_USE_GATEWAY}" -eq 1 ]]; then
WORKSHOP_USE_GATEWAY_VALUE="true"
else
WORKSHOP_USE_GATEWAY_VALUE="false"
fi
resolve_conda_sh() { resolve_conda_sh() {
if [[ -n "${CONDA_SH:-}" && -f "${CONDA_SH}" ]]; then if [[ -n "${CONDA_SH:-}" && -f "${CONDA_SH}" ]]; then
echo "${CONDA_SH}" echo "${CONDA_SH}"
@@ -182,6 +242,28 @@ source_ros_env() {
set -u set -u
} }
wait_for_background_exit() {
local timeout_sec="$1"
local deadline=$((SECONDS + timeout_sec))
local pid
while (( SECONDS < deadline )); do
local any_alive=0
for pid in "${PIDS[@]}"; do
if kill -0 "${pid}" >/dev/null 2>&1; then
any_alive=1
break
fi
done
if [[ "${any_alive}" -eq 0 ]]; then
return 0
fi
sleep 1
done
return 1
}
cleanup() { cleanup() {
if [[ "${CLEANED}" -eq 1 ]]; then if [[ "${CLEANED}" -eq 1 ]]; then
return return
@@ -203,17 +285,17 @@ cleanup() {
fi fi
done done
sleep 3 wait_for_background_exit "${STOP_GRACE_SEC}" || true
for ((i=${#PIDS[@]}-1; i>=0; i--)); do for ((i=${#PIDS[@]}-1; i>=0; i--)); do
pid="${PIDS[$i]}" pid="${PIDS[$i]}"
name="${NAMES[$i]}" name="${NAMES[$i]}"
if kill -0 "${pid}" >/dev/null 2>&1; then if kill -0 "${pid}" >/dev/null 2>&1; then
warn "${name} 未退出,发送 SIGTERM: pid=${pid}" log "${name}${STOP_GRACE_SEC}s 内退出,发送 SIGTERM: pid=${pid}"
kill -TERM "${pid}" >/dev/null 2>&1 || true kill -TERM "${pid}" >/dev/null 2>&1 || true
fi fi
done done
sleep 2 wait_for_background_exit "${TERM_GRACE_SEC}" || true
for ((i=${#PIDS[@]}-1; i>=0; i--)); do for ((i=${#PIDS[@]}-1; i>=0; i--)); do
pid="${PIDS[$i]}" pid="${PIDS[$i]}"
name="${NAMES[$i]}" name="${NAMES[$i]}"
@@ -321,6 +403,62 @@ wait_for_ros_service() {
return 1 return 1
} }
wait_for_ros_param() {
local node="$1"
local param="$2"
local timeout_sec="$3"
local deadline=$((SECONDS + timeout_sec))
log "等待 ROS 参数: ${node} ${param}"
while (( SECONDS < deadline )); do
if ros2 param get "${node}" "${param}" >/dev/null 2>&1; then
log "参数已就绪: ${node} ${param}"
return 0
fi
sleep 1
done
warn "等待参数超时: ${node} ${param}"
return 1
}
require_ros_param_non_empty() {
local node="$1"
local param="$2"
local output
output="$(ros2 param get "${node}" "${param}" 2>/dev/null || true)"
if [[ -z "${output}" || "${output}" == *"[]"* || "${output}" == *"not set"* ]]; then
warn "数据输入参数为空: ${node} ${param}; 输出: ${output}"
return 1
fi
log "数据输入参数已注入: ${node} ${param}"
return 0
}
verify_data_input_params() {
if [[ -z "${DATA_INPUT_PARAMS_FILE}" ]]; then
return 0
fi
log "验证 data_input 参数注入: ${DATA_INPUT_PARAMS_FILE}"
wait_for_ros_param "/external_localization_service" "data_input.data_window_start_timestamp_us" 30 || return 1
wait_for_ros_param "/sensor_calibration_service" "data_input.data_window_start_timestamp_us" 30 || return 1
require_ros_param_non_empty "/external_localization_service" "data_input.external_observation_files" || return 1
require_ros_param_non_empty "/sensor_calibration_service" "data_input.synchronized_dataset_files" || return 1
wait_for_ros_param "/workshop_orchestrator_v2" "data_input_params_file" 30 || return 1
require_ros_param_non_empty "/workshop_orchestrator_v2" "data_input_params_file" || return 1
if [[ "${WORKSHOP_USE_GATEWAY}" -eq 0 ]]; then
wait_for_ros_param "/chassis_calibration_service" "data_input.data_window_start_timestamp_us" 30 || return 1
wait_for_ros_param "/control_calibration_service" "data_input.data_window_start_timestamp_us" 30 || return 1
require_ros_param_non_empty "/chassis_calibration_service" "data_input.chassis_motion_data_files" || return 1
require_ros_param_non_empty "/control_calibration_service" "data_input.control_evaluation_data_files" || return 1
else
warn "当前使用 vehicle_agent_gateway,底盘/运控 data_input 由车端 agent 侧负责,本脚本只验证 external/sensor 注入。"
fi
}
check_no_existing_workshop_orchestrator() { check_no_existing_workshop_orchestrator() {
local nodes local nodes
nodes="$(timeout 5s ros2 node list 2>/dev/null || true)" nodes="$(timeout 5s ros2 node list 2>/dev/null || true)"
@@ -361,6 +499,10 @@ log "工作空间: ${WORKSPACE_DIR}"
log "ROS_LOG_DIR: ${ROS_LOG_DIR}" log "ROS_LOG_DIR: ${ROS_LOG_DIR}"
log "进程日志目录: ${RUN_LOG_ROOT}" log "进程日志目录: ${RUN_LOG_ROOT}"
log "Isaac conda 环境: ${CONDA_ENV_NAME}" log "Isaac conda 环境: ${CONDA_ENV_NAME}"
log "总控底盘/运控模式 use_gateway=${WORKSHOP_USE_GATEWAY_VALUE}"
if [[ -n "${DATA_INPUT_PARAMS_FILE}" ]]; then
log "数据输入参数文件: ${DATA_INPUT_PARAMS_FILE}"
fi
export PYTHONUNBUFFERED=1 export PYTHONUNBUFFERED=1
check_no_existing_workshop_orchestrator check_no_existing_workshop_orchestrator
@@ -382,6 +524,7 @@ start_bg "isaac" bash -lc "
source '${WORKSPACE_DIR}/install/setup.bash' source '${WORKSPACE_DIR}/install/setup.bash'
set -u set -u
export ROS_LOG_DIR='${ROS_LOG_DIR}' export ROS_LOG_DIR='${ROS_LOG_DIR}'
export PYTHONNOUSERSITE=1
exec python3 src/simulation/tools/launch_sim_stack.py --component isaac ${ISAAC_HEADLESS_ARG} exec python3 src/simulation/tools/launch_sim_stack.py --component isaac ${ISAAC_HEADLESS_ARG}
" "
@@ -447,13 +590,16 @@ start_bg "workshop-demo" bash -lc "
set -u set -u
export ROS_LOG_DIR='${ROS_LOG_DIR}' export ROS_LOG_DIR='${ROS_LOG_DIR}'
exec ros2 launch win_ubuntu_bridge minimal_workshop_demo.launch.py \ exec ros2 launch win_ubuntu_bridge minimal_workshop_demo.launch.py \
use_gateway:=true \ use_gateway:=${WORKSHOP_USE_GATEWAY_VALUE} \
chassis_host:=127.0.0.1 \ chassis_host:=127.0.0.1 \
control_host:=127.0.0.1 \ control_host:=127.0.0.1 \
sensor_registry:=demo_front_camera,demo_down_camera,demo_lidar_3d,demo_lidar_2d,demo_imu sensor_registry:=demo_front_camera,demo_down_camera,demo_lidar_3d,demo_lidar_2d,demo_imu \
data_input_params_file:='${DATA_INPUT_PARAMS_FILE}' \
dataset_index_file:='${DATASET_INDEX_FILE}'
" "
wait_for_ros_service "/workshop_v2/create_session" 60 || exit 1 wait_for_ros_service "/workshop_v2/create_session" 60 || exit 1
verify_data_input_params || exit 1
log "等待 ${WORKSHOP_WARMUP_SEC}s,让 external_localization_service 累积 Isaac 真值历史" log "等待 ${WORKSHOP_WARMUP_SEC}s,让 external_localization_service 累积 Isaac 真值历史"
sleep "${WORKSHOP_WARMUP_SEC}" sleep "${WORKSHOP_WARMUP_SEC}"
@@ -464,6 +610,9 @@ if [[ "${RUN_SMOKE}" -eq 1 ]]; then
--verbose-feedback --verbose-feedback
--no-publish-fake-external-telemetry --no-publish-fake-external-telemetry
) )
if [[ "${WORKSHOP_USE_GATEWAY}" -eq 0 ]]; then
SMOKE_ARGS+=(--disable-wifi6-precheck)
fi
if [[ -n "${SMOKE_TASKS}" ]]; then if [[ -n "${SMOKE_TASKS}" ]]; then
SMOKE_ARGS+=(--tasks "${SMOKE_TASKS}") SMOKE_ARGS+=(--tasks "${SMOKE_TASKS}")
fi fi
@@ -481,6 +630,12 @@ if [[ "${RUN_SMOKE}" -eq 1 ]]; then
set -e set -e
if [[ "${SMOKE_STATUS}" -eq 0 ]]; then if [[ "${SMOKE_STATUS}" -eq 0 ]]; then
if [[ -n "${DATA_INPUT_PARAMS_FILE}" ]]; then
grep -q "site_data_input.yaml" "${SMOKE_LOG}" || die "报告中没有记录 site_data_input.yaml"
if [[ -f "${DATASET_INDEX_FILE}" ]]; then
grep -q "dataset_index.yaml" "${SMOKE_LOG}" || die "报告中没有记录 dataset_index.yaml"
fi
fi
log "仿真闭环验收通过" log "仿真闭环验收通过"
else else
warn "仿真闭环验收失败,退出码: ${SMOKE_STATUS}" warn "仿真闭环验收失败,退出码: ${SMOKE_STATUS}"
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env bash
# 本地数据输入回归测试。
#
# 验证链路:
# dataset_index.yaml -> site_data_input.yaml -> external -> chassis -> control -> sensor_intrinsic -> report
#
# 这个脚本不启动 Isaac,不依赖 GPU;用于快速检查算法模板和数据输入注入合同。
set -Eeuo pipefail
WORKSPACE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROS_LOG_DIR="${ROS_LOG_DIR:-/tmp/roslog}"
RUN_LOG_ROOT="${RUN_LOG_ROOT:-${WORKSPACE_DIR}/log/local_data_input_smoke_$(date +%Y%m%d_%H%M%S)}"
SITE_PROFILE="src/deployment/profiles/site_template.yaml"
SESSION_ID="session_001"
SESSION_DIR="/tmp/agv_calib_local_data_input_smoke/session_001"
DATASET_INDEX_FILE=""
DATA_INPUT_PARAMS_FILE=""
TASKS="external,chassis,control,sensor_intrinsic"
KEEP_RUNNING=0
SKIP_GENERATE=0
STRICT_INDEX=1
PIDS=()
NAMES=()
CLEANED=0
usage() {
cat <<'EOF'
用法:
./run_local_data_input_smoke.sh [选项]
用途:
不启动 Isaac,只启动本地 ROS 标定服务和总控,验证数据输入参数注入与 report 链路。
选项:
--site-profile PATH 现场 profile,默认 src/deployment/profiles/site_template.yaml。
--session-id ID 自动生成样例数据集时使用的 session_id,默认 session_001。
--session-dir PATH 自动生成样例数据集时使用的会话目录。
--data-input-params-file PATH
使用已有 site_data_input.yaml,跳过自动生成。
--dataset-index PATH 使用已有 site_data_input.yaml 时,对应的 dataset_index.yaml。
--tasks LIST 逗号分隔任务列表,默认 external,chassis,control,sensor_intrinsic。
--skip-generate 不生成 dataset_index/site_data_input,只使用 --data-input-params-file。
--no-strict-index 关闭 dataset_index 严格任务输入校验。
--keep-running smoke 结束后保持 launch 进程运行。
-h, --help 显示帮助。
示例:
./run_local_data_input_smoke.sh
./run_local_data_input_smoke.sh --data-input-params-file /data/agv_calib/session_001/site_data_input.yaml
EOF
}
log() {
echo "[INFO] $*"
}
warn() {
echo "[WARN] $*" >&2
}
die() {
echo "[ERROR] $*" >&2
exit 1
}
resolve_workspace_path() {
local path="$1"
case "${path}" in
/*)
echo "${path}"
;;
*)
echo "${WORKSPACE_DIR}/${path}"
;;
esac
}
source_ros_env() {
set +u
if [[ -f /opt/ros/humble/setup.bash ]]; then
source /opt/ros/humble/setup.bash
fi
source "${WORKSPACE_DIR}/install/setup.bash"
set -u
}
cleanup() {
if [[ "${CLEANED}" -eq 1 ]]; then
return
fi
CLEANED=1
local i pid name
for ((i=${#PIDS[@]}-1; i>=0; i--)); do
pid="${PIDS[$i]}"
name="${NAMES[$i]}"
if kill -0 "${pid}" >/dev/null 2>&1; then
log "停止 ${name}: pid=${pid}"
kill -INT "${pid}" >/dev/null 2>&1 || true
fi
done
sleep 2
for ((i=${#PIDS[@]}-1; i>=0; i--)); do
pid="${PIDS[$i]}"
name="${NAMES[$i]}"
if kill -0 "${pid}" >/dev/null 2>&1; then
warn "${name} 未退出,发送 SIGTERM: pid=${pid}"
kill -TERM "${pid}" >/dev/null 2>&1 || true
fi
done
}
trap cleanup EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--site-profile)
[[ $# -ge 2 ]] || die "--site-profile 需要参数"
SITE_PROFILE="$2"
shift 2
;;
--session-id)
[[ $# -ge 2 ]] || die "--session-id 需要参数"
SESSION_ID="$2"
shift 2
;;
--session-dir)
[[ $# -ge 2 ]] || die "--session-dir 需要参数"
SESSION_DIR="$2"
shift 2
;;
--data-input-params-file)
[[ $# -ge 2 ]] || die "--data-input-params-file 需要参数"
DATA_INPUT_PARAMS_FILE="$2"
SKIP_GENERATE=1
shift 2
;;
--dataset-index)
[[ $# -ge 2 ]] || die "--dataset-index 需要参数"
DATASET_INDEX_FILE="$2"
shift 2
;;
--tasks)
[[ $# -ge 2 ]] || die "--tasks 需要参数"
TASKS="$2"
shift 2
;;
--skip-generate)
SKIP_GENERATE=1
shift
;;
--no-strict-index)
STRICT_INDEX=0
shift
;;
--keep-running)
KEEP_RUNNING=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "未知参数: $1"
;;
esac
done
SITE_PROFILE="$(resolve_workspace_path "${SITE_PROFILE}")"
SESSION_DIR="$(resolve_workspace_path "${SESSION_DIR}")"
if [[ -n "${DATA_INPUT_PARAMS_FILE}" ]]; then
DATA_INPUT_PARAMS_FILE="$(resolve_workspace_path "${DATA_INPUT_PARAMS_FILE}")"
fi
if [[ -n "${DATASET_INDEX_FILE}" ]]; then
DATASET_INDEX_FILE="$(resolve_workspace_path "${DATASET_INDEX_FILE}")"
fi
[[ -f "${SITE_PROFILE}" ]] || die "找不到 site profile: ${SITE_PROFILE}"
[[ -f "${WORKSPACE_DIR}/install/setup.bash" ]] || die "找不到 install/setup.bash,请先执行 colcon build"
mkdir -p "${ROS_LOG_DIR}" "${RUN_LOG_ROOT}"
source_ros_env
if [[ "${SKIP_GENERATE}" -eq 0 ]]; then
log "生成本地样例数据集索引和数据输入参数文件"
python3 "${WORKSPACE_DIR}/src/site_deployment/workshop_sensor_ingest_real/site_session_capture.py" \
--site-profile "${SITE_PROFILE}" \
--session-dir "${SESSION_DIR}" \
--session-id "${SESSION_ID}" \
--site-id local_smoke \
--vehicle-id demo_agv_001 \
--start-us 1770000000000000 \
--end-us 1770000120000000 \
--overwrite \
--file external.external_observation_files=external/external_observations.csv \
--file chassis.chassis_motion_data_files=chassis/chassis_motion.csv \
--file control.control_evaluation_data_files=control/control_eval.csv \
--file sensor.image_sample_files=sensor/front_camera/images.yaml \
--file sensor.target_detection_files=sensor/front_camera/charuco_detections.json \
--finalize
DATA_INPUT_PARAMS_FILE="${SESSION_DIR}/site_data_input.yaml"
DATASET_INDEX_FILE="${SESSION_DIR}/dataset_index.yaml"
elif [[ -z "${DATASET_INDEX_FILE}" ]]; then
DATASET_INDEX_FILE="$(dirname "${DATA_INPUT_PARAMS_FILE}")/dataset_index.yaml"
fi
[[ -f "${DATA_INPUT_PARAMS_FILE}" ]] || die "找不到数据输入参数文件: ${DATA_INPUT_PARAMS_FILE}"
if [[ "${STRICT_INDEX}" -eq 1 ]]; then
if [[ -f "${DATASET_INDEX_FILE}" ]]; then
log "校验数据集索引: ${DATASET_INDEX_FILE}"
python3 "${WORKSPACE_DIR}/src/deployment/tools/validate_dataset_index.py" \
"${DATASET_INDEX_FILE}" \
--tasks "${TASKS}"
else
warn "未找到 dataset_index.yaml,跳过索引校验: ${DATASET_INDEX_FILE}"
fi
fi
LAUNCH_LOG="${RUN_LOG_ROOT}/launch.log"
log "启动本地标定服务和总控,日志: ${LAUNCH_LOG}"
ros2 launch win_ubuntu_bridge minimal_workshop_demo.launch.py \
use_gateway:=false \
data_input_params_file:="${DATA_INPUT_PARAMS_FILE}" \
dataset_index_file:="${DATASET_INDEX_FILE}" \
sensor_registry:=demo_front_camera,demo_down_camera,demo_lidar_3d,demo_lidar_2d,demo_imu \
>"${LAUNCH_LOG}" 2>&1 &
PIDS+=("$!")
NAMES+=("local-workshop-demo")
log "等待 /workshop_v2/create_session"
for _ in $(seq 1 60); do
if ros2 service list 2>/dev/null | grep -qx /workshop_v2/create_session; then
break
fi
sleep 1
done
ros2 service list 2>/dev/null | grep -qx /workshop_v2/create_session || {
tail -n 120 "${LAUNCH_LOG}" >&2 || true
die "等待 /workshop_v2/create_session 超时"
}
log "确认四个算法服务已读取 data_input 参数"
ros2 param get /external_localization_service data_input.external_observation_files
ros2 param get /chassis_calibration_service data_input.chassis_motion_data_files
ros2 param get /control_calibration_service data_input.control_evaluation_data_files
ros2 param get /sensor_calibration_service data_input.synchronized_dataset_files
ros2 param get /workshop_orchestrator_v2 data_input_params_file
ros2 param get /workshop_orchestrator_v2 dataset_index_file
SMOKE_LOG="${RUN_LOG_ROOT}/smoke.log"
log "执行本地四阶段 smoke,日志: ${SMOKE_LOG}"
python3 "${WORKSPACE_DIR}/src/simulation/tools/smoke_test_workshop_orchestrator.py" \
--tasks "${TASKS}" \
--verbose-feedback \
--disable-wifi6-precheck \
2>&1 | tee "${SMOKE_LOG}"
grep -q "site_data_input.yaml" "${SMOKE_LOG}" || die "报告中没有记录 site_data_input.yaml"
if [[ -f "${DATASET_INDEX_FILE}" ]]; then
grep -q "dataset_index.yaml" "${SMOKE_LOG}" || die "报告中没有记录 dataset_index.yaml"
fi
if [[ "${KEEP_RUNNING}" -eq 1 ]]; then
log "本地服务保持运行中。按 Ctrl+C 停止。"
while true; do
sleep 3600
done
fi
log "本地数据输入回归测试通过"
+15 -1
View File
@@ -17,7 +17,7 @@
部署前仿真验证代码。Isaac 车间、仿真车辆、仿真车端 agent、仿真传感器、仿真标定靶和旧版仿真包都放在这里。 部署前仿真验证代码。Isaac 车间、仿真车辆、仿真车端 agent、仿真传感器、仿真标定靶和旧版仿真包都放在这里。
- `deployment/` - `deployment/`
部署 profile 和从仿真迁移到现场前的检查清单。这里放配置基准,不放算法实现。 部署 profile、数据集索引转换工具和从仿真迁移到现场前的检查清单。这里放配置基准,不放算法实现。
- `docs/` - `docs/`
源码树内的设计说明、边界说明和迁移规则。 源码树内的设计说明、边界说明和迁移规则。
@@ -34,8 +34,22 @@
- 部署 profile 放在 `deployment/` - 部署 profile 放在 `deployment/`
- 设计说明和迁移边界说明放在 `docs/` - 设计说明和迁移边界说明放在 `docs/`
## 文档入口
- `docs/sim_to_site_code_boundary.md`
仿真代码、可复用核心代码和真实现场部署代码的边界。
- `docs/algorithm_template_contract.md`
标定算法模板合同,定义模板维护者和算法实现者的责任边界、可修改文件、输入输出字段和 smoke 验证要求。
## 主要入口 ## 主要入口
本地数据输入回归测试:
```bash
./run_local_data_input_smoke.sh
```
Isaac 车间仿真: Isaac 车间仿真:
```bash ```bash
@@ -16,7 +16,6 @@ def generate_launch_description():
Node( Node(
package="vehicle_agent_gateway", package="vehicle_agent_gateway",
executable="vehicle_agent_gateway_node", executable="vehicle_agent_gateway_node",
name="vehicle_agent_gateway",
output="screen", output="screen",
parameters=[{ parameters=[{
"chassis_host": LaunchConfiguration("chassis_host"), "chassis_host": LaunchConfiguration("chassis_host"),
@@ -4,13 +4,15 @@ from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node from launch_ros.actions import Node
# 最小 demo 拉起完整闭环:车辆画像 + 外部定位 + 传感器 + 底盘/运控(stub 或 WiFi 网关)+ 车间编排。 # 最小演示启动完整闭环:车辆画像 + 外部定位 + 传感器 + 底盘/运控(桩节点或 WiFi 网关)+ 车间编排。
# 参数: # 参数:
# use_gateway (bool, 默认 false)true 时用 vehicle_agent_gateway 替换本地底盘/运控 stub 节点。 # use_gateway(布尔值,默认 falsetrue 时用 vehicle_agent_gateway 替换本地底盘/运控节点。
# chassis_host / control_hostWindows 车端 IP(仅 use_gateway=true 时生效)。 # chassis_host / control_hostWindows 车端 IP(仅 use_gateway=true 时生效)。
# profile_storage_pathvehicle_profile_manager 的画像持久化文件。 # profile_storage_pathvehicle_profile_manager 的画像持久化文件。
# sensor_storage_root / sensor_registry / capture_pipeline_name / telemetry_topics # sensor_storage_root / sensor_registry / capture_pipeline_name / telemetry_topics
# 传感器 readiness 的最小真实配置入口。 # 传感器 readiness 的最小真实配置入口。
# data_input_params_file:可选 ROS 参数文件,用于把真实采集/ingest 产物路径注入算法输入。
# dataset_index_file:可选数据集索引文件;为空时主控会按 data_input_params_file 同目录自动查找。
def generate_launch_description(): def generate_launch_description():
use_gateway_arg = DeclareLaunchArgument( use_gateway_arg = DeclareLaunchArgument(
"use_gateway", "use_gateway",
@@ -60,6 +62,16 @@ def generate_launch_description():
default_value="true", default_value="true",
description="当前机械臂是否就绪", description="当前机械臂是否就绪",
) )
data_input_params_file_arg = DeclareLaunchArgument(
"data_input_params_file",
default_value="",
description="可选 ROS 参数文件,包含各 service 的 data_input.* 数据文件引用配置",
)
dataset_index_file_arg = DeclareLaunchArgument(
"dataset_index_file",
default_value="",
description="可选数据集索引文件,用于最终报告追溯本次真实采集数据",
)
def make_nodes(context): def make_nodes(context):
use_gateway = LaunchConfiguration("use_gateway").perform(context).lower() == "true" use_gateway = LaunchConfiguration("use_gateway").perform(context).lower() == "true"
@@ -72,6 +84,16 @@ def generate_launch_description():
telemetry_topics = LaunchConfiguration("telemetry_topics").perform(context) telemetry_topics = LaunchConfiguration("telemetry_topics").perform(context)
vehicle_safe_to_move = LaunchConfiguration("vehicle_safe_to_move").perform(context).lower() == "true" vehicle_safe_to_move = LaunchConfiguration("vehicle_safe_to_move").perform(context).lower() == "true"
arm_ready = LaunchConfiguration("arm_ready").perform(context).lower() == "true" arm_ready = LaunchConfiguration("arm_ready").perform(context).lower() == "true"
data_input_params_file = LaunchConfiguration("data_input_params_file").perform(context).strip()
dataset_index_file = LaunchConfiguration("dataset_index_file").perform(context).strip()
def with_data_input_params(params=None):
merged = []
if data_input_params_file:
merged.append(data_input_params_file)
if params is not None:
merged.append(params)
return merged
common_nodes = [ common_nodes = [
Node( Node(
@@ -88,26 +110,31 @@ def generate_launch_description():
executable="external_localization_service_node", executable="external_localization_service_node",
name="external_localization_service", name="external_localization_service",
output="screen", output="screen",
parameters=with_data_input_params(),
), ),
Node( Node(
package="sensor_calibration_service", package="sensor_calibration_service",
executable="sensor_calibration_service_node", executable="sensor_calibration_service_node",
name="sensor_calibration_service", name="sensor_calibration_service",
output="screen", output="screen",
parameters=[{ parameters=with_data_input_params({
"sensor_storage_root": sensor_storage_root, "sensor_storage_root": sensor_storage_root,
"sensor_registry": sensor_registry, "sensor_registry": sensor_registry,
"capture_pipeline_name": capture_pipeline_name, "capture_pipeline_name": capture_pipeline_name,
"telemetry_topics": telemetry_topics, "telemetry_topics": telemetry_topics,
"vehicle_safe_to_move": vehicle_safe_to_move, "vehicle_safe_to_move": vehicle_safe_to_move,
"arm_ready": arm_ready, "arm_ready": arm_ready,
}], }),
), ),
Node( Node(
package="workshop_orchestrator_v2", package="workshop_orchestrator_v2",
executable="workshop_orchestrator_v2_node", executable="workshop_orchestrator_v2_node",
name="workshop_orchestrator_v2", name="workshop_orchestrator_v2",
output="screen", output="screen",
parameters=[{
"data_input_params_file": data_input_params_file,
"dataset_index_file": dataset_index_file,
}],
), ),
] ]
@@ -117,7 +144,6 @@ def generate_launch_description():
Node( Node(
package="vehicle_agent_gateway", package="vehicle_agent_gateway",
executable="vehicle_agent_gateway_node", executable="vehicle_agent_gateway_node",
name="vehicle_agent_gateway",
output="screen", output="screen",
parameters=[{ parameters=[{
"chassis_host": chassis_host, "chassis_host": chassis_host,
@@ -130,19 +156,21 @@ def generate_launch_description():
), ),
] ]
else: else:
# 本地 stub 模式:底盘和运控使用本地 stub 节点,无需 Windows 连接。 # 本地桩节点模式:底盘和运控使用本地节点,无需 Windows 连接。
chassis_control_nodes = [ chassis_control_nodes = [
Node( Node(
package="chassis_calibration_service", package="chassis_calibration_service",
executable="chassis_calibration_service_node", executable="chassis_calibration_service_node",
name="chassis_calibration_service", name="chassis_calibration_service",
output="screen", output="screen",
parameters=with_data_input_params(),
), ),
Node( Node(
package="control_calibration_service", package="control_calibration_service",
executable="control_calibration_service_node", executable="control_calibration_service_node",
name="control_calibration_service", name="control_calibration_service",
output="screen", output="screen",
parameters=with_data_input_params(),
), ),
] ]
@@ -159,5 +187,7 @@ def generate_launch_description():
telemetry_topics_arg, telemetry_topics_arg,
vehicle_safe_to_move_arg, vehicle_safe_to_move_arg,
arm_ready_arg, arm_ready_arg,
data_input_params_file_arg,
dataset_index_file_arg,
OpaqueFunction(function=make_nodes), OpaqueFunction(function=make_nodes),
]) ])
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -9,6 +10,7 @@
#include "calibration_chassis_interfaces/msg/chassis_readiness_response.hpp" #include "calibration_chassis_interfaces/msg/chassis_readiness_response.hpp"
#include "calibration_chassis_interfaces/msg/chassis_telemetry.hpp" #include "calibration_chassis_interfaces/msg/chassis_telemetry.hpp"
#include "calibration_chassis_interfaces/msg/chassis_work_mode.hpp" #include "calibration_chassis_interfaces/msg/chassis_work_mode.hpp"
#include "calibration_common_interfaces/msg/file_reference.hpp"
#include "calibration_control_interfaces/msg/active_controller_parameters_response.hpp" #include "calibration_control_interfaces/msg/active_controller_parameters_response.hpp"
#include "calibration_control_interfaces/msg/control_readiness_response.hpp" #include "calibration_control_interfaces/msg/control_readiness_response.hpp"
#include "calibration_control_interfaces/msg/control_telemetry.hpp" #include "calibration_control_interfaces/msg/control_telemetry.hpp"
@@ -101,6 +103,20 @@ struct ChassisCalibrationInput
// 历史外部定位遥测窗口:用于和车端里程计、控制误差、标定结果做对齐分析。 // 历史外部定位遥测窗口:用于和车端里程计、控制误差、标定结果做对齐分析。
std::vector<calibration_external_localization_interfaces::msg::ExternalLocalizationTelemetry> external_localization_telemetry_history; std::vector<calibration_external_localization_interfaces::msg::ExternalLocalizationTelemetry> external_localization_telemetry_history;
// ===== 算法数据输入窗口 =====
// 当前动作原语对应的数据时间窗口。0 表示上层尚未提供明确边界。
int64_t data_window_start_timestamp_us{0};
int64_t data_window_end_timestamp_us{0};
// 底盘原始运动数据文件:例如 wheel/steering/odom/driver_state CSV、JSON 或 rosbag。
std::vector<calibration_common_interfaces::msg::FileReference> chassis_motion_data_files;
// 控制命令与执行器反馈文件:例如 cmd_vel、Ackermann 命令、驱动器反馈、制动状态。
std::vector<calibration_common_interfaces::msg::FileReference> actuator_command_files;
// 外部真值轨迹文件:例如 external pose 轨迹、地面真值、测量轨迹,用于和底盘里程计对齐。
std::vector<calibration_common_interfaces::msg::FileReference> truth_trajectory_files;
// 说明:如果后续算法还需要更多原始反馈,可以继续在这里扩展。 // 说明:如果后续算法还需要更多原始反馈,可以继续在这里扩展。
// 常见补充项包括:GNSS、SLAM 里程计、地面真值、控制器内部状态、制动状态、故障码、机械臂反馈等。 // 常见补充项包括:GNSS、SLAM 里程计、地面真值、控制器内部状态、制动状态、故障码、机械臂反馈等。
}; };
@@ -2,10 +2,12 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector>
#include "rclcpp/rclcpp.hpp" #include "rclcpp/rclcpp.hpp"
#include "rclcpp_action/rclcpp_action.hpp" #include "rclcpp_action/rclcpp_action.hpp"
#include "calibration_common_interfaces/msg/file_reference.hpp"
#include "calibration_chassis_interfaces/action/execute_motion_primitive.hpp" #include "calibration_chassis_interfaces/action/execute_motion_primitive.hpp"
#include "calibration_chassis_interfaces/srv/get_chassis_readiness.hpp" #include "calibration_chassis_interfaces/srv/get_chassis_readiness.hpp"
#include "calibration_vehicle_profile_interfaces/srv/get_vehicle_profile.hpp" #include "calibration_vehicle_profile_interfaces/srv/get_vehicle_profile.hpp"
@@ -30,12 +32,24 @@ private:
using ExecuteTask = calibration_chassis_interfaces::action::ExecuteMotionPrimitive; using ExecuteTask = calibration_chassis_interfaces::action::ExecuteMotionPrimitive;
using GoalHandleExecuteTask = rclcpp_action::ServerGoalHandle<ExecuteTask>; using GoalHandleExecuteTask = rclcpp_action::ServerGoalHandle<ExecuteTask>;
struct DataInputConfig
{
int64_t data_window_start_timestamp_us{0};
int64_t data_window_end_timestamp_us{0};
std::vector<calibration_common_interfaces::msg::FileReference> chassis_motion_data_files;
std::vector<calibration_common_interfaces::msg::FileReference> actuator_command_files;
std::vector<calibration_common_interfaces::msg::FileReference> truth_trajectory_files;
};
// readiness 服务:让编排器确认当前底盘是否具备执行条件。 // readiness 服务:让编排器确认当前底盘是否具备执行条件。
rclcpp::Service<ReadinessSrv>::SharedPtr readiness_service_; rclcpp::Service<ReadinessSrv>::SharedPtr readiness_service_;
// 车辆画像客户端:从 vehicle_profile_manager 取当前车辆的底盘类型。 // 车辆画像客户端:从 vehicle_profile_manager 取当前车辆的底盘类型。
rclcpp::Client<GetVehicleProfileSrv>::SharedPtr get_vehicle_profile_client_; rclcpp::Client<GetVehicleProfileSrv>::SharedPtr get_vehicle_profile_client_;
// action server:接收底盘动作原语任务,并在后台线程里执行。 // action server:接收底盘动作原语任务,并在后台线程里执行。
rclcpp_action::Server<ExecuteTask>::SharedPtr execute_task_action_server_; rclcpp_action::Server<ExecuteTask>::SharedPtr execute_task_action_server_;
DataInputConfig data_input_config_;
DataInputConfig load_data_input_config();
// readiness 回调:把底盘运行状态翻译成统一的 readiness 响应。 // readiness 回调:把底盘运行状态翻译成统一的 readiness 响应。
void handle_readiness( void handle_readiness(
@@ -1,6 +1,7 @@
#include "chassis_calibration_service/chassis_calibration_service_node.hpp" #include "chassis_calibration_service/chassis_calibration_service_node.hpp"
#include <chrono> #include <chrono>
#include <future>
#include <thread> #include <thread>
#include "rclcpp_components/register_node_macro.hpp" #include "rclcpp_components/register_node_macro.hpp"
@@ -21,6 +22,33 @@ int64_t now_us()
std::chrono::system_clock::now().time_since_epoch()) std::chrono::system_clock::now().time_since_epoch())
.count(); .count();
} }
calibration_common_interfaces::msg::FileReference make_file_reference(
const std::string & file_uri,
const std::string & description)
{
calibration_common_interfaces::msg::FileReference ref;
ref.file_uri = file_uri;
const auto pos = file_uri.find_last_of("/\\");
ref.file_name = pos == std::string::npos ? file_uri : file_uri.substr(pos + 1);
ref.size_bytes = 0;
ref.description = description;
return ref;
}
std::vector<calibration_common_interfaces::msg::FileReference> make_file_references(
const std::vector<std::string> & file_uris,
const std::string & description)
{
std::vector<calibration_common_interfaces::msg::FileReference> refs;
refs.reserve(file_uris.size());
for (const auto & file_uri : file_uris) {
if (!file_uri.empty()) {
refs.push_back(make_file_reference(file_uri, description));
}
}
return refs;
}
} // namespace } // namespace
using calibration_common_interfaces::msg::ErrorCode; using calibration_common_interfaces::msg::ErrorCode;
@@ -29,6 +57,8 @@ using calibration_common_interfaces::msg::JobState;
ChassisCalibrationServiceNode::ChassisCalibrationServiceNode(const rclcpp::NodeOptions & options) ChassisCalibrationServiceNode::ChassisCalibrationServiceNode(const rclcpp::NodeOptions & options)
: Node("chassis_calibration_service", options) : Node("chassis_calibration_service", options)
{ {
data_input_config_ = load_data_input_config();
// readiness:供 orchestrator 在开跑前检查底盘是否具备执行条件。 // readiness:供 orchestrator 在开跑前检查底盘是否具备执行条件。
readiness_service_ = create_service<ReadinessSrv>( readiness_service_ = create_service<ReadinessSrv>(
"/chassis/get_readiness", "/chassis/get_readiness",
@@ -46,6 +76,28 @@ ChassisCalibrationServiceNode::ChassisCalibrationServiceNode(const rclcpp::NodeO
std::bind(&ChassisCalibrationServiceNode::handle_accepted, this, std::placeholders::_1)); std::bind(&ChassisCalibrationServiceNode::handle_accepted, this, std::placeholders::_1));
} }
ChassisCalibrationServiceNode::DataInputConfig ChassisCalibrationServiceNode::load_data_input_config()
{
DataInputConfig config;
config.data_window_start_timestamp_us =
declare_parameter<int64_t>("data_input.data_window_start_timestamp_us", 0);
config.data_window_end_timestamp_us =
declare_parameter<int64_t>("data_input.data_window_end_timestamp_us", 0);
config.chassis_motion_data_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.chassis_motion_data_files", std::vector<std::string>{}),
"chassis motion data");
config.actuator_command_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.actuator_command_files", std::vector<std::string>{}),
"actuator command and feedback data");
config.truth_trajectory_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.truth_trajectory_files", std::vector<std::string>{}),
"external truth trajectory data");
return config;
}
void ChassisCalibrationServiceNode::handle_readiness( void ChassisCalibrationServiceNode::handle_readiness(
const std::shared_ptr<ReadinessSrv::Request> request, const std::shared_ptr<ReadinessSrv::Request> request,
std::shared_ptr<ReadinessSrv::Response> response) std::shared_ptr<ReadinessSrv::Response> response)
@@ -109,8 +161,9 @@ bool ChassisCalibrationServiceNode::fetch_chassis_type(
auto request = std::make_shared<GetVehicleProfileSrv::Request>(); auto request = std::make_shared<GetVehicleProfileSrv::Request>();
request->request.vehicle_id = vehicle_id; request->request.vehicle_id = vehicle_id;
auto future = get_vehicle_profile_client_->async_send_request(request); auto future = get_vehicle_profile_client_->async_send_request(request);
if (rclcpp::spin_until_future_complete(this->get_node_base_interface(), future, std::chrono::seconds(2)) !=
rclcpp::FutureReturnCode::SUCCESS) { // 当前节点已经由 main/launch 的 executor spin,不能在执行线程里再次 spin 同一个节点。
if (future.wait_for(std::chrono::seconds(2)) != std::future_status::ready) {
failure_reason = "查询车辆画像超时。"; failure_reason = "查询车辆画像超时。";
return false; return false;
} }
@@ -143,6 +196,11 @@ void ChassisCalibrationServiceNode::execute_goal(
// chassis_type 不再靠 request_id 猜,而是从车辆画像服务中按 vehicle_id 查询。 // chassis_type 不再靠 request_id 猜,而是从车辆画像服务中按 vehicle_id 查询。
ChassisCalibrationAlgorithmTemplate::Input input; ChassisCalibrationAlgorithmTemplate::Input input;
input.request = *goal_handle->get_goal(); input.request = *goal_handle->get_goal();
input.data_window_start_timestamp_us = data_input_config_.data_window_start_timestamp_us;
input.data_window_end_timestamp_us = data_input_config_.data_window_end_timestamp_us;
input.chassis_motion_data_files = data_input_config_.chassis_motion_data_files;
input.actuator_command_files = data_input_config_.actuator_command_files;
input.truth_trajectory_files = data_input_config_.truth_trajectory_files;
std::string failure_reason; std::string failure_reason;
if (!fetch_chassis_type( if (!fetch_chassis_type(
input.request.goal.header.vehicle_id, input.request.goal.header.vehicle_id,
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -7,6 +8,7 @@
#include "calibration_chassis_interfaces/msg/chassis_readiness_response.hpp" #include "calibration_chassis_interfaces/msg/chassis_readiness_response.hpp"
#include "calibration_chassis_interfaces/msg/chassis_telemetry.hpp" #include "calibration_chassis_interfaces/msg/chassis_telemetry.hpp"
#include "calibration_chassis_interfaces/msg/chassis_work_mode.hpp" #include "calibration_chassis_interfaces/msg/chassis_work_mode.hpp"
#include "calibration_common_interfaces/msg/file_reference.hpp"
#include "calibration_control_interfaces/action/execute_controller_evaluation.hpp" #include "calibration_control_interfaces/action/execute_controller_evaluation.hpp"
#include "calibration_control_interfaces/msg/acceleration_deceleration_task.hpp" #include "calibration_control_interfaces/msg/acceleration_deceleration_task.hpp"
#include "calibration_control_interfaces/msg/active_controller_parameters_response.hpp" #include "calibration_control_interfaces/msg/active_controller_parameters_response.hpp"
@@ -164,6 +166,23 @@ struct ControlCalibrationInput
bool sensor_history_available{false}; bool sensor_history_available{false};
bool truth_history_available{false}; bool truth_history_available{false};
// ===== 算法数据输入窗口 =====
// 当前控制评估任务对应的数据时间窗口。0 表示上层尚未提供明确边界。
int64_t data_window_start_timestamp_us{0};
int64_t data_window_end_timestamp_us{0};
// 控制评估原始数据文件:例如控制误差、控制输出、饱和状态、控制器内部状态 CSV/JSON/rosbag。
std::vector<calibration_common_interfaces::msg::FileReference> control_evaluation_data_files;
// 参考轨迹或参考速度曲线文件:当 reference_trajectory 不足以表达完整任务时使用。
std::vector<calibration_common_interfaces::msg::FileReference> reference_signal_files;
// 底盘响应文件:例如底盘里程计、轮速、舵角、执行器状态,用于分析车辆实际响应。
std::vector<calibration_common_interfaces::msg::FileReference> chassis_response_files;
// 外部真值轨迹文件:例如 external pose 轨迹、地面真值、定位质量日志。
std::vector<calibration_common_interfaces::msg::FileReference> truth_trajectory_files;
// 说明:如果后续控制算法还需要更细反馈,可继续在这里补充。 // 说明:如果后续控制算法还需要更细反馈,可继续在这里补充。
// 常见补充项包括:规划轨迹、参考速度曲线、控制器内部状态、执行器饱和原因、故障码、时间同步诊断信息等。 // 常见补充项包括:规划轨迹、参考速度曲线、控制器内部状态、执行器饱和原因、故障码、时间同步诊断信息等。
} }
@@ -2,12 +2,14 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector>
#include "rclcpp/rclcpp.hpp" #include "rclcpp/rclcpp.hpp"
#include "rclcpp_action/rclcpp_action.hpp" #include "rclcpp_action/rclcpp_action.hpp"
#include "control_calibration_service/control_calibration_algorithm_template.hpp" #include "control_calibration_service/control_calibration_algorithm_template.hpp"
#include "calibration_common_interfaces/msg/file_reference.hpp"
#include "calibration_control_interfaces/action/execute_controller_evaluation.hpp" #include "calibration_control_interfaces/action/execute_controller_evaluation.hpp"
#include "calibration_control_interfaces/srv/get_control_readiness.hpp" #include "calibration_control_interfaces/srv/get_control_readiness.hpp"
@@ -31,9 +33,22 @@ private:
using GoalHandleExecuteTask = rclcpp_action::ServerGoalHandle<ExecuteTask>; using GoalHandleExecuteTask = rclcpp_action::ServerGoalHandle<ExecuteTask>;
using ControllerAlgorithmType = calibration_vehicle_profile_interfaces::msg::ControllerAlgorithmType; using ControllerAlgorithmType = calibration_vehicle_profile_interfaces::msg::ControllerAlgorithmType;
struct DataInputConfig
{
int64_t data_window_start_timestamp_us{0};
int64_t data_window_end_timestamp_us{0};
std::vector<calibration_common_interfaces::msg::FileReference> control_evaluation_data_files;
std::vector<calibration_common_interfaces::msg::FileReference> reference_signal_files;
std::vector<calibration_common_interfaces::msg::FileReference> chassis_response_files;
std::vector<calibration_common_interfaces::msg::FileReference> truth_trajectory_files;
};
rclcpp::Service<ReadinessSrv>::SharedPtr readiness_service_; rclcpp::Service<ReadinessSrv>::SharedPtr readiness_service_;
rclcpp_action::Server<ExecuteTask>::SharedPtr execute_task_action_server_; rclcpp_action::Server<ExecuteTask>::SharedPtr execute_task_action_server_;
ControlCalibrationAlgorithmTemplate algorithm_; ControlCalibrationAlgorithmTemplate algorithm_;
DataInputConfig data_input_config_;
DataInputConfig load_data_input_config();
void handle_readiness( void handle_readiness(
const std::shared_ptr<ReadinessSrv::Request> request, const std::shared_ptr<ReadinessSrv::Request> request,
@@ -22,6 +22,33 @@ int64_t now_us()
std::chrono::system_clock::now().time_since_epoch()) std::chrono::system_clock::now().time_since_epoch())
.count(); .count();
} }
calibration_common_interfaces::msg::FileReference make_file_reference(
const std::string & file_uri,
const std::string & description)
{
calibration_common_interfaces::msg::FileReference ref;
ref.file_uri = file_uri;
const auto pos = file_uri.find_last_of("/\\");
ref.file_name = pos == std::string::npos ? file_uri : file_uri.substr(pos + 1);
ref.size_bytes = 0;
ref.description = description;
return ref;
}
std::vector<calibration_common_interfaces::msg::FileReference> make_file_references(
const std::vector<std::string> & file_uris,
const std::string & description)
{
std::vector<calibration_common_interfaces::msg::FileReference> refs;
refs.reserve(file_uris.size());
for (const auto & file_uri : file_uris) {
if (!file_uri.empty()) {
refs.push_back(make_file_reference(file_uri, description));
}
}
return refs;
}
} // namespace } // namespace
using calibration_common_interfaces::msg::ErrorCode; using calibration_common_interfaces::msg::ErrorCode;
@@ -31,6 +58,8 @@ using calibration_control_interfaces::msg::ControllerEvaluationTaskType;
ControlCalibrationServiceNode::ControlCalibrationServiceNode(const rclcpp::NodeOptions & options) ControlCalibrationServiceNode::ControlCalibrationServiceNode(const rclcpp::NodeOptions & options)
: Node("control_calibration_service", options) : Node("control_calibration_service", options)
{ {
data_input_config_ = load_data_input_config();
readiness_service_ = create_service<ReadinessSrv>( readiness_service_ = create_service<ReadinessSrv>(
"/control/get_readiness", "/control/get_readiness",
std::bind(&ControlCalibrationServiceNode::handle_readiness, this, std::placeholders::_1, std::placeholders::_2)); std::bind(&ControlCalibrationServiceNode::handle_readiness, this, std::placeholders::_1, std::placeholders::_2));
@@ -43,6 +72,32 @@ ControlCalibrationServiceNode::ControlCalibrationServiceNode(const rclcpp::NodeO
std::bind(&ControlCalibrationServiceNode::handle_accepted, this, std::placeholders::_1)); std::bind(&ControlCalibrationServiceNode::handle_accepted, this, std::placeholders::_1));
} }
ControlCalibrationServiceNode::DataInputConfig ControlCalibrationServiceNode::load_data_input_config()
{
DataInputConfig config;
config.data_window_start_timestamp_us =
declare_parameter<int64_t>("data_input.data_window_start_timestamp_us", 0);
config.data_window_end_timestamp_us =
declare_parameter<int64_t>("data_input.data_window_end_timestamp_us", 0);
config.control_evaluation_data_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.control_evaluation_data_files", std::vector<std::string>{}),
"control evaluation data");
config.reference_signal_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.reference_signal_files", std::vector<std::string>{}),
"control reference signal data");
config.chassis_response_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.chassis_response_files", std::vector<std::string>{}),
"chassis response data for control evaluation");
config.truth_trajectory_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.truth_trajectory_files", std::vector<std::string>{}),
"external truth trajectory data");
return config;
}
void ControlCalibrationServiceNode::handle_readiness( void ControlCalibrationServiceNode::handle_readiness(
const std::shared_ptr<ReadinessSrv::Request> request, const std::shared_ptr<ReadinessSrv::Request> request,
std::shared_ptr<ReadinessSrv::Response> response) std::shared_ptr<ReadinessSrv::Response> response)
@@ -116,6 +171,12 @@ void ControlCalibrationServiceNode::execute_goal(
input.chassis_history_available = false; input.chassis_history_available = false;
input.sensor_history_available = false; input.sensor_history_available = false;
input.truth_history_available = false; input.truth_history_available = false;
input.data_window_start_timestamp_us = data_input_config_.data_window_start_timestamp_us;
input.data_window_end_timestamp_us = data_input_config_.data_window_end_timestamp_us;
input.control_evaluation_data_files = data_input_config_.control_evaluation_data_files;
input.reference_signal_files = data_input_config_.reference_signal_files;
input.chassis_response_files = data_input_config_.chassis_response_files;
input.truth_trajectory_files = data_input_config_.truth_trajectory_files;
ControlCalibrationAlgorithmTemplate::Output output; ControlCalibrationAlgorithmTemplate::Output output;
std::string failure_reason; std::string failure_reason;
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -7,6 +8,7 @@
#include "calibration_chassis_interfaces/msg/chassis_readiness_response.hpp" #include "calibration_chassis_interfaces/msg/chassis_readiness_response.hpp"
#include "calibration_chassis_interfaces/msg/chassis_telemetry.hpp" #include "calibration_chassis_interfaces/msg/chassis_telemetry.hpp"
#include "calibration_chassis_interfaces/msg/chassis_work_mode.hpp" #include "calibration_chassis_interfaces/msg/chassis_work_mode.hpp"
#include "calibration_common_interfaces/msg/file_reference.hpp"
#include "calibration_control_interfaces/msg/active_controller_parameters_response.hpp" #include "calibration_control_interfaces/msg/active_controller_parameters_response.hpp"
#include "calibration_control_interfaces/msg/control_readiness_response.hpp" #include "calibration_control_interfaces/msg/control_readiness_response.hpp"
#include "calibration_control_interfaces/msg/control_telemetry.hpp" #include "calibration_control_interfaces/msg/control_telemetry.hpp"
@@ -102,6 +104,20 @@ struct ExternalLocalizationInput
// 数据采集与落盘诊断扩展:例如原始位姿采集窗口不足、采样频率不满足、落盘失败等。 // 数据采集与落盘诊断扩展:例如原始位姿采集窗口不足、采样频率不满足、落盘失败等。
std::vector<std::string> acquisition_diagnostics; std::vector<std::string> acquisition_diagnostics;
// ===== 算法数据输入窗口 =====
// 当前算法可使用的数据时间窗口。0 表示上层尚未提供明确边界。
int64_t data_window_start_timestamp_us{0};
int64_t data_window_end_timestamp_us{0};
// 外部定位原始观测文件:例如 CSV/JSON/rosbag,内容应至少包含时间戳、位姿、质量、协方差或标准差。
std::vector<calibration_common_interfaces::msg::FileReference> external_observation_files;
// 标靶观测或检测结果文件:例如 marker/AprilTag/反光柱观测日志、角点检测结果、对齐中间结果。
std::vector<calibration_common_interfaces::msg::FileReference> marker_observation_files;
// 时间同步诊断文件:例如 PTP/NTP 偏差统计、车端与车间主机时钟对齐日志。
std::vector<calibration_common_interfaces::msg::FileReference> time_sync_diagnostic_files;
// ===== 底盘域反馈 ===== // ===== 底盘域反馈 =====
// 外部定位验证往往需要结合车辆真实运动状态,因此保留底盘域反馈接口。 // 外部定位验证往往需要结合车辆真实运动状态,因此保留底盘域反馈接口。
calibration_chassis_interfaces::msg::ChassisReadinessResponse chassis_readiness; calibration_chassis_interfaces::msg::ChassisReadinessResponse chassis_readiness;
@@ -59,6 +59,7 @@ private:
}; };
ReadinessConfig load_readiness_config(); ReadinessConfig load_readiness_config();
ExternalReferenceExecutor::DataInputConfig load_data_input_config();
void handle_external_telemetry(const ExternalTelemetry::SharedPtr msg); void handle_external_telemetry(const ExternalTelemetry::SharedPtr msg);
void append_issue( void append_issue(
calibration_external_localization_interfaces::msg::ExternalLocalizationValidationSummary & summary, calibration_external_localization_interfaces::msg::ExternalLocalizationValidationSummary & summary,
@@ -71,6 +72,7 @@ private:
calibration_external_localization_interfaces::msg::ExternalLocalizationReadinessResponse & readiness) const; calibration_external_localization_interfaces::msg::ExternalLocalizationReadinessResponse & readiness) const;
ReadinessConfig readiness_config_; ReadinessConfig readiness_config_;
ExternalReferenceExecutor::DataInputConfig data_input_config_;
mutable std::mutex telemetry_mutex_; mutable std::mutex telemetry_mutex_;
TelemetrySnapshot telemetry_snapshot_; TelemetrySnapshot telemetry_snapshot_;
rclcpp::Subscription<ExternalTelemetry>::SharedPtr telemetry_subscription_; rclcpp::Subscription<ExternalTelemetry>::SharedPtr telemetry_subscription_;
@@ -1,7 +1,10 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <vector>
#include "calibration_common_interfaces/msg/file_reference.hpp"
#include "calibration_workshop_orchestration_interfaces/msg/stage_result_summary.hpp" #include "calibration_workshop_orchestration_interfaces/msg/stage_result_summary.hpp"
#include "external_localization_service/external_localization_algorithm_template.hpp" #include "external_localization_service/external_localization_algorithm_template.hpp"
@@ -26,11 +29,21 @@ public:
using ExecuteTask = using ExecuteTask =
calibration_external_localization_interfaces::action::ExecuteExternalLocalizationTask; calibration_external_localization_interfaces::action::ExecuteExternalLocalizationTask;
struct DataInputConfig
{
int64_t data_window_start_timestamp_us{0};
int64_t data_window_end_timestamp_us{0};
std::vector<calibration_common_interfaces::msg::FileReference> external_observation_files;
std::vector<calibration_common_interfaces::msg::FileReference> marker_observation_files;
std::vector<calibration_common_interfaces::msg::FileReference> time_sync_diagnostic_files;
};
bool validate_goal(const ExecuteTask::Goal & goal, std::string & reject_reason) const; bool validate_goal(const ExecuteTask::Goal & goal, std::string & reject_reason) const;
bool build_result( bool build_result(
const ExecuteTask::Goal & goal, const ExecuteTask::Goal & goal,
const calibration_external_localization_interfaces::msg::ExternalLocalizationReadinessResponse & readiness, const calibration_external_localization_interfaces::msg::ExternalLocalizationReadinessResponse & readiness,
const std::vector<calibration_external_localization_interfaces::msg::ExternalLocalizationTelemetry> & telemetry_history, const std::vector<calibration_external_localization_interfaces::msg::ExternalLocalizationTelemetry> & telemetry_history,
const DataInputConfig & data_input_config,
ExecuteTask::Result & external_result, ExecuteTask::Result & external_result,
std::string & failure_reason) const; std::string & failure_reason) const;
@@ -19,6 +19,33 @@ int64_t now_us()
std::chrono::system_clock::now().time_since_epoch()) std::chrono::system_clock::now().time_since_epoch())
.count(); .count();
} }
calibration_common_interfaces::msg::FileReference make_file_reference(
const std::string & file_uri,
const std::string & description)
{
calibration_common_interfaces::msg::FileReference ref;
ref.file_uri = file_uri;
const auto pos = file_uri.find_last_of("/\\");
ref.file_name = pos == std::string::npos ? file_uri : file_uri.substr(pos + 1);
ref.size_bytes = 0;
ref.description = description;
return ref;
}
std::vector<calibration_common_interfaces::msg::FileReference> make_file_references(
const std::vector<std::string> & file_uris,
const std::string & description)
{
std::vector<calibration_common_interfaces::msg::FileReference> refs;
refs.reserve(file_uris.size());
for (const auto & file_uri : file_uris) {
if (!file_uri.empty()) {
refs.push_back(make_file_reference(file_uri, description));
}
}
return refs;
}
} // namespace } // namespace
using calibration_common_interfaces::msg::ErrorCode; using calibration_common_interfaces::msg::ErrorCode;
@@ -26,7 +53,8 @@ using calibration_common_interfaces::msg::JobState;
ExternalLocalizationServiceNode::ExternalLocalizationServiceNode(const rclcpp::NodeOptions & options) ExternalLocalizationServiceNode::ExternalLocalizationServiceNode(const rclcpp::NodeOptions & options)
: Node("external_localization_service", options), : Node("external_localization_service", options),
readiness_config_(load_readiness_config()) readiness_config_(load_readiness_config()),
data_input_config_(load_data_input_config())
{ {
telemetry_subscription_ = create_subscription<ExternalTelemetry>( telemetry_subscription_ = create_subscription<ExternalTelemetry>(
readiness_config_.telemetry_topic, readiness_config_.telemetry_topic,
@@ -67,6 +95,28 @@ ExternalLocalizationServiceNode::ReadinessConfig ExternalLocalizationServiceNode
return config; return config;
} }
ExternalReferenceExecutor::DataInputConfig ExternalLocalizationServiceNode::load_data_input_config()
{
ExternalReferenceExecutor::DataInputConfig config;
config.data_window_start_timestamp_us =
declare_parameter<int64_t>("data_input.data_window_start_timestamp_us", 0);
config.data_window_end_timestamp_us =
declare_parameter<int64_t>("data_input.data_window_end_timestamp_us", 0);
config.external_observation_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.external_observation_files", std::vector<std::string>{}),
"external localization observation data");
config.marker_observation_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.marker_observation_files", std::vector<std::string>{}),
"external marker observation data");
config.time_sync_diagnostic_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.time_sync_diagnostic_files", std::vector<std::string>{}),
"external time synchronization diagnostics");
return config;
}
void ExternalLocalizationServiceNode::handle_external_telemetry(const ExternalTelemetry::SharedPtr msg) void ExternalLocalizationServiceNode::handle_external_telemetry(const ExternalTelemetry::SharedPtr msg)
{ {
std::lock_guard<std::mutex> lock(telemetry_mutex_); std::lock_guard<std::mutex> lock(telemetry_mutex_);
@@ -345,7 +395,14 @@ void ExternalLocalizationServiceNode::execute_goal(
telemetry_history = telemetry_snapshot_.history; telemetry_history = telemetry_snapshot_.history;
} }
if (!executor_.build_result(*goal_handle->get_goal(), readiness, telemetry_history, *result, failure_reason)) { if (!executor_.build_result(
*goal_handle->get_goal(),
readiness,
telemetry_history,
data_input_config_,
*result,
failure_reason))
{
result->result.success = false; result->result.success = false;
result->result.error_code.code = result->result.error_code.code =
readiness.success ? ErrorCode::INVALID_ARGUMENT : readiness.error_code.code; readiness.success ? ErrorCode::INVALID_ARGUMENT : readiness.error_code.code;
@@ -32,6 +32,7 @@ bool ExternalReferenceExecutor::build_result(
const ExecuteTask::Goal & goal, const ExecuteTask::Goal & goal,
const calibration_external_localization_interfaces::msg::ExternalLocalizationReadinessResponse & readiness, const calibration_external_localization_interfaces::msg::ExternalLocalizationReadinessResponse & readiness,
const std::vector<calibration_external_localization_interfaces::msg::ExternalLocalizationTelemetry> & telemetry_history, const std::vector<calibration_external_localization_interfaces::msg::ExternalLocalizationTelemetry> & telemetry_history,
const DataInputConfig & data_input_config,
ExecuteTask::Result & external_result, ExecuteTask::Result & external_result,
std::string & failure_reason) const std::string & failure_reason) const
{ {
@@ -90,6 +91,13 @@ bool ExternalReferenceExecutor::build_result(
input.chassis_history_available = !input.chassis_telemetry_history.empty(); input.chassis_history_available = !input.chassis_telemetry_history.empty();
input.control_history_available = !input.control_telemetry_history.empty(); input.control_history_available = !input.control_telemetry_history.empty();
input.sensor_history_available = !input.sensor_telemetry_history.empty(); input.sensor_history_available = !input.sensor_telemetry_history.empty();
input.data_window_start_timestamp_us = data_input_config.data_window_start_timestamp_us;
input.data_window_end_timestamp_us = data_input_config.data_window_end_timestamp_us;
input.external_observation_files = data_input_config.external_observation_files;
input.marker_observation_files = data_input_config.marker_observation_files;
input.time_sync_diagnostic_files = data_input_config.time_sync_diagnostic_files;
input.acquisition_diagnostics.push_back(
"external_observation_files=" + std::to_string(input.external_observation_files.size()));
ExternalLocalizationOutput output; ExternalLocalizationOutput output;
if (!algorithm_.run(input, output, failure_reason)) { if (!algorithm_.run(input, output, failure_reason)) {
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -7,6 +8,7 @@
#include "calibration_chassis_interfaces/msg/chassis_readiness_response.hpp" #include "calibration_chassis_interfaces/msg/chassis_readiness_response.hpp"
#include "calibration_chassis_interfaces/msg/chassis_telemetry.hpp" #include "calibration_chassis_interfaces/msg/chassis_telemetry.hpp"
#include "calibration_chassis_interfaces/msg/chassis_work_mode.hpp" #include "calibration_chassis_interfaces/msg/chassis_work_mode.hpp"
#include "calibration_common_interfaces/msg/file_reference.hpp"
#include "calibration_control_interfaces/msg/active_controller_parameters_response.hpp" #include "calibration_control_interfaces/msg/active_controller_parameters_response.hpp"
#include "calibration_control_interfaces/msg/control_readiness_response.hpp" #include "calibration_control_interfaces/msg/control_readiness_response.hpp"
#include "calibration_control_interfaces/msg/control_telemetry.hpp" #include "calibration_control_interfaces/msg/control_telemetry.hpp"
@@ -132,6 +134,33 @@ struct SensorCalibrationInput
bool control_history_available{false}; bool control_history_available{false};
bool truth_history_available{false}; bool truth_history_available{false};
// ===== 算法数据输入窗口 =====
// 当前采集任务对应的数据时间窗口。0 表示上层尚未提供明确边界。
int64_t data_window_start_timestamp_us{0};
int64_t data_window_end_timestamp_us{0};
// 图像样本文件:适用于前视/下视相机内参、相机外参、手眼标定。
// 文件可为单帧图片、图片列表、rosbag、mcap 或算法约定的数据集索引。
std::vector<calibration_common_interfaces::msg::FileReference> image_sample_files;
// IMU 原始样本文件:适用于 IMU 内参、IMU 外参、运动段对齐。
std::vector<calibration_common_interfaces::msg::FileReference> imu_sample_files;
// 3D 点云样本文件:适用于 3D LiDAR 外参或点云配准类算法。
std::vector<calibration_common_interfaces::msg::FileReference> pointcloud_sample_files;
// 2D 激光扫描样本文件:适用于 2D LiDAR 外参、角落/平面靶标拟合。
std::vector<calibration_common_interfaces::msg::FileReference> laser_scan_sample_files;
// 标定目标检测结果文件:例如棋盘格/ChArUco/AprilTag 角点、PnP 初值、LiDAR 平面/角点提取结果。
std::vector<calibration_common_interfaces::msg::FileReference> target_detection_files;
// 机械臂或平台位姿文件:适用于手眼标定、传感器到 base_link 外参联合求解。
std::vector<calibration_common_interfaces::msg::FileReference> robot_pose_sample_files;
// 已同步的数据集索引:推荐作为真实算法的首选入口,内部可引用图像、IMU、点云、真值、检测结果。
std::vector<calibration_common_interfaces::msg::FileReference> synchronized_dataset_files;
// 说明:如果后续某类传感器算法还需要更细原始数据入口,可继续在这里补充。 // 说明:如果后续某类传感器算法还需要更细原始数据入口,可继续在这里补充。
// 常见补充项包括:图像帧缓存、IMU 原始包、点云帧列表、机械臂位姿序列、标定板检测结果等。 // 常见补充项包括:图像帧缓存、IMU 原始包、点云帧列表、机械臂位姿序列、标定板检测结果等。
}; };
@@ -11,6 +11,7 @@
#include "sensor_calibration_service/sensor_calibration_algorithm_template.hpp" #include "sensor_calibration_service/sensor_calibration_algorithm_template.hpp"
#include "calibration_common_interfaces/msg/file_reference.hpp"
#include "calibration_sensor_interfaces/action/execute_sensor_calibration_task.hpp" #include "calibration_sensor_interfaces/action/execute_sensor_calibration_task.hpp"
#include "calibration_sensor_interfaces/msg/sensor_calibration_task_type.hpp" #include "calibration_sensor_interfaces/msg/sensor_calibration_task_type.hpp"
#include "calibration_sensor_interfaces/srv/get_sensor_readiness.hpp" #include "calibration_sensor_interfaces/srv/get_sensor_readiness.hpp"
@@ -47,12 +48,27 @@ private:
std::vector<std::string> ready_sensor_ids; std::vector<std::string> ready_sensor_ids;
}; };
struct DataInputConfig
{
int64_t data_window_start_timestamp_us{0};
int64_t data_window_end_timestamp_us{0};
std::vector<calibration_common_interfaces::msg::FileReference> image_sample_files;
std::vector<calibration_common_interfaces::msg::FileReference> imu_sample_files;
std::vector<calibration_common_interfaces::msg::FileReference> pointcloud_sample_files;
std::vector<calibration_common_interfaces::msg::FileReference> laser_scan_sample_files;
std::vector<calibration_common_interfaces::msg::FileReference> target_detection_files;
std::vector<calibration_common_interfaces::msg::FileReference> robot_pose_sample_files;
std::vector<calibration_common_interfaces::msg::FileReference> synchronized_dataset_files;
};
rclcpp::Service<ReadinessSrv>::SharedPtr readiness_service_; rclcpp::Service<ReadinessSrv>::SharedPtr readiness_service_;
rclcpp_action::Server<ExecuteTask>::SharedPtr execute_task_action_server_; rclcpp_action::Server<ExecuteTask>::SharedPtr execute_task_action_server_;
SensorCalibrationAlgorithmTemplate algorithm_; SensorCalibrationAlgorithmTemplate algorithm_;
ReadinessConfig readiness_config_; ReadinessConfig readiness_config_;
DataInputConfig data_input_config_;
ReadinessConfig load_readiness_config(); ReadinessConfig load_readiness_config();
DataInputConfig load_data_input_config();
std::vector<std::string> parse_csv_list(const std::string & raw) const; std::vector<std::string> parse_csv_list(const std::string & raw) const;
void append_issue( void append_issue(
calibration_sensor_interfaces::msg::SensorReadinessResponse & response, calibration_sensor_interfaces::msg::SensorReadinessResponse & response,
@@ -24,6 +24,33 @@ int64_t now_us()
std::chrono::system_clock::now().time_since_epoch()) std::chrono::system_clock::now().time_since_epoch())
.count(); .count();
} }
calibration_common_interfaces::msg::FileReference make_file_reference(
const std::string & file_uri,
const std::string & description)
{
calibration_common_interfaces::msg::FileReference ref;
ref.file_uri = file_uri;
const auto pos = file_uri.find_last_of("/\\");
ref.file_name = pos == std::string::npos ? file_uri : file_uri.substr(pos + 1);
ref.size_bytes = 0;
ref.description = description;
return ref;
}
std::vector<calibration_common_interfaces::msg::FileReference> make_file_references(
const std::vector<std::string> & file_uris,
const std::string & description)
{
std::vector<calibration_common_interfaces::msg::FileReference> refs;
refs.reserve(file_uris.size());
for (const auto & file_uri : file_uris) {
if (!file_uri.empty()) {
refs.push_back(make_file_reference(file_uri, description));
}
}
return refs;
}
} // namespace } // namespace
using calibration_common_interfaces::msg::ErrorCode; using calibration_common_interfaces::msg::ErrorCode;
@@ -33,6 +60,7 @@ SensorCalibrationServiceNode::SensorCalibrationServiceNode(const rclcpp::NodeOpt
: Node("sensor_calibration_service", options) : Node("sensor_calibration_service", options)
{ {
readiness_config_ = load_readiness_config(); readiness_config_ = load_readiness_config();
data_input_config_ = load_data_input_config();
readiness_service_ = create_service<ReadinessSrv>( readiness_service_ = create_service<ReadinessSrv>(
"/sensor_calibration/get_readiness", "/sensor_calibration/get_readiness",
@@ -141,6 +169,46 @@ SensorCalibrationServiceNode::ReadinessConfig SensorCalibrationServiceNode::load
return config; return config;
} }
SensorCalibrationServiceNode::DataInputConfig SensorCalibrationServiceNode::load_data_input_config()
{
DataInputConfig config;
config.data_window_start_timestamp_us =
declare_parameter<int64_t>("data_input.data_window_start_timestamp_us", 0);
config.data_window_end_timestamp_us =
declare_parameter<int64_t>("data_input.data_window_end_timestamp_us", 0);
config.image_sample_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.image_sample_files", std::vector<std::string>{}),
"sensor image samples");
config.imu_sample_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.imu_sample_files", std::vector<std::string>{}),
"sensor imu samples");
config.pointcloud_sample_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.pointcloud_sample_files", std::vector<std::string>{}),
"sensor pointcloud samples");
config.laser_scan_sample_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.laser_scan_sample_files", std::vector<std::string>{}),
"sensor laser scan samples");
config.target_detection_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.target_detection_files", std::vector<std::string>{}),
"sensor calibration target detection results");
config.robot_pose_sample_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.robot_pose_sample_files", std::vector<std::string>{}),
"robot or platform pose samples");
config.synchronized_dataset_files = make_file_references(
declare_parameter<std::vector<std::string>>(
"data_input.synchronized_dataset_files", std::vector<std::string>{}),
"synchronized sensor calibration dataset index");
return config;
}
std::vector<std::string> SensorCalibrationServiceNode::parse_csv_list(const std::string & raw) const std::vector<std::string> SensorCalibrationServiceNode::parse_csv_list(const std::string & raw) const
{ {
std::vector<std::string> items; std::vector<std::string> items;
@@ -265,6 +333,17 @@ void SensorCalibrationServiceNode::execute_goal(
input.chassis_history_available = false; input.chassis_history_available = false;
input.control_history_available = false; input.control_history_available = false;
input.truth_history_available = false; input.truth_history_available = false;
input.data_window_start_timestamp_us = data_input_config_.data_window_start_timestamp_us;
input.data_window_end_timestamp_us = data_input_config_.data_window_end_timestamp_us;
input.image_sample_files = data_input_config_.image_sample_files;
input.imu_sample_files = data_input_config_.imu_sample_files;
input.pointcloud_sample_files = data_input_config_.pointcloud_sample_files;
input.laser_scan_sample_files = data_input_config_.laser_scan_sample_files;
input.target_detection_files = data_input_config_.target_detection_files;
input.robot_pose_sample_files = data_input_config_.robot_pose_sample_files;
input.synchronized_dataset_files = data_input_config_.synchronized_dataset_files;
input.storage_diagnostics.push_back(
"synchronized_dataset_files=" + std::to_string(input.synchronized_dataset_files.size()));
SensorCalibrationAlgorithmTemplate::Output output; SensorCalibrationAlgorithmTemplate::Output output;
std::string failure_reason; std::string failure_reason;
@@ -185,6 +185,7 @@ private:
void finish_session_canceled( void finish_session_canceled(
const std::shared_ptr<GoalHandleExecuteWorkshopSession> goal_handle, const std::shared_ptr<GoalHandleExecuteWorkshopSession> goal_handle,
const SessionExecutionContext & context); const SessionExecutionContext & context);
void append_report_trace_files(WorkshopReport & report) const;
// ─── 事件发布(同时发 topic + action feedback ─── // ─── 事件发布(同时发 topic + action feedback ───
void publish_event( void publish_event(
@@ -223,6 +224,8 @@ private:
std::unique_ptr<Wifi6LinkClient> wifi6_link_client_; std::unique_ptr<Wifi6LinkClient> wifi6_link_client_;
bool wifi6_precheck_enabled_{true}; bool wifi6_precheck_enabled_{true};
bool wifi6_precheck_require_all_links_{true}; bool wifi6_precheck_require_all_links_{true};
std::string data_input_params_file_;
std::string dataset_index_file_;
rclcpp::node_interfaces::OnSetParametersCallbackHandle::SharedPtr parameter_callback_handle_; rclcpp::node_interfaces::OnSetParametersCallbackHandle::SharedPtr parameter_callback_handle_;
// ─── ROS 接口 ─── // ─── ROS 接口 ───
@@ -1,7 +1,10 @@
#include "workshop_orchestrator_v2/workshop_orchestrator_v2_node.hpp" // 对应头文件实现。 #include "workshop_orchestrator_v2/workshop_orchestrator_v2_node.hpp" // 对应头文件实现。
#include <algorithm> // 检查报告文件是否已经存在。
#include <chrono> // sleep_for 需要的时间单位。 #include <chrono> // sleep_for 需要的时间单位。
#include <filesystem> // 规范化现场数据输入和数据集索引路径。
#include <sstream> // 拼接 session_id 时要用字符串流。 #include <sstream> // 拼接 session_id 时要用字符串流。
#include <system_error> // 文件系统查询失败时用 error_code 接住错误。
#include <thread> // 把整场执行放到后台线程时要用 std::thread。 #include <thread> // 把整场执行放到后台线程时要用 std::thread。
#include "calibration_workshop_orchestration_interfaces/msg/approval_state.hpp" #include "calibration_workshop_orchestration_interfaces/msg/approval_state.hpp"
@@ -19,11 +22,106 @@ using calibration_workshop_orchestration_interfaces::srv::GetWorkshopSession; //
using calibration_workshop_orchestration_interfaces::srv::PauseWorkshopSession; // 暂停接口短名。 using calibration_workshop_orchestration_interfaces::srv::PauseWorkshopSession; // 暂停接口短名。
using calibration_workshop_orchestration_interfaces::srv::ResumeWorkshopSession; // 恢复接口短名。 using calibration_workshop_orchestration_interfaces::srv::ResumeWorkshopSession; // 恢复接口短名。
namespace
{
namespace fs = std::filesystem;
std::string normalize_file_uri(const std::string & raw_path)
{
if (raw_path.empty()) {
return "";
}
std::error_code ec;
const fs::path path(raw_path);
const fs::path absolute_path = path.is_absolute() ? path : fs::absolute(path, ec);
if (ec) {
return path.lexically_normal().string();
}
return absolute_path.lexically_normal().string();
}
calibration_common_interfaces::msg::FileReference make_trace_file_reference(
const std::string & raw_path,
const std::string & description)
{
calibration_common_interfaces::msg::FileReference ref;
ref.file_uri = normalize_file_uri(raw_path);
if (ref.file_uri.empty()) {
return ref;
}
const fs::path path(ref.file_uri);
ref.file_name = path.filename().string();
ref.description = description;
std::error_code ec;
if (fs::exists(path, ec) && fs::is_regular_file(path, ec)) {
ref.size_bytes = static_cast<int64_t>(fs::file_size(path, ec));
if (ec) {
ref.size_bytes = 0;
}
}
return ref;
}
std::string infer_dataset_index_file(const std::string & data_input_params_file)
{
const std::string normalized_data_input_file = normalize_file_uri(data_input_params_file);
if (normalized_data_input_file.empty()) {
return "";
}
const fs::path candidate = fs::path(normalized_data_input_file).parent_path() / "dataset_index.yaml";
std::error_code ec;
if (!fs::exists(candidate, ec) || !fs::is_regular_file(candidate, ec)) {
return "";
}
return candidate.lexically_normal().string();
}
void append_metadata_value(
WorkshopReport & report,
const std::string & key,
const std::string & value)
{
if (value.empty()) {
return;
}
calibration_common_interfaces::msg::KeyValuePair kv;
kv.key = key;
kv.value = value;
report.metadata.push_back(kv);
}
void append_report_file(
WorkshopReport & report,
const calibration_common_interfaces::msg::FileReference & ref)
{
if (ref.file_uri.empty()) {
return;
}
const auto duplicate = std::find_if(
report.report_files.begin(),
report.report_files.end(),
[&ref](const auto & existing) {
return existing.file_uri == ref.file_uri;
});
if (duplicate == report.report_files.end()) {
report.report_files.push_back(ref);
}
}
} // namespace
WorkshopOrchestratorV2Node::WorkshopOrchestratorV2Node(const rclcpp::NodeOptions & options) WorkshopOrchestratorV2Node::WorkshopOrchestratorV2Node(const rclcpp::NodeOptions & options)
: Node("workshop_orchestrator_v2", options) // 创建一个名为 workshop_orchestrator_v2 的 ROS 节点。 : Node("workshop_orchestrator_v2", options) // 创建一个名为 workshop_orchestrator_v2 的 ROS 节点。
{ {
wifi6_precheck_enabled_ = declare_parameter<bool>("wifi6_precheck_enabled", true); wifi6_precheck_enabled_ = declare_parameter<bool>("wifi6_precheck_enabled", true);
wifi6_precheck_require_all_links_ = declare_parameter<bool>("wifi6_precheck_require_all_links", true); wifi6_precheck_require_all_links_ = declare_parameter<bool>("wifi6_precheck_require_all_links", true);
data_input_params_file_ = declare_parameter<std::string>("data_input_params_file", "");
dataset_index_file_ = declare_parameter<std::string>("dataset_index_file", "");
Wifi6LinkClient::Config wifi6_config; Wifi6LinkClient::Config wifi6_config;
wifi6_config.vehicle_host = declare_parameter<std::string>("wifi6_vehicle_host", "127.0.0.1"); wifi6_config.vehicle_host = declare_parameter<std::string>("wifi6_vehicle_host", "127.0.0.1");
wifi6_config.vehicle_port = static_cast<uint16_t>(declare_parameter<int>("wifi6_vehicle_port", 9000)); wifi6_config.vehicle_port = static_cast<uint16_t>(declare_parameter<int>("wifi6_vehicle_port", 9000));
@@ -38,6 +136,10 @@ WorkshopOrchestratorV2Node::WorkshopOrchestratorV2Node(const rclcpp::NodeOptions
wifi6_precheck_enabled_ = parameter.as_bool(); wifi6_precheck_enabled_ = parameter.as_bool();
} else if (parameter.get_name() == "wifi6_precheck_require_all_links") { } else if (parameter.get_name() == "wifi6_precheck_require_all_links") {
wifi6_precheck_require_all_links_ = parameter.as_bool(); wifi6_precheck_require_all_links_ = parameter.as_bool();
} else if (parameter.get_name() == "data_input_params_file") {
data_input_params_file_ = parameter.as_string();
} else if (parameter.get_name() == "dataset_index_file") {
dataset_index_file_ = parameter.as_string();
} }
} }
return result; return result;
@@ -1019,6 +1121,23 @@ void WorkshopOrchestratorV2Node::record_stage_result(
} }
} }
void WorkshopOrchestratorV2Node::append_report_trace_files(WorkshopReport & report) const
{
const auto data_input_ref = make_trace_file_reference(
data_input_params_file_,
"本次会话算法数据输入参数文件");
append_report_file(report, data_input_ref);
append_metadata_value(report, "data_input_params_file", data_input_ref.file_uri);
const std::string resolved_dataset_index_file =
dataset_index_file_.empty() ? infer_dataset_index_file(data_input_params_file_) : dataset_index_file_;
const auto dataset_index_ref = make_trace_file_reference(
resolved_dataset_index_file,
"本次会话真实采集数据集索引文件");
append_report_file(report, dataset_index_ref);
append_metadata_value(report, "dataset_index_file", dataset_index_ref.file_uri);
}
void WorkshopOrchestratorV2Node::finish_session_precheck_failed( void WorkshopOrchestratorV2Node::finish_session_precheck_failed(
const std::shared_ptr<GoalHandleExecuteWorkshopSession> goal_handle, const std::shared_ptr<GoalHandleExecuteWorkshopSession> goal_handle,
const SessionExecutionContext & context, const SessionExecutionContext & context,
@@ -1038,6 +1157,7 @@ void WorkshopOrchestratorV2Node::finish_session_precheck_failed(
context.started_timestamp_us, context.started_timestamp_us,
now_us(), now_us(),
record.last_precheck.message.empty() ? "Precheck failed." : record.last_precheck.message); record.last_precheck.message.empty() ? "Precheck failed." : record.last_precheck.message);
append_report_trace_files(record.report);
record.executing = false; record.executing = false;
record.report_ready = true; record.report_ready = true;
publish_event(record, WorkshopEventType::REPORT_READY, "Report ready: precheck failed."); publish_event(record, WorkshopEventType::REPORT_READY, "Report ready: precheck failed.");
@@ -1069,6 +1189,7 @@ void WorkshopOrchestratorV2Node::finish_session_failed(
context.started_timestamp_us, context.started_timestamp_us,
now_us(), now_us(),
failure_reason); failure_reason);
append_report_trace_files(record.report);
record.executing = false; record.executing = false;
record.report_ready = true; record.report_ready = true;
publish_event(record, WorkshopEventType::REPORT_READY, "Report ready: session failed."); publish_event(record, WorkshopEventType::REPORT_READY, "Report ready: session failed.");
@@ -1099,6 +1220,7 @@ void WorkshopOrchestratorV2Node::finish_session_succeeded(
context.started_timestamp_us, context.started_timestamp_us,
now_us(), now_us(),
"Workshop v2 minimal flow succeeded."); "Workshop v2 minimal flow succeeded.");
append_report_trace_files(record.report);
record.executing = false; record.executing = false;
record.report_ready = true; record.report_ready = true;
publish_event(record, WorkshopEventType::REPORT_READY, "Report ready."); publish_event(record, WorkshopEventType::REPORT_READY, "Report ready.");
@@ -1129,6 +1251,7 @@ void WorkshopOrchestratorV2Node::finish_session_canceled(
context.started_timestamp_us, context.started_timestamp_us,
now_us(), now_us(),
"Session canceled."); "Session canceled.");
append_report_trace_files(record.report);
record.executing = false; record.executing = false;
record.report_ready = true; record.report_ready = true;
publish_event(record, WorkshopEventType::SESSION_STATE_CHANGED, "Session canceled."); publish_event(record, WorkshopEventType::SESSION_STATE_CHANGED, "Session canceled.");
@@ -1,18 +1,24 @@
# Simulation To Site Checklist # 从仿真迁移到现场的检查清单
Before promoting a simulation-validated setup to site deployment, verify: 把已经通过仿真验证的配置迁移到真实现场前,需要确认:
- ROS 2 service/action/topic names match the deployment profile.
- `vehicle_id` is stable and checked by gateway and vehicle-side agent.
- `base_link` definition matches the real vehicle, especially rear axle center.
- Sensor frame IDs match the real URDF/TF tree.
- Calibration target dimensions are measured onsite and not copied from defaults.
- Vehicle-side agent owns low-level motion control.
- Workshop PC cannot publish directly to actuator topics.
- Emergency stop was tested from gateway and vehicle-side agent.
- Disconnect or timeout causes vehicle stop.
- WiFi or wired network timeout behavior was tested.
- Vehicle path is clear at the real site.
- First site run is low-speed and supervised.
- Calibration results include vehicle ID, config version, operator, and timestamp.
- ROS 2 service、action、topic 名称与部署 profile 一致。
- `vehicle_id` 稳定,并且 gateway 和车端 agent 都会校验。
- `base_link` 定义与真实车辆一致,尤其是后轴中心位置。
- 传感器 frame ID 与真实 URDF/TF 树一致。
- 标定靶尺寸已在现场实测,不能直接沿用默认值。
- 真实采集/ingest 已生成本次会话专用的 `dataset_index.yaml`
- 已使用 `validate_dataset_index.py` 校验本次 `dataset_index.yaml`
- 已在会话结束时调用收尾工具,从 `dataset_index.yaml` 生成 `site_data_input.yaml`
- 已使用 `run_local_data_input_smoke.sh` 验证数据输入注入和 report 链路。
- 运行真实算法前,已通过 `data_input_params_file` 把数据输入参数文件传给现场 launch。
- 最终 report 的 `report_files` 已包含本次 `site_data_input.yaml``dataset_index.yaml`
- 数据集索引只引用本次会话和本次数据窗口内的文件。
- 车端 agent 负责底层运动控制。
- 车间工控机不能直接向执行器 topic 发布控制指令。
- 已从 gateway 和车端 agent 两侧测试急停。
- 断连或超时会触发车辆停车。
- 已测试 WiFi 或有线网络的超时行为。
- 真实现场的车辆行驶路径已清空。
- 第一次现场运行必须低速并有人监督。
- 标定结果包含车辆 ID、配置版本、操作者和时间戳。
@@ -0,0 +1,56 @@
# 数据集索引样例:真实采集/ingest 每次标定会话应输出同类文件。
# 具体转换命令见 src/docs/algorithm_template_contract.md。
schema_version: 1
session:
session_id: session_001
site_id: site_a
vehicle_id: agv_001
dataset_root: /data/agv_calib/site_a/session_001
data_window_start_timestamp_us: 1770000000000000
data_window_end_timestamp_us: 1770000120000000
data_inputs:
external:
external_observation_files:
- external/external_observations.csv
- external/truth_trajectory.csv
marker_observation_files:
- external/marker_observations.json
time_sync_diagnostic_files:
- external/time_sync_report.json
chassis:
chassis_motion_data_files:
- chassis/chassis_motion.csv
actuator_command_files:
- chassis/actuator_commands.csv
truth_trajectory_files:
- external/truth_trajectory.csv
control:
control_evaluation_data_files:
- control/control_eval.csv
reference_signal_files:
- control/reference_signal.csv
chassis_response_files:
- control/chassis_response.csv
truth_trajectory_files:
- external/truth_trajectory.csv
sensor:
image_sample_files:
- sensor/front_camera/images.yaml
- sensor/down_camera/images.yaml
imu_sample_files:
- sensor/imu/imu_samples.mcap
pointcloud_sample_files:
- sensor/lidar_3d/pointclouds.mcap
laser_scan_sample_files:
- sensor/lidar_2d/scans.mcap
target_detection_files:
- sensor/front_camera/charuco_detections.json
- sensor/down_camera/charuco_detections.json
robot_pose_sample_files:
- sensor/hand_eye/robot_poses.csv
@@ -0,0 +1,64 @@
# ROS 2 参数模板:用于把真实标定采集数据注入算法输入。
#
# 使用方式:
# 启动现场 launch 时通过 data_input_params_file 指向本文件副本。
#
# 采集或数据接入程序应为每次标定会话生成一份本文件的副本,
# 并把数据集索引、rosbag/mcap、CSV/JSON 日志、图像、点云、
# IMU 样本和检测结果等路径写入对应字段。
# 推荐从 dataset_index.yaml 自动生成,转换工具为:
# src/deployment/tools/dataset_index_to_site_data_input.py
external_localization_service:
ros__parameters:
data_input.data_window_start_timestamp_us: 0
data_input.data_window_end_timestamp_us: 0
# data_input.external_observation_files:
# - /data/agv_calib/session_001/external/external_observations.csv
# data_input.marker_observation_files:
# - /data/agv_calib/session_001/external/marker_observations.json
# data_input.time_sync_diagnostic_files:
# - /data/agv_calib/session_001/external/time_sync_report.json
chassis_calibration_service:
ros__parameters:
data_input.data_window_start_timestamp_us: 0
data_input.data_window_end_timestamp_us: 0
# data_input.chassis_motion_data_files:
# - /data/agv_calib/session_001/chassis/chassis_motion.csv
# data_input.actuator_command_files:
# - /data/agv_calib/session_001/chassis/actuator_commands.csv
# data_input.truth_trajectory_files:
# - /data/agv_calib/session_001/external/truth_trajectory.csv
control_calibration_service:
ros__parameters:
data_input.data_window_start_timestamp_us: 0
data_input.data_window_end_timestamp_us: 0
# data_input.control_evaluation_data_files:
# - /data/agv_calib/session_001/control/control_eval.csv
# data_input.reference_signal_files:
# - /data/agv_calib/session_001/control/reference_signal.csv
# data_input.chassis_response_files:
# - /data/agv_calib/session_001/control/chassis_response.csv
# data_input.truth_trajectory_files:
# - /data/agv_calib/session_001/external/truth_trajectory.csv
sensor_calibration_service:
ros__parameters:
data_input.data_window_start_timestamp_us: 0
data_input.data_window_end_timestamp_us: 0
# data_input.image_sample_files:
# - /data/agv_calib/session_001/sensor/front_camera/images.yaml
# data_input.imu_sample_files:
# - /data/agv_calib/session_001/sensor/imu/imu_samples.mcap
# data_input.pointcloud_sample_files:
# - /data/agv_calib/session_001/sensor/lidar_3d/pointclouds.mcap
# data_input.laser_scan_sample_files:
# - /data/agv_calib/session_001/sensor/lidar_2d/scans.mcap
# data_input.target_detection_files:
# - /data/agv_calib/session_001/sensor/front_camera/charuco_detections.json
# data_input.robot_pose_sample_files:
# - /data/agv_calib/session_001/sensor/hand_eye/robot_poses.csv
# data_input.synchronized_dataset_files:
# - /data/agv_calib/session_001/dataset_index.yaml
@@ -36,10 +36,23 @@ external_pose_bridge:
workshop_sensor_ingest: workshop_sensor_ingest:
type: workshop_sensor_ingest type: workshop_sensor_ingest
capture_session_tool: src/site_deployment/workshop_sensor_ingest_real/site_session_capture.py
publish_prefix: /workshop/vehicle_sensor publish_prefix: /workshop/vehicle_sensor
poll_hz: 15.0 poll_hz: 15.0
max_payload_bytes: 4194304 max_payload_bytes: 4194304
algorithm_data_inputs:
# 每次标定会话对应的 ROS 参数文件,由 minimal_workshop_demo.launch.py
# 通过 data_input_params_file:=... 注入。
ros_params_file: /data/agv_calib/replace_with_site_or_line_id/session_xxx/site_data_input.yaml
# 真实采集或数据接入程序应把每次会话的数据集写到这里。
dataset_root: /data/agv_calib/replace_with_site_or_line_id
dataset_index_name: dataset_index.yaml
# 推荐由 dataset_index_to_site_data_input.py 从 dataset_index.yaml 自动生成。
converter: src/deployment/tools/dataset_index_to_site_data_input.py
# 真实采集/ingest 会话结束时推荐调用这个收尾入口。
session_finalizer: src/deployment/tools/finalize_site_session.py
frames: frames:
map: workshop map: workshop
base_link: rear_axle_center base_link: rear_axle_center
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""把真实采集数据集索引转换为算法服务可读取的 ROS 2 参数文件。"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError as exc:
raise SystemExit("缺少 PyYAML,请先在当前 Python 环境安装 yaml 模块。") from exc
URI_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
SERVICE_INPUT_FIELDS = {
"external_localization_service": {
"index_section": "external",
"fields": [
"external_observation_files",
"marker_observation_files",
"time_sync_diagnostic_files",
],
},
"chassis_calibration_service": {
"index_section": "chassis",
"fields": [
"chassis_motion_data_files",
"actuator_command_files",
"truth_trajectory_files",
],
},
"control_calibration_service": {
"index_section": "control",
"fields": [
"control_evaluation_data_files",
"reference_signal_files",
"chassis_response_files",
"truth_trajectory_files",
],
},
"sensor_calibration_service": {
"index_section": "sensor",
"fields": [
"image_sample_files",
"imu_sample_files",
"pointcloud_sample_files",
"laser_scan_sample_files",
"target_detection_files",
"robot_pose_sample_files",
"synchronized_dataset_files",
],
},
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="根据真实采集 dataset_index.yaml 生成 site_data_input.yaml。"
)
parser.add_argument(
"dataset_index",
help="真实采集或 ingest 生成的数据集索引文件。",
)
parser.add_argument(
"-o",
"--output",
help="输出的 ROS 2 参数文件;不填写时输出到标准输出。",
)
parser.add_argument(
"--dataset-root",
help="覆盖索引文件中的 session.dataset_root,用于现场挂载路径重定向。",
)
parser.add_argument(
"--no-index-reference",
action="store_true",
help="不自动把 dataset_index.yaml 写入 sensor 的 synchronized_dataset_files。",
)
parser.add_argument(
"--strict",
action="store_true",
help="严格检查:要求时间窗口有效,并且至少存在一个数据文件引用。",
)
return parser.parse_args()
def load_yaml(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as stream:
data = yaml.safe_load(stream)
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError(f"{path} 的顶层结构必须是 YAML map。")
return data
def is_uri(value: str) -> bool:
return bool(URI_SCHEME_RE.match(value))
def normalize_dataset_root(index_path: Path, raw_root: str | None) -> Path:
if not raw_root:
return index_path.parent
root = Path(raw_root).expanduser()
if root.is_absolute():
return root
return (index_path.parent / root).resolve(strict=False)
def resolve_file_uri(raw_uri: str, dataset_root: Path) -> str:
uri = raw_uri.strip()
if not uri:
return ""
if is_uri(uri):
return uri
path = Path(uri).expanduser()
if path.is_absolute():
return str(path)
return str((dataset_root / path).resolve(strict=False))
def extract_file_uri(entry: Any) -> str:
if isinstance(entry, str):
return entry
if isinstance(entry, dict):
for key in ("file_uri", "uri", "path", "file"):
value = entry.get(key)
if isinstance(value, str):
return value
raise ValueError(f"文件引用条目必须是字符串,或包含 file_uri/uri/path/file 的 map{entry!r}")
def read_file_list(value: Any, dataset_root: Path) -> list[str]:
if value is None:
return []
entries = value if isinstance(value, list) else [value]
result: list[str] = []
for entry in entries:
file_uri = resolve_file_uri(extract_file_uri(entry), dataset_root)
if file_uri and file_uri not in result:
result.append(file_uri)
return result
def read_int(value: Any, field_name: str) -> int:
if value in (None, ""):
return 0
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} 必须是整数微秒时间戳,当前值为 {value!r}") from exc
def module_data(index: dict[str, Any], section_name: str) -> dict[str, Any]:
data_inputs = index.get("data_inputs")
if data_inputs is None:
data_inputs = index.get("files", {})
if not isinstance(data_inputs, dict):
raise ValueError("data_inputs 必须是 YAML map。")
section = data_inputs.get(section_name, {})
if section is None:
return {}
if not isinstance(section, dict):
raise ValueError(f"data_inputs.{section_name} 必须是 YAML map。")
return section
def window_value(session: dict[str, Any], section: dict[str, Any], key: str) -> int:
return read_int(section.get(key, session.get(key, 0)), key)
def build_ros_params(
index_path: Path,
index: dict[str, Any],
dataset_root_override: str | None,
add_index_reference: bool,
) -> dict[str, Any]:
session = index.get("session", {})
if session is None:
session = {}
if not isinstance(session, dict):
raise ValueError("session 必须是 YAML map。")
dataset_root = normalize_dataset_root(
index_path,
dataset_root_override or session.get("dataset_root"),
)
output: dict[str, Any] = {}
for service_name, config in SERVICE_INPUT_FIELDS.items():
section_name = str(config["index_section"])
section = module_data(index, section_name)
params: dict[str, Any] = {
"data_input.data_window_start_timestamp_us": window_value(
session, section, "data_window_start_timestamp_us"
),
"data_input.data_window_end_timestamp_us": window_value(
session, section, "data_window_end_timestamp_us"
),
}
for field_name in config["fields"]:
files = read_file_list(section.get(field_name), dataset_root)
if (
add_index_reference
and section_name == "sensor"
and field_name == "synchronized_dataset_files"
):
index_uri = str(index_path.resolve(strict=False))
if index_uri not in files:
files.append(index_uri)
if files:
params[f"data_input.{field_name}"] = files
output[service_name] = {"ros__parameters": params}
return output
def validate_output(output: dict[str, Any], strict: bool) -> None:
if not strict:
return
file_count = 0
for service_name, node_config in output.items():
params = node_config["ros__parameters"]
start_us = params["data_input.data_window_start_timestamp_us"]
end_us = params["data_input.data_window_end_timestamp_us"]
if start_us <= 0 or end_us <= 0 or end_us <= start_us:
raise ValueError(f"{service_name} 的数据时间窗口无效。")
file_count += sum(
len(value)
for key, value in params.items()
if key.startswith("data_input.") and isinstance(value, list)
)
if file_count == 0:
raise ValueError("严格检查要求至少存在一个数据文件引用。")
def main() -> int:
args = parse_args()
index_path = Path(args.dataset_index).expanduser().resolve(strict=False)
index = load_yaml(index_path)
output = build_ros_params(
index_path,
index,
args.dataset_root,
not args.no_index_reference,
)
validate_output(output, args.strict)
rendered = yaml.safe_dump(output, sort_keys=False, allow_unicode=True)
if args.output:
output_path = Path(args.output).expanduser()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(rendered, encoding="utf-8")
print(f"已生成 ROS 2 数据输入参数文件:{output_path}", file=sys.stderr)
else:
print(rendered, end="")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"[错误] {exc}", file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""收尾真实标定会话,并生成算法服务数据输入参数文件。"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
import yaml
from dataset_index_to_site_data_input import (
build_ros_params,
load_yaml,
validate_output,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="根据现场 profile 和本次会话数据集索引生成 site_data_input.yaml。"
)
parser.add_argument(
"--site-profile",
default="src/deployment/profiles/site_template.yaml",
help="现场部署 profile 路径。",
)
parser.add_argument(
"--session-id",
help="本次标定会话 ID;未提供 --session-dir 时,会拼到 dataset_root 后面。",
)
parser.add_argument(
"--session-dir",
help="本次标定会话目录;优先级高于 --session-id。",
)
parser.add_argument(
"--dataset-index",
help="本次会话 dataset_index.yaml 路径;优先级高于 profile 中的约定。",
)
parser.add_argument(
"-o",
"--output",
help="输出的 site_data_input.yaml 路径;默认写入会话目录。",
)
parser.add_argument(
"--no-strict",
action="store_true",
help="关闭严格检查;默认要求时间窗口有效并且存在数据文件引用。",
)
parser.add_argument(
"--no-index-reference",
action="store_true",
help="不自动把 dataset_index.yaml 写入 sensor 的 synchronized_dataset_files。",
)
return parser.parse_args()
def require_map(value: Any, field_name: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValueError(f"{field_name} 必须是 YAML map。")
return value
def resolve_relative(path_value: str, base_dir: Path) -> Path:
path = Path(path_value).expanduser()
if path.is_absolute():
return path
return (base_dir / path).resolve(strict=False)
def load_site_profile(path: Path) -> dict[str, Any]:
profile = load_yaml(path)
return require_map(profile, "site_profile")
def resolve_session_dir(
profile: dict[str, Any],
profile_path: Path,
session_id: str | None,
session_dir_arg: str | None,
) -> Path:
if session_dir_arg:
return resolve_relative(session_dir_arg, Path.cwd())
data_inputs = require_map(profile.get("algorithm_data_inputs", {}), "algorithm_data_inputs")
dataset_root_raw = data_inputs.get("dataset_root")
if not isinstance(dataset_root_raw, str) or not dataset_root_raw:
raise ValueError("缺少 algorithm_data_inputs.dataset_root。")
dataset_root = resolve_relative(dataset_root_raw, profile_path.parent)
if session_id:
return dataset_root / session_id
return dataset_root
def resolve_dataset_index(
profile: dict[str, Any],
profile_path: Path,
session_dir: Path,
dataset_index_arg: str | None,
) -> Path:
if dataset_index_arg:
return resolve_relative(dataset_index_arg, Path.cwd())
data_inputs = require_map(profile.get("algorithm_data_inputs", {}), "algorithm_data_inputs")
index_name = data_inputs.get("dataset_index_name", "dataset_index.yaml")
if not isinstance(index_name, str) or not index_name:
raise ValueError("algorithm_data_inputs.dataset_index_name 必须是非空字符串。")
index_path = Path(index_name).expanduser()
if index_path.is_absolute():
return index_path
return (session_dir / index_path).resolve(strict=False)
def resolve_output_path(
profile: dict[str, Any],
profile_path: Path,
session_dir: Path,
session_id: str | None,
output_arg: str | None,
) -> Path:
if output_arg:
return resolve_relative(output_arg, Path.cwd())
data_inputs = require_map(profile.get("algorithm_data_inputs", {}), "algorithm_data_inputs")
ros_params_file = data_inputs.get("ros_params_file")
if isinstance(ros_params_file, str) and ros_params_file:
output_value = ros_params_file
if session_id:
output_value = output_value.replace("{session_id}", session_id)
output_value = output_value.replace("session_xxx", session_id)
if "replace_with" not in output_value and "session_xxx" not in output_value:
return resolve_relative(output_value, profile_path.parent)
return (session_dir / "site_data_input.yaml").resolve(strict=False)
def main() -> int:
args = parse_args()
profile_path = Path(args.site_profile).expanduser().resolve(strict=False)
profile = load_site_profile(profile_path)
session_dir = resolve_session_dir(profile, profile_path, args.session_id, args.session_dir)
dataset_index_path = resolve_dataset_index(profile, profile_path, session_dir, args.dataset_index)
output_path = resolve_output_path(profile, profile_path, session_dir, args.session_id, args.output)
index = load_yaml(dataset_index_path)
output = build_ros_params(
dataset_index_path,
index,
str(session_dir),
not args.no_index_reference,
)
validate_output(output, strict=not args.no_strict)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
yaml.safe_dump(output, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
print(f"已完成会话数据输入收尾:{output_path}")
print(f"启动参数:data_input_params_file:={output_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"[错误] {exc}", file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""校验真实采集数据集索引是否满足算法输入合同。"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from dataset_index_to_site_data_input import (
SERVICE_INPUT_FIELDS,
extract_file_uri,
is_uri,
load_yaml,
normalize_dataset_root,
resolve_file_uri,
)
TASK_REQUIREMENTS = {
"external": {
"section": "external",
"any_of": ["external_observation_files", "marker_observation_files"],
},
"chassis": {
"section": "chassis",
"all_of": ["chassis_motion_data_files"],
},
"control": {
"section": "control",
"all_of": ["control_evaluation_data_files"],
},
"sensor_intrinsic": {
"section": "sensor",
"all_of": ["image_sample_files"],
"warn_if_missing": ["target_detection_files"],
},
"sensor_extrinsic": {
"section": "sensor",
"all_of": ["image_sample_files", "robot_pose_sample_files"],
"warn_if_missing": ["target_detection_files"],
},
"hand_eye": {
"section": "sensor",
"all_of": ["image_sample_files", "robot_pose_sample_files"],
},
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="校验 dataset_index.yaml。")
parser.add_argument("dataset_index", help="待校验的数据集索引文件。")
parser.add_argument(
"--tasks",
default="external,chassis,control,sensor_intrinsic",
help="逗号分隔的任务列表,用于检查每个任务的最小数据输入。",
)
parser.add_argument(
"--dataset-root",
help="覆盖索引文件中的 session.dataset_root,用于现场挂载路径重定向。",
)
parser.add_argument(
"--require-existing-files",
action="store_true",
help="要求所有本地文件引用都真实存在;URI 引用只检查格式。",
)
parser.add_argument(
"--no-strict-window",
action="store_true",
help="不强制要求数据窗口大于 0 且结束时间晚于开始时间。",
)
return parser.parse_args()
def parse_tasks(raw_tasks: str) -> list[str]:
tasks: list[str] = []
for item in raw_tasks.split(","):
task = item.strip()
if not task:
continue
if task not in TASK_REQUIREMENTS:
raise ValueError(f"未知任务 {task!r},可选任务:{', '.join(TASK_REQUIREMENTS)}")
tasks.append(task)
if not tasks:
raise ValueError("至少需要指定一个任务。")
return tasks
def known_sections() -> set[str]:
return {str(config["index_section"]) for config in SERVICE_INPUT_FIELDS.values()}
def known_fields_by_section() -> dict[str, set[str]]:
result: dict[str, set[str]] = {}
for config in SERVICE_INPUT_FIELDS.values():
section = str(config["index_section"])
result.setdefault(section, set()).update(str(field) for field in config["fields"])
return result
def require_map(value: Any, field_name: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValueError(f"{field_name} 必须是 YAML map。")
return value
def read_int(value: Any, field_name: str) -> int:
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} 必须是整数,当前值为 {value!r}") from exc
def file_entries(value: Any) -> list[Any]:
if value is None:
return []
return value if isinstance(value, list) else [value]
def resolve_entries(value: Any, dataset_root: Path) -> list[str]:
result: list[str] = []
for entry in file_entries(value):
uri = resolve_file_uri(extract_file_uri(entry), dataset_root)
if uri and uri not in result:
result.append(uri)
return result
def field_has_files(section: dict[str, Any], field_name: str) -> bool:
return bool(file_entries(section.get(field_name)))
def validate_session(
index: dict[str, Any],
strict_window: bool,
) -> dict[str, Any]:
if read_int(index.get("schema_version"), "schema_version") != 1:
raise ValueError("schema_version 必须为 1。")
session = require_map(index.get("session"), "session")
for field_name in ("session_id", "vehicle_id", "dataset_root"):
value = session.get(field_name)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"session.{field_name} 必须是非空字符串。")
start_us = read_int(
session.get("data_window_start_timestamp_us"),
"session.data_window_start_timestamp_us",
)
end_us = read_int(
session.get("data_window_end_timestamp_us"),
"session.data_window_end_timestamp_us",
)
if strict_window and (start_us <= 0 or end_us <= 0 or end_us <= start_us):
raise ValueError("数据窗口无效,要求 start_us > 0 且 end_us > start_us。")
return session
def validate_sections(index: dict[str, Any]) -> dict[str, dict[str, Any]]:
data_inputs = require_map(index.get("data_inputs"), "data_inputs")
valid_sections = known_sections()
valid_fields = known_fields_by_section()
result: dict[str, dict[str, Any]] = {}
for section_name, section_value in data_inputs.items():
if section_name not in valid_sections:
raise ValueError(f"未知数据输入模块 data_inputs.{section_name}")
section = require_map(section_value, f"data_inputs.{section_name}")
for field_name in section:
if field_name not in valid_fields[section_name]:
raise ValueError(f"未知数据输入字段 data_inputs.{section_name}.{field_name}")
result[section_name] = section
for section_name in valid_sections:
result.setdefault(section_name, {})
return result
def validate_task_requirements(
sections: dict[str, dict[str, Any]],
tasks: list[str],
) -> list[str]:
warnings: list[str] = []
for task in tasks:
requirement = TASK_REQUIREMENTS[task]
section_name = str(requirement["section"])
section = sections.get(section_name, {})
for field_name in requirement.get("all_of", []):
if not field_has_files(section, str(field_name)):
raise ValueError(f"任务 {task} 缺少 data_inputs.{section_name}.{field_name}")
any_of = [str(field) for field in requirement.get("any_of", [])]
if any_of and not any(field_has_files(section, field_name) for field_name in any_of):
fields = ", ".join(f"data_inputs.{section_name}.{field_name}" for field_name in any_of)
raise ValueError(f"任务 {task} 至少需要以下字段之一:{fields}")
for field_name in requirement.get("warn_if_missing", []):
if not field_has_files(section, str(field_name)):
warnings.append(f"任务 {task} 未提供 data_inputs.{section_name}.{field_name}")
return warnings
def validate_file_references(
sections: dict[str, dict[str, Any]],
dataset_root: Path,
require_existing_files: bool,
) -> list[str]:
warnings: list[str] = []
for section_name, section in sections.items():
for field_name, value in section.items():
files = resolve_entries(value, dataset_root)
if not files:
warnings.append(f"data_inputs.{section_name}.{field_name} 为空。")
continue
if not require_existing_files:
continue
for file_uri in files:
if is_uri(file_uri):
continue
if not Path(file_uri).exists():
raise ValueError(f"文件不存在:{file_uri}")
return warnings
def main() -> int:
args = parse_args()
index_path = Path(args.dataset_index).expanduser().resolve(strict=False)
index = load_yaml(index_path)
tasks = parse_tasks(args.tasks)
session = validate_session(index, strict_window=not args.no_strict_window)
sections = validate_sections(index)
dataset_root = normalize_dataset_root(index_path, args.dataset_root or session.get("dataset_root"))
warnings = []
warnings.extend(validate_task_requirements(sections, tasks))
warnings.extend(validate_file_references(sections, dataset_root, args.require_existing_files))
print(f"[OK] dataset_index 校验通过: {index_path}")
print(f"[OK] 数据集根目录: {dataset_root}")
print(f"[OK] 任务列表: {', '.join(tasks)}")
for warning in warnings:
print(f"[WARN] {warning}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"[错误] {exc}", file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,653 @@
# 算法模板合同
本文档定义自动化标定车间的算法接入模板边界。它面向两类角色:
- 模板维护者:维护 ROS 2 接口、service/action 节点、算法模板分发、报告汇总和 smoke test。
- 算法实现者:只在指定算法文件中补充具体标定算法、质量评估和产物保存逻辑。
当前模板状态按 `v0.9` 处理:调度框架、输入输出结构和结果回填路径已经可以作为算法开发基线;真实现场数据接入后,如果发现原始数据窗口或产物字段不够,可以由模板维护者小范围扩展 `Input` 结构和接口文档。
## 总原则
算法实现者默认只改各模块 `src/*_algorithm.cpp` 文件,不改 node、launch、gateway、orchestrator、report 和 smoke 主流程。
算法文件必须满足这些约束:
- 保留 `run(const Input &, Output &, std::string & failure_reason)` 入口签名。
- 失败时返回 `false`,同时写清楚 `failure_reason`
- 成功时返回 `true`,并完整填写 `output.response.result`
- 不直接发布 ROS topic,不直接创建 ROS service/client,不绕过模板写报告。
- 不把 Isaac、仿真车辆、真实车辆 SDK、PLC/CAN 代码写进算法文件。
- 需要新增输入字段时,先由模板维护者扩展对应 `Input` 结构,再由 node 层统一填充。
- 需要新增输出字段时,先评估是否已有 `validation_summary``estimated_*``artifacts` 可承载;确实不够时再改接口包。
## 统一执行链路
四个算法域都遵循同一个形态:
```text
orchestrator / gateway
-> service node 组装 Input
-> AlgorithmTemplate 做通用校验和分发
-> 具体 Algorithm::run(...) 执行
-> Output.response.result 回填
-> orchestrator 汇总 stage result
-> report 输出
```
通用校验只检查请求是否完整、任务类型是否明确、必要 ID 是否存在。业务层更细的判断,例如采样数量、图像质量、轨迹长度、时间同步、真值源质量、目标板检测质量,应写在具体算法文件里。
## 稳定边界
这些部分视为模板边界,算法实现者不应修改:
- `src/core/agv_calib_core/workshop_orchestrator/`
- `src/communication/win_ubuntu_bridge/`
- `src/core/agv_calib_core/*_service/src/*_service_node.cpp`
- `src/core/agv_calib_core/*_service/src/*_algorithm_template.cpp`
- `src/core/agv_calib_core/*_service/include/*/*_algorithm_template.hpp`
- `run_isaac_real_sim_test.sh`
- `src/simulation/tools/smoke_test_workshop_orchestrator.py`
这些部分是算法实现者的主要填充位置:
- `src/core/agv_calib_core/external_localization_service/src/reference_pose_collection_algorithm.cpp`
- `src/core/agv_calib_core/external_localization_service/src/marker_alignment_algorithm.cpp`
- `src/core/agv_calib_core/external_localization_service/src/truth_source_validation_algorithm.cpp`
- `src/core/agv_calib_core/chassis_calibration_service/src/ackermann_chassis_algorithm.cpp`
- `src/core/agv_calib_core/chassis_calibration_service/src/differential_chassis_algorithm.cpp`
- `src/core/agv_calib_core/chassis_calibration_service/src/single_steer_wheel_chassis_algorithm.cpp`
- `src/core/agv_calib_core/chassis_calibration_service/src/multi_steer_wheel_chassis_algorithm.cpp`
- `src/core/agv_calib_core/control_calibration_service/src/pid_control_calibration_algorithm.cpp`
- `src/core/agv_calib_core/control_calibration_service/src/mpc_control_calibration_algorithm.cpp`
- `src/core/agv_calib_core/control_calibration_service/src/lqr_control_calibration_algorithm.cpp`
- `src/core/agv_calib_core/control_calibration_service/src/pure_pursuit_control_calibration_algorithm.cpp`
- `src/core/agv_calib_core/sensor_calibration_service/src/camera_intrinsic_calibration_algorithm.cpp`
- `src/core/agv_calib_core/sensor_calibration_service/src/imu_intrinsic_calibration_algorithm.cpp`
- `src/core/agv_calib_core/sensor_calibration_service/src/sensor_to_base_extrinsic_calibration_algorithm.cpp`
- `src/core/agv_calib_core/sensor_calibration_service/src/hand_eye_calibration_algorithm.cpp`
## 统一输出要求
每个算法最终都要写入对应 action result。通用字段含义如下:
- `success`:算法任务是否执行成功。
- `error_code`:失败时必须写非 OK 错误码;成功时写 OK。
- `message`:面向总控、日志和报告的简短结果说明。
- `job_id`:通常沿用请求里的 `request_id`
- `data_quality_passed`:采集数据或执行数据是否满足算法输入质量。
- `suitable_for_commit`:是否建议把本轮结果进入参数提交或人工确认环节。
- `recommended_parameter_version`:本轮推荐结果版本号,必须能用于追踪。
- `validation_summary`:自动验收摘要,是 report 和 stage result 的核心依据。
- `estimated_params` / `estimated_parameter_set` / `result`:本轮估计出的参数或外部定位结果。
- `artifacts`:算法产物引用,例如 YAML、JSON、CSV、图片、波形图、拟合日志和报告片段。
成功但不建议提交时,允许 `success=true``data_quality_passed=true``suitable_for_commit=false`。例如算法完成了评估,但质量指标未达到自动写入阈值。
采集数据不足、真值源不可用、车辆状态不满足、安全条件不满足时,应返回 `false` 并写明 `failure_reason`
## 统一数据输入合同
算法模板的数据输入分为五类。算法实现者只能从这些入口读取数据,不应自己订阅 ROS topic、连接 TCP、读取未声明的临时目录,或绕过 service node 访问仿真/真实设备。
- 任务请求:`input.request` 以及已经展开的 `task_type``task_subtype``required_*``reference_*` 字段,表达“本次要做什么”。
- 静态上下文:`input.workshop_session``input.vehicle_profile`、目标传感器 ID、底盘类型、控制器类型、frame ID,表达“在哪台车、哪个工位、哪个坐标系下做”。
- 轻量在线数据:各模块的 `latest_*_telemetry``*_telemetry_history`,表达“最近一段时间的状态、质量和趋势”。
- 大文件数据:各模块新增的 `*data_files``*sample_files``*trajectory_files``synchronized_dataset_files`,通过 `calibration_common_interfaces/FileReference` 指向图片、点云、IMU 包、CSV、JSON、rosbag、mcap 或数据集索引。
- 诊断与有效性:`*_diagnostics``*_history_available``data_window_start_timestamp_us``data_window_end_timestamp_us`,表达“这些数据是否可信、采样窗口是什么、缺什么”。
大文件数据不直接塞进 action result 或 `Input` 内存对象。模板统一使用 `FileReference` 传递文件引用:
- `file_name`:文件名或数据集逻辑名。
- `file_uri`:本机绝对路径、挂载路径或约定 URI。
- `size_bytes`:可选,未知时填 0。
- `description`:说明数据来源、传感器 ID、时间窗口、格式。
- `digest`:可选,现场归档或验收时建议填写。
算法实现者应按这个顺序选择输入:
1. 优先使用 `synchronized_dataset_files` 或对应模块的数据集索引文件,因为它能表达多传感器、真值、检测结果和时间同步关系。
2. 没有数据集索引时,使用领域内专用文件列表,例如 `image_sample_files``imu_sample_files``chassis_motion_data_files``control_evaluation_data_files`
3. 只做 readiness、链路、轻量统计或 smoke 时,可以使用 `*_telemetry_history`
4. 如果本算法必须使用原始数据,而对应文件列表为空,应返回 `false`,并把缺失项写入 `failure_reason`
数据窗口约定:
- `data_window_start_timestamp_us``data_window_end_timestamp_us` 使用微秒 Unix 时间戳。
- 两者都为 0 表示上层尚未提供明确窗口,算法可以从文件或 telemetry 自行推断。
- 如果只给了 telemetry history,没有文件引用,算法只能做轻量统计或模板级验证,不能宣称完成真实高精度标定。
- 如果文件引用和 telemetry history 同时存在,算法应优先用文件数据求解,用 telemetry history 做质量和安全状态交叉检查。
当前数据填充责任:
- `external_localization_service` 已经缓存外部定位 telemetry history,并将其填入 `ExternalLocalizationInput`
- `external_localization_service``chassis_calibration_service``control_calibration_service``sensor_calibration_service` 都支持通过 ROS 参数 `data_input.*` 传入数据窗口和数据文件路径,并在 node 层转换成 `FileReference` 填入 `Input`
- 真实采集链路接入后,应由采集/ingest 节点负责落盘并把这些参数指向本轮任务的数据集;算法实现者不应自己寻找临时目录。
- gateway 模式下,chassis/control 的真实执行可能在车端 agent 完成;这种情况下车端返回的 `ChassisJobResult` / `ControlJobResult` 也必须按同一套数据输入和产物追溯规则生成。
当前已支持的 ROS 参数如下。所有文件列表参数类型都是 `string[]`,窗口参数类型是 `int64`
External:
- `data_input.data_window_start_timestamp_us`
- `data_input.data_window_end_timestamp_us`
- `data_input.external_observation_files`
- `data_input.marker_observation_files`
- `data_input.time_sync_diagnostic_files`
Chassis:
- `data_input.data_window_start_timestamp_us`
- `data_input.data_window_end_timestamp_us`
- `data_input.chassis_motion_data_files`
- `data_input.actuator_command_files`
- `data_input.truth_trajectory_files`
Control:
- `data_input.data_window_start_timestamp_us`
- `data_input.data_window_end_timestamp_us`
- `data_input.control_evaluation_data_files`
- `data_input.reference_signal_files`
- `data_input.chassis_response_files`
- `data_input.truth_trajectory_files`
Sensor:
- `data_input.data_window_start_timestamp_us`
- `data_input.data_window_end_timestamp_us`
- `data_input.image_sample_files`
- `data_input.imu_sample_files`
- `data_input.pointcloud_sample_files`
- `data_input.laser_scan_sample_files`
- `data_input.target_detection_files`
- `data_input.robot_pose_sample_files`
- `data_input.synchronized_dataset_files`
配置示例:
```yaml
sensor_calibration_service:
ros__parameters:
data_input.data_window_start_timestamp_us: 0
data_input.data_window_end_timestamp_us: 0
data_input.image_sample_files:
- /data/calib/session_001/front_camera/images.yaml
data_input.target_detection_files:
- /data/calib/session_001/front_camera/charuco_detections.json
data_input.synchronized_dataset_files:
- /data/calib/session_001/dataset_index.yaml
```
现场启动时,推荐把这些参数放到一个 session-specific ROS 参数文件里,然后通过 launch 注入:
```bash
ros2 launch win_ubuntu_bridge minimal_workshop_demo.launch.py \
use_gateway:=true \
chassis_host:=192.168.10.42 \
control_host:=192.168.10.42 \
data_input_params_file:=/data/agv_calib/session_001/site_data_input.yaml \
dataset_index_file:=/data/agv_calib/session_001/dataset_index.yaml
```
仓库提供了模板文件:
- `src/deployment/profiles/site_data_input_template.yaml`
真实采集/ingest 程序应为每次 session 生成一份副本,并把本轮采集产物路径写入其中。
### 数据集索引到算法输入参数
真实采集/ingest 的推荐输出是 `dataset_index.yaml`,再由工具转换成
`site_data_input.yaml`。这样采集侧只维护一份数据索引,算法服务仍然只读取
稳定的 `data_input.*` ROS 参数。
数据集索引样例:
- `src/deployment/profiles/example_dataset_index.yaml`
转换工具:
- `src/deployment/tools/dataset_index_to_site_data_input.py`
- `src/deployment/tools/finalize_site_session.py`
真实采集侧脚手架:
- `src/site_deployment/workshop_sensor_ingest_real/site_session_capture.py`
校验与本地回归:
- `src/deployment/tools/validate_dataset_index.py`
- `run_local_data_input_smoke.sh`
真实采集/ingest 在本次会话结束时推荐调用收尾工具。它会读取现场
`site_template.yaml`,按 `algorithm_data_inputs.dataset_root`
`dataset_index_name``session_id` 找到数据集索引,并生成
`site_data_input.yaml`
```bash
python3 src/deployment/tools/finalize_site_session.py \
--site-profile src/deployment/profiles/site_template.yaml \
--session-id session_001
```
底层转换命令也可以直接使用:
```bash
python3 src/deployment/tools/dataset_index_to_site_data_input.py \
/data/agv_calib/session_001/dataset_index.yaml \
-o /data/agv_calib/session_001/site_data_input.yaml \
--strict
```
数据集索引校验命令:
```bash
python3 src/deployment/tools/validate_dataset_index.py \
/data/agv_calib/session_001/dataset_index.yaml \
--tasks external,chassis,control,sensor_intrinsic
```
生成参数文件后,可以用现有 smoke 脚本验证注入链路。若要同时验证
external、chassis、control 和 sensor 四个本地算法服务都读取了同一份
`site_data_input.yaml`,并验证最终 report 记录本次 `site_data_input.yaml`
`dataset_index.yaml`,需要使用本地标定服务模式:
```bash
./run_isaac_real_sim_test.sh \
--headless \
--local-calibration-services \
--data-input-params-file /data/agv_calib/session_001/site_data_input.yaml \
--dataset-index-file /data/agv_calib/session_001/dataset_index.yaml \
--tasks external,chassis,control,sensor_intrinsic
```
不依赖 Isaac 的推荐回归命令:
```bash
./run_local_data_input_smoke.sh \
--data-input-params-file /data/agv_calib/session_001/site_data_input.yaml
```
`dataset_index.yaml` 顶层字段约定如下:
- `schema_version`:索引格式版本,当前为 `1`
- `session.session_id`:本次标定会话 ID。
- `session.vehicle_id`:本次会话对应车辆 ID。
- `session.dataset_root`:本次数据集根目录;相对路径都会基于它解析。
- `session.data_window_start_timestamp_us`:本次数据窗口开始时间,微秒 Unix 时间戳。
- `session.data_window_end_timestamp_us`:本次数据窗口结束时间,微秒 Unix 时间戳。
- `data_inputs.external`:外部定位相关数据。
- `data_inputs.chassis`:底盘标定相关数据。
- `data_inputs.control`:运控标定相关数据。
- `data_inputs.sensor`:传感器标定相关数据。
文件引用字段可以写字符串路径,也可以写成包含 `path``uri``file_uri`
的 map。转换工具会保留 URI,保留绝对路径,并把相对路径解析到
`session.dataset_root` 下。默认情况下,工具还会把 `dataset_index.yaml`
自身写入 `sensor_calibration_service`
`data_input.synchronized_dataset_files`,便于算法实现者追溯完整数据关系。
## 外部定位模板
模板入口:
- `src/core/agv_calib_core/external_localization_service/include/external_localization_service/external_localization_algorithms.hpp`
- `src/core/agv_calib_core/external_localization_service/src/external_localization_algorithm_template.cpp`
分发关系:
- `REFERENCE_POSE_COLLECTION` -> `ReferencePoseCollectionAlgorithm`
- `MARKER_ALIGNMENT` -> `MarkerAlignmentAlgorithm`
- `TRUTH_SOURCE_VALIDATION` -> `TruthSourceValidationAlgorithm`
算法实现者优先读取:
- `input.request`
- `input.task_type`
- `input.reference_pose_collection_task`
- `input.marker_alignment_task`
- `input.truth_source_validation_task`
- `input.localization_source_id`
- `input.workcell_zone_id`
- `input.external_localization_readiness`
- `input.latest_external_localization_telemetry`
- `input.external_localization_telemetry_history`
- `input.chassis_telemetry_history`
- `input.control_telemetry_history`
- `input.sensor_telemetry_history`
- `input.data_window_start_timestamp_us`
- `input.data_window_end_timestamp_us`
- `input.external_observation_files`
- `input.marker_observation_files`
- `input.time_sync_diagnostic_files`
数据输入要求:
- `TRUTH_SOURCE_VALIDATION` 至少需要 `external_localization_telemetry_history``external_observation_files`
- `REFERENCE_POSE_COLLECTION` 至少需要满足采样数量的外部定位观测窗口。
- `MARKER_ALIGNMENT` 至少需要 `marker_observation_files`,或 telemetry 中已经包含可用于对齐的标靶观测结果。
- 如果任务要求时间同步验收,应使用 telemetry 中的 `time_sync_offset_ms`,或使用 `time_sync_diagnostic_files` 中的统计结果。
必须回填:
- `output.response.result.validation_summary.time_sync_ok`
- `output.response.result.validation_summary.coverage_ok`
- `output.response.result.validation_summary.quality_ok`
- `output.response.result.validation_summary.tracking_stable`
- `output.response.result.validation_summary.recommended_as_truth_source`
- `output.response.result.validation_summary.position_stddev_m`
- `output.response.result.validation_summary.yaw_stddev_rad`
- `output.response.result.validation_summary.tracking_loss_ratio`
- `output.response.result.validation_summary.time_sync_offset_ms`
- `output.response.result.result`
- `output.response.result.suitable_for_commit`
- `output.response.result.recommended_parameter_version`
典型失败原因:
- 没有外部定位观测窗口。
- `localization_source_id``workcell_zone_id` 不匹配。
- 时间同步偏差超阈值。
- 位置或航向标准差超阈值。
- 跟踪丢失率过高。
- 标靶观测数量不足。
## 底盘标定模板
模板入口:
- `src/core/agv_calib_core/chassis_calibration_service/include/chassis_calibration_service/chassis_calibration_algorithms.hpp`
- `src/core/agv_calib_core/chassis_calibration_service/src/chassis_calibration_algorithm_template.cpp`
分发关系:
- `ACKERMANN` -> `AckermannChassisAlgorithm`
- `DIFFERENTIAL` -> `DifferentialChassisAlgorithm`
- `SINGLE_STEER_WHEEL` -> `SingleSteerWheelChassisAlgorithm`
- `MULTI_STEER_WHEEL` -> `MultiSteerWheelChassisAlgorithm`
当前本地模板的通用校验只演示 `STRAIGHT_LINE` 动作原语。后续要支持转弯、原地旋转、横移、舵角扫描等原语时,应由模板维护者扩展分发规则和输入展开字段。
算法实现者优先读取:
- `input.request`
- `input.vehicle_profile`
- `input.chassis_type`
- `input.chassis_capability`
- `input.chassis_readiness`
- `input.applied_chassis_parameters`
- `input.latest_chassis_telemetry`
- `input.chassis_telemetry_history`
- `input.latest_external_localization_telemetry`
- `input.external_localization_telemetry_history`
- `input.latest_control_telemetry`
- `input.control_telemetry_history`
- `input.data_window_start_timestamp_us`
- `input.data_window_end_timestamp_us`
- `input.chassis_motion_data_files`
- `input.actuator_command_files`
- `input.truth_trajectory_files`
数据输入要求:
- 底盘真实标定至少需要 `chassis_telemetry_history``chassis_motion_data_files`
- 需要计算真实跑偏、曲率、轮径、轴距、舵角零位时,应同时提供 `truth_trajectory_files``external_localization_telemetry_history`
- 需要分析执行器响应时,应提供 `actuator_command_files` 或控制/驱动遥测历史。
- 如果只有动作原语请求,没有任何运动反馈数据,只能返回模板 smoke 结果,不能作为真实标定结果。
必须回填:
- `output.response.result.estimated_straight_line_bias`
- `output.response.result.validation_summary.max_lateral_error_m`
- `output.response.result.validation_summary.max_yaw_error_rad`
- `output.response.result.validation_summary.rms_lateral_error_m`
- `output.response.result.validation_summary.rms_yaw_error_rad`
- `output.response.result.validation_summary.repeatability_error_m`
- `output.response.result.validation_summary.curvature_error`
- `output.response.result.validation_summary.module_consistency_error`
- `output.response.result.validation_summary.auto_acceptance_passed`
- `output.response.result.estimated_params`
- `output.response.result.suitable_for_commit`
- `output.response.result.recommended_parameter_version`
典型失败原因:
- 底盘未就绪或急停未释放。
- 动作原语不被当前底盘支持。
- 有效运动距离或有效轨迹时间不足。
- 轮速、舵角、里程计、驱动状态缺失。
- 真值源不可用或时间同步不达标。
- 执行过程中触发安全限幅或人工中断。
注意:在当前完整 smoke 的 gateway 模式下,`chassis` 阶段主要验证 `orchestrator -> vehicle_agent_gateway -> 车端 agent -> result` 链路,不等价于已经运行本地 `chassis_calibration_service` 内的算法模板。若现场设计为车端执行底盘算法,应保证车端返回的结果语义与这里的 `ChassisJobResult` 一致。
## 运控标定模板
模板入口:
- `src/core/agv_calib_core/control_calibration_service/include/control_calibration_service/control_calibration_algorithms.hpp`
- `src/core/agv_calib_core/control_calibration_service/src/control_calibration_algorithm_template.cpp`
分发关系:
- `PID` -> `PidControlCalibrationAlgorithm`
- `MPC` -> `MpcControlCalibrationAlgorithm`
- `LQR` -> `LqrControlCalibrationAlgorithm`
- `PURE_PURSUIT` -> `PurePursuitControlCalibrationAlgorithm`
算法实现者优先读取:
- `input.request`
- `input.controller_algorithm`
- `input.task_type`
- `input.trajectory_tracking_task`
- `input.velocity_step_task`
- `input.acceleration_deceleration_task`
- `input.stop_accuracy_task`
- `input.reference_trajectory`
- `input.reference_target_velocity_ms`
- `input.reference_hold_time_sec`
- `input.active_controller_parameters`
- `input.latest_control_telemetry`
- `input.control_telemetry_history`
- `input.latest_chassis_telemetry`
- `input.chassis_telemetry_history`
- `input.latest_external_localization_telemetry`
- `input.external_localization_telemetry_history`
- `input.data_window_start_timestamp_us`
- `input.data_window_end_timestamp_us`
- `input.control_evaluation_data_files`
- `input.reference_signal_files`
- `input.chassis_response_files`
- `input.truth_trajectory_files`
数据输入要求:
- 控制真实评估至少需要 `control_telemetry_history``control_evaluation_data_files`
- 轨迹跟踪类任务必须有 `reference_trajectory``reference_signal_files`
- 速度阶跃、加减速、停车精度任务必须能从请求 payload 或 `reference_signal_files` 还原参考信号。
- 如果要计算横向/航向/速度误差,应提供底盘响应和真值轨迹;没有真值时只能做车端反馈一致性评估。
必须回填:
- `output.response.result.validation_summary.rms_lateral_error_m`
- `output.response.result.validation_summary.rms_heading_error_rad`
- `output.response.result.validation_summary.rms_speed_error_ms`
- `output.response.result.validation_summary.overshoot_ratio`
- `output.response.result.validation_summary.settle_time_sec`
- `output.response.result.validation_summary.stop_position_error_m`
- `output.response.result.validation_summary.max_jerk`
- `output.response.result.validation_summary.saturation_ratio`
- `output.response.result.validation_summary.auto_acceptance_passed`
- `output.response.result.estimated_parameter_set`
- `output.response.result.suitable_for_commit`
- `output.response.result.recommended_parameter_version`
典型失败原因:
- 参考轨迹或速度阶跃任务参数缺失。
- 控制历史窗口不足。
- 底盘执行器受限或长时间饱和。
- 车辆实际响应和控制输出无法对齐。
- 真值定位质量不足,无法计算可靠误差。
- 安全状态不允许继续执行。
注意:在当前完整 smoke 的 gateway 模式下,`control` 阶段主要验证 `orchestrator -> vehicle_agent_gateway -> 车端 agent -> result` 链路,不等价于已经运行本地 `control_calibration_service` 内的算法模板。若现场设计为车端执行控制评估,应保证车端返回的结果语义与这里的 `ControlJobResult` 一致。
## 传感器标定模板
模板入口:
- `src/core/agv_calib_core/sensor_calibration_service/include/sensor_calibration_service/sensor_calibration_algorithms.hpp`
- `src/core/agv_calib_core/sensor_calibration_service/src/sensor_calibration_algorithm_template.cpp`
分发关系:
- `CAMERA_INTRINSIC + FRONT_CAMERA_INTRINSIC` -> `FrontCameraIntrinsicCalibrationAlgorithm`
- `CAMERA_INTRINSIC + DOWNWARD_CAMERA_INTRINSIC` -> `DownwardCameraIntrinsicCalibrationAlgorithm`
- `IMU_INTRINSIC + IMU_INTRINSIC` -> `ImuIntrinsicCalibrationAlgorithm`
- `SENSOR_TO_BASE_EXTRINSIC + FRONT_CAMERA_EXTRINSIC` -> `FrontCameraExtrinsicCalibrationAlgorithm`
- `SENSOR_TO_BASE_EXTRINSIC + DOWNWARD_CAMERA_EXTRINSIC` -> `DownwardCameraExtrinsicCalibrationAlgorithm`
- `SENSOR_TO_BASE_EXTRINSIC + IMU_EXTRINSIC` -> `ImuExtrinsicCalibrationAlgorithm`
- `SENSOR_TO_BASE_EXTRINSIC + LIDAR_2D_EXTRINSIC` -> `Lidar2DExtrinsicCalibrationAlgorithm`
- `SENSOR_TO_BASE_EXTRINSIC + LIDAR_3D_EXTRINSIC` -> `Lidar3DExtrinsicCalibrationAlgorithm`
- `HAND_EYE + EYE_IN_HAND` -> `EyeInHandCalibrationAlgorithm`
- `HAND_EYE + EYE_TO_HAND` -> `EyeToHandCalibrationAlgorithm`
算法实现者优先读取:
- `input.request`
- `input.task_type`
- `input.task_subtype`
- `input.target_sensor_id`
- `input.target_sensor_type`
- `input.camera_intrinsic_task`
- `input.imu_intrinsic_task`
- `input.sensor_to_base_extrinsic_task`
- `input.hand_eye_task`
- `input.required_image_count`
- `input.required_static_segment_count`
- `input.required_motion_segment_count`
- `input.required_sample_count`
- `input.required_pose_count`
- `input.reference_board_id`
- `input.reference_base_frame_id`
- `input.latest_sensor_telemetry`
- `input.sensor_telemetry_history`
- `input.capture_quality_diagnostics`
- `input.storage_diagnostics`
- `input.latest_chassis_telemetry`
- `input.latest_control_telemetry`
- `input.latest_external_localization_telemetry`
- `input.data_window_start_timestamp_us`
- `input.data_window_end_timestamp_us`
- `input.image_sample_files`
- `input.imu_sample_files`
- `input.pointcloud_sample_files`
- `input.laser_scan_sample_files`
- `input.target_detection_files`
- `input.robot_pose_sample_files`
- `input.synchronized_dataset_files`
数据输入要求:
- `front_camera_intrinsic` / `downward_camera_intrinsic` 至少需要 `image_sample_files` 或包含图像列表的 `synchronized_dataset_files`
- `imu_intrinsic` 至少需要 `imu_sample_files`,并能区分静止段和运动段。
- `front_camera_extrinsic` / `downward_camera_extrinsic` 至少需要图像样本和标定目标检测结果;检测结果可由算法内部生成,也可由 `target_detection_files` 提供。
- `lidar_2d_extrinsic` 至少需要 `laser_scan_sample_files` 和对应靶标/角点/平面检测结果。
- `lidar_3d_extrinsic` 至少需要 `pointcloud_sample_files` 和对应靶标/平面/点云配准输入。
- `imu_extrinsic` 至少需要 `imu_sample_files`,并通常需要底盘运动或外部真值轨迹辅助。
- `hand_eye` 至少需要图像/检测结果和 `robot_pose_sample_files`
- 如果只存在 `sensor_telemetry_history`,但没有原始图像/IMU/点云/位姿文件,算法只能判断采集状态,不能完成真实传感器标定。
必须回填:
- `output.response.result.validation_summary.reprojection_error_px`
- `output.response.result.validation_summary.translation_residual_m`
- `output.response.result.validation_summary.rotation_residual_rad`
- `output.response.result.validation_summary.plane_residual_m`
- `output.response.result.validation_summary.repeatability_error_m`
- `output.response.result.validation_summary.auto_acceptance_passed`
- `output.response.result.estimated_params`
- `output.response.result.suitable_for_commit`
- `output.response.result.recommended_parameter_version`
典型失败原因:
- 图像帧、IMU 原始包、点云或机械臂位姿数量不足。
- 标定板 ID 不匹配或检测质量不足。
- 图像模糊、过曝、角点不足、点云稀疏、IMU 饱和。
- 采样窗口时间同步不满足。
- 车辆静止条件或运动激励条件不满足。
- 产物目录不可写或中间数据落盘失败。
当前完整 smoke 只跑 `front_camera_intrinsic` 作为 sensor intrinsic 的最小代表链路。它验证的是任务分发、结果回填和 report 汇总,不代表所有传感器子类型都已经完成真实算法验证。
## 报告和冒烟测试要求
模板维护者需要保证每个算法接入后,最终 report 能读到这些信息:
- 阶段是否成功。
- 自动验收是否通过。
- 推荐参数版本。
- 验证摘要关键指标。
- 产物引用。
- 失败原因或阻塞原因。
当前完整链路 smoke 命令:
```bash
./run_isaac_real_sim_test.sh --headless --tasks external,chassis,control,sensor_intrinsic
```
该 smoke 的定位是链路和合同验证:
- `external`:验证外部定位遥测、readiness、truth validation、stage result 和 report 汇总。
- `chassis`:gateway 模式下验证底盘任务下发、车端 agent 执行结果返回和 stage result。
- `control`:gateway 模式下验证控制评估任务下发、车端 agent 执行结果返回和 stage result。
- `sensor_intrinsic`:验证 sensor service 的 `front_camera_intrinsic` 模板分发和结果回填。
- `report`:验证 `overall_success`、各阶段结果、自动验收字段和 `/workshop_v2/get_report`
smoke 通过不代表真实算法精度达标;真实算法合入后还要补对应模块的单元测试、数据回放测试和现场低速验收。
## 新增算法子类型流程
如果要新增算法类型或子类型,按这个顺序修改:
1. 在对应接口包里新增 task type / subtype 枚举。
2. 在对应 `Input` 结构里补充任务 payload 或展开字段。
3. 在 service node 中把 request payload 展开到 `Input`
4. 在对应 `Input` 结构里补充数据文件引用字段,或复用已有 `FileReference` 字段。
5. 在 service node 中把采集产物、数据窗口和 telemetry history 填入 `Input`
6. 在 `*_algorithm_template.cpp` 中新增分发规则。
7. 新增具体 `*_algorithm.cpp` 文件和类声明。
8. 在 `CMakeLists.txt` 中加入新源文件。
9. 在 smoke 中新增最小任务用例。
10. 在本文档中补充分发关系、数据输入要求和输出要求。
## 交付检查清单
算法实现者提交前至少自查:
- 算法只改了约定的算法文件,或已说明必须改接口/模板的原因。
- `run(...)` 的成功和失败路径都能稳定返回。
- 失败路径写了可定位的 `failure_reason`
- 成功路径填了 `message``data_quality_passed``suitable_for_commit``recommended_parameter_version`
- `validation_summary` 中和本算法相关的字段不是模板默认值。
- 参数结果写到了对应的 `estimated_*``result` 字段。
- 产物路径写入 `artifacts`,路径可追溯。
- 所需输入数据来自 `Input` 中的 telemetry history 或 `FileReference`,没有私自读取未声明路径。
- 必需数据为空时返回 `false`,并在 `failure_reason` 中指出缺少哪个输入。
- 不依赖 Isaac 专有 topic 或真实车辆 SDK 专有代码。
- 本地 build 通过。
- 相关 smoke 或回放测试通过。
模板维护者提交前至少自查:
- 新增字段在 node 层被统一填充。
- 所有新增枚举都有模板分发规则。
- 数据窗口、telemetry history 和 `FileReference` 产物引用有明确来源。
- 旧 smoke 不被破坏。
- report 能看到新增算法的关键结果。
- 文档中的文件路径、字段名和代码一致。
@@ -2,6 +2,7 @@ import argparse
import json import json
import math import math
import os import os
import signal
import time import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
@@ -32,6 +33,7 @@ except ImportError:
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(description="Isaac 标定车间构建脚本") parser = argparse.ArgumentParser(description="Isaac 标定车间构建脚本")
parser.add_argument("--headless", action="store_true", help="以无界面模式启动 Isaac Sim") parser.add_argument("--headless", action="store_true", help="以无界面模式启动 Isaac Sim")
parser.add_argument("--skip-final-close", action="store_true", help="退出时不调用 SimulationApp.close(),用于定位 Isaac 关闭阶段崩溃")
parser.add_argument("--room-length", type=float, default=10.0, help="车间长度(米)") parser.add_argument("--room-length", type=float, default=10.0, help="车间长度(米)")
parser.add_argument("--room-width", type=float, default=6.0, help="车间宽度(米)") parser.add_argument("--room-width", type=float, default=6.0, help="车间宽度(米)")
parser.add_argument("--room-height", type=float, default=3.5, help="车间高度(米)") parser.add_argument("--room-height", type=float, default=3.5, help="车间高度(米)")
@@ -161,6 +163,52 @@ def parse_args():
ARGS = parse_args() ARGS = parse_args()
SCRIPT_PATH = Path(__file__).resolve() SCRIPT_PATH = Path(__file__).resolve()
SHUTDOWN_SIGNAL = None
SHUTDOWN_REQUESTED = False
def _record_shutdown_signal(signum, _frame):
global SHUTDOWN_SIGNAL, SHUTDOWN_REQUESTED
SHUTDOWN_SIGNAL = signum
SHUTDOWN_REQUESTED = True
print(f"\n[INFO] 收到退出信号: {signal.Signals(signum).name}({signum})", flush=True)
def install_shutdown_signal_handlers():
signal.signal(signal.SIGINT, _record_shutdown_signal)
signal.signal(signal.SIGTERM, _record_shutdown_signal)
def ros_context_is_shutdown():
return rclpy is not None and not rclpy.ok()
def is_ros_context_invalid_error(exc):
text = str(exc)
return "publisher's context is invalid" in text or "context is invalid" in text
install_shutdown_signal_handlers()
def patch_pillow_compat_for_isaac_extensions():
try:
import PIL._util as pil_util
except Exception as exc:
print(f"[WARN] Pillow 兼容补丁跳过: {exc}")
return
if hasattr(pil_util, "is_directory"):
return
def is_directory(path):
return isinstance(path, (bytes, str, os.PathLike)) and os.path.isdir(path)
pil_util.is_directory = is_directory
print("[*] 已补齐 PIL._util.is_directory 兼容接口,用于 Isaac UI 测试扩展加载。")
patch_pillow_compat_for_isaac_extensions()
def find_project_root(script_path): def find_project_root(script_path):
@@ -566,6 +614,12 @@ class VehicleTfPublisher:
t.header.stamp = timestamp t.header.stamp = timestamp
t.header.frame_id = frame_id t.header.frame_id = frame_id
t.child_frame_id = child_frame_id t.child_frame_id = child_frame_id
x = float(x)
y = float(y)
z = float(z)
roll = float(roll)
pitch = float(pitch)
yaw = float(yaw)
t.transform.translation.x = x t.transform.translation.x = x
t.transform.translation.y = y t.transform.translation.y = y
t.transform.translation.z = z t.transform.translation.z = z
@@ -578,10 +632,10 @@ class VehicleTfPublisher:
cr = math.cos(roll * 0.5) cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5) sr = math.sin(roll * 0.5)
t.transform.rotation.w = cr * cp * cy + sr * sp * sy t.transform.rotation.w = float(cr * cp * cy + sr * sp * sy)
t.transform.rotation.x = sr * cp * cy - cr * sp * sy t.transform.rotation.x = float(sr * cp * cy - cr * sp * sy)
t.transform.rotation.y = cr * sp * cy + sr * cp * sy t.transform.rotation.y = float(cr * sp * cy + sr * cp * sy)
t.transform.rotation.z = cr * cp * sy - sr * sp * cy t.transform.rotation.z = float(cr * cp * sy - sr * sp * cy)
return t return t
@@ -627,7 +681,7 @@ class VehicleTfPublisher:
position, quat = agv.get_world_pose() position, quat = agv.get_world_pose()
# 四元数 (w, x, y, z) 转欧拉角(用于调试) # 四元数 (w, x, y, z) 转欧拉角(用于调试)
qw, qx, qy, qz = quat qw, qx, qy, qz = (float(quat[0]), float(quat[1]), float(quat[2]), float(quat[3]))
yaw = math.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)) yaw = math.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz))
# 发布 world -> base_link 动态 TF # 发布 world -> base_link 动态 TF
@@ -639,10 +693,10 @@ class VehicleTfPublisher:
timestamp timestamp
) )
# 使用原始四元数覆盖(更精确) # 使用原始四元数覆盖(更精确)
t.transform.rotation.w = qw t.transform.rotation.w = float(qw)
t.transform.rotation.x = qx t.transform.rotation.x = float(qx)
t.transform.rotation.y = qy t.transform.rotation.y = float(qy)
t.transform.rotation.z = qz t.transform.rotation.z = float(qz)
self.tf_broadcaster.sendTransform(t) self.tf_broadcaster.sendTransform(t)
self.last_publish_time = now self.last_publish_time = now
@@ -991,14 +1045,14 @@ class ChassisCalibrationTelemetryPublisher:
msg.odom_x_m = float(position[0] + self.rng.normal(0.0, self.odom_noise_stddev_m)) msg.odom_x_m = float(position[0] + self.rng.normal(0.0, self.odom_noise_stddev_m))
msg.odom_y_m = float(position[1] + self.rng.normal(0.0, self.odom_noise_stddev_m)) msg.odom_y_m = float(position[1] + self.rng.normal(0.0, self.odom_noise_stddev_m))
msg.odom_yaw_rad = float(normalize_angle(yaw_rad + self.rng.normal(0.0, self.yaw_noise_stddev_rad))) msg.odom_yaw_rad = float(normalize_angle(yaw_rad + self.rng.normal(0.0, self.yaw_noise_stddev_rad)))
msg.linear_velocity_ms = forward_velocity msg.linear_velocity_ms = float(forward_velocity)
msg.angular_velocity_rads = yaw_rate msg.angular_velocity_rads = float(yaw_rate)
msg.modules = self.build_modules(forward_velocity, yaw_rate, dt) msg.modules = self.build_modules(forward_velocity, yaw_rate, dt)
msg.estop_engaged = False msg.estop_engaged = False
msg.driver_error_code = 0 msg.driver_error_code = 0
msg.active_job_id = "isaac_chassis_calibration" msg.active_job_id = "isaac_chassis_calibration"
msg.lateral_slip_estimate = lateral_velocity msg.lateral_slip_estimate = float(lateral_velocity)
msg.curvature_estimate = yaw_rate / forward_velocity if abs(forward_velocity) > 0.02 else 0.0 msg.curvature_estimate = float(yaw_rate / forward_velocity if abs(forward_velocity) > 0.02 else 0.0)
self.publisher.publish(msg) self.publisher.publish(msg)
self.last_publish_wall_time = now self.last_publish_wall_time = now
@@ -1049,25 +1103,25 @@ class ControlCalibrationTelemetryPublisher:
forward_axis = np.array([math.cos(yaw_rad), math.sin(yaw_rad)]) forward_axis = np.array([math.cos(yaw_rad), math.sin(yaw_rad)])
forward_velocity = float(np.dot(np.array([linear_velocity[0], linear_velocity[1]]), forward_axis)) forward_velocity = float(np.dot(np.array([linear_velocity[0], linear_velocity[1]]), forward_axis))
lateral_error = float(position[1] - self.reference_y_m) lateral_error = float(position[1] - self.reference_y_m)
heading_error = normalize_angle(yaw_rad) heading_error = float(normalize_angle(yaw_rad))
speed_error = forward_velocity - self.reference_speed_ms speed_error = float(forward_velocity - self.reference_speed_ms)
steering_output = clamp(-0.8 * lateral_error - 1.2 * heading_error, -1.0, 1.0) steering_output = float(clamp(-0.8 * lateral_error - 1.2 * heading_error, -1.0, 1.0))
throttle_output = clamp(-2.0 * speed_error, 0.0, 1.0) throttle_output = float(clamp(-2.0 * speed_error, 0.0, 1.0))
brake_output = clamp(2.0 * speed_error, 0.0, 1.0) brake_output = float(clamp(2.0 * speed_error, 0.0, 1.0))
msg = ControlTelemetry() msg = ControlTelemetry()
msg.hardware_timestamp_us = time.time_ns() // 1000 msg.hardware_timestamp_us = time.time_ns() // 1000
msg.odom_x_m = float(position[0]) msg.odom_x_m = float(position[0])
msg.odom_y_m = float(position[1]) msg.odom_y_m = float(position[1])
msg.odom_yaw_rad = float(yaw_rad) msg.odom_yaw_rad = float(yaw_rad)
msg.linear_velocity_ms = forward_velocity msg.linear_velocity_ms = float(forward_velocity)
msg.angular_velocity_rads = float(angular_velocity[2]) msg.angular_velocity_rads = float(angular_velocity[2])
msg.lateral_error_m = lateral_error msg.lateral_error_m = float(lateral_error)
msg.heading_error_rad = heading_error msg.heading_error_rad = float(heading_error)
msg.speed_error_ms = speed_error msg.speed_error_ms = float(speed_error)
msg.steering_output = steering_output msg.steering_output = float(steering_output)
msg.throttle_output = throttle_output msg.throttle_output = float(throttle_output)
msg.brake_output = brake_output msg.brake_output = float(brake_output)
msg.saturation_flag = abs(steering_output) > 0.98 or throttle_output > 0.98 or brake_output > 0.98 msg.saturation_flag = abs(steering_output) > 0.98 or throttle_output > 0.98 or brake_output > 0.98
msg.active_job_id = "isaac_control_calibration" msg.active_job_id = "isaac_control_calibration"
msg.parameter_version = self.parameter_version msg.parameter_version = self.parameter_version
@@ -1674,7 +1728,7 @@ class IsaacWorkshopRuntime:
if not self.args.disable_vehicle_imu: if not self.args.disable_vehicle_imu:
imu_parent_path = self.find_vehicle_rigid_body_prim_path(["base_link", "imu_link"]) imu_parent_path = self.find_vehicle_rigid_body_prim_path(["base_link", "imu_link"])
if not imu_parent_path: if not imu_parent_path:
print("[WARN] 未找到可挂载 IMU 的车辆刚体 link,跳过 Isaac IMU sensor prim 创建") print("[INFO] 未找到可挂载 IMU 的车辆刚体 link,跳过 Isaac IMU sensor primIMU ROS topic 继续由车体运动学发布器输出")
imu_parent_path = "" imu_parent_path = ""
imu_path = f"{imu_parent_path}/SimImu" if imu_parent_path else "" imu_path = f"{imu_parent_path}/SimImu" if imu_parent_path else ""
if imu_parent_path: if imu_parent_path:
@@ -1893,6 +1947,8 @@ def main():
sensor_telemetry_publisher = SensorCalibrationTelemetryPublisher(ARGS) sensor_telemetry_publisher = SensorCalibrationTelemetryPublisher(ARGS)
world = runtime.build_workshop() world = runtime.build_workshop()
vehicle_laser_scan_publisher = VehicleLaserScanTopicPublisher(ARGS, runtime.lidar_2d_target_specs) vehicle_laser_scan_publisher = VehicleLaserScanTopicPublisher(ARGS, runtime.lidar_2d_target_specs)
# rclpy.init() may install its own signal handlers; restore ours before entering the long-running loop.
install_shutdown_signal_handlers()
runtime.write_scene_manifest() runtime.write_scene_manifest()
world.reset() world.reset()
@@ -1934,8 +1990,32 @@ def main():
if ARGS.headless and render_sensor_outputs: if ARGS.headless and render_sensor_outputs:
print("[*] headless 模式下启用离屏渲染,用于相机和 RTX LiDAR render product 发布。") print("[*] headless 模式下启用离屏渲染,用于相机和 RTX LiDAR render product 发布。")
frame_count = 0
loop_started_wall_time = time.time()
exit_reason = "unknown"
ros_context_shutdown_seen = False
ros_publishers = (
(telemetry_publisher, (agv,)),
(vehicle_tf_publisher, (agv,)),
(vehicle_imu_publisher, (agv,)),
(vehicle_laser_scan_publisher, (agv,)),
(chassis_telemetry_publisher, (agv,)),
(control_telemetry_publisher, (agv,)),
(sensor_telemetry_publisher, ()),
)
ros_context_required = any(getattr(publisher, "node", None) is not None for publisher, _ in ros_publishers)
try: try:
while simulation_app.is_running(): while True:
if SHUTDOWN_REQUESTED:
exit_reason = f"收到退出信号 {signal.Signals(SHUTDOWN_SIGNAL).name if SHUTDOWN_SIGNAL else 'UNKNOWN'}"
break
if ros_context_required and ros_context_is_shutdown():
ros_context_shutdown_seen = True
exit_reason = "ROS context 已关闭"
break
if not simulation_app.is_running():
exit_reason = "simulation_app.is_running() returned False"
break
try: try:
lin_vel = og.Controller.get(og.Controller.attribute("/World/ROS2_Twist_Graph/TwistSub.outputs:linearVelocity")) lin_vel = og.Controller.get(og.Controller.attribute("/World/ROS2_Twist_Graph/TwistSub.outputs:linearVelocity"))
ang_vel = og.Controller.get(og.Controller.attribute("/World/ROS2_Twist_Graph/TwistSub.outputs:angularVelocity")) ang_vel = og.Controller.get(og.Controller.attribute("/World/ROS2_Twist_Graph/TwistSub.outputs:angularVelocity"))
@@ -1961,14 +2041,37 @@ def main():
print(f"\n[ERROR] 运动学控制循环异常: {exc}") print(f"\n[ERROR] 运动学控制循环异常: {exc}")
world.step(render=render_frame) world.step(render=render_frame)
telemetry_publisher.publish(agv) try:
vehicle_tf_publisher.publish(agv) for publisher, publish_args in ros_publishers:
vehicle_imu_publisher.publish(agv) if SHUTDOWN_REQUESTED:
vehicle_laser_scan_publisher.publish(agv) exit_reason = f"收到退出信号 {signal.Signals(SHUTDOWN_SIGNAL).name if SHUTDOWN_SIGNAL else 'UNKNOWN'}"
chassis_telemetry_publisher.publish(agv) break
control_telemetry_publisher.publish(agv) if ros_context_required and ros_context_is_shutdown():
sensor_telemetry_publisher.publish() ros_context_shutdown_seen = True
exit_reason = "ROS context 已关闭"
break
publisher.publish(*publish_args)
else:
frame_count += 1
continue
break
except Exception as exc:
if is_ros_context_invalid_error(exc):
ros_context_shutdown_seen = True
exit_reason = "ROS context 已关闭,停止发布"
break
raise
except Exception as exc:
exit_reason = f"未捕获异常: {type(exc).__name__}: {exc}"
print(f"\n[ERROR] Isaac 主循环异常退出: {exit_reason}", flush=True)
raise
finally: finally:
elapsed_sec = time.time() - loop_started_wall_time
print(
f"\n[*] Isaac 主循环退出: reason={exit_reason}, "
f"frames={frame_count}, elapsed_sec={elapsed_sec:.3f}",
flush=True,
)
telemetry_publisher.shutdown() telemetry_publisher.shutdown()
vehicle_tf_publisher.shutdown() vehicle_tf_publisher.shutdown()
vehicle_imu_publisher.shutdown() vehicle_imu_publisher.shutdown()
@@ -1978,6 +2081,13 @@ def main():
sensor_telemetry_publisher.shutdown() sensor_telemetry_publisher.shutdown()
if rclpy is not None and rclpy.ok(): if rclpy is not None and rclpy.ok():
rclpy.shutdown() rclpy.shutdown()
if SHUTDOWN_REQUESTED or ros_context_shutdown_seen:
print("[*] 停止信号/ROS context 关闭期间跳过 SimulationApp.close(),避免 Isaac 退出阶段崩溃。", flush=True)
os._exit(0)
elif ARGS.skip_final_close:
print("[*] 已按 --skip-final-close 跳过 SimulationApp.close()。", flush=True)
else:
print("[*] 正在调用 SimulationApp.close()。如这里发生 segfault,请加 --skip-final-close 复测。", flush=True)
simulation_app.close() simulation_app.close()
@@ -11,6 +11,7 @@ import time
from typing import Any from typing import Any
import rclpy import rclpy
from rclpy.executors import ExternalShutdownException
try: try:
from calibration_external_localization_interfaces.msg import ExternalLocalizationTelemetry from calibration_external_localization_interfaces.msg import ExternalLocalizationTelemetry
@@ -127,7 +128,7 @@ class ExternalPoseWifi6Bridge:
except Exception as exc: except Exception as exc:
self.fail_count += 1 self.fail_count += 1
now = time.monotonic() now = time.monotonic()
if now - self.last_error_log_monotonic >= self.args.error_log_interval_sec: if rclpy.ok() and now - self.last_error_log_monotonic >= self.args.error_log_interval_sec:
self.last_error_log_monotonic = now self.last_error_log_monotonic = now
self.node.get_logger().warning( self.node.get_logger().warning(
f"failed to push external pose over wifi6_sim_tcp: {exc}; fail_count={self.fail_count}" f"failed to push external pose over wifi6_sim_tcp: {exc}; fail_count={self.fail_count}"
@@ -153,7 +154,7 @@ def main() -> int:
try: try:
bridge = ExternalPoseWifi6Bridge(args) bridge = ExternalPoseWifi6Bridge(args)
rclpy.spin(bridge.node) rclpy.spin(bridge.node)
except KeyboardInterrupt: except (KeyboardInterrupt, ExternalShutdownException):
pass pass
finally: finally:
if bridge is not None: if bridge is not None:
@@ -556,6 +556,14 @@ class OrchestratorSmoke:
def print_report(report) -> None: def print_report(report) -> None:
print(f"[REPORT] session_id={report.session_id} overall_success={report.overall_success}") print(f"[REPORT] session_id={report.session_id} overall_success={report.overall_success}")
print(f"[REPORT] summary={report.summary}") print(f"[REPORT] summary={report.summary}")
for file_ref in report.report_files:
print(
"[REPORT_FILE] "
f"name={file_ref.file_name} "
f"uri={file_ref.file_uri} "
f"size={file_ref.size_bytes} "
f"description={file_ref.description}"
)
for result in report.stage_results: for result in report.stage_results:
print( print(
"[STAGE] " "[STAGE] "
@@ -12,6 +12,7 @@ import time
from typing import Any from typing import Any
import rclpy import rclpy
from rclpy.executors import ExternalShutdownException
from sensor_msgs.msg import Image, Imu, LaserScan, PointCloud2, PointField from sensor_msgs.msg import Image, Imu, LaserScan, PointCloud2, PointField
@@ -26,6 +27,11 @@ PAYLOAD_LASER_SCAN = 3
PAYLOAD_IMU = 4 PAYLOAD_IMU = 4
def is_shutdown_context_error(exc: Exception) -> bool:
text = str(exc)
return "publisher's context is invalid" in text or "context is invalid" in text
def read_exactly(conn: socket.socket, size: int) -> bytes: def read_exactly(conn: socket.socket, size: int) -> bytes:
chunks: list[bytes] = [] chunks: list[bytes] = []
remaining = size remaining = size
@@ -107,6 +113,8 @@ class WorkshopSensorIngestSim:
) )
def log_error(self, message: str) -> None: def log_error(self, message: str) -> None:
if not rclpy.ok():
return
self.fail_count += 1 self.fail_count += 1
now = time.monotonic() now = time.monotonic()
if now - self.last_error_log_monotonic >= self.args.error_log_interval_sec: if now - self.last_error_log_monotonic >= self.args.error_log_interval_sec:
@@ -161,12 +169,18 @@ class WorkshopSensorIngestSim:
self.node.get_logger().info(f"publish {sensor_id} frames to {topic}") self.node.get_logger().info(f"publish {sensor_id} frames to {topic}")
def poll_once(self) -> None: def poll_once(self) -> None:
if not rclpy.ok():
return
try: try:
self.refresh_sensors() self.refresh_sensors()
for sensor_id in list(self.sensors): for sensor_id in list(self.sensors):
if not rclpy.ok():
return
frame = self.fetch_latest_frame(sensor_id) frame = self.fetch_latest_frame(sensor_id)
self.publish_frame(frame) self.publish_frame(frame)
except Exception as exc: except Exception as exc:
if not rclpy.ok() or is_shutdown_context_error(exc):
return
self.log_error(str(exc)) self.log_error(str(exc))
def fetch_latest_frame(self, sensor_id: str) -> dict[str, Any]: def fetch_latest_frame(self, sensor_id: str) -> dict[str, Any]:
@@ -342,12 +356,14 @@ def parse_args() -> argparse.Namespace:
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
rclpy.init(args=None) rclpy.init(args=None)
ingest = WorkshopSensorIngestSim(args) ingest = None
try: try:
ingest = WorkshopSensorIngestSim(args)
rclpy.spin(ingest.node) rclpy.spin(ingest.node)
except KeyboardInterrupt: except (KeyboardInterrupt, ExternalShutdownException):
pass pass
finally: finally:
if ingest is not None:
ingest.node.destroy_node() ingest.node.destroy_node()
if rclpy.ok(): if rclpy.ok():
rclpy.shutdown() rclpy.shutdown()
@@ -4,6 +4,31 @@
现场部署代码应该复用仿真阶段已经验证过的外部接口语义,但需要把仿真适配器替换成真实适配器。 现场部署代码应该复用仿真阶段已经验证过的外部接口语义,但需要把仿真适配器替换成真实适配器。
真实采集链路还需要为每次标定 session 生成数据集索引文件。推荐先输出
`dataset_index.yaml`,然后在会话结束时调用
`src/deployment/tools/finalize_site_session.py` 生成 `site_data_input.yaml`,最后通过
`minimal_workshop_demo.launch.py data_input_params_file:=... dataset_index_file:=...`
注入到算法服务和最终报告。
索引格式样例见 `src/deployment/profiles/example_dataset_index.yaml`
如果真实采集程序还没有自己的索引写入逻辑,可以先使用
`src/site_deployment/workshop_sensor_ingest_real/site_session_capture.py` 登记采集文件并触发收尾。
收尾命令示例:
```bash
python3 src/deployment/tools/finalize_site_session.py \
--site-profile src/deployment/profiles/site_template.yaml \
--session-id session_001
```
本地回归命令:
```bash
./run_local_data_input_smoke.sh \
--data-input-params-file /data/agv_calib/session_001/site_data_input.yaml \
--dataset-index /data/agv_calib/session_001/dataset_index.yaml
```
当前边界: 当前边界:
- `vehicle_agent_real`:真实车端电脑适配器。 - `vehicle_agent_real`:真实车端电脑适配器。
@@ -0,0 +1,63 @@
# 真实采集会话接入
这个目录放真实车间侧采集/ingest 的接入脚手架。它不直接绑定具体相机、雷达、
底盘或控制器 SDK,只固定一件事:真实采集程序在采到文件后,把文件路径登记到
`dataset_index.yaml`,并在会话结束时生成 `site_data_input.yaml`
最小使用示例:
```bash
python3 src/site_deployment/workshop_sensor_ingest_real/site_session_capture.py \
--site-profile src/deployment/profiles/site_template.yaml \
--session-dir /data/agv_calib/site_a/session_001 \
--site-id site_a \
--vehicle-id agv_001 \
--file external.external_observation_files=external/external_observations.csv \
--file chassis.chassis_motion_data_files=chassis/chassis_motion.csv \
--file control.control_evaluation_data_files=control/control_eval.csv \
--file sensor.image_sample_files=sensor/front_camera/images.yaml \
--file sensor.target_detection_files=sensor/front_camera/charuco_detections.json \
--finalize
```
执行后会生成:
- `dataset_index.yaml`
- `site_data_input.yaml`
校验 `dataset_index.yaml`
```bash
python3 src/deployment/tools/validate_dataset_index.py \
/data/agv_calib/site_a/session_001/dataset_index.yaml \
--tasks external,chassis,control,sensor_intrinsic
```
现场 launch 使用 `site_data_input.yaml`
```bash
ros2 launch win_ubuntu_bridge minimal_workshop_demo.launch.py \
use_gateway:=true \
data_input_params_file:=/data/agv_calib/site_a/session_001/site_data_input.yaml
```
使用现有 Isaac smoke 验证四个算法服务的数据输入注入:
```bash
./run_isaac_real_sim_test.sh \
--headless \
--local-calibration-services \
--data-input-params-file /data/agv_calib/site_a/session_001/site_data_input.yaml \
--tasks external,chassis,control,sensor_intrinsic
```
更快的本地回归命令:
```bash
./run_local_data_input_smoke.sh \
--data-input-params-file /data/agv_calib/site_a/session_001/site_data_input.yaml
```
真实采集程序后续只需要在对应设备数据落盘后调用本脚本,或者直接复用本脚本里的
索引写入逻辑。具体标定算法不需要知道采集程序如何连接设备,只读取算法模板里的
`data_input.*` 文件引用。
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""真实采集会话索引写入入口。"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[3]
DEPLOYMENT_TOOLS = PROJECT_ROOT / "src" / "deployment" / "tools"
if str(DEPLOYMENT_TOOLS) not in sys.path:
sys.path.insert(0, str(DEPLOYMENT_TOOLS))
from dataset_index_to_site_data_input import SERVICE_INPUT_FIELDS, load_yaml
from finalize_site_session import (
load_site_profile,
resolve_dataset_index,
resolve_output_path,
resolve_session_dir,
)
def now_us() -> int:
return int(datetime.now(timezone.utc).timestamp() * 1_000_000)
def default_session_id() -> str:
return datetime.now().strftime("session_%Y%m%d_%H%M%S")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="把真实采集产物登记到 dataset_index.yaml,并可选生成 site_data_input.yaml。"
)
parser.add_argument(
"--site-profile",
default="src/deployment/profiles/site_template.yaml",
help="现场部署 profile 路径。",
)
parser.add_argument(
"--session-id",
default="",
help="本次标定会话 ID;不填写时自动生成。",
)
parser.add_argument(
"--session-dir",
help="本次标定会话目录;优先级高于 --session-id。",
)
parser.add_argument(
"--site-id",
default="",
help="现场或产线 ID。",
)
parser.add_argument(
"--vehicle-id",
default="",
help="车辆 ID;默认读取 site profile 的 vehicle_id。",
)
parser.add_argument(
"--start-us",
type=int,
default=0,
help="采集窗口开始时间,微秒 Unix 时间戳;不填写时使用当前时间。",
)
parser.add_argument(
"--end-us",
type=int,
default=0,
help="采集窗口结束时间,微秒 Unix 时间戳;--finalize 时不填写则使用当前时间。",
)
parser.add_argument(
"--file",
action="append",
default=[],
metavar="模块.字段=路径",
help="登记采集文件,可重复,例如 sensor.image_sample_files=sensor/front_camera/images.yaml。",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="覆盖已有 dataset_index.yaml;默认合并已有索引。",
)
parser.add_argument(
"--finalize",
action="store_true",
help="写完 dataset_index.yaml 后立即生成 site_data_input.yaml。",
)
parser.add_argument(
"--site-data-output",
help="生成的 site_data_input.yaml 路径;仅 --finalize 时生效。",
)
parser.add_argument(
"--no-strict",
action="store_true",
help="生成 site_data_input.yaml 时关闭严格检查。",
)
return parser.parse_args()
def known_input_fields() -> set[str]:
result: set[str] = set()
for config in SERVICE_INPUT_FIELDS.values():
section_name = str(config["index_section"])
for field_name in config["fields"]:
result.add(f"{section_name}.{field_name}")
return result
def parse_file_reference(raw_value: str, allowed_fields: set[str]) -> tuple[str, str, str]:
if "=" not in raw_value:
raise ValueError(f"--file 必须使用 模块.字段=路径 格式,当前值为 {raw_value!r}")
key, path = raw_value.split("=", 1)
key = key.strip()
path = path.strip()
if "." not in key:
raise ValueError(f"--file 左侧必须是 模块.字段,当前值为 {key!r}")
section_name, field_name = key.split(".", 1)
if key not in allowed_fields:
raise ValueError(f"未知数据输入字段 {key!r}")
if not path:
raise ValueError(f"{key} 的路径不能为空。")
return section_name, field_name, path
def empty_data_inputs() -> dict[str, Any]:
return {
str(config["index_section"]): {}
for config in SERVICE_INPUT_FIELDS.values()
}
def merge_file_reference(index: dict[str, Any], section_name: str, field_name: str, path: str) -> None:
data_inputs = index.setdefault("data_inputs", empty_data_inputs())
section = data_inputs.setdefault(section_name, {})
files = section.setdefault(field_name, [])
if isinstance(files, str):
files = [files]
section[field_name] = files
if not isinstance(files, list):
raise ValueError(f"data_inputs.{section_name}.{field_name} 必须是列表或字符串。")
if path not in files:
files.append(path)
def make_base_index(
session_id: str,
site_id: str,
vehicle_id: str,
session_dir: Path,
start_us: int,
end_us: int,
) -> dict[str, Any]:
return {
"schema_version": 1,
"session": {
"session_id": session_id,
"site_id": site_id,
"vehicle_id": vehicle_id,
"dataset_root": str(session_dir),
"data_window_start_timestamp_us": start_us,
"data_window_end_timestamp_us": end_us,
},
"data_inputs": empty_data_inputs(),
}
def update_session_metadata(
index: dict[str, Any],
session_id: str,
site_id: str,
vehicle_id: str,
session_dir: Path,
start_us: int,
end_us: int,
) -> None:
session = index.setdefault("session", {})
if not isinstance(session, dict):
raise ValueError("dataset_index.yaml 中的 session 必须是 YAML map。")
session["session_id"] = session_id
session["site_id"] = site_id
session["vehicle_id"] = vehicle_id
session["dataset_root"] = str(session_dir)
session["data_window_start_timestamp_us"] = start_us
session["data_window_end_timestamp_us"] = end_us
index.setdefault("schema_version", 1)
index.setdefault("data_inputs", empty_data_inputs())
def write_yaml(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml.safe_dump(data, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
def main() -> int:
args = parse_args()
profile_path = Path(args.site_profile).expanduser().resolve(strict=False)
profile = load_site_profile(profile_path)
session_id = args.session_id.strip() or default_session_id()
session_dir = resolve_session_dir(profile, profile_path, session_id, args.session_dir)
dataset_index_path = resolve_dataset_index(profile, profile_path, session_dir, None)
site_id = args.site_id.strip()
vehicle_id = args.vehicle_id.strip() or str(profile.get("vehicle_id", "")).strip()
start_us = args.start_us or now_us()
end_us = args.end_us
if args.finalize and end_us <= 0:
end_us = now_us()
if end_us > 0 and end_us <= start_us:
end_us = start_us + 1
if dataset_index_path.exists() and not args.overwrite:
index = load_yaml(dataset_index_path)
else:
index = make_base_index(session_id, site_id, vehicle_id, session_dir, start_us, end_us)
update_session_metadata(index, session_id, site_id, vehicle_id, session_dir, start_us, end_us)
allowed_fields = known_input_fields()
for raw_file in args.file:
section_name, field_name, file_path = parse_file_reference(raw_file, allowed_fields)
merge_file_reference(index, section_name, field_name, file_path)
write_yaml(dataset_index_path, index)
print(f"已写入数据集索引:{dataset_index_path}")
if args.finalize:
from dataset_index_to_site_data_input import build_ros_params, validate_output
output_path = resolve_output_path(
profile,
profile_path,
session_dir,
session_id,
args.site_data_output,
)
output = build_ros_params(
dataset_index_path,
index,
str(session_dir),
True,
)
validate_output(output, strict=not args.no_strict)
write_yaml(output_path, output)
print(f"已生成数据输入参数文件:{output_path}")
print(f"启动参数:data_input_params_file:={output_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"[错误] {exc}", file=sys.stderr)
raise SystemExit(1)
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat <<'USAGE'
Usage:
./upload_to_github.sh [options]
Options:
-m, --message MESSAGE Commit message. Default: "Update AutoCalib-Workshop"
-r, --repo-url URL GitHub repository URL. Uses existing origin if omitted.
-b, --branch BRANCH Branch to push. Default: current branch.
--remote NAME Git remote name. Default: origin.
--dry-run Print commands without changing anything.
-h, --help Show this help.
Examples:
./upload_to_github.sh -m "Initial upload"
./upload_to_github.sh -r https://github.com/USER/REPO.git -m "Upload project"
GITHUB_REPO_URL=git@github.com:USER/REPO.git ./upload_to_github.sh
USAGE
}
die() {
echo "Error: $*" >&2
exit 1
}
run() {
echo "+ $*"
if [[ "${DRY_RUN}" != "1" ]]; then
"$@"
fi
}
PROJECT_DIR="${PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}"
REMOTE_NAME="${REMOTE_NAME:-origin}"
REPO_URL="${GITHUB_REPO_URL:-}"
BRANCH="${BRANCH:-}"
COMMIT_MESSAGE="${COMMIT_MESSAGE:-Update AutoCalib-Workshop}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
-m|--message)
[[ $# -ge 2 ]] || die "missing value for $1"
COMMIT_MESSAGE="$2"
shift 2
;;
-r|--repo-url)
[[ $# -ge 2 ]] || die "missing value for $1"
REPO_URL="$2"
shift 2
;;
-b|--branch)
[[ $# -ge 2 ]] || die "missing value for $1"
BRANCH="$2"
shift 2
;;
--remote)
[[ $# -ge 2 ]] || die "missing value for $1"
REMOTE_NAME="$2"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
command -v git >/dev/null 2>&1 || die "git is not installed"
[[ -d "${PROJECT_DIR}" ]] || die "project directory does not exist: ${PROJECT_DIR}"
cd "${PROJECT_DIR}"
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
run git init
fi
CURRENT_BRANCH="$(git branch --show-current 2>/dev/null || true)"
if [[ -z "${BRANCH}" ]]; then
if [[ -n "${CURRENT_BRANCH}" ]]; then
BRANCH="${CURRENT_BRANCH}"
elif git rev-parse --verify HEAD >/dev/null 2>&1; then
die "detached HEAD; provide a branch with -b or BRANCH=..."
else
BRANCH="main"
fi
fi
if [[ -n "${CURRENT_BRANCH}" && "${CURRENT_BRANCH}" != "${BRANCH}" ]]; then
if git show-ref --verify --quiet "refs/heads/${BRANCH}"; then
run git switch "${BRANCH}"
else
run git switch -c "${BRANCH}"
fi
fi
if git remote get-url "${REMOTE_NAME}" >/dev/null 2>&1; then
if [[ -n "${REPO_URL}" ]]; then
run git remote set-url "${REMOTE_NAME}" "${REPO_URL}"
fi
else
[[ -n "${REPO_URL}" ]] || die "remote '${REMOTE_NAME}' does not exist; provide --repo-url URL"
run git remote add "${REMOTE_NAME}" "${REPO_URL}"
fi
HAS_WORKTREE_CHANGES=0
if ! git diff --quiet || ! git diff --cached --quiet || [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
HAS_WORKTREE_CHANGES=1
fi
if [[ "${HAS_WORKTREE_CHANGES}" == "1" && "${DRY_RUN}" != "1" ]]; then
[[ -n "$(git config --get user.name || true)" ]] || die "git user.name is not set"
[[ -n "$(git config --get user.email || true)" ]] || die "git user.email is not set"
fi
run git add -A
if git diff --cached --quiet; then
echo "No staged changes to commit."
else
run git commit -m "${COMMIT_MESSAGE}"
fi
run git push -u "${REMOTE_NAME}" "${BRANCH}"
echo "Done."