feat(Quick): 异常项快拍流程重构为静态图框选识别模式

重构异常项快拍功能,将原有的局部小框拍摄改为整幅单拍后静态框选模式。
新流程为:整幅单拍/相册选择 → 静态图手动框选 → 框内OCR+LLM提取指标 → 核对 → 存储独立Indicator。

主要变更包括:
- 移除实时预览小框拍摄模式,改为整幅拍摄后手动框选
- 新增RegionAdjustView组件用于静态图框选和识别
- 更新状态机流程:idle → adjust(静态图框选) → confirm → save
- 修改识别逻辑,对框选区域进行OCR+LLM处理
- 更新相机组件为SingleShotCameraView,支持整幅拍摄
- 调整错误处理策略,识别失败时可挪框重试而非强制手动录入
- 优化本地化字符串,更新用户界面提示文案
```
This commit is contained in:
link2026
2026-06-07 14:27:25 +08:00
parent 77a4ee1c37
commit ac11aa0f99
5 changed files with 509 additions and 313 deletions

View File

@@ -1,32 +1,25 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import UIKit import UIKit
import Combine
/// · /// ·
/// VL ( indicators) Indicator( Report) /// ()/ () OCR+LLM Indicator
/// ///
/// : /// :
/// ``` /// ```
/// idle(/) analyzing(croppedImage) confirm(items) /// idle(/) adjust(,) confirm() save dismiss
/// / /// confirm idle
/// confirm( + warning)
/// confirm save dismiss · confirm idle
/// ``` /// ```
/// /: adjust , confirm (§3.2 退线)
struct QuickRegionCaptureFlow: View { struct QuickRegionCaptureFlow: View {
@Environment(\.modelContext) private var ctx @Environment(\.modelContext) private var ctx
let onClose: () -> Void let onClose: () -> Void
@State private var phase: Phase = .idle @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 { enum Phase {
case idle case idle
case analyzing(image: UIImage) case adjust(image: UIImage)
case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?) case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?)
} }
@@ -42,28 +35,17 @@ struct QuickRegionCaptureFlow: View {
captureEntry captureEntry
.ignoresSafeArea() .ignoresSafeArea()
case .analyzing(let image): case .adjust(let image):
NavigationStack { RegionAdjustView(
AnalyzingRegionView( image: image,
image: image, recognize: { await recognizeRegion($0) },
timeoutSeconds: analyzeTimeoutSeconds, onProceed: { items in
onCancel: { phase = .confirm(image: image, items: items, warning: nil)
analyzeTask?.cancel() },
analyzeTask = nil onRetake: { phase = .idle },
// (,) onCancel: { onClose() }
phase = .confirm(image: image, items: [], )
warning: String(appLoc: "已取消识别,手动补充或重拍")) .ignoresSafeArea()
}
)
.navigationTitle(String(appLoc: "本地识别中…"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { cancelAll() }
.foregroundStyle(Tj.Palette.text)
}
}
}
case .confirm(let image, let items, let warning): case .confirm(let image, let items, let warning):
NavigationStack { NavigationStack {
@@ -72,14 +54,14 @@ struct QuickRegionCaptureFlow: View {
items: items, items: items,
warning: warning, warning: warning,
onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) }, onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) },
onCancel: cancelAll, onCancel: { onClose() },
onRetake: { phase = .idle } onRetake: { phase = .idle }
) )
.navigationTitle(String(appLoc: "核对异常项")) .navigationTitle(String(appLoc: "核对异常项"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button("取消") { cancelAll() } Button("取消") { onClose() }
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
} }
} }
@@ -87,100 +69,59 @@ struct QuickRegionCaptureFlow: View {
} }
} }
// MARK: - :()/ () // MARK: - :()/ ()
// RegionCameraView ( 1-2 ); ·
// , ,VL VisionKit :
// + ,VL / 退
@ViewBuilder @ViewBuilder
private var captureEntry: some View { private var captureEntry: some View {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
PhotoPickerSheet( PhotoPickerSheet(
onFinish: { imgs in handleScanned(imgs) }, onFinish: { handlePicked($0) },
onCancel: onClose onCancel: onClose
) )
#else #else
if DocumentScannerView.isSupported { SingleShotCameraView(
DocumentScannerView( onCapture: { phase = .adjust(image: $0) },
onFinish: { imgs in handleScanned(imgs) }, onCancel: onClose
onCancel: onClose )
)
} else {
PhotoPickerSheet(
onFinish: { imgs in handleScanned(imgs) },
onCancel: onClose
)
}
#endif #endif
} }
/// /:(); /// /:;(=)
private func handleScanned(_ images: [UIImage]) { private func handlePicked(_ images: [UIImage]) {
if let first = images.first { if let first = images.first {
startAnalyze(image: first) phase = .adjust(image: first)
} else { } else {
onClose() onClose()
} }
} }
// MARK: - // MARK: - ( OCR LLM)
private func startAnalyze(image: UIImage) { /// /,( RegionAdjustView )
analyzeTask?.cancel() /// :Vision OCR Qwen3-1.7B ( indicator-capture-ocr-llm)
phase = .analyzing(image: image) private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
let timeout = analyzeTimeoutSeconds do {
// MainActor ,Task{} , phase 线, let text = try await OCRService.recognizeText(in: image)
// :Vision OCR Qwen3-1.7B LLM ( 3B VL ) if Task.isCancelled { return ([], nil) } // :
analyzeTask = Task { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍") #if DEBUG
print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---")
let watchdog = Task { #endif
try? await Task.sleep(for: .seconds(timeout)) if trimmed.isEmpty {
analyzeTask?.cancel() return ([], String(appLoc: "没识别到文字,挪一下框再试"))
}
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)"))
} }
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, QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
range: $0.range, status: $0.status, include: true) range: $0.range, status: $0.status, include: true)
} }
// (stable):high/low ,normal
return mapped.enumerated().sorted { a, b in return mapped.enumerated().sorted { a, b in
let aAbn = a.element.status != .normal let aAbn = a.element.status != .normal
let bAbn = b.element.status != .normal let bAbn = b.element.status != .normal
@@ -199,13 +139,7 @@ struct QuickRegionCaptureFlow: View {
}.map { $0.element } }.map { $0.element }
} }
// MARK: - / // MARK: -
private func cancelAll() {
analyzeTask?.cancel()
analyzeTask = nil
onClose()
}
/// Indicator(): Report Asset seriesKey /// Indicator(): Report Asset seriesKey
private func save(items: [QuickRegionItem], capturedAt: Date) { private func save(items: [QuickRegionItem], capturedAt: Date) {
@@ -230,56 +164,3 @@ struct QuickRegionCaptureFlow: View {
onClose() 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 }
}
}

View 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) 项,可继续挪框或进入核对")
}
}
}

View File

@@ -3,14 +3,11 @@ import AVFoundation
import UIKit import UIKit
import Combine import Combine
/// · /// ·
/// + + **** UIImage /// + **** upright UIImage()
/// (,QuickRegionCaptureFlow 退 PhotoPicker) /// `RegionAdjustView`
/// /// (,`QuickRegionCaptureFlow` 退 PhotoPicker)
/// : bake `.up`(), aspect-fill struct SingleShotCameraView: View {
/// (view ) rect( `RegionImageCropper`)
/// `metadataOutputRectConverted` ,
struct RegionCameraView: View {
let onCapture: (UIImage) -> Void let onCapture: (UIImage) -> Void
let onCancel: () -> Void let onCancel: () -> Void
@@ -31,7 +28,9 @@ struct RegionCameraView: View {
case .denied: case .denied:
deniedView deniedView
case .authorized: case .authorized:
cameraStack RegionCameraPreview(controller: controller, cropsToBox: false)
.ignoresSafeArea()
controlsOverlay
} }
if flash { if flash {
@@ -41,47 +40,6 @@ struct RegionCameraView: View {
.task { await resolveAuth() } .task { await resolveAuth() }
} }
// MARK: - + +
private var cameraStack: some View {
GeometryReader { proxy in
let box = RegionFraming.box(in: proxy.size)
ZStack {
RegionCameraPreview(controller: controller)
.ignoresSafeArea()
// (even-odd ),
Canvas { ctx, size in
var path = Path(CGRect(origin: .zero, size: size))
path.addPath(Path(roundedRect: box, cornerRadius: Tj.Radius.md))
ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true))
}
.ignoresSafeArea()
.allowsHitTesting(false)
//
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Color.white.opacity(0.95),
style: StrokeStyle(lineWidth: 2, dash: [8, 6]))
.frame(width: box.width, height: box.height)
.position(x: box.midX, y: box.midY)
.allowsHitTesting(false)
//
Text("把异常项放进框里 · 对准一两行")
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(.black.opacity(0.4)))
.position(x: box.midX, y: box.minY - 22)
.allowsHitTesting(false)
controlsOverlay
}
}
}
private var controlsOverlay: some View { private var controlsOverlay: some View {
VStack { VStack {
HStack { HStack {
@@ -102,6 +60,14 @@ struct RegionCameraView: View {
Spacer() Spacer()
Text("拍一张含异常指标的照片 · 拍完再框选")
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(.black.opacity(0.4)))
.padding(.bottom, 14)
shutterButton shutterButton
.padding(.bottom, 36) .padding(.bottom, 36)
} }
@@ -120,7 +86,7 @@ struct RegionCameraView: View {
} }
} }
.disabled(isCapturing) .disabled(isCapturing)
.accessibilityLabel("拍摄异常项") .accessibilityLabel("拍摄照片")
} }
private var deniedView: some View { private var deniedView: some View {
@@ -155,8 +121,6 @@ struct RegionCameraView: View {
} }
} }
// MARK: -
private func capture() { private func capture() {
guard !isCapturing else { return } guard !isCapturing else { return }
isCapturing = true isCapturing = true
@@ -182,70 +146,6 @@ struct RegionCameraView: View {
} }
} }
// MARK: - (UIView SwiftUI ,)
enum RegionFraming {
/// 84% , 160 28%
static func box(in size: CGSize) -> CGRect {
guard size.width > 0, size.height > 0 else { return .zero }
let w = size.width * 0.84
let h = min(160, size.height * 0.28)
let x = (size.width - w) / 2
let y = (size.height - h) / 2 - size.height * 0.06
return CGRect(x: x, y: y, width: w, height: h)
}
}
// MARK: -
enum RegionImageCropper {
/// (view ) `.resizeAspectFill` `.up` rect
/// : aspect-fill viewSize,
/// `metadataOutputRectConverted`(****,
/// x/y ,, RegionImageCropperTests)
static func cropRect(photoPixelSize p: CGSize, box: CGRect, in viewSize: CGSize) -> CGRect {
guard p.width > 0, p.height > 0, viewSize.width > 0, viewSize.height > 0 else { return .zero }
// aspect-fill:,
let scale = max(viewSize.width / p.width, viewSize.height / p.height)
let scaledW = p.width * scale
let scaledH = p.height * scale
// ,
let ox = (viewSize.width - scaledW) / 2
let oy = (viewSize.height - scaledH) / 2
// :,
var x = (box.minX - ox) / scale
var y = (box.minY - oy) / scale
var w = box.width / scale
var h = box.height / scale
//
x = max(0, min(p.width, x))
y = max(0, min(p.height, y))
w = max(0, min(p.width - x, w))
h = max(0, min(p.height - y, h))
return CGRect(x: x, y: y, width: w, height: h).integral
}
/// `.up` (`box` / `viewSize` view );退
static func crop(_ image: UIImage, box: CGRect, viewSize: CGSize) -> UIImage {
guard let cg = image.cgImage else { return image }
let rect = cropRect(photoPixelSize: CGSize(width: cg.width, height: cg.height),
box: box, in: viewSize)
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return image }
return UIImage(cgImage: cropped, scale: image.scale, orientation: .up)
}
}
extension UIImage {
/// EXIF bake , `.up` ,便 rect CGImage
func normalizedUp() -> UIImage {
if imageOrientation == .up { return self }
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) }
}
}
// MARK: - AVFoundation // MARK: - AVFoundation
/// SwiftUI ,(weak UIView) /// SwiftUI ,(weak UIView)
@@ -259,9 +159,12 @@ final class RegionCameraController: ObservableObject {
struct RegionCameraPreview: UIViewRepresentable { struct RegionCameraPreview: UIViewRepresentable {
let controller: RegionCameraController let controller: RegionCameraController
/// false()
var cropsToBox: Bool = false
func makeUIView(context: Context) -> RegionPreviewUIView { func makeUIView(context: Context) -> RegionPreviewUIView {
let v = RegionPreviewUIView() let v = RegionPreviewUIView()
v.cropsToBox = cropsToBox
controller.view = v controller.view = v
return v return v
} }
@@ -273,8 +176,10 @@ struct RegionCameraPreview: UIViewRepresentable {
} }
} }
/// + , /// + `cropsToBox` , upright
final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate { final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
var cropsToBox = false
private let session = AVCaptureSession() private let session = AVCaptureSession()
private let output = AVCapturePhotoOutput() private let output = AVCapturePhotoOutput()
private var previewLayer: AVCaptureVideoPreviewLayer? private var previewLayer: AVCaptureVideoPreviewLayer?
@@ -356,12 +261,11 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
return return
} }
let upright = image.normalizedUp() let upright = image.normalizedUp()
guard previewLayer != nil else { // :,
guard cropsToBox, previewLayer != nil else {
deliver(upright) deliver(upright)
return return
} }
// : .resizeAspectFill bounds,,
// aspect-fill rect bounds 线
DispatchQueue.main.async { DispatchQueue.main.async {
let viewSize = self.bounds.size let viewSize = self.bounds.size
let box = RegionFraming.box(in: viewSize) let box = RegionFraming.box(in: viewSize)
@@ -370,3 +274,93 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
} }
} }
} }
// MARK: - ( fill , cropsToBox )
enum RegionFraming {
/// 84% , 160 28%
static func box(in size: CGSize) -> CGRect {
guard size.width > 0, size.height > 0 else { return .zero }
let w = size.width * 0.84
let h = min(160, size.height * 0.28)
let x = (size.width - w) / 2
let y = (size.height - h) / 2 - size.height * 0.06
return CGRect(x: x, y: y, width: w, height: h)
}
}
// MARK: -
enum RegionImageCropper {
/// (view ) `.resizeAspectFill` `.up` rect
/// : aspect-fill viewSize,
/// cropsToBox
static func cropRect(photoPixelSize p: CGSize, box: CGRect, in viewSize: CGSize) -> CGRect {
guard p.width > 0, p.height > 0, viewSize.width > 0, viewSize.height > 0 else { return .zero }
let scale = max(viewSize.width / p.width, viewSize.height / p.height)
let scaledW = p.width * scale
let scaledH = p.height * scale
let ox = (viewSize.width - scaledW) / 2
let oy = (viewSize.height - scaledH) / 2
var x = (box.minX - ox) / scale
var y = (box.minY - oy) / scale
var w = box.width / scale
var h = box.height / scale
x = max(0, min(p.width, x))
y = max(0, min(p.height, y))
w = max(0, min(p.width - x, w))
h = max(0, min(p.height - y, h))
return CGRect(x: x, y: y, width: w, height: h).integral
}
/// `.up` (aspect-fill );退
static func crop(_ image: UIImage, box: CGRect, viewSize: CGSize) -> UIImage {
guard let cg = image.cgImage else { return image }
let rect = cropRect(photoPixelSize: CGSize(width: cg.width, height: cg.height),
box: box, in: viewSize)
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return image }
return UIImage(cgImage: cropped, scale: image.scale, orientation: .up)
}
/// aspect-FIT : `.scaledToFit` `imageFrame`(view ,
/// `AVMakeRect(aspectRatio:insideRect:)` ), rect
/// `RegionAdjustView`
static func cropRectAspectFit(photoPixelSize p: CGSize, box: CGRect, imageFrame f: CGRect) -> CGRect {
guard p.width > 0, p.height > 0, f.width > 0, f.height > 0 else { return .zero }
// aspect-fit: imageFrame ,
let scale = f.width / p.width
guard scale > 0 else { return .zero }
var x = (box.minX - f.minX) / scale
var y = (box.minY - f.minY) / scale
var w = box.width / scale
var h = box.height / scale
x = max(0, min(p.width, x))
y = max(0, min(p.height, y))
w = max(0, min(p.width - x, w))
h = max(0, min(p.height - y, h))
return CGRect(x: x, y: y, width: w, height: h).integral
}
/// (aspect-fit);退
static func cropAspectFit(_ image: UIImage, box: CGRect, imageFrame: CGRect) -> UIImage {
let up = image.normalizedUp()
guard let cg = up.cgImage else { return image }
let rect = cropRectAspectFit(
photoPixelSize: CGSize(width: cg.width, height: cg.height),
box: box, imageFrame: imageFrame
)
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return up }
return UIImage(cgImage: cropped, scale: up.scale, orientation: .up)
}
}
extension UIImage {
/// EXIF bake , `.up` ,便 rect CGImage
func normalizedUp() -> UIImage {
if imageOrientation == .up { return self }
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) }
}
}

View File

@@ -997,9 +997,6 @@
} }
} }
} }
},
"100%% 本地推理 · 已用 %llds" : {
}, },
"2026 / 05 / 25 · 协和医院体检中心" : { "2026 / 05 / 25 · 协和医院体检中心" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -5244,9 +5241,6 @@
} }
} }
} }
},
"已取消识别,手动补充或重拍" : {
}, },
"已处理 %.1fs · 比云端快 4.2×" : { "已处理 %.1fs · 比云端快 4.2×" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -6170,9 +6164,6 @@
} }
} }
} }
},
"快超时了,>%llds 会自动转手动录入" : {
}, },
"性别" : { "性别" : {
"localizations" : { "localizations" : {
@@ -6543,9 +6534,6 @@
}, },
"手动记录" : { "手动记录" : {
},
"把异常项放进框里 · 对准一两行" : {
}, },
"抑郁/焦虑" : { "抑郁/焦虑" : {
"localizations" : { "localizations" : {
@@ -6682,6 +6670,9 @@
} }
} }
} }
},
"拍一张含异常指标的照片 · 拍完再框选" : {
}, },
"拍到的局部" : { "拍到的局部" : {
@@ -6710,9 +6701,6 @@
} }
} }
} }
},
"拍摄异常项" : {
}, },
"拍摄报告" : { "拍摄报告" : {
"localizations" : { "localizations" : {
@@ -6735,6 +6723,9 @@
} }
} }
} }
},
"拍摄照片" : {
}, },
"拍摄识别" : { "拍摄识别" : {
"localizations" : { "localizations" : {
@@ -6804,6 +6795,9 @@
} }
} }
} }
},
"拖动方框对准要识别的指标,可拖右下角缩放" : {
}, },
"持续" : { "持续" : {
"localizations" : { "localizations" : {
@@ -8797,6 +8791,9 @@
} }
} }
} }
},
"框住异常指标" : {
}, },
"档案 · %lld" : { "档案 · %lld" : {
"localizations" : { "localizations" : {
@@ -9229,10 +9226,10 @@
"没有识别到指标,点「加一项」手动补充,或返回重拍" : { "没有识别到指标,点「加一项」手动补充,或返回重拍" : {
}, },
"没识别到文字,手动补充或重拍" : { "没识别到文字,挪一下框再试" : {
}, },
"没读出指标,手动补充或重拍" : { "没读出指标,挪一下框再试" : {
}, },
"测试 PROMPT" : { "测试 PROMPT" : {
@@ -10972,6 +10969,9 @@
} }
} }
} }
},
"识别到 %lld 项,可继续挪框或进入核对" : {
}, },
"识别到的指标 (%lld)" : { "识别到的指标 (%lld)" : {
@@ -11045,6 +11045,9 @@
} }
} }
} }
},
"识别超时,挪一下框再试或手动补充" : {
}, },
"识别超时(>%llds)" : { "识别超时(>%llds)" : {
"localizations" : { "localizations" : {
@@ -11111,9 +11114,6 @@
} }
} }
} }
},
"识别超时(>%llds),手动补充或重拍" : {
}, },
"该测%@了" : { "该测%@了" : {
"localizations" : { "localizations" : {
@@ -11414,6 +11414,9 @@
} }
} }
} }
},
"跳过 · 手动录入" : {
}, },
"身体档案" : { "身体档案" : {
"localizations" : { "localizations" : {
@@ -11918,6 +11921,9 @@
} }
} }
} }
},
"进入核对(%lld)" : {
}, },
"进行中" : { "进行中" : {
"localizations" : { "localizations" : {

View File

@@ -1,5 +1,6 @@
import XCTest import XCTest
import CoreGraphics import CoreGraphics
import AVFoundation
@testable import @testable import
/// ///
@@ -58,4 +59,57 @@ final class RegionImageCropperTests: XCTestCase {
in: CGSize(width: 100, height: 200)), in: CGSize(width: 100, height: 200)),
.zero) .zero)
} }
// MARK: - aspect-FIT()
/// aspect-fit ; rect
func testAspectFitWideBoxYieldsLandscapeInsideBounds() {
let photo = CGSize(width: 3024, height: 4032)
let view = CGSize(width: 393, height: 852)
let fitted = AVMakeRect(aspectRatio: photo, insideRect: CGRect(origin: .zero, size: view))
// fitted
let box = CGRect(x: fitted.minX + 30, y: fitted.midY - 50, width: fitted.width - 60, height: 100)
let rect = RegionImageCropper.cropRectAspectFit(photoPixelSize: photo, box: box, imageFrame: fitted)
XCTAssertGreaterThan(rect.width, rect.height)
XCTAssertGreaterThanOrEqual(rect.minX, 0)
XCTAssertGreaterThanOrEqual(rect.minY, 0)
XCTAssertLessThanOrEqual(rect.maxX, photo.width)
XCTAssertLessThanOrEqual(rect.maxY, photo.height)
}
/// ()
func testAspectFitFullFrameBoxYieldsFullPhoto() {
let photo = CGSize(width: 2000, height: 1000) //
let view = CGSize(width: 400, height: 800)
let fitted = AVMakeRect(aspectRatio: photo, insideRect: CGRect(origin: .zero, size: view))
let rect = RegionImageCropper.cropRectAspectFit(photoPixelSize: photo, box: fitted, imageFrame: fitted)
XCTAssertEqual(rect.minX, 0, accuracy: 1)
XCTAssertEqual(rect.minY, 0, accuracy: 1)
XCTAssertEqual(rect.width, photo.width, accuracy: 2)
XCTAssertEqual(rect.height, photo.height, accuracy: 2)
}
/// : rect
func testAspectFitPreservesBoxAspectRatio() {
let photo = CGSize(width: 3024, height: 4032)
let view = CGSize(width: 393, height: 852)
let fitted = AVMakeRect(aspectRatio: photo, insideRect: CGRect(origin: .zero, size: view))
let box = CGRect(x: fitted.minX + 20, y: fitted.minY + 40, width: 180, height: 120)
let rect = RegionImageCropper.cropRectAspectFit(photoPixelSize: photo, box: box, imageFrame: fitted)
XCTAssertEqual(rect.width / rect.height, box.width / box.height, accuracy: 0.05)
}
func testAspectFitZeroInputsReturnZero() {
XCTAssertEqual(
RegionImageCropper.cropRectAspectFit(photoPixelSize: .zero,
box: CGRect(x: 0, y: 0, width: 10, height: 10),
imageFrame: CGRect(x: 0, y: 0, width: 100, height: 100)),
.zero)
}
} }