From baa6cb22c9823c3eafbf476459f9596bf180b481 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 12 Oct 2026 12:25:46 +0000 Subject: [PATCH] fix: select supported CPU compute devices for modern Vision OCR --- .github/workflows/release.yml | 19 ++++++ src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 + src-tauri/src/ocr/tests/diagnose-vision.swift | 62 +++++++++++++++++++ src-tauri/src/ocr/vision.rs | 45 +++++++++++--- 5 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/ocr/tests/diagnose-vision.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54af7c3..c5efd54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ jobs: uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Cache current macOS OCR dependencies + id: ocr-cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | @@ -51,6 +52,24 @@ jobs: run: | cargo test --manifest-path src-tauri/Cargo.toml ocr:: -- --nocapture + - name: Diagnose native Vision if OCR fails + if: failure() + run: | + fixture_dir="$RUNNER_TEMP/ttree-ocr-diagnostics" + mkdir -p "$fixture_dir" + swift src-tauri/src/ocr/tests/generate-fixtures.swift "$fixture_dir" + swift src-tauri/src/ocr/tests/diagnose-vision.swift "$fixture_dir/english.png" + + - name: Save compiled OCR dependencies + if: always() && steps.ocr-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: macos-27-ocr-${{ hashFiles('src-tauri/Cargo.lock') }} + verify: needs: ocr-current-macos runs-on: macos-14 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a946a4d..96cb64a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4472,6 +4472,7 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-ml", "objc2-foundation", "objc2-image-io", "objc2-vision", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0291f4d..12c9fbf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ core-foundation = "0.10" security-framework = "3" objc2 = "0.6" objc2-core-foundation = "0.3" +objc2-core-ml = { version = "0.3", features = ["MLCPUComputeDevice", "MLComputeDeviceProtocol"] } objc2-image-io = { version = "0.3", features = ["CGImageSource"] } objc2-app-kit = { version = "0.3", features = [ "NSPasteboard", @@ -62,6 +63,7 @@ objc2-foundation = { version = "0.3", features = [ ] } objc2-vision = { version = "0.3", features = [ "objc2-core-graphics", + "objc2-core-ml", "VNRequest", "VNRecognizeTextRequest", "VNObservation", diff --git a/src-tauri/src/ocr/tests/diagnose-vision.swift b/src-tauri/src/ocr/tests/diagnose-vision.swift new file mode 100644 index 0000000..329414c --- /dev/null +++ b/src-tauri/src/ocr/tests/diagnose-vision.swift @@ -0,0 +1,62 @@ +// CI-only comparison against Apple's minimal Vision request; synthetic text only. +import Foundation +import ImageIO +import Vision +import CoreML + +let file = URL(fileURLWithPath: CommandLine.arguments[1]) +let data = try Data(contentsOf: file) +let source = CGImageSourceCreateWithData(data as CFData, nil)! +let image = CGImageSourceCreateImageAtIndex(source, 0, nil)! +print("Vision default revision: \(VNRecognizeTextRequest.defaultRevision)") +print("Vision supported revisions: \(VNRecognizeTextRequest.supportedRevisions)") + +func assignCPU(_ request: VNRecognizeTextRequest) throws { + if #available(macOS 14.0, *) { + let stages = try request.supportedComputeStageDevices() + for (stage, devices) in stages { + print("compute stage \(stage): \(devices)") + if let cpu = devices.first(where: { $0 is MLCPUComputeDevice }) { + request.setComputeDevice(cpu, for: stage) + } + } + } +} + +let profiles: [(String, (VNRecognizeTextRequest) throws -> Void)] = [ + ("apple-default", { _ in }), + ("default-explicit-cpu", { try assignCPU($0) }), + ("rev3-english-cpu", { request in + request.revision = 3 + request.recognitionLanguages = ["en-US"] + try assignCPU(request) + }), + ("rev3-chinese-english-cpu", { request in + request.revision = 3 + request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"] + try assignCPU(request) + }), + ("rev2-chinese-english-cpu", { request in + request.revision = 2 + request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"] + try assignCPU(request) + }), + ("fast-english-cpu", { request in + request.recognitionLevel = .fast + request.recognitionLanguages = ["en-US"] + try assignCPU(request) + }), +] +for (name, configure) in profiles { + do { + let request = VNRecognizeTextRequest() + try configure(request) + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + try handler.perform([request]) + let text = request.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n") ?? "" + print("\(name): OK \(text)") + } catch { + let error = error as NSError + print("\(name): ERROR domain=\(error.domain) code=\(error.code) details=\(error.userInfo)") + } +} diff --git a/src-tauri/src/ocr/vision.rs b/src-tauri/src/ocr/vision.rs index 27eff36..b592b8b 100644 --- a/src-tauri/src/ocr/vision.rs +++ b/src-tauri/src/ocr/vision.rs @@ -6,9 +6,10 @@ #![cfg(target_os = "macos")] use objc2::rc::{autoreleasepool, Retained}; -use objc2::AnyThread; +use objc2::{AnyThread, ClassType}; use objc2_core_graphics::CGImage; -use objc2_foundation::{NSArray, NSString}; +use objc2_core_ml::MLCPUComputeDevice; +use objc2_foundation::{NSArray, NSObjectProtocol, NSString}; use objc2_vision::{VNImageRequestHandler, VNRecognizeTextRequest, VNRequestTextRecognitionLevel}; /// 对指定图片文件路径执行 OCR,返回识别文本(按行拼接)。 @@ -85,11 +86,6 @@ fn configure_request(request: &VNRecognizeTextRequest, compatibility: bool) -> R unsafe { request.setRevision(revision); request.setPreferBackgroundProcessing(true); - if compatibility { - // macOS 11 也必须支持回退,因此保留兼容的 CPU-only API。 - #[allow(deprecated)] - request.setUsesCPUOnly(true); - } } if objc2::available!(macos = 13.0) { request.setAutomaticallyDetectsLanguage(!compatibility); @@ -116,6 +112,41 @@ fn configure_request(request: &VNRecognizeTextRequest, compatibility: bool) -> R if !languages.is_empty() { request.setRecognitionLanguages(&NSArray::from_retained_slice(&languages)); } + // 新系统后台 OCR 不能依赖 ANE 自动选择。旧 usesCPUOnly 属性在新 + // Vision 上不足以保证 CPU 执行,必须按模型支持的计算阶段显式选择。 + if compatibility || objc2::available!(macos = 27.0) { + configure_cpu(request)?; + } + Ok(()) +} + +fn configure_cpu(request: &VNRecognizeTextRequest) -> Result<(), String> { + // SAFETY: 新计算设备 API 和 MLCPUComputeDevice 从 macOS 14 才可用。 + unsafe { + if objc2::available!(macos = 14.0) { + let stages = request + .supportedComputeStageDevicesAndReturnError() + .map_err(|error| format!("读取 OCR 计算设备失败: {error}"))?; + let mut assigned = false; + for stage in stages.allKeys().iter() { + if let Some(devices) = stages.objectForKey(stage) { + if let Some(cpu) = devices + .iter() + .find(|device| device.isKindOfClass(MLCPUComputeDevice::class())) + { + request.setComputeDevice_forComputeStage(Some(cpu), stage); + assigned = true; + } + } + } + if !assigned { + return Err("此 OCR 模型没有可用的 CPU 计算设备".to_string()); + } + } else { + #[allow(deprecated)] + request.setUsesCPUOnly(true); + } + } Ok(()) }