```
feat(Quick): 异常项快拍流程重构为静态图框选识别模式 重构异常项快拍功能,将原有的局部小框拍摄改为整幅单拍后静态框选模式。 新流程为:整幅单拍/相册选择 → 静态图手动框选 → 框内OCR+LLM提取指标 → 核对 → 存储独立Indicator。 主要变更包括: - 移除实时预览小框拍摄模式,改为整幅拍摄后手动框选 - 新增RegionAdjustView组件用于静态图框选和识别 - 更新状态机流程:idle → adjust(静态图框选) → confirm → save - 修改识别逻辑,对框选区域进行OCR+LLM处理 - 更新相机组件为SingleShotCameraView,支持整幅拍摄 - 调整错误处理策略,识别失败时可挪框重试而非强制手动录入 - 优化本地化字符串,更新用户界面提示文案 ```
This commit is contained in:
261
康康/Features/Quick/RegionAdjustView.swift
Normal file
261
康康/Features/Quick/RegionAdjustView.swift
Normal file
@@ -0,0 +1,261 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// 异常项快拍 · 静态图框选识别。
|
||||
/// 拍/选一张后,在静态照片上手动拖动 + 缩放一个方框,点「识别」只对框内做 OCR+LLM。
|
||||
/// 可反复挪框重识别,满意后进入核对页;0 项也能进核对手动补(失败回退红线)。
|
||||
struct RegionAdjustView: View {
|
||||
let image: UIImage
|
||||
/// 对裁好的子图跑 OCR+LLM,返回(识别项, 提示文案?)。几何裁剪由本视图负责。
|
||||
let recognize: (UIImage) async -> (items: [QuickRegionItem], warning: String?)
|
||||
let onProceed: ([QuickRegionItem]) -> Void
|
||||
let onRetake: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
/// 单次识别超时(秒)。超时取消并提示挪框重试。
|
||||
let timeoutSeconds: Int = 60
|
||||
|
||||
@State private var box: CGRect = .zero
|
||||
@State private var fittedRect: CGRect = .zero
|
||||
@State private var boxInited = false
|
||||
@State private var dragStartBox: CGRect? = nil
|
||||
@State private var resizeStartBox: CGRect? = nil
|
||||
|
||||
@State private var isRecognizing = false
|
||||
@State private var items: [QuickRegionItem] = []
|
||||
@State private var statusText: String? = nil
|
||||
@State private var recognizeTask: Task<Void, Never>? = nil
|
||||
|
||||
private let handleSize: CGFloat = 30
|
||||
private let minBox: CGFloat = 56
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
canvas
|
||||
controls
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
}
|
||||
|
||||
// MARK: - 顶栏
|
||||
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Button {
|
||||
recognizeTask?.cancel()
|
||||
onCancel()
|
||||
} label: {
|
||||
Text("取消")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Spacer()
|
||||
Text("框住异常指标")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
Button {
|
||||
recognizeTask?.cancel()
|
||||
onRetake()
|
||||
} label: {
|
||||
Text("重拍")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
// MARK: - 图 + 选框
|
||||
|
||||
private var canvas: some View {
|
||||
GeometryReader { proxy in
|
||||
let fitted = AVMakeRect(
|
||||
aspectRatio: image.size == .zero ? CGSize(width: 1, height: 1) : image.size,
|
||||
insideRect: CGRect(origin: .zero, size: proxy.size)
|
||||
)
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
|
||||
// 框外压暗,突出框内
|
||||
Canvas { ctx, size in
|
||||
var path = Path(CGRect(origin: .zero, size: size))
|
||||
path.addPath(Path(roundedRect: box, cornerRadius: 10))
|
||||
ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true))
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// 选框边框 + 拖动
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.white, style: StrokeStyle(lineWidth: 2, dash: [7, 5]))
|
||||
.frame(width: max(box.width, 1), height: max(box.height, 1))
|
||||
.position(x: box.midX, y: box.midY)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(moveGesture(in: fitted))
|
||||
|
||||
// 右下角缩放手柄
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: handleSize, height: handleSize)
|
||||
.overlay(
|
||||
Image(systemName: "arrow.down.right")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
)
|
||||
.position(x: box.maxX, y: box.maxY)
|
||||
.gesture(resizeGesture(in: fitted))
|
||||
}
|
||||
.onAppear {
|
||||
fittedRect = fitted
|
||||
if !boxInited {
|
||||
box = defaultBox(in: fitted)
|
||||
boxInited = true
|
||||
}
|
||||
}
|
||||
.onChange(of: proxy.size) { _, _ in
|
||||
fittedRect = fitted
|
||||
box = clampSize(clampOrigin(box, in: fitted), in: fitted)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
// MARK: - 底部控件
|
||||
|
||||
private var controls: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text(statusText ?? String(appLoc: "拖动方框对准要识别的指标,可拖右下角缩放"))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 34)
|
||||
|
||||
Button {
|
||||
runRecognize()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isRecognizing { ProgressView().tint(.black) }
|
||||
Text(isRecognizing ? String(appLoc: "本地识别中…") : String(appLoc: "识别框内指标"))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Capsule().fill(.white))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.disabled(isRecognizing)
|
||||
|
||||
Button {
|
||||
recognizeTask?.cancel()
|
||||
onProceed(items)
|
||||
} label: {
|
||||
Text(items.isEmpty
|
||||
? String(appLoc: "跳过 · 手动录入")
|
||||
: String(appLoc: "进入核对(\(items.count))"))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Capsule().strokeBorder(.white.opacity(0.6), lineWidth: 1))
|
||||
}
|
||||
.disabled(isRecognizing)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 28)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
// MARK: - 选框默认值 / 夹紧
|
||||
|
||||
private func defaultBox(in fitted: CGRect) -> CGRect {
|
||||
guard fitted.width > 0, fitted.height > 0 else { return .zero }
|
||||
let w = fitted.width * 0.8
|
||||
let h = min(fitted.height * 0.3, max(minBox, fitted.height * 0.18))
|
||||
let x = fitted.minX + (fitted.width - w) / 2
|
||||
let y = fitted.minY + (fitted.height - h) / 2
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
|
||||
/// 移动时夹紧原点(尺寸不变),保证框不超出图片显示区。
|
||||
private func clampOrigin(_ b: CGRect, in fitted: CGRect) -> CGRect {
|
||||
guard fitted.width > 0 else { return b }
|
||||
let w = min(b.width, fitted.width)
|
||||
let h = min(b.height, fitted.height)
|
||||
let x = min(max(b.minX, fitted.minX), fitted.maxX - w)
|
||||
let y = min(max(b.minY, fitted.minY), fitted.maxY - h)
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
|
||||
/// 缩放时夹紧尺寸,保证不超出图片显示区。
|
||||
private func clampSize(_ b: CGRect, in fitted: CGRect) -> CGRect {
|
||||
guard fitted.width > 0 else { return b }
|
||||
let w = max(minBox, min(b.width, fitted.maxX - b.minX))
|
||||
let h = max(minBox, min(b.height, fitted.maxY - b.minY))
|
||||
return CGRect(x: b.minX, y: b.minY, width: w, height: h)
|
||||
}
|
||||
|
||||
private func moveGesture(in fitted: CGRect) -> some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { v in
|
||||
if dragStartBox == nil { dragStartBox = box }
|
||||
let start = dragStartBox ?? box
|
||||
let moved = start.offsetBy(dx: v.translation.width, dy: v.translation.height)
|
||||
box = clampOrigin(moved, in: fitted)
|
||||
}
|
||||
.onEnded { _ in dragStartBox = nil }
|
||||
}
|
||||
|
||||
private func resizeGesture(in fitted: CGRect) -> some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { v in
|
||||
if resizeStartBox == nil { resizeStartBox = box }
|
||||
let start = resizeStartBox ?? box
|
||||
let grown = CGRect(
|
||||
x: start.minX, y: start.minY,
|
||||
width: start.width + v.translation.width,
|
||||
height: start.height + v.translation.height
|
||||
)
|
||||
box = clampSize(grown, in: fitted)
|
||||
}
|
||||
.onEnded { _ in resizeStartBox = nil }
|
||||
}
|
||||
|
||||
// MARK: - 识别
|
||||
|
||||
private func runRecognize() {
|
||||
guard !isRecognizing, fittedRect.width > 1, box.width > 1, box.height > 1 else { return }
|
||||
let cropped = RegionImageCropper.cropAspectFit(image, box: box, imageFrame: fittedRect)
|
||||
recognizeTask?.cancel()
|
||||
isRecognizing = true
|
||||
statusText = String(appLoc: "本地识别中…")
|
||||
recognizeTask = Task {
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeoutSeconds))
|
||||
recognizeTask?.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
let result = await recognize(cropped)
|
||||
isRecognizing = false
|
||||
if Task.isCancelled {
|
||||
statusText = String(appLoc: "识别超时,挪一下框再试或手动补充")
|
||||
return
|
||||
}
|
||||
items = result.items
|
||||
statusText = result.warning
|
||||
?? String(appLoc: "识别到 \(result.items.count) 项,可继续挪框或进入核对")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user