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

@@ -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 骨窗进行候选迭代评分,采样切片可人工填写并真实参与评分,可将最佳位姿写回项目库。
## 一、本机部署 ## 一、本机部署

View File

@@ -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,

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 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">

View File

@@ -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[];

View 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/`

View 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 的转换必须只发生在发请求前。
- 不应让采样切片输入影响逆向工作区原有切片范围。

View File

@@ -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)`,转换必须集中在请求边界。自动优化结果如果没有改变位姿,应明确解释“当前就是本轮最高分”,避免用户误以为按钮或算法没执行。

View 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 个采样切片,超出后截断并排序去重。