Implement storage architecture from ai_blueprint.md
Primary sync: replace PersistenceActor JSON file with SwiftData + CloudKit - Add SavedCardModel (@Model class) and PersistenceController (ModelContainer with .automatic CloudKit, fallback to local). BackgroundPersistenceActor (@ModelActor) handles all DB I/O off the main thread. - One-time migration imports user_collection.json into SwiftData and renames the original file to prevent re-import. - Inject modelContainer into SwiftUI environment in IYmtgApp. Image storage: Documents/UserContent/ subfolder (blueprint requirement) - ImageManager.dir now targets iCloud Documents/UserContent/ (or local equiv). - migrateImagesToUserContent() moves existing JPGs to the new subfolder on first launch; called during the SwiftData migration. Firebase: demoted to optional manual backup (metadata only, no images) - Remove all automatic CloudEngine.save/delete/batchUpdatePrices calls from CollectionViewModel mutations. - Add backupAllToFirebase() for user-triggered metadata sync. - Add isFirebaseBackupEnabled to AppConfig (default false). - Add Cloud Backup section in Library settings with iCloud vs Firebase explanation and "Backup Metadata to Firebase Now" button. Also: full modular refactor (Data/, Features/, Services/ directories) and README updated with CloudKit setup steps and revised release checklist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
131
IYmtg_App_iOS/Services/Vision/CardRecognizer.swift
Normal file
131
IYmtg_App_iOS/Services/Vision/CardRecognizer.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user