欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

基于Vue的移动端图片裁剪组件功能

程序员文章站 2022-06-25 13:22:47
最近项目上要做一个车牌识别的功能。本来以为很简单,只需要将图片扔给后台就可以了,但是经测试后识别率只有20-40%。因此产品建议拍摄图片后,可以对图片进行拖拽和缩放,然后裁...

最近项目上要做一个车牌识别的功能。本来以为很简单,只需要将图片扔给后台就可以了,但是经测试后识别率只有20-40%。因此产品建议拍摄图片后,可以对图片进行拖拽和缩放,然后裁剪车牌部分上传给后台来提高识别率。刚开始的话还是百度了一下看看有没有现成的组件,但是找来找去都没有找到一个合适的,还好这个功能不是很着急,因此自己周末就在家里研究一下。

  demo地址:https://vivialex.github.io/demo/imageclipper/index.html

  下载地址:https://github.com/vivialex/vue-imageclipper

  因为移动端是用vue,所以就写成了一个vue组件,下面就说说自己的一些实现思路(本人技术有限,各位大神请体谅。另外展示的代码不一定是某个功能的完整代码),先看看效果: 

基于Vue的移动端图片裁剪组件功能基于Vue的移动端图片裁剪组件功能  

  一、组件的初始化参数

  1、图片img(url或者base64 data-url)

  2、截图的宽clipperimgwidth

  3、截图的高clipperimgheight

props: {
  img: string, //url或dataurl
  clipperimgwidth: {
    type: number,
    default: 500
  },
  clipperimgheight: {
    type: number,
    default: 200
  }
}

  二、布局

  在z轴方向看主要是由4层组成。第1层是一个占满整个容器的canvas(称ccanvas);第2层是一个有透明度的遮罩层;第3层是裁剪的区域(示例图中的白色方框),里面包含一个与裁剪区域大小相等的canvas(称pcanvas);第4层是一个透明层gesture-mask,用作绑定touchstart,touchmove,touchend事件。其中两个canvas都会加载同一张图片,只是起始坐标不一样。为什么需要两个canvas?因为想做出当手指离开屏幕时,裁剪区域外的部分表面会有一个遮罩层的效果,这样能突出裁剪区域的内容。

<div class="cut-container" ref="cut">
  <canvas ref="canvas"></canvas>
  <!-- 裁剪部分 -->
  <div class="cut-part">
    <div class="pcanvas-container">
      <canvas ref="pcanvas"></canvas>
    </div>
  </div>
  <!-- 底部操作栏 -->
  <div class="action-bar">
    <button class="btn-cancel" @click="_cancel">取消</button>
    <button class="btn-ok" @click="_cut">确认</button>
  </div>
  <!-- 背景遮罩 -->
  <div class="mask" :class="{opacity: maskshow}"></div>
  <!-- 手势操作层 -->
  <div class="gesture-mask" ref="gesture"></div>
</div>

  三、初始化canvas

  canvas绘制的图片在hdpi显示屏上会出现模糊,具体原因这里不作分析,可以参考下这里。我这里的做法是让canvas的width与height为其css width/height的devicepixelratio倍,以及调用canvas api时所传入的参数都要乘以window.devicepixelratio。最后还要记录一下两个canvas坐标原点的x, y差值(originxdiff与originydiff)。如下

_ratio(size) {
  return parseint(window.devicepixelratio * size);
},
_initcanvas() {
  let $canvas = this.$refs.canvas,
    $pcanvas = this.$refs.pcanvas,
    clipperclientrect = this.$refs.clipper.getboundingclientrect(),
    clipperwidth = parseint(this.clipperimgwidth / window.devicepixelratio),
    clipperheight = parseint(this.clipperimgheight / window.devicepixelratio);

  this.ctx = $canvas.getcontext('2d');
  this.pctx = $pcanvas.getcontext('2d');

  //判断clipperwidth与clipperheight有没有超过容器值
  if (clipperwidth < 0 || clipperwidth > clipperclientrect.width) {
    clipperwidth = 250
  }

  if (clipperheight < 0 || clipperheight > clipperclientrect.height) {
    clipperheight = 100
  }

  //因为canvas在手机上会被放大,因此里面的内容会模糊,这里根据手机的devicepixelratio来放大canvas,然后再通过设置css来收缩,因此关于canvas的所有值或坐标都要乘以devicepixelratio
  $canvas.style.width = clipperclientrect.width + 'px';
  $canvas.style.height = clipperclientrect.height + 'px';
  $canvas.width = this._ratio(clipperclientrect.width);
  $canvas.height = this._ratio(clipperclientrect.height);

  $pcanvas.style.width = clipperwidth + 'px';
  $pcanvas.style.height = clipperheight + 'px';
  $pcanvas.width = this._ratio(clipperwidth);
  $pcanvas.height = this._ratio(clipperheight);

  //计算两个canvas原点的x y差值
  let cclientrect = $canvas.getboundingclientrect(),
    pclientrect = $pcanvas.getboundingclientrect();

  this.originxdiff = pclientrect.left - cclientrect.left;
  this.originydiff = pclientrect.top - cclientrect.top;
  this.cwidth = cclientrect.width;
  this.cheight = cclientrect.height;
}

  四、加载图片

  加载图片比较简单,首先是创建一个image对象并监听器onload事件(因为加载的图片有可能是跨域的,因此要设置其crossorigin属性为anonymous,然后服务器上要设置access-control-allow-origin响应头)。加载的图片如果宽高大于容器的宽高,要对其进行缩小处理。最后垂直水平居中显示()(这里注意的是要保存图片绘制前的宽高值,因为日后缩放图片是以该值为基础再乘以缩放倍率,这里取imgstartwidth,imgstartheight)如下

_loadimg() {
  if (this.imgloading || this.loadimgqueue.length === 0) {
    return;
  }
  let img = this.loadimgqueue.shift();
  if (!img) {
    return;
  }
  let $img = new image(),
    onload = e => {
      $img.removeeventlistener('load', onload, false);
      this.$img = $img;
      this.imgloaded = true;
      this.imgloading = false;
      this._initimg($img.width, $img.height);
      this.$emit('loadsuccess', e);
      this.$emit('loadcomplete', e);
      this._loadimg();
    },
    onerror = e => {
      $img.removeeventlistener('error', onerror, false);
      this.$img = $img = null;
      this.imgloading = false;
      this.$emit('loaderror', e);
      this.$emit('loadcomplete', e);
      this._loadimg();
    };
  this.$emit('beforeload');
  this.imgloading = true;
  this.imgloaded = false;
  $img.src = this.img;
  $img.crossorigin = 'anonymous'; //因为canvas todataurl不能操作未经允许的跨域图片,这需要服务器设置access-control-allow-origin头
  $img.addeventlistener('load', onload, false);
  $img.addeventlistener('error', onerror, false);
}
_initimg(w, h) {
  let ew = null,
    eh = null,
    maxw = this.cwidth,
    maxh = this.cheight - this.actionbarheight;
  //如果图片的宽高都少于容器的宽高,则不做处理
  if (w <= maxw && h <= maxh) {
    ew = w;
    eh = h;
  } else if (w > maxw && h <= maxh) {
    ew = maxw;
    eh = parseint(h / w * maxw);
  } else if (w <= maxw && h > maxh) {
    ew = parseint(w / h * maxh);
    eh = maxh;
  } else {
    //判断是横图还是竖图
    if (h > w) {
      ew = parseint(w / h * maxh);
      eh = maxh;
    } else {
      ew = maxw;
      eh = parseint(h / w * maxw);
    }
  }
  if (ew <= maxw && eh <= maxh) {
    //记录其初始化的宽高,日后的缩放功能以此值为基础
    this.imgstartwidth = ew;
    this.imgstartheight = eh;
    this._drawimage((maxw - ew) / 2, (maxh - eh) / 2, ew, eh);
  } else {
    this._initimg(ew, eh);
  }
}

   五、绘制图片

  下面的_drawimage有四个参数,分别是图片对应ccanvas的x,y坐标以及图片目前的宽高w,h。函数首先会清空两个canvas的内容,方法是重新设置canvas的宽高。然后更新组件实例中对应的值,最后再调用两个canvas的drawimage去绘制图片。对于pcanvas来说,其绘制的图片坐标值为x,y减去对应的originxdiff与originydiff(其实相当于切换坐标系显示而已,因此只需要减去两个坐标系原点的x,y差值即可)。看看代码

_drawimage(x, y, w, h) {
  this._clearcanvas();
  this.imgx = parseint(x);
  this.imgy = parseint(y);
  this.imgcurrentwidth = parseint(w);
  this.imgcurrentheight = parseint(h);
  //更新canvas
  this.ctx.drawimage(this.$img, this._ratio(x), this._ratio(y), this._ratio(w), this._ratio(h));
  //更新pcanvas,只需要减去两个canvas坐标原点对应的差值即可
  this.pctx.drawimage(this.$img, this._ratio(x - this.originxdiff), this._ratio(y - this.originydiff), this._ratio(w), this._ratio(h));
},
_clearcanvas() {
  let $canvas = this.$refs.canvas,
    $pcanvas = this.$refs.pcanvas;
  $canvas.width = $canvas.width;
  $canvas.height = $canvas.height;
  $pcanvas.width = $pcanvas.width;
  $pcanvas.height = $pcanvas.height;
}

   六、移动图片

  移动图片实现非常简单,首先给gesture-mask绑定touchstart,touchmove,touchend事件,下面分别介绍这三个事件的内容

  首先定义四个变量scx, scy(手指的起始坐标),ix,iy(图片目前的坐标,相对于ccanvas)。

  1、touchstart

    方法很简单,就是获取touches[0]的pagex,pagey来更新scx与scy以及更新ix与iy

  2、touchmove

    获取touches[0]的pagex,声明变量f1x存放,移动后的x坐标等于ix + f1x - scx,y坐标同理,最后调用_drawimage来更新图片。

  看看代码吧

_initevent() {
  let $gesture = this.$refs.gesture,
    scx = 0,
    scy = 0;
  let ix = this.imgx,
    iy = this.imgy;
  $gesture.addeventlistener('touchstart', e => {
    if (!this.imgloaded) {
      return;
    }
    let finger = e.touches[0];
      scx = finger.pagex;
      scy = finger.pagey;
      ix = this.imgx;
      iy = this.imgy;  
  }, false);
  $gesture.addeventlistener('touchmove', e => {
    e.preventdefault();
    if (!this.imgloaded) {
      return;
    }
    let f1x = e.touches[0].pagex,
      f1y = e.touches[0].pagey;
      this._drawimage(ix + f1x - scx, iy + f1y - scy, this.imgcurrentwidth, this.imgcurrentheight);
  }, false);
}  

   七、缩放图片(这里不作特别说明的坐标都是相对于ccanvas坐标系)

  绘制缩放后的图片无非需要4个参数,缩放后图片左上角的坐标以及宽高。求宽高相对好办,宽高等于imgstartwidth * 缩放比率与imgstartheight * 缩放倍率(imgstartwidth ,imgstartheight 上文第四节有提到)。接下来就是求缩放倍率的问题了,首先在touchstart事件上求取两手指间的距离d1;然后在touchmove事件上继续求取两手指间的距离d2,当前缩放倍率= 初始缩放倍率 + (d2-d1) / 步长(例如每60px算0.1),touchend事件上让初始缩放倍率=当前缩放倍率。

  至于如何求取缩放后图片左上角的坐标值,在草稿纸上画来画去,画了很久......终于有点眉目。首先要找到一个缩放中心(这里做法是取双指的中点坐标,但是这个坐标必须要位于图片上,如果不在图片上,则取图片上离该中点坐标最近的点),然后存在下面这个等式

  (缩放中心x坐标 - 缩放后图片左上角x坐标)/ 缩放后图片的宽度 = (缩放中心x坐标 - 缩放前图片左上角x坐标)/ 缩放前图片的宽度;(y坐标同理)

  接下来看看下面这个例子(在visio找了很久都没有画坐标系的功能,所以只能手工画了)

  基于Vue的移动端图片裁剪组件功能

  绿色框是一张10*5的图片,蓝色框是宽高放大两倍后的图片20*10,根据上面的公式推算的x2 = sx - w2(sx - x1) / w1,y2 = sy - h2(sy - y1) / h1。

  坚持...继续看看代码吧

_initevent() {
  let $gesture = this.$refs.gesture,
    cclientrect = this.$refs.canvas.getboundingclientrect(),
    scx = 0, //对于单手操作是移动的起点坐标,对于缩放是图片距离两手指的中点最近的图标。
    scy = 0,
    fingers = {}; //记录当前有多少只手指在触控屏幕
  //one finger
  let ix = this.imgx,
    iy = this.imgy;
  //two finger
  let figuredistance = 0,
    pinchscale = this.imgscale;
  $gesture.addeventlistener('touchstart', e => {
    if (!this.imgloaded) {
      return;
    }
    if (e.touches.length === 1) {
      let finger = e.touches[0];
      scx = finger.pagex;
      scy = finger.pagey;
      ix = this.imgx;
      iy = this.imgy;
      fingers[finger.identifier] = finger;
    } else if (e.touches.length === 2) {
      let finger1 = e.touches[0],
        finger2 = e.touches[1],
        f1x = finger1.pagex - cclientrect.left,
        f1y = finger1.pagey - cclientrect.top,
        f2x = finger2.pagex - cclientrect.left,
        f2y = finger2.pagey - cclientrect.top;
      scx = parseint((f1x + f2x) / 2);
      scy = parseint((f1y + f2y) / 2);
      figuredistance = this._pointdistance(f1x, f1y, f2x, f2y);
      fingers[finger1.identifier] = finger1;
      fingers[finger2.identifier] = finger2;
      //判断变换中点是否在图片中,如果不是则去离图片最近的点
      if (scx < this.imgx) {
        scx = this.imgx;
      }
      if (scx > this.imgx + this.imgcurrentwidth) {
        scx = this.imgx + this.imgcurrentheight;
      }
      if (scy < this.imgy) {
        scy = this.imgy;
      }
      if (scy > this.imgy + this.imgcurrentheight) {
        scy = this.imgy + this.imgcurrentheight;
      }
    }
  }, false);
  $gesture.addeventlistener('touchmove', e => {
    e.preventdefault();
    if (!this.imgloaded) {
      return;
    }
    this.maskshowtimer && cleartimeout(this.maskshowtimer);
    this.maskshow = false;
    if (e.touches.length === 1) {
      let f1x = e.touches[0].pagex,
        f1y = e.touches[0].pagey;
      this._drawimage(ix + f1x - scx, iy + f1y - scy, this.imgcurrentwidth, this.imgcurrentheight);
    } else if (e.touches.length === 2) {
      let finger1 = e.touches[0],
        finger2 = e.touches[1],
        f1x = finger1.pagex - cclientrect.left,
        f1y = finger1.pagey - cclientrect.top,
        f2x = finger2.pagex - cclientrect.left,
        f2y = finger2.pagey - cclientrect.top,
        newfiguredistance = this._pointdistance(f1x, f1y, f2x, f2y),
        scale = this.imgscale + parsefloat(((newfiguredistance - figuredistance) / this.imgscalestep).tofixed(1));
      fingers[finger1.identifier] = finger1;
      fingers[finger2.identifier] = finger2;
      if (scale !== pinchscale) {
        //目前缩放的最小比例是1,最大是5
        if (scale < this.imgminscale) {
          scale = this.imgminscale;
        } else if (scale > this.imgmaxscale) {
          scale = this.imgmaxscale;
        }
        pinchscale = scale;
        this._scale(scx, scy, scale);
      }
    }
  }, false);
  $gesture.addeventlistener('touchend', e => {
    if (!this.imgloaded) {
      return;
    }
    this.imgscale = pinchscale;
    //从finger删除已经离开的手指
    let touches = array.prototype.slice.call(e.changedtouches, 0);
    touches.foreach(item => {
      delete fingers[item.identifier];
    });
    //迭代fingers,如果存在finger则更新scx,scy,ix,iy,因为可能缩放后立即单指拖动
    let i,
      fingerarr = [];
    for(i in fingers) {
      if (fingers.hasownproperty(i)) {
        fingerarr.push(fingers[i]);
      }
    }
    if (fingerarr.length > 0) {
      scx = fingerarr[0].pagex;
      scy = fingerarr[0].pagey;
      ix = this.imgx;
      iy = this.imgy;
    } else {
      this.maskshowtimer = settimeout(() => {
        this.maskshow = true;
      }, 300);
    }
    //做边界值检测
    let x = this.imgx,
      y = this.imgy,
      pclientrect = this.$refs.pcanvas.getboundingclientrect();
    if (x > pclientrect.left + pclientrect.width) {
      x = pclientrect.left
    } else if (x + this.imgcurrentwidth < pclientrect.left) {
      x = pclientrect.left + pclientrect.width - this.imgcurrentwidth;
    }
    if (y > pclientrect.top + pclientrect.height) {
      y = pclientrect.top;
    } else if (y + this.imgcurrentheight < pclientrect.top) {
      y = pclientrect.top + pclientrect.height - this.imgcurrentheight;
    }
    if (this.imgx !== x || this.imgy !== y) {
      this._drawimage(x, y, this.imgcurrentwidth, this.imgcurrentheight);
    }
  });
},
_scale(x, y, scale) {
  let newpicwidth = parseint(this.imgstartwidth * scale),
    newpicheight = parseint(this.imgstartheight * scale),
    newix = parseint(x - newpicwidth * (x - this.imgx) / this.imgcurrentwidth),
    newiy = parseint(y - newpicheight * (y - this.imgy) / this.imgcurrentheight);
  this._drawimage(newix, newiy, newpicwidth, newpicheight);
},
_pointdistance(x1, y1, x2, y2) {
  return parseint(math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)));
}

  说明一下fingers是干嘛的,是用来记录当前有多少只手指在屏幕上触摸。可能会出现这种情况,双指缩放后,其中一只手指移出显示屏,而另外一个手指在显示屏上移动。针对这种情况,要在touchend事件上根据e.changedtouches来移除fingers里已经离开显示屏的finger,如果此时fingers里只剩下一个finger,则更新scx,scy,ix,iy为移动图片做初始化准备。

  八、裁剪图片

  这里很简单,就调用pcanvas的todataurl方法就可以了

_clipper() {
  let imgdata = null;
  try {
    imgdata = this.$refs.pcanvas.todataurl();
  } catch (e) {
    console.error('请在response header加上access-control-allow-origin,否则canvas无法裁剪未经许可的跨域图片');
  }
  this.$emit('sure', imgdata);
}

   总结

  上面只是列出了一些自己认为比较关键的点, 如果有兴趣的,可以到我的github上下载源码看看。

以上所述是小编给大家介绍的基于vue的移动端图片裁剪组件功能,希望对大家有所帮助