2026-05-24-23-24-34 支持自动匹配采样切片调节

This commit is contained in:
2026-05-24 23:46:39 +08:00
parent bca3619b9b
commit 2fac0200fc
8 changed files with 294 additions and 5 deletions

View File

@@ -64,6 +64,7 @@ const weightOptions: Array<{ key: keyof AutoMatchWeights; label: string; min: nu
];
const boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
const maxManualSampleSlices = 96;
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
if (incomingPose) {
@@ -91,6 +92,64 @@ function poseDelta(base: ModelPose, next: ModelPose, key: AutoMatchParameterKey)
return next[key] - base[key];
}
function defaultSampleSliceNumbers(total: number) {
const maxSlice = Math.max(total - 1, 0);
const fractions = [0.12, 0.22, 0.32, 0.42, 0.5, 0.58, 0.68, 0.78, 0.88];
return [...new Set(fractions.map((fraction) => Math.max(0, Math.min(maxSlice, Math.round(maxSlice * fraction))) + 1))]
.sort((a, b) => a - b);
}
function formatSampleSliceText(total: number) {
return defaultSampleSliceNumbers(total).join(', ');
}
function parseSampleSliceText(input: string, total: number) {
const maxSlice = Math.max(total, 1);
const values: number[] = [];
const normalized = input
.replace(/[,、;;]/g, ',')
.replace(/\s+/g, ',')
.split(',')
.map((token) => token.trim())
.filter(Boolean);
normalized.forEach((token) => {
const rangeMatch = token.match(/^(\d+)\s*[-~]\s*(\d+)(?::(\d+))?$/);
if (rangeMatch) {
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
const rawStep = Number(rangeMatch[3] ?? 1);
const step = Math.max(1, Math.min(50, Number.isFinite(rawStep) ? Math.round(rawStep) : 1));
const direction = start <= end ? 1 : -1;
for (let value = start; direction > 0 ? value <= end : value >= end; value += step * direction) {
values.push(value);
if (values.length > maxManualSampleSlices * 2) {
break;
}
}
return;
}
const value = Number(token);
if (Number.isFinite(value)) {
values.push(value);
}
});
const slices = [...new Set(values
.map((value) => Math.round(value))
.filter((value) => Number.isFinite(value))
.map((value) => Math.max(1, Math.min(maxSlice, value))))]
.sort((a, b) => a - b)
.slice(0, maxManualSampleSlices);
return slices.length ? slices : defaultSampleSliceNumbers(total);
}
function poseHasVisibleChange(base: ModelPose, next: ModelPose) {
return parameterOptions.some((option) => Math.abs(poseDelta(base, next, option.key)) >= 0.0005);
}
interface AutoMatchWorkspaceProps {
projectId: string;
initialPose?: ModelPose | null;
@@ -105,11 +164,14 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
const [selectedBoneFiles, setSelectedBoneFiles] = useState<string[]>([]);
const [adjustable, setAdjustable] = useState<AutoMatchParameterSelection>(defaultAdjustable);
const [weights, setWeights] = useState<AutoMatchWeights>(defaultWeights);
const [sampleSlicesText, setSampleSlicesText] = useState('');
const [iterations, setIterations] = useState(6);
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
const [result, setResult] = useState<{
bestPose: ModelPose;
bestScore: number;
bestMode: string;
bestChanged: AutoMatchParameterKey[];
evaluated: number;
trace: AutoMatchCandidate[];
sampleSlices: number[];
@@ -151,6 +213,7 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
setProject(nextProject);
setBasePose(latestPoseFromProject(nextProject, incomingPose));
setSelectedBoneFiles(defaultBoneFiles(nextProject));
setSampleSlicesText(formatSampleSliceText(nextProject.dicomCount));
setResult(null);
})
.catch((loadError) => {
@@ -183,6 +246,11 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
() => parameterOptions.filter((option) => adjustable[option.key]).length,
[adjustable],
);
const parsedSampleSlices = useMemo(
() => parseSampleSliceText(sampleSlicesText, project?.dicomCount ?? 1),
[project?.dicomCount, sampleSlicesText],
);
const resultHasPoseChange = result ? poseHasVisibleChange(basePose, result.bestPose) : false;
const toggleBoneFile = (fileName: string) => {
setSelectedBoneFiles((current) => (
@@ -214,6 +282,7 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
pose: basePose,
adjustable,
boneFiles: selectedBoneFiles,
sampleSlices: parsedSampleSlices.map((slice) => slice - 1),
iterations,
candidatesPerRound,
weights,
@@ -221,6 +290,8 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
setResult({
bestPose: data.bestPose,
bestScore: data.bestScore,
bestMode: data.bestMode,
bestChanged: data.bestChanged,
evaluated: data.evaluated,
trace: data.trace,
sampleSlices: data.sampleSlices,
@@ -380,6 +451,28 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
</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={() => setSampleSlicesText(formatSampleSliceText(project?.dicomCount ?? 1))}
className="text-xs font-black text-blue-600 hover:text-blue-700"
>
</button>
</div>
<textarea
value={sampleSlicesText}
onChange={(event) => setSampleSlicesText(event.target.value)}
placeholder="39, 71, 102 或 39-160:5"
className="h-20 w-full resize-none rounded-lg border border-slate-200 bg-white px-3 py-2 font-mono text-sm font-bold text-slate-700 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
/>
<div className="mt-2 flex items-center justify-between text-xs font-bold text-slate-400">
<span> 1-{Math.max(project?.dicomCount ?? 1, 1)}</span>
<span>{parsedSampleSlices.length}/{maxManualSampleSlices} </span>
</div>
</div>
<div>
<div className="mb-3 text-xs font-black uppercase text-slate-400"></div>
<div className="space-y-3">
@@ -478,7 +571,13 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
))}
</div>
<div className="mt-5 grid grid-cols-3 gap-3 max-lg:grid-cols-1">
{result && !resultHasPoseChange && (
<div className="mt-4 rounded-lg border border-amber-100 bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800">
{result.bestMode}姿 +0.000
</div>
)}
<div className="mt-5 grid grid-cols-4 gap-3 max-xl:grid-cols-2 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>
@@ -493,6 +592,10 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
<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 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 text-sm font-black text-slate-800">{result?.bestMode ?? '-'}</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">

View File

@@ -101,6 +101,8 @@ export interface AutoMatchResult {
basePose: ModelPose;
bestPose: ModelPose;
bestScore: number;
bestMode: string;
bestChanged: AutoMatchParameterKey[];
iterations: number;
evaluated: number;
boneFiles: string[];