phase 6
This commit is contained in:
@@ -12,3 +12,4 @@ export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||
export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue'
|
||||
export { default as GraphRenderer } from './renderers/GraphRenderer.vue'
|
||||
export { default as FrameRenderer } from './renderers/FrameRenderer.vue'
|
||||
|
||||
115
ui/framework/src/renderers/FrameRenderer.vue
Normal file
115
ui/framework/src/renderers/FrameRenderer.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
export interface FrameBBox {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
confidence: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Base64 JPEG image */
|
||||
imageSrc: string
|
||||
/** Bounding boxes to overlay */
|
||||
boxes: FrameBBox[]
|
||||
}>()
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
|
||||
function draw() {
|
||||
const cvs = canvas.value
|
||||
const ctr = container.value
|
||||
if (!cvs || !ctr || !props.imageSrc) return
|
||||
|
||||
const ctx = cvs.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const img = new window.Image()
|
||||
img.onload = () => {
|
||||
cvs.width = ctr.clientWidth
|
||||
cvs.height = ctr.clientHeight
|
||||
|
||||
const scale = Math.min(cvs.width / img.width, cvs.height / img.height)
|
||||
const dx = (cvs.width - img.width * scale) / 2
|
||||
const dy = (cvs.height - img.height * scale) / 2
|
||||
|
||||
ctx.clearRect(0, 0, cvs.width, cvs.height)
|
||||
ctx.drawImage(img, dx, dy, img.width * scale, img.height * scale)
|
||||
|
||||
for (const box of props.boxes) {
|
||||
const bx = dx + box.x * scale
|
||||
const by = dy + box.y * scale
|
||||
const bw = box.w * scale
|
||||
const bh = box.h * scale
|
||||
|
||||
// Box outline
|
||||
ctx.strokeStyle = confidenceColor(box.confidence)
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(bx, by, bw, bh)
|
||||
|
||||
// Label background
|
||||
const label = `${box.label} ${(box.confidence * 100).toFixed(0)}%`
|
||||
ctx.font = '11px var(--font-mono)'
|
||||
const metrics = ctx.measureText(label)
|
||||
const labelH = 16
|
||||
ctx.fillStyle = confidenceColor(box.confidence)
|
||||
ctx.fillRect(bx, by - labelH, metrics.width + 8, labelH)
|
||||
|
||||
// Label text
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.fillText(label, bx + 4, by - 4)
|
||||
}
|
||||
}
|
||||
img.src = `data:image/jpeg;base64,${props.imageSrc}`
|
||||
}
|
||||
|
||||
function confidenceColor(conf: number): string {
|
||||
if (conf >= 0.7) return 'var(--conf-high)'
|
||||
if (conf >= 0.4) return 'var(--conf-mid)'
|
||||
return 'var(--conf-low)'
|
||||
}
|
||||
|
||||
watch(() => [props.imageSrc, props.boxes], () => nextTick(draw), { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(draw)
|
||||
const observer = new ResizeObserver(() => draw())
|
||||
if (container.value) observer.observe(container.value)
|
||||
onUnmounted(() => observer.disconnect())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="frame-renderer">
|
||||
<canvas ref="canvas" />
|
||||
<div v-if="!imageSrc" class="frame-empty">No frame</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.frame-renderer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-renderer canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.frame-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user