Три разных номера из одной книги — и все проходят контрольную цифру
- пятница, 7 августа 2026 г. в 00:00:01
У EAN-13 есть контрольная цифра. Тринадцатая цифра пересчитывается по остальным двенадцати, и если номер прочитан с ошибкой — проверка не сойдётся. Так написано в любом объяснении стандарта, и это правда. Проблема в том, что из этой правды обычно делают неверный вывод: «раз контрольная сошлась, номер прочитан верно».
Не сошлась. Точнее — сошлась, но это ничего не доказывает.
Мы делаем бесплатный сканер кодов, который работает прямо в браузере: кадр с камеры разбирается на устройстве, ничего не уходит на сервер. Я навёл его на штрихкод книги и получил три разных результата подряд:
8783922467046 9785222467046 3705912467046
Верный — средний, это ISBN. Два других — ошибочные прочтения смазанного кадра. Первая мысль: сейчас отсеем их контрольной цифрой. Считаем по правилу GS1 (веса 1 и 3, начиная слева):
const checkDigit = (body) => { const sum = [...body].map(Number) .reduce((total, digit, index) => total + digit * (index % 2 ? 3 : 1), 0); return (10 - (sum % 10)) % 10; }; checkDigit("978522246704"); // 6 — совпадает с последней цифрой checkDigit("878392246704"); // 6 — тоже совпадает checkDigit("370591246704"); // 6 — и это тоже
Все три номера контрольную цифру проходят.
Контрольная цифра EAN-13 — это одна цифра, вычисленная как дополнение взвешенной суммы до кратного десяти. Она ловит любую одиночную ошибку и большинство перестановок соседних цифр. Но она не ловит ошибку, при которой взвешенная сумма меняется на величину, кратную десяти.
Если распознаватель ошибся в нескольких цифрах сразу — а именно так и происходит при смазанном кадре, когда границы штрихов «плывут», — вероятность, что новая сумма случайно попадёт в тот же остаток по модулю десять, составляет примерно одну десятую. Не «почти ноль», а каждый десятый бракованный результат.
Дальше работает отбор выжившего: библиотека распознавания сама отбрасывает варианты с неверной контрольной цифрой и ничего вам не возвращает. До интерфейса доходят только те ошибки, которые проверку прошли. Поэтому со стороны кажется, что «проверка работает» — вы просто не видите девять отсеянных из десяти, зато десятый выглядит совершенно нормальным номером.
Стоит сравнить с двумерными кодами. В QR внутри код Рида — Соломона: избыточность занимает от 7 до 30 процентов ёмкости, в зависимости от уровня коррекции. Искажённый кадр либо восстанавливается полностью, либо не декодируется вовсе. Промежуточного состояния «прочиталось, но не то» практически не бывает — чтобы обмануть Рида — Соломона, нужно совпадение, вероятность которого исчезающе мала.
Отсюда практический вывод, который стоит держать в голове при работе с камерой:
двумерному коду можно верить с первого чтения;
линейному коду с первого чтения верить нельзя.
Промышленные сканеры решают задачу так же, как решили её мы: требуют повторяемости. Верный номер стабилен — он повторяется кадр за кадром, пока код в поле зрения. Ошибочные прочтения каждый раз разные, потому что причина у них случайная: другой уровень смаза, другое дрожание руки, другой блик.
Значит, достаточно накапливать голоса и показывать номер только после нескольких одинаковых чтений:
const LINEAR_CONFIRMATIONS = 3; export function tallyConfirmation(tally, value, { needed = LINEAR_CONFIRMATIONS, now = 0, forgetAfterMs = 4000 } = {}) { // Накопитель забывается, если код надолго ушёл из кадра, — иначе // следующий товар донашивал бы подтверждения предыдущего. const fresh = tally && now - tally.at <= forgetAfterMs ? { counts: { ...tally.counts }, at: now } : { counts: {}, at: now }; const count = (fresh.counts[value] || 0) + 1; fresh.counts[value] = count; return { tally: fresh, count, needed, confirmed: count >= needed }; }
Три одинаковых чтения при разборе восьми кадров в секунду — это меньше секунды ожидания. Пользователь разницы не замечает, а ошибочные номера до экрана не доходят: каждый из них остаётся с одним голосом и умирает в накопителе.
Тест на реальных данных выглядит так — и это, пожалуй, самый честный тест из всех, что мы написали:
// Настоящая череда прочтений с той самой книги const sequence = ["8783922467046", "3705912467046", "9785222467046", "9785222467046", "9785222467046"]; // В списке результатов остаётся ровно один номер — верный assert.deepEqual(shown.values, ["9785222467046"]);
Второй сюжет из той же истории. Квадратный код маркировки «Честного знака» с пачки молока не читался вообще — счётчик кадров крутится, результата нет.
Замер показал точную причину. Распознавателю нужно, чтобы код занимал не меньше примерно 110 точек в разбираемом кадре. А мы ужимали кадр камеры до 800 точек по длинной стороне ради экономии батареи. Код маркировки печатают мелким — миллиметров десять на упаковке, — и после ужатия ему доставалось около девяноста точек. Ниже порога. Сколько ни держи телефон, результата быть не могло.
Порог подняли до 1000, и код стал читаться. Мораль простая: прежде чем оптимизировать разрешение, стоит выяснить, с каким минимальным размером объекта работает распознаватель. Мы этого не сделали и потеряли на этом несколько дней.
Когда номер прочитан, хочется показать человеку, что это за товар. Обычный путь — спросить чужой API. Но тогда на сторонний сервер уходит штрихкод, IP и время, а мы обещаем пользователям, что содержимое кодов не покидает браузер.
Сделали иначе: взяли открытую выгрузку Open Food Facts (ODbL), отобрали российские товары, отбросили записи с неверной контрольной цифрой — таких в исходной базе оказалось 13,5 процента — и нарезали остаток на файлы по первым четырём цифрам номера. Получилось 32 772 товара в 1387 файлах, средний файл — 1,4 КБ.
Браузер скачивает один файл и ищет в нём сам. В адрес запроса попадает только номер кусочка, общий для целой сотни номеров. Наружу не уходит ничего. Проверяется это тестом, который ловит все сетевые запросы страницы и требует, чтобы внешних было ноль.
Побочный эффект оказался интереснее основного. Первые семь цифр штрихкода — номер предприятия, который GS1 выдаёт компании один раз, и все её товары начинаются с него. В нашей выборке 32 772 товара распределились всего по 5 924 таким номерам, причём 305 номеров покрывают половину товаров. То есть знание про один товар автоматически распространяется на весь ассортимент производителя — и триста подсказок закрывают половину полки в магазине.
Все числа в тексте — результат замеров, а не оценка по памяти. Вот чем проверялось каждое:
Утверждение | Как проверено |
|---|---|
Три номера проходят контрольную цифру | Пересчёт по правилу GS1 для каждого: |
13,5 % записей в выгрузке с неверной контрольной | Фильтр отсеял 5 120 записей из 37 892 |
32 772 товара, 1387 файлов, средний 1,4 КБ | Итог сборки справочника |
5 924 номера предприятий, 305 покрывают половину | Группировка товаров по первым семи цифрам |
Порог распознавания около 110 точек | Замер на нарисованном Data Matrix: 80 точек — не читается, 110 — читается |
Разбор восемь кадров в секунду | Дросселирование цикла: 120 мс между попытками |
Уровни коррекции QR (7–30 % избыточности) — из стандарта ISO/IEC 18004, это общеизвестная величина, а не наш замер.
Если вы читаете линейные штрихкоды камерой — не доверяйте одному чтению, даже если контрольная цифра сошлась. Она отсеивает девять ошибок из десяти, а десятую пропускает, и выглядит эта десятая совершенно правдоподобно. Требуйте повторяемости: это несколько строк кода и меньше секунды ожидания.
Сканер, о котором речь, работает бесплатно и без регистрации: vitrinaqr.ru/qr-kody/skaner-qr-onlayn/. Исходные наблюдения, цифры и тесты — оттуда же.