Files
IYmtg/IYmtg_App_iOS/Services/Vision/OCREngine.swift
Mike Wichers 24dcb44af4 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>
2026-03-05 12:13:17 -05:00

70 lines
3.8 KiB
Swift

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)
}
}