javascript

Надпись «Откройте камерой» мешала прочитать QR-код

  • четверг, 10 сентября 2026 г. в 00:00:06
https://habr.com/ru/articles/1080454/

Когда QR не читается, хочется первым делом увеличить картинку. У меня нашёлся файл, для которого полезнее было сделать наоборот.

В ширину 640 пикселей он не читался. В ширину 320 читался. SVG один и тот же, контакт внутри тот же. В четыре раза больше пикселей, ноль прочитанных контактов.

Выглядел файл вполне прилично: QR, белое поле, надпись «ОТКРОЙТЕ КАМЕРОЙ». Подсказка для человека. Как выяснилось, программа тоже обратила на неё внимание. Зря.

Растры из эксперимента показаны с сохранением размерного соотношения. Результаты относятся к исходным файлам, а не к картинкам после масштабирования в статье.
Растры из эксперимента показаны с сохранением размерного соотношения. Результаты относятся к исходным файлам, а не к картинкам после масштабирования в статье.

Баг в экспорте уже исправлен. Опыты ниже я провёл для статьи, чтобы разобраться, почему сработало исправление. Данные контакта учебные. Chromium 153.0.8010.36 растеризует SVG, jsQR 1.4.0 получает пиксели. Камеру телефона и печать я здесь не проверял.

Файл прочитался. Только не у всех

Для начала я передал исходный растр второму декодеру, @zxing/library 0.21.3. Он вернул ожидаемый контакт и при 640 пикселях.

Версия с испорченными данными стала менее убедительной. Теперь нужно было выяснить, на каком этапе jsQR теряет этот код.

У jsQR на входе массив RGBA, на выходе результат или null. Между ними несколько этапов: превратить пиксели в чёрное и белое, найти код, извлечь его матрицу, прочитать содержимое. Один null не говорит, какой из этих шагов сорвался.

Я начал с поиска кода. Прежде чем читать квадратики, программа должна решить, какие квадратики вообще относятся к QR.

Третий угол нашёлся в инструкции

В трёх углах QR видны большие вложенные квадраты. Это ориентиры для распознавателя. По их положению он может определить, как расположен код, даже если картинка снята под углом.

Для человека на нашем изображении всё очевидно. Вот три квадрата, вот подпись снизу. Для программы там нет отдельного слоя «подпись для человека»: есть только пиксели.

Я вывел координаты, которые выбрал локатор jsQR, и нанёс их на исходную картинку.

Линии соединяют выбранные ориентиры, это не точная граница извлекаемой области. Наложения добавлены после распознавания и не подавались декодеру.
Линии соединяют выбранные ориентиры, это не точная граница извлекаемой области. Наложения добавлены после распознавания и не подавались декодеру.

Две точки попали в настоящие ориентиры: примерно (525, 137) и (115, 546.5). Третья оказалась в (386, 638.5), в надписи «ОТКРОЙТЕ КАМЕРОЙ».

Настоящий верхний левый квадрат в выбранную тройку не вошёл.

Дальше программа честно строила геометрию по тому, что сама нашла. Вместо сетки 57 × 57 модулей получилась оценка стороны 81, а при повторном центрировании 83. Модуль здесь просто один маленький квадрат QR.

На этом этапе можно было сколько угодно обсуждать коррекцию ошибок. Мы уже смотрели не на ту сетку.

Надпись под подозрением. Проверяем

Координата в тексте выглядит убедительно, но сама по себе ещё не доказывает причину. Поэтому следующий опыт я делал на готовом растре, без повторной генерации SVG.

Закрасил белым только нижнюю полосу с подписью. Не обрезал изображение, не изменил его размеры, не сдвинул QR.

Чтение вернулось. В области самого QR изменилось ровно ноль пикселей. Это проверено сравнением всех четырёх каналов в прямоугольнике 500 × 500, который содержит код вместе с его полем.

Для контроля убрал вместо нижней подписи верхнюю плашку. Не помогло. Значит, дело не в том, что после удаления любого оформления картинка становится «попроще».

Потом стёр только квадрат 24 × 24 около подозрительной точки в подписи. Этого тоже не хватило: локатор выбрал другой фрагмент той же строки, около (314.5, 638.5), и снова не прочитал код.

Во всех четырёх вариантах область QR побайтно одинакова. Закрашивание выполнено до распознавания; цветные метки добавлены только для объяснения результата.
Во всех четырёх вариантах область QR побайтно одинакова. Закрашивание выполнено до распознавания; цветные метки добавлены только для объяснения результата.

Получилось довольно настойчиво: убираешь один ложный ориентир, программа находит запасной. Инструкция была короткой, но кандидатов в ней хватало.

А если просто показать, где код?

Есть ещё одна проверка, независимая от закрашивания подписи.

Я взял полный исходный растр и передал этапу извлечения правильные координаты ориентиров, полученные на исправленном изображении. Декодер вернул исходный контакт.

Так я обошёл поиск ориентиров и отдельно проверил извлечение матрицы и чтение данных. На произвольной фотографии этот приём не поможет: правильные координаты ещё нужно найти.

Ниже не нарисованные вручную QR, а матрицы, которые действительно извлёк алгоритм из одного исходного растра.

Слева первый вариант, выбранный локатором: 81 × 81. Справа контроль с правильными координатами: 57 × 57. Масштаб каждого модуля увеличен без сглаживания.
Слева первый вариант, выбранный локатором: 81 × 81. Справа контроль с правильными координатами: 57 × 57. Масштаб каждого модуля увеличен без сглаживания.

Содержимое всё это время было доступно. До него не доходила правильная геометрия.

Почему буквы победили квадрат

Осталось понять, почему программа предпочла буквы настоящему ориентиру.

Локатор ищет чередования чёрного и белого. Если провести линию через центр углового узора QR, ширины полос должны соотноситься как 1:1:3:1:1. Он проверяет похожие узоры по нескольким направлениям и выставляет каждому оценку: меньше значит лучше. Это мера несовпадения с образцом, не процент уверенности.

Настоящий верхний левый ориентир нашёлся со смещённым центром и получил 78.53. Фрагмент подписи получил 28.84. По этой оценке буквы выглядели убедительнее квадрата.

Затем локатор собирает кандидатов в тройки и добавляет штраф за различие их размеров. Победила тройка с фрагментом подписи; её итоговая оценка составила 46.93.

Центры и оценки взяты из протокола локатора. Оценка тройки включает штрафы за различие размеров, поэтому это не простая сумма оценок точек.
Центры и оценки взяты из протокола локатора. Оценка тройки включает штрафы за различие размеров, поэтому это не простая сумма оценок точек.

В исходниках локатора после сортировки выбирается finderPatternGroups[0]. Следующие тройки после неудачного чтения не перебираются. Есть повторная попытка, но она лишь уточняет центры тех же выбранных ориентиров. Отдельная попытка с инверсией цветов здесь тоже не помогает.

Программа перепроверяла, где именно находится найденный «угол». А нужно было усомниться, что это вообще угол.

Исправление оставило надпись на месте

Историческое исправление экспорта оказалось коротким:

<svg x="70" y="92" width="500" height="500"
     viewBox="0 0 854 854"
     shape-rendering="crispEdges">
  <!-- Та же векторная матрица QR -->
</svg>

Атрибут поставили только на внутренний SVG с кодом. Подпись и верхняя плашка остались с прежней отрисовкой. Не менялись содержимое контакта, векторная матрица, размеры и расположение элементов.

По спецификации SVG, crispEdges просит растеризатор отдать приоритет чётким границам. Ради этого реализация может отключить сглаживание и подстроить края под пиксельную сетку. Это указание о способе рисования, не дополнительная коррекция QR и не гарантия поведения любого браузера.

В нашем растре изменение стало видно на настоящем верхнем левом ориентире. Локатор нашёл его центр в (115, 137), а базовая оценка улучшилась с 78.53 до 6.24. Теперь группа из трёх настоящих ориентиров получила 17.34 и выиграла.

Самое приятное здесь: ложный кандидат в подписи никуда не исчез. Его базовая оценка осталась 28.84. После исправления он просто перестал побеждать.

Одни и те же области до и после исправления. Пиксели растра меняются, векторное содержимое QR нет. На шкале показан диапазон 620–660; ещё шесть размеров проверены за её пределами, всего по 47 для каждого варианта.
Одни и те же области до и после исправления. Пиксели растра меняются, векторное содержимое QR нет. На шкале показан диапазон 620–660; ещё шесть размеров проверены за её пределами, всего по 47 для каждого варианта.

Отсюда и ответ на вопрос про размер. Смена масштаба меняет не только количество пикселей, но и то, как границы фигур попадают на их сетку. В нашем случае это меняло результат поиска ориентиров. Больше пикселей не означало более удачный выбор локатора.

Исходный файл отказал на ширинах 636, 638, 639 и 640. При 637 уже прочитался. После исправления прочитались все 47 проверенных размеров: диапазон 620–660 с шагом один и ещё 200, 240, 320, 480, 960, 1280.

Эти результаты относятся к одному файлу в указанной связке Chromium и jsQR. Общего правила, которое предсказывает успех чтения по размеру картинки, эти проверки не дают.

Что из этого стоит унести

Для такого дефекта важно проверять не только матрицу кода, но и готовый экспорт целиком, с его подписью, рамкой и реальным размером. Отдельно нарисованный QR может читаться, пока вся композиция не читается у выбранного распознавателя.

Проверка только на увеличенной картинке тоже недостаточна: она способна пропустить отказ в исходном размере. В регрессионный тест этого экспорта вошли реальные скачанные SVG в четырёх размерах, с тремя рамками и двумя вариантами фона. Нынешний разбор отдельно проверяет сохранённый проблемный файл и причину его отказа.

Если декодер возвращает только null, полезно хотя бы в диагностике видеть найденные ориентиры и извлечённую матрицу. Иногда эти две картинки объясняют больше, чем ещё десять вариантов контраста.

Главную проверку можно повторить по коду ниже. Для неё нужны именно исходные PNG, а не уменьшенные иллюстрации из статьи: изменение масштаба здесь меняет условия опыта.

Повторить проверку: исходные PNG и код

Исходные файлы без поясняющих наложений: 640 × 688 до исправления, 320 × 344 до исправления, 640 × 688 после исправления.

Проверено на Node.js 24.15.0. В пустой папке установите две закреплённые зависимости:

npm init -y
npm install --save-exact --ignore-scripts jsqr@1.4.0 pngjs@7.0.0

Код для файла check.mjs:

import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import jsQR from 'jsqr';
import { PNG } from 'pngjs';

const base = 'https://habrastorage.org/getpro/habr/upload_files/';
const fixtures = [
  ['original', '32b/3aa/354/32b3aa3548eff1efef04ba65c6bef7b3.png',
    640, 688, '2080ee6ac0b982331ebedf703bc93139974f652dc7ab37f3fa8d0a0132a849a2'],
  ['small', '1d6/9da/d9e/1d69dad9ed7be4e98838354bf1ff2f5c.png',
    320, 344, '7540fa4264703a433fd50b6173f452dc9d3cab288a3471597f8f0acf7a8f1589'],
  ['crisp', 'b8f/764/d78/b8f764d78d946d750a19826cf2ea4fa7.png',
    640, 688, '85d07e947315c0e0c93b022f8c2848cdf2064dabac785675ca270b366278055f'],
];
const images = {};
for (const [name, path, width, height, hash] of fixtures) {
  const response = await fetch(base + path, { signal: AbortSignal.timeout(30000) });
  assert.ok(response.ok, `${name}: HTTP ${response.status}`);
  const png = PNG.sync.read(Buffer.from(await response.arrayBuffer()));
  assert.equal(png.width, width);
  assert.equal(png.height, height);
  assert.equal(createHash('sha256').update(png.data).digest('hex'), hash, name);
  images[name] = { width, height, data: new Uint8ClampedArray(png.data) };
}

const expected = [
  'BEGIN:VCARD', 'VERSION:3.0', 'N:Иванова;Мария;;;', 'FN:Мария Иванова',
  'ORG:Студия «Форма»', 'TEL;TYPE=CELL:+79000000000',
  'EMAIL:maria@example.org', 'URL:https://example.org/contact', 'END:VCARD',
].join('\r\n');
const read = ({ data, width, height }) => jsQR(data, width, height);
assert.equal(read(images.original), null);
assert.equal(read(images.small)?.data, expected);
assert.equal(read(images.crisp)?.data, expected);

function erase(image, [left, top, width, height]) {
  const data = image.data.slice();
  for (let y = top; y < top + height; y++) {
    for (let x = left; x < left + width; x++) {
      const offset = 4 * (y * image.width + x);
      data.fill(255, offset, offset + 4);
    }
  }
  return { ...image, data };
}

const results = [];
for (const [name, rect, shouldRead] of [
  ['without-caption', [0, 600, 640, 88], true],
  ['without-header', [0, 0, 640, 70], false],
  ['without-fragment', [375, 622, 24, 24], false],
]) {
  const image = erase(images.original, rect);
  // Compare every channel inside the unchanged 500 x 500 QR area.
  for (let y = 92; y < 592; y++) {
    const start = 4 * (y * 640 + 70);
    const end = start + 4 * 500;
    assert.deepEqual(image.data.subarray(start, end),
      images.original.data.subarray(start, end));
  }
  const result = read(image);
  if (shouldRead) assert.equal(result?.data, expected);
  else assert.equal(result, null);
  results.push({ name, reads: result !== null, changedPixelsInQr: 0 });
}
console.table(results);
console.log('All checks passed: original=null, small=contact, crisp=contact');

Запуск:

node check.mjs

Скрипт скачивает три PNG, проверяет размеры и SHA-256 декодированных RGBA-массивов, затем запускает jsQR. Исходный большой растр должен вернуть null, уменьшенный и исправленный должны вернуть точный учебный контакт, включая переводы строк.

В таблице результатов чтение возвращается только у without-caption. У without-header и without-fragment остаётся null. Для каждого вмешательства отдельно проверяются все четыре канала области QR: изменённых пикселей должно быть ноль.

Это минимальная проверка главного эффекта на сохранённых пикселях. Она не пересоздаёт SVG, не повторяет перебор 47 размеров и не выводит внутренние оценки локатора. Так другой браузер или шрифт не может незаметно изменить исходные условия.

Подпись «ОТКРОЙТЕ КАМЕРОЙ» осталась на месте. Ложный кандидат тоже. Просто теперь программа выбрала три настоящих ориентира.