From ac11aa0f99b29403da6347f41562575a44cd6a67 Mon Sep 17 00:00:00 2001 From: link2026 Date: Sun, 7 Jun 2026 14:27:25 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(Quick):=20=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E9=A1=B9=E5=BF=AB=E6=8B=8D=E6=B5=81=E7=A8=8B=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=BA=E9=9D=99=E6=80=81=E5=9B=BE=E6=A1=86=E9=80=89=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构异常项快拍功能,将原有的局部小框拍摄改为整幅单拍后静态框选模式。 新流程为:整幅单拍/相册选择 → 静态图手动框选 → 框内OCR+LLM提取指标 → 核对 → 存储独立Indicator。 主要变更包括: - 移除实时预览小框拍摄模式,改为整幅拍摄后手动框选 - 新增RegionAdjustView组件用于静态图框选和识别 - 更新状态机流程:idle → adjust(静态图框选) → confirm → save - 修改识别逻辑,对框选区域进行OCR+LLM处理 - 更新相机组件为SingleShotCameraView,支持整幅拍摄 - 调整错误处理策略,识别失败时可挪框重试而非强制手动录入 - 优化本地化字符串,更新用户界面提示文案 ``` --- .../Features/Quick/QuickRegionCaptureFlow.swift | 225 ++++----------- 康康/Features/Quick/RegionAdjustView.swift | 261 ++++++++++++++++++ 康康/Features/Quick/RegionCameraView.swift | 236 ++++++++-------- 康康/Localizable.xcstrings | 46 +-- 康康Tests/RegionImageCropperTests.swift | 54 ++++ 5 files changed, 509 insertions(+), 313 deletions(-) create mode 100644 康康/Features/Quick/RegionAdjustView.swift diff --git a/康康/Features/Quick/QuickRegionCaptureFlow.swift b/康康/Features/Quick/QuickRegionCaptureFlow.swift index 36f7447..b14b177 100644 --- a/康康/Features/Quick/QuickRegionCaptureFlow.swift +++ b/康康/Features/Quick/QuickRegionCaptureFlow.swift @@ -1,32 +1,25 @@ import SwiftUI import SwiftData import UIKit -import Combine /// 异常项快拍 · 统一流程。 -/// 局部小框拍摄 → VL 识别(只抽 indicators)→ 确认 → 存成独立 Indicator(不建 Report、不留图)。 +/// 整幅单拍(真机)/ 相册(模拟器)→ 静态图手动框选 → 框内 OCR+LLM 抽指标 → 核对 → 存独立 Indicator。 /// /// 状态机: /// ``` -/// idle(相机/相册) → analyzing(croppedImage) → confirm(items) -/// ↓ 失败/超时 -/// confirm(空 + warning) -/// confirm → save → dismiss · confirm → 重拍 → idle +/// idle(相机/相册) → adjust(静态图框选,可反复识别) → confirm(核对) → save → dismiss +/// confirm → 重拍 → idle /// ``` +/// 识别失败/超时不卡死:停在 adjust 提示挪框重试,或直接进 confirm 手动补(§3.2 失败回退红线)。 struct QuickRegionCaptureFlow: View { @Environment(\.modelContext) private var ctx let onClose: () -> Void @State private var phase: Phase = .idle - @State private var analyzeTask: Task? = nil - - /// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。 - /// 整页化验单指标多、生成 token 多,30s 偏紧,放宽到 60s。 - private let analyzeTimeoutSeconds: Int = 60 enum Phase { case idle - case analyzing(image: UIImage) + case adjust(image: UIImage) case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?) } @@ -42,28 +35,17 @@ struct QuickRegionCaptureFlow: View { captureEntry .ignoresSafeArea() - case .analyzing(let image): - NavigationStack { - AnalyzingRegionView( - image: image, - timeoutSeconds: analyzeTimeoutSeconds, - onCancel: { - analyzeTask?.cancel() - analyzeTask = nil - // 取消识别 → 直接进确认页手动补充(图仍在内存,可重拍) - phase = .confirm(image: image, items: [], - warning: String(appLoc: "已取消识别,手动补充或重拍")) - } - ) - .navigationTitle(String(appLoc: "本地识别中…")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("取消") { cancelAll() } - .foregroundStyle(Tj.Palette.text) - } - } - } + case .adjust(let image): + RegionAdjustView( + image: image, + recognize: { await recognizeRegion($0) }, + onProceed: { items in + phase = .confirm(image: image, items: items, warning: nil) + }, + onRetake: { phase = .idle }, + onCancel: { onClose() } + ) + .ignoresSafeArea() case .confirm(let image, let items, let warning): NavigationStack { @@ -72,14 +54,14 @@ struct QuickRegionCaptureFlow: View { items: items, warning: warning, onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) }, - onCancel: cancelAll, + onCancel: { onClose() }, onRetake: { phase = .idle } ) .navigationTitle(String(appLoc: "核对异常项")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { - Button("取消") { cancelAll() } + Button("取消") { onClose() } .foregroundStyle(Tj.Palette.text) } } @@ -87,100 +69,59 @@ struct QuickRegionCaptureFlow: View { } } - // MARK: - 入口:整页文档扫描(真机)/ 相册(模拟器或不支持) + // MARK: - 入口:整幅单拍(真机)/ 相册(模拟器或无相机) - // 旧实现用 RegionCameraView 的「细条小框」(为 1-2 行异常项设计);并入「记录指标 · 拍照识别」后 - // 用户会拍整张化验单,塞进细条须离远拍 → 小字像素过低,VL 读不出。改用 VisionKit 整页扫描: - // 全分辨率 + 自动透视校正,VL 能读清整表。模拟器 / 不支持时回退相册选图。 @ViewBuilder private var captureEntry: some View { #if targetEnvironment(simulator) PhotoPickerSheet( - onFinish: { imgs in handleScanned(imgs) }, + onFinish: { handlePicked($0) }, onCancel: onClose ) #else - if DocumentScannerView.isSupported { - DocumentScannerView( - onFinish: { imgs in handleScanned(imgs) }, - onCancel: onClose - ) - } else { - PhotoPickerSheet( - onFinish: { imgs in handleScanned(imgs) }, - onCancel: onClose - ) - } + SingleShotCameraView( + onCapture: { phase = .adjust(image: $0) }, + onCancel: onClose + ) #endif } - /// 扫描/选图回来:取首页跑识别(单张化验单通常一页);无图则关闭。 - private func handleScanned(_ images: [UIImage]) { + /// 拍/选回来:取首张进框选;无图则关闭(「只能拍一张」=只用第一张)。 + private func handlePicked(_ images: [UIImage]) { if let first = images.first { - startAnalyze(image: first) + phase = .adjust(image: first) } else { onClose() } } - // MARK: - 识别 + // MARK: - 识别(框内子图 → OCR → LLM) - private func startAnalyze(image: UIImage) { - analyzeTask?.cancel() - phase = .analyzing(image: image) - let timeout = analyzeTimeoutSeconds - // 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。 - // 新链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B LLM 结构化抽指标(替代 3B VL 直读图)。 - analyzeTask = Task { - let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍") - - let watchdog = Task { - try? await Task.sleep(for: .seconds(timeout)) - analyzeTask?.cancel() - } - 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)")) + /// 对已裁好的框内子图跑识别。失败/超时返回提示文案,绝不抛出(由 RegionAdjustView 展示)。 + /// 链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B 结构化抽指标(对齐 indicator-capture-ocr-llm)。 + private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) { + do { + let text = try await OCRService.recognizeText(in: image) + if Task.isCancelled { return ([], nil) } // 超时:文案由调用方给 + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + #if DEBUG + print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---") + #endif + if trimmed.isEmpty { + return ([], String(appLoc: "没识别到文字,挪一下框再试")) } + 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, range: $0.range, status: $0.status, include: true) } - // 异常优先(stable):high/low 在前,normal 在后 return mapped.enumerated().sorted { a, b in let aAbn = a.element.status != .normal let bAbn = b.element.status != .normal @@ -199,13 +139,7 @@ struct QuickRegionCaptureFlow: View { }.map { $0.element } } - // MARK: - 取消 / 保存 - - private func cancelAll() { - analyzeTask?.cancel() - analyzeTask = nil - onClose() - } + // MARK: - 保存 /// 勾选项各存一条独立 Indicator(与「记录指标」自由输入一致):无 Report、无 Asset、无 seriesKey。 private func save(items: [QuickRegionItem], capturedAt: Date) { @@ -230,56 +164,3 @@ struct QuickRegionCaptureFlow: View { 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 } - } -} diff --git a/康康/Features/Quick/RegionAdjustView.swift b/康康/Features/Quick/RegionAdjustView.swift new file mode 100644 index 0000000..d40bd45 --- /dev/null +++ b/康康/Features/Quick/RegionAdjustView.swift @@ -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? = 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) 项,可继续挪框或进入核对") + } + } +} diff --git a/康康/Features/Quick/RegionCameraView.swift b/康康/Features/Quick/RegionCameraView.swift index 9197da3..efce6a8 100644 --- a/康康/Features/Quick/RegionCameraView.swift +++ b/康康/Features/Quick/RegionCameraView.swift @@ -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)) } + } +} diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 06c9b31..66af2dd 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -997,9 +997,6 @@ } } } - }, - "100%% 本地推理 · 已用 %llds" : { - }, "2026 / 05 / 25 · 协和医院体检中心" : { "extractionState" : "stale", @@ -5244,9 +5241,6 @@ } } } - }, - "已取消识别,手动补充或重拍" : { - }, "已处理 %.1fs · 比云端快 4.2×" : { "extractionState" : "stale", @@ -6170,9 +6164,6 @@ } } } - }, - "快超时了,>%llds 会自动转手动录入" : { - }, "性别" : { "localizations" : { @@ -6543,9 +6534,6 @@ }, "手动记录" : { - }, - "把异常项放进框里 · 对准一两行" : { - }, "抑郁/焦虑" : { "localizations" : { @@ -6682,6 +6670,9 @@ } } } + }, + "拍一张含异常指标的照片 · 拍完再框选" : { + }, "拍到的局部" : { @@ -6710,9 +6701,6 @@ } } } - }, - "拍摄异常项" : { - }, "拍摄报告" : { "localizations" : { @@ -6735,6 +6723,9 @@ } } } + }, + "拍摄照片" : { + }, "拍摄识别" : { "localizations" : { @@ -6804,6 +6795,9 @@ } } } + }, + "拖动方框对准要识别的指标,可拖右下角缩放" : { + }, "持续" : { "localizations" : { @@ -8797,6 +8791,9 @@ } } } + }, + "框住异常指标" : { + }, "档案 · %lld" : { "localizations" : { @@ -9229,10 +9226,10 @@ "没有识别到指标,点「加一项」手动补充,或返回重拍" : { }, - "没识别到文字,手动补充或重拍" : { + "没识别到文字,挪一下框再试" : { }, - "没读出指标,手动补充或重拍" : { + "没读出指标,挪一下框再试" : { }, "测试 PROMPT" : { @@ -10972,6 +10969,9 @@ } } } + }, + "识别到 %lld 项,可继续挪框或进入核对" : { + }, "识别到的指标 (%lld)" : { @@ -11045,6 +11045,9 @@ } } } + }, + "识别超时,挪一下框再试或手动补充" : { + }, "识别超时(>%llds)" : { "localizations" : { @@ -11111,9 +11114,6 @@ } } } - }, - "识别超时(>%llds),手动补充或重拍" : { - }, "该测%@了" : { "localizations" : { @@ -11414,6 +11414,9 @@ } } } + }, + "跳过 · 手动录入" : { + }, "身体档案" : { "localizations" : { @@ -11918,6 +11921,9 @@ } } } + }, + "进入核对(%lld)" : { + }, "进行中" : { "localizations" : { diff --git a/康康Tests/RegionImageCropperTests.swift b/康康Tests/RegionImageCropperTests.swift index 6d8537d..9ed5df1 100644 --- a/康康Tests/RegionImageCropperTests.swift +++ b/康康Tests/RegionImageCropperTests.swift @@ -1,5 +1,6 @@ import XCTest import CoreGraphics +import AVFoundation @testable import 康康 /// 异常项快拍的局部裁剪几何。 @@ -58,4 +59,57 @@ final class RegionImageCropperTests: XCTestCase { in: CGSize(width: 100, height: 200)), .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) + } }