<!--
接触角求解-多算法版
通过图片处理,得到轮廓数据,然后通过计算拟合最终求解接触角。
计算机视觉库的实现,以及一些交互的设计思路。
构建OpenCV.js:https://docs.opencv.ac.cn/4.12.0/d4/da1/tutorial_js_setup.html
交互主要分4步完成,共5个状态:
1. 读取图片/上传图片。读取到的图片将直接灰度化,渲染在canvas上。
此处的交互主要就是上传图片。
此步骤将保存原图片的Mat对象,以便后续使用。
2. 裁剪图片为合适的尺寸。裁剪完毕后将裁剪好的图片渲染在canvas上。
短按控制边框;长按清空已有选框。可以反复多次裁剪。直至点击"完成裁剪"按钮。
此步骤将覆盖保存Mat图像,即用裁剪后的灰度Mat图像替换原Mat图像。
同时,也将保存canvas的imageData对象,以便后续使用。
3. 寻找液滴的最佳轮廓。
有2个算法(默认的Canny算法更好),调节switch开关以切换。
Canny算法有2个参数(2个滑轨),阈值化方法有1个参数(1个滑轨)。
调节滑轨可实时查看轮廓效果(灰度图叠加轮廓线)。
设置了粗调、细调的切换。切换后可细化/粗化滑轨的范围值。
长按canvas可清空效果。
此步骤结束时将保存轮廓数据为坐标数组,然后迭代优化获得椭圆对象。
然后会将椭圆对象绘制在canvas上,并保存灰度图+拟合椭圆的imageData对象,以便下一步使用。
4. 寻找基线。
在第3步叠加imageData基础上寻找基线。
点击canvas将粗调基线。canvas分左中右3个部分,左右用于调整单边截距,中间用于调整双截距。
同时设置了左、右调节滑轨,可微调左右截距,并实时查看基线效果。
此步骤结束时将获得基线的截距。
5. 计算接触角。此处不用再有复杂交互。
得到接触角后,将出现结果表格,以及下载按钮,可以以excel表格格式下载数据结果。
-->
<!--
特别的注意事项/开发札记:
这里有一个巨大的坑点,就是计算机的Y轴是以向下为正的。
-->
<!--
视图层
-->
<template><MySpace>
<!-- 警报框 -->
<t-alert theme="info" :title="lang.FunctionIntroductionTitle">
<div v-for="(content, index) of lang.FunctionIntroductionContent" :key="index">
{{ content }}
</div>
</t-alert>
<!-- canvas头-步骤1 -->
<!-- 警报框 -->
<t-alert
v-if="taskStatusRef === 1"
theme="warning" :title="lang.SetpTitle + '1'"
>
<div v-for="(content, index) of lang.Setp1Content" :key="index">
{{ content }}
</div>
</t-alert>
<!--
图片上传
这个一直都存在,方便用户删除上传的图片
onPicChange:图片上传、删除时触发。
上传则处理图片并进入下个流程;
删除则清空所有数据,回到初始状态(状态1)。
-->
<t-upload
class="center" :disabled="false" theme="image"
accept="image/*" :multiple="false" :draggable="false"
:showImageFileName="true" :abridgeName="[3, 8]"
v-model:files="fileArrRef" :autoUpload="false"
:sizeLimit="{ size: 10, unit: 'MB' }"
:onChange="onPicChange"
/>
<!-- canvas头-步骤2 -->
<!-- 警报框 -->
<t-alert
v-if="taskStatusRef === 2"
theme="warning" :title="lang.SetpTitle + '2'"
>
<div v-for="(content, index) of lang.Setp2Content" :key="index">
{{ content }}
</div>
</t-alert>
<!--
canvas头-步骤3
这里有一个switch开关,用于切换边缘检测算法。
Canny算法和阈值化算法。恰好都有2个参数,一主一辅,所以UI是可以统一的。
(参数调节放在"canvas脚-步骤3"部分了)
onContourAlgorithmSwitchChange:切换算法时触发。
-->
<MySpace v-else-if="taskStatusRef === 3">
<!-- 警报框 -->
<t-alert theme="warning" :title="lang.SetpTitle + '3'">
<div v-for="(content, index) of lang.Setp3Content" :key="index">
{{ content }}
</div>
</t-alert>
<!-- 警报框:轮廓算法/边缘检测算法切换开关 -->
<t-alert theme="info" :title="lang.ContourAlgorithmTitle">
<div v-for="(content, index) of lang.ContourAlgorithmContent" :key="index">
<div v-if="typeof content !== 'string'">
<strong>{{ content.strong }}</strong>{{ content.normal }}
</div>
<div v-else>
{{ content }}
</div>
</div>
</t-alert>
<!-- 边缘检测算法切换选框 -->
<MyRadio
@change="onContourAlgorithmSwitch"
v-model:value="contourAlgorithmRadioRef"
:radioContentArr="lang.ContourAlgorithmArr"
/>
<!-- 警报框:遮罩 -->
<t-alert theme="info" :title="lang.ContourMaskTitle">
<div v-for="(content, index) of lang.ContourMaskContent" :key="index">
<div v-if="typeof content !== 'string'">
<strong>{{ content.strong }}</strong>{{ content.normal }}
</div>
<div v-else>{{ content }}</div>
</div>
</t-alert>
<!-- 遮罩切换选框 -->
<MyRadio
v-model:value="contourFilterAlgorithmRadioRef"
:radioContentArr="lang.ContourMaskContentArr"
/>
</MySpace>
<!-- canvas头-步骤4 -->
<!-- 警报框 -->
<t-alert
v-else-if="taskStatusRef === 4"
theme="warning" :title="lang.SetpTitle + '4'"
>
<div v-for="(content, index) of lang.Setp4Content" :key="index">
{{ content }}
</div>
</t-alert>
<!--
canvas元素块
这个一直存在。这是最重要的,从第二步开始,其它的元素块都围绕这个展开
onCanvasClick:点击canvas时触发。
步骤2时用于选框;步骤4时用于粗选基线。
onLongPress:在逻辑层注册,长按canvas时触发。
步骤2、步骤3时用于清空选框(初始化)。
-->
<div style="width: 100%; overflow: hidden">
<canvas
v-show="taskStatusRef >= 2"
ref="canvasRef"
@click="onCanvasClick"
></canvas>
</div>
<!--
canvas脚-步骤2
主要就是选框裁剪。主要交互放在canvas上了。这里只是按钮。
-->
<div
v-if="taskStatusRef === 2"
class="center"
>
<!-- 裁剪图片 -->
<MyButton
@click="onSureRect(false)"
:block="false"
>
{{ lang.CutPictureButtonText }}
</MyButton>
<!-- 裁剪完成 -->
<MyButton
@click="onSureRect(true)"
:block="false" theme="danger"
>
{{ lang.CutPictureCompleteButtonText }}
</MyButton>
</div>
<!--
canvas脚-步骤3
主要就是调节滑轨来实现轮廓选择。有按钮来控制滑轨的粗调和细调切换。
onSlideChange:滑轨变化时触发,用于实时渲染轮廓效果。
onContourSlideChangeEnd:滑轨变化结束时触发,用于在细调的时候,更新滑轨的可移动范围。
contourCoarseToggle:切换滑轨的粗调和细调。
onDetermineContour:最终确定轮廓的按钮事件回调钩子。
-->
<MySpace
v-else-if="taskStatusRef === 3"
size="small"
>
<!-- 滑轨:主参数 -->
{{ lang.ContourSliderMainParameterLabelArr[contourAlgorithmRadioRef] }}
<t-slider
@change="onSliderChange" @changeEnd="onSliderChangeEnd"
v-model="thresholdNumArrRef[0].value"
:min="thresholdNumArrRef[0].min" :max="thresholdNumArrRef[0].max"
:marks="thresholdNumArrRef[0].marks"
:step="1" :range="false"
:inputNumberProps="false" :label="true" layout="horizontal"
/><t-divider />
<!-- 滑轨:辅助参数 -->
<MySpace
v-if="contourAlgorithmRadioRef === 0"
size="small"
>
{{ lang.ContourSliderAuxiliaryParameterLabel }}
<t-slider
@change="onSliderChange" @changeEnd="onSliderChangeEnd"
v-model="thresholdNumArrRef[1].value"
:min="thresholdNumArrRef[1].min" :max="thresholdNumArrRef[1].max"
:marks="thresholdNumArrRef[1].marks"
:step="1" :range="false"
:inputNumberProps="false" :label="true" layout="horizontal"
/><t-divider />
</MySpace>
<!-- 容器(按钮容器) -->
<div class="center">
<!-- 轮廓粗调/细调切换 -->
<MyButton
@click="onContourSliderCoarseFineToggle"
:block="false" :theme="isContourCoarseRef ? 'primary' : 'warning'"
>
{{
isContourCoarseRef
? lang.ContourSliderSwitchFineButtonLabel
: lang.ContourSliderSwitchCoarseButtonLabel
}}
</MyButton>
<!-- 确定轮廓 -->
<MyButton
@click="onDetermineContour"
:block="false" theme="danger"
>
{{ lang.ContourDetermineButtonLabel }}
</MyButton>
</div>
</MySpace>
<!--
canvas脚-步骤4
主要就是找基线。点击canvas实现基线粗找,调节滑轨来实现基线细调。
onSlideChange:滑轨变化时触发,用于实时渲染基线效果。
没有滑轨变化结束时的触发方法/钩子,因为canvas事件回调钩子会劫持slider的change事件,使其没有end。
onBackToStep3:返回第3步,这里需要有次功能,以满足找基线时候对轮廓的反复微调。
onDetermineBaseline:最终确定基线的按钮事件回调钩子。
-->
<MySpace
v-else-if="taskStatusRef === 4"
size="small"
>
<!-- 滑轨:左截距 -->
{{ lang.InterceptLeftSliderLabel }}
<t-slider
@change="onSliderChange" @changeEnd="onSliderChangeEnd"
v-model="interceptNumArrRef[0].value"
:min="interceptNumArrRef[0].min" :max="interceptNumArrRef[0].max"
:marks="interceptNumArrRef[0].marks"
:step="1" :range="false"
:inputNumberProps="false" :label="true" layout="horizontal"
/><t-divider />
<!-- 滑轨:右截距 -->
{{ lang.InterceptRightSliderLabel }}
<t-slider
@change="onSliderChange" @changeEnd="onSliderChangeEnd"
v-model="interceptNumArrRef[1].value"
:min="interceptNumArrRef[1].min" :max="interceptNumArrRef[1].max"
:marks="interceptNumArrRef[1].marks"
:step="1" :range="false"
:inputNumberProps="false" :label="true" layout="horizontal"
/><t-divider />
<!-- 容器(按钮容器) -->
<div class="center">
<!-- 返回上一步 -->
<MyButton
@click="onBackToStep3"
:block="false" theme="default"
>
{{ lang.StepBackButtonLabel }}
</MyButton>
<!-- 确认基线 -->
<MyButton
@click="onDetermineBaseline"
:block="false" theme="primary"
>
{{ lang.BaselineConfirmButtonLabel }}
</MyButton>
</div>
</MySpace>
<!--
canvas脚-步骤5
数据结果的呈现:数据表格、下载按钮
-->
<MySpace
v-if="resultTableDataRef?.length !== 0"
size="small"
>
<!-- 表格和翻页器容器 -->
<div>
<!-- 接触角数据表格 -->
<div class="center"><table>
<!-- 表头 -->
<thead>
<tr>
<th v-for="(content, index) of lang.ResultTableContent" :key="index">
{{ content }}
</th>
<!-- 处理 -->
<th>{{ lang.ResultTableProcessingLabel }}</th>
</tr>
</thead>
<!-- 表格体 -->
<tbody>
<tr v-for="(resultArr, index) in resultTableDataRef" :key="index">
<td>{{ resultArr[0] + 1 }}</td>
<td>{{ resultArr[1] }}</td>
<td>{{ resultArr[2]?.toFixed(2) }}</td>
<td>{{ resultArr[3]?.toFixed(2) }}</td>
<td>{{ resultArr[4]?.toFixed(2) }}</td>
<td>{{ resultArr[5]?.toFixed(2) }}</td>
<td>{{ resultArr[6]?.toFixed(2) }}</td>
<td>{{ resultArr[7]?.toFixed(4) }}</td>
<td>{{ lang.FitNatureStrMap[resultArr[8]] ?? resultArr[8] }}</td>
<!-- 删除按钮 -->
<td><MyButton
@click="onDeleteUniResult(resultArr[0])"
:block="false" theme="danger"
>
{{ lang.ResultTableDeleteButtonLabel }}
</MyButton></td>
</tr>
</tbody>
</table></div>
<!-- 分页器 -->
<t-pagination
v-model:current="resultTableCurrentPageRef"
:total="resultRef.length"
:showFirstAndLastPageBtn="false"
:showJumper="true"
:showPageNumber="false"
:showPageSize="false"
:showPreviousAndNextBtn="true"
/><t-divider />
</div>
<!-- 容器(按钮容器) -->
<div class="center">
<!-- 倒序/正序 -->
<MyButton
@click="onReverseResultOrder"
:block="false" theme="default"
>
{{
isResultReverseRef === false
? lang.ResultTableReverseButtonLabel
: lang.ResultTableNormalButtonLabel
}}
</MyButton>
<!-- 下载数据 -->
<MyButton
@click="onDownloadResult"
:block="false" theme="primary"
>
{{ lang.ResultTableExportButtonLabel }}
</MyButton>
<!-- 清空数据 -->
<MyButton
@click="onDeleteAllResult"
:block="false" theme="danger"
>
{{ lang.DeleteAllResultButtonLabel }}
</MyButton>
</div>
</MySpace>
</MySpace></template>
<!--
逻辑层
-->
<script setup>
// 导入VUE的各类响应式方法
import { useTemplateRef, onMounted, onBeforeUnmount, ref, watch, nextTick } from "vue"
// 导入VueUse的各类响应式方法
import { useMouseInElement, onLongPress, useThrottleFn } from "@vueuse/core"
// 导入自有方法
import my from "@/utils/myFunc.js"
// 导入xlsx相关方法
import { aoaMapToWorkbook, downloadXlsx } from "@/utils/app-xlsx.js"
// 导入OpenCV.js加载器
import { loadOpenCV } from "@/utils/opencvLoader.js"
// 导入纯算法模块(从Vue文件中解耦出来的无UI依赖的计算逻辑)
import * as Algorithm from "./ContactAngle-algorithm.js"
// 导入语言包
import { useLang, lang } from "./ContactAngle-lang.js"
// ======================================== 数据类型声明 ========================================
/**
* @typedef { object } SliderParam 调参数组
* @property { number } value 当前值
* @property { number } min 最小值
* @property { number } max 最大值
* @property { number[] } marks 标记
*/
/**
* @typedef { [string, number, number, number, number, number, number, string] } ResultDatum 单个数据结果
* [文件名, 接触角, 偏差, 左接触角, 右接触角, 基线角度, 拟合R², 拟合结果类型]
*/
/**
* @typedef { [number, string, number, number, number, number, number, number, string] } OrderResultDatum 带序号的单个数据结果
* [序号, 文件名, 接触角, 偏差, 左接触角, 右接触角, 基线角度, 拟合R², 拟合结果类型]
*/
/**
* 接触角业务的全局对象
* @typedef { object } ContactAngle
* @property { CV } cv OpenCV.js对象
* @property { number } canvasStyleWidth canvas元素块的显示宽度
* @property { string } filename 所上传文件的文件名
* @property { CanvasRenderingContext2D } ctx canvas的绘图上下文对象
* @property { number } canvasScaling canvas元素块的缩放比例:实际/显示
* @property { CV.Mat } matGray 灰度图Mat对象
* @property { ImageData } imageData canvas的图像数据,用于暂存,便于恢复
* @property { Rect } rect canvas元素块选框
* @property { ColLine } colLine 轮廓选择时用于过滤的两侧基线
* @property { Baseline } baseline 轮廓选择时用于过滤的底部基线。
* 此值应与步骤4滑轨绑定,相对canvas对称(当且仅当步骤4)。
* 步骤4的计算应以Ref对象为基准,而不是baseline对象。步骤4的逻辑归集到Ref对象了。
* @property { [number, number] } baselineReferencePoint 基线参考点
* @property { CV.Ellipse } ellipse 拟合得到的椭圆对象
* @property { number } ellipseR2 椭圆拟合的决定系数R²
* @property { string } resultType 拟合迭代结果的类型
* @note canvas的实际宽高在canvasRef.value.width和canvasRef.value.height上
* @note canvas的显示宽最大值在canvasParentRef.value.clientWidth上,但是这个可能会变化!很坑
*/
/**
* @typedef { object } Rect canvas元素块选框
* @property { number } Rect.xMax canvas元素块选框的X坐标大值(亦用于步骤3的遮罩框)
* @property { number } Rect.yMax canvas元素块选框的Y坐标大值(亦用于步骤3的遮罩框)
* @property { number } Rect.xMin canvas元素块选框的X坐标小值(亦用于步骤3的遮罩框)
* @property { number } Rect.yMin canvas元素块选框的Y坐标小值(亦用于步骤3的遮罩框)
*/
/**
* @typedef { object } ColLine canvas元素块遮罩线
* @property { number } ColLine.left canvas元素块遮罩线的左侧线X坐标
* @property { number } ColLine.right canvas元素块遮罩线的右侧线X坐标
*/
/**
* @typedef { object } Baseline canvas元素基线遮罩线
* @property { number } Baseline.left canvas元素块基线遮罩线的左侧Y坐标
* @property { number } Baseline.right canvas元素块基线遮罩线的右侧Y坐标
*/
// ======================================== 业务内的全局对象 ========================================
/**
* 任务状态:
* 1. 未开始,或删除了图片。正在等待读取图片;
* 2. 读取到了图片。正在选框裁剪图片;
* 3. 完成了选框,得到了裁剪的图片。正在寻找并确定轮廓;
* 4. 完成了轮廓寻找,得到了液滴轮廓坐标。正在寻找基线;
* 5. 完成了基线寻找,得到了基线坐标。正在计算接触角。
* 其实并不存在状态5,因为计算接触角是最后一步,没有下一步了。
*/
const taskStatusRef = ref(1)
/** 用户上传的文件数组对象 @type { Ref<File[]> } */
const fileArrRef = ref([])
/**
* 视图层的<canvas>Dom对象
* canvas加载很慢,需要等,比较好的等待方法是watch监听钩子。
* 实测nextTick、onMounted都不如watch。
*/
const canvasRef = useTemplateRef("canvasRef")
/** 第三步寻找轮廓的调参数组Ref对象 @type { Ref<SliderParam[]> } */
const thresholdNumArrRef = ref([])
/** 第三步寻找轮廓的调参数组常量对象 @type { SliderParam[] } */
const thresholdNumArrConst = [{
// 主参数:当前值、最小值、最大值、marks标记
value: 255,
min: 0,
max: 255,
marks: [0, 85, 170, 255]
}, {
// 辅助参数:当前值、最小值、最大值、marks标记
value: 0,
min: 0,
max: 255,
marks: [0, 85, 170, 255]
}]
/**
* 第三步寻找轮廓的算法选框对象
* @type { Ref<number> }
* @value 0 - Canny算法
* @value 1 - 阈值化法
*/
const contourAlgorithmRadioRef = ref(0)
/** 第三步寻找轮廓是否是粗调模式 @type { Ref<boolean> } */
const isContourCoarseRef = ref(true)
/**
* 第三步寻找轮廓的遮罩算法选框对象
* @type { Ref<number> }
* @value 0 - 基线遮罩
* @value 1 - 两边遮罩
* @value 2 - 中心遮罩
*/
const contourFilterAlgorithmRadioRef = ref(0)
/**
* 第四步寻找基线的截距
* @type { Ref<SliderParam[]> }
* 格式:左截距:当前值、最小值、最大值、marks标记,右截距:当前值、最小值、最大值、marks标记
* @note 不能轻易赋值,因为在步骤4,一旦赋值,就会触发绘制基线等回调
*/
const interceptNumArrRef = ref([])
/**
* 第五步计算接触角的最终结果
* @type { Ref<ResultDatum[]> }
* 分别是:[文件名, 接触角, 偏差, 左接触角, 右接触角, 基线角度, 拟合R², 拟合结果类型]
*/
const resultRef = ref([])
/** 第五步最终结果表格的页码 @type { Ref<number> } */
const resultTableCurrentPageRef = ref(1)
/**
* 第五步最终结果数据是否倒序显示
* @type { Ref<boolean> }
* @value false - 升序
* @value true - 数据倒置
*/
const isResultReverseRef = ref(false)
/**
* 第五步最终结果的表格内容
* @type { Ref<OrderResultDatum[]> }
* 分别是:[序号, 文件名, 接触角, 偏差, 左接触角, 右接触角, 基线角度, 拟合R², 拟合结果类型]
* @note resultTableDataRef比resultRef多了个序号
* 可能因版本差异,数组元素数量不足7个,所以用可选链,并需在软件初始化时做验证
*/
const resultTableDataRef = ref([])
/** 接触角业务的全局对象 @type { ContactAngle } */
const contactAngleObj = {
cv: null,
canvasStyleWidth: null,
filename: null,
ctx: null,
canvasScaling: 0.0,
matGray: null,
imageData: null,
rect: {
xMax: null,
yMax: null,
xMin: null,
yMin: null,
},
colLine: {
left: null,
right: null,
},
baseline: {
left: null,
right: null,
},
baselineReferencePoint: null,
ellipse: null,
ellipseR2: null,
resultType: null,
}
// 注册一个<canvas>的响应式鼠标点击监听
const {
// 鼠标点在<canvas>内部的X坐标、Y坐标
elementX, elementY,
// // <canvas>的宽度、高度
// elementWidth, elementHeight,
// // 判断鼠标是否在<canvas>外部
// isOutside,
// // 停止监听方法
// stop: stopMouseInElement
} = useMouseInElement(canvasRef)
// ================================================================================
// 全局钩子
// 生命周期钩子、监听钩子
// ================================================================================
// 生命周期钩子,SSG的SPA化实现,组件挂载后执行
// 用于进行必要的各类初始化操作
onMounted(() => { try {
// 语言包水合
lang.value = useLang()
// 语言刷新完毕,渲染加载框
my.loading(lang.value.OpenCVLoadingContent)
// 接下来做一些该WebApp的准备工作
// 阻止页面刷新和关闭,该方法不能阻止页面前进(跳转)、后退
window.addEventListener("beforeunload", beforeunloadHandler)
// 初始化数据结果resultRef
initResultData()
// 监听resultRef,实现表格数据resultTableDataRef刷新
watch(
[resultRef, resultTableCurrentPageRef, isResultReverseRef],
refreshResultTableData,
{
// 立即执行
immediate: true,
// 深度监听:2,(1是本体,2是子数组)
deep: 2
}
)
// 如果canvas没有初始化(第一次进入页面)
if (!canvasRef.value) {
// 注册一个监听钩子,用于实现canvasRef的初始化监听
// 解构赋值,得到监听钩子的stop()方法,用于停止监听
const { stop: stopCanvasWatch } = watch(
// 监听canvasRef
canvasRef,
// 回调
(newCanvas) => {
// 得确保新值均不为null,则完成初始化
if (newCanvas) {
// 停止监听
stopCanvasWatch()
// 获取canvas元素块的2d绘图上下文,赋值给全局对象
contactAngleObj.ctx = newCanvas.getContext(
// CanvasRenderingContext2D接口的2D渲染上下文
"2d",
// 为频繁读取做优化,但仅Gecko内核(FireFox浏览器)支持
{ willReadFrequently: true }
)
}
}
)
// 如果已经初始化了,则直接赋值
} else {
// 获取canvas元素块的2d绘图上下文,赋值给全局对象
contactAngleObj.ctx = canvasRef.value.getContext(
// CanvasRenderingContext2D接口的2D渲染上下文
"2d",
// 为频繁读取做优化,但仅Gecko内核(FireFox浏览器)支持
{ willReadFrequently: true }
)
}
// 导入OpenCV.js库
// 这是从@techstark/opencv-js库中导入cv对象,原库cv比较大,已改为重构建的OpenCV.js了,故注释掉
// const cvImportPromise = import("@techstark/opencv-js")
// // 等待OpenCV.js加载完成
// cvImportPromise.then((cvReadyPromise) => {
// // 动态导入钩子里面仍是个Promise对象,需要再then
// cvReadyPromise.default.then((cv) => {
// // 赋值给全局变量cv
// contactAngleObj.cv = cv
// // 停止加载框
// my.loading(false)
// })
// })
loadOpenCV().then((cvReady) => {
// 赋值给全局变量cv
contactAngleObj.cv = cvReady
// 停止加载框
my.loading(false)
})
// 注册一个对taskStatusRef的监听:
// 任务状态改变时,始终保持canvas滚动到视图中间
watch(taskStatusRef, nextTickFocusOnCanvas)
// 注册一个<canvas>长按的监听钩子
onLongPress(
// 监听对象:<canvas>
canvasRef,
// 回调钩子
onCanvasLongPress,
// 配置:长按时间
// { delay: 500 }
)
} catch (error) {
my.error("onMounted()报错:", error, errorDialog)
}})
/**
* 数据初始化
* 图表上呈现的数据,会强行让数据长度为8
*/
function initResultData() {
// 数据长度
const DATA_LENGTH = 8
// 从localStorage中读取
const resultDataStr = localStorage.getItem("contactAngleResult")
// 如果没有数据
if (!resultDataStr) {
// 直接跳出即可
return
}
/** 处理数据,将字符串转为JSON对象 @type { ResultDatum[] } */
const resultDataAoa = JSON.parse(resultDataStr)
// 数据检查
if(!dataInitCheck(resultDataAoa)) {
return
}
// 遍历数据
for (const resultDataArr of resultDataAoa) {
// 数据检查
if(!dataInitCheck(resultDataArr)) {
return
}
// 强行初始化数据长度
resultDataArr.length = DATA_LENGTH
}
// 检查完毕,赋值给resultRef
resultRef.value = resultDataAoa
/**
* 数据检查
* @param { ResultDatum | ResultDatum[] } dataArr 数据数组
*/
function dataInitCheck(dataArr) {
// 如果数据格式有问题,则清空数据
if (!Array.isArray(dataArr)) {
// 清空数据
localStorage.removeItem("contactAngleResult")
// 通知
my.message(lang.value.DataInitErrorContent)
// 跳出
return false
} else {
return true
}
}
}
/**
* 刷新数据呈现
* @param { [ResultDatum[], number, boolean] } 参数数组
* 包含以下内容:
* - [0] newResultAoa - 新结果数据
* - [1] newResultTablePage - 新页码数据
* - [2] newIsResultReverse - 新是否倒序数据
* - 结果数据:文件名、接触角均值、左接触角、右接触角、左右偏差、基线角度、椭圆拟合的决定系数R²
*/
function refreshResultTableData([newResultAoa, newResultTablePage, newIsResultReverse]) {
// 接参数
const resultAoaLength = newResultAoa.length
// 如果新数据为空,则清空表格数据
if (resultAoaLength === 0) {
// 刷新为空数组
resultTableDataRef.value = []
// 直接返回
return
}
// 接参数
const dataNumberPerPage = 10
// 初始化起止索引(startIndexRaw必大于等于0)
const endIndexRaw = newResultTablePage * dataNumberPerPage
const startIndexRaw = endIndexRaw - dataNumberPerPage
// 根据是否倒置,计算起止索引
const [startIndex, endIndex] =
newIsResultReverse
? [
Math.max(0, (resultAoaLength - endIndexRaw)),
(resultAoaLength - startIndexRaw)
]
: [
startIndexRaw,
Math.min(resultAoaLength, endIndexRaw)
]
// 接收新数据。这一步操作是为了避免原数组长度不足endIndex造成的bug
const resultTableDataAoaTemp = newResultAoa.slice(startIndex, endIndex)
/** 建立一个空数组,用于存放处理后的数据 @type { OrderResultDatum[] } */
const resultTableDataAoa = []
// 遍历取值 + 补一个原index
for (let i = 0; i < resultTableDataAoaTemp.length; i++) {
// 把原index加上,推进新数组里
resultTableDataAoa.push([(startIndex + i), ...resultTableDataAoaTemp[i]])
}
// 如果是倒序
if (newIsResultReverse) {
// 倒序处理
resultTableDataAoa.reverse()
}
// 赋值给表格数据
resultTableDataRef.value = resultTableDataAoa
}
/**
* 聚焦canvas
* 下个DOM渲染周期将canvas滚动到视图中
*/
function nextTickFocusOnCanvas() {
// 下个渲染周期执行focusOnCanvas()
nextTick(focusOnCanvas).catch((error) => {
my.error("nextTickFocusOnCanvas()报错:", error, errorDialog)
})
/**
* 聚焦canvas的内部方法
*/
function focusOnCanvas() {
// 接参数
const canvas = canvasRef.value
// 滚动到canvas
canvas.scrollIntoView({
// 平滑滚动
behavior: "smooth",
// 垂直中心对齐
block: "center",
// 水平就近对齐
inline: "nearest"
})
}
}
// 生命周期钩子,组件卸载前执行
// 用于进行必要的各类初始化操作
onBeforeUnmount(() => {
// 取消监听:用于阻止页面刷新和关闭
window.removeEventListener("beforeunload", beforeunloadHandler)
})
/**
* 页面关闭、后退或刷新的回调
* @param { Event } event 页面关闭或刷新事件
*/
function beforeunloadHandler(event) {
// 阻止默认行为
event.preventDefault()
// 取消默认事件:兼容方法
event.returnValue = false
}
/**
* 报错的通知方法
* @param { Error } error 报错信息
*/
function errorDialog(error) {
// 直接对话框报错
my.dialog({
theme: "danger",
header: lang.value.ErrorDialogTitle,
body: lang.value.ErrorDialogContent + error
})
}
/**
* 长按<canvas>触发的回调
* 步骤2、步骤3:清空<canvas>上的标记
*/
function onCanvasLongPress() { try {
// 获取任务进度
const taskStatus = taskStatusRef.value
// 任务进度为2或3时
if ((taskStatus === 2) || (taskStatus === 3)) {
// 清空canvas上的矩形标记数据
canvasMarkDataRemove()
// 恢复canvas原图
contactAngleObj.ctx.putImageData(contactAngleObj.imageData, 0, 0)
}
} catch (error) {
my.error("onCanvasLongPress()报错:", error, errorDialog)
}}
/**
* 点击<canvas>触发的回调
* 步骤2:选框
* 步骤3:遮罩
* 步骤4:绘制基线
*/
function onCanvasClick() { try {
// 获取任务进度
const taskStatus = taskStatusRef.value
// 任务进度为2时,即选框绘制阶段,则调用选框方法
if (taskStatus === 2) {
// 选框(选框可在步骤3复用,所以和绘图解耦了)
chooseRect()
// 绘图
drawRect()
// 任务进度为3时,即轮廓选择阶段,则调用轮廓过滤方法
} else if (taskStatus === 3) {
chooseMask()
// 任务进度为4时,即基线绘制阶段,则调用基线粗调方法
} else if (taskStatus === 4) {
// 基线粗调(基线粗调在步骤3复用,所以和刷新滑块/绘图解耦)
chooseBaseline()
// 接参数
const { baseline } = contactAngleObj
// 刷新细调滑块(这一步会触发绘图)
refreshBaselineSlider([baseline.left, baseline.right])
}
} catch (error) {
my.error("onCanvasClick()报错:", error, errorDialog)
}}
/**
* 清空canvas上的各类标记数据
*/
function canvasMarkDataRemove() {
// 接参数
const { rect, colLine, baseline } = contactAngleObj
// 清空选框的X和Y边界值
rect.xMax = null
rect.yMax = null
rect.xMin = null
rect.yMin = null
// 清空两侧遮罩的值
colLine.left = null
colLine.right = null
// 清空基线粗调的值
baseline.left = null
baseline.right = null
}
/**
* 设置canvas的绘图上下文ctx
*/
function ctxSetting() {
// 接参数
const { ctx, canvasScaling } = contactAngleObj
// 红色笔迹
ctx.strokeStyle = "red"
// 线宽:2像素 x 缩放比例
ctx.lineWidth = 2 * canvasScaling
// 填充色:灰色
ctx.fillStyle = "rgba(0, 0, 0, 0.7)"
}
/**
* 滑轨调节的事件回调钩子
* 步骤3:基线粗调 + 细调。此步骤下,用户只能操作滑轨,因此事件全部来源于滑轨。
* 步骤4:基线细调。此步骤下,用户点击canvas会触发绑定值的修改,也会触发该回调。
* @note 对于模型绑定,即便是其它操作修改了所绑定的值,也会触发值变化事件的回调。
*/
function onSliderChange() { try {
// 获取任务进度
const taskStatus = taskStatusRef.value
// 任务进度为3时,即确定轮廓阶段
if (taskStatus === 3) {
// 直接执行节流处理的轮廓查找方法
chooseContourThrottled()
// 任务进度为4时,即基线绘制阶段
} else if (taskStatus === 4) {
// 直接执行节流的绘制基线方法
drawBaselineThrottled()
}
} catch (error) {
my.error("onSliderChange()报错:", error, errorDialog)
}}
/**
* 调节滑轨操作刚停止的事件回调钩子
* 步骤3:基线细调。此步骤下,用户只能操作滑轨,因此事件全部来源于滑轨。
* 步骤4:基线细调,此步骤下,用户点击canvas会触发绑定值的修改,也会触发该回调。
* @note 步骤4的计算应以Ref对象为基准,而不是baseline对象。步骤4的逻辑归集到Ref对象了。
*/
function onSliderChangeEnd() { try {
// 接参数
const taskStatus = taskStatusRef.value
// 任务进度为3,且为细调状态
if ((taskStatus === 3) && (isContourCoarseRef.value === false)) {
// 调用进度3下的具体刷新滑轨方法
refreshContourFineSlider()
// 任务进度为4
} else if (taskStatus === 4) {
// 接参数
const [{ value: userLeftIntercept }, { value: userRightIntercept }] = interceptNumArrRef.value
// 执行滑轨数据刷新方法(会被动触发绘制基线),此处从滑轨取值,本身就是用户视角,无需显示再转为用户视角
refreshBaselineSlider([userLeftIntercept, userRightIntercept], false)
}
} catch (error) {
my.error("onSliderChangeEnd()报错:", error, errorDialog)
}}
// ================================ 步骤状态的函数方法 ================================
/**
* @步骤1 传图。该步骤的方法:
* 0. taskToStep1 - 任务切换。这个没啥要准备的。
* 1. onPicChange - 传图回调
*/
/**
* 任务进度切换到步骤1
*/
function taskToStep1() {
taskStatusRef.value = 1
}
/**
* 图片上传或改变时触发的回调
* @param { TDesign.UploadFile[] } event 事件对象
*/
async function onPicChange(event) { try {
// 如果是清空了照片,则把任务进度切换回1,并直接返回即可
if (event.length === 0) {
taskToStep1()
return
}
// 加载框
my.loading(lang.value.PicLoadingContent)
// 接对象
const { cv, ctx, matGray } = contactAngleObj
const canvas = canvasRef.value
// 接收文件名
contactAngleObj.filename = event[0].name
// 从第一个对象获取文件url
const fileURL = URL.createObjectURL(event[0].raw)
// 构造<img>元素
const imgElement = new Image()
// 在网页内隐藏<img>元素
imgElement.style.display = "none"
// 将图片路径挂载在<img>元素上
imgElement.src = fileURL
// 等待图片加载完成
await imgElement.decode()
// 读取图片文件为OpenCV的Mat对象
const matOrigin = cv.imread(imgElement)
// 读取完毕,销毁图片元素以释放内存
imgElement.remove()
// 如果全局灰度图Mat对象存在且有成员对象delete方法,则先删除
if (matGray?.delete) {
matGray.delete()
}
// 初始化全局灰度图Mat对象
contactAngleObj.matGray = new cv.Mat()
// 将原始图像Mat转为灰度Mat,赋值给全局灰度图Mat对象
// “cvtColor”即convert color
cv.cvtColor(
// 原图(输入)
matOrigin,
// 灰度图(输出)
contactAngleObj.matGray,
// 颜色空间转换代码:color:RGBA to gray
cv.COLOR_RGBA2GRAY,
// 通道数:0,即自动
0
)
// 将灰度图绘制到canvas上(该步骤后,需恢复canvas的上下文设置)
cv.imshow(canvas, contactAngleObj.matGray)
// 释放原图Mat的WASM内存
matOrigin.delete()
// 把canvas的原图保存好,以方便恢复
contactAngleObj.imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// 第一阶段完成,任务进度改为2(该步骤会恢复canvas的上下文设置)
taskToStep2()
// 停止加载框
my.loading(false)
} catch (error) {
// 停止加载框
my.loading(false)
// 报错处理
my.error("onPicChange()报错:", error, errorDialog)
}}
/**
* @步骤2 绘制选框
* 0. taskToStep2 - 任务切换
* 1. canvasFit - canvas尺寸适应方法
* 2. chooseRect - 中转方法 → Algorithm.computeRect(纯算法,已解耦)
* 3. drawRect - 绘制选框(OpenCV 绘图,留在 Vue)
* 4. onSureRect - 确认选框
*/
/**
* 任务进度切换到步骤2
*/
function taskToStep2() {
// 清空canvas上的矩形标记数据
canvasMarkDataRemove()
// 任务进度改为2
taskStatusRef.value = 2
// 下个DOM周期:调整canvas尺寸以适应屏幕
nextTick(canvasFit).catch((error) => {
my.error("taskToStep2().nextTick()报错:", error, errorDialog)
})
}
/**
* canvas尺寸适应方法:调整canvas的显示宽高
* canvas在步骤2时,有一个show的变化,需要根据图片大小重新进行缩放,以适应屏幕
* @note 调整canvasScaling后,需重新设置canvas的绘图上下文设置ctx(画笔粗细)
*/
function canvasFit() {
// 接参数
const canvas = canvasRef.value
// 接canvas父元素的最大内宽,赋值给全局变量
contactAngleObj.canvasStyleWidth = canvas.parentElement.clientWidth
// 设置canvas的显示宽度
canvas.style.width = canvas.parentElement.clientWidth + "px"
// 以canvas的真实宽度和显示宽度之比,赋值给全局对象的缩放比例变量
contactAngleObj.canvasScaling = canvas.width / contactAngleObj.canvasStyleWidth
// 更新canvas的显示高度
canvas.style.height = canvas.height / contactAngleObj.canvasScaling + "px"
// 调整宽高后,需重新设置canvas的上下文设置
ctxSetting()
}
/**
* 选框方法(中转方法)
* 用于更新选框的X、Y坐标边界值,赋值给全局rect对象
* 从 contactAngleObj 和 elementX/Y 中提取参数,调用纯算法 Algorithm.computeRect
*/
function chooseRect() {
// 接参数
const { canvasScaling, rect } = contactAngleObj
const canvas = canvasRef.value
// 点击位置的实际坐标
const clickX = elementX.value * canvasScaling
const clickY = elementY.value * canvasScaling
// 调用算法模块的计算选框方法(会直接修改rect对象)
Algorithm.computeRect({
clickX, clickY, canvasWidth: canvas.width, canvasHeight: canvas.height, rect
})
}
/**
* 绘制选框
*/
function drawRect() {
// 接参数
const { ctx, imageData, rect } = contactAngleObj
const canvas = canvasRef.value
// 先对选框进行初始化,去掉上一次的绘制
ctx.putImageData(imageData, 0, 0)
// 画一个全canvas遮罩
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 计算宽高
const rectWidth = rect.xMax - rect.xMin
const rectHeight = rect.yMax - rect.yMin
// 然后重绘选框中部
ctx.putImageData(
imageData, 0, 0,
rect.xMin, rect.yMin, rectWidth, rectHeight
)
// 最后直接绘制框
ctx.strokeRect(rect.xMin, rect.yMin, rectWidth, rectHeight)
}
/**
* "裁剪图片"或"完成裁剪"的回调方法
* @param { boolean } isDetermine 是否确定完成裁剪
*/
function onSureRect(isDetermine) { try {
// 接参数
const { cv, matGray, ctx, rect } = contactAngleObj
const canvas = canvasRef.value
// 如果有选框
if (rect.xMax) {
// 开始裁剪:确定裁剪区域
const rectRect = new cv.Rect(
rect.xMin,
rect.yMin,
rect.xMax - rect.xMin,
rect.yMax - rect.yMin
)
// 裁剪区域确定好了,可以清空裁剪标记了
canvasMarkDataRemove()
// 执行裁剪
const metCropped = matGray.roi(rectRect)
// 在canvas上绘制裁剪结果(需记得重新设置ctx)
cv.imshow(canvas, metCropped)
// 绘制完成,更新Mat灰度图对象matGray为裁剪后的灰度图Mat对象metCropped
matGray.delete()
contactAngleObj.matGray = metCropped
// 绘制后,更新canvas的显示缩放及高度(此处也会重新设置ctx)
canvasFit()
// 把canvas的原图保存好,以方便恢复
contactAngleObj.imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
}
// 如果是"完成裁剪"
if (isDetermine === true) {
// 则任务状态进展到"3"
taskToStep3()
}
} catch (error) {
my.error("onSureRect()报错:", error, errorDialog)
}}
/**
* @步骤3 选择轮廓
* 这一步很重要。有轮廓算法选择、轮廓参数调节(滑轨)、遮罩参数调节,这三个操作。
* 轮廓参数调节(滑轨)还有粗调/细调切换,遮罩参数调节也有中心遮罩/两边遮罩/基线遮罩切换。
* 然后在最后“确认轮廓”时,还会有轮廓到椭圆的拟合。
* 这里的拟合,均先采用快速算法。
* 业务逻辑思路上设定为:
* 0. 进入步骤3,初始化各类参数后,先执行一次[选择轮廓]方法,刷新一次轮廓渲染。
* 1. 切换轮廓算法、调节轮廓参数、切换轮廓参数粗/细调,都会执行[选择轮廓(节流)]方法,刷新一次轮廓渲染。
* 1.1 切换轮廓参数的粗/细调,会被动触发[选择轮廓(节流)]方法,因为改变了轮廓参数值,被事件监听器监听到。
* 2. 点击canvas调节遮罩参数,会执行[绘制遮罩]方法,刷新一次遮罩渲染。
* 3. 点击“确定轮廓”按钮,会执行[确定轮廓]方法,获得椭圆数据。进而绘制椭圆并进入步骤4。
* 该步骤的方法:
* 0. taskToStep3 - 任务切换
* 0.1 轮廓查找算法切换、粗调/细调切换、遮罩算法切换,恢复默认设置
* 0.2 初始化调参参数滑轨
* 0.3 清空遮罩数据
* 0.4 切换到状态3
* 0.5 nextTick:用[轮廓绘制]方法刷新一次轮廓渲染
* 1. thresholdNumArrRestore - 初始化调参参数滑轨(使用 Algorithm.deepCopyAoa)
* 2. chooseContour - 选择轮廓算法(调用 OpenCV 绘图)
* 3. getContour - 查找轮廓(OpenCV 绘图)
* 4. drawContour - 绘制轮廓(OpenCV 绘图)
* 5. chooseMask - 选择遮罩(中转方法 → Algorithm.computeColLine / Algorithm.computeBaseline / Algorithm.computeRect)
* 6. drawMask - 绘制遮罩(OpenCV 绘图)
* 7. onContourSliderCoarseFineToggle - 轮廓参数粗/细调切换
* 8. refreshContourFineSlider - 中转方法 → Algorithm.computeContourFineSliderRange
* 9. onDetermineContour - 确定轮廓(中转方法 → Algorithm.filterContourPoints / Algorithm.getEllipse)
* 10. drawEllipse - 绘制椭圆(OpenCV 绘图)
*/
/**
* 任务进度切换到步骤3
* 1. 把各类参数恢复为默认设置
* 2. 下个DOM周期,调用轮廓查找方法刷新一次轮廓渲染
*/
function taskToStep3() {
// 轮廓查找算法恢复默认设置
// contourAlgorithmRadioRef.value = 0
// 粗调/细调恢复默认设置
isContourCoarseRef.value = true
// 中心遮罩/两边遮罩恢复默认设置
contourFilterAlgorithmRadioRef.value = 0
// 初始化调参参数滑轨
thresholdNumArrRestore()
// 清空遮罩数据
canvasMarkDataRemove()
// 切换到状态3
taskStatusRef.value = 3
// 下一个DOM周期:用轮廓查找方法刷新一次轮廓渲染
nextTick(chooseContour).catch((error) => {
my.error("taskToStep3().nextTick()报错:", error, errorDialog)
})
}
/**
* 初始化步骤3调参参数滑轨
* 如果滑轨Ref值为空,则全部初始化
* 否则保留每个传参的当前值,对范围、步进等初始化
*/
function thresholdNumArrRestore() {
// 接参数
const thresholdNumArr = thresholdNumArrRef.value
// 如果滑轨参数为空,则初始化滑轨参数
if (thresholdNumArr.length === 0) {
// 空数组,用于存数据
const thresholdNumArrTemp = []
// 直接从滑轨副本赋值即可
for (let i = 0; i < thresholdNumArrConst.length; i++) {
// 解构赋值
const thresholdNumTemp = { ...thresholdNumArrConst[i] }
// marks数组要再次解构,否则只是内存空间的指针引用
thresholdNumTemp.marks = [...thresholdNumArrConst[i].marks]
// 推进数组
thresholdNumArrTemp.push(thresholdNumTemp)
}
// 赋值
thresholdNumArrRef.value = thresholdNumArrTemp
// 否则,保留每个参数的取值
} else {
// 空数组,用于存数据
const thresholdNumArrTemp = []
// 从滑轨副本赋值(取值不能赋值)
for (let i = 0; i < thresholdNumArrConst.length; i++) {
// 解构赋值
const thresholdNumTemp = { ...thresholdNumArrConst[i] }
// marks数组要再次解构,否则只是内存空间的指针引用
thresholdNumTemp.marks = [...thresholdNumArrConst[i].marks]
// 用当前值覆盖
thresholdNumTemp.value = thresholdNumArr[i].value
// 推进数组
thresholdNumArrTemp.push(thresholdNumTemp)
}
// 赋值
thresholdNumArrRef.value = thresholdNumArrTemp
}
}
/**
* 选择轮廓
* 会调用[查找轮廓]和[绘制遮罩]方法
*/
function chooseContour() {
// 节流的bug防范:只有在"3"任务状态时,才允许执行
// 这可以防止进入步骤4的瞬间执行此方法,造成canvas不正常的刷新回退
if (taskStatusRef.value !== 3) { return }
// 获取轮廓
const [metVectorContours, metHierarchy] = getContour()
// 绘制轮廓
drawContour([metVectorContours, metHierarchy])
// 绘制完毕,销毁Met对象以释放WASM内存
metVectorContours.delete()
metHierarchy.delete()
// 最后绘制遮罩
drawMask()
}
/**
* 选择轮廓方法的节流方法
*/
const chooseContourThrottled = useThrottleFn(chooseContour, 500, true)
/**
* 获取轮廓(OpenCV 绘图)
* 1. 先用2种算法(中的一个)得到二值化轮廓图
* 2. 然后根据传参寻找轮廓
* @returns { [CV.MatVector, CV.Mat] } 轮廓AOA数组和轮廓层次结构
* @note 返回的2个对象,务必记得在用完后手动删除,否则会一直占用WASM内存
*/
function getContour() {
// 接参数
const { cv, matGray } = contactAngleObj
const contourAlgorithmRadio = contourAlgorithmRadioRef.value
// 接阈值数组参数
const [{ value: mainParam }, { value: auxParam }] = thresholdNumArrRef.value
// 初始化一个二值化图的过渡对象
const matBinary = new cv.Mat()
// 选框为0,则为Canny算法
if (contourAlgorithmRadio === 0) {
// Canny算法,边缘检测,赋值给全局变量matObj.binary
// 详见:https://docs.opencv.ac.cn/4.12.0/d7/de1/tutorial_js_canny.html
cv.Canny(
// 灰度图
matGray,
// 输出:二值化Canny边缘检测图
matBinary,
// 阈值minVal:辅助参数
auxParam,
// 阈值maxVal:主参数
mainParam,
// apertureSize,Sobel 算子的孔径大小
3,
// L2gradient,是否使用更精确的L2范数计算图像梯度:不启用
false
)
// 选框不为0(即为1),则为Threshold方法
} else {
// Threshold算法,将灰度图转为二值化图,赋值给全局变量matObj.binary
cv.threshold(
// 灰度图
matGray,
// 输出数组(二值化图)
matBinary,
// 阈值:主参数
mainParam,
// 用于THRESH_BINARY和THRESH_BINARY_INV阈值类型的最大值
255,
// 阈值类型
// 参见:https://docs.opencv.ac.cn/4.12.0/d7/d1b/group__imgproc__misc.html#gaa9e58d2860d4afa658ef70a9b1115576
cv.THRESH_BINARY_INV
)
}
// 用获得的二值化对象寻找轮廓
// 初始化轮廓AOA数组metVectorContours
const metVectorContours = new cv.MatVector()
// 初始化轮廓层次结构metHierarchy
const metHierarchy = new cv.Mat()
// cv.findContours()方法,查找轮廓
// 详见:https://docs.opencv.org/4.12.0/d5/daa/tutorial_js_contours_begin.html
cv.findContours(
// 二值化的Mat图像
matBinary,
// 轮廓。AOA的Mat对象数组,即MatVector
metVectorContours,
// 轮廓层次结构,即各轮廓间的拓扑关系
// 如圈形图像会有内轮廓和外轮廓,则内外轮廓间会存在包含与被包含等关系
// 轮廓层次结构即建立“轮廓树”,以层级化的方式描述轮廓之间的关系
metHierarchy,
// 轮廓检索模式
// RETR_EXTERNAL => retrieval external - 只检索最外层的轮廓
// cv.RETR_EXTERNAL - 只检索最外面的轮廓
// cv.RETR_LIST - 检索所有的轮廓,但不创建轮廓的拓扑结构
// cv.RETR_CCOMP - 检索所有的轮廓,并将它们组织成两级层次结构
// cv.RETR_TREE - 检索所有的轮廓,并重建完整的轮廓层次结构
// cv.RETR_FLOODFILL => retrieval flood fill - 使用洪水填充算法
cv.RETR_LIST,
// 轮廓近似法
// CHAIN_APPROX_NONE => chain approx none - 不作近似处理,存储所有轮廓点
cv.CHAIN_APPROX_NONE
)
// 轮廓获取完毕,销毁二值化Mat对象
matBinary.delete()
// 返回轮廓AOA数组metVectorContours和轮廓层次结构metHierarchy
return [metVectorContours, metHierarchy]
}
/**
* 绘制轮廓(OpenCV 绘图)
* @param { [CV.MatVector, CV.Mat] } 轮廓AOA数组和轮廓层次结构
* @note 传参的2个对象,务必记得在用完后手动删除,否则会一直占用WASM内存
*/
function drawContour([metVectorContours, metHierarchy]) {
// 接参数
const { cv, matGray, canvasScaling, ctx } = contactAngleObj
const canvas = canvasRef.value
// 从灰度图拷贝一个原画布,用于绘制轮廓
const matContoursHandleMat = new cv.Mat()
matGray.copyTo(matContoursHandleMat)
// 定义轮廓颜色为白色
const contoursColor = new cv.Scalar(255, 255, 255)
// 轮廓粗细
const contoursThickness = 2 * canvasScaling
// 在画布上绘制所有轮廓(轮廓索引传参为-1即可)
cv.drawContours(
// Mat画布
matContoursHandleMat,
// 轮廓组
metVectorContours,
// 轮廓索引(-1就是全部)
-1,
// 轮廓颜色
contoursColor,
// 轮廓线条粗细
contoursThickness,
// 轮廓线条类型,维持默认即可
undefined,
// 轮廓层次结构
metHierarchy,
// 轮廓层数,本次不存在层次结构,维持默认即可
undefined,
)
// 把画布图案绘制在canvas上
cv.imshow(canvas, matContoursHandleMat)
// 重新设置ctx,方便用户交互操作
ctxSetting()
// 绘制完毕,销毁Met对象以释放WASM内存
matContoursHandleMat.delete()
// 把canvas的原图保存好,以方便恢复
contactAngleObj.imageData = ctx.getImageData(
0, 0, canvas.width, canvas.height
)
}
/**
* 轮廓算法切换的回调钩子
*/
function onContourAlgorithmSwitch() { try {
chooseContourThrottled()
} catch (error) {
my.error("onContourAlgorithmSwitch()报错:", error, errorDialog)
}}
/**
* 选择遮罩(中转方法)
* 根据遮罩种类设置,调用对应的纯算法函数,然后刷新 canvas 并绘制遮罩
*/
function chooseMask() {
// 接参数
const { ctx, imageData, canvasScaling, colLine, rect, baseline } = contactAngleObj
const canvas = canvasRef.value
const contourFilterAlgorithmRadio = contourFilterAlgorithmRadioRef.value
// 点击位置的实际坐标
const clickX = elementX.value * canvasScaling
const clickY = elementY.value * canvasScaling
// 选框值为0:基线遮罩
if (contourFilterAlgorithmRadio === 0) {
Algorithm.computeBaseline({
clickX, clickY, canvasWidth: canvas.width, canvasHeight: canvas.height, baseline
})
// 选框值为1:两边遮罩
} else if (contourFilterAlgorithmRadio === 1) {
Algorithm.computeColLine({ clickX, canvasWidth: canvas.width, colLine })
// 选框值为其它(2):中心遮罩
} else {
// 调用算法模块的计算选框方法(会直接修改rect对象)
Algorithm.computeRect({
clickX, clickY, canvasWidth: canvas.width, canvasHeight: canvas.height, rect
})
}
// 刷新canvas,去掉之前画的遮罩
ctx.putImageData(imageData, 0, 0)
// 绘制遮罩
drawMask()
}
/**
* 选线方法
* @note 会触发绘制基线截距
*/
function chooseBaseline() {
// 接参数
const { canvasScaling, baseline } = contactAngleObj
const canvas = canvasRef.value
// 点击位置的实际坐标
const clickX = elementX.value * canvasScaling
const clickY = elementY.value * canvasScaling
// 调用选线算法,就地修改baseline对象
Algorithm.computeBaseline({
clickX, clickY,
canvasWidth: canvas.width, canvasHeight: canvas.height,
baseline
})
}
/**
* 绘制遮罩(OpenCV 绘图)
*/
function drawMask() {
// 接参数
const { ctx, colLine, rect, baseline } = contactAngleObj
const canvas = canvasRef.value
// 左边的线
if (colLine.left !== null) {
// 左边阴影区
ctx.fillRect(0, 0, colLine.left, canvas.height)
// 绘制左边线:开始绘制
ctx.beginPath()
// 起点坐标
ctx.moveTo(colLine.left, 0)
// 终点坐标
ctx.lineTo(colLine.left, canvas.height)
// 连线
ctx.stroke()
}
// 右边的线
if (colLine.right !== null) {
// 右边阴影区
ctx.fillRect(colLine.right, 0, canvas.width - colLine.right, canvas.height)
// 绘制右边线:开始绘制
ctx.beginPath()
// 起点坐标
ctx.moveTo(colLine.right, 0)
// 终点坐标
ctx.lineTo(colLine.right, canvas.height)
// 连线
ctx.stroke()
}
// 中间遮罩区
if (rect.xMin !== null) {
// 计算宽高
const rectW = rect.xMax - rect.xMin
const rectH = rect.yMax - rect.yMin
// 中间阴影区
ctx.fillRect(rect.xMin, rect.yMin, rectW, rectH)
// 中间线框
ctx.strokeRect(rect.xMin, rect.yMin, rectW, rectH)
}
// 基线遮罩区
if (baseline.left !== null) {
// 开始绘制
ctx.beginPath()
// 四个点
ctx.moveTo(0, baseline.left)
ctx.lineTo(canvas.width, baseline.right)
ctx.lineTo(canvas.width, canvas.height)
ctx.lineTo(0, canvas.height)
// 闭合路径
ctx.closePath()
// 填充
ctx.fill()
// 绘制基线
ctx.beginPath()
// 左上角,左截距点
ctx.moveTo(0, baseline.left)
// 右上角,右截距点
ctx.lineTo(canvas.width, baseline.right)
// 画线
ctx.stroke()
}
}
/**
* 轮廓粗/细调的切换的回调钩子
* @note 会被动触发绘制轮廓
*/
function onContourSliderCoarseFineToggle() { try {
// 如果目前是细调,则要修改为粗调
if (isContourCoarseRef.value === false) {
thresholdNumArrRestore()
// 如果目前是粗调,要改为细调
} else {
// 刷新细调滑块(这一步会触发绘图)
refreshContourFineSlider()
}
// 更新标记
isContourCoarseRef.value = !isContourCoarseRef.value
} catch (error) {
my.error("onContourSliderCoarseFineToggle()报错:", error, errorDialog)
}}
/**
* 步骤3里刷新细调滑块的具体方法
* @note 会触发绘制轮廓
*/
function refreshContourFineSlider() {
// 接收主参数和辅助参数
const [{ value: mainParam }, { value: auxParam }] = thresholdNumArrRef.value
// 找主参数的下限:主参数的下限必须不小于0,不大于243
const mainParamMin = Math.max(0, Math.min(243, (mainParam - 6)))
// 找辅助参数的下限:辅助参数的下限必须不小于0,不大于243
const auxParamMin = Math.max(0, Math.min(243, (auxParam - 6)))
/** 细调的阈值数组 @type { SliderParam[] } */
const thresholdNumArrTemp = [{
// 主参数:当前值、下限、上限、mark标记
value: mainParam,
min: mainParamMin,
max: mainParamMin + 12,
marks: [mainParamMin, mainParamMin + 4, mainParamMin + 8, mainParamMin + 12]
}, {
// 辅助参数:当前值、下限、上限、mark标记
value: auxParam,
min: auxParamMin,
max: auxParamMin + 12,
marks: [auxParamMin, auxParamMin + 4, auxParamMin + 8, auxParamMin + 12]
}]
// 赋值(这一步会触发绘图)
thresholdNumArrRef.value = thresholdNumArrTemp
}
/**
* 确定轮廓(中转方法)
* 调用纯算法 Algorithm.filterContourPoints 和 Algorithm.getEllipse
*/
function onDetermineContour() { try {
// 加载框
my.loading(lang.value.ContourFitLoadingContent)
// 接参数
const { cv, colLine, rect, baseline } = contactAngleObj
const { width: canvasWidth, height: canvasHeight } = canvasRef.value
// 获取轮廓
const [metVectorContours, metHierarchy] = getContour()
// 轮廓层次结构Mat对象不需要,销毁以释放WASM内存
metHierarchy.delete()
// 提取轮廓点
const rawContourPointAoa = Algorithm.getContourPoints({
metVectorContours, colLine, rect,
canvasWidth, canvasHeight
})
// 轮廓点集合MetVoctor对象用过了,不需要了,销毁以释放WASM内存
metVectorContours.delete()
// 过滤轮廓点,获取轮廓点集合P(0)和轮廓点到基线的距离数组
const {
contourPointAoa,
contourPointToBaselineDistanceArr
} = Algorithm.baselineFilterContourPoints({
rawContourPointAoa, baseline, canvasWidth, canvasHeight
})
// 获取椭圆数据
const {
ellipse,
baselineReferencePoint,
R2Arr,
resultType,
...restEllipseObj
} = Algorithm.getEllipse({ cv, contourPointAoa, contourPointToBaselineDistanceArr })
// 写回全局对象
contactAngleObj.ellipse = ellipse
contactAngleObj.ellipseR2 = R2Arr[R2Arr.length - 1]
contactAngleObj.resultType = resultType
contactAngleObj.baselineReferencePoint = baselineReferencePoint
// 绘制椭圆
drawEllipse()
// 进入步骤4
taskToStep4()
// 关闭加载框
my.loading(false)
} catch (error) {
// 关闭加载框
my.loading(false)
// 报错处理
my.error("onDetermineContour()报错:", error, errorDialog)
}}
/**
* 绘制椭圆(OpenCV 绘图)
*/
function drawEllipse() {
// 接参数
const { cv, matGray, canvasScaling, ctx, ellipse } = contactAngleObj
const canvas = canvasRef.value
// 拷贝一个灰度图的原画布Mat对象
const matEllipseHandle = new cv.Mat()
matGray.copyTo(matEllipseHandle)
// 定义椭圆的颜色为白色
const ellipseColor = new cv.Scalar(255, 255, 255)
// 轮廓粗细
const ellipseThickness = 2 * canvasScaling
// 凑个新的椭圆axes,因为椭圆绘制方法传参需要椭圆主轴尺寸一半的axes
const ellipseAxes = new cv.Size(
// width
ellipse.size.width * 0.5,
// height
ellipse.size.height * 0.5
)
// 绘制一个椭圆Mat对象
cv.ellipse(
// img:Mat对象
matEllipseHandle,
// center:椭圆中心点坐标
ellipse.center,
// axes:主轴尺寸的一半
ellipseAxes,
// angle:椭圆旋转角度(以度为单位)
ellipse.angle,
// startAngle:椭圆弧的起始角度(以度为单位)
0,
// endAngle:椭圆弧的结束角度(以度为单位)
360,
// color:椭圆颜色
ellipseColor,
// thickness:椭圆线条粗细,如果为负值,则表示填充椭圆
ellipseThickness,
// lineType:线条类型
cv.LINE_AA
)
// 渲染到画布上
cv.imshow(canvas, matEllipseHandle)
// 绘制完毕,销毁Mat对象以释放WASM内存
matEllipseHandle.delete()
// 恢复ctx设置
ctxSetting()
// 把canvas的原图保存好
contactAngleObj.imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
}
/**
* @步骤4 选择基线
* 用户有2种交互:点击canvas(粗调,单次)和拖动滑轨(细调,连续)。
* 粗调:需要手动修改滑轨的值 + 滑轨上下限等,进而触发绘图。
* 细调:直接修改了滑轨的值(进而触发绘图),但是没有修改上下限,
* 所以细调停止(changeEnd)的时候要修改上下限,这会进一步触发二次绘图。
* 绘图触发不可控,所以必然要节流处理。
* 该步骤还有一个难点:canvas里,零点在左上角,而用户习惯于左下角。
* 滑轨肯定跟着用户习惯走,所以绘图时需要翻转坐标轴。
* 该步骤的方法:
* 0. taskToStep4 - 初始化方法
* 1. initialBaseline - 中转方法 → Algorithm.computeInitialBaseline
* 2. drawBaseline - 绘制基线方法(OpenCV 绘图)
* 3. refreshBaselineSlider - 中转方法 → Algorithm.computeBaselineSliderRange
* 4. onBackToStep3 - 返回步骤3的方法
* *. onDetermineBaseline - 确定基线(中转方法 → Algorithm.calculateContactAngle)
*/
/**
* 步骤4的初始化方法
*/
function taskToStep4() {
// 初始化截距数组(在滑轨没加载的情况下,不会触发绘图)
initialBaseline()
// 状态机切换到4
taskStatusRef.value = 4
// 下个DOM周期:绘制基线
nextTick(drawBaseline).catch((error) => {
my.error("taskToStep4().nextTick()报错:", error, errorDialog)
})
}
/**
* 初始化基线截距的值
* 会读取椭圆角度、有效轮廓最低点坐标、canvas宽高
* 以椭圆旋转的角度的tan值来得到初始化的截距
* 这一截距是视椭圆为“正”的
*/
function initialBaseline() {
// 接参数
const { ellipse, baselineReferencePoint, baseline } = contactAngleObj
const canvas = canvasRef.value
// 如果用户没指定基线,则需要自动计算
if (baseline.left === null) {
// 拟合得到的椭圆一般来说是“正”的
// 也就是接近90°的倍数,比如263°、92°等。不会出现极端“歪”的情况,如45°这样
// 可以先对90°取余,余下的数如果小于45°(92°的情况),则直接用余数
// 如果余下的数大于45°(263°的情况),则再减90°
// 取余
const angleRemainder = ellipse.angle % 90
// 确定基线角度
const baselineAngle = (angleRemainder <= 45) ? angleRemainder : (angleRemainder - 90)
// 确定基线截距
const baselineIntercept = Math.tan(baselineAngle * Math.PI / 180)
// 构建了一个方程:y - bp.y = baselineIntercept * (x - bp.x)
// 把x = 0,x = canvasWidth代入,得到2个y值,即为截距
const leftIntercept = baselineIntercept * (0 - baselineReferencePoint[0]) + baselineReferencePoint[1]
const rightIntercept =
baselineIntercept * (canvas.width - baselineReferencePoint[0]) + baselineReferencePoint[1]
// 根据赋值刷新滑块(这一步不能触发绘图,因为务必确保此时滑轨组件没加载)
refreshBaselineSlider([leftIntercept, rightIntercept])
// 如果用户指定了基线,则直接用用户指定的值
} else {
// 根据赋值刷新滑块(这一步不能触发绘图,因为务必确保此时滑轨组件没加载)
refreshBaselineSlider([baseline.left, baseline.right])
}
}
/**
* 步骤4里绘制基线的具体方法(OpenCV 绘图)
*/
function drawBaseline() {
// 接参数
const { ctx, imageData } = contactAngleObj
const [{ value: leftIntercept }, { value: rightIntercept }] = interceptNumArrRef.value
const canvas = canvasRef.value
// 计算真正的截距(canvas视角的y值)
const realLeftY = canvas.height - leftIntercept
const realRightY = canvas.height - rightIntercept
// 先恢复原图
ctx.putImageData(imageData, 0, 0)
// 然后直接绘图即可
ctx.beginPath()
// 起点坐标
ctx.moveTo(0, realLeftY)
// 终点坐标
ctx.lineTo(canvas.width, realRightY)
// 连线
ctx.stroke()
}
/**
* 绘制基线方法的节流方法
* @note 给drawBaseline做了节流处理,以防止频繁调用
*/
const drawBaselineThrottled = useThrottleFn(drawBaseline, 200, true)
/**
* 步骤4里刷新滑块数据的具体方法
* @param { [number, number] } 左截距和右截距数据
* @param { boolean } [isConvertToUser = true] 是否需要转换成用户视角
* @note baseline对象的值是canvas视角的y值,当使用baseline对象传参时,需要转换成用户视角;
* 滑轨组件的值是用户视角的y值,当使用滑轨组件传参时,需显式声明不需转换
* @note 会触发绘制基线截距
*/
function refreshBaselineSlider([leftInterceptRaw, rightInterceptRaw], isConvertToUser = true) {
// 接参数
const canvas = canvasRef.value
// 把截距改为用户视角,并取整
const leftIntercept =
isConvertToUser
? Math.round(canvas.height - leftInterceptRaw)
: Math.round(leftInterceptRaw)
const rightIntercept =
isConvertToUser
? Math.round(canvas.height - rightInterceptRaw)
: Math.round(rightInterceptRaw)
// 根据高计算截距的上下限范围,目前以高的1/15为限度。
// 截距需能被6整除。所以是除以90。
// 整除后,乘以2是阶梯的宽度,乘以3是mark的宽度。
// 除此以外,得确保截距最小单位起码是1。
const delta = Math.max(Math.floor(canvas.height / 90), 1)
// 左截距的下限
const leftParamMin = leftIntercept - (delta * 3)
// 右截距的下限
const rightParamMin = rightIntercept - (delta * 3)
// 细调的阈值数组
/** @type { SliderParam[] } */
const interceptNumArr = [{
// 左截距:当前值、下限、上限、mark标记
value: leftIntercept,
min: leftParamMin,
max: (leftParamMin + (delta * 6)),
marks: [
leftParamMin, (leftParamMin + (delta * 2)),
(leftParamMin + (delta * 4)), (leftParamMin + (delta * 6))
]
}, {
// 右截距:当前值、下限、上限、mark标记
value: rightIntercept,
min: rightParamMin,
max: (rightParamMin + (delta * 6)),
marks: [
rightParamMin, (rightParamMin + (delta * 2)),
(rightParamMin + (delta * 4)), (rightParamMin + (delta * 6))
]
}]
// 赋值(这一步会触发绘图)
interceptNumArrRef.value = interceptNumArr
}
/**
* 步骤4里返回上一步的事件回调钩子
*/
function onBackToStep3() { try {
// 直接返回上一步即可
// 不能初始化上一步,如果初始化的话,已有轮廓数据的暂存参数设置就会丢失
taskStatusRef.value = 3
// 下一个DOM周期:用轮廓查找方法刷新一次轮廓渲染
nextTick(chooseContour).catch((error) => {
my.error("onBackToStep3().nextTick()报错:", error, errorDialog)
})
} catch (error) {
my.error("onBackToStep3()报错:", error, errorDialog)
}}
/**
* @步骤5 计算接触角
* OpenCV.js的椭圆拟合方法:fitEllipse()、fitEllipseAMS()和fitEllipseDirect():
* 1. fitEllipse():基于最小二乘法拟合旋转矩形,再转换为椭圆参数;无显式椭圆约束;可能输出非椭圆结果。
* 2. fitEllipseAMS():近似均方(Approximate Mean Square, AMS)方法求解,可迭代优化,最小化几何距离误差;
* 强制满足椭圆判别式(B² - 4AC < 0);严格保证椭圆解。
* 3. fitEllipseDirect():直接最小二乘法(Direct Least Squares)求解(基于Fitzgibbon 1991的闭式解)。
* 通过约束(4AC - B² = 1)以消除尺度模糊性;严格保证椭圆解。
* 详见:https://docs.opencv.ac.cn/4.12.0/d3/dc0/group__imgproc__shape.html
* 该步骤的方法:
* 1. onDetermineBaseline - 确定基线(中转方法 → Algorithm.calculateContactAngle)
* 2. onDeleteUniResult - 删除单个接触角结果
* 3. onDeleteAllResult - 清空所有接触角结果
* 4. onDownloadResult - 下载数据
*/
/**
* 椭圆方程:
* [x / (w/2)]² + [y / (h/2)]² = 1
* 以 x = r·cosθ ,y = r·sinθ 代入,得:
* r²·{[(2·cosθ)/w]²+[(2·sinθ)/h]²} = 1
* 一些公式:
* R² = SSR / SST
* = 平方和[(r拟合值 - r均值)²] / 平方和[(r个体值 - r均值)²]
* SST = SSR + SSE
* 平方和[(r个体值 - r均值)²] = 平方和[(r拟合值 - r均值)²] + 平方和[(r个体值 - r拟合值)²]
*/
/**
* 步骤4-5里确认基线的事件回调钩子
* 会计算接触角
*/
function onDetermineBaseline() { try {
// 接截距值
const [{ value: leftIntercept }, { value: rightIntercept }] = interceptNumArrRef.value
// 接参数
const { width: canvasWidth, height: canvasHeight } = canvasRef.value
// 接椭圆对象
const { ellipse } = contactAngleObj
// 计算接触角
const result = Algorithm.calculateContactAngle({
ellipse,
leftIntercept,
rightIntercept,
canvasWidth, canvasHeight
})
// 将结果写入结果ref对象和本地localStorage
resultRef.value.push([
contactAngleObj.filename,
result.contactAngleAverage,
result.contactAngleDeviation,
result.contactAngleLeft,
result.contactAngleRight,
result.interceptAngle,
contactAngleObj.ellipseR2,
contactAngleObj.resultType
])
localStorage.setItem("contactAngleResult", JSON.stringify(resultRef.value))
// 发个通知
my.dialog(
lang.value.ResultDialogContent[0]
+ result.contactAngleAverage.toFixed(2)
+ lang.value.ResultDialogContent[1],
)
} catch (error) {
// 处理纯算法模块抛出的特定错误消息
if (error.message === "方程没有2个解") {
my.message({ theme: "error", content: lang.value.ContactErrorMessageContent, duration: 10000 })
return
}
// 其它错误直接处理
my.error("onDetermineBaseline()报错:", error, errorDialog)
}}
/**
* 删除单个数据结果
* @param { number } resultsIndex 结果的索引
*/
function onDeleteUniResult(resultsIndex) { try {
// 接参数
const result = resultRef.value
// 弹出确认框
my.dialog({
// 主题:警示
theme: "danger",
// 通知内容
body: lang.value.DeleteUniResultDialogContent,
// 确认按钮的文本
confirmBtn: lang.value.DeleteResultDialogConfirmBtnLabel,
// 取消按钮的文本
cancelBtn: lang.value.DeleteResultDialogCancelBtnLabel,
// 确认后的回调
onConfirmCallBack: () => {
// 删除result的对应项
result.splice(resultsIndex, 1)
// 更新localStorage
localStorage.setItem("contactAngleResult", JSON.stringify(result))
// 提示用户
my.message(lang.value.DeleteUniResultMessageContent)
}
})
} catch (error) {
my.error("onDeleteUniResult()报错:", error, errorDialog)
}}
/**
* 删除全部数据结果
*/
function onDeleteAllResult() { try {
// 接参数
const result = resultRef.value
// 弹出确认框
my.dialog({
// 主题:警示
theme: "danger",
// 通知内容
body: lang.value.DeleteAllResultDialogContent,
// 确认按钮的文本
confirmBtn: lang.value.DeleteResultDialogConfirmBtnLabel,
// 取消按钮的文本
cancelBtn: lang.value.DeleteResultDialogCancelBtnLabel,
// 确认后的回调
onConfirmCallBack: () => {
// 删除result的所以项
result.length = 0
// 清理localStorage
localStorage.removeItem("contactAngleResult")
// 提示用户
my.message(lang.value.DeleteUniResultMessageContent)
}
})
} catch (error) {
my.error("onDeleteAllResult()报错:", error, errorDialog)
}}
/**
* 结果正序/倒序排序的回调
*/
function onReverseResultOrder() { try {
// 直接反转即可
isResultReverseRef.value = !isResultReverseRef.value
} catch (error) {
my.error("onReverseResultOrder()报错:", error, errorDialog)
}}
/**
* 下载结果
*/
function onDownloadResult() { try {
/** 接一个AOA对象,第一个元素是表头,后面是数据 @type { (string | number)[][] } */
const resultAoa = [[...lang.value.ResultTableContent]]
// 填充数据:遍历resultRef.value
const resultOrigin = resultRef.value
for (let i = 0; i < resultOrigin.length; i++) {
// 将代理对象转成普通数组
const resultArr = [(i + 1), ...resultOrigin[i]]
// 将结果数组推入AOA对象
resultAoa.push(resultArr)
}
// 建立工作表文件的Map对象
const resultMap = new Map()
// 把数据结果AOA数组加进Map里
resultMap.set(lang.value.ResultSheetLabel, resultAoa)
// AOA数据的Map对象转成xlsx文件
const workbook = aoaMapToWorkbook(resultMap)
// 下载xlsx文件
downloadXlsx(workbook, "contact-angle_data.xlsx")
} catch (error) {
my.error("onDownloadResult()报错:", error, errorDialog)
}}
</script>
<!--
样式层
-->
<style lang="css" scoped>
/* 让表格内文字居中 */
td, th {
text-align: center;
vertical-align: middle;
}
</style>