Scanning v1.1

This commit is contained in:
2026-03-22 02:36:24 -05:00
parent 7524274e86
commit b7a252ee0e

View File

@@ -36,7 +36,6 @@ private let labelExtractionInstructions = Instructions {
extract only the fields that are explicitly present in the text. \ 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. \ 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. \ 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. \ 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. 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 : "" return knownUnits.contains(s.lowercased()) ? s : ""
} }
// MARK: - Vision container classification // MARK: - Container type options
/// Vision label identifier fragments friendly container name. /// All container types available for the user to select.
/// Ordered from most specific to least specific. let containerTypeOptions: [String] = [
private let containerVisionMappings: [(fragment: String, name: String)] = [ "Glass Bottle", "Plastic Bottle", "Vial", "Ampoule", "Flask",
("vial", "Vial"), "Beaker", "Tube", "Jar", "Can", "Canister", "Drum", "Bag"
("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"),
] ]
/// 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, *) @available(iOS 18.1, *)
@@ -530,8 +479,10 @@ final class LabelScannerViewModel {
// Incremented on every resetScanState(). Async tasks capture this value at launch; // 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. // if it differs when they complete, the result belongs to a previous scan and is discarded.
private var scanGeneration: Int = 0 private var scanGeneration: Int = 0
// Set to true once Vision has classified the container shape for this scan. // True once the user has picked a container type for this scan.
private var containerClassified = false var containerTypeSelected = false
// True when the container type picker sheet should be shown.
var showContainerTypePicker = false
// Debug info shown in banner // Debug info shown in banner
var debugBarcode: String = "" var debugBarcode: String = ""
@@ -539,6 +490,7 @@ final class LabelScannerViewModel {
var debugPubChemName: String = "" // raw name returned by PubChem var debugPubChemName: String = "" // raw name returned by PubChem
var debugAIName: String = "" // name extracted by on-device AI (with confidence) var debugAIName: String = "" // name extracted by on-device AI (with confidence)
var debugArbitration: String = "" // result of name arbitration var debugArbitration: String = "" // result of name arbitration
var debugAIQueryCount: Int = 0 // how many times the AI extraction has been triggered var debugAIQueryCount: Int = 0 // how many times the AI extraction has been triggered
var allOCRLinesCount: Int { allOCRLines.count } var allOCRLinesCount: Int { allOCRLines.count }
var showDebug: Bool = false var showDebug: Bool = false
@@ -565,20 +517,9 @@ final class LabelScannerViewModel {
return return
} }
// Snapshot for slider also trigger container classification on first thumbnail // Keep thumbnail fresh every frame (used by the fill slider preview)
if let thumb = thumbnail, current.thumbnail == nil { if let thumb = thumbnail {
current.thumbnail = thumb 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 // Debug: track OCR line count every frame
@@ -792,7 +733,8 @@ final class LabelScannerViewModel {
aiTask?.cancel() aiTask?.cancel()
aiTask = nil aiTask = nil
isRunningAI = false isRunningAI = false
containerClassified = false containerTypeSelected = false
showContainerTypePicker = false
aiTriggered = false aiTriggered = false
aiNameConfidence = 0 aiNameConfidence = 0
lastAITriggerTime = .distantPast lastAITriggerTime = .distantPast
@@ -810,6 +752,7 @@ final class LabelScannerViewModel {
func startScanning() { func startScanning() {
resetScanState() resetScanState()
isScanning = true isScanning = true
showContainerTypePicker = true
} }
func stopScanning() { func stopScanning() {
@@ -954,6 +897,11 @@ struct ScanView: View {
.sheet(isPresented: $viewModel.showReview) { .sheet(isPresented: $viewModel.showReview) {
ScanReviewView(viewModel: viewModel, onDone: { dismiss() }) ScanReviewView(viewModel: viewModel, onDone: { dismiss() })
} }
.sheet(isPresented: $viewModel.showContainerTypePicker) {
ContainerTypePickerSheet(viewModel: viewModel)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.animation(.easeInOut(duration: 0.3), value: viewModel.isScanning) .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 // MARK: - Scan banner
struct ScanBanner: View { 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("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) Text(viewModel.debugArbitration).font(.system(size: 9, design: .monospaced)).foregroundStyle(.mint).lineLimit(1).truncationMode(.tail)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 6) .padding(.vertical, 6)