x5 132gs想自行标定,出流后想看到实时图像但打不开http://192.168.127.10:8000/TogetheROS/

我在终端一输入:source /opt/tros/humble/setup.bash
ros2 run mipi_cam mipi_cam --ros-args -p device_mode:=“dual”
-p out_format:=“bgr8” -p dual_combine:=2 -p framerate:=10.0 -p gdc_enable:=false出流后,想看实时图像以确认棋盘格在相机视野中的位置,但打不开http://192.168.127.10:8000/TogetheROS/。
终端二输入的是:source /opt/tros/humble/setup.bash
python3 save_frame.py
save_frame.py的内容是:
import sys, os, threading, cv2, rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge

OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser(‘~/calib_data’)
os.makedirs(OUT, exist_ok=True)

class Grabber(Node):
def init(self):
super().init(‘frame_grabber’)
self.frame = None
self.count = 0
self.create_subscription(Image, ‘/image_combine_raw’,
lambda m: setattr(self, ‘frame’, CvBridge().imgmsg_to_cv2(m, ‘bgr8’)), 10)
def save(self):
if self.frame is None:
print(‘还没收到图像’); return
self.count += 1
# === 从Saver脚本提取的切分保存逻辑,仅这一块替换 ===
img = self.frame
h, w = img.shape[:2]
assert w == 1088 and h % 2 == 0, (w, h) # 预期 w*h=1088x2560
half = h // 2
ok1 = cv2.imwrite(f’{OUT}/left_{self.count:04d}.png’, img[:half, :])
ok2 = cv2.imwrite(f’{OUT}/right_{self.count:04d}.png’, img[half:, :])
print(f’saved {self.count:04d}, {w}x{h} → 单目 {w}x{half}, write={ok1},{ok2}')

rclpy.init()
node = Grabber()
threading.Thread(target=rclpy.spin, args=(node,), daemon=True).start()
while input('摆好棋盘格按回车存一张,q 退出: ').strip() != ‘q’:
node.save()

8000 这个页面不是 mipi_cam 提供的。mipi_cam 只往 ROS 话题发图,8000 端口上的 Web 服务由 websocket 节点(内含 nginx)起,而且它只渲染 JPEG 话题,中间还要一个 hobot_codec 做编码。你现在只跑了 ros2 run mipi_cam mipi_cam,8000 上根本没有进程在监听,所以页面打不开;手册里给的访问地址也是根路径 http://IP:8000,进去后点左上角“Web 端展示”。

只看棋盘格摆位(最省事):官方 SC132GS 双目模块(GS130WI,1088x1280 单目 / 1088x2560 拼接,与你脚本里的断言一致)已有一条 launch 把采集+JPEG 编码+Web 一起拉起。先 Ctrl+C 停掉手动起的 mipi_cam(同一路相机不能两个节点抢),再执行:

source /opt/tros/humble/setup.bash
ros2 launch mipi_cam mipi_cam_dual_channel_websocket.launch.py

PC 浏览器打开 http://192.168.127.10:8000:

依据:GS130WI 双目相机快速开始(第 3、4 步),原理见数据展示 · Web 展示。

要保持你现在这条 bgr8 → /image_combine_raw 的链路给 save_frame.py 用(CvBridge 取 bgr8 不能改 nv12,所以别直接换成上面那条 launch),那就手动补两个节点:

# 终端B:把 bgr8 话题编码成 jpeg
ros2 launch hobot_codec hobot_codec.launch.py codec_in_mode:=ros codec_in_format:=bgr8 \
  codec_out_mode:=ros codec_out_format:=jpeg \
  codec_sub_topic:=/image_combine_raw codec_pub_topic:=/image_jpeg

# 终端C:起 Web 服务
ros2 launch websocket websocket.launch.py websocket_image_topic:=/image_jpeg websocket_only_show_image:=true

参数名取自官方 图像编解码 示例(示例是 shared_mem/nv12 场景,这里改 ros/bgr8;若 codec 报不支持该入格式,就退回示例的 mipi_out_format:=nv12 + codec_in_format:=nv12,取图脚本相应改成解析 nv12)。另一条更轻的路子:PC 上装 rqt,用 rqt_image_view 订阅 /image_combine_raw 直接看 bgr8,见 数据展示 · RQt 展示。

页面还是打不开时按顺序看:

  • ls /opt/tros/humble/share/mipi_cam/launch/ 里没有 mipi_cam_dual_channel_websocket.launch.py,说明 TROS 版本偏旧,按上面 GS130WI 文档“升级到最新版 TROS”后再试。
  • 板端 netstat -tulnp | grep 8000 无 LISTEN:看 websocket 终端日志是否报端口占用,lsof -i:8000 找到残留进程 kill,或 sudo pkill nginx 后重拉,见 FAQ Q13。
  • 板端 ip a 确认 192.168.127.10 就是当前联网网卡的地址,PC 能 ping 通、浏览器代理已关;开发者工具 Console/Network 里若报 WebSocket 连接失败,基本就是网络/IP 这一层。

一个问题:你这套是地瓜的 GS130W/GS130WI 双目模组,还是一体机(MagicBox 有线口默认就是 192.168.127.10)?如果是后者,双目示例走的是 /userdata/magicbox 下的脚本而不是上面手动链路,我再按那条路给命令。

好的,你说的‘# 终端B:把 bgr8 话题编码成 jpeg
ros2 launch hobot_codec hobot_codec.launch.py codec_in_mode:=ros codec_in_format:=bgr8
codec_out_mode:=ros codec_out_format:=jpeg
codec_sub_topic:=/image_combine_raw codec_pub_topic:=/image_jpeg’

是与‘source /opt/tros/humble/setup.bash
ros2 run mipi_cam mipi_cam --ros-args -p device_mode:=“dual”
-p out_format:=“bgr8” -p dual_combine:=2 -p framerate:=10.0 -p gdc_enable:=false’
起同样作用的吗?

为什么‘device_mode:=“dual”
-p out_format:=“bgr8” -p dual_combine:=2 -p framerate:=10.0 -p gdc_enable:=false’这些信息都没写呢?

或者能不能在终端B输入‘source /opt/tros/humble/setup.bash
ros2 run mipi_cam mipi_cam --ros-args -p device_mode:=“dual”
-p out_format:=“bgr8” -p dual_combine:=2 -p framerate:=10.0 -p gdc_enable:=false’

然后直接在终端C输入你写的‘# 终端C:起 Web 服务
ros2 launch websocket websocket.launch.py websocket_image_topic:=/image_jpeg websocket_only_show_image:=
true’呢?

1. 不等价,这是三个不同节点,职责不重叠:

命令 节点 干的事
你终端A的 mipi_cam mipi_cam 驱动 sensor、出图,发到 /image_combine_raw
hobot_codec 那条 hobot_codec 订阅图像话题,硬件编码成 JPEG,发到 /image_jpeg
websocket 那条 websocket 起 nginx 占 8000 端口,把 JPEG 推给浏览器

GS130WI 快速开始里写得很直接:“由于发布原始数据,需要一个编码 JPEG 图像的节点,一个用 webservice 发布的节点”,所以只跑 mipi_cam 时 8000 上没进程、页面自然打不开。

2. 参数各归各的节点,不能互相搬。 device_mode、out_format、dual_combine、framerate、gdc_enable 全是 mipi_cam 节点的参数,决定“相机怎么出图”;而 codec 的参数(codec_in_mode、codec_in_format、codec_sub_topic、codec_pub_topic)只描述“从哪个话题拿什么格式、编成什么、发到哪”。相机已经由终端A负责采集了,codec 只需要知道输入是 /image_combine_raw 的 bgr8,写 device_mode 它既不认也用不上。反过来,mipi_cam 命令里也不需要 websocket 的任何参数。

3. 不能跳过 codec。 Web 端只负责渲染 JPEG 图像,它订阅的话题必须是 JPEG;你把 bgr8 的 /image_combine_raw 直接交给 websocket_image_topic,页面能连上但这帧解不出来、看不到画面。所以必须是 mipi_cam → hobot_codec → websocket 三段,官方 图像编解码 和 数据展示 · Web 展示 给的示例都是这三步。

省事的替代只有一条:官方 mipi_cam_dual_channel_websocket.launch.py 把采集+编码+Web 一起拉起(GS130WI 快速开始 第 3 步),跑通后浏览器里应看到这样的双目画面:

但那条 launch 里的相机出图格式由 launch 决定,不一定是你脚本断言的 1088x2560 bgr8,会和 save_frame.py 的 CvBridge bgr8 取图冲突。你现在要同时用脚本存档,就保留手动三段最稳;确认 codec 起来了看 ros2 topic hz /image_jpeg 有没有帧率,codec 终端若报入格式不支持,把日志贴上来我再核对参数。

我在终端A输入:ros2 run mipi_cam mipi_cam --ros-args -p device_mode:=“dual”
-p out_format:=“bgr8” -p dual_combine:=2 -p framerate:=10.0 -p gdc_enable:=false
日志:
[INFO] [0946688871.144486598] [mipi_cap]: capture a frame, handle: 165733, id: 12251, timestamps: 4064.531180, current uptime: 4064.555971, sys_timestamps: 946688871.119668, HW timestamp: 4070.965300, trig timestamp: 0.000000,current timestamp: 946688871.144457, laps ms: 24.789095ms, exposure_time: 2147.483647ms.
[INFO] [0946688871.156017806] [mipi_cap]: capture a frame, handle: 362341, id: 12252, timestamps: 4064.544359, current uptime: 4064.567498, sys_timestamps: 946688871.132846, HW timestamp: 4070.978480, trig timestamp: 0.000000,current timestamp: 946688871.155985, laps ms: 23.138404ms, exposure_time: 2147.483647ms.

终端B输入:ros2 launch hobot_codec hobot_codec.launch.py codec_in_mode:=ros codec_in_format:=bgr8
codec_out_mode:=ros codec_out_format:=jpeg
codec_sub_topic:=/image_combine_raw codec_pub_topic:=/image_jpeg
日志:[INFO] [launch]: All log files can be found below /home/sunrise/.ros/log/2000-01-01-09-05-19-160155-ubuntu-32566
[INFO] [launch]: Default logging verbosity is set to INFO
Hobot shm pkg enables zero-copy with fastrtps profiles file: /opt/tros/humble/lib/hobot_shm/config/shm_fastdds.xml
Hobot shm pkg sets RMW_FASTRTPS_USE_QOS_FROM_XML: 1
env of RMW_FASTRTPS_USE_QOS_FROM_XML is 1 , ignore env setting
[INFO] [hobot_codec_republish-1]: process started with pid [32583]
[hobot_codec_republish-1] [WARN] [0946688719.718116484] [hobot_codec_2bf5b169]: Parameters:
[hobot_codec_republish-1] sub_topic: /image_combine_raw
[hobot_codec_republish-1] pub_topic: /image_jpeg
[hobot_codec_republish-1] channel: 1
[hobot_codec_republish-1] in_mode: ros
[hobot_codec_republish-1] out_mode: ros
[hobot_codec_republish-1] in_format: bgr8
[hobot_codec_republish-1] out_format: jpeg
[hobot_codec_republish-1] jpg_quality: 60.00
[hobot_codec_republish-1] input_framerate: 30
[hobot_codec_republish-1] output_framerate: -1
[hobot_codec_republish-1] dump_output: false
[hobot_codec_republish-1] dump_file_prefix: ./dump_codec_output
[hobot_codec_republish-1] dump_frame_count: -1 (unlimited)
[hobot_codec_republish-1] [WARN] [0946688719.727546359] [HobotCodecImpl]: platform x5
[hobot_codec_republish-1] [WARN] [0946688719.838361651] [HobotVenc]: init_pic_w_: 1088, init_pic_h_: 2560, alined_pic_w_: 1088, alined_pic_h_: 2560, aline_w_: 16, aline_h_: 16
[hobot_codec_republish-1] [WARN] [0946688724.755345528] [hobot_codec_2bf5b169]: Pub img fps [32.73]

终端C输入:ros2 launch websocket websocket.launch.py websocket_image_topic:=/image_jpeg websocket_only_show_image:=true
日志:-bash: ros2: command not found

终端C 的 -bash: ros2: command not found 跟相机、编码都无关:这个终端没执行过 source /opt/tros/humble/setup.bash,PATH 里找不到 ros2。每个新开的终端都要单独 source 一次。

另外从日志看,终端A、B 其实都成功了:终端A 正常出帧(约 24ms/帧),终端B 的 codec 打出 init_pic_w_: 1088, init_pic_h_: 2560、Pub img fps [32.73],说明 bgr8 入格式被接受、/image_jpeg 已在发图,尺寸也与 save_frame.py 里 1088x2560 切两半的断言一致。

在终端C 里按顺序执行:

source /opt/tros/humble/setup.bash
ros2 launch websocket websocket.launch.py websocket_image_topic:=/image_jpeg websocket_only_show_image:=true

然后 PC 浏览器打开 http://192.168.127.10:8000(IP 换成板端当前网卡地址),应看到实时画面:

命令与访问方式见官方 数据展示 · Web 展示。顺手把 source 写进 ~/.bashrc 可免掉这个坑。

起不来时只看两点:

  • websocket 终端报 8000 被占用:lsof -i:8000 找到进程 kill 后重拉,或改 nginx.conf 里的 listen 端口并同步改浏览器 URL。
  • 页面能开但画面空白:先 ros2 topic hz /image_jpeg 确认有帧率,没有就说明问题仍在 codec 那一侧。

好的,现在浏览器能正常看到页面,这是终端C的输出:
sunrise@ubuntu:~$ ros2 launch websocket websocket.launch.py websocket_image_topic:=/image_jpeg websocket_only_show_image:=true
[INFO] [launch]: All log files can be found below /home/sunrise/.ros/log/2000-01-01-09-21-30-681518-ubuntu-40180
[INFO] [launch]: Default logging verbosity is set to INFO
launch webserver
pwd_path is /home/sunrise
webserver_path is /opt/tros/humble/lib/websocket/webservice
launch webserver cmd is ./sbin/nginx -p .
nginx: [alert] could not open error log file: open() “./logs/error.log” failed (13: Permission denied)
[INFO] [websocket-1]: process started with pid [40227]
[websocket-1] [WARN] [0946689693.050806990] [websocket]:
[websocket-1] Parameter:
[websocket-1] image_topic: /image_jpeg
[websocket-1] image_type: mjpeg
[websocket-1] only_show_image: 1
[websocket-1] output_fps: 0

还有一个问题想请教:感觉视野有点鱼眼,棋盘格是弯曲的,这样能进行内外参标定吗?

能标,弯曲不是不能标,而是标定本来就要解决的那部分。SC132GS 这类广角镜头是明显的桶形畸变,直线在画面边缘成像成弧线属于正常光学表现;标定就是把内参 + 畸变系数(再加大畸变的双目外参)拟合出来,之后用它们去畸变。前提有两条:

  • 标定必须用原始畸变图,所以你现在的 gdc_enable:=false 要保持不动。开了 GDC 先做硬件矫正,再去标定拟合到的是矫正后的残差,内参和畸变系数都不对。
  • 模型要能描述这个畸变。官方 AMR 开发指南 · 3.5.2 双目摄像头内参标定 给的流程是用 kalibr,相机模型填 pinhole-radtan:录 /image_combine_raw 的 bag(经 ros1_bridge 转成 rosbag),再跑
rosrun kalibr kalibr_calibrate_cameras --bag $1 \
  --topics /camera/left/image_raw /camera/right/image_raw \
  --models pinhole-radtan pinhole-radtan --target checkboard.yaml --show-extraction

双目标定一次就同时给出左右内参、畸变系数和右目相对左目的 T_cn_cnm1,不用先单目再单独标外参。如果实测畸变系数拟合残差仍大(弧线特别强),改用等距/fisheye 模型(kalibr 的 pinhole-equi,或 OpenCV 的 cv2.fisheye.calibrate),这时畸变系数要用 k1–k4 那一套,不能和 radtan 的混用。

采图要求(决定标定成败,比模型更关键):不同距离、不同方位移动棋盘格或相机,至少 15~20 组位姿,含明显倾斜,让棋盘格铺开到画面四角与边缘——你现在这张棋盘格只占画面中间一小块,边缘几乎没有约束,畸变系数和主点会拟合得很飘。近距离拍时让棋盘格占画面 1/3 以上。kalibr 的 target 文件里 targetCols/targetRows 填的是内角点数(方格数 -1),填错会识别不出。

顺带两点:终端C 里那条 nginx: [alert] could not open error log file ... Permission denied 只是启动目录不可写,页面已经正常出图就说明不影响;日志里 image_topic: /image_jpeg、image_type: mjpeg、only_show_image: 1 也都对。

你用的棋盘格是几×几内角点、方格边长多少?按这个可以给你能直接用的 kalibr checkboard.yaml,并确认这套板子该走 radtan 还是等距模型。

好的,现在双目能保存原始图,后续我自己写标定的代码可行吗?自己写标定的代码要提前准备什么呢?
我看了AMR 开发指南 · 3.5.2 双目摄像头内参标定 ,目前下载很慢,而且有些地方看不懂,比如:调用标定程序进行标定(需指定记录的 bag 包路径以及标定配置文件的路径)。
请问更推荐自己写标定计算代码还是用官方标定方法呢?

优先走官方 kalibr 流程,自写 OpenCV 只当“只要内参和畸变”的轻量替代。 理由很实际:官方 AMR 开发指南 · 3.5.2 双目摄像头内参标定 用的是 kalibr 双目联合优化,一次就给出左右内参、畸变系数和右目相对左目的 T_cn_cnm1,输出 yaml 的 cam0/cam1 字段正好是下游双目深度要的格式。自己写 cv2.calibrateCamera + cv2.stereoCalibrate 算法本身不难,但角点亚像素、双目标定初值、畸变模型选择这些细节很容易让结果“看着收敛其实不准”,而且得到的数组还得自己转成下游认的 yaml。

你看不懂的那句不是要你填字面量:

$1=path_to_bag          # 换成你录的 bag 文件绝对路径,如 /share_dir/2026-10-21-10-30-00.bag
$2=path_to_calib_yaml    # 换成标定板描述文件路径,如 /share_dir/checkerboard.yaml
rosrun kalibr kalibr_calibrate_cameras --bag $1 \
  --topics /camera/left/image_raw /camera/right/image_raw \
  --models pinhole-radtan pinhole-radtan --target $2 --show-extraction

$1/$2 是 shell 变量占位,替换成真实路径后整条命令照抄即可。棋盘格 yaml 至少这三项,targetCols/targetRows 填内角点数(方格数−1),targetSquare 单位米:

target_type: 'checkerboard'
targetCols: 9
targetRows: 6
targetSquare: 0.025

还有一处文档没写透、最容易卡住的:3.5.2 第 ii 步录的是拼接图 /image_combine_raw 一个话题,第 iii 步 kalibr 的 --topics 却是左右两路 /camera/left/image_raw、/camera/right/image_raw。kalibr 只认 bag 里实际存在的话题名,所以要么 bag 里已经拆成左右两路,要么先用你那套切分逻辑把拼接 bag 拆成两路再标定。另外你板端是 Humble,文档里 ros-foxy-ros1-bridge 要换成 ros-humble-ros1-bridge,别照抄包名。

如果坚持自己写,这几点是前置条件,缺一个结果就飘:

  • 左右图必须同一时刻、同分辨率切出(你脚本从 1088x2560 切两半已满足),文件名序号对齐;
  • 先知道棋盘格内角点数与边长,用 cv2.findChessboardCorners 逐张确认能全检出再谈标定;
  • 模型选对:这套广角建议 cv2.fisheye.calibrate(k1–k4),普通针孔才用 radtan(k1,k2,p1,p2,k3),两套系数不能混用;
  • 至少 15~20 组位姿、含明显倾斜、棋盘格铺到画面四角与边缘——你现在这张只占画面中间一小块,边缘几乎没约束;
  • 官方对面标定板还有要求:方格边长 ≥10cm、行列数不相同,否则角点提取和尺度精度都不够,先量一下你手上这块。

下载慢的话不必纠结在板端跑:OpenCV 方案在 PC 上直接读你那批 PNG 就能算,不需要 ROS;要用 kalibr 则按 3.5.1 在 PC 上装它给的 docker 镜像。

再确认两点:你的棋盘格是几×几内角点、方格边长多少(我按这个给你能直接用的 yaml);标定结果只是自己看画面对齐/去畸变,还是后面要跑 hobot_stereonet 双目深度?后者建议直接走官方 kalibr 输出格式,省掉转字段的麻烦。

棋盘格是11*8,方格边长是20mm
后面要跑hobot_stereonet 双目深度。
我目前能确定:1.左右图是同一时刻、同分辨率切出;

2.cv2.findChessboardCorners 和 cv2.fisheye.calibrate是不是在自写标定代码时要使用的函数?

3.除了上面两个必须要用的函数自写代码时还有其他要注意的吗?

因为下载官方标定软件还要两小时
image

1. 11×8 方格 = 10×7 内角点。 官方说得明确:“格子数为9列x8列,则内角点数为8列x7列”,所以你填 10 / 7,不是 11 / 8。20mm 方格换算成米是 0.02,直接可用的 kalibr 标定板文件:

target_type: 'checkboard'
targetCols: 10        # 11 列方格 → 10 个内角点
targetRows: 7         # 8 行方格 → 7 个内角点
targetSquare: 0.02    # 20 mm

(走 AMR 文档那种分行字段写法时用 rowSpacingMeters / colSpacingMeters,文档正文印成 rawSpacingMeters,报未知字段就换回来。)依据:AMR 开发指南 · 3.5.2 双目摄像头内参标定。

但有个现实问题:官方建议方格边长 ≥10cm、行列数不同,你手上这块只有 20mm。GS130WI 对角视场 157.2°、单目 1088×1280(配件清单),20mm 的板子要贴得很近才能占够画面,边缘约束也不足,内参会飘。条件允许就打印一块方格 40~50mm 的 A2 板(11×8 格约 55×40cm),标定质量差一个档。

2. 这两个函数是对的,但要分清模型,别混用。 流程是 cv2.findChessboardCorners(要配 cv2.cornerSubPix 做亚像素,否则精度不够)→ 每个目单独 cv2.calibrateCamera(radtan:k1,k2,p1,p2,k3)或 cv2.fisheye.calibrate(等距:k1–k4)→ 再 cv2.stereoCalibrate / cv2.fisheye.stereoCalibrate 联合优化出 R、T。关键点: 官方 3.5.2 的下游格式就是 kalibr 输出的 cam0/cam1,distortion_model: radtan、4 个畸变系数。所以 fisheye 算出来的 k1–k4 不能直接当 radtan 填进那个 yaml。先按 radtan 做,若边缘(你这张图里画面四角的直线弧得厉害)重投影残差明显偏大,再改用 fisheye 模型并单独校验去畸变后的直线度。

3. 自写时还要注意的:

  • 标定用的 imageSize 是单目尺寸 1088×1280,不是拼接的 1088×2560;objectPoints 按 (1, N, 3) float64 组织(fisheye 接口强制要求这个形状)。
  • 每张先确认角点全检出再进标定;总帧数 15~20 组起步(建议 25~30),含明显倾斜、覆盖四角边缘、不同距离,纯平移没有价值。
  • 看两个数字:单目 RMS 重投影误差(大误差的帧直接剔掉)、以及双目 T 的平移量应与模组基线同量级——GS130WI 规格是 70mm(GS130W 是 80mm),差很多说明标错了。
  • 下游 hobot_stereonet 模型输入是 640x352x3x2(双目深度估计),送进网络前图像有缩放/裁剪,内参要按那条链路的实际分辨率换算,否则深度尺度不对。

4. 路线选择:不必等那两小时。 你要的图已经在 ~/calib_data 里成对存好了,OpenCV 方案在 PC 上直接读 PNG 就能算,不需要 ROS、不需要 bag。建议并行:现在先用自写脚本跑通采集质量验证(看角点检出率、重投影误差、去畸变后直线是否变直),确认这批图够用;后台继续下载 kalibr 镜像,等下载完再跑一遍 kalibr 拿到 cam0/cam1 格式的结果喂 hobot_stereonet——因为你要跑双目深度,最终格式对齐官方那条链路最省事。

一个问题:你准备用的 hobot_stereonet 是 TROS 3.5.x 自带那条 launch 还是 MagicBox 里的 run_stereo.sh?把它的 launch 或标定 yaml 路径贴一下,我确认它到底读哪个标定文件、字段怎么填。

好的好的
这是我自写的标定代码:
import argparse
import os
from pathlib import Path

import cv2
import numpy as np

SCRIPT_DIR = os.path.dirname(os.path.realpath(file))
ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, “..”))

GS130W 模组基线(米),用于核对双目 T 的平移量是否在同一量级。

EXPECTED_BASELINE_M = 0.080

def parse_args():
parser = argparse.ArgumentParser(
description=“Offline stereo calibration from raw distorted chessboard images, "
“outputting a kalibr-style cam0/cam1 yaml (radtan, 4 distortion coeffs).”
)
parser.add_argument(
“–image_dir”,
default=os.path.join(ROOT_DIR, “digua18yuanshi”),
type=str,
help=“Directory containing left_XXXX / right_XXXX chessboard images.”,
)
parser.add_argument(
“–output”,
default=os.path.join(ROOT_DIR, “output”, “calibration”, “digua18_stereo.yaml”),
type=str,
help=“Output kalibr-style yaml path.”,
)
parser.add_argument(”–pattern_cols", default=11, type=int, help=“Inner corners per chessboard row.”)
parser.add_argument(“–pattern_rows”, default=8, type=int, help=“Inner corners per chessboard column.”)
parser.add_argument(“–square_size”, default=0.030, type=float, help=“Chessboard square size in meters.”)
parser.add_argument(
“–model”,
default=“radtan”,
type=str,
choices=[“radtan”, “fisheye”],
help="Lens model. radtan: pinhole + Brown-Conrady (k1,k2,p1,p2,k3). "
“fisheye: equidistant (k1~k4), for straightness validation only.”,
)
parser.add_argument(
“–max_frame_rms”,
default=1.0,
type=float,
help=“Per-frame reprojection RMS threshold (px). Frames above this are dropped.”,
)
parser.add_argument(
“–fix_intrinsics”,
default=1,
type=int,
help=“1: fix intrinsics from mono calibration and only optimize R,T in stereoCalibrate.”,
)
parser.add_argument(
“–compare_fisheye”,
default=0,
type=int,
help=“1: additionally run fisheye mono calibration + straightness check for comparison.”,
)
return parser.parse_args()

图片配对

def extract_index(path):
“”“从 left_0001.png / right_0001.png 里提取编号,用于严格左右配对。”“”
stem = Path(path).stem.lower()
for prefix in (“left_”, “right_”, “left-”, “right-”):
if stem.startswith(prefix):
return stem[len(prefix):]
if stem.startswith(“left”):
return stem[4:]
if stem.startswith(“right”):
return stem[5:]
return None

def collect_stereo_pairs(image_dir):
“”“扫描目录中的左右图,按编号配对。”“”
supported_exts = {“.jpg”, “.jpeg”, “.png”, “.bmp”, “.tif”, “.tiff”}
left_map = {}
right_map = {}

for path in Path(image_dir).iterdir():
    if not path.is_file() or path.suffix.lower() not in supported_exts:
        continue
    idx = extract_index(path.name)
    if not idx:
        continue
    lower_name = path.name.lower()
    if lower_name.startswith("left"):
        left_map[idx] = str(path)
    elif lower_name.startswith("right"):
        right_map[idx] = str(path)

common_ids = sorted(set(left_map.keys()) & set(right_map.keys()), key=lambda x: int(x))
if not common_ids:
    raise RuntimeError(f"No valid stereo pairs found in {image_dir}")
return [(idx, left_map[idx], right_map[idx]) for idx in common_ids]

角点与目标点

def create_object_points(pattern_size, square_size):
“”"
生成棋盘格 3D 角点模板,Z 恒为 0。

形状按 (1, N, 3) float64 组织 —— cv2.fisheye.calibrate 强制要求这个形状,
普通 cv2.calibrateCamera 用 float32 版即可(见 as_pinhole_points)。
"""
cols, rows = pattern_size
n = cols * rows
objp = np.zeros((1, n, 3), np.float64)
objp[0, :, :2] = np.mgrid[0:cols, 0:rows].T.reshape(-1, 2)
objp *= square_size
return objp

def find_corners(gray, pattern_size):
“”“检测棋盘格角点 + 亚像素优化。”“”
flags = cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE
found, corners = cv2.findChessboardCorners(gray, pattern_size, flags)
if not found:
return False, None

criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
return True, corners

投影 / 重投影误差

def project_points(obj_points, rvec, tvec, camera_matrix, dist_coeffs, model):
“”“把一组 3D 角点按模型投到像素平面,返回 (N, 2)。”“”
if model == “radtan”:
objp = np.asarray(obj_points, dtype=np.float32).reshape(-1, 3)
projected, _ = cv2.projectPoints(objp, rvec, tvec, camera_matrix, dist_coeffs)
else:
objp = np.asarray(obj_points, dtype=np.float64).reshape(1, -1, 3)
projected, _ = cv2.fisheye.projectPoints(objp, rvec, tvec, camera_matrix, dist_coeffs)
return projected.reshape(-1, 2)

def compute_rms(img_points, obj_points, rvecs, tvecs, camera_matrix, dist_coeffs, model):
“”“逐帧计算重投影 RMS,返回 (总体 RMS, 每帧 RMS 列表)。”“”
total_sq = 0.0
total_n = 0
per_frame =
for objp, imgp, rvec, tvec in zip(obj_points, img_points, rvecs, tvecs):
projected = project_points(objp, rvec, tvec, camera_matrix, dist_coeffs, model)
imgp = imgp.reshape(-1, 2)
sq = float(np.sum((imgp - projected) ** 2))
n = imgp.shape[0]
per_frame.append(float(np.sqrt(sq / n)))
total_sq += sq
total_n += n
return float(np.sqrt(total_sq / total_n)), per_frame

单目标定

def calibrate_mono(obj_points, img_points, image_size, model):
“”“单目标定,返回 (rms, K, dist, rvecs, tvecs)。”“”
if model == “radtan”:
obj_list = [np.asarray(o, dtype=np.float32).reshape(-1, 3) for o in obj_points]
img_list = [np.asarray(p, dtype=np.float32).reshape(-1, 2) for p in img_points]
ret, K, dist, rvecs, tvecs = cv2.calibrateCamera(
obj_list, img_list, image_size, None, None
)
else:
obj_list = [np.asarray(o, dtype=np.float64).reshape(1, -1, 3) for o in obj_points]
img_list = [np.asarray(p, dtype=np.float64).reshape(1, -1, 2) for p in img_points]
ret, K, dist, rvecs, tvecs = cv2.fisheye.calibrate(
obj_list, img_list, image_size, None, None
)
return float(ret), K, dist, rvecs, tvecs

双目标定

def calibrate_stereo(obj_points, img_points_left, img_points_right, image_size, model,
K_left, dist_left, K_right, dist_right, fix_intrinsics):
“”“双目标定,返回 (rms, K_left, dist_left, K_right, dist_right, R, T, E, F)。”“”
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-6)

if model == "radtan":
    obj_list = [np.asarray(o, dtype=np.float32).reshape(-1, 3) for o in obj_points]
    img_l = [np.asarray(p, dtype=np.float32).reshape(-1, 2) for p in img_points_left]
    img_r = [np.asarray(p, dtype=np.float32).reshape(-1, 2) for p in img_points_right]
    flags = cv2.CALIB_FIX_INTRINSIC if fix_intrinsics else cv2.CALIB_USE_INTRINSIC_GUESS
    out = cv2.stereoCalibrate(
        obj_list, img_l, img_r,
        K_left, dist_left, K_right, dist_right,
        image_size, criteria=criteria, flags=flags,
    )
    rms, K_l, d_l, K_r, d_r, R, T, E, F = out
else:
    obj_list = [np.asarray(o, dtype=np.float64).reshape(1, -1, 3) for o in obj_points]
    img_l = [np.asarray(p, dtype=np.float64).reshape(1, -1, 2) for p in img_points_left]
    img_r = [np.asarray(p, dtype=np.float64).reshape(1, -1, 2) for p in img_points_right]
    flags = cv2.fisheye.CALIB_FIX_INTRINSIC if fix_intrinsics else cv2.fisheye.CALIB_USE_INTRINSIC_GUESS
    out = cv2.fisheye.stereoCalibrate(
        obj_list, img_l, img_r,
        K_left, dist_left, K_right, dist_right,
        image_size, flags=flags, criteria=criteria,
    )
    rms, K_l, d_l, K_r, d_r, R, T = out
    E = F = None
return float(rms), K_l, d_l, K_r, d_r, R, T, E, F

去畸变后直线度校验

def undistort_points_to_pixels(points, K, dist, model):
“”“把角点去畸变到理想像素坐标,返回 (N, 2)。”“”
pts = np.asarray(points, dtype=np.float64).reshape(-1, 1, 2)
if model == “radtan”:
u = cv2.undistortPoints(pts, K, dist, P=K)
else:
u = cv2.fisheye.undistortPoints(pts, K, dist)
fx, fy = K[0, 0], K[1, 1]
cx, cy = K[0, 2], K[1, 2]
u = u * np.array([[[fx, fy]]]) + np.array([[[cx, cy]]])
return u.reshape(-1, 2)

def _line_rms(pts):
“”“拟合一条直线,返回点到直线的 RMS 垂直距离(像素)。”“”
if pts.shape[0] < 2:
return 0.0
# 用 PCA:最小特征值对应方向即为法向,点到均值在法向的投影即残差。
mean = pts.mean(axis=0)
centered = pts - mean
cov = centered.T @ centered / pts.shape[0]
eigvals, eigvecs = np.linalg.eigh(cov)
normal = eigvecs[:, 0] # 最小特征值方向
residuals = centered @ normal
return float(np.sqrt(np.mean(residuals ** 2)))
def straightness_check(img_points, K, dist, pattern_size, model):
“”“去畸变后检查棋盘格每行/每列角点是否共线,返回 RMS(像素)。”“”
cols, rows = pattern_size
all_rms =
for pts in img_points:
up = undistort_points_to_pixels(pts, K, dist, model).reshape(rows, cols, 2)
for r in range(rows):
all_rms.append(_line_rms(up[r]))
for c in range(cols):
all_rms.append(_line_rms(up[:, c]))
return float(np.sqrt(np.mean(np.array(all_rms) ** 2))), all_rms

kalibr yaml 输出

def _fmt(x):
“”“格式化单个数值,保证足够精度且整洁。”“”
if isinstance(x, (int, np.integer)):
return str(int(x))
s = f"{float(x):.12g}"
if s in (“-0”, “0”) and s.startswith(“-0”):
s = “0”
return s
def _fmt_vec(values):
return “[” + “, “.join(_fmt(v) for v in np.asarray(values).ravel()) + “]”
def _fmt_mat_4x4(mat):
rows =
for i in range(4):
rows.append(” - [” + ", “.join(_fmt(v) for v in mat[i]) + “]”)
return “\n”.join(rows)
def write_kalibr_yaml(output_path, K_left, dist_left, K_right, dist_right, R, T, image_size, model):
“””
输出 kalibr 风格 cam0/cam1 yaml。

radtan:distortion_coeffs 取 OpenCV 5 个系数里的前 4 个 [k1,k2,p1,p2],
与 kalibr 的 radtan 模型一致。
fisheye:distortion_model 标为 equidistant,仅供诊断,不能直接当下游 radtan 用。
"""
output_path = os.path.abspath(output_path)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
fx, fy = float(K_left[0, 0]), float(K_left[1, 1])
cx, cy = float(K_left[0, 2]), float(K_left[1, 2])
fx2, fy2 = float(K_right[0, 0]), float(K_right[1, 1])
cx2, cy2 = float(K_right[0, 2]), float(K_right[1, 2])
d_left = np.asarray(dist_left).ravel()
d_right = np.asarray(dist_right).ravel()
if model == "radtan":
    # kalibr radtan = [k1, k2, p1, p2]
    d_left_out = d_left[:4]
    d_right_out = d_right[:4]
    distortion_model = "radtan"
else:
    d_left_out = d_left[:4]
    d_right_out = d_right[:4]
    distortion_model = "equidistant"
T_cn_cnm1 = np.eye(4)
T_cn_cnm1[:3, :3] = R
T_cn_cnm1[:3, 3] = np.asarray(T).ravel()
cam0_T = np.eye(4)
lines = []
lines.append("cam0:")
lines.append("  T_cam_imu:")
lines.append(_fmt_mat_4x4(cam0_T))
lines.append("  camera_model: pinhole")
lines.append(f"  distortion_coeffs: {_fmt_vec(d_left_out)}")
lines.append(f"  distortion_model: {distortion_model}")
lines.append(f"  intrinsics: [{_fmt(fx)}, {_fmt(fy)}, {_fmt(cx)}, {_fmt(cy)}]")
lines.append(f"  resolution: [{int(image_size[0])}, {int(image_size[1])}]")
lines.append("  rostopic: /cam0/image_raw")
lines.append("cam1:")
lines.append("  T_cn_cnm1:")
lines.append(_fmt_mat_4x4(T_cn_cnm1))
lines.append("  camera_model: pinhole")
lines.append(f"  distortion_coeffs: {_fmt_vec(d_right_out)}")
lines.append(f"  distortion_model: {distortion_model}")
lines.append(f"  intrinsics: [{_fmt(fx2)}, {_fmt(fy2)}, {_fmt(cx2)}, {_fmt(cy2)}]")
lines.append(f"  resolution: [{int(image_size[0])}, {int(image_size[1])}]")
lines.append("  rostopic: /cam1/image_raw")
with open(output_path, "w", encoding="utf-8") as f:
    f.write("\n".join(lines) + "\n")
return output_path

主流程

def run_calibration(args):
pattern_size = (args.pattern_cols, args.pattern_rows)
pairs = collect_stereo_pairs(args.image_dir)
obj_template = create_object_points(pattern_size, args.square_size)

obj_points = []
img_points_left = []
img_points_right = []
frame_ids = []
failed = []
image_size = None

print(f"输入目录: {os.path.abspath(args.image_dir)}")
print(f"检测到 {len(pairs)} 组左右图,开始提取棋盘格角点({args.pattern_cols}x{args.pattern_rows})...")

for idx, left_path, right_path in pairs:
    left_gray = cv2.imread(left_path, cv2.IMREAD_GRAYSCALE)
    right_gray = cv2.imread(right_path, cv2.IMREAD_GRAYSCALE)

    if left_gray is None or right_gray is None:
        failed.append((idx, "image read failed"))
        continue
    if left_gray.shape != right_gray.shape:
        failed.append((idx, "left/right size mismatch"))
        continue

    current_size = (left_gray.shape[1], left_gray.shape[0])
    if image_size is None:
        image_size = current_size
    elif image_size != current_size:
        failed.append((idx, "size differs from previous frames"))
        continue

    found_l, corners_l = find_corners(left_gray, pattern_size)
    found_r, corners_r = find_corners(right_gray, pattern_size)
    if not (found_l and found_r):
        failed.append((idx, f"corner detection failed left={found_l} right={found_r}"))
        continue

    obj_points.append(obj_template.copy())
    img_points_left.append(corners_l)
    img_points_right.append(corners_r)
    frame_ids.append(idx)

if len(obj_points) < 5:
    raise RuntimeError(f"有效标定组不足: {len(obj_points)} < 5")

print(f"角点提取完成:有效 {len(obj_points)} / {len(pairs)} 组,"
      f"图像尺寸 {image_size[0]} x {image_size[1]}")

if failed:
    for idx, reason in failed:
        print(f"  跳过 {idx}: {reason}")

print(f"\n===== 单目标定({args.model})=====")
# 先用全部有效帧做一次单目标定,拿到逐帧误差用于剔除离群帧。
mono_l = calibrate_mono(obj_points, img_points_left, image_size, args.model)
mono_r = calibrate_mono(obj_points, img_points_right, image_size, args.model)
rms_l, K_l, dist_l, rvecs_l, tvecs_l = mono_l
rms_r, K_r, dist_r, rvecs_r, tvecs_r = mono_r
_, per_l = compute_rms(img_points_left, obj_points, rvecs_l, tvecs_l, K_l, dist_l, args.model)
_, per_r = compute_rms(img_points_right, obj_points, rvecs_r, tvecs_r, K_r, dist_r, args.model)
keep = []
for i, (idl, e_l, e_r) in enumerate(zip(frame_ids, per_l, per_r)):
    worst = max(e_l, e_r)
    drop = worst > args.max_frame_rms
    keep.append(not drop)
    tag = "DROP" if drop else "ok"
    print(f"  frame {idl:>4s}  left_rms={e_l:6.3f}  right_rms={e_r:6.3f}  {tag}")

n_dropped = sum(1 for k in keep if not k)
if n_dropped:
    print(f"\n剔除 {n_dropped} 组大误差帧,重新标定...")
    obj_points = [o for o, k in zip(obj_points, keep) if k]
    img_points_left = [p for p, k in zip(img_points_left, keep) if k]
    img_points_right = [p for p, k in zip(img_points_right, keep) if k]
    frame_ids = [f for f, k in zip(frame_ids, keep) if k]

    mono_l = calibrate_mono(obj_points, img_points_left, image_size, args.model)
    mono_r = calibrate_mono(obj_points, img_points_right, image_size, args.model)
    rms_l, K_l, dist_l, rvecs_l, tvecs_l = mono_l
    rms_r, K_r, dist_r, rvecs_r, tvecs_r = mono_r
    _, per_l = compute_rms(img_points_left, obj_points, rvecs_l, tvecs_l, K_l, dist_l, args.model)
    _, per_r = compute_rms(img_points_right, obj_points, rvecs_r, tvecs_r, K_r, dist_r, args.model)

print(f"\n左目单标 RMS: {rms_l:.6f} px")
print(f"右目单标 RMS: {rms_r:.6f} px")
print(f"左目内参 K:\n{K_l}")
print(f"右目内参 K:\n{K_r}")
print(f"左目畸变: {np.asarray(dist_l).ravel()}")
print(f"右目畸变: {np.asarray(dist_r).ravel()}")
print(f"\n===== 双目标定({args.model},fix_intrinsics={args.fix_intrinsics})=====")
stereo = calibrate_stereo(
    obj_points, img_points_left, img_points_right, image_size, args.model,
    K_l, dist_l, K_r, dist_r, args.fix_intrinsics,
)
stereo_rms, K_l2, d_l2, K_r2, d_r2, R, T, E, F = stereo
baseline_norm = float(np.linalg.norm(T))
baseline_x = float(abs(np.asarray(T).ravel()[0]))
print(f"双目 RMS(OpenCV): {stereo_rms:.6f} px")
print(f"R:\n{R}")
print(f"T: {np.asarray(T).ravel()}  (m)")
print(f"基线 norm(T): {baseline_norm * 1000:.2f} mm")
print(f"基线 abs(Tx): {baseline_x * 1000:.2f} mm")
print(f"预期模组基线: {EXPECTED_BASELINE_M * 1000:.0f} mm (GS130W)")

if abs(baseline_norm - EXPECTED_BASELINE_M) > 0.5 * EXPECTED_BASELINE_M:
    print(f"  [警告] 基线 {baseline_norm*1000:.1f} mm 与预期 80 mm 差很多,标定可能有问题!")
# 去畸变后直线度校验(radtan 用来确认畸变是否拟合到位;fisheye 用来确认等距模型是否更合适)。
straight_rms, _ = straightness_check(img_points_left, K_l2, d_l2, pattern_size, args.model)
print(f"\n去畸变后直线度 RMS(左目,{args.model}): {straight_rms:.4f} px(越小越直)")
output_path = write_kalibr_yaml(
    args.output, K_l2, d_l2, K_r2, d_r2, R, T, image_size, args.model
)
print(f"\n输出 yaml: {output_path}")
if args.model == "fisheye":
    print("[注意] 当前为 fisheye(equidistant) 结果,k1~k4 不能直接当 radtan 填进下游 yaml。")
# 可选:额外跑一遍 fisheye 做对照,帮助判断镜头是否真需要等距模型。
if args.model == "radtan" and args.compare_fisheye:
    print(f"\n===== 对照:fisheye 单标 + 直线度 =====")
    mono_fl = calibrate_mono(obj_points, img_points_left, image_size, "fisheye")
    straight_fl, _ = straightness_check(img_points_left, mono_fl[1], mono_fl[2], pattern_size, "fisheye")
    print(f"fisheye 左目单标 RMS: {mono_fl[0]:.6f} px")
    print(f"fisheye 去畸变直线度 RMS: {straight_fl:.4f} px")
    print(f"radtan 去畸变直线度 RMS: {straight_rms:.4f} px")
return output_path

def main():
args = parse_args()
if not os.path.isdir(args.image_dir):
raise RuntimeError(f"image_dir does not exist: {args.image_dir}")
run_calibration(args)
if name == “main”:
main()

这是输出的内外参数据:
cam0:
T_cam_imu:

  • [1, 0, 0, 0]
  • [0, 1, 0, 0]
  • [0, 0, 1, 0]
  • [0, 0, 0, 1]
    camera_model: pinhole
    distortion_coeffs: [-0.31721662039, 0.117014683228, -0.000181593970357, -0.000248241629583]
    distortion_model: radtan
    intrinsics: [664.432856919, 664.695802563, 542.557978907, 640.030064614]
    resolution: [1088, 1280]
    rostopic: /cam0/image_raw
    cam1:
    T_cn_cnm1:
  • [0.999992467237, -0.00331026808085, 0.00202671993326, 6.83110523268e-05]
  • [0.00330757127264, 0.99999364214, 0.00133253593532, -0.0800232985085]
  • [-0.00203111809883, -0.00132582237702, 0.999997058373, -0.000227819310598]
  • [0, 0, 0, 1]
    camera_model: pinhole
    distortion_coeffs: [-0.329345201684, 0.137607827237, 2.83570959091e-05, 5.53948748956e-05]
    distortion_model: radtan
    intrinsics: [664.506352575, 664.634680142, 541.638922966, 639.581869587]
    resolution: [1088, 1280]
    rostopic: /cam1/image_raw
    请问这个输出有什么问题吗?

后续想用这个内外参生成”点云“和”矫正后的左图“具体怎么做呢?

结论:几何本身是自洽的,内参可以用;但有 3 处要核对,其中“方格边长”这一处会直接让点云尺度错位。

1. 数值上没崩的地方

  • fx/fy≈664.4/664.7、cx/cy≈(542.6, 640.0),主点非常接近 1088×1280 的中心 (544, 640),左右目 fx 差 0.07px、cx 差 0.9px,一致性很好。
  • k1≈-0.32 / k2≈0.12:桶形畸变,和官方 3.5.2 示例标定结果(cam0 k1=-0.305, k2=0.0687)同量级,不是崩坏值。
  • R 非对角量约 0.003(≈0.2°),两目基本平行,合理。
  • 基线方向:T=(0.07mm, -80.02mm, -0.23mm),平移主要在 Y 轴。你拼接图是 1088×2560 上下切,两目上下排列,所以这是对的;官方示例是左右排列(1920×1080),它的 T 在 X 轴(-68mm)。差别在于后面极线/视差方向不同,别直接照抄假设水平基线的代码。

2. 必须核对:--square_size
脚本默认 --square_size 0.030、--pattern_cols 11 --pattern_rows 8。你棋盘 11×8 方格只能是 10×7 内角点,能出结果说明参数传了;但边长请确认:

  • T 与你传入的 square_size 严格成比例:真实基线 = 打印的 80.02mm × (实际边长 / 你传入的值)。
  • 若沿用默认 0.030(实际 0.020),真实基线只有约 53mm;若确实传了 0.02,而你的单目是 1088×1280(对应 GS130WI,规格基线 70mm),则 80mm 仍偏大 14%。
  • 内参不受这个参数影响,所以只看内参是发现不了这个错的,但点云所有距离会整体缩放。请把当时那条 python3 xxxx.py ... 的完整参数行贴一下。

3. yaml 与官方下游格式的差异
对照官方 AMR 3.5.2 的产物:

  • 缺 cam_overlaps: [1] / cam_overlaps: [0],官方 cam0/cam1 都有;
  • cam0 你多写了 T_cam_imu(官方示例没有),不影响解析但没必要;
  • resolution: [1088, 1280] 是 [w, h],对的;
  • 你 yaml 只写 4 个畸变系数,而脚本内部直线度校验用的是 OpenCV 5 系数(含 k3)。后续你去畸变/校正必须和这里统一:要么全程用标定输出的 5 系数数组(cv2.undistort、initUndistortRectifyMap 都直接吃 5 系数),要么全程 4 系数,别一边 4 一边 5。

4. 生成“矫正后左图 + 点云”的最短路径(用你脚本里的 K1,D1,K2,D2,R,T,单位米)

W, H = 1088, 1280            # 单目尺寸,不是 2560
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
    K1, D1, K2, D2, (W, H), R, T,
    flags=cv2.CALIB_ZERO_DISPARITY, alpha=0)   # alpha=0 裁黑边,点云更干净;要全视场用 1

m1x, m1y = cv2.initUndistortRectifyMap(K1, D1, R1, P1, (W, H), cv2.CV_32FC1)
m2x, m2y = cv2.initUndistortRectifyMap(K2, D2, R2, P2, (W, H), cv2.CV_32FC1)
left_rect  = cv2.remap(left,  m1x, m1y, cv2.INTER_LINEAR)   # 矫正后的左图
right_rect = cv2.remap(right, m2x, m2y, cv2.INTER_LINEAR)
  • 校正结果会被绕光轴转约 90°(基线在 Y 轴上,stereoRectify 要把极线摆成水平行),画面看着“躺倒”是正常的,不是 bug。
  • 校正是否可靠,先验一件事:在左右 *_rect 图上跑 findChessboardCorners,同一个角点在两图的 y 坐标应基本一致(差 <1px)。这一条不过,后面点云一定不对。
  • 点云:先出视差再反投影,disparity 用 StereoSGBM(全局快门、同时刻采集,可以直接用),points = cv2.reprojectImageTo3D(disp.astype(np.float32)/16.0, Q),即得到左目坐标系下的 XYZ(单位米,与 T 一致),按 disp>0 滤掉无效点。
  • 若改走 hobot_stereonet:它输出的是左视图对应的视差/深度图,网络输入是 640×352,反投影要用同分辨率的 fx/cx/基线(Z = fx·b/d,X=(u-cx)Z/fx,Y=(v-cy)Z/fy),别直接拿 1088×1280 的 K 套 640×352 的视差图。这条路的内部校正用的是它自己的配置,所以你把这份标定喂进去时字段要对齐官方格式(上面第 3 点)。

还缺两个信息才能判质量:你脚本打印的「左/右单标 RMS、双目 RMS、去畸变直线度 RMS、有效帧数」这几个数,以及你打算跑哪条 stereonet(TROS 里的 launch,还是 /userdata/magicbox 的 run_stereo.sh)——后者决定标定文件放哪、字段怎么填,我按你那条给具体命令。

好的
2.我的棋盘格内角点是11*8,边长是30mm,你说的‘若沿用默认 0.030(实际 0.020),真实基线只有约 53mm;若确实传了 0.02,而你的单目是 1088×1280(对应 GS130WI,规格基线 70mm),则 80mm 仍偏大 14%。’我没看明白

3.请提供一下官方下游格式的实例,我这次输出是按照你上次说的‘官方 3.5.2 的下游格式就是 kalibr 输出的 cam0/cam1,distortion_model: radtan、4 个畸变系数’感觉与你这次说的‘而脚本内部直线度校验用的是 OpenCV 5 系数(含 k3)’有些矛盾。请提供正确的官方下游格式我一次性改好

4.是不是有内外参就可以自写代码输出矫正后的左图呢?
点云我要走hobot_stereonet,现在有内外参,具体如何用自行标定的数据生成单帧的点云图呢?
我需要和之前的save_frame.py一样,最终要能保存单帧的、同一时刻的左右原始图、矫正后的左图和点云深度图

先给结论:内参不用重标;尺度这条你给的参数已把上一轮的怀疑排除了(我上一轮的 70mm 说法作废);真正要改的只有 yaml 字段。矫正左图和点云脚本下面直接可用。

1. 尺度这件事,前后两版的区别在哪
上一轮我说“默认 0.030 而实际 0.020 会缩到 53mm”,依据是你 66913 里写的“棋盘格是 11*8,方格边长 20mm”。现在你确认是内角点 11×8、方格边长 30mm,那正好等于你脚本默认的 --pattern_cols 11 --pattern_rows 8 --square_size 0.030,也就是说 T 里的 80.02mm 就是真实基线,没有整体缩放。
顺便两个数对齐了:官方 GS130W 产品简介 写的是“基线 80mm”,你测到 norm(T)=80.02mm,吻合;我上一轮引用的 70mm/14% 偏差那句撤回。注意你这块板实际是 12×9 格(内角点 11×8),以后重标别再按“11×8 格”去改参数。

2. 官方下游格式就是 3.5.2 示例里那一段,完整长这样
摘自 AMR 开发指南 · 3.5.2 双目摄像头内参标定,我把数值换成你的:

cam0:
  cam_overlaps: [1]
  camera_model: pinhole
  distortion_coeffs: [-0.31721662039, 0.117014683228, -0.000181593970357, -0.000248241629583]
  distortion_model: radtan
  intrinsics: [664.432856919, 664.695802563, 542.557978907, 640.030064614]
  resolution: [1088, 1280]
  rostopic: /camera/left/image_raw
cam1:
  T_cn_cnm1:
  - [0.999992467237, -0.00331026808085, 0.00202671993326, 6.83110523268e-05]
  - [0.00330757127264, 0.99999364214, 0.00133253593532, -0.0800232985085]
  - [-0.00203111809883, -0.00132582237702, 0.999997058373, -0.000227819310598]
  - [0.0, 0.0, 0.0, 1.0]
  cam_overlaps: [0]
  camera_model: pinhole
  distortion_coeffs: [-0.329345201684, 0.137607827237, 2.83570959091e-05, 5.53948748956e-05]
  distortion_model: radtan
  intrinsics: [664.506352575, 664.634680142, 541.638922966, 639.581869587]
  resolution: [1088, 1280]
  rostopic: /camera/right/image_raw

改动只有三处:cam0 补 cam_overlaps: [1]、cam1 补 cam_overlaps: [0]、删掉 cam0 里官方没有的 T_cam_imu。

3. “4 个系数”和“5 个系数”不矛盾,是两套约定
kalibr 的 radtan 定义上就是 4 个:k1, k2, p1, p2,官方 yaml 因此只有 4 个值;而 cv2.calibrateCamera 返回 5 个:k1, k2, p1, p2, k3。你脚本写文件时截了前 4 个,内部校验却用 5 个算 RMS/直线度——风险只在于:如果 k3 不小,你丢弃它再做 remap,得到的矫正图和“验证过的那套参数”就不是一回事了。
用法定死一条:给下游的 yaml 只写 4 个;自己做 remap/undistort 也用这 4 个(cv2.initUndistortRectifyMap 接受 4/5/8 系数,传 4 个即可)。这样下游读到的就是你验证过的同一组数。另外把你脚本打印的 5 个值里第 5 位(k3)看一眼,量级很小就可以放心丢。

4. 有内外参就能出矫正左图和点云,直接替换你的 save_frame.py
把下面这段存成 save_stereo.py,python3 save_stereo.py ~/calib_data,每按一次回车存一组:原始左/右、矫正左图、视差、16 位深度(mm)、彩色点云 PLY,全部同一帧同一切分。

import os, sys, threading, cv2, numpy as np, rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge

OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser('~/calib_data')
os.makedirs(OUT, exist_ok=True)

# 你的标定结果(radtan 4 系数,与上面 yaml 完全一致)
K1 = np.array([[664.432856919, 0, 542.557978907], [0, 664.695802563, 640.030064614], [0, 0, 1]])
D1 = np.array([-0.31721662039, 0.117014683228, -0.000181593970357, -0.000248241629583])
K2 = np.array([[664.506352575, 0, 541.638922966], [0, 664.634680142, 639.581869587], [0, 0, 1]])
D2 = np.array([-0.329345201684, 0.137607827237, 2.83570959091e-05, 5.53948748956e-05])
R = np.array([[0.999992467237, -0.00331026808085, 0.00202671993326],
              [0.00330757127264, 0.99999364214, 0.00133253593532],
              [-0.00203111809883, -0.00132582237702, 0.999997058373]])
T = np.array([6.83110523268e-05, -0.0800232985085, -0.000227819310598])
W, H = 1088, 1280                     # 单目尺寸

# 极线校正:垂直基线会被旋转成水平极线,矫正图看着“躺倒”属正常
R1, R2, P1, P2, Q, _, _ = cv2.stereoRectify(
    K1, D1, K2, D2, (W, H), R, T, flags=cv2.CALIB_ZERO_DISPARITY, alpha=0)
m1x, m1y = cv2.initUndistortRectifyMap(K1, D1, R1, P1, (W, H), cv2.CV_32FC1)
m2x, m2y = cv2.initUndistortRectifyMap(K2, D2, R2, P2, (W, H), cv2.CV_32FC1)

sgbm = cv2.StereoSGBM_create(minDisparity=0, numDisparities=256, blockSize=5,
    P1=8 * 5 * 5, P2=32 * 5 * 5, uniquenessRatio=10, speckleWindowSize=100,
    speckleRange=2, disp12MaxDiff=1, mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY)

class Grabber(Node):
    def __init__(self):
        super().__init__('frame_grabber')
        self.frame = None
        self.count = 0
        self.create_subscription(Image, '/image_combine_raw',
            lambda m: setattr(self, 'frame', CvBridge().imgmsg_to_cv2(m, 'bgr8')), 10)

    def save(self):
        if self.frame is None:
            print('还没收到图像'); return
        h, w = self.frame.shape[:2]
        assert w == W and h == 2 * H, (w, h)          # 1088x2560 上下切
        left, right = self.frame[:H, :], self.frame[H:, :]
        self.count += 1
        base = lambda n: os.path.join(OUT, f'{n}_{self.count:04d}')

        # 1) 同一帧的左右原始图
        cv2.imwrite(base('left') + '.png', left)
        cv2.imwrite(base('right') + '.png', right)
        # 2) 矫正后的左图(右图一并存,方便验极线)
        left_rect = cv2.remap(left, m1x, m1y, cv2.INTER_LINEAR)
        right_rect = cv2.remap(right, m2x, m2y, cv2.INTER_LINEAR)
        cv2.imwrite(base('left_rect') + '.png', left_rect)
        cv2.imwrite(base('right_rect') + '.png', right_rect)
        # 3) 视差 + 深度(mm,16 位)+ 左目坐标点云
        disp = sgbm.compute(left_rect, right_rect).astype(np.float32) / 16.0
        valid = disp > 0
        cv2.imwrite(base('disp') + '.png', np.clip(disp * 4, 0, 255).astype(np.uint8))
        pts = cv2.reprojectImageTo3D(disp, Q)          # 单位米,左目坐标系
        z = pts[..., 2]
        cv2.imwrite(base('depth') + '.png',
                    np.where(valid & (z > 0), z * 1000, 0).astype(np.uint16))
        m = valid & (z > 0) & (z < 10)
        ys, xs = np.nonzero(m)
        xyz, rgb = pts[ys, xs], left_rect[ys, xs][:, ::-1]
        with open(base('cloud') + '.ply', 'w') as f:
            f.write(f"ply\nformat ascii 1.0\nelement vertex {len(xyz)}\n")
            f.write("property float x\nproperty float y\nproperty float z\n")
            f.write("property uchar red\nproperty uchar green\nproperty uchar blue\n")
            f.write("end_header\n")
            for (x, y, zz), (r, g, b) in zip(xyz, rgb):
                f.write(f"{x:.4f} {y:.4f} {zz:.4f} {r} {g} {b}\n")
        print(f'saved {self.count:04d}: 1088x2560 -> 单目 1088x1280, 有效点 {len(xyz)}')

rclpy.init()
node = Grabber()
threading.Thread(target=rclpy.spin, args=(node,), daemon=True).start()
while input('摆好棋盘格按回车存一帧,q 退出: ').strip() != 'q':
    node.save()

存完先做两项自检,不过就别信点云:

  • 在 left_rect/right_rect 上跑 findChessboardCorners,同一个角点在两图里的 y 坐标差应 <1px;差很多说明 R/T 或裁剪参数有问题。
  • 已知距离摆一次棋盘格,看 depth 里该处 mm 值是否接近真实距离(差 20% 以上多半是基线/尺度或 Q 用法问题)。

5. 如果坚持走 hobot_stereonet
它吃的是相机节点发布的 camera_info(新版 TROS 里是 /image_combine_raw/left/camera_info、/image_combine_raw/right/camera_info),不是直接读你这份 yaml。mipi_cam 启动日志里有 config_file_path is /opt/tros/humble/lib/mipi_cam/config/,标定配置就在那个目录,先 ls /opt/tros/humble/lib/mipi_cam/config/ 看文件名,再把上面第 2 点的 yaml 按其字段替换;核对是否生效用 ros2 topic echo /image_combine_raw/right/camera_info 看 K/D 是否等于你的数。另外它输出的是 640×352 的视差/深度图,反投影要用同分辨率的 fx/cx/基线(Z=fx·b/d),不能拿 1088×1280 的 K 去套。

还差一个数就能判标定质量:你脚本打印的「左右单目 RMS、双目 RMS、去畸变直线度 RMS、有效帧数」发一下,我看这批图够不够,不够就补采(棋盘格要铺到四角、15 组以上、含倾斜)。

好的
3.请问‘自己做 remap/undistort ’是什么意思呢?
我脚本打印没有第 5 个值里第 5 位,只有四位

4.请问save_stereo.py生成的点云和 hobot_stereonet生成的点云有什么区别呢?save_stereo.py生成的点云是地瓜立体匹配网络计算生成的吧?hobot_stereonet是不是只是地瓜里其中的一种立体匹配网络呢?

1. “自己做 remap/undistort”就是按标定参数重排像素

cv2.initUndistortRectifyMap(K, D, R, P, (W,H), cv2.CV_32FC1) 用你的 K/D/R 算出两张 float32 映射表:每个输出像素该到原图哪个坐标取样。然后 cv2.remap(left, m1x, m1y, cv2.INTER_LINEAR) 按表重采样,就得到去畸变(并已极线校正)的左图。映射表只取决于标定结果,进程里算一次,之后每帧只做 remap,开销很小。你手里有 K/D/R/T,所以这步完全能自己完成,且用的就是你验证过的那组数——这也是 stereonet、标定工具内部做的同一步。

2. 4 个和 5 个系数不矛盾,是两套约定

kalibr 的 radtan 定义上只有 4 个:k1,k2,p1,p2,所以官方 yaml 只有 4 个值(AMR 3.5.2 · iv 示例);而 cv2.calibrateCamera 返回 5 个:k1,k2,p1,p2,k3。你 yaml 里只有 4 个,是因为写文件那行截了 d_left[:4];终端打印 左目畸变: 那行本该是 5 个。k3 量级很小(一般 |k3|<0.01)丢掉没影响,所以约定死:yaml 写 4 个,initUndistortRectifyMap 也传这 4 个,下游和自己 remap 始终一致。

3. 两个点云不是一回事

  • save_stereo.py 里的点云:cv2.StereoSGBM 是传统块匹配(不是神经网络),视差除 16 后 cv2.reprojectImageTo3D(disp/16.0, Q) 得到左目坐标系 XYZ(米)。离线、参数可控、直接吃你这份 yaml。
  • hobot_stereonet:地瓜的双目深度算法,模型借鉴 IGEV、用 GRU 细化,X5 上是 V2.4_int8 / V2.4_int16、输入 640×352×3×2,官方描述的输出是左视图对应的视差图和深度图,不含 PLY,点云同样要自己反投影。它是这一个包,int8/int16 是同一网络的量化版本,不是并列的多个网络;SGBM 不属于这个体系。见双目深度估计。
  • 标定怎么进:stereonet 不读你这份 kalibr yaml,走 camera_info 话题。magicbox 侧脚本是 run_stereo.sh ... --camera_info_topic /image_combine_raw/right/camera_info(同上页),TROS 2.5.1 起 mipi_cam 的拼接图标定话题改为 /image_combine_raw/left/camera_info、/image_combine_raw/right/camera_info(版本说明 2.5.1)。所以先核对它拿到的是什么:
ros2 topic echo /image_combine_raw/right/camera_info --once

width/height、K 要和你的 yaml 对得上(网络实际吃 640×352,深度尺度按 camera_info 的内参推,分辨率对不上整幅深度就整体错)。想让 stereonet 用你自己标的结果,就得由你的脚本按这组 K/D/R/T 发布 camera_info,或把 camera_info_topic 指到该话题。

建议顺序:先用 SGBM 那条跑通,用它验标定(左右 *_rect 同名角点 y 差 <1px、量已知尺寸看 Z 是否准),再把同一组参数接到 stereonet 比精度。

把终端里 左目畸变: / 右目畸变: 两行原样贴一下,我确认 k3 是否可以放心丢。

好的,这是终端输出:
左目单标 RMS: 0.274216 px
右目单标 RMS: 0.158946 px
左目内参 K:
[[664.43285692 0. 542.55797891]
[ 0. 664.69580256 640.03006461]
[ 0. 0. 1. ]]
右目内参 K:
[[664.50635257 0. 541.63892297]
[ 0. 664.63468014 639.58186959]
[ 0. 0. 1. ]]
左目畸变: [-3.17216620e-01 1.17014683e-01 -1.81593970e-04 -2.48241630e-04
-2.09038446e-02]
右目畸变: [-3.29345202e-01 1.37607827e-01 2.83570959e-05 5.53948749e-05
-3.02145943e-02]
===== 双目标定(radtan,fix_intrinsics=1)=====
双目 RMS(OpenCV): 0.233947 px
R:
[[ 0.99999247 -0.00331027 0.00202672]
[ 0.00330757 0.99999364 0.00133254]
[-0.00203112 -0.00132582 0.99999706]]
T: [ 6.83110523e-05 -8.00232985e-02 -2.27819311e-04] (m)
基线 norm(T): 80.02 mm
基线 abs(Tx): 0.07 mm
预期模组基线: 80 mm (GS130W)
去畸变后直线度 RMS(左目,radtan): 0.2522 px(越小越直)

你上面说的‘ 你打算跑哪条 stereonet(TROS 里的 launch,还是 /userdata/magicbox 的 run_stereo.sh )——后者决定标定文件放哪、字段怎么填,我按你那条给具体命令。’是什么意思呢?地瓜有很多条stereo net吗?很多种生成点云文件的方案吗?
我需要用地瓜的双目深度算法,而且要用自己标定的结果,具体要怎么做呢?

1. remap/undistort 就是按标定参数重排像素。 cv2.initUndistortRectifyMap(K, D, R, P, (W,H), cv2.CV_32FC1) 用你的 K/D/R 算出两张 float32 映射表(每个输出像素去原图哪个坐标取样,算一次即可),cv2.remap 每帧按表重采样,得到的就是"矫正后的左图"。stereonet 内部做的也是这一步,只是它不读你的 yaml,而是读 camera_info 里的参数。

2. 你这两个 k3 不能按上一轮的说法直接丢。 终端打出的第 5 位是 k3=-0.0209(左)/-0.0302(右),不是我上条说的 |k3|<0.01 那种量级;配 k1≈-0.32 的广角,k3 在画面边缘对径向项有 10% 以上贡献。所以上一轮"yaml 和 remap 都用 4 个"的约定按你的实际数据改成:

  • 自己 remap / SGBM:用完整 5 个(initUndistortRectifyMap 接受 5 元素 D);
  • 官方 kalibr yaml 只有 k1,k2,p1,p2 四个位置,写进去必然截断:拿同一张图分别用 D[:4] 和 D 各建一次 map,比对棋盘角点位置与直线度,差 <0.3px 就按 4 个走;差得多就别走 yaml,走 camera_info(下文)。

3. 不是"很多条 stereonet"。 X5 上就是 hobot_stereonet 一个算法,V2.4_int8 / V2.4_int16 是同一个网络的两种量化,不是并列的多个网络;save_stereo.py 里的 StereoSGBM 是 OpenCV 传统块匹配,不是地瓜的立体匹配网络,那个点云完全是本地算出来的。两条路唯一区别是视差来源,PLY 都得自己反投影——官方链路本身只输出左视图对应的视差图和深度图,不含点云文件(magicbox 双目深度估计)。

4. 想让 stereonet 吃你的标定,唯一入口是 camera_info,不是 kalibr yaml。 TROS 2.5.2 起它订阅的是 /image_combine_raw/left/camera_info 和 /image_combine_raw/right/camera_info(版本发布记录 2.5.2),magicbox 脚本对应参数就是 --camera_info_topic。

  1. 先看官方那份长什么样,记下 width/height、K、D、P:
ros2 topic echo /image_combine_raw/right/camera_info --once
  1. 停掉你终端A 的 mipi_cam(run_stereo.sh 自己会拉相机,两个进程抢同一路 sensor),先按官方默认跑通:
cd /userdata/magicbox
source /opt/tros/humble/setup.bash
source app/ros_ws/install/setup.bash
bash app/ros_ws/src/magicbox/hobot_stereonet/script/run_stereo.sh \
  --mipi_rotation 0.0 --stereonet_version v2.4_int8 \
  --camera_info_topic /image_combine_raw/right/camera_info
  1. 换成你的参数:写个小节点发布 sensor_msgs/CameraInfo 到 /my_calib/right/camera_info,K 用 664.43/664.70/542.56/640.03,D 填 5 个,P 的 P[0,3] = -fx*0.08002 ≈ -53.17(右目基线项),width/height 照官方那份 camera_info 填(别拿 1088×1280 猜,它按自己消费的分辨率做校正),再 --camera_info_topic /my_calib/right/camera_info 重跑;左目那份也要一起发,否则左右来源不一致。
  2. 验证:棋盘/已知距离物体放 30cm、50cm,看深度读数。整体按比例偏 = 分辨率或基线填错;中间准、边缘糊 = 畸变系数没被吃全(回第 2 点对比 4/5 系数)。

官方默认脚本跑通后,Web 端大致是这样的深度图效果:

你的 hobot_stereonet 是哪个版本(ros2 pkg xml hobot_stereonet | grep version)?2.5.2 之前订阅的话题名不一样,我按你的版本给对应命令。