```
feat(Quick): 异常项快拍流程重构为静态图框选识别模式 重构异常项快拍功能,将原有的局部小框拍摄改为整幅单拍后静态框选模式。 新流程为:整幅单拍/相册选择 → 静态图手动框选 → 框内OCR+LLM提取指标 → 核对 → 存储独立Indicator。 主要变更包括: - 移除实时预览小框拍摄模式,改为整幅拍摄后手动框选 - 新增RegionAdjustView组件用于静态图框选和识别 - 更新状态机流程:idle → adjust(静态图框选) → confirm → save - 修改识别逻辑,对框选区域进行OCR+LLM处理 - 更新相机组件为SingleShotCameraView,支持整幅拍摄 - 调整错误处理策略,识别失败时可挪框重试而非强制手动录入 - 优化本地化字符串,更新用户界面提示文案 ```
This commit is contained in:
@@ -1,32 +1,25 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// 异常项快拍 · 统一流程。
|
||||
/// 局部小框拍摄 → VL 识别(只抽 indicators)→ 确认 → 存成独立 Indicator(不建 Report、不留图)。
|
||||
/// 整幅单拍(真机)/ 相册(模拟器)→ 静态图手动框选 → 框内 OCR+LLM 抽指标 → 核对 → 存独立 Indicator。
|
||||
///
|
||||
/// 状态机:
|
||||
/// ```
|
||||
/// idle(相机/相册) → analyzing(croppedImage) → confirm(items)
|
||||
/// ↓ 失败/超时
|
||||
/// confirm(空 + warning)
|
||||
/// confirm → save → dismiss · confirm → 重拍 → idle
|
||||
/// idle(相机/相册) → adjust(静态图框选,可反复识别) → confirm(核对) → save → dismiss
|
||||
/// confirm → 重拍 → idle
|
||||
/// ```
|
||||
/// 识别失败/超时不卡死:停在 adjust 提示挪框重试,或直接进 confirm 手动补(§3.2 失败回退红线)。
|
||||
struct QuickRegionCaptureFlow: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var phase: Phase = .idle
|
||||
@State private var analyzeTask: Task<Void, Never>? = nil
|
||||
|
||||
/// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。
|
||||
/// 整页化验单指标多、生成 token 多,30s 偏紧,放宽到 60s。
|
||||
private let analyzeTimeoutSeconds: Int = 60
|
||||
|
||||
enum Phase {
|
||||
case idle
|
||||
case analyzing(image: UIImage)
|
||||
case adjust(image: UIImage)
|
||||
case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?)
|
||||
}
|
||||
|
||||
@@ -42,28 +35,17 @@ struct QuickRegionCaptureFlow: View {
|
||||
captureEntry
|
||||
.ignoresSafeArea()
|
||||
|
||||
case .analyzing(let image):
|
||||
NavigationStack {
|
||||
AnalyzingRegionView(
|
||||
image: image,
|
||||
timeoutSeconds: analyzeTimeoutSeconds,
|
||||
onCancel: {
|
||||
analyzeTask?.cancel()
|
||||
analyzeTask = nil
|
||||
// 取消识别 → 直接进确认页手动补充(图仍在内存,可重拍)
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "已取消识别,手动补充或重拍"))
|
||||
}
|
||||
)
|
||||
.navigationTitle(String(appLoc: "本地识别中…"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("取消") { cancelAll() }
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .adjust(let image):
|
||||
RegionAdjustView(
|
||||
image: image,
|
||||
recognize: { await recognizeRegion($0) },
|
||||
onProceed: { items in
|
||||
phase = .confirm(image: image, items: items, warning: nil)
|
||||
},
|
||||
onRetake: { phase = .idle },
|
||||
onCancel: { onClose() }
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
case .confirm(let image, let items, let warning):
|
||||
NavigationStack {
|
||||
@@ -72,14 +54,14 @@ struct QuickRegionCaptureFlow: View {
|
||||
items: items,
|
||||
warning: warning,
|
||||
onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) },
|
||||
onCancel: cancelAll,
|
||||
onCancel: { onClose() },
|
||||
onRetake: { phase = .idle }
|
||||
)
|
||||
.navigationTitle(String(appLoc: "核对异常项"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("取消") { cancelAll() }
|
||||
Button("取消") { onClose() }
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
@@ -87,100 +69,59 @@ struct QuickRegionCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 入口:整页文档扫描(真机)/ 相册(模拟器或不支持)
|
||||
// MARK: - 入口:整幅单拍(真机)/ 相册(模拟器或无相机)
|
||||
|
||||
// 旧实现用 RegionCameraView 的「细条小框」(为 1-2 行异常项设计);并入「记录指标 · 拍照识别」后
|
||||
// 用户会拍整张化验单,塞进细条须离远拍 → 小字像素过低,VL 读不出。改用 VisionKit 整页扫描:
|
||||
// 全分辨率 + 自动透视校正,VL 能读清整表。模拟器 / 不支持时回退相册选图。
|
||||
@ViewBuilder
|
||||
private var captureEntry: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onFinish: { handlePicked($0) },
|
||||
onCancel: onClose
|
||||
)
|
||||
#else
|
||||
if DocumentScannerView.isSupported {
|
||||
DocumentScannerView(
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onCancel: onClose
|
||||
)
|
||||
} else {
|
||||
PhotoPickerSheet(
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onCancel: onClose
|
||||
)
|
||||
}
|
||||
SingleShotCameraView(
|
||||
onCapture: { phase = .adjust(image: $0) },
|
||||
onCancel: onClose
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 扫描/选图回来:取首页跑识别(单张化验单通常一页);无图则关闭。
|
||||
private func handleScanned(_ images: [UIImage]) {
|
||||
/// 拍/选回来:取首张进框选;无图则关闭(「只能拍一张」=只用第一张)。
|
||||
private func handlePicked(_ images: [UIImage]) {
|
||||
if let first = images.first {
|
||||
startAnalyze(image: first)
|
||||
phase = .adjust(image: first)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别
|
||||
// MARK: - 识别(框内子图 → OCR → LLM)
|
||||
|
||||
private func startAnalyze(image: UIImage) {
|
||||
analyzeTask?.cancel()
|
||||
phase = .analyzing(image: image)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
// 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。
|
||||
// 新链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B LLM 结构化抽指标(替代 3B VL 直读图)。
|
||||
analyzeTask = Task {
|
||||
let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
|
||||
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
analyzeTask?.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
// 1. 端侧 OCR
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
if Task.isCancelled {
|
||||
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
|
||||
}
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#if DEBUG
|
||||
print("🔤 [OCR] recognized text:\n\(trimmed)\n--- end OCR ---")
|
||||
#endif
|
||||
if trimmed.isEmpty {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "没识别到文字,手动补充或重拍"))
|
||||
return
|
||||
}
|
||||
// 2. LLM 解析文本 → 指标
|
||||
let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed)
|
||||
if Task.isCancelled {
|
||||
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
|
||||
}
|
||||
let items = Self.buildItems(from: parsed)
|
||||
phase = .confirm(
|
||||
image: image,
|
||||
items: items,
|
||||
warning: items.isEmpty ? String(appLoc: "没读出指标,手动补充或重拍") : nil
|
||||
)
|
||||
} catch CaptureError.modelNotReady {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "AI 模型未就绪,手动补充"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "解析失败:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: Task.isCancelled ? timeoutWarn
|
||||
: String(appLoc: "识别失败:\(msg)"))
|
||||
} catch {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: Task.isCancelled ? timeoutWarn
|
||||
: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
/// 对已裁好的框内子图跑识别。失败/超时返回提示文案,绝不抛出(由 RegionAdjustView 展示)。
|
||||
/// 链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B 结构化抽指标(对齐 indicator-capture-ocr-llm)。
|
||||
private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
||||
do {
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
if Task.isCancelled { return ([], nil) } // 超时:文案由调用方给
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#if DEBUG
|
||||
print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---")
|
||||
#endif
|
||||
if trimmed.isEmpty {
|
||||
return ([], String(appLoc: "没识别到文字,挪一下框再试"))
|
||||
}
|
||||
let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed)
|
||||
if Task.isCancelled { return ([], nil) }
|
||||
let items = Self.buildItems(from: parsed)
|
||||
return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil)
|
||||
} catch CaptureError.modelNotReady {
|
||||
return ([], String(appLoc: "AI 模型未就绪,手动补充"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
return ([], String(appLoc: "解析失败:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)"))
|
||||
} catch {
|
||||
return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +131,6 @@ struct QuickRegionCaptureFlow: View {
|
||||
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
|
||||
range: $0.range, status: $0.status, include: true)
|
||||
}
|
||||
// 异常优先(stable):high/low 在前,normal 在后
|
||||
return mapped.enumerated().sorted { a, b in
|
||||
let aAbn = a.element.status != .normal
|
||||
let bAbn = b.element.status != .normal
|
||||
@@ -199,13 +139,7 @@ struct QuickRegionCaptureFlow: View {
|
||||
}.map { $0.element }
|
||||
}
|
||||
|
||||
// MARK: - 取消 / 保存
|
||||
|
||||
private func cancelAll() {
|
||||
analyzeTask?.cancel()
|
||||
analyzeTask = nil
|
||||
onClose()
|
||||
}
|
||||
// MARK: - 保存
|
||||
|
||||
/// 勾选项各存一条独立 Indicator(与「记录指标」自由输入一致):无 Report、无 Asset、无 seriesKey。
|
||||
private func save(items: [QuickRegionItem], capturedAt: Date) {
|
||||
@@ -230,56 +164,3 @@ struct QuickRegionCaptureFlow: View {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别中视图
|
||||
|
||||
private struct AnalyzingRegionView: View {
|
||||
let image: UIImage
|
||||
let timeoutSeconds: Int
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var elapsed: Int = 0
|
||||
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(ProgressView().tint(Tj.Palette.ink).scaleEffect(1.3))
|
||||
)
|
||||
VStack(spacing: 6) {
|
||||
Text("识别框内指标")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("100% 本地推理 · 已用 \(elapsed)s")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if elapsed >= timeoutSeconds - 5 {
|
||||
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Tj.Palette.sand)
|
||||
.onReceive(tick) { _ in elapsed += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user