CoreML модель на сервере с Vapor
Важно! Это лишь прототип и он не претендует на роль готового решения.
Готовый проект на GitHub.
Начнём
Для начала создаём новую директорию, в которой будет находиться наше рабочее пространство 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) }
Если все прошло по плану, то теперь вы можете создавать/выбирать картинку, загружать ее на сервер, классифицировать доминирующий объект на картинке, а затем отображать результаты классификации в пользовательском интерфейсе!