基于Python和OpenCV的轮廓优先级排序

4

我正在尝试按照到达顺序对轮廓进行排序,就像你写任何东西一样,从上到下、从左到右。先从上面,再从左侧开始,然后按照相应的顺序进行排序。

这是我迄今为止所实现的内容和方式:

def get_contour_precedence(contour, cols):
    tolerance_factor = 61
    origin = cv2.boundingRect(contour)
    return ((origin[1] // tolerance_factor) * tolerance_factor) * cols + origin[0]


image = cv2.imread("C:/Users/XXXX/PycharmProjects/OCR/raw_dataset/23.png", 0)

ret, thresh1 = cv2.threshold(image, 130, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

contours, h = cv2.findContours(thresh1.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# perform edge detection, find contours in the edge map, and sort the
# resulting contours from left-to-right
contours.sort(key=lambda x: get_contour_precedence(x, thresh1.shape[1]))

# initialize the list of contour bounding boxes and associated
# characters that we'll be OCR'ing
chars = []
inc = 0
# loop over the contours
for c in contours:
    inc += 1

    # compute the bounding box of the contour
    (x, y, w, h) = cv2.boundingRect(c)

    label = str(inc)
    cv2.rectangle(image, (x, y), (x + w, y + h), (0, 255, 0), 2)
    cv2.putText(image, label, (x - 2, y - 2),
                cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
    print('x=', x)
    print('y=', y)
    print('x+w=', x + w)
    print('y+h=', y + h)
    crop_img = image[y + 2:y + h - 1, x + 2:x + w - 1]
    name = os.path.join("bounding boxes", 'Image_%d.png' % (
        inc))
    cv2.imshow("cropped", crop_img)
    print(name)
    crop_img = Image.fromarray(crop_img)
    crop_img.save(name)
    cv2.waitKey(0)

cv2.imshow('mat', image)
cv2.waitKey(0)

输入图像:

输入图像

输出图像1:

输出图像1(正在处理)

输入图像2:

输入图像2

输出图像2:

输出图像2

输入图像3:

输入图像3

输出图像3:

输出图像3

您可以看到,数字 "1,2,3,4" 不是我在每个图像中预期的内容,如图像编号3中所示。

我该如何调整它以使其正常工作,或编写自定义函数?

注意:我在问题中提供了同一输入图像的多个图像。内容相同但文本有变化,因此 "容差因子" 对于其中每一个都不起作用。手动调整也不是一个好主意。


3
首先需要将文本行分开。这应该很容易,因为文本行之间都有黑色的行。然后针对每一行,你可以轻松地从左到右排序。 - Miki
@Miki,在文本行未能正确排序的情况下,我该如何对其进行排序? - Jimit Vaghela
1
请查看以下链接的答案:https://dev59.com/YVsW5IYBdhLWcg3wbm0O#48268334 - Miki
@Miki 如果出现 .-,它还能正常工作吗? - Jimit Vaghela
你对回复有相当高的期望。如果你:1)编辑问题以使代码更易读;2)展示原始输入图像;3)展示期望输出的具体示例,那么可能会引起更多兴趣。3)你的所有图像都是水平文本吗?还是有些是倾斜的? - user1269942
@user1269942 我已经更新了问题,是的,所有的图片都会有横向文本。问题在于每张图片中文本略有不同的变化。 - Jimit Vaghela
4个回答

4
这是我对问题的看法。我会给你一个大致的概述,然后展示我的C++实现。主要想法是我想从左到右、从上到下处理图像。我将在找到每个blob(或轮廓)时进行处理,但是为了实现成功的(有序的)分割,我需要一些中间步骤。
第一步是尝试按行对blobs进行排序-这意味着每行都有一组(无序的)水平blobs。这没关系。第一步是计算某种垂直排序,如果我们从上到下处理每行,我们将实现这一点。
当blobs通过行(垂直)排序后,我就可以检查它们的质心(或重心)并对它们进行水平排序。想法是我将逐行处理,并为每行对blob质心进行排序。让我们看一下我想在这里实现什么样的例子。
这是您的输入图像:
这是我称之为“行掩码”的内容:
最后一个图像包含代表每个“行”的白色区域。每一行都有一个编号(例如Row1,Row2等),每一行都包含一组blobs(或字符,在这种情况下)。通过从上到下处理每一行,您已经在垂直轴上对blobs进行了排序。
如果我从上到下给每行编号,我会得到这张图片:
行掩码是创建“blob行”的一种方法,可以通过形态学计算获得。查看叠加在一起的2个图像以便更好地查看处理顺序:
我们要做的第一件事是垂直排序(蓝箭头),然后我们将解决水平排序(红箭头)。您可以看到通过逐行处理,我们可能会克服排序问题!
使用质心进行水平排序。
现在让我们看看如何水平排序这些斑点。如果我们创建一个简化图片,宽度等于输入图片的宽度,高度等于行掩模中的行数,我们可以简单地重叠每个斑点质心的每个水平坐标(x坐标)。看看这个例子: 这是一个行表。 每行代表在行掩模中找到的行数,并且从上到下进行阅读。表的宽度与输入图像的宽度相同,在空间上对应水平轴。 每个方格都是输入图像中的像素,仅使用水平坐标将其映射到行表中(因为我们对行的简化非常直观)。 行表中每个像素的实际值是一个标签,用于标记输入图像上的每个斑点。请注意,标签没有顺序!
例如,此表显示,在第1行(您已经知道第1行是什么 - 它是行掩模上的第一片白色区域)的位置(1,4)处有斑点号码3。 在位置(1,6)处有斑点号码2,依此类推。我认为这个表很棒的一点是您可以遍历它,并且对于每个不同于0的值,水平排序变得非常简单。这是左到右的行表排序: 将斑点信息映射到质心 我们将使用斑点的质心映射在两个表示(行掩模/行表)之间的信息。假设您已经有了两个“辅助”图像,并且一次处理输入图像上的每个斑点(或轮廓)。例如,您开始时有以下内容: 好吧,这里有一个斑点。我们如何将其映射到行掩模行表中?使用其质心。如果我们计算质心(在图中显示为绿点),则可以构建一个质心和标签的字典。例如,对于此斑点,质心位于(271,193)。好的,让我们分配标签= 1。因此,我们现在有了这个字典:
现在,我们使用行掩模上相同的质心来查找此 Blob 所在的行。类似于这样:
rowNumber = rowMask.at( 271,193 )

这个操作应该返回 rownNumber = 3。很好!我们知道了我们的 blob 在哪一行,因此它现在是垂直排列的。现在,让我们将它的水平坐标存储在行表中:

rowTable.at( 271, 193 ) = 1

现在,rowTable(在其行和列中)保存了已处理斑点的标签。行表应该是这样的: 这个表要更宽一些,因为它的水平维度必须与输入图像相同。在这张图片中,label 1 被放在 Column 271,Row 3 。如果这是您图像上唯一的斑点,则斑点已经排好序了。但是,如果您在例如Column 2Row 1 中添加另一个斑点,会发生什么?那就是为什么在处理所有斑点之后需要重新遍历此表,以正确地更正它们的标签。
C++实现:
好了,算法应该有点清晰了(如果不清楚,请问我)。我将尝试使用C++和OpenCV实现这些想法。首先,我需要您输入的二进制图像,计算使用Otsu阈值法很容易:
//Read the input image:
std::string imageName = "C://opencvImages//yFX3M.png";
cv::Mat testImage = cv::imread( imageName );

//Compute grayscale image
cv::Mat grayImage;
cv::cvtColor( testImage, grayImage, cv::COLOR_RGB2GRAY );

//Get binary image via Otsu:
cv::Mat binImage;
cv::threshold( grayImage, binImage, 0, 255, cv::THRESH_OTSU );

//Invert image:
binImage = 255 - binImage;

这是生成的二进制图像,没有花哨的东西,只是我们开始工作所需的基本内容: 第一步是获取“行掩码”。可以使用形态学实现。只需将具有非常大水平“结构元素”的膨胀+腐蚀应用于图像。想法是将那些斑块水平地“熔合”成矩形:
//Create a hard copy of the binary mask:
cv::Mat rowMask = binImage.clone();

//horizontal dilation + erosion:
int horizontalSize = 100; // a very big horizontal structuring element
cv::Mat SE = cv::getStructuringElement( cv::MORPH_RECT, cv::Size(horizontalSize,1) );
cv::morphologyEx( rowMask, rowMask, cv::MORPH_DILATE, SE, cv::Point(-1,-1), 2 );
cv::morphologyEx( rowMask, rowMask, cv::MORPH_ERODE, SE, cv::Point(-1,-1), 1 );

这将导致以下行掩码

非常酷,现在我们有了行掩码,我们必须给它们编上号,对吧?有很多方法可以做到这一点,但现在我只对简单的方法感兴趣:遍历此图像并获取每个像素。如果一个像素是白色的,则使用泛洪填充(Flood Fill)操作将该部分图像标记为唯一的块(或行,在本例中)。这可以按以下方式完成:

//Label the row mask:
int rowCount = 0; //This will count our rows

//Loop thru the mask:
for( int y = 0; y < rowMask.rows; y++ ){
    for( int x = 0; x < rowMask.cols; x++ ){
        //Get the current pixel:
        uchar currentPixel = rowMask.at<uchar>( y, x );
        //If the pixel is white, this is an unlabeled blob:
        if ( currentPixel == 255 ) {
            //Create new label (different from zero):
            rowCount++;
            //Flood fill on this point:
            cv::floodFill( rowMask, cv::Point( x, y ), rowCount, (cv::Rect*)0, cv::Scalar(), 0 );
        }
    }
}

这个过程将会把所有行从1r标记上标签。这正是我们想要的结果。如果你查看图片,你会模糊地看到这些行,那是因为我们的标签对应灰度像素非常低的值。
好的,现在让我们准备行表格。这个“表格”实际上只是另一张图片,记住:它的宽度与输入图像相同,高度等于在行掩膜上计算的行数:
//create rows image:
cv::Mat rowTable = cv::Mat::zeros( cv::Size(binImage.cols, rowCount), CV_8UC1 );
//Just for convenience:
rowTable = 255 - rowTable;

这里,我只是为了方便翻转了最终图像。因为我想实际看到表格如何被填充(非常低强度的)像素,并确保一切按预期工作。

现在来到有趣的部分。我们已经准备好了两张图片(或数据容器)。我们需要单独处理每个 blob。思路是从二进制图像中提取每个 blob/ 轮廓/ 字符,计算其质心并指定一个新的标签。再次说明,有很多方法可以做到这一点。在这里,我使用以下方法:

我将循环遍历二进制掩模。我将从此二进制输入中获取当前最大的blob。我将计算其质心,并将其数据存储在所需的每个容器中,然后,我将从掩模中删除该 blob。我将重复该过程,直到没有更多的 blobs 为止。这是我的做法,特别是因为我已经为此编写了函数。这是我的方法:

//Prepare a couple of dictionaries for data storing:
std::map< int, cv::Point > blobMap; //holds label, gives centroid
std::map< int, cv::Rect > boundingBoxMap; //holds label, gives bounding box

首先,有两个字典。一个接收一个blob标签并返回质心。另一个接收相同的标签并返回边界框。

//Extract each individual blob:
cv::Mat bobFilterInput = binImage.clone();

//The new blob label:
int blobLabel = 0;

//Some control variables:
bool extractBlobs = true; //Controls loop
int currentBlob = 0; //Counter of blobs

while ( extractBlobs ){

    //Get the biggest blob:
    cv::Mat biggestBlob = findBiggestBlob( bobFilterInput );

    //Compute the centroid/center of mass:
    cv::Moments momentStructure = cv::moments( biggestBlob, true );
    float cx = momentStructure.m10 / momentStructure.m00;
    float cy = momentStructure.m01 / momentStructure.m00;

    //Centroid point:
    cv::Point blobCentroid;
    blobCentroid.x = cx;
    blobCentroid.y = cy;

    //Compute bounding box:
    boundingBox boxData;
    computeBoundingBox( biggestBlob, boxData );

    //Convert boundingBox data into opencv rect data:
    cv::Rect cropBox = boundingBox2Rect( boxData );


    //Label blob:
    blobLabel++;
    blobMap.emplace( blobLabel, blobCentroid );
    boundingBoxMap.emplace( blobLabel, cropBox );

    //Get the row for this centroid
    int blobRow = rowMask.at<uchar>( cy, cx );
    blobRow--;

    //Place centroid on rowed image:
    rowTable.at<uchar>( blobRow, cx ) = blobLabel;

    //Resume blob flow control:
    cv::Mat blobDifference = bobFilterInput - biggestBlob;
    //How many pixels are left on the new mask?
    int pixelsLeft = cv::countNonZero( blobDifference );
    bobFilterInput = blobDifference;

    //Done extracting blobs?
    if ( pixelsLeft <= 0 ){
        extractBlobs = false;
    }

    //Increment blob counter:
    currentBlob++;

}

观看下面这个漂亮的动画,展示了如何处理每个blob,对其进行处理并删除,直到一个都不剩:

现在,关于上面的代码片段,我有一些辅助函数:biggestBlobcomputeBoundingBox。这些函数分别计算二进制图像中最大的blob,以及将自定义的bounding box结构体转换为OpenCVRect结构体。这些是这些函数所执行的操作。

这个代码片段的核心是:一旦你有了一个被隔离的blob,就要计算它的质心(实际上我通过central moments计算重心)。生成一个新的label。将这个labelcentroid存储在一个dictionary中,在我的情况下,是blobMap字典。此外,计算bounding box并将其存储在另一个dictionary中,即boundingBoxMap

//Label blob:
blobLabel++;
blobMap.emplace( blobLabel, blobCentroid );
boundingBoxMap.emplace( blobLabel, cropBox );

现在,使用centroid数据,fetch该blob的对应row。一旦你获得了这一行,将此数字存储到你的行表中:

//Get the row for this centroid
int blobRow = rowMask.at<uchar>( cy, cx );
blobRow--;

//Place centroid on rowed image:
rowTable.at<uchar>( blobRow, cx ) = blobLabel;

非常好。此时您已经准备好了行表格。让我们循环遍历它并最终对这些可恶的 blob 进行排序:

int blobCounter = 1; //The ORDERED label, starting at 1
for( int y = 0; y < rowTable.rows; y++ ){
    for( int x = 0; x < rowTable.cols; x++ ){
        //Get current label:
        uchar currentLabel = rowTable.at<uchar>( y, x );
        //Is it a valid label?
        if ( currentLabel != 255 ){
            //Get the bounding box for this label:
            cv::Rect currentBoundingBox = boundingBoxMap[ currentLabel ];
            cv::rectangle( testImage, currentBoundingBox, cv::Scalar(0,255,0), 2, 8, 0 );
            //The blob counter to string:
            std::string counterString = std::to_string( blobCounter );
            cv::putText( testImage, counterString, cv::Point( currentBoundingBox.x, currentBoundingBox.y-1 ),
                         cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255,0,0), 1, cv::LINE_8, false );
            blobCounter++; //Increment the blob/label
        }
    }
}

没有花哨的东西,只是一个普通的嵌套 for 循环,遍历每个在 行表 上的像素。如果像素不同于白色,则使用 标签 检索出 质心边界框,并将 标签 更改为递增的数字。为了显示结果,我只需在原始图像上绘制边界框和新标签。

查看此动画中的有序处理:

非常酷,这里还有一个奖励动画,行表用水平坐标填充:


这是一个很好的例子,但当字符过于接近时,比如22-2020,那么就无法正确识别。我可以理解为人类错误,不应作为OCR评估的依据。感谢您给出了详细的答案。我将在我的Python实现中使用您的代码,并会告诉您结果。 - Jimit Vaghela
rowMask.rows 就是矩阵 rowMaskrowscols 也是同理。如果你在将它移植到 Python 中遇到任何问题或有其他问题,请告诉我! - stateMachine
@Jimit Vaghela,此外,我并没有看到22-2020这行代码有什么问题...您能否详细说明一下?也许我可以找到另一种解决方案! - stateMachine
是的,我正在尝试将您的同一代码的for循环移植到Python中,但遇到了困难。如果您能提供帮助,将会非常有帮助。 :) - Jimit Vaghela
1
@JimitVaghela 啊!对于第一部分,即“重叠”斑点的部分,二进制掩模可能会将斑点“融合”在一起,从而给您一个大斑点和一个质心,而不是两个斑点和两个质心。轻微的腐蚀可以帮助解决这个问题。对于另一部分(for循环),请随时加入此聊天室,以便我们进一步讨论转换。 - stateMachine
显示剩余2条评论

3

我甚至建议使用色调矩来估计多边形的中心点,这比"普通"的矩形坐标中心点更为准确。因此,函数可表示为:

def get_contour_precedence(contour, cols):
     tolerance_factor = 61
     M = cv2.moments(contour)
     # calculate x,y coordinate of centroid
     if M["m00"] != 0:
             cX = int(M["m10"] / M["m00"])
             cY = int(M["m01"] / M["m00"])
     else:
     # set values as what you need in the situation
             cX, cY = 0, 0
     return ((cY // tolerance_factor) * tolerance_factor) * cols + cX

关于Hue Moments是什么的超级数学解释,您可以在这里找到。

也许您应该考虑摆脱tolerance_factor,使用聚类算法(如kmeans)对中心进行行和列的聚类。OpenCV有一个kmeans实现,您可以在这里找到。

我不确定您的目标是什么,但另一个想法是将每一行拆分为感兴趣区域(ROI),以便进行进一步处理,之后您可以通过每个轮廓的X-值和行号轻松计算字母数量。

import cv2
import numpy as np

## (1) read
img = cv2.imread("yFX3M.png")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

## (2) threshold
th, threshed = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV|cv2.THRESH_OTSU)

## (3) minAreaRect on the nozeros
pts = cv2.findNonZero(threshed)
ret = cv2.minAreaRect(pts)

(cx,cy), (w,h), ang = ret
if w>h:
    w,h = h,w

## (4) Find rotated matrix, do rotation
M = cv2.getRotationMatrix2D((cx,cy), ang, 1.0)
rotated = cv2.warpAffine(threshed, M, (img.shape[1], img.shape[0]))

## (5) find and draw the upper and lower boundary of each lines
hist = cv2.reduce(rotated,1, cv2.REDUCE_AVG).reshape(-1)

th = 2
H,W = img.shape[:2]
#   (6) using histogramm with threshold
uppers = [y for y in range(H-1) if hist[y]<=th and hist[y+1]>th]
lowers = [y for y in range(H-1) if hist[y]>th and hist[y+1]<=th]

rotated = cv2.cvtColor(rotated, cv2.COLOR_GRAY2BGR)
for y in uppers:
    cv2.line(rotated, (0,y), (W, y), (255,0,0), 1)

for y in lowers:
    cv2.line(rotated, (0,y), (W, y), (0,255,0), 1)
cv2.imshow('pic', rotated)

# (7) we iterate all rois and count 
for i in range(len(uppers)) : 
    print('line=',i)
    roi = rotated[uppers[i]:lowers[i],0:W]
    cv2.imshow('line', roi)
    cv2.waitKey(0)
    # here again calc thres and contours

我在这里找到了一篇旧帖子,其中包含了这段代码


最终出现了这个错误:Traceback (most recent call last): File "C:/XXX/eva_module/dataset_gen.py", line 29, in <module> contours.sort(key=lambda x: get_contour_precedence(x, thresh1.shape[1])) File "C:/Users/XXX/PycharmProjects/OCR/eva_module/dataset_gen.py", line 29, in <lambda> contours.sort(key=lambda x: get_contour_precedence(x, thresh1.shape[1])) File "C:/Users/XXX/PycharmProjects/OCR/eva_module/dataset_gen.py", line 16, in get_contour_precedence cX = int(M["m10"] / M["m00"]) ZeroDivisionError: float division by zero - Jimit Vaghela
忘记了这种可能性并修复了问题,希望能解决。不确定什么时候 moments[m00] 实际上为零,这意味着形状的面积为零,可能应该被排除掉? - t2solve

2

与其使用轮廓的左上角,我更愿意使用质心或至少是边界框中心。

def get_contour_precedence(contour, cols):
tolerance_factor = 4
origin = cv2.boundingRect(contour)
return (((origin[1] + origin[3])/2 // tolerance_factor) * tolerance_factor) * cols + (origin[0] + origin[2]) / 2

但是在所有情况下都能找到适用的公差值可能很困难。


有没有办法可以克服这个问题?即使文本略有变化,使用相同的容差值也会导致错误。调整二值化图像的大小是否会更好? - Jimit Vaghela
那么看起来这是一个聚类问题,你应该查看这个线程:https://dev59.com/f47ea4cB1Zd3GeqPGdfu - antoine

1

以下是使用Python/OpenCV进行行字符处理的一种方法。

  • 读取输入
  • 转换为灰度图像
  • 阈值处理并反转
  • 使用长水平核对形成行应用形态学操作
  • 获取行的轮廓和边界框
  • 保存行框并按Y排序
  • 循环遍历每个排序后的行框,并从二值化图像中提取行
  • 获取行中每个字符的轮廓,并保存字符的边界框。
  • 按X对给定行的轮廓进行排序
  • 在输入上绘制边界框和图像上的索引号文本
  • 增加索引
  • 保存结果

输入:

enter image description here

import cv2
import numpy as np

# read input image
img = cv2.imread('vision78.png')

# convert img to grayscale
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# otsu threshold
thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_OTSU )[1]
thresh = 255 - thresh 

# apply morphology close to form rows
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (51,1))
morph = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)

# find contours and bounding boxes of rows
rows_img = img.copy()
boxes_img = img.copy()
rowboxes = []
rowcontours = cv2.findContours(morph, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
rowcontours = rowcontours[0] if len(rowcontours) == 2 else rowcontours[1]
index = 1
for rowcntr in rowcontours:
    xr,yr,wr,hr = cv2.boundingRect(rowcntr)
    cv2.rectangle(rows_img, (xr, yr), (xr+wr, yr+hr), (0, 0, 255), 1)
    rowboxes.append((xr,yr,wr,hr))

# sort rowboxes on y coordinate
def takeSecond(elem):
    return elem[1]
rowboxes.sort(key=takeSecond)
    
# loop over each row    
for rowbox in rowboxes:
    # crop the image for a given row
    xr = rowbox[0]
    yr = rowbox[1]
    wr = rowbox[2]
    hr = rowbox[3]  
    row = thresh[yr:yr+hr, xr:xr+wr]
    bboxes = []
    # find contours of each character in the row
    contours = cv2.findContours(row, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    contours = contours[0] if len(contours) == 2 else contours[1]
    for cntr in contours:
        x,y,w,h = cv2.boundingRect(cntr)
        bboxes.append((x+xr,y+yr,w,h))
    # sort bboxes on x coordinate
    def takeFirst(elem):
        return elem[0]
    bboxes.sort(key=takeFirst)
    # draw sorted boxes
    for box in bboxes:
        xb = box[0]
        yb = box[1]
        wb = box[2]
        hb = box[3]
        cv2.rectangle(boxes_img, (xb, yb), (xb+wb, yb+hb), (0, 0, 255), 1)
        cv2.putText(boxes_img, str(index), (xb,yb), cv2.FONT_HERSHEY_COMPLEX_SMALL, 0.75, (0,255,0), 1)
        index = index + 1
    
# save result
cv2.imwrite("vision78_thresh.jpg", thresh)
cv2.imwrite("vision78_morph.jpg", morph)
cv2.imwrite("vision78_rows.jpg", rows_img)
cv2.imwrite("vision78_boxes.jpg", boxes_img)

# show images
cv2.imshow("thresh", thresh)
cv2.imshow("morph", morph)
cv2.imshow("rows_img", rows_img)
cv2.imshow("boxes_img", boxes_img)
cv2.waitKey(0)

阈值图像:

enter image description here

行的形态学图像:

enter image description here

行轮廓图像:

enter image description here

字符轮廓图像:

enter image description here


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接