基於Vue的移動端圖片裁剪組件


  最近項目上要做一個車牌識別的功能。本來以為很簡單,只需要將圖片扔給后台就可以了,但是經測試后識別率只有20-40%。因此產品建議拍攝圖片后,可以對圖片進行拖拽和縮放,然后裁剪車牌部分上傳給后台來提高識別率。剛開始的話還是百度了一下看看有沒有現成的組件,但是找來找去都沒有找到一個合適的,還好這個功能不是很着急,因此自己周末就在家里研究一下。

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

  下載地址:https://github.com/vivialex/vue-imageClipper

  因為移動端是用vue,所以就寫成了一個vue組件,下面就說說自己的一些實現思路(本人技術有限,各位大神請體諒。另外展示的代碼不一定是某個功能的完整代碼),先看看效果:

     

 

  一、組件的初始化參數

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

  2、截圖的寬clipperImgWidth

  3、截圖的高clipperImgHeight

 1 props: {
 2     img: String, //url或dataUrl
 3     clipperImgWidth: {
 4         type: Number,
 5         default: 500
 6     },
 7     clipperImgHeight: {
 8         type: Number,
 9         default: 200
10     }
11 }
參數

  二、布局

  在Z軸方向看主要是由4層組成。第1層是一個占滿整個容器的canvas(稱cCanvas);第2層是一個有透明度的遮罩層;第3層是裁剪的區域(示例圖中的白色方框),里面包含一個與裁剪區域大小相等的canvas(稱pCanvas);第4層是一個透明層gesture-mask,用作綁定touchstart,touchmove,touchend事件。其中兩個canvas都會加載同一張圖片,只是起始坐標不一樣。為什么需要兩個canvas?因為想做出當手指離開屏幕時,裁剪區域外的部分表面會有一個遮罩層的效果,這樣能突出裁剪區域的內容。

 1 <div class="cut-container" ref="cut">
 2     <canvas ref="canvas"></canvas>
 3 
 4     <!-- 裁剪部分 -->
 5     <div class="cut-part">
 6         <div class="pCanvas-container">
 7             <canvas ref="pCanvas"></canvas>
 8         </div>
 9     </div>
10 
11     <!-- 底部操作欄 -->
12     <div class="action-bar">
13         <button class="btn-cancel" @click="_cancel">取消</button>
14         <button class="btn-ok" @click="_cut">確認</button>
15     </div>
16 
17     <!-- 背景遮罩 -->
18     <div class="mask" :class="{opacity: maskShow}"></div>
19 
20     <!-- 手勢操作層 -->
21     <div class="gesture-mask" ref="gesture"></div>
22 </div>
html結構

  三、初始化canvas

  canvas繪制的圖片在hdpi顯示屏上會出現模糊,具體原因這里不作分析,可以參考下這里。我這里的做法是讓canvaswidthheight為其css width/heightdevicePixelRatio倍以及調用canvas api時所傳入的參數都要乘以window.devicePixelRatio。最后還要記錄一下兩個canvas坐標原點的x, y差值(originXDifforiginYDiff)。如下

 1 _ratio(size) {
 2     return parseInt(window.devicePixelRatio * size);
 3 },
 4 _initCanvas() {
 5     let $canvas = this.$refs.canvas,
 6         $pCanvas = this.$refs.pCanvas,
 7         clipperClientRect = this.$refs.clipper.getBoundingClientRect(),
 8         clipperWidth = parseInt(this.clipperImgWidth / window.devicePixelRatio),
 9         clipperHeight = parseInt(this.clipperImgHeight / window.devicePixelRatio);
10 
11     this.ctx = $canvas.getContext('2d');
12     this.pCtx = $pCanvas.getContext('2d');
13 
14     //判斷clipperWidth與clipperHeight有沒有超過容器值
15     if (clipperWidth < 0 || clipperWidth > clipperClientRect.width) {
16         clipperWidth = 250
17     }
18 
19     if (clipperHeight < 0 || clipperHeight > clipperClientRect.height) {
20         clipperHeight = 100
21     }
22 
23     //因為canvas在手機上會被放大,因此里面的內容會模糊,這里根據手機的devicePixelRatio來放大canvas,然后再通過設置css來收縮,因此關於canvas的所有值或坐標都要乘以devicePixelRatio
24     $canvas.style.width = clipperClientRect.width + 'px';
25     $canvas.style.height = clipperClientRect.height + 'px';
26     $canvas.width = this._ratio(clipperClientRect.width);
27     $canvas.height = this._ratio(clipperClientRect.height);
28 
29     $pCanvas.style.width = clipperWidth + 'px';
30     $pCanvas.style.height = clipperHeight + 'px';
31     $pCanvas.width = this._ratio(clipperWidth);
32     $pCanvas.height = this._ratio(clipperHeight);
33 
34     //計算兩個canvas原點的x y差值
35     let cClientRect = $canvas.getBoundingClientRect(),
36         pClientRect = $pCanvas.getBoundingClientRect();
37 
38     this.originXDiff = pClientRect.left - cClientRect.left;
39     this.originYDiff = pClientRect.top - cClientRect.top;
40     this.cWidth = cClientRect.width;
41     this.cHeight = cClientRect.height;
42 }
初始化canvas

   四、加載圖片

  加載圖片比較簡單,首先是創建一個Image對象並監聽器onload事件(因為加載的圖片有可能是跨域的,因此要設置其crossOrigin屬性為Anonymous,然后服務器上要設置Access-Control-Allow-Origin響應頭)。加載的圖片如果寬高大於容器的寬高,要對其進行縮小處理。最后垂直水平居中顯示()(這里注意的是要保存圖片繪制前的寬高值,因為日后縮放圖片是以該值為基礎再乘以縮放倍率,這里取imgStartWidthimgStartHeight)如下

 1 _loadImg() {
 2     if (this.imgLoading || this.loadImgQueue.length === 0) {
 3         return;
 4     }
 5 
 6     let img = this.loadImgQueue.shift();
 7 
 8     if (!img) {
 9         return;
10     }
11 
12     let $img = new Image(),
13         onLoad = e => {
14             $img.removeEventListener('load', onLoad, false);
15             this.$img = $img;
16             this.imgLoaded = true;
17             this.imgLoading = false;
18 
19             this._initImg($img.width, $img.height);
20             this.$emit('loadSuccess', e);
21             this.$emit('loadComplete', e);
22             this._loadImg();
23         },
24         onError = e => {
25             $img.removeEventListener('error', onError, false);
26             this.$img = $img = null;
27             this.imgLoading = false;
28 
29             this.$emit('loadError', e);
30             this.$emit('loadComplete', e);
31             this._loadImg();
32         };
33 
34     this.$emit('beforeLoad');
35     this.imgLoading = true;
36     this.imgLoaded = false;
37     $img.src = this.img;
38     $img.crossOrigin = 'Anonymous'; //因為canvas toDataUrl不能操作未經允許的跨域圖片,這需要服務器設置Access-Control-Allow-Origin頭
39     $img.addEventListener('load', onLoad, false);
40     $img.addEventListener('error', onError, false);
41 }
42 _initImg(w, h) {
43     let eW = null,
44         eH = null,
45         maxW = this.cWidth,
46         maxH = this.cHeight - this.actionBarHeight;
47 
48     //如果圖片的寬高都少於容器的寬高,則不做處理
49     if (w <= maxW && h <= maxH) {
50         eW = w;
51         eH = h;
52     } else if (w > maxW && h <= maxH) {
53         eW = maxW;
54         eH = parseInt(h / w * maxW);
55     } else if (w <= maxW && h > maxH) {
56         eW = parseInt(w / h * maxH);
57         eH = maxH;
58     } else {
59         //判斷是橫圖還是豎圖
60         if (h > w) {
61             eW = parseInt(w / h * maxH);
62             eH = maxH;
63         } else {
64             eW = maxW;
65             eH = parseInt(h / w * maxW);
66         }
67     }
68 
69     if (eW <= maxW && eH <= maxH) {
70         //記錄其初始化的寬高,日后的縮放功能以此值為基礎
71         this.imgStartWidth = eW;
72         this.imgStartHeight = eH;
73         this._drawImage((maxW - eW) / 2, (maxH - eH) / 2, eW, eH);
74     } else {
75         this._initImg(eW, eH);
76     }
77 }
加載圖片

   五、繪制圖片

  下面的_drawImage有四個參數,分別是圖片對應cCanvas的x,y坐標以及圖片目前的寬高w,h。函數首先會清空兩個canvas的內容,方法是重新設置canvas的寬高。然后更新組件實例中對應的值,最后再調用兩個canvas的drawImage去繪制圖片。對於pCanvas來說,其繪制的圖片坐標值為x,y減去對應的originXDifforiginYDiff(其實相當於切換坐標系顯示而已,因此只需要減去兩個坐標系原點的x,y差值即可)。看看代碼

 1 _drawImage(x, y, w, h) {
 2     this._clearCanvas();
 3     this.imgX = parseInt(x);
 4     this.imgY = parseInt(y);
 5     this.imgCurrentWidth = parseInt(w);
 6     this.imgCurrentHeight = parseInt(h);
 7 
 8     //更新canvas
 9     this.ctx.drawImage(this.$img, this._ratio(x), this._ratio(y), this._ratio(w), this._ratio(h));
10 
11     //更新pCanvas,只需要減去兩個canvas坐標原點對應的差值即可
12     this.pCtx.drawImage(this.$img, this._ratio(x - this.originXDiff), this._ratio(y - this.originYDiff), this._ratio(w), this._ratio(h));
13 },
14 _clearCanvas() {
15     let $canvas = this.$refs.canvas,
16         $pCanvas = this.$refs.pCanvas;
17 
18     $canvas.width = $canvas.width;
19     $canvas.height = $canvas.height;
20     $pCanvas.width = $pCanvas.width;
21     $pCanvas.height = $pCanvas.height;
22 }
繪制圖片

   六、移動圖片

  移動圖片實現非常簡單,首先給gesture-mask綁定touchstart,touchmove,touchend事件,下面分別介紹這三個事件的內容

  首先定義四個變量scx, scy(手指的起始坐標),iXiY(圖片目前的坐標,相對於cCanvas)。

  1、touchstart

    方法很簡單,就是獲取touches[0]的pageX,pageY來更新scxscy以及更新iXiY

  2、touchmove

    獲取touches[0]的pageX,聲明變量f1x存放,移動后的x坐標等於iX + f1x - scx,y坐標同理,最后調用_drawImage來更新圖片。

  看看代碼吧

 1 _initEvent() {
 2     let $gesture = this.$refs.gesture,
 3         scx = 0,
 4         scy = 0;
 5 
 6     let iX = this.imgX,
 7         iY = this.imgY;
 8 
 9     $gesture.addEventListener('touchstart', e => {
10         if (!this.imgLoaded) {
11             return;
12         }
13 
14         let finger = e.touches[0];
15             scx = finger.pageX;
16             scy = finger.pageY;
17             iX = this.imgX;
18             iY = this.imgY;    
19     }, false);
20     $gesture.addEventListener('touchmove', e => {
21         e.preventDefault();
22 
23         if (!this.imgLoaded) {
24             return;
25         }
26 
27         let f1x = e.touches[0].pageX,
28             f1y = e.touches[0].pageY;
29             this._drawImage(iX + f1x - scx, iY + f1y - scy, this.imgCurrentWidth, this.imgCurrentHeight);
30     }, false);
31 }            
移動圖片

   七、縮放圖片(這里不作特別說明的坐標都是相對於cCanvas坐標系)

  繪制縮放后的圖片無非需要4個參數,縮放后圖片左上角的坐標以及寬高。求寬高相對好辦,寬高等於imgStartWidth * 縮放比率與imgstartHeight * 縮放倍率(imgStartWidth ,imgstartHeight 上文第四節有提到)。接下來就是求縮放倍率的問題了,首先在touchstart事件上求取兩手指間的距離d1;然后在touchmove事件上繼續求取兩手指間的距離d2當前縮放倍率= 初始縮放倍率 + (d2-d1) / 步長(例如每60px算0.1),touchend事件上讓初始縮放倍率=當前縮放倍率

  至於如何求取縮放后圖片左上角的坐標值,在草稿紙上畫來畫去,畫了很久......終於有點眉目。首先要找到一個縮放中心(這里做法是取雙指的中點坐標,但是這個坐標必須要位於圖片上,如果不在圖片上,則取圖片上離該中點坐標最近的點),然后存在下面這個等式

  (縮放中心x坐標 - 縮放后圖片左上角x坐標)/ 縮放后圖片的寬度 = (縮放中心x坐標 - 縮放前圖片左上角x坐標)/ 縮放前圖片的寬度;(y坐標同理)

  接下來看看下面這個例子(在visio找了很久都沒有畫坐標系的功能,所以只能手工畫了)

  

  綠色框是一張10*5的圖片,藍色框是寬高放大兩倍后的圖片20*10,根據上面的公式推算的x2 = sx - w2(sx - x1) / w1,y2 = sy - h2(sy - y1) / h1

  堅持...繼續看看代碼吧

  1 _initEvent() {
  2     let $gesture = this.$refs.gesture,
  3         cClientRect = this.$refs.canvas.getBoundingClientRect(),
  4         scx = 0, //對於單手操作是移動的起點坐標,對於縮放是圖片距離兩手指的中點最近的圖標。
  5         scy = 0,
  6         fingers = {}; //記錄當前有多少只手指在觸控屏幕
  7 
  8     //one finger
  9     let iX = this.imgX,
 10         iY = this.imgY;
 11 
 12     //two finger
 13     let figureDistance = 0,
 14         pinchScale = this.imgScale;
 15 
 16     $gesture.addEventListener('touchstart', e => {
 17         if (!this.imgLoaded) {
 18             return;
 19         }
 20 
 21         if (e.touches.length === 1) {
 22             let finger = e.touches[0];
 23 
 24             scx = finger.pageX;
 25             scy = finger.pageY;
 26             iX = this.imgX;
 27             iY = this.imgY;
 28             fingers[finger.identifier] = finger;
 29         } else if (e.touches.length === 2) {
 30             let finger1 = e.touches[0],
 31                 finger2 = e.touches[1],
 32                 f1x = finger1.pageX - cClientRect.left,
 33                 f1y = finger1.pageY - cClientRect.top,
 34                 f2x = finger2.pageX - cClientRect.left,
 35                 f2y = finger2.pageY - cClientRect.top;
 36 
 37             scx = parseInt((f1x + f2x) / 2);
 38             scy = parseInt((f1y + f2y) / 2);
 39             figureDistance = this._pointDistance(f1x, f1y, f2x, f2y);
 40             fingers[finger1.identifier] = finger1;
 41             fingers[finger2.identifier] = finger2;
 42 
 43             //判斷變換中點是否在圖片中,如果不是則去離圖片最近的點
 44             if (scx < this.imgX) {
 45                 scx = this.imgX;
 46             }
 47             if (scx > this.imgX + this.imgCurrentWidth) {
 48                 scx = this.imgX + this.imgCurrentHeight;
 49             }
 50             if (scy < this.imgY) {
 51                 scy = this.imgY;
 52             }
 53             if (scy > this.imgY + this.imgCurrentHeight) {
 54                 scy = this.imgY + this.imgCurrentHeight;
 55             }
 56         }
 57     }, false);
 58     $gesture.addEventListener('touchmove', e => {
 59         e.preventDefault();
 60 
 61         if (!this.imgLoaded) {
 62             return;
 63         }
 64 
 65         this.maskShowTimer && clearTimeout(this.maskShowTimer);
 66         this.maskShow = false;
 67 
 68         if (e.touches.length === 1) {
 69             let f1x = e.touches[0].pageX,
 70                 f1y = e.touches[0].pageY;
 71             this._drawImage(iX + f1x - scx, iY + f1y - scy, this.imgCurrentWidth, this.imgCurrentHeight);
 72         } else if (e.touches.length === 2) {
 73             let finger1 = e.touches[0],
 74                 finger2 = e.touches[1],
 75                 f1x = finger1.pageX - cClientRect.left,
 76                 f1y = finger1.pageY - cClientRect.top,
 77                 f2x = finger2.pageX - cClientRect.left,
 78                 f2y = finger2.pageY - cClientRect.top,
 79                 newFigureDistance = this._pointDistance(f1x, f1y, f2x, f2y),
 80                 scale = this.imgScale + parseFloat(((newFigureDistance - figureDistance) / this.imgScaleStep).toFixed(1));
 81 
 82             fingers[finger1.identifier] = finger1;
 83             fingers[finger2.identifier] = finger2;
 84 
 85             if (scale !== pinchScale) {
 86                 //目前縮放的最小比例是1,最大是5
 87                 if (scale < this.imgMinScale) {
 88                     scale = this.imgMinScale;
 89                 } else if (scale > this.imgMaxScale) {
 90                     scale = this.imgMaxScale;
 91                 }
 92 
 93                 pinchScale = scale;
 94                 this._scale(scx, scy, scale);
 95             }
 96         }
 97     }, false);
 98     $gesture.addEventListener('touchend', e => {
 99         if (!this.imgLoaded) {
100             return;
101         }
102 
103         this.imgScale = pinchScale;
104 
105         //從finger刪除已經離開的手指
106         let touches = Array.prototype.slice.call(e.changedTouches, 0);
107 
108         touches.forEach(item => {
109             delete fingers[item.identifier];
110         });
111 
112         //迭代fingers,如果存在finger則更新scx,scy,iX,iY,因為可能縮放后立即單指拖動
113         let i,
114             fingerArr = [];
115 
116         for(i in fingers) {
117             if (fingers.hasOwnProperty(i)) {
118                 fingerArr.push(fingers[i]);
119             }
120         }
121 
122         if (fingerArr.length > 0) {
123             scx = fingerArr[0].pageX;
124             scy = fingerArr[0].pageY;
125             iX = this.imgX;
126             iY = this.imgY;
127         } else {
128             this.maskShowTimer = setTimeout(() => {
129                 this.maskShow = true;
130             }, 300);
131         }
132 
133         //做邊界值檢測
134         let x = this.imgX,
135             y = this.imgY,
136             pClientRect = this.$refs.pCanvas.getBoundingClientRect();
137 
138         if (x > pClientRect.left + pClientRect.width) {
139             x = pClientRect.left
140         } else if (x + this.imgCurrentWidth < pClientRect.left) {
141             x = pClientRect.left + pClientRect.width - this.imgCurrentWidth;
142         }
143 
144         if (y > pClientRect.top + pClientRect.height) {
145             y = pClientRect.top;
146         } else if (y + this.imgCurrentHeight < pClientRect.top) {
147             y = pClientRect.top + pClientRect.height - this.imgCurrentHeight;
148         }
149 
150         if (this.imgX !== x || this.imgY !== y) {
151             this._drawImage(x, y, this.imgCurrentWidth, this.imgCurrentHeight);
152         }
153     });
154 },
155 _scale(x, y, scale) {
156     let newPicWidth = parseInt(this.imgStartWidth * scale),
157         newPicHeight = parseInt(this.imgStartHeight * scale),
158         newIX = parseInt(x - newPicWidth * (x - this.imgX) / this.imgCurrentWidth),
159         newIY = parseInt(y - newPicHeight * (y - this.imgY) / this.imgCurrentHeight);
160 
161     this._drawImage(newIX, newIY, newPicWidth, newPicHeight);
162 },
163 _pointDistance(x1, y1, x2, y2) {
164     return parseInt(Math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)));
165 }
166 
167 縮放圖片
縮放圖片

  說明一下fingers是干嘛的,是用來記錄當前有多少只手指在屏幕上觸摸。可能會出現這種情況,雙指縮放后,其中一只手指移出顯示屏,而另外一個手指在顯示屏上移動。針對這種情況,要在touchend事件上根據e.changedTouches來移除fingers里已經離開顯示屏的finger,如果此時fingers里只剩下一個finger,則更新scxscyiXiY為移動圖片做初始化准備。

  八、裁剪圖片

  這里很簡單,就調用pCanvastoDataURL方法就可以了

 1 _clipper() {
 2     let imgData = null;
 3 
 4     try {
 5         imgData = this.$refs.pCanvas.toDataURL();
 6     } catch (e) {
 7         console.error('請在response header加上Access-Control-Allow-Origin,否則canvas無法裁剪未經許可的跨域圖片');
 8     }
 9     this.$emit('sure', imgData);
10 }
裁剪圖片

   總結

  上面只是列出了一些自己認為比較關鍵的點, 如果有興趣的,可以到我的github上下載源碼看看。

  本人前端菜鳥一枚,歡迎各位大神的建議與指導,交流可用QQ:594580652或發郵件到此QQ郵箱。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM