简介:基于Python的答题卡处理源码包,实现了答题卡检测、试题切分、学生考号识别与选择题自动批改的完整流程,面向计算机、电子信息等专业学生,适合作为课程设计、期末大作业或毕业设计的参考,也适合入门者通过源码实践理解图像处理与识别算法。资源共35个文件,包含8个Python脚本、2个Jupyter Notebook演示文档、多张jpg/png样例图片以及Dockerfile、requirements.txt等环境配置,压缩包仅6.04MB,轻量易部署。代码按功能模块划分,涵盖检测、切分、考号识别和批改等环节,并附有测试脚本与样例图片,便于对照调试;Dockerfile可快速搭建运行环境,降低复现门槛。已有237人学习下载,适合需要完整项目方案或动手实战的开发者参考。

1. 答题卡检测与自动批改:一套让毕设答辩现场直接演示的 Python 方案

每年毕业季都有同学抱着「答题卡识别」这个题目找现成方案。商用 OMR 软件一小时就能跑通,但核心逻辑全封装在黑匣子里,答辩现场解释不了内部原理。基于 Python 的答题卡检测、试题切分、学生考号识别和选择题自动批改系统,正好是典型的机器视觉落地项目:拍一张照片进来,经过预处理、轮廓提取、透视矫正、网格切分、涂卡判断,最后算出分数,把 OpenCV 的核心知识点串成一条完整链路,也适合直接拿去做毕设演示。

这套方案解决的具体问题是:批量读取答题卡照片,自动定位答题区域、还原考号、逐题判断涂卡状态并生成得分。适合的人群也很明确——正在做毕设的学生、想从「看了教程还是不会写」进入实际项目的开发者,以及要给自己系统里加一个自动批改模块的工程人员。下面按每个模块拆开讲,参数和坑都会带到。

2. 答题卡定位与透视矫正:从 Canny 边缘提取到单应性矩阵的完整链路

2.1 预处理链路:为什么按灰度 → 高斯模糊 → Canny → 膨胀的顺序走

手持拍摄或扫描仪出来的答题卡照片,背景里经常有桌面纹理、手指、阴影,直接丢给轮廓查找会把背景里的杂物一起当成候选。处理这类卡片的常规链路是:先把彩色图转灰度、高斯模糊去噪、Canny 提边缘,再膨胀让边缘断口闭合。如果还没装 OpenCV,先 pip install opencv-python numpy ,后面所有代码都依赖这两个库。

import cv2
import numpy as np

def load_resized(image_path, max_width=1200):
    """读图并把宽度压到 1200 以内,统一后续参数基准"""
    img = cv2.imread(image_path)
    if img is None:
        raise ValueError(f"读不到图片: {image_path}")
    h, w = img.shape[:2]
    if w > max_width:
        scale = max_width / w
        img = cv2.resize(img, (max_width, int(h * scale)))
    return img

def preprocess_to_edges(img):
    # 彩色图转灰度,减少光照和颜色噪声
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    edges = cv2.Canny(blurred, 50, 150)
    kernel = np.ones((5, 5), np.uint8)
    dilated = cv2.dilate(edges, kernel, iterations=2)
    return dilated

这里有两个参数值得留意。第一个是 max_width=1200 :分辨率越大, cv2.findContours 耗时越长,而答题卡这种结构化对象 1200 宽度足够稳定识别。第二个是 Canny 的低阈值 50、高阈值 150:如果卡面打印颜色偏浅、边缘对比度低,把低阈值降到 30 左右,太高会滤掉答题卡的外框细线。膨胀的 iterations=2 是经验值,一次不够时边框缺口闭合不上,三次以上会让边缘线条粗到连成一片。

预处理这一步看起来简单,但后面所有顶点坐标、切分比例都建立在“边缘图干净”这个前提上。如果预处理结束后的边缘图上仍然能看到背景里的物体边缘,不要急着往下走,先调 Canny 阈值或者检查拍照角度。

2.2 四边形轮廓提取与顶点排序:面积筛选与四点排序细节

拿到干净的边缘图后,用 cv2.findContours 找外轮廓。答题卡一般是画面里面积最大的四边形,所以候选按面积降序排,找到的第一个四边轮廓就当作卡面。关键点是只用 RETR_EXTERNAL ,如果用了 RETR_LIST 或 RETR_TREE ,会把答题卡内部题号框、涂卡圆框也当成候选,严重影响排序。

def find_card_contour(dilated):
    # 只取最外层轮廓,内部题号框和气泡圆不参与竞争
    contours, _ = cv2.findContours(
        dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )
    contours = sorted(contours, key=cv2.contourArea, reverse=True)

    for c in contours:
        peri = cv2.arcLength(c, True)
        approx = cv2.approxPolyDP(c, 0.02 * peri, True)
        area = cv2.contourArea(approx)
        if len(approx) == 4 and area > 50000:
            return approx
    return None

approxPolyDP 的 epsilon 取 0.02 * peri ,意思是对周长做 2% 的逼近误差。这个值太小会把边缘的锯齿全留下来,太大则把圆角答题卡压成三角形。面积下限 50000 是用像素过滤小杂物,如果图片缩到 800 宽,下限要跟着调到 30000 左右。

轮廓找到之后,接下来是一个很隐蔽的坑: findContours 返回的四个顶点顺序并不固定,可能是逆时针、顺时针,也可能从任意点开始。如果不做排序,后面透视变换的顶点对应关系会乱,矫正出来的图可能是旋转 90 度甚至镜像的。我用的是经典的 sum/diff 排序法——和最小的是左上角,和最大的是右下角,差最小的是右上角,差最大的是左下角。

def order_points(pts):
    rect = np.zeros((4, 2), dtype="float32")
    s = pts.sum(axis=1)         # x + y
    diff = np.diff(pts, axis=1)  # 第二列减第一列,即 y - x
    rect[0] = pts[np.argmin(s)]    # 左上:和最小
    rect[2] = pts[np.argmax(s)]    # 右下:和最大
    rect[1] = pts[np.argmin(diff)] # 右上:y - x 最小
    rect[3] = pts[np.argmax(diff)] # 左下:y - x 最大
    return rect

需要强调: np.diff 得到的是 y - x ,不是 x - y 。左上角和右下角的判断用 sum 没问题,但右上角与左下角如果搞反,矫正出来的图会上下翻转,而程序没有任何报错,这种错误最难发现。我见过好几个同学的毕设就栽在这一行上。

2.3 透视矫正:把四边形投影成正视图的数学落点

得到排好序的四点后,把原图的这个四边形区域投影到一个正矩形上。这里特别要说明输出尺寸的计算:不要用固定宽高,而是用原始四边形的边长去算目标矩形,否则答题卡内容会被拉伸变形,切分坐标就全废了。

def perspective_transform(img, pts):
    rect = order_points(pts.reshape(4, 2))
    (tl, tr, br, bl) = rect

    # 上边与下边取较长者作为输出宽,左右边取较长者作为输出高
    width_a = np.linalg.norm(br - bl)
    width_b = np.linalg.norm(tr - tl)
    height_a = np.linalg.norm(tr - br)
    height_b = np.linalg.norm(tl - bl)
    max_width = max(int(width_a), int(width_b))
    max_height = max(int(height_a), int(height_b))

    dst = np.array([
        [0, 0],
        [max_width - 1, 0],
        [max_width - 1, max_height - 1],
        [0, max_height - 1]
    ], dtype="float32")

    matrix = cv2.getPerspectiveTransform(rect, dst)
    warped = cv2.warpPerspective(img, matrix, (max_width, max_height))
    return warped

getPerspectiveTransform 只需要四个点就能算单应性矩阵,但它对顶点精度很敏感,任何一个顶点偏了 2 像素,矫正结果就可能是斜的。所以前面 approxPolyDP 的 epsilon 别贪大。 warpPerspective 默认用双线性插值,答题卡这种黑白高对比图像够用了,不需要开 INTER_CUBIC ,后者只是更慢,对圆框边缘的改善微乎其微。

矫正完的 warped 就是我们后面所有处理的基准图。到这一步,我一般先 cv2.imwrite 存下来肉眼检查一遍,确认外框是矩形、文字方向正常,再进入考号识别和试题切分。这一步检查能省掉后续很多“为什么全错”的排查时间。

3. 学生考号识别与试题切分:把答题卡网格化成可统计的数据结构

3.1 学生考号识别:涂卡式气泡阵列的逐位判定

考号识别有两条路线:一种是直接用 OCR 识别打印好的数字,另一种是识别学生涂的黑点气泡矩阵。新版源码里走的是后者,因为气泡矩阵的识别逻辑和选择题涂卡判断完全复用,代码结构上也更统一。

先约定配置。考号区域在矫正图中的位置,我一般用相对坐标而不是绝对坐标,这样换分辨率的图也不用改配置:

config = {
    'id_x1': 0.08, 'id_y1': 0.06,   # 考号区左上角相对矫正图宽高
    'id_x2': 0.40, 'id_y2': 0.22,   # 考号区右下角相对矫正图宽高
    'id_rows': 10, 'id_cols': 10,   # 每位考号 0-9,共 10 位
    'grid_x1': 0.05, 'grid_y1': 0.30,  # 选择题区左上角
    'grid_w': 0.90, 'grid_h': 0.65,    # 选择题区宽高
    'question_count': 20,               # 选择题个数
    'option_count': 4,                  # 每道题选项数
}
参数 含义 常见取值
id_x1 / id_y1 考号区左上角在矫正图中的相对位置 0.08 / 0.06
id_x2 / id_y2 考号区右下角相对位置 0.40 / 0.22
id_rows 每位考号的可选数字个数 10(数字 0-9)
id_cols 考号位数 10
grid_w / grid_h 选择题区域宽度和高度占比 0.90 / 0.65

考号识别函数如下:把考号区域二值化,然后按 10 行 × 10 列切成单个气泡格,统计每格里前景像素占比,占比最大且超过阈值的行号就是这一位的数字。

def extract_student_id(warped_gray, config):
    h, w = warped_gray.shape[:2]
    x1, y1 = int(w * config['id_x1']), int(h * config['id_y1'])
    x2, y2 = int(w * config['id_x2']), int(h * config['id_y2'])
    region = warped_gray[y1:y2, x1:x2]

    # THRESH_BINARY_INV:涂黑的格子变成白色前景,方便 countNonZero
    _, thresh = cv2.threshold(
        region, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
    )

    rows, cols = config['id_rows'], config['id_cols']
    cell_h, cell_w = thresh.shape[0] // rows, thresh.shape[1] // cols
    student_id = ""
    for col in range(cols):
        max_ratio, best_row = 0.0, 0
        for row in range(rows):
            cell = thresh[row * cell_h:(row + 1) * cell_h,
                          col * cell_w:(col + 1) * cell_w]
            ratio = cv2.countNonZero(cell) / (cell_h * cell_w)
            if ratio > max_ratio:
                max_ratio, best_row = ratio, row
        # 整列都没有明显涂卡时按 0 处理,避免乱识别
        student_id += str(best_row if max_ratio > 0.12 else 0)
    return student_id

这里 0.12 是涂卡判定下限。如果学生用 2B 铅笔正常涂,一个格子的黑色覆盖一般会到 25% 以上;浅涂或扫描偏淡时可能掉到 15%。取 0.12 是给浅涂留余量。注意这里用的是绝对阈值,实际批次换打印纸或换光源后会漂移,这个问题的根治方案放在最后一章说。

3.2 试题切分:按行高列宽把选择题区域拆成单元格

选择题切分最稳的方式是基于配置直接切网格,而不是用轮廓去找每个选项格。原因很简单:涂卡后格子已经被大面积黑色占满,轮廓和圆圈对不上;而矫正图是规整矩形,按比例切分误差很小。

def split_choice_grid(warped_gray, config):
    h, w = warped_gray.shape[:2]
    gx1 = int(w * config['grid_x1'])
    gy1 = int(h * config['grid_y1'])
    g_w = int(w * config['grid_w'])
    g_h = int(h * config['grid_h'])

    q_count = config['question_count']
    opt_count = config['option_count']
    cell_w = g_w // opt_count
    cell_h = g_h // q_count

    cells = []
    for q in range(q_count):
        row_cells = []
        for opt in range(opt_count):
            x = gx1 + opt * cell_w
            y = gy1 + q * cell_h
            row_cells.append(warped_gray[y:y + cell_h, x:x + cell_w])
        cells.append(row_cells)
    return cells  # cells[q][opt] 是第 q+1 题第 opt 个选项的灰度图

上面的逻辑默认题目按行排列:同一道题的 ABCD 横向排在一行。如果你的答题卡是纵向排列,也就是所有 A 选项在一列、所有 B 选项在另一列,把 opt 和 q 的索引交换即可。很多同学在这一步直接照搬别人的代码,没有意识到行列顺序是答题卡布局决定的。

切分完成后,建议顺手把格子边界画回原图存下来。用红框把 20×4 个格子框出来,和真实答题卡叠在一起看,比对着坐标想象可靠得多。这一步做一次,后面所有分析都踏实。

3.3 涂卡状态判定:OTSU 阈值与非零像素占比

单个格子的涂卡判断是整个系统里最核心的小函数。它做的事是:输入一个灰度格子,输出这个格子是否被涂。标准做法是 OTSU 二值化后统计非零像素占比,占比超过阈值就视为涂卡。

def cell_is_marked(cell_gray, ratio_threshold=0.15):
    """判断一个选项格是否被涂黑"""
    blurred = cv2.GaussianBlur(cell_gray, (3, 3), 0)
    _, thresh = cv2.threshold(
        blurred, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
    )
    ratio = cv2.countNonZero(thresh) / thresh.size
    if ratio >= ratio_threshold:
        return True, ratio
    return False, ratio

ratio_threshold=0.15 的含义是格子内至少 15% 的像素被判为前景,才认为被涂。这个值的选取逻辑是:正常涂卡覆盖率 25%-50%,空白格因为印刷底纹会有 3%-8% 的噪声,15% 正好卡在两类分布的中间。如果答题卡印刷底纹很深,噪声可能到 10%,阈值就得提到 18% 以上。

实际批量识别时,我会把每个格子的 ratio 值也返回而不是只返回布尔值。原因是纯布尔结果出了问题很难定位:到底是阈值低了还是格子切偏了?有了 ratio,一眼就能看出来是 0.1 附近徘徊的还是 0.5 稳定命中的。

4. 自动批改与结果导出:答案比对、得分统计与可视化核对

4.1 答案比对逻辑:单选与多选的得分策略

答案比对的核心不是 == ,而是把考生的涂卡结果规范化成和标准答案同样的字符串格式。前面 cell_is_marked 已经给出了每个格子的布尔值,按题目聚合:把 A-D 四个格子拼成 BC 这样的字符串,再与标准答案比较。

def grade_choices(cells, standard_answers, score_per_question=2):
    """
    cells: split_choice_grid 返回的三维结构
    standard_answers: {1: 'B', 2: 'A', 3: 'BCD', ...}
    """
    results = {}
    for q_idx, row in enumerate(cells):
        marked = []
        for opt_idx, cell in enumerate(row):
            marked_flag, _ = cell_is_marked(cell)
            if marked_flag:
                marked.append(chr(ord('A') + opt_idx))
        student_answer = ''.join(marked)

        standard = standard_answers.get(q_idx + 1, '')
        if student_answer == standard:
            results[q_idx + 1] = {
                'answer': student_answer,
                'score': score_per_question,
                'correct': True
            }
        else:
            results[q_idx + 1] = {
                'answer': student_answer,
                'score': 0,
                'correct': False
            }
    return results

这里有个得分策略的细节:如果是单选题,学生答案拼出来只能是一个字母,比如 A ;多选题拼出来可能是 BCD 。拼接顺序要按选项字母顺序,否则答案本身对但字符串顺序不一致会被误判。实际阅卷中多选题还有“部分得分”策略,例如标准 BCD 、考生 BC 得一半分。要不要加这个逻辑,取决于题目定义,源码包里一般有一个 partial_score 开关,开启时按“正确选项命中数 / 标准选项总数 × 满分”计算,按需调整即可。

4.2 导出双结果:CSV 明细表与错误标注图

只输出一个总分的系统在答辩和复查时都不好用。我一般同时导出两份:一份 CSV 明细,记录每道题的考生答案、标准答案、得分;一份标注图,在矫正图上把错题框标红。这样使用的人既能按题号追查,也能从图上直观看是哪道题出了问题。

import csv

def export_csv(results, standard_answers, student_id, output_path):
    total = 0
    with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
        writer = csv.writer(f)
        writer.writerow(['考号', '题号', '考生答案', '标准答案', '得分'])
        for q in sorted(results.keys()):
            info = results[q]
            total += info['score']
            writer.writerow([
                student_id, q,
                info['answer'],
                standard_answers.get(q, ''),
                info['score']
            ])
        writer.writerow(['', '', '总分', '', total])

utf-8-sig 编码是为了让 Excel 打开 CSV 不乱码,这是导出中经常被忽略的点。如果用了 utf-8 不带 BOM,Windows 上的 Excel 看到中文列名会直接显示成乱码。

标注图用 cv2.rectangle 在 warped 彩色图上重画网格,错题涂红框,正确题涂绿框。同时把每道题的题号写在框内,方便跟 CSV 对照:

def draw_marked_grid(warped_bgr, cells, results, config):
    canvas = warped_bgr.copy()
    h, w = canvas.shape[:2]
    gx1 = int(w * config['grid_x1'])
    gy1 = int(h * config['grid_y1'])
    cell_w = int(w * config['grid_w']) // config['option_count']
    cell_h = int(h * config['grid_h']) // config['question_count']

    for q, row in enumerate(cells):
        is_correct = results[q + 1]['correct']
        color = (0, 0, 255) if not is_correct else (0, 255, 0)
        for opt in range(config['option_count']):
            x = gx1 + opt * cell_w
            y = gy1 + q * cell_h
            cv2.rectangle(canvas, (x, y),
                          (x + cell_w, y + cell_h), color, 2)
        # 题号写在该题第一个选项格左侧
        text_y = gy1 + q * cell_h + 20
        cv2.putText(canvas, str(q + 1), (gx1 - 30, text_y),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
    return canvas

红绿框只标对错,不标正确选项位置,所以配合 CSV 就能精确定位到某个具体格子。这套组合拳下来,批改结果基本就是“可视化可审计”的状态,比直接甩出一个总分有说服力得多,尤其是毕设答辩演示阶段。

5. 避坑指南:五个最容易翻车的位置与排查方法

5.1 透视矫正后图像还是斜的

现象: warpPerspective 输出图上答题卡边缘仍然带弧形,或者上下边不平行,切分出来的格子歪斜。

原因:普通手机镜头有明显的桶形畸变,单应性矩阵假设目标面是平面,但实际成像面是弯曲的,所以四周区域会有残余扭曲。加上 approxPolyDP 得到的顶点如果带 1-2 像素误差,放大到整张图上就变成几度的倾斜。

解决:拍摄时让卡片尽量占满画面、贴近画面中心。如果批量拍摄条件固定,先用棋盘格标定一次镜头畸变,在 warpPerspective 前做 cv2.undistort ;没有标定条件时,把图像四周裁掉 3%-5% 再检测轮廓,能明显降低边缘畸变影响。另外把 approxPolyDP 的 epsilon 从 0.02 降到 0.01,顶点会更贴合真实边角。

5.2 考号识别结果串位

现象:识别出来的考号部分位数正确、部分错位,例如实际是 20240713,识别成 20240731,且错误集中在后几位。

原因:打印批次不同,答题卡的左边距和上边距有 1-3mm 的裁切公差。相对坐标定位的考号区域在公差累积后,气泡矩阵整体和切分网格错开了半个格宽。

解决:在配置里给考号区做一次手动校准,印一张已涂好考号的样卡,拍照识别后微调 id_x1/id_y1 ,偏差通常只有几像素。更自动化的做法是检测考号区每一行的投影:竖直投影会有明显的十个波峰,如果波峰数量不等于 id_cols ,说明区域切偏了,按波峰中心重新定位。

5.3 浅涂和擦不干净引发的误判

现象:浅涂选项被识别成空白,橡皮擦过的地方反而被识别成涂卡。

原因:OTSU 是全局阈值,浅涂和深涂出现在同一张卡上时,浅涂的灰度可能被判成背景。橡皮擦残留则是小面积高密度噪声,绝对阈值判不出来。

解决:改用相对阈值。对一道题的四个格子,按格子覆盖率降序排列,取最大值和次大值的平均值作为本地阈值。正常情况最大值明显高于次大值,一判就准;只有最大值和次大值非常接近时,才退回绝对阈值并给人工复核标记。这个策略对浅涂和残留都有效,是我在这个项目里最推荐的一条改动。

5.4 光线不均导致一侧识别失败

现象:同一张答题卡,左边选项识别正常,右边全部判为空。

原因:拍摄角度或环境光造成卡面灰度从亮到暗的梯度。全局 OTSU 阈值只能抓住亮区的特征,暗区里浅涂的灰度整体更低,就滑到背景侧了。

解决:把 OTSU 做在单个格子上而不是整张卡上,每个格子都有自己的局部阈值,光线梯度的影响就基本被抵消了。如果局部阈值仍不稳定,先对矫正图做 CLAHE 均衡: clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) ,再用均衡后的图做网格切分。这样做之后识别率的不确定性明显降低。

5.5 切分网格与印刷区错位

现象:前 5 题识别全对,第 6 题开始题目整体错位一格,后面错位越来越多。

原因:打印时答题卡模板本身没有对齐,或者扫描时卡面在进纸方向上有偏移。配置里的 grid_y1 是一个整体起点,一旦印刷起点变化,后面的网格全跟着偏。

解决:在矫正图上先找定位标记。正规答题卡会在选择题区四周印黑色基准块,用轮廓检测找到这些基准块的中心,以它为网格起点,而不是直接用配置里的相对坐标。当检测不到基准块时,就检查预处理是否会腐蚀掉这些细小的黑色标记,必要时把膨胀的 iterations 降回 1。

6. 一个进阶技巧:批量识别前先做基线校准,稳定性直接上一个台阶

前面提到阈值会受打印批次、光源、扫描仪影响,解决这个问题最实用的办法不是反复调参,而是给整批答题卡做一次“空白基线校准”。具体做法:从同一批答题卡里抽出三张空白卡(没涂任何内容),放进同样的拍摄环境走下同一套预处理和切分流程,统计所有格子位置的灰度均值和噪声水平,存成一个 JSON 校准文件。真正识别时拿当前卡片的格子灰度去和基线对比,而不是和固定的 0.15 阈值硬比。

def calibrate_baseline(blank_cards_paths, config):
    """统计空白卡每个格子位置的灰度均值,返回基线矩阵"""
    cell_means = np.zeros((config['question_count'], config['option_count']))
    for path in blank_cards_paths:
        img = load_resized(path)
        # 这里复用前面的透视矫正,得到矫正后的灰度图
        warped_gray = get_warped_gray(img)
        cells = split_choice_grid(warped_gray, config)
        for q, row in enumerate(cells):
            for opt, cell in enumerate(row):
                cell_means[q][opt] += cell.mean() / len(blank_cards_paths)
    return cell_means.tolist()

识别当前卡时,每个格子的判定语句就变成:

dynamic_threshold = baseline[q][opt] + margin
marked = cell.mean() < dynamic_threshold

空白格会因为印刷底纹和噪点有一个稳定的灰度基线,一旦真正涂黑,灰度会显著低于这个基线,所以这个判定方式对批次差异并不敏感。注意 margin 要设置得比基线到涂黑格的距离小很多,否则会把浅涂漏掉。

校准完之后强烈建议先跑一遍可视化验证:把当前卡每个格子的灰度值按位置打印成 20×4 的矩阵,和基线矩阵做差值。差值矩阵里正常涂卡的位置会明显“凹”下去,如果整行都凹下去,那大概率是这道题整行都有涂卡;如果整列都不对,说明切分坐标问题,网格整体偏移了。这个方法能在一分钟内分辨出是切分问题还是涂卡问题,省掉盲目调参的时间。

从那以后我每次拿到新一批答题卡,都会强制自己先花五分钟做基线校准、存校准 JSON,再批量跑识别。这个习惯帮我避开了大多数标着「玄学」的识别失败——它们大部分不是算法不行,而是作业环境变了、阈值还留在原地。希望帮到你。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

聚焦RDK与具身智能,汇集机器人开发教程、开源案例和技术交流

更多推荐