From 9e99aef1ccd2021d16e50ad136f2fb23b85ae701 Mon Sep 17 00:00:00 2001 From: LH_R Date: Sat, 23 Nov 2024 17:31:48 +0800 Subject: [PATCH 1/4] feat(uploadImage): add image segmentation --- .../boardOperation/downloadImage/index.tsx | 52 +- .../uploadImage/imageSegmentation.tsx | 462 ++++++++++++++++++ .../boardOperation/uploadImage/index.tsx | 42 +- .../image-segmentation-negative.svg | 12 + .../image-segmentation-positive.svg | 12 + src/components/icons/loading.svg | 7 + src/i18n/en.json | 14 +- src/i18n/zh.json | 14 +- src/types/index.d.ts | 6 + src/utils/common/cropTransparent.ts | 69 +++ 10 files changed, 648 insertions(+), 42 deletions(-) create mode 100644 src/components/boardOperation/uploadImage/imageSegmentation.tsx create mode 100644 src/components/icons/boardOperation/image-segmentation-negative.svg create mode 100644 src/components/icons/boardOperation/image-segmentation-positive.svg create mode 100644 src/components/icons/loading.svg create mode 100644 src/utils/common/cropTransparent.ts diff --git a/src/components/boardOperation/downloadImage/index.tsx b/src/components/boardOperation/downloadImage/index.tsx index 9aa7001..ae9eede 100644 --- a/src/components/boardOperation/downloadImage/index.tsx +++ b/src/components/boardOperation/downloadImage/index.tsx @@ -106,6 +106,8 @@ const DownloadImage: FC = ({ url, showModal, setShowModal }) => { if (hiddenAnchorRef.current) { hiddenAnchorRef.current.href = blobUrlRef.current hiddenAnchorRef.current.click() + + setShowModal(false) } } @@ -141,7 +143,31 @@ const DownloadImage: FC = ({ url, showModal, setShowModal }) => { {completedCrop && (
-
+
+
+ +
+
+ {[1, 2, 3].map((value) => ( + { + updateSaveImageSize(value) + }} + > + {`${value}x`} + + ))} +
+
+ +
= ({ url, showModal, setShowModal }) => { }} />
- -
-
- -
-
- {[1, 2, 3].map((value) => ( - { - updateSaveImageSize(value) - }} - > - {`${value}x`} - - ))} -
-
diff --git a/src/components/boardOperation/uploadImage/imageSegmentation.tsx b/src/components/boardOperation/uploadImage/imageSegmentation.tsx new file mode 100644 index 0000000..3cbb127 --- /dev/null +++ b/src/components/boardOperation/uploadImage/imageSegmentation.tsx @@ -0,0 +1,462 @@ +import { useState, useRef, useEffect, useMemo, MouseEvent, FC } from 'react' +import { useTranslation } from 'react-i18next' +import { + SamModel, + AutoProcessor, + RawImage, + PreTrainedModel, + Processor, + Tensor, + SamImageProcessorResult +} from '@huggingface/transformers' +import { ImageElement } from '@/utils/element/image' +import { cropTransparent } from '@/utils/common/cropTransparent' + +import Mask from '@/components/mask' +import InfoOutline from '@/components/icons/info-outline.svg?react' +import LoadingIcon from '@/components/icons/loading.svg?react' +import PositiveIcon from '@/components/icons/boardOperation/image-segmentation-positive.svg?react' +import NegativeIcon from '@/components/icons/boardOperation/image-segmentation-negative.svg?react' + +interface MarkPoint { + position: number[] + label: number +} + +interface IProps { + url: string + showModal: boolean + setShowModal: (show: boolean) => void + cancelUploadImageModal: () => void +} + +const SEGMENTATION_STATUS = { + LOADING: 0, + NO_SUPPORT_WEBGPU: 1, + LOAD_ERROR: 2, + LOAD_SUCCESS: 3, + PROCESSING: 4, + PROCESSING_SUCCESS: 5 +} + +type SegmentationStatusType = + (typeof SEGMENTATION_STATUS)[keyof typeof SEGMENTATION_STATUS] + +const ImageSegmentation: FC = ({ + url, + showModal, + setShowModal, + cancelUploadImageModal +}) => { + const { t } = useTranslation() + + const [markPoints, setMarkPoints] = useState([]) + const [segmentationStatus, setSegmentationStatus] = + useState() + const [pointStatus, setPointStatus] = useState(true) + + const maskCanvasRef = useRef(null) + const modelRef = useRef() + const processorRef = useRef() + const imageInputRef = useRef() + const imageProcessed = useRef() + const imageEmbeddings = useRef() + + const segmentationTip = useMemo(() => { + switch (segmentationStatus) { + case SEGMENTATION_STATUS.LOADING: + return 'uploadImage.imageSegmentationLoading' + case SEGMENTATION_STATUS.NO_SUPPORT_WEBGPU: + return 'uploadImage.imageSegmentationGpuTip' + case SEGMENTATION_STATUS.LOAD_ERROR: + return 'uploadImage.imageSegmentationFailed' + case SEGMENTATION_STATUS.LOAD_SUCCESS: + return 'uploadImage.imageSegmentationSuccess' + case SEGMENTATION_STATUS.PROCESSING: + return 'uploadImage.imageSegmentationProcessing' + case SEGMENTATION_STATUS.PROCESSING_SUCCESS: + return 'uploadImage.imageSegmentationProcessed' + default: + return '' + } + }, [segmentationStatus]) + + useEffect(() => { + ;(async () => { + try { + if ( + !showModal || + modelRef.current || + processorRef.current || + segmentationStatus === SEGMENTATION_STATUS.LOADING + ) { + return + } + + setSegmentationStatus(SEGMENTATION_STATUS.LOADING) + if (!navigator?.gpu) { + setSegmentationStatus(SEGMENTATION_STATUS.NO_SUPPORT_WEBGPU) + return + } + const model_id = 'Xenova/slimsam-77-uniform' + modelRef.current ??= await SamModel.from_pretrained(model_id, { + dtype: 'fp16', // or "fp32" + device: 'webgpu' + }) + processorRef.current ??= await AutoProcessor.from_pretrained(model_id) + + setSegmentationStatus(SEGMENTATION_STATUS.LOAD_SUCCESS) + } catch (err) { + console.log('err', err) + setSegmentationStatus(SEGMENTATION_STATUS.LOAD_ERROR) + } + })() + }, [showModal]) + + useEffect(() => { + ;(async () => { + try { + if ( + !showModal || + !modelRef.current || + !processorRef.current || + !url || + segmentationStatus === SEGMENTATION_STATUS.PROCESSING + ) { + return + } + setSegmentationStatus(SEGMENTATION_STATUS.PROCESSING) + clearPoints() + + imageInputRef.current = await RawImage.fromURL(url) + imageProcessed.current = await processorRef.current( + imageInputRef.current + ) + imageEmbeddings.current = await ( + modelRef.current as any + ).get_image_embeddings(imageProcessed.current) + + setSegmentationStatus(SEGMENTATION_STATUS.PROCESSING_SUCCESS) + } catch (err) { + console.log('err', err) + } + })() + }, [url, modelRef.current, processorRef.current, showModal]) + + function updateMaskOverlay(mask: RawImage, scores: Float32Array) { + const maskCanvas = maskCanvasRef.current + if (!maskCanvas) { + return + } + const maskContext = maskCanvas.getContext('2d') as CanvasRenderingContext2D + + // Update canvas dimensions (if different) + if (maskCanvas.width !== mask.width || maskCanvas.height !== mask.height) { + maskCanvas.width = mask.width + maskCanvas.height = mask.height + } + + // Allocate buffer for pixel data + const imageData = maskContext.createImageData( + maskCanvas.width, + maskCanvas.height + ) + + // Select best mask + const numMasks = scores.length // 3 + let bestIndex = 0 + for (let i = 1; i < numMasks; ++i) { + if (scores[i] > scores[bestIndex]) { + bestIndex = i + } + } + + // Fill mask with colour + const pixelData = imageData.data + for (let i = 0; i < pixelData.length; ++i) { + if (mask.data[numMasks * i + bestIndex] === 1) { + const offset = 4 * i + pixelData[offset] = 101 // r + pixelData[offset + 1] = 204 // g + pixelData[offset + 2] = 138 // b + pixelData[offset + 3] = 255 // a + } + } + + // Draw image data to context + maskContext.putImageData(imageData, 0, 0) + } + + const decode = async (markPoints: MarkPoint[]) => { + if ( + !modelRef.current || + !imageEmbeddings.current || + !processorRef.current || + !imageProcessed.current + ) { + return + } + + if (!markPoints.length && maskCanvasRef.current) { + const maskContext = maskCanvasRef.current.getContext( + '2d' + ) as CanvasRenderingContext2D + maskContext.clearRect( + 0, + 0, + maskCanvasRef.current.width, + maskCanvasRef.current.height + ) + return + } + + // Prepare inputs for decoding + const reshaped = imageProcessed.current.reshaped_input_sizes[0] + const points = markPoints + .map((x) => [x.position[0] * reshaped[1], x.position[1] * reshaped[0]]) + .flat(Infinity) + const labels = markPoints.map((x) => BigInt(x.label)).flat(Infinity) + + const num_points = markPoints.length + const input_points = new Tensor('float32', points, [1, 1, num_points, 2]) + const input_labels = new Tensor('int64', labels, [1, 1, num_points]) + + // Generate the mask + const { pred_masks, iou_scores } = await modelRef.current({ + ...imageEmbeddings.current, + input_points, + input_labels + }) + + // Post-process the mask + const masks = await (processorRef.current as any).post_process_masks( + pred_masks, + imageProcessed.current.original_sizes, + imageProcessed.current.reshaped_input_sizes + ) + + updateMaskOverlay(RawImage.fromTensor(masks[0][0]), iou_scores.data) + } + + const clamp = (x: number, min = 0, max = 1) => { + return Math.max(Math.min(x, max), min) + } + + const clickImage = (e: MouseEvent) => { + if (segmentationStatus !== SEGMENTATION_STATUS.PROCESSING_SUCCESS) { + return + } + + const { clientX, clientY, currentTarget } = e + const { left, top } = currentTarget.getBoundingClientRect() + + const x = clamp( + (clientX - left + currentTarget.scrollLeft) / currentTarget.scrollWidth + ) + const y = clamp( + (clientY - top + currentTarget.scrollTop) / currentTarget.scrollHeight + ) + + const existingPointIndex = markPoints.findIndex( + (point) => + Math.abs(point.position[0] - x) < 0.01 && + Math.abs(point.position[1] - y) < 0.01 && + point.label === (pointStatus ? 1 : 0) + ) + + const newPoints = [...markPoints] + if (existingPointIndex !== -1) { + newPoints.splice(existingPointIndex, 1) + } else { + newPoints.push({ + position: [x, y], + label: pointStatus ? 1 : 0 + }) + } + + setMarkPoints(newPoints) + decode(newPoints) + } + + const clearPoints = () => { + setMarkPoints([]) + decode([]) + } + + const handleCancel = () => { + setShowModal(false) + clearPoints() + } + + const uploadImage = async () => { + const maskCanvas = maskCanvasRef.current + if (!maskCanvas || !imageInputRef.current) { + return + } + + const image = new ImageElement() + + if (!markPoints.length) { + image.addImage(url) + } else { + const maskContext = maskCanvas.getContext( + '2d' + ) as CanvasRenderingContext2D + + const [w, h] = [maskCanvas.width, maskCanvas.height] + + // Get the mask pixel data (and use this as a buffer) + const maskImageData = maskContext.getImageData(0, 0, w, h) + + // Create a new canvas to hold the cut-out + const cutCanvas = new OffscreenCanvas(w, h) + const cutContext = cutCanvas.getContext( + '2d' + ) as OffscreenCanvasRenderingContext2D + + // Copy the image pixel data to the cut canvas + const maskPixelData = maskImageData.data + const imagePixelData = imageInputRef.current.data + + for (let i = 0; i < w * h; ++i) { + const sourceOffset = 3 * i // RGB + const targetOffset = 4 * i // RGBA + + if (maskPixelData[targetOffset + 3] > 0) { + // Only copy opaque pixels + for (let j = 0; j < 3; ++j) { + maskPixelData[targetOffset + j] = imagePixelData[sourceOffset + j] + } + } + } + cutContext.putImageData(maskImageData, 0, 0) + + const url = URL.createObjectURL(await cutCanvas.convertToBlob()) + cropTransparent(url).then((url) => { + image.addImage(url) + }) + } + + handleCancel() + cancelUploadImageModal() + } + + return ( + { + handleCancel() + }} + > +
+
+ + + + +
+ setPointStatus(true)} + > + {t('uploadImage.positivePoint')} + + + + + setPointStatus(false)} + > + {t('uploadImage.negativePoint')} + +
+
+
+ + {t(segmentationTip)} +
+
+ {segmentationStatus !== SEGMENTATION_STATUS.PROCESSING_SUCCESS && ( +
+ +
+ )} +
+ + + + + {markPoints.map((point, index) => { + switch (point.label) { + case 1: + return ( + + ) + case 0: + return ( + + ) + default: + return null + } + })} +
+
+
+
+ ) +} + +export default ImageSegmentation diff --git a/src/components/boardOperation/uploadImage/index.tsx b/src/components/boardOperation/uploadImage/index.tsx index 885ced9..2cf86e8 100644 --- a/src/components/boardOperation/uploadImage/index.tsx +++ b/src/components/boardOperation/uploadImage/index.tsx @@ -9,14 +9,12 @@ import { Processor } from '@huggingface/transformers' import { ImageElement } from '@/utils/element/image' +import { cropTransparent } from '@/utils/common/cropTransparent' import Mask from '@/components/mask' +import ImageSegmentation from './imageSegmentation' import InfoOutline from '@/components/icons/info-outline.svg?react' -interface NavigatorWithGPU extends Navigator { - gpu: unknown -} - interface IProps { url: string showModal: boolean @@ -37,6 +35,8 @@ type RemoveBackgroundStatusType = const UploadImage: FC = ({ url, showModal, setShowModal }) => { const { t } = useTranslation() + const [showImageSegmentationModal, setShowImageSegmentationModal] = + useState(false) const [removeBackgroundStatus, setRemoveBackgroundStatus] = useState() @@ -51,7 +51,7 @@ const UploadImage: FC = ({ url, showModal, setShowModal }) => { case REMOVE_BACKGROUND_STATUS.LOADING: return 'uploadImage.removeBackgroundLoading' case REMOVE_BACKGROUND_STATUS.NO_SUPPORT_WEBGPU: - return 'uploadImage.webGPUTip' + return 'uploadImage.removeBackgroundGpuTip' case REMOVE_BACKGROUND_STATUS.LOAD_ERROR: return 'uploadImage.removeBackgroundFailed' case REMOVE_BACKGROUND_STATUS.LOAD_SUCCESS: @@ -76,9 +76,9 @@ const UploadImage: FC = ({ url, showModal, setShowModal }) => { ) { return } + setRemoveBackgroundStatus(REMOVE_BACKGROUND_STATUS.LOADING) - console.log('loading') - if (!(navigator as NavigatorWithGPU)?.gpu) { + if (!navigator?.gpu) { setRemoveBackgroundStatus(REMOVE_BACKGROUND_STATUS.NO_SUPPORT_WEBGPU) return } @@ -96,10 +96,11 @@ const UploadImage: FC = ({ url, showModal, setShowModal }) => { setRemoveBackgroundStatus(REMOVE_BACKGROUND_STATUS.LOAD_ERROR) } })() - }, [showModal, modelRef, processorRef]) + }, [showModal]) const handleCancel = () => { setShowModal(false) + setShowImageSegmentationModal(false) setShowOriginImage(true) setProcessedImage('') } @@ -162,7 +163,9 @@ const UploadImage: FC = ({ url, showModal, setShowModal }) => { image.addImage(url) handleCancel() } else if (processedImage) { - image.addImage(processedImage) + cropTransparent(processedImage).then((url) => { + image.addImage(url) + }) handleCancel() } } @@ -175,16 +178,16 @@ const UploadImage: FC = ({ url, showModal, setShowModal }) => { }} >
-
- +
+ +
@@ -222,6 +231,13 @@ const UploadImage: FC = ({ url, showModal, setShowModal }) => { /> )}
+ +
) diff --git a/src/components/icons/boardOperation/image-segmentation-negative.svg b/src/components/icons/boardOperation/image-segmentation-negative.svg new file mode 100644 index 0000000..0f58258 --- /dev/null +++ b/src/components/icons/boardOperation/image-segmentation-negative.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/icons/boardOperation/image-segmentation-positive.svg b/src/components/icons/boardOperation/image-segmentation-positive.svg new file mode 100644 index 0000000..639fb15 --- /dev/null +++ b/src/components/icons/boardOperation/image-segmentation-positive.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/icons/loading.svg b/src/components/icons/loading.svg new file mode 100644 index 0000000..f4cbd0e --- /dev/null +++ b/src/components/icons/loading.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/i18n/en.json b/src/i18n/en.json index 791e321..bff9349 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -151,14 +151,24 @@ }, "uploadImage": { "removeBackground": "Remove Background", - "webGPUTip": "WebGPU is not supported in this browser, to use the remove background function, please use the latest version of Google Chrome", + "removeBackgroundGpuTip": "WebGPU is not supported in this browser, to use the remove background function, please use the latest version of Google Chrome", "removeBackgroundLoading": "Remove background function loading", "removeBackgroundFailed": "Remove background function failed to load", "removeBackgroundSuccess": "Remove background function loaded successfully", "removeBackgroundProcessing": "Remove Background Processing", "removeBackgroundProcessingSuccess": "Remove Background Processing Success", "restore": "Restore", - "upload": "Upload" + "upload": "Upload", + "imageSegmentation": "Image Segmentation", + "clearPoints": "Clear Points", + "imageSegmentationLoading": "Image Segmentation function Loading", + "imageSegmentationFailed": "Image Segmentation function failed to load", + "imageSegmentationSuccess": "Image Segmentation function loaded successfully", + "imageSegmentationProcessing": "Image Processing...", + "imageSegmentationProcessed": "The image has been processed successfully, you can click on the image to mark it, the green mask area is the segmentation area.", + "imageSegmentationGpuTip": "WebGPU is not supported in this browser, to use the image segmentation function, please use the latest version of Google Chrome.", + "positivePoint": "Positive", + "negativePoint": "Negative" }, "downloadImage": { "rotate": "Rotate", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 2f73488..b9ca1ff 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -151,14 +151,24 @@ }, "uploadImage": { "removeBackground": "去除背景", - "webGPUTip": "本浏览器不支持WebGPU, 要使用去除背景功能请使用最新版谷歌浏览器", + "removeBackgroundGpuTip": "本浏览器不支持WebGPU, 要使用去除背景功能请使用最新版谷歌浏览器", "removeBackgroundLoading": "去除背景功能加载中", "removeBackgroundFailed": "去除背景功能加载失败", "removeBackgroundSuccess": "去除背景功能加载成功", "removeBackgroundProcessing": "去除背景处理中", "removeBackgroundProcessingSuccess": "去除背景处理成功", "restore": "还原", - "upload": "上传" + "upload": "上传", + "imageSegmentation": "图像分割", + "clearPoints": "清除标记点", + "imageSegmentationLoading": "图像分割功能加载中", + "imageSegmentationFailed": "图像分割功能加载失败", + "imageSegmentationSuccess": "图像分割功能加载成功", + "imageSegmentationProcessing": "处理图像中", + "imageSegmentationProcessed": "图像处理成功, 可点击图像进行标记, 绿色蒙层区域就是分割区域", + "imageSegmentationGpuTip": "本浏览器不支持WebGPU, 要使用图像分割功能请使用最新版谷歌浏览器", + "positivePoint": "正标记", + "negativePoint": "负标记" }, "downloadImage": { "rotate": "旋转", diff --git a/src/types/index.d.ts b/src/types/index.d.ts index f73eaec..a1239e5 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,5 +1,11 @@ import { Object as FabricObject, IAllFilters } from 'fabric/fabric-impl' +declare global { + interface Navigator { + gpu?: any + } +} + declare module 'fabric/fabric-impl' { export interface Object { id: string diff --git a/src/utils/common/cropTransparent.ts b/src/utils/common/cropTransparent.ts new file mode 100644 index 0000000..b9fb92c --- /dev/null +++ b/src/utils/common/cropTransparent.ts @@ -0,0 +1,69 @@ +/** + * crop transparent areas of the image + * @param imgDataUrl + */ +export const cropTransparent = (imgDataUrl: string) => { + return new Promise((resolve, reject) => { + const img = new Image() + img.src = imgDataUrl + + img.onload = () => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + + canvas.width = img.width + canvas.height = img.height + ctx.drawImage(img, 0, 0) + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + const { data, width, height } = imageData + + // calculating the minimum area + let minX = width, + minY = height, + maxX = 0, + maxY = 0 + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4 + const alpha = data[index + 3] // get alpha + + // If alpha is not 0, record + if (alpha !== 0) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + } + + // If no opaque area is found, return to the original image + if (minX > maxX || minY > maxY) { + resolve(imgDataUrl) + return + } + + // cropped width and height + const cropWidth = maxX - minX + 1 + const cropHeight = maxY - minY + 1 + + // cropped image + const croppedImageData = ctx.getImageData( + minX, + minY, + cropWidth, + cropHeight + ) + canvas.width = cropWidth + canvas.height = cropHeight + ctx.putImageData(croppedImageData, 0, 0) + + // put image + const croppedDataUrl = canvas.toDataURL() + resolve(croppedDataUrl) + } + + img.onerror = () => reject(new Error('Image loading failed')) + }) +} From eb0166bb0610e208e8334ca6ffdf626a65ce817a Mon Sep 17 00:00:00 2001 From: LH_R Date: Sat, 23 Nov 2024 17:32:10 +0800 Subject: [PATCH 2/4] feat: update version --- package.json | 2 +- src/store/files.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ac46ef3..4bbb309 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "paint-board", "private": true, - "version": "1.5.2", + "version": "1.5.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src/store/files.ts b/src/store/files.ts index 8f77a8e..c854889 100644 --- a/src/store/files.ts +++ b/src/store/files.ts @@ -55,7 +55,7 @@ interface FileAction { } const initId = uuidv4() -export const BOARD_VERSION = '1.5.2' +export const BOARD_VERSION = '1.5.3' const useFileStore = create()( persist( From fc9f612c4783931afb42f7daa27d162201e06848 Mon Sep 17 00:00:00 2001 From: LH_R Date: Sat, 23 Nov 2024 17:32:20 +0800 Subject: [PATCH 3/4] docs: update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6a875..3b782f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.5.3 + +- upload image + - add image segmentation + # 1.5.2 ### Feat From 0b5f3600a4d77105b0c9028687ecfb96e5244c53 Mon Sep 17 00:00:00 2001 From: LH_R Date: Sat, 23 Nov 2024 17:32:34 +0800 Subject: [PATCH 4/4] docs: update README --- README.md | 2 +- README.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d09320..65fc04a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Link: [https://songlh.top/paint-board/](https://songlh.top/paint-board/) + Multifunction Menu - The bottom left button shows the current zoom ratio in real time, click it to reset the zoom ratio. - The list of buttons in the center, in order from left to right, are: Undo, Redo, Copy Current Selection, Delete Current Selection, Draw Text, Upload Image, Clear Drawing, Save as Image, and Open File List. - - Support removing background for uploading images (browser needs to support WebGPU) + - Upload images with support for background remove, image segmentation. This function needs WebGPU support in your browser. - Save as image supports custom configurations. Rotate, Scale, Crop - PC: - Hold down the Space key and click the left mouse button to move the canvas, scroll the mouse wheel to zoom the canvas. diff --git a/README.zh.md b/README.zh.md index 663f645..b758385 100644 --- a/README.zh.md +++ b/README.zh.md @@ -55,7 +55,7 @@ Link: [https://songlh.top/paint-board/](https://songlh.top/paint-board/) + 多功能菜单 - 左下角按钮实时显示当前缩放比例,点击即可重置缩放比例。 - 中间按钮列表按从左到右的功能分别为:撤销、反撤销、复制当前选择内容、删除当前选择内容、绘制文字、上传图片、清除绘制内容、保存为图片、打开文件列表。 - - 上传图片支持去除背景(浏览器需支持WebGPU) + - 上传图片支持去除背景, 图像分割. 此功能需要浏览器支持WebGPU - 保存为图片支持自定义配置. 旋转, 缩放, 裁切 - 电脑端: - 按住 Space 键并点击鼠标左键可移动画布,滚动鼠标滚轮实现画布缩放。