diff --git a/.gitignore b/.gitignore index 330d167..5f8a6f8 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,6 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ + +# OS X Finder +.DS_Store diff --git a/Example/MRZScannerExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/MRZScannerExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1d94759..cf468f3 100644 --- a/Example/MRZScannerExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/MRZScannerExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,66 @@ { "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, { "identity" : "mrzparser", "kind" : "remoteSourceControl", "location" : "https://github.com/romanmazeev/MRZParser.git", "state" : { - "revision" : "2c1c4809379f081c01297f0ac92df2e94c6db54a", - "version" : "1.1.3" + "branch" : "master", + "revision" : "a39f93e35e4d8de2dceeb6103ca4089856e3c566" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "9783b58167f7618cb86011156e741cbc6f4cc864", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "state" : { + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" } } ], diff --git a/Example/MRZScannerExample/Camera.swift b/Example/MRZScannerExample/Camera.swift index 0f27c7e..0d62489 100644 --- a/Example/MRZScannerExample/Camera.swift +++ b/Example/MRZScannerExample/Camera.swift @@ -11,17 +11,20 @@ import CoreImage final class Camera: NSObject { let captureSession = AVCaptureSession() - private(set) lazy var imageStream: AsyncStream = { + var imageStream: AsyncStream { AsyncStream { continuation in imageStreamCallback = { ciImage in continuation.yield(ciImage) } } - }() - private var imageStreamCallback: ((CIImage) -> Void)? + } + private var imageStreamCallback: ((CIImage) -> Void)? private let captureDevice = AVCaptureDevice.default(for: .video) + + // TODO: Refactor to use Swift Concurrency private let sessionQueue = DispatchQueue(label: "Session queue") + private var isCaptureSessionConfigured = false private var deviceInput: AVCaptureDeviceInput? private var videoOutput: AVCaptureVideoDataOutput? diff --git a/Example/MRZScannerExample/ContentView.swift b/Example/MRZScannerExample/ContentView.swift index 54b8079..48cfa19 100644 --- a/Example/MRZScannerExample/ContentView.swift +++ b/Example/MRZScannerExample/ContentView.swift @@ -5,8 +5,8 @@ // Created by Roman Mazeev on 01/01/2023. // +import MRZScanner import SwiftUI -import MRZParser struct ContentView: View { private let dateFormatter: DateFormatter = { @@ -17,11 +17,13 @@ struct ContentView: View { }() @StateObject private var viewModel = ViewModel() + @State private var cameraRect: CGRect? + @State private var mrzRect: CGRect? var body: some View { GeometryReader { proxy in Group { - if let cameraRect = viewModel.cameraRect { + if let cameraRect { CameraView(captureSession: viewModel.captureSession) .frame(width: cameraRect.width, height: cameraRect.height) } @@ -29,23 +31,20 @@ struct ContentView: View { ZStack { Color.black.opacity(0.5) - if let mrzRect = viewModel.mrzRect { + if let mrzRect { Rectangle() .blendMode(.destinationOut) .frame(width: mrzRect.size.width, height: mrzRect.size.height) .position(mrzRect.origin) .task { - do { - try await viewModel.startMRZScanning() - } catch { - print(error.localizedDescription) - } + guard let cameraRect else { return } + + await viewModel.startMRZScanning(cameraRect: cameraRect, mrzRect: mrzRect) } } } .compositingGroup() - if let boundingRects = viewModel.boundingRects { ForEach(boundingRects.valid, id: \.self) { boundingRect in createBoundingRect(boundingRect, color: .green) @@ -57,9 +56,9 @@ struct ContentView: View { } } .onAppear { - viewModel.cameraRect = proxy.frame(in: .global) - viewModel.mrzRect = .init(origin: .init(x: proxy.size.width / 2, y: proxy.size.height / 2), - size: .init(width: proxy.size.width - 40, height: proxy.size.width / 5)) + cameraRect = proxy.frame(in: .global) + mrzRect = .init(origin: .init(x: proxy.size.width / 2, y: proxy.size.height / 2), + size: .init(width: proxy.size.width - 40, height: proxy.size.width / 5)) } } .alert(isPresented: .init(get: { viewModel.mrzResult != nil }, set: { _ in viewModel.mrzResult = nil })) { @@ -68,13 +67,15 @@ struct ContentView: View { message: Text(createAlertMessage(mrzResult: viewModel.mrzResult!)), dismissButton: .default(Text("Got it!")) { Task { - try await viewModel.startMRZScanning() + guard let cameraRect, let mrzRect else { return } + + await viewModel.startMRZScanning(cameraRect: cameraRect, mrzRect: mrzRect) } } ) } .task { - viewModel.startCamera() + await viewModel.startCamera() } .statusBarHidden() .ignoresSafeArea() @@ -87,7 +88,7 @@ struct ContentView: View { .position(rect.origin) } - private func createAlertMessage(mrzResult: MRZResult) -> String { + private func createAlertMessage(mrzResult: ParserResult) -> String { var birthdateString: String? var expiryDateString: String? diff --git a/Example/MRZScannerExample/ViewModel.swift b/Example/MRZScannerExample/ViewModel.swift index 0870cc6..575adda 100644 --- a/Example/MRZScannerExample/ViewModel.swift +++ b/Example/MRZScannerExample/ViewModel.swift @@ -8,54 +8,48 @@ import AVFoundation import SwiftUI import MRZScanner -import MRZParser import Vision +@MainActor final class ViewModel: ObservableObject { // MARK: Camera private let camera = Camera() - @Published var cameraRect: CGRect? var captureSession: AVCaptureSession { camera.captureSession } - func startCamera() { - Task { - await camera.start() - } + func startCamera() async { + await camera.start() } // MARK: Scanning - @Published var boundingRects: ScanedBoundingRects? - @Published var mrzRect: CGRect? - @Published var mrzResult: MRZResult? - - private var scanningTask: Task<(), Error>? - - func startMRZScanning() async throws { - guard let cameraRect, let mrzRect else { return } - - let correctedMRZRect = correctCoordinates(to: .leftTop, rect: mrzRect) - let roi = MRZScanner.convertRect(to: .normalizedRect, rect: correctedMRZRect, imageWidth: Int(cameraRect.width), imageHeight: Int(cameraRect.height)) - let scanningStream = MRZScanner.scanLive( - imageStream: camera.imageStream, - configuration: .init(orientation: .up, regionOfInterest: roi, minimumTextHeight: 0.1, recognitionLevel: .fast) - ) - - scanningTask = Task { - for try await liveScanningResult in scanningStream { - Task { @MainActor in - switch liveScanningResult { - case .found(let scanningResult): - boundingRects = correctBoundingRects(to: .center, rects: scanningResult.boundingRects) - mrzResult = scanningResult.result - scanningTask?.cancel() - case .notFound(let boundingRects): - self.boundingRects = correctBoundingRects(to: .center, rects: boundingRects) - } + @Published var boundingRects: ScannedBoundingRects? + @Published var mrzResult: ParserResult? + + func startMRZScanning(cameraRect: CGRect, mrzRect: CGRect) async { + do { + for try await scanningResult in camera.imageStream.scanForMRZCode( + configuration: .init( + orientation: .up, + regionOfInterest: VNNormalizedRectForImageRect( + correctCoordinates(to: .leftTop, rect: mrzRect), + Int(cameraRect.width), + Int(cameraRect.height) + ), + minimumTextHeight: 0.1, + recognitionLevel: .fast + ) + ) { + boundingRects = correctBoundingRects(to: .center, rects: scanningResult.boundingRects, mrzRect: mrzRect) + if let bestResult = scanningResult.best(repetitions: 2) { + mrzResult = bestResult + boundingRects = nil + return } } + } catch { + print(error.localizedDescription) } } @@ -66,9 +60,7 @@ final class ViewModel: ObservableObject { case leftTop } - private func correctBoundingRects(to type: CorrectionType, rects: ScanedBoundingRects) -> ScanedBoundingRects { - guard let mrzRect else { fatalError("Camera rect must be set") } - + private func correctBoundingRects(to type: CorrectionType, rects: ScannedBoundingRects, mrzRect: CGRect) -> ScannedBoundingRects { let convertedCoordinates = rects.convertedToImageRects(imageWidth: Int(mrzRect.width), imageHeight: Int(mrzRect.height)) let correctedMRZRect = correctCoordinates(to: .leftTop, rect: mrzRect) diff --git a/Package.resolved b/Package.resolved index 1d94759..cf468f3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,66 @@ { "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, { "identity" : "mrzparser", "kind" : "remoteSourceControl", "location" : "https://github.com/romanmazeev/MRZParser.git", "state" : { - "revision" : "2c1c4809379f081c01297f0ac92df2e94c6db54a", - "version" : "1.1.3" + "branch" : "master", + "revision" : "a39f93e35e4d8de2dceeb6103ca4089856e3c566" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "9783b58167f7618cb86011156e741cbc6f4cc864", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git", + "state" : { + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" } } ], diff --git a/Package.swift b/Package.swift index 35c54c4..de33b9c 100644 --- a/Package.swift +++ b/Package.swift @@ -13,15 +13,28 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/romanmazeev/MRZParser.git", .upToNextMajor(from: "1.1.3")) + .package(url: "https://github.com/romanmazeev/MRZParser.git", branch: "master"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", .upToNextMajor(from: "1.0.2")), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.1.2") ], targets: [ .target( name: "MRZScanner", - dependencies: ["MRZParser"] + dependencies: [ + "MRZParser", + .product( + name: "XCTestDynamicOverlay", + package: "xctest-dynamic-overlay" + ), + .product( + name: "Dependencies", + package: "swift-dependencies" + ) + ] ), .testTarget( name: "MRZScannerTests", - dependencies: ["MRZScanner"]), + dependencies: ["MRZScanner"], + resources: [.process("Private/TextRecognizerTests/ImageTest.png")]), ] ) diff --git a/README.md b/README.md index 6f497d6..68dfc49 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Library for scanning documents via [MRZ](https://en.wikipedia.org/wiki/Machine-r ### Swift Package Manager ```swift dependencies: [ - .package(url: "https://github.com/romanmazeev/MRZScanner.git", .upToNextMajor(from: "1.0.0")) + .package(url: "https://github.com/romanmazeev/MRZScanner.git", .upToNextMajor(from: "1.1.0")) ] ``` *The library has an SPM [dependency](https://github.com/romanmazeev/MRZParser) for MRZ code parsing.* @@ -28,17 +28,12 @@ ScanningConfiguration(orientation: .up, regionOfInterest: roi, minimumTextHeight 2. After you need to start scanning ```swift /// Live scanning -for try await scanningResult in MRZScanner.scanLive(imageStream: imageStream, configuration: configuration) { +for try await scanningResult in imageStream.scanForMRZCode(configuration: configuration) { // Handle `scanningResult` here } /// Single scanning -let scanningResult = try await MRZScanner.scanSingle(image: image, configuration: configuration) -``` - -*Also, for the convenience of transforming coordinates into a normalized form and back, there is a static method `convertRect`* -```swift -MRZScanner.convertRect(to: .normalizedRect, rect: rect, imageWidth: imageWidth, imageHeight: imageHeight) +let scanningResult = try await image.scanForMRZCode(configuration: configuration) ``` ## Example diff --git a/Sources/MRZScanner/AsyncStream+map.swift b/Sources/MRZScanner/AsyncStream+map.swift deleted file mode 100644 index d7d2cd5..0000000 --- a/Sources/MRZScanner/AsyncStream+map.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// AsyncStream+map.swift -// -// -// Created by Roman Mazeev on 29/12/2022. -// - -extension AsyncStream { - public func map(_ transform: @escaping (Self.Element) async throws -> Transformed) -> AsyncThrowingStream { - return AsyncThrowingStream { continuation in - Task { - for await element in self { - do { - continuation.yield(try await transform(element)) - } catch { - continuation.finish(throwing: error) - } - } - continuation.finish() - } - } - } -} diff --git a/Sources/MRZScanner/MRZFrequencyTracker.swift b/Sources/MRZScanner/MRZFrequencyTracker.swift deleted file mode 100644 index 498f8d7..0000000 --- a/Sources/MRZScanner/MRZFrequencyTracker.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// MRZFrequencyTracker.swift -// -// -// Created by Roman Mazeev on 13.07.2021. -// - -import MRZParser - -final class MRZFrequencyTracker { - private let frequency: Int - private var seenResults: [MRZResult: Int] = [:] - - init(frequency: Int) { - self.frequency = frequency - } - - func isResultStable(_ result: MRZResult) -> Bool { - guard let seenResultFrequency = seenResults[result] else { - seenResults[result] = 1 - return false - } - - guard seenResultFrequency + 1 < frequency else { - seenResults = [:] - return true - } - - seenResults[result]? += 1 - return false - } -} diff --git a/Sources/MRZScanner/MRZScanner.swift b/Sources/MRZScanner/MRZScanner.swift deleted file mode 100644 index b5be7f1..0000000 --- a/Sources/MRZScanner/MRZScanner.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// MRZScanner.swift -// -// -// Created by Roman Mazeev on 12.07.2021. -// - -import CoreImage -import Vision -import MRZParser - -public struct ScanningConfiguration { - let orientation: CGImagePropertyOrientation - let regionOfInterest: CGRect - let minimumTextHeight: Float - let recognitionLevel: VNRequestTextRecognitionLevel - - public init(orientation: CGImagePropertyOrientation, regionOfInterest: CGRect, minimumTextHeight: Float, recognitionLevel: VNRequestTextRecognitionLevel) { - self.orientation = orientation - self.regionOfInterest = regionOfInterest - self.minimumTextHeight = minimumTextHeight - self.recognitionLevel = recognitionLevel - } -} - -public struct MRZScanner { - public static func scanLive(imageStream: AsyncStream, configuration: ScanningConfiguration) -> AsyncThrowingStream, Error> { - let frequencyTracker = MRZFrequencyTracker(frequency: 2) - - return imageStream.map { image in - let recognizerResults = try await VisionTextRecognizer.recognize(scanningImage: image, configuration: configuration) - let validatedResults = MRZValidator.getValidatedResults(from: recognizerResults.map(\.results)) - - let boundingRects = getScannedBoundingRects(from: recognizerResults, validLines: validatedResults) - guard let parsedResult = MRZParser.init(isOCRCorrectionEnabled: true).parse(mrzLines: validatedResults.map(\.result)) else { - return .notFound(boundingRects) - } - - return frequencyTracker.isResultStable(parsedResult) - ? LiveScanningResult.found(.init(result: parsedResult, boundingRects: boundingRects)) - : .notFound(boundingRects) - } - } - - public static func scanSingle(image: CIImage, configuration: ScanningConfiguration) async throws -> ScanningResult { - let recognizerResults = try await VisionTextRecognizer.recognize(scanningImage: image, configuration: configuration) - let validatedResults = MRZValidator.getValidatedResults(from: recognizerResults.map(\.results)) - guard let parsedResult = MRZParser.init(isOCRCorrectionEnabled: true).parse(mrzLines: validatedResults.map(\.result)) else { - throw MRZScannerError.codeNotFound - } - - return .init(result: parsedResult, boundingRects: getScannedBoundingRects(from: recognizerResults, validLines: validatedResults)) - } - - private static func getScannedBoundingRects( - from results: [VisionTextRecognizer.Result], - validLines: [MRZValidator.Result] - ) -> ScanedBoundingRects { - let allBoundingRects = results.map(\.boundingRect) - let validRectIndexes = Set(validLines.map(\.index)) - - var validScannedBoundingRects: [CGRect] = [] - var invalidScannedBoundingRects: [CGRect] = [] - allBoundingRects.enumerated().forEach { - if validRectIndexes.contains($0.offset) { - validScannedBoundingRects.append($0.element) - } else { - invalidScannedBoundingRects.append($0.element) - } - } - - return .init(valid: validScannedBoundingRects, invalid: invalidScannedBoundingRects) - } - - private init() {} -} - -public extension MRZScanner { - enum RectConvertationType { - case imageRect - case normalizedRect - } - - static func convertRect(to type: RectConvertationType, rect: CGRect, imageWidth: Int, imageHeight: Int) -> CGRect { - switch type { - case .imageRect: - return VNImageRectForNormalizedRect(rect, imageWidth, imageHeight) - case .normalizedRect: - return VNNormalizedRectForImageRect(rect, imageWidth, imageHeight) - } - } -} - -enum MRZScannerError: Error { - case codeNotFound -} diff --git a/Sources/MRZScanner/MRZValidator.swift b/Sources/MRZScanner/MRZValidator.swift deleted file mode 100644 index 5a9307a..0000000 --- a/Sources/MRZScanner/MRZValidator.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// MRZValidator.swift -// -// -// Created by Roman Mazeev on 13.07.2021. -// - -import MRZParser - -struct MRZValidator { - struct Result { - /// MRZLine - let result: String - /// MRZLine boundingRect index - let index: Int - } - - static func getValidatedResults(from possibleLines: [[String]]) -> [Result] { - var validLines: [Result] = [] - - for validMRZCode in MRZFormat.allCases { - guard validLines.count < validMRZCode.linesCount else { break } - for (index, lines) in possibleLines.enumerated() { - guard validLines.count < validMRZCode.linesCount else { break } - let spaceFreeLines = lines.lazy.map { $0.filter { !$0.isWhitespace } } - guard let mostLikelyLine = spaceFreeLines.first(where: { - $0.count == validMRZCode.lineLength - }) else { continue } - validLines.append(.init(result: mostLikelyLine, index: index)) - } - - if validLines.count != validMRZCode.linesCount { - validLines = [] - } - } - return validLines - } - - private init() {} -} diff --git a/Sources/MRZScanner/Private/BoundingRectConverter.swift b/Sources/MRZScanner/Private/BoundingRectConverter.swift new file mode 100644 index 0000000..1eee9b4 --- /dev/null +++ b/Sources/MRZScanner/Private/BoundingRectConverter.swift @@ -0,0 +1,54 @@ +// +// BoundingRectConverter.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// +// + +import CoreImage +import Dependencies + +struct BoundingRectConverter: Sendable { + let convert: @Sendable (_ results: [TextRecognizer.Result], _ validLines: [Validator.Result]) -> ScannedBoundingRects +} + +extension BoundingRectConverter: DependencyKey { + static var liveValue: Self { + .init( + convert: { results, validLines in + let allBoundingRects = results.map(\.boundingRect) + let validRectIndexes = Set(validLines.map(\.index)) + + var validScannedBoundingRects: [CGRect] = [] + var invalidScannedBoundingRects: [CGRect] = [] + allBoundingRects.enumerated().forEach { + if validRectIndexes.contains($0.offset) { + validScannedBoundingRects.append($0.element) + } else { + invalidScannedBoundingRects.append($0.element) + } + } + + return .init(valid: validScannedBoundingRects, invalid: invalidScannedBoundingRects) + } + ) + } +} + +extension DependencyValues { + var boundingRectConverter: BoundingRectConverter { + get { self[BoundingRectConverter.self] } + set { self[BoundingRectConverter.self] = newValue } + } +} + +#if DEBUG +extension BoundingRectConverter: TestDependencyKey { + static var testValue: Self { + Self( + convert: unimplemented("BoundingRectConverter.convert") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Private/Parser.swift b/Sources/MRZScanner/Private/Parser.swift new file mode 100644 index 0000000..67f3f39 --- /dev/null +++ b/Sources/MRZScanner/Private/Parser.swift @@ -0,0 +1,40 @@ +// +// Parser.swift +// +// +// Created by Roman Mazeev on 02/12/2023. +// + +import Dependencies +import MRZParser + +struct Parser: Sendable { + let parse: @Sendable (_ mrzLines: [String]) -> ParserResult? +} + +extension Parser: DependencyKey { + static var liveValue: Self { + .init( + parse: { mrzLines in + MRZParser(isOCRCorrectionEnabled: true).parse(mrzLines: mrzLines) + } + ) + } +} + +extension DependencyValues { + var parser: Parser { + get { self[Parser.self] } + set { self[Parser.self] = newValue } + } +} + +#if DEBUG +extension Parser: TestDependencyKey { + static var testValue: Self { + Self( + parse: unimplemented("Parser.parse") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Private/TextRecognizer.swift b/Sources/MRZScanner/Private/TextRecognizer.swift new file mode 100644 index 0000000..02d4fc2 --- /dev/null +++ b/Sources/MRZScanner/Private/TextRecognizer.swift @@ -0,0 +1,67 @@ +// +// TextRecognizer.swift +// +// +// Created by Roman Mazeev on 13.07.2021. +// + +import CoreImage +import Dependencies +import Vision + +struct TextRecognizer: Sendable { + struct Result { + let results: [String] + let boundingRect: CGRect + } + + let recognize: @Sendable (_ configuration: ScanningConfiguration, _ scanningImage: CIImage) async throws -> [Result] +} + +extension TextRecognizer: DependencyKey { + static var liveValue: Self { + .init( + recognize: { request, scanningImage in + try await withCheckedThrowingContinuation { continuation in + let visionRequest = VNRecognizeTextRequest { request, _ in + guard let visionResults = request.results as? [VNRecognizedTextObservation] else { + return + } + + continuation.resume(returning: visionResults.map { + Result(results: $0.topCandidates(10).map(\.string), boundingRect: $0.boundingBox) + }) + } + visionRequest.regionOfInterest = request.regionOfInterest + visionRequest.minimumTextHeight = request.minimumTextHeight + visionRequest.recognitionLevel = request.recognitionLevel + visionRequest.usesLanguageCorrection = false + + do { + try VNImageRequestHandler(ciImage: scanningImage, orientation: request.orientation) + .perform([visionRequest]) + } catch { + continuation.resume(throwing: error) + } + } + } + ) + } +} + +extension DependencyValues { + var textRecognizer: TextRecognizer { + get { self[TextRecognizer.self] } + set { self[TextRecognizer.self] = newValue } + } +} + +#if DEBUG +extension TextRecognizer: TestDependencyKey { + static var testValue: Self { + Self( + recognize: unimplemented("TextRecognizer.recognize") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Private/Tracker.swift b/Sources/MRZScanner/Private/Tracker.swift new file mode 100644 index 0000000..477fcb0 --- /dev/null +++ b/Sources/MRZScanner/Private/Tracker.swift @@ -0,0 +1,46 @@ +// +// Tracker.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// + +import Dependencies + +struct Tracker: Sendable { + let updateResults: @Sendable (_ results: TrackerResult, _ result: ParserResult) -> TrackerResult +} + +extension Tracker: DependencyKey { + static var liveValue: Self { + .init( + updateResults: { results, result in + var seenResults = results + guard let seenResultFrequency = seenResults[result] else { + seenResults[result] = 1 + return seenResults + } + + seenResults[result] = seenResultFrequency + 1 + return seenResults + } + ) + } +} + +extension DependencyValues { + var tracker: Tracker { + get { self[Tracker.self] } + set { self[Tracker.self] = newValue } + } +} + +#if DEBUG +extension Tracker: TestDependencyKey { + static var testValue: Self { + Self( + updateResults: unimplemented("Tracker.updateResults") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Private/Validator.swift b/Sources/MRZScanner/Private/Validator.swift new file mode 100644 index 0000000..caa4a4e --- /dev/null +++ b/Sources/MRZScanner/Private/Validator.swift @@ -0,0 +1,64 @@ +// +// Validator.swift +// +// +// Created by Roman Mazeev on 13.07.2021. +// + +import Dependencies +import MRZParser + +struct Validator: Sendable { + struct Result { + /// MRZLine + let result: String + /// MRZLine boundingRect index + let index: Int + } + + let getValidatedResults: @Sendable (_ possibleLines: [[String]]) -> [Result] +} + +extension Validator: DependencyKey { + static var liveValue: Self { + .init( + getValidatedResults: { possibleLines in + var validLines: [Result] = [] + + for validMRZCode in MRZFormat.allCases { + guard validLines.count < validMRZCode.linesCount else { break } + for (index, lines) in possibleLines.enumerated() { + guard validLines.count < validMRZCode.linesCount else { break } + let spaceFreeLines = lines.lazy.map { $0.filter { !$0.isWhitespace } } + guard let mostLikelyLine = spaceFreeLines.first(where: { + $0.count == validMRZCode.lineLength + }) else { continue } + validLines.append(.init(result: mostLikelyLine, index: index)) + } + + if validLines.count != validMRZCode.linesCount { + validLines = [] + } + } + return validLines + } + ) + } +} + +extension DependencyValues { + var validator: Validator { + get { self[Validator.self] } + set { self[Validator.self] = newValue } + } +} + +#if DEBUG +extension Validator: TestDependencyKey { + static var testValue: Self { + Self( + getValidatedResults: unimplemented("Validator.getValidatedResults") + ) + } +} +#endif diff --git a/Sources/MRZScanner/Public/Scanner.swift b/Sources/MRZScanner/Public/Scanner.swift new file mode 100644 index 0000000..dc8db57 --- /dev/null +++ b/Sources/MRZScanner/Public/Scanner.swift @@ -0,0 +1,101 @@ +// +// Scanner.swift +// +// +// Created by Roman Mazeev on 12.07.2021. +// + +import CoreImage +import Dependencies +import Vision + +public struct ScanningConfiguration { + let orientation: CGImagePropertyOrientation + let regionOfInterest: CGRect + let minimumTextHeight: Float + let recognitionLevel: VNRequestTextRecognitionLevel + + public init(orientation: CGImagePropertyOrientation, regionOfInterest: CGRect, minimumTextHeight: Float, recognitionLevel: VNRequestTextRecognitionLevel) { + self.orientation = orientation + self.regionOfInterest = regionOfInterest + self.minimumTextHeight = minimumTextHeight + self.recognitionLevel = recognitionLevel + } +} + +// MARK: Image stream scanning + +public extension AsyncStream { + func scanForMRZCode( + configuration: ScanningConfiguration, + scanningPriority: TaskPriority? = nil + ) -> AsyncThrowingStream, Error> { + .init { continuation in + let scanningTask = Task { + let seenResults: LockIsolated = .init([:]) + + for await image in self { + await withTaskGroup(of: Void.self) { group in + _ = group.addTaskUnlessCancelled(priority: scanningPriority) { + do { + @Dependency(\.textRecognizer) var textRecognizer + let recognizerResult = try await textRecognizer.recognize(configuration, image) + + @Dependency(\.validator) var validator + let validatedResults = validator.getValidatedResults(recognizerResult.map(\.results)) + + @Dependency(\.boundingRectConverter) var boundingRectConverter + let boundingRects = boundingRectConverter.convert(recognizerResult, validatedResults) + + @Dependency(\.parser) var parser + guard let parsedResult = parser.parse(validatedResults.map(\.result)) else { + continuation.yield(.init(results: seenResults.value, boundingRects: boundingRects)) + return + } + + @Dependency(\.tracker) var tracker + seenResults.withValue { + $0 = tracker.updateResults(seenResults.value, parsedResult) + } + + continuation.yield(.init(results: seenResults.value, boundingRects: boundingRects)) + } catch { + continuation.finish(throwing: error) + } + } + } + } + } + + continuation.onTermination = { _ in + scanningTask.cancel() + } + } + } +} + +// MARK: Single image scanning + +public extension CIImage { + enum ScanningError: Error { + case codeNotFound + } + + func scanForMRZCode(configuration: ScanningConfiguration) async throws -> ScanningResult { + @Dependency(\.textRecognizer) var textRecognizer + let recognizerResult = try await textRecognizer.recognize(configuration, self) + + @Dependency(\.validator) var validator + let validatedResults = validator.getValidatedResults(recognizerResult.map(\.results)) + + @Dependency(\.boundingRectConverter) var boundingRectConverter + let boundingRects = boundingRectConverter.convert(recognizerResult, validatedResults) + + @Dependency(\.parser) var parser + guard let parsedResult = parser.parse(validatedResults.map(\.result)) else { + throw ScanningError.codeNotFound + } + + return .init(results: parsedResult, boundingRects: boundingRects) + } +} diff --git a/Sources/MRZScanner/Public/ScanningResult.swift b/Sources/MRZScanner/Public/ScanningResult.swift new file mode 100644 index 0000000..7d47ed8 --- /dev/null +++ b/Sources/MRZScanner/Public/ScanningResult.swift @@ -0,0 +1,74 @@ +// +// ScanningResult.swift +// +// +// Created by Roman Mazeev on 29/12/2022. +// + +import CoreGraphics +import Vision +import MRZParser + +public typealias TrackerResult = [ParserResult: Int] +public typealias ParserResult = MRZResult + +extension ParserResult: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(format) + hasher.combine(documentType) + hasher.combine(documentTypeAdditional) + hasher.combine(countryCode) + hasher.combine(surnames) + hasher.combine(givenNames) + hasher.combine(documentNumber) + hasher.combine(nationalityCountryCode) + hasher.combine(birthdate) + hasher.combine(sex) + hasher.combine(expiryDate) + hasher.combine(optionalData) + hasher.combine(optionalData2) + } + + public static func == (lhs: MRZResult, rhs: MRZResult) -> Bool { + lhs.format == rhs.format && + lhs.documentType == rhs.documentType && + lhs.documentTypeAdditional == rhs.documentTypeAdditional && + lhs.countryCode == rhs.countryCode && + lhs.surnames == rhs.surnames && + lhs.givenNames == rhs.givenNames && + lhs.documentNumber == rhs.documentNumber && + lhs.nationalityCountryCode == rhs.nationalityCountryCode && + lhs.birthdate == rhs.birthdate && + lhs.sex == rhs.sex && + lhs.expiryDate == rhs.expiryDate && + lhs.optionalData == rhs.optionalData && + lhs.optionalData2 == rhs.optionalData2 + } +} + +public struct ScanningResult { + public let results: T + public let boundingRects: ScannedBoundingRects +} + +public extension ScanningResult where T == [ParserResult: Int] { + func best(repetitions: Int) -> ParserResult? { + results.max(by: { $0.value > $1.value })?.key + } +} + +public struct ScannedBoundingRects { + public let valid: [CGRect], invalid: [CGRect] + + public func convertedToImageRects(imageWidth: Int, imageHeight: Int) -> Self { + .init( + valid: valid.map { VNImageRectForNormalizedRect($0, imageWidth, imageHeight) }, + invalid: invalid.map { VNImageRectForNormalizedRect($0, imageWidth, imageHeight) } + ) + } + + public init(valid: [CGRect], invalid: [CGRect]) { + self.valid = valid + self.invalid = invalid + } +} diff --git a/Sources/MRZScanner/ScanningResult.swift b/Sources/MRZScanner/ScanningResult.swift deleted file mode 100644 index 42a242a..0000000 --- a/Sources/MRZScanner/ScanningResult.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// ScanningResult.swift -// -// -// Created by Roman Mazeev on 29/12/2022. -// - -import CoreGraphics - -public struct ScanedBoundingRects { - public let valid: [CGRect], invalid: [CGRect] - - public func convertedToImageRects(imageWidth: Int, imageHeight: Int) -> Self { - .init( - valid: valid.map { MRZScanner.convertRect(to: .imageRect, rect: $0, imageWidth: imageWidth, imageHeight: imageHeight) }, - invalid: invalid.map { MRZScanner.convertRect(to: .imageRect, rect: $0, imageWidth: imageWidth, imageHeight: imageHeight) } - ) - } - - public init(valid: [CGRect], invalid: [CGRect]) { - self.valid = valid - self.invalid = invalid - } -} - -public struct ScanningResult { - public let result: T - public let boundingRects: ScanedBoundingRects -} - -public enum LiveScanningResult { - case notFound(ScanedBoundingRects) - case found(ScanningResult) -} diff --git a/Sources/MRZScanner/VisionTextRecognizer.swift b/Sources/MRZScanner/VisionTextRecognizer.swift deleted file mode 100644 index ae206a7..0000000 --- a/Sources/MRZScanner/VisionTextRecognizer.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// TextRecognizer.swift -// -// -// Created by Roman Mazeev on 13.07.2021. -// - -import Vision -import CoreImage - -struct VisionTextRecognizer { - struct Result { - let results: [String] - let boundingRect: CGRect - } - - static func recognize(scanningImage: CIImage, configuration: ScanningConfiguration) async throws -> [Result] { - try await withCheckedThrowingContinuation { continuation in - let request = VNRecognizeTextRequest { response, _ in - guard let visionResults = response.results as? [VNRecognizedTextObservation] else { return } - continuation.resume(returning: visionResults.map { - Result(results: $0.topCandidates(10).map(\.string), boundingRect: $0.boundingBox) - }) - } - - request.regionOfInterest = configuration.regionOfInterest - request.minimumTextHeight = configuration.minimumTextHeight - request.recognitionLevel = configuration.recognitionLevel - request.usesLanguageCorrection = false - - do { - try VNImageRequestHandler(ciImage: scanningImage, orientation: configuration.orientation).perform([request]) - } catch { - continuation.resume(throwing: error) - } - } - } - - private init() {} -} diff --git a/Tests/MRZScannerTests/MRZFrequencyTrackerTests.swift b/Tests/MRZScannerTests/MRZFrequencyTrackerTests.swift deleted file mode 100644 index 17e54aa..0000000 --- a/Tests/MRZScannerTests/MRZFrequencyTrackerTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// MRZFrequencyTrackerTests.swift -// -// -// Created by Roman Mazeev on 13.07.2021. -// - -import XCTest -@testable import MRZScanner - -final class MRZFrequencyTrackerTests: XCTestCase { - private var tracker: MRZFrequencyTracker! - private let frequency = 8 - - override func setUp() { - super.setUp() - - tracker = MRZFrequencyTracker(frequency: frequency) - } - - func testOneResultFrequencyTimes() { - for _ in 0 ..< frequency - 1 { - _ = tracker.isResultStable(StubModels.firstParsedResult) - } - - XCTAssertTrue(tracker.isResultStable(StubModels.firstParsedResult)) - } - - func testOneResultOneTime() { - XCTAssertFalse(tracker.isResultStable(StubModels.firstParsedResult)) - } - - func testTwoResultsFrequencyTimes() { - _ = tracker.isResultStable(StubModels.firstParsedResult) - XCTAssertFalse(tracker.isResultStable(StubModels.firstParsedResult)) - } - - func testTwoResultFrequencyTimes() { - for _ in 0 ..< 2 { - _ = tracker.isResultStable(StubModels.firstParsedResult) - } - - for _ in 0 ..< 2 { - _ = tracker.isResultStable(StubModels.secondParsedResult) - } - - for _ in 0 ..< 3 { - _ = tracker.isResultStable(StubModels.firstParsedResult) - } - - for _ in 0 ..< 1 { - _ = tracker.isResultStable(StubModels.secondParsedResult) - } - - XCTAssertFalse(tracker.isResultStable(StubModels.firstParsedResult)) - } -} diff --git a/Tests/MRZScannerTests/MRZValidatorTests.swift b/Tests/MRZScannerTests/MRZValidatorTests.swift deleted file mode 100644 index e58c41b..0000000 --- a/Tests/MRZScannerTests/MRZValidatorTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// MRZValidatorTests.swift -// -// -// Created by Roman Mazeev on 12.07.2021. -// - -import XCTest -@testable import MRZScanner - -final class MRZValidatorTests: XCTestCase { - func testTD1CleanValidation() { - let valueToValidate = [ - ["I Bool { - lhs.result == rhs.result && lhs.index == rhs.index - } -} diff --git a/Tests/MRZScannerTests/Mocks.swift b/Tests/MRZScannerTests/Mocks.swift new file mode 100644 index 0000000..c8674db --- /dev/null +++ b/Tests/MRZScannerTests/Mocks.swift @@ -0,0 +1,70 @@ +// +// Mocks.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// + +import Foundation +@testable import MRZScanner +import Vision + +extension ParserResult { + static var mock: Self { + .init( + format: .td3, + documentType: .passport, + documentTypeAdditional: "A", + countryCode: "test", + surnames: "test", + givenNames: "test", + documentNumber: "test", + nationalityCountryCode: "test", + birthdate: .mock, + sex: .male, + expiryDate: .mock, + optionalData: "", + optionalData2: "" + ) + } +} + +extension Date { + static var mock: Self { + .init(timeIntervalSince1970: 0) + } +} + +extension ScanningConfiguration { + static func mock(roi: CGRect = .init(x: 0, y: 0, width: 1, height: 1)) -> Self { + .init( + orientation: .up, + regionOfInterest: roi, + minimumTextHeight: 0, + recognitionLevel: .fast + ) + } +} + +extension ScannedBoundingRects: Equatable { + public static func == (lhs: ScannedBoundingRects, rhs: ScannedBoundingRects) -> Bool { + lhs.valid == rhs.valid && + lhs.invalid == rhs.invalid + } +} + +extension TextRecognizer.Result: Equatable { + public static func == (lhs: TextRecognizer.Result, rhs: TextRecognizer.Result) -> Bool { + lhs.results == rhs.results && + lhs.boundingRect == rhs.boundingRect + } +} + +extension ScanningConfiguration: Equatable { + public static func == (lhs: ScanningConfiguration, rhs: ScanningConfiguration) -> Bool { + lhs.orientation == rhs.orientation && + lhs.regionOfInterest == rhs.regionOfInterest && + lhs.minimumTextHeight == rhs.minimumTextHeight && + lhs.recognitionLevel == rhs.recognitionLevel + } +} diff --git a/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift b/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift new file mode 100644 index 0000000..67c93f4 --- /dev/null +++ b/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift @@ -0,0 +1,51 @@ +// +// BoundingRectConverterTests.swift +// +// +// Created by Roman Mazeev on 01/12/2023. +// + +@testable import MRZScanner +import XCTest + +final class BoundingRectConverterTests: XCTestCase { + func testConverterEmpty() { + let result = BoundingRectConverter.liveValue.convert([], []) + XCTAssert(result.valid.isEmpty) + XCTAssert(result.invalid.isEmpty) + } + + func testConverter() { + let firstResult = TextRecognizer.Result(results: ["test"], boundingRect: .init(x: 0, y: 0, width: 20, height: 20)) + let secondResult = TextRecognizer.Result(results: ["test"], boundingRect: .zero) + let thirdResult = TextRecognizer.Result(results: ["test"], boundingRect: .init(x: 1, y: 1, width: 40, height: 60)) + + let result = BoundingRectConverter.liveValue.convert( + [ + firstResult, + secondResult, + thirdResult + ], + [ + Validator.Result(result: "test", index: 0), + Validator.Result(result: "test", index: 1), + Validator.Result(result: "test", index: 1) + ] + ) + XCTAssertEqual(result.valid, [firstResult.boundingRect, secondResult.boundingRect]) + XCTAssertEqual(result.invalid, [thirdResult.boundingRect]) + + XCTAssertEqual( + result.convertedToImageRects(imageWidth: 10, imageHeight: 10), + .init( + valid: [ + .init(x: 0, y: 0, width: 200, height: 200), + .zero + ], + invalid: [ + .init(x: 10, y: 10, width: 400, height: 600) + ] + ) + ) + } +} diff --git a/Tests/MRZScannerTests/Private/ParserTests.swift b/Tests/MRZScannerTests/Private/ParserTests.swift new file mode 100644 index 0000000..4e3aa65 --- /dev/null +++ b/Tests/MRZScannerTests/Private/ParserTests.swift @@ -0,0 +1,57 @@ +// +// ParserTests.swift +// +// +// Created by Roman Mazeev on 02/12/2023. +// + +@testable import MRZScanner +import XCTest + +/// More tests are located in `MRZParser` library +final class ParserTests: XCTestCase { + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(abbreviation: "GMT+0:00") + return formatter + }() + + func testEmpty() { + let parser = Parser.liveValue + + let result = parser.parse([]) + XCTAssertNil(result) + } + + func testValid() throws { + let parser = Parser.liveValue + + let mrzStrings = ["IRUTOERIKSSON< Bool { + lhs.result == rhs.result && lhs.index == rhs.index + } +} diff --git a/Tests/MRZScannerTests/Public/ScannerTests.swift b/Tests/MRZScannerTests/Public/ScannerTests.swift new file mode 100644 index 0000000..359bd02 --- /dev/null +++ b/Tests/MRZScannerTests/Public/ScannerTests.swift @@ -0,0 +1,254 @@ +// +// ScannerTests.swift +// +// +// Created by Roman Mazeev on 02/12/2023. +// + +import Dependencies +@testable import MRZScanner +import XCTest + +final class MRZScannerTests: XCTestCase { + private let image = CIImage(color: .blue) + private let scanningConfiguration: ScanningConfiguration = .mock() + private let textRecognizerResults: [TextRecognizer.Result] = [.init(results: ["test"], boundingRect: .zero)] + private let validatorResults: [Validator.Result] = [.init(result: "test", index: 0)] + private let boundingRectConverterResults: ScannedBoundingRects = .init(valid: [.init(), .init()], invalid: [.init()]) + private let parserResult: ParserResult = .mock + private let trackerResult: TrackerResult = [.mock: 1] + + var validatorMock: Validator { + Validator { possibleLines in + XCTAssertEqual(self.textRecognizerResults.map(\.results), possibleLines) + return self.validatorResults + } + } + + var boundingRectConverterMock: BoundingRectConverter { + BoundingRectConverter { results, validLines in + XCTAssertEqual(results, self.textRecognizerResults) + XCTAssertEqual(validLines, self.validatorResults) + return self.boundingRectConverterResults + } + } + + func testSingleImageSuccess() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + return self.textRecognizerResults + } + + let parser = Parser { mrzLines in + XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) + return self.parserResult + } + + let scanningExpectation = expectation(description: "scanning") + Task { + await withDependencies { + $0.textRecognizer = textRecognizerMock + $0.validator = validatorMock + $0.boundingRectConverter = boundingRectConverterMock + $0.parser = parser + } operation: { + do { + let currentResult = try await image.scanForMRZCode(configuration: scanningConfiguration) + XCTAssertEqual(currentResult.results, parserResult) + XCTAssertEqual(currentResult.boundingRects, boundingRectConverterResults) + } catch { + XCTFail("Should not fail here. Error: \(error.localizedDescription)") + } + + scanningExpectation.fulfill() + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + func testSingleImageParserFailure() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + return self.textRecognizerResults + } + + let parser = Parser { mrzLines in + XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) + return nil + } + + let scanningExpectation = expectation(description: "scanning") + Task { + try await withDependencies { + $0.textRecognizer = textRecognizerMock + $0.validator = validatorMock + $0.boundingRectConverter = boundingRectConverterMock + $0.parser = parser + } operation: { + do { + _ = try await image.scanForMRZCode(configuration: scanningConfiguration) + XCTFail("Should fail here") + } catch { + XCTAssert(try XCTUnwrap(error as? CIImage.ScanningError) == .codeNotFound) + } + scanningExpectation.fulfill() + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + func testSingleImageTextRecognizerFailure() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + throw CIImage.ScanningError.codeNotFound + } + + let scanningExpectation = expectation(description: "scanning") + Task { + try await withDependencies { + $0.textRecognizer = textRecognizerMock + } operation: { + do { + _ = try await image.scanForMRZCode(configuration: scanningConfiguration) + XCTFail("Should fail here") + } catch { + XCTAssert(try XCTUnwrap(error as? CIImage.ScanningError) == .codeNotFound) + } + scanningExpectation.fulfill() + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + func testImageStreamSuccess() { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + return self.textRecognizerResults + } + + let parser = Parser { mrzLines in + XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) + return self.parserResult + } + + let tracker = Tracker { _, result in + XCTAssertEqual(result, self.parserResult) + return self.trackerResult + } + + let scanningExpectation = expectation(description: "scanning") + Task { + await withDependencies { + $0.textRecognizer = textRecognizerMock + $0.validator = validatorMock + $0.boundingRectConverter = boundingRectConverterMock + $0.parser = parser + $0.tracker = tracker + } operation: { + let resultsStream = AsyncStream { continuation in + continuation.yield(image) + continuation.finish() + } + .scanForMRZCode(configuration: scanningConfiguration) + + do { + for try await liveScanningResult in resultsStream { + XCTAssertEqual(liveScanningResult.results, trackerResult) + XCTAssertEqual(liveScanningResult.boundingRects, boundingRectConverterResults) + scanningExpectation.fulfill() + } + } catch { + XCTFail("Should not fail here. Error: \(error)") + } + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + + func testImageStreamParsingFailure() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + return self.textRecognizerResults + } + + let parser = Parser { mrzLines in + XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) + return nil + } + + let scanningExpectation = expectation(description: "scanning") + Task { + await withDependencies { + $0.textRecognizer = textRecognizerMock + $0.validator = validatorMock + $0.boundingRectConverter = boundingRectConverterMock + $0.parser = parser + } operation: { + let resultsStream = AsyncStream { continuation in + continuation.yield(image) + continuation.finish() + } + .scanForMRZCode(configuration: scanningConfiguration) + + do { + for try await liveScanningResult in resultsStream { + XCTAssertEqual(liveScanningResult.results, [:]) + XCTAssertEqual(liveScanningResult.boundingRects, boundingRectConverterResults) + scanningExpectation.fulfill() + } + } catch { + XCTFail("Should not fail here. Error: \(error)") + } + } + } + + wait(for: [scanningExpectation], timeout: 10) + } + + func testImageStreamTextRecognizerFailure() throws { + let textRecognizerMock = TextRecognizer { configuration, scanningImage in + XCTAssertEqual(self.image, scanningImage) + XCTAssertEqual(self.scanningConfiguration, configuration) + + throw CIImage.ScanningError.codeNotFound + } + + let scanningExpectation = expectation(description: "scanning") + Task { + try await withDependencies { + $0.textRecognizer = textRecognizerMock + } operation: { + let resultsStream = AsyncStream { continuation in + continuation.yield(image) + continuation.finish() + } + .scanForMRZCode(configuration: scanningConfiguration) + + do { + for try await _ in resultsStream {} + } catch { + let error = try XCTUnwrap(error as? CIImage.ScanningError) + XCTAssertEqual(error, .codeNotFound) + scanningExpectation.fulfill() + } + } + } + + wait(for: [scanningExpectation], timeout: 10) + } +} diff --git a/Tests/MRZScannerTests/StubModels.swift b/Tests/MRZScannerTests/StubModels.swift deleted file mode 100644 index 25abdd6..0000000 --- a/Tests/MRZScannerTests/StubModels.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// StubModels.swift -// -// -// Created by Roman Mazeev on 14.07.2021. -// - -@testable import MRZScanner -import MRZParser -import CoreImage - -struct StubModels { - private static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyyMMdd" - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(abbreviation: "GMT+0:00") - return formatter - }() - - static let firstParsedResult = MRZResult( - format: .td3, - documentType: .passport, - documentTypeAdditional: nil, - countryCode: "UTO", - surnames: "ERIKSSON", - givenNames: "ANNA MARIA", - documentNumber: "L898902C3", - nationalityCountryCode: "UTO", - birthdate: dateFormatter.date(from: "740812")!, - sex: .female, - expiryDate: dateFormatter.date(from: "120415")!, - optionalData: "ZE184226B", - optionalData2: nil - ) - - static let secondParsedResult = MRZResult( - format: .td2, - documentType: .id, - documentTypeAdditional: "A", - countryCode: "", - surnames: "", - givenNames: "", - documentNumber: nil, - nationalityCountryCode: "", - birthdate: nil, - sex: .male, - expiryDate: nil, - optionalData: nil, - optionalData2: nil - ) -}