detection 畫框、classification 改在影像右上角疊標籤(不畫框), 並在推論面板提供即時切換解析方式與上傳 label 檔。 - 新增 InferenceOverlay 依 taskType 分派;ClassificationOverlay 用 DOM 而非 canvas,讓 CJK 排版與 aria-live 交給瀏覽器處理 - 標籤防閃爍:挑戰者需連續 3 幀居冠才切換,或信心度領先 15% 直接切; 低於門檻立即清空(顯示過期標籤比空白更糟) - 身分比對優先用 classIndex,避免換 label 檔時被誤判為換類別 - 推論設定卡片:即時切 detection/classification、上傳 .txt label、 清除 label,皆不需重燒 - classification-result 原本無條件渲染,導致 detection 模式下側欄 永遠顯示「分類結果」與空圖表,改為依 taskType 切換 - 清掉 camera-overlay 每幀執行的 debug console.log - 補 ResizeObserver stub,原本任何渲染 InferencePanel 的測試都會 在 jsdom 直接拋錯 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
2.3 KiB
TypeScript
68 lines
2.3 KiB
TypeScript
'use client';
|
|
|
|
import type { ClassResult } from '@/types/inference';
|
|
import { classResultLabel } from '@/lib/classification';
|
|
import { useStableTopClass } from '@/hooks/use-stable-top-class';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
interface ClassificationOverlayProps {
|
|
classifications: ClassResult[];
|
|
width: number;
|
|
height: number;
|
|
confidenceThreshold: number;
|
|
}
|
|
|
|
/**
|
|
* Classification overlay — no bounding boxes.
|
|
*
|
|
* Classification models produce a whole-image verdict rather than localised
|
|
* objects, so we annotate the frame with a single top-1 chip instead of
|
|
* drawing rectangles. Rendered as absolutely positioned DOM (not canvas) so
|
|
* that CJK label text, rounded corners and theming come from CSS rather than
|
|
* hand-rolled `fillText` layout, and so screen readers can announce the result.
|
|
*
|
|
* The chip sits on the top-right, because CameraFeed already occupies the
|
|
* top-left with the source-type badge.
|
|
*/
|
|
export function ClassificationOverlay({
|
|
classifications,
|
|
width,
|
|
height,
|
|
confidenceThreshold,
|
|
}: ClassificationOverlayProps) {
|
|
const { t } = useTranslation();
|
|
const top = useStableTopClass(classifications, { confidenceThreshold });
|
|
|
|
return (
|
|
<div
|
|
style={{ width, height }}
|
|
className="absolute left-0 top-0 pointer-events-none"
|
|
data-testid="classification-overlay"
|
|
>
|
|
<div
|
|
className="absolute right-2 top-2 max-w-[70%] rounded-md bg-black/70 px-3 py-2 text-white shadow-lg"
|
|
// Announce the verdict for screen-reader users; `polite` avoids
|
|
// interrupting on every frame of a video stream.
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
>
|
|
{top ? (
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="truncate text-base font-semibold" data-testid="classification-overlay-label">
|
|
{classResultLabel(top)}
|
|
</span>
|
|
<span className="shrink-0 text-sm tabular-nums opacity-80" data-testid="classification-overlay-confidence">
|
|
{(top.confidence * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<span className="text-sm opacity-80" data-testid="classification-overlay-empty">
|
|
{t('inference.unrecognized')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|