e904b667c6
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled
276 lines
11 KiB
Swift
276 lines
11 KiB
Swift
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
import Foundation
|
|
import UIKit
|
|
|
|
// MARK: - App State
|
|
|
|
/// The complete set of states the app can be in.
|
|
/// Drives all UI rendering via a single `@Published` property on `OCRViewModel`.
|
|
enum AppState: Equatable {
|
|
/// Models are being loaded from the bundle.
|
|
case loadingModels
|
|
/// Models are loaded and ready; waiting for the user to select an image.
|
|
case ready
|
|
/// An image is being processed by OCR.
|
|
case processing(UIImage)
|
|
/// OCR completed successfully with results and the processed image.
|
|
/// `runId` distinguishes successive successful runs for ``Equatable`` without requiring ``OCRRunResult`` to conform.
|
|
case results(OCRRunResult, UIImage, runId: UUID)
|
|
/// An error occurred during model loading or inference.
|
|
case error(AppError)
|
|
|
|
static func == (lhs: AppState, rhs: AppState) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.loadingModels, .loadingModels): return true
|
|
case (.ready, .ready): return true
|
|
case (.processing, .processing): return true
|
|
case (.results(_, _, let runIdL), .results(_, _, let runIdR)): return runIdL == runIdR
|
|
case (.error(let a), .error(let b)): return a == b
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - App Error
|
|
|
|
/// User-facing errors with recovery guidance.
|
|
enum AppError: Equatable, LocalizedError {
|
|
case modelLoadFailed(String)
|
|
case inferenceFailed(String)
|
|
case imageLoadFailed
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .modelLoadFailed(let msg): return msg
|
|
case .inferenceFailed(let msg): return msg
|
|
case .imageLoadFailed: return "Could not load the selected image. Try a different one."
|
|
}
|
|
}
|
|
|
|
/// Whether this error is recoverable by retrying model load (vs just selecting a new image).
|
|
var isModelError: Bool {
|
|
if case .modelLoadFailed = self { return true }
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - OCRViewModel
|
|
|
|
/// Central view model that manages the complete app lifecycle.
|
|
///
|
|
/// State transitions:
|
|
/// ```
|
|
/// loadingModels --> ready --> processing --> results
|
|
/// | ^ | |
|
|
/// v | v v
|
|
/// error ----retry----> error ready (reset)
|
|
/// ```
|
|
///
|
|
/// All state mutations happen on `@MainActor` through the single
|
|
/// `@Published var state` property, giving SwiftUI a single source of truth.
|
|
@MainActor
|
|
class OCRViewModel: ObservableObject {
|
|
@Published var state: AppState = .loadingModels
|
|
@Published var copiedFeedback: Bool = false
|
|
@Published var runtimeParams = OCRRuntimeParams.noOverrides
|
|
/// ONNX Runtime presets and tuning shown in UI (draft). Use **Apply & reload** in Advanced settings to reload sessions (`loadModels()`).
|
|
@Published var ortPrimaryExecutionProvider: ORTPrimaryExecutionProvider = .cpu
|
|
@Published var ortSessionTuning = ORTSessionTuningOptions.default
|
|
/// Snapshot of Ort settings actually used by the loaded ``ocrEngine``.
|
|
private var ortLoadedPrimaryExecutionProvider: ORTPrimaryExecutionProvider = .cpu
|
|
private var ortLoadedSessionTuning: ORTSessionTuningOptions = .default
|
|
@Published private(set) var resolvedRuntimeBaseline: ResolvedOCRRuntimeParams?
|
|
|
|
private var sessionManager: ORTSessionManager?
|
|
private var ocrEngine: OCREngine?
|
|
/// Last image run through OCR (normalized orientation), for **Re-run OCR** without re-picking.
|
|
private var lastNormalizedImage: UIImage?
|
|
/// OCR runtime parameters used for the last successful run (drives ``hasPendingOcrChanges``).
|
|
private var lastRunRuntimeParams: OCRRuntimeParams?
|
|
|
|
/// Bundled sample resource names (stem or `stem.ext`) for ``selectSampleImage(named:)``.
|
|
let sampleImageNames: [String] = [DemoAssets.defaultSampleImageStem]
|
|
|
|
/// Draft Ort settings differ from the configuration used by loaded sessions.
|
|
var hasUnappliedOrtSettings: Bool {
|
|
ortPrimaryExecutionProvider != ortLoadedPrimaryExecutionProvider
|
|
|| ortSessionTuning != ortLoadedSessionTuning
|
|
}
|
|
|
|
/// `runtimeParams` differs from the last successful OCR run (only meaningful in ``AppState/results``).
|
|
var hasPendingOcrChanges: Bool {
|
|
guard case .results = state else { return false }
|
|
guard let last = lastRunRuntimeParams else { return false }
|
|
return runtimeParams != last
|
|
}
|
|
|
|
/// When `false`, disable Apply/Revert (reload would conflict with loading or OCR in flight).
|
|
var allowsOrtApplyControls: Bool {
|
|
switch state {
|
|
case .loadingModels, .processing: return false
|
|
case .ready, .results, .error: return true
|
|
}
|
|
}
|
|
|
|
/// Restores ONNX Runtime controls to match the currently loaded sessions.
|
|
func revertOrtDraftToLoaded() {
|
|
ortPrimaryExecutionProvider = ortLoadedPrimaryExecutionProvider
|
|
ortSessionTuning = ortLoadedSessionTuning
|
|
}
|
|
|
|
/// Photo picker or file load produced unusable image data.
|
|
func reportImageLoadFailed() {
|
|
state = .error(.imageLoadFailed)
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
/// Load ORT models from the app bundle.
|
|
///
|
|
/// Creates an `ORTSessionManager`, loads both detection and recognition models,
|
|
/// then creates the `OCREngine` for end-to-end OCR.
|
|
func loadModels() async {
|
|
state = .loadingModels
|
|
do {
|
|
let manager = ORTSessionManager()
|
|
try await manager.loadModels(executionProvider: ortPrimaryExecutionProvider, tuning: ortSessionTuning)
|
|
let engine = try OCREngine(sessionManager: manager)
|
|
self.sessionManager = manager
|
|
self.ocrEngine = engine
|
|
self.resolvedRuntimeBaseline = engine.baselineRuntimeDefaults()
|
|
ortLoadedPrimaryExecutionProvider = ortPrimaryExecutionProvider
|
|
ortLoadedSessionTuning = ortSessionTuning
|
|
state = .ready
|
|
} catch {
|
|
state = .error(.modelLoadFailed(error.localizedDescription))
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Processing
|
|
|
|
/// Run full OCR on a user-selected image.
|
|
///
|
|
/// Normalizes orientation first (camera photos often have EXIF rotation),
|
|
/// then delegates to `OCREngine.run(_:)`.
|
|
func processImage(_ uiImage: UIImage) async {
|
|
let normalized = normalizeOrientation(uiImage)
|
|
lastNormalizedImage = normalized
|
|
state = .processing(normalized)
|
|
guard let cgImage = normalized.cgImage else {
|
|
state = .error(.imageLoadFailed)
|
|
return
|
|
}
|
|
guard let engine = ocrEngine else {
|
|
state = .error(.modelLoadFailed("OCR engine is not ready."))
|
|
return
|
|
}
|
|
do {
|
|
let result = try await engine.run(cgImage, params: runtimeParams)
|
|
lastRunRuntimeParams = runtimeParams
|
|
state = .results(result, normalized, runId: UUID())
|
|
} catch {
|
|
lastNormalizedImage = nil
|
|
state = .error(.inferenceFailed(error.localizedDescription))
|
|
}
|
|
}
|
|
|
|
/// Runs OCR again on the **last image** using the current `runtimeParams` (e.g. after tuning sliders).
|
|
func rerunOCR() async {
|
|
guard let image = lastNormalizedImage else { return }
|
|
await processImage(image)
|
|
}
|
|
|
|
/// Load a bundled sample image by name and run OCR on it.
|
|
func selectSampleImage(named name: String) async {
|
|
guard let path = Bundle.main.path(forBundledImageNamed: name, subdirectory: "SampleImages") else {
|
|
state = .error(.imageLoadFailed)
|
|
return
|
|
}
|
|
do {
|
|
guard let uiFromFile = UIImage(contentsOfFile: path) else {
|
|
state = .error(.imageLoadFailed)
|
|
return
|
|
}
|
|
let normalized = normalizeOrientation(uiFromFile)
|
|
lastNormalizedImage = normalized
|
|
state = .processing(normalized)
|
|
guard let cgImage = normalized.cgImage else {
|
|
state = .error(.imageLoadFailed)
|
|
return
|
|
}
|
|
guard let engine = ocrEngine else {
|
|
state = .error(.modelLoadFailed("OCR engine is not ready."))
|
|
return
|
|
}
|
|
let result = try await engine.run(cgImage, params: runtimeParams)
|
|
lastRunRuntimeParams = runtimeParams
|
|
state = .results(result, normalized, runId: UUID())
|
|
} catch {
|
|
lastNormalizedImage = nil
|
|
state = .error(.inferenceFailed(error.localizedDescription))
|
|
}
|
|
}
|
|
|
|
// MARK: - Result Actions
|
|
|
|
/// Copy all recognized text to the system clipboard, with brief visual feedback.
|
|
func copyResultsToClipboard() {
|
|
guard case .results(let result, _, _) = state else { return }
|
|
let allText = result.results.map(\.text).joined(separator: "\n")
|
|
UIPasteboard.general.string = allText
|
|
copiedFeedback = true
|
|
Task {
|
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
|
copiedFeedback = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Recovery
|
|
|
|
/// Retry after an error. Model errors reload models; other errors return to ready state.
|
|
func retry() async {
|
|
guard case .error(let error) = state else { return }
|
|
if error.isModelError {
|
|
await loadModels()
|
|
} else {
|
|
state = .ready
|
|
}
|
|
}
|
|
|
|
/// Return to the ready state (e.g. after viewing results, user wants to pick a new image).
|
|
func reset() {
|
|
lastNormalizedImage = nil
|
|
lastRunRuntimeParams = nil
|
|
state = .ready
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Orientation
|
|
|
|
/// Normalize EXIF orientation by redrawing the image in `.up` orientation.
|
|
///
|
|
/// Camera photos from the photo picker often carry non-`.up` orientation metadata.
|
|
/// CoreGraphics ignores EXIF orientation, so we must flatten it before OCR processing.
|
|
private func normalizeOrientation(_ image: UIImage) -> UIImage {
|
|
guard image.imageOrientation != .up else { return image }
|
|
let format = UIGraphicsImageRendererFormat()
|
|
format.scale = image.scale
|
|
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
|
return renderer.image { _ in
|
|
image.draw(in: CGRect(origin: .zero, size: image.size))
|
|
}
|
|
}
|