專案背景
最近做了個電子報專案,使用者可在上傳的報刊版面圖上劃出一個個區域,通過OCR
圖文識別技術,識別出區域文字資訊,然後編輯成一條條新聞,可在PC端和手機端點選版面圖,檢視新聞詳情。
⚠️關鍵技術點: 用Canvas
如何繪製出裁剪框。
本文主要介紹裁剪框的實現過程。
單個裁剪
批量裁剪
Canvas技術點
CanvasRenderingContext2D.drawImage()
方法CanvasRenderingContext2D.save()
和CanvasRenderingContext2D.restore()
方法的成對使用CanvasRenderingContext2D.globalCompositeOperation
屬性CanvasRenderingContext2D.getImageData()
、CanvasRenderingContext2D.putImageData
方法
?小貼士:如果您對本文有興趣,期望您先了解以上技術點。
流程簡介
-
讀取圖片
-
用
Canvas
繪製圖片drawImage()
的使用- 繪製版面圖
-
裁剪操作
- 基本裁剪流程
- 裁剪框的繪製
-
輸出裁剪圖片
getImageData()
的使用putImageData()
的使用- 使用
Canvas.toDataURL()
輸出圖片 - 使用
OCR
識別圖片資訊
一、讀取圖片
元件初始化時,通過new Image
物件讀取圖片連結;
若圖片是通過本地上傳的,可用new FileReader
物件讀取。
⚠️注意點:
- 圖片的跨域問題;
image.src = url
放在圖片讀取後面,因為會偶發圖片讀取異常。
?實現程式碼如下:
import React, { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Button } from 'antd';
import styles from './index.less';
/**
*file 版面檔案
*useOcr true:通過OCR轉換成文字;false:轉換為圖片
*onTransform 轉換成文字後呼叫元件外部方法
*/
export default function ({ file, useOcr, onTransform }) {
const { url } = file;
const [originImg, setOriginImg] = useState(); // 源圖片
const [contentNode, setContentNode] = useState(); // 最外層節點
const [canvasNode, setCanvasNode] = useState(); // canvas節點
const [btnGroupNode, setBtnGroupNode] = useState(); // 按鈕組
const [startCoordinate, setStartCoordinate] = useState([0, 0]); // 開始座標
const [dragging, setDragging] = useState(false); // 是否可以裁剪
const [curPoisition, setCurPoisition] = useState(null); // 當前裁剪框座標資訊
const [trimPositionMap, setTrimPositionMap] = useState([]); // 裁剪框座標資訊
const fileSyncUpdating = useSelector(state => state.loading.effects['digital/postImgFileWithAliOcr']);
const dispatch = useDispatch();
const initCanvas = () => {
// url為上傳的圖片連結
if (url == null) {
return;
}
// 例項化一個Image物件,獲取圖片寬高,用於設定canvas寬高
const image = new Image();
image.addEventListener('load', () => {
...
});
image.crossOrigin = 'anonymous'; // 解決圖片跨域問題
image.src = url;
};
useEffect(() => {
initCanvas();
}, [url]);
}
複製程式碼
二、canvas
繪製圖片
2.1 drawImage()
的使用
語法
ctx.drawImage(image, dx, dy)
ctx.drawImage(image, dx, dy, dw, dh)
ctxdrawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
複製程式碼
引數
image
: 影像源;dx
和dy
是canvas中即將繪製區域的開始座標值;dw
和dh
是canvas中即將繪製區域的寬高;- 若需繪製源影像某部分,
sx
和sy
是該區域的左上角座標值; - 若需繪製源影像某部分,
sw
和sh
是該區域的寬高。
2.2 繪製版面圖
在讀取版面圖的時候,通過呼叫CanvasRenderingContext2D.drawImage()
繪製圖片。
⚠️注意: 每次呼叫canvas
方法時,需要用ctx.clearRect()
擦除一次,這樣可以節省記憶體,否則canvas
繪製的影像會一層層疊加,雖然看上去只有一張圖。
?實現程式碼如下:
// 初始化
const initCanvas = () => {
// url為上傳的圖片連結
if (url == null) {
return;
}
// contentNode為最外層DOM節點
if (contentNode == null) {
return;
}
// canvasNode為canvas節點
if (canvasNode == null) {
return;
}
const image = new Image();
setOriginImg(image); // 儲存源圖
image.addEventListener('load', () => {
const ctx = canvasNode.getContext('2d');
// 擦除一次,否則canvas會一層層疊加,節省記憶體
ctx.clearRect(0, 0, canvasNode.width, canvasNode.height);
// 若源圖寬度大於最外層節點的clientWidth,則設定canvas寬為clientWidth,否則設定為圖片的寬度
const clientW = contentNode.clientWidth;
const size = image.width / clientW;
if (image.width > clientW) {
canvasNode.width = clientW;
canvasNode.height = image.height / size;
} else {
canvasNode.width = image.width;
canvasNode.height = image.height;
}
// 呼叫drawImage API將版面圖繪製出來
ctx.drawImage(image, 0, 0, canvasNode.width, canvasNode.height);
});
image.crossOrigin = 'anonymous'; // 解決圖片跨域問題
image.src = url;
};
useEffect(() => {
initCanvas();
}, [canvasNode, url]);
return (
<section ref={setContentNode} className={styles.modaLLayout}>
<canvas
ref={setCanvasNode}
onMouseDown={handleMouseDownEvent}
onMouseMove={handleMouseMoveEvent}
onMouseUp={handleMouseRemoveEvent}
/>
</section>
)
複製程式碼
三、裁剪操作
3.1 基本裁剪流程
流程如下:
- 滑鼠移入
canvas
畫布區; - 點選滑鼠,通過
onMouseDown
事件獲取開始座標點(startX,startY)
; - 移動滑鼠,通過
onMouseMove
事件獲取座標,實時繪製裁剪框; - 鬆開滑鼠,通過
onMouseUp
事件終止裁剪框的繪製
?實現程式碼如下:
// 點選滑鼠事件
const handleMouseDownEvent = e => {
// 開始裁剪
setDragging(true);
const { offsetX, offsetY } = e.nativeEvent;
// 儲存開始座標
setStartCoordinate([offsetX, offsetY]);
if (btnGroupNode == null) {
return;
}
// 裁剪按鈕不可見
btnGroupNode.style.display = 'none';
};
// 移動滑鼠事件
const handleMouseMoveEvent = e => {
if (!dragging) {
return;
}
const ctx = canvasNode.getContext('2d');
// 每一幀都需要清除畫布(取最後一幀繪圖狀態, 否則狀態會累加)
ctx.clearRect(0, 0, canvasNode.width, canvasNode.height);
const { offsetX, offsetY } = e.nativeEvent;
// 計算臨時裁剪框的寬高
const tempWidth = offsetX - startCoordinate[0];
const tempHeight = offsetY - startCoordinate[1];
// 呼叫繪製裁剪框的方法
drawTrim(startCoordinate[0], startCoordinate[1], tempWidth, tempHeight);
};
// 鬆開滑鼠
const handleMouseRemoveEvent = () => {
// 結束裁剪
setDragging(false);
// 處理裁剪按鈕樣式
if (curPoisition == null) {
return;
}
if (btnGroupNode == null) {
return;
}
btnGroupNode.style.display = 'block';
btnGroupNode.style.left = `${curPoisition.startX}px`;
btnGroupNode.style.top = `${curPoisition.startY + curPoisition.height}px`;
// 判斷裁剪區是否重疊(此專案需要裁剪不規則的相鄰區域,所以裁剪框重疊時才支援批量裁剪)
judgeTrimAreaIsOverlap();
};
return (
<section ref={setContentNode} className={styles.modaLLayout}>
<canvas
ref={setCanvasNode}
onMouseDown={handleMouseDownEvent}
onMouseMove={handleMouseMoveEvent}
onMouseUp={handleMouseRemoveEvent}
/>
<div ref={setBtnGroupNode} className={styles.buttonWrap}>
<Button type="link" icon="close" size="small" ghost disabled={fileSyncUpdating} onClick={handleCancle}>
取消
</Button>
<Button
type="link"
icon="file-image"
size="small"
ghost
disabled={fileSyncUpdating}
onClick={() => getImgTrimData('justImg')}
>
轉為圖片
</Button>
<Button
type="link"
icon="file-text"
size="small"
ghost
loading={fileSyncUpdating}
onClick={getImgTrimData}
>
轉為文字
</Button>
</div>
</section>
)
複製程式碼
3.2 繪製裁剪框
實現流程如下:
⚠️注意: canvas
是基於狀態的,save()
和restore()
需要成對使用
如何將版面圖、蒙層、裁剪框和邊框畫素點按照順序疊在一起呢❓
這裡需要用到CanvasRenderingContext2D.globalCompositeOperation
屬性,它可以實現影像的合成。
?實現程式碼如下:
// 繪製裁剪框的方法
const drawTrim = (x, y, w, h, flag) => {
const ctx = canvasNode.getContext('2d');
// 繪製蒙層
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.6)'; // 蒙層顏色
ctx.fillRect(0, 0, canvasNode.width, canvasNode.height);
// 將蒙層鑿開
ctx.globalCompositeOperation = 'source-atop';
// 裁剪選擇框
ctx.clearRect(x, y, w, h);
if (!flag && trimPositionMap.length > 0) {
trimPositionMap.map(item => ctx.clearRect(item.startX, item.startY, item.width, item.height));
}
// 繪製8個邊框畫素點
ctx.globalCompositeOperation = 'source-over';
drawBorderPixel(ctx, x, y, w, h);
if (!flag && trimPositionMap.length > 0) {
trimPositionMap.map(item => drawBorderPixel(ctx, item.startX, item.startY, item.width, item.height));
}
// 儲存當前區域座標資訊
setCurPoisition({
width: w,
height: h,
startX: x,
startY: y,
position: [
(x, y),
(x + w, y),
(x, y + h),
(x + w, y + h),
(x + w / 2, y),
(x + w / 2, y + h),
(x, y + h / 2),
(x + w, y + h / 2),
],
canvasWidth: canvasNode.width, // 用於計算移動端版面圖縮放比例
});
ctx.restore();
// 再次呼叫drawImage將圖片繪製到蒙層下方
ctx.save();
ctx.globalCompositeOperation = 'destination-over';
ctx.drawImage(originImg, 0, 0, canvasNode.width, canvasNode.height);
ctx.restore();
};
// 繪製邊框畫素點的方法
const drawBorderPixel = (ctx, x, y, w, h) => {
ctx.fillStyle = '#f5222d';
const size = 5; // 自定義畫素點大小
ctx.fillRect(x - size / 2, y - size / 2, size, size);
// ...同理通過ctx.fillRect再畫出其餘畫素點
ctx.fillRect(x + w - size / 2, y - size / 2, size, size);
ctx.fillRect(x - size / 2, y + h - size / 2, size, size);
ctx.fillRect(x + w - size / 2, y + h - size / 2, size, size);
ctx.fillRect(x + w / 2 - size / 2, y - size / 2, size, size);
ctx.fillRect(x + w / 2 - size / 2, y + h - size / 2, size, size);
ctx.fillRect(x - size / 2, y + h / 2 - size / 2, size, size);
ctx.fillRect(x + w - size / 2, y + h / 2 - size / 2, size, size);
};
複製程式碼
四、輸出裁剪圖片
4.1 getImageData()
的使用
我們要獲取裁剪框的影像資訊,需要用到getImageData()
方法,它返回一個ImageData
物件。
語法
context.getImageData(sx, sy, sWidth, sHeight);
引數
sx、sy
:截圖框的起始座標值;sWidth, sHeight
: 截圖框的寬高
❓:獲取了裁剪框影像資訊後,那怎麼將它們轉換成圖片呢
需要新建一個canvas
,通過getImageData()
方法把裁剪框影像資訊放在該canvas
上。
❓:為什麼要新建canvas,直接用toBlob()
不行嗎
HTMLCanvasElement.toBlob()
是將整個canvas
進行輸出,而此專案要的是canvas
中裁剪框的影像資訊。
4.2 putImageData()
的使用
putImageData()
可以把已有的裁剪框資料繪製到新畫布的指定區域上。
語法
context.putImageData(imagedata, dx, dy)
;context.putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight)
;
引數
imagedata
:裁剪框影像資訊;dx, dy
: 目標Canvas
中被imagedata
替換的起始座標;dirtyX, dirtyY
:裁剪框區域左上角的座標,預設為0
;dirtyWidth, dirtyHeight
:裁剪框的寬高。預設值是imagedata
影像的寬高。
4.3 使用Canvas.toDataURL()
輸出圖片
canvas
提供了兩個2D
轉換為圖片的方法:
-
HTMLCanvasElement.toDataURL()
返回base64
地址 -
HTMLCanvasElement.toBlob()
返回Blob
物件
本專案OCR
介面要求的圖片格式是Base64
,所以使用HTMLCanvasElement.toDataURL()
方法。
4.4 使用OCR
識別圖片資訊
❓:為什麼要計算出包含多個裁剪框的最小矩形
因為OCR
每呼叫一次都是計費的,所以不管有多少個裁剪框,最後只輸出到一個canvas
上,這樣只呼叫一次OCR
。
⚠️單個裁剪框的最小矩形即是其本身。
❓:如何計算出最小矩形
很簡單,分別得到多個裁剪框的最小startX
、startY
值和最大endX
、endY
值,即可計算出最小矩形的開始座標和寬高。
程式碼實現如下:
// 獲得裁剪後的圖片檔案
const getImgTrimData = flag => {
// trimPositionMap為裁剪框的座標資料
if (trimPositionMap.length === 0) {
return;
}
const ctx = canvasNode.getContext('2d');
// 重新構建一個canvas,計算出包含多個裁剪框的最小矩形
const trimCanvasNode = document.createElement('canvas');
const { startX, startY, minWidth, minHeight } = getMinTrimReactArea();
trimCanvasNode.width = minWidth;
trimCanvasNode.height = minHeight;
const trimCtx = trimCanvasNode.getContext('2d');
trimCtx.clearRect(0, 0, trimCanvasNode.width, trimCanvasNode.height);
trimPositionMap.map(pos => {
// 取到裁剪框的畫素資料
const data = ctx.getImageData(pos.startX, pos.startY, pos.width, pos.height);
// 輸出在canvas上
return trimCtx.putImageData(data, pos.startX - startX, pos.startY - startY);
});
const trimData = trimCanvasNode.toDataURL();
// 若轉成圖片,直接輸出trimData;若轉成文字,則請求OCR介面,轉換成文字
(flag === 'justImg'
? Promise.resolve(trimData)
: dispatch({
type: 'digital/postImgFileWithAliOcr',
payload: {
img: trimData,
},
})
).then(result => {
// 呼叫外部api,輸出圖片資料
onTransform(result, flag);
});
};
// 計算出包含多個裁剪框的最小矩形
const getMinTrimReactArea = () => {
const startX = Math.min(...trimPositionMap.map(item => item.startX));
const endX = Math.max(...trimPositionMap.map(item => item.startX + item.width));
const startY = Math.min(...trimPositionMap.map(item => item.startY));
const endY = Math.max(...trimPositionMap.map(item => item.startY + item.height));
return {
startX,
startY,
minWidth: endX - startX,
minHeight: endY - startY,
};
};
複製程式碼
總結
很多業務場景中會用到圖片的裁剪功能,因為裁剪元件實現起來比較費時間,所以很多前端朋友直接藉助第三方外掛,但外掛中又依賴了很多別的外掛,這樣你的專案後期維護會比較費勁,個人建議能不依賴第三方庫的儘量自己去實現。
本文主要是介紹裁剪框的繪製,至於裁剪框的移動、伸縮、旋轉,暫沒有去實現,這些都是基於座標點的操作,相對簡單。
Canvas
的屬性和方法若能用得好的話,可以實現非常多好玩的效果,前提是要吃透canvas
。
歡迎指正,謝謝!