import Vision import CoreML // MARK: - ANALYSIS ACTOR (Core Card Recognition) actor AnalysisActor { private var database: [CardMetadata] = [] private var fingerprintCache: [UUID: VNFeaturePrintObservation] = [:] func loadDatabase(from url: URL) throws { let data = try Data(contentsOf: url, options: .mappedIfSafe) let loaded = try JSONDecoder().decode([CardFingerprint].self, from: data) self.fingerprintCache.removeAll() self.database.removeAll() for card in loaded { if let obs = try? NSKeyedUnarchiver.unarchivedObject(ofClass: VNFeaturePrintObservation.self, from: card.featureData) { self.fingerprintCache[card.id] = obs } self.database.append(CardMetadata(id: card.id, name: card.name, setCode: card.setCode, collectorNumber: card.collectorNumber, hasFoilPrinting: card.hasFoilPrinting, hasSerializedPrinting: card.hasSerializedPrinting ?? false, priceScanned: card.priceScanned)) } } func analyze(croppedImage: CGImage, orientation: CGImagePropertyOrientation) async -> (CardMetadata?, Bool) { guard let print = try? await FeatureMatcher.generateFingerprint(from: croppedImage, orientation: orientation) else { return (nil, false) } let result = FeatureMatcher.identify(scan: print, database: self.database, cache: self.fingerprintCache) var resolvedCard: CardMetadata? var detectedSerialized = false switch result { case .exact(let card): resolvedCard = card case .unknown: return (nil, false) case .ambiguous(_, let candidates): let (ocrSet, ocrNum, ocrYear, isSerialized) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation) detectedSerialized = isSerialized // Run Heuristics to resolve specific ambiguities let isAlpha = CornerDetector.isAlphaCorner(image: croppedImage, orientation: orientation) let saturation = SaturationDetector.analyze(image: croppedImage, orientation: orientation) let borderColor = BorderDetector.detect(image: croppedImage, orientation: orientation) let hasListSymbol = ListSymbolDetector.hasListSymbol(image: croppedImage, orientation: orientation) let hasStamp = StampDetector.hasStamp(image: croppedImage, orientation: orientation) var filtered = candidates // 1. Alpha (LEA) vs Beta (LEB) if candidates.contains(where: { $0.setCode == "LEA" }) && candidates.contains(where: { $0.setCode == "LEB" }) { if isAlpha { filtered = filtered.filter { $0.setCode == "LEA" } } else { filtered = filtered.filter { $0.setCode == "LEB" } } } // 2. Unlimited (2ED) vs Revised (3ED) if candidates.contains(where: { $0.setCode == "2ED" }) && candidates.contains(where: { $0.setCode == "3ED" }) { if saturation > 0.25 { filtered = filtered.filter { $0.setCode == "2ED" } } else { filtered = filtered.filter { $0.setCode == "3ED" || $0.setCode == "SUM" } } } // 3. The List / Mystery if hasListSymbol { let listSets = ["PLIST", "MB1", "UPLIST", "H1R"] let listCandidates = filtered.filter { listSets.contains($0.setCode) } if !listCandidates.isEmpty { filtered = listCandidates } } // 4. World Champ (Gold Border) if borderColor == .gold { let wcCandidates = filtered.filter { $0.setCode.hasPrefix("WC") } if !wcCandidates.isEmpty { filtered = wcCandidates } } // 5. Promo Stamps if hasStamp { let promoCandidates = filtered.filter { $0.setCode.lowercased().hasPrefix("p") } if !promoCandidates.isEmpty { filtered = promoCandidates } } // 6. Chronicles (White Border) vs Originals (Black Border) let chroniclesOriginals = ["ARN", "ATQ", "LEG", "DRK"] if candidates.contains(where: { $0.setCode == "CHR" }) && candidates.contains(where: { chroniclesOriginals.contains($0.setCode) }) { if borderColor == .white { filtered = filtered.filter { $0.setCode == "CHR" } } else if borderColor == .black { filtered = filtered.filter { chroniclesOriginals.contains($0.setCode) } } } // 7. Summer Magic (Edgar) - 1994 Copyright on Revised if let year = ocrYear, year == "1994", candidates.contains(where: { $0.setCode == "3ED" }) { let sumCandidates = filtered.filter { $0.setCode == "SUM" } if !sumCandidates.isEmpty { filtered = sumCandidates } } else if candidates.contains(where: { $0.setCode == "3ED" }) { filtered = filtered.filter { $0.setCode != "SUM" } } var resolved: CardMetadata? if let set = ocrSet, let num = ocrNum, let match = filtered.first(where: { $0.setCode.uppercased() == set && $0.collectorNumber == num }) { resolved = match } else if let set = ocrSet, let match = filtered.first(where: { $0.setCode.uppercased() == set }) { resolved = match } else if let set = SetSymbolEngine.recognizeSet(image: croppedImage, orientation: orientation), let match = filtered.first(where: { $0.setCode.caseInsensitiveCompare(set) == .orderedSame }) { resolved = match } else if let set = ClusterEngine.refine(candidates: filtered), let match = filtered.first(where: { $0.setCode == set }) { resolved = match } else { resolved = filtered.first ?? candidates.first } resolvedCard = resolved } guard let card = resolvedCard else { return (nil, false) } // DB CHECK: Only run/trust OCR serialization if the card is known to have a serialized printing if card.hasSerializedPrinting { if case .exact = result { let (_, _, _, isSer) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation) detectedSerialized = isSer } } else { detectedSerialized = false } return (card, detectedSerialized) } } // MARK: - CLUSTER ENGINE (Ambiguity Resolution) class ClusterEngine { static func refine(candidates: [CardMetadata]) -> String? { // Weighted voting: 1st candidate = 3 pts, 2nd = 2 pts, others = 1 pt var scores: [String: Int] = [:] for (index, card) in candidates.prefix(5).enumerated() { let weight = max(1, 3 - index) scores[card.setCode, default: 0] += weight } return scores.sorted { $0.value > $1.value }.first?.key } }