react.js 基于原生js 开发图片标注功能
react.js 基于原生js 开发图片标注功能
github地址
效果展示:

代码:
index.jsx
import React, { useState, useEffect, useRef } from 'react';
import { Radio, Button } from 'antd';
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
import './FileSaver';
import './index.css';
import { colorChange } from './utils';
import axios from 'axios';
import cnames from 'classnames';let canvas,obj = {},p1 = {},p2 = {},image = new Image(),xMin,yMin,xMax,yMax,pMin,pMax,k,imgX,imgY,canW,canH,ctx,imW,imH,color,label,value,imgWK,imgHK,lenIm,xl,xr,yu,yd,target,x,y,xm,ym,p,mouseX,mouseY,imgXY,resValue = [],flag_drawBbox = false;
const radioValueList = [{label: '植物',value: 'botany',labelColor: '#68228B',},{label: '水果',value: 'fruit',labelColor: '#FF82AB',},{label: '咖啡',value: 'coffee',labelColor: '#00CD00',},{label: '纸箱',value: 'carton',labelColor: '#00B2EE',},{label: '磁带',value: 'tape',labelColor: '#DEB887',},
];
export default function PicMark() {canvas = useRef(null);let selectValue = radioValueList?.at(0);const [objValueArr, setObjValueArr] = useState([]);const [radioValue, setRadioValue] = useState(() => radioValueList?.at(0)?.value);// 初始化function init() {canvas = canvas.current;canW = canvas.width;canH = canvas.height;ctx = canvas.getContext('2d');ctx.lineWidth = 3;flush_canvas();image.src = 'http://www.tietuku.cn/assets/img/error.svg';image.objects = [];// 加载图片image.onload = function () {showOriginImg();};// 双击方法canvas.ondblclick = function (e) {enlargedPicture(e, image);};canvas.oncontextmenu = function (e) {e.preventDefault();};canvas.onmouseup = function (e) {if (e.button === 2) {showOriginImg();}};// 划线canvas.onmousedown = function (e) {// 0 : 鼠标左键if (e.button === 0) {if (!flag_drawBbox) {flag_drawBbox = true;p1.x = e.offsetX > image.canx ? e.offsetX : image.canx;p1.x =p1.x < image.canx + image.canw? p1.x: image.canx + image.canw;p1.y = e.offsetY > image.cany ? e.offsetY : image.cany;p1.y =p1.y < image.cany + image.canh? p1.y: image.cany + image.canw;return;}flag_drawBbox = false;}};canvas.onmousemove = function (e) {if (flag_drawBbox) {p2.x = e.offsetX > image.canx ? e.offsetX : image.canx;p2.x =p2.x < image.canx + image.canw? p2.x: image.canx + image.canw;p2.y = e.offsetY > image.cany ? e.offsetY : image.cany;p2.y =p2.y < image.cany + image.canh? p2.y: image.cany + image.canw;obj.x = Math.min(p1.x, p2.x);obj.y = Math.min(p1.y, p2.y);obj.w = Math.abs(p1.x - p2.x);obj.h = Math.abs(p1.y - p2.y);showImage(image);ctx.fillStyle = utilsColorChange(obj.labelColor);ctx.fillRect(obj.x, obj.y, obj.w, obj.h);ctx.save();}};}// 获取数据function getData() {axios.get(`https://search.heweather.com/find?location=杭州&key=bc08513d63c749aab3761f77d74fe820`).then((res) => {if (res.status === 200) {let data = {imgName: 'http://www.tietuku.cn/assets/img/error.svg',objValue: [{label: '植物',labelColor: '#68228B',value: 'botany',keyId: '0.9097',xMin: 110,xMax: 236,yMin: 65,yMax: 208,width: 126,height: 143,},{label: '植物',labelColor: '#68228B',value: 'botany',keyId: '0.2382',xMin: 266,xMax: 366,yMin: 64,yMax: 208,width: 100,height: 144,},{label: '植物',labelColor: '#68228B',value: 'botany',keyId: '0.1336',xMin: 416,xMax: 516,yMin: 64,yMax: 206,width: 100,height: 142,},{label: '咖啡',labelColor: '#00CD00',value: 'coffee',keyId: '0.7728',xMin: 190,xMax: 280,yMin: 241,yMax: 371,width: 90,height: 130,},{label: '咖啡',labelColor: '#00CD00',value: 'coffee',keyId: '0.7403',xMin: 352,xMax: 486,yMin: 241,yMax: 390,width: 134,height: 149,},{label: '水果',labelColor: '#FF82AB',value: 'fruit',keyId: '0.4778',xMin: 528,xMax: 696,yMin: 219,yMax: 324,width: 168,height: 105,},{label: '纸箱',labelColor: '#00B2EE',value: 'carton',keyId: '0.5362',xMin: 729,xMax: 953,yMin: 37,yMax: 261,width: 224,height: 224,},{label: '纸箱',labelColor: '#00B2EE',value: 'carton',keyId: '0.3866',xMin: 1038,xMax: 1248,yMin: 167,yMax: 420,width: 210,height: 253,},{label: '纸箱',labelColor: '#00B2EE',value: 'carton',keyId: '0.2151',xMin: 1220,xMax: 1423,yMin: 20,yMax: 229,width: 203,height: 209,},{label: '磁带',labelColor: '#DEB887',value: 'tape',keyId: '0.2404',xMin: 616,xMax: 967,yMin: 537,yMax: 776,width: 351,height: 239,},{label: '磁带',labelColor: '#DEB887',value: 'tape',keyId: '0.9110',xMin: 173,xMax: 406,yMin: 554,yMax: 830,width: 233,height: 276,},{label: '磁带',labelColor: '#DEB887',value: 'tape',keyId: '0.1834',xMin: 1249,xMax: 1444,yMin: 492,yMax: 699,width: 195,height: 207,},{label: '咖啡',labelColor: '#00CD00',value: 'coffee',keyId: '0.9641',xMin: 877,xMax: 1077,yMin: 500,yMax: 703,width: 200,height: 203,},],};const { imgName, objValue } = data;objValue?.forEach((i) => {drawFill(imgName,i.xMin,i.yMin,i.xMax,i.yMax,i.labelColor);i.x = i.xMin + 1;i.y = i.yMin + 1;i.w = i.xMax - i.xMin;i.h = i.yMax - i.yMin;// Tjt: 眼睛图标打开i.isShow = true;// Tjt: 是否选中i.isSelect = false;});setObjValueArr(objValue);resValue = objValue;confirmBox(objValue);return;}});}//双击放大图片function enlargedPicture(e, img) {if (e) {mouseX = e.offsetX;mouseY = e.offsetY;} else {mouseX = 1;mouseY = 1;}if (canXYonImage(mouseX, mouseY)) {imgXY = canXYtoImageXY(img, mouseX, mouseY);img.focusX = imgXY[0];img.focusY = imgXY[1];img.sizek *= 1.2;resetDataNewObj();showImage(img);return;}}// 缩小图片function zoomOutPicture(img) {mouseX = 1;mouseY = 1;imgXY = canXYtoImageXY(img, mouseX, mouseY);imgXY = [1, 1];img.focusX = imgXY[0];img.focusY = imgXY[1];img.sizek *= 0.9;resetDataNewObj();showImage(img);}//判断点是否在image上function canXYonImage(x, y) {if (x > image.canx && x < image.canx + image.canw) {if (y > image.cany && y < image.cany + image.canh) {return true;}} else {return false;}}//获取canvas上一个点对应原图像的点function canXYtoImageXY(img, canx, cany) {k = 1 / img.sizek;imgX = (canx - img.canx) * k + img.cutx;imgY = (cany - img.cany) * k + img.cuty;return [imgX, imgY];}//在canvas上展示原图片function showOriginImg() {flush_canvas();canvas = canvas.current || canvas;imW = canvas.width;imH = canvas.height;image.width = canW;image.height = canH;k = canW / imW;if (imH * k > canH) {k = canH / imH;}image.sizek = k;image.focusX = imW / 2;image.focusY = imH / 2;resetDataNewObj();showImage(image);}//在canvas上展示图像对应的部分function showImage(img) {flush_canvas();imgWK = img.width * img.sizek;imgHK = img.height * img.sizek;// if (canW > imgWK) {// img.cutx = 0;// img.canx = (canW - imgWK) / 2;// img.cutw = img.width;// img.canw = imgWK;// } else {img.canx = 0;img.canw = canW;lenIm = canW / img.sizek;img.cutw = lenIm;xl = img.focusX - lenIm / 2;xr = img.focusX + lenIm / 2;img.cutx = xl;if (xl < 0) {img.cutx = 0;}if (xr >= img.width) {img.cutx = xl - (xr - img.width + 1);}// }// if (canH > imgHK) {// img.cuty = 0;// img.cany = (canH - imgHK) / 2;// img.cuth = img.height;// img.canh = imgHK;// } else {img.cany = 0;img.canh = canH;lenIm = canH / img.sizek;img.cuth = lenIm;yu = img.focusY - lenIm / 2;yd = img.focusY + lenIm / 2;img.cuty = yu;if (yu < 0) {img.cuty = 0;}if (yd >= img.height) {img.cuty = yu - (yd - img.height + 1);}// }// 先把图片缩放成画布比例的大小,否则直接设置图片宽高图片展示不完整ctx.drawImage(img,0,0,img.cutw,img.cuth,img.canx,img.cany,img.canw,img.canh);showObjects(img);}//图像上的点对应的canvas坐标function imageXYtoCanXY(img, x, y) {x = (x - img.cutx) * img.sizek + img.canx;y = (y - img.cuty) * img.sizek + img.cany;return [x, y];}//在canvas上显示已标注目标function showObjects(img) {for (let i = 0; i < img.objects.length; i++) {target = img.objects[i];x = target.xMin;y = target.yMin;xm = target.xMax;ym = target.yMax;p = imageXYtoCanXY(img, x, y);x = p[0];y = p[1];p = imageXYtoCanXY(img, xm, ym);xm = p[0];ym = p[1];// 画填充drawFill(img, x, y, xm, ym, target.labelColor);}}// 画填充function drawFill(img, x1, y1, x2, y2, color) {ctx.fillStyle = utilsColorChange(color);ctx.beginPath();ctx.lineTo(x2, y1);ctx.lineTo(x2, y2);ctx.lineTo(x1, y2);ctx.lineTo(x1, y1);ctx.fill();ctx.closePath();}// 充值objfunction resetDataNewObj() {obj = {};color = selectValue?.[0]?.labelColor || selectValue?.labelColor;value = selectValue?.[0]?.value || selectValue?.value;label = selectValue?.[0]?.label || selectValue?.label;// 塞入数据到objobj.labelColor = color;obj.value = value;obj.label = label;obj.keyId = Math.random().toFixed(4);}// 背景画布function flush_canvas() {ctx.fillStyle = 'rgb(255, 255, 255)';ctx.fillRect(0, 0, canW, canH);}// 更改line颜色const onChangeLineColor = (e) => {setRadioValue(e.target.value);};// 确认框function confirmBox(resData) {if ('w' in obj && obj.w !== 0) {xMin = obj.x;yMin = obj.y;xMax = xMin + obj.w;yMax = yMin + obj.h;pMin = canXYtoImageXY(image, xMin, yMin);obj.xMin = pMin[0];obj.yMin = pMin[1];pMax = canXYtoImageXY(image, xMax, yMax);obj.xMax = pMax[0];obj.yMax = pMax[1];image.objects.push(obj);showOriginImg();return true;}if (resData?.length) {image.objects = resData;resetDataNewObj();showOriginImg();return true;}// 没有划线路线return false;}// 颜色转换const utilsColorChange = (color) => {return colorChange.hexToRgb(color || '#000000').rgba;};//保存标注结果const saveObj = () => {if (image?.objects?.length) {const objArr = [];for (let i = 0; i < image?.objects?.length; i++) {target = image.objects[i];objArr.push({label: target.label,labelColor: target.labelColor,value: target.value,keyId: target.keyId,xMin: parseInt(target.xMin),xMax: parseInt(target.xMax),yMin: parseInt(target.yMin),yMax: parseInt(target.yMax),width: parseInt(target.w),height: parseInt(target.h),});}selectValue = objArr;console.log('标注数组:', objArr);// Tjt: 传给后端的数据const imRes = { imgName: image.src, objArr };const blob = new Blob([JSON.stringify(imRes)], { type: '' });const imgName = image.src.split('.')[0];const jsonFile = imgName + '.json';saveJson(jsonFile, blob);return;}alert('未进行任何标注');};//保存json文件function saveJson(file, data) {//下载为json文件const Link = document.createElement('a');Link.download = file;Link.style.display = 'none';// 字符内容转变成blob地址Link.href = URL.createObjectURL(data);// 触发点击document.body.appendChild(Link);Link.click();// 然后移除document.body.removeChild(Link);}// 显隐标注useEffect(() => {init();// 获取已存的数据;setTimeout(() => {getData();}, 0);}, []);useEffect(() => {// eslint-disable-next-line react-hooks/exhaustive-depsselectValue = radioValueList.filter((i) => i.value === radioValue);resetDataNewObj();showImage(image);ctx.fillStyle = utilsColorChange(selectValue?.labelColor);ctx.fillRect(obj.x, obj.y, obj.w, obj.h);ctx.save();}, [radioValue]);return (<><header><div className="operation"><Buttontype="primary"icon={<MinusOutlined />}onClick={() => zoomOutPicture(image)}/><Buttontype="primary"icon={<PlusOutlined />}onClick={() => enlargedPicture(null, image)}/><ButtononClick={() => {if (resValue.length) {!confirmBox(resValue) &&alert('未选择目标区域!');return;}!confirmBox() && alert('未选择目标区域!');}}>确认</Button><Button onClick={() => saveObj()}>完成图片标注</Button><Buttontype="dashed"onClick={() => {resValue = [];image.objects = [];showOriginImg();}}>重新标注图片</Button><div className="labelSelect"><Radio.GrouponChange={onChangeLineColor}value={radioValue}>{radioValueList.map((i) => (<Radiokey={i.value}value={i.value}style={{ color: i.labelColor }}>{i.label}</Radio>))}</Radio.Group></div></div></header><div className="container"><div id="canvas"><canvas width="1920" height="1080" ref={canvas}></canvas></div><div className="operation-area"><div className="card-title">操作</div><ul className="radio-label">{objValueArr?.map((v, i) => (<liclassName={cnames('li-radio-content',v.isSelect && v.isShow? 'radio-select': null,v.isShow ? null : 'opacity')}key={v.keyId}onClick={() => {[...objValueArr].forEach((item) => (item.isSelect = false));objValueArr[i].isSelect = true;setObjValueArr([...objValueArr]);}}><divclassName="li-radio"style={{ background: v.labelColor }}>{v.label}</div><div className="li-operation"><iclassName={cnames('iconfont',v.isShow ? 'tjtyanjing' : 'tjtbiyan')}onClick={() => {objValueArr[i].isShow =!objValueArr[i].isShow;setObjValueArr([...objValueArr]);console.log([...objValueArr]);console.log('isShow===>', i);}}></i><iclassName="iconfont tjtlajitong1"onClick={() => {console.log(i);}}></i></div></li>))}</ul></div></div></>);
}
index.css
.operation {padding: 0px 30px;box-sizing: border-box;line-height: 50px;background-color: #ffffff;box-shadow: 2px 0 6px rgb(0 21 41 / 35%);
}.ant-btn {margin-right: 5px;
}.labelSelect {margin-bottom: 10px;line-height: 30px;
}.container {width: 100%;height: calc(100% - 90px);display: flex;flex-direction: row;justify-content: space-between;
}#canvas {width: auto;height: auto;box-shadow: 0 3px 1px -2px rgb(0 0 0 / 20%), 0 2px 2px 0 rgb(0 0 0 / 14%),0 1px 5px 0 rgb(0 0 0 / 12%);overflow: auto;
}.operation-area::-webkit-scrollbar ,#canvas::-webkit-scrollbar {/*滚动条整体样式*/width: 3px;/*高宽分别对应横竖滚动条的尺寸*/height: 3px;
}.operation-area::-webkit-scrollbar-thumb ,#canvas::-webkit-scrollbar-thumb {/*滚动条里面小方块*/border-radius: 5px;-webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);background: rgba(0, 0, 0, 0.2);
}.operation-area::-webkit-scrollbar-track ,#canvas::-webkit-scrollbar-track {/*滚动条里面轨道*/-webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);border-radius: 0;background: rgba(0, 0, 0, 0.1);
}.operation-area {position: relative;padding: 0 5px;min-width: 200px;height: auto;margin-left: 5px;overflow-y: auto;box-shadow: 2px 0 6px rgb(0 21 41 / 35%);
}.card-title {align-items: center;display: flex;flex-wrap: wrap;padding: 5px 8px;font-size: 1.25rem;font-weight: 800;letter-spacing: 0.0125em;line-height: 2rem;word-break: break-all;
}
.card-title::after {content: '';display: block;width: 100%;margin: 5px 0;height: 1px;border-bottom: 1px solid rgba(0, 0, 0, 0.2);
}.radio-label {list-style: none;margin: 0;padding: 0;box-sizing: border-box;
}.li-radio-content {padding: 10px;display: flex;flex-direction: row;justify-content: space-between;align-items: center;background-color:#ffffff;cursor: pointer;
}
.opacity{opacity: 0.3;
}.li-radio-content:hover{background-color:rgb(245,245,245);
}.li-radio{padding:4px 12px;font-size: 12px;background-color: rgb(0,156,224);color:#ffffff;letter-spacing: 3px;border-radius: 15px;
}.tjtyanjing,
.tjtbiyan {margin-right: 15px;
}
.tjtlajitong1 {color: rgb(100, 100, 100);
}.radio-select {background-color: #e0e0e0 !important;
}
FileSaver.js
/* eslint-disable no-restricted-globals */var _global = typeof window === 'object' && window.window === window? window : typeof self === 'object' && self.self === self? self : typeof global === 'object' && global.global === global? global: thisfunction bom (blob, opts) {if (typeof opts === 'undefined') opts = { autoBom: false }else if (typeof opts !== 'object') {console.warn('Deprecated: Expected third argument to be a object')opts = { autoBom: !opts }}if (opts.autoBom && /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) {return new Blob([String.fromCharCode(0xFEFF), blob], { type: blob.type })}return blob
}function download (url, name, opts) {var xhr = new XMLHttpRequest()xhr.open('GET', url)xhr.responseType = 'blob'xhr.onload = function () {saveAs(xhr.response, name, opts)}xhr.onerror = function () {console.error('could not download file')}xhr.send()
}function corsEnabled (url) {var xhr = new XMLHttpRequest()xhr.open('HEAD', url, false)try {xhr.send()} catch (e) {}return xhr.status >= 200 && xhr.status <= 299
}// `a.click()` doesn't work for all browsers (#465)
function click (node) {try {node.dispatchEvent(new MouseEvent('click'))} catch (e) {var evt = document.createEvent('MouseEvents')evt.initMouseEvent('click', true, true, window, 0, 0, 0, 80,20, false, false, false, false, 0, null)node.dispatchEvent(evt)}
}var saveAs = _global.saveAs || (// probably in some web worker(typeof window !== 'object' || window !== _global)? function saveAs () { /* noop */ }// Use download attribute first if possible (#193 Lumia mobile): 'download' in HTMLAnchorElement.prototype? function saveAs (blob, name, opts) {var URL = _global.URL || _global.webkitURLvar a = document.createElement('a')name = name || blob.name || 'download'a.download = namea.rel = 'noopener' // tabnabbing// TODO: detect chrome extensions & packaged apps// a.target = '_blank'if (typeof blob === 'string') {// Support regular linksa.href = blobif (a.origin !== location.origin) {corsEnabled(a.href)? download(blob, name, opts): click(a, a.target = '_blank')} else {click(a)}} else {// Support blobsa.href = URL.createObjectURL(blob)setTimeout(function () { URL.revokeObjectURL(a.href) }, 4E4) // 40ssetTimeout(function () { click(a) }, 0)}}// Use msSaveOrOpenBlob as a second approach: 'msSaveOrOpenBlob' in navigator? function saveAs (blob, name, opts) {name = name || blob.name || 'download'if (typeof blob === 'string') {if (corsEnabled(blob)) {download(blob, name, opts)} else {var a = document.createElement('a')a.href = bloba.target = '_blank'setTimeout(function () { click(a) })}} else {navigator.msSaveOrOpenBlob(bom(blob, opts), name)}}// Fallback to using FileReader and a popup: function saveAs (blob, name, opts, popup) {// Open a popup immediately do go around popup blocker// Mostly only available on user interaction and the fileReader is async so...popup = popup || open('', '_blank')if (popup) {popup.document.title =popup.document.body.innerText = 'downloading...'}if (typeof blob === 'string') return download(blob, name, opts)var force = blob.type === 'application/octet-stream'var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safarivar isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent)if ((isChromeIOS || (force && isSafari)) && typeof FileReader === 'object') {// Safari doesn't allow downloading of blob URLsvar reader = new FileReader()reader.onloadend = function () {var url = reader.resulturl = isChromeIOS ? url : url.replace(/^data:[^;]*;/, 'data:attachment/file;')if (popup) popup.location.href = urlelse location = urlpopup = null // reverse-tabnabbing #460}reader.readAsDataURL(blob)} else {var URL = _global.URL || _global.webkitURLvar url = URL.createObjectURL(blob)if (popup) popup.location = urlelse location.href = urlpopup = null // reverse-tabnabbing #460setTimeout(function () { URL.revokeObjectURL(url) }, 4E4) // 40s}}
)_global.saveAs = saveAs.saveAs = saveAsif (typeof module !== 'undefined') {module.exports = saveAs;
}
utils.js
export const colorChange = {rgbToHex: function (val) {//RGB(A)颜色转换为HEX十六进制的颜色值var r,g,b,a,regRgba = /rgba?\((\d{1,3}),(\d{1,3}),(\d{1,3})(,([.\d]+))?\)/, //判断rgb颜色值格式的正则表达式,如rgba(255,20,10,.54)rsa = val.replace(/\s+/g, '').match(regRgba);if (!!rsa) {r = parseInt(rsa[1]).toString(16);r = r.length === 1 ? '0' + r : r;g = (+rsa[2]).toString(16);g = g.length === 1 ? '0' + g : g;b = (+rsa[3]).toString(16);b = b.length === 1 ? '0' + b : b;a = +(rsa[5] ? rsa[5] : 1) * 100;return {hex: '#' + r + g + b,r: parseInt(r, 16),g: parseInt(g, 16),b: parseInt(b, 16),alpha: Math.ceil(a),};} else {return { hex: '无效', alpha: 100 };}},hexToRgb: function (val) {//HEX十六进制颜色值转换为RGB(A)颜色值// 16进制颜色值的正则var reg = /^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$/;// 把颜色值变成小写var color = val.toLowerCase();var result = '';if (reg.test(color)) {// 如果只有三位的值,需变成六位,如:#fff => #ffffffif (color.length === 4) {var colorNew = '#';for (var i = 1; i < 4; i += 1) {colorNew += color.slice(i, i + 1).concat(color.slice(i, i + 1));}color = colorNew;}// 处理六位的颜色值,转为RGBvar colorChange = [];for (let i = 1; i < 7; i += 2) {colorChange.push(parseInt('0x' + color.slice(i, i + 2)));}result = 'rgba(' + colorChange.join(',') + ',0.3' + ')';return {rgba: result,r: colorChange[0],g: colorChange[1],b: colorChange[2],};} else {result = '无效';return { rgb: result };}},
};
http://localhost:3000/pic-mark
具体可以参考代码👆
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!
