Техническая статья

Декодирование повёрнутых QR-кодов в PDF-страницах с HotPDF

HotPDF декодирует повёрнутые QR-символы на загруженной PDF-странице, нормализуя сэмплированную матрицу модулей по всем восьми ориентациям D4 внутри самого декодера. Внешний retry с вращением, работающий для линейных символик, для QR работать не может, и понимание почему экономит день охоты за декодером, который выглядит сломанным, но не сломан

Сценарий вполне заурядный. Отсканированные накладные приходят в PDF, на каждой странице QR-наклейка, а оператор сканера закладывал стопку листов в ту сторону, куда лоток согласился принимать. Часть наклеек стоит прямо, часть повёрнута на четверть оборота, несколько — вверх ногами. Вы вызываете декодер штрихкодов, половина страниц разрешается, а вторая половина возвращается пустой и без единой ошибки

Почему вращение маски сканирования никогда не чинит повёрнутый QR?

Потому что раскладка finder-паттернов QR намеренно асимметрична, и вращение целого изображения сохраняет эту асимметрию, а не убирает её. QR Code кладёт три finder-квадрата в верхний левый, верхний правый и нижний левый углы, оставляя нижний правый угол пустым (ISO/IEC 18004:2015 §6.3.3). Этот отсутствующий угол и есть подсказка об ориентации. Поверните битмап страницы на девяносто градусов — и пустота просто переедет в другой угол. Нет нетривиального вращения плоскости, которое отобразило бы трёхугловую раскладку на себя, поэтому декодер, принимающий только каноничное расположение, отвергнет каждую попытку по очереди

Это важно, потому что очевидный фикс — неправильный. Естественный инстинкт — повесить retry снаружи: отрендерить страницу, отдать маску декодеру, а если неудача — повернуть маску и попробовать снова на 90, 180 и 270 градусов. Для Code 39 такая политика ровно правильна, потому что у линейной символики есть start- и stop-паттерны, которые сканер найдёт, как только штрихи пойдут горизонтально. Для QR это четыре гарантированных провала, за которыми следует отчёт «ничего не найдено»

Группа D4, применённая к матрице модулей

Правильное место нормализации — после сэмплирования, на булевой сетке модулей, а не на пиксельной маске. Когда декодер уже свёл символ к матрице n на n из тёмных и светлых модулей, он может перечислить диэдральную группу квадрата: четыре вращения на два отражения, восемь кандидатов-ориентаций в сумме. Для каждого кандидата он проверяет finder-треугольник, и первый кандидат, чьи три finder'а легли в верхний левый, верхний правый и нижний левый позиции, — истинная ориентация. Дальше существующий пайплайн идёт без изменений, потому что биты format information, зигзаг-размещение данных и коррекция Рида-Соломона все предполагают каноничную матрицу и теперь её получают

Четыре отрисовки одной и той же матрицы модулей QR в HotPDF под вращениями группы D4 на 0, 90, 180 и 270 градусов: три finder-паттерна мигрируют по углам, пустой угол движется вместе с ними, и только каноническая ориентация предъявляет декодеру finder'ы в верхнем левом, верхнем правом и нижнем левом углах
Вращение пиксельной маски не может убрать асимметрию QR-finder'ов, поэтому HotPDF перечисляет ориентации D4 на сэмплированной матрице модулей и берёт первого кандидата, чьи finder'ы легли в верхний левый, верхний правый и нижний левый углы

Два свойства делают это дешёвым. Матрица мала по сравнению с отрендеренным битмапом, поэтому восемь транспонирований стоят куда меньше восьми рендеров страницы. И матрица — чистый булев массив, построенный сэмплером, так что никакой трансформ по пути не введёт значения, которые никогда не сэмплировались

Определение версии — поиск по делимости, а не деление

Число модулей нельзя вывести делением сэмплированной ширины на предполагаемый размер модуля, и ошибка здесь — тонкий источник провалов декодирования на рендерах высокого разрешения. QR-символ версии v — это 4v + 17 модулей в поперечнике, то есть версия 1 — 21 модуль, версия 40 — 177. Маска шириной 126 пикселей одинаково согласуется с версией 1 при шести пикселях на модуль и с несколькими старшими версиями при меньших размерах модуля. Линейное деление выберет одну из них и обычно ошибётся

Работает поиск по делимости среди кандидатов-версий. Идите от версии 40 вниз до версии 1, оставляйте кандидатов, чьё число модулей делит сэмплированную ширину нацело и оставляет хотя бы три пикселя на модуль, и берите наименьшую выжившую версию. Пол в три пикселя — то, что не даёт поиску принять абсурдно плотное прочтение грубого символа, а правило наименьшей версии снимает оставшуюся двусмысленность в пользу прочтения, которое реально выдал бы сканер

Обход определения версии в HotPDF для QR-символа на сэмплированной маске в 126 пикселей: каждое число модулей-кандидат 4v плюс 17 проверяется от версии 40 вниз до версии 1 на нацельную делимость и пол модуля в три пикселя, прежде чем победит наименьшая выжившая версия
Число модулей QR приходит из поиска по делимости среди кандидатов-версий, а не из деления ширины маски на предполагаемый размер модуля, и наименьшая выжившая версия снимает двусмысленность
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 возвращает заполненную запись, а не обнулённую, и это важно, потому что DPI ноль или лимит результатов ноль — выглядящий валидным способ не получить ничего. RotationPolicy управляет только внешним retry: bdrpNone рендерит один раз, bdrpFallback ретраит остальные ориентации после провала первого прохода, а bdrpAll рендерит каждую ориентацию безусловно. Поскольку нормализация QR происходит внутри декодера, QR-страницы разрешаются с первой попытки при любой из трёх политик. Политика нужна линейным символикам, которым она реально необходима

Как доказать, что битмап-трансформ не выдумывает пиксели?

Посчитайте чернила по обе стороны и потребуйте совпадения сумм. Вращение — перестановка пикселей, и ничего больше, поэтому число ненулевых ячеек на выходе обязано равняться числу на входе. Когда вращение маски во внешнем retry-пути отрапортило 4800 установленных ячеек на входе и 7439 на выходе, одной этой сверки хватило, чтобы осудить трансформ, не читая ни строчки его геометрии

Причина оказалась будничной, и её стоит унести как правило. Динамический массив, размеченный SetLength, не гарантированно приходит обнулённым, когда он результат функции, идущий путём, который рантайм не чистит, и ячейки, которые вращение никогда не записывало, несут те байты, что лежали там раньше. Часть этих протухших байтов ненулевая, а ненулевое означает чернила. Фикс — одна строка, FillChar(Result[0], N, 0) до запуска цикла перестановки, а дисциплина шире: любая функция, возвращающая буфер маски или битмапа, должна явно чистить вывод, а не полагаться на семантику аллокации

Что позволило дефекту пережить три релиза, интереснее самого дефекта. Когда QR перенёс обработку ориентации в декодер, QR перестал вообще упражнять внешнее вращение маски, и единственным оставшимся потребителем этого пути кода остался Code 39. Общая инфраструктура постоянно прячет такие баги: покрытие от одной фичи делает путь выглядящим протестированным, тогда как фича, реально зависящая от него, не имеет своего. Каждый путь, который новая фича перестала использовать, нуждается в тесте, который всё ещё его использует

Чтение результатов обратно в координатах страницы

Каждое геометрическое значение, которое выдаёт декодер, выражено в системе координат attempt-битмапа, а вызывающему оно нужно в user space PDF. Конверсия идёт в два этапа: отменить четверть оборота, которую применил retry, затем отменить рендер-трансформ, отобразивший user space на битмап. То, что приходит в THPDFDecodedBarcode, — axis-aligned bounding box в user space, с Left, Bottom, Right и Top по PDF-конвенции, где Y растёт вверх, плюс counter-clockwise OrientationDegrees

Пайплайн штрихкодов HotPDF от отрендеренного битмапа страницы через сэмплирование в булеву матрицу модулей, нормализацию D4, определение версии по делимости и декодирование Рида-Соломона, затем двухэтапная конверсия координат, отменяющая четверть оборота retry и рендер-трансформ, прежде чем THPDFDecodedBarcode опубликует Left, Bottom, Right, Top и OrientationDegrees в user space
Нормализация QR внутри декодера даёт страницам разрешаться с первой попытки, а двухэтапная конверсия координат превращает результаты attempt-битмапа в axis-aligned боксы user space

Перепутаете направление второй конверсии — симптом мерзкий: текст декодируется идеально, но бокс, который вы рисуете для оверлея ревью, падает на зеркальное отражение правильной позиции. Кому строит интерфейс ревью поверх декодера, стоит проверить себя фикстурой с известным ответом: символ положен намеренно близко к углу страницы, чтобы перевёрнутая ось Y была видна с одного взгляда. Та же логика приложима к любой координате, пересекающей границу рендеринга, поэтому рендер страницы PDF в битмап в Delphi стоит понять, прежде чем строить поверх декодера

Что встроенный декодер умеет, а что нет

Встроенный декодер — ограниченная реализация без зависимостей, и она честна о своих пределах, а не деградирует тихо. Она распознаёт 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 говорят, запускался ли вообще внешний retry, ReceivedResultCount против AcceptedResultCount разделяет декодер, не нашедший ничего, от порога уверенности, отвергнувшего всё найденное, а RenderedPixels с PeakWorkingBytes — то, что вы рисуете графиком, когда батч-задача начинает трэшить. Пустой набор результатов плюс bdsSucceeded означает, что на странице реально нет читаемого символа, — операционный факт иного рода, чем bdsBudgetExceeded

Бюджетные поля заслуживают осознанного решения, а не дефолта. MaxPixels и MaxWorkingBytes существуют потому, что DPI умножается квадратично: переход с 300 на 600 DPI на странице A4 учетверяет и стоимость рендера, и пиковую аллокацию, а недоверенный ввод, объявляющий огромный page box, может превратить задачу сканирования в инцидент с out-of-memory. Выставьте лимиты под худший легитимный документ, а выбросы пусть bdsBudgetExceeded маршрутизирует в более медленный изолированный путь

Если ваши документы мешают машиночитаемые наклейки с печатным текстом, который вы собираетесь индексировать, декодер штрихкодов естественно дружит с движком распознавания из template-matching OCR внутри HotPDF, а генеративная сторона той же истории — в рисовании штрихкодов в PDF с HotPDF. Оба работают на одной инфраструктуре рендеринга и бюджетов, поэтому пайплайн, уже выставивший разумные лимиты для одного, получает второй почти бесплатно

Толерантность к вращению — из тех фич, которые невидимы, когда работают, и бесят, когда нет, и инженерный урок обобщается за пределы QR: нормализуйте настолько близко к семантическому представлению, насколько сумеете добраться, а не на пиксельном слое, где данные всё ещё несут все случайности того, как их сняли. HotPDF поставляет это в составе Delphi PDF-компонента HotPDF рядом с рендерингом, OCR и анализом страниц, которые intake-пайплайнам обычно тоже нужны