@traveler123/t-scanner
v0.1.0
Published
Headless document scanner for the browser: edge detection, auto corner detection, perspective warp and scan filters. Framework-agnostic core + optional React hook and UI.
Downloads
103
Maintainers
Readme
@traveler123/t-scanner
Сканер документов для браузера: детекция краёв, авто-поиск углов, выпрямление
перспективы (гомография) и фильтры скана — всё на чистом <canvas>, без внешних
зависимостей.
Библиотека разбита на три уровня, и каждый верхний — опционален:
| Слой | Импорт | Зависимости | Назначение |
|------|--------|-------------|------------|
| Ядро | @traveler123/t-scanner | нет | вся обработка изображения, фреймворк-агностично |
| Headless-хук | @traveler123/t-scanner/react | react | состояние + действия, без разметки |
| Готовый UI | @traveler123/t-scanner/react/ui | react + css | компоненты «из коробки» |
UI-часть можно полностью убрать: импортируйте только ядро или только хук и постройте собственный интерфейс. React нужен лишь для двух верхних слоёв (заявлен как опциональная peer-зависимость).
Установка
npm install @traveler123/t-scanner
# react нужен только для слоёв /react и /react/ui
npm install react react-dom1. Только ядро (без UI и без фреймворка)
Чистые функции и классы для обработки кадра. Подходит для любого фреймворка, воркера или ванильного JS.
import {
EdgeDetector,
detectDocument,
extractDocument,
warpPerspective,
applyFilter,
CameraController,
} from "@traveler123/t-scanner";
// 1. Найти документ в кадре (видео/картинка/canvas) → 4 угла [0..1]
const quad = detectDocument(videoEl); // или null
// 2. Выпрямить и применить фильтр скана за один вызов
if (quad) {
const canvas = extractDocument(videoEl, quad, { filter: "bw" });
document.body.appendChild(canvas);
}
// Живой цикл детекции (переиспользует буферы):
const detector = new EdgeDetector();
function tick() {
const { bin, quad, width, height } = detector.detect(videoEl);
// ...рисуйте bin/quad как хотите
requestAnimationFrame(tick);
}Доступные примитивы (все экспортируются из @traveler123/t-scanner):
toGray, boxBlur, sobel, threshold, adaptiveThreshold,
detectQuad, polyArea, orderCorners,
getPerspectiveTransform, applyTransform, solve8,
warpPerspective, bilinear, applyFilter,
EdgeDetector, detectDocument, extractDocument, captureFrame,
CameraController, drawQuad, drawEdgeMask, fitContain, createCanvas,
mergePages, pagesToPdf, cloneCanvas, encodeCanvas, centerCropRect.
2. Headless React-хук (логика без разметки)
useDocumentScanner владеет камерой, циклом детекции и машиной состояний
(capture → edit → result), но ничего не рендерит. Вы привязываете
возвращённые ref-ы и вызываете действия из своей разметки.
import { useDocumentScanner } from "@traveler123/t-scanner/react";
function MyScanner() {
const s = useDocumentScanner({ filter: "bw", autoDetect: true });
return (
<div>
<div ref={s.stageRef} style={{ position: "relative", height: 480 }}>
{s.mode === "capture" && (
<>
<video ref={s.videoRef} playsInline muted />
<canvas ref={s.overlayRef} />
</>
)}
{s.mode === "edit" && (
<>
<canvas ref={s.editCanvasRef} />
{s.cornerHandles.map((h) => (
<div
key={h.index}
style={{ position: "absolute", left: h.x, top: h.y }}
onPointerDown={(e) => e.currentTarget.setPointerCapture(e.pointerId)}
onPointerMove={(e) =>
e.currentTarget.hasPointerCapture(e.pointerId) &&
s.moveCorner(h.index, e.clientX, e.clientY)
}
/>
))}
</>
)}
{s.mode === "result" && <canvas ref={s.resultCanvasRef} />}
</div>
{s.mode === "capture" && <button onClick={s.capture}>Снять</button>}
{s.mode === "edit" && <button onClick={s.scan}>Сканировать</button>}
{s.mode === "result" && <button onClick={() => s.download()}>Скачать</button>}
</div>
);
}Хук возвращает: mode, isReady, status, error, facingMode, quad,
cornerHandles, filter, autoDetect, zoom/minZoom/maxZoom, pages;
действия start, stop, capture, retake, scan, back, flipCamera,
setFilter, setQuad, moveCorner, toggleAutoDetect, setZoom/zoomIn/zoomOut,
addPage/removePage/clearPages; и вывод getResultCanvas, toBlob,
toDataURL, download, exportPdf/downloadPdf, mergePagesCanvas. Полные
типы — в DocumentScannerApi.
3. Готовый UI (опционально)
import { DocumentScanner } from "@traveler123/t-scanner/react/ui";
import "@traveler123/t-scanner/styles.css"; // стили подключаются отдельно — их тоже можно убрать
export default function App() {
return <DocumentScanner filter="bw" onScan={(c) => console.log(c.width)} />;
}Кастомизация UI
Всё настраивается, а любую часть можно скрыть или заменить:
<DocumentScanner
labels={{ title: "Scan", scan: "Process →", filters: { bw: "Mono" } }}
filters={["color", "bw"]} // какие фильтры показывать
showHeader={false} // убрать шапку
showToolbar={false} // убрать тулбар целиком
saveFilename="document.png"
onSave={(api) => api.toBlob().then(upload)} // своя логика сохранения
renderToolbar={(api, mode) => <MyBar api={api} mode={mode} />} // свой тулбар
className="my-scanner"
edgeStyle={{ color: [255, 80, 80], alpha: 0.6 }}
quadStyle={{ stroke: "#ff5050" }}
resultBackground="#fff"
/>Тема меняется CSS-переменными (переопределите после импорта стилей):
.ds-app {
--ds-accent: #3b82f6;
--ds-bg: #ffffff;
--ds-ink: #0f172a;
}Несколько страниц в один документ (multi-page)
Хук всегда отдаёт API страниц — собирайте несколько сканов и выгружайте в один PDF или одно «сшитое» изображение:
s.addPage(); // зафиксировать текущий результат как страницу
s.pages; // ScannedPage[] — собранные страницы
s.removePage(id);
s.clearPages();
await s.downloadPdf("doc.pdf"); // все страницы → один PDF
const blob = await s.exportPdf(); // тот же PDF как Blob (для загрузки/превью)
const sheet = s.mergePagesCanvas({ direction: "vertical", gap: 16 }); // одно изображениеВ готовом UI достаточно включить флаг — появится кнопка «+ Страница», а основная кнопка станет экспортом всех страниц в PDF:
<DocumentScanner multiPage saveFilename="document.pdf" />Те же функции есть и в ядре (без React), они работают с любыми canvas:
import { pagesToPdf, mergePages } from "@traveler123/t-scanner";
const pdf = await pagesToPdf([page1Canvas, page2Canvas]); // Blob, zero-deps
const tall = mergePages([page1Canvas, page2Canvas], { gap: 12 });pagesToPdf встраивает каждую страницу как JPEG (/DCTDecode) и сам пишет байты
PDF — без сторонних зависимостей.
Цифровой зум (center crop)
Берётся не весь кадр, а его центральная часть, и растягивается на холст.
Реализован чисто программно (без track.applyConstraints/ImageCapture), поэтому
работает одинаково везде — desktop, iOS, Android, — предсказуемо ускоряет
детекцию и убирает лишние объекты по краям. Превью увеличивается CSS-трансформом
видео, а оверлей/детекция/снимок используют тот же центральный кроп, так что углы
всегда совпадают с картинкой.
В готовом UI зум включён по умолчанию: пилюля − 1.0× + в режиме съёмки, плюс
колесо мыши и пинч. Настройка границ — через опции хука:
<DocumentScanner minZoom={1} maxZoom={4} zoomStep={0.5} zoom={1} />
// скрыть контрол: <DocumentScanner showZoom={false} />Через хук (headless):
s.zoom; // текущий зум (>= 1)
s.setZoom(2); // с клампом в [minZoom, maxZoom]
s.zoomIn(); s.zoomOut();
s.canZoomIn; s.canZoomOut; s.minZoom; s.maxZoom;В ядре зум — это параметр zoom у детекции/снимка (центр-кроп):
detector.detect(video, { zoom: 2 }); // углы нормализованы к кропу
const frame = captureFrame(video, 2); // снимок только центральной части
const doc = extractDocument(video, quad, { zoom: 2, filter: "bw" });
const { sx, sy, sw, sh } = centerCropRect(video.videoWidth, video.videoHeight, 2);Свой контроллер + готовая разметка
DocumentScannerView — чисто презентационный компонент. Можно держать хук у
себя, а рендер отдать ему (или наоборот — взять хук и не брать View вовсе):
import { useDocumentScanner } from "@traveler123/t-scanner/react";
import { DocumentScannerView } from "@traveler123/t-scanner/react/ui";
function App() {
const api = useDocumentScanner();
// ...свои кнопки, которые дёргают api.capture() / api.scan() ...
return <DocumentScannerView api={api} showToolbar={false} />;
}Производительность
Живая детекция краёв идёт в main thread каждый кадр (только в режиме съёмки).
Превью видео при этом всегда плавное — это нативный <video>; троттлится лишь
детекция и подсветка краёв/углов. Два способа снизить нагрузку:
// Детект раз в N кадров (видео по-прежнему на полном fps):
<DocumentScanner detectEveryNFrames={2} />
// Или ограничение по времени — стабильно на любом дисплее (60/120 Гц):
<DocumentScanner detectInterval={66} /> // ~15 детекций/сек; имеет приоритет
// Меньше пикселей на детекцию (грубее углы, на качество скана не влияет):
<DocumentScanner detect={{ procWidth: 240 }} />Те же опции есть и у хука (useDocumentScanner({ detectEveryNFrames, detectInterval, detect })).
detectInterval (мс) предпочтительнее detectEveryNFrames: «каждый 2-й кадр»
на 120 Гц даёт 60 детекций/с, а на 60 Гц — 30, тогда как интервал в мс держит
нагрузку одинаковой. На качество финального скана ничего из этого не влияет —
варп берёт полный снятый кадр.
Web Worker (полная разгрузка main thread)
Флаг worker уносит всю детекцию (downscale через OffscreenCanvas + Sobel/threshold/
поиск углов) в Web Worker. В главном потоке остаётся только захват кадра
(createImageBitmap с кропом под зум, передаётся transfer-ом) и отрисовка оверлея.
<DocumentScanner worker detectInterval={66} />const s = useDocumentScanner({ worker: true });
s.usingWorker; // true, когда детекция реально идёт в воркереВоркер инлайнится как Blob — отдельный файл подключать не нужно, работает в
любом сборщике (Vite/webpack/Rollup/esbuild) без конфига. Если окружение не
поддерживает (например, Safari < 16.4, нет OffscreenCanvas в воркере) или Blob-
воркер заблокирован CSP — происходит автоматический фолбэк на main-thread,
usingWorker остаётся false. По умолчанию worker выключен (opt-in).
Как это работает
- Детекция краёв — даунскейл кадра → grayscale → box-blur → оператор Собеля
→ адаптивная бинаризация (
mean + k·std). - Поиск углов — без полного contour-tracing: из облака краевых точек берутся
4 экстремума по диагоналям
x±y. Устойчиво, когда документ — доминирующий контрастный объект; дальше углы правятся вручную. - Выпрямление — гомография 4→4 точки (Гаусс с выбором ведущего), обратный маппинг с билинейной интерполяцией.
- Фильтры —
color(как есть),gray(по яркости),bw(адаптивный порог через интегральное изображение — чистый «скан»).
Примеры
examples/vanilla.html— только ядро, без React и без стилей пакета (запуск:npx serve .→ открыть/examples/vanilla.html).examples/react— Vite + React: готовый UI с multi-page → PDF и полностью кастомный UI на хуке (cd examples/react && npm install && npm run dev).
Требования
- Камера доступна только по HTTPS или с
localhost. - Нужен браузер с
getUserMedia,<canvas>2D иrequestAnimationFrame.
Скрипты
npm run build # сборка ESM + CJS + .d.ts (+ styles.css)
npm run typecheck # проверка типов
npm test # smoke-тесты математики ядраЛицензия
MIT
