import Vision import CoreGraphics // MARK: - OCR ENGINE class OCREngine { static func readCardDetails(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> (setCode: String?, number: String?, year: String?, isSerialized: Bool) { let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate request.usesLanguageCorrection = false let handler = VNImageRequestHandler(cgImage: image, orientation: orientation) try? handler.perform([request]) guard let obs = request.results as? [VNRecognizedTextObservation] else { return (nil, nil, nil, false) } var possibleSetCode: String? var possibleNumber: String? var possibleYear: String? var isSerialized = false for observation in obs { guard let candidate = observation.topCandidates(1).first else { continue } let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines) // Set Code: 3-5 chars, uppercase // FIX: Ensure it's in the bottom half to avoid reading Card Name (e.g. "FOG") as Set Code // FIX: Must contain at least one letter to avoid reading years or numbers as Set Codes if text.count >= 3 && text.count <= 5 && text == text.uppercased() && possibleSetCode == nil && text.rangeOfCharacter(from: .letters) != nil { // Vision coordinates: (0,0) is Bottom-Left. y < 0.5 is Bottom Half. // FIX: Tighten to y < 0.2 to match Collector Number and fully exclude Text Box (e.g. "FLY") if observation.boundingBox.origin.y < 0.2 { possibleSetCode = text } } // Collector Number & Serialized if text.contains("/") && text.count <= 10 { // FILTER: Ignore Power/Toughness in bottom-right corner (x > 0.75, y < 0.2) if observation.boundingBox.origin.x > 0.75 && observation.boundingBox.origin.y < 0.2 { continue } // FIX: Tighten standard location to bottom 20% to avoid text box stats (e.g. "10/10" token) let isStandardLocation = observation.boundingBox.origin.y < 0.2 let parts = text.split(separator: "/") if parts.count == 2 { let numStr = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) let denomStr = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) if let num = Int(numStr), let denom = Int(denomStr) { if isStandardLocation { if denom < 10 { continue } if possibleNumber == nil { possibleNumber = numStr } } else if observation.boundingBox.origin.y > 0.5 { // FIX: Only consider Top Half (Art) as Serialized to avoid Text Box false positives isSerialized = true } } } } else if text.count >= 1 && text.count <= 5, let first = text.first, first.isNumber, possibleNumber == nil { // FIX: Only accept simple numbers if they are at the very bottom (Collector Number location) // AND not in the bottom-right corner (Power/Toughness zone) if observation.boundingBox.origin.y < 0.2 && observation.boundingBox.origin.x < 0.75 { possibleNumber = text } } // Copyright Year (for Summer Magic detection) if let range = text.range(of: #"(19|20)\d{2}"#, options: .regularExpression) { if observation.boundingBox.origin.y < 0.15 { possibleYear = String(text[range]) } } } return (possibleSetCode, possibleNumber, possibleYear, isSerialized) } }