
Apple Pencil
превратил iPad
в мощный инструмент для создания заметок и творческого самовыражения. А как насчет превращения рукописных каракулей в пригодный для использования цифровой текст? Для разработчиков SwiftUI
фреймворк Vision
от Apple
предлагает надежное решение на устройстве для оптического распознавания символов (OCR), которое бесшовно интегрируется с PencilKit
, чтобы оживить распознавание рукописного ввода.
Итак, PencilKit
— для ввода рукописного текста, Vision
— для его распознавания, SwiftUI — для UI.
Код находится на GitHub.
Волшебство происходит благодаря сотрудничеству двух основных фреймворков Apple
:
- PencilKit (PKCanvasView): этот фреймворк предоставляет доску для рисования. Она позволяет пользователям писать и делать наброски естественным образом с помощью
Apple Pencil
или пальца, фиксируя эти данные какPKDrawing
данные — векторное представлениеStrokes
. - Vision (VNRecognizeTextRequest): как только рукописный текст захвачен,
Vision
берет управление на себя.VNRecognizeTextRequest
специально разработан для анализа изображений и идентификации текстового контента, включая сложный рукописный текст.
“Мост” между двумя Мирами: внедрение PKCanvasView в SwiftUI
Поскольку PKCanvasView
является компонентом UIKit
, для его интеграции в SwiftUI
приложение требуется UIViewRepresentable
. Эта структура действует как жизненно важный “мост”, позволяя SwiftUI
управлять и отображать UIKit Views
. Класс class
Coordinator
обычно сопровождает UIViewRepresentable
для взаимодействия с методами делегатов из PKCanvasView
, в частности, canvasViewDrawingDidChange(_:),
который сигнализирует о том, что пользователь обновил рисунок drawing
.
Путь от Stroke к строке String: пошаговый рабочий процесс.
- Настройка
PencilKit
:
- Используем
PKCanvasView
для захвата рисунковdrawings
, которые вы нарисовали с помощью карандашаPencil
. - Поскольку
PKCanvasView
— этоUIView
, вам нужно будет обернуть его вUIViewRepresentable
для использования вSwiftUI
. - Используем
PKToolPicker
для предоставления пользователю инструментов рисования (перо, ластик и т. д.).
- Распознавание текста с помощью
Vision
:
- Как только рисунок
drawing
изменится, мы узнаем об этом в методе делегатаcanvasViewDrawingDidChange
и там получим текущий рисунокPKDrawing
. - Преобразуем
PKDrawing
вUIImage
.Это растровое изображение и его качество имеет первостепенное значение. Адекватное разрешение (с использованиемUIScreen.main.scale
), достаточные отступы вокруг текста (чтобы избежать обрезки символов) и контрастный фон являются ключевыми. - Используем
VNRecognizeTextRequest
из фреймворкаVision
для обработки этого изображения и извлечения текста. - Обновляем
SwiftUI
View
с помощью уже распознанного текста.
- Запрос
VNRecognizeTextRequest
подготавливается с определенными параметрами:
recognitionLevel: .accurate
обычно предпочтительнее для нюансов рукописного текста.recognizeLanguages:
это важно. Предоставление массива языковых кодов BCP 47 (например, [«en-US»], [«fr-FR»], [«ja-JP»]) помогаетVision
сузить фокус и повысить точность. Это можно сделать выбираемым пользователем с помощью SwiftUIPicker
, заполненного с помощьюVNRecognizeTextRequest.supportedRecognitionLanguages().
usesLanguageCorrection
: Обычно включается для использования лингвистических моделей Vision.
- Распознаватель
VNImageRequestHandler
инициализируется с помощью изображенияCGImage
, полученного из (оригинального или предварительно обработанного)UIImage
. Обработчик выполняет запрос на распознаваниеVNRecognizeTextRequest
. Чтобы предотвратить блокировку UI, эта операция должна быть отправлена в фоновый поток (background thread
). - Интерпретация результатов: Обработчик запроса
request
получает массив объектовVNRecognizedTextObservation
. Каждый такой объект представляет собой фрагмент распознанного текста и предоставляет списокtopCandidates(1)
— наиболее вероятных интерпретаций строк. - Отображение в 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)
}
}
@Binding var drawing
: двусторонняя привязка кPKDrawing
вContentView
.@Binding var recognizedText:
односторонняя привязка (хотя может быть и двусторонней, если необходимо) для обновления текста вContentView
.@Binding var toolPicker
: передается изContentView
для настройки.makeUIView
:- Создает
PKCanvasView
.- Назначает начальный рисунок
drawing
.- Назначает делегата
delegate
координаторуCoordinator
.- Задает
drawingPolicy
(например,.anyInput
для пальца и карандаша,.pencilOnly
дляApple Pencil)
.- Настройка выбора инструмента:
-
toolPicker.addObserver(canvasView)
: позволяетcanvasView
наблюдать за изменениями выбора инструмента.-
toolPicker.setVisible(true, forFirstResponder: canvasView)
: показывает выбор инструмента.-
canvasView.becomeFirstResponder()
: делаетcanvasView
активным ответчиком, чтобы выбор инструмента знал, для какогоView
он предназначен.
-
-
-
- Настройка выбора инструмента:
- Задает
- Назначает делегата
- Назначает начальный рисунок
- Создает
updateUIView:
вызывается, когда изменяется состояниеSwiftUI
, привязанное к этому View. Мы используем его здесь для обновления uiView.drawing, если наш рисунок @Binding drawing изменяется извне (например, кнопкой «Очистить»).- 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 неизменяемы.
- Определение цвета «близкого к черному» 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 представляют один и тот же визуальный цвет, заключается в следующем:
- Преобразуйте оба цвета в общее каноническое цветовое пространство (например, sRGB).
- Сравните их компоненты в этом общем цветовом пространстве, допуская небольшой допуск из-за возможных неточностей чисел с плавающей точкой во время преобразования.
В расширение 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.
Вот как это можно сделать:
- Вам нужно будет выбрать список поддерживаемых языков в @State переменную supportedLanguages из VNRecognizeTextRequest.supportedRecognitionLanguages(for:revision:).
- Добавить переменную @State selectedLanguage для хранения текущего выбранного языка.
- Добавить Picker в свой UI.
- Передать выбранный язык selectedLanguage в DrawingView (и его координатор Coordinator), они должен будет знать выбранный язык для настройки VNRecognizeTextRequest.
- Измененный код 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)
}
}
}
Ключевые изменения:
- 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.
- DrawingView (UIViewRepresentable):
- @Binding var selectedLanguage: String: Добавлено для получения выбранного пользователем языка.
- updateUIView(_:context:):
- Вызывает context.coordinator.updateSelectedLanguage(selectedLanguage), чтобы сообщить координатору Coordinator об изменении выбора языка из ContentView.
- Начальное значение selectedLanguage передается координатору Coordinator во время его инициализации.
- 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.