线结构光标定详细步骤与实现HALCON,带3D平面拟合

引用:线结构光标定详细步骤与实现HALCON_冯相文要加油呀!的博客-CSDN博客_halcon线结构光标定

线结构光标定详细步骤与实现HALCON

冯相文要加油呀  于 2021-08-23 12:08:05 发布  818  收藏 18
分类专栏: 机器视觉 文章标签: c++ c语言 计算机视觉
版权

机器视觉
专栏收录该内容
175 篇文章5 订阅
订阅专栏
这部分是HALCON官方的一个例子,下面是对这个比较复杂的例子的一些理解,具体的每一句代码都对应相应的作用解释

具体的例子是这个:

此程序演示如何执行校准光片测量系统:

测量系统由区域扫描摄像机和光线投影仪(例如激光线投影仪)组成。投影机的位置和方向相机是固定的。为了重建整个物体的表面(而不仅仅是单个轮廓),物体必须在测量系统下移动,或者在物体上移动测量系统。
因此,校准分三个步骤执行:

首先,我们使用标准摄像机确定摄像机的内部和外部参数
校准程序。
然后,我们确定光平面与世界坐标系统的方向。这是通过计算一个面向z=0的平面与轻型平面重合的姿势来完成的。
最后,我们校准了对象在连续两个配置文件的获取之间的移动。与世界坐标系也表达了与这一运动相对应的变革。
最后的应用:我们展示了如何将校准转换应用于已经获得的差异图像。
执行一些初始化
dev_update_off ()
dev_close_window ()
*//读取图片
read_image (ProfileImage, 'sheet_of_light/connection_rod_001.png')
*//获取图片大小
get_image_size (ProfileImage, Width, Height)
*//创建窗口
dev_open_window (0, 0, Width, Height, 'black', WindowHandle)
*//设置填充模式:只显示轮廓
dev_set_draw ('margin')
*//设置宽度
dev_set_line_width (3)
*//设置颜色
dev_set_color ('lime green')
*//设置查找表,查找表定义了将来自单通道图像的“灰度值”转换为屏幕上的灰度值或颜色。
dev_set_lut ('default')
*//设置字体
set_display_font (WindowHandle, 14, 'mono', 'true', 'false')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
上面这部分的代码读取激光图像,获取图像尺寸,设置一些字体大小形状颜色等

读取出来的激光图像:

图像读取的一些信息:


第 1 部分:执行摄像机校准
* -------

* Initialize some parameters required for the camera calibration
* //初始化一些相机参数
gen_cam_par_area_scan_polynomial (0.0125, 0.0, 0.0, 0.0, 0.0, 0.0, 0.000006, 0.000006, 376.0, 120.0, 752, 240, StartParameters)
*//下面是选择标定板文件
CalTabDescription := 'caltab_30mm.descr'
* Note that the thickness of the calibration target used for this example is 0.63 mm.
* If you adapt this example program to your application, it is necessary to determine
* the thickness of your specific calibration target and to use this value instead.
*//下面是标定板厚度,这个例子里面是这么多,我们自己用的时候需要对应修改
CalTabThickness := .00063
*//这是图像数目
NumCalibImages := 20

* Initialize a calibration data model
* //初始化标定模型,用标定助手生成代码的时候也会有这一句
create_calib_data ('calibration_object', 1, 1, CalibDataID)
*//这个StartParameters其实就是我们最上面初始化的数据
set_calib_data_cam_param (CalibDataID, 0, [], StartParameters)
*//这个是标定物的一些参数,也就是上面的那些数据
set_calib_data_calib_object (CalibDataID, 0, CalTabDescription)

* Collect mark positions and estimated poses for all
* calibration images
* //找到所有图片中的标志点,并且估算出这些图像的位姿
for Index := 1 to NumCalibImages by 1
    *//循环式读取文件夹中多幅图片
    read_image (Image, 'sheet_of_light/connection_rod_calib_' + Index$'.2')
    *//显示图片
    dev_display (Image)
    *//找到图片中标定物
    find_calib_object (Image, CalibDataID, 0, 0, Index, [], [])
    *//下面两句是找到标定板中的标志点和轮廓
    get_calib_data_observ_points (CalibDataID, 0, 0, Index, Row, Column, _Index, Pose)
    get_calib_data_observ_contours (Contours, CalibDataID, 'caltab', 0, 0, Index)
    *//设置颜色
    dev_set_color ('green')
    *//显示轮廓
    dev_display (Contours)
    *//在中心点上画绿色的叉叉和外轮廓
    gen_cross_contour_xld (Cross, Row, Column, 6, 0.785398)
    *//设置颜色
    dev_set_color ('yellow')
    *//把叉叉设置为黄色的
    dev_display (Cross)
endfor

* Perform the actual calibration
*// 真正的标定函数,计算相机的内外参矩阵,CalibDataID只是一个句柄,供我们后面获取参数使用
*//Errors返回为优化后的反投影的均方根误差,单位为像素,该误差用来反映优化是否成功,越接近0表示效果越好(这里的解释是参考了网上的一些查找的资料)
calibrate_cameras (CalibDataID, Errors)
*//显示标定成功
disp_message (WindowHandle, 'The camera calibration has been performed successfully', 'window', 12, 12, 'black', 'true')
*//显示按下F5,Continue信息
disp_continue_message (WindowHandle, 'black', 'true')
stop ()

* -------

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
上面这部分代码,也就是常见的相机标定了,目的也就是标定好相机,可以利用标定助手帮助我们拍摄标定图片,标定助手使用可以参考这一篇:
HALCON标定助手使用实例
标定板文件的选择参考:
HALCON选择标定板文件

这是找到轮廓和关键点之后画的绿色的线和特征点:


把中心点叉叉转为黄色:

经过标定计算之后得到的数据:


第 2 部分:校准线结构光平面与世界坐标系统的方向
* -------
* //dev_set_color定义用于在图形窗口中显示region,XLD和其他geometrical对象的颜色
dev_set_colored (3)
*//设定好一个阈值,下面的算子可以用上
MinThreshold := 80

* Definition of the world coordinate system (WCS):
* Here, the WCS is defined implicitly by choosing one
* specific calibration image. In order to take the thickness
* of the calibration table into account, we shift the origin
* of the pose that corresponds to the chosen calibration image
* (and that was computed during the calibration).
*//世界坐标系统的定义:
*//在这里,WCS 通过选择一个来隐含定义特定校准图像。为了采取厚度校准表的考虑,我们转移原点对应于所选校准图像的姿势(这是在校准期间计算的)。
*//Index给19为了得到第19幅图像中标定物的位姿数据
Index := 19
*//CalibDataID就是上面我们标定出来的结果数据,获取标定数据
get_calib_data (CalibDataID, 'calib_obj_pose', [0,Index], 'pose', CalTabPose)
*//平移CalTabPose的原点,沿着厚度方向CalTabThickness的大小,输出为新的位姿,平移是相对于姿势本身的局部坐标系执行的
set_origin_pose (CalTabPose, 0.0, 0.0, CalTabThickness, CameraPose)
*//读取一幅图像
read_image (CalTabImage1, 'sheet_of_light/connection_rod_calib_' + Index$'.2')
*//显示图片
dev_display (CalTabImage1)
*//通过上面获得的句柄得到标定参数
get_calib_data (CalibDataID, 'camera', 0, 'params', CameraParameters)
*//显示一个3D坐标系统,是以我们转换之后的坐标原点来设置的
disp_3d_coord_system (WindowHandle, CameraParameters, CameraPose, .01)
*//显示信息
disp_message (WindowHandle, 'World coordinate system', 'window', 12, 12, 'black', 'true')
*//F5提示
disp_continue_message (WindowHandle, 'black', 'true')
stop ()

* Definition of a temporary coordinate system (TCS):
* The TCS is also defined implicitly by choosing another
* calibration image. Here again we shift the origin of
* the coordinate system in order to take the thickness
* of the calibration table into account.
*//临时坐标系统的定义:
*//TCS 还通过选择另一个校准图像来隐含定义。在这里,我们再次改变坐标系统的起源,以考虑到校准表的厚度。
*//下面的过程和上面的完全一样,就是建立不同的坐标系
Index := 20
get_calib_data (CalibDataID, 'calib_obj_pose', [0,Index], 'pose', CalTabPose)
set_origin_pose (CalTabPose, 0.0, 0.0, CalTabThickness, TmpCameraPose)
read_image (CalTabImage2, 'sheet_of_light/connection_rod_calib_' + Index$'.2')
dev_display (CalTabImage2)
disp_3d_coord_system (WindowHandle, CameraParameters, TmpCameraPose, .01)
disp_message (WindowHandle, 'Temporary coordinate system', 'window', 12, 12, 'black', 'true')
disp_continue_message (WindowHandle, 'black', 'true')
stop ()


* Compute the 3D coordinates of the light line points
* in the plane z=0 of the WCS
* //在世界坐标系中Z等于0的平面计算激光线的点的3D坐标,此图像为实时图像。
dev_clear_window ()
read_image (ProfileImage1, 'sheet_of_light/connection_rod_lightline_019.png')
*//计算图像中激光点的世界坐标,并且要以计算的那个平面为Z=0的平面
compute_3d_coordinates_of_light_line (ProfileImage1, MinThreshold, CameraParameters, [], CameraPose, X19, Y19, Z19)
*//如果激光线的3D坐标的数模等于0,就中止程序,也就是说当点都是0的时候就不继续执行了
if (|X19| == 0 or |Y19| == 0 or |Z19| == 0)
    dev_display (ProfileImage1)
    disp_message (WindowHandle, 'The profile MUST be oriented horizontally\nfor successfull processing!\nThe program will exit.', 'window', 12, 12, 'black', 'true')
    return ()
endif

* Compute the 3D coordinates of the light line points
* in the plane z=0 of the TCS
*//计算临时坐标下的Z=0的激光点的3D坐标,和上面的过程类似
read_image (ProfileImage2, 'sheet_of_light/connection_rod_lightline_020.png')
compute_3d_coordinates_of_light_line (ProfileImage2, MinThreshold, CameraParameters, TmpCameraPose, CameraPose, X20, Y20, Z20)
if (|X20| == 0 or |Y20| == 0 or |Z20| == 0)
    disp_message (WindowHandle, 'The profile MUST be oriented horizontally\nfor successfull processing!\nThe program will exit.', 'window', 12, 12, 'black', 'true')
    return ()
endif

* Fit the light plane in the 3D coordinates of the line
* points computed previously. Note that this requires
* nearly coplanar points. We must provide line points
* recorded at -at least- two different heights, in order
* to get an unambiguous solution. To obtain stable and
* accurate results, acquire the light line points at the
* bottom and at the top of the measurement volume.
*// 将光平面安装在以前计算的线点的 3D 坐标中。请注意,这几乎需要平面点。
*//我们必须提供至少两个不同的高度记录的线点,以便得到一个明确的解决方案。
*//要获得稳定准确的结果,获取测量量底部和顶部的光线点。
*//通过两个面的点坐标拟合3D平面,该算子返回输入坐标的形心坐标(通过求平均值)和拟合平面的法向量(求奇异值分解)。
fit_3d_plane_xyz ([X19,X20], [Y19,Y20], [Z19,Z20], Ox, Oy, Oz, Nx, Ny, Nz, MeanResidual)
*//如果点太少或者几乎是很接近就返回
if (|Nx| == 0 or |Ny| == 0 or |Nz| == 0)
    disp_message (WindowHandle, 'Too few 3d points have been provided to fit the light plane,\nor the points are (nearly) collinear!\nThe program will exit.', 'window', 12, 12, 'black', 'true')
    return ()
endif
*//如果3D点之间平均剩余距离太大就返回
if (MeanResidual > 5e-5)
    disp_message (WindowHandle, 'The light plane could not be fitted accurately!\nThe mean residual distance between the 3d-points and the\nfitted plane is too high (' + (MeanResidual * 1000)$'.3' + 'mm). Please check the\nquality and the correctness of those points.\nThe program will exit!', 'window', 12, 21, 'black', 'true')
    return ()
endif

* Compute the light plane pose: this pose must be oriented
* such that the plane defined by z=0 coincides with the
* light plane.
* //get_light_plane_pose算子通过中心点和法向量计算出LightPlanePose就是光平面的3D表达式了
get_light_plane_pose (Ox, Oy, Oz, Nx, Ny, Nz, LightPlanePose)
*//如果计算出来不是7个数据就说明出现错误返回
if (|LightPlanePose| != 7)
    disp_message (WindowHandle, 'The pose of the light plane could not be\ndetermined. Please verify that the vector\npassed at input of the procedure\nget_light_plane_pose() is not null.\nThe program will exit!', 'window', 12, 12, 'black', 'true')
    return ()
endif
*//下面就是显示出标定数据
String := ['LightPlanePose: ','  Tx    = ' + LightPlanePose[0]$'.3' + ' m','  Ty    = ' + LightPlanePose[1]$'.3' + ' m','  Tz    = ' + LightPlanePose[2]$'.3' + ' m','  alpha = ' + LightPlanePose[3]$'.4' + '°','  beta  = ' + LightPlanePose[4]$'.4' + '°','  gamma = ' + LightPlanePose[5]$'.4' + '°','  type  = ' + LightPlanePose[6]]
disp_message (WindowHandle, String, 'window', 12, 12, 'black', 'true')
disp_continue_message (WindowHandle, 'black', 'true')
stop ()
dev_clear_window ()

* -------

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
set_origin_pose算子解释(帮助文档):


set_origin_pose算子计算出来的结果:

set_origin_pose算子计算出来的结果:

compute_3d_coordinates_of_light_line算子的解释:

compute_3d_coordinates_of_light_line算子计算的结果:

fit_3d_plane_xyz算子计算结果:

fit_3d_plane_xyz算子参数:

get_light_plane_pose算子计算结果:


LightPlanePose数据类型:

下面就是显示出来的标定数据,和上面的一样,这样就标定出来了我们所要的光平面表达式:


第 3 部分:在获取连续两个配置文件之间校准对象的移动
* -------

* In order to determine the movement of the object between
* two successive profile images, we acquire two images of a
* calibration table which describe the same movement.
* In order to get a good accuracy, we usually move the
* calibration table by more than one step.
*// 为了确定物体在连续两个配置文件图像之间的移动,我们需要获取一个标定板的两个图像,该图像描述了相同的运动。
*// 为了获得良好的准确性,我们通常将校准表移动不止一步,需要多次移动标定板。
*// 再读取两幅图像
read_image (CaltabImagePos1, 'sheet_of_light/caltab_at_position_1.png')
read_image (CaltabImagePos20, 'sheet_of_light/caltab_at_position_2.png')
*//需要移动19次
StepNumber := 19

* Set the optimized camera parameter as new start camera parameters for the
* calibration data model to extract the following poses using
* these calibrated parameters.
* //* 将优化的相机参数设置为校准数据模型的新启动相机参数,以便使用这些校准参数提取以下姿势。
set_calib_data_cam_param (CalibDataID, 0, [], CameraParameters)
* Compute the pose of the calibration table in each image
* //计算每幅图像中的标定板的位姿,从校准数据模型中提取未优化的姿势
find_calib_object (CaltabImagePos1, CalibDataID, 0, 0, NumCalibImages + 1, [], [])
* Extract the unoptimized pose from the calibration data model
* //获取位姿信息
get_calib_data_observ_points (CalibDataID, 0, 0, NumCalibImages + 1, Row1, Column1, Index1, CameraPosePos1)
find_calib_object (CaltabImagePos20, CalibDataID, 0, 0, NumCalibImages + 2, [], [])
get_calib_data_observ_points (CalibDataID, 0, 0, NumCalibImages + 2, Row1, Column1, Index1, CameraPosePos20)
* Clear the model
* //删除标定模型
clear_calib_data (CalibDataID)

* Compute the coordinates of the origin of the calibration
* table in the two positions with respect to the world
* coordinate system and determine the coordinates of the
* corresponding translation vector

* //计算标定板在两个位置相对于世界坐标系的原点坐标,并确定相应平移向量的坐标
* //平移CameraPosePos1和20的原点,输出为新的位姿
set_origin_pose (CameraPosePos1, 0.0, 0.0, CalTabThickness, CameraPosePos1)
set_origin_pose (CameraPosePos20, 0.0, 0.0, CalTabThickness, CameraPosePos20)
*//将CameraPosePos1和CameraPosePos20位姿转换为齐次变换矩阵
pose_to_hom_mat3d (CameraPosePos1, HomMat3DPos1ToCamera)
pose_to_hom_mat3d (CameraPosePos20, HomMat3DPos20ToCamera)
pose_to_hom_mat3d (CameraPose, HomMat3DWorldToCamera)
*//再把上面的齐次变换矩阵转换为齐次3D变换矩阵
hom_mat3d_invert (HomMat3DWorldToCamera, HomMat3DCameraToWorld)
*//两个矩阵相乘见下面截图有算法
hom_mat3d_compose (HomMat3DCameraToWorld, HomMat3DPos1ToCamera, HomMat3DPos1ToWorld)
hom_mat3d_compose (HomMat3DCameraToWorld, HomMat3DPos20ToCamera, HomMat3DPos20ToWorld)
*//对点做仿射变换,得到相对于世界坐标系的起始坐标和终点坐标,下面有截图算法
affine_trans_point_3d (HomMat3DPos1ToWorld, 0, 0, 0, StartX, StartY, StartZ)
affine_trans_point_3d (HomMat3DPos20ToWorld, 0, 0, 0, EndX, EndY, EndZ)
*//这里是创建位姿,算法比较复杂在帮助文档内有
create_pose (EndX - StartX, EndY - StartY, EndZ - StartZ, 0, 0, 0, 'Rp+T', 'gba', 'point', MovementPoseNSteps)
*//这里就是计算出来的在两个连续的轮廓图像间物体的移动标定结果
MovementPose := MovementPoseNSteps / StepNumber
String := ['MovementPose: ','  Tx    = ' + MovementPose[0]$'.3' + ' m','  Ty    = ' + MovementPose[1]$'.3' + ' m','  Tz    = ' + MovementPose[2]$'.3' + ' m','  alpha = ' + MovementPose[3] + '°','  beta  = ' + MovementPose[4] + '°','  gamma = ' + MovementPose[5] + '°','  type  = ' + MovementPose[6]]
disp_message (WindowHandle, String, 'window', 12, 12, 'black', 'true')
disp_continue_message (WindowHandle, 'black', 'true')
stop ()
dev_clear_window ()

* -------

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
hom_mat3d_invert算子原理:


hom_mat3d_compose算子原理:

affine_trans_point_3d算法原理:

create_pose算子运行结果:

MovementPose数据格式:


第 4 部分:将校准转换应用于已经获得的差异图像。
* -------

* Read an already acquired disparity map from file
read_image (Disparity, 'sheet_of_light/connection_rod_disparity.tif')
* Create a model and set the required parameters
* //创建一个矩形框并且设置一些位置参数
gen_rectangle1 (ProfileRegion, 120, 75, 195, 710)
*//创建激光三角的模型
create_sheet_of_light_model (ProfileRegion, ['min_gray','num_profiles','ambiguity_solving'], [70,290,'first'], SheetOfLightModelID)
*//设置激光三角的参数
set_sheet_of_light_param (SheetOfLightModelID, 'calibration', 'xyz')
set_sheet_of_light_param (SheetOfLightModelID, 'scale', 'mm')
set_sheet_of_light_param (SheetOfLightModelID, 'camera_parameter', CameraParameters)
set_sheet_of_light_param (SheetOfLightModelID, 'camera_pose', CameraPose)
set_sheet_of_light_param (SheetOfLightModelID, 'lightplane_pose', LightPlanePose)
set_sheet_of_light_param (SheetOfLightModelID, 'movement_pose', MovementPose)

* Apply the calibration transforms and
* get the resulting calibrated coordinates
*//计算三种标定结果
apply_sheet_of_light_calibration (Disparity, SheetOfLightModelID)
get_sheet_of_light_result (X, SheetOfLightModelID, 'x')
get_sheet_of_light_result (Y, SheetOfLightModelID, 'y')
get_sheet_of_light_result (Z, SheetOfLightModelID, 'z')
clear_sheet_of_light_model (SheetOfLightModelID)

* Display the resulting Z-coordinates
* //显示生成的 Z 坐标结果
dev_close_window ()
get_image_size (Disparity, Width, Height)
dev_open_window (Height + 10, 0, Width * .5, Height * .5, 'black', WindowHandle3)
set_display_font (WindowHandle3, 14, 'mono', 'true', 'false')
dev_set_lut ('temperature')
dev_display (Z)
disp_message (WindowHandle3, 'Calibrated Z-coordinates', 'window', 12, 12, 'black', 'true')

* Display the resulting Y-coordinates
* //显示生成的 Y 坐标结果
dev_open_window ((Height + 10) * .5, 0, Width * .5, Height * .5, 'black', WindowHandle2)
set_display_font (WindowHandle2, 14, 'mono', 'true', 'false')
dev_display (Y)
disp_message (WindowHandle2, 'Calibrated Y-coordinates', 'window', 12, 12, 'black', 'true')

* Display the resulting X-coordinates
* //显示生成的 X 坐标结果
dev_open_window (0, 0, Width * .5, Height * .5, 'black', WindowHandle1)
dev_display (X)
set_display_font (WindowHandle1, 14, 'mono', 'true', 'false')
disp_message (WindowHandle1, 'Calibrated X-coordinates', 'window', 12, 12, 'black', 'true')

* //显示重建后的3D模型
* //注意,摄像机观察物体穿过的平面的那一部分
* //生成3D模型
* //下面这部分是实例中没有的,我再另一处文章中拷过来的,我试了一下有问题
get_sheet_of_light_result_object_model_3d (SheetOfLightModelID, ObjectModel3D)
gen_sheet_of_light_object_model_3d (SheetOfLightModelID, 0.1, 0.05, 0.3, OM3DLightPlane, OM3DMovement, OM3DCamera, OM3DCone)
dev_open_window (0, Width * .5 + 10, Width, Height * 1.5, 'black', WindowHandle)
dev_set_lut ('default')
set_display_font (WindowHandle, 14, 'mono', 'true', 'false')
visualize_object_model_3d (WindowHandle, [ObjectModel3D,OM3DLightPlane,OM3DMovement,OM3DCamera,OM3DCone], [], [-0.002989894371,0.1325031046,8.667736001,288.0583956,2.798360231,297.2537796,0], ['alpha_1','alpha_3','alpha_4','alpha_5','color_0','color_3','color_4','color_5'], [0.5,0.5,0.5,0.5,'blue','green','green','green'], 'Setup with reconstructed object', [], [], PoseOut)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
综上所述
线结构光标定的流程有三步:

普通的相机标定

标定激光平面
a,两幅图像考虑完厚度之后,平移变换得到一个世界坐标,一个临时坐标
b,调用compute_3d_coordinates_of_light_line计算出两幅图片中光线上面的3D点坐标并且利用这些点调用fit_3d_plane_xyz计算出拟合激光面
c,调用get_light_plane_pose算子计算出上面拟合的激光面的参数

物体移动的标定
a,计算两幅图像的对应的两个位姿
b,通过一系列矩阵变换的算子,计算出两个图像中物体分别的3D坐标,就能求出物体的移动是多少了
————————————————
版权声明:本文为CSDN博主「冯相文要加油呀」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_51229250/article/details/119862087

猜你喜欢

转载自blog.csdn.net/u014090257/article/details/122630984