ko-hand-ocr
한글·영문이 섞인 손글씨 한 줄을 읽는다. 손글씨 데이터셋 없이, 합성 그림만으로 처음부터 학습한 41M 모델. Apache-2.0 — 가중치도 같다.
GitHub · PyPI · 만든 과정 (PDF) · 한국어 · English
맨 위 표는 공개된 다른 OCR 여섯(ko-trocr · PaddleOCR PP-OCRv5 · EasyOCR · Tesseract 5 ·
PaddleOCR-VL-1.6 · Qwen3-VL-2B)과 같은 시험지·같은 PC·같은 순간에 견준 것이다 — 누구를 어떻게
돌렸는지는 아래. 그 아래는 우리 두 모델의 항목별 성적이고,
tools/verify.py 로 글씨체마다 810~963줄씩 잰 값이다. 단일 모델은 41M 모델 하나로 읽는다.
앙상블은 모델 3개가 같이 읽고 서로 닮은 답을 고른다 — 더 정확하고 일곱 배 느리다. 초록 줄은 사진을 통째로 읽는 우리
VL 모델(0.8B)이다 — LM Studio·Ollama 에
그대로 올라간다(아래).
2026-10-08 에 시험지를 고쳤다. 동해독도·기랑해랑 시험지에 그 글꼴이 못 그리는 글자 (빈칸·□)가 든 줄이 섞여 있어서 다 맞혀도 97.5% 였다. 그 줄을 빼고 모두 다시 쟀다(나머지 네 벌은 그림까지 같다). 그래서 글씨체 숫자는 그 전 숫자보다 높게 나온다 — 모델이 바뀐 것이 아니다. 사진 숫자는 그대로다.
2026-10-08 에 시험지를 고쳤다. 동해독도·기랑해랑 시험지에 그 글꼴이 못 그리는 글자 (빈칸·□)가 든 줄이 섞여 있어서 다 맞혀도 97.5% 였다. 그 줄을 빼고 모두 다시 쟀다(나머지 네 벌은 그림까지 같다). 그래서 이 README 의 글씨체 숫자는 그 전 숫자보다 높게 나온다 — 모델이 바뀐 것이 아니다. 사진 숫자는 그대로다.
받기와 쓰기
이 저장소의 가중치는 GitHub v0.4.1 릴리스의 zip 과 같은 무게를
float16 으로 저장한 것이다(2026-10-12, 받는 크기가 반). 읽을 때는 설정대로 float32 로 올라간다. 시험 글씨체·두루
글씨체·사진 칸에서 단일 모델은 743줄 모두 답과 믿음값이 그대로였고, 앙상블은 731줄 가운데 한 줄이 다른 틀린 답으로
바뀌었다(정답 2025.12.06 을 2025.12.26 대신 2027.12.06 으로) — 점수는 소수 셋째 자리까지 같다. zip 과
sha256 이 같은 float32 원본은
이 저장소의 태그 v0.4.1 에 있다. 아래 「빠르기와 덩치」 표는 그 전에 잰 값이다 — 같은 날 ko-hand-ocr-vl 도 어휘를
잘라 853M -> 660M 이 됐다(답은 같다).
| 자리 | 무엇 | 크기 |
|---|---|---|
/ (맨 위) | 단일 모델 — 41M, 디코더 8층. 대부분 이것으로 충분하다 | 78MB |
ensemble/ | 앙상블 — 모델 3개(36M + 41M + 41M)가 같이 읽고 서로 닮은 답을 고른다. 바닥을 올리고 7배 느리다 | 225MB |
무게만으로는 안 읽힌다. 자모 어휘와 줄 자르기가 kohandocr
꾸러미에 있으니 먼저 설치한다. transformers 의 pipeline 으로는 못 연다.
pip install ko-hand-ocr
from huggingface_hub import snapshot_download
from kohandocr.reader import Reader
# 단일 모델 (78MB)
folder = snapshot_download("localdeel/ko-hand-ocr", ignore_patterns=["ensemble/*", "assets/*"])
reader = Reader(folder, device="cpu")
# 사진 한 장을 통째로. 줄 자르기까지 해 준다.
reader.read_photo(open("scan.jpg", "rb").read())
# -> ['보안점검표', '부서 : 포테토뭉부서', '이름 : 감자밭']
# 이미 줄을 잘라 두었다면 그림(PIL)을 묶음으로 넘긴다.
reader.read([cell_a, cell_b, cell_c])
# 앙상블 (225MB) — ensemble/also/ 가 있으면 Reader 가 알아서 모델 3개로 읽는다
folder = snapshot_download("localdeel/ko-hand-ocr", allow_patterns=["ensemble/*"])
reader = Reader(folder + "/ensemble", device="cpu")
후보 목록 안에서만 고르게 가두는 both(), 읽은 값과 믿음값을 같이 주는 read_trust()(믿음값은
모델끼리 견주어 재므로 앙상블에서만 나온다) 같은 나머지 쓰는 법은 GitHub README 에 있다.
LM Studio · Ollama · vLLM 과 같이 쓰기
모델을 그대로 올리고 싶으면 ko-hand-ocr-vl 을 쓴다. Qwen3.5-0.8B 를 구조 그대로 한글 손글씨로 미세조정한 VL 모델이라 새 코드 없이 올라가고, 사진을 자르지 않고 통째로 읽는다. 설정(욕심 뽑기, 생각 끔)까지 같이 들어 있어서 받자마자 쓴다.
ollama run hf.co/localdeel/ko-hand-ocr-vl-GGUF:Q8_0 # Ollama
lms get https://huggingface.co/localdeel/ko-hand-ocr-vl-GGUF@q8_0 # LM Studio
대신 스무 배 크고(1.6GB) CPU 에서 일곱 배 느리다. 잘라 둔 줄을 빨리 많이 읽을 것이면 이 41M 이 낫다.
이 41M 모델은 그대로는 못 올린다. 셋은 글을 이어 쓰는 LLM 을 돌리는 엔진이다. LM Studio·Ollama 는 llama.cpp 의 GGUF 만, vLLM 은 자기가 아는 구조만 돌린다. 이 모델은 그림 인코더에 교차 주의 디코더를 단 TrOCR 꼴이고 자모 어휘와 줄 자르기가 따로 있어서, GGUF 로 바꿀 수도 그 목록에 넣을 수도 없다. 대신 그 엔진들과 같은 말(API)을 하는 서버와 MCP 도구를 넣었다.
pip install "ko-hand-ocr[mcp]" # 서버만 쓸 거면 [mcp] 는 빼도 된다
서버 — OpenAI·Ollama 와 같은 말
ko-hand-ocr-serve # 허깅페이스에서 단일 모델(78MB)을 받아 127.0.0.1:8765 에 띄운다
ko-hand-ocr-serve --ensemble # 앙상블(225MB)
| 부르는 쪽 | 이렇게 |
|---|---|
openai 클라이언트, vLLM 을 부르던 코드 | base_url="http://127.0.0.1:8765/v1" 로 바꾼다. 그림은 image_url 에 base64 로 |
ollama CLI | OLLAMA_HOST=127.0.0.1:8765 를 주고 ollama run ko-hand-ocr "C:\scan.jpg" · ollama list |
| curl | curl --data-binary @scan.jpg http://127.0.0.1:8765/read |
import base64
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8765/v1", api_key="none")
picture = "data:image/jpeg;base64," + base64.b64encode(open("scan.jpg", "rb").read()).decode()
reply = client.chat.completions.create(model="ko-hand-ocr", messages=[
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": picture}}]}])
print(reply.choices[0].message.content) # 줄마다 한 줄
- 글로 물은 것은 쓰지 않는다. 그림을 줄로 잘라 읽고 줄마다 한 줄씩 돌려줄 뿐이다. 요약·정리는 못 한다 — 그건 채팅 모델 몫이다(아래 MCP).
- 그림 주소(
http://…)는 받으러 가지 않는다. base64 로 실어 보낸다. 아무 데도 나가지 않는다. - 기본은 이 PC(
127.0.0.1)에서만 열린다. 비밀번호가 없으니--host 0.0.0.0은 믿는 망에서만.
LM Studio — MCP 도구로
LM Studio(0.3.17 부터)의 채팅 모델은 바깥 도구를 부를 수 있다(MCP). ~/.lmstudio/mcp.json 에:
{"mcpServers": {"ko-hand-ocr": {"command": "ko-hand-ocr-mcp"}}}
ko-hand-ocr-mcp 가 PATH 에 없으면 전체 경로를 적는다(가상환경의 Scripts\ko-hand-ocr-mcp.exe).
그 뒤 도구를 부를 줄 아는 채팅 모델에게 "C:\scan.jpg 읽고 표로 정리해 줘" 라고 하면, 모델이
read_handwriting 으로 글자를 받아 정리한다. 글자를 알아보는 것은 이 모델, 정리는 채팅
모델이다. 채팅 창에 붙인 그림은 도구에게 오지 않으므로 파일 경로로 준다. 처음 부를 때 모델을
받는다(78MB). 그다음은 사진 한 장에 1초 안팎이다.
확인한 것과 안 한 것. ollama CLI 0.34(list·show·run), 공식 openai 클라이언트(한
번에·흘려 받기), MCP SDK 2.3 손님으로 불러 보았다. LM Studio 채팅 창과 Open WebUI 화면 안에서
끝까지 해 본 것은 아직 아니다.
vLLM 자체에 넣으려면 플러그인으로 모델 구조를 새로 써야 한다. 41M 모델이라 GPU 서버로 얻을 것이 작아서 하지 않았다 — 위 서버가 vLLM 과 같은 OpenAI 주소를 낸다. vLLM 에 모델로 올릴 것이면 위 VL 모델이 Qwen3.5 구조 그대로다(우리는 Windows 라 vLLM 위에서는 직접 돌려 보지 않았다).
한눈에
| 읽는 것 | 사진 속 손글씨 한 줄 (한글 + 영문) |
| 모델 | ViT-Small 인코더(ImageNet, Apache-2.0) + 8층 TrOCR 디코더(처음부터 학습) |
| 어휘 | 자모 229개 — 음절 11,172자를 외우지 않는다. 못 본 글자도 쓴다 |
| 파라미터 | 41M — ko-trocr(213.7M)의 1/5 |
| 가중치 | 78MB 단일 모델 · 225MB 앙상블(모델 3개) (따로 받는다 — GitHub Releases · 허깅페이스) |
| 꾸러미 | 119KB — 코드만 |
| 붙이기 | OpenAI·Ollama 와 같은 말을 하는 서버(ko-hand-ocr-serve) · LM Studio 에 붙는 MCP 도구(ko-hand-ocr-mcp) |
| 속도 | 줄당 0.18초 · 사진 한 장 1.04초 — GPU 없이 CPU 만으로 |
| 메모리 | 1.07GB 단일 모델 · 1.51GB 앙상블 |
| 성적 | 학습에 안 쓴 글씨체 여섯 벌 평균 96.6% (앙상블 96.6%) · 가장 낮은 벌 92.7% (앙상블 93.1%) · 손글씨 사진 11장 평균 94.9% (앙상블 96.6%) · 17개 가운데 95% 넘김 12개 (앙상블 13개) |
| 남과 견주면 | 같은 시험지에서 공개 OCR 여섯 가운데 가장 잘 읽는 것보다 글씨체 6벌 +18.6%p(ko-trocr), 사진 +15.7%p(ko-trocr) — 단일 모델 기준 |
| VL 판 | 사진을 자르지 않고 통째로 읽는 0.8B 모델 ko-hand-ocr-vl — Qwen3.5-0.8B 를 구조 그대로 미세조정해 LM Studio·Ollama·llama.cpp 에 그대로 올라간다 |
| 학습 자료 | 합성 — OFL 글꼴로 그때그때 그린다. 디스크에 남지 않는다 |
| 라이선스 | Apache-2.0, 가중치도 같다 — 자료에서 물려받는 약관이 없다 |
| 필요한 것 | Python 3.10+ · PyTorch 2.5+ |
되는 것 — 그리고 안 되는 것
된다
- 사진 한 장을 통째로. 줄을 찾아 자르고 각각 읽는다 (
read_photo) - 이미 잘린 줄 그림을 묶음으로 (
read) - 가둬서 읽기 — 후보 목록 안에서만 고르게 하고, 자유롭게 읽은 것과 맞는지 알려 준다 (
both) - 앙상블로 읽기 — 모델을 여럿 두고 서로 닮은 답을 고른다
- 아무 데도 보내지 않는다. CPU 에서 완전히 혼자 돈다
- LM Studio·Ollama·vLLM 쪽 도구와 붙는다 — 같은 말을 하는 서버와 MCP 도구(위 「LM Studio · Ollama · vLLM 과 같이 쓰기」)
안 된다
- 표 안의 칸을 라벨과 값으로 갈라 주지 않는다.
read_photo는 줄까지만 자른다 - 세로쓰기, 칸 병합은 다루지 않는다
- 읽을 것이 없는 칸(전체가 낙서인 것)도 무언가 읽어 낸다
왜 만들었나
우리가 찾아본 가운데 한국어 손글씨를 노리고 공개된 모델은 ddobokki/ko-trocr 하나였는데,
학습 자료가 AI Hub 라 이용 목적·재배포에 제약이 붙는다. 사내 도구에 넣거나 공개 꾸러미로
배포하려면 걸린다. 두루 쓰는 OCR(PaddleOCR·EasyOCR·Tesseract)과 큰 VLM 은 손글씨 한
줄에서 크게 떨어진다 — 같은 시험지에서 그 가운데 가장 잘 읽는 것이 글씨체 6벌
78.2%, 사진 78.5% 다(아래). 그래서 이 일을 하는 모델을
처음부터 다시 만들었다 — 부품마다 출처를 따져 볼 수 있게.
다른 OCR 과 같은 자로 견주면
상대는 여섯이다. 한국어 OCR 로 사람들이 실제로 꺼내 쓰는 것을 갈래마다 세웠다.
| 상대 | 갈래 | 크기 | 어떻게 돌렸나 |
|---|---|---|---|
ddobokki/ko-trocr | 한국어 TrOCR (AI Hub 로 배움) | 214M | transformers, float32, 빔 5, 길이 상한 16 -> 64 로 풀어 줌 |
| PaddleOCR PP-OCRv5 | 줄 인식기 (korean_PP-OCRv5_mobile_rec) | 3.3M | paddleocr 3.7.0 / paddle 3.4.0, TextRecognition |
| EasyOCR | 줄 인식기 (korean_g2) | 4.0M | easyocr 1.7.2, Reader(["ko", "en"]).recognize() |
| Tesseract 5 | 줄 인식기 (LSTM, 파라미터 수는 공개하지 않는다) | 파일 6MB | tesseract 5.5.3, --oem 1 --psm 7 -l kor+eng |
| PaddleOCR-VL-1.6 | OCR 전용 VLM | 906M | transformers 5.16.1, bfloat16, 물음 「OCR:」(모델 카드) |
| Qwen3-VL-2B-Instruct | 두루 쓰는 VLM | 2.1B | transformers 5.16.1, bfloat16, 한글 물음 — 셋을 대 보고 가장 잘 읽은 것 |
모두 모델 카드의 사용법 그대로다. 엔진마다 따로 깐 환경에서 돌리고
(tools/rival_worker.py), 시간은 그 엔진이 잰 값만 센다(그림을 넘기는 값은 뺀다).
견줌이 성립하려면 모델만 다르고 나머지는 같아야 한다. 사진은 우리 줄
자르기(kohandocr.page)로 자른 같은 칸을 모두에게 넣었고, 글씨체 시험지는
synth.render 가 그린 날것을 넣었다 — 우리 전처리(64×640 letterbox)를
씌워서 주면 상대만 두 번 찌그러진다. 자·GPU·잰 순간이 모두 같다. 무엇을 어떻게
쟀는지는 tools/vs.py 머리말에 다 적혀 있고, 아래 숫자는 전부 그것이 남긴
runs/VS.json 에서 나왔다.
실제 손글씨 사진 · 34줄 209글자
| 자모 닮음 | 글자 오류율(CER) | 줄 통째로 일치 | 가장 나쁜 장 | |
|---|---|---|---|---|
| ko-hand-ocr 앙상블 (118M) | 96.52% | 8.61% | 67.6% | 90.28% |
| ko-hand-ocr 단일 모델 (41M) | 94.24% | 13.40% | 61.8% | 83.03% |
| ko-hand-ocr-vl (853M) | 93.42% | 15.79% | 55.9% | 81.99% |
| ddobokki/ko-trocr (214M) | 78.50% | 39.23% | 26.5% | 58.56% |
| PaddleOCR PP-OCRv5 (3.3M) | 71.75% | 39.71% | 8.8% | 34.81% |
| Qwen3-VL-2B (2.1B) | 69.86% | 38.28% | 8.8% | 46.86% |
| PaddleOCR-VL-1.6 (906M) | 61.71% | 44.02% | 2.9% | 18.52% |
| EasyOCR (4.0M) | 66.88% | 55.02% | 2.9% | 46.19% |
| Tesseract 5 (6MB) | 13.21% | 98.56% | 0.0% | 0.00% |
안 배운 글씨체 6벌 · 640줄 4448글자
| 자모 닮음 | 글자 오류율(CER) | 줄 통째로 일치 | 가장 나쁜 벌 | |
|---|---|---|---|---|
| ko-hand-ocr 앙상블 (118M) | 97.08% | 6.72% | 76.7% | 94.06% |
| ko-hand-ocr 단일 모델 (41M) | 96.80% | 7.17% | 74.5% | 93.14% |
| ko-hand-ocr-vl (853M) | 97.01% | 5.82% | 78.8% | 94.96% |
| ddobokki/ko-trocr (214M) | 78.24% | 42.31% | 29.5% | 65.93% |
| PaddleOCR PP-OCRv5 (3.3M) | 72.69% | 40.54% | 29.4% | 52.43% |
| Qwen3-VL-2B (2.1B) | 66.56% | 79.07% | 20.6% | 49.77% |
| PaddleOCR-VL-1.6 (906M) | 63.71% | 61.13% | 19.2% | 43.58% |
| EasyOCR (4.0M) | 56.63% | 64.32% | 9.7% | 35.70% |
| Tesseract 5 (6MB) | 17.73% | 97.35% | 0.8% | 6.90% |
두루 읽나 — 24벌 · 634줄 3614글자
| 자모 닮음 | 글자 오류율(CER) | 줄 통째로 일치 | 가장 나쁜 벌 | |
|---|---|---|---|---|
| ko-hand-ocr 앙상블 (118M) | 97.73% | 3.21% | 87.5% | 89.83% |
| ko-hand-ocr 단일 모델 (41M) | 97.26% | 3.74% | 87.1% | 89.14% |
| ko-hand-ocr-vl (853M) | 97.38% | 4.01% | 85.5% | 92.24% |
| ddobokki/ko-trocr (214M) | 80.45% | 31.99% | 42.9% | 47.56% |
| PaddleOCR PP-OCRv5 (3.3M) | 75.29% | 30.02% | 40.7% | 19.38% |
| Qwen3-VL-2B (2.1B) | 67.97% | 63.25% | 26.8% | 23.91% |
| PaddleOCR-VL-1.6 (906M) | 66.45% | 45.99% | 25.2% | 27.29% |
| EasyOCR (4.0M) | 64.58% | 48.73% | 19.9% | 19.08% |
| Tesseract 5 (6MB) | 19.73% | 95.30% | 2.2% | 3.07% |
글꼴 480벌 전부 · 벌당 12줄 4316줄 22532글자
| 고르기 6벌6벌 | 두루 24벌24벌 | 학습에 쓴 벌450벌 | 전부480벌 | 95% 넘긴 벌 | 80% 밑인 벌 | |
|---|---|---|---|---|---|---|
| ko-hand-ocr 앙상블 (118M) | 99.15% | 99.00% | 98.24% | 98.29% | 437벌 | 5벌 |
| ko-hand-ocr 단일 모델 (41M) | 98.49% | 98.58% | 98.09% | 98.11% | 435벌 | 4벌 |
| ko-hand-ocr-vl (853M) | 99.26% | 98.69% | 97.93% | 97.98% | 429벌 | 7벌 |
| ddobokki/ko-trocr (214M) | 85.29% | 83.29% | 82.50% | 82.58% | 47벌 | 158벌 |
| PaddleOCR PP-OCRv5 (3.3M) | 80.07% | 78.48% | 78.05% | 78.09% | 86벌 | 198벌 |
| Qwen3-VL-2B (2.1B) | 65.50% | 67.42% | 69.34% | 69.20% | 52벌 | 286벌 |
| PaddleOCR-VL-1.6 (906M) | — | — | — | — | — | — |
| EasyOCR (4.0M) | 67.73% | 68.17% | 67.18% | 67.24% | 9벌 | 319벌 |
| Tesseract 5 (6MB) | 21.56% | 19.28% | 20.55% | 20.50% | 0벌 | 480벌 |
- 상대 여섯 가운데 가장 잘 읽는 것도 큰 차이로 뒤진다. 안 배운 글씨체 6벌에서 상대 으뜸은 ko-trocr 78.24%, 우리 단일 모델(41M)은 96.80% 다. 두루 24벌에서도 ko-trocr 80.45% 대 97.26%.
- 사진 열한 장은 아무도 본 적이 없다. 거기가 가장 깨끗한 자리이고, 우리 앙상블 96.52% · 단일 모델 94.24% 대 상대 으뜸 ko-trocr 78.50% 다. 줄을 통째로 맞힌 비율은 67.6% 대 26.5%(상대 가운데 가장 높은 값).
- 큰 VLM 이라고 손글씨 한 줄을 잘 읽지는 않는다. Qwen3-VL-2B(2.1B)는 6벌 66.56%, PaddleOCR-VL-1.6(906M)은 63.71% 다. 우리보다 스무 배에서 쉰 배 크지만 이 일에 맞춰 배운 모델이 아니다. 문서 전체를 읽는 일이라면 이야기가 다르다(재지 않았다).
- Tesseract 는 손글씨를 거의 못 읽는다(6벌 17.73%, 사진 13.21%). 인쇄 글자용 엔진이라 예상한 대로다. 기준선으로 둔다.
- 글꼴을 하나도 안 빼고 480벌을 읽혀도 우리 98.29%(앙상블) · 98.11%(단일 모델) 대 상대 으뜸 ko-trocr 82.58%. 우리가 본 적 없는 24벌(99.00%)이 학습에 쓴 450벌(98.24%)보다 높다 — 외워서 올라간 숫자가 아니라는 뜻이다. PaddleOCR-VL 은 가장 싼 묶음이 줄당 1초 안팎이라 480벌은 재지 않았다(표에서 빈칸).
- 그래도 무너지는 벌이 있다. 480벌 가운데 우리 단일 모델의 가장 낮은 벌이 47.31%, 앙상블은 63.21% 다. 여섯 벌만 보면 이런 벌이 안 보인다. 벌당 12줄뿐이라 한 벌의 값은 크게 흔들리므로 무리 합계와 분포로 본다.
- 단일 모델은 사진 바닥이 0.3.0 보다 낮다. 가장 나쁜 장이 hand-06
83.03% 다 — 세 줄짜리 장의 한 줄(
전자금융TF서약)이다. 앙상블에서는 다른 모델이 받아 주어 90.28% 그대로다. - 사진 통째를 읽는 VL 모델도 같이 쟀다(초록). ko-hand-ocr-vl (0.8B)은 줄을 하나씩 읽혀도 글씨체 6벌 97.01% 로 단일 모델(96.80%)과 비슷하고 가장 낮은 벌은 더 높다(94.96 대 93.14%). 사진 줄 칸은 93.42% 로 조금 낮고, 스무 배 크고 CPU 에서 일곱 배 느리다. 그 모델의 쓰임은 줄을 자르지 않고 사진 통째를 읽는 것과 LM Studio·Ollama 에 그대로 올라가는 것이다. 사진 통째로는 그쪽 설명서에 상대 VL 들과 견준 값이 있다(쪽 단위 94.3% 대 72.7~79.9%).
- 0.4.0 단일 모델(v83)과의 견줌은 아래 「얼마나 맞히나」에 새 시험지로 다시 잰 값으로 있다.
어디서 갈리나
갈래별 글자 오류율 — 낮을수록 좋다
| 라틴 섞임60줄 | 서식 라벨58줄 | 숫자 섞임84줄 | 한글 낱말·이름295줄 | 한글 문장143줄 | |
|---|---|---|---|---|---|
| ko-hand-ocr 앙상블 (118M) | 10.6% | 6.9% | 8.9% | 3.1% | 6.9% |
| ko-hand-ocr 단일 모델 (41M) | 10.6% | 7.1% | 8.8% | 3.2% | 8.1% |
| ko-hand-ocr-vl (853M) | 8.7% | 7.1% | 9.5% | 3.6% | 4.0% |
| ddobokki/ko-trocr (214M) | 55.7% | 30.7% | 50.9% | 24.7% | 49.5% |
| PaddleOCR PP-OCRv5 (3.3M) | 51.2% | 36.2% | 44.4% | 32.4% | 42.1% |
| Qwen3-VL-2B (2.1B) | 58.3% | 92.9% | 51.9% | 90.8% | 89.7% |
| PaddleOCR-VL-1.6 (906M) | 76.6% | 65.3% | 46.9% | 77.2% | 50.6% |
| EasyOCR (4.0M) | 67.5% | 56.1% | 65.3% | 64.1% | 64.9% |
| Tesseract 5 (6MB) | 90.6% | 100.5% | 81.4% | 113.4% | 95.7% |
줄 길이별 글자 오류율 — 낮을수록 좋다
| 1~5자279줄 | 6~10자257줄 | 11~20자98줄 | 21자 이상6줄 | |
|---|---|---|---|---|
| ko-hand-ocr 앙상블 (118M) | 4.1% | 6.4% | 7.4% | 17.6% |
| ko-hand-ocr 단일 모델 (41M) | 4.0% | 7.6% | 7.2% | 17.6% |
| ko-hand-ocr-vl (853M) | 5.4% | 5.2% | 6.7% | 7.9% |
| ddobokki/ko-trocr (214M) | 29.4% | 36.0% | 52.3% | 94.9% |
| PaddleOCR PP-OCRv5 (3.3M) | 41.3% | 33.3% | 41.3% | 96.3% |
| Qwen3-VL-2B (2.1B) | 95.1% | 82.7% | 61.6% | 83.3% |
| PaddleOCR-VL-1.6 (906M) | 95.0% | 50.4% | 52.1% | 57.9% |
| EasyOCR (4.0M) | 69.4% | 59.1% | 62.6% | 98.1% |
| Tesseract 5 (6MB) | 113.5% | 97.4% | 84.8% | 102.3% |
- 라틴 약어에서 갈린다. 글자 오류율이 우리 10.6%(단일 모델) 대 상대 가운데
가장 나은 PaddleOCR PP-OCRv5 51.2% 다. 서식에 섞이는 영문은 대개
약어(
TF,OCR,Codex)인데, 한글과 영문을 한 줄에서 같이 읽는 데서 갈린다. - 길수록 갈린다. 21자 이상에서 우리 17.6% 대 상대 가운데 가장 나은 PaddleOCR-VL-1.6 57.9%. ko-trocr 는 인코더가 384×384 정사각형이라 긴 줄을 통째로 눌러 넣는다. 우리는 64×640 이다. 다만 21자 이상은 6줄뿐이라 이 칸 하나로 결론 내면 안 된다 — 방향만 본다. 긴 줄과 문장은 우리 VL 모델이 더 낫다(21자 이상 7.9%, 한글 문장 4.0%).
무엇을 치렀나
빠르기와 덩치
| 파라미터 | 받는 크기 | 줄/초 (GPU) | 줄/초 (CPU) | 30줄 쪽 (GPU) | 30줄 쪽 (CPU) | 꼭대기 VRAM | |
|---|---|---|---|---|---|---|---|
| ko-hand-ocr 앙상블 (118M) | 118M | 225MB | 8.2 | 1.05 | 4.04초 | 29.1초 | 1772MB |
| ko-hand-ocr 단일 모델 (41M) | 41M | 78MB | 42.0 | 6.76 | 1.12초 | 4.8초 | 1052MB |
| ko-hand-ocr-vl (853M) | 853M | 1666MB | 12.4 | 0.95 | 2.83초 | 31.9초 | 2601MB |
| ddobokki/ko-trocr (214M) | 214M | 408MB | 6.2 | 0.92 | 5.23초 | 32.9초 | 2744MB |
| PaddleOCR PP-OCRv5 (3.3M) | 3.3M | 13MB | 522.3 | 64.01 | 0.46초 | 0.9초 | 111MB |
| Qwen3-VL-2B (2.1B) | 2.1B | 4058MB | 10.5 | 0.60 | 3.27초 | 50.4초 | 4912MB |
| PaddleOCR-VL-1.6 (906M) | 906M | 1828MB | 1.4 | 0.08 | 22.36초 | 355.8초 | 1831MB |
| EasyOCR (4.0M) | 4.0M | 15MB | 66.5 | 42.47 | 0.85초 | 1.1초 | 70MB |
| Tesseract 5 (6MB) | — | 6MB | — | 35.79 | — | 1.2초 | — |
- 가장 빠른 것은 우리가 아니다. CPU 에서 PaddleOCR PP-OCRv5 64.0줄/초, EasyOCR 42.5줄/초, Tesseract 5 35.8줄/초로 우리 단일 모델(6.8줄/초)보다 빠르다. 대신 6벌에서 PaddleOCR PP-OCRv5 72.7%, EasyOCR 56.6%, Tesseract 5 17.7% 를 읽는다. 맞히는 값과 빠르기를 같이 봐야 한다 — 서식에서 틀린 답은 빈칸보다 나쁘다.
- 같은 일을 노린 ko-trocr(214M)와 견주면 CPU 에서 7.3배 빠르다(서른 줄 쪽 4.8초 대 32.9초). GPU 없는 사무용 PC 에 넣을 수 있느냐가 여기서 갈린다.
- CPU 값은 이 노트북의 그때 상태를 크게 탄다. 2026-10-07 하루에 같은 칸·같은
방식으로 단일 모델이 4.6·3.1·2.6줄/초, ko-trocr 와의 배수가 6.3·5.9·4.4배로 움직였고,
이 판(2026-10-09)은 6.8줄/초·7.3배였다. 그래서 아래 「속도와 사양」(
bench.py로 따로 잰 것)의 줄당 0.18초와 숫자가 다르다. 줄/초 자체보다 같은 판 안의 순서를 보고, 배수는 4~7배로 읽는다 — ko-trocr 보다 빠르다는 것은 네 번 모두 같았다. - 큰 VLM 은 GPU 가 있어야 쓸 수 있다. CPU 에서 Qwen3-VL-2B 는 0.60줄/초, PaddleOCR-VL-1.6 은 0.08줄/초라 서른 줄 쪽이 50초, 356초다. GPU 에서도 10.5줄/초, 1.4줄/초로 우리 단일 모델(42.0줄/초)보다 느리다. CPU 에서 줄당 2초·12초라 1줄 묶음만 한 번 쟀다. 우리 VL 모델(0.8B)도 같은 처지다 — CPU 0.95줄/초, GPU 12.4줄/초.
- 묶음 크기는 짐작하지 않고 잰다. 모델마다 가장 싼 묶음으로 읽혔다. 이 카드에 안 들어가는 묶음은 재지 않는다 — ko-trocr 의 32줄 묶음이 그렇고, 자가 적어 놓은 까닭은 「앞 묶음 4642MB(가중치 848MB)에서 활성값을 두 배 하면 8437MB 로 카드 8151MB 를 넘는다」이다. 안 잰 값을 잰 것처럼 적지 않는다.
같지 않은 것 — 어느 쪽에 유리한지까지 적는다
| 무엇이 | 어떻게 | 누구에게 유리 |
|---|---|---|
| 시험 글꼴 | 여섯·스물네 벌은 우리 학습에서만 뺐다. 상대가 무엇을 보고 배웠는지 우리는 모른다 | 상대 |
| 줄 자르기 | 상대에게 우리 것을 빌려주었다(모두 한 줄 그림을 받는다) | 상대 |
| 길이 상한 | ko-trocr 의 기본은 max_length: 16(글자 단위). VLM 둘과 같이 64 토큰으로 풀어 주었다. Qwen3-VL 이 상한에 닿은 줄은 모두 되풀이로 넘친 줄(11 11 11 …)이었고 긴 정답이 잘린 줄은 없었다(2026-10-07 에 글씨체 670줄을 다시 읽혀 보니 상한에 닿은 24줄이 모두 그랬다) | ko-trocr |
| 물음 | Qwen3-VL 은 정해 둔 OCR 물음이 없어 셋을 대 보고 가장 잘 읽는 것을 주었다 | Qwen3-VL |
| 정밀도 | VLM 셋(우리 VL 포함)은 나온 정밀도(bfloat16) 그대로, 나머지는 float32. ko-trocr 는 float16 빠르기도 따로 쟀다 | — |
| VLM 의 CPU | 줄당 몇 초에서 수십 초라 1줄 묶음을 한 번만 쟀다. 8줄로 묶으면 더 빠를 수도 있다 | ko-hand-ocr |
| 480벌 | PaddleOCR-VL 은 줄당 1초 안팎이라 480벌 전부는 안 쟀다(빈칸) | — |
| 만든 목적 | 줄 인식기 셋은 주로 인쇄·장면 글자, VLM 둘은 문서 전체, ko-trocr 는 AI Hub 의 손글씨·공공행정문서. 손글씨 한 줄 하나만 노리고 만든 것은 우리 쪽이다 | ko-hand-ocr |
| 모델 수 | 우리 앙상블은 모델 셋(36M 하나 + 41M 둘, 118M). 크기로 견주려면 「단일 모델」 줄을 본다 | ko-hand-ocr |
| 학습에 쓴 글꼴 | 480벌 가운데 450벌은 우리가 본 글씨체다. 그래서 무리를 갈라 적는다 | ko-hand-ocr |
| 그리개 | 우리는 시험지와 같은 그리개(synth.render)로 그린 합성 그림으로 배웠다(글꼴은 다르다). 글씨체 시험지의 생김새가 우리에게 낯익다 — 그래서 사진 쪽이 가장 깨끗한 견줌이다 | ko-hand-ocr |
상대들이 나쁜 모델이라는 뜻이 아니다. 인쇄 서류·장면 글자·문서 전체를 읽으라고 만든 모델에 손글씨 한 줄을 들이민 것이고, 그 일에서는 이쪽이 낫다는 것뿐이다. 반대 방향(인쇄 서류 전체, 표, 장면 글자)은 재지 않았고, 재면 우리가 질 가능성이 크다.
잰 자리: NVIDIA GeForce RTX 5060 Laptop GPU · AMD Ryzen 7 260 · torch 2.14.0+cu130 · 2026-10-09 07:25. 2026-10-08 에 시험지를 고친 뒤 열 모델을 한 번에 다시 잰 것이다(VL 설명서에만 싣는 Qwen3.5-0.8B 원본은 이 표에서 뺐다). 사진 쪽은 그 전 판(2026-10-07)과 글자 하나까지 같다 — 사진은 고친 것이 없다. 빠르기는 그때그때 기계 상태를 타므로 같은 순간에 잰 것끼리만 견준다.
얼마나 맞히나
두 가지로 잰다. 둘 다 봐야 한다. 아래는 전부
python tools/verify.py 로 낸 값이다(글씨체 벌마다 810~963줄, 사진 11장).
| 안 배운 글씨체 (평균) | 가장 낮은 글씨체 | 손글씨 사진 11장 | 가장 나쁜 장 | 95% 넘긴 항목 | |
|---|---|---|---|---|---|
| 단일 모델 (0.4.1) | 96.6% | 92.7% | 94.9% | 84.2% | 12 / 17 |
| 앙상블 (모델 3개) | 96.6% | 93.1% | 96.6% | 90.3% | 13 / 17 |
| 0.4.0 단일 모델 (v83) | 96.4% | 92.6% | 95.0% | 83.0% | 11 / 17 |
글씨체는 벌마다 1,000줄을 뽑고, 그 글꼴이 못 그리는 글자가 든 줄을 뺀다 — 963줄, 동해독도 814줄, 기랑해랑 810줄이다. 기울임 줄은 지난 릴리스이고 같은 자로 다시 쟀다 (2026-10-09). 2026-10-08 에 시험지를 고쳐서 그 전에 낸 글씨체 숫자와는 견주지 않는다 — 고친 것은 맨 위에 적었다. 0.3.0 은 새 시험지로 다시 재지 않아서 표에서 뺐다 (그 전 시험지로 잰 견줌은 아래에 있다).
0.4.1 에서 바뀐 것은 단일 모델 하나다. 앙상블(모델 3개)이 다음 글자마다 내는 확률을 단일 모델이 따라 배우게 하고(증류), 그렇게 배운 모델을 0.4.0 의 v83 과 반씩 섞었다. 크기·구조가 그대로라 빠르기도 같다. 17개 가운데 95% 를 넘긴 것이 11 -> 12개(hand-07 94.4 -> 95.8%), 글씨체 여섯 벌이 모두 조금씩 올랐고(가장 낮은 벌 92.6 -> 92.7%), 가장 나쁜 장 hand-06 이 83.0 -> 84.2% 다. 대신 hand-08 이 100 -> 96.7% 로 내려갔다. 크게 나아진 것은 아니다 — 사진 평균은 95.0 대 94.9% 로 같다. 같은 방법을 세기만 바꿔 다시 해 보거나(몫 0.25·1.0), 드롭아웃·무게 지수 평균·SAM 으로 해 본 것은 모두 v83 을 못 넘었다.
0.4.0 은 0.3.0 에서 무엇이 나아졌나(그 전 시험지로 잰 값). 단일 모델(v83)은 글씨체 평균 94.6 -> 95.5%, 가장 낮은 벌 88.5 -> 90.1% 로 올랐다. 대신 사진 바닥이 내려갔다 (hand-06 90.3 -> 83.0%, 세 줄짜리 장의 한 줄). 앙상블은 바닥을 그대로 지키고 사진 평균이 95.8 -> 96.6% 로 올랐다. 17개 항목(글씨체 6 + 사진 11) 가운데 95% 를 넘긴 것이 앙상블 10 -> 12개다.
자모 단위 닮음이고, 사진 쪽은 사진 한 장에 한 표다.
사진 숫자를 그대로 기대하면 안 된다. 만든 사람의 사진 11장 35칸으로 잰 값이라 폭이 ±5%p 다. 남의 손씨에 더 가까운 값은 '안 배운 글씨체' 쪽이다 — 학습에 한 번도 안 쓴 글꼴 여섯 벌로만 시험한 것이라 폭이 절반이다(±2~3%p).
앙상블의 값은 평균이 아니라 바닥에 있다. 글씨체 평균은 이제 같은데 (96.6 대 96.6) 가장 낮은 글씨체가 92.7% 에서 93.1% 로 올라가고, 단일 모델이 84.2% 로 흘린 장(hand-06)이 90.3% 가 된다. 한 모델이 아슬아슬하게 읽는 칸을 다른 모델이 받아 주기 때문이다.
대신 모든 장이 좋아지지는 않는다. 단일 모델이 95.2% 로 넘긴 장이 92.9% 로 내려가기도 한다(hand-10) — 두 모델이 같이 틀리면 맞게 읽은 한 모델이 밀린다. 그리고 일곱 배 느리다. 한 장이라도 사람 손으로 넘어가면 곤란한 자리에는 앙상블을, 많은 양을 빨리 훑어야 하면 단일 모델을 쓴다.
글씨체마다 (벌마다 810~963줄)
합계 하나만 보면 무너지는 글씨체가 가려진다. 실제로 6%p 넘게 벌어진다.
| 글씨체 | 줄 | 단일 모델 (0.4.1) | 앙상블 | 0.4.0 단일 모델 (v83) |
|---|---|---|---|---|
| 동해독도 (EastSeaDokdo) | 814 | 92.7% | 93.1% | 92.6% |
| 하이멜로디 (HiMelody) | 963 | 95.6% | 95.4% | 95.4% |
| 나눔펜스크립트 (NanumPenScript) | 963 | 95.9% | 95.9% | 95.7% |
| 감자꽃 (GamjaFlower) | 963 | 98.0% | 98.0% | 97.9% |
| 기랑해랑 (KirangHaerang) | 810 | 98.1% | 98.0% | 98.1% |
| 싱글데이 (SingleDay) | 963 | 99.0% | 98.9% | 98.9% |
넘어야 하는 것은 평균이 아니라 가장 낮은 벌이다. 여섯 벌이 모두 90% 를 넘긴 뒤(2026-09-18) 목표를 95% 로 올렸다. 지금은 다섯이 넘고 동해독도가 남았다. 획이 뭉개지고 생략되는 손씨다. 기랑해랑은 시험지를 고치자 94.7 -> 98.1% 가 됐다 — 모델이 나아진 것이 아니라 그 글꼴이 네모(□)로 그리던 줄을 뺀 몫이다.
여섯 벌 다 학습에 한 번도 안 쓴 글꼴이고, 모두 SIL Open Font License 다.
python tools/holdout.py <모델폴더> --per-font --sample sample.png
더 보기
- 학습 방법, 시험지 그림, 속도와 사양(
tools/bench.py), 돌아가는 곳: GitHub README - 부품마다의 출처와 약관: PROVENANCE.md
- 견줌을 다시 재는 도구:
tools/vs.py,tools/rival_worker.py
라이선스
Apache-2.0. 가중치도 같다. 학습에 쓴 글꼴의 라이선스는 가중치에 옮아붙지 않는다 — 가중치는 글자 모양의 저작물이 아니라 그림에서 배운 값이다. 근거는 PROVENANCE.md 에 부품별로 적어 두었다.
English
Reads one line of handwritten Korean + English. A 41M model trained from scratch on synthetic data — no handwriting dataset, no inherited terms. Apache-2.0, weights included. Full English README: README.en.md. The green row on the board is our VL model (0.8B), which reads whole photos and loads straight into LM Studio and Ollama.
The test sheets were fixed on 2026-10-08. The EastSeaDokdo and KirangHaerang sheets contained lines with characters those fonts cannot draw, capping them at 97.5%. Those lines are now dropped and everything was re-measured; font numbers are higher than before — the models did not change.
Download and use
The weights here are the same weights as the zips on the GitHub
v0.4.1 release, stored as float16 since 2026-10-12
(half the download). They load as float32 as the config says. On the test fonts, held-out fonts and photo line
crops the single model gave the same answers and trust values on all 743 lines; the ensemble changed one line of
731 from one wrong reading to another (2025.12.06 read as 2027.12.06 instead of 2025.12.26), with scores
equal to three decimals. The float32 originals (same sha256 as the zips) are at this repo's tag v0.4.1. The "speed and
size" table below was measured before that; the same day ko-hand-ocr-vl also had its vocabulary cut,
853M -> 660M (same answers).
| Path | What | Size |
|---|---|---|
/ (root) | Single model — 41M, 8-layer decoder. Enough for most uses | 78MB |
ensemble/ | Ensemble — three models (36M + 41M + 41M) read together and the answer they agree on wins. Raises the floor, 7x slower | 225MB |
The weights alone do not read anything: the jamo vocabulary and the line cutter live in the
kohandocr package, so install it first. The
transformers pipeline cannot open this model.
pip install ko-hand-ocr
from huggingface_hub import snapshot_download
from kohandocr.reader import Reader
folder = snapshot_download("localdeel/ko-hand-ocr", ignore_patterns=["ensemble/*", "assets/*"])
reader = Reader(folder, device="cpu")
reader.read_photo(open("scan.jpg", "rb").read()) # a whole photo: cuts lines, reads each
# ensemble
folder = snapshot_download("localdeel/ko-hand-ocr", allow_patterns=["ensemble/*"])
reader = Reader(folder + "/ensemble", device="cpu")
Use it with LM Studio, Ollama and vLLM
To load a model straight into them, use ko-hand-ocr-vl. It is Qwen3.5-0.8B fine-tuned on Korean handwriting with the architecture unchanged, so it loads with no new code and reads a whole photo without line cutting. Its settings (greedy decoding, thinking off) ship with it, so it works as soon as it is downloaded.
ollama run hf.co/localdeel/ko-hand-ocr-vl-GGUF:Q8_0 # Ollama
lms get https://huggingface.co/localdeel/ko-hand-ocr-vl-GGUF@q8_0 # LM Studio
It is twenty times larger (1.6 GB) and seven times slower on CPU. For reading many pre-cut lines quickly, this 41M model is the better fit.
This 41M model cannot be loaded into them as is. All three are engines for LLMs that continue text: LM Studio and Ollama run llama.cpp GGUF files, and vLLM runs the architectures it knows. This model is a TrOCR-style image encoder with a cross-attention decoder, plus its own jamo vocabulary and line cutter, so it neither converts to GGUF nor fits vLLM's list. Instead the package ships a server that speaks the same APIs and an MCP tool.
pip install "ko-hand-ocr[mcp]" # drop [mcp] if you only need the server
Server — the OpenAI and Ollama APIs
ko-hand-ocr-serve # fetches the single model (78MB) from Hugging Face, serves 127.0.0.1:8765
ko-hand-ocr-serve --ensemble # the ensemble (225MB)
| Caller | How |
|---|---|
openai client, code written for vLLM | set base_url="http://127.0.0.1:8765/v1"; send the image as base64 in image_url |
ollama CLI | set OLLAMA_HOST=127.0.0.1:8765, then ollama run ko-hand-ocr "C:\scan.jpg" · ollama list |
| curl | curl --data-binary @scan.jpg http://127.0.0.1:8765/read |
import base64
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8765/v1", api_key="none")
picture = "data:image/jpeg;base64," + base64.b64encode(open("scan.jpg", "rb").read()).decode()
reply = client.chat.completions.create(model="ko-hand-ocr", messages=[
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": picture}}]}])
print(reply.choices[0].message.content) # one line per handwritten line
- Text in the prompt is ignored. The image is cut into lines and each line comes back as one line of text. It cannot summarise or tidy up — that is the chat model's job (MCP, below).
- Image URLs (
http://…) are not fetched. Send base64. Nothing leaves the machine. - By default it listens on this PC only (
127.0.0.1). There is no password, so use--host 0.0.0.0only on a network you trust.
LM Studio — as an MCP tool
Since 0.3.17, chat models in LM Studio can call outside tools (MCP). In ~/.lmstudio/mcp.json:
{"mcpServers": {"ko-hand-ocr": {"command": "ko-hand-ocr-mcp"}}}
If ko-hand-ocr-mcp is not on PATH, give the full path (Scripts\ko-hand-ocr-mcp.exe in the
virtual environment). Then ask a chat model that can call tools to "read C:\scan.jpg and put it in
a table": it calls read_handwriting for the text and does the tidying itself. This model
recognises the characters; the chat model organises them. An image pasted into the chat window
goes to the chat model, not the tool, so pass a file path. The first call downloads the model
(78MB); after that a photo takes about a second.
What was checked and what was not. The ollama CLI 0.34 (list, show, run), the official
openai client (whole and streamed replies) and an MCP SDK 2.3 client were all run against it. A full
run inside the LM Studio chat window or Open WebUI has not been done yet.
Putting it inside vLLM itself would mean writing the architecture as a plugin. For a 41M model a GPU server buys little, so that was not done — the server above exposes the same OpenAI address vLLM does. To serve a model on vLLM itself, the VL model above keeps the Qwen3.5 architecture (we build on Windows, so it has not been run on vLLM ourselves).
At a glance
| What it reads | One line of handwritten Korean + English, from a photo |
| Model | ViT-Small encoder (ImageNet, Apache-2.0) + 8-layer TrOCR decoder trained from scratch |
| Vocabulary | 229 jamo tokens — not 11,172 syllables, so unseen characters are still writable |
| Parameters | 41M — one fifth of ko-trocr (213.7M) |
| Weights | 78 MB single model · 225 MB ensemble (3 models) (downloaded separately — GitHub Releases · Hugging Face) |
| Package | 119 KB — the code only |
| Plugs into | a server that speaks the OpenAI and Ollama APIs (ko-hand-ocr-serve) · an MCP tool for LM Studio (ko-hand-ocr-mcp) |
| Speed | 0.18 s per line · 1.04 s for a whole photo — CPU only, no GPU |
| Memory | 1.07 GB single model · 1.51 GB ensemble |
| Accuracy | 96.6% mean on six handwriting fonts never seen in training (96.6% ensemble) · worst font 92.7% (93.1% ensemble) · 11 handwriting photos 94.9% (96.6% ensemble) · 12 of 17 items over 95% (ensemble 13) |
| Against others | On the same sheets, ahead of the best of six public OCR models by +18.6 points on the six fonts (ko-trocr) and +15.7 points on the photos (ko-trocr) — single model |
| VL sibling | ko-hand-ocr-vl, a 0.8B model that reads whole photos without line cutting — Qwen3.5-0.8B fine-tuned with the architecture unchanged, so it loads straight into LM Studio, Ollama and llama.cpp |
| Training data | Synthetic — drawn on the fly from OFL fonts. Nothing is stored on disk |
| License | Apache-2.0, weights included — no dataset terms inherited |
| Python | 3.10+ · PyTorch 2.5+ |
Against six other OCR models, same sheets, same PC, same moment
| Rival | Kind | Size | How it was run |
|---|---|---|---|
ddobokki/ko-trocr | Korean TrOCR (trained on AI Hub) | 214M | transformers, float32, beam 5, length cap raised 16 -> 64 |
| PaddleOCR PP-OCRv5 | line recogniser (korean_PP-OCRv5_mobile_rec) | 3.3M | paddleocr 3.7.0 / paddle 3.4.0, TextRecognition |
| EasyOCR | line recogniser (korean_g2) | 4.0M | easyocr 1.7.2, Reader(["ko", "en"]).recognize() |
| Tesseract 5 | line recogniser (LSTM, no parameter count published) | 6MB file | tesseract 5.5.3, --oem 1 --psm 7 -l kor+eng |
| PaddleOCR-VL-1.6 | OCR-specific VLM | 906M | transformers 5.16.1, bfloat16, prompt "OCR:" (model card) |
| Qwen3-VL-2B-Instruct | general VLM | 2.1B | transformers 5.16.1, bfloat16, Korean prompt — the best of three we tried |
Every rival runs exactly as its model card says, each in its own environment. Photos were cut once with our line cutter and the same cells went to everyone; font sheets were handed over raw.
Real handwriting photos · 34 lines, 209 characters
| Jamo similarity | Character error rate | Exact line match | Worst photo | |
|---|---|---|---|---|
| ko-hand-ocr ensemble (118M) | 96.52% | 8.61% | 67.6% | 90.28% |
| ko-hand-ocr single model (41M) | 94.24% | 13.40% | 61.8% | 83.03% |
| ko-hand-ocr-vl (853M) | 93.42% | 15.79% | 55.9% | 81.99% |
| ddobokki/ko-trocr (214M) | 78.50% | 39.23% | 26.5% | 58.56% |
| PaddleOCR PP-OCRv5 (3.3M) | 71.75% | 39.71% | 8.8% | 34.81% |
| Qwen3-VL-2B (2.1B) | 69.86% | 38.28% | 8.8% | 46.86% |
| PaddleOCR-VL-1.6 (906M) | 61.71% | 44.02% | 2.9% | 18.52% |
| EasyOCR (4.0M) | 66.88% | 55.02% | 2.9% | 46.19% |
| Tesseract 5 (6MB) | 13.21% | 98.56% | 0.0% | 0.00% |
Six unseen handwriting fonts · 640 lines, 4448 characters
| Jamo similarity | Character error rate | Exact line match | Worst font | |
|---|---|---|---|---|
| ko-hand-ocr ensemble (118M) | 97.08% | 6.72% | 76.7% | 94.06% |
| ko-hand-ocr single model (41M) | 96.80% | 7.17% | 74.5% | 93.14% |
| ko-hand-ocr-vl (853M) | 97.01% | 5.82% | 78.8% | 94.96% |
| ddobokki/ko-trocr (214M) | 78.24% | 42.31% | 29.5% | 65.93% |
| PaddleOCR PP-OCRv5 (3.3M) | 72.69% | 40.54% | 29.4% | 52.43% |
| Qwen3-VL-2B (2.1B) | 66.56% | 79.07% | 20.6% | 49.77% |
| PaddleOCR-VL-1.6 (906M) | 63.71% | 61.13% | 19.2% | 43.58% |
| EasyOCR (4.0M) | 56.63% | 64.32% | 9.7% | 35.70% |
| Tesseract 5 (6MB) | 17.73% | 97.35% | 0.8% | 6.90% |
Speed and size
| Parameters | Download | lines/s (GPU) | lines/s (CPU) | 30-line page (GPU) | 30-line page (CPU) | Peak VRAM | |
|---|---|---|---|---|---|---|---|
| ko-hand-ocr ensemble (118M) | 118M | 225MB | 8.2 | 1.05 | 4.04s | 29.1s | 1772MB |
| ko-hand-ocr single model (41M) | 41M | 78MB | 42.0 | 6.76 | 1.12s | 4.8s | 1052MB |
| ko-hand-ocr-vl (853M) | 853M | 1666MB | 12.4 | 0.95 | 2.83s | 31.9s | 2601MB |
| ddobokki/ko-trocr (214M) | 214M | 408MB | 6.2 | 0.92 | 5.23s | 32.9s | 2744MB |
| PaddleOCR PP-OCRv5 (3.3M) | 3.3M | 13MB | 522.3 | 64.01 | 0.46s | 0.9s | 111MB |
| Qwen3-VL-2B (2.1B) | 2.1B | 4058MB | 10.5 | 0.60 | 3.27s | 50.4s | 4912MB |
| PaddleOCR-VL-1.6 (906M) | 906M | 1828MB | 1.4 | 0.08 | 22.36s | 355.8s | 1831MB |
| EasyOCR (4.0M) | 4.0M | 15MB | 66.5 | 42.47 | 0.85s | 1.1s | 70MB |
| Tesseract 5 (6MB) | — | 6MB | — | 35.79 | — | 1.2s | — |
- We are not the fastest. On CPU, PaddleOCR PP-OCRv5 (64.0 lines/s), EasyOCR (42.5 lines/s) and Tesseract 5 (35.8 lines/s) beat our single model (6.8 lines/s). They read PaddleOCR PP-OCRv5 72.7%, EasyOCR 56.6%, Tesseract 5 17.7% of the six fonts. Read accuracy and speed together — on a form a wrong answer is worse than a blank.
- Against a model of similar purpose, ko-trocr (214M), we are 7.3× faster on CPU (a thirty-line page in 4.8s against 32.9s). Whether it fits on an office PC with no GPU is decided here.
- CPU figures ride hard on this laptop's state at the time. On 2026-10-07, with the same
cells and method, the single model ran at 4.6, 3.1 and 2.6 lines/s and its lead over
ko-trocr moved through 6.3×, 5.9× and 4.4×; this run (2026-10-09) gave 6.8 lines/s and
7.3×. That is why the 0.18 s per line under "Speed and footprint" below (
bench.py, measured separately) does not match. Read the order within one run rather than lines/s itself, and the lead as 4–7× — being faster than ko-trocr held all four times. - The large VLMs need a GPU. On CPU Qwen3-VL-2B does 0.60 lines/s and PaddleOCR-VL-1.6 0.08, so a thirty-line page takes 50s and 356s. Even on GPU they run at 10.5 and 1.4 lines/s, slower than our single model (42.0). On CPU they take 2s and 12s per line, so only the one-line batch was timed, once. Our VL model (0.8B) is in the same position — 0.95 lines/s on CPU, 12.4 on GPU.
- Batch size is measured, not guessed. Each model reads at its own cheapest batch. A batch that does not fit this card is not timed at all — ko-trocr's 32-line batch is one, and the tool's own reason reads: "앞 묶음 4642MB(가중치 848MB)에서 활성값을 두 배 하면 8437MB 로 카드 8151MB 를 넘는다". A number that was not measured is not written down as if it were.
What is not equal — and which side it favours
| What | How | Favours |
|---|---|---|
| Test fonts | The six and the twenty-four were held out of our training only. What the rivals were trained on is unknown to us | rivals |
| Line cutting | Our cutter was lent to every rival (they all receive one line image) | rivals |
| Length cap | ko-trocr ships max_length: 16 (character tokenizer). Like the two VLMs it was given 64 tokens. Every line where Qwen3-VL hit the cap was a runaway repeat (11 11 11 …); no long answer was cut short (re-reading the 670 font lines on 2026-10-07, all 24 that hit the cap were like that) | ko-trocr |
| Prompt | Qwen3-VL has no fixed OCR prompt; three were tried and the one it reads best with was used | Qwen3-VL |
| Precision | The three VLMs (ours included) run in their released precision (bfloat16), the rest in float32. ko-trocr's float16 speed was also timed | — |
| VLM on CPU | Seconds to tens of seconds per line, so only the one-line batch was timed, once. An 8-line batch might be faster | ko-hand-ocr |
| All 480 fonts | PaddleOCR-VL takes about a second per line, so it was not run on all 480 (blank) | — |
| Design intent | The three line recognisers target mainly printed and scene text, the VLMs whole documents, ko-trocr AI Hub's handwriting and administrative documents. Only this model targets a single handwritten line | ko-hand-ocr |
| Number of models | Our ensemble is three models (one 36M + two 41M, 118M). For a size-matched view read the "single model" row | ko-hand-ocr |
| Training fonts | 450 of the 480 fonts are ones we have seen. That is why the herds are reported apart | ko-hand-ocr |
| Renderer | We trained on synthetic images from the same renderer as the font sheets (synth.render, different fonts). The look of the font sheets is familiar to us — which is why the photos are the cleanest comparison | ko-hand-ocr |
None of this says the rivals are bad models. Models built for printed documents, scene text or whole pages were handed a line of handwriting, and on that job this one does better. The other direction — whole printed documents, tables, scene text — was not measured, and if it were, this model would probably lose.
Measured on: NVIDIA GeForce RTX 5060 Laptop GPU · AMD Ryzen 7 260 · torch 2.14.0+cu130 · 2026-10-09 07:25 — all ten models re-run in one go after the test sheets were fixed on 2026-10-08 (the base Qwen3.5-0.8B, compared only on the VL card, is left out of this table). The photo results match the previous run (2026-10-07) to the character — nothing about the photos changed. Speed rides on the machine's state, so compare only figures taken at the same moment.