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:
Matthias
2026-04-19 21:11:32 +02:00
parent 577214d474
commit a60a76b797
679 changed files with 138964 additions and 73 deletions

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