什么是 AI 消除?
将图片中不需要的内容移除掉,包括:
- 移除图片中的部分内容
- 移除图片背景
对于移除图片中的部分内容,需要借助 AI 的能力。在移除内容时,根据移除内容周围的图片信息,自动绘制移除部分的内容,这样可以使移除部分不会显得空洞并且与周围的内容看起来更真实。效果图如下所示:
这样的功能对于 PS 高手来说,也就分分钟的事。但对于普通用户,还是会有些难度。
AI消除 就是为了简化普通用户的使用而生。
实现原理
原图 => 绘制蒙层 => 绘制隐藏蒙层 => AI API
原图是用户上传的。绘制蒙层是为了方便用户知道目前选中的区域。绘制的 mask 是要传给 AI 的图片。AI 会根据 mask 来对原图进行修改,黑色区域保留,白色区域去掉,并根据黑色区域对应的原图信息自动绘制白色区域的内容。
如何实现画板?
要实现 AI消除 画板,需要有:
- 绘制区(主要)
- 操作步骤控制。上一步、下一步、重置(主要)
- 页面缩放
- 画笔大小控制
- 查看原图
- 开始消除按钮
- 下载消除后的图片
面板示例图如下所示:
绘制区
绘制区虽然看到的只有一个区域,实际上是由三个 Canvas 组成。
Canvas-1。绘制原图。Canvas-2。绘制用户可见的蓝色区域部分。Canvas-3。根据Canvas-2同步绘制需要传给AI的mask图层。
Canvas-2 和 Canvas-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 即可。
撤销、下一步、重置
手动绘制时,难免会绘制出错,撤销、下一步、重置功能是必不可少的一部队。
为实现步骤控制,我们需要将每一步的 Canvas 的 ImageData 数据缓存起来。因为缓存不需要进行页面渲染,所以使用 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 去请求图片信息,指定 responseType 为 blob。然后再生成一个 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();
});
}
其它
自定义画笔样式
默认是鼠标样式,这样不方便查看目前的画笔大小是多大,所以需要设置 Canvas 的 cursor: 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-2和Canvas-3绘制的图形样式一模一样,但展示的和上传的前景和背景颜色不同,所以需要两个Canvas来绘制。- 绘制时,不是使用
rgba设置画笔的颜色透明度,这样在同一个区域重复绘制时,颜色会叠加,使绘制区域不透明。直接使用纯色绘制,给Canvas-2添加一定的opacity即可。 - 不要使用
arc或者lineTo来绘制连续路径,快速绘制时效果不好。要使用quadraticCurveTo来绘制。 - 在没手动绘制前,需要先记录一次初始状态的
ImageData值,以方便撤回到最初状态。 - 刚点击未拖动鼠标时,需要手动绘制一个圆来实现及时响应效果。
- 默认的鼠标样式不好看,需要自定义画笔样式。
- 图片缩放时,对
Canvas进行绘制时,思考一下怎样实现效果才能更好!