Scanning v1.1
This commit is contained in:
@@ -36,7 +36,6 @@ private let labelExtractionInstructions = Instructions {
|
||||
extract only the fields that are explicitly present in the text. \
|
||||
Do NOT invent, infer, or hallucinate values — if a field is not clearly visible in the provided text, return an empty string for it. \
|
||||
For expiration dates: look for keywords like EXP, EXPIRY, EXPIRATION, USE BY, BEST BEFORE followed by a date. \
|
||||
The date may use numeric months (06/2018), abbreviated month names (Jun), or full month names. \
|
||||
For amount and unit: look for a number directly followed by a unit like g, gm, mg, kg, mL, L, mol. \
|
||||
Set nameConfidence honestly: 90+ only when the product name is explicitly and clearly printed; 50-89 when inferred; below 50 when guessing.
|
||||
"""
|
||||
@@ -134,64 +133,14 @@ private func aiSanitizeUnit(_ value: String) -> String {
|
||||
return knownUnits.contains(s.lowercased()) ? s : ""
|
||||
}
|
||||
|
||||
// MARK: - Vision container classification
|
||||
// MARK: - Container type options
|
||||
|
||||
/// Vision label identifier fragments → friendly container name.
|
||||
/// Ordered from most specific to least specific.
|
||||
private let containerVisionMappings: [(fragment: String, name: String)] = [
|
||||
("vial", "Vial"),
|
||||
("ampoule", "Ampoule"),
|
||||
("ampule", "Ampoule"),
|
||||
("flask", "Flask"),
|
||||
("beaker", "Beaker"),
|
||||
("tube", "Tube"),
|
||||
("canister", "Canister"),
|
||||
("drum", "Drum"),
|
||||
("barrel", "Drum"),
|
||||
("jar", "Jar"),
|
||||
("can", "Can"),
|
||||
("bag", "Bag"),
|
||||
("bottle", "Bottle"), // generic — caller refines to Glass/Plastic if possible
|
||||
("container", "Bottle"),
|
||||
("jug", "Bottle"),
|
||||
/// All container types available for the user to select.
|
||||
let containerTypeOptions: [String] = [
|
||||
"Glass Bottle", "Plastic Bottle", "Vial", "Ampoule", "Flask",
|
||||
"Beaker", "Tube", "Jar", "Can", "Canister", "Drum", "Bag"
|
||||
]
|
||||
|
||||
/// Minimum confidence for a Vision label to be considered a reliable container identification.
|
||||
private let containerConfidenceThreshold: Float = 0.35
|
||||
|
||||
/// Runs Vision's on-device image classifier on a thumbnail and returns a
|
||||
/// human-friendly container type string, or nil if nothing was identified with sufficient confidence.
|
||||
private func classifyContainerType(from image: UIImage) async -> String? {
|
||||
guard let cgImage = image.cgImage else { return nil }
|
||||
do {
|
||||
let results = try await ClassifyImageRequest()
|
||||
.perform(on: cgImage)
|
||||
.filter { $0.confidence >= containerConfidenceThreshold }
|
||||
.sorted { $0.confidence > $1.confidence }
|
||||
|
||||
for observation in results {
|
||||
let id = observation.identifier.lowercased()
|
||||
for mapping in containerVisionMappings {
|
||||
guard id.contains(mapping.fragment) else { continue }
|
||||
// For generic "Bottle" matches, require a higher bar and try to
|
||||
// distinguish glass vs plastic from co-occurring labels.
|
||||
if mapping.name == "Bottle" {
|
||||
guard observation.confidence >= 0.5 else { return nil }
|
||||
let allIDs = results.map { $0.identifier.lowercased() }.joined(separator: " ")
|
||||
if allIDs.contains("plastic") { return "Plastic Bottle" }
|
||||
if allIDs.contains("glass") { return "Glass Bottle" }
|
||||
// Can't distinguish material — don't guess
|
||||
return nil
|
||||
}
|
||||
return mapping.name
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Vision failure is non-fatal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
@available(iOS 18.1, *)
|
||||
@@ -530,8 +479,10 @@ final class LabelScannerViewModel {
|
||||
// Incremented on every resetScanState(). Async tasks capture this value at launch;
|
||||
// if it differs when they complete, the result belongs to a previous scan and is discarded.
|
||||
private var scanGeneration: Int = 0
|
||||
// Set to true once Vision has classified the container shape for this scan.
|
||||
private var containerClassified = false
|
||||
// True once the user has picked a container type for this scan.
|
||||
var containerTypeSelected = false
|
||||
// True when the container type picker sheet should be shown.
|
||||
var showContainerTypePicker = false
|
||||
|
||||
// Debug info shown in banner
|
||||
var debugBarcode: String = "—"
|
||||
@@ -539,6 +490,7 @@ final class LabelScannerViewModel {
|
||||
var debugPubChemName: String = "—" // raw name returned by PubChem
|
||||
var debugAIName: String = "—" // name extracted by on-device AI (with confidence)
|
||||
var debugArbitration: String = "—" // result of name arbitration
|
||||
|
||||
var debugAIQueryCount: Int = 0 // how many times the AI extraction has been triggered
|
||||
var allOCRLinesCount: Int { allOCRLines.count }
|
||||
var showDebug: Bool = false
|
||||
@@ -565,20 +517,9 @@ final class LabelScannerViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot for slider — also trigger container classification on first thumbnail
|
||||
if let thumb = thumbnail, current.thumbnail == nil {
|
||||
// Keep thumbnail fresh every frame (used by the fill slider preview)
|
||||
if let thumb = thumbnail {
|
||||
current.thumbnail = thumb
|
||||
if !containerClassified {
|
||||
containerClassified = true
|
||||
let generation = scanGeneration
|
||||
Task {
|
||||
if let containerType = await classifyContainerType(from: thumb),
|
||||
generation == scanGeneration {
|
||||
current.storageDevice = containerType
|
||||
previewFields["container"] = containerType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: track OCR line count every frame
|
||||
@@ -792,7 +733,8 @@ final class LabelScannerViewModel {
|
||||
aiTask?.cancel()
|
||||
aiTask = nil
|
||||
isRunningAI = false
|
||||
containerClassified = false
|
||||
containerTypeSelected = false
|
||||
showContainerTypePicker = false
|
||||
aiTriggered = false
|
||||
aiNameConfidence = 0
|
||||
lastAITriggerTime = .distantPast
|
||||
@@ -810,6 +752,7 @@ final class LabelScannerViewModel {
|
||||
func startScanning() {
|
||||
resetScanState()
|
||||
isScanning = true
|
||||
showContainerTypePicker = true
|
||||
}
|
||||
|
||||
func stopScanning() {
|
||||
@@ -954,6 +897,11 @@ struct ScanView: View {
|
||||
.sheet(isPresented: $viewModel.showReview) {
|
||||
ScanReviewView(viewModel: viewModel, onDone: { dismiss() })
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showContainerTypePicker) {
|
||||
ContainerTypePickerSheet(viewModel: viewModel)
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: viewModel.isScanning)
|
||||
}
|
||||
}
|
||||
@@ -1032,6 +980,46 @@ struct LiveScannerView: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Container type picker sheet
|
||||
|
||||
struct ContainerTypePickerSheet: View {
|
||||
@Bindable var viewModel: LabelScannerViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Container Type")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
ForEach(containerTypeOptions, id: \.self) { option in
|
||||
let isSelected = viewModel.current.storageDevice == option
|
||||
Button {
|
||||
viewModel.current.storageDevice = option
|
||||
viewModel.previewFields["container"] = option
|
||||
viewModel.containerTypeSelected = true
|
||||
viewModel.showContainerTypePicker = false
|
||||
} label: {
|
||||
Text(option)
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(isSelected ? Color.accentColor : Color(.secondarySystemBackground))
|
||||
.foregroundStyle(isSelected ? .white : .primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scan banner
|
||||
|
||||
struct ScanBanner: View {
|
||||
@@ -1117,6 +1105,7 @@ struct ScanBanner: View {
|
||||
Text("ARB").font(.system(size: 9, design: .monospaced)).foregroundStyle(.mint.opacity(0.7)).frame(width: 28, alignment: .leading)
|
||||
Text(viewModel.debugArbitration).font(.system(size: 9, design: .monospaced)).foregroundStyle(.mint).lineLimit(1).truncationMode(.tail)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
Reference in New Issue
Block a user