```
feat(Quick): 异常项快拍流程重构为静态图框选识别模式 重构异常项快拍功能,将原有的局部小框拍摄改为整幅单拍后静态框选模式。 新流程为:整幅单拍/相册选择 → 静态图手动框选 → 框内OCR+LLM提取指标 → 核对 → 存储独立Indicator。 主要变更包括: - 移除实时预览小框拍摄模式,改为整幅拍摄后手动框选 - 新增RegionAdjustView组件用于静态图框选和识别 - 更新状态机流程:idle → adjust(静态图框选) → confirm → save - 修改识别逻辑,对框选区域进行OCR+LLM处理 - 更新相机组件为SingleShotCameraView,支持整幅拍摄 - 调整错误处理策略,识别失败时可挪框重试而非强制手动录入 - 优化本地化字符串,更新用户界面提示文案 ```
This commit is contained in:
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user