Add scan flow MVP and local Axiom skill workspace
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.
This commit is contained in:
155
StackDex/Services/CardTextHeuristicExtractor.swift
Normal file
155
StackDex/Services/CardTextHeuristicExtractor.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user