【项目028】基于内容的图像检索(十二生肖)

作者:欧新宇(Xinyu OU)
当前版本:Release v1.1
开发平台:Paddle 2.3.2
运行环境:Intel Core i7-7700K CPU 4.2GHz, nVidia GeForce GTX 1080 Ti
本教案所涉及的数据集仅用于教学和交流使用,请勿用作商用。

最后更新:2022年4月8日


本项目使用【项目013】 迁移学习和恢复训练(十二生肖) 所生成的模型进行特征提取,大家可以自行按照该项目的要求进行模型训练,也可以使用本课程已经训练好模型(Zodiac_Mobilenetv2)完成本实验。

0.基于内容的图像检索

基于内容的图像检索(CBIR, Content-Based Image Retrieval) 是计算机视觉领域中关注大规模数字图像内容检索的研究分支。典型的CBIR系统,允许用户输入一张图片,以查找具有相同或相似内容的其他图片。而传统的图像检索是基于文本的,即通过图片的名称、文字信息和索引关系来实现查询功能。
这一概念于1992年由T.Kato提出的。他在论文中构建了一个基于色彩与形状的图像数据库,并提供了一定的检索功能进行实验。此后,基于图像特征提取以实现图像检索的过程以及CBIR这一概念,被广泛应用于各种研究领域,如统计学、模式识别、信号处理和计算机视觉。
相关研究已发展近20年,传统的搜索引擎公司包括Google、百度、Bing都已提供一定的基于内容的图像搜索产品,例如Google Similar Images,百度识图;此外,包括京东,淘宝的通过拍照进行商品搜索也是基于内容的图像检索的典型应用。

基于内容的图像检索,如下图所示,一般包括以下几个步骤:

  1. 训练一个用于图像分类(大多数时候)的深度神经网络模型。
  2. 利用预训练好的模型对图像库(Gallery)进行特征提取,并将这些特征向量保存到本地。
  3. 获取用户图像或待检索图像(Query),并利用预训练好的模型对其进行特征提取。值得注意的是对Query进行特征提取的时候,需要使用提取图像库时完全相同的方法和图像预处理方法。
  4. 计算待检索图像的特征向量和图像库中每个图像的特征向量之间相似度,并按照从大到小的顺序进行排列。
  5. 输出相似度最大的TopK个图像样本。

在进行图像特征提取和图像特征表达的时候,我们一般使用三种类型特征对图像进行描述,分别是基于高层语义的特征表达、基于深度学习的中级/高级特征表达、基于哈希编码的特征表达;此外,我们常常使用多种特征的混合特征对图像进行描述,例如同一个深度模型不同深度级的特征,基于特征工程的颜色、纹理、SIFT、HoG等特征与CNN特征的混合融合等。本项目主要关注单一特征的实现,有兴趣的同学可以根据数据的特性,尝试多种不同特征的融合,通常都会获得更好的检索性能(精确率precision, 召回率recall)。

1.导入依赖库及数据预处理函数

1.1 定义依赖库及全局参数

# 导入依赖库
import numpy as np
import random
import os
import cv2
import json
import matplotlib.pyplot as plt
import paddle
import paddle.nn.functional as F

# 定义全局参数
args={
    'project_name': 'Project11ZodiacRetrieval',
    'dataset_name': 'Zodiac',
    'architecture': 'Mobilenetv2',
    'input_size': [3, 227, 227],
    'mean_value': [0.485, 0.456, 0.406],     # Imagenet均值
    'std_value': [0.229, 0.224, 0.225],      # Imagenet标准差
    'dataset_root_path': 'D:\\Workspace\\ExpDatasets\\',
    'deployment_root_path': 'D:\\Workspace\\ExpDeployments\\',
    'result_root_path': 'D:\\Workspace\\ExpResults\\'
}

# 定义数据集中图片的路径,用于生成图片特征
model_name = args['dataset_name'] + '_' + args['architecture']
dataset_path = os.path.join(args['dataset_root_path'], args['dataset_name'])
Gallery_filelist = os.path.join(dataset_path, 'trainval.txt')
Query_filelist = os.path.join(dataset_path, 'test.txt')
json_dataset_info = os.path.join(dataset_path, 'dataset_info.json')

# 定义预训练好的模型和模型参数的
deployment_path = os.path.join(args['deployment_root_path'], args['project_name'])
weights_path = os.path.join(deployment_path, 'weights', args['architecture'], model_name+'_final')
models_path = os.path.join(deployment_path, 'models')

# 定义特征保存的路径
result_path = os.path.join(args['result_root_path'], args['project_name'], 'features')

# 输出测试,判断定义的相关文件/文件夹是否存在
if __name__ == '__main__':
    try:
        os.path.exists(model_name)
        os.path.exists(deployment_path)
        os.path.exists(dataset_path)
        os.path.exists(Gallery_filelist)
        os.path.exists(Query_filelist)
        print('测试通过!')
    except:
        print('部分文件不存在')
    if not os.path.exists(result_path):
        os.makedirs(result_path)
        print('create result path success.')

测试通过!

1.2 数据预处理函数

数据预处理函数用于对图片输入模型进行前向传输前进行基本的处理,此步骤和分类、检测任务进行的数据预处理相同,主要包括图像Resize,转换色彩通道,减均值和去方差。

import paddle
import paddle.vision.transforms as T

transform = T.Compose([
    T.ToTensor(),
    T.Normalize(mean=args['mean_value'], std=args['std_value']) 
])


def SimplePreprocessing(image, transform=transform, input_size = (args['input_size'][1],args['input_size'][2])):

    image = cv2.resize(image, input_size)
    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)    
        


    image = transform(image)
    image = paddle.unsqueeze(image, axis=0) 
            
    return image

2. 基于语义特征的图像检索

基于语义特征的图像检索是指按照图像的类别进行检索,这种检索方法要求语义分类模型具有较高的准确率,即当模型总是能够将图像划分到正确的语义类别中时,这种方法的效果会比较好。缺点是,当某个样本被错误地进行分类,那么基于语义特征的图像检索将无法得到正确的检索返回。

在使用基于特征的图像检索时,我们可以直接输出FC8层的特征,即最后一层的语义类别概率值。通常,还需要将该概率值使用Softmax实现[0,1]归一化。下面的代码实现直接从模型中预先训练好的十二生肖数据集 模型中获取模型参数。paddle.summary() 展示了该模型的基本机构特性,不难发现它只保留了输出层的特征矩阵。

2.1 载入训练好的模型

import sys
sys.path.append(models_path)
import mobilenetv2

model = mobilenetv2.mobilenet_v2(num_classes=12)
model = paddle.jit.load(weights_path)
paddle.summary(model, (1,3,227,227))
    -----------------------------------------------------------------------------
      Layer (type)        Input Shape          Output Shape         Param #    
    =============================================================================
    TranslatedLayer-1  [[1, 3, 227, 227]]        [1, 12]           2,273,356   
    =============================================================================
    Total params: 2,273,356
    Trainable params: 2,273,356
    Non-trainable params: 0
    -----------------------------------------------------------------------------
    Input size (MB): 0.59
    Forward/backward pass size (MB): 0.00
    Params size (MB): 8.67
    Estimated Total Size (MB): 9.26
    -----------------------------------------------------------------------------

    {'total_params': 2273356, 'trainable_params': 2273356}

2.2 定义特征获取函数

特征获取函数的基本功能是实现图像的前向推理,主要包括以下几个功能:

  1. 读取并打开图像列表文件;
  2. 定义一个特征矩阵 features,用于保存图像经过模型推理后生成的特征,该矩阵的形态为 [n, Dim],其中n为图像的数量,Dim为特征的维度;
  3. 顺序读取图像,并使用训练好的模型对其进行前向推理,生成特征向量;
  4. 将特征向量转换为Numpy数据类型,并保存在特征矩阵features中。
def get_feature(file_list, dim_feature):
    with open(file_list, 'r') as f:
        lines = f.readlines()
        num = len(lines)
        features = np.zeros([num, dim_feature], dtype=np.float32)
        for n in range(1,num+1):
            img_path = lines[n-1].strip().split('\t')[0]
            img = cv2.imread(img_path, 1)
            img = SimplePreprocessing(img)
            logits = model(img)
            # logits = F.softmax(logits)        # 归一化到[0,1]
            # features[n-1] = logits.numpy()

            if n % 10 == 0 or n == num:            
                print('\r已处理{}/{}幅图片.'.format(n, num), end='')
        print('...处理完毕,特征维度:{}。'.format(features.shape))
    return features

2.3 保存特征向量

使用 pickle库,将生成特征保存到硬盘中,用于后续的检索。通常,我们需要保持的是用于检索的图像库Gallery。在本例中,我们将 训练验证集合trainval 当做图像库Gallery,将测试集test 当作待检索样本Query。在进行测试时,我们会随机地从待检索样本库中随机获取一张图片,并对其在图像库Gallery中进行检索查询。

PS:为了便于对模型进行评估,通常还会将待检索库中的所有图像也进行特征预处理,并保存在硬盘中。

import pickle

features_query = get_feature(Query_filelist, dim_feature=12)
# pickle.dump(features_query, open(os.path.join(result_path, 'Features_Query_Classify.pkl'), 'wb'))
# features_gallery = get_feature(Gallery_filelist, dim_feature=12)
# pickle.dump(features_gallery, open(os.path.join(result_path, 'Features_Gallery_Classify.pkl'), 'wb'))

已处理160/660幅图片.

2.4 输出检索结果

# Feature_classify
import paddle.nn.functional as F
import paddle
import pickle

# 1. 获取待处理图像库Query中的文件
with open(Query_filelist, 'r') as f_test:
    lines = f_test.readlines()
line = random.choice(lines)
img_path, label = line.split() 

# 2. 获取待预测样本和图像库样本的深度特征
# 2.1 读取待预测图像,并进行预测
image = cv2.imread(img_path, 1)
image = SimplePreprocessing(image)
logits = model(image)
features_query = F.softmax(logits)
# print(img_path)     # 显示带查询图像的路径
# print(logits.shape) # 显示带查询图像特征的维度
# 2.2 从硬盘读取实现保存的Gallery图像库样本特征
features_gallery = pickle.load(open(os.path.join(result_path, 'Features_Gallery_Classify.pkl'), 'rb'))

# 3. 计算待查询样本和图像库所有样本间的相似度,并输出Top_k的样本
# 3.1 将特征向量从Numpy数据类型转换为Paddle数据类型
tensor_features_query = paddle.to_tensor(features_query)
tensor_features_gallery = paddle.to_tensor(features_gallery)
# 3.2 使用Paddle.nn实现余弦距离的计算,获得相似性矩阵
similarities_matrix = paddle.nn.functional.common.cosine_similarity(tensor_features_query, tensor_features_gallery)
# 3.3. 对相似性矩阵进行排序,并输出top_k的样本
index = np.argsort(similarities_matrix)[::-1]
# 3.4 输出Top10的样本
# 3.4.1 读取图像库的图像
Gallery = Gallery_filelist # Gallery_filelist|Query_filelist
with open(Gallery, 'r') as f_gallery:
    lines_gallery = f_gallery.readlines()
# 3.4.2 读取图像类别标签
with open(json_dataset_info, 'r') as f_info:
    dataset_info = json.load(f_info)
# 3.4.3 将带查询图像和图像库图像地址合并成一个序列,便于统一展示
imgs_show = [img_path]
label_show = [label]
top_k = 10
for i in index[:top_k]:
    line = lines_gallery[i]
    img_path, label = line.split() 
    imgs_show.append(img_path)
    label_show.append(label)

# 3.4.4 显示图像序列
plt.figure(figsize=(30, 4))
for i in range(len(imgs_show)):   
    img = cv2.imread(imgs_show[i], 1)
    img = cv2.resize(img, [300, 200])
    img_RGB = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    plt.subplot(1,11,i+1)
    plt.imshow(img_RGB)
    plt.title(dataset_info['label_dict'][str(label_show[i])], fontsize=20)
    plt.xticks([])  # 去掉x轴
    plt.yticks([])  # 去掉y轴
    plt.axis('off')  # 去掉坐标轴

output_15_0

3.基于中级/高级特征的图像检索

基于中级/高级特征的图像检索,通常会使用后面几层的卷积层,或者用于Softmax分类前的两个全连接层作为图像的特征;同时,也可以使用多个特征层的融合特征作为图像的特征。常见的特征层包括[Conv5, GlobalAvgPool, FC6, FC6],以及它们之间的串联,并联或经过1×1卷积之后的融合特征。

3.1 载入训练好的模型(修改版)

在本例中,我们使用 mobilenetv2 模型最后的自适应全局均值池化层(AdaptiveAvgPool2D-6)作为图像的深度特征,用于实现检索。

为了使用 mobilenetv2 模型实现AdaptiveAvgPool2D-6层特征的输出,需要对模型进行剪枝,将AdaptiveAvgPool2D-6层后面的层删除。如下所示,代码 # x = self.classifier(x),实现最后一层的屏蔽,让前向传输只输出 pool2d_avg(x) 被拉成向量 (paddle.flatten()) 后的结果。

# mobilenetv2.py
def forward(self, x):
    x = self.features(x)

    if self.with_pool:
        x = self.pool2d_avg(x)

    if self.num_classes > 0:
        x = paddle.flatten(x, 1)
        # x = self.classifier(x)
    return x

通过 paddle.summary(model, (1,3,227,227)) 命令,我们可以展示出模型当前的结构。

     Conv2D-312        [[1, 320, 8, 8]]     [1, 1280, 8, 8]        409,600    
  BatchNorm2D-312     [[1, 1280, 8, 8]]     [1, 1280, 8, 8]         5,120     
     ReLU6-210        [[1, 1280, 8, 8]]     [1, 1280, 8, 8]           0       
AdaptiveAvgPool2D-6   [[1, 1280, 8, 8]]     [1, 1280, 1, 1]           0       
================================================================================

对于被修改过的mobilenetv2模型,我们可以通过 model.load_dict(model_state_dict) 实现已经训练好的模型参数的载入。因为最后一层的 classifier(x) 被注释掉了,所以参数在进行载入的时候,会自动将最后一层的权重丢弃,只保留 pool2d_avg 及之前的内容。此时,通过前向传输,我们就可以获得样本经过mobilenetv2模型之后,基于 AdaptiveAvgPool2D-6 层的深度特征了。

import sys
sys.path.append(models_path)
import mobilenetv2

model = mobilenetv2.mobilenet_v2(num_classes=1280) 
model_state_dict = paddle.load(weights_path)
model.load_dict(model_state_dict)
    C:\Users\Administrator\anaconda3\lib\site-packages\paddle\fluid\dygraph\layers.py:1301: UserWarning: Skip loading for classifier.1.weight. classifier.1.weight receives a shape [1280, 12], but the expected shape is [1280, 1280].
      warnings.warn(("Skip loading for {}. ".format(key) + str(err)))
    C:\Users\Administrator\anaconda3\lib\site-packages\paddle\fluid\dygraph\layers.py:1301: UserWarning: Skip loading for classifier.1.bias. classifier.1.bias receives a shape [12], but the expected shape is [1280].
      warnings.warn(("Skip loading for {}. ".format(key) + str(err)))

3.2 保存特征向量

与基于语义特征的图像检索类似,基于深度特征的图像检索也同样需要将图像库Gallery中样本事先进行特征提取并保存到硬盘中。

import pickle
features_query = get_feature(Query_filelist, dim_feature=1280)
pickle.dump(features_query, open(os.path.join(result_path, 'Features_Query_AdaptiveAvgPool2D-6.pkl'), 'wb'))
features_gallery = get_feature(Gallery_filelist, dim_feature=1280)
pickle.dump(features_gallery, open(os.path.join(result_path, 'Features_Gallery_AdaptiveAvgPool2D-6.pkl'), 'wb'))

已处理660/660幅图片....处理完毕,特征维度:(660, 1280)。
已处理7840/7840幅图片....处理完毕,特征维度:(7840, 1280)。

3.3 输出检索结果

# Feature_classify
import paddle.nn.functional as F
import paddle
import pickle

# 1. 获取待处理图像库Query中的文件
with open(Query_filelist, 'r') as f_test:
    lines = f_test.readlines()
line = random.choice(lines)
img_path, label = line.split() 

# 2. 获取待预测样本和图像库样本的深度特征
# 2.1 读取待预测图像,并进行预测
image = cv2.imread(img_path, 1)
image = SimplePreprocessing(image)
logits = model(image)
features_query = logits
# features_query = F.softmax(logits)
# print(img_path)     # 显示带查询图像的路径
# print(logits.shape) # 显示带查询图像特征的维度
# 2.2 从硬盘读取实现保存的Gallery图像库样本特征
features_gallery = pickle.load(open(os.path.join(result_path, 'Features_Gallery_AdaptiveAvgPool2D-6.pkl'), 'rb'))

# 3. 计算待查询样本和图像库所有样本间的相似度,并输出Top_k的样本
# 3.1 将特征向量从Numpy数据类型转换为Paddle数据类型
tensor_features_query = paddle.to_tensor(features_query)
tensor_features_gallery = paddle.to_tensor(features_gallery)
# 3.2 使用Paddle.nn实现余弦距离的计算,获得相似性矩阵
similarities_matrix = paddle.nn.functional.common.cosine_similarity(tensor_features_query, tensor_features_gallery)
# 3.3. 对相似性矩阵进行排序,并输出top_k的样本
index = np.argsort(similarities_matrix)[::-1]
# 3.4 输出Top10的样本
# 3.4.1 读取图像库的图像
Gallery = Gallery_filelist # Gallery_filelist|Query_filelist
with open(Gallery, 'r') as f_gallery:
    lines_gallery = f_gallery.readlines()
# 3.4.2 读取图像类别标签
with open(json_dataset_info, 'r') as f_info:
    dataset_info = json.load(f_info)
# 3.4.3 将带查询图像和图像库图像地址合并成一个序列,便于统一展示
imgs_show = [img_path]
label_show = [label]
top_k = 10
for i in index[:top_k]:
    line = lines_gallery[i]
    img_path, label = line.split() 
    imgs_show.append(img_path)
    label_show.append(label)

# 3.4.4 显示图像序列
plt.figure(figsize=(30, 4))
for i in range(len(imgs_show)):   
    img = cv2.imread(imgs_show[i], 1)
    img = cv2.resize(img, [300, 200])
    img_RGB = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    plt.subplot(1,11,i+1)
    plt.imshow(img_RGB)
    plt.title(dataset_info['label_dict'][str(label_show[i])], fontsize=20)
    plt.xticks([])  # 去掉x轴
    plt.yticks([])  # 去掉y轴
    plt.axis('off')  # 去掉坐标轴

output_22_0

从上述结果中,我们可以看到检索系统获得了一些和给定样本较为相似的图像样本,及时很多返回的结果并不是那么理想,但依然给出了较为相似的检索结果。事实上,更好、更准确、更鲁棒的模型可以获得更好的检索结果。有兴趣的同学可以尝试使用ResNet系列模型用于提取图像特征。

4. 模型的检索性能进行评估

对检索系统的评估包括很多种评价指标,常见的包括召回率(Recall),精确度(Precision),F1分数(F1 Score),精确度-召回率曲线(PR Curve),以及时间复杂度T(n)和检索时间(Times),此外mAP@1, mAP@5, mAP@10等也是评价检索系统常用的方法。其中mAP@5表示对整个测试集都完成前五个返回值的准确率测评,并计算平均值。

# Feature_classify
import paddle.nn.functional as F
import paddle
import pickle

# 1. 读取预先保存好的待测图像Query和图像集Gallery的特征
# features_query = pickle.load(open(os.path.join(result_path, 'Features_Query_AdaptiveAvgPool2D-6.pkl'), 'rb'))
# features_gallery = pickle.load(open(os.path.join(result_path, 'Features_Gallery_AdaptiveAvgPool2D-6.pkl'), 'rb'))

features_query = pickle.load(open(os.path.join(result_path, 'Features_Query_Classify.pkl'), 'rb'))
features_gallery = pickle.load(open(os.path.join(result_path, 'Features_Gallery_Classify.pkl'), 'rb'))

tensor_features_query = paddle.to_tensor(features_query)
tensor_features_gallery = paddle.to_tensor(features_gallery)

# 2. 读取图像列表文件,获取图像类别标签,用于评估检索系统的mAP
with open(Query_filelist, 'r') as f_query:
    lines_query = f_query.readlines()
with open(Gallery_filelist, 'r') as f_gallery:
    lines_gallery = f_gallery.readlines()


# 3. 计算每一个查询图像关于图像库返回结果的相关度:0=不相关,1=相关
top_k = 10
num_query = features_query.shape[0]
scores = np.zeros([num_query, top_k])
for i in range(num_query):
    # 使用余弦距离计算相似性矩阵,并按照距离获得最接近待测样本的样本索引
    similarities_matrix = paddle.nn.functional.common.cosine_similarity(paddle.unsqueeze(tensor_features_query[i], axis=0), tensor_features_gallery)
    index = np.argsort(similarities_matrix)[::-1]

    _, label_query = lines_query[i].split()

    j = 0
    for n in index[:top_k]:
        line = lines_gallery[n]
        _, label_gallery = line.split()

        if label_gallery == label_query:
            scores[i, j] = 1
        j = j + 1

# 4. 根据相关度计算平均精度
scores_top1 = np.mean(scores[:,1])
scores_top5 = np.mean(np.mean(scores[:, :5], axis=1))
scores_top10 = np.mean(np.mean(scores[:, :10], axis=1))

print('评估完毕,模型的 mAP@1 = {:.2f}%, mAP@5 = {:.2f}%, mAP@10 = {:.2f}%。'.format(scores_top1*100, scores_top5*100, scores_top10*100))   

评估完毕,模型的 mAP@1 = 89.39%, mAP@5 = 90.09%, mAP@10 = 89.70%。

5.基于哈希编码的图像检索

哈希函数又称散列函数(或散列算法,又称哈希函数),是一种从任何一种数据中创建小的数字"指纹"的方法。哈希函数把消息或数据压缩成摘要,使得数据量变小,方便数据传输或加速运算,通常哈希值以二进制表示,例如:[0,1,1,1,0,0,0]。在信息检索算法中,经常使用哈希函数将样本的原始特征值转换为以0和1表示的二进制值进行编码,然后使用汉明距离计算两个样本的相似度。常见的哈希函数包括均值哈希中值哈希特定阈值哈希等,下面给出均值哈希的数学表达:

mean=1ni=1nximean = \frac{1}{n} \sum_{i=1}^n x_i

f(xi)={0x < mean1x>= meanf(x_i)=\begin{cases} 0& x \text{ < mean} \\ 1& x \text{>= mean} \end{cases}

在将深度转换为 Hashing 编码时,我们只需要将抽取的CNN特征输入到 Hashing 函数中,并转换成对应的哈希编码即可,在保存特征的时候,可以保存原始特征,也可以直接保存原始特征的哈希值。下面给出均值哈希的函数代码,有兴趣的同学可以尝试使用哈希编码替代原始特征进行图像检索。

import paddle
def Hashing(data):
    f = paddle.nn.Sigmoid()     # 使用paddle定义Sigmoid函数
    data = f(data)              # 应用Sigmoid函数,将特征值约束到[0,1]之间
    data = data.numpy()         # 将paddle数据类型,转换为Numpy数据类型
    threshold = np.mean(data)   # 在汉明距离中,一般使用均值作为阈值
    data[data<threshold] = 0    # 小于阈值的值,设置为 0
    data[data>threshold] = 1    # 大于阈值的值,设置为 1
    
    return data