最近項目上要做一個車牌識別的功能。本來以為很簡單,只需要將圖片扔給后台就可以了,但是經測試后識別率只有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>
三、初始化canvas
canvas繪制的圖片在hdpi顯示屏上會出現模糊,具體原因這里不作分析,可以參考下這里。我這里的做法是讓canvas的width與height為其css width/height的devicePixelRatio倍,以及調用canvas api時所傳入的參數都要乘以window.devicePixelRatio。最后還要記錄一下兩個canvas坐標原點的x, y差值(originXDiff與originYDiff)。如下

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

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減去對應的originXDiff與originYDiff(其實相當於切換坐標系顯示而已,因此只需要減去兩個坐標系原點的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(手指的起始坐標),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來更新圖片。
看看代碼吧

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,則更新scx,scy,iX,iY為移動圖片做初始化准備。
八、裁剪圖片
這里很簡單,就調用pCanvas的toDataURL方法就可以了

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郵箱。