feat(iOS): 更新MNN后端模型配置优化性能 将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本 实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX 兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。 ```
262 lines
9.7 KiB
Swift
262 lines
9.7 KiB
Swift
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) 项,可继续挪框或进入核对")
|
|
}
|
|
}
|
|
}
|