Apple Pencil 필기를 스도쿠 숫자로 바꾸는 입력 처리

Apple Pencil 입력을 DailySudoku에 넣으면서 정한 목표는 단순했어요. 셀 위에 7을 쓰면 7이 들어가고, 펜으로 콕 찍으면 그 셀이 선택돼요. 대신 손바닥이 닿거나 인식 결과가 불확실할 때는 아무 숫자도 들어가지 않아야 해요.

이 목표를 지키려면 네 가지를 풀어야 했어요. 기존 보드 조작과 충돌하지 않게 입력을 받는 일, 여러 획을 한 글자로 묶는 일, 학습 때와 같은 모양으로 입력을 바꾸는 일, 결과를 언제 적용할지 정하는 일이에요. 아래는 2026-08-07의 develop 브랜치(커밋 6db30672)를 기준으로 한 구현과, 자동 테스트만으로는 확인하지 못한 부분이에요.

펜과 손가락을 다른 길로 보내기

BoardView 위에 자식 뷰 PencilDrawOverlay를 올렸어요. PencilDrawOverlay는 투명한 PKCanvasView이고, .pencilOnly 정책을 적용해서 펜만 그릴 수 있게 했어요. 반대로 보드의 recognizer는 펜을 받지 않아요.

보드 recognizer가 받는 touch 종류예요.

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

이렇게 나누면 펜은 overlay로 가고, 손가락·마우스·트랙패드는 원래의 셀 선택 처리로 가요. 획을 쓰는 동안에는 보드의 pan을 끄고 tap은 그대로 둬요. overlay는 접근성 요소로 노출하지 않아요. VoiceOver 사용자는 보드 접근성을 다룬 글에서 설명한 가상 셀 81개를 그대로 써요. 셀의 geometry는 하나를 같이 쓰고, touch가 들어오는 경로만 다른 구조예요.

tap인지 필기인지, 어느 묶음인지

첫 획의 경계 상자 대각선이 6pt 이하이고 걸린 시간이 0.12초 이하이면 tap으로 봐요. 두 조건을 모두 만족해야 해요. tap이면 ink를 지우고 경계 상자의 중심에 있는 셀을 선택해요. 이미 쓰고 있는 묶음 근처에 찍힌 획은 작고 짧아도 ink로 취급해요. 숫자의 일부일 수 있기 때문이에요.

묶음은 마지막 획 뒤로 0.45초를 기다렸다가 확정해요. 획이 추가될 때마다 대기 종료 시각을 새로 잡아요. 4, 5, 7처럼 여러 획으로 쓸 수 있는 숫자를 고려한 값이에요.

tap이 아닌 획이 기존 묶음의 중심에서 x 방향이나 y 방향으로 60pt 넘게 떨어지면 이전 묶음을 확정하고 새 묶음을 시작해요. 멀리 떨어진 곳의 짧고 작은 획은 tap 판정이 먼저예요. 이전 묶음을 처리한 다음 새 셀을 선택해요. 대상 셀은 묶음 전체의 상자 중심을 BoardView.cellIndex에 넘겨서 구해요. 둥근 모서리 판정도 기존 보드와 같은 함수를 써요. 획의 분류, 묶음 소속, 대상 셀 결정은 서로 다른 단계로 분리했어요.

60pt는 고정 거리라는 점이 약점이에요. 화면 크기에 따라 셀 간격이 달라지는데, 인접한 셀에 연달아 쓸 때의 경계는 테스트로 확정하지 못했어요. 셀 크기에 비례하는 값으로 바꾸거나, 지원 기기에서 거리를 재서 보정하는 방법을 생각하고 있어요.

모델이 보는 28×28 만들기

PKDrawing의 path에서 점 목록을 꺼내 보드 크기의 회색조 버퍼에 다시 그려요. 그 뒤 MNIST 형식에 맞춰 자르고 줄여요.

전처리 단계를 순서대로 적은 흐름이에요.

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

화면에 보이는 획의 굵기는 일반 입력이 6pt, note가 3pt예요. 추론용으로 다시 그릴 때는 둘 다 6pt로 그려요. 표시 모드가 모델이 받는 입력의 분포를 바꾸지 않게 하려는 선택이에요.

전처리 테스트는 빈 입력이 nil을 돌려주는지, 같은 입력에 같은 결과가 나오는지, 긴 변이 20px인지, 질량 중심이 제자리인지를 봐요. Python 기준의 Core ML label·확률과 Swift 결과도 비교해요. crop, 배율, 픽셀 방향, 강도 범위 가운데 하나라도 학습 조건과 어긋나면 오류 없이 실행되면서도 잘못 인식할 수 있어요. 실행이 성공했다는 사실만으로는 전처리가 맞는지 알 수 없어요.

받아들일 결과와 버릴 결과

모델의 class는 0부터 9까지 10개를 그대로 뒀어요. 스도쿠에는 0이 없지만, 애매한 동그라미를 6이나 9로 억지로 배정하지 않으려고 0을 남겼어요. 0으로 분류된 입력은 적용 단계에서 탈락시켜요.

confidence 기준은 기본 0.8이에요. note는 고치는 비용이 낮다고 보고 0.1 낮춘 0.7을 써요. 결과가 1부터 9 사이이고 기준 이상일 때만 셀을 선택하고 숫자를 넣어요. 0, 범위 밖의 값, 낮은 confidence, 모델 적재 실패, 전처리 실패, 추론 오류는 모두 reject예요. reject는 mistake 수를 올리지 않고 그냥 아무 동작도 하지 않아요. ink는 결과와 상관없이 지워요.

지금 구조에서는 원인이 다른 실패가 전부 .reject 하나로 합쳐져요. 모델을 못 불러온 것인지, 확률이 낮았던 것인지, label이 틀린 것인지 구분할 수 없어요. 원인별 집계가 필요해요. 원본 획을 보내지 않고 기기 안의 통계만으로 실패율을 보는 방법을 생각하고 있지만 아직 구현하지 않았어요.

늦게 도착한 결과

렌더링과 prediction은 Task.detached에서 돌리고, 받아들인 결과는 main actor에서 selectCell과 placeDigit을 호출해요. 여기에 문제가 두 가지 있어요.

첫째는 모델 공유예요. CoreMLDigitRecognizer가 @unchecked Sendable로 선언돼 있어서 같은 MLModel을 여러 task가 동시에 쓸 수 있어요. MLModel 문서는 한 인스턴스에 대한 호출을 직렬화하라고 조건을 달아요. @unchecked Sendable은 컴파일러 검사를 건너뛸 뿐이고 MLModel의 실행 조건을 바꾸지는 않아요. 그래서 지금 선언은 그 조건과 충돌할 우려가 있어요.

actor가 recognizer를 소유하게 바꾸는 안이에요. 아직 적용한 코드가 아니라 제안이에요.

actor DigitInference {
    private let recognizer: CoreMLDigitRecognizer

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

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

이렇게 하면 prediction이 직렬로 실행되고, 호출하는 쪽은 await로 불러야 해요. queue마다 모델을 복제하는 방법도 있지만, actor 하나로는 latency나 throughput이 부족하다고 측정된 뒤에 검토할 대안이에요.

둘째는 상태가 바뀐 뒤에 예전 결과가 적용될 수 있다는 점이에요. completion은 지금 playing 상태인지만 확인해요. pause 했다가 resume 하거나 note 모드를 바꾼 뒤에도 앞서 요청한 결과가 들어갈 수 있어요. 임계값은 획을 캡처할 때의 mode로 정하고, placeDigit은 완료 시점의 mode로 동작하는 불일치도 있어요. 요청할 때 아래 세 값을 함께 보관하고 적용 직전에 같은지 확인하는 방법을 생각하고 있어요.

input revision + target cell + entry mode

값이 다르면 결과를 버려요. revision은 단조 증가하는 숫자로 두면 구분할 수 있어요.

자동 테스트가 본 것과 보지 못한 것

자동 테스트는 순수 reducer와 renderer를 중심으로 짰어요. 둥근 보드의 hit test, tap과 ink의 조건, debounce, 먼 획의 재시작, 정규화, reject 분기, 번들 모델 적재, Python과 Swift 결과 비교, touch 허용 목록, delegate 연결을 봐요.

필기 fixture는 1과 7뿐이에요. 렌더링부터 예상 숫자까지 확인하는 입력이 두 종류라는 뜻이에요. 학습 스크립트의 임계값 탐색도 회전과 굵기를 바꾼 MNIST, 그리고 0과 noise를 쓴 대리 평가예요. 그래서 실제 펜 필기의 인식률이나 0.8이 가장 좋은 값이라는 것은 입증되지 않았어요.

delegate에 관해서는 소스 주석에 기록이 있어요. iOS 26.5에서 canvas가 자기 자신을 delegate로 연결하면 PencilKit이 재귀에 빠진다고 진단했고, 그래서 별도의 bridge를 둬요. 테스트는 canvas와 delegate가 같은 객체인지 아닌지만 확인해요. 당시의 기기 로그를 이번에 다시 확인하지는 않았어요. simulator의 마우스로는 .pencilOnly 경로를 실행할 수 없다는 제약도 있어요.

기기에서 확인해야 할 일은 여섯 가지예요.

  • 필체별 1~9의 precision과 reject 비율
  • 첫 응답의 latency와 연속 결과의 순서
  • 화면별 60pt 경계
  • 펜, 손가락, pointer, VoiceOver를 섞어 쓸 때의 동작
  • pause·resume이나 mode 변경 중 이전 결과의 폐기
  • 모델 적재 실패나 반복 reject를 사용자가 이해할 수 있는지

지표로는 받아들인 결과 가운데 오입력의 비율, reject 비율, p95 latency를 볼 생각이에요. 적용된 동작이 맞는 비율을 먼저 지키고, 인식하지 못하는 경우가 늘어나는 것은 감수하는 쪽으로 판단했어요.

iOS 27의 PKStrokeRecognizer로 바꾸려면

iOS 27 beta에는 PKStrokeRecognizer가 있어요. actor이고, 기기 안에서 비동기로 획을 text로 인식하는 API예요. PKDrawing을 입력으로 받아요. OS에 따라 인식 결과가 달라질 수 있어서, 결과를 저장하려면 recognitionVersion을 함께 보존해야 해요. 필기를 인식해 text로 바꾸는 방법 안내도 같이 공개돼 있어요. 배포 대상이 iOS 26.5인 앱에서 쓰려면 Xcode 27 beta SDK와 #available(iOS 27.0, *), 그리고 Core ML로 돌아가는 대체 경로가 필요해요.

구현만 바꿔 끼우기는 어려워요. 지금의 DigitRecognizing은 [Float]을 받아 digit과 confidence를 돌려줘요. PKStrokeRecognizer는 updateDrawing(_:)으로 입력을 넣고 recognizedText(strokeIDs:)에서 String?을 받으며 confidence가 없어요. 그래서 drawing과 context를 받는 더 위쪽의 비동기 경계가 필요해요.

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

Core ML 쪽은 지금의 렌더링과 확률 정책을 그대로 두고, 새 adapter는 text를 1~9로 제한하면 돼요. 확률이 없는 결과를 어떤 기준으로 자동 적용할지는 따로 제품 검증이 필요해요. beta 기간에는 기존 모델을 유지할 생각이에요.

이 글에 나온 0.8, 0.7, 60pt는 정책으로 정한 상수이고 측정으로 얻은 최적값이 아니에요. 필기 fixture 두 종류와 MNIST 대리 평가로는 실제 정확도를 말할 수 없고, 위에 적은 여섯 가지는 기기에서 확인하기 전까지 열려 있어요.