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>
This commit is contained in:
parent
4583406efa
commit
5c1c37d151
@ -8,6 +8,7 @@ import { InferencePanel } from '@/components/inference/inference-panel';
|
||||
import { FlashDialog } from '@/components/devices/flash-dialog';
|
||||
import { useDeviceStore } from '@/stores/device-store';
|
||||
import { useInferenceStore } from '@/stores/inference-store';
|
||||
import { useInferenceOptionsStore } from '@/stores/inference-options-store';
|
||||
import { useInferenceStream } from '@/hooks/use-inference-stream';
|
||||
import { useCameraStore } from '@/stores/camera-store';
|
||||
import { useResolvedParams } from '@/hooks/use-resolved-params';
|
||||
@ -17,6 +18,7 @@ export default function WorkspaceClient() {
|
||||
const { deviceId } = useResolvedParams();
|
||||
const { selectedDevice, fetchDevice } = useDeviceStore();
|
||||
const { isRunning, setRunning, reset } = useInferenceStore();
|
||||
const resetInferenceOptions = useInferenceOptionsStore((s) => s.reset);
|
||||
const { isStreaming, sourceType } = useCameraStore();
|
||||
|
||||
// For image/video mode, inference runs automatically as part of the pipeline
|
||||
@ -41,8 +43,11 @@ export default function WorkspaceClient() {
|
||||
}
|
||||
return () => {
|
||||
reset();
|
||||
// Options are per-session and per-device; leaving them set would apply a
|
||||
// label mapping uploaded for one device to the next one opened.
|
||||
resetInferenceOptions();
|
||||
};
|
||||
}, [deviceId, fetchDevice, fetchCameras, reset]);
|
||||
}, [deviceId, fetchDevice, fetchCameras, reset, resetInferenceOptions]);
|
||||
|
||||
const handleStartInference = async () => {
|
||||
await api.post(`/devices/${deviceId}/inference/start`);
|
||||
@ -89,7 +94,7 @@ export default function WorkspaceClient() {
|
||||
<CameraInferenceView deviceId={deviceId} />
|
||||
</div>
|
||||
<div className="w-80 shrink-0">
|
||||
<InferencePanel />
|
||||
<InferencePanel deviceId={deviceId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { CameraFeed } from './camera-feed';
|
||||
import { CameraOverlay } from './camera-overlay';
|
||||
import { InferenceOverlay } from './inference-overlay';
|
||||
import { SourceSelector } from './source-selector';
|
||||
import { BatchImageThumbnails } from './batch-image-thumbnails';
|
||||
import { useCameraStore } from '@/stores/camera-store';
|
||||
@ -25,11 +25,10 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) {
|
||||
setRenderedSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h }));
|
||||
}, []);
|
||||
|
||||
// In batch mode, show the selected image's detections
|
||||
// In batch mode, show the selected image's result
|
||||
const selectedResult = isBatchMode
|
||||
? batchResults[batchSelectedIndex]
|
||||
: result;
|
||||
const detections = selectedResult?.detections || [];
|
||||
|
||||
// In batch mode, use static image endpoint for viewing selected image
|
||||
const batchImageUrl = isBatchMode
|
||||
@ -48,8 +47,8 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) {
|
||||
onDimensionsChange={handleDimensionsChange}
|
||||
overlay={
|
||||
isStreaming && renderedSize ? (
|
||||
<CameraOverlay
|
||||
detections={detections}
|
||||
<InferenceOverlay
|
||||
result={selectedResult}
|
||||
width={renderedSize.w}
|
||||
height={renderedSize.h}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
|
||||
@ -25,16 +25,6 @@ export function CameraOverlay({ detections, width, height, confidenceThreshold }
|
||||
|
||||
const filtered = detections.filter((d) => d.confidence >= confidenceThreshold);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// TEMP debug: 驗證 bbox coordinate space 對齊問題
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[bbox-debug] canvas=%dx%d total=%d filtered=%d threshold=%s', width, height, detections.length, filtered.length, confidenceThreshold, filtered.map((d) => ({
|
||||
label: d.label,
|
||||
bbox: d.bbox,
|
||||
conf: d.confidence,
|
||||
})));
|
||||
}
|
||||
|
||||
filtered.forEach((det, i) => {
|
||||
const color = COLORS[i % COLORS.length];
|
||||
// Convert normalized coordinates (0-1) to pixel values
|
||||
@ -68,6 +58,7 @@ export function CameraOverlay({ detections, width, height, confidenceThreshold }
|
||||
height={height}
|
||||
style={{ width, height }}
|
||||
className="absolute left-0 top-0 pointer-events-none"
|
||||
data-testid="camera-overlay"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import type { InferenceResult } from '@/types/inference';
|
||||
import { isClassificationTask } from '@/lib/classification';
|
||||
import { CameraOverlay } from './camera-overlay';
|
||||
import { ClassificationOverlay } from './classification-overlay';
|
||||
|
||||
interface InferenceOverlayProps {
|
||||
result: InferenceResult | null | undefined;
|
||||
width: number;
|
||||
height: number;
|
||||
confidenceThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the frame overlay based on the task type reported by the
|
||||
* inference result itself.
|
||||
*
|
||||
* `result.taskType` is preferred over the model metadata because it reflects
|
||||
* what the Python post-processor actually did — metadata can disagree with the
|
||||
* executed code path.
|
||||
*
|
||||
* Note the branch is written as "is this classification?" rather than "is this
|
||||
* detection?" on purpose: the detection task type string has been inconsistent
|
||||
* across the stack (`detection` vs `object_detection`, plan R-4), so detection
|
||||
* is the fall-through default and stays correct under either spelling.
|
||||
*/
|
||||
export function InferenceOverlay({
|
||||
result,
|
||||
width,
|
||||
height,
|
||||
confidenceThreshold,
|
||||
}: InferenceOverlayProps) {
|
||||
if (isClassificationTask(result?.taskType)) {
|
||||
return (
|
||||
<ClassificationOverlay
|
||||
classifications={result?.classifications || []}
|
||||
width={width}
|
||||
height={height}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CameraOverlay
|
||||
detections={result?.detections || []}
|
||||
width={width}
|
||||
height={height}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -39,11 +39,16 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
|
||||
const device = devices.find((d) => d.id === deviceId);
|
||||
const selectedModel = models.find((m) => m.id === selectedModelId);
|
||||
|
||||
// S2: 資料載入前預設 compatible=true,避免在 model/device 還沒載入時就顯示不相容警告
|
||||
const compatible = useMemo(() => {
|
||||
if (!selectedModel || !device) return true;
|
||||
return isModelCompatible(selectedModel.supportedHardware, device.type);
|
||||
}, [selectedModel, device]);
|
||||
// 載入模型時不選推論種類:解析方式改由推論頁(InferenceOptions)即時切換,
|
||||
// 那裡不需要重燒就能改,功能完全涵蓋燒錄時選一次的舊做法。
|
||||
|
||||
// S2: 資料載入前預設 compatible=true,避免在 model/device 還沒載入時就顯示不相容警告。
|
||||
// 這裡不再包 useMemo —— isModelCompatible 只是一次陣列比對,手動 memo 反而讓
|
||||
// React Compiler 整個元件跳過優化(react-hooks/preserve-manual-memoization)。
|
||||
const compatible =
|
||||
!selectedModel || !device
|
||||
? true
|
||||
: isModelCompatible(selectedModel.supportedHardware, device.type);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Cell } from 'recharts';
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
import { classResultLabel } from '@/lib/classification';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
interface ClassificationResultProps {
|
||||
@ -18,8 +19,10 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 8);
|
||||
|
||||
// Labels may be missing when the model ships without a label mapping — fall
|
||||
// back to the raw class index (`class_<n>`) rather than rendering blank bars.
|
||||
const data = filtered.map((r) => ({
|
||||
label: r.label,
|
||||
label: classResultLabel(r),
|
||||
confidence: +(r.confidence * 100).toFixed(1),
|
||||
}));
|
||||
|
||||
@ -32,6 +35,7 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="classification-result-chart">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 80, right: 20, top: 5, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@ -44,5 +48,6 @@ export function ClassificationResult({ results, confidenceThreshold }: Classific
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import type { DetectionResult } from '@/types/inference';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
interface DetectionResultListProps {
|
||||
results: DetectionResult[];
|
||||
confidenceThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detected object list for the side panel.
|
||||
*
|
||||
* Detection results are spatial, so the primary read is the bounding boxes on
|
||||
* the frame itself; this list is the secondary, textual read (what was found,
|
||||
* how many, how confident) which the canvas cannot convey to assistive tech.
|
||||
*/
|
||||
export function DetectionResultList({ results, confidenceThreshold }: DetectionResultListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const filtered = results
|
||||
.filter((d) => d.confidence >= confidenceThreshold)
|
||||
.sort((a, b) => b.confidence - a.confidence);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
|
||||
{t('inference.noResultsAboveThreshold')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="detection-result-list">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('inference.detectedCount')}</span>
|
||||
<span className="tabular-nums">{filtered.length}</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{filtered.map((det, i) => (
|
||||
<li
|
||||
key={`${det.label}-${i}`}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="truncate">{det.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{(det.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
useInferenceOptionsStore,
|
||||
validateLabelFile,
|
||||
MAX_LABEL_FILE_BYTES,
|
||||
ACCEPTED_LABEL_EXTENSIONS,
|
||||
} from '@/stores/inference-options-store';
|
||||
import { FLASH_TASK_TYPES, normalizeTaskType, type FlashTaskType } from '@/lib/task-type';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
interface InferenceOptionsProps {
|
||||
deviceId: string;
|
||||
/**
|
||||
* Task type reported by the most recent inference result. Used as the
|
||||
* selector's value until the user overrides it, so the control opens showing
|
||||
* what the pipeline is actually doing rather than a guess.
|
||||
*/
|
||||
resultTaskType: string | undefined;
|
||||
}
|
||||
|
||||
/** Local-only errors that never reach the server (size / extension). */
|
||||
type LocalError = 'too-large' | 'wrong-type' | null;
|
||||
|
||||
/** Preview of the first few label names, so the user can spot a wrong file. */
|
||||
const LABEL_PREVIEW_COUNT = 3;
|
||||
|
||||
/**
|
||||
* Live inference controls (M4).
|
||||
*
|
||||
* Lets the user swap the parsing mode and attach a display-only label mapping
|
||||
* mid-session, without re-flashing the model. Sits in the right-hand
|
||||
* InferencePanel next to the confidence slider — the other control that
|
||||
* re-interprets results already on screen — so all the "how do I read this
|
||||
* output" knobs live together and none of them block the video area.
|
||||
*
|
||||
* The label section is only rendered for classification: labels map class
|
||||
* indices to names, which is meaningless for detection (whose labels come from
|
||||
* the model metadata per box).
|
||||
*/
|
||||
export function InferenceOptions({ deviceId, resultTaskType }: InferenceOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [localError, setLocalError] = useState<LocalError>(null);
|
||||
const { taskType, labels, applying, error, setTaskType, uploadLabels, clearLabels } =
|
||||
useInferenceOptionsStore();
|
||||
|
||||
// The user's explicit choice wins; otherwise mirror what the pipeline last
|
||||
// reported. Both go through normalizeTaskType so an unknown/legacy spelling
|
||||
// (plan R-4) falls through to object detection instead of rendering blank.
|
||||
const effectiveTaskType: FlashTaskType = taskType ?? normalizeTaskType(resultTaskType);
|
||||
const isClassification = effectiveTaskType === 'classification';
|
||||
|
||||
const taskTypeLabel = (value: FlashTaskType) =>
|
||||
value === 'classification'
|
||||
? t('devices.flash.taskTypeClassification')
|
||||
: t('devices.flash.taskTypeObjectDetection');
|
||||
|
||||
const handleTaskTypeChange = (value: string) => {
|
||||
setLocalError(null);
|
||||
void setTaskType(deviceId, normalizeTaskType(value));
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset the input so picking the SAME file again still fires a change
|
||||
// event — otherwise a user who fixed their labels.txt on disk and re-picked
|
||||
// it would get no feedback at all.
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const check = validateLabelFile(file);
|
||||
if (!check.ok) {
|
||||
setLocalError(check.reason);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
void uploadLabels(deviceId, file);
|
||||
};
|
||||
|
||||
const handleClearLabels = () => {
|
||||
setLocalError(null);
|
||||
void clearLabels(deviceId);
|
||||
};
|
||||
|
||||
const localErrorMessage =
|
||||
localError === 'too-large'
|
||||
? t('inference.options.labelFileTooLarge', {
|
||||
limit: `${Math.round(MAX_LABEL_FILE_BYTES / 1024)} KB`,
|
||||
})
|
||||
: localError === 'wrong-type'
|
||||
? t('inference.options.labelFileWrongType')
|
||||
: null;
|
||||
|
||||
// Server errors carry a line number for parse failures — surfacing it is the
|
||||
// whole reason parsing happens server-side.
|
||||
const serverErrorMessage = error
|
||||
? error.line != null
|
||||
? t('inference.options.labelParseErrorLine', { line: error.line, message: error.message })
|
||||
: t('inference.options.applyFailed', { message: error.message })
|
||||
: null;
|
||||
|
||||
const errorMessage = localErrorMessage ?? serverErrorMessage;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="inference-options">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="inference-task-type"
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{t('inference.options.taskType')}
|
||||
</label>
|
||||
<Select
|
||||
value={effectiveTaskType}
|
||||
onValueChange={handleTaskTypeChange}
|
||||
disabled={applying}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="inference-task-type"
|
||||
aria-describedby="inference-task-type-hint"
|
||||
data-testid="inference-task-type-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLASH_TASK_TYPES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{taskTypeLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p id="inference-task-type-hint" className="text-xs text-muted-foreground">
|
||||
{t('inference.options.taskTypeHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isClassification && (
|
||||
<div className="space-y-1.5 border-t pt-3" data-testid="inference-label-section">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t('inference.options.labels')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('inference.options.labelsHint')}</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_LABEL_EXTENSIONS.join(',')}
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
data-testid="inference-label-file-input"
|
||||
aria-label={t('inference.options.selectLabelFile')}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={applying}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="inference-label-upload-btn"
|
||||
>
|
||||
{labels
|
||||
? t('inference.options.replaceLabelFile')
|
||||
: t('inference.options.selectLabelFile')}
|
||||
</Button>
|
||||
{labels && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={applying}
|
||||
onClick={handleClearLabels}
|
||||
data-testid="inference-label-clear-btn"
|
||||
>
|
||||
{t('inference.options.clearLabels')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('inference.options.labelsFormatHint')}
|
||||
</p>
|
||||
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
// The applied/cleared state changes as a result of an async request,
|
||||
// so announce it rather than leaving screen-reader users guessing.
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="inference-label-status"
|
||||
>
|
||||
{applying
|
||||
? t('inference.options.applying')
|
||||
: labels
|
||||
? t('inference.options.labelsApplied', {
|
||||
count: labels.length,
|
||||
names: labels.slice(0, LABEL_PREVIEW_COUNT).join('、'),
|
||||
})
|
||||
: t('inference.options.noLabels')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<p
|
||||
className="text-xs text-destructive"
|
||||
role="alert"
|
||||
data-testid="inference-options-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,14 +2,21 @@
|
||||
|
||||
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';
|
||||
|
||||
export function InferencePanel() {
|
||||
interface InferencePanelProps {
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export function InferencePanel({ deviceId }: InferencePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { result, fps, avgLatency, isRunning, confidenceThreshold, batchResults } =
|
||||
useInferenceStore();
|
||||
@ -22,6 +29,10 @@ export function InferencePanel() {
|
||||
? 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">
|
||||
@ -62,13 +73,33 @@ export function InferencePanel() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{t('inference.classificationResults')}</CardTitle>
|
||||
<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>
|
||||
|
||||
|
||||
72
local-tool/frontend/src/hooks/use-stable-top-class.ts
Normal file
72
local-tool/frontend/src/hooks/use-stable-top-class.ts
Normal file
@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
import {
|
||||
DEFAULT_HYSTERESIS,
|
||||
INITIAL_HYSTERESIS_STATE,
|
||||
nextHysteresisState,
|
||||
type HysteresisState,
|
||||
} from '@/lib/classification';
|
||||
|
||||
interface UseStableTopClassOptions {
|
||||
confidenceThreshold: number;
|
||||
streakFrames?: number;
|
||||
immediateMargin?: number;
|
||||
}
|
||||
|
||||
interface InternalState {
|
||||
hysteresis: HysteresisState;
|
||||
/** The `classifications` array identity the hysteresis state was derived from. */
|
||||
seenClassifications: ClassResult[] | undefined;
|
||||
seenConfidenceThreshold: number;
|
||||
}
|
||||
|
||||
const INITIAL_INTERNAL: InternalState = {
|
||||
hysteresis: INITIAL_HYSTERESIS_STATE,
|
||||
seenClassifications: undefined,
|
||||
seenConfidenceThreshold: NaN,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the anti-flicker top-1 class for the current frame.
|
||||
*
|
||||
* The hysteresis state machine advances once per inference result — the store
|
||||
* always hands us a fresh `classifications` array, so array identity is a
|
||||
* reliable "new frame" signal. Static image sources produce a single result and
|
||||
* therefore a single transition, which the state machine handles (the first
|
||||
* result above the threshold is displayed immediately).
|
||||
*
|
||||
* Uses React's "adjust state while rendering" pattern rather than a ref or an
|
||||
* effect: an effect-driven version would render one frame behind, which on a
|
||||
* 15 FPS stream is visible lag, and a ref mutated during render is unsafe under
|
||||
* concurrent rendering.
|
||||
* See https://react.dev/reference/react/useState#storing-information-from-previous-renders
|
||||
*/
|
||||
export function useStableTopClass(
|
||||
classifications: ClassResult[] | undefined,
|
||||
{ confidenceThreshold, streakFrames, immediateMargin }: UseStableTopClassOptions,
|
||||
): ClassResult | null {
|
||||
const [state, setState] = useState<InternalState>(INITIAL_INTERNAL);
|
||||
|
||||
let current = state;
|
||||
// Recompute when a new frame arrives, or when the user moves the confidence
|
||||
// slider (the currently displayed label may no longer qualify).
|
||||
if (
|
||||
state.seenClassifications !== classifications ||
|
||||
state.seenConfidenceThreshold !== confidenceThreshold
|
||||
) {
|
||||
current = {
|
||||
hysteresis: nextHysteresisState(state.hysteresis, classifications, {
|
||||
streakFrames: streakFrames ?? DEFAULT_HYSTERESIS.streakFrames,
|
||||
immediateMargin: immediateMargin ?? DEFAULT_HYSTERESIS.immediateMargin,
|
||||
confidenceThreshold,
|
||||
}),
|
||||
seenClassifications: classifications,
|
||||
seenConfidenceThreshold: confidenceThreshold,
|
||||
};
|
||||
setState(current);
|
||||
}
|
||||
|
||||
return current.hysteresis.displayed;
|
||||
}
|
||||
@ -1,12 +1,22 @@
|
||||
import { getApiBaseUrl, getRelayToken, fetchAndCacheRelayToken } from './constants';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard response envelope.
|
||||
*
|
||||
* `E` widens the error shape for endpoints that attach extra diagnostics — the
|
||||
* label upload returns a `line` number on parse failures, and that field has to
|
||||
* be declared rather than cast in, or the type system stops protecting the one
|
||||
* piece of information the user actually needs.
|
||||
*/
|
||||
export interface ApiResponse<T, E extends ApiError = ApiError> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: E;
|
||||
}
|
||||
|
||||
// Ensure relay token is available before making API requests.
|
||||
@ -40,7 +50,10 @@ function buildHeaders(): Record<string, string> {
|
||||
};
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<ApiResponse<T>> {
|
||||
async function request<T, E extends ApiError = ApiError>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<ApiResponse<T, E>> {
|
||||
// Wait for relay token to be available before first request
|
||||
await ensureRelayToken();
|
||||
|
||||
@ -51,10 +64,32 @@ async function request<T>(path: string, options?: RequestInit): Promise<ApiRespo
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart POST.
|
||||
*
|
||||
* Deliberately does NOT go through `buildHeaders()`: setting Content-Type
|
||||
* manually would omit the multipart boundary that `fetch` generates from the
|
||||
* FormData body, and the server would fail to parse the request.
|
||||
*/
|
||||
async function postForm<T, E extends ApiError = ApiError>(
|
||||
path: string,
|
||||
form: FormData,
|
||||
): Promise<ApiResponse<T, E>> {
|
||||
await ensureRelayToken();
|
||||
|
||||
const res = await fetch(`${getApiBaseUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: getRelayHeaders(),
|
||||
body: form,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
post: <T, E extends ApiError = ApiError>(path: string, body?: unknown) =>
|
||||
request<T, E>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
postForm,
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
||||
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
|
||||
146
local-tool/frontend/src/lib/classification.ts
Normal file
146
local-tool/frontend/src/lib/classification.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
|
||||
/**
|
||||
* Defensive taskType check.
|
||||
*
|
||||
* The taskType value set has historically been inconsistent across the stack:
|
||||
* the Python bridge used to emit `"detection"` while models.json / the
|
||||
* frontend TASK_TYPES constant use `"object_detection"` (see plan R-4).
|
||||
*
|
||||
* We therefore only ever branch on "is this classification?" — anything else
|
||||
* (including `undefined`) falls through to the existing detection rendering
|
||||
* path, which is the safe default: a detection model rendered as detection is
|
||||
* correct, whereas a mis-detected classification just means no overlay label.
|
||||
*/
|
||||
export function isClassificationTask(taskType: string | undefined | null): boolean {
|
||||
return taskType === 'classification';
|
||||
}
|
||||
|
||||
/**
|
||||
* Display label for a class result.
|
||||
*
|
||||
* `classIndex` is optional because the Go layer may not forward it (M2-d is a
|
||||
* "nice to have"). When the label is missing/blank we fall back to
|
||||
* `class_<index>`, and if we have neither we emit a stable placeholder rather
|
||||
* than rendering an empty chip.
|
||||
*/
|
||||
export function classResultLabel(result: Pick<ClassResult, 'label' | 'classIndex'>): string {
|
||||
const label = result.label?.trim();
|
||||
if (label) return label;
|
||||
if (typeof result.classIndex === 'number' && Number.isFinite(result.classIndex)) {
|
||||
return `class_${result.classIndex}`;
|
||||
}
|
||||
return 'class_?';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a class result, used by the hysteresis state machine to
|
||||
* decide whether two consecutive frames refer to the same class.
|
||||
*
|
||||
* Prefers `classIndex` (immune to label churn when labels are re-uploaded) and
|
||||
* falls back to the label string when the index is absent.
|
||||
*/
|
||||
export function classResultKey(result: Pick<ClassResult, 'label' | 'classIndex'>): string {
|
||||
if (typeof result.classIndex === 'number' && Number.isFinite(result.classIndex)) {
|
||||
return `i:${result.classIndex}`;
|
||||
}
|
||||
return `l:${classResultLabel(result)}`;
|
||||
}
|
||||
|
||||
/** Highest-confidence entry, or null when there is nothing to show. */
|
||||
export function pickTopClass(results: ClassResult[] | undefined): ClassResult | null {
|
||||
if (!results || results.length === 0) return null;
|
||||
let top = results[0];
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
if (results[i].confidence > top.confidence) top = results[i];
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
export interface HysteresisState {
|
||||
/** Currently displayed class (null = nothing displayed yet). */
|
||||
displayed: ClassResult | null;
|
||||
/** Candidate class that is trying to replace `displayed`. */
|
||||
candidateKey: string | null;
|
||||
/** How many consecutive frames the candidate has been the top class. */
|
||||
candidateStreak: number;
|
||||
}
|
||||
|
||||
export interface HysteresisOptions {
|
||||
/**
|
||||
* Frames the challenger must stay on top before it replaces the displayed
|
||||
* class. 3 @ ~15 FPS ≈ 200 ms — long enough to swallow single-frame noise,
|
||||
* short enough that a genuine gesture change still feels instant.
|
||||
*/
|
||||
streakFrames: number;
|
||||
/**
|
||||
* Confidence margin that lets a challenger switch immediately, bypassing the
|
||||
* streak counter. 0.15 means "clearly more confident, not a coin flip".
|
||||
*/
|
||||
immediateMargin: number;
|
||||
/**
|
||||
* Below this confidence nothing is displayed. Mirrors the user-facing
|
||||
* confidence slider so the overlay agrees with the side panel.
|
||||
*/
|
||||
confidenceThreshold: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_HYSTERESIS: Pick<HysteresisOptions, 'streakFrames' | 'immediateMargin'> = {
|
||||
streakFrames: 3,
|
||||
immediateMargin: 0.15,
|
||||
};
|
||||
|
||||
export const INITIAL_HYSTERESIS_STATE: HysteresisState = {
|
||||
displayed: null,
|
||||
candidateKey: null,
|
||||
candidateStreak: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Anti-flicker state machine for the top-1 classification label.
|
||||
*
|
||||
* Video/camera sources re-classify every frame, so a 50/50 boundary makes the
|
||||
* raw top-1 oscillate wildly (plan risk R-9). A frame is only promoted when it
|
||||
* either (a) beats the incumbent by a clear confidence margin, or (b) has been
|
||||
* the top class for `streakFrames` consecutive frames.
|
||||
*
|
||||
* Pure function: takes the previous state, returns the next one. No React,
|
||||
* no timers — which makes it directly unit-testable.
|
||||
*/
|
||||
export function nextHysteresisState(
|
||||
prev: HysteresisState,
|
||||
results: ClassResult[] | undefined,
|
||||
options: HysteresisOptions,
|
||||
): HysteresisState {
|
||||
const { streakFrames, immediateMargin, confidenceThreshold } = options;
|
||||
const top = pickTopClass(results);
|
||||
|
||||
// Nothing above the threshold → clear immediately. Showing a stale label over
|
||||
// a frame the model no longer recognises is worse than showing nothing.
|
||||
if (!top || top.confidence < confidenceThreshold) {
|
||||
return INITIAL_HYSTERESIS_STATE;
|
||||
}
|
||||
|
||||
// First result, or the incumbent already matches → refresh confidence in place.
|
||||
if (!prev.displayed) {
|
||||
return { displayed: top, candidateKey: null, candidateStreak: 0 };
|
||||
}
|
||||
|
||||
const topKey = classResultKey(top);
|
||||
if (topKey === classResultKey(prev.displayed)) {
|
||||
return { displayed: top, candidateKey: null, candidateStreak: 0 };
|
||||
}
|
||||
|
||||
// Clear winner → switch without waiting for the streak.
|
||||
if (top.confidence - prev.displayed.confidence >= immediateMargin) {
|
||||
return { displayed: top, candidateKey: null, candidateStreak: 0 };
|
||||
}
|
||||
|
||||
const streak = prev.candidateKey === topKey ? prev.candidateStreak + 1 : 1;
|
||||
if (streak >= streakFrames) {
|
||||
return { displayed: top, candidateKey: null, candidateStreak: 0 };
|
||||
}
|
||||
|
||||
// Challenger not convincing enough yet — keep showing the incumbent.
|
||||
return { displayed: prev.displayed, candidateKey: topKey, candidateStreak: streak };
|
||||
}
|
||||
@ -125,6 +125,12 @@ export const en: TranslationDict = {
|
||||
flashFailed: 'Flash Failed',
|
||||
preparingFlash: 'Preparing flash...',
|
||||
flashComplete: 'Flash complete!',
|
||||
selectModelFirst: 'Select a model first',
|
||||
// Display names for the inference types. The flash dialog no longer
|
||||
// selects a type, but the inference page's InferenceOptions still shares
|
||||
// these two labels, so they stay.
|
||||
taskTypeObjectDetection: 'Object Detection',
|
||||
taskTypeClassification: 'Classification',
|
||||
},
|
||||
card: {
|
||||
fwBadge: {
|
||||
@ -234,6 +240,9 @@ export const en: TranslationDict = {
|
||||
confidenceFilter: 'Confidence Filter',
|
||||
confidenceThreshold: 'Confidence Threshold',
|
||||
classificationResults: 'Classification Results',
|
||||
detectionResults: 'Detection Results',
|
||||
detectedCount: 'Detected',
|
||||
unrecognized: 'Unrecognized',
|
||||
noResultsAboveThreshold: 'No results above threshold',
|
||||
details: 'Details',
|
||||
model: 'Model',
|
||||
@ -250,6 +259,27 @@ export const en: TranslationDict = {
|
||||
videoProgress: 'Video Progress',
|
||||
frames: 'frames',
|
||||
framesProcessed: 'Frames Processed',
|
||||
options: {
|
||||
title: 'Inference Options',
|
||||
taskType: 'Parsing Mode',
|
||||
taskTypeHint:
|
||||
'Applies to subsequent results immediately — no need to re-flash the model.',
|
||||
applying: 'Applying...',
|
||||
applyFailed: 'Could not apply: {message}',
|
||||
labels: 'Label Mapping',
|
||||
labelsHint:
|
||||
'Optional. Without a label file the raw class indices are shown (class_0, class_1…).',
|
||||
labelsFormatHint: 'One "<index> <name>" per line, e.g. 0 scissors',
|
||||
selectLabelFile: 'Upload label file',
|
||||
replaceLabelFile: 'Replace label file',
|
||||
clearLabels: 'Clear labels',
|
||||
labelsApplied: 'Applied {count} labels ({names})',
|
||||
labelsCleared: 'Labels cleared — raw class indices will be shown',
|
||||
noLabels: 'No label file uploaded',
|
||||
labelFileTooLarge: 'Label file is too large (limit {limit})',
|
||||
labelFileWrongType: 'Only .txt or .names files are accepted',
|
||||
labelParseErrorLine: 'Line {line}: {message}',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
|
||||
@ -123,6 +123,10 @@ export interface TranslationDict {
|
||||
flashFailed: string;
|
||||
preparingFlash: string;
|
||||
flashComplete: string;
|
||||
selectModelFirst: string;
|
||||
// Shared with the inference page's InferenceOptions selector.
|
||||
taskTypeObjectDetection: string;
|
||||
taskTypeClassification: string;
|
||||
};
|
||||
card: {
|
||||
fwBadge: {
|
||||
@ -232,6 +236,9 @@ export interface TranslationDict {
|
||||
confidenceFilter: string;
|
||||
confidenceThreshold: string;
|
||||
classificationResults: string;
|
||||
detectionResults: string;
|
||||
detectedCount: string;
|
||||
unrecognized: string;
|
||||
noResultsAboveThreshold: string;
|
||||
details: string;
|
||||
model: string;
|
||||
@ -248,6 +255,25 @@ export interface TranslationDict {
|
||||
videoProgress: string;
|
||||
frames: string;
|
||||
framesProcessed: string;
|
||||
options: {
|
||||
title: string;
|
||||
taskType: string;
|
||||
taskTypeHint: string;
|
||||
applying: string;
|
||||
applyFailed: string;
|
||||
labels: string;
|
||||
labelsHint: string;
|
||||
labelsFormatHint: string;
|
||||
selectLabelFile: string;
|
||||
replaceLabelFile: string;
|
||||
clearLabels: string;
|
||||
labelsApplied: string;
|
||||
labelsCleared: string;
|
||||
noLabels: string;
|
||||
labelFileTooLarge: string;
|
||||
labelFileWrongType: string;
|
||||
labelParseErrorLine: string;
|
||||
};
|
||||
};
|
||||
settings: {
|
||||
title: string;
|
||||
|
||||
@ -125,6 +125,11 @@ export const zhTW: TranslationDict = {
|
||||
flashFailed: '燒錄失敗',
|
||||
preparingFlash: '準備燒錄中...',
|
||||
flashComplete: '燒錄完成!',
|
||||
selectModelFirst: '請先選擇模型',
|
||||
// 推論種類的顯示名稱。燒錄對話框已不再選推論種類,但推論頁的
|
||||
// InferenceOptions 仍共用這兩個標籤,故保留。
|
||||
taskTypeObjectDetection: '物件偵測',
|
||||
taskTypeClassification: '分類',
|
||||
},
|
||||
card: {
|
||||
fwBadge: {
|
||||
@ -234,6 +239,9 @@ export const zhTW: TranslationDict = {
|
||||
confidenceFilter: '信心度篩選',
|
||||
confidenceThreshold: '信心度門檻',
|
||||
classificationResults: '分類結果',
|
||||
detectionResults: '偵測結果',
|
||||
detectedCount: '偵測數量',
|
||||
unrecognized: '無法辨識',
|
||||
noResultsAboveThreshold: '沒有超過門檻的結果',
|
||||
details: '詳細資訊',
|
||||
model: '模型',
|
||||
@ -250,6 +258,25 @@ export const zhTW: TranslationDict = {
|
||||
videoProgress: '影片進度',
|
||||
frames: '幀',
|
||||
framesProcessed: '已處理幀數',
|
||||
options: {
|
||||
title: '推論設定',
|
||||
taskType: '解析方式',
|
||||
taskTypeHint: '切換後立即套用到之後的推論結果,不需要重新載入模型。',
|
||||
applying: '套用中...',
|
||||
applyFailed: '套用失敗:{message}',
|
||||
labels: '標籤對照',
|
||||
labelsHint: '選用。沒有上傳時會顯示原始類別編號(class_0、class_1…)。',
|
||||
labelsFormatHint: '每行一筆「編號 名稱」,例如:0 剪刀',
|
||||
selectLabelFile: '上傳標籤檔',
|
||||
replaceLabelFile: '更換標籤檔',
|
||||
clearLabels: '清除標籤',
|
||||
labelsApplied: '已套用 {count} 個標籤({names})',
|
||||
labelsCleared: '已清除標籤,改用原始類別編號',
|
||||
noLabels: '尚未上傳標籤檔',
|
||||
labelFileTooLarge: '標籤檔過大(上限 {limit})',
|
||||
labelFileWrongType: '只接受 .txt 或 .names 檔',
|
||||
labelParseErrorLine: '第 {line} 行:{message}',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: '設定',
|
||||
|
||||
29
local-tool/frontend/src/lib/task-type.ts
Normal file
29
local-tool/frontend/src/lib/task-type.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Task types the inference pipeline can actually parse.
|
||||
*
|
||||
* models.json also carries `segmentation` / `pose_estimation` (the upload form
|
||||
* offers them), but neither the Python bridge nor the frontend has a rendering
|
||||
* path for those yet. The inference-page selector therefore only exposes the
|
||||
* two types that produce meaningful output.
|
||||
*
|
||||
* Naming note: the `FLASH_` prefix is historical — the selector originally
|
||||
* lived in the flash dialog. It now only backs the inference page's runtime
|
||||
* switch (`components/inference/inference-options.tsx`).
|
||||
*/
|
||||
export const FLASH_TASK_TYPES = ['object_detection', 'classification'] as const;
|
||||
|
||||
export type FlashTaskType = (typeof FLASH_TASK_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Normalise an arbitrary taskType string to one of the two supported values.
|
||||
*
|
||||
* The value set has historically been inconsistent across the stack: the Python
|
||||
* bridge used to emit `"detection"` while models.json and the frontend
|
||||
* TASK_TYPES constant use `"object_detection"` (plan R-4). We therefore only
|
||||
* ever test for classification and let everything else — including `undefined`,
|
||||
* `segmentation` and legacy `detection` — fall through to object detection,
|
||||
* which is the existing safe default.
|
||||
*/
|
||||
export function normalizeTaskType(taskType: string | undefined | null): FlashTaskType {
|
||||
return taskType === 'classification' ? 'classification' : 'object_detection';
|
||||
}
|
||||
198
local-tool/frontend/src/stores/inference-options-store.ts
Normal file
198
local-tool/frontend/src/stores/inference-options-store.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
import { normalizeTaskType, type FlashTaskType } from '@/lib/task-type';
|
||||
import type { ApiResponse } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Live inference options — the parsing mode and the display-only label mapping
|
||||
* that can be changed WITHOUT re-flashing the model (M4).
|
||||
*
|
||||
* The whole HTTP contract lives in this file on purpose. The backend endpoint
|
||||
* is being built in parallel, so keeping every request shape in one module
|
||||
* means realigning with the server is a single-file edit rather than a hunt
|
||||
* through components.
|
||||
*
|
||||
* Contract as implemented here:
|
||||
*
|
||||
* POST /api/devices/:id/inference/options
|
||||
* Content-Type: application/json
|
||||
* { "taskType": "object_detection" | "classification" }
|
||||
*
|
||||
* POST /api/devices/:id/inference/options (label upload)
|
||||
* Content-Type: multipart/form-data
|
||||
* labelFile: <labels.txt> // `<index> <name>` per line
|
||||
*
|
||||
* POST /api/devices/:id/inference/options (clear the mapping)
|
||||
* Content-Type: application/json
|
||||
* { "labels": [] } // explicit empty array; omitting the field
|
||||
* // instead means "leave labels untouched"
|
||||
*
|
||||
* All return the standard envelope:
|
||||
* { success: true, data: { deviceId, taskType, labelCount, labels, maxIndex } }
|
||||
* { success: false, error: { code, message, line? } }
|
||||
*
|
||||
* The server rejects a request that carries neither `taskType` nor `labels`,
|
||||
* so every action below sends at least one of them.
|
||||
*
|
||||
* Nothing is persisted: the user re-uploads the label file per session, which
|
||||
* is what they asked for ("不用記,每次現場傳").
|
||||
*/
|
||||
|
||||
/** Matches the plan §3.5 cap (3 classes ≈ 30 bytes; 10k classes < 200 KB). */
|
||||
export const MAX_LABEL_FILE_BYTES = 256 * 1024;
|
||||
|
||||
/** Extensions accepted client-side. Content validation happens server-side. */
|
||||
export const ACCEPTED_LABEL_EXTENSIONS = ['.txt', '.names'] as const;
|
||||
|
||||
export interface InferenceOptionsResponse {
|
||||
taskType?: string;
|
||||
labelCount?: number;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error envelope for label parsing. `line` is optional and only present for
|
||||
* `LABEL_PARSE_ERROR`, which is the whole point of letting the server parse:
|
||||
* it can point at the offending line.
|
||||
*/
|
||||
export interface InferenceOptionsError {
|
||||
code: string;
|
||||
message: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
interface InferenceOptionsState {
|
||||
/**
|
||||
* `null` = the user has not overridden anything this session, so the mode
|
||||
* flashed with the model is still in effect. Components resolve the value to
|
||||
* display by falling back to the last inference result's taskType.
|
||||
*/
|
||||
taskType: FlashTaskType | null;
|
||||
labels: string[] | null;
|
||||
applying: boolean;
|
||||
error: InferenceOptionsError | null;
|
||||
|
||||
setTaskType: (deviceId: string, taskType: FlashTaskType) => Promise<boolean>;
|
||||
uploadLabels: (deviceId: string, file: File) => Promise<boolean>;
|
||||
clearLabels: (deviceId: string) => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function optionsPath(deviceId: string) {
|
||||
return `/devices/${deviceId}/inference/options`;
|
||||
}
|
||||
|
||||
type OptionsResponse = ApiResponse<InferenceOptionsResponse, InferenceOptionsError>;
|
||||
|
||||
/** Normalises a failed response into a fully-populated error envelope. */
|
||||
function toError(res: OptionsResponse): InferenceOptionsError {
|
||||
return {
|
||||
code: res.error?.code || 'UNKNOWN',
|
||||
message: res.error?.message || 'Request failed',
|
||||
line: res.error?.line,
|
||||
};
|
||||
}
|
||||
|
||||
export const useInferenceOptionsStore = create<InferenceOptionsState>((set) => ({
|
||||
taskType: null,
|
||||
labels: null,
|
||||
applying: false,
|
||||
error: null,
|
||||
|
||||
setTaskType: async (deviceId, taskType) => {
|
||||
set({ applying: true, error: null });
|
||||
try {
|
||||
const res = await api.post<InferenceOptionsResponse, InferenceOptionsError>(
|
||||
optionsPath(deviceId),
|
||||
{ taskType },
|
||||
);
|
||||
if (!res.success) {
|
||||
set({ applying: false, error: toError(res) });
|
||||
return false;
|
||||
}
|
||||
// Trust the server's echo when it sends one, so a server-side coercion
|
||||
// never leaves the UI showing a mode that is not actually in effect.
|
||||
set({
|
||||
applying: false,
|
||||
taskType: res.data?.taskType ? normalizeTaskType(res.data.taskType) : taskType,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({
|
||||
applying: false,
|
||||
error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
uploadLabels: async (deviceId, file) => {
|
||||
set({ applying: true, error: null });
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('labelFile', file);
|
||||
const res = await api.postForm<InferenceOptionsResponse, InferenceOptionsError>(
|
||||
optionsPath(deviceId),
|
||||
form,
|
||||
);
|
||||
if (!res.success) {
|
||||
set({ applying: false, error: toError(res) });
|
||||
return false;
|
||||
}
|
||||
set({ applying: false, labels: res.data?.labels ?? [] });
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({
|
||||
applying: false,
|
||||
error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clearLabels: async (deviceId) => {
|
||||
set({ applying: true, error: null });
|
||||
try {
|
||||
// An explicit empty array clears the mapping. Omitting the field would
|
||||
// mean "leave labels as they are", so the array must actually be sent.
|
||||
const res = await api.post<InferenceOptionsResponse, InferenceOptionsError>(
|
||||
optionsPath(deviceId),
|
||||
{ labels: [] },
|
||||
);
|
||||
if (!res.success) {
|
||||
set({ applying: false, error: toError(res) });
|
||||
return false;
|
||||
}
|
||||
set({ applying: false, labels: null });
|
||||
return true;
|
||||
} catch (e) {
|
||||
set({
|
||||
applying: false,
|
||||
error: { code: 'NETWORK_ERROR', message: e instanceof Error ? e.message : String(e) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
reset: () => set({ taskType: null, labels: null, applying: false, error: null }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Client-side pre-checks. The server validates again — this only spares the
|
||||
* user a round trip for the two mistakes that need no parsing to detect.
|
||||
*/
|
||||
export function validateLabelFile(
|
||||
file: File,
|
||||
): { ok: true } | { ok: false; reason: 'too-large' | 'wrong-type' } {
|
||||
const lower = file.name.toLowerCase();
|
||||
if (!ACCEPTED_LABEL_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
|
||||
return { ok: false, reason: 'wrong-type' };
|
||||
}
|
||||
if (file.size > MAX_LABEL_FILE_BYTES) {
|
||||
return { ok: false, reason: 'too-large' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DetectionResultList } from '@/components/inference/detection-result';
|
||||
import type { DetectionResult } from '@/types/inference';
|
||||
|
||||
function det(label: string, confidence: number): DetectionResult {
|
||||
return { label, confidence, bbox: { x: 0.1, y: 0.1, width: 0.2, height: 0.2 } };
|
||||
}
|
||||
|
||||
describe('DetectionResultList — M3-b detection side panel', () => {
|
||||
it('lists detections sorted by confidence descending', () => {
|
||||
render(
|
||||
<DetectionResultList
|
||||
results={[det('car', 0.6), det('person', 0.9), det('dog', 0.75)]}
|
||||
confidenceThreshold={0.5}
|
||||
/>,
|
||||
);
|
||||
const items = screen.getAllByRole('listitem').map((li) => li.textContent);
|
||||
expect(items[0]).toContain('person');
|
||||
expect(items[1]).toContain('dog');
|
||||
expect(items[2]).toContain('car');
|
||||
});
|
||||
|
||||
it('filters out detections below the confidence threshold', () => {
|
||||
render(
|
||||
<DetectionResultList
|
||||
results={[det('person', 0.9), det('noise', 0.1)]}
|
||||
confidenceThreshold={0.5}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
||||
expect(screen.queryByText('noise')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the detected count', () => {
|
||||
render(
|
||||
<DetectionResultList
|
||||
results={[det('person', 0.9), det('car', 0.8)]}
|
||||
confidenceThreshold={0.5}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('detection-result-list').textContent).toContain('2');
|
||||
});
|
||||
|
||||
it('shows the empty state when nothing clears the threshold', () => {
|
||||
render(<DetectionResultList results={[det('person', 0.1)]} confidenceThreshold={0.5} />);
|
||||
expect(screen.queryByTestId('detection-result-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the empty state for an empty result set', () => {
|
||||
render(<DetectionResultList results={[]} confidenceThreshold={0.5} />);
|
||||
expect(screen.queryByTestId('detection-result-list')).toBeNull();
|
||||
});
|
||||
});
|
||||
191
local-tool/frontend/src/tests/components/flash-dialog.test.tsx
Normal file
191
local-tool/frontend/src/tests/components/flash-dialog.test.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { FlashDialog } from '@/components/devices/flash-dialog';
|
||||
import { useModelStore } from '@/stores/model-store';
|
||||
import { useFlashStore } from '@/stores/flash-store';
|
||||
import { useDeviceStore } from '@/stores/device-store';
|
||||
import { api } from '@/lib/api';
|
||||
import type { ModelSummary } from '@/types/model';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue({ success: true, data: { models: [], total: 0 } }),
|
||||
post: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
getRelayHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
// The dialog opens a flash-progress WebSocket before POSTing. Stub it so
|
||||
// `connectAndWait` resolves immediately instead of hitting the 3s timeout.
|
||||
//
|
||||
// The returned callbacks must keep a STABLE identity across renders: the real
|
||||
// hook wraps them in useCallback, and the dialog lists `disconnect` in a
|
||||
// useEffect dependency array. Returning fresh functions each render re-fires
|
||||
// that effect on every render and blows the React update-depth limit.
|
||||
const connectAndWaitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const disconnectMock = vi.fn();
|
||||
vi.mock('@/hooks/use-flash-progress', () => ({
|
||||
useFlashProgress: () => ({
|
||||
connectAndWait: connectAndWaitMock,
|
||||
disconnect: disconnectMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const DEVICE_ID = 'dev-1';
|
||||
|
||||
function model(overrides: Partial<ModelSummary> & Pick<ModelSummary, 'id' | 'name'>): ModelSummary {
|
||||
return {
|
||||
thumbnail: '',
|
||||
taskType: 'object_detection',
|
||||
categories: [],
|
||||
modelSize: 1024,
|
||||
accuracy: 0.9,
|
||||
fps: 30,
|
||||
supportedHardware: ['KL520'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const DETECTION_MODEL = model({ id: 'm-det', name: '物件辨識', taskType: 'object_detection' });
|
||||
const CLASSIFICATION_MODEL = model({ id: 'm-cls', name: '手勢分類', taskType: 'classification' });
|
||||
// Declares a different hardware family than the seeded KL520 device, so the
|
||||
// incompatibility path can be exercised.
|
||||
const INCOMPATIBLE_MODEL = model({ id: 'm-720', name: '高階模型', supportedHardware: ['KL720'] });
|
||||
|
||||
// Stable identity — replacing this per-test would change the dialog's effect
|
||||
// dependencies on every render and spin the component into an update loop.
|
||||
const noopFetchModels = async () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// clearAllMocks() also drops the resolved value, which would make
|
||||
// connectAndWait return undefined and break the await in handleFlash.
|
||||
connectAndWaitMock.mockResolvedValue(undefined);
|
||||
vi.mocked(api.post).mockResolvedValue({ success: true });
|
||||
// fetchModels() runs when the dialog opens; with the mocked api it would
|
||||
// overwrite the seeded list with []. Stub it once with a stable identity so
|
||||
// the dialog's `useEffect([open, fetchModels, ...])` does not re-fire.
|
||||
useModelStore.setState({
|
||||
models: [DETECTION_MODEL, CLASSIFICATION_MODEL, INCOMPATIBLE_MODEL],
|
||||
fetchModels: noopFetchModels,
|
||||
});
|
||||
useFlashStore.setState({
|
||||
activeDeviceId: null,
|
||||
isFlashing: false,
|
||||
progress: null,
|
||||
error: null,
|
||||
lastFlashParams: null,
|
||||
});
|
||||
useDeviceStore.setState({
|
||||
devices: [{ id: DEVICE_ID, type: 'KL520' } as ReturnType<
|
||||
typeof useDeviceStore.getState
|
||||
>['devices'][number]],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Radix Select cannot be opened with a plain click under jsdom (it depends on
|
||||
* pointer capture), but its keyboard path works. Enter opens the listbox and a
|
||||
* click on the rendered option commits the value.
|
||||
*/
|
||||
async function selectOption(trigger: HTMLElement, optionName: string) {
|
||||
fireEvent.keyDown(trigger, { key: 'Enter', code: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('option', { name: optionName }));
|
||||
}
|
||||
|
||||
/** Opens the dialog and picks a model by its visible name. */
|
||||
async function openAndSelectModel(name: string) {
|
||||
render(<FlashDialog deviceId={DEVICE_ID} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '載入模型' }));
|
||||
const triggers = await screen.findAllByRole('combobox');
|
||||
await selectOption(triggers[0], name);
|
||||
}
|
||||
|
||||
describe('FlashDialog — model selection', () => {
|
||||
it('cannot submit until a model is chosen', () => {
|
||||
render(<FlashDialog deviceId={DEVICE_ID} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '載入模型' }));
|
||||
expect(screen.getByRole('button', { name: '請先選擇模型' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers exactly one selector — the model dropdown', async () => {
|
||||
// Guards the removal of the flash-time inference-type selector: parsing is
|
||||
// now switched on the inference page, so a second combobox here would mean
|
||||
// the duplicate entry point came back.
|
||||
await openAndSelectModel('物件辨識');
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('enables submitting once a compatible model is selected', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
expect(screen.getByRole('button', { name: '開始載入' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FlashDialog — hardware compatibility', () => {
|
||||
it('warns and blocks flashing a model the device does not support', async () => {
|
||||
await openAndSelectModel('高階模型');
|
||||
|
||||
expect(screen.getByText('硬體不相容')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '不相容 — 無法載入' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows no incompatibility warning for a supported model', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
expect(screen.queryByText('硬體不相容')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FlashDialog — flash request payload', () => {
|
||||
it('posts only the model id', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled());
|
||||
expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-det' });
|
||||
});
|
||||
|
||||
it('posts only the model id for a classification model too', async () => {
|
||||
// The model's declared task type must never leak into the flash body: the
|
||||
// server resolves it from models.json on its own.
|
||||
await openAndSelectModel('手勢分類');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled());
|
||||
expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-cls' });
|
||||
});
|
||||
|
||||
it('waits for the progress socket before posting', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled());
|
||||
expect(connectAndWaitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not post when the progress socket fails to connect', async () => {
|
||||
connectAndWaitMock.mockRejectedValueOnce(new Error('socket down'));
|
||||
await openAndSelectModel('物件辨識');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
|
||||
await waitFor(() => expect(useFlashStore.getState().error).toBe('socket down'));
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replays the same payload on retry', async () => {
|
||||
await openAndSelectModel('物件辨識');
|
||||
fireEvent.click(screen.getByRole('button', { name: '開始載入' }));
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1));
|
||||
|
||||
vi.mocked(api.post).mockClear();
|
||||
await useFlashStore.getState().retryFlash();
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith(`/devices/${DEVICE_ID}/flash`, { modelId: 'm-det' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,345 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { InferenceOptions } from '@/components/inference/inference-options';
|
||||
import {
|
||||
useInferenceOptionsStore,
|
||||
MAX_LABEL_FILE_BYTES,
|
||||
type InferenceOptionsResponse,
|
||||
type InferenceOptionsError,
|
||||
} from '@/stores/inference-options-store';
|
||||
import { api, type ApiResponse } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* The store calls `api.post`/`api.postForm` with the widened error type so a
|
||||
* parse error keeps its `line` number. `vi.mocked` resolves the generic to its
|
||||
* default `ApiError`, so responses are built through this helper to stay in the
|
||||
* shape the store actually receives.
|
||||
*/
|
||||
type OptionsResponse = ApiResponse<InferenceOptionsResponse, InferenceOptionsError>;
|
||||
const reply = (r: OptionsResponse) => r as ApiResponse<InferenceOptionsResponse>;
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
post: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
postForm: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
},
|
||||
getRelayHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const DEVICE_ID = 'dev-1';
|
||||
const OPTIONS_PATH = `/devices/${DEVICE_ID}/inference/options`;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.post).mockResolvedValue({ success: true, data: {} });
|
||||
vi.mocked(api.postForm).mockResolvedValue({ success: true, data: {} });
|
||||
useInferenceOptionsStore.getState().reset();
|
||||
});
|
||||
|
||||
/**
|
||||
* Radix Select cannot be opened by a plain click under jsdom (pointer capture),
|
||||
* but the keyboard path works: Enter opens the listbox, a click commits.
|
||||
*/
|
||||
async function selectOption(trigger: HTMLElement, optionName: string) {
|
||||
fireEvent.keyDown(trigger, { key: 'Enter', code: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('option', { name: optionName }));
|
||||
}
|
||||
|
||||
function taskTypeTrigger() {
|
||||
return screen.getByTestId('inference-task-type-trigger');
|
||||
}
|
||||
|
||||
function labelFile(name = 'labels.txt', content = '0 剪刀\n1 石頭\n2 布\n') {
|
||||
return new File([content], name, { type: 'text/plain' });
|
||||
}
|
||||
|
||||
function uploadLabelFile(file: File) {
|
||||
const input = screen.getByTestId('inference-label-file-input') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
}
|
||||
|
||||
describe('InferenceOptions — parsing mode selector', () => {
|
||||
it('mirrors the task type reported by the latest result', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(taskTypeTrigger()).toHaveTextContent('分類');
|
||||
});
|
||||
|
||||
it('shows object detection for a detection result', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
|
||||
});
|
||||
|
||||
it('falls back to object detection for the legacy "detection" spelling (R-4)', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="detection" />);
|
||||
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
|
||||
});
|
||||
|
||||
it('falls back to object detection when no result has arrived yet', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType={undefined} />);
|
||||
expect(taskTypeTrigger()).toHaveTextContent('物件偵測');
|
||||
});
|
||||
|
||||
it('POSTs the newly chosen task type', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled());
|
||||
expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { taskType: 'classification' });
|
||||
});
|
||||
|
||||
it('keeps showing the user choice after it is applied, not the stale result type', async () => {
|
||||
// The prop still says object_detection (no new frame has arrived yet). The
|
||||
// explicit choice must win, otherwise the selector snaps back and looks
|
||||
// like the switch failed.
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
|
||||
await waitFor(() => expect(taskTypeTrigger()).toHaveTextContent('分類'));
|
||||
});
|
||||
|
||||
it('adopts the task type echoed back by the server', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({
|
||||
success: true,
|
||||
data: { taskType: 'object_detection' },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
|
||||
// Server refused to switch and said "still object_detection" — the UI must
|
||||
// not claim classification is in effect.
|
||||
await waitFor(() => expect(taskTypeTrigger()).toHaveTextContent('物件偵測'));
|
||||
});
|
||||
|
||||
it('surfaces a failed switch', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({
|
||||
success: false,
|
||||
error: { code: 'DEVICE_NOT_CONNECTED', message: 'device not connected' },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
|
||||
const err = await screen.findByTestId('inference-options-error');
|
||||
expect(err.textContent).toContain('device not connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InferenceOptions — label section visibility', () => {
|
||||
it('hides the label section for object detection', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
expect(screen.queryByTestId('inference-label-section')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the label section for classification', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(screen.getByTestId('inference-label-section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals the label section once the user switches to classification', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="object_detection" />);
|
||||
expect(screen.queryByTestId('inference-label-section')).toBeNull();
|
||||
|
||||
await selectOption(taskTypeTrigger(), '分類');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-section')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the label section again when switching back to detection', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(screen.getByTestId('inference-label-section')).toBeInTheDocument();
|
||||
|
||||
await selectOption(taskTypeTrigger(), '物件偵測');
|
||||
await waitFor(() => expect(screen.queryByTestId('inference-label-section')).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('InferenceOptions — label upload', () => {
|
||||
it('uploads the picked file as multipart to the options endpoint', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
await waitFor(() => expect(api.postForm).toHaveBeenCalled());
|
||||
const [path, form] = vi.mocked(api.postForm).mock.calls[0];
|
||||
expect(path).toBe(OPTIONS_PATH);
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
expect((form.get('labelFile') as File).name).toBe('labels.txt');
|
||||
});
|
||||
|
||||
it('shows the applied labels returned by the server', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labelCount: 3, labels: ['剪刀', '石頭', '布'] },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
await waitFor(() => {
|
||||
const status = screen.getByTestId('inference-label-status');
|
||||
expect(status.textContent).toContain('3');
|
||||
expect(status.textContent).toContain('剪刀');
|
||||
});
|
||||
});
|
||||
|
||||
it('says no labels are set before anything is uploaded', () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(screen.getByTestId('inference-label-status').textContent).toContain(
|
||||
'尚未上傳標籤檔',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a clear action only after labels are applied', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labels: ['剪刀', '石頭', '布'] },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
expect(screen.queryByTestId('inference-label-clear-btn')).toBeNull();
|
||||
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-clear-btn')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the mapping through the endpoint and drops back to raw indices', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labels: ['剪刀', '石頭', '布'] },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-clear-btn')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('inference-label-clear-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-status').textContent).toContain(
|
||||
'尚未上傳標籤檔',
|
||||
),
|
||||
);
|
||||
// Clearing goes over JSON with an explicit empty array — an omitted field
|
||||
// would leave the mapping in place on the device.
|
||||
expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { labels: [] });
|
||||
});
|
||||
|
||||
it('lets the same file be re-picked after fixing it on disk', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() => expect(api.postForm).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The input value is reset after each pick, so selecting the same filename
|
||||
// again still fires a change event.
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() => expect(api.postForm).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('InferenceOptions — label upload errors', () => {
|
||||
it('rejects a non-.txt file without hitting the network', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile('labels.json', '{}'));
|
||||
|
||||
expect(await screen.findByTestId('inference-options-error')).toHaveTextContent(
|
||||
'只接受 .txt 或 .names 檔',
|
||||
);
|
||||
expect(api.postForm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a .names file', async () => {
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile('coco.names'));
|
||||
|
||||
await waitFor(() => expect(api.postForm).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('inference-options-error')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an oversized file without hitting the network', async () => {
|
||||
const huge = labelFile('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES + 1));
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(huge);
|
||||
|
||||
expect(await screen.findByTestId('inference-options-error')).toHaveTextContent('過大');
|
||||
expect(api.postForm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the offending line number for a parse error', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'LABEL_PARSE_ERROR',
|
||||
message: "index 必須為非負整數,收到 'abc'",
|
||||
line: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
const err = await screen.findByTestId('inference-options-error');
|
||||
// The line number is the whole reason parsing lives server-side — losing it
|
||||
// would leave the user hunting through their file blind.
|
||||
expect(err.textContent).toContain('5');
|
||||
expect(err.textContent).toContain('非負整數');
|
||||
});
|
||||
|
||||
it('shows a generic failure message when the server sends no line number', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: false,
|
||||
error: { code: 'STORAGE_ERROR', message: 'could not write labels' },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
const err = await screen.findByTestId('inference-options-error');
|
||||
expect(err.textContent).toContain('could not write labels');
|
||||
});
|
||||
|
||||
it('keeps the previous mapping when a replacement upload fails', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labels: ['剪刀', '石頭', '布'] },
|
||||
});
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('inference-label-status').textContent).toContain('剪刀'),
|
||||
);
|
||||
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 2 },
|
||||
}),
|
||||
);
|
||||
uploadLabelFile(labelFile('bad.txt', 'garbage'));
|
||||
|
||||
await screen.findByTestId('inference-options-error');
|
||||
// A failed replacement must not silently wipe the mapping that is still in
|
||||
// effect on the device.
|
||||
expect(screen.getByTestId('inference-label-status').textContent).toContain('剪刀');
|
||||
});
|
||||
|
||||
it('clears a stale error once a later upload succeeds', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 2 },
|
||||
}),
|
||||
);
|
||||
render(<InferenceOptions deviceId={DEVICE_ID} resultTaskType="classification" />);
|
||||
uploadLabelFile(labelFile('bad.txt'));
|
||||
await screen.findByTestId('inference-options-error');
|
||||
|
||||
vi.mocked(api.postForm).mockResolvedValue({
|
||||
success: true,
|
||||
data: { labels: ['剪刀'] },
|
||||
});
|
||||
uploadLabelFile(labelFile());
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('inference-options-error')).toBeNull());
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { InferenceOverlay } from '@/components/camera/inference-overlay';
|
||||
import type { InferenceResult } from '@/types/inference';
|
||||
|
||||
function makeResult(overrides: Partial<InferenceResult> = {}): InferenceResult {
|
||||
return {
|
||||
deviceId: 'dev-1',
|
||||
modelId: 'model-1',
|
||||
taskType: 'object_detection',
|
||||
timestamp: 1784620457620,
|
||||
latencyMs: 12.5,
|
||||
detections: [],
|
||||
classifications: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SIZE = { width: 640, height: 480, confidenceThreshold: 0.5 };
|
||||
|
||||
describe('InferenceOverlay — M3-a taskType dispatch', () => {
|
||||
it('renders the bbox canvas for object_detection', () => {
|
||||
render(<InferenceOverlay result={makeResult()} {...SIZE} />);
|
||||
expect(screen.getByTestId('camera-overlay')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('classification-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the bbox canvas for the legacy "detection" spelling (R-4)', () => {
|
||||
render(<InferenceOverlay result={makeResult({ taskType: 'detection' })} {...SIZE} />);
|
||||
expect(screen.getByTestId('camera-overlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the bbox canvas when taskType is unknown', () => {
|
||||
render(<InferenceOverlay result={makeResult({ taskType: 'something_new' })} {...SIZE} />);
|
||||
expect(screen.getByTestId('camera-overlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the bbox canvas when there is no result at all', () => {
|
||||
render(<InferenceOverlay result={null} {...SIZE} />);
|
||||
expect(screen.getByTestId('camera-overlay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the classification chip — and NO bbox canvas — for classification', () => {
|
||||
render(
|
||||
<InferenceOverlay
|
||||
result={makeResult({
|
||||
taskType: 'classification',
|
||||
classifications: [
|
||||
{ label: '石頭', classIndex: 1, confidence: 0.9425 },
|
||||
{ label: '布', classIndex: 2, confidence: 0.0384 },
|
||||
],
|
||||
})}
|
||||
{...SIZE}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('classification-overlay')).toBeInTheDocument();
|
||||
// The whole point of the feature: classification must not draw boxes.
|
||||
expect(screen.queryByTestId('camera-overlay')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassificationOverlay rendering', () => {
|
||||
function renderClassification(classifications: InferenceResult['classifications'], threshold = 0.5) {
|
||||
return render(
|
||||
<InferenceOverlay
|
||||
result={makeResult({ taskType: 'classification', classifications })}
|
||||
{...SIZE}
|
||||
confidenceThreshold={threshold}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('shows the top-1 label and confidence', () => {
|
||||
renderClassification([
|
||||
{ label: '布', classIndex: 2, confidence: 0.0384 },
|
||||
{ label: '石頭', classIndex: 1, confidence: 0.9425 },
|
||||
]);
|
||||
expect(screen.getByTestId('classification-overlay-label').textContent).toBe('石頭');
|
||||
expect(screen.getByTestId('classification-overlay-confidence').textContent).toBe('94.3%');
|
||||
});
|
||||
|
||||
it('falls back to class_<index> when the label is missing', () => {
|
||||
renderClassification([{ label: '', classIndex: 1, confidence: 0.88 }]);
|
||||
expect(screen.getByTestId('classification-overlay-label').textContent).toBe('class_1');
|
||||
});
|
||||
|
||||
it('renders without crashing when classIndex is absent (M2-d not shipped)', () => {
|
||||
renderClassification([{ label: 'class_1', confidence: 0.88 }]);
|
||||
expect(screen.getByTestId('classification-overlay-label').textContent).toBe('class_1');
|
||||
});
|
||||
|
||||
it('shows the unrecognized state when nothing clears the threshold', () => {
|
||||
renderClassification([{ label: '石頭', classIndex: 1, confidence: 0.2 }]);
|
||||
expect(screen.getByTestId('classification-overlay-empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('classification-overlay-label')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the unrecognized state when the classifications array is empty', () => {
|
||||
renderClassification([]);
|
||||
expect(screen.getByTestId('classification-overlay-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the verdict to assistive tech via a polite live region', () => {
|
||||
renderClassification([{ label: '石頭', classIndex: 1, confidence: 0.9 }]);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status.getAttribute('aria-live')).toBe('polite');
|
||||
expect(status.textContent).toContain('石頭');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { InferencePanel } from '@/components/inference/inference-panel';
|
||||
import { useInferenceStore } from '@/stores/inference-store';
|
||||
import { useCameraStore } from '@/stores/camera-store';
|
||||
import { useInferenceOptionsStore } from '@/stores/inference-options-store';
|
||||
import type { InferenceResult } from '@/types/inference';
|
||||
|
||||
const DEVICE_ID = 'dev-1';
|
||||
|
||||
function makeResult(overrides: Partial<InferenceResult> = {}): InferenceResult {
|
||||
return {
|
||||
deviceId: 'dev-1',
|
||||
modelId: 'model-1',
|
||||
taskType: 'object_detection',
|
||||
timestamp: 1784620457620,
|
||||
latencyMs: 12.5,
|
||||
detections: [],
|
||||
classifications: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setResult(result: InferenceResult | null) {
|
||||
useInferenceStore.setState({ result, confidenceThreshold: 0.5 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useInferenceStore.setState({
|
||||
isRunning: false,
|
||||
result: null,
|
||||
results: [],
|
||||
fps: 0,
|
||||
avgLatency: 0,
|
||||
confidenceThreshold: 0.5,
|
||||
batchResults: {},
|
||||
});
|
||||
useCameraStore.setState({ sourceType: 'camera', batchSelectedIndex: 0, batchImages: [] });
|
||||
useInferenceOptionsStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('InferencePanel — M3-b taskType card switching', () => {
|
||||
it('shows the detection list card for object_detection', () => {
|
||||
setResult(
|
||||
makeResult({
|
||||
detections: [
|
||||
{ label: 'person', confidence: 0.9, bbox: { x: 0, y: 0, width: 0.1, height: 0.1 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<InferencePanel deviceId={DEVICE_ID} />);
|
||||
expect(screen.getByTestId('detection-result-list')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('classification-result-chart')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the classification chart card for classification', () => {
|
||||
setResult(
|
||||
makeResult({
|
||||
taskType: 'classification',
|
||||
classifications: [{ label: '石頭', classIndex: 1, confidence: 0.94 }],
|
||||
}),
|
||||
);
|
||||
render(<InferencePanel deviceId={DEVICE_ID} />);
|
||||
expect(screen.getByTestId('classification-result-chart')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('detection-result-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to the detection card when there is no result yet', () => {
|
||||
setResult(null);
|
||||
render(<InferencePanel deviceId={DEVICE_ID} />);
|
||||
// No result → detection branch renders its empty state, not the chart.
|
||||
expect(screen.queryByTestId('classification-result-chart')).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to the detection card for the legacy "detection" spelling (R-4)', () => {
|
||||
setResult(
|
||||
makeResult({
|
||||
taskType: 'detection',
|
||||
detections: [
|
||||
{ label: 'person', confidence: 0.9, bbox: { x: 0, y: 0, width: 0.1, height: 0.1 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<InferencePanel deviceId={DEVICE_ID} />);
|
||||
expect(screen.getByTestId('detection-result-list')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
179
local-tool/frontend/src/tests/lib/classification.test.ts
Normal file
179
local-tool/frontend/src/tests/lib/classification.test.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isClassificationTask,
|
||||
classResultLabel,
|
||||
classResultKey,
|
||||
pickTopClass,
|
||||
nextHysteresisState,
|
||||
INITIAL_HYSTERESIS_STATE,
|
||||
DEFAULT_HYSTERESIS,
|
||||
type HysteresisOptions,
|
||||
type HysteresisState,
|
||||
} from '@/lib/classification';
|
||||
import type { ClassResult } from '@/types/inference';
|
||||
|
||||
const OPTS: HysteresisOptions = {
|
||||
...DEFAULT_HYSTERESIS,
|
||||
confidenceThreshold: 0.5,
|
||||
};
|
||||
|
||||
function cls(label: string, confidence: number, classIndex?: number): ClassResult {
|
||||
return classIndex === undefined ? { label, confidence } : { label, confidence, classIndex };
|
||||
}
|
||||
|
||||
/** Feed a sequence of frames through the state machine, return displayed labels. */
|
||||
function run(frames: ClassResult[][], options: HysteresisOptions = OPTS): (string | null)[] {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
return frames.map((f) => {
|
||||
state = nextHysteresisState(state, f, options);
|
||||
return state.displayed ? classResultLabel(state.displayed) : null;
|
||||
});
|
||||
}
|
||||
|
||||
describe('isClassificationTask — R-4 defensive branching', () => {
|
||||
it('is true only for the exact classification value', () => {
|
||||
expect(isClassificationTask('classification')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for both detection spellings, so detection stays the fall-through', () => {
|
||||
expect(isClassificationTask('detection')).toBe(false);
|
||||
expect(isClassificationTask('object_detection')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for undefined/null/unknown values', () => {
|
||||
expect(isClassificationTask(undefined)).toBe(false);
|
||||
expect(isClassificationTask(null)).toBe(false);
|
||||
expect(isClassificationTask('segmentation')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classResultLabel — classIndex fallback (M2-d may be absent)', () => {
|
||||
it('uses the label when present', () => {
|
||||
expect(classResultLabel(cls('石頭', 0.9, 1))).toBe('石頭');
|
||||
});
|
||||
|
||||
it('falls back to class_<index> when the label is empty', () => {
|
||||
expect(classResultLabel(cls('', 0.9, 7))).toBe('class_7');
|
||||
});
|
||||
|
||||
it('falls back to class_<index> when the label is only whitespace', () => {
|
||||
expect(classResultLabel(cls(' ', 0.9, 0))).toBe('class_0');
|
||||
});
|
||||
|
||||
it('emits a placeholder when both label and classIndex are missing', () => {
|
||||
expect(classResultLabel(cls('', 0.9))).toBe('class_?');
|
||||
});
|
||||
|
||||
it('works when classIndex is undefined but the label exists (the common case)', () => {
|
||||
expect(classResultLabel(cls('cat', 0.9))).toBe('cat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classResultKey — identity used by hysteresis', () => {
|
||||
it('prefers classIndex so label churn does not reset the state machine', () => {
|
||||
expect(classResultKey(cls('石頭', 0.9, 1))).toBe('i:1');
|
||||
expect(classResultKey(cls('rock', 0.9, 1))).toBe('i:1');
|
||||
});
|
||||
|
||||
it('falls back to the label when classIndex is absent', () => {
|
||||
expect(classResultKey(cls('石頭', 0.9))).toBe('l:石頭');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickTopClass', () => {
|
||||
it('returns null for empty/undefined input', () => {
|
||||
expect(pickTopClass([])).toBeNull();
|
||||
expect(pickTopClass(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the highest-confidence entry regardless of array order', () => {
|
||||
const top = pickTopClass([cls('a', 0.1), cls('b', 0.8), cls('c', 0.3)]);
|
||||
expect(top?.label).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextHysteresisState — R-9 anti-flicker', () => {
|
||||
it('shows the first above-threshold result immediately', () => {
|
||||
expect(run([[cls('石頭', 0.9)]])).toEqual(['石頭']);
|
||||
});
|
||||
|
||||
it('shows nothing when the top class is below the confidence threshold', () => {
|
||||
expect(run([[cls('石頭', 0.3)]])).toEqual([null]);
|
||||
});
|
||||
|
||||
it('clears the label when confidence drops below the threshold', () => {
|
||||
expect(run([[cls('石頭', 0.9)], [cls('石頭', 0.2)]])).toEqual(['石頭', null]);
|
||||
});
|
||||
|
||||
it('does NOT switch on a single dissenting frame (the core anti-flicker case)', () => {
|
||||
// 石頭 established, then one frame where 布 barely wins → must hold 石頭.
|
||||
const out = run([
|
||||
[cls('石頭', 0.6), cls('布', 0.4)],
|
||||
[cls('布', 0.62), cls('石頭', 0.58)],
|
||||
[cls('石頭', 0.6), cls('布', 0.4)],
|
||||
]);
|
||||
expect(out).toEqual(['石頭', '石頭', '石頭']);
|
||||
});
|
||||
|
||||
it('switches after streakFrames consecutive frames of the challenger', () => {
|
||||
// Margins stay under immediateMargin (0.15) so only the streak can promote.
|
||||
const out = run([
|
||||
[cls('石頭', 0.60)],
|
||||
[cls('布', 0.62)],
|
||||
[cls('布', 0.63)],
|
||||
[cls('布', 0.64)],
|
||||
]);
|
||||
// frame1 石頭; frames 2-3 still 石頭 (streak 1,2); frame 4 promotes 布 (streak 3).
|
||||
expect(out).toEqual(['石頭', '石頭', '石頭', '布']);
|
||||
});
|
||||
|
||||
it('switches immediately when the challenger wins by the confidence margin', () => {
|
||||
const out = run([
|
||||
[cls('石頭', 0.60)],
|
||||
[cls('布', 0.80)], // +0.20 >= immediateMargin 0.15
|
||||
]);
|
||||
expect(out).toEqual(['石頭', '布']);
|
||||
});
|
||||
|
||||
it('resets the streak when the challenger is interrupted', () => {
|
||||
const out = run([
|
||||
[cls('石頭', 0.60)],
|
||||
[cls('布', 0.62)], // streak 1
|
||||
[cls('石頭', 0.61)], // incumbent back → streak cleared
|
||||
[cls('布', 0.62)], // streak restarts at 1
|
||||
[cls('布', 0.62)], // streak 2
|
||||
]);
|
||||
expect(out).toEqual(['石頭', '石頭', '石頭', '石頭', '石頭']);
|
||||
});
|
||||
|
||||
it('keeps refreshing confidence of the incumbent while it stays on top', () => {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.60)], OPTS);
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.95)], OPTS);
|
||||
expect(state.displayed?.confidence).toBe(0.95);
|
||||
});
|
||||
|
||||
it('uses classIndex identity so a re-labelled same class is not treated as a switch', () => {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.60, 1)], OPTS);
|
||||
// Same index, different label string → same class, no hysteresis delay.
|
||||
state = nextHysteresisState(state, [cls('rock', 0.61, 1)], OPTS);
|
||||
expect(state.displayed?.label).toBe('rock');
|
||||
expect(state.candidateStreak).toBe(0);
|
||||
});
|
||||
|
||||
it('clears state entirely on an empty frame', () => {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.9)], OPTS);
|
||||
state = nextHysteresisState(state, [], OPTS);
|
||||
expect(state).toEqual(INITIAL_HYSTERESIS_STATE);
|
||||
});
|
||||
|
||||
it('honours a raised confidenceThreshold on the very next frame', () => {
|
||||
let state: HysteresisState = INITIAL_HYSTERESIS_STATE;
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.6)], OPTS);
|
||||
expect(state.displayed).not.toBeNull();
|
||||
state = nextHysteresisState(state, [cls('石頭', 0.6)], { ...OPTS, confidenceThreshold: 0.8 });
|
||||
expect(state.displayed).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -6,6 +6,31 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// jsdom does not implement ResizeObserver, which Radix primitives (Slider) and
|
||||
// CameraFeed rely on. A no-op stub is enough: tests assert on rendered output,
|
||||
// not on resize-driven behaviour.
|
||||
if (!('ResizeObserver' in globalThis)) {
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver = ResizeObserverStub;
|
||||
}
|
||||
|
||||
// jsdom implements neither the Pointer Capture API nor scrollIntoView, both of
|
||||
// which Radix Select calls while opening its dropdown. Without these stubs the
|
||||
// trigger throws `target.hasPointerCapture is not a function` and the listbox
|
||||
// never mounts, so any test that opens a <Select> is untestable.
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = () => false;
|
||||
Element.prototype.setPointerCapture = () => {};
|
||||
Element.prototype.releasePointerCapture = () => {};
|
||||
}
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
|
||||
@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useInferenceOptionsStore,
|
||||
validateLabelFile,
|
||||
MAX_LABEL_FILE_BYTES,
|
||||
type InferenceOptionsResponse,
|
||||
type InferenceOptionsError,
|
||||
} from '@/stores/inference-options-store';
|
||||
import { api, type ApiResponse } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* The store widens the error type so a parse error keeps its `line`. `vi.mocked`
|
||||
* resolves the generic to the default `ApiError`, so mock responses go through
|
||||
* this helper to stay in the shape the store actually receives.
|
||||
*/
|
||||
type OptionsResponse = ApiResponse<InferenceOptionsResponse, InferenceOptionsError>;
|
||||
const reply = (r: OptionsResponse) => r as ApiResponse<InferenceOptionsResponse>;
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
post: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
},
|
||||
getRelayHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const DEVICE_ID = 'dev-1';
|
||||
const OPTIONS_PATH = `/devices/${DEVICE_ID}/inference/options`;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.post).mockResolvedValue({ success: true, data: {} });
|
||||
vi.mocked(api.postForm).mockResolvedValue({ success: true, data: {} });
|
||||
useInferenceOptionsStore.getState().reset();
|
||||
});
|
||||
|
||||
function file(name = 'labels.txt', content = '0 a\n') {
|
||||
return new File([content], name, { type: 'text/plain' });
|
||||
}
|
||||
|
||||
describe('inference options store — request shapes', () => {
|
||||
it('POSTs JSON { taskType } to the device options path', async () => {
|
||||
await useInferenceOptionsStore.getState().setTaskType(DEVICE_ID, 'classification');
|
||||
expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { taskType: 'classification' });
|
||||
});
|
||||
|
||||
it('POSTs the label file as multipart under the "labelFile" field', async () => {
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
const [path, form] = vi.mocked(api.postForm).mock.calls[0];
|
||||
expect(path).toBe(OPTIONS_PATH);
|
||||
// The server reads `labelFile`; anything else is silently ignored and the
|
||||
// request then fails the "neither taskType nor labels" guard.
|
||||
expect((form.get('labelFile') as File).name).toBe('labels.txt');
|
||||
});
|
||||
|
||||
it('clears via an explicit empty labels array, not multipart', async () => {
|
||||
await useInferenceOptionsStore.getState().clearLabels(DEVICE_ID);
|
||||
// Omitting `labels` means "leave untouched" server-side, so the empty array
|
||||
// has to be on the wire for the mapping to actually be dropped.
|
||||
expect(api.post).toHaveBeenCalledWith(OPTIONS_PATH, { labels: [] });
|
||||
expect(api.postForm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inference options store — state transitions', () => {
|
||||
it('returns true and records the task type on success', async () => {
|
||||
const ok = await useInferenceOptionsStore
|
||||
.getState()
|
||||
.setTaskType(DEVICE_ID, 'classification');
|
||||
expect(ok).toBe(true);
|
||||
expect(useInferenceOptionsStore.getState().taskType).toBe('classification');
|
||||
});
|
||||
|
||||
it('returns false and leaves the task type untouched on failure', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({
|
||||
success: false,
|
||||
error: { code: 'DEVICE_NOT_CONNECTED', message: 'nope' },
|
||||
});
|
||||
const ok = await useInferenceOptionsStore
|
||||
.getState()
|
||||
.setTaskType(DEVICE_ID, 'classification');
|
||||
|
||||
expect(ok).toBe(false);
|
||||
// A rejected switch must not leave the UI claiming the new mode is active.
|
||||
expect(useInferenceOptionsStore.getState().taskType).toBeNull();
|
||||
expect(useInferenceOptionsStore.getState().error?.code).toBe('DEVICE_NOT_CONNECTED');
|
||||
});
|
||||
|
||||
it('preserves the parse error line number', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: { code: 'LABEL_PARSE_ERROR', message: 'bad index', line: 7 },
|
||||
}),
|
||||
);
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
expect(useInferenceOptionsStore.getState().error?.line).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps existing labels when a replacement upload fails', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({ success: true, data: { labels: ['a', 'b'] } });
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
|
||||
vi.mocked(api.postForm).mockResolvedValue(
|
||||
reply({
|
||||
success: false,
|
||||
error: { code: 'LABEL_PARSE_ERROR', message: 'bad', line: 1 },
|
||||
}),
|
||||
);
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
|
||||
expect(useInferenceOptionsStore.getState().labels).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('turns a thrown network error into an error envelope rather than rejecting', async () => {
|
||||
vi.mocked(api.post).mockRejectedValue(new Error('offline'));
|
||||
const ok = await useInferenceOptionsStore
|
||||
.getState()
|
||||
.setTaskType(DEVICE_ID, 'classification');
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(useInferenceOptionsStore.getState().error?.code).toBe('NETWORK_ERROR');
|
||||
expect(useInferenceOptionsStore.getState().applying).toBe(false);
|
||||
});
|
||||
|
||||
it('drops both the task type and labels on reset', async () => {
|
||||
vi.mocked(api.postForm).mockResolvedValue({ success: true, data: { labels: ['a'] } });
|
||||
await useInferenceOptionsStore.getState().setTaskType(DEVICE_ID, 'classification');
|
||||
await useInferenceOptionsStore.getState().uploadLabels(DEVICE_ID, file());
|
||||
|
||||
useInferenceOptionsStore.getState().reset();
|
||||
|
||||
const s = useInferenceOptionsStore.getState();
|
||||
expect(s.taskType).toBeNull();
|
||||
expect(s.labels).toBeNull();
|
||||
expect(s.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLabelFile', () => {
|
||||
it('accepts .txt', () => {
|
||||
expect(validateLabelFile(file('labels.txt'))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts .names', () => {
|
||||
expect(validateLabelFile(file('coco.names'))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts an uppercase extension', () => {
|
||||
expect(validateLabelFile(file('LABELS.TXT'))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects another extension', () => {
|
||||
expect(validateLabelFile(file('labels.json'))).toEqual({
|
||||
ok: false,
|
||||
reason: 'wrong-type',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a file over the size cap', () => {
|
||||
const huge = file('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES + 1));
|
||||
expect(validateLabelFile(huge)).toEqual({ ok: false, reason: 'too-large' });
|
||||
});
|
||||
|
||||
it('accepts a file exactly at the cap', () => {
|
||||
const exact = file('labels.txt', 'x'.repeat(MAX_LABEL_FILE_BYTES));
|
||||
expect(validateLabelFile(exact)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@ -8,6 +8,11 @@ export interface BBox {
|
||||
export interface ClassResult {
|
||||
label: string;
|
||||
confidence: number;
|
||||
/**
|
||||
* Raw class index from the model output. Optional — the Go layer may not
|
||||
* forward it (M2-d is optional), so consumers must fall back to `label`.
|
||||
*/
|
||||
classIndex?: number;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user