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

130 lines
4.7 KiB
TypeScript

'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ClassificationResult } from './classification-result';
import { DetectionResultList } from './detection-result';
import { PerformanceMetrics } from './performance-metrics';
import { ConfidenceSlider } from './confidence-slider';
import { VideoProgress } from './video-progress';
import { InferenceOptions } from './inference-options';
import { useInferenceStore } from '@/stores/inference-store';
import { useCameraStore } from '@/stores/camera-store';
import { isClassificationTask } from '@/lib/classification';
import { useTranslation } from '@/lib/i18n';
interface InferencePanelProps {
deviceId: string;
}
export function InferencePanel({ deviceId }: InferencePanelProps) {
const { t } = useTranslation();
const { result, fps, avgLatency, isRunning, confidenceThreshold, batchResults } =
useInferenceStore();
const { sourceType, batchSelectedIndex, batchImages, batchProcessedCount } =
useCameraStore();
const isVideoMode = sourceType === 'video';
const isBatchMode = sourceType === 'batch_image';
const displayResult = isBatchMode
? batchResults[batchSelectedIndex]
: result;
const classifications = displayResult?.classifications || [];
const detections = displayResult?.detections || [];
// Branch on "is classification?" so detection stays the safe fall-through
// under either taskType spelling (see lib/classification.ts).
const isClassification = isClassificationTask(displayResult?.taskType);
return (
<div className="w-80 space-y-4">
<PerformanceMetrics fps={fps} avgLatency={avgLatency} isRunning={isRunning} />
{isVideoMode && isRunning && <VideoProgress />}
{isBatchMode && batchImages.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('inference.batchProgress')}</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('inference.processed')}</span>
<span>{batchProcessedCount} / {batchImages.length}</span>
</div>
{batchImages[batchSelectedIndex] && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('inference.currentImage')}</span>
<span className="truncate max-w-32 text-right">
{batchImages[batchSelectedIndex].filename}
</span>
</div>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('inference.confidenceFilter')}</CardTitle>
</CardHeader>
<CardContent>
<ConfidenceSlider />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('inference.options.title')}</CardTitle>
</CardHeader>
<CardContent>
<InferenceOptions deviceId={deviceId} resultTaskType={displayResult?.taskType} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
{isClassification
? t('inference.classificationResults')
: t('inference.detectionResults')}
</CardTitle>
</CardHeader>
<CardContent>
{isClassification ? (
<ClassificationResult
results={classifications}
confidenceThreshold={confidenceThreshold}
/>
) : (
<DetectionResultList
results={detections}
confidenceThreshold={confidenceThreshold}
/>
)}
</CardContent>
</Card>
{displayResult && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('inference.details')}</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('inference.model')}</span>
<span>{displayResult.modelId}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('inference.task')}</span>
<span>{displayResult.taskType}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('inference.latency')}</span>
<span>{displayResult.latencyMs.toFixed(1)} ms</span>
</div>
</CardContent>
</Card>
)}
</div>
);
}