From b7a252ee0eb95b32a2e1e7bc400005d6e42f540e Mon Sep 17 00:00:00 2001 From: pulipakaa24 Date: Sun, 22 Mar 2026 02:36:24 -0500 Subject: [PATCH] Scanning v1.1 --- LabWise/ScanView.swift | 133 +++++++++++++++++++---------------------- 1 file changed, 61 insertions(+), 72 deletions(-) diff --git a/LabWise/ScanView.swift b/LabWise/ScanView.swift index d7b1e3f..73b82c3 100644 --- a/LabWise/ScanView.swift +++ b/LabWise/ScanView.swift @@ -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)