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

内容较多、请先马后看;借助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的余弦相似度计算,切换到新版本实现性能应该也会好很多。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容