Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
336 lines
9.1 KiB
TypeScript
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;
|
|
}
|
|
};
|