【智能协同云图库】智能协同云图库第七弹:基于 Jsoup 爬虫实现以图搜图、颜色搜图、批量操作

.


图片功能拓展介绍


本节旨在吸引更多用户使用我们平台的私有空间作为个人相册,为此我们对图片功能进行了扩展,具体包括以下功能:

  • 图片搜索

    • 基础属性搜索
    • 以图搜图
    • 颜色搜索
  • 图片批量管理

    • 批量修改信息
    • 批量重命名

通过这些功能,用户能够更高效地管理和分享平台上的图片资源,进一步提升使用体验。


一、图片搜索 - 基础属性搜索需求分析


需求分析


我们可以提供多种搜索维度,帮助用户更快地找到自己空间的图片,并按优先级对搜索维度进行排序,优先级高的展示在靠前位置,具体如下:

  • 关键词:同时搜索名称和简介
  • 标签
  • 分类
  • 编辑时间(开始时间与结束时间)
  • 图片名称
  • 图片简介
  • 图片宽度
  • 图片高度
  • 图片格式

方案设计


后端可以直接复用原有的分页获取图片列表接口,并在此基础上增加相应的搜索条件,以支持更灵活的筛选。


后端开发

后端可以直接复用原有的分页获取图片列表接口,并在此基础上增加相应的搜索条件,以支持更灵活的筛选。具体操作如下:

1. 添加编辑时间字段

为了支持按编辑时间进行搜索,需要在请求类 PictureQueryRequest 中添加开始和结束编辑时间字段:

java 复制代码
/** 开始编辑时间 */
private Date startEditTime;
/** 结束编辑时间 */
private Date endEditTime;

2. 更新图片服务的 getQueryWrapper 方法:在处理查询时,补充按编辑时间筛选的逻辑:

java 复制代码
// 从请求中获取字段值
Date startEditTime = pictureQueryRequest.getStartEditTime();
Date endEditTime = pictureQueryRequest.getEndEditTime();

// 拼接查询条件
queryWrapper.ge(ObjUtil.isNotEmpty(startEditTime), "editTime", startEditTime);
queryWrapper.lt(ObjUtil.isNotEmpty(endEditTime), "editTime", endEditTime);
// startEditTime <= time <= endEditTime 
// >= ge, <= lt

二、图片搜索 - 以图搜图需求分析


需求分析


用户可以使用一张图片来搜索相似的图片,相比传统的关键词搜索,能够更精确地找到与上传图片内容相似的图片。

为了获得更多的搜索结果,我们的需求是从全网搜索图片,而不是只在自己的图库中搜索。

注意,该功能不用局限于私有空间,公共图库也可以使用


方案设计


主要有 2 种方案:第三方 API以及数据抓取(爬虫)。


1. 第三方API****


  • 如果想从自建的图库中搜索:可以使用百度 AI 提供的图片搜索 API,参考官方文档
  • Bing 以图搜图 API:利用必应的图库,可以从全网进行搜索,而且可以免费使用,参考官方文档

2. 数据抓取


利用已有的以图搜图网站,通过数据抓取的方式实时查询搜图网站的返回结果。

为了让大家学习到更多知识,此处我们选择这种方案。

以百度搜图网站为例,我们可以先体验一遍流程:

1. 搜索"百度以图搜图",进到百度图片搜索,百度识图搜索结果


该接口的返回值为"以图搜图的页面地址"。

所以,我们就可以考虑,在后端用程序,上传已有的图片,调用百度以图搜图的接口,拿到以图搜图结果的页面中展示的图片,即可实现以图搜图功能。


体验完一遍以图搜图的流程后,接下来,我们需要对接口进行分析:

1. 通过 URL 上传图片


2. 发现接口:


3. 点击接口,查看接口返回值:


4. 访问上一步得到的页面地址,可以在返回值中找到 firstUrl


如果浏览器控制台响应中找不到这个页面地址,就可以考虑抓包或者换一个浏览器进行操作;


我们使用 fiddler 进行抓包,获取到 upload 接口返回响应的 url:


还有一个更简单的方法:因为如果我们直接在浏览器中打开 upload 的结果,因为缺少很多请求参数,所以会报一个参数不合法的错误:


我们可以先复制一个完整的请求,这个请求就会包含缺少的参数,我们指定复制请求的格式是:


打开 cmd,执行复制的请求:


5. 访问 firstUrl,因为 url 中包含部分转义字符,所以直接访问抓包的 url,会访问失败:


我们先找到在线解码工具,对 url 进行解码,再次访问解码后的 url:


6. 访问 url 成功后,如何获取页面中展示的图片呢?


7. 按照上面的步骤操作,即可获取到 firstUrl,复制 firstUrl 并修改:


8. 打开 firstUrl

https://graph.baidu.com/ajax/pcsimi?carousel=503\&entrance=GENERAL\&extUiData[isLogoShow]=1\&inspire=general_pc\&limit=30\&next=2\&render_type=card\&session_id=4708351762571702325\&sign=12667495065e063840c6101753412941\&tk=35cb5\&tpl_from=pc

就能得到 JSON 格式的相似图片列表,里面包含了图片的缩略图和原图地址。

💡 友情提示:这种方式只适合学习使用!注意不要给目标网站带来压力!!否则后果自负!!!


后端开发


新建 api 包,由于项目可能会用到多个 api,可以将每个 api 都放在 api 目录下的一个包中。

比如图片搜索 api 的相关代码,全部放在 api.imagesearch 包下。


1. 数据模型开发


imagesearch.model 包中,新建一个图片搜索结果类,用于接受 API 的返回值:

java 复制代码
@Data
public class ImageSearchResult {
    /**
     * 缩略图地址
     */
    private String thumbUrl;
    /**
     * 来源地址
     */
    private String fromUrl;
}

2. API开发****


根据方案,我们要调用多个 API,每个子 API 可以作为一个静态类来实现,统一放在 imagesearch.sub 包中,并且每个类都包含一个 main 方法,用于进行本地测试。


(1) 获取以图搜图的页面地址

通过向百度发送 POST 请求,获取给定图片 URL 的相似图片页面地址。


1. 首先,我们需要获取到百度以图搜图时,upload 接口需要的请求参数:

复制一份表单信息,然后在我们自己的接口中构造出来;


2. 并且upload 接口后面还跟着查询字符串参数 uptime,我们也需要构造出来:


3. 构造请求地址


6. 解析响应的值为:


以上内容为下面代码注释步骤的特别说明:

java 复制代码
@Slf4j
public class GetImagePageUrlApi {

    /**
     * 获取以图搜图页面地址
     * @param imageUrl
     * @return
     */
    public static String getImagePageUrl(String imageUrl){
        // 调用百度以图搜图接口, F12 中 upload 载荷的请求参数:
        // image: https%3A%2F%2Fpic35.photophoto.cn%2F20150511%2F0034034892281415_b.jpg
        //tn: pc
        //from: pc
        //image_source: PC_UPLOAD_URL
        //sdkParams:  这个参数用不到

        // 1. 根据表单数据, 准备请求参数
        Map<String, Object> formData = new HashMap<>();
        formData.put("image", imageUrl);
        formData.put("tn", "pc");
        formData.put("from", "pc");
        formData.put("image_source", "PC_UPLOAD_URL");

        // 2. 获取当前时间戳, 用于构造查询字符串 uptime
        long uptime = System.currentTimeMillis();

        // 3. 请求地址
        String url = "https://graph.baidu.com/upload?uptime=" + uptime;

        // 11. 发送请求, 解析响应的过程可能会出现异常, 包一层 try 来捕获中间过程可能出现的一层
        try {
            // 4. 使用 Hutool 工具类发送请求
            HttpResponse httpResponse = HttpRequest.post(url)
                	// 这里需要指定acs-token 不然会响应系统异常
                    .header("acs-token", RandomUtil.randomString(1))
                    .form(formData)
                    .timeout(5000)
                    .execute();

            // 5. 请求的响应码错误, 抛异常
            if(httpResponse.getStatus() != HttpStatus.HTTP_OK){
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "接口调用失败");
            }

            // 6. 请求的响应码正确, 解析响应
            // {"status":0,"msg":"Success","data":{"url":"https://graph.baidu.com/s?.....","sign":"....."}}
            String body = httpResponse.body();
            // 获取响应体
            Map<String, Object> result = JSONUtil.toBean(body, Map.class);
            // 将响应体转换为 Map 结构

            // 7. 处理响应结果不正确的情况
            if(result == null || !Integer.valueOf(0).equals(result.get("status"))){
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "接口调用失败");
            }

            // 8. 处理响应结果正确的情况
            Map<String, Object> data = (Map<String, Object>) result.get("data");

            // 9. 从 data 中取出 url, 并且使用 Hutool 工具类对 url 进行 UTF-8 格式的解码
            String rawUrl = (String) data.get("url");
            String searchResultUrl = URLUtil.decode(rawUrl, StandardCharsets.UTF_8);
            if(StrUtil.isBlank(searchResultUrl)){
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "未返回有效的结果地址");
            }

            // 10. 返回结果
            return searchResultUrl;
        }catch (Exception e){
            // 12. 打印日志, 抛异常
            log.error("调用百度以图搜图结果失败", e);
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜索失败");
        }
    }

    public static void main(String[] args) {
        // 12. 调用以图搜图接口测试
        String imageUrl = "https://pic35.photophoto.cn/20150511/0034034892281415_b.jpg";
        String searchResultUrl = getImagePageUrl(imageUrl);
        System.out.println("搜索成功, 结果 URL " + searchResultUrl);
    }
}

main 方法测试接口的结果:

tex 复制代码
搜索成功, 结果 URL https://graph.baidu.com/s?card_key=&entrance=GENERAL&extUiData[isLogoShow]=1&f=all&isLogoShow=1&session_id=485990642469686119&sign=12602495065e063840c6101753453440&tpl_from=pc

打开 URL ,就是我们要获取的目标结果页面:


(2) 获取图片列表页面地址

接下来,我们需要获取目标页面响应中的 firstUrl


通过 jsoup 爬取 HTML 页面,提取其中包含 firstUrl 的 JavaScript 脚本,并返回图片列表的页面地址。


java 复制代码
@Slf4j
public class GetImageFirstUrlApi {
    /**
     * 获取图片列表页面地址
     *
     * @param url 页面地址
     * @return 图片列表的页面地址
     */
    public static String getImageFirstUrl(String url) {
        try {
            // 使用 Jsoup 获取 HTML 内容
            Document document = Jsoup.connect(url)
                    .timeout(5000)
                    .get();
            // 获取所有 <script> 标签
            Elements scriptElements = document.getElementsByTag("script");
            // 遍历找到包含 `firstUrl` 的脚本内容
            for (Element script : scriptElements) {
                String scriptContent = script.html();
                if (scriptContent.contains("\"firstUrl\"")) {
                    // 正则表达式提取 firstUrl 的值
                    Pattern pattern = Pattern.compile("\"firstUrl\"\\s*:\\s*\"(.*?)\"");
                    Matcher matcher = pattern.matcher(scriptContent);
                    if (matcher.find()) {
                        String firstUrl = matcher.group(1);
                        // 处理转义字符
                        firstUrl = firstUrl.replace("\\/", "/");
                        return firstUrl;
                    }
                }
            }
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "未找到 url");
        } catch (Exception e) {
            log.error("搜索失败", e);
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜索失败");
        }
    }
}

测试接口:

第二步的接口,放在第一步获取页面地址的 main 方法中测试,,是因为必须先调用获取 resultUrl 的接口先建立 HTTP 连接,才能进行进一步操作:从 resultUrl 中 获取 firstUrl


测试结果:

tex 复制代码
搜索成功, resultUrl: https://graph.baidu.com/s?card_key=&entrance=GENERAL&extUiData[isLogoShow]=1&f=all&isLogoShow=1&session_id=8584231745208082887&sign=12672495065e063840c6101753521748&tpl_from=pc

搜索成功,firstURL: https://graph.baidu.com/ajax/pcsimi?carousel=503&entrance=GENERAL&extUiData%5BisLogoShow%5D=1&inspire=general_pc&limit=30&next=2&render_type=card&session_id=8584231745208082887&sign=12672495065e063840c6101753521748&tk=30cdf&tpl_from=pc

打开 firstURL ,查看结果:


(3) 获取图片列表

接下来,我们要处理 firstUrl,编写接口的思想如下:

先判断响应状态 status,再判断是否有 data,再判断 data 中是否有 list,有 list 则定义 JSONArray 来拿到 list,这个 list 就包含我们以图搜图的所以图片的结果 URL:


通过调用百度接口返回的 JSON 数据,提取出其中的图片列表并返回。

java 复制代码
@Slf4j
public class GetImageListApi {
    /**
     * 获取图片列表
     *
     * @param url 图片列表的页面地址
     * @return 图片搜索结果列表
     */
    public static List<ImageSearchResult> getImageList(String url) {
        try {
            // 发起 GET 请求
            HttpResponse response = HttpUtil.createGet(url).execute();
            // 获取响应内容
            int statusCode = response.getStatus();
            String body = response.body();
            // 处理响应
            if (statusCode == 200) {
                // 解析 JSON 数据并处理
                return processResponse(body);
            } else {
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "接口调用失败");
            }
        } catch (Exception e) {
            log.error("获取图片列表失败", e);
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取图片列表失败");
        }
    }

    /**
     * 处理接口响应内容
     *
     * @param responseBody 接口返回的 JSON 字符串
     * @return 图片搜索结果列表
     */
    private static List<ImageSearchResult> processResponse(String responseBody) {
        // 解析响应对象
        JSONObject jsonObject = new JSONObject(responseBody);
        if (!jsonObject.containsKey("data")) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "未获取到图片列表");
        }
        JSONObject data = jsonObject.getJSONObject("data");
        if (!data.containsKey("list")) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "未获取到图片列表");
        }
        JSONArray list = data.getJSONArray("list");
        return JSONUtil.toList(list, ImageSearchResult.class);
    }
}

接口测试:


测试结果:


3. 图片搜索服务(门面模式)


这里我们运用一种设计模式来提供图片搜索服务。门面模式通过提供一个统一的接口,来简化多个接口的调用,使得客户端不需要关注内部的具体实现

比如你去餐厅吃饭,餐厅的菜单就像是门面模式的统一接口。你只需要在菜单上找到自己想吃的菜(比如"红烧肉"),然后告诉服务员要点这道菜。服务员会去通知厨师,厨师会在厨房里用各种复杂的烹饪设备和食材(比如切菜、炒菜、调味等)来完成这道菜。你不需要知道厨房里具体是怎么做的,只需要通过菜单这个"统一接口"来点菜,就能享受到美味的红烧肉。

我们可以将多个 API 整合到一个门面类中,简化调用过程。在 imagesearch 包下新建门面类,整合几个接口的调用:

java 复制代码
@Slf4j
public class ImageSearchApiFacade {
    /**
     * 搜索图片
     *
     * @param imageUrl 图片 URL
     * @return 图片搜索结果列表
     */
    public static List<ImageSearchResult> searchImage(String imageUrl) {
        String imagePageUrl = GetImagePageUrlApi.getImagePageUrl(imageUrl);
        String imageFirstUrl = GetImageFirstUrlApi.getImageFirstUrl(imagePageUrl);
        List<ImageSearchResult> imageList = GetImageListApi.getImageList(imageFirstUrl);
        return imageList;
    }

    public static void main(String[] args) {
        // 测试以图搜图功能
        String imageUrl = "https://www.codefather.cn/logo.png";
        List<ImageSearchResult> resultList = searchImage(imageUrl);
        System.out.println("结果列表:" + resultList);
    }
}

使用门面模式,把以图搜图的三个接口,按照正确的顺序封装到了一个接口中,实现了高内聚低耦合,减少沟通成本;


测试结果:


4. 接口开发


开发请求类

我们只需要传一个 pictureId ,即可从数据库中获取这张图片的 URL,再以 URL 调用以图搜图接口即可;因此,我们的以图搜图请求类设计如下:

java 复制代码
@Data
public class SearchPictureByPictureRequest implements Serializable {
    /**
     * 图片 id
     */
    private Long pictureId;
    private static final long serialVersionUID = 1L;
}

开发接口

java 复制代码
/**
 * 以图搜图
 */
@PostMapping("/search/picture")
public BaseResponse<List<ImageSearchResult>> searchPictureByPicture(@RequestBody SearchPictureByPictureRequest searchPictureByPictureRequest) {
    ThrowUtils.throwIf(searchPictureByPictureRequest == null, ErrorCode.PARAMS_ERROR);
    Long pictureId = searchPictureByPictureRequest.getPictureId();
    ThrowUtils.throwIf(pictureId == null || pictureId <= 0, ErrorCode.PARAMS_ERROR);
    Picture oldPicture = pictureService.getById(pictureId);
    ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);
    List<ImageSearchResult> resultList = ImageSearchApiFacade.searchImage(oldPicture.getUrl());
    return ResultUtils.success(resultList);
}

三、图片搜索 - 颜色搜索需求分析


需求分析


能够按照颜色搜索空间内 主色调 最相似的图片,在设计、创意和电商领域有广泛应用。

参考其他网站的颜色搜图效果:

此处我们将该功能限定在空间内使用,主要是考虑到公共图库的图片数量可能非常庞大,直接进行颜色匹配会导致搜索速度较慢,影响用户体验。


方案设计


需要思考几个问题:

  1. 整体业务流程
  2. 怎么获取图片主色调
  3. 怎么设计搜索算法

1. 整体流程


为了提升性能,避免每次搜索都实时计算图片主色调,建议在图片上传成功后立即提取主色调并存储到数据库的独立字段中。完整流程如下:

  1. 提取图片颜色:通过图像处理技术(云服务 API 或者 OpenCV 图像处理库)提取图片的颜色特征,可以采用主色调、颜色直方图等方法表示图片的颜色特征。此处我们采用主色调,便于理解。
  2. 存储颜色特征:将提取的颜色数据存储到数据库中,以便后续快速检索。
  3. 用户查询输入:用户通过颜色选择器、RGB 值输入、或预定义颜色名称指定颜色查询条件。
  4. 计算相似度:根据用户指定的颜色,与数据库中的颜色特征进行相似度计算(如欧氏距离、余弦相似度等方法)
  5. 返回结果:由于空间内的图片数量相对较少,可以按照图片与目标颜色的相似度进行排序,优先返回最符合用户要求的图片,而不是仅返回完全符合指定色调的图片。


2. 怎么获取图片主色调?


我们存储图片使用的 COS 对象存储服务已经帮我们整合了 数据万象,自带获取图片主色调的功能,参考文档

💡 在使用云服务功能前,我们可以详细了解下服务的相关限制,比如数据万象 使用限制,一般情况下达不到限制。

除了方便之外,这个功能属于基础图片处理,官方提供的免费额度较高,适合学习测试。

💡 一般我们做项目时,尽可能减少新依赖或服务的引入,会让成本更可控。比如看到腾讯云 COS 有现成的支持和免费额度,就已经是我们的首选解决方案,无需考虑第三方 API,可能会带来的额外限制和兼容性问题(比如我们的图片开启防盗链,可能就解析不到)。


3. 如何计算颜色相似度?


数据库不支持直接按照颜色检索,用 LIKE 检索又不符合颜色的特性。所以可以使用一些算法来解决。此处使用 欧几里得距离 算法:

颜色可以用 RGB 值表示,可以通过计算两种颜色 RGB 值之间的欧几里得距离来判断它们的相似度。

公式:

解释:

  • ( R1, G1, B1 ):第一个颜色的 RGB 分量(红色、绿色、蓝色)。
  • ( R2, G2, B2 ):第二个颜色的 RGB 分量。
  • ( d ):两个颜色之间的欧几里得距离。
  • 距离越小,表示颜色越相似;距离越大,表示颜色越不同。

还有一些其他的方法,需要用到时自己在网上调研即可:

  • 余弦相似度 (Cosine Similarity):伙伴匹配系统项目中有讲过。
  • 曼哈顿距离 (Manhattan Distance)
  • Jaccard 相似度 (Jaccard Similarity)
  • 平均颜色差异 (Mean Color Difference)
  • 哈希算法 (Color Hashing)
  • 色调、饱和度和亮度 (HSL) 差异

后端开发


1. 补充颜色字段


(1) 图片表新增字段,执行 SQL:

sql 复制代码
ALTER TABLE picture
ADD COLUMN picColor varchar(16) null comment '图片主色调';

(2) 每次新增字段时,都要修改 PictureMapper.xml 以支持新字段的查询。


Picture 实体类、PictureVO 包装类、UploadPictureResult(从上传文件的结果中,使用数据万象解析 color) 上传图片结果类也需要补充新字段:

java 复制代码
/**
 * 图片主色调
 */
private String picColor;

2. 存储颜色


(1) 修改 PictureUploadTemplatebuildResult 方法,直接从 ImageInfo 对象中就能获得主色调:

java 复制代码
uploadPictureResult.setPicColor(imageInfo.getAve());


注意两个 buildResult 方法都要修改,其中一个 buildResult 方法要补充 imageInfo 参数,修改的代码如下:

更新代码:9~10

java 复制代码
private UploadPictureResult buildResult(String originalFilename, CIObject compressCiObject, CIObject thumbnailCiObject, ImageInfo imageInfo) {
                                        
    // 9. 新增参数, 对象存储返回的图片信息 imageInfo
    // 1. 对原有的封装返回结果的方法代码进行修改
    // 2. 从压缩结果中获取宽高
    int picWidth = compressCiObject.getWidth();
    int picHeight = compressCiObject.getHeight();
    double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2).doubleValue();
    UploadPictureResult uploadPictureResult = new UploadPictureResult();
    // 3. 设置压缩后的原图地址
    uploadPictureResult.setUrl(cosClientConfig.getHost() + "/" + compressCiObject.getKey());
    // cosManage 将图片后缀 .jpg 等修改为 .webp, 新路径为 compressCiObject.getKey()

    //  4. ("/" 不知道会不会影响, 视频中带有这里也先补上)
    uploadPictureResult.setPicName(FileUtil.mainName(originalFilename));
    // 5. 从压缩结果中获取文件大小
    uploadPictureResult.setPicSize(compressCiObject.getSize().longValue());
    uploadPictureResult.setPicWidth(picWidth);
    uploadPictureResult.setPicHeight(picHeight);
    uploadPictureResult.setPicScale(picScale);
    // 6. 从压缩结果中取图片格式
    uploadPictureResult.setPicFormat(compressCiObject.getFormat());
    // 10. 获取图片主色调, 封装到返回结果中
    uploadPictureResult.setPicColor(imageInfo.getAve());
    // 8. 设置缩略图地址
    uploadPictureResult.setThumbnailUrl(cosClientConfig.getHost() + "/" + thumbnailCiObject.getKey());

    // 7. 返回可访问的地址
    return uploadPictureResult;
}


获取到的值格式为十六进制,如图:


(2) 图片服务的 uploadPicture 中补充设置 picColor,从而将该字段保存到数据库中:

java 复制代码
picture.setPicColor(uploadPictureResult.getPicColor());


3. 颜色相似度计算


新建 utils 包,直接利用 AI 来编写工具类:

java 复制代码
/**
 * 工具类:计算颜色相似度
 */
public class ColorSimilarUtils {
    private ColorSimilarUtils() {
        // 工具类不需要实例化
    }

    /**
     * 计算两个颜色的相似度
     *
     * @param color1 第一个颜色
     * @param color2 第二个颜色
     * @return 相似度(0到1之间,1为完全相同)
     */
    public static double calculateSimilarity(Color color1, Color color2) {
        int r1 = color1.getRed();
        int g1 = color1.getGreen();
        int b1 = color1.getBlue();
        int r2 = color2.getRed();
        int g2 = color2.getGreen();
        int b2 = color2.getBlue();
        // 计算欧氏距离
        double distance = Math.sqrt(Math.pow(r1 - r2, 2) + Math.pow(g1 - g2, 2) + Math.pow(b1 - b2, 2));
        // 计算相似度
        return 1 - distance / Math.sqrt(3 * Math.pow(255, 2));
    }

    /**
     * 根据十六进制颜色代码计算相似度
     *
     * @param hexColor1 第一个颜色的十六进制代码(如 0xFF0000)
     * @param hexColor2 第二个颜色的十六进制代码(如 0xFE0101)
     * @return 相似度(0到1之间,1为完全相同)
     */
    public static double calculateSimilarity(String hexColor1, String hexColor2) {
        Color color1 = Color.decode(hexColor1);
        Color color2 = Color.decode(hexColor2);
        return calculateSimilarity(color1, color2);
    }

    // 示例代码
    public static void main(String[] args) {
        // 测试颜色
        Color color1 = Color.decode("0xFF0000");
        Color color2 = Color.decode("0xFE0101");
        double similarity = calculateSimilarity(color1, color2);
        System.out.println("颜色相似度为:" + similarity);
        // 测试十六进制方法
        double hexSimilarity = calculateSimilarity("0xFF0000", "0xFE0101");
        System.out.println("十六进制颜色相似度为:" + hexSimilarity);
    }
}

4. 颜色查询服务


为了让大家学习更清晰,在图片服务中新编写按颜色查询图片的方法 searchPictureByColor,不和其他的搜索条件放在一起。


按照方案设计中的流程开发,代码如下:

java 复制代码
/**
 * 根据颜色搜索图片 
 * @param spaceId
 * @param picColor
 * @param loginUser
 * @return
 */
List<PictureVO> searchPictureByColor(Long spaceId, String picColor, User loginUser);

java 复制代码
@Override
public List<PictureVO> searchPictureByColor(Long spaceId, String picColor, User loginUser) {
    // (1) 校验参数
    // (2) 校验空间权限
    // (3) 查询该空间下的所有图片 (必须要有主色调)
    // (4) 如果没有图片, 直接返回空列表
    // (5) 将颜色字符串转换为主色调
    // (6) 计算相似度并排序
    // (7) 返回结果

    // 1. 校验参数
    ThrowUtils.throwIf(spaceId == null || StrUtil.isBlank(picColor) , ErrorCode.PARAMS_ERROR);
    ThrowUtils.throwIf(loginUser == null , ErrorCode.NO_AUTH_ERROR);

    // 2. 根据 spaceId 获取空间
    Space space = spaceService.getById(spaceId);
    ThrowUtils.throwIf(space == null , ErrorCode.NOT_FOUND_ERROR, "空间不存在");

    // 3. 校验空间权限, 只有空间管理者才可以在本空间进行颜色搜图
    if(!space.getUserId().equals(loginUser.getId())){
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间访问权限");
    }

    // 4. 查询该空间下的所有图片 (必须要有主色调)
    List<Picture> pictureList = this.lambdaQuery()
            .eq(Picture::getSpaceId, spaceId)
            .list();

    // 5. 如果没有图片, 直接返回空列表
    if(CollUtil.isEmpty(pictureList)){
        return new ArrayList<>();
    }

    // 6. 将颜色字符串转换为主色调, 提前对图片颜色进行解析
    Color targetColor = Color.decode(picColor);

    // 7. 计算相似度并排序
    List<Picture> sortedPictureList = pictureList.stream()
            .sorted(Comparator.comparingDouble(picture -> {
                // 获取图片颜色的十六进制
                String haxColor = picture.getPicColor();
                if (StrUtil.isBlank(haxColor)) {
                    // 如果是空字符串, 表示没有主色调, 设置为一个最大值, 会默认排序到最后
                    return Double.MAX_VALUE;
                }
                // 对图片颜色的十六进制进行解码, 得到颜色对象
                Color pictureColor = Color.decode(haxColor);
                // 计算相似度, 这里填负号, 因为欧几里得计算的值越大, 相似度越大, 但是 sorted() 默认从小到大排序, 所以要取反
                return -ColorSimilarUtils.calculateSimilarity(targetColor, pictureColor);
            }))
            .limit(12) // 取前 12 条
            .collect(Collectors.toList());

    // 8. 返回封装结果
    return sortedPictureList.stream()
            .map(PictureVO::objToVo)
            .collect(Collectors.toList());
}

上述代码有 2 个小细节:

  1. 我们提前把目标颜色从字符串转为 Color 对象,而不是每计算一张图都重新转换一次对象。
  2. 最后将 Picture 转为 PictureVO 时,不要调用 service 中的转换方法,会额外查询用户信息,这是没必要的。

5. 接口开发


1. 请求封装类 SearchPictureByColorRequest,需要传入空间 id 和主色调:

java 复制代码
@Data
public class SearchPictureByColorRequest implements Serializable {
    /**
     * 图片主色调
     */
    private String picColor;
    /**
     * 空间 id
     */
    private Long spaceId;
    private static final long serialVersionUID = 1L;
}

2. 开发接口:

java 复制代码
@PostMapping("/search/color")
public BaseResponse<List<PictureVO>> searchPictureByColor(@RequestBody SearchPictureByColorRequest searchPictureByColorRequest, HttpServletRequest request) {
    ThrowUtils.throwIf(searchPictureByColorRequest == null, ErrorCode.PARAMS_ERROR);
    String picColor = searchPictureByColorRequest.getPicColor();
    Long spaceId = searchPictureByColorRequest.getSpaceId();
    User loginUser = userService.getLoginUser(request);
    List<PictureVO> result = pictureService.searchPictureByColor(spaceId, picColor, loginUser);
    return ResultUtils.success(result);
}

6. 拓展


1. 刷新历史数据,让所有的图片都有主色调。

2. 将颜色搜索和其他的搜索相结合,比如先用其他的搜索条件过滤数据,再运用相似度算法排序。

3. 将颜色搜索应用到主页公共图库、图片管理页面等。

4. 使用 ES 分词搜索图片的名称和简介,鱼皮编程导航的聚合搜索项目、面试刷题平台项目都有从 0 开始的 ES 讲解。

5. 多模态搜索:可以用文字搜索图片内容,一般使用第三方云服务实现。比如 数据万象 智能检索 MetaInsight_腾讯云,可以通过自然语言或结构化的检索条件,分析存储在对象存储 COS 中的文件,满足对存储数据的管理、分析、检索需求。

智能检索利用数据万象已有的图片、视频、语音、文档等数据处理能力,提取文件的特征或元数据并索引到数据集中,提供文件的聚合统计查询、人脸图像检索、图片内容检索等能力。

使用它能实现更智能的搜索,比如:

  • 图片自动打标签
  • 自动识别出相同的人物进行分类(手机智能相册)

6. 自动按日期将图片分类到不同的文件夹中。

7. 颜色检索时,定义一个阈值范围,过滤掉不相似颜色。


四、图片批量管理需求分析


需求分析


用户可以对私有空间内的图片进行批量修改,包括:

  • 批量修改信息:修改图片的标签和分类
  • 批量重命名:批量修改图片名称

方案设计


1. 批处理操作优化


批量操作的实现并不难,首先查询出空间内所有的图片,然后最简单的方式就是 for 循环遍历一下嘛!但如果想让批量操作更快、更稳定地完成,我们需要注意几点:

  1. 数据校验:校验参数的合法性,并且校验用户是否具有空间的访问权限,确保操作安全。
  2. 查询优化:查询图片时,仅选择所需的字段(如 idspaceId),减少数据库开销。
  3. 事务:确保批量操作具有原子性,如果有一条更新失败,那么需要对这一批操作进行回滚,避免数据不一致。
  4. 批量更新:利用 MyBatis-Plus 提供的 updateBatchById 方法进行批量更新,而不是 for 循环多次操作数据库,从而提高性能并降低操作时间。

此外,如果要处理的数据量非常大(上千条),为了进一步优化性能,还可以结合使用线程池、分批处理和并发编程,提升大规模操作的效率。

还可以通过添加日志来记录批处理操作的执行情况,提高可观测性。


2. 批量重命名


最简单的实现是将所有图片都修改为同一个名称,但这样不够有区分度。

所以我们可以定义一个动态生成规则,允许用户在重命名时填写动态变量(占位符)。

比如用户输入 图片_{序号},其中 {序号} 就是动态变量,每个图片的序号都不同,会从 1 开始持续递增。

后端可以使用字符串替换方法来处理 {序号} 占位符,适用于比较简单的场景,如果动态生成规则很复杂,可以使用模板引擎技术,代码生成器平台项目中讲解过。


3. 扩展知识 -Spring表达式


提到动态替换内容,这里顺便分享一下 Spring 表达式技术

Spring 表达式语言(Spring Expression Language,简称 SpEL) 用于在 Spring 配置文件Java 代码中动态地查询和操作对象

SpEL 可以在运行时解析表达式,并执行对 Java 对象的访问、操作和计算,支持丰富的功能,如条件判断、方法调用、属性访问、集合处理、正则表达式等。

举一些语法示例:

java 复制代码
#{user.name}                     // 访问 user 对象的 name 属性
#{person.address.city}           // 访问嵌套对象地址中的 city 属性
#{user.getFullName()}            // 调用 user 对象的 getFullName() 方法
#{user.age > 18 ? 'Adult' : 'Child'}  // 根据 age 判断是否为成年人

举例一个应用场景:比如缓存注解 @Cacheable中,使用表达式根据方法参数动态生成缓存的 key:

java 复制代码
/**
 * 根据用户 ID 获取用户信息,并将结果缓存。
 * 使用 SpEL 动态生成缓存的 key,加入用户 ID 和请求的语言(locale)。
 *
 * @param userId 用户 ID
 * @param locale 当前语言环境(如 en, zh)
 * @return 用户信息
 */
@Cacheable(value = "users", key = "#userId + ':' + #locale")
public String getUserInfo(Long userId, String locale) {
    // 模拟数据库查询
    System.out.println("Fetching user info from DB...");
    return "User " + userId + " info in " + locale + " language";
}

它的实现方式可就不是字符串替换这么简单了,而是用到了 AST 抽象语法树 来对字符串进行解析,大家要对这种思路有个印象。


后端开发


1. 批量修改信息


(1) 开发请求类

接受图片 id 列表等字段:

java 复制代码
@Data
public class PictureEditByBatchRequest implements Serializable {
    /**
     * 图片 id 列表
     */
    private List<Long> pictureIdList;
    /**
     * 空间 id
     */
    private Long spaceId;
    /**
     * 分类
     */
    private String category;
    /**
     * 标签
     */
    private List<String> tags;
    private static final long serialVersionUID = 1L;
}

(2) 服务开发

开发批量修改图片服务,依次完成参数校验、空间权限校验、图片查询、批量更新操作:


java 复制代码
/**
 * 批量编辑图片接口
 * @param pictureEditByBatchRequest 批量编辑图片请求类
 * @param loginUser
 */
void editPictureByBatch(PictureEditByBatchRequest pictureEditByBatchRequest, User loginUser)

java 复制代码
@Override
public void editPictureByBatch(PictureEditByBatchRequest pictureEditByBatchRequest, User loginUser) {
    // (1) 获取参数并校验
    // (2) 校验空间权限
    // (3) 查询指定图片 (仅选择需要的字段)
    // (4) 更新分类和标签
    // (5) 操作数据库进行批量更新

    // 1. 从请求中获取参数
    List<Long> pictureIdList = pictureEditByBatchRequest.getPictureIdList();
    Long spaceId = pictureEditByBatchRequest.getSpaceId();
    String category = pictureEditByBatchRequest.getCategory();
    List<String> tags = pictureEditByBatchRequest.getTags();

    // 2. 校验参数
    ThrowUtils.throwIf(CollUtil.isEmpty(pictureIdList), ErrorCode.PARAMS_ERROR);
    ThrowUtils.throwIf(spaceId == null , ErrorCode.PARAMS_ERROR);
    ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);

    // 3. 校验空间权限
    Space space = spaceService.getById(spaceId);
    ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");

    // 4. 校验是否为空间管理员
    if(!loginUser.getId().equals(space.getUserId())){
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间访问权限");
    }

    // 5. 查询指定图片
    List<Picture> pictureList = this.lambdaQuery()
            .select(Picture::getId, Picture::getSpaceId)
            .eq(Picture::getSpaceId, spaceId)
            .in(Picture::getId, pictureIdList)
            .list();
    // SQL: SELECT id, space_id FROM picture WHERE space_id = 123 AND id IN (1, 2, 3);
    // 收集到的结果存入列表中

    // 6. 根据 id 、spaceId 获取到的图片列表为空, 直接返回, 无需编辑
    if(pictureList.isEmpty()){
        return;
    }

    // 7. 更新分类和标签
    pictureList.forEach(picture -> {
        if(StrUtil.isNotBlank(category)){
            picture.setCategory(category);
        }
        if(CollUtil.isNotEmpty(tags)){
            // 将整个 tags 列表转为 JSON 字符串
            picture.setTags(JSONUtil.toJsonStr(tags));
        }
    });

    // 8. 操作数据库进行批量更新
    boolean result = this.updateBatchById(pictureList);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "批量编辑失败");
}

💡 对于我们的项目来说,由于用户要处理的数据量不大,上述代码已经能够满足需求。

但如果要处理大量数据,可以使用线程池 + 分批 + 并发进行优化,参考代码如下:

java 复制代码
@Resource
private ThreadPoolExecutor customExecutor;

/**
 * 批量编辑图片分类和标签
 */
@Override
@Transactional(rollbackFor = Exception.class)
public void batchEditPictureMetadata(PictureBatchEditRequest request, Long spaceId, Long loginUserId) {
    // 参数校验
    validateBatchEditRequest(request, spaceId, loginUserId);

    // 查询空间下的图片
    List<Picture> pictureList = this.lambdaQuery()
            .eq(Picture::getSpaceId, spaceId)
            .in(Picture::getId, request.getPictureIds())
            .list();

    if (pictureList.isEmpty()) {
        throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "指定的图片不存在或不属于该空间");
    }

    // 分批处理避免长事务
    int batchSize = 100;
    List<CompletableFuture<Void>> futures = new ArrayList<>();

    for (int i = 0; i < pictureList.size(); i += batchSize) {
        List<Picture> batch = pictureList.subList(i, Math.min(i + batchSize, pictureList.size()));

        // 异步处理每批数据
        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            batch.forEach(picture -> {
                // 编辑分类和标签
                if (request.getCategory() != null) {
                    picture.setCategory(request.getCategory());
                }
                if (request.getTags() != null) {
                    picture.setTags(String.join(",", request.getTags()));
                }
            });

            boolean result = this.updateBatchById(batch);
            if (!result) {
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "批量更新图片失败");
            }
        }, customExecutor);

        futures.add(future);
    }

    // 等待所有任务完成
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}

此外,还可以多记录日志,或者让返回结果更加详细,比如更新成功了多少条数据之类的。


(3) 接口开发

java 复制代码
@PostMapping("/edit/batch")
public BaseResponse<Boolean> editPictureByBatch(@RequestBody PictureEditByBatchRequest pictureEditByBatchRequest, HttpServletRequest request) {
    ThrowUtils.throwIf(pictureEditByBatchRequest == null, ErrorCode.PARAMS_ERROR);
    User loginUser = userService.getLoginUser(request);
    pictureService.editPictureByBatch(pictureEditByBatchRequest, loginUser);
    return ResultUtils.success(true);
}

2. 批量重命名


直接复用批量修改信息的方法,在此基础上做增强,补充对图片名称的修改。


(1) 补充字段

批量编辑请求类 PictureEditByBatchRequest 补充字段:

java 复制代码
/**
 * 命名规则
 */
private String nameRule;

(2) 补充图片名称

批量修改方法补充图片名称:

java 复制代码
// 批量重命名
String nameRule = pictureEditByBatchRequest.getNameRule();
fillPictureWithNameRule(pictureList, nameRule);


(3) 编写填充图片名称的方法

编写填充图片名称的方法,使用字符串的 replaceAll 方法替换动态变量:

java 复制代码
/**
 * 根据批量编辑请求中的命名规则, 填充被选择的图片
 *
 * @param pictureList
 * @param nameRule 格式: 图片{序号}
 */
private void filePictureWithNameRule(List<Picture> pictureList, String nameRule) {
    if(StrUtil.isBlank(nameRule) || CollUtil.isEmpty(pictureList)){
        return;
    }
    // 遍历所有被选择的图片
    long count = 1;
    try{
        for(Picture picture : pictureList){
            // replaceAll() 会将 {序号} 全部换成数字, 如果使用 replaceAll, { 需要用 \\ 转义
            // 如果 nameRule = "图片{序号}", 第一次执行时 count = 1, 则结果变成 "图片1",同时 count 变为 2
            String pictureName = nameRule.replaceAll("\\{序号}", String.valueOf(count++));
            picture.setName(pictureName);
        }
    }catch (Exception e){
        log.error("名称解析错误", e);
        throw new BusinessException(ErrorCode.OPERATION_ERROR, "名称解析错误");
    }
}

相关推荐
飞翔的佩奇1 小时前
Java项目:基于SSM框架实现的社区团购管理系统【ssm+B/S架构+源码+数据库+毕业论文+答辩PPT+远程部署】
java·数据库·vue.js·毕业设计·mybatis·答辩ppt·社区团购
neoooo3 小时前
《锁得住,才能活得久》——一篇讲透 Redisson 分布式锁的技术实录
java·spring boot·redis
Always_July3 小时前
MyBatis-Plus TypeHander不生效
后端·mybatis
胡斌附体4 小时前
mybatis-plus逻辑删除配置
java·mybatis·mybatis-plus·逻辑删除
用户6083089290475 小时前
Spring Boot自定义注解
spring boot
普郎特6 小时前
大白话帮你彻底理解 aiohttp 的 ClientSession 与 ClientResponse 对象
爬虫·python
马哥python说7 小时前
【效率软件】抖音转换工具:主页链接和抖音号一键批量互转
爬虫·python
harmful_sheep7 小时前
easyexcel流式导出
servlet
hrrrrb7 小时前
【Spring Boot 快速入门】二、请求与响应
spring boot·后端
小七mod7 小时前
【Spring】Spring Boot启动过程源码解析
java·spring boot·spring·面试·ssm·源码