id-scanner-lib
Version:
Browser-based ID card, QR code, and face recognition scanner with liveness detection
594 lines (512 loc) • 17.6 kB
text/typescript
/**
* @file 图像处理工具类
* @description 提供图像预处理功能,用于提高OCR识别率
* @module ImageProcessor
* @version 1.4.0
*/
import imageCompression from "browser-image-compression"
import { Point, Rect, ImageProcessingOptions } from './types';
import { CanvasPool } from './canvas-pool';
import { EdgeDetector } from './edge-detector';
/**
* 图像处理器配置选项
*/
export interface ImageProcessorOptions {
brightness?: number // 亮度调整,范围 -100 到 100
contrast?: number // 对比度调整,范围 -100 到 100
grayscale?: boolean // 是否转换为灰度图
invert?: boolean // 是否反转颜色
blur?: number // 模糊程度 (0-10)
sharpen?: boolean // 是否锐化
/** 是否使用 Canvas 对象池(减少内存分配) */
usePool?: boolean;
}
/**
* 图像压缩选项
*/
export interface ImageCompressionOptions {
maxSizeMB?: number // 图片最大大小,MB
maxWidthOrHeight?: number // 图片最大宽度或高度
useWebWorker?: boolean // 是否使用Web Worker处理
maxIteration?: number // 最大压缩迭代次数
quality?: number // 输出质量 (0-1)
fileType?: string // 输出文件类型 ('image/jpeg', 'image/png' 等)
}
/**
* 图像处理工具类
*
* 提供各种图像处理功能,用于优化识别效果
*/
export class ImageProcessor {
/**
* 将ImageData转换为Canvas元素
*
* @param {ImageData} imageData - 要转换的图像数据
* @returns {HTMLCanvasElement} 包含图像的Canvas元素
*/
static imageDataToCanvas(imageData: ImageData, usePool: boolean = true): HTMLCanvasElement {
let canvas: HTMLCanvasElement;
let context: CanvasRenderingContext2D;
if (usePool) {
({ canvas, context } = CanvasPool.getInstance().acquire(imageData.width, imageData.height));
} else {
canvas = document.createElement("canvas");
canvas.width = imageData.width;
canvas.height = imageData.height;
context = canvas.getContext("2d")!;
}
context.putImageData(imageData, 0, 0);
if (usePool) {
// 立即释放回池中,用户保留 canvas 引用即可
CanvasPool.getInstance().release(canvas);
}
return canvas;
}
/**
* 将Canvas转换为ImageData
*
* @param {HTMLCanvasElement} canvas - 要转换的Canvas元素
* @returns {ImageData|null} Canvas的图像数据,如果获取失败则返回null
*/
static canvasToImageData(canvas: HTMLCanvasElement): ImageData | null {
const ctx = canvas.getContext("2d")
return ctx ? ctx.getImageData(0, 0, canvas.width, canvas.height) : null
}
/**
* 调整图像亮度和对比度
*
* @param imageData 原始图像数据
* @param brightness 亮度调整值 (-100到100)
* @param contrast 对比度调整值 (-100到100)
* @returns 处理后的图像数据
*/
static adjustBrightnessContrast(
imageData: ImageData,
brightness: number = 0,
contrast: number = 0
): ImageData {
// 将亮度和对比度范围限制在 -100 到 100 之间
brightness = Math.max(-100, Math.min(100, brightness))
contrast = Math.max(-100, Math.min(100, contrast))
// 将范围转换为适合计算的值
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast))
const briAdjust = (brightness / 100) * 255
const data = imageData.data
const length = data.length
for (let i = 0; i < length; i += 4) {
// 分别处理 RGB 三个通道
for (let j = 0; j < 3; j++) {
// 应用亮度和对比度调整公式
const newValue = factor * (data[i + j] + briAdjust - 128) + 128
data[i + j] = Math.max(0, Math.min(255, newValue))
}
// Alpha 通道保持不变
}
return imageData
}
/**
* 将图像转换为灰度图(返回新 ImageData,不修改原图)
*
* @param imageData 原始图像数据
* @returns 灰度图像数据(新对象)
*/
static toGrayscale(imageData: ImageData): ImageData {
const srcData = imageData.data
const length = srcData.length
// 创建新数组,避免修改原图
const destData = new Uint8ClampedArray(srcData)
for (let i = 0; i < length; i += 4) {
// 使用加权平均法将 RGB 转换为灰度值
const gray = srcData[i] * 0.3 + srcData[i + 1] * 0.59 + srcData[i + 2] * 0.11
destData[i] = destData[i + 1] = destData[i + 2] = gray
// Alpha 通道保持不变
destData[i + 3] = srcData[i + 3]
}
return new ImageData(destData, imageData.width, imageData.height)
}
/**
* 锐化图像
*
* @param imageData 原始图像数据
* @param amount 锐化程度,默认为2
* @returns 锐化后的图像数据
*/
static sharpen(imageData: ImageData, amount: number = 2): ImageData {
if (!imageData || !imageData.data) return imageData
const width = imageData.width
const height = imageData.height
const data = imageData.data
const outputData = new Uint8ClampedArray(data.length)
// 锐化卷积核
const kernel = [
0,
-amount,
0,
-amount,
1 + 4 * amount,
-amount,
0,
-amount,
0,
]
// 应用卷积
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const pos = (y * width + x) * 4
// 对每个通道应用卷积
for (let c = 0; c < 3; c++) {
let val = 0
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const kernelPos = (ky + 1) * 3 + (kx + 1)
const dataPos = ((y + ky) * width + (x + kx)) * 4 + c
val += data[dataPos] * kernel[kernelPos]
}
}
outputData[pos + c] = Math.max(0, Math.min(255, val))
}
outputData[pos + 3] = data[pos + 3] // 保持透明度不变
}
}
// 处理边缘像素(仅遍历四条边,而非全图 O(width×height) → O(width+height))
// 上边 + 下边
for (let x = 0; x < width; x++) {
const topPos = x * 4
const bottomPos = ((height - 1) * width + x) * 4
outputData[topPos] = data[topPos]; outputData[topPos + 1] = data[topPos + 1]; outputData[topPos + 2] = data[topPos + 2]; outputData[topPos + 3] = data[topPos + 3]
outputData[bottomPos] = data[bottomPos]; outputData[bottomPos + 1] = data[bottomPos + 1]; outputData[bottomPos + 2] = data[bottomPos + 2]; outputData[bottomPos + 3] = data[bottomPos + 3]
}
// 左边 + 右边(排除四角,它们已在上下一行处理)
for (let y = 1; y < height - 1; y++) {
const leftPos = y * width * 4
const rightPos = (y * width + width - 1) * 4
outputData[leftPos] = data[leftPos]; outputData[leftPos + 1] = data[leftPos + 1]; outputData[leftPos + 2] = data[leftPos + 2]; outputData[leftPos + 3] = data[leftPos + 3]
outputData[rightPos] = data[rightPos]; outputData[rightPos + 1] = data[rightPos + 1]; outputData[rightPos + 2] = data[rightPos + 2]; outputData[rightPos + 3] = data[rightPos + 3]
}
// 创建新的ImageData对象
return new ImageData(outputData, width, height)
}
/**
* 对图像应用阈值操作,增强对比度(二值化)
*
* @param imageData 原始图像数据
* @param threshold 阈值 (0-255)
* @returns 处理后的图像数据(新对象,不修改原图)
*/
static threshold(imageData: ImageData, threshold: number = 128): ImageData {
// 先转换为灰度图(返回新 ImageData,不修改原图)
const grayscaleImage = this.toGrayscale(imageData)
const srcData = grayscaleImage.data
const length = srcData.length
// 创建新数组存储二值化结果
const destData = new Uint8ClampedArray(length)
for (let i = 0; i < length; i += 4) {
// 二值化处理
const value = srcData[i] < threshold ? 0 : 255
destData[i] = destData[i + 1] = destData[i + 2] = value
destData[i + 3] = srcData[i + 3] // 保持透明度
}
return new ImageData(destData, grayscaleImage.width, grayscaleImage.height)
}
/**
* 将图像转换为黑白图像(二值化,使用OTSU自动阈值)
*
* @param imageData 原始图像数据
* @returns 二值化后的图像数据(新对象,不修改原图)
*/
static toBinaryImage(imageData: ImageData): ImageData {
// 先转换为灰度图(返回新 ImageData,不修改原图)
const grayscaleImage = this.toGrayscale(imageData)
// 使用OTSU算法自动确定阈值
const threshold = this.getOtsuThreshold(grayscaleImage)
// 直接对灰度图进行二值化,避免再次调用 toGrayscale
const srcData = grayscaleImage.data
const length = srcData.length
const destData = new Uint8ClampedArray(length)
for (let i = 0; i < length; i += 4) {
const value = srcData[i] < threshold ? 0 : 255
destData[i] = destData[i + 1] = destData[i + 2] = value
destData[i + 3] = srcData[i + 3] // 保持透明度
}
return new ImageData(destData, grayscaleImage.width, grayscaleImage.height)
}
/**
* 使用OTSU算法计算最佳阈值
*
* @param imageData 灰度图像数据
* @returns 最佳阈值
*/
private static getOtsuThreshold(imageData: ImageData): number {
const data = imageData.data
// 使用 Uint8Array 替代 Array<number>,避免 boxing 开销,提升直方图统计性能
const histogram = new Uint32Array(256)
// 统计灰度直方图(每4字节取R通道,即灰度值)
for (let i = 0; i < data.length; i += 4) {
histogram[data[i]]++
}
const total = imageData.width * imageData.height
let sum = 0
// 计算总灰度值和
for (let i = 0; i < 256; i++) {
sum += i * histogram[i]
}
let sumB = 0
let wB = 0
let wF = 0
let maxVariance = 0
let threshold = 0
// 遍历所有可能的阈值,找到最大类间方差
for (let t = 0; t < 256; t++) {
wB += histogram[t] // 背景权重
if (wB === 0) continue
wF = total - wB // 前景权重
if (wF === 0) break
sumB += t * histogram[t]
const mB = sumB / wB // 背景平均灰度
const mF = (sum - sumB) / wF // 前景平均灰度
// 计算类间方差
const variance = wB * wF * (mB - mF) * (mB - mF)
if (variance > maxVariance) {
maxVariance = variance
threshold = t
}
}
return threshold
}
/**
* 批量应用图像处理
*
* @param imageData 原始图像数据
* @param options 处理选项
* @returns 处理后的图像数据
*/
static batchProcess(
imageData: ImageData,
options: ImageProcessorOptions
): ImageData {
let processedImage = new ImageData(
new Uint8ClampedArray(imageData.data),
imageData.width,
imageData.height
)
// 应用亮度和对比度调整
if (options.brightness !== undefined || options.contrast !== undefined) {
processedImage = this.adjustBrightnessContrast(
processedImage,
options.brightness || 0,
options.contrast || 0
)
}
// 应用灰度转换
if (options.grayscale) {
processedImage = this.toGrayscale(processedImage)
}
// 应用锐化
if (options.sharpen) {
processedImage = this.sharpen(processedImage)
}
// 应用颜色反转
if (options.invert) {
const data = processedImage.data
for (let i = 0; i < data.length; i += 4) {
// 反转RGB值
data[i] = 255 - data[i]
data[i + 1] = 255 - data[i + 1]
data[i + 2] = 255 - data[i + 2]
// Alpha通道保持不变
}
}
return processedImage
}
/**
* 压缩图片文件
*
* @param file 图片文件
* @param options 压缩选项
* @returns Promise<File> 压缩后的文件
*/
static async compressImage(
file: File,
options?: ImageCompressionOptions
): Promise<File> {
const defaultOptions = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
quality: 0.8,
fileType: file.type || "image/jpeg",
}
const compressOptions = { ...defaultOptions, ...options }
try {
return await imageCompression(file, compressOptions)
} catch (error) {
console.error("图片压缩失败:", error)
return file // 如果压缩失败,返回原始文件
}
}
/**
* 从图片文件创建ImageData
*
* @param file 图片文件
* @returns Promise<ImageData>
*/
static async createImageDataFromFile(file: File): Promise<ImageData> {
return new Promise((resolve, reject) => {
try {
const img = new Image()
const url = URL.createObjectURL(file)
img.onload = () => {
try {
// 使用 Canvas 池获取 canvas
const { canvas, context } = CanvasPool.getInstance().acquire(img.width, img.height);
// 绘制图片到canvas
context.drawImage(img, 0, 0);
// 获取图像数据
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
// 释放回池
CanvasPool.getInstance().release(canvas);
// 释放资源
URL.revokeObjectURL(url);
resolve(imageData);
} catch (e) {
URL.revokeObjectURL(url);
reject(e);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("图片加载失败"));
};
img.src = url;
} catch (error) {
reject(error);
}
});
}
/**
* 将ImageData转换为File对象
*
* @param imageData ImageData对象
* @param fileName 输出文件名
* @param fileType 输出文件类型
* @param quality 图片质量 (0-1)
* @returns Promise<File>
*/
static async imageDataToFile(
imageData: ImageData,
fileName: string = "image.jpg",
fileType: string = "image/jpeg",
quality: number = 0.8
): Promise<File> {
return new Promise((resolve, reject) => {
try {
// 使用 Canvas 池
const { canvas, context } = CanvasPool.getInstance().acquire(imageData.width, imageData.height);
context.putImageData(imageData, 0, 0);
canvas.toBlob(
(blob) => {
// 释放回池
CanvasPool.getInstance().release(canvas);
if (!blob) {
reject(new Error("无法创建图片Blob"));
return;
}
const file = new File([blob], fileName, { type: fileType });
resolve(file)
},
fileType,
quality
)
} catch (error) {
reject(error)
}
})
}
/**
* 将图像调整到指定大小
* @param image 输入图像
* @param maxWidth 最大宽度
* @param maxHeight 最大高度
* @param keepAspectRatio 是否保持宽高比
* @returns 调整后的图像
*/
public static resizeImage(
image: ImageData | HTMLImageElement | HTMLCanvasElement,
maxWidth: number,
maxHeight: number,
keepAspectRatio: boolean = true
): ImageData {
// 创建canvas元素
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法创建Canvas上下文');
}
// 获取图像尺寸
let width: number;
let height: number;
if (image instanceof ImageData) {
width = image.width;
height = image.height;
} else {
width = image.width;
height = image.height;
}
// 计算调整后的尺寸
let newWidth = width;
let newHeight = height;
if (keepAspectRatio) {
if (width > height) {
if (width > maxWidth) {
newHeight = Math.round(height * (maxWidth / width));
newWidth = maxWidth;
}
} else {
if (height > maxHeight) {
newWidth = Math.round(width * (maxHeight / height));
newHeight = maxHeight;
}
}
} else {
newWidth = Math.min(width, maxWidth);
newHeight = Math.min(height, maxHeight);
}
// 设置canvas尺寸
canvas.width = newWidth;
canvas.height = newHeight;
// 绘制调整后的图像
if (image instanceof ImageData) {
// 创建临时canvas存储ImageData
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
throw new Error('无法创建临时Canvas上下文');
}
tempCanvas.width = image.width;
tempCanvas.height = image.height;
tempCtx.putImageData(image, 0, 0);
// 绘制调整后的图像
ctx.drawImage(tempCanvas, 0, 0, width, height, 0, 0, newWidth, newHeight);
} else {
ctx.drawImage(image, 0, 0, width, height, 0, 0, newWidth, newHeight);
}
// 返回调整后的ImageData
return ctx.getImageData(0, 0, newWidth, newHeight);
}
/**
* @deprecated 请使用 EdgeDetector.detectEdges()
*/
static detectEdges(imageData: ImageData, threshold: number = 30): ImageData {
return EdgeDetector.detectEdges(imageData, threshold);
}
/**
* @deprecated 请使用 EdgeDetector.cannyEdgeDetection()
*/
static cannyEdgeDetection(
imageData: ImageData,
lowThreshold: number = 20,
highThreshold: number = 50
): ImageData {
return EdgeDetector.cannyEdgeDetection(imageData, lowThreshold, highThreshold);
}
}