diff --git a/agv_calib_brain/.isaac_cache/workshop_scene_manifest.json b/agv_calib_brain/.isaac_cache/workshop_scene_manifest.json
index 36dd910..dc532a5 100644
--- a/agv_calib_brain/.isaac_cache/workshop_scene_manifest.json
+++ b/agv_calib_brain/.isaac_cache/workshop_scene_manifest.json
@@ -637,7 +637,7 @@
}
},
"ros_topics": {
- "cmd_vel": "/vehicle/demo_agv_001/actuator/cmd_vel",
+ "cmd_vel": "/cmd_vel",
"camera_image": "/AutoCalib_Workshop/camera/image_raw",
"lidar_prefix": "/AutoCalib_Workshop/lidar",
"front_camera_image": "/sensor/front_camera/image_raw",
@@ -692,12 +692,12 @@
"image_topic": "/sensor/front_camera/image_raw",
"telemetry_topic": "/sensor_calibration/telemetry",
"mount_pose": {
- "x_m": 0.82,
+ "x_m": 1.12,
"y_m": 0.0,
- "z_m": 0.32,
- "roll_rad": 0.0,
- "pitch_rad": -1.5707963267948966,
- "yaw_rad": 0.0
+ "z_m": 1.18,
+ "roll_rad": 1.5707963267948966,
+ "pitch_rad": 0.0,
+ "yaw_rad": -1.5707963267948966
}
},
{
@@ -708,7 +708,7 @@
"mount_pose": {
"x_m": 0.4,
"y_m": 0.0,
- "z_m": 0.3,
+ "z_m": 0.2,
"roll_rad": 0.0,
"pitch_rad": 0.0,
"yaw_rad": 0.0
@@ -722,7 +722,7 @@
"mount_pose": {
"x_m": 0.55,
"y_m": 0.0,
- "z_m": 0.46,
+ "z_m": 1.2,
"roll_rad": 0.0,
"pitch_rad": 0.0,
"yaw_rad": 0.0
@@ -736,7 +736,7 @@
"mount_pose": {
"x_m": 0.7,
"y_m": 0.0,
- "z_m": 0.24,
+ "z_m": 1.0,
"roll_rad": 0.0,
"pitch_rad": 0.0,
"yaw_rad": 0.0
diff --git a/agv_calib_brain/src/simulation/isaac_workshop_sim/assets/urdf/ackermann_front_steer_rear_drive.urdf b/agv_calib_brain/src/simulation/isaac_workshop_sim/assets/urdf/ackermann_front_steer_rear_drive.urdf
index dfa90f8..9658f22 100644
--- a/agv_calib_brain/src/simulation/isaac_workshop_sim/assets/urdf/ackermann_front_steer_rear_drive.urdf
+++ b/agv_calib_brain/src/simulation/isaac_workshop_sim/assets/urdf/ackermann_front_steer_rear_drive.urdf
@@ -277,7 +277,7 @@
-
+
@@ -293,14 +293,14 @@
-
+
-
+
@@ -309,14 +309,14 @@
-
+
-
+
@@ -325,7 +325,7 @@
-
+
diff --git a/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/build_calibration_room.py b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/build_calibration_room.py
index 65d5e29..23deb62 100644
--- a/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/build_calibration_room.py
+++ b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/build_calibration_room.py
@@ -6,6 +6,20 @@ import time
import xml.etree.ElementTree as ET
from pathlib import Path
+# 导入本地工具模块
+from utils import (
+ chassis_type_value,
+ clamp,
+ controller_algorithm_value,
+ make_mesh_path_absolute,
+ normalize_angle,
+ quat_wxyz_to_yaw,
+ resolve_topic,
+ vehicle_state_from_isaac,
+)
+from urdf_utils import prepare_isaac_urdf
+from image_utils import create_charuco_image, create_checkerboard_image
+
# TF 发布相关导入(用于发布 base_link 和传感器静态 TF)
try:
from geometry_msgs.msg import TransformStamped
@@ -33,6 +47,7 @@ def parse_args():
parser.add_argument("--ros-topic-prefix", type=str, default="/AutoCalib_Workshop", help="相机与激光雷达话题前缀")
parser.add_argument("--cmd-vel-topic", type=str, default="/cmd_vel", help="车辆速度控制话题")
parser.add_argument("--lidar-config", type=str, default="Example_Rotary", help="Isaac LiDAR 配置名")
+ parser.add_argument("--lidar-3d-config", type=str, default="Hesai_XT32_SD10", help="车载3D LiDAR 配置名(默认32线)")
parser.add_argument("--disable-camera", action="store_true", help="不创建顶置相机")
parser.add_argument("--disable-lidars", action="store_true", help="不创建四角激光雷达")
parser.add_argument("--disable-vehicle-camera", action="store_true", help="不创建车载前视相机")
@@ -45,12 +60,12 @@ def parse_args():
parser.add_argument("--vehicle-lidar-topic", type=str, default="/sensor/lidar_3d/pointcloud", help="车载 3D 激光雷达点云话题")
parser.add_argument("--vehicle-2d-lidar-topic", type=str, default="/sensor/lidar_2d/scan", help="车载 2D 激光雷达 LaserScan 话题")
parser.add_argument("--vehicle-imu-topic", type=str, default="/sensor/imu/data", help="车载 IMU 话题")
- parser.add_argument("--vehicle-camera-x", type=float, default=0.82, help="车载前视相机相对 base_link X 坐标")
+ parser.add_argument("--vehicle-camera-x", type=float, default=1.12, help="车载前视相机相对 base_link X 坐标")
parser.add_argument("--vehicle-camera-y", type=float, default=0.0, help="车载前视相机相对 base_link Y 坐标")
- parser.add_argument("--vehicle-camera-z", type=float, default=0.32, help="车载前视相机相对 base_link Z 坐标")
+ parser.add_argument("--vehicle-camera-z", type=float, default=1.18, help="车载前视相机相对 base_link Z 坐标")
parser.add_argument("--down-camera-x", type=float, default=0.40, help="车载下视相机相对 base_link X 坐标")
parser.add_argument("--down-camera-y", type=float, default=0.0, help="车载下视相机相对 base_link Y 坐标")
- parser.add_argument("--down-camera-z", type=float, default=0.30, help="车载下视相机相对 base_link Z 坐标")
+ parser.add_argument("--down-camera-z", type=float, default=0.20, help="车载下视相机相对 base_link Z 坐标")
parser.add_argument("--disable-down-camera-intrinsic-target", action="store_true", help="不创建下视相机 3D ChArUco 内参标定台")
parser.add_argument("--down-camera-target-x", type=float, default=0.0, help="下视相机内参标定台中心 X 坐标")
parser.add_argument("--down-camera-target-y", type=float, default=-2.15, help="下视相机内参标定台中心 Y 坐标")
@@ -61,10 +76,10 @@ def parse_args():
parser.add_argument("--down-camera-target-squares-y", type=int, default=10, help="下视相机 ChArUco 纹理 Y 方向格数")
parser.add_argument("--vehicle-lidar-x", type=float, default=0.55, help="车载 3D LiDAR 相对 base_link X 坐标")
parser.add_argument("--vehicle-lidar-y", type=float, default=0.0, help="车载 LiDAR 相对 base_link Y 坐标")
- parser.add_argument("--vehicle-lidar-z", type=float, default=0.46, help="车载 3D LiDAR 相对 base_link Z 坐标")
+ parser.add_argument("--vehicle-lidar-z", type=float, default=1.20, help="车载 3D LiDAR 相对 base_link Z 坐标")
parser.add_argument("--vehicle-2d-lidar-x", type=float, default=0.70, help="车载 2D LiDAR 相对 base_link X 坐标")
parser.add_argument("--vehicle-2d-lidar-y", type=float, default=0.0, help="车载 2D LiDAR 相对 base_link Y 坐标")
- parser.add_argument("--vehicle-2d-lidar-z", type=float, default=0.24, help="车载 2D LiDAR 相对 base_link Z 坐标")
+ parser.add_argument("--vehicle-2d-lidar-z", type=float, default=1.00, help="车载 2D LiDAR 相对 base_link Z 坐标")
parser.add_argument("--vehicle-imu-x", type=float, default=0.40, help="车载 IMU 相对 base_link X 坐标")
parser.add_argument("--vehicle-imu-y", type=float, default=0.0, help="车载 IMU 相对 base_link Y 坐标")
parser.add_argument("--vehicle-imu-z", type=float, default=0.26, help="车载 IMU 相对 base_link Z 坐标")
@@ -264,6 +279,16 @@ from omni.isaac.core.utils.prims import create_prim
from omni.isaac.core.utils.rotations import euler_angles_to_quat
from omni.isaac.core.utils.viewports import set_camera_view
+# 导入本地模块(需要在 Isaac Sim 初始化后)
+from usd_utils import create_raw_usd_material, create_textured_board, create_textured_top_strip
+from calibration_targets import (
+ add_calibration_boards,
+ add_calibration_floor_fixtures,
+ add_down_camera_intrinsic_target,
+ add_2d_lidar_calibration_targets,
+ lidar_2d_checkerboard_reserved_zones,
+)
+
try:
import rclpy
except ImportError:
@@ -309,202 +334,14 @@ except ImportError:
LaserScan = None
-def clamp(value, low, high):
- return max(low, min(high, value))
-
-
-def resolve_topic(prefix, suffix):
- normalized_prefix = prefix.rstrip("/")
- normalized_suffix = suffix if suffix.startswith("/") else f"/{suffix}"
- return f"{normalized_prefix}{normalized_suffix}" if normalized_prefix else normalized_suffix
-
-
-def quat_wxyz_to_yaw(quat_wxyz):
- w, x, y, z = quat_wxyz
- siny_cosp = 2.0 * (w * z + x * y)
- cosy_cosp = 1.0 - 2.0 * (y * y + z * z)
- return math.atan2(siny_cosp, cosy_cosp)
-
-
-def normalize_angle(angle):
- return math.atan2(math.sin(angle), math.cos(angle))
-
-
-def chassis_type_value(name):
- if ChassisType is None:
- return 0
- mapping = {
- "ackermann": ChassisType.ACKERMANN,
- "differential": ChassisType.DIFFERENTIAL,
- "single_steer": ChassisType.SINGLE_STEER_WHEEL,
- "multi_steer": ChassisType.MULTI_STEER_WHEEL,
- }
- return mapping.get(name, ChassisType.CHASSIS_TYPE_UNSPECIFIED)
-
-
-def controller_algorithm_value(name):
- if ControllerAlgorithmType is None:
- return 0
- mapping = {
- "pid": ControllerAlgorithmType.PID,
- "mpc": ControllerAlgorithmType.MPC,
- "lqr": ControllerAlgorithmType.LQR,
- "pure_pursuit": ControllerAlgorithmType.PURE_PURSUIT,
- }
- return mapping.get(name, ControllerAlgorithmType.CONTROLLER_ALGORITHM_UNSPECIFIED)
-
-
-def vehicle_state_from_isaac(agv):
- position, quat = agv.get_world_pose()
- yaw_rad = quat_wxyz_to_yaw(quat)
- linear_velocity = agv.get_linear_velocity()
- try:
- angular_velocity = agv.get_angular_velocity()
- except Exception:
- angular_velocity = np.array([0.0, 0.0, 0.0])
- return position, yaw_rad, linear_velocity, angular_velocity
-
-
-def make_mesh_path_absolute(mesh_filename, source_dir):
- mesh_path = Path(mesh_filename)
- if mesh_path.is_absolute():
- return str(mesh_path)
- return str((source_dir / mesh_path).resolve())
-
-
-def prepare_isaac_urdf(source_urdf_path, output_urdf_path):
- tree = ET.parse(source_urdf_path)
- root = tree.getroot()
- source_dir = source_urdf_path.parent
-
- local_mesh_links = {
- "left_steering_hinge",
- "right_steering_hinge",
- "left_wheel",
- "right_wheel",
- "left_rear_wheel",
- "right_rear_wheel",
- "camera",
- "laser",
- }
-
- for link in root.findall("link"):
- link_name = link.get("name", "")
- for section_name in ("visual", "collision"):
- section = link.find(section_name)
- if section is None:
- continue
- mesh = section.find("geometry/mesh")
- if mesh is not None and mesh.get("filename"):
- mesh.set("filename", make_mesh_path_absolute(mesh.get("filename"), source_dir))
- if link_name in local_mesh_links:
- origin = section.find("origin")
- if origin is None:
- origin = ET.SubElement(section, "origin")
- origin.set("xyz", "0 0 0")
- origin.set("rpy", "0 0 0")
-
- output_urdf_path.parent.mkdir(parents=True, exist_ok=True)
- tree.write(output_urdf_path, encoding="utf-8", xml_declaration=True)
- return output_urdf_path
-
-
-def create_checkerboard_image(filepath, rows=6, cols=9, square_size_px=500):
- width = cols * square_size_px
- height = rows * square_size_px
- img = np.ones((height, width, 3), dtype=np.uint8) * 255
-
- for r in range(rows):
- for c in range(cols):
- if (r + c) % 2 == 1:
- img[r * square_size_px:(r + 1) * square_size_px, c * square_size_px:(c + 1) * square_size_px] = 0
-
- border = square_size_px
- img_with_border = np.pad(
- img,
- pad_width=((border, border), (border, border), (0, 0)),
- mode="constant",
- constant_values=255,
- )
-
- abs_filepath = Path(filepath).resolve()
- abs_filepath.parent.mkdir(parents=True, exist_ok=True)
- Image.fromarray(img_with_border).save(abs_filepath)
- usd_filepath = str(abs_filepath).replace("\\", "/")
- print(f"[*] 棋盘格纹理已生成: {usd_filepath}")
- return usd_filepath
-
-
-def create_charuco_image(filepath, squares_x=30, squares_y=10, square_size_px=90):
- width = squares_x * square_size_px
- height = squares_y * square_size_px
- abs_filepath = Path(filepath).resolve()
- abs_filepath.parent.mkdir(parents=True, exist_ok=True)
-
- try:
- import cv2
- dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)
- try:
- board = cv2.aruco.CharucoBoard((squares_x, squares_y), 1.0, 0.70, dictionary)
- except TypeError:
- board = cv2.aruco.CharucoBoard_create(squares_x, squares_y, 1.0, 0.70, dictionary)
- if hasattr(board, "generateImage"):
- img = board.generateImage((width, height), marginSize=0)
- else:
- img = board.draw((width, height), marginSize=0)
- if len(img.shape) == 2:
- img = np.repeat(img[:, :, None], 3, axis=2)
- Image.fromarray(img).save(abs_filepath)
- usd_filepath = str(abs_filepath).replace("\\", "/")
- print(f"[*] ChArUco 纹理已生成: {usd_filepath}")
- return usd_filepath
- except Exception as exc:
- print(f"[WARN] OpenCV ChArUco 生成失败,使用内置 ChArUco 风格纹理: {exc}")
-
- img = np.ones((height, width, 3), dtype=np.uint8) * 255
- marker_cells = 6
- marker_margin = max(3, square_size_px // 7)
- marker_size = square_size_px - 2 * marker_margin
- marker_cell_px = max(1, marker_size // marker_cells)
- marker_px = marker_cell_px * marker_cells
-
- def marker_bits(marker_id):
- marker = np.ones((marker_cells, marker_cells), dtype=np.uint8) * 255
- marker[0, :] = 0
- marker[-1, :] = 0
- marker[:, 0] = 0
- marker[:, -1] = 0
- state = (marker_id + 1) * 1103515245 + 12345
- for r in range(1, marker_cells - 1):
- for c in range(1, marker_cells - 1):
- state = (state * 1664525 + 1013904223 + r * 97 + c * 193) & 0xFFFFFFFF
- marker[r, c] = 0 if (state & 1) else 255
- return marker
-
- marker_id = 0
- for r in range(squares_y):
- for c in range(squares_x):
- y0 = r * square_size_px
- x0 = c * square_size_px
- if (r + c) % 2 == 1:
- img[y0:y0 + square_size_px, x0:x0 + square_size_px] = 0
- continue
- marker = marker_bits(marker_id)
- marker_img = np.kron(marker, np.ones((marker_cell_px, marker_cell_px), dtype=np.uint8))
- marker_img = marker_img[:marker_px, :marker_px]
- marker_rgb = np.repeat(marker_img[:, :, None], 3, axis=2)
- marker_y = y0 + (square_size_px - marker_px) // 2
- marker_x = x0 + (square_size_px - marker_px) // 2
- img[marker_y:marker_y + marker_px, marker_x:marker_x + marker_px] = marker_rgb
- marker_id += 1
-
- Image.fromarray(img).save(abs_filepath)
- usd_filepath = str(abs_filepath).replace("\\", "/")
- print(f"[*] ChArUco 风格纹理已生成: {usd_filepath}")
- return usd_filepath
-
-
def add_corner_rotary_lidars(room_length, room_width, height, lidar_config, topic_prefix):
+ """添加四角旋转 LiDAR"""
+ from omni.isaac.core.utils.rotations import euler_angles_to_quat
+ import omni.graph.core as og
+ import omni.kit.commands
+ import omni.replicator.core as rep
+ from pxr import Gf
+
offset = 0.3
x_pos = (room_length / 2.0) - offset
y_pos = (room_width / 2.0) - offset
@@ -562,521 +399,6 @@ def add_corner_rotary_lidars(room_length, room_width, height, lidar_config, topi
)
-def create_raw_usd_material(stage, mat_path, tex_path):
- material = UsdShade.Material.Define(stage, mat_path)
- pbr_shader = UsdShade.Shader.Define(stage, f"{mat_path}/PBRShader")
- pbr_shader.CreateIdAttr("UsdPreviewSurface")
- pbr_shader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(1.0)
- pbr_shader.CreateInput("metallic", Sdf.ValueTypeNames.Float).Set(0.0)
-
- tex_sampler = UsdShade.Shader.Define(stage, f"{mat_path}/diffuseTexture")
- tex_sampler.CreateIdAttr("UsdUVTexture")
- tex_sampler.CreateInput("file", Sdf.ValueTypeNames.Asset).Set(Sdf.AssetPath(tex_path))
- tex_sampler.CreateInput("magFilter", Sdf.ValueTypeNames.Token).Set("nearest")
- tex_sampler.CreateInput("minFilter", Sdf.ValueTypeNames.Token).Set("nearest")
-
- st_reader = UsdShade.Shader.Define(stage, f"{mat_path}/stReader")
- st_reader.CreateIdAttr("UsdPrimvarReader_float2")
- st_reader.CreateInput("varname", Sdf.ValueTypeNames.Token).Set("st")
-
- tex_sampler.CreateInput("st", Sdf.ValueTypeNames.Float2).ConnectToSource(st_reader.ConnectableAPI(), "result")
- pbr_shader.CreateInput("diffuseColor", Sdf.ValueTypeNames.Color3f).ConnectToSource(tex_sampler.ConnectableAPI(), "rgb")
- material.CreateSurfaceOutput().ConnectToSource(pbr_shader.ConnectableAPI(), "surface")
- return material
-
-
-def create_textured_board(stage, prim_path, width, height, center, euler_rot_deg, usd_material):
- mesh = UsdGeom.Mesh.Define(stage, prim_path)
- half_width, half_height = width / 2.0, height / 2.0
- mesh.GetPointsAttr().Set(Vt.Vec3fArray([
- Gf.Vec3f(-half_width, -half_height, 0),
- Gf.Vec3f(half_width, -half_height, 0),
- Gf.Vec3f(half_width, half_height, 0),
- Gf.Vec3f(-half_width, half_height, 0),
- ]))
- mesh.GetFaceVertexCountsAttr().Set([4])
- mesh.GetFaceVertexIndicesAttr().Set([0, 1, 2, 3])
- mesh.GetNormalsAttr().Set([Gf.Vec3f(0, 0, 1)] * 4)
- mesh.SetNormalsInterpolation(UsdGeom.Tokens.vertex)
-
- primvars_api = UsdGeom.PrimvarsAPI(mesh)
- st_primvar = primvars_api.CreatePrimvar("st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.vertex)
- st_primvar.Set([Gf.Vec2f(0, 0), Gf.Vec2f(1, 0), Gf.Vec2f(1, 1), Gf.Vec2f(0, 1)])
- mesh.GetExtentAttr().Set([Gf.Vec3f(-half_width, -half_height, -0.01), Gf.Vec3f(half_width, half_height, 0.01)])
-
- xform = UsdGeom.Xformable(mesh)
- xform.AddTranslateOp().Set(Gf.Vec3d(*center))
- xform.AddRotateXYZOp().Set(Gf.Vec3f(*euler_rot_deg))
- UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim()).Bind(usd_material)
- return mesh
-
-
-def create_textured_top_strip(stage, prim_path, x_edges, y_min, y_max, z_values, usd_material):
- mesh = UsdGeom.Mesh.Define(stage, prim_path)
- x0 = x_edges[0]
- x1 = x_edges[-1]
- total_length = max(x1 - x0, 1e-6)
-
- points = []
- st_values = []
- for x, z in zip(x_edges, z_values):
- u = (x - x0) / total_length
- points.append(Gf.Vec3f(x, y_min, z))
- points.append(Gf.Vec3f(x, y_max, z))
- st_values.append(Gf.Vec2f(u, 0.0))
- st_values.append(Gf.Vec2f(u, 1.0))
-
- face_vertex_counts = []
- face_vertex_indices = []
- for index in range(len(x_edges) - 1):
- face_vertex_counts.append(4)
- face_vertex_indices.extend([2 * index, 2 * (index + 1), 2 * (index + 1) + 1, 2 * index + 1])
-
- mesh.GetPointsAttr().Set(Vt.Vec3fArray(points))
- mesh.GetFaceVertexCountsAttr().Set(face_vertex_counts)
- mesh.GetFaceVertexIndicesAttr().Set(face_vertex_indices)
- mesh.GetExtentAttr().Set([
- Gf.Vec3f(min(x_edges), y_min, min(z_values) - 0.005),
- Gf.Vec3f(max(x_edges), y_max, max(z_values) + 0.005),
- ])
-
- primvars_api = UsdGeom.PrimvarsAPI(mesh)
- st_primvar = primvars_api.CreatePrimvar("st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.vertex)
- st_primvar.Set(st_values)
- UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim()).Bind(usd_material)
- return mesh
-
-
-def add_down_camera_intrinsic_target(world, stage, args, material):
- length = args.down_camera_target_length
- width = args.down_camera_target_width
- center_x = args.down_camera_target_x
- center_y = args.down_camera_target_y
- z_low = 0.012
- z_high = args.down_camera_target_max_height
- x_start = center_x - length / 2.0
- x_low_end = x_start + length * 0.22
- x_ramp_end = x_start + length * 0.74
- x_end = center_x + length / 2.0
- y_min = center_y - width / 2.0
- y_max = center_y + width / 2.0
- base_thickness = 0.006
-
- world.scene.add(FixedCuboid(
- prim_path="/World/Workshop/DownCameraIntrinsicTarget/Base",
- name="down_camera_intrinsic_target_base",
- position=np.array([center_x, center_y, base_thickness / 2.0]),
- scale=np.array([length + 0.08, width + 0.08, base_thickness]),
- color=np.array([0.045, 0.050, 0.052]),
- ))
-
- create_textured_top_strip(
- stage,
- "/World/Workshop/DownCameraIntrinsicTarget/CharucoRampSurface",
- [x_start, x_low_end, x_ramp_end, x_end],
- y_min,
- y_max,
- [z_low, z_low, z_high, z_high],
- material,
- )
-
- specs = [{
- "id": "down_camera_3d_charuco_ramp",
- "target_type": "down_camera_intrinsic",
- "pattern": "charuco",
- "purpose": "intrinsic_calibration_with_depth_and_pose_gradient",
- "center": [center_x, center_y, (z_low + z_high) / 2.0],
- "length_m": length,
- "width_m": width,
- "squares_x": args.down_camera_target_squares_x,
- "squares_y": args.down_camera_target_squares_y,
- "square_size_m": width / args.down_camera_target_squares_y,
- "min_height_m": z_low,
- "max_height_m": z_high,
- "recommended_drive_axis": "+x",
- "recommended_speed_mps": 0.10,
- "surfaces": [
- {
- "id": "low_flat_charuco",
- "type": "flat",
- "x_range_m": [x_start, x_low_end],
- "z_range_m": [z_low, z_low],
- },
- {
- "id": "continuous_slope_charuco",
- "type": "continuous_slope",
- "x_range_m": [x_low_end, x_ramp_end],
- "z_range_m": [z_low, z_high],
- },
- {
- "id": "high_flat_charuco",
- "type": "flat",
- "x_range_m": [x_ramp_end, x_end],
- "z_range_m": [z_high, z_high],
- },
- ],
- }]
- return specs
-
-
-def lidar_2d_target_wall_slots(args):
- if args.disable_2d_lidar_targets:
- return {}
-
- width = args.lidar_2d_target_width
- height = args.lidar_2d_target_height
- gap = 0.12
- edge_margin = 0.18
- bay_margin = 0.07
- bay_width = max(1.90, 2.0 * width + gap + 2.0 * bay_margin)
- bay_z_min = 0.0
- bay_z_max = args.room_height
- center_spacing = width + gap
-
- def group_offsets(span, side):
- usable_min = -span / 2.0 + edge_margin
- usable_max = span / 2.0 - edge_margin
- usable_width = max(0.0, usable_max - usable_min)
- effective_bay_width = min(bay_width, usable_width) if usable_width > 0.0 else bay_width
- if usable_width <= effective_bay_width:
- axis_min = usable_min
- axis_max = usable_max
- elif side == "max":
- axis_max = usable_max
- axis_min = axis_max - effective_bay_width
- else:
- axis_min = usable_min
- axis_max = axis_min + effective_bay_width
- group_center = (axis_min + axis_max) / 2.0
- vertical_offset = group_center - center_spacing / 2.0
- slope_offset = group_center + center_spacing / 2.0
- return vertical_offset, slope_offset, axis_min, axis_max
-
- front_vertical, front_slope, front_min, front_max = group_offsets(args.room_width, "min")
- _, _, right_min, right_max = group_offsets(args.room_length, "max")
- right_vertical = (right_min + right_max) / 2.0
-
- slots = {
- "front": {
- "vertical_offset": front_vertical,
- "slope_offset": front_slope,
- "reserved_axis_min": front_min,
- "reserved_axis_max": front_max,
- "reserved_z_min": bay_z_min,
- "reserved_z_max": bay_z_max,
- },
- "right": {
- "vertical_offset": right_vertical,
- "reserved_axis_min": right_min,
- "reserved_axis_max": right_max,
- "reserved_z_min": bay_z_min,
- "reserved_z_max": bay_z_max,
- },
- }
- return slots
-
-
-def lidar_2d_checkerboard_reserved_zones(args):
- slots = lidar_2d_target_wall_slots(args)
- reserved_zones = {}
- for wall, slot in slots.items():
- reserved_zones[wall] = [{
- "axis_min": slot["reserved_axis_min"],
- "axis_max": slot["reserved_axis_max"],
- "z_min": slot["reserved_z_min"],
- "z_max": slot["reserved_z_max"],
- "reason": "2d_lidar_extrinsic_corner_bay",
- }]
- return reserved_zones
-
-
-def calibration_board_layout(args, board_width, board_height):
- wall_standoff = 0.015
- front_x = args.room_length / 2.0 - wall_standoff
- back_x = -args.room_length / 2.0 + wall_standoff
- left_y = args.room_width / 2.0 - wall_standoff
- right_y = -args.room_width / 2.0 + wall_standoff
- horizontal_gap = 0.18
- vertical_gap = 0.16
- side_margin = 0.35
- bottom_margin = 0.32
- top_margin = 0.28
-
- def axis_positions(span, item_size, margin, gap):
- available = span - 2.0 * margin
- count = max(1, int((available + gap) // (item_size + gap)))
- if count == 1:
- return [0.0]
- used = count * item_size + (count - 1) * gap
- start = -used / 2.0 + item_size / 2.0
- return [start + index * (item_size + gap) for index in range(count)]
-
- def z_positions():
- available = args.room_height - bottom_margin - top_margin
- count = max(1, int((available + vertical_gap) // (board_height + vertical_gap)))
- if count == 1:
- return [bottom_margin + board_height / 2.0]
- used = count * board_height + (count - 1) * vertical_gap
- start = bottom_margin + board_height / 2.0 + max(0.0, available - used) / 2.0
- return [start + index * (board_height + vertical_gap) for index in range(count)]
-
- zs = z_positions()
- front_back_offsets = axis_positions(args.room_width, board_width, side_margin, horizontal_gap)
- side_offsets = axis_positions(args.room_length, board_width, side_margin, horizontal_gap)
- reserved_zones = lidar_2d_checkerboard_reserved_zones(args)
-
- board_specs = []
-
- def overlaps_reserved_zone(wall, offset, z):
- board_axis_min = offset - board_width / 2.0
- board_axis_max = offset + board_width / 2.0
- board_z_min = z - board_height / 2.0
- board_z_max = z + board_height / 2.0
- for zone in reserved_zones.get(wall, []):
- axis_overlaps = board_axis_min < zone["axis_max"] and board_axis_max > zone["axis_min"]
- z_overlaps = board_z_min < zone["z_max"] and board_z_max > zone["z_min"]
- if axis_overlaps and z_overlaps:
- return True
- return False
-
- def add_wall_grid(wall, fixed_value, offsets, rotation_deg):
- for row, z in enumerate(zs):
- for col, offset in enumerate(offsets):
- if overlaps_reserved_zone(wall, offset, z):
- continue
- if wall == "front":
- position = [fixed_value, offset, z]
- elif wall == "back":
- position = [fixed_value, offset, z]
- elif wall == "left":
- position = [offset, fixed_value, z]
- else:
- position = [offset, fixed_value, z]
-
- board_specs.append({
- "id": f"{wall}_wall_r{row:02d}_c{col:02d}",
- "wall": wall,
- "mount": "flush",
- "purpose": "wall_checkerboard_array",
- "position": position,
- "rotation_deg": rotation_deg,
- })
-
- add_wall_grid("front", front_x, front_back_offsets, [90, 0, 90])
- add_wall_grid("back", back_x, front_back_offsets, [90, 0, -90])
- add_wall_grid("left", left_y, side_offsets, [90, 0, 0])
- add_wall_grid("right", right_y, side_offsets, [90, 0, 180])
-
- return board_specs
-
-
-def add_calibration_boards(stage, args, board_width, board_height, material):
- board_specs = calibration_board_layout(args, board_width, board_height)
- for spec in board_specs:
- create_textured_board(
- stage,
- f"/World/Workshop/CalibrationBoards/{spec['id']}",
- board_width,
- board_height,
- spec["position"],
- spec["rotation_deg"],
- material,
- )
- return board_specs
-
-
-def add_lidar_2d_panel(world, prim_path, name, position, scale, color, rotation_deg=None):
- orientation = None
- if rotation_deg is not None:
- orientation = np.array(euler_angles_to_quat(np.array(rotation_deg), degrees=True))
- world.scene.add(FixedCuboid(
- prim_path=prim_path,
- name=name,
- position=np.array(position),
- orientation=orientation,
- scale=np.array(scale),
- color=np.array(color),
- ))
-
-
-def add_2d_lidar_calibration_targets(world, args):
- thickness = args.lidar_2d_target_thickness
- width = args.lidar_2d_target_width
- height = args.lidar_2d_target_height
- bottom_z = args.lidar_2d_target_bottom_z
- center_z = bottom_z + height / 2.0
- angle_deg = args.lidar_2d_target_slope_angle_deg
- angle_rad = math.radians(angle_deg)
- sloped_length = height / max(math.cos(angle_rad), 1e-3)
- standoff = 0.06
- slots = lidar_2d_target_wall_slots(args)
-
- front_wall_x = args.room_length / 2.0
- right_wall_y = -args.room_width / 2.0
-
- front_base_x = front_wall_x - standoff - thickness / 2.0
- side_right_base_y = right_wall_y + standoff + thickness / 2.0
-
- front_slope_center_x = front_wall_x - standoff - height / 2.0 - thickness
-
- front_vertical_y = slots["front"]["vertical_offset"]
- front_slope_y = slots["front"]["slope_offset"]
- right_vertical_x = slots["right"]["vertical_offset"]
-
- vertical_color = [0.92, 0.82, 0.18]
- slope_color = [0.12, 0.65, 0.95]
- specs = []
-
- def add_spec(spec):
- specs.append(spec)
- return spec
-
- front_vertical = add_spec({
- "id": "front_vertical_reference_panel",
- "wall": "front",
- "type": "vertical_reference",
- "position": [front_base_x, front_vertical_y, center_z],
- "scale": [thickness, width, height],
- "rotation_deg": [0.0, 0.0, 0.0],
- "nominal_plane": "x = room_length/2 - standoff - thickness",
- })
- add_lidar_2d_panel(
- world,
- "/World/Workshop/Lidar2DCalibrationTargets/FrontVerticalReference",
- "front_vertical_reference_panel",
- front_vertical["position"],
- front_vertical["scale"],
- vertical_color,
- )
-
- front_slope = add_spec({
- "id": "front_45deg_height_encoding_panel",
- "wall": "front",
- "type": "height_encoding_slope",
- "position": [front_slope_center_x, front_slope_y, center_z],
- "scale": [thickness, width, sloped_length],
- "rotation_deg": [0.0, -angle_deg, 0.0],
- "slope_angle_deg": angle_deg,
- "height_to_range_sign": "higher_scan_plane_farther_from_front_wall",
- })
- add_lidar_2d_panel(
- world,
- "/World/Workshop/Lidar2DCalibrationTargets/FrontSlope45",
- "front_45deg_height_encoding_panel",
- front_slope["position"],
- front_slope["scale"],
- slope_color,
- front_slope["rotation_deg"],
- )
-
- right_vertical = add_spec({
- "id": "right_vertical_reference_panel",
- "wall": "right",
- "type": "vertical_reference",
- "position": [right_vertical_x, side_right_base_y, center_z],
- "scale": [width, thickness, height],
- "rotation_deg": [0.0, 0.0, 0.0],
- "nominal_plane": "y = -room_width/2 + standoff + thickness",
- })
- add_lidar_2d_panel(
- world,
- "/World/Workshop/Lidar2DCalibrationTargets/RightVerticalReference",
- "right_vertical_reference_panel",
- right_vertical["position"],
- right_vertical["scale"],
- vertical_color,
- )
-
- return specs
-
-
-def add_floor_marker(world, prim_path, name, position, scale, color):
- world.scene.add(FixedCuboid(
- prim_path=prim_path,
- name=name,
- position=np.array(position),
- scale=np.array(scale),
- color=np.array(color),
- ))
-
-
-def add_calibration_floor_fixtures(world, args):
- z = 0.012
- thickness = 0.012
- length = args.straight_track_length
- width = args.straight_track_width
-
- add_floor_marker(
- world,
- "/World/Workshop/CalibrationFixtures/StraightTrack",
- "straight_track",
- [0.0, 0.0, z],
- [length, width, thickness],
- [0.08, 0.12, 0.10],
- )
- add_floor_marker(
- world,
- "/World/Workshop/CalibrationFixtures/StraightCenterLine",
- "straight_center_line",
- [0.0, 0.0, z + thickness],
- [length, 0.035, thickness],
- [0.95, 0.95, 0.15],
- )
- for side, y in (("Left", width / 2.0), ("Right", -width / 2.0)):
- add_floor_marker(
- world,
- f"/World/Workshop/CalibrationFixtures/StraightBoundary{side}",
- f"straight_boundary_{side.lower()}",
- [0.0, y, z + thickness],
- [length, 0.025, thickness],
- [0.95, 0.78, 0.08],
- )
-
- stop_x = min(length / 2.0 - 0.35, args.room_length / 2.0 - 1.0)
- add_floor_marker(
- world,
- "/World/Workshop/CalibrationFixtures/StopAccuracyTarget",
- "stop_accuracy_target",
- [stop_x, 0.0, z + 2.0 * thickness],
- [0.6, 0.9, thickness],
- [0.88, 0.10, 0.10],
- )
-
- lateral_x = -min(length / 2.0 - 0.8, args.room_length / 2.0 - 1.2)
- add_floor_marker(
- world,
- "/World/Workshop/CalibrationFixtures/LateralMotionPad",
- "lateral_motion_pad",
- [lateral_x, 0.0, z + thickness],
- [0.75, 2.0, thickness],
- [0.10, 0.34, 0.88],
- )
-
- arc_center = np.array([0.0, -args.room_width * 0.20])
- arc_radius = min(args.arc_track_radius, args.room_width * 0.32)
- marker_count = 25
- for index in range(marker_count):
- theta = math.radians(20.0 + 140.0 * index / (marker_count - 1))
- x = arc_center[0] + arc_radius * math.cos(theta)
- y = arc_center[1] + arc_radius * math.sin(theta)
- add_floor_marker(
- world,
- f"/World/Workshop/CalibrationFixtures/ArcMarker_{index:02d}",
- f"arc_marker_{index:02d}",
- [x, y, z + 3.0 * thickness],
- [0.10, 0.10, thickness],
- [0.12, 0.75, 0.35],
- )
-
- add_floor_marker(
- world,
- "/World/Workshop/CalibrationFixtures/SensorCapturePad",
- "sensor_capture_pad",
- [0.0, args.room_width * 0.28, z + thickness],
- [1.2, 0.9, thickness],
- [0.42, 0.18, 0.78],
- )
-
class ExternalTruthTelemetryPublisher:
def __init__(self, args):
@@ -1175,9 +497,10 @@ class VehicleTfPublisher:
'x': args.vehicle_camera_x,
'y': args.vehicle_camera_y,
'z': args.vehicle_camera_z,
- 'roll': 0.0,
- 'pitch': -math.pi / 2, # 相机朝下 -90度
- 'yaw': 0.0,
+ # USD 相机默认看向局部 -Z;这里让光轴朝 base_link +X,画面上方朝 base_link +Z,避免图像顺时针旋转 90°。
+ 'roll': math.pi / 2,
+ 'pitch': 0.0,
+ 'yaw': -math.pi / 2,
'enabled': not args.disable_vehicle_camera,
},
'down_camera_link': {
@@ -1944,6 +1267,8 @@ class IsaacWorkshopRuntime:
orientation=np.array(orientation),
attributes={
"focalLength": float(focal_length),
+ # 下视/近距离相机需要较小近裁剪面,否则离地几厘米时画面会被 near plane 裁成黑屏。
+ "clippingRange": (0.005, 1000.0),
},
)
render_product = rep.create.render_product(
@@ -2044,9 +1369,9 @@ class IsaacWorkshopRuntime:
"x_m": self.args.vehicle_camera_x,
"y_m": self.args.vehicle_camera_y,
"z_m": self.args.vehicle_camera_z,
- "roll_rad": 0.0,
- "pitch_rad": -math.pi / 2.0,
- "yaw_rad": 0.0,
+ "roll_rad": math.pi / 2.0,
+ "pitch_rad": 0.0,
+ "yaw_rad": -math.pi / 2.0,
},
},
{
@@ -2169,6 +1494,20 @@ class IsaacWorkshopRuntime:
if physx_rb:
physx_rb.GetSleepThresholdAttr().Set(0.0)
+ def find_vehicle_prim_path_by_name(self, preferred_names):
+ stage = omni.usd.get_context().get_stage()
+ vehicle_root = self.vehicle_prim_path.rstrip("/")
+ preferred = set(preferred_names)
+
+ for prim in stage.Traverse():
+ prim_path = str(prim.GetPath())
+ if prim_path != vehicle_root and not prim_path.startswith(f"{vehicle_root}/"):
+ continue
+ if prim.GetName() in preferred:
+ return prim_path
+
+ return ""
+
def find_vehicle_rigid_body_prim_path(self, preferred_names):
stage = omni.usd.get_context().get_stage()
vehicle_root = self.vehicle_prim_path.rstrip("/")
@@ -2192,6 +1531,37 @@ class IsaacWorkshopRuntime:
return fallback_path
+ def get_vehicle_sensor_mount_parent_path(self):
+ """返回车载传感器的 USD 父 prim,优先挂到车辆 base_link 下。"""
+ base_link_path = self.find_vehicle_prim_path_by_name(["base_link"])
+ if base_link_path:
+ return base_link_path
+
+ rigid_body_path = self.find_vehicle_rigid_body_prim_path(["base_link"])
+ if rigid_body_path:
+ print(f"[WARN] 未按名称找到 base_link,车载传感器改挂到车辆刚体 prim: {rigid_body_path}")
+ return rigid_body_path
+
+ print(f"[WARN] 未找到车辆 base_link/刚体 prim,车载传感器改挂到车辆根 prim: {self.vehicle_prim_path}")
+ return self.vehicle_prim_path
+
+ def hide_sensor_placeholder_visuals(self):
+ """隐藏 URDF 里的传感器占位外壳,避免相机/LiDAR 打到自己的可视化模型。"""
+ stage = omni.usd.get_context().get_stage()
+ hidden_paths = []
+ for link_name in ("front_camera_link", "down_camera_link", "lidar_3d_link", "lidar_2d_link"):
+ link_path = self.find_vehicle_prim_path_by_name([link_name])
+ if not link_path:
+ continue
+ prim = stage.GetPrimAtPath(link_path)
+ if not prim or not prim.IsValid():
+ continue
+ UsdGeom.Imageable(prim).MakeInvisible()
+ hidden_paths.append(link_path)
+
+ if hidden_paths:
+ print(f"[*] 已隐藏传感器 URDF 占位外壳,避免相机/LiDAR 自遮挡: {hidden_paths}")
+
def add_vehicle_sensor_publishers(self):
if (
self.args.disable_vehicle_camera
@@ -2207,11 +1577,14 @@ class IsaacWorkshopRuntime:
connections = []
set_values = []
vehicle_root = self.vehicle_prim_path
+ vehicle_sensor_parent_path = self.get_vehicle_sensor_mount_parent_path()
+ print(f"[*] 车载传感器 USD 父 prim: {vehicle_sensor_parent_path}(安装参数按 base_link 局部坐标解释)")
if not self.args.disable_vehicle_camera:
- camera_quat = euler_angles_to_quat(np.array([0.0, -90.0, 0.0]), degrees=True)
+ # 光轴朝 base_link +X,画面上方朝 +Z,右方朝 -Y;修正原先顺时针旋转 90° 的图像。
+ camera_quat = np.array([0.5, 0.5, -0.5, -0.5])
vehicle_camera_render_product = self.create_ros_camera_render_product(
- prim_path=f"{vehicle_root}/SimFrontCamera",
+ prim_path=f"{vehicle_sensor_parent_path}/SimFrontCamera",
position=[
self.args.vehicle_camera_x,
self.args.vehicle_camera_y,
@@ -2231,7 +1604,7 @@ class IsaacWorkshopRuntime:
if not self.args.disable_down_camera:
down_camera_render_product = self.create_ros_camera_render_product(
- prim_path=f"{vehicle_root}/SimDownCamera",
+ prim_path=f"{vehicle_sensor_parent_path}/SimDownCamera",
position=[
self.args.down_camera_x,
self.args.down_camera_y,
@@ -2250,13 +1623,14 @@ class IsaacWorkshopRuntime:
])
if not self.args.disable_vehicle_lidar:
- lidar_path = f"{vehicle_root}/SimLidar3D"
+ lidar_parent_path = vehicle_sensor_parent_path
+ lidar_path = f"{lidar_parent_path}/SimLidar3D"
lidar_quat = euler_angles_to_quat(np.array([0.0, 0.0, 0.0]), degrees=True)
omni.kit.commands.execute(
"IsaacSensorCreateRtxLidar",
path=lidar_path,
parent=None,
- config=self.args.lidar_config,
+ config=self.args.lidar_3d_config,
translation=Gf.Vec3d(
self.args.vehicle_lidar_x,
self.args.vehicle_lidar_y,
@@ -2264,7 +1638,7 @@ class IsaacWorkshopRuntime:
),
orientation=Gf.Quatd(lidar_quat[0], lidar_quat[1], lidar_quat[2], lidar_quat[3]),
)
- render_product = rep.create.render_product(lidar_path, [1, 1])
+ render_product = rep.create.render_product(lidar_path, [360, 32])
self.sensor_render_products.append(render_product)
nodes.append(("VehicleLidar3D", "omni.isaac.ros2_bridge.ROS2RtxLidarHelper"))
connections.append(("OnTick.outputs:tick", "VehicleLidar3D.inputs:execIn"))
@@ -2277,7 +1651,8 @@ class IsaacWorkshopRuntime:
])
if not self.args.disable_vehicle_2d_lidar:
- lidar_2d_path = f"{vehicle_root}/SimLidar2D"
+ lidar_2d_parent_path = vehicle_sensor_parent_path
+ lidar_2d_path = f"{lidar_2d_parent_path}/SimLidar2D"
lidar_2d_quat = euler_angles_to_quat(np.array([0.0, 0.0, 0.0]), degrees=True)
omni.kit.commands.execute(
"IsaacSensorCreateRtxLidar",
@@ -2489,6 +1864,7 @@ class IsaacWorkshopRuntime:
)
self.import_vehicle_to_stage(world)
+ self.hide_sensor_placeholder_visuals()
self.add_vehicle_sensor_publishers()
keys = og.Controller.Keys
diff --git a/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/calibration_targets.py b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/calibration_targets.py
new file mode 100644
index 0000000..7dddf4e
--- /dev/null
+++ b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/calibration_targets.py
@@ -0,0 +1,452 @@
+"""标定板和标靶创建工具
+
+需要在 Isaac Sim 初始化后导入。
+"""
+
+import math
+
+import numpy as np
+from omni.isaac.core.objects import FixedCuboid
+from omni.isaac.core.utils.rotations import euler_angles_to_quat
+
+from usd_utils import create_textured_board, create_textured_top_strip
+
+
+def add_down_camera_intrinsic_target(world, stage, args, material):
+ """添加下视相机内参标定台"""
+ length = args.down_camera_target_length
+ width = args.down_camera_target_width
+ center_x = args.down_camera_target_x
+ center_y = args.down_camera_target_y
+ z_low = 0.012
+ z_high = args.down_camera_target_max_height
+ x_start = center_x - length / 2.0
+ x_low_end = x_start + length * 0.22
+ x_ramp_end = x_start + length * 0.74
+ x_end = center_x + length / 2.0
+ y_min = center_y - width / 2.0
+ y_max = center_y + width / 2.0
+ base_thickness = 0.006
+
+ world.scene.add(FixedCuboid(
+ prim_path="/World/Workshop/DownCameraIntrinsicTarget/Base",
+ name="down_camera_intrinsic_target_base",
+ position=np.array([center_x, center_y, base_thickness / 2.0]),
+ scale=np.array([length + 0.08, width + 0.08, base_thickness]),
+ color=np.array([0.045, 0.050, 0.052]),
+ ))
+
+ create_textured_top_strip(
+ stage,
+ "/World/Workshop/DownCameraIntrinsicTarget/CharucoRampSurface",
+ [x_start, x_low_end, x_ramp_end, x_end],
+ y_min,
+ y_max,
+ [z_low, z_low, z_high, z_high],
+ material,
+ )
+
+ specs = [{
+ "id": "down_camera_3d_charuco_ramp",
+ "target_type": "down_camera_intrinsic",
+ "pattern": "charuco",
+ "purpose": "intrinsic_calibration_with_depth_and_pose_gradient",
+ "center": [center_x, center_y, (z_low + z_high) / 2.0],
+ "length_m": length,
+ "width_m": width,
+ "squares_x": args.down_camera_target_squares_x,
+ "squares_y": args.down_camera_target_squares_y,
+ "square_size_m": width / args.down_camera_target_squares_y,
+ "min_height_m": z_low,
+ "max_height_m": z_high,
+ "recommended_drive_axis": "+x",
+ "recommended_speed_mps": 0.10,
+ "surfaces": [
+ {
+ "id": "low_flat_charuco",
+ "type": "flat",
+ "x_range_m": [x_start, x_low_end],
+ "z_range_m": [z_low, z_low],
+ },
+ {
+ "id": "continuous_slope_charuco",
+ "type": "continuous_slope",
+ "x_range_m": [x_low_end, x_ramp_end],
+ "z_range_m": [z_low, z_high],
+ },
+ {
+ "id": "high_flat_charuco",
+ "type": "flat",
+ "x_range_m": [x_ramp_end, x_end],
+ "z_range_m": [z_high, z_high],
+ },
+ ],
+ }]
+ return specs
+
+
+def lidar_2d_target_wall_slots(args):
+ """计算 2D LiDAR 靶标墙面插槽位置"""
+ if args.disable_2d_lidar_targets:
+ return {}
+
+ width = args.lidar_2d_target_width
+ height = args.lidar_2d_target_height
+ gap = 0.12
+ edge_margin = 0.18
+ bay_margin = 0.07
+ bay_width = max(1.90, 2.0 * width + gap + 2.0 * bay_margin)
+ bay_z_min = 0.0
+ bay_z_max = args.room_height
+ center_spacing = width + gap
+
+ def group_offsets(span, side):
+ usable_min = -span / 2.0 + edge_margin
+ usable_max = span / 2.0 - edge_margin
+ usable_width = max(0.0, usable_max - usable_min)
+ effective_bay_width = min(bay_width, usable_width) if usable_width > 0.0 else bay_width
+ if usable_width <= effective_bay_width:
+ axis_min = usable_min
+ axis_max = usable_max
+ elif side == "max":
+ axis_max = usable_max
+ axis_min = axis_max - effective_bay_width
+ else:
+ axis_min = usable_min
+ axis_max = axis_min + effective_bay_width
+ group_center = (axis_min + axis_max) / 2.0
+ vertical_offset = group_center - center_spacing / 2.0
+ slope_offset = group_center + center_spacing / 2.0
+ return vertical_offset, slope_offset, axis_min, axis_max
+
+ front_vertical, front_slope, front_min, front_max = group_offsets(args.room_width, "min")
+ _, _, right_min, right_max = group_offsets(args.room_length, "max")
+ right_vertical = (right_min + right_max) / 2.0
+
+ slots = {
+ "front": {
+ "vertical_offset": front_vertical,
+ "slope_offset": front_slope,
+ "reserved_axis_min": front_min,
+ "reserved_axis_max": front_max,
+ "reserved_z_min": bay_z_min,
+ "reserved_z_max": bay_z_max,
+ },
+ "right": {
+ "vertical_offset": right_vertical,
+ "reserved_axis_min": right_min,
+ "reserved_axis_max": right_max,
+ "reserved_z_min": bay_z_min,
+ "reserved_z_max": bay_z_max,
+ },
+ }
+ return slots
+
+
+def lidar_2d_checkerboard_reserved_zones(args):
+ """获取 2D LiDAR 标靶预留区域"""
+ slots = lidar_2d_target_wall_slots(args)
+ reserved_zones = {}
+ for wall, slot in slots.items():
+ reserved_zones[wall] = [{
+ "axis_min": slot["reserved_axis_min"],
+ "axis_max": slot["reserved_axis_max"],
+ "z_min": slot["reserved_z_min"],
+ "z_max": slot["reserved_z_max"],
+ "reason": "2d_lidar_extrinsic_corner_bay",
+ }]
+ return reserved_zones
+
+
+def calibration_board_layout(args, board_width, board_height):
+ """计算标定板布局"""
+ wall_standoff = 0.015
+ front_x = args.room_length / 2.0 - wall_standoff
+ back_x = -args.room_length / 2.0 + wall_standoff
+ left_y = args.room_width / 2.0 - wall_standoff
+ right_y = -args.room_width / 2.0 + wall_standoff
+ horizontal_gap = 0.18
+ vertical_gap = 0.16
+ side_margin = 0.35
+ bottom_margin = 0.32
+ top_margin = 0.28
+
+ def axis_positions(span, item_size, margin, gap):
+ available = span - 2.0 * margin
+ count = max(1, int((available + gap) // (item_size + gap)))
+ if count == 1:
+ return [0.0]
+ used = count * item_size + (count - 1) * gap
+ start = -used / 2.0 + item_size / 2.0
+ return [start + index * (item_size + gap) for index in range(count)]
+
+ def z_positions():
+ available = args.room_height - bottom_margin - top_margin
+ count = max(1, int((available + vertical_gap) // (board_height + vertical_gap)))
+ if count == 1:
+ return [bottom_margin + board_height / 2.0]
+ used = count * board_height + (count - 1) * vertical_gap
+ start = bottom_margin + board_height / 2.0 + max(0.0, available - used) / 2.0
+ return [start + index * (board_height + vertical_gap) for index in range(count)]
+
+ zs = z_positions()
+ front_back_offsets = axis_positions(args.room_width, board_width, side_margin, horizontal_gap)
+ side_offsets = axis_positions(args.room_length, board_width, side_margin, horizontal_gap)
+ reserved_zones = lidar_2d_checkerboard_reserved_zones(args)
+
+ board_specs = []
+
+ def overlaps_reserved_zone(wall, offset, z):
+ board_axis_min = offset - board_width / 2.0
+ board_axis_max = offset + board_width / 2.0
+ board_z_min = z - board_height / 2.0
+ board_z_max = z + board_height / 2.0
+ for zone in reserved_zones.get(wall, []):
+ axis_overlaps = board_axis_min < zone["axis_max"] and board_axis_max > zone["axis_min"]
+ z_overlaps = board_z_min < zone["z_max"] and board_z_max > zone["z_min"]
+ if axis_overlaps and z_overlaps:
+ return True
+ return False
+
+ def add_wall_grid(wall, fixed_value, offsets, rotation_deg):
+ for row, z in enumerate(zs):
+ for col, offset in enumerate(offsets):
+ if overlaps_reserved_zone(wall, offset, z):
+ continue
+ if wall == "front":
+ position = [fixed_value, offset, z]
+ elif wall == "back":
+ position = [fixed_value, offset, z]
+ elif wall == "left":
+ position = [offset, fixed_value, z]
+ else:
+ position = [offset, fixed_value, z]
+
+ board_specs.append({
+ "id": f"{wall}_wall_r{row:02d}_c{col:02d}",
+ "wall": wall,
+ "mount": "flush",
+ "purpose": "wall_checkerboard_array",
+ "position": position,
+ "rotation_deg": rotation_deg,
+ })
+
+ add_wall_grid("front", front_x, front_back_offsets, [90, 0, 90])
+ add_wall_grid("back", back_x, front_back_offsets, [90, 0, -90])
+ add_wall_grid("left", left_y, side_offsets, [90, 0, 0])
+ add_wall_grid("right", right_y, side_offsets, [90, 0, 180])
+
+ return board_specs
+
+
+def add_calibration_boards(stage, args, board_width, board_height, material):
+ """添加墙面标定板"""
+ board_specs = calibration_board_layout(args, board_width, board_height)
+ for spec in board_specs:
+ create_textured_board(
+ stage,
+ f"/World/Workshop/CalibrationBoards/{spec['id']}",
+ board_width,
+ board_height,
+ spec["position"],
+ spec["rotation_deg"],
+ material,
+ )
+ return board_specs
+
+
+def add_lidar_2d_panel(world, prim_path, name, position, scale, color, rotation_deg=None):
+ """添加 2D LiDAR 标定面板"""
+ orientation = None
+ if rotation_deg is not None:
+ orientation = np.array(euler_angles_to_quat(np.array(rotation_deg), degrees=True))
+ world.scene.add(FixedCuboid(
+ prim_path=prim_path,
+ name=name,
+ position=np.array(position),
+ orientation=orientation,
+ scale=np.array(scale),
+ color=np.array(color),
+ ))
+
+
+def add_2d_lidar_calibration_targets(world, args):
+ """添加 2D LiDAR 标定靶标"""
+ thickness = args.lidar_2d_target_thickness
+ width = args.lidar_2d_target_width
+ height = args.lidar_2d_target_height
+ bottom_z = args.lidar_2d_target_bottom_z
+ center_z = bottom_z + height / 2.0
+ angle_deg = args.lidar_2d_target_slope_angle_deg
+ angle_rad = math.radians(angle_deg)
+ sloped_length = height / max(math.cos(angle_rad), 1e-3)
+ standoff = 0.06
+ slots = lidar_2d_target_wall_slots(args)
+
+ front_wall_x = args.room_length / 2.0
+ right_wall_y = -args.room_width / 2.0
+
+ front_base_x = front_wall_x - standoff - thickness / 2.0
+ side_right_base_y = right_wall_y + standoff + thickness / 2.0
+
+ front_slope_center_x = front_wall_x - standoff - height / 2.0 - thickness
+
+ front_vertical_y = slots["front"]["vertical_offset"]
+ front_slope_y = slots["front"]["slope_offset"]
+ right_vertical_x = slots["right"]["vertical_offset"]
+
+ vertical_color = [0.92, 0.82, 0.18]
+ slope_color = [0.12, 0.65, 0.95]
+ specs = []
+
+ def add_spec(spec):
+ specs.append(spec)
+ return spec
+
+ front_vertical = add_spec({
+ "id": "front_vertical_reference_panel",
+ "wall": "front",
+ "type": "vertical_reference",
+ "position": [front_base_x, front_vertical_y, center_z],
+ "scale": [thickness, width, height],
+ "rotation_deg": [0.0, 0.0, 0.0],
+ "nominal_plane": "x = room_length/2 - standoff - thickness",
+ })
+ add_lidar_2d_panel(
+ world,
+ "/World/Workshop/Lidar2DCalibrationTargets/FrontVerticalReference",
+ "front_vertical_reference_panel",
+ front_vertical["position"],
+ front_vertical["scale"],
+ vertical_color,
+ )
+
+ front_slope = add_spec({
+ "id": "front_45deg_height_encoding_panel",
+ "wall": "front",
+ "type": "height_encoding_slope",
+ "position": [front_slope_center_x, front_slope_y, center_z],
+ "scale": [thickness, width, sloped_length],
+ "rotation_deg": [0.0, -angle_deg, 0.0],
+ "slope_angle_deg": angle_deg,
+ "height_to_range_sign": "higher_scan_plane_farther_from_front_wall",
+ })
+ add_lidar_2d_panel(
+ world,
+ "/World/Workshop/Lidar2DCalibrationTargets/FrontSlope45",
+ "front_45deg_height_encoding_panel",
+ front_slope["position"],
+ front_slope["scale"],
+ slope_color,
+ front_slope["rotation_deg"],
+ )
+
+ right_vertical = add_spec({
+ "id": "right_vertical_reference_panel",
+ "wall": "right",
+ "type": "vertical_reference",
+ "position": [right_vertical_x, side_right_base_y, center_z],
+ "scale": [width, thickness, height],
+ "rotation_deg": [0.0, 0.0, 0.0],
+ "nominal_plane": "y = -room_width/2 + standoff + thickness",
+ })
+ add_lidar_2d_panel(
+ world,
+ "/World/Workshop/Lidar2DCalibrationTargets/RightVerticalReference",
+ "right_vertical_reference_panel",
+ right_vertical["position"],
+ right_vertical["scale"],
+ vertical_color,
+ )
+
+ return specs
+
+
+def add_floor_marker(world, prim_path, name, position, scale, color):
+ """添加地面标记"""
+ world.scene.add(FixedCuboid(
+ prim_path=prim_path,
+ name=name,
+ position=np.array(position),
+ scale=np.array(scale),
+ color=np.array(color),
+ ))
+
+
+def add_calibration_floor_fixtures(world, args):
+ """添加地面标定设施"""
+ z = 0.012
+ thickness = 0.012
+ length = args.straight_track_length
+ width = args.straight_track_width
+
+ add_floor_marker(
+ world,
+ "/World/Workshop/CalibrationFixtures/StraightTrack",
+ "straight_track",
+ [0.0, 0.0, z],
+ [length, width, thickness],
+ [0.08, 0.12, 0.10],
+ )
+ add_floor_marker(
+ world,
+ "/World/Workshop/CalibrationFixtures/StraightCenterLine",
+ "straight_center_line",
+ [0.0, 0.0, z + thickness],
+ [length, 0.035, thickness],
+ [0.95, 0.95, 0.15],
+ )
+ for side, y in (("Left", width / 2.0), ("Right", -width / 2.0)):
+ add_floor_marker(
+ world,
+ f"/World/Workshop/CalibrationFixtures/StraightBoundary{side}",
+ f"straight_boundary_{side.lower()}",
+ [0.0, y, z + thickness],
+ [length, 0.025, thickness],
+ [0.95, 0.78, 0.08],
+ )
+
+ stop_x = min(length / 2.0 - 0.35, args.room_length / 2.0 - 1.0)
+ add_floor_marker(
+ world,
+ "/World/Workshop/CalibrationFixtures/StopAccuracyTarget",
+ "stop_accuracy_target",
+ [stop_x, 0.0, z + 2.0 * thickness],
+ [0.6, 0.9, thickness],
+ [0.88, 0.10, 0.10],
+ )
+
+ lateral_x = -min(length / 2.0 - 0.8, args.room_length / 2.0 - 1.2)
+ add_floor_marker(
+ world,
+ "/World/Workshop/CalibrationFixtures/LateralMotionPad",
+ "lateral_motion_pad",
+ [lateral_x, 0.0, z + thickness],
+ [0.75, 2.0, thickness],
+ [0.10, 0.34, 0.88],
+ )
+
+ arc_center = np.array([0.0, -args.room_width * 0.20])
+ arc_radius = min(args.arc_track_radius, args.room_width * 0.32)
+ marker_count = 25
+ for index in range(marker_count):
+ theta = math.radians(20.0 + 140.0 * index / (marker_count - 1))
+ x = arc_center[0] + arc_radius * math.cos(theta)
+ y = arc_center[1] + arc_radius * math.sin(theta)
+ add_floor_marker(
+ world,
+ f"/World/Workshop/CalibrationFixtures/ArcMarker_{index:02d}",
+ f"arc_marker_{index:02d}",
+ [x, y, z + 3.0 * thickness],
+ [0.10, 0.10, thickness],
+ [0.12, 0.75, 0.35],
+ )
+
+ add_floor_marker(
+ world,
+ "/World/Workshop/CalibrationFixtures/SensorCapturePad",
+ "sensor_capture_pad",
+ [0.0, args.room_width * 0.28, z + thickness],
+ [1.2, 0.9, thickness],
+ [0.42, 0.18, 0.78],
+ )
diff --git a/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/image_utils.py b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/image_utils.py
new file mode 100644
index 0000000..3387991
--- /dev/null
+++ b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/image_utils.py
@@ -0,0 +1,108 @@
+"""图像生成工具模块
+
+提供棋盘格和 ChArUco 纹理生成功能。
+"""
+
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+
+def create_checkerboard_image(filepath, rows=6, cols=9, square_size_px=500):
+ """创建棋盘格纹理图像"""
+ width = cols * square_size_px
+ height = rows * square_size_px
+ img = np.ones((height, width, 3), dtype=np.uint8) * 255
+
+ for r in range(rows):
+ for c in range(cols):
+ if (r + c) % 2 == 1:
+ img[r * square_size_px:(r + 1) * square_size_px,
+ c * square_size_px:(c + 1) * square_size_px] = 0
+
+ border = square_size_px
+ img_with_border = np.pad(
+ img,
+ pad_width=((border, border), (border, border), (0, 0)),
+ mode="constant",
+ constant_values=255,
+ )
+
+ abs_filepath = Path(filepath).resolve()
+ abs_filepath.parent.mkdir(parents=True, exist_ok=True)
+ Image.fromarray(img_with_border).save(abs_filepath)
+ usd_filepath = str(abs_filepath).replace("\\", "/")
+ print(f"[*] 棋盘格纹理已生成: {usd_filepath}")
+ return usd_filepath
+
+
+def create_charuco_image(filepath, squares_x=30, squares_y=10, square_size_px=90):
+ """创建 ChArUco 纹理图像"""
+ width = squares_x * square_size_px
+ height = squares_y * square_size_px
+ abs_filepath = Path(filepath).resolve()
+ abs_filepath.parent.mkdir(parents=True, exist_ok=True)
+
+ try:
+ import cv2
+ dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)
+ try:
+ board = cv2.aruco.CharucoBoard((squares_x, squares_y), 1.0, 0.70, dictionary)
+ except TypeError:
+ board = cv2.aruco.CharucoBoard_create(squares_x, squares_y, 1.0, 0.70, dictionary)
+ if hasattr(board, "generateImage"):
+ img = board.generateImage((width, height), marginSize=0)
+ else:
+ img = board.draw((width, height), marginSize=0)
+ if len(img.shape) == 2:
+ img = np.repeat(img[:, :, None], 3, axis=2)
+ Image.fromarray(img).save(abs_filepath)
+ usd_filepath = str(abs_filepath).replace("\\", "/")
+ print(f"[*] ChArUco 纹理已生成: {usd_filepath}")
+ return usd_filepath
+ except Exception as exc:
+ print(f"[WARN] OpenCV ChArUco 生成失败,使用内置 ChArUco 风格纹理: {exc}")
+
+ # 备用实现
+ img = np.ones((height, width, 3), dtype=np.uint8) * 255
+ marker_cells = 6
+ marker_margin = max(3, square_size_px // 7)
+ marker_size = square_size_px - 2 * marker_margin
+ marker_cell_px = max(1, marker_size // marker_cells)
+ marker_px = marker_cell_px * marker_cells
+
+ def marker_bits(marker_id):
+ marker = np.ones((marker_cells, marker_cells), dtype=np.uint8) * 255
+ marker[0, :] = 0
+ marker[-1, :] = 0
+ marker[:, 0] = 0
+ marker[:, -1] = 0
+ state = (marker_id + 1) * 1103515245 + 12345
+ for r in range(1, marker_cells - 1):
+ for c in range(1, marker_cells - 1):
+ state = (state * 1664525 + 1013904223 + r * 97 + c * 193) & 0xFFFFFFFF
+ marker[r, c] = 0 if (state & 1) else 255
+ return marker
+
+ marker_id = 0
+ for r in range(squares_y):
+ for c in range(squares_x):
+ y0 = r * square_size_px
+ x0 = c * square_size_px
+ if (r + c) % 2 == 1:
+ img[y0:y0 + square_size_px, x0:x0 + square_size_px] = 0
+ continue
+ marker = marker_bits(marker_id)
+ marker_img = np.kron(marker, np.ones((marker_cell_px, marker_cell_px), dtype=np.uint8))
+ marker_img = marker_img[:marker_px, :marker_px]
+ marker_rgb = np.repeat(marker_img[:, :, None], 3, axis=2)
+ marker_y = y0 + (square_size_px - marker_px) // 2
+ marker_x = x0 + (square_size_px - marker_px) // 2
+ img[marker_y:marker_y + marker_px, marker_x:marker_x + marker_px] = marker_rgb
+ marker_id += 1
+
+ Image.fromarray(img).save(abs_filepath)
+ usd_filepath = str(abs_filepath).replace("\\", "/")
+ print(f"[*] ChArUco 风格纹理已生成: {usd_filepath}")
+ return usd_filepath
diff --git a/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/urdf_utils.py b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/urdf_utils.py
new file mode 100644
index 0000000..b05d654
--- /dev/null
+++ b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/urdf_utils.py
@@ -0,0 +1,50 @@
+"""URDF 处理工具模块
+
+提供 URDF 文件处理功能。
+"""
+
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from utils import make_mesh_path_absolute
+
+
+def prepare_isaac_urdf(source_urdf_path, output_urdf_path):
+ """准备 Isaac Sim 可用的 URDF 文件
+
+ 处理网格路径和关节原点设置。
+ """
+ tree = ET.parse(source_urdf_path)
+ root = tree.getroot()
+ source_dir = source_urdf_path.parent
+
+ local_mesh_links = {
+ "left_steering_hinge",
+ "right_steering_hinge",
+ "left_wheel",
+ "right_wheel",
+ "left_rear_wheel",
+ "right_rear_wheel",
+ "camera",
+ "laser",
+ }
+
+ for link in root.findall("link"):
+ link_name = link.get("name", "")
+ for section_name in ("visual", "collision"):
+ section = link.find(section_name)
+ if section is None:
+ continue
+ mesh = section.find("geometry/mesh")
+ if mesh is not None and mesh.get("filename"):
+ mesh.set("filename", make_mesh_path_absolute(mesh.get("filename"), source_dir))
+ if link_name in local_mesh_links:
+ origin = section.find("origin")
+ if origin is None:
+ origin = ET.SubElement(section, "origin")
+ origin.set("xyz", "0 0 0")
+ origin.set("rpy", "0 0 0")
+
+ output_urdf_path.parent.mkdir(parents=True, exist_ok=True)
+ tree.write(output_urdf_path, encoding="utf-8", xml_declaration=True)
+ return output_urdf_path
diff --git a/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/usd_utils.py b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/usd_utils.py
new file mode 100644
index 0000000..637f451
--- /dev/null
+++ b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/usd_utils.py
@@ -0,0 +1,94 @@
+"""USD 材质和几何体创建工具
+
+需要在 Isaac Sim 初始化后导入。
+"""
+
+from pxr import Gf, Sdf, UsdGeom, UsdShade, Vt
+
+
+def create_raw_usd_material(stage, mat_path, tex_path):
+ """创建 USD PBR 材质"""
+ material = UsdShade.Material.Define(stage, mat_path)
+ pbr_shader = UsdShade.Shader.Define(stage, f"{mat_path}/PBRShader")
+ pbr_shader.CreateIdAttr("UsdPreviewSurface")
+ pbr_shader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(1.0)
+ pbr_shader.CreateInput("metallic", Sdf.ValueTypeNames.Float).Set(0.0)
+
+ tex_sampler = UsdShade.Shader.Define(stage, f"{mat_path}/diffuseTexture")
+ tex_sampler.CreateIdAttr("UsdUVTexture")
+ tex_sampler.CreateInput("file", Sdf.ValueTypeNames.Asset).Set(Sdf.AssetPath(tex_path))
+ tex_sampler.CreateInput("magFilter", Sdf.ValueTypeNames.Token).Set("nearest")
+ tex_sampler.CreateInput("minFilter", Sdf.ValueTypeNames.Token).Set("nearest")
+
+ st_reader = UsdShade.Shader.Define(stage, f"{mat_path}/stReader")
+ st_reader.CreateIdAttr("UsdPrimvarReader_float2")
+ st_reader.CreateInput("varname", Sdf.ValueTypeNames.Token).Set("st")
+
+ tex_sampler.CreateInput("st", Sdf.ValueTypeNames.Float2).ConnectToSource(st_reader.ConnectableAPI(), "result")
+ pbr_shader.CreateInput("diffuseColor", Sdf.ValueTypeNames.Color3f).ConnectToSource(tex_sampler.ConnectableAPI(), "rgb")
+ material.CreateSurfaceOutput().ConnectToSource(pbr_shader.ConnectableAPI(), "surface")
+ return material
+
+
+def create_textured_board(stage, prim_path, width, height, center, euler_rot_deg, usd_material):
+ """创建带纹理的标定板"""
+ mesh = UsdGeom.Mesh.Define(stage, prim_path)
+ half_width, half_height = width / 2.0, height / 2.0
+ mesh.GetPointsAttr().Set(Vt.Vec3fArray([
+ Gf.Vec3f(-half_width, -half_height, 0),
+ Gf.Vec3f(half_width, -half_height, 0),
+ Gf.Vec3f(half_width, half_height, 0),
+ Gf.Vec3f(-half_width, half_height, 0),
+ ]))
+ mesh.GetFaceVertexCountsAttr().Set([4])
+ mesh.GetFaceVertexIndicesAttr().Set([0, 1, 2, 3])
+ mesh.GetNormalsAttr().Set([Gf.Vec3f(0, 0, 1)] * 4)
+ mesh.SetNormalsInterpolation(UsdGeom.Tokens.vertex)
+
+ primvars_api = UsdGeom.PrimvarsAPI(mesh)
+ st_primvar = primvars_api.CreatePrimvar("st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.vertex)
+ st_primvar.Set([Gf.Vec2f(0, 0), Gf.Vec2f(1, 0), Gf.Vec2f(1, 1), Gf.Vec2f(0, 1)])
+ mesh.GetExtentAttr().Set([Gf.Vec3f(-half_width, -half_height, -0.01), Gf.Vec3f(half_width, half_height, 0.01)])
+
+ xform = UsdGeom.Xformable(mesh)
+ xform.AddTranslateOp().Set(Gf.Vec3d(*center))
+ xform.AddRotateXYZOp().Set(Gf.Vec3f(*euler_rot_deg))
+ UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim()).Bind(usd_material)
+ return mesh
+
+
+def create_textured_top_strip(stage, prim_path, x_edges, y_min, y_max, z_values, usd_material):
+ """创建顶部纹理条带(用于下视相机标定台)"""
+ mesh = UsdGeom.Mesh.Define(stage, prim_path)
+ x0 = x_edges[0]
+ x1 = x_edges[-1]
+ total_length = max(x1 - x0, 1e-6)
+
+ points = []
+ st_values = []
+ for x, z in zip(x_edges, z_values):
+ u = (x - x0) / total_length
+ points.append(Gf.Vec3f(x, y_min, z))
+ points.append(Gf.Vec3f(x, y_max, z))
+ st_values.append(Gf.Vec2f(u, 0.0))
+ st_values.append(Gf.Vec2f(u, 1.0))
+
+ face_vertex_counts = []
+ face_vertex_indices = []
+ for index in range(len(x_edges) - 1):
+ face_vertex_counts.append(4)
+ face_vertex_indices.extend([2 * index, 2 * (index + 1), 2 * (index + 1) + 1, 2 * index + 1])
+
+ mesh.GetPointsAttr().Set(Vt.Vec3fArray(points))
+ mesh.GetFaceVertexCountsAttr().Set(face_vertex_counts)
+ mesh.GetFaceVertexIndicesAttr().Set(face_vertex_indices)
+ mesh.GetExtentAttr().Set([
+ Gf.Vec3f(min(x_edges), y_min, min(z_values) - 0.005),
+ Gf.Vec3f(max(x_edges), y_max, max(z_values) + 0.005),
+ ])
+
+ primvars_api = UsdGeom.PrimvarsAPI(mesh)
+ st_primvar = primvars_api.CreatePrimvar("st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.vertex)
+ st_primvar.Set(st_values)
+ UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim()).Bind(usd_material)
+ return mesh
diff --git a/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/utils.py b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/utils.py
new file mode 100644
index 0000000..b8b1eb4
--- /dev/null
+++ b/agv_calib_brain/src/simulation/isaac_workshop_sim/scripts/utils.py
@@ -0,0 +1,80 @@
+"""工具函数模块
+
+提供不依赖 omni.isaac 的纯工具函数。
+"""
+
+import math
+from pathlib import Path
+
+import numpy as np
+
+
+def clamp(value, low, high):
+ """将值限制在指定范围内"""
+ return max(low, min(high, value))
+
+
+def resolve_topic(prefix, suffix):
+ """解析 ROS 话题名"""
+ normalized_prefix = prefix.rstrip("/")
+ normalized_suffix = suffix if suffix.startswith("/") else f"/{suffix}"
+ return f"{normalized_prefix}{normalized_suffix}" if normalized_prefix else normalized_suffix
+
+
+def quat_wxyz_to_yaw(quat_wxyz):
+ """从四元数 (w, x, y, z) 提取偏航角"""
+ w, x, y, z = quat_wxyz
+ siny_cosp = 2.0 * (w * z + x * y)
+ cosy_cosp = 1.0 - 2.0 * (y * y + z * z)
+ return math.atan2(siny_cosp, cosy_cosp)
+
+
+def normalize_angle(angle):
+ """将角度归一化到 [-pi, pi] 范围"""
+ return math.atan2(math.sin(angle), math.cos(angle))
+
+
+def chassis_type_value(name, chassis_type_cls=None):
+ """将底盘类型名称转换为枚举值"""
+ if chassis_type_cls is None:
+ return 0
+ mapping = {
+ "ackermann": chassis_type_cls.ACKERMANN,
+ "differential": chassis_type_cls.DIFFERENTIAL,
+ "single_steer": chassis_type_cls.SINGLE_STEER_WHEEL,
+ "multi_steer": chassis_type_cls.MULTI_STEER_WHEEL,
+ }
+ return mapping.get(name, chassis_type_cls.CHASSIS_TYPE_UNSPECIFIED)
+
+
+def controller_algorithm_value(name, controller_type_cls=None):
+ """将控制器算法名称转换为枚举值"""
+ if controller_type_cls is None:
+ return 0
+ mapping = {
+ "pid": controller_type_cls.PID,
+ "mpc": controller_type_cls.MPC,
+ "lqr": controller_type_cls.LQR,
+ "pure_pursuit": controller_type_cls.PURE_PURSUIT,
+ }
+ return mapping.get(name, controller_type_cls.CONTROLLER_ALGORITHM_UNSPECIFIED)
+
+
+def vehicle_state_from_isaac(agv):
+ """从 Isaac Sim 车辆对象获取状态"""
+ position, quat = agv.get_world_pose()
+ yaw_rad = quat_wxyz_to_yaw(quat)
+ linear_velocity = agv.get_linear_velocity()
+ try:
+ angular_velocity = agv.get_angular_velocity()
+ except Exception:
+ angular_velocity = np.array([0.0, 0.0, 0.0])
+ return position, yaw_rad, linear_velocity, angular_velocity
+
+
+def make_mesh_path_absolute(mesh_filename, source_dir):
+ """将相对网格路径转换为绝对路径"""
+ mesh_path = Path(mesh_filename)
+ if mesh_path.is_absolute():
+ return str(mesh_path)
+ return str((source_dir / mesh_path).resolve())