"use strict"
* 接触角求解的纯算法模块
* 接触角业务中的“纯计算”逻辑,模块内部分为以下几个业务区域:
* 0. 导入导出方法
* getEllipse() 椭圆拟合算法,即TIR-DT算法的完整模块
* 1. 选框/遮罩相关工具方法
* computeRect() 选框坐标计算
* computeBaseline() 基线计算
* computeColLine() 选线方法。选择轮廓左右两侧的过滤线
* 2. 轮廓拟合算法
* filterContourPoints() 轮廓点过滤。拟合之前过滤掉遮罩及基线下方的点
* getEllipseOld() 椭圆拟合。旧的椭圆拟合算法,已废弃
* 3. 接触角角度求解算法
* calculateContactAngle() 接触角计算
* 4. 其它工具函数
*/
export { getEllipse } from './ContactAngle-algorithm-dtirdt.js'
* @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坐标
*/
* computeRect 选框坐标计算
* 根据点击位置更新选框的 X、Y 坐标边界值
* 设计思路:
* - 第一次点击时,以点击位置为中心,生成一个初始矩形框(宽高比约 3:2)
* - 后续点击时,根据点击位置与当前选框的几何关系,动态调整选框边界:
* * 在框外 → 直接扩展对应边界
* * 在框内 → 通过"交叉线斜率比较"判断点位于哪个象限,然后缩进对应边界
* @note 选框的 X、Y 坐标边界值会直接修改传入的 rect 对象
* @param { object } param
* @param { number } param.clickX - 点击位置的实际 X 坐标(已乘以缩放比)
* @param { number } param.clickY - 点击位置的实际 Y 坐标(已乘以缩放比)
* @param { number } param.canvasWidth - canvas 实际宽度
* @param { number } param.canvasHeight - canvas 实际高度
* @param { Rect } param.rect - 当前的选框坐标对象(会被当场修改)
* @param { number } [param.RECT_SCALE = 0.5] - 画框相对短边的比例,默认为 0.5
* @param { number } [param.RECT_X_TO_Y = 2 / 3] - 画框的Y比X的值,默认为 2/3
*/
export function computeRect({
clickX, clickY, canvasWidth, canvasHeight, rect,
RECT_SCALE = 0.5,
RECT_X_TO_Y = 2 / 3
}) {
if (!rect.xMax) {
const rectHalfX = Math.min(
canvasWidth * RECT_SCALE * 0.5,
canvasHeight * RECT_SCALE * 0.5
)
const rectHalfY = rectHalfX * RECT_X_TO_Y
rect.xMax = Math.min(clickX + rectHalfX, canvasWidth)
rect.yMax = Math.min(clickY + rectHalfY, canvasHeight)
rect.xMin = Math.max(clickX - rectHalfX, 0)
rect.yMin = Math.max(clickY - rectHalfY, 0)
return
}
let isInRect = 0
if (clickX >= rect.xMax) {
rect.xMax = clickX
} else if (clickX <= rect.xMin) {
rect.xMin = clickX
} else {
isInRect++
}
if (clickY >= rect.yMax) {
rect.yMax = clickY
} else if (clickY <= rect.yMin) {
rect.yMin = clickY
} else {
isInRect++
}
if (isInRect !== 2) {
return
}
const rectSlope = (rect.yMax - rect.yMin) / (rect.xMax - rect.xMin)
const clickSlopePositive = (clickY - rect.yMin) / (clickX - rect.xMin)
const clickSlopeNegative = (clickY - rect.yMin) / (clickX - rect.xMax)
if (clickSlopePositive >= rectSlope) {
if (clickSlopeNegative <= -rectSlope) {
rect.yMax = clickY
} else {
rect.xMin = clickX
}
} else {
if (clickSlopeNegative <= -rectSlope) {
rect.xMax = clickX
} else {
rect.yMin = clickY
}
}
return
}
* computeBaseline 选线方法。选择轮廓下方的基线过滤线
* 根据点击位置更新基线的左右截距
* 设计思路:
* 将 canvas 水平分为三个区域:左区(35%)、中区(30%)、右区(35%)。
* - 左区:只调整左截距,通过相似三角形计算新的左截距值
* - 右区:只调整右截距,同理
* - 中区:计算当前点击位置对应的基线 Y 值,然后整体上下平移两条截距
* @note 基线截距会直接修改传入的 baseline 对象
* @param { object } param
* @param { number } param.clickX - 点击位置的实际 X 坐标(已乘以缩放比)
* @param { number } param.clickY - 点击位置的实际 Y 坐标(已乘以缩放比)
* @param { number } param.canvasWidth - canvas 实际宽度
* @param { number } param.canvasHeight - canvas 实际高度
* @param { Baseline } param.baseline - 当前的基线截距对象(会被就地修改)
*/
export function computeBaseline({ clickX, clickY, canvasWidth, canvasHeight, baseline }) {
let leftIntercept = baseline.left ?? canvasHeight
let rightIntercept = baseline.right ?? canvasHeight
if (clickX < canvasWidth * 0.35) {
leftIntercept =
canvasWidth / (canvasWidth - clickX)
* (clickY - rightIntercept)
+ rightIntercept
} else if (clickX > canvasWidth * 0.65) {
rightIntercept =
canvasWidth / clickX
* (clickY - leftIntercept)
+ leftIntercept
} else {
const baselineSlope = (rightIntercept - leftIntercept) / canvasWidth
const interceptPointY = baselineSlope * clickX + leftIntercept
const offsetY = clickY - interceptPointY
leftIntercept += offsetY
rightIntercept += offsetY
}
baseline.left = leftIntercept
baseline.right = rightIntercept
return
}
* computeColLine 选线方法。选择轮廓左右两侧的过滤线
* 根据点击位置更新轮廓左右两侧遮罩线的 X 坐标
* 设计思路:
* 将 canvas 水平一分为二,点击左半则更新左侧线,右半则更新右侧线。
* @note 遮罩线坐标会直接修改传入的 colLine 对象
* @param { object } param
* @param { number } param.clickX - 点击位置的实际 X 坐标(已乘以缩放比)
* @param { number } param.canvasWidth - canvas 实际宽度
* @param { ColLine } param.colLine - 当前的左右遮罩线坐标对象(会被就地修改)
*/
export function computeColLine({ clickX, canvasWidth, colLine }) {
const canvasWidthHalf = canvasWidth / 2
if (clickX < canvasWidthHalf) {
colLine.left = Math.ceil(clickX)
} else {
colLine.right = Math.floor(clickX)
}
return
}
* getContourPoints 获取轮廓点,并会滤去明显有问题的杂点
* 明显有问题的杂点:位于canvas边缘1%区域内的点,及位于中心遮罩框内、两边遮罩框外的点
* 基线遮罩不在这一步过滤,而放到后一步。因为最后一步学生也会调整基线,所以基线遮罩框的边界可能会变
* 设计思路:
* 1. 从OpenCV的Met数据中提取轮廓点数据
* 2. 先过滤掉位于canvas边缘1%区域内的点
* 3. 最后根据中心遮罩过滤掉位于遮罩框内的点
* @param { object } param - 参数对象
* @param { CV.MatVector } param.metVectorContours - OpenCV轮廓点的MatVector对象
* @param { ColLine } param.colLine - 左右遮罩线坐标
* @param { Rect } param.rect - 中心遮罩框坐标
* @param { number } param.canvasWidth - canvas实际宽度
* @param { number } param.canvasHeight - canvas实际高度
* @returns { [number, number][] } contourPointAoa - 过滤后的轮廓点坐标数组 [x, y][]
*/
export function getContourPoints({
metVectorContours, colLine, rect, canvasWidth, canvasHeight
}) {
const CANVAS_EDGE_PERCENTAGE = 0.01
const contourPointAoa = []
const filterWidthMin = colLine.left ?? Math.ceil(canvasWidth * CANVAS_EDGE_PERCENTAGE)
const filterWidthMax = colLine.right ?? Math.floor(canvasWidth * (1 - CANVAS_EDGE_PERCENTAGE))
const filterHeightMin = Math.ceil(canvasHeight * CANVAS_EDGE_PERCENTAGE)
const filterHeightMax = Math.floor(canvasHeight * (1 - CANVAS_EDGE_PERCENTAGE))
const maskWidthMin = rect.xMin ?? filterWidthMax
const maskHeightMin = rect.yMin ?? filterHeightMax
const maskWidthMax = rect.xMax ?? filterWidthMin
const maskHeightMax = rect.yMax ?? filterHeightMin
forEachContour: for (let i = 0; i < metVectorContours.size(); i++) {
const metContour = metVectorContours.get(i)
forEachContourPoint: for (let j = 0; j < metContour.rows; j++) {
const pointX = metContour.data32S[j * 2]
const pointY = metContour.data32S[j * 2 + 1]
if (
(pointX <= filterWidthMin) || (pointX >= filterWidthMax)
|| (pointY <= filterHeightMin) || (pointY >= filterHeightMax)
) {
continue forEachContourPoint
}
if (
(pointX < maskWidthMin) || (pointX > maskWidthMax)
|| (pointY < maskHeightMin) || (pointY > maskHeightMax)
) {
contourPointAoa.push([pointX, pointY])
}
}
metContour.delete()
}
return contourPointAoa
}
* baselineFilterContourPoints 用基线过滤轮廓点
* 设计思路:
* 1. 先根据基线遮罩(如有)过滤掉位于基线下方的点(通过斜率比较)
* 2. 同时计算每个保留点到基线的距离,供后续椭圆拟合使用
* @param { object } param
* @param { [number, number][] } param.rawContourPointAoa - 轮廓点坐标数组 [x, y][]
* @param { Baseline } param.baseline - 基线截距坐标,canvas视角。
* @param { number } param.canvasWidth - canvas 实际宽度
* @param { number } param.canvasHeight - canvas 实际高度
* @param { boolean } [param.isBaselineConvertToCanvas] - 基线是否已转换到canvas视角
* @returns {{ contourPointAoa: [number, number][], contourPointToBaselineDistanceArr: number[] }}
* - newContourPointAoa - 过滤后的轮廓点坐标数组 [x, y][]
* - contourPointToBaselineDistanceArr - 各轮廓点到基线的距离数组
* @note 基线截距坐标默认canvas视角,如从滑轨Ref对象取值,则需显式转换。
*/
export function baselineFilterContourPoints({
rawContourPointAoa, baseline, canvasWidth, canvasHeight, isBaselineConvertToCanvas = true
}) {
const newContourPointAoa = []
const contourPointToBaselineDistanceArr = []
const CANVAS_EDGE_PERCENTAGE = 0.01
const filterHeightMax = Math.floor(canvasHeight * (1 - CANVAS_EDGE_PERCENTAGE))
const filterBaselineLeft = baseline.left ?? filterHeightMax
const filterBaselineRight = baseline.right ?? filterHeightMax
const filterBaselineDifference = filterBaselineRight - filterBaselineLeft
const filterBaselineSlope = filterBaselineDifference / canvasWidth
const distanceSquareDenominator = canvasWidth ** 2 + filterBaselineDifference ** 2
forEachContourPoint: for (const contourPoint of rawContourPointAoa) {
const pointX = contourPoint[0]
const pointY = contourPoint[1]
const pointToBaselineSlope = (pointY - filterBaselineLeft) / pointX
if (pointToBaselineSlope > filterBaselineSlope) {
continue forEachContourPoint
}
newContourPointAoa.push([pointX, pointY])
const distanceSquareNumerator =
(canvasWidth * (pointY - filterBaselineLeft) - filterBaselineDifference * pointX) ** 2
const distance = Math.sqrt(distanceSquareNumerator / distanceSquareDenominator)
contourPointToBaselineDistanceArr.push(distance)
}
return {
contourPointAoa: newContourPointAoa,
contourPointToBaselineDistanceArr: contourPointToBaselineDistanceArr
}
}
* filterContourPoints 过滤轮廓点,滤去明显有问题的杂点
* 设计思路:
* 1. 先过滤掉位于 canvas 边缘 1% 区域内的点
* 2. 再根据基线遮罩过滤掉位于基线下方的点(通过斜率比较)
* 3. 最后根据中心遮罩过滤掉位于遮罩框内的点
* 4. 同时计算每个保留点到基线的距离,供后续椭圆拟合使用
* @param { object } param
* @param { CV.MatVector } param.metVectorContours - OpenCV轮廓点的MatVector对象
* @param { ColLine } param.colLine - 左右遮罩线坐标
* @param { Rect } param.rect - 中心遮罩框坐标
* @param { Baseline } param.baseline - 基线截距坐标
* @param { number } param.canvasWidth - canvas 实际宽度
* @param { number } param.canvasHeight - canvas 实际高度
* @returns {{ contourPointAoa: [number, number][], contourPointToBaselineDistanceArr: number[] }}
* - contourPointAoa - 过滤后的轮廓点坐标数组 [x, y][]
* - contourPointToBaselineDistanceArr - 各轮廓点到基线的距离数组
* @throws { Error } 轮廓点不足6个时抛出异常
*/
export function filterContourPoints({
metVectorContours, colLine, rect, baseline, canvasWidth, canvasHeight
}) {
const CANVAS_EDGE_PERCENTAGE = 0.01
const contourPointAoa = []
const contourPointToBaselineDistanceArr = []
const filterWidthMin = colLine.left ?? Math.ceil(canvasWidth * CANVAS_EDGE_PERCENTAGE)
const filterWidthMax = colLine.right ?? Math.floor(canvasWidth * (1 - CANVAS_EDGE_PERCENTAGE))
const filterHeightMin = Math.ceil(canvasHeight * CANVAS_EDGE_PERCENTAGE)
const filterHeightMax = Math.floor(canvasHeight * (1 - CANVAS_EDGE_PERCENTAGE))
const filterHeightMaxLeft = baseline.left ?? filterHeightMax
const filterHeightMaxRight = baseline.right ?? filterHeightMax
const heightDifference = filterHeightMaxRight - filterHeightMaxLeft
const filterHeightMaxSlope = heightDifference / canvasWidth
const maskWidthMin = rect.xMin ?? filterWidthMax
const maskHeightMin = rect.yMin ?? filterHeightMax
const maskWidthMax = rect.xMax ?? filterWidthMin
const maskHeightMax = rect.yMax ?? filterHeightMin
const distanceSquareDenominator = canvasWidth ** 2 + heightDifference ** 2
forEachContour: for (let i = 0; i < metVectorContours.size(); i++) {
const metContour = metVectorContours.get(i)
forEachContourPoint: for (let j = 0; j < metContour.rows; j++) {
const pointX = metContour.data32S[j * 2]
const pointY = metContour.data32S[j * 2 + 1]
if (pointX <= filterWidthMin || pointX >= filterWidthMax || pointY <= filterHeightMin) {
continue forEachContourPoint
}
const pointToBaselineSlope = (pointY - filterHeightMaxLeft) / pointX
if (pointToBaselineSlope > filterHeightMaxSlope) {
continue forEachContourPoint
}
if (
pointX < maskWidthMin || pointX > maskWidthMax ||
pointY < maskHeightMin || pointY > maskHeightMax
) {
contourPointAoa.push([pointX, pointY])
const distanceSquareNumerator =
(canvasWidth * (pointY - filterHeightMaxLeft) - heightDifference * pointX) ** 2
contourPointToBaselineDistanceArr.push(
Math.sqrt(distanceSquareNumerator / distanceSquareDenominator)
)
}
}
metContour.delete()
}
return { contourPointAoa, contourPointToBaselineDistanceArr }
}
* 迭代拟合获得椭圆对象(旧版)
* @deprecated 已废弃
* 设计思路:
* 算法核心是一个"双层迭代"结构:
* - 外层:收紧容差值(toleranceValue × ITERATION_WEIGHT)
* - 内层:将轮廓点分为"阳性点集"(拟合良好)和"阴性点集"(偏离过大),
* 每轮用阳性点集拟合椭圆,再给阴性点一个"复活赛"机会。
* 收敛条件:阳性/阴性点集不再变化,且 R² ≥ 阈值 或容差值已收紧至最小。
* 算法细节:
* 1. 每次拟合得到的椭圆参数用于构建中心坐标系(去中心化 + 逆旋转)
* 2. 在该坐标系下计算每个点到椭圆的径向距离偏差
* 3. 同时考虑点到基线的距离作为辅助筛选条件
* @param { object } param
* @param { CV } param.cv - OpenCV.js 实例
* @param { [number, number][] } param.contourPointAoa - 轮廓点坐标数组
* @param { number[] } param.contourPointToBaselineDistanceArr - 各轮廓点到基线的距离
* @returns {{
* ellipse: CV.Ellipse,
* R2: number,
* baselineReferencePoint: [number, number]
* }}
* - ellipse - 椭圆对象
* - R2 - 拟合优度
* - baselineReferencePoint - 基线参考点:取阳性点中 Y 值最大的点(即最低点)
*/
export function getEllipseOld({ cv, contourPointAoa, contourPointToBaselineDistanceArr }) {
const TOLERANCE_VALUE_INIT = 0.2
const TOLERANCE_VALUE_MIN = 0.001
const NP_TO_PP_THRESHOLD = 0.7
const ITERATION_WEIGHT = 0.7
const R2_THRESHOLD = 0.99
const ITERATION_COUNT_MAX = 100
let toleranceValue = TOLERANCE_VALUE_INIT
const positivePointAoa = contourPointAoa
const positiveDistanceArr = contourPointToBaselineDistanceArr
const negativePointAoa = []
const negativeDistanceArr = []
let R2 = null
let isConverge = false
let iterationCount = 0
let ellipse = null
let baselineReferencePoint = [0, 0]
contourPointIterate: while (!isConverge && iterationCount < ITERATION_COUNT_MAX) {
iterationCount++
const metContourPoints = cv.matFromArray(
positivePointAoa.length,
1,
cv.CV_32SC2,
positivePointAoa.flat(),
)
ellipse = cv.fitEllipseAMS(metContourPoints)
metContourPoints.delete()
const PTPointAoa = []
const PFPointAoa = []
const NTPointAoa = []
const NFPointAoa = []
const PTDistanceArr = []
const PFDistanceArr = []
const NTDistanceArr = []
const NFDistanceArr = []
const statisticDataArr = []
let statisticPointRSum = 0
const ellipseW = ellipse.size.width
const ellipseH = ellipse.size.height
const ellipseHalfHWSquare = (ellipseW ** 2) * (ellipseH ** 2) / 4
const ellipseCenterX = ellipse.center.x
const ellipseCenterY = ellipse.center.y
const ellipseAngle = -ellipse.angle
const ellipseAngleSin = Math.sin(ellipseAngle * Math.PI / 180)
const ellipseAngleCos = Math.cos(ellipseAngle * Math.PI / 180)
* 打包椭圆参数,方便后面筛选点时传递
* @type { [number, number, number, number, number, number, number] }
*/
const ellipseParamArr = [
ellipseH, ellipseW, ellipseHalfHWSquare,
ellipseCenterX, ellipseCenterY,
ellipseAngleSin, ellipseAngleCos
]
forEachPositivePoint: for (let i = 0; i < positivePointAoa.length; i++) {
const [pointR, ellipseR] = _pointFilter(
positivePointAoa[i], toleranceValue,
positiveDistanceArr[i], 1,
PTPointAoa, PFPointAoa, PTDistanceArr, PFDistanceArr,
ellipseParamArr
)
statisticDataArr.push([pointR, ellipseR])
statisticPointRSum += pointR
}
forEachNegativePoint: for (let i = 0; i < negativePointAoa.length; i++) {
_pointFilter(
negativePointAoa[i], toleranceValue * NP_TO_PP_THRESHOLD,
negativeDistanceArr[i], NP_TO_PP_THRESHOLD,
NFPointAoa, NTPointAoa, NFDistanceArr, NTDistanceArr,
ellipseParamArr
)
}
const pointLength = statisticDataArr.length
const pointRAve = statisticPointRSum / pointLength
let SSR = 0
let SST = 0
for (let i = 0; i < pointLength; i++) {
const [pointR, ellipseR] = statisticDataArr[i]
SSR += (ellipseR - pointRAve) ** 2
SST += (pointR - pointRAve) ** 2
}
R2 = SSR / SST
if (PFPointAoa.length === 0 && NFPointAoa.length === 0) {
if (R2 >= R2_THRESHOLD || toleranceValue < TOLERANCE_VALUE_MIN) {
isConverge = true
} else {
toleranceValue *= ITERATION_WEIGHT
}
} else {
positivePointAoa.length = 0
positivePointAoa.push(...PTPointAoa, ...NFPointAoa)
negativePointAoa.length = 0
negativePointAoa.push(...PFPointAoa, ...NTPointAoa)
positiveDistanceArr.length = 0
positiveDistanceArr.push(...PTDistanceArr, ...NFDistanceArr)
negativeDistanceArr.length = 0
negativeDistanceArr.push(...PFDistanceArr, ...NTDistanceArr)
}
}
return { ellipse, R2, baselineReferencePoint }
* 筛选单个点:将其归入"阳性"或"阴性"点集
* 设计思路:
* 1. 将点去中心化 + 逆旋转,迁移到标准椭圆坐标系
* 2. 计算点的径向距离 pointR 和该方向上椭圆的径向距离 ellipseR
* 3. 以 pointR 与 ellipseR 的相对偏差作为主要筛选条件
* 4. 以点到基线的距离作为辅助筛选条件(距离太远的点更可能是噪声)
* @param { [number, number] } point - 点的原始坐标 [x, y]
* @param { number } tolerance - 容差(相对值)
* @param { number } pointToBaselineDistance - 点到基线的距离
* @param { number } distanceCoefficient - 距离系数(阴性点用更严格的系数)
* @param { number[][] } PPointAoa - 阳性点集(接收数组)
* @param { number[][] } NPointAoa - 阴性点集(接收数组)
* @param { number[] } PDistanceArr - 阳性点距离数组(接收数组)
* @param { number[] } NDistanceArr - 阴性点距离数组(接收数组)
* @param { [number, number, number, number, number, number, number] } eParam - 椭圆参数数组
* @returns { [number, number] } [pointR, ellipseR] 点的径向距离和椭圆径向距离
*/
function _pointFilter(
[pointX, pointY], tolerance, pointToBaselineDistance, distanceCoefficient,
PPointAoa, NPointAoa, PDistanceArr, NDistanceArr,
[eH, eW, eHalfHWSquare, eCenterX, eCenterY, eAngleSin, eAngleCos]
) {
const pointXCentered = pointX - eCenterX
const pointYCentered = pointY - eCenterY
const pointXNormalized = pointXCentered * eAngleCos - pointYCentered * eAngleSin
const pointYNormalized = pointXCentered * eAngleSin + pointYCentered * eAngleCos
const pointR = Math.sqrt(pointXNormalized ** 2 + pointYNormalized ** 2)
const pointRad = Math.atan2(pointYNormalized, pointXNormalized)
const ellipseRSquare = eHalfHWSquare /
(((eH * Math.cos(pointRad)) ** 2) + ((eW * Math.sin(pointRad)) ** 2))
const ellipseR = Math.sqrt(ellipseRSquare)
const pointToEllipseDistance = Math.abs(pointR - ellipseR)
const pointRRelative = pointToEllipseDistance / ellipseR
if (
pointRRelative > tolerance ||
pointToEllipseDistance > pointToBaselineDistance / distanceCoefficient
) {
NPointAoa.push([pointX, pointY])
NDistanceArr.push(pointToBaselineDistance)
} else {
PPointAoa.push([pointX, pointY])
PDistanceArr.push(pointToBaselineDistance)
if (baselineReferencePoint[1] < pointY) {
baselineReferencePoint = [pointX, pointY]
}
}
return [pointR, ellipseR]
}
}
* calculateContactAngle 计算接触角(纯数学部分)
* 设计思路:
* 1. 把基线的2个截距点迁移到标准椭圆坐标系
* 2. 以2个截距点构建基线方程 y = ax + b
* 3. 基线方程变换为 r ~ θ 关系:
* y = r·sinθ;x = r·cosθ
* 4. 基线方程与椭圆方程联立。椭圆方程:
* r²·{[(cosθ)/(w/2)]² + [(sinθ)/(h/2)]²} = 1
* 5. 解3和4的方程,消去r后得到关于cotθ的二次方程:
* a·cot²θ + b·cotθ + c = 0
* 6. 由cotθ得到θ,得到两边的切线斜率
* 斜率 = - (1 / tanθ) · (h / w)²
* 7. 计算两切线斜率和基线截距之间的夹角,即为接触角
* @param { object } param
* @param { CV.Ellipse } param.ellipse - 椭圆对象
* @param { number } param.leftIntercept - 左截距(用户视角 Y 坐标,即 canvas.height - canvasY)
* @param { number } param.rightIntercept - 右截距(用户视角 Y 坐标)
* @param { number } param.canvasWidth - canvas 实际宽度
* @param { number } param.canvasHeight - canvas 实际高度
* @returns {{
* contactAngleAverage: number,
* contactAngleLeft: number,
* contactAngleRight: number,
* contactAngleDeviation: number,
* interceptAngle: number
* }} 接触角计算结果:
* - contactAngleAverage: 平均接触角
* - contactAngleLeft: 左接触角
* - contactAngleRight: 右接触角
* - contactAngleDeviation: 角度偏差
* - interceptAngle: 基线角度
* @throws { Error } 方程判别式 ≤ 0 时抛出异常
*/
export function calculateContactAngle({
ellipse, leftIntercept, rightIntercept, canvasWidth, canvasHeight
}) {
const interceptPoint1X = 0
const interceptPoint1Y = canvasHeight - leftIntercept
const interceptPoint2X = canvasWidth
const interceptPoint2Y = canvasHeight - rightIntercept
const interceptAngle = Math.atan2(
interceptPoint2Y - interceptPoint1Y,
interceptPoint2X - interceptPoint1X
) * 180 / Math.PI * -1
const ellipseAngle = -ellipse.angle
const ellipseAngleSin = Math.sin(ellipseAngle * Math.PI / 180)
const ellipseAngleCos = Math.cos(ellipseAngle * Math.PI / 180)
const ellipseCenterX = ellipse.center.x
const ellipseCenterY = ellipse.center.y
const p1xCentered = interceptPoint1X - ellipseCenterX
const p1yCentered = interceptPoint1Y - ellipseCenterY
const p2xCentered = interceptPoint2X - ellipseCenterX
const p2yCentered = interceptPoint2Y - ellipseCenterY
const newP1X = p1xCentered * ellipseAngleCos - p1yCentered * ellipseAngleSin
const newP1Y = p1xCentered * ellipseAngleSin + p1yCentered * ellipseAngleCos
const newP2X = p2xCentered * ellipseAngleCos - p2yCentered * ellipseAngleSin
const newP2Y = p2xCentered * ellipseAngleSin + p2yCentered * ellipseAngleCos
const w = ellipse.size.width
const h = ellipse.size.height
const kx = newP2X - newP1X
const ky = newP2Y - newP1Y
const kmix = newP2X * newP1Y - newP1X * newP2Y
const a = (ky ** 2) - ((2 * kmix / w) ** 2)
const b = -2 * kx * ky
const c = (kx ** 2) - ((2 * kmix / h) ** 2)
const delta = b ** 2 - 4 * a * c
if (delta <= 0) {
throw Error("方程没有2个解")
}
const cot1 = (-b + Math.sqrt(delta)) / (2 * a)
const cot2 = (-b - Math.sqrt(delta)) / (2 * a)
const slope1 = -cot1 * ((h / w) ** 2)
const slope2 = -cot2 * ((h / w) ** 2)
const baselineSlope = ky / kx
let oldAngleTangent1 = (Math.atan(slope1) * 180 / Math.PI - ellipseAngle) % 180
let oldAngleTangent2 = (Math.atan(slope2) * 180 / Math.PI - ellipseAngle) % 180
let oldAngleBaseline = (Math.atan(baselineSlope) * 180 / Math.PI - ellipseAngle) % 180
oldAngleTangent1 = _normalizeAngle(oldAngleTangent1)
oldAngleTangent2 = _normalizeAngle(oldAngleTangent2)
oldAngleBaseline = _normalizeAngle(oldAngleBaseline)
const baselineEllipseCenterY =
(interceptPoint2Y - interceptPoint1Y) / interceptPoint2X * ellipseCenterX
+ interceptPoint1Y
const isContactAngleObtuse = baselineEllipseCenterY > ellipseCenterY
let contactAngleLeft, contactAngleRight
if (!isContactAngleObtuse) {
if (oldAngleTangent1 > oldAngleTangent2) {
contactAngleLeft = -(oldAngleTangent2 - oldAngleBaseline)
contactAngleRight = oldAngleTangent1 - oldAngleBaseline
} else {
contactAngleLeft = -(oldAngleTangent1 - oldAngleBaseline)
contactAngleRight = oldAngleTangent2 - oldAngleBaseline
}
} else {
if (oldAngleTangent1 > oldAngleTangent2) {
contactAngleLeft = 180 - (oldAngleTangent1 - oldAngleBaseline)
contactAngleRight = 180 + (oldAngleTangent2 - oldAngleBaseline)
} else {
contactAngleLeft = 180 - (oldAngleTangent2 - oldAngleBaseline)
contactAngleRight = 180 + (oldAngleTangent1 - oldAngleBaseline)
}
}
contactAngleLeft = _clampAngle(contactAngleLeft)
contactAngleRight = _clampAngle(contactAngleRight)
const contactAngleAverage = (contactAngleLeft + contactAngleRight) / 2
const contactAngleDeviation = Math.abs(contactAngleLeft - contactAngleRight)
return {
contactAngleAverage,
contactAngleLeft,
contactAngleRight,
contactAngleDeviation,
interceptAngle,
}
* 将角度修正到 [-90°, 90°] 范围
* @param { number } angle
* @returns { number }
*/
function _normalizeAngle(angle) {
if (angle > 90) return angle - 180
if (angle <= -90) return angle + 180
return angle
}
* 将接触角修正到 (0°, 180°) 范围
* @param { number } angle
* @returns { number }
*/
function _clampAngle(angle) {
if (angle < 0) return angle + 180
if (angle > 180) return angle - 180
return angle
}
}