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

@@ -3,14 +3,11 @@ import AVFoundation
import UIKit
import Combine
/// ·
/// + + **** UIImage
/// (,QuickRegionCaptureFlow 退 PhotoPicker)
///
/// : bake `.up`(), aspect-fill
/// (view ) rect( `RegionImageCropper`)
/// `metadataOutputRectConverted` ,
struct RegionCameraView: View {
/// ·
/// + **** upright UIImage()
/// `RegionAdjustView`
/// (,`QuickRegionCaptureFlow` 退 PhotoPicker)
struct SingleShotCameraView: View {
let onCapture: (UIImage) -> Void
let onCancel: () -> Void
@@ -31,7 +28,9 @@ struct RegionCameraView: View {
case .denied:
deniedView
case .authorized:
cameraStack
RegionCameraPreview(controller: controller, cropsToBox: false)
.ignoresSafeArea()
controlsOverlay
}
if flash {
@@ -41,47 +40,6 @@ struct RegionCameraView: View {
.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 {
VStack {
HStack {
@@ -102,6 +60,14 @@ struct RegionCameraView: View {
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
.padding(.bottom, 36)
}
@@ -120,7 +86,7 @@ struct RegionCameraView: View {
}
}
.disabled(isCapturing)
.accessibilityLabel("拍摄异常项")
.accessibilityLabel("拍摄照片")
}
private var deniedView: some View {
@@ -155,8 +121,6 @@ struct RegionCameraView: View {
}
}
// MARK: -
private func capture() {
guard !isCapturing else { return }
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
/// SwiftUI ,(weak UIView)
@@ -259,9 +159,12 @@ final class RegionCameraController: ObservableObject {
struct RegionCameraPreview: UIViewRepresentable {
let controller: RegionCameraController
/// false()
var cropsToBox: Bool = false
func makeUIView(context: Context) -> RegionPreviewUIView {
let v = RegionPreviewUIView()
v.cropsToBox = cropsToBox
controller.view = v
return v
}
@@ -273,8 +176,10 @@ struct RegionCameraPreview: UIViewRepresentable {
}
}
/// + ,
/// + `cropsToBox` , upright
final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
var cropsToBox = false
private let session = AVCaptureSession()
private let output = AVCapturePhotoOutput()
private var previewLayer: AVCaptureVideoPreviewLayer?
@@ -356,12 +261,11 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
return
}
let upright = image.normalizedUp()
guard previewLayer != nil else {
// :,
guard cropsToBox, previewLayer != nil else {
deliver(upright)
return
}
// : .resizeAspectFill bounds,,
// aspect-fill rect bounds 线
DispatchQueue.main.async {
let viewSize = self.bounds.size
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)) }
}
}