feat: Code Scanner API (#1912)

* feat: CodeScanner JS API

* feat: iOS

* Use guard

* Format

* feat: Android base

* fix: Attach Surfaces

* Use isBusy var

* fix: Use separate Queue

* feat: Finish iOS types

* feat: Implement all other code types on Android

* fix: Call JS event

* fix: Pass codetypes on Android

* fix: iOS use Preview coordinate system

* docs: Add comments

* chore: Format code

* Update CameraView+AVCaptureSession.swift

* docs: Add Code Scanner docs

* docs: Update

* feat: Use lazily downloaded model on Android

* Revert changes in CameraPage

* Format

* fix: Fix empty QR codes

* Update README.md
This commit is contained in:
Marc Rousavy
2023-10-04 12:53:52 +02:00
committed by GitHub
parent 2c08e5ae78
commit 6640b72a00
36 changed files with 763 additions and 29 deletions

View File

@@ -229,6 +229,31 @@ enum CaptureError {
}
}
// MARK: - CodeScannerError
enum CodeScannerError {
case notCompatibleWithOutputs
case codeTypeNotSupported(codeType: String)
var code: String {
switch self {
case .notCompatibleWithOutputs:
return "not-compatible-with-outputs"
case .codeTypeNotSupported:
return "code-type-not-supported"
}
}
var message: String {
switch self {
case .notCompatibleWithOutputs:
return "The Code Scanner is not supported in combination with the current outputs! Either disable video or photo outputs."
case let .codeTypeNotSupported(codeType: codeType):
return "The codeType \"\(codeType)\" is not supported by the Code Scanner!"
}
}
}
// MARK: - CameraError
enum CameraError: Error {
@@ -238,6 +263,7 @@ enum CameraError: Error {
case format(_ id: FormatError)
case session(_ id: SessionError)
case capture(_ id: CaptureError)
case codeScanner(_ id: CodeScannerError)
case unknown(message: String? = nil)
var code: String {
@@ -254,6 +280,8 @@ enum CameraError: Error {
return "session/\(id.code)"
case let .capture(id: id):
return "capture/\(id.code)"
case let .codeScanner(id: id):
return "code-scanner/\(id.code)"
case .unknown:
return "unknown/unknown"
}
@@ -273,6 +301,8 @@ enum CameraError: Error {
return id.message
case let .capture(id: id):
return id.message
case let .codeScanner(id: id):
return id.message
case let .unknown(message: message):
return message ?? "An unexpected error occured."
}

View File

@@ -24,6 +24,13 @@ public class CameraQueues: NSObject {
autoreleaseFrequency: .inherit,
target: nil)
/// The serial execution queue for output processing of QR/barcodes.
@objc public static let codeScannerQueue = DispatchQueue(label: "mrousavy/VisionCamera.codeScanner",
qos: .userInteractive,
attributes: [],
autoreleaseFrequency: .inherit,
target: nil)
/// The serial execution queue for output processing of audio buffers.
@objc public static let audioQueue = DispatchQueue(label: "mrousavy/VisionCamera.audio",
qos: .userInteractive,

View File

@@ -124,6 +124,34 @@ extension CameraView {
captureSession.addOutput(videoOutput!)
}
// Code Scanner
if let codeScannerOptions = codeScannerOptions {
guard let codeScanner = try? CodeScanner(fromJsValue: codeScannerOptions) else {
invokeOnError(.parameter(.invalid(unionName: "codeScanner", receivedValue: codeScannerOptions.description)))
return
}
let metadataOutput = AVCaptureMetadataOutput()
guard captureSession.canAddOutput(metadataOutput) else {
invokeOnError(.codeScanner(.notCompatibleWithOutputs))
return
}
captureSession.addOutput(metadataOutput)
for codeType in codeScanner.codeTypes {
// swiftlint:disable:next for_where
if !metadataOutput.availableMetadataObjectTypes.contains(codeType) {
invokeOnError(.codeScanner(.codeTypeNotSupported(codeType: codeType.descriptor)))
return
}
}
metadataOutput.setMetadataObjectsDelegate(self, queue: CameraQueues.codeScannerQueue)
metadataOutput.metadataObjectTypes = codeScanner.codeTypes
if let rectOfInterest = codeScanner.regionOfInterest {
metadataOutput.rectOfInterest = rectOfInterest
}
}
if outputOrientation != .portrait {
updateOrientation()
}

View File

@@ -0,0 +1,45 @@
//
// CameraView+CodeScanner.swift
// VisionCamera
//
// Created by Marc Rousavy on 03.10.23.
// Copyright © 2023 mrousavy. All rights reserved.
//
import AVFoundation
import Foundation
extension CameraView: AVCaptureMetadataOutputObjectsDelegate {
public func metadataOutput(_: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from _: AVCaptureConnection) {
guard let onCodeScanned = onCodeScanned else {
return
}
guard !metadataObjects.isEmpty else {
return
}
// Map codes to JS values
let codes = metadataObjects.map { object in
var value: String?
if let code = object as? AVMetadataMachineReadableCodeObject {
value = code.stringValue
}
let frame = previewView.layerRectConverted(fromMetadataOutputRect: object.bounds)
return [
"type": object.type.descriptor,
"value": value as Any,
"frame": [
"x": frame.origin.x,
"y": frame.origin.y,
"width": frame.size.width,
"height": frame.size.height,
],
]
}
// Call JS event
onCodeScanned([
"codes": codes,
])
}
}

View File

@@ -27,7 +27,8 @@ private let propsThatRequireReconfiguration = ["cameraId",
"video",
"enableFrameProcessor",
"hdr",
"pixelFormat"]
"pixelFormat",
"codeScannerOptions"]
private let propsThatRequireDeviceReconfiguration = ["fps",
"lowLightBoost"]
@@ -46,6 +47,7 @@ public final class CameraView: UIView {
@objc var video: NSNumber? // nullable bool
@objc var audio: NSNumber? // nullable bool
@objc var enableFrameProcessor = false
@objc var codeScannerOptions: NSDictionary?
@objc var pixelFormat: NSString?
// props that require format reconfiguring
@objc var format: NSDictionary?
@@ -69,6 +71,7 @@ public final class CameraView: UIView {
@objc var onInitialized: RCTDirectEventBlock?
@objc var onError: RCTDirectEventBlock?
@objc var onViewReady: RCTDirectEventBlock?
@objc var onCodeScanned: RCTDirectEventBlock?
// zoom
@objc var enableZoomGesture = false {
didSet {

View File

@@ -51,6 +51,9 @@ RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString);
RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onInitialized, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onViewReady, RCTDirectEventBlock);
// Code Scanner
RCT_EXPORT_VIEW_PROPERTY(codeScannerOptions, NSDictionary);
RCT_EXPORT_VIEW_PROPERTY(onCodeScanned, RCTDirectEventBlock);
// Camera View Functions
RCT_EXTERN_METHOD(startRecording

View File

@@ -0,0 +1,94 @@
//
// AVMetadataObject.ObjectType+descriptor.swift
// VisionCamera
//
// Created by Marc Rousavy on 03.10.23.
// Copyright © 2023 mrousavy. All rights reserved.
//
import AVFoundation
import Foundation
extension AVMetadataObject.ObjectType {
init(withString string: String) throws {
switch string {
case "code-128":
self = .code128
return
case "code-39":
self = .code39
return
case "code-93":
self = .code93
return
case "codabar":
if #available(iOS 15.4, *) {
self = .codabar
} else {
throw CameraError.codeScanner(.codeTypeNotSupported(codeType: string))
}
return
case "ean-13":
self = .ean13
return
case "ean-8":
self = .ean8
return
case "itf":
self = .itf14
return
case "upc-e":
self = .upce
return
case "qr":
self = .qr
return
case "pdf-417":
self = .pdf417
return
case "aztec":
self = .aztec
return
case "data-matrix":
self = .dataMatrix
return
default:
throw EnumParserError.invalidValue
}
}
var descriptor: String {
if #available(iOS 15.4, *) {
if self == .codabar {
return "codabar"
}
}
switch self {
case .code128:
return "code-128"
case .code39:
return "code-39"
case .code93:
return "code-93"
case .ean13:
return "ean-13"
case .ean8:
return "ean-8"
case .itf14:
return "itf"
case .upce:
return "upce"
case .qr:
return "qr"
case .pdf417:
return "pdf-417"
case .aztec:
return "aztec"
case .dataMatrix:
return "data-matrix"
default:
return "unknown"
}
}
}

View File

@@ -38,6 +38,10 @@ class PreviewView: UIView {
return AVCaptureVideoPreviewLayer.self
}
func layerRectConverted(fromMetadataOutputRect rect: CGRect) -> CGRect {
return videoPreviewLayer.layerRectConverted(fromMetadataOutputRect: rect)
}
init(frame: CGRect, session: AVCaptureSession) {
super.init(frame: frame)
videoPreviewLayer.session = session

View File

@@ -0,0 +1,45 @@
//
// CodeScanner.swift
// VisionCamera
//
// Created by Marc Rousavy on 03.10.23.
// Copyright © 2023 mrousavy. All rights reserved.
//
import AVFoundation
import Foundation
class CodeScanner {
let codeTypes: [AVMetadataObject.ObjectType]
let interval: Int
let regionOfInterest: CGRect?
init(fromJsValue dictionary: NSDictionary) throws {
if let codeTypes = dictionary["codeTypes"] as? [String] {
self.codeTypes = try codeTypes.map { value in
return try AVMetadataObject.ObjectType(withString: value)
}
} else {
throw CameraError.parameter(.invalidCombination(provided: "codeScanner", missing: "codeTypes"))
}
if let interval = dictionary["interval"] as? Double {
self.interval = Int(interval)
} else {
interval = 300
}
if let regionOfInterest = dictionary["regionOfInterest"] as? NSDictionary {
guard let x = regionOfInterest["x"] as? Double,
let y = regionOfInterest["y"] as? Double,
let width = regionOfInterest["width"] as? Double,
let height = regionOfInterest["height"] as? Double else {
throw CameraError.parameter(.invalid(unionName: "regionOfInterest", receivedValue: regionOfInterest.description))
}
self.regionOfInterest = CGRect(x: x, y: y, width: width, height: height)
} else {
regionOfInterest = nil
}
}
}

View File

@@ -65,6 +65,9 @@
B8DB3BCA263DC4D8004C18D7 /* RecordingSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DB3BC9263DC4D8004C18D7 /* RecordingSession.swift */; };
B8DB3BCC263DC97E004C18D7 /* AVFileType+descriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DB3BCB263DC97E004C18D7 /* AVFileType+descriptor.swift */; };
B8E957D02A693AD2008F5480 /* CameraView+Torch.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E957CF2A693AD2008F5480 /* CameraView+Torch.swift */; };
B8FF60AC2ACC93EF009D612F /* CameraView+CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF60AB2ACC93EF009D612F /* CameraView+CodeScanner.swift */; };
B8FF60AE2ACC9731009D612F /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF60AD2ACC9731009D612F /* CodeScanner.swift */; };
B8FF60B12ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF60B02ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -152,6 +155,9 @@
B8F0825E2A6046FC00C17EB6 /* FrameProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FrameProcessor.h; sourceTree = "<group>"; };
B8F0825F2A60491900C17EB6 /* FrameProcessor.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FrameProcessor.mm; sourceTree = "<group>"; };
B8F7DDD1266F715D00120533 /* Frame.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Frame.m; sourceTree = "<group>"; };
B8FF60AB2ACC93EF009D612F /* CameraView+CodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CameraView+CodeScanner.swift"; sourceTree = "<group>"; };
B8FF60AD2ACC9731009D612F /* CodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = "<group>"; };
B8FF60B02ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVMetadataObject.ObjectType+descriptor.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -188,6 +194,7 @@
B887518025E0102000DB86D6 /* CameraView+Focus.swift */,
B887515D25E0102000DB86D6 /* CameraView+RecordVideo.swift */,
B887517125E0102000DB86D6 /* CameraView+TakePhoto.swift */,
B8FF60AB2ACC93EF009D612F /* CameraView+CodeScanner.swift */,
B887518225E0102000DB86D6 /* CameraView+Zoom.swift */,
B86400512784A23400E9D2CA /* CameraView+Orientation.swift */,
B887515F25E0102000DB86D6 /* CameraViewManager.m */,
@@ -208,6 +215,7 @@
isa = PBXGroup;
children = (
B80175EB2ABDEBD000E7DE90 /* ResizeMode.swift */,
B8FF60AD2ACC9731009D612F /* CodeScanner.swift */,
);
path = Types;
sourceTree = "<group>";
@@ -264,6 +272,7 @@
B8DB3BCB263DC97E004C18D7 /* AVFileType+descriptor.swift */,
B864004F27849A2400E9D2CA /* UIInterfaceOrientation+descriptor.swift */,
B87B11BE2A8E63B700732EBF /* PixelFormat.swift */,
B8FF60B02ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift */,
);
path = Parsers;
sourceTree = "<group>";
@@ -413,6 +422,7 @@
B887518525E0102000DB86D6 /* PhotoCaptureDelegate.swift in Sources */,
B887518B25E0102000DB86D6 /* AVCaptureDevice.Format+isBetterThan.swift in Sources */,
B8BD3BA2266E22D2006C80A2 /* Callback.swift in Sources */,
B8FF60B12ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift in Sources */,
B84760A62608EE7C004C3180 /* FrameHostObject.mm in Sources */,
B864005027849A2400E9D2CA /* UIInterfaceOrientation+descriptor.swift in Sources */,
B887518E25E0102000DB86D6 /* AVFrameRateRange+includes.swift in Sources */,
@@ -429,10 +439,12 @@
B881D35E2ABC775E009B21C8 /* AVCaptureDevice+toDictionary.swift in Sources */,
B87B11BF2A8E63B700732EBF /* PixelFormat.swift in Sources */,
B88751A625E0102000DB86D6 /* CameraViewManager.swift in Sources */,
B8FF60AC2ACC93EF009D612F /* CameraView+CodeScanner.swift in Sources */,
B80175EC2ABDEBD000E7DE90 /* ResizeMode.swift in Sources */,
B887519F25E0102000DB86D6 /* AVCaptureDevice.DeviceType+physicalDeviceDescriptor.swift in Sources */,
B8D22CDC2642DB4D00234472 /* AVAssetWriterInputPixelBufferAdaptor+initWithVideoSettings.swift in Sources */,
B84760DF2608F57D004C3180 /* CameraQueues.swift in Sources */,
B8FF60AE2ACC9731009D612F /* CodeScanner.swift in Sources */,
B8446E502ABA14C900E56077 /* CameraDevicesManager.m in Sources */,
B887519025E0102000DB86D6 /* AVCaptureDevice.Format+matchesFilter.swift in Sources */,
B887518F25E0102000DB86D6 /* AVCapturePhotoOutput+mirror.swift in Sources */,