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. \
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user