ソースを参照

feat: 绘制图片组件

leo 6 日 前
コミット
2c472c1408
1 ファイル変更638 行追加0 行削除
  1. 638 0
      components/RecognizationImage/index.vue

+ 638 - 0
components/RecognizationImage/index.vue

@@ -0,0 +1,638 @@
+<template>
+  <view class="recognization-image">
+    <!-- 画布绘制阶段 -->
+    <view v-if="!resultImage" class="canvas-phase">
+      <canvas
+        canvas-id="pestCanvas"
+        id="pestCanvas"
+        :style="{ width: canvasW + 'px', height: canvasH + 'px' }"
+      ></canvas>
+    </view>
+
+    <!-- 加载中 -->
+    <view v-if="loading" class="loading-mask">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">标注图片生成中...</text>
+    </view>
+
+    <!-- 图片查看器:支持拖拽和缩放 -->
+    <movable-area
+      v-if="resultImage"
+      class="image-viewer"
+      :style="{ width: containerW + 'px', height: containerH + 'px' }"
+    >
+      <movable-view
+        direction="all"
+        :scale="true"
+        :scale-min="0.5"
+        :scale-max="5"
+        :scale-value="currentScale"
+        :inertia="true"
+        :damping="40"
+        :x="initX"
+        :y="initY"
+        :style="{ width: imgDisplayW + 'px', height: imgDisplayH + 'px' }"
+        class="movable-img"
+        @scale="onScale"
+      >
+        <image :src="resultImage" mode="scaleToFill" class="annotated-image" />
+      </movable-view>
+    </movable-area>
+
+    <!-- 缩放控制按钮 -->
+    <!-- <view v-if="resultImage" class="zoom-controls">
+      <view class="zoom-btn" @click="zoomIn">
+        <text class="zoom-icon">+</text>
+      </view>
+      <view class="zoom-btn" @click="zoomOut">
+        <text class="zoom-icon">-</text>
+      </view>
+    </view> -->
+  </view>
+</template>
+
+<script>
+/**
+ * 害虫识别图片组件
+ * 功能:在原始图片上绘制害虫标注框和标签,生成标注图片,支持拖拽和缩放查看
+ *
+ * Props:
+ *   imageSrc  - 原始图片URL
+ *   pestData  - 害虫数据数组 [{startX, startY, width, height, text}]
+ *
+ * 使用示例:
+<RecognizationImage
+        :imageSrc="currentImg.addr"
+        :pestData="[
+          {
+            startX: 790.89,
+            startY: 211.76,
+            width: 169.65,
+            height: 189.47,
+            text: '二化螟',
+          },
+          {
+            startX: 455.3,
+            startY: 313.3,
+            width: 102.78,
+            height: 90.4,
+            text: '二化螟',
+          },
+          {
+            startX: 676.85,
+            startY: 472.55,
+            width: 135.01,
+            height: 101.26,
+            text: '草地贪夜蛾',
+          },
+          {
+            startX: 321.37,
+            startY: 819.22,
+            width: -29.88,
+            height: -29.08,
+            text: '草地贪夜蛾2号',
+          },
+          {
+            startX: 879.37,
+            startY: 79.9,
+            width: -49.03,
+            height: -45.74,
+            text: '666',
+          },
+          {
+            startX: 845.62,
+            startY: 643.09,
+            width: 14.21,
+            height: 15.99,
+            text: '6663',
+          },
+          {
+            startX: 370.17,
+            startY: 45.58,
+            width: 32.24,
+            height: 30.45,
+            text: '666',
+          },
+        ]"
+        style="width: 100%; height: 500rpx"
+      />
+ */
+
+// 害虫类型对应的颜色调色板
+const COLOR_PALETTE = [
+  '#FF0000',
+  '#00FFC2',
+  '#FF00A8',
+  '#120080',
+  '#BDFF00',
+  '#FFB800',
+  '#5E8000',
+  '#FF5C00',
+  '#00FF75',
+  '#00F0FF',
+  '#0094FF',
+  '#EB00FF',
+  '#00C2FF',
+  '#C74C4C',
+  '#FFFFFF',
+  '#FF6B6B',
+];
+
+export default {
+  name: 'RecognizationImage',
+  props: {
+    // 原始图片地址
+    imageSrc: {
+      type: String,
+      default: '',
+    },
+    // 害虫标注数据
+    pestData: {
+      type: Array,
+      default: () => [],
+    },
+  },
+  data() {
+    return {
+      ctx: null,
+      loading: false,
+      // 画布尺寸
+      canvasW: 300,
+      canvasH: 300,
+      // 原始图片尺寸
+      originalW: 0,
+      originalH: 0,
+      // 缩放系数
+      imageScale: 1, // 图片适配画布的缩放比
+      baseScale: 0.25, // 基础缩放参考值
+      markScale: 1, // 标注坐标缩放比
+      // 生成的标注图片路径
+      resultImage: '',
+      // 图片显示尺寸(适配容器)
+      imgDisplayW: 300,
+      imgDisplayH: 300,
+      // movable-view 初始位置(居中)
+      initX: 0,
+      initY: 0,
+      // 容器尺寸
+      containerW: 300,
+      containerH: 300,
+      // 害虫类型→颜色映射
+      colorMap: {},
+      // 当前缩放值
+      currentScale: 1,
+    };
+  },
+  watch: {
+    imageSrc(newVal) {
+      if (newVal) {
+        this.render();
+      }
+    },
+    pestData: {
+      handler() {
+        if (this.imageSrc) {
+          this.render();
+        }
+      },
+      deep: true,
+    },
+  },
+  mounted() {
+    this.getContainerSize().then(() => {
+      if (this.imageSrc) {
+        this.render();
+      }
+    });
+  },
+  methods: {
+    /**
+     * 获取容器尺寸
+     */
+    getContainerSize() {
+      return new Promise((resolve) => {
+        uni
+          .createSelectorQuery()
+          .in(this)
+          .select('.recognization-image')
+          .boundingClientRect((rect) => {
+            if (rect && rect.width > 0) {
+              this.containerW = rect.width;
+              this.containerH = rect.height;
+            }
+            resolve();
+          })
+          .exec();
+      });
+    },
+
+    /**
+     * 主渲染流程:加载图片 → 绘制标注 → 导出图片
+     */
+    async render() {
+      if (!this.imageSrc) return;
+
+      this.loading = true;
+      this.resultImage = '';
+      this.colorMap = {};
+      this.currentScale = 1;
+
+      try {
+        // 1. 加载图片信息
+        const imgInfo = await this.loadImageInfo(this.imageSrc);
+        console.log('[RecognizationImage] 图片信息:', imgInfo);
+
+        if (imgInfo.width > 0 && imgInfo.height > 0) {
+          this.originalW = imgInfo.width;
+          this.originalH = imgInfo.height;
+        } else {
+          // 回退场景:无法获取尺寸,使用默认比例 4:3
+          console.warn(
+            '[RecognizationImage] 无法获取图片尺寸,使用默认 4:3 比例',
+          );
+          this.originalW = 4000;
+          this.originalH = 3000;
+        }
+
+        // 2. 计算缩放参数和画布尺寸
+        this.calcSizes();
+
+        // 3. 等待 canvas 渲染到 DOM
+        await this.$nextTick();
+        await this.delay(100);
+
+        // 4. 初始化画布上下文
+        this.ctx = uni.createCanvasContext('pestCanvas', this);
+
+        // 5. 绘制背景图片(使用 imageScale 缩放到画布尺寸)
+        const drawSrc = imgInfo.path || this.imageSrc;
+        const displayWidth = this.originalW * this.imageScale;
+        const displayHeight = this.originalH * this.imageScale;
+        console.log('[RecognizationImage] 绘制参数:', {
+          canvas: this.canvasW + 'x' + this.canvasH,
+          imageScale: this.imageScale,
+          baseScale: this.baseScale,
+          markScale: this.markScale,
+          displaySize: displayWidth + 'x' + displayHeight,
+        });
+        this.ctx.drawImage(drawSrc, 0, 0, displayWidth, displayHeight);
+
+        // 6. 绘制害虫标注(使用 markScale 缩放坐标)
+        this.drawAnnotations();
+
+        // 8. 执行绘制并导出
+        this.ctx.draw(false, () => {
+          setTimeout(() => {
+            this.exportCanvas();
+          }, 300);
+        });
+      } catch (err) {
+        console.error('[RecognizationImage] 渲染失败:', err);
+        this.loading = false;
+      }
+    },
+
+    /**
+     * 计算缩放参数和画布尺寸(与 Draw 组件一致的缩放逻辑)
+     */
+    calcSizes() {
+      const cw = this.containerW || 300;
+      const ch = this.containerH || 300;
+      const canvasAspect = cw / ch;
+      const imageAspect = this.originalW / this.originalH;
+
+      // 画布尺寸 = 容器尺寸
+      this.canvasW = cw;
+      this.canvasH = ch;
+
+      // 计算图片等比缩放系数(适配画布)
+      if (imageAspect > canvasAspect) {
+        this.imageScale = cw / this.originalW;
+      } else {
+        this.imageScale = ch / this.originalH;
+      }
+
+      // 根据图片宽度定义基础缩放比例
+      if (this.originalW >= 5000) {
+        this.baseScale = 0.25;
+      } else if (this.originalW >= 4000) {
+        this.baseScale = 0.31;
+      } else {
+        this.baseScale = 0.4;
+      }
+
+      // 标注坐标缩放比 = imageScale / baseScale
+      this.markScale = 1 - (this.baseScale - this.imageScale) / this.baseScale;
+
+      // 图片在画布上的实际显示尺寸
+      this.imgDisplayW = Math.round(this.originalW * this.imageScale);
+      this.imgDisplayH = Math.round(this.originalH * this.imageScale);
+
+      // movable-view 初始居中
+      this.initX = Math.round((cw - this.imgDisplayW) / 2);
+      this.initY = Math.round((ch - this.imgDisplayH) / 2);
+    },
+
+    /**
+     * 加载图片信息(支持多级回退)
+     * 1. uni.getImageInfo(首选,返回尺寸+本地路径)
+     * 2. uni.downloadFile → uni.getImageInfo(小程序域名白名单场景)
+     * 3. H5 直接使用 Image 对象获取尺寸
+     */
+    loadImageInfo(src) {
+      if (!src) return Promise.reject(new Error('imageSrc 为空'));
+
+      return new Promise((resolve, reject) => {
+        // 方式1:uni.getImageInfo
+        uni.getImageInfo({
+          src,
+          success: (res) => resolve(res),
+          fail: (err1) => {
+            console.warn(
+              '[RecognizationImage] getImageInfo 失败,尝试 downloadFile:',
+              src,
+              err1,
+            );
+            // 方式2:先下载再获取信息
+            uni.downloadFile({
+              url: src,
+              success: (dlRes) => {
+                if (dlRes.statusCode === 200) {
+                  uni.getImageInfo({
+                    src: dlRes.tempFilePath,
+                    success: (res) => resolve(res),
+                    fail: (err2) => {
+                      console.warn(
+                        '[RecognizationImage] 下载后 getImageInfo 也失败,使用下载路径:',
+                        err2,
+                      );
+                      // 无法获取尺寸,需要后续处理
+                      resolve({
+                        path: dlRes.tempFilePath,
+                        width: 0,
+                        height: 0,
+                      });
+                    },
+                  });
+                } else {
+                  reject(new Error('下载失败,状态码: ' + dlRes.statusCode));
+                }
+              },
+              fail: (dlErr) => {
+                console.error(
+                  '[RecognizationImage] downloadFile 也失败:',
+                  dlErr,
+                );
+                reject(dlErr);
+              },
+            });
+          },
+        });
+      });
+    },
+
+    /**
+     * 绘制害虫标注(使用 markScale 缩放坐标,与 Draw 组件一致)
+     */
+    drawAnnotations() {
+      if (!this.pestData || !this.pestData.length) return;
+
+      let colorIndex = 0;
+
+      // 根据画布尺寸计算合适的线宽和字号
+      const lineWidth = Math.max(1, Math.round(this.canvasW / 200));
+      const fontSize = Math.max(10, Math.round(this.canvasW / 25));
+      const labelPadding = Math.max(2, Math.round(fontSize / 4));
+
+      this.pestData.forEach((pest) => {
+        const { startX, startY, width, height, text } = pest;
+
+        // 为每种害虫分配颜色
+        if (!this.colorMap[text]) {
+          this.colorMap[text] =
+            COLOR_PALETTE[colorIndex % COLOR_PALETTE.length];
+          colorIndex++;
+        }
+        const color = this.colorMap[text];
+
+        // 使用 markScale 缩放坐标到画布空间
+        let x = Number(startX) * this.markScale;
+        let y = Number(startY) * this.markScale;
+        let w = Number(width) * this.markScale;
+        let h = Number(height) * this.markScale;
+
+        // 处理反向绘制(宽或高为负数):调整起点,宽高取绝对值
+        if (w < 0) {
+          x = x + w;
+          w = -w;
+        }
+        if (h < 0) {
+          y = y + h;
+          h = -h;
+        }
+        w = Math.max(1, w);
+        h = Math.max(1, h);
+
+        // --- 绘制半透明填充底色 ---
+        const r = parseInt(color.slice(1, 3), 16);
+        const g = parseInt(color.slice(3, 5), 16);
+        const b = parseInt(color.slice(5, 7), 16);
+        // this.ctx.setFillStyle(`rgba(${r},${g},${b},0.15)`);
+        // this.ctx.fillRect(x, y, w, h);
+
+        // --- 绘制矩形边框 ---
+        this.ctx.setStrokeStyle(color);
+        this.ctx.setLineWidth(lineWidth);
+        this.ctx.strokeRect(x, y, w, h);
+
+        // --- 绘制标签 ---
+        if (text) {
+          // 标签定位:优先在矩形上方,空间不够则放在矩形内部顶部
+          let labelX = x;
+          let labelY = y - fontSize - labelPadding;
+          if (labelY < 0) {
+            labelY = y + labelPadding;
+          }
+
+          // 标签文字
+          this.ctx.setFillStyle(color);
+          this.ctx.setFontSize(fontSize);
+          this.ctx.fillText(
+            text,
+            labelX + labelPadding,
+            labelY + fontSize + labelPadding * 0.5,
+          );
+        }
+      });
+    },
+
+    /**
+     * 手指缩放回调,同步记录当前缩放值
+     */
+    onScale(e) {
+      if (e.detail.scale) {
+        this.currentScale = e.detail.scale;
+      }
+    },
+
+    /**
+     * 按钮放大
+     */
+    zoomIn() {
+      this.currentScale = Math.min(5, this.currentScale + 0.3);
+    },
+
+    /**
+     * 按钮缩小
+     */
+    zoomOut() {
+      this.currentScale = Math.max(0.5, this.currentScale - 0.3);
+    },
+
+    /**
+     * 导出画布为图片
+     */
+    exportCanvas() {
+      uni.canvasToTempFilePath(
+        {
+          canvasId: 'pestCanvas',
+          quality: 1,
+          success: (res) => {
+            this.resultImage = res.tempFilePath;
+            this.loading = false;
+            this.$emit('generated', res.tempFilePath);
+          },
+          fail: (err) => {
+            console.error('[RecognizationImage] 导出失败:', err);
+            this.loading = false;
+            this.$emit('error', err);
+          },
+        },
+        this,
+      );
+    },
+
+    /**
+     * 工具方法:延迟
+     */
+    delay(ms) {
+      return new Promise((resolve) => setTimeout(resolve, ms));
+    },
+
+    /**
+     * 对外方法:重新渲染
+     */
+    refresh() {
+      this.render();
+    },
+
+    /**
+     * 对外方法:获取标注图片路径
+     */
+    getResultImage() {
+      return this.resultImage;
+    },
+  },
+};
+</script>
+
+<style scoped>
+.recognization-image {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow: hidden;
+  background-color: #f0f0f0;
+}
+
+/* 画布阶段 */
+.canvas-phase {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  overflow: hidden;
+}
+
+/* 加载遮罩 */
+.loading-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  background-color: rgba(255, 255, 255, 0.85);
+  z-index: 10;
+}
+
+.loading-spinner {
+  width: 36rpx;
+  height: 36rpx;
+  border: 4rpx solid #e0e0e0;
+  border-top-color: #1890ff;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.loading-text {
+  margin-top: 16rpx;
+  font-size: 28rpx;
+  color: #666666;
+}
+
+/* 图片查看器 */
+.image-viewer {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+.movable-img {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.annotated-image {
+  width: 100%;
+  height: 100%;
+}
+
+/* 缩放控制按钮 */
+.zoom-controls {
+  position: absolute;
+  right: 20rpx;
+  bottom: 40rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+  z-index: 20;
+}
+
+.zoom-btn {
+  width: 64rpx;
+  height: 64rpx;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: rgba(0, 0, 0, 0.5);
+  border-radius: 50%;
+}
+
+.zoom-icon {
+  font-size: 36rpx;
+  color: #ffffff;
+  line-height: 1;
+}
+</style>