2026-05-24-22-40-13 新增自动微调匹配工作区
This commit is contained in:
@@ -15,8 +15,9 @@ import Sidebar from './components/Sidebar';
|
||||
import Overview from './components/Overview';
|
||||
import ProjectLibrary from './components/ProjectLibrary';
|
||||
import ReverseWorkspace from './components/ReverseWorkspace';
|
||||
import AutoMatchWorkspace from './components/AutoMatchWorkspace';
|
||||
import UserManagement from './components/UserManagement';
|
||||
import { ViewType } from './types';
|
||||
import { ModelPose, ViewType } from './types';
|
||||
import { api } from './lib/api';
|
||||
|
||||
export default function App() {
|
||||
@@ -25,13 +26,14 @@ export default function App() {
|
||||
const [activeView, setActiveView] = useState<ViewType>(ViewType.OVERVIEW);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [activeProjectId, setActiveProjectId] = useState('head-ct-demo');
|
||||
const [autoMatchInitialPose, setAutoMatchInitialPose] = useState<ModelPose | null>(null);
|
||||
const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
||||
const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const bootSessionResetRef = useRef(false);
|
||||
|
||||
// Automatically collapse main sidebar when entering Project Library or Workspace
|
||||
useEffect(() => {
|
||||
if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE) {
|
||||
if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE || activeView === ViewType.AUTO_MATCH) {
|
||||
setSidebarCollapsed(true);
|
||||
} else {
|
||||
setSidebarCollapsed(false);
|
||||
@@ -94,6 +96,9 @@ export default function App() {
|
||||
if (leaveWorkspace && nextView === ViewType.PROJECTS) {
|
||||
setProjectLibraryInitialView('mask');
|
||||
}
|
||||
if (nextView === ViewType.AUTO_MATCH) {
|
||||
setAutoMatchInitialPose(null);
|
||||
}
|
||||
setActiveView(nextView);
|
||||
};
|
||||
|
||||
@@ -123,6 +128,12 @@ export default function App() {
|
||||
setActiveView(ViewType.OVERVIEW);
|
||||
};
|
||||
|
||||
const openAutoMatchWorkspace = (projectId: string, pose?: ModelPose) => {
|
||||
setActiveProjectId(projectId);
|
||||
setAutoMatchInitialPose(pose ?? null);
|
||||
setActiveView(ViewType.AUTO_MATCH);
|
||||
};
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center text-slate-500 font-medium">
|
||||
@@ -153,6 +164,7 @@ export default function App() {
|
||||
{activeView === ViewType.OVERVIEW && '总体概况'}
|
||||
{activeView === ViewType.PROJECTS && '项目库'}
|
||||
{activeView === ViewType.WORKSPACE && '逆向工作区'}
|
||||
{activeView === ViewType.AUTO_MATCH && '自动微调匹配工作区'}
|
||||
{activeView === ViewType.SYSTEM && '系统管理工作区'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -185,11 +197,22 @@ export default function App() {
|
||||
{activeView === ViewType.WORKSPACE && (
|
||||
<ReverseWorkspace
|
||||
projectId={activeProjectId}
|
||||
onAutoMatch={openAutoMatchWorkspace}
|
||||
onLeaveGuardChange={(handler) => {
|
||||
workspaceLeaveGuardRef.current = handler;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeView === ViewType.AUTO_MATCH && (
|
||||
<AutoMatchWorkspace
|
||||
projectId={activeProjectId}
|
||||
initialPose={autoMatchInitialPose}
|
||||
onOpenReverse={(projectId) => {
|
||||
setActiveProjectId(projectId);
|
||||
setActiveView(ViewType.WORKSPACE);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeView === ViewType.SYSTEM && <UserManagement />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
559
WebSite/src/components/AutoMatchWorkspace.tsx
Normal file
559
WebSite/src/components/AutoMatchWorkspace.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bone,
|
||||
CheckCircle2,
|
||||
Crosshair,
|
||||
Loader2,
|
||||
Lock,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AutoMatchCandidate,
|
||||
AutoMatchParameterKey,
|
||||
AutoMatchParameterSelection,
|
||||
AutoMatchWeights,
|
||||
ModelPose,
|
||||
Project,
|
||||
} from '../types';
|
||||
import { api } from '../lib/api';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const defaultModelPose: ModelPose = {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
rotateZ: 0,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
};
|
||||
|
||||
const defaultAdjustable: AutoMatchParameterSelection = {
|
||||
translateX: true,
|
||||
translateY: true,
|
||||
translateZ: true,
|
||||
scale: true,
|
||||
};
|
||||
|
||||
const defaultWeights: AutoMatchWeights = {
|
||||
boneReward: 1,
|
||||
missPenalty: 0.45,
|
||||
movementPenalty: 0.08,
|
||||
scalePenalty: 0.12,
|
||||
};
|
||||
|
||||
const parameterOptions: Array<{ key: AutoMatchParameterKey; label: string }> = [
|
||||
{ key: 'translateX', label: '平移 X' },
|
||||
{ key: 'translateY', label: '平移 Y' },
|
||||
{ key: 'translateZ', label: '平移 Z' },
|
||||
{ key: 'scale', label: '缩放' },
|
||||
];
|
||||
|
||||
const weightOptions: Array<{ key: keyof AutoMatchWeights; label: string; min: number; max: number; step: number }> = [
|
||||
{ key: 'boneReward', label: '骨窗命中奖励', min: 0.2, max: 2, step: 0.05 },
|
||||
{ key: 'missPenalty', label: '非骨区域惩罚', min: 0, max: 1.5, step: 0.05 },
|
||||
{ key: 'movementPenalty', label: '移动惩罚', min: 0, max: 0.4, step: 0.01 },
|
||||
{ key: 'scalePenalty', label: '缩放惩罚', min: 0, max: 0.6, step: 0.01 },
|
||||
];
|
||||
|
||||
const boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
|
||||
|
||||
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
|
||||
if (incomingPose) {
|
||||
return incomingPose;
|
||||
}
|
||||
const latestResult = project?.segmentationResults?.[project.segmentationResults.length - 1];
|
||||
return latestResult?.pose
|
||||
?? project?.modelPoses?.find((pose) => pose.id === 'auto-match')?.pose
|
||||
?? project?.modelPoses?.find((pose) => pose.id === 'default')?.pose
|
||||
?? project?.modelPoses?.[0]?.pose
|
||||
?? defaultModelPose;
|
||||
}
|
||||
|
||||
function defaultBoneFiles(project: Project | null) {
|
||||
const files = project?.stlFiles ?? [];
|
||||
const matched = files.filter((fileName) => boneNamePattern.test(fileName));
|
||||
return matched.length ? matched : files;
|
||||
}
|
||||
|
||||
function formatPoseNumber(value: number) {
|
||||
return Number.isFinite(value) ? value.toFixed(3) : '0.000';
|
||||
}
|
||||
|
||||
function poseDelta(base: ModelPose, next: ModelPose, key: AutoMatchParameterKey) {
|
||||
return next[key] - base[key];
|
||||
}
|
||||
|
||||
interface AutoMatchWorkspaceProps {
|
||||
projectId: string;
|
||||
initialPose?: ModelPose | null;
|
||||
onOpenReverse?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export default function AutoMatchWorkspace({ projectId, initialPose, onOpenReverse }: AutoMatchWorkspaceProps) {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(projectId);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [basePose, setBasePose] = useState<ModelPose>(initialPose ?? defaultModelPose);
|
||||
const [selectedBoneFiles, setSelectedBoneFiles] = useState<string[]>([]);
|
||||
const [adjustable, setAdjustable] = useState<AutoMatchParameterSelection>(defaultAdjustable);
|
||||
const [weights, setWeights] = useState<AutoMatchWeights>(defaultWeights);
|
||||
const [iterations, setIterations] = useState(6);
|
||||
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
|
||||
const [result, setResult] = useState<{
|
||||
bestPose: ModelPose;
|
||||
bestScore: number;
|
||||
evaluated: number;
|
||||
trace: AutoMatchCandidate[];
|
||||
sampleSlices: number[];
|
||||
} | null>(null);
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [loadingPercent, setLoadingPercent] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProjectId(projectId);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
api.getProjects()
|
||||
.then((items) => {
|
||||
if (mounted) {
|
||||
setProjects(items);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoadingProject(true);
|
||||
setError('');
|
||||
api.getProject(selectedProjectId)
|
||||
.then((nextProject) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const incomingPose = selectedProjectId === projectId ? initialPose : null;
|
||||
setProject(nextProject);
|
||||
setBasePose(latestPoseFromProject(nextProject, incomingPose));
|
||||
setSelectedBoneFiles(defaultBoneFiles(nextProject));
|
||||
setResult(null);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (mounted) {
|
||||
setError(loadError instanceof Error ? loadError.message : '项目加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
setLoadingProject(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [selectedProjectId, projectId, initialPose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
return undefined;
|
||||
}
|
||||
setLoadingPercent(8);
|
||||
const timer = window.setInterval(() => {
|
||||
setLoadingPercent((current) => Math.min(92, current + Math.max(1, (94 - current) * 0.08)));
|
||||
}, 320);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running]);
|
||||
|
||||
const enabledParameterCount = useMemo(
|
||||
() => parameterOptions.filter((option) => adjustable[option.key]).length,
|
||||
[adjustable],
|
||||
);
|
||||
|
||||
const toggleBoneFile = (fileName: string) => {
|
||||
setSelectedBoneFiles((current) => (
|
||||
current.includes(fileName)
|
||||
? current.filter((item) => item !== fileName)
|
||||
: [...current, fileName]
|
||||
));
|
||||
};
|
||||
|
||||
const runAutoMatch = async () => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
if (!selectedBoneFiles.length) {
|
||||
setError('请选择至少一个骨骼区域 STL');
|
||||
return;
|
||||
}
|
||||
if (!enabledParameterCount) {
|
||||
setError('请选择至少一个可微调参数');
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setError('');
|
||||
setStatus('');
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await api.runAutoMatch(project.id, {
|
||||
pose: basePose,
|
||||
adjustable,
|
||||
boneFiles: selectedBoneFiles,
|
||||
iterations,
|
||||
candidatesPerRound,
|
||||
weights,
|
||||
});
|
||||
setResult({
|
||||
bestPose: data.bestPose,
|
||||
bestScore: data.bestScore,
|
||||
evaluated: data.evaluated,
|
||||
trace: data.trace,
|
||||
sampleSlices: data.sampleSlices,
|
||||
});
|
||||
setLoadingPercent(100);
|
||||
setStatus('已完成自动微调匹配');
|
||||
} catch (runError) {
|
||||
setError(runError instanceof Error ? runError.message : '自动微调匹配失败');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyBestPose = async (returnToWorkspace = false) => {
|
||||
if (!project || !result) {
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setStatus('');
|
||||
try {
|
||||
const updated = await api.applyProjectModelPose(project.id, result.bestPose);
|
||||
setProject(updated);
|
||||
setBasePose(result.bestPose);
|
||||
setStatus('最佳位姿已写入项目库');
|
||||
if (returnToWorkspace) {
|
||||
onOpenReverse?.(project.id);
|
||||
}
|
||||
} catch (applyError) {
|
||||
setError(applyError instanceof Error ? applyError.message : '保存最佳位姿失败');
|
||||
}
|
||||
};
|
||||
|
||||
const traceRows = result?.trace.slice(0, 10) ?? [];
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-2">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={(event) => setSelectedProjectId(event.target.value)}
|
||||
className="h-11 min-w-64 rounded-lg border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm outline-none focus:border-blue-400"
|
||||
>
|
||||
{projects.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="rounded-lg bg-blue-50 px-3 py-2 text-sm font-bold text-blue-700">DICOM {project?.dicomCount ?? '-'}</span>
|
||||
<span className="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700">STL {project?.modelCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onOpenReverse?.(selectedProjectId)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-bold text-slate-700 shadow-sm transition hover:border-blue-200 hover:text-blue-700"
|
||||
>
|
||||
<ArrowLeft size={17} />
|
||||
返回逆向工作区
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(running || loadingProject) && (
|
||||
<div className="mb-4 overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm font-bold text-slate-700">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} className="animate-spin text-blue-600" />
|
||||
{loadingProject ? '正在读取项目数据' : '正在迭代匹配'}
|
||||
</span>
|
||||
<span className="font-mono text-blue-700">{Math.round(loadingProject ? 28 : loadingPercent)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all"
|
||||
style={{ width: `${loadingProject ? 28 : loadingPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-100 bg-red-50 px-4 py-3 text-sm font-bold text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{status && (
|
||||
<div className="mb-4 rounded-lg border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-700">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-h-0 grid-cols-[minmax(360px,0.85fr)_minmax(520px,1.15fr)] gap-5 max-xl:grid-cols-1">
|
||||
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={18} className="text-blue-600" />
|
||||
<h2 className="text-base font-black text-slate-800">匹配参数</h2>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-lg bg-slate-100 px-2.5 py-1 text-[11px] font-black text-slate-600">
|
||||
<Lock size={13} />
|
||||
旋转锁定
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-5">
|
||||
<div>
|
||||
<div className="mb-3 text-xs font-black uppercase text-slate-400">可调整参数</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{parameterOptions.map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg border px-3 py-2.5 text-sm font-bold transition',
|
||||
adjustable[option.key] ? 'border-blue-200 bg-blue-50 text-blue-700' : 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adjustable[option.key]}
|
||||
onChange={(event) => setAdjustable((current) => ({ ...current, [option.key]: event.target.checked }))}
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-black uppercase text-slate-400">骨骼区域</span>
|
||||
<button
|
||||
onClick={() => setSelectedBoneFiles(defaultBoneFiles(project))}
|
||||
className="text-xs font-black text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
重置选择
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-2">
|
||||
{(project?.stlFiles ?? []).map((fileName) => (
|
||||
<label
|
||||
key={fileName}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-bold transition',
|
||||
selectedBoneFiles.includes(fileName) ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBoneFiles.includes(fileName)}
|
||||
onChange={() => toggleBoneFile(fileName)}
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
/>
|
||||
<Bone size={16} className={selectedBoneFiles.includes(fileName) ? 'text-amber-500' : 'text-slate-300'} />
|
||||
<span className="min-w-0 truncate">{fileName.replace(/\.stl$/i, '')}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 text-xs font-black uppercase text-slate-400">评分权重</div>
|
||||
<div className="space-y-3">
|
||||
{weightOptions.map((option) => (
|
||||
<label key={option.key} className="grid grid-cols-[112px_1fr_64px] items-center gap-3 text-xs font-bold text-slate-500">
|
||||
<span>{option.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={option.min}
|
||||
max={option.max}
|
||||
step={option.step}
|
||||
value={weights[option.key]}
|
||||
onChange={(event) => setWeights((current) => ({ ...current, [option.key]: Number(event.target.value) }))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={option.min}
|
||||
max={option.max}
|
||||
step={option.step}
|
||||
value={weights[option.key]}
|
||||
onChange={(event) => setWeights((current) => ({ ...current, [option.key]: Number(event.target.value) }))}
|
||||
className="h-8 rounded-md border border-slate-200 bg-white px-2 text-right font-mono text-xs text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="text-xs font-bold text-slate-500">
|
||||
迭代轮次
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={12}
|
||||
value={iterations}
|
||||
onChange={(event) => setIterations(Math.max(2, Math.min(12, Number(event.target.value) || 2)))}
|
||||
className="mt-1 h-10 w-full rounded-lg border border-slate-200 px-3 font-mono text-sm text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-bold text-slate-500">
|
||||
每轮候选
|
||||
<input
|
||||
type="number"
|
||||
min={12}
|
||||
max={80}
|
||||
value={candidatesPerRound}
|
||||
onChange={(event) => setCandidatesPerRound(Math.max(12, Math.min(80, Number(event.target.value) || 12)))}
|
||||
className="mt-1 h-10 w-full rounded-lg border border-slate-200 px-3 font-mono text-sm text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => void runAutoMatch()}
|
||||
disabled={running || loadingProject || !project}
|
||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-blue-600 text-sm font-black text-white shadow-lg shadow-blue-950/10 transition hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{running ? <Loader2 size={18} className="animate-spin" /> : <Play size={18} />}
|
||||
开始自动微调匹配
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crosshair size={18} className="text-blue-600" />
|
||||
<h2 className="text-base font-black text-slate-800">匹配结果</h2>
|
||||
</div>
|
||||
{result && (
|
||||
<span className="inline-flex items-center gap-1 rounded-lg bg-emerald-50 px-2.5 py-1 text-[11px] font-black text-emerald-700">
|
||||
<CheckCircle2 size={13} />
|
||||
score {result.bestScore.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-2">
|
||||
{parameterOptions.map((option) => (
|
||||
<div key={option.key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">
|
||||
<div className="text-[11px] font-black uppercase text-slate-400">{option.label}</div>
|
||||
<div className="mt-2 font-mono text-lg font-black text-slate-800">
|
||||
{formatPoseNumber((result?.bestPose ?? basePose)[option.key])}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'mt-1 font-mono text-xs font-bold',
|
||||
result && poseDelta(basePose, result.bestPose, option.key) >= 0 ? 'text-emerald-600' : 'text-red-500',
|
||||
)}
|
||||
>
|
||||
{result ? `${poseDelta(basePose, result.bestPose, option.key) >= 0 ? '+' : ''}${formatPoseNumber(poseDelta(basePose, result.bestPose, option.key))}` : '+0.000'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-3 gap-3 max-lg:grid-cols-1">
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-xs font-black text-slate-400">候选评估</div>
|
||||
<div className="mt-2 font-mono text-xl font-black text-slate-800">{result?.evaluated ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-xs font-black text-slate-400">采样切片</div>
|
||||
<div className="mt-2 truncate font-mono text-sm font-black text-slate-800">
|
||||
{result?.sampleSlices.map((slice) => slice + 1).join(', ') ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-xs font-black text-slate-400">骨骼构件</div>
|
||||
<div className="mt-2 font-mono text-xl font-black text-slate-800">{selectedBoneFiles.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => void applyBestPose(false)}
|
||||
disabled={!result}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-sm font-black text-white shadow-lg shadow-emerald-950/10 transition hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Save size={17} />
|
||||
应用最佳位姿
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void applyBestPose(true)}
|
||||
disabled={!result}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-black text-white shadow-lg shadow-slate-950/10 transition hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
<Save size={17} />
|
||||
应用并返回逆向工作区
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setBasePose(latestPoseFromProject(project, selectedProjectId === projectId ? initialPose : null));
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-black text-slate-600 transition hover:border-blue-200 hover:text-blue-700"
|
||||
>
|
||||
<RefreshCcw size={17} />
|
||||
重置结果
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid grid-cols-[72px_1fr_100px_96px_96px] bg-slate-50 px-3 py-2 text-[11px] font-black uppercase text-slate-400">
|
||||
<span>轮次</span>
|
||||
<span>模式</span>
|
||||
<span>评分</span>
|
||||
<span>命中</span>
|
||||
<span>贡献点</span>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{traceRows.length ? traceRows.map((item, index) => (
|
||||
<div
|
||||
key={`${item.iteration}-${item.mode}-${index}`}
|
||||
className="grid grid-cols-[72px_1fr_100px_96px_96px] border-t border-slate-100 px-3 py-2 text-xs font-bold text-slate-600"
|
||||
>
|
||||
<span className="font-mono">{item.iteration + 1}</span>
|
||||
<span className="truncate">{item.mode}</span>
|
||||
<span className="font-mono text-slate-900">{item.score.toFixed(4)}</span>
|
||||
<span className="font-mono text-emerald-600">{item.boneReward.toFixed(3)}</span>
|
||||
<span className="font-mono">{item.contributors}</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="px-3 py-10 text-center text-sm font-bold text-slate-400">
|
||||
尚未运行自动微调匹配
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
FlipHorizontal2,
|
||||
FlipVertical2,
|
||||
Move3d,
|
||||
Crosshair,
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
||||
@@ -2782,9 +2783,11 @@ export function VoxelizationMappingView({
|
||||
export default function ReverseWorkspace({
|
||||
projectId,
|
||||
onLeaveGuardChange,
|
||||
onAutoMatch,
|
||||
}: {
|
||||
projectId: string;
|
||||
onLeaveGuardChange?: (handler: WorkspaceLeaveGuard | null) => void;
|
||||
onAutoMatch?: (projectId: string, pose: ModelPose) => void;
|
||||
}) {
|
||||
const [sliceStart, setSliceStart] = useState(0);
|
||||
const [sliceEnd, setSliceEnd] = useState(49);
|
||||
@@ -3732,6 +3735,14 @@ export default function ReverseWorkspace({
|
||||
{!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型,生成像素映射关系</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => project && onAutoMatch?.(project.id, modelPose)}
|
||||
disabled={!project}
|
||||
className="bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Crosshair size={18} />
|
||||
自动微调匹配
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveSegmentationResult()}
|
||||
disabled={!project}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BarChart3,
|
||||
FolderRoot,
|
||||
Workflow,
|
||||
Crosshair,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
@@ -32,6 +33,7 @@ export default function Sidebar({
|
||||
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
|
||||
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
|
||||
{ id: ViewType.WORKSPACE, icon: Workflow, label: '逆向工作区' },
|
||||
{ id: ViewType.AUTO_MATCH, icon: Crosshair, label: '自动微调匹配工作区' },
|
||||
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
|
||||
import { AutoMatchRequest, AutoMatchResult, DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
|
||||
|
||||
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
|
||||
export type SegmentationExportMode = 'combined' | 'separate';
|
||||
@@ -129,6 +129,16 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ modelPoses }),
|
||||
}),
|
||||
runAutoMatch: (projectId: string, payload: AutoMatchRequest) =>
|
||||
request<AutoMatchResult>(`/api/projects/${projectId}/auto-match`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
applyProjectModelPose: (projectId: string, pose: ModelPose) =>
|
||||
request<Project>(`/api/projects/${projectId}/model-pose`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ pose }),
|
||||
}),
|
||||
importProjectAssets: (
|
||||
projectId: string,
|
||||
kind: ProjectAssetImportKind,
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum ViewType {
|
||||
OVERVIEW = 'overview',
|
||||
PROJECTS = 'projects',
|
||||
WORKSPACE = 'workspace',
|
||||
AUTO_MATCH = 'auto-match',
|
||||
SYSTEM = 'system',
|
||||
}
|
||||
|
||||
@@ -56,6 +57,58 @@ export interface SavedModelPose {
|
||||
pose: ModelPose;
|
||||
}
|
||||
|
||||
export type AutoMatchParameterKey = 'translateX' | 'translateY' | 'translateZ' | 'scale';
|
||||
|
||||
export interface AutoMatchParameterSelection {
|
||||
translateX: boolean;
|
||||
translateY: boolean;
|
||||
translateZ: boolean;
|
||||
scale: boolean;
|
||||
}
|
||||
|
||||
export interface AutoMatchWeights {
|
||||
boneReward: number;
|
||||
missPenalty: number;
|
||||
movementPenalty: number;
|
||||
scalePenalty: number;
|
||||
}
|
||||
|
||||
export interface AutoMatchRequest {
|
||||
pose: ModelPose;
|
||||
adjustable: AutoMatchParameterSelection;
|
||||
boneFiles: string[];
|
||||
sampleSlices?: number[];
|
||||
iterations?: number;
|
||||
candidatesPerRound?: number;
|
||||
weights?: Partial<AutoMatchWeights>;
|
||||
}
|
||||
|
||||
export interface AutoMatchCandidate {
|
||||
iteration: number;
|
||||
mode: string;
|
||||
pose: ModelPose;
|
||||
score: number;
|
||||
boneReward: number;
|
||||
missPenalty: number;
|
||||
movementPenalty: number;
|
||||
scalePenalty: number;
|
||||
contributors: number;
|
||||
changed: AutoMatchParameterKey[];
|
||||
}
|
||||
|
||||
export interface AutoMatchResult {
|
||||
projectId: string;
|
||||
basePose: ModelPose;
|
||||
bestPose: ModelPose;
|
||||
bestScore: number;
|
||||
iterations: number;
|
||||
evaluated: number;
|
||||
boneFiles: string[];
|
||||
sampleSlices: number[];
|
||||
weights: AutoMatchWeights;
|
||||
trace: AutoMatchCandidate[];
|
||||
}
|
||||
|
||||
export type SegmentationExportScope = 'all' | 'visible';
|
||||
export type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
Reference in New Issue
Block a user