id-scanner-lib
Version:
一款纯前端实现的TypeScript身份证&二维码识别库,无需后端支持,所有处理在浏览器端完成,新增图像批处理与优化
547 lines (469 loc) • 14.3 kB
text/typescript
/**
* @file 图像处理工具类
* @description 提供图像预处理功能,用于提高OCR识别率
* @module ImageProcessor
*/
import imageCompression from "browser-image-compression"
/**
* 图像处理器配置选项
*/
export interface ImageProcessorOptions {
brightness?: number // 亮度调整,范围 -100 到 100
contrast?: number // 对比度调整,范围 -100 到 100
grayscale?: boolean // 是否转换为灰度图
invert?: boolean // 是否反转颜色
blur?: number // 模糊程度 (0-10)
sharpen?: 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): HTMLCanvasElement {
const canvas = document.createElement("canvas")
canvas.width = imageData.width
canvas.height = imageData.height
const ctx = canvas.getContext("2d")
if (ctx) {
ctx.putImageData(imageData, 0, 0)
}
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
}
/**
* 将图像转换为灰度图
*
* @param imageData 原始图像数据
* @returns 灰度图像数据
*/
static toGrayscale(imageData: ImageData): ImageData {
const data = imageData.data
const length = data.length
for (let i = 0; i < length; i += 4) {
// 使用加权平均法将 RGB 转换为灰度值
const gray = data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11
data[i] = data[i + 1] = data[i + 2] = gray
}
return imageData
}
/**
* 锐化图像
*
* @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] // 保持透明度不变
}
}
// 处理边缘像素
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (y === 0 || y === height - 1 || x === 0 || x === width - 1) {
const pos = (y * width + x) * 4
outputData[pos] = data[pos]
outputData[pos + 1] = data[pos + 1]
outputData[pos + 2] = data[pos + 2]
outputData[pos + 3] = data[pos + 3]
}
}
}
// 创建新的ImageData对象
return new ImageData(outputData, width, height)
}
/**
* 对图像应用阈值操作,增强对比度
*
* @param imageData 原始图像数据
* @param threshold 阈值 (0-255)
* @returns 处理后的图像数据
*/
static threshold(imageData: ImageData, threshold: number = 128): ImageData {
// 先转换为灰度图
const grayscaleImage = this.toGrayscale(
new ImageData(
new Uint8ClampedArray(imageData.data),
imageData.width,
imageData.height
)
)
const data = grayscaleImage.data
const length = data.length
for (let i = 0; i < length; i += 4) {
// 二值化处理
const value = data[i] < threshold ? 0 : 255
data[i] = data[i + 1] = data[i + 2] = value
}
return grayscaleImage
}
/**
* 将图像转换为黑白图像(二值化)
*
* @param imageData 原始图像数据
* @returns 二值化后的图像数据
*/
static toBinaryImage(imageData: ImageData): ImageData {
// 先转换为灰度图
const grayscaleImage = this.toGrayscale(
new ImageData(
new Uint8ClampedArray(imageData.data),
imageData.width,
imageData.height
)
)
// 使用OTSU算法自动确定阈值
const threshold = this.getOtsuThreshold(grayscaleImage)
return this.threshold(grayscaleImage, threshold)
}
/**
* 使用OTSU算法计算最佳阈值
*
* @param imageData 灰度图像数据
* @returns 最佳阈值
*/
private static getOtsuThreshold(imageData: ImageData): number {
const data = imageData.data
const histogram = new Array(256).fill(0)
// 统计灰度直方图
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元素
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
if (!ctx) {
reject(new Error("无法创建2D上下文"))
return
}
canvas.width = img.width
canvas.height = img.height
// 绘制图片到canvas
ctx.drawImage(img, 0, 0)
// 获取图像数据
const imageData = ctx.getImageData(
0,
0,
canvas.width,
canvas.height
)
// 释放资源
URL.revokeObjectURL(url)
resolve(imageData)
} catch (e) {
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 {
const canvas = document.createElement("canvas")
canvas.width = imageData.width
canvas.height = imageData.height
const ctx = canvas.getContext("2d")
if (!ctx) {
reject(new Error("无法创建2D上下文"))
return
}
ctx.putImageData(imageData, 0, 0)
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("无法创建图片Blob"))
return
}
const file = new File([blob], fileName, { type: fileType })
resolve(file)
},
fileType,
quality
)
} catch (error) {
reject(error)
}
})
}
/**
* 调整图像大小
*
* @param imageData 原始图像数据
* @param maxWidth 最大宽度
* @param maxHeight 最大高度
* @param maintainAspectRatio 是否保持宽高比
* @returns ImageData 调整大小后的图像数据
*/
static resizeImage(
imageData: ImageData,
maxWidth: number,
maxHeight: number,
maintainAspectRatio: boolean = true
): ImageData {
const { width, height } = imageData
// 如果图像已经小于指定大小,则不需要调整
if (width <= maxWidth && height <= maxHeight) {
return imageData
}
let newWidth = maxWidth
let newHeight = maxHeight
// 计算新的尺寸,保持宽高比
if (maintainAspectRatio) {
const ratio = Math.min(maxWidth / width, maxHeight / height)
newWidth = Math.floor(width * ratio)
newHeight = Math.floor(height * ratio)
}
// 创建用于调整大小的Canvas
const canvas = document.createElement("canvas")
canvas.width = newWidth
canvas.height = newHeight
const ctx = canvas.getContext("2d")
if (!ctx) {
throw new Error("无法创建2D上下文")
}
// 创建临时Canvas绘制原始ImageData
const tempCanvas = document.createElement("canvas")
tempCanvas.width = width
tempCanvas.height = height
const tempCtx = tempCanvas.getContext("2d")
if (!tempCtx) {
throw new Error("无法创建临时2D上下文")
}
tempCtx.putImageData(imageData, 0, 0)
// 使用缩放平滑算法
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = "high"
// 绘制调整大小的图像
ctx.drawImage(tempCanvas, 0, 0, width, height, 0, 0, newWidth, newHeight)
// 获取新的ImageData
return ctx.getImageData(0, 0, newWidth, newHeight)
}
}