HotPDF는 로드된 PDF 페이지에서 회전된 QR 심볼을, 샘플링한 모듈 행렬을 디코더 내부에서 D4의 여덟 방향 모두로 정규화해 읽어 들입니다. 선형 심볼로지에 통하는 바깥 회전 재시도는 QR에는 통할 수 없고, 그 이유를 이해하면 망가진 것 같지만 망가지지 않은 디코더를 하루 종일 쫓는 일을 피할 수 있습니다
시나리오는 흔합니다. 스캔된 배송지가 PDF로 도착하고, 각 페이지가 QR 라벨을 실으며, 스캔 담당자는 트레이가 받아들이는 아무 방향으로나 종이 더미를 밀어 넣었습니다. 어떤 라벨은 똑바로, 어떤 것은 90도 비켜 있고, 몇 개는 거꾸로입니다. 바코드 디코더를 호출하면 절반의 페이지는 풀리고 나머지 절반은 에러 하나 없이 빈 결과로 돌아옵니다
스캔 마스크를 돌리는 것은 왜 회전된 QR을 고치지 못하는가?
QR 파인더 패턴 배치는 의도적으로 비대칭이고, 이미지 전체 회전은 그 비대칭을 제거하는 게 아니라 보존하기 때문입니다. QR Code는 세 개의 파인더 사각형을 좌상단, 우상단, 좌하단 모서리에 놓고 우하단 모서리를 비워 둡니다(ISO/IEC 18004:2015 §6.3.3). 그 빠진 모서리가 방향 단서입니다. 페이지 비트맵을 90도 돌리면 그 빈자리는 그저 다른 모서리로 이동할 뿐입니다. 삼 모서리 배치를 자기 자신에게 사상하는 자명하지 않은 평면 회전은 존재하지 않으므로, 정준 배치만 받아들이는 디코더는 모든 시도를 차례로 거부합니다
당연해 보이는 수정이 틀린 수정이므로 이 얘기가 중요합니다. 자연스러운 직관은 재시도를 바깥에 거는 것입니다. 페이지를 렌더링하고 마스크를 디코더에 넘기고, 실패하면 마스크를 돌려 90, 180, 270도로 다시 시도하는 것이죠. Code 39라면 이 정책이 정확히 옳습니다. 선형 심볼로지는 막대가 수평으로 놓이는 순간 스캐너가 찾을 수 있는 시작/정지 패턴을 갖고 있으니까요. QR에는 네 번의 보장된 실패 뒤에 아무것도 발견하지 못했다는 보고가 따라옵니다
모듈 행렬에 적용하는 D4 군
정규화의 올바른 자리는 샘플링 뒤, 픽셀 마스크가 아니라 불리언 모듈 그리드 위입니다. 디코더가 심볼을 어두운 모듈과 밝은 모듈의 nxn 행렬로 해석하고 나면 정사각형의 이면체군(dihedral group), 즉 네 회전 곱하기 두 반사, 총 여덟 후보 방향을 열거할 수 있습니다. 각 후보마다 파인더 삼각형을 검사하고, 세 파인더가 좌상단, 우상단, 좌하단 위치에 떨어지는 첫 번째 후보가 진짜 방향입니다. 거기서부터는 기존 파이프라인이 그대로 돌아갑니다. 포맷 정보 비트와 지그재그 데이터 배치와 Reed-Solomon 정정은 모두 정준 행렬을 가정하고, 이제 그것을 받기 때문입니다
이것을 값싸게 만드는 특성이 두 가지입니다. 행렬은 렌더링된 비트맵에 비해 작아서 여덟 번의 전치가 여덟 번의 페이지 렌더링보다 훨씬 쌉니다. 그리고 행렬은 샘플러가 만든 깨끗한 불리언 배열이라 경로 어디의 변환도 샘플링된 적 없는 값을 도입할 수 없습니다
버전 판별은 나눗셈이 아니라 나누어떨어짐 검색
모듈 수는 샘플링한 폭을 가정한 모듈 크기로 나누어 도출할 수 없고, 이것을 잘못 다루는 것이 고해상도 렌더링에서 디코드 실패의 미묘한 원천입니다. 버전 v의 QR 심볼은 한 변이 4v + 17 모듈이므로 버전 1은 21 모듈, 버전 40은 177입니다. 126픽셀 폭의 마스크는 모듈당 6픽셀의 버전 1과 더 작은 모듈 크기의 여러 상위 버전에 똑같이 부합합니다. 선형 나눗셈은 그중 하나를 고르며 대개 틀립니다
동작하는 것은 후보 버전들에 대한 나누어떨어짐 검색입니다. 버전 40에서 버전 1로 내려가며, 모듈 수가 샘플링 폭을 남김없이 나누고 모듈당 최소 3픽셀을 남기는 후보들을 걸러 내고, 살아남은 가장 작은 버전을 취하세요. 3픽셀 하한이 검색이 조잡한 심볼의 터무니없이 조밀한 해석을 받아들이는 것을 막고, 가장 작은 버전 규칙이 남은 모호성을 스캐너가 실제로 내놓을 해석 쪽으로 풀어 줍니다
var
Pdf: THotPDF;
Options: THPDFBarcodeDecodeOptions;
Codes: THPDFDecodedBarcodes;
Info: THPDFBarcodeDecodeInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('delivery-notes.pdf');
Options := THPDFBarcodeDecodeOptions.Default;
Options.DPI := 300;
Options.RotationPolicy := bdrpFallback;
Options.MinimumConfidence := 0.5;
Options.MaxResults := 16;
if Pdf.DecodeLoadedPageBarcodes(0, Options, Codes, Info) then
for I := 0 to High(Codes) do
if Codes[I].Symbology = bsyQRCode then
Writeln(Codes[I].Text, ' at ',
Format('%.0f', [Codes[I].OrientationDegrees]), ' degrees');
finally
Pdf.Free;
end;
end;
THPDFBarcodeDecodeOptions.Default은 0으로 채워진 레코드가 아니라 값이 채워진 레코드를 돌려주는데, 이것이 중요한 이유는 DPI 0이나 결과 상한 0이 아무것도 돌려받지 않는 그럴듯해 보이는 방법이기 때문입니다. RotationPolicy는 바깥 재시도만 제어합니다. bdrpNone은 한 번 렌더링하고, bdrpFallback은 첫 패스가 실패한 뒤 다른 방향들을 재시도하며, bdrpAll은 모든 방향을 무조건 렌더링합니다. QR 정규화는 디코더 안에서 일어나므로 QR 페이지는 세 정책 모두에서 첫 시도에 풀립니다. 정책은 진짜로 필요한 선형 심볼로지들을 위한 것입니다
비트맵 변환이 픽셀을 지어내지 않는다는 것을 어떻게 증명하는가?
양쪽의 잉크를 세고 합계가 일치하도록 요구하세요. 회전은 픽셀의 재배열(permutation) 그 이상도 이하도 아니므로, 출력의 0이 아닌 셀 수는 입력의 그것과 같아야 합니다. 바깥 재시도 경로의 마스크 회전이 들어갈 때 4800개의 설정된 셀을 보고하고 나올 때 7439개를 보고했다면, 그 한 번의 비교만으로 기하 코드를 한 줄도 읽지 않고 변환을 유죄로 만들기 충분합니다
원인은 시시했고 규칙으로 가질 가치가 있습니다. SetLength로 크기를 정한 동적 배열은 런타임이 클리어하지 않는 경로를 여행하는 함수 결과일 때 0으로 초기화되어 도착한다는 보장이 없고, 회전이 쓰지 않은 셀들은 그전에 그 자리에 있던 바이트를 실은 채 남습니다. 그 오래된 바이트 중 일부는 0이 아니고, 0이 아니라는 것은 잉크라는 뜻입니다. 수정은 한 줄입니다. 치환 루프가 돌기 전에 FillChar(Result[0], N, 0). 그것이 함축하는 규율은 더 넓습니다. 마스크나 비트맵 버퍼를 돌려주는 함수는 할당 의미론에 기대지 말고 출력을 명시적으로 클리어해야 합니다
결함이 세 릴리스를 살아남게 만든 것은 결함 자체보다 흥미롭습니다. QR이 방향 처리를 디코더 안으로 옮긴 뒤 QR은 바깥 마스크 회전을 전혀 거치지 않게 됐고, 그 코드 경로의 유일한 남은 소비자는 Code 39였습니다. 공유 인프라는 이런 버그를 늘 숨깁니다. 한 기능의 커버리지가 경로를 테스트된 것처럼 보이게 하는 동안 실제로 그것에 의존하는 기능은 자기 커버리지가 하나도 없는 것이죠. 새 기능이 쓰기를 그만둔 모든 경로에는 여전히 그것을 쓰는 테스트가 필요합니다
결과를 페이지 좌표로 읽어 들이기
디코더가 만드는 모든 기하 값은 시도 비트맵의 좌표 프레임으로 표현되고, 호출자는 그것을 PDF 사용자 공간으로 필요로 합니다. 그 변환은 두 단계로 돌아갑니다. 재시도가 적용한 90도 회전을 되돌린 뒤, 사용자 공간을 비트맵에 사상한 렌더 변환을 되돌립니다. THPDFDecodedBarcode에 도착하는 것은 사용자 공간의 축 정렬 바운딩 박스로, Left, Bottom, Right, Top은 Y가 위로 증가하는 PDF 관습을 따르고, 반시계 방향의 OrientationDegrees가 딸려 옵니다
두 번째 변환의 방향을 틀리면 증상이 악질입니다. 텍스트는 완벽하게 디코드되는데 리뷰 오버레이를 위해 그리는 박스가 올바른 위치의 거울상에 떨어집니다. 디코더 위에 리뷰 인터페이스를 짓는 사람이라면 알려진 픽스처(fixture)에 대해 단언해야 합니다. 심볼을 일부러 한 페이지 모서리 근처에 놓아 뒤집힌 Y축이 한눈에 보이게 하세요. 같은 논리가 렌더링 경계를 넘는 모든 좌표에 적용되며, 그래서 디코더 위에 뭔가를 짓기 전에 Delphi에서 PDF 페이지를 비트맵으로 렌더링하기를 이해할 가치가 있습니다
내장 디코더가 하는 일과 하지 않는 일
내장 디코더는 경계가 있고 의존성 없는 구현이며, 조용히 열화하는 대신 한계에 정직합니다. Code 39와 QR을 인식하고, 데이터를 공표하기 전에 BCH로 보호되는 포맷 비트와 마스크 패턴을 검증하며, 손상된 심볼에 오류 복구를 시도하지 않습니다. 입력이 고르지 않은 빛 아래 구부러진 라벨의 사진이라면 그것은 다른 문제 부류이고 전문 엔진이 필요합니다
// 자기 엔진으로 교체: IHPDFBarcodeDecoder를 구현해 디코더 인식
// 오버로드에 넘깁니다. 페이지 렌더링, 예산, 좌표 매핑, 중복 제거는
// 여전히 HotPDF가 소유합니다
if not Pdf.DecodeLoadedPageBarcodes(PageIndex, MyDecoder, Options,
Codes, Info) then
case Info.Status of
bdsBudgetExceeded:
Log('raise MaxPixels or lower DPI: ' + string(Info.Diagnostic));
bdsRenderError:
Log('page did not render: ' + string(Info.Diagnostic));
bdsDecoderError:
Log(string(Info.DecoderName) + ' failed: ' + string(Info.Diagnostic));
end;
THPDFBarcodeDecodeInfo는 프로덕션 파이프라인이 진가를 발휘하는 자리입니다. RotationAttemptCount와 DecoderCallCount는 바깥 재시도가 애초에 돌았는지를 말해 주고, AcceptedResultCount에 대한 ReceivedResultCount는 아무것도 찾지 못한 디코더와 찾은 것을 전부 신뢰도 임계가 거부한 경우를 가르며, RenderedPixels와 PeakWorkingBytes는 배치 작업이 스래싱하기 시작할 때 그래프로 그릴 지표입니다. 빈 결과 집합에 bdsSucceeded라는 것은 페이지에 정말로 읽을 수 있는 심볼이 없다는 뜻이며, bdsBudgetExceeded와는 다른 운영적 사실입니다
예산 필드는 기본값에 맡기기보다 의도적 결정을 받을 자격이 있습니다. MaxPixels와 MaxWorkingBytes가 존재하는 이유는 DPI가 제곱으로 곱해지기 때문입니다. A4 페이지에서 300 DPI에서 600 DPI로 가면 렌더 비용과 최대 할당이 둘 다 네 배가 되고, 어마어마한 페이지 박스를 선언하는 신뢰할 수 없는 입력은 스캔 작업을 메모리 부족 사고로 바꿀 수 있습니다. 캡은 가장 무리한 정상 문서가 필요로 하는 수준으로 설정한 뒤, bdsBudgetExceeded로 이단자들을 더 느리고 격리된 경로로 라우팅하세요
문서가 인덱싱할 계획인 인쇄 텍스트와 기계 판독 라벨을 섞고 있다면, 바코드 디코더는 HotPDF 안의 템플릿 매칭 OCR이 다루는 인식 엔진과 자연스럽게 짝이 되고, 같은 이야기의 생성 쪽은 HotPDF로 PDF에 바코드 그리기에 있습니다. 둘 다 같은 렌더링 및 예산 인프라 위에서 돌므로, 하나에 상식적인 한계를 이미 설정해 둔 파이프라인은 다른 쪽을 거의 공짜로 얻습니다
회전 내성은 동작할 때는 보이지 않고 실패할 때는 화나는 기능 부류입니다. 그리고 그 엔지니어링 교훈은 QR을 넘어 일반화됩니다. 데이터가 캡처된 모든 우연을 아직 실고 있는 픽셀 계층이 아니라, 도달할 수 있는 한 의미 표현에 가까운 곳에서 정규화하세요. HotPDF는 이것을 HotPDF Delphi PDF 컴포넌트의 일부로, 같은 인테이크 파이프라인이 보통 필요로 하는 렌더링, OCR, 페이지 분석 조각들과 함께 배송합니다