AI扩图+裁剪编辑

tim-qtp...大约 15 分钟云图库项目

1、图片裁剪:

图片裁剪和旋转等操作,可以用开源的 vue-cropper 组件open in new window

效果大概是这样

不过测试的时候出现了一个问题,就是图片不显示,这是因为出现了跨域问题

这是因为前端域名和服务器(对象存储)的域名不一样导致的。

解决跨域问题的方式有很多,因为图片地址全部都是同一个对象存储 URL,所以可以直接登录云平台来修改对象存储的跨域访问 CORS 设置,直接给特定的源站(域名 + 端口)开放跨域。如图:

这样就可以出现了!

2、AI扩图

随着 AI 的高速发展,AI 几乎可以应用到任何传统业务中,增强应用的功能,带给用户更好的体验。

对于图库网站来说,AI 也有非常多的应用空间,比如可以利用 AI 绘图大模型来编辑图片,实现扩图、擦除补全、图配文、去水印等功能。

哈哈哈,说了点骚话。

国外比较知名的就是 Midjourney,我以前用的就过这个,不过不仅开发对接麻烦,价格也比较贵。国内的 AI 绘图大模型比较推荐 阿里云百炼open in new window ,它是一站式的大模型开发及应用构建平台,可以通过简单的界面操作,在 5 分钟内开发出一款大模型应用,并在线体验效果。

通过阅读 官方文档open in new window,发现它是支持 AI 图像编辑与生成功能的,包括 AI 扩图,支持 HTTP 调用,符合需求。

控制台open in new window 也能看到对应的图像画面扩展模型:

百炼的大模型提供了 新人免费额度open in new window,可以通过文档或者点进大模型了解,对于学习用来说足够了:

1、调用方式

通过阅读 AI 图像扩展的官方文档open in new window,可以发现,API 只支持异步方式调用。

这是因为 AI 绘画任务计算量大且耗时长,同步调用会导致服务器线程长时间被单个任务占用,限制了并发处理能力,增加了超时和系统崩溃的风险。通过异步调用,服务器可以将任务放入队列中,合理调度资源,避免阻塞主线程,从而更高效地服务多个用户请求,提升整体系统的稳定性和可扩展性。

同步调用流程如下,好处是客户端可以直接获取到结果,调用更方便:

image-20250321225403083
image-20250321225403083

由于 AI 接口已经选择了异步调用,所以我们作为要调用 AI 接口的客户端,要使用轮询的方式来检查任务状态是否为 “已完成”,如果完成了,才可以获取到生成的图片。

2、那么是前端轮询还是后端轮询呢?

image-20250321230355755
image-20250321230355755

1)前端轮询(前调后,后调AI)

前端调用后端提交任务后得到任务 ID,然后通过定时器轮询请求查询任务状态接口,直到任务完成或失败。示例代码:

// 提交任务
async function submitTask() {
  const response = await fetch('/api/createTask', { method: 'POST' });
  const { taskId } = await response.json();
  checkTaskStatus(taskId);
}

// 调用
submitTask();

// 检查任务状态
async function checkTaskStatus(taskId) {
  const intervalId = setInterval(async () => {
    const response = await fetch(`/api/taskStatus?taskId=${taskId}`);
    const { status, result } = await response.json();

    if (status === 'success') {
      console.log('Task completed:', result);
      clearInterval(intervalId); // 停止轮询
    } else if (status === 'failed') {
      console.error('Task failed');
      clearInterval(intervalId); // 停止轮询
    }
  }, 2000); // 每隔 2 秒轮询
}

2)后端轮询

后端通过循环或定时任务检测任务状态,接口保持阻塞,直到任务完成或失败,直接返回结果给前端。示例代码:

@RestController
public class TaskController {

    @PostMapping("/createTask")
    public String createTask() {
        String taskId = taskService.submitTask();
        return taskId;
    }

    @GetMapping("/waitForTask")
    public ResponseEntity<String> waitForTask(@RequestParam String taskId) {
        while (true) {
            String status = taskService.checkTaskStatus(taskId);

            if ("success".equals(status)) {
                return ResponseEntity.ok("Task completed");
            } else if ("failed".equals(status)) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Task failed");
            }

            try {
                Thread.sleep(2000); // 等待 2 秒后重试
            } catch (InterruptedException e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error occurred");
            }
        }
    }
}

显然,后端轮询容易因为任务阻塞导致资源耗尽,所以通常推荐 前端轮询

3、后端开发

1、AI 扩图 API

首先开发业务依赖的基础能力,也就是 AI 扩图 API。

1)需要先进入 阿里云百炼控制台open in new window 开通服务:

2)开通之后,我们要在控制台获取 API Key,可参考文档open in new window

能够在控制台查看到 API Key,注意,API Key 一定不要对外泄露!

通过阅读文档发现,百炼支持通过 SDK 或 HTTP 调用。虽然官方写的支持 Java SDK,但 AI 扩图功能中对 SDK 的介绍非常少,此处考虑到兼容性,我们还是 使用 HTTP 调用

由于使用异步的方式,需要开发创建任务和查询结果 2 个 API

3)在配置文件中填写获取到的 apiKey:

# 阿里云 AI 配置
aliYunAi:
  apiKey: xxxx

4)新建数据模型类

api 包下新建 aliyunai 包,存放阿里云 AI 相关代码。

aliyunai.model 包下新建数据模型类,可以让 AI 根据官方文档中的请求响应信息自动生成,无需自己手动编写。图像画面扩展API参考open in new window

curl --location --request POST 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting' \
--header "Authorization: Bearer $DASHSCOPE_API_KEY" \
--header 'X-DashScope-Async: enable' \
--header 'Content-Type: application/json' \
--data '{
    "model": "image-out-painting",
    "input": {
        "image_url": "http://xxx/image.jpg"
    },
    "parameters":{
        "angle": 45,
        "x_scale":1.5,
        "y_scale":1.5
    }
}'

由于每个 AI 图片处理操的请求响应都有一些区别,所以单独给 AI 扩图功能编写具体的请求响应类。

创建扩图任务请求类:

@Data
public class CreateOutPaintingTaskRequest implements Serializable {

    /**
     * 模型,例如 "image-out-painting"
     */
    private String model = "image-out-painting";

    /**
     * 输入图像信息
     */
    private Input input;

    /**
     * 图像处理参数
     */
    private Parameters parameters;

    @Data
    public static class Input {
        /**
         * 必选,图像 URL
         */
        @Alias("image_url")
        private String imageUrl;
    }

    @Data
    public static class Parameters implements Serializable {
        /**
         * 可选,逆时针旋转角度,默认值 0,取值范围 [0, 359]
         */
        private Integer angle;

        /**
         * 可选,输出图像的宽高比,默认空字符串,不设置宽高比
         * 可选值:["", "1:1", "3:4", "4:3", "9:16", "16:9"]
         */
        @Alias("output_ratio")
        private String outputRatio;

        /**
         * 可选,图像居中,在水平方向上按比例扩展,默认值 1.0,范围 [1.0, 3.0]
         */
        @Alias("x_scale")
        @JsonProperty("xScale")
        private Float xScale;

        /**
         * 可选,图像居中,在垂直方向上按比例扩展,默认值 1.0,范围 [1.0, 3.0]
         */
        @Alias("y_scale")
        @JsonProperty("yScale")
        private Float yScale;

        /**
         * 可选,在图像上方添加像素,默认值 0
         */
        @Alias("top_offset")
        private Integer topOffset;

        /**
         * 可选,在图像下方添加像素,默认值 0
         */
        @Alias("bottom_offset")
        private Integer bottomOffset;

        /**
         * 可选,在图像左侧添加像素,默认值 0
         */
        @Alias("left_offset")
        private Integer leftOffset;

        /**
         * 可选,在图像右侧添加像素,默认值 0
         */
        @Alias("right_offset")
        private Integer rightOffset;

        /**
         * 可选,开启图像最佳质量模式,默认值 false
         * 若为 true,耗时会成倍增加
         */
        @Alias("best_quality")
        private Boolean bestQuality;

        /**
         * 可选,限制模型生成的图像文件大小,默认值 true
         * - 单边长度 <= 10000:输出图像文件大小限制为 5MB 以下
         * - 单边长度 > 10000:输出图像文件大小限制为 10MB 以下
         */
        @Alias("limit_image_size")
        private Boolean limitImageSize;

        /**
         * 可选,添加 "Generated by AI" 水印,默认值 true
         */
        @Alias("add_watermark")
        private Boolean addWatermark = false;
    }
}

注意,上述代码中,某些字段打上了 Hutool 工具类的 @Alias 注解,这个注解仅对 Hutool 的 JSON 转换生效,对 SpringMVC 的 JSON 转换没有任何影响。

💡 这里有一个巨坑的地方!经过测试发现,前端如果传递参数名 xScale,是无法赋值给 xScale 字段的;但是传递参数名 xscale,就可以赋值。这是因为 SpringMVC 对于第二个字母是大写的参数无法映射(和参数类别无关),参考博客open in new window

解决方案是,给这些字段增加 @JsonProperty 注解:

/**
 * 可选,图像居中,在水平方向上按比例扩展,默认值 1.0,范围 [1.0, 3.0]
 */
@Alias("x_scale")
@JsonProperty("xScale")
private Float xScale;

/**
 * 可选,图像居中,在垂直方向上按比例扩展,默认值 1.0,范围 [1.0, 3.0]
 */
@Alias("y_scale")
@JsonProperty("yScale")
private Float yScale;

为什么 SpringMVC 要这样设计呢?通过查阅了解到,这是因为 Jackson 在处理字段名与 JSON 属性名映射时,会依赖 Java 的 标准命名规范反射 API

举个例子,根据 JavaBean 的规范,属性名称与其访问器方法(getter 和 setter)之间的映射规则是:如果属性名以小写字母开头,第二个字母是大写(如 eMail),规范仍认为属性名称是 eMail,而访问器方法应为 geteMail()seteMail()。但 Jackson 会尝试推断属性名为 email(因为 eMail 不常见),从而导致 JSON 中 eMailemail 可能无法正确映射。

创建扩图任务响应类:

{
    "output": {
        "task_status": "PENDING",
        "task_id": "0385dc79-5ff8-4d82-bcb6-xxxxxx"
    },
    "request_id": "4909100c-7b5a-9f92-bfe5-xxxxxx"
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateOutPaintingTaskResponse {

    private Output output;

    /**
     * 表示任务的输出信息
     */
    @Data
    public static class Output {

        /**
         * 任务 ID
         */
        private String taskId;

        /**
         * 任务状态
         * <ul>
         *     <li>PENDING:排队中</li>
         *     <li>RUNNING:处理中</li>
         *     <li>SUSPENDED:挂起</li>
         *     <li>SUCCEEDED:执行成功</li>
         *     <li>FAILED:执行失败</li>
         *     <li>UNKNOWN:任务不存在或状态未知</li>
         * </ul>
         */
        private String taskStatus;
    }

    /**
     * 接口错误码。
     * <p>接口成功请求不会返回该参数。</p>
     */
    private String code;

    /**
     * 接口错误信息。
     * <p>接口成功请求不会返回该参数。</p>
     */
    private String message;

    /**
     * 请求唯一标识。
     * <p>可用于请求明细溯源和问题排查。</p>
     */
    private String requestId;

}

查询任务响应类:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class GetOutPaintingTaskResponse {

    /**
     * 请求唯一标识
     */
    private String requestId;

    /**
     * 输出信息
     */
    private Output output;

    /**
     * 表示任务的输出信息
     */
    @Data
    public static class Output {

        /**
         * 任务 ID
         */
        private String taskId;

        /**
         * 任务状态
         * <ul>
         *     <li>PENDING:排队中</li>
         *     <li>RUNNING:处理中</li>
         *     <li>SUSPENDED:挂起</li>
         *     <li>SUCCEEDED:执行成功</li>
         *     <li>FAILED:执行失败</li>
         *     <li>UNKNOWN:任务不存在或状态未知</li>
         * </ul>
         */
        private String taskStatus;

        /**
         * 提交时间
         * 格式:YYYY-MM-DD HH:mm:ss.SSS
         */
        private String submitTime;

        /**
         * 调度时间
         * 格式:YYYY-MM-DD HH:mm:ss.SSS
         */
        private String scheduledTime;

        /**
         * 结束时间
         * 格式:YYYY-MM-DD HH:mm:ss.SSS
         */
        private String endTime;

        /**
         * 输出图像的 URL
         */
        private String outputImageUrl;

        /**
         * 接口错误码
         * <p>接口成功请求不会返回该参数</p>
         */
        private String code;

        /**
         * 接口错误信息
         * <p>接口成功请求不会返回该参数</p>
         */
        private String message;

        /**
         * 任务指标信息
         */
        private TaskMetrics taskMetrics;
    }

    /**
     * 表示任务的统计信息
     */
    @Data
    public static class TaskMetrics {

        /**
         * 总任务数
         */
        private Integer total;

        /**
         * 成功任务数
         */
        private Integer succeeded;

        /**
         * 失败任务数
         */
        private Integer failed;
    }
}

5)开发 API 调用类,通过 Hutool 的 HTTP 请求工具类来调用阿里云百炼的 API:

@Slf4j
@Component
public class AliYunAiApi {
    // 读取配置文件
    @Value("${aliYunAi.apiKey}")
    private String apiKey;

    // 创建任务地址
    public static final String CREATE_OUT_PAINTING_TASK_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting";

    // 查询任务状态
    public static final String GET_OUT_PAINTING_TASK_URL = "https://dashscope.aliyuncs.com/api/v1/tasks/%s";

    /**
     * 创建任务
     *
     * @param createOutPaintingTaskRequest
     * @return
     */
    public CreateOutPaintingTaskResponse createOutPaintingTask(CreateOutPaintingTaskRequest createOutPaintingTaskRequest) {
        if (createOutPaintingTaskRequest == null) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "扩图参数为空");
        }
        // 发送请求
        HttpRequest httpRequest = HttpRequest.post(CREATE_OUT_PAINTING_TASK_URL)
                .header(Header.AUTHORIZATION, "Bearer " + apiKey)
                // 必须开启异步处理,设置为enable。
                .header("X-DashScope-Async", "enable")
                .header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
                .body(JSONUtil.toJsonStr(createOutPaintingTaskRequest));
        try (HttpResponse httpResponse = httpRequest.execute()) {
            if (!httpResponse.isOk()) {
                log.error("请求异常:{}", httpResponse.body());
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "AI 扩图失败");
            }
            CreateOutPaintingTaskResponse response = JSONUtil.toBean(httpResponse.body(), CreateOutPaintingTaskResponse.class);
            String errorCode = response.getCode();
            if (StrUtil.isNotBlank(errorCode)) {
                String errorMessage = response.getMessage();
                log.error("AI 扩图失败,errorCode:{}, errorMessage:{}", errorCode, errorMessage);
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "AI 扩图接口响应异常");
            }
            return response;
        }
    }

    /**
     * 查询创建的任务
     *
     * @param taskId
     * @return
     */
    public GetOutPaintingTaskResponse getOutPaintingTask(String taskId) {
        if (StrUtil.isBlank(taskId)) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "任务 id 不能为空");
        }
        try (HttpResponse httpResponse = HttpRequest.get(String.format(GET_OUT_PAINTING_TASK_URL, taskId))
                .header(Header.AUTHORIZATION, "Bearer " + apiKey)
                .execute()) {
            if (!httpResponse.isOk()) {
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取任务失败");
            }
            return JSONUtil.toBean(httpResponse.body(), GetOutPaintingTaskResponse.class);
        }
    }
}

注意,要按照官方文档的要求给请求头增加鉴权信息,拼接配置中写好的 apiKey:

2、扩图服务

model.dto.picture 包下新建 AI 扩图请求类,用于接受前端传来的参数并传递给 Service 服务层。字段包括图片 id 和扩图参数:

@Data
public class CreatePictureOutPaintingTaskRequest implements Serializable {

    /**
     * 图片 id
     */
    private Long pictureId;

    /**
     * 扩图参数
     */
    private CreateOutPaintingTaskRequest.Parameters parameters;

    private static final long serialVersionUID = 1L;
}

在图片服务中编写创建扩图任务方法,从数据库中获取图片信息和 url 地址,构造请求参数后调用 api 创建扩图任务。注意,如果图片有空间 id,则需要校验权限,直接复用以前的权限校验方法。

@Override
public CreateOutPaintingTaskResponse createPictureOutPaintingTask(CreatePictureOutPaintingTaskRequest createPictureOutPaintingTaskRequest, User loginUser) {
    // 获取图片信息
    Long pictureId = createPictureOutPaintingTaskRequest.getPictureId();
    Picture picture = Optional.ofNullable(this.getById(pictureId))
            .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_ERROR));
    // 权限校验
    checkPictureAuth(loginUser, picture);
    // 构造请求参数
    CreateOutPaintingTaskRequest taskRequest = new CreateOutPaintingTaskRequest();
    CreateOutPaintingTaskRequest.Input input = new CreateOutPaintingTaskRequest.Input();
    input.setImageUrl(picture.getUrl());
    taskRequest.setInput(input);
    BeanUtil.copyProperties(createPictureOutPaintingTaskRequest, taskRequest);
    // 创建任务
    return aliYunAiApi.createOutPaintingTask(taskRequest);
}

3、扩图接口

在 PictureController 添加 AI 扩图接口,包括创建任务和查询任务状态接口:

/**
 * 创建 AI 扩图任务
 */
@PostMapping("/out_painting/create_task")
public BaseResponse<CreateOutPaintingTaskResponse> createPictureOutPaintingTask(
        @RequestBody CreatePictureOutPaintingTaskRequest createPictureOutPaintingTaskRequest,
        HttpServletRequest request) {
    if (createPictureOutPaintingTaskRequest == null || createPictureOutPaintingTaskRequest.getPictureId() == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User loginUser = userService.getLoginUser(request);
    CreateOutPaintingTaskResponse response = pictureService.createPictureOutPaintingTask(createPictureOutPaintingTaskRequest, loginUser);
    return ResultUtils.success(response);
}

/**
 * 查询 AI 扩图任务
 */
@GetMapping("/out_painting/get_task")
public BaseResponse<GetOutPaintingTaskResponse> getPictureOutPaintingTask(String taskId) {
    ThrowUtils.throwIf(StrUtil.isBlank(taskId), ErrorCode.PARAMS_ERROR);
    GetOutPaintingTaskResponse task = aliYunAiApi.getOutPaintingTask(taskId);
    return ResultUtils.success(task);
}

4、前端开发

编写创建任务函数:

// 任务 id
let taskId = ref<string>()

/**
 * 创建任务
 */
const createTask = async () => {
  if (!props.picture?.id) {
    return
  }
  const res = await createPictureOutPaintingTaskUsingPost({
    pictureId: props.picture.id,
    // 可以根据需要设置扩图参数
    parameters: {
      xScale: 2,
      yScale: 2,
    },
  })
  if (res.data.code === 0 && res.data.data) {
    message.success('创建任务成功,请耐心等待,不要退出界面')
    console.log(res.data.data.output.taskId)
    taskId.value = res.data.data.output.taskId
    // 开启轮询
    startPolling()
  } else {
    message.error('创建任务失败,' + res.data.message)
  }
}

任务创建成功后,要开启轮询。

编写轮询逻辑。注意无论任务执行成功或失败、还是退出当前页面时,都需要执行清理逻辑,包括:

  • 清理定时器
  • 将定时器变量设置为 null
  • 将任务 id 设置为 null,这样允许前端多次执行任务

代码如下:

// 轮询定时器
let pollingTimer: NodeJS.Timeout = null

// 清理轮询定时器
const clearPolling = () => {
  if (pollingTimer) {
    clearInterval(pollingTimer)
    pollingTimer = null
    taskId.value = null
  }
}

// 开始轮询
const startPolling = () => {
  if (!taskId.value) return

  pollingTimer = setInterval(async () => {
    try {
      const res = await getPictureOutPaintingTaskUsingGet({
        taskId: taskId.value,
      })
      if (res.data.code === 0 && res.data.data) {
        const taskResult = res.data.data.output
        if (taskResult.taskStatus === 'SUCCEEDED') {
          message.success('扩图任务成功')
          resultImageUrl.value = taskResult.outputImageUrl
          clearPolling()
        } else if (taskResult.taskStatus === 'FAILED') {
          message.error('扩图任务失败')
          clearPolling()
        }
      }
    } catch (error) {
      console.error('轮询任务状态失败', error)
      message.error('检测任务状态失败,请稍后重试')
      clearPolling()
    }
  }, 3000) // 每隔 3 秒轮询一次
}

// 清理定时器,避免内存泄漏
onUnmounted(() => {
  clearPolling()
})

5、扩展知识 - 异步任务优化

1)任务队列和优先级

使用消息队列系统(比如 RabbitMQ、Kafka)对异步任务进行管理,可以根据优先级(会员&普通用户)灵活调度任务。通过队列还可以限制同时处理的任务数量、削峰填谷,防止资源过载,提高系统稳定性。

2)任务记录和状态管理

现在用户是无法找到往期执行的任务和生成的图片的。可以设计任务记录表,存储每个任务的状态、结果和相关信息,并提供接口供用户查询历史任务。

前端可以给用户提供往期任务查询页面,能够查看任务结果、重试某一次任务等。还可以给管理员提供监控系统所有任务的页面,比如任务数、成功率和失败率,全面掌握任务执行情况。

实现起来并不难,其实就是对任务记录表的增删改查。

3)任务错误信息优化

完善任务失败的具体原因,帮助用户快速理解和解决问题。比如参数错误、图片格式不支持等。如果调用了第三方接口,需要认真阅读接口所有可能的错误情况。

4)计费与额度控制

AI 扩图一般是计费业务,需要做好额度控制,并且仅登录用户才可以使用。

分享几个实现思路:

  1. 在用户表中添加“扩图额度”(比如使用次数或余额),每次提交任务前先检查额度是否足够,额度不足则提示用户充值。
  2. 每次任务提交时,可采用预扣费逻辑,任务完成扣费,任务失败则自动退还额度。
  3. 提供查询用户当前剩余额度的接口,用户可以在前端看到自己剩余的额度。
  4. 支持充值额度或会员订阅制收费,还可以根据扩图模式按比例扣费。比如普通模式扣 1 点,高清模式扣 2 点。

💡 一般对于后付费资源(随用随付费),即使余额 < 0,小额欠费也是可以接受的。尤其是对于大厂云服务来说,由于调用量巨大,很难做到实时计费。

5)安全性与稳定性

由于任务要消耗系统资源或成本,所以一定要设置合理的限流规则,防止恶意刷任务。比如限制单用户的任务提交频率,每分钟最多允许提交 3 次任务,超过限制后返回提示信息。

对于长耗时任务,还要设置任务的最大执行时间(比如 10 分钟),超时则自动标记任务失败。

6、扩展

1、尝试更多 AI 图片处理能力,比如 参考文档实现图配文open in new window

2、如果 AI 绘画 API 支持返回当前进度(比如 MidJourney 的 API),可以通过 SSE 的方式将进度返回给前端。

3、优化 AI 扩图参数。可以 参考官方文档open in new window,补充更多扩图参数,并允许用户自主选择扩图参数: