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>
132 lines
6.7 KiB
Swift
132 lines
6.7 KiB
Swift
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
|
|
}
|
|
}
|