Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[D-0] Vision를 통해 영수증 스캔 기능 구현 #91

Merged
merged 9 commits into from
Jan 16, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,21 @@ import Core

import RxSwift

protocol CameraViewDelegate: AnyObject {
func cameraView(_ cameraView: CameraView, scanResult result: UIImage, originalImage image: UIImage)
}

final class CameraView: UIView {
weak var delegate: AVCapturePhotoCaptureDelegate?
weak var delegate: CameraViewDelegate?

private var scanFailedCount = 0 {
didSet {
if scanFailedCount > 10, !maskLayer.isHidden {
maskLayer.isHidden = true
scanFailedCount = 0
}
}
}

private let captureSession: AVCaptureSession = {
let session = AVCaptureSession()
Expand All @@ -15,6 +28,10 @@ final class CameraView: UIView {
}()

private let stillImageOutput = AVCapturePhotoOutput()
private let videoDataOutput = AVCaptureVideoDataOutput()
private let documentScanner = DocumentScanner()

private var maskLayer = CAShapeLayer()

private let videoPreviewLayer: AVCaptureVideoPreviewLayer = {
let layer = AVCaptureVideoPreviewLayer()
Expand Down Expand Up @@ -70,6 +87,18 @@ final class CameraView: UIView {
captureSession.addOutput(stillImageOutput)
}

if captureSession.canAddOutput(videoDataOutput) {
self.videoDataOutput.setSampleBufferDelegate(self, queue: .global())
captureSession.addOutput(videoDataOutput)

guard let connection = self.videoDataOutput.connection(with: AVMediaType.video),
connection.isVideoOrientationSupported else { return }

connection.videoOrientation = .portrait
}

self.layer.addSublayer(maskLayer)

// 프리뷰 레이어 설정
videoPreviewLayer.session = captureSession

Expand Down Expand Up @@ -100,11 +129,44 @@ final class CameraView: UIView {

var takePhoto: Binder<Void> {
return Binder(self) { owner, _ in
guard let delegate = owner.delegate else {
fatalError("델리게이트를 설정하세요.")
}
let settings = AVCapturePhotoSettings()
owner.stillImageOutput.capturePhoto(with: settings, delegate: delegate)
owner.stillImageOutput.capturePhoto(with: settings, delegate: owner)
}
}
}

extension CameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput,didOutput sampleBuffer: CMSampleBuffer,from connection: AVCaptureConnection) {
guard let buffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

Task {
guard let scanRect = try await documentScanner.scanDocument(imageBuffer: buffer, with: bounds) else {
scanFailedCount += 1
return
}
scanFailedCount = 0
updateMaskLayer(in: scanRect)
}
}

private func updateMaskLayer(in rect: CGRect) {
maskLayer.isHidden = false
maskLayer.frame = rect
maskLayer.cornerRadius = 10
maskLayer.borderColor = UIColor.systemBlue.cgColor
maskLayer.borderWidth = 1
maskLayer.opacity = 1
}
}

extension CameraView: AVCapturePhotoCaptureDelegate {
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
Task {
guard let imageData = photo.fileDataRepresentation(),
let originalImage = UIImage(data: imageData),
let result = await documentScanner.editImageWithScanResult(imageData) else { return }

delegate?.cameraView(self, scanResult: UIImage(ciImage: result), originalImage: originalImage)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Vision
import CoreImage

actor DocumentScanner: Sendable {
private var recentScanResult: VNRectangleObservation?

func scanDocument(imageBuffer: CVImageBuffer, with previewSize: CGRect) async throws -> CGRect? {
return try await withCheckedThrowingContinuation { [weak self] continuation in
guard let self else { continuation.resume(returning: nil); return }
let request = VNDetectRectanglesRequest { (request: VNRequest, error: Error?) in
guard let results = request.results as? [VNRectangleObservation],
let rectangleObservation = results.first else {
continuation.resume(returning: nil); return
}

Task {
await self.updateRecentScanResult(rectangleObservation)
let rect = await self.transformVisionToIOS(rectangleObservation, to: previewSize)
continuation.resume(returning: rect)
}
}

request.minimumAspectRatio = 0.2
request.maximumAspectRatio = 1.0
request.minimumConfidence = 0.8

let handler = VNImageRequestHandler(cvPixelBuffer: imageBuffer, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}

func editImageWithScanResult(_ imageData: Data) -> CIImage? {
guard let ciImage = CIImage(data: imageData)?.oriented(.right),
let recentScanResult else { return nil }

let topLeft = recentScanResult.topLeft.scaled(to: ciImage.extent.size)
let topRight = recentScanResult.topRight.scaled(to: ciImage.extent.size)
let bottomLeft = recentScanResult.bottomLeft.scaled(to: ciImage.extent.size)
let bottomRight = recentScanResult.bottomRight.scaled(to: ciImage.extent.size)

return ciImage.applyingFilter("CIPerspectiveCorrection", parameters: [
"inputTopLeft": CIVector(cgPoint: topLeft),
"inputTopRight": CIVector(cgPoint: topRight),
"inputBottomLeft": CIVector(cgPoint: bottomLeft),
"inputBottomRight": CIVector(cgPoint: bottomRight),
])
}

private func transformVisionToIOS(_ rectangleObservation: VNRectangleObservation, to previewSize: CGRect) -> CGRect {
let visionRect = rectangleObservation.boundingBox
return CGRect(
origin: CGPoint(x: CGFloat(visionRect.minX * previewSize.width), y: CGFloat((1 - visionRect.maxY) * previewSize.height)),
size: CGSize(width: visionRect.width * previewSize.width, height: visionRect.height * previewSize.height)
)
}

private func updateRecentScanResult(_ rectangleObservation: VNRectangleObservation) {
recentScanResult = rectangleObservation
}
}

private extension CGPoint {
func scaled(to size: CGSize) -> CGPoint {
return CGPoint(x: self.x * size.width,
y: self.y * size.height)
}
}

extension CVImageBuffer: @unchecked @retroactive Sendable {}

Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ final class CreateOCRLedgerReactor: Reactor {
}

enum Mutation {
case setImageData(Data?)
case setTake(Bool)
case setLoading(Bool)
case setError(MoneyMongError)
case setDestination(State.Destination)
}

struct State {
let agencyId: Int
@Pulse var imageData: Data?
@Pulse var isTook: Bool = false
@Pulse var isLoading: Bool = false
@Pulse var error: MoneyMongError?
@Pulse var destination: Destination?
Expand All @@ -44,32 +44,32 @@ final class CreateOCRLedgerReactor: Reactor {

func mutate(action: Action) -> Observable<Mutation> {
switch action {
case .onAppear:
.just(.setImageData(nil))
case .receiptShoot(let data):
.concat([
.just(.setImageData(data)),
.just(.setTake(true)),
.just(.setLoading(true)),
requsetOCR(data),
.just(.setLoading(false))
])
case let .onError(error):
.just(.setError(error))
case .onAppear:
.just(.setTake(false))
}
}

func reduce(state: State, mutation: Mutation) -> State {
var newState = state
newState.error = nil
switch mutation {
case let .setImageData(data):
newState.imageData = data
case let .setLoading(isLoading):
newState.isLoading = isLoading
case let .setError(error):
newState.error = error
case let .setDestination(destination):
newState.destination = destination
case let .setTake(isTook):
newState.isTook = isTook
}
return newState
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,16 +184,10 @@ final class CreateOCRLedgerVC: UIViewController, View {
.bind(to: reactor.action)
.disposed(by: disposeBag)

reactor.pulse(\.$imageData)
.map { $0 != nil ? UIImage(data: $0!) : nil }
.bind(to: captureImageView.rx.image)
.disposed(by: disposeBag)

reactor.pulse(\.$imageData)
.map { $0 == nil }
reactor.pulse(\.$isTook)
.bind(with: self) { owner, value in
owner.captureImageView.isHidden = value
owner.guideLabel.isHidden = !value
owner.captureImageView.isHidden = !value
owner.guideLabel.isHidden = value
}
.disposed(by: disposeBag)

Expand All @@ -217,8 +211,8 @@ final class CreateOCRLedgerVC: UIViewController, View {
.alert(
title: error.errorTitle,
subTitle: error.errorDescription,
type: .onlyOkButton({ [weak self] in
self?.captureImageView.image = nil
type: .onlyOkButton({
owner.captureImageView.image = nil
})
)
)
Expand All @@ -238,9 +232,11 @@ final class CreateOCRLedgerVC: UIViewController, View {
}
}

extension CreateOCRLedgerVC: AVCapturePhotoCaptureDelegate {
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
let imageData = photo.fileDataRepresentation()
extension CreateOCRLedgerVC: CameraViewDelegate {
func cameraView(_ cameraView: CameraView, scanResult result: UIImage, originalImage image: UIImage) {
captureImageView.image = image

guard let imageData = result.jpegData(compressionQuality: 1.0) else { return }
reactor?.action.onNext(.receiptShoot(imageData))
}
}