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. \
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)