This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
156 lines
5.6 KiB
Swift
156 lines
5.6 KiB
Swift
import CoreGraphics
|
|
import Foundation
|
|
|
|
struct CardTextHeuristicExtractor {
|
|
private let numberRegex = try? NSRegularExpression(pattern: #"\b\d{1,3}\s*/\s*\d{1,3}\b"#)
|
|
private let rarityKeywords: [(needle: String, rarity: CardRarity)] = [
|
|
("special art rare", .specialArtRare),
|
|
("illustration rare", .illustrationRare),
|
|
("hyper rare", .hyperRare),
|
|
("secret rare", .secretRare),
|
|
("ultra rare", .ultraRare),
|
|
("holo rare", .holoRare),
|
|
("uncommon", .uncommon),
|
|
("common", .common),
|
|
("rare", .rare),
|
|
]
|
|
|
|
func extract(payload: OCRTextPayload, source: RecognitionSource, notes: [String] = []) -> CardRecognitionDraft {
|
|
let cleanedLines = payload.lines
|
|
.map { line in
|
|
RecognizedTextLine(
|
|
text: normalize(line.text),
|
|
confidence: line.confidence,
|
|
normalizedBounds: line.normalizedBounds
|
|
)
|
|
}
|
|
.filter { !$0.text.isEmpty }
|
|
|
|
let rawText = cleanedLines.map(\.text).joined(separator: "\n")
|
|
let cardNumber = extractCardNumber(from: rawText)
|
|
let rarity = extractRarity(from: rawText)
|
|
let cardName = extractCardName(from: cleanedLines)
|
|
let setIdentifier = extractSetIdentifier(from: cleanedLines, cardNumber: cardNumber, rarity: rarity)
|
|
|
|
let foundCount = [cardName, cardNumber, setIdentifier, rarity == CardRarity.unknown.rawValue ? "" : rarity]
|
|
.filter { !$0.isEmpty }
|
|
.count
|
|
|
|
let confidence: ConfidenceLevel
|
|
if foundCount >= 3 && payload.averageConfidence >= 0.68 {
|
|
confidence = .high
|
|
} else if foundCount >= 2 && payload.averageConfidence >= 0.45 {
|
|
confidence = .medium
|
|
} else {
|
|
confidence = .low
|
|
}
|
|
|
|
var draftNotes = notes
|
|
if confidence != .high {
|
|
draftNotes.append(confidence.helperText)
|
|
}
|
|
if foundCount == 0 {
|
|
draftNotes.append("No structured match was found — manual entry is prefilled with OCR fragments.")
|
|
}
|
|
|
|
return CardRecognitionDraft(
|
|
cardName: cardName,
|
|
cardNumber: cardNumber,
|
|
setIdentifier: setIdentifier,
|
|
rarity: rarity,
|
|
source: source,
|
|
confidence: confidence,
|
|
notes: Array(Set(draftNotes)),
|
|
rawText: rawText
|
|
)
|
|
}
|
|
|
|
private func extractCardName(from lines: [RecognizedTextLine]) -> String {
|
|
let upperLines = lines
|
|
.filter { $0.normalizedBounds.midY > 0.45 }
|
|
.sorted { lhs, rhs in
|
|
if lhs.normalizedBounds.midY == rhs.normalizedBounds.midY {
|
|
return lhs.confidence > rhs.confidence
|
|
}
|
|
return lhs.normalizedBounds.midY > rhs.normalizedBounds.midY
|
|
}
|
|
|
|
let candidate = upperLines.first { line in
|
|
let text = line.text
|
|
return text.rangeOfCharacter(from: .decimalDigits) == nil &&
|
|
!text.localizedCaseInsensitiveContains("hp") &&
|
|
!text.localizedCaseInsensitiveContains("trainer") &&
|
|
text.count > 2
|
|
}
|
|
|
|
return candidate?.text ?? lines.first(where: { !$0.text.isEmpty })?.text ?? ""
|
|
}
|
|
|
|
private func extractCardNumber(from rawText: String) -> String {
|
|
guard let numberRegex else { return "" }
|
|
let range = NSRange(rawText.startIndex..., in: rawText)
|
|
guard let match = numberRegex.firstMatch(in: rawText, options: [], range: range),
|
|
let matchRange = Range(match.range, in: rawText) else {
|
|
return ""
|
|
}
|
|
|
|
return String(rawText[matchRange]).replacingOccurrences(of: " ", with: "")
|
|
}
|
|
|
|
private func extractSetIdentifier(from lines: [RecognizedTextLine], cardNumber: String, rarity: String) -> String {
|
|
guard !lines.isEmpty else { return "" }
|
|
|
|
if let lineContainingNumber = lines.first(where: { !cardNumber.isEmpty && $0.text.contains(cardNumber) }) {
|
|
let stripped = lineContainingNumber.text
|
|
.replacingOccurrences(of: cardNumber, with: "")
|
|
.replacingOccurrences(of: "•", with: " ")
|
|
.replacingOccurrences(of: "·", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if stripped.count > 2 {
|
|
return stripped
|
|
}
|
|
}
|
|
|
|
let bottomCandidates = lines
|
|
.filter { $0.normalizedBounds.midY < 0.35 }
|
|
.map(\.text)
|
|
.filter {
|
|
!$0.localizedCaseInsensitiveContains(rarity) &&
|
|
$0.rangeOfCharacter(from: .letters) != nil &&
|
|
!$0.localizedCaseInsensitiveContains("hp")
|
|
}
|
|
|
|
return bottomCandidates.first ?? ""
|
|
}
|
|
|
|
private func extractRarity(from rawText: String) -> String {
|
|
let lowered = rawText.lowercased()
|
|
|
|
if lowered.contains("★") || lowered.contains("holo") {
|
|
return CardRarity.holoRare.rawValue
|
|
}
|
|
|
|
if lowered.contains("◆") {
|
|
return CardRarity.uncommon.rawValue
|
|
}
|
|
|
|
if lowered.contains("●") {
|
|
return CardRarity.common.rawValue
|
|
}
|
|
|
|
for keyword in rarityKeywords {
|
|
if lowered.contains(keyword.needle) {
|
|
return keyword.rarity.rawValue
|
|
}
|
|
}
|
|
|
|
return CardRarity.unknown.rawValue
|
|
}
|
|
|
|
private func normalize(_ value: String) -> String {
|
|
value
|
|
.replacingOccurrences(of: " ", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|