feat: 算法升级、整车配准集成与性能分析文档

算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3
适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新
后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动
前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复
文档: 新增5份算法性能分析报告
This commit is contained in:
2026-06-25 08:22:20 +00:00
parent cec0766e67
commit 29f1d45d66
266 changed files with 1311475 additions and 2846 deletions

View File

@@ -11,7 +11,7 @@
*/
import { useEffect, useMemo, useState } from 'react';
import { trpc } from '@/lib/trpc';
import { CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw } from 'lucide-react';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan } from 'lucide-react';
import { toast } from 'sonner';
import TrainMeasurementPanel from './TrainMeasurementPanel';
@@ -44,10 +44,14 @@ interface PluginSummary {
interface Props {
taskId: number | null;
/** 是否已加载点云(用于按钮 disabled 提示) */
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
pointCloudLoaded: boolean;
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
taskPcFileCount?: number;
/** 当前任务中用户选中的点云文件 ID(空/undefined = 全部) */
selectedPcFileIds?: number[];
/** 当前任务中选中的点云中是否包含配准后的点云 */
hasRegisteredCloud?: boolean;
/** 当前任务中用户选中的 CAD 文件 ID(空/undefined = 不传 CAD) */
selectedCadFileIds?: number[];
/** ROI 拾取相关(train-measurement 专用) */
@@ -64,7 +68,7 @@ function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
return out;
}
export default function AlgorithmRunPanel({ taskId, pointCloudLoaded, selectedPcFileIds, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) {
export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) {
const utils = trpc.useUtils();
const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
staleTime: 60_000,
@@ -122,14 +126,13 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded, selectedPc
};
const canRun = useMemo(
() => taskId != null && pointCloudLoaded && selected.size > 0 && !runBatch.isPending,
[taskId, pointCloudLoaded, selected, runBatch.isPending],
// 任务下有点云文件(taskPcFileCount > 0)即可执行,无需先在 3D 视图加载
() => taskId != null && (taskPcFileCount ?? 0) > 0 && selected.size > 0 && !runBatch.isPending,
[taskId, taskPcFileCount, selected, runBatch.isPending],
);
const handleRun = () => {
const handleRun = (ids: string[]) => {
if (!canRun || taskId == null) return;
const ids: string[] = [];
selected.forEach((id) => ids.push(id));
const jobs = ids.map((pluginId) => ({
pluginId,
params: paramsById[pluginId] ?? {},
@@ -146,137 +149,202 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded, selectedPc
});
};
// 区分配准插件 vs 测量插件
const regPlugins = plugins.filter((p) => p.category === '配准');
const measPlugins = plugins.filter((p) => p.category !== '配准');
const selectedMeasIds = measPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
const selectedRegIds = regPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
// 不再需要 showRegWarning 变量,已直接内联到 JSX 里
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-1">
<span className="industrial-label flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5" /> 算法运行(多选并发)
</span>
<div className="flex items-center gap-1">
{scheduler && (
<span
className="text-[9px] text-muted-foreground tabular-nums"
title={`并发上限 ${scheduler.limit},运行中 ${scheduler.running},排队 ${scheduler.queued}`}
>
{scheduler.running}/{scheduler.limit} · 排队 {scheduler.queued}
</span>
)}
<div className="space-y-3">
{/* ── 步骤 1:整车配准 ────────────────────────────────── */}
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-1">
<span className="industrial-label flex items-center gap-1.5 text-blue-400">
<Scan className="w-3.5 h-3.5" />
第 1 步 · 整车配准
</span>
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${canRun ? '' : 'opacity-50 cursor-not-allowed'}`}
disabled={!canRun}
onClick={handleRun}
title={
taskId == null
? '请先选择检测任务'
: !pointCloudLoaded
? '请先在 3D 视图加载点云'
: selected.size === 0
? '请至少勾选一个算法'
: '并发执行已勾选的算法'
}
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
canRun && selectedRegIds.length > 0 ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={!canRun || selectedRegIds.length === 0}
onClick={() => handleRun(selectedRegIds)}
title={selectedRegIds.length === 0 ? '请勾选配准算法' : '执行配准'}
>
{runBatch.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Play className="w-3 h-3" />
)}
执行({selected.size})
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
执行({selectedRegIds.length})
</button>
</div>
{regPlugins.length === 0 && !isLoading && (
<div className="text-[10px] text-muted-foreground italic px-1">暂无配准插件</div>
)}
{regPlugins.map((p) => (
<PluginCard
key={p.id}
p={p}
isSelected={selected.has(p.id)}
isExpanded={expanded.has(p.id)}
params={paramsById[p.id] ?? {}}
onToggleSelect={toggleSelect}
onToggleExpand={toggleExpand}
onUpdateParam={updateParam}
taskId={taskId}
roiPickedPoint={roiPickedPoint}
onStartRoiPick={onStartRoiPick}
activeRoiPickTarget={activeRoiPickTarget}
/>
))}
</div>
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-2">加载插件列表…</div>
)}
{!isLoading && plugins.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-2">
暂无已注册算法插件
{/* ── 步骤 2:测量分析 ────────────────────────────────── */}
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-1">
<span className="industrial-label flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5" />
第 2 步 · 测量分析(多选并发)
</span>
<div className="flex items-center gap-1">
{scheduler && (
<span
className="text-[9px] text-muted-foreground tabular-nums"
title={`并发上限 ${scheduler.limit},运行中 ${scheduler.running},排队 ${scheduler.queued}`}
>
{scheduler.running}/{scheduler.limit} · 排队 {scheduler.queued}
</span>
)}
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
canRun && selectedMeasIds.length > 0 ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={!canRun || selectedMeasIds.length === 0}
onClick={() => handleRun(selectedMeasIds)}
title={
taskId == null ? '请先选择检测任务'
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
: selectedMeasIds.length === 0 ? '请至少勾选一个测量算法'
: '并发执行已勾选算法'
}
>
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
执行({selectedMeasIds.length})
</button>
</div>
</div>
{/* 未配准提示:有点云文件且选了测量算法,但所选点云均未配准 */}
{selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && (
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-[10px] text-amber-300">
当前选中的点云未经整车配准,测量结果可能不准确。建议先完成第 1 步配准,再选用已配准点云。
</span>
</div>
)}
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-2">加载插件列表…</div>
)}
{!isLoading && measPlugins.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-2">暂无测量算法插件</div>
)}
{measPlugins.map((p) => (
<PluginCard
key={p.id}
p={p}
isSelected={selected.has(p.id)}
isExpanded={expanded.has(p.id)}
params={paramsById[p.id] ?? {}}
onToggleSelect={toggleSelect}
onToggleExpand={toggleExpand}
onUpdateParam={updateParam}
taskId={taskId}
roiPickedPoint={roiPickedPoint}
onStartRoiPick={onStartRoiPick}
activeRoiPickTarget={activeRoiPickTarget}
/>
))}
</div>
</div>
);
}
function PluginCard({
p, isSelected, isExpanded, params,
onToggleSelect, onToggleExpand, onUpdateParam,
taskId, roiPickedPoint, onStartRoiPick, activeRoiPickTarget,
}: {
p: PluginSummary;
isSelected: boolean;
isExpanded: boolean;
params: Record<string, unknown>;
onToggleSelect: (id: string) => void;
onToggleExpand: (id: string) => void;
onUpdateParam: (pluginId: string, name: string, value: unknown) => void;
taskId: number | null;
roiPickedPoint?: { x: number; y: number; z: number } | null;
onStartRoiPick?: (taskIdx: number, roiKey: 'source_roi' | 'target_roi', corner: 'min' | 'max') => void;
activeRoiPickTarget?: { taskIdx: number; roiKey: string; corner: string } | null;
}) {
return (
<div className={`industrial-card rounded overflow-hidden ${isSelected ? 'border-primary/50' : ''}`}>
<div className="flex items-center gap-2 px-2 py-1.5">
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(p.id)}
className="w-3.5 h-3.5 accent-primary"
/>
<button
type="button"
className="flex-1 flex items-center gap-1.5 text-left"
onClick={() => onToggleExpand(p.id)}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs flex-1 truncate" title={p.description}>{p.name}</span>
<span className="text-[9px] text-muted-foreground tabular-nums flex-shrink-0">v{p.version}</span>
{p.runtimeKind === 'mock' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-900/40 text-amber-400 flex-shrink-0" title="Mock 模式">mock</span>
)}
{p.runtimeKind === 'process' && (
<CheckCircle2 className="w-3 h-3 text-green-400 flex-shrink-0" aria-label="已就绪" />
)}
</button>
</div>
{isExpanded && (
<div className="px-3 pb-2 pt-1 border-t border-border/40 space-y-2">
<p className="text-[10px] text-muted-foreground">{p.description}</p>
{p.id === 'train-measurement' ? (
<TrainMeasurementPanel
taskId={taskId}
pickedPoint={roiPickedPoint}
onStartRoiPick={onStartRoiPick}
activePickTarget={activeRoiPickTarget}
/>
) : p.inputs.length === 0 ? (
<p className="text-[10px] text-muted-foreground italic">该算法无可调参数。</p>
) : (
<div className="space-y-1.5">
{p.inputs.map((field) => (
<FieldEditor
key={field.name}
field={field}
value={params[field.name]}
onChange={(v) => onUpdateParam(p.id, field.name, v)}
/>
))}
</div>
)}
</div>
)}
<div className="space-y-1.5">
{plugins.map((p) => {
const isSelected = selected.has(p.id);
const isExpanded = expanded.has(p.id);
const params = paramsById[p.id] ?? {};
return (
<div
key={p.id}
className={`industrial-card rounded overflow-hidden ${isSelected ? 'border-primary/50' : ''}`}
>
<div className="flex items-center gap-2 px-2 py-1.5">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(p.id)}
className="w-3.5 h-3.5 accent-primary"
/>
<button
type="button"
className="flex-1 flex items-center gap-1.5 text-left"
onClick={() => toggleExpand(p.id)}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs flex-1 truncate" title={p.description}>
{p.name}
</span>
<span className="text-[9px] text-muted-foreground tabular-nums flex-shrink-0">
v{p.version}
</span>
{p.runtimeKind === 'mock' && (
<span
className="text-[9px] px-1 py-0.5 rounded bg-amber-900/40 text-amber-400 flex-shrink-0"
title="Mock 模式:返回预设结果,算法 CLI 就绪后切换为真实执行"
>
mock
</span>
)}
{p.runtimeKind === 'process' && (
<CheckCircle2
className="w-3 h-3 text-green-400 flex-shrink-0"
aria-label="已就绪"
/>
)}
</button>
</div>
{isExpanded && (
<div className="px-3 pb-2 pt-1 border-t border-border/40 space-y-2">
<p className="text-[10px] text-muted-foreground">{p.description}</p>
{p.id === 'train-measurement' ? (
<TrainMeasurementPanel
taskId={taskId}
pickedPoint={roiPickedPoint}
onStartRoiPick={onStartRoiPick}
activePickTarget={activeRoiPickTarget}
/>
) : p.inputs.length === 0 ? (
<p className="text-[10px] text-muted-foreground italic">
该算法无可调参数。
</p>
) : (
<div className="space-y-1.5">
{p.inputs.map((field) => (
<FieldEditor
key={field.name}
field={field}
value={params[field.name]}
onChange={(v) => updateParam(p.id, field.name, v)}
/>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,8 +1,46 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { trpc } from '@/lib/trpc';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu } from 'lucide-react';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer } from 'lucide-react';
import GenericResultView, { type Overlay3D } from './GenericResultView';
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
return `${Math.floor(m / 60)}h ${m % 60}m`;
}
/** 运行中的任务:实时更新计时;已结束:显示静态 updatedAt - createdAt */
function ElapsedTime({ status, createdAt, updatedAt }: {
status: string;
createdAt: string | Date;
updatedAt: string | Date;
}) {
const start = new Date(createdAt).getTime();
const end = new Date(updatedAt).getTime();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (status !== 'processing' && status !== 'pending') return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [status]);
const ms = (status === 'processing' || status === 'pending')
? now - start
: end - start;
if (ms < 500) return null;
return (
<span className="flex items-center gap-0.5 text-[9px] tabular-nums text-muted-foreground flex-shrink-0" title="执行时长">
<Timer className="w-2.5 h-2.5" />
{fmtDuration(ms)}
</span>
);
}
interface Props {
taskId: number;
onReplay?: (resultId: number) => void;
@@ -245,6 +283,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
r.status === 'error' ? 'bg-red-900/40 text-red-400' :
'bg-secondary text-muted-foreground'
}`}>{r.status}</span>
<ElapsedTime status={r.status} createdAt={r.createdAt} updatedAt={r.updatedAt} />
{r.status === 'processing' &&
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
(() => {

View File

@@ -8,7 +8,7 @@
* PluginResult,就能自动渲染。
*/
import { useState } from 'react';
import { Box, ChevronDown, ChevronRight, Image as ImageIcon } from 'lucide-react';
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
interface Measurement {
name: string;
@@ -41,6 +41,8 @@ interface PluginResultLike {
errorStats?: ErrorStats | null;
overlay3d?: Overlay3D;
images?: string[];
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
files?: string[];
extra?: Record<string, unknown>;
error?: string;
}
@@ -99,6 +101,7 @@ export default function GenericResultView({
const errorStats = data.errorStats ?? null;
const measurements = data.measurements ?? [];
const images = data.images ?? [];
const files = data.files ?? [];
const overlay = data.overlay3d ?? { kind: 'none' };
return (
@@ -177,6 +180,29 @@ export default function GenericResultView({
</button>
)}
{files.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">报表下载</div>
<div className="space-y-1">
{files.map((rel) => {
const url = `/api/files/read/analysis/${taskId}/${resultId}/${encodeURI(rel)}`;
return (
<a
key={rel}
href={url}
download
className="industrial-card p-1.5 flex items-center gap-1.5 hover:bg-secondary/40 text-[10px]"
title={rel}
>
<FileSpreadsheet className="w-3 h-3 flex-shrink-0 text-green-500" />
<span className="truncate">{rel}</span>
</a>
);
})}
</div>
</div>
)}
{images.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">产物图片</div>

View File

@@ -436,6 +436,11 @@ export default function TaskManager({
<span className="flex-1 truncate" title={f.name}>
{f.name}
</span>
{(f as { source?: string }).source === 'registered' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap">
已配准
</span>
)}
<span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)}
</span>

View File

@@ -49,6 +49,18 @@ export default function Home() {
const [pcLoadKey, setPcLoadKey] = useState(0);
/** 当前任务下用户勾选的点云文件 ID(空 = 加载全部) */
const [selectedPcFileIds, setSelectedPcFileIds] = useState<number[]>([]);
/** 当前任务下所有点云文件列表(用于判断是否含已配准点云) */
const { data: taskPcFiles = [] } = trpc.pointcloudFile.list.useQuery(
{ taskId: selection.taskId! },
{ enabled: selection.taskId != null },
);
/** 已选中的点云文件中,是否包含配准算法产出的点云 */
const hasRegisteredCloud =
selectedPcFileIds.length === 0
? taskPcFiles.some((f) => (f as { source?: string }).source === 'registered')
: taskPcFiles
.filter((f) => selectedPcFileIds.includes(f.id))
.some((f) => (f as { source?: string }).source === 'registered');
/** 当前任务下用户勾选的 CAD 文件 ID(空 = 不传 CAD) */
const [selectedTaskCadFileIds, setSelectedTaskCadFileIds] = useState<number[]>([]);
/** ROI 坐标拾取状态(train-measurement 专用) */
@@ -339,7 +351,9 @@ export default function Home() {
<AlgorithmRunPanel
taskId={selection.taskId}
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
taskPcFileCount={taskPcFiles.length}
selectedPcFileIds={selectedPcFileIds}
hasRegisteredCloud={hasRegisteredCloud}
selectedCadFileIds={selectedTaskCadFileIds}
roiPickedPoint={roiPickedPoint}
onStartRoiPick={(taskIdx, roiKey, corner) => {