-
Notifications
You must be signed in to change notification settings - Fork 135
Expand file tree
/
Copy pathCardImageParser.swift
More file actions
185 lines (148 loc) · 6.06 KB
/
CardImageParser.swift
File metadata and controls
185 lines (148 loc) · 6.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
//
// Copyright (c) 2025 Adyen N.V.
//
// This file is open source and available under the MIT license. See the LICENSE file for more info.
//
import CoreImage.CIFilterBuiltins
import Foundation
import Vision
internal protocol CardImageParsing {
func parse(
image: CIImage,
completion: @escaping (CreditCard) -> Void
)
}
internal class CardImageParser: CardImageParsing {
private enum Constants {
static let expirationDateRegex = "\\d{2}\\/\\d{2,4}"
static let topCandidates = 10
static let cardNumberConfidence: Float = 0.4
static let expirationDateConfidence: Float = 0.4
}
// MARK: - Properties
private let expirationDateFormatter: ExpirationDateFormatting
private var cachedCardNumber: String?
private var cachedExpirationDate: Date?
// MARK: - Initializers
internal init(expirationDateFormatter: ExpirationDateFormatting) {
self.expirationDateFormatter = expirationDateFormatter
}
// MARK: - CardImageParsing
internal func parse(
image: CIImage,
completion: @escaping (CreditCard) -> Void
) {
guard let transformedImage = transform(image: image) else { return }
let recognizeTextRequest = VNRecognizeTextRequest()
recognizeTextRequest.recognitionLevel = .accurate
recognizeTextRequest.usesLanguageCorrection = false
let imageRequestHandler = VNImageRequestHandler(ciImage: transformedImage, options: [:])
try? imageRequestHandler.perform([recognizeTextRequest])
guard let results = recognizeTextRequest.results, !results.isEmpty else {
return
}
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
DispatchQueue.global().async {
if self.cachedCardNumber == nil {
self.cachedCardNumber = self.extractCardNumber(from: results)
}
dispatchGroup.leave()
}
dispatchGroup.enter()
DispatchQueue.global().async {
if self.cachedExpirationDate == nil {
self.cachedExpirationDate = self.extractExpirationDate(from: results)
}
dispatchGroup.leave()
}
dispatchGroup.notify(queue: .main) {
guard let cardNumber = self.cachedCardNumber,
let expirationDate = self.cachedExpirationDate else {
return
}
let card = CreditCard(number: cardNumber, expirationDate: expirationDate)
completion(card)
}
}
// MARK: - Private
private func transform(image: CIImage) -> CIImage? {
image
.applyNoiseReductionFilter()?
.applyColorControlsFilter()?
.applySharpnessEnhancementFilter()
}
private func extractCardNumber(from textObservations: [VNRecognizedTextObservation]) -> String? {
if let cachedCardNumber { return cachedCardNumber }
let cardNumberMatch = textObservations
.compactMap { $0.topCandidates(Constants.topCandidates).first }
.filter { $0.confidence > Constants.cardNumberConfidence }
.map { $0.string.replacingOccurrences(of: " ", with: "") }
.first(where: { $0.isCardNumber && isValidLuhn($0) })
guard let cardNumberMatch else { return nil }
self.cachedCardNumber = cardNumberMatch
return cardNumberMatch
}
private func extractExpirationDate(from textObservations: [VNRecognizedTextObservation]) -> Date? {
if let cachedExpirationDate { return cachedExpirationDate }
let match = textObservations
.compactMap { $0.topCandidates(Constants.topCandidates).first }
.filter { $0.confidence > Constants.expirationDateConfidence }
.compactMap { extractMatch(from: $0.string, using: Constants.expirationDateRegex) }
.first
guard let match else { return nil }
let expirationDate = expirationDateFormatter.date(from: match)
self.cachedExpirationDate = expirationDate
return expirationDate
}
private func isValidLuhn(_ number: String) -> Bool {
guard number.allSatisfy(\.isNumber) else { return false }
var sum = 0
let reversedDigits = number.reversed().map { Int(String($0))! }
for (index, digit) in reversedDigits.enumerated() {
if index % 2 == 1 {
let doubled = digit * 2
sum += (doubled > 9) ? (doubled - 9) : doubled
} else {
sum += digit
}
}
return sum % 10 == 0
}
private func extractMatch(from text: String, using regex: String) -> String? {
let regex = try? NSRegularExpression(pattern: regex)
let matches = regex?.matches(in: text, range: NSRange(text.startIndex..., in: text))
guard let match = matches?.first, let range = Range(match.range, in: text) else { return nil }
return String(text[range])
}
}
private extension String {
var isCardNumber: Bool {
let isOnlyNumbers = !isEmpty && range(of: "[^0-9]", options: .regularExpression) == nil
let isLengthValid = count >= 12 && count <= 19
return isOnlyNumbers && isLengthValid
}
}
private extension CIImage {
func applyNoiseReductionFilter() -> CIImage? {
let noiseReductionFilter = CIFilter.noiseReduction()
noiseReductionFilter.inputImage = self
noiseReductionFilter.noiseLevel = 0.02
noiseReductionFilter.sharpness = 0.4
return noiseReductionFilter.outputImage
}
func applyColorControlsFilter() -> CIImage? {
let colorControlsFilter = CIFilter.colorControls()
colorControlsFilter.inputImage = self
colorControlsFilter.brightness = 0.2
colorControlsFilter.contrast = 1.5
colorControlsFilter.saturation = 1.2
return colorControlsFilter.outputImage
}
func applySharpnessEnhancementFilter() -> CIImage? {
let sharpenFilter = CIFilter.sharpenLuminance()
sharpenFilter.inputImage = self
sharpenFilter.sharpness = 0.5
return sharpenFilter.outputImage
}
}