| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638 |
- <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>
|