2026-05-24-23-24-34 支持自动匹配采样切片调节
This commit is contained in:
@@ -20,7 +20,7 @@
|
|||||||
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。
|
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。
|
||||||
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
|
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
|
||||||
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
|
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
|
||||||
- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,并可将最佳位姿写回项目库。
|
- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,采样切片可人工填写并真实参与评分,可将最佳位姿写回项目库。
|
||||||
|
|
||||||
## 一、本机部署
|
## 一、本机部署
|
||||||
|
|
||||||
|
|||||||
@@ -1044,6 +1044,7 @@ interface AutoMatchContext {
|
|||||||
volume: DicomHuVolume;
|
volume: DicomHuVolume;
|
||||||
metrics: ExportSceneMetrics;
|
metrics: ExportSceneMetrics;
|
||||||
samples: Point3DRecord[];
|
samples: Point3DRecord[];
|
||||||
|
sampleSlices: number[];
|
||||||
basePose: ModelPoseValue;
|
basePose: ModelPoseValue;
|
||||||
weights: AutoMatchWeights;
|
weights: AutoMatchWeights;
|
||||||
}
|
}
|
||||||
@@ -1117,6 +1118,19 @@ function chooseAutoMatchSampleSlices(input: unknown, depth: number) {
|
|||||||
.sort((a, b) => a - b);
|
.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) {
|
function collectAutoMatchPreviews(project: ProjectRecord) {
|
||||||
return (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => {
|
return (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => {
|
||||||
const filePath = getProjectModelFilePath(project, fileName);
|
const filePath = getProjectModelFilePath(project, fileName);
|
||||||
@@ -1207,14 +1221,18 @@ function evaluateAutoMatchPose(
|
|||||||
context.samples.forEach((sample) => {
|
context.samples.forEach((sample) => {
|
||||||
const transformed = transformPointForExportPose(sample.x, sample.y, sample.z, context.metrics, pose);
|
const transformed = transformPointForExportPose(sample.x, sample.y, sample.z, context.metrics, pose);
|
||||||
const mapped = mapAutoMatchPointToVolume(transformed, context.metrics, context.volume);
|
const mapped = mapAutoMatchPointToVolume(transformed, context.metrics, context.volume);
|
||||||
|
const sampleSlice = nearestAutoMatchSampleSlice(mapped.slice, context.sampleSlices);
|
||||||
|
if (sampleSlice === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
contributors += 1;
|
contributors += 1;
|
||||||
|
|
||||||
if (mapped.slice < 0 || mapped.slice >= context.volume.depth) {
|
if (sampleSlice < 0 || sampleSlice >= context.volume.depth) {
|
||||||
missPenalty += 0.8;
|
missPenalty += 0.8;
|
||||||
return;
|
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) {
|
if (bone.value > 0) {
|
||||||
hitReward += bone.value;
|
hitReward += bone.value;
|
||||||
} else {
|
} else {
|
||||||
@@ -1323,6 +1341,7 @@ function createAutoMatchContext(project: ProjectRecord, body: Record<string, unk
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boneFiles = resolveAutoMatchBoneFiles(project, body.boneFiles);
|
const boneFiles = resolveAutoMatchBoneFiles(project, body.boneFiles);
|
||||||
|
const sampleSlices = chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth);
|
||||||
const samples = collectAutoMatchSamples(project, boneFiles);
|
const samples = collectAutoMatchSamples(project, boneFiles);
|
||||||
if (!samples.length) {
|
if (!samples.length) {
|
||||||
throw new Error('未能从骨骼 STL 中采样到可匹配点');
|
throw new Error('未能从骨骼 STL 中采样到可匹配点');
|
||||||
@@ -1334,11 +1353,12 @@ function createAutoMatchContext(project: ProjectRecord, body: Record<string, unk
|
|||||||
volume,
|
volume,
|
||||||
metrics,
|
metrics,
|
||||||
samples,
|
samples,
|
||||||
|
sampleSlices,
|
||||||
basePose: normalizeModelPoseValue(body.pose as Partial<ModelPoseValue> | undefined),
|
basePose: normalizeModelPoseValue(body.pose as Partial<ModelPoseValue> | undefined),
|
||||||
weights: normalizeAutoMatchWeights(body.weights),
|
weights: normalizeAutoMatchWeights(body.weights),
|
||||||
} satisfies AutoMatchContext,
|
} satisfies AutoMatchContext,
|
||||||
boneFiles,
|
boneFiles,
|
||||||
sampleSlices: chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth),
|
sampleSlices,
|
||||||
adjustable: normalizeAutoMatchAdjustable(body.adjustable),
|
adjustable: normalizeAutoMatchAdjustable(body.adjustable),
|
||||||
iterations: normalizeAutoMatchIterations(body.iterations),
|
iterations: normalizeAutoMatchIterations(body.iterations),
|
||||||
candidatesPerRound: normalizeAutoMatchCandidatesPerRound(body.candidatesPerRound),
|
candidatesPerRound: normalizeAutoMatchCandidatesPerRound(body.candidatesPerRound),
|
||||||
@@ -1375,6 +1395,8 @@ function runProjectAutoMatch(project: ProjectRecord, body: Record<string, unknow
|
|||||||
basePose: context.basePose,
|
basePose: context.basePose,
|
||||||
bestPose: best.pose,
|
bestPose: best.pose,
|
||||||
bestScore: best.score,
|
bestScore: best.score,
|
||||||
|
bestMode: best.mode,
|
||||||
|
bestChanged: best.changed,
|
||||||
iterations,
|
iterations,
|
||||||
evaluated,
|
evaluated,
|
||||||
boneFiles,
|
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 boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
|
||||||
|
const maxManualSampleSlices = 96;
|
||||||
|
|
||||||
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
|
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
|
||||||
if (incomingPose) {
|
if (incomingPose) {
|
||||||
@@ -91,6 +92,64 @@ function poseDelta(base: ModelPose, next: ModelPose, key: AutoMatchParameterKey)
|
|||||||
return next[key] - base[key];
|
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 {
|
interface AutoMatchWorkspaceProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
initialPose?: ModelPose | null;
|
initialPose?: ModelPose | null;
|
||||||
@@ -105,11 +164,14 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
|||||||
const [selectedBoneFiles, setSelectedBoneFiles] = useState<string[]>([]);
|
const [selectedBoneFiles, setSelectedBoneFiles] = useState<string[]>([]);
|
||||||
const [adjustable, setAdjustable] = useState<AutoMatchParameterSelection>(defaultAdjustable);
|
const [adjustable, setAdjustable] = useState<AutoMatchParameterSelection>(defaultAdjustable);
|
||||||
const [weights, setWeights] = useState<AutoMatchWeights>(defaultWeights);
|
const [weights, setWeights] = useState<AutoMatchWeights>(defaultWeights);
|
||||||
|
const [sampleSlicesText, setSampleSlicesText] = useState('');
|
||||||
const [iterations, setIterations] = useState(6);
|
const [iterations, setIterations] = useState(6);
|
||||||
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
|
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
|
||||||
const [result, setResult] = useState<{
|
const [result, setResult] = useState<{
|
||||||
bestPose: ModelPose;
|
bestPose: ModelPose;
|
||||||
bestScore: number;
|
bestScore: number;
|
||||||
|
bestMode: string;
|
||||||
|
bestChanged: AutoMatchParameterKey[];
|
||||||
evaluated: number;
|
evaluated: number;
|
||||||
trace: AutoMatchCandidate[];
|
trace: AutoMatchCandidate[];
|
||||||
sampleSlices: number[];
|
sampleSlices: number[];
|
||||||
@@ -151,6 +213,7 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
|||||||
setProject(nextProject);
|
setProject(nextProject);
|
||||||
setBasePose(latestPoseFromProject(nextProject, incomingPose));
|
setBasePose(latestPoseFromProject(nextProject, incomingPose));
|
||||||
setSelectedBoneFiles(defaultBoneFiles(nextProject));
|
setSelectedBoneFiles(defaultBoneFiles(nextProject));
|
||||||
|
setSampleSlicesText(formatSampleSliceText(nextProject.dicomCount));
|
||||||
setResult(null);
|
setResult(null);
|
||||||
})
|
})
|
||||||
.catch((loadError) => {
|
.catch((loadError) => {
|
||||||
@@ -183,6 +246,11 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
|||||||
() => parameterOptions.filter((option) => adjustable[option.key]).length,
|
() => parameterOptions.filter((option) => adjustable[option.key]).length,
|
||||||
[adjustable],
|
[adjustable],
|
||||||
);
|
);
|
||||||
|
const parsedSampleSlices = useMemo(
|
||||||
|
() => parseSampleSliceText(sampleSlicesText, project?.dicomCount ?? 1),
|
||||||
|
[project?.dicomCount, sampleSlicesText],
|
||||||
|
);
|
||||||
|
const resultHasPoseChange = result ? poseHasVisibleChange(basePose, result.bestPose) : false;
|
||||||
|
|
||||||
const toggleBoneFile = (fileName: string) => {
|
const toggleBoneFile = (fileName: string) => {
|
||||||
setSelectedBoneFiles((current) => (
|
setSelectedBoneFiles((current) => (
|
||||||
@@ -214,6 +282,7 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
|||||||
pose: basePose,
|
pose: basePose,
|
||||||
adjustable,
|
adjustable,
|
||||||
boneFiles: selectedBoneFiles,
|
boneFiles: selectedBoneFiles,
|
||||||
|
sampleSlices: parsedSampleSlices.map((slice) => slice - 1),
|
||||||
iterations,
|
iterations,
|
||||||
candidatesPerRound,
|
candidatesPerRound,
|
||||||
weights,
|
weights,
|
||||||
@@ -221,6 +290,8 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
|||||||
setResult({
|
setResult({
|
||||||
bestPose: data.bestPose,
|
bestPose: data.bestPose,
|
||||||
bestScore: data.bestScore,
|
bestScore: data.bestScore,
|
||||||
|
bestMode: data.bestMode,
|
||||||
|
bestChanged: data.bestChanged,
|
||||||
evaluated: data.evaluated,
|
evaluated: data.evaluated,
|
||||||
trace: data.trace,
|
trace: data.trace,
|
||||||
sampleSlices: data.sampleSlices,
|
sampleSlices: data.sampleSlices,
|
||||||
@@ -380,6 +451,28 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div className="mb-3 text-xs font-black uppercase text-slate-400">评分权重</div>
|
<div className="mb-3 text-xs font-black uppercase text-slate-400">评分权重</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -478,7 +571,13 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||||
<div className="text-xs font-black text-slate-400">候选评估</div>
|
<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 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="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 className="mt-2 font-mono text-xl font-black text-slate-800">{selectedBoneFiles.length}</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ export interface AutoMatchResult {
|
|||||||
basePose: ModelPose;
|
basePose: ModelPose;
|
||||||
bestPose: ModelPose;
|
bestPose: ModelPose;
|
||||||
bestScore: number;
|
bestScore: number;
|
||||||
|
bestMode: string;
|
||||||
|
bestChanged: AutoMatchParameterKey[];
|
||||||
iterations: number;
|
iterations: number;
|
||||||
evaluated: number;
|
evaluated: number;
|
||||||
boneFiles: string[];
|
boneFiles: string[];
|
||||||
|
|||||||
52
工程分析/实现方案-2026-05-24-23-24-34.md
Normal file
52
工程分析/实现方案-2026-05-24-23-24-34.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 实现方案-2026-05-24-23-24-34
|
||||||
|
|
||||||
|
## 实现方案文档路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-24-23-24-34.md`
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
- 在自动微调匹配工作区增加可编辑的采样切片输入。
|
||||||
|
- 自动生成默认切片文本,允许一键恢复默认。
|
||||||
|
- 运行自动匹配时将人工采样切片传入后端。
|
||||||
|
- 在结果面板中区分“保持当前位姿最佳”和“位姿已改变”。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
- 前端新增 `sampleSlicesText` 状态,按项目 DICOM 数量生成默认 1-based 采样切片。
|
||||||
|
- 新增解析函数:支持逗号、空格、中文顿号和范围表达;范围支持步长 `起-止:步长`。
|
||||||
|
- 运行前解析为 0-based 切片数组,通过现有 `sampleSlices` 字段传给 `api.runAutoMatch`。
|
||||||
|
- 结果面板中把 delta 全为 0 的情况显示为“当前位姿已是本轮最高分”,并显示最佳候选模式。
|
||||||
|
- 更新经验记录,说明 `+0.000` 的真实含义。
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 阅读 `AutoMatchWorkspace.tsx` 当前状态和运行逻辑。
|
||||||
|
2. 添加采样切片生成、解析、校验与 UI 控件。
|
||||||
|
3. 调整运行接口入参和结果状态字段。
|
||||||
|
4. 执行 `npm run lint` 与 `npm run build`。
|
||||||
|
5. 重启 tmux 服务并验证本机/公网。
|
||||||
|
6. 提交并推送到 Gitea。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 后端接口已经支持 `sampleSlices`,本次主要暴露前端入口。
|
||||||
|
- 为空时仍使用系统默认采样,不破坏旧行为。
|
||||||
|
- 如输入解析有问题,可回滚本次提交或临时恢复默认采样输入隐藏。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- 1 个前端组件文件。
|
||||||
|
- 3 个当次工程分析文档。
|
||||||
|
- 1 个经验记录追加。
|
||||||
|
|
||||||
|
## 提交与部署策略
|
||||||
|
|
||||||
|
- Commit message 使用 `2026-05-24-23-24-34 支持自动匹配采样切片调节`。
|
||||||
|
- 构建通过后重启 `revoxelseg-dicom` tmux 会话。
|
||||||
|
- 验证公网 `https://revoxel.huijutec.cn/`。
|
||||||
47
工程分析/测试方案-2026-05-24-23-24-34.md
Normal file
47
工程分析/测试方案-2026-05-24-23-24-34.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 测试方案-2026-05-24-23-24-34
|
||||||
|
|
||||||
|
## 测试方案文档路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-24-23-24-34.md`
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 执行 `cd WebSite && npm run lint`。
|
||||||
|
- 确认采样切片解析函数、状态类型和 `AutoMatchRequest.sampleSlices` 类型一致。
|
||||||
|
|
||||||
|
## 构建检查
|
||||||
|
|
||||||
|
- 执行 `cd WebSite && npm run build`。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 自动微调匹配工作区展示采样切片输入框。
|
||||||
|
- 默认切片按 `1-N` 显示。
|
||||||
|
- 输入 `39, 71, 102` 可正常解析。
|
||||||
|
- 输入 `39-120:5` 可正常解析为多切片。
|
||||||
|
- 输入超范围切片会被裁剪到 `1-N`。
|
||||||
|
- 运行结果中若最佳为保持当前,页面明确说明当前位姿未改变。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- 页面使用 1-based 切片编号,接口传入 0-based 切片索引。
|
||||||
|
- 本次不修改 DICOM/STL 原始数据,也不修改导出分割逻辑。
|
||||||
|
|
||||||
|
## 部署验证
|
||||||
|
|
||||||
|
- 重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||||
|
- 验证 `http://127.0.0.1:4000/api/health`。
|
||||||
|
- 验证 `http://127.0.0.1:4000/`。
|
||||||
|
- 验证 `https://revoxel.huijutec.cn/api/health` 与 `https://revoxel.huijutec.cn/`。
|
||||||
|
|
||||||
|
## Git/Gitea 备份验证
|
||||||
|
|
||||||
|
- 暂存本次相关源码与工程分析文档。
|
||||||
|
- 提交 message 包含时间戳。
|
||||||
|
- 推送到 Gitea `main`。
|
||||||
|
|
||||||
|
## 风险与回归关注点
|
||||||
|
|
||||||
|
- 切片数量过多可能让运行时间变长。
|
||||||
|
- 1-based 到 0-based 的转换必须只发生在发请求前。
|
||||||
|
- 不应让采样切片输入影响逆向工作区原有切片范围。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1851,3 +1851,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
后续继续优化自动配准时,要保持“人工旋转锁定、自动只微调平移/缩放”的边界,且必须复用导出/映射同一坐标系。评分函数可以继续替换为 Dice、Chamfer、距离变换或多线程 Worker,但移动惩罚、缩放惩罚、候选上限和锁定项目保护不能省;否则会出现匹配看似得分更高、实际位置漂移或覆盖锁定项目的问题。
|
后续继续优化自动配准时,要保持“人工旋转锁定、自动只微调平移/缩放”的边界,且必须复用导出/映射同一坐标系。评分函数可以继续替换为 Dice、Chamfer、距离变换或多线程 Worker,但移动惩罚、缩放惩罚、候选上限和锁定项目保护不能省;否则会出现匹配看似得分更高、实际位置漂移或覆盖锁定项目的问题。
|
||||||
|
|
||||||
|
## 2026-05-24-23-24-34 自动匹配采样切片要可调且真实参与评分
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户运行自动微调匹配后,结果中的平移和缩放变化都显示 `+0.000`,容易理解为算法没有运行;同时采样切片只能展示,不能人工加密或调整。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
`+0.000` 的直接原因是本轮候选里“保持当前/初始位姿”的得分最高,最佳位姿没有超过当前位姿,所以相对变化为 0。另一个问题是后端虽然返回了 `sampleSlices`,但评分时仍遍历全部 STL 采样点,没有按用户关注的 DICOM 切片过滤,导致采样切片在第一版中更像结果说明而不是优化输入。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
自动微调匹配工作区新增采样切片输入,按界面可见的 `1-N` 切片编号填写,支持离散切片和范围步长写法,运行前转换为接口内部的 `0-(N-1)`。后端 `AutoMatchContext` 增加 `sampleSlices`,评分时只统计落在采样切片附近的 STL 点,并用最近采样切片的 DICOM HU 骨窗值计算命中奖励和惩罚。结果面板新增最佳模式,并在最佳位姿无可见变化时提示“当前位姿得分最高,所以显示 +0.000”。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
凡是页面允许用户配置的优化条件,都要确认该条件确实进入评分函数,而不是只进入展示层。医学切片编号在 UI 中使用 `1-N`,服务端计算用 `0-(N-1)`,转换必须集中在请求边界。自动优化结果如果没有改变位姿,应明确解释“当前就是本轮最高分”,避免用户误以为按钮或算法没执行。
|
||||||
|
|||||||
45
工程分析/需求分析-2026-05-24-23-24-34.md
Normal file
45
工程分析/需求分析-2026-05-24-23-24-34.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 需求分析-2026-05-24-23-24-34
|
||||||
|
|
||||||
|
## 开始时间
|
||||||
|
|
||||||
|
2026-05-24-23-24-34
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户反馈自动微调匹配运行后结果面板中的位姿变化全部显示 `+0.000`,希望解释原因;同时希望“采样切片”可以人工调节,后续可增加更多采样切片参与优化。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 让自动匹配结果的 `+0.000` 含义更清楚,避免误以为算法没有运行。
|
||||||
|
- 支持用户人工配置采样切片,包含更多切片参与评分。
|
||||||
|
- 保持现有自动匹配默认行为可用,并允许一键恢复默认采样。
|
||||||
|
- 尽量不改变当前自动优化后端结构,只把已支持的 `sampleSlices` 参数暴露到 UI。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:用户填写的采样切片文本,支持逗号、空格和范围表达。
|
||||||
|
- 输出:传入自动匹配接口的 0-based `sampleSlices` 数组,以及结果面板中可读的 1-based 采样切片显示。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
- 当次工程分析三件套
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- 页面显示和用户输入均按医学切片编号 `1-N`,接口内部继续使用 `0-(N-1)`。
|
||||||
|
- 采样切片数量可以增多,但需要做上限,避免一次请求过重。
|
||||||
|
- 结果变化为 `+0.000` 时应明确表示“最佳候选未优于当前位姿”,不是运行失败。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 用户输入范围过大可能导致运行时间显著增长。
|
||||||
|
- 如果 1-based/0-based 转换不清楚,会造成采样切片偏一层。
|
||||||
|
- 若只显示最终结果,不显示最佳候选是否为“保持当前”,用户仍难判断优化有没有变化。
|
||||||
|
|
||||||
|
## 默认假设
|
||||||
|
|
||||||
|
- 采样切片输入支持 `39, 71, 102`、`39-120:5`、`39-120` 等格式。
|
||||||
|
- 默认采样切片继续由系统按全序列生成。
|
||||||
|
- 单次最多保留 96 个采样切片,超出后截断并排序去重。
|
||||||
Reference in New Issue
Block a user