visionA/local-tool/frontend/src/components/camera/classification-overlay.tsx
jim800121chen 5c1c37d151 feat(frontend): classification 結果呈現與推論期控制項
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>
2026-07-22 19:27:28 +08:00

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>
);
}