HotPDF는 전부 Object Pascal로 작성된 제한형 template-matching OCR engine인 THPDFBuiltInOCREngine을 제공합니다. 이 engine은 Otsu thresholding으로 render된 page를 binarize하고 connected component로 glyph를 추출하며 cache된 여러 font template과의 grayscale coverage를 기준으로 각 glyph를 score합니다. 따라서 Delphi application이 외부 OCR dependency 없이 검색 가능한 text layer를 만들 수 있습니다. 이 engine은 v2.731.0에서 처음부터 다시 만들어야 했고 원인은 matcher가 아니라 pixel이었습니다
예전 engine은 테스트를 통과했습니다. synthetic bitmap에서 uppercase ASCII를 인식했고 Win32에서는 몇 달 동안 계속 그렇게 동작했습니다. 그러다 같은 code를 Win64에서 실행하자 아무것도 만들지 못했습니다. word도 없고 "found no high-contrast foreground"라는 진단 외에는 아무것도 없었으며 crash도 없었습니다. 원인은 pixel-reading path에서 서로 독립적인 두 실수가 우연히 서로를 상쇄하고 있었던 것이었고, 이를 풀어 가는 과정은 OCR code가 왜 큰 소리로 실패하지 않고 조용히 실패하는지 잘 보여 줍니다
예전 OCR engine이 우연히만 작동했던 이유
예전 engine이 작동한 것은 template bitmap과 target bitmap이 같은 방향으로 뒤집혀 있었기 때문이라 pixel reader의 vertical inversion이 matcher에 보이지 않았습니다. TBitmap.ScanLine은 나머지 imaging path가 가정하는 positive-biHeight DIB convention과 반대 순서의 row를 돌려줍니다. M을 거꾸로 render하고 역시 거꾸로 된 template과 비교하면 L1 difference는 올바른 비교와 같습니다. 모든 glyph가 match했습니다. 올바른 것은 아무것도 없었습니다
이 대칭성이 이런 종류의 bug를 비싸게 만듭니다. target read만 고치고 template을 그대로 두면 recognition이 noise로 무너지고 template을 먼저 고쳐도 반대 방향에서 같은 붕괴가 생깁니다. 점진적인 repair path는 없습니다. 따라서 rebuild는 전체 read를 명시적으로 선언한 BITMAPINFOHEADER에 대해 GetDIBits를 수행하도록 바꾸었습니다. 여기서는 positive biHeight가 VCL convention이 아니라 contract에 따라 bottom-up row를 뜻하며 grayscale buffer로 복사할 때 의도적으로 한 번 뒤집습니다
두 번째 실수는 Win64에서만 드러났습니다. GetDIBits에 전달하는 HDC는 bitmap 자체의 memory DC가 아니어야 합니다. bitmap이 이미 그 안에 select되어 있어 Windows 문서상 invalid이기 때문입니다. Bitmap.Canvas.Handle을 넘기는 것은 Win32 process에서는 허용되었지만 Win64 test process에서는 일관되게 실패했습니다. 수정은 어떤 bitmap에도 속하지 않는 임시 screen DC를 GetDC(0)으로 얻고 finally block에서 release하는 것입니다
procedure BitmapToGray(Bitmap: TBitmap; out Gray: TBytes);
var
Work: TBitmap;
Info: TBitmapInfo;
Buffer: TBytes;
DC: HDC;
P: PByte;
Stride, X, Y: Integer;
begin
Work := TBitmap.Create;
try
Work.Assign(Bitmap);
Work.PixelFormat := pf24bit;
Stride := ((Work.Width * 24 + 31) div 32) * 4;
SetLength(Buffer, Stride * Work.Height);
FillChar(Info, SizeOf(Info), 0);
Info.bmiHeader.biSize := SizeOf(BITMAPINFOHEADER);
Info.bmiHeader.biWidth := Work.Width;
Info.bmiHeader.biHeight := Work.Height; // positive => bottom-up rows
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // Work.Canvas.Handle을 사용하지 않습니다: Work가 거기 select됨
if DC = 0 then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
try
if GetDIBits(DC, Work.Handle, 0, Work.Height,
@Buffer[0], Info, DIB_RGB_COLORS) <> Work.Height then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
finally
ReleaseDC(0, DC);
end;
SetLength(Gray, Work.Width * Work.Height);
for Y := 0 to Work.Height - 1 do
begin
P := @Buffer[(Work.Height - 1 - Y) * Stride]; // 의도적인 flip은 한 번뿐입니다
for X := 0 to Work.Width - 1 do
Gray[Y * Work.Width + X] :=
(Integer(P[X * 3]) * 29 + Integer(P[X * 3 + 1]) * 150 +
Integer(P[X * 3 + 2]) * 77) shr 8;
end;
finally
Work.Free;
end;
end;
gray pixel에서 glyph box로: binarization과 connected component
HotPDF는 먼저 Otsu method로 binarize하고 Otsu를 적용할 수 없을 때만 local-window threshold로 fallback합니다. global path는 실제 bimodal histogram을 요구합니다. engine은 between-class variance maximum을 계산하고 gray range가 최소 64 level을 가로질러야만 결과를 신뢰합니다. 색이 빠진 scan, gradient background가 있는 page 또는 거의 전부가 ink인 bitmap은 모두 이 검사를 통과하지 못합니다. 그러면 fallback은 31 by 31 window의 mean과 각 pixel을 비교하고 bias 6 gray level을 적용합니다. sliding window가 pixel 수에 대해 linear하게 유지되도록 running column sum으로 계산합니다
glyph extraction은 결과 mask에 대한 8-connected component labeling이며 deep flood fill이 Delphi thread stack을 쉽게 터뜨릴 수 있으므로 recursion 대신 명시적 stack을 사용합니다. labeling 시점에는 두 filter도 적용합니다. 9 pixel보다 작은 component는 speckle noise로 버리고 image의 width와 height 각각의 3/5보다 크게 뻗는 component는 glyph가 아니라 frame이나 rule로 버립니다. 두 번째 pass는 더 좁은 box의 수평 overlap이 최소 1/4인 수직으로 쌓인 box를 합치며, 이것이 i나 j의 dot과 stem을 다시 결합합니다. 이 모든 과정은 raster 위에서 동작하며 raster는 Delphi에서 loaded PDF page를 bitmap으로 render하는 과정과 같은 renderer에서 나옵니다. 실무적으로 중요한 이유는 OCR quality가 render quality보다 높을 수 없고 기본 text-layer DPI 300이 maximum이 아니라 의도적인 trade-off이기 때문입니다
대문자 I와 소문자 l을 판별할 수 없는 이유
Arial에서는 capital I와 lowercase l이 pixel-identical bar로 rasterize되므로 어떤 shape feature도 둘을 나눌 수 없고 case는 전혀 다른 곳에서 가져와야 합니다. engine은 line-level height clustering으로 답합니다. glyph box를 vertical overlap으로 text line에 묶고 각 line의 cap height와 modal baseline을 분석한 다음 line 안의 height를 short cluster와 tall cluster로 나눕니다. short cluster에 있는 bar는 l이고 tall cluster에 같은 bar가 있으면 I입니다
이 분할을 구현하는 가장 뻔한 방법은 고정 ratio threshold인데 작동하지 않습니다. Arial의 x-height 대 cap-height ratio는 약 0.72라서 모두가 처음 시도하는 0.70과 0.75 값의 한가운데에 놓입니다. constant를 어느 방향으로든 0.01 움직이면 전체 corpus의 case가 뒤집힙니다. HotPDF는 대신 1차원 k=2 variance-minimizing split을 수행합니다. candidate height를 정렬하고 모든 cut point를 시도한 뒤 within-cluster sum of squared deviations가 가장 작은 cut을 유지합니다. threshold가 source의 constant가 아니라 page의 속성이 됩니다
// ClusterHeights는 오름차순으로 정렬되어 있습니다. variance가 가장 작은 k=2 split을 찾습니다
BestSplit := 1;
BestVariance := 1E18;
for I := 1 to ClusterCount - 1 do
begin
SumA := 0;
for J := 0 to I - 1 do SumA := SumA + ClusterHeights[J];
SumB := 0;
for J := I to ClusterCount - 1 do SumB := SumB + ClusterHeights[J];
MeanA := SumA / I;
MeanB := SumB / (ClusterCount - I);
Variance := 0;
for J := 0 to I - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanA);
for J := I to ClusterCount - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanB);
if Variance < BestVariance then
begin
BestVariance := Variance;
BestSplit := I;
end;
end;
// 두 cluster mean 사이의 ratio만 short band인지 결정합니다
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // 실제 x-height band: lowercase shape
else
SmallGroup := ggTall; // 하나의 height band: 모두 cap height
Line.LowercaseContext := (SmallGroup = ggSmall);
height band가 하나뿐인 line은 내부 evidence를 전혀 갖지 않습니다. all-caps heading과 all-lowercase caption은 단독으로 보면 같습니다. 그런 경우 HotPDF는 split된 line에서 얻은 page-level median x-height와 line의 median height를 비교합니다. 1.10 이하의 ratio는 lowercase context, 1.18 이상은 cap context로 표시하고 그 사이는 unconstrained로 둡니다. 그 다음 matching은 context에 맞는 candidate 쪽에 0.03의 작은 case-preference bonus를 적용해 tie를 밀어 주지만 명확한 shape difference를 무시하지는 않습니다
12x18 template grid가 c와 o를 혼동한 이유
template grid는 12 by 18 cell에서 16 by 24로 넓어졌습니다. 작은 resolution에서는 c와 o 사이 grayscale coverage margin이 0.007 아래로 떨어져 engine의 ambiguity threshold 안에 들어왔기 때문입니다. 각 glyph box는 binary stencil이 아니라 0부터 255까지의 coverage value로 grid에 resample됩니다. 따라서 1/3이 ink인 cell은 black이나 white로 round되지 않고 대략 85로 읽힙니다. 12 by 18에서는 c의 열린 쪽이 한 cell column을 간신히 넘고 antialiased average가 gap을 씻어 냅니다. 16 by 24에서는 resampling 뒤에도 gap이 남으며 쉽게 혼동되는 pair 대부분이 다시 안전한 거리로 이동합니다
score는 두 coverage grid의 normalized L1 distance에 aspect-ratio difference의 log에 0.30을 곱한 penalty와 ink-density difference의 0.16 배를 더한 값이며 aspect ratio가 2.6배를 넘게 다른 template은 hard prefilter로 건너뜁니다. template은 다섯 system font(Arial, Times New Roman, Courier New, Tahoma, Segoe UI)에서 62-character alphabet 전체에 대해 process마다 한 번 rasterize되고 critical section 뒤에 cache되어 이후 call마다 재사용됩니다
마지막 constant가 흥미로운 부분입니다. runner-up character의 score가 winner와 0.018 이내면 HotPDF가 glyph confidence를 0.5로 clamp하고 이는 0.55 acceptance gate보다 낮으므로 glyph를 아예 내보내지 않습니다. 이것은 tuning artifact가 아니라 의도적인 fail-closed cut입니다. 제한형 engine이 추측하면 image와 text가 일치하지 않는 searchable layer를 만들고 scan을 검토하는 사람이 보지 못하는 잘못된 word가 없는 word보다 나쁘기 때문입니다
고정 gap threshold 없이 word 나누기
HotPDF는 고정된 평균 glyph width 배수가 아니라 inter-glyph gap 분포에서 line별 word-space threshold를 계산합니다. "평균 advance의 0.75보다 넓은 gap은 space"라는 고전적 heuristic은 line에 digit과 narrow letter가 섞이는 순간 깨집니다. 평균 advance가 더 이상 실제 어떤 것도 설명하지 못하기 때문입니다. 대신 engine은 line의 gap을 정렬하고 연속된 값 사이의 가장 큰 jump를 찾습니다. 하나가 존재한다면 intra-word cluster와 inter-word cluster 사이의 경계입니다. 세 guard가 noise 위에서 이 규칙이 발동하지 않도록 합니다. jump는 average glyph width의 최소 0.22여야 하고 split보다 큰 첫 gap은 그 값의 최소 0.32여야 하며 split보다 작은 마지막 gap은 0.65를 넘으면 안 됩니다. 어느 guard라도 실패하면 threshold는 MaxInt로 남고 line 전체가 하나의 word가 됩니다. 마지막 guard가 단일한 넓은 kerning pair 때문에 word를 둘로 나누는 일을 막습니다. 두 word를 합치는 것보다 훨씬 큰 오류이며 합쳐진 token도 substring search에 필요한 올바른 문자와 순서를 여전히 담기 때문입니다
스캔 이미지 위에 보이지 않는 text layer 쓰기
ApplyLoadedOCRTextLayer는 recognized word를 text rendering mode 3으로 그려 searchable layer로 바꿉니다. 이는 ISO 32000-1 §9.3.6에 정의된 neither-fill-nor-stroke mode이며 word가 나온 scanned image 위에 배치됩니다. content stream은 BT와 3 Tr로 시작하고 각 word는 reported baseline, request DPI에서 pixel로 변환한 cap height와 측정된 word width에 맞게 synthetic glyph run을 늘리는 horizontal scale로 만든 text matrix를 사용해 배치됩니다. 결과는 text처럼 copy와 search가 되지만 아무것도 paint하지 않습니다
built-in path의 대부분 caller가 사용해야 하는 engine-free overload도 있으며 이것은 built-in recognizer를 자동으로 instantiate합니다. recognition, Unicode validation, budget accounting과 content construction은 copy-on-write transaction이 열리기 전에 모두 끝나므로 cancellation, budget overrun 또는 engine failure가 object graph와 version number를 건드리지 않습니다. word는 두 번 filter됩니다. engine이 자체 0.55 per-glyph confidence gate 아래의 값을 버리고 그 다음 THPDFOCRTextLayerOptions.MinimumConfidence (기본값 0.5)가 caller가 정한 기준 아래의 전체 word를 버립니다
var
Doc: THotPDF;
Options: THPDFOCRTextLayerOptions;
Info: THPDFOCRTextLayerInfo;
begin
Doc := THotPDF.Create(nil);
try
Doc.AutoLaunch := False;
if Doc.LoadFromFile('scan.pdf') < 1 then
Exit;
Options := THPDFOCRTextLayerOptions.Default; // DPI 300, MinimumConfidence 0.5
Options.SkipPagesWithText := True; // 이미 digital-born인 page는 그대로 둡니다
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// engine-free overload: HotPDF가 내장된 제한형 recognizer를 제공합니다
if Doc.ApplyLoadedOCRTextLayer([0], Options, Info) then
begin
Writeln(Info.AcceptedWordCount, ' words accepted by ',
string(Info.EngineName));
Doc.SaveLoadedDocument('scan-searchable.pdf');
end
else
Writeln('No text layer written: ', string(Info.Diagnostic));
finally
Doc.Free;
end;
end;
나중에 발견하기보다 지금 분명히 말해 둘 제한이 하나 있습니다. invisible layer는 공유되는 synthetic non-embedded Type0 font를 사용하며 모든 viewer에서 search와 copy에는 충분하지만 ISO 19005의 font-embedding requirement를 충족하지 않습니다. output이 PDF/A여야 한다면 caller가 conforming font를 별도로 embed해야 합니다. 또한 OCR text layer는 geometry만 담으므로 reading order는 glyph position만으로 결정됩니다. 이미 real text가 있는 page에서 logical order가 필요하다면 tag tree가 주도하는 structure-order text extraction이라는 다른 문제를 위한 다른 도구가 필요합니다
내장 engine이 멈추는 지점
built-in engine은 의도적으로 좁으며 그 경계를 알아야 유용하게 쓸 수 있습니다. 다섯 template face와 가까운 font의 high-contrast machine-printed ASCII를 대상으로 하고 그 밖의 것은 추측 대신 no word를 반환합니다. 구체적인 경계는 다음과 같습니다:
- 최대 4096 x 4096 및 4,194,304 pixel의 image, 2000 ms recognition deadline과
THPDFCancellationToken을 통한 cooperative cancellation - ASCII letter와 digit로 구성된 62-character alphabet이며 punctuation, accented character와 CJK는 지원하지 않음
- renderer가 이미 정규화한 page rotation에서 axis-aligned text만 지원하며 skewed scan은 deskew하지 않음
- ambiguous glyph pair는 resolve되지 않으므로 page가 partial word 또는 "found no unambiguous ASCII words" diagnostic을 반환할 수 있음
이 envelope가 너무 작다면 IHPDFOCREngine이 seam입니다. 자신의 engine으로 Recognize를 구현해 세 인자를 받는 ApplyLoadedOCRTextLayer overload에 넘기면 그 이후의 모든 단계(coordinate mapping, rotation handling, Unicode validation, budget, atomic commit)는 그대로 유지됩니다. bitmap은 synchronous call 동안 빌려 쓰는 것이므로 보관해서는 안 됩니다. layer가 올바르게 들어갔는지 확인하려면 저장한 file을 reload하고 Delphi에서 loaded PDF의 text를 추출하는 일반 경로를 실행하세요. word가 돌아오면 layer는 실제로 존재합니다
built-in template-matching OCR, invisible text layer, 이를 공급하는 page renderer와 이를 검증하는 loaded-document text extraction은 모두 같은 native VCL component에 들어 있으며 외부 OCR runtime이나 application과 함께 배포할 DLL이 필요 없습니다. Delphi나 C++Builder에서 scanned PDF의 document capture, archival 또는 search를 만들고 있다면 HotPDF Delphi PDF component가 전체 pipeline을 하나의 dependency로 제공합니다