Spaces:
Running
Running
| import { useEffect, useRef } from "react"; | |
| import type { Detection } from "../hooks/LLMContext"; | |
| import { toPixelCoords, DETECTION_COLORS } from "../utils/detection-parser"; | |
| interface DetectionOverlayProps { | |
| imageUrl: string; | |
| detections: Detection[]; | |
| } | |
| export function DetectionOverlay({ imageUrl, detections }: DetectionOverlayProps) { | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const imgRef = useRef<HTMLImageElement>(null); | |
| useEffect(() => { | |
| const canvas = canvasRef.current; | |
| const img = imgRef.current; | |
| if (!canvas || !img || !img.complete) return; | |
| const ctx = canvas.getContext("2d"); | |
| if (!ctx) return; | |
| // Match canvas size to displayed image size | |
| const rect = img.getBoundingClientRect(); | |
| canvas.width = rect.width; | |
| canvas.height = rect.height; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Build unique label map for consistent colors | |
| const labelSet = [...new Set(detections.map((d) => d.label))]; | |
| for (const det of detections) { | |
| const [x1, y1, x2, y2] = toPixelCoords( | |
| det.box_2d, | |
| canvas.width, | |
| canvas.height, | |
| ); | |
| const colorIdx = labelSet.indexOf(det.label); | |
| const color = DETECTION_COLORS[colorIdx % DETECTION_COLORS.length]; | |
| // Draw box | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 2.5; | |
| ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); | |
| // Draw label background | |
| ctx.font = "bold 13px 'Google Sans', sans-serif"; | |
| const textWidth = ctx.measureText(det.label).width; | |
| const labelHeight = 20; | |
| const labelY = Math.max(0, y1 - labelHeight); | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); | |
| ctx.roundRect(x1, labelY, textWidth + 10, labelHeight, 4); | |
| ctx.fill(); | |
| // Draw label text | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.fillText(det.label, x1 + 5, labelY + 14); | |
| } | |
| }, [detections]); | |
| const handleImageLoad = () => { | |
| // Re-trigger detection rendering once image loads | |
| if (detections.length > 0) { | |
| const canvas = canvasRef.current; | |
| const img = imgRef.current; | |
| if (!canvas || !img) return; | |
| const ctx = canvas.getContext("2d"); | |
| if (!ctx) return; | |
| const rect = img.getBoundingClientRect(); | |
| canvas.width = rect.width; | |
| canvas.height = rect.height; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const labelSet = [...new Set(detections.map((d) => d.label))]; | |
| for (const det of detections) { | |
| const [x1, y1, x2, y2] = toPixelCoords( | |
| det.box_2d, | |
| canvas.width, | |
| canvas.height, | |
| ); | |
| const colorIdx = labelSet.indexOf(det.label); | |
| const color = DETECTION_COLORS[colorIdx % DETECTION_COLORS.length]; | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 2.5; | |
| ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); | |
| ctx.font = "bold 13px 'Google Sans', sans-serif"; | |
| const textWidth = ctx.measureText(det.label).width; | |
| const labelHeight = 20; | |
| const labelY = Math.max(0, y1 - labelHeight); | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); | |
| ctx.roundRect(x1, labelY, textWidth + 10, labelHeight, 4); | |
| ctx.fill(); | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.fillText(det.label, x1 + 5, labelY + 14); | |
| } | |
| } | |
| }; | |
| return ( | |
| <div className="relative inline-block w-full"> | |
| <img | |
| ref={imgRef} | |
| src={imageUrl} | |
| alt="Detection result" | |
| className="w-full max-h-[500px] object-contain rounded-xl" | |
| onLoad={handleImageLoad} | |
| /> | |
| <canvas | |
| ref={canvasRef} | |
| className="absolute top-0 left-0 w-full h-full pointer-events-none" | |
| style={{ objectFit: "contain" }} | |
| /> | |
| </div> | |
| ); | |
| } | |