导入 XuXuCheck 工程(iOS 干化学尿液试纸分析 App)
从桌面工程复制而来,作为本仓库的 Xuxucheck/ 子目录。 为控制仓库体积做了两处删减: - 移除 Frameworks/opencv2.xcframework 下的两个 visionOS 切片 (xros-arm64、xros-arm64-simulator,合计约 106 MB),并同步删掉该 xcframework 内 Info.plist 里对应的两条 AvailableLibraries 记录, 只保留 ios-arm64 与 ios-arm64_x86_64-simulator。 - 移除全部 .DS_Store 与 xcuserdata/。 已用 xcodebuild 从本目录完整编译验证:BUILD SUCCEEDED, ProcessXCFramework 以 --platform ios 成功选中框架,产物含 3699 个 cv:: 符号 (OpenCV 确实链入),嵌入 framework 3 个,与移除无用 pod 之后的状态一致。 仓库约 206 MB,其中 OpenCV 203 MB、其余源码与依赖约 3 MB。
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
//
|
||||
// CameraManager.swift
|
||||
// XuXuCheck
|
||||
//
|
||||
// Created by henseycai on 2025/6/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import CoreImage
|
||||
|
||||
|
||||
class CameraManager: NSObject {
|
||||
|
||||
private let captureSession = AVCaptureSession()
|
||||
private var deviceInput: AVCaptureDeviceInput?
|
||||
private var videoOutput: AVCaptureVideoDataOutput?
|
||||
private let systemPreferredCamera = AVCaptureDevice.default(for: .video)
|
||||
|
||||
private var sessionQueue = DispatchQueue(label: "video.preview.session")
|
||||
|
||||
private var addToPreviewStream: ((CGImage) -> Void)?
|
||||
|
||||
lazy var previewStream: AsyncStream<CGImage> = {
|
||||
AsyncStream { continuation in
|
||||
addToPreviewStream = { cgImage in
|
||||
continuation.yield(cgImage)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
private var isAuthorized: Bool {
|
||||
get async {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
|
||||
// Determine if the user previously authorized camera access.
|
||||
var isAuthorized = status == .authorized
|
||||
|
||||
// If the system hasn't determined the user's authorization status,
|
||||
// explicitly prompt them for approval.
|
||||
if status == .notDetermined {
|
||||
isAuthorized = await AVCaptureDevice.requestAccess(for: .video)
|
||||
}
|
||||
|
||||
return isAuthorized
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
Task {
|
||||
await configureSession()
|
||||
//await startSession()
|
||||
}
|
||||
|
||||
}
|
||||
public func StartCapture(){
|
||||
Task{
|
||||
await startSession()
|
||||
}
|
||||
}
|
||||
public func StopCapture(){
|
||||
Task{
|
||||
await stopSession()
|
||||
}
|
||||
}
|
||||
|
||||
private func startSession() async {
|
||||
guard await isAuthorized else { return }
|
||||
captureSession.startRunning()
|
||||
}
|
||||
|
||||
private func stopSession() async {
|
||||
guard await isAuthorized else { return }
|
||||
captureSession.stopRunning()
|
||||
}
|
||||
|
||||
private func configureSession() async {
|
||||
guard await isAuthorized,
|
||||
let systemPreferredCamera,
|
||||
let deviceInput = try? AVCaptureDeviceInput(device: systemPreferredCamera)
|
||||
else { return }
|
||||
|
||||
captureSession.beginConfiguration()
|
||||
|
||||
defer {
|
||||
self.captureSession.commitConfiguration()
|
||||
}
|
||||
|
||||
let videoOutput = AVCaptureVideoDataOutput()
|
||||
|
||||
videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)
|
||||
|
||||
guard captureSession.canAddInput(deviceInput) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard captureSession.canAddOutput(videoOutput) else {
|
||||
return
|
||||
}
|
||||
|
||||
captureSession.addInput(deviceInput)
|
||||
captureSession.addOutput(videoOutput)
|
||||
|
||||
//For a vertical orientation of the camera stream
|
||||
videoOutput.connection(with: .video)?.videoRotationAngle = 90
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private func rotate(by angle: CGFloat, from connection: AVCaptureConnection) {
|
||||
guard connection.isVideoRotationAngleSupported(angle) else { return }
|
||||
connection.videoRotationAngle = angle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||
|
||||
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||
guard let currentFrame = sampleBuffer.cgImage else {
|
||||
print("Can't translate to CGImage")
|
||||
return
|
||||
}
|
||||
addToPreviewStream?(currentFrame)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
extension CMSampleBuffer {
|
||||
var cgImage: CGImage? {
|
||||
let pixelBuffer: CVPixelBuffer? = CMSampleBufferGetImageBuffer(self)
|
||||
guard let imagePixelBuffer = pixelBuffer else { return nil }
|
||||
return CIImage(cvPixelBuffer: imagePixelBuffer).cgImage
|
||||
}
|
||||
}
|
||||
|
||||
extension CIImage {
|
||||
var cgImage: CGImage? {
|
||||
let ciContext = CIContext()
|
||||
guard let cgImage = ciContext.createCGImage(self, from: self.extent) else { return nil }
|
||||
return cgImage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//
|
||||
// CameraView.swift
|
||||
// XuXuCheck
|
||||
//
|
||||
// Created by henseycai on 2025/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CameraView: View {
|
||||
// @Binding var image: CGImage?
|
||||
// @Binding var array3D: [[[Double]]]
|
||||
|
||||
@Environment(CameraViewModel.self) private var cameraViewModel
|
||||
@Environment(\.dismiss) private var dissmiss
|
||||
|
||||
//https://cloverlaun.tistory.com/77
|
||||
//https://stackoverflow.com/questions/59438285/disable-swipe-back-for-a-navigationlink-swiftui/65354662
|
||||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||
|
||||
@State private var showingNaiveSafariViewFullScreenCover = false
|
||||
|
||||
@State var isAnimating = false
|
||||
|
||||
//let grayImage = OpenCVWrapper.toGrayscale(uiImage)
|
||||
var body: some View {
|
||||
|
||||
ZStack (alignment: .top){
|
||||
|
||||
|
||||
|
||||
|
||||
GeometryReader { geometry in
|
||||
|
||||
|
||||
|
||||
|
||||
if let image = cameraViewModel.currentFrame {
|
||||
|
||||
let uiImage = UIImage(cgImage: image)
|
||||
let result = OpenCVWrapper.analysisImage(uiImage)
|
||||
|
||||
let wbArr = result.paperWbValue as! [Double]
|
||||
// let descArr = result.paperTypeDesc as! [Int]
|
||||
|
||||
// let wbArr = result.paperWbValue as! [Int]
|
||||
|
||||
|
||||
if(wbArr[3] == 0){
|
||||
|
||||
if(cameraViewModel.array3D.count < 16){
|
||||
let _ = cameraViewModel.array3D.append(result.paperAnalResults as! [[Double]])
|
||||
// let _ = print(cameraViewModel.array3D.count)
|
||||
}else{
|
||||
|
||||
if(cameraViewModel.isDataSent == false){
|
||||
|
||||
let _ = print(cameraViewModel.array3D)
|
||||
|
||||
//let _ = cameraViewModel.stopSession()
|
||||
|
||||
|
||||
let _ = cameraViewModel.addRecord(desc: result.paperTypeDesc as! [Int])
|
||||
}
|
||||
|
||||
|
||||
//following line works just bcs Image come continuously ,so following triggered. thanks God~
|
||||
// if(cameraViewModel.isSuccess == true){
|
||||
// let _ = self.presentationMode.wrappedValue.dismiss()
|
||||
//
|
||||
// XuXuWebView(currentUrl: cameraViewModel.recordDetailUrl())
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
//let _ = print(wbArr)
|
||||
// let _ = print(descArr)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Image(uiImage: result.srcImg)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: geometry.size.width,
|
||||
height: geometry.size.height)
|
||||
//.clipped()
|
||||
/*clipped() This crucial modifier crops any content of the view that extends beyond its defined frame. When combined with .scaledToFill() and .frame(), it effectively "crops" the image to the specified dimensions.*/
|
||||
|
||||
|
||||
|
||||
Image("scanfan").resizable().frame(width: 60,height: 235)
|
||||
.offset(x: isAnimating ? geometry.size.width : -50,y:70)
|
||||
.animation(.easeInOut(duration: 5).repeatForever(autoreverses: false), value: isAnimating)
|
||||
.onAppear{
|
||||
isAnimating.toggle()
|
||||
}
|
||||
|
||||
Text("Scan urinal strip in natural light. Put strip on white paper towel.Ensure strip is completely presented in green box.").foregroundStyle(Color.white)
|
||||
.font(.title2).frame(maxWidth: .infinity, maxHeight:.infinity,alignment: .topLeading)
|
||||
.padding().background(Color.gray)
|
||||
.offset(x:0,y:400)
|
||||
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
// ContentUnavailableView("Camera feed interrupted", systemImage: "xmark.circle.fill")
|
||||
// .frame(width: geometry.size.width,
|
||||
// height: geometry.size.height)
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
.padding(.top,20)
|
||||
.onChange(of: cameraViewModel.isSuccess){oldState,newState in
|
||||
if(oldState == false && newState == true){
|
||||
|
||||
//https://www.hackingwithswift.com/quick-start/swiftui/how-to-run-some-code-when-state-changes-using-onchange
|
||||
|
||||
//https://developer.apple.com/documentation/swiftui/view/onchange(of:perform:)
|
||||
|
||||
// showingNaiveSafariViewFullScreenCover = true
|
||||
// dissmiss()
|
||||
showingNaiveSafariViewFullScreenCover.toggle()
|
||||
}
|
||||
}
|
||||
.onChange(of: cameraViewModel.isNeedPopErr){oldState,newState in
|
||||
if(oldState == false && newState == true){
|
||||
showTopToast.toggle()
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showingNaiveSafariViewFullScreenCover) {
|
||||
XuXuWebView(currentUrl: cameraViewModel.recordDetailUrl())
|
||||
}
|
||||
.present(isPresented: self.$showTopToast, type: .toast, position: .top, duration: 6.0, onTap: {
|
||||
|
||||
}, onToastDismiss: {
|
||||
dissmiss()
|
||||
}) {
|
||||
self.createTopToastView()
|
||||
}
|
||||
|
||||
|
||||
|
||||
HStack {
|
||||
|
||||
Image(systemName: "arrow.backward")
|
||||
.frame(width: 50, height: 50)
|
||||
.foregroundColor(.secondary)
|
||||
.strokeStyle(cornerRadius: 15)
|
||||
.onTapGesture {
|
||||
dissmiss()
|
||||
}
|
||||
|
||||
Text("Scanning").font(.headline).frame(maxWidth: .infinity, alignment: .center)
|
||||
}.padding(.top,50)
|
||||
}
|
||||
|
||||
// .navigationBarTitleDisplayMode(.inline)
|
||||
// .toolbar{
|
||||
// ToolbarItem(placement:.principal){
|
||||
//
|
||||
//
|
||||
// Text("Timer").font(.headline)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// .navigationBarItems(
|
||||
// leading: Button(action: {
|
||||
// dissmiss()
|
||||
// }, label: {
|
||||
// Image(systemName: "arrow.backward")
|
||||
// .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
|
||||
// }), trailing: Button(action: {
|
||||
// // 点击按钮时的操作
|
||||
// }, label: {
|
||||
// Text("明细")
|
||||
// .foregroundColor(Color(red: 231/255, green: 200/255, blue: 153/255))
|
||||
// }))
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
let topToastColor = Color(hex: "4067CC")
|
||||
|
||||
@State var showTopToast = false
|
||||
|
||||
func createTopToastView() -> some View {
|
||||
VStack {
|
||||
Spacer(minLength: 30)
|
||||
HStack() {
|
||||
Image("AppIcon")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: ContentMode.fill)
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(25)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text("Error")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(.white)
|
||||
.fontWeight(.bold)
|
||||
Spacer()
|
||||
Image(systemName: "info")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Text("\(cameraViewModel.xuxuError?.message)" + "!")
|
||||
.lineLimit(2)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}.padding(15)
|
||||
}
|
||||
.frame(height: 130)
|
||||
.background(self.topToastColor)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CameraView()
|
||||
|
||||
// CameraView(image: .constant(nil),array3D: .constant([]))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// CameraViewModel.swift
|
||||
// XuXuCheck
|
||||
//
|
||||
// Created by henseycai on 2025/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreImage
|
||||
|
||||
@Observable
|
||||
class CameraViewModel {
|
||||
|
||||
var currentFrame: CGImage?
|
||||
var array3D: [[[Double]]] = []
|
||||
var arrayPaperDesc: [Int] = [0,0,0,0]
|
||||
var isSuccess: Bool = false
|
||||
var isDataSent: Bool = false
|
||||
|
||||
var recordId: Int = 0
|
||||
var recordResultModel: RecordResultModel? = nil
|
||||
|
||||
var xuxuError:XuXuRespError? = nil
|
||||
var isNeedPopErr = false
|
||||
|
||||
private let cameraManager = CameraManager()
|
||||
|
||||
init() {
|
||||
Task {
|
||||
await handleCameraPreviews()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetData(){
|
||||
recordId = 0
|
||||
isSuccess = false
|
||||
isDataSent = false
|
||||
isNeedPopErr = false
|
||||
array3D = []
|
||||
}
|
||||
|
||||
func startSession(){
|
||||
//init data
|
||||
resetData()
|
||||
|
||||
cameraManager.StartCapture()
|
||||
}
|
||||
|
||||
func stopSession(){
|
||||
cameraManager.StopCapture()
|
||||
}
|
||||
|
||||
func handleCameraPreviews() async {
|
||||
for await image in cameraManager.previewStream {
|
||||
Task { @MainActor in
|
||||
currentFrame = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recordDetailUrl() -> String{
|
||||
|
||||
let res = API.webrecordDetailUrl + GetGeneralRequestParams() + "&record_type=0&record_id=\(recordId)"
|
||||
return res
|
||||
}
|
||||
|
||||
func addRecord(desc: [Int]){
|
||||
self.isDataSent = true
|
||||
APIService.instance.addNewRecord(with: desc, with: array3D, completion: {(model,error) in
|
||||
// debugPrint(desc[0])
|
||||
// debugPrint(desc[1])
|
||||
// debugPrint(desc[2])
|
||||
|
||||
if let error = error{
|
||||
debugPrint(error)
|
||||
|
||||
self.xuxuError = error
|
||||
self.isNeedPopErr = true
|
||||
|
||||
return
|
||||
}
|
||||
self.recordResultModel = model
|
||||
self.recordId = model?.data?.record_id ?? 0
|
||||
self.isSuccess = true
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// ScanView.swift
|
||||
// XuXuCheck
|
||||
//
|
||||
// Created by henseycai on 2025/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ScanView: View {
|
||||
// @State
|
||||
// private var viewModel = CameraViewModel()
|
||||
|
||||
// @Environment(CameraViewModel.self) private var cameraViewModel
|
||||
@State private var canStartScan = false
|
||||
@Environment(CameraViewModel.self) private var cameraViewModel
|
||||
|
||||
var body: some View {
|
||||
// CameraView(image: $viewModel.currentFrame,array3D: $viewModel.array3D)
|
||||
// .ignoresSafeArea()
|
||||
if(canStartScan){
|
||||
|
||||
CameraView()
|
||||
.onDisappear {
|
||||
|
||||
//can this line be called?????????????????
|
||||
|
||||
cameraViewModel.stopSession()
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}else{
|
||||
TimerCountView(canStartScan: $canStartScan)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ScanView()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// TimerCountView.swift
|
||||
// XuXuCheck
|
||||
//
|
||||
// Created by henseycai on 2025/7/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TimerCountView: View {
|
||||
|
||||
@State var timeRemaining: Int = 60
|
||||
@State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
@Binding var canStartScan: Bool
|
||||
|
||||
@Environment(\.dismiss) private var dissmiss
|
||||
@Environment(CameraViewModel.self) private var cameraViewModel
|
||||
|
||||
var body: some View {
|
||||
let bgcolor = Color(hex: 0x2449f1)
|
||||
let bgcolor2 = Color(hex: 0x1638d9)
|
||||
VStack{
|
||||
|
||||
HStack {
|
||||
|
||||
Image(systemName: "arrow.backward")
|
||||
.frame(width: 50, height: 50)
|
||||
.foregroundColor(.primary)
|
||||
.strokeStyle(cornerRadius: 15)
|
||||
.onTapGesture {
|
||||
dissmiss()
|
||||
}
|
||||
|
||||
Text("Countdown Timer").font(.headline).frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
ZStack {
|
||||
// Digit Counter
|
||||
Text(
|
||||
"\(timeRemaining)s"
|
||||
)
|
||||
.font(.system(size: 56, weight: .bold))
|
||||
|
||||
// Circle Counter
|
||||
Group {
|
||||
Circle()
|
||||
.rotation(.degrees(-90))
|
||||
.stroke(
|
||||
bgcolor.opacity(0.3),
|
||||
style: StrokeStyle(lineWidth: 12, dash: [CGFloat.pi / 2, CGFloat.pi * 3.5])
|
||||
)
|
||||
Circle()
|
||||
.trim(
|
||||
from: 0,
|
||||
to: (CGFloat(timeRemaining).truncatingRemainder(dividingBy: 60) - 0.25) / 60
|
||||
)
|
||||
.rotation(.degrees(-90))
|
||||
.stroke(
|
||||
bgcolor2.opacity(0.8),
|
||||
style: StrokeStyle(lineWidth: 12, dash: [CGFloat.pi / 2, CGFloat.pi * 3.5])
|
||||
)
|
||||
}
|
||||
.frame(width: 240, height: 240)
|
||||
}.onReceive(timer){ _ in
|
||||
if timeRemaining > 0 {
|
||||
// if timeremaining is greater than zero subtract 1
|
||||
timeRemaining -= 1
|
||||
}
|
||||
else
|
||||
{
|
||||
//else reset it to 5 if its equal to 0. this will save it from a negative num
|
||||
timeRemaining = 60
|
||||
self.timer.upstream.connect().cancel()
|
||||
|
||||
|
||||
cameraViewModel.startSession()
|
||||
|
||||
|
||||
canStartScan = true
|
||||
|
||||
//dissmiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Text("Urine chemical reaction needs one minute about. Please put strip on white paper towel.").foregroundStyle(.primary)
|
||||
.font(.title2).frame(maxWidth: .infinity, alignment: .center)
|
||||
.offset(x:20,y:100)
|
||||
|
||||
///-----------------------------
|
||||
Spacer()
|
||||
|
||||
HStack{
|
||||
Button(action: {
|
||||
timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
timeRemaining = 60
|
||||
}, label: {
|
||||
Text("Reset Timer")
|
||||
.frame(width: 120)
|
||||
.padding()
|
||||
.foregroundColor(.white)
|
||||
.background(Color.gray)
|
||||
.cornerRadius(10)
|
||||
})
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
self.timer.upstream.connect().cancel()
|
||||
|
||||
cameraViewModel.startSession()
|
||||
|
||||
canStartScan = true
|
||||
|
||||
}, label: {
|
||||
Text("Scan Now")
|
||||
.frame(width: 120)
|
||||
.padding()
|
||||
.foregroundColor(.white)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(10)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}.padding(50)
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user