如何使用 Canvas 实现 AI 消除画板?

942 阅读7分钟

什么是 AI 消除?

将图片中不需要的内容移除掉,包括:

  • 移除图片中的部分内容
  • 移除图片背景

对于移除图片中的部分内容,需要借助 AI 的能力。在移除内容时,根据移除内容周围的图片信息,自动绘制移除部分的内容,这样可以使移除部分不会显得空洞并且与周围的内容看起来更真实。效果图如下所示:

原图

消除后的图

这样的功能对于 PS 高手来说,也就分分钟的事。但对于普通用户,还是会有些难度。

AI消除 就是为了简化普通用户的使用而生。

实现原理

原图 => 绘制蒙层 => 绘制隐藏蒙层 => AI API

原图 绘制蒙层 绘制 mask

原图是用户上传的。绘制蒙层是为了方便用户知道目前选中的区域。绘制的 mask 是要传给 AI 的图片。AI 会根据 mask 来对原图进行修改,黑色区域保留,白色区域去掉,并根据黑色区域对应的原图信息自动绘制白色区域的内容。

如何实现画板?

要实现 AI消除 画板,需要有:

  • 绘制区(主要)
  • 操作步骤控制。上一步、下一步、重置(主要)
  • 页面缩放
  • 画笔大小控制
  • 查看原图
  • 开始消除按钮
  • 下载消除后的图片

面板示例图如下所示:

画板示例图

绘制区

绘制区虽然看到的只有一个区域,实际上是由三个 Canvas 组成。

  • Canvas-1。绘制原图。
  • Canvas-2。绘制用户可见的蓝色区域部分。
  • Canvas-3。根据 Canvas-2 同步绘制需要传给 AImask 图层。

Canvas-2Canvas-3 都很好理解,为什么需要 Canvas-1 也需要使用 Canvas 进行绘制呢?

经常测试,如果原图直接使用 <img src="..." /> 展示,图片区域可以被选中,在拖动时效果不好。当然也可以尝试使用 user-select: none; 设置节点不可被选中,但在拖动其它 div 节点时,它也可能会被拖动,影响用户体验。

所以,原图还是使用了一个 Canvas 来绘制。

当然,可能还有其它办法解决,暂时没有再深入去研究了。

这个部分的代码如下:

<div className={styles.canvasGroup}>
    <canvas className={styles.originCanvas} ref={originCanvas} width={newImage.width} height={newImage.height}></canvas>
    <canvas className={classNames(styles.drawCanvas, loading && styles.drawCanvasActive)} ref={drawCanvas} width={newImage.width} height={newImage.height}></canvas>
    <canvas className={styles.maskCanvas} ref={maskCanvas} width={newImage.width} height={newImage.height}></canvas>
</div>

初始化绘制原图:

useEffect(() => {
	const originCtx = originCanvas.current?.getContext('2d') as CanvasRenderingContext2D;
	const image = new Image();
	image.src = newImage.inputImg;

	image.onload = function () {
		// 绘制底图
		if (!originCanvas || !originCanvas.current) return;
		originCtx.drawImage(image, 0, 0, newImage.width, newImage.height);
	};
	// 页面需要放大、缩小,默认滚动到页面中间
	eraserRef.current?.scrollTo(0, ((canvasPanelRef?.current?.scrollHeight || 0) - originImage.height * originScale) / 2);
}, [newImage.height, newImage.inputImg, newImage.width, originImage.height, originScale]);

原图绘制好后,就给第二个、第三个面板添加鼠标监听事件,当鼠标按下并且开始滑动时,进行 Canvas 路径绘制。

useEffect(() => {
	const drawCanvasCurrent = drawCanvas.current;
	const maskCanvasCurrent = maskCanvas.current;

	const drawCtx = drawCanvasCurrent?.getContext('2d') as CanvasRenderingContext2D;
	const maskCtx = maskCanvasCurrent?.getContext('2d') as CanvasRenderingContext2D;
	let isDrawing = false;
	const ctxGroup = [drawCtx, maskCtx];

	if (!drawCanvas || !drawCanvasCurrent) return;

	// 设置 ctx 公共样式
	const setCtxStyle = () => {
		ctxGroup.forEach(ctx => {
			ctx.lineWidth = brushSize; // 设置线宽
			ctx.lineCap = 'round'; // 设置线帽为圆形
			ctx.lineJoin = 'round'; // 设置线帽为圆形
		});
	};

	// 设置画笔属性
	drawCtx.strokeStyle = '#38f'; // 设置颜色
	setCtxStyle();

	function drawLine(beginPoint: IObject, controlPoint: IObject, endPoint: IObject){
		ctxGroup.forEach(ctx => {
			ctx.beginPath();
			ctx.moveTo(beginPoint.x, beginPoint.y);
			ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, endPoint.x, endPoint.y);
			ctx.stroke();
		});
	}

	const handleMouseDown = (e: MouseEvent) => {
		isDrawing = true;
		const x = e.offsetX;
		const y = e.offsetY;
		ctxGroup.forEach(ctx => {
			ctx.moveTo(x, y);
		});

		pointsRef.current.push({ x, y });
		beginPointRef.current = { x, y };

		setCtxStyle();
	};

	// 绘制路径
	function handleMousemove(e: MouseEvent) {
		setShowPointCircle(true);
		if (!isDrawing) return;
		const x = e.offsetX;
		const y = e.offsetY;
		pointsRef.current.push({ x, y });

		if (pointsRef.current.length > 3) {
			const lastTwoPoints = pointsRef.current.slice(-2);
			const controlPoint = lastTwoPoints[0];
			const endPoint = {
				x: (lastTwoPoints[0].x + lastTwoPoints[1].x) / 2,
				y: (lastTwoPoints[0].y + lastTwoPoints[1].y) / 2,
			};
			drawLine(beginPointRef.current, controlPoint, endPoint);
			beginPointRef.current = endPoint;
		}
	}

	const handleMouseup = () => {
		isDrawing = false;
		ctxGroup.forEach(ctx => {
			ctx.closePath();
		});
	};

	const handleMouseout = () => {
		isDrawing = false;
		setShowPointCircle(false);
	};
	
	drawCanvasCurrent.addEventListener('mousedown', handleMouseDown);
	drawCanvasCurrent.addEventListener('mousemove', handleMousemove);
	drawCanvasCurrent.addEventListener('mouseup', handleMouseup);
	drawCanvasCurrent.addEventListener('mouseout', handleMouseout);
	return () => {
		if (drawCanvasCurrent) {
			drawCanvasCurrent.removeEventListener('mousedown', handleMouseDown);
			drawCanvasCurrent.removeEventListener('mousemove', handleMousemove);
			drawCanvasCurrent.removeEventListener('mouseup', handleMouseup);
			drawCanvasCurrent.removeEventListener('mouseout', handleMouseout);
		}
	};
	
}, [brushSize]);

从这个里面可以看到,当鼠标点下时并拖动时,就开始使用 quadraticCurveTo 方法进行绘制。

这里也是经历过好几次优化。

最初是使用 arc (画圆)进行绘制,但这有一个问题,鼠标移动过快,路径就断掉了,出现下面这种现象。

路径不完整

后面又改为 lineTo 方式进行绘制,但又遇见了绘制过快时,绘制的路径会有锯齿的问题。

经过一番调研,使用 quadraticCurveTo 方法能较好地实现效果。

绘制时,不是使用 rgba 设置画笔的颜色透明度,这样在同一个区域重复绘制时,颜色会叠加,使绘制区域不透明。直接使用纯色绘制,给 Canvas-2 添加一定的 opacity 即可。

撤销、下一步、重置

手动绘制时,难免会绘制出错,撤销、下一步、重置功能是必不可少的一部队。

为实现步骤控制,我们需要将每一步的 CanvasImageData 数据缓存起来。因为缓存不需要进行页面渲染,所以使用 ref 来进行存储。

// 当前是第几步
const [currentStep, setCurrentStep] = useState<number>(-1);
// 缓存 Canvas 对应的 ImageData 数据
const imgBuffer = useRef<Array<IImgBuffer>>([]);

当回退到第一步时,也就是刚进入页面的状态,此时什么都还没有,将此时的 Canvas 作为缓存的第一个节点。

// 初始化 mask canvas,并记录初始状态
useEffect(() => {
	const drawCanvasCurrent = drawCanvas.current;
	const maskCanvasCurrent = maskCanvas.current;
	const drawCtx = drawCanvasCurrent?.getContext('2d') as CanvasRenderingContext2D;
	const maskCtx = maskCanvasCurrent?.getContext('2d') as CanvasRenderingContext2D;
	// 创建一个黑色背景
	maskCtx.strokeStyle = "black"; // 设置填充颜色为黑色
	maskCtx.fillRect(0, 0, drawCanvasCurrent?.width as number, drawCanvasCurrent?.height as number); // 填充整个Canvas
	maskCtx.strokeStyle = "white"; // 设置填充颜色为黑色

	// 记录最初的空白画板
	if (drawCanvasCurrent && maskCanvasCurrent) {
		imgBuffer.current.push({
			drawBuffer: drawCtx.getImageData(0, 0, drawCanvasCurrent?.width, drawCanvasCurrent?.height),
			maskBuffer: maskCtx.getImageData(0, 0, maskCanvasCurrent?.width, maskCanvas.current?.height)
		});
		setCurrentStep(0);
	}
}, []);

然后在鼠标的 mouseup 事件中,这时鼠标已经取消点击,一定是绘制完成了一步,将这时的 Canvas 对应的 ImageData 进行缓存。

const handleMouseup = () => {
	// ...
	if (drawCanvasCurrent && maskCanvasCurrent) {
		setCurrentStep(preStep => {
			imgBuffer.current.splice(preStep + 1, Infinity, {
				drawBuffer: drawCtx.getImageData(0, 0, drawCanvasCurrent?.width, drawCanvasCurrent?.height),
				maskBuffer: maskCtx.getImageData(0, 0, maskCanvasCurrent?.width, maskCanvasCurrent?.height)
			});
			return preStep + 1;
		});
	}
};

当点击 撤销 时:

// 处理撤销
const handleUndo = () => {
	if (!imgBuffer.current.length || loading) return;
	const { drawBuffer, maskBuffer } = imgBuffer.current[currentStep - 1];
	drawCanvas.current?.getContext('2d')?.putImageData(drawBuffer!, 0, 0);
	maskCanvas.current?.getContext('2d')?.putImageData(maskBuffer!, 0, 0);
	setCurrentStep(preStep => preStep - 1);
};

点击 下一步 时:

const handleRedo = () => {
	const { drawBuffer, maskBuffer } = imgBuffer.current[currentStep + 1];
	drawCanvas.current?.getContext('2d')?.putImageData(drawBuffer!, 0, 0);
	maskCanvas.current?.getContext('2d')?.putImageData(maskBuffer!, 0, 0);
	setCurrentStep(preStep => preStep + 1);
};

点击 重置 时:

const handleCleanAll = () => {
	const drawCtx = drawCanvas.current?.getContext('2d') as CanvasRenderingContext2D;
	const maskCtx = maskCanvas.current?.getContext('2d') as CanvasRenderingContext2D;
	// 清空蒙版
	drawCtx.clearRect(0, 0, drawCanvas.current?.width as number, drawCanvas.current?.height as number);
	maskCtx.strokeStyle = "black"; // 设置填充颜色为黑色
	maskCtx.fillRect(0, 0, drawCanvas.current?.width as number, drawCanvas.current?.height as number);
	maskCtx.strokeStyle = "white"; // 设置填充颜色为黑色

	imgBuffer.current.splice(1);
	setCurrentStep(0);
};

画笔大小

画笔大小是通过 ctx.lineWidth 设置的,所以只需要将它的值存放于 state 中,使用 ant-design<Slide> 组件或者其它的滑动输入条组件绑定更新 state 值就行了。

const [brushSize, setBrushSize] = useState<number>(60); // 画笔大小

<Slider
	defaultValue={brushSize}
	min={20}
	max={200}
	onChange={updateBurshSize}
	tooltip={{ formatter: null }}
/>

因为画笔会依赖于 brushSize,所以当 brushSize 变化后,画笔大小自然也就变了。

下载图片

根据图片的 url 去请求图片信息,指定 responseTypeblob。然后再生成一个 a 标签,用代码自动触发点击事件。

<Button
	disabled={progress !== 100 || loading}
	type="primary"
	icon={<DownloadOutlined />}
	onClick={() => onDownload(newImgUrl)}
>下载图片</Button>

下载图片具体方法:

const downloadImg = (imgUrl: string) => {
	if (!imgUrl) return;
	axios.get(imgUrl, {
		responseType: 'blob'
	}).then((data: any) => {
		const blobData = URL.createObjectURL(data);

		const link = document.createElement('a');
		link.href = blobData;
		link.download = `${Date.now()}.png`;
		link.target = "_blank";

		// 模拟点击下载链接
		link.click();
	});
}

其它

自定义画笔样式

默认是鼠标样式,这样不方便查看目前的画笔大小是多大,所以需要设置 Canvascursor: none;。让鼠标在 Canvas 上不可见,然后再自己根据 brushSize 实现一个跟随鼠标的圆来模拟画笔。

在页面缩放时卡顿

更新 scale 时使用 requestAnimationFrame

useEffect(() => {
	const drawCanvasCurrent = drawCanvas.current;
	const handleWheelEvent = (event: WheelEvent) => {
		// 阻止事件的默认行为,以防止页面滚动
		event.preventDefault();
		// 获取滚动的方向和幅度
		const deltaY = event.deltaY;
		const updateScale = () => {
			if (deltaY > 0) {
				// 向下滚动,缩小
				if (canvasPanelScale > originScale / 2) {
					setCanvasPanelScale(canvasPanelScale - originScale / 4);
				}
			} else if (deltaY < 0) {
				// 向上滚动,放大
				if (canvasPanelScale < originScale * 2) {
					setCanvasPanelScale(canvasPanelScale + originScale / 2);
				}
			}
		};
		requestAnimationFrame(updateScale);
	};

	drawCanvasCurrent?.addEventListener('wheel', handleWheelEvent);

	return () => {
		drawCanvasCurrent?.removeEventListener('wheel', handleWheelEvent);
	};
}, [canvasPanelScale, originScale]);

鼠标点下时没有绘制

因为使用的是 quadraticCurveTo 方法绘制路径。如果鼠标点击时不滑动,就不会开始绘制。所以在点击时,需要进行一次绘制。

const handleMouseDown = (e: MouseEvent) => {
	isDrawing = true;
	const x = e.offsetX;
	const y = e.offsetY;
	ctxGroup.forEach(ctx => {
		ctx.moveTo(x, y);
	});
    // ...
	ctxGroup.forEach(ctx => {
		ctx.beginPath();//开始绘制
		ctx.lineWidth = brushSize / 2;
		ctx.arc(x, y, brushSize / 4, 0, 2 * Math.PI);//arc 的意思是“弧”
		ctx.stroke();
	});
	setCtxStyle();
};

总结

  • 原图也使用 Canvas 进行绘制是为了减少默认事件对用户体验的影响。
  • Canvas-2Canvas-3 绘制的图形样式一模一样,但展示的和上传的前景和背景颜色不同,所以需要两个 Canvas 来绘制。
  • 绘制时,不是使用 rgba 设置画笔的颜色透明度,这样在同一个区域重复绘制时,颜色会叠加,使绘制区域不透明。直接使用纯色绘制,给 Canvas-2 添加一定的 opacity 即可。
  • 不要使用 arc 或者 lineTo 来绘制连续路径,快速绘制时效果不好。要使用 quadraticCurveTo 来绘制。
  • 在没手动绘制前,需要先记录一次初始状态的 ImageData 值,以方便撤回到最初状态。
  • 刚点击未拖动鼠标时,需要手动绘制一个圆来实现及时响应效果。
  • 默认的鼠标样式不好看,需要自定义画笔样式。
  • 图片缩放时,对 Canvas 进行绘制时,思考一下怎样实现效果才能更好!