C#离线身份证识别实战:飞桨PP-OCR模型与ONNX Runtime源码部署
简介:这是一份以C#为主、调用百度飞桨深度学习平台完成的身份证识别项目源码,面向希望落地OCR功能的.NET开发者和技术学习者,可自动提取身份证上的姓名、性别、出生日期、证件号码等关键字段,整体覆盖图像预处理、文字定位、识别结果结构化输出等典型流程。压缩包共收纳20个文件,大小约16KB,以9个C#源文件为主,搭配3个JSON配置、2个csproj工程文件、1个sln解决方案文件与说明文档,组成一个轻量的Web API服务骨架。核心类涵盖身份证识别服务、接口定义与证件信息实体,飞桨模型调用、识别结果映射与接口封装均有体现;控制器与服务模块分离,分层设计清晰,方便按业务与识别逻辑进行维护。整体代码布局紧凑,适合课程设计、毕业设计或企业证件识别模块参考,也便于在现有结构上扩展新识别类型或调整预处理步骤,减少从零搭建成本。已有606人次浏览学习,说明该案例具有实际参考价值。
1. 身份证识别在C#端落地的关键:飞桨模型负责看清字段,你负责写好这一层源代码
做C#上位机或后台服务的人,大概率都遇到过客户提这个需求:给我做身份证识别,把姓名、地址、身份证号导出来。第一反应是接云OCR,但数据出境、按次收费、离线环境这三关直接把方案毙掉。于是百度飞桨成了候选人:模型是开源的中文OCR模型,识别效果好,还能本地部署。麻烦在于飞桨是Python生态,C#工程直接调不动。这篇笔记要讲的,就是一套把飞桨PP-OCR模型拉进C#进程、做成离线身份证识别管线的源代码方案。适合谁看:有C#基础、不想被云OCR按次收费、希望保留全部模型和后处理源码的开发者。看完你能跑通识别,也知道哪些参数不能乱调。
2. 先看清飞桨OCR在C#里的真实链路:检测、方向分类、识别一个都不能少
身份证不会自己摆成标准正面。实际环境里有横放、竖放、反光、边角缺失。飞桨PP-OCR把“读图”拆成了多个模型:先由文本检测模型找出图片里所有文字块的位置,再由方向分类模型判断文字块是否需要旋转,最后由识别模型把文字块变成字符串。C#端要实现的源代码不是“调用一个OCR函数”,而是把这三段串成一条完整的识别管线,再针对身份证板式做结构化输出。
2.1 身份证图片不是直接塞进识别模型:先解决“图里有证件、证件是歪的”
很多第一次做身份证识别的工程师,会直接下载一个识别模型,把整张身份证图片塞进去,然后得到一堆乱码。这大概率是跳过了检测模型。识别模型的设计输入是一小块“已经是正立、已经是文字区域”的图片,而不是整张证件。文本检测模型的作用是先在原图上画框,把“姓名”“性别 男”“公民身份号码”这些文字行分别框出来。检测框带有四角顶点坐标,后续可以判断是否倾斜,必要时做透视矫正。
方向分类模型解决的是“旋转180度”的问题。扫描或拍照时,身份证可能被倒置。通用识别模型对倒置文本非常敏感,所以管线里要加方向分类器,判断当前文本块是0度还是180度,再决定是否旋转后送识别模型。三个模型的分工如下表:
| 模型 | 输入 | 输出 | 在身份证识别里的角色 |
|---|---|---|---|
| 文本检测模型 | 整张图片 | 文本框四角坐标 | 定位姓名、身份证号等文字块位置 |
| 方向分类模型 | 裁出的文字块 | 0°/180°角度 | 纠正倒置文本,提升识别率 |
| 文本识别模型 | 正立文字块 | 字符序列及置信度 | 把图片变成“张三”“110101...”字符串 |
这也是为什么一套身份证识别源代码通常包含三个模型文件,而不是一个。有人觉得“我只识别身份证,不需要检测模型”,最后用下来发现识别结果全是碎片,就是这个原因。
2.2 在C#端跑飞桨模型的两种方案:Paddle Inference封装和ONNX Runtime
我见过两种常见做法。第一种是用PaddleOCRSharp这类封装库,它把Paddle Inference的C++侧封装成C#接口,内部仍然是百度飞桨的推理引擎。优点是最贴近飞桨原始模型、部署简单,缺点是后处理逻辑被封装在库里,出了问题你很难改。
第二种做法,也是我在自己工程里沿用的方案:把飞桨预训练好的推理模型用paddle2onnx导出成ONNX格式,交给Microsoft.ML.OnnxRuntime推理。ONNX Runtime在Windows和Linux下都能跑,运行环境不需要安装Python,也不依赖PaddlePaddle运行库。源码透明度更高,检测后处理、识别解码、字段解析全部自己写,遇到问题可以从模型输出一层层排查。这才是“源代码”的价值所在:每个环节都可改,而不是把模型当成黑匣子。
导出ONNX只需要在准备阶段做一次,C#运行环境完全不用碰Python。常见的导出命令如下:
paddle2onnx --model_dir ch_PP-OCRv4_det_infer \
--model_filename inference.pdmodel \
--params_filename inference.pdiparams \
--save_file ch_PP-OCRv4_det_infer.onnx \
--opset_version 13
注意opset版本和ONNX Runtime要兼容。opset 13在大多数新版本运行时里都能跑,如果换成老版本运行时,导出时最好用opset 11。模型文件的精度不会因为换格式而改变,变的只是推理载体,所以你在Python里测试过的识别效果,在C#里应该能复现,前提是前后处理参数保持一致。
2.3 模型目录与运行时依赖:不装Python,也能离线识别
一个可交付的源代码工程,建议把模型放成这样的目录结构:
IdCardOcr/
├── Models/
│ ├── ch_PP-OCRv4_det_infer.onnx # 文本检测模型
│ ├── ch_PP-OCRv4_rec_infer.onnx # 文本识别模型
│ ├── ch_ppocr_mobile_v2.0_cls_infer.onnx # 方向分类模型
│ └── ppocr_keys_v1.txt # 识别模型的中文字典
├── OcrCore/
│ ├── TextDetector.cs
│ ├── TextRecognizer.cs
│ ├── ImageClassifier.cs
│ └── OcrPipeline.cs
├── IdCard/
│ ├── IdCardParser.cs
│ └── IdCardResult.cs
└── Program.cs
模型用飞桨官方PP-OCR的中文预训练模型即可,身份证文字是常用汉字和数字,通用模型已经覆盖。真正的问题往往不在文字本身,而在“0”和“O”、“1”和“I”这种字符混淆,这部分要在后处理阶段解决,而不是重新训练模型。工程里需要的最小依赖如下:
| NuGet包 | 用途 |
|---|---|
| Microsoft.ML.OnnxRuntime | 加载ONNX模型并执行推理 |
| OpenCvSharp4 | 图像读入、缩放、透视变换、轮廓查找 |
| OpenCvSharp4.runtime.win | Windows下OpenCV原生库 |
到这里,你已经清楚“这段源代码到底长什么样”。接下来要解决的是怎么让这些模型在C#代码里真正跑起来。
3. 把识别源代码跑起来:图像预处理、模型推理和后处理算法
当模型转成ONNX后,C#侧的工作可以拆成四步:加载模型、图像预处理、调用推理、解析输出。这里最影响识别率的部分不是推理本身,而是预处理和后处理。飞桨官方在Python里做了不少OpenCV操作,C#端用OpenCvSharp4对应实现。
3.1 环境准备:从NuGet引入OnnxRuntime和OpenCvSharp,验证模型可加载
先建好一个.NET控制台工程或类库,引入运行时依赖:
dotnet add package Microsoft.ML.OnnxRuntime
dotnet add package OpenCvSharp4
dotnet add package OpenCvSharp4.runtime.win
加载模型前,先读取输入维度,因为不同导出工具产生的ONNX输入名可能是“x”也可能是“input”。通过以下代码确认:
using Microsoft.ML.OnnxRuntime;
using var detSession = new InferenceSession("Models/ch_PP-OCRv4_det_infer.onnx");
foreach (var item in detSession.InputMetadata)
{
Console.WriteLine($"{item.Key}: {string.Join(",", item.Value.Dimensions)}");
}
这段代码的作用是输出模型输入名和维度。如果看到输入名是“x”,维度包含 [1,3,960,960] ,后面构造输入张量时就要严格用这个名字和形状。第一个1是batch,第二个3是通道数,后面两个是模型期望的宽高。检测模型通常支持动态高宽,但缩放后的尺寸需要按32对齐。如果ONNX模型被固定了尺寸,预处理就必须严格按模型要求的宽高来填充。
3.2 检测阶段:等比缩放、归一化、找文本框
检测模型需要整张图。常见的做法是长边缩放到960,短边按比例缩放,再向上取整到32的倍数。预处理代码:
public static Mat PreprocessDet(Mat src, int targetLongEdge = 960)
{
float ratio = targetLongEdge / (float)Math.Max(src.Width, src.Height);
int newW = (int)Math.Round(src.Width * ratio / 32f) * 32;
int newH = (int)Math.Round(src.Height * ratio / 32f) * 32;
var resized = new Mat();
Cv2.Resize(src, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear);
var rgb = new Mat();
Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB);
return rgb;
}
把长边缩到960,再把宽高取整到32的倍数,是为了匹配模型卷积层的下采样比例。如果尺寸不按32对齐,模型输出特征图和原图之间的坐标推算就会偏差。
填张量时,检测模型用的是ImageNet归一化参数,mean=(0.485, 0.456, 0.406),std=(0.229, 0.224, 0.225)。这段代码把图像按NCHW排列填充到DenseTensor:
float[] mean = { 0.485f, 0.456f, 0.406f };
float[] std = { 0.229f, 0.224f, 0.225f };
var detTensor = new DenseTensor<float>(new[] { 1, 3, rgb.Rows, rgb.Cols });
for (int c = 0; c < 3; c++)
{
for (int y = 0; y < rgb.Rows; y++)
{
for (int x = 0; x < rgb.Cols; x++)
{
var v = rgb.At<Vec3b>(y, x);
detTensor[0, c, y, x] = (v[2 - c] / 255f - mean[c]) / std[c];
}
}
}
rgb.At<Vec3b> 取出的是RGB三个通道, 2 - c 负责把通道顺序按模型要求摆放。如果你在代码里保持BGR,这个下标就要反过来。前面调用 Cv2.CvtColor 转成RGB,这里就不用再做一次通道交换。
推理后得到检测模型的输出,是一个与模型输入等大的概率图,每个像素代表“这里是不是文字”。后处理需要做三件事:上采样到原图尺寸、阈值二值化、找连通域外接框。C#里对应代码如下:
Mat mask = new Mat();
Cv2.Threshold(probMap, mask, 0.3, 255, ThresholdTypes.Binary);
var contours = new Point[][] { };
var hierarchy = new HierarchyIndex[] { };
Cv2.FindContours(mask, out contours, out hierarchy,
RetrievalModes.External, ContourApproximationModes.ApproxSimple);
foreach (var contour in contours)
{
var rect = Cv2.MinAreaRect(contour);
// 过滤掉短边过小、宽高比异常的框
}
这里的0.3是检测阈值,经验值。太大会漏掉浅色水印字,太小会把背景纹路也当成文字。找到的连通域还要做一次过滤:身份证正常文本块的高度和宽度有固定量级,如果框的短边小于几个像素,或者宽高比异常,大概率是干扰项。真正的文本框可能有倾斜角,所以用 MinAreaRect 得到最小外接矩形,后续靠四个顶点做透视矫正。
3.3 识别阶段:裁文字块、保持宽高比缩放、CTC解码
识别模型的输入不是正方形,而是高度固定、宽度随文字长度变化的矩形。把检测框裁出来后,先做透视矫正,把倾斜的文本块变成水平正立的图片,再缩放到模型输入尺寸:
public static Mat PrepareForRecognition(Mat crop, int targetHeight = 48)
{
int targetWidth = (int)Math.Round(crop.Width * (targetHeight / (float)crop.Height));
targetWidth = Math.Max(targetWidth, 16);
var resized = new Mat();
Cv2.Resize(crop, resized, new Size(targetWidth, targetHeight), 0, 0, InterpolationFlags.Linear);
var rgb = new Mat();
Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB);
return rgb;
}
targetHeight要看模型输入,常见的是32或48。如果ONNX模型输入维度是 [1,3,48,-1] 就用48, [1,3,32,-1] 就用32。识别模型的归一化参数和检测模型不一样,通常是用mean=0.5、std=0.5,也就是把像素从0到255归一化到-1到1。填张量并运行推理:
var input = new DenseTensor<float>(new[] { 1, 3, targetHeight, targetWidth });
for (int c = 0; c < 3; c++)
{
for (int h = 0; h < targetHeight; h++)
{
for (int w = 0; w < targetWidth; w++)
{
var v = rgb.At<Vec3b>(h, w);
input[0, c, h, w] = (v[2 - c] / 255f - 0.5f) / 0.5f;
}
}
}
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("x", input)
};
using var outputs = recSession.Run(inputs);
var recOutput = outputs.First().AsTensor<float>();
2 - c 和归一化参数都要和检测模型区分开。识别模型的输出通常是 [batch, seqLen, numClasses] ,也可能是 [batch, numClasses, seqLen] 。拿到输出后先看Dimension,再决定要不要转置。解码取每个时间步概率最大的类别,去掉空白符和相邻重复字符,再对照字典映射回汉字:
var keys = File.ReadAllLines("Models/ppocr_keys_v1.txt", Encoding.UTF8);
var sb = new StringBuilder();
int lastIdx = -1;
for (int t = 0; t < seqLen; t++)
{
int maxIdx = 0;
for (int c = 0; c < numClasses; c++)
{
if (recOutput[0, t, c] > recOutput[0, maxIdx, c]) maxIdx = c;
}
if (maxIdx != 0 && maxIdx != lastIdx)
sb.Append(keys[maxIdx - 1]);
lastIdx = maxIdx;
}
maxIdx等于0代表CTC的blank,也就是空白帧。过滤掉相邻重复是因为CTC解码天然会产生连续重复字符,比如“姓姓名名”要合并成“姓名”。这个过滤如果漏掉,后边解析身份证字段时会出现大量脏数据。谁在这里偷懒,谁就等着被长尾样本折磨。
4. 从OCR文本到身份证字段:结构化解析与结果回调
前一章做完,你得到的是图片里所有文字行和对应的坐标。身份证识别要交付的不是一堆字符串,而是姓名、性别、民族、出生、住址、公民身份号码这些结构化字段。所以还需要单独一层解析代码,专门处理身份证板式。
4.1 用正则还是规则锚点:身份证字段为什么不能只靠正则
有人会把OCR结果拼起来,再用一个超长正则提取身份证号。这样确实能拿身份证号,但姓名和住址很难靠正则在长文本里稳定定位。更可行的做法是“先找锚点文本,再取锚点右侧的值”。身份证正面关键词非常固定:姓名、性别、民族、出生、住址、公民身份号码。即便OCR把“姓名”识别成“姓名”或“性名”,这几个字的相似度仍然很高,可以容错匹配。
正则只适合做格式校验和清洗。比如身份证号 110101199003071234 ,第7到14位是出生日期,倒数第二位表示性别,最后一位可能是X。这些规则用来判断模型输出的身份证号是否可疑,但不能帮你在图片里定位字段。先锚点、后正则,是身份证解析的基本顺序。
4.2 字段提取实现:关键词锚点、文本行聚类和值清洗
先定义一个结果类,把原始文本和置信度一起存下来:
public sealed class IdCardRecord
{
public string Name { get; set; } = "";
public string Gender { get; set; } = "";
public string Nation { get; set; } = "";
public string Birth { get; set; } = "";
public string Address { get; set; } = "";
public string IdNumber { get; set; } = "";
public string RawText { get; set; } = "";
public double Confidence { get; set; }
}
解析时先按检测框中心点的y坐标把文字行排序。身份证字段是上下排列,同一行文字的y坐标接近,按这个规则聚类成行:
var rows = new List<List<OcrWord>>();
foreach (var word in words.OrderBy(w => w.Box.Center.Y))
{
var lastRow = rows.LastOrDefault();
if (lastRow != null && Math.Abs(word.Box.Center.Y - lastRow[0].Box.Center.Y) < 12)
lastRow.Add(word);
else
rows.Add(new List<OcrWord> { word });
}
12这个阈值代表同一行文字中心的垂直偏移。身份证字体字号固定,在全分辨率扫描图里大概是12像素左右;如果图片被缩放过,这个阈值必须按原图高度缩放,不能写死。行分完之后,同一行内的文字按x坐标从左到右排列,就能看到类似“姓名 张三”这样的结构。
接下来做锚点匹配和值提取:
foreach (var row in rows)
{
string left = string.Join("", row.OrderBy(w => w.Box.Left).Select(w => w.Text));
if (left.StartsWith("姓名"))
result.Name = CleanValue(left.Replace("姓名", ""));
else if (left.StartsWith("性别"))
result.Gender = left.Contains("男") ? "男" : "女";
else if (left.StartsWith("民族"))
result.Nation = left.Replace("民族", "");
else if (left.StartsWith("出生"))
result.Birth = left.Replace("出生", "")
.Replace("年", "-").Replace("月", "-").Replace("日", "");
else if (left.StartsWith("住址"))
result.Address = left.Replace("住址", "");
else if (left.Contains("公民身份号码"))
result.IdNumber = CleanIdNumber(left);
}
CleanValue 和 CleanIdNumber 是两个清洗函数。 CleanIdNumber 尤其重要:先只保留数字和大写X,再把大写O替换成0、大写I替换成1,最后用身份证校验位算法验证。如果校验失败,就把该字段标记为低置信度,而不是硬给一个可能错误的结果。身份证字段里不含英文字母,所以这种暴力替换是安全的。
4.3 用事件而不是直接改UI:C#上位机拿到识别结果的标准姿势
识别一张证件图通常需要几百毫秒到一秒。在C#上位机里,如果直接在主线程调用,界面会卡死,摄像头预览也会断。正确做法是把识别放到后台线程,完成后再通过事件把结果交给UI层:
public event EventHandler<IdCardRecord> Recognized;
public void RecognizeAsync(string imagePath)
{
Task.Run(() =>
{
var record = Recognize(imagePath);
Recognized?.Invoke(this, record);
});
}
调用方在事件里更新Label或TextBox时,要用BeginInvoke回到UI线程。C#的委托和事件就是为这种异步回调场景准备的,这也是上位机开发里的标准姿势。不要在工作线程里直接访问控件,否则跨线程异常会时有时无,排查起来非常难受。如果识别任务是持续不断的,建议再加一个有上限的TaskQueue,控制并发数,避免识别把CPU全部占满。
5. 身份证识别避坑指南:反光、字符混叠、并发调用的5个排查记录
这一章是血泪经验。很多问题不在模型,而在把模型放进C#工程后的工程化细节。先准备三件套,再讲具体踩坑记录。
5.1 先装好三件套再谈识别率:可视化、日志、错误样本备份
第一件是“可视化调试开关”:在源代码里加一个配置项,打开后把检测框、旋转方向和识别文本画在原图上,输出到debug目录。看一眼就明白是哪个环节出问题。第二件是结构化日志:记录识别耗时、每个字段的置信度、模型输入分辨率,而不是只记录“成功”或“失败”。第三件是错误样本备份:识别失败或置信度低于0.8的图片自动另存到单独文件夹,后续微调模型或后处理时,靠的就是这些样本。
这三件套解决的最大问题是“黑匣子”。OCR不像普通接口调用,失败原因可能来自光照、像素、检测框偏移、字典缺失。没有可视化和日志,你只能靠猜,这是最浪费时间的事。
5.2 五个高频踩坑记录(现象 / 原因 / 解决)
现象1:身份证号码里的“0”被识别成“O”,“1”被识别成“I”,校验位怎么算都不对。 原因:通用中文识别模型对单独印刷数字的区分度不够,OCR结果里数字和大写字母存在系统性混淆。身份证号是纯数字加结尾X,但识别器不知道这个约束。 解决:清洗身份证号时,先限制字符集为 [0-9X] ,把O映射成0、I映射成1、L映射成1、Z映射成2,再用身份证最后一位校验码规则纠错。如果替换后校验值仍然不对,说明还有字符错位,就把该字段标记为低置信度,不要硬给结果。
现象2:竖放或旋转180度的身份证,识别出来的字段全乱。 原因:拍摄端没有做倾斜检测,管线里没有接入方向分类模型,或者模型输出了180度但你没有应用旋转。 解决:检测模型得到文本框后,把每个文本框送入方向分类器。分类器输出0度或180度,如果是180度就旋转后再送识别模型。对整张证件照,也可以先做透视矫正:取身份证外边框四角,用 Cv2.GetPerspectiveTransform 变换成横版正图。文本行水平了,识别模型会轻松很多。
现象3:多线程批量识别时,C#进程偶发崩溃,C++侧返回access violation c0000005。 原因:多个线程同时调用同一个InferenceSession实例,或者多个线程同时操作同一个OpenCV Mat底层的非托管指针。OpenCvSharp对象如果被提前释放,再访问就会触发访问违例。 解决:最简单做法是给识别管线加读写锁,保证同一时间只有一个线程进入推理。如果要求高并发,按线程数创建多个Session实例,每个线程用自己的Session。永远不要手动释放Mat后继续使用,这是C#和C++互操作里最容易翻车的地方。
现象4:识别结果里住址字段后面跟着“有效期限”或“签发机关”的文字。 原因:用户传的是身份证反面图片,或者扫描时正反面被拼在同一张图里,解析逻辑没有按证面区分。 解决:解析前先做证面判断。正面一定包含“公民身份号码”,反面一定包含“签发机关”,并且正面“住址”值后面不会出现“有效期限”。如果识别文本里有“签发机关”或“有效期限”,直接按反面处理或终止解析,并给后端一个明确的证面状态,而不是返回一个半截住址。
现象5:批量识别过程中内存占用一路飙升,处理几千张后系统卡死。 原因:循环里创建的Mat和Tensor没有释放。OpenCvSharp的Mat底层是非托管内存,GC不是及时的;ONNX Runtime的Tensor如果被长期引用,也会阻止内存释放。 解决:逻辑里不再使用的Mat立即调用Dispose,尽量用using包裹。Tensor输出只保留需要的部分,不要把原始图片数据和完整推理结果全部挂在结果对象上。批量场景每处理完一张图后检查一次内存和句柄数,这是交付前必须做的回归测试。
这五个坑并不孤立。反光导致检测框截断,截断又导致识别解码时字符混叠,最后反映在身份证号校验失败。调试时要从前到后看,先看检测画框是否完整,再看识别字符串是否合理,最后才怀疑字段解析。
6. 上线前给自己交个底:量化识别率、批量导出、守住敏感数据
6.1 用三五十张带标注图片算一次真实识别率
不要靠“感觉挺准”交付。准备30张正反面样本,每张手工标好姓名和身份证号,跑一遍统计两个硬指标:字段准确率、身份证号准确率,外加平均耗时。身份证号准确率是硬指标,因为业务系统会直接拿它做校验。如果低于99%,先调检测阈值和输入分辨率,再考虑换更大的识别模型。把这个数字写进交付说明,客户验收时你也说得清。
6.2 批量识别图片并导出Excel:写CSV比引用Excel组件更稳
批量识别后导出Excel不难,难的是在几百张甚至几万张图片的场景下稳定运行。C#直接操作Excel需要安装Office组件,服务器上很容易权限不够。更稳妥的做法是直接写出UTF-8的CSV文件,Excel双击就能打开。代码:
using var sw = new StreamWriter("result.csv", false, Encoding.UTF8);
sw.WriteLine("图片名,姓名,性别,民族,出生日期,住址,身份证号,置信度");
foreach (var row in rows)
{
sw.WriteLine($"{row.FileName},{Escape(row.Name)},{Escape(row.Gender)},{Escape(row.Nation)},{row.Birth},{Escape(row.Address)},{row.IdNumber},{row.Confidence:F2}");
}
Escape函数负责把字段里的逗号和引号转义,防止CSV格式错位。批量识别时一定要把图片路径写进结果,方便回头用可视化调试工具核对。
6.3 最后一条习惯:日志里绝不能出现完整公民身份号码
身份证号属于敏感个人信息。本地调试再方便,也不要让日志、异常信息或备份文件里出现完整明文号码。我在代码里始终做脱敏,只记录前六位和后四位。这个习惯可能让你少背一个事故责任。源代码功能再完整,也抵不过一条泄露日志带来的信任崩塌。识别成功后如果业务需要,可以在受控内存里处理完整号码,但不要写进持久化日志。
我在交付自己的识别工程时,验收标准一直用“字段准确率加身份证号准确率”两个数字,而不是笼统说“识别率挺高”。这个标准很简单,但在客户面前比“挺好用的”有说服力得多。希望帮到你。
更多推荐

所有评论(0)