Section 14/151 menit

14. Real Use Cases

14. Real Use Cases

Use Case 1: Smart Document Scanner App

App untuk scan dan ekstrak informasi dari dokumen KTP, NPWP, dan faktur — semua on-device, tidak ada data yang keluar dari device.

swift
import Vision
import NaturalLanguage
import UIKit

@MainActor
class DocumentScannerViewModel: ObservableObject {

    @Published var scanResult: ScanResult?
    @Published var isProcessing = false

    struct ScanResult {
        let documentType: DocumentType
        let extractedFields: [String: String]
        let confidence: Float
        let rawText: String
    }

    enum DocumentType {
        case ktp, npwp, invoice, unknown
    }

    func processDocument(_ image: UIImage) async {
        isProcessing = true
        defer { isProcessing = false }

        guard let cgImage = image.cgImage else { return }

        // Step 1: OCR
        let textBlocks = await extractText(from: cgImage)
        let fullText = textBlocks.joined(separator: "\n")

        // Step 2: Classify document type
        let docType = classifyDocumentType(fullText)

        // Step 3: Extract fields berdasarkan type
        let fields = await extractFields(from: fullText, docType: docType)

        scanResult = ScanResult(
            documentType: docType,
            extractedFields: fields,
            confidence: 0.85,
            rawText: fullText
        )
    }

    private func extractText(from cgImage: CGImage) async -> [String] {
        await withCheckedContinuation { continuation in
            let request = VNRecognizeTextRequest { req, _ in
                let results = req.results?
                    .compactMap { $0.topCandidates(1).first?.string } ?? []
                continuation.resume(returning: results)
            }
            request.recognitionLevel = .accurate
            request.usesLanguageCorrection = true
            request.recognitionLanguages = ["id-ID", "en-US"]
            try? VNImageRequestHandler(cgImage: cgImage).perform([request])
        }
    }

    private func classifyDocumentType(_ text: String) -> DocumentType {
        let lowercased = text.lowercased()

        if lowercased.contains("nomor induk kependudukan") ||
           lowercased.contains("nik") {
            return .ktp
        } else if lowercased.contains("npwp") ||
                  lowercased.contains("nomor pokok wajib pajak") {
            return .npwp
        } else if lowercased.contains("faktur") ||
                  lowercased.contains("invoice") ||
                  lowercased.contains("total") {
            return .invoice
        }
        return .unknown
    }

    private func extractFields(from text: String, docType: DocumentType) async -> [String: String] {
        var fields: [String: String] = [:]
        let lines = text.components(separatedBy: .newlines)

        switch docType {
        case .ktp:
            // Pattern matching untuk field KTP
            for (index, line) in lines.enumerated() {
                let upper = line.uppercased()
                if upper.contains("NIK") && index + 1 < lines.count {
                    let nik = lines[index + 1].filter { $0.isNumber }
                    if nik.count == 16 { fields["NIK"] = nik }
                }
                if upper.contains("NAMA") && index + 1 < lines.count {
                    fields["Nama"] = lines[index + 1].trimmingCharacters(in: .whitespaces)
                }
                if upper.contains("TEMPAT") && upper.contains("LAHIR") {
                    // Extract tanggal lahir dari format: Tempat/Tgl Lahir: JAKARTA, 01-01-1990
                    let components = line.components(separatedBy: ":")
                    if components.count > 1 { fields["Tempat/Tgl Lahir"] = components[1].trimmingCharacters(in: .whitespaces) }
                }
            }

        case .invoice:
            // Ekstrak total, tanggal, nomor invoice
            let pattern = #"(TOTAL|Total|total)[\s:Rp.]*(\d[\d.,]+)"#
            let regex = try? NSRegularExpression(pattern: pattern)
            let range = NSRange(text.startIndex..., in: text)
            if let match = regex?.firstMatch(in: text, range: range),
               let totalRange = Range(match.range(at: 2), in: text) {
                fields["Total"] = "Rp " + String(text[totalRange])
            }

        default:
            break
        }

        return fields
    }
}

Use Case 2: Fitness Rep Counter dengan Body Pose

App fitness yang menghitung repetisi gerakan (squat, push-up) menggunakan Vision body pose:

swift
import Vision
import AVFoundation
import Combine

@MainActor
class FitnessRepCounter: NSObject, ObservableObject {

    @Published var repCount = 0
    @Published var currentPhase: ExercisePhase = .standing
    @Published var jointAngles: [String: Double] = [:]

    private let captureSession = AVCaptureSession()
    private let videoOutput = AVCaptureVideoDataOutput()
    private var sequenceHandler = VNSequenceRequestHandler()
    private let analysisQueue = DispatchQueue(label: "com.fitness.pose")

    enum ExercisePhase { case standing, squatting, unknown }
    enum Exercise { case squat, pushup, lunge }

    var exercise: Exercise = .squat
    private var previousPhase: ExercisePhase = .unknown
    private var isTransitioning = false

    func startSession() throws {
        captureSession.beginConfiguration()
        captureSession.sessionPreset = .high

        let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)!
        let input = try AVCaptureDeviceInput(device: camera)
        captureSession.addInput(input)

        videoOutput.setSampleBufferDelegate(self, queue: analysisQueue)
        videoOutput.alwaysDiscardsLateVideoFrames = true
        captureSession.addOutput(videoOutput)

        captureSession.commitConfiguration()
        captureSession.startRunning()
    }
}

extension FitnessRepCounter: AVCaptureVideoDataOutputSampleBufferDelegate {

    nonisolated func captureOutput(
        _ output: AVCaptureOutput,
        didOutput sampleBuffer: CMSampleBuffer,
        from connection: AVCaptureConnection
    ) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

        let poseRequest = VNDetectHumanBodyPoseRequest()

        try? sequenceHandler.perform([poseRequest], on: pixelBuffer, orientation: .up)

        guard let observation = poseRequest.results?.first as? VNHumanBodyPoseObservation,
              let allPoints = try? observation.recognizedPoints(.all) else { return }

        // Hitung sudut knee untuk squat
        if let hip = allPoints[.rightHip],
           let knee = allPoints[.rightKnee],
           let ankle = allPoints[.rightAnkle],
           hip.confidence > 0.5, knee.confidence > 0.5, ankle.confidence > 0.5 {

            let kneeAngle = calculateAngle(
                point1: CGPoint(x: hip.x, y: hip.y),
                center: CGPoint(x: knee.x, y: knee.y),
                point2: CGPoint(x: ankle.x, y: ankle.y)
            )

            Task { @MainActor in
                self.jointAngles["Knee"] = kneeAngle
                self.updateSquatPhase(kneeAngle: kneeAngle)
            }
        }
    }

    private func calculateAngle(point1: CGPoint, center: CGPoint, point2: CGPoint) -> Double {
        let v1 = CGVector(dx: point1.x - center.x, dy: point1.y - center.y)
        let v2 = CGVector(dx: point2.x - center.x, dy: point2.y - center.y)
        let dot = v1.dx * v2.dx + v1.dy * v2.dy
        let mag = sqrt(v1.dx * v1.dx + v1.dy * v1.dy) * sqrt(v2.dx * v2.dx + v2.dy * v2.dy)
        guard mag > 0 else { return 0 }
        return acos(max(-1, min(1, dot / mag))) * 180 / .pi
    }

    @MainActor
    private func updateSquatPhase(kneeAngle: Double) {
        let newPhase: ExercisePhase = kneeAngle < 100 ? .squatting : .standing

        // Hitung rep ketika transisi dari squatting ke standing
        if previousPhase == .squatting && newPhase == .standing {
            repCount += 1
        }

        previousPhase = currentPhase
        currentPhase = newPhase
    }
}