Фантастическое распознавание рукописного текста в реальном времени с помощью Vision, Apple Pencil и SwiftUI, но есть нюансы. 

Apple Pencil превратил iPad в мощный инструмент для создания заметок и творческого самовыражения. А как насчет превращения рукописных каракулей в пригодный для использования цифровой текст? Для разработчиков SwiftUI фреймворк Vision от Apple предлагает надежное решение на устройстве для оптического распознавания символов (OCR), которое бесшовно интегрируется с PencilKit, чтобы оживить распознавание рукописного ввода.

Итак, PencilKit — для ввода рукописного текста, Vision — для его распознавания, SwiftUI — для UI. 

Код находится на GitHub.

Волшебство происходит благодаря сотрудничеству двух основных фреймворков Apple:

  1. PencilKit (PKCanvasView): этот фреймворк предоставляет доску для рисования. Она позволяет пользователям писать и делать наброски естественным образом с помощью Apple Pencil или пальца, фиксируя эти данные как PKDrawing данные — векторное представление Strokes.
  2. Vision (VNRecognizeTextRequest): как только рукописный текст захвачен, Vision берет управление на себя. VNRecognizeTextRequest специально разработан для анализа изображений и идентификации текстового контента, включая сложный рукописный текст.

“Мост” между двумя Мирами: внедрение PKCanvasView в SwiftUI

Поскольку PKCanvasView является компонентом UIKit, для его интеграции в SwiftUI приложение требуется UIViewRepresentable. Эта структура действует как жизненно важный “мост”, позволяя SwiftUI управлять и отображать UIKit Views. Класс class Coordinator обычно сопровождает UIViewRepresentable для взаимодействия с методами делегатов из PKCanvasView, в частности, canvasViewDrawingDidChange(_:), который сигнализирует о том, что пользователь обновил рисунок drawing.

Путь от Stroke  к строке String: пошаговый рабочий процесс.

  1. Настройка PencilKit:
  • Используем PKCanvasView для захвата рисунков drawings, которые вы нарисовали с помощью карандаша Pencil.
  • Поскольку PKCanvasView — это UIView, вам нужно будет обернуть его в UIViewRepresentable для использования в SwiftUI.
  • Используем PKToolPicker для предоставления пользователю инструментов рисования (перо, ластик и т. д.).
  1. Распознавание текста с помощью Vision:
  • Как только рисунок drawing изменится, мы узнаем об этом в  методе делегата canvasViewDrawingDidChange и там получим текущий рисунок PKDrawing
  • Преобразуем PKDrawing в UIImage.Это растровое изображение и его качество имеет первостепенное значение. Адекватное разрешение (с использованием UIScreen.main.scale), достаточные отступы вокруг текста (чтобы избежать обрезки символов) и контрастный фон являются ключевыми.
  • Используем VNRecognizeTextRequest из фреймворка Vision для обработки этого изображения и извлечения текста.
  • Обновляем SwiftUI View с помощью уже распознанного текста.
  1. Запрос VNRecognizeTextRequest подготавливается с определенными параметрами:
  • recognitionLevel: .accurate обычно предпочтительнее для нюансов рукописного текста.
  • recognizeLanguages: это важно. Предоставление массива языковых кодов BCP 47 (например, [«en-US»], [«fr-FR»], [«ja-JP»]) помогает Vision сузить фокус и повысить точность. Это можно сделать выбираемым пользователем с помощью SwiftUI Picker, заполненного с помощью VNRecognizeTextRequest.supportedRecognitionLanguages().
  • usesLanguageCorrection: Обычно включается для использования лингвистических моделей Vision.
  1. Распознаватель VNImageRequestHandler инициализируется с помощью изображения CGImage, полученного из (оригинального или предварительно обработанного) UIImage. Обработчик выполняет запрос на распознавание VNRecognizeTextRequest. Чтобы предотвратить блокировку UI, эта операция должна быть отправлена ​​в фоновый поток (background thread).
  2. Интерпретация результатов: Обработчик запроса request получает массив объектов VNRecognizedTextObservation. Каждый такой объект представляет собой фрагмент распознанного текста и предоставляет список topCandidates(1) — наиболее вероятных интерпретаций строк.
  3. Отображение в SwiftUI: Извлеченный текст затем возвращается в основной поток (main thread) и отображается в интерфейсе SwiftUI, например, в TextEditor для дальнейшего редактирования или в простом Text.

Ниже представлен код приложения.

ContentView:

import SwiftUI
import PencilKit
import Vision // Don't forget to import Vision

// 1. SwiftUI View to host everything
struct ContentView: View {
    @State private var drawing = PKDrawing()
    @State private var recognizedText: String = "Draw something and text will appear here."
    // This will hold the tool picker instance
    @State private var toolPicker = PKToolPicker()
    var body: some View {
        NavigationView {
            VStack {
                Text("Recognized Text:")
                    .font(.headline)
                TextEditor(text: .constant(recognizedText)) // Use TextEditor for scrollable multi-line text
                    .frame(height: 100)
                    .border(Color.gray)
                    .padding()
               
                Button("Clear Drawing") {
                    drawing = PKDrawing() // Reset the drawing
                    recognizedText = "Drawing cleared."
                }
                .padding()
                Text("Canvas:")
                    .font(.headline)
                DrawingView(drawing: $drawing, 
                            recognizedText: $recognizedText, 
                            toolPicker: $toolPicker)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .border(Color.blue)
                    .padding()
            }
         //   .navigationTitle("Handwriting OCR")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}
  • @State var drawing: хранит данные PKDrawing. Это позволяет сохранять и очищать рисунок drawing.
  • @State var recognizedText: хранит текст, найденный Vision.
  • @State var toolPicker: экземпляр PKToolPicker. Мы создаем его здесь и передаем дальше.
  • DrawingView: наш пользовательский UIViewRepresentable для холста (canvas).
  • Button("Clear Drawing"): очищает @State var drawing, которое в свою очередь (через updateUIView) очищает холст (canvas).

DrawingView (UIViewRepresentable):

// 2. UIViewRepresentable for PKCanvasView
struct DrawingView: UIViewRepresentable {
    @Binding var drawing: PKDrawing
    @Binding var recognizedText: String
    @Binding var toolPicker: PKToolPicker // Pass the tool picker

    func makeUIView(context: Context) -> PKCanvasView {
        let canvasView = PKCanvasView()
        canvasView.drawing = drawing
        canvasView.delegate = context.coordinator
        canvasView.drawingPolicy = .anyInput // Or .pencilOnly for Apple Pencil only
        canvasView.backgroundColor = .clear // Or any other color
        canvasView.isOpaque = false
       
        // Show the tool picker
        toolPicker.addObserver(canvasView) // Observe tool changes
        toolPicker.setVisible(true, forFirstResponder: canvasView)
        canvasView.becomeFirstResponder() // Important to make the canvas active for the tool picker
        return canvasView
    }
   
    func updateUIView(_ uiView: PKCanvasView, context: Context) {
        // Update the drawing if it's changed from outside (e.g., by the "Clear Drawing" button)
        if uiView.drawing != drawing {
             uiView.drawing = drawing
        }
    }
    func makeCoordinator() -> Coordinator {
        Coordinator(self, recognizedText: $recognizedText)
    }
}   
  1. @Binding var drawing: двусторонняя привязка к PKDrawing в ContentView.
  2. @Binding var recognizedText: односторонняя привязка (хотя может быть и двусторонней, если необходимо) для обновления текста в ContentView.
  3. @Binding var toolPicker: передается из ContentView для настройки.
  4. makeUIView:
    • Создает PKCanvasView.
      • Назначает начальный рисунок drawing.
        • Назначает делегата delegate координатору Coordinator.
          • Задает drawingPolicy (например, .anyInput для пальца и карандаша, .pencilOnly для Apple Pencil).
            • Настройка выбора инструмента:
              • toolPicker.addObserver(canvasView): позволяет canvasView наблюдать за изменениями выбора инструмента.
                • toolPicker.setVisible(true, forFirstResponder: canvasView): показывает выбор инструмента.
                  • canvasView.becomeFirstResponder(): делает canvasView активным ответчиком, чтобы выбор инструмента знал, для какого View он предназначен.
  5. updateUIView: вызывается, когда изменяется состояние SwiftUI, привязанное к этому View. Мы используем его здесь для обновления uiView.drawing, если наш рисунок @Binding drawing изменяется извне (например, кнопкой «Очистить»). 
  6. makeCoordinator: создает экземпляр Coordinator.

Координатор Coordinator для распознавания рукописного текста в реальном времени:

class Coordinator: NSObject, PKCanvasViewDelegate {
        var parent: DrawingView
        @Binding var recognizedText: String
        private var recognitionTask: DispatchWorkItem? // To debounce recognition

      init(_ parent: DrawingView, recognizedText: Binding<String>) {
            self.parent = parent
            self._recognizedText = recognizedText
        }

        func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
            // Update the parent's drawing binding
            parent.drawing = canvasView.drawing
    // Debounce recognition: Cancel previous task and schedule a new one
            recognitionTask?.cancel()
            let task = DispatchWorkItem { [weak self] in
                self?.recognizeText(in: canvasView.drawing)
            }
            self.recognitionTask = task
    // Perform recognition after a short delay to avoid excessive processing
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: task)
        }

        private func recognizeText(in currentDrawing: PKDrawing) {
            // Ensure there's something to recognize
            guard !currentDrawing.bounds.isEmpty else {
                DispatchQueue.main.async {
                    self.recognizedText = "Canvas is empty."
                }
                return
            }
            //-------------------
            // 1. Get the current drawing from the canvas or state
            // especially if they go near the edges of the tight `drawing.bounds`.
            let imageRect = currentDrawing.bounds.insetBy(dx: -20, dy: -20)                  
            let image = currentDrawing.image(from: imageRect, 
                                             scale: UIScreen.main.scale)
            // 2. Create a Vision text recognition request
            let request = VNRecognizeTextRequest { (request, error) in
                if let error = error {
            print("Error recognizing text: \(error.localizedDescription)")
                    DispatchQueue.main.async {
                self.recognizedText = "Error: \(error.localizedDescription)"
                    }
                    return
                }

                guard let observations = 
                          request.results as? [VNRecognizedTextObservation],     
                                      !observations.isEmpty else {
                    DispatchQueue.main.async {
                        self.recognizedText = "No text recognized."
                    }
                    return
                }
          let recognizedStrings = observations.compactMap { observation in
                    // Return the string with the highest confidence.
                    observation.topCandidates(1).first?.string
          }
                
                DispatchQueue.main.async {
                    self.recognizedText = 
                                   recognizedStrings.joined(separator: "\n")
                    if self.recognizedText.isEmpty {
                         self.recognizedText = 
                         "No text confidently recognized."
                    }
                }
            }
            // Optional: Configure the request
            request.recognitionLevel = .accurate//.fast
            request.usesLanguageCorrection = true
            request.recognitionLanguages = ["en-US"] // Specify languages 
            // Very important - set this to true for handwriting
            if #available(iOS 16.0, *) {
                request.automaticallyDetectsLanguage = false
                request.revision = VNRecognizeTextRequestRevision3
            }
            // 3. Create a request handler and perform the request
           guard let cgImage = image.cgImage else {
                print("Failed to get CGImage from drawing.")
                DispatchQueue.main.async {
                    self.recognizedText = "Error processing drawing."
                }
                return
            }
            let handler = VNImageRequestHandler(cgImage: cgImage, 
                                                options: [:])
            DispatchQueue.global(qos: .userInitiated).async {
                do {
                    try handler.perform([request])
                } catch {
                    print(
             "Failed to perform recognition: \(error.localizedDescription)")
                    DispatchQueue.main.async {
                        self.recognizedText = "Recognition failed."
                    } 
                } 
            } 
        }   
  }
  • PKCanvasViewDelegate: этот протокол необходим для прослушивания изменений в drawing.
  • parent: ссылка на структуру DrawingView.
  • @Binding var recognizedText: для обновления состояния SwiftUI.
  • recognizedTask: DispatchWorkItem, используемый для устранения дребезга. Это предотвращает чрезмерное выполнение распознавания текста, пока пользователь активно рисует drawing. Он ждет короткой паузы (в этом примере 0,5 секунды) перед началом распознавания.
  • canvasViewDrawingDidChange(_:):
    • Это важный метод делегата, вызываемый при каждом изменении drawing на PKCanvasView.
    • Он обновляет привязку parent.drawing, чтобы @State drawing в ContentView всегда будет актуальным.
    • Он отменяет (cancels) любую ожидающую задачу распознавания и планирует новую.
  • recognizedText(in:):
    • Получить изображение: currentDrawing.image(from:scale:) преобразует PKDrawing в UIImage. imageRect делается немного больше, чем drawing.bounds, чтобы гарантировать полный захват strokes около края.
    • Запрос Vision:
      • VNRecognizeTextRequest настроен. Его обработчик получает [VNRecognizedTextObservation].
      • Каждый VNRecognizedTextObservation представляет найденный блок текста. topCandidates(1).first?.string получает наиболее вероятную строку для этого блока.
      • Результаты объединяются и обновляются в основном потоке.
    • Обработчик запроса Request Handler:
      • VNImageRequestHandler инициализируется с помощью cgImage рисунка drawing.
      • handler.perform([request]) выполняет распознавание. Это делается в фоновом потоке (DispatchQueue.global(qos: .userInitiated).async), чтобы избежать блокировки пользовательского интерфейса.

Результаты работы RecognizeTextGemini25 в динамике:

Если выставлен цвет отличный от черного, то распознавание текста работает очень хорошо:

Однако абсолютно черный цвет не распознается, хотя черный цвет является цветом по умолчанию, и скорее всего при первом запуске приложения вы попадете именно на этот режим черного цвета:

Любой другой (даже темный) цвет распознается прекрасно:

Маркер тоже распознается хорошо:

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

Замена строго черного цвета текста на “почти” черный.

Попросим Gemini 2.5 Pro помочь нам  с этим вопросом.

Хорошо, вы хотите итерировать по strokes в PKDrawing, проверить, черный ли у них цвет чернил (или очень близкий к черному), и если да, изменить его на немного менее интенсивный черный (например, темно-серый). Это может быть полезно, если «чистый черный» вызывает проблемы с последующей обработкой. Вот как это можно сделать. Вам нужно будет создать новый PKDrawing с измененными Strokes, потому что сами по себе PKStroke и PKInk неизменяемы.

  1. Определение цвета «близкого к черному» isEssentiallyBlack(tolerance:):

Вам нужен способ определить, является ли UIColor «черным». Поскольку цвета могут иметь небольшие вариации, точная проверка равенства может не всегда работать, если черный не является точно UIColor(red: 0, green: 0, blue: 0, alpha: 1).

extension UIColor {
        // Check if this color is essentially black
        func isEssentiallyBlack(tolerance: CGFloat = 0.05) -> Bool {
            var r: CGFloat = 0
            var g: CGFloat = 0
            var b: CGFloat = 0
            var a: CGFloat = 0
            
           // Convert to sRGB to get consistent components for black
            guard let srgbColor = self.convertToSRGB() else {
                // Fallback or handle error if conversion fails
                // For black, if conversion fails, it's unlikely to be black anyway
                return false
            }
            
            srgbColor.getRed(&r, green: &g, blue: &b, alpha: &a)
            return r <= tolerance && g <= tolerance && b <= tolerance /*&& a >= (1.0 - tolerance)*/
        }

        // Convert UIColor to sRGB color space
        func convertToSRGB() -> UIColor? {
            // If already sRGB, no need to convert (though converting again is safe)
            // Note: Comparing CGColor.colorSpace is more robust than string matching name
            if self.cgColor.colorSpace?.name == CGColorSpace.sRGB {
                return self
            }
            var r: CGFloat = 0
            var g: CGFloat = 0
            var b: CGFloat = 0
            var a: CGFloat = 0
            
            // GetRed can convert simple color spaces (like gray) to a compatible RGB
            if self.getRed(&r, green: &g, blue: &b, alpha: &a) {
                return UIColor(red: r, green: g, blue: b, alpha: a) // This will be in sRGB by default
            } else {
                // More robust conversion using a graphics context (handles more color spaces)
                let newColorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
                let cgColor = self.cgColor
                guard let convertedCGColor = cgColor.converted(to: newColorSpace, intent: .defaultIntent, options: nil) else {
                    print("Warning: Could not convert color \(self) to sRGB.")
                    return nil // Or return self if conversion fails, depending on desired behavior
                }
                return UIColor(cgColor: convertedCGColor)
            }
        }
    }

Этот метод расширения extension UIColor проверяет компоненты RGB. Если все они ниже допустимого значения (например, 0,05, что означает менее 5% интенсивности для красного, зеленого и синего), то он считается черным. Непрозрачность alpha не будем сравнивать, так как черный цвет с любой непрозрачностью не распознается и подлежит замене. Это обрабатывает случаи, когда черный может быть UIColor(red: 0,01, green: 0,02, blue: 0,01, alpha: 1,0) или  UIColor(red: 0,01, green: 0,02, blue: 0,01, alpha: 0,27).

2.  newDarkGrayColor: Это цвет, которым вы хотите заменить черный. Вы можете настроить его.

// Define your new "not so black" color
let newDarkGrayColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1.0) // Example: Dark Gray
// Or: UIColor.darkGray

3. Функция modifyBlackStrokes изменения цвета нарисованных черных strokes на «близкий к черному» цвет:

func modifyBlackStrokes(in drawing: PKDrawing,
                         to newColor: UIColor,
                         blacknessTolerance: CGFloat = 0.05) -> PKDrawing {
   var modifiedStrokes: [PKStroke] = []
   for originalStroke in drawing.strokes {
      // Check if the original stroke's ink color is essentially black
     if originalStroke.ink.color.isEssentiallyBlack(tolerance: blacknessTolerance) {
      // Create a new ink with the new color, keeping other ink properties
            let newInk = PKInk(originalStroke.ink.inkType, color: newColor)
                    
      // Create a new stroke with the new ink but the same path and transform
            let newStroke = PKStroke(ink: newInk, path: originalStroke.path, 
                                     transform: originalStroke.transform, 
                                     mask: originalStroke.mask)
             modifiedStrokes.append(newStroke)
       } else {
                    // If the stroke is not black, keep it as is
               modifiedStrokes.append(originalStroke)
        }
    }
            return PKDrawing(strokes: modifiedStrokes)
}
  • Эта функция перебирает каждый PKStroke в рисунке drawing, который является входным параметром.
  • Для каждого stroke она проверяет цвет нарисованного originalStroke.ink.color на чисто черный цвет любой прозрачности с помощью isEssentiallyBlack.
  • Если цвет stroke — черный:
    • Она создает новый экземпляр PKInk. Важно использовать originalStroke.ink.inkType для сохранения типа чернил (ручка, карандаш, маркер). Изменяется только цвет.
    • Затем она создает новый PKStroke с использованием newInk, но копируя путь path, преобразование transform и маску mask из originalStroke. Это гарантирует, что форма и положение strokes  останутся неизменными, a поменяется толькр цвет с чисто черного на “близкий к черному” newColor.
  • Если цвет stroke не черный, stroke добавляется в массив modifiedStrokes без изменений.
  • Наконец, функция возвращает новый PKDrawing, созданный из массива modifiedStrokes.

4. Использование в функции recognizedText(in:):

  • Вы вызываете modifyBlackStrokes с вашим текущим drawing и желаемым новым цветом.
  • Возвращенный modifiedDrawing затем можно использовать для дальнейшей обработки (например, для создания изображения для OCR) или можно вернуть в PKCanvasView.drawing, если вы хотите, чтобы пользователь увидел изменение на холсте (canvas).

Этот подход гарантирует, что вы делаете неразрушающие изменения  drawing (создавая новый) и сохраняете все аспекты strokes, за исключением цвета тех, которые имели черный цвет.

Вот как изменился код для функции recognizedText(in:):

func recognizeHandwriting() {
        isRecognizing = true
   
        //------
        // 1. Get the current drawing from the canvas or state
        let originalDrawing = canvasView.drawing // Or self.currentDrawing
        // 2. Define the target "not so black" color
        let slightlyLighterBlack = UIColor(white: 0.15, alpha: 1.0) // A very dark gray
        // 3. Modify the drawing
        let modifiedDrawing = modifyBlackStrokes(in: originalDrawing,
                                                 to: slightlyLighterBlack,
                                                 blacknessTolerance: 0.1)
        //-------------PRINT-----
        let originalBlackStrokes = originalDrawing.strokes.filter { 
                    $0.ink.color.isEssentiallyBlack(tolerance: 0.1) }.count
        let newBlackStrokes = modifiedDrawing.strokes.filter { 
                    $0.ink.color.isEssentiallyBlack(tolerance: 0.1) }.count
        
        print("Original drawing had \(originalBlackStrokes) black strokes.")
        print("Modified drawing has \(newBlackStrokes) black strokes (should be 0 if all were changed).")
        let newColorStrokes = modifiedDrawing.strokes.filter { 
                    $0.ink.color.isEqual(slightlyLighterBlack)}.count
  print("Modified drawing has \(newColorStrokes) strokes with the new color.")
        //------------
       
       let image = modifiedDrawing.image(from: 
                            modifiedDrawing.bounds.insetBy(dx: -20, dy: -20),
                            scale: UIScreen.main.scale)
           
        // Create a request handler
        guard let cgImage = image.cgImage else {
            print("Could not get CGImage from UIImage")
            isRecognizing = false
            return
        }
        // Important: Create the request with the recognition level set to accurate
        let request = VNRecognizeTextRequest { (request, error) in
            if let error = error {
                print("Error: \(error)")
                isRecognizing = false
                return
            }
            
            guard let observations = request.results as?    
                                 [VNRecognizedTextObservation] else {
                print("No text observations")
                isRecognizing = false
                return
            }
            
            // Process the recognized text
            let recognizedStrings = observations.compactMap { observation in
                observation.topCandidates(1).first?.string
            }
            
            DispatchQueue.main.async {
                self.recognizedText = recognizedStrings.joined(separator: " ")
                self.isRecognizing = false
            }
        }
        
        // THIS IS THE KEY: Set recognition to handwriting mode
        request.recognitionLevel =  .accurate//.fast.
        request.recognitionLanguages = ["en-US"]
        request.usesLanguageCorrection = true
        request.customWords = ["o3Draw"] // Add custom words that might appear in your app
        
        // Very important - set this to true for handwriting
        if #available(iOS 16.0, *) {
            request.automaticallyDetectsLanguage = false
            request.revision = VNRecognizeTextRequestRevision3
        }
        
        DispatchQueue.global(qos: .userInitiated).async {
            do {
                let requestHandler = VNImageRequestHandler(cgImage: cgImage, 
                                                           options: [:])
                try requestHandler.perform([request])
            } catch {
                print(
             "Failed to perform recognition: \(error.localizedDescription)")
                DispatchQueue.main.async {
                    self.recognizedText = "Recognition failed."
                }
            }
        }
    }

Мы добавили для проверки наших операций с цветами блок кода —- PRINT —- с печатью того, сколько strokes черного цвета в оригинальном drawing,сколько осталось strokes черного цвета в модифицированном drawing и сколько обнаружено strokes с новым цветом в модифицированном drawing:

//-------------PRINT-----
            let originalBlackStrokes = currentDrawing.strokes.filter { 
                    $0.ink.color.isEssentiallyBlack(tolerance: 0.1) }.count
            let newBlackStrokes = modifiedDrawing.strokes.filter { 
                    $0.ink.color.isEssentiallyBlack(tolerance: 0.1) }.count
            let newColorStrokes = modifiedDrawing.strokes.filter {
              $0.ink.color.isVisuallyEqual(to: slightlyLighterBlack)}.count
            
            print(
            "Original drawing had \(originalBlackStrokes) black strokes.")
            print("Modified drawing has \(newBlackStrokes) black strokes (should be 0 if all were changed).")
            print(
        "Modified drawing has \(newColorStrokes) strokes with the new color.")
//------------

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

Мы устанавливаем черный цвет для текста:

Распознаем текст, при этом печатаем, сколько мы обнаружили strokes черного цвета в оригинальном drawing, сколько осталось strokes черного цвета в модифицированном drawing и сколько обнаружено strokes с новым цветом в модифицированном drawing:

Действительно, количество strokes черного цвета меняется динамически по мере написания текста и равно от 1 до 5,  после модификации их не осталось, то есть количество strokes черного цвета после модификации всегда равно 0, a вот количество  strokes нового ( взамен черного)  цвета должно также динамически меняться от 1 до 5,  но остаётся равным 0.

В этом и ошибка. 

Давайте посмотрим внимательно на код, вычисляющих количество  strokes нового  цвета (взамен черного):

 let newColorStrokes = modifiedDrawing.strokes.filter {       
                                   $0.ink.color.isEqual(slightlyLighterBlack)}.count
        print("Modified drawing has \(newColorStrokes) strokes with the new color.")

Похоже, что оператор isEqual не работает для цвета slightlyLighterBlack, у которого ТИП UIExtendedGrayColorSpace:

… a у modifiedDrawing.strokes.map {$0.ink.color } bмеет ТИП: UIExtendedSRGBColorSpace:

Сообщаем Gemini 2.5 Pro об этом несоответствии и просим скорректировать код:

Сравнение цветов Colors, несмотря на различия в цветовых пространствах:

Вы затронули очень распространенный и тонкий момент при работе с UIColor! Цветовое пространство UIColor может меняться в зависимости от того, как цвет UIColor был создан и как он был обработан или назначен.

Когда вы создаете цвет с помощью UIColor(white:alpha:), он инициализируется в цветовом пространстве оттенков серого (UIExtendedGrayColorSpace). Однако, когда PencilKit (или другие фреймворки, такие как Core Graphics или UIKit drawing) обрабатывает или сохраняет цвета, он часто преобразует их в стандартное цветовое пространство RGB, например sRGB (UIExtendedSRGBColorSpace).

Это означает, что даже если визуальный вид цветов UIColor идентичен, isEqual: вернет false, поскольку базовое цветовое пространство и представление их компонентов различаются.

Как надежно сравнивать цвета, несмотря на различия в цветовых пространствах:

Самый надежный способ сравнение того, что два объекта UIColor представляют один и тот же визуальный цвет, заключается в следующем:

  1. Преобразуйте оба цвета в общее каноническое цветовое пространство (например, sRGB).
  2. Сравните их компоненты в этом общем цветовом пространстве, допуская небольшой допуск из-за возможных неточностей чисел с плавающей точкой во время преобразования.

В расширение extension для UIColor нужно добавить еще одну функцию isVisuallyEqual(to: UIColor, tolerance:)   -> Bool:

extension UIColor {
// Check if this color is visually equal to another color, considering color spaces
        func isVisuallyEqual(to otherColor: UIColor, tolerance: CGFloat = 0.001) -> Bool {
            guard let srgbSelf = self.convertToSRGB(),
                  let srgbOther = otherColor.convertToSRGB() else {
  // If conversion fails for either, consider them not equal for safety
  // or handle as a direct isEqual if one conversion failed but not the other.
                // Simplest is to return false if any conversion fails.
                if self.isEqual(otherColor) { return true } // Fallback for simple cases
                return false
            }
        var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
        var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
            srgbSelf.getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
            srgbOther.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
            return abs(r1 - r2) <= tolerance &&
                   abs(g1 - g2) <= tolerance &&
                   abs(b1 - b2) <= tolerance &&
                   abs(a1 - a2) <= tolerance
        }
    }

Теперь вам следует использовать isVisuallyEqual в коде печати в функции recognizedText(in:)

modifiedDrawing.strokes.filter 
    { $0.ink.color.isVisuallyEqual(to: mildLighterBlack) }.count

вместо:

modifiedDrawing.strokes.filter 
     { $0.ink.color.isEqual(slightlyLighterBlack) }.count

Теперь в функции recognizedText (in currentDrawing: PKDrawing) печать изменения цветов будет выглядеть так:

//-------------PRINT-----
            let originalBlackStrokes = currentDrawing.strokes.filter { 
                     $0.ink.color.isEssentiallyBlack(tolerance: 0.1) }.count
            let newBlackStrokes = modifiedDrawing.strokes.filter { 
                     $0.ink.color.isEssentiallyBlack(tolerance: 0.1) }.count
            let newColorStrokes = modifiedDrawing.strokes.filter {
                $0.ink.color.isVisuallyEqual(to: slightlyLighterBlack)}.count
            
  print("Original drawing had \(originalBlackStrokes) black strokes.")
  print("Modified drawing has \(newBlackStrokes) black strokes (should be 0 if all were changed).")
  print("Modified drawing has \(newColorStrokes) strokes with the new color.") //------------

И печать при замене цветов будет правильной:

Действительно, количество strokes черного цвета меняется динамически по мере написания текста и равно от 1 до 5,  после модификации их не осталось, то есть количество strokes черного цвета после модификации всегда равно 0, количество  strokes нового ( взамен черного)  цвета также динамически меняться от 1 до 5.

Выбор языка для рукописного текста

Давайте добавим выбор языка для распознавания рукописного текста в нашем приложении и опять  попросим Gemini 2.5 сделать это для нас:

Хорошо, давайте добавим Picker в ваш SwiftUI ContentView, чтобы пользователи могли выбирать язык распознавания для VNRecognizeTextRequest.

Вот как это можно сделать:

  1. Вам нужно будет выбрать список поддерживаемых языков в @State переменную supportedLanguages из VNRecognizeTextRequest.supportedRecognitionLanguages(for:revision:).
  2. Добавить переменную @State selectedLanguage для хранения текущего выбранного языка.
  3. Добавить Picker в свой UI.
  4. Передать выбранный язык selectedLanguage в DrawingView (и его координатор Coordinator), они должен будет знать выбранный язык для настройки VNRecognizeTextRequest.
  1. Измененный код ContentView.swift:
import SwiftUI
import PencilKit
import Vision // Don't forget to import Vision

// 1. SwiftUI View to host everything
struct ContentView: View {
    @State private var drawing = PKDrawing()
    @State private var recognizedText: String = "Draw something and text will appear here."
    // This will hold the tool picker instance
    @State private var toolPicker = PKToolPicker()
    
    // State for supported languages and the selected language
    @State private var supportedLanguages: [String] = []
    @State private var selectedLanguage: String = "" // Will be set to a default
    
    var body: some View {
        NavigationView {
            VStack {
// --- Language Picker ---
               HStack {
                 Text("Language:")
                 if supportedLanguages.isEmpty {
                     Text("Loading languages...")
                           .foregroundColor(.gray)
                 } else {
                   Picker("Select Language", selection: $selectedLanguage) {
                      ForEach(supportedLanguages, id: \.self) { language in
                           Text(Locale.current.localizedString(
                                        forIdentifier: language) ?? language)
                           .tag(language)
                      }
                   }
                   .pickerStyle(MenuPickerStyle()) // Or .automatic, .wheel
                   .disabled(supportedLanguages.isEmpty)
                   }
               }
               .padding(.horizontal)
// --- End Language Picker ---
                Text("Recognized Text:")
                    .font(.headline)
                TextEditor(text: .constant(recognizedText))                        
                    .frame(height: 100)
                    .border(Color.gray)
                    .padding()
                
                Button("Clear Drawing") {
                    drawing = PKDrawing() // Reset the drawing
                    recognizedText = "Drawing cleared."
                }
                .padding()
                Text("Canvas:")
                    .font(.headline)
                DrawingView(drawing: $drawing, 
                            recognizedText: $recognizedText, 
                            toolPicker: $toolPicker,
                selectedLanguage: $selectedLanguage) // <-- Pass selected language
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .border(Color.blue)
                    .padding()
                
                Button("Clear Drawing") {
                    drawing = PKDrawing() // Reset the drawing
                    recognizedText = "Drawing cleared."
                }
                .padding()
            }
            //   .navigationTitle("Handwriting OCR")
            .navigationBarTitleDisplayMode(.inline)
            .onAppear { loadSupportedLanguages() }
        }
    }
    
    private func loadSupportedLanguages() {
        DispatchQueue.global(qos: .userInitiated).async {
            do {
      // Get supported languages for accurate recognition level and default revision
                let languages = try VNRecognizeTextRequest () .supportedRecognitionLanguages()
                DispatchQueue.main.async {
                    self.supportedLanguages = languages
      // Set a default language if available (e.g., English or the first one)
        if let enUS = languages.first(where: { $0.starts(with: "en-US") }) {
                        self.selectedLanguage = enUS
                    } else if let firstLang = languages.first {
                        self.selectedLanguage = firstLang
                    } else {
                        print("No supported languages found.")
                        self.selectedLanguage = "" // Fallback
                    }
                }
            } catch {
  print("Error fetching supported languages: \(error.localizedDescription)")
                DispatchQueue.main.async {
                    self.supportedLanguages = []
                    self.selectedLanguage = ""
                }
            }
        }
    }
}

Измененный код DrawingView.swift:

/ 2. UIViewRepresentable for PKCanvasView
struct DrawingView: UIViewRepresentable {
    @Binding var drawing: PKDrawing
    @Binding var recognizedText: String
    @Binding var toolPicker: PKToolPicker // Pass the tool picker
    
    @Binding var selectedLanguage: String // <-- Receive selected language

    func makeUIView(context: Context) -> PKCanvasView {
        let canvasView = PKCanvasView()
        canvasView.drawing = drawing
        canvasView.delegate = context.coordinator
        canvasView.drawingPolicy = .anyInput // Or .pencilOnly for Apple Pencil only
        canvasView.backgroundColor = .clear // Or any other color
        canvasView.isOpaque = false
        // Show the tool picker
        toolPicker.addObserver(canvasView) // Observe tool changes
        toolPicker.setVisible(true, forFirstResponder: canvasView)
        canvasView.becomeFirstResponder() // Important to make the canvas active for the tool picker
        return canvasView
    }

    func updateUIView(_ uiView: PKCanvasView, context: Context) {
   // Update the drawing if it's changed from outside (e.g., by the "Clear Drawing" button)
        if uiView.drawing != drawing {
             uiView.drawing = drawing
        }
  // If the selected language changes, we might want to trigger a re-recognition
  // or just ensure the coordinator knows about the new language for the *next* recognition.
  // For simplicity, the coordinator will use the binding directly when a new drawing change 
                context.coordinator.updateSelectedLanguage(selectedLanguage)
    }

    func makeCoordinator() -> Coordinator {
       // Coordinator(self, recognizedText: $recognizedText)
        Coordinator(self, recognizedText: $recognizedText, 
                          selectedLanguage: selectedLanguage)
    }

Измененный код Coordinator.swift:

// 3. Coordinator to handle PKCanvasViewDelegate methods
    class Coordinator: NSObject, PKCanvasViewDelegate {
        var parent: DrawingView
        @Binding var recognizedText: String
        private var recognitionTask: DispatchWorkItem? // To debounce recognition
        
        private var currentSelectedLanguage: String // Store the language locally in Coordinator
        init(_ parent: DrawingView, recognizedText: Binding<String>, 
             selectedLanguage: String) {
            self.parent = parent
            self._recognizedText = recognizedText
            self.currentSelectedLanguage = selectedLanguage
        }
        
// Method to update language if it changes from ContentView
        func updateSelectedLanguage(_ newLanguage: String) {
            self.currentSelectedLanguage = newLanguage
// Optional: If you want to re-recognize immediately when language changes,
// you could call recognizeText here, but it might be too frequent.
// It's usually better to apply the new language on the next drawing change.
        }
        func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
        // Update the parent's drawing binding
            parent.drawing = canvasView.drawing
        // Debounce recognition: Cancel previous task and schedule a new one
            recognitionTask?.cancel()
            let task = DispatchWorkItem { [weak self] in
              //  self?.recognizeText(in: canvasView.drawing)
                guard let self = self else { return }
                self.recognizeText(in: canvasView.drawing,
                                   language: self.currentSelectedLanguage) // <-- Use selected language
            }
            self.recognitionTask = task
    // Perform recognition after a short delay to avoid excessive processing
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: task)
        }
        // Updated recognizeText to accept language
        private func recognizeText(in currentDrawing: PKDrawing, 
                                   language: String) {
            
            // Ensure there's something to recognize
            guard !currentDrawing.bounds.isEmpty else {
                DispatchQueue.main.async {
                    self.recognizedText = "Canvas is empty."
                }
                return
            }
            //-------------------
            // 1. Get the current drawing from the canvas or state
            // Or currentDrawing
            // 2. Define the target "not so black" color
            let slightlyLighterBlack = UIColor(white: 0.15, alpha: 1.0) // A very dark gray
            // 3. Modify the drawing
            let modifiedDrawing = modifyBlackStrokes(in: currentDrawing,
                                         to: slightlyLighterBlack,
                                         blacknessTolerance: 0.1)
            //-------------PRINT-----
            let originalBlackStrokes = currentDrawing.strokes.filter { 
                   $0.ink.color.isEssentiallyBlack(tolerance: 0.1) }.count
            let newBlackStrokes = modifiedDrawing.strokes.filter { 
                   $0.ink.color.isEssentiallyBlack(tolerance: 0.1) }.count
            let newColorStrokes = modifiedDrawing.strokes.filter {
                $0.ink.color.isVisuallyEqual(to: slightlyLighterBlack)}.count
            
print("Original drawing had \(originalBlackStrokes) black strokes.")
print("Modified drawing has \(newBlackStrokes) black strokes (should be 0 if all were changed).")
print("Modified drawing has \(newColorStrokes) strokes with the new color.")
            //------------
            // 1. Get an image from the PKDrawing
      // Use a slightly larger bounds for the image to ensure all strokes are captured,
      // especially if they go near the edges of the tight `drawing.bounds`.
            let imageRect = currentDrawing.bounds.insetBy(dx: -20, dy: -20)    
      // Add some padding
            let image = modifiedDrawing.image(from: imageRect, scale: UIScreen.main.scale)
            // 2. Create a Vision text recognition request
            let request = VNRecognizeTextRequest { (request, error) in
                if let error = error {
        print("Error recognizing text: \(error.localizedDescription)")
                    DispatchQueue.main.async {
                self.recognizedText = "Error: \(error.localizedDescription)"
                    }
                    return
                }
                guard let observations = 
                        request.results as? [VNRecognizedTextObservation], 
                        !observations.isEmpty else {
                    DispatchQueue.main.async {
                        self.recognizedText = "No text recognized."
                    }
                    return
                }
                let recognizedStrings = observations.compactMap { observation in
                    // Return the string with the highest confidence.
                    observation.topCandidates(1).first?.string
                }
                
                DispatchQueue.main.async {
                    self.recognizedText = recognizedStrings.joined(separator: "\n")
                    if self.recognizedText.isEmpty {
                         self.recognizedText = 
                                           "No text confidently recognized."
                    }
                }
            }
            // Optional: Configure the request
            request.recognitionLevel = .accurate//.fast // .accurate or .fast
            request.usesLanguageCorrection = true
          //  request.recognitionLanguages = ["en-US"] // Specify languages if needed
            if !language.isEmpty { 
        // Only set if a language is actually selected
                  request.recognitionLanguages = [language] // <-- Set the selected language
                  print("Using language for OCR: \(language)")
            } else {
                  request.recognitionLanguages = ["en-US"]
      print("No specific language selected for OCR, using Vision's default.")
                      }
            // Very important - set this to true for handwriting
            if #available(iOS 16.0, *) {
                request.automaticallyDetectsLanguage = false
                request.revision = VNRecognizeTextRequestRevision3
            }
            // 3. Create a request handler and perform the request
           guard let cgImage = image.cgImage else {
                print("Failed to get CGImage from drawing.")
                DispatchQueue.main.async {
                    self.recognizedText = "Error processing drawing."
                }
                return
            }
            let handler = VNImageRequestHandler(cgImage: cgImage, 
                                                options: [:])
            DispatchQueue.global(qos: .userInitiated).async {
                do {
                    try handler.perform([request])
                } catch {
    print("Failed to perform recognition: \(error.localizedDescription)")
                    DispatchQueue.main.async {
                        self.recognizedText = "Recognition failed."
                    }
                }
            }
        }
     
        func modifyBlackStrokes(in drawing: PKDrawing,
                                to newColor: UIColor,
                                blacknessTolerance: CGFloat = 0.05) -> PKDrawing {
            var modifiedStrokes: [PKStroke] = []
            for originalStroke in drawing.strokes {
    // Check if the original stroke's ink color is essentially black
                if originalStroke.ink.color.isEssentiallyBlack(tolerance: blacknessTolerance) {
    // Create a new ink with the new color, keeping other ink properties
                    let newInk = PKInk(originalStroke.ink.inkType, 
                                       color: newColor)
                    
    // Create a new stroke with the new ink but the same path and transform
                    let newStroke = PKStroke(ink: newInk, 
                                             path: originalStroke.path, 
                                             transform: 
                                                    originalStroke.transform, 
                                             mask: originalStroke.mask)
                    modifiedStrokes.append(newStroke)
                } else {
                    // If the stroke is not black, keep it as is
                    modifiedStrokes.append(originalStroke)
                }
            }
            return PKDrawing(strokes: modifiedStrokes)
        }
    }
}

Ключевые изменения:

  1. ContentView:
  • @State private var supportedLanguages: [String] = []: содержит массив кодов языков (например, «en-US», «fr-FR»).
  • @State private var selectedLanguage: String = «»: содержит текущий выбранный код языка.
  • loadSupportedLanguages():
    • Вызывается в .onAppear.
    • Использует VNRecognizeTextRequest.supportedRecognitionLanguages(for:revision:) для получения списка. Это функция выбрасывает ошибки (throws), поэтому она обернута в блок do-catch.
    • Работает в фоновом потоке, чтобы избежать блокировки пользовательского интерфейса.
    • Обновляет supportedLanguages ​​и устанавливает значение по умолчанию для selectedLanguage (пробует «en-US», затем первый доступный).
  • UI Picker:
    • В VStack добавляется Picker.
    • Он заполняется supportedLanguages.
    • Locale.current.localizedString(forIdentifier: language) используется для отображения понятных человеку названий языков.
    • Переменная @State переменная selectedLanguage привязана (@Binding) к selection Picker.
  • Теперь переменная selectedLanguage передается как @Binding в DrawingView.
  1. DrawingView (UIViewRepresentable):
  • @Binding var selectedLanguage: String: Добавлено для получения выбранного пользователем языка.
  • updateUIView(_:context:):
  • Вызывает context.coordinator.updateSelectedLanguage(selectedLanguage), чтобы сообщить координатору Coordinator об изменении выбора языка из ContentView.
  • Начальное значение selectedLanguage передается координатору Coordinator во время его инициализации.
  1. DrawingView.Coordinator:
  • private var currentSelectedLanguage: String: Сохраняет язык, переданный из DrawingView.
  • updateSelectedLanguage(_:): Новый метод, позволяющий DrawingView обновлять currentSelectedLanguage координатора, если привязка в ContentView изменяется.
  • recognizeText(in:language:):
    • Метод теперь принимает параметр language: String.
    • Этот язык используется для установки request.recognitionLanguages ​​= [language] в VNRecognizeTextRequest. Важно устанавливать это только в том случае, если language не пуст.
  • canvasViewDrawingDidChange(_:): При изменении рисунка drawing он вызывает recognizeText, передавая self.currentSelectedLanguage.

Теперь при запуске приложения вы должны увидеть Picker, позволяющий вам выбирать из языков, поддерживаемых Vision OCR на вашем устройстве/версии ОС. Выбранный язык будет использоваться для последующих распознаваний рукописного ввода.

Запускаем приложение и выбираем Русский язык из Picker. Пишем текст на русском и английском языках, и оба текста прекрасно распознаются:

Вообще-то мы могли бы и не выбирать язык для нашего рукописного текста, он распознается динамически и автоматически:

Заключение

Используя декларативный фреймворк SwiftUI  с PencilKit вместе с мощными технологиями фреймворка Vision от Apple, разработчики могут создавать приложения, которые не просто распознают рукописный ввод, но и делают это способами, которые кажутся пользователям волшебными и не требуют от них никаких усилий. Vision поддерживает множество языков и сценариев и динамически определяет язык на основе шаблонов символов. Это  еще больше повышают точность распознавания. Используя эти мощные фреймворки Apple, разработчики могут создавать интуитивно понятные приложения, которые легко сокращают разрыв между аналоговым рукописным вводом и цифровым текстом, открывая новые возможности для создания заметок, диаграмм и обучающего контента на iOS и iPadOS.