用霍夫变换&SCNN码一个车道追踪器
选自towardsdatascience
作者:Chuan-en Lin
机器之心编译
参与:李诗萌、张倩
车道标志线向人类驾驶员指示了车道的位置,并作为导向参考来引导车辆的方向,但是无人驾驶汽车是如何「认路」的呢?这要依赖于它们识别和追踪车道的能力。这项能力对于开发无人驾驶车辆的算法来说至关重要。本教程将讲解如何用计算机视觉技术构建车道追踪器。
本教程使用霍夫变换和 SCNN 两种方法来完成这项任务。
方法 1:霍夫变换
大多数车道都设计得相对简单,这不仅是为了鼓励有序,还为了让人类驾驶员更容易以相同的速度驾驶车辆。因此,我们的方法可能会通过边缘检测和特征提取技术先检测出摄像机馈送回来的直线。我们将用 OpenCV(一个开源的用来实现计算机视觉算法的库)。下图是我们的方法流程的概述。
在开始之前,这里有一个结果的演示视频:
1. 配置你的环境
如果你还没有安装 OpenCV,打开终端并运行:
pipinstallopencv-python
运行下列命令行,把教程所用的库克隆下来:
gitclonehttps://github.com/chuanenlin/lane-detector.git
接着用文本编辑器打开 detector.py。这个 Python 文件中有这一节所有的代码。
2. 处理视频
将我们的样本视频以 10 毫秒为间隔变成一组连续的帧(图像)用于车道检测。可以随时按下「q」键退出程序。
importcv2ascv
#ThevideofeedisreadinasaVideoCaptureobject
cap=cv.VideoCapture("input.mp4")
while(cap.isOpened()):
#ret=abooleanreturnvaluefromgettingtheframe,frame=thecurrentframebeingprojectedinthevideo
ret,frame=cap.read()
#Framesarereadbyintervalsof10milliseconds.Theprogramsbreaksoutofthewhileloopwhentheuserpressesthe'q'key
ifcv.waitKey(10)0xFF==ord('q'):
break
#Thefollowingfreesupresourcesandclosesallwindows
cap.release()
cv.destroyAllWindows()
3. 用 Canny 检测器
Canny 检测器是一种可以快速进行实时边缘检测的多阶段优化算法。该算法的基本目标是检测亮度的急剧变化(大梯度),比如从白色到黑色的变化,在给出一组阈值的情况下将它们定义为边。Canny 算法主要有 4 个阶段:
A. 降噪
和所有边缘检测算法一样,噪声是导致错误检测的关键问题。用 5*5 的高斯滤波器卷积(平滑)图像,从而降低检测器对噪声的敏感度。这是通过在整个图像上运行数字正态分布的核(本例中是 5*5 的核)来实现的,将每个像素的值设置为相邻像素值的加权平均值。
5*5 的高斯核。星号表示卷积运算。
B.强度梯度
然后在平滑的图像上用 Sobel、Roberts 或 Prewitt 核(Sobel 用在 OpenCV 中)沿 x 轴和 y 轴检测边缘是水平的、垂直的还是呈对角线的。
用于计算水平方向和垂直方向的一阶导数的 Sobel 核。
C.非极大值抑制
非极大值抑制用于「细化」和有效地锐化边缘。检查每个像素的值在先前计算的梯度方向上是否为局部最大值。
三个点上的非极大值抑制。
A 在垂直方向的边上。由于梯度方向垂直于边的方向,比较 A 的像素值和 B、C 的像素值来确定 A 是否是局部最大值。如果 A 是局部最大值,则在下一个点上测试非极大值抑制,否则将 A 的像素值设置为 0 并抑制 A。
D. 滞后阈值
在非极大值抑制后,确认强像素在最终的边缘映射中。但还要对弱像素进行进一步分析来确定它是边缘还是噪声。利用预先定义的 minVal 和 maxVal 阈值,我们认为强度梯度高于 maxVal 的是边缘,低于 minVal 的不是边缘并将其删除。强度梯度在 minVal 和 maxVal 之间的像素只有在和梯度高于 maxVal 的像素相连时才是边缘。
滞后阈值在两条线上的例子。
A 边高于 maxVal,所以是边。B 边在 maxVal 和 minVal 之间但没有和任何高于 maxVal 的边相连,所以删除。C 边在 maxVal 和 minVal 之间,且与 A 边(高于 maxVal)相连,所以是边。
根据上述流程,首先要灰度化我们的帧,因为我们只需用亮度通道来检测边缘,还要用 5*5 的高斯模糊来减少噪声从而避免判断出错误的边。
#importcv2ascv
defdo_canny(frame):
#Convertsframetograyscalebecauseweonlyneedtheluminancechannelfordetectingedges-lesscomputationallyexpensive
gray=cv.cvtColor(frame,cv.COLOR_RGB2GRAY)
#Appliesa5x5gaussianblurwithdeviationof0toframe-notmandatorysinceCannywilldothisforus
blur=cv.GaussianBlur(gray,(5,5),0)
#AppliesCannyedgedetectorwithminValof50andmaxValof150
canny=cv.Canny(blur,50,150)
returncanny
#cap=cv.VideoCapture("input.mp4")
#while(cap.isOpened()):
#ret,frame=cap.read()
canny=do_canny(frame)
#ifcv.waitKey(10)0xFF==ord('q'):
#break
#cap.release()
#cv.destroyAllWindows()
4. 分割车道区域
我们要手动制作一个三角形掩码来分割帧中的车道区域,删除帧中的不相关区域以便提高后期的效率。
三角形掩码通过三个坐标定义,用图中的绿色圆圈表示。
#importcv2ascv
importnumpyasnp
importmatplotlib.pyplotasplt
#defdo_canny(frame):
#gray=cv.cvtColor(frame,cv.COLOR_RGB2GRAY)
#blur=cv.GaussianBlur(gray,(5,5),0)
#canny=cv.Canny(blur,50,150)
#returncanny
defdo_segment(frame):
#Sinceanimageisamulti-directionalarraycontainingtherelativeintensitiesofeachpixelintheimage,wecanuseframe.shapetoreturnatuple:[numberofrows,numberofcolumns,numberofchannels]ofthedimensionsoftheframe
#frame.shape[0]giveusthenumberofrowsofpixelstheframehas.Sinceheightbeginsfrom0atthetop,they-coordinateofthebottomoftheframeisitsheight
height=frame.shape[0]
#Createsatriangularpolygonforthemaskdefinedbythree(x,y)coordinates
polygons=np.array([
[(0,height),(800,height),(380,290)]
])
#Createsanimagefilledwithzerointensitieswiththesamedimensionsastheframe
mask=np.zeros_like(frame)
#Allowsthemasktobefilledwithvaluesof1andtheotherareastobefilledwithvaluesof0
cv.fillPoly(mask,polygons,255)
#Abitwiseandoperationbetweenthemaskandframekeepsonlythetriangularareaoftheframe
segment=cv.bitwise_and(frame,mask)
returnsegment
#cap=cv.VideoCapture("input.mp4")
#while(cap.isOpened()):
#ret,frame=cap.read()
#canny=do_canny(frame)
#First,visualizetheframetofigureoutthethreecoordinatesdefiningthetriangularmask
plt.imshow(frame)
plt.show()
segment=do_segment(canny)
#ifcv.waitKey(10)0xFF==ord('q'):
#break
#cap.release()
#cv.destroyAllWindows()
5. 霍夫变换
在笛卡尔坐标系中,我们可以通过绘制 y 对 x 的图像来表示 y=mx+b。但在霍夫空间中,我们也可以通过绘制 b 对 m 的图像将这条线表示为一个点。例如,直线方程 y=2x+1 在霍夫空间中可能是用 (2, 1) 表示的。
现在,我们要在笛卡尔坐标系中绘制一个点而不是一条线。可能会有许多条线经过这个点,每条线的参数 m 和 b 的值都不同。例如,经过 (2,12) 的线可能有 y=2x+8、y=3x+6、y=4x+4、y=5x+2 以及 y=6x 等。这些线在霍夫空间中表示为 (2, 8)、(3, 6)、(4, 4)、(5, 2) 和 (6, 0)。注意,这在霍夫空间中可能会产生一条 b 对 m 的线。
每当我们在笛卡尔坐标系中看到一系列点,并且知道这些点可以用线连接起来时,我们可以先按上述方法绘制出笛卡尔坐标系中的每一个点在霍夫空间中的线,然后在霍夫空间中找到交点,就可以找到那条线的方程。霍夫空间中的交点表示通过这一系列点的直线的 m 值和 b 值。
由于我们传入 Canny 检测器的帧可能会被简单地看成表示我们图像空间中边的一系列白点,因此我们可以用相同的技术来识别哪些点应该被连成同一条线,以及这些点连接之后的线的方程是什么,以便我们可以在帧中绘制出这条线。
为了便于解释,我们用笛卡尔坐标来对应霍夫空间。但这种方法存在一个数学上的缺陷:当这条线垂直时,梯度是无穷大的,无法在霍夫空间中表示出来。为了解决这个问题,我们用极坐标代替。过程还是大致相同的,只是我们不在霍夫空间中绘制 b 对 m 的图,我们要绘制的是 r 对 θ 的图。
例如,对极坐标系中的点 (8, 6)、(4, 9) 和 (12, 3),我们在霍夫空间中绘制出的相应图像如下:
我们可以看到,霍夫空间中的线相交于 θ=0.925,r=9.6 处。极坐标系中的线是由 y = xcosθ + ysinθ 定义的,因此我们可以将所有穿过这一点的一条线定义为 9.6 = xcos0.925 + ysin0.925。
一般而言,在霍夫空间中相交的曲线越多,意味着用交点表示的线对应的点越多。在实现中,我们在霍夫空间中定义了交点的最小阈值,以便检测线。因此,霍夫变换基本上跟踪了帧中的每个点的霍夫空间交点。如果交点数量超过了定义的阈值,我们就确定一条对应参数 θ 和 r 的线。
我们用霍夫变换来识别两条直线——车道的左右边界。
#importcv2ascv
#importnumpyasnp
##importmatplotlib.pyplotasplt
#defdo_canny(frame):
#gray=cv.cvtColor(frame,cv.COLOR_RGB2GRAY)
#blur=cv.GaussianBlur(gray,(5,5),0)
#canny=cv.Canny(blur,50,150)
#returncanny
#defdo_segment(frame):
#height=frame.shape[0]
#polygons=np.array([
#[(0,height),(800,height),(380,290)]
#])
#mask=np.zeros_like(frame)
#cv.fillPoly(mask,polygons,255)
#segment=cv.bitwise_and(frame,mask)
#returnsegment
#cap=cv.VideoCapture("input.mp4")
#while(cap.isOpened()):
#ret,frame=cap.read()
#canny=do_canny(frame)
##plt.imshow(frame)
##plt.show()
#segment=do_segment(canny)
#cv.HoughLinesP(frame,distanceresolutionofaccumulatorinpixels(larger=lessprecision),angleresolutionofaccumulatorinradians(larger=lessprecision),thresholdofminimumnumberofintersections,emptyplaceholderarray,minimumlengthoflineinpixels,maximumdistanceinpixelsbetweendisconnectedlines)
hough=cv.HoughLinesP(segment,2,np.pi/180,100,np.array([]),minLineLength=100,maxLineGap=50)
#ifcv.waitKey(10)0xFF==ord('q'):
#break
#cap.release()
#cv.destroyAllWindows()
6. 可视化
将车道可视化为两个浅绿色的、线性拟合的多项式,将它们叠加在我们的输入帧上。
#importcv2ascv
#importnumpyasnp
##importmatplotlib.pyplotasplt
#defdo_canny(frame):
#gray=cv.cvtColor(frame,cv.COLOR_RGB2GRAY)
#blur=cv.GaussianBlur(gray,(5,5),0)
#canny=cv.Canny(blur,50,150)
#returncanny
#defdo_segment(frame):
#height=frame.shape[0]
#polygons=np.array([
#[(0,height),(800,height),(380,290)]
#])
#mask=np.zeros_like(frame)
#cv.fillPoly(mask,polygons,255)
#segment=cv.bitwise_and(frame,mask)
#returnsegment
defcalculate_lines(frame,lines):
#Emptyarraystostorethecoordinatesoftheleftandrightlines
left=[]
right=[]
#Loopsthrougheverydetectedline
forlineinlines:
#Reshapeslinefrom2Darrayto1Darray
x1,y1,x2,y2=line.reshape(4)
#Fitsalinearpolynomialtothexandycoordinatesandreturnsavectorofcoefficientswhichdescribetheslopeandy-intercept
parameters=np.polyfit((x1,x2),(y1,y2),1)
slope=parameters[0]
y_intercept=parameters[1]
#Ifslopeisnegative,thelineistotheleftofthelane,andotherwise,thelineistotherightofthelane
ifslope0:
left.append((slope,y_intercept))
else:
right.append((slope,y_intercept))
#Averagesoutallthevaluesforleftandrightintoasingleslopeandy-interceptvalueforeachline
left_avg=np.average(left,axis=0)
right_avg=np.average(right,axis=0)
#Calculatesthex1,y1,x2,y2coordinatesfortheleftandrightlines
left_line=calculate_coordinates(frame,left_avg)
right_line=calculate_coordinates(frame,right_avg)
returnnp.array([left_line,right_line])
defcalculate_coordinates(frame,parameters):
slope,intercept=parameters
#Setsinitialy-coordinateasheightfromtopdown(bottomoftheframe)
y1=frame.shape[0]
#Setsfinaly-coordinateas150abovethebottomoftheframe
y2=int(y1-150)
#Setsinitialx-coordinateas(y1-b)/msincey1=mx1+b
x1=int((y1-intercept)/slope)
#Setsfinalx-coordinateas(y2-b)/msincey2=mx2+b
x2=int((y2-intercept)/slope)
returnnp.array([x1,y1,x2,y2])
defvisualize_lines(frame,lines):
#Createsanimagefilledwithzerointensitieswiththesamedimensionsastheframe
lines_visualize=np.zeros_like(frame)
#Checksifanylinesaredetected
iflinesisnotNone:
forx1,y1,x2,y2inlines:
#Drawslinesbetweentwocoordinateswithgreencolorand5thickness
cv.line(lines_visualize,(x1,y1),(x2,y2),(0,255,0),5)
returnlines_visualize
#cap=cv.VideoCapture("input.mp4")
#while(cap.isOpened()):
#ret,frame=cap.read()
#canny=do_canny(frame)
##plt.imshow(frame)
##plt.show()
#segment=do_segment(canny)
#hough=cv.HoughLinesP(segment,2,np.pi/180,100,np.array([]),minLineLength=100,maxLineGap=50)
#Averagesmultipledetectedlinesfromhoughintoonelineforleftborderoflaneandonelineforrightborderoflane
lines=calculate_lines(frame,hough)
#Visualizesthelines
lines_visualize=visualize_lines(frame,lines)
#Overlayslinesonframebytakingtheirweightedsumsandaddinganarbitraryscalarvalueof1asthegammaargument
output=cv.addWeighted(frame,0.9,lines_visualize,1,1)
#Opensanewwindowanddisplaystheoutputframe
cv.imshow("output",output)
#ifcv.waitKey(10)0xFF==ord('q'):
#break
#cap.release()
#cv.destroyAllWindows()
现在,打开终端并运行 python detector.py 来测试你的车道检测器。以防你遗失了任何代码,下面是带有注释的完整的解决方案:
importcv2ascv
importnumpyasnp
#importmatplotlib.pyplotasplt
defdo_canny(frame):
#Convertsframetograyscalebecauseweonlyneedtheluminancechannelfordetectingedges-lesscomputationallyexpensive
gray=cv.cvtColor(frame,cv.COLOR_RGB2GRAY)
#Appliesa5x5gaussianblurwithdeviationof0toframe-notmandatorysinceCannywilldothisforus
blur=cv.GaussianBlur(gray,(5,5),0)
#AppliesCannyedgedetectorwithminValof50andmaxValof150
canny=cv.Canny(blur,50,150)
returncanny
defdo_segment(frame):
#Sinceanimageisamulti-directionalarraycontainingtherelativeintensitiesofeachpixelintheimage,wecanuseframe.shapetoreturnatuple:[numberofrows,numberofcolumns,numberofchannels]ofthedimensionsoftheframe
#frame.shape[0]giveusthenumberofrowsofpixelstheframehas.Sinceheightbeginsfrom0atthetop,they-coordinateofthebottomoftheframeisitsheight
height=frame.shape[0]
#Createsatriangularpolygonforthemaskdefinedbythree(x,y)coordinates
polygons=np.array([
[(0,height),(800,height),(380,290)]
])
#Createsanimagefilledwithzerointensitieswiththesamedimensionsastheframe
mask=np.zeros_like(frame)
#Allowsthemasktobefilledwithvaluesof1andtheotherareastobefilledwithvaluesof0
cv.fillPoly(mask,polygons,255)
#Abitwiseandoperationbetweenthemaskandframekeepsonlythetriangularareaoftheframe
segment=cv.bitwise_and(frame,mask)
returnsegment
defcalculate_lines(frame,lines):
#Emptyarraystostorethecoordinatesoftheleftandrightlines
left=[]
right=[]
#Loopsthrougheverydetectedline
forlineinlines:
#Reshapeslinefrom2Darrayto1Darray
x1,y1,x2,y2=line.reshape(4)
#Fitsalinearpolynomialtothexandycoordinatesandreturnsavectorofcoefficientswhichdescribetheslopeandy-intercept
parameters=np.polyfit((x1,x2),(y1,y2),1)
slope=parameters[0]
y_intercept=parameters[1]
#Ifslopeisnegative,thelineistotheleftofthelane,andotherwise,thelineistotherightofthelane
ifslope0:
left.append((slope,y_intercept))
else:
right.append((slope,y_intercept))
#Averagesoutallthevaluesforleftandrightintoasingleslopeandy-interceptvalueforeachline
left_avg=np.average(left,axis=0)
right_avg=np.average(right,axis=0)
#Calculatesthex1,y1,x2,y2coordinatesfortheleftandrightlines
left_line=calculate_coordinates(frame,left_avg)
right_line=calculate_coordinates(frame,right_avg)
returnnp.array([left_line,right_line])
defcalculate_coordinates(frame,parameters):
slope,intercept=parameters
#Setsinitialy-coordinateasheightfromtopdown(bottomoftheframe)
y1=frame.shape[0]
#Setsfinaly-coordinateas150abovethebottomoftheframe
y2=int(y1-150)
#Setsinitialx-coordinateas(y1-b)/msincey1=mx1+b
x1=int((y1-intercept)/slope)
#Setsfinalx-coordinateas(y2-b)/msincey2=mx2+b
x2=int((y2-intercept)/slope)
returnnp.array([x1,y1,x2,y2])
defvisualize_lines(frame,lines):
#Createsanimagefilledwithzerointensitieswiththesamedimensionsastheframe
lines_visualize=np.zeros_like(frame)
#Checksifanylinesaredetected
iflinesisnotNone:
forx1,y1,x2,y2inlines:
#Drawslinesbetweentwocoordinateswithgreencolorand5thickness
cv.line(lines_visualize,(x1,y1),(x2,y2),(0,255,0),5)
returnlines_visualize
#ThevideofeedisreadinasaVideoCaptureobject
cap=cv.VideoCapture("input.mp4")
while(cap.isOpened()):
#ret=abooleanreturnvaluefromgettingtheframe,frame=thecurrentframebeingprojectedinthevideo
ret,frame=cap.read()
canny=do_canny(frame)
cv.imshow("canny",canny)
#plt.imshow(frame)
#plt.show()
segment=do_segment(canny)
hough=cv.HoughLinesP(segment,2,np.pi/180,100,np.array([]),minLineLength=100,maxLineGap=50)
#Averagesmultipledetectedlinesfromhoughintoonelineforleftborderoflaneandonelineforrightborderoflane
lines=calculate_lines(frame,hough)
#Visualizesthelines
lines_visualize=visualize_lines(frame,lines)
cv.imshow("hough",lines_visualize)
#Overlayslinesonframebytakingtheirweightedsumsandaddinganarbitraryscalarvalueof1asthegammaargument
output=cv.addWeighted(frame,0.9,lines_visualize,1,1)
#Opensanewwindowanddisplaystheoutputframe
cv.imshow("output",output)
#Framesarereadbyintervalsof10milliseconds.Theprogramsbreaksoutofthewhileloopwhentheuserpressesthe'q'key
ifcv.waitKey(10)0xFF==ord('q'):
break
#Thefollowingfreesupresourcesandclosesallwindows
cap.release()
cv.destroyAllWindows()
方法 2:空间 CNN
方法 1 中这种手动的传统方法对于清晰且笔直的道路来说效果还不错。但很明显当遇到弯道或急转弯时这种方法会失效。此外,我们注意到车道上有由直线组成的标记,比如绘制的箭头标志,可能会不时地扰乱车道检测器,这从演示视频中可以看得出来。解决这个问题的一种方法可能是将三角掩码进一步细化为两个独立的、更精确的掩码。但这些相当随意的掩码参数还是无法适应变化多端的道路环境。另一个缺点是车道检测器可能会忽略只有点状标记或者根本没有清晰标记的车道,因为缺乏满足霍夫变换阈值的连续直线。最后,天气和照明条件也会影响线路可见度。
1. 架构
尽管卷积神经网络(CNN)可以有效地识别较低层次图像的简单特征(例如边和颜色梯度)以及更高等级的复杂特征和实体(例如目标识别),但它们很难表示这些特征和实体的「姿势」——也就是说,CNN 可以从原始像素中提取语义,但无法捕获图像中像素的空间关系(例如旋转关系和平移关系)。但车道检测是具有强形状先验但弱外观相干性的任务,因此这些空间关系对车道检测任务来说至关重要。
例如,只通过提取语特征义是很难判断交通标志的,因为它们缺少清晰且连贯的外观线索,而且经常被遮挡。
左上角图片中右边的车和左下角图片中右边的摩托车遮挡了右边的车道标记,对 CNN 的结果产生了负面影响。
由于我们知道在交通道路上一般会出现类似于物体被垂直放在道路两边这样的空间关系,因此我们了解到加强空间信息的重要性。检测车道的情况也是类似的。
为了解决这个问题,空间 CNN(SCNN)给出了一个架构,这个架构可以「将经典的深度逐层卷积在特征映射下推广到逐片(slice-by-slice)卷积」。这是什么意思?在经典的层到层的 CNN 中,每一个卷积层都从前面的一层接收输入,应用卷积和非线性激活后,将输出传递给后面的层。SCNN 将各个特征映射行和列视为「层」,进一步应用这一步骤,按顺序进行相同的过程(这里的按顺序指的是只有当这一片从前面的一片中接收到信息才会将信息传递给后面一片),这个过程允许像素信息在同一层的不同神经元之间传递,可以有效增强空间信息。
SCNN 是相对较新的、2018 年早些时候才发布的方法,但已经超越了像 ReNet(RNN)、MRFNet(MRF+CNN)这样更深的 ResNet 架构,以 96.53% 的准确率赢得了 TuSimple 基准车道检测挑战赛的冠军。
此外,除了 SCNN,作者还发布了 CULane 数据集,这是一个包括交通车道注释的大型数据集。CULane 数据集还包括许多极具挑战性的场景,包括遮挡情况和光照条件不同的情况。
2. 模型
车道检测需要精确的像素识别和车道曲线预测。SCNN 的作者并未直接训练车道并进行聚类,而是先将蓝色、绿色、红色和黄色的车道分别标记为四类。模型针对每一条曲线输出了概率映射(probmaps),和语义分割任务相似,然后通过小型网络传递了 probmaps,并预测最终的 cubic spines。该模型基于 DeepLab-LargeFOV 模型的变体。
每条车道用超过 0.5 的存在值标记,以 20 行为间隔搜索相应的 probmap 以找到响应最高的位置。为了确定是否检测到了车道标记,计算真实数据(正确的标签)和预测值间的 IoU,将高于设定阈值的 IoU 评估为真正(TP)样本,用来计算精度和召回率。
3. 测试和训练
你可以按照这个库(https://github.com/XingangPan/SCNN)中的代码来重现 SCNN 论文中的结果,也可以用 CULane 数据集来测试你自己的模型。
原文链接:https://towardsdatascience.com/tutorial-build-a-lane-detector-679fd8953132
本文为机器之心编译,转载请联系原作者获得授权。
?------------------------------------------------
加入机器之心(全职记者 / 实习生):hr@jiqizhixin.com
投稿或寻求报道:content@jiqizhixin.com
广告 & 商务合作:bd@jiqizhixin.com
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线