package com.ruoyi.common.utils.image; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.multipart.MultipartFile; import javax.imageio.IIOImage; import javax.imageio.ImageIO; import javax.imageio.ImageWriteParam; import javax.imageio.ImageWriter; import javax.imageio.stream.ImageOutputStream; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.Iterator; /** * 图片压缩工具类 * 专为OCR识别优化,在保证文字清晰度的前提下压缩图片大小 * * @author ruoyi * @date 2025-01-20 */ public class ImageCompressUtil { private static final Logger log = LoggerFactory.getLogger(ImageCompressUtil.class); /** * 默认最大文件大小(10MB) */ public static final long DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; /** * OCR最佳识别分辨率(最小边像素) */ public static final int OCR_OPTIMAL_MIN_SIZE = 1500; /** * 高质量压缩质量参数(0.90 = 90%质量) */ public static final float HIGH_QUALITY = 0.90f; /** * 中等质量压缩质量参数(0.80 = 80%质量) */ public static final float MEDIUM_QUALITY = 0.80f; /** * 低质量压缩质量参数(0.75 = 75%质量) */ public static final float LOW_QUALITY = 0.75f; /** * 智能压缩图片(针对OCR优化) * 使用默认3MB限制 * * @param file 上传的图片文件 * @return 压缩后的临时文件 * @throws IOException IO异常 */ public static File compressForOCR(MultipartFile file) throws IOException { return compressForOCR(file, DEFAULT_MAX_FILE_SIZE); } /** * 智能压缩图片(针对OCR优化) * 保证识别准确率的同时压缩到指定大小以下 * * @param file 上传的图片文件 * @param maxSize 最大文件大小(字节) * @return 压缩后的临时文件 * @throws IOException IO异常 */ public static File compressForOCR(MultipartFile file, long maxSize) throws IOException { long fileSize = file.getSize(); // 如果文件小于限制,直接保存 if (fileSize <= maxSize) { log.debug("图片大小 {} KB 未超过限制 {} KB,无需压缩", fileSize / 1024.0, maxSize / 1024.0); return saveToTempFile(file); } log.info("图片大小 {} MB 超过限制 {} MB,开始智能压缩...", fileSize / 1024.0 / 1024.0, maxSize / 1024.0 / 1024.0); // 读取原始图片 BufferedImage originalImage = ImageIO.read(file.getInputStream()); if (originalImage == null) { throw new IOException("无法读取图片文件,可能格式不支持"); } int originalWidth = originalImage.getWidth(); int originalHeight = originalImage.getHeight(); log.info("原始图片尺寸: {}x{}", originalWidth, originalHeight); // 策略1:先尝试高质量JPEG压缩(不改变尺寸) File compressedFile = compressWithQuality(originalImage, file.getOriginalFilename(), HIGH_QUALITY); if (compressedFile.length() <= maxSize) { log.info("压缩成功(高质量压缩),文件大小: {} MB", compressedFile.length() / 1024.0 / 1024.0); return compressedFile; } // 策略2:如果还是太大,适度缩小尺寸(保证OCR识别) double scaleFactor = calculateScaleFactor(originalWidth, originalHeight, maxSize, compressedFile.length()); if (scaleFactor < 1.0) { int newWidth = (int) (originalWidth * scaleFactor); int newHeight = (int) (originalHeight * scaleFactor); log.info("调整图片尺寸至: {}x{} (缩放比例: {}%)", newWidth, newHeight, (int)(scaleFactor * 100)); BufferedImage resizedImage = resizeImageHighQuality(originalImage, newWidth, newHeight); compressedFile.delete(); // 删除之前的临时文件 compressedFile = compressWithQuality(resizedImage, file.getOriginalFilename(), HIGH_QUALITY); } // 策略3:如果还是太大,降低压缩质量(最后手段) if (compressedFile.length() > maxSize) { log.warn("尺寸调整后仍超限,降低压缩质量"); BufferedImage finalImage; if (scaleFactor < 1.0) { int newWidth = (int) (originalWidth * scaleFactor); int newHeight = (int) (originalHeight * scaleFactor); finalImage = resizeImageHighQuality(originalImage, newWidth, newHeight); } else { finalImage = originalImage; } compressedFile.delete(); // 删除之前的临时文件 // 尝试中等质量 compressedFile = compressWithQuality(finalImage, file.getOriginalFilename(), MEDIUM_QUALITY); // 如果还不行,使用低质量 if (compressedFile.length() > maxSize) { log.warn("使用低质量压缩(75%)"); compressedFile.delete(); compressedFile = compressWithQuality(finalImage, file.getOriginalFilename(), LOW_QUALITY); } } long finalSize = compressedFile.length(); double compressionRatio = (1 - (double)finalSize / fileSize) * 100; log.info("最终压缩完成,文件大小: {} MB (压缩率: {}%)", finalSize / 1024.0 / 1024.0, (int)compressionRatio); if (finalSize > maxSize) { log.warn("警告:压缩后文件大小 {} MB 仍超过限制 {} MB", finalSize / 1024.0 / 1024.0, maxSize / 1024.0 / 1024.0); } return compressedFile; } /** * 使用指定质量压缩图片为JPEG格式 * * @param image 图片对象 * @param originalFilename 原始文件名 * @param quality 压缩质量(0.0-1.0) * @return 压缩后的文件 * @throws IOException IO异常 */ public static File compressWithQuality(BufferedImage image, String originalFilename, float quality) throws IOException { if (quality < 0.0f || quality > 1.0f) { throw new IllegalArgumentException("压缩质量必须在0.0到1.0之间"); } // 转换为RGB格式(JPEG不支持透明度) BufferedImage rgbImage = convertToRGB(image); // 创建临时文件 String tempDir = System.getProperty("java.io.tmpdir"); String filename = System.currentTimeMillis() + "_compressed_" + getJpegFilename(originalFilename); File outputFile = new File(tempDir, filename); // 使用ImageWriter进行高质量压缩 Iterator writers = ImageIO.getImageWritersByFormatName("jpg"); if (!writers.hasNext()) { throw new IOException("系统不支持JPEG编码"); } ImageWriter writer = writers.next(); ImageWriteParam param = writer.getDefaultWriteParam(); // 设置压缩模式和质量 param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionQuality(quality); try (ImageOutputStream ios = ImageIO.createImageOutputStream(outputFile)) { writer.setOutput(ios); writer.write(null, new IIOImage(rgbImage, null, null), param); } finally { writer.dispose(); } log.debug("压缩完成,质量: {}%, 文件大小: {} KB", (int)(quality * 100), outputFile.length() / 1024.0); return outputFile; } /** * 高质量图片缩放(专为OCR优化) * 使用双三次插值算法保证文字清晰度 * * @param originalImage 原始图片 * @param targetWidth 目标宽度 * @param targetHeight 目标高度 * @return 缩放后的图片 */ public static BufferedImage resizeImageHighQuality(BufferedImage originalImage, int targetWidth, int targetHeight) { if (targetWidth <= 0 || targetHeight <= 0) { throw new IllegalArgumentException("目标宽度和高度必须大于0"); } BufferedImage resizedImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB); Graphics2D g2d = resizedImage.createGraphics(); try { // 设置高质量渲染参数(关键!) g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY); g2d.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null); } finally { g2d.dispose(); } return resizedImage; } /** * 通用图片压缩(按目标宽高) * * @param file 上传的图片文件 * @param targetWidth 目标宽度 * @param targetHeight 目标高度 * @param quality 压缩质量(0.0-1.0) * @return 压缩后的文件 * @throws IOException IO异常 */ public static File compress(MultipartFile file, int targetWidth, int targetHeight, float quality) throws IOException { BufferedImage originalImage = ImageIO.read(file.getInputStream()); if (originalImage == null) { throw new IOException("无法读取图片文件"); } BufferedImage resizedImage = resizeImageHighQuality(originalImage, targetWidth, targetHeight); return compressWithQuality(resizedImage, file.getOriginalFilename(), quality); } /** * 按比例压缩图片 * * @param file 上传的图片文件 * @param scale 缩放比例(0.0-1.0) * @param quality 压缩质量(0.0-1.0) * @return 压缩后的文件 * @throws IOException IO异常 */ public static File compressByScale(MultipartFile file, double scale, float quality) throws IOException { if (scale <= 0.0 || scale > 1.0) { throw new IllegalArgumentException("缩放比例必须在0.0到1.0之间"); } BufferedImage originalImage = ImageIO.read(file.getInputStream()); if (originalImage == null) { throw new IOException("无法读取图片文件"); } int newWidth = (int) (originalImage.getWidth() * scale); int newHeight = (int) (originalImage.getHeight() * scale); BufferedImage resizedImage = resizeImageHighQuality(originalImage, newWidth, newHeight); return compressWithQuality(resizedImage, file.getOriginalFilename(), quality); } /** * 计算最佳缩放比例 * * @param width 原始宽度 * @param height 原始高度 * @param targetSize 目标文件大小 * @param currentSize 当前文件大小 * @return 缩放比例 */ private static double calculateScaleFactor(int width, int height, long targetSize, long currentSize) { int minDimension = Math.min(width, height); double scaleFactor = 1.0; // 基于文件大小估算缩放比例 double sizeRatio = Math.sqrt((double) targetSize / currentSize); // 保护最小尺寸(保证OCR识别) if (minDimension > 2000) { // 大图片,可以适度缩小 scaleFactor = Math.min(sizeRatio, 2000.0 / minDimension); } else if (minDimension > OCR_OPTIMAL_MIN_SIZE) { // 中等图片,轻微缩小 scaleFactor = Math.min(sizeRatio, (double) OCR_OPTIMAL_MIN_SIZE / minDimension); } else { // 小图片,尽量不缩小 scaleFactor = Math.min(sizeRatio, 0.95); } return scaleFactor; } /** * 将图片转换为RGB格式 * * @param image 原始图片 * @return RGB格式图片 */ private static BufferedImage convertToRGB(BufferedImage image) { if (image.getType() == BufferedImage.TYPE_INT_RGB) { return image; } BufferedImage rgbImage = new BufferedImage( image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB ); Graphics2D g = rgbImage.createGraphics(); try { g.drawImage(image, 0, 0, null); } finally { g.dispose(); } return rgbImage; } /** * 将文件名转换为JPEG格式 * * @param originalFilename 原始文件名 * @return JPEG文件名 */ private static String getJpegFilename(String originalFilename) { if (originalFilename == null || originalFilename.isEmpty()) { return "image.jpg"; } return originalFilename.replaceAll("\\.(png|PNG|gif|GIF|bmp|BMP|webp|WEBP)$", ".jpg"); } /** * 保存MultipartFile到临时文件 * * @param file 上传的文件 * @return 临时文件 * @throws IOException IO异常 */ private static File saveToTempFile(MultipartFile file) throws IOException { String tempDir = System.getProperty("java.io.tmpdir"); String originalFilename = file.getOriginalFilename(); File tempFile = new File(tempDir, System.currentTimeMillis() + "_" + originalFilename); file.transferTo(tempFile); return tempFile; } }