Знак для разработчиков

Всё, чтобы встроить круглый код Знак (АДАК) в свой продукт: устройство формата, алгоритм декодирования, JS API и рабочие примеры приложений для Android и iOS. Формат открыт — эталонная реализация лежит в одном файле /assets/znak.js без зависимостей.

Геометрия кода

Знак — концентрическая структура. Все радиусы нормированы: за единицу принят внешний край замыкающего кольца. Угол 0° — «север» кода, углы растут по часовой стрелке.

ЭлементРадиусыНазначение
Диск мишени0 — 0,14Поиск кода в кадре: горизонтальная линия через центр даёт прогоны Ч:Б:Ч:Б:Ч с отношениями 1 : 1 : 3,5 : 1 : 1
Белое кольцо0,14 — 0,22
Кольцо мишени0,22 — 0,30
Кольцо ориентации0,33 — 0,385Чёрная дуга 32° с центром на «севере» — задаёт нулевой угол
6 колец данных0,40 — 0,90Секторов по кольцам изнутри наружу: 24, 32, 40, 48, 56, 64. Итого 264 бита
Белый зазор0,90 — 0,93Опорный уровень белого для порога
Замыкающее кольцо0,93 — 1,00Измерение радиуса по каждому направлению — устойчивость к наклону
Тихая зона0,12 внешнего радиусаСвободное поле вокруг кода

Тёмный сектор — бит 1, светлый — 0. Сектор i кольца j занимает дугу [i·360°/nj, (i+1)·360°/nj) от «севера»; биты укладываются подряд от внутреннего кольца к внешнему, старший бит байта — первым.

Знак 2.0 — ёмкая версия с лучами

Для длинных данных используется Знак 2.0: 22 кольца данных (радиусы 0,334—0,906, толщина кольца 0,026), число секторов в кольце — round(2π·r_mid / 0,026), от 84 во внутреннем до 216 во внешнем. Вместо дуговой метки — три луча от центра (радиусы 0,30—0,93, полуширина 2°) на углах 0°, 100° и 220°: несимметричные промежутки (100/120/140°) дают однозначную ориентацию, а измерение углового положения лучей на разных радиусах калибрует остаточное перспективное скручивание. Секторы, чей центр ближе к лучу, чем полуширина луча + полсектора + 0,8°, из данных исключаются (маска детерминирована). Итого 3082 рабочих бита = 385 байт: 3 байта заголовка (версия/режим и длина uint16), до 314 байт данных и два чередующихся блока Рида-Соломона по 34 контрольных байта (исправление до 17 байт на блок). Детекция лучей — по белой полосе на радиусе 0,316. Версию декодер различает автоматически: сначала пробует лучи, затем дугу 1.0.

Формат данных

264 бита = 33 байта. Первые 24 байта — данные, последние 9 — контрольные байты Рида-Соломона.

БайтыСодержимое
0Старшие 4 бита — версия (сейчас 1), младшие — режим: 0 байты UTF-8, 1 URL (при декодировании добавляется префикс https://)
1Длина полезных данных L (0—22)
2 … 2+L−1Полезные данные UTF-8
до 23Паддинг: чередование 0xA5 и 0x5A
24 … 329 байт Рида-Соломона: GF(256), примитивный полином 0x11D, генератор от α⁰. Исправляет до 4 повреждённых байт (~12% кода)

Алгоритм декодера

Эталонный конвейер из znak.js — шесть шагов, каждый можно воспроизвести на любом языке:

  1. Порог. Кадр в оттенки серого; глобальный порог — метод Оцу (устойчив, когда код занимает малую долю кадра). Все выборки — субпиксельные, с билинейной интерполяцией.
  2. Поиск мишени. По строкам (шаг ~2 px) ищутся прогоны Ч:Б:Ч:Б:Ч с отношениями 1:1:3,5:1:1 (допуск ±45%). Кандидаты кластеризуются; каждый кластер подтверждается таким же вертикальным прогоном. Ширина единичного прогона u = 0,08·R даёт первичную оценку радиуса.
  3. Трассировка контура и нормализация. Из центра пускаются 72 луча; на каждом ищется внешний край замыкающего кольца. По найденным точкам методом наименьших квадратов вписывается эллипс Ax²+Bxy+Cy²=1, и разложением Холецкого строится аффинное преобразование, возвращающее эллипс в единичную окружность. Наклон камеры — это аффинное искажение круга, поэтому после нормализации все дальнейшие выборки идут по точной круговой сетке: код читается при наклоне до ~40°. Если вписать эллипс не удалось, декодер откатывается на выборку с радиусом по направлению R(θ).
  4. Опорные уровни. Чёрный — среднее по диску мишени, белый — по зазору на 0,915·R. Их середина — рабочий порог.
  5. Ориентация. Кольцо 0,33—0,385 читается в 360 точках; скользящим окном шириной 32° ищется самая тёмная дуга — её центр и есть «север» θ₀.
  6. Калибровка (Знак 2.0). Радиальная шкала уточняется по двум опорным краям — внешнему краю кольца мишени (r=0,30) и внутреннему краю замыкающего кольца (r=0,93); угловое скручивание — по трём лучам на двух радиусах. Межсимвольная интерференция от расфокуса подавляется эквалайзером: из каждого сектора вычитается взвешенное влияние четырёх соседей, сила вычета подбирается перебором вместе с угловым сдвигом.
  7. Чтение и коррекция. Каждый сектор усредняется по 9 выборкам (3 радиуса × 3 угла). Так как погрешность θ₀ около градуса, а сектор внешнего кольца всего 5,6°, чтение повторяется со сдвигами от −2,5° до +2,5° шагом 0,5°, пока Рид-Соломон не сойдётся. Успешная коррекция RS — одновременно и проверка целостности: вероятность ложного срабатывания пренебрежимо мала.
Портируете декодер? Возьмите znak.js как эталон и сверяйте промежуточные результаты на одних и тех же изображениях: сначала синтетика (рендер + известные биты), потом фото. Функция decodeImage принимает голый RGBA-буфер — её легко дёргать из тестов.

JS API

Один файл, ноль зависимостей, работает в браузере и Node.js. Подключение и генерация:

<script src="https://qr-znak.ru/assets/znak.js"></script>
<script>
  var enc = Znak.encodePayload('https://example.ru');
  if (enc.error) throw new Error(enc.error);   // больше 22 байт

  // SVG-строка (вектор)
  var svg = Znak.buildSvg(enc.bits, { size: 300, color: '#16181D' });

  // или в canvas (растровый PNG)
  var cv = document.createElement('canvas');
  cv.width = cv.height = 1024;
  Znak.drawCanvas(cv.getContext('2d'), enc.bits, 1024, '#16181D', '#FFFFFF');
</script>

Декодирование кадра камеры или картинки:

// кадр видео → центральный квадрат 480×480 → декодер
ctx.drawImage(video, sx, sy, side, side, 0, 0, 480, 480);
var img = ctx.getImageData(0, 0, 480, 480);
var res = Znak.decodeImage(img.data, 480, 480);
if (res) console.log(res.text);   // строка; res.mode: 1 — URL, 0 — текст

Для сканеров реального времени есть Znak.locate(rgba, w, h) — быстрая локализация без чтения данных (центр и радиус найденного кода): найдите код на уменьшенном кадре, затем вырежьте его область из кадра полного разрешения и передайте в decodeImage — так наш сканер читает ёмкий Знак 2.0.

decodeImage возвращает null, если Знака в кадре нет — вызывайте её на каждом кадре (7—10 раз в секунду достаточно). Полный рабочий пример — исходник страницы /scan.

Приложение для Android

Быстрый путь: WebView

Именно так собран наш официальный APK: одна Activity с WebView открывает сканер, а нативный код лишь корректно пробрасывает разрешение камеры. Полный класс — можно копировать как есть:

public class MainActivity extends Activity {
    private WebView web;
    private PermissionRequest pendingRequest;

    @Override protected void onCreate(Bundle b) {
        super.onCreate(b);
        web = new WebView(this);
        setContentView(web);
        WebSettings s = web.getSettings();
        s.setJavaScriptEnabled(true);
        s.setDomStorageEnabled(true);
        s.setMediaPlaybackRequiresUserGesture(false);
        web.setWebChromeClient(new WebChromeClient() {
            @Override public void onPermissionRequest(final PermissionRequest req) {
                runOnUiThread(() -> {
                    String host = req.getOrigin().getHost();
                    if (host == null || !host.endsWith("qr-znak.ru")) { req.deny(); return; }
                    if (checkSelfPermission(Manifest.permission.CAMERA)
                            == PackageManager.PERMISSION_GRANTED) {
                        req.grant(req.getResources());
                    } else {
                        pendingRequest = req;
                        requestPermissions(new String[]{Manifest.permission.CAMERA}, 1);
                    }
                });
            }
        });
        web.loadUrl("https://qr-znak.ru/scan");
    }

    @Override public void onRequestPermissionsResult(int c, String[] p, int[] g) {
        if (c == 1 && pendingRequest != null) {
            if (g.length > 0 && g[0] == PackageManager.PERMISSION_GRANTED)
                pendingRequest.grant(pendingRequest.getResources());
            else pendingRequest.deny();
            pendingRequest = null;
        }
    }
}

В манифест добавьте <uses-permission android:name="android.permission.CAMERA"/> и <uses-feature android:name="android.hardware.camera" android:required="false"/>. Если хотите свой интерфейс без нашего сайта — положите znak.js и HTML-страницу в assets и грузите через file:///android_asset/: декодер работает полностью офлайн.

Нативный путь: CameraX + порт декодера

Для максимальной скорости портируйте декодер на Kotlin по разделу «Алгоритм» (это ~500 строк без зависимостей). Кадры берите из CameraX ImageAnalysis (формат YUV_420_888 — плоскость Y уже даёт оттенки серого, шаг «порог» упрощается), разрешение анализа 640×480 достаточно.

Приложение для iOS

Аналогичная схема через WKWebView — getUserMedia внутри него работает с iOS 14.5. Минимальный контроллер на Swift:

import UIKit
import WebKit

class ScanViewController: UIViewController, WKUIDelegate {
    override func viewDidLoad() {
        super.viewDidLoad()
        let cfg = WKWebViewConfiguration()
        cfg.allowsInlineMediaPlayback = true
        cfg.mediaTypesRequiringUserActionForPlayback = []
        let web = WKWebView(frame: view.bounds, configuration: cfg)
        web.uiDelegate = self
        web.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(web)
        web.load(URLRequest(url: URL(string: "https://qr-znak.ru/scan")!))
    }

    // iOS 15+: выдаём камеру только своему домену
    func webView(_ webView: WKWebView,
                 requestMediaCapturePermissionFor origin: WKSecurityOrigin,
                 initiatedByFrame frame: WKFrameInfo,
                 type: WKMediaCaptureType,
                 decisionHandler: @escaping (WKPermissionDecision) -> Void) {
        decisionHandler(origin.host.hasSuffix("qr-znak.ru") ? .grant : .deny)
    }
}

В Info.plist обязателен ключ NSCameraUsageDescription с текстом, зачем нужна камера — без него система завершит приложение при первом обращении к ней. Нативный порт декодера на Swift делается так же, как на Kotlin: кадры из AVCaptureVideoDataOutput, плоскость яркости — сразу готовый вход для алгоритма.

Публикация в App Store требует аккаунта Apple Developer. Чистая WebView-обёртка без добавленной ценности может не пройти ревью (гайдлайн 4.2) — добавьте нативные детали: историю сканирований, виброотклик, шорткат на экран «Домой».

Лицензия и правила использования

Формат Знак 1.0 открыт: реализуйте генераторы и сканеры свободно, в том числе в коммерческих продуктах. Условия простые — укажите источник «Система Знак (АДАК), qr-znak.ru» в описании приложения или разделе «О программе» и не выдавайте формат за собственную разработку. Название «Знак» применительно к формату используйте с ссылкой на первоисточник. Вопросы и предложения по развитию формата — через страницу истории системы.