Files
flyer-crawler.projectium.com/src/services/barcodeService.server.ts
Torben Sorensen 11aeac5edd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
whoa - so much - new features (UPC,etc) - Sentry for app logging! so much more !
2026-01-11 19:07:02 -08:00

336 lines
9.1 KiB
TypeScript

// src/services/barcodeService.server.ts
/**
* @file Barcode Detection Service
* Provides barcode/UPC detection from images using zxing-wasm.
* Supports UPC-A, UPC-E, EAN-13, EAN-8, CODE-128, CODE-39, and QR codes.
*/
import type { Logger } from 'pino';
import type { Job } from 'bullmq';
import type { BarcodeDetectionJobData } from '../types/job-data';
import type { BarcodeDetectionResult } from '../types/upc';
import { upcRepo } from './db/index.db';
import sharp from 'sharp';
import fs from 'node:fs/promises';
/**
* Supported barcode formats for detection.
*/
export type BarcodeFormat =
| 'UPC-A'
| 'UPC-E'
| 'EAN-13'
| 'EAN-8'
| 'CODE-128'
| 'CODE-39'
| 'QR_CODE'
| 'unknown';
/**
* Maps zxing-wasm format names to our BarcodeFormat type.
*/
const formatMap: Record<string, BarcodeFormat> = {
'UPC-A': 'UPC-A',
'UPC-E': 'UPC-E',
'EAN-13': 'EAN-13',
'EAN-8': 'EAN-8',
Code128: 'CODE-128',
Code39: 'CODE-39',
QRCode: 'QR_CODE',
};
/**
* Detects barcodes in an image using zxing-wasm.
*
* @param imagePath Path to the image file
* @param logger Pino logger instance
* @returns Detection result with UPC code if found
*/
export const detectBarcode = async (
imagePath: string,
logger: Logger,
): Promise<BarcodeDetectionResult> => {
const detectionLogger = logger.child({ imagePath });
detectionLogger.info('Starting barcode detection');
try {
// Dynamically import zxing-wasm (ES module)
const { readBarcodesFromImageData } = await import('zxing-wasm/reader');
// Read and process the image with sharp
const imageBuffer = await fs.readFile(imagePath);
// Convert to raw pixel data (RGBA)
const image = sharp(imageBuffer);
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) {
detectionLogger.warn('Could not determine image dimensions');
return {
detected: false,
upc_code: null,
confidence: null,
format: null,
error: 'Could not determine image dimensions',
};
}
// Convert to raw RGBA pixels
const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
// Create ImageData-like object for zxing-wasm
const imageData = {
data: new Uint8ClampedArray(data),
width: info.width,
height: info.height,
colorSpace: 'srgb' as const,
};
detectionLogger.debug(
{ width: info.width, height: info.height },
'Processing image for barcode detection',
);
// Attempt barcode detection
const results = await readBarcodesFromImageData(imageData as ImageData, {
tryHarder: true,
tryRotate: true,
tryInvert: true,
formats: ['UPC-A', 'UPC-E', 'EAN-13', 'EAN-8', 'Code128', 'Code39'],
});
if (results.length === 0) {
detectionLogger.info('No barcode detected in image');
return {
detected: false,
upc_code: null,
confidence: null,
format: null,
error: null,
};
}
// Take the first (best) result
const bestResult = results[0];
const format = formatMap[bestResult.format] || 'unknown';
// Calculate confidence based on result quality indicators
// zxing-wasm doesn't provide direct confidence, so we estimate based on format match
const confidence = bestResult.text ? 0.95 : 0.5;
detectionLogger.info(
{ upcCode: bestResult.text, format, confidence },
'Barcode detected successfully',
);
return {
detected: true,
upc_code: bestResult.text,
confidence,
format,
error: null,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
detectionLogger.error({ err: error }, 'Barcode detection failed');
return {
detected: false,
upc_code: null,
confidence: null,
format: null,
error: errorMessage,
};
}
};
/**
* Validates a UPC code format.
* @param code The code to validate
* @returns True if valid UPC format
*/
export const isValidUpcFormat = (code: string): boolean => {
// UPC-A: 12 digits
// UPC-E: 8 digits
// EAN-13: 13 digits
// EAN-8: 8 digits
return /^[0-9]{8,14}$/.test(code);
};
/**
* Calculates the check digit for a UPC-A code.
* @param code The 11-digit UPC-A code (without check digit)
* @returns The check digit
*/
export const calculateUpcCheckDigit = (code: string): number | null => {
if (code.length !== 11 || !/^\d+$/.test(code)) {
return null;
}
let sum = 0;
for (let i = 0; i < 11; i++) {
const digit = parseInt(code[i], 10);
// Odd positions (0, 2, 4, ...) multiplied by 3
// Even positions (1, 3, 5, ...) multiplied by 1
sum += digit * (i % 2 === 0 ? 3 : 1);
}
const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit;
};
/**
* Validates a UPC code including check digit.
* @param code The complete UPC code
* @returns True if check digit is valid
*/
export const validateUpcCheckDigit = (code: string): boolean => {
if (code.length !== 12 || !/^\d+$/.test(code)) {
return false;
}
const codeWithoutCheck = code.slice(0, 11);
const providedCheck = parseInt(code[11], 10);
const calculatedCheck = calculateUpcCheckDigit(codeWithoutCheck);
return calculatedCheck === providedCheck;
};
/**
* Processes a barcode detection job from the queue.
* @param job The BullMQ job
* @param logger Pino logger instance
* @returns Detection result
*/
export const processBarcodeDetectionJob = async (
job: Job<BarcodeDetectionJobData>,
logger: Logger,
): Promise<BarcodeDetectionResult> => {
const { scanId, imagePath, userId } = job.data;
const jobLogger = logger.child({
jobId: job.id,
scanId,
userId,
requestId: job.data.meta?.requestId,
});
jobLogger.info('Processing barcode detection job');
try {
// Attempt barcode detection
const result = await detectBarcode(imagePath, jobLogger);
// If a code was detected, update the scan record
if (result.detected && result.upc_code) {
await upcRepo.updateScanWithDetectedCode(
scanId,
result.upc_code,
result.confidence,
jobLogger,
);
jobLogger.info(
{ upcCode: result.upc_code, confidence: result.confidence },
'Barcode detected and scan record updated',
);
} else {
jobLogger.info('No barcode detected in image');
}
return result;
} catch (error) {
jobLogger.error({ err: error }, 'Barcode detection job failed');
return {
detected: false,
upc_code: null,
confidence: null,
format: null,
error: error instanceof Error ? error.message : String(error),
};
}
};
/**
* Detects multiple barcodes in an image.
* Useful for receipts or product lists with multiple items.
* @param imagePath Path to the image file
* @param logger Pino logger instance
* @returns Array of detection results
*/
export const detectMultipleBarcodes = async (
imagePath: string,
logger: Logger,
): Promise<BarcodeDetectionResult[]> => {
const detectionLogger = logger.child({ imagePath });
detectionLogger.info('Starting multiple barcode detection');
try {
const { readBarcodesFromImageData } = await import('zxing-wasm/reader');
// Read and process the image
const imageBuffer = await fs.readFile(imagePath);
const image = sharp(imageBuffer);
const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const imageData = {
data: new Uint8ClampedArray(data),
width: info.width,
height: info.height,
colorSpace: 'srgb' as const,
};
// Detect all barcodes
const results = await readBarcodesFromImageData(imageData as ImageData, {
tryHarder: true,
tryRotate: true,
tryInvert: true,
formats: ['UPC-A', 'UPC-E', 'EAN-13', 'EAN-8', 'Code128', 'Code39'],
});
detectionLogger.info({ count: results.length }, 'Multiple barcode detection complete');
return results.map((result) => ({
detected: true,
upc_code: result.text,
confidence: 0.95,
format: formatMap[result.format] || 'unknown',
error: null,
}));
} catch (error) {
detectionLogger.error({ err: error }, 'Multiple barcode detection failed');
return [];
}
};
/**
* Enhances image for better barcode detection.
* Applies preprocessing like grayscale conversion, contrast adjustment, etc.
* @param imagePath Path to the source image
* @param logger Pino logger instance
* @returns Path to enhanced image (or original if enhancement fails)
*/
export const enhanceImageForDetection = async (
imagePath: string,
logger: Logger,
): Promise<string> => {
const detectionLogger = logger.child({ imagePath });
try {
// Create enhanced version with improved contrast for barcode detection
const enhancedPath = imagePath.replace(/(\.[^.]+)$/, '-enhanced$1');
await sharp(imagePath)
.grayscale()
.normalize() // Improve contrast
.sharpen() // Enhance edges
.toFile(enhancedPath);
detectionLogger.debug({ enhancedPath }, 'Image enhanced for barcode detection');
return enhancedPath;
} catch (error) {
detectionLogger.warn({ err: error }, 'Image enhancement failed, using original');
return imagePath;
}
};