|
|
@@ -126,10 +126,16 @@ public class ImageUtil {
|
|
|
|
|
|
/**
|
|
|
* 图片压缩参数配置
|
|
|
+ * - MAX_WIDTH/MAX_HEIGHT: 图片最大尺寸限制,超过则按比例缩放
|
|
|
+ * - INITIAL_QUALITY: 初始压缩质量(0.0-1.0),值越高质量越好但文件越大
|
|
|
+ * - MIN_QUALITY: 最低压缩质量,达到此值后停止迭代
|
|
|
+ * - QUALITY_STEP: 每次迭代降低的质量步长
|
|
|
*/
|
|
|
private static final int MAX_WIDTH = 1920;
|
|
|
private static final int MAX_HEIGHT = 1080;
|
|
|
- private static final float QUALITY = 0.7f;
|
|
|
+ private static final float INITIAL_QUALITY = 0.7f;
|
|
|
+ private static final float MIN_QUALITY = 0.1f;
|
|
|
+ private static final float QUALITY_STEP = 0.1f;
|
|
|
|
|
|
/**
|
|
|
* 判断文件是否为图片类型(通过 Content-Type 判断)
|
|
|
@@ -158,70 +164,175 @@ public class ImageUtil {
|
|
|
|
|
|
/**
|
|
|
* 图片压缩方法
|
|
|
+ *
|
|
|
* 压缩策略:
|
|
|
- * 1. 如果图片尺寸超过 MAX_WIDTH x MAX_HEIGHT,先按比例缩放
|
|
|
- * 2. 无论是否缩放,都进行质量压缩(压缩质量为 QUALITY)
|
|
|
- * 3. 保持原图片格式,PNG 保持透明通道
|
|
|
+ * 1. 尺寸压缩:如果图片尺寸超过 MAX_WIDTH x MAX_HEIGHT,先按比例缩放至最大尺寸以内
|
|
|
+ * 2. 迭代质量压缩:从 INITIAL_QUALITY(0.7) 开始逐步降低质量,直到压缩后文件小于原图或达到 MIN_QUALITY(0.1)
|
|
|
+ * 3. 格式降级:如果PNG格式压缩无效,自动尝试转为JPEG格式进行有损压缩
|
|
|
+ * 4. 透明通道处理:转为JPEG时,将透明通道填充为白色背景
|
|
|
*
|
|
|
* @param file 上传的图片文件
|
|
|
- * @return 压缩后的字节数组,压缩失败返回 null
|
|
|
+ * @return 压缩后的字节数组,压缩失败或压缩后更大则返回 null(调用方应使用原文件)
|
|
|
*/
|
|
|
public static byte[] compressImage(MultipartFile file) {
|
|
|
+ // 参数校验:文件为空直接返回null
|
|
|
if (file == null || file.isEmpty()) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
+ // 获取原图大小,用于后续压缩效果对比
|
|
|
+ long originalSize = file.getSize();
|
|
|
+
|
|
|
try (InputStream is = file.getInputStream()) {
|
|
|
+ // 读取图片为BufferedImage对象
|
|
|
BufferedImage originalImage = ImageIO.read(is);
|
|
|
if (originalImage == null) {
|
|
|
+ log.error("无法读取图片,ImageIO.read返回null,可能是不支持的图片格式");
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
+ // 获取图片原始尺寸
|
|
|
int width = originalImage.getWidth();
|
|
|
int height = originalImage.getHeight();
|
|
|
-
|
|
|
- // 如果图片尺寸在限制范围内,直接进行质量压缩
|
|
|
- if (width <= MAX_WIDTH && height <= MAX_HEIGHT) {
|
|
|
- return compressWithQuality(originalImage, file.getOriginalFilename());
|
|
|
+ log.debug("图片原始尺寸: {} x {}, 文件大小: {} bytes", width, height, originalSize);
|
|
|
+
|
|
|
+ // 默认使用原图,如需缩放则替换
|
|
|
+ BufferedImage targetImage = originalImage;
|
|
|
+
|
|
|
+ // 步骤1:尺寸压缩 - 超过最大尺寸则按比例缩放
|
|
|
+ if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
|
|
+ // 计算缩放比例,保持宽高比,取最小比例确保不超过任一维度限制
|
|
|
+ double scale = Math.min((double) MAX_WIDTH / width, (double) MAX_HEIGHT / height);
|
|
|
+ int newWidth = (int) (width * scale);
|
|
|
+ int newHeight = (int) (height * scale);
|
|
|
+
|
|
|
+ // 创建缩放后的图片,使用双线性插值保证清晰度
|
|
|
+ targetImage = new BufferedImage(newWidth, newHeight, originalImage.getType());
|
|
|
+ Graphics2D g2d = targetImage.createGraphics();
|
|
|
+ g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
|
|
+ g2d.drawImage(originalImage, 0, 0, newWidth, newHeight, null);
|
|
|
+ g2d.dispose();
|
|
|
+ log.debug("图片缩放后尺寸: {} x {}", newWidth, newHeight);
|
|
|
}
|
|
|
|
|
|
- // 计算缩放比例,保持宽高比
|
|
|
- double scale = Math.min((double) MAX_WIDTH / width, (double) MAX_HEIGHT / height);
|
|
|
- int newWidth = (int) (width * scale);
|
|
|
- int newHeight = (int) (height * scale);
|
|
|
+ // 获取图片格式(从文件名解析)
|
|
|
+ String format = getImageFormat(file.getOriginalFilename());
|
|
|
+
|
|
|
+ // 步骤2:迭代质量压缩,尝试不同质量档位
|
|
|
+ byte[] compressedBytes = compressWithIterativeQuality(targetImage, format, originalSize);
|
|
|
|
|
|
- // 创建缩放后的图片,使用双线性插值算法保证清晰度
|
|
|
- BufferedImage resizedImage = new BufferedImage(newWidth, newHeight, originalImage.getType());
|
|
|
- Graphics2D g2d = resizedImage.createGraphics();
|
|
|
- g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
|
|
- g2d.drawImage(originalImage, 0, 0, newWidth, newHeight, null);
|
|
|
- g2d.dispose();
|
|
|
+ // 校验压缩结果:必须小于原图且不为空才视为有效压缩
|
|
|
+ if (compressedBytes != null && compressedBytes.length < originalSize) {
|
|
|
+ log.debug("图片压缩成功,原图: {} bytes, 压缩后: {} bytes", originalSize, compressedBytes.length);
|
|
|
+ return compressedBytes;
|
|
|
+ }
|
|
|
|
|
|
- // 对缩放后的图片进行质量压缩
|
|
|
- return compressWithQuality(resizedImage, file.getOriginalFilename());
|
|
|
+ // 压缩无效,记录警告日志并返回null(调用方应使用原文件)
|
|
|
+ log.warn("图片压缩无效,压缩后大小({} bytes) >= 原图大小({} bytes)",
|
|
|
+ compressedBytes != null ? compressedBytes.length : 0, originalSize);
|
|
|
+ return null;
|
|
|
} catch (IOException e) {
|
|
|
- log.error("图片压缩失败", e);
|
|
|
+ log.error("图片压缩失败,读取或处理图片时发生IO异常", e);
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 图片质量压缩
|
|
|
- * 使用 ImageWriter 进行高质量压缩,保持原格式
|
|
|
- *
|
|
|
- * @param image 待压缩的图片对象
|
|
|
- * @param filename 原文件名,用于确定图片格式
|
|
|
- * @return 压缩后的字节数组
|
|
|
+ * 迭代质量压缩
|
|
|
+ *
|
|
|
+ * 核心算法:从 INITIAL_QUALITY(0.7) 开始逐步降低质量,每次降低 QUALITY_STEP(0.1),
|
|
|
+ * 直到压缩后文件小于原图或达到 MIN_QUALITY(0.1)。
|
|
|
+ *
|
|
|
+ * 特殊处理:
|
|
|
+ * - 如果某质量档位压缩失败(返回null),继续尝试下一档位,不直接退出
|
|
|
+ * - 如果PNG格式所有质量档位均无效,自动尝试转为JPEG格式进行有损压缩
|
|
|
+ *
|
|
|
+ * @param image 待压缩的图片对象(已完成尺寸缩放)
|
|
|
+ * @param format 图片格式(如 "jpg", "png")
|
|
|
+ * @param originalSize 原图大小(字节),用于判断压缩是否有效
|
|
|
+ * @return 压缩后的字节数组,若所有档位均无效则返回 null
|
|
|
+ */
|
|
|
+ private static byte[] compressWithIterativeQuality(BufferedImage image, String format, long originalSize) {
|
|
|
+ log.info("开始迭代压缩,格式: {}, 原图大小: {} bytes", format, originalSize);
|
|
|
+
|
|
|
+ // 从初始质量开始迭代
|
|
|
+ float quality = INITIAL_QUALITY;
|
|
|
+
|
|
|
+ // 迭代循环:从高到低尝试不同质量档位
|
|
|
+ while (quality >= MIN_QUALITY) {
|
|
|
+ // 使用当前质量档位进行压缩
|
|
|
+ byte[] compressedBytes = compressWithQuality(image, format, quality);
|
|
|
+
|
|
|
+ // 压缩失败(返回null),继续尝试下一质量档位
|
|
|
+ if (compressedBytes == null) {
|
|
|
+ log.warn("compressWithQuality返回null, 格式: {}, 质量: {}", format, quality);
|
|
|
+ quality -= QUALITY_STEP;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 记录当前压缩尝试结果
|
|
|
+ log.info("压缩尝试 - 格式: {}, 质量: {}, 压缩后大小: {} bytes", format, quality, compressedBytes.length);
|
|
|
+
|
|
|
+ // 判断压缩是否有效:压缩后大小 > 0 且 < 原图大小
|
|
|
+ if (compressedBytes.length > 0 && compressedBytes.length < originalSize) {
|
|
|
+ log.info("压缩成功,使用质量 {},压缩后大小 {} bytes", quality, compressedBytes.length);
|
|
|
+ return compressedBytes;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 压缩无效,降低质量继续尝试
|
|
|
+ quality -= QUALITY_STEP;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 所有质量档位均无效
|
|
|
+ log.warn("所有质量档位均无效,格式: {}", format);
|
|
|
+
|
|
|
+ // PNG格式特殊处理:尝试转为JPEG格式进行有损压缩
|
|
|
+ // JPEG的有损压缩通常能获得更好的压缩率,适合对透明度要求不高的场景
|
|
|
+ if ("png".equalsIgnoreCase(format)) {
|
|
|
+ log.info("尝试将PNG转为JPEG格式压缩");
|
|
|
+ byte[] jpegBytes = compressWithIterativeQuality(image, "jpg", originalSize);
|
|
|
+ if (jpegBytes != null && jpegBytes.length > 0 && jpegBytes.length < originalSize) {
|
|
|
+ log.info("PNG转JPEG压缩成功,原图: {} bytes, 压缩后: {} bytes", originalSize, jpegBytes.length);
|
|
|
+ return jpegBytes;
|
|
|
+ }
|
|
|
+ log.warn("PNG转JPEG压缩也失败");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 所有压缩尝试均无效,返回null
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 图片质量压缩(指定质量参数)
|
|
|
+ *
|
|
|
+ * 使用 ImageWriter 进行高质量压缩,支持以下特性:
|
|
|
+ * 1. 透明通道处理:当输出格式为JPEG且原图有透明通道时,先转为白色背景的不透明图片
|
|
|
+ * 2. 压缩参数配置:通过 ImageWriteParam 设置显式压缩质量
|
|
|
+ * 3. 降级处理:如果 ImageWriter 不可用,使用 ImageIO.write 作为回退方案
|
|
|
+ *
|
|
|
+ * @param image 待压缩的图片对象
|
|
|
+ * @param format 图片格式(如 "jpg", "png")
|
|
|
+ * @param quality 压缩质量(0.0-1.0),值越高质量越好但文件越大
|
|
|
+ * @return 压缩后的字节数组,所有方案均失败则返回 null
|
|
|
*/
|
|
|
- private static byte[] compressWithQuality(BufferedImage image, String filename) {
|
|
|
+ private static byte[] compressWithQuality(BufferedImage image, String format, float quality) {
|
|
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
|
try {
|
|
|
- String format = getImageFormat(filename);
|
|
|
+ // 默认使用原图作为输出
|
|
|
+ BufferedImage outputImage = image;
|
|
|
+
|
|
|
+ // 特殊处理:JPEG格式不支持透明通道,需要先转为不透明图片
|
|
|
+ // 将透明区域填充为白色背景,避免透明部分显示为黑色
|
|
|
+ if ("jpg".equalsIgnoreCase(format) && image.getTransparency() != Transparency.OPAQUE) {
|
|
|
+ outputImage = convertToOpaque(image);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取对应格式的 ImageWriter
|
|
|
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName(format);
|
|
|
|
|
|
- // 如果找不到对应格式的 ImageWriter,使用默认方式写入
|
|
|
+ // 如果找不到 ImageWriter,使用 ImageIO.write 作为回退方案
|
|
|
if (!writers.hasNext()) {
|
|
|
- ImageIO.write(image, format, baos);
|
|
|
+ ImageIO.write(outputImage, format, baos);
|
|
|
return baos.toByteArray();
|
|
|
}
|
|
|
|
|
|
@@ -229,29 +340,69 @@ public class ImageUtil {
|
|
|
ImageWriter writer = writers.next();
|
|
|
ImageWriteParam writeParam = writer.getDefaultWriteParam();
|
|
|
|
|
|
+ // 设置显式压缩模式和质量参数
|
|
|
if (writeParam.canWriteCompressed()) {
|
|
|
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
|
|
- writeParam.setCompressionQuality(QUALITY);
|
|
|
+ writeParam.setCompressionQuality(quality);
|
|
|
}
|
|
|
|
|
|
+ // 写入压缩后的图片数据
|
|
|
writer.setOutput(ImageIO.createImageOutputStream(baos));
|
|
|
- writer.write(null, new javax.imageio.IIOImage(image, null, null), writeParam);
|
|
|
+ writer.write(null, new javax.imageio.IIOImage(outputImage, null, null), writeParam);
|
|
|
writer.dispose();
|
|
|
|
|
|
- return baos.toByteArray();
|
|
|
+ // 获取压缩结果并记录日志
|
|
|
+ byte[] result = baos.toByteArray();
|
|
|
+ log.debug("compressWithQuality完成 - 格式: {}, 质量: {}, 结果大小: {} bytes", format, quality, result.length);
|
|
|
+ return result;
|
|
|
} catch (IOException e) {
|
|
|
- log.error("图片质量压缩失败", e);
|
|
|
+ // 压缩失败,记录错误日志并尝试回退方案
|
|
|
+ log.error("图片质量压缩失败,格式: {}, 质量: {}", format, quality, e);
|
|
|
try {
|
|
|
- // 压缩失败时回退到普通写入方式
|
|
|
- ImageIO.write(image, getImageFormat(filename), baos);
|
|
|
+ // 回退:使用 ImageIO.write 直接写入(不设置压缩参数)
|
|
|
+ ImageIO.write(image, format, baos);
|
|
|
return baos.toByteArray();
|
|
|
} catch (IOException ex) {
|
|
|
+ // 回退也失败,返回null
|
|
|
log.error("图片回退保存失败", ex);
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 将带透明通道的图片转换为不透明图片
|
|
|
+ *
|
|
|
+ * JPEG格式不支持透明通道,当需要将PNG等带透明通道的图片转为JPEG时,
|
|
|
+ * 需要先将透明区域填充为白色背景,避免透明部分显示为黑色。
|
|
|
+ *
|
|
|
+ * @param image 带透明通道的图片
|
|
|
+ * @return 不透明图片(TYPE_INT_RGB),透明区域已填充为白色
|
|
|
+ */
|
|
|
+ private static BufferedImage convertToOpaque(BufferedImage image) {
|
|
|
+ // 创建不透明的RGB图片(TYPE_INT_RGB不支持透明通道)
|
|
|
+ BufferedImage opaqueImage = new BufferedImage(
|
|
|
+ image.getWidth(),
|
|
|
+ image.getHeight(),
|
|
|
+ BufferedImage.TYPE_INT_RGB
|
|
|
+ );
|
|
|
+
|
|
|
+ // 创建 Graphics2D 对象用于绘制
|
|
|
+ Graphics2D g2d = opaqueImage.createGraphics();
|
|
|
+
|
|
|
+ // 步骤1:填充白色背景,覆盖透明区域
|
|
|
+ g2d.setColor(Color.WHITE);
|
|
|
+ g2d.fillRect(0, 0, image.getWidth(), image.getHeight());
|
|
|
+
|
|
|
+ // 步骤2:绘制原图,此时透明区域已被白色背景覆盖
|
|
|
+ g2d.drawImage(image, 0, 0, null);
|
|
|
+
|
|
|
+ // 释放资源
|
|
|
+ g2d.dispose();
|
|
|
+
|
|
|
+ return opaqueImage;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 根据文件名获取图片格式
|
|
|
*
|