Canvas和Konva结合——实现手动标记图像特定位置目标、对图像进行旋转、缩放、拖拽、等基本浏览功能,记录标注坐标,命名后生成xml样本文件保存

CanvasKonva结合——实现手动标记图像特定位置目标功能,记录坐标,命名后保存

页面效果如下图:
手动标注图像目标并自定义标注名称

功能需求简介:

对各种图像数据进行样本采集(标注),生成样本文件。客户端操作需要就是上传图像数据后,可以进行手动标注图像目标,同时对标注目标进行命名,和当前标注目标的坐标对应保存生成xml文件作为样本。本文介绍的就是如上图所示,运用canvas和konva来实现此功能,首先你需要安装Konva,然后main.js引入

import Konva from 'konva'
Vue.use(Konva)

vue代码如下:

<div class="canvasbox" ref="canvasbox">
           <div class="el-image-viewer__canvas">
            <canvas id="previewCanvas" :style="{
                  transform:'scale(' + scale + ',' + scale + ') ' + 'translate3d(' +x / scale + 'px,' + y / scale + 'px,' + '0)' + 'rotateZ(' + rotate * 90 + 'deg)',
                }"
                :width="trueWidth"
                :height="trueHeight"></canvas>
                <div class="occlusion">
                <div class="img_box">
              <div
                id="markcontainer"
                ref="markcontainer"
                :style="{
                  transform: 'scale(' + scale + ',' + scale + ') ' + 'translate3d(' + x / scale + 'px,' + y / scale + 'px,' + '0)' + 'rotateZ(' + rotate * 90 + 'deg)',
                    cursor:crosshair
              }"
              ></div>
                </div>
                </div>
            </div>
           </div>
data() {
    return {
      mouseMovement:false,//鼠标在画布上是否移动标注画框
      // isDragDrop:false,//拖拽功能切换
      crosshair:'',//鼠标显示形状,决定是否可以拖拽
      previewCanvas:null,
      w: 0,// 容器宽
      h: 0,// 容器高
      scale: 1,// 图片缩放比例
      x: 0,// 图片偏移x轴
      y: 0,// 图片偏移y轴
      trueWidth: 0,// 图片真实宽度
      trueHeight: 0,// 图片真实高度
      move: true,
      moveX: 0,// 移动的x
      moveY: 0, // 移动的y
      support: "",// 支持的滚动事件
      touches: [],// 移动端手指缩放
      touchNow: false,
      rotate: 0,// 图片旋转
      isIos: false,
      orientation: 0,
      coe: 0.2,// 图片缩放系数
      scaling: false,// 是否正在多次缩放
      scalingSet: "",
      coeStatus: "",
      isCanShow: true,// 控制emit触发频率
      imgList:[],
      /**/
      width: 0,
      height: 0,
      deg: 0, //画布旋转
      ratio: 1, //图片缩放比
      stage: null,
      layer: null,
      graphNow: null, // 当前图形
      flag: "rectH",
      drawing: false, // 绘制中
      pointStart: [], // 初始坐标
      dialogInput: false,
      imgTotal: 0,
      selfCurrentRectInfo: [], //自己标注的坐标用来回显
      coordinateObj: {}, //回显自己标注的坐标对象
      restaurants: [],
      state: '',
    };
  },
  props: {
    img: {
      type: [String, Blob, null, File],
      default: ""
    },
    // 输出图片压缩比
    outputSize: {
      type: Number,
      default: 1
    },
    outputType: {
      type: String,
      default: "jpeg"
    },
    info: {
      type: Boolean,
      default: true
    },
    // 是否开启滚轮放大缩小
    canScale: {
      type: Boolean,
      default: true
    },
    // 是否开启固定宽高比
    fixed: {
      type: Boolean,
      default: false
    },
    // 宽高比 w/h
    fixedNumber: {
      type: Array,
      default: () => {
        return [1, 1];
      }
    },
    // 是否可以拖动图片
    canMove: {
      type: Boolean,
      default: true
    },
    // 上传图片按照原始比例显示
    original: {
      type: Boolean,
      default: false
    },
    // 是否根据dpr输出高清图片
    high: {
      type: Boolean,
      default: true
    },
    // 可以压缩图片宽高  默认不超过200
    maxImgSize: {
      type: [Number, String],
      default: 2000
    },
    // 倍数  可渲染当前截图框的n倍 0 - 1000;
    enlarge: {
      type: [Number, String],
      default: 1
    },
    // 自动预览的固定宽度
    preW: {
      type: [Number, String],
      default: 0
    },
    mode: {
      type: String,
      default: "contain"
    },
    limitMinSize: {
      type: [Number, Array, String],
      default: () => {
        return 10;
      }
    },
  },

实现功能包含对图像进行标注、旋转、缩放、拖拽、复位等基本操作浏览功能,根据自己需要参考吧(以下代码只是记录实现思路和方法,如有错误的地方可自行补充)

beforeDestroy() {
//清除监听事件
    window.removeEventListener("keydown", this.handleKey);
  },
methods: {
//canvas实现将图像绘制到画布上
	markInitCanvas: function (imgData, canvas, hasClear=true) {
	    let
	      ctx = canvas.getContext('2d'),
	      canvasW = this.trueWidth,
	      canvasH = this.trueHeight;
	    if (hasClear) {
	      ctx.clearRect(0, 0, canvasW, canvasH);
	    }
	    let img = new Image();
	    let self = this;
	    img.src = imgData.fileUrl;
	    img.onload = function () {
	      ctx.drawImage(img, 0, 0, self.trueWidth, self.trueHeight);
	    };
	  }, 
	 // 快捷键绑定以及鼠标状态切换同步实现是否可以拖拽图像
	  handleKey: function (event) {
        let evt = window.event || event;
        let keyCode = evt.keyCode;
        if (keyCode == 87) {
          this.stage.show()
          this.crosshair === '' ? this.crosshair = 'crosshair' : 					this.crosshair = ''
          this.currentOperation = 'mark'
        }
        if ( !this.dialogInput && !this.defaultFocus) {
          if (keyCode == 68) {
            this.next()
            this.stage.show()
          }
          if (keyCode == 65) {
            this.prev()
            this.stage.show()
          }
        }
        //ctrl + s 
        if (keyCode == 83 && (navigator.platform.match("Mac") ? evt.metaKey : evt.ctrlKey)) {
            evt.preventDefault();
            this.Operationsave()
        }
      },
      // 缩放图片
    scaleImg: function () {
      if (this.canScale) {
        window.addEventListener(this.support, this.changeSize, this.passive);
      }
    },
    // 改变大小函数
    changeSize: function (e) {
      e.preventDefault();
      let scale = this.scale;
      var change = e.deltaY || e.wheelDelta;
      // 根据图片本身大小 决定每次改变大小的系数, 图片越大系数越小
      var isFirefox = navigator.userAgent.indexOf("Firefox");
      change = isFirefox > 0 ? change * 30 : change;
      // 修复ie的滚动缩放
      if (this.isIE) {
        change = -change;
      }
      // 1px - 0.2
      var coe = this.coe;
      coe =
        coe / this.trueWidth > coe / this.trueHeight
          ? coe / this.trueHeight
          : coe / this.trueWidth;
      var num = coe * change;
      num < 0
        ? (scale += Math.abs(num))
        : scale > Math.abs(num)
        ? (scale -= Math.abs(num))
        : scale;
      // 延迟0.1s 每次放大大或者缩小的范围
      let status = num < 0 ? "add" : "reduce";
      if (status !== this.coeStatus) {
        this.coeStatus = status;
        this.coe = 0.2;
      }
      if (!this.scaling) {
        this.scalingSet = setTimeout(() => {
          this.scaling = false;
          this.coe = this.coe += 0.01;
        }, 50);
      }
      this.scaling = true;
      this.scale = scale;
    },
    // 修改图片大小函数
    changeScale: function (num) {
      let scale = this.scale;
      num = num || 1;
      var coe = 20;
      coe =
        coe / this.trueWidth > coe / this.trueHeight
          ? coe / this.trueHeight
          : coe / this.trueWidth;
      num = num * coe;
      num > 0
        ? (scale += Math.abs(num))
        : scale > Math.abs(num)
        ? (scale -= Math.abs(num))
        : scale;
      this.scale = scale;
    },

    // 自动预览函数
    showPreview: function () {
      // 优化不要多次触发
      if (this.isCanShow) {
        this.isCanShow = false;
        setTimeout(() => {
          this.isCanShow = true;
        }, 16);
      } else {
        return false;
      }
    },
    mark: function (currentOperation) {
      this.currentOperation = currentOperation
      this.stage.show();
      this.crosshair = 'crosshair'
    },
    // 监听鼠标滚轮事件
    checkDivScroolTop: function () {
      //绑定事件
      let that = this
      this.$refs.canvasbox.addEventListener("mousewheel", function() {
        if ( event.deltaY > 0 ) {
           that.changeScale(1)
        }
        if (event.deltaY < 0) {
          that.changeScale(-1)
        }
      });
    },
    //图像左旋转
    rotateLeft: function () {
      this.rotate = this.rotate <= -3 ? 0 : this.rotate - 1;
      if (this.deg <= 0) {
        this.deg = 360;
      }
      this.deg -= 90;
    },
    //图像右旋转
    rotateRight: function () {
      this.rotate = this.rotate >= 3 ? 0 : this.rotate + 1;
      this.deg += 90;
      if (this.deg >= 360) {
        this.deg = 0;
      }
    },
    //图像复位
    reset: function () {
      this.scale = 1
      this.rotate = 0
      this.x = 0
      this.y = 0
    },
    // 获取上传图片的宽高方法
    loadImageAsync: function (url) {
      return new Promise(function(resolve, reject) {
        const image = new Image();
        image.onload = function() {
          resolve(image);
        };
        image.onerror = function() {
          reject(new Error("Could not load image at " + url));
        };
        image.src = url;
      });
    },

}
konva实现在图像上画框等操作
//canvas画布
    initCanvas: function(width, height, hasHide) {
      this.stage = new Konva.Stage({
        container: "markcontainer",
        width: width,
        height: height
        // draggable: true,
      });
      this.layer = new Konva.Layer({
        zIndex: 20
      });
      let self = this;
      this.stage.add(self.layer);
      this.stage.on("mousedown", self.mouseDown);
      this.stage.on("mousemove", self.mouseMove);
      this.stage.on("mouseup", self.mouseUp);
      if (hasHide) this.stage.hide();
    },
    mouseDown: function(e) {
      if( this.crosshair != 'crosshair') {
        this.dialogInput = false
        this.move = true
        this.startMove()
        return
      }
      if( !this.mouseMovement && this.state === "" && this.dialogInput === true) {
        return
      }
      if (e.target === this.stage) {
        let x = e.evt.offsetX,
            y = e.evt.offsetY;
        this.pointStart = [x, y];
        this.drawRect(x, y, 0, 0);
        this.drawing = true;
        // 移除图形选择框
        this.stage.find("Transformer").destroy();
        this.layer.draw();
        return;
      }
      // 如果没有匹配到就终止往下执行
      if (
        !e.target.hasName("line") &&
        !e.target.hasName("ellipse") &&
        !e.target.hasName("rect") &&
        !e.target.hasName("circle")
      ) {
        return;
      }
      // 移除图形选择框
      this.stage.find("Transformer").destroy();
      // 当前点击的对象赋值给graphNow
      this.graphNow = e.target;
      // 创建图形选框事件
      const tr = new Konva.Transformer({
        borderStroke: "#000", // 虚线颜色
        borderStrokeWidth: 1, //虚线大小
        borderDash: [5], // 虚线间距
        keepRatio: false // 不等比缩放
      });
      this.layer.add(tr);
      tr.attachTo(e.target);
      this.layer.draw();
    },
    mouseMove: function(e) {
      if( this.defaultNameChecked) this.state = this.defaultNameInput;
      if( !this.state && this.defaultNameChecked ) return;

      if (this.graphNow && this.flag && this.drawing) {
        this.graphNow.setAttrs({
          width: e.evt.offsetX - this.pointStart[0],
          height: e.evt.offsetY - this.pointStart[1]
        });
        this.layer.draw();
        this.mouseMovement = true
      }
    },
    mouseUp: function(e) {
      if( this.crosshair != 'crosshair') {
        this.dialogInput = false
        return
      }
      this.drawing = false;
      // 弹窗写文字
      if( this.defaultNameChecked ) {
        this.dialogInput = false;
        this.state = this.defaultNameInput
        this.submitText()
        return
      }
      if( !this.mouseMovement ) {
        this.dialogInput = true;
        this.$refs.selectfocus.focus()
        return
      }
      this.mouseMovement = false
      if ( !this.defaultNameChecked ) {
        this.dialogInput = true;
        // console.log(e.evt.offsetX,e.evt.offsetY)
        this.tranLeft = (e.evt.offsetX +  this.$refs.markcontainer.offsetLeft + this.x / this.scale ) + 'px'
        this.tranTop = (e.evt.offsetY + this.$refs.markcontainer.offsetTop + this.y / this.scale ) + 'px'
        this.focusTimer = setTimeout( () => {
            this.state = ''
            this.$refs.selectfocus.focus()
        }, 50);
      }
    },
    querySearch: function (queryString, cb) {
        // var restaurants = this.restaurants;
        // var results = queryString ? restaurants.filter(this.createFilter(queryString)) : restaurants;
        // 调用 callback 返回建议列表的数据
        cb(this.restaurants);
      },
      createFilter: function(queryString) {
        return (restaurant) => {
          return (restaurant.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0);
        };
      },
      handleSelect: function (item) {
        this.dialogInput = false
        this.state = item.value
        this.submitText()
      },
      parentboxBlur: function () {
        if( !this.status && !this.mouseMovement ) {
          this.dialogInput = true
          this.focusTimer = setTimeout( () => {
              this.$refs.selectfocus.focus()
           }, 50);
          return
        }
        this.mouseMovement = false
      },
    submitText: function() {
      if (!this.state) {
        this.$confirm('请输入标注名称', {
          closeOnClickModal: false,
          type: 'warning',
        })
        return
      }
      this.state = this.state.replace(/[^a-zA-Z]/g,'')
      if (!this.typeTextList.includes(this.state)) {
        this.typeTextList.push(this.state);
        this.restaurants.push({value:this.state})
      }
      this.dialogInput = false;
      let { attrs } = this.graphNow;
      let rectInfo = {
        labelName: this.state,
        upperLeftCoorX: attrs.x,
        upperLeftCoorY: attrs.y,
        lowerRightCoorX: attrs.x + attrs.width,
        lowerRightCoorY: attrs.y + attrs.height,
        imageId:this.curData[this.curIndex].imageId,
        ratio:this.ratio
      };
      this.currentRectInfo.push(rectInfo);
    },
    cancelSubmission: function() {
      this.nameCheck = false
      this.dialogInput = false;
    },
    drawRect: function(x, y, w, h) {
      const rect = new Konva.Rect({
        name: "rect",
        x: x,
        y: y,
        width: w,
        height: h,
        fill: "rgba(255,0,0,0.3)",
        stroke: "red",
        strokeWidth: 2,
        opacity: 1,
        scale: 1,
        draggable: true
        // fillcolor:'rgba(0,255,0,0.5)'
      });
      this.graphNow = rect;
      this.layer.add(rect);
      this.layer.draw();
      const self = this;
      //双击标注框删除自己
      rect.addEventListener("dblclick", () => {
        if ( self.crosshair === 'crosshair' ) {
          self.dialogInput = false
          self.currentRectInfo.splice(self.graphNow.index,1)
          rect.remove();
          self.stage.find("Transformer").destroy();
          self.layer.draw();
        }
      });
    },
   draw: function(url) {
      var img = new Image();
      let self = this;
      img.onload = function() {
        self.ctx.drawImage(img, 0, 0);
      };
      img.src = url;
    },

实现图像拖拽

   /**图像拖拽功能 */
    // 当按下鼠标键
    startMove:function(event) {
      let e = window.event || event;
      e.preventDefault();
      // 如果move 为true 表示当前可以拖动
      if (this.move) {
        if (!this.canMove) {
          return false;
        }
        // 开始移动
        this.moveX = (e.clientX ? e.clientX : e.touches[0].clientX) - this.x;
        this.moveY = (e.clientY ? e.clientY : e.touches[0].clientY) - this.y;
        if (e.touches) {
          window.addEventListener("touchmove", this.moveImg);
          window.addEventListener("touchend", this.leaveImg);
        } else {
          window.addEventListener("mousemove", this.moveImg);
          window.addEventListener("mouseup", this.leaveImg);
        }
      }
    },
    cancelTouchScale:function(e) {
      window.removeEventListener("touchmove", this.touchScale);
    },
    // 移动图片
    moveImg:function(e) {
      e.preventDefault();
      if (e.touches && e.touches.length === 2) {
        this.touches = e.touches;
        window.addEventListener("touchmove", this.touchScale);
        window.addEventListener("touchend", this.cancelTouchScale);
        window.removeEventListener("touchmove", this.moveImg);
        return false;
      }
      let nowX = e.clientX ? e.clientX : e.touches[0].clientX;
      let nowY = e.clientY ? e.clientY : e.touches[0].clientY;
      let changeX, changeY;
      changeX = nowX - this.moveX;
      changeY = nowY - this.moveY;
      this.$nextTick(() => {
        this.x = changeX;
        this.y = changeY;
      });
    },
    // 移动图片结束
    leaveImg:function(e) {
      window.removeEventListener("mousemove", this.moveImg);
      window.removeEventListener("touchmove", this.moveImg);
      window.removeEventListener("mouseup", this.leaveImg);
      window.removeEventListener("touchend", this.leaveImg);
    },
    touchmove:function() {
      if (this.flags) {
        let touch;
        if (event.touches) {
          touch = event.touches[0];
        } else {
          touch = event;
        }
        this.nx = touch.clientX - this.positions.x;
        this.ny = touch.clientY - this.positions.y;
        this.xPum = this.dx + this.nx;
        this.yPum = this.dy + this.ny;
        //添加限制:只允许在屏幕内拖动
        const maxWidth = document.body.clientWidth - 54; //屏幕宽度减去悬浮框宽高
        const maxHeight = document.body.clientHeight - 54;
        if (this.xPum < 0) {
          //屏幕x限制
          this.xPum = 0;
        } else if (this.xPum > maxWidth) {
          this.xPum = maxWidth;
        }
        if (this.yPum < 0) {
          //屏幕y限制
          this.yPum = 0;
        } else if (this.yPum > maxHeight) {
          this.yPum = maxHeight;
        }
        this.$refs.cropper.style.left = this.xPum + "px";
        this.$refs.cropper.style.top = this.yPum + "px";
        //阻止页面的滑动默认事件
        document.addEventListener(
          "touchmove",
          function() {
            // 1.2 如果碰到滑动问题,请注意是否获取到 touchmove
            event.preventDefault(); //jq 阻止冒泡事件
            // event.stopPropagation(); // 如果没有引入jq 就用 stopPropagation()
          },
          false
        );
      }
    },
代码至此完成

自己写的图片适应画布大小进行缩放的方法、有时间再来改进优化:

方法一
 //判断当前显示图片宽高,自适应画布
 changeImgSize: function (imgWidth,imgHeight) {
 //固定盒子宽高
   let fixedwidtn = 1300;
   let fixedheight = 680;
   if (imgWidth <= fixedwidtn && imgHeight <= fixedheight) {
     // 当图片比图片框小时不做任何改变
     this.trueWidth = imgWidth
     this.trueHeight = imgHeight
     this.ratio = 1
     this.$refs.markcontainer.style.width = imgWidth+'px'
     this.$refs.markcontainer.style.height = imgHeight+'px'
     this.initCanvas(imgWidth, imgHeight, true);
   } else if (imgWidth > fixedwidtn && imgHeight > fixedheight) {
     // 当图片宽高都比图片框小大时
     if (fixedwidtn / imgWidth >= fixedheight / imgHeight) {
     //相比之下高大的更多,按照高来缩放
       this.initCanvas((680 / imgHeight) * imgWidth, 680, true);
       this.ratio = imgHeight / 680
       this.trueWidth = (680 / imgHeight) * imgWidth
       this.trueHeight = 680
       this.$refs.markcontainer.style.width = (680 / imgHeight) * imgWidth + 'px'
       this.$refs.markcontainer.style.height = 680 + 'px'
     } else {
       //根据宽来缩放
       this.ratio = imgWidth / 1300
       this.trueWidth = 1300
       this.trueHeight = (1300 / imgWidth) * imgHeight
       this.$refs.markcontainer.style.width = 1300 + 'px'
       this.$refs.markcontainer.style.height = (1300 / imgWidth) * imgHeight + 'px'
       this.initCanvas(1300, (1300 / imgWidth) * imgHeight, true);
     }
   } else if (imgWidth > fixedwidtn && imgHeight < fixedheight) {
     //根据宽来缩放
     this.ratio = imgWidth / 1300
     this.trueWidth = 1300
     this.trueHeight = (1300 / imgWidth) * imgHeight
     this.$refs.markcontainer.style.width = 1300 + 'px'
     this.$refs.markcontainer.style.height = (1300 / imgWidth) * imgHeight + 'px'
     this.initCanvas(1300, (1300 / imgWidth) * imgHeight, true);
   } else if (imgWidth < fixedwidtn && imgHeight > fixedheight) {
     //根据高来缩放
     if (imgWidth > imgHeight) {
     }
     if (imgWidth <= imgHeight) {
     }
     this.ratio = imgHeight / 680
     this.trueWidth = (680 / imgHeight) * imgWidth
     this.trueHeight = 680
     this.$refs.markcontainer.style.width = (680 / imgHeight) * imgWidth + 'px'
     this.$refs.markcontainer.style.height = 680 + 'px'
     this.initCanvas((680 / imgHeight) * imgWidth, 680, true);
   }
 },
方法二
    //判断当前显示图片宽高,自适应画布
    changeImgSize: function (imgWidth,imgHeight) {
   if (actImgW > canvasW || actImgH > canvasH) {
      if (actImgW / actImgH >= canvasW / canvasH) {
        imgW = canvasW;
        rate = actImgW / canvasW;
        imgH = actImgH / rate;
        top = (canvasH - imgH) / 2;
      } else {
        imgH = canvasH;
        rate = actImgH / canvasH;
        imgW = actImgW / rate;
        left = (canvasW - imgW) / 2;
      }
    } else {
      left = (canvasW - imgW) / 2;
      top = (canvasH - imgH) / 2;
    }
    },

总结:

1、实现思路及原理:
1)思路:操作区域由两部分组成(两层组合来实现),一是用canvas绘制的图像,二是konva生成画布进行手动画框操作。首先利用canvas将要进行标注的图像绘制到画布上,在页面中显示,这一过程中可添加对图像的上传格式、大小、等进行校验限制。在绘制的时候,需对图像进行缩放处理。页面将图像绘制完成后,通过点击操作按钮或者快捷键来切换鼠标样式stage.show来进行手动画框,标注。
2)原理:canvas绘制的图像,和konva生成的画布重叠在一起,绑定相同的旋转、缩放参数,同步赋值来实现客户端图像缩放,旋转的效果。坐标回显则是图像加载显示完成后,konva将获取到的该图像已有标注坐标绘制到画布上。
2、canvas默认宽高是300、150,为避免异常,最好使用显示属性添加而不通过css来添加宽高,canvas动态设置width和weight时需直接定义在标签,写在:style中动态赋值会不起作用
3、图像标注回显的时候,由于浏览器加载渲染的原因,将图像绘制到canvas画布中会慢于回显的坐标,造成坐标回显失败,添加定时器可解决。
4、在实现标注框可删除功能,给画框绑定监听事件时,需要注意要清除监听事件,不然会有你意想不到的bug。

<think>好的,我现在需要帮用户解决如何用Python创建一个GUI程序,实现图片浏览、垛位图显示、标注边界计算的功能。首先,用户的问题涉及多个库的选择具体实现方法,我得一步步理清楚思路。 首先,确定用户的需求。他们需要一个人机交互界面,具备以下功能图片浏览(可能是打开图片并显示)、全场垛位图显示(可能是一个仓库或场地的平面图,显示各个垛位的位置)、垛位标注(用户可以在图上标记垛位的位置或信息)以及垛位边界计算(根据标注的数据计算边界,比如面积或坐标范围)。这些功能需要集成在一个GUI程序中,所以选择合适的库是关键。 接下来,考虑Python中常用的GUI库。用户提到了Tkinter、PyQtPillow。Tkinter是Python自带的,适合简单的界面,但功能可能有限;PyQt更强大,适合复杂的界面,但学习曲线较陡。Pillow主要用于图像处理,通常需要结合GUI库使用。由于用户需要图像显示交互,可能需要结合使用这些库。比如,用PyQt或Tkinter作为GUI框架,Pillow处理图像,可能还需要Matplotlib或OpenCV来绘制图形或处理图像分析。 然后是具体实现步骤。首先,创建主窗口,设计界面布局,可能需要分割成图片显示区域控制按钮区域。接着,图片浏览功能需要文件对话框选择图片,并用Pillow加载显示。PyQt的QLabel或Tkinter的Canvas可以显示图像。然后,垛位图的显示可能需要在主图像上叠加标注,比如用Canvas绘制矩形或点。标注功能需要处理鼠标事件,比如点击或拖动来标记位置,并存储坐标数据。边界计算可能需要根据标注的点计算几何形状,比如多边形的最小外接矩形或凸包,这可能需要数学库如NumPy或Shapely。 还需要考虑数据存储,比如保存标注坐标边界计算结果。可能使用JSON或CSV文件。此外,用户交互方面,比如右键删除标注拖拽调整位置,这些需要事件绑定处理。 在库的选择上,PyQt5可能更适合,因为它的QGraphicsViewQGraphicsScene可以高效处理图形项交互,适合复杂的图像标注。Tkinter虽然简单,但处理大量图形项可能性能不足。Matplotlib可以用于绘图,但交互性可能不如PyQt。OpenCV如果需要进行图像处理,比如边缘检测,但显示可能还是需要结合GUI库。 现在,具体实现步骤可能包括: 1. 使用PyQt5创建主窗口,布局包含图像显示区域、控制按钮状态栏。 2. 使用QFileDialog打开图片,用QPixmap显示在QLabel或QGraphicsView中。 3. 在QGraphicsView中绘制垛位图的标注,通过鼠标事件记录坐标。 4. 实现标注的增删改功能,比如点击添加,右键菜单删除。 5. 边界计算部分,收集所有标注点的坐标使用凸包算法或最小矩形计算,然后绘制结果。 6. 数据保存,将标注计算结果导出为文件。 可能的挑战包括图像坐标与界面坐标的转换,确保标注位置准确;性能问题,当图像很大或标注点很多时,需要优化绘制方式;以及用户交互的流畅性,比如拖拽标注时的实时反馈。 还需要考虑错误处理,比如图片加载失败,标注点不足无法计算边界等情况。另外,界面美观用户体验也是需要考虑的因素,比如提供缩放、平移图像功能,方便用户查看标注。 最后,测试各个功能是否正常,比如打开图片、添加标注、计算边界是否正确,以及数据保存是否完整。可能需要编写示例代码,并逐步调试,确保每个部分协同工作。</think>针对您提出的需求,下面将分步骤说明如何使用Python创建GUI程序实现相关功能: --- ### **一、技术选型建议** 1. **PyQt5**:推荐作为GUI开发框架,提供丰富的控件图形绘制能力,支持复杂交互场景[^1] 2. **OpenCV/Pillow**:用于图像读取、处理坐标转换 3. **Matplotlib**:可选用于绘制垛位图或数据可视化 4. **Shapely**:用于几何计算(边界、面积等) --- ### **二、实现步骤** #### **1. 界面框架搭建** ```python from PyQt5.QtWidgets import QApplication, QMainWindow, QFileDialog, QLabel, QGraphicsView, QGraphicsScene from PyQt5.QtGui import QPixmap, QImage import cv2 class MainWindow(QMainWindow): def __init__(self): super().__init__() # 创建图形视图场景 self.scene = QGraphicsScene() self.view = QGraphicsView(self.scene) self.setCentralWidget(self.view) # 创建工具栏 self.create_toolbar() ``` #### **2. 图片浏览功能** ```python def load_image(self): file_path, _ = QFileDialog.getOpenFileName(filter="Image Files (*.png *.jpg *.bmp)") if file_path: # 使用OpenCV读取图像 self.cv_img = cv2.imread(file_path) # 转换为Qt可显示的格式 height, width, channel = self.cv_img.shape bytes_per_line = 3 * width q_img = QImage(self.cv_img.data, width, height, bytes_per_line, QImage.Format_RGB888).rgbSwapped() self.scene.addPixmap(QPixmap.fromImage(q_img)) ``` #### **3. 垛位标注功能** ```python # 鼠标点击事件处理 def mousePressEvent(self, event): if event.button() == Qt.LeftButton: # 获取场景坐标 scene_pos = self.view.mapToScene(event.pos()) # 创建标注点 self.draw_marker(scene_pos.x(), scene_pos.y()) def draw_marker(self, x, y): marker = self.scene.addEllipse(x-5, y-5, 10, 10, QPen(Qt.red), QBrush(Qt.darkRed)) marker.setData(0, "MARKER") # 存储元数据 ``` #### **4. 边界计算实现** ```python from shapely.geometry import MultiPoint, Polygon def calculate_boundary(self): # 获取所有标注坐标 points = [item.scenePos() for item in self.scene.items() if item.data(0) == "MARKER"] # 使用凸包算法计算边界 multipoint = MultiPoint([(p.x(), p.y()) for p in points]) convex_hull = multipoint.convex_hull # 绘制边界 if isinstance(convex_hull, Polygon): hull_coords = list(convex_hull.exterior.coords) self.draw_polygon(hull_coords) ``` --- ### **三、关键优化点** 1. **坐标转换**:建立图像像素坐标与实际垛位坐标的映射关系 2. **数据持久化**:建议使用SQLite存储标注数据 3. **性能优化**: - 对大型图像使用金字塔分层加载 - 对标注数据采用空间索引(如R-Tree) --- ### **四、推荐库版本** ```text PyQt5 5.15.9 opencv-python 4.7.0 shapely 2.0.1 numpy 1.24.3 ``` ---
评论 18
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值