
简介本资源是一套面向本科生与教育技术初学者的手写数学公式智能识别系统实现方案聚焦于将手写图像精准转换为可编辑的LaTeX数学表达式解决学术写作、在线教育及作业批改中公式数字化效率低下的实际问题。项目基于Python 3.8构建融合OpenCV图像预处理、Tesseract OCR符号识别与NLTK语法解析技术完整覆盖图像采集、区域分割、符号分类、结构建模到LaTeX生成的全流程。压缩包共21个文件含11个核心Python源码如train.py、test.py、latex2gtd.py、model.py等、3张BMP测试样本、3个备份文件.zbak、1个README说明文档及Git配置文件总大小仅34KB结构精炼、模块职责清晰便于理解多阶段识别管道设计逻辑。目前已有84人学习下载读者可直接运行调试、复现识别流程掌握手写公式识别中的字符归一化、公式树构建与语义映射等关键技术实践。1. 项目缘起从一张草稿纸到一行代码不知道你有没有过这样的经历在纸上推演一个复杂的公式或者记录下灵光一现的数学推导然后想把它们搬到电脑上。这时候要么一个字一个字地敲LaTeX过程繁琐且容易打断思路要么用鼠标在公式编辑器里点来点去效率低得让人抓狂。手写数学公式识别就是为了解决这个痛点而生的技术。它能让你的手写笔迹无论是写在平板、数位板还是用手机拍下的纸张直接转换成结构化的、可编辑的数学表达式比如LaTeX代码或者MathML。这个需求在科研、教育、笔记整理等领域非常普遍。我之前在做一些算法推导和论文笔记时就深受其扰。市面上虽然有一些现成的工具比如Mathpix但它们通常是云端服务有使用次数限制涉及到隐私和数据安全时也需要斟酌。更重要的是作为一个开发者我更想搞清楚这背后的“黑盒子”是怎么运作的能不能自己动手搭建一个更可控、更符合个人需求的系统。所以就有了这个项目基于Python的手写数学公式识别系统。我们的目标不是做一个商业级的、大而全的产品而是从零开始理解并实现一个可工作的核心流程。这包括了从图像预处理、符号分割、符号识别到最终的结构分析与表达式重建。整个过程就像是在解一道复杂的工程题充满了挑战也充满了乐趣。接下来我会把我从零搭建这个系统的完整思路、踩过的坑以及最终的实现方案毫无保留地分享给你。2. 系统架构总览化繁为简的四步走策略在动手写代码之前我们必须先想清楚整个系统的脉络。一个完整的手写公式识别流程可以抽象为四个核心阶段它们环环相扣共同完成了从“像素”到“符号”再到“结构”的转换。2.1 第一阶段图像预处理——为识别打好地基你拍下的或者写下的手写公式图片通常不是“干净”的。它可能背景杂乱、光照不均、笔迹潦草、有噪点。预处理的目的就是把这张“脏”图片变成一张背景干净、笔画清晰、符号突出的“标准照”为后续步骤扫清障碍。这一步的核心操作通常包括灰度化与二值化将彩色或灰度图像转换为只有黑白两色的图像。这里的关键在于选择一个合适的阈值。我尝试过全局阈值如OTSU算法和局部自适应阈值。对于光照均匀的图片OTSU效果很好且速度快但对于光照不均的自适应阈值如cv2.adaptiveThreshold是更好的选择它能根据像素周围的小区域动态计算阈值。去噪使用形态学操作如开运算、闭运算去除小的噪点或者使用中值滤波器平滑图像同时保留边缘。倾斜校正手写时难免有倾斜。我们可以通过霍夫变换检测图像中笔画的整体角度然后进行旋转校正。这一步对后续的行分割和符号定位至关重要。尺寸归一化将图像缩放到一个统一的尺寸比如高度固定为64像素宽度按比例缩放。这能保证输入到神经网络中的图像尺寸一致。注意预处理并非越复杂越好。过度处理如过强的滤波可能会抹掉重要的细节比如分数线的细小断开或上下标的小点。我的经验是先尝试最简单的流程灰度-OTSU二值化如果效果不佳再逐步引入更复杂的步骤。2.2 第二阶段符号检测与分割——把公式“拆”成零件这是整个系统最具挑战性的环节之一。一个数学公式不是由孤立的字符组成的而是由符号数字、字母、运算符及其二维空间关系上下标、分式、根号构成的复杂结构。我们不能简单地用OCR那样按行切割。这里的核心任务是连通域分析。我们通过扫描二值图像找到所有彼此连接的黑像素组每一个组就是一个潜在的符号。但是问题来了粘连符号比如“π”和“i”写得太近会被识别成一个连通域。离散符号比如“等号”由上下两条短线组成可能被识别成两个连通域。复合结构根号的“√”和横线分式的分数线和分子分母它们需要被识别为一个整体结构而不是拆散。我的策略是分两步走初步分割使用OpenCV的cv2.findContours或cv2.connectedComponentsWithStats获取所有连通域并计算它们的边界框。结构预判与合并对于水平方向距离非常近的边界框检查它们是否可能属于同一个符号如“i”的点和竖线通过宽高比和相对位置进行合并。对于像分数线、根号横线这种长条状的连通域需要特别标记它们将是后续结构分析的关键“结构符号”。这里需要设定一些经验阈值比如两个框在水平方向的重叠比例超过多少且垂直方向对齐良好则考虑合并。这个过程需要反复调试。2.3 第三阶段符号分类——认出每一个“零件”分割出来的每一个图像块边界框内的区域我们需要知道它是什么。是数字“7”是字母“x”还是运算符“”这本质上是一个图像分类问题而卷积神经网络CNN是当前的主流解决方案。我们不需要从头发明轮子可以基于成熟的架构进行微调。模型选型与训练基础模型我选择了ResNet-18。它比VGG更轻量比简单的LeNet-5更强大在保证精度的同时训练和推理速度都较快非常适合作为我们项目的起点。数据集这是最大的难点。公开的手写数学公式数据集不多最著名的是CROHME系列数据集。但它规模有限且符号类别可能不全。我的做法是“组合拳”使用CROHME作为基础训练集学习手写风格。数据增强对CROHME中的样本进行旋转、缩放、平移、弹性形变、添加噪点等极大地扩充数据量提高模型鲁棒性。合成数据使用LaTeX渲染出大量印刷体数学符号涵盖更全的类别然后应用随机的手写风格化滤波器如薄板样条扭曲、笔画粗细变化来模拟手写。这一步能有效补充稀有符号。类别设计类别不仅包括常见字符a-z, A-Z, 0-9, , -, ×, ÷, 还必须包含结构符号如\frac分式开始、\sqrt根号开始、_下标、^上标的占位标识。在标注时一个分式整体可能被标记为一个特殊的“FRAC”类别用于后续处理。训练要点输入图像统一归一化到[64, 64]大小并归一化像素值。损失函数使用交叉熵损失。由于类别不均衡数字出现多某些希腊字母出现少可以考虑使用带权重的交叉熵损失。训练时将验证集上的准确率作为早停Early Stopping的依据防止过拟合。2.4 第四阶段结构分析与表达式重建——把“零件”组装回去这是将分类结果转化为最终可读表达式如LaTeX的“大脑”。我们知道了一堆符号是什么以及它们在图上的位置边界框坐标现在需要理解它们之间的二维语法关系。核心逻辑空间关系语法树我们可以将公式视为一棵树。根节点是整个表达式。运算符如、或结构符号如分式线、根号是中间节点。数字和字母是叶子节点。上下标关系可以看作父节点与子节点的关系。重建算法的大致步骤基线检测估算出公式的主基线baseline大多数符号的中心点都在这条线附近。这有助于区分同一行的符号和上下标。关系判定遍历所有识别出的符号框根据它们的空间位置关系判断关系水平相邻判断是否属于同一行。比较两个框的垂直重叠度和水平距离。上下标如果一个框如指数“2”位于另一个框如底数“x”的右上方且尺寸较小则判定为“x^2”。下标同理。分式识别出的“分数线”结构符号其上方和下方聚集的符号框分别判定为分子和分母。根号识别出的“√”符号其右侧下方覆盖的符号框判定为被开方数。递归构建从主要的运算符或结构符号开始递归地将其操作数可能是子表达式作为孩子节点构建一棵语法树。树转表达式对这棵语法树进行中序遍历或后序遍历根据节点类型生成对应的LaTeX代码片段并递归组合最终生成完整的LaTeX字符串。这个过程需要精心设计大量的规则和阈值比如多大尺寸差、什么位置关系算上下标是调试中最繁琐的部分。一个实用的技巧是将中间结果识别出的符号和框以及初步判定的关系可视化出来方便调试。3. 核心模块实现细节与代码剖析理论说完了我们来看看具体的代码怎么组织。我的项目结构大致如下handwritten_math_ocr/ ├── core/ │ ├── preprocessor.py # 图像预处理模块 │ ├── segmenter.py # 符号分割模块 │ ├── classifier.py # 符号分类模块包含模型定义与推理 │ └── parser.py # 结构分析与LaTeX生成模块 ├── utils/ │ ├── visualization.py # 可视化工具 │ └── latex_tools.py # LaTeX相关辅助函数 ├── models/ │ └── resnet18_sym.pth # 训练好的模型权重 ├── data/ │ ├── train/ # 训练数据 │ └── eval/ # 评估数据 └── main.py # 主程序入口3.1 图像预处理模块的实现我们使用OpenCV作为主力。以下是preprocessor.py的核心函数import cv2 import numpy as np def preprocess_image(image_path, target_height800): 对输入图像进行预处理返回处理后的二值图像。 # 1. 读取图像 img cv2.imread(image_path) if img is None: raise ValueError(f无法读取图像: {image_path}) # 2. 转为灰度图 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 3. 可选调整大小保持宽高比 h, w gray.shape scaling_factor target_height / h new_width int(w * scaling_factor) resized cv2.resize(gray, (new_width, target_height), interpolationcv2.INTER_CUBIC) # 4. 去噪使用中值滤波对椒盐噪声效果好 denoised cv2.medianBlur(resized, ksize3) # ksize取奇数 # 5. 二值化自适应阈值应对光照不均 # 参数说明255是最大值cv2.ADAPTIVE_THRESH_GAUSSIAN_C是自适应方法 # cv2.THRESH_BINARY_INV是因为我们想要背景白255笔画黑0 # 11是邻域块大小2是常数C从均值或加权均值中减去 binary cv2.adaptiveThreshold(denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) # 6. 形态学操作去除小噪点开运算 kernel np.ones((2, 2), np.uint8) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 7. 可选倾斜校正这里使用霍夫变换找直线计算角度 # 这是一个简化版实际应用可能需要更鲁棒的算法 edges cv2.Canny(binary, 50, 150, apertureSize3) lines cv2.HoughLinesP(edges, 1, np.pi/180, threshold100, minLineLength100, maxLineGap10) angle 0.0 if lines is not None: angles [] for line in lines: x1, y1, x2, y2 line[0] angles.append(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi) # 取角度中位数避免异常值影响 angle np.median(angles) # 如果角度绝对值很小则不旋转 if abs(angle) 0.5: (h, w) binary.shape[:2] center (w // 2, h // 2) M cv2.getRotationMatrix2D(center, angle, 1.0) binary cv2.warpAffine(binary, M, (w, h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE) return binary实操心得adaptiveThreshold的blockSize邻域大小和C常数需要根据图像分辨率调整。对于高分辨率图像blockSize要调大。THRESH_BINARY_INV是为了让笔画为黑色0背景为白色255这是后续findContours函数的常见期望输入在白色背景上找黑色物体。3.2 符号分割模块的实现segmenter.py负责从二值图像中提取出一个个符号候选框。import cv2 import numpy as np def segment_symbols(binary_image, min_area50, max_aspect_ratio10): 对二值图像进行连通域分析返回符号的边界框列表。 每个边界框为 (x, y, w, h)。 # 为了使用findContours确保背景是黑色物体是白色与预处理输出相反 # 或者我们在预处理时就用 THRESH_BINARY 而不是 INV。 # 这里假设输入 binary_image 是笔画为白255背景为黑0。 # 如果预处理输出是笔画黑背景白需要先反转。 # 我们假设输入已经是笔画白背景黑。 # 寻找连通域 # cv2.RETR_EXTERNAL 只检测最外层轮廓 # cv2.CHAIN_APPROX_SIMPLE 压缩轮廓 contours, hierarchy cv2.findContours(binary_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) bounding_boxes [] for cnt in contours: area cv2.contourArea(cnt) if area min_area: # 过滤面积太小的噪点 continue x, y, w, h cv2.boundingRect(cnt) aspect_ratio max(w, h) / (min(w, h) 1e-5) if aspect_ratio max_aspect_ratio: # 过滤过于细长的线可能是未处理的结构线 # 这里可以进一步判断如果是长横线可能是分数线需要特殊处理 # 暂时跳过或标记为特殊结构 continue bounding_boxes.append((x, y, w, h)) # 按从左到右从上到下的顺序排序简单的多行排序 # 先按y坐标行排序再按x坐标列排序 bounding_boxes.sort(keylambda b: (b[1] // 20, b[0])) # //20 是一个粗略的行分组阈值 return bounding_boxes def merge_boxes(boxes, horizontal_threshold0.3, vertical_overlap_threshold0.5): 合并可能属于同一符号的相邻框如‘i’的点与竖线。 if not boxes: return boxes merged [] used [False] * len(boxes) for i in range(len(boxes)): if used[i]: continue current_box list(boxes[i]) for j in range(i1, len(boxes)): if used[j]: continue box_j boxes[j] # 计算两个框的水平距离和垂直重叠度 x_overlap max(0, min(current_box[0]current_box[2], box_j[0]box_j[2]) - max(current_box[0], box_j[0])) y_overlap max(0, min(current_box[1]current_box[3], box_j[1]box_j[3]) - max(current_box[1], box_j[1])) min_height min(current_box[3], box_j[3]) horizontal_gap max(box_j[0] - (current_box[0]current_box[2]), current_box[0] - (box_j[0]box_j[2])) horizontal_gap max(0, horizontal_gap) # 如果水平距离很近且垂直方向有显著重叠则合并 if horizontal_gap horizontal_threshold * min(current_box[2], box_j[2]) and \ y_overlap vertical_overlap_threshold * min_height: # 合并框取最小x最小y最大右边界最大下边界 new_x min(current_box[0], box_j[0]) new_y min(current_box[1], box_j[1]) new_w max(current_box[0]current_box[2], box_j[0]box_j[2]) - new_x new_h max(current_box[1]current_box[3], box_j[1]box_j[3]) - new_y current_box [new_x, new_y, new_w, new_h] used[j] True merged.append(tuple(current_box)) used[i] True return merged踩坑记录cv2.findContours函数在不同OpenCV版本中返回值不同。在OpenCV 4.x中它返回contours, hierarchy两个值在OpenCV 3.x中可能返回image, contours, hierarchy三个值。务必检查你的OpenCV版本。另外合并框的阈值horizontal_threshold,vertical_overlap_threshold需要在自己的数据集上反复调试没有万能值。一个可视化调试界面在这里至关重要。3.3 符号分类模块模型定义与推理classifier.py包含模型定义、加载和预测功能。我们使用PyTorch框架。import torch import torch.nn as nn import torchvision.models as models from torchvision import transforms from PIL import Image import cv2 class SymbolClassifier(nn.Module): def __init__(self, num_classes): super(SymbolClassifier, self).__init__() # 加载预训练的ResNet-18并替换最后的全连接层 self.resnet models.resnet18(pretrainedTrue) num_features self.resnet.fc.in_features self.resnet.fc nn.Linear(num_features, num_classes) def forward(self, x): return self.resnet(x) # 图像转换管道必须与训练时一致 transform transforms.Compose([ transforms.Grayscale(num_output_channels3), # ResNet期望3通道输入将灰度图复制到3个通道 transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) # ImageNet统计量 ]) def predict_symbol(model, image_patch, class_names, devicecpu): 对单个图像块进行预测。 image_patch: 从原图中裁剪出的RGB或灰度numpy数组 (H, W, C) 或 (H, W) # 确保图像是PIL Image格式 if isinstance(image_patch, np.ndarray): # 如果是单通道灰度图转为PIL灰度图 if len(image_patch.shape) 2: image_patch Image.fromarray(image_patch, modeL) else: # 如果是BGR的OpenCV图像转为RGB image_patch cv2.cvtColor(image_patch, cv2.COLOR_BGR2RGB) image_patch Image.fromarray(image_patch) # 应用转换 input_tensor transform(image_patch).unsqueeze(0) # 增加batch维度 input_tensor input_tensor.to(device) # 预测 model.eval() with torch.no_grad(): outputs model(input_tensor) _, predicted_idx torch.max(outputs, 1) predicted_label class_names[predicted_idx.item()] return predicted_label训练脚本要点 训练部分代码较长但核心逻辑是标准的PyTorch训练循环。关键点在于数据加载器的构建。你需要准备一个数据集其中每个符号图像对应一个标签整数索引。标签与class_names列表一一对应。经验之谈即使使用预训练模型在小型数据集如CROHME上训练也容易过拟合。数据增强是救命稻草。我使用了torchvision.transforms中的RandomAffine旋转、平移、缩放、RandomPerspective透视变换以及自定义的弹性形变来模拟手写抖动。此外将学习率设置得较低如1e-4并使用学习率调度器如ReduceLROnPlateau在验证损失停滞时降低学习率对稳定训练很有帮助。3.4 结构分析与LaTeX生成模块parser.py是逻辑最复杂的部分。这里给出一个高度简化的、基于规则的关系判定和LaTeX生成框架。class SymbolNode: 表示一个符号及其在表达式树中的节点。 def __init__(self, label, bbox): self.label label # 分类结果如 x, , frac self.bbox bbox # (x, y, w, h) self.children [] # 子节点如分子的符号列表、分母的符号列表 def build_expression_tree(symbols_with_boxes, image_height): 根据识别出的符号和其位置构建一个粗糙的表达式树。 这是一个极度简化的示例真实情况复杂得多。 symbols_with_boxes: list of tuples (label, (x, y, w, h)) nodes [SymbolNode(label, bbox) for label, bbox in symbols_with_boxes] # 按x坐标排序 nodes.sort(keylambda n: n.bbox[0]) # 简单基线估计取所有符号框中心y坐标的中位数 if nodes: centers_y [n.bbox[1] n.bbox[3] // 2 for n in nodes] baseline_y sorted(centers_y)[len(centers_y)//2] else: baseline_y image_height // 2 root SymbolNode(root, (0,0,0,0)) current_line [] # 第一步粗略分组这里假设单行公式多行需要更复杂处理 for node in nodes: cy node.bbox[1] node.bbox[3] // 2 # 如果符号中心在基线附近认为是主行符号 if abs(cy - baseline_y) node.bbox[3] * 0.5: current_line.append(node) else: # 否则可能是上下标需要关联到前一个主行符号 # 这里简化处理直接挂到前一个节点下实际需判断是上标还是下标 if current_line: parent current_line[-1] # 简单判断如果cy比基线高很多是上标低很多是下标 if cy baseline_y - node.bbox[3]: # 上标 parent.children.append((sup, node)) elif cy baseline_y node.bbox[3]: # 下标 parent.children.append((sub, node)) # 对于像分式、根号这种需要更复杂的结构匹配此处省略 root.children current_line return root def tree_to_latex(node): 递归地将表达式树转换为LaTeX字符串。 if node.label root: parts [tree_to_latex(child) for child in node.children] return .join(parts) else: latex node.label # 处理子节点上下标 for rel, child in node.children: if rel sup: latex ^{ tree_to_latex(child) } elif rel sub: latex _{ tree_to_latex(child) } return latex重要提示上面的build_expression_tree函数是一个极度简化的示例仅用于说明思路。真实的公式结构分析需要处理分式、根号、括号匹配、矩阵、多行表达式等算法复杂度呈指数级上升。工业级系统通常会采用基于语法Grammar的解析方法或者使用基于图神经网络GNN的端到端识别模型如Watch, Attend and Parse直接输出LaTeX序列避免复杂的规则设计。对于我们这个学习项目从规则方法入手能让你更深刻地理解问题的本质。4. 系统集成与效果评估将上述模块串联起来就形成了main.py的主流程import sys from core.preprocessor import preprocess_image from core.segmenter import segment_symbols, merge_boxes from core.classifier import SymbolClassifier, predict_symbol from core.parser import build_expression_tree, tree_to_latex import torch import cv2 def main(image_path): # 1. 预处理 print(正在预处理图像...) binary_img preprocess_image(image_path) # 2. 分割 print(正在分割符号...) boxes segment_symbols(binary_img) boxes merge_boxes(boxes) # 尝试合并 print(f检测到 {len(boxes)} 个候选符号区域。) # 3. 加载分类模型 device torch.device(cuda if torch.cuda.is_available() else cpu) num_classes 100 # 根据你的数据集类别数修改 model SymbolClassifier(num_classes) model.load_state_dict(torch.load(models/resnet18_sym.pth, map_locationdevice)) model.to(device) model.eval() # 加载类别名称 with open(data/class_names.txt, r) as f: class_names [line.strip() for line in f] # 4. 分类并收集结果 symbols_with_boxes [] original_img_color cv2.imread(image_path) h, w binary_img.shape for i, (x, y, w_box, h_box) in enumerate(boxes): # 从原图或二值图裁剪符号区域 # 注意分类器可能需要RGB输入我们从原彩色图裁剪 patch original_img_color[y:yh_box, x:xw_box] if patch.size 0: continue label predict_symbol(model, patch, class_names, device) symbols_with_boxes.append((label, (x, y, w_box, h_box))) print(f符号 {i}: 位置({x},{y}), 识别为 {label}) # 5. 结构分析与生成LaTeX print(\n进行结构分析...) expression_tree build_expression_tree(symbols_with_boxes, h) latex_code tree_to_latex(expression_tree) print(\n 识别结果 ) print(fLaTeX 代码: {latex_code}) # 可以调用外部工具渲染LaTeX代码为图片进行可视化对比 # 例如使用 matplotlib 的 latex 渲染 或 调用 latex 命令行 if __name__ __main__: if len(sys.argv) 2: print(用法: python main.py 图片路径) sys.exit(1) main(sys.argv[1])效果评估与调试 没有标准答案评估主要靠肉眼观察和定性分析。我通常会准备一个包含各种典型公式的测试集简单如y x^2 1复杂如\frac{\sqrt{x1}}{2\pi}。运行系统后对比生成的LaTeX和标准LaTeX。常见的失败案例及调试方向符号漏检或误检调整预处理参数二值化阈值、去噪强度和分割参数min_area。符号分类错误检查训练数据是否覆盖了该符号增加该符号的训练样本或调整数据增强策略。结构关系错误这是最难调的。需要可视化每个符号框和判定的关系比如画线连接父子节点仔细分析空间关系判定的阈值是否合理。对于分式、根号等复杂结构可能需要设计专门的检测器而不是依赖通用符号分类。5. 项目总结与进阶思考实现这个基础版本的系统就像完成了一次漫长的登山。你亲手搭建了从图像到结构化数据的完整管道对计算机视觉和模式识别的交叉应用有了切身的体会。这个过程里最大的收获不是代码本身而是处理“不完美现实数据”的思维方式如何设计鲁棒的预处理来应对各种书写风格和拍摄条件如何用有限的数据通过增强和合成来训练一个可用的模型如何用启发式规则去理解二维空间关系并深知其局限性。这个项目远非终点而是一个起点。如果你想继续深入以下几个方向值得探索端到端模型放弃传统的“分割-识别-解析”流水线尝试使用基于注意力机制如Transformer的序列到序列Seq2Seq模型。输入整张公式图片直接输出LaTeX标记序列。这能从根本上避免分割错误和复杂的结构分析。Pix2Seq、Watch, Attend and Parse (WAP)等论文是很好的起点。更强大的分类器在符号分类阶段可以尝试更先进的网络架构如EfficientNet、Vision Transformer或者集成多个模型集成学习来提升准确率。利用上下文信息在解析时不仅考虑空间位置也考虑数学语法。例如在识别出开括号后优先在右侧寻找闭括号识别出“sin”后后面跟着的很可能是括号内的参数。可以结合一个简单的数学语法模型来约束解析结果。交互式修正没有一个系统是100%准确的。设计一个用户界面允许用户对识别错误的符号或结构进行快速点击修正并将修正结果反馈给模型进行在线学习能极大提升实用体验。最后这个项目的所有代码和数据包括我处理过的CROHME数据子集和合成数据脚本我都整理放在了GitHub上。纸上得来终觉浅绝知此事要躬行。希望我的这份“踩坑指南”和代码骨架能帮你更快地上手亲手实现这个有趣且有用的系统。当你第一次看到自己手写的潦草公式被准确地转换成LaTeX代码时那种成就感就是对我们这些开发者最好的奖励。本文还有配套的精品资源点击获取