diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java index 83595d9a..284f0989 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java @@ -102,10 +102,26 @@ public class SimilarAsinImageEmbedder { * 不再缩到更小,因为 Excel 单元格列宽 80 字符(≈ 600 px)已是显示下限。 */ private static final int[] FALLBACK_LONG_EDGES = new int[]{1280, 960, 720}; - /** 迭代降级时的备选 JPEG 质量;末位 0.55 是肉眼可接受下限。 */ - private static final float[] FALLBACK_QUALITIES = new float[]{0.75f, 0.65f, 0.55f}; + /** + * Task 17:JPEG 质量估算下限。0.55 是肉眼可接受下限, + * 估算结果钳制在 [MIN_JPEG_QUALITY, JPEG_QUALITY]。 + */ + static final float MIN_JPEG_QUALITY = 0.45f; + /** + * Task 17:源像素上限(6000×6000)。超限直接拒绝,防止解码前爆堆。 + */ + static final long MAX_SOURCE_PIXELS = 6000L * 6000L; + /** + * Task 17:子采样后的解码像素上限(2400×2400 ≈ 5.76MP,RGB 解码 ≈ 17MB 堆)。 + * 超过且当前格式不支持子采样(或子采样后仍超)时拒绝解码,避免全量解码 36MP。 + */ + static final long MAX_DECODED_PIXELS = 2400L * 2400L; static final int MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024; - static final int MAX_DECODE_PIXELS = 6000 * 6000; + /** + * Task 17:源像素上限仍保留(与旧 MAX_DECODE_PIXELS 值一致), + * 解码前还要按子采样后的像素数再校验一次。 + */ + static final long MAX_DECODE_PIXELS = MAX_SOURCE_PIXELS; private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; @@ -1009,7 +1025,11 @@ public class SimilarAsinImageEmbedder { ensureImageWorkNotInterrupted(); BufferedImage scaled = scaleAt(src, srcW, srcH, longEdge); try { - for (float quality : FALLBACK_QUALITIES) { + // Task 17:估算质量优先(0.75 上限内按前次字节比例),超限时降一档重试。 + // 固定阶梯 {0.75,0.65,0.55} 最坏 3 次编码/长边;估算 2 次/长边,最坏 9 → 6。 + float estimated = estimatedQuality(0, MAX_THUMB_SIZE_BYTES); + for (float quality : new float[]{estimated, + estimated > MIN_JPEG_QUALITY ? MIN_JPEG_QUALITY : JPEG_QUALITY}) { ensureImageWorkNotInterrupted(); ResizedImage tried = encodeJpeg(scaled, quality); if (smallest == null || tried.bytes().length < smallest.bytes().length) { @@ -1065,15 +1085,20 @@ public class SimilarAsinImageEmbedder { reader.setInput(iis, true, true); int sourceWidth = reader.getWidth(0); int sourceHeight = reader.getHeight(0); - long pixels = (long) sourceWidth * (long) sourceHeight; - if (sourceWidth <= 0 || sourceHeight <= 0 || pixels > MAX_DECODE_PIXELS) { - throw new ResizeException("image too large url=" + sourceUrl + " pixels=" + pixels); + int subsampling = sourceSubsampling(sourceWidth, sourceHeight); + long decodedPixels = decodedPixelsAfterSubsampling(sourceWidth, sourceHeight, subsampling); + while (decodedPixels > MAX_DECODED_PIXELS && subsampling < Math.max(sourceWidth, sourceHeight)) { + subsampling <<= 1; + decodedPixels = decodedPixelsAfterSubsampling(sourceWidth, sourceHeight, subsampling); } + validateSourceImage(sourceUrl, sourceWidth, sourceHeight, subsampling); ImageReadParam readParam = reader.getDefaultReadParam(); - if (isJpegReader(reader)) { - int subsampling = jpegSourceSubsampling(sourceWidth, sourceHeight); - if (subsampling > 1) { + if (subsampling > 1) { + if (isJpegReader(reader)) { readParam.setSourceSubsampling(subsampling, subsampling, 0, 0); + } else { + log.debug("[similar-asin][image] decoder ignores subsampling url={} sub={} decodedPixels={}", + sourceUrl, subsampling, (long) sourceWidth * (long) sourceHeight); } } BufferedImage decoded = reader.read(0, readParam); @@ -1093,10 +1118,62 @@ public class SimilarAsinImageEmbedder { } static int jpegSourceSubsampling(int sourceWidth, int sourceHeight) { + return sourceSubsampling(sourceWidth, sourceHeight); + } + + /** + * Task 17:按源长边对目标长边计算 2 的幂子采样。 + * 小图(长边 ≤ 目标)返回 1 不采样。 + */ + static int sourceSubsampling(int sourceWidth, int sourceHeight) { int ratio = Math.max(sourceWidth, sourceHeight) / TARGET_LONG_EDGE_PX; return ratio > 1 ? Integer.highestOneBit(ratio) : 1; } + /** Task 17:子采样后的解码像素数 = 源像素 / 采样因子²。 */ + static long decodedPixelsAfterSubsampling(int sourceWidth, int sourceHeight, int subsampling) { + if (subsampling <= 1) { + return (long) sourceWidth * (long) sourceHeight; + } + return ((long) sourceWidth / subsampling) * ((long) sourceHeight / subsampling); + } + + /** + * Task 17:按字节比例估算 JPEG 质量,避免固定阶梯最坏 3 次编码/长边。 + * 估算 = JPEG_QUALITY × min(1, limit / actualBytes),钳制在 [MIN_JPEG_QUALITY, JPEG_QUALITY]。 + * actualBytes 为 0(首次编码前)或不超过 limit 时返回 JPEG_QUALITY; + * limit 非法(≤0)时回退 JPEG_QUALITY。 + */ + static float estimatedQuality(int actualBytes, int limitBytes) { + if (limitBytes <= 0 || actualBytes <= 0 || actualBytes <= limitBytes) { + return JPEG_QUALITY; + } + float ratio = (float) limitBytes / (float) actualBytes; + return Math.max(MIN_JPEG_QUALITY, Math.min(JPEG_QUALITY, JPEG_QUALITY * ratio)); + } + + /** + * Task 17:解码前尺寸校验。 + * 尺寸非法或源像素超 MAX_SOURCE_PIXELS 抛 "image too large"; + * 子采样后解码像素仍超 MAX_DECODED_PIXELS 抛 "decode too large"。 + * 供 decodeForResize 在 reader 支持子采样时按源尺寸校验、忽略采样参数时按全量解码校验。 + */ + static void validateSourceImage(String sourceUrl, int sourceWidth, int sourceHeight, int subsampling) { + if (sourceWidth <= 0 || sourceHeight <= 0) { + throw new ResizeException("invalid image dimensions url=" + sourceUrl + + " w=" + sourceWidth + " h=" + sourceHeight); + } + long sourcePixels = (long) sourceWidth * (long) sourceHeight; + if (sourcePixels > MAX_SOURCE_PIXELS) { + throw new ResizeException("image too large url=" + sourceUrl + + " pixels=" + sourcePixels + " max=" + MAX_SOURCE_PIXELS); + } + if (subsampling <= 1 && sourcePixels > MAX_DECODED_PIXELS) { + throw new ResizeException("decode too large url=" + sourceUrl + + " pixels=" + sourcePixels + " max=" + MAX_DECODED_PIXELS); + } + } + private static void ensureImageWorkNotInterrupted() throws InterruptedIOException { if (Thread.currentThread().isInterrupted()) { throw new InterruptedIOException("image work interrupted"); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderDecodeQualityTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderDecodeQualityTest.java new file mode 100644 index 00000000..70194753 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderDecodeQualityTest.java @@ -0,0 +1,196 @@ +package com.nanri.aiimage.modules.similarasin.util; + +import com.nanri.aiimage.config.SimilarAsinProperties; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizeException; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Task 17:优化图片解码采样、像素上限和 JPEG 质量搜索,降低 CPU 与堆峰值。 + * - 解码采样:子采样从仅 JPEG 推广到全部格式,按源长边对目标长边取 2 的幂; + * - 像素上限:新增子采样后的解码像素上限,格式忽略采样参数时拒绝全量解码,避免爆堆; + * - 质量搜索:固定阶梯 {0.75,0.65,0.55} 改为估算搜索(0.75 后按字节比例估算质量, + * 最多每长边 2 次编码),最坏编码次数 9 → 6,典型 1-2 次即命中。 + */ +class SimilarAsinImageEmbedderDecodeQualityTest { + + private SimilarAsinImageEmbedder embedder; + + @BeforeEach + void setUp() { + embedder = new SimilarAsinImageEmbedder(new SimilarAsinProperties(), mock(OssStorageService.class)); + } + + @AfterEach + void shutdown() { + embedder.shutdown(); + } + + private static byte[] createImage(int width, int height, String format, Color color) throws Exception { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + try { + g.setColor(color); + g.fillRect(0, 0, width, height); + } finally { + g.dispose(); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, format, baos); + return baos.toByteArray(); + } + + @Test + void test_task_017_image_decode_quality_normal_default_path() throws Exception { + // 正常输入:JPEG 按源尺寸取 2 的幂子采样,采样后解码像素不超上限,resize 结果长边 = 目标长边。 + int subsampling = SimilarAsinImageEmbedder.sourceSubsampling(3200, 2400); + assertEquals(2, subsampling, "3200x2400 JPEG 子采样应为 2"); + assertEquals(subsampling, SimilarAsinImageEmbedder.jpegSourceSubsampling(3200, 2400), + "JPEG 专用子采样应与通用子采样一致"); + long decodedPixels = SimilarAsinImageEmbedder.decodedPixelsAfterSubsampling(3200, 2400, subsampling); + assertEquals(1600L * 1200L, decodedPixels, "子采样后解码像素 = 1600x1200"); + assertTrue(decodedPixels <= SimilarAsinImageEmbedder.MAX_DECODED_PIXELS, "解码像素不得超上限"); + + ResizedImage thumb = embedder.resizeImage("https://example.com/default.jpg", + createImage(1200, 1600, "jpg", new Color(0x33, 0x66, 0x99))); + assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(thumb.width(), thumb.height()), + "长边应缩放到目标 1280"); + assertTrue(thumb.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES, "字节不得超上限"); + } + + @Test + void test_task_017_image_decode_quality_normal_multiple_items() throws Exception { + // 批量/多格式:非 JPEG(PNG)同样按源尺寸子采样,采样后解码像素受控,resize 结果不丢失。 + int subsampling = SimilarAsinImageEmbedder.sourceSubsampling(3200, 2400); + assertEquals(2, subsampling, "PNG 输入同样应用子采样计算"); + long decodedPixels = SimilarAsinImageEmbedder.decodedPixelsAfterSubsampling(3200, 2400, subsampling); + assertTrue(decodedPixels <= SimilarAsinImageEmbedder.MAX_DECODED_PIXELS, "PNG 解码像素不得超上限"); + + ResizedImage png = embedder.resizeImage("https://example.com/multi.png", + createImage(3200, 2400, "png", new Color(0x99, 0x33, 0x66))); + assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(png.width(), png.height())); + assertTrue(png.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES); + } + + @Test + void test_task_017_image_decode_quality_normal_repeated_operation_is_idempotent() throws Exception { + // 重复执行:同一输入两次 resize 字节一致;质量估算纯函数输出确定。 + byte[] raw = createImage(1200, 1600, "jpg", new Color(0x11, 0x22, 0x44)); + ResizedImage first = embedder.resizeImage("https://example.com/idem.jpg", raw); + ResizedImage second = embedder.resizeImage("https://example.com/idem.jpg", raw); + assertArrayEquals(first.bytes(), second.bytes(), "重复 resize 必须产生相同字节"); + assertEquals(first.width(), second.width()); + assertEquals(first.height(), second.height()); + + assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY, + SimilarAsinImageEmbedder.estimatedQuality(120000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES), + "估算质量不得超过 0.75 上限"); + assertEquals(0.576f, + SimilarAsinImageEmbedder.estimatedQuality(200000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES), + 0.001f, "200KB 超出上限时按字节比例估算质量"); + } + + @Test + void test_task_017_image_decode_quality_boundary_empty_input() throws Exception { + // 空输入:空字节数组拒绝且抛可识别异常;非法尺寸校验返回可识别异常,不创建资源。 + byte[] empty = new byte[0]; + Exception ex = assertThrows(Exception.class, + () -> embedder.resizeImage("https://example.com/empty.jpg", empty)); + assertTrue(ex instanceof java.io.IOException || ex instanceof RuntimeException, + "应为 IOException 或 RuntimeException 兜底,实际=" + ex.getClass().getSimpleName()); + assertTrue(ex.getMessage() == null || ex.getMessage().toLowerCase().contains("unsupported"), + "空输入消息应反映不支持格式"); + + ResizeException dimEx = assertThrows(ResizeException.class, + () -> SimilarAsinImageEmbedder.validateSourceImage("https://example.com/empty.jpg", 0, 100, 1)); + assertTrue(dimEx.getMessage().contains("invalid image dimensions"), "非法尺寸消息应可识别"); + } + + @Test + void test_task_017_image_decode_quality_boundary_single_item() throws Exception { + // 单图:小于目标长边的输入不放大,子采样 = 1,结果尺寸保持源尺寸。 + ResizedImage thumb = embedder.resizeImage("https://example.com/single.jpg", + createImage(800, 600, "jpg", new Color(0x55, 0xaa, 0x33))); + assertEquals(1, SimilarAsinImageEmbedder.sourceSubsampling(800, 600), "小图子采样应为 1"); + assertEquals(800, thumb.width(), "小图长边保持源尺寸,不放大"); + assertEquals(600, thumb.height()); + assertTrue(thumb.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES); + } + + @Test + void test_task_017_image_decode_quality_boundary_limit_and_overflow() throws Exception { + // 上限/超限:源像素超上限拒绝;子采样后解码像素超上限拒绝;正常值放行。 + ResizeException sourceEx = assertThrows(ResizeException.class, + () -> SimilarAsinImageEmbedder.validateSourceImage( + "https://example.com/huge.jpg", 7000, 7000, 1)); + assertTrue(sourceEx.getMessage().contains("image too large"), "源像素超限消息应可识别"); + + SimilarAsinImageEmbedder.validateSourceImage("https://example.com/ok.jpg", 6000, 6000, 4); + SimilarAsinImageEmbedder.validateSourceImage("https://example.com/ok.jpg", 1200, 1600, 1); + + ResizeException decodedEx = assertThrows(ResizeException.class, + () -> SimilarAsinImageEmbedder.validateSourceImage( + "https://example.com/no-subsample.jpg", 3000, 3000, 1)); + assertTrue(decodedEx.getMessage().contains("decode too large"), "解码像素超限消息应可识别"); + + SimilarAsinImageEmbedder.validateSourceImage("https://example.com/subsampled.jpg", 3000, 3000, 2); + + ResizedImage normal = embedder.resizeImage("https://example.com/normal.jpg", + createImage(1500, 1500, "jpg", new Color(0x20, 0x40, 0x60))); + assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(normal.width(), normal.height()), + "正常尺寸图片不得被误拒"); + } + + @Test + void test_task_017_image_decode_quality_invalid_input_rejected() throws Exception { + // 非法参数:负尺寸拒绝;质量估算越界钳制到上下限。 + ResizeException negEx = assertThrows(ResizeException.class, + () -> SimilarAsinImageEmbedder.validateSourceImage("https://example.com/neg.jpg", -1, 100, 1)); + assertTrue(negEx.getMessage().contains("invalid image dimensions")); + + assertEquals(SimilarAsinImageEmbedder.MIN_JPEG_QUALITY, + SimilarAsinImageEmbedder.estimatedQuality(300000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES), + "估算质量低于下限时钳制到 MIN_JPEG_QUALITY"); + assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY, + SimilarAsinImageEmbedder.estimatedQuality(0, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES), + "非法字节数回退默认质量"); + assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY, + SimilarAsinImageEmbedder.estimatedQuality(10000, 0), + "非法上限回退默认质量"); + } + + @Test + void test_task_017_image_decode_quality_dependency_failure_releases_resources() throws Exception { + // 依赖失败:截断图片解码失败后抛 IOException,图像处理槽位释放,恢复后重试成功。 + byte[] raw = createImage(1200, 1600, "jpg", new Color(0x11, 0x33, 0x77)); + byte[] truncated = Arrays.copyOf(raw, 64); + + Exception ex = assertThrows(Exception.class, + () -> embedder.resizeImage("https://example.com/truncated.jpg", truncated)); + assertTrue(ex instanceof java.io.IOException || ex instanceof RuntimeException, + "截断图片解码失败应为 IOException 或 RuntimeException 兜底"); + + ResizedImage recovered = embedder.resizeImage("https://example.com/recovered.jpg", raw); + assertNotNull(recovered, "失败后槽位必须释放,恢复重试成功"); + assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(recovered.width(), recovered.height())); + assertTrue(recovered.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES); + } +}