
简介面向iOS开发者的银行卡OCR识别源码适用于商户进件、实名认证等需要快速填充银行卡信息的场景。项目以自定义相机为入口集成第三方静态库libexbankcardios.a与libbexbankcard.a完成银行卡号与银行名称的识别识别次数不设限制且免费同时定制了带镂空窗口和往返扫描线的扫描界面交互完整、体验直观。解压后共68个文件其中Objective-C的h/m源码与png界面资源占比最高还包含3个a识别库、plist配置、storyboard/xib界面布局以及完整Xcode工程文件整体仅6.88MB目录结构简洁清晰方便按需查看和二次加工。已有1644人学习下载适合希望快速接入银行卡识别能力或参考相机扫描交互与OCR调用细节的初中级iOS开发者拿到后可直接运行工程并对照核心代码理解识别流程。1. iOS银行卡识别OCR源码先想清楚这三件事再动手抄代码“iOS银行卡识别OCR源码”这个标题对应的是金融类 App 里最高频、也最劝退的交互绑卡、收款、进件时让用户对着 16 到 19 位卡号手工输入。我做这类功能时有个很直接的体感只要把输入方式换成“相机扫一下卡号自动带出来”绑卡环节的转化率能明显上一个台阶。但真正把这套源码落地难点往往不在 OCR 框架本身而在于采集链路、识别参数、后处理校验这三层有没有拧成一股绳。这篇按这条路把方案讲透以系统自带的 Vision 框架为主路线不依赖云端、不需要申请第三方 SDK 账号适合金融 App 开发者、外包交付团队以及想拿真机练手的独立开发者。2. 选型为什么本地 Vision 是银行卡识别的主路线云端 SDK 反而别急着接很多教程一上来就引导读者去注册云服务商账号、拿 API Key但对银行卡识别这个具体场景iOS 上有自己的合理默认值。我经手过的方案里“本地为主、云端兜底”是命中率最高的一种架构。下面三条选型判断分别对应三种项目处境照着对号入座就行。2.1 最小链路AVFoundation 与 Vision 能覆盖的采集识别闭环能覆盖的最小链路是四段请求相机权限 → 配置 AVCaptureSession → 在视频帧回调里把 CVPixelBuffer 交给 Vision → 从 VNRecognizedTextObservation 里取出候选字符串。AVFoundation 负责底层取流Vision 负责文字识别两个都是系统框架不需要引入任何第三方依赖。这条链路要按“实时视频帧”来设计而不是按拍照来设计。银行卡识别的体验上限取决于用户能不能在预览画面里实时看到卡号跳动、框体跟随。用户看到数字在变才会确信“扫上了”如果做成按下快门才识别拍完后才发现卡号被手指挡了、卡面没照全重采率会高到让产品经理直接拍桌子。所以采集层的工作重点是稳定地出帧而不是拍一张高清照片。采集层的几个关键参数值得先说清楚。sessionPreset 用 .hd1280x720 而不是 .photo因为对 Vision 来说 720p 的纹理信息已经完全够用还能省下 ISP 带宽和内存拷贝开销output.alwaysDiscardsLateVideoFrames 要设为 true它的语义是“识别线程跟不上时就丢帧”换取预览的实时性buffer 回调不要在主线程里做任何事交给独立的串行队列。这些参数不是玄学是保证 60 帧预览不卡顿的基础。整套源码我一般会按三层组织采集层CameraSession、识别层OCRService、结果层CardNumberValidator。后面第三章的代码块就是按这三层拆开的方便直接搬进自己的模块。不要写成一个上帝类把所有逻辑堆在一起不然真机调试时你根本分不清是采集慢了还是识别慢了。2.2 本地识别与云端 SDK 的取舍延迟、隐私与成本的三笔账在选择路线上我最常被问到的问题就是“百度/阿里的银行卡识别那么准为什么还要在本地做”。答案是本地方案和云 SDK 适用的项目处境完全不同单纯比准确率没有意义。下面这个表是我团队在落地时用的经验维度不是官方指标但足够做决策参考。维度本地 Vision云端 SDK单帧延迟50~120msA12 及以上机型受网络影响通常 600ms 起步隐私合规画面不出终端无敏感数据外传卡面图片上传云端需签数据处理协议调用成本无按次费用按次计费长期绑卡流程是一笔不小的支出离线可用完全可用弱网/飞行模式下直接失效复杂卡面准确率普通卡面 85%~95%烫金/强光下降常见厂商 95% 以上覆盖卡面类型更全逐条说。延迟这条最直观用户拿卡在镜头前停一下本地方案能在一两帧内给出候选结果体验是“数字跟着镜头走”云端方案至少需要一次网络往返用户会明显感觉到卡顿弱网下干脆转圈。隐私这条在金融类 App 里越来越重要卡面图片如果上传到第三方服务法务和合规大概率会介入评审周期拉长几周很常见。成本这条容易被忽略当你的日活绑卡量上来后按次计费会变成一个持续出血点而本地方案的一次性开发成本反而是可控的。那么云端 SDK 什么时候值得接当业务方明确要求识别卡面类型贷记卡/借记卡、信用卡级别、发卡行 Logo而本地 Vision 做不到这些结构化输出时云服务商现成的卡面分类模型能省不少事。我常用的做法是混合路线本地 Vision 先跑置信度和 Luhn 校验通过就直接返回失败才请求云端兜底。这个策略能覆盖绝大多数场景同时把云端调用量压到最低。2.3 识别率到顶之后什么时候该上 Core ML 专用模型必须清醒一点Vision 是通用文字识别引擎不是专门为银行卡训练的。凸起数字在强光下会产生阴影烫金卡面会反光彩色底纹和防伪图案会干扰文字分割这些场景里 VNRecognizeTextRequest 的置信度会掉到 0.6 以下识别结果开始出现系统性错乱。当你的真机回归测试里 Luhn 校验通过率长期低于 90% 时就该考虑换或者补模型了。升级路线有两条。一条是二阶段方案先用目标检测模型YOLO 类框出卡号所在区域再对裁剪后的区域做识别或数字分类。这样做的好处是砍掉了底纹干扰让识别模型只面对一行凸起数字。另一条是直接用 Vision 配合 Core ML 自定义模型训练一个小型 CNN 做数字分类输入是单字符图像块输出 0-9 的概率分布。但我不会建议一上来就上模型。模型方案的代价是标注数据、训练时长、模型体积和设备兼容性测试这四样东西在小团队里往往比写代码更耗时。合理的推进顺序是先调采集和光照压榨 Vision 的潜力再上 regionOfInterest 限制识别区域还不够才考虑 Core ML。判断指标不是“看起来准不准”而是 Luhn 通过率有没有稳定站上 90%。3. 落地从摄像头到识别结果四段可复现的 Swift 代码这一章直接给代码。下面的代码块按“采集层 → 识别层 → 坐标映射”的顺序组织每一段都能独立跑进你自己的测试工程。真机调试是必须的模拟器上 AVFoundation 的相机行为与真机差异很大很多参数只在真机上才体现得出差别。3.1 采集层用 AVFoundation 按“实时视频帧”配置会话import AVFoundation import Vision final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { private let session AVCaptureSession() private let sessionQueue DispatchQueue(label: camera.session.queue) private let visionQueue DispatchQueue(label: camera.vision.queue) func configure() throws { session.beginConfiguration() // 注意不要用 .photo1280x720 对 Vision 完全够用 session.sessionPreset .hd1280x720 guard let device AVCaptureDevice.default( .builtInWideAngleCamera, for: .video, position: .unspecified ), let input try? AVCaptureDeviceInput(device: device) else { throw CameraError.cameraUnavailable } session.addInput(input) let output AVCaptureVideoDataOutput() // 识别跟不上就丢帧优先保预览流畅 output.alwaysDiscardsLateVideoFrames true output.setSampleBufferDelegate(self, queue: sessionQueue) session.addOutput(output) session.commitConfiguration() } func startRunning() { sessionQueue.async { [weak self] in self?.session.startRunning() } } // AVCaptureVideoDataOutputSampleBufferDelegate func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let pixelBuffer CMSampleBufferGetImageBuffer(sampleBuffer) else { return } visionQueue.async { [weak self] in self?.handle(pixelBuffer: pixelBuffer) } } }这段代码的逻辑很直白configure 里建立 session把相机输入和视频数据输出接好然后由 sessionQueue 控制生命周期buffer 回调里拿到 CVPixelBuffer 后丢到 visionQueue 去做识别。关键点在于队列分离——session 的 startRunning 是阻塞操作不能在主线程直接调识别任务也要从采集线程里挪走否则会互相阻塞导致预览掉帧。参数说明sessionPreset 选 .hd1280x720 是因为银行卡识别只在画面中央一个条状区域里找数字不需要 4K 细节分辨率越高内存拷贝和格式转换开销越大。alwaysDiscardsLateVideoFrames 设为 true 后如果 Vision 处理一帧超过 16ms采集层会自动丢帧而不是堆积缓冲这个行为对实时取景至关重要。如果你发现预览卡顿优先检查是不是开了 .photo 或者把识别任务放在了 sessionQueue 上。3.2 识别层VNRecognizeTextRequest 的参数怎么设才不丢卡号import Vision final class OCRService { lazy var recognizeRequest: VNRecognizeTextRequest { let request VNRecognizeTextRequest { [weak self] request, error in guard let observations request.results as? [VNRecognizedTextObservation] else { return } self?.handle(observations: observations) } // accurate 级别对卡号识别是必须的fast 会丢掉大量低对比度字符 request.recognitionLevel .accurate // 银行卡是数字串不需要语言修正关闭后识别更稳定 request.usesLanguageCorrection false request.recognitionLanguages [zh-Hans, en-US] // 只扫画面中间一条横向区域大幅降低计算量 request.regionOfInterest CGRect(x: 0.1, y: 0.55, width: 0.8, height: 0.25) request.minimumTextHeight 0.01 return request }() func recognize(pixelBuffer: CVPixelBuffer) { let handler VNImageRequestHandler( pixelBuffer: pixelBuffer, orientation: .right // 注意竖屏后摄通常需要这个方向模拟器另说 ) try? handler.perform([recognizeRequest]) } private func handle(observations: [VNRecognizedTextObservation]) { for observation in observations { if let candidate observation.topCandidates(1).first { print(candidate.string, candidate.confidence) } } } }这段代码的核心是 recognitionLevel 和 usesLanguageCorrection 这两个参数。recognitionLevel 设成 .accurate 后 Vision 会走更重的识别管线速度大概慢一倍但对卡号这种极短文本来说完全可接受。usesLanguageCorrection 必须关掉——语言修正是给自然语言用的它会把数字串里的“0”按上下文改写成“O”或“o”对银行卡识别是纯副作用。参数说明recognitionLanguages 保持 [zh-Hans, en-US] 的顺序Vision 会按这个优先级尝试匹配卡面文字一般以数字和英文为主中文反而少见。regionOfInterest 是性能优化的关键它告诉 Vision“只要看画面中 10% 到 90% 横向、55% 到 80% 纵向这一条区域”识别面积缩小到全图的五分之一帧处理时间明显下降。如果你把卡横着拿这个矩形需要按实际姿态调整或干脆设置为全图再在结果阶段过滤。提示不要打开 usesLanguageCorrection这个开关对卡号识别只有害处。3.3 坐标映射拿到的 observation 怎么变成屏幕上看得见的框import Vision import UIKit enum CoordinateConverter { /// 把 Vision 的归一化 boundingBox 转成 previewLayer 坐标系下的 CGRect static func convert(_ box: CGRect, in view: CGRect) - CGRect { // Vision 原点在左下UIKit 原点在左上必须翻转 Y 轴 let converted CGRect( x: box.minX * view.width, y: (1 - box.maxY) * view.height, width: box.width * view.width, height: box.height * view.height ) return converted } } // 使用示例在 captureOutput 回调里拿到 observation 后回到主线程刷新 UI let visionBox observation.boundingBox // 归一化坐标origin 在左下 let previewBounds previewLayer.bounds let uiRect CoordinateConverter.convert(visionBox, in: previewBounds)这段解决的是一提就翻车的坐标系问题。Vision 返回的 boundingBox 是归一化坐标表示相对整帧图像的比例且 origin 在图像左下角而 UIKit 的视图坐标系 origin 在左上角。直接把 Vision 的 CGRect 拿去画 UIView框体一定会出现在错误位置而且竖屏横屏切换后错得更离谱。参数说明观察代码里的 y 转换逻辑是用1 - box.maxY而不是1 - box.minY这两个结果完全不同折腾过的人应该懂。另外无论 regionOfInterest 怎么设置返回的 boundingBox 都是相对整帧图像的归一化数值不需要再去乘以 regionOfInterest。如果你用 AVPreviewLayer 做预览记得把 previewLayer.videoGravity 和图像宽高比对齐否则坐标即使转换正确也会因为图层拉伸而错位。4. 从“识别出文字”到“拿到卡号”后处理比 OCR 本身更决定体验识别层拿到的只是一堆字符串里面混着有效期、持卡人姓名、银行名和卡号。如果没有后处理这层OCR 结果直接展示给用户绑卡成功率会低到怀疑人生。这一章解决“怎么从文字堆里抠出真正的卡号”。4.1 卡号候选筛选别把所有数字拼在一起func candidateCardNumbers(in text: String) - [String] { // 先把连续数字切成段避免有效期 09/28 这类片段混入 let parts text.split(whereSeparator: { !$0.isNumber }) .map { String($0) } .filter { $0.count 4 } var candidates: [String] [] // 相邻段合并凑出 16~19 位候选 var index 0 while index parts.count { var combined parts[index] var next index 1 while next parts.count combined.count parts[next].count 19 { combined parts[next] next 1 } if combined.count 16 { candidates.append(combined) } index 1 } return candidates }这段代码的逻辑是“先切段再合并最后按长度过滤”。为什么要切段因为卡面上的有效期“09/28”只有 4 位持卡人姓名里可能有数字如果不切段直接把全部数字拼在一起16 位卡的识别结果可能会变成“卡号前 16 位 有效期 4 位”的 20 位假卡号Luhn 校验必挂。参数说明合并循环里的长度上限 19 对应的是当前银行卡最长号段银联部分卡为 19 位低于 16 的直接丢弃。如果你在主扫 16 位卡为主也可以把下限提到 16但在卡号最后一位被遮挡的场景下我会允许 15 位候选进入下一轮做模糊匹配而不是直接丢弃。很多教程省略了这个容错导致少一位时整个识别失败。4.2 字符混淆与格式化0/O、1/I 这类坑怎么过字符识别混淆是银行卡识别里最明显的“血泪经验”来源。凸起数字在强光下的阴影会让 Vision 把很多字符认错我整理过一张高频混淆表按出现频率排序容易混淆的字符常见误读应对策略0O、o、口数字白名单过滤优先保留 01I、l、T卡号上下文启发式1 后面通常跟数字8B、SLuhn 校验兜底5S、6字符级替换 重新校验7T、1结合相邻数字的间距对应的处理代码用“数字白名单 替换表 Luhn 兜底”三层func normalizeCardDigits(_ raw: String) - String { let confusedMap: [Character: Character] [ O: 0, o: 0, 口: 0, I: 1, l: 1, T: 1, B: 8, S: 5, Z: 2 ] var normalized for char in raw { if char.isNumber { normalized.append(char) } else if let replacement confusedMap[char] { normalized.append(replacement) } // 其他字符直接丢弃 } return normalized } func formatCardNumber(_ raw: String) - String { let digits raw.filter { $0.isNumber } return digits.enumerated().map { index, char in index 0 index % 4 0 ? -\(char) : String(char) }.joined() }normalizeCardDigits 做的事情是把 OCR 结果里的字母按表映射回数字而不是直接当垃圾丢弃。举例来说“6222 0000 1234 5678”如果被读成“6222 OOOO 1234 5678”直接丢字符会得到 16 位数字但内容错了按表替换后能还原成正确的 0保住这一帧的识别结果。这是从“识别对了”到“最终对了”之间的一层保险。formatCardNumber 做 4-4-4-4 分组展示。需要注意 19 位卡的尾部多出 3 位不要硬套 16 位格式我一般先取前 16 位按 4-4-4-4 分组剩余 3 位单独显示或者干脆不格式化让用户自己核对。格式化在这里只是 UI 展示层的事千万不要改变实际提交的卡号字符串。4.3 Luhn 校验识别结果对不对最后一关说了算func luhnCheck(_ cardNumber: String) - Bool { let digits cardNumber.filter { $0.isNumber } .map { $0.wholeNumberValue ?? 0 } guard digits.count 16, digits.count 19 else { return false } var sum 0 for (index, digit) in digits.reversed().enumerated() { if index % 2 1 { let doubled digit * 2 sum doubled 9 ? doubled - 9 : doubled } else { sum digit } } return sum % 10 0 }Luhn 算法的实现很标准从右往左偶数位数字翻倍翻倍后大于 9 就减 9最后所有数字求和能被 10 整除则通过。这段代码的作用不只是“校验”它本身就是 OCR 结果的一道后悔药——如果某一位被错认成别的数字Luhn 大概率直接失败你就可以丢弃这帧结果继续下一帧而不是把错误的卡号提交出去。参数说明先过滤非数字再统计长度这是为了兼容 OCR 结果里残留的分隔符。16~19 的长度限制对应现有卡组织的最短最长卡号不在这个范围内的直接判无效。另外注意 reversed 后的 index 从 0 开始0 视为偶数位不翻倍这和 Luhn 的“从右侧第一位开始不翻倍”规则一致。提示Luhn 通过不代表卡号真实存在它只验证“符不符合卡号的编码规则”。要过滤仿冒卡号还得结合发卡行 IIN 前缀表做二次校验。5. 避坑银行卡识别在真机上反复翻车的 5 个现场这章写的是我在真机调试里踩过的、或者在帮别人 review 代码时见过的坑。每一条都是“现象 → 原因 → 解决”的结构你可以直接拿来做排查清单。5.1 一请求相机权限就闪退界面都没弹出来现象App 第一次启动点击扫卡按钮后没出现权限弹窗直接闪退回桌面。 原因Info.plist 里没有配置 NSCameraUsageDescription。iOS 对相机权限的描述缺失时会直接强杀进程而不是像 Android 那样给个默认提示。 解决在 Info.plist 里补上完整描述例如“用于扫描银行卡卡号”。更稳妥的做法是在触发相机前用AVCaptureDevice.authorizationStatus(for: .video)自查未授权时先用 UIAlertController 引导用户去设置页开启再初始化 session。5.2 卡号总是少一位怎么扫都差最后一位现象识别出的一串数字常常是 15 位比真实卡号少一位。用户拿实体卡对比才发现末位没了绑卡提交时校验失败。 原因卡号最后一位校验位常被卡面右下角的银联 Logo 或 Visa 全息标志遮挡另一种可能是凸起数字在强光下的阴影让 Vision 把末位的 0 认成了非数字字符被过滤规则直接丢弃。 解决过滤规则不要把 15 位候选直接判死而是进入下一轮做 IIN 前缀匹配前 6 位符合已知卡 BIN 就保留同时把图像先做一次灰度与对比度增强再交给 Vision对无效候选宁可空一帧也不返回错误结果等下一帧再来。5.3 识别框漂移画出来的框在卡号下方偏一大截现象真机上卡号识别准了但 UI 画的框和卡号实际位置对不上横屏切竖屏后更严重。 原因Vision 的 boundingBox 是归一化坐标且原点在左下UIKit 视图原点在左上直接拿 Vision 返回的 CGRect 画 UI 必定错位。竖屏变横屏时旋转后的图像方向变化叠加坐标变换错位更明显。 解决所有坐标换算收拢到一个函数里就是 3.3 的 CoordinateConverterUI 层只认转换后的 CGRectAVCaptureConnection 的 videoOrientation 要和预览层保持一致。凡是出现坐标错位先查有没有绕过这个统一出口直接拼坐标的地方。5.4 旧 iPhone 直接卡顿预览掉到每秒 10 帧现象iPhone 11 及以下机型扫卡时预览画面一顿一顿识别结果半天不出来。 原因三个配置叠加导致sessionPreset 用了 .photo 全分辨率、每帧都在主线程跑 Vision、没有设置 regionOfInterest 导致全图识别。这三个问题各自都会拖慢帧处理叠加后旧机型直接扛不住。 解决sessionPreset 降到 .hd1280x720identify 放到独立串行队列regionOfInterest 收敛到画面中央条状区域。这三个改动做完帧率能回到可接受范围。如果还卡再考虑降低 Vision 请求的 minimumTextHeight跳过过小的文字。5.5 云端 SDK 接好了弱网线下却彻底不可用现象App 在室内有 Wi-Fi 时识别正常用户一到地下车库或地铁里扫卡就转圈最终超时失败。 原因云端识别方案依赖网络弱网环境没有本地兜底用户体验直接从“秒识别”跌到“不可用”。 解决把架构改成“本地优先、云端兜底”。Vision 先跑结果通过 Luhn 校验就直接返回本地失败或置信度低于阈值时才走云端。离线场景下大不了识别不出也比永远转圈强。这个决策最好在方案设计阶段就定下来后期硬改会涉及整个调用链路的调整。6. 进阶真机调试、卡号框选与本地模型做到能上线的识别体验6.1 iOS 17 开发者模式与 Xcode 调试 iOS 15 设备的兼容问题如果你用的是 Xcode 15 以上版本配合 iOS 17 真机第一次跑工程时不再插上数据线就能直接调试。iOS 17 起设备上需要手动打开“设置 → 隐私与安全性 → 开发者模式”不打开的话 Xcode 会报设备拒绝连接看起来像驱动问题其实只是系统开关没开。另一个高频问题是 Xcode 升级后旧系统真机连不上报错“Could not locate device support files”——这是因为新版 Xcode 只带了最新系统的 DeviceSupport 文件iOS 15/16 的设备需要单独下载对应的支持文件放进 Xcode 的 DeviceSupport 目录或者保持 Xcode 版本与设备系统版本大致匹配。6.2 把卡号框选画到预览层给用户“扫到了”的即时反馈实时预览里出现一个跟随卡号移动的框是这套方案里性价比最高的体验升级。在识别结果回调里拿到 observation 后用 CoordinateConverter 转成当前预览层的 CGRect再在主线程把这个 rect 交给一个高亮的 UIView。按 iOS UI 规范框体颜色用系统蓝跟随卡面时加一个 0.12 秒的 UIView.animate 过渡避免跳变。用户看到框跟着卡走才会愿意多停一秒把卡放稳识别成功率自然上去。6.3 从 Vision 换到 Core ML 的判定指标与验证方法不要凭感觉判断“Vision 不够准”就盲目换模型。我建议用两个硬指标做决策Luhn 通过率和单帧耗时。准备至少 10 张不同发卡行、不同卡面材质哑光、烫金、磨砂的卡片在室内灯光、窗边逆光、夜间室内三种光照下各扫三次统计 Luhn 通过率。如果低于 90%且已经从采集层排除了对焦和反光问题再考虑训练专用模型。模型方案的正向收益是准确率能稳定到 98% 以上代价是要投入标注数据和时间更适合把扫卡做成核心卖点的产品。我自己第一个扫卡 demo 就栽在坐标翻转上后来把所有换算收拢到一个函数里当唯一出口这个问题才彻底绝根。早一点做这个抽象能帮你省掉大量排查时间。识别链路里每一个环节都不复杂但它们组合在一起时顺序和边界才是真正的工程难点。希望帮到你。本文还有配套的精品资源点击获取