177 lines
4.8 KiB
Vue
177 lines
4.8 KiB
Vue
<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
|
|
resolved_brand?: string | null
|
|
source?: string | null
|
|
stage?: string | null
|
|
ocr_text?: string | null
|
|
}
|
|
|
|
export interface FrameOverlay {
|
|
/** Base64 JPEG image (same dimensions as main image) */
|
|
src: string
|
|
label: string
|
|
visible: boolean
|
|
/** Opacity 0-1, default 0.5 */
|
|
opacity?: number
|
|
}
|
|
|
|
const props = defineProps<{
|
|
/** Base64 JPEG image */
|
|
imageSrc: string
|
|
/** Bounding boxes to overlay */
|
|
boxes: FrameBBox[]
|
|
/** Debug overlay layers (edge images, line visualizations, etc.) */
|
|
overlays?: FrameOverlay[]
|
|
}>()
|
|
|
|
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)
|
|
|
|
// Draw debug overlays (edge images, line visualizations)
|
|
drawOverlays(ctx, dx, dy, img.width * scale, img.height * scale)
|
|
|
|
// Draw bounding boxes on top
|
|
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
|
|
|
|
const color = sourceColor(box)
|
|
const resolved = box.resolved_brand || box.ocr_text
|
|
|
|
ctx.strokeStyle = color
|
|
ctx.lineWidth = 2
|
|
if (!resolved) {
|
|
ctx.setLineDash([4, 3])
|
|
}
|
|
ctx.strokeRect(bx, by, bw, bh)
|
|
ctx.setLineDash([])
|
|
}
|
|
}
|
|
img.src = `data:image/jpeg;base64,${props.imageSrc}`
|
|
}
|
|
|
|
/** Pending overlay images that need async loading */
|
|
const overlayCache = new Map<string, HTMLImageElement>()
|
|
|
|
function drawOverlays(ctx: CanvasRenderingContext2D, dx: number, dy: number, dw: number, dh: number) {
|
|
const layers = props.overlays ?? []
|
|
for (const layer of layers) {
|
|
if (!layer.visible || !layer.src) continue
|
|
|
|
const cached = overlayCache.get(layer.src)
|
|
if (cached && cached.complete) {
|
|
ctx.globalAlpha = layer.opacity ?? 0.5
|
|
ctx.drawImage(cached, dx, dy, dw, dh)
|
|
ctx.globalAlpha = 1.0
|
|
} else if (!cached) {
|
|
// Load async, redraw when ready
|
|
const overlay = new window.Image()
|
|
overlay.onload = () => draw()
|
|
overlay.src = `data:image/jpeg;base64,${layer.src}`
|
|
overlayCache.set(layer.src, overlay)
|
|
}
|
|
}
|
|
}
|
|
|
|
const SOURCE_COLORS: Record<string, string> = {
|
|
yolo: '#f5a623', // yellow — raw detection
|
|
ocr: '#ff8c42', // orange — text extracted
|
|
ocr_matched: '#3ecf8e', // green — brand resolved
|
|
local_vlm: '#4f9cf9', // blue — VLM resolved
|
|
cloud_llm: '#a78bfa', // purple — cloud resolved
|
|
unresolved: '#e05252', // red — nothing matched
|
|
}
|
|
|
|
// CV region labels — distinct from source-based colors
|
|
const REGION_COLORS: Record<string, string> = {
|
|
edge_region: '#00bcd4', // cyan
|
|
contour_region: '#ffd54f', // yellow
|
|
color_region: '#e040fb', // magenta
|
|
candidate: '#4caf50', // green — passed readability
|
|
rejected: '#e05252', // red — failed readability
|
|
}
|
|
|
|
function sourceColor(box: FrameBBox): string {
|
|
if (REGION_COLORS[box.label]) return REGION_COLORS[box.label]
|
|
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
|
|
if (box.source && SOURCE_COLORS[box.source]) return SOURCE_COLORS[box.source]
|
|
return confidenceColor(box.confidence)
|
|
}
|
|
|
|
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, props.overlays], () => 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>
|