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:
69
IYmtg_App_iOS/Services/Vision/OCREngine.swift
Normal file
69
IYmtg_App_iOS/Services/Vision/OCREngine.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user