
简介面向 iOS 开发者的银行卡 OCR 识别完整源码用于在应用内实现扫描银行卡、自动提取卡号与银行名称并截取卡片图像可直接对接实名认证、商户进件等需要快速填充卡号的业务场景。工程基于自定义相机开发集成免授权的第三方识别库识别次数无限制同时自带镂空窗口和来回移动的扫描线便于用户对准卡片。压缩包共 68 个文件以头文件与实现文件等源代码为主包含界面图片、界面布局、配置文件及静态库整体约 6.88MB解开即可在开发环境中查阅或编译。目前已有 1644 人学习下载。源码提供了完整工程目录、相机调用与识别流程、结果展示页面和版权说明适合有一定基础、希望快速接入银行卡识别能力的开发者参考并二次开发。1. iOS 银行卡识别 OCR 源码从相机取景到卡号回显的完整链路做 iOS 银行卡识别最大的坑不是 OCR 引擎选型而是你以为拿到一张卡就能直接识别。实际卡面有反光、凸字、镭射防伪、背景图案卡号位置还不固定一套流程跑下来你会发现真正决定识别率的是前处理链路而不是识别器本身。这套 iOS 银行卡 OCR 源码把相机采集、卡面定位、卡号识别、Luhn 校验、发卡行匹配串成一条完整链路不依赖云端 API离线就能跑。适合三类人想给 App 加绑卡自动填号功能的 iOS 开发、做 OCR 技术选型需要对比本地方案的工程师、以及想搞懂 AVFoundation 与 Vision 框架怎么配合的初学者。本文按这条链路的推进顺序把每个环节的参数设置与踩坑点逐一拆开。2. 为什么银行卡识别比通用 OCR 更难四个技术前提先立住2.1 银行卡识别的对象与通用文字识别有什么本质差异通用 OCR 识别的是身份证、车牌、纸质票据这类文字密度高、排版规则的目标银行卡则完全是另一种生物。卡号是 16 到 19 位数字通常每 4 位一组用空格或凸字分隔而卡面同时存在持卡人姓名、有效期、发卡行 Logo、芯片、镭射标签、签名条这些干扰元素。更麻烦的是银行卡的卡号往往是凸字压印侧光下会产生阴影阴影方向不同二值化出来的数字形态就完全不同。通用 OCR 的目标是识别尽可能多的文字银行卡识别的目标只有一个——卡号。这意味着整个流程可以高度定制先定位卡片再裁剪卡号区域最后只对数字做识别和校验。这个差异决定了你不能拿一个现成的 OCR SDK 直接跑必须自己编排链路。另一个差异是容错率。识别身份证少一个字可以人工补银行卡号错一位Luhn 校验直接挂掉后面绑卡流程全部作废。所以银行卡 OCR 的核心不是识别率而是识别结果的可信度。2.2 卡面预处理链透视矫正、灰度化、二值化、数字区域定位相机拍到的卡面几乎不可能完全正面多少会带透视形变。第一步是检测卡片边缘用 Vision 的 VNDetectRectanglesRequest 找出卡片的四个角点然后做透视变换把不规则的四边形拉成规整的矩形。这一步不做后面的 ROI 裁剪全都不准。透视矫正之后是灰度化银行卡卡号底色通常是深蓝色或黑色背景是浅色灰度化之后对比度保留得比较好。二值化是翻车重灾区。常见做法是用 Otsu 全局阈值但卡面有渐变背景时一块亮一块暗全局阈值会把暗部的数字吃掉。我一般会先用高斯模糊去掉细碎噪点再做自适应阈值而不是 Otsu。自适应阈值把图像分成小块分别计算阈值对卡面不均匀光照更稳。参数上 blockSize 取 15 到 25 比较合适C 值阈值偏移量取 2 到 5。二值化之后做水平投影把数字行找出来再做垂直投影切出每个数字的边界。投影法对凸字卡特别有效因为凸字的阴影让数字区域的像素密度明显高于周围。2.3 识别引擎选型原生 Vision 与 Tesseract 的取舍iOS 上没有太多本地 OCR 引擎可选。苹果原生 Vision 框架的 VNRecognizeTextRequest 支持中英文数字识别精度在 .accurate 级别下表现不错但它毕竟是通用场景的引擎遇到凸字卡号偶尔会把 3 认成 8、把 1 认成 7。Tesseract 的好处是可以用自定义字体训练但你需要把 C 库接进工程编译配置烦琐识别速度也一般。更稳的方案是组合拳用 Vision 识别但在此之前先做 2.2 的预处理把卡号区域单独裁剪出来再喂给 Vision。裁剪之后的图像只有数字干扰项大幅减少Vision 的识别精度会明显提升。同时配合 Luhn 校验和 BIN 表匹配做结果兜底识别置信度低就重试而不是直接提交。商业云 OCR 精度高但这套源码的定位是离线本地识别不依赖网络对用户隐私更友好也不产生接口调用费用。下面是我整理的选型对比方案精度速度集成成本离线可用原生 Vision中等偏高快低是Tesseract低需自训字体中等高是商业云 OCR高取决于网络低否需网络3. 相机采集与实时帧处理AVFoundation 通道这样搭3.1 相机会话配置分辨率选 1080p、对焦连续、曝光必须锁AVFoundation 的 AVCaptureSession 配置并不复杂真正影响识别率的是曝光和分辨率。分辨率我选 .hd1920x1080不是越高越好——4K 分辨率单帧处理耗时直接翻倍识别端的收益却很小。对焦用 .continuousAutoFocus让卡片进入画面后自动快速合焦。曝光则要单独处理自动曝光在弱光下会把卡面整体提亮凸字的阴影被抹平数字和背景的对比度断崖式下降。常见做法是等对焦稳定后用 autoExpose 让画面先正常曝光一次然后锁定曝光参数。let session AVCaptureSession() session.sessionPreset .hd1920x1080 guard let device AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let input try? AVCaptureDeviceInput(device: device) else { return } session.addInput(input) let output AVCaptureVideoDataOutput() output.videoSettings [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] output.alwaysDiscardsLateVideoFrames true session.addOutput(output) try? device.lockForConfiguration() if device.isFocusModeSupported(.continuousAutoFocus) { device.focusMode .continuousAutoFocus } if device.isExposureModeSupported(.autoExpose) { device.exposureMode .autoExpose } device.unlockForConfiguration()这段配置把视频输出格式设为 32BGRA方便后续直接用 Core Image 处理。alwaysDiscardsLateVideoFrames 设成 true处理不过来时丢弃旧帧而不是排队实时预览才不会越来越卡。曝光锁定要特别注意不是一开始就锁而是等用户把卡放进取景框、对焦完成之后才锁。如果一张卡都没进来就锁了曝光后面场景切换时画面会一片黑或一片白。3.2 实时帧采样不要每帧都跑完整管线的三个理由采集回调是高频事件每秒钟 60 帧的缓冲流如果每帧都做矩形检测加 OCRCPU 直接爆掉手机发烫降频是必然的。实际上银行卡识别根本不需要 60 fps 的识别频率——用户手持卡片从进入到识别成功一般有 1 到 3 秒的窗口识别算法只需要在这个窗口内捕捉到几帧清晰的画面就够了。常见做法是采样降频加检测前置每隔 200 毫秒取一帧做矩形检测检测到卡片后再把这一帧送进识别管线。func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { let now Date() guard now.timeIntervalSince(lastSampleTime) 0.2 else { return } lastSampleTime now guard let pixelBuffer CMSampleBufferGetImageBuffer(sampleBuffer) else { return } let ciImage CIImage(cvPixelBuffer: pixelBuffer) // 第一级只做矩形检测开销小 let rectangleRequest VNDetectRectanglesRequest { request, error in guard let result request.results?.first as? VNRectangleObservation else { return } self.processCardRect(result, frame: ciImage.extent) } rectangleRequest.minimumConfidence 0.2 rectangleRequest.maximumObservations 1 let handler VNImageRequestHandler(ciImage: ciImage, options: [:]) try? handler.perform([rectangleRequest]) }采样间隔 0.2 秒是我在 iPhone 上的经验值。太快会增加无意义的 CPU 开销太慢会错过清晰帧——用户手抖一下一张清晰的卡面可能只持续 200 毫秒。如果设备发热明显可以把间隔放到 0.25 秒识别率损失不大。minimumConfidence 降到 0.2 是给复杂卡面留余量后面用卡片的宽高比约束来过滤误检而不是把置信度下限拉高。3.3 卡号区域动态定位候选区裁剪加水平投影找数字行拿到卡片的 VNRectangleObservation 之后下一步是把卡号区域从卡面里精确切出来。行业手册里有大致规律大部分银行卡片的下半部分 35% 到 60% 区域是卡号但设计花哨的卡会把 Logo 或宣传语放在这个范围里。纯靠固定比例切误检率不低。我一般先取一个宽松的候选区再做水平投影定位数字行的精确位置。func cropCardNumberRect(from cardRect: CGRect, in image: CGImage) - CGRect? { // 候选区卡面左 5% 至 95%上 40% 至 75% 的范围内 let candidate CGRect(x: cardRect.minX cardRect.width * 0.05, y: cardRect.minY cardRect.height * 0.40, width: cardRect.width * 0.90, height: cardRect.height * 0.35) guard let cropped image.cropping(to: candidate) else { return nil } let projection horizontalProjection(of: cropped) guard let digitRow locateDigitRow(in: projection) else { return nil } return CGRect(x: candidate.minX, y: candidate.minY CGFloat(digitRow.start), width: candidate.width, height: CGFloat(digitRow.end - digitRow.start)) }水平投影的原理是把裁剪图转成灰度按行累加像素亮度数字行的像素密度明显高于空行投影图上会出现一个突出的峰值区域。locateDigitRow 会找这个峰值区域的上下边界。候选区取 40% 到 75% 的竖向范围覆盖了绝大多数物理卡面上卡号的位置。如果这个范围内找不到投影峰值这一帧大概率是卡面反光严重直接丢弃等下一帧。硬识别烂帧是识别率上不去的头号原因学会放弃比学会硬扛更重要。4. 卡号识别与后处理Luhn 校验与 BIN 匹配一个不能少4.1 Vision 文本识别参数.accurate 级别加关闭语言纠错把裁剪出的卡号 ROI 交给 Vision 时参数设置直接影响结果形态。recognitionLevel 用 .accurate虽然慢一点但数字识别的区分度远高于 .fast。recognitionLanguages 设成 [en-US]卡号是阿拉伯数字中文语言包反而可能把相邻数字识别成中文标点。usesLanguageCorrection 要设成 false——语言纠错机制会试图把识别结果拼成合法单词卡号不是单词纠错反而会把正确的数字串改坏。func recognizeCardNumber(in croppedImage: CGImage, completion: escaping (String, Float) - Void) { let request VNRecognizeTextRequest { request, error in guard let observations request.results as? [VNRecognizedTextObservation], let first observations.first, let candidate first.topCandidates(1).first else { return } completion(candidate.string, candidate.confidence) } request.recognitionLevel .accurate request.recognitionLanguages [en-US] request.usesLanguageCorrection false request.minimumTextHeight 0.05 let handler VNImageRequestHandler(cgImage: croppedImage, options: [:]) try? handler.perform([request]) }minimumTextHeight 设 0.05意思是字体高度至少占识别区域的 5%。这个值过滤掉 ROI 边缘混入的小字或噪点又不至于把正常的卡号数字滤掉。topCandidates(1) 只取置信度最高的一个结果后面你会用到这个 confidence 值做二次判断。识别结果不是拿到手就用后处理才是区分工程实现和 Demo 的分水岭。4.2 结果清洗规则去空格、抽数字、过滤杂讯Vision 返回的结果可能是 6222 0210 1234 5678也可能是 6222O21012345678 或者 6222 02ID 1234 5678——O 和 D 是 0 和 1 的误读。清洗规则要处理四件事去掉所有空格和连字符把拉丁字母映射到数字O 映射 0、I 映射 1、Z 映射 2、S 映射 5只保留纯数字字符检查长度是否在 14 到 19 位之间。不在这个范围直接丢弃。func cleanCardNumber(_ raw: String) - String? { let upper raw.uppercased() let characterMap: [Character: Character] [ O: 0, I: 1, Z: 2, S: 5, B: 8 ] var cleaned for ch in upper { if let mapped characterMap[ch] { cleaned.append(mapped) } else if ch.isNumber { cleaned.append(ch) } // 其余字符直接跳过 } guard cleaned.count 14 cleaned.count 19 else { return nil } return cleaned }字母映射这块要注意优先级如果字符串里同时出现 O 和 0映射不会破坏原本就是数字的字符。B 映射到 8 是经典的凸字误读场景——凸字的 8 在阴影下看起来像 B 的底部。长度限制是粗筛后面还有更重要的校验。这里有个实际场景银联卡大部分是 16 位和 19 位Visa 是 13 到 16 位MasterCard 是 16 位。长度不在合法范围的直接返回 nil让用户重新对准拍摄比带病往下走更省事。4.3 Luhn 校验卡号最后一位是防伪码用它拦截误识别Luhn 算法是银行卡号的最后一道格式防线原理很简单从右往左偶数位数字翻倍翻倍结果大于 9 就减 9所有数字累加后能被 10 整除则通过。这个校验能拦截掉相当一部分 OCR 误识别因为一个数字识别错了算出来的校验位大概率对不上。但它只能证明卡号格式合法不能证明卡号真实存在这是个必须明确的边界。func luhnCheck(cardNumber: String) - Bool { let digits cardNumber.compactMap { $0.wholeNumberValue } guard digits.count 14 else { return false } var sum 0 let parity digits.count % 2 for (index, digit) in digits.enumerated() { var value digit if index % 2 parity { value * 2 if value 9 { value - 9 } } sum value } return sum % 10 0 }parity 这个变量是大多数人写错的点。它取决于卡号总位数的奇偶性如果写死成 0偶数位卡号的校验结果就全反了。Luhn 通过不代表卡号真实存在所以还要配合 BIN 匹配。BIN 是卡号前 6 位代表发卡机构比如 62 开头是银联、4 开头是 Visa、5 开头是 MasterCard。工程上把 BIN 范围表做成 plist 或 SQLite匹配时先试前 8 位因为银联部分新卡发卡行标识是 8 位再退回 6 位。func matchBank(cardNumber: String, binTable: [BINInfo]) - BINInfo? { let digits Array(cardNumber) for len in [8, 6] { guard digits.count len else { continue } let prefix String(digits[0..len]) if let hit binTable.first(where: { $0.prefix prefix }) { return hit } } return nil }BIN 匹配的收益不只是告诉用户发卡行更关键的是可以交叉验证卡号类型。识别结果匹配到的 BIN 是 Visa卡片却显示银联标识那这张卡要么是伪造的要么是识别错了应该提示用户重新拍摄。BIN 表的数据量不大国内银行几千条足够用数组遍历不会成为性能瓶颈。5. 避坑与常见问题识别率上不去的五个真实原因5.1 弱光下卡号反复漏识别现象室内灯光稍暗的环境下卡号识别率从充足光照下的 90% 掉到不足 50%取景框里明明能看到卡片识别结果却一直为空。原因自动曝光把画面整体提亮凸字卡号的阴影被抹平数字与卡面的对比度降到 OCR 引擎识别阈值以下。我最早调这个项目时以为是 Vision 引擎不行换了 Tesseract 也一样后来才发现是曝光的问题。解决对焦完成后锁定曝光并把曝光补偿手动拉到 -0.5 到 -1.0。让卡面稍微偏暗凸字阴影更清晰识别率反而显著回升。这个参数可以通过 AVCaptureDevice 的 setExposureTargetBias 设置注意设完之后要重新锁定曝光。5.2 凸字卡的数字粘连导致位数误判现象Visa 凸字卡连续出现两个相邻数字被识别成一个的情况比如 62 变成 6Z或者卡号长度直接少一位。二值化图像里能看到两个数字的阴影连成一条黑带。原因凸字在侧光下产生阴影二值化之后阴影被算进数字区域相邻数字的阴影桥接在一起OCR 引擎把黏连的组件当成一个字符。这个现象在卡号第四个分组通常是 4 位一组尤其明显。解决预处理阶段加一次开运算操作用腐蚀先断开阴影桥再膨胀恢复数字原本的宽度。另外调整采集姿势让用户稍微倾斜卡片让凸字阴影落在数字的侧下方而不是数字之间。代码里对应的是 OpenCV 的 morphologyEx 操作kernel 大小取 3x3 到 5x5 之间太大会把数字本体腐蚀掉。5.3 置信度低的结果也会通过 Luhn 校验千万别直接提交现象识别出来的卡号看起来完全正常Luhn 也通过了但用户实际绑卡时银行接口返回卡号不存在。原因Luhn 只能防手滑防不了系统性的误识别。OCR 在个别数字上出错——比如 8 和 0、1 和 7——这几个错的组合恰好生成了一个能通过 Luhn 的假卡号概率不低大约在百分之几到十几之间。解决识别时把 Vision 的 confidence 值保留下来低于 0.8 的结果统一弹窗让用户确认不直接自动带入下一步。这个 0.8 是经验值你可以在自己的测试集上打点统计找到召回率和误识别率的平衡点。这不是体验问题是风控问题——绑卡成功之后才发现卡号错了用户对你的 App 的信任直接归零。5.4 模拟器上跑不通相机通道必须真机加开发者模式现象在 Xcode 模拟器上运行项目相机权限弹窗都没出现AVCaptureSession 的 startRunning() 调用之后 didStart 回调一直没有触发控制台报出访问摄像头硬件失败的错误。原因模拟器没有摄像头硬件iOS 14 之后模拟器对相机权限的处理逻辑也和真机不一致在模拟器上调试 AVFoundation 本来就是浪费时间。解决接上 iPhone 真机在项目的 Info.plist 里写好 NSCameraUsageDescription 描述文案真机上开启开发者模式后首次启动会弹出权限框。授权之后才能看到相机画面。每次改完相机相关配置都需要重新 build 到真机验证模拟器上的表现不能作为参考依据。5.5 复杂卡面背景让矩形检测直接失效现象纯色底的银行卡能稳定检出卡片矩形换成带抽象图案或渐变背景的卡VNDetectRectanglesRequest 有时候一个候选矩形都返回不了。原因矩形检测依赖边缘梯度复杂背景下卡片边缘和背景的对比度不够强默认的 minimumConfidence 是 0.5图案卡的边缘置信度可能只有 0.3。另外取景框里的其他矩形物体手机屏幕、书本封面会抢占检测结果。解决把 minimumConfidence 从 0.5 降到 0.2同时把 minimumAspectRatio 和 maximumAspectRatio 限定在 1.4 到 1.6 之间——银行卡的标准宽高比约 1.586。用比例约束替代置信度约束既能把复杂的图案卡拉回来又不会误检到手机屏幕这些非卡片矩形。6. 性能优化与验证把端到端识别耗时压进 300ms 的三个技巧识别链路跑通之后下一步是打磨耗时。银行卡识别的使用场景里用户体验最差的就是取景框里出现了卡片结果转了 2 秒还没出来——用户早就失去耐心手动输卡号了。我把目标定在端到端 300ms 以内即卡片稳定出现在取景框后到卡号回显在界面上中间不超过 300ms。这个目标靠三个手段达成。第一个技巧是帧降频加状态机。矩形检测帧频保持 5fps 左右一旦检测到卡片并稳定超过 0.2 秒立刻把这一帧送进识别管线同时暂停后续的矩形检测等识别结果出来或超时再恢复。状态机避免了一个画面被重复识别三次的资源浪费。第二个技巧是 Core Image 预处理用 GPU 管线。灰度化、高斯模糊、自适应阈值这些操作全部走 CIFilter不走 CPU 像素循环iPhone 上可以省掉将近一半耗时。第三个技巧是结果缓存。前后两帧如果卡片位置变化很小识别结果直接沿用前一帧用同一帧画面反复 OCR 是最常见的性能浪费。验证方法我推荐打点统计在代码里对预处理、矩形检测、OCR 识别、Luhn 校验四个阶段分别记录耗时测试时必须固定同一张卡、同一个距离、同一个光照条件连续测 20 次取中位数。改参数之后对比中位数变化而不是每次只看感觉快不快——识别率调试最大的陷阱就是凭感觉调曝光那是玄学不是工程。这套流程跑通之后我养成了一个习惯每次改完预处理参数或换测试卡都强制自己先跑一遍同样条件的打点序列再决定要不要收下这个改动。希望帮到你。本文还有配套的精品资源点击获取