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