feat(image): 添加图像超分处理功能

- 新增 ImageSRStage 类实现图像超分辨率处理
- 在 AioDeviceController 中启用图像超分和增强的 Stage
- 修改 ImageEnhanceStage 配置检查逻辑,增加空值和占位符检测
- 为图像处理 Pipeline 添加超分 Stage
- 增加 ImageSRStage 的单元测试覆盖各种配置和执行情况
- 实现百度云图像超分 API 的调用和结果处理逻辑
This commit is contained in:
2025-11-27 18:45:10 +08:00
parent d60d7d9ad8
commit 610a183be1
4 changed files with 660 additions and 8 deletions

View File

@@ -0,0 +1,456 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* ImageSRStage 单元测试
*/
class ImageSRStageTest {
@Test
void testGetName() {
ImageSRStage stage = new ImageSRStage();
assertEquals("ImageSRStage", stage.getName());
}
@Test
void testDefaultConstructor_ShouldSetDefaultConfig() {
ImageSRStage stage = new ImageSRStage();
BceEnhancerConfig config = stage.getEnhancerConfig();
assertNotNull(config);
assertEquals(1.0f, config.getQps());
assertEquals(System.getenv("BCE_IMAGE_APP_ID"), config.getAppId());
assertEquals(System.getenv("BCE_IMAGE_API_KEY"), config.getApiKey());
assertEquals(System.getenv("BCE_IMAGE_SECRET_KEY"), config.getSecretKey());
}
@Test
void testConstructorWithConfig() {
BceEnhancerConfig customConfig = new BceEnhancerConfig();
customConfig.setAppId("custom-app-id");
customConfig.setApiKey("custom-api-key");
customConfig.setSecretKey("custom-secret-key");
customConfig.setQps(2.0f);
ImageSRStage stage = new ImageSRStage(customConfig);
BceEnhancerConfig config = stage.getEnhancerConfig();
assertEquals("custom-app-id", config.getAppId());
assertEquals("custom-api-key", config.getApiKey());
assertEquals("custom-secret-key", config.getSecretKey());
assertEquals(2.0f, config.getQps());
}
@Test
void testShouldExecute_IPCSource() {
ImageSRStage stage = new ImageSRStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 需要显式启用 image_sr
context.loadStageConfig(null, java.util.Map.of("image_sr", true));
assertTrue(stage.shouldExecute(context));
}
@Test
void testShouldExecute_PhoneSource_ShouldSkip() {
ImageSRStage stage = new ImageSRStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.PHONE)
.build();
context.loadStageConfig(null, java.util.Map.of("image_sr", true));
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_CameraSource_ShouldSkip() {
ImageSRStage stage = new ImageSRStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.CAMERA)
.build();
context.loadStageConfig(null, java.util.Map.of("image_sr", true));
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_UnknownSource_ShouldSkip() {
ImageSRStage stage = new ImageSRStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.UNKNOWN)
.build();
context.loadStageConfig(null, java.util.Map.of("image_sr", true));
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_NullSource_ShouldSkip() {
ImageSRStage stage = new ImageSRStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
context.loadStageConfig(null, java.util.Map.of("image_sr", true));
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_DisabledByConfig_ShouldSkip() {
ImageSRStage stage = new ImageSRStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 默认不启用,不加载配置
assertFalse(stage.shouldExecute(context));
}
@Test
void testExecute_NoCurrentFile_ShouldSkip() {
// 使用有效配置,确保配置检查通过
BceEnhancerConfig validConfig = new BceEnhancerConfig();
validConfig.setAppId("valid-app-id");
validConfig.setApiKey("valid-api-key");
validConfig.setSecretKey("valid-secret-key");
ImageSRStage stage = new ImageSRStage(validConfig);
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 不设置文件,让 getCurrentFile() 返回 null
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("当前文件不存在", result.getMessage());
}
@Test
void testExecute_InvalidConfig_ShouldSkip() {
// 创建无效配置(使用 TODO 占位符)
BceEnhancerConfig invalidConfig = new BceEnhancerConfig();
invalidConfig.setAppId("TODO_YOUR_APP_ID");
invalidConfig.setApiKey("TODO_YOUR_API_KEY");
invalidConfig.setSecretKey("TODO_YOUR_SECRET_KEY");
ImageSRStage stage = new ImageSRStage(invalidConfig);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-invalid-config")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 创建一个实际存在的临时文件
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(tempFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
} catch (Exception e) {
// 忽略
}
context.setOriginalFile(tempFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过超分辨率", result.getMessage());
// 清理
context.cleanup();
}
@Test
void testExecute_NullConfig_ShouldSkip(@TempDir Path tempDir) throws Exception {
ImageSRStage stage = new ImageSRStage(null);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-null-config")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 创建一个临时文件
File testFile = tempDir.resolve("test.jpg").toFile();
try (FileOutputStream fos = new FileOutputStream(testFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
}
context.setOriginalFile(testFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过超分辨率", result.getMessage());
}
@Test
void testExecute_NullAppId_ShouldSkip(@TempDir Path tempDir) throws Exception {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId(null);
config.setApiKey("valid-api-key");
config.setSecretKey("valid-secret-key");
ImageSRStage stage = new ImageSRStage(config);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-null-appid")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
File testFile = tempDir.resolve("test.jpg").toFile();
try (FileOutputStream fos = new FileOutputStream(testFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
}
context.setOriginalFile(testFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过超分辨率", result.getMessage());
}
@Test
void testExecute_TodoPlaceholderAppId_ShouldSkip(@TempDir Path tempDir) throws Exception {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId("TODO_APP_ID");
config.setApiKey("valid-api-key");
config.setSecretKey("valid-secret-key");
ImageSRStage stage = new ImageSRStage(config);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-todo-appid")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
File testFile = tempDir.resolve("test.jpg").toFile();
try (FileOutputStream fos = new FileOutputStream(testFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
}
context.setOriginalFile(testFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped(), "应该跳过执行,因为配置包含 TODO 占位符");
assertEquals("配置不完整,跳过超分辨率", result.getMessage());
}
@Test
void testSetAndGetEnhancerConfig() {
ImageSRStage stage = new ImageSRStage();
BceEnhancerConfig newConfig = new BceEnhancerConfig();
newConfig.setAppId("new-app-id");
newConfig.setApiKey("new-api-key");
newConfig.setSecretKey("new-secret-key");
newConfig.setQps(3.0f);
stage.setEnhancerConfig(newConfig);
BceEnhancerConfig retrieved = stage.getEnhancerConfig();
assertEquals("new-app-id", retrieved.getAppId());
assertEquals("new-api-key", retrieved.getApiKey());
assertEquals("new-secret-key", retrieved.getSecretKey());
assertEquals(3.0f, retrieved.getQps());
}
@Test
void testExecute_AllImageTypes_WithIPCSource(@TempDir Path tempDir) throws Exception {
ImageSRStage stage = new ImageSRStage();
// 测试普通照片
testImageType(stage, tempDir, ImageType.NORMAL_PHOTO);
// 测试拼图
testImageType(stage, tempDir, ImageType.PUZZLE);
// 测试手机上传
testImageType(stage, tempDir, ImageType.MOBILE_UPLOAD);
}
private void testImageType(ImageSRStage stage, Path tempDir, ImageType imageType) throws Exception {
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-" + imageType.getCode())
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(imageType)
.source(ImageSource.IPC)
.build();
File testFile = tempDir.resolve("test-" + imageType.getCode() + ".jpg").toFile();
try (FileOutputStream fos = new FileOutputStream(testFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
}
context.setOriginalFile(testFile);
context.loadStageConfig(null, java.util.Map.of("image_sr", true));
// 应该可以执行(因为 source 是 IPC)
assertTrue(stage.shouldExecute(context));
}
@Test
void testConfigValidation_AllTodoFields() {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId("TODO_APP_ID");
config.setApiKey("TODO_API_KEY");
config.setSecretKey("TODO_SECRET_KEY");
ImageSRStage stage = new ImageSRStage(config);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-all-todo")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(tempFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
} catch (Exception e) {
// 忽略
}
context.setOriginalFile(tempFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过超分辨率", result.getMessage());
// 清理
context.cleanup();
}
@Test
void testConfigValidation_MixedTodoAndValid() {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId("valid-app-id");
config.setApiKey("TODO_API_KEY");
config.setSecretKey("valid-secret-key");
ImageSRStage stage = new ImageSRStage(config);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-mixed-todo")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(tempFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
} catch (Exception e) {
// 忽略
}
context.setOriginalFile(tempFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过超分辨率", result.getMessage());
// 清理
context.cleanup();
}
/**
* 测试说明:
*
* 由于 ImageSRStage 依赖外部 API (BceImageSR),
* 以下测试场景需要 Mock 或集成测试环境:
*
* 1. testExecute_Success_ValidConfig()
* - 模拟成功的图像超分辨率调用
* - 验证超分后文件大小变化
* - 验证 context.updateProcessedFile() 被调用
*
* 2. testExecute_ApiReturnsNull_ShouldDegrade()
* - 模拟 API 返回 null
* - 验证返回 StageResult.degraded()
*
* 3. testExecute_ApiReturnsEmptyFile_ShouldDegrade()
* - 模拟 API 返回空文件
* - 验证返回 StageResult.degraded()
*
* 4. testExecute_ApiThrowsException_ShouldDegrade()
* - 模拟 API 抛出异常
* - 验证返回 StageResult.degraded() 而不是 failed()
*
* 5. testExecute_SaveFileFails_ShouldDegrade()
* - 模拟文件保存失败
* - 验证返回 StageResult.degraded()
*
* 这些测试需要使用 Mockito 或类似的 Mock 框架来模拟 BceImageSR 的行为。
*
* 示例(使用 Mockito):
*
* @Test
* void testExecute_Success_ValidConfig() {
* // 创建 Mock 的 BceImageSR
* BceImageSR mockSR = mock(BceImageSR.class);
*
* // 创建 Mock 的 MultipartFile
* MultipartFile mockEnhancedImage = mock(MultipartFile.class);
* when(mockEnhancedImage.isEmpty()).thenReturn(false);
* when(mockEnhancedImage.getBytes()).thenReturn(new byte[]{1, 2, 3, 4, 5, 6, 7, 8});
*
* // 配置 Mock 行为
* when(mockSR.enhance(anyString())).thenReturn(mockEnhancedImage);
*
* // 测试逻辑...
* }
*/
}