2026-05-25-00-07-30 优化自动匹配对比视图与采样设置
This commit is contained in:
@@ -20,7 +20,8 @@
|
||||
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。
|
||||
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
|
||||
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
|
||||
- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,采样切片可人工填写并真实参与评分,可将最佳位姿写回项目库。
|
||||
- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,采样切片按数量均匀筛选并真实参与评分,可将最佳位姿写回项目库;结果区支持当前位姿/自动匹配结果双视图对比。
|
||||
- 大型 STL preview JSON 响应会按浏览器能力 gzip 返回,降低公网 HTTP/2 代理下高精度预览的传输失败概率。
|
||||
|
||||
## 一、本机部署
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import express from 'express';
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import AdmZip from 'adm-zip';
|
||||
import multer from 'multer';
|
||||
import { createServer as createViteServer } from 'vite';
|
||||
@@ -747,6 +747,23 @@ function writeState(state: AppState) {
|
||||
fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2));
|
||||
}
|
||||
|
||||
function sendJsonPayload(req: Request, res: Response, payload: unknown) {
|
||||
const json = JSON.stringify(payload);
|
||||
const acceptsGzip = String(req.headers['accept-encoding'] ?? '').includes('gzip');
|
||||
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Vary', 'Accept-Encoding');
|
||||
if (acceptsGzip && Buffer.byteLength(json) > 64 * 1024) {
|
||||
const compressed = zlib.gzipSync(Buffer.from(json));
|
||||
res.setHeader('Content-Encoding', 'gzip');
|
||||
res.setHeader('Content-Length', String(compressed.length));
|
||||
res.send(compressed);
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(json);
|
||||
}
|
||||
|
||||
interface DicomHuVolume {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -1026,9 +1043,9 @@ function transformPointForExportPose(x: number, y: number, z: number, metrics: E
|
||||
|
||||
const defaultAutoMatchWeights: AutoMatchWeights = {
|
||||
boneReward: 1,
|
||||
missPenalty: 0.45,
|
||||
movementPenalty: 0.08,
|
||||
scalePenalty: 0.12,
|
||||
missPenalty: 0.1,
|
||||
movementPenalty: 0,
|
||||
scalePenalty: 0,
|
||||
};
|
||||
const defaultAutoMatchAdjustable: AutoMatchParameterSelection = {
|
||||
translateX: true,
|
||||
@@ -3753,7 +3770,7 @@ async function startServer() {
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
|
||||
sendJsonPayload(req, res, createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
|
||||
} catch (error) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' });
|
||||
}
|
||||
|
||||
@@ -12,15 +12,16 @@ import {
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AutoMatchCandidate,
|
||||
AutoMatchParameterKey,
|
||||
AutoMatchParameterSelection,
|
||||
AutoMatchWeights,
|
||||
ModuleStyle,
|
||||
ModelPose,
|
||||
Project,
|
||||
} from '../types';
|
||||
import { api } from '../lib/api';
|
||||
import { cn } from '../lib/utils';
|
||||
import { VoxelizationMappingView, type MappingDisplayMode } from './ReverseWorkspace';
|
||||
|
||||
const defaultModelPose: ModelPose = {
|
||||
rotateX: 0,
|
||||
@@ -44,9 +45,9 @@ const defaultAdjustable: AutoMatchParameterSelection = {
|
||||
|
||||
const defaultWeights: AutoMatchWeights = {
|
||||
boneReward: 1,
|
||||
missPenalty: 0.45,
|
||||
movementPenalty: 0.08,
|
||||
scalePenalty: 0.12,
|
||||
missPenalty: 0.1,
|
||||
movementPenalty: 0,
|
||||
scalePenalty: 0,
|
||||
};
|
||||
|
||||
const parameterOptions: Array<{ key: AutoMatchParameterKey; label: string }> = [
|
||||
@@ -64,7 +65,13 @@ 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;
|
||||
const maxSampleSliceCount = 96;
|
||||
const defaultSampleSliceCount = 9;
|
||||
type AutoMatchMappingMode = 'selected' | 'score';
|
||||
const mappingModeOptions: Array<{ id: AutoMatchMappingMode; label: string }> = [
|
||||
{ id: 'selected', label: '所选骨骼区域' },
|
||||
{ id: 'score', label: '得分可视化' },
|
||||
];
|
||||
|
||||
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
|
||||
if (incomingPose) {
|
||||
@@ -78,7 +85,7 @@ function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose
|
||||
?? defaultModelPose;
|
||||
}
|
||||
|
||||
function defaultBoneFiles(project: Project | null) {
|
||||
function suggestedBoneFiles(project: Project | null) {
|
||||
const files = project?.stlFiles ?? [];
|
||||
const matched = files.filter((fileName) => boneNamePattern.test(fileName));
|
||||
return matched.length ? matched : files;
|
||||
@@ -99,57 +106,41 @@ function defaultSampleSliceNumbers(total: number) {
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function formatSampleSliceText(total: number) {
|
||||
return defaultSampleSliceNumbers(total).join(', ');
|
||||
}
|
||||
|
||||
function parseSampleSliceText(input: string, total: number) {
|
||||
function uniformSampleSliceNumbers(total: number, count: 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);
|
||||
const safeCount = Math.max(1, Math.min(maxSampleSliceCount, Math.round(count)));
|
||||
if (safeCount <= 1) {
|
||||
return [Math.max(1, Math.round(maxSlice / 2))];
|
||||
}
|
||||
return [...new Set(Array.from({ length: safeCount }, (_, index) => (
|
||||
Math.max(1, Math.min(maxSlice, Math.round(1 + ((maxSlice - 1) * index) / (safeCount - 1))))
|
||||
)))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function poseHasVisibleChange(base: ModelPose, next: ModelPose) {
|
||||
return parameterOptions.some((option) => Math.abs(poseDelta(base, next, option.key)) >= 0.0005);
|
||||
}
|
||||
|
||||
function buildSelectedModuleStyles(
|
||||
project: Project | null,
|
||||
selectedFiles: string[],
|
||||
mode: AutoMatchMappingMode,
|
||||
accentColor: string,
|
||||
) {
|
||||
const selected = new Set(selectedFiles);
|
||||
return (project?.stlFiles ?? []).reduce<Record<string, ModuleStyle>>((accumulator, fileName, index) => {
|
||||
const source = project?.moduleStyles?.[fileName];
|
||||
const visible = selected.has(fileName);
|
||||
accumulator[fileName] = {
|
||||
visible,
|
||||
color: visible && mode === 'score' ? accentColor : source?.color ?? ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6'][index % 5],
|
||||
opacity: visible ? (mode === 'score' ? 0.9 : source?.opacity ?? 0.72) : source?.opacity ?? 0.72,
|
||||
partId: source?.partId ?? index + 1,
|
||||
};
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
interface AutoMatchWorkspaceProps {
|
||||
projectId: string;
|
||||
initialPose?: ModelPose | null;
|
||||
@@ -164,7 +155,9 @@ 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 [sampleSliceCount, setSampleSliceCount] = useState(defaultSampleSliceCount);
|
||||
const [previewSlice, setPreviewSlice] = useState(0);
|
||||
const [mappingMode, setMappingMode] = useState<AutoMatchMappingMode>('selected');
|
||||
const [iterations, setIterations] = useState(6);
|
||||
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
|
||||
const [result, setResult] = useState<{
|
||||
@@ -173,7 +166,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
bestMode: string;
|
||||
bestChanged: AutoMatchParameterKey[];
|
||||
evaluated: number;
|
||||
trace: AutoMatchCandidate[];
|
||||
sampleSlices: number[];
|
||||
} | null>(null);
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
@@ -212,8 +204,9 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
const incomingPose = selectedProjectId === projectId ? initialPose : null;
|
||||
setProject(nextProject);
|
||||
setBasePose(latestPoseFromProject(nextProject, incomingPose));
|
||||
setSelectedBoneFiles(defaultBoneFiles(nextProject));
|
||||
setSampleSlicesText(formatSampleSliceText(nextProject.dicomCount));
|
||||
setSelectedBoneFiles([]);
|
||||
setSampleSliceCount(defaultSampleSliceCount);
|
||||
setPreviewSlice(Math.max(0, Math.floor((nextProject.dicomCount - 1) / 2)));
|
||||
setResult(null);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
@@ -247,10 +240,21 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
[adjustable],
|
||||
);
|
||||
const parsedSampleSlices = useMemo(
|
||||
() => parseSampleSliceText(sampleSlicesText, project?.dicomCount ?? 1),
|
||||
[project?.dicomCount, sampleSlicesText],
|
||||
() => uniformSampleSliceNumbers(project?.dicomCount ?? 1, sampleSliceCount),
|
||||
[project?.dicomCount, sampleSliceCount],
|
||||
);
|
||||
const resultHasPoseChange = result ? poseHasVisibleChange(basePose, result.bestPose) : false;
|
||||
const maxPreviewSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||
const comparePose = result?.bestPose ?? basePose;
|
||||
const mappingDisplayMode: MappingDisplayMode = mappingMode === 'score' ? 'bone' : 'soft';
|
||||
const currentModuleStyles = useMemo(
|
||||
() => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#f59e0b'),
|
||||
[project, selectedBoneFiles, mappingMode],
|
||||
);
|
||||
const bestModuleStyles = useMemo(
|
||||
() => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#22c55e'),
|
||||
[project, selectedBoneFiles, mappingMode],
|
||||
);
|
||||
|
||||
const toggleBoneFile = (fileName: string) => {
|
||||
setSelectedBoneFiles((current) => (
|
||||
@@ -293,7 +297,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
bestMode: data.bestMode,
|
||||
bestChanged: data.bestChanged,
|
||||
evaluated: data.evaluated,
|
||||
trace: data.trace,
|
||||
sampleSlices: data.sampleSlices,
|
||||
});
|
||||
setLoadingPercent(100);
|
||||
@@ -324,8 +327,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -423,10 +424,10 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
<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))}
|
||||
onClick={() => setSelectedBoneFiles(suggestedBoneFiles(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">
|
||||
@@ -455,21 +456,33 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
<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))}
|
||||
onClick={() => setSampleSliceCount(defaultSampleSliceCount)}
|
||||
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="grid grid-cols-[1fr_72px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={maxSampleSliceCount}
|
||||
value={sampleSliceCount}
|
||||
onChange={(event) => setSampleSliceCount(Number(event.target.value))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxSampleSliceCount}
|
||||
value={sampleSliceCount}
|
||||
onChange={(event) => setSampleSliceCount(Math.max(1, Math.min(maxSampleSliceCount, Number(event.target.value) || 1)))}
|
||||
className="h-9 rounded-md border border-slate-200 px-2 text-right font-mono text-sm font-black text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<span>均匀筛选 1-{Math.max(project?.dicomCount ?? 1, 1)}</span>
|
||||
<span>{parsedSampleSlices.length} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -577,27 +590,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
</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>
|
||||
</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 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">
|
||||
<button
|
||||
onClick={() => void applyBestPose(false)}
|
||||
@@ -627,32 +619,77 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
</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 className="mt-6 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{mappingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMappingMode(option.id)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 text-xs font-black transition',
|
||||
mappingMode === option.id ? 'bg-slate-900 text-white' : 'bg-white text-slate-500 hover:text-blue-700',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="font-mono text-xs font-black text-slate-500">
|
||||
切片 {previewSlice + 1} / {Math.max(project?.dicomCount ?? 1, 1)}
|
||||
</div>
|
||||
</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>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={maxPreviewSlice}
|
||||
value={Math.min(previewSlice, maxPreviewSlice)}
|
||||
onChange={(event) => setPreviewSlice(Number(event.target.value))}
|
||||
className="mb-4 w-full accent-blue-600"
|
||||
/>
|
||||
|
||||
{selectedBoneFiles.length ? (
|
||||
<div className="grid grid-cols-2 gap-4 max-2xl:grid-cols-1">
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-black text-slate-500">当前位姿</div>
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
moduleStyles={currentModuleStyles}
|
||||
modelPose={basePose}
|
||||
detailLimit={360000}
|
||||
slice={Math.min(previewSlice, maxPreviewSlice)}
|
||||
totalSlices={Math.max(project?.dicomCount ?? 1, 1)}
|
||||
onSliceChange={setPreviewSlice}
|
||||
displayMode={mappingDisplayMode}
|
||||
rotation={0}
|
||||
variant="workspace"
|
||||
overlayPlacement="bottom"
|
||||
showSliceControl={false}
|
||||
/>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="px-3 py-10 text-center text-sm font-bold text-slate-400">
|
||||
尚未运行自动微调匹配
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-black text-slate-500">自动匹配结果</div>
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
moduleStyles={bestModuleStyles}
|
||||
modelPose={comparePose}
|
||||
detailLimit={360000}
|
||||
slice={Math.min(previewSlice, maxPreviewSlice)}
|
||||
totalSlices={Math.max(project?.dicomCount ?? 1, 1)}
|
||||
onSliceChange={setPreviewSlice}
|
||||
displayMode={mappingDisplayMode}
|
||||
rotation={0}
|
||||
variant="workspace"
|
||||
overlayPlacement="bottom"
|
||||
showSliceControl={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-12 text-center text-sm font-bold text-slate-400">
|
||||
请选择骨骼区域后查看映射对比
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2227,6 +2227,7 @@ export function VoxelizationMappingView({
|
||||
toolbar,
|
||||
overlayPlacement,
|
||||
onOverlayStatsChange,
|
||||
showSliceControl = true,
|
||||
}: {
|
||||
project: Project | null;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
@@ -2241,6 +2242,7 @@ export function VoxelizationMappingView({
|
||||
toolbar?: React.ReactNode;
|
||||
overlayPlacement?: 'bottom' | 'side' | 'none';
|
||||
onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void;
|
||||
showSliceControl?: boolean;
|
||||
}) {
|
||||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -2593,7 +2595,7 @@ export function VoxelizationMappingView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid min-h-0 flex-1 ${activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]'} bg-black`}>
|
||||
<div className={`grid min-h-0 flex-1 ${showSliceControl ? (activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]') : 'grid-cols-1'} bg-black`}>
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
>
|
||||
@@ -2632,21 +2634,23 @@ export function VoxelizationMappingView({
|
||||
{activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')}
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
|
||||
<div className="relative min-h-[220px] w-8 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
{activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
|
||||
</aside>
|
||||
{showSliceControl && (
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
|
||||
<div className="relative min-h-[220px] w-8 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
{activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2676,7 +2680,7 @@ export function VoxelizationMappingView({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
|
||||
<div className={`grid min-h-0 flex-1 ${showSliceControl ? 'grid-cols-[minmax(0,1fr)_110px]' : 'grid-cols-1'}`}>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div
|
||||
ref={mappingViewportRef}
|
||||
@@ -2734,47 +2738,49 @@ export function VoxelizationMappingView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-slate-100 bg-white px-3 py-4">
|
||||
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
|
||||
<p className="text-[10px] font-bold text-slate-700">DICOM 切片位置</p>
|
||||
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
|
||||
{displaySliceNumber} / {Math.max(totalSlices, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="上一层"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div className="relative min-h-[240px] w-10 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-vertical-input"
|
||||
aria-label="逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="下一层"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
|
||||
<span>顶层 1</span>
|
||||
<span className="text-blue-600">当前 {displaySliceNumber}</span>
|
||||
<span>底层 {Math.max(totalSlices, 1)}</span>
|
||||
</div>
|
||||
</aside>
|
||||
{showSliceControl && (
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-slate-100 bg-white px-3 py-4">
|
||||
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
|
||||
<p className="text-[10px] font-bold text-slate-700">DICOM 切片位置</p>
|
||||
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
|
||||
{displaySliceNumber} / {Math.max(totalSlices, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="上一层"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div className="relative min-h-[240px] w-10 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-vertical-input"
|
||||
aria-label="逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="下一层"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
|
||||
<span>顶层 1</span>
|
||||
<span className="text-blue-600">当前 {displaySliceNumber}</span>
|
||||
<span>底层 {Math.max(totalSlices, 1)}</span>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
64
工程分析/实现方案-2026-05-25-00-07-30.md
Normal file
64
工程分析/实现方案-2026-05-25-00-07-30.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 实现方案-2026-05-25-00-07-30
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-00-07-30.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 移除自动匹配页候选轨迹表格和低价值统计卡片。
|
||||
- 在“匹配结果”下方新增双栏 DICOM/STL 映射对比视图。
|
||||
- 同一个切片滑条同时控制两个视图。
|
||||
- 显示模式支持“所选骨骼区域”和“得分可视化”。
|
||||
- 骨骼区域默认空选。
|
||||
- 采样切片改为数量输入,按数量均匀筛选。
|
||||
- 调整默认权重:非骨区域惩罚 0.1、移动惩罚 0、缩放惩罚 0。
|
||||
- 为大 STL preview JSON 响应增加 gzip 发送,缓解 HTTP/2 大响应错误。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 从 `ReverseWorkspace.tsx` 导出 `VoxelizationMappingView`,在自动匹配页复用。
|
||||
- 自动匹配页维护 `previewSlice` 和 `mappingMode` 状态,双视图传入同一个切片。
|
||||
- 为对比视图构造仅包含选中骨骼构件的 `moduleStyles`。
|
||||
- 采样切片数量通过 `buildUniformSampleSlices(total, count)` 生成 0-based 数组。
|
||||
- 自动匹配页不再显示候选轨迹表,保留关键位姿差值和操作按钮。
|
||||
- 后端新增 JSON gzip helper,`/models/:fileName/preview` 使用该 helper 返回大 preview。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 查看 `VoxelizationMappingView` 当前导出和 props。
|
||||
2. 修改自动匹配页面布局、状态和默认值。
|
||||
3. 修改采样切片生成逻辑与接口入参。
|
||||
4. 修改 STL preview 路由的大 JSON 发送方式。
|
||||
5. 执行 `npm run lint`、`npm run build`。
|
||||
6. 重启服务并验证健康检查与 preview 请求。
|
||||
7. 提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 自动匹配接口保留原 `sampleSlices` 字段,前端只是从数量生成。
|
||||
- 预览 gzip helper 只在客户端声明支持 gzip 时启用,不支持时仍走普通 JSON。
|
||||
- 若双视图性能不理想,可降低显示级别或只在有选中骨骼时加载。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 2 个前端组件。
|
||||
- 1 个后端服务文件。
|
||||
- 1 个类型文件。
|
||||
- 1 个 Docker 说明。
|
||||
- 工程分析三件套和经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-25-00-07-30 优化自动匹配对比视图与采样设置`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证本机和公网入口。
|
||||
54
工程分析/测试方案-2026-05-25-00-07-30.md
Normal file
54
工程分析/测试方案-2026-05-25-00-07-30.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 测试方案-2026-05-25-00-07-30
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-25-00-07-30.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `cd WebSite && npm run lint`。
|
||||
- 确认 `VoxelizationMappingView` 导出后类型不破坏逆向工作区。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 执行 `cd WebSite && npm run build`。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 自动微调匹配页骨骼区域默认无选中。
|
||||
- 未选择骨骼区域时运行会提示选择。
|
||||
- 采样切片数量默认均匀生成,可调整数量。
|
||||
- “匹配结果”下方显示两个映射视图,滑条同时控制两个视图切片。
|
||||
- 两种显示模式可切换。
|
||||
- 候选评估、采样切片、骨骼构件、最佳模式卡片和候选表格不再显示。
|
||||
- 运行后右侧视图使用最佳位姿,左侧视图保留基准位姿。
|
||||
|
||||
## HTTP/2 preview 验证
|
||||
|
||||
- 请求 `/api/projects/:projectId/models/sternum.stl/preview?limit=800000`。
|
||||
- 确认本机返回 200。
|
||||
- 确认响应支持 gzip 压缩。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 不修改原始 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 备份验证
|
||||
|
||||
- 暂存本次相关源码、Docker 说明和工程分析文档。
|
||||
- 提交 message 包含时间戳。
|
||||
- 推送到 Gitea `main`。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 双视图不应导致未选中构件参与显示。
|
||||
- gzip 发送 JSON 后浏览器仍应能正常 `fetch().json()`。
|
||||
- 大 STL preview 仍可能消耗内存,后续可进一步做二进制/流式格式。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1869,3 +1869,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
凡是页面允许用户配置的优化条件,都要确认该条件确实进入评分函数,而不是只进入展示层。医学切片编号在 UI 中使用 `1-N`,服务端计算用 `0-(N-1)`,转换必须集中在请求边界。自动优化结果如果没有改变位姿,应明确解释“当前就是本轮最高分”,避免用户误以为按钮或算法没执行。
|
||||
|
||||
## 2026-05-25-00-07-30 自动匹配页应以映射对比为主,高精度预览要压缩传输
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户认为自动匹配页下方候选表格和若干统计卡片价值不大,希望直接看到逆向分割映射视图,并能用同一个切片进度条对比当前位姿和自动匹配结果。同时用户反馈公网请求 `sternum.stl/preview?limit=800000` 会报 `ERR_HTTP2_PROTOCOL_ERROR`。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
自动匹配第一版偏调参和数值轨迹,缺少与逆向工作区一致的可视化闭环。高精度 STL preview 返回的是很大的 JSON,未压缩时在 HTTP/2 反向代理链路中更容易触发传输错误或代理缓冲限制。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
自动匹配页移除候选轨迹表和低价值统计卡片,复用 `VoxelizationMappingView` 放置当前位姿/自动匹配结果两个对比画面,并新增共享横向切片条;视图只显示用户选中的骨骼构件,且骨骼区域默认空选。采样切片改为按数量均匀生成,默认 9 张。默认评分权重调整为非骨区域惩罚 `0.1`、移动惩罚 `0`、缩放惩罚 `0`。后端新增 JSON gzip 发送 helper,STL preview 大响应在浏览器支持 gzip 时压缩返回,缓解公网 HTTP/2 大响应失败。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
自动优化类页面应优先提供可视化验证,而不是只展示候选表格。高精度 STL preview 继续使用 JSON 时,必须关注响应大小、压缩和代理限制;若后续仍遇到大模型传输问题,应考虑二进制 preview、分块流式加载或按切片局部计算,而不是继续扩大单次 JSON。
|
||||
|
||||
50
工程分析/需求分析-2026-05-25-00-07-30.md
Normal file
50
工程分析/需求分析-2026-05-25-00-07-30.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 需求分析-2026-05-25-00-07-30
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-25-00-07-30
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求自动微调匹配工作区去掉下方候选表格和若干统计卡片,把逆向分割映射视图放到“匹配结果”正下方,提供两个视图进行对比,并通过同一个进度条调节切片;只显示选中的骨骼区域,并支持“所选骨骼区域”和“得分可视化”两种模式。骨骼区域默认不选,需要用户选择;采样切片改为可选择数量,默认均匀筛选;移动惩罚和缩放惩罚默认 0,非骨区域惩罚默认 0.1。另有 STL preview 请求 `/models/sternum.stl/preview?limit=800000` 报 `ERR_HTTP2_PROTOCOL_ERROR` 的问题需要修复。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 自动匹配页面更像实际配准工作台,而不是调参表格。
|
||||
- 支持运行前/运行后在同一切片位置直观看骨骼区域对比。
|
||||
- 采样切片按数量自动均匀选取,减少手动输入负担。
|
||||
- 自动匹配初始权重更宽松,避免默认惩罚过强导致难以移动。
|
||||
- 修复高精度 STL preview 大 JSON 在公网 HTTP/2 下传输失败的问题。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:项目 ID、当前位姿、选中骨骼 STL、采样切片数量、评分权重、切片预览位置、显示模式。
|
||||
- 输出:自动匹配最佳位姿、两个映射预览画布、同一切片控制、可写回项目的最佳位姿。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 自动匹配仍不能调整旋转。
|
||||
- 只显示用户选中的骨骼构件;默认不能预选。
|
||||
- UI 中切片编号使用 `1-N`,接口内部使用 `0-(N-1)`。
|
||||
- 高精度 STL preview 不能再因为响应过大导致公网请求失败。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 复用逆向分割映射视图需要避免引入工作区保存/导出逻辑。
|
||||
- 双视图加载 DICOM 与 STL preview 可能增加网络请求,要限制只加载选中骨骼区域。
|
||||
- 对大 STL preview 做 gzip 传输要保留普通 JSON 响应兼容。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- “两个对比视图”分别显示基准位姿和最佳位姿。
|
||||
- “得分可视化”第一版使用当前骨骼区域在 DICOM 骨窗下的命中/偏离叠加,而不是完整热力图算法。
|
||||
- 采样切片数量默认 9 张,可调到 1-96 张。
|
||||
Reference in New Issue
Block a user