2026-05-27 13:59:59 -04:00
|
|
|
/**
|
|
|
|
|
* Browser-side Tesseract OCR for Layer-1 card name strip recognition.
|
|
|
|
|
* Uses tesseract.js workers (off main thread). Language data loads from CDN on first use.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
let workerPromise = null;
|
|
|
|
|
|
2026-08-14 21:21:20 -04:00
|
|
|
const NAME_STRIP_PSM = '7';
|
|
|
|
|
const NUMBER_STRIP_PSM = '7';
|
|
|
|
|
const NAME_STRIP_HEIGHT_RATIO = 0.35;
|
|
|
|
|
const NUMBER_STRIP_HEIGHT_RATIO = 0.18;
|
|
|
|
|
const OCR_JPEG_QUALITY = 0.92;
|
|
|
|
|
|
2026-05-27 13:59:59 -04:00
|
|
|
async function getWorker() {
|
|
|
|
|
if (!workerPromise) {
|
|
|
|
|
workerPromise = (async () => {
|
|
|
|
|
const { createWorker } = await import('tesseract.js');
|
|
|
|
|
const worker = await createWorker('eng', 1, {
|
|
|
|
|
logger: () => {},
|
|
|
|
|
});
|
|
|
|
|
return worker;
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
return workerPromise;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 21:21:20 -04:00
|
|
|
function cropCardStrip(imageDataUrl, { fromTop = true, heightRatio }) {
|
2026-05-27 13:59:59 -04:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.onload = () => {
|
2026-08-14 21:21:20 -04:00
|
|
|
const stripHeight = Math.max(1, Math.floor(img.height * heightRatio));
|
2026-05-27 13:59:59 -04:00
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
canvas.width = img.width;
|
|
|
|
|
canvas.height = stripHeight;
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
2026-08-14 21:21:20 -04:00
|
|
|
const sourceY = fromTop ? 0 : Math.max(0, img.height - stripHeight);
|
|
|
|
|
ctx.drawImage(
|
|
|
|
|
img,
|
|
|
|
|
0,
|
|
|
|
|
sourceY,
|
|
|
|
|
img.width,
|
|
|
|
|
stripHeight,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
canvas.width,
|
|
|
|
|
canvas.height
|
|
|
|
|
);
|
|
|
|
|
resolve(canvas.toDataURL('image/jpeg', OCR_JPEG_QUALITY));
|
2026-05-27 13:59:59 -04:00
|
|
|
};
|
|
|
|
|
img.onerror = () => reject(new Error('Failed to load image for OCR'));
|
|
|
|
|
img.src = imageDataUrl;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 21:21:20 -04:00
|
|
|
/**
|
|
|
|
|
* Pick the most likely collector number token from OCR of the bottom strip.
|
|
|
|
|
* @param {string} rawText
|
|
|
|
|
* @returns {string}
|
|
|
|
|
*/
|
|
|
|
|
export function extractCollectorNumberCandidate(rawText) {
|
|
|
|
|
if (!rawText || typeof rawText !== 'string') return '';
|
|
|
|
|
|
|
|
|
|
const lines = rawText
|
|
|
|
|
.split(/\r?\n/)
|
|
|
|
|
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
const joined = lines.join(' ');
|
|
|
|
|
const slashMatch = joined.match(/\b(\d{1,4}\s*\/\s*\d{1,4}[A-Za-z]?)\b/);
|
|
|
|
|
if (slashMatch) {
|
|
|
|
|
return slashMatch[1].replace(/\s+/g, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
|
|
|
const line = lines[i];
|
|
|
|
|
if (/\d/.test(line) && line.length <= 12) {
|
|
|
|
|
return line;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 13:59:59 -04:00
|
|
|
/**
|
|
|
|
|
* OCR the top name strip of a card crop.
|
|
|
|
|
* @param {string} imageDataUrl - full card crop data URL
|
|
|
|
|
* @returns {Promise<{ text: string, confidence: number }>}
|
|
|
|
|
*/
|
|
|
|
|
export async function recognizeCardNameStrip(imageDataUrl) {
|
2026-08-14 21:21:20 -04:00
|
|
|
const fields = await recognizeCardFields(imageDataUrl);
|
|
|
|
|
return {
|
|
|
|
|
text: fields.nameText,
|
|
|
|
|
confidence: fields.nameConfidence,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* OCR name strip + bottom collector-number strip.
|
2026-09-01 18:17:20 -04:00
|
|
|
* Runs both strips in parallel using two dedicated workers (~50% faster on mobile).
|
2026-08-14 21:21:20 -04:00
|
|
|
* @param {string} imageDataUrl
|
|
|
|
|
* @returns {Promise<{ nameText: string, nameConfidence: number, cardNumber: string, numberConfidence: number }>}
|
|
|
|
|
*/
|
2026-09-01 18:17:20 -04:00
|
|
|
let numberWorkerPromise = null;
|
|
|
|
|
|
|
|
|
|
async function getNumberWorker() {
|
|
|
|
|
if (!numberWorkerPromise) {
|
|
|
|
|
numberWorkerPromise = (async () => {
|
|
|
|
|
const { createWorker } = await import('tesseract.js');
|
|
|
|
|
const worker = await createWorker('eng', 1, {
|
|
|
|
|
logger: () => {},
|
|
|
|
|
});
|
|
|
|
|
await worker.setParameters({ tessedit_pageseg_mode: NUMBER_STRIP_PSM });
|
|
|
|
|
return worker;
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
return numberWorkerPromise;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 21:21:20 -04:00
|
|
|
export async function recognizeCardFields(imageDataUrl) {
|
2026-05-27 13:59:59 -04:00
|
|
|
if (typeof window === 'undefined') {
|
2026-08-14 21:21:20 -04:00
|
|
|
return { nameText: '', nameConfidence: 0, cardNumber: '', numberConfidence: 0 };
|
2026-05-27 13:59:59 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-08-14 21:21:20 -04:00
|
|
|
const [nameStripUrl, numberStripUrl] = await Promise.all([
|
|
|
|
|
cropCardStrip(imageDataUrl, { fromTop: true, heightRatio: NAME_STRIP_HEIGHT_RATIO }),
|
|
|
|
|
cropCardStrip(imageDataUrl, { fromTop: false, heightRatio: NUMBER_STRIP_HEIGHT_RATIO }),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-09-01 18:17:20 -04:00
|
|
|
const [nameWorker, numberWorker] = await Promise.all([
|
|
|
|
|
getWorker(),
|
|
|
|
|
getNumberWorker(),
|
|
|
|
|
]);
|
2026-08-14 21:21:20 -04:00
|
|
|
|
2026-09-01 18:17:20 -04:00
|
|
|
await nameWorker.setParameters({ tessedit_pageseg_mode: NAME_STRIP_PSM });
|
|
|
|
|
|
|
|
|
|
const [nameResult, numberResult] = await Promise.all([
|
|
|
|
|
nameWorker.recognize(nameStripUrl),
|
|
|
|
|
numberWorker.recognize(numberStripUrl),
|
|
|
|
|
]);
|
2026-08-14 21:21:20 -04:00
|
|
|
|
|
|
|
|
const cardNumber = extractCollectorNumberCandidate(numberResult.data.text || '');
|
|
|
|
|
|
2026-05-27 13:59:59 -04:00
|
|
|
return {
|
2026-08-14 21:21:20 -04:00
|
|
|
nameText: (nameResult.data.text || '').trim(),
|
|
|
|
|
nameConfidence: Math.round(nameResult.data.confidence || 0),
|
|
|
|
|
cardNumber,
|
|
|
|
|
numberConfidence: Math.round(numberResult.data.confidence || 0),
|
2026-05-27 13:59:59 -04:00
|
|
|
};
|
|
|
|
|
} catch (error) {
|
2026-08-14 21:21:20 -04:00
|
|
|
console.warn('[ocr-worker] recognizeCardFields failed:', error);
|
|
|
|
|
return { nameText: '', nameConfidence: 0, cardNumber: '', numberConfidence: 0 };
|
2026-05-27 13:59:59 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function terminateOcrWorker() {
|
2026-09-01 18:17:20 -04:00
|
|
|
const workers = [workerPromise, numberWorkerPromise];
|
2026-05-27 13:59:59 -04:00
|
|
|
workerPromise = null;
|
2026-09-01 18:17:20 -04:00
|
|
|
numberWorkerPromise = null;
|
|
|
|
|
await Promise.all(
|
|
|
|
|
workers.filter(Boolean).map(async (promise) => {
|
|
|
|
|
try {
|
|
|
|
|
const worker = await promise;
|
|
|
|
|
await worker.terminate();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore teardown errors
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-05-27 13:59:59 -04:00
|
|
|
}
|