feat(quick): 异常项快拍改为局部小框 + VL 识别
将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径: 小框拍局部 → Qwen-VL 只抽 indicators → 用户确认逐项编辑 → 存成独立 Indicator(不建 Report、不留原图,与「记录指标」统一落库)。 - RegionCameraView: AVFoundation 实时预览 + 居中小框,快门后按 metadataOutputRectConverted 裁剪到框内区域;含裁剪纯函数与权限态。 - VLPrompts.regionExtraction(): 局部识别 prompt,严格 JSON 只要 indicators。 - CaptureService.recognizeRegion(): 临时文件推理后即删,不写 Vault; 新增 parseIndicatorsJSON / extractBalancedJSON 解析容错。 - QuickRegionConfirmView: 异常项高亮置顶、默认勾选,可编辑/增删/选纳入。 - QuickRegionCaptureFlow: 状态机 idle→analyzing→confirm,30s 超时回退手动。 - RootView: .quick 路由改指向新流程(.archive 仍走 UnifiedCaptureFlow)。 - 删除 5 个无引用的旧 mockup(A1/A2/A3/SmartFramer/QuickCaptureFlow)。 模拟器无相机退化为相册整图;小框裁剪坐标需真机验证。 设计见 docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
349
康康/Features/Quick/RegionCameraView.swift
Normal file
349
康康/Features/Quick/RegionCameraView.swift
Normal file
@@ -0,0 +1,349 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// 异常项快拍 · 局部相机。
|
||||
/// 实时预览 + 居中小框 + 快门 → **裁剪到小框区域**的 UIImage 回调。
|
||||
/// 只在真机可用(模拟器无相机,QuickRegionCaptureFlow 退化到 PhotoPicker)。
|
||||
///
|
||||
/// 裁剪原理:`previewLayer.metadataOutputRectConverted(fromLayerRect:)` 把屏上小框换算成
|
||||
/// 归一化(0-1)裁剪 rect;先把拍到的照片方向 bake 成 `.up`,再按归一化 rect 裁 CGImage。
|
||||
struct RegionCameraView: View {
|
||||
let onCapture: (UIImage) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@StateObject private var controller = RegionCameraController()
|
||||
@State private var authState: AuthState = .checking
|
||||
@State private var isCapturing = false
|
||||
@State private var flash = false
|
||||
|
||||
enum AuthState { case checking, authorized, denied }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
switch authState {
|
||||
case .checking:
|
||||
ProgressView().tint(.white)
|
||||
case .denied:
|
||||
deniedView
|
||||
case .authorized:
|
||||
cameraStack
|
||||
}
|
||||
|
||||
if flash {
|
||||
Color.white.ignoresSafeArea().transition(.opacity)
|
||||
}
|
||||
}
|
||||
.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(.system(size: 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 {
|
||||
Button {
|
||||
onCancel()
|
||||
} label: {
|
||||
Text("取消")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Capsule().fill(.black.opacity(0.35)))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
shutterButton
|
||||
.padding(.bottom, 36)
|
||||
}
|
||||
}
|
||||
|
||||
private var shutterButton: some View {
|
||||
Button {
|
||||
capture()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle().fill(.white).frame(width: 72, height: 72)
|
||||
Circle().strokeBorder(.white.opacity(0.6), lineWidth: 3).frame(width: 84, height: 84)
|
||||
if isCapturing {
|
||||
ProgressView().tint(.black)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isCapturing)
|
||||
.accessibilityLabel("拍摄异常项")
|
||||
}
|
||||
|
||||
private var deniedView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
Text("相机权限未开启")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(.white)
|
||||
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 36)
|
||||
HStack(spacing: 12) {
|
||||
Button("取消") { onCancel() }
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
|
||||
Button("去设置") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||
.background(Capsule().fill(.white))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 行为
|
||||
|
||||
private func capture() {
|
||||
guard !isCapturing else { return }
|
||||
isCapturing = true
|
||||
withAnimation(.easeOut(duration: 0.08)) { flash = true }
|
||||
controller.capture { image in
|
||||
withAnimation(.easeIn(duration: 0.15)) { flash = false }
|
||||
isCapturing = false
|
||||
guard let image else { return }
|
||||
onCapture(image)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveAuth() async {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
authState = .authorized
|
||||
case .notDetermined:
|
||||
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||||
authState = granted ? .authorized : .denied
|
||||
default:
|
||||
authState = .denied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
/// 按归一化 rect(原点左上、范围 0-1、与显示方向一致)裁剪 `.up` 方向的图。
|
||||
/// 入参 image 须已 bake 成 `.up`(见 `UIImage.normalizedUp()`)。越界自动夹紧;失败回退原图。
|
||||
static func crop(_ image: UIImage, normalizedRect: CGRect) -> UIImage {
|
||||
guard let cg = image.cgImage else { return image }
|
||||
let w = CGFloat(cg.width), h = CGFloat(cg.height)
|
||||
let nx = max(0, min(1, normalizedRect.origin.x))
|
||||
let ny = max(0, min(1, normalizedRect.origin.y))
|
||||
let nw = max(0, min(1 - nx, normalizedRect.size.width))
|
||||
let nh = max(0, min(1 - ny, normalizedRect.size.height))
|
||||
let rect = CGRect(x: nx * w, y: ny * h, width: nw * w, height: nh * h).integral
|
||||
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)。
|
||||
final class RegionCameraController: ObservableObject {
|
||||
weak var view: RegionPreviewUIView?
|
||||
func capture(_ completion: @escaping (UIImage?) -> Void) {
|
||||
guard let view else { completion(nil); return }
|
||||
view.capture(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
struct RegionCameraPreview: UIViewRepresentable {
|
||||
let controller: RegionCameraController
|
||||
|
||||
func makeUIView(context: Context) -> RegionPreviewUIView {
|
||||
let v = RegionPreviewUIView()
|
||||
controller.view = v
|
||||
return v
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: RegionPreviewUIView, context: Context) {}
|
||||
|
||||
static func dismantleUIView(_ uiView: RegionPreviewUIView, coordinator: ()) {
|
||||
uiView.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/// 实时预览 + 单张拍摄,拍完按小框裁剪。
|
||||
final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
||||
private let session = AVCaptureSession()
|
||||
private let output = AVCapturePhotoOutput()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var setupDone = false
|
||||
private var captureCompletion: ((UIImage?) -> Void)?
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
guard !setupDone, window != nil else { return }
|
||||
setupDone = true
|
||||
configure()
|
||||
}
|
||||
|
||||
private func configure() {
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .photo
|
||||
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else {
|
||||
session.commitConfiguration()
|
||||
return
|
||||
}
|
||||
session.addInput(input)
|
||||
if session.canAddOutput(output) { session.addOutput(output) }
|
||||
session.commitConfiguration()
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = bounds
|
||||
layer.addSublayer(preview)
|
||||
self.previewLayer = preview
|
||||
applyPortrait(preview.connection)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
/// 锁竖屏(iOS 17+ 用 videoRotationAngle,避免 videoOrientation 弃用告警)。
|
||||
private func applyPortrait(_ connection: AVCaptureConnection?) {
|
||||
guard let connection else { return }
|
||||
if connection.isVideoRotationAngleSupported(90) {
|
||||
connection.videoRotationAngle = 90
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
previewLayer?.frame = bounds
|
||||
}
|
||||
|
||||
func capture(completion: @escaping (UIImage?) -> Void) {
|
||||
guard session.isRunning else { completion(nil); return }
|
||||
captureCompletion = completion
|
||||
applyPortrait(output.connection(with: .video))
|
||||
output.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard session.isRunning else { return }
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.session.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func photoOutput(_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?) {
|
||||
let completion = captureCompletion
|
||||
captureCompletion = nil
|
||||
// 代理回调在 AVFoundation 私有队列,SwiftUI 状态更新必须切回主线程。
|
||||
let deliver: (UIImage?) -> Void = { result in
|
||||
DispatchQueue.main.async { completion?(result) }
|
||||
}
|
||||
guard error == nil,
|
||||
let data = photo.fileDataRepresentation(),
|
||||
let image = UIImage(data: data) else {
|
||||
deliver(nil)
|
||||
return
|
||||
}
|
||||
let upright = image.normalizedUp()
|
||||
guard let preview = previewLayer else {
|
||||
deliver(upright)
|
||||
return
|
||||
}
|
||||
// metadataOutputRectConverted 读 previewLayer 几何,回主线程算更稳。
|
||||
DispatchQueue.main.async {
|
||||
let box = RegionFraming.box(in: self.bounds.size)
|
||||
let normalized = preview.metadataOutputRectConverted(fromLayerRect: box)
|
||||
let cropped = RegionImageCropper.crop(upright, normalizedRect: normalized)
|
||||
completion?(cropped)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user