index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. <template>
  2. <view class="recognization-image">
  3. <!-- 画布绘制阶段 -->
  4. <view v-if="!resultImage" class="canvas-phase">
  5. <canvas
  6. canvas-id="pestCanvas"
  7. id="pestCanvas"
  8. :style="{ width: canvasW + 'px', height: canvasH + 'px' }"
  9. ></canvas>
  10. </view>
  11. <!-- 加载中 -->
  12. <view v-if="loading" class="loading-mask">
  13. <view class="loading-spinner"></view>
  14. <text class="loading-text">标注图片生成中...</text>
  15. </view>
  16. <!-- 图片查看器:支持拖拽和缩放 -->
  17. <movable-area
  18. v-if="resultImage"
  19. class="image-viewer"
  20. :style="{ width: containerW + 'px', height: containerH + 'px' }"
  21. >
  22. <movable-view
  23. direction="all"
  24. :scale="true"
  25. :scale-min="0.5"
  26. :scale-max="5"
  27. :scale-value="currentScale"
  28. :inertia="true"
  29. :damping="40"
  30. :x="initX"
  31. :y="initY"
  32. :style="{ width: imgDisplayW + 'px', height: imgDisplayH + 'px' }"
  33. class="movable-img"
  34. @scale="onScale"
  35. >
  36. <image :src="resultImage" mode="scaleToFill" class="annotated-image" />
  37. </movable-view>
  38. </movable-area>
  39. <!-- 缩放控制按钮 -->
  40. <!-- <view v-if="resultImage" class="zoom-controls">
  41. <view class="zoom-btn" @click="zoomIn">
  42. <text class="zoom-icon">+</text>
  43. </view>
  44. <view class="zoom-btn" @click="zoomOut">
  45. <text class="zoom-icon">-</text>
  46. </view>
  47. </view> -->
  48. </view>
  49. </template>
  50. <script>
  51. /**
  52. * 害虫识别图片组件
  53. * 功能:在原始图片上绘制害虫标注框和标签,生成标注图片,支持拖拽和缩放查看
  54. *
  55. * Props:
  56. * imageSrc - 原始图片URL
  57. * pestData - 害虫数据数组 [{startX, startY, width, height, text}]
  58. *
  59. * 使用示例:
  60. <RecognizationImage
  61. :imageSrc="currentImg.addr"
  62. :pestData="[
  63. {
  64. startX: 790.89,
  65. startY: 211.76,
  66. width: 169.65,
  67. height: 189.47,
  68. text: '二化螟',
  69. },
  70. {
  71. startX: 455.3,
  72. startY: 313.3,
  73. width: 102.78,
  74. height: 90.4,
  75. text: '二化螟',
  76. },
  77. {
  78. startX: 676.85,
  79. startY: 472.55,
  80. width: 135.01,
  81. height: 101.26,
  82. text: '草地贪夜蛾',
  83. },
  84. {
  85. startX: 321.37,
  86. startY: 819.22,
  87. width: -29.88,
  88. height: -29.08,
  89. text: '草地贪夜蛾2号',
  90. },
  91. {
  92. startX: 879.37,
  93. startY: 79.9,
  94. width: -49.03,
  95. height: -45.74,
  96. text: '666',
  97. },
  98. {
  99. startX: 845.62,
  100. startY: 643.09,
  101. width: 14.21,
  102. height: 15.99,
  103. text: '6663',
  104. },
  105. {
  106. startX: 370.17,
  107. startY: 45.58,
  108. width: 32.24,
  109. height: 30.45,
  110. text: '666',
  111. },
  112. ]"
  113. style="width: 100%; height: 500rpx"
  114. />
  115. */
  116. // 害虫类型对应的颜色调色板
  117. const COLOR_PALETTE = [
  118. '#FF0000',
  119. '#00FFC2',
  120. '#FF00A8',
  121. '#120080',
  122. '#BDFF00',
  123. '#FFB800',
  124. '#5E8000',
  125. '#FF5C00',
  126. '#00FF75',
  127. '#00F0FF',
  128. '#0094FF',
  129. '#EB00FF',
  130. '#00C2FF',
  131. '#C74C4C',
  132. '#FFFFFF',
  133. '#FF6B6B',
  134. ];
  135. export default {
  136. name: 'RecognizationImage',
  137. props: {
  138. // 原始图片地址
  139. imageSrc: {
  140. type: String,
  141. default: '',
  142. },
  143. // 害虫标注数据
  144. pestData: {
  145. type: Array,
  146. default: () => [],
  147. },
  148. },
  149. data() {
  150. return {
  151. ctx: null,
  152. loading: false,
  153. // 画布尺寸
  154. canvasW: 300,
  155. canvasH: 300,
  156. // 原始图片尺寸
  157. originalW: 0,
  158. originalH: 0,
  159. // 缩放系数
  160. imageScale: 1, // 图片适配画布的缩放比
  161. baseScale: 0.25, // 基础缩放参考值
  162. markScale: 1, // 标注坐标缩放比
  163. // 生成的标注图片路径
  164. resultImage: '',
  165. // 图片显示尺寸(适配容器)
  166. imgDisplayW: 300,
  167. imgDisplayH: 300,
  168. // movable-view 初始位置(居中)
  169. initX: 0,
  170. initY: 0,
  171. // 容器尺寸
  172. containerW: 300,
  173. containerH: 300,
  174. // 害虫类型→颜色映射
  175. colorMap: {},
  176. // 当前缩放值
  177. currentScale: 1,
  178. };
  179. },
  180. watch: {
  181. imageSrc(newVal) {
  182. if (newVal) {
  183. this.render();
  184. }
  185. },
  186. pestData: {
  187. handler() {
  188. if (this.imageSrc) {
  189. this.render();
  190. }
  191. },
  192. deep: true,
  193. },
  194. },
  195. mounted() {
  196. this.getContainerSize().then(() => {
  197. if (this.imageSrc) {
  198. this.render();
  199. }
  200. });
  201. },
  202. methods: {
  203. /**
  204. * 获取容器尺寸
  205. */
  206. getContainerSize() {
  207. return new Promise((resolve) => {
  208. uni
  209. .createSelectorQuery()
  210. .in(this)
  211. .select('.recognization-image')
  212. .boundingClientRect((rect) => {
  213. if (rect && rect.width > 0) {
  214. this.containerW = rect.width;
  215. this.containerH = rect.height;
  216. }
  217. resolve();
  218. })
  219. .exec();
  220. });
  221. },
  222. /**
  223. * 主渲染流程:加载图片 → 绘制标注 → 导出图片
  224. */
  225. async render() {
  226. if (!this.imageSrc) return;
  227. this.loading = true;
  228. this.resultImage = '';
  229. this.colorMap = {};
  230. this.currentScale = 1;
  231. try {
  232. // 1. 加载图片信息
  233. const imgInfo = await this.loadImageInfo(this.imageSrc);
  234. console.log('[RecognizationImage] 图片信息:', imgInfo);
  235. if (imgInfo.width > 0 && imgInfo.height > 0) {
  236. this.originalW = imgInfo.width;
  237. this.originalH = imgInfo.height;
  238. } else {
  239. // 回退场景:无法获取尺寸,使用默认比例 4:3
  240. console.warn(
  241. '[RecognizationImage] 无法获取图片尺寸,使用默认 4:3 比例',
  242. );
  243. this.originalW = 4000;
  244. this.originalH = 3000;
  245. }
  246. // 2. 计算缩放参数和画布尺寸
  247. this.calcSizes();
  248. // 3. 等待 canvas 渲染到 DOM
  249. await this.$nextTick();
  250. await this.delay(100);
  251. // 4. 初始化画布上下文
  252. this.ctx = uni.createCanvasContext('pestCanvas', this);
  253. // 5. 绘制背景图片(使用 imageScale 缩放到画布尺寸)
  254. const drawSrc = imgInfo.path || this.imageSrc;
  255. const displayWidth = this.originalW * this.imageScale;
  256. const displayHeight = this.originalH * this.imageScale;
  257. console.log('[RecognizationImage] 绘制参数:', {
  258. canvas: this.canvasW + 'x' + this.canvasH,
  259. imageScale: this.imageScale,
  260. baseScale: this.baseScale,
  261. markScale: this.markScale,
  262. displaySize: displayWidth + 'x' + displayHeight,
  263. });
  264. this.ctx.drawImage(drawSrc, 0, 0, displayWidth, displayHeight);
  265. // 6. 绘制害虫标注(使用 markScale 缩放坐标)
  266. this.drawAnnotations();
  267. // 8. 执行绘制并导出
  268. this.ctx.draw(false, () => {
  269. setTimeout(() => {
  270. this.exportCanvas();
  271. }, 300);
  272. });
  273. } catch (err) {
  274. console.error('[RecognizationImage] 渲染失败:', err);
  275. this.loading = false;
  276. }
  277. },
  278. /**
  279. * 计算缩放参数和画布尺寸(与 Draw 组件一致的缩放逻辑)
  280. */
  281. calcSizes() {
  282. const cw = this.containerW || 300;
  283. const ch = this.containerH || 300;
  284. const canvasAspect = cw / ch;
  285. const imageAspect = this.originalW / this.originalH;
  286. // 画布尺寸 = 容器尺寸
  287. this.canvasW = cw;
  288. this.canvasH = ch;
  289. // 计算图片等比缩放系数(适配画布)
  290. if (imageAspect > canvasAspect) {
  291. this.imageScale = cw / this.originalW;
  292. } else {
  293. this.imageScale = ch / this.originalH;
  294. }
  295. // 根据图片宽度定义基础缩放比例
  296. if (this.originalW >= 5000) {
  297. this.baseScale = 0.25;
  298. } else if (this.originalW >= 4000) {
  299. this.baseScale = 0.31;
  300. } else {
  301. this.baseScale = 0.4;
  302. }
  303. // 标注坐标缩放比 = imageScale / baseScale
  304. this.markScale = 1 - (this.baseScale - this.imageScale) / this.baseScale;
  305. // 图片在画布上的实际显示尺寸
  306. this.imgDisplayW = Math.round(this.originalW * this.imageScale);
  307. this.imgDisplayH = Math.round(this.originalH * this.imageScale);
  308. // movable-view 初始居中
  309. this.initX = Math.round((cw - this.imgDisplayW) / 2);
  310. this.initY = Math.round((ch - this.imgDisplayH) / 2);
  311. },
  312. /**
  313. * 加载图片信息(支持多级回退)
  314. * 1. uni.getImageInfo(首选,返回尺寸+本地路径)
  315. * 2. uni.downloadFile → uni.getImageInfo(小程序域名白名单场景)
  316. * 3. H5 直接使用 Image 对象获取尺寸
  317. */
  318. loadImageInfo(src) {
  319. if (!src) return Promise.reject(new Error('imageSrc 为空'));
  320. return new Promise((resolve, reject) => {
  321. // 方式1:uni.getImageInfo
  322. uni.getImageInfo({
  323. src,
  324. success: (res) => resolve(res),
  325. fail: (err1) => {
  326. console.warn(
  327. '[RecognizationImage] getImageInfo 失败,尝试 downloadFile:',
  328. src,
  329. err1,
  330. );
  331. // 方式2:先下载再获取信息
  332. uni.downloadFile({
  333. url: src,
  334. success: (dlRes) => {
  335. if (dlRes.statusCode === 200) {
  336. uni.getImageInfo({
  337. src: dlRes.tempFilePath,
  338. success: (res) => resolve(res),
  339. fail: (err2) => {
  340. console.warn(
  341. '[RecognizationImage] 下载后 getImageInfo 也失败,使用下载路径:',
  342. err2,
  343. );
  344. // 无法获取尺寸,需要后续处理
  345. resolve({
  346. path: dlRes.tempFilePath,
  347. width: 0,
  348. height: 0,
  349. });
  350. },
  351. });
  352. } else {
  353. reject(new Error('下载失败,状态码: ' + dlRes.statusCode));
  354. }
  355. },
  356. fail: (dlErr) => {
  357. console.error(
  358. '[RecognizationImage] downloadFile 也失败:',
  359. dlErr,
  360. );
  361. reject(dlErr);
  362. },
  363. });
  364. },
  365. });
  366. });
  367. },
  368. /**
  369. * 绘制害虫标注(使用 markScale 缩放坐标,与 Draw 组件一致)
  370. */
  371. drawAnnotations() {
  372. if (!this.pestData || !this.pestData.length) return;
  373. let colorIndex = 0;
  374. // 根据画布尺寸计算合适的线宽和字号
  375. const lineWidth = Math.max(1, Math.round(this.canvasW / 200));
  376. const fontSize = Math.max(10, Math.round(this.canvasW / 25));
  377. const labelPadding = Math.max(2, Math.round(fontSize / 4));
  378. this.pestData.forEach((pest) => {
  379. const { startX, startY, width, height, text } = pest;
  380. // 为每种害虫分配颜色
  381. if (!this.colorMap[text]) {
  382. this.colorMap[text] =
  383. COLOR_PALETTE[colorIndex % COLOR_PALETTE.length];
  384. colorIndex++;
  385. }
  386. const color = this.colorMap[text];
  387. // 使用 markScale 缩放坐标到画布空间
  388. let x = Number(startX) * this.markScale;
  389. let y = Number(startY) * this.markScale;
  390. let w = Number(width) * this.markScale;
  391. let h = Number(height) * this.markScale;
  392. // 处理反向绘制(宽或高为负数):调整起点,宽高取绝对值
  393. if (w < 0) {
  394. x = x + w;
  395. w = -w;
  396. }
  397. if (h < 0) {
  398. y = y + h;
  399. h = -h;
  400. }
  401. w = Math.max(1, w);
  402. h = Math.max(1, h);
  403. // --- 绘制半透明填充底色 ---
  404. const r = parseInt(color.slice(1, 3), 16);
  405. const g = parseInt(color.slice(3, 5), 16);
  406. const b = parseInt(color.slice(5, 7), 16);
  407. // this.ctx.setFillStyle(`rgba(${r},${g},${b},0.15)`);
  408. // this.ctx.fillRect(x, y, w, h);
  409. // --- 绘制矩形边框 ---
  410. this.ctx.setStrokeStyle(color);
  411. this.ctx.setLineWidth(lineWidth);
  412. this.ctx.strokeRect(x, y, w, h);
  413. // --- 绘制标签 ---
  414. if (text) {
  415. // 标签定位:优先在矩形上方,空间不够则放在矩形内部顶部
  416. let labelX = x;
  417. let labelY = y - fontSize - labelPadding;
  418. if (labelY < 0) {
  419. labelY = y + labelPadding;
  420. }
  421. // 标签文字
  422. this.ctx.setFillStyle(color);
  423. this.ctx.setFontSize(fontSize);
  424. this.ctx.fillText(
  425. text,
  426. labelX + labelPadding,
  427. labelY + fontSize + labelPadding * 0.5,
  428. );
  429. }
  430. });
  431. },
  432. /**
  433. * 手指缩放回调,同步记录当前缩放值
  434. */
  435. onScale(e) {
  436. if (e.detail.scale) {
  437. this.currentScale = e.detail.scale;
  438. }
  439. },
  440. /**
  441. * 按钮放大
  442. */
  443. zoomIn() {
  444. this.currentScale = Math.min(5, this.currentScale + 0.3);
  445. },
  446. /**
  447. * 按钮缩小
  448. */
  449. zoomOut() {
  450. this.currentScale = Math.max(0.5, this.currentScale - 0.3);
  451. },
  452. /**
  453. * 导出画布为图片
  454. */
  455. exportCanvas() {
  456. uni.canvasToTempFilePath(
  457. {
  458. canvasId: 'pestCanvas',
  459. quality: 1,
  460. success: (res) => {
  461. this.resultImage = res.tempFilePath;
  462. this.loading = false;
  463. this.$emit('generated', res.tempFilePath);
  464. },
  465. fail: (err) => {
  466. console.error('[RecognizationImage] 导出失败:', err);
  467. this.loading = false;
  468. this.$emit('error', err);
  469. },
  470. },
  471. this,
  472. );
  473. },
  474. /**
  475. * 工具方法:延迟
  476. */
  477. delay(ms) {
  478. return new Promise((resolve) => setTimeout(resolve, ms));
  479. },
  480. /**
  481. * 对外方法:重新渲染
  482. */
  483. refresh() {
  484. this.render();
  485. },
  486. /**
  487. * 对外方法:获取标注图片路径
  488. */
  489. getResultImage() {
  490. return this.resultImage;
  491. },
  492. },
  493. };
  494. </script>
  495. <style scoped>
  496. .recognization-image {
  497. width: 100%;
  498. height: 100%;
  499. position: relative;
  500. overflow: hidden;
  501. background-color: #f0f0f0;
  502. }
  503. /* 画布阶段 */
  504. .canvas-phase {
  505. width: 100%;
  506. height: 100%;
  507. display: flex;
  508. justify-content: center;
  509. align-items: center;
  510. overflow: hidden;
  511. }
  512. /* 加载遮罩 */
  513. .loading-mask {
  514. position: absolute;
  515. top: 0;
  516. left: 0;
  517. width: 100%;
  518. height: 100%;
  519. display: flex;
  520. flex-direction: column;
  521. justify-content: center;
  522. align-items: center;
  523. background-color: rgba(255, 255, 255, 0.85);
  524. z-index: 10;
  525. }
  526. .loading-spinner {
  527. width: 36rpx;
  528. height: 36rpx;
  529. border: 4rpx solid #e0e0e0;
  530. border-top-color: #1890ff;
  531. border-radius: 50%;
  532. animation: spin 0.8s linear infinite;
  533. }
  534. @keyframes spin {
  535. to {
  536. transform: rotate(360deg);
  537. }
  538. }
  539. .loading-text {
  540. margin-top: 16rpx;
  541. font-size: 28rpx;
  542. color: #666666;
  543. }
  544. /* 图片查看器 */
  545. .image-viewer {
  546. width: 100%;
  547. height: 100%;
  548. overflow: hidden;
  549. }
  550. .movable-img {
  551. display: flex;
  552. justify-content: center;
  553. align-items: center;
  554. }
  555. .annotated-image {
  556. width: 100%;
  557. height: 100%;
  558. }
  559. /* 缩放控制按钮 */
  560. .zoom-controls {
  561. position: absolute;
  562. right: 20rpx;
  563. bottom: 40rpx;
  564. display: flex;
  565. flex-direction: column;
  566. gap: 16rpx;
  567. z-index: 20;
  568. }
  569. .zoom-btn {
  570. width: 64rpx;
  571. height: 64rpx;
  572. display: flex;
  573. justify-content: center;
  574. align-items: center;
  575. background-color: rgba(0, 0, 0, 0.5);
  576. border-radius: 50%;
  577. }
  578. .zoom-icon {
  579. font-size: 36rpx;
  580. color: #ffffff;
  581. line-height: 1;
  582. }
  583. </style>