SwiftUI
November 29, 2023

 CoreML модель на сервере с Vapor 

Важно! Это лишь прототип и он не претендует на роль готового решения.

Готовый проект на GitHub.

Требования:

  • Xcode 15
  • macOS 14
  • Homebrew
  • Аккаунт разработчика Apple + девайс для тестирования

Начнём

Для начала создаём новую директорию, в которой будет находиться наше рабочее пространство Xcode. Мы назовём его coreml-web-api.

cd ~/Desktop && mkdir coreml-web-api && cd coreml-web-api

Теперь устанавливаем Vapor и загружаем новый сервер. Более подробную информацию вы найдете в документации.

brew install vaporvapor 
new server -n
open Package.swift

Мы хотим, чтобы пользователи могли загружать изображения для классификации, поэтому добавляем новый маршрут под названием classify, который поддерживает это. В файле server/Sources/App/routes.swift удалите весь сгенерированный шаблон и добавьте следующий:

import CoreImage
import Vapor
func routes(_ app: Application) throws {
 app.post("classify") { req -> [ClassifierResult] in
 let classificationReq = try req.content.decode(ClassificationRequest.self)
 let imageBuffer = classificationReq.file.data
 guard let fileData = imageBuffer.getData(at: imageBuffer.readerIndex, length: imageBuffer.readableBytes),
 let ciImage = CIImage(data: fileData)
 else {
 throw Errors.badImageData
 }
 let classifier = Classifier() // we'll add this in a sec
 return try classifier.classify(image: ciImage)
 }
}
enum Errors: Error {
 case badImageData // or whatever
}
struct ClassificationRequest: Content {
 var file: File
}

Кроме того, увеличиваем максимальный размер файла, разрешенный для загрузки, в configure.swift:

import Vapor
// configures your application
public func configure(_ app: Application) async throws {
 app.routes.defaultMaxBodySize = "10mb"
 // register routes
 try routes(app)
}

Теперь напишем API классификатора. Во-первых, зайдите на страницу Apple ML и загрузите предварительно обученную модель по вашему выбору. В этой демонстрации мы использем модель Resnet50.

Добавьте новый файл под названием Classifier и добавьте в него следующее:

import CoreImage
import Vapor
import Vision
struct Classifier {
 func classify(image: CIImage) throws -> [ClassifierResult] {
 let url = Bundle.module.url(forResource: "Resnet50", withExtension: "mlmodelc")!
 guard let model = try? VNCoreMLModel(for: Resnet50(contentsOf: url, configuration: MLModelConfiguration()).model) else {
 throw Errors.unableToLoadMLModel
 }
 let request = VNCoreMLRequest(model: model)
 let handler = VNImageRequestHandler(ciImage: image)
 try? handler.perform([request])
 guard let results = request.results as? [VNClassificationObservation] else {
 throw Errors.noResults
 }
 return results.map { ClassifierResult(label: $0.identifier, confidence: $0.confidence) }
 }
 enum Errors: Error {
 case unableToLoadMLModel
 case noResults
 }
}
struct ClassifierResult: Encodable, Content {
 var label: String
 var confidence: Float
}

Сначала мы загружаем модель. Добавление модели CoreML в пакет не так просто. Нам нужно самостоятельно скомпилировать .mlmodel и добавить несколько файлов в Sources/.

Как только модель загружена, мы подготавливаем запрос и обработчик запроса; затем мы выполняем классификацию. Чтобы отправить результаты в формате JSON клиенту, нам нужно переделать их в структуру, соответствующую Encodable и Content.

Добавление модели в Package

И так, мы не можем просто перетащить модель в проект. В корне пакета сервера создайте новую папку MLModelSource и добавьте в нее файл Resnet50.mlmodel. Создайте еще одну папку под названием Resources в server/Sources/App/Resources/.

Теперь нам нужно скомпилировать модель, добавить класс Swift в sources и включить .mlmodelc в пакетный пакет. Шаги компиляции повторяются, поэтому мы поместим их в целевой Makefile. В корне проекта создайте Makefile:

# ~/Desktop/coreml-web-api/
touch Makefile

И добавьте compile_ml_modeltarget:

compile_ml_model:
 cd server/MLModelSource && \
 xcrun coremlcompiler compile Resnet50.mlmodel ../Sources/App/Resources && \
 xcrun coremlcompiler generate Resnet50.mlmodel ../Sources/App/Resources --language Swift

Затем добавляем это в исполняемую цель в файле Package.swift:

resources: [    
           .copy("Resources/Resnet50.mlmodelc"),
]

Цель должна выглядеть следующим образом:

.executableTarget(
 name: "App",
 dependencies: [
 .product(name: "Vapor", package: "vapor"),
 ],
 resources: [
 .copy("Resources/Resnet50.mlmodelc"),
 ]
),

Теперь из корня проекта запустите compile_ml_model:

make compile_ml_model

Теперь у нас есть сервер, который поддерживает классификацию загруженных изображений с помощью модели Resnet50. Прежде чем мы перейдем к созданию клиента, нам нужно настроить схему App, чтобы сервер был доступен для физического устройства в вашей сети.

Откройте редактор схемы и добавьте serve --hostname 0.0.0.0 в аргументы run.

Теперь создадем клиент, который будет выполнять загрузку.

iOS Client

В Xcode перейдите в File -> New -> Project и добавьте iOS-приложение в рабочую область. Нам нужен только SwiftUI.

Отлично. Теперь давайте немного поработаем с конфигурацией. Поскольку мы собираемся использовать камеру, нам нужно обновить Info.plist с ключом Privacy - Camera Usage Description.

В нашем клиенте мы хотим предоставить пользователям возможность использовать камеру или выбирать из библиотеки фотографий. Создаём новый файл ImagePicker.swift и вставляем в него следующее:

import SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
 @Binding var sourceType: UIImagePickerController.SourceType
 @Environment(\.presentationMode) private var presentationMode
 var completion: (UIImage) -> Void
 func makeUIViewController(context: Context) -> some UIViewController {
 let picker = UIImagePickerController()
 picker.sourceType = sourceType
 picker.delegate = context.coordinator
 return picker
 }
 func updateUIViewController(_: UIViewControllerType, context _: Context) {}
 func makeCoordinator() -> Coordinator {
 Coordinator(self)
 }
 class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
 var parent: ImagePicker
 init(_ parent: ImagePicker) {
 self.parent = parent
 }
 func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
 if let image = info[.originalImage] as? UIImage {
 parent.completion(image)
 }
 parent.presentationMode.wrappedValue.dismiss()
 }
 }
}

Мы будем использовать привязку sourceType для переключения между камерой и библиотекой.

Добавляем классификатор, который будет обрабатывать загрузку изображений и возвращать результаты классификации. Создаём новый файл Classifier.swift и добавляем в него следующее:

import Foundation
import UIKit
struct Classifier {
 /// replace this with your dev machine IP address
 /// for testing with a physical device.
 private let host = "localhost"
 func classify(image: UIImage) async throws -> [ClassifierResult] {
 // Ensure the URL is valid
 guard let uploadURL = URL(string: "http://\(host):8080/classify") else {
 throw URLError(.badURL)
 }
 // Convert the image to JPEG data
 guard let imageData = image.jpegData(compressionQuality: 1.0) else {
 throw URLError(.unknown)
 }
 // Generate boundary string using a unique per-app string
 let boundary = "Boundary-\(UUID().uuidString)"
 // Create a URLRequest object
 var request = URLRequest(url: uploadURL)
 request.httpMethod = "POST"
 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
 // Create multipart form body
 let body = createMultipartFormData(boundary: boundary, data: imageData, fileName: "photo.jpg")
 request.httpBody = body
 // Perform the upload task
 let (data, response) = try await URLSession.shared.upload(for: request, from: body)
 // Check the response and throw an error if it's not a HTTPURLResponse or the status code is not 200
 guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
 throw URLError(.badServerResponse)
 }
 // Decode the data into an array of ClassifierResult
 return try JSONDecoder().decode([ClassifierResult].self, from: data)
 }
 /// Creates a multipart/form-data body with the image data.
 /// - Parameters:
 /// - boundary: The boundary string separating parts of the data.
 /// - data: The image data to be included in the request.
 /// - fileName: The filename for the image data in the form-data.
 /// - Returns: A `Data` object representing the multipart/form-data body.
 private func createMultipartFormData(boundary: String, data: Data, fileName: String) -> Data {
 var body = Data()
 // Add the image data to the raw http request data
 body.append("--\(boundary)\r\n")
 body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n")
 body.append("Content-Type: image/jpeg\r\n\r\n")
 body.append(data)
 body.append("\r\n")
 // Add the closing boundary
 body.append("--\(boundary)--\r\n")
 return body
 }
 struct ClassifierResult: Decodable, Identifiable {
 let id = UUID()
 var label: String
 var confidence: Float
 }
}
// Helper function to append string data to Data object
private extension Data {
 mutating func append(_ string: String) {
 if let data = string.data(using: .utf8) {
 append(data)
 }
 }
}

Теперь перейдем к пользовательскому интерфейсу. Вернемся в ContentView и добавим перечисление RequestStatus, чтобы сообщить пользователю, что происходит.

enum RequestStatus {
 case loading, success, idle, error
}

Теперь мы создадим модель представления для ContentView, которая будет использовать только что созданный классификатор для загрузки фотографии на сервер и передачи результатов в пользовательский интерфейс. Здесь также будет использоваться новый фреймворк Observation.

extension ContentView {
 @Observable
 class ViewModel {
 var requestStatus: RequestStatus = .idle
 var results: [Classifier.ClassifierResult] = []
 private var classifier = Classifier()
 func upload(_ image: UIImage) {
      Task { @MainActor in
           do {
              requestStatus = .loading
              results.removeAll()
              results = try await classifier.classify(image: image)
              requestStatus = .success
           } catch {
              print(error.localizedDescription)
              requestStatus = .error
           }
        }
     }
   }
}

Теперь нам нужно добавить некоторые состояния:

// ContentView.swift
@State private var selectedImage: UIImage?
@State private var isImagePickerPresented = false
@State private var viewModel = ViewModel()
@State private var sourceType: UIImagePickerController.SourceType = .camera

Теперь сделаем еще немного интерфейса:

var body: some View {
   VStack(spacing: 20) {
     HStack(spacing: 20) {
       if let image = selectedImage {
          VStack {
             Image(uiImage: image)
                .resizable()
                .scaledToFit()
          }.padding()
             .frame(maxHeight: 350)
      }
          List {
               ForEach(viewModel.results, id: \.id) { result in
                    VStack(alignment: .leading) {
                         Text(result.label)
                              .font(.callout)
                         Text(formatAsPercentage(result.confidence))
                              .font(.caption2)
                      }
                  }
              }
           }
           Divider()
                HStack(spacing: 20) {
                     actionButton()
                     if viewModel.requestStatus == .loading {
                              ProgressView()
                     }
               }
          }
           .sheet(isPresented: $isImagePickerPresented) {
               ImagePicker(sourceType: $sourceType) { image in
                      self.selectedImage = image
               }
          }
   }

А чтобы устранить ошибки компилятора, добавьте две новые функции:

// ContentView.swift
@ViewBuilder
private func actionButton() -> some View {
     if let image = selectedImage {
        Button("Upload Image") {
            viewModel.upload(image)
        }.buttonStyle(.borderedProminent)
    } else {
       HStack(spacing: 20) {
          Button("Camera") {
             sourceType = .camera
             isImagePickerPresented = true
           }.buttonStyle(.bordered)
                Button("Photo Library") {
                   sourceType = .photoLibrary
                   isImagePickerPresented = true
                 }.buttonStyle(.bordered)
           }.padding(.bottom, 20)
       }
}
// and
private func formatAsPercentage(_ value: Float) -> String {
 String(format: "%.2f%%", value * 100)
}

Если все прошло по плану, то теперь вы можете создавать/выбирать картинку, загружать ее на сервер, классифицировать доминирующий объект на картинке, а затем отображать результаты классификации в пользовательском интерфейсе!

Источник: medium.com