VisionScanEngine.swift
137 lignes · 5804 octets
import UIKit import Vision import CoreImage /// Apple-native replacement for `OpenCvScanEngine`: /// `VNDetectRectanglesRequest` finds the card quad, `CIPerspectiveCorrection` warps it. /// Mirrors the Kotlin behavior: manual corners win (confidence 1), detected quad /// scores 0.8, and when nothing is found the near-full-frame fallback (2% margin) /// is warped with confidence 0.4 instead of failing. final class VisionScanEngine: ScanEngine { func scan(_ source: UIImage, manualCorners: ScanCorners?) async throws -> ScanResult { guard let cgImage = source.cgImage ?? Self.renderCGImage(source) else { throw ScanException("Image invalide — impossible de décoder la photo") } let width = CGFloat(cgImage.width) let height = CGFloat(cgImage.height) let corners: ScanCorners let confidence: Float if let manualCorners { corners = manualCorners confidence = 1 } else if let detected = await detectCardCorners(cgImage: cgImage, width: width, height: height) { corners = detected confidence = 0.8 } else { corners = Self.fullFrameCorners(width: width, height: height) confidence = 0.4 } let warped = try Self.perspectiveCorrect(cgImage: cgImage, corners: corners, imageHeight: height) return ScanResult( bitmap: warped, angleDegrees: Self.estimateSkewAngle(corners), confidence: confidence, corners: corners ) } /// Returns detected corners, or nil when no plausible quad was found /// (Kotlin `detectDocumentCorners` null -> fallback path, never fatal). private func detectCardCorners( cgImage: CGImage, width: CGFloat, height: CGFloat ) async -> ScanCorners? { await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let request = VNDetectRectanglesRequest() // Business cards: ~0.55 h/w landscape, up to square-ish when portrait. request.minimumAspectRatio = 0.3 request.maximumAspectRatio = 1.0 request.minimumSize = 0.2 request.minimumConfidence = 0.6 request.maximumObservations = 5 request.quadratureTolerance = 20 let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up) do { try handler.perform([request]) } catch { continuation.resume(returning: nil) return } guard let best = (request.results ?? []).max(by: { $0.confidence < $1.confidence }) else { continuation.resume(returning: nil) return } // Vision points are normalized, origin bottom-left -> pixels, origin top-left. func toPixels(_ p: CGPoint) -> CGPoint { CGPoint(x: p.x * width, y: (1 - p.y) * height) } continuation.resume(returning: ScanCorners( topLeft: toPixels(best.topLeft), topRight: toPixels(best.topRight), bottomRight: toPixels(best.bottomRight), bottomLeft: toPixels(best.bottomLeft) )) } } } /// Kotlin `fullFrameCorners(marginRatio = 0.02)`. static func fullFrameCorners( width: CGFloat, height: CGFloat, marginRatio: CGFloat = 0.02 ) -> ScanCorners { let mx = width * marginRatio let my = height * marginRatio let w = width - 1 let h = height - 1 return ScanCorners( topLeft: CGPoint(x: mx, y: my), topRight: CGPoint(x: w - mx, y: my), bottomRight: CGPoint(x: w - mx, y: h - my), bottomLeft: CGPoint(x: mx, y: h - my) ) } /// Kotlin `perspectiveCorrect` equivalent via CIPerspectiveCorrection. static func perspectiveCorrect( cgImage: CGImage, corners: ScanCorners, imageHeight: CGFloat ) throws -> UIImage { // ScanCorners use a top-left origin; Core Image uses bottom-left. func ciPoint(_ p: CGPoint) -> CIVector { CIVector(x: p.x, y: imageHeight - p.y) } guard let filter = CIFilter(name: "CIPerspectiveCorrection") else { throw ScanException("CIPerspectiveCorrection indisponible") } filter.setValue(CIImage(cgImage: cgImage), forKey: kCIInputImageKey) filter.setValue(ciPoint(corners.topLeft), forKey: "inputTopLeft") filter.setValue(ciPoint(corners.topRight), forKey: "inputTopRight") filter.setValue(ciPoint(corners.bottomRight), forKey: "inputBottomRight") filter.setValue(ciPoint(corners.bottomLeft), forKey: "inputBottomLeft") guard let output = filter.outputImage, let corrected = CIContext().createCGImage(output, from: output.extent) else { throw ScanException("Correction de perspective impossible") } return UIImage(cgImage: corrected) } /// Kotlin `estimateSkewAngle`: angle of the top edge, top-left-origin coordinates. static func estimateSkewAngle(_ corners: ScanCorners) -> Float { let dx = corners.topRight.x - corners.topLeft.x let dy = corners.topRight.y - corners.topLeft.y return Float(atan2(dy, dx) * 180 / .pi) } private static func renderCGImage(_ image: UIImage) -> CGImage? { guard let ciImage = image.ciImage else { return nil } return CIContext().createCGImage(ciImage, from: ciImage.extent) } }
GitRust