简介:面向 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 帧的缓冲流如果每帧都做矩形检测加 OCR,CPU 直接爆掉,手机发烫降频是必然的。实际上银行卡识别根本不需要 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 次取中位数。改参数之后对比中位数变化,而不是每次只看感觉快不快——识别率调试最大的陷阱就是凭感觉调曝光,那是玄学,不是工程。这套流程跑通之后,我养成了一个习惯:每次改完预处理参数或换测试卡,都强制自己先跑一遍同样条件的打点序列,再决定要不要收下这个改动。希望帮到你。
本文还有配套的精品资源,点击获取