2026-05-25-00-07-30 优化自动匹配对比视图与采样设置

This commit is contained in:
2026-05-25 00:28:52 +08:00
parent 2fac0200fc
commit d1fa79aef9
8 changed files with 426 additions and 179 deletions

View File

@@ -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 代理下高精度预览的传输失败概率。
## 一、本机部署

View File

@@ -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 预览失败' });
}

View File

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

View File

@@ -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>
);

View 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`
- 验证本机和公网入口。

View 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 仍可能消耗内存,后续可进一步做二进制/流式格式。

View File

@@ -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 发送 helperSTL preview 大响应在浏览器支持 gzip 时压缩返回,缓解公网 HTTP/2 大响应失败。
D. 后续如何避免问题
自动优化类页面应优先提供可视化验证,而不是只展示候选表格。高精度 STL preview 继续使用 JSON 时,必须关注响应大小、压缩和代理限制;若后续仍遇到大模型传输问题,应考虑二进制 preview、分块流式加载或按切片局部计算而不是继续扩大单次 JSON。

View 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 张。