2026-05-24-23-24-34 支持自动匹配采样切片调节
This commit is contained in:
@@ -1044,6 +1044,7 @@ interface AutoMatchContext {
|
||||
volume: DicomHuVolume;
|
||||
metrics: ExportSceneMetrics;
|
||||
samples: Point3DRecord[];
|
||||
sampleSlices: number[];
|
||||
basePose: ModelPoseValue;
|
||||
weights: AutoMatchWeights;
|
||||
}
|
||||
@@ -1117,6 +1118,19 @@ function chooseAutoMatchSampleSlices(input: unknown, depth: number) {
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function nearestAutoMatchSampleSlice(slice: number, sampleSlices: number[], tolerance = 1) {
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = Infinity;
|
||||
sampleSlices.forEach((candidate) => {
|
||||
const distance = Math.abs(candidate - slice);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = candidate;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearestDistance <= tolerance ? nearest : null;
|
||||
}
|
||||
|
||||
function collectAutoMatchPreviews(project: ProjectRecord) {
|
||||
return (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => {
|
||||
const filePath = getProjectModelFilePath(project, fileName);
|
||||
@@ -1207,14 +1221,18 @@ function evaluateAutoMatchPose(
|
||||
context.samples.forEach((sample) => {
|
||||
const transformed = transformPointForExportPose(sample.x, sample.y, sample.z, context.metrics, pose);
|
||||
const mapped = mapAutoMatchPointToVolume(transformed, context.metrics, context.volume);
|
||||
const sampleSlice = nearestAutoMatchSampleSlice(mapped.slice, context.sampleSlices);
|
||||
if (sampleSlice === null) {
|
||||
return;
|
||||
}
|
||||
contributors += 1;
|
||||
|
||||
if (mapped.slice < 0 || mapped.slice >= context.volume.depth) {
|
||||
if (sampleSlice < 0 || sampleSlice >= context.volume.depth) {
|
||||
missPenalty += 0.8;
|
||||
return;
|
||||
}
|
||||
|
||||
const bone = sampleAutoMatchBoneWindow(context.volume, mapped.slice, mapped.x, mapped.y);
|
||||
const bone = sampleAutoMatchBoneWindow(context.volume, sampleSlice, mapped.x, mapped.y);
|
||||
if (bone.value > 0) {
|
||||
hitReward += bone.value;
|
||||
} else {
|
||||
@@ -1323,6 +1341,7 @@ function createAutoMatchContext(project: ProjectRecord, body: Record<string, unk
|
||||
}
|
||||
|
||||
const boneFiles = resolveAutoMatchBoneFiles(project, body.boneFiles);
|
||||
const sampleSlices = chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth);
|
||||
const samples = collectAutoMatchSamples(project, boneFiles);
|
||||
if (!samples.length) {
|
||||
throw new Error('未能从骨骼 STL 中采样到可匹配点');
|
||||
@@ -1334,11 +1353,12 @@ function createAutoMatchContext(project: ProjectRecord, body: Record<string, unk
|
||||
volume,
|
||||
metrics,
|
||||
samples,
|
||||
sampleSlices,
|
||||
basePose: normalizeModelPoseValue(body.pose as Partial<ModelPoseValue> | undefined),
|
||||
weights: normalizeAutoMatchWeights(body.weights),
|
||||
} satisfies AutoMatchContext,
|
||||
boneFiles,
|
||||
sampleSlices: chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth),
|
||||
sampleSlices,
|
||||
adjustable: normalizeAutoMatchAdjustable(body.adjustable),
|
||||
iterations: normalizeAutoMatchIterations(body.iterations),
|
||||
candidatesPerRound: normalizeAutoMatchCandidatesPerRound(body.candidatesPerRound),
|
||||
@@ -1375,6 +1395,8 @@ function runProjectAutoMatch(project: ProjectRecord, body: Record<string, unknow
|
||||
basePose: context.basePose,
|
||||
bestPose: best.pose,
|
||||
bestScore: best.score,
|
||||
bestMode: best.mode,
|
||||
bestChanged: best.changed,
|
||||
iterations,
|
||||
evaluated,
|
||||
boneFiles,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface AutoMatchResult {
|
||||
basePose: ModelPose;
|
||||
bestPose: ModelPose;
|
||||
bestScore: number;
|
||||
bestMode: string;
|
||||
bestChanged: AutoMatchParameterKey[];
|
||||
iterations: number;
|
||||
evaluated: number;
|
||||
boneFiles: string[];
|
||||
|
||||
Reference in New Issue
Block a user