易企秀基于elasticsearch快速构建图片搜索引擎(一)

96
_江边城外_ B67c298d f020 4f89 aac6 0710bc0709ec
0.1 2019.04.21 00:01* 字数 1288

内容较多、请先马后看;借助es分布式计算的能力,使得早期易企秀APP端图片搜索功能就具备了高可用、可扩展的能力

1、背景

易企秀商场为我们提供了大量免付费的模板,这些模板多以固定的图片及样式组合而成,用户在这个基础上稍加修改便可以快速实现自己的H5场景,为了满足小白用户能够快速制作H5场景的需求,方便用户能够从海量商城作品中快速找到符合自己使用的风格模板,为此产品上提供了通过文本搜索快速获取样例商品的途径,也提供了基于图片搜索样例商品的功能,做图片搜索的目的是为了拓展用户获取商品的途径,同时也满足了用户基于图片风格样式获取商品的诉求。

以下内容进入实战,项目来自易企秀一线工程师操刀实践,干货满满

2、流程介绍

业务处理流程相对比较简单,这里就不放架构图了,整个项目中用到了sqoop、hive、spark、elasticsearch等大数据组件,步骤如下:
1、商品模板主要来自设计师、秀客以及运营精选,每个小时都有大量新增商品入库,我们通过sqoop实现商品数据增量同步到数据仓库(hive),主要包括商品库中的商品封面图、标题、描述、Id等信息
2、借助spark分布式计算的能力快速清洗并抽取图片特征
3、将抽取后的特征与商品模板建立对应关系,并存储到es
4、编写查询script脚本,用于计算用户输入图片与候选集的相似度。

3、具体操作

  • ETL

通过sqoop实现增量数据同步非常简单,需要指定一个用于监控增量变化的字段:

sqoop job --create jobname -- import --connect jdbc:mysql://host:3306/mall --username 'bigdata' --password pwd 
--table mysqlablename --hive-import    --hive-table hivetablename 
--incremental lastmodified --check-column create_time --last-value '2019-04-22 13:00:00'

以下几点需要注意:
1、不能在sqoop job中指定-m参数,指定了-m参数会在数据迁移过程中产生临时数据文件,下次导入时会报数据目录已存在的错误;
2、因为我们执行的是增量操作,所以需要提前在hive中创建hivetablename对应的数据表;
3、增量同步需将incremental配置为lastmodified,并在第一次导入数据时设置--last-value为数据下届,每次sqoop会同步大于该下届的数据并自动更新该下届值;

  • 特征提取

图片特征提取是本项目的核心模块之一,由于图片特征提取方式较多,通过调研这里我们先对几种常用的传统特征提取算法做简要说明:

算法 描述 应用场景
颜色直方图 提取图片中各种颜色的分布数据,对图片翻转、缩放、模糊处理后的特征影响比较小 自然环境、色彩风格
颜色向量 在颜色直方图基础上增加了色彩空间分布特征的提取 -
文理特征 提取图片中颜色渐变与物体纹理数据特征 物体分类、图像搜索
形状特征 提取图片中物体轮廓特征与区域形状特征 物体分类
SIFT 通过复杂的数据公式实现物体局部特征提取,具有平移、旋转、光照不变性 物体识别、图像检测
SURF 采用了SIFT相近的实现原理,但计算复杂度降低很多 -

在实际操作后我们选用了颜色灰度直方图算法,以下是相关代码,原生jdk代码实现,没有第三方依赖,直接拷贝可运行(需要全部工程代码的请留下你的邮箱):

import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.util.Base64;

import javax.imageio.ImageIO;

 public class Hog extends FeatureSelect {

    private static int GRAYBIT = 2;     //GRAYBIT=4;用12位的int表示灰度值,前4位表示red,中间4们表示green,后面4位表示blue

   
    /**

     * 求三维的灰度直方图

     * @throws IOException
     * @throws MalformedURLException

     */
    public static void main(String[] args)  {
        /*double[] data5 = getHistgram2("http://pic15.nipic.com/20110713/2328079_172740212177_2.jpg");
        ImageVector.print(data5);
        double[] data1 = getHistgram2("http://imgup01.sj88.com/2018-07/04/09/15306691026479_3.jpg");
        ImageVector.print(data1);*/
        double[] data2 = getHistgram2("http://res.eqh5.com/o_1cjacked6nsv1m4du77esr1mr4u.jpg");
        print(data2);
//      double[] data3 = getHistgram2("http://res.eqh5.com/o_1cgqee47bfb966fmf8j472559.jpg");
//      ImageVector.print(data3);
//      double[] data4 = getHistgram2("http://res.eqh5.com/o_1ci40kmlv1c7b16ob1imfk961kjae.png");
//      print(data4);
//      double[] data6 = getHistgram2("http://res.eqh5.com/o_1ci40kmlv1c7b16ob1imfk961kjae.png");
//      print(data6);
    }

    public static void print(double[] data){
        StringBuffer sb = new StringBuffer();
        StringBuffer sb2 = new StringBuffer();
        for(int i=0; i<data.length; i++){
            sb.append(i+"|"+data[i]+" ");
            sb2.append( Double.valueOf(data[i])+",");
        }
//      System.out.println(sb);
        System.out.println(sb2);

        System.out.println( convertArrayToBase64(data));
    }

    public static final String convertArrayToBase64(double[] array) {
        final int capacity = 8 * array.length;
        final ByteBuffer bb = ByteBuffer.allocate(capacity);
        for (int i = 0; i < array.length; i++) {
            bb.putDouble(array[i]);
        }
        bb.rewind();
        final ByteBuffer encodedBB = Base64.getEncoder().encode(bb);
        return new String(encodedBB.array());
    }

    private static BufferedImage  readImg(String url)  {
        try {
            return ImageIO.read(new URL(url).openStream());
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    public static double[][] getHistgram(String srcPath) {

        BufferedImage img = readImg(srcPath);

        return getHistogram(img);

    }

    /**

     * hist[0][]red的直方图,hist[1][]green的直方图,hist[2][]blue的直方图

     * @param img 要获取直方图的图像

     * @return 返回r,g,b的三维直方图

     */

    public static double[][] getHistogram(BufferedImage img) {

        int w = img.getWidth();

        int h = img.getHeight();

        double[][] hist = new double[3][256];

        int r, g, b;

        int pix[] = new int[w*h];

        pix = img.getRGB(0, 0, w, h, pix, 0, w);

        for(int i=0; i<w*h; i++) {

            r = pix[i]>>16 & 0xff;

            g = pix[i]>>8 & 0xff;

            b = pix[i] & 0xff;

            /*hr[r] ++;

            hg[g] ++;

            hb[b] ++;*/

            hist[0][r] ++;

            hist[1][g] ++;

            hist[2][b] ++;

        }

        for(int j=0; j<256; j++) {

            for(int i=0; i<3; i++) {

                hist[i][j] = hist[i][j]/(w*h);

                //System.out.println(hist[i][j] + "  ");

            }

        }

        return hist;

    }
 
    /**

     * 求一维的灰度直方图

     * @param srcPath

     * @return

     */

    public static double[] getHistgram2(String srcPath) {

        BufferedImage img = readImg(srcPath);

        return getHistogram2(img);

    }

    /**

     * 求一维的灰度直方图

     * @param img

     * @return

     */


    public static double[] getHistogram2(BufferedImage img) {

        int w = img.getWidth();

        int h = img.getHeight();

        int series = (int) Math.pow(2, GRAYBIT);    //GRAYBIT=4;用12位的int表示灰度值,前4位表示red,中间4们表示green,后面4位表示blue

        int greyScope = 256/series;

        double[] hist = new double[series*series*series];

        int r, g, b, index;

        int pix[] = new int[w*h];

        pix = img.getRGB(0, 0, w, h, pix, 0, w);

        for(int i=0; i<w*h; i++) {

            r = pix[i]>>16 & 0xff;

            r = r/greyScope;

            g = pix[i]>>8 & 0xff;

            g = g/greyScope;

            b = pix[i] & 0xff;

            b = b/greyScope;

            index = r<<(2*GRAYBIT) | g<<GRAYBIT | b;

            hist[index] ++;

        }

        for(int i=0; i<hist.length; i++) {

            hist[i] = hist[i]/(w*h);

            //System.out.println(hist[i] + "  ");

        }

        return hist;

    }

    
}


  • 特征存储

首先在mapping中定义存储特征field

      "features": {
            "type": "binary",
            "doc_values": true
       }

其次借助spark的并行计算能力,每小时增量读取hive表中新增商品的数据,对封面图进行特征提取,并将提取后的特征字段连同其它属性值一并存入ES,由于features存储的是binary类型,数据需要转化为base64字符串进行存储,所以spark中主要代码是:

String b64 = Hog.convertArrayToBase64(Hog.getHistgram2( imgUrl ));
  • 图片检索

和构建索引库的方式一样,我们在检索前也需要对图片进行特征提取,但这次提取后的特征不需要进行base64转化,以下是query的核心语句:


{
  "query": {
    "function_score": {
      "boost_mode": "replace",
      "script_score": {
        "script": {
          "inline": "binary_vector_score",
          "lang": "knn",
          "params": {
            "cosine": true,
            "field": "features",
            "vector": [
               -0.09217305481433868, 0.010635560378432274, -0.02878434956073761, 0.06988169997930527, 0.1273992955684662, -0.023723633959889412, 0.05490724742412567, -0.12124507874250412, -0.023694118484854698 
          }
        }
      }
    }
  } 

如果你觉得上述查询返回的结果相关度不高或者响应很慢,也可以重写query增加过滤条件,以限制参与计算的数据范围。

需要注意的是es5.6中并不原生支持cosine等计算相似度的函数,开始执行上述query之前,我们要先安装一个script脚本,在这里下载

4、小结

上述工程虽然实现了图片与文本相结合搜索功能,但检索效果和性能并不是很出色,可优化的空间还有很多,比如特征提取部分可以尝试使用深度学习模型,通过卷积神经网络提取的特征可能效果会更好,另外新版ES7.0支持了vector数据类型(图片数据存储为该类型更合适),并且内部实现了基于vector的余弦相似度计算,切换到新版本实现性能应该也会好很多。

大数据实战