first commit
This commit is contained in:
+396
@@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<div class="stage">
|
||||
<!-- 隐藏视频元素仅用于抓帧 -->
|
||||
<video
|
||||
ref="videoEl"
|
||||
playsinline
|
||||
muted
|
||||
autoplay
|
||||
class="hidden-video"
|
||||
></video>
|
||||
|
||||
<!-- 可见的绘制画布 -->
|
||||
<canvas ref="canvasEl" class="canvas"></canvas>
|
||||
|
||||
<!-- HUD 面板 -->
|
||||
<div class="hud">
|
||||
<div class="row">
|
||||
<button class="btn" @click="toggle()">
|
||||
{{ running ? '停止' : '启动' }}
|
||||
</button>
|
||||
<button class="btn" @click="switchFacing()">
|
||||
切换摄像头({{ facingModeLabel }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="row small">
|
||||
<span>FPS: {{ fps.toFixed(1) }}</span>
|
||||
<span v-if="err" class="err">错误:{{ err }}</span>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="group">
|
||||
<h4>Green</h4>
|
||||
<div>x: {{ fmt(metrics.greenRectCenterX) }}, y: {{ fmt(metrics.greenRectCenterY) }}</div>
|
||||
<div>θ: {{ fmt(metrics.greenRectRotation) }}°</div>
|
||||
<div>L×W: {{ fmt(metrics.greenRectLength) }} × {{ fmt(metrics.greenRectWidth) }}</div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<h4>Blue</h4>
|
||||
<div>x: {{ fmt(metrics.blueRectCenterX) }}, y: {{ fmt(metrics.blueRectCenterY) }}</div>
|
||||
<div>θ: {{ fmt(metrics.blueRectRotation) }}°</div>
|
||||
<div>L×W: {{ fmt(metrics.blueRectLength) }} × {{ fmt(metrics.blueRectWidth) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
提示:蓝色矩形基于红色/绿色关系构造;若未检测到所需目标,相关字段会为空(null),画面也不会绘制对应图形。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref, reactive, computed } from 'vue'
|
||||
import cv from '@techstark/opencv-js'
|
||||
import { processImage } from './lib/processImage' // ← 根据你的实际路径调整
|
||||
|
||||
type Metrics = ReturnType<typeof processImage>
|
||||
|
||||
// 画布/视频引用
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
|
||||
// 状态
|
||||
const running = ref(false)
|
||||
const fps = ref(0)
|
||||
const err = ref<string | null>(null)
|
||||
// OpenCV 就绪标记:仅在完成 WASM 初始化后才进行图像处理
|
||||
const cvReady = ref(false)
|
||||
const metrics = reactive<Metrics>({
|
||||
greenRectCenterX: null,
|
||||
greenRectCenterY: null,
|
||||
blueRectCenterX: null,
|
||||
blueRectCenterY: null,
|
||||
greenRectRotation: null,
|
||||
blueRectRotation: null,
|
||||
greenRectLength: null,
|
||||
blueRectLength: null,
|
||||
greenRectWidth: null,
|
||||
blueRectWidth: null
|
||||
})
|
||||
|
||||
// 摄像头方向
|
||||
const facingMode = ref<'environment' | 'user'>('environment')
|
||||
const facingModeLabel = computed(() =>
|
||||
facingMode.value === 'environment' ? '后置' : '前置'
|
||||
)
|
||||
|
||||
// RAF 相关
|
||||
let rafId = 0
|
||||
let lastT = 0
|
||||
|
||||
// 资源引用
|
||||
let stream: MediaStream | null = null
|
||||
|
||||
function fmt(n: number | null) {
|
||||
return n == null ? '—' : n.toFixed(1)
|
||||
}
|
||||
|
||||
function drawRotRect(
|
||||
g: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
longLen: number,
|
||||
shortLen: number,
|
||||
angleDeg: number,
|
||||
color: string
|
||||
) {
|
||||
const rad = (angleDeg * Math.PI) / 180
|
||||
const dir = { x: Math.cos(rad), y: Math.sin(rad) }
|
||||
const perp = { x: -dir.y, y: dir.x }
|
||||
const hl = longLen / 2
|
||||
const hw = shortLen / 2
|
||||
|
||||
const p0 = { x: cx - dir.x * hl - perp.x * hw, y: cy - dir.y * hl - perp.y * hw }
|
||||
const p1 = { x: cx + dir.x * hl - perp.x * hw, y: cy + dir.y * hl - perp.y * hw }
|
||||
const p2 = { x: cx + dir.x * hl + perp.x * hw, y: cy + dir.y * hl + perp.y * hw }
|
||||
const p3 = { x: cx - dir.x * hl + perp.x * hw, y: cy - dir.y * hl + perp.y * hw }
|
||||
|
||||
g.save()
|
||||
g.lineWidth = 2
|
||||
g.strokeStyle = color
|
||||
g.beginPath()
|
||||
g.moveTo(p0.x, p0.y)
|
||||
g.lineTo(p1.x, p1.y)
|
||||
g.lineTo(p2.x, p2.y)
|
||||
g.lineTo(p3.x, p3.y)
|
||||
g.closePath()
|
||||
g.stroke()
|
||||
|
||||
// 中心点 + 朝向线
|
||||
g.fillStyle = color
|
||||
g.beginPath()
|
||||
g.arc(cx, cy, 4, 0, Math.PI * 2)
|
||||
g.fill()
|
||||
g.beginPath()
|
||||
g.moveTo(cx, cy)
|
||||
g.lineTo(cx + dir.x * (hl * 0.8), cy + dir.y * (hl * 0.8))
|
||||
g.stroke()
|
||||
g.restore()
|
||||
}
|
||||
|
||||
function clearCanvas(g: CanvasRenderingContext2D, w: number, h: number) {
|
||||
g.clearRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
function resizeCanvasToVideo(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
|
||||
const vw = video.videoWidth || 640
|
||||
const vh = video.videoHeight || 480
|
||||
// 实际像素尺寸
|
||||
canvas.width = vw
|
||||
canvas.height = vh
|
||||
// CSS 自适应
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = 'auto'
|
||||
}
|
||||
|
||||
function copyMetrics(next: Metrics) {
|
||||
;(Object.keys(metrics) as (keyof Metrics)[]).forEach((k) => {
|
||||
metrics[k] = next[k] as any
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureCVReady(): Promise<void> {
|
||||
// @techstark/opencv-js 在 WASM 初始化完成后可调用 getBuildInformation
|
||||
try {
|
||||
if ((cv as any)?.getBuildInformation) {
|
||||
cvReady.value = true
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
;(cv as any).onRuntimeInitialized = () => {
|
||||
cvReady.value = true
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 忽略异常,让后续帧内判断 cvReady 决定是否处理
|
||||
}
|
||||
}
|
||||
|
||||
async function startStream() {
|
||||
try {
|
||||
err.value = null
|
||||
// 关闭旧流
|
||||
await stopStream()
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: facingMode.value }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: false
|
||||
})
|
||||
if (!videoEl.value) return
|
||||
videoEl.value.srcObject = stream
|
||||
|
||||
// 等待元数据并尝试播放(兼容 Safari/部分浏览器自动播放策略)
|
||||
await new Promise<void>((res) => {
|
||||
if (!videoEl.value) return res()
|
||||
const v = videoEl.value
|
||||
if (v.readyState >= 1) return res()
|
||||
const onMeta = () => {
|
||||
v.removeEventListener('loadedmetadata', onMeta)
|
||||
res()
|
||||
}
|
||||
v.addEventListener('loadedmetadata', onMeta)
|
||||
})
|
||||
try {
|
||||
await videoEl.value.play()
|
||||
} catch {
|
||||
// 若被策略拦截,依赖用户点击“启动”后再次尝试
|
||||
}
|
||||
|
||||
if (!canvasEl.value || !videoEl.value) return
|
||||
ctx = canvasEl.value.getContext('2d')
|
||||
if (!ctx) throw new Error('CanvasRenderingContext2D 获取失败')
|
||||
resizeCanvasToVideo(videoEl.value, canvasEl.value)
|
||||
|
||||
running.value = true
|
||||
lastT = performance.now()
|
||||
loop()
|
||||
} catch (e: any) {
|
||||
err.value = e?.message ?? String(e)
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopStream() {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = 0
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
}
|
||||
running.value = false
|
||||
}
|
||||
|
||||
function loop() {
|
||||
if (!running.value || !videoEl.value || !canvasEl.value || !ctx) return
|
||||
|
||||
const now = performance.now()
|
||||
const dt = now - lastT
|
||||
lastT = now
|
||||
fps.value = 1000 / (dt || 16.7)
|
||||
|
||||
// 将视频帧绘制到画布
|
||||
const vw = canvasEl.value.width
|
||||
const vh = canvasEl.value.height
|
||||
ctx.drawImage(videoEl.value, 0, 0, vw, vh)
|
||||
|
||||
try {
|
||||
// 取像素、识别
|
||||
const imgData = ctx.getImageData(0, 0, vw, vh)
|
||||
if (cvReady.value) {
|
||||
const res = processImage(imgData)
|
||||
copyMetrics(res)
|
||||
}
|
||||
|
||||
// 覆盖绘制识别结果
|
||||
if (metrics.greenRectCenterX != null && metrics.greenRectCenterY != null && metrics.greenRectLength && metrics.greenRectWidth && metrics.greenRectRotation != null) {
|
||||
drawRotRect(
|
||||
ctx,
|
||||
metrics.greenRectCenterX,
|
||||
metrics.greenRectCenterY,
|
||||
metrics.greenRectLength,
|
||||
metrics.greenRectWidth,
|
||||
metrics.greenRectRotation,
|
||||
'#00ff6a'
|
||||
)
|
||||
}
|
||||
if (metrics.blueRectCenterX != null && metrics.blueRectCenterY != null && metrics.blueRectLength && metrics.blueRectWidth && metrics.blueRectRotation != null) {
|
||||
drawRotRect(
|
||||
ctx,
|
||||
metrics.blueRectCenterX,
|
||||
metrics.blueRectCenterY,
|
||||
metrics.blueRectLength,
|
||||
metrics.blueRectWidth,
|
||||
metrics.blueRectRotation,
|
||||
'#2e6bff'
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 单帧异常不中断,显示在 HUD
|
||||
err.value = e?.message ?? String(e)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (running.value) {
|
||||
await stopStream()
|
||||
} else {
|
||||
// 不阻塞摄像头启动,让 CV 就绪在后台进行
|
||||
ensureCVReady()
|
||||
await startStream()
|
||||
}
|
||||
}
|
||||
|
||||
async function switchFacing() {
|
||||
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
||||
if (running.value) {
|
||||
await startStream()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 后台等待 CV 就绪
|
||||
ensureCVReady()
|
||||
// 自动启动(可改为手动)
|
||||
await startStream()
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
await stopStream()
|
||||
ctx = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #0b0f19;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.hidden-video {
|
||||
display: none; /* 仅用于抓帧即可 */
|
||||
}
|
||||
.canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
.hud {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
color: #e9eef7;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
min-width: 220px;
|
||||
}
|
||||
.hud .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.hud .row.small {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.metrics .group h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.err { color: #ffb4b4; }
|
||||
.btn {
|
||||
appearance: none;
|
||||
background: #1a5cff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn:hover { filter: brightness(1.05); }
|
||||
.hint {
|
||||
color: #5f6b7b;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,292 @@
|
||||
// tsconfig tip: set "skipLibCheck": true if your bundler yells about opencv.js types.
|
||||
import cv from "@techstark/opencv-js";
|
||||
|
||||
export type ProcessImageResult = {
|
||||
greenRectCenterX: number | null;
|
||||
greenRectCenterY: number | null;
|
||||
blueRectCenterX: number | null;
|
||||
blueRectCenterY: number | null;
|
||||
greenRectRotation: number | null; // degrees in [0,180)
|
||||
blueRectRotation: number | null; // degrees in [0,180)
|
||||
greenRectLength: number | null; // long side
|
||||
blueRectLength: number | null; // long side (ray length)
|
||||
greenRectWidth: number | null; // short side
|
||||
blueRectWidth: number | null; // short side = red short side
|
||||
};
|
||||
|
||||
// Geometry helpers use fixed-length tuples to satisfy TS with noUncheckedIndexedAccess
|
||||
type Vec2 = [number, number];
|
||||
type Box4 = [Vec2, Vec2, Vec2, Vec2];
|
||||
|
||||
type RectProps = {
|
||||
box: Box4; // 4 points [x,y]
|
||||
center: Vec2; // [x,y]
|
||||
longLen: number;
|
||||
shortLen: number;
|
||||
dirLong: Vec2; // unit vector along long edge
|
||||
};
|
||||
|
||||
export function processImage(imgData: ImageData): ProcessImageResult {
|
||||
const src = cv.matFromImageData(imgData); // CV_8UC4 RGBA
|
||||
const toFree: cv.Mat[] = [src];
|
||||
|
||||
const rgb = new cv.Mat(); toFree.push(rgb);
|
||||
cv.cvtColor(src, rgb, cv.COLOR_RGBA2RGB);
|
||||
const hsv = new cv.Mat(); toFree.push(hsv);
|
||||
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
|
||||
const gray = new cv.Mat(); toFree.push(gray);
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
|
||||
|
||||
// ---- color masks (HSV) ----
|
||||
const kernel3 = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3));
|
||||
// red = two ranges
|
||||
const redMask = (() => {
|
||||
const low1 = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [0, 80, 80, 0]);
|
||||
const up1 = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [5, 255, 255, 0]);
|
||||
const low2 = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [175, 80, 80, 0]);
|
||||
const up2 = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [179, 255, 255, 0]);
|
||||
const m1 = new cv.Mat(); const m2 = new cv.Mat();
|
||||
cv.inRange(hsv, low1, up1, m1);
|
||||
cv.inRange(hsv, low2, up2, m2);
|
||||
const rm = new cv.Mat();
|
||||
cv.bitwise_or(m1, m2, rm);
|
||||
// morphology open -> close
|
||||
cv.morphologyEx(rm, rm, cv.MORPH_OPEN, kernel3);
|
||||
cv.morphologyEx(rm, rm, cv.MORPH_CLOSE, kernel3);
|
||||
low1.delete(); up1.delete(); low2.delete(); up2.delete(); m1.delete(); m2.delete();
|
||||
toFree.push(rm);
|
||||
return rm;
|
||||
})();
|
||||
|
||||
const grnMask = (() => {
|
||||
const low = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [35, 80, 80, 0]);
|
||||
const up = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [85, 255, 255, 0]);
|
||||
const gm = new cv.Mat();
|
||||
cv.inRange(hsv, low, up, gm);
|
||||
cv.morphologyEx(gm, gm, cv.MORPH_OPEN, kernel3);
|
||||
cv.morphologyEx(gm, gm, cv.MORPH_CLOSE, kernel3);
|
||||
low.delete(); up.delete();
|
||||
toFree.push(gm);
|
||||
return gm;
|
||||
})();
|
||||
|
||||
// ---- detect largest rect (by area) within mask ----
|
||||
const detectLargestBox = (graySrc: cv.Mat, mask: cv.Mat): RectProps | null => {
|
||||
const masked = new cv.Mat(); toFree.push(masked);
|
||||
cv.bitwise_and(graySrc, graySrc, masked, mask);
|
||||
|
||||
const blur = new cv.Mat(); toFree.push(blur);
|
||||
cv.GaussianBlur(masked, blur, new cv.Size(0, 0), 3.0, 3.0, cv.BORDER_DEFAULT);
|
||||
|
||||
const high = new cv.Mat(); toFree.push(high);
|
||||
// unsharp: (1+alpha)*I + (-alpha)*blur
|
||||
cv.addWeighted(masked, 3.0 /*1+2*/, blur, -2.0, 0, high);
|
||||
|
||||
const edges = new cv.Mat(); toFree.push(edges);
|
||||
cv.Canny(high, edges, 40, 120, 3, true);
|
||||
|
||||
const edgesD = new cv.Mat(); toFree.push(edgesD);
|
||||
cv.dilate(edges, edgesD, kernel3);
|
||||
|
||||
const contours = new cv.MatVector(); const hierarchy = new cv.Mat();
|
||||
cv.findContours(edgesD, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
|
||||
|
||||
let bestArea = 0;
|
||||
let bestRect: any = null; // rotated rect (object)
|
||||
for (let i = 0; i < contours.size(); i++) {
|
||||
const cnt = contours.get(i);
|
||||
const area = cv.contourArea(cnt);
|
||||
if (area > 300 && area > bestArea) {
|
||||
bestArea = area;
|
||||
bestRect = cv.minAreaRect(cnt); // {center:{x,y}, size:{width,height}, angle:number}
|
||||
}
|
||||
cnt.delete();
|
||||
}
|
||||
contours.delete(); hierarchy.delete();
|
||||
|
||||
if (!bestRect) return null;
|
||||
|
||||
const box = rotatedRectToPoints(bestRect); // Float32Array[2] * 4
|
||||
const props = rectPropsFromBox(box);
|
||||
return props;
|
||||
};
|
||||
|
||||
const redProps = detectLargestBox(gray, redMask);
|
||||
const grnProps = detectLargestBox(gray, grnMask);
|
||||
|
||||
// ---- build blue rectangle from rules ----
|
||||
let blueCenter: Vec2 | null = null;
|
||||
let blueDir: Vec2 | null = null;
|
||||
let blueLen: number | null = null;
|
||||
let blueW: number | null = null;
|
||||
|
||||
if (redProps && grnProps) {
|
||||
const rBox = orderBoxClockwise(redProps.box);
|
||||
const gCenter = grnProps.center;
|
||||
|
||||
// midpoints of 4 edges (typed as Box4 to avoid undefined indexes)
|
||||
const [r0, r1, r2, r3] = rBox;
|
||||
const mids: Box4 = [
|
||||
midpoint(r0, r1),
|
||||
midpoint(r1, r2),
|
||||
midpoint(r2, r3),
|
||||
midpoint(r3, r0)
|
||||
];
|
||||
|
||||
// farthest midpoint from green center
|
||||
let farIdx = 0, maxD2 = -1;
|
||||
mids.forEach((m, i) => {
|
||||
const dx = m[0] - gCenter[0];
|
||||
const dy = m[1] - gCenter[1];
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 > maxD2) { maxD2 = d2; farIdx = i; }
|
||||
});
|
||||
const [m0, m1, m2, m3] = mids;
|
||||
const startPt: Vec2 = farIdx === 0 ? m0 : farIdx === 1 ? m1 : farIdx === 2 ? m2 : m3;
|
||||
|
||||
// long direction of red; point toward projection to green center
|
||||
const baseDir = redProps.dirLong;
|
||||
let dir: Vec2 = [baseDir[0], baseDir[1]];
|
||||
const toG: Vec2 = v2(gCenter[0] - startPt[0], gCenter[1] - startPt[1]);
|
||||
if (dot(toG, dir) < 0) dir = scale(dir, -1);
|
||||
|
||||
const proj = dot(toG, dir); // scalar projection
|
||||
if (proj > 1.0) {
|
||||
blueLen = proj;
|
||||
blueW = redProps.shortLen;
|
||||
const bb = blueBoxFromRay(startPt, dir, blueLen, blueW);
|
||||
blueCenter = bb.center;
|
||||
blueDir = dir;
|
||||
// const blueBox = bb.box; // (available if you later want corners)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- assemble metrics ----
|
||||
const res: ProcessImageResult = {
|
||||
greenRectCenterX: grnProps ? grnProps.center[0] : null,
|
||||
greenRectCenterY: grnProps ? grnProps.center[1] : null,
|
||||
blueRectCenterX: blueCenter ? blueCenter[0] : null,
|
||||
blueRectCenterY: blueCenter ? blueCenter[1] : null,
|
||||
greenRectRotation: grnProps ? angleDegFromDir(grnProps.dirLong) : null,
|
||||
blueRectRotation: blueDir ? angleDegFromDir(blueDir) : null,
|
||||
greenRectLength: grnProps ? grnProps.longLen : null,
|
||||
blueRectLength: blueLen,
|
||||
greenRectWidth: grnProps ? grnProps.shortLen : null,
|
||||
blueRectWidth: blueW
|
||||
};
|
||||
|
||||
// ---- cleanup ----
|
||||
try {
|
||||
// nothing else
|
||||
} finally {
|
||||
kernel3.delete();
|
||||
toFree.forEach(m => { try { m.delete(); } catch { /* noop */ } });
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/* ----------------------- helpers ----------------------- */
|
||||
|
||||
function v2(x: number, y: number): Vec2 { return [x, y]; }
|
||||
function f32(arr: readonly number[]): Vec2 { return [arr[0] ?? 0, arr[1] ?? 0]; }
|
||||
|
||||
function dot(a: Vec2, b: Vec2): number { return a[0] * b[0] + a[1] * b[1]; }
|
||||
function norm(a: Vec2): number { return Math.hypot(a[0], a[1]); }
|
||||
function normalize(a: Vec2): Vec2 {
|
||||
const n = norm(a) + 1e-9;
|
||||
return f32([a[0]/n, a[1]/n]);
|
||||
}
|
||||
function scale(a: Vec2, k: number): Vec2 { return f32([a[0]*k, a[1]*k]); }
|
||||
function add(a: Vec2, b: Vec2): Vec2 { return f32([a[0]+b[0], a[1]+b[1]]); }
|
||||
function sub(a: Vec2, b: Vec2): Vec2 { return f32([a[0]-b[0], a[1]-b[1]]); }
|
||||
|
||||
function midpoint(a: Vec2, b: Vec2): Vec2 {
|
||||
return f32([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]);
|
||||
}
|
||||
|
||||
function angleDegFromDir(d: Vec2): number {
|
||||
let ang = Math.atan2(d[1], d[0]) * 180 / Math.PI;
|
||||
ang = (ang + 360) % 180; // [0,180)
|
||||
return ang;
|
||||
}
|
||||
|
||||
function orderBoxClockwise(box: Box4): Box4 {
|
||||
const c = centroid(box);
|
||||
const withAng = box.map(p => ({ p, a: Math.atan2(p[1] - c[1], p[0] - c[0]) }));
|
||||
withAng.sort((u, v) => u.a - v.a); // -pi..pi
|
||||
const sorted = withAng.map(o => o.p) as Vec2[];
|
||||
// ensure Box4 length 4 (non-null assertion since input is Box4)
|
||||
return [sorted[0]!, sorted[1]!, sorted[2]!, sorted[3]!];
|
||||
}
|
||||
|
||||
function centroid(pts: ReadonlyArray<Vec2>): Vec2 {
|
||||
let x = 0, y = 0;
|
||||
for (const p of pts) { x += p[0]; y += p[1]; }
|
||||
return f32([x / pts.length, y / pts.length]);
|
||||
}
|
||||
|
||||
function edgeLengths(box: Box4): number[] {
|
||||
const [p0, p1, p2, p3] = box;
|
||||
return [
|
||||
Math.hypot(p1[0] - p0[0], p1[1] - p0[1]),
|
||||
Math.hypot(p2[0] - p1[0], p2[1] - p1[1]),
|
||||
Math.hypot(p3[0] - p2[0], p3[1] - p2[1]),
|
||||
Math.hypot(p0[0] - p3[0], p0[1] - p3[1])
|
||||
];
|
||||
}
|
||||
|
||||
function rectPropsFromBox(boxIn: Box4): RectProps {
|
||||
const box = orderBoxClockwise(boxIn);
|
||||
const center = centroid(box);
|
||||
const L = edgeLengths(box);
|
||||
const longLen = Math.max(...L);
|
||||
const shortLen = Math.min(...L);
|
||||
const idx = L.indexOf(longLen) as 0 | 1 | 2 | 3;
|
||||
const [p0, p1, p2, p3] = box;
|
||||
let a: Vec2, b: Vec2;
|
||||
switch (idx) {
|
||||
case 0: a = p0; b = p1; break;
|
||||
case 1: a = p1; b = p2; break;
|
||||
case 2: a = p2; b = p3; break;
|
||||
case 3: a = p3; b = p0; break;
|
||||
}
|
||||
const dirLong = normalize(sub(b, a));
|
||||
return { box, center, longLen, shortLen, dirLong };
|
||||
}
|
||||
|
||||
function rotatedRectToPoints(rr: { center: { x: number, y: number }, size: { width: number, height: number }, angle: number }): Box4 {
|
||||
const cx = rr.center.x, cy = rr.center.y;
|
||||
const w = rr.size.width, h = rr.size.height;
|
||||
const ang = rr.angle * Math.PI / 180.0;
|
||||
const cosA = Math.cos(ang), sinA = Math.sin(ang);
|
||||
const hw = w * 0.5, hh = h * 0.5;
|
||||
|
||||
const local: [number, number][] = [
|
||||
[-hw, -hh], [ hw, -hh],
|
||||
[ hw, hh], [-hw, hh]
|
||||
];
|
||||
|
||||
const pts: Vec2[] = [];
|
||||
for (const [lx, ly] of local) {
|
||||
const x = cx + lx * cosA - ly * sinA;
|
||||
const y = cy + lx * sinA + ly * cosA;
|
||||
pts.push(f32([x, y]));
|
||||
}
|
||||
return [pts[0]!, pts[1]!, pts[2]!, pts[3]!];
|
||||
}
|
||||
|
||||
function blueBoxFromRay(startPt: Vec2, dirUnitIn: Vec2, length: number, width: number): { box: Box4, center: Vec2 } {
|
||||
const dir = normalize(dirUnitIn);
|
||||
const perp = f32([-dir[1], dir[0]]);
|
||||
const c = add(startPt, scale(dir, length * 0.5));
|
||||
const halfL = scale(dir, length * 0.5);
|
||||
const halfW = scale(perp, width * 0.5);
|
||||
|
||||
const p0 = sub(sub(c, halfL), halfW);
|
||||
const p1 = add(sub(c, halfW), halfL);
|
||||
const p2 = add(add(c, halfL), halfW);
|
||||
const p3 = add(sub(c, halfW), scale(halfL, -1));
|
||||
|
||||
return { box: [p0, p1, p2, p3], center: c };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user