L-Scan: Що під капотом у WASM-сканера Lego-мініфігурок із морфологічним відновленням меж
Привіт, шановне товариство!
Сьогодні ми поринемо у світ сучасного комп’ютерного зору (Computer Vision) всередині мобільних браузерів та розберемося, як влаштований мій новий застосунок:
Якщо ви належите до глобальної спільноти AFOLs (Adult Fans of Lego — дорослих фанатів Lego), то нещодавно ви пережили монументальну трагедію: Еру Картонних Коробок. Заради екології та сталого розвитку компанія Lego замінила свої традиційні шурхотливі пластикові «сліпі» пакетики на жорсткі картонні коробочки. Для поціновувачів це стало ударом, бо тепер намацати, що всередині, просто неможливо.

Але коли Lego закриває двері, вони залишають відчиненим бекдор. На дні цих сучасних картонних коробок є крихітний, ледь помітний Data Matrix код. У цьому матричному коді зашито унікальний заводський серійний рядок, який чітко вказує на конкретну мініфігурку всередині.
Природно, як справжній розробник, я не міг пройти повз і створив веб-застосунок для їхнього миттєвого сканування та декодування. Але це не просто чергова generic-обгортка над готовою лібою. Сьогодні ми увімкнемо режим Master Builder і детально розберемо, як

Архітектура: абсолютна незалежність від мережі

Уявіть, що ви стоїте десь у глибині залізобетонного супермаркету, де мобільний зв’язок нестабільний. Якщо веб-застосунку знадобиться хоча б один round-trip запит до сервера для парсингу коду, продукт можна вважати мертвим ще на етапі релізу.
Тому
- Svelte 5 & Runes: Забезпечує реактивний та швидкий UI з мінімальним runtime-оверхедом. Робота з рунами через
$stateта$derivedдозволяє елегантно керувати зміною орієнтації екрана та стейт-машиною інтерфейсу без зайвих перерендерів. Це наче збирати конструктор із преміальних деталей Technic замість китайських аналогів. - Service Workers: Кешують усі скрипти, стилі, кастомні теми та шаблони зображень локально на пристрої користувача під час першого ж візиту.
- Dexie.js & IndexedDB: Ініціалізує вбудовану базу даних (
LegoScannerDB) у пісочниці клієнта, зберігаючи повний каталог відомих матриць прямо в телефоні.

Щоб інтерфейс ніколи не фризив і не заїкався під час обробки важких потоків зображень, sync-worker.js) тихо оновлює бази даних, тоді як окремий спеціалізований воркер (scanner-worker.js) крутить активний цикл комп’ютерного зору.
Камера-пайплайни та багатопотоковість без копіювання даних (Zero-Copy)
Класична помилка перформансу в браузерному скануванні зображень — це спроба витягнути піксельну матрицю з елемента <video>, перетворити її на масив сирих цілих чисел і передати у воркер через стандартний postMessage(). Така дія змушує браузер створювати глибоку копію (deep duplicate) ваших даних за допомогою алгоритму структурованого клонування (structured clone).
Копіювання важкого буфера кадру 1080p від 30 до 60 разів на секунду швидко заганяє CPU в тротлінг і викликає такі паузи Garbage Collection (GC), що ваш FPS впаде нижче, ніж самооцінка розробника після жорсткого код-рев’ю.
Всередині нашого головного рендер-пайплайну живі кадри витягуються безпосередньо з активного медіапотоку камери, генеруючи легке посилання на графічний ресурс:
// Захоплюємо швидкий зріз кадру з живого відеопотоку без копіювання const imageBitmap = await createImageBitmap(videoElement); // Миттєво передаємо абсолютні права власності на пам'ять у воркер через Comlink await workerAPI.detect(Comlink.transfer(imageBitmap, [imageBitmap]));
Додаючи imageBitmap безпосередньо до списку передачі postMessage, ми переносимо посилання на буфер пам’яті пікселів у воркер без дублювання даних. Головний потік (main thread) миттєво втрачає доступ до цього об’єкта, що знижує вартість передачі повідомлення практично до нуля.
WASM-Powered комп’ютерний зір: локалізація ZXing
Парсинг стандартних конфігурацій Data Matrix виконується бібліотекою ZXing (Zebra Crossing), яка з самого початку була написана на C++. Рахувати складну матричну математику безпосередньо в JavaScript — це як намагатися зібрати Lego Star Destroyer в товстих зимових рукавицях. Тому zxing-wasm.
Проте типові npm-інтеграції WASM-модулів підтягують свої бінарники динамічно з глобальних CDN під час виконання. Якщо телефон офлайн у магазині — застосунок ламається. Щоб усе працювало автономно, /public/wasm/ та динамічно перевизначає логіку локалізації модуля під час ініціалізації:
import { prepareZXingModule, ZXING_WASM_VERSION } from "zxing-wasm/reader";
prepareZXingModule({
overrides: {
locateFile: (path, prefix) => {
if (path.endsWith(".wasm")) {
// Примусово направляємо роутинг у локальний кеш нашого PWA
return `${cleanBase}/wasm/zxing/${ZXING_WASM_VERSION}/${path}`;
}
return `${prefix}${path}`;
},
},
fireImmediately: true,
});
Завдяки перехопленню рутини locateFile, бінарний рушій дістається безпосередньо з локального кешу PWA. Ядро сканера ініціалізується миттєво в абсолютній ізоляції від зовнішнього світу.
Морфологічний препроцесинг

А тепер реальний інженерний челендж: високошвидкісні пакувальні лінії Lego друкують ці крихітні коди на грубому, волокнистому картоні. Це часто призводить до мікроскопічних лисин у чорних квадратах, змазування або подряпин прямо на коді. Додайте сюди той факт, що камери смартфонів часто не можуть сфокусуватися на такому макрооб’єкті, а у покупця в магазині тремтять руки.
Якщо згодувати розмитий, блідий або злегка подряпаний код з коробки декодеру «в лоб», бібліотека просто не зможе знайти калібрувальні межі й викине помилку декодування.
Щоб вирішити цю проблему, scanner-worker.js:
// Безперервно крутимо лічильник фаз від 0 до 3 liveFrameSequenceCounter = (liveFrameSequenceCounter + 1) % 4; const framePhase = liveFrameSequenceCounter;
- Фази 0 & 2 (Сирий шлях): Передають незмінений кадр безпосередньо в декодер. Якщо код чіткий і добре освітлений, він розпізнається миттєво з нульовою затримкою.
- Фаза 1 (Відновлення ліній на повному кадрі): Запускає морфологічний фільтр для «зшивання» розірваних квадратів матриці до купи.
- Фаза 3 (Макро-зум по центру та відновлення): Графічно кропає центральні 55% кадру (імітуючи цифровий макро-зум) та ізолює його за допомогою морфологічного відновлення меж, щоб захопити дрібні чи віддалені коди.
Давайте розберемо, як це працює, не висаджуючи акумулятор пристрою за дві хвилини.
Витискаємо соки з GPU за допомогою трюків із 2D Canvas Compositing

Стандартні морфологічні фільтри (дилатація та ерозія) вимагають проходу по багатовимірних масивах пікселів через вкладені цикли в JavaScript. Робити такі обчислення вручну на кожен кадр у межах
globalCompositeOperation.
Ось як виконується пайплайн imageScratchRepairFullProcessing у воркері:
Крок 1: Висококонтрастна ізоляція в градаціях сірого
Спочатку сирий кадр зменшується та фільтрується, щоб прибрати відволікаючі кольори й посилити тіні, залишені надрукованим кодом:
basePool.ctx.filter = "grayscale(100%) contrast(170%) brightness(95%)"; basePool.ctx.drawImage( baseBmp, 0, 0, width, height, 0, 0, canvasWidth, canvasHeight, ); const grayscaleSnapshot = await createImageBitmap(basePool.canvas);
Крок 2: Dilation (закриваємо білі подряпини)
Далі ми запускаємо фільтр дилатації (dilation). Конфігуруємо режим накладання контексту як darken і циклічно малюємо наш чорно-білий знімок із серією точних вертикальних зміщень ([-4, -3, -2, -1, 1, 2, 3, 4]). Темні елементи матриці «розпливаються» по вертикалі на сусідні пікселі. Це миттєво «лікує» подряпини або пропуски фарби:
basePool.ctx.globalCompositeOperation = "darken";
for (const offset of MORPHOLOGICAL_HEALER_CLOSING_OFFSETS) {
basePool.ctx.drawImage(grayscaleSnapshot, 0, offset);
}
const dilatedSnapshot = await createImageBitmap(basePool.canvas);
Крок 3: Erosion (повертаємо пропорції)
Хоча дилатація успішно склеїла подряпини, вона також спотворила пропорції коду, зробивши всі чорні модулі занадто високими. Щоб збалансувати це, ми перемикаємо режим композиту на lighten і проганяємо той самий масив зміщень. Це звужує роздуті пікселі назад до їхнього оригінального розміру, але залишає закритими ті горизонтальні щілини, які ми щойно зшили. Це як хороший рефакторинг: прибрали зайвий код, але фічі не зламали.
basePool.ctx.globalCompositeOperation = "lighten";
for (const offset of MORPHOLOGICAL_HEALER_CLOSING_OFFSETS) {
basePool.ctx.drawImage(dilatedSnapshot, 0, offset);
}
Крок 4: Фінальне підсилення профілю
Наостанок ми підвищуємо контраст (contrast(250%)), щоб зробити краї ідеально гострими. На виході маємо чітке, математично відновлене зображення, яке WASM-декодер лузає як насіння.
Усуваємо витік пам’яті через пул Canvas (Canvas Pools)
Створення нового об’єкта OffscreenCanvas для кожного кадру відео спричинило б катастрофічний Memory Leak та змусило б Garbage Collector працювати на повну потужність. canvasPool.
const canvasPool = {};
const getPooledCanvas = (key, width, height) => {
if (!canvasPool[key]) {
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
canvasPool[key] = { canvas, ctx };
} else {
const entry = canvasPool[key];
if (entry.canvas.width !== width || entry.canvas.height !== height) {
entry.canvas.width = width;
entry.canvas.height = height;
} else {
entry.ctx.reset(); // Очищення буфера з нульовим виділенням нової пам'яті
}
}
return canvasPool[key];
};
Завдяки виклику entry.ctx.reset(), внутрішній стан канвасу та стеки фільтрів повністю скидаються, але виділена пам’ять залишається незмінною.
Офлайн-ферст механіки: двохешевий захист та боротьба з «Lie-Fi»
Щоб залишатися повністю незалежним від примх мобільного інтернету, src/service-worker.js.
Замість того, щоб звалювати весь кеш в одну велику купу, застосунок чітко розділяє стратегію збереження на два рівні:
// Розділення стратегії кешування на два рівні
const STATIC_CACHE = `static-${version}`; // Замінюється та видаляється при оновленні версії програми
const IMAGE_CACHE = `runtime-images-${IMAGE_CACHE_VERSION}`; // Постійний кеш для збереження історії картинок користувача
Рівень статичного кешу (STATIC_CACHE)
Це фундамент нашого додатку. Він заздалегідь кешує всі артефакти збірки (...build), статичні сторінки (...prerendered) та конфігурації лейаутів під час події install сервіс-воркера. Коли релізиться нова версія
Рівень постійного сховища зображень (IMAGE_CACHE)
Графіка мініфігурок високої роздільної здатності важить чимало. Якби ці зображення зберігалися разом із кодом додатку, кожне дрібне виправлення багів (hotfix) очищувало б увесь кеш, змушуючи ваш телефон знову викачувати мегабайти картинок під час наступного походу в магазин.
Завдяки ізоляції картинок у runtime-images-v1, цей рівень успішно переживає оновлення версії коду додатку. Ваша історія відсканованих фігурок залишається доступною миттєво, заощаджуючи дорогий мобільний трафік.
Ліниве завантаження (Lazy-Loading) за стратегією Cache-First
Сервіс-воркер ідентифікує графічні ресурси за допомогою регулярного виразу:
const OPTIMIZED_ASSETS_REGEX = /_app\/immutable\/assets\/.+\.(webp|avif|png|jpg|jpeg)$/i;
Коли перехоплюється запит на таке оптимізоване зображення,
Він не намагається «смикати» мережу, щоб перевірити, чи не змінилася картинка на сервері (вона статична). Застосунок одразу лізе в IMAGE_CACHE. Якщо файл знайдено — він миттєво віддається на екран. Якщо ви скануєте нову серію, якої ще не було в кеші, запит іде в мережу, клонує отриману картинку відповіді та фоново зберігає її в кеш для майбутніх офлайн-пригод:
// Інтерцептор персистентних зображень (Cache-First)
const isOptimizedImage = OPTIMIZED_ASSETS_REGEX.test(sanitizedPath);
if (isOptimizedImage) {
const cachedImage = await imageCache.match(standardizedReq);
if (cachedImage) return cachedImage; // Нуль оверхеду на мережу
}
Таймаути для захисту від «Lie-Fi»
Один із найбільших болів мобільної розробки — це стан «Lie-Fi». Це коли ваш телефон показує всі палички 4G всередині ТРЦ, але пакети даних насправді йдуть в чорну діру. Стандартний браузерний fetch може висіти в очікуванні до 90 секунд, перш ніж відвалитися за таймаутом. Користувач у цей час бачить лише нескінченний лоадер і починає нервувати.
const API_TIMEOUT_MS = 3500;
const fetchWithTimeout = (request, timeoutMs) => {
return Promise.race([
fetch(request),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Network request timed out")),
timeoutMs,
),
),
]);
};
Під час перевірки публічної інформації про колекції всередині /api/, сервіс-воркер використовує підхід Network-First with fallback, обмежений жорстким таймаутом у 3.5 секунди:
if (sanitizedPath.startsWith("/api/")) {
try {
const response = await fetchWithTimeout(event.request, API_TIMEOUT_MS);
if (response.status === 200) {
staticCache.put(standardizedReq, response.clone()); // Тихо оновлюємо локальні дані
}
return response;
} catch (err) {
// Мережа зависла? Миттєво віддаємо версію з локального кешу
const cachedResponse = await staticCache.match(standardizedReq);
if (cachedResponse) return cachedResponse;
throw err;
}
}
Якщо мережа не відповіла за 3.5 секунди, запит скасовується, а користувачу миттєво підтягується локальний датасет.
Нормалізація запитів
Cache Storage API порівнює URL-адреси як звичайні рядки. Якщо браузер додасть слеш наприкінці (наприклад, /howto/ замість /howto) або прикріпить якийсь маркетинговий чи аналітичний query-параметр, кеш зарахує це як промах (cache miss) і піде в мережу.
Щоб цього не відбувалося,
const normalizeRequest = (request) => {
const url = new URL(request.url);
let pathname = url.pathname;
// Захисний тримінг фінальних слешів
if (pathname.length > 1 && pathname.endsWith("/")) {
pathname = pathname.slice(0, -1);
}
// Відрізаємо query-стрінги для вирівнювання абсолютних шляхів
if (url.search.length > 0 || url.pathname !== pathname) {
return new Request(`${url.origin}${pathname}`, {
method: request.method,
headers: request.headers,
mode: request.mode,
});
}
return request;
};
Завдяки цьому, як би браузер чи зовнішній лінк не модифікували URL, застосунок завжди дістане правильний файл із локального сховища.
Спільнота та безкоштовний API для всіх

Будь-який сканер корисний рівно настільки, наскільки актуальна його база даних. Оскільки Lego постійно випускає нові заводи й нові партії з різними серійними форматами, підтримка бази в актуальному стані вимагає постійного моніторингу.
Датасет, на якому працює
Замість того, щоб закривати ці дані за пропрієтарною стіною комерційного застосунку, вся база даних доступна як відкритий публічний API для кожного.
Якщо ви будуєте свій власний трекер інвентарю Lego, нативний мобільний застосунок або аналітичного бота, ви можете смикати статичні JSON-ендпоінти напряму, без жодної реєстрації, смс чи API-ключів:
- Головна сторінка застосунку: lego-scanner.leopard.in.ua
- Публічна документація API: github.com/...wiki/Public-API-Endpoints
Наприклад, отримати повну матрицю кодів для космічної серії Space Series 26 можна звичайним GET-запитом на: https://lego-scanner.leopard.in.ua/api/collections/series-26.json
Резюме
Весь проєкт повністю відкритий і поширюється під ліцензією MIT на GitHub. Заходьте в репозиторій, вивчайте код, створюйте свої pull-реквести!
Успішного вам сканування, і нехай наступна «сліпа» коробка принесе вам саме ту фігурку, яку ви шукаєте!
13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівНе знаю, чому мене сюди закинуло, але цікаво. Я користуюсь minifig scan, але зараз краще буду користуватись Вашим. Вже в печінці сидять ці нові додатки на телефон. Кожен магазин, кожна фігня має додаток, який треба встановити... Я за веб! 😃 Дякую Вам щиро, не знала про Ваше творіння.
Дякую!
Ще додав би:
Стратегію stale-while-revalidate для API-запитів
Перевірку вільного місця через navigator.storage.estimate()
Механізм фонових оновлень через periodicSync або push API
Логування помилок кешування для моніторингу
Добре, записав )
Про SRW я писав — для lego каталога тут не швидкість, а актуальніть данних, тому network-first стратегія. Якщо Lego щойно випустила нову серію, і я додам її в API, користувач із SWR-стратегією при першому скануванні в магазині отримає стару базу з кешу. Сканер видасть помилку, і лише у фоні кеш оновиться для наступного разу
З мінусів відсутність інвалідації кешу для бази даних IMAGE_CACHE. Якщо картинка оновилася на сервері, користувач ніколи не побачить нову версію.
Немає стратегії stale-while-revalidate для API-даних. Якщо кеш застарів, а мережа повільна, користувач бачить старі дані, доки не оновить сторінку.
Це вирішено через хешування в назві картинки — якщо вона змінилась, то і назві зміниться, а значить буде видаватися нова (йде генерація з однієї картинки форматів avif, webp що видавати браузеру тей формат, що він підтримує). Також додатково вбудований механізм чистки старих картинок по даті доступу — ті які по даті найстаріші (давно відкривалися) — видаляються, щоб кеш картинок не зростав безконтрольно.
Хороше зауваження — тут можна додати кнопку якщо API fetch був з кеша в інтерфейсі щоб зробити force-fetch update. Тут SWR не підходить, бо треба пріоритет на свіжі дані, якщо мережа жива, а не швидкість по даним, які оновлюються «на перспективу»
Стаття космос. Роками не заходив на свій аккаунт на DOU. В RSS читалку прилетіла стаття, почитав в шторці на Lock Screen. Відкрив більше, зрозумів що це скарб.
Проект на GitHub супер
Дякую за коментар
Не читав, але стаття крута.
Я користуюсь ось таким застосунком: apps.apple.com/...ig-scan/id6475598662?l=uk
В ньому є ще база даних фігурок, які вже куплені, щоб не памʼятати що вже купив, а що ні.
Дякую. Ідея була щоб не ставити ще один додаток на телефон, але при цьому мати можливість користуватись без інтернету.
Таку базу можна прикрутити, хоча тут вже виникне ситуація якогось центрального збереження саме Ваших данних, щоб на телефоні і на комп’ютері це було синхронізовано. Хоча може ці додатки про це не думають і все зберігають локально, тоді це спрощує задачу
Для мобіли це не актуально, бо як ви собі уявляєте: я с десктоп-компьютером (або ноутбуком) в магазині сканую фігурки?
Хоча має сенс якась база даних на сервері, якщо фігурки збираються родиною. Тоді можуть сканувати батько № 1, батько № 2, ...., батько № N. :)
Інженерно цікаво, але практично малозначимо, бо інтернет є зараз в усіх.
Дані між девайсами можна синхронізувати через Google Drive. Це те що у всіх є і легко інтегрувати.
Можна спробувати, прийдеться тільки юзати implicity flow, бо без сервера треба синхронізація