# Apple Pencil 손글씨 숫자 입력: PencilKit·Core ML reject 설계

> PencilKit stroke를 스도쿠 숫자 입력으로 바꿀 때 필요한 tap·ink 세션 분류, 28×28 전처리, confidence reject, 비동기 추론 계약을 DailySudoku 구현으로 살펴봅니다.

- Canonical: https://blog.jaemyeong.com/posts/apple-pencil-handwriting-digit-input-pipeline/
- Published: 2026.08.07
- Category: 기술
- Tags: #iOS, #Swift, #PencilKit, #Core ML, #Apple Pencil

스도쿠 보드에 Apple Pencil로 `7`을 쓰면 앱은 숫자 7을 넣어야 한다. 작은 점을 찍었다면 숫자 인식이 아니라 셀 선택이어야 한다. 손바닥이 닿거나 모델이 애매한 답을 내놓았다고 오답을 입력해서도 안 된다.

이 기능의 핵심은 `PKCanvasView`를 화면에 올리는 일이 아니다. Pencil touch를 기존 입력과 충돌 없이 소유하고, 여러 stroke를 하나의 의도로 묶고, 모델이 학습한 형태로 정규화한 뒤, 확실한 결과만 게임 action으로 보내는 **실패 허용 파이프라인**을 만드는 일이다.

이 글은 2026년 8월 7일 DailySudoku `develop` 커밋 `6db30672`의 iOS 구현을 기준으로 한다. 현재 코드가 보장하는 것과 아직 실기기에서 증명하지 못한 것, Apple 공식 문서와 어긋나는 동시성 전제까지 구분해서 다룬다.

## 입력 소유권부터 분리한다

DailySudoku는 `BoardView` 위에 투명한 `PencilDrawOverlay`를 자식 view로 붙인다. overlay는 `PKCanvasView`이고 drawing policy는 `.pencilOnly`다. Apple 문서상 이 정책에서는 Pencil touch만 canvas에 그린다.

그렇다고 `BoardView`의 gesture recognizer를 그대로 두면 안 된다. Pencil touch를 보드의 tap이나 pan이 먼저 소비할 수 있기 때문이다. 현재 구현은 drawing capture가 켜진 동안 보드 recognizer의 허용 목록을 다음 세 종류로 제한한다.

```text
허용: direct, indirect, indirectPointer
제외: pencil
```

Pencil은 overlay로 가고, 손가락·마우스·트랙패드는 기존 셀 선택 경로에 남는다. stroke가 진행되는 동안에는 손바닥 접촉이 셀을 훑지 않도록 pan만 잠시 끄고 tap은 남긴다. overlay 자체는 접근성 트리에서 숨겨 `BoardView`의 가상 셀이 계속 노출되도록 구성한다. 이 구성이 Pencil과 VoiceOver를 함께 쓰는 실기기 경험까지 보장하는지는 별도 검증 항목이다.

여기서 보드의 81개 접근성 셀과 공용 hit-test geometry는 이전의 [UIKit·Jetpack Compose 스도쿠 보드 접근성 글](/posts/adaptive-accessible-sudoku-board/)에서 이미 다뤘다. Pencil 입력에 필요한 추가 계약은 **같은 geometry를 쓰되 touch 소유권은 분리하는 것**이다.

## 한 stroke가 아니라 한 입력 세션을 판정한다

Pencil이 닿았다는 사실만으로 숫자 입력을 시작할 수는 없다. 현재 구현은 첫 stroke의 bounding box 대각선이 6pt 이하이고 지속 시간이 0.12초 이하면 tap으로 분류한다. 이때는 ink를 지우고 bounding box 중심이 가리키는 셀만 선택한다. 기존 session과 가까운 두 번째 stroke부터는 크기와 시간이 작아도 ink로 분류한다.

ink는 Pencil을 뗄 때마다 바로 인식하지 않는다. `4`, `5`, `7`처럼 여러 stroke로 쓸 수 있기 때문이다. 마지막 stroke가 끝난 뒤 0.45초 동안 새 stroke가 없으면 session을 확정한다. 새 stroke가 들어오면 deadline을 다시 미룬다.

tap으로 분류되지 않은 새 stroke의 중심이 기존 session 중심에서 어느 축으로든 60pt보다 멀면 reducer는 이전 숫자를 즉시 확정하고 새 ink session을 시작한다. 작고 빠른 far stroke는 먼저 tap으로 분류되므로 이전 session을 확정한 뒤 tapped cell만 선택한다. session 전체 bounding box의 중심을 기존 `BoardView.cellIndex`에 넣어 target cell을 구하므로, tap과 ink가 둥근 보드 모서리까지 같은 판정을 쓴다.

이 구조는 세 결정을 분리한다.

1. 이 stroke는 tap인가 ink인가.
2. 이 stroke는 기존 session에 속하는가 새 session인가.
3. 완성된 session은 어느 셀을 가리키는가.

다만 60pt는 보드 크기와 무관한 고정값이다. 보드가 커지거나 작아질 때 인접 셀 사이의 실제 거리는 달라진다. 현재 테스트는 가까운 stroke와 아주 먼 stroke를 구분하지만, 여러 화면 크기에서 인접 셀을 빠르게 이어 쓰는 경계는 고정하지 않는다. 이 값은 셀 크기에 비례시키거나, 최소한 지원 기기별 실측으로 보정해야 하는 calibration 값이다.

## 전처리가 모델의 실질적인 API다

Core ML 모델이 받는 값은 `PKDrawing`이 아니다. 현재 파이프라인은 stroke path를 점 배열로 바꾸고, 보드 크기의 grayscale buffer에 같은 굵기로 rasterize한 뒤 28×28 tensor로 정규화한다.

```text
PKDrawing
  -> [[CGPoint]]
  -> round-cap grayscale raster
  -> ink bounding-box crop
  -> longest side 20px, aspect ratio 유지
  -> bilinear resample
  -> center of mass를 28×28 중앙으로 이동
  -> [1, 1, 28, 28] Float tensor
```

화면에서는 일반 입력 ink를 6pt, note mode ink를 3pt로 그린다. 하지만 모델 입력은 두 경우 모두 6pt로 rasterize한다. 시각적 mode 표시가 모델 분포까지 바꾸지 않게 한 것이다.

전처리는 단순한 이미지 유틸리티가 아니라 모델의 입력 계약이다. crop, scale, pixel 방향, intensity 범위 중 하나만 학습 때와 달라져도 정상적으로 실행되는 오인식기를 만들 수 있다. 그래서 현재 테스트는 빈 입력의 `nil`, 결정성, 20px longest side, center-of-mass 정렬과 Python에서 고정한 Core ML 출력의 label·확률 parity를 따로 확인한다.

이 테스트가 실제 필기 정확도를 증명하는 것은 아니다. renderer부터 모델까지 기대 숫자를 검사하는 손글씨 모양 fixture도 `1`과 `7`뿐이다. 학습 스크립트의 threshold sweep 역시 회전·굵기 변화가 적용된 MNIST와 0·noise를 사용한 proxy이며, 결과 report는 임시 경로에 기록된다. 따라서 “실제 Pencil 필기 정확도가 몇 퍼센트다” 또는 “0.8이 최적값이다”라고 말할 근거는 아직 없다.

## 숫자를 맞히는 것보다 잘못 놓지 않는 것이 중요하다

모델은 0부터 9까지 열 개 class를 출력한다. 스도쿠에 넣을 수 없는 0을 없애지 않고 reject sink로 남긴다. 애매한 원을 억지로 6이나 9에 배정하는 대신 0으로 빠질 자리를 주는 선택이다.

현재 입력 정책의 base confidence threshold는 0.8이다. note mode에서는 잘못된 note를 지우는 비용이 일반 숫자 오입력보다 낮다고 보고 0.1을 낮춘 0.7을 쓴다.

| 모델 결과 | 사용자 action |
| --- | --- |
| 1...9, confidence가 threshold 이상 | target cell 선택 후 숫자 입력 |
| 0 또는 범위 밖 label | reject |
| confidence가 threshold 미만 | reject |
| 모델 load·전처리·추론 실패 | reject |

reject는 mistake를 늘리지 않는 silent no-op이다. ink는 성공 여부와 관계없이 사라진다. 잘못된 숫자를 자동으로 놓는 것보다 사용자가 다시 쓰게 하는 편이 싼 제품에서는 합리적인 비대칭이다.

그러나 사용자에게 조용하다는 말이 운영에서도 보이지 않아야 한다는 뜻은 아니다. 모델 load 실패, low confidence, 잘못된 label을 내부에서는 구분해야 threshold와 전처리 문제를 찾을 수 있다. 현재 코드는 이 세 경우를 최종 `.reject` 하나로 합치고, stale result 여부는 검사하지 않는다. 실기기 보정 전에 원인별 계측과 stale 판정 경계가 필요하다. 원본 stroke를 서버로 보내지 않고 기기 안에서 집계 가능한 결과만 남기면 handwriting data를 수집하지 않고도 실패율을 볼 수 있다.

## 비동기 결과에도 입력 정체성이 필요하다

현재 구현은 rasterize와 Core ML prediction을 `Task.detached`에서 실행하고, 성공 결과만 main actor로 돌아와 `selectCell`과 `placeDigit`을 호출한다. UI thread를 막지 않는 방향은 맞지만 두 계약이 비어 있다.

첫째, 한 `MLModel` instance의 호출을 직렬화해야 한다. 현재 `CoreMLDigitRecognizer`는 `@unchecked Sendable`이고 여러 detached task가 같은 model을 공유할 수 있다. 하지만 Apple의 `MLModel` 문서는 한 instance를 한 thread 또는 한 dispatch queue에서 사용하고, 호출을 serialize하거나 queue마다 별도 instance를 만들라고 명시한다. `@unchecked Sendable`은 컴파일러 검사를 우회할 뿐 이 런타임 계약을 바꾸지 않는다.

가장 작은 수정은 model을 actor 하나가 소유하게 하는 것이다.

```swift
actor DigitInference {
    private let recognizer: CoreMLDigitRecognizer

    init() throws {
        recognizer = try CoreMLDigitRecognizer()
    }

    func recognize(_ pixels: [Float]) throws -> DigitRecognition? {
        try recognizer.recognize(pixels: pixels)
    }
}
```

이 코드는 prediction 호출을 직렬화하며 호출부는 actor method를 `await`해야 한다. queue마다 model을 복제하는 방식은 실제 latency와 throughput이 actor 하나로 부족하다고 측정된 뒤에 선택해도 된다.

둘째, 추론을 시작한 입력과 결과를 적용할 게임 상태를 묶어야 한다. 현재 completion은 여전히 playing인지만 확인한다. 그 사이 pause 후 resume하거나 note mode가 바뀌면 이전 stroke의 결과가 다시 활성화된 상태에 적용될 수 있다. 특히 threshold는 캡처한 mode로 계산하지만 `placeDigit`은 completion 시점의 현재 mode를 사용한다.

요청마다 최소한 다음 값을 함께 캡처하면 된다.

```text
input revision + target cell + entry mode
```

결과를 적용하기 직전에 이 값이 현재 상태와 같은지 확인하고, 다르면 cost-free reject로 버린다. 범용 작업 관리 framework보다 monotonically increasing revision 하나가 이 경계에는 충분하다.

## 자동 테스트와 물리 기기 검증을 나눈다

현재 자동 테스트가 강한 부분은 UI framework 밖으로 꺼낸 순수 결정이다.

- rounded-board hit test, tap·ink threshold, debounce와 far restart
- 28×28 normalization의 결정성·크기·center of mass
- 0·범위 밖·low-confidence reject table
- bundled model load와 Python·Swift output parity
- Pencil을 제외한 touch-type whitelist
- `PKCanvasView`가 자기 자신을 delegate로 삼지 않는 wiring

마지막 항목은 특히 중요하다. DailySudoku 소스 주석에는 iOS 26.5 기기에서 canvas 자신을 delegate로 연결했을 때 PencilKit 내부 delegate 전달이 자기 재귀로 이어졌고, 별도 bridge 객체로 끊었다는 진단이 기록돼 있다. 현재 test가 독립적으로 검사하는 것은 delegate가 canvas 자신이 아니라는 wiring뿐이며, 당시 기기 log는 이 글에서 다시 확인하지 않았다. `.pencilOnly` 경로는 simulator mouse로 실행되지 않으므로 구조적 회귀 test와 물리 기기 test의 역할이 다르다.

아직 다음 항목은 자동 테스트 통과만으로 말할 수 없다.

- 다양한 필체의 1...9 precision과 reject rate
- 첫 결과가 나오기까지의 latency와 연속 입력 순서
- 화면 크기별 60pt session 분리 경계
- Pencil·손가락·pointer·VoiceOver 동시 사용
- pause·resume와 mode 전환 중 stale result 폐기
- 모델 load 실패와 반복 reject가 사용자에게 이해되는지

실기기 검증에서는 전체 정확도 하나보다 `accepted 중 오입력 비율`, `reject 비율`, `p95 latency`를 나눠 보는 편이 낫다. 이 제품에서는 coverage를 조금 포기해도 오입력을 줄이는 것이 목표이기 때문이다.

## iOS 27 beta API는 아직 drop-in 교체가 아니다

Apple은 iOS 27 beta의 PencilKit에 `PKStrokeRecognizer`를 제공한다. 공식 문서상 이 타입은 actor이고, on-device에서 비동기로 동작하며, `PKDrawing`의 stroke를 text로 인식한다. 인식 결과를 저장한다면 OS에 따라 결과가 바뀔 수 있으므로 `recognitionVersion`도 함께 보존해야 한다.

기준 커밋의 배포 대상은 iOS 26.5다. 따라서 이 경로를 구현하려면 Xcode 27 beta SDK, `#available(iOS 27.0, *)` gate와 기존 Core ML fallback이 필요하다.

현재 DailySudoku의 `DigitRecognizing` protocol은 미래 교체 지점으로 설명돼 있지만 method는 이미 정규화된 `[Float]`를 받고 digit과 confidence를 반환한다. 반면 `PKStrokeRecognizer`에는 `PKDrawing`을 `updateDrawing(_:)`로 제공한 뒤 `recognizedText(strokeIDs:)`에서 `String?`을 받는다. 이 method는 confidence 값을 반환하지 않는다. 지금 signature 그대로는 renderer를 삭제하고 구현만 바꾸는 drop-in 교체가 아니다.

진짜 migration seam은 한 단계 위여야 한다.

```text
recognize(drawing, context) async -> candidate 또는 reject
```

그 아래에서 현재 Core ML adapter는 rasterize와 confidence policy를 쓰고, iOS 27 adapter는 `PKStrokeRecognizer`의 text를 1...9로 제한한다. confidence가 없는 API에서 어떤 결과를 자동 입력할지는 별도의 제품 검증이 필요하다. API가 beta인 동안에는 현재 모델을 지우기보다 이 경계만 정확히 잡아 두는 것으로 충분하다.

## 정리

Apple Pencil 숫자 입력은 handwriting model 하나를 붙이는 기능이 아니다. Pencil touch 소유권, tap·ink session, target cell geometry, 28×28 전처리, reject 정책, 비동기 결과 정체성이 차례로 맞아야 하나의 안전한 game action이 된다.

DailySudoku의 현재 구현은 이 결정을 순수 reducer와 renderer로 분리하고, 0과 low confidence를 비용 없는 reject로 처리한다. 반면 실제 Pencil 정확도와 latency, 크기별 session 경계, stale result, `MLModel` 직렬화는 아직 닫히지 않았다. iOS 27의 `PKStrokeRecognizer`도 현재 pixels 기반 protocol에 그대로 꽂히지는 않는다.

먼저 잘못된 숫자를 놓지 않는 pipeline을 만들고, acceptance와 reject를 실기기에서 측정한 뒤 threshold를 조정해야 한다. handwriting 기능에서 중요한 수치는 모델의 단독 정확도보다 **사용자 action으로 commit된 결과의 precision**이다.

## 출처

- DailySudoku 비공개 저장소(권한 필요) — 기준 commit `6db30672d5b8fa63826ad10da7daba7287d29e75`: `apps/ios/DailySudoku/Game/PencilDrawOverlay.swift:15-119,124-223,251-365`, `apps/ios/DailySudoku/Game/DigitImageRenderer.swift:27-236`, `apps/ios/DailySudoku/Game/DigitRecognizer.swift:20-144`, `apps/ios/DailySudoku/Game/GameViewController.swift:84-98,584-675`, `apps/ios/DailySudoku/Game/GameViewModel.swift:241-263`, `apps/ios/DailySudoku/Game/BoardView.swift:43-49,180-242`, `apps/ios/DailySudoku.xcodeproj/project.pbxproj:427,515`
- DailySudoku 비공개 저장소(권한 필요) — 같은 commit의 검증 근거: `apps/ios/DailySudokuTests/PencilDrawTests.swift:15-206`, `apps/ios/DailySudokuTests/PencilDigitRecognitionTests.swift:59-210`, `apps/ios/DailySudokuTests/BoardViewPencilCaptureTouchTypesTests.swift:5-40`, `apps/ios/scripts/pencil_digit_train_export.py:44-180,201-244,247-334`
- [Apple Developer Documentation — PKCanvasViewDrawingPolicy](https://developer.apple.com/documentation/pencilkit/pkcanvasviewdrawingpolicy)
- [Apple Developer Documentation — MLModel](https://developer.apple.com/documentation/coreml/mlmodel)
- [Apple Developer Documentation — PKStrokeRecognizer](https://developer.apple.com/documentation/pencilkit/pkstrokerecognizer)
- [Apple Developer Documentation — Recognizing handwriting and converting it to text](https://developer.apple.com/documentation/pencilkit/recognizing-handwriting-and-converting-to-text)
